nautilus_okx/common/
parse.rs

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