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:
Ryan O'Hara-Reid
2021-08-16 16:53:23 +10:00
committed by GitHub
parent e77baf3ad4
commit 736c92a99b
20 changed files with 3034 additions and 2112 deletions

View File

@@ -13,8 +13,8 @@ MadCozBadd | https://github.com/MadCozBadd
vadimzhukck | https://github.com/vadimzhukck vadimzhukck | https://github.com/vadimzhukck
140am | https://github.com/140am 140am | https://github.com/140am
marcofranssen | https://github.com/marcofranssen marcofranssen | https://github.com/marcofranssen
dackroyd | https://github.com/dackroyd
ydm | https://github.com/ydm ydm | https://github.com/ydm
dackroyd | https://github.com/dackroyd
cranktakular | https://github.com/cranktakular cranktakular | https://github.com/cranktakular
woshidama323 | https://github.com/woshidama323 woshidama323 | https://github.com/woshidama323
crackcomm | https://github.com/crackcomm crackcomm | https://github.com/crackcomm

View File

@@ -22,7 +22,7 @@ Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader
| Binance| Yes | Yes | NA | | Binance| Yes | Yes | NA |
| Bitfinex | Yes | Yes | NA | | Bitfinex | Yes | Yes | NA |
| Bitflyer | Yes | No | NA | | Bitflyer | Yes | No | NA |
| Bithumb | Yes | NA | NA | | Bithumb | Yes | Yes | NA |
| BitMEX | Yes | Yes | NA | | BitMEX | Yes | Yes | NA |
| Bitstamp | Yes | Yes | No | | Bitstamp | Yes | Yes | No |
| Bittrex | Yes | Yes | NA | | Bittrex | Yes | Yes | NA |
@@ -143,11 +143,11 @@ Binaries will be published once the codebase reaches a stable condition.
|User|Contribution Amount| |User|Contribution Amount|
|--|--| |--|--|
| [thrasher-](https://github.com/thrasher-) | 656 | | [thrasher-](https://github.com/thrasher-) | 656 |
| [shazbert](https://github.com/shazbert) | 212 | | [shazbert](https://github.com/shazbert) | 214 |
| [gloriousCode](https://github.com/gloriousCode) | 186 | | [gloriousCode](https://github.com/gloriousCode) | 189 |
| [dependabot-preview[bot]](https://github.com/apps/dependabot-preview) | 88 | | [dependabot-preview[bot]](https://github.com/apps/dependabot-preview) | 88 |
| [xtda](https://github.com/xtda) | 47 | | [xtda](https://github.com/xtda) | 47 |
| [dependabot[bot]](https://github.com/apps/dependabot) | 18 | | [dependabot[bot]](https://github.com/apps/dependabot) | 20 |
| [Rots](https://github.com/Rots) | 15 | | [Rots](https://github.com/Rots) | 15 |
| [vazha](https://github.com/vazha) | 15 | | [vazha](https://github.com/vazha) | 15 |
| [ermalguni](https://github.com/ermalguni) | 14 | | [ermalguni](https://github.com/ermalguni) | 14 |
@@ -155,8 +155,8 @@ Binaries will be published once the codebase reaches a stable condition.
| [vadimzhukck](https://github.com/vadimzhukck) | 10 | | [vadimzhukck](https://github.com/vadimzhukck) | 10 |
| [140am](https://github.com/140am) | 8 | | [140am](https://github.com/140am) | 8 |
| [marcofranssen](https://github.com/marcofranssen) | 8 | | [marcofranssen](https://github.com/marcofranssen) | 8 |
| [ydm](https://github.com/ydm) | 8 |
| [dackroyd](https://github.com/dackroyd) | 5 | | [dackroyd](https://github.com/dackroyd) | 5 |
| [ydm](https://github.com/ydm) | 5 |
| [cranktakular](https://github.com/cranktakular) | 5 | | [cranktakular](https://github.com/cranktakular) | 5 |
| [woshidama323](https://github.com/woshidama323) | 3 | | [woshidama323](https://github.com/woshidama323) | 3 |
| [crackcomm](https://github.com/crackcomm) | 3 | | [crackcomm](https://github.com/crackcomm) | 3 |

View File

@@ -45,7 +45,7 @@ _b in this context is an `IBotExchange` implemented struct_
| Binance| Yes | Yes | Yes | | Binance| Yes | Yes | Yes |
| Bitfinex | Yes | Yes | Yes | | Bitfinex | Yes | Yes | Yes |
| Bitflyer | Yes | No | No | | Bitflyer | Yes | No | No |
| Bithumb | Yes | NA | No | | Bithumb | Yes | Yes | No |
| BitMEX | Yes | Yes | Yes | | BitMEX | Yes | Yes | Yes |
| Bitstamp | Yes | Yes | No | | Bitstamp | Yes | Yes | No |
| Bittrex | Yes | Yes | No | | Bittrex | Yes | Yes | No |

View File

@@ -23,7 +23,7 @@ Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader
| Binance| Yes | Yes | NA | | Binance| Yes | Yes | NA |
| Bitfinex | Yes | Yes | NA | | Bitfinex | Yes | Yes | NA |
| Bitflyer | Yes | No | NA | | Bitflyer | Yes | No | NA |
| Bithumb | Yes | NA | NA | | Bithumb | Yes | Yes | NA |
| BitMEX | Yes | Yes | NA | | BitMEX | Yes | Yes | NA |
| Bitstamp | Yes | Yes | No | | Bitstamp | Yes | Yes | No |
| Bittrex | Yes | Yes | NA | | Bittrex | Yes | Yes | NA |

View File

@@ -285,9 +285,9 @@ func (s *RPCServer) EnableExchange(_ context.Context, r *gctrpc.GenericExchangeN
} }
// GetExchangeOTPCode retrieves an exchanges OTP code // GetExchangeOTPCode retrieves an exchanges OTP code
func (s *RPCServer) GetExchangeOTPCode(_ context.Context, r *gctrpc.GenericExchangeNameRequest) (*gctrpc.GetExchangeOTPReponse, error) { func (s *RPCServer) GetExchangeOTPCode(_ context.Context, r *gctrpc.GenericExchangeNameRequest) (*gctrpc.GetExchangeOTPResponse, error) {
result, err := s.GetExchangeOTPByName(r.Exchange) result, err := s.GetExchangeOTPByName(r.Exchange)
return &gctrpc.GetExchangeOTPReponse{OtpCode: result}, err return &gctrpc.GetExchangeOTPResponse{OtpCode: result}, err
} }
// GetExchangeOTPCodes retrieves OTP codes for all exchanges which have an // GetExchangeOTPCodes retrieves OTP codes for all exchanges which have an

View File

@@ -51,6 +51,7 @@ const (
// Bithumb is the overarching type across the Bithumb package // Bithumb is the overarching type across the Bithumb package
type Bithumb struct { type Bithumb struct {
exchange.Base exchange.Base
obm orderbookManager
} }
// GetTradablePairs returns a list of tradable currencies // GetTradablePairs returns a list of tradable currencies
@@ -114,18 +115,18 @@ func (b *Bithumb) GetAllTickers() (map[string]Ticker, error) {
// GetOrderBook returns current orderbook // GetOrderBook returns current orderbook
// //
// symbol e.g. "btc" // symbol e.g. "btc"
func (b *Bithumb) GetOrderBook(symbol string) (Orderbook, error) { func (b *Bithumb) GetOrderBook(symbol string) (*Orderbook, error) {
response := Orderbook{} response := Orderbook{}
err := b.SendHTTPRequest(exchange.RestSpot, publicOrderBook+strings.ToUpper(symbol), &response) err := b.SendHTTPRequest(exchange.RestSpot, publicOrderBook+strings.ToUpper(symbol), &response)
if err != nil { if err != nil {
return response, err return nil, err
} }
if response.Status != noError { 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 // GetTransactionHistory returns recent transactions

View 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
}

View 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: &currency.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")
}
}

View 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
}

View File

@@ -20,12 +20,15 @@ import (
"github.com/thrasher-corp/gocryptotrader/exchanges/orderbook" "github.com/thrasher-corp/gocryptotrader/exchanges/orderbook"
"github.com/thrasher-corp/gocryptotrader/exchanges/protocol" "github.com/thrasher-corp/gocryptotrader/exchanges/protocol"
"github.com/thrasher-corp/gocryptotrader/exchanges/request" "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/ticker"
"github.com/thrasher-corp/gocryptotrader/exchanges/trade" "github.com/thrasher-corp/gocryptotrader/exchanges/trade"
"github.com/thrasher-corp/gocryptotrader/log" "github.com/thrasher-corp/gocryptotrader/log"
"github.com/thrasher-corp/gocryptotrader/portfolio/withdraw" "github.com/thrasher-corp/gocryptotrader/portfolio/withdraw"
) )
const wsRateLimitMillisecond = 1000
var errNotEnoughPairs = errors.New("at least one currency is required to fetch order history") var errNotEnoughPairs = errors.New("at least one currency is required to fetch order history")
// GetDefaultConfig returns a default exchange config // GetDefaultConfig returns a default exchange config
@@ -92,6 +95,13 @@ func (b *Bithumb) SetDefaults() {
CryptoWithdrawalFee: true, CryptoWithdrawalFee: true,
KlineFetching: true, KlineFetching: true,
}, },
Websocket: true,
WebsocketCapabilities: protocol.Features{
TradeFetching: true,
TickerFetching: true,
OrderbookFetching: true,
Subscribe: true,
},
WithdrawPermissions: exchange.AutoWithdrawCrypto | WithdrawPermissions: exchange.AutoWithdrawCrypto |
exchange.AutoWithdrawFiat, exchange.AutoWithdrawFiat,
Kline: kline.ExchangeCapabilitiesSupported{ Kline: kline.ExchangeCapabilitiesSupported{
@@ -120,11 +130,16 @@ func (b *Bithumb) SetDefaults() {
request.WithLimiter(SetRateLimit())) request.WithLimiter(SetRateLimit()))
b.API.Endpoints = b.NewEndpoints() b.API.Endpoints = b.NewEndpoints()
err = b.API.Endpoints.SetDefaultEndpoints(map[exchange.URL]string{ err = b.API.Endpoints.SetDefaultEndpoints(map[exchange.URL]string{
exchange.RestSpot: apiURL, exchange.RestSpot: apiURL,
exchange.WebsocketSpot: wsEndpoint,
}) })
if err != nil { if err != nil {
log.Errorln(log.ExchangeSys, err) 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 // 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) b.SetEnabled(false)
return nil 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 // Start starts the Bithumb go routine

View 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)
}

View 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, &timestamp); 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)
}

View 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())
}
}

View File

@@ -1,211 +1,211 @@
# GoCryptoTrader package Mock # GoCryptoTrader package Mock
<img src="/common/gctlogo.png?raw=true" width="350px" height="350px" hspace="70"> <img src="/common/gctlogo.png?raw=true" width="350px" height="350px" hspace="70">
[![Build Status](https://github.com/thrasher-corp/gocryptotrader/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/thrasher-corp/gocryptotrader/actions/workflows/tests.yml) [![Build Status](https://github.com/thrasher-corp/gocryptotrader/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/thrasher-corp/gocryptotrader/actions/workflows/tests.yml)
[![Software License](https://img.shields.io/badge/License-MIT-orange.svg?style=flat-square)](https://github.com/thrasher-corp/gocryptotrader/blob/master/LICENSE) [![Software License](https://img.shields.io/badge/License-MIT-orange.svg?style=flat-square)](https://github.com/thrasher-corp/gocryptotrader/blob/master/LICENSE)
[![GoDoc](https://godoc.org/github.com/thrasher-corp/gocryptotrader?status.svg)](https://godoc.org/github.com/thrasher-corp/gocryptotrader/exchanges/mock) [![GoDoc](https://godoc.org/github.com/thrasher-corp/gocryptotrader?status.svg)](https://godoc.org/github.com/thrasher-corp/gocryptotrader/exchanges/mock)
[![Coverage Status](http://codecov.io/github/thrasher-corp/gocryptotrader/coverage.svg?branch=master)](http://codecov.io/github/thrasher-corp/gocryptotrader?branch=master) [![Coverage Status](http://codecov.io/github/thrasher-corp/gocryptotrader/coverage.svg?branch=master)](http://codecov.io/github/thrasher-corp/gocryptotrader?branch=master)
[![Go Report Card](https://goreportcard.com/badge/github.com/thrasher-corp/gocryptotrader)](https://goreportcard.com/report/github.com/thrasher-corp/gocryptotrader) [![Go Report Card](https://goreportcard.com/badge/github.com/thrasher-corp/gocryptotrader)](https://goreportcard.com/report/github.com/thrasher-corp/gocryptotrader)
This mock package is part of the GoCryptoTrader codebase. This mock package is part of the GoCryptoTrader codebase.
## This is still in active development ## 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). 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) Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader Slack](https://join.slack.com/t/gocryptotrader/shared_invite/enQtNTQ5NDAxMjA2Mjc5LTc5ZDE1ZTNiOGM3ZGMyMmY1NTAxYWZhODE0MWM5N2JlZDk1NDU0YTViYzk4NTk3OTRiMDQzNGQ1YTc4YmRlMTk)
## Mock Testing Suite ## Mock Testing Suite
## Current Features for mock ## Current Features for mock
+ REST recording service + REST recording service
+ REST mock response server + REST mock response server
### How to enable ### 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. + 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 ## Mock test setup
+ Create two additional test files for the exchange. Examples are below: + Create two additional test files for the exchange. Examples are below:
### file one - your_current_exchange_name_live_test.go ### file one - your_current_exchange_name_live_test.go
```go ```go
//+build mock_test_off //+build mock_test_off
// This will build if build tag mock_test_off is parsed and will do live testing // This will build if build tag mock_test_off is parsed and will do live testing
// using all tests in (exchange)_test.go // using all tests in (exchange)_test.go
package your_current_exchange_name package your_current_exchange_name
import ( import (
"os" "os"
"testing" "testing"
"log" "log"
"github.com/thrasher-corp/gocryptotrader/config" "github.com/thrasher-corp/gocryptotrader/config"
"github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues" "github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues"
) )
var mockTests = false var mockTests = false
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
cfg := config.GetConfig() cfg := config.GetConfig()
cfg.LoadConfig("../../testdata/configtest.json") cfg.LoadConfig("../../testdata/configtest.json")
your_current_exchange_nameConfig, err := cfg.GetExchangeConfig("your_current_exchange_name") your_current_exchange_nameConfig, err := cfg.GetExchangeConfig("your_current_exchange_name")
if err != nil { if err != nil {
log.Fatal("your_current_exchange_name Setup() init error", err) log.Fatal("your_current_exchange_name Setup() init error", err)
} }
your_current_exchange_nameConfig.API.AuthenticatedSupport = true your_current_exchange_nameConfig.API.AuthenticatedSupport = true
your_current_exchange_nameConfig.API.Credentials.Key = apiKey your_current_exchange_nameConfig.API.Credentials.Key = apiKey
your_current_exchange_nameConfig.API.Credentials.Secret = apiSecret your_current_exchange_nameConfig.API.Credentials.Secret = apiSecret
s.SetDefaults() s.SetDefaults()
s.Setup(&your_current_exchange_nameConfig) s.Setup(&your_current_exchange_nameConfig)
log.Printf(sharedtestvalues.LiveTesting, s.Name, s.API.Endpoints.URL) log.Printf(sharedtestvalues.LiveTesting, s.Name, s.API.Endpoints.URL)
os.Exit(m.Run()) os.Exit(m.Run())
} }
``` ```
### file two - your_current_exchange_name_mock_test.go ### file two - your_current_exchange_name_mock_test.go
```go ```go
//+build !mock_test_off //+build !mock_test_off
// This will build if build tag mock_test_off is not parsed and will try to mock // This will build if build tag mock_test_off is not parsed and will try to mock
// all tests in _test.go // all tests in _test.go
package your_current_exchange_name package your_current_exchange_name
import ( import (
"os" "os"
"testing" "testing"
"log" "log"
"github.com/thrasher-corp/gocryptotrader/config" "github.com/thrasher-corp/gocryptotrader/config"
"github.com/thrasher-corp/gocryptotrader/exchanges/mock" "github.com/thrasher-corp/gocryptotrader/exchanges/mock"
"github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues" "github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues"
) )
const mockfile = "../../testdata/http_mock/your_current_exchange_name/your_current_exchange_name.json" const mockfile = "../../testdata/http_mock/your_current_exchange_name/your_current_exchange_name.json"
var mockTests = true var mockTests = true
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
cfg := config.GetConfig() cfg := config.GetConfig()
cfg.LoadConfig("../../testdata/configtest.json") cfg.LoadConfig("../../testdata/configtest.json")
your_current_exchange_nameConfig, err := cfg.GetExchangeConfig("your_current_exchange_name") your_current_exchange_nameConfig, err := cfg.GetExchangeConfig("your_current_exchange_name")
if err != nil { if err != nil {
log.Fatal("your_current_exchange_name Setup() init error", err) log.Fatal("your_current_exchange_name Setup() init error", err)
} }
your_current_exchange_nameConfig.API.AuthenticatedSupport = true your_current_exchange_nameConfig.API.AuthenticatedSupport = true
your_current_exchange_nameConfig.API.Credentials.Key = apiKey your_current_exchange_nameConfig.API.Credentials.Key = apiKey
your_current_exchange_nameConfig.API.Credentials.Secret = apiSecret your_current_exchange_nameConfig.API.Credentials.Secret = apiSecret
s.SetDefaults() s.SetDefaults()
s.Setup(&your_current_exchange_nameConfig) s.Setup(&your_current_exchange_nameConfig)
serverDetails, newClient, err := mock.NewVCRServer(mockfile) serverDetails, newClient, err := mock.NewVCRServer(mockfile)
if err != nil { if err != nil {
log.Fatalf("Mock server error %s", err) log.Fatalf("Mock server error %s", err)
} }
s.HTTPClient = newClient s.HTTPClient = newClient
s.API.Endpoints.URL = serverDetails s.API.Endpoints.URL = serverDetails
log.Printf(sharedtestvalues.MockTesting, s.Name, s.API.Endpoints.URL) log.Printf(sharedtestvalues.MockTesting, s.Name, s.API.Endpoints.URL)
os.Exit(m.Run()) os.Exit(m.Run())
} }
``` ```
## Mock test storage ## 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: + 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": { "routes": {
} }
} }
``` ```
## Recording a test result ## 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 + 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 ```go
var s SomeExchange var s SomeExchange
func TestDummyTest(t *testing.T) { func TestDummyTest(t *testing.T) {
s.Verbose = true // This will show you some fancy debug output s.Verbose = true // This will show you some fancy debug output
s.HTTPRecording = true // This will record the request and response payloads 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.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.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 s.HTTPClient = http.DefaultClient // This will ensure that a real HTTPClient is used to record
err := s.SomeExchangeEndpointFunction() err := s.SomeExchangeEndpointFunction()
// check error // check error
} }
``` ```
+ This will store the request and results under the freshly created `testdata/http_mock/your_current_exchange/your_current_exchange.json` + This will store the request and results under the freshly created `testdata/http_mock/your_current_exchange/your_current_exchange.json`
## Validating ## Validating
+ To check if the recording was successful, comment out recording and apiurl changes, then re-run test. + To check if the recording was successful, comment out recording and apiurl changes, then re-run test.
```go ```go
var s SomeExchange var s SomeExchange
func TestDummyTest(t *testing.T) { func TestDummyTest(t *testing.T) {
s.Verbose = true // This will show you some fancy debug output s.Verbose = true // This will show you some fancy debug output
// s.HTTPRecording = true // This will record the request and response payloads // 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.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.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 // s.HTTPClient = http.DefaultClient // This will ensure that a real HTTPClient is used to record
err := s.SomeExchangeEndpointFunction() err := s.SomeExchangeEndpointFunction()
// check error // check error
} }
``` ```
+ The payload should be the same. + The payload should be the same.
## Considerations ## Considerations
+ Some functions require timestamps. Mock tests _must_ match the same request structure, so `time.Now()` will cause problems for mock testing. + 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. + To address this, use the boolean variable `mockTests` to create a consistent date. An example is below.
``` ```
startTime := time.Now().Add(-time.Hour * 1) startTime := time.Now().Add(-time.Hour * 1)
endTime := time.Now() endTime := time.Now()
if mockTests { if mockTests {
startTime = time.Date(2020, 9, 1, 0, 0, 0, 0, time.UTC) startTime = time.Date(2020, 9, 1, 0, 0, 0, 0, time.UTC)
endTime = time.Date(2020, 9, 2, 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 + 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 { if mockTests {
t.Skip("skipping authenticated function for mock testing") t.Skip("skipping authenticated function for mock testing")
} }
``` ```
### Please click GoDocs chevron above to view current GoDoc information for this package ### Please click GoDocs chevron above to view current GoDoc information for this package
## Contribution ## Contribution
Please feel free to submit any pull requests or suggest any desired features to be added. 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: 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 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 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). + 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. + Pull requests need to be based on and opened against the `master` branch.
## Donations ## Donations
<img src="https://github.com/thrasher-corp/gocryptotrader/blob/master/web/src/assets/donate.png?raw=true" hspace="70"> <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: If this framework helped you in any way, or you would like to support the developers working on it, please donate Bitcoin to:
***bc1qk0jareu4jytc0cfrhr5wgshsq8282awpavfahc*** ***bc1qk0jareu4jytc0cfrhr5wgshsq8282awpavfahc***

View File

@@ -23,7 +23,11 @@ const (
defaultTrafficPeriod = time.Second 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 // New initialises the websocket struct
func New() *Websocket { func New() *Websocket {
@@ -210,7 +214,7 @@ func (w *Websocket) Connect() error {
if len(w.subscriptions) != 0 { if len(w.subscriptions) != 0 {
err = w.Subscriber(w.subscriptions) err = w.Subscriber(w.subscriptions)
if err != nil { 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 // AddSuccessfulSubscriptions adds subscriptions to the subscription lists that

View File

@@ -63,7 +63,7 @@ _b in this context is an `IBotExchange` implemented struct_
| Binance| Yes | Yes | Yes | | Binance| Yes | Yes | Yes |
| Bitfinex | Yes | Yes | Yes | | Bitfinex | Yes | Yes | Yes |
| Bitflyer | Yes | No | No | | Bitflyer | Yes | No | No |
| Bithumb | Yes | NA | No | | Bithumb | Yes | Yes | No |
| BitMEX | Yes | Yes | Yes | | BitMEX | Yes | Yes | Yes |
| Bitstamp | Yes | Yes | No | | Bitstamp | Yes | Yes | No |
| Bittrex | Yes | Yes | No | | Bittrex | Yes | Yes | No |

File diff suppressed because it is too large Load Diff

View File

@@ -61,7 +61,7 @@ message GetExchangesResponse {
string exchanges = 1; string exchanges = 1;
} }
message GetExchangeOTPReponse { message GetExchangeOTPResponse {
string otp_code = 1; string otp_code = 1;
} }
@@ -1016,7 +1016,7 @@ service GoCryptoTrader {
}; };
} }
rpc GetExchangeOTPCode (GenericExchangeNameRequest) returns (GetExchangeOTPReponse) { rpc GetExchangeOTPCode (GenericExchangeNameRequest) returns (GetExchangeOTPResponse) {
option (google.api.http) = { option (google.api.http) = {
get: "/v1/getexchangeotp" get: "/v1/getexchangeotp"
}; };

View File

@@ -1285,7 +1285,7 @@
"200": { "200": {
"description": "A successful response.", "description": "A successful response.",
"schema": { "schema": {
"$ref": "#/definitions/gctrpcGetExchangeOTPReponse" "$ref": "#/definitions/gctrpcGetExchangeOTPResponse"
} }
}, },
"default": { "default": {
@@ -3793,7 +3793,7 @@
} }
} }
}, },
"gctrpcGetExchangeOTPReponse": { "gctrpcGetExchangeOTPResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"otpCode": { "otpCode": {

View File

@@ -27,7 +27,7 @@ type GoCryptoTraderClient interface {
GetExchanges(ctx context.Context, in *GetExchangesRequest, opts ...grpc.CallOption) (*GetExchangesResponse, error) GetExchanges(ctx context.Context, in *GetExchangesRequest, opts ...grpc.CallOption) (*GetExchangesResponse, error)
DisableExchange(ctx context.Context, in *GenericExchangeNameRequest, opts ...grpc.CallOption) (*GenericResponse, error) DisableExchange(ctx context.Context, in *GenericExchangeNameRequest, opts ...grpc.CallOption) (*GenericResponse, error)
GetExchangeInfo(ctx context.Context, in *GenericExchangeNameRequest, opts ...grpc.CallOption) (*GetExchangeInfoResponse, error) GetExchangeInfo(ctx context.Context, in *GenericExchangeNameRequest, opts ...grpc.CallOption) (*GetExchangeInfoResponse, error)
GetExchangeOTPCode(ctx context.Context, in *GenericExchangeNameRequest, opts ...grpc.CallOption) (*GetExchangeOTPReponse, error) GetExchangeOTPCode(ctx context.Context, in *GenericExchangeNameRequest, opts ...grpc.CallOption) (*GetExchangeOTPResponse, error)
GetExchangeOTPCodes(ctx context.Context, in *GetExchangeOTPsRequest, opts ...grpc.CallOption) (*GetExchangeOTPsResponse, error) GetExchangeOTPCodes(ctx context.Context, in *GetExchangeOTPsRequest, opts ...grpc.CallOption) (*GetExchangeOTPsResponse, error)
EnableExchange(ctx context.Context, in *GenericExchangeNameRequest, opts ...grpc.CallOption) (*GenericResponse, error) EnableExchange(ctx context.Context, in *GenericExchangeNameRequest, opts ...grpc.CallOption) (*GenericResponse, error)
GetTicker(ctx context.Context, in *GetTickerRequest, opts ...grpc.CallOption) (*TickerResponse, error) GetTicker(ctx context.Context, in *GetTickerRequest, opts ...grpc.CallOption) (*TickerResponse, error)
@@ -197,8 +197,8 @@ func (c *goCryptoTraderClient) GetExchangeInfo(ctx context.Context, in *GenericE
return out, nil return out, nil
} }
func (c *goCryptoTraderClient) GetExchangeOTPCode(ctx context.Context, in *GenericExchangeNameRequest, opts ...grpc.CallOption) (*GetExchangeOTPReponse, error) { func (c *goCryptoTraderClient) GetExchangeOTPCode(ctx context.Context, in *GenericExchangeNameRequest, opts ...grpc.CallOption) (*GetExchangeOTPResponse, error) {
out := new(GetExchangeOTPReponse) out := new(GetExchangeOTPResponse)
err := c.cc.Invoke(ctx, "/gctrpc.GoCryptoTrader/GetExchangeOTPCode", in, out, opts...) err := c.cc.Invoke(ctx, "/gctrpc.GoCryptoTrader/GetExchangeOTPCode", in, out, opts...)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1059,7 +1059,7 @@ type GoCryptoTraderServer interface {
GetExchanges(context.Context, *GetExchangesRequest) (*GetExchangesResponse, error) GetExchanges(context.Context, *GetExchangesRequest) (*GetExchangesResponse, error)
DisableExchange(context.Context, *GenericExchangeNameRequest) (*GenericResponse, error) DisableExchange(context.Context, *GenericExchangeNameRequest) (*GenericResponse, error)
GetExchangeInfo(context.Context, *GenericExchangeNameRequest) (*GetExchangeInfoResponse, error) GetExchangeInfo(context.Context, *GenericExchangeNameRequest) (*GetExchangeInfoResponse, error)
GetExchangeOTPCode(context.Context, *GenericExchangeNameRequest) (*GetExchangeOTPReponse, error) GetExchangeOTPCode(context.Context, *GenericExchangeNameRequest) (*GetExchangeOTPResponse, error)
GetExchangeOTPCodes(context.Context, *GetExchangeOTPsRequest) (*GetExchangeOTPsResponse, error) GetExchangeOTPCodes(context.Context, *GetExchangeOTPsRequest) (*GetExchangeOTPsResponse, error)
EnableExchange(context.Context, *GenericExchangeNameRequest) (*GenericResponse, error) EnableExchange(context.Context, *GenericExchangeNameRequest) (*GenericResponse, error)
GetTicker(context.Context, *GetTickerRequest) (*TickerResponse, error) GetTicker(context.Context, *GetTickerRequest) (*TickerResponse, error)
@@ -1172,7 +1172,7 @@ func (UnimplementedGoCryptoTraderServer) DisableExchange(context.Context, *Gener
func (UnimplementedGoCryptoTraderServer) GetExchangeInfo(context.Context, *GenericExchangeNameRequest) (*GetExchangeInfoResponse, error) { func (UnimplementedGoCryptoTraderServer) GetExchangeInfo(context.Context, *GenericExchangeNameRequest) (*GetExchangeInfoResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetExchangeInfo not implemented") return nil, status.Errorf(codes.Unimplemented, "method GetExchangeInfo not implemented")
} }
func (UnimplementedGoCryptoTraderServer) GetExchangeOTPCode(context.Context, *GenericExchangeNameRequest) (*GetExchangeOTPReponse, error) { func (UnimplementedGoCryptoTraderServer) GetExchangeOTPCode(context.Context, *GenericExchangeNameRequest) (*GetExchangeOTPResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetExchangeOTPCode not implemented") return nil, status.Errorf(codes.Unimplemented, "method GetExchangeOTPCode not implemented")
} }
func (UnimplementedGoCryptoTraderServer) GetExchangeOTPCodes(context.Context, *GetExchangeOTPsRequest) (*GetExchangeOTPsResponse, error) { func (UnimplementedGoCryptoTraderServer) GetExchangeOTPCodes(context.Context, *GetExchangeOTPsRequest) (*GetExchangeOTPsResponse, error) {