Skip to content

Commit d3f0122

Browse files
committed
Implement OKX spot margin reconciliation and quote-quantity orders
1 parent 5dd6a18 commit d3f0122

File tree

11 files changed

+989
-44
lines changed

11 files changed

+989
-44
lines changed

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

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ use crate::{
5959
consts::OKX_VENUE,
6060
enums::{
6161
OKXExecType, OKXInstrumentType, OKXOrderStatus, OKXOrderType, OKXPositionSide, OKXSide,
62-
OKXVipLevel,
62+
OKXTargetCurrency, OKXVipLevel,
6363
},
6464
models::OKXInstrument,
6565
},
@@ -532,18 +532,100 @@ pub fn parse_order_status_report(
532532
size_precision: u8,
533533
ts_init: UnixNanos,
534534
) -> OrderStatusReport {
535-
let quantity = order
536-
.sz
537-
.parse::<f64>()
538-
.ok()
539-
.map(|v| Quantity::new(v, size_precision))
540-
.unwrap_or_default();
541-
let filled_qty = order
542-
.acc_fill_sz
543-
.parse::<f64>()
544-
.ok()
545-
.map(|v| Quantity::new(v, size_precision))
546-
.unwrap_or_default();
535+
// Parse quantities based on target currency
536+
// OKX always returns acc_fill_sz in base currency, but sz depends on tgt_ccy
537+
538+
// Determine if this is a quote-quantity order
539+
// Method 1: Explicit tgt_ccy field set to QuoteCcy
540+
let is_quote_qty_explicit = order.tgt_ccy == Some(OKXTargetCurrency::QuoteCcy);
541+
542+
// Method 2: Use OKX defaults when tgt_ccy is None (old orders or missing field)
543+
// OKX API defaults for SPOT market orders: BUY orders use quote_ccy, SELL orders use base_ccy
544+
// Note: tgtCcy only applies to SPOT market orders (not limit orders)
545+
// For limit orders, sz is always in base currency regardless of side
546+
let is_quote_qty_heuristic = order.tgt_ccy.is_none()
547+
&& (order.inst_type == OKXInstrumentType::Spot
548+
|| order.inst_type == OKXInstrumentType::Margin)
549+
&& order.side == OKXSide::Buy
550+
&& order.ord_type == OKXOrderType::Market;
551+
552+
let (quantity, filled_qty) = if is_quote_qty_explicit || is_quote_qty_heuristic {
553+
// Quote-quantity order: sz is in quote currency, need to convert to base
554+
let sz_quote = order.sz.parse::<f64>().unwrap_or(0.0);
555+
556+
// Determine the price to use for conversion
557+
// Priority: 1) limit price (px) for limit orders, 2) avg_px for market orders
558+
let conversion_price = if !order.px.is_empty() && order.px != "0" {
559+
// Limit order: use the limit price (order.px)
560+
order.px.parse::<f64>().unwrap_or(0.0)
561+
} else if !order.avg_px.is_empty() && order.avg_px != "0" {
562+
// Market order with fills: use average fill price
563+
order.avg_px.parse::<f64>().unwrap_or(0.0)
564+
} else {
565+
log::warn!(
566+
"No price available for conversion: ord_id={}, px='{}', avg_px='{}'",
567+
order.ord_id.as_str(),
568+
order.px,
569+
order.avg_px
570+
);
571+
0.0
572+
};
573+
574+
// Convert quote quantity to base: quantity_base = sz_quote / price
575+
let quantity_base = if conversion_price > 0.0 {
576+
Quantity::new(sz_quote / conversion_price, size_precision)
577+
} else {
578+
// No price available, can't convert - use sz as-is temporarily
579+
log::warn!(
580+
"Cannot convert, using sz as-is: ord_id={}, sz={}",
581+
order.ord_id.as_str(),
582+
order.sz
583+
);
584+
order
585+
.sz
586+
.parse::<f64>()
587+
.ok()
588+
.map(|v| Quantity::new(v, size_precision))
589+
.unwrap_or_default()
590+
};
591+
592+
let filled_qty = order
593+
.acc_fill_sz
594+
.parse::<f64>()
595+
.ok()
596+
.map(|v| Quantity::new(v, size_precision))
597+
.unwrap_or_default();
598+
599+
(quantity_base, filled_qty)
600+
} else {
601+
// Base-quantity order: both sz and acc_fill_sz are in base currency
602+
let quantity = order
603+
.sz
604+
.parse::<f64>()
605+
.ok()
606+
.map(|v| Quantity::new(v, size_precision))
607+
.unwrap_or_default();
608+
let filled_qty = order
609+
.acc_fill_sz
610+
.parse::<f64>()
611+
.ok()
612+
.map(|v| Quantity::new(v, size_precision))
613+
.unwrap_or_default();
614+
615+
(quantity, filled_qty)
616+
};
617+
618+
// For quote-quantity orders marked as FILLED, adjust quantity to match filled_qty
619+
// to avoid precision mismatches from quote-to-base conversion
620+
let (quantity, filled_qty) = if (is_quote_qty_explicit || is_quote_qty_heuristic)
621+
&& order.state == OKXOrderStatus::Filled
622+
&& filled_qty.is_positive()
623+
{
624+
(filled_qty, filled_qty)
625+
} else {
626+
(quantity, filled_qty)
627+
};
628+
547629
let order_side: OrderSide = order.side.into();
548630
let okx_status: OKXOrderStatus = order.state;
549631
let order_status: OrderStatus = okx_status.into();

crates/adapters/okx/src/http/models.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ pub struct OKXAccount {
175175
/// Represents a balance detail for a single currency in an OKX account.
176176
#[derive(Clone, Debug, Serialize, Deserialize)]
177177
#[serde(rename_all = "camelCase")]
178+
#[cfg_attr(feature = "python", pyo3::pyclass)]
178179
pub struct OKXBalanceDetail {
179180
/// Available balance.
180181
pub avail_bal: String,

crates/adapters/okx/src/python/http.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,29 @@ impl OKXHttpClient {
539539
Python::attach(|py| timestamp.into_py_any(py))
540540
})
541541
}
542+
543+
#[pyo3(name = "http_get_balance")]
544+
fn py_http_get_balance<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
545+
let client = self.clone();
546+
547+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
548+
let accounts = client
549+
.inner
550+
.http_get_balance()
551+
.await
552+
.map_err(to_pyvalue_err)?;
553+
554+
let details: Vec<_> = accounts
555+
.into_iter()
556+
.flat_map(|account| account.details)
557+
.collect();
558+
559+
Python::attach(|py| {
560+
let pylist = PyList::new(py, details)?;
561+
Ok(pylist.into_py_any_unwrap(py))
562+
})
563+
})
564+
}
542565
}
543566

