Skip to content

Commit 5b6fa62

Browse files
committed
Fix OKX SPOT handling and currency registration
- Add instrument type filtering for funding rate subscriptions - Fix position reconciliation for mixed SPOT+SWAP configurations
1 parent 5924f1e commit 5b6fa62

File tree

6 files changed

+125
-39
lines changed

6 files changed

+125
-39
lines changed

crates/adapters/okx/src/websocket/client.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2372,13 +2372,15 @@ impl OKXWebSocketClient {
23722372
}
23732373
};
23742374

2375+
// For SPOT market orders, handle tgtCcy parameter
2376+
// https://www.okx.com/docs-v5/en/#order-book-trading-trade-post-place-order
2377+
// OKX API default behavior for SPOT market orders:
2378+
// - BUY orders default to tgtCcy=quote_ccy (sz represents quote currency amount)
2379+
// - SELL orders default to tgtCcy=base_ccy (sz represents base currency amount)
23752380
if instrument_type == OKXInstrumentType::Spot && order_type == OrderType::Market {
2376-
// https://www.okx.com/docs-v5/en/#order-book-trading-trade-post-place-order
2377-
// OKX API default behavior for SPOT:
2378-
// - BUY orders default to tgtCcy=quote_ccy
2379-
// - SELL orders default to tgtCcy=base_ccy
23802381
match quote_quantity {
23812382
Some(true) => {
2383+
// Explicitly request quote currency sizing
23822384
builder.tgt_ccy(OKX_TARGET_CCY_QUOTE.to_string());
23832385
}
23842386
Some(false) => {

examples/live/okx/okx_data_tester.py

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,33 +38,56 @@
3838

3939
# Configuration - Change instrument_type to switch between trading modes
4040
instrument_type = OKXInstrumentType.SWAP # SPOT, SWAP, FUTURES, OPTION
41+
token = "ETH"
4142

4243
# Symbol mapping based on instrument type
4344
if instrument_type == OKXInstrumentType.SPOT:
44-
symbol = "ETH-USDT"
45+
symbol = f"{token}-USDT"
4546
contract_types: tuple[OKXContractType, ...] | None = None # SPOT doesn't use contract types
4647
trade_size = Decimal("0.01")
4748
elif instrument_type == OKXInstrumentType.SWAP:
48-
symbol = "ETH-USDT-SWAP"
49-
contract_types = (OKXContractType.LINEAR, OKXContractType.INVERSE)
49+
symbol = f"{token}-USDT-SWAP"
50+
contract_types = (OKXContractType.LINEAR,)
5051
trade_size = Decimal("0.01")
5152
elif instrument_type == OKXInstrumentType.FUTURES:
5253
# Note: ETH-USD futures follow same pattern as BTC-USD
5354
# Format: ETH-USD-YYMMDD (e.g., ETH-USD-241227, ETH-USD-250131)
54-
symbol = "ETH-USD-251226" # ETH-USD futures expiring December 26, 2025
55+
symbol = f"{token}-USD-251226" # ETH-USD futures expiring December 26, 2025
5556
contract_types = (OKXContractType.INVERSE,) # ETH-USD futures are inverse contracts
5657
trade_size = Decimal(1)
5758
elif instrument_type == OKXInstrumentType.OPTION:
58-
symbol = "ETH-USD-251226-4000-C" # Example: ETH-USD call option, strike $4000, exp 2025-12-26
59+
symbol = (
60+
f"{token}-USD-251226-4000-C" # Example: ETH-USD call option, strike $4000, exp 2025-12-26
61+
)
5962
contract_types = None # OPTIONS don't use contract types in the same way
6063
trade_size = Decimal(1)
6164
else:
6265
raise ValueError(f"Unsupported instrument type: {instrument_type}")
6366

67+
instrument_id = InstrumentId.from_str(f"{symbol}.{OKX}")
68+
69+
# Additional instruments for testing (matching exec_tester setup)
70+
spot_instrument_id = InstrumentId.from_str(f"{token}-USDT.{OKX}")
71+
swap_instrument_id = InstrumentId.from_str(f"{token}-USDT-SWAP.{OKX}")
72+
73+
instrument_types = (
74+
OKXInstrumentType.SPOT,
75+
OKXInstrumentType.SWAP,
76+
)
77+
78+
instrument_families = (
79+
# "BTC-USD",
80+
# "ETH-USDT",
81+
)
82+
6483
# Configure the trading node
6584
config_node = TradingNodeConfig(
6685
trader_id=TraderId("TESTER-001"),
67-
logging=LoggingConfig(log_level="INFO", use_pyo3=True),
86+
logging=LoggingConfig(
87+
log_level="INFO",
88+
log_level_file="DEBUG",
89+
use_pyo3=True,
90+
),
6891
exec_engine=LiveExecEngineConfig(
6992
reconciliation=False, # Not applicable
7093
),
@@ -74,32 +97,38 @@
7497
api_secret=None, # 'OKX_API_SECRET' env var
7598
api_passphrase=None, # 'OKX_API_PASSPHRASE' env var
7699
base_url_http=None, # Override with custom endpoint
77-
instrument_provider=InstrumentProviderConfig(load_all=True),
78-
instrument_types=(instrument_type,), # Will load swap instruments
79-
contract_types=contract_types, # Will load linear contracts
100+
instrument_provider=InstrumentProviderConfig(
101+
load_all=False,
102+
load_ids=frozenset([spot_instrument_id, swap_instrument_id]),
103+
),
104+
instrument_types=instrument_types,
105+
contract_types=contract_types,
80106
is_demo=False, # If client uses the demo API
81107
http_timeout_secs=10, # Set to reasonable duration
82108
),
83109
},
84110
timeout_connection=20.0,
85111
timeout_disconnection=5.0,
86-
timeout_post_stop=1.0,
112+
timeout_post_stop=2.0,
87113
)
88114

89115
# Instantiate the node with a configuration
90116
node = TradingNode(config=config_node)
91117

92118
# Configure and initialize the tester
93119
config_tester = DataTesterConfig(
94-
instrument_ids=[InstrumentId.from_str(f"{symbol}.OKX")],
95-
bar_types=[BarType.from_str(f"{symbol}.OKX-1-MINUTE-LAST-EXTERNAL")],
120+
instrument_ids=[spot_instrument_id, swap_instrument_id],
121+
bar_types=[
122+
BarType.from_str(f"{spot_instrument_id.value}-1-MINUTE-LAST-EXTERNAL"),
123+
BarType.from_str(f"{swap_instrument_id.value}-1-MINUTE-LAST-EXTERNAL"),
124+
],
96125
# subscribe_book_deltas=True,
97126
# subscribe_book_depth=True,
98127
subscribe_book_at_interval=True, # Only legacy Cython wrapped book (not PyO3)
99128
subscribe_quotes=True,
100129
subscribe_trades=True,
101130
subscribe_mark_prices=True,
102-
subscribe_index_prices=True if instrument_type == OKXInstrumentType.SPOT else False,
131+
subscribe_index_prices=False, # Only for some derivatives
103132
subscribe_funding_rates=True,
104133
subscribe_bars=True,
105134
subscribe_instrument_status=False,

examples/live/okx/okx_exec_tester.py

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,26 +41,29 @@
4141

4242
# Configuration - Change instrument_type to switch between trading modes
4343
instrument_type = OKXInstrumentType.SWAP # SPOT, SWAP, FUTURES, OPTION
44+
token = "ETH"
4445

4546
# Symbol mapping based on instrument type
4647
if instrument_type == OKXInstrumentType.SPOT:
47-
symbol = "ETH-USDT"
48+
symbol = f"{token}-USDT"
4849
contract_types: tuple[OKXContractType, ...] | None = None # SPOT doesn't use contract types
49-
order_qty = Decimal("0.005")
50+
order_qty = Decimal("0.01")
5051
enable_sells = False
5152
elif instrument_type == OKXInstrumentType.SWAP:
52-
symbol = "ETH-USDT-SWAP"
53-
contract_types = (OKXContractType.LINEAR, OKXContractType.INVERSE)
53+
symbol = f"{token}-USDT-SWAP"
54+
contract_types = (OKXContractType.LINEAR,)
5455
order_qty = Decimal("0.01")
5556
enable_sells = True
5657
elif instrument_type == OKXInstrumentType.FUTURES:
5758
# Format: ETH-USD-YYMMDD (e.g., ETH-USD-241227, ETH-USD-250131)
58-
symbol = "ETH-USD-251226" # ETH-USD futures expiring 2025-12-26
59+
symbol = f"{token}-USD-251226" # ETH-USD futures expiring 2025-12-26
5960
contract_types = (OKXContractType.INVERSE,) # ETH-USD futures are inverse contracts
6061
order_qty = Decimal(1)
6162
enable_sells = True
6263
elif instrument_type == OKXInstrumentType.OPTION:
63-
symbol = "ETH-USD-251226-4000-C" # Example: ETH-USD call option, strike 4000, exp 2025-12-26
64+
symbol = (
65+
f"{token}-USD-251226-4000-C" # Example: ETH-USD call option, strike 4000, exp 2025-12-26
66+
)
6467
contract_types = None # Options don't use contract types in the same way
6568
order_qty = Decimal(1)
6669
enable_sells = True
@@ -69,11 +72,13 @@
6972

7073
instrument_id = InstrumentId.from_str(f"{symbol}.{OKX}")
7174

75+
# Additional instruments for reconciliation (matching wingman setup)
76+
spot_instrument_id = InstrumentId.from_str(f"{token}-USDT.{OKX}")
77+
swap_instrument_id = InstrumentId.from_str(f"{token}-USDT-SWAP.{OKX}")
78+
7279
instrument_types = (
7380
OKXInstrumentType.SPOT,
7481
OKXInstrumentType.SWAP,
75-
OKXInstrumentType.FUTURES,
76-
# OKXInstrumentType.OPTION,
7782
)
7883

7984
instrument_families = (
@@ -91,7 +96,7 @@
9196
),
9297
exec_engine=LiveExecEngineConfig(
9398
reconciliation=True,
94-
reconciliation_instrument_ids=[instrument_id],
99+
reconciliation_instrument_ids=[spot_instrument_id, swap_instrument_id],
95100
# reconciliation_lookback_mins=60,
96101
open_check_interval_secs=5.0,
97102
open_check_open_only=True,
@@ -132,9 +137,11 @@
132137
api_secret=None, # 'OKX_API_SECRET' env var
133138
api_passphrase=None, # 'OKX_API_PASSPHRASE' env var
134139
base_url_http=None, # Override with custom endpoint
135-
instrument_provider=InstrumentProviderConfig(load_all=True),
140+
instrument_provider=InstrumentProviderConfig(
141+
load_all=False,
142+
load_ids=frozenset([spot_instrument_id, swap_instrument_id]),
143+
),
136144
instrument_types=instrument_types,
137-
instrument_families=instrument_families,
138145
contract_types=contract_types,
139146
is_demo=False, # If client uses the demo API
140147
http_timeout_secs=10, # Set to reasonable duration
@@ -147,9 +154,11 @@
147154
api_passphrase=None, # 'OKX_API_PASSPHRASE' env var
148155
base_url_http=None, # Override with custom endpoint
149156
base_url_ws=None, # Override with custom endpoint
150-
instrument_provider=InstrumentProviderConfig(load_all=True),
157+
instrument_provider=InstrumentProviderConfig(
158+
load_all=False,
159+
load_ids=frozenset([spot_instrument_id, swap_instrument_id]),
160+
),
151161
instrument_types=instrument_types,
152-
instrument_families=instrument_families,
153162
contract_types=contract_types,
154163
# margin_mode=OKXMarginMode.ISOLATED,
155164
use_spot_margin=False,
@@ -172,14 +181,14 @@
172181
# Configure your strategy
173182
config_tester = ExecTesterConfig(
174183
instrument_id=instrument_id,
175-
external_order_claims=[instrument_id],
184+
external_order_claims=[spot_instrument_id, swap_instrument_id],
176185
use_hyphens_in_client_order_ids=False, # OKX doesn't allow hyphens in client order IDs
177186
# subscribe_quotes=False,
178187
# subscribe_trades=False,
179188
# subscribe_book=True,
180189
enable_buys=True,
181190
enable_sells=enable_sells,
182-
# open_position_on_start_qty=order_qty,
191+
open_position_on_start_qty=order_qty,
183192
open_position_time_in_force=TimeInForce.FOK,
184193
tob_offset_ticks=100,
185194
# stop_offset_ticks=1,

nautilus_trader/adapters/okx/data.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
from nautilus_trader.model.enums import BookType
5959
from nautilus_trader.model.enums import book_type_to_str
6060
from nautilus_trader.model.identifiers import ClientId
61+
from nautilus_trader.model.instruments import CryptoPerpetual
6162
from nautilus_trader.model.instruments import Instrument
6263

6364

@@ -314,6 +315,20 @@ async def _subscribe_index_prices(self, command: SubscribeIndexPrices) -> None:
314315
await self._ws_client.subscribe_index_prices(pyo3_instrument_id)
315316

316317
async def _subscribe_funding_rates(self, command: SubscribeFundingRates) -> None:
318+
# Funding rates only apply to perpetual swaps
319+
instrument = self._instrument_provider.find(command.instrument_id)
320+
if instrument is None:
321+
self._log.error(f"Cannot find instrument for {command.instrument_id}")
322+
return
323+
324+
# Check if instrument is a perpetual swap
325+
if not isinstance(instrument, CryptoPerpetual):
326+
self._log.warning(
327+
f"Funding rates not applicable for {command.instrument_id} "
328+
f"(instrument type: {type(instrument).__name__}), skipping subscription",
329+
)
330+
return
331+
317332
pyo3_instrument_id = nautilus_pyo3.InstrumentId.from_str(command.instrument_id.value)
318333
await self._ws_client.subscribe_funding_rates(pyo3_instrument_id)
319334

@@ -362,6 +377,20 @@ async def _unsubscribe_index_prices(self, command: UnsubscribeIndexPrices) -> No
362377
await self._ws_client.unsubscribe_index_prices(pyo3_instrument_id)
363378

364379
async def _unsubscribe_funding_rates(self, command: UnsubscribeFundingRates) -> None:
380+
# Funding rates only apply to perpetual swaps
381+
instrument = self._instrument_provider.find(command.instrument_id)
382+
if instrument is None:
383+
self._log.error(f"Cannot find instrument for {command.instrument_id}")
384+
return
385+
386+
# Check if instrument is a perpetual swap
387+
if not isinstance(instrument, CryptoPerpetual):
388+
self._log.warning(
389+
f"Funding rates not applicable for {command.instrument_id} "
390+
f"(instrument type: {type(instrument).__name__}), skipping unsubscription",
391+
)
392+
return
393+
365394
pyo3_instrument_id = nautilus_pyo3.InstrumentId.from_str(command.instrument_id.value)
366395
await self._ws_client.unsubscribe_funding_rates(pyo3_instrument_id)
367396

nautilus_trader/adapters/okx/execution.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
from nautilus_trader.model.identifiers import ClientId
6363
from nautilus_trader.model.identifiers import ClientOrderId
6464
from nautilus_trader.model.identifiers import InstrumentId
65+
from nautilus_trader.model.instruments import CurrencyPair
6566
from nautilus_trader.model.orders import Order
6667

6768

@@ -647,13 +648,15 @@ async def generate_position_status_reports( # noqa: C901 (too complex)
647648

648649
try:
649650
if command.instrument_id:
650-
# SPOT instruments in CASH account don't have positions - return FLAT
651-
if self.account_type == AccountType.CASH:
652-
instrument = self._cache.instrument(command.instrument_id)
653-
if instrument is None:
654-
raise RuntimeError(
655-
f"Cannot create FLAT position report - instrument {command.instrument_id} not found in cache",
656-
)
651+
# Check if this is a SPOT instrument (SPOT instruments don't have positions)
652+
instrument = self._cache.instrument(command.instrument_id)
653+
if instrument is None:
654+
raise RuntimeError(
655+
f"Cannot create position report - instrument {command.instrument_id} not found in cache",
656+
)
657+
658+
# SPOT instruments (CurrencyPair) don't have positions - return FLAT
659+
if isinstance(instrument, CurrencyPair):
657660
report = PositionStatusReport.create_flat(
658661
account_id=self.account_id,
659662
instrument_id=command.instrument_id,

nautilus_trader/adapters/okx/providers.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,13 @@ async def load_all_async(self, filters: dict | None = None) -> None:
148148
for instrument in instruments:
149149
self.add(instrument=instrument)
150150

151+
base_currency = instrument.get_base_currency()
152+
if base_currency is not None:
153+
self.add_currency(base_currency)
154+
155+
self.add_currency(instrument.quote_currency)
156+
self.add_currency(instrument.get_settlement_currency())
157+
151158
self._log.info(f"Loaded {len(self._instruments)} instruments")
152159

153160
async def load_ids_async( # noqa: C901 (too complex)
@@ -203,6 +210,13 @@ async def load_ids_async( # noqa: C901 (too complex)
203210
continue # Filter instrument ID
204211
self.add(instrument=instrument)
205212

213+
base_currency = instrument.get_base_currency()
214+
if base_currency is not None:
215+
self.add_currency(base_currency)
216+
217+
self.add_currency(instrument.quote_currency)
218+
self.add_currency(instrument.get_settlement_currency())
219+
206220
async def load_async(self, instrument_id: InstrumentId, filters: dict | None = None) -> None:
207221
PyCondition.not_none(instrument_id, "instrument_id")
208222
await self.load_ids_async([instrument_id], filters)

0 commit comments

Comments
 (0)