Merge pull request #1412 from c9s/edwin/bitget/add-order-event

FEATURE: [bitget] implement order, trade user stream
This commit is contained in:
bailantaotao 2023-11-15 21:32:16 +08:00 committed by GitHub
commit 579e8b0ae5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 768 additions and 41 deletions

View File

@ -58,6 +58,10 @@ type ExchangeSession struct {
// This option is exchange specific // This option is exchange specific
PrivateChannels []string `json:"privateChannels,omitempty" yaml:"privateChannels,omitempty"` PrivateChannels []string `json:"privateChannels,omitempty" yaml:"privateChannels,omitempty"`
// PrivateChannelSymbols is used for filtering the private user data channel, .e.g, order symbol subscription.
// This option is exchange specific
PrivateChannelSymbols []string `json:"privateChannelSymbols,omitempty" yaml:"privateChannelSymbols,omitempty"`
Margin bool `json:"margin,omitempty" yaml:"margin"` Margin bool `json:"margin,omitempty" yaml:"margin"`
IsolatedMargin bool `json:"isolatedMargin,omitempty" yaml:"isolatedMargin,omitempty"` IsolatedMargin bool `json:"isolatedMargin,omitempty" yaml:"isolatedMargin,omitempty"`
IsolatedMarginSymbol string `json:"isolatedMarginSymbol,omitempty" yaml:"isolatedMarginSymbol,omitempty"` IsolatedMarginSymbol string `json:"isolatedMarginSymbol,omitempty" yaml:"isolatedMarginSymbol,omitempty"`
@ -248,6 +252,11 @@ func (session *ExchangeSession) Init(ctx context.Context, environ *Environment)
setter.SetPrivateChannels(session.PrivateChannels) setter.SetPrivateChannels(session.PrivateChannels)
} }
} }
if len(session.PrivateChannelSymbols) > 0 {
if setter, ok := session.UserDataStream.(types.PrivateChannelSymbolSetter); ok {
setter.SetPrivateChannelSymbols(session.PrivateChannelSymbols)
}
}
logger.Infof("querying account balances...") logger.Infof("querying account balances...")
account, err := session.Exchange.QueryAccount(ctx) account, err := session.Exchange.QueryAccount(ctx)

View File

@ -51,8 +51,8 @@ type OrderDetail struct {
// The value is json string, so we unmarshal it after unmarshal OrderDetail // The value is json string, so we unmarshal it after unmarshal OrderDetail
FeeDetailRaw string `json:"feeDetail"` FeeDetailRaw string `json:"feeDetail"`
OrderSource string `json:"orderSource"` OrderSource string `json:"orderSource"`
CTime types.MillisecondTimestamp `json:"cTime"` CreatedTime types.MillisecondTimestamp `json:"cTime"`
UTime types.MillisecondTimestamp `json:"uTime"` UpdatedTime types.MillisecondTimestamp `json:"uTime"`
FeeDetail FeeDetail FeeDetail FeeDetail
} }

View File

