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