nautilus_okx/common/
parse.rs

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//! Parsing utilities that convert OKX payloads into Nautilus domain models.
17
18use std::str::FromStr;
19
20use nautilus_core::{
21    UUID4,
22    datetime::{NANOSECONDS_IN_MILLISECOND, millis_to_nanos_unchecked},
23    nanos::UnixNanos,
24};
25use nautilus_model::{
26    data::{
27        Bar, BarSpecification, BarType, Data, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
28        TradeTick,
29        bar::{
30            BAR_SPEC_1_DAY_LAST, BAR_SPEC_1_HOUR_LAST, BAR_SPEC_1_MINUTE_LAST,
31            BAR_SPEC_1_MONTH_LAST, BAR_SPEC_1_SECOND_LAST, BAR_SPEC_1_WEEK_LAST,
32            BAR_SPEC_2_DAY_LAST, BAR_SPEC_2_HOUR_LAST, BAR_SPEC_3_DAY_LAST, BAR_SPEC_3_MINUTE_LAST,
33            BAR_SPEC_3_MONTH_LAST, BAR_SPEC_4_HOUR_LAST, BAR_SPEC_5_DAY_LAST,
34            BAR_SPEC_5_MINUTE_LAST, BAR_SPEC_6_HOUR_LAST, BAR_SPEC_6_MONTH_LAST,
35            BAR_SPEC_12_HOUR_LAST, BAR_SPEC_12_MONTH_LAST, BAR_SPEC_15_MINUTE_LAST,
36            BAR_SPEC_30_MINUTE_LAST,
37        },
38    },
39    enums::{
40        AccountType, AggregationSource, AggressorSide, LiquiditySide, OptionKind, OrderSide,
41        OrderStatus, OrderType, PositionSide, TimeInForce,
42    },
43    events::AccountState,
44    identifiers::{
45        AccountId, ClientOrderId, InstrumentId, PositionId, Symbol, TradeId, Venue, VenueOrderId,
46    },
47    instruments::{CryptoFuture, CryptoOption, CryptoPerpetual, CurrencyPair, InstrumentAny},
48    reports::{FillReport, OrderStatusReport, PositionStatusReport},
49    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
50};
51use rust_decimal::{Decimal, prelude::ToPrimitive};
52use serde::{Deserialize, Deserializer, de::DeserializeOwned};
53use ustr::Ustr;
54
55use super::enums::OKXContractType;
56use crate::{
57    common::{
58        consts::OKX_VENUE,
59        enums::{
60            OKXExecType, OKXInstrumentType, OKXOrderStatus, OKXOrderType, OKXPositionSide, OKXSide,
61            OKXTargetCurrency, OKXVipLevel,
62        },
63        models::OKXInstrument,
64    },
65    http::models::{
66        OKXAccount, OKXBalanceDetail, OKXCandlestick, OKXIndexTicker, OKXMarkPrice,
67        OKXOrderHistory, OKXPosition, OKXTrade, OKXTransactionDetail,
68    },
69    websocket::{enums::OKXWsChannel, messages::OKXFundingRateMsg},
70};
71
72/// Determines if a price string represents a market order.
73///
74/// OKX uses special values to indicate market execution:
75/// - Empty string
76/// - "0"
77/// - "-1" (optimal market price)
78/// - "-2" (optimal market price, alternate)
79pub fn is_market_price(px: &str) -> bool {
80    px.is_empty() || px == "0" || px == "-1" || px == "-2"
81}
82
83/// Determines the [`OrderType`] from OKX order type and price.
84///
85/// For FOK, IOC, and OptimalLimitIoc orders, the presence of a price
86/// determines whether it's a market or limit order execution.
87pub fn determine_order_type(okx_ord_type: OKXOrderType, px: &str) -> OrderType {
88    match okx_ord_type {
89        OKXOrderType::Fok | OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => {
90            if is_market_price(px) {
91                OrderType::Market
92            } else {
93                OrderType::Limit
94            }
95        }
96        _ => okx_ord_type.into(),
97    }
98}
99
100/// Deserializes an empty string into [`None`].
101///
102/// OKX frequently represents *null* string fields as an empty string (`""`).
103/// When such a payload is mapped onto `Option<String>` the default behaviour
104/// would yield `Some("")`, which is semantically different from the intended
105/// absence of a value. This helper ensures that empty strings are normalised
106/// to `None` during deserialization.
107///
108/// # Errors
109///
110/// Returns an error if the JSON value cannot be deserialised into a string.
111pub fn deserialize_empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
112where
113    D: Deserializer<'de>,
114{
115    let opt = Option::<String>::deserialize(deserializer)?;
116    Ok(opt.filter(|s| !s.is_empty()))
117}
118
119/// Deserializes an empty [`Ustr`] into [`None`].
120///
121/// # Errors
122///
123/// Returns an error if the JSON value cannot be deserialised into a string.
124pub fn deserialize_empty_ustr_as_none<'de, D>(deserializer: D) -> Result<Option<Ustr>, D::Error>
125where
126    D: Deserializer<'de>,
127{
128    let opt = Option::<Ustr>::deserialize(deserializer)?;
129    Ok(opt.filter(|s| !s.is_empty()))
130}
131
132/// Deserializes a string into `Option<OKXTargetCurrency>`, treating empty strings as `None`.
133///
134/// # Errors
135///
136/// Returns an error if the string cannot be parsed into an `OKXTargetCurrency`.
137pub fn deserialize_target_currency_as_none<'de, D>(
138    deserializer: D,
139) -> Result<Option<OKXTargetCurrency>, D::Error>
140where
141    D: Deserializer<'de>,
142{
143    let s = String::deserialize(deserializer)?;
144    if s.is_empty() {
145        Ok(None)
146    } else {
147        s.parse().map(Some).map_err(serde::de::Error::custom)
148    }
149}
150
151/// Deserializes a numeric string into a `u64`.
152///
153/// # Errors
154///
155/// Returns an error if the string cannot be parsed into a `u64`.
156pub fn deserialize_string_to_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
157where
158    D: Deserializer<'de>,
159{
160    let s = String::deserialize(deserializer)?;
161    if s.is_empty() {
162        Ok(0)
163    } else {
164        s.parse::<u64>().map_err(serde::de::Error::custom)
165    }
166}
167
168/// Deserializes an optional numeric string into `Option<u64>`.
169///
170/// # Errors
171///
172/// Returns an error under the same cases as [`deserialize_string_to_u64`].
173pub fn deserialize_optional_string_to_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
174where
175    D: Deserializer<'de>,
176{
177    let s: Option<String> = Option::deserialize(deserializer)?;
178    match s {
179        Some(s) if s.is_empty() => Ok(None),
180        Some(s) => s.parse().map(Some).map_err(serde::de::Error::custom),
181        None => Ok(None),
182    }
183}
184
185/// Deserializes an OKX VIP level string into [`OKXVipLevel`].
186///
187/// OKX returns VIP levels in multiple formats:
188/// - "VIP0", "VIP1", ..., "VIP9" (VIP tier format)
189/// - "Lv0", "Lv1", ..., "Lv9" (Level format)
190/// - "0", "1", ..., "9" (bare numeric)
191/// - "" (empty string, defaults to VIP0)
192///
193/// This function handles all formats by stripping any prefix and parsing the numeric value.
194///
195/// # Errors
196///
197/// Returns an error if the string cannot be parsed into a valid VIP level.
198pub fn deserialize_vip_level<'de, D>(deserializer: D) -> Result<OKXVipLevel, D::Error>
199where
200    D: Deserializer<'de>,
201{
202    let s = String::deserialize(deserializer)?;
203
204    if s.is_empty() {
205        return Ok(OKXVipLevel::Vip0);
206    }
207
208    let level_str = if s.len() >= 3 && s[..3].eq_ignore_ascii_case("vip") {
209        &s[3..]
210    } else if s.len() >= 2 && s[..2].eq_ignore_ascii_case("lv") {
211        &s[2..]
212    } else {
213        &s
214    };
215
216    let level_num = level_str
217        .parse::<u8>()
218        .map_err(|e| serde::de::Error::custom(format!("Invalid VIP level '{s}': {e}")))?;
219
220    Ok(OKXVipLevel::from(level_num))
221}
222
223/// Returns the [`OKXInstrumentType`] that corresponds to the supplied
224/// [`InstrumentAny`].
225///
226/// # Errors
227///
228/// Returns an error if the instrument variant is not supported by OKX.
229pub fn okx_instrument_type(instrument: &InstrumentAny) -> anyhow::Result<OKXInstrumentType> {
230    match instrument {
231        InstrumentAny::CurrencyPair(_) => Ok(OKXInstrumentType::Spot),
232        InstrumentAny::CryptoPerpetual(_) => Ok(OKXInstrumentType::Swap),
233        InstrumentAny::CryptoFuture(_) => Ok(OKXInstrumentType::Futures),
234        InstrumentAny::CryptoOption(_) => Ok(OKXInstrumentType::Option),
235        _ => anyhow::bail!("Invalid instrument type for OKX: {instrument:?}"),
236    }
237}
238
239/// Parses `OKXInstrumentType` from an instrument symbol.
240///
241/// OKX instrument symbol formats:
242/// - SPOT: {BASE}-{QUOTE} (e.g., BTC-USDT)
243/// - MARGIN: {BASE}-{QUOTE} (same as SPOT, determined by trade mode)
244/// - SWAP: {BASE}-{QUOTE}-SWAP (e.g., BTC-USDT-SWAP)
245/// - FUTURES: {BASE}-{QUOTE}-{YYMMDD} (e.g., BTC-USDT-250328)
246/// - OPTION: {BASE}-{QUOTE}-{YYMMDD}-{STRIKE}-{C/P} (e.g., BTC-USD-250328-50000-C)
247pub fn okx_instrument_type_from_symbol(symbol: &str) -> OKXInstrumentType {
248    // Count dashes to determine part count
249    let dash_count = symbol.bytes().filter(|&b| b == b'-').count();
250
251    match dash_count {
252        1 => OKXInstrumentType::Spot, // 2 parts: BASE-QUOTE
253        2 => {
254            // 3 parts: Check suffix after last dash
255            let suffix = symbol.rsplit('-').next().unwrap_or("");
256            if suffix == "SWAP" {
257                OKXInstrumentType::Swap
258            } else if suffix.len() == 6 && suffix.bytes().all(|b| b.is_ascii_digit()) {
259                // Date format YYMMDD
260                OKXInstrumentType::Futures
261            } else {
262                OKXInstrumentType::Spot
263            }
264        }
265        4 => OKXInstrumentType::Option, // 5 parts: BASE-QUOTE-DATE-STRIKE-C/P
266        _ => OKXInstrumentType::Spot,   // Default fallback
267    }
268}
269
270/// Extracts base and quote currencies from an OKX symbol.
271///
272/// All OKX instrument symbols start with {BASE}-{QUOTE}, regardless of type.
273///
274/// # Errors
275///
276/// Returns an error if the symbol doesn't contain at least two parts separated by '-'.
277pub fn parse_base_quote_from_symbol(symbol: &str) -> anyhow::Result<(&str, &str)> {
278    let mut parts = symbol.split('-');
279    let base = parts.next().ok_or_else(|| {
280        anyhow::anyhow!("Invalid symbol format: missing base currency in '{symbol}'")
281    })?;
282    let quote = parts.next().ok_or_else(|| {
283        anyhow::anyhow!("Invalid symbol format: missing quote currency in '{symbol}'")
284    })?;
285    Ok((base, quote))
286}
287
288/// Parses a Nautilus instrument ID from the given OKX `symbol` value.
289#[must_use]
290pub fn parse_instrument_id(symbol: Ustr) -> InstrumentId {
291    InstrumentId::new(Symbol::from_ustr_unchecked(symbol), *OKX_VENUE)
292}
293
294/// Parses a Nautilus client order ID from the given OKX `clOrdId` value.
295#[must_use]
296pub fn parse_client_order_id(value: &str) -> Option<ClientOrderId> {
297    if value.is_empty() {
298        None
299    } else {
300        Some(ClientOrderId::new(value))
301    }
302}
303
304/// Converts a millisecond-based timestamp (as returned by OKX) into
305/// [`UnixNanos`].
306#[must_use]
307pub fn parse_millisecond_timestamp(timestamp_ms: u64) -> UnixNanos {
308    UnixNanos::from(timestamp_ms * NANOSECONDS_IN_MILLISECOND)
309}
310
311/// Parses an RFC 3339 timestamp string into [`UnixNanos`].
312///
313/// # Errors
314///
315/// Returns an error if the string is not a valid RFC 3339 datetime or if the
316/// timestamp cannot be represented in nanoseconds.
317pub fn parse_rfc3339_timestamp(timestamp: &str) -> anyhow::Result<UnixNanos> {
318    let dt = chrono::DateTime::parse_from_rfc3339(timestamp)?;
319    let nanos = dt.timestamp_nanos_opt().ok_or_else(|| {
320        anyhow::anyhow!("Failed to extract nanoseconds from timestamp: {timestamp}")
321    })?;
322    Ok(UnixNanos::from(nanos as u64))
323}
324
325/// Converts a textual price to a [`Price`] using the given precision.
326///
327/// # Errors
328///
329/// Returns an error if the string fails to parse into `Decimal` or if the number
330/// of decimal places exceeds `precision`.
331pub fn parse_price(value: &str, precision: u8) -> anyhow::Result<Price> {
332    let decimal = Decimal::from_str(value)?;
333    Price::from_decimal_dp(decimal, precision)
334}
335
336/// Converts a textual quantity to a [`Quantity`].
337///
338/// # Errors
339///
340/// Returns an error for the same reasons as [`parse_price`] – parsing failure or invalid
341/// precision.
342pub fn parse_quantity(value: &str, precision: u8) -> anyhow::Result<Quantity> {
343    let decimal = Decimal::from_str(value)?;
344    Quantity::from_decimal_dp(decimal, precision)
345}
346
347/// Converts a textual fee amount into a [`Money`] value.
348///
349/// OKX represents *charges* as positive numbers but they reduce the account
350/// balance, hence the value is negated.
351///
352/// # Errors
353///
354/// Returns an error if the fee cannot be parsed into `Decimal` or fails internal
355/// validation in [`Money::from_decimal`].
356pub fn parse_fee(value: Option<&str>, currency: Currency) -> anyhow::Result<Money> {
357    // OKX report positive fees with negative signs (i.e., fee charged)
358    let decimal = Decimal::from_str(value.unwrap_or("0"))?;
359    Money::from_decimal(-decimal, currency)
360}
361
362/// Parses OKX fee currency code, handling empty strings.
363///
364/// OKX sometimes returns empty fee currency codes.
365/// When the fee currency is empty, defaults to USDT and logs a warning for non-zero fees.
366pub fn parse_fee_currency(
367    fee_ccy: &str,
368    fee_amount: Decimal,
369    context: impl FnOnce() -> String,
370) -> Currency {
371    let trimmed = fee_ccy.trim();
372    if trimmed.is_empty() {
373        if !fee_amount.is_zero() {
374            let ctx = context();
375            tracing::warn!(
376                "Empty fee_ccy in {ctx} with non-zero fee={fee_amount}, using USDT as fallback"
377            );
378        }
379        return Currency::USDT();
380    }
381
382    Currency::get_or_create_crypto_with_context(trimmed, Some(&context()))
383}
384
385/// Parses OKX side to Nautilus aggressor side.
386pub fn parse_aggressor_side(side: &Option<OKXSide>) -> AggressorSide {
387    match side {
388        Some(OKXSide::Buy) => AggressorSide::Buyer,
389        Some(OKXSide::Sell) => AggressorSide::Seller,
390        None => AggressorSide::NoAggressor,
391    }
392}
393
394/// Parses OKX execution type to Nautilus liquidity side.
395pub fn parse_execution_type(liquidity: &Option<OKXExecType>) -> LiquiditySide {
396    match liquidity {
397        Some(OKXExecType::Maker) => LiquiditySide::Maker,
398        Some(OKXExecType::Taker) => LiquiditySide::Taker,
399        _ => LiquiditySide::NoLiquiditySide,
400    }
401}
402
403/// Parses quantity to Nautilus position side.
404pub fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
405    match current_qty {
406        Some(qty) if qty > 0 => PositionSide::Long,
407        Some(qty) if qty < 0 => PositionSide::Short,
408        _ => PositionSide::Flat,
409    }
410}
411
412/// Parses an OKX mark price record into a Nautilus [`MarkPriceUpdate`].
413///
414/// # Errors
415///
416/// Returns an error if `raw.mark_px` cannot be parsed into a [`Price`] with
417/// the specified precision.
418pub fn parse_mark_price_update(
419    raw: &OKXMarkPrice,
420    instrument_id: InstrumentId,
421    price_precision: u8,
422    ts_init: UnixNanos,
423) -> anyhow::Result<MarkPriceUpdate> {
424    let ts_event = parse_millisecond_timestamp(raw.ts);
425    let price = parse_price(&raw.mark_px, price_precision)?;
426    Ok(MarkPriceUpdate::new(
427        instrument_id,
428        price,
429        ts_event,
430        ts_init,
431    ))
432}
433
434/// Parses an OKX index ticker record into a Nautilus [`IndexPriceUpdate`].
435///
436/// # Errors
437///
438/// Returns an error if `raw.idx_px` cannot be parsed into a [`Price`] with the
439/// specified precision.
440pub fn parse_index_price_update(
441    raw: &OKXIndexTicker,
442    instrument_id: InstrumentId,
443    price_precision: u8,
444    ts_init: UnixNanos,
445) -> anyhow::Result<IndexPriceUpdate> {
446    let ts_event = parse_millisecond_timestamp(raw.ts);
447    let price = parse_price(&raw.idx_px, price_precision)?;
448    Ok(IndexPriceUpdate::new(
449        instrument_id,
450        price,
451        ts_event,
452        ts_init,
453    ))
454}
455
456/// Parses an [`OKXFundingRateMsg`] into a [`FundingRateUpdate`].
457///
458/// # Errors
459///
460/// Returns an error if the `funding_rate` or `next_funding_rate` fields fail
461/// to parse into Decimal values.
462pub fn parse_funding_rate_msg(
463    msg: &OKXFundingRateMsg,
464    instrument_id: InstrumentId,
465    ts_init: UnixNanos,
466) -> anyhow::Result<FundingRateUpdate> {
467    let funding_rate = msg
468        .funding_rate
469        .as_str()
470        .parse::<Decimal>()
471        .map_err(|e| anyhow::anyhow!("Invalid funding_rate value: {e}"))?
472        .normalize();
473
474    let funding_time = Some(parse_millisecond_timestamp(msg.funding_time));
475    let ts_event = parse_millisecond_timestamp(msg.ts);
476
477    Ok(FundingRateUpdate::new(
478        instrument_id,
479        funding_rate,
480        funding_time,
481        ts_event,
482        ts_init,
483    ))
484}
485
486/// Parses an OKX trade record into a Nautilus [`TradeTick`].
487///
488/// # Errors
489///
490/// Returns an error if the price or quantity strings cannot be parsed, or if
491/// [`TradeTick::new_checked`] validation fails.
492pub fn parse_trade_tick(
493    raw: &OKXTrade,
494    instrument_id: InstrumentId,
495    price_precision: u8,
496    size_precision: u8,
497    ts_init: UnixNanos,
498) -> anyhow::Result<TradeTick> {
499    let ts_event = parse_millisecond_timestamp(raw.ts);
500    let price = parse_price(&raw.px, price_precision)?;
501    let size = parse_quantity(&raw.sz, size_precision)?;
502    let aggressor: AggressorSide = raw.side.into();
503    let trade_id = TradeId::new(raw.trade_id);
504
505    TradeTick::new_checked(
506        instrument_id,
507        price,
508        size,
509        aggressor,
510        trade_id,
511        ts_event,
512        ts_init,
513    )
514}
515
516/// Parses an OKX historical candlestick record into a Nautilus [`Bar`].
517///
518/// # Errors
519///
520/// Returns an error if any of the price or volume strings cannot be parsed or
521/// if [`Bar::new`] validation fails.
522pub fn parse_candlestick(
523    raw: &OKXCandlestick,
524    bar_type: BarType,
525    price_precision: u8,
526    size_precision: u8,
527    ts_init: UnixNanos,
528) -> anyhow::Result<Bar> {
529    let ts_event = parse_millisecond_timestamp(raw.0.parse()?);
530    let open = parse_price(&raw.1, price_precision)?;
531    let high = parse_price(&raw.2, price_precision)?;
532    let low = parse_price(&raw.3, price_precision)?;
533    let close = parse_price(&raw.4, price_precision)?;
534    let volume = parse_quantity(&raw.5, size_precision)?;
535
536    Ok(Bar::new(
537        bar_type, open, high, low, close, volume, ts_event, ts_init,
538    ))
539}
540
541/// Parses an OKX order history record into a Nautilus [`OrderStatusReport`].
542///
543/// # Errors
544///
545/// Returns an error if the average price cannot be converted to a valid `Decimal`.
546#[allow(clippy::too_many_lines)]
547pub fn parse_order_status_report(
548    order: &OKXOrderHistory,
549    account_id: AccountId,
550    instrument_id: InstrumentId,
551    price_precision: u8,
552    size_precision: u8,
553    ts_init: UnixNanos,
554) -> anyhow::Result<OrderStatusReport> {
555    let okx_ord_type: OKXOrderType = order.ord_type;
556    let order_type = determine_order_type(okx_ord_type, &order.px);
557
558    // Parse quantities based on target currency
559    // OKX always returns acc_fill_sz in base currency, but sz depends on tgt_ccy
560
561    // Determine if this is a quote-quantity order
562    // Method 1: Explicit tgt_ccy field set to QuoteCcy
563    let is_quote_qty_explicit = order.tgt_ccy == Some(OKXTargetCurrency::QuoteCcy);
564
565    // Method 2: Use OKX defaults when tgt_ccy is None (old orders or missing field)
566    // OKX API defaults for SPOT market orders: BUY orders use quote_ccy, SELL orders use base_ccy
567    // Note: tgtCcy only applies to SPOT market orders (not limit orders)
568    // For limit orders, sz is always in base currency regardless of side
569    let is_quote_qty_heuristic = order.tgt_ccy.is_none()
570        && (order.inst_type == OKXInstrumentType::Spot
571            || order.inst_type == OKXInstrumentType::Margin)
572        && order.side == OKXSide::Buy
573        && order_type == OrderType::Market;
574
575    let (quantity, filled_qty) = if is_quote_qty_explicit || is_quote_qty_heuristic {
576        // Quote-quantity order: sz is in quote currency, need to convert to base
577        let sz_quote_dec = Decimal::from_str(&order.sz).ok();
578
579        // Determine the price to use for conversion
580        // Priority: 1) limit price (px) for limit orders, 2) avg_px for market orders
581        let conversion_price_dec = if !order.px.is_empty() && order.px != "0" {
582            // Limit order: use the limit price (order.px)
583            Decimal::from_str(&order.px).ok()
584        } else if !order.avg_px.is_empty() && order.avg_px != "0" {
585            // Market order with fills: use average fill price
586            Decimal::from_str(&order.avg_px).ok()
587        } else {
588            log::warn!(
589                "No price available for conversion: ord_id={}, px='{}', avg_px='{}'",
590                order.ord_id.as_str(),
591                order.px,
592                order.avg_px
593            );
594            None
595        };
596
597        // Convert quote quantity to base: quantity_base = sz_quote / price
598        let quantity_base = if let (Some(sz), Some(price)) = (sz_quote_dec, conversion_price_dec) {
599            if !price.is_zero() {
600                let quantity_dec = sz / price;
601                Quantity::from_decimal_dp(quantity_dec, size_precision).map_err(|e| {
602                    anyhow::anyhow!(
603                        "Failed to convert quote-to-base quantity for ord_id={}, sz={sz}, price={price}, quantity_dec={quantity_dec}: {e}",
604                        order.ord_id.as_str()
605                    )
606                })?
607            } else {
608                log::warn!(
609                    "Cannot convert quote quantity with zero price: ord_id={}, sz={}, using sz as-is",
610                    order.ord_id.as_str(),
611                    order.sz
612                );
613                Quantity::from_str(&order.sz).map_err(|e| {
614                    anyhow::anyhow!(
615                        "Failed to parse fallback quantity for ord_id={}, sz='{}': {e}",
616                        order.ord_id.as_str(),
617                        order.sz
618                    )
619                })?
620            }
621        } else {
622            log::warn!(
623                "Cannot convert quote quantity without price: ord_id={}, sz={}, px='{}', avg_px='{}', using sz as-is",
624                order.ord_id.as_str(),
625                order.sz,
626                order.px,
627                order.avg_px
628            );
629            Quantity::from_str(&order.sz).map_err(|e| {
630                anyhow::anyhow!(
631                    "Failed to parse fallback quantity for ord_id={}, sz='{}': {e}",
632                    order.ord_id.as_str(),
633                    order.sz
634                )
635            })?
636        };
637
638        let filled_qty_dec = parse_quantity(&order.acc_fill_sz, size_precision).map_err(|e| {
639            anyhow::anyhow!(
640                "Failed to parse filled quantity for ord_id={}, acc_fill_sz='{}': {e}",
641                order.ord_id.as_str(),
642                order.acc_fill_sz
643            )
644        })?;
645
646        (quantity_base, filled_qty_dec)
647    } else {
648        // Base-quantity order: both sz and acc_fill_sz are in base currency
649        let quantity_dec = parse_quantity(&order.sz, size_precision).map_err(|e| {
650            anyhow::anyhow!(
651                "Failed to parse base quantity for ord_id={}, sz='{}': {e}",
652                order.ord_id.as_str(),
653                order.sz
654            )
655        })?;
656        let filled_qty_dec = parse_quantity(&order.acc_fill_sz, size_precision).map_err(|e| {
657            anyhow::anyhow!(
658                "Failed to parse filled quantity for ord_id={}, acc_fill_sz='{}': {e}",
659                order.ord_id.as_str(),
660                order.acc_fill_sz
661            )
662        })?;
663
664        (quantity_dec, filled_qty_dec)
665    };
666
667    // For quote-quantity orders marked as FILLED, adjust quantity to match filled_qty
668    // to avoid precision mismatches from quote-to-base conversion
669    let (quantity, filled_qty) = if (is_quote_qty_explicit || is_quote_qty_heuristic)
670        && order.state == OKXOrderStatus::Filled
671        && filled_qty.is_positive()
672    {
673        (filled_qty, filled_qty)
674    } else {
675        (quantity, filled_qty)
676    };
677
678    let order_side: OrderSide = order.side.into();
679    let okx_status: OKXOrderStatus = order.state;
680    let order_status: OrderStatus = okx_status.into();
681    let time_in_force = match okx_ord_type {
682        OKXOrderType::Fok => TimeInForce::Fok,
683        OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
684        _ => TimeInForce::Gtc,
685    };
686
687    let mut client_order_id = if order.cl_ord_id.is_empty() {
688        None
689    } else {
690        Some(ClientOrderId::new(order.cl_ord_id.as_str()))
691    };
692
693    let mut linked_ids = Vec::new();
694
695    if let Some(algo_cl_ord_id) = order
696        .algo_cl_ord_id
697        .as_ref()
698        .filter(|value| !value.as_str().is_empty())
699    {
700        let algo_client_id = ClientOrderId::new(algo_cl_ord_id.as_str());
701        match &client_order_id {
702            Some(existing) if existing == &algo_client_id => {}
703            Some(_) => linked_ids.push(algo_client_id),
704            None => client_order_id = Some(algo_client_id),
705        }
706    }
707
708    let venue_order_id = if order.ord_id.is_empty() {
709        if let Some(algo_id) = order
710            .algo_id
711            .as_ref()
712            .filter(|value| !value.as_str().is_empty())
713        {
714            VenueOrderId::new(algo_id.as_str())
715        } else if !order.cl_ord_id.is_empty() {
716            VenueOrderId::new(order.cl_ord_id.as_str())
717        } else {
718            let synthetic_id = format!("{}:{}", account_id, order.c_time);
719            VenueOrderId::new(&synthetic_id)
720        }
721    } else {
722        VenueOrderId::new(order.ord_id.as_str())
723    };
724
725    let ts_accepted = parse_millisecond_timestamp(order.c_time);
726    let ts_last = UnixNanos::from(order.u_time * NANOSECONDS_IN_MILLISECOND);
727
728    let mut report = OrderStatusReport::new(
729        account_id,
730        instrument_id,
731        client_order_id,
732        venue_order_id,
733        order_side,
734        order_type,
735        time_in_force,
736        order_status,
737        quantity,
738        filled_qty,
739        ts_accepted,
740        ts_last,
741        ts_init,
742        None,
743    );
744
745    // Optional fields
746    if !order.px.is_empty()
747        && let Ok(decimal) = Decimal::from_str(&order.px)
748        && let Ok(price) = Price::from_decimal_dp(decimal, price_precision)
749    {
750        report = report.with_price(price);
751    }
752
753    if !order.avg_px.is_empty()
754        && let Ok(decimal) = Decimal::from_str(&order.avg_px)
755    {
756        report = report.with_avg_px(decimal.to_f64().unwrap_or(0.0))?;
757    }
758
759    if order.ord_type == OKXOrderType::PostOnly {
760        report = report.with_post_only(true);
761    }
762
763    if order.reduce_only == "true" {
764        report = report.with_reduce_only(true);
765    }
766
767    if !linked_ids.is_empty() {
768        report = report.with_linked_order_ids(linked_ids);
769    }
770
771    Ok(report)
772}
773
774/// Parses spot margin position from OKX balance detail.
775///
776/// Spot margin positions appear in `/api/v5/account/balance` as balance sheet items
777/// rather than in `/api/v5/account/positions`. This function converts balance details
778/// with non-zero liability (`liab`) or spot in use amount (`spotInUseAmt`) into position reports.
779///
780/// # Position Determination
781///
782/// - `liab` > 0 and `spotInUseAmt` < 0 → Short position (borrowed and sold)
783/// - `liab` > 0 and `spotInUseAmt` > 0 → Long position (borrowed to buy)
784/// - `liab` == 0 → No margin position (regular spot balance)
785///
786/// # Errors
787///
788/// Returns an error if numeric fields cannot be parsed.
789pub fn parse_spot_margin_position_from_balance(
790    balance: &OKXBalanceDetail,
791    account_id: AccountId,
792    instrument_id: InstrumentId,
793    size_precision: u8,
794    ts_init: UnixNanos,
795) -> anyhow::Result<Option<PositionStatusReport>> {
796    // OKX returns empty strings for zero values, normalize to "0" before parsing
797    let liab_str = if balance.liab.trim().is_empty() {
798        "0"
799    } else {
800        balance.liab.trim()
801    };
802    let spot_in_use_str = if balance.spot_in_use_amt.trim().is_empty() {
803        "0"
804    } else {
805        balance.spot_in_use_amt.trim()
806    };
807
808    let liab_dec = Decimal::from_str(liab_str)
809        .map_err(|e| anyhow::anyhow!("Failed to parse liab '{liab_str}': {e}"))?;
810    let spot_in_use_dec = Decimal::from_str(spot_in_use_str)
811        .map_err(|e| anyhow::anyhow!("Failed to parse spotInUseAmt '{spot_in_use_str}': {e}"))?;
812
813    // Skip if no margin position (no liability and no spot in use)
814    if liab_dec.is_zero() && spot_in_use_dec.is_zero() {
815        return Ok(None);
816    }
817
818    // Check if spotInUseAmt is zero first
819    if spot_in_use_dec.is_zero() {
820        // No position if spotInUseAmt is zero (regardless of liability)
821        return Ok(None);
822    }
823
824    // Position side based on spotInUseAmt sign
825    let (position_side, quantity_dec) = if spot_in_use_dec.is_sign_negative() {
826        // Negative spotInUseAmt = sold (short position)
827        (PositionSide::Short, spot_in_use_dec.abs())
828    } else {
829        // Positive spotInUseAmt = bought (long position)
830        (PositionSide::Long, spot_in_use_dec)
831    };
832
833    let quantity = Quantity::from_decimal_dp(quantity_dec, size_precision)
834        .map_err(|e| anyhow::anyhow!("Failed to create quantity from {quantity_dec}: {e}"))?;
835
836    let ts_last = parse_millisecond_timestamp(balance.u_time);
837
838    Ok(Some(PositionStatusReport::new(
839        account_id,
840        instrument_id,
841        position_side.as_specified(),
842        quantity,
843        ts_last,
844        ts_init,
845        None, // report_id
846        None, // venue_position_id is None for net mode margin positions
847        None, // avg_px_open not available from balance
848    )))
849}
850
851/// Parses an OKX position into a Nautilus [`PositionStatusReport`].
852///
853/// # Position Mode Handling
854///
855/// OKX returns position data differently based on the account's position mode:
856///
857/// - **Net mode** (`posSide="net"`): The `pos` field uses signed quantities where
858///   positive = long, negative = short. Position side is derived from the sign.
859///
860/// - **Long/Short mode** (`posSide="long"` or `"short"`): The `pos` field is always
861///   positive regardless of side. Position side is determined from the `posSide` field.
862///   Position IDs are suffixed with `-LONG` or `-SHORT` for uniqueness.
863///
864/// See: <https://www.okx.com/docs-v5/en/#trading-account-rest-api-get-positions>
865///
866/// # Errors
867///
868/// Returns an error if any numeric fields cannot be parsed into their target types.
869#[allow(clippy::too_many_lines)]
870pub fn parse_position_status_report(
871    position: OKXPosition,
872    account_id: AccountId,
873    instrument_id: InstrumentId,
874    size_precision: u8,
875    ts_init: UnixNanos,
876) -> anyhow::Result<PositionStatusReport> {
877    let pos_dec = Decimal::from_str(&position.pos).map_err(|e| {
878        anyhow::anyhow!(
879            "Failed to parse position quantity '{}' for instrument {}: {e:?}",
880            position.pos,
881            instrument_id
882        )
883    })?;
884
885    // For SPOT/MARGIN: determine position side and quantity based on pos_ccy
886    // - If pos_ccy = base currency: LONG position, pos is in base currency
887    // - If pos_ccy = quote currency: SHORT position, pos is in quote currency (needs conversion)
888    // - If pos_ccy is empty: FLAT position (no position)
889    let (position_side, quantity_dec) = if position.inst_type == OKXInstrumentType::Spot
890        || position.inst_type == OKXInstrumentType::Margin
891    {
892        // Extract base and quote currencies from instrument symbol
893        let (base_ccy, quote_ccy) = parse_base_quote_from_symbol(instrument_id.symbol.as_str())?;
894
895        let pos_ccy = position.pos_ccy.as_str();
896
897        if pos_ccy.is_empty() || pos_dec.is_zero() {
898            // Flat position: no position or zero quantity
899            (PositionSide::Flat, Decimal::ZERO)
900        } else if pos_ccy == base_ccy {
901            // Long position: pos_ccy is base currency, pos is already in base
902            (PositionSide::Long, pos_dec.abs())
903        } else if pos_ccy == quote_ccy {
904            // Short position: pos_ccy is quote currency, need to convert to base
905            // Use Decimal arithmetic to avoid floating-point precision errors
906            let avg_px_str = if !position.avg_px.is_empty() {
907                &position.avg_px
908            } else {
909                // If no avg_px, use mark_px as fallback
910                &position.mark_px
911            };
912            let avg_px_dec = Decimal::from_str(avg_px_str)?;
913
914            if avg_px_dec.is_zero() {
915                anyhow::bail!(
916                    "Cannot convert SHORT position from quote to base: avg_px is zero for {instrument_id}"
917                );
918            }
919
920            let quantity_dec = pos_dec.abs() / avg_px_dec;
921            (PositionSide::Short, quantity_dec)
922        } else {
923            anyhow::bail!(
924                "Unknown position currency '{pos_ccy}' for instrument {instrument_id} (base={base_ccy}, quote={quote_ccy})"
925            );
926        }
927    } else {
928        // For SWAP/FUTURES/OPTION: use existing logic
929        // Determine position side based on OKX position mode:
930        // - Net mode: posSide="net", uses signed quantities (positive=long, negative=short)
931        // - Long/Short mode: posSide="long"/"short", quantities are always positive, side from field
932        let side = match position.pos_side {
933            OKXPositionSide::Net | OKXPositionSide::None => {
934                // Net mode: derive side from signed quantity
935                if pos_dec.is_sign_positive() && !pos_dec.is_zero() {
936                    PositionSide::Long
937                } else if pos_dec.is_sign_negative() {
938                    PositionSide::Short
939                } else {
940                    PositionSide::Flat
941                }
942            }
943            OKXPositionSide::Long => {
944                // Long/Short mode: trust the pos_side field
945                PositionSide::Long
946            }
947            OKXPositionSide::Short => {
948                // Long/Short mode: trust the pos_side field
949                PositionSide::Short
950            }
951        };
952        (side, pos_dec.abs())
953    };
954
955    let position_side = position_side.as_specified();
956
957    // Convert to absolute quantity (positions are always positive in Nautilus)
958    let quantity = Quantity::from_decimal_dp(quantity_dec, size_precision)?;
959
960    // Generate venue position ID only for Long/Short mode (hedging)
961    // In Net mode, venue_position_id must be None to signal NETTING OMS behavior
962    let venue_position_id = match position.pos_side {
963        OKXPositionSide::Long => {
964            // Long/Short mode - Long leg: append "-LONG"
965            position
966                .pos_id
967                .map(|pos_id| PositionId::new(format!("{pos_id}-LONG")))
968        }
969        OKXPositionSide::Short => {
970            // Long/Short mode - Short leg: append "-SHORT"
971            position
972                .pos_id
973                .map(|pos_id| PositionId::new(format!("{pos_id}-SHORT")))
974        }
975        OKXPositionSide::Net | OKXPositionSide::None => {
976            // Net mode: None signals NETTING OMS (Nautilus uses its own position IDs)
977            None
978        }
979    };
980
981    let avg_px_open = if position.avg_px.is_empty() {
982        None
983    } else {
984        Some(Decimal::from_str(&position.avg_px)?)
985    };
986    let ts_last = parse_millisecond_timestamp(position.u_time);
987
988    Ok(PositionStatusReport::new(
989        account_id,
990        instrument_id,
991        position_side,
992        quantity,
993        ts_last,
994        ts_init,
995        None, // Will generate a UUID4
996        venue_position_id,
997        avg_px_open,
998    ))
999}
1000
1001/// Parses an OKX transaction detail into a Nautilus `FillReport`.
1002///
1003/// # Errors
1004///
1005/// Returns an error if the OKX transaction detail cannot be parsed.
1006pub fn parse_fill_report(
1007    detail: OKXTransactionDetail,
1008    account_id: AccountId,
1009    instrument_id: InstrumentId,
1010    price_precision: u8,
1011    size_precision: u8,
1012    ts_init: UnixNanos,
1013) -> anyhow::Result<FillReport> {
1014    let client_order_id = if detail.cl_ord_id.is_empty() {
1015        None
1016    } else {
1017        Some(ClientOrderId::new(detail.cl_ord_id))
1018    };
1019    let venue_order_id = VenueOrderId::new(detail.ord_id);
1020    let trade_id = TradeId::new(detail.trade_id);
1021    let order_side: OrderSide = detail.side.into();
1022    let last_px = parse_price(&detail.fill_px, price_precision)?;
1023    let last_qty = parse_quantity(&detail.fill_sz, size_precision)?;
1024    let fee_dec = Decimal::from_str(detail.fee.as_deref().unwrap_or("0"))?;
1025    let fee_currency = parse_fee_currency(&detail.fee_ccy, fee_dec, || {
1026        format!("fill report for instrument_id={instrument_id}")
1027    });
1028    let commission = Money::from_decimal(-fee_dec, fee_currency)?;
1029    let liquidity_side: LiquiditySide = detail.exec_type.into();
1030    let ts_event = parse_millisecond_timestamp(detail.ts);
1031
1032    Ok(FillReport::new(
1033        account_id,
1034        instrument_id,
1035        venue_order_id,
1036        trade_id,
1037        order_side,
1038        last_qty,
1039        last_px,
1040        commission,
1041        liquidity_side,
1042        client_order_id,
1043        None, // venue_position_id not provided by OKX fills
1044        ts_event,
1045        ts_init,
1046        None, // Will generate a new UUID4
1047    ))
1048}
1049
1050/// Parses vector messages from OKX WebSocket data.
1051///
1052/// Reduces code duplication by providing a common pattern for deserializing JSON arrays,
1053/// parsing each message, and wrapping results in Nautilus Data enum variants.
1054///
1055/// # Errors
1056///
1057/// Returns an error if the payload is not an array or if individual messages
1058/// cannot be parsed.
1059pub fn parse_message_vec<T, R, F, W>(
1060    data: serde_json::Value,
1061    parser: F,
1062    wrapper: W,
1063) -> anyhow::Result<Vec<Data>>
1064where
1065    T: DeserializeOwned,
1066    F: Fn(&T) -> anyhow::Result<R>,
1067    W: Fn(R) -> Data,
1068{
1069    let messages: Vec<T> =
1070        serde_json::from_value(data).map_err(|e| anyhow::anyhow!("Expected array payload: {e}"))?;
1071
1072    let mut results = Vec::with_capacity(messages.len());
1073
1074    for message in &messages {
1075        let parsed = parser(message)?;
1076        results.push(wrapper(parsed));
1077    }
1078
1079    Ok(results)
1080}
1081
1082/// Converts a Nautilus bar specification into the matching OKX candle channel.
1083///
1084/// # Errors
1085///
1086/// Returns an error if the provided bar specification does not have a matching
1087/// OKX websocket channel.
1088pub fn bar_spec_as_okx_channel(bar_spec: BarSpecification) -> anyhow::Result<OKXWsChannel> {
1089    let channel = match bar_spec {
1090        BAR_SPEC_1_SECOND_LAST => OKXWsChannel::Candle1Second,
1091        BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::Candle1Minute,
1092        BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::Candle3Minute,
1093        BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::Candle5Minute,
1094        BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::Candle15Minute,
1095        BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::Candle30Minute,
1096        BAR_SPEC_1_HOUR_LAST => OKXWsChannel::Candle1Hour,
1097        BAR_SPEC_2_HOUR_LAST => OKXWsChannel::Candle2Hour,
1098        BAR_SPEC_4_HOUR_LAST => OKXWsChannel::Candle4Hour,
1099        BAR_SPEC_6_HOUR_LAST => OKXWsChannel::Candle6Hour,
1100        BAR_SPEC_12_HOUR_LAST => OKXWsChannel::Candle12Hour,
1101        BAR_SPEC_1_DAY_LAST => OKXWsChannel::Candle1Day,
1102        BAR_SPEC_2_DAY_LAST => OKXWsChannel::Candle2Day,
1103        BAR_SPEC_3_DAY_LAST => OKXWsChannel::Candle3Day,
1104        BAR_SPEC_5_DAY_LAST => OKXWsChannel::Candle5Day,
1105        BAR_SPEC_1_WEEK_LAST => OKXWsChannel::Candle1Week,
1106        BAR_SPEC_1_MONTH_LAST => OKXWsChannel::Candle1Month,
1107        BAR_SPEC_3_MONTH_LAST => OKXWsChannel::Candle3Month,
1108        BAR_SPEC_6_MONTH_LAST => OKXWsChannel::Candle6Month,
1109        BAR_SPEC_12_MONTH_LAST => OKXWsChannel::Candle1Year,
1110        _ => anyhow::bail!("Invalid `BarSpecification` for channel, was {bar_spec}"),
1111    };
1112    Ok(channel)
1113}
1114
1115/// Converts Nautilus bar specification to OKX mark price channel.
1116///
1117/// # Errors
1118///
1119/// Returns an error if the bar specification does not map to a mark price
1120/// channel.
1121pub fn bar_spec_as_okx_mark_price_channel(
1122    bar_spec: BarSpecification,
1123) -> anyhow::Result<OKXWsChannel> {
1124    let channel = match bar_spec {
1125        BAR_SPEC_1_SECOND_LAST => OKXWsChannel::MarkPriceCandle1Second,
1126        BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::MarkPriceCandle1Minute,
1127        BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::MarkPriceCandle3Minute,
1128        BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::MarkPriceCandle5Minute,
1129        BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::MarkPriceCandle15Minute,
1130        BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::MarkPriceCandle30Minute,
1131        BAR_SPEC_1_HOUR_LAST => OKXWsChannel::MarkPriceCandle1Hour,
1132        BAR_SPEC_2_HOUR_LAST => OKXWsChannel::MarkPriceCandle2Hour,
1133        BAR_SPEC_4_HOUR_LAST => OKXWsChannel::MarkPriceCandle4Hour,
1134        BAR_SPEC_6_HOUR_LAST => OKXWsChannel::MarkPriceCandle6Hour,
1135        BAR_SPEC_12_HOUR_LAST => OKXWsChannel::MarkPriceCandle12Hour,
1136        BAR_SPEC_1_DAY_LAST => OKXWsChannel::MarkPriceCandle1Day,
1137        BAR_SPEC_2_DAY_LAST => OKXWsChannel::MarkPriceCandle2Day,
1138        BAR_SPEC_3_DAY_LAST => OKXWsChannel::MarkPriceCandle3Day,
1139        BAR_SPEC_5_DAY_LAST => OKXWsChannel::MarkPriceCandle5Day,
1140        BAR_SPEC_1_WEEK_LAST => OKXWsChannel::MarkPriceCandle1Week,
1141        BAR_SPEC_1_MONTH_LAST => OKXWsChannel::MarkPriceCandle1Month,
1142        BAR_SPEC_3_MONTH_LAST => OKXWsChannel::MarkPriceCandle3Month,
1143        _ => anyhow::bail!("Invalid `BarSpecification` for mark price channel, was {bar_spec}"),
1144    };
1145    Ok(channel)
1146}
1147
1148/// Converts Nautilus bar specification to OKX timeframe string.
1149///
1150/// # Errors
1151///
1152/// Returns an error if the bar specification does not have a corresponding
1153/// OKX timeframe value.
1154pub fn bar_spec_as_okx_timeframe(bar_spec: BarSpecification) -> anyhow::Result<&'static str> {
1155    let timeframe = match bar_spec {
1156        BAR_SPEC_1_SECOND_LAST => "1s",
1157        BAR_SPEC_1_MINUTE_LAST => "1m",
1158        BAR_SPEC_3_MINUTE_LAST => "3m",
1159        BAR_SPEC_5_MINUTE_LAST => "5m",
1160        BAR_SPEC_15_MINUTE_LAST => "15m",
1161        BAR_SPEC_30_MINUTE_LAST => "30m",
1162        BAR_SPEC_1_HOUR_LAST => "1H",
1163        BAR_SPEC_2_HOUR_LAST => "2H",
1164        BAR_SPEC_4_HOUR_LAST => "4H",
1165        BAR_SPEC_6_HOUR_LAST => "6H",
1166        BAR_SPEC_12_HOUR_LAST => "12H",
1167        BAR_SPEC_1_DAY_LAST => "1D",
1168        BAR_SPEC_2_DAY_LAST => "2D",
1169        BAR_SPEC_3_DAY_LAST => "3D",
1170        BAR_SPEC_5_DAY_LAST => "5D",
1171        BAR_SPEC_1_WEEK_LAST => "1W",
1172        BAR_SPEC_1_MONTH_LAST => "1M",
1173        BAR_SPEC_3_MONTH_LAST => "3M",
1174        BAR_SPEC_6_MONTH_LAST => "6M",
1175        BAR_SPEC_12_MONTH_LAST => "1Y",
1176        _ => anyhow::bail!("Invalid `BarSpecification` for timeframe, was {bar_spec}"),
1177    };
1178    Ok(timeframe)
1179}
1180
1181/// Converts OKX timeframe string to Nautilus bar specification.
1182///
1183/// # Errors
1184///
1185/// Returns an error if the timeframe string is not recognized.
1186pub fn okx_timeframe_as_bar_spec(timeframe: &str) -> anyhow::Result<BarSpecification> {
1187    let bar_spec = match timeframe {
1188        "1s" => BAR_SPEC_1_SECOND_LAST,
1189        "1m" => BAR_SPEC_1_MINUTE_LAST,
1190        "3m" => BAR_SPEC_3_MINUTE_LAST,
1191        "5m" => BAR_SPEC_5_MINUTE_LAST,
1192        "15m" => BAR_SPEC_15_MINUTE_LAST,
1193        "30m" => BAR_SPEC_30_MINUTE_LAST,
1194        "1H" => BAR_SPEC_1_HOUR_LAST,
1195        "2H" => BAR_SPEC_2_HOUR_LAST,
1196        "4H" => BAR_SPEC_4_HOUR_LAST,
1197        "6H" => BAR_SPEC_6_HOUR_LAST,
1198        "12H" => BAR_SPEC_12_HOUR_LAST,
1199        "1D" => BAR_SPEC_1_DAY_LAST,
1200        "2D" => BAR_SPEC_2_DAY_LAST,
1201        "3D" => BAR_SPEC_3_DAY_LAST,
1202        "5D" => BAR_SPEC_5_DAY_LAST,
1203        "1W" => BAR_SPEC_1_WEEK_LAST,
1204        "1M" => BAR_SPEC_1_MONTH_LAST,
1205        "3M" => BAR_SPEC_3_MONTH_LAST,
1206        "6M" => BAR_SPEC_6_MONTH_LAST,
1207        "1Y" => BAR_SPEC_12_MONTH_LAST,
1208        _ => anyhow::bail!("Invalid timeframe for `BarSpecification`, was {timeframe}"),
1209    };
1210    Ok(bar_spec)
1211}
1212
1213/// Constructs a properly formatted BarType from OKX instrument ID and timeframe string.
1214/// This ensures the BarType uses canonical Nautilus format instead of raw OKX strings.
1215///
1216/// # Errors
1217///
1218/// Returns an error if the timeframe cannot be converted into a
1219/// `BarSpecification`.
1220pub fn okx_bar_type_from_timeframe(
1221    instrument_id: InstrumentId,
1222    timeframe: &str,
1223) -> anyhow::Result<BarType> {
1224    let bar_spec = okx_timeframe_as_bar_spec(timeframe)?;
1225    Ok(BarType::new(
1226        instrument_id,
1227        bar_spec,
1228        AggregationSource::External,
1229    ))
1230}
1231
1232/// Converts OKX WebSocket channel to bar specification if it's a candle channel.
1233pub fn okx_channel_to_bar_spec(channel: &OKXWsChannel) -> Option<BarSpecification> {
1234    use OKXWsChannel::*;
1235    match channel {
1236        Candle1Second | MarkPriceCandle1Second => Some(BAR_SPEC_1_SECOND_LAST),
1237        Candle1Minute | MarkPriceCandle1Minute => Some(BAR_SPEC_1_MINUTE_LAST),
1238        Candle3Minute | MarkPriceCandle3Minute => Some(BAR_SPEC_3_MINUTE_LAST),
1239        Candle5Minute | MarkPriceCandle5Minute => Some(BAR_SPEC_5_MINUTE_LAST),
1240        Candle15Minute | MarkPriceCandle15Minute => Some(BAR_SPEC_15_MINUTE_LAST),
1241        Candle30Minute | MarkPriceCandle30Minute => Some(BAR_SPEC_30_MINUTE_LAST),
1242        Candle1Hour | MarkPriceCandle1Hour => Some(BAR_SPEC_1_HOUR_LAST),
1243        Candle2Hour | MarkPriceCandle2Hour => Some(BAR_SPEC_2_HOUR_LAST),
1244        Candle4Hour | MarkPriceCandle4Hour => Some(BAR_SPEC_4_HOUR_LAST),
1245        Candle6Hour | MarkPriceCandle6Hour => Some(BAR_SPEC_6_HOUR_LAST),
1246        Candle12Hour | MarkPriceCandle12Hour => Some(BAR_SPEC_12_HOUR_LAST),
1247        Candle1Day | MarkPriceCandle1Day => Some(BAR_SPEC_1_DAY_LAST),
1248        Candle2Day | MarkPriceCandle2Day => Some(BAR_SPEC_2_DAY_LAST),
1249        Candle3Day | MarkPriceCandle3Day => Some(BAR_SPEC_3_DAY_LAST),
1250        Candle5Day | MarkPriceCandle5Day => Some(BAR_SPEC_5_DAY_LAST),
1251        Candle1Week | MarkPriceCandle1Week => Some(BAR_SPEC_1_WEEK_LAST),
1252        Candle1Month | MarkPriceCandle1Month => Some(BAR_SPEC_1_MONTH_LAST),
1253        Candle3Month | MarkPriceCandle3Month => Some(BAR_SPEC_3_MONTH_LAST),
1254        Candle6Month => Some(BAR_SPEC_6_MONTH_LAST),
1255        Candle1Year => Some(BAR_SPEC_12_MONTH_LAST),
1256        _ => None,
1257    }
1258}
1259
1260/// Parses an OKX instrument definition into a Nautilus instrument.
1261///
1262/// # Errors
1263///
1264/// Returns an error if the instrument definition cannot be parsed.
1265pub fn parse_instrument_any(
1266    instrument: &OKXInstrument,
1267    margin_init: Option<Decimal>,
1268    margin_maint: Option<Decimal>,
1269    maker_fee: Option<Decimal>,
1270    taker_fee: Option<Decimal>,
1271    ts_init: UnixNanos,
1272) -> anyhow::Result<Option<InstrumentAny>> {
1273    match instrument.inst_type {
1274        OKXInstrumentType::Spot => parse_spot_instrument(
1275            instrument,
1276            margin_init,
1277            margin_maint,
1278            maker_fee,
1279            taker_fee,
1280            ts_init,
1281        )
1282        .map(Some),
1283        OKXInstrumentType::Margin => parse_spot_instrument(
1284            instrument,
1285            margin_init,
1286            margin_maint,
1287            maker_fee,
1288            taker_fee,
1289            ts_init,
1290        )
1291        .map(Some),
1292        OKXInstrumentType::Swap => parse_swap_instrument(
1293            instrument,
1294            margin_init,
1295            margin_maint,
1296            maker_fee,
1297            taker_fee,
1298            ts_init,
1299        )
1300        .map(Some),
1301        OKXInstrumentType::Futures => parse_futures_instrument(
1302            instrument,
1303            margin_init,
1304            margin_maint,
1305            maker_fee,
1306            taker_fee,
1307            ts_init,
1308        )
1309        .map(Some),
1310        OKXInstrumentType::Option => parse_option_instrument(
1311            instrument,
1312            margin_init,
1313            margin_maint,
1314            maker_fee,
1315            taker_fee,
1316            ts_init,
1317        )
1318        .map(Some),
1319        _ => Ok(None),
1320    }
1321}
1322
1323/// Common parsed instrument data extracted from OKX definitions.
1324#[derive(Debug)]
1325struct CommonInstrumentData {
1326    instrument_id: InstrumentId,
1327    raw_symbol: Symbol,
1328    price_increment: Price,
1329    size_increment: Quantity,
1330    lot_size: Option<Quantity>,
1331    max_quantity: Option<Quantity>,
1332    min_quantity: Option<Quantity>,
1333    max_notional: Option<Money>,
1334    min_notional: Option<Money>,
1335    max_price: Option<Price>,
1336    min_price: Option<Price>,
1337}
1338
1339/// Margin and fee configuration for an instrument.
1340struct MarginAndFees {
1341    margin_init: Option<Decimal>,
1342    margin_maint: Option<Decimal>,
1343    maker_fee: Option<Decimal>,
1344    taker_fee: Option<Decimal>,
1345}
1346
1347/// Parses the multiplier as the product of ct_mult and ct_val.
1348///
1349/// For SPOT instruments where both fields are empty, returns None.
1350/// For derivatives, multiplies the two fields to get the final multiplier.
1351fn parse_multiplier_product(definition: &OKXInstrument) -> anyhow::Result<Option<Quantity>> {
1352    if definition.ct_mult.is_empty() && definition.ct_val.is_empty() {
1353        return Ok(None);
1354    }
1355
1356    let mult_value = if definition.ct_mult.is_empty() {
1357        Decimal::ONE
1358    } else {
1359        Decimal::from_str(&definition.ct_mult).map_err(|e| {
1360            anyhow::anyhow!(
1361                "Failed to parse `ct_mult` '{}' for {}: {e}",
1362                definition.ct_mult,
1363                definition.inst_id
1364            )
1365        })?
1366    };
1367
1368    let val_value = if definition.ct_val.is_empty() {
1369        Decimal::ONE
1370    } else {
1371        Decimal::from_str(&definition.ct_val).map_err(|e| {
1372            anyhow::anyhow!(
1373                "Failed to parse `ct_val` '{}' for {}: {e}",
1374                definition.ct_val,
1375                definition.inst_id
1376            )
1377        })?
1378    };
1379
1380    let product = mult_value * val_value;
1381    Ok(Some(Quantity::from(product.to_string())))
1382}
1383
1384/// Trait for instrument-specific parsing logic.
1385trait InstrumentParser {
1386    /// Parses instrument-specific fields and creates the final instrument.
1387    fn parse_specific_fields(
1388        &self,
1389        definition: &OKXInstrument,
1390        common: CommonInstrumentData,
1391        margin_fees: MarginAndFees,
1392        ts_init: UnixNanos,
1393    ) -> anyhow::Result<InstrumentAny>;
1394}
1395
1396/// Extracts common fields shared across all instrument types.
1397fn parse_common_instrument_data(
1398    definition: &OKXInstrument,
1399) -> anyhow::Result<CommonInstrumentData> {
1400    let instrument_id = parse_instrument_id(definition.inst_id);
1401    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1402
1403    if definition.tick_sz.is_empty() {
1404        anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1405    }
1406
1407    let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
1408        anyhow::anyhow!(
1409            "Failed to parse `tick_sz` '{}' into Price for {}: {e}",
1410            definition.tick_sz,
1411            definition.inst_id,
1412        )
1413    })?;
1414
1415    if definition.lot_sz.is_empty() {
1416        anyhow::bail!("`lot_sz` is empty for {}", definition.inst_id);
1417    }
1418
1419    let size_increment = Quantity::from(&definition.lot_sz);
1420    let lot_size = Some(Quantity::from(&definition.lot_sz));
1421    let max_quantity = if definition.max_mkt_sz.is_empty() {
1422        None
1423    } else {
1424        Some(Quantity::from(&definition.max_mkt_sz))
1425    };
1426    let min_quantity = if definition.min_sz.is_empty() {
1427        None
1428    } else {
1429        Some(Quantity::from(&definition.min_sz))
1430    };
1431    let max_notional: Option<Money> = None;
1432    let min_notional: Option<Money> = None;
1433    let max_price = None; // TBD
1434    let min_price = None; // TBD
1435
1436    Ok(CommonInstrumentData {
1437        instrument_id,
1438        raw_symbol,
1439        price_increment,
1440        size_increment,
1441        lot_size,
1442        max_quantity,
1443        min_quantity,
1444        max_notional,
1445        min_notional,
1446        max_price,
1447        min_price,
1448    })
1449}
1450
1451/// Generic instrument parsing function that delegates to type-specific parsers.
1452fn parse_instrument_with_parser<P: InstrumentParser>(
1453    definition: &OKXInstrument,
1454    parser: P,
1455    margin_init: Option<Decimal>,
1456    margin_maint: Option<Decimal>,
1457    maker_fee: Option<Decimal>,
1458    taker_fee: Option<Decimal>,
1459    ts_init: UnixNanos,
1460) -> anyhow::Result<InstrumentAny> {
1461    let common = parse_common_instrument_data(definition)?;
1462    parser.parse_specific_fields(
1463        definition,
1464        common,
1465        MarginAndFees {
1466            margin_init,
1467            margin_maint,
1468            maker_fee,
1469            taker_fee,
1470        },
1471        ts_init,
1472    )
1473}
1474
1475/// Parser for spot trading pairs (CurrencyPair).
1476struct SpotInstrumentParser;
1477
1478impl InstrumentParser for SpotInstrumentParser {
1479    fn parse_specific_fields(
1480        &self,
1481        definition: &OKXInstrument,
1482        common: CommonInstrumentData,
1483        margin_fees: MarginAndFees,
1484        ts_init: UnixNanos,
1485    ) -> anyhow::Result<InstrumentAny> {
1486        let context = format!("{} instrument {}", definition.inst_type, definition.inst_id);
1487        let base_currency =
1488            Currency::get_or_create_crypto_with_context(definition.base_ccy, Some(&context));
1489        let quote_currency =
1490            Currency::get_or_create_crypto_with_context(definition.quote_ccy, Some(&context));
1491
1492        // Parse multiplier as product of ct_mult and ct_val
1493        let multiplier = parse_multiplier_product(definition)?;
1494
1495        let instrument = CurrencyPair::new(
1496            common.instrument_id,
1497            common.raw_symbol,
1498            base_currency,
1499            quote_currency,
1500            common.price_increment.precision,
1501            common.size_increment.precision,
1502            common.price_increment,
1503            common.size_increment,
1504            multiplier,
1505            common.lot_size,
1506            common.max_quantity,
1507            common.min_quantity,
1508            common.max_notional,
1509            common.min_notional,
1510            common.max_price,
1511            common.min_price,
1512            margin_fees.margin_init,
1513            margin_fees.margin_maint,
1514            margin_fees.maker_fee,
1515            margin_fees.taker_fee,
1516            ts_init,
1517            ts_init,
1518        );
1519
1520        Ok(InstrumentAny::CurrencyPair(instrument))
1521    }
1522}
1523
1524/// Parses an OKX spot instrument definition into a Nautilus currency pair.
1525///
1526/// # Errors
1527///
1528/// Returns an error if the instrument definition cannot be parsed.
1529pub fn parse_spot_instrument(
1530    definition: &OKXInstrument,
1531    margin_init: Option<Decimal>,
1532    margin_maint: Option<Decimal>,
1533    maker_fee: Option<Decimal>,
1534    taker_fee: Option<Decimal>,
1535    ts_init: UnixNanos,
1536) -> anyhow::Result<InstrumentAny> {
1537    parse_instrument_with_parser(
1538        definition,
1539        SpotInstrumentParser,
1540        margin_init,
1541        margin_maint,
1542        maker_fee,
1543        taker_fee,
1544        ts_init,
1545    )
1546}
1547
1548/// Validates that the underlying field is not empty for derivative instruments.
1549///
1550/// # Errors
1551///
1552/// Returns an error if the underlying field is empty, which typically indicates
1553/// a pre-open or misconfigured instrument.
1554fn validate_underlying(inst_id: Ustr, uly: Ustr) -> anyhow::Result<()> {
1555    if uly.is_empty() {
1556        anyhow::bail!(
1557            "Empty underlying for {inst_id}: instrument may be pre-open or misconfigured"
1558        );
1559    }
1560    Ok(())
1561}
1562
1563/// Parses an OKX swap instrument definition into a Nautilus crypto perpetual.
1564///
1565/// # Errors
1566///
1567/// Returns an error if the instrument definition cannot be parsed.
1568pub fn parse_swap_instrument(
1569    definition: &OKXInstrument,
1570    margin_init: Option<Decimal>,
1571    margin_maint: Option<Decimal>,
1572    maker_fee: Option<Decimal>,
1573    taker_fee: Option<Decimal>,
1574    ts_init: UnixNanos,
1575) -> anyhow::Result<InstrumentAny> {
1576    validate_underlying(definition.inst_id, definition.uly)?;
1577
1578    let context = format!("SWAP instrument {}", definition.inst_id);
1579    let (base_currency, quote_currency) = definition.uly.split_once('-').ok_or_else(|| {
1580        anyhow::anyhow!(
1581            "Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
1582            definition.uly,
1583            definition.inst_id
1584        )
1585    })?;
1586
1587    let instrument_id = parse_instrument_id(definition.inst_id);
1588    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1589    let base_currency = Currency::get_or_create_crypto_with_context(base_currency, Some(&context));
1590    let quote_currency =
1591        Currency::get_or_create_crypto_with_context(quote_currency, Some(&context));
1592    let settlement_currency =
1593        Currency::get_or_create_crypto_with_context(definition.settle_ccy, Some(&context));
1594    let is_inverse = match definition.ct_type {
1595        OKXContractType::Linear => false,
1596        OKXContractType::Inverse => true,
1597        OKXContractType::None => {
1598            anyhow::bail!(
1599                "Invalid contract type '{}' for {}: expected 'linear' or 'inverse'",
1600                definition.ct_type,
1601                definition.inst_id
1602            )
1603        }
1604    };
1605
1606    if definition.tick_sz.is_empty() {
1607        anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1608    }
1609
1610    let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
1611        anyhow::anyhow!(
1612            "Failed to parse `tick_sz` '{}' into Price for {}: {e}",
1613            definition.tick_sz,
1614            definition.inst_id
1615        )
1616    })?;
1617    let size_increment = Quantity::from(&definition.lot_sz);
1618    let multiplier = parse_multiplier_product(definition)?;
1619    let lot_size = Some(Quantity::from(&definition.lot_sz));
1620    let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1621    let min_quantity = Some(Quantity::from(&definition.min_sz));
1622    let max_notional: Option<Money> = None;
1623    let min_notional: Option<Money> = None;
1624    let max_price = None; // TBD
1625    let min_price = None; // TBD
1626
1627    let instrument = CryptoPerpetual::new(
1628        instrument_id,
1629        raw_symbol,
1630        base_currency,
1631        quote_currency,
1632        settlement_currency,
1633        is_inverse,
1634        price_increment.precision,
1635        size_increment.precision,
1636        price_increment,
1637        size_increment,
1638        multiplier,
1639        lot_size,
1640        max_quantity,
1641        min_quantity,
1642        max_notional,
1643        min_notional,
1644        max_price,
1645        min_price,
1646        margin_init,
1647        margin_maint,
1648        maker_fee,
1649        taker_fee,
1650        ts_init, // No ts_event for response
1651        ts_init,
1652    );
1653
1654    Ok(InstrumentAny::CryptoPerpetual(instrument))
1655}
1656
1657/// Parses an OKX futures instrument definition into a Nautilus crypto future.
1658///
1659/// # Errors
1660///
1661/// Returns an error if the instrument definition cannot be parsed.
1662pub fn parse_futures_instrument(
1663    definition: &OKXInstrument,
1664    margin_init: Option<Decimal>,
1665    margin_maint: Option<Decimal>,
1666    maker_fee: Option<Decimal>,
1667    taker_fee: Option<Decimal>,
1668    ts_init: UnixNanos,
1669) -> anyhow::Result<InstrumentAny> {
1670    validate_underlying(definition.inst_id, definition.uly)?;
1671
1672    let context = format!("FUTURES instrument {}", definition.inst_id);
1673    let (_, quote_currency) = definition.uly.split_once('-').ok_or_else(|| {
1674        anyhow::anyhow!(
1675            "Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
1676            definition.uly,
1677            definition.inst_id
1678        )
1679    })?;
1680
1681    let instrument_id = parse_instrument_id(definition.inst_id);
1682    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1683    let underlying = Currency::get_or_create_crypto_with_context(definition.uly, Some(&context));
1684    let quote_currency =
1685        Currency::get_or_create_crypto_with_context(quote_currency, Some(&context));
1686    let settlement_currency =
1687        Currency::get_or_create_crypto_with_context(definition.settle_ccy, Some(&context));
1688    let is_inverse = match definition.ct_type {
1689        OKXContractType::Linear => false,
1690        OKXContractType::Inverse => true,
1691        OKXContractType::None => {
1692            anyhow::bail!(
1693                "Invalid contract type '{}' for {}: expected 'linear' or 'inverse'",
1694                definition.ct_type,
1695                definition.inst_id
1696            )
1697        }
1698    };
1699    let listing_time = definition
1700        .list_time
1701        .ok_or_else(|| anyhow::anyhow!("`list_time` is required for {}", definition.inst_id))?;
1702    let expiry_time = definition
1703        .exp_time
1704        .ok_or_else(|| anyhow::anyhow!("`exp_time` is required for {}", definition.inst_id))?;
1705    let activation_ns = UnixNanos::from(millis_to_nanos_unchecked(listing_time as f64));
1706    let expiration_ns = UnixNanos::from(millis_to_nanos_unchecked(expiry_time as f64));
1707
1708    if definition.tick_sz.is_empty() {
1709        anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1710    }
1711
1712    let price_increment = Price::from(definition.tick_sz.clone());
1713    let size_increment = Quantity::from(&definition.lot_sz);
1714    let multiplier = parse_multiplier_product(definition)?;
1715    let lot_size = Some(Quantity::from(&definition.lot_sz));
1716    let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1717    let min_quantity = Some(Quantity::from(&definition.min_sz));
1718    let max_notional: Option<Money> = None;
1719    let min_notional: Option<Money> = None;
1720    let max_price = None; // TBD
1721    let min_price = None; // TBD
1722
1723    let instrument = CryptoFuture::new(
1724        instrument_id,
1725        raw_symbol,
1726        underlying,
1727        quote_currency,
1728        settlement_currency,
1729        is_inverse,
1730        activation_ns,
1731        expiration_ns,
1732        price_increment.precision,
1733        size_increment.precision,
1734        price_increment,
1735        size_increment,
1736        multiplier,
1737        lot_size,
1738        max_quantity,
1739        min_quantity,
1740        max_notional,
1741        min_notional,
1742        max_price,
1743        min_price,
1744        margin_init,
1745        margin_maint,
1746        maker_fee,
1747        taker_fee,
1748        ts_init, // No ts_event for response
1749        ts_init,
1750    );
1751
1752    Ok(InstrumentAny::CryptoFuture(instrument))
1753}
1754
1755/// Parses an OKX option instrument definition into a Nautilus option contract.
1756///
1757/// # Errors
1758///
1759/// Returns an error if the instrument definition cannot be parsed.
1760pub fn parse_option_instrument(
1761    definition: &OKXInstrument,
1762    margin_init: Option<Decimal>,
1763    margin_maint: Option<Decimal>,
1764    maker_fee: Option<Decimal>,
1765    taker_fee: Option<Decimal>,
1766    ts_init: UnixNanos,
1767) -> anyhow::Result<InstrumentAny> {
1768    validate_underlying(definition.inst_id, definition.uly)?;
1769
1770    let context = format!("OPTION instrument {}", definition.inst_id);
1771    let (underlying_str, quote_ccy_str) = definition.uly.split_once('-').ok_or_else(|| {
1772        anyhow::anyhow!(
1773            "Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
1774            definition.uly,
1775            definition.inst_id
1776        )
1777    })?;
1778
1779    let instrument_id = parse_instrument_id(definition.inst_id);
1780    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1781    let underlying = Currency::get_or_create_crypto_with_context(underlying_str, Some(&context));
1782    let option_kind: OptionKind = definition.opt_type.into();
1783    let strike_price = Price::from(&definition.stk);
1784    let quote_currency = Currency::get_or_create_crypto_with_context(quote_ccy_str, Some(&context));
1785    let settlement_currency =
1786        Currency::get_or_create_crypto_with_context(definition.settle_ccy, Some(&context));
1787
1788    let is_inverse = if definition.ct_type == OKXContractType::None {
1789        settlement_currency == underlying
1790    } else {
1791        matches!(definition.ct_type, OKXContractType::Inverse)
1792    };
1793
1794    let listing_time = definition
1795        .list_time
1796        .ok_or_else(|| anyhow::anyhow!("`list_time` is required for {}", definition.inst_id))?;
1797    let expiry_time = definition
1798        .exp_time
1799        .ok_or_else(|| anyhow::anyhow!("`exp_time` is required for {}", definition.inst_id))?;
1800    let activation_ns = UnixNanos::from(millis_to_nanos_unchecked(listing_time as f64));
1801    let expiration_ns = UnixNanos::from(millis_to_nanos_unchecked(expiry_time as f64));
1802
1803    if definition.tick_sz.is_empty() {
1804        anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1805    }
1806
1807    let price_increment = Price::from(definition.tick_sz.clone());
1808    let size_increment = Quantity::from(&definition.lot_sz);
1809    let multiplier = parse_multiplier_product(definition)?;
1810    let lot_size = Quantity::from(&definition.lot_sz);
1811    let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1812    let min_quantity = Some(Quantity::from(&definition.min_sz));
1813    let max_notional = None;
1814    let min_notional = None;
1815    let max_price = None;
1816    let min_price = None;
1817
1818    let instrument = CryptoOption::new(
1819        instrument_id,
1820        raw_symbol,
1821        underlying,
1822        quote_currency,
1823        settlement_currency,
1824        is_inverse,
1825        option_kind,
1826        strike_price,
1827        activation_ns,
1828        expiration_ns,
1829        price_increment.precision,
1830        size_increment.precision,
1831        price_increment,
1832        size_increment,
1833        multiplier,
1834        Some(lot_size),
1835        max_quantity,
1836        min_quantity,
1837        max_notional,
1838        min_notional,
1839        max_price,
1840        min_price,
1841        margin_init,
1842        margin_maint,
1843        maker_fee,
1844        taker_fee,
1845        ts_init,
1846        ts_init,
1847    );
1848
1849    Ok(InstrumentAny::CryptoOption(instrument))
1850}
1851
1852/// Parses an OKX account into a Nautilus account state.
1853///
1854fn parse_balance_field(
1855    value_str: &str,
1856    field_name: &str,
1857    currency: Currency,
1858    ccy_str: &str,
1859) -> Option<Money> {
1860    match Decimal::from_str(value_str) {
1861        Ok(decimal) => Money::from_decimal(decimal, currency).ok(),
1862        Err(e) => {
1863            tracing::warn!(
1864                "Skipping balance detail for {ccy_str} with invalid {field_name} '{value_str}': {e}"
1865            );
1866            None
1867        }
1868    }
1869}
1870
1871/// # Errors
1872///
1873/// Returns an error if the data cannot be parsed.
1874pub fn parse_account_state(
1875    okx_account: &OKXAccount,
1876    account_id: AccountId,
1877    ts_init: UnixNanos,
1878) -> anyhow::Result<AccountState> {
1879    let mut balances = Vec::new();
1880    for b in &okx_account.details {
1881        // Skip balances with empty or whitespace-only currency codes
1882        let ccy_str = b.ccy.as_str().trim();
1883        if ccy_str.is_empty() {
1884            tracing::debug!(
1885                "Skipping balance detail with empty currency code | raw_data={:?}",
1886                b
1887            );
1888            continue;
1889        }
1890
1891        // Get or create currency (consistent with instrument parsing)
1892        let currency = Currency::get_or_create_crypto_with_context(ccy_str, Some("balance detail"));
1893
1894        // Parse balance values, skip if invalid
1895        let Some(total) = parse_balance_field(&b.cash_bal, "cash_bal", currency, ccy_str) else {
1896            continue;
1897        };
1898
1899        let Some(free) = parse_balance_field(&b.avail_bal, "avail_bal", currency, ccy_str) else {
1900            continue;
1901        };
1902
1903        let locked = total - free;
1904        let balance = AccountBalance::new(total, locked, free);
1905        balances.push(balance);
1906    }
1907
1908    // Ensure at least one balance exists (Nautilus requires non-empty balances)
1909    // OKX may return empty details for certain account configurations
1910    if balances.is_empty() {
1911        let zero_currency = Currency::USD();
1912        let zero_money = Money::new(0.0, zero_currency);
1913        let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
1914        balances.push(zero_balance);
1915    }
1916
1917    let mut margins = Vec::new();
1918
1919    // OKX provides account-level margin requirements (not per instrument)
1920    if !okx_account.imr.is_empty() && !okx_account.mmr.is_empty() {
1921        match (
1922            Decimal::from_str(&okx_account.imr),
1923            Decimal::from_str(&okx_account.mmr),
1924        ) {
1925            (Ok(imr_dec), Ok(mmr_dec)) => {
1926                if !imr_dec.is_zero() || !mmr_dec.is_zero() {
1927                    let margin_currency = Currency::USD();
1928                    let margin_instrument_id =
1929                        InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("OKX"));
1930
1931                    let initial_margin = Money::from_decimal(imr_dec, margin_currency)
1932                        .unwrap_or_else(|e| {
1933                            tracing::error!("Failed to create initial margin: {e}");
1934                            Money::zero(margin_currency)
1935                        });
1936                    let maintenance_margin = Money::from_decimal(mmr_dec, margin_currency)
1937                        .unwrap_or_else(|e| {
1938                            tracing::error!("Failed to create maintenance margin: {e}");
1939                            Money::zero(margin_currency)
1940                        });
1941
1942                    let margin_balance = MarginBalance::new(
1943                        initial_margin,
1944                        maintenance_margin,
1945                        margin_instrument_id,
1946                    );
1947
1948                    margins.push(margin_balance);
1949                }
1950            }
1951            (Err(e1), _) => {
1952                tracing::warn!(
1953                    "Failed to parse initial margin requirement '{}': {}",
1954                    okx_account.imr,
1955                    e1
1956                );
1957            }
1958            (_, Err(e2)) => {
1959                tracing::warn!(
1960                    "Failed to parse maintenance margin requirement '{}': {}",
1961                    okx_account.mmr,
1962                    e2
1963                );
1964            }
1965        }
1966    }
1967
1968    let account_type = AccountType::Margin;
1969    let is_reported = true;
1970    let event_id = UUID4::new();
1971    let ts_event = UnixNanos::from(millis_to_nanos_unchecked(okx_account.u_time as f64));
1972
1973    Ok(AccountState::new(
1974        account_id,
1975        account_type,
1976        balances,
1977        margins,
1978        is_reported,
1979        event_id,
1980        ts_event,
1981        ts_init,
1982        None,
1983    ))
1984}
1985
1986#[cfg(test)]
1987mod tests {
1988    use nautilus_model::{identifiers::PositionId, instruments::Instrument};
1989    use rstest::rstest;
1990    use rust_decimal_macros::dec;
1991
1992    use super::*;
1993    use crate::{
1994        OKXPositionSide,
1995        common::{enums::OKXMarginMode, testing::load_test_json},
1996        http::{
1997            client::OKXResponse,
1998            models::{
1999                OKXAccount, OKXBalanceDetail, OKXCandlestick, OKXIndexTicker, OKXMarkPrice,
2000                OKXOrderHistory, OKXPlaceOrderResponse, OKXPosition, OKXPositionHistory,
2001                OKXPositionTier, OKXTrade, OKXTransactionDetail,
2002            },
2003        },
2004    };
2005
2006    #[rstest]
2007    fn test_parse_fee_currency_with_zero_fee_empty_string() {
2008        let result = parse_fee_currency("", Decimal::ZERO, || "test context".to_string());
2009        assert_eq!(result, Currency::USDT());
2010    }
2011
2012    #[rstest]
2013    fn test_parse_fee_currency_with_zero_fee_valid_currency() {
2014        let result = parse_fee_currency("BTC", Decimal::ZERO, || "test context".to_string());
2015        assert_eq!(result, Currency::BTC());
2016    }
2017
2018    #[rstest]
2019    fn test_parse_fee_currency_with_valid_currency() {
2020        let result = parse_fee_currency("BTC", dec!(0.001), || "test context".to_string());
2021        assert_eq!(result, Currency::BTC());
2022    }
2023
2024    #[rstest]
2025    fn test_parse_fee_currency_with_empty_string_nonzero_fee() {
2026        let result = parse_fee_currency("", dec!(0.5), || "test context".to_string());
2027        assert_eq!(result, Currency::USDT());
2028    }
2029
2030    #[rstest]
2031    fn test_parse_fee_currency_with_whitespace() {
2032        let result = parse_fee_currency("  ETH  ", dec!(0.002), || "test context".to_string());
2033        assert_eq!(result, Currency::ETH());
2034    }
2035
2036    #[rstest]
2037    fn test_parse_fee_currency_with_unknown_code() {
2038        // Unknown currency code should create a new Currency (8 decimals, crypto)
2039        let result = parse_fee_currency("NEWTOKEN", dec!(0.5), || "test context".to_string());
2040        assert_eq!(result.code.as_str(), "NEWTOKEN");
2041        assert_eq!(result.precision, 8);
2042    }
2043
2044    #[rstest]
2045    fn test_parse_balance_field_valid() {
2046        let result = parse_balance_field("100.5", "test_field", Currency::BTC(), "BTC");
2047        assert!(result.is_some());
2048        assert_eq!(result.unwrap().as_f64(), 100.5);
2049    }
2050
2051    #[rstest]
2052    fn test_parse_balance_field_invalid_numeric() {
2053        let result = parse_balance_field("not_a_number", "test_field", Currency::BTC(), "BTC");
2054        assert!(result.is_none());
2055    }
2056
2057    #[rstest]
2058    fn test_parse_balance_field_empty() {
2059        let result = parse_balance_field("", "test_field", Currency::BTC(), "BTC");
2060        assert!(result.is_none());
2061    }
2062
2063    // Note: Tests for parse_account_state with edge cases (empty currency codes, invalid values)
2064    // are covered by the existing tests using test data files (e.g., http_get_account_balance.json)
2065
2066    #[rstest]
2067    fn test_parse_trades() {
2068        let json_data = load_test_json("http_get_trades.json");
2069        let parsed: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
2070
2071        // Basic response envelope
2072        assert_eq!(parsed.code, "0");
2073        assert_eq!(parsed.msg, "");
2074        assert_eq!(parsed.data.len(), 2);
2075
2076        // Inspect first record
2077        let trade0 = &parsed.data[0];
2078        assert_eq!(trade0.inst_id, "BTC-USDT");
2079        assert_eq!(trade0.px, "102537.9");
2080        assert_eq!(trade0.sz, "0.00013669");
2081        assert_eq!(trade0.side, OKXSide::Sell);
2082        assert_eq!(trade0.trade_id, "734864333");
2083        assert_eq!(trade0.ts, 1747087163557);
2084
2085        // Inspect second record
2086        let trade1 = &parsed.data[1];
2087        assert_eq!(trade1.inst_id, "BTC-USDT");
2088        assert_eq!(trade1.px, "102537.9");
2089        assert_eq!(trade1.sz, "0.0000125");
2090        assert_eq!(trade1.side, OKXSide::Buy);
2091        assert_eq!(trade1.trade_id, "734864332");
2092        assert_eq!(trade1.ts, 1747087161666);
2093    }
2094
2095    #[rstest]
2096    fn test_parse_candlesticks() {
2097        let json_data = load_test_json("http_get_candlesticks.json");
2098        let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
2099
2100        // Basic response envelope
2101        assert_eq!(parsed.code, "0");
2102        assert_eq!(parsed.msg, "");
2103        assert_eq!(parsed.data.len(), 2);
2104
2105        let bar0 = &parsed.data[0];
2106        assert_eq!(bar0.0, "1625097600000");
2107        assert_eq!(bar0.1, "33528.6");
2108        assert_eq!(bar0.2, "33870.0");
2109        assert_eq!(bar0.3, "33528.6");
2110        assert_eq!(bar0.4, "33783.9");
2111        assert_eq!(bar0.5, "778.838");
2112
2113        let bar1 = &parsed.data[1];
2114        assert_eq!(bar1.0, "1625097660000");
2115        assert_eq!(bar1.1, "33783.9");
2116        assert_eq!(bar1.2, "33783.9");
2117        assert_eq!(bar1.3, "33782.1");
2118        assert_eq!(bar1.4, "33782.1");
2119        assert_eq!(bar1.5, "0.123");
2120    }
2121
2122    #[rstest]
2123    fn test_parse_candlesticks_full() {
2124        let json_data = load_test_json("http_get_candlesticks_full.json");
2125        let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
2126
2127        // Basic response envelope
2128        assert_eq!(parsed.code, "0");
2129        assert_eq!(parsed.msg, "");
2130        assert_eq!(parsed.data.len(), 2);
2131
2132        // Inspect first record
2133        let bar0 = &parsed.data[0];
2134        assert_eq!(bar0.0, "1747094040000");
2135        assert_eq!(bar0.1, "102806.1");
2136        assert_eq!(bar0.2, "102820.4");
2137        assert_eq!(bar0.3, "102806.1");
2138        assert_eq!(bar0.4, "102820.4");
2139        assert_eq!(bar0.5, "1040.37");
2140        assert_eq!(bar0.6, "10.4037");
2141        assert_eq!(bar0.7, "1069603.34883");
2142        assert_eq!(bar0.8, "1");
2143
2144        // Inspect second record
2145        let bar1 = &parsed.data[1];
2146        assert_eq!(bar1.0, "1747093980000");
2147        assert_eq!(bar1.5, "7164.04");
2148        assert_eq!(bar1.6, "71.6404");
2149        assert_eq!(bar1.7, "7364701.57952");
2150        assert_eq!(bar1.8, "1");
2151    }
2152
2153    #[rstest]
2154    fn test_parse_mark_price() {
2155        let json_data = load_test_json("http_get_mark_price.json");
2156        let parsed: OKXResponse<OKXMarkPrice> = serde_json::from_str(&json_data).unwrap();
2157
2158        // Basic response envelope
2159        assert_eq!(parsed.code, "0");
2160        assert_eq!(parsed.msg, "");
2161        assert_eq!(parsed.data.len(), 1);
2162
2163        // Inspect first record
2164        let mark_price = &parsed.data[0];
2165
2166        assert_eq!(mark_price.inst_id, "BTC-USDT-SWAP");
2167        assert_eq!(mark_price.mark_px, "84660.1");
2168        assert_eq!(mark_price.ts, 1744590349506);
2169    }
2170
2171    #[rstest]
2172    fn test_parse_index_price() {
2173        let json_data = load_test_json("http_get_index_price.json");
2174        let parsed: OKXResponse<OKXIndexTicker> = serde_json::from_str(&json_data).unwrap();
2175
2176        // Basic response envelope
2177        assert_eq!(parsed.code, "0");
2178        assert_eq!(parsed.msg, "");
2179        assert_eq!(parsed.data.len(), 1);
2180
2181        // Inspect first record
2182        let index_price = &parsed.data[0];
2183
2184        assert_eq!(index_price.inst_id, "BTC-USDT");
2185        assert_eq!(index_price.idx_px, "103895");
2186        assert_eq!(index_price.ts, 1746942707815);
2187    }
2188
2189    #[rstest]
2190    fn test_parse_account() {
2191        let json_data = load_test_json("http_get_account_balance.json");
2192        let parsed: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
2193
2194        // Basic response envelope
2195        assert_eq!(parsed.code, "0");
2196        assert_eq!(parsed.msg, "");
2197        assert_eq!(parsed.data.len(), 1);
2198
2199        // Inspect first record
2200        let account = &parsed.data[0];
2201        assert_eq!(account.adj_eq, "");
2202        assert_eq!(account.borrow_froz, "");
2203        assert_eq!(account.imr, "");
2204        assert_eq!(account.iso_eq, "5.4682385526666675");
2205        assert_eq!(account.mgn_ratio, "");
2206        assert_eq!(account.mmr, "");
2207        assert_eq!(account.notional_usd, "");
2208        assert_eq!(account.notional_usd_for_borrow, "");
2209        assert_eq!(account.notional_usd_for_futures, "");
2210        assert_eq!(account.notional_usd_for_option, "");
2211        assert_eq!(account.notional_usd_for_swap, "");
2212        assert_eq!(account.ord_froz, "");
2213        assert_eq!(account.total_eq, "99.88870288820581");
2214        assert_eq!(account.upl, "");
2215        assert_eq!(account.u_time, 1744499648556);
2216        assert_eq!(account.details.len(), 1);
2217
2218        let detail = &account.details[0];
2219        assert_eq!(detail.ccy, "USDT");
2220        assert_eq!(detail.avail_bal, "94.42612990333333");
2221        assert_eq!(detail.avail_eq, "94.42612990333333");
2222        assert_eq!(detail.cash_bal, "94.42612990333333");
2223        assert_eq!(detail.dis_eq, "5.4682385526666675");
2224        assert_eq!(detail.eq, "99.89469657000001");
2225        assert_eq!(detail.eq_usd, "99.88870288820581");
2226        assert_eq!(detail.fixed_bal, "0");
2227        assert_eq!(detail.frozen_bal, "5.468566666666667");
2228        assert_eq!(detail.imr, "0");
2229        assert_eq!(detail.iso_eq, "5.468566666666667");
2230        assert_eq!(detail.iso_upl, "-0.0273000000000002");
2231        assert_eq!(detail.mmr, "0");
2232        assert_eq!(detail.notional_lever, "0");
2233        assert_eq!(detail.ord_frozen, "0");
2234        assert_eq!(detail.reward_bal, "0");
2235        assert_eq!(detail.smt_sync_eq, "0");
2236        assert_eq!(detail.spot_copy_trading_eq, "0");
2237        assert_eq!(detail.spot_iso_bal, "0");
2238        assert_eq!(detail.stgy_eq, "0");
2239        assert_eq!(detail.twap, "0");
2240        assert_eq!(detail.upl, "-0.0273000000000002");
2241        assert_eq!(detail.u_time, 1744498994783);
2242    }
2243
2244    #[rstest]
2245    fn test_parse_order_history() {
2246        let json_data = load_test_json("http_get_orders_history.json");
2247        let parsed: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
2248
2249        // Basic response envelope
2250        assert_eq!(parsed.code, "0");
2251        assert_eq!(parsed.msg, "");
2252        assert_eq!(parsed.data.len(), 1);
2253
2254        // Inspect first record
2255        let order = &parsed.data[0];
2256        assert_eq!(order.ord_id, "2497956918703120384");
2257        assert_eq!(order.fill_sz, "0.03");
2258        assert_eq!(order.acc_fill_sz, "0.03");
2259        assert_eq!(order.state, OKXOrderStatus::Filled);
2260        assert!(order.fill_fee.is_none());
2261    }
2262
2263    #[rstest]
2264    fn test_parse_position() {
2265        let json_data = load_test_json("http_get_positions.json");
2266        let parsed: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
2267
2268        // Basic response envelope
2269        assert_eq!(parsed.code, "0");
2270        assert_eq!(parsed.msg, "");
2271        assert_eq!(parsed.data.len(), 1);
2272
2273        // Inspect first record
2274        let pos = &parsed.data[0];
2275        assert_eq!(pos.inst_id, "BTC-USDT-SWAP");
2276        assert_eq!(pos.pos_side, OKXPositionSide::Long);
2277        assert_eq!(pos.pos, "0.5");
2278        assert_eq!(pos.base_bal, "0.5");
2279        assert_eq!(pos.quote_bal, "5000");
2280        assert_eq!(pos.u_time, 1622559930237);
2281    }
2282
2283    #[rstest]
2284    fn test_parse_position_history() {
2285        let json_data = load_test_json("http_get_account_positions-history.json");
2286        let parsed: OKXResponse<OKXPositionHistory> = serde_json::from_str(&json_data).unwrap();
2287
2288        // Basic response envelope
2289        assert_eq!(parsed.code, "0");
2290        assert_eq!(parsed.msg, "");
2291        assert_eq!(parsed.data.len(), 1);
2292
2293        // Inspect first record
2294        let hist = &parsed.data[0];
2295        assert_eq!(hist.inst_id, "ETH-USDT-SWAP");
2296        assert_eq!(hist.inst_type, OKXInstrumentType::Swap);
2297        assert_eq!(hist.mgn_mode, OKXMarginMode::Isolated);
2298        assert_eq!(hist.pos_side, OKXPositionSide::Long);
2299        assert_eq!(hist.lever, "3.0");
2300        assert_eq!(hist.open_avg_px, "3226.93");
2301        assert_eq!(hist.close_avg_px.as_deref(), Some("3224.8"));
2302        assert_eq!(hist.pnl.as_deref(), Some("-0.0213"));
2303        assert!(!hist.c_time.is_empty());
2304        assert!(hist.u_time > 0);
2305    }
2306
2307    #[rstest]
2308    fn test_parse_position_tiers() {
2309        let json_data = load_test_json("http_get_position_tiers.json");
2310        let parsed: OKXResponse<OKXPositionTier> = serde_json::from_str(&json_data).unwrap();
2311
2312        // Basic response envelope
2313        assert_eq!(parsed.code, "0");
2314        assert_eq!(parsed.msg, "");
2315        assert_eq!(parsed.data.len(), 1);
2316
2317        // Inspect first tier record
2318        let tier = &parsed.data[0];
2319        assert_eq!(tier.inst_id, "BTC-USDT");
2320        assert_eq!(tier.tier, "1");
2321        assert_eq!(tier.min_sz, "0");
2322        assert_eq!(tier.max_sz, "50");
2323        assert_eq!(tier.imr, "0.1");
2324        assert_eq!(tier.mmr, "0.03");
2325    }
2326
2327    #[rstest]
2328    fn test_parse_account_field_name_compatibility() {
2329        // Test with new field names (with Amt suffix)
2330        let json_new = load_test_json("http_balance_detail_new_fields.json");
2331        let detail_new: OKXBalanceDetail = serde_json::from_str(&json_new).unwrap();
2332        assert_eq!(detail_new.max_spot_in_use_amt, "50.0");
2333        assert_eq!(detail_new.spot_in_use_amt, "30.0");
2334        assert_eq!(detail_new.cl_spot_in_use_amt, "25.0");
2335
2336        // Test with old field names (without Amt suffix) - for backward compatibility
2337        let json_old = load_test_json("http_balance_detail_old_fields.json");
2338        let detail_old: OKXBalanceDetail = serde_json::from_str(&json_old).unwrap();
2339        assert_eq!(detail_old.max_spot_in_use_amt, "75.0");
2340        assert_eq!(detail_old.spot_in_use_amt, "40.0");
2341        assert_eq!(detail_old.cl_spot_in_use_amt, "35.0");
2342    }
2343
2344    #[rstest]
2345    fn test_parse_place_order_response() {
2346        let json_data = load_test_json("http_place_order_response.json");
2347        let parsed: OKXPlaceOrderResponse = serde_json::from_str(&json_data).unwrap();
2348        assert_eq!(
2349            parsed.ord_id,
2350            Some(ustr::Ustr::from("12345678901234567890"))
2351        );
2352        assert_eq!(parsed.cl_ord_id, Some(ustr::Ustr::from("client_order_123")));
2353        assert_eq!(parsed.tag, Some(String::new()));
2354    }
2355
2356    #[rstest]
2357    fn test_parse_transaction_details() {
2358        let json_data = load_test_json("http_transaction_detail.json");
2359        let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
2360        assert_eq!(parsed.inst_type, OKXInstrumentType::Spot);
2361        assert_eq!(parsed.inst_id, Ustr::from("BTC-USDT"));
2362        assert_eq!(parsed.trade_id, Ustr::from("123456789"));
2363        assert_eq!(parsed.ord_id, Ustr::from("987654321"));
2364        assert_eq!(parsed.cl_ord_id, Ustr::from("client_123"));
2365        assert_eq!(parsed.bill_id, Ustr::from("bill_456"));
2366        assert_eq!(parsed.fill_px, "42000.5");
2367        assert_eq!(parsed.fill_sz, "0.001");
2368        assert_eq!(parsed.side, OKXSide::Buy);
2369        assert_eq!(parsed.exec_type, OKXExecType::Taker);
2370        assert_eq!(parsed.fee_ccy, "USDT");
2371        assert_eq!(parsed.fee, Some("0.042".to_string()));
2372        assert_eq!(parsed.ts, 1625097600000);
2373    }
2374
2375    #[rstest]
2376    fn test_parse_empty_fee_field() {
2377        let json_data = load_test_json("http_transaction_detail_empty_fee.json");
2378        let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
2379        assert_eq!(parsed.fee, None);
2380    }
2381
2382    #[rstest]
2383    fn test_parse_optional_string_to_u64() {
2384        use serde::Deserialize;
2385
2386        #[derive(Deserialize)]
2387        struct TestStruct {
2388            #[serde(deserialize_with = "crate::common::parse::deserialize_optional_string_to_u64")]
2389            value: Option<u64>,
2390        }
2391
2392        let json_cases = load_test_json("common_optional_string_to_u64.json");
2393        let cases: Vec<TestStruct> = serde_json::from_str(&json_cases).unwrap();
2394
2395        assert_eq!(cases[0].value, Some(12345));
2396        assert_eq!(cases[1].value, None);
2397        assert_eq!(cases[2].value, None);
2398    }
2399
2400    #[rstest]
2401    fn test_parse_error_handling() {
2402        // Test error handling with invalid price string
2403        let invalid_price = "invalid-price";
2404        let result = crate::common::parse::parse_price(invalid_price, 2);
2405        assert!(result.is_err());
2406
2407        // Test error handling with invalid quantity string
2408        let invalid_quantity = "invalid-quantity";
2409        let result = crate::common::parse::parse_quantity(invalid_quantity, 8);
2410        assert!(result.is_err());
2411    }
2412
2413    #[rstest]
2414    fn test_parse_spot_instrument() {
2415        let json_data = load_test_json("http_get_instruments_spot.json");
2416        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2417        let okx_inst: &OKXInstrument = response
2418            .data
2419            .first()
2420            .expect("Test data must have an instrument");
2421
2422        let instrument =
2423            parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2424
2425        assert_eq!(instrument.id(), InstrumentId::from("BTC-USD.OKX"));
2426        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD"));
2427        assert_eq!(instrument.underlying(), None);
2428        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2429        assert_eq!(instrument.quote_currency(), Currency::USD());
2430        assert_eq!(instrument.settlement_currency(), Currency::USD());
2431        assert_eq!(instrument.price_precision(), 1);
2432        assert_eq!(instrument.size_precision(), 8);
2433        assert_eq!(instrument.price_increment(), Price::from("0.1"));
2434        assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
2435        assert_eq!(instrument.multiplier(), Quantity::from(1));
2436        assert_eq!(instrument.lot_size(), Some(Quantity::from("0.00000001")));
2437        assert_eq!(instrument.max_quantity(), Some(Quantity::from(1000000)));
2438        assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.00001")));
2439        assert_eq!(instrument.max_notional(), None);
2440        assert_eq!(instrument.min_notional(), None);
2441        assert_eq!(instrument.max_price(), None);
2442        assert_eq!(instrument.min_price(), None);
2443    }
2444
2445    #[rstest]
2446    fn test_parse_margin_instrument() {
2447        let json_data = load_test_json("http_get_instruments_margin.json");
2448        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2449        let okx_inst: &OKXInstrument = response
2450            .data
2451            .first()
2452            .expect("Test data must have an instrument");
2453
2454        let instrument =
2455            parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2456
2457        assert_eq!(instrument.id(), InstrumentId::from("BTC-USDT.OKX"));
2458        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USDT"));
2459        assert_eq!(instrument.underlying(), None);
2460        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2461        assert_eq!(instrument.quote_currency(), Currency::USDT());
2462        assert_eq!(instrument.settlement_currency(), Currency::USDT());
2463        assert_eq!(instrument.price_precision(), 1);
2464        assert_eq!(instrument.size_precision(), 8);
2465        assert_eq!(instrument.price_increment(), Price::from("0.1"));
2466        assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
2467        assert_eq!(instrument.multiplier(), Quantity::from(1));
2468        assert_eq!(instrument.lot_size(), Some(Quantity::from("0.00000001")));
2469        assert_eq!(instrument.max_quantity(), Some(Quantity::from(1000000)));
2470        assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.00001")));
2471        assert_eq!(instrument.max_notional(), None);
2472        assert_eq!(instrument.min_notional(), None);
2473        assert_eq!(instrument.max_price(), None);
2474        assert_eq!(instrument.min_price(), None);
2475    }
2476
2477    #[rstest]
2478    fn test_parse_spot_instrument_with_valid_ct_mult() {
2479        let json_data = load_test_json("http_get_instruments_spot.json");
2480        let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2481
2482        // Modify ctMult to have a valid multiplier value (ctVal is empty, defaults to 1)
2483        if let Some(inst) = response.data.first_mut() {
2484            inst.ct_mult = "0.01".to_string();
2485        }
2486
2487        let okx_inst = response.data.first().unwrap();
2488        let instrument =
2489            parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2490
2491        // Should parse the multiplier as product of ctMult * ctVal (0.01 * 1 = 0.01)
2492        if let InstrumentAny::CurrencyPair(pair) = instrument {
2493            assert_eq!(pair.multiplier, Quantity::from("0.01"));
2494        } else {
2495            panic!("Expected CurrencyPair instrument");
2496        }
2497    }
2498
2499    #[rstest]
2500    fn test_parse_spot_instrument_with_invalid_ct_mult() {
2501        let json_data = load_test_json("http_get_instruments_spot.json");
2502        let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2503
2504        // Modify ctMult to be invalid
2505        if let Some(inst) = response.data.first_mut() {
2506            inst.ct_mult = "invalid_number".to_string();
2507        }
2508
2509        let okx_inst = response.data.first().unwrap();
2510        let result = parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default());
2511
2512        // Should error instead of silently defaulting to 1.0
2513        assert!(result.is_err());
2514        assert!(
2515            result
2516                .unwrap_err()
2517                .to_string()
2518                .contains("Failed to parse `ct_mult`")
2519        );
2520    }
2521
2522    #[rstest]
2523    fn test_parse_spot_instrument_with_fees() {
2524        let json_data = load_test_json("http_get_instruments_spot.json");
2525        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2526        let okx_inst = response.data.first().unwrap();
2527
2528        let maker_fee = Some(dec!(0.0008));
2529        let taker_fee = Some(dec!(0.0010));
2530
2531        let instrument = parse_spot_instrument(
2532            okx_inst,
2533            None,
2534            None,
2535            maker_fee,
2536            taker_fee,
2537            UnixNanos::default(),
2538        )
2539        .unwrap();
2540
2541        // Should apply the provided fees to the instrument
2542        if let InstrumentAny::CurrencyPair(pair) = instrument {
2543            assert_eq!(pair.maker_fee, dec!(0.0008));
2544            assert_eq!(pair.taker_fee, dec!(0.0010));
2545        } else {
2546            panic!("Expected CurrencyPair instrument");
2547        }
2548    }
2549
2550    #[rstest]
2551    fn test_parse_instrument_any_passes_through_fees() {
2552        // parse_instrument_any receives fees already converted to Nautilus format
2553        // (negation happens in HTTP client when parsing OKX API values)
2554        let json_data = load_test_json("http_get_instruments_spot.json");
2555        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2556        let okx_inst = response.data.first().unwrap();
2557
2558        // Fees are already in Nautilus convention (negated by HTTP client)
2559        let maker_fee = Some(dec!(-0.00025)); // Nautilus: rebate (negative)
2560        let taker_fee = Some(dec!(0.00050)); // Nautilus: commission (positive)
2561
2562        let instrument = parse_instrument_any(
2563            okx_inst,
2564            None,
2565            None,
2566            maker_fee,
2567            taker_fee,
2568            UnixNanos::default(),
2569        )
2570        .unwrap()
2571        .expect("Should parse spot instrument");
2572
2573        // Fees should pass through unchanged
2574        if let InstrumentAny::CurrencyPair(pair) = instrument {
2575            assert_eq!(pair.maker_fee, dec!(-0.00025));
2576            assert_eq!(pair.taker_fee, dec!(0.00050));
2577        } else {
2578            panic!("Expected CurrencyPair instrument");
2579        }
2580    }
2581
2582    #[rstest]
2583    fn test_parse_swap_instrument() {
2584        let json_data = load_test_json("http_get_instruments_swap.json");
2585        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2586        let okx_inst: &OKXInstrument = response
2587            .data
2588            .first()
2589            .expect("Test data must have an instrument");
2590
2591        let instrument =
2592            parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2593
2594        assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-SWAP.OKX"));
2595        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-SWAP"));
2596        assert_eq!(instrument.underlying(), None);
2597        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2598        assert_eq!(instrument.quote_currency(), Currency::USD());
2599        assert_eq!(instrument.settlement_currency(), Currency::BTC());
2600        assert!(instrument.is_inverse());
2601        assert_eq!(instrument.price_precision(), 1);
2602        assert_eq!(instrument.size_precision(), 0);
2603        assert_eq!(instrument.price_increment(), Price::from("0.1"));
2604        assert_eq!(instrument.size_increment(), Quantity::from(1));
2605        assert_eq!(instrument.multiplier(), Quantity::from(100));
2606        assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2607        assert_eq!(instrument.max_quantity(), Some(Quantity::from(30000)));
2608        assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2609        assert_eq!(instrument.max_notional(), None);
2610        assert_eq!(instrument.min_notional(), None);
2611        assert_eq!(instrument.max_price(), None);
2612        assert_eq!(instrument.min_price(), None);
2613    }
2614
2615    #[rstest]
2616    fn test_parse_linear_swap_instrument() {
2617        let json_data = load_test_json("http_get_instruments_swap.json");
2618        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2619
2620        let okx_inst = response
2621            .data
2622            .iter()
2623            .find(|i| i.inst_id == "ETH-USDT-SWAP")
2624            .expect("ETH-USDT-SWAP must be in test data");
2625
2626        let instrument =
2627            parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2628
2629        assert_eq!(instrument.id(), InstrumentId::from("ETH-USDT-SWAP.OKX"));
2630        assert_eq!(instrument.raw_symbol(), Symbol::from("ETH-USDT-SWAP"));
2631        assert_eq!(instrument.base_currency(), Some(Currency::ETH()));
2632        assert_eq!(instrument.quote_currency(), Currency::USDT());
2633        assert_eq!(instrument.settlement_currency(), Currency::USDT());
2634        assert!(!instrument.is_inverse());
2635        assert_eq!(instrument.multiplier(), Quantity::from("0.1"));
2636        assert_eq!(instrument.price_precision(), 2);
2637        assert_eq!(instrument.size_precision(), 2);
2638        assert_eq!(instrument.price_increment(), Price::from("0.01"));
2639        assert_eq!(instrument.size_increment(), Quantity::from("0.01"));
2640        assert_eq!(instrument.lot_size(), Some(Quantity::from("0.01")));
2641        assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.01")));
2642        assert_eq!(instrument.max_quantity(), Some(Quantity::from(20000)));
2643    }
2644
2645    #[rstest]
2646    fn test_fee_field_selection_for_contract_types() {
2647        // Mock OKXFeeRate with different values for crypto vs USDT-margined
2648        let maker_crypto = "0.0002"; // Crypto-margined maker fee
2649        let taker_crypto = "0.0005"; // Crypto-margined taker fee
2650        let maker_usdt = "0.0008"; // USDT-margined maker fee
2651        let taker_usdt = "0.0010"; // USDT-margined taker fee
2652
2653        // Test Linear (USDT-margined) - should use maker_u/taker_u
2654        let is_usdt_margined = true;
2655        let (maker_str, taker_str) = if is_usdt_margined {
2656            (maker_usdt, taker_usdt)
2657        } else {
2658            (maker_crypto, taker_crypto)
2659        };
2660
2661        assert_eq!(maker_str, "0.0008");
2662        assert_eq!(taker_str, "0.0010");
2663
2664        let maker_fee = Decimal::from_str(maker_str).unwrap();
2665        let taker_fee = Decimal::from_str(taker_str).unwrap();
2666
2667        assert_eq!(maker_fee, dec!(0.0008));
2668        assert_eq!(taker_fee, dec!(0.0010));
2669
2670        // Test Inverse (crypto-margined) - should use maker/taker
2671        let is_usdt_margined = false;
2672        let (maker_str, taker_str) = if is_usdt_margined {
2673            (maker_usdt, taker_usdt)
2674        } else {
2675            (maker_crypto, taker_crypto)
2676        };
2677
2678        assert_eq!(maker_str, "0.0002");
2679        assert_eq!(taker_str, "0.0005");
2680
2681        let maker_fee = Decimal::from_str(maker_str).unwrap();
2682        let taker_fee = Decimal::from_str(taker_str).unwrap();
2683
2684        assert_eq!(maker_fee, dec!(0.0002));
2685        assert_eq!(taker_fee, dec!(0.0005));
2686    }
2687
2688    #[rstest]
2689    fn test_parse_futures_instrument() {
2690        let json_data = load_test_json("http_get_instruments_futures.json");
2691        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2692        let okx_inst: &OKXInstrument = response
2693            .data
2694            .first()
2695            .expect("Test data must have an instrument");
2696
2697        let instrument =
2698            parse_futures_instrument(okx_inst, None, None, None, None, UnixNanos::default())
2699                .unwrap();
2700
2701        assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-241220.OKX"));
2702        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-241220"));
2703        assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
2704        assert_eq!(instrument.quote_currency(), Currency::USD());
2705        assert_eq!(instrument.settlement_currency(), Currency::BTC());
2706        assert!(instrument.is_inverse());
2707        assert_eq!(instrument.price_precision(), 1);
2708        assert_eq!(instrument.size_precision(), 0);
2709        assert_eq!(instrument.price_increment(), Price::from("0.1"));
2710        assert_eq!(instrument.size_increment(), Quantity::from(1));
2711        assert_eq!(instrument.multiplier(), Quantity::from(100));
2712        assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2713        assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2714        assert_eq!(instrument.max_quantity(), Some(Quantity::from(10000)));
2715    }
2716
2717    #[rstest]
2718    fn test_parse_option_instrument() {
2719        let json_data = load_test_json("http_get_instruments_option.json");
2720        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2721        let okx_inst: &OKXInstrument = response
2722            .data
2723            .first()
2724            .expect("Test data must have an instrument");
2725
2726        let instrument =
2727            parse_option_instrument(okx_inst, None, None, None, None, UnixNanos::default())
2728                .unwrap();
2729
2730        assert_eq!(
2731            instrument.id(),
2732            InstrumentId::from("BTC-USD-241217-92000-C.OKX")
2733        );
2734        assert_eq!(
2735            instrument.raw_symbol(),
2736            Symbol::from("BTC-USD-241217-92000-C")
2737        );
2738        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2739        assert_eq!(instrument.quote_currency(), Currency::USD());
2740        assert_eq!(instrument.settlement_currency(), Currency::BTC());
2741        assert!(instrument.is_inverse());
2742        assert_eq!(instrument.price_precision(), 4);
2743        assert_eq!(instrument.size_precision(), 0);
2744        assert_eq!(instrument.price_increment(), Price::from("0.0001"));
2745        assert_eq!(instrument.size_increment(), Quantity::from(1));
2746        assert_eq!(instrument.multiplier(), Quantity::from("0.01"));
2747        assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2748        assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2749        assert_eq!(instrument.max_quantity(), Some(Quantity::from(5000)));
2750        assert_eq!(instrument.max_notional(), None);
2751        assert_eq!(instrument.min_notional(), None);
2752        assert_eq!(instrument.max_price(), None);
2753        assert_eq!(instrument.min_price(), None);
2754    }
2755
2756    #[rstest]
2757    fn test_parse_account_state() {
2758        let json_data = load_test_json("http_get_account_balance.json");
2759        let response: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
2760        let okx_account = response
2761            .data
2762            .first()
2763            .expect("Test data must have an account");
2764
2765        let account_id = AccountId::new("OKX-001");
2766        let account_state =
2767            parse_account_state(okx_account, account_id, UnixNanos::default()).unwrap();
2768
2769        assert_eq!(account_state.account_id, account_id);
2770        assert_eq!(account_state.account_type, AccountType::Margin);
2771        assert_eq!(account_state.balances.len(), 1);
2772        assert_eq!(account_state.margins.len(), 0); // No margins in this test data (spot account)
2773        assert!(account_state.is_reported);
2774
2775        // Check the USDT balance details
2776        let usdt_balance = &account_state.balances[0];
2777        assert_eq!(
2778            usdt_balance.total,
2779            Money::new(94.42612990333333, Currency::USDT())
2780        );
2781        assert_eq!(
2782            usdt_balance.free,
2783            Money::new(94.42612990333333, Currency::USDT())
2784        );
2785        assert_eq!(usdt_balance.locked, Money::new(0.0, Currency::USDT()));
2786    }
2787
2788    #[rstest]
2789    fn test_parse_account_state_with_margins() {
2790        // Create test data with margin requirements
2791        let account_json = r#"{
2792            "adjEq": "10000.0",
2793            "borrowFroz": "0",
2794            "details": [{
2795                "accAvgPx": "",
2796                "availBal": "8000.0",
2797                "availEq": "8000.0",
2798                "borrowFroz": "0",
2799                "cashBal": "10000.0",
2800                "ccy": "USDT",
2801                "clSpotInUseAmt": "0",
2802                "coinUsdPrice": "1.0",
2803                "colBorrAutoConversion": "0",
2804                "collateralEnabled": false,
2805                "collateralRestrict": false,
2806                "crossLiab": "0",
2807                "disEq": "10000.0",
2808                "eq": "10000.0",
2809                "eqUsd": "10000.0",
2810                "fixedBal": "0",
2811                "frozenBal": "2000.0",
2812                "imr": "0",
2813                "interest": "0",
2814                "isoEq": "0",
2815                "isoLiab": "0",
2816                "isoUpl": "0",
2817                "liab": "0",
2818                "maxLoan": "0",
2819                "mgnRatio": "0",
2820                "maxSpotInUseAmt": "0",
2821                "mmr": "0",
2822                "notionalLever": "0",
2823                "openAvgPx": "",
2824                "ordFrozen": "2000.0",
2825                "rewardBal": "0",
2826                "smtSyncEq": "0",
2827                "spotBal": "0",
2828                "spotCopyTradingEq": "0",
2829                "spotInUseAmt": "0",
2830                "spotIsoBal": "0",
2831                "spotUpl": "0",
2832                "spotUplRatio": "0",
2833                "stgyEq": "0",
2834                "totalPnl": "0",
2835                "totalPnlRatio": "0",
2836                "twap": "0",
2837                "uTime": "1704067200000",
2838                "upl": "0",
2839                "uplLiab": "0"
2840            }],
2841            "imr": "500.25",
2842            "isoEq": "0",
2843            "mgnRatio": "20.5",
2844            "mmr": "250.75",
2845            "notionalUsd": "5000.0",
2846            "notionalUsdForBorrow": "0",
2847            "notionalUsdForFutures": "0",
2848            "notionalUsdForOption": "0",
2849            "notionalUsdForSwap": "5000.0",
2850            "ordFroz": "2000.0",
2851            "totalEq": "10000.0",
2852            "uTime": "1704067200000",
2853            "upl": "0"
2854        }"#;
2855
2856        let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
2857        let account_id = AccountId::new("OKX-001");
2858        let account_state =
2859            parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
2860
2861        // Verify account details
2862        assert_eq!(account_state.account_id, account_id);
2863        assert_eq!(account_state.account_type, AccountType::Margin);
2864        assert_eq!(account_state.balances.len(), 1);
2865
2866        // Verify margin information was parsed
2867        assert_eq!(account_state.margins.len(), 1);
2868        let margin = &account_state.margins[0];
2869
2870        // Check margin values
2871        assert_eq!(margin.initial, Money::new(500.25, Currency::USD()));
2872        assert_eq!(margin.maintenance, Money::new(250.75, Currency::USD()));
2873        assert_eq!(margin.currency, Currency::USD());
2874        assert_eq!(margin.instrument_id.symbol.as_str(), "ACCOUNT");
2875        assert_eq!(margin.instrument_id.venue.as_str(), "OKX");
2876
2877        // Check the USDT balance details
2878        let usdt_balance = &account_state.balances[0];
2879        assert_eq!(usdt_balance.total, Money::new(10000.0, Currency::USDT()));
2880        assert_eq!(usdt_balance.free, Money::new(8000.0, Currency::USDT()));
2881        assert_eq!(usdt_balance.locked, Money::new(2000.0, Currency::USDT()));
2882    }
2883
2884    #[rstest]
2885    fn test_parse_account_state_empty_margins() {
2886        // Create test data with empty margin strings (common for spot accounts)
2887        let account_json = r#"{
2888            "adjEq": "",
2889            "borrowFroz": "",
2890            "details": [{
2891                "accAvgPx": "",
2892                "availBal": "1000.0",
2893                "availEq": "1000.0",
2894                "borrowFroz": "0",
2895                "cashBal": "1000.0",
2896                "ccy": "BTC",
2897                "clSpotInUseAmt": "0",
2898                "coinUsdPrice": "50000.0",
2899                "colBorrAutoConversion": "0",
2900                "collateralEnabled": false,
2901                "collateralRestrict": false,
2902                "crossLiab": "0",
2903                "disEq": "50000.0",
2904                "eq": "1000.0",
2905                "eqUsd": "50000.0",
2906                "fixedBal": "0",
2907                "frozenBal": "0",
2908                "imr": "0",
2909                "interest": "0",
2910                "isoEq": "0",
2911                "isoLiab": "0",
2912                "isoUpl": "0",
2913                "liab": "0",
2914                "maxLoan": "0",
2915                "mgnRatio": "0",
2916                "maxSpotInUseAmt": "0",
2917                "mmr": "0",
2918                "notionalLever": "0",
2919                "openAvgPx": "",
2920                "ordFrozen": "0",
2921                "rewardBal": "0",
2922                "smtSyncEq": "0",
2923                "spotBal": "0",
2924                "spotCopyTradingEq": "0",
2925                "spotInUseAmt": "0",
2926                "spotIsoBal": "0",
2927                "spotUpl": "0",
2928                "spotUplRatio": "0",
2929                "stgyEq": "0",
2930                "totalPnl": "0",
2931                "totalPnlRatio": "0",
2932                "twap": "0",
2933                "uTime": "1704067200000",
2934                "upl": "0",
2935                "uplLiab": "0"
2936            }],
2937            "imr": "",
2938            "isoEq": "0",
2939            "mgnRatio": "",
2940            "mmr": "",
2941            "notionalUsd": "",
2942            "notionalUsdForBorrow": "",
2943            "notionalUsdForFutures": "",
2944            "notionalUsdForOption": "",
2945            "notionalUsdForSwap": "",
2946            "ordFroz": "",
2947            "totalEq": "50000.0",
2948            "uTime": "1704067200000",
2949            "upl": "0"
2950        }"#;
2951
2952        let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
2953        let account_id = AccountId::new("OKX-SPOT");
2954        let account_state =
2955            parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
2956
2957        // Verify no margins are created when fields are empty
2958        assert_eq!(account_state.margins.len(), 0);
2959        assert_eq!(account_state.balances.len(), 1);
2960
2961        // Check the BTC balance
2962        let btc_balance = &account_state.balances[0];
2963        assert_eq!(btc_balance.total, Money::new(1000.0, Currency::BTC()));
2964    }
2965
2966    #[rstest]
2967    fn test_parse_order_status_report() {
2968        let json_data = load_test_json("http_get_orders_history.json");
2969        let response: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
2970        let okx_order = response
2971            .data
2972            .first()
2973            .expect("Test data must have an order")
2974            .clone();
2975
2976        let account_id = AccountId::new("OKX-001");
2977        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2978        let order_report = parse_order_status_report(
2979            &okx_order,
2980            account_id,
2981            instrument_id,
2982            2,
2983            8,
2984            UnixNanos::default(),
2985        )
2986        .unwrap();
2987
2988        assert_eq!(order_report.account_id, account_id);
2989        assert_eq!(order_report.instrument_id, instrument_id);
2990        assert_eq!(order_report.quantity, Quantity::from("0.03000000"));
2991        assert_eq!(order_report.filled_qty, Quantity::from("0.03000000"));
2992        assert_eq!(order_report.order_side, OrderSide::Buy);
2993        assert_eq!(order_report.order_type, OrderType::Market);
2994        assert_eq!(order_report.order_status, OrderStatus::Filled);
2995    }
2996
2997    #[rstest]
2998    fn test_parse_position_status_report() {
2999        let json_data = load_test_json("http_get_positions.json");
3000        let response: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
3001        let okx_position = response
3002            .data
3003            .first()
3004            .expect("Test data must have a position")
3005            .clone();
3006
3007        let account_id = AccountId::new("OKX-001");
3008        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3009        let position_report = parse_position_status_report(
3010            okx_position,
3011            account_id,
3012            instrument_id,
3013            8,
3014            UnixNanos::default(),
3015        )
3016        .unwrap();
3017
3018        assert_eq!(position_report.account_id, account_id);
3019        assert_eq!(position_report.instrument_id, instrument_id);
3020    }
3021
3022    #[rstest]
3023    fn test_parse_trade_tick() {
3024        let json_data = load_test_json("http_get_trades.json");
3025        let response: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
3026        let okx_trade = response.data.first().expect("Test data must have a trade");
3027
3028        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3029        let trade_tick =
3030            parse_trade_tick(okx_trade, instrument_id, 2, 8, UnixNanos::default()).unwrap();
3031
3032        assert_eq!(trade_tick.instrument_id, instrument_id);
3033        assert_eq!(trade_tick.price, Price::from("102537.90"));
3034        assert_eq!(trade_tick.size, Quantity::from("0.00013669"));
3035        assert_eq!(trade_tick.aggressor_side, AggressorSide::Seller);
3036        assert_eq!(trade_tick.trade_id, TradeId::new("734864333"));
3037    }
3038
3039    #[rstest]
3040    fn test_parse_mark_price_update() {
3041        let json_data = load_test_json("http_get_mark_price.json");
3042        let response: OKXResponse<crate::http::models::OKXMarkPrice> =
3043            serde_json::from_str(&json_data).unwrap();
3044        let okx_mark_price = response
3045            .data
3046            .first()
3047            .expect("Test data must have a mark price");
3048
3049        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3050        let mark_price_update =
3051            parse_mark_price_update(okx_mark_price, instrument_id, 2, UnixNanos::default())
3052                .unwrap();
3053
3054        assert_eq!(mark_price_update.instrument_id, instrument_id);
3055        assert_eq!(mark_price_update.value, Price::from("84660.10"));
3056        assert_eq!(
3057            mark_price_update.ts_event,
3058            UnixNanos::from(1744590349506000000)
3059        );
3060    }
3061
3062    #[rstest]
3063    fn test_parse_index_price_update() {
3064        let json_data = load_test_json("http_get_index_price.json");
3065        let response: OKXResponse<crate::http::models::OKXIndexTicker> =
3066            serde_json::from_str(&json_data).unwrap();
3067        let okx_index_ticker = response
3068            .data
3069            .first()
3070            .expect("Test data must have an index ticker");
3071
3072        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3073        let index_price_update =
3074            parse_index_price_update(okx_index_ticker, instrument_id, 2, UnixNanos::default())
3075                .unwrap();
3076
3077        assert_eq!(index_price_update.instrument_id, instrument_id);
3078        assert_eq!(index_price_update.value, Price::from("103895.00"));
3079        assert_eq!(
3080            index_price_update.ts_event,
3081            UnixNanos::from(1746942707815000000)
3082        );
3083    }
3084
3085    #[rstest]
3086    fn test_parse_candlestick() {
3087        let json_data = load_test_json("http_get_candlesticks.json");
3088        let response: OKXResponse<crate::http::models::OKXCandlestick> =
3089            serde_json::from_str(&json_data).unwrap();
3090        let okx_candlestick = response
3091            .data
3092            .first()
3093            .expect("Test data must have a candlestick");
3094
3095        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3096        let bar_type = BarType::new(
3097            instrument_id,
3098            BAR_SPEC_1_DAY_LAST,
3099            AggregationSource::External,
3100        );
3101        let bar = parse_candlestick(okx_candlestick, bar_type, 2, 8, UnixNanos::default()).unwrap();
3102
3103        assert_eq!(bar.bar_type, bar_type);
3104        assert_eq!(bar.open, Price::from("33528.60"));
3105        assert_eq!(bar.high, Price::from("33870.00"));
3106        assert_eq!(bar.low, Price::from("33528.60"));
3107        assert_eq!(bar.close, Price::from("33783.90"));
3108        assert_eq!(bar.volume, Quantity::from("778.83800000"));
3109        assert_eq!(bar.ts_event, UnixNanos::from(1625097600000000000));
3110    }
3111
3112    #[rstest]
3113    fn test_parse_millisecond_timestamp() {
3114        let timestamp_ms = 1625097600000u64;
3115        let result = parse_millisecond_timestamp(timestamp_ms);
3116        assert_eq!(result, UnixNanos::from(1625097600000000000));
3117    }
3118
3119    #[rstest]
3120    fn test_parse_rfc3339_timestamp() {
3121        let timestamp_str = "2021-07-01T00:00:00.000Z";
3122        let result = parse_rfc3339_timestamp(timestamp_str).unwrap();
3123        assert_eq!(result, UnixNanos::from(1625097600000000000));
3124
3125        // Test with timezone
3126        let timestamp_str_tz = "2021-07-01T08:00:00.000+08:00";
3127        let result_tz = parse_rfc3339_timestamp(timestamp_str_tz).unwrap();
3128        assert_eq!(result_tz, UnixNanos::from(1625097600000000000));
3129
3130        // Test error case
3131        let invalid_timestamp = "invalid-timestamp";
3132        assert!(parse_rfc3339_timestamp(invalid_timestamp).is_err());
3133    }
3134
3135    #[rstest]
3136    fn test_parse_price() {
3137        let price_str = "42219.5";
3138        let precision = 2;
3139        let result = parse_price(price_str, precision).unwrap();
3140        assert_eq!(result, Price::from("42219.50"));
3141
3142        // Test error case
3143        let invalid_price = "invalid-price";
3144        assert!(parse_price(invalid_price, precision).is_err());
3145    }
3146
3147    #[rstest]
3148    fn test_parse_quantity() {
3149        let quantity_str = "0.12345678";
3150        let precision = 8;
3151        let result = parse_quantity(quantity_str, precision).unwrap();
3152        assert_eq!(result, Quantity::from("0.12345678"));
3153
3154        // Test error case
3155        let invalid_quantity = "invalid-quantity";
3156        assert!(parse_quantity(invalid_quantity, precision).is_err());
3157    }
3158
3159    #[rstest]
3160    fn test_parse_aggressor_side() {
3161        assert_eq!(
3162            parse_aggressor_side(&Some(OKXSide::Buy)),
3163            AggressorSide::Buyer
3164        );
3165        assert_eq!(
3166            parse_aggressor_side(&Some(OKXSide::Sell)),
3167            AggressorSide::Seller
3168        );
3169        assert_eq!(parse_aggressor_side(&None), AggressorSide::NoAggressor);
3170    }
3171
3172    #[rstest]
3173    fn test_parse_execution_type() {
3174        assert_eq!(
3175            parse_execution_type(&Some(OKXExecType::Maker)),
3176            LiquiditySide::Maker
3177        );
3178        assert_eq!(
3179            parse_execution_type(&Some(OKXExecType::Taker)),
3180            LiquiditySide::Taker
3181        );
3182        assert_eq!(parse_execution_type(&None), LiquiditySide::NoLiquiditySide);
3183    }
3184
3185    #[rstest]
3186    fn test_parse_position_side() {
3187        assert_eq!(parse_position_side(Some(100)), PositionSide::Long);
3188        assert_eq!(parse_position_side(Some(-100)), PositionSide::Short);
3189        assert_eq!(parse_position_side(Some(0)), PositionSide::Flat);
3190        assert_eq!(parse_position_side(None), PositionSide::Flat);
3191    }
3192
3193    #[rstest]
3194    fn test_parse_client_order_id() {
3195        let valid_id = "client_order_123";
3196        let result = parse_client_order_id(valid_id);
3197        assert_eq!(result, Some(ClientOrderId::new(valid_id)));
3198
3199        let empty_id = "";
3200        let result_empty = parse_client_order_id(empty_id);
3201        assert_eq!(result_empty, None);
3202    }
3203
3204    #[rstest]
3205    fn test_deserialize_empty_string_as_none() {
3206        let json_with_empty = r#""""#;
3207        let result: Option<String> = serde_json::from_str(json_with_empty).unwrap();
3208        let processed = result.filter(|s| !s.is_empty());
3209        assert_eq!(processed, None);
3210
3211        let json_with_value = r#""test_value""#;
3212        let result: Option<String> = serde_json::from_str(json_with_value).unwrap();
3213        let processed = result.filter(|s| !s.is_empty());
3214        assert_eq!(processed, Some("test_value".to_string()));
3215    }
3216
3217    #[rstest]
3218    fn test_deserialize_string_to_u64() {
3219        use serde::Deserialize;
3220
3221        #[derive(Deserialize)]
3222        struct TestStruct {
3223            #[serde(deserialize_with = "deserialize_string_to_u64")]
3224            value: u64,
3225        }
3226
3227        let json_value = r#"{"value": "12345"}"#;
3228        let result: TestStruct = serde_json::from_str(json_value).unwrap();
3229        assert_eq!(result.value, 12345);
3230
3231        let json_empty = r#"{"value": ""}"#;
3232        let result_empty: TestStruct = serde_json::from_str(json_empty).unwrap();
3233        assert_eq!(result_empty.value, 0);
3234    }
3235
3236    #[rstest]
3237    fn test_fill_report_parsing() {
3238        // Create a mock transaction detail for testing
3239        let transaction_detail = crate::http::models::OKXTransactionDetail {
3240            inst_type: OKXInstrumentType::Spot,
3241            inst_id: Ustr::from("BTC-USDT"),
3242            trade_id: Ustr::from("12345"),
3243            ord_id: Ustr::from("67890"),
3244            cl_ord_id: Ustr::from("client_123"),
3245            bill_id: Ustr::from("bill_456"),
3246            fill_px: "42219.5".to_string(),
3247            fill_sz: "0.001".to_string(),
3248            side: OKXSide::Buy,
3249            exec_type: OKXExecType::Taker,
3250            fee_ccy: "USDT".to_string(),
3251            fee: Some("0.042".to_string()),
3252            ts: 1625097600000,
3253        };
3254
3255        let account_id = AccountId::new("OKX-001");
3256        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3257        let fill_report = parse_fill_report(
3258            transaction_detail,
3259            account_id,
3260            instrument_id,
3261            2,
3262            8,
3263            UnixNanos::default(),
3264        )
3265        .unwrap();
3266
3267        assert_eq!(fill_report.account_id, account_id);
3268        assert_eq!(fill_report.instrument_id, instrument_id);
3269        assert_eq!(fill_report.trade_id, TradeId::new("12345"));
3270        assert_eq!(fill_report.venue_order_id, VenueOrderId::new("67890"));
3271        assert_eq!(fill_report.order_side, OrderSide::Buy);
3272        assert_eq!(fill_report.last_px, Price::from("42219.50"));
3273        assert_eq!(fill_report.last_qty, Quantity::from("0.00100000"));
3274        assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
3275    }
3276
3277    #[rstest]
3278    fn test_bar_type_identity_preserved_through_parse() {
3279        use std::str::FromStr;
3280
3281        use crate::http::models::OKXCandlestick;
3282
3283        // Create a BarType
3284        let bar_type = BarType::from_str("ETH-USDT-SWAP.OKX-1-MINUTE-LAST-EXTERNAL").unwrap();
3285
3286        // Create sample candlestick data
3287        let raw_candlestick = OKXCandlestick(
3288            "1721807460000".to_string(), // timestamp
3289            "3177.9".to_string(),        // open
3290            "3177.9".to_string(),        // high
3291            "3177.7".to_string(),        // low
3292            "3177.8".to_string(),        // close
3293            "18.603".to_string(),        // volume
3294            "59054.8231".to_string(),    // turnover
3295            "18.603".to_string(),        // base_volume
3296            "1".to_string(),             // count
3297        );
3298
3299        // Parse the candlestick
3300        let bar =
3301            parse_candlestick(&raw_candlestick, bar_type, 1, 3, UnixNanos::default()).unwrap();
3302
3303        // Verify that the BarType is preserved exactly
3304        assert_eq!(
3305            bar.bar_type, bar_type,
3306            "BarType must be preserved exactly through parsing"
3307        );
3308    }
3309
3310    #[rstest]
3311    fn test_deserialize_vip_level_all_formats() {
3312        use serde::Deserialize;
3313        use serde_json;
3314
3315        #[derive(Deserialize)]
3316        struct TestFeeRate {
3317            #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3318            level: OKXVipLevel,
3319        }
3320
3321        // Test VIP prefix format
3322        let json = r#"{"level":"VIP4"}"#;
3323        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3324        assert_eq!(result.level, OKXVipLevel::Vip4);
3325
3326        let json = r#"{"level":"VIP5"}"#;
3327        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3328        assert_eq!(result.level, OKXVipLevel::Vip5);
3329
3330        // Test Lv prefix format
3331        let json = r#"{"level":"Lv1"}"#;
3332        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3333        assert_eq!(result.level, OKXVipLevel::Vip1);
3334
3335        let json = r#"{"level":"Lv0"}"#;
3336        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3337        assert_eq!(result.level, OKXVipLevel::Vip0);
3338
3339        let json = r#"{"level":"Lv9"}"#;
3340        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3341        assert_eq!(result.level, OKXVipLevel::Vip9);
3342    }
3343
3344    #[rstest]
3345    fn test_deserialize_vip_level_empty_string() {
3346        use serde::Deserialize;
3347        use serde_json;
3348
3349        #[derive(Deserialize)]
3350        struct TestFeeRate {
3351            #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3352            level: OKXVipLevel,
3353        }
3354
3355        // Empty string should default to VIP0
3356        let json = r#"{"level":""}"#;
3357        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3358        assert_eq!(result.level, OKXVipLevel::Vip0);
3359    }
3360
3361    #[rstest]
3362    fn test_deserialize_vip_level_without_prefix() {
3363        use serde::Deserialize;
3364        use serde_json;
3365
3366        #[derive(Deserialize)]
3367        struct TestFeeRate {
3368            #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3369            level: OKXVipLevel,
3370        }
3371
3372        let json = r#"{"level":"5"}"#;
3373        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3374        assert_eq!(result.level, OKXVipLevel::Vip5);
3375    }
3376
3377    #[rstest]
3378    fn test_parse_position_status_report_net_mode_long() {
3379        // Test Net mode: positive quantity = Long position
3380        let position = OKXPosition {
3381            inst_id: Ustr::from("BTC-USDT-SWAP"),
3382            inst_type: OKXInstrumentType::Swap,
3383            mgn_mode: OKXMarginMode::Cross,
3384            pos_id: Some(Ustr::from("12345")),
3385            pos_side: OKXPositionSide::Net, // Net mode
3386            pos: "1.5".to_string(),         // Positive = Long
3387            base_bal: "1.5".to_string(),
3388            ccy: "BTC".to_string(),
3389            fee: "0.01".to_string(),
3390            lever: "10.0".to_string(),
3391            last: "50000".to_string(),
3392            mark_px: "50000".to_string(),
3393            liq_px: "45000".to_string(),
3394            mmr: "0.1".to_string(),
3395            interest: "0".to_string(),
3396            trade_id: Ustr::from("111"),
3397            notional_usd: "75000".to_string(),
3398            avg_px: "50000".to_string(),
3399            upl: "0".to_string(),
3400            upl_ratio: "0".to_string(),
3401            u_time: 1622559930237,
3402            margin: "0.5".to_string(),
3403            mgn_ratio: "0.01".to_string(),
3404            adl: "0".to_string(),
3405            c_time: "1622559930237".to_string(),
3406            realized_pnl: "0".to_string(),
3407            upl_last_px: "0".to_string(),
3408            upl_ratio_last_px: "0".to_string(),
3409            avail_pos: "1.5".to_string(),
3410            be_px: "0".to_string(),
3411            funding_fee: "0".to_string(),
3412            idx_px: "0".to_string(),
3413            liq_penalty: "0".to_string(),
3414            opt_val: "0".to_string(),
3415            pending_close_ord_liab_val: "0".to_string(),
3416            pnl: "0".to_string(),
3417            pos_ccy: "BTC".to_string(),
3418            quote_bal: "75000".to_string(),
3419            quote_borrowed: "0".to_string(),
3420            quote_interest: "0".to_string(),
3421            spot_in_use_amt: "0".to_string(),
3422            spot_in_use_ccy: "BTC".to_string(),
3423            usd_px: "50000".to_string(),
3424        };
3425
3426        let account_id = AccountId::new("OKX-001");
3427        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3428        let report = parse_position_status_report(
3429            position,
3430            account_id,
3431            instrument_id,
3432            8,
3433            UnixNanos::default(),
3434        )
3435        .unwrap();
3436
3437        assert_eq!(report.account_id, account_id);
3438        assert_eq!(report.instrument_id, instrument_id);
3439        assert_eq!(report.position_side, PositionSide::Long.as_specified());
3440        assert_eq!(report.quantity, Quantity::from("1.5"));
3441        // Net mode: venue_position_id is None (signals NETTING OMS)
3442        assert_eq!(report.venue_position_id, None);
3443    }
3444
3445    #[rstest]
3446    fn test_parse_position_status_report_net_mode_short() {
3447        // Test Net mode: negative quantity = Short position
3448        let position = OKXPosition {
3449            inst_id: Ustr::from("BTC-USDT-SWAP"),
3450            inst_type: OKXInstrumentType::Swap,
3451            mgn_mode: OKXMarginMode::Isolated,
3452            pos_id: Some(Ustr::from("67890")),
3453            pos_side: OKXPositionSide::Net, // Net mode
3454            pos: "-2.3".to_string(),        // Negative = Short
3455            base_bal: "2.3".to_string(),
3456            ccy: "BTC".to_string(),
3457            fee: "0.02".to_string(),
3458            lever: "5.0".to_string(),
3459            last: "50000".to_string(),
3460            mark_px: "50000".to_string(),
3461            liq_px: "55000".to_string(),
3462            mmr: "0.2".to_string(),
3463            interest: "0".to_string(),
3464            trade_id: Ustr::from("222"),
3465            notional_usd: "115000".to_string(),
3466            avg_px: "50000".to_string(),
3467            upl: "0".to_string(),
3468            upl_ratio: "0".to_string(),
3469            u_time: 1622559930237,
3470            margin: "1.0".to_string(),
3471            mgn_ratio: "0.02".to_string(),
3472            adl: "0".to_string(),
3473            c_time: "1622559930237".to_string(),
3474            realized_pnl: "0".to_string(),
3475            upl_last_px: "0".to_string(),
3476            upl_ratio_last_px: "0".to_string(),
3477            avail_pos: "2.3".to_string(),
3478            be_px: "0".to_string(),
3479            funding_fee: "0".to_string(),
3480            idx_px: "0".to_string(),
3481            liq_penalty: "0".to_string(),
3482            opt_val: "0".to_string(),
3483            pending_close_ord_liab_val: "0".to_string(),
3484            pnl: "0".to_string(),
3485            pos_ccy: "BTC".to_string(),
3486            quote_bal: "115000".to_string(),
3487            quote_borrowed: "0".to_string(),
3488            quote_interest: "0".to_string(),
3489            spot_in_use_amt: "0".to_string(),
3490            spot_in_use_ccy: "BTC".to_string(),
3491            usd_px: "50000".to_string(),
3492        };
3493
3494        let account_id = AccountId::new("OKX-001");
3495        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3496        let report = parse_position_status_report(
3497            position,
3498            account_id,
3499            instrument_id,
3500            8,
3501            UnixNanos::default(),
3502        )
3503        .unwrap();
3504
3505        assert_eq!(report.account_id, account_id);
3506        assert_eq!(report.instrument_id, instrument_id);
3507        assert_eq!(report.position_side, PositionSide::Short.as_specified());
3508        assert_eq!(report.quantity, Quantity::from("2.3")); // Absolute value
3509        // Net mode: venue_position_id is None (signals NETTING OMS)
3510        assert_eq!(report.venue_position_id, None);
3511    }
3512
3513    #[rstest]
3514    fn test_parse_position_status_report_net_mode_flat() {
3515        // Test Net mode: zero quantity = Flat position
3516        let position = OKXPosition {
3517            inst_id: Ustr::from("ETH-USDT-SWAP"),
3518            inst_type: OKXInstrumentType::Swap,
3519            mgn_mode: OKXMarginMode::Cross,
3520            pos_id: Some(Ustr::from("99999")),
3521            pos_side: OKXPositionSide::Net, // Net mode
3522            pos: "0".to_string(),           // Zero = Flat
3523            base_bal: "0".to_string(),
3524            ccy: "ETH".to_string(),
3525            fee: "0".to_string(),
3526            lever: "10.0".to_string(),
3527            last: "3000".to_string(),
3528            mark_px: "3000".to_string(),
3529            liq_px: "0".to_string(),
3530            mmr: "0".to_string(),
3531            interest: "0".to_string(),
3532            trade_id: Ustr::from("333"),
3533            notional_usd: "0".to_string(),
3534            avg_px: String::new(),
3535            upl: "0".to_string(),
3536            upl_ratio: "0".to_string(),
3537            u_time: 1622559930237,
3538            margin: "0".to_string(),
3539            mgn_ratio: "0".to_string(),
3540            adl: "0".to_string(),
3541            c_time: "1622559930237".to_string(),
3542            realized_pnl: "0".to_string(),
3543            upl_last_px: "0".to_string(),
3544            upl_ratio_last_px: "0".to_string(),
3545            avail_pos: "0".to_string(),
3546            be_px: "0".to_string(),
3547            funding_fee: "0".to_string(),
3548            idx_px: "0".to_string(),
3549            liq_penalty: "0".to_string(),
3550            opt_val: "0".to_string(),
3551            pending_close_ord_liab_val: "0".to_string(),
3552            pnl: "0".to_string(),
3553            pos_ccy: "ETH".to_string(),
3554            quote_bal: "0".to_string(),
3555            quote_borrowed: "0".to_string(),
3556            quote_interest: "0".to_string(),
3557            spot_in_use_amt: "0".to_string(),
3558            spot_in_use_ccy: "ETH".to_string(),
3559            usd_px: "3000".to_string(),
3560        };
3561
3562        let account_id = AccountId::new("OKX-001");
3563        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
3564        let report = parse_position_status_report(
3565            position,
3566            account_id,
3567            instrument_id,
3568            8,
3569            UnixNanos::default(),
3570        )
3571        .unwrap();
3572
3573        assert_eq!(report.account_id, account_id);
3574        assert_eq!(report.instrument_id, instrument_id);
3575        assert_eq!(report.position_side, PositionSide::Flat.as_specified());
3576        assert_eq!(report.quantity, Quantity::from("0"));
3577        // Net mode: venue_position_id is None (signals NETTING OMS)
3578        assert_eq!(report.venue_position_id, None);
3579    }
3580
3581    #[rstest]
3582    fn test_parse_position_status_report_long_short_mode_long() {
3583        // Test Long/Short mode: posSide="long" with positive quantity
3584        let position = OKXPosition {
3585            inst_id: Ustr::from("BTC-USDT-SWAP"),
3586            inst_type: OKXInstrumentType::Swap,
3587            mgn_mode: OKXMarginMode::Cross,
3588            pos_id: Some(Ustr::from("11111")),
3589            pos_side: OKXPositionSide::Long, // Long/Short mode - Long leg
3590            pos: "3.2".to_string(),          // Positive quantity (always positive in this mode)
3591            base_bal: "3.2".to_string(),
3592            ccy: "BTC".to_string(),
3593            fee: "0.01".to_string(),
3594            lever: "10.0".to_string(),
3595            last: "50000".to_string(),
3596            mark_px: "50000".to_string(),
3597            liq_px: "45000".to_string(),
3598            mmr: "0.1".to_string(),
3599            interest: "0".to_string(),
3600            trade_id: Ustr::from("444"),
3601            notional_usd: "160000".to_string(),
3602            avg_px: "50000".to_string(),
3603            upl: "0".to_string(),
3604            upl_ratio: "0".to_string(),
3605            u_time: 1622559930237,
3606            margin: "1.6".to_string(),
3607            mgn_ratio: "0.01".to_string(),
3608            adl: "0".to_string(),
3609            c_time: "1622559930237".to_string(),
3610            realized_pnl: "0".to_string(),
3611            upl_last_px: "0".to_string(),
3612            upl_ratio_last_px: "0".to_string(),
3613            avail_pos: "3.2".to_string(),
3614            be_px: "0".to_string(),
3615            funding_fee: "0".to_string(),
3616            idx_px: "0".to_string(),
3617            liq_penalty: "0".to_string(),
3618            opt_val: "0".to_string(),
3619            pending_close_ord_liab_val: "0".to_string(),
3620            pnl: "0".to_string(),
3621            pos_ccy: "BTC".to_string(),
3622            quote_bal: "160000".to_string(),
3623            quote_borrowed: "0".to_string(),
3624            quote_interest: "0".to_string(),
3625            spot_in_use_amt: "0".to_string(),
3626            spot_in_use_ccy: "BTC".to_string(),
3627            usd_px: "50000".to_string(),
3628        };
3629
3630        let account_id = AccountId::new("OKX-001");
3631        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3632        let report = parse_position_status_report(
3633            position,
3634            account_id,
3635            instrument_id,
3636            8,
3637            UnixNanos::default(),
3638        )
3639        .unwrap();
3640
3641        assert_eq!(report.account_id, account_id);
3642        assert_eq!(report.instrument_id, instrument_id);
3643        assert_eq!(report.position_side, PositionSide::Long.as_specified());
3644        assert_eq!(report.quantity, Quantity::from("3.2"));
3645        // Long/Short mode - Long leg: "-LONG" suffix
3646        assert_eq!(
3647            report.venue_position_id,
3648            Some(PositionId::new("11111-LONG"))
3649        );
3650    }
3651
3652    #[rstest]
3653    fn test_parse_position_status_report_long_short_mode_short() {
3654        // Test Long/Short mode: posSide="short" with positive quantity
3655        // This is the critical test - positive quantity but SHORT side!
3656        let position = OKXPosition {
3657            inst_id: Ustr::from("BTC-USDT-SWAP"),
3658            inst_type: OKXInstrumentType::Swap,
3659            mgn_mode: OKXMarginMode::Cross,
3660            pos_id: Some(Ustr::from("22222")),
3661            pos_side: OKXPositionSide::Short, // Long/Short mode - Short leg
3662            pos: "1.8".to_string(),           // Positive quantity (always positive in this mode)
3663            base_bal: "1.8".to_string(),
3664            ccy: "BTC".to_string(),
3665            fee: "0.02".to_string(),
3666            lever: "10.0".to_string(),
3667            last: "50000".to_string(),
3668            mark_px: "50000".to_string(),
3669            liq_px: "55000".to_string(),
3670            mmr: "0.2".to_string(),
3671            interest: "0".to_string(),
3672            trade_id: Ustr::from("555"),
3673            notional_usd: "90000".to_string(),
3674            avg_px: "50000".to_string(),
3675            upl: "0".to_string(),
3676            upl_ratio: "0".to_string(),
3677            u_time: 1622559930237,
3678            margin: "0.9".to_string(),
3679            mgn_ratio: "0.02".to_string(),
3680            adl: "0".to_string(),
3681            c_time: "1622559930237".to_string(),
3682            realized_pnl: "0".to_string(),
3683            upl_last_px: "0".to_string(),
3684            upl_ratio_last_px: "0".to_string(),
3685            avail_pos: "1.8".to_string(),
3686            be_px: "0".to_string(),
3687            funding_fee: "0".to_string(),
3688            idx_px: "0".to_string(),
3689            liq_penalty: "0".to_string(),
3690            opt_val: "0".to_string(),
3691            pending_close_ord_liab_val: "0".to_string(),
3692            pnl: "0".to_string(),
3693            pos_ccy: "BTC".to_string(),
3694            quote_bal: "90000".to_string(),
3695            quote_borrowed: "0".to_string(),
3696            quote_interest: "0".to_string(),
3697            spot_in_use_amt: "0".to_string(),
3698            spot_in_use_ccy: "BTC".to_string(),
3699            usd_px: "50000".to_string(),
3700        };
3701
3702        let account_id = AccountId::new("OKX-001");
3703        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3704        let report = parse_position_status_report(
3705            position,
3706            account_id,
3707            instrument_id,
3708            8,
3709            UnixNanos::default(),
3710        )
3711        .unwrap();
3712
3713        assert_eq!(report.account_id, account_id);
3714        assert_eq!(report.instrument_id, instrument_id);
3715        // This is the critical assertion: positive quantity but SHORT side
3716        assert_eq!(report.position_side, PositionSide::Short.as_specified());
3717        assert_eq!(report.quantity, Quantity::from("1.8"));
3718        // Long/Short mode - Short leg: "-SHORT" suffix
3719        assert_eq!(
3720            report.venue_position_id,
3721            Some(PositionId::new("22222-SHORT"))
3722        );
3723    }
3724
3725    #[rstest]
3726    fn test_parse_position_status_report_margin_long() {
3727        // Test MARGIN long position: pos_ccy = base currency (ETH)
3728        let position = OKXPosition {
3729            inst_id: Ustr::from("ETH-USDT"),
3730            inst_type: OKXInstrumentType::Margin,
3731            mgn_mode: OKXMarginMode::Cross,
3732            pos_id: Some(Ustr::from("margin-long-1")),
3733            pos_side: OKXPositionSide::Net,
3734            pos: "1.5".to_string(), // Total position (may include pending)
3735            base_bal: "1.5".to_string(),
3736            ccy: "ETH".to_string(),
3737            fee: "0".to_string(),
3738            lever: "3".to_string(),
3739            last: "4000".to_string(),
3740            mark_px: "4000".to_string(),
3741            liq_px: "3500".to_string(),
3742            mmr: "0.1".to_string(),
3743            interest: "0".to_string(),
3744            trade_id: Ustr::from("trade1"),
3745            notional_usd: "6000".to_string(),
3746            avg_px: "3800".to_string(), // Bought at 3800
3747            upl: "300".to_string(),
3748            upl_ratio: "0.05".to_string(),
3749            u_time: 1622559930237,
3750            margin: "2000".to_string(),
3751            mgn_ratio: "0.33".to_string(),
3752            adl: "0".to_string(),
3753            c_time: "1622559930237".to_string(),
3754            realized_pnl: "0".to_string(),
3755            upl_last_px: "300".to_string(),
3756            upl_ratio_last_px: "0.05".to_string(),
3757            avail_pos: "1.5".to_string(),
3758            be_px: "3800".to_string(),
3759            funding_fee: "0".to_string(),
3760            idx_px: "4000".to_string(),
3761            liq_penalty: "0".to_string(),
3762            opt_val: "0".to_string(),
3763            pending_close_ord_liab_val: "0".to_string(),
3764            pnl: "300".to_string(),
3765            pos_ccy: "ETH".to_string(), // pos_ccy = base = LONG
3766            quote_bal: "0".to_string(),
3767            quote_borrowed: "0".to_string(),
3768            quote_interest: "0".to_string(),
3769            spot_in_use_amt: "0".to_string(),
3770            spot_in_use_ccy: String::new(),
3771            usd_px: "4000".to_string(),
3772        };
3773
3774        let account_id = AccountId::new("OKX-001");
3775        let instrument_id = InstrumentId::from("ETH-USDT.OKX");
3776        let report = parse_position_status_report(
3777            position,
3778            account_id,
3779            instrument_id,
3780            4,
3781            UnixNanos::default(),
3782        )
3783        .unwrap();
3784
3785        assert_eq!(report.account_id, account_id);
3786        assert_eq!(report.instrument_id, instrument_id);
3787        assert_eq!(report.position_side, PositionSide::Long.as_specified());
3788        assert_eq!(report.quantity, Quantity::from("1.5")); // 1.5 ETH in base
3789        assert_eq!(report.venue_position_id, None); // Net mode
3790    }
3791
3792    #[rstest]
3793    fn test_parse_position_status_report_margin_short() {
3794        // Test MARGIN short position: pos_ccy = quote currency (USDT)
3795        // pos is in quote currency and needs conversion to base
3796        let position = OKXPosition {
3797            inst_id: Ustr::from("ETH-USDT"),
3798            inst_type: OKXInstrumentType::Margin,
3799            mgn_mode: OKXMarginMode::Cross,
3800            pos_id: Some(Ustr::from("margin-short-1")),
3801            pos_side: OKXPositionSide::Net,
3802            pos: "244.56".to_string(), // Position in quote currency (USDT)
3803            base_bal: "0".to_string(),
3804            ccy: "USDT".to_string(),
3805            fee: "0".to_string(),
3806            lever: "3".to_string(),
3807            last: "4092".to_string(),
3808            mark_px: "4092".to_string(),
3809            liq_px: "4500".to_string(),
3810            mmr: "0.1".to_string(),
3811            interest: "0".to_string(),
3812            trade_id: Ustr::from("trade2"),
3813            notional_usd: "244.56".to_string(),
3814            avg_px: "4092".to_string(), // Shorted at 4092
3815            upl: "-10".to_string(),
3816            upl_ratio: "-0.04".to_string(),
3817            u_time: 1622559930237,
3818            margin: "100".to_string(),
3819            mgn_ratio: "0.4".to_string(),
3820            adl: "0".to_string(),
3821            c_time: "1622559930237".to_string(),
3822            realized_pnl: "0".to_string(),
3823            upl_last_px: "-10".to_string(),
3824            upl_ratio_last_px: "-0.04".to_string(),
3825            avail_pos: "244.56".to_string(),
3826            be_px: "4092".to_string(),
3827            funding_fee: "0".to_string(),
3828            idx_px: "4092".to_string(),
3829            liq_penalty: "0".to_string(),
3830            opt_val: "0".to_string(),
3831            pending_close_ord_liab_val: "0".to_string(),
3832            pnl: "-10".to_string(),
3833            pos_ccy: "USDT".to_string(), // pos_ccy = quote indicates SHORT, pos in USDT
3834            quote_bal: "244.56".to_string(),
3835            quote_borrowed: "0".to_string(),
3836            quote_interest: "0".to_string(),
3837            spot_in_use_amt: "0".to_string(),
3838            spot_in_use_ccy: String::new(),
3839            usd_px: "4092".to_string(),
3840        };
3841
3842        let account_id = AccountId::new("OKX-001");
3843        let instrument_id = InstrumentId::from("ETH-USDT.OKX");
3844        let report = parse_position_status_report(
3845            position,
3846            account_id,
3847            instrument_id,
3848            4,
3849            UnixNanos::default(),
3850        )
3851        .unwrap();
3852
3853        assert_eq!(report.account_id, account_id);
3854        assert_eq!(report.instrument_id, instrument_id);
3855        assert_eq!(report.position_side, PositionSide::Short.as_specified());
3856        // Position is 244.56 USDT / 4092 USDT/ETH = 0.0597... ETH
3857        assert_eq!(report.quantity.to_string(), "0.0598");
3858        assert_eq!(report.venue_position_id, None); // Net mode
3859    }
3860
3861    #[rstest]
3862    fn test_parse_position_status_report_margin_flat() {
3863        // Test MARGIN flat position: pos_ccy is empty string
3864        let position = OKXPosition {
3865            inst_id: Ustr::from("ETH-USDT"),
3866            inst_type: OKXInstrumentType::Margin,
3867            mgn_mode: OKXMarginMode::Cross,
3868            pos_id: Some(Ustr::from("margin-flat-1")),
3869            pos_side: OKXPositionSide::Net,
3870            pos: "0".to_string(),
3871            base_bal: "0".to_string(),
3872            ccy: "ETH".to_string(),
3873            fee: "0".to_string(),
3874            lever: "0".to_string(),
3875            last: "4000".to_string(),
3876            mark_px: "4000".to_string(),
3877            liq_px: "0".to_string(),
3878            mmr: "0".to_string(),
3879            interest: "0".to_string(),
3880            trade_id: Ustr::from(""),
3881            notional_usd: "0".to_string(),
3882            avg_px: String::new(),
3883            upl: "0".to_string(),
3884            upl_ratio: "0".to_string(),
3885            u_time: 1622559930237,
3886            margin: "0".to_string(),
3887            mgn_ratio: "0".to_string(),
3888            adl: "0".to_string(),
3889            c_time: "1622559930237".to_string(),
3890            realized_pnl: "0".to_string(),
3891            upl_last_px: "0".to_string(),
3892            upl_ratio_last_px: "0".to_string(),
3893            avail_pos: "0".to_string(),
3894            be_px: "0".to_string(),
3895            funding_fee: "0".to_string(),
3896            idx_px: "0".to_string(),
3897            liq_penalty: "0".to_string(),
3898            opt_val: "0".to_string(),
3899            pending_close_ord_liab_val: "0".to_string(),
3900            pnl: "0".to_string(),
3901            pos_ccy: String::new(), // Empty pos_ccy = FLAT
3902            quote_bal: "0".to_string(),
3903            quote_borrowed: "0".to_string(),
3904            quote_interest: "0".to_string(),
3905            spot_in_use_amt: "0".to_string(),
3906            spot_in_use_ccy: String::new(),
3907            usd_px: "0".to_string(),
3908        };
3909
3910        let account_id = AccountId::new("OKX-001");
3911        let instrument_id = InstrumentId::from("ETH-USDT.OKX");
3912        let report = parse_position_status_report(
3913            position,
3914            account_id,
3915            instrument_id,
3916            4,
3917            UnixNanos::default(),
3918        )
3919        .unwrap();
3920
3921        assert_eq!(report.account_id, account_id);
3922        assert_eq!(report.instrument_id, instrument_id);
3923        assert_eq!(report.position_side, PositionSide::Flat.as_specified());
3924        assert_eq!(report.quantity, Quantity::from("0"));
3925        assert_eq!(report.venue_position_id, None); // Net mode
3926    }
3927
3928    #[rstest]
3929    fn test_parse_swap_instrument_empty_underlying_returns_error() {
3930        let instrument = OKXInstrument {
3931            inst_type: OKXInstrumentType::Swap,
3932            inst_id: Ustr::from("ETH-USD_UM-SWAP"),
3933            uly: Ustr::from(""), // Empty underlying
3934            inst_family: Ustr::from(""),
3935            base_ccy: Ustr::from(""),
3936            quote_ccy: Ustr::from(""),
3937            settle_ccy: Ustr::from("USD"),
3938            ct_val: "1".to_string(),
3939            ct_mult: "1".to_string(),
3940            ct_val_ccy: "USD".to_string(),
3941            opt_type: crate::common::enums::OKXOptionType::None,
3942            stk: String::new(),
3943            list_time: None,
3944            exp_time: None,
3945            lever: String::new(),
3946            tick_sz: "0.1".to_string(),
3947            lot_sz: "1".to_string(),
3948            min_sz: "1".to_string(),
3949            ct_type: OKXContractType::Linear,
3950            state: crate::common::enums::OKXInstrumentStatus::Preopen,
3951            rule_type: String::new(),
3952            max_lmt_sz: String::new(),
3953            max_mkt_sz: String::new(),
3954            max_lmt_amt: String::new(),
3955            max_mkt_amt: String::new(),
3956            max_twap_sz: String::new(),
3957            max_iceberg_sz: String::new(),
3958            max_trigger_sz: String::new(),
3959            max_stop_sz: String::new(),
3960        };
3961
3962        let result =
3963            parse_swap_instrument(&instrument, None, None, None, None, UnixNanos::default());
3964        assert!(result.is_err());
3965        assert!(result.unwrap_err().to_string().contains("Empty underlying"));
3966    }
3967
3968    #[rstest]
3969    fn test_parse_futures_instrument_empty_underlying_returns_error() {
3970        let instrument = OKXInstrument {
3971            inst_type: OKXInstrumentType::Futures,
3972            inst_id: Ustr::from("ETH-USD_UM-250328"),
3973            uly: Ustr::from(""), // Empty underlying
3974            inst_family: Ustr::from(""),
3975            base_ccy: Ustr::from(""),
3976            quote_ccy: Ustr::from(""),
3977            settle_ccy: Ustr::from("USD"),
3978            ct_val: "1".to_string(),
3979            ct_mult: "1".to_string(),
3980            ct_val_ccy: "USD".to_string(),
3981            opt_type: crate::common::enums::OKXOptionType::None,
3982            stk: String::new(),
3983            list_time: None,
3984            exp_time: Some(1743004800000),
3985            lever: String::new(),
3986            tick_sz: "0.1".to_string(),
3987            lot_sz: "1".to_string(),
3988            min_sz: "1".to_string(),
3989            ct_type: OKXContractType::Linear,
3990            state: crate::common::enums::OKXInstrumentStatus::Preopen,
3991            rule_type: String::new(),
3992            max_lmt_sz: String::new(),
3993            max_mkt_sz: String::new(),
3994            max_lmt_amt: String::new(),
3995            max_mkt_amt: String::new(),
3996            max_twap_sz: String::new(),
3997            max_iceberg_sz: String::new(),
3998            max_trigger_sz: String::new(),
3999            max_stop_sz: String::new(),
4000        };
4001
4002        let result =
4003            parse_futures_instrument(&instrument, None, None, None, None, UnixNanos::default());
4004        assert!(result.is_err());
4005        assert!(result.unwrap_err().to_string().contains("Empty underlying"));
4006    }
4007
4008    #[rstest]
4009    fn test_parse_option_instrument_empty_underlying_returns_error() {
4010        let instrument = OKXInstrument {
4011            inst_type: OKXInstrumentType::Option,
4012            inst_id: Ustr::from("BTC-USD-250328-50000-C"),
4013            uly: Ustr::from(""), // Empty underlying
4014            inst_family: Ustr::from(""),
4015            base_ccy: Ustr::from(""),
4016            quote_ccy: Ustr::from(""),
4017            settle_ccy: Ustr::from("USD"),
4018            ct_val: "0.01".to_string(),
4019            ct_mult: "1".to_string(),
4020            ct_val_ccy: "BTC".to_string(),
4021            opt_type: crate::common::enums::OKXOptionType::Call,
4022            stk: "50000".to_string(),
4023            list_time: None,
4024            exp_time: Some(1743004800000),
4025            lever: String::new(),
4026            tick_sz: "0.0005".to_string(),
4027            lot_sz: "0.1".to_string(),
4028            min_sz: "0.1".to_string(),
4029            ct_type: OKXContractType::Linear,
4030            state: crate::common::enums::OKXInstrumentStatus::Preopen,
4031            rule_type: String::new(),
4032            max_lmt_sz: String::new(),
4033            max_mkt_sz: String::new(),
4034            max_lmt_amt: String::new(),
4035            max_mkt_amt: String::new(),
4036            max_twap_sz: String::new(),
4037            max_iceberg_sz: String::new(),
4038            max_trigger_sz: String::new(),
4039            max_stop_sz: String::new(),
4040        };
4041
4042        let result =
4043            parse_option_instrument(&instrument, None, None, None, None, UnixNanos::default());
4044        assert!(result.is_err());
4045        assert!(result.unwrap_err().to_string().contains("Empty underlying"));
4046    }
4047
4048    #[rstest]
4049    fn test_parse_spot_margin_position_from_balance_short_usdt() {
4050        let balance = OKXBalanceDetail {
4051            ccy: Ustr::from("ENA"),
4052            liab: "130047.3610487126".to_string(),
4053            spot_in_use_amt: "-129950".to_string(),
4054            cross_liab: "130047.3610487126".to_string(),
4055            eq: "-130047.3610487126".to_string(),
4056            u_time: 1704067200000,
4057            avail_bal: "0".to_string(),
4058            avail_eq: "0".to_string(),
4059            borrow_froz: "0".to_string(),
4060            cash_bal: "0".to_string(),
4061            dis_eq: "0".to_string(),
4062            eq_usd: "0".to_string(),
4063            smt_sync_eq: "0".to_string(),
4064            spot_copy_trading_eq: "0".to_string(),
4065            fixed_bal: "0".to_string(),
4066            frozen_bal: "0".to_string(),
4067            imr: "0".to_string(),
4068            interest: "0".to_string(),
4069            iso_eq: "0".to_string(),
4070            iso_liab: "0".to_string(),
4071            iso_upl: "0".to_string(),
4072            max_loan: "0".to_string(),
4073            mgn_ratio: "0".to_string(),
4074            mmr: "0".to_string(),
4075            notional_lever: "0".to_string(),
4076            ord_frozen: "0".to_string(),
4077            reward_bal: "0".to_string(),
4078            cl_spot_in_use_amt: "0".to_string(),
4079            max_spot_in_use_amt: "0".to_string(),
4080            spot_iso_bal: "0".to_string(),
4081            stgy_eq: "0".to_string(),
4082            twap: "0".to_string(),
4083            upl: "0".to_string(),
4084            upl_liab: "0".to_string(),
4085            spot_bal: "0".to_string(),
4086            open_avg_px: "0".to_string(),
4087            acc_avg_px: "0".to_string(),
4088            spot_upl: "0".to_string(),
4089            spot_upl_ratio: "0".to_string(),
4090            total_pnl: "0".to_string(),
4091            total_pnl_ratio: "0".to_string(),
4092        };
4093
4094        let account_id = AccountId::new("OKX-001");
4095        let size_precision = 2;
4096        let ts_init = UnixNanos::default();
4097
4098        let result = parse_spot_margin_position_from_balance(
4099            &balance,
4100            account_id,
4101            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4102            size_precision,
4103            ts_init,
4104        )
4105        .unwrap();
4106
4107        assert!(result.is_some());
4108        let report = result.unwrap();
4109        assert_eq!(report.account_id, account_id);
4110        assert_eq!(report.instrument_id.to_string(), "ENA-USDT.OKX".to_string());
4111        assert_eq!(report.position_side, PositionSide::Short.as_specified());
4112        assert_eq!(report.quantity.to_string(), "129950.00");
4113    }
4114
4115    #[rstest]
4116    fn test_parse_spot_margin_position_from_balance_long() {
4117        let balance = OKXBalanceDetail {
4118            ccy: Ustr::from("BTC"),
4119            liab: "1.5".to_string(),
4120            spot_in_use_amt: "1.2".to_string(),
4121            cross_liab: "1.5".to_string(),
4122            eq: "1.2".to_string(),
4123            u_time: 1704067200000,
4124            avail_bal: "0".to_string(),
4125            avail_eq: "0".to_string(),
4126            borrow_froz: "0".to_string(),
4127            cash_bal: "0".to_string(),
4128            dis_eq: "0".to_string(),
4129            eq_usd: "0".to_string(),
4130            smt_sync_eq: "0".to_string(),
4131            spot_copy_trading_eq: "0".to_string(),
4132            fixed_bal: "0".to_string(),
4133            frozen_bal: "0".to_string(),
4134            imr: "0".to_string(),
4135            interest: "0".to_string(),
4136            iso_eq: "0".to_string(),
4137            iso_liab: "0".to_string(),
4138            iso_upl: "0".to_string(),
4139            max_loan: "0".to_string(),
4140            mgn_ratio: "0".to_string(),
4141            mmr: "0".to_string(),
4142            notional_lever: "0".to_string(),
4143            ord_frozen: "0".to_string(),
4144            reward_bal: "0".to_string(),
4145            cl_spot_in_use_amt: "0".to_string(),
4146            max_spot_in_use_amt: "0".to_string(),
4147            spot_iso_bal: "0".to_string(),
4148            stgy_eq: "0".to_string(),
4149            twap: "0".to_string(),
4150            upl: "0".to_string(),
4151            upl_liab: "0".to_string(),
4152            spot_bal: "0".to_string(),
4153            open_avg_px: "0".to_string(),
4154            acc_avg_px: "0".to_string(),
4155            spot_upl: "0".to_string(),
4156            spot_upl_ratio: "0".to_string(),
4157            total_pnl: "0".to_string(),
4158            total_pnl_ratio: "0".to_string(),
4159        };
4160
4161        let account_id = AccountId::new("OKX-001");
4162        let size_precision = 8;
4163        let ts_init = UnixNanos::default();
4164
4165        let result = parse_spot_margin_position_from_balance(
4166            &balance,
4167            account_id,
4168            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4169            size_precision,
4170            ts_init,
4171        )
4172        .unwrap();
4173
4174        assert!(result.is_some());
4175        let report = result.unwrap();
4176        assert_eq!(report.position_side, PositionSide::Long.as_specified());
4177        assert_eq!(report.quantity.to_string(), "1.20000000");
4178    }
4179
4180    #[rstest]
4181    fn test_parse_spot_margin_position_from_balance_usdc_quote() {
4182        let balance = OKXBalanceDetail {
4183            ccy: Ustr::from("ETH"),
4184            liab: "10.5".to_string(),
4185            spot_in_use_amt: "-10.0".to_string(),
4186            cross_liab: "10.5".to_string(),
4187            eq: "-10.0".to_string(),
4188            u_time: 1704067200000,
4189            avail_bal: "0".to_string(),
4190            avail_eq: "0".to_string(),
4191            borrow_froz: "0".to_string(),
4192            cash_bal: "0".to_string(),
4193            dis_eq: "0".to_string(),
4194            eq_usd: "0".to_string(),
4195            smt_sync_eq: "0".to_string(),
4196            spot_copy_trading_eq: "0".to_string(),
4197            fixed_bal: "0".to_string(),
4198            frozen_bal: "0".to_string(),
4199            imr: "0".to_string(),
4200            interest: "0".to_string(),
4201            iso_eq: "0".to_string(),
4202            iso_liab: "0".to_string(),
4203            iso_upl: "0".to_string(),
4204            max_loan: "0".to_string(),
4205            mgn_ratio: "0".to_string(),
4206            mmr: "0".to_string(),
4207            notional_lever: "0".to_string(),
4208            ord_frozen: "0".to_string(),
4209            reward_bal: "0".to_string(),
4210            cl_spot_in_use_amt: "0".to_string(),
4211            max_spot_in_use_amt: "0".to_string(),
4212            spot_iso_bal: "0".to_string(),
4213            stgy_eq: "0".to_string(),
4214            twap: "0".to_string(),
4215            upl: "0".to_string(),
4216            upl_liab: "0".to_string(),
4217            spot_bal: "0".to_string(),
4218            open_avg_px: "0".to_string(),
4219            acc_avg_px: "0".to_string(),
4220            spot_upl: "0".to_string(),
4221            spot_upl_ratio: "0".to_string(),
4222            total_pnl: "0".to_string(),
4223            total_pnl_ratio: "0".to_string(),
4224        };
4225
4226        let account_id = AccountId::new("OKX-001");
4227        let size_precision = 6;
4228        let ts_init = UnixNanos::default();
4229
4230        let result = parse_spot_margin_position_from_balance(
4231            &balance,
4232            account_id,
4233            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4234            size_precision,
4235            ts_init,
4236        )
4237        .unwrap();
4238
4239        assert!(result.is_some());
4240        let report = result.unwrap();
4241        assert_eq!(report.position_side, PositionSide::Short.as_specified());
4242        assert_eq!(report.quantity.to_string(), "10.000000");
4243        assert!(report.instrument_id.to_string().contains("ETH-"));
4244    }
4245
4246    #[rstest]
4247    fn test_parse_spot_margin_position_from_balance_no_position() {
4248        let balance = OKXBalanceDetail {
4249            ccy: Ustr::from("USDT"),
4250            liab: "0".to_string(),
4251            spot_in_use_amt: "0".to_string(),
4252            cross_liab: "0".to_string(),
4253            eq: "1000.5".to_string(),
4254            u_time: 1704067200000,
4255            avail_bal: "1000.5".to_string(),
4256            avail_eq: "1000.5".to_string(),
4257            borrow_froz: "0".to_string(),
4258            cash_bal: "1000.5".to_string(),
4259            dis_eq: "0".to_string(),
4260            eq_usd: "1000.5".to_string(),
4261            smt_sync_eq: "0".to_string(),
4262            spot_copy_trading_eq: "0".to_string(),
4263            fixed_bal: "0".to_string(),
4264            frozen_bal: "0".to_string(),
4265            imr: "0".to_string(),
4266            interest: "0".to_string(),
4267            iso_eq: "0".to_string(),
4268            iso_liab: "0".to_string(),
4269            iso_upl: "0".to_string(),
4270            max_loan: "0".to_string(),
4271            mgn_ratio: "0".to_string(),
4272            mmr: "0".to_string(),
4273            notional_lever: "0".to_string(),
4274            ord_frozen: "0".to_string(),
4275            reward_bal: "0".to_string(),
4276            cl_spot_in_use_amt: "0".to_string(),
4277            max_spot_in_use_amt: "0".to_string(),
4278            spot_iso_bal: "0".to_string(),
4279            stgy_eq: "0".to_string(),
4280            twap: "0".to_string(),
4281            upl: "0".to_string(),
4282            upl_liab: "0".to_string(),
4283            spot_bal: "1000.5".to_string(),
4284            open_avg_px: "0".to_string(),
4285            acc_avg_px: "0".to_string(),
4286            spot_upl: "0".to_string(),
4287            spot_upl_ratio: "0".to_string(),
4288            total_pnl: "0".to_string(),
4289            total_pnl_ratio: "0".to_string(),
4290        };
4291
4292        let account_id = AccountId::new("OKX-001");
4293        let size_precision = 2;
4294        let ts_init = UnixNanos::default();
4295
4296        let result = parse_spot_margin_position_from_balance(
4297            &balance,
4298            account_id,
4299            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4300            size_precision,
4301            ts_init,
4302        )
4303        .unwrap();
4304
4305        assert!(result.is_none());
4306    }
4307
4308    #[rstest]
4309    fn test_parse_spot_margin_position_from_balance_liability_no_spot_in_use() {
4310        let balance = OKXBalanceDetail {
4311            ccy: Ustr::from("BTC"),
4312            liab: "0.5".to_string(),
4313            spot_in_use_amt: "0".to_string(),
4314            cross_liab: "0.5".to_string(),
4315            eq: "0".to_string(),
4316            u_time: 1704067200000,
4317            avail_bal: "0".to_string(),
4318            avail_eq: "0".to_string(),
4319            borrow_froz: "0".to_string(),
4320            cash_bal: "0".to_string(),
4321            dis_eq: "0".to_string(),
4322            eq_usd: "0".to_string(),
4323            smt_sync_eq: "0".to_string(),
4324            spot_copy_trading_eq: "0".to_string(),
4325            fixed_bal: "0".to_string(),
4326            frozen_bal: "0".to_string(),
4327            imr: "0".to_string(),
4328            interest: "0".to_string(),
4329            iso_eq: "0".to_string(),
4330            iso_liab: "0".to_string(),
4331            iso_upl: "0".to_string(),
4332            max_loan: "0".to_string(),
4333            mgn_ratio: "0".to_string(),
4334            mmr: "0".to_string(),
4335            notional_lever: "0".to_string(),
4336            ord_frozen: "0".to_string(),
4337            reward_bal: "0".to_string(),
4338            cl_spot_in_use_amt: "0".to_string(),
4339            max_spot_in_use_amt: "0".to_string(),
4340            spot_iso_bal: "0".to_string(),
4341            stgy_eq: "0".to_string(),
4342            twap: "0".to_string(),
4343            upl: "0".to_string(),
4344            upl_liab: "0".to_string(),
4345            spot_bal: "0".to_string(),
4346            open_avg_px: "0".to_string(),
4347            acc_avg_px: "0".to_string(),
4348            spot_upl: "0".to_string(),
4349            spot_upl_ratio: "0".to_string(),
4350            total_pnl: "0".to_string(),
4351            total_pnl_ratio: "0".to_string(),
4352        };
4353
4354        let account_id = AccountId::new("OKX-001");
4355        let size_precision = 8;
4356        let ts_init = UnixNanos::default();
4357
4358        let result = parse_spot_margin_position_from_balance(
4359            &balance,
4360            account_id,
4361            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4362            size_precision,
4363            ts_init,
4364        )
4365        .unwrap();
4366
4367        assert!(result.is_none());
4368    }
4369
4370    #[rstest]
4371    fn test_parse_spot_margin_position_from_balance_empty_strings() {
4372        let balance = OKXBalanceDetail {
4373            ccy: Ustr::from("USDT"),
4374            liab: String::new(),
4375            spot_in_use_amt: String::new(),
4376            cross_liab: String::new(),
4377            eq: "5000.25".to_string(),
4378            u_time: 1704067200000,
4379            avail_bal: "5000.25".to_string(),
4380            avail_eq: "5000.25".to_string(),
4381            borrow_froz: String::new(),
4382            cash_bal: "5000.25".to_string(),
4383            dis_eq: String::new(),
4384            eq_usd: "5000.25".to_string(),
4385            smt_sync_eq: String::new(),
4386            spot_copy_trading_eq: String::new(),
4387            fixed_bal: String::new(),
4388            frozen_bal: String::new(),
4389            imr: String::new(),
4390            interest: String::new(),
4391            iso_eq: String::new(),
4392            iso_liab: String::new(),
4393            iso_upl: String::new(),
4394            max_loan: String::new(),
4395            mgn_ratio: String::new(),
4396            mmr: String::new(),
4397            notional_lever: String::new(),
4398            ord_frozen: String::new(),
4399            reward_bal: String::new(),
4400            cl_spot_in_use_amt: String::new(),
4401            max_spot_in_use_amt: String::new(),
4402            spot_iso_bal: String::new(),
4403            stgy_eq: String::new(),
4404            twap: String::new(),
4405            upl: String::new(),
4406            upl_liab: String::new(),
4407            spot_bal: "5000.25".to_string(),
4408            open_avg_px: String::new(),
4409            acc_avg_px: String::new(),
4410            spot_upl: String::new(),
4411            spot_upl_ratio: String::new(),
4412            total_pnl: String::new(),
4413            total_pnl_ratio: String::new(),
4414        };
4415
4416        let account_id = AccountId::new("OKX-001");
4417        let size_precision = 2;
4418        let ts_init = UnixNanos::default();
4419
4420        let result = parse_spot_margin_position_from_balance(
4421            &balance,
4422            account_id,
4423            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4424            size_precision,
4425            ts_init,
4426        )
4427        .unwrap();
4428
4429        // Empty strings should be treated as zero, returning None (no margin position)
4430        assert!(result.is_none());
4431    }
4432
4433    #[rstest]
4434    #[case::fok_maps_to_fok_tif(OKXOrderType::Fok, TimeInForce::Fok)]
4435    #[case::ioc_maps_to_ioc_tif(OKXOrderType::Ioc, TimeInForce::Ioc)]
4436    #[case::optimal_limit_ioc_maps_to_ioc_tif(OKXOrderType::OptimalLimitIoc, TimeInForce::Ioc)]
4437    #[case::market_maps_to_gtc(OKXOrderType::Market, TimeInForce::Gtc)]
4438    #[case::limit_maps_to_gtc(OKXOrderType::Limit, TimeInForce::Gtc)]
4439    #[case::post_only_maps_to_gtc(OKXOrderType::PostOnly, TimeInForce::Gtc)]
4440    #[case::trigger_maps_to_gtc(OKXOrderType::Trigger, TimeInForce::Gtc)]
4441    fn test_okx_order_type_to_time_in_force(
4442        #[case] okx_ord_type: OKXOrderType,
4443        #[case] expected_tif: TimeInForce,
4444    ) {
4445        let time_in_force = match okx_ord_type {
4446            OKXOrderType::Fok => TimeInForce::Fok,
4447            OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
4448            _ => TimeInForce::Gtc,
4449        };
4450
4451        assert_eq!(
4452            time_in_force, expected_tif,
4453            "OKXOrderType::{okx_ord_type:?} should map to TimeInForce::{expected_tif:?}"
4454        );
4455    }
4456
4457    #[rstest]
4458    fn test_fok_order_type_serialization() {
4459        let ord_type = OKXOrderType::Fok;
4460        let json = serde_json::to_string(&ord_type).expect("serialize");
4461        assert_eq!(json, "\"fok\"", "FOK should serialize to 'fok'");
4462    }
4463
4464    #[rstest]
4465    fn test_ioc_order_type_serialization() {
4466        let ord_type = OKXOrderType::Ioc;
4467        let json = serde_json::to_string(&ord_type).expect("serialize");
4468        assert_eq!(json, "\"ioc\"", "IOC should serialize to 'ioc'");
4469    }
4470
4471    #[rstest]
4472    fn test_optimal_limit_ioc_serialization() {
4473        let ord_type = OKXOrderType::OptimalLimitIoc;
4474        let json = serde_json::to_string(&ord_type).expect("serialize");
4475        assert_eq!(
4476            json, "\"optimal_limit_ioc\"",
4477            "OptimalLimitIoc should serialize to 'optimal_limit_ioc'"
4478        );
4479    }
4480
4481    #[rstest]
4482    fn test_fok_order_type_deserialization() {
4483        let json = "\"fok\"";
4484        let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
4485        assert_eq!(ord_type, OKXOrderType::Fok);
4486    }
4487
4488    #[rstest]
4489    fn test_ioc_order_type_deserialization() {
4490        let json = "\"ioc\"";
4491        let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
4492        assert_eq!(ord_type, OKXOrderType::Ioc);
4493    }
4494
4495    #[rstest]
4496    fn test_optimal_limit_ioc_deserialization() {
4497        let json = "\"optimal_limit_ioc\"";
4498        let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
4499        assert_eq!(ord_type, OKXOrderType::OptimalLimitIoc);
4500    }
4501
4502    #[rstest]
4503    #[case(TimeInForce::Fok, OKXOrderType::Fok)]
4504    #[case(TimeInForce::Ioc, OKXOrderType::Ioc)]
4505    fn test_time_in_force_round_trip(
4506        #[case] original_tif: TimeInForce,
4507        #[case] expected_okx_type: OKXOrderType,
4508    ) {
4509        let okx_ord_type = match original_tif {
4510            TimeInForce::Fok => OKXOrderType::Fok,
4511            TimeInForce::Ioc => OKXOrderType::Ioc,
4512            TimeInForce::Gtc => OKXOrderType::Limit,
4513            _ => OKXOrderType::Limit,
4514        };
4515        assert_eq!(okx_ord_type, expected_okx_type);
4516
4517        let parsed_tif = match okx_ord_type {
4518            OKXOrderType::Fok => TimeInForce::Fok,
4519            OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
4520            _ => TimeInForce::Gtc,
4521        };
4522        assert_eq!(parsed_tif, original_tif);
4523    }
4524
4525    #[rstest]
4526    #[case::limit_fok(
4527        OrderType::Limit,
4528        TimeInForce::Fok,
4529        OKXOrderType::Fok,
4530        "Limit + FOK should map to Fok"
4531    )]
4532    #[case::limit_ioc(
4533        OrderType::Limit,
4534        TimeInForce::Ioc,
4535        OKXOrderType::Ioc,
4536        "Limit + IOC should map to Ioc"
4537    )]
4538    #[case::market_ioc(
4539        OrderType::Market,
4540        TimeInForce::Ioc,
4541        OKXOrderType::OptimalLimitIoc,
4542        "Market + IOC should map to OptimalLimitIoc"
4543    )]
4544    #[case::limit_gtc(
4545        OrderType::Limit,
4546        TimeInForce::Gtc,
4547        OKXOrderType::Limit,
4548        "Limit + GTC should map to Limit"
4549    )]
4550    #[case::market_gtc(
4551        OrderType::Market,
4552        TimeInForce::Gtc,
4553        OKXOrderType::Market,
4554        "Market + GTC should map to Market"
4555    )]
4556    fn test_order_type_time_in_force_combinations(
4557        #[case] order_type: OrderType,
4558        #[case] tif: TimeInForce,
4559        #[case] expected_okx_type: OKXOrderType,
4560        #[case] description: &str,
4561    ) {
4562        let okx_ord_type = match (order_type, tif) {
4563            (OrderType::Market, TimeInForce::Ioc) => OKXOrderType::OptimalLimitIoc,
4564            (OrderType::Limit, TimeInForce::Fok) => OKXOrderType::Fok,
4565            (OrderType::Limit, TimeInForce::Ioc) => OKXOrderType::Ioc,
4566            _ => OKXOrderType::from(order_type),
4567        };
4568
4569        assert_eq!(okx_ord_type, expected_okx_type, "{description}");
4570    }
4571
4572    #[rstest]
4573    fn test_market_fok_not_supported() {
4574        let order_type = OrderType::Market;
4575        let tif = TimeInForce::Fok;
4576
4577        let is_market_fok = matches!((order_type, tif), (OrderType::Market, TimeInForce::Fok));
4578        assert!(
4579            is_market_fok,
4580            "Market + FOK combination should be identified for rejection"
4581        );
4582    }
4583
4584    #[rstest]
4585    #[case::empty_string("", true)]
4586    #[case::zero("0", true)]
4587    #[case::minus_one("-1", true)]
4588    #[case::minus_two("-2", true)]
4589    #[case::normal_price("100.5", false)]
4590    #[case::another_price("0.001", false)]
4591    fn test_is_market_price(#[case] price: &str, #[case] expected: bool) {
4592        assert_eq!(is_market_price(price), expected);
4593    }
4594
4595    #[rstest]
4596    #[case::fok_market(OKXOrderType::Fok, "", OrderType::Market)]
4597    #[case::fok_limit(OKXOrderType::Fok, "100.5", OrderType::Limit)]
4598    #[case::ioc_market(OKXOrderType::Ioc, "", OrderType::Market)]
4599    #[case::ioc_limit(OKXOrderType::Ioc, "100.5", OrderType::Limit)]
4600    #[case::optimal_limit_ioc_market(OKXOrderType::OptimalLimitIoc, "", OrderType::Market)]
4601    #[case::optimal_limit_ioc_market_zero(OKXOrderType::OptimalLimitIoc, "0", OrderType::Market)]
4602    #[case::optimal_limit_ioc_market_minus_one(
4603        OKXOrderType::OptimalLimitIoc,
4604        "-1",
4605        OrderType::Market
4606    )]
4607    #[case::optimal_limit_ioc_limit(OKXOrderType::OptimalLimitIoc, "100.5", OrderType::Limit)]
4608    #[case::market_passthrough(OKXOrderType::Market, "", OrderType::Market)]
4609    #[case::limit_passthrough(OKXOrderType::Limit, "100.5", OrderType::Limit)]
4610    fn test_determine_order_type(
4611        #[case] okx_ord_type: OKXOrderType,
4612        #[case] price: &str,
4613        #[case] expected: OrderType,
4614    ) {
4615        assert_eq!(determine_order_type(okx_ord_type, price), expected);
4616    }
4617}