Skip to content

Commit da12d31

Browse files
authored
Merge pull request #2025 from anywhy/okx-contract-size
FEATURE: [okx] support global order quantity convert to okx contract size
2 parents 0179dd4 + d606aa2 commit da12d31

File tree

5 files changed

+188
-41
lines changed

5 files changed

+188
-41
lines changed

pkg/exchange/okex/exchange.go

Lines changed: 23 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ func (e *Exchange) QueryMarkets(ctx context.Context) (types.MarketMap, error) {
170170
// OKEx does not offer minimal notional, use 1 USD here.
171171
MinNotional: fixedpoint.One,
172172
MinAmount: fixedpoint.One,
173+
174+
ContractValue: instrument.ContractValue,
173175
}
174176
markets[symbol] = market
175177
}
@@ -184,10 +186,7 @@ func (e *Exchange) QueryTicker(ctx context.Context, symbol string) (*types.Ticke
184186
return nil, fmt.Errorf("ticker rate limiter wait error: %w", err)
185187
}
186188

187-
symbol = toLocalSymbol(symbol)
188-
if e.IsFutures {
189-
symbol = toLocalSymbol(symbol, okexapi.InstrumentTypeSwap)
190-
}
189+
symbol = e.getInstrumentId(symbol)
191190
marketTicker, err := e.client.NewGetTickerRequest().InstId(symbol).Do(ctx)
192191
if err != nil {
193192
return nil, err
@@ -314,7 +313,7 @@ func (e *Exchange) queryAccountBalance(ctx context.Context) ([]okexapi.Account,
314313
func (e *Exchange) SubmitOrder(ctx context.Context, order types.SubmitOrder) (*types.Order, error) {
315314
orderReq := e.client.NewPlaceOrderRequest()
316315

317-
orderReq.InstrumentID(toLocalSymbol(order.Symbol))
316+
orderReq.InstrumentID(e.getInstrumentId(order.Symbol))
318317
orderReq.Side(toLocalSideType(order.Side))
319318
orderReq.Size(order.Market.FormatQuantity(order.Quantity))
320319

@@ -329,7 +328,6 @@ func (e *Exchange) SubmitOrder(ctx context.Context, order types.SubmitOrder) (*t
329328
orderReq.TradeMode(okexapi.TradeModeCross)
330329
}
331330
} else if e.IsFutures {
332-
orderReq.InstrumentID(toLocalSymbol(order.Symbol, okexapi.InstrumentTypeSwap))
333331
if e.FuturesSettings.IsIsolatedFutures {
334332
orderReq.TradeMode(okexapi.TradeModeIsolated)
335333
} else {
@@ -350,12 +348,8 @@ func (e *Exchange) SubmitOrder(ctx context.Context, order types.SubmitOrder) (*t
350348
case types.OrderTypeMarket:
351349
// target currency = Default is quote_ccy for buy, base_ccy for sell
352350
// Because our order.Quantity unit is base coin, so we indicate the target currency to Base.
353-
switch order.Side {
354-
case types.SideTypeSell:
355-
orderReq.Size(order.Market.FormatQuantity(order.Quantity))
356-
orderReq.TargetCurrency(okexapi.TargetCurrencyBase)
357-
case types.SideTypeBuy:
358-
orderReq.Size(order.Market.FormatQuantity(order.Quantity))
351+
// Only applicable to SPOT Market Orders
352+
if !e.IsFutures {
359353
orderReq.TargetCurrency(okexapi.TargetCurrencyBase)
360354
}
361355
}
@@ -419,7 +413,7 @@ func (e *Exchange) SubmitOrder(ctx context.Context, order types.SubmitOrder) (*t
419413
// QueryOpenOrders retrieves the pending orders. The data returned is ordered by createdTime, and we utilized the
420414
// `After` parameter to acquire all orders.
421415
func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders []types.Order, err error) {
422-
instrumentID := toLocalSymbol(symbol)
416+
instrumentID := e.getInstrumentId(symbol)
423417

424418
nextCursor := int64(0)
425419
for {
@@ -434,7 +428,6 @@ func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders [
434428
if e.MarginSettings.IsMargin {
435429
req.InstrumentType(okexapi.InstrumentTypeMargin)
436430
} else if e.IsFutures {
437-
req.InstrumentID(toLocalSymbol(symbol, okexapi.InstrumentTypeSwap))
438431
req.InstrumentType(okexapi.InstrumentTypeSwap)
439432
}
440433

@@ -479,10 +472,8 @@ func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) erro
479472
}
480473

481474
req := e.client.NewCancelOrderRequest()
482-
req.InstrumentID(toLocalSymbol(order.Symbol))
483-
if e.IsFutures {
484-
req.InstrumentID(toLocalSymbol(order.Symbol, okexapi.InstrumentTypeSwap))
485-
}
475+
req.InstrumentID(e.getInstrumentId(order.Symbol))
476+
486477
req.OrderID(strconv.FormatUint(order.OrderID, 10))
487478
if len(order.ClientOrderID) > 0 {
488479
if ok := clientOrderIdRegex.MatchString(order.ClientOrderID); !ok {
@@ -520,10 +511,7 @@ func (e *Exchange) QueryKLines(
520511
return nil, fmt.Errorf("failed to get interval: %w", err)
521512
}
522513

523-
instrumentID := toLocalSymbol(symbol)
524-
if e.IsFutures {
525-
instrumentID = toLocalSymbol(symbol, okexapi.InstrumentTypeSwap)
526-
}
514+
instrumentID := e.getInstrumentId(symbol)
527515

528516
req := e.client.NewGetCandlesRequest().InstrumentID(instrumentID)
529517
req.Bar(intervalParam)
@@ -558,10 +546,8 @@ func (e *Exchange) QueryOrder(ctx context.Context, q types.OrderQuery) (*types.O
558546
return nil, errors.New("okex.QueryOrder: OrderId or ClientOrderId is required parameter")
559547
}
560548
req := e.client.NewGetOrderDetailsRequest()
561-
instrumentID := toLocalSymbol(q.Symbol)
562-
if e.IsFutures {
563-
instrumentID = toLocalSymbol(q.Symbol, okexapi.InstrumentTypeSwap)
564-
}
549+
instrumentID := e.getInstrumentId(q.Symbol)
550+
565551
req.InstrumentID(instrumentID)
566552
// Either ordId or clOrdId is required, if both are passed, ordId will be used
567553
// ref: https://www.okx.com/docs-v5/en/#order-book-trading-trade-get-order-details
@@ -591,10 +577,7 @@ func (e *Exchange) QueryOrderTrades(ctx context.Context, q types.OrderQuery) (tr
591577

592578
req := e.client.NewGetThreeDaysTransactionHistoryRequest()
593579
if len(q.Symbol) != 0 {
594-
instrumentID := toLocalSymbol(q.Symbol)
595-
if e.IsFutures {
596-
instrumentID = toLocalSymbol(q.Symbol, okexapi.InstrumentTypeSwap)
597-
}
580+
instrumentID := e.getInstrumentId(q.Symbol)
598581
req.InstrumentID(instrumentID)
599582
}
600583

@@ -654,11 +637,7 @@ func (e *Exchange) QueryClosedOrders(
654637
}
655638

656639
req := e.client.NewGetOrderHistoryRequest()
657-
instrumentID := toLocalSymbol(symbol)
658-
if e.IsFutures {
659-
instrumentID = toLocalSymbol(symbol, okexapi.InstrumentTypeSwap)
660-
req.InstrumentType(okexapi.InstrumentTypeSwap)
661-
}
640+
instrumentID := e.getInstrumentId(symbol)
662641

663642
req.InstrumentID(instrumentID).
664643
StartTime(since).
@@ -814,6 +793,14 @@ func (e *Exchange) getInstrumentType() okexapi.InstrumentType {
814793
return okexapi.InstrumentTypeSpot
815794
}
816795

796+
func (e *Exchange) getInstrumentId(symbol string) string {
797+
if e.IsFutures {
798+
return toLocalSymbol(symbol, okexapi.InstrumentTypeSwap)
799+
}
800+
801+
return toLocalSymbol(symbol)
802+
}
803+
817804
func (e *Exchange) QueryDepositHistory(
818805
ctx context.Context, asset string, startTime, endTime *time.Time,
819806
) ([]types.Deposit, error) {
@@ -904,11 +891,7 @@ func (e *Exchange) QueryTrades(
904891
"req_id": uuid.New().String(),
905892
})
906893

907-
instrumentID := toLocalSymbol(symbol)
908-
if e.IsFutures {
909-
instrumentID = toLocalSymbol(symbol, okexapi.InstrumentTypeSwap)
910-
}
911-
894+
instrumentID := e.getInstrumentId(symbol)
912895
instrType := e.getInstrumentType()
913896

914897
if lessThan3Day {

pkg/exchange/okex/exchange_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,56 @@ func TestExchange_Futures_SubmitOrder(t *testing.T) {
500500
assert.NotEmpty(t, orders)
501501
}
502502

503+
func TestExchange_Futures_SubmitMarketOrder(t *testing.T) {
504+
key, secret, passphrase, ok := testutil.IntegrationTestWithPassphraseConfigured(t, "OKEX")
505+
if !ok {
506+
t.SkipNow()
507+
return
508+
}
509+
510+
ex := New(key, secret, passphrase)
511+
ex.UseFutures()
512+
dualSidePosition = true
513+
514+
markets, err := ex.QueryMarkets(context.Background())
515+
assert.NoError(t, err)
516+
517+
market := markets["BTCUSDT"]
518+
orders, err := ex.SubmitOrder(context.Background(), types.SubmitOrder{
519+
Symbol: "BTCUSDT",
520+
Side: types.SideTypeSell,
521+
Type: types.OrderTypeMarket,
522+
Quantity: market.MinQuantity,
523+
Market: market,
524+
})
525+
assert.NoError(t, err)
526+
assert.NotEmpty(t, orders)
527+
}
528+
529+
func TestExchange_Spot_SubmitMarketOrder(t *testing.T) {
530+
key, secret, passphrase, ok := testutil.IntegrationTestWithPassphraseConfigured(t, "OKEX")
531+
if !ok {
532+
t.SkipNow()
533+
return
534+
}
535+
536+
ex := New(key, secret, passphrase)
537+
538+
markets, err := ex.QueryMarkets(context.Background())
539+
assert.NoError(t, err)
540+
541+
market := markets["BTCUSDT"]
542+
orders, err := ex.SubmitOrder(context.Background(), types.SubmitOrder{
543+
Symbol: "BTCUSDT",
544+
Side: types.SideTypeBuy,
545+
Type: types.OrderTypeMarket,
546+
Quantity: market.MinQuantity,
547+
Market: market,
548+
})
549+
assert.NoError(t, err)
550+
assert.NotEmpty(t, orders)
551+
}
552+
503553
func TestExchange_QueryKlines(t *testing.T) {
504554
key, secret, passphrase, ok := testutil.IntegrationTestWithPassphraseConfigured(t, "OKEX")
505555
if !ok {
@@ -758,3 +808,52 @@ func TestExchange_QueryPositionRisk(t *testing.T) {
758808
}
759809
})
760810
}
811+
812+
func TestExchange_getInstrumentId(t *testing.T) {
813+
tests := []struct {
814+
name string
815+
symbol string
816+
isFutures bool
817+
expectedSymbol string
818+
}{
819+
{
820+
name: "BTCUSDT in spot mode",
821+
symbol: "BTCUSDT",
822+
isFutures: false,
823+
expectedSymbol: "BTC-USDT",
824+
},
825+
{
826+
name: "BTCUSDT in futures mode",
827+
symbol: "BTCUSDT",
828+
isFutures: true,
829+
expectedSymbol: "BTC-USDT-SWAP",
830+
},
831+
{
832+
name: "ETHUSDT in spot mode",
833+
symbol: "ETHUSDT",
834+
isFutures: false,
835+
expectedSymbol: "ETH-USDT",
836+
},
837+
{
838+
name: "ETHUSDT in futures mode",
839+
symbol: "ETHUSDT",
840+
isFutures: true,
841+
expectedSymbol: "ETH-USDT-SWAP",
842+
},
843+
{
844+
name: "empty symbol",
845+
symbol: "",
846+
isFutures: false,
847+
expectedSymbol: "",
848+
},
849+
}
850+
851+
for _, tt := range tests {
852+
t.Run(tt.name, func(t *testing.T) {
853+
ex := New("", "", "")
854+
ex.IsFutures = tt.isFutures
855+
got := ex.getInstrumentId(tt.symbol)
856+
assert.Equal(t, tt.expectedSymbol, got)
857+
})
858+
}
859+
}

pkg/exchange/okex/okexapi/get_instruments_info_request.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ type InstrumentInfo struct {
1515
BaseCurrency string `json:"baseCcy"`
1616
QuoteCurrency string `json:"quoteCcy"`
1717
SettleCurrency string `json:"settleCcy"`
18-
ContractValue string `json:"ctVal"`
18+
ContractValue fixedpoint.Value `json:"ctVal"`
1919
ContractMultiplier string `json:"ctMult"`
2020
ContractValueCurrency string `json:"ctValCcy"`
2121
ListTime types.MillisecondTimestamp `json:"listTime"`

pkg/types/market.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ type Market struct {
5555

5656
MinPrice fixedpoint.Value `json:"minPrice,omitempty"`
5757
MaxPrice fixedpoint.Value `json:"maxPrice,omitempty"`
58+
59+
ContractValue fixedpoint.Value `json:"contractValue,omitempty"`
5860
}
5961

6062
func (m Market) IsDustQuantity(quantity, price fixedpoint.Value) bool {
@@ -265,6 +267,16 @@ func (m Market) AdjustQuantityByMaxAmount(quantity, currentPrice, maxAmount fixe
265267
return m.TruncateQuantity(quantity)
266268
}
267269

270+
// AdjustQuantityToContractSize adjusts the quantity to contract size
271+
func (m Market) AdjustQuantityToContractSize(quantity fixedpoint.Value) fixedpoint.Value {
272+
if m.ContractValue.Sign() <= 0 || m.StepSize.Sign() <= 0 {
273+
return quantity
274+
}
275+
276+
contractQuantity := quantity.Div(m.ContractValue)
277+
return m.RoundUpByStepSize(contractQuantity)
278+
}
279+
268280
type MarketMap map[string]Market
269281

270282
func (m MarketMap) Add(markets ...Market) {

pkg/types/market_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,3 +266,56 @@ func TestMarket_AdjustQuantityByMinNotional(t *testing.T) {
266266
assert.False(t, market.IsDustQuantity(q2, testCase.price))
267267
}
268268
}
269+
270+
func TestMarket_AdjustQuantityToContractSize(t *testing.T) {
271+
market := Market{
272+
Symbol: "BTCUSDT",
273+
StepSize: fixedpoint.NewFromFloat(0.01), // step size is 0.01 BTC
274+
ContractValue: fixedpoint.NewFromFloat(0.01), // 1 contract = 0.01 BTC
275+
VolumePrecision: 8,
276+
}
277+
278+
testCases := []struct {
279+
name string
280+
quantity fixedpoint.Value
281+
expect fixedpoint.Value
282+
}{
283+
{
284+
name: "exact contract size",
285+
quantity: fixedpoint.NewFromFloat(0.01), // 1 contract
286+
expect: fixedpoint.NewFromFloat(1.0),
287+
},
288+
{
289+
name: "multiple contracts",
290+
quantity: fixedpoint.NewFromFloat(0.05), // 5 contracts
291+
expect: fixedpoint.NewFromFloat(5.0),
292+
},
293+
{
294+
name: "round up to step size",
295+
quantity: fixedpoint.NewFromFloat(0.024), // 2.4 contracts
296+
expect: fixedpoint.NewFromFloat(2.4),
297+
},
298+
{
299+
name: "zero quantity",
300+
quantity: fixedpoint.Zero,
301+
expect: fixedpoint.Zero,
302+
},
303+
{
304+
name: "small quantity",
305+
quantity: fixedpoint.NewFromFloat(0.005), // 0.5 contracts
306+
expect: fixedpoint.NewFromFloat(0.5),
307+
},
308+
{
309+
name: "large quantity",
310+
quantity: fixedpoint.NewFromFloat(1.0), // 100 contracts
311+
expect: fixedpoint.NewFromFloat(100.0),
312+
},
313+
}
314+
315+
for _, tc := range testCases {
316+
t.Run(tc.name, func(t *testing.T) {
317+
result := market.AdjustQuantityToContractSize(tc.quantity)
318+
assert.Equal(t, tc.expect, result, "quantity: %v", tc.quantity.Float64())
319+
})
320+
}
321+
}

0 commit comments

Comments
 (0)