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