nautilus_okx/common/
parse.rs

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