From 5cf0aedc67eff89a8f82082326f878844ac7b5d5 Mon Sep 17 00:00:00 2001 From: Nikhil Saraf <1028334+nikhilsaraf@users.noreply.github.com> Date: Wed, 9 Jan 2019 14:31:04 -0800 Subject: [PATCH] Complete trading APIs for CCXT (#85), closes #11 * ccxt_trading: 1 - thread through API Key - testing to get API key to work * ccxt_trading: 2 - instance creation logic * ccxt_trading: 3 - FetchBalance() implementation for ccxt + test * ccxt_trading: 4 - implement ccxtExchange#GetAccountBalances() * ccxt_trading: 5 - fix GetOpenOrders API to supply the tradingPairs as input * ccxt_trading: 6 - implement ccxt.FetchOpenOrders + test * ccxt_trading: 7 - ccxtExchange#GetOpenOrders() + test * ccxt_trading: 8 - ccxt.CreateLimitOrder() + test * ccxt_trading: 9 - ccxtExchange.AddOrder() + test * ccxt_trading: 10 - ccxt.CancelOrder() + test * ccxt_trading: 11 - update CancelOrder Exchange API + ccxtExchange.CancelOrder() + test * ccxt_trading: 12 - clean up ccxtExchange tests * ccxt_trading: 13 - enable trading on binance * ccxt_trading: 14 - use orderConstraints in ccxtExchange * ccxt_trading: 15 - add support for fetching trade history --- api/exchange.go | 6 +- glide.lock | 6 +- glide.yaml | 2 + model/orderbook.go | 8 +- model/tradingPair.go | 13 ++ plugins/ccxtExchange.go | 219 +++++++++++++++--- plugins/ccxtExchange_test.go | 301 ++++++++++++++++++++++-- plugins/factory.go | 33 ++- plugins/fillTracker.go | 2 +- plugins/krakenExchange.go | 52 +++-- plugins/krakenExchange_test.go | 9 +- plugins/sdex.go | 8 +- support/sdk/ccxt.go | 287 +++++++++++++++++++++-- support/sdk/ccxt_test.go | 412 +++++++++++++++++++++++++++++++-- 14 files changed, 1229 insertions(+), 129 deletions(-) diff --git a/api/exchange.go b/api/exchange.go index c56393068..c64c58190 100644 --- a/api/exchange.go +++ b/api/exchange.go @@ -58,7 +58,7 @@ type FillHandler interface { // TradeFetcher is the common method between FillTrackable and exchange // temporarily extracted out from TradeAPI so SDEX has the flexibility to only implement this rather than exchange and FillTrackable type TradeFetcher interface { - GetTradeHistory(maybeCursorStart interface{}, maybeCursorEnd interface{}) (*TradeHistoryResult, error) + GetTradeHistory(pair model.TradingPair, maybeCursorStart interface{}, maybeCursorEnd interface{}) (*TradeHistoryResult, error) } // FillTrackable enables any implementing exchange to support fill tracking @@ -85,11 +85,11 @@ type TradeAPI interface { TradeFetcher - GetOpenOrders() (map[model.TradingPair][]model.OpenOrder, error) + GetOpenOrders(pairs []*model.TradingPair) (map[model.TradingPair][]model.OpenOrder, error) AddOrder(order *model.Order) (*model.TransactionID, error) - CancelOrder(txID *model.TransactionID) (model.CancelOrderResult, error) + CancelOrder(txID *model.TransactionID, pair model.TradingPair) (model.CancelOrderResult, error) } // PrepareDepositResult is the result of a PrepareDeposit call diff --git a/glide.lock b/glide.lock index 69b8b37c5..acad24ab8 100644 --- a/glide.lock +++ b/glide.lock @@ -1,5 +1,5 @@ -hash: ae83d6a72fa620aca2edba83aa180580ecf8d4ffa1d6c96212e10492a857f5db -updated: 2018-11-30T16:56:13.813823058-08:00 +hash: 846bc46b777e613b79332fe3dd038931e16d76b8b9da1c096bb2082fc232b82e +updated: 2018-12-06T12:00:37.303778558-08:00 imports: - name: cloud.google.com/go version: 793297ec250352b0ece46e103381a0fc3dab95a1 @@ -41,6 +41,8 @@ imports: - oid - name: github.com/manucorporat/sse version: ee05b128a739a0fb76c7ebd3ae4810c1de808d6d +- name: github.com/mitchellh/mapstructure + version: 3536a929edddb9a5b34bd6861dc4a9647cb459fe - name: github.com/nikhilsaraf/go-tools version: 19004f22be08c82a22e679726ca22853c65919ae subpackages: diff --git a/glide.yaml b/glide.yaml index 3e817081c..ad8d0a9fa 100644 --- a/glide.yaml +++ b/glide.yaml @@ -21,3 +21,5 @@ import: version: 7595ba02fbce171759c10d69d96e4cd898d1fa93 - package: github.com/nikhilsaraf/go-tools version: 19004f22be08c82a22e679726ca22853c65919ae +- package: github.com/mitchellh/mapstructure + version: v1.1.2 diff --git a/model/orderbook.go b/model/orderbook.go index 3ac091759..94ada24e9 100644 --- a/model/orderbook.go +++ b/model/orderbook.go @@ -175,11 +175,15 @@ type OpenOrder struct { // String is the stringer function func (o OpenOrder) String() string { - return fmt.Sprintf("OpenOrder[order=%s, ID=%s, startTime=%d, expireTime=%d, volumeExecuted=%s]", + expireTimeString := "" + if o.ExpireTime != nil { + expireTimeString = fmt.Sprintf("%d", o.ExpireTime.AsInt64()) + } + return fmt.Sprintf("OpenOrder[order=%s, ID=%s, startTime=%d, expireTime=%s, volumeExecuted=%s]", o.Order.String(), o.ID, o.StartTime.AsInt64(), - o.ExpireTime.AsInt64(), + expireTimeString, o.VolumeExecuted.AsString(), ) } diff --git a/model/tradingPair.go b/model/tradingPair.go index d353ea88c..8e7a6eb7a 100644 --- a/model/tradingPair.go +++ b/model/tradingPair.go @@ -73,3 +73,16 @@ func TradingPairs2Strings(c *AssetConverter, delim string, pairs []TradingPair) } return m, nil } + +// TradingPairs2Strings2 converts the trading pairs to an array of strings +func TradingPairs2Strings2(c *AssetConverter, delim string, pairs []*TradingPair) (map[TradingPair]string, error) { + m := map[TradingPair]string{} + for _, p := range pairs { + pairString, e := p.ToString(c, delim) + if e != nil { + return nil, e + } + m[*p] = pairString + } + return m, nil +} diff --git a/plugins/ccxtExchange.go b/plugins/ccxtExchange.go index 48900204b..0b2cc76c6 100644 --- a/plugins/ccxtExchange.go +++ b/plugins/ccxtExchange.go @@ -2,6 +2,7 @@ package plugins import ( "fmt" + "log" "github.com/interstellar/kelp/api" "github.com/interstellar/kelp/model" @@ -9,31 +10,43 @@ import ( "github.com/interstellar/kelp/support/utils" ) +const ccxtBalancePrecision = 10 + // ensure that ccxtExchange conforms to the Exchange interface var _ api.Exchange = ccxtExchange{} // ccxtExchange is the implementation for the CCXT REST library that supports many exchanges (https://github.com/franz-see/ccxt-rest, https://github.com/ccxt/ccxt/) type ccxtExchange struct { - assetConverter *model.AssetConverter - delimiter string - api *sdk.Ccxt - precision int8 - simMode bool + assetConverter *model.AssetConverter + delimiter string + orderConstraints map[model.TradingPair]model.OrderConstraints + api *sdk.Ccxt + simMode bool } // makeCcxtExchange is a factory method to make an exchange using the CCXT interface -func makeCcxtExchange(ccxtBaseURL string, exchangeName string, simMode bool) (api.Exchange, error) { - c, e := sdk.MakeInitializedCcxtExchange(ccxtBaseURL, exchangeName) +func makeCcxtExchange( + ccxtBaseURL string, + exchangeName string, + orderConstraints map[model.TradingPair]model.OrderConstraints, + apiKeys []api.ExchangeAPIKey, + simMode bool, +) (api.Exchange, error) { + if len(apiKeys) == 0 { + return nil, fmt.Errorf("need at least 1 ExchangeAPIKey, even if it is an empty key") + } + + c, e := sdk.MakeInitializedCcxtExchange(ccxtBaseURL, exchangeName, apiKeys[0]) if e != nil { return nil, fmt.Errorf("error making a ccxt exchange: %s", e) } return ccxtExchange{ - assetConverter: model.CcxtAssetConverter, - delimiter: "/", - api: c, - precision: utils.SdexPrecision, - simMode: simMode, + assetConverter: model.CcxtAssetConverter, + delimiter: "/", + orderConstraints: orderConstraints, + api: c, + simMode: simMode, }, nil } @@ -61,8 +74,8 @@ func (c ccxtExchange) GetTickerPrice(pairs []model.TradingPair) (map[model.Tradi } priceResult[p] = api.Ticker{ - AskPrice: model.NumberFromFloat(askPrice, c.precision), - BidPrice: model.NumberFromFloat(bidPrice, c.precision), + AskPrice: model.NumberFromFloat(askPrice, c.orderConstraints[p].PricePrecision), + BidPrice: model.NumberFromFloat(bidPrice, c.orderConstraints[p].PricePrecision), } } @@ -76,14 +89,31 @@ func (c ccxtExchange) GetAssetConverter() *model.AssetConverter { // GetOrderConstraints impl func (c ccxtExchange) GetOrderConstraints(pair *model.TradingPair) *model.OrderConstraints { - // TODO implement - return nil + oc := c.orderConstraints[*pair] + return &oc } // GetAccountBalances impl func (c ccxtExchange) GetAccountBalances(assetList []model.Asset) (map[model.Asset]model.Number, error) { - // TODO implement - return nil, nil + balanceResponse, e := c.api.FetchBalance() + if e != nil { + return nil, e + } + + m := map[model.Asset]model.Number{} + for _, asset := range assetList { + ccxtAssetString, e := c.GetAssetConverter().ToString(asset) + if e != nil { + return nil, e + } + + if ccxtBalance, ok := balanceResponse[ccxtAssetString]; ok { + m[asset] = *model.NumberFromFloat(ccxtBalance.Total, ccxtBalancePrecision) + } else { + m[asset] = *model.NumberConstants.Zero + } + } + return m, nil } // GetOrderBook impl @@ -112,20 +142,52 @@ func (c ccxtExchange) GetOrderBook(pair *model.TradingPair, maxCount int32) (*mo } func (c ccxtExchange) readOrders(orders []sdk.CcxtOrder, pair *model.TradingPair, orderAction model.OrderAction) []model.Order { + pricePrecision := c.orderConstraints[*pair].PricePrecision + volumePrecision := c.orderConstraints[*pair].VolumePrecision + result := []model.Order{} for _, o := range orders { result = append(result, model.Order{ Pair: pair, OrderAction: orderAction, OrderType: model.OrderTypeLimit, - Price: model.NumberFromFloat(o.Price, c.precision), - Volume: model.NumberFromFloat(o.Amount, c.precision), + Price: model.NumberFromFloat(o.Price, pricePrecision), + Volume: model.NumberFromFloat(o.Amount, volumePrecision), Timestamp: nil, }) } return result } +// GetTradeHistory impl +func (c ccxtExchange) GetTradeHistory(pair model.TradingPair, maybeCursorStart interface{}, maybeCursorEnd interface{}) (*api.TradeHistoryResult, error) { + pairString, e := pair.ToString(c.assetConverter, c.delimiter) + if e != nil { + return nil, fmt.Errorf("error converting pair to string: %s", e) + } + + // TODO use cursor when fetching trade history + tradesRaw, e := c.api.FetchMyTrades(pairString) + if e != nil { + return nil, fmt.Errorf("error while fetching trade history for trading pair '%s': %s", pairString, e) + } + + trades := []model.Trade{} + for _, raw := range tradesRaw { + t, e := c.readTrade(&pair, pairString, raw) + if e != nil { + return nil, fmt.Errorf("error while reading trade: %s", e) + } + trades = append(trades, *t) + } + + // TODO implement cursor logic + return &api.TradeHistoryResult{ + Cursor: nil, + Trades: trades, + }, nil +} + // GetTrades impl func (c ccxtExchange) GetTrades(pair *model.TradingPair, maybeCursor interface{}) (*api.TradesResult, error) { pairString, e := pair.ToString(c.assetConverter, c.delimiter) @@ -160,11 +222,14 @@ func (c ccxtExchange) readTrade(pair *model.TradingPair, pairString string, rawT return nil, fmt.Errorf("expected '%s' for 'symbol' field, got: %s", pairString, rawTrade.Symbol) } + pricePrecision := c.orderConstraints[*pair].PricePrecision + volumePrecision := c.orderConstraints[*pair].VolumePrecision + trade := model.Trade{ Order: model.Order{ Pair: pair, - Price: model.NumberFromFloat(rawTrade.Price, c.precision), - Volume: model.NumberFromFloat(rawTrade.Amount, c.precision), + Price: model.NumberFromFloat(rawTrade.Price, pricePrecision), + Volume: model.NumberFromFloat(rawTrade.Amount, volumePrecision), OrderType: model.OrderTypeLimit, Timestamp: model.MakeTimestamp(rawTrade.Timestamp), }, @@ -181,34 +246,116 @@ func (c ccxtExchange) readTrade(pair *model.TradingPair, pairString string, rawT } if rawTrade.Cost != 0.0 { - // use 2x the precision for cost since it's logically derived from amount and price - trade.Cost = model.NumberFromFloat(rawTrade.Cost, c.precision*2) + // use bigger precision for cost since it's logically derived from amount and price + costPrecision := pricePrecision + if volumePrecision > pricePrecision { + costPrecision = volumePrecision + } + trade.Cost = model.NumberFromFloat(rawTrade.Cost, costPrecision) } return &trade, nil } -// GetTradeHistory impl -func (c ccxtExchange) GetTradeHistory(maybeCursorStart interface{}, maybeCursorEnd interface{}) (*api.TradeHistoryResult, error) { - // TODO implement - return nil, nil +// GetOpenOrders impl +func (c ccxtExchange) GetOpenOrders(pairs []*model.TradingPair) (map[model.TradingPair][]model.OpenOrder, error) { + pairStrings := []string{} + string2Pair := map[string]model.TradingPair{} + for _, pair := range pairs { + pairString, e := pair.ToString(c.assetConverter, c.delimiter) + if e != nil { + return nil, fmt.Errorf("error converting pairs to strings: %s", e) + } + pairStrings = append(pairStrings, pairString) + string2Pair[pairString] = *pair + } + + openOrdersMap, e := c.api.FetchOpenOrders(pairStrings) + if e != nil { + return nil, fmt.Errorf("error while fetching open orders for trading pairs '%v': %s", pairStrings, e) + } + + result := map[model.TradingPair][]model.OpenOrder{} + for asset, ccxtOrderList := range openOrdersMap { + pair, ok := string2Pair[asset] + if !ok { + return nil, fmt.Errorf("traing symbol %s returned from FetchOpenOrders was not in the original list of trading pairs: %v", asset, pairStrings) + } + + openOrderList := []model.OpenOrder{} + for _, o := range ccxtOrderList { + openOrder, e := c.convertOpenOrderFromCcxt(&pair, o) + if e != nil { + return nil, fmt.Errorf("cannot convertOpenOrderFromCcxt: %s", e) + } + openOrderList = append(openOrderList, *openOrder) + } + result[pair] = openOrderList + } + return result, nil } -// GetOpenOrders impl -func (c ccxtExchange) GetOpenOrders() (map[model.TradingPair][]model.OpenOrder, error) { - // TODO implement - return nil, nil +func (c ccxtExchange) convertOpenOrderFromCcxt(pair *model.TradingPair, o sdk.CcxtOpenOrder) (*model.OpenOrder, error) { + if o.Type != "limit" { + return nil, fmt.Errorf("we currently only support limit order types") + } + + orderAction := model.OrderActionSell + if o.Side == "buy" { + orderAction = model.OrderActionBuy + } + ts := model.MakeTimestamp(o.Timestamp) + + return &model.OpenOrder{ + Order: model.Order{ + Pair: pair, + OrderAction: orderAction, + OrderType: model.OrderTypeLimit, + Price: model.NumberFromFloat(o.Price, c.orderConstraints[*pair].PricePrecision), + Volume: model.NumberFromFloat(o.Amount, c.orderConstraints[*pair].VolumePrecision), + Timestamp: ts, + }, + ID: o.ID, + StartTime: ts, + ExpireTime: nil, + VolumeExecuted: model.NumberFromFloat(o.Filled, c.orderConstraints[*pair].VolumePrecision), + }, nil } // AddOrder impl func (c ccxtExchange) AddOrder(order *model.Order) (*model.TransactionID, error) { - // TODO implement - return nil, nil + pairString, e := order.Pair.ToString(c.assetConverter, c.delimiter) + if e != nil { + return nil, fmt.Errorf("error converting pair to string: %s", e) + } + + side := "sell" + if order.OrderAction.IsBuy() { + side = "buy" + } + + log.Printf("ccxt is submitting order: pair=%s, orderAction=%s, orderType=%s, volume=%s, price=%s\n", + pairString, order.OrderAction.String(), order.OrderType.String(), order.Volume.AsString(), order.Price.AsString()) + ccxtOpenOrder, e := c.api.CreateLimitOrder(pairString, side, order.Volume.AsFloat(), order.Price.AsFloat()) + if e != nil { + return nil, fmt.Errorf("error while creating limit order %s: %s", *order, e) + } + + return model.MakeTransactionID(ccxtOpenOrder.ID), nil } // CancelOrder impl -func (c ccxtExchange) CancelOrder(txID *model.TransactionID) (model.CancelOrderResult, error) { - // TODO implement +func (c ccxtExchange) CancelOrder(txID *model.TransactionID, pair model.TradingPair) (model.CancelOrderResult, error) { + log.Printf("ccxt is canceling order: ID=%s, tradingPair: %s\n", txID.String(), pair.String()) + + resp, e := c.api.CancelOrder(txID.String(), pair.String()) + if e != nil { + return model.CancelResultFailed, e + } + + if resp == nil { + return model.CancelResultFailed, fmt.Errorf("response from CancelOrder was nil") + } return model.CancelResultCancelSuccessful, nil } diff --git a/plugins/ccxtExchange_test.go b/plugins/ccxtExchange_test.go index 5fedd66db..569e9c9c6 100644 --- a/plugins/ccxtExchange_test.go +++ b/plugins/ccxtExchange_test.go @@ -2,13 +2,24 @@ package plugins import ( "fmt" + "log" "testing" + "github.com/interstellar/kelp/api" "github.com/interstellar/kelp/model" "github.com/stretchr/testify/assert" ) -var supportedExchanges = []string{"binance", "poloniex", "bittrex"} +var supportedExchanges = []string{"binance"} +var emptyAPIKey = api.ExchangeAPIKey{} +var supportedTradingExchanges = map[string]api.ExchangeAPIKey{ + "binance": api.ExchangeAPIKey{}, +} + +var testOrderConstraints = map[model.TradingPair]model.OrderConstraints{ + *model.MakeTradingPair(model.XLM, model.USDT): *model.MakeOrderConstraints(4, 5, 0.1), + *model.MakeTradingPair(model.XLM, model.BTC): *model.MakeOrderConstraints(8, 4, 1.0), +} func TestGetTickerPrice_Ccxt(t *testing.T) { if testing.Short() { @@ -17,7 +28,7 @@ func TestGetTickerPrice_Ccxt(t *testing.T) { for _, exchangeName := range supportedExchanges { t.Run(exchangeName, func(t *testing.T) { - testCcxtExchange, e := makeCcxtExchange("http://localhost:3000", exchangeName, false) + testCcxtExchange, e := makeCcxtExchange("http://localhost:3000", exchangeName, testOrderConstraints, []api.ExchangeAPIKey{emptyAPIKey}, false) if !assert.NoError(t, e) { return } @@ -44,7 +55,7 @@ func TestGetOrderBook_Ccxt(t *testing.T) { for _, exchangeName := range supportedExchanges { t.Run(exchangeName, func(t *testing.T) { - testCcxtExchange, e := makeCcxtExchange("http://localhost:3000", exchangeName, false) + testCcxtExchange, e := makeCcxtExchange("http://localhost:3000", exchangeName, testOrderConstraints, []api.ExchangeAPIKey{emptyAPIKey}, false) if !assert.NoError(t, e) { return } @@ -77,7 +88,7 @@ func TestGetTrades_Ccxt(t *testing.T) { for _, exchangeName := range supportedExchanges { t.Run(exchangeName, func(t *testing.T) { - testCcxtExchange, e := makeCcxtExchange("http://localhost:3000", exchangeName, false) + testCcxtExchange, e := makeCcxtExchange("http://localhost:3000", exchangeName, testOrderConstraints, []api.ExchangeAPIKey{emptyAPIKey}, false) if !assert.NoError(t, e) { return } @@ -90,36 +101,292 @@ func TestGetTrades_Ccxt(t *testing.T) { } assert.Equal(t, nil, tradeResult.Cursor) - for _, trade := range tradeResult.Trades { - if !assert.Equal(t, &pair, trade.Pair) { + validateTrades(t, pair, tradeResult.Trades) + }) + } +} + +func TestGetTradeHistory_Ccxt(t *testing.T) { + if testing.Short() { + return + } + + for exchangeName, apiKey := range supportedTradingExchanges { + t.Run(exchangeName, func(t *testing.T) { + testCcxtExchange, e := makeCcxtExchange("http://localhost:3000", exchangeName, testOrderConstraints, []api.ExchangeAPIKey{apiKey}, false) + if !assert.NoError(t, e) { + return + } + + pair := model.TradingPair{Base: model.XLM, Quote: model.BTC} + // TODO test with cursor once implemented + tradeHistoryResult, e := testCcxtExchange.GetTradeHistory(pair, nil, nil) + if !assert.NoError(t, e) { + return + } + assert.Equal(t, nil, tradeHistoryResult.Cursor) + + validateTrades(t, pair, tradeHistoryResult.Trades) + }) + } +} + +func validateTrades(t *testing.T, pair model.TradingPair, trades []model.Trade) { + for _, trade := range trades { + if !assert.Equal(t, &pair, trade.Pair) { + return + } + if !assert.True(t, trade.Price.AsFloat() > 0, fmt.Sprintf("%.7f", trade.Price.AsFloat())) { + return + } + if !assert.True(t, trade.Volume.AsFloat() > 0, fmt.Sprintf("%.7f", trade.Volume.AsFloat())) { + return + } + if !assert.Equal(t, trade.OrderType, model.OrderTypeLimit) { + return + } + if !assert.True(t, trade.Timestamp.AsInt64() > 0, fmt.Sprintf("%d", trade.Timestamp.AsInt64())) { + return + } + if !assert.NotNil(t, trade.TransactionID) { + return + } + if !assert.Nil(t, trade.Fee) { + return + } + if trade.OrderAction != model.OrderActionBuy && trade.OrderAction != model.OrderActionSell { + assert.Fail(t, "trade.OrderAction should be either OrderActionBuy or OrderActionSell: %v", trade.OrderAction) + return + } + if trade.Cost != nil && !assert.True(t, trade.Cost.AsFloat() > 0, fmt.Sprintf("%s x %s = %s", trade.Price.AsString(), trade.Volume.AsString(), trade.Cost.AsString())) { + return + } + } +} + +func TestGetAccountBalances_Ccxt(t *testing.T) { + if testing.Short() { + return + } + + for exchangeName, apiKey := range supportedTradingExchanges { + t.Run(exchangeName, func(t *testing.T) { + testCcxtExchange, e := makeCcxtExchange("http://localhost:3000", exchangeName, testOrderConstraints, []api.ExchangeAPIKey{apiKey}, false) + if !assert.NoError(t, e) { + return + } + + balances, e := testCcxtExchange.GetAccountBalances([]model.Asset{ + model.XLM, + model.BTC, + model.USD, + }) + if !assert.NoError(t, e) { + return + } + + log.Printf("balances: %+v\n", balances) + if !assert.Equal(t, 20.0, balances[model.XLM].AsFloat()) { + return + } + + if !assert.Equal(t, 0.0, balances[model.BTC].AsFloat()) { + return + } + + if !assert.Equal(t, 0.0, balances[model.USD].AsFloat()) { + return + } + + assert.Fail(t, "force fail") + }) + } +} + +func TestGetOpenOrders_Ccxt(t *testing.T) { + if testing.Short() { + return + } + + tradingPairs := []model.TradingPair{ + model.TradingPair{Base: model.XLM, Quote: model.BTC}, + model.TradingPair{Base: model.XLM, Quote: model.USDT}, + } + + for exchangeName, apiKey := range supportedTradingExchanges { + for _, pair := range tradingPairs { + t.Run(exchangeName, func(t *testing.T) { + testCcxtExchange, e := makeCcxtExchange("http://localhost:3000", exchangeName, testOrderConstraints, []api.ExchangeAPIKey{apiKey}, false) + if !assert.NoError(t, e) { return } - if !assert.True(t, trade.Price.AsFloat() > 0, fmt.Sprintf("%.7f", trade.Price.AsFloat())) { + + m, e := testCcxtExchange.GetOpenOrders([]*model.TradingPair{&pair}) + if !assert.NoError(t, e) { return } - if !assert.True(t, trade.Volume.AsFloat() > 0, fmt.Sprintf("%.7f", trade.Volume.AsFloat())) { + + if !assert.Equal(t, 1, len(m)) { return } - if !assert.Equal(t, trade.OrderType, model.OrderTypeLimit) { + + openOrders := m[pair] + if !assert.True(t, len(openOrders) > 0, fmt.Sprintf("%d", len(openOrders))) { return } - if !assert.True(t, trade.Timestamp.AsInt64() > 0, fmt.Sprintf("%d", trade.Timestamp.AsInt64())) { + + for _, o := range openOrders { + log.Printf("open order: %+v\n", o) + + if !assert.Equal(t, &pair, o.Order.Pair) { + return + } + + // OrderAction has it's underlying type as a boolean so will always be valid + + if !assert.Equal(t, model.OrderTypeLimit, o.Order.OrderType) { + return + } + + if !assert.True(t, o.Order.Price.AsFloat() > 0, o.Order.Price.AsString()) { + return + } + + if !assert.True(t, o.Order.Volume.AsFloat() > 0, o.Order.Volume.AsString()) { + return + } + + if !assert.NotNil(t, o.Order.Timestamp) { + return + } + + if !assert.True(t, len(o.ID) > 0, o.ID) { + return + } + + if !assert.NotNil(t, o.StartTime) { + return + } + + // ExpireTime is always nil for now + if !assert.Nil(t, o.ExpireTime) { + return + } + + if !assert.NotNil(t, o.VolumeExecuted) { + return + } + + // additional check to see if the two timestamps match + if !assert.Equal(t, o.Order.Timestamp, o.StartTime) { + return + } + } + assert.Fail(t, "force fail") + }) + } + } +} + +func TestAddOrder_Ccxt(t *testing.T) { + if testing.Short() { + return + } + + for exchangeName, apiKey := range supportedTradingExchanges { + for _, kase := range []struct { + pair *model.TradingPair + orderAction model.OrderAction + orderType model.OrderType + price *model.Number + volume *model.Number + }{ + { + pair: &model.TradingPair{Base: model.XLM, Quote: model.BTC}, + orderAction: model.OrderActionSell, + orderType: model.OrderTypeLimit, + price: model.NumberFromFloat(0.000041, 6), + volume: model.NumberFromFloat(60.12345678, 6), + }, { + pair: &model.TradingPair{Base: model.XLM, Quote: model.BTC}, + orderAction: model.OrderActionBuy, + orderType: model.OrderTypeLimit, + price: model.NumberFromFloat(0.000026, 6), + volume: model.NumberFromFloat(40.012345, 6), + }, { + pair: &model.TradingPair{Base: model.XLM, Quote: model.USDT}, + orderAction: model.OrderActionSell, + orderType: model.OrderTypeLimit, + price: model.NumberFromFloat(0.15, 6), + volume: model.NumberFromFloat(51.5, 6), + }, + } { + t.Run(exchangeName, func(t *testing.T) { + testCcxtExchange, e := makeCcxtExchange("http://localhost:3000", exchangeName, testOrderConstraints, []api.ExchangeAPIKey{apiKey}, false) + if !assert.NoError(t, e) { return } - if !assert.NotNil(t, trade.TransactionID) { + + txID, e := testCcxtExchange.AddOrder(&model.Order{ + Pair: kase.pair, + OrderAction: kase.orderAction, + OrderType: kase.orderType, + Price: kase.price, + Volume: kase.volume, + }) + if !assert.NoError(t, e) { return } - if !assert.Nil(t, trade.Fee) { + + log.Printf("transactionID from order: %s\n", txID) + if !assert.NotNil(t, txID) { return } - if trade.OrderAction != model.OrderActionBuy && trade.OrderAction != model.OrderActionSell { - assert.Fail(t, "trade.OrderAction should be either OrderActionBuy or OrderActionSell: %v", trade.OrderAction) + + if !assert.NotEqual(t, "", txID.String()) { return } - if trade.Cost != nil && !assert.True(t, trade.Cost.AsFloat() > 0, fmt.Sprintf("%.7f", trade.Cost.AsFloat())) { + + assert.Fail(t, "force fail") + }) + } + } +} + +func TestCancelOrder_Ccxt(t *testing.T) { + if testing.Short() { + return + } + + for exchangeName, apiKey := range supportedTradingExchanges { + for _, kase := range []struct { + orderID string + pair *model.TradingPair + }{ + { + orderID: "", + pair: &model.TradingPair{Base: model.XLM, Quote: model.BTC}, + }, { + orderID: "", + pair: &model.TradingPair{Base: model.XLM, Quote: model.USDT}, + }, + } { + t.Run(exchangeName, func(t *testing.T) { + testCcxtExchange, e := makeCcxtExchange("http://localhost:3000", exchangeName, testOrderConstraints, []api.ExchangeAPIKey{apiKey}, false) + if !assert.NoError(t, e) { return } - } - }) + + result, e := testCcxtExchange.CancelOrder(model.MakeTransactionID(kase.orderID), *kase.pair) + if !assert.NoError(t, e) { + return + } + + log.Printf("result from cancel order (transactionID=%s): %s\n", kase.orderID, result.String()) + if !assert.Equal(t, model.CancelResultCancelSuccessful, result) { + return + } + }) + } } } diff --git a/plugins/factory.go b/plugins/factory.go index 073d779ec..14a60d220 100644 --- a/plugins/factory.go +++ b/plugins/factory.go @@ -171,9 +171,22 @@ var exchanges = map[string]ExchangeContainer{ "ccxt-binance": ExchangeContainer{ SortOrder: 1, Description: "Binance is a popular centralized cryptocurrency exchange (via ccxt-rest)", - TradeEnabled: false, + TradeEnabled: true, makeFn: func(exchangeFactoryData exchangeFactoryData) (api.Exchange, error) { - return makeCcxtExchange("http://localhost:3000", "binance", exchangeFactoryData.simMode) + // https://www.binance.com/api/v1/exchangeInfo + // https://support.binance.com/hc/en-us/articles/115000594711-Trading-Rule + binanceOrderConstraints := map[model.TradingPair]model.OrderConstraints{ + *model.MakeTradingPair(model.XLM, model.USDT): *model.MakeOrderConstraints(5, 2, 100.0), // converted USDT value to XLM for minBaseVolume with a lot of slack + *model.MakeTradingPair(model.XLM, model.BTC): *model.MakeOrderConstraints(8, 0, 100.0), // converted BTC value to XLM for minBaseVolume with a lot of slack + } + + return makeCcxtExchange( + "http://localhost:3000", + "binance", + binanceOrderConstraints, + exchangeFactoryData.apiKeys, + exchangeFactoryData.simMode, + ) }, }, "ccxt-poloniex": ExchangeContainer{ @@ -181,7 +194,13 @@ var exchanges = map[string]ExchangeContainer{ Description: "Poloniex is a popular centralized cryptocurrency exchange (via ccxt-rest)", TradeEnabled: false, makeFn: func(exchangeFactoryData exchangeFactoryData) (api.Exchange, error) { - return makeCcxtExchange("http://localhost:3000", "poloniex", exchangeFactoryData.simMode) + return makeCcxtExchange( + "http://localhost:3000", + "poloniex", + map[model.TradingPair]model.OrderConstraints{}, // TODO when enabling trading + exchangeFactoryData.apiKeys, + exchangeFactoryData.simMode, + ) }, }, "ccxt-bittrex": ExchangeContainer{ @@ -189,7 +208,13 @@ var exchanges = map[string]ExchangeContainer{ Description: "Bittrex is a popular centralized cryptocurrency exchange (via ccxt-rest)", TradeEnabled: false, makeFn: func(exchangeFactoryData exchangeFactoryData) (api.Exchange, error) { - return makeCcxtExchange("http://localhost:3000", "bittrex", exchangeFactoryData.simMode) + return makeCcxtExchange( + "http://localhost:3000", + "bittrex", + map[model.TradingPair]model.OrderConstraints{}, // TODO when enabling trading + exchangeFactoryData.apiKeys, + exchangeFactoryData.simMode, + ) }, }, } diff --git a/plugins/fillTracker.go b/plugins/fillTracker.go index f426d0eb6..a0b4a2126 100644 --- a/plugins/fillTracker.go +++ b/plugins/fillTracker.go @@ -63,7 +63,7 @@ func (f *FillTracker) TrackFills() error { // do nothing } - tradeHistoryResult, e := f.fillTrackable.GetTradeHistory(lastCursor, nil) + tradeHistoryResult, e := f.fillTrackable.GetTradeHistory(*f.GetPair(), lastCursor, nil) if e != nil { return fmt.Errorf("error when fetching trades: %s", e) } diff --git a/plugins/krakenExchange.go b/plugins/krakenExchange.go index 33f446c1b..6f6768151 100644 --- a/plugins/krakenExchange.go +++ b/plugins/krakenExchange.go @@ -127,11 +127,12 @@ func (k *krakenExchange) AddOrder(order *model.Order) (*model.TransactionID, err } // CancelOrder impl. -func (k *krakenExchange) CancelOrder(txID *model.TransactionID) (model.CancelOrderResult, error) { +func (k *krakenExchange) CancelOrder(txID *model.TransactionID, pair model.TradingPair) (model.CancelOrderResult, error) { if k.isSimulated { return model.CancelResultCancelSuccessful, nil } + // we don't actually use the pair for kraken resp, e := k.nextAPI().CancelOrder(txID.String()) if e != nil { return model.CancelResultFailed, e @@ -194,12 +195,19 @@ func (k *krakenExchange) GetAssetConverter() *model.AssetConverter { } // GetOpenOrders impl. -func (k *krakenExchange) GetOpenOrders() (map[model.TradingPair][]model.OpenOrder, error) { +func (k *krakenExchange) GetOpenOrders(pairs []*model.TradingPair) (map[model.TradingPair][]model.OpenOrder, error) { openOrdersResponse, e := k.nextAPI().OpenOrders(map[string]string{}) if e != nil { return nil, e } + // convert to a map so we can easily search for the existence of a trading pair + // kraken uses different symbols when fetching open orders! + pairsMap, e := model.TradingPairs2Strings2(k.assetConverterOpenOrders, "", pairs) + if e != nil { + return nil, e + } + m := map[model.TradingPair][]model.OpenOrder{} for ID, o := range openOrdersResponse.Open { // kraken uses different symbols when fetching open orders! @@ -207,6 +215,12 @@ func (k *krakenExchange) GetOpenOrders() (map[model.TradingPair][]model.OpenOrde if e != nil { return nil, e } + + if _, ok := pairsMap[*pair]; !ok { + // skip open orders for pairs that were not requested + continue + } + if _, ok := m[*pair]; !ok { m[*pair] = []model.OpenOrder{} } @@ -303,7 +317,7 @@ func values(m map[model.TradingPair]string) []string { } // GetTradeHistory impl. -func (k *krakenExchange) GetTradeHistory(maybeCursorStart interface{}, maybeCursorEnd interface{}) (*api.TradeHistoryResult, error) { +func (k *krakenExchange) GetTradeHistory(pair model.TradingPair, maybeCursorStart interface{}, maybeCursorEnd interface{}) (*api.TradeHistoryResult, error) { var mcs *int64 if maybeCursorStart != nil { i := maybeCursorStart.(int64) @@ -316,10 +330,10 @@ func (k *krakenExchange) GetTradeHistory(maybeCursorStart interface{}, maybeCurs mce = &i } - return k.getTradeHistory(mcs, mce) + return k.getTradeHistory(pair, mcs, mce) } -func (k *krakenExchange) getTradeHistory(maybeCursorStart *int64, maybeCursorEnd *int64) (*api.TradeHistoryResult, error) { +func (k *krakenExchange) getTradeHistory(tradingPair model.TradingPair, maybeCursorStart *int64, maybeCursorEnd *int64) (*api.TradeHistoryResult, error) { input := map[string]string{} if maybeCursorStart != nil { input["start"] = strconv.FormatInt(*maybeCursorStart, 10) @@ -359,19 +373,21 @@ func (k *krakenExchange) getTradeHistory(maybeCursorStart *int64, maybeCursorEnd feeCostPrecision = orderConstraints.VolumePrecision } - res.Trades = append(res.Trades, model.Trade{ - Order: model.Order{ - Pair: pair, - OrderAction: model.OrderActionFromString(_type), - OrderType: model.OrderTypeFromString(_ordertype), - Price: model.MustNumberFromString(_price, orderConstraints.PricePrecision), - Volume: model.MustNumberFromString(_vol, orderConstraints.VolumePrecision), - Timestamp: ts, - }, - TransactionID: model.MakeTransactionID(_txid), - Cost: model.MustNumberFromString(_cost, feeCostPrecision), - Fee: model.MustNumberFromString(_fee, feeCostPrecision), - }) + if *pair == tradingPair { + res.Trades = append(res.Trades, model.Trade{ + Order: model.Order{ + Pair: pair, + OrderAction: model.OrderActionFromString(_type), + OrderType: model.OrderTypeFromString(_ordertype), + Price: model.MustNumberFromString(_price, orderConstraints.PricePrecision), + Volume: model.MustNumberFromString(_vol, orderConstraints.VolumePrecision), + Timestamp: ts, + }, + TransactionID: model.MakeTransactionID(_txid), + Cost: model.MustNumberFromString(_cost, feeCostPrecision), + Fee: model.MustNumberFromString(_fee, feeCostPrecision), + }) + } res.Cursor = _time } return &res, nil diff --git a/plugins/krakenExchange_test.go b/plugins/krakenExchange_test.go index 4f9d051ff..a66300471 100644 --- a/plugins/krakenExchange_test.go +++ b/plugins/krakenExchange_test.go @@ -132,7 +132,8 @@ func TestGetTradeHistory(t *testing.T) { return } - tradeHistoryResult, e := testKrakenExchange.GetTradeHistory(nil, nil) + pair := model.TradingPair{Base: model.XLM, Quote: model.BTC} + tradeHistoryResult, e := testKrakenExchange.GetTradeHistory(pair, nil, nil) if !assert.NoError(t, e) { return } @@ -159,7 +160,8 @@ func TestGetOpenOrders(t *testing.T) { return } - m, e := testKrakenExchange.GetOpenOrders() + pair := &model.TradingPair{Base: model.XLM, Quote: model.USD} + m, e := testKrakenExchange.GetOpenOrders([]*model.TradingPair{pair}) if !assert.NoError(t, e) { return } @@ -210,8 +212,9 @@ func TestCancelOrder(t *testing.T) { } // need to add some transactionID here to run this test + pair := model.TradingPair{Base: model.XLM, Quote: model.BTC} txID := model.MakeTransactionID("") - result, e := testKrakenExchange.CancelOrder(txID) + result, e := testKrakenExchange.CancelOrder(txID, pair) if !assert.NoError(t, e) { return } diff --git a/plugins/sdex.go b/plugins/sdex.go index 97286e668..b4cd7dee6 100644 --- a/plugins/sdex.go +++ b/plugins/sdex.go @@ -646,10 +646,14 @@ func (sdex *SDEX) pair2Assets() (baseAsset horizon.Asset, quoteAsset horizon.Ass var _ api.FillTrackable = &SDEX{} // GetTradeHistory fetches trades for the trading account bound to this instance of SDEX -func (sdex *SDEX) GetTradeHistory(maybeCursorStart interface{}, maybeCursorEnd interface{}) (*api.TradeHistoryResult, error) { +func (sdex *SDEX) GetTradeHistory(pair model.TradingPair, maybeCursorStart interface{}, maybeCursorEnd interface{}) (*api.TradeHistoryResult, error) { + if pair != *sdex.pair { + return nil, fmt.Errorf("passed in pair (%s) did not match sdex.pair (%s)", pair.String(), sdex.pair.String()) + } + baseAsset, quoteAsset, e := sdex.pair2Assets() if e != nil { - return nil, fmt.Errorf("error while convertig pair to base and quote asset: %s", e) + return nil, fmt.Errorf("error while converting pair to base and quote asset: %s", e) } var cursorStart string diff --git a/support/sdk/ccxt.go b/support/sdk/ccxt.go index 8b7dc720b..2e448c352 100644 --- a/support/sdk/ccxt.go +++ b/support/sdk/ccxt.go @@ -3,11 +3,15 @@ package sdk import ( "encoding/json" "fmt" + "hash/fnv" "log" "net/http" + "reflect" "strings" + "github.com/interstellar/kelp/api" "github.com/interstellar/kelp/support/networking" + "github.com/mitchellh/mapstructure" ) // Ccxt Rest SDK (https://github.com/franz-see/ccxt-rest, https://github.com/ccxt/ccxt/) @@ -21,18 +25,23 @@ type Ccxt struct { const pathExchanges = "/exchanges" // MakeInitializedCcxtExchange constructs an instance of Ccxt that is bound to a specific exchange instance on the CCXT REST server -func MakeInitializedCcxtExchange(ccxtBaseURL string, exchangeName string) (*Ccxt, error) { +func MakeInitializedCcxtExchange(ccxtBaseURL string, exchangeName string, apiKey api.ExchangeAPIKey) (*Ccxt, error) { if strings.HasSuffix(ccxtBaseURL, "/") { return nil, fmt.Errorf("invalid format for ccxtBaseURL: %s", ccxtBaseURL) } + instanceName, e := makeInstanceName(exchangeName, apiKey) + if e != nil { + return nil, fmt.Errorf("cannot make instance name: %s", e) + } c := &Ccxt{ httpClient: http.DefaultClient, ccxtBaseURL: ccxtBaseURL, exchangeName: exchangeName, - // don't initialize instanceName since it's initialized in the call to init() below + instanceName: instanceName, } - e := c.init() + + e = c.init(apiKey) if e != nil { return nil, fmt.Errorf("error when initializing Ccxt exchange: %s", e) } @@ -40,7 +49,7 @@ func MakeInitializedCcxtExchange(ccxtBaseURL string, exchangeName string) (*Ccxt return c, nil } -func (c *Ccxt) init() error { +func (c *Ccxt) init(apiKey api.ExchangeAPIKey) error { // get exchange list var exchangeList []string e := networking.JSONRequest(c.httpClient, "GET", c.ccxtBaseURL+pathExchanges, "", map[string]string{}, &exchangeList) @@ -68,16 +77,14 @@ func (c *Ccxt) init() error { } // make a new instance if needed - if len(instanceList) == 0 { - instanceName := c.exchangeName + "1" - e = c.newInstance(instanceName) + if !c.hasInstance(instanceList) { + e = c.newInstance(apiKey) if e != nil { - return fmt.Errorf("error creating new instance '%s' for exchange '%s': %s", instanceName, c.exchangeName, e) + return fmt.Errorf("error creating new instance '%s' for exchange '%s': %s", c.instanceName, c.exchangeName, e) } - log.Printf("created new instance '%s' for exchange '%s'\n", instanceName, c.exchangeName) - c.instanceName = instanceName + log.Printf("created new instance '%s' for exchange '%s'\n", c.instanceName, c.exchangeName) } else { - c.instanceName = instanceList[0] + log.Printf("instance '%s' for exchange '%s' already exists\n", c.instanceName, c.exchangeName) } // load markets to populate fields related to markets @@ -90,14 +97,48 @@ func (c *Ccxt) init() error { return nil } -func (c *Ccxt) newInstance(instanceName string) error { +func makeInstanceName(exchangeName string, apiKey api.ExchangeAPIKey) (string, error) { + if apiKey.Key == "" { + return exchangeName, nil + } + + number, e := hashString(apiKey.Key) + if e != nil { + return "", fmt.Errorf("could not hash apiKey.Key: %s", e) + } + return fmt.Sprintf("%s%d", exchangeName, number), nil +} + +func hashString(s string) (uint32, error) { + h := fnv.New32a() + _, e := h.Write([]byte(s)) + if e != nil { + return 0, fmt.Errorf("error while hashing string: %s", e) + } + return h.Sum32(), nil +} + +func (c *Ccxt) hasInstance(instanceList []string) bool { + for _, i := range instanceList { + if i == c.instanceName { + return true + } + } + return false +} + +func (c *Ccxt) newInstance(apiKey api.ExchangeAPIKey) error { data, e := json.Marshal(&struct { - ID string `json:"id"` + ID string `json:"id"` + APIKey string `json:"apiKey"` + Secret string `json:"secret"` }{ - ID: instanceName, + ID: c.instanceName, + APIKey: apiKey.Key, + Secret: apiKey.Secret, }) if e != nil { - return fmt.Errorf("error marshaling instanceName '%s' as ID for exchange '%s': %s", instanceName, c.exchangeName, e) + return fmt.Errorf("error marshaling instanceName '%s' as ID for exchange '%s': %s", c.instanceName, c.exchangeName, e) } var newInstance map[string]interface{} @@ -107,7 +148,7 @@ func (c *Ccxt) newInstance(instanceName string) error { } if _, ok := newInstance["urls"]; !ok { - return fmt.Errorf("check for new instance of exchange '%s' failed for instanceName: %s", c.exchangeName, instanceName) + return fmt.Errorf("check for new instance of exchange '%s' failed for instanceName: %s", c.exchangeName, c.instanceName) } return nil } @@ -259,3 +300,217 @@ func (c *Ccxt) FetchTrades(tradingPair string) ([]CcxtTrade, error) { } return output, nil } + +func (c *Ccxt) FetchMyTrades(tradingPair string) ([]CcxtTrade, error) { + e := c.symbolExists(tradingPair) + if e != nil { + return nil, fmt.Errorf("symbol does not exist: %s", e) + } + + // marshal input data + data, e := json.Marshal(&[]string{tradingPair}) + if e != nil { + return nil, fmt.Errorf("error marshaling input (tradingPair=%s) as an array for exchange '%s': %s", tradingPair, c.exchangeName, e) + } + // fetch trades for symbol + url := c.ccxtBaseURL + pathExchanges + "/" + c.exchangeName + "/" + c.instanceName + "/fetchMyTrades" + // decode generic data (see "https://blog.golang.org/json-and-go#TOC_4.") + output := []CcxtTrade{} + e = networking.JSONRequest(c.httpClient, "POST", url, string(data), map[string]string{}, &output) + if e != nil { + return nil, fmt.Errorf("error fetching trades for trading pair '%s': %s", tradingPair, e) + } + return output, nil +} + +// CcxtBalance represents the balance for an asset +type CcxtBalance struct { + Total float64 + Used float64 + Free float64 +} + +// FetchBalance calls the /fetchBalance endpoint on CCXT +func (c *Ccxt) FetchBalance() (map[string]CcxtBalance, error) { + url := c.ccxtBaseURL + pathExchanges + "/" + c.exchangeName + "/" + c.instanceName + "/fetchBalance" + // decode generic data (see "https://blog.golang.org/json-and-go#TOC_4.") + var output interface{} + e := networking.JSONRequest(c.httpClient, "POST", url, "", map[string]string{}, &output) + if e != nil { + return nil, fmt.Errorf("error fetching balance: %s", e) + } + + outputMap := output.(map[string]interface{}) + if _, ok := outputMap["total"]; !ok { + return nil, fmt.Errorf("result from call to fetchBalance did not contain 'total' field") + } + totals := outputMap["total"].(map[string]interface{}) + + result := map[string]CcxtBalance{} + for asset, v := range totals { + var totalBalance float64 + if b, ok := v.(float64); ok { + totalBalance = b + } else { + return nil, fmt.Errorf("could not convert total balance for asset '%s' from interface{} to float64", asset) + } + if totalBalance == 0 { + continue + } + + assetData := outputMap[asset].(map[string]interface{}) + var assetBalance CcxtBalance + e = mapstructure.Decode(assetData, &assetBalance) + if e != nil { + return nil, fmt.Errorf("error converting balance map to CcxtBalance for asset '%s': %s", asset, e) + } + result[asset] = assetBalance + } + return result, nil +} + +// CcxtOpenOrder represents an open order +type CcxtOpenOrder struct { + Amount float64 + Cost float64 + Filled float64 + ID string + Price float64 + Side string + Status string + Symbol string + Type string + Timestamp int64 +} + +// FetchOpenOrders calls the /fetchOpenOrders endpoint on CCXT +func (c *Ccxt) FetchOpenOrders(tradingPairs []string) (map[string][]CcxtOpenOrder, error) { + for _, p := range tradingPairs { + e := c.symbolExists(p) + if e != nil { + return nil, fmt.Errorf("symbol does not exist: %s", e) + } + } + + // marshal input data + data, e := json.Marshal(&tradingPairs) + if e != nil { + return nil, fmt.Errorf("error marshaling input (tradingPairs=%v) for exchange '%s': %s", tradingPairs, c.exchangeName, e) + } + + url := c.ccxtBaseURL + pathExchanges + "/" + c.exchangeName + "/" + c.instanceName + "/fetchOpenOrders" + // decode generic data (see "https://blog.golang.org/json-and-go#TOC_4.") + var output interface{} + e = networking.JSONRequest(c.httpClient, "POST", url, string(data), map[string]string{}, &output) + if e != nil { + return nil, fmt.Errorf("error fetching open orders: %s", e) + } + + result := map[string][]CcxtOpenOrder{} + outputList := output.([]interface{}) + for _, elem := range outputList { + elemMap, ok := elem.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("could not convert the element in the result to a map[string]interface{}, type = %s", reflect.TypeOf(elem)) + } + + var openOrder CcxtOpenOrder + e = mapstructure.Decode(elemMap, &openOrder) + if e != nil { + return nil, fmt.Errorf("could not decode open order element (%v): %s", elemMap, e) + } + + var orderList []CcxtOpenOrder + if l, ok := result[openOrder.Symbol]; ok { + orderList = l + } else { + orderList = []CcxtOpenOrder{} + } + + orderList = append(orderList, openOrder) + result[openOrder.Symbol] = orderList + } + return result, nil +} + +// CreateLimitOrder calls the /createOrder endpoint on CCXT with a limit price and the order type set to "limit" +func (c *Ccxt) CreateLimitOrder(tradingPair string, side string, amount float64, price float64) (*CcxtOpenOrder, error) { + orderType := "limit" + e := c.symbolExists(tradingPair) + if e != nil { + return nil, fmt.Errorf("symbol does not exist: %s", e) + } + + // marshal input data + inputData := []interface{}{ + tradingPair, + orderType, + side, + amount, + price, + } + data, e := json.Marshal(&inputData) + if e != nil { + return nil, fmt.Errorf("error marshaling input (%v) for exchange '%s': %s", inputData, c.exchangeName, e) + } + + url := c.ccxtBaseURL + pathExchanges + "/" + c.exchangeName + "/" + c.instanceName + "/createOrder" + // decode generic data (see "https://blog.golang.org/json-and-go#TOC_4.") + var output interface{} + e = networking.JSONRequest(c.httpClient, "POST", url, string(data), map[string]string{}, &output) + if e != nil { + return nil, fmt.Errorf("error creating order: %s", e) + } + + outputMap, ok := output.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("could not convert the output to a map[string]interface{}, type = %s", reflect.TypeOf(output)) + } + + var openOrder CcxtOpenOrder + e = mapstructure.Decode(outputMap, &openOrder) + if e != nil { + return nil, fmt.Errorf("could not decode outputMap to openOrder (%v): %s", outputMap, e) + } + + return &openOrder, nil +} + +// CancelOrder calls the /cancelOrder endpoint on CCXT with the orderID and tradingPair +func (c *Ccxt) CancelOrder(orderID string, tradingPair string) (*CcxtOpenOrder, error) { + e := c.symbolExists(tradingPair) + if e != nil { + return nil, fmt.Errorf("symbol does not exist: %s", e) + } + + // marshal input data + inputData := []interface{}{ + orderID, + tradingPair, + } + data, e := json.Marshal(&inputData) + if e != nil { + return nil, fmt.Errorf("error marshaling input (%v) for exchange '%s': %s", inputData, c.exchangeName, e) + } + + url := c.ccxtBaseURL + pathExchanges + "/" + c.exchangeName + "/" + c.instanceName + "/cancelOrder" + // decode generic data (see "https://blog.golang.org/json-and-go#TOC_4.") + var output interface{} + e = networking.JSONRequest(c.httpClient, "POST", url, string(data), map[string]string{}, &output) + if e != nil { + return nil, fmt.Errorf("error canceling order: %s", e) + } + + outputMap, ok := output.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("could not convert the output to a map[string]interface{}, type = %s", reflect.TypeOf(output)) + } + + var openOrder CcxtOpenOrder + e = mapstructure.Decode(outputMap, &openOrder) + if e != nil { + return nil, fmt.Errorf("could not decode outputMap to openOrder (%v): %s", outputMap, e) + } + + return &openOrder, nil +} diff --git a/support/sdk/ccxt_test.go b/support/sdk/ccxt_test.go index 2a9819cf2..7d7950a00 100644 --- a/support/sdk/ccxt_test.go +++ b/support/sdk/ccxt_test.go @@ -2,18 +2,47 @@ package sdk import ( "fmt" + "log" "strings" "testing" + "github.com/interstellar/kelp/api" + "github.com/interstellar/kelp/model" "github.com/stretchr/testify/assert" ) +func TestHashString(t *testing.T) { + testCases := []struct { + s string + wantHash uint32 + }{ + { + s: "hello", + wantHash: 1335831723, + }, { + s: "world", + wantHash: 933488787, + }, + } + + for _, kase := range testCases { + t.Run(kase.s, func(t *testing.T) { + result, e := hashString(kase.s) + if !assert.Nil(t, e) { + return + } + + assert.Equal(t, kase.wantHash, result) + }) + } +} + func TestMakeValid(t *testing.T) { if testing.Short() { return } - _, e := MakeInitializedCcxtExchange("http://localhost:3000", "kraken") + _, e := MakeInitializedCcxtExchange("http://localhost:3000", "kraken", api.ExchangeAPIKey{}) if e != nil { assert.Fail(t, fmt.Sprintf("unexpected error: %s", e)) return @@ -26,7 +55,7 @@ func TestMakeInvalid(t *testing.T) { return } - _, e := MakeInitializedCcxtExchange("http://localhost:3000", "missing-exchange") + _, e := MakeInitializedCcxtExchange("http://localhost:3000", "missing-exchange", api.ExchangeAPIKey{}) if e == nil { assert.Fail(t, "expected an error when trying to make and initialize an exchange that is missing: 'missing-exchange'") return @@ -44,7 +73,7 @@ func TestFetchTickers(t *testing.T) { return } - c, e := MakeInitializedCcxtExchange("http://localhost:3000", "binance") + c, e := MakeInitializedCcxtExchange("http://localhost:3000", "binance", api.ExchangeAPIKey{}) if e != nil { assert.Fail(t, fmt.Sprintf("error when making ccxt exchange: %s", e)) return @@ -65,7 +94,7 @@ func TestFetchTickersWithMissingSymbol(t *testing.T) { return } - c, e := MakeInitializedCcxtExchange("http://localhost:3000", "binance") + c, e := MakeInitializedCcxtExchange("http://localhost:3000", "binance", api.ExchangeAPIKey{}) if e != nil { assert.Fail(t, fmt.Sprintf("error when making ccxt exchange: %s", e)) return @@ -152,7 +181,7 @@ func runTestFetchOrderBook(k orderbookTest, t *testing.T) { return } - c, e := MakeInitializedCcxtExchange("http://localhost:3000", k.exchangeName) + c, e := MakeInitializedCcxtExchange("http://localhost:3000", k.exchangeName, api.ExchangeAPIKey{}) if e != nil { assert.Fail(t, fmt.Sprintf("error when making ccxt exchange: %s", e)) return @@ -195,7 +224,11 @@ func TestFetchTrades(t *testing.T) { binanceFields := []string{"amount", "cost", "datetime", "id", "price", "side", "symbol", "timestamp"} bittrexFields := []string{"amount", "datetime", "id", "price", "side", "symbol", "timestamp", "type"} - for _, k := range []tradesTest{ + for _, k := range []struct { + exchangeName string + tradingPair string + expectedFields []string + }{ { exchangeName: "poloniex", tradingPair: "BTC/USDT", @@ -219,37 +252,77 @@ func TestFetchTrades(t *testing.T) { }, } { t.Run(k.exchangeName, func(t *testing.T) { - runTestFetchTrades(k, t) + c, e := MakeInitializedCcxtExchange("http://localhost:3000", k.exchangeName, api.ExchangeAPIKey{}) + if e != nil { + assert.Fail(t, fmt.Sprintf("error when making ccxt exchange: %s", e)) + return + } + + trades, e := c.FetchTrades(k.tradingPair) + if e != nil { + assert.Fail(t, fmt.Sprintf("error when fetching trades: %s", e)) + return + } + + validateTrades(trades, k.expectedFields, k.tradingPair, t) }) } } -type tradesTest struct { - exchangeName string - tradingPair string - expectedFields []string -} - -func runTestFetchTrades(k tradesTest, t *testing.T) { +func TestFetchMyTrades(t *testing.T) { if testing.Short() { return } - c, e := MakeInitializedCcxtExchange("http://localhost:3000", k.exchangeName) - if e != nil { - assert.Fail(t, fmt.Sprintf("error when making ccxt exchange: %s", e)) - return - } + poloniexFields := []string{"amount", "cost", "datetime", "id", "price", "side", "symbol", "timestamp", "type"} + binanceFields := []string{"amount", "cost", "datetime", "id", "price", "side", "symbol", "timestamp"} + bittrexFields := []string{"amount", "datetime", "id", "price", "side", "symbol", "timestamp", "type"} - trades, e := c.FetchTrades(k.tradingPair) - if e != nil { - assert.Fail(t, fmt.Sprintf("error when fetching trades: %s", e)) - return + for _, k := range []struct { + exchangeName string + tradingPair string + expectedFields []string + apiKey api.ExchangeAPIKey + }{ + { + exchangeName: "poloniex", + tradingPair: "BTC/USDT", + expectedFields: poloniexFields, + apiKey: api.ExchangeAPIKey{}, + }, { + exchangeName: "binance", + tradingPair: "XLM/USDT", + expectedFields: binanceFields, + apiKey: api.ExchangeAPIKey{}, + }, { + exchangeName: "bittrex", + tradingPair: "XLM/BTC", + expectedFields: bittrexFields, + apiKey: api.ExchangeAPIKey{}, + }, + } { + t.Run(k.exchangeName, func(t *testing.T) { + c, e := MakeInitializedCcxtExchange("http://localhost:3000", k.exchangeName, k.apiKey) + if e != nil { + assert.Fail(t, fmt.Sprintf("error when making ccxt exchange: %s", e)) + return + } + + trades, e := c.FetchMyTrades(k.tradingPair) + if e != nil { + assert.Fail(t, fmt.Sprintf("error when fetching my trades: %s", e)) + return + } + + validateTrades(trades, k.expectedFields, k.tradingPair, t) + }) } +} +func validateTrades(trades []CcxtTrade, expectedFields []string, tradingPair string, t *testing.T) { // convert expectedFields to a map and create the supportsField function fieldsMap := map[string]bool{} - for _, f := range k.expectedFields { + for _, f := range expectedFields { fieldsMap[f] = true } supportsField := func(field string) bool { @@ -277,7 +350,7 @@ func runTestFetchTrades(k tradesTest, t *testing.T) { if supportsField("side") && !assert.True(t, trade.Side == "sell" || trade.Side == "buy") { return } - if supportsField("symbol") && !assert.True(t, trade.Symbol == k.tradingPair) { + if supportsField("symbol") && !assert.True(t, trade.Symbol == tradingPair) { return } if supportsField("timestamp") && !assert.True(t, trade.Timestamp > 0) { @@ -285,3 +358,292 @@ func runTestFetchTrades(k tradesTest, t *testing.T) { } } } + +func TestFetchBalance(t *testing.T) { + if testing.Short() { + return + } + + for _, k := range []struct { + exchangeName string + apiKey api.ExchangeAPIKey + }{ + { + exchangeName: "binance", + apiKey: api.ExchangeAPIKey{}, + }, + } { + t.Run(k.exchangeName, func(t *testing.T) { + c, e := MakeInitializedCcxtExchange("http://localhost:3000", k.exchangeName, k.apiKey) + if e != nil { + assert.Fail(t, fmt.Sprintf("error when making ccxt exchange: %s", e)) + return + } + + balances, e := c.FetchBalance() + if !assert.Nil(t, e) { + return + } + + if !assert.True(t, len(balances) > 0, fmt.Sprintf("%d", len(balances))) { + return + } + + for asset, ccxtBalance := range balances { + if !assert.True(t, ccxtBalance.Total > 0, fmt.Sprintf("total balance for asset '%s' should have been > 0, was %f", asset, ccxtBalance.Total)) { + return + } + + log.Printf("balance for asset '%s': %+v\n", asset, ccxtBalance) + } + + assert.Fail(t, "force fail") + }) + } +} + +func TestOpenOrders(t *testing.T) { + if testing.Short() { + return + } + + for _, k := range []struct { + exchangeName string + apiKey api.ExchangeAPIKey + tradingPair model.TradingPair + }{ + { + exchangeName: "binance", + apiKey: api.ExchangeAPIKey{}, + tradingPair: model.TradingPair{ + Base: model.XLM, + Quote: model.BTC, + }, + }, + } { + t.Run(k.exchangeName, func(t *testing.T) { + c, e := MakeInitializedCcxtExchange("http://localhost:3000", k.exchangeName, k.apiKey) + if e != nil { + assert.Fail(t, fmt.Sprintf("error when making ccxt exchange: %s", e)) + return + } + + openOrders, e := c.FetchOpenOrders([]string{k.tradingPair.String()}) + if !assert.NoError(t, e) { + return + } + + if !assert.True(t, len(openOrders) > 0, fmt.Sprintf("%d", len(openOrders))) { + return + } + + for asset, orderList := range openOrders { + if !assert.Equal(t, k.tradingPair.String(), asset) { + return + } + + for _, o := range orderList { + if !assert.Equal(t, k.tradingPair.String(), o.Symbol) { + return + } + + if !assert.True(t, o.Amount > 0, o.Amount) { + return + } + + if !assert.True(t, o.Price > 0, o.Price) { + return + } + + if !assert.Equal(t, "limit", o.Type) { + return + } + + log.Printf("order: %+v\n", o) + } + } + + assert.Fail(t, "force fail") + }) + } +} + +func TestCreateLimitOrder(t *testing.T) { + if testing.Short() { + return + } + + apiKey := api.ExchangeAPIKey{} + for _, k := range []struct { + exchangeName string + apiKey api.ExchangeAPIKey + tradingPair model.TradingPair + side string + amount float64 + price float64 + }{ + { + exchangeName: "binance", + apiKey: apiKey, + tradingPair: model.TradingPair{ + Base: model.XLM, + Quote: model.BTC, + }, + side: "sell", + amount: 40, + price: 0.00004228, + }, { + exchangeName: "binance", + apiKey: apiKey, + tradingPair: model.TradingPair{ + Base: model.XLM, + Quote: model.BTC, + }, + side: "buy", + amount: 42, + price: 0.00002536, + }, + } { + t.Run(k.exchangeName, func(t *testing.T) { + c, e := MakeInitializedCcxtExchange("http://localhost:3000", k.exchangeName, k.apiKey) + if e != nil { + assert.Fail(t, fmt.Sprintf("error when making ccxt exchange: %s", e)) + return + } + + openOrder, e := c.CreateLimitOrder(k.tradingPair.String(), k.side, k.amount, k.price) + if !assert.NoError(t, e) { + return + } + + if !assert.NotNil(t, openOrder) { + return + } + + if !assert.Equal(t, k.tradingPair.String(), openOrder.Symbol) { + return + } + + if !assert.NotEqual(t, "", openOrder.ID) { + return + } + + if !assert.Equal(t, k.amount, openOrder.Amount) { + return + } + + if !assert.Equal(t, k.price, openOrder.Price) { + return + } + + if !assert.Equal(t, "limit", openOrder.Type) { + return + } + + if !assert.Equal(t, k.side, openOrder.Side) { + return + } + + if !assert.Equal(t, 0.0, openOrder.Cost) { + return + } + + if !assert.Equal(t, 0.0, openOrder.Filled) { + return + } + + if !assert.Equal(t, "open", openOrder.Status) { + return + } + }) + } +} + +func TestCancelOrder(t *testing.T) { + if testing.Short() { + return + } + + apiKey := api.ExchangeAPIKey{} + for _, k := range []struct { + exchangeName string + apiKey api.ExchangeAPIKey + orderID string + tradingPair model.TradingPair + }{ + { + exchangeName: "binance", + apiKey: apiKey, + orderID: "67391789", + tradingPair: model.TradingPair{ + Base: model.XLM, + Quote: model.BTC, + }, + }, { + exchangeName: "binance", + apiKey: apiKey, + orderID: "67391791", + tradingPair: model.TradingPair{ + Base: model.XLM, + Quote: model.BTC, + }, + }, + } { + t.Run(k.exchangeName, func(t *testing.T) { + c, e := MakeInitializedCcxtExchange("http://localhost:3000", k.exchangeName, k.apiKey) + if e != nil { + assert.Fail(t, fmt.Sprintf("error when making ccxt exchange: %s", e)) + return + } + + openOrder, e := c.CancelOrder(k.orderID, k.tradingPair.String()) + if !assert.NoError(t, e) { + return + } + + if !assert.NotNil(t, openOrder) { + return + } + + if !assert.Equal(t, k.tradingPair.String(), openOrder.Symbol) { + return + } + + if !assert.NotEqual(t, "", openOrder.ID) { + return + } + + if !assert.True(t, openOrder.Amount > 0, fmt.Sprintf("%f", openOrder.Amount)) { + return + } + + if !assert.True(t, openOrder.Price > 0, fmt.Sprintf("%f", openOrder.Price)) { + return + } + + if !assert.Equal(t, "limit", openOrder.Type) { + return + } + + if !assert.True(t, openOrder.Side == "buy" || openOrder.Side == "sell", openOrder.Side) { + return + } + + if !assert.Equal(t, 0.0, openOrder.Cost) { + return + } + + if !assert.Equal(t, 0.0, openOrder.Filled) { + return + } + + if !assert.Equal(t, "canceled", openOrder.Status) { + return + } + + log.Printf("canceled order %+v\n", openOrder) + + assert.Fail(t, "force fail") + }) + } +}