diff --git a/pkg/exchange/okex/convert.go b/pkg/exchange/okex/convert.go index 968544729..881f66ab9 100644 --- a/pkg/exchange/okex/convert.go +++ b/pkg/exchange/okex/convert.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/pkg/errors" + "go.uber.org/multierr" "github.com/c9s/bbgo/pkg/exchange/okex/okexapi" "github.com/c9s/bbgo/pkg/fixedpoint" @@ -18,6 +19,7 @@ func toGlobalSymbol(symbol string) string { } // //go:generate sh -c "echo \"package okex\nvar spotSymbolMap = map[string]string{\n\" $(curl -s -L 'https://okex.com/api/v5/public/instruments?instType=SPOT' | jq -r '.data[] | \"\\(.instId | sub(\"-\" ; \"\") | tojson ): \\( .instId | tojson),\n\"') \"\n}\" > symbols.go" +// //go:generate go run gensymbols.go func toLocalSymbol(symbol string) string { if s, ok := spotSymbolMap[symbol]; ok { @@ -163,64 +165,18 @@ func toGlobalTrades(orderDetails []okexapi.OrderDetails) ([]types.Trade, error) func toGlobalOrders(orderDetails []okexapi.OrderDetails) ([]types.Order, error) { var orders []types.Order + var err error for _, orderDetail := range orderDetails { - orderID, err := strconv.ParseInt(orderDetail.OrderID, 10, 64) - if err != nil { - return orders, err + + o, err2 := toGlobalOrder(&orderDetail) + if err2 != nil { + err = multierr.Append(err, err2) + continue } - - side := types.SideType(strings.ToUpper(string(orderDetail.Side))) - - orderType, err := toGlobalOrderType(orderDetail.OrderType) - if err != nil { - return orders, err - } - - timeInForce := types.TimeInForceGTC - switch orderDetail.OrderType { - case okexapi.OrderTypeFOK: - timeInForce = types.TimeInForceFOK - case okexapi.OrderTypeIOC: - timeInForce = types.TimeInForceIOC - - } - - orderStatus, err := toGlobalOrderStatus(orderDetail.State) - if err != nil { - return orders, err - } - - isWorking := false - switch orderStatus { - case types.OrderStatusNew, types.OrderStatusPartiallyFilled: - isWorking = true - - } - - orders = append(orders, types.Order{ - SubmitOrder: types.SubmitOrder{ - ClientOrderID: orderDetail.ClientOrderID, - Symbol: toGlobalSymbol(orderDetail.InstrumentID), - Side: side, - Type: orderType, - Price: orderDetail.Price, - Quantity: orderDetail.Quantity, - StopPrice: fixedpoint.Zero, // not supported yet - TimeInForce: timeInForce, - }, - Exchange: types.ExchangeOKEx, - OrderID: uint64(orderID), - Status: orderStatus, - ExecutedQuantity: orderDetail.FilledQuantity, - IsWorking: isWorking, - CreationTime: types.Time(orderDetail.CreationTime), - UpdateTime: types.Time(orderDetail.UpdateTime), - IsMargin: false, - IsIsolated: false, - }) + orders = append(orders, *o) } - return orders, nil + return orders, err } func toGlobalOrderStatus(state okexapi.OrderState) (types.OrderStatus, error) { @@ -256,18 +212,19 @@ func toLocalOrderType(orderType types.OrderType) (okexapi.OrderType, error) { } func toGlobalOrderType(orderType okexapi.OrderType) (types.OrderType, error) { + // IOC, FOK are only allowed with limit order type, so we assume the order type is always limit order for FOK, IOC orders switch orderType { case okexapi.OrderTypeMarket: return types.OrderTypeMarket, nil - case okexapi.OrderTypeLimit: + + case okexapi.OrderTypeLimit, okexapi.OrderTypeFOK, okexapi.OrderTypeIOC: return types.OrderTypeLimit, nil + case okexapi.OrderTypePostOnly: return types.OrderTypeLimitMaker, nil - case okexapi.OrderTypeFOK: - case okexapi.OrderTypeIOC: - } + return "", fmt.Errorf("unknown or unsupported okex order type: %s", orderType) } @@ -277,3 +234,75 @@ func toLocalInterval(src string) string { return strings.ToUpper(w) }) } + +func toGlobalSide(side okexapi.SideType) (s types.SideType) { + switch string(side) { + case "sell": + s = types.SideTypeSell + case "buy": + s = types.SideTypeBuy + } + return s +} + +func toGlobalOrder(okexOrder *okexapi.OrderDetails) (*types.Order, error) { + + orderID, err := strconv.ParseInt(okexOrder.OrderID, 10, 64) + if err != nil { + return nil, err + } + + side := toGlobalSide(okexOrder.Side) + + orderType, err := toGlobalOrderType(okexOrder.OrderType) + if err != nil { + return nil, err + } + + timeInForce := types.TimeInForceGTC + switch okexOrder.OrderType { + case okexapi.OrderTypeFOK: + timeInForce = types.TimeInForceFOK + case okexapi.OrderTypeIOC: + timeInForce = types.TimeInForceIOC + } + + orderStatus, err := toGlobalOrderStatus(okexOrder.State) + if err != nil { + return nil, err + } + + isWorking := false + switch orderStatus { + case types.OrderStatusNew, types.OrderStatusPartiallyFilled: + isWorking = true + + } + + isMargin := false + if okexOrder.InstrumentType == string(okexapi.InstrumentTypeMARGIN) { + isMargin = true + } + + return &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: okexOrder.ClientOrderID, + Symbol: toGlobalSymbol(okexOrder.InstrumentID), + Side: side, + Type: orderType, + Price: okexOrder.Price, + Quantity: okexOrder.Quantity, + StopPrice: fixedpoint.Zero, // not supported yet + TimeInForce: timeInForce, + }, + Exchange: types.ExchangeOKEx, + OrderID: uint64(orderID), + Status: orderStatus, + ExecutedQuantity: okexOrder.FilledQuantity, + IsWorking: isWorking, + CreationTime: types.Time(okexOrder.CreationTime), + UpdateTime: types.Time(okexOrder.UpdateTime), + IsMargin: isMargin, + IsIsolated: false, + }, nil +} diff --git a/pkg/exchange/okex/exchange.go b/pkg/exchange/okex/exchange.go index e810f54a7..700b95837 100644 --- a/pkg/exchange/okex/exchange.go +++ b/pkg/exchange/okex/exchange.go @@ -26,6 +26,8 @@ var log = logrus.WithFields(logrus.Fields{ "exchange": ID, }) +var ErrSymbolRequired = errors.New("symbol is a required parameter") + type Exchange struct { key, secret, passphrase string @@ -273,7 +275,7 @@ func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) erro var reqs []*okexapi.CancelOrderRequest for _, order := range orders { if len(order.Symbol) == 0 { - return errors.New("symbol is required for canceling an okex order") + return ErrSymbolRequired } req := e.client.TradeService.NewCancelOrderRequest() @@ -339,3 +341,25 @@ func (e *Exchange) QueryKLines(ctx context.Context, symbol string, interval type return klines, nil } + +func (e *Exchange) QueryOrder(ctx context.Context, q types.OrderQuery) (*types.Order, error) { + if len(q.Symbol) == 0 { + return nil, ErrSymbolRequired + } + if len(q.OrderID) == 0 && len(q.ClientOrderID) == 0 { + return nil, errors.New("okex.QueryOrder: OrderId or ClientOrderId is required parameter") + } + req := e.client.TradeService.NewGetOrderDetailsRequest() + req.InstrumentID(q.Symbol). + OrderID(q.OrderID). + ClientOrderID(q.ClientOrderID) + + var order *okexapi.OrderDetails + order, err := req.Do(ctx) + + if err != nil { + return nil, err + } + + return toGlobalOrder(order) +} diff --git a/pkg/exchange/okex/okexapi/client.go b/pkg/exchange/okex/okexapi/client.go index 626e84160..ccb81f70a 100644 --- a/pkg/exchange/okex/okexapi/client.go +++ b/pkg/exchange/okex/okexapi/client.go @@ -47,6 +47,7 @@ const ( InstrumentTypeSwap InstrumentType = "SWAP" InstrumentTypeFutures InstrumentType = "FUTURES" InstrumentTypeOption InstrumentType = "OPTION" + InstrumentTypeMARGIN InstrumentType = "MARGIN" ) type OrderState string diff --git a/pkg/exchange/okex/okexapi/client_test.go b/pkg/exchange/okex/okexapi/client_test.go new file mode 100644 index 000000000..ec6841642 --- /dev/null +++ b/pkg/exchange/okex/okexapi/client_test.go @@ -0,0 +1,107 @@ +package okexapi + +import ( + "context" + "os" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/testutil" +) + +func getTestClientOrSkip(t *testing.T) *RestClient { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + + key, secret, passphrase, ok := testutil.IntegrationTestWithPassphraseConfigured(t, "OKEX") + if !ok { + t.Skip("Please configure all credentials about OKEX") + return nil + } + + client := NewClient() + client.Auth(key, secret, passphrase) + return client +} + +func TestClient_GetInstrumentsRequest(t *testing.T) { + client := NewClient() + ctx := context.Background() + + srv := &PublicDataService{client: client} + req := srv.NewGetInstrumentsRequest() + + instruments, err := req. + InstrumentType(InstrumentTypeSpot). + Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, instruments) + t.Logf("instruments: %+v", instruments) +} + +func TestClient_GetFundingRateRequest(t *testing.T) { + client := NewClient() + ctx := context.Background() + srv := &PublicDataService{client: client} + req := srv.NewGetFundingRate() + + instrument, err := req. + InstrumentID("BTC-USDT-SWAP"). + Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, instrument) + t.Logf("instrument: %+v", instrument) +} + +func TestClient_PlaceOrderRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + srv := &TradeService{client: client} + req := srv.NewPlaceOrderRequest() + + order, err := req. + InstrumentID("BTC-USDT"). + TradeMode("cash"). + Side(SideTypeBuy). + OrderType(OrderTypeLimit). + Price("15000"). + Quantity("0.0001"). + Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, order) + t.Logf("place order: %+v", order) +} + +func TestClient_GetPendingOrderRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + srv := &TradeService{client: client} + req := srv.NewGetPendingOrderRequest() + odr_type := []string{string(OrderTypeLimit), string(OrderTypeIOC)} + + pending_order, err := req. + InstrumentID("BTC-USDT"). + OrderTypes(odr_type). + Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, pending_order) + t.Logf("pending order: %+v", pending_order) +} + +func TestClient_GetOrderDetailsRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + srv := &TradeService{client: client} + req := srv.NewGetOrderDetailsRequest() + + orderDetail, err := req. + InstrumentID("BTC-USDT"). + OrderID("609869603774656544"). + Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, orderDetail) + t.Logf("order detail: %+v", orderDetail) +} diff --git a/pkg/exchange/okex/okexapi/trade.go b/pkg/exchange/okex/okexapi/trade.go index 7adc37c98..8a814057b 100644 --- a/pkg/exchange/okex/okexapi/trade.go +++ b/pkg/exchange/okex/okexapi/trade.go @@ -363,7 +363,7 @@ func (r *GetOrderDetailsRequest) Do(ctx context.Context) (*OrderDetails, error) } if len(orderResponse.Data) == 0 { - return nil, errors.New("order create error") + return nil, errors.New("get order details error") } return &orderResponse.Data[0], nil diff --git a/pkg/exchange/okex/query_order_test.go b/pkg/exchange/okex/query_order_test.go new file mode 100644 index 000000000..3c32da40c --- /dev/null +++ b/pkg/exchange/okex/query_order_test.go @@ -0,0 +1,36 @@ +package okex + +import ( + "context" + "os" + "testing" + + "github.com/c9s/bbgo/pkg/types" + "github.com/stretchr/testify/assert" +) + +func Test_QueryOrder(t *testing.T) { + key := os.Getenv("OKEX_API_KEY") + secret := os.Getenv("OKEX_API_SECRET") + passphrase := os.Getenv("OKEX_API_PASSPHRASE") + if len(key) == 0 && len(secret) == 0 { + t.Skip("api key/secret are not configured") + return + } + if len(passphrase) == 0 { + t.Skip("passphrase are not configured") + return + } + + e := New(key, secret, passphrase) + + queryOrder := types.OrderQuery{ + Symbol: "BTC-USDT", + OrderID: "609869603774656544", + } + orderDetail, err := e.QueryOrder(context.Background(), queryOrder) + if assert.NoError(t, err) { + assert.NotEmpty(t, orderDetail) + } + t.Logf("order detail: %+v", orderDetail) +} diff --git a/pkg/testutil/auth.go b/pkg/testutil/auth.go index 164207e29..8e5bd43c7 100644 --- a/pkg/testutil/auth.go +++ b/pkg/testutil/auth.go @@ -23,3 +23,16 @@ func IntegrationTestConfigured(t *testing.T, prefix string) (key, secret string, return key, secret, ok } + +func IntegrationTestWithPassphraseConfigured(t *testing.T, prefix string) (key, secret, passphrase string, ok bool) { + var hasKey, hasSecret, hasPassphrase bool + key, hasKey = os.LookupEnv(prefix + "_API_KEY") + secret, hasSecret = os.LookupEnv(prefix + "_API_SECRET") + passphrase, hasPassphrase = os.LookupEnv(prefix + "_API_PASSPHRASE") + ok = hasKey && hasSecret && hasPassphrase && os.Getenv("TEST_"+prefix) == "1" + if ok { + t.Logf(prefix+" api integration test enabled, key = %s, secret = %s, passphrase= %s", maskSecret(key), maskSecret(secret), maskSecret(passphrase)) + } + + return key, secret, passphrase, ok +}