Merge pull request #1399 from c9s/edwin/bitget/submit-orders

FEATURE: [bitget] support submit order
This commit is contained in:
bailantaotao 2023-11-10 16:02:59 +08:00 committed by GitHub
commit 58a810ecc9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 456 additions and 4 deletions

View File

@ -2,11 +2,12 @@ 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"
)
@ -45,4 +46,16 @@ func TestClient(t *testing.T) {
t.Logf("place order resp: %+v", req)
})
t.Run("PlaceOrderRequest", func(t *testing.T) {
req, err := client.NewPlaceOrderRequest().Symbol("APEUSDT").OrderType(OrderTypeLimit).
Side(SideTypeSell).
Price("2").
Size("5").
Force(OrderForceGTC).
Do(context.Background())
assert.NoError(t, err)
t.Logf("place order resp: %+v", req)
})
}

View File

@ -0,0 +1,29 @@
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 (
"github.com/c9s/requestgen"
)
type PlaceOrderResponse struct {
OrderId string `json:"orderId"`
ClientOrderId string `json:"clientOrderId"`
}
//go:generate PostRequest -url "/api/v2/spot/trade/place-order" -type PlaceOrderRequest -responseDataType .PlaceOrderResponse
type PlaceOrderRequest struct {
client requestgen.AuthenticatedAPIClient
symbol string `param:"symbol"`
orderType OrderType `param:"orderType"`
side SideType `param:"side"`
force OrderForce `param:"force"`
price *string `param:"price"`
size string `param:"size"`
clientOrderId *string `param:"clientOid"`
}
func (c *Client) NewPlaceOrderRequest() *PlaceOrderRequest {
return &PlaceOrderRequest{client: c.Client}
}

View File

