Skip to content

Commit 806f1f8

Browse files
committed
Fix OKX spot order fills handling
Trade SPOT with reconciliation off until spot position reports can be implemented.
1 parent cb24c34 commit 806f1f8

File tree

8 files changed

+131
-55
lines changed

8 files changed

+131
-55
lines changed

crates/adapters/okx/src/common/consts.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,9 @@ pub const OKX_POST_ONLY_CANCEL_SOURCE: &str = "31";
117117

118118
/// Human-readable reason used when a post-only order is auto-cancelled for taking liquidity.
119119
pub const OKX_POST_ONLY_CANCEL_REASON: &str = "POST_ONLY would take liquidity";
120+
121+
/// Target currency literal for base currency.
122+
pub const OKX_TARGET_CCY_BASE: &str = "base_ccy";
123+
124+
/// Target currency literal for quote currency.
125+
pub const OKX_TARGET_CCY_QUOTE: &str = "quote_ccy";

crates/adapters/okx/src/common/parse.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ pub fn okx_instrument_type(instrument: &InstrumentAny) -> anyhow::Result<OKXInst
160160
InstrumentAny::CurrencyPair(_) => Ok(OKXInstrumentType::Spot),
161161
InstrumentAny::CryptoPerpetual(_) => Ok(OKXInstrumentType::Swap),
162162
InstrumentAny::CryptoFuture(_) => Ok(OKXInstrumentType::Futures),
163-
InstrumentAny::OptionContract(_) => Ok(OKXInstrumentType::Option),
163+
InstrumentAny::CryptoOption(_) => Ok(OKXInstrumentType::Option),
164164
_ => anyhow::bail!("Invalid instrument type for OKX: {instrument:?}"),
165165
}
166166
}

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

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ use crate::{
8888
consts::{
8989
OKX_NAUTILUS_BROKER_ID, OKX_POST_ONLY_CANCEL_REASON, OKX_POST_ONLY_CANCEL_SOURCE,
9090
OKX_POST_ONLY_ERROR_CODE, OKX_SUPPORTED_ORDER_TYPES, OKX_SUPPORTED_TIME_IN_FORCE,
91-
OKX_WS_PUBLIC_URL, should_retry_error_code,
91+
OKX_TARGET_CCY_BASE, OKX_TARGET_CCY_QUOTE, OKX_WS_PUBLIC_URL, should_retry_error_code,
9292
},
9393
credential::Credential,
9494
enums::{
@@ -2260,7 +2260,6 @@ impl OKXWebSocketClient {
22602260
_ => {
22612261
// For other instrument types (OPTIONS, etc.), use quote currency as fallback
22622262
builder.ccy(quote_currency.to_string());
2263-
builder.tgt_ccy(quote_currency.to_string());
22642263

22652264
// TODO: Consider position mode (only applicable for NET)
22662265
if let Some(ro) = reduce_only
@@ -2271,12 +2270,27 @@ impl OKXWebSocketClient {
22712270
}
22722271
};
22732272

2274-
if let Some(is_quote_quantity) = quote_quantity
2275-
&& is_quote_quantity
2276-
{
2277-
builder.tgt_ccy(quote_currency.to_string());
2273+
if instrument_type == OKXInstrumentType::Spot && order_type == OrderType::Market {
2274+
// https://www.okx.com/docs-v5/en/#order-book-trading-trade-post-place-order
2275+
// OKX API default behavior for SPOT:
2276+
// - BUY orders default to tgtCcy=quote_ccy
2277+
// - SELL orders default to tgtCcy=base_ccy
2278+
match quote_quantity {
2279+
Some(true) => {
2280+
builder.tgt_ccy(OKX_TARGET_CCY_QUOTE.to_string());
2281+
}
2282+
Some(false) => {
2283+
if order_side == OrderSide::Buy {
2284+
// For BUY orders, must explicitly set to base_ccy to override OKX default
2285+
builder.tgt_ccy(OKX_TARGET_CCY_BASE.to_string());
2286+
}
2287+
// For SELL orders with quote_quantity=false, omit tgtCcy (OKX defaults to base_ccy correctly)
2288+
}
2289+
None => {
2290+
// No preference specified, use OKX defaults
2291+
}
2292+
}
22782293
}
2279-
// If is_quote_quantity is false, we don't set tgtCcy (defaults to base currency)
22802294

22812295
builder.side(order_side);
22822296

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

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
//! Functions translating raw OKX WebSocket frames into Nautilus data types.
1717
1818
use ahash::AHashMap;
19-
use nautilus_core::nanos::UnixNanos;
19+
use nautilus_core::{UUID4, nanos::UnixNanos};
2020
use nautilus_model::{
2121
data::{
2222
Bar, BarSpecification, BarType, BookOrder, Data, FundingRateUpdate, IndexPriceUpdate,
@@ -644,8 +644,12 @@ pub fn parse_order_msg(
644644

645645
let previous_fee = fee_cache.get(&msg.ord_id).copied();
646646

647+
// Only generate fill reports when there's actual new fill data
648+
// Check if fillSz is non-zero/non-empty OR trade_id is present
649+
let has_new_fill = (!msg.fill_sz.is_empty() && msg.fill_sz != "0") || !msg.trade_id.is_empty();
650+
647651
match msg.state {
648-
OKXOrderStatus::Filled | OKXOrderStatus::PartiallyFilled => {
652+
OKXOrderStatus::Filled | OKXOrderStatus::PartiallyFilled if has_new_fill => {
649653
parse_fill_report(msg, instrument, account_id, previous_fee, ts_init)
650654
.map(ExecutionReport::Fill)
651655
}
@@ -804,7 +808,6 @@ pub fn parse_order_status_report(
804808
let size_precision = instrument.size_precision();
805809
let quantity = parse_quantity(&msg.sz, size_precision)?;
806810
let filled_qty = parse_quantity(&msg.acc_fill_sz.clone().unwrap_or_default(), size_precision)?;
807-
808811
let ts_accepted = parse_millisecond_timestamp(msg.c_time);
809812
let ts_last = parse_millisecond_timestamp(msg.u_time);
810813

@@ -904,16 +907,65 @@ pub fn parse_fill_report(
904907
) -> anyhow::Result<FillReport> {
905908
let client_order_id = parse_client_order_id(&msg.cl_ord_id);
906909
let venue_order_id = VenueOrderId::new(msg.ord_id);
907-
let trade_id = TradeId::from(msg.trade_id.as_str());
910+
911+
// TODO: Extract to dedicated function:
912+
// OKX may not provide a trade_id, so generate a UUID4 as fallback
913+
let trade_id = if msg.trade_id.is_empty() {
914+
TradeId::from(UUID4::new().to_string().as_str())
915+
} else {
916+
TradeId::from(msg.trade_id.as_str())
917+
};
918+
908919
let order_side: OrderSide = msg.side.into();
909920

910921
let price_precision = instrument.price_precision();
911922
let size_precision = instrument.size_precision();
912-
let last_px = parse_price(&msg.fill_px, price_precision)?;
913-
let last_qty = parse_quantity(&msg.fill_sz, size_precision)?;
923+
924+
// TODO: Extract to dedicated function:
925+
// OKX may not provide fillPx for some orders, fall back to avgPx or lastPx
926+
let price_str = if !msg.fill_px.is_empty() {
927+
&msg.fill_px
928+
} else if !msg.avg_px.is_empty() {
929+
&msg.avg_px
930+
} else {
931+
&msg.px // Last resort, use order price
932+
};
933+
let last_px = parse_price(price_str, price_precision).map_err(|e| {
934+
anyhow::anyhow!(
935+
"Failed to parse price (fill_px='{}', avg_px='{}', px='{}'): {}",
936+
msg.fill_px,
937+
msg.avg_px,
938+
msg.px,
939+
e
940+
)
941+
})?;
942+
943+
// TODO: Extract to dedicated function:
944+
// OKX may not provide fillSz for some orders, fall back to accFillSz (accumulated fill size)
945+
let qty_str = if !msg.fill_sz.is_empty() && msg.fill_sz != "0" {
946+
&msg.fill_sz
947+
} else if let Some(ref acc_fill_sz) = msg.acc_fill_sz {
948+
if !acc_fill_sz.is_empty() && acc_fill_sz != "0" {
949+
acc_fill_sz
950+
} else {
951+
&msg.sz // Last resort, use order size
952+
}
953+
} else {
954+
&msg.sz // Last resort, use order size
955+
};
956+
let last_qty = parse_quantity(qty_str, size_precision).map_err(|e| {
957+
anyhow::anyhow!(
958+
"Failed to parse quantity (fill_sz='{}', acc_fill_sz={:?}, sz='{}'): {}",
959+
msg.fill_sz,
960+
msg.acc_fill_sz,
961+
msg.sz,
962+
e
963+
)
964+
})?;
914965

915966
let fee_currency = Currency::from(&msg.fee_ccy);
916-
let total_fee = parse_fee(msg.fee.as_deref(), fee_currency)?;
967+
let total_fee = parse_fee(msg.fee.as_deref(), fee_currency)
968+
.map_err(|e| anyhow::anyhow!("Failed to parse fee={:?}: {}", msg.fee, e))?;
917969
let commission = if let Some(previous_fee) = previous_fee {
918970
total_fee - previous_fee
919971
} else {

docs/integrations/okx.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,10 @@ OKX supports four trade modes, which the adapter selects automatically based on
216216

217217
| Mode | Used For | Leverage | Borrowing | Configuration |
218218
|---------------------|--------------------------------------------|----------|-----------|---------------|
219-
| **`cash`** | Simple spot trading | ❌ No | ❌ No | `use_spot_margin=False` (default for SPOT) |
220-
| **`spot_isolated`** | Spot trading with margin/leverage | ✅ Yes | ✅ Yes | `use_spot_margin=True` |
221-
| **`isolated`** | Derivatives trading (SWAP/FUTURES/OPTIONS) | ✅ Yes | ✅ Yes | `margin_mode=ISOLATED` or unset (default for derivatives) |
222-
| **`cross`** | Derivatives with shared margin pool | ✅ Yes | ✅ Yes | `margin_mode=CROSS` |
219+
| **`cash`** | Simple spot trading | - | - | `use_spot_margin=False` (default for SPOT) |
220+
| **`spot_isolated`** | Spot trading with margin/leverage | | | `use_spot_margin=True` |
221+
| **`isolated`** | Derivatives trading (SWAP/FUTURES/OPTIONS) | | | `margin_mode=ISOLATED` or unset (default for derivatives) |
222+
| **`cross`** | Derivatives with shared margin pool | | | `margin_mode=CROSS` |
223223

224224
#### Configuration-based trade mode selection
225225

examples/live/okx/okx_exec_tester.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,9 @@
2727
from nautilus_trader.config import TradingNodeConfig
2828
from nautilus_trader.core.nautilus_pyo3 import OKXContractType
2929
from nautilus_trader.core.nautilus_pyo3 import OKXInstrumentType
30+
from nautilus_trader.live.config import LiveRiskEngineConfig
3031
from nautilus_trader.live.node import TradingNode
31-
from nautilus_trader.model.enums import OrderType
3232
from nautilus_trader.model.enums import TimeInForce
33-
from nautilus_trader.model.enums import TriggerType
3433
from nautilus_trader.model.identifiers import InstrumentId
3534
from nautilus_trader.model.identifiers import TraderId
3635
from nautilus_trader.test_kit.strategies.tester_exec import ExecTester
@@ -64,6 +63,8 @@
6463
else:
6564
raise ValueError(f"Unsupported instrument type: {instrument_type}")
6665

66+
instrument_id = InstrumentId.from_str(f"{symbol}.{OKX}")
67+
6768
instrument_types = (instrument_type,)
6869

6970
# instrument_types = (
@@ -78,7 +79,6 @@
7879
# "ETH-USDT",
7980
)
8081

81-
8282
# Configure the trading node
8383
config_node = TradingNodeConfig(
8484
trader_id=TraderId("TESTER-001"),
@@ -89,6 +89,7 @@
8989
),
9090
exec_engine=LiveExecEngineConfig(
9191
reconciliation=True,
92+
reconciliation_instrument_ids=[instrument_id],
9293
# reconciliation_lookback_mins=60,
9394
open_check_interval_secs=5.0,
9495
open_check_open_only=True,
@@ -105,6 +106,7 @@
105106
purge_account_events_lookback_mins=60, # Purge account events occurring more than an hour ago
106107
graceful_shutdown_on_exception=True,
107108
),
109+
risk_engine=LiveRiskEngineConfig(bypass=True), # Must bypass for spot margin for now
108110
# cache=CacheConfig(
109111
# database=DatabaseConfig(),
110112
# timestamps_as_iso8601=True,
@@ -147,6 +149,8 @@
147149
instrument_types=instrument_types,
148150
instrument_families=instrument_families,
149151
contract_types=contract_types,
152+
# margin_mode=OKXMarginMode.ISOLATED,
153+
use_spot_margin=False,
150154
# use_mm_mass_cancel=True,
151155
is_demo=False, # If client uses the demo API
152156
use_fills_channel=False, # Set to True if VIP5+ to get separate fill reports
@@ -165,25 +169,26 @@
165169

166170
# Configure your strategy
167171
config_tester = ExecTesterConfig(
168-
instrument_id=InstrumentId.from_str(f"{symbol}.OKX"),
169-
external_order_claims=[InstrumentId.from_str(f"{symbol}.OKX")],
172+
instrument_id=instrument_id,
173+
external_order_claims=[instrument_id],
170174
use_hyphens_in_client_order_ids=False, # OKX doesn't allow hyphens in client order IDs
171175
# subscribe_quotes=False,
172176
# subscribe_trades=False,
173177
# subscribe_book=True,
174-
# enable_buys=False,
175-
# enable_sells=False,
178+
enable_buys=True,
179+
enable_sells=True,
176180
# open_position_on_start_qty=order_qty,
177181
open_position_time_in_force=TimeInForce.FOK,
178-
tob_offset_ticks=0,
179-
stop_offset_ticks=1,
182+
tob_offset_ticks=100,
183+
# stop_offset_ticks=1,
180184
order_qty=order_qty,
181185
# modify_orders_to_maintain_tob_offset=True,
182-
use_post_only=True,
186+
# use_post_only=True,
187+
# use_quote_quantity=True,
183188
# enable_stop_buys=True,
184189
# enable_stop_sells=True,
185-
stop_order_type=OrderType.STOP_MARKET,
186-
stop_trigger_type=TriggerType.LAST_PRICE,
190+
# stop_order_type=OrderType.STOP_MARKET,
191+
# stop_trigger_type=TriggerType.LAST_PRICE,
187192
# stop_offset_ticks=50, # Offset from current price for stop trigger
188193
# stop_limit_offset_ticks=10, # Additional offset for STOP_LIMIT orders
189194
# cancel_orders_on_stop=False,

nautilus_trader/adapters/okx/config.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ class OKXDataClientConfig(LiveDataClientConfig, frozen=True):
4040
instrument_types : tuple[OKXInstrumentType], default `(OKXInstrumentType.SPOT,)`
4141
The OKX instrument types of instruments to load.
4242
If None, all instrument types are loaded (subject to contract types and their compatibility with instrument types).
43-
contract_types : tuple[OKXInstrumentType], optional
44-
The OKX contract types of instruments to load.
45-
If None, all contract types are loaded (subject to instrument types and their compatibility with contract types).
4643
instrument_families : tuple[str, ...], optional
4744
The OKX instrument families to load (e.g., "BTC-USD", "ETH-USD").
4845
Required for OPTIONS. Optional for FUTURES/SWAP. Not applicable for SPOT/MARGIN.
4946
If None, all available instrument families will be attempted (may fail for OPTIONS).
47+
contract_types : tuple[OKXInstrumentType], optional
48+
The OKX contract types of instruments to load.
49+
If None, all contract types are loaded (subject to instrument types and their compatibility with contract types).
5050
base_url_http : str, optional
5151
The base url to OKX's http api.
5252
If ``None`` then will source the `get_http_base_url()`.
@@ -68,8 +68,8 @@ class OKXDataClientConfig(LiveDataClientConfig, frozen=True):
6868
api_secret: str | None = None
6969
api_passphrase: str | None = None
7070
instrument_types: tuple[OKXInstrumentType, ...] = (OKXInstrumentType.SPOT,)
71-
contract_types: tuple[OKXContractType, ...] | None = None
7271
instrument_families: tuple[str, ...] | None = None
72+
contract_types: tuple[OKXContractType, ...] | None = None
7373
base_url_http: str | None = None
7474
base_url_ws: str | None = None
7575
is_demo: bool = False

nautilus_trader/adapters/okx/execution.py

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,23 @@ async def generate_position_status_reports( # noqa: C901 (too complex)
711711

712712
# -- COMMAND HANDLERS -------------------------------------------------------------------------
713713

714+
def _parse_trade_mode_from_params(self, params: dict[str, Any] | None) -> OKXTradeMode:
715+
if not params:
716+
return self._trade_mode
717+
718+
td_mode_str = params.get("td_mode")
719+
if not td_mode_str:
720+
return self._trade_mode
721+
722+
try:
723+
return OKXTradeMode(td_mode_str)
724+
except ValueError:
725+
self._log.warning(
726+
f"Failed to parse OKXTradeMode: Valid modes are 'cash', 'isolated', 'cross', 'spot_isolated', "
727+
f"falling back to '{str(self._trade_mode).lower()}'",
728+
)
729+
return self._trade_mode
730+
714731
async def _query_account(self, _command: QueryAccount) -> None:
715732
# TODO: Specific account ID (sub account) not yet supported
716733
await self._update_account_state()
@@ -764,19 +781,7 @@ async def _submit_order_websocket(self, command: SubmitOrder) -> None:
764781
time_in_force_to_pyo3(order.time_in_force) if order.time_in_force else None
765782
)
766783

767-
td_mode = self._trade_mode
768-
769-
if command.params:
770-
td_mode_str = command.params.get("td_mode")
771-
if td_mode_str:
772-
try:
773-
td_mode = OKXTradeMode(td_mode_str)
774-
except ValueError:
775-
self._log.warning(
776-
f"Failed to parse OKXTradeMode: Valid modes are 'cash', 'isolated', 'cross', 'spot_isolated', "
777-
f"falling back to '{str(self._trade_mode).lower()}'",
778-
)
779-
td_mode = self._trade_mode
784+
td_mode = self._parse_trade_mode_from_params(command.params)
780785

781786
try:
782787
await self._ws_client.submit_order(
@@ -824,13 +829,7 @@ async def _submit_algo_order_http(self, command: SubmitOrder) -> None:
824829
trigger_type_to_pyo3(order.trigger_type) if hasattr(order, "trigger_type") else None
825830
)
826831

827-
td_mode = self._trade_mode
828-
if command.params and "td_mode" in command.params:
829-
td_mode_str = command.params["td_mode"]
830-
try:
831-
td_mode = OKXTradeMode(td_mode_str)
832-
except ValueError:
833-
self._log.warning(f"Invalid trade mode '{td_mode_str}', using default")
832+
td_mode = self._parse_trade_mode_from_params(command.params)
834833

835834
try:
836835
response = await self._http_client.place_algo_order(

0 commit comments

Comments
 (0)