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