mirror of
https://github.com/d0zingcat/gocryptotrader.git
synced 2026-05-14 07:26:47 +00:00
bithumb: Add websocket support (#745)
* bithumb: Add websocket support (WIP) * bithumb: cont impl. * bithumb: finish, issues with orderbook needs review * linter issues * bithumb: move to separate file * bithumb: change to pointer for book * bithumb: Add tests * bithumb: Address nits * websocket: Export subscription error and wrap returns * bithumb: cleanup/ fix tests * gctrpc/bithumb: fix misspelling * bithumb: gofmt * readme: update support table, regen doc * tradesReadme: update * readme: update template
This commit is contained in:
@@ -51,6 +51,7 @@ const (
|
||||
// Bithumb is the overarching type across the Bithumb package
|
||||
type Bithumb struct {
|
||||
exchange.Base
|
||||
obm orderbookManager
|
||||
}
|
||||
|
||||
// GetTradablePairs returns a list of tradable currencies
|
||||
@@ -114,18 +115,18 @@ func (b *Bithumb) GetAllTickers() (map[string]Ticker, error) {
|
||||
// GetOrderBook returns current orderbook
|
||||
//
|
||||
// symbol e.g. "btc"
|
||||
func (b *Bithumb) GetOrderBook(symbol string) (Orderbook, error) {
|
||||
func (b *Bithumb) GetOrderBook(symbol string) (*Orderbook, error) {
|
||||
response := Orderbook{}
|
||||
err := b.SendHTTPRequest(exchange.RestSpot, publicOrderBook+strings.ToUpper(symbol), &response)
|
||||
if err != nil {
|
||||
return response, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.Status != noError {
|
||||
return response, errors.New(response.Message)
|
||||
return nil, errors.New(response.Message)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// GetTransactionHistory returns recent transactions
|
||||
|
||||
210
exchanges/bithumb/bithumb_websocket.go
Normal file
210
exchanges/bithumb/bithumb_websocket.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package bithumb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/stream"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/ticker"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/trade"
|
||||
)
|
||||
|
||||
const (
|
||||
wsEndpoint = "wss://pubwss.bithumb.com/pub/ws"
|
||||
tickerTimeLayout = "20060102150405"
|
||||
tradeTimeLayout = "2006-01-02 15:04:05.000000"
|
||||
)
|
||||
|
||||
var (
|
||||
wsDefaultTickTypes = []string{"30M"} // alternatives "1H", "12H", "24H", "MID"
|
||||
location *time.Location
|
||||
)
|
||||
|
||||
// WsConnect initiates a websocket connection
|
||||
func (b *Bithumb) WsConnect() error {
|
||||
if !b.Websocket.IsEnabled() || !b.IsEnabled() {
|
||||
return errors.New(stream.WebsocketNotEnabled)
|
||||
}
|
||||
|
||||
var dialer websocket.Dialer
|
||||
dialer.HandshakeTimeout = b.Config.HTTPTimeout
|
||||
dialer.Proxy = http.ProxyFromEnvironment
|
||||
|
||||
err := b.Websocket.Conn.Dial(&dialer, http.Header{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v - Unable to connect to Websocket. Error: %w",
|
||||
b.Name,
|
||||
err)
|
||||
}
|
||||
go b.wsReadData()
|
||||
b.setupOrderbookManager()
|
||||
return nil
|
||||
}
|
||||
|
||||
// wsReadData receives and passes on websocket messages for processing
|
||||
func (b *Bithumb) wsReadData() {
|
||||
b.Websocket.Wg.Add(1)
|
||||
defer b.Websocket.Wg.Done()
|
||||
|
||||
for {
|
||||
resp := b.Websocket.Conn.ReadMessage()
|
||||
if resp.Raw == nil {
|
||||
return
|
||||
}
|
||||
err := b.wsHandleData(resp.Raw)
|
||||
if err != nil {
|
||||
b.Websocket.DataHandler <- err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bithumb) wsHandleData(respRaw []byte) error {
|
||||
var resp WsResponse
|
||||
err := json.Unmarshal(respRaw, &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(resp.Status) > 0 {
|
||||
if resp.Status == "0000" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s: %w",
|
||||
resp.ResponseMessage,
|
||||
stream.ErrSubscriptionFailure)
|
||||
}
|
||||
|
||||
switch resp.Type {
|
||||
case "ticker":
|
||||
var tick WsTicker
|
||||
err = json.Unmarshal(resp.Content, &tick)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var lu time.Time
|
||||
lu, err = time.ParseInLocation(tickerTimeLayout,
|
||||
tick.Date+tick.Time,
|
||||
location)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Websocket.DataHandler <- &ticker.Price{
|
||||
ExchangeName: b.Name,
|
||||
AssetType: asset.Spot,
|
||||
Last: tick.PreviousClosePrice,
|
||||
Pair: tick.Symbol,
|
||||
Open: tick.OpenPrice,
|
||||
Close: tick.ClosePrice,
|
||||
Low: tick.LowPrice,
|
||||
High: tick.HighPrice,
|
||||
QuoteVolume: tick.Value,
|
||||
Volume: tick.Volume,
|
||||
LastUpdated: lu,
|
||||
}
|
||||
case "transaction":
|
||||
if !b.IsSaveTradeDataEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
var trades WsTransactions
|
||||
err = json.Unmarshal(resp.Content, &trades)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toBuffer := make([]trade.Data, len(trades.List))
|
||||
var lu time.Time
|
||||
for x := range trades.List {
|
||||
lu, err = time.ParseInLocation(tradeTimeLayout,
|
||||
trades.List[x].ContractTime,
|
||||
location)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toBuffer[x] = trade.Data{
|
||||
Exchange: b.Name,
|
||||
AssetType: asset.Spot,
|
||||
CurrencyPair: trades.List[x].Symbol,
|
||||
Timestamp: lu,
|
||||
Price: trades.List[x].ContractPrice,
|
||||
Amount: trades.List[x].ContractAmount,
|
||||
}
|
||||
}
|
||||
|
||||
err = b.AddTradesToBuffer(toBuffer...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case "orderbookdepth":
|
||||
var orderbooks WsOrderbooks
|
||||
err = json.Unmarshal(resp.Content, &orderbooks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
init, err := b.UpdateLocalBuffer(&orderbooks)
|
||||
if err != nil && !init {
|
||||
return fmt.Errorf("%v - UpdateLocalCache error: %s", b.Name, err)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unhandled response type %s", resp.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateSubscriptions generates the default subscription set
|
||||
func (b *Bithumb) GenerateSubscriptions() ([]stream.ChannelSubscription, error) {
|
||||
var channels = []string{"ticker", "transaction", "orderbookdepth"}
|
||||
var subscriptions []stream.ChannelSubscription
|
||||
pairs, err := b.GetEnabledPairs(asset.Spot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for x := range pairs {
|
||||
for y := range channels {
|
||||
subscriptions = append(subscriptions, stream.ChannelSubscription{
|
||||
Channel: channels[y],
|
||||
Currency: pairs[x].Format("_", true),
|
||||
Asset: asset.Spot,
|
||||
})
|
||||
}
|
||||
}
|
||||
return subscriptions, nil
|
||||
}
|
||||
|
||||
// Subscribe subscribes to a set of channels
|
||||
func (b *Bithumb) Subscribe(channelsToSubscribe []stream.ChannelSubscription) error {
|
||||
subs := make(map[string]*WsSubscribe)
|
||||
for i := range channelsToSubscribe {
|
||||
s, ok := subs[channelsToSubscribe[i].Channel]
|
||||
if !ok {
|
||||
s = &WsSubscribe{
|
||||
Type: channelsToSubscribe[i].Channel,
|
||||
}
|
||||
subs[channelsToSubscribe[i].Channel] = s
|
||||
}
|
||||
s.Symbols = append(s.Symbols, channelsToSubscribe[i].Currency)
|
||||
}
|
||||
|
||||
tSub, ok := subs["ticker"]
|
||||
if ok {
|
||||
tSub.TickTypes = wsDefaultTickTypes
|
||||
}
|
||||
|
||||
for _, s := range subs {
|
||||
err := b.Websocket.Conn.SendJSONMessage(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
b.Websocket.AddSuccessfulSubscriptions(channelsToSubscribe...)
|
||||
return nil
|
||||
}
|
||||
101
exchanges/bithumb/bithumb_websocket_test.go
Normal file
101
exchanges/bithumb/bithumb_websocket_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package bithumb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/thrasher-corp/gocryptotrader/currency"
|
||||
exchange "github.com/thrasher-corp/gocryptotrader/exchanges"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/stream"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/ticker"
|
||||
)
|
||||
|
||||
var (
|
||||
wsTickerResp = []byte(`{"type":"ticker","content":{"tickType":"24H","date":"20210811","time":"132017","openPrice":"33400","closePrice":"34010","lowPrice":"32660","highPrice":"34510","value":"45741663716.89916828275244531","volume":"1359398.496892086826189907","sellVolume":"198021.237915860451480504","buyVolume":"1161377.258976226374709403","prevClosePrice":"33530","chgRate":"1.83","chgAmt":"610","volumePower":"500","symbol":"UNI_KRW"}}`)
|
||||
wsTransResp = []byte(`{"type":"transaction","content":{"list":[{"buySellGb":"1","contPrice":"1166","contQty":"125.2400","contAmt":"146029.8400","contDtm":"2021-08-13 15:23:42.911273","updn":"dn","symbol":"DAI_KRW"}]}}`)
|
||||
wsOrderbookResp = []byte(`{"type":"orderbookdepth","content":{"list":[{"symbol":"XLM_KRW","orderType":"ask","price":"401.2","quantity":"0","total":"0"},{"symbol":"XLM_KRW","orderType":"ask","price":"401.6","quantity":"21277.735","total":"1"},{"symbol":"XLM_KRW","orderType":"ask","price":"403.3","quantity":"4000","total":"1"},{"symbol":"XLM_KRW","orderType":"bid","price":"399.5","quantity":"0","total":"0"},{"symbol":"XLM_KRW","orderType":"bid","price":"398.2","quantity":"0","total":"0"},{"symbol":"XLM_KRW","orderType":"bid","price":"399.8","quantity":"31416.8779","total":"1"},{"symbol":"XLM_KRW","orderType":"bid","price":"398.5","quantity":"34328.387","total":"1"}],"datetime":"1628835823604483"}}`)
|
||||
)
|
||||
|
||||
func TestWsHandleData(t *testing.T) {
|
||||
t.Parallel()
|
||||
pairs := currency.Pairs{
|
||||
currency.Pair{
|
||||
Base: currency.BTC,
|
||||
Quote: currency.USDT,
|
||||
},
|
||||
}
|
||||
|
||||
dummy := Bithumb{
|
||||
Base: exchange.Base{
|
||||
Name: "dummy",
|
||||
Features: exchange.Features{
|
||||
Enabled: exchange.FeaturesEnabled{SaveTradeData: true},
|
||||
},
|
||||
CurrencyPairs: currency.PairsManager{
|
||||
Pairs: map[asset.Item]*currency.PairStore{
|
||||
asset.Spot: {
|
||||
Available: pairs,
|
||||
Enabled: pairs,
|
||||
ConfigFormat: ¤cy.PairFormat{
|
||||
Uppercase: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Websocket: &stream.Websocket{
|
||||
Wg: new(sync.WaitGroup),
|
||||
DataHandler: make(chan interface{}, 1),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
dummy.setupOrderbookManager()
|
||||
dummy.API.Endpoints = b.NewEndpoints()
|
||||
|
||||
welcomeMsg := []byte(`{"status":"0000","resmsg":"Connected Successfully"}`)
|
||||
err := dummy.wsHandleData(welcomeMsg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = dummy.wsHandleData([]byte(`{"status":"1336","resmsg":"Failed"}`))
|
||||
if !errors.Is(err, stream.ErrSubscriptionFailure) {
|
||||
t.Fatalf("received: %v but expected: %v",
|
||||
err,
|
||||
stream.ErrSubscriptionFailure)
|
||||
}
|
||||
|
||||
err = dummy.wsHandleData(wsTickerResp)
|
||||
if !errors.Is(err, nil) {
|
||||
t.Fatalf("received: %v but expected: %v", err, nil)
|
||||
}
|
||||
|
||||
handled := <-dummy.Websocket.DataHandler
|
||||
_, ok := handled.(*ticker.Price)
|
||||
if !ok {
|
||||
t.Fatal("unexpected value")
|
||||
}
|
||||
|
||||
err = dummy.wsHandleData(wsTransResp) // This doesn't pipe to datahandler
|
||||
if !errors.Is(err, nil) {
|
||||
t.Fatalf("received: %v but expected: %v", err, nil)
|
||||
}
|
||||
|
||||
err = dummy.wsHandleData(wsOrderbookResp) // This doesn't pipe to datahandler
|
||||
if !errors.Is(err, nil) {
|
||||
t.Fatalf("received: %v but expected: %v", err, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSubscriptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
sub, err := b.GenerateSubscriptions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sub == nil {
|
||||
t.Fatal("unexpected value")
|
||||
}
|
||||
}
|
||||
100
exchanges/bithumb/bithumb_websocket_types.go
Normal file
100
exchanges/bithumb/bithumb_websocket_types.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package bithumb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/thrasher-corp/gocryptotrader/currency"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/order"
|
||||
)
|
||||
|
||||
// WsResponse is a generalised response data structure which will defer
|
||||
// unmarshalling of different contents.
|
||||
type WsResponse struct {
|
||||
Status string `json:"status"`
|
||||
ResponseMessage string `json:"resmsg"`
|
||||
Type string `json:"type"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
|
||||
// WsTicker defines a websocket ticker
|
||||
type WsTicker struct {
|
||||
Symbol currency.Pair `json:"symbol"`
|
||||
TickType string `json:"tickType"`
|
||||
Date string `json:"date"`
|
||||
Time string `json:"time"`
|
||||
OpenPrice float64 `json:"openPrice,string"`
|
||||
ClosePrice float64 `json:"closePrice,string"`
|
||||
LowPrice float64 `json:"lowPrice,string"`
|
||||
HighPrice float64 `json:"highPrice,string"`
|
||||
Value float64 `json:"value,string"`
|
||||
Volume float64 `json:"volume,string"`
|
||||
SellVolume float64 `json:"sellVolume,string"`
|
||||
BuyVolume float64 `json:"buyVolume,string"`
|
||||
PreviousClosePrice float64 `json:"prevClosePrice,string"`
|
||||
ChangeRate float64 `json:"chgRate,string"`
|
||||
ChangeAmount float64 `json:"chgAmt,string"`
|
||||
VolumePower float64 `json:"volumePower,string"`
|
||||
}
|
||||
|
||||
// WsOrderbooks defines an amalgamated bid ask orderbook tranche list
|
||||
type WsOrderbooks struct {
|
||||
List []WsOrderbook `json:"list"`
|
||||
DateTime bithumbTime `json:"datetime"`
|
||||
}
|
||||
|
||||
// WsOrderbook defines a singular orderbook tranche
|
||||
type WsOrderbook struct {
|
||||
Symbol currency.Pair `json:"symbol"`
|
||||
OrderSide order.Side `json:"orderType"`
|
||||
Price float64 `json:"price,string"`
|
||||
Quantity float64 `json:"quantity,string"`
|
||||
Total int32 `json:"total,string"`
|
||||
}
|
||||
|
||||
// WsTransactions defines a transaction list
|
||||
type WsTransactions struct {
|
||||
List []WsTransaction `json:"list"`
|
||||
}
|
||||
|
||||
// WsTransaction defines a trade that has executed via their matching engine
|
||||
type WsTransaction struct {
|
||||
Symbol currency.Pair `json:"symbol"`
|
||||
BuySell int32 `json:"buySellGb,string"` // 1: Sell 2: Buy
|
||||
ContractPrice float64 `json:"contPrice,string"`
|
||||
ContractQuantity float64 `json:"contQty,string"`
|
||||
ContractAmount float64 `json:"contAmt,string"`
|
||||
ContractTime string `json:"contDtm"` // 2020-01-29 12:24:18.830039
|
||||
UpOrDown string `json:"updn"`
|
||||
}
|
||||
|
||||
// WsSubscribe is used to subscribe to the ws channel.
|
||||
type WsSubscribe struct {
|
||||
Type string `json:"type"`
|
||||
Symbols []currency.Pair `json:"symbols"`
|
||||
TickTypes []string `json:"tickTypes,omitempty"`
|
||||
}
|
||||
|
||||
// orderbookManager defines a way of managing and maintaining synchronisation
|
||||
// across connections and assets.
|
||||
type orderbookManager struct {
|
||||
state map[currency.Code]map[currency.Code]map[asset.Item]*update
|
||||
sync.Mutex
|
||||
|
||||
jobs chan job
|
||||
}
|
||||
|
||||
type update struct {
|
||||
buffer chan *WsOrderbooks
|
||||
fetchingBook bool
|
||||
initialSync bool
|
||||
lastUpdated time.Time
|
||||
}
|
||||
|
||||
// job defines a synchonisation job that tells a go routine to fetch an
|
||||
// orderbook via the REST protocol
|
||||
type job struct {
|
||||
Pair currency.Pair
|
||||
}
|
||||
@@ -20,12 +20,15 @@ import (
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/orderbook"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/protocol"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/request"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/stream"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/ticker"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/trade"
|
||||
"github.com/thrasher-corp/gocryptotrader/log"
|
||||
"github.com/thrasher-corp/gocryptotrader/portfolio/withdraw"
|
||||
)
|
||||
|
||||
const wsRateLimitMillisecond = 1000
|
||||
|
||||
var errNotEnoughPairs = errors.New("at least one currency is required to fetch order history")
|
||||
|
||||
// GetDefaultConfig returns a default exchange config
|
||||
@@ -92,6 +95,13 @@ func (b *Bithumb) SetDefaults() {
|
||||
CryptoWithdrawalFee: true,
|
||||
KlineFetching: true,
|
||||
},
|
||||
Websocket: true,
|
||||
WebsocketCapabilities: protocol.Features{
|
||||
TradeFetching: true,
|
||||
TickerFetching: true,
|
||||
OrderbookFetching: true,
|
||||
Subscribe: true,
|
||||
},
|
||||
WithdrawPermissions: exchange.AutoWithdrawCrypto |
|
||||
exchange.AutoWithdrawFiat,
|
||||
Kline: kline.ExchangeCapabilitiesSupported{
|
||||
@@ -120,11 +130,16 @@ func (b *Bithumb) SetDefaults() {
|
||||
request.WithLimiter(SetRateLimit()))
|
||||
b.API.Endpoints = b.NewEndpoints()
|
||||
err = b.API.Endpoints.SetDefaultEndpoints(map[exchange.URL]string{
|
||||
exchange.RestSpot: apiURL,
|
||||
exchange.RestSpot: apiURL,
|
||||
exchange.WebsocketSpot: wsEndpoint,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorln(log.ExchangeSys, err)
|
||||
}
|
||||
|
||||
b.Websocket = stream.New()
|
||||
b.WebsocketResponseMaxLimit = exchange.DefaultWebsocketResponseMaxLimit
|
||||
b.WebsocketResponseCheckTimeout = exchange.DefaultWebsocketResponseCheckTimeout
|
||||
}
|
||||
|
||||
// Setup takes in the supplied exchange configuration details and sets params
|
||||
@@ -133,7 +148,44 @@ func (b *Bithumb) Setup(exch *config.ExchangeConfig) error {
|
||||
b.SetEnabled(false)
|
||||
return nil
|
||||
}
|
||||
return b.SetupDefaults(exch)
|
||||
err := b.SetupDefaults(exch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
location, err = time.LoadLocation("Asia/Seoul")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ePoint, err := b.API.Endpoints.GetURL(exchange.WebsocketSpot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = b.Websocket.Setup(&stream.WebsocketSetup{
|
||||
Enabled: exch.Features.Enabled.Websocket,
|
||||
Verbose: exch.Verbose,
|
||||
AuthenticatedWebsocketAPISupport: exch.API.AuthenticatedWebsocketSupport,
|
||||
WebsocketTimeout: exch.WebsocketTrafficTimeout,
|
||||
DefaultURL: wsEndpoint,
|
||||
ExchangeName: exch.Name,
|
||||
RunningURL: ePoint,
|
||||
Connector: b.WsConnect,
|
||||
Subscriber: b.Subscribe,
|
||||
GenerateSubscriptions: b.GenerateSubscriptions,
|
||||
Features: &b.Features.Supports.WebsocketCapabilities,
|
||||
OrderbookBufferLimit: exch.OrderbookConfig.WebsocketBufferLimit,
|
||||
BufferEnabled: exch.OrderbookConfig.WebsocketBufferEnabled,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.Websocket.SetupNewConnection(stream.ConnectionSetup{
|
||||
ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout,
|
||||
ResponseMaxLimit: exch.WebsocketResponseMaxLimit,
|
||||
RateLimit: wsRateLimitMillisecond,
|
||||
})
|
||||
}
|
||||
|
||||
// Start starts the Bithumb go routine
|
||||
|
||||
391
exchanges/bithumb/bithumb_ws_orderbook.go
Normal file
391
exchanges/bithumb/bithumb_ws_orderbook.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package bithumb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/thrasher-corp/gocryptotrader/currency"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/orderbook"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/stream/buffer"
|
||||
"github.com/thrasher-corp/gocryptotrader/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxWSUpdateBuffer defines max websocket updates to apply when an
|
||||
// orderbook is initially fetched
|
||||
maxWSUpdateBuffer = 150
|
||||
// maxWSOrderbookJobs defines max websocket orderbook jobs in queue to fetch
|
||||
// an orderbook snapshot via REST
|
||||
maxWSOrderbookJobs = 2000
|
||||
// maxWSOrderbookWorkers defines a max amount of workers allowed to execute
|
||||
// jobs from the job channel
|
||||
maxWSOrderbookWorkers = 10
|
||||
)
|
||||
|
||||
func (b *Bithumb) processBooks(updates *WsOrderbooks) error {
|
||||
var bids, asks []orderbook.Item
|
||||
for x := range updates.List {
|
||||
i := orderbook.Item{Price: updates.List[x].Price, Amount: updates.List[x].Quantity}
|
||||
if updates.List[x].OrderSide == "bid" {
|
||||
bids = append(bids, i)
|
||||
continue
|
||||
}
|
||||
asks = append(asks, i)
|
||||
}
|
||||
return b.Websocket.Orderbook.Update(&buffer.Update{
|
||||
Pair: updates.List[0].Symbol,
|
||||
Asset: asset.Spot,
|
||||
Bids: bids,
|
||||
Asks: asks,
|
||||
UpdateTime: updates.DateTime.Time(),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateLocalBuffer updates and returns the most recent iteration of the orderbook
|
||||
func (b *Bithumb) UpdateLocalBuffer(wsdp *WsOrderbooks) (bool, error) {
|
||||
if len(wsdp.List) < 1 {
|
||||
return false, errors.New("insufficient data to process")
|
||||
}
|
||||
err := b.obm.stageWsUpdate(wsdp, wsdp.List[0].Symbol, asset.Spot)
|
||||
if err != nil {
|
||||
init, err2 := b.obm.checkIsInitialSync(wsdp.List[0].Symbol)
|
||||
if err2 != nil {
|
||||
return false, err2
|
||||
}
|
||||
return init, err
|
||||
}
|
||||
|
||||
err = b.applyBufferUpdate(wsdp.List[0].Symbol)
|
||||
if err != nil {
|
||||
b.flushAndCleanup(wsdp.List[0].Symbol)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// applyBufferUpdate applies the buffer to the orderbook or initiates a new
|
||||
// orderbook sync by the REST protocol which is off handed to go routine.
|
||||
func (b *Bithumb) applyBufferUpdate(pair currency.Pair) error {
|
||||
fetching, err := b.obm.checkIsFetchingBook(pair)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fetching {
|
||||
return nil
|
||||
}
|
||||
|
||||
recent, err := b.Websocket.Orderbook.GetOrderbook(pair, asset.Spot)
|
||||
if err != nil || (recent.Asks == nil && recent.Bids == nil) {
|
||||
return b.obm.fetchBookViaREST(pair)
|
||||
}
|
||||
|
||||
return b.obm.checkAndProcessUpdate(b.processBooks, pair, recent)
|
||||
}
|
||||
|
||||
// SynchroniseWebsocketOrderbook synchronises full orderbook for currency pair
|
||||
// asset
|
||||
func (b *Bithumb) SynchroniseWebsocketOrderbook() {
|
||||
b.Websocket.Wg.Add(1)
|
||||
go func() {
|
||||
defer b.Websocket.Wg.Done()
|
||||
for {
|
||||
select {
|
||||
case j := <-b.obm.jobs:
|
||||
err := b.processJob(j.Pair)
|
||||
if err != nil {
|
||||
log.Errorf(log.WebsocketMgr,
|
||||
"%s processing websocket orderbook error %v",
|
||||
b.Name, err)
|
||||
}
|
||||
case <-b.Websocket.ShutdownC:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// processJob fetches and processes orderbook updates
|
||||
func (b *Bithumb) processJob(p currency.Pair) error {
|
||||
err := b.SeedLocalCache(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %s seeding local cache for orderbook error: %v",
|
||||
p, asset.Spot, err)
|
||||
}
|
||||
|
||||
err = b.obm.stopFetchingBook(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Immediately apply the buffer updates so we don't wait for a
|
||||
// new update to initiate this.
|
||||
err = b.applyBufferUpdate(p)
|
||||
if err != nil {
|
||||
b.flushAndCleanup(p)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// flushAndCleanup flushes orderbook and clean local cache
|
||||
func (b *Bithumb) flushAndCleanup(p currency.Pair) {
|
||||
errClean := b.Websocket.Orderbook.FlushOrderbook(p, asset.Spot)
|
||||
if errClean != nil {
|
||||
log.Errorf(log.WebsocketMgr,
|
||||
"%s flushing websocket error: %v",
|
||||
b.Name,
|
||||
errClean)
|
||||
}
|
||||
errClean = b.obm.cleanup(p)
|
||||
if errClean != nil {
|
||||
log.Errorf(log.WebsocketMgr, "%s cleanup websocket error: %v",
|
||||
b.Name,
|
||||
errClean)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bithumb) setupOrderbookManager() {
|
||||
if b.obm.state == nil {
|
||||
b.obm.state = make(map[currency.Code]map[currency.Code]map[asset.Item]*update)
|
||||
b.obm.jobs = make(chan job, maxWSOrderbookJobs)
|
||||
|
||||
for i := 0; i < maxWSOrderbookWorkers; i++ {
|
||||
// 10 workers for synchronising book
|
||||
b.SynchroniseWebsocketOrderbook()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stageWsUpdate stages websocket update to roll through updates that need to
|
||||
// be applied to a fetched orderbook via REST.
|
||||
func (o *orderbookManager) stageWsUpdate(u *WsOrderbooks, pair currency.Pair, a asset.Item) error {
|
||||
o.Lock()
|
||||
defer o.Unlock()
|
||||
m1, ok := o.state[pair.Base]
|
||||
if !ok {
|
||||
m1 = make(map[currency.Code]map[asset.Item]*update)
|
||||
o.state[pair.Base] = m1
|
||||
}
|
||||
|
||||
m2, ok := m1[pair.Quote]
|
||||
if !ok {
|
||||
m2 = make(map[asset.Item]*update)
|
||||
m1[pair.Quote] = m2
|
||||
}
|
||||
|
||||
state, ok := m2[a]
|
||||
if !ok {
|
||||
state = &update{
|
||||
buffer: make(chan *WsOrderbooks, maxWSUpdateBuffer),
|
||||
fetchingBook: false,
|
||||
initialSync: true,
|
||||
}
|
||||
m2[a] = state
|
||||
}
|
||||
|
||||
if !state.lastUpdated.IsZero() && u.DateTime.Time().Before(state.lastUpdated) {
|
||||
return fmt.Errorf("websocket orderbook synchronisation failure for pair %s and asset %s", pair, a)
|
||||
}
|
||||
state.lastUpdated = u.DateTime.Time()
|
||||
|
||||
select {
|
||||
// Put update in the channel buffer to be processed
|
||||
case state.buffer <- u:
|
||||
return nil
|
||||
default:
|
||||
<-state.buffer // pop one element
|
||||
state.buffer <- u // to shift buffer on fail
|
||||
return fmt.Errorf("channel blockage for %s, asset %s and connection",
|
||||
pair, a)
|
||||
}
|
||||
}
|
||||
|
||||
// checkIsFetchingBook checks status if the book is currently being via the REST
|
||||
// protocol.
|
||||
func (o *orderbookManager) checkIsFetchingBook(pair currency.Pair) (bool, error) {
|
||||
o.Lock()
|
||||
defer o.Unlock()
|
||||
state, ok := o.state[pair.Base][pair.Quote][asset.Spot]
|
||||
if !ok {
|
||||
return false,
|
||||
fmt.Errorf("check is fetching book cannot match currency pair %s asset type %s",
|
||||
pair,
|
||||
asset.Spot)
|
||||
}
|
||||
return state.fetchingBook, nil
|
||||
}
|
||||
|
||||
// stopFetchingBook completes the book fetching.
|
||||
func (o *orderbookManager) stopFetchingBook(pair currency.Pair) error {
|
||||
o.Lock()
|
||||
defer o.Unlock()
|
||||
state, ok := o.state[pair.Base][pair.Quote][asset.Spot]
|
||||
if !ok {
|
||||
return fmt.Errorf("could not match pair %s and asset type %s in hash table",
|
||||
pair,
|
||||
asset.Spot)
|
||||
}
|
||||
if !state.fetchingBook {
|
||||
return fmt.Errorf("fetching book already set to false for %s %s",
|
||||
pair,
|
||||
asset.Spot)
|
||||
}
|
||||
state.fetchingBook = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// completeInitialSync sets if an asset type has completed its initial sync
|
||||
func (o *orderbookManager) completeInitialSync(pair currency.Pair) error {
|
||||
o.Lock()
|
||||
defer o.Unlock()
|
||||
state, ok := o.state[pair.Base][pair.Quote][asset.Spot]
|
||||
if !ok {
|
||||
return fmt.Errorf("complete initial sync cannot match currency pair %s asset type %s",
|
||||
pair,
|
||||
asset.Spot)
|
||||
}
|
||||
if !state.initialSync {
|
||||
return fmt.Errorf("initital sync already set to false for %s %s",
|
||||
pair,
|
||||
asset.Spot)
|
||||
}
|
||||
state.initialSync = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkIsInitialSync checks status if the book is Initial Sync being via the REST
|
||||
// protocol.
|
||||
func (o *orderbookManager) checkIsInitialSync(pair currency.Pair) (bool, error) {
|
||||
o.Lock()
|
||||
defer o.Unlock()
|
||||
state, ok := o.state[pair.Base][pair.Quote][asset.Spot]
|
||||
if !ok {
|
||||
return false,
|
||||
fmt.Errorf("checkIsInitialSync of orderbook cannot match currency pair %s asset type %s",
|
||||
pair,
|
||||
asset.Spot)
|
||||
}
|
||||
return state.initialSync, nil
|
||||
}
|
||||
|
||||
// fetchBookViaREST pushes a job of fetching the orderbook via the REST protocol
|
||||
// to get an initial full book that we can apply our buffered updates too.
|
||||
func (o *orderbookManager) fetchBookViaREST(pair currency.Pair) error {
|
||||
o.Lock()
|
||||
defer o.Unlock()
|
||||
|
||||
state, ok := o.state[pair.Base][pair.Quote][asset.Spot]
|
||||
if !ok {
|
||||
return fmt.Errorf("fetch book via rest cannot match currency pair %s asset type %s",
|
||||
pair,
|
||||
asset.Spot)
|
||||
}
|
||||
|
||||
state.initialSync = true
|
||||
state.fetchingBook = true
|
||||
|
||||
select {
|
||||
case o.jobs <- job{pair}:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("%s %s book synchronisation channel blocked up",
|
||||
pair,
|
||||
asset.Spot)
|
||||
}
|
||||
}
|
||||
|
||||
func (o *orderbookManager) checkAndProcessUpdate(processor func(*WsOrderbooks) error, pair currency.Pair, recent *orderbook.Base) error {
|
||||
o.Lock()
|
||||
defer o.Unlock()
|
||||
state, ok := o.state[pair.Base][pair.Quote][asset.Spot]
|
||||
if !ok {
|
||||
return fmt.Errorf("could not match pair [%s] asset type [%s] in hash table to process websocket orderbook update",
|
||||
pair, asset.Spot)
|
||||
}
|
||||
|
||||
// This will continuously remove updates from the buffered channel and
|
||||
// apply them to the current orderbook.
|
||||
buffer:
|
||||
for {
|
||||
select {
|
||||
case d := <-state.buffer:
|
||||
if !state.validate(d, recent) {
|
||||
continue
|
||||
}
|
||||
err := processor(d)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %s processing update error: %w",
|
||||
pair, asset.Spot, err)
|
||||
}
|
||||
default:
|
||||
break buffer
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validate checks for correct update alignment
|
||||
func (u *update) validate(updt *WsOrderbooks, recent *orderbook.Base) bool {
|
||||
return updt.DateTime.Time().After(recent.LastUpdated)
|
||||
}
|
||||
|
||||
// cleanup cleans up buffer and reset fetch and init
|
||||
func (o *orderbookManager) cleanup(pair currency.Pair) error {
|
||||
o.Lock()
|
||||
state, ok := o.state[pair.Base][pair.Quote][asset.Spot]
|
||||
if !ok {
|
||||
o.Unlock()
|
||||
return fmt.Errorf("cleanup cannot match %s %s to hash table",
|
||||
pair,
|
||||
asset.Spot)
|
||||
}
|
||||
|
||||
bufferEmpty:
|
||||
for {
|
||||
select {
|
||||
case <-state.buffer:
|
||||
// bleed and discard buffer
|
||||
default:
|
||||
break bufferEmpty
|
||||
}
|
||||
}
|
||||
o.Unlock()
|
||||
// disable rest orderbook synchronisation
|
||||
_ = o.stopFetchingBook(pair)
|
||||
_ = o.completeInitialSync(pair)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedLocalCache seeds depth data
|
||||
func (b *Bithumb) SeedLocalCache(p currency.Pair) error {
|
||||
ob, err := b.GetOrderBook(p.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.SeedLocalCacheWithBook(p, ob)
|
||||
}
|
||||
|
||||
// SeedLocalCacheWithBook seeds the local orderbook cache
|
||||
func (b *Bithumb) SeedLocalCacheWithBook(p currency.Pair, o *Orderbook) error {
|
||||
var newOrderBook orderbook.Base
|
||||
for i := range o.Data.Bids {
|
||||
newOrderBook.Bids = append(newOrderBook.Bids, orderbook.Item{
|
||||
Amount: o.Data.Bids[i].Quantity,
|
||||
Price: o.Data.Bids[i].Price,
|
||||
})
|
||||
}
|
||||
for i := range o.Data.Asks {
|
||||
newOrderBook.Asks = append(newOrderBook.Asks, orderbook.Item{
|
||||
Amount: o.Data.Asks[i].Quantity,
|
||||
Price: o.Data.Asks[i].Price,
|
||||
})
|
||||
}
|
||||
|
||||
newOrderBook.Pair = p
|
||||
newOrderBook.Asset = asset.Spot
|
||||
newOrderBook.Exchange = b.Name
|
||||
newOrderBook.LastUpdated = time.Unix(0, o.Data.Timestamp*int64(time.Millisecond))
|
||||
newOrderBook.VerifyOrderbook = b.CanVerifyOrderbook
|
||||
return b.Websocket.Orderbook.LoadSnapshot(&newOrderBook)
|
||||
}
|
||||
31
exchanges/bithumb/convert.go
Normal file
31
exchanges/bithumb/convert.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package bithumb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// bithumbMSTime provides an internal conversion helper for microsecond parsing
|
||||
type bithumbTime time.Time
|
||||
|
||||
// UnmarshalJSON implements the unmarshal interface
|
||||
func (t *bithumbTime) UnmarshalJSON(data []byte) error {
|
||||
var timestamp string
|
||||
if err := json.Unmarshal(data, ×tamp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*t = bithumbTime(time.Unix(0, i*int64(time.Microsecond)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Time returns a time.Time object
|
||||
func (t bithumbTime) Time() time.Time {
|
||||
return time.Time(t)
|
||||
}
|
||||
27
exchanges/bithumb/convert_test.go
Normal file
27
exchanges/bithumb/convert_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package bithumb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBithumbTime(t *testing.T) {
|
||||
var newTime bithumbTime
|
||||
err := json.Unmarshal([]byte("bad news"), &newTime)
|
||||
if err == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
strData := []byte(`"1628739590000000"`) // Thursday, August 12, 2021 3:39:50 AM UTC
|
||||
err = json.Unmarshal(strData, &newTime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tt := newTime.Time()
|
||||
if tt.UTC().String() != "2021-08-12 03:39:50 +0000 UTC" {
|
||||
t.Fatalf("expected: %s but receieved: %s",
|
||||
"2021-08-12 03:39:50 +0000 UTC",
|
||||
tt.UTC().String())
|
||||
}
|
||||
}
|
||||
@@ -1,211 +1,211 @@
|
||||
# GoCryptoTrader package Mock
|
||||
|
||||
<img src="/common/gctlogo.png?raw=true" width="350px" height="350px" hspace="70">
|
||||
|
||||
|
||||
[](https://github.com/thrasher-corp/gocryptotrader/actions/workflows/tests.yml)
|
||||
[](https://github.com/thrasher-corp/gocryptotrader/blob/master/LICENSE)
|
||||
[](https://godoc.org/github.com/thrasher-corp/gocryptotrader/exchanges/mock)
|
||||
[](http://codecov.io/github/thrasher-corp/gocryptotrader?branch=master)
|
||||
[](https://goreportcard.com/report/github.com/thrasher-corp/gocryptotrader)
|
||||
|
||||
|
||||
This mock package is part of the GoCryptoTrader codebase.
|
||||
|
||||
## This is still in active development
|
||||
|
||||
You can track ideas, planned features and what's in progress on this Trello board: [https://trello.com/b/ZAhMhpOy/gocryptotrader](https://trello.com/b/ZAhMhpOy/gocryptotrader).
|
||||
|
||||
Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader Slack](https://join.slack.com/t/gocryptotrader/shared_invite/enQtNTQ5NDAxMjA2Mjc5LTc5ZDE1ZTNiOGM3ZGMyMmY1NTAxYWZhODE0MWM5N2JlZDk1NDU0YTViYzk4NTk3OTRiMDQzNGQ1YTc4YmRlMTk)
|
||||
|
||||
## Mock Testing Suite
|
||||
|
||||
## Current Features for mock
|
||||
+ REST recording service
|
||||
+ REST mock response server
|
||||
|
||||
### How to enable
|
||||
|
||||
+ Any exchange with mock testing will be enabled by default. This is done using build tags which are highlighted in the examples below via `//+build mock_test_off`. To disable and run live endpoint testing parse `-tags=mock_test_off` as a go test param.
|
||||
|
||||
## Mock test setup
|
||||
|
||||
+ Create two additional test files for the exchange. Examples are below:
|
||||
|
||||
### file one - your_current_exchange_name_live_test.go
|
||||
|
||||
```go
|
||||
//+build mock_test_off
|
||||
|
||||
// This will build if build tag mock_test_off is parsed and will do live testing
|
||||
// using all tests in (exchange)_test.go
|
||||
package your_current_exchange_name
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"log"
|
||||
|
||||
"github.com/thrasher-corp/gocryptotrader/config"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues"
|
||||
)
|
||||
|
||||
var mockTests = false
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
cfg := config.GetConfig()
|
||||
cfg.LoadConfig("../../testdata/configtest.json")
|
||||
your_current_exchange_nameConfig, err := cfg.GetExchangeConfig("your_current_exchange_name")
|
||||
if err != nil {
|
||||
log.Fatal("your_current_exchange_name Setup() init error", err)
|
||||
}
|
||||
your_current_exchange_nameConfig.API.AuthenticatedSupport = true
|
||||
your_current_exchange_nameConfig.API.Credentials.Key = apiKey
|
||||
your_current_exchange_nameConfig.API.Credentials.Secret = apiSecret
|
||||
s.SetDefaults()
|
||||
s.Setup(&your_current_exchange_nameConfig)
|
||||
log.Printf(sharedtestvalues.LiveTesting, s.Name, s.API.Endpoints.URL)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
```
|
||||
|
||||
### file two - your_current_exchange_name_mock_test.go
|
||||
|
||||
```go
|
||||
//+build !mock_test_off
|
||||
|
||||
// This will build if build tag mock_test_off is not parsed and will try to mock
|
||||
// all tests in _test.go
|
||||
package your_current_exchange_name
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"log"
|
||||
|
||||
"github.com/thrasher-corp/gocryptotrader/config"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/mock"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues"
|
||||
)
|
||||
|
||||
const mockfile = "../../testdata/http_mock/your_current_exchange_name/your_current_exchange_name.json"
|
||||
|
||||
var mockTests = true
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
cfg := config.GetConfig()
|
||||
cfg.LoadConfig("../../testdata/configtest.json")
|
||||
your_current_exchange_nameConfig, err := cfg.GetExchangeConfig("your_current_exchange_name")
|
||||
if err != nil {
|
||||
log.Fatal("your_current_exchange_name Setup() init error", err)
|
||||
}
|
||||
your_current_exchange_nameConfig.API.AuthenticatedSupport = true
|
||||
your_current_exchange_nameConfig.API.Credentials.Key = apiKey
|
||||
your_current_exchange_nameConfig.API.Credentials.Secret = apiSecret
|
||||
s.SetDefaults()
|
||||
s.Setup(&your_current_exchange_nameConfig)
|
||||
|
||||
serverDetails, newClient, err := mock.NewVCRServer(mockfile)
|
||||
if err != nil {
|
||||
log.Fatalf("Mock server error %s", err)
|
||||
}
|
||||
|
||||
s.HTTPClient = newClient
|
||||
s.API.Endpoints.URL = serverDetails
|
||||
|
||||
log.Printf(sharedtestvalues.MockTesting, s.Name, s.API.Endpoints.URL)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Mock test storage
|
||||
|
||||
+ Under `testdata/http_mock` create a folder matching the name of your exchange. Then create a JSON file matching the name of your exchange with the following formatting:
|
||||
```
|
||||
{
|
||||
"routes": {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Recording a test result
|
||||
|
||||
+ Once the files `your_current_exchange_name_mock_test.go` and `your_current_exchange_name_live_test.go` along with the JSON file `testdata/http_mock/our_current_exchange_name/our_current_exchange_name.json` are created, go through each individual test function and add
|
||||
|
||||
```go
|
||||
var s SomeExchange
|
||||
|
||||
func TestDummyTest(t *testing.T) {
|
||||
s.Verbose = true // This will show you some fancy debug output
|
||||
s.HTTPRecording = true // This will record the request and response payloads
|
||||
s.API.Endpoints.URL = apiURL // This will overwrite the current mock url at localhost
|
||||
s.API.Endpoints.URLSecondary = secondAPIURL // This is only if your API has multiple endpoints
|
||||
s.HTTPClient = http.DefaultClient // This will ensure that a real HTTPClient is used to record
|
||||
err := s.SomeExchangeEndpointFunction()
|
||||
// check error
|
||||
}
|
||||
```
|
||||
|
||||
+ This will store the request and results under the freshly created `testdata/http_mock/your_current_exchange/your_current_exchange.json`
|
||||
|
||||
## Validating
|
||||
|
||||
+ To check if the recording was successful, comment out recording and apiurl changes, then re-run test.
|
||||
|
||||
```go
|
||||
var s SomeExchange
|
||||
|
||||
func TestDummyTest(t *testing.T) {
|
||||
s.Verbose = true // This will show you some fancy debug output
|
||||
// s.HTTPRecording = true // This will record the request and response payloads
|
||||
// s.API.Endpoints.URL = apiURL // This will overwrite the current mock url at localhost
|
||||
// s.API.Endpoints.URLSecondary = secondAPIURL // This is only if your API has multiple endpoints
|
||||
// s.HTTPClient = http.DefaultClient // This will ensure that a real HTTPClient is used to record
|
||||
err := s.SomeExchangeEndpointFunction()
|
||||
// check error
|
||||
}
|
||||
```
|
||||
|
||||
+ The payload should be the same.
|
||||
|
||||
## Considerations
|
||||
|
||||
+ Some functions require timestamps. Mock tests _must_ match the same request structure, so `time.Now()` will cause problems for mock testing.
|
||||
+ To address this, use the boolean variable `mockTests` to create a consistent date. An example is below.
|
||||
```
|
||||
startTime := time.Now().Add(-time.Hour * 1)
|
||||
endTime := time.Now()
|
||||
if mockTests {
|
||||
startTime = time.Date(2020, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||
endTime = time.Date(2020, 9, 2, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
```
|
||||
+ Authenticated endpoints will typically require valid API keys and a signature to run successfully. Authenticated endpoints should be skipped. See an example below
|
||||
```
|
||||
if mockTests {
|
||||
t.Skip("skipping authenticated function for mock testing")
|
||||
}
|
||||
```
|
||||
|
||||
### Please click GoDocs chevron above to view current GoDoc information for this package
|
||||
|
||||
## Contribution
|
||||
|
||||
Please feel free to submit any pull requests or suggest any desired features to be added.
|
||||
|
||||
When submitting a PR, please abide by our coding guidelines:
|
||||
|
||||
+ Code must adhere to the official Go [formatting](https://golang.org/doc/effective_go.html#formatting) guidelines (i.e. uses [gofmt](https://golang.org/cmd/gofmt/)).
|
||||
+ Code must be documented adhering to the official Go [commentary](https://golang.org/doc/effective_go.html#commentary) guidelines.
|
||||
+ Code must adhere to our [coding style](https://github.com/thrasher-corp/gocryptotrader/blob/master/doc/coding_style.md).
|
||||
+ Pull requests need to be based on and opened against the `master` branch.
|
||||
|
||||
## Donations
|
||||
|
||||
<img src="https://github.com/thrasher-corp/gocryptotrader/blob/master/web/src/assets/donate.png?raw=true" hspace="70">
|
||||
|
||||
If this framework helped you in any way, or you would like to support the developers working on it, please donate Bitcoin to:
|
||||
|
||||
***bc1qk0jareu4jytc0cfrhr5wgshsq8282awpavfahc***
|
||||
# GoCryptoTrader package Mock
|
||||
|
||||
<img src="/common/gctlogo.png?raw=true" width="350px" height="350px" hspace="70">
|
||||
|
||||
|
||||
[](https://github.com/thrasher-corp/gocryptotrader/actions/workflows/tests.yml)
|
||||
[](https://github.com/thrasher-corp/gocryptotrader/blob/master/LICENSE)
|
||||
[](https://godoc.org/github.com/thrasher-corp/gocryptotrader/exchanges/mock)
|
||||
[](http://codecov.io/github/thrasher-corp/gocryptotrader?branch=master)
|
||||
[](https://goreportcard.com/report/github.com/thrasher-corp/gocryptotrader)
|
||||
|
||||
|
||||
This mock package is part of the GoCryptoTrader codebase.
|
||||
|
||||
## This is still in active development
|
||||
|
||||
You can track ideas, planned features and what's in progress on this Trello board: [https://trello.com/b/ZAhMhpOy/gocryptotrader](https://trello.com/b/ZAhMhpOy/gocryptotrader).
|
||||
|
||||
Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader Slack](https://join.slack.com/t/gocryptotrader/shared_invite/enQtNTQ5NDAxMjA2Mjc5LTc5ZDE1ZTNiOGM3ZGMyMmY1NTAxYWZhODE0MWM5N2JlZDk1NDU0YTViYzk4NTk3OTRiMDQzNGQ1YTc4YmRlMTk)
|
||||
|
||||
## Mock Testing Suite
|
||||
|
||||
## Current Features for mock
|
||||
+ REST recording service
|
||||
+ REST mock response server
|
||||
|
||||
### How to enable
|
||||
|
||||
+ Any exchange with mock testing will be enabled by default. This is done using build tags which are highlighted in the examples below via `//+build mock_test_off`. To disable and run live endpoint testing parse `-tags=mock_test_off` as a go test param.
|
||||
|
||||
## Mock test setup
|
||||
|
||||
+ Create two additional test files for the exchange. Examples are below:
|
||||
|
||||
### file one - your_current_exchange_name_live_test.go
|
||||
|
||||
```go
|
||||
//+build mock_test_off
|
||||
|
||||
// This will build if build tag mock_test_off is parsed and will do live testing
|
||||
// using all tests in (exchange)_test.go
|
||||
package your_current_exchange_name
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"log"
|
||||
|
||||
"github.com/thrasher-corp/gocryptotrader/config"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues"
|
||||
)
|
||||
|
||||
var mockTests = false
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
cfg := config.GetConfig()
|
||||
cfg.LoadConfig("../../testdata/configtest.json")
|
||||
your_current_exchange_nameConfig, err := cfg.GetExchangeConfig("your_current_exchange_name")
|
||||
if err != nil {
|
||||
log.Fatal("your_current_exchange_name Setup() init error", err)
|
||||
}
|
||||
your_current_exchange_nameConfig.API.AuthenticatedSupport = true
|
||||
your_current_exchange_nameConfig.API.Credentials.Key = apiKey
|
||||
your_current_exchange_nameConfig.API.Credentials.Secret = apiSecret
|
||||
s.SetDefaults()
|
||||
s.Setup(&your_current_exchange_nameConfig)
|
||||
log.Printf(sharedtestvalues.LiveTesting, s.Name, s.API.Endpoints.URL)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
```
|
||||
|
||||
### file two - your_current_exchange_name_mock_test.go
|
||||
|
||||
```go
|
||||
//+build !mock_test_off
|
||||
|
||||
// This will build if build tag mock_test_off is not parsed and will try to mock
|
||||
// all tests in _test.go
|
||||
package your_current_exchange_name
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"log"
|
||||
|
||||
"github.com/thrasher-corp/gocryptotrader/config"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/mock"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues"
|
||||
)
|
||||
|
||||
const mockfile = "../../testdata/http_mock/your_current_exchange_name/your_current_exchange_name.json"
|
||||
|
||||
var mockTests = true
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
cfg := config.GetConfig()
|
||||
cfg.LoadConfig("../../testdata/configtest.json")
|
||||
your_current_exchange_nameConfig, err := cfg.GetExchangeConfig("your_current_exchange_name")
|
||||
if err != nil {
|
||||
log.Fatal("your_current_exchange_name Setup() init error", err)
|
||||
}
|
||||
your_current_exchange_nameConfig.API.AuthenticatedSupport = true
|
||||
your_current_exchange_nameConfig.API.Credentials.Key = apiKey
|
||||
your_current_exchange_nameConfig.API.Credentials.Secret = apiSecret
|
||||
s.SetDefaults()
|
||||
s.Setup(&your_current_exchange_nameConfig)
|
||||
|
||||
serverDetails, newClient, err := mock.NewVCRServer(mockfile)
|
||||
if err != nil {
|
||||
log.Fatalf("Mock server error %s", err)
|
||||
}
|
||||
|
||||
s.HTTPClient = newClient
|
||||
s.API.Endpoints.URL = serverDetails
|
||||
|
||||
log.Printf(sharedtestvalues.MockTesting, s.Name, s.API.Endpoints.URL)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Mock test storage
|
||||
|
||||
+ Under `testdata/http_mock` create a folder matching the name of your exchange. Then create a JSON file matching the name of your exchange with the following formatting:
|
||||
```
|
||||
{
|
||||
"routes": {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Recording a test result
|
||||
|
||||
+ Once the files `your_current_exchange_name_mock_test.go` and `your_current_exchange_name_live_test.go` along with the JSON file `testdata/http_mock/our_current_exchange_name/our_current_exchange_name.json` are created, go through each individual test function and add
|
||||
|
||||
```go
|
||||
var s SomeExchange
|
||||
|
||||
func TestDummyTest(t *testing.T) {
|
||||
s.Verbose = true // This will show you some fancy debug output
|
||||
s.HTTPRecording = true // This will record the request and response payloads
|
||||
s.API.Endpoints.URL = apiURL // This will overwrite the current mock url at localhost
|
||||
s.API.Endpoints.URLSecondary = secondAPIURL // This is only if your API has multiple endpoints
|
||||
s.HTTPClient = http.DefaultClient // This will ensure that a real HTTPClient is used to record
|
||||
err := s.SomeExchangeEndpointFunction()
|
||||
// check error
|
||||
}
|
||||
```
|
||||
|
||||
+ This will store the request and results under the freshly created `testdata/http_mock/your_current_exchange/your_current_exchange.json`
|
||||
|
||||
## Validating
|
||||
|
||||
+ To check if the recording was successful, comment out recording and apiurl changes, then re-run test.
|
||||
|
||||
```go
|
||||
var s SomeExchange
|
||||
|
||||
func TestDummyTest(t *testing.T) {
|
||||
s.Verbose = true // This will show you some fancy debug output
|
||||
// s.HTTPRecording = true // This will record the request and response payloads
|
||||
// s.API.Endpoints.URL = apiURL // This will overwrite the current mock url at localhost
|
||||
// s.API.Endpoints.URLSecondary = secondAPIURL // This is only if your API has multiple endpoints
|
||||
// s.HTTPClient = http.DefaultClient // This will ensure that a real HTTPClient is used to record
|
||||
err := s.SomeExchangeEndpointFunction()
|
||||
// check error
|
||||
}
|
||||
```
|
||||
|
||||
+ The payload should be the same.
|
||||
|
||||
## Considerations
|
||||
|
||||
+ Some functions require timestamps. Mock tests _must_ match the same request structure, so `time.Now()` will cause problems for mock testing.
|
||||
+ To address this, use the boolean variable `mockTests` to create a consistent date. An example is below.
|
||||
```
|
||||
startTime := time.Now().Add(-time.Hour * 1)
|
||||
endTime := time.Now()
|
||||
if mockTests {
|
||||
startTime = time.Date(2020, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||
endTime = time.Date(2020, 9, 2, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
```
|
||||
+ Authenticated endpoints will typically require valid API keys and a signature to run successfully. Authenticated endpoints should be skipped. See an example below
|
||||
```
|
||||
if mockTests {
|
||||
t.Skip("skipping authenticated function for mock testing")
|
||||
}
|
||||
```
|
||||
|
||||
### Please click GoDocs chevron above to view current GoDoc information for this package
|
||||
|
||||
## Contribution
|
||||
|
||||
Please feel free to submit any pull requests or suggest any desired features to be added.
|
||||
|
||||
When submitting a PR, please abide by our coding guidelines:
|
||||
|
||||
+ Code must adhere to the official Go [formatting](https://golang.org/doc/effective_go.html#formatting) guidelines (i.e. uses [gofmt](https://golang.org/cmd/gofmt/)).
|
||||
+ Code must be documented adhering to the official Go [commentary](https://golang.org/doc/effective_go.html#commentary) guidelines.
|
||||
+ Code must adhere to our [coding style](https://github.com/thrasher-corp/gocryptotrader/blob/master/doc/coding_style.md).
|
||||
+ Pull requests need to be based on and opened against the `master` branch.
|
||||
|
||||
## Donations
|
||||
|
||||
<img src="https://github.com/thrasher-corp/gocryptotrader/blob/master/web/src/assets/donate.png?raw=true" hspace="70">
|
||||
|
||||
If this framework helped you in any way, or you would like to support the developers working on it, please donate Bitcoin to:
|
||||
|
||||
***bc1qk0jareu4jytc0cfrhr5wgshsq8282awpavfahc***
|
||||
|
||||
@@ -23,7 +23,11 @@ const (
|
||||
defaultTrafficPeriod = time.Second
|
||||
)
|
||||
|
||||
var errClosedConnection = errors.New("use of closed network connection")
|
||||
var (
|
||||
errClosedConnection = errors.New("use of closed network connection")
|
||||
// ErrSubscriptionFailure defines an error when a subscription fails
|
||||
ErrSubscriptionFailure = errors.New("subscription failure")
|
||||
)
|
||||
|
||||
// New initialises the websocket struct
|
||||
func New() *Websocket {
|
||||
@@ -210,7 +214,7 @@ func (w *Websocket) Connect() error {
|
||||
if len(w.subscriptions) != 0 {
|
||||
err = w.Subscriber(w.subscriptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v Error subscribing %s", w.exchangeName, err)
|
||||
return fmt.Errorf("%v %w: %v", w.exchangeName, ErrSubscriptionFailure, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,7 +840,11 @@ func (w *Websocket) SubscribeToChannels(channels []ChannelSubscription) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
return w.Subscriber(channels)
|
||||
err := w.Subscriber(channels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v %w: %v", w.exchangeName, ErrSubscriptionFailure, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddSuccessfulSubscriptions adds subscriptions to the subscription lists that
|
||||
|
||||
@@ -63,7 +63,7 @@ _b in this context is an `IBotExchange` implemented struct_
|
||||
| Binance| Yes | Yes | Yes |
|
||||
| Bitfinex | Yes | Yes | Yes |
|
||||
| Bitflyer | Yes | No | No |
|
||||
| Bithumb | Yes | NA | No |
|
||||
| Bithumb | Yes | Yes | No |
|
||||
| BitMEX | Yes | Yes | Yes |
|
||||
| Bitstamp | Yes | Yes | No |
|
||||
| Bittrex | Yes | Yes | No |
|
||||
|
||||
Reference in New Issue
Block a user