Skip to main content

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 uses opposite sign convention: negative = cost, positive = rebate.
296    // Negate to match Nautilus convention: positive = cost, negative = rebate.
297    let decimal = Decimal::from_str(value.unwrap_or("0"))?;
298    Money::from_decimal(-decimal, currency)
299}
300
301/// Parses OKX fee currency code, handling empty strings.
302///
303/// OKX sometimes returns empty fee currency codes.
304/// When the fee currency is empty, defaults to USDT and logs a warning for non-zero fees.
305pub fn parse_fee_currency(
306    fee_ccy: &str,
307    fee_amount: Decimal,
308    context: impl FnOnce() -> String,
309) -> Currency {
310    let trimmed = fee_ccy.trim();
311    if trimmed.is_empty() {
312        if !fee_amount.is_zero() {
313            let ctx = context();
314            log::warn!(
315                "Empty fee_ccy in {ctx} with non-zero fee={fee_amount}, using USDT as fallback"
316            );
317        }
318        return Currency::USDT();
319    }
320
321    Currency::get_or_create_crypto_with_context(trimmed, Some(&context()))
322}
323
324/// Parses OKX side to Nautilus aggressor side.
325pub fn parse_aggressor_side(side: &Option<OKXSide>) -> AggressorSide {
326    match side {
327        Some(OKXSide::Buy) => AggressorSide::Buyer,
328        Some(OKXSide::Sell) => AggressorSide::Seller,
329        None => AggressorSide::NoAggressor,
330    }
331}
332
333/// Parses OKX execution type to Nautilus liquidity side.
334pub fn parse_execution_type(liquidity: &Option<OKXExecType>) -> LiquiditySide {
335    match liquidity {
336        Some(OKXExecType::Maker) => LiquiditySide::Maker,
337        Some(OKXExecType::Taker) => LiquiditySide::Taker,
338        _ => LiquiditySide::NoLiquiditySide,
339    }
340}
341
342/// Parses quantity to Nautilus position side.
343pub fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
344    match current_qty {
345        Some(qty) if qty > 0 => PositionSide::Long,
346        Some(qty) if qty < 0 => PositionSide::Short,
347        _ => PositionSide::Flat,
348    }
349}
350
351/// Parses an OKX mark price record into a Nautilus [`MarkPriceUpdate`].
352///
353/// # Errors
354///
355/// Returns an error if `raw.mark_px` cannot be parsed into a [`Price`] with
356/// the specified precision.
357pub fn parse_mark_price_update(
358    raw: &OKXMarkPrice,
359    instrument_id: InstrumentId,
360    price_precision: u8,
361    ts_init: UnixNanos,
362) -> anyhow::Result<MarkPriceUpdate> {
363    let ts_event = parse_millisecond_timestamp(raw.ts);
364    let price = parse_price(&raw.mark_px, price_precision)?;
365    Ok(MarkPriceUpdate::new(
366        instrument_id,
367        price,
368        ts_event,
369        ts_init,
370    ))
371}
372
373/// Parses an OKX index ticker record into a Nautilus [`IndexPriceUpdate`].
374///
375/// # Errors
376///
377/// Returns an error if `raw.idx_px` cannot be parsed into a [`Price`] with the
378/// specified precision.
379pub fn parse_index_price_update(
380    raw: &OKXIndexTicker,
381    instrument_id: InstrumentId,
382    price_precision: u8,
383    ts_init: UnixNanos,
384) -> anyhow::Result<IndexPriceUpdate> {
385    let ts_event = parse_millisecond_timestamp(raw.ts);
386    let price = parse_price(&raw.idx_px, price_precision)?;
387    Ok(IndexPriceUpdate::new(
388        instrument_id,
389        price,
390        ts_event,
391        ts_init,
392    ))
393}
394
395/// Parses an [`OKXFundingRateMsg`] into a [`FundingRateUpdate`].
396///
397/// # Errors
398///
399/// Returns an error if the `funding_rate` or `next_funding_rate` fields fail
400/// to parse into Decimal values.
401pub fn parse_funding_rate_msg(
402    msg: &OKXFundingRateMsg,
403    instrument_id: InstrumentId,
404    ts_init: UnixNanos,
405) -> anyhow::Result<FundingRateUpdate> {
406    let funding_rate = msg
407        .funding_rate
408        .as_str()
409        .parse::<Decimal>()
410        .map_err(|e| anyhow::anyhow!("Invalid funding_rate value: {e}"))?;
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_parse_inst_id_code_from_swap_instrument() {
2579        let json_data = load_test_json("http_get_instruments_swap.json");
2580        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2581
2582        // Verify instIdCode is parsed correctly for BTC-USD-SWAP (inverse)
2583        let btc_usd_swap = response
2584            .data
2585            .iter()
2586            .find(|i| i.inst_id == "BTC-USD-SWAP")
2587            .expect("BTC-USD-SWAP must be in test data");
2588        assert_eq!(btc_usd_swap.inst_id_code, Some(10458));
2589
2590        // Verify instIdCode is parsed correctly for ETH-USDT-SWAP (linear)
2591        let eth_usdt_swap = response
2592            .data
2593            .iter()
2594            .find(|i| i.inst_id == "ETH-USDT-SWAP")
2595            .expect("ETH-USDT-SWAP must be in test data");
2596        assert_eq!(eth_usdt_swap.inst_id_code, Some(10461));
2597
2598        // Verify instIdCode is parsed correctly for BTC-USDT-SWAP
2599        let btc_usdt_swap = response
2600            .data
2601            .iter()
2602            .find(|i| i.inst_id == "BTC-USDT-SWAP")
2603            .expect("BTC-USDT-SWAP must be in test data");
2604        assert_eq!(btc_usdt_swap.inst_id_code, Some(10459));
2605    }
2606
2607    #[rstest]
2608    fn test_fee_field_selection_for_contract_types() {
2609        // Mock OKXFeeRate with different values for crypto vs USDT-margined
2610        let maker_crypto = "0.0002"; // Crypto-margined maker fee
2611        let taker_crypto = "0.0005"; // Crypto-margined taker fee
2612        let maker_usdt = "0.0008"; // USDT-margined maker fee
2613        let taker_usdt = "0.0010"; // USDT-margined taker fee
2614
2615        // Test Linear (USDT-margined) - should use maker_u/taker_u
2616        let is_usdt_margined = true;
2617        let (maker_str, taker_str) = if is_usdt_margined {
2618            (maker_usdt, taker_usdt)
2619        } else {
2620            (maker_crypto, taker_crypto)
2621        };
2622
2623        assert_eq!(maker_str, "0.0008");
2624        assert_eq!(taker_str, "0.0010");
2625
2626        let maker_fee = Decimal::from_str(maker_str).unwrap();
2627        let taker_fee = Decimal::from_str(taker_str).unwrap();
2628
2629        assert_eq!(maker_fee, dec!(0.0008));
2630        assert_eq!(taker_fee, dec!(0.0010));
2631
2632        // Test Inverse (crypto-margined) - should use maker/taker
2633        let is_usdt_margined = false;
2634        let (maker_str, taker_str) = if is_usdt_margined {
2635            (maker_usdt, taker_usdt)
2636        } else {
2637            (maker_crypto, taker_crypto)
2638        };
2639
2640        assert_eq!(maker_str, "0.0002");
2641        assert_eq!(taker_str, "0.0005");
2642
2643        let maker_fee = Decimal::from_str(maker_str).unwrap();
2644        let taker_fee = Decimal::from_str(taker_str).unwrap();
2645
2646        assert_eq!(maker_fee, dec!(0.0002));
2647        assert_eq!(taker_fee, dec!(0.0005));
2648    }
2649
2650    #[rstest]
2651    fn test_parse_futures_instrument() {
2652        let json_data = load_test_json("http_get_instruments_futures.json");
2653        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2654        let okx_inst: &OKXInstrument = response
2655            .data
2656            .first()
2657            .expect("Test data must have an instrument");
2658
2659        let instrument =
2660            parse_futures_instrument(okx_inst, None, None, None, None, UnixNanos::default())
2661                .unwrap();
2662
2663        assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-241220.OKX"));
2664        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-241220"));
2665        assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
2666        assert_eq!(instrument.quote_currency(), Currency::USD());
2667        assert_eq!(instrument.settlement_currency(), Currency::BTC());
2668        assert!(instrument.is_inverse());
2669        assert_eq!(instrument.price_precision(), 1);
2670        assert_eq!(instrument.size_precision(), 0);
2671        assert_eq!(instrument.price_increment(), Price::from("0.1"));
2672        assert_eq!(instrument.size_increment(), Quantity::from(1));
2673        assert_eq!(instrument.multiplier(), Quantity::from(100));
2674        assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2675        assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2676        assert_eq!(instrument.max_quantity(), Some(Quantity::from(10000)));
2677    }
2678
2679    #[rstest]
2680    fn test_parse_option_instrument() {
2681        let json_data = load_test_json("http_get_instruments_option.json");
2682        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2683        let okx_inst: &OKXInstrument = response
2684            .data
2685            .first()
2686            .expect("Test data must have an instrument");
2687
2688        let instrument =
2689            parse_option_instrument(okx_inst, None, None, None, None, UnixNanos::default())
2690                .unwrap();
2691
2692        assert_eq!(
2693            instrument.id(),
2694            InstrumentId::from("BTC-USD-241217-92000-C.OKX")
2695        );
2696        assert_eq!(
2697            instrument.raw_symbol(),
2698            Symbol::from("BTC-USD-241217-92000-C")
2699        );
2700        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2701        assert_eq!(instrument.quote_currency(), Currency::USD());
2702        assert_eq!(instrument.settlement_currency(), Currency::BTC());
2703        assert!(instrument.is_inverse());
2704        assert_eq!(instrument.price_precision(), 4);
2705        assert_eq!(instrument.size_precision(), 0);
2706        assert_eq!(instrument.price_increment(), Price::from("0.0001"));
2707        assert_eq!(instrument.size_increment(), Quantity::from(1));
2708        assert_eq!(instrument.multiplier(), Quantity::from("0.01"));
2709        assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2710        assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2711        assert_eq!(instrument.max_quantity(), Some(Quantity::from(5000)));
2712        assert_eq!(instrument.max_notional(), None);
2713        assert_eq!(instrument.min_notional(), None);
2714        assert_eq!(instrument.max_price(), None);
2715        assert_eq!(instrument.min_price(), None);
2716    }
2717
2718    #[rstest]
2719    fn test_parse_account_state() {
2720        let json_data = load_test_json("http_get_account_balance.json");
2721        let response: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
2722        let okx_account = response
2723            .data
2724            .first()
2725            .expect("Test data must have an account");
2726
2727        let account_id = AccountId::new("OKX-001");
2728        let account_state =
2729            parse_account_state(okx_account, account_id, UnixNanos::default()).unwrap();
2730
2731        assert_eq!(account_state.account_id, account_id);
2732        assert_eq!(account_state.account_type, AccountType::Margin);
2733        assert_eq!(account_state.balances.len(), 1);
2734        assert_eq!(account_state.margins.len(), 0); // No margins in this test data (spot account)
2735        assert!(account_state.is_reported);
2736
2737        // Check the USDT balance details
2738        let usdt_balance = &account_state.balances[0];
2739        assert_eq!(
2740            usdt_balance.total,
2741            Money::new(94.42612990333333, Currency::USDT())
2742        );
2743        assert_eq!(
2744            usdt_balance.free,
2745            Money::new(94.42612990333333, Currency::USDT())
2746        );
2747        assert_eq!(usdt_balance.locked, Money::new(0.0, Currency::USDT()));
2748    }
2749
2750    #[rstest]
2751    fn test_parse_account_state_with_margins() {
2752        // Create test data with margin requirements
2753        let account_json = r#"{
2754            "adjEq": "10000.0",
2755            "borrowFroz": "0",
2756            "details": [{
2757                "accAvgPx": "",
2758                "availBal": "8000.0",
2759                "availEq": "8000.0",
2760                "borrowFroz": "0",
2761                "cashBal": "10000.0",
2762                "ccy": "USDT",
2763                "clSpotInUseAmt": "0",
2764                "coinUsdPrice": "1.0",
2765                "colBorrAutoConversion": "0",
2766                "collateralEnabled": false,
2767                "collateralRestrict": false,
2768                "crossLiab": "0",
2769                "disEq": "10000.0",
2770                "eq": "10000.0",
2771                "eqUsd": "10000.0",
2772                "fixedBal": "0",
2773                "frozenBal": "2000.0",
2774                "imr": "0",
2775                "interest": "0",
2776                "isoEq": "0",
2777                "isoLiab": "0",
2778                "isoUpl": "0",
2779                "liab": "0",
2780                "maxLoan": "0",
2781                "mgnRatio": "0",
2782                "maxSpotInUseAmt": "0",
2783                "mmr": "0",
2784                "notionalLever": "0",
2785                "openAvgPx": "",
2786                "ordFrozen": "2000.0",
2787                "rewardBal": "0",
2788                "smtSyncEq": "0",
2789                "spotBal": "0",
2790                "spotCopyTradingEq": "0",
2791                "spotInUseAmt": "0",
2792                "spotIsoBal": "0",
2793                "spotUpl": "0",
2794                "spotUplRatio": "0",
2795                "stgyEq": "0",
2796                "totalPnl": "0",
2797                "totalPnlRatio": "0",
2798                "twap": "0",
2799                "uTime": "1704067200000",
2800                "upl": "0",
2801                "uplLiab": "0"
2802            }],
2803            "imr": "500.25",
2804            "isoEq": "0",
2805            "mgnRatio": "20.5",
2806            "mmr": "250.75",
2807            "notionalUsd": "5000.0",
2808            "notionalUsdForBorrow": "0",
2809            "notionalUsdForFutures": "0",
2810            "notionalUsdForOption": "0",
2811            "notionalUsdForSwap": "5000.0",
2812            "ordFroz": "2000.0",
2813            "totalEq": "10000.0",
2814            "uTime": "1704067200000",
2815            "upl": "0"
2816        }"#;
2817
2818        let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
2819        let account_id = AccountId::new("OKX-001");
2820        let account_state =
2821            parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
2822
2823        // Verify account details
2824        assert_eq!(account_state.account_id, account_id);
2825        assert_eq!(account_state.account_type, AccountType::Margin);
2826        assert_eq!(account_state.balances.len(), 1);
2827
2828        // Verify margin information was parsed
2829        assert_eq!(account_state.margins.len(), 1);
2830        let margin = &account_state.margins[0];
2831
2832        // Check margin values
2833        assert_eq!(margin.initial, Money::new(500.25, Currency::USD()));
2834        assert_eq!(margin.maintenance, Money::new(250.75, Currency::USD()));
2835        assert_eq!(margin.currency, Currency::USD());
2836        assert_eq!(margin.instrument_id.symbol.as_str(), "ACCOUNT");
2837        assert_eq!(margin.instrument_id.venue.as_str(), "OKX");
2838
2839        // Check the USDT balance details
2840        let usdt_balance = &account_state.balances[0];
2841        assert_eq!(usdt_balance.total, Money::new(10000.0, Currency::USDT()));
2842        assert_eq!(usdt_balance.free, Money::new(8000.0, Currency::USDT()));
2843        assert_eq!(usdt_balance.locked, Money::new(2000.0, Currency::USDT()));
2844    }
2845
2846    #[rstest]
2847    fn test_parse_account_state_empty_margins() {
2848        // Create test data with empty margin strings (common for spot accounts)
2849        let account_json = r#"{
2850            "adjEq": "",
2851            "borrowFroz": "",
2852            "details": [{
2853                "accAvgPx": "",
2854                "availBal": "1000.0",
2855                "availEq": "1000.0",
2856                "borrowFroz": "0",
2857                "cashBal": "1000.0",
2858                "ccy": "BTC",
2859                "clSpotInUseAmt": "0",
2860                "coinUsdPrice": "50000.0",
2861                "colBorrAutoConversion": "0",
2862                "collateralEnabled": false,
2863                "collateralRestrict": false,
2864                "crossLiab": "0",
2865                "disEq": "50000.0",
2866                "eq": "1000.0",
2867                "eqUsd": "50000.0",
2868                "fixedBal": "0",
2869                "frozenBal": "0",
2870                "imr": "0",
2871                "interest": "0",
2872                "isoEq": "0",
2873                "isoLiab": "0",
2874                "isoUpl": "0",
2875                "liab": "0",
2876                "maxLoan": "0",
2877                "mgnRatio": "0",
2878                "maxSpotInUseAmt": "0",
2879                "mmr": "0",
2880                "notionalLever": "0",
2881                "openAvgPx": "",
2882                "ordFrozen": "0",
2883                "rewardBal": "0",
2884                "smtSyncEq": "0",
2885                "spotBal": "0",
2886                "spotCopyTradingEq": "0",
2887                "spotInUseAmt": "0",
2888                "spotIsoBal": "0",
2889                "spotUpl": "0",
2890                "spotUplRatio": "0",
2891                "stgyEq": "0",
2892                "totalPnl": "0",
2893                "totalPnlRatio": "0",
2894                "twap": "0",
2895                "uTime": "1704067200000",
2896                "upl": "0",
2897                "uplLiab": "0"
2898            }],
2899            "imr": "",
2900            "isoEq": "0",
2901            "mgnRatio": "",
2902            "mmr": "",
2903            "notionalUsd": "",
2904            "notionalUsdForBorrow": "",
2905            "notionalUsdForFutures": "",
2906            "notionalUsdForOption": "",
2907            "notionalUsdForSwap": "",
2908            "ordFroz": "",
2909            "totalEq": "50000.0",
2910            "uTime": "1704067200000",
2911            "upl": "0"
2912        }"#;
2913
2914        let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
2915        let account_id = AccountId::new("OKX-SPOT");
2916        let account_state =
2917            parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
2918
2919        // Verify no margins are created when fields are empty
2920        assert_eq!(account_state.margins.len(), 0);
2921        assert_eq!(account_state.balances.len(), 1);
2922
2923        // Check the BTC balance
2924        let btc_balance = &account_state.balances[0];
2925        assert_eq!(btc_balance.total, Money::new(1000.0, Currency::BTC()));
2926    }
2927
2928    #[rstest]
2929    fn test_parse_order_status_report() {
2930        let json_data = load_test_json("http_get_orders_history.json");
2931        let response: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
2932        let okx_order = response
2933            .data
2934            .first()
2935            .expect("Test data must have an order")
2936            .clone();
2937
2938        let account_id = AccountId::new("OKX-001");
2939        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2940        let order_report = parse_order_status_report(
2941            &okx_order,
2942            account_id,
2943            instrument_id,
2944            2,
2945            8,
2946            UnixNanos::default(),
2947        )
2948        .unwrap();
2949
2950        assert_eq!(order_report.account_id, account_id);
2951        assert_eq!(order_report.instrument_id, instrument_id);
2952        assert_eq!(order_report.quantity, Quantity::from("0.03000000"));
2953        assert_eq!(order_report.filled_qty, Quantity::from("0.03000000"));
2954        assert_eq!(order_report.order_side, OrderSide::Buy);
2955        assert_eq!(order_report.order_type, OrderType::Market);
2956        assert_eq!(order_report.order_status, OrderStatus::Filled);
2957    }
2958
2959    #[rstest]
2960    fn test_parse_position_status_report() {
2961        let json_data = load_test_json("http_get_positions.json");
2962        let response: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
2963        let okx_position = response
2964            .data
2965            .first()
2966            .expect("Test data must have a position")
2967            .clone();
2968
2969        let account_id = AccountId::new("OKX-001");
2970        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2971        let position_report = parse_position_status_report(
2972            okx_position,
2973            account_id,
2974            instrument_id,
2975            8,
2976            UnixNanos::default(),
2977        )
2978        .unwrap();
2979
2980        assert_eq!(position_report.account_id, account_id);
2981        assert_eq!(position_report.instrument_id, instrument_id);
2982    }
2983
2984    #[rstest]
2985    fn test_parse_trade_tick() {
2986        let json_data = load_test_json("http_get_trades.json");
2987        let response: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
2988        let okx_trade = response.data.first().expect("Test data must have a trade");
2989
2990        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2991        let trade_tick =
2992            parse_trade_tick(okx_trade, instrument_id, 2, 8, UnixNanos::default()).unwrap();
2993
2994        assert_eq!(trade_tick.instrument_id, instrument_id);
2995        assert_eq!(trade_tick.price, Price::from("102537.90"));
2996        assert_eq!(trade_tick.size, Quantity::from("0.00013669"));
2997        assert_eq!(trade_tick.aggressor_side, AggressorSide::Seller);
2998        assert_eq!(trade_tick.trade_id, TradeId::new("734864333"));
2999    }
3000
3001    #[rstest]
3002    fn test_parse_mark_price_update() {
3003        let json_data = load_test_json("http_get_mark_price.json");
3004        let response: OKXResponse<crate::http::models::OKXMarkPrice> =
3005            serde_json::from_str(&json_data).unwrap();
3006        let okx_mark_price = response
3007            .data
3008            .first()
3009            .expect("Test data must have a mark price");
3010
3011        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3012        let mark_price_update =
3013            parse_mark_price_update(okx_mark_price, instrument_id, 2, UnixNanos::default())
3014                .unwrap();
3015
3016        assert_eq!(mark_price_update.instrument_id, instrument_id);
3017        assert_eq!(mark_price_update.value, Price::from("84660.10"));
3018        assert_eq!(
3019            mark_price_update.ts_event,
3020            UnixNanos::from(1744590349506000000)
3021        );
3022    }
3023
3024    #[rstest]
3025    fn test_parse_index_price_update() {
3026        let json_data = load_test_json("http_get_index_price.json");
3027        let response: OKXResponse<crate::http::models::OKXIndexTicker> =
3028            serde_json::from_str(&json_data).unwrap();
3029        let okx_index_ticker = response
3030            .data
3031            .first()
3032            .expect("Test data must have an index ticker");
3033
3034        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3035        let index_price_update =
3036            parse_index_price_update(okx_index_ticker, instrument_id, 2, UnixNanos::default())
3037                .unwrap();
3038
3039        assert_eq!(index_price_update.instrument_id, instrument_id);
3040        assert_eq!(index_price_update.value, Price::from("103895.00"));
3041        assert_eq!(
3042            index_price_update.ts_event,
3043            UnixNanos::from(1746942707815000000)
3044        );
3045    }
3046
3047    #[rstest]
3048    fn test_parse_candlestick() {
3049        let json_data = load_test_json("http_get_candlesticks.json");
3050        let response: OKXResponse<crate::http::models::OKXCandlestick> =
3051            serde_json::from_str(&json_data).unwrap();
3052        let okx_candlestick = response
3053            .data
3054            .first()
3055            .expect("Test data must have a candlestick");
3056
3057        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3058        let bar_type = BarType::new(
3059            instrument_id,
3060            BAR_SPEC_1_DAY_LAST,
3061            AggregationSource::External,
3062        );
3063        let bar = parse_candlestick(okx_candlestick, bar_type, 2, 8, UnixNanos::default()).unwrap();
3064
3065        assert_eq!(bar.bar_type, bar_type);
3066        assert_eq!(bar.open, Price::from("33528.60"));
3067        assert_eq!(bar.high, Price::from("33870.00"));
3068        assert_eq!(bar.low, Price::from("33528.60"));
3069        assert_eq!(bar.close, Price::from("33783.90"));
3070        assert_eq!(bar.volume, Quantity::from("778.83800000"));
3071        assert_eq!(bar.ts_event, UnixNanos::from(1625097600000000000));
3072    }
3073
3074    #[rstest]
3075    fn test_parse_millisecond_timestamp() {
3076        let timestamp_ms = 1625097600000u64;
3077        let result = parse_millisecond_timestamp(timestamp_ms);
3078        assert_eq!(result, UnixNanos::from(1625097600000000000));
3079    }
3080
3081    #[rstest]
3082    fn test_parse_rfc3339_timestamp() {
3083        let timestamp_str = "2021-07-01T00:00:00.000Z";
3084        let result = parse_rfc3339_timestamp(timestamp_str).unwrap();
3085        assert_eq!(result, UnixNanos::from(1625097600000000000));
3086
3087        // Test with timezone
3088        let timestamp_str_tz = "2021-07-01T08:00:00.000+08:00";
3089        let result_tz = parse_rfc3339_timestamp(timestamp_str_tz).unwrap();
3090        assert_eq!(result_tz, UnixNanos::from(1625097600000000000));
3091
3092        // Test error case
3093        let invalid_timestamp = "invalid-timestamp";
3094        assert!(parse_rfc3339_timestamp(invalid_timestamp).is_err());
3095    }
3096
3097    #[rstest]
3098    fn test_parse_price() {
3099        let price_str = "42219.5";
3100        let precision = 2;
3101        let result = parse_price(price_str, precision).unwrap();
3102        assert_eq!(result, Price::from("42219.50"));
3103
3104        // Test error case
3105        let invalid_price = "invalid-price";
3106        assert!(parse_price(invalid_price, precision).is_err());
3107    }
3108
3109    #[rstest]
3110    fn test_parse_quantity() {
3111        let quantity_str = "0.12345678";
3112        let precision = 8;
3113        let result = parse_quantity(quantity_str, precision).unwrap();
3114        assert_eq!(result, Quantity::from("0.12345678"));
3115
3116        // Test error case
3117        let invalid_quantity = "invalid-quantity";
3118        assert!(parse_quantity(invalid_quantity, precision).is_err());
3119    }
3120
3121    #[rstest]
3122    fn test_parse_aggressor_side() {
3123        assert_eq!(
3124            parse_aggressor_side(&Some(OKXSide::Buy)),
3125            AggressorSide::Buyer
3126        );
3127        assert_eq!(
3128            parse_aggressor_side(&Some(OKXSide::Sell)),
3129            AggressorSide::Seller
3130        );
3131        assert_eq!(parse_aggressor_side(&None), AggressorSide::NoAggressor);
3132    }
3133
3134    #[rstest]
3135    fn test_parse_execution_type() {
3136        assert_eq!(
3137            parse_execution_type(&Some(OKXExecType::Maker)),
3138            LiquiditySide::Maker
3139        );
3140        assert_eq!(
3141            parse_execution_type(&Some(OKXExecType::Taker)),
3142            LiquiditySide::Taker
3143        );
3144        assert_eq!(parse_execution_type(&None), LiquiditySide::NoLiquiditySide);
3145    }
3146
3147    #[rstest]
3148    fn test_parse_position_side() {
3149        assert_eq!(parse_position_side(Some(100)), PositionSide::Long);
3150        assert_eq!(parse_position_side(Some(-100)), PositionSide::Short);
3151        assert_eq!(parse_position_side(Some(0)), PositionSide::Flat);
3152        assert_eq!(parse_position_side(None), PositionSide::Flat);
3153    }
3154
3155    #[rstest]
3156    fn test_parse_client_order_id() {
3157        let valid_id = "client_order_123";
3158        let result = parse_client_order_id(valid_id);
3159        assert_eq!(result, Some(ClientOrderId::new(valid_id)));
3160
3161        let empty_id = "";
3162        let result_empty = parse_client_order_id(empty_id);
3163        assert_eq!(result_empty, None);
3164    }
3165
3166    #[rstest]
3167    fn test_deserialize_empty_string_as_none() {
3168        let json_with_empty = r#""""#;
3169        let result: Option<String> = serde_json::from_str(json_with_empty).unwrap();
3170        let processed = result.filter(|s| !s.is_empty());
3171        assert_eq!(processed, None);
3172
3173        let json_with_value = r#""test_value""#;
3174        let result: Option<String> = serde_json::from_str(json_with_value).unwrap();
3175        let processed = result.filter(|s| !s.is_empty());
3176        assert_eq!(processed, Some("test_value".to_string()));
3177    }
3178
3179    #[rstest]
3180    fn test_deserialize_string_to_u64() {
3181        use serde::Deserialize;
3182
3183        #[derive(Deserialize)]
3184        struct TestStruct {
3185            #[serde(deserialize_with = "deserialize_string_to_u64")]
3186            value: u64,
3187        }
3188
3189        let json_value = r#"{"value": "12345"}"#;
3190        let result: TestStruct = serde_json::from_str(json_value).unwrap();
3191        assert_eq!(result.value, 12345);
3192
3193        let json_empty = r#"{"value": ""}"#;
3194        let result_empty: TestStruct = serde_json::from_str(json_empty).unwrap();
3195        assert_eq!(result_empty.value, 0);
3196    }
3197
3198    #[rstest]
3199    fn test_fill_report_parsing() {
3200        // Create a mock transaction detail for testing
3201        let transaction_detail = crate::http::models::OKXTransactionDetail {
3202            inst_type: OKXInstrumentType::Spot,
3203            inst_id: Ustr::from("BTC-USDT"),
3204            trade_id: Ustr::from("12345"),
3205            ord_id: Ustr::from("67890"),
3206            cl_ord_id: Ustr::from("client_123"),
3207            bill_id: Ustr::from("bill_456"),
3208            fill_px: "42219.5".to_string(),
3209            fill_sz: "0.001".to_string(),
3210            side: OKXSide::Buy,
3211            exec_type: OKXExecType::Taker,
3212            fee_ccy: "USDT".to_string(),
3213            fee: Some("0.042".to_string()),
3214            ts: 1625097600000,
3215        };
3216
3217        let account_id = AccountId::new("OKX-001");
3218        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3219        let fill_report = parse_fill_report(
3220            transaction_detail,
3221            account_id,
3222            instrument_id,
3223            2,
3224            8,
3225            UnixNanos::default(),
3226        )
3227        .unwrap();
3228
3229        assert_eq!(fill_report.account_id, account_id);
3230        assert_eq!(fill_report.instrument_id, instrument_id);
3231        assert_eq!(fill_report.trade_id, TradeId::new("12345"));
3232        assert_eq!(fill_report.venue_order_id, VenueOrderId::new("67890"));
3233        assert_eq!(fill_report.order_side, OrderSide::Buy);
3234        assert_eq!(fill_report.last_px, Price::from("42219.50"));
3235        assert_eq!(fill_report.last_qty, Quantity::from("0.00100000"));
3236        assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
3237    }
3238
3239    #[rstest]
3240    fn test_bar_type_identity_preserved_through_parse() {
3241        use std::str::FromStr;
3242
3243        use crate::http::models::OKXCandlestick;
3244
3245        // Create a BarType
3246        let bar_type = BarType::from_str("ETH-USDT-SWAP.OKX-1-MINUTE-LAST-EXTERNAL").unwrap();
3247
3248        // Create sample candlestick data
3249        let raw_candlestick = OKXCandlestick(
3250            "1721807460000".to_string(), // timestamp
3251            "3177.9".to_string(),        // open
3252            "3177.9".to_string(),        // high
3253            "3177.7".to_string(),        // low
3254            "3177.8".to_string(),        // close
3255            "18.603".to_string(),        // volume
3256            "59054.8231".to_string(),    // turnover
3257            "18.603".to_string(),        // base_volume
3258            "1".to_string(),             // count
3259        );
3260
3261        // Parse the candlestick
3262        let bar =
3263            parse_candlestick(&raw_candlestick, bar_type, 1, 3, UnixNanos::default()).unwrap();
3264
3265        // Verify that the BarType is preserved exactly
3266        assert_eq!(
3267            bar.bar_type, bar_type,
3268            "BarType must be preserved exactly through parsing"
3269        );
3270    }
3271
3272    #[rstest]
3273    fn test_deserialize_vip_level_all_formats() {
3274        use serde::Deserialize;
3275        use serde_json;
3276
3277        #[derive(Deserialize)]
3278        struct TestFeeRate {
3279            #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3280            level: OKXVipLevel,
3281        }
3282
3283        // Test VIP prefix format
3284        let json = r#"{"level":"VIP4"}"#;
3285        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3286        assert_eq!(result.level, OKXVipLevel::Vip4);
3287
3288        let json = r#"{"level":"VIP5"}"#;
3289        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3290        assert_eq!(result.level, OKXVipLevel::Vip5);
3291
3292        // Test Lv prefix format
3293        let json = r#"{"level":"Lv1"}"#;
3294        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3295        assert_eq!(result.level, OKXVipLevel::Vip1);
3296
3297        let json = r#"{"level":"Lv0"}"#;
3298        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3299        assert_eq!(result.level, OKXVipLevel::Vip0);
3300
3301        let json = r#"{"level":"Lv9"}"#;
3302        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3303        assert_eq!(result.level, OKXVipLevel::Vip9);
3304    }
3305
3306    #[rstest]
3307    fn test_deserialize_vip_level_empty_string() {
3308        use serde::Deserialize;
3309        use serde_json;
3310
3311        #[derive(Deserialize)]
3312        struct TestFeeRate {
3313            #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3314            level: OKXVipLevel,
3315        }
3316
3317        // Empty string should default to VIP0
3318        let json = r#"{"level":""}"#;
3319        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3320        assert_eq!(result.level, OKXVipLevel::Vip0);
3321    }
3322
3323    #[rstest]
3324    fn test_deserialize_vip_level_without_prefix() {
3325        use serde::Deserialize;
3326        use serde_json;
3327
3328        #[derive(Deserialize)]
3329        struct TestFeeRate {
3330            #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3331            level: OKXVipLevel,
3332        }
3333
3334        let json = r#"{"level":"5"}"#;
3335        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3336        assert_eq!(result.level, OKXVipLevel::Vip5);
3337    }
3338
3339    #[rstest]
3340    fn test_parse_position_status_report_net_mode_long() {
3341        // Test Net mode: positive quantity = Long position
3342        let position = OKXPosition {
3343            inst_id: Ustr::from("BTC-USDT-SWAP"),
3344            inst_type: OKXInstrumentType::Swap,
3345            mgn_mode: OKXMarginMode::Cross,
3346            pos_id: Some(Ustr::from("12345")),
3347            pos_side: OKXPositionSide::Net, // Net mode
3348            pos: "1.5".to_string(),         // Positive = Long
3349            base_bal: "1.5".to_string(),
3350            ccy: "BTC".to_string(),
3351            fee: "0.01".to_string(),
3352            lever: "10.0".to_string(),
3353            last: "50000".to_string(),
3354            mark_px: "50000".to_string(),
3355            liq_px: "45000".to_string(),
3356            mmr: "0.1".to_string(),
3357            interest: "0".to_string(),
3358            trade_id: Ustr::from("111"),
3359            notional_usd: "75000".to_string(),
3360            avg_px: "50000".to_string(),
3361            upl: "0".to_string(),
3362            upl_ratio: "0".to_string(),
3363            u_time: 1622559930237,
3364            margin: "0.5".to_string(),
3365            mgn_ratio: "0.01".to_string(),
3366            adl: "0".to_string(),
3367            c_time: "1622559930237".to_string(),
3368            realized_pnl: "0".to_string(),
3369            upl_last_px: "0".to_string(),
3370            upl_ratio_last_px: "0".to_string(),
3371            avail_pos: "1.5".to_string(),
3372            be_px: "0".to_string(),
3373            funding_fee: "0".to_string(),
3374            idx_px: "0".to_string(),
3375            liq_penalty: "0".to_string(),
3376            opt_val: "0".to_string(),
3377            pending_close_ord_liab_val: "0".to_string(),
3378            pnl: "0".to_string(),
3379            pos_ccy: "BTC".to_string(),
3380            quote_bal: "75000".to_string(),
3381            quote_borrowed: "0".to_string(),
3382            quote_interest: "0".to_string(),
3383            spot_in_use_amt: "0".to_string(),
3384            spot_in_use_ccy: "BTC".to_string(),
3385            usd_px: "50000".to_string(),
3386        };
3387
3388        let account_id = AccountId::new("OKX-001");
3389        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3390        let report = parse_position_status_report(
3391            position,
3392            account_id,
3393            instrument_id,
3394            8,
3395            UnixNanos::default(),
3396        )
3397        .unwrap();
3398
3399        assert_eq!(report.account_id, account_id);
3400        assert_eq!(report.instrument_id, instrument_id);
3401        assert_eq!(report.position_side, PositionSide::Long.as_specified());
3402        assert_eq!(report.quantity, Quantity::from("1.5"));
3403        // Net mode: venue_position_id is None (signals NETTING OMS)
3404        assert_eq!(report.venue_position_id, None);
3405    }
3406
3407    #[rstest]
3408    fn test_parse_position_status_report_net_mode_short() {
3409        // Test Net mode: negative quantity = Short position
3410        let position = OKXPosition {
3411            inst_id: Ustr::from("BTC-USDT-SWAP"),
3412            inst_type: OKXInstrumentType::Swap,
3413            mgn_mode: OKXMarginMode::Isolated,
3414            pos_id: Some(Ustr::from("67890")),
3415            pos_side: OKXPositionSide::Net, // Net mode
3416            pos: "-2.3".to_string(),        // Negative = Short
3417            base_bal: "2.3".to_string(),
3418            ccy: "BTC".to_string(),
3419            fee: "0.02".to_string(),
3420            lever: "5.0".to_string(),
3421            last: "50000".to_string(),
3422            mark_px: "50000".to_string(),
3423            liq_px: "55000".to_string(),
3424            mmr: "0.2".to_string(),
3425            interest: "0".to_string(),
3426            trade_id: Ustr::from("222"),
3427            notional_usd: "115000".to_string(),
3428            avg_px: "50000".to_string(),
3429            upl: "0".to_string(),
3430            upl_ratio: "0".to_string(),
3431            u_time: 1622559930237,
3432            margin: "1.0".to_string(),
3433            mgn_ratio: "0.02".to_string(),
3434            adl: "0".to_string(),
3435            c_time: "1622559930237".to_string(),
3436            realized_pnl: "0".to_string(),
3437            upl_last_px: "0".to_string(),
3438            upl_ratio_last_px: "0".to_string(),
3439            avail_pos: "2.3".to_string(),
3440            be_px: "0".to_string(),
3441            funding_fee: "0".to_string(),
3442            idx_px: "0".to_string(),
3443            liq_penalty: "0".to_string(),
3444            opt_val: "0".to_string(),
3445            pending_close_ord_liab_val: "0".to_string(),
3446            pnl: "0".to_string(),
3447            pos_ccy: "BTC".to_string(),
3448            quote_bal: "115000".to_string(),
3449            quote_borrowed: "0".to_string(),
3450            quote_interest: "0".to_string(),
3451            spot_in_use_amt: "0".to_string(),
3452            spot_in_use_ccy: "BTC".to_string(),
3453            usd_px: "50000".to_string(),
3454        };
3455
3456        let account_id = AccountId::new("OKX-001");
3457        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3458        let report = parse_position_status_report(
3459            position,
3460            account_id,
3461            instrument_id,
3462            8,
3463            UnixNanos::default(),
3464        )
3465        .unwrap();
3466
3467        assert_eq!(report.account_id, account_id);
3468        assert_eq!(report.instrument_id, instrument_id);
3469        assert_eq!(report.position_side, PositionSide::Short.as_specified());
3470        assert_eq!(report.quantity, Quantity::from("2.3")); // Absolute value
3471        // Net mode: venue_position_id is None (signals NETTING OMS)
3472        assert_eq!(report.venue_position_id, None);
3473    }
3474
3475    #[rstest]
3476    fn test_parse_position_status_report_net_mode_flat() {
3477        // Test Net mode: zero quantity = Flat position
3478        let position = OKXPosition {
3479            inst_id: Ustr::from("ETH-USDT-SWAP"),
3480            inst_type: OKXInstrumentType::Swap,
3481            mgn_mode: OKXMarginMode::Cross,
3482            pos_id: Some(Ustr::from("99999")),
3483            pos_side: OKXPositionSide::Net, // Net mode
3484            pos: "0".to_string(),           // Zero = Flat
3485            base_bal: "0".to_string(),
3486            ccy: "ETH".to_string(),
3487            fee: "0".to_string(),
3488            lever: "10.0".to_string(),
3489            last: "3000".to_string(),
3490            mark_px: "3000".to_string(),
3491            liq_px: "0".to_string(),
3492            mmr: "0".to_string(),
3493            interest: "0".to_string(),
3494            trade_id: Ustr::from("333"),
3495            notional_usd: "0".to_string(),
3496            avg_px: String::new(),
3497            upl: "0".to_string(),
3498            upl_ratio: "0".to_string(),
3499            u_time: 1622559930237,
3500            margin: "0".to_string(),
3501            mgn_ratio: "0".to_string(),
3502            adl: "0".to_string(),
3503            c_time: "1622559930237".to_string(),
3504            realized_pnl: "0".to_string(),
3505            upl_last_px: "0".to_string(),
3506            upl_ratio_last_px: "0".to_string(),
3507            avail_pos: "0".to_string(),
3508            be_px: "0".to_string(),
3509            funding_fee: "0".to_string(),
3510            idx_px: "0".to_string(),
3511            liq_penalty: "0".to_string(),
3512            opt_val: "0".to_string(),
3513            pending_close_ord_liab_val: "0".to_string(),
3514            pnl: "0".to_string(),
3515            pos_ccy: "ETH".to_string(),
3516            quote_bal: "0".to_string(),
3517            quote_borrowed: "0".to_string(),
3518            quote_interest: "0".to_string(),
3519            spot_in_use_amt: "0".to_string(),
3520            spot_in_use_ccy: "ETH".to_string(),
3521            usd_px: "3000".to_string(),
3522        };
3523
3524        let account_id = AccountId::new("OKX-001");
3525        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
3526        let report = parse_position_status_report(
3527            position,
3528            account_id,
3529            instrument_id,
3530            8,
3531            UnixNanos::default(),
3532        )
3533        .unwrap();
3534
3535        assert_eq!(report.account_id, account_id);
3536        assert_eq!(report.instrument_id, instrument_id);
3537        assert_eq!(report.position_side, PositionSide::Flat.as_specified());
3538        assert_eq!(report.quantity, Quantity::from("0"));
3539        // Net mode: venue_position_id is None (signals NETTING OMS)
3540        assert_eq!(report.venue_position_id, None);
3541    }
3542
3543    #[rstest]
3544    fn test_parse_position_status_report_long_short_mode_long() {
3545        // Test Long/Short mode: posSide="long" with positive quantity
3546        let position = OKXPosition {
3547            inst_id: Ustr::from("BTC-USDT-SWAP"),
3548            inst_type: OKXInstrumentType::Swap,
3549            mgn_mode: OKXMarginMode::Cross,
3550            pos_id: Some(Ustr::from("11111")),
3551            pos_side: OKXPositionSide::Long, // Long/Short mode - Long leg
3552            pos: "3.2".to_string(),          // Positive quantity (always positive in this mode)
3553            base_bal: "3.2".to_string(),
3554            ccy: "BTC".to_string(),
3555            fee: "0.01".to_string(),
3556            lever: "10.0".to_string(),
3557            last: "50000".to_string(),
3558            mark_px: "50000".to_string(),
3559            liq_px: "45000".to_string(),
3560            mmr: "0.1".to_string(),
3561            interest: "0".to_string(),
3562            trade_id: Ustr::from("444"),
3563            notional_usd: "160000".to_string(),
3564            avg_px: "50000".to_string(),
3565            upl: "0".to_string(),
3566            upl_ratio: "0".to_string(),
3567            u_time: 1622559930237,
3568            margin: "1.6".to_string(),
3569            mgn_ratio: "0.01".to_string(),
3570            adl: "0".to_string(),
3571            c_time: "1622559930237".to_string(),
3572            realized_pnl: "0".to_string(),
3573            upl_last_px: "0".to_string(),
3574            upl_ratio_last_px: "0".to_string(),
3575            avail_pos: "3.2".to_string(),
3576            be_px: "0".to_string(),
3577            funding_fee: "0".to_string(),
3578            idx_px: "0".to_string(),
3579            liq_penalty: "0".to_string(),
3580            opt_val: "0".to_string(),
3581            pending_close_ord_liab_val: "0".to_string(),
3582            pnl: "0".to_string(),
3583            pos_ccy: "BTC".to_string(),
3584            quote_bal: "160000".to_string(),
3585            quote_borrowed: "0".to_string(),
3586            quote_interest: "0".to_string(),
3587            spot_in_use_amt: "0".to_string(),
3588            spot_in_use_ccy: "BTC".to_string(),
3589            usd_px: "50000".to_string(),
3590        };
3591
3592        let account_id = AccountId::new("OKX-001");
3593        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3594        let report = parse_position_status_report(
3595            position,
3596            account_id,
3597            instrument_id,
3598            8,
3599            UnixNanos::default(),
3600        )
3601        .unwrap();
3602
3603        assert_eq!(report.account_id, account_id);
3604        assert_eq!(report.instrument_id, instrument_id);
3605        assert_eq!(report.position_side, PositionSide::Long.as_specified());
3606        assert_eq!(report.quantity, Quantity::from("3.2"));
3607        // Long/Short mode - Long leg: "-LONG" suffix
3608        assert_eq!(
3609            report.venue_position_id,
3610            Some(PositionId::new("11111-LONG"))
3611        );
3612    }
3613
3614    #[rstest]
3615    fn test_parse_position_status_report_long_short_mode_short() {
3616        // Test Long/Short mode: posSide="short" with positive quantity
3617        // This is the critical test - positive quantity but SHORT side!
3618        let position = OKXPosition {
3619            inst_id: Ustr::from("BTC-USDT-SWAP"),
3620            inst_type: OKXInstrumentType::Swap,
3621            mgn_mode: OKXMarginMode::Cross,
3622            pos_id: Some(Ustr::from("22222")),
3623            pos_side: OKXPositionSide::Short, // Long/Short mode - Short leg
3624            pos: "1.8".to_string(),           // Positive quantity (always positive in this mode)
3625            base_bal: "1.8".to_string(),
3626            ccy: "BTC".to_string(),
3627            fee: "0.02".to_string(),
3628            lever: "10.0".to_string(),
3629            last: "50000".to_string(),
3630            mark_px: "50000".to_string(),
3631            liq_px: "55000".to_string(),
3632            mmr: "0.2".to_string(),
3633            interest: "0".to_string(),
3634            trade_id: Ustr::from("555"),
3635            notional_usd: "90000".to_string(),
3636            avg_px: "50000".to_string(),
3637            upl: "0".to_string(),
3638            upl_ratio: "0".to_string(),
3639            u_time: 1622559930237,
3640            margin: "0.9".to_string(),
3641            mgn_ratio: "0.02".to_string(),
3642            adl: "0".to_string(),
3643            c_time: "1622559930237".to_string(),
3644            realized_pnl: "0".to_string(),
3645            upl_last_px: "0".to_string(),
3646            upl_ratio_last_px: "0".to_string(),
3647            avail_pos: "1.8".to_string(),
3648            be_px: "0".to_string(),
3649            funding_fee: "0".to_string(),
3650            idx_px: "0".to_string(),
3651            liq_penalty: "0".to_string(),
3652            opt_val: "0".to_string(),
3653            pending_close_ord_liab_val: "0".to_string(),
3654            pnl: "0".to_string(),
3655            pos_ccy: "BTC".to_string(),
3656            quote_bal: "90000".to_string(),
3657            quote_borrowed: "0".to_string(),
3658            quote_interest: "0".to_string(),
3659            spot_in_use_amt: "0".to_string(),
3660            spot_in_use_ccy: "BTC".to_string(),
3661            usd_px: "50000".to_string(),
3662        };
3663
3664        let account_id = AccountId::new("OKX-001");
3665        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3666        let report = parse_position_status_report(
3667            position,
3668            account_id,
3669            instrument_id,
3670            8,
3671            UnixNanos::default(),
3672        )
3673        .unwrap();
3674
3675        assert_eq!(report.account_id, account_id);
3676        assert_eq!(report.instrument_id, instrument_id);
3677        // This is the critical assertion: positive quantity but SHORT side
3678        assert_eq!(report.position_side, PositionSide::Short.as_specified());
3679        assert_eq!(report.quantity, Quantity::from("1.8"));
3680        // Long/Short mode - Short leg: "-SHORT" suffix
3681        assert_eq!(
3682            report.venue_position_id,
3683            Some(PositionId::new("22222-SHORT"))
3684        );
3685    }
3686
3687    #[rstest]
3688    fn test_parse_position_status_report_margin_long() {
3689        // Test MARGIN long position: pos_ccy = base currency (ETH)
3690        let position = OKXPosition {
3691            inst_id: Ustr::from("ETH-USDT"),
3692            inst_type: OKXInstrumentType::Margin,
3693            mgn_mode: OKXMarginMode::Cross,
3694            pos_id: Some(Ustr::from("margin-long-1")),
3695            pos_side: OKXPositionSide::Net,
3696            pos: "1.5".to_string(), // Total position (may include pending)
3697            base_bal: "1.5".to_string(),
3698            ccy: "ETH".to_string(),
3699            fee: "0".to_string(),
3700            lever: "3".to_string(),
3701            last: "4000".to_string(),
3702            mark_px: "4000".to_string(),
3703            liq_px: "3500".to_string(),
3704            mmr: "0.1".to_string(),
3705            interest: "0".to_string(),
3706            trade_id: Ustr::from("trade1"),
3707            notional_usd: "6000".to_string(),
3708            avg_px: "3800".to_string(), // Bought at 3800
3709            upl: "300".to_string(),
3710            upl_ratio: "0.05".to_string(),
3711            u_time: 1622559930237,
3712            margin: "2000".to_string(),
3713            mgn_ratio: "0.33".to_string(),
3714            adl: "0".to_string(),
3715            c_time: "1622559930237".to_string(),
3716            realized_pnl: "0".to_string(),
3717            upl_last_px: "300".to_string(),
3718            upl_ratio_last_px: "0.05".to_string(),
3719            avail_pos: "1.5".to_string(),
3720            be_px: "3800".to_string(),
3721            funding_fee: "0".to_string(),
3722            idx_px: "4000".to_string(),
3723            liq_penalty: "0".to_string(),
3724            opt_val: "0".to_string(),
3725            pending_close_ord_liab_val: "0".to_string(),
3726            pnl: "300".to_string(),
3727            pos_ccy: "ETH".to_string(), // pos_ccy = base = LONG
3728            quote_bal: "0".to_string(),
3729            quote_borrowed: "0".to_string(),
3730            quote_interest: "0".to_string(),
3731            spot_in_use_amt: "0".to_string(),
3732            spot_in_use_ccy: String::new(),
3733            usd_px: "4000".to_string(),
3734        };
3735
3736        let account_id = AccountId::new("OKX-001");
3737        let instrument_id = InstrumentId::from("ETH-USDT.OKX");
3738        let report = parse_position_status_report(
3739            position,
3740            account_id,
3741            instrument_id,
3742            4,
3743            UnixNanos::default(),
3744        )
3745        .unwrap();
3746
3747        assert_eq!(report.account_id, account_id);
3748        assert_eq!(report.instrument_id, instrument_id);
3749        assert_eq!(report.position_side, PositionSide::Long.as_specified());
3750        assert_eq!(report.quantity, Quantity::from("1.5")); // 1.5 ETH in base
3751        assert_eq!(report.venue_position_id, None); // Net mode
3752    }
3753
3754    #[rstest]
3755    fn test_parse_position_status_report_margin_short() {
3756        // Test MARGIN short position: pos_ccy = quote currency (USDT)
3757        // pos is in quote currency and needs conversion to base
3758        let position = OKXPosition {
3759            inst_id: Ustr::from("ETH-USDT"),
3760            inst_type: OKXInstrumentType::Margin,
3761            mgn_mode: OKXMarginMode::Cross,
3762            pos_id: Some(Ustr::from("margin-short-1")),
3763            pos_side: OKXPositionSide::Net,
3764            pos: "244.56".to_string(), // Position in quote currency (USDT)
3765            base_bal: "0".to_string(),
3766            ccy: "USDT".to_string(),
3767            fee: "0".to_string(),
3768            lever: "3".to_string(),
3769            last: "4092".to_string(),
3770            mark_px: "4092".to_string(),
3771            liq_px: "4500".to_string(),
3772            mmr: "0.1".to_string(),
3773            interest: "0".to_string(),
3774            trade_id: Ustr::from("trade2"),
3775            notional_usd: "244.56".to_string(),
3776            avg_px: "4092".to_string(), // Shorted at 4092
3777            upl: "-10".to_string(),
3778            upl_ratio: "-0.04".to_string(),
3779            u_time: 1622559930237,
3780            margin: "100".to_string(),
3781            mgn_ratio: "0.4".to_string(),
3782            adl: "0".to_string(),
3783            c_time: "1622559930237".to_string(),
3784            realized_pnl: "0".to_string(),
3785            upl_last_px: "-10".to_string(),
3786            upl_ratio_last_px: "-0.04".to_string(),
3787            avail_pos: "244.56".to_string(),
3788            be_px: "4092".to_string(),
3789            funding_fee: "0".to_string(),
3790            idx_px: "4092".to_string(),
3791            liq_penalty: "0".to_string(),
3792            opt_val: "0".to_string(),
3793            pending_close_ord_liab_val: "0".to_string(),
3794            pnl: "-10".to_string(),
3795            pos_ccy: "USDT".to_string(), // pos_ccy = quote indicates SHORT, pos in USDT
3796            quote_bal: "244.56".to_string(),
3797            quote_borrowed: "0".to_string(),
3798            quote_interest: "0".to_string(),
3799            spot_in_use_amt: "0".to_string(),
3800            spot_in_use_ccy: String::new(),
3801            usd_px: "4092".to_string(),
3802        };
3803
3804        let account_id = AccountId::new("OKX-001");
3805        let instrument_id = InstrumentId::from("ETH-USDT.OKX");
3806        let report = parse_position_status_report(
3807            position,
3808            account_id,
3809            instrument_id,
3810            4,
3811            UnixNanos::default(),
3812        )
3813        .unwrap();
3814
3815        assert_eq!(report.account_id, account_id);
3816        assert_eq!(report.instrument_id, instrument_id);
3817        assert_eq!(report.position_side, PositionSide::Short.as_specified());
3818        // Position is 244.56 USDT / 4092 USDT/ETH = 0.0597... ETH
3819        assert_eq!(report.quantity.to_string(), "0.0598");
3820        assert_eq!(report.venue_position_id, None); // Net mode
3821    }
3822
3823    #[rstest]
3824    fn test_parse_position_status_report_margin_flat() {
3825        // Test MARGIN flat position: pos_ccy is empty string
3826        let position = OKXPosition {
3827            inst_id: Ustr::from("ETH-USDT"),
3828            inst_type: OKXInstrumentType::Margin,
3829            mgn_mode: OKXMarginMode::Cross,
3830            pos_id: Some(Ustr::from("margin-flat-1")),
3831            pos_side: OKXPositionSide::Net,
3832            pos: "0".to_string(),
3833            base_bal: "0".to_string(),
3834            ccy: "ETH".to_string(),
3835            fee: "0".to_string(),
3836            lever: "0".to_string(),
3837            last: "4000".to_string(),
3838            mark_px: "4000".to_string(),
3839            liq_px: "0".to_string(),
3840            mmr: "0".to_string(),
3841            interest: "0".to_string(),
3842            trade_id: Ustr::from(""),
3843            notional_usd: "0".to_string(),
3844            avg_px: String::new(),
3845            upl: "0".to_string(),
3846            upl_ratio: "0".to_string(),
3847            u_time: 1622559930237,
3848            margin: "0".to_string(),
3849            mgn_ratio: "0".to_string(),
3850            adl: "0".to_string(),
3851            c_time: "1622559930237".to_string(),
3852            realized_pnl: "0".to_string(),
3853            upl_last_px: "0".to_string(),
3854            upl_ratio_last_px: "0".to_string(),
3855            avail_pos: "0".to_string(),
3856            be_px: "0".to_string(),
3857            funding_fee: "0".to_string(),
3858            idx_px: "0".to_string(),
3859            liq_penalty: "0".to_string(),
3860            opt_val: "0".to_string(),
3861            pending_close_ord_liab_val: "0".to_string(),
3862            pnl: "0".to_string(),
3863            pos_ccy: String::new(), // Empty pos_ccy = FLAT
3864            quote_bal: "0".to_string(),
3865            quote_borrowed: "0".to_string(),
3866            quote_interest: "0".to_string(),
3867            spot_in_use_amt: "0".to_string(),
3868            spot_in_use_ccy: String::new(),
3869            usd_px: "0".to_string(),
3870        };
3871
3872        let account_id = AccountId::new("OKX-001");
3873        let instrument_id = InstrumentId::from("ETH-USDT.OKX");
3874        let report = parse_position_status_report(
3875            position,
3876            account_id,
3877            instrument_id,
3878            4,
3879            UnixNanos::default(),
3880        )
3881        .unwrap();
3882
3883        assert_eq!(report.account_id, account_id);
3884        assert_eq!(report.instrument_id, instrument_id);
3885        assert_eq!(report.position_side, PositionSide::Flat.as_specified());
3886        assert_eq!(report.quantity, Quantity::from("0"));
3887        assert_eq!(report.venue_position_id, None); // Net mode
3888    }
3889
3890    #[rstest]
3891    fn test_parse_swap_instrument_empty_underlying_returns_error() {
3892        let instrument = OKXInstrument {
3893            inst_type: OKXInstrumentType::Swap,
3894            inst_id: Ustr::from("ETH-USD_UM-SWAP"),
3895            uly: Ustr::from(""), // Empty underlying
3896            inst_family: Ustr::from(""),
3897            base_ccy: Ustr::from(""),
3898            quote_ccy: Ustr::from(""),
3899            settle_ccy: Ustr::from("USD"),
3900            ct_val: "1".to_string(),
3901            ct_mult: "1".to_string(),
3902            ct_val_ccy: "USD".to_string(),
3903            opt_type: crate::common::enums::OKXOptionType::None,
3904            stk: String::new(),
3905            list_time: None,
3906            exp_time: None,
3907            lever: String::new(),
3908            tick_sz: "0.1".to_string(),
3909            lot_sz: "1".to_string(),
3910            min_sz: "1".to_string(),
3911            ct_type: OKXContractType::Linear,
3912            state: crate::common::enums::OKXInstrumentStatus::Preopen,
3913            rule_type: String::new(),
3914            max_lmt_sz: String::new(),
3915            max_mkt_sz: String::new(),
3916            max_lmt_amt: String::new(),
3917            max_mkt_amt: String::new(),
3918            max_twap_sz: String::new(),
3919            max_iceberg_sz: String::new(),
3920            max_trigger_sz: String::new(),
3921            max_stop_sz: String::new(),
3922            inst_id_code: None,
3923        };
3924
3925        let result =
3926            parse_swap_instrument(&instrument, None, None, None, None, UnixNanos::default());
3927        assert!(result.is_err());
3928        assert!(result.unwrap_err().to_string().contains("Empty underlying"));
3929    }
3930
3931    #[rstest]
3932    fn test_parse_futures_instrument_empty_underlying_returns_error() {
3933        let instrument = OKXInstrument {
3934            inst_type: OKXInstrumentType::Futures,
3935            inst_id: Ustr::from("ETH-USD_UM-250328"),
3936            uly: Ustr::from(""), // Empty underlying
3937            inst_family: Ustr::from(""),
3938            base_ccy: Ustr::from(""),
3939            quote_ccy: Ustr::from(""),
3940            settle_ccy: Ustr::from("USD"),
3941            ct_val: "1".to_string(),
3942            ct_mult: "1".to_string(),
3943            ct_val_ccy: "USD".to_string(),
3944            opt_type: crate::common::enums::OKXOptionType::None,
3945            stk: String::new(),
3946            list_time: None,
3947            exp_time: Some(1743004800000),
3948            lever: String::new(),
3949            tick_sz: "0.1".to_string(),
3950            lot_sz: "1".to_string(),
3951            min_sz: "1".to_string(),
3952            ct_type: OKXContractType::Linear,
3953            state: crate::common::enums::OKXInstrumentStatus::Preopen,
3954            rule_type: String::new(),
3955            max_lmt_sz: String::new(),
3956            max_mkt_sz: String::new(),
3957            max_lmt_amt: String::new(),
3958            max_mkt_amt: String::new(),
3959            max_twap_sz: String::new(),
3960            max_iceberg_sz: String::new(),
3961            max_trigger_sz: String::new(),
3962            max_stop_sz: String::new(),
3963            inst_id_code: None,
3964        };
3965
3966        let result =
3967            parse_futures_instrument(&instrument, None, None, None, None, UnixNanos::default());
3968        assert!(result.is_err());
3969        assert!(result.unwrap_err().to_string().contains("Empty underlying"));
3970    }
3971
3972    #[rstest]
3973    fn test_parse_option_instrument_empty_underlying_returns_error() {
3974        let instrument = OKXInstrument {
3975            inst_type: OKXInstrumentType::Option,
3976            inst_id: Ustr::from("BTC-USD-250328-50000-C"),
3977            uly: Ustr::from(""), // Empty underlying
3978            inst_family: Ustr::from(""),
3979            base_ccy: Ustr::from(""),
3980            quote_ccy: Ustr::from(""),
3981            settle_ccy: Ustr::from("USD"),
3982            ct_val: "0.01".to_string(),
3983            ct_mult: "1".to_string(),
3984            ct_val_ccy: "BTC".to_string(),
3985            opt_type: crate::common::enums::OKXOptionType::Call,
3986            stk: "50000".to_string(),
3987            list_time: None,
3988            exp_time: Some(1743004800000),
3989            lever: String::new(),
3990            tick_sz: "0.0005".to_string(),
3991            lot_sz: "0.1".to_string(),
3992            min_sz: "0.1".to_string(),
3993            ct_type: OKXContractType::Linear,
3994            state: crate::common::enums::OKXInstrumentStatus::Preopen,
3995            rule_type: String::new(),
3996            max_lmt_sz: String::new(),
3997            max_mkt_sz: String::new(),
3998            max_lmt_amt: String::new(),
3999            max_mkt_amt: String::new(),
4000            max_twap_sz: String::new(),
4001            max_iceberg_sz: String::new(),
4002            max_trigger_sz: String::new(),
4003            max_stop_sz: String::new(),
4004            inst_id_code: None,
4005        };
4006
4007        let result =
4008            parse_option_instrument(&instrument, None, None, None, None, UnixNanos::default());
4009        assert!(result.is_err());
4010        assert!(result.unwrap_err().to_string().contains("Empty underlying"));
4011    }
4012
4013    #[rstest]
4014    fn test_parse_spot_margin_position_from_balance_short_usdt() {
4015        let balance = OKXBalanceDetail {
4016            ccy: Ustr::from("ENA"),
4017            liab: "130047.3610487126".to_string(),
4018            spot_in_use_amt: "-129950".to_string(),
4019            cross_liab: "130047.3610487126".to_string(),
4020            eq: "-130047.3610487126".to_string(),
4021            u_time: 1704067200000,
4022            avail_bal: "0".to_string(),
4023            avail_eq: "0".to_string(),
4024            borrow_froz: "0".to_string(),
4025            cash_bal: "0".to_string(),
4026            dis_eq: "0".to_string(),
4027            eq_usd: "0".to_string(),
4028            smt_sync_eq: "0".to_string(),
4029            spot_copy_trading_eq: "0".to_string(),
4030            fixed_bal: "0".to_string(),
4031            frozen_bal: "0".to_string(),
4032            imr: "0".to_string(),
4033            interest: "0".to_string(),
4034            iso_eq: "0".to_string(),
4035            iso_liab: "0".to_string(),
4036            iso_upl: "0".to_string(),
4037            max_loan: "0".to_string(),
4038            mgn_ratio: "0".to_string(),
4039            mmr: "0".to_string(),
4040            notional_lever: "0".to_string(),
4041            ord_frozen: "0".to_string(),
4042            reward_bal: "0".to_string(),
4043            cl_spot_in_use_amt: "0".to_string(),
4044            max_spot_in_use_amt: "0".to_string(),
4045            spot_iso_bal: "0".to_string(),
4046            stgy_eq: "0".to_string(),
4047            twap: "0".to_string(),
4048            upl: "0".to_string(),
4049            upl_liab: "0".to_string(),
4050            spot_bal: "0".to_string(),
4051            open_avg_px: "0".to_string(),
4052            acc_avg_px: "0".to_string(),
4053            spot_upl: "0".to_string(),
4054            spot_upl_ratio: "0".to_string(),
4055            total_pnl: "0".to_string(),
4056            total_pnl_ratio: "0".to_string(),
4057        };
4058
4059        let account_id = AccountId::new("OKX-001");
4060        let size_precision = 2;
4061        let ts_init = UnixNanos::default();
4062
4063        let result = parse_spot_margin_position_from_balance(
4064            &balance,
4065            account_id,
4066            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4067            size_precision,
4068            ts_init,
4069        )
4070        .unwrap();
4071
4072        assert!(result.is_some());
4073        let report = result.unwrap();
4074        assert_eq!(report.account_id, account_id);
4075        assert_eq!(report.instrument_id.to_string(), "ENA-USDT.OKX".to_string());
4076        assert_eq!(report.position_side, PositionSide::Short.as_specified());
4077        assert_eq!(report.quantity.to_string(), "129950.00");
4078    }
4079
4080    #[rstest]
4081    fn test_parse_spot_margin_position_from_balance_long() {
4082        let balance = OKXBalanceDetail {
4083            ccy: Ustr::from("BTC"),
4084            liab: "1.5".to_string(),
4085            spot_in_use_amt: "1.2".to_string(),
4086            cross_liab: "1.5".to_string(),
4087            eq: "1.2".to_string(),
4088            u_time: 1704067200000,
4089            avail_bal: "0".to_string(),
4090            avail_eq: "0".to_string(),
4091            borrow_froz: "0".to_string(),
4092            cash_bal: "0".to_string(),
4093            dis_eq: "0".to_string(),
4094            eq_usd: "0".to_string(),
4095            smt_sync_eq: "0".to_string(),
4096            spot_copy_trading_eq: "0".to_string(),
4097            fixed_bal: "0".to_string(),
4098            frozen_bal: "0".to_string(),
4099            imr: "0".to_string(),
4100            interest: "0".to_string(),
4101            iso_eq: "0".to_string(),
4102            iso_liab: "0".to_string(),
4103            iso_upl: "0".to_string(),
4104            max_loan: "0".to_string(),
4105            mgn_ratio: "0".to_string(),
4106            mmr: "0".to_string(),
4107            notional_lever: "0".to_string(),
4108            ord_frozen: "0".to_string(),
4109            reward_bal: "0".to_string(),
4110            cl_spot_in_use_amt: "0".to_string(),
4111            max_spot_in_use_amt: "0".to_string(),
4112            spot_iso_bal: "0".to_string(),
4113            stgy_eq: "0".to_string(),
4114            twap: "0".to_string(),
4115            upl: "0".to_string(),
4116            upl_liab: "0".to_string(),
4117            spot_bal: "0".to_string(),
4118            open_avg_px: "0".to_string(),
4119            acc_avg_px: "0".to_string(),
4120            spot_upl: "0".to_string(),
4121            spot_upl_ratio: "0".to_string(),
4122            total_pnl: "0".to_string(),
4123            total_pnl_ratio: "0".to_string(),
4124        };
4125
4126        let account_id = AccountId::new("OKX-001");
4127        let size_precision = 8;
4128        let ts_init = UnixNanos::default();
4129
4130        let result = parse_spot_margin_position_from_balance(
4131            &balance,
4132            account_id,
4133            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4134            size_precision,
4135            ts_init,
4136        )
4137        .unwrap();
4138
4139        assert!(result.is_some());
4140        let report = result.unwrap();
4141        assert_eq!(report.position_side, PositionSide::Long.as_specified());
4142        assert_eq!(report.quantity.to_string(), "1.20000000");
4143    }
4144
4145    #[rstest]
4146    fn test_parse_spot_margin_position_from_balance_usdc_quote() {
4147        let balance = OKXBalanceDetail {
4148            ccy: Ustr::from("ETH"),
4149            liab: "10.5".to_string(),
4150            spot_in_use_amt: "-10.0".to_string(),
4151            cross_liab: "10.5".to_string(),
4152            eq: "-10.0".to_string(),
4153            u_time: 1704067200000,
4154            avail_bal: "0".to_string(),
4155            avail_eq: "0".to_string(),
4156            borrow_froz: "0".to_string(),
4157            cash_bal: "0".to_string(),
4158            dis_eq: "0".to_string(),
4159            eq_usd: "0".to_string(),
4160            smt_sync_eq: "0".to_string(),
4161            spot_copy_trading_eq: "0".to_string(),
4162            fixed_bal: "0".to_string(),
4163            frozen_bal: "0".to_string(),
4164            imr: "0".to_string(),
4165            interest: "0".to_string(),
4166            iso_eq: "0".to_string(),
4167            iso_liab: "0".to_string(),
4168            iso_upl: "0".to_string(),
4169            max_loan: "0".to_string(),
4170            mgn_ratio: "0".to_string(),
4171            mmr: "0".to_string(),
4172            notional_lever: "0".to_string(),
4173            ord_frozen: "0".to_string(),
4174            reward_bal: "0".to_string(),
4175            cl_spot_in_use_amt: "0".to_string(),
4176            max_spot_in_use_amt: "0".to_string(),
4177            spot_iso_bal: "0".to_string(),
4178            stgy_eq: "0".to_string(),
4179            twap: "0".to_string(),
4180            upl: "0".to_string(),
4181            upl_liab: "0".to_string(),
4182            spot_bal: "0".to_string(),
4183            open_avg_px: "0".to_string(),
4184            acc_avg_px: "0".to_string(),
4185            spot_upl: "0".to_string(),
4186            spot_upl_ratio: "0".to_string(),
4187            total_pnl: "0".to_string(),
4188            total_pnl_ratio: "0".to_string(),
4189        };
4190
4191        let account_id = AccountId::new("OKX-001");
4192        let size_precision = 6;
4193        let ts_init = UnixNanos::default();
4194
4195        let result = parse_spot_margin_position_from_balance(
4196            &balance,
4197            account_id,
4198            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4199            size_precision,
4200            ts_init,
4201        )
4202        .unwrap();
4203
4204        assert!(result.is_some());
4205        let report = result.unwrap();
4206        assert_eq!(report.position_side, PositionSide::Short.as_specified());
4207        assert_eq!(report.quantity.to_string(), "10.000000");
4208        assert!(report.instrument_id.to_string().contains("ETH-"));
4209    }
4210
4211    #[rstest]
4212    fn test_parse_spot_margin_position_from_balance_no_position() {
4213        let balance = OKXBalanceDetail {
4214            ccy: Ustr::from("USDT"),
4215            liab: "0".to_string(),
4216            spot_in_use_amt: "0".to_string(),
4217            cross_liab: "0".to_string(),
4218            eq: "1000.5".to_string(),
4219            u_time: 1704067200000,
4220            avail_bal: "1000.5".to_string(),
4221            avail_eq: "1000.5".to_string(),
4222            borrow_froz: "0".to_string(),
4223            cash_bal: "1000.5".to_string(),
4224            dis_eq: "0".to_string(),
4225            eq_usd: "1000.5".to_string(),
4226            smt_sync_eq: "0".to_string(),
4227            spot_copy_trading_eq: "0".to_string(),
4228            fixed_bal: "0".to_string(),
4229            frozen_bal: "0".to_string(),
4230            imr: "0".to_string(),
4231            interest: "0".to_string(),
4232            iso_eq: "0".to_string(),
4233            iso_liab: "0".to_string(),
4234            iso_upl: "0".to_string(),
4235            max_loan: "0".to_string(),
4236            mgn_ratio: "0".to_string(),
4237            mmr: "0".to_string(),
4238            notional_lever: "0".to_string(),
4239            ord_frozen: "0".to_string(),
4240            reward_bal: "0".to_string(),
4241            cl_spot_in_use_amt: "0".to_string(),
4242            max_spot_in_use_amt: "0".to_string(),
4243            spot_iso_bal: "0".to_string(),
4244            stgy_eq: "0".to_string(),
4245            twap: "0".to_string(),
4246            upl: "0".to_string(),
4247            upl_liab: "0".to_string(),
4248            spot_bal: "1000.5".to_string(),
4249            open_avg_px: "0".to_string(),
4250            acc_avg_px: "0".to_string(),
4251            spot_upl: "0".to_string(),
4252            spot_upl_ratio: "0".to_string(),
4253            total_pnl: "0".to_string(),
4254            total_pnl_ratio: "0".to_string(),
4255        };
4256
4257        let account_id = AccountId::new("OKX-001");
4258        let size_precision = 2;
4259        let ts_init = UnixNanos::default();
4260
4261        let result = parse_spot_margin_position_from_balance(
4262            &balance,
4263            account_id,
4264            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4265            size_precision,
4266            ts_init,
4267        )
4268        .unwrap();
4269
4270        assert!(result.is_none());
4271    }
4272
4273    #[rstest]
4274    fn test_parse_spot_margin_position_from_balance_liability_no_spot_in_use() {
4275        let balance = OKXBalanceDetail {
4276            ccy: Ustr::from("BTC"),
4277            liab: "0.5".to_string(),
4278            spot_in_use_amt: "0".to_string(),
4279            cross_liab: "0.5".to_string(),
4280            eq: "0".to_string(),
4281            u_time: 1704067200000,
4282            avail_bal: "0".to_string(),
4283            avail_eq: "0".to_string(),
4284            borrow_froz: "0".to_string(),
4285            cash_bal: "0".to_string(),
4286            dis_eq: "0".to_string(),
4287            eq_usd: "0".to_string(),
4288            smt_sync_eq: "0".to_string(),
4289            spot_copy_trading_eq: "0".to_string(),
4290            fixed_bal: "0".to_string(),
4291            frozen_bal: "0".to_string(),
4292            imr: "0".to_string(),
4293            interest: "0".to_string(),
4294            iso_eq: "0".to_string(),
4295            iso_liab: "0".to_string(),
4296            iso_upl: "0".to_string(),
4297            max_loan: "0".to_string(),
4298            mgn_ratio: "0".to_string(),
4299            mmr: "0".to_string(),
4300            notional_lever: "0".to_string(),
4301            ord_frozen: "0".to_string(),
4302            reward_bal: "0".to_string(),
4303            cl_spot_in_use_amt: "0".to_string(),
4304            max_spot_in_use_amt: "0".to_string(),
4305            spot_iso_bal: "0".to_string(),
4306            stgy_eq: "0".to_string(),
4307            twap: "0".to_string(),
4308            upl: "0".to_string(),
4309            upl_liab: "0".to_string(),
4310            spot_bal: "0".to_string(),
4311            open_avg_px: "0".to_string(),
4312            acc_avg_px: "0".to_string(),
4313            spot_upl: "0".to_string(),
4314            spot_upl_ratio: "0".to_string(),
4315            total_pnl: "0".to_string(),
4316            total_pnl_ratio: "0".to_string(),
4317        };
4318
4319        let account_id = AccountId::new("OKX-001");
4320        let size_precision = 8;
4321        let ts_init = UnixNanos::default();
4322
4323        let result = parse_spot_margin_position_from_balance(
4324            &balance,
4325            account_id,
4326            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4327            size_precision,
4328            ts_init,
4329        )
4330        .unwrap();
4331
4332        assert!(result.is_none());
4333    }
4334
4335    #[rstest]
4336    fn test_parse_spot_margin_position_from_balance_empty_strings() {
4337        let balance = OKXBalanceDetail {
4338            ccy: Ustr::from("USDT"),
4339            liab: String::new(),
4340            spot_in_use_amt: String::new(),
4341            cross_liab: String::new(),
4342            eq: "5000.25".to_string(),
4343            u_time: 1704067200000,
4344            avail_bal: "5000.25".to_string(),
4345            avail_eq: "5000.25".to_string(),
4346            borrow_froz: String::new(),
4347            cash_bal: "5000.25".to_string(),
4348            dis_eq: String::new(),
4349            eq_usd: "5000.25".to_string(),
4350            smt_sync_eq: String::new(),
4351            spot_copy_trading_eq: String::new(),
4352            fixed_bal: String::new(),
4353            frozen_bal: String::new(),
4354            imr: String::new(),
4355            interest: String::new(),
4356            iso_eq: String::new(),
4357            iso_liab: String::new(),
4358            iso_upl: String::new(),
4359            max_loan: String::new(),
4360            mgn_ratio: String::new(),
4361            mmr: String::new(),
4362            notional_lever: String::new(),
4363            ord_frozen: String::new(),
4364            reward_bal: String::new(),
4365            cl_spot_in_use_amt: String::new(),
4366            max_spot_in_use_amt: String::new(),
4367            spot_iso_bal: String::new(),
4368            stgy_eq: String::new(),
4369            twap: String::new(),
4370            upl: String::new(),
4371            upl_liab: String::new(),
4372            spot_bal: "5000.25".to_string(),
4373            open_avg_px: String::new(),
4374            acc_avg_px: String::new(),
4375            spot_upl: String::new(),
4376            spot_upl_ratio: String::new(),
4377            total_pnl: String::new(),
4378            total_pnl_ratio: String::new(),
4379        };
4380
4381        let account_id = AccountId::new("OKX-001");
4382        let size_precision = 2;
4383        let ts_init = UnixNanos::default();
4384
4385        let result = parse_spot_margin_position_from_balance(
4386            &balance,
4387            account_id,
4388            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4389            size_precision,
4390            ts_init,
4391        )
4392        .unwrap();
4393
4394        // Empty strings should be treated as zero, returning None (no margin position)
4395        assert!(result.is_none());
4396    }
4397
4398    #[rstest]
4399    #[case::fok_maps_to_fok_tif(OKXOrderType::Fok, TimeInForce::Fok)]
4400    #[case::ioc_maps_to_ioc_tif(OKXOrderType::Ioc, TimeInForce::Ioc)]
4401    #[case::optimal_limit_ioc_maps_to_ioc_tif(OKXOrderType::OptimalLimitIoc, TimeInForce::Ioc)]
4402    #[case::market_maps_to_gtc(OKXOrderType::Market, TimeInForce::Gtc)]
4403    #[case::limit_maps_to_gtc(OKXOrderType::Limit, TimeInForce::Gtc)]
4404    #[case::post_only_maps_to_gtc(OKXOrderType::PostOnly, TimeInForce::Gtc)]
4405    #[case::trigger_maps_to_gtc(OKXOrderType::Trigger, TimeInForce::Gtc)]
4406    fn test_okx_order_type_to_time_in_force(
4407        #[case] okx_ord_type: OKXOrderType,
4408        #[case] expected_tif: TimeInForce,
4409    ) {
4410        let time_in_force = match okx_ord_type {
4411            OKXOrderType::Fok => TimeInForce::Fok,
4412            OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
4413            _ => TimeInForce::Gtc,
4414        };
4415
4416        assert_eq!(
4417            time_in_force, expected_tif,
4418            "OKXOrderType::{okx_ord_type:?} should map to TimeInForce::{expected_tif:?}"
4419        );
4420    }
4421
4422    #[rstest]
4423    fn test_fok_order_type_serialization() {
4424        let ord_type = OKXOrderType::Fok;
4425        let json = serde_json::to_string(&ord_type).expect("serialize");
4426        assert_eq!(json, "\"fok\"", "FOK should serialize to 'fok'");
4427    }
4428
4429    #[rstest]
4430    fn test_ioc_order_type_serialization() {
4431        let ord_type = OKXOrderType::Ioc;
4432        let json = serde_json::to_string(&ord_type).expect("serialize");
4433        assert_eq!(json, "\"ioc\"", "IOC should serialize to 'ioc'");
4434    }
4435
4436    #[rstest]
4437    fn test_optimal_limit_ioc_serialization() {
4438        let ord_type = OKXOrderType::OptimalLimitIoc;
4439        let json = serde_json::to_string(&ord_type).expect("serialize");
4440        assert_eq!(
4441            json, "\"optimal_limit_ioc\"",
4442            "OptimalLimitIoc should serialize to 'optimal_limit_ioc'"
4443        );
4444    }
4445
4446    #[rstest]
4447    fn test_fok_order_type_deserialization() {
4448        let json = "\"fok\"";
4449        let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
4450        assert_eq!(ord_type, OKXOrderType::Fok);
4451    }
4452
4453    #[rstest]
4454    fn test_ioc_order_type_deserialization() {
4455        let json = "\"ioc\"";
4456        let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
4457        assert_eq!(ord_type, OKXOrderType::Ioc);
4458    }
4459
4460    #[rstest]
4461    fn test_optimal_limit_ioc_deserialization() {
4462        let json = "\"optimal_limit_ioc\"";
4463        let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
4464        assert_eq!(ord_type, OKXOrderType::OptimalLimitIoc);
4465    }
4466
4467    #[rstest]
4468    #[case(TimeInForce::Fok, OKXOrderType::Fok)]
4469    #[case(TimeInForce::Ioc, OKXOrderType::Ioc)]
4470    fn test_time_in_force_round_trip(
4471        #[case] original_tif: TimeInForce,
4472        #[case] expected_okx_type: OKXOrderType,
4473    ) {
4474        let okx_ord_type = match original_tif {
4475            TimeInForce::Fok => OKXOrderType::Fok,
4476            TimeInForce::Ioc => OKXOrderType::Ioc,
4477            TimeInForce::Gtc => OKXOrderType::Limit,
4478            _ => OKXOrderType::Limit,
4479        };
4480        assert_eq!(okx_ord_type, expected_okx_type);
4481
4482        let parsed_tif = match okx_ord_type {
4483            OKXOrderType::Fok => TimeInForce::Fok,
4484            OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
4485            _ => TimeInForce::Gtc,
4486        };
4487        assert_eq!(parsed_tif, original_tif);
4488    }
4489
4490    #[rstest]
4491    #[case::limit_fok(
4492        OrderType::Limit,
4493        TimeInForce::Fok,
4494        OKXOrderType::Fok,
4495        "Limit + FOK should map to Fok"
4496    )]
4497    #[case::limit_ioc(
4498        OrderType::Limit,
4499        TimeInForce::Ioc,
4500        OKXOrderType::Ioc,
4501        "Limit + IOC should map to Ioc"
4502    )]
4503    #[case::market_ioc(
4504        OrderType::Market,
4505        TimeInForce::Ioc,
4506        OKXOrderType::OptimalLimitIoc,
4507        "Market + IOC should map to OptimalLimitIoc"
4508    )]
4509    #[case::limit_gtc(
4510        OrderType::Limit,
4511        TimeInForce::Gtc,
4512        OKXOrderType::Limit,
4513        "Limit + GTC should map to Limit"
4514    )]
4515    #[case::market_gtc(
4516        OrderType::Market,
4517        TimeInForce::Gtc,
4518        OKXOrderType::Market,
4519        "Market + GTC should map to Market"
4520    )]
4521    fn test_order_type_time_in_force_combinations(
4522        #[case] order_type: OrderType,
4523        #[case] tif: TimeInForce,
4524        #[case] expected_okx_type: OKXOrderType,
4525        #[case] description: &str,
4526    ) {
4527        let okx_ord_type = match (order_type, tif) {
4528            (OrderType::Market, TimeInForce::Ioc) => OKXOrderType::OptimalLimitIoc,
4529            (OrderType::Limit, TimeInForce::Fok) => OKXOrderType::Fok,
4530            (OrderType::Limit, TimeInForce::Ioc) => OKXOrderType::Ioc,
4531            _ => OKXOrderType::from(order_type),
4532        };
4533
4534        assert_eq!(okx_ord_type, expected_okx_type, "{description}");
4535    }
4536
4537    #[rstest]
4538    fn test_market_fok_not_supported() {
4539        let order_type = OrderType::Market;
4540        let tif = TimeInForce::Fok;
4541
4542        let is_market_fok = matches!((order_type, tif), (OrderType::Market, TimeInForce::Fok));
4543        assert!(
4544            is_market_fok,
4545            "Market + FOK combination should be identified for rejection"
4546        );
4547    }
4548
4549    #[rstest]
4550    #[case::empty_string("", true)]
4551    #[case::zero("0", true)]
4552    #[case::minus_one("-1", true)]
4553    #[case::minus_two("-2", true)]
4554    #[case::normal_price("100.5", false)]
4555    #[case::another_price("0.001", false)]
4556    fn test_is_market_price(#[case] price: &str, #[case] expected: bool) {
4557        assert_eq!(is_market_price(price), expected);
4558    }
4559
4560    #[rstest]
4561    #[case::fok_market(OKXOrderType::Fok, "", OrderType::Market)]
4562    #[case::fok_limit(OKXOrderType::Fok, "100.5", OrderType::Limit)]
4563    #[case::ioc_market(OKXOrderType::Ioc, "", OrderType::Market)]
4564    #[case::ioc_limit(OKXOrderType::Ioc, "100.5", OrderType::Limit)]
4565    #[case::optimal_limit_ioc_market(OKXOrderType::OptimalLimitIoc, "", OrderType::Market)]
4566    #[case::optimal_limit_ioc_market_zero(OKXOrderType::OptimalLimitIoc, "0", OrderType::Market)]
4567    #[case::optimal_limit_ioc_market_minus_one(
4568        OKXOrderType::OptimalLimitIoc,
4569        "-1",
4570        OrderType::Market
4571    )]
4572    #[case::optimal_limit_ioc_limit(OKXOrderType::OptimalLimitIoc, "100.5", OrderType::Limit)]
4573    #[case::market_passthrough(OKXOrderType::Market, "", OrderType::Market)]
4574    #[case::limit_passthrough(OKXOrderType::Limit, "100.5", OrderType::Limit)]
4575    fn test_determine_order_type(
4576        #[case] okx_ord_type: OKXOrderType,
4577        #[case] price: &str,
4578        #[case] expected: OrderType,
4579    ) {
4580        assert_eq!(determine_order_type(okx_ord_type, price), expected);
4581    }
4582}