@ -0,0 +1,251 @@
// Code generated by "requestgen -method POST -responseType .APIResponse -responseDataField Data -url /api/v2/spot/trade/place-order -type PlaceOrderRequest -responseDataType .PlaceOrderResponse"; DO NOT EDIT.
package bitgetapi
import (
"context"
"encoding/json"
"fmt"
"github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi"
"net/url"
"reflect"
"regexp"
)
func (p *PlaceOrderRequest) Symbol(symbol string) *PlaceOrderRequest {
p.symbol = symbol
return p
}
func (p *PlaceOrderRequest) OrderType(orderType OrderType) *PlaceOrderRequest {
p.orderType = orderType
return p
}
func (p *PlaceOrderRequest) Side(side SideType) *PlaceOrderRequest {
p.side = side
return p
}
func (p *PlaceOrderRequest) Force(force OrderForce) *PlaceOrderRequest {
p.force = force
return p
}
func (p *PlaceOrderRequest) Price(price string) *PlaceOrderRequest {
p.price = &price
return p
}
func (p *PlaceOrderRequest) Size(size string) *PlaceOrderRequest {
p.size = size
return p
}
func (p *PlaceOrderRequest) ClientOrderId(clientOrderId string) *PlaceOrderRequest {
p.clientOrderId = &clientOrderId
return p
}
// GetQueryParameters builds and checks the query parameters and returns url.Values
func (p *PlaceOrderRequest) GetQueryParameters() (url.Values, error) {
var params = map[string]interface{}{}
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 (p *PlaceOrderRequest) GetParameters() (map[string]interface{}, error) {
var params = map[string]interface{}{}
// check symbol field -> json key symbol
symbol := p.symbol
// assign parameter of symbol
params["symbol"] = symbol
// check orderType field -> json key orderType
orderType := p.orderType
// TEMPLATE check-valid-values
switch orderType {
case OrderTypeLimit, OrderTypeMarket:
params["orderType"] = orderType
default:
return nil, fmt.Errorf("orderType value %v is invalid", orderType)
}
// END TEMPLATE check-valid-values
// assign parameter of orderType
params["orderType"] = orderType
// check side field -> json key side
side := p.side
// TEMPLATE check-valid-values
switch side {
case SideTypeBuy, SideTypeSell:
params["side"] = side
default:
return nil, fmt.Errorf("side value %v is invalid", side)
}
// END TEMPLATE check-valid-values
// assign parameter of side
params["side"] = side
// check force field -> json key force
force := p.force
// TEMPLATE check-valid-values
switch force {
case OrderForceGTC, OrderForcePostOnly, OrderForceFOK, OrderForceIOC:
params["force"] = force
default:
return nil, fmt.Errorf("force value %v is invalid", force)
}
// END TEMPLATE check-valid-values
// assign parameter of force
params["force"] = force
// check price field -> json key price
if p.price != nil {
price := *p.price
// assign parameter of price
params["price"] = price
} else {
}
// check size field -> json key size
size := p.size
// assign parameter of size
params["size"] = size
// check clientOrderId field -> json key clientOid
if p.clientOrderId != nil {
clientOrderId := *p.clientOrderId
// assign parameter of clientOrderId
params["clientOid"] = clientOrderId
} else {
}
return params, nil
}
// GetParametersQuery converts the parameters from GetParameters into the url.Values format
func (p *PlaceOrderRequest) GetParametersQuery() (url.Values, error) {
query := url.Values{}
params, err := p.GetParameters()
if err != nil {
return query, err
}
for _k, _v := range params {
if p.isVarSlice(_v) {
p.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 (p *PlaceOrderRequest) GetParametersJSON() ([]byte, error) {
params, err := p.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 (p *PlaceOrderRequest) GetSlugParameters() (map[string]interface{}, error) {
var params = map[string]interface{}{}
return params, nil
}
func (p *PlaceOrderRequest) 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 (p *PlaceOrderRequest) 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 (p *PlaceOrderRequest) isVarSlice(_v interface{}) bool {
rt := reflect.TypeOf(_v)
switch rt.Kind() {
case reflect.Slice:
return true
}
return false
}
func (p *PlaceOrderRequest) GetSlugsMap() (map[string]string, error) {
slugs := map[string]string{}
params, err := p.GetSlugParameters()
if err != nil {
return slugs, nil
}
for _k, _v := range params {
slugs[_k] = fmt.Sprintf("%v", _v)
}
return slugs, nil
}
func (p *PlaceOrderRequest) Do(ctx context.Context) (*PlaceOrderResponse, error) {
params, err := p.GetParameters()
if err != nil {
return nil, err
}
query := url.Values{}
apiURL := "/api/v2/spot/trade/place-order"
req, err := p.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params)
if err != nil {
return nil, err
}
response, err := p.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 PlaceOrderResponse
if err := json.Unmarshal(apiResponse.Data, &data); err != nil {
return nil, err
}
return &data, nil
}

View File

@ -243,3 +243,29 @@ func processMarketBuyQuantity(filledQty, filledPrice, priceAvg, buyQty fixedpoin
return fixedpoint.Zero, fmt.Errorf("failed to execute market buy quantity due to unexpected order status %s ", orderStatus)
}
}
func toLocalOrderType(orderType types.OrderType) (v2.OrderType, error) {
switch orderType {
case types.OrderTypeLimit:
return v2.OrderTypeLimit, nil
case types.OrderTypeMarket:
return v2.OrderTypeMarket, nil
default:
return "", fmt.Errorf("order type %s not supported", orderType)
}
}
func toLocalSide(side types.SideType) (v2.SideType, error) {
switch side {
case types.SideTypeSell:
return v2.SideTypeSell, nil
case types.SideTypeBuy:
return v2.SideTypeBuy, nil
default:
return "", fmt.Errorf("side type %s not supported", side)
}
}

View File

@ -465,3 +465,29 @@ func Test_processMarketBuyQuantity(t *testing.T) {
assert.ErrorContains(err, "xxx")
})
}
func Test_toLocalOrderType(t *testing.T) {
orderType, err := toLocalOrderType(types.OrderTypeLimit)
assert.NoError(t, err)
assert.Equal(t, v2.OrderTypeLimit, orderType)
orderType, err = toLocalOrderType(types.OrderTypeMarket)
assert.NoError(t, err)
assert.Equal(t, v2.OrderTypeMarket, orderType)
_, err = toLocalOrderType("xxx")
assert.ErrorContains(t, err, "xxx")
}
func Test_toLocalSide(t *testing.T) {
orderType, err := toLocalSide(types.SideTypeSell)
assert.NoError(t, err)
assert.Equal(t, v2.SideTypeSell, orderType)
orderType, err = toLocalSide(types.SideTypeBuy)
assert.NoError(t, err)
assert.Equal(t, v2.SideTypeBuy, orderType)
_, err = toLocalOrderType("xxx")
assert.ErrorContains(t, err, "xxx")
}

View File

@ -42,6 +42,8 @@ var (
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)
// submitOrdersRateLimiter has its own rate limit. https://www.bitget.com/zh-CN/api-doc/spot/trade/Place-Order
submitOrdersRateLimiter = rate.NewLimiter(rate.Every(time.Second/5), 5)
)
type Exchange struct {
@ -183,9 +185,114 @@ func (e *Exchange) QueryAccountBalances(ctx context.Context) (types.BalanceMap,
return bals, nil
}
// SubmitOrder submits an order.
//
// Remark:
// 1. We support only GTC for time-in-force, because the response from queryOrder does not include time-in-force information.
// 2. For market buy orders, the size unit is quote currency, whereas the unit for order.Quantity is in base currency.
// Therefore, we need to calculate the equivalent quote currency amount based on the ticker data.
//
// Note that there is a bug in Bitget where you can place a market order with the 'post_only' option successfully,
// which should not be possible. The issue has been reported.
func (e *Exchange) SubmitOrder(ctx context.Context, order types.SubmitOrder) (createdOrder *types.Order, err error) {
// TODO implement me
panic("implement me")
if len(order.Market.Symbol) == 0 {
return nil, fmt.Errorf("order.Market.Symbol is required: %+v", order)
}
req := e.v2Client.NewPlaceOrderRequest()
req.Symbol(order.Market.Symbol)
// set order type
orderType, err := toLocalOrderType(order.Type)
if err != nil {
return nil, err
}
req.OrderType(orderType)
// set side
side, err := toLocalSide(order.Side)
if err != nil {
return nil, err
}
req.Side(side)
// set quantity
qty := order.Quantity
// if the order is market buy, the quantity is quote coin, instead of base coin. so we need to convert it.
if order.Type == types.OrderTypeMarket && order.Side == types.SideTypeBuy {
ticker, err := e.QueryTicker(ctx, order.Market.Symbol)
if err != nil {
return nil, err
}
qty = order.Quantity.Mul(ticker.Buy)
}
req.Size(order.Market.FormatQuantity(qty))
// we support only GTC/PostOnly, this is because:
// 1. We support only SPOT trading.
// 2. The query oepn/closed order does not including the `force` in SPOT.
// If we support FOK/IOC, but you can't query them, that would be unreasonable.
// The other case to consider is 'PostOnly', which is a trade-off because we want to support 'xmaker'.
if order.TimeInForce != types.TimeInForceGTC {
return nil, fmt.Errorf("time-in-force %s not supported", order.TimeInForce)
}
req.Force(v2.OrderForceGTC)
// set price
if order.Type == types.OrderTypeLimit || order.Type == types.OrderTypeLimitMaker {
req.Price(order.Market.FormatPrice(order.Price))
if order.Type == types.OrderTypeLimitMaker {
req.Force(v2.OrderForcePostOnly)
}
}
// set client order id
if len(order.ClientOrderID) > maxOrderIdLen {
return nil, fmt.Errorf("unexpected length of order id, got: %d", len(order.ClientOrderID))
}
if len(order.ClientOrderID) > 0 {
req.ClientOrderId(order.ClientOrderID)
}
if err := submitOrdersRateLimiter.Wait(ctx); err != nil {
return nil, fmt.Errorf("place order rate limiter wait error: %w", err)
}
res, err := req.Do(ctx)
if err != nil {
return nil, fmt.Errorf("failed to place order, order: %#v, err: %w", order, err)
}
if len(res.OrderId) == 0 || (len(order.ClientOrderID) != 0 && res.ClientOrderId != order.ClientOrderID) {
return nil, fmt.Errorf("unexpected order id, resp: %#v, order: %#v", res, order)
}
orderId := res.OrderId
ordersResp, err := e.v2Client.NewGetUnfilledOrdersRequest().OrderId(orderId).Do(ctx)
if err != nil {
return nil, fmt.Errorf("failed to query open order by order id: %s, err: %w", orderId, err)
}
switch len(ordersResp) {
case 0:
// The market order will be executed immediately, so we cannot retrieve it through the NewGetUnfilledOrdersRequest API.
// Try to get the order from the NewGetHistoryOrdersRequest API.
ordersResp, err := e.v2Client.NewGetHistoryOrdersRequest().OrderId(orderId).Do(ctx)
if err != nil {
return nil, fmt.Errorf("failed to query history order by order id: %s, err: %w", orderId, err)
}
if len(ordersResp) != 1 {
return nil, fmt.Errorf("unexpected order length, order id: %s", orderId)
}
return toGlobalOrder(ordersResp[0])
case 1:
return unfilledOrderToGlobalOrder(ordersResp[0])
default:
return nil, fmt.Errorf("unexpected order length, order id: %s", orderId)
}
}
func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders []types.Order, err error) {
@ -238,7 +345,7 @@ func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders [
// ** 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 {
if time.Since(since) > queryMaxDuration {
return nil, fmt.Errorf("start time from the last 90 days can be queried, got: %s", since)
}
if until.Before(since) {