nautilus_okx/common/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parsing utilities that convert OKX payloads into Nautilus domain models.
17
18use std::str::FromStr;
19
20use nautilus_core::{
21    UUID4,
22    datetime::{NANOSECONDS_IN_MILLISECOND, millis_to_nanos},
23    nanos::UnixNanos,
24};
25use nautilus_model::{
26    currencies::CURRENCY_MAP,
27    data::{
28        Bar, BarSpecification, BarType, Data, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
29        TradeTick,
30        bar::{
31            BAR_SPEC_1_DAY_LAST, BAR_SPEC_1_HOUR_LAST, BAR_SPEC_1_MINUTE_LAST,
32            BAR_SPEC_1_MONTH_LAST, BAR_SPEC_1_SECOND_LAST, BAR_SPEC_1_WEEK_LAST,
33            BAR_SPEC_2_DAY_LAST, BAR_SPEC_2_HOUR_LAST, BAR_SPEC_3_DAY_LAST, BAR_SPEC_3_MINUTE_LAST,
34            BAR_SPEC_3_MONTH_LAST, BAR_SPEC_4_HOUR_LAST, BAR_SPEC_5_DAY_LAST,
35            BAR_SPEC_5_MINUTE_LAST, BAR_SPEC_6_HOUR_LAST, BAR_SPEC_6_MONTH_LAST,
36            BAR_SPEC_12_HOUR_LAST, BAR_SPEC_12_MONTH_LAST, BAR_SPEC_15_MINUTE_LAST,
37            BAR_SPEC_30_MINUTE_LAST,
38        },
39    },
40    enums::{
41        AccountType, AggregationSource, AggressorSide, LiquiditySide, OptionKind, OrderSide,
42        OrderStatus, OrderType, PositionSide, TimeInForce,
43    },
44    events::AccountState,
45    identifiers::{
46        AccountId, ClientOrderId, InstrumentId, PositionId, Symbol, TradeId, Venue, VenueOrderId,
47    },
48    instruments::{CryptoFuture, CryptoOption, CryptoPerpetual, CurrencyPair, InstrumentAny},
49    reports::{FillReport, OrderStatusReport, PositionStatusReport},
50    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
51};
52use rust_decimal::Decimal;
53use serde::{Deserialize, Deserializer, de::DeserializeOwned};
54use ustr::Ustr;
55
56use super::enums::OKXContractType;
57use crate::{
58    common::{
59        consts::OKX_VENUE,
60        enums::{
61            OKXExecType, OKXInstrumentType, OKXOrderStatus, OKXOrderType, OKXPositionSide, OKXSide,
62            OKXTargetCurrency, OKXVipLevel,
63        },
64        models::OKXInstrument,
65    },
66    http::models::{
67        OKXAccount, OKXCandlestick, OKXIndexTicker, OKXMarkPrice, OKXOrderHistory, OKXPosition,
68        OKXTrade, OKXTransactionDetail,
69    },
70    websocket::{enums::OKXWsChannel, messages::OKXFundingRateMsg},
71};
72
73/// Deserializes an empty string into [`None`].
74///
75/// OKX frequently represents *null* string fields as an empty string (`""`).
76/// When such a payload is mapped onto `Option<String>` the default behaviour
77/// would yield `Some("")`, which is semantically different from the intended
78/// absence of a value.  Applying this helper via
79///
80/// ```rust
81/// #[serde(deserialize_with = "crate::common::parse::deserialize_empty_string_as_none")]
82/// pub cl_ord_id: Option<String>,
83/// ```
84///
85/// ensures that empty strings are normalised to `None` during deserialization.
86///
87/// # Errors
88///
89/// Returns an error if the JSON value cannot be deserialised into a string.
90pub fn deserialize_empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
91where
92    D: Deserializer<'de>,
93{
94    let opt = Option::<String>::deserialize(deserializer)?;
95    Ok(opt.filter(|s| !s.is_empty()))
96}
97
98/// Deserializes an empty [`Ustr`] into [`None`].
99///
100/// # Errors
101///
102/// Returns an error if the JSON value cannot be deserialised into a string.
103pub fn deserialize_empty_ustr_as_none<'de, D>(deserializer: D) -> Result<Option<Ustr>, D::Error>
104where
105    D: Deserializer<'de>,
106{
107    let opt = Option::<Ustr>::deserialize(deserializer)?;
108    Ok(opt.filter(|s| !s.is_empty()))
109}
110
111/// Deserializes a string into `Option<OKXTargetCurrency>`, treating empty strings as `None`.
112///
113/// # Errors
114///
115/// Returns an error if the string cannot be parsed into an `OKXTargetCurrency`.
116pub fn deserialize_target_currency_as_none<'de, D>(
117    deserializer: D,
118) -> Result<Option<crate::common::enums::OKXTargetCurrency>, D::Error>
119where
120    D: Deserializer<'de>,
121{
122    let s = String::deserialize(deserializer)?;
123    if s.is_empty() {
124        Ok(None)
125    } else {
126        s.parse().map(Some).map_err(serde::de::Error::custom)
127    }
128}
129
130/// Deserializes a numeric string into a `u64`.
131///
132/// # Errors
133///
134/// Returns an error if the string cannot be parsed into a `u64`.
135pub fn deserialize_string_to_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
136where
137    D: Deserializer<'de>,
138{
139    let s = String::deserialize(deserializer)?;
140    if s.is_empty() {
141        Ok(0)
142    } else {
143        s.parse::<u64>().map_err(serde::de::Error::custom)
144    }
145}
146
147/// Deserializes an optional numeric string into `Option<u64>`.
148///
149/// # Errors
150///
151/// Returns an error under the same cases as [`deserialize_string_to_u64`].
152pub fn deserialize_optional_string_to_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
153where
154    D: Deserializer<'de>,
155{
156    let s: Option<String> = Option::deserialize(deserializer)?;
157    match s {
158        Some(s) if s.is_empty() => Ok(None),
159        Some(s) => s.parse().map(Some).map_err(serde::de::Error::custom),
160        None => Ok(None),
161    }
162}
163
164/// Deserializes an OKX VIP level string into [`OKXVipLevel`].
165///
166/// OKX returns VIP levels in multiple formats:
167/// - "VIP0", "VIP1", ..., "VIP9" (VIP tier format)
168/// - "Lv0", "Lv1", ..., "Lv9" (Level format)
169/// - "0", "1", ..., "9" (bare numeric)
170/// - "" (empty string, defaults to VIP0)
171///
172/// This function handles all formats by stripping any prefix and parsing the numeric value.
173///
174/// # Errors
175///
176/// Returns an error if the string cannot be parsed into a valid VIP level.
177pub fn deserialize_vip_level<'de, D>(deserializer: D) -> Result<OKXVipLevel, D::Error>
178where
179    D: Deserializer<'de>,
180{
181    let s = String::deserialize(deserializer)?;
182
183    if s.is_empty() {
184        return Ok(OKXVipLevel::Vip0);
185    }
186
187    let s_lower = s.to_lowercase();
188    let level_str = s_lower
189        .strip_prefix("vip")
190        .or_else(|| s_lower.strip_prefix("lv"))
191        .unwrap_or(&s_lower);
192
193    let level_num = level_str
194        .parse::<u8>()
195        .map_err(|e| serde::de::Error::custom(format!("Invalid VIP level '{s}': {e}")))?;
196
197    Ok(OKXVipLevel::from(level_num))
198}
199
200/// Returns the currency either from the internal currency map or creates a default crypto.
201///
202/// If the code is empty, logs a warning with context and returns USDT as fallback.
203/// For unknown but valid codes, creates a new Currency (preserves newly listed OKX assets).
204fn get_currency_with_context(code: &str, context: Option<&str>) -> Currency {
205    let trimmed = code.trim();
206    let ctx = context.unwrap_or("unknown");
207
208    if trimmed.is_empty() {
209        tracing::warn!(
210            "get_currency called with empty code (context: {ctx}), using USDT as fallback"
211        );
212        return Currency::USDT();
213    }
214
215    CURRENCY_MAP
216        .lock()
217        .unwrap()
218        .get(trimmed)
219        .copied()
220        .unwrap_or_else(|| {
221            // For unknown codes, create a new currency (8 decimals, crypto type)
222            // This preserves newly listed OKX assets that aren't in CURRENCY_MAP yet
223            use nautilus_model::enums::CurrencyType;
224            Currency::new(trimmed, 8, 0, trimmed, CurrencyType::Crypto)
225        })
226}
227
228/// Returns the [`OKXInstrumentType`] that corresponds to the supplied
229/// [`InstrumentAny`].
230///
231/// # Errors
232///
233/// Returns an error if the instrument variant is not supported by OKX.
234pub fn okx_instrument_type(instrument: &InstrumentAny) -> anyhow::Result<OKXInstrumentType> {
235    match instrument {
236        InstrumentAny::CurrencyPair(_) => Ok(OKXInstrumentType::Spot),
237        InstrumentAny::CryptoPerpetual(_) => Ok(OKXInstrumentType::Swap),
238        InstrumentAny::CryptoFuture(_) => Ok(OKXInstrumentType::Futures),
239        InstrumentAny::CryptoOption(_) => Ok(OKXInstrumentType::Option),
240        _ => anyhow::bail!("Invalid instrument type for OKX: {instrument:?}"),
241    }
242}
243
244/// Parses `OKXInstrumentType` from an instrument symbol.
245///
246/// OKX instrument symbol formats:
247/// - SPOT: {BASE}-{QUOTE} (e.g., BTC-USDT)
248/// - MARGIN: {BASE}-{QUOTE} (same as SPOT, determined by trade mode)
249/// - SWAP: {BASE}-{QUOTE}-SWAP (e.g., BTC-USDT-SWAP)
250/// - FUTURES: {BASE}-{QUOTE}-{YYMMDD} (e.g., BTC-USDT-250328)
251/// - OPTION: {BASE}-{QUOTE}-{YYMMDD}-{STRIKE}-{C/P} (e.g., BTC-USD-250328-50000-C)
252pub fn okx_instrument_type_from_symbol(symbol: &str) -> OKXInstrumentType {
253    // TODO: Improve efficiency of this
254    let parts: Vec<&str> = symbol.split('-').collect();
255
256    match parts.len() {
257        2 => OKXInstrumentType::Spot,
258        3 => {
259            let suffix = parts[2];
260            if suffix == "SWAP" {
261                OKXInstrumentType::Swap
262            } else if suffix.len() == 6 && suffix.chars().all(|c| c.is_ascii_digit()) {
263                // Date format YYMMDD
264                OKXInstrumentType::Futures
265            } else {
266                OKXInstrumentType::Spot
267            }
268        }
269        5 => OKXInstrumentType::Option,
270        _ => OKXInstrumentType::Spot, // Default fallback
271    }
272}
273
274/// Parses a Nautilus instrument ID from the given OKX `symbol` value.
275#[must_use]
276pub fn parse_instrument_id(symbol: Ustr) -> InstrumentId {
277    InstrumentId::new(Symbol::from_ustr_unchecked(symbol), *OKX_VENUE)
278}
279
280/// Parses a Nautilus client order ID from the given OKX `clOrdId` value.
281#[must_use]
282pub fn parse_client_order_id(value: &str) -> Option<ClientOrderId> {
283    if value.is_empty() {
284        None
285    } else {
286        Some(ClientOrderId::new(value))
287    }
288}
289
290/// Converts a millisecond-based timestamp (as returned by OKX) into
291/// [`UnixNanos`].
292#[must_use]
293pub fn parse_millisecond_timestamp(timestamp_ms: u64) -> UnixNanos {
294    UnixNanos::from(timestamp_ms * NANOSECONDS_IN_MILLISECOND)
295}
296
297/// Parses an RFC 3339 timestamp string into [`UnixNanos`].
298///
299/// # Errors
300///
301/// Returns an error if the string is not a valid RFC 3339 datetime or if the
302/// timestamp cannot be represented in nanoseconds.
303pub fn parse_rfc3339_timestamp(timestamp: &str) -> anyhow::Result<UnixNanos> {
304    let dt = chrono::DateTime::parse_from_rfc3339(timestamp)?;
305    let nanos = dt.timestamp_nanos_opt().ok_or_else(|| {
306        anyhow::anyhow!("Failed to extract nanoseconds from timestamp: {timestamp}")
307    })?;
308    Ok(UnixNanos::from(nanos as u64))
309}
310
311/// Converts a textual price to a [`Price`] using the given precision.
312///
313/// # Errors
314///
315/// Returns an error if the string fails to parse into `f64` or if the number
316/// of decimal places exceeds `precision`.
317pub fn parse_price(value: &str, precision: u8) -> anyhow::Result<Price> {
318    Price::new_checked(value.parse::<f64>()?, precision)
319}
320
321/// Converts a textual quantity to a [`Quantity`].
322///
323/// # Errors
324///
325/// Returns an error for the same reasons as [`parse_price`] – parsing failure or invalid
326/// precision.
327pub fn parse_quantity(value: &str, precision: u8) -> anyhow::Result<Quantity> {
328    Quantity::new_checked(value.parse::<f64>()?, precision)
329}
330
331/// Converts a textual fee amount into a [`Money`] value.
332///
333/// OKX represents *charges* as positive numbers but they reduce the account
334/// balance, hence the value is negated.
335///
336/// # Errors
337///
338/// Returns an error if the fee cannot be parsed into `f64` or fails internal
339/// validation in [`Money::new_checked`].
340pub fn parse_fee(value: Option<&str>, currency: Currency) -> anyhow::Result<Money> {
341    // OKX report positive fees with negative signs (i.e., fee charged)
342    let fee_f64 = value.unwrap_or("0").parse::<f64>()?;
343    Money::new_checked(-fee_f64, currency)
344}
345
346/// Parses OKX fee currency code, handling empty strings.
347///
348/// OKX sometimes returns empty fee currency codes.
349/// When the fee currency is empty, defaults to USDT and logs a warning for non-zero fees.
350pub fn parse_fee_currency(
351    fee_ccy: &str,
352    fee_amount: f64,
353    context: impl FnOnce() -> String,
354) -> Currency {
355    let trimmed = fee_ccy.trim();
356    if trimmed.is_empty() {
357        if fee_amount != 0.0 {
358            let ctx = context();
359            tracing::warn!(
360                "Empty fee_ccy in {ctx} with non-zero fee={fee_amount}, using USDT as fallback"
361            );
362        }
363        return Currency::USDT();
364    }
365
366    get_currency_with_context(trimmed, Some(&context()))
367}
368
369/// Parses OKX side to Nautilus aggressor side.
370pub fn parse_aggressor_side(side: &Option<OKXSide>) -> AggressorSide {
371    match side {
372        Some(OKXSide::Buy) => AggressorSide::Buyer,
373        Some(OKXSide::Sell) => AggressorSide::Seller,
374        None => AggressorSide::NoAggressor,
375    }
376}
377
378/// Parses OKX execution type to Nautilus liquidity side.
379pub fn parse_execution_type(liquidity: &Option<OKXExecType>) -> LiquiditySide {
380    match liquidity {
381        Some(OKXExecType::Maker) => LiquiditySide::Maker,
382        Some(OKXExecType::Taker) => LiquiditySide::Taker,
383        _ => LiquiditySide::NoLiquiditySide,
384    }
385}
386
387/// Parses quantity to Nautilus position side.
388pub fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
389    match current_qty {
390        Some(qty) if qty > 0 => PositionSide::Long,
391        Some(qty) if qty < 0 => PositionSide::Short,
392        _ => PositionSide::Flat,
393    }
394}
395
396/// Parses an OKX mark price record into a Nautilus [`MarkPriceUpdate`].
397///
398/// # Errors
399///
400/// Returns an error if `raw.mark_px` cannot be parsed into a [`Price`] with
401/// the specified precision.
402pub fn parse_mark_price_update(
403    raw: &OKXMarkPrice,
404    instrument_id: InstrumentId,
405    price_precision: u8,
406    ts_init: UnixNanos,
407) -> anyhow::Result<MarkPriceUpdate> {
408    let ts_event = parse_millisecond_timestamp(raw.ts);
409    let price = parse_price(&raw.mark_px, price_precision)?;
410    Ok(MarkPriceUpdate::new(
411        instrument_id,
412        price,
413        ts_event,
414        ts_init,
415    ))
416}
417
418/// Parses an OKX index ticker record into a Nautilus [`IndexPriceUpdate`].
419///
420/// # Errors
421///
422/// Returns an error if `raw.idx_px` cannot be parsed into a [`Price`] with the
423/// specified precision.
424pub fn parse_index_price_update(
425    raw: &OKXIndexTicker,
426    instrument_id: InstrumentId,
427    price_precision: u8,
428    ts_init: UnixNanos,
429) -> anyhow::Result<IndexPriceUpdate> {
430    let ts_event = parse_millisecond_timestamp(raw.ts);
431    let price = parse_price(&raw.idx_px, price_precision)?;
432    Ok(IndexPriceUpdate::new(
433        instrument_id,
434        price,
435        ts_event,
436        ts_init,
437    ))
438}
439
440/// Parses an [`OKXFundingRateMsg`] into a [`FundingRateUpdate`].
441///
442/// # Errors
443///
444/// Returns an error if the `funding_rate` or `next_funding_rate` fields fail
445/// to parse into Decimal values.
446pub fn parse_funding_rate_msg(
447    msg: &OKXFundingRateMsg,
448    instrument_id: InstrumentId,
449    ts_init: UnixNanos,
450) -> anyhow::Result<FundingRateUpdate> {
451    let funding_rate = msg
452        .funding_rate
453        .as_str()
454        .parse::<Decimal>()
455        .map_err(|e| anyhow::anyhow!("Invalid funding_rate value: {e}"))?
456        .normalize();
457
458    let funding_time = Some(parse_millisecond_timestamp(msg.funding_time));
459    let ts_event = parse_millisecond_timestamp(msg.ts);
460
461    Ok(FundingRateUpdate::new(
462        instrument_id,
463        funding_rate,
464        funding_time,
465        ts_event,
466        ts_init,
467    ))
468}
469
470/// Parses an OKX trade record into a Nautilus [`TradeTick`].
471///
472/// # Errors
473///
474/// Returns an error if the price or quantity strings cannot be parsed, or if
475/// [`TradeTick::new_checked`] validation fails.
476pub fn parse_trade_tick(
477    raw: &OKXTrade,
478    instrument_id: InstrumentId,
479    price_precision: u8,
480    size_precision: u8,
481    ts_init: UnixNanos,
482) -> anyhow::Result<TradeTick> {
483    let ts_event = parse_millisecond_timestamp(raw.ts);
484    let price = parse_price(&raw.px, price_precision)?;
485    let size = parse_quantity(&raw.sz, size_precision)?;
486    let aggressor: AggressorSide = raw.side.into();
487    let trade_id = TradeId::new(raw.trade_id);
488
489    TradeTick::new_checked(
490        instrument_id,
491        price,
492        size,
493        aggressor,
494        trade_id,
495        ts_event,
496        ts_init,
497    )
498}
499
500/// Parses an OKX historical candlestick record into a Nautilus [`Bar`].
501///
502/// # Errors
503///
504/// Returns an error if any of the price or volume strings cannot be parsed or
505/// if [`Bar::new`] validation fails.
506pub fn parse_candlestick(
507    raw: &OKXCandlestick,
508    bar_type: BarType,
509    price_precision: u8,
510    size_precision: u8,
511    ts_init: UnixNanos,
512) -> anyhow::Result<Bar> {
513    let ts_event = parse_millisecond_timestamp(raw.0.parse()?);
514    let open = parse_price(&raw.1, price_precision)?;
515    let high = parse_price(&raw.2, price_precision)?;
516    let low = parse_price(&raw.3, price_precision)?;
517    let close = parse_price(&raw.4, price_precision)?;
518    let volume = parse_quantity(&raw.5, size_precision)?;
519
520    Ok(Bar::new(
521        bar_type, open, high, low, close, volume, ts_event, ts_init,
522    ))
523}
524
525/// Parses an OKX order history record into a Nautilus [`OrderStatusReport`].
526#[allow(clippy::too_many_lines)]
527pub fn parse_order_status_report(
528    order: &OKXOrderHistory,
529    account_id: AccountId,
530    instrument_id: InstrumentId,
531    price_precision: u8,
532    size_precision: u8,
533    ts_init: UnixNanos,
534) -> OrderStatusReport {
535    // Parse quantities based on target currency
536    // OKX always returns acc_fill_sz in base currency, but sz depends on tgt_ccy
537
538    // Determine if this is a quote-quantity order
539    // Method 1: Explicit tgt_ccy field set to QuoteCcy
540    let is_quote_qty_explicit = order.tgt_ccy == Some(OKXTargetCurrency::QuoteCcy);
541
542    // Method 2: Use OKX defaults when tgt_ccy is None (old orders or missing field)
543    // OKX API defaults for SPOT market orders: BUY orders use quote_ccy, SELL orders use base_ccy
544    // Note: tgtCcy only applies to SPOT market orders (not limit orders)
545    // For limit orders, sz is always in base currency regardless of side
546    let is_quote_qty_heuristic = order.tgt_ccy.is_none()
547        && (order.inst_type == OKXInstrumentType::Spot
548            || order.inst_type == OKXInstrumentType::Margin)
549        && order.side == OKXSide::Buy
550        && order.ord_type == OKXOrderType::Market;
551
552    let (quantity, filled_qty) = if is_quote_qty_explicit || is_quote_qty_heuristic {
553        // Quote-quantity order: sz is in quote currency, need to convert to base
554        let sz_quote = order.sz.parse::<f64>().unwrap_or(0.0);
555
556        // Determine the price to use for conversion
557        // Priority: 1) limit price (px) for limit orders, 2) avg_px for market orders
558        let conversion_price = if !order.px.is_empty() && order.px != "0" {
559            // Limit order: use the limit price (order.px)
560            order.px.parse::<f64>().unwrap_or(0.0)
561        } else if !order.avg_px.is_empty() && order.avg_px != "0" {
562            // Market order with fills: use average fill price
563            order.avg_px.parse::<f64>().unwrap_or(0.0)
564        } else {
565            log::warn!(
566                "No price available for conversion: ord_id={}, px='{}', avg_px='{}'",
567                order.ord_id.as_str(),
568                order.px,
569                order.avg_px
570            );
571            0.0
572        };
573
574        // Convert quote quantity to base: quantity_base = sz_quote / price
575        let quantity_base = if conversion_price > 0.0 {
576            Quantity::new(sz_quote / conversion_price, size_precision)
577        } else {
578            // No price available, can't convert - use sz as-is temporarily
579            log::warn!(
580                "Cannot convert, using sz as-is: ord_id={}, sz={}",
581                order.ord_id.as_str(),
582                order.sz
583            );
584            order
585                .sz
586                .parse::<f64>()
587                .ok()
588                .map(|v| Quantity::new(v, size_precision))
589                .unwrap_or_default()
590        };
591
592        let filled_qty = order
593            .acc_fill_sz
594            .parse::<f64>()
595            .ok()
596            .map(|v| Quantity::new(v, size_precision))
597            .unwrap_or_default();
598
599        (quantity_base, filled_qty)
600    } else {
601        // Base-quantity order: both sz and acc_fill_sz are in base currency
602        let quantity = order
603            .sz
604            .parse::<f64>()
605            .ok()
606            .map(|v| Quantity::new(v, size_precision))
607            .unwrap_or_default();
608        let filled_qty = order
609            .acc_fill_sz
610            .parse::<f64>()
611            .ok()
612            .map(|v| Quantity::new(v, size_precision))
613            .unwrap_or_default();
614
615        (quantity, filled_qty)
616    };
617
618    // For quote-quantity orders marked as FILLED, adjust quantity to match filled_qty
619    // to avoid precision mismatches from quote-to-base conversion
620    let (quantity, filled_qty) = if (is_quote_qty_explicit || is_quote_qty_heuristic)
621        && order.state == OKXOrderStatus::Filled
622        && filled_qty.is_positive()
623    {
624        (filled_qty, filled_qty)
625    } else {
626        (quantity, filled_qty)
627    };
628
629    let order_side: OrderSide = order.side.into();
630    let okx_status: OKXOrderStatus = order.state;
631    let order_status: OrderStatus = okx_status.into();
632    let okx_ord_type: OKXOrderType = order.ord_type;
633    let order_type: OrderType = okx_ord_type.into();
634    // Note: OKX uses ordType for type and liquidity instructions; time-in-force not explicitly represented here
635    let time_in_force = TimeInForce::Gtc;
636
637    // Build report
638    let mut client_order_id = if order.cl_ord_id.is_empty() {
639        None
640    } else {
641        Some(ClientOrderId::new(order.cl_ord_id.as_str()))
642    };
643
644    let mut linked_ids = Vec::new();
645
646    if let Some(algo_cl_ord_id) = order
647        .algo_cl_ord_id
648        .as_ref()
649        .filter(|value| !value.as_str().is_empty())
650    {
651        let algo_client_id = ClientOrderId::new(algo_cl_ord_id.as_str());
652        match &client_order_id {
653            Some(existing) if existing == &algo_client_id => {}
654            Some(_) => linked_ids.push(algo_client_id),
655            None => client_order_id = Some(algo_client_id),
656        }
657    }
658
659    let venue_order_id = if order.ord_id.is_empty() {
660        if let Some(algo_id) = order
661            .algo_id
662            .as_ref()
663            .filter(|value| !value.as_str().is_empty())
664        {
665            VenueOrderId::new(algo_id.as_str())
666        } else if !order.cl_ord_id.is_empty() {
667            VenueOrderId::new(order.cl_ord_id.as_str())
668        } else {
669            let synthetic_id = format!("{}:{}", account_id, order.c_time);
670            VenueOrderId::new(&synthetic_id)
671        }
672    } else {
673        VenueOrderId::new(order.ord_id.as_str())
674    };
675
676    let ts_accepted = parse_millisecond_timestamp(order.c_time);
677    let ts_last = UnixNanos::from(order.u_time * NANOSECONDS_IN_MILLISECOND);
678
679    let mut report = OrderStatusReport::new(
680        account_id,
681        instrument_id,
682        client_order_id,
683        venue_order_id,
684        order_side,
685        order_type,
686        time_in_force,
687        order_status,
688        quantity,
689        filled_qty,
690        ts_accepted,
691        ts_last,
692        ts_init,
693        None,
694    );
695
696    // Optional fields
697    if !order.px.is_empty()
698        && let Ok(p) = order.px.parse::<f64>()
699    {
700        report = report.with_price(Price::new(p, price_precision));
701    }
702    if !order.avg_px.is_empty()
703        && let Ok(avg) = order.avg_px.parse::<f64>()
704    {
705        report = report.with_avg_px(avg);
706    }
707    if order.ord_type == OKXOrderType::PostOnly {
708        report = report.with_post_only(true);
709    }
710    if order.reduce_only == "true" {
711        report = report.with_reduce_only(true);
712    }
713
714    if !linked_ids.is_empty() {
715        report = report.with_linked_order_ids(linked_ids);
716    }
717
718    report
719}
720
721/// Parses an OKX position into a Nautilus [`PositionStatusReport`].
722///
723/// # Position Mode Handling
724///
725/// OKX returns position data differently based on the account's position mode:
726///
727/// - **Net mode** (`posSide="net"`): The `pos` field uses signed quantities where
728///   positive = long, negative = short. Position side is derived from the sign.
729///
730/// - **Long/Short mode** (`posSide="long"` or `"short"`): The `pos` field is always
731///   positive regardless of side. Position side is determined from the `posSide` field.
732///   Position IDs are suffixed with `-LONG` or `-SHORT` for uniqueness.
733///
734/// See: <https://www.okx.com/docs-v5/en/#trading-account-rest-api-get-positions>
735///
736/// # Errors
737///
738/// Returns an error if any numeric fields cannot be parsed into their target types.
739///
740/// # Panics
741///
742/// Panics if position quantity is invalid and cannot be parsed.
743#[allow(clippy::too_many_lines)]
744pub fn parse_position_status_report(
745    position: OKXPosition,
746    account_id: AccountId,
747    instrument_id: InstrumentId,
748    size_precision: u8,
749    ts_init: UnixNanos,
750) -> anyhow::Result<PositionStatusReport> {
751    let pos_value = position.pos.parse::<f64>().unwrap_or_else(|e| {
752        panic!(
753            "Failed to parse position quantity '{}' for instrument {}: {:?}",
754            position.pos, instrument_id, e
755        )
756    });
757
758    // Determine position side based on OKX position mode:
759    // - Net mode: posSide="net", uses signed quantities (positive=long, negative=short)
760    // - Long/Short mode: posSide="long"/"short", quantities are always positive, side from field
761    let position_side = match position.pos_side {
762        OKXPositionSide::Net => {
763            // Net mode: derive side from signed quantity
764            if pos_value > 0.0 {
765                PositionSide::Long
766            } else if pos_value < 0.0 {
767                PositionSide::Short
768            } else {
769                PositionSide::Flat
770            }
771        }
772        OKXPositionSide::Long => {
773            // Long/Short mode: trust the pos_side field
774            PositionSide::Long
775        }
776        OKXPositionSide::Short => {
777            // Long/Short mode: trust the pos_side field
778            PositionSide::Short
779        }
780        OKXPositionSide::None => {
781            // Fallback: use signed quantity (same as Net mode logic)
782            if pos_value > 0.0 {
783                PositionSide::Long
784            } else if pos_value < 0.0 {
785                PositionSide::Short
786            } else {
787                PositionSide::Flat
788            }
789        }
790    }
791    .as_specified();
792
793    // Convert to absolute quantity (positions are always positive in Nautilus)
794    let quantity = Quantity::new(pos_value.abs(), size_precision);
795
796    // Generate venue position ID only for Long/Short mode (hedging)
797    // In Net mode, venue_position_id must be None to signal NETTING OMS behavior
798    let venue_position_id = match position.pos_side {
799        OKXPositionSide::Long => {
800            // Long/Short mode - Long leg: append "-LONG"
801            position
802                .pos_id
803                .map(|pos_id| PositionId::new(format!("{pos_id}-LONG")))
804        }
805        OKXPositionSide::Short => {
806            // Long/Short mode - Short leg: append "-SHORT"
807            position
808                .pos_id
809                .map(|pos_id| PositionId::new(format!("{pos_id}-SHORT")))
810        }
811        OKXPositionSide::Net | OKXPositionSide::None => {
812            // Net mode: None signals NETTING OMS (Nautilus uses its own position IDs)
813            None
814        }
815    };
816
817    let avg_px_open = if position.avg_px.is_empty() {
818        None
819    } else {
820        Some(Decimal::from_str(&position.avg_px)?)
821    };
822    let ts_last = parse_millisecond_timestamp(position.u_time);
823
824    Ok(PositionStatusReport::new(
825        account_id,
826        instrument_id,
827        position_side,
828        quantity,
829        ts_last,
830        ts_init,
831        None, // Will generate a UUID4
832        venue_position_id,
833        avg_px_open,
834    ))
835}
836
837/// Parses an OKX transaction detail into a Nautilus `FillReport`.
838///
839/// # Errors
840///
841/// Returns an error if the OKX transaction detail cannot be parsed.
842pub fn parse_fill_report(
843    detail: OKXTransactionDetail,
844    account_id: AccountId,
845    instrument_id: InstrumentId,
846    price_precision: u8,
847    size_precision: u8,
848    ts_init: UnixNanos,
849) -> anyhow::Result<FillReport> {
850    let client_order_id = if detail.cl_ord_id.is_empty() {
851        None
852    } else {
853        Some(ClientOrderId::new(detail.cl_ord_id))
854    };
855    let venue_order_id = VenueOrderId::new(detail.ord_id);
856    let trade_id = TradeId::new(detail.trade_id);
857    let order_side: OrderSide = detail.side.into();
858    let last_px = parse_price(&detail.fill_px, price_precision)?;
859    let last_qty = parse_quantity(&detail.fill_sz, size_precision)?;
860    let fee_f64 = detail.fee.as_deref().unwrap_or("0").parse::<f64>()?;
861    let fee_currency = parse_fee_currency(&detail.fee_ccy, fee_f64, || {
862        format!("fill report for instrument_id={}", instrument_id)
863    });
864    let commission = Money::new(-fee_f64, fee_currency);
865    let liquidity_side: LiquiditySide = detail.exec_type.into();
866    let ts_event = parse_millisecond_timestamp(detail.ts);
867
868    Ok(FillReport::new(
869        account_id,
870        instrument_id,
871        venue_order_id,
872        trade_id,
873        order_side,
874        last_qty,
875        last_px,
876        commission,
877        liquidity_side,
878        client_order_id,
879        None, // venue_position_id not provided by OKX fills
880        ts_event,
881        ts_init,
882        None, // Will generate a new UUID4
883    ))
884}
885
886/// Parses vector messages from OKX WebSocket data.
887///
888/// Reduces code duplication by providing a common pattern for deserializing JSON arrays,
889/// parsing each message, and wrapping results in Nautilus Data enum variants.
890///
891/// # Errors
892///
893/// Returns an error if the payload is not an array or if individual messages
894/// cannot be parsed.
895pub fn parse_message_vec<T, R, F, W>(
896    data: serde_json::Value,
897    parser: F,
898    wrapper: W,
899) -> anyhow::Result<Vec<Data>>
900where
901    T: DeserializeOwned,
902    F: Fn(&T) -> anyhow::Result<R>,
903    W: Fn(R) -> Data,
904{
905    let items = match data {
906        serde_json::Value::Array(items) => items,
907        other => {
908            let raw = serde_json::to_string(&other).unwrap_or_else(|_| other.to_string());
909            let mut snippet: String = raw.chars().take(512).collect();
910            if raw.len() > snippet.len() {
911                snippet.push_str("...");
912            }
913            anyhow::bail!("Expected array payload, received {snippet}");
914        }
915    };
916
917    let mut results = Vec::with_capacity(items.len());
918
919    for item in items {
920        let message: T = serde_json::from_value(item)?;
921        let parsed = parser(&message)?;
922        results.push(wrapper(parsed));
923    }
924
925    Ok(results)
926}
927
928/// Converts a Nautilus bar specification into the matching OKX candle channel.
929///
930/// # Errors
931///
932/// Returns an error if the provided bar specification does not have a matching
933/// OKX websocket channel.
934pub fn bar_spec_as_okx_channel(bar_spec: BarSpecification) -> anyhow::Result<OKXWsChannel> {
935    let channel = match bar_spec {
936        BAR_SPEC_1_SECOND_LAST => OKXWsChannel::Candle1Second,
937        BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::Candle1Minute,
938        BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::Candle3Minute,
939        BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::Candle5Minute,
940        BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::Candle15Minute,
941        BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::Candle30Minute,
942        BAR_SPEC_1_HOUR_LAST => OKXWsChannel::Candle1Hour,
943        BAR_SPEC_2_HOUR_LAST => OKXWsChannel::Candle2Hour,
944        BAR_SPEC_4_HOUR_LAST => OKXWsChannel::Candle4Hour,
945        BAR_SPEC_6_HOUR_LAST => OKXWsChannel::Candle6Hour,
946        BAR_SPEC_12_HOUR_LAST => OKXWsChannel::Candle12Hour,
947        BAR_SPEC_1_DAY_LAST => OKXWsChannel::Candle1Day,
948        BAR_SPEC_2_DAY_LAST => OKXWsChannel::Candle2Day,
949        BAR_SPEC_3_DAY_LAST => OKXWsChannel::Candle3Day,
950        BAR_SPEC_5_DAY_LAST => OKXWsChannel::Candle5Day,
951        BAR_SPEC_1_WEEK_LAST => OKXWsChannel::Candle1Week,
952        BAR_SPEC_1_MONTH_LAST => OKXWsChannel::Candle1Month,
953        BAR_SPEC_3_MONTH_LAST => OKXWsChannel::Candle3Month,
954        BAR_SPEC_6_MONTH_LAST => OKXWsChannel::Candle6Month,
955        BAR_SPEC_12_MONTH_LAST => OKXWsChannel::Candle1Year,
956        _ => anyhow::bail!("Invalid `BarSpecification` for channel, was {bar_spec}"),
957    };
958    Ok(channel)
959}
960
961/// Converts Nautilus bar specification to OKX mark price channel.
962///
963/// # Errors
964///
965/// Returns an error if the bar specification does not map to a mark price
966/// channel.
967pub fn bar_spec_as_okx_mark_price_channel(
968    bar_spec: BarSpecification,
969) -> anyhow::Result<OKXWsChannel> {
970    let channel = match bar_spec {
971        BAR_SPEC_1_SECOND_LAST => OKXWsChannel::MarkPriceCandle1Second,
972        BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::MarkPriceCandle1Minute,
973        BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::MarkPriceCandle3Minute,
974        BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::MarkPriceCandle5Minute,
975        BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::MarkPriceCandle15Minute,
976        BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::MarkPriceCandle30Minute,
977        BAR_SPEC_1_HOUR_LAST => OKXWsChannel::MarkPriceCandle1Hour,
978        BAR_SPEC_2_HOUR_LAST => OKXWsChannel::MarkPriceCandle2Hour,
979        BAR_SPEC_4_HOUR_LAST => OKXWsChannel::MarkPriceCandle4Hour,
980        BAR_SPEC_6_HOUR_LAST => OKXWsChannel::MarkPriceCandle6Hour,
981        BAR_SPEC_12_HOUR_LAST => OKXWsChannel::MarkPriceCandle12Hour,
982        BAR_SPEC_1_DAY_LAST => OKXWsChannel::MarkPriceCandle1Day,
983        BAR_SPEC_2_DAY_LAST => OKXWsChannel::MarkPriceCandle2Day,
984        BAR_SPEC_3_DAY_LAST => OKXWsChannel::MarkPriceCandle3Day,
985        BAR_SPEC_5_DAY_LAST => OKXWsChannel::MarkPriceCandle5Day,
986        BAR_SPEC_1_WEEK_LAST => OKXWsChannel::MarkPriceCandle1Week,
987        BAR_SPEC_1_MONTH_LAST => OKXWsChannel::MarkPriceCandle1Month,
988        BAR_SPEC_3_MONTH_LAST => OKXWsChannel::MarkPriceCandle3Month,
989        _ => anyhow::bail!("Invalid `BarSpecification` for mark price channel, was {bar_spec}"),
990    };
991    Ok(channel)
992}
993
994/// Converts Nautilus bar specification to OKX timeframe string.
995///
996/// # Errors
997///
998/// Returns an error if the bar specification does not have a corresponding
999/// OKX timeframe value.
1000pub fn bar_spec_as_okx_timeframe(bar_spec: BarSpecification) -> anyhow::Result<&'static str> {
1001    let timeframe = match bar_spec {
1002        BAR_SPEC_1_SECOND_LAST => "1s",
1003        BAR_SPEC_1_MINUTE_LAST => "1m",
1004        BAR_SPEC_3_MINUTE_LAST => "3m",
1005        BAR_SPEC_5_MINUTE_LAST => "5m",
1006        BAR_SPEC_15_MINUTE_LAST => "15m",
1007        BAR_SPEC_30_MINUTE_LAST => "30m",
1008        BAR_SPEC_1_HOUR_LAST => "1H",
1009        BAR_SPEC_2_HOUR_LAST => "2H",
1010        BAR_SPEC_4_HOUR_LAST => "4H",
1011        BAR_SPEC_6_HOUR_LAST => "6H",
1012        BAR_SPEC_12_HOUR_LAST => "12H",
1013        BAR_SPEC_1_DAY_LAST => "1D",
1014        BAR_SPEC_2_DAY_LAST => "2D",
1015        BAR_SPEC_3_DAY_LAST => "3D",
1016        BAR_SPEC_5_DAY_LAST => "5D",
1017        BAR_SPEC_1_WEEK_LAST => "1W",
1018        BAR_SPEC_1_MONTH_LAST => "1M",
1019        BAR_SPEC_3_MONTH_LAST => "3M",
1020        BAR_SPEC_6_MONTH_LAST => "6M",
1021        BAR_SPEC_12_MONTH_LAST => "1Y",
1022        _ => anyhow::bail!("Invalid `BarSpecification` for timeframe, was {bar_spec}"),
1023    };
1024    Ok(timeframe)
1025}
1026
1027/// Converts OKX timeframe string to Nautilus bar specification.
1028///
1029/// # Errors
1030///
1031/// Returns an error if the timeframe string is not recognized.
1032pub fn okx_timeframe_as_bar_spec(timeframe: &str) -> anyhow::Result<BarSpecification> {
1033    let bar_spec = match timeframe {
1034        "1s" => BAR_SPEC_1_SECOND_LAST,
1035        "1m" => BAR_SPEC_1_MINUTE_LAST,
1036        "3m" => BAR_SPEC_3_MINUTE_LAST,
1037        "5m" => BAR_SPEC_5_MINUTE_LAST,
1038        "15m" => BAR_SPEC_15_MINUTE_LAST,
1039        "30m" => BAR_SPEC_30_MINUTE_LAST,
1040        "1H" => BAR_SPEC_1_HOUR_LAST,
1041        "2H" => BAR_SPEC_2_HOUR_LAST,
1042        "4H" => BAR_SPEC_4_HOUR_LAST,
1043        "6H" => BAR_SPEC_6_HOUR_LAST,
1044        "12H" => BAR_SPEC_12_HOUR_LAST,
1045        "1D" => BAR_SPEC_1_DAY_LAST,
1046        "2D" => BAR_SPEC_2_DAY_LAST,
1047        "3D" => BAR_SPEC_3_DAY_LAST,
1048        "5D" => BAR_SPEC_5_DAY_LAST,
1049        "1W" => BAR_SPEC_1_WEEK_LAST,
1050        "1M" => BAR_SPEC_1_MONTH_LAST,
1051        "3M" => BAR_SPEC_3_MONTH_LAST,
1052        "6M" => BAR_SPEC_6_MONTH_LAST,
1053        "1Y" => BAR_SPEC_12_MONTH_LAST,
1054        _ => anyhow::bail!("Invalid timeframe for `BarSpecification`, was {timeframe}"),
1055    };
1056    Ok(bar_spec)
1057}
1058
1059/// Constructs a properly formatted BarType from OKX instrument ID and timeframe string.
1060/// This ensures the BarType uses canonical Nautilus format instead of raw OKX strings.
1061///
1062/// # Errors
1063///
1064/// Returns an error if the timeframe cannot be converted into a
1065/// `BarSpecification`.
1066pub fn okx_bar_type_from_timeframe(
1067    instrument_id: InstrumentId,
1068    timeframe: &str,
1069) -> anyhow::Result<BarType> {
1070    let bar_spec = okx_timeframe_as_bar_spec(timeframe)?;
1071    Ok(BarType::new(
1072        instrument_id,
1073        bar_spec,
1074        AggregationSource::External,
1075    ))
1076}
1077
1078/// Converts OKX WebSocket channel to bar specification if it's a candle channel.
1079pub fn okx_channel_to_bar_spec(channel: &OKXWsChannel) -> Option<BarSpecification> {
1080    use OKXWsChannel::*;
1081    match channel {
1082        Candle1Second | MarkPriceCandle1Second => Some(BAR_SPEC_1_SECOND_LAST),
1083        Candle1Minute | MarkPriceCandle1Minute => Some(BAR_SPEC_1_MINUTE_LAST),
1084        Candle3Minute | MarkPriceCandle3Minute => Some(BAR_SPEC_3_MINUTE_LAST),
1085        Candle5Minute | MarkPriceCandle5Minute => Some(BAR_SPEC_5_MINUTE_LAST),
1086        Candle15Minute | MarkPriceCandle15Minute => Some(BAR_SPEC_15_MINUTE_LAST),
1087        Candle30Minute | MarkPriceCandle30Minute => Some(BAR_SPEC_30_MINUTE_LAST),
1088        Candle1Hour | MarkPriceCandle1Hour => Some(BAR_SPEC_1_HOUR_LAST),
1089        Candle2Hour | MarkPriceCandle2Hour => Some(BAR_SPEC_2_HOUR_LAST),
1090        Candle4Hour | MarkPriceCandle4Hour => Some(BAR_SPEC_4_HOUR_LAST),
1091        Candle6Hour | MarkPriceCandle6Hour => Some(BAR_SPEC_6_HOUR_LAST),
1092        Candle12Hour | MarkPriceCandle12Hour => Some(BAR_SPEC_12_HOUR_LAST),
1093        Candle1Day | MarkPriceCandle1Day => Some(BAR_SPEC_1_DAY_LAST),
1094        Candle2Day | MarkPriceCandle2Day => Some(BAR_SPEC_2_DAY_LAST),
1095        Candle3Day | MarkPriceCandle3Day => Some(BAR_SPEC_3_DAY_LAST),
1096        Candle5Day | MarkPriceCandle5Day => Some(BAR_SPEC_5_DAY_LAST),
1097        Candle1Week | MarkPriceCandle1Week => Some(BAR_SPEC_1_WEEK_LAST),
1098        Candle1Month | MarkPriceCandle1Month => Some(BAR_SPEC_1_MONTH_LAST),
1099        Candle3Month | MarkPriceCandle3Month => Some(BAR_SPEC_3_MONTH_LAST),
1100        Candle6Month => Some(BAR_SPEC_6_MONTH_LAST),
1101        Candle1Year => Some(BAR_SPEC_12_MONTH_LAST),
1102        _ => None,
1103    }
1104}
1105
1106/// Parses an OKX instrument definition into a Nautilus instrument.
1107///
1108/// # Errors
1109///
1110/// Returns an error if the instrument definition cannot be parsed.
1111pub fn parse_instrument_any(
1112    instrument: &OKXInstrument,
1113    margin_init: Option<Decimal>,
1114    margin_maint: Option<Decimal>,
1115    maker_fee: Option<Decimal>,
1116    taker_fee: Option<Decimal>,
1117    ts_init: UnixNanos,
1118) -> anyhow::Result<Option<InstrumentAny>> {
1119    match instrument.inst_type {
1120        OKXInstrumentType::Spot => parse_spot_instrument(
1121            instrument,
1122            margin_init,
1123            margin_maint,
1124            maker_fee,
1125            taker_fee,
1126            ts_init,
1127        )
1128        .map(Some),
1129        OKXInstrumentType::Margin => parse_spot_instrument(
1130            instrument,
1131            margin_init,
1132            margin_maint,
1133            maker_fee,
1134            taker_fee,
1135            ts_init,
1136        )
1137        .map(Some),
1138        OKXInstrumentType::Swap => parse_swap_instrument(
1139            instrument,
1140            margin_init,
1141            margin_maint,
1142            maker_fee,
1143            taker_fee,
1144            ts_init,
1145        )
1146        .map(Some),
1147        OKXInstrumentType::Futures => parse_futures_instrument(
1148            instrument,
1149            margin_init,
1150            margin_maint,
1151            maker_fee,
1152            taker_fee,
1153            ts_init,
1154        )
1155        .map(Some),
1156        OKXInstrumentType::Option => parse_option_instrument(
1157            instrument,
1158            margin_init,
1159            margin_maint,
1160            maker_fee,
1161            taker_fee,
1162            ts_init,
1163        )
1164        .map(Some),
1165        _ => Ok(None),
1166    }
1167}
1168
1169/// Common parsed instrument data extracted from OKX definitions.
1170#[derive(Debug)]
1171struct CommonInstrumentData {
1172    instrument_id: InstrumentId,
1173    raw_symbol: Symbol,
1174    price_increment: Price,
1175    size_increment: Quantity,
1176    lot_size: Option<Quantity>,
1177    max_quantity: Option<Quantity>,
1178    min_quantity: Option<Quantity>,
1179    max_notional: Option<Money>,
1180    min_notional: Option<Money>,
1181    max_price: Option<Price>,
1182    min_price: Option<Price>,
1183}
1184
1185/// Margin and fee configuration for an instrument.
1186struct MarginAndFees {
1187    margin_init: Option<Decimal>,
1188    margin_maint: Option<Decimal>,
1189    maker_fee: Option<Decimal>,
1190    taker_fee: Option<Decimal>,
1191}
1192
1193/// Parses the multiplier as the product of ct_mult and ct_val.
1194///
1195/// For SPOT instruments where both fields are empty, returns None.
1196/// For derivatives, multiplies the two fields to get the final multiplier.
1197fn parse_multiplier_product(definition: &OKXInstrument) -> anyhow::Result<Option<Quantity>> {
1198    if definition.ct_mult.is_empty() && definition.ct_val.is_empty() {
1199        return Ok(None);
1200    }
1201
1202    let mult_value = if definition.ct_mult.is_empty() {
1203        Decimal::ONE
1204    } else {
1205        Decimal::from_str(&definition.ct_mult).map_err(|e| {
1206            anyhow::anyhow!(
1207                "Failed to parse `ct_mult` '{}' for {}: {e}",
1208                definition.ct_mult,
1209                definition.inst_id
1210            )
1211        })?
1212    };
1213
1214    let val_value = if definition.ct_val.is_empty() {
1215        Decimal::ONE
1216    } else {
1217        Decimal::from_str(&definition.ct_val).map_err(|e| {
1218            anyhow::anyhow!(
1219                "Failed to parse `ct_val` '{}' for {}: {e}",
1220                definition.ct_val,
1221                definition.inst_id
1222            )
1223        })?
1224    };
1225
1226    let product = mult_value * val_value;
1227    Ok(Some(Quantity::from(product.to_string().as_str())))
1228}
1229
1230/// Trait for instrument-specific parsing logic.
1231trait InstrumentParser {
1232    /// Parses instrument-specific fields and creates the final instrument.
1233    fn parse_specific_fields(
1234        &self,
1235        definition: &OKXInstrument,
1236        common: CommonInstrumentData,
1237        margin_fees: MarginAndFees,
1238        ts_init: UnixNanos,
1239    ) -> anyhow::Result<InstrumentAny>;
1240}
1241
1242/// Extracts common fields shared across all instrument types.
1243fn parse_common_instrument_data(
1244    definition: &OKXInstrument,
1245) -> anyhow::Result<CommonInstrumentData> {
1246    let instrument_id = parse_instrument_id(definition.inst_id);
1247    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1248
1249    if definition.tick_sz.is_empty() {
1250        anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1251    }
1252
1253    let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
1254        anyhow::anyhow!(
1255            "Failed to parse `tick_sz` '{}' into Price for {}: {e}",
1256            definition.tick_sz,
1257            definition.inst_id,
1258        )
1259    })?;
1260
1261    let size_increment = Quantity::from(&definition.lot_sz);
1262    let lot_size = Some(Quantity::from(&definition.lot_sz));
1263    let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1264    let min_quantity = Some(Quantity::from(&definition.min_sz));
1265    let max_notional: Option<Money> = None;
1266    let min_notional: Option<Money> = None;
1267    let max_price = None; // TBD
1268    let min_price = None; // TBD
1269
1270    Ok(CommonInstrumentData {
1271        instrument_id,
1272        raw_symbol,
1273        price_increment,
1274        size_increment,
1275        lot_size,
1276        max_quantity,
1277        min_quantity,
1278        max_notional,
1279        min_notional,
1280        max_price,
1281        min_price,
1282    })
1283}
1284
1285/// Generic instrument parsing function that delegates to type-specific parsers.
1286fn parse_instrument_with_parser<P: InstrumentParser>(
1287    definition: &OKXInstrument,
1288    parser: P,
1289    margin_init: Option<Decimal>,
1290    margin_maint: Option<Decimal>,
1291    maker_fee: Option<Decimal>,
1292    taker_fee: Option<Decimal>,
1293    ts_init: UnixNanos,
1294) -> anyhow::Result<InstrumentAny> {
1295    let common = parse_common_instrument_data(definition)?;
1296    parser.parse_specific_fields(
1297        definition,
1298        common,
1299        MarginAndFees {
1300            margin_init,
1301            margin_maint,
1302            maker_fee,
1303            taker_fee,
1304        },
1305        ts_init,
1306    )
1307}
1308
1309/// Parser for spot trading pairs (CurrencyPair).
1310struct SpotInstrumentParser;
1311
1312impl InstrumentParser for SpotInstrumentParser {
1313    fn parse_specific_fields(
1314        &self,
1315        definition: &OKXInstrument,
1316        common: CommonInstrumentData,
1317        margin_fees: MarginAndFees,
1318        ts_init: UnixNanos,
1319    ) -> anyhow::Result<InstrumentAny> {
1320        let context = format!("{} instrument {}", definition.inst_type, definition.inst_id);
1321        let base_currency = get_currency_with_context(&definition.base_ccy, Some(&context));
1322        let quote_currency = get_currency_with_context(&definition.quote_ccy, Some(&context));
1323
1324        // Parse multiplier as product of ct_mult and ct_val
1325        let multiplier = parse_multiplier_product(definition)?;
1326
1327        let instrument = CurrencyPair::new(
1328            common.instrument_id,
1329            common.raw_symbol,
1330            base_currency,
1331            quote_currency,
1332            common.price_increment.precision,
1333            common.size_increment.precision,
1334            common.price_increment,
1335            common.size_increment,
1336            multiplier,
1337            common.lot_size,
1338            common.max_quantity,
1339            common.min_quantity,
1340            common.max_notional,
1341            common.min_notional,
1342            common.max_price,
1343            common.min_price,
1344            margin_fees.margin_init,
1345            margin_fees.margin_maint,
1346            margin_fees.maker_fee,
1347            margin_fees.taker_fee,
1348            ts_init,
1349            ts_init,
1350        );
1351
1352        Ok(InstrumentAny::CurrencyPair(instrument))
1353    }
1354}
1355
1356/// Parses an OKX spot instrument definition into a Nautilus currency pair.
1357///
1358/// # Errors
1359///
1360/// Returns an error if the instrument definition cannot be parsed.
1361pub fn parse_spot_instrument(
1362    definition: &OKXInstrument,
1363    margin_init: Option<Decimal>,
1364    margin_maint: Option<Decimal>,
1365    maker_fee: Option<Decimal>,
1366    taker_fee: Option<Decimal>,
1367    ts_init: UnixNanos,
1368) -> anyhow::Result<InstrumentAny> {
1369    parse_instrument_with_parser(
1370        definition,
1371        SpotInstrumentParser,
1372        margin_init,
1373        margin_maint,
1374        maker_fee,
1375        taker_fee,
1376        ts_init,
1377    )
1378}
1379
1380/// Parses an OKX swap instrument definition into a Nautilus crypto perpetual.
1381///
1382/// # Errors
1383///
1384/// Returns an error if the instrument definition cannot be parsed.
1385pub fn parse_swap_instrument(
1386    definition: &OKXInstrument,
1387    margin_init: Option<Decimal>,
1388    margin_maint: Option<Decimal>,
1389    maker_fee: Option<Decimal>,
1390    taker_fee: Option<Decimal>,
1391    ts_init: UnixNanos,
1392) -> anyhow::Result<InstrumentAny> {
1393    let instrument_id = parse_instrument_id(definition.inst_id);
1394    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1395    let context = format!("SWAP instrument {}", definition.inst_id);
1396    let (base_currency, quote_currency) = definition.uly.split_once('-').ok_or_else(|| {
1397        anyhow::anyhow!(
1398            "Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
1399            definition.uly,
1400            definition.inst_id
1401        )
1402    })?;
1403    let base_currency = get_currency_with_context(base_currency, Some(&context));
1404    let quote_currency = get_currency_with_context(quote_currency, Some(&context));
1405    let settlement_currency = get_currency_with_context(&definition.settle_ccy, Some(&context));
1406    let is_inverse = match definition.ct_type {
1407        OKXContractType::Linear => false,
1408        OKXContractType::Inverse => true,
1409        OKXContractType::None => {
1410            anyhow::bail!(
1411                "Invalid contract type '{}' for {}: expected 'linear' or 'inverse'",
1412                definition.ct_type,
1413                definition.inst_id
1414            )
1415        }
1416    };
1417
1418    if definition.tick_sz.is_empty() {
1419        anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1420    }
1421
1422    let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
1423        anyhow::anyhow!(
1424            "Failed to parse `tick_sz` '{}' into Price for {}: {e}",
1425            definition.tick_sz,
1426            definition.inst_id
1427        )
1428    })?;
1429    let size_increment = Quantity::from(&definition.lot_sz);
1430    let multiplier = parse_multiplier_product(definition)?;
1431    let lot_size = Some(Quantity::from(&definition.lot_sz));
1432    let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1433    let min_quantity = Some(Quantity::from(&definition.min_sz));
1434    let max_notional: Option<Money> = None;
1435    let min_notional: Option<Money> = None;
1436    let max_price = None; // TBD
1437    let min_price = None; // TBD
1438
1439    let instrument = CryptoPerpetual::new(
1440        instrument_id,
1441        raw_symbol,
1442        base_currency,
1443        quote_currency,
1444        settlement_currency,
1445        is_inverse,
1446        price_increment.precision,
1447        size_increment.precision,
1448        price_increment,
1449        size_increment,
1450        multiplier,
1451        lot_size,
1452        max_quantity,
1453        min_quantity,
1454        max_notional,
1455        min_notional,
1456        max_price,
1457        min_price,
1458        margin_init,
1459        margin_maint,
1460        maker_fee,
1461        taker_fee,
1462        ts_init, // No ts_event for response
1463        ts_init,
1464    );
1465
1466    Ok(InstrumentAny::CryptoPerpetual(instrument))
1467}
1468
1469/// Parses an OKX futures instrument definition into a Nautilus crypto future.
1470///
1471/// # Errors
1472///
1473/// Returns an error if the instrument definition cannot be parsed.
1474pub fn parse_futures_instrument(
1475    definition: &OKXInstrument,
1476    margin_init: Option<Decimal>,
1477    margin_maint: Option<Decimal>,
1478    maker_fee: Option<Decimal>,
1479    taker_fee: Option<Decimal>,
1480    ts_init: UnixNanos,
1481) -> anyhow::Result<InstrumentAny> {
1482    let instrument_id = parse_instrument_id(definition.inst_id);
1483    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1484    let context = format!("FUTURES instrument {}", definition.inst_id);
1485    let underlying = get_currency_with_context(&definition.uly, Some(&context));
1486    let (_, quote_currency) = definition.uly.split_once('-').ok_or_else(|| {
1487        anyhow::anyhow!(
1488            "Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
1489            definition.uly,
1490            definition.inst_id
1491        )
1492    })?;
1493    let quote_currency = get_currency_with_context(quote_currency, Some(&context));
1494    let settlement_currency = get_currency_with_context(&definition.settle_ccy, Some(&context));
1495    let is_inverse = match definition.ct_type {
1496        OKXContractType::Linear => false,
1497        OKXContractType::Inverse => true,
1498        OKXContractType::None => {
1499            anyhow::bail!(
1500                "Invalid contract type '{}' for {}: expected 'linear' or 'inverse'",
1501                definition.ct_type,
1502                definition.inst_id
1503            )
1504        }
1505    };
1506    let listing_time = definition
1507        .list_time
1508        .ok_or_else(|| anyhow::anyhow!("`list_time` is required for {}", definition.inst_id))?;
1509    let expiry_time = definition
1510        .exp_time
1511        .ok_or_else(|| anyhow::anyhow!("`exp_time` is required for {}", definition.inst_id))?;
1512    let activation_ns = UnixNanos::from(millis_to_nanos(listing_time as f64));
1513    let expiration_ns = UnixNanos::from(millis_to_nanos(expiry_time as f64));
1514
1515    if definition.tick_sz.is_empty() {
1516        anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1517    }
1518
1519    let price_increment = Price::from(definition.tick_sz.clone());
1520    let size_increment = Quantity::from(&definition.lot_sz);
1521    let multiplier = parse_multiplier_product(definition)?;
1522    let lot_size = Some(Quantity::from(&definition.lot_sz));
1523    let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1524    let min_quantity = Some(Quantity::from(&definition.min_sz));
1525    let max_notional: Option<Money> = None;
1526    let min_notional: Option<Money> = None;
1527    let max_price = None; // TBD
1528    let min_price = None; // TBD
1529
1530    let instrument = CryptoFuture::new(
1531        instrument_id,
1532        raw_symbol,
1533        underlying,
1534        quote_currency,
1535        settlement_currency,
1536        is_inverse,
1537        activation_ns,
1538        expiration_ns,
1539        price_increment.precision,
1540        size_increment.precision,
1541        price_increment,
1542        size_increment,
1543        multiplier,
1544        lot_size,
1545        max_quantity,
1546        min_quantity,
1547        max_notional,
1548        min_notional,
1549        max_price,
1550        min_price,
1551        margin_init,
1552        margin_maint,
1553        maker_fee,
1554        taker_fee,
1555        ts_init, // No ts_event for response
1556        ts_init,
1557    );
1558
1559    Ok(InstrumentAny::CryptoFuture(instrument))
1560}
1561
1562/// Parses an OKX option instrument definition into a Nautilus option contract.
1563///
1564/// # Errors
1565///
1566/// Returns an error if the instrument definition cannot be parsed.
1567pub fn parse_option_instrument(
1568    definition: &OKXInstrument,
1569    margin_init: Option<Decimal>,
1570    margin_maint: Option<Decimal>,
1571    maker_fee: Option<Decimal>,
1572    taker_fee: Option<Decimal>,
1573    ts_init: UnixNanos,
1574) -> anyhow::Result<InstrumentAny> {
1575    let instrument_id = parse_instrument_id(definition.inst_id);
1576    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1577    let option_kind: OptionKind = definition.opt_type.into();
1578    let strike_price = Price::from(&definition.stk);
1579    let context = format!("OPTION instrument {}", definition.inst_id);
1580
1581    let (underlying_str, quote_ccy_str) = definition.uly.split_once('-').ok_or_else(|| {
1582        anyhow::anyhow!(
1583            "Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
1584            definition.uly,
1585            definition.inst_id
1586        )
1587    })?;
1588
1589    let underlying = get_currency_with_context(underlying_str, Some(&context));
1590    let quote_currency = get_currency_with_context(quote_ccy_str, Some(&context));
1591    let settlement_currency = get_currency_with_context(&definition.settle_ccy, Some(&context));
1592
1593    let is_inverse = if definition.ct_type == OKXContractType::None {
1594        settlement_currency == underlying
1595    } else {
1596        matches!(definition.ct_type, OKXContractType::Inverse)
1597    };
1598
1599    let listing_time = definition
1600        .list_time
1601        .ok_or_else(|| anyhow::anyhow!("`list_time` is required for {}", definition.inst_id))?;
1602    let expiry_time = definition
1603        .exp_time
1604        .ok_or_else(|| anyhow::anyhow!("`exp_time` is required for {}", definition.inst_id))?;
1605    let activation_ns = UnixNanos::from(millis_to_nanos(listing_time as f64));
1606    let expiration_ns = UnixNanos::from(millis_to_nanos(expiry_time as f64));
1607
1608    if definition.tick_sz.is_empty() {
1609        anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1610    }
1611
1612    let price_increment = Price::from(definition.tick_sz.clone());
1613    let size_increment = Quantity::from(&definition.lot_sz);
1614    let multiplier = parse_multiplier_product(definition)?;
1615    let lot_size = Quantity::from(&definition.lot_sz);
1616    let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1617    let min_quantity = Some(Quantity::from(&definition.min_sz));
1618    let max_notional = None;
1619    let min_notional = None;
1620    let max_price = None;
1621    let min_price = None;
1622
1623    let instrument = CryptoOption::new(
1624        instrument_id,
1625        raw_symbol,
1626        underlying,
1627        quote_currency,
1628        settlement_currency,
1629        is_inverse,
1630        option_kind,
1631        strike_price,
1632        activation_ns,
1633        expiration_ns,
1634        price_increment.precision,
1635        size_increment.precision,
1636        price_increment,
1637        size_increment,
1638        multiplier,
1639        Some(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,
1651        ts_init,
1652    );
1653
1654    Ok(InstrumentAny::CryptoOption(instrument))
1655}
1656
1657/// Parses an OKX account into a Nautilus account state.
1658///
1659fn parse_balance_field(
1660    value_str: &str,
1661    field_name: &str,
1662    currency: Currency,
1663    ccy_str: &str,
1664) -> Option<Money> {
1665    match value_str.parse::<f64>() {
1666        Ok(v) => Some(Money::new(v, currency)),
1667        Err(e) => {
1668            tracing::warn!(
1669                "Skipping balance detail for {ccy_str} with invalid {field_name} '{value_str}': {e}"
1670            );
1671            None
1672        }
1673    }
1674}
1675
1676/// # Errors
1677///
1678/// Returns an error if the data cannot be parsed.
1679pub fn parse_account_state(
1680    okx_account: &OKXAccount,
1681    account_id: AccountId,
1682    ts_init: UnixNanos,
1683) -> anyhow::Result<AccountState> {
1684    let mut balances = Vec::new();
1685    for b in &okx_account.details {
1686        // Skip balances with empty or whitespace-only currency codes
1687        let ccy_str = b.ccy.as_str().trim();
1688        if ccy_str.is_empty() {
1689            tracing::debug!(
1690                "Skipping balance detail with empty currency code | raw_data={:?}",
1691                b
1692            );
1693            continue;
1694        }
1695
1696        // Get or create currency (consistent with instrument parsing)
1697        let currency = get_currency_with_context(ccy_str, Some("balance detail"));
1698
1699        // Parse balance values, skip if invalid
1700        let Some(total) = parse_balance_field(&b.cash_bal, "cash_bal", currency, ccy_str) else {
1701            continue;
1702        };
1703
1704        let Some(free) = parse_balance_field(&b.avail_bal, "avail_bal", currency, ccy_str) else {
1705            continue;
1706        };
1707
1708        let locked = total - free;
1709        let balance = AccountBalance::new(total, locked, free);
1710        balances.push(balance);
1711    }
1712
1713    // Ensure at least one balance exists (Nautilus requires non-empty balances)
1714    // OKX may return empty details for certain account configurations
1715    if balances.is_empty() {
1716        let zero_currency = Currency::USD();
1717        let zero_money = Money::new(0.0, zero_currency);
1718        let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
1719        balances.push(zero_balance);
1720    }
1721
1722    let mut margins = Vec::new();
1723
1724    // OKX provides account-level margin requirements (not per instrument)
1725    if !okx_account.imr.is_empty() && !okx_account.mmr.is_empty() {
1726        match (
1727            okx_account.imr.parse::<f64>(),
1728            okx_account.mmr.parse::<f64>(),
1729        ) {
1730            (Ok(imr_value), Ok(mmr_value)) => {
1731                if imr_value > 0.0 || mmr_value > 0.0 {
1732                    let margin_currency = Currency::USD();
1733                    let margin_instrument_id =
1734                        InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("OKX"));
1735
1736                    let initial_margin = Money::new(imr_value, margin_currency);
1737                    let maintenance_margin = Money::new(mmr_value, margin_currency);
1738
1739                    let margin_balance = MarginBalance::new(
1740                        initial_margin,
1741                        maintenance_margin,
1742                        margin_instrument_id,
1743                    );
1744
1745                    margins.push(margin_balance);
1746                }
1747            }
1748            (Err(e1), _) => {
1749                tracing::warn!(
1750                    "Failed to parse initial margin requirement '{}': {}",
1751                    okx_account.imr,
1752                    e1
1753                );
1754            }
1755            (_, Err(e2)) => {
1756                tracing::warn!(
1757                    "Failed to parse maintenance margin requirement '{}': {}",
1758                    okx_account.mmr,
1759                    e2
1760                );
1761            }
1762        }
1763    }
1764
1765    let account_type = AccountType::Margin;
1766    let is_reported = true;
1767    let event_id = UUID4::new();
1768    let ts_event = UnixNanos::from(millis_to_nanos(okx_account.u_time as f64));
1769
1770    Ok(AccountState::new(
1771        account_id,
1772        account_type,
1773        balances,
1774        margins,
1775        is_reported,
1776        event_id,
1777        ts_event,
1778        ts_init,
1779        None,
1780    ))
1781}
1782
1783////////////////////////////////////////////////////////////////////////////////
1784// Tests
1785////////////////////////////////////////////////////////////////////////////////
1786
1787#[cfg(test)]
1788mod tests {
1789    use nautilus_model::{identifiers::PositionId, instruments::Instrument};
1790    use rstest::rstest;
1791
1792    use super::*;
1793    use crate::{
1794        OKXPositionSide,
1795        common::{enums::OKXMarginMode, testing::load_test_json},
1796        http::{
1797            client::OKXResponse,
1798            models::{
1799                OKXAccount, OKXBalanceDetail, OKXCandlestick, OKXIndexTicker, OKXMarkPrice,
1800                OKXOrderHistory, OKXPlaceOrderResponse, OKXPosition, OKXPositionHistory,
1801                OKXPositionTier, OKXTrade, OKXTransactionDetail,
1802            },
1803        },
1804    };
1805
1806    #[rstest]
1807    fn test_parse_fee_currency_with_zero_fee_empty_string() {
1808        let result = parse_fee_currency("", 0.0, || "test context".to_string());
1809        assert_eq!(result, Currency::USDT());
1810    }
1811
1812    #[rstest]
1813    fn test_parse_fee_currency_with_zero_fee_valid_currency() {
1814        let result = parse_fee_currency("BTC", 0.0, || "test context".to_string());
1815        assert_eq!(result, Currency::BTC());
1816    }
1817
1818    #[rstest]
1819    fn test_parse_fee_currency_with_valid_currency() {
1820        let result = parse_fee_currency("BTC", 0.001, || "test context".to_string());
1821        assert_eq!(result, Currency::BTC());
1822    }
1823
1824    #[rstest]
1825    fn test_parse_fee_currency_with_empty_string_nonzero_fee() {
1826        let result = parse_fee_currency("", 0.5, || "test context".to_string());
1827        assert_eq!(result, Currency::USDT());
1828    }
1829
1830    #[rstest]
1831    fn test_parse_fee_currency_with_whitespace() {
1832        let result = parse_fee_currency("  ETH  ", 0.002, || "test context".to_string());
1833        assert_eq!(result, Currency::ETH());
1834    }
1835
1836    #[rstest]
1837    fn test_parse_fee_currency_with_unknown_code() {
1838        // Unknown currency code should create a new Currency (8 decimals, crypto)
1839        let result = parse_fee_currency("NEWTOKEN", 0.5, || "test context".to_string());
1840        assert_eq!(result.code.as_str(), "NEWTOKEN");
1841        assert_eq!(result.precision, 8);
1842    }
1843
1844    #[rstest]
1845    fn test_get_currency_with_context_valid() {
1846        let result = get_currency_with_context("BTC", Some("test context"));
1847        assert_eq!(result, Currency::BTC());
1848    }
1849
1850    #[rstest]
1851    fn test_get_currency_with_context_empty() {
1852        let result = get_currency_with_context("", Some("test context"));
1853        assert_eq!(result, Currency::USDT());
1854    }
1855
1856    #[rstest]
1857    fn test_get_currency_with_context_whitespace() {
1858        let result = get_currency_with_context("  ", Some("test context"));
1859        assert_eq!(result, Currency::USDT());
1860    }
1861
1862    #[rstest]
1863    fn test_get_currency_with_context_unknown() {
1864        // Unknown codes should create a new Currency, preserving newly listed assets
1865        let result = get_currency_with_context("NEWCOIN", Some("test context"));
1866        assert_eq!(result.code.as_str(), "NEWCOIN");
1867        assert_eq!(result.precision, 8);
1868    }
1869
1870    #[rstest]
1871    fn test_parse_balance_field_valid() {
1872        let result = parse_balance_field("100.5", "test_field", Currency::BTC(), "BTC");
1873        assert!(result.is_some());
1874        assert_eq!(result.unwrap().as_f64(), 100.5);
1875    }
1876
1877    #[rstest]
1878    fn test_parse_balance_field_invalid_numeric() {
1879        let result = parse_balance_field("not_a_number", "test_field", Currency::BTC(), "BTC");
1880        assert!(result.is_none());
1881    }
1882
1883    #[rstest]
1884    fn test_parse_balance_field_empty() {
1885        let result = parse_balance_field("", "test_field", Currency::BTC(), "BTC");
1886        assert!(result.is_none());
1887    }
1888
1889    // Note: Tests for parse_account_state with edge cases (empty currency codes, invalid values)
1890    // are covered by the existing tests using test data files (e.g., http_get_account_balance.json)
1891
1892    #[rstest]
1893    fn test_parse_trades() {
1894        let json_data = load_test_json("http_get_trades.json");
1895        let parsed: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
1896
1897        // Basic response envelope
1898        assert_eq!(parsed.code, "0");
1899        assert_eq!(parsed.msg, "");
1900        assert_eq!(parsed.data.len(), 2);
1901
1902        // Inspect first record
1903        let trade0 = &parsed.data[0];
1904        assert_eq!(trade0.inst_id, "BTC-USDT");
1905        assert_eq!(trade0.px, "102537.9");
1906        assert_eq!(trade0.sz, "0.00013669");
1907        assert_eq!(trade0.side, OKXSide::Sell);
1908        assert_eq!(trade0.trade_id, "734864333");
1909        assert_eq!(trade0.ts, 1747087163557);
1910
1911        // Inspect second record
1912        let trade1 = &parsed.data[1];
1913        assert_eq!(trade1.inst_id, "BTC-USDT");
1914        assert_eq!(trade1.px, "102537.9");
1915        assert_eq!(trade1.sz, "0.0000125");
1916        assert_eq!(trade1.side, OKXSide::Buy);
1917        assert_eq!(trade1.trade_id, "734864332");
1918        assert_eq!(trade1.ts, 1747087161666);
1919    }
1920
1921    #[rstest]
1922    fn test_parse_candlesticks() {
1923        let json_data = load_test_json("http_get_candlesticks.json");
1924        let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
1925
1926        // Basic response envelope
1927        assert_eq!(parsed.code, "0");
1928        assert_eq!(parsed.msg, "");
1929        assert_eq!(parsed.data.len(), 2);
1930
1931        let bar0 = &parsed.data[0];
1932        assert_eq!(bar0.0, "1625097600000");
1933        assert_eq!(bar0.1, "33528.6");
1934        assert_eq!(bar0.2, "33870.0");
1935        assert_eq!(bar0.3, "33528.6");
1936        assert_eq!(bar0.4, "33783.9");
1937        assert_eq!(bar0.5, "778.838");
1938
1939        let bar1 = &parsed.data[1];
1940        assert_eq!(bar1.0, "1625097660000");
1941        assert_eq!(bar1.1, "33783.9");
1942        assert_eq!(bar1.2, "33783.9");
1943        assert_eq!(bar1.3, "33782.1");
1944        assert_eq!(bar1.4, "33782.1");
1945        assert_eq!(bar1.5, "0.123");
1946    }
1947
1948    #[rstest]
1949    fn test_parse_candlesticks_full() {
1950        let json_data = load_test_json("http_get_candlesticks_full.json");
1951        let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
1952
1953        // Basic response envelope
1954        assert_eq!(parsed.code, "0");
1955        assert_eq!(parsed.msg, "");
1956        assert_eq!(parsed.data.len(), 2);
1957
1958        // Inspect first record
1959        let bar0 = &parsed.data[0];
1960        assert_eq!(bar0.0, "1747094040000");
1961        assert_eq!(bar0.1, "102806.1");
1962        assert_eq!(bar0.2, "102820.4");
1963        assert_eq!(bar0.3, "102806.1");
1964        assert_eq!(bar0.4, "102820.4");
1965        assert_eq!(bar0.5, "1040.37");
1966        assert_eq!(bar0.6, "10.4037");
1967        assert_eq!(bar0.7, "1069603.34883");
1968        assert_eq!(bar0.8, "1");
1969
1970        // Inspect second record
1971        let bar1 = &parsed.data[1];
1972        assert_eq!(bar1.0, "1747093980000");
1973        assert_eq!(bar1.5, "7164.04");
1974        assert_eq!(bar1.6, "71.6404");
1975        assert_eq!(bar1.7, "7364701.57952");
1976        assert_eq!(bar1.8, "1");
1977    }
1978
1979    #[rstest]
1980    fn test_parse_mark_price() {
1981        let json_data = load_test_json("http_get_mark_price.json");
1982        let parsed: OKXResponse<OKXMarkPrice> = serde_json::from_str(&json_data).unwrap();
1983
1984        // Basic response envelope
1985        assert_eq!(parsed.code, "0");
1986        assert_eq!(parsed.msg, "");
1987        assert_eq!(parsed.data.len(), 1);
1988
1989        // Inspect first record
1990        let mark_price = &parsed.data[0];
1991
1992        assert_eq!(mark_price.inst_id, "BTC-USDT-SWAP");
1993        assert_eq!(mark_price.mark_px, "84660.1");
1994        assert_eq!(mark_price.ts, 1744590349506);
1995    }
1996
1997    #[rstest]
1998    fn test_parse_index_price() {
1999        let json_data = load_test_json("http_get_index_price.json");
2000        let parsed: OKXResponse<OKXIndexTicker> = serde_json::from_str(&json_data).unwrap();
2001
2002        // Basic response envelope
2003        assert_eq!(parsed.code, "0");
2004        assert_eq!(parsed.msg, "");
2005        assert_eq!(parsed.data.len(), 1);
2006
2007        // Inspect first record
2008        let index_price = &parsed.data[0];
2009
2010        assert_eq!(index_price.inst_id, "BTC-USDT");
2011        assert_eq!(index_price.idx_px, "103895");
2012        assert_eq!(index_price.ts, 1746942707815);
2013    }
2014
2015    #[rstest]
2016    fn test_parse_account() {
2017        let json_data = load_test_json("http_get_account_balance.json");
2018        let parsed: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
2019
2020        // Basic response envelope
2021        assert_eq!(parsed.code, "0");
2022        assert_eq!(parsed.msg, "");
2023        assert_eq!(parsed.data.len(), 1);
2024
2025        // Inspect first record
2026        let account = &parsed.data[0];
2027        assert_eq!(account.adj_eq, "");
2028        assert_eq!(account.borrow_froz, "");
2029        assert_eq!(account.imr, "");
2030        assert_eq!(account.iso_eq, "5.4682385526666675");
2031        assert_eq!(account.mgn_ratio, "");
2032        assert_eq!(account.mmr, "");
2033        assert_eq!(account.notional_usd, "");
2034        assert_eq!(account.notional_usd_for_borrow, "");
2035        assert_eq!(account.notional_usd_for_futures, "");
2036        assert_eq!(account.notional_usd_for_option, "");
2037        assert_eq!(account.notional_usd_for_swap, "");
2038        assert_eq!(account.ord_froz, "");
2039        assert_eq!(account.total_eq, "99.88870288820581");
2040        assert_eq!(account.upl, "");
2041        assert_eq!(account.u_time, 1744499648556);
2042        assert_eq!(account.details.len(), 1);
2043
2044        let detail = &account.details[0];
2045        assert_eq!(detail.ccy, "USDT");
2046        assert_eq!(detail.avail_bal, "94.42612990333333");
2047        assert_eq!(detail.avail_eq, "94.42612990333333");
2048        assert_eq!(detail.cash_bal, "94.42612990333333");
2049        assert_eq!(detail.dis_eq, "5.4682385526666675");
2050        assert_eq!(detail.eq, "99.89469657000001");
2051        assert_eq!(detail.eq_usd, "99.88870288820581");
2052        assert_eq!(detail.fixed_bal, "0");
2053        assert_eq!(detail.frozen_bal, "5.468566666666667");
2054        assert_eq!(detail.imr, "0");
2055        assert_eq!(detail.iso_eq, "5.468566666666667");
2056        assert_eq!(detail.iso_upl, "-0.0273000000000002");
2057        assert_eq!(detail.mmr, "0");
2058        assert_eq!(detail.notional_lever, "0");
2059        assert_eq!(detail.ord_frozen, "0");
2060        assert_eq!(detail.reward_bal, "0");
2061        assert_eq!(detail.smt_sync_eq, "0");
2062        assert_eq!(detail.spot_copy_trading_eq, "0");
2063        assert_eq!(detail.spot_iso_bal, "0");
2064        assert_eq!(detail.stgy_eq, "0");
2065        assert_eq!(detail.twap, "0");
2066        assert_eq!(detail.upl, "-0.0273000000000002");
2067        assert_eq!(detail.u_time, 1744498994783);
2068    }
2069
2070    #[rstest]
2071    fn test_parse_order_history() {
2072        let json_data = load_test_json("http_get_orders_history.json");
2073        let parsed: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
2074
2075        // Basic response envelope
2076        assert_eq!(parsed.code, "0");
2077        assert_eq!(parsed.msg, "");
2078        assert_eq!(parsed.data.len(), 1);
2079
2080        // Inspect first record
2081        let order = &parsed.data[0];
2082        assert_eq!(order.ord_id, "2497956918703120384");
2083        assert_eq!(order.fill_sz, "0.03");
2084        assert_eq!(order.acc_fill_sz, "0.03");
2085        assert_eq!(order.state, OKXOrderStatus::Filled);
2086        assert!(order.fill_fee.is_none());
2087    }
2088
2089    #[rstest]
2090    fn test_parse_position() {
2091        let json_data = load_test_json("http_get_positions.json");
2092        let parsed: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
2093
2094        // Basic response envelope
2095        assert_eq!(parsed.code, "0");
2096        assert_eq!(parsed.msg, "");
2097        assert_eq!(parsed.data.len(), 1);
2098
2099        // Inspect first record
2100        let pos = &parsed.data[0];
2101        assert_eq!(pos.inst_id, "BTC-USDT-SWAP");
2102        assert_eq!(pos.pos_side, OKXPositionSide::Long);
2103        assert_eq!(pos.pos, "0.5");
2104        assert_eq!(pos.base_bal, "0.5");
2105        assert_eq!(pos.quote_bal, "5000");
2106        assert_eq!(pos.u_time, 1622559930237);
2107    }
2108
2109    #[rstest]
2110    fn test_parse_position_history() {
2111        let json_data = load_test_json("http_get_account_positions-history.json");
2112        let parsed: OKXResponse<OKXPositionHistory> = serde_json::from_str(&json_data).unwrap();
2113
2114        // Basic response envelope
2115        assert_eq!(parsed.code, "0");
2116        assert_eq!(parsed.msg, "");
2117        assert_eq!(parsed.data.len(), 1);
2118
2119        // Inspect first record
2120        let hist = &parsed.data[0];
2121        assert_eq!(hist.inst_id, "ETH-USDT-SWAP");
2122        assert_eq!(hist.inst_type, OKXInstrumentType::Swap);
2123        assert_eq!(hist.mgn_mode, OKXMarginMode::Isolated);
2124        assert_eq!(hist.pos_side, OKXPositionSide::Long);
2125        assert_eq!(hist.lever, "3.0");
2126        assert_eq!(hist.open_avg_px, "3226.93");
2127        assert_eq!(hist.close_avg_px.as_deref(), Some("3224.8"));
2128        assert_eq!(hist.pnl.as_deref(), Some("-0.0213"));
2129        assert!(!hist.c_time.is_empty());
2130        assert!(hist.u_time > 0);
2131    }
2132
2133    #[rstest]
2134    fn test_parse_position_tiers() {
2135        let json_data = load_test_json("http_get_position_tiers.json");
2136        let parsed: OKXResponse<OKXPositionTier> = serde_json::from_str(&json_data).unwrap();
2137
2138        // Basic response envelope
2139        assert_eq!(parsed.code, "0");
2140        assert_eq!(parsed.msg, "");
2141        assert_eq!(parsed.data.len(), 1);
2142
2143        // Inspect first tier record
2144        let tier = &parsed.data[0];
2145        assert_eq!(tier.inst_id, "BTC-USDT");
2146        assert_eq!(tier.tier, "1");
2147        assert_eq!(tier.min_sz, "0");
2148        assert_eq!(tier.max_sz, "50");
2149        assert_eq!(tier.imr, "0.1");
2150        assert_eq!(tier.mmr, "0.03");
2151    }
2152
2153    #[rstest]
2154    fn test_parse_account_field_name_compatibility() {
2155        // Test with new field names (with Amt suffix)
2156        let json_new = load_test_json("http_balance_detail_new_fields.json");
2157        let detail_new: OKXBalanceDetail = serde_json::from_str(&json_new).unwrap();
2158        assert_eq!(detail_new.max_spot_in_use_amt, "50.0");
2159        assert_eq!(detail_new.spot_in_use_amt, "30.0");
2160        assert_eq!(detail_new.cl_spot_in_use_amt, "25.0");
2161
2162        // Test with old field names (without Amt suffix) - for backward compatibility
2163        let json_old = load_test_json("http_balance_detail_old_fields.json");
2164        let detail_old: OKXBalanceDetail = serde_json::from_str(&json_old).unwrap();
2165        assert_eq!(detail_old.max_spot_in_use_amt, "75.0");
2166        assert_eq!(detail_old.spot_in_use_amt, "40.0");
2167        assert_eq!(detail_old.cl_spot_in_use_amt, "35.0");
2168    }
2169
2170    #[rstest]
2171    fn test_parse_place_order_response() {
2172        let json_data = load_test_json("http_place_order_response.json");
2173        let parsed: OKXPlaceOrderResponse = serde_json::from_str(&json_data).unwrap();
2174        assert_eq!(
2175            parsed.ord_id,
2176            Some(ustr::Ustr::from("12345678901234567890"))
2177        );
2178        assert_eq!(parsed.cl_ord_id, Some(ustr::Ustr::from("client_order_123")));
2179        assert_eq!(parsed.tag, Some("".to_string()));
2180    }
2181
2182    #[rstest]
2183    fn test_parse_transaction_details() {
2184        let json_data = load_test_json("http_transaction_detail.json");
2185        let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
2186        assert_eq!(parsed.inst_type, OKXInstrumentType::Spot);
2187        assert_eq!(parsed.inst_id, Ustr::from("BTC-USDT"));
2188        assert_eq!(parsed.trade_id, Ustr::from("123456789"));
2189        assert_eq!(parsed.ord_id, Ustr::from("987654321"));
2190        assert_eq!(parsed.cl_ord_id, Ustr::from("client_123"));
2191        assert_eq!(parsed.bill_id, Ustr::from("bill_456"));
2192        assert_eq!(parsed.fill_px, "42000.5");
2193        assert_eq!(parsed.fill_sz, "0.001");
2194        assert_eq!(parsed.side, OKXSide::Buy);
2195        assert_eq!(parsed.exec_type, OKXExecType::Taker);
2196        assert_eq!(parsed.fee_ccy, "USDT");
2197        assert_eq!(parsed.fee, Some("0.042".to_string()));
2198        assert_eq!(parsed.ts, 1625097600000);
2199    }
2200
2201    #[rstest]
2202    fn test_parse_empty_fee_field() {
2203        let json_data = load_test_json("http_transaction_detail_empty_fee.json");
2204        let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
2205        assert_eq!(parsed.fee, None);
2206    }
2207
2208    #[rstest]
2209    fn test_parse_optional_string_to_u64() {
2210        use serde::Deserialize;
2211
2212        #[derive(Deserialize)]
2213        struct TestStruct {
2214            #[serde(deserialize_with = "crate::common::parse::deserialize_optional_string_to_u64")]
2215            value: Option<u64>,
2216        }
2217
2218        let json_cases = load_test_json("common_optional_string_to_u64.json");
2219        let cases: Vec<TestStruct> = serde_json::from_str(&json_cases).unwrap();
2220
2221        assert_eq!(cases[0].value, Some(12345));
2222        assert_eq!(cases[1].value, None);
2223        assert_eq!(cases[2].value, None);
2224    }
2225
2226    #[rstest]
2227    fn test_parse_error_handling() {
2228        // Test error handling with invalid price string
2229        let invalid_price = "invalid-price";
2230        let result = crate::common::parse::parse_price(invalid_price, 2);
2231        assert!(result.is_err());
2232
2233        // Test error handling with invalid quantity string
2234        let invalid_quantity = "invalid-quantity";
2235        let result = crate::common::parse::parse_quantity(invalid_quantity, 8);
2236        assert!(result.is_err());
2237    }
2238
2239    #[rstest]
2240    fn test_parse_spot_instrument() {
2241        let json_data = load_test_json("http_get_instruments_spot.json");
2242        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2243        let okx_inst: &OKXInstrument = response
2244            .data
2245            .first()
2246            .expect("Test data must have an instrument");
2247
2248        let instrument =
2249            parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2250
2251        assert_eq!(instrument.id(), InstrumentId::from("BTC-USD.OKX"));
2252        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD"));
2253        assert_eq!(instrument.underlying(), None);
2254        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2255        assert_eq!(instrument.quote_currency(), Currency::USD());
2256        assert_eq!(instrument.settlement_currency(), Currency::USD());
2257        assert_eq!(instrument.price_precision(), 1);
2258        assert_eq!(instrument.size_precision(), 8);
2259        assert_eq!(instrument.price_increment(), Price::from("0.1"));
2260        assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
2261        assert_eq!(instrument.multiplier(), Quantity::from(1));
2262        assert_eq!(instrument.lot_size(), Some(Quantity::from("0.00000001")));
2263        assert_eq!(instrument.max_quantity(), Some(Quantity::from(1000000)));
2264        assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.00001")));
2265        assert_eq!(instrument.max_notional(), None);
2266        assert_eq!(instrument.min_notional(), None);
2267        assert_eq!(instrument.max_price(), None);
2268        assert_eq!(instrument.min_price(), None);
2269    }
2270
2271    #[rstest]
2272    fn test_parse_margin_instrument() {
2273        let json_data = load_test_json("http_get_instruments_margin.json");
2274        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2275        let okx_inst: &OKXInstrument = response
2276            .data
2277            .first()
2278            .expect("Test data must have an instrument");
2279
2280        let instrument =
2281            parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2282
2283        assert_eq!(instrument.id(), InstrumentId::from("BTC-USDT.OKX"));
2284        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USDT"));
2285        assert_eq!(instrument.underlying(), None);
2286        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2287        assert_eq!(instrument.quote_currency(), Currency::USDT());
2288        assert_eq!(instrument.settlement_currency(), Currency::USDT());
2289        assert_eq!(instrument.price_precision(), 1);
2290        assert_eq!(instrument.size_precision(), 8);
2291        assert_eq!(instrument.price_increment(), Price::from("0.1"));
2292        assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
2293        assert_eq!(instrument.multiplier(), Quantity::from(1));
2294        assert_eq!(instrument.lot_size(), Some(Quantity::from("0.00000001")));
2295        assert_eq!(instrument.max_quantity(), Some(Quantity::from(1000000)));
2296        assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.00001")));
2297        assert_eq!(instrument.max_notional(), None);
2298        assert_eq!(instrument.min_notional(), None);
2299        assert_eq!(instrument.max_price(), None);
2300        assert_eq!(instrument.min_price(), None);
2301    }
2302
2303    #[rstest]
2304    fn test_parse_spot_instrument_with_valid_ct_mult() {
2305        let json_data = load_test_json("http_get_instruments_spot.json");
2306        let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2307
2308        // Modify ctMult to have a valid multiplier value (ctVal is empty, defaults to 1)
2309        if let Some(inst) = response.data.first_mut() {
2310            inst.ct_mult = "0.01".to_string();
2311        }
2312
2313        let okx_inst = response.data.first().unwrap();
2314        let instrument =
2315            parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2316
2317        // Should parse the multiplier as product of ctMult * ctVal (0.01 * 1 = 0.01)
2318        if let InstrumentAny::CurrencyPair(pair) = instrument {
2319            assert_eq!(pair.multiplier, Quantity::from("0.01"));
2320        } else {
2321            panic!("Expected CurrencyPair instrument");
2322        }
2323    }
2324
2325    #[rstest]
2326    fn test_parse_spot_instrument_with_invalid_ct_mult() {
2327        let json_data = load_test_json("http_get_instruments_spot.json");
2328        let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2329
2330        // Modify ctMult to be invalid
2331        if let Some(inst) = response.data.first_mut() {
2332            inst.ct_mult = "invalid_number".to_string();
2333        }
2334
2335        let okx_inst = response.data.first().unwrap();
2336        let result = parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default());
2337
2338        // Should error instead of silently defaulting to 1.0
2339        assert!(result.is_err());
2340        assert!(
2341            result
2342                .unwrap_err()
2343                .to_string()
2344                .contains("Failed to parse `ct_mult`")
2345        );
2346    }
2347
2348    #[rstest]
2349    fn test_parse_spot_instrument_with_fees() {
2350        let json_data = load_test_json("http_get_instruments_spot.json");
2351        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2352        let okx_inst = response.data.first().unwrap();
2353
2354        let maker_fee = Some(Decimal::new(8, 4)); // 0.0008
2355        let taker_fee = Some(Decimal::new(10, 4)); // 0.0010
2356
2357        let instrument = parse_spot_instrument(
2358            okx_inst,
2359            None,
2360            None,
2361            maker_fee,
2362            taker_fee,
2363            UnixNanos::default(),
2364        )
2365        .unwrap();
2366
2367        // Should apply the provided fees to the instrument
2368        if let InstrumentAny::CurrencyPair(pair) = instrument {
2369            assert_eq!(pair.maker_fee, Decimal::new(8, 4));
2370            assert_eq!(pair.taker_fee, Decimal::new(10, 4));
2371        } else {
2372            panic!("Expected CurrencyPair instrument");
2373        }
2374    }
2375
2376    #[rstest]
2377    fn test_parse_swap_instrument() {
2378        let json_data = load_test_json("http_get_instruments_swap.json");
2379        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2380        let okx_inst: &OKXInstrument = response
2381            .data
2382            .first()
2383            .expect("Test data must have an instrument");
2384
2385        let instrument =
2386            parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2387
2388        assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-SWAP.OKX"));
2389        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-SWAP"));
2390        assert_eq!(instrument.underlying(), None);
2391        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2392        assert_eq!(instrument.quote_currency(), Currency::USD());
2393        assert_eq!(instrument.settlement_currency(), Currency::BTC());
2394        assert!(instrument.is_inverse());
2395        assert_eq!(instrument.price_precision(), 1);
2396        assert_eq!(instrument.size_precision(), 0);
2397        assert_eq!(instrument.price_increment(), Price::from("0.1"));
2398        assert_eq!(instrument.size_increment(), Quantity::from(1));
2399        assert_eq!(instrument.multiplier(), Quantity::from(100));
2400        assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2401        assert_eq!(instrument.max_quantity(), Some(Quantity::from(30000)));
2402        assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2403        assert_eq!(instrument.max_notional(), None);
2404        assert_eq!(instrument.min_notional(), None);
2405        assert_eq!(instrument.max_price(), None);
2406        assert_eq!(instrument.min_price(), None);
2407    }
2408
2409    #[rstest]
2410    fn test_parse_linear_swap_instrument() {
2411        let json_data = load_test_json("http_get_instruments_swap.json");
2412        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2413
2414        let okx_inst = response
2415            .data
2416            .iter()
2417            .find(|i| i.inst_id == "ETH-USDT-SWAP")
2418            .expect("ETH-USDT-SWAP must be in test data");
2419
2420        let instrument =
2421            parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2422
2423        assert_eq!(instrument.id(), InstrumentId::from("ETH-USDT-SWAP.OKX"));
2424        assert_eq!(instrument.raw_symbol(), Symbol::from("ETH-USDT-SWAP"));
2425        assert_eq!(instrument.base_currency(), Some(Currency::ETH()));
2426        assert_eq!(instrument.quote_currency(), Currency::USDT());
2427        assert_eq!(instrument.settlement_currency(), Currency::USDT());
2428        assert!(!instrument.is_inverse());
2429        assert_eq!(instrument.multiplier(), Quantity::from("0.1"));
2430        assert_eq!(instrument.price_precision(), 2);
2431        assert_eq!(instrument.size_precision(), 2);
2432        assert_eq!(instrument.price_increment(), Price::from("0.01"));
2433        assert_eq!(instrument.size_increment(), Quantity::from("0.01"));
2434        assert_eq!(instrument.lot_size(), Some(Quantity::from("0.01")));
2435        assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.01")));
2436        assert_eq!(instrument.max_quantity(), Some(Quantity::from(20000)));
2437    }
2438
2439    #[rstest]
2440    fn test_fee_field_selection_for_contract_types() {
2441        use rust_decimal::Decimal;
2442
2443        // Mock OKXFeeRate with different values for crypto vs USDT-margined
2444        let maker_crypto = "0.0002"; // Crypto-margined maker fee
2445        let taker_crypto = "0.0005"; // Crypto-margined taker fee
2446        let maker_usdt = "0.0008"; // USDT-margined maker fee
2447        let taker_usdt = "0.0010"; // USDT-margined taker fee
2448
2449        // Test Linear (USDT-margined) - should use maker_u/taker_u
2450        let is_usdt_margined = true;
2451        let (maker_str, taker_str) = if is_usdt_margined {
2452            (maker_usdt, taker_usdt)
2453        } else {
2454            (maker_crypto, taker_crypto)
2455        };
2456
2457        assert_eq!(maker_str, "0.0008");
2458        assert_eq!(taker_str, "0.0010");
2459
2460        let maker_fee = Decimal::from_str(maker_str).unwrap();
2461        let taker_fee = Decimal::from_str(taker_str).unwrap();
2462
2463        assert_eq!(maker_fee, Decimal::new(8, 4));
2464        assert_eq!(taker_fee, Decimal::new(10, 4));
2465
2466        // Test Inverse (crypto-margined) - should use maker/taker
2467        let is_usdt_margined = false;
2468        let (maker_str, taker_str) = if is_usdt_margined {
2469            (maker_usdt, taker_usdt)
2470        } else {
2471            (maker_crypto, taker_crypto)
2472        };
2473
2474        assert_eq!(maker_str, "0.0002");
2475        assert_eq!(taker_str, "0.0005");
2476
2477        let maker_fee = Decimal::from_str(maker_str).unwrap();
2478        let taker_fee = Decimal::from_str(taker_str).unwrap();
2479
2480        assert_eq!(maker_fee, Decimal::new(2, 4));
2481        assert_eq!(taker_fee, Decimal::new(5, 4));
2482    }
2483
2484    #[rstest]
2485    fn test_parse_futures_instrument() {
2486        let json_data = load_test_json("http_get_instruments_futures.json");
2487        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2488        let okx_inst: &OKXInstrument = response
2489            .data
2490            .first()
2491            .expect("Test data must have an instrument");
2492
2493        let instrument =
2494            parse_futures_instrument(okx_inst, None, None, None, None, UnixNanos::default())
2495                .unwrap();
2496
2497        assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-241220.OKX"));
2498        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-241220"));
2499        assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
2500        assert_eq!(instrument.quote_currency(), Currency::USD());
2501        assert_eq!(instrument.settlement_currency(), Currency::BTC());
2502        assert!(instrument.is_inverse());
2503        assert_eq!(instrument.price_precision(), 1);
2504        assert_eq!(instrument.size_precision(), 0);
2505        assert_eq!(instrument.price_increment(), Price::from("0.1"));
2506        assert_eq!(instrument.size_increment(), Quantity::from(1));
2507        assert_eq!(instrument.multiplier(), Quantity::from(100));
2508        assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2509        assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2510        assert_eq!(instrument.max_quantity(), Some(Quantity::from(10000)));
2511    }
2512
2513    #[rstest]
2514    fn test_parse_option_instrument() {
2515        let json_data = load_test_json("http_get_instruments_option.json");
2516        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2517        let okx_inst: &OKXInstrument = response
2518            .data
2519            .first()
2520            .expect("Test data must have an instrument");
2521
2522        let instrument =
2523            parse_option_instrument(okx_inst, None, None, None, None, UnixNanos::default())
2524                .unwrap();
2525
2526        assert_eq!(
2527            instrument.id(),
2528            InstrumentId::from("BTC-USD-241217-92000-C.OKX")
2529        );
2530        assert_eq!(
2531            instrument.raw_symbol(),
2532            Symbol::from("BTC-USD-241217-92000-C")
2533        );
2534        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2535        assert_eq!(instrument.quote_currency(), Currency::USD());
2536        assert_eq!(instrument.settlement_currency(), Currency::BTC());
2537        assert!(instrument.is_inverse());
2538        assert_eq!(instrument.price_precision(), 4);
2539        assert_eq!(instrument.size_precision(), 0);
2540        assert_eq!(instrument.price_increment(), Price::from("0.0001"));
2541        assert_eq!(instrument.size_increment(), Quantity::from(1));
2542        assert_eq!(instrument.multiplier(), Quantity::from("0.01"));
2543        assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2544        assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2545        assert_eq!(instrument.max_quantity(), Some(Quantity::from(5000)));
2546        assert_eq!(instrument.max_notional(), None);
2547        assert_eq!(instrument.min_notional(), None);
2548        assert_eq!(instrument.max_price(), None);
2549        assert_eq!(instrument.min_price(), None);
2550    }
2551
2552    #[rstest]
2553    fn test_parse_account_state() {
2554        let json_data = load_test_json("http_get_account_balance.json");
2555        let response: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
2556        let okx_account = response
2557            .data
2558            .first()
2559            .expect("Test data must have an account");
2560
2561        let account_id = AccountId::new("OKX-001");
2562        let account_state =
2563            parse_account_state(okx_account, account_id, UnixNanos::default()).unwrap();
2564
2565        assert_eq!(account_state.account_id, account_id);
2566        assert_eq!(account_state.account_type, AccountType::Margin);
2567        assert_eq!(account_state.balances.len(), 1);
2568        assert_eq!(account_state.margins.len(), 0); // No margins in this test data (spot account)
2569        assert!(account_state.is_reported);
2570
2571        // Check the USDT balance details
2572        let usdt_balance = &account_state.balances[0];
2573        assert_eq!(
2574            usdt_balance.total,
2575            Money::new(94.42612990333333, Currency::USDT())
2576        );
2577        assert_eq!(
2578            usdt_balance.free,
2579            Money::new(94.42612990333333, Currency::USDT())
2580        );
2581        assert_eq!(usdt_balance.locked, Money::new(0.0, Currency::USDT()));
2582    }
2583
2584    #[rstest]
2585    fn test_parse_account_state_with_margins() {
2586        // Create test data with margin requirements
2587        let account_json = r#"{
2588            "adjEq": "10000.0",
2589            "borrowFroz": "0",
2590            "details": [{
2591                "accAvgPx": "",
2592                "availBal": "8000.0",
2593                "availEq": "8000.0",
2594                "borrowFroz": "0",
2595                "cashBal": "10000.0",
2596                "ccy": "USDT",
2597                "clSpotInUseAmt": "0",
2598                "coinUsdPrice": "1.0",
2599                "colBorrAutoConversion": "0",
2600                "collateralEnabled": false,
2601                "collateralRestrict": false,
2602                "crossLiab": "0",
2603                "disEq": "10000.0",
2604                "eq": "10000.0",
2605                "eqUsd": "10000.0",
2606                "fixedBal": "0",
2607                "frozenBal": "2000.0",
2608                "imr": "0",
2609                "interest": "0",
2610                "isoEq": "0",
2611                "isoLiab": "0",
2612                "isoUpl": "0",
2613                "liab": "0",
2614                "maxLoan": "0",
2615                "mgnRatio": "0",
2616                "maxSpotInUseAmt": "0",
2617                "mmr": "0",
2618                "notionalLever": "0",
2619                "openAvgPx": "",
2620                "ordFrozen": "2000.0",
2621                "rewardBal": "0",
2622                "smtSyncEq": "0",
2623                "spotBal": "0",
2624                "spotCopyTradingEq": "0",
2625                "spotInUseAmt": "0",
2626                "spotIsoBal": "0",
2627                "spotUpl": "0",
2628                "spotUplRatio": "0",
2629                "stgyEq": "0",
2630                "totalPnl": "0",
2631                "totalPnlRatio": "0",
2632                "twap": "0",
2633                "uTime": "1704067200000",
2634                "upl": "0",
2635                "uplLiab": "0"
2636            }],
2637            "imr": "500.25",
2638            "isoEq": "0",
2639            "mgnRatio": "20.5",
2640            "mmr": "250.75",
2641            "notionalUsd": "5000.0",
2642            "notionalUsdForBorrow": "0",
2643            "notionalUsdForFutures": "0",
2644            "notionalUsdForOption": "0",
2645            "notionalUsdForSwap": "5000.0",
2646            "ordFroz": "2000.0",
2647            "totalEq": "10000.0",
2648            "uTime": "1704067200000",
2649            "upl": "0"
2650        }"#;
2651
2652        let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
2653        let account_id = AccountId::new("OKX-001");
2654        let account_state =
2655            parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
2656
2657        // Verify account details
2658        assert_eq!(account_state.account_id, account_id);
2659        assert_eq!(account_state.account_type, AccountType::Margin);
2660        assert_eq!(account_state.balances.len(), 1);
2661
2662        // Verify margin information was parsed
2663        assert_eq!(account_state.margins.len(), 1);
2664        let margin = &account_state.margins[0];
2665
2666        // Check margin values
2667        assert_eq!(margin.initial, Money::new(500.25, Currency::USD()));
2668        assert_eq!(margin.maintenance, Money::new(250.75, Currency::USD()));
2669        assert_eq!(margin.currency, Currency::USD());
2670        assert_eq!(margin.instrument_id.symbol.as_str(), "ACCOUNT");
2671        assert_eq!(margin.instrument_id.venue.as_str(), "OKX");
2672
2673        // Check the USDT balance details
2674        let usdt_balance = &account_state.balances[0];
2675        assert_eq!(usdt_balance.total, Money::new(10000.0, Currency::USDT()));
2676        assert_eq!(usdt_balance.free, Money::new(8000.0, Currency::USDT()));
2677        assert_eq!(usdt_balance.locked, Money::new(2000.0, Currency::USDT()));
2678    }
2679
2680    #[rstest]
2681    fn test_parse_account_state_empty_margins() {
2682        // Create test data with empty margin strings (common for spot accounts)
2683        let account_json = r#"{
2684            "adjEq": "",
2685            "borrowFroz": "",
2686            "details": [{
2687                "accAvgPx": "",
2688                "availBal": "1000.0",
2689                "availEq": "1000.0",
2690                "borrowFroz": "0",
2691                "cashBal": "1000.0",
2692                "ccy": "BTC",
2693                "clSpotInUseAmt": "0",
2694                "coinUsdPrice": "50000.0",
2695                "colBorrAutoConversion": "0",
2696                "collateralEnabled": false,
2697                "collateralRestrict": false,
2698                "crossLiab": "0",
2699                "disEq": "50000.0",
2700                "eq": "1000.0",
2701                "eqUsd": "50000.0",
2702                "fixedBal": "0",
2703                "frozenBal": "0",
2704                "imr": "0",
2705                "interest": "0",
2706                "isoEq": "0",
2707                "isoLiab": "0",
2708                "isoUpl": "0",
2709                "liab": "0",
2710                "maxLoan": "0",
2711                "mgnRatio": "0",
2712                "maxSpotInUseAmt": "0",
2713                "mmr": "0",
2714                "notionalLever": "0",
2715                "openAvgPx": "",
2716                "ordFrozen": "0",
2717                "rewardBal": "0",
2718                "smtSyncEq": "0",
2719                "spotBal": "0",
2720                "spotCopyTradingEq": "0",
2721                "spotInUseAmt": "0",
2722                "spotIsoBal": "0",
2723                "spotUpl": "0",
2724                "spotUplRatio": "0",
2725                "stgyEq": "0",
2726                "totalPnl": "0",
2727                "totalPnlRatio": "0",
2728                "twap": "0",
2729                "uTime": "1704067200000",
2730                "upl": "0",
2731                "uplLiab": "0"
2732            }],
2733            "imr": "",
2734            "isoEq": "0",
2735            "mgnRatio": "",
2736            "mmr": "",
2737            "notionalUsd": "",
2738            "notionalUsdForBorrow": "",
2739            "notionalUsdForFutures": "",
2740            "notionalUsdForOption": "",
2741            "notionalUsdForSwap": "",
2742            "ordFroz": "",
2743            "totalEq": "50000.0",
2744            "uTime": "1704067200000",
2745            "upl": "0"
2746        }"#;
2747
2748        let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
2749        let account_id = AccountId::new("OKX-SPOT");
2750        let account_state =
2751            parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
2752
2753        // Verify no margins are created when fields are empty
2754        assert_eq!(account_state.margins.len(), 0);
2755        assert_eq!(account_state.balances.len(), 1);
2756
2757        // Check the BTC balance
2758        let btc_balance = &account_state.balances[0];
2759        assert_eq!(btc_balance.total, Money::new(1000.0, Currency::BTC()));
2760    }
2761
2762    #[rstest]
2763    fn test_parse_order_status_report() {
2764        let json_data = load_test_json("http_get_orders_history.json");
2765        let response: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
2766        let okx_order = response
2767            .data
2768            .first()
2769            .expect("Test data must have an order")
2770            .clone();
2771
2772        let account_id = AccountId::new("OKX-001");
2773        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2774        let order_report = parse_order_status_report(
2775            &okx_order,
2776            account_id,
2777            instrument_id,
2778            2,
2779            8,
2780            UnixNanos::default(),
2781        );
2782
2783        assert_eq!(order_report.account_id, account_id);
2784        assert_eq!(order_report.instrument_id, instrument_id);
2785        assert_eq!(order_report.quantity, Quantity::from("0.03000000"));
2786        assert_eq!(order_report.filled_qty, Quantity::from("0.03000000"));
2787        assert_eq!(order_report.order_side, OrderSide::Buy);
2788        assert_eq!(order_report.order_type, OrderType::Market);
2789        assert_eq!(order_report.order_status, OrderStatus::Filled);
2790    }
2791
2792    #[rstest]
2793    fn test_parse_position_status_report() {
2794        let json_data = load_test_json("http_get_positions.json");
2795        let response: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
2796        let okx_position = response
2797            .data
2798            .first()
2799            .expect("Test data must have a position")
2800            .clone();
2801
2802        let account_id = AccountId::new("OKX-001");
2803        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2804        let position_report = parse_position_status_report(
2805            okx_position,
2806            account_id,
2807            instrument_id,
2808            8,
2809            UnixNanos::default(),
2810        )
2811        .unwrap();
2812
2813        assert_eq!(position_report.account_id, account_id);
2814        assert_eq!(position_report.instrument_id, instrument_id);
2815    }
2816
2817    #[rstest]
2818    fn test_parse_trade_tick() {
2819        let json_data = load_test_json("http_get_trades.json");
2820        let response: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
2821        let okx_trade = response.data.first().expect("Test data must have a trade");
2822
2823        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2824        let trade_tick =
2825            parse_trade_tick(okx_trade, instrument_id, 2, 8, UnixNanos::default()).unwrap();
2826
2827        assert_eq!(trade_tick.instrument_id, instrument_id);
2828        assert_eq!(trade_tick.price, Price::from("102537.90"));
2829        assert_eq!(trade_tick.size, Quantity::from("0.00013669"));
2830        assert_eq!(trade_tick.aggressor_side, AggressorSide::Seller);
2831        assert_eq!(trade_tick.trade_id, TradeId::new("734864333"));
2832    }
2833
2834    #[rstest]
2835    fn test_parse_mark_price_update() {
2836        let json_data = load_test_json("http_get_mark_price.json");
2837        let response: OKXResponse<crate::http::models::OKXMarkPrice> =
2838            serde_json::from_str(&json_data).unwrap();
2839        let okx_mark_price = response
2840            .data
2841            .first()
2842            .expect("Test data must have a mark price");
2843
2844        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2845        let mark_price_update =
2846            parse_mark_price_update(okx_mark_price, instrument_id, 2, UnixNanos::default())
2847                .unwrap();
2848
2849        assert_eq!(mark_price_update.instrument_id, instrument_id);
2850        assert_eq!(mark_price_update.value, Price::from("84660.10"));
2851        assert_eq!(
2852            mark_price_update.ts_event,
2853            UnixNanos::from(1744590349506000000)
2854        );
2855    }
2856
2857    #[rstest]
2858    fn test_parse_index_price_update() {
2859        let json_data = load_test_json("http_get_index_price.json");
2860        let response: OKXResponse<crate::http::models::OKXIndexTicker> =
2861            serde_json::from_str(&json_data).unwrap();
2862        let okx_index_ticker = response
2863            .data
2864            .first()
2865            .expect("Test data must have an index ticker");
2866
2867        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2868        let index_price_update =
2869            parse_index_price_update(okx_index_ticker, instrument_id, 2, UnixNanos::default())
2870                .unwrap();
2871
2872        assert_eq!(index_price_update.instrument_id, instrument_id);
2873        assert_eq!(index_price_update.value, Price::from("103895.00"));
2874        assert_eq!(
2875            index_price_update.ts_event,
2876            UnixNanos::from(1746942707815000000)
2877        );
2878    }
2879
2880    #[rstest]
2881    fn test_parse_candlestick() {
2882        let json_data = load_test_json("http_get_candlesticks.json");
2883        let response: OKXResponse<crate::http::models::OKXCandlestick> =
2884            serde_json::from_str(&json_data).unwrap();
2885        let okx_candlestick = response
2886            .data
2887            .first()
2888            .expect("Test data must have a candlestick");
2889
2890        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2891        let bar_type = BarType::new(
2892            instrument_id,
2893            BAR_SPEC_1_DAY_LAST,
2894            AggregationSource::External,
2895        );
2896        let bar = parse_candlestick(okx_candlestick, bar_type, 2, 8, UnixNanos::default()).unwrap();
2897
2898        assert_eq!(bar.bar_type, bar_type);
2899        assert_eq!(bar.open, Price::from("33528.60"));
2900        assert_eq!(bar.high, Price::from("33870.00"));
2901        assert_eq!(bar.low, Price::from("33528.60"));
2902        assert_eq!(bar.close, Price::from("33783.90"));
2903        assert_eq!(bar.volume, Quantity::from("778.83800000"));
2904        assert_eq!(bar.ts_event, UnixNanos::from(1625097600000000000));
2905    }
2906
2907    #[rstest]
2908    fn test_parse_millisecond_timestamp() {
2909        let timestamp_ms = 1625097600000u64;
2910        let result = parse_millisecond_timestamp(timestamp_ms);
2911        assert_eq!(result, UnixNanos::from(1625097600000000000));
2912    }
2913
2914    #[rstest]
2915    fn test_parse_rfc3339_timestamp() {
2916        let timestamp_str = "2021-07-01T00:00:00.000Z";
2917        let result = parse_rfc3339_timestamp(timestamp_str).unwrap();
2918        assert_eq!(result, UnixNanos::from(1625097600000000000));
2919
2920        // Test with timezone
2921        let timestamp_str_tz = "2021-07-01T08:00:00.000+08:00";
2922        let result_tz = parse_rfc3339_timestamp(timestamp_str_tz).unwrap();
2923        assert_eq!(result_tz, UnixNanos::from(1625097600000000000));
2924
2925        // Test error case
2926        let invalid_timestamp = "invalid-timestamp";
2927        assert!(parse_rfc3339_timestamp(invalid_timestamp).is_err());
2928    }
2929
2930    #[rstest]
2931    fn test_parse_price() {
2932        let price_str = "42219.5";
2933        let precision = 2;
2934        let result = parse_price(price_str, precision).unwrap();
2935        assert_eq!(result, Price::from("42219.50"));
2936
2937        // Test error case
2938        let invalid_price = "invalid-price";
2939        assert!(parse_price(invalid_price, precision).is_err());
2940    }
2941
2942    #[rstest]
2943    fn test_parse_quantity() {
2944        let quantity_str = "0.12345678";
2945        let precision = 8;
2946        let result = parse_quantity(quantity_str, precision).unwrap();
2947        assert_eq!(result, Quantity::from("0.12345678"));
2948
2949        // Test error case
2950        let invalid_quantity = "invalid-quantity";
2951        assert!(parse_quantity(invalid_quantity, precision).is_err());
2952    }
2953
2954    #[rstest]
2955    fn test_parse_aggressor_side() {
2956        assert_eq!(
2957            parse_aggressor_side(&Some(OKXSide::Buy)),
2958            AggressorSide::Buyer
2959        );
2960        assert_eq!(
2961            parse_aggressor_side(&Some(OKXSide::Sell)),
2962            AggressorSide::Seller
2963        );
2964        assert_eq!(parse_aggressor_side(&None), AggressorSide::NoAggressor);
2965    }
2966
2967    #[rstest]
2968    fn test_parse_execution_type() {
2969        assert_eq!(
2970            parse_execution_type(&Some(OKXExecType::Maker)),
2971            LiquiditySide::Maker
2972        );
2973        assert_eq!(
2974            parse_execution_type(&Some(OKXExecType::Taker)),
2975            LiquiditySide::Taker
2976        );
2977        assert_eq!(parse_execution_type(&None), LiquiditySide::NoLiquiditySide);
2978    }
2979
2980    #[rstest]
2981    fn test_parse_position_side() {
2982        assert_eq!(parse_position_side(Some(100)), PositionSide::Long);
2983        assert_eq!(parse_position_side(Some(-100)), PositionSide::Short);
2984        assert_eq!(parse_position_side(Some(0)), PositionSide::Flat);
2985        assert_eq!(parse_position_side(None), PositionSide::Flat);
2986    }
2987
2988    #[rstest]
2989    fn test_parse_client_order_id() {
2990        let valid_id = "client_order_123";
2991        let result = parse_client_order_id(valid_id);
2992        assert_eq!(result, Some(ClientOrderId::new(valid_id)));
2993
2994        let empty_id = "";
2995        let result_empty = parse_client_order_id(empty_id);
2996        assert_eq!(result_empty, None);
2997    }
2998
2999    #[rstest]
3000    fn test_deserialize_empty_string_as_none() {
3001        let json_with_empty = r#""""#;
3002        let result: Option<String> = serde_json::from_str(json_with_empty).unwrap();
3003        let processed = result.filter(|s| !s.is_empty());
3004        assert_eq!(processed, None);
3005
3006        let json_with_value = r#""test_value""#;
3007        let result: Option<String> = serde_json::from_str(json_with_value).unwrap();
3008        let processed = result.filter(|s| !s.is_empty());
3009        assert_eq!(processed, Some("test_value".to_string()));
3010    }
3011
3012    #[rstest]
3013    fn test_deserialize_string_to_u64() {
3014        use serde::Deserialize;
3015
3016        #[derive(Deserialize)]
3017        struct TestStruct {
3018            #[serde(deserialize_with = "deserialize_string_to_u64")]
3019            value: u64,
3020        }
3021
3022        let json_value = r#"{"value": "12345"}"#;
3023        let result: TestStruct = serde_json::from_str(json_value).unwrap();
3024        assert_eq!(result.value, 12345);
3025
3026        let json_empty = r#"{"value": ""}"#;
3027        let result_empty: TestStruct = serde_json::from_str(json_empty).unwrap();
3028        assert_eq!(result_empty.value, 0);
3029    }
3030
3031    #[rstest]
3032    fn test_fill_report_parsing() {
3033        // Create a mock transaction detail for testing
3034        let transaction_detail = crate::http::models::OKXTransactionDetail {
3035            inst_type: OKXInstrumentType::Spot,
3036            inst_id: Ustr::from("BTC-USDT"),
3037            trade_id: Ustr::from("12345"),
3038            ord_id: Ustr::from("67890"),
3039            cl_ord_id: Ustr::from("client_123"),
3040            bill_id: Ustr::from("bill_456"),
3041            fill_px: "42219.5".to_string(),
3042            fill_sz: "0.001".to_string(),
3043            side: OKXSide::Buy,
3044            exec_type: OKXExecType::Taker,
3045            fee_ccy: "USDT".to_string(),
3046            fee: Some("0.042".to_string()),
3047            ts: 1625097600000,
3048        };
3049
3050        let account_id = AccountId::new("OKX-001");
3051        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3052        let fill_report = parse_fill_report(
3053            transaction_detail,
3054            account_id,
3055            instrument_id,
3056            2,
3057            8,
3058            UnixNanos::default(),
3059        )
3060        .unwrap();
3061
3062        assert_eq!(fill_report.account_id, account_id);
3063        assert_eq!(fill_report.instrument_id, instrument_id);
3064        assert_eq!(fill_report.trade_id, TradeId::new("12345"));
3065        assert_eq!(fill_report.venue_order_id, VenueOrderId::new("67890"));
3066        assert_eq!(fill_report.order_side, OrderSide::Buy);
3067        assert_eq!(fill_report.last_px, Price::from("42219.50"));
3068        assert_eq!(fill_report.last_qty, Quantity::from("0.00100000"));
3069        assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
3070    }
3071
3072    #[rstest]
3073    fn test_bar_type_identity_preserved_through_parse() {
3074        use std::str::FromStr;
3075
3076        use crate::http::models::OKXCandlestick;
3077
3078        // Create a BarType
3079        let bar_type = BarType::from_str("ETH-USDT-SWAP.OKX-1-MINUTE-LAST-EXTERNAL").unwrap();
3080
3081        // Create sample candlestick data
3082        let raw_candlestick = OKXCandlestick(
3083            "1721807460000".to_string(), // timestamp
3084            "3177.9".to_string(),        // open
3085            "3177.9".to_string(),        // high
3086            "3177.7".to_string(),        // low
3087            "3177.8".to_string(),        // close
3088            "18.603".to_string(),        // volume
3089            "59054.8231".to_string(),    // turnover
3090            "18.603".to_string(),        // base_volume
3091            "1".to_string(),             // count
3092        );
3093
3094        // Parse the candlestick
3095        let bar =
3096            parse_candlestick(&raw_candlestick, bar_type, 1, 3, UnixNanos::default()).unwrap();
3097
3098        // Verify that the BarType is preserved exactly
3099        assert_eq!(
3100            bar.bar_type, bar_type,
3101            "BarType must be preserved exactly through parsing"
3102        );
3103    }
3104
3105    #[rstest]
3106    fn test_deserialize_vip_level_all_formats() {
3107        use serde::Deserialize;
3108        use serde_json;
3109
3110        #[derive(Deserialize)]
3111        struct TestFeeRate {
3112            #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3113            level: OKXVipLevel,
3114        }
3115
3116        // Test VIP prefix format
3117        let json = r#"{"level":"VIP4"}"#;
3118        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3119        assert_eq!(result.level, OKXVipLevel::Vip4);
3120
3121        let json = r#"{"level":"VIP5"}"#;
3122        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3123        assert_eq!(result.level, OKXVipLevel::Vip5);
3124
3125        // Test Lv prefix format
3126        let json = r#"{"level":"Lv1"}"#;
3127        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3128        assert_eq!(result.level, OKXVipLevel::Vip1);
3129
3130        let json = r#"{"level":"Lv0"}"#;
3131        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3132        assert_eq!(result.level, OKXVipLevel::Vip0);
3133
3134        let json = r#"{"level":"Lv9"}"#;
3135        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3136        assert_eq!(result.level, OKXVipLevel::Vip9);
3137    }
3138
3139    #[rstest]
3140    fn test_deserialize_vip_level_empty_string() {
3141        use serde::Deserialize;
3142        use serde_json;
3143
3144        #[derive(Deserialize)]
3145        struct TestFeeRate {
3146            #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3147            level: OKXVipLevel,
3148        }
3149
3150        // Empty string should default to VIP0
3151        let json = r#"{"level":""}"#;
3152        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3153        assert_eq!(result.level, OKXVipLevel::Vip0);
3154    }
3155
3156    #[rstest]
3157    fn test_deserialize_vip_level_without_prefix() {
3158        use serde::Deserialize;
3159        use serde_json;
3160
3161        #[derive(Deserialize)]
3162        struct TestFeeRate {
3163            #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3164            level: OKXVipLevel,
3165        }
3166
3167        let json = r#"{"level":"5"}"#;
3168        let result: TestFeeRate = serde_json::from_str(json).unwrap();
3169        assert_eq!(result.level, OKXVipLevel::Vip5);
3170    }
3171
3172    #[rstest]
3173    fn test_parse_position_status_report_net_mode_long() {
3174        // Test Net mode: positive quantity = Long position
3175        let position = OKXPosition {
3176            inst_id: Ustr::from("BTC-USDT-SWAP"),
3177            inst_type: OKXInstrumentType::Swap,
3178            mgn_mode: OKXMarginMode::Cross,
3179            pos_id: Some(Ustr::from("12345")),
3180            pos_side: OKXPositionSide::Net, // Net mode
3181            pos: "1.5".to_string(),         // Positive = Long
3182            base_bal: "1.5".to_string(),
3183            ccy: "BTC".to_string(),
3184            fee: "0.01".to_string(),
3185            lever: "10.0".to_string(),
3186            last: "50000".to_string(),
3187            mark_px: "50000".to_string(),
3188            liq_px: "45000".to_string(),
3189            mmr: "0.1".to_string(),
3190            interest: "0".to_string(),
3191            trade_id: Ustr::from("111"),
3192            notional_usd: "75000".to_string(),
3193            avg_px: "50000".to_string(),
3194            upl: "0".to_string(),
3195            upl_ratio: "0".to_string(),
3196            u_time: 1622559930237,
3197            margin: "0.5".to_string(),
3198            mgn_ratio: "0.01".to_string(),
3199            adl: "0".to_string(),
3200            c_time: "1622559930237".to_string(),
3201            realized_pnl: "0".to_string(),
3202            upl_last_px: "0".to_string(),
3203            upl_ratio_last_px: "0".to_string(),
3204            avail_pos: "1.5".to_string(),
3205            be_px: "0".to_string(),
3206            funding_fee: "0".to_string(),
3207            idx_px: "0".to_string(),
3208            liq_penalty: "0".to_string(),
3209            opt_val: "0".to_string(),
3210            pending_close_ord_liab_val: "0".to_string(),
3211            pnl: "0".to_string(),
3212            pos_ccy: "BTC".to_string(),
3213            quote_bal: "75000".to_string(),
3214            quote_borrowed: "0".to_string(),
3215            quote_interest: "0".to_string(),
3216            spot_in_use_amt: "0".to_string(),
3217            spot_in_use_ccy: "BTC".to_string(),
3218            usd_px: "50000".to_string(),
3219        };
3220
3221        let account_id = AccountId::new("OKX-001");
3222        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3223        let report = parse_position_status_report(
3224            position,
3225            account_id,
3226            instrument_id,
3227            8,
3228            UnixNanos::default(),
3229        )
3230        .unwrap();
3231
3232        assert_eq!(report.account_id, account_id);
3233        assert_eq!(report.instrument_id, instrument_id);
3234        assert_eq!(report.position_side, PositionSide::Long.as_specified());
3235        assert_eq!(report.quantity, Quantity::from("1.5"));
3236        // Net mode: venue_position_id is None (signals NETTING OMS)
3237        assert_eq!(report.venue_position_id, None);
3238    }
3239
3240    #[rstest]
3241    fn test_parse_position_status_report_net_mode_short() {
3242        // Test Net mode: negative quantity = Short position
3243        let position = OKXPosition {
3244            inst_id: Ustr::from("BTC-USDT-SWAP"),
3245            inst_type: OKXInstrumentType::Swap,
3246            mgn_mode: OKXMarginMode::Isolated,
3247            pos_id: Some(Ustr::from("67890")),
3248            pos_side: OKXPositionSide::Net, // Net mode
3249            pos: "-2.3".to_string(),        // Negative = Short
3250            base_bal: "2.3".to_string(),
3251            ccy: "BTC".to_string(),
3252            fee: "0.02".to_string(),
3253            lever: "5.0".to_string(),
3254            last: "50000".to_string(),
3255            mark_px: "50000".to_string(),
3256            liq_px: "55000".to_string(),
3257            mmr: "0.2".to_string(),
3258            interest: "0".to_string(),
3259            trade_id: Ustr::from("222"),
3260            notional_usd: "115000".to_string(),
3261            avg_px: "50000".to_string(),
3262            upl: "0".to_string(),
3263            upl_ratio: "0".to_string(),
3264            u_time: 1622559930237,
3265            margin: "1.0".to_string(),
3266            mgn_ratio: "0.02".to_string(),
3267            adl: "0".to_string(),
3268            c_time: "1622559930237".to_string(),
3269            realized_pnl: "0".to_string(),
3270            upl_last_px: "0".to_string(),
3271            upl_ratio_last_px: "0".to_string(),
3272            avail_pos: "2.3".to_string(),
3273            be_px: "0".to_string(),
3274            funding_fee: "0".to_string(),
3275            idx_px: "0".to_string(),
3276            liq_penalty: "0".to_string(),
3277            opt_val: "0".to_string(),
3278            pending_close_ord_liab_val: "0".to_string(),
3279            pnl: "0".to_string(),
3280            pos_ccy: "BTC".to_string(),
3281            quote_bal: "115000".to_string(),
3282            quote_borrowed: "0".to_string(),
3283            quote_interest: "0".to_string(),
3284            spot_in_use_amt: "0".to_string(),
3285            spot_in_use_ccy: "BTC".to_string(),
3286            usd_px: "50000".to_string(),
3287        };
3288
3289        let account_id = AccountId::new("OKX-001");
3290        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3291        let report = parse_position_status_report(
3292            position,
3293            account_id,
3294            instrument_id,
3295            8,
3296            UnixNanos::default(),
3297        )
3298        .unwrap();
3299
3300        assert_eq!(report.account_id, account_id);
3301        assert_eq!(report.instrument_id, instrument_id);
3302        assert_eq!(report.position_side, PositionSide::Short.as_specified());
3303        assert_eq!(report.quantity, Quantity::from("2.3")); // Absolute value
3304        // Net mode: venue_position_id is None (signals NETTING OMS)
3305        assert_eq!(report.venue_position_id, None);
3306    }
3307
3308    #[rstest]
3309    fn test_parse_position_status_report_net_mode_flat() {
3310        // Test Net mode: zero quantity = Flat position
3311        let position = OKXPosition {
3312            inst_id: Ustr::from("ETH-USDT-SWAP"),
3313            inst_type: OKXInstrumentType::Swap,
3314            mgn_mode: OKXMarginMode::Cross,
3315            pos_id: Some(Ustr::from("99999")),
3316            pos_side: OKXPositionSide::Net, // Net mode
3317            pos: "0".to_string(),           // Zero = Flat
3318            base_bal: "0".to_string(),
3319            ccy: "ETH".to_string(),
3320            fee: "0".to_string(),
3321            lever: "10.0".to_string(),
3322            last: "3000".to_string(),
3323            mark_px: "3000".to_string(),
3324            liq_px: "0".to_string(),
3325            mmr: "0".to_string(),
3326            interest: "0".to_string(),
3327            trade_id: Ustr::from("333"),
3328            notional_usd: "0".to_string(),
3329            avg_px: "".to_string(),
3330            upl: "0".to_string(),
3331            upl_ratio: "0".to_string(),
3332            u_time: 1622559930237,
3333            margin: "0".to_string(),
3334            mgn_ratio: "0".to_string(),
3335            adl: "0".to_string(),
3336            c_time: "1622559930237".to_string(),
3337            realized_pnl: "0".to_string(),
3338            upl_last_px: "0".to_string(),
3339            upl_ratio_last_px: "0".to_string(),
3340            avail_pos: "0".to_string(),
3341            be_px: "0".to_string(),
3342            funding_fee: "0".to_string(),
3343            idx_px: "0".to_string(),
3344            liq_penalty: "0".to_string(),
3345            opt_val: "0".to_string(),
3346            pending_close_ord_liab_val: "0".to_string(),
3347            pnl: "0".to_string(),
3348            pos_ccy: "ETH".to_string(),
3349            quote_bal: "0".to_string(),
3350            quote_borrowed: "0".to_string(),
3351            quote_interest: "0".to_string(),
3352            spot_in_use_amt: "0".to_string(),
3353            spot_in_use_ccy: "ETH".to_string(),
3354            usd_px: "3000".to_string(),
3355        };
3356
3357        let account_id = AccountId::new("OKX-001");
3358        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
3359        let report = parse_position_status_report(
3360            position,
3361            account_id,
3362            instrument_id,
3363            8,
3364            UnixNanos::default(),
3365        )
3366        .unwrap();
3367
3368        assert_eq!(report.account_id, account_id);
3369        assert_eq!(report.instrument_id, instrument_id);
3370        assert_eq!(report.position_side, PositionSide::Flat.as_specified());
3371        assert_eq!(report.quantity, Quantity::from("0"));
3372        // Net mode: venue_position_id is None (signals NETTING OMS)
3373        assert_eq!(report.venue_position_id, None);
3374    }
3375
3376    #[rstest]
3377    fn test_parse_position_status_report_long_short_mode_long() {
3378        // Test Long/Short mode: posSide="long" with positive quantity
3379        let position = OKXPosition {
3380            inst_id: Ustr::from("BTC-USDT-SWAP"),
3381            inst_type: OKXInstrumentType::Swap,
3382            mgn_mode: OKXMarginMode::Cross,
3383            pos_id: Some(Ustr::from("11111")),
3384            pos_side: OKXPositionSide::Long, // Long/Short mode - Long leg
3385            pos: "3.2".to_string(),          // Positive quantity (always positive in this mode)
3386            base_bal: "3.2".to_string(),
3387            ccy: "BTC".to_string(),
3388            fee: "0.01".to_string(),
3389            lever: "10.0".to_string(),
3390            last: "50000".to_string(),
3391            mark_px: "50000".to_string(),
3392            liq_px: "45000".to_string(),
3393            mmr: "0.1".to_string(),
3394            interest: "0".to_string(),
3395            trade_id: Ustr::from("444"),
3396            notional_usd: "160000".to_string(),
3397            avg_px: "50000".to_string(),
3398            upl: "0".to_string(),
3399            upl_ratio: "0".to_string(),
3400            u_time: 1622559930237,
3401            margin: "1.6".to_string(),
3402            mgn_ratio: "0.01".to_string(),
3403            adl: "0".to_string(),
3404            c_time: "1622559930237".to_string(),
3405            realized_pnl: "0".to_string(),
3406            upl_last_px: "0".to_string(),
3407            upl_ratio_last_px: "0".to_string(),
3408            avail_pos: "3.2".to_string(),
3409            be_px: "0".to_string(),
3410            funding_fee: "0".to_string(),
3411            idx_px: "0".to_string(),
3412            liq_penalty: "0".to_string(),
3413            opt_val: "0".to_string(),
3414            pending_close_ord_liab_val: "0".to_string(),
3415            pnl: "0".to_string(),
3416            pos_ccy: "BTC".to_string(),
3417            quote_bal: "160000".to_string(),
3418            quote_borrowed: "0".to_string(),
3419            quote_interest: "0".to_string(),
3420            spot_in_use_amt: "0".to_string(),
3421            spot_in_use_ccy: "BTC".to_string(),
3422            usd_px: "50000".to_string(),
3423        };
3424
3425        let account_id = AccountId::new("OKX-001");
3426        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3427        let report = parse_position_status_report(
3428            position,
3429            account_id,
3430            instrument_id,
3431            8,
3432            UnixNanos::default(),
3433        )
3434        .unwrap();
3435
3436        assert_eq!(report.account_id, account_id);
3437        assert_eq!(report.instrument_id, instrument_id);
3438        assert_eq!(report.position_side, PositionSide::Long.as_specified());
3439        assert_eq!(report.quantity, Quantity::from("3.2"));
3440        // Long/Short mode - Long leg: "-LONG" suffix
3441        assert_eq!(
3442            report.venue_position_id,
3443            Some(PositionId::new("11111-LONG"))
3444        );
3445    }
3446
3447    #[rstest]
3448    fn test_parse_position_status_report_long_short_mode_short() {
3449        // Test Long/Short mode: posSide="short" with positive quantity
3450        // This is the critical test - positive quantity but SHORT side!
3451        let position = OKXPosition {
3452            inst_id: Ustr::from("BTC-USDT-SWAP"),
3453            inst_type: OKXInstrumentType::Swap,
3454            mgn_mode: OKXMarginMode::Cross,
3455            pos_id: Some(Ustr::from("22222")),
3456            pos_side: OKXPositionSide::Short, // Long/Short mode - Short leg
3457            pos: "1.8".to_string(),           // Positive quantity (always positive in this mode)
3458            base_bal: "1.8".to_string(),
3459            ccy: "BTC".to_string(),
3460            fee: "0.02".to_string(),
3461            lever: "10.0".to_string(),
3462            last: "50000".to_string(),
3463            mark_px: "50000".to_string(),
3464            liq_px: "55000".to_string(),
3465            mmr: "0.2".to_string(),
3466            interest: "0".to_string(),
3467            trade_id: Ustr::from("555"),
3468            notional_usd: "90000".to_string(),
3469            avg_px: "50000".to_string(),
3470            upl: "0".to_string(),
3471            upl_ratio: "0".to_string(),
3472            u_time: 1622559930237,
3473            margin: "0.9".to_string(),
3474            mgn_ratio: "0.02".to_string(),
3475            adl: "0".to_string(),
3476            c_time: "1622559930237".to_string(),
3477            realized_pnl: "0".to_string(),
3478            upl_last_px: "0".to_string(),
3479            upl_ratio_last_px: "0".to_string(),
3480            avail_pos: "1.8".to_string(),
3481            be_px: "0".to_string(),
3482            funding_fee: "0".to_string(),
3483            idx_px: "0".to_string(),
3484            liq_penalty: "0".to_string(),
3485            opt_val: "0".to_string(),
3486            pending_close_ord_liab_val: "0".to_string(),
3487            pnl: "0".to_string(),
3488            pos_ccy: "BTC".to_string(),
3489            quote_bal: "90000".to_string(),
3490            quote_borrowed: "0".to_string(),
3491            quote_interest: "0".to_string(),
3492            spot_in_use_amt: "0".to_string(),
3493            spot_in_use_ccy: "BTC".to_string(),
3494            usd_px: "50000".to_string(),
3495        };
3496
3497        let account_id = AccountId::new("OKX-001");
3498        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3499        let report = parse_position_status_report(
3500            position,
3501            account_id,
3502            instrument_id,
3503            8,
3504            UnixNanos::default(),
3505        )
3506        .unwrap();
3507
3508        assert_eq!(report.account_id, account_id);
3509        assert_eq!(report.instrument_id, instrument_id);
3510        // This is the critical assertion: positive quantity but SHORT side
3511        assert_eq!(report.position_side, PositionSide::Short.as_specified());
3512        assert_eq!(report.quantity, Quantity::from("1.8"));
3513        // Long/Short mode - Short leg: "-SHORT" suffix
3514        assert_eq!(
3515            report.venue_position_id,
3516            Some(PositionId::new("22222-SHORT"))
3517        );
3518    }
3519}