@ -53,8 +53,8 @@ func TestOrderDetail_UnmarshalJSON(t *testing.T) {
EnterPointSource: "API", EnterPointSource: "API",
FeeDetailRaw: "", FeeDetailRaw: "",
OrderSource: "normal", OrderSource: "normal",
CTime: types.NewMillisecondTimestampFromInt(1699021576683), CreatedTime: types.NewMillisecondTimestampFromInt(1699021576683),
UTime: types.NewMillisecondTimestampFromInt(1699021649099), UpdatedTime: types.NewMillisecondTimestampFromInt(1699021649099),
FeeDetail: FeeDetail{}, FeeDetail: FeeDetail{},
}, od) }, od)
}) })
@ -98,8 +98,8 @@ func TestOrderDetail_UnmarshalJSON(t *testing.T) {
EnterPointSource: "API", 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}}`, 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", OrderSource: "normal",
CTime: types.NewMillisecondTimestampFromInt(1699020564659), CreatedTime: types.NewMillisecondTimestampFromInt(1699020564659),
UTime: types.NewMillisecondTimestampFromInt(1699020564688), UpdatedTime: types.NewMillisecondTimestampFromInt(1699020564688),
FeeDetail: FeeDetail{ FeeDetail: FeeDetail{
NewFees: struct { NewFees: struct {
DeductedByCoupon fixedpoint.Value `json:"c"` DeductedByCoupon fixedpoint.Value `json:"c"`

View File

@ -47,8 +47,8 @@ type Trade struct {
Amount fixedpoint.Value `json:"amount"` Amount fixedpoint.Value `json:"amount"`
FeeDetail TradeFee `json:"feeDetail"` FeeDetail TradeFee `json:"feeDetail"`
TradeScope TradeScope `json:"tradeScope"` TradeScope TradeScope `json:"tradeScope"`
CTime types.MillisecondTimestamp `json:"cTime"` CreatedTime types.MillisecondTimestamp `json:"cTime"`
UTime types.MillisecondTimestamp `json:"uTime"` UpdatedTime types.MillisecondTimestamp `json:"uTime"`
} }
//go:generate GetRequest -url "/api/v2/spot/trade/fills" -type GetTradeFillsRequest -responseDataType []Trade //go:generate GetRequest -url "/api/v2/spot/trade/fills" -type GetTradeFillsRequest -responseDataType []Trade

View File

@ -27,8 +27,8 @@ type UnfilledOrder struct {
QuoteVolume fixedpoint.Value `json:"quoteVolume"` QuoteVolume fixedpoint.Value `json:"quoteVolume"`
EnterPointSource string `json:"enterPointSource"` EnterPointSource string `json:"enterPointSource"`
OrderSource string `json:"orderSource"` OrderSource string `json:"orderSource"`
CTime types.MillisecondTimestamp `json:"cTime"` CreatedTime types.MillisecondTimestamp `json:"cTime"`
UTime types.MillisecondTimestamp `json:"uTime"` UpdatedTime types.MillisecondTimestamp `json:"uTime"`
} }
//go:generate GetRequest -url "/api/v2/spot/trade/unfilled-orders" -type GetUnfilledOrdersRequest -responseDataType []UnfilledOrder //go:generate GetRequest -url "/api/v2/spot/trade/unfilled-orders" -type GetUnfilledOrdersRequest -responseDataType []UnfilledOrder

View File

@ -158,7 +158,7 @@ func toGlobalTrade(trade v2.Trade) (*types.Trade, error) {
Side: side, Side: side,
IsBuyer: side == types.SideTypeBuy, IsBuyer: side == types.SideTypeBuy,
IsMaker: isMaker, IsMaker: isMaker,
Time: types.Time(trade.CTime), Time: types.Time(trade.CreatedTime),
Fee: trade.FeeDetail.TotalFee.Abs(), Fee: trade.FeeDetail.TotalFee.Abs(),
FeeCurrency: trade.FeeDetail.FeeCoin, FeeCurrency: trade.FeeDetail.FeeCoin,
FeeDiscounted: isDiscount, FeeDiscounted: isDiscount,
@ -211,8 +211,8 @@ func unfilledOrderToGlobalOrder(order v2.UnfilledOrder) (*types.Order, error) {
Status: status, Status: status,
ExecutedQuantity: order.BaseVolume, ExecutedQuantity: order.BaseVolume,
IsWorking: order.Status.IsWorking(), IsWorking: order.Status.IsWorking(),
CreationTime: types.Time(order.CTime.Time()), CreationTime: types.Time(order.CreatedTime.Time()),
UpdateTime: types.Time(order.UTime.Time()), UpdateTime: types.Time(order.UpdatedTime.Time()),
}, nil }, nil
} }
@ -262,8 +262,8 @@ func toGlobalOrder(order v2.OrderDetail) (*types.Order, error) {
Status: status, Status: status,
ExecutedQuantity: order.BaseVolume, ExecutedQuantity: order.BaseVolume,
IsWorking: order.Status.IsWorking(), IsWorking: order.Status.IsWorking(),
CreationTime: types.Time(order.CTime.Time()), CreationTime: types.Time(order.CreatedTime.Time()),
UpdateTime: types.Time(order.UTime.Time()), UpdateTime: types.Time(order.UpdatedTime.Time()),
}, nil }, nil
} }
@ -362,3 +362,120 @@ func toGlobalKLines(symbol string, interval types.Interval, kLines v2.KLineRespo
} }
return gKLines return gKLines
} }
func toGlobalTimeInForce(force v2.OrderForce) (types.TimeInForce, error) {
switch force {
case v2.OrderForceFOK:
return types.TimeInForceFOK, nil
case v2.OrderForceGTC, v2.OrderForcePostOnly:
return types.TimeInForceGTC, nil
case v2.OrderForceIOC:
return types.TimeInForceIOC, nil
default:
return "", fmt.Errorf("unexpected time-in-force: %s", force)
}
}
func (o *Order) processMarketBuyQuantity() (fixedpoint.Value, error) {
switch o.Status {
case v2.OrderStatusLive, v2.OrderStatusNew, v2.OrderStatusInit, v2.OrderStatusCancelled:
return fixedpoint.Zero, nil
case v2.OrderStatusPartialFilled:
if o.FillPrice.IsZero() {
return fixedpoint.Zero, fmt.Errorf("fillPrice for a partialFilled should not be zero")
}
return o.Size.Div(o.FillPrice), nil
case v2.OrderStatusFilled:
return o.AccBaseVolume, nil
default:
return fixedpoint.Zero, fmt.Errorf("unexpected status: %s", o.Status)
}
}
func (o *Order) toGlobalOrder() (types.Order, error) {
side, err := toGlobalSideType(o.Side)
if err != nil {
return types.Order{}, err
}
orderType, err := toGlobalOrderType(o.OrderType)
if err != nil {
return types.Order{}, err
}
timeInForce, err := toGlobalTimeInForce(o.Force)
if err != nil {
return types.Order{}, err
}
status, err := toGlobalOrderStatus(o.Status)
if err != nil {
return types.Order{}, err
}
qty := o.Size
if orderType == types.OrderTypeMarket && side == types.SideTypeBuy {
qty, err = o.processMarketBuyQuantity()
if err != nil {
return types.Order{}, err
}
}
return types.Order{
SubmitOrder: types.SubmitOrder{
ClientOrderID: o.ClientOrderId,
Symbol: o.InstId,
Side: side,
Type: orderType,
Quantity: qty,
Price: o.PriceAvg,
TimeInForce: timeInForce,
},
Exchange: types.ExchangeBitget,
OrderID: uint64(o.OrderId),
UUID: strconv.FormatInt(int64(o.OrderId), 10),
Status: status,
ExecutedQuantity: o.AccBaseVolume,
IsWorking: o.Status.IsWorking(),
CreationTime: types.Time(o.CreatedTime.Time()),
UpdateTime: types.Time(o.UpdatedTime.Time()),
}, nil
}
func (o *Order) toGlobalTrade() (types.Trade, error) {
if o.Status != v2.OrderStatusPartialFilled {
return types.Trade{}, fmt.Errorf("failed to convert to global trade, unexpected status: %s", o.Status)
}
side, err := toGlobalSideType(o.Side)
if err != nil {
return types.Trade{}, err
}
isMaker, err := o.isMaker()
if err != nil {
return types.Trade{}, err
}
return types.Trade{
ID: uint64(o.TradeId),
OrderID: uint64(o.OrderId),
Exchange: types.ExchangeBitget,
Price: o.FillPrice,
Quantity: o.BaseVolume,
QuoteQuantity: o.FillPrice.Mul(o.BaseVolume),
Symbol: o.InstId,
Side: side,
IsBuyer: side == types.SideTypeBuy,
IsMaker: isMaker,
Time: types.Time(o.FillTime),
Fee: o.FillFee.Abs(),
FeeCurrency: o.FillFeeCoin,
}, nil
}

View File

@ -222,8 +222,8 @@ func Test_unfilledOrderToGlobalOrder(t *testing.T) {
QuoteVolume: fixedpoint.NewFromFloat(0), QuoteVolume: fixedpoint.NewFromFloat(0),
EnterPointSource: "API", EnterPointSource: "API",
OrderSource: "normal", OrderSource: "normal",
CTime: types.NewMillisecondTimestampFromInt(1660704288118), CreatedTime: types.NewMillisecondTimestampFromInt(1660704288118),
UTime: types.NewMillisecondTimestampFromInt(1660704288118), UpdatedTime: types.NewMillisecondTimestampFromInt(1660704288118),
} }
) )
@ -296,8 +296,8 @@ func Test_toGlobalOrder(t *testing.T) {
EnterPointSource: "API", 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}}`, 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", OrderSource: "normal",
CTime: types.NewMillisecondTimestampFromInt(1660704288118), CreatedTime: types.NewMillisecondTimestampFromInt(1660704288118),
UTime: types.NewMillisecondTimestampFromInt(1660704288118), UpdatedTime: types.NewMillisecondTimestampFromInt(1660704288118),
} }
expOrder = &types.Order{ expOrder = &types.Order{
@ -559,8 +559,8 @@ func Test_toGlobalTrade(t *testing.T) {
TotalFee: fixedpoint.NewFromFloat(-0.0070005), TotalFee: fixedpoint.NewFromFloat(-0.0070005),
}, },
TradeScope: v2.TradeTaker, TradeScope: v2.TradeTaker,
CTime: types.NewMillisecondTimestampFromInt(1699020564676), CreatedTime: types.NewMillisecondTimestampFromInt(1699020564676),
UTime: types.NewMillisecondTimestampFromInt(1699020564687), UpdatedTime: types.NewMillisecondTimestampFromInt(1699020564687),
} }
res, err := toGlobalTrade(trade) res, err := toGlobalTrade(trade)
@ -597,7 +597,7 @@ func Test_toGlobalBalanceMap(t *testing.T) {
Frozen: fixedpoint.NewFromFloat(0.6), Frozen: fixedpoint.NewFromFloat(0.6),
Locked: fixedpoint.NewFromFloat(0.7), Locked: fixedpoint.NewFromFloat(0.7),
LimitAvailable: fixedpoint.Zero, LimitAvailable: fixedpoint.Zero,
UTime: types.NewMillisecondTimestampFromInt(1699020564676), UpdatedTime: types.NewMillisecondTimestampFromInt(1699020564676),
}, },
})) }))
} }
@ -686,3 +686,452 @@ func Test_toGlobalKLines(t *testing.T) {
assert.Equal(t, toGlobalKLines(symbol, interval, resp), expKlines) assert.Equal(t, toGlobalKLines(symbol, interval, resp), expKlines)
} }
func Test_toGlobalTimeInForce(t *testing.T) {
force, err := toGlobalTimeInForce(v2.OrderForceFOK)
assert.NoError(t, err)
assert.Equal(t, types.TimeInForceFOK, force)
force, err = toGlobalTimeInForce(v2.OrderForceGTC)
assert.NoError(t, err)
assert.Equal(t, types.TimeInForceGTC, force)
force, err = toGlobalTimeInForce(v2.OrderForcePostOnly)
assert.NoError(t, err)
assert.Equal(t, types.TimeInForceGTC, force)
force, err = toGlobalTimeInForce(v2.OrderForceIOC)
assert.NoError(t, err)
assert.Equal(t, types.TimeInForceIOC, force)
_, err = toGlobalTimeInForce("xxx")
assert.ErrorContains(t, err, "xxx")
}
func TestOrder_processMarketBuyQuantity(t *testing.T) {
t.Run("zero qty", func(t *testing.T) {
o := Order{}
for _, s := range []v2.OrderStatus{v2.OrderStatusLive, v2.OrderStatusNew, v2.OrderStatusInit, v2.OrderStatusCancelled} {
o.Status = s
qty, err := o.processMarketBuyQuantity()
assert.NoError(t, err)
assert.Equal(t, fixedpoint.Zero, qty)
}
})
t.Run("calculate qty", func(t *testing.T) {
o := Order{
Size: fixedpoint.NewFromFloat(2),
Trade: Trade{
FillPrice: fixedpoint.NewFromFloat(1),
},
Status: v2.OrderStatusPartialFilled,
}
qty, err := o.processMarketBuyQuantity()
assert.NoError(t, err)
assert.Equal(t, fixedpoint.NewFromFloat(2), qty)
})
t.Run("return accumulated balance", func(t *testing.T) {
o := Order{
AccBaseVolume: fixedpoint.NewFromFloat(5),
Status: v2.OrderStatusFilled,
}
qty, err := o.processMarketBuyQuantity()
assert.NoError(t, err)
assert.Equal(t, fixedpoint.NewFromFloat(5), qty)
})
t.Run("unexpected status", func(t *testing.T) {
o := Order{
Status: "xxx",
}
_, err := o.processMarketBuyQuantity()
assert.ErrorContains(t, err, "xxx")
})
}
func TestOrder_toGlobalOrder(t *testing.T) {
o := Order{
Trade: Trade{
FillPrice: fixedpoint.NewFromFloat(0.49016),
TradeId: types.StrInt64(1107950490073112582),
BaseVolume: fixedpoint.NewFromFloat(33.6558),
FillTime: types.NewMillisecondTimestampFromInt(1699881902235),
FillFee: fixedpoint.NewFromFloat(-0.0336558),
FillFeeCoin: "BGB",
TradeScope: "T",
},
InstId: "BGBUSDT",
OrderId: types.StrInt64(1107950489998626816),
ClientOrderId: "cc73aab9-1e44-4022-8458-60d8c6a08753",
Size: fixedpoint.NewFromFloat(39.0),
Notional: fixedpoint.NewFromFloat(39.0),
OrderType: v2.OrderTypeMarket,
Force: v2.OrderForceGTC,
Side: v2.SideTypeBuy,
AccBaseVolume: fixedpoint.NewFromFloat(33.6558),
PriceAvg: fixedpoint.NewFromFloat(0.49016),
Status: v2.OrderStatusPartialFilled,
CreatedTime: types.NewMillisecondTimestampFromInt(1699881902217),
UpdatedTime: types.NewMillisecondTimestampFromInt(1699881902248),
FeeDetail: nil,
EnterPointSource: "API",
}
// market buy example:
// {
// "instId":"BGBUSDT",
// "orderId":"1107950489998626816",
// "clientOid":"cc73aab9-1e44-4022-8458-60d8c6a08753",
// "size":"39.0000",
// "notional":"39.000000",
// "orderType":"market",
// "force":"gtc",
// "side":"buy",
// "fillPrice":"0.49016",
// "tradeId":"1107950490073112582",
// "baseVolume":"33.6558",
// "fillTime":"1699881902235",
// "fillFee":"-0.0336558",
// "fillFeeCoin":"BGB",
// "tradeScope":"T",
// "accBaseVolume":"33.6558",
// "priceAvg":"0.49016",
// "status":"partially_filled",
// "cTime":"1699881902217",
// "uTime":"1699881902248",
// "feeDetail":[
// {
// "feeCoin":"BGB",
// "fee":"-0.0336558"
// }
// ],
// "enterPointSource":"API"
// }
t.Run("market buy", func(t *testing.T) {
newO := o
res, err := newO.toGlobalOrder()
assert.NoError(t, err)
assert.Equal(t, types.Order{
SubmitOrder: types.SubmitOrder{
ClientOrderID: "cc73aab9-1e44-4022-8458-60d8c6a08753",
Symbol: "BGBUSDT",
Side: types.SideTypeBuy,
Type: types.OrderTypeMarket,
Quantity: newO.Size.Div(newO.FillPrice),
Price: newO.PriceAvg,
TimeInForce: types.TimeInForceGTC,
},
Exchange: types.ExchangeBitget,
OrderID: uint64(newO.OrderId),
UUID: strconv.FormatInt(int64(newO.OrderId), 10),
Status: types.OrderStatusPartiallyFilled,
ExecutedQuantity: newO.AccBaseVolume,
IsWorking: newO.Status.IsWorking(),
CreationTime: types.Time(newO.CreatedTime),
UpdateTime: types.Time(newO.UpdatedTime),
}, res)
})
// market sell example:
// {
// "instId":"BGBUSDT",
// "orderId":"1107940456212631553",
// "clientOid":"088bb971-858e-48e2-b503-85c3274edd89",
// "size":"285.0000",
// "orderType":"market",
// "force":"gtc",
// "side":"sell",
// "fillPrice":"0.48706",
// "tradeId":"1107940456278728706",
// "baseVolume":"22.5840",
// "fillTime":"1699879509992",
// "fillFee":"-0.01099976304",
// "fillFeeCoin":"USDT",
// "tradeScope":"T",
// "accBaseVolume":"45.1675",
// "priceAvg":"0.48706",
// "status":"partially_filled",
// "cTime":"1699879509976",
// "uTime":"1699879510007",
// "feeDetail":[
// {
// "feeCoin":"USDT",
// "fee":"-0.02199928255"
// }
// ],
// "enterPointSource":"API"
// }
t.Run("market sell", func(t *testing.T) {
newO := o
newO.OrderType = v2.OrderTypeMarket
newO.Side = v2.SideTypeSell
res, err := newO.toGlobalOrder()
assert.NoError(t, err)
assert.Equal(t, types.Order{
SubmitOrder: types.SubmitOrder{
ClientOrderID: "cc73aab9-1e44-4022-8458-60d8c6a08753",
Symbol: "BGBUSDT",
Side: types.SideTypeSell,
Type: types.OrderTypeMarket,
Quantity: newO.Size,
Price: newO.PriceAvg,
TimeInForce: types.TimeInForceGTC,
},
Exchange: types.ExchangeBitget,
OrderID: uint64(newO.OrderId),
UUID: strconv.FormatInt(int64(newO.OrderId), 10),
Status: types.OrderStatusPartiallyFilled,
ExecutedQuantity: newO.AccBaseVolume,
IsWorking: newO.Status.IsWorking(),
CreationTime: types.Time(newO.CreatedTime),
UpdateTime: types.Time(newO.UpdatedTime),
}, res)
})
// limit buy example:
// {
// "instId":"BGBUSDT",
// "orderId":"1107955329902481408",
// "clientOid":"c578164a-bf34-44ba-8bb7-a1538f33b1b8",
// "price":"0.49998",
// "size":"24.9990",
// "notional":"24.999000",
// "orderType":"limit",
// "force":"gtc",
// "side":"buy",
// "fillPrice":"0.49998",
// "tradeId":"1107955401758285828",
// "baseVolume":"15.9404",
// "fillTime":"1699883073272",
// "fillFee":"-0.0159404",
// "fillFeeCoin":"BGB",
// "tradeScope":"M",
// "accBaseVolume":"15.9404",
// "priceAvg":"0.49998",
// "status":"partially_filled",
// "cTime":"1699883056140",
// "uTime":"1699883073285",
// "feeDetail":[
// {
// "feeCoin":"BGB",
// "fee":"-0.0159404"
// }
// ],
// "enterPointSource":"API"
// }
t.Run("limit buy", func(t *testing.T) {
newO := o
newO.OrderType = v2.OrderTypeLimit
res, err := newO.toGlobalOrder()
assert.NoError(t, err)
assert.Equal(t, types.Order{
SubmitOrder: types.SubmitOrder{
ClientOrderID: "cc73aab9-1e44-4022-8458-60d8c6a08753",
Symbol: "BGBUSDT",
Side: types.SideTypeBuy,
Type: types.OrderTypeLimit,
Quantity: newO.Size,
Price: newO.PriceAvg,
TimeInForce: types.TimeInForceGTC,
},
Exchange: types.ExchangeBitget,
OrderID: uint64(newO.OrderId),
UUID: strconv.FormatInt(int64(newO.OrderId), 10),
Status: types.OrderStatusPartiallyFilled,
ExecutedQuantity: newO.AccBaseVolume,
IsWorking: newO.Status.IsWorking(),
CreationTime: types.Time(newO.CreatedTime),
UpdateTime: types.Time(newO.UpdatedTime),
}, res)
})
// limit sell example:
// {
// "instId":"BGBUSDT",
// "orderId":"1107936497259417600",
// "clientOid":"02d4592e-091c-4b5a-aef3-6a7cf57b5e82",
// "price":"0.48710",
// "size":"280.0000",
// "orderType":"limit",
// "force":"gtc",
// "side":"sell",
// "fillPrice":"0.48710",
// "tradeId":"1107937053540556809",
// "baseVolume":"41.0593",
// "fillTime":"1699878698716",
// "fillFee":"-0.01999998503",
// "fillFeeCoin":"USDT",
// "tradeScope":"M",
// "accBaseVolume":"146.3209",
// "priceAvg":"0.48710",
// "status":"partially_filled",
// "cTime":"1699878566088",
// "uTime":"1699878698746",
// "feeDetail":[
// {
// "feeCoin":"USDT",
// "fee":"-0.07127291039"
// }
// ],
// "enterPointSource":"API"
// }
t.Run("limit sell", func(t *testing.T) {
newO := o
newO.OrderType = v2.OrderTypeLimit
newO.Side = v2.SideTypeSell
res, err := newO.toGlobalOrder()
assert.NoError(t, err)
assert.Equal(t, types.Order{
SubmitOrder: types.SubmitOrder{
ClientOrderID: "cc73aab9-1e44-4022-8458-60d8c6a08753",
Symbol: "BGBUSDT",
Side: types.SideTypeSell,
Type: types.OrderTypeLimit,
Quantity: newO.Size,
Price: newO.PriceAvg,
TimeInForce: types.TimeInForceGTC,
},
Exchange: types.ExchangeBitget,
OrderID: uint64(newO.OrderId),
UUID: strconv.FormatInt(int64(newO.OrderId), 10),
Status: types.OrderStatusPartiallyFilled,
ExecutedQuantity: newO.AccBaseVolume,
IsWorking: newO.Status.IsWorking(),
CreationTime: types.Time(newO.CreatedTime),
UpdateTime: types.Time(newO.UpdatedTime),
}, res)
})
t.Run("unexpected status", func(t *testing.T) {
newO := o
newO.Status = "xxx"
_, err := newO.toGlobalOrder()
assert.ErrorContains(t, err, "xxx")
})
t.Run("unexpected time-in-force", func(t *testing.T) {
newO := o
newO.Force = "xxx"
_, err := newO.toGlobalOrder()
assert.ErrorContains(t, err, "xxx")
})
t.Run("unexpected order type", func(t *testing.T) {
newO := o
newO.OrderType = "xxx"
_, err := newO.toGlobalOrder()
assert.ErrorContains(t, err, "xxx")
})
t.Run("unexpected side", func(t *testing.T) {
newO := o
newO.Side = "xxx"
_, err := newO.toGlobalOrder()
assert.ErrorContains(t, err, "xxx")
})
}
func TestOrder_toGlobalTrade(t *testing.T) {
// market buy example:
// {
// "instId":"BGBUSDT",
// "orderId":"1107950489998626816",
// "clientOid":"cc73aab9-1e44-4022-8458-60d8c6a08753",
// "size":"39.0000",
// "notional":"39.000000",
// "orderType":"market",
// "force":"gtc",
// "side":"buy",
// "fillPrice":"0.49016",
// "tradeId":"1107950490073112582",
// "baseVolume":"33.6558",
// "fillTime":"1699881902235",
// "fillFee":"-0.0336558",
// "fillFeeCoin":"BGB",
// "tradeScope":"T",
// "accBaseVolume":"33.6558",
// "priceAvg":"0.49016",
// "status":"partially_filled",
// "cTime":"1699881902217",
// "uTime":"1699881902248",
// "feeDetail":[
// {
// "feeCoin":"BGB",
// "fee":"-0.0336558"
// }
// ],
// "enterPointSource":"API"
// }
o := Order{
Trade: Trade{
FillPrice: fixedpoint.NewFromFloat(0.49016),
TradeId: types.StrInt64(1107950490073112582),
BaseVolume: fixedpoint.NewFromFloat(33.6558),
FillTime: types.NewMillisecondTimestampFromInt(1699881902235),
FillFee: fixedpoint.NewFromFloat(-0.0336558),
FillFeeCoin: "BGB",
TradeScope: "T",
},
InstId: "BGBUSDT",
OrderId: types.StrInt64(1107950489998626816),
ClientOrderId: "cc73aab9-1e44-4022-8458-60d8c6a08753",
Size: fixedpoint.NewFromFloat(39.0),
Notional: fixedpoint.NewFromFloat(39.0),
OrderType: v2.OrderTypeMarket,
Force: v2.OrderForceGTC,
Side: v2.SideTypeBuy,
AccBaseVolume: fixedpoint.NewFromFloat(33.6558),
PriceAvg: fixedpoint.NewFromFloat(0.49016),
Status: v2.OrderStatusPartialFilled,
CreatedTime: types.NewMillisecondTimestampFromInt(1699881902217),
UpdatedTime: types.NewMillisecondTimestampFromInt(1699881902248),
FeeDetail: nil,
EnterPointSource: "API",
}
t.Run("succeeds", func(t *testing.T) {
res, err := o.toGlobalTrade()
assert.NoError(t, err)
assert.Equal(t, types.Trade{
ID: uint64(o.TradeId),
OrderID: uint64(o.OrderId),
Exchange: types.ExchangeBitget,
Price: o.FillPrice,
Quantity: o.BaseVolume,
QuoteQuantity: o.FillPrice.Mul(o.BaseVolume),
Symbol: "BGBUSDT",
Side: types.SideTypeBuy,
IsBuyer: true,
IsMaker: false,
Time: types.Time(o.FillTime),
Fee: o.FillFee.Abs(),
FeeCurrency: "BGB",
}, res)
})
t.Run("unexpected trade scope", func(t *testing.T) {
newO := o
newO.TradeScope = "xxx"
_, err := newO.toGlobalTrade()
assert.ErrorContains(t, err, "xxx")
})
t.Run("unexpected side type", func(t *testing.T) {
newO := o
newO.Side = "xxx"
_, err := newO.toGlobalTrade()
assert.ErrorContains(t, err, "xxx")
})
t.Run("unexpected side type", func(t *testing.T) {
newO := o
newO.Status = "xxx"
_, err := newO.toGlobalTrade()
assert.ErrorContains(t, err, "xxx")
})
}

View File

@ -394,7 +394,7 @@ func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders [
return orders, nil return orders, nil
} }
// QueryClosedOrders queries closed order by time range(`CTime`) and id. The order of the response is in descending order. // QueryClosedOrders queries closed order by time range(`CreatedTime`) 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. // 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 is inclusive, Until is exclusive. If you use a time range to query, you must provide both a start time and an end time. **
@ -495,7 +495,7 @@ func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) (err
} }
// QueryTrades queries fill trades. The trade of the response is in descending order. The time-based query are typically // QueryTrades queries fill trades. The trade of the response is in descending order. The time-based query are typically
// using (`CTime`) as the search criteria. // using (`CreatedTime`) as the search criteria.
// If you need to retrieve all data, please utilize the function pkg/exchange/batch.TradeBatchQuery. // If you need to retrieve all data, please utilize the function pkg/exchange/batch.TradeBatchQuery.
// //
// ** StartTime is inclusive, EndTime is exclusive. If you use the EndTime, the StartTime is required. ** // ** StartTime is inclusive, EndTime is exclusive. If you use the EndTime, the StartTime is required. **

View File

@ -25,12 +25,15 @@ var (
type Stream struct { type Stream struct {
types.StandardStream types.StandardStream
privateChannelSymbols []string
key, secret, passphrase string key, secret, passphrase string
bookEventCallbacks []func(o BookEvent) bookEventCallbacks []func(o BookEvent)
marketTradeEventCallbacks []func(o MarketTradeEvent) marketTradeEventCallbacks []func(o MarketTradeEvent)
KLineEventCallbacks []func(o KLineEvent) KLineEventCallbacks []func(o KLineEvent)
accountEventCallbacks []func(e AccountEvent) accountEventCallbacks []func(e AccountEvent)
orderTradeEventCallbacks []func(e OrderTradeEvent)
lastCandle map[string]types.KLine lastCandle map[string]types.KLine
} }
@ -56,6 +59,7 @@ func NewStream(key, secret, passphrase string) *Stream {
stream.OnAuth(stream.handleAuth) stream.OnAuth(stream.handleAuth)
stream.OnAccountEvent(stream.handleAccountEvent) stream.OnAccountEvent(stream.handleAccountEvent)
stream.OnOrderTradeEvent(stream.handleOrderTradeEvent)
return stream return stream
} }
@ -129,25 +133,52 @@ func (s *Stream) dispatchEvent(event interface{}) {
case *AccountEvent: case *AccountEvent:
s.EmitAccountEvent(*e) s.EmitAccountEvent(*e)
case *OrderTradeEvent:
s.EmitOrderTradeEvent(*e)
case []byte:
// We only handle the 'pong' case. Others are unexpected.
if !bytes.Equal(e, pongBytes) {
log.Errorf("invalid event: %q", e)
}
} }
} }
// handleAuth subscribe private stream channels. Because Bitget doesn't allow authentication and subscription to be used
// consecutively, we subscribe after authentication confirmation.
func (s *Stream) handleAuth() { func (s *Stream) handleAuth() {
if err := s.Conn.WriteJSON(WsOp{ op := WsOp{
Op: WsEventSubscribe, Op: WsEventSubscribe,
Args: []WsArg{ Args: []WsArg{
{ {
InstType: instSpV2, InstType: instSpV2,
Channel: ChannelAccount, Channel: ChannelAccount,
Coin: "default", // default all Coin: "default", // all coins
}, },
}, },
}); err != nil { }
if len(s.privateChannelSymbols) > 0 {
for _, symbol := range s.privateChannelSymbols {
op.Args = append(op.Args, WsArg{
InstType: instSpV2,
Channel: ChannelOrders,
InstId: symbol,
})
}
} else {
log.Warnf("you have not subscribed to any order channels")
}
if err := s.Conn.WriteJSON(op); err != nil {
log.WithError(err).Error("failed to send subscription request") log.WithError(err).Error("failed to send subscription request")
return return
} }
} }
func (s *Stream) SetPrivateChannelSymbols(symbols []string) {
s.privateChannelSymbols = symbols
}
func (s *Stream) handlerConnect() { func (s *Stream) handlerConnect() {
if s.PublicOnly { if s.PublicOnly {
// errors are handled in the syncSubscriptions, so they are skipped here. // errors are handled in the syncSubscriptions, so they are skipped here.
@ -279,6 +310,17 @@ func parseEvent(in []byte) (interface{}, error) {
book.instId = event.Arg.InstId book.instId = event.Arg.InstId
return &book, nil return &book, nil
case ChannelOrders:
var order OrderTradeEvent
err = json.Unmarshal(event.Data, &order.Orders)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal data into OrderTradeEvent, Arg: %+v Data: %s, err: %w", event.Arg, string(event.Data), err)
}
order.actionType = event.Action
order.instId = event.Arg.InstId
return &order, nil
case ChannelTrade: case ChannelTrade:
var trade MarketTradeEvent var trade MarketTradeEvent
err = json.Unmarshal(event.Data, &trade.Events) err = json.Unmarshal(event.Data, &trade.Events)
@ -364,3 +406,31 @@ func (s *Stream) handleAccountEvent(m AccountEvent) {
} }
s.StandardStream.EmitBalanceSnapshot(balanceMap) s.StandardStream.EmitBalanceSnapshot(balanceMap)
} }
func (s *Stream) handleOrderTradeEvent(m OrderTradeEvent) {
if len(m.Orders) == 0 {
return
}
for _, order := range m.Orders {
globalOrder, err := order.toGlobalOrder()
if err != nil {
log.Errorf("failed to convert order to global: %s", err)
continue
}
// The bitget support only snapshot on orders channel, so we use snapshot as update to emit data.
if m.actionType != ActionTypeSnapshot {
continue
}
s.StandardStream.EmitOrderUpdate(globalOrder)
if globalOrder.Status == types.OrderStatusPartiallyFilled {
trade, err := order.toGlobalTrade()
if err != nil {
log.Errorf("failed to convert trade to global: %s", err)
continue
}
s.StandardStream.EmitTradeUpdate(trade)
}
}
}

View File

@ -43,3 +43,13 @@ func (s *Stream) EmitAccountEvent(e AccountEvent) {
cb(e) cb(e)
} }
} }
func (s *Stream) OnOrderTradeEvent(cb func(e OrderTradeEvent)) {
s.orderTradeEventCallbacks = append(s.orderTradeEventCallbacks, cb)
}
func (s *Stream) EmitOrderTradeEvent(e OrderTradeEvent) {
for _, cb := range s.orderTradeEventCallbacks {
cb(e)
}
}

View File

@ -134,6 +134,12 @@ func TestStream(t *testing.T) {
s.OnBalanceUpdate(func(balances types.BalanceMap) { s.OnBalanceUpdate(func(balances types.BalanceMap) {
t.Log("get update", balances) t.Log("get update", balances)
}) })
s.OnOrderUpdate(func(order types.Order) {
t.Log("order update", order)
})
s.OnTradeUpdate(func(trade types.Trade) {
t.Log("trade update", trade)
})
c := make(chan struct{}) c := make(chan struct{})
<-c <-c

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"time" "time"
v2 "github.com/c9s/bbgo/pkg/exchange/bitget/bitgetapi/v2"
"github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/fixedpoint"
"github.com/c9s/bbgo/pkg/types" "github.com/c9s/bbgo/pkg/types"
) )
@ -29,6 +30,7 @@ const (
// ChannelOrderBook15 top 15 order book of "books" that begins from bid1/ask1 // ChannelOrderBook15 top 15 order book of "books" that begins from bid1/ask1
ChannelOrderBook15 ChannelType = "books15" ChannelOrderBook15 ChannelType = "books15"
ChannelTrade ChannelType = "trade" ChannelTrade ChannelType = "trade"
ChannelOrders ChannelType = "orders"
) )
type WsArg struct { type WsArg struct {
@ -450,7 +452,7 @@ type Balance struct {
Locked fixedpoint.Value `json:"locked"` Locked fixedpoint.Value `json:"locked"`
// Restricted availability For spot copy trading // Restricted availability For spot copy trading
LimitAvailable fixedpoint.Value `json:"limitAvailable"` LimitAvailable fixedpoint.Value `json:"limitAvailable"`
UTime types.MillisecondTimestamp `json:"uTime"` UpdatedTime types.MillisecondTimestamp `json:"uTime"`
} }
type AccountEvent struct { type AccountEvent struct {
@ -460,3 +462,63 @@ type AccountEvent struct {
actionType ActionType actionType ActionType
instId string instId string
} }
type Trade struct {
// Latest filled price
FillPrice fixedpoint.Value `json:"fillPrice"`
TradeId types.StrInt64 `json:"tradeId"`
// Number of latest filled orders
BaseVolume fixedpoint.Value `json:"baseVolume"`
FillTime types.MillisecondTimestamp `json:"fillTime"`
// Transaction fee of the latest transaction, negative value
FillFee fixedpoint.Value `json:"fillFee"`
// Currency of transaction fee of the latest transaction
FillFeeCoin string `json:"fillFeeCoin"`
// Direction of liquidity of the latest transaction
TradeScope string `json:"tradeScope"`
}
type Order struct {
Trade
InstId string `json:"instId"`
// 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"`
// Size is base coin when orderType=limit; quote coin when orderType=market
Size fixedpoint.Value `json:"size"`
// Buy amount, returned when buying at market price
Notional fixedpoint.Value `json:"notional"`
OrderType v2.OrderType `json:"orderType"`
Force v2.OrderForce `json:"force"`
Side v2.SideType `json:"side"`
AccBaseVolume fixedpoint.Value `json:"accBaseVolume"`
PriceAvg fixedpoint.Value `json:"priceAvg"`
Status v2.OrderStatus `json:"status"`
CreatedTime types.MillisecondTimestamp `json:"cTime"`
UpdatedTime types.MillisecondTimestamp `json:"uTime"`
FeeDetail []struct {
FeeCoin string `json:"feeCoin"`
Fee string `json:"fee"`
} `json:"feeDetail"`
EnterPointSource string `json:"enterPointSource"`
}
func (o *Order) isMaker() (bool, error) {
switch o.TradeScope {
case "T":
return false, nil
case "M":
return true, nil
default:
return false, fmt.Errorf("unexpected trade scope: %s", o.TradeScope)
}
}
type OrderTradeEvent struct {
Orders []Order
// internal use
actionType ActionType
instId string
}

View File

@ -46,6 +46,10 @@ type PrivateChannelSetter interface {
SetPrivateChannels(channels []string) SetPrivateChannels(channels []string)
} }
type PrivateChannelSymbolSetter interface {
SetPrivateChannelSymbols(symbols []string)
}
type Unsubscriber interface { type Unsubscriber interface {
// Unsubscribe unsubscribes the all subscriptions. // Unsubscribe unsubscribes the all subscriptions.
Unsubscribe() Unsubscribe()