nautilus_okx/common/
parse.rs

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