Skip to main content

nautilus_okx/common/
parse.rs

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