diff --git a/pkg/exchange/okex/convert.go b/pkg/exchange/okex/convert.go index 881f66ab9..193ba233e 100644 --- a/pkg/exchange/okex/convert.go +++ b/pkg/exchange/okex/convert.go @@ -128,36 +128,14 @@ func segmentOrderDetails(orderDetails []okexapi.OrderDetails) (trades, orders [] func toGlobalTrades(orderDetails []okexapi.OrderDetails) ([]types.Trade, error) { var trades []types.Trade + var err error for _, orderDetail := range orderDetails { - tradeID, err := strconv.ParseInt(orderDetail.LastTradeID, 10, 64) - if err != nil { - return trades, errors.Wrapf(err, "error parsing tradeId value: %s", orderDetail.LastTradeID) + trade, err2 := toGlobalTrade(&orderDetail) + if err2 != nil { + err = multierr.Append(err, err2) + continue } - - orderID, err := strconv.ParseInt(orderDetail.OrderID, 10, 64) - if err != nil { - return trades, errors.Wrapf(err, "error parsing ordId value: %s", orderDetail.OrderID) - } - - side := types.SideType(strings.ToUpper(string(orderDetail.Side))) - - trades = append(trades, types.Trade{ - ID: uint64(tradeID), - OrderID: uint64(orderID), - Exchange: types.ExchangeOKEx, - Price: orderDetail.LastFilledPrice, - Quantity: orderDetail.LastFilledQuantity, - QuoteQuantity: orderDetail.LastFilledPrice.Mul(orderDetail.LastFilledQuantity), - Symbol: toGlobalSymbol(orderDetail.InstrumentID), - Side: side, - IsBuyer: side == types.SideTypeBuy, - IsMaker: orderDetail.ExecutionType == "M", - Time: types.Time(orderDetail.LastFilledTime), - Fee: orderDetail.LastFilledFee, - FeeCurrency: orderDetail.LastFilledFeeCurrency, - IsMargin: false, - IsIsolated: false, - }) + trades = append(trades, *trade) } return trades, nil @@ -280,7 +258,7 @@ func toGlobalOrder(okexOrder *okexapi.OrderDetails) (*types.Order, error) { } isMargin := false - if okexOrder.InstrumentType == string(okexapi.InstrumentTypeMARGIN) { + if okexOrder.InstrumentType == okexapi.InstrumentTypeMARGIN { isMargin = true } @@ -306,3 +284,46 @@ func toGlobalOrder(okexOrder *okexapi.OrderDetails) (*types.Order, error) { IsIsolated: false, }, nil } + +func toGlobalTrade(orderDetail *okexapi.OrderDetails) (*types.Trade, error) { + tradeID, err := strconv.ParseInt(orderDetail.LastTradeID, 10, 64) + if err != nil { + return nil, errors.Wrapf(err, "error parsing tradeId value: %s", orderDetail.LastTradeID) + } + + orderID, err := strconv.ParseInt(orderDetail.OrderID, 10, 64) + if err != nil { + return nil, errors.Wrapf(err, "error parsing ordId value: %s", orderDetail.OrderID) + } + + side := toGlobalSide(orderDetail.Side) + + isMargin := false + if orderDetail.InstrumentType == okexapi.InstrumentTypeMARGIN { + isMargin = true + } + + isFuture := false + if orderDetail.InstrumentType == okexapi.InstrumentTypeFutures { + isFuture = true + } + + return &types.Trade{ + ID: uint64(tradeID), + OrderID: uint64(orderID), + Exchange: types.ExchangeOKEx, + Price: orderDetail.LastFilledPrice, + Quantity: orderDetail.LastFilledQuantity, + QuoteQuantity: orderDetail.LastFilledPrice.Mul(orderDetail.LastFilledQuantity), + Symbol: toGlobalSymbol(orderDetail.InstrumentID), + Side: side, + IsBuyer: side == types.SideTypeBuy, + IsMaker: orderDetail.ExecutionType == "M", + Time: types.Time(orderDetail.LastFilledTime), + Fee: orderDetail.LastFilledFee, + FeeCurrency: orderDetail.LastFilledFeeCurrency, + IsMargin: isMargin, + IsFutures: isFuture, + IsIsolated: false, + }, nil +} diff --git a/pkg/exchange/okex/exchange.go b/pkg/exchange/okex/exchange.go index d09fd76bd..b89d7740a 100644 --- a/pkg/exchange/okex/exchange.go +++ b/pkg/exchange/okex/exchange.go @@ -2,12 +2,14 @@ package okex import ( "context" + "fmt" "math" "strconv" "time" "github.com/pkg/errors" "github.com/sirupsen/logrus" + "go.uber.org/multierr" "golang.org/x/time/rate" "github.com/c9s/bbgo/pkg/exchange/okex/okexapi" @@ -15,7 +17,15 @@ import ( "github.com/c9s/bbgo/pkg/types" ) -var marketDataLimiter = rate.NewLimiter(rate.Every(time.Second/10), 1) +// Okex rate limit list in each api document +// The default order limiter apply 30 requests per second and a 5 initial bucket +// this includes QueryOrder, QueryOrderTrades, SubmitOrder, QueryOpenOrders, CancelOrders +// Market data limiter means public api, this includes QueryMarkets, QueryTicker, QueryTickers, QueryKLines +var ( + marketDataLimiter = rate.NewLimiter(rate.Every(100*time.Millisecond), 5) + tradeRateLimiter = rate.NewLimiter(rate.Every(100*time.Millisecond), 5) + orderRateLimiter = rate.NewLimiter(rate.Every(300*time.Millisecond), 5) +) const ID = "okex" @@ -363,3 +373,44 @@ func (e *Exchange) QueryOrder(ctx context.Context, q types.OrderQuery) (*types.O return toGlobalOrder(order) } + +// Query order trades can query trades in last 3 months. +func (e *Exchange) QueryOrderTrades(ctx context.Context, q types.OrderQuery) ([]types.Trade, error) { + if len(q.ClientOrderID) != 0 { + log.Warn("!!!OKEX EXCHANGE API NOTICE!!! Okex does not support searching for trades using OrderClientId.") + } + + req := e.client.NewGetTransactionHistoriesRequest() + if len(q.Symbol) != 0 { + req.InstrumentID(q.Symbol) + } + + if len(q.OrderID) != 0 { + req.OrderID(q.OrderID) + } + + if err := orderRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("order rate limiter wait error: %w", err) + } + response, err := req.Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query order trades, err: %w", err) + } + + var trades []types.Trade + var errs error + for _, trade := range response { + res, err := toGlobalTrade(&trade) + if err != nil { + errs = multierr.Append(errs, err) + continue + } + trades = append(trades, *res) + } + + if errs != nil { + return nil, errs + } + + return trades, nil +} diff --git a/pkg/exchange/okex/okexapi/get_transaction_histories_request.go b/pkg/exchange/okex/okexapi/get_transaction_histories_request.go new file mode 100644 index 000000000..7e16d3b54 --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_transaction_histories_request.go @@ -0,0 +1,40 @@ +package okexapi + +import ( + "time" + + "github.com/c9s/requestgen" +) + +//go:generate GetRequest -url "/api/v5/trade/fills-history" -type GetTransactionHistoriesRequest -responseDataType .APIResponse +type GetTransactionHistoriesRequest struct { + client requestgen.AuthenticatedAPIClient + + instrumentType InstrumentType `param:"instType,query"` + instrumentID *string `param:"instId,query"` + orderType *OrderType `param:"ordType,query"` + orderID string `param:"ordId,query"` + // Underlying and InstrumentFamily Applicable to FUTURES/SWAP/OPTION + underlying *string `param:"uly,query"` + instrumentFamily *string `param:"instFamily,query"` + + after *string `param:"after,query"` + before *string `param:"before,query"` + startTime *time.Time `param:"begin,query,milliseconds"` + + // endTime for each request, startTime and endTime can be any interval, but should be in last 3 months + endTime *time.Time `param:"end,query,milliseconds"` + + // limit for data size per page. Default: 100 + limit *uint64 `param:"limit,query"` +} + +type OrderList []OrderDetails + +// NewGetOrderHistoriesRequest is descending order by createdTime +func (c *RestClient) NewGetTransactionHistoriesRequest() *GetTransactionHistoriesRequest { + return &GetTransactionHistoriesRequest{ + client: c, + instrumentType: InstrumentTypeSpot, + } +} diff --git a/pkg/exchange/okex/okexapi/get_transaction_histories_request_requestgen.go b/pkg/exchange/okex/okexapi/get_transaction_histories_request_requestgen.go new file mode 100644 index 000000000..f88a68513 --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_transaction_histories_request_requestgen.go @@ -0,0 +1,305 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v5/trade/fills-history -type GetTransactionHistoriesRequest -responseDataType .OrderList"; DO NOT EDIT. + +package okexapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetTransactionHistoriesRequest) InstrumentType(instrumentType InstrumentType) *GetTransactionHistoriesRequest { + g.instrumentType = instrumentType + return g +} + +func (g *GetTransactionHistoriesRequest) InstrumentID(instrumentID string) *GetTransactionHistoriesRequest { + g.instrumentID = &instrumentID + return g +} + +func (g *GetTransactionHistoriesRequest) OrderType(orderType OrderType) *GetTransactionHistoriesRequest { + g.orderType = &orderType + return g +} + +func (g *GetTransactionHistoriesRequest) OrderID(orderID string) *GetTransactionHistoriesRequest { + g.orderID = orderID + return g +} + +func (g *GetTransactionHistoriesRequest) Underlying(underlying string) *GetTransactionHistoriesRequest { + g.underlying = &underlying + return g +} + +func (g *GetTransactionHistoriesRequest) InstrumentFamily(instrumentFamily string) *GetTransactionHistoriesRequest { + g.instrumentFamily = &instrumentFamily + return g +} + +func (g *GetTransactionHistoriesRequest) After(after string) *GetTransactionHistoriesRequest { + g.after = &after + return g +} + +func (g *GetTransactionHistoriesRequest) Before(before string) *GetTransactionHistoriesRequest { + g.before = &before + return g +} + +func (g *GetTransactionHistoriesRequest) StartTime(startTime time.Time) *GetTransactionHistoriesRequest { + g.startTime = &startTime + return g +} + +func (g *GetTransactionHistoriesRequest) EndTime(endTime time.Time) *GetTransactionHistoriesRequest { + g.endTime = &endTime + return g +} + +func (g *GetTransactionHistoriesRequest) Limit(limit uint64) *GetTransactionHistoriesRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetTransactionHistoriesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check instrumentType field -> json key instType + instrumentType := g.instrumentType + + // TEMPLATE check-valid-values + switch instrumentType { + case InstrumentTypeSpot, InstrumentTypeSwap, InstrumentTypeFutures, InstrumentTypeOption, InstrumentTypeMARGIN: + params["instType"] = instrumentType + + default: + return nil, fmt.Errorf("instType value %v is invalid", instrumentType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of instrumentType + params["instType"] = instrumentType + // check instrumentID field -> json key instId + if g.instrumentID != nil { + instrumentID := *g.instrumentID + + // assign parameter of instrumentID + params["instId"] = instrumentID + } else { + } + // check orderType field -> json key ordType + if g.orderType != nil { + orderType := *g.orderType + + // TEMPLATE check-valid-values + switch orderType { + case OrderTypeMarket, OrderTypeLimit, OrderTypePostOnly, OrderTypeFOK, OrderTypeIOC: + params["ordType"] = orderType + + default: + return nil, fmt.Errorf("ordType value %v is invalid", orderType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of orderType + params["ordType"] = orderType + } else { + } + // check orderID field -> json key ordId + orderID := g.orderID + + // assign parameter of orderID + params["ordId"] = orderID + // check underlying field -> json key uly + if g.underlying != nil { + underlying := *g.underlying + + // assign parameter of underlying + params["uly"] = underlying + } else { + } + // check instrumentFamily field -> json key instFamily + if g.instrumentFamily != nil { + instrumentFamily := *g.instrumentFamily + + // assign parameter of instrumentFamily + params["instFamily"] = instrumentFamily + } else { + } + // check after field -> json key after + if g.after != nil { + after := *g.after + + // assign parameter of after + params["after"] = after + } else { + } + // check before field -> json key before + if g.before != nil { + before := *g.before + + // assign parameter of before + params["before"] = before + } else { + } + // check startTime field -> json key begin + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["begin"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key end + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["end"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetTransactionHistoriesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetTransactionHistoriesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetTransactionHistoriesRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetTransactionHistoriesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetTransactionHistoriesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetTransactionHistoriesRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetTransactionHistoriesRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetTransactionHistoriesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetTransactionHistoriesRequest) Do(ctx context.Context) (OrderList, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + apiURL := "/api/v5/trade/fills-history" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data OrderList + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/okex/okexapi/trade.go b/pkg/exchange/okex/okexapi/trade.go index 4c0ecf26a..164ae46da 100644 --- a/pkg/exchange/okex/okexapi/trade.go +++ b/pkg/exchange/okex/okexapi/trade.go @@ -251,7 +251,7 @@ func (r *BatchPlaceOrderRequest) Do(ctx context.Context) ([]OrderResponse, error } type OrderDetails struct { - InstrumentType string `json:"instType"` + InstrumentType InstrumentType `json:"instType"` InstrumentID string `json:"instId"` Tag string `json:"tag"` Price fixedpoint.Value `json:"px"` @@ -275,6 +275,7 @@ type OrderDetails struct { LastFilledTime types.MillisecondTimestamp `json:"fillTime"` LastFilledFee fixedpoint.Value `json:"fillFee"` LastFilledFeeCurrency string `json:"fillFeeCcy"` + LastFilledPnl fixedpoint.Value `json:"fillPnl"` // ExecutionType = liquidity (M = maker or T = taker) ExecutionType string `json:"execType"` diff --git a/pkg/exchange/okex/query_order_trades_test.go b/pkg/exchange/okex/query_order_trades_test.go new file mode 100644 index 000000000..15aad9643 --- /dev/null +++ b/pkg/exchange/okex/query_order_trades_test.go @@ -0,0 +1,40 @@ +package okex + +import ( + "context" + "testing" + "time" + + "github.com/c9s/bbgo/pkg/testutil" + "github.com/c9s/bbgo/pkg/types" + "github.com/stretchr/testify/assert" +) + +func Test_QueryOrderTrades(t *testing.T) { + + key, secret, passphrase, ok := testutil.IntegrationTestWithPassphraseConfigured(t, "OKEX") + if !ok { + t.Skip("Please configure all credentials about OKEX") + } + + e := New(key, secret, passphrase) + + queryOrder := types.OrderQuery{ + OrderID: "609869603774656544", + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + transactionDetail, err := e.QueryOrderTrades(ctx, queryOrder) + if assert.NoError(t, err) { + assert.NotEmpty(t, transactionDetail) + } + t.Logf("transaction detail: %+v", transactionDetail) + queryOrder = types.OrderQuery{ + Symbol: "BTC-USDT", + } + transactionDetail, err = e.QueryOrderTrades(ctx, queryOrder) + if assert.NoError(t, err) { + assert.NotEmpty(t, transactionDetail) + } + t.Logf("transaction detail: %+v", transactionDetail) +}