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

@@ -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

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/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

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
<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)
[![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)
[![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)
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">
[![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)
[![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)
[![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 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***

View File

@@ -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

View File

@@ -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 |