544567
impl From<OKXHttpError> for PyErr {

crates/adapters/okx/src/python/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
1818
pub mod enums;
1919
pub mod http;
20+
pub mod models;
2021
pub mod urls;
2122
pub mod websocket;
2223

@@ -32,6 +33,7 @@ pub fn okx(_: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
3233
m.add_class::<super::websocket::OKXWebSocketClient>()?;
3334
m.add_class::<super::websocket::messages::OKXWebSocketError>()?;
3435
m.add_class::<super::http::OKXHttpClient>()?;
36+
m.add_class::<crate::http::models::OKXBalanceDetail>()?;
3537
m.add_class::<crate::common::enums::OKXInstrumentType>()?;
3638
m.add_class::<crate::common::enums::OKXContractType>()?;
3739
m.add_class::<crate::common::enums::OKXMarginMode>()?;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// -------------------------------------------------------------------------------------------------
2+
// Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3+
// https://nautechsystems.io
4+
//
5+
// Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6+
// You may not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
// -------------------------------------------------------------------------------------------------
15+
16+
use pyo3::prelude::*;
17+
18+
use crate::http::models::OKXBalanceDetail;
19+
20+
#[pymethods]
21+
impl OKXBalanceDetail {
22+
#[getter]
23+
fn ccy(&self) -> String {
24+
self.ccy.to_string()
25+
}
26+
27+
#[getter]
28+
fn cash_bal(&self) -> &str {
29+
&self.cash_bal
30+
}
31+
32+
#[getter]
33+
fn liab(&self) -> &str {
34+
&self.liab
35+
}
36+
}

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

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ use crate::{
4646
common::{
4747
consts::{OKX_POST_ONLY_CANCEL_REASON, OKX_POST_ONLY_CANCEL_SOURCE},
4848
enums::{
49-
OKXBookAction, OKXCandleConfirm, OKXOrderCategory, OKXOrderStatus, OKXOrderType,
50-
OKXTriggerType,
49+
OKXBookAction, OKXCandleConfirm, OKXInstrumentType, OKXOrderCategory, OKXOrderStatus,
50+
OKXOrderType, OKXSide, OKXTargetCurrency, OKXTriggerType,
5151
},
5252
models::OKXInstrument,
5353
parse::{
@@ -892,8 +892,77 @@ pub fn parse_order_status_report(
892892
};
893893

894894
let size_precision = instrument.size_precision();
895-
let quantity = parse_quantity(&msg.sz, size_precision)?;
896-
let filled_qty = parse_quantity(&msg.acc_fill_sz.clone().unwrap_or_default(), size_precision)?;
895+
896+
// Parse quantities based on target currency
897+
// OKX always returns acc_fill_sz in base currency, but sz depends on tgt_ccy
898+
899+
// Determine if this is a quote-quantity order
900+
// Method 1: Explicit tgt_ccy field set to QuoteCcy
901+
let is_quote_qty_explicit = msg.tgt_ccy == Some(OKXTargetCurrency::QuoteCcy);
902+
903+
// Method 2: Use OKX defaults when tgt_ccy is None (old orders or missing field)
904+
// OKX API defaults for SPOT market orders: BUY orders use quote_ccy, SELL orders use base_ccy
905+
// Note: tgtCcy only applies to SPOT market orders (not limit orders)
906+
// For limit orders, sz is always in base currency regardless of side
907+
let is_quote_qty_heuristic = msg.tgt_ccy.is_none()
908+
&& (msg.inst_type == OKXInstrumentType::Spot || msg.inst_type == OKXInstrumentType::Margin)
909+
&& msg.side == OKXSide::Buy
910+
&& msg.ord_type == OKXOrderType::Market;
911+
912+
let (quantity, filled_qty) = if is_quote_qty_explicit || is_quote_qty_heuristic {
913+
// Quote-quantity order: sz is in quote currency, need to convert to base
914+
let sz_quote = msg.sz.parse::<f64>().map_err(|e| {
915+
anyhow::anyhow!("Failed to parse sz='{}' as quote quantity: {}", msg.sz, e)
916+
})?;
917+
918+
// Determine the price to use for conversion
919+
// Priority: 1) limit price (px) for limit orders, 2) avg_px for market orders
920+
let conversion_price = if !msg.px.is_empty() && msg.px != "0" {
921+
// Limit order: use the limit price (msg.px)
922+
msg.px
923+
.parse::<f64>()
924+
.map_err(|e| anyhow::anyhow!("Failed to parse px='{}': {}", msg.px, e))?
925+
} else if !msg.avg_px.is_empty() && msg.avg_px != "0" {
926+
// Market order with fills: use average fill price
927+
msg.avg_px
928+
.parse::<f64>()
929+
.map_err(|e| anyhow::anyhow!("Failed to parse avg_px='{}': {}", msg.avg_px, e))?
930+
} else {
931+
0.0
932+
};
933+
934+
// Convert quote quantity to base: quantity_base = sz_quote / price
935+
let quantity_base = if conversion_price > 0.0 {
936+
Quantity::new(sz_quote / conversion_price, size_precision)
937+
} else {
938+
// No price available, can't convert - use sz as-is temporarily
939+
// This will be corrected once the order gets filled and price is available
940+
parse_quantity(&msg.sz, size_precision)?
941+
};
942+
943+
let filled_qty =
944+
parse_quantity(&msg.acc_fill_sz.clone().unwrap_or_default(), size_precision)?;
945+
946+
(quantity_base, filled_qty)
947+
} else {
948+
// Base-quantity order: both sz and acc_fill_sz are in base currency
949+
let quantity = parse_quantity(&msg.sz, size_precision)?;
950+
let filled_qty =
951+
parse_quantity(&msg.acc_fill_sz.clone().unwrap_or_default(), size_precision)?;
952+
953+
(quantity, filled_qty)
954+
};
955+
956+
// For quote-quantity orders marked as FILLED, adjust quantity to match filled_qty
957+
// to avoid precision mismatches from quote-to-base conversion
958+
let (quantity, filled_qty) = if (is_quote_qty_explicit || is_quote_qty_heuristic)
959+
&& msg.state == OKXOrderStatus::Filled
960+
&& filled_qty.is_positive()
961+
{
962+
(filled_qty, filled_qty)
963+
} else {
964+
(quantity, filled_qty)
965+
};
897966

898967
let ts_accepted = parse_millisecond_timestamp(msg.c_time);
899968
let ts_last = parse_millisecond_timestamp(msg.u_time);

examples/live/okx/okx_exec_tester.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,15 @@
4141

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

4747
# Symbol mapping based on instrument type
4848
if instrument_type == OKXInstrumentType.SPOT:
4949
symbol = f"{token}-USDT"
5050
contract_types: tuple[OKXContractType, ...] | None = None # SPOT doesn't use contract types
5151
if use_spot_margin:
52-
order_qty = Decimal("20.00")
52+
order_qty = Decimal("10.00")
5353
use_quote_quantity = True
5454
else:
5555
order_qty = Decimal("0.01")
@@ -182,6 +182,7 @@
182182
contract_types=contract_types,
183183
margin_mode=OKXMarginMode.CROSS,
184184
use_spot_margin=use_spot_margin,
185+
# use_spot_cash_position_reports=True, # Spot CASH position reports
185186
# use_mm_mass_cancel=True,
186187
is_demo=False, # If client uses the demo API
187188
use_fills_channel=False, # Set to True if VIP5+ to get separate fill reports

nautilus_trader/adapters/okx/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ class OKXExecClientConfig(LiveExecClientConfig, frozen=True):
137137
If True, uses OKX's mass-cancel endpoint for cancel_all_orders operations.
138138
This endpoint is typically restricted to market makers and high-volume traders.
139139
If False, cancels orders individually (works for all users).
140+
use_spot_cash_position_reports : bool, default False
141+
If True, generates position reports for SPOT CASH instruments based on wallet balances.
142+
Positive balances (cash_bal - liab) are treated as LONG positions, and negative balances
143+
(borrowing) as SHORT positions. This may lead to unintended liquidation of wallet assets
144+
if strategies are not designed to handle SPOT positions properly.
145+
If False, SPOT instruments return FLAT position reports (default behavior).
140146
141147
"""
142148

@@ -157,3 +163,4 @@ class OKXExecClientConfig(LiveExecClientConfig, frozen=True):
157163
retry_delay_max_ms: PositiveInt | None = 10_000
158164
use_fills_channel: bool = False
159165
use_mm_mass_cancel: bool = False
166+
use_spot_cash_position_reports: bool = False

0 commit comments

Comments
 (0)