From 3978fca27df6b9e524092e93568c8c65a22f8b87 Mon Sep 17 00:00:00 2001 From: Edwin Date: Mon, 6 Nov 2023 11:51:16 +0800 Subject: [PATCH] pkg/exchange: support query closed orders --- .../bitget/bitgetapi/v2/client_test.go | 12 +- .../v2/get_history_orders_request.go | 102 ++++++++ .../get_history_orders_request_requestgen.go | 222 ++++++++++++++++++ .../v2/get_history_orders_request_test.go | 121 ++++++++++ pkg/exchange/bitget/convert.go | 85 +++++++ pkg/exchange/bitget/convert_test.go | 193 +++++++++++++++ pkg/exchange/bitget/exchange.go | 64 ++++- 7 files changed, 792 insertions(+), 7 deletions(-) create mode 100644 pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request.go create mode 100644 pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request_requestgen.go create mode 100644 pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request_test.go diff --git a/pkg/exchange/bitget/bitgetapi/v2/client_test.go b/pkg/exchange/bitget/bitgetapi/v2/client_test.go index 21c86dfc6..3d56735e9 100644 --- a/pkg/exchange/bitget/bitgetapi/v2/client_test.go +++ b/pkg/exchange/bitget/bitgetapi/v2/client_test.go @@ -2,12 +2,11 @@ package bitgetapi import ( "context" + "github.com/stretchr/testify/assert" "os" "strconv" "testing" - "github.com/stretchr/testify/assert" - "github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi" "github.com/c9s/bbgo/pkg/testutil" ) @@ -25,7 +24,6 @@ func getTestClientOrSkip(t *testing.T) *Client { client := bitgetapi.NewClient() client.Auth(key, secret, os.Getenv("BITGET_API_PASSPHRASE")) - return NewClient(client) } @@ -39,4 +37,12 @@ func TestClient(t *testing.T) { assert.NoError(t, err) t.Logf("resp: %+v", resp) }) + + t.Run("GetHistoryOrdersRequest", func(t *testing.T) { + // market buy + req, err := client.NewGetHistoryOrdersRequest().Symbol("APEUSDT").Do(ctx) + assert.NoError(t, err) + + t.Logf("place order resp: %+v", req) + }) } diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request.go b/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request.go new file mode 100644 index 000000000..51807eb5e --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request.go @@ -0,0 +1,102 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "encoding/json" + "fmt" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" + "github.com/c9s/requestgen" +) + +type FeeDetail struct { + // NewFees should have a value because when I was integrating, it already prompted, + // "If there is no 'newFees' field, this data represents earlier historical data." + NewFees struct { + // Amount deducted by coupons, unit:currency obtained from the transaction. + DeductedByCoupon fixedpoint.Value `json:"c"` + // Amount deducted in BGB (Bitget Coin), unit:BGB + DeductedInBGB fixedpoint.Value `json:"d"` + // If the BGB balance is insufficient to cover the fees, the remaining amount is deducted from the + //currency obtained from the transaction. + DeductedFromCurrency fixedpoint.Value `json:"r"` + // The total fee amount to be paid, unit :currency obtained from the transaction. + ToBePaid fixedpoint.Value `json:"t"` + // ignored + Deduction bool `json:"deduction"` + // ignored + TotalDeductionFee fixedpoint.Value `json:"totalDeductionFee"` + } `json:"newFees"` +} + +type OrderDetail struct { + UserId types.StrInt64 `json:"userId"` + Symbol string `json:"symbol"` + // OrderId are always numeric. It's confirmed with official customer service. https://t.me/bitgetOpenapi/24172 + OrderId types.StrInt64 `json:"orderId"` + ClientOrderId string `json:"clientOid"` + Price fixedpoint.Value `json:"price"` + // Size is base coin when orderType=limit; quote coin when orderType=market + Size fixedpoint.Value `json:"size"` + OrderType OrderType `json:"orderType"` + Side SideType `json:"side"` + Status OrderStatus `json:"status"` + PriceAvg fixedpoint.Value `json:"priceAvg"` + BaseVolume fixedpoint.Value `json:"baseVolume"` + QuoteVolume fixedpoint.Value `json:"quoteVolume"` + EnterPointSource string `json:"enterPointSource"` + // The value is json string, so we unmarshal it after unmarshal OrderDetail + FeeDetailRaw string `json:"feeDetail"` + OrderSource string `json:"orderSource"` + CTime types.MillisecondTimestamp `json:"cTime"` + UTime types.MillisecondTimestamp `json:"uTime"` + + FeeDetail FeeDetail +} + +func (o *OrderDetail) UnmarshalJSON(data []byte) error { + if o == nil { + return fmt.Errorf("failed to unmarshal json from nil pointer order detail") + } + // define new type to avoid loop reference + type AuxOrderDetail OrderDetail + + var aux AuxOrderDetail + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *o = OrderDetail(aux) + + if len(aux.FeeDetailRaw) == 0 { + return nil + } + + var feeDetail FeeDetail + if err := json.Unmarshal([]byte(aux.FeeDetailRaw), &feeDetail); err != nil { + return fmt.Errorf("unexpected fee detail raw: %s, err: %w", aux.FeeDetailRaw, err) + } + o.FeeDetail = feeDetail + + return nil +} + +//go:generate GetRequest -url "/api/v2/spot/trade/history-orders" -type GetHistoryOrdersRequest -responseDataType []OrderDetail +type GetHistoryOrdersRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol *string `param:"symbol,query"` + // Limit number default 100 max 100 + limit *string `param:"limit,query"` + // idLessThan requests the content on the page before this ID (older data), the value input should be the orderId of the corresponding interface. + idLessThan *string `param:"idLessThan,query"` + startTime *int64 `param:"startTime,query"` + endTime *int64 `param:"endTime,query"` + orderId *string `param:"orderId,query"` +} + +func (c *Client) NewGetHistoryOrdersRequest() *GetHistoryOrdersRequest { + return &GetHistoryOrdersRequest{client: c.Client} +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request_requestgen.go new file mode 100644 index 000000000..0a681f322 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request_requestgen.go @@ -0,0 +1,222 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v2/spot/trade/history-orders -type GetHistoryOrdersRequest -responseDataType []OrderDetail"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi" + "net/url" + "reflect" + "regexp" +) + +func (g *GetHistoryOrdersRequest) Symbol(symbol string) *GetHistoryOrdersRequest { + g.symbol = &symbol + return g +} + +func (g *GetHistoryOrdersRequest) Limit(limit string) *GetHistoryOrdersRequest { + g.limit = &limit + return g +} + +func (g *GetHistoryOrdersRequest) IdLessThan(idLessThan string) *GetHistoryOrdersRequest { + g.idLessThan = &idLessThan + return g +} + +func (g *GetHistoryOrdersRequest) StartTime(startTime int64) *GetHistoryOrdersRequest { + g.startTime = &startTime + return g +} + +func (g *GetHistoryOrdersRequest) EndTime(endTime int64) *GetHistoryOrdersRequest { + g.endTime = &endTime + return g +} + +func (g *GetHistoryOrdersRequest) OrderId(orderId string) *GetHistoryOrdersRequest { + g.orderId = &orderId + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetHistoryOrdersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + if g.symbol != nil { + symbol := *g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check idLessThan field -> json key idLessThan + if g.idLessThan != nil { + idLessThan := *g.idLessThan + + // assign parameter of idLessThan + params["idLessThan"] = idLessThan + } else { + } + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + params["startTime"] = startTime + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + params["endTime"] = endTime + } else { + } + // check orderId field -> json key orderId + if g.orderId != nil { + orderId := *g.orderId + + // assign parameter of orderId + params["orderId"] = orderId + } 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 *GetHistoryOrdersRequest) 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 *GetHistoryOrdersRequest) 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 *GetHistoryOrdersRequest) 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 *GetHistoryOrdersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetHistoryOrdersRequest) 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 *GetHistoryOrdersRequest) 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 *GetHistoryOrdersRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetHistoryOrdersRequest) 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 *GetHistoryOrdersRequest) Do(ctx context.Context) ([]OrderDetail, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + apiURL := "/api/v2/spot/trade/history-orders" + + 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 bitgetapi.APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + var data []OrderDetail + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request_test.go b/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request_test.go new file mode 100644 index 000000000..07e319fab --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request_test.go @@ -0,0 +1,121 @@ +package bitgetapi + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +func TestOrderDetail_UnmarshalJSON(t *testing.T) { + var ( + assert = assert.New(t) + ) + t.Run("empty fee", func(t *testing.T) { + input := `{ + "userId":"8672173294", + "symbol":"APEUSDT", + "orderId":"1104342023170068480", + "clientOid":"f3d6a1ee-4e94-48b5-a6e0-25f3e93d92e1", + "price":"1.2000000000000000", + "size":"5.0000000000000000", + "orderType":"limit", + "side":"buy", + "status":"cancelled", + "priceAvg":"0", + "baseVolume":"0.0000000000000000", + "quoteVolume":"0.0000000000000000", + "enterPointSource":"API", + "feeDetail":"", + "orderSource":"normal", + "cTime":"1699021576683", + "uTime":"1699021649099" + }` + var od OrderDetail + err := json.Unmarshal([]byte(input), &od) + assert.NoError(err) + assert.Equal(OrderDetail{ + UserId: types.StrInt64(8672173294), + Symbol: "APEUSDT", + OrderId: types.StrInt64(1104342023170068480), + ClientOrderId: "f3d6a1ee-4e94-48b5-a6e0-25f3e93d92e1", + Price: fixedpoint.NewFromFloat(1.2), + Size: fixedpoint.NewFromFloat(5), + OrderType: OrderTypeLimit, + Side: SideTypeBuy, + Status: OrderStatusCancelled, + PriceAvg: fixedpoint.Zero, + BaseVolume: fixedpoint.Zero, + QuoteVolume: fixedpoint.Zero, + EnterPointSource: "API", + FeeDetailRaw: "", + OrderSource: "normal", + CTime: types.NewMillisecondTimestampFromInt(1699021576683), + UTime: types.NewMillisecondTimestampFromInt(1699021649099), + FeeDetail: FeeDetail{}, + }, od) + }) + + t.Run("fee", func(t *testing.T) { + input := `{ + "userId":"8672173294", + "symbol":"APEUSDT", + "orderId":"1104337778433757184", + "clientOid":"8afea7bd-d873-44fe-aff8-6a1fae3cc765", + "price":"1.4000000000000000", + "size":"5.0000000000000000", + "orderType":"limit", + "side":"sell", + "status":"filled", + "priceAvg":"1.4001000000000000", + "baseVolume":"5.0000000000000000", + "quoteVolume":"7.0005000000000000", + "enterPointSource":"API", + "feeDetail":"{\"newFees\":{\"c\":0,\"d\":0,\"deduction\":false,\"r\":-0.0070005,\"t\":-0.0070005,\"totalDeductionFee\":0},\"USDT\":{\"deduction\":false,\"feeCoinCode\":\"USDT\",\"totalDeductionFee\":0,\"totalFee\":-0.007000500000}}", + "orderSource":"normal", + "cTime":"1699020564659", + "uTime":"1699020564688" + }` + var od OrderDetail + err := json.Unmarshal([]byte(input), &od) + assert.NoError(err) + assert.Equal(OrderDetail{ + UserId: types.StrInt64(8672173294), + Symbol: "APEUSDT", + OrderId: types.StrInt64(1104337778433757184), + ClientOrderId: "8afea7bd-d873-44fe-aff8-6a1fae3cc765", + Price: fixedpoint.NewFromFloat(1.4), + Size: fixedpoint.NewFromFloat(5), + OrderType: OrderTypeLimit, + Side: SideTypeSell, + Status: OrderStatusFilled, + PriceAvg: fixedpoint.NewFromFloat(1.4001), + BaseVolume: fixedpoint.NewFromFloat(5), + QuoteVolume: fixedpoint.NewFromFloat(7.0005), + EnterPointSource: "API", + FeeDetailRaw: `{"newFees":{"c":0,"d":0,"deduction":false,"r":-0.0070005,"t":-0.0070005,"totalDeductionFee":0},"USDT":{"deduction":false,"feeCoinCode":"USDT","totalDeductionFee":0,"totalFee":-0.007000500000}}`, + OrderSource: "normal", + CTime: types.NewMillisecondTimestampFromInt(1699020564659), + UTime: types.NewMillisecondTimestampFromInt(1699020564688), + FeeDetail: FeeDetail{ + NewFees: struct { + DeductedByCoupon fixedpoint.Value `json:"c"` + DeductedInBGB fixedpoint.Value `json:"d"` + DeductedFromCurrency fixedpoint.Value `json:"r"` + ToBePaid fixedpoint.Value `json:"t"` + Deduction bool `json:"deduction"` + TotalDeductionFee fixedpoint.Value `json:"totalDeductionFee"` + }{DeductedByCoupon: fixedpoint.NewFromFloat(0), + DeductedInBGB: fixedpoint.NewFromFloat(0), + DeductedFromCurrency: fixedpoint.NewFromFloat(-0.0070005), + ToBePaid: fixedpoint.NewFromFloat(-0.0070005), + Deduction: false, + TotalDeductionFee: fixedpoint.Zero, + }, + }, + }, od) + }) +} diff --git a/pkg/exchange/bitget/convert.go b/pkg/exchange/bitget/convert.go index a4a9756d0..24785a81f 100644 --- a/pkg/exchange/bitget/convert.go +++ b/pkg/exchange/bitget/convert.go @@ -1,6 +1,7 @@ package bitget import ( + "errors" "fmt" "math" "strconv" @@ -158,3 +159,87 @@ func unfilledOrderToGlobalOrder(order v2.UnfilledOrder) (*types.Order, error) { UpdateTime: types.Time(order.UTime.Time()), }, nil } + +func toGlobalOrder(order v2.OrderDetail) (*types.Order, error) { + side, err := toGlobalSideType(order.Side) + if err != nil { + return nil, err + } + + orderType, err := toGlobalOrderType(order.OrderType) + if err != nil { + return nil, err + } + + status, err := toGlobalOrderStatus(order.Status) + if err != nil { + return nil, err + } + + qty := order.Size + price := order.Price + + if orderType == types.OrderTypeMarket { + price = order.PriceAvg + if side == types.SideTypeBuy { + qty, err = processMarketBuyQuantity(order.BaseVolume, order.QuoteVolume, order.PriceAvg, order.Size, order.Status) + if err != nil { + return nil, err + } + } + } + + return &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: order.ClientOrderId, + Symbol: order.Symbol, + Side: side, + Type: orderType, + Quantity: qty, + Price: price, + // Bitget does not include the "time-in-force" field in its API response for spot trading, so we set GTC. + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: uint64(order.OrderId), + UUID: strconv.FormatInt(int64(order.OrderId), 10), + Status: status, + ExecutedQuantity: order.BaseVolume, + IsWorking: order.Status.IsWorking(), + CreationTime: types.Time(order.CTime.Time()), + UpdateTime: types.Time(order.UTime.Time()), + }, nil +} + +// processMarketBuyQuantity returns the estimated base quantity or real. The order size will be 'quote quantity' when side is buy and +// type is market, so we need to convert that. This is because the unit of types.Order.Quantity is base coin. +// +// If the order status is PartialFilled, return estimated base coin quantity. +// If the order status is Filled, return the filled base quantity instead of the buy quantity, because a market order on the buy side +// cannot execute all. +// Otherwise, return zero. +func processMarketBuyQuantity(filledQty, filledPrice, priceAvg, buyQty fixedpoint.Value, orderStatus v2.OrderStatus) (fixedpoint.Value, error) { + switch orderStatus { + case v2.OrderStatusInit, v2.OrderStatusNew, v2.OrderStatusLive, v2.OrderStatusCancelled: + return fixedpoint.Zero, nil + + case v2.OrderStatusPartialFilled: + // sanity check for avoid divide 0 + if priceAvg.IsZero() { + return fixedpoint.Zero, errors.New("priceAvg for a partialFilled should not be zero") + } + // calculate the remaining quote coin quantity. + remainPrice := buyQty.Sub(filledPrice) + // calculate the remaining base coin quantity. + remainBaseCoinQty := remainPrice.Div(priceAvg) + // Estimated quantity that may be purchased. + return filledQty.Add(remainBaseCoinQty), nil + + case v2.OrderStatusFilled: + // Market buy orders may not purchase the entire quantity, hence the use of filledQty here. + return filledQty, nil + + default: + return fixedpoint.Zero, fmt.Errorf("failed to execute market buy quantity due to unexpected order status %s ", orderStatus) + } +} diff --git a/pkg/exchange/bitget/convert_test.go b/pkg/exchange/bitget/convert_test.go index 8b8bc61c7..174798074 100644 --- a/pkg/exchange/bitget/convert_test.go +++ b/pkg/exchange/bitget/convert_test.go @@ -272,3 +272,196 @@ func Test_unfilledOrderToGlobalOrder(t *testing.T) { assert.ErrorContains(err, "xxx") }) } + +func Test_toGlobalOrder(t *testing.T) { + var ( + assert = assert.New(t) + orderId = 1105087175647989764 + unfilledOrder = v2.OrderDetail{ + UserId: 123456, + Symbol: "BTCUSDT", + OrderId: types.StrInt64(orderId), + ClientOrderId: "74b86af3-6098-479c-acac-bfb074c067f3", + Price: fixedpoint.NewFromFloat(1.2), + Size: fixedpoint.NewFromFloat(5), + OrderType: v2.OrderTypeLimit, + Side: v2.SideTypeBuy, + Status: v2.OrderStatusFilled, + PriceAvg: fixedpoint.NewFromFloat(1.4), + BaseVolume: fixedpoint.NewFromFloat(5), + QuoteVolume: fixedpoint.NewFromFloat(7.0005), + EnterPointSource: "API", + FeeDetailRaw: `{\"newFees\":{\"c\":0,\"d\":0,\"deduction\":false,\"r\":-0.0070005,\"t\":-0.0070005,\"totalDeductionFee\":0},\"USDT\":{\"deduction\":false,\"feeCoinCode\":\"USDT\",\"totalDeductionFee\":0,\"totalFee\":-0.007000500000}}`, + OrderSource: "normal", + CTime: types.NewMillisecondTimestampFromInt(1660704288118), + UTime: types.NewMillisecondTimestampFromInt(1660704288118), + } + + expOrder = &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "74b86af3-6098-479c-acac-bfb074c067f3", + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.NewFromFloat(5), + Price: fixedpoint.NewFromFloat(1.2), + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: uint64(orderId), + UUID: strconv.FormatInt(int64(orderId), 10), + Status: types.OrderStatusFilled, + ExecutedQuantity: fixedpoint.NewFromFloat(5), + IsWorking: false, + CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()), + UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()), + } + ) + + t.Run("succeeds with limit buy", func(t *testing.T) { + order, err := toGlobalOrder(unfilledOrder) + assert.NoError(err) + assert.Equal(expOrder, order) + }) + + t.Run("succeeds with limit sell", func(t *testing.T) { + newUnfilledOrder := unfilledOrder + newUnfilledOrder.Side = v2.SideTypeSell + + newExpOrder := *expOrder + newExpOrder.Side = types.SideTypeSell + + order, err := toGlobalOrder(newUnfilledOrder) + assert.NoError(err) + assert.Equal(&newExpOrder, order) + }) + + t.Run("succeeds with market sell", func(t *testing.T) { + newUnfilledOrder := unfilledOrder + newUnfilledOrder.Side = v2.SideTypeSell + newUnfilledOrder.OrderType = v2.OrderTypeMarket + + newExpOrder := *expOrder + newExpOrder.Side = types.SideTypeSell + newExpOrder.Type = types.OrderTypeMarket + newExpOrder.Price = newUnfilledOrder.PriceAvg + + order, err := toGlobalOrder(newUnfilledOrder) + assert.NoError(err) + assert.Equal(&newExpOrder, order) + }) + + t.Run("succeeds with market buy", func(t *testing.T) { + newUnfilledOrder := unfilledOrder + newUnfilledOrder.Side = v2.SideTypeBuy + newUnfilledOrder.OrderType = v2.OrderTypeMarket + + newExpOrder := *expOrder + newExpOrder.Side = types.SideTypeBuy + newExpOrder.Type = types.OrderTypeMarket + newExpOrder.Price = newUnfilledOrder.PriceAvg + newExpOrder.Quantity = newUnfilledOrder.BaseVolume + + order, err := toGlobalOrder(newUnfilledOrder) + assert.NoError(err) + assert.Equal(&newExpOrder, order) + }) + + t.Run("succeeds with limit buy", func(t *testing.T) { + order, err := toGlobalOrder(unfilledOrder) + assert.NoError(err) + assert.Equal(&types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "74b86af3-6098-479c-acac-bfb074c067f3", + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.NewFromFloat(5), + Price: fixedpoint.NewFromFloat(1.2), + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: uint64(orderId), + UUID: strconv.FormatInt(int64(orderId), 10), + Status: types.OrderStatusFilled, + ExecutedQuantity: fixedpoint.NewFromFloat(5), + IsWorking: false, + CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()), + UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()), + }, order) + }) + + t.Run("failed to convert side", func(t *testing.T) { + newOrder := unfilledOrder + newOrder.Side = "xxx" + + _, err := toGlobalOrder(newOrder) + assert.ErrorContains(err, "xxx") + }) + + t.Run("failed to convert oder type", func(t *testing.T) { + newOrder := unfilledOrder + newOrder.OrderType = "xxx" + + _, err := toGlobalOrder(newOrder) + assert.ErrorContains(err, "xxx") + }) + + t.Run("failed to convert oder status", func(t *testing.T) { + newOrder := unfilledOrder + newOrder.Status = "xxx" + + _, err := toGlobalOrder(newOrder) + assert.ErrorContains(err, "xxx") + }) +} + +func Test_processMarketBuyQuantity(t *testing.T) { + var ( + assert = assert.New(t) + filledBaseCoinQty = fixedpoint.NewFromFloat(3.5648) + filledPrice = fixedpoint.NewFromFloat(4.99998848) + priceAvg = fixedpoint.NewFromFloat(1.4026) + buyQty = fixedpoint.NewFromFloat(5) + ) + + t.Run("zero quantity on Init/New/Live/Cancelled", func(t *testing.T) { + qty, err := processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusInit) + assert.NoError(err) + assert.Equal(fixedpoint.Zero, qty) + + qty, err = processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusNew) + assert.NoError(err) + assert.Equal(fixedpoint.Zero, qty) + + qty, err = processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusLive) + assert.NoError(err) + assert.Equal(fixedpoint.Zero, qty) + + qty, err = processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusCancelled) + assert.NoError(err) + assert.Equal(fixedpoint.Zero, qty) + }) + + t.Run("5 on PartialFilled", func(t *testing.T) { + priceAvg := fixedpoint.NewFromFloat(2) + buyQty := fixedpoint.NewFromFloat(10) + filledPrice := fixedpoint.NewFromFloat(4) + filledBaseCoinQty := fixedpoint.NewFromFloat(2) + + qty, err := processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusPartialFilled) + assert.NoError(err) + assert.Equal(fixedpoint.NewFromFloat(5), qty) + }) + + t.Run("3.5648 on Filled", func(t *testing.T) { + qty, err := processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusFilled) + assert.NoError(err) + assert.Equal(fixedpoint.NewFromFloat(3.5648), qty) + }) + + t.Run("unexpected order status", func(t *testing.T) { + _, err := processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, "xxx") + assert.ErrorContains(err, "xxx") + }) +} diff --git a/pkg/exchange/bitget/exchange.go b/pkg/exchange/bitget/exchange.go index 542a3bfc2..298054e0a 100644 --- a/pkg/exchange/bitget/exchange.go +++ b/pkg/exchange/bitget/exchange.go @@ -7,6 +7,7 @@ import ( "time" "github.com/sirupsen/logrus" + "go.uber.org/multierr" "golang.org/x/time/rate" "github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi" @@ -19,7 +20,9 @@ const ( PlatformToken = "BGB" - queryOpenOrdersLimit = 100 + queryLimit = 100 + maxOrderIdLen = 36 + queryMaxDuration = 90 * 24 * time.Hour ) var log = logrus.WithFields(logrus.Fields{ @@ -37,6 +40,8 @@ var ( queryTickersRateLimiter = rate.NewLimiter(rate.Every(time.Second/10), 5) // queryOpenOrdersRateLimiter has its own rate limit. https://www.bitget.com/zh-CN/api-doc/spot/trade/Get-Unfilled-Orders queryOpenOrdersRateLimiter = rate.NewLimiter(rate.Every(time.Second/10), 5) + // closedQueryOrdersRateLimiter has its own rate limit. https://www.bitget.com/api-doc/spot/trade/Get-History-Orders + closedQueryOrdersRateLimiter = rate.NewLimiter(rate.Every(time.Second/15), 5) ) type Exchange struct { @@ -192,7 +197,7 @@ func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders [ req := e.v2Client.NewGetUnfilledOrdersRequest(). Symbol(symbol). - Limit(strconv.FormatInt(queryOpenOrdersLimit, 10)) + Limit(strconv.FormatInt(queryLimit, 10)) if nextCursor != 0 { req.IdLessThan(strconv.FormatInt(int64(nextCursor), 10)) } @@ -213,11 +218,11 @@ func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders [ orderLen := len(openOrders) // a defensive programming to ensure the length of order response is expected. - if orderLen > queryOpenOrdersLimit { + if orderLen > queryLimit { return nil, fmt.Errorf("unexpected open orders length %d", orderLen) } - if orderLen < queryOpenOrdersLimit { + if orderLen < queryLimit { break } nextCursor = openOrders[orderLen-1].OrderId @@ -226,6 +231,57 @@ func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders [ return orders, nil } +// QueryClosedOrders queries closed order by time range(`CTime`) and id. The order of the response is in descending order. +// If you need to retrieve all data, please utilize the function pkg/exchange/batch.ClosedOrderBatchQuery. +// +// ** Since is inclusive, Until is exclusive. If you use a time range to query, you must provide both a start time and an end time. ** +// ** Since and Until cannot exceed 90 days. ** +// ** Since from the last 90 days can be queried. ** +func (e *Exchange) QueryClosedOrders(ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64) (orders []types.Order, err error) { + if since.Sub(time.Now()) > queryMaxDuration { + return nil, fmt.Errorf("start time from the last 90 days can be queried, got: %s", since) + } + if until.Before(since) { + return nil, fmt.Errorf("end time %s before start %s", until, since) + } + if until.Sub(since) > queryMaxDuration { + return nil, fmt.Errorf("the start time %s and end time %s cannot exceed 90 days", since, until) + } + if lastOrderID != 0 { + log.Warn("!!!BITGET EXCHANGE API NOTICE!!! The order of response is in descending order, so the last order id not supported.") + } + + if err := closedQueryOrdersRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("query closed order rate limiter wait error: %w", err) + } + res, err := e.v2Client.NewGetHistoryOrdersRequest(). + Symbol(symbol). + Limit(strconv.Itoa(queryLimit)). + StartTime(since.UnixMilli()). + EndTime(until.UnixMilli()). + Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to call get order histories error: %w", err) + } + + for _, order := range res { + o, err2 := toGlobalOrder(order) + if err2 != nil { + err = multierr.Append(err, err2) + continue + } + + if o.Status.Closed() { + orders = append(orders, *o) + } + } + if err != nil { + return nil, err + } + + return types.SortOrdersAscending(orders), nil +} + func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) error { // TODO implement me panic("implement me")