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 / 60_000_000_000)
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
879            .algo_id
880            .as_ref()
881            .filter(|value| !value.as_str().is_empty())
882        {
883            VenueOrderId::new(algo_id.as_str())
884        } else if !order.cl_ord_id.is_empty() {
885            VenueOrderId::new(order.cl_ord_id.as_str())
886        } else {
887            let synthetic_id = format!("{}:{}", account_id, order.c_time);
888            VenueOrderId::new(&synthetic_id)
889        }
890    } else {
891        VenueOrderId::new(order.ord_id.as_str())
892    };
893
894    let ts_accepted = parse_millisecond_timestamp(order.c_time);
895    let ts_last = UnixNanos::from(order.u_time * NANOSECONDS_IN_MILLISECOND);
896
897    let mut report = OrderStatusReport::new(
898        account_id,
899        instrument_id,
900        client_order_id,
901        venue_order_id,
902        order_side.into(),
903        order_type,
904        time_in_force,
905        order_status,
906        quantity,
907        filled_qty,
908        ts_accepted,
909        ts_last,
910        ts_init,
911        None,
912    );
913
914    // Optional fields
915    if !order.px.is_empty()
916        && let Ok(decimal) = Decimal::from_str(&order.px)
917        && let Ok(price) = Price::from_decimal_dp(decimal, price_precision)
918    {
919        report = report.with_price(price);
920    }
921
922    if !order.avg_px.is_empty()
923        && let Ok(decimal) = Decimal::from_str(&order.avg_px)
924    {
925        report.avg_px = Some(decimal);
926    }
927
928    if matches!(order.ord_type, OKXOrderType::PostOnly | OKXOrderType::Rpi) {
929        report = report.with_post_only(true);
930    }
931
932    if order.reduce_only == "true" {
933        report = report.with_reduce_only(true);
934    }
935
936    if !linked_ids.is_empty() {
937        report = report.with_linked_order_ids(linked_ids);
938    }
939
940    Ok(report)
941}
942
943/// Parses spot margin position from OKX balance detail.
944///
945/// Spot margin positions appear in `/api/v5/account/balance` as balance sheet items
946/// rather than in `/api/v5/account/positions`. This function converts balance details
947/// with non-zero liability (`liab`) or spot in use amount (`spotInUseAmt`) into position reports.
948///
949/// # Position Determination
950///
951/// - `liab` > 0 and `spotInUseAmt` < 0 → Short position (borrowed and sold)
952/// - `liab` > 0 and `spotInUseAmt` > 0 → Long position (borrowed to buy)
953/// - `liab` == 0 → No margin position (regular spot balance)
954///
955/// # Errors
956///
957/// Returns an error if numeric fields cannot be parsed.
958pub fn parse_spot_margin_position_from_balance(
959    balance: &OKXBalanceDetail,
960    account_id: AccountId,
961    instrument_id: InstrumentId,
962    size_precision: u8,
963    ts_init: UnixNanos,
964) -> anyhow::Result<Option<PositionStatusReport>> {
965    // OKX returns empty strings for zero values, normalize to "0" before parsing
966    let liab_str = if balance.liab.trim().is_empty() {
967        "0"
968    } else {
969        balance.liab.trim()
970    };
971    let spot_in_use_str = if balance.spot_in_use_amt.trim().is_empty() {
972        "0"
973    } else {
974        balance.spot_in_use_amt.trim()
975    };
976
977    let liab_dec = Decimal::from_str(liab_str)
978        .map_err(|e| anyhow::anyhow!("Failed to parse liab '{liab_str}': {e}"))?;
979    let spot_in_use_dec = Decimal::from_str(spot_in_use_str)
980        .map_err(|e| anyhow::anyhow!("Failed to parse spotInUseAmt '{spot_in_use_str}': {e}"))?;
981
982    // Skip if no margin position (no liability and no spot in use)
983    if liab_dec.is_zero() && spot_in_use_dec.is_zero() {
984        return Ok(None);
985    }
986
987    // Check if spotInUseAmt is zero first
988    if spot_in_use_dec.is_zero() {
989        // No position if spotInUseAmt is zero (regardless of liability)
990        return Ok(None);
991    }
992
993    // Position side based on spotInUseAmt sign
994    let (position_side, quantity_dec) = if spot_in_use_dec.is_sign_negative() {
995        // Negative spotInUseAmt = sold (short position)
996        (PositionSide::Short, spot_in_use_dec.abs())
997    } else {
998        // Positive spotInUseAmt = bought (long position)
999        (PositionSide::Long, spot_in_use_dec)
1000    };
1001
1002    let quantity = Quantity::from_decimal_dp(quantity_dec, size_precision)
1003        .map_err(|e| anyhow::anyhow!("Failed to create quantity from {quantity_dec}: {e}"))?;
1004
1005    let ts_last = parse_millisecond_timestamp(balance.u_time);
1006
1007    Ok(Some(PositionStatusReport::new(
1008        account_id,
1009        instrument_id,
1010        position_side,
1011        quantity,
1012        ts_last,
1013        ts_init,
1014        None, // report_id
1015        None, // venue_position_id is None for net mode margin positions
1016        None, // avg_px_open not available from balance
1017    )))
1018}
1019
1020/// Parses an OKX position into a Nautilus [`PositionStatusReport`].
1021///
1022/// # Position Mode Handling
1023///
1024/// OKX returns position data differently based on the account's position mode:
1025///
1026/// - **Net mode** (`posSide="net"`): The `pos` field uses signed quantities where
1027///   positive = long, negative = short. Position side is derived from the sign.
1028///
1029/// - **Long/Short mode** (`posSide="long"` or `"short"`): The `pos` field is always
1030///   positive regardless of side. Position side is determined from the `posSide` field.
1031///   Position IDs are suffixed with `-LONG` or `-SHORT` for uniqueness.
1032///
1033/// See: <https://www.okx.com/docs-v5/en/#trading-account-rest-api-get-positions>
1034///
1035/// # Errors
1036///
1037/// Returns an error if any numeric fields cannot be parsed into their target types.
1038pub fn parse_position_status_report(
1039    position: &OKXPosition,
1040    account_id: AccountId,
1041    instrument_id: InstrumentId,
1042    size_precision: u8,
1043    ts_init: UnixNanos,
1044) -> anyhow::Result<PositionStatusReport> {
1045    let pos_dec = Decimal::from_str(&position.pos).map_err(|e| {
1046        anyhow::anyhow!(
1047            "Failed to parse position quantity '{}' for instrument {}: {e:?}",
1048            position.pos,
1049            instrument_id
1050        )
1051    })?;
1052
1053    // For SPOT/MARGIN: determine position side and quantity based on pos_ccy
1054    // - If pos_ccy = base currency: LONG position, pos is in base currency
1055    // - If pos_ccy = quote currency: SHORT position, pos is in quote currency (needs conversion)
1056    // - If pos_ccy is empty: FLAT position (no position)
1057    let (position_side, quantity_dec) = if position.inst_type == OKXInstrumentType::Spot
1058        || position.inst_type == OKXInstrumentType::Margin
1059    {
1060        // Extract base and quote currencies from instrument symbol
1061        let (base_ccy, quote_ccy) = parse_base_quote_from_symbol(instrument_id.symbol.as_str())?;
1062
1063        let pos_ccy = position.pos_ccy.as_str();
1064
1065        if pos_ccy.is_empty() || pos_dec.is_zero() {
1066            // Flat position: no position or zero quantity
1067            (PositionSide::Flat, Decimal::ZERO)
1068        } else if pos_ccy == base_ccy {
1069            // Long position: pos_ccy is base currency, pos is already in base
1070            (PositionSide::Long, pos_dec.abs())
1071        } else if pos_ccy == quote_ccy {
1072            // Short position: pos_ccy is quote currency, need to convert to base
1073            // Use Decimal arithmetic to avoid floating-point precision errors
1074            let avg_px_str = if position.avg_px.is_empty() {
1075                // If no avg_px, use mark_px as fallback
1076                &position.mark_px
1077            } else {
1078                &position.avg_px
1079            };
1080            let avg_px_dec = Decimal::from_str(avg_px_str)?;
1081
1082            if avg_px_dec.is_zero() {
1083                anyhow::bail!(
1084                    "Cannot convert SHORT position from quote to base: avg_px is zero for {instrument_id}"
1085                );
1086            }
1087
1088            let quantity_dec = (pos_dec.abs() / avg_px_dec).round_dp(size_precision as u32);
1089            (PositionSide::Short, quantity_dec)
1090        } else {
1091            anyhow::bail!(
1092                "Unknown position currency '{pos_ccy}' for instrument {instrument_id} (base={base_ccy}, quote={quote_ccy})"
1093            );
1094        }
1095    } else {
1096        // For SWAP/FUTURES/OPTION: use existing logic
1097        // Determine position side based on OKX position mode:
1098        // - Net mode: posSide="net", uses signed quantities (positive=long, negative=short)
1099        // - Long/Short mode: posSide="long"/"short", quantities are always positive, side from field
1100        let side = match position.pos_side {
1101            OKXPositionSide::Net | OKXPositionSide::None => {
1102                // Net mode: derive side from signed quantity
1103                if pos_dec.is_sign_positive() && !pos_dec.is_zero() {
1104                    PositionSide::Long
1105                } else if pos_dec.is_sign_negative() {
1106                    PositionSide::Short
1107                } else {
1108                    PositionSide::Flat
1109                }
1110            }
1111            OKXPositionSide::Long => {
1112                // Long/Short mode: trust the pos_side field
1113                PositionSide::Long
1114            }
1115            OKXPositionSide::Short => {
1116                // Long/Short mode: trust the pos_side field
1117                PositionSide::Short
1118            }
1119        };
1120        (side, pos_dec.abs())
1121    };
1122
1123    // Convert to absolute quantity (positions are always positive in Nautilus)
1124    let quantity = Quantity::from_decimal_dp(quantity_dec, size_precision)?;
1125
1126    // Generate venue position ID only for Long/Short mode (hedging)
1127    // In Net mode, venue_position_id must be None to signal NETTING OMS behavior
1128    let venue_position_id = match position.pos_side {
1129        OKXPositionSide::Long => {
1130            // Long/Short mode - Long leg: append "-LONG"
1131            position
1132                .pos_id
1133                .map(|pos_id| PositionId::new(format!("{pos_id}-LONG")))
1134        }
1135        OKXPositionSide::Short => {
1136            // Long/Short mode - Short leg: append "-SHORT"
1137            position
1138                .pos_id
1139                .map(|pos_id| PositionId::new(format!("{pos_id}-SHORT")))
1140        }
1141        OKXPositionSide::Net | OKXPositionSide::None => {
1142            // Net mode: None signals NETTING OMS (Nautilus uses its own position IDs)
1143            None
1144        }
1145    };
1146
1147    let avg_px_open = if position.avg_px.is_empty() {
1148        None
1149    } else {
1150        Some(Decimal::from_str(&position.avg_px)?)
1151    };
1152    let ts_last = parse_millisecond_timestamp(position.u_time);
1153
1154    Ok(PositionStatusReport::new(
1155        account_id,
1156        instrument_id,
1157        position_side,
1158        quantity,
1159        ts_last,
1160        ts_init,
1161        None, // Will generate a UUID4
1162        venue_position_id,
1163        avg_px_open,
1164    ))
1165}
1166
1167/// Parses an OKX transaction detail into a Nautilus `FillReport`.
1168///
1169/// # Errors
1170///
1171/// Returns an error if the OKX transaction detail cannot be parsed.
1172pub fn parse_fill_report(
1173    detail: &OKXTransactionDetail,
1174    account_id: AccountId,
1175    instrument_id: InstrumentId,
1176    price_precision: u8,
1177    size_precision: u8,
1178    ts_init: UnixNanos,
1179) -> anyhow::Result<FillReport> {
1180    let client_order_id = if detail.cl_ord_id.is_empty() {
1181        None
1182    } else {
1183        Some(ClientOrderId::new(detail.cl_ord_id))
1184    };
1185    let venue_order_id = VenueOrderId::new(detail.ord_id);
1186    let trade_id = TradeId::new(detail.trade_id);
1187    let order_side = OrderSide::from(detail.side);
1188    let last_px = parse_price(&detail.fill_px, price_precision)?;
1189    let last_qty = parse_quantity(&detail.fill_sz, size_precision)?;
1190    let fee_dec = required_fee_amount(detail.fee.as_deref()).with_context(|| {
1191        format!("missing or invalid fee for fill report instrument_id={instrument_id}")
1192    })?;
1193    let fee_currency = parse_fee_currency(&detail.fee_ccy, fee_dec, || {
1194        format!("fill report for instrument_id={instrument_id}")
1195    });
1196    let commission = Money::from_decimal(-fee_dec, fee_currency)?;
1197    let liquidity_side: LiquiditySide = detail.exec_type.into();
1198    let ts_event = parse_millisecond_timestamp(detail.ts);
1199
1200    Ok(FillReport::new(
1201        account_id,
1202        instrument_id,
1203        venue_order_id,
1204        trade_id,
1205        order_side,
1206        last_qty,
1207        last_px,
1208        commission,
1209        liquidity_side,
1210        client_order_id,
1211        None, // venue_position_id not provided by OKX fills
1212        ts_event,
1213        ts_init,
1214        None, // Will generate a new UUID4
1215    ))
1216}
1217
1218/// Parses an OKX spread order record into a Nautilus [`OrderStatusReport`].
1219///
1220/// # Errors
1221///
1222/// Returns an error if quantities or prices cannot be parsed.
1223pub fn parse_spread_order_status_report(
1224    order: &OKXSpreadOrder,
1225    account_id: AccountId,
1226    instrument_id: InstrumentId,
1227    price_precision: u8,
1228    size_precision: u8,
1229    ts_init: UnixNanos,
1230) -> anyhow::Result<OrderStatusReport> {
1231    let order_type = determine_order_type(order.ord_type, &order.px)?;
1232    let quantity = parse_quantity(&order.sz, size_precision)?;
1233    let filled_qty = parse_quantity(&order.acc_fill_sz, size_precision)?;
1234    let order_side = OrderSide::from(order.side);
1235    let order_status: OrderStatus = order
1236        .state
1237        .try_into()
1238        .map_err(|e| anyhow::anyhow!("Unsupported OKX order status: {e}"))?;
1239    let time_in_force = match order.ord_type {
1240        OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
1241        OKXOrderType::Fok | OKXOrderType::OpFok => TimeInForce::Fok,
1242        _ => TimeInForce::Gtc,
1243    };
1244    let client_order_id = if order.cl_ord_id.is_empty() {
1245        None
1246    } else {
1247        Some(ClientOrderId::new(order.cl_ord_id.as_str()))
1248    };
1249    let venue_order_id = if order.ord_id.is_empty() {
1250        VenueOrderId::new(order.cl_ord_id.as_str())
1251    } else {
1252        VenueOrderId::new(order.ord_id.as_str())
1253    };
1254    let ts_accepted = order.c_time.map_or(ts_init, parse_millisecond_timestamp);
1255    let ts_last = order
1256        .u_time
1257        .or(order.c_time)
1258        .map_or(ts_accepted, parse_millisecond_timestamp);
1259
1260    let mut report = OrderStatusReport::new(
1261        account_id,
1262        instrument_id,
1263        client_order_id,
1264        venue_order_id,
1265        order_side.into(),
1266        order_type,
1267        time_in_force,
1268        order_status,
1269        quantity,
1270        filled_qty,
1271        ts_accepted,
1272        ts_last,
1273        ts_init,
1274        None,
1275    );
1276
1277    if !order.px.is_empty()
1278        && let Ok(decimal) = Decimal::from_str(&order.px)
1279        && let Ok(price) = Price::from_decimal_dp(decimal, price_precision)
1280    {
1281        report = report.with_price(price);
1282    }
1283
1284    if !order.avg_px.is_empty()
1285        && let Ok(decimal) = Decimal::from_str(&order.avg_px)
1286    {
1287        report.avg_px = Some(decimal);
1288    }
1289
1290    if matches!(order.ord_type, OKXOrderType::PostOnly | OKXOrderType::Rpi) {
1291        report = report.with_post_only(true);
1292    }
1293
1294    Ok(report)
1295}
1296
1297/// Parses an OKX spread trade into a Nautilus [`FillReport`].
1298///
1299/// # Errors
1300///
1301/// Returns an error if the trade quantity, price, or fee cannot be parsed.
1302pub fn parse_spread_fill_report(
1303    detail: &OKXSpreadTrade,
1304    account_id: AccountId,
1305    instrument_id: InstrumentId,
1306    price_precision: u8,
1307    size_precision: u8,
1308    ts_init: UnixNanos,
1309) -> anyhow::Result<FillReport> {
1310    let client_order_id = if detail.cl_ord_id.is_empty() {
1311        None
1312    } else {
1313        Some(ClientOrderId::new(detail.cl_ord_id.as_str()))
1314    };
1315    let venue_order_id = VenueOrderId::new(detail.ord_id.as_str());
1316    let trade_id = TradeId::new(detail.trade_id.as_str());
1317    let order_side = OrderSide::from(detail.side);
1318    let last_px = parse_price(&detail.fill_px, price_precision)?;
1319    let last_qty = parse_quantity(&detail.fill_sz, size_precision)?;
1320    let fee_dec = required_fee_amount(detail.fee.as_deref()).with_context(|| {
1321        format!("missing or invalid fee for spread fill report instrument_id={instrument_id}")
1322    })?;
1323    let fee_currency = parse_fee_currency(&detail.fee_ccy, fee_dec, || {
1324        format!("spread fill report for instrument_id={instrument_id}")
1325    });
1326    let commission = Money::from_decimal(-fee_dec, fee_currency)?;
1327    let liquidity_side: LiquiditySide = detail.exec_type.into();
1328    let ts_event = parse_millisecond_timestamp(detail.ts);
1329
1330    Ok(FillReport::new(
1331        account_id,
1332        instrument_id,
1333        venue_order_id,
1334        trade_id,
1335        order_side,
1336        last_qty,
1337        last_px,
1338        commission,
1339        liquidity_side,
1340        client_order_id,
1341        None,
1342        ts_event,
1343        ts_init,
1344        None,
1345    ))
1346}
1347
1348/// Parses vector messages from OKX WebSocket data.
1349///
1350/// Reduces code duplication by providing a common pattern for deserializing JSON arrays,
1351/// parsing each message, and wrapping results in Nautilus Data enum variants.
1352///
1353/// # Errors
1354///
1355/// Returns an error if the payload is not an array or if individual messages
1356/// cannot be parsed.
1357pub fn parse_message_vec<T, R, F, W>(
1358    data: serde_json::Value,
1359    parser: F,
1360    wrapper: W,
1361) -> anyhow::Result<Vec<Data>>
1362where
1363    T: DeserializeOwned,
1364    F: Fn(&T) -> anyhow::Result<R>,
1365    W: Fn(R) -> Data,
1366{
1367    let messages: Vec<T> =
1368        serde_json::from_value(data).map_err(|e| anyhow::anyhow!("Expected array payload: {e}"))?;
1369
1370    let mut results = Vec::with_capacity(messages.len());
1371
1372    for message in &messages {
1373        let parsed = parser(message)?;
1374        results.push(wrapper(parsed));
1375    }
1376
1377    Ok(results)
1378}
1379
1380/// Converts a Nautilus bar specification into the matching OKX candle channel.
1381///
1382/// # Errors
1383///
1384/// Returns an error if the provided bar specification does not have a matching
1385/// OKX websocket channel.
1386pub fn bar_spec_as_okx_channel(bar_spec: BarSpecification) -> anyhow::Result<OKXWsChannel> {
1387    let channel = match bar_spec {
1388        BAR_SPEC_1_SECOND_LAST => OKXWsChannel::Candle1Second,
1389        BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::Candle1Minute,
1390        BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::Candle3Minute,
1391        BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::Candle5Minute,
1392        BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::Candle15Minute,
1393        BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::Candle30Minute,
1394        BAR_SPEC_1_HOUR_LAST => OKXWsChannel::Candle1Hour,
1395        BAR_SPEC_2_HOUR_LAST => OKXWsChannel::Candle2Hour,
1396        BAR_SPEC_4_HOUR_LAST => OKXWsChannel::Candle4Hour,
1397        BAR_SPEC_6_HOUR_LAST => OKXWsChannel::Candle6Hour,
1398        BAR_SPEC_12_HOUR_LAST => OKXWsChannel::Candle12Hour,
1399        BAR_SPEC_1_DAY_LAST => OKXWsChannel::Candle1Day,
1400        BAR_SPEC_2_DAY_LAST => OKXWsChannel::Candle2Day,
1401        BAR_SPEC_3_DAY_LAST => OKXWsChannel::Candle3Day,
1402        BAR_SPEC_5_DAY_LAST => OKXWsChannel::Candle5Day,
1403        BAR_SPEC_1_WEEK_LAST => OKXWsChannel::Candle1Week,
1404        BAR_SPEC_1_MONTH_LAST => OKXWsChannel::Candle1Month,
1405        BAR_SPEC_3_MONTH_LAST => OKXWsChannel::Candle3Month,
1406        BAR_SPEC_6_MONTH_LAST => OKXWsChannel::Candle6Month,
1407        BAR_SPEC_12_MONTH_LAST => OKXWsChannel::Candle1Year,
1408        _ => anyhow::bail!("Invalid `BarSpecification` for channel, was {bar_spec}"),
1409    };
1410    Ok(channel)
1411}
1412
1413/// Converts Nautilus bar specification to OKX mark price channel.
1414///
1415/// # Errors
1416///
1417/// Returns an error if the bar specification does not map to a mark price
1418/// channel.
1419pub fn bar_spec_as_okx_mark_price_channel(
1420    bar_spec: BarSpecification,
1421) -> anyhow::Result<OKXWsChannel> {
1422    let channel = match bar_spec {
1423        BAR_SPEC_1_SECOND_LAST => OKXWsChannel::MarkPriceCandle1Second,
1424        BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::MarkPriceCandle1Minute,
1425        BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::MarkPriceCandle3Minute,
1426        BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::MarkPriceCandle5Minute,
1427        BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::MarkPriceCandle15Minute,
1428        BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::MarkPriceCandle30Minute,
1429        BAR_SPEC_1_HOUR_LAST => OKXWsChannel::MarkPriceCandle1Hour,
1430        BAR_SPEC_2_HOUR_LAST => OKXWsChannel::MarkPriceCandle2Hour,
1431        BAR_SPEC_4_HOUR_LAST => OKXWsChannel::MarkPriceCandle4Hour,
1432        BAR_SPEC_6_HOUR_LAST => OKXWsChannel::MarkPriceCandle6Hour,
1433        BAR_SPEC_12_HOUR_LAST => OKXWsChannel::MarkPriceCandle12Hour,
1434        BAR_SPEC_1_DAY_LAST => OKXWsChannel::MarkPriceCandle1Day,
1435        BAR_SPEC_2_DAY_LAST => OKXWsChannel::MarkPriceCandle2Day,
1436        BAR_SPEC_3_DAY_LAST => OKXWsChannel::MarkPriceCandle3Day,
1437        BAR_SPEC_5_DAY_LAST => OKXWsChannel::MarkPriceCandle5Day,
1438        BAR_SPEC_1_WEEK_LAST => OKXWsChannel::MarkPriceCandle1Week,
1439        BAR_SPEC_1_MONTH_LAST => OKXWsChannel::MarkPriceCandle1Month,
1440        BAR_SPEC_3_MONTH_LAST => OKXWsChannel::MarkPriceCandle3Month,
1441        _ => anyhow::bail!("Invalid `BarSpecification` for mark price channel, was {bar_spec}"),
1442    };
1443    Ok(channel)
1444}
1445
1446/// Converts Nautilus bar specification to OKX timeframe string.
1447///
1448/// # Errors
1449///
1450/// Returns an error if the bar specification does not have a corresponding
1451/// OKX timeframe value.
1452pub fn bar_spec_as_okx_timeframe(bar_spec: BarSpecification) -> anyhow::Result<&'static str> {
1453    let timeframe = match bar_spec {
1454        BAR_SPEC_1_SECOND_LAST => "1s",
1455        BAR_SPEC_1_MINUTE_LAST => "1m",
1456        BAR_SPEC_3_MINUTE_LAST => "3m",
1457        BAR_SPEC_5_MINUTE_LAST => "5m",
1458        BAR_SPEC_15_MINUTE_LAST => "15m",
1459        BAR_SPEC_30_MINUTE_LAST => "30m",
1460        BAR_SPEC_1_HOUR_LAST => "1H",
1461        BAR_SPEC_2_HOUR_LAST => "2H",
1462        BAR_SPEC_4_HOUR_LAST => "4H",
1463        BAR_SPEC_6_HOUR_LAST => "6H",
1464        BAR_SPEC_12_HOUR_LAST => "12H",
1465        BAR_SPEC_1_DAY_LAST => "1D",
1466        BAR_SPEC_2_DAY_LAST => "2D",
1467        BAR_SPEC_3_DAY_LAST => "3D",
1468        BAR_SPEC_5_DAY_LAST => "5D",
1469        BAR_SPEC_1_WEEK_LAST => "1W",
1470        BAR_SPEC_1_MONTH_LAST => "1M",
1471        BAR_SPEC_3_MONTH_LAST => "3M",
1472        BAR_SPEC_6_MONTH_LAST => "6M",
1473        BAR_SPEC_12_MONTH_LAST => "1Y",
1474        _ => anyhow::bail!("Invalid `BarSpecification` for timeframe, was {bar_spec}"),
1475    };
1476    Ok(timeframe)
1477}
1478
1479/// Converts OKX timeframe string to Nautilus bar specification.
1480///
1481/// # Errors
1482///
1483/// Returns an error if the timeframe string is not recognized.
1484pub fn okx_timeframe_as_bar_spec(timeframe: &str) -> anyhow::Result<BarSpecification> {
1485    let bar_spec = match timeframe {
1486        "1s" => BAR_SPEC_1_SECOND_LAST,
1487        "1m" => BAR_SPEC_1_MINUTE_LAST,
1488        "3m" => BAR_SPEC_3_MINUTE_LAST,
1489        "5m" => BAR_SPEC_5_MINUTE_LAST,
1490        "15m" => BAR_SPEC_15_MINUTE_LAST,
1491        "30m" => BAR_SPEC_30_MINUTE_LAST,
1492        "1H" => BAR_SPEC_1_HOUR_LAST,
1493        "2H" => BAR_SPEC_2_HOUR_LAST,
1494        "4H" => BAR_SPEC_4_HOUR_LAST,
1495        "6H" => BAR_SPEC_6_HOUR_LAST,
1496        "12H" => BAR_SPEC_12_HOUR_LAST,
1497        "1D" => BAR_SPEC_1_DAY_LAST,
1498        "2D" => BAR_SPEC_2_DAY_LAST,
1499        "3D" => BAR_SPEC_3_DAY_LAST,
1500        "5D" => BAR_SPEC_5_DAY_LAST,
1501        "1W" => BAR_SPEC_1_WEEK_LAST,
1502        "1M" => BAR_SPEC_1_MONTH_LAST,
1503        "3M" => BAR_SPEC_3_MONTH_LAST,
1504        "6M" => BAR_SPEC_6_MONTH_LAST,
1505        "1Y" => BAR_SPEC_12_MONTH_LAST,
1506        _ => anyhow::bail!("Invalid timeframe for `BarSpecification`, was {timeframe}"),
1507    };
1508    Ok(bar_spec)
1509}
1510
1511/// Constructs a properly formatted BarType from OKX instrument ID and timeframe string.
1512/// This ensures the BarType uses canonical Nautilus format instead of raw OKX strings.
1513///
1514/// # Errors
1515///
1516/// Returns an error if the timeframe cannot be converted into a
1517/// `BarSpecification`.
1518pub fn okx_bar_type_from_timeframe(
1519    instrument_id: InstrumentId,
1520    timeframe: &str,
1521) -> anyhow::Result<BarType> {
1522    let bar_spec = okx_timeframe_as_bar_spec(timeframe)?;
1523    Ok(BarType::new(
1524        instrument_id,
1525        bar_spec,
1526        AggregationSource::External,
1527    ))
1528}
1529
1530/// Converts OKX WebSocket channel to bar specification if it's a candle channel.
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    (!info.is_empty()).then_some(info)
2457}
2458
2459fn insert_non_empty_info(info: &mut Params, key: &str, value: &str) {
2460    if !value.is_empty() {
2461        info.insert(
2462            key.to_string(),
2463            serde_json::Value::String(value.to_string()),
2464        );
2465    }
2466}
2467
2468/// Parses an OKX option instrument definition into a Nautilus option contract.
2469///
2470/// # Errors
2471///
2472/// Returns an error if the instrument definition cannot be parsed.
2473///
2474/// # Panics
2475///
2476/// Panics if the constructed instrument fails validation.
2477pub fn parse_option_instrument(
2478    definition: &OKXInstrument,
2479    margin_init: Option<Decimal>,
2480    margin_maint: Option<Decimal>,
2481    maker_fee: Option<Decimal>,
2482    taker_fee: Option<Decimal>,
2483    ts_init: UnixNanos,
2484) -> anyhow::Result<InstrumentAny> {
2485    validate_underlying(definition.inst_id, definition.uly)?;
2486
2487    let context = format!("OPTION instrument {}", definition.inst_id);
2488    let (underlying_str, quote_ccy_str) = definition.uly.split_once('-').ok_or_else(|| {
2489        anyhow::anyhow!(
2490            "Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
2491            definition.uly,
2492            definition.inst_id
2493        )
2494    })?;
2495
2496    let instrument_id = parse_instrument_id(definition.inst_id);
2497    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
2498    let underlying = Currency::get_or_create_crypto_with_context(underlying_str, Some(&context));
2499    let option_kind: OptionKind = OptionKind::try_from(definition.opt_type).map_err(|kind| {
2500        anyhow::anyhow!(
2501            "Unsupported `optType` '{kind:?}' for {}: cannot map to Nautilus OptionKind",
2502            definition.inst_id
2503        )
2504    })?;
2505    let strike_price = Price::from_str(&definition.stk).map_err(|e| {
2506        anyhow::anyhow!(
2507            "Failed to parse `stk` '{}' for {}: {e}",
2508            definition.stk,
2509            definition.inst_id
2510        )
2511    })?;
2512    let quote_currency = Currency::get_or_create_crypto_with_context(quote_ccy_str, Some(&context));
2513    let settlement_currency =
2514        Currency::get_or_create_crypto_with_context(definition.settle_ccy, Some(&context));
2515
2516    let is_inverse = if definition.ct_type == OKXContractType::None {
2517        settlement_currency == underlying
2518    } else {
2519        matches!(definition.ct_type, OKXContractType::Inverse)
2520    };
2521
2522    let listing_time = definition
2523        .list_time
2524        .ok_or_else(|| anyhow::anyhow!("`list_time` is required for {}", definition.inst_id))?;
2525    let expiry_time = definition
2526        .exp_time
2527        .ok_or_else(|| anyhow::anyhow!("`exp_time` is required for {}", definition.inst_id))?;
2528    let activation_ns = parse_millisecond_timestamp(listing_time);
2529    let expiration_ns = parse_millisecond_timestamp(expiry_time);
2530
2531    if definition.tick_sz.is_empty() {
2532        anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
2533    }
2534
2535    let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
2536        anyhow::anyhow!(
2537            "Failed to parse `tick_sz` '{}' for {}: {e}",
2538            definition.tick_sz,
2539            definition.inst_id
2540        )
2541    })?;
2542
2543    if definition.lot_sz.is_empty() {
2544        anyhow::bail!("`lot_sz` is empty for {}", definition.inst_id);
2545    }
2546    let size_increment = Quantity::from_str(&definition.lot_sz).map_err(|e| {
2547        anyhow::anyhow!(
2548            "Failed to parse `lot_sz` '{}' for {}: {e}",
2549            definition.lot_sz,
2550            definition.inst_id
2551        )
2552    })?;
2553    let multiplier = parse_multiplier_product(definition)?;
2554    let lot_size = size_increment;
2555    let max_quantity = if definition.max_mkt_sz.is_empty() {
2556        None
2557    } else {
2558        Some(Quantity::from_str(&definition.max_mkt_sz).map_err(|e| {
2559            anyhow::anyhow!(
2560                "Failed to parse `max_mkt_sz` '{}' for {}: {e}",
2561                definition.max_mkt_sz,
2562                definition.inst_id
2563            )
2564        })?)
2565    };
2566    let min_quantity = if definition.min_sz.is_empty() {
2567        None
2568    } else {
2569        Some(Quantity::from_str(&definition.min_sz).map_err(|e| {
2570            anyhow::anyhow!(
2571                "Failed to parse `min_sz` '{}' for {}: {e}",
2572                definition.min_sz,
2573                definition.inst_id
2574            )
2575        })?)
2576    };
2577    let max_notional = None;
2578    let min_notional = None;
2579    let max_price = None;
2580    let min_price = None;
2581
2582    let instrument = CryptoOption::builder()
2583        .instrument_id(instrument_id)
2584        .raw_symbol(raw_symbol)
2585        .underlying(underlying)
2586        .quote_currency(quote_currency)
2587        .settlement_currency(settlement_currency)
2588        .is_inverse(is_inverse)
2589        .option_kind(option_kind)
2590        .strike_price(strike_price)
2591        .activation_ns(activation_ns)
2592        .expiration_ns(expiration_ns)
2593        .price_precision(price_increment.precision)
2594        .size_precision(size_increment.precision)
2595        .price_increment(price_increment)
2596        .size_increment(size_increment)
2597        .maybe_multiplier(multiplier)
2598        .lot_size(lot_size)
2599        .maybe_max_quantity(max_quantity)
2600        .maybe_min_quantity(min_quantity)
2601        .maybe_max_notional(max_notional)
2602        .maybe_min_notional(min_notional)
2603        .maybe_max_price(max_price)
2604        .maybe_min_price(min_price)
2605        .maybe_margin_init(margin_init)
2606        .maybe_margin_maint(margin_maint)
2607        .maybe_maker_fee(maker_fee)
2608        .maybe_taker_fee(taker_fee)
2609        .ts_event(ts_init)
2610        .ts_init(ts_init)
2611        .build()
2612        .unwrap();
2613
2614    Ok(InstrumentAny::CryptoOption(instrument))
2615}
2616
2617fn okx_inst_category_to_asset_class(category: Option<OKXInstrumentCategory>) -> AssetClass {
2618    match category {
2619        Some(OKXInstrumentCategory::Crypto) => AssetClass::Cryptocurrency,
2620        Some(OKXInstrumentCategory::Equity) => AssetClass::Equity,
2621        Some(OKXInstrumentCategory::Commodity) => AssetClass::Commodity,
2622        Some(OKXInstrumentCategory::Fx) => AssetClass::FX,
2623        Some(OKXInstrumentCategory::Debt) => AssetClass::Debt,
2624        Some(OKXInstrumentCategory::Unknown) | None => AssetClass::Alternative,
2625    }
2626}
2627
2628fn parse_event_contract_currency(definition: &OKXInstrument) -> anyhow::Result<Currency> {
2629    let context = format!("EVENTS instrument {}", definition.inst_id);
2630    let currency = if !definition.settle_ccy.is_empty() {
2631        definition.settle_ccy
2632    } else if !definition.quote_ccy.is_empty() {
2633        definition.quote_ccy
2634    } else {
2635        anyhow::bail!(
2636            "`settle_ccy` or `quote_ccy` is required for EVENTS instrument {}",
2637            definition.inst_id
2638        );
2639    };
2640
2641    Ok(Currency::get_or_create_crypto_with_context(
2642        currency,
2643        Some(&context),
2644    ))
2645}
2646
2647fn build_event_contract_info(definition: &OKXInstrument) -> anyhow::Result<Params> {
2648    let mut map = serde_json::Map::new();
2649
2650    if let Some(series_id) = definition.series_id {
2651        map.insert(
2652            "series_id".to_string(),
2653            serde_json::Value::String(series_id.to_string()),
2654        );
2655    }
2656
2657    if let Some(inst_category) = definition.inst_category {
2658        let code = inst_category.as_ref();
2659        if !code.is_empty() {
2660            map.insert(
2661                "inst_category".to_string(),
2662                serde_json::Value::String(code.to_string()),
2663            );
2664        }
2665    }
2666
2667    if let Some(inst_id_code) = definition.inst_id_code {
2668        map.insert(
2669            "inst_id_code".to_string(),
2670            serde_json::Value::Number(inst_id_code.into()),
2671        );
2672    }
2673
2674    map.insert(
2675        "state".to_string(),
2676        serde_json::Value::String(definition.state.to_string()),
2677    );
2678    map.insert(
2679        "rule_type".to_string(),
2680        serde_json::Value::String(definition.rule_type.clone()),
2681    );
2682
2683    Ok(serde_json::from_value(serde_json::Value::Object(map))?)
2684}
2685
2686/// Parses an OKX event contract instrument definition into a Nautilus binary option.
2687///
2688/// # Errors
2689///
2690/// Returns an error if the instrument definition cannot be parsed.
2691pub fn parse_event_contract_instrument(
2692    definition: &OKXInstrument,
2693    margin_init: Option<Decimal>,
2694    margin_maint: Option<Decimal>,
2695    maker_fee: Option<Decimal>,
2696    taker_fee: Option<Decimal>,
2697    ts_init: UnixNanos,
2698) -> anyhow::Result<InstrumentAny> {
2699    let common = parse_common_instrument_data(definition)?;
2700    let currency = parse_event_contract_currency(definition)?;
2701
2702    let activation_ns = definition
2703        .list_time
2704        .map(parse_millisecond_timestamp)
2705        .unwrap_or_default();
2706    let expiration_ns = definition
2707        .exp_time
2708        .map(parse_millisecond_timestamp)
2709        .unwrap_or_default();
2710    let asset_class = okx_inst_category_to_asset_class(definition.inst_category);
2711    let info = build_event_contract_info(definition)?;
2712
2713    let instrument = BinaryOption::builder()
2714        .instrument_id(common.instrument_id)
2715        .raw_symbol(common.raw_symbol)
2716        .asset_class(asset_class)
2717        .currency(currency)
2718        .activation_ns(activation_ns)
2719        .expiration_ns(expiration_ns)
2720        .price_precision(common.price_increment.precision)
2721        .size_precision(common.size_increment.precision)
2722        .price_increment(common.price_increment)
2723        .size_increment(common.size_increment)
2724        .maybe_description(definition.series_id)
2725        .maybe_max_quantity(common.max_quantity)
2726        .maybe_min_quantity(common.min_quantity)
2727        .maybe_max_notional(common.max_notional)
2728        .maybe_min_notional(common.min_notional)
2729        .max_price(Price::from("1"))
2730        .min_price(Price::from("0"))
2731        .maybe_margin_init(margin_init)
2732        .maybe_margin_maint(margin_maint)
2733        .maybe_maker_fee(maker_fee)
2734        .maybe_taker_fee(taker_fee)
2735        .info(info)
2736        .ts_event(ts_init)
2737        .ts_init(ts_init)
2738        .build()?;
2739
2740    Ok(InstrumentAny::BinaryOption(instrument))
2741}
2742
2743/// Parses an OKX account into a Nautilus account state.
2744fn parse_balance_field(value_str: &str, field_name: &str, ccy_str: &str) -> Option<Decimal> {
2745    match Decimal::from_str(value_str) {
2746        Ok(decimal) => Some(decimal),
2747        Err(e) => {
2748            log::warn!(
2749                "Skipping balance detail for {ccy_str} with invalid {field_name} '{value_str}': {e}"
2750            );
2751            None
2752        }
2753    }
2754}
2755
2756/// # Errors
2757///
2758/// Returns an error if the data cannot be parsed.
2759pub fn parse_account_state(
2760    okx_account: &OKXAccount,
2761    account_id: AccountId,
2762    ts_init: UnixNanos,
2763) -> anyhow::Result<AccountState> {
2764    let mut balances = Vec::new();
2765
2766    for b in &okx_account.details {
2767        // Skip balances with empty or whitespace-only currency codes
2768        let ccy_str = b.ccy.as_str().trim();
2769        if ccy_str.is_empty() {
2770            log::debug!("Skipping balance detail with empty currency code | raw_data={b:?}");
2771            continue;
2772        }
2773
2774        // Get or create currency (consistent with instrument parsing)
2775        let currency = Currency::get_or_create_crypto_with_context(ccy_str, Some("balance detail"));
2776
2777        // Parse balance values, skip if invalid
2778        let Some(total) = parse_balance_field(&b.cash_bal, "cash_bal", ccy_str) else {
2779            continue;
2780        };
2781
2782        let Some(free) = parse_balance_field(&b.avail_bal, "avail_bal", ccy_str) else {
2783            continue;
2784        };
2785
2786        match AccountBalance::from_total_and_free(total, free, currency) {
2787            Ok(balance) => balances.push(balance),
2788            Err(e) => {
2789                log::warn!("Skipping balance detail for {ccy_str} with invalid total/free: {e}");
2790            }
2791        }
2792    }
2793
2794    // Ensure at least one balance exists (Nautilus requires non-empty balances)
2795    // OKX may return empty details for certain account configurations
2796    if balances.is_empty() {
2797        let zero_currency = Currency::USD();
2798        let zero_money = Money::new(0.0, zero_currency);
2799        let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
2800        balances.push(zero_balance);
2801    }
2802
2803    let mut margins = Vec::new();
2804
2805    // OKX reports aggregate cross-margin requirements (`imr` / `mmr`) in USD terms;
2806    // emit as an account-wide margin entry keyed by USD.
2807    if !okx_account.imr.is_empty() && !okx_account.mmr.is_empty() {
2808        match (
2809            Decimal::from_str(&okx_account.imr),
2810            Decimal::from_str(&okx_account.mmr),
2811        ) {
2812            (Ok(imr_dec), Ok(mmr_dec)) => {
2813                if !imr_dec.is_zero() || !mmr_dec.is_zero() {
2814                    let margin_currency = Currency::USD();
2815
2816                    let initial_margin = Money::from_decimal(imr_dec, margin_currency)
2817                        .unwrap_or_else(|e| {
2818                            log::error!("Failed to create initial margin: {e}");
2819                            Money::zero(margin_currency)
2820                        });
2821                    let maintenance_margin = Money::from_decimal(mmr_dec, margin_currency)
2822                        .unwrap_or_else(|e| {
2823                            log::error!("Failed to create maintenance margin: {e}");
2824                            Money::zero(margin_currency)
2825                        });
2826
2827                    margins.push(MarginBalance::new(initial_margin, maintenance_margin, None));
2828                }
2829            }
2830            (Err(e1), _) => {
2831                log::warn!(
2832                    "Failed to parse initial margin requirement '{}': {}",
2833                    okx_account.imr,
2834                    e1
2835                );
2836            }
2837            (_, Err(e2)) => {
2838                log::warn!(
2839                    "Failed to parse maintenance margin requirement '{}': {}",
2840                    okx_account.mmr,
2841                    e2
2842                );
2843            }
2844        }
2845    }
2846
2847    let account_type = AccountType::Margin;
2848    let is_reported = true;
2849    let event_id = UUID4::new();
2850    let ts_event = parse_millisecond_timestamp(okx_account.u_time);
2851
2852    Ok(AccountState::new(
2853        account_id,
2854        account_type,
2855        balances,
2856        margins,
2857        is_reported,
2858        event_id,
2859        ts_event,
2860        ts_init,
2861        None,
2862    ))
2863}
2864
2865/// Converts an optional `UnixNanos` to an optional `Timestamp`.
2866pub fn nanos_to_datetime(value: Option<UnixNanos>) -> Option<jiff::Timestamp> {
2867    value.map(|nanos| nanos.to_datetime_utc())
2868}
2869
2870#[cfg(test)]
2871mod tests {
2872    use nautilus_model::{enums::OrderSide, identifiers::PositionId, instruments::Instrument};
2873    use rstest::rstest;
2874    use rust_decimal_macros::dec;
2875
2876    use super::*;
2877    use crate::{
2878        OKXPositionSide,
2879        common::{enums::OKXMarginMode, testing::load_test_json},
2880        http::{
2881            client::OKXResponse,
2882            models::{
2883                OKXAccount, OKXBalanceDetail, OKXCandlestick, OKXIndexTicker, OKXMarkPrice,
2884                OKXOrderHistory, OKXPlaceOrderResponse, OKXPosition, OKXPositionHistory,
2885                OKXPositionTier, OKXSpread, OKXTrade, OKXTransactionDetail,
2886            },
2887        },
2888    };
2889
2890    #[rstest]
2891    fn test_parse_fee_currency_with_zero_fee_empty_string() {
2892        let result = parse_fee_currency("", Decimal::ZERO, || "test context".to_string());
2893        assert_eq!(result, Currency::USDT());
2894    }
2895
2896    #[rstest]
2897    fn test_parse_fee_currency_with_zero_fee_valid_currency() {
2898        let result = parse_fee_currency("BTC", Decimal::ZERO, || "test context".to_string());
2899        assert_eq!(result, Currency::BTC());
2900    }
2901
2902    #[rstest]
2903    fn test_parse_fee_currency_with_valid_currency() {
2904        let result = parse_fee_currency("BTC", dec!(0.001), || "test context".to_string());
2905        assert_eq!(result, Currency::BTC());
2906    }
2907
2908    #[rstest]
2909    fn test_parse_fee_currency_with_empty_string_nonzero_fee() {
2910        let result = parse_fee_currency("", dec!(0.5), || "test context".to_string());
2911        assert_eq!(result, Currency::USDT());
2912    }
2913
2914    #[rstest]
2915    fn test_parse_fee_currency_with_whitespace() {
2916        let result = parse_fee_currency("  ETH  ", dec!(0.002), || "test context".to_string());
2917        assert_eq!(result, Currency::ETH());
2918    }
2919
2920    #[rstest]
2921    fn test_parse_fee_currency_with_unknown_code() {
2922        // Unknown currency code should create a new Currency (8 decimals, crypto)
2923        let result = parse_fee_currency("NEWTOKEN", dec!(0.5), || "test context".to_string());
2924        assert_eq!(result.code.as_str(), "NEWTOKEN");
2925        assert_eq!(result.precision, 8);
2926    }
2927
2928    #[rstest]
2929    fn test_parse_balance_field_valid() {
2930        let result = parse_balance_field("100.5", "test_field", "BTC");
2931        assert_eq!(result, Some(dec!(100.5)));
2932    }
2933
2934    #[rstest]
2935    fn test_parse_balance_field_invalid_numeric() {
2936        let result = parse_balance_field("not_a_number", "test_field", "BTC");
2937        assert!(result.is_none());
2938    }
2939
2940    #[rstest]
2941    fn test_parse_balance_field_empty() {
2942        let result = parse_balance_field("", "test_field", "BTC");
2943        assert!(result.is_none());
2944    }
2945
2946    // Note: Tests for parse_account_state with edge cases (empty currency codes, invalid values)
2947    // are covered by the existing tests using test data files (e.g., http_get_account_balance.json)
2948
2949    #[rstest]
2950    fn test_parse_trades() {
2951        let json_data = load_test_json("http_get_trades.json");
2952        let parsed: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
2953
2954        // Basic response envelope
2955        assert_eq!(parsed.code, "0");
2956        assert_eq!(parsed.msg, "");
2957        assert_eq!(parsed.data.len(), 2);
2958
2959        // Inspect first record
2960        let trade0 = &parsed.data[0];
2961        assert_eq!(trade0.inst_id, "BTC-USDT");
2962        assert_eq!(trade0.px, "102537.9");
2963        assert_eq!(trade0.sz, "0.00013669");
2964        assert_eq!(trade0.side, OKXSide::Sell);
2965        assert_eq!(trade0.trade_id, "734864333");
2966        assert_eq!(trade0.ts, 1747087163557);
2967        assert_eq!(trade0.source.as_deref(), Some("1"));
2968
2969        // Inspect second record
2970        let trade1 = &parsed.data[1];
2971        assert_eq!(trade1.inst_id, "BTC-USDT");
2972        assert_eq!(trade1.px, "102537.9");
2973        assert_eq!(trade1.sz, "0.0000125");
2974        assert_eq!(trade1.side, OKXSide::Buy);
2975        assert_eq!(trade1.trade_id, "734864332");
2976        assert_eq!(trade1.ts, 1747087161666);
2977        assert_eq!(trade1.source.as_deref(), Some("0"));
2978    }
2979
2980    #[rstest]
2981    fn test_parse_candlesticks() {
2982        let json_data = load_test_json("http_get_candlesticks.json");
2983        let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
2984
2985        // Basic response envelope
2986        assert_eq!(parsed.code, "0");
2987        assert_eq!(parsed.msg, "");
2988        assert_eq!(parsed.data.len(), 2);
2989
2990        let bar0 = &parsed.data[0];
2991        assert_eq!(bar0.0, "1625097600000");
2992        assert_eq!(bar0.1, "33528.6");
2993        assert_eq!(bar0.2, "33870.0");
2994        assert_eq!(bar0.3, "33528.6");
2995        assert_eq!(bar0.4, "33783.9");
2996        assert_eq!(bar0.5, "778.838");
2997
2998        let bar1 = &parsed.data[1];
2999        assert_eq!(bar1.0, "1625097660000");
3000        assert_eq!(bar1.1, "33783.9");
3001        assert_eq!(bar1.2, "33783.9");
3002        assert_eq!(bar1.3, "33782.1");
3003        assert_eq!(bar1.4, "33782.1");
3004        assert_eq!(bar1.5, "0.123");
3005    }
3006
3007    #[rstest]
3008    fn test_parse_candlesticks_full() {
3009        let json_data = load_test_json("http_get_candlesticks_full.json");
3010        let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
3011
3012        // Basic response envelope
3013        assert_eq!(parsed.code, "0");
3014        assert_eq!(parsed.msg, "");
3015        assert_eq!(parsed.data.len(), 2);
3016
3017        // Inspect first record
3018        let bar0 = &parsed.data[0];
3019        assert_eq!(bar0.0, "1747094040000");
3020        assert_eq!(bar0.1, "102806.1");
3021        assert_eq!(bar0.2, "102820.4");
3022        assert_eq!(bar0.3, "102806.1");
3023        assert_eq!(bar0.4, "102820.4");
3024        assert_eq!(bar0.5, "1040.37");
3025        assert_eq!(bar0.6, "10.4037");
3026        assert_eq!(bar0.7, "1069603.34883");
3027        assert_eq!(bar0.8, "1");
3028
3029        // Inspect second record
3030        let bar1 = &parsed.data[1];
3031        assert_eq!(bar1.0, "1747093980000");
3032        assert_eq!(bar1.5, "7164.04");
3033        assert_eq!(bar1.6, "71.6404");
3034        assert_eq!(bar1.7, "7364701.57952");
3035        assert_eq!(bar1.8, "1");
3036    }
3037
3038    #[rstest]
3039    fn test_parse_mark_price() {
3040        let json_data = load_test_json("http_get_mark_price.json");
3041        let parsed: OKXResponse<OKXMarkPrice> = serde_json::from_str(&json_data).unwrap();
3042
3043        // Basic response envelope
3044        assert_eq!(parsed.code, "0");
3045        assert_eq!(parsed.msg, "");
3046        assert_eq!(parsed.data.len(), 1);
3047
3048        // Inspect first record
3049        let mark_price = &parsed.data[0];
3050
3051        assert_eq!(mark_price.inst_id, "BTC-USDT-SWAP");
3052        assert_eq!(mark_price.mark_px, "84660.1");
3053        assert_eq!(mark_price.ts, 1744590349506);
3054    }
3055
3056    #[rstest]
3057    fn test_parse_index_price() {
3058        let json_data = load_test_json("http_get_index_price.json");
3059        let parsed: OKXResponse<OKXIndexTicker> = serde_json::from_str(&json_data).unwrap();
3060
3061        // Basic response envelope
3062        assert_eq!(parsed.code, "0");
3063        assert_eq!(parsed.msg, "");
3064        assert_eq!(parsed.data.len(), 1);
3065
3066        // Inspect first record
3067        let index_price = &parsed.data[0];
3068
3069        assert_eq!(index_price.inst_id, "BTC-USDT");
3070        assert_eq!(index_price.idx_px, "103895");
3071        assert_eq!(index_price.ts, 1746942707815);
3072    }
3073
3074    #[rstest]
3075    fn test_parse_account() {
3076        let json_data = load_test_json("http_get_account_balance.json");
3077        let parsed: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
3078
3079        // Basic response envelope
3080        assert_eq!(parsed.code, "0");
3081        assert_eq!(parsed.msg, "");
3082        assert_eq!(parsed.data.len(), 1);
3083
3084        // Inspect first record
3085        let account = &parsed.data[0];
3086        assert_eq!(account.adj_eq, "");
3087        assert_eq!(account.borrow_froz, "");
3088        assert_eq!(account.imr, "");
3089        assert_eq!(account.iso_eq, "5.4682385526666675");
3090        assert_eq!(account.mgn_ratio, "");
3091        assert_eq!(account.mmr, "");
3092        assert_eq!(account.notional_usd, "");
3093        assert_eq!(account.notional_usd_for_borrow, "");
3094        assert_eq!(account.notional_usd_for_futures, "");
3095        assert_eq!(account.notional_usd_for_option, "");
3096        assert_eq!(account.notional_usd_for_swap, "");
3097        assert_eq!(account.ord_froz, "");
3098        assert_eq!(account.total_eq, "99.88870288820581");
3099        assert_eq!(account.upl, "");
3100        assert_eq!(account.u_time, 1744499648556);
3101        assert_eq!(account.details.len(), 1);
3102
3103        let detail = &account.details[0];
3104        assert_eq!(detail.ccy, "USDT");
3105        assert_eq!(detail.avail_bal, "94.42612990333333");
3106        assert_eq!(detail.avail_eq, "94.42612990333333");
3107        assert_eq!(detail.cash_bal, "94.42612990333333");
3108        assert_eq!(detail.dis_eq, "5.4682385526666675");
3109        assert_eq!(detail.eq, "99.89469657000001");
3110        assert_eq!(detail.eq_usd, "99.88870288820581");
3111        assert_eq!(detail.fixed_bal, "0");
3112        assert_eq!(detail.frozen_bal, "5.468566666666667");
3113        assert_eq!(detail.imr, "0");
3114        assert_eq!(detail.iso_eq, "5.468566666666667");
3115        assert_eq!(detail.iso_upl, "-0.0273000000000002");
3116        assert_eq!(detail.mmr, "0");
3117        assert_eq!(detail.notional_lever, "0");
3118        assert_eq!(detail.ord_frozen, "0");
3119        assert_eq!(detail.reward_bal, "0");
3120        assert_eq!(detail.smt_sync_eq, "0");
3121        assert_eq!(detail.spot_copy_trading_eq, "0");
3122        assert_eq!(detail.spot_iso_bal, "0");
3123        assert_eq!(detail.stgy_eq, "0");
3124        assert_eq!(detail.twap, "0");
3125        assert_eq!(detail.upl, "-0.0273000000000002");
3126        assert_eq!(detail.u_time, 1744498994783);
3127    }
3128
3129    #[rstest]
3130    fn test_parse_order_history() {
3131        let json_data = load_test_json("http_get_orders_history.json");
3132        let parsed: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
3133
3134        // Basic response envelope
3135        assert_eq!(parsed.code, "0");
3136        assert_eq!(parsed.msg, "");
3137        assert_eq!(parsed.data.len(), 1);
3138
3139        // Inspect first record
3140        let order = &parsed.data[0];
3141        assert_eq!(order.ord_id, "2497956918703120384");
3142        assert_eq!(order.fill_sz, "0.03");
3143        assert_eq!(order.acc_fill_sz, "0.03");
3144        assert_eq!(order.state, OKXOrderStatus::Filled);
3145        assert!(order.fill_fee.is_none());
3146    }
3147
3148    #[rstest]
3149    fn test_parse_position() {
3150        let json_data = load_test_json("http_get_positions.json");
3151        let parsed: OKXResponse<OKXPosition> = 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 pos = &parsed.data[0];
3160        assert_eq!(pos.inst_id, "BTC-USDT-SWAP");
3161        assert_eq!(pos.pos_side, OKXPositionSide::Long);
3162        assert_eq!(pos.pos, "0.5");
3163        assert_eq!(pos.base_bal, "0.5");
3164        assert_eq!(pos.quote_bal, "5000");
3165        assert_eq!(pos.u_time, 1622559930237);
3166    }
3167
3168    #[rstest]
3169    fn test_parse_position_history() {
3170        let json_data = load_test_json("http_get_account_positions-history.json");
3171        let parsed: OKXResponse<OKXPositionHistory> = serde_json::from_str(&json_data).unwrap();
3172
3173        // Basic response envelope
3174        assert_eq!(parsed.code, "0");
3175        assert_eq!(parsed.msg, "");
3176        assert_eq!(parsed.data.len(), 1);
3177
3178        // Inspect first record
3179        let hist = &parsed.data[0];
3180        assert_eq!(hist.inst_id, "ETH-USDT-SWAP");
3181        assert_eq!(hist.inst_type, OKXInstrumentType::Swap);
3182        assert_eq!(hist.mgn_mode, OKXMarginMode::Isolated);
3183        assert_eq!(hist.pos_side, OKXPositionSide::Long);
3184        assert_eq!(hist.lever, "3.0");
3185        assert_eq!(hist.open_avg_px, "3226.93");
3186        assert_eq!(hist.close_avg_px.as_deref(), Some("3224.8"));
3187        assert_eq!(hist.pnl.as_deref(), Some("-0.0213"));
3188        assert!(!hist.c_time.is_empty());
3189        assert!(hist.u_time > 0);
3190    }
3191
3192    #[rstest]
3193    fn test_parse_position_tiers() {
3194        let json_data = load_test_json("http_get_position_tiers.json");
3195        let parsed: OKXResponse<OKXPositionTier> = serde_json::from_str(&json_data).unwrap();
3196
3197        // Basic response envelope
3198        assert_eq!(parsed.code, "0");
3199        assert_eq!(parsed.msg, "");
3200        assert_eq!(parsed.data.len(), 1);
3201
3202        // Inspect first tier record
3203        let tier = &parsed.data[0];
3204        assert_eq!(tier.inst_id, "BTC-USDT");
3205        assert_eq!(tier.tier, "1");
3206        assert_eq!(tier.min_sz, "0");
3207        assert_eq!(tier.max_sz, "50");
3208        assert_eq!(tier.imr, "0.1");
3209        assert_eq!(tier.mmr, "0.03");
3210    }
3211
3212    #[rstest]
3213    fn test_parse_account_field_name_compatibility() {
3214        // Test with new field names (with Amt suffix)
3215        let json_new = load_test_json("http_balance_detail_new_fields.json");
3216        let detail_new: OKXBalanceDetail = serde_json::from_str(&json_new).unwrap();
3217        assert_eq!(detail_new.max_spot_in_use_amt, "50.0");
3218        assert_eq!(detail_new.spot_in_use_amt, "30.0");
3219        assert_eq!(detail_new.cl_spot_in_use_amt, "25.0");
3220
3221        // Test with old field names (without Amt suffix) - for backward compatibility
3222        let json_old = load_test_json("http_balance_detail_old_fields.json");
3223        let detail_old: OKXBalanceDetail = serde_json::from_str(&json_old).unwrap();
3224        assert_eq!(detail_old.max_spot_in_use_amt, "75.0");
3225        assert_eq!(detail_old.spot_in_use_amt, "40.0");
3226        assert_eq!(detail_old.cl_spot_in_use_amt, "35.0");
3227    }
3228
3229    #[rstest]
3230    fn test_parse_place_order_response() {
3231        let json_data = load_test_json("http_place_order_response.json");
3232        let parsed: OKXPlaceOrderResponse = serde_json::from_str(&json_data).unwrap();
3233        assert_eq!(parsed.ord_id, Some(Ustr::from("12345678901234567890")));
3234        assert_eq!(parsed.cl_ord_id, Some(Ustr::from("client_order_123")));
3235        assert_eq!(parsed.tag, Some(String::new()));
3236    }
3237
3238    #[rstest]
3239    fn test_parse_transaction_details() {
3240        let json_data = load_test_json("http_transaction_detail.json");
3241        let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
3242        assert_eq!(parsed.inst_type, OKXInstrumentType::Spot);
3243        assert_eq!(parsed.inst_id, Ustr::from("BTC-USDT"));
3244        assert_eq!(parsed.trade_id, Ustr::from("123456789"));
3245        assert_eq!(parsed.ord_id, Ustr::from("987654321"));
3246        assert_eq!(parsed.cl_ord_id, Ustr::from("client_123"));
3247        assert_eq!(parsed.bill_id, Ustr::from("bill_456"));
3248        assert_eq!(parsed.fill_px, "42000.5");
3249        assert_eq!(parsed.fill_sz, "0.001");
3250        assert_eq!(parsed.side, OKXSide::Buy);
3251        assert_eq!(parsed.exec_type, OKXExecType::Taker);
3252        assert_eq!(parsed.fee_ccy, "USDT");
3253        assert_eq!(parsed.fee, Some("0.042".to_string()));
3254        assert_eq!(parsed.ts, 1625097600000);
3255    }
3256
3257    #[rstest]
3258    fn test_parse_empty_fee_field() {
3259        let json_data = load_test_json("http_transaction_detail_empty_fee.json");
3260        let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
3261        assert_eq!(parsed.fee, None);
3262    }
3263
3264    #[rstest]
3265    fn test_parse_optional_string_to_u64() {
3266        use serde::Deserialize;
3267
3268        #[derive(Deserialize)]
3269        struct TestStruct {
3270            #[serde(deserialize_with = "crate::common::parse::deserialize_optional_string_to_u64")]
3271            value: Option<u64>,
3272        }
3273
3274        let json_cases = load_test_json("common_optional_string_to_u64.json");
3275        let cases: Vec<TestStruct> = serde_json::from_str(&json_cases).unwrap();
3276
3277        assert_eq!(cases[0].value, Some(12345));
3278        assert_eq!(cases[1].value, None);
3279        assert_eq!(cases[2].value, None);
3280    }
3281
3282    #[rstest]
3283    fn test_parse_error_handling() {
3284        // Test error handling with invalid price string
3285        let invalid_price = "invalid-price";
3286        let result = crate::common::parse::parse_price(invalid_price, 2);
3287        result.unwrap_err();
3288
3289        // Test error handling with invalid quantity string
3290        let invalid_quantity = "invalid-quantity";
3291        let result = crate::common::parse::parse_quantity(invalid_quantity, 8);
3292        result.unwrap_err();
3293    }
3294
3295    #[rstest]
3296    fn test_parse_spot_instrument() {
3297        let json_data = load_test_json("http_get_instruments_spot.json");
3298        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
3299        let okx_inst: &OKXInstrument = response
3300            .data
3301            .first()
3302            .expect("Test data must have an instrument");
3303
3304        let instrument =
3305            parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
3306
3307        assert_eq!(instrument.id(), InstrumentId::from("BTC-USD.OKX"));
3308        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD"));
3309        assert_eq!(instrument.underlying(), None);
3310        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
3311        assert_eq!(instrument.quote_currency(), Currency::USD());
3312        assert_eq!(instrument.settlement_currency(), Currency::USD());
3313        assert_eq!(instrument.price_precision(), 1);
3314        assert_eq!(instrument.size_precision(), 8);
3315        assert_eq!(instrument.price_increment(), Price::from("0.1"));
3316        assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
3317        assert_eq!(instrument.multiplier(), Quantity::from(1));
3318        assert_eq!(instrument.lot_size(), Some(Quantity::from("0.00000001")));
3319        assert_eq!(instrument.max_quantity(), Some(Quantity::from(1000000)));
3320        assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.00001")));
3321        assert_eq!(instrument.max_notional(), None);
3322        assert_eq!(instrument.min_notional(), None);
3323        assert_eq!(instrument.max_price(), None);
3324        assert_eq!(instrument.min_price(), None);
3325    }
3326
3327    #[rstest]
3328    fn test_parse_spot_instrument_exposes_price_limit_percentages_as_info() {
3329        let json_data = load_test_json("http_get_instruments_price_limit.json");
3330        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
3331        let okx_inst = response
3332            .data
3333            .first()
3334            .expect("Test data must have an instrument");
3335
3336        assert_eq!(okx_inst.init_px_lmt_pct, "0.05");
3337        assert_eq!(okx_inst.float_px_lmt_pct, "0.03");
3338        assert_eq!(okx_inst.max_px_lmt_pct, "0.15");
3339
3340        let instrument =
3341            parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
3342
3343        let InstrumentAny::CurrencyPair(pair) = instrument else {
3344            panic!("expected CurrencyPair");
3345        };
3346        let info = pair.info.expect("price-limit info must be set");
3347
3348        assert_eq!(info.get_str("okx_init_px_lmt_pct"), Some("0.05"));
3349        assert_eq!(info.get_str("okx_float_px_lmt_pct"), Some("0.03"));
3350        assert_eq!(info.get_str("okx_max_px_lmt_pct"), Some("0.15"));
3351        assert_eq!(pair.max_price, None);
3352        assert_eq!(pair.min_price, None);
3353    }
3354
3355    #[rstest]
3356    fn test_parse_margin_instrument() {
3357        let json_data = load_test_json("http_get_instruments_margin.json");
3358        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
3359        let okx_inst: &OKXInstrument = response
3360            .data
3361            .first()
3362            .expect("Test data must have an instrument");
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-USDT.OKX"));
3368        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USDT"));
3369        assert_eq!(instrument.underlying(), None);
3370        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
3371        assert_eq!(instrument.quote_currency(), Currency::USDT());
3372        assert_eq!(instrument.settlement_currency(), Currency::USDT());
3373        assert_eq!(instrument.price_precision(), 1);
3374        assert_eq!(instrument.size_precision(), 8);
3375        assert_eq!(instrument.price_increment(), Price::from("0.1"));
3376        assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
3377        assert_eq!(instrument.multiplier(), Quantity::from(1));
3378        assert_eq!(instrument.lot_size(), Some(Quantity::from("0.00000001")));
3379        assert_eq!(instrument.max_quantity(), Some(Quantity::from(1000000)));
3380        assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.00001")));
3381        assert_eq!(instrument.max_notional(), None);
3382        assert_eq!(instrument.min_notional(), None);
3383        assert_eq!(instrument.max_price(), None);
3384        assert_eq!(instrument.min_price(), None);
3385    }
3386
3387    #[rstest]
3388    fn test_parse_spot_instrument_with_valid_ct_mult() {
3389        let json_data = load_test_json("http_get_instruments_spot.json");
3390        let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
3391
3392        // Modify ctMult to have a valid multiplier value (ctVal is empty, defaults to 1)
3393        if let Some(inst) = response.data.first_mut() {
3394            inst.ct_mult = "0.01".to_string();
3395        }
3396
3397        let okx_inst = response.data.first().unwrap();
3398        let instrument =
3399            parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
3400
3401        // Should parse the multiplier as product of ctMult * ctVal (0.01 * 1 = 0.01)
3402        if let InstrumentAny::CurrencyPair(pair) = instrument {
3403            assert_eq!(pair.multiplier, Quantity::from("0.01"));
3404        } else {
3405            panic!("Expected CurrencyPair instrument");
3406        }
3407    }
3408
3409    #[rstest]
3410    fn test_parse_spot_instrument_with_invalid_ct_mult() {
3411        let json_data = load_test_json("http_get_instruments_spot.json");
3412        let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
3413
3414        // Modify ctMult to be invalid
3415        if let Some(inst) = response.data.first_mut() {
3416            inst.ct_mult = "invalid_number".to_string();
3417        }
3418
3419        let okx_inst = response.data.first().unwrap();
3420        let result = parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default());
3421
3422        // Should error instead of silently defaulting to 1.0
3423        assert!(result.is_err());
3424        assert!(
3425            result
3426                .unwrap_err()
3427                .to_string()
3428                .contains("Failed to parse `ct_mult`")
3429        );
3430    }
3431
3432    #[rstest]
3433    fn test_parse_spot_instrument_with_fees() {
3434        let json_data = load_test_json("http_get_instruments_spot.json");
3435        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
3436        let okx_inst = response.data.first().unwrap();
3437
3438        let maker_fee = Some(dec!(0.0008));
3439        let taker_fee = Some(dec!(0.0010));
3440
3441        let instrument = parse_spot_instrument(
3442            okx_inst,
3443            None,
3444            None,
3445            maker_fee,
3446            taker_fee,
3447            UnixNanos::default(),
3448        )
3449        .unwrap();
3450
3451        // Should apply the provided fees to the instrument
3452        if let InstrumentAny::CurrencyPair(pair) = instrument {
3453            assert_eq!(pair.maker_fee, dec!(0.0008));
3454            assert_eq!(pair.taker_fee, dec!(0.0010));
3455        } else {
3456            panic!("Expected CurrencyPair instrument");
3457        }
3458    }
3459
3460    #[rstest]
3461    fn test_parse_instrument_any_passes_through_fees() {
3462        // parse_instrument_any receives fees already converted to Nautilus format
3463        // (negation happens in HTTP client when parsing OKX API values)
3464        let json_data = load_test_json("http_get_instruments_spot.json");
3465        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
3466        let okx_inst = response.data.first().unwrap();
3467
3468        // Fees are already in Nautilus convention (negated by HTTP client)
3469        let maker_fee = Some(dec!(-0.00025)); // Nautilus: rebate (negative)
3470        let taker_fee = Some(dec!(0.00050)); // Nautilus: commission (positive)
3471
3472        let instrument = parse_instrument_any(
3473            okx_inst,
3474            None,
3475            None,
3476            maker_fee,
3477            taker_fee,
3478            UnixNanos::default(),
3479        )
3480        .unwrap()
3481        .expect("Should parse spot instrument");
3482
3483        // Fees should pass through unchanged
3484        if let InstrumentAny::CurrencyPair(pair) = instrument {
3485            assert_eq!(pair.maker_fee, dec!(-0.00025));
3486            assert_eq!(pair.taker_fee, dec!(0.00050));
3487        } else {
3488            panic!("Expected CurrencyPair instrument");
3489        }
3490    }
3491
3492    #[rstest]
3493    fn test_parse_swap_instrument() {
3494        let json_data = load_test_json("http_get_instruments_swap.json");
3495        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
3496        let okx_inst: &OKXInstrument = response
3497            .data
3498            .first()
3499            .expect("Test data must have an instrument");
3500
3501        let instrument =
3502            parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
3503
3504        assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-SWAP.OKX"));
3505        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-SWAP"));
3506        assert_eq!(instrument.underlying(), None);
3507        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
3508        assert_eq!(instrument.quote_currency(), Currency::USD());
3509        assert_eq!(instrument.settlement_currency(), Currency::BTC());
3510        assert!(instrument.is_inverse());
3511        assert_eq!(instrument.price_precision(), 1);
3512        assert_eq!(instrument.size_precision(), 0);
3513        assert_eq!(instrument.price_increment(), Price::from("0.1"));
3514        assert_eq!(instrument.size_increment(), Quantity::from(1));
3515        assert_eq!(instrument.multiplier(), Quantity::from(100));
3516        assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
3517        assert_eq!(instrument.max_quantity(), Some(Quantity::from(30000)));
3518        assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
3519        assert_eq!(instrument.max_notional(), None);
3520        assert_eq!(instrument.min_notional(), None);
3521        assert_eq!(instrument.max_price(), None);
3522        assert_eq!(instrument.min_price(), None);
3523    }
3524
3525    #[rstest]
3526    fn test_parse_swap_instrument_exposes_price_limit_percentages_as_info() {
3527        let json_data = load_test_json("http_get_instruments_swap.json");
3528        let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
3529        let okx_inst = response
3530            .data
3531            .first_mut()
3532            .expect("Test data must have an instrument");
3533        okx_inst.init_px_lmt_pct = "0.05".to_string();
3534        okx_inst.float_px_lmt_pct = "0.03".to_string();
3535        okx_inst.max_px_lmt_pct = "0.15".to_string();
3536
3537        let instrument =
3538            parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
3539
3540        let InstrumentAny::CryptoPerpetual(perpetual) = instrument else {
3541            panic!("expected CryptoPerpetual");
3542        };
3543        let info = perpetual.info.expect("price-limit info must be set");
3544
3545        assert_eq!(info.get_str("okx_init_px_lmt_pct"), Some("0.05"));
3546        assert_eq!(info.get_str("okx_float_px_lmt_pct"), Some("0.03"));
3547        assert_eq!(info.get_str("okx_max_px_lmt_pct"), Some("0.15"));
3548        assert_eq!(perpetual.max_price, None);
3549        assert_eq!(perpetual.min_price, None);
3550    }
3551
3552    #[rstest]
3553    fn test_deserialize_swap_instrument_with_rebase_state() {
3554        let json_data = load_test_json("http_get_instruments_swap.json");
3555        let mut value: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3556        value["data"][0]["state"] = serde_json::Value::String("rebase".to_string());
3557
3558        let response: OKXResponse<OKXInstrument> = serde_json::from_value(value).unwrap();
3559
3560        assert_eq!(response.data[0].inst_id, "BTC-USD-SWAP");
3561    }
3562
3563    #[rstest]
3564    fn test_parse_inverse_spread_instrument() {
3565        let json_data = load_test_json("http_get_spreads.json");
3566        let response: OKXResponse<OKXSpread> = serde_json::from_str(&json_data).unwrap();
3567        let okx_spread = response.data.first().expect("Test data must have a spread");
3568
3569        let instrument =
3570            parse_spread_instrument(okx_spread, None, None, None, None, UnixNanos::default())
3571                .unwrap();
3572
3573        let InstrumentAny::CryptoFuturesSpread(spread) = instrument else {
3574            panic!("Expected CryptoFuturesSpread");
3575        };
3576        let info = spread.info.as_ref().expect("spread info must be set");
3577        let legs = info
3578            .get("okx_spread_legs")
3579            .and_then(serde_json::Value::as_array)
3580            .expect("spread legs must be present");
3581
3582        assert_eq!(
3583            spread.id,
3584            InstrumentId::from("ETH-USD-SWAP_ETH-USD-231229.OKX")
3585        );
3586        assert_eq!(
3587            spread.raw_symbol,
3588            Symbol::from("ETH-USD-SWAP_ETH-USD-231229")
3589        );
3590        assert_eq!(spread.underlying, Currency::ETH());
3591        assert_eq!(spread.quote_currency, Currency::USD());
3592        assert_eq!(spread.settlement_currency, Currency::ETH());
3593        assert!(spread.is_inverse);
3594        assert_eq!(spread.strategy_type, Ustr::from("inverse"));
3595        assert_eq!(spread.price_precision, 2);
3596        assert_eq!(spread.size_precision, 0);
3597        assert_eq!(spread.price_increment, Price::from("0.01"));
3598        assert_eq!(spread.size_increment, Quantity::from("10"));
3599        assert_eq!(spread.lot_size, Quantity::from("10"));
3600        assert_eq!(spread.min_quantity, Some(Quantity::from("10")));
3601        assert_eq!(spread.max_quantity, None);
3602        assert_eq!(info.get_str("okx_sz_ccy"), Some("USD"));
3603        assert_eq!(legs.len(), 2);
3604        assert_eq!(legs[0]["inst_id"].as_str(), Some("ETH-USD-SWAP"));
3605        assert_eq!(legs[0]["side"].as_str(), Some("sell"));
3606        assert_eq!(legs[0]["ratio"].as_i64(), Some(-1));
3607        assert_eq!(legs[1]["inst_id"].as_str(), Some("ETH-USD-231229"));
3608        assert_eq!(legs[1]["side"].as_str(), Some("buy"));
3609        assert_eq!(legs[1]["ratio"].as_i64(), Some(1));
3610    }
3611
3612    #[rstest]
3613    fn test_parse_linear_spread_instrument_without_expiry() {
3614        let json_data = load_test_json("http_get_spreads.json");
3615        let response: OKXResponse<OKXSpread> = serde_json::from_str(&json_data).unwrap();
3616        let okx_spread = response
3617            .data
3618            .get(1)
3619            .expect("Test data must have a linear spread");
3620
3621        let instrument =
3622            parse_spread_instrument(okx_spread, None, None, None, None, UnixNanos::default())
3623                .unwrap();
3624
3625        let InstrumentAny::CryptoFuturesSpread(spread) = instrument else {
3626            panic!("Expected CryptoFuturesSpread");
3627        };
3628
3629        assert_eq!(spread.id, InstrumentId::from("BTC-USDT_BTC-USDT-SWAP.OKX"));
3630        assert_eq!(spread.underlying, Currency::BTC());
3631        assert_eq!(spread.quote_currency, Currency::USDT());
3632        assert_eq!(spread.settlement_currency, Currency::USDT());
3633        assert!(!spread.is_inverse);
3634        assert_eq!(spread.price_precision, 4);
3635        assert_eq!(spread.size_precision, 3);
3636        assert_eq!(spread.price_increment, Price::from("0.0001"));
3637        assert_eq!(spread.size_increment, Quantity::from("0.001"));
3638        assert_eq!(spread.expiration_ns, UnixNanos::default());
3639    }
3640
3641    #[rstest]
3642    fn test_parse_option_spread_instrument() {
3643        let json_data = load_test_json("http_get_spreads.json");
3644        let mut payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3645        let spread = payload["data"][0]
3646            .as_object_mut()
3647            .expect("spread payload must be an object");
3648        spread.insert(
3649            "sprdId".to_string(),
3650            serde_json::Value::String(
3651                "BTC-USD-260626-100000-C_BTC-USD-260626-110000-C".to_string(),
3652            ),
3653        );
3654        spread.insert(
3655            "baseCcy".to_string(),
3656            serde_json::Value::String("BTC".to_string()),
3657        );
3658        spread.insert(
3659            "quoteCcy".to_string(),
3660            serde_json::Value::String("USD".to_string()),
3661        );
3662        spread["legs"][0]["instId"] =
3663            serde_json::Value::String("BTC-USD-260626-100000-C".to_string());
3664        spread["legs"][1]["instId"] =
3665            serde_json::Value::String("BTC-USD-260626-110000-C".to_string());
3666
3667        let response: OKXResponse<OKXSpread> = serde_json::from_value(payload).unwrap();
3668        let instrument = parse_spread_instrument(
3669            response.data.first().expect("Test data must have a spread"),
3670            None,
3671            None,
3672            None,
3673            None,
3674            UnixNanos::default(),
3675        )
3676        .unwrap();
3677
3678        let InstrumentAny::CryptoOptionSpread(spread) = instrument else {
3679            panic!("Expected CryptoOptionSpread");
3680        };
3681        let info = spread.info.as_ref().expect("spread info must be set");
3682        let legs = info
3683            .get("okx_spread_legs")
3684            .and_then(serde_json::Value::as_array)
3685            .expect("spread legs must be present");
3686
3687        assert_eq!(
3688            spread.id,
3689            InstrumentId::from("BTC-USD-260626-100000-C_BTC-USD-260626-110000-C.OKX")
3690        );
3691        assert_eq!(spread.underlying, Currency::BTC());
3692        assert_eq!(spread.quote_currency, Currency::USD());
3693        assert_eq!(legs[0]["inst_id"].as_str(), Some("BTC-USD-260626-100000-C"));
3694        assert_eq!(legs[1]["inst_id"].as_str(), Some("BTC-USD-260626-110000-C"));
3695    }
3696
3697    #[rstest]
3698    #[case::empty_tick_size("tickSz", Some(""), "`tick_sz` is empty")]
3699    #[case::empty_lot_size("lotSz", Some(""), "`lot_sz` is empty")]
3700    #[case::invalid_min_size("minSz", Some("not-a-quantity"), "Failed to parse `min_sz`")]
3701    #[case::missing_list_time("listTime", None, "`list_time` is required")]
3702    fn test_parse_spread_instrument_rejects_invalid_fields(
3703        #[case] field: &str,
3704        #[case] value: Option<&str>,
3705        #[case] expected: &str,
3706    ) {
3707        let json_data = load_test_json("http_get_spreads.json");
3708        let mut payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3709        let spread = payload["data"][0]
3710            .as_object_mut()
3711            .expect("spread payload must be an object");
3712
3713        if let Some(value) = value {
3714            spread.insert(
3715                field.to_string(),
3716                serde_json::Value::String(value.to_string()),
3717            );
3718        } else {
3719            spread.remove(field);
3720        }
3721
3722        let response: OKXResponse<OKXSpread> = serde_json::from_value(payload).unwrap();
3723        let result = parse_spread_instrument(
3724            response.data.first().expect("Test data must have a spread"),
3725            None,
3726            None,
3727            None,
3728            None,
3729            UnixNanos::default(),
3730        );
3731
3732        let err = result.expect_err("invalid spread field must fail");
3733        assert!(
3734            err.to_string().contains(expected),
3735            "expected error to contain {expected:?}, was {err}"
3736        );
3737    }
3738
3739    #[rstest]
3740    fn test_parse_event_contract_instrument() {
3741        let instrument = OKXInstrument {
3742            inst_type: OKXInstrumentType::Events,
3743            inst_id: Ustr::from("BTC-ABOVE-DAILY-260224-1600-65000"),
3744            inst_id_code: Some(1000000001),
3745            uly: Ustr::from(""),
3746            inst_family: Ustr::from(""),
3747            series_id: Some(Ustr::from("BTC-ABOVE-DAILY")),
3748            inst_category: Some(OKXInstrumentCategory::Crypto),
3749            init_px_lmt_pct: String::new(),
3750            float_px_lmt_pct: String::new(),
3751            max_px_lmt_pct: String::new(),
3752            base_ccy: Ustr::from(""),
3753            quote_ccy: Ustr::from("USDT"),
3754            settle_ccy: Ustr::from("USDT"),
3755            ct_val: String::new(),
3756            ct_mult: String::new(),
3757            ct_val_ccy: String::new(),
3758            opt_type: crate::common::enums::OKXOptionType::None,
3759            stk: String::new(),
3760            list_time: Some(1769697132335),
3761            exp_time: Some(1769700732335),
3762            lever: String::new(),
3763            tick_sz: "0.001".to_string(),
3764            lot_sz: "1".to_string(),
3765            min_sz: "1".to_string(),
3766            ct_type: OKXContractType::None,
3767            state: OKXInstrumentStatus::Settling,
3768            rule_type: "normal".to_string(),
3769            max_lmt_sz: "1000000".to_string(),
3770            max_mkt_sz: "1000000".to_string(),
3771            max_lmt_amt: String::new(),
3772            max_mkt_amt: String::new(),
3773            max_twap_sz: String::new(),
3774            max_iceberg_sz: String::new(),
3775            max_trigger_sz: String::new(),
3776            max_stop_sz: String::new(),
3777            rpi: None,
3778            rpi_min_level: None,
3779            rpi_min_px_band: None,
3780        };
3781
3782        let parsed = parse_event_contract_instrument(
3783            &instrument,
3784            None,
3785            None,
3786            Some(dec!(-0.0002)),
3787            Some(dec!(-0.0005)),
3788            UnixNanos::default(),
3789        )
3790        .unwrap();
3791
3792        let InstrumentAny::BinaryOption(binary) = parsed else {
3793            panic!("Expected BinaryOption");
3794        };
3795
3796        assert_eq!(
3797            binary.id,
3798            InstrumentId::from("BTC-ABOVE-DAILY-260224-1600-65000.OKX")
3799        );
3800        assert_eq!(binary.asset_class, AssetClass::Cryptocurrency);
3801        assert_eq!(binary.currency, Currency::USDT());
3802        assert_eq!(binary.price_increment, Price::from("0.001"));
3803        assert_eq!(binary.size_increment, Quantity::from(1));
3804        assert_eq!(binary.description, Some(Ustr::from("BTC-ABOVE-DAILY")));
3805        assert_eq!(binary.maker_fee, dec!(-0.0002));
3806        assert_eq!(binary.taker_fee, dec!(-0.0005));
3807    }
3808
3809    #[rstest]
3810    fn test_parse_linear_swap_instrument() {
3811        let json_data = load_test_json("http_get_instruments_swap.json");
3812        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
3813
3814        let okx_inst = response
3815            .data
3816            .iter()
3817            .find(|i| i.inst_id == "ETH-USDT-SWAP")
3818            .expect("ETH-USDT-SWAP must be in test data");
3819
3820        let instrument =
3821            parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
3822
3823        assert_eq!(instrument.id(), InstrumentId::from("ETH-USDT-SWAP.OKX"));
3824        assert_eq!(instrument.raw_symbol(), Symbol::from("ETH-USDT-SWAP"));
3825        assert_eq!(instrument.base_currency(), Some(Currency::ETH()));
3826        assert_eq!(instrument.quote_currency(), Currency::USDT());
3827        assert_eq!(instrument.settlement_currency(), Currency::USDT());
3828        assert!(!instrument.is_inverse());
3829        assert_eq!(instrument.multiplier(), Quantity::from("0.1"));
3830        assert_eq!(instrument.price_precision(), 2);
3831        assert_eq!(instrument.size_precision(), 2);
3832        assert_eq!(instrument.price_increment(), Price::from("0.01"));
3833        assert_eq!(instrument.size_increment(), Quantity::from("0.01"));
3834        assert_eq!(instrument.lot_size(), Some(Quantity::from("0.01")));
3835        assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.01")));
3836        assert_eq!(instrument.max_quantity(), Some(Quantity::from(20000)));
3837    }
3838
3839    #[rstest]
3840    fn test_parse_inst_id_code_from_swap_instrument() {
3841        let json_data = load_test_json("http_get_instruments_swap.json");
3842        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
3843
3844        // Verify instIdCode is parsed correctly for BTC-USD-SWAP (inverse)
3845        let btc_usd_swap = response
3846            .data
3847            .iter()
3848            .find(|i| i.inst_id == "BTC-USD-SWAP")
3849            .expect("BTC-USD-SWAP must be in test data");
3850        assert_eq!(btc_usd_swap.inst_id_code, Some(10458));
3851
3852        // Verify instIdCode is parsed correctly for ETH-USDT-SWAP (linear)
3853        let eth_usdt_swap = response
3854            .data
3855            .iter()
3856            .find(|i| i.inst_id == "ETH-USDT-SWAP")
3857            .expect("ETH-USDT-SWAP must be in test data");
3858        assert_eq!(eth_usdt_swap.inst_id_code, Some(10461));
3859
3860        // Verify instIdCode is parsed correctly for BTC-USDT-SWAP
3861        let btc_usdt_swap = response
3862            .data
3863            .iter()
3864            .find(|i| i.inst_id == "BTC-USDT-SWAP")
3865            .expect("BTC-USDT-SWAP must be in test data");
3866        assert_eq!(btc_usdt_swap.inst_id_code, Some(10459));
3867    }
3868
3869    #[rstest]
3870    fn test_fee_field_selection_for_contract_types() {
3871        // Mock OKXFeeRate with different values for crypto vs USDT-margined
3872        let maker_crypto = "0.0002"; // Crypto-margined maker fee
3873        let taker_crypto = "0.0005"; // Crypto-margined taker fee
3874        let maker_usdt = "0.0008"; // USDT-margined maker fee
3875        let taker_usdt = "0.0010"; // USDT-margined taker fee
3876
3877        // Test Linear (USDT-margined) - should use maker_u/taker_u
3878        let is_usdt_margined = true;
3879        let (maker_str, taker_str) = if is_usdt_margined {
3880            (maker_usdt, taker_usdt)
3881        } else {
3882            (maker_crypto, taker_crypto)
3883        };
3884
3885        assert_eq!(maker_str, "0.0008");
3886        assert_eq!(taker_str, "0.0010");
3887
3888        let maker_fee = Decimal::from_str(maker_str).unwrap();
3889        let taker_fee = Decimal::from_str(taker_str).unwrap();
3890
3891        assert_eq!(maker_fee, dec!(0.0008));
3892        assert_eq!(taker_fee, dec!(0.0010));
3893
3894        // Test Inverse (crypto-margined) - should use maker/taker
3895        let is_usdt_margined = false;
3896        let (maker_str, taker_str) = if is_usdt_margined {
3897            (maker_usdt, taker_usdt)
3898        } else {
3899            (maker_crypto, taker_crypto)
3900        };
3901
3902        assert_eq!(maker_str, "0.0002");
3903        assert_eq!(taker_str, "0.0005");
3904
3905        let maker_fee = Decimal::from_str(maker_str).unwrap();
3906        let taker_fee = Decimal::from_str(taker_str).unwrap();
3907
3908        assert_eq!(maker_fee, dec!(0.0002));
3909        assert_eq!(taker_fee, dec!(0.0005));
3910    }
3911
3912    #[rstest]
3913    fn test_parse_futures_instrument() {
3914        let json_data = load_test_json("http_get_instruments_futures.json");
3915        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
3916        let okx_inst: &OKXInstrument = response
3917            .data
3918            .first()
3919            .expect("Test data must have an instrument");
3920
3921        let instrument =
3922            parse_futures_instrument(okx_inst, None, None, None, None, UnixNanos::default())
3923                .unwrap();
3924
3925        assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-241220.OKX"));
3926        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-241220"));
3927        assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
3928        assert_eq!(instrument.quote_currency(), Currency::USD());
3929        assert_eq!(instrument.settlement_currency(), Currency::BTC());
3930        assert!(instrument.is_inverse());
3931        assert_eq!(instrument.price_precision(), 1);
3932        assert_eq!(instrument.size_precision(), 0);
3933        assert_eq!(instrument.price_increment(), Price::from("0.1"));
3934        assert_eq!(instrument.size_increment(), Quantity::from(1));
3935        assert_eq!(instrument.multiplier(), Quantity::from(100));
3936        assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
3937        assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
3938        assert_eq!(instrument.max_quantity(), Some(Quantity::from(10000)));
3939
3940        let InstrumentAny::CryptoFuture(crypto_future) = instrument else {
3941            panic!("expected CryptoFuture, was {instrument:?}");
3942        };
3943        let info = crypto_future.info.expect("info populated for FUTURES");
3944        assert_eq!(info.get_str("rule_type"), Some("normal"));
3945    }
3946
3947    #[rstest]
3948    fn test_parse_futures_instrument_merges_price_limit_percentages_with_rule_type() {
3949        let json_data = load_test_json("http_get_instruments_futures.json");
3950        let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
3951        let okx_inst = response
3952            .data
3953            .first_mut()
3954            .expect("Test data must have an instrument");
3955        okx_inst.init_px_lmt_pct = "0.04".to_string();
3956        okx_inst.float_px_lmt_pct = "0.02".to_string();
3957        okx_inst.max_px_lmt_pct = "0.12".to_string();
3958
3959        let instrument =
3960            parse_futures_instrument(okx_inst, None, None, None, None, UnixNanos::default())
3961                .unwrap();
3962
3963        let InstrumentAny::CryptoFuture(crypto_future) = instrument else {
3964            panic!("expected CryptoFuture");
3965        };
3966        let info = crypto_future.info.expect("price-limit info must be set");
3967
3968        assert_eq!(info.get_str("rule_type"), Some("normal"));
3969        assert_eq!(info.get_str("okx_init_px_lmt_pct"), Some("0.04"));
3970        assert_eq!(info.get_str("okx_float_px_lmt_pct"), Some("0.02"));
3971        assert_eq!(info.get_str("okx_max_px_lmt_pct"), Some("0.12"));
3972        assert_eq!(crypto_future.max_price, None);
3973        assert_eq!(crypto_future.min_price, None);
3974    }
3975
3976    #[rstest]
3977    fn test_parse_futures_instrument_xperp_carries_rule_type() {
3978        // X-Perp instruments ship with the standard 3-part futures symbol
3979        // shape; the `ruleType=xperp` field is what distinguishes them from
3980        // regular dated futures.
3981        let instrument = OKXInstrument {
3982            inst_type: OKXInstrumentType::Futures,
3983            inst_id: Ustr::from("BTC-USDT-250328"),
3984            uly: Ustr::from("BTC-USDT"),
3985            inst_family: Ustr::from("BTC-USDT"),
3986            series_id: None,
3987            inst_category: None,
3988            init_px_lmt_pct: String::new(),
3989            float_px_lmt_pct: String::new(),
3990            max_px_lmt_pct: String::new(),
3991            base_ccy: Ustr::from(""),
3992            quote_ccy: Ustr::from("USDT"),
3993            settle_ccy: Ustr::from("USDT"),
3994            ct_val: "1".to_string(),
3995            ct_mult: "1".to_string(),
3996            ct_val_ccy: "USDT".to_string(),
3997            opt_type: crate::common::enums::OKXOptionType::None,
3998            stk: String::new(),
3999            list_time: Some(1_700_000_000_000),
4000            exp_time: Some(1_743_004_800_000),
4001            lever: "10".to_string(),
4002            tick_sz: "0.1".to_string(),
4003            lot_sz: "1".to_string(),
4004            min_sz: "1".to_string(),
4005            ct_type: OKXContractType::Linear,
4006            state: crate::common::enums::OKXInstrumentStatus::Live,
4007            rule_type: "xperp".to_string(),
4008            max_lmt_sz: String::new(),
4009            max_mkt_sz: String::new(),
4010            max_lmt_amt: String::new(),
4011            max_mkt_amt: String::new(),
4012            max_twap_sz: String::new(),
4013            max_iceberg_sz: String::new(),
4014            max_trigger_sz: String::new(),
4015            max_stop_sz: String::new(),
4016            inst_id_code: None,
4017            rpi: None,
4018            rpi_min_level: None,
4019            rpi_min_px_band: None,
4020        };
4021
4022        let parsed =
4023            parse_futures_instrument(&instrument, None, None, None, None, UnixNanos::default())
4024                .expect("parses synthetic X-Perp instrument");
4025
4026        let InstrumentAny::CryptoFuture(crypto_future) = parsed else {
4027            panic!("expected CryptoFuture for X-Perp");
4028        };
4029        let info = crypto_future.info.expect("info populated for X-Perp");
4030        assert_eq!(info.get_str("rule_type"), Some("xperp"));
4031        assert!(is_xperp_rule_type("xperp"));
4032        assert!(is_xperp_rule_type("XPERP"));
4033        assert!(!is_xperp_rule_type("normal"));
4034    }
4035
4036    #[rstest]
4037    fn test_parse_option_instrument() {
4038        let json_data = load_test_json("http_get_instruments_option.json");
4039        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
4040        let okx_inst: &OKXInstrument = response
4041            .data
4042            .first()
4043            .expect("Test data must have an instrument");
4044
4045        let instrument =
4046            parse_option_instrument(okx_inst, None, None, None, None, UnixNanos::default())
4047                .unwrap();
4048
4049        assert_eq!(
4050            instrument.id(),
4051            InstrumentId::from("BTC-USD-241217-92000-C.OKX")
4052        );
4053        assert_eq!(
4054            instrument.raw_symbol(),
4055            Symbol::from("BTC-USD-241217-92000-C")
4056        );
4057        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
4058        assert_eq!(instrument.quote_currency(), Currency::USD());
4059        assert_eq!(instrument.settlement_currency(), Currency::BTC());
4060        assert!(instrument.is_inverse());
4061        assert_eq!(instrument.price_precision(), 4);
4062        assert_eq!(instrument.size_precision(), 0);
4063        assert_eq!(instrument.price_increment(), Price::from("0.0001"));
4064        assert_eq!(instrument.size_increment(), Quantity::from(1));
4065        assert_eq!(instrument.multiplier(), Quantity::from("0.01"));
4066        assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
4067        assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
4068        assert_eq!(instrument.max_quantity(), Some(Quantity::from(5000)));
4069        assert_eq!(instrument.max_notional(), None);
4070        assert_eq!(instrument.min_notional(), None);
4071        assert_eq!(instrument.max_price(), None);
4072        assert_eq!(instrument.min_price(), None);
4073    }
4074
4075    #[rstest]
4076    fn test_parse_account_state() {
4077        let json_data = load_test_json("http_get_account_balance.json");
4078        let response: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
4079        let okx_account = response
4080            .data
4081            .first()
4082            .expect("Test data must have an account");
4083
4084        let account_id = AccountId::new("OKX-001");
4085        let account_state =
4086            parse_account_state(okx_account, account_id, UnixNanos::default()).unwrap();
4087
4088        assert_eq!(account_state.account_id, account_id);
4089        assert_eq!(account_state.account_type, AccountType::Margin);
4090        assert_eq!(account_state.balances.len(), 1);
4091        assert_eq!(account_state.margins.len(), 0); // No margins in this test data (spot account)
4092        assert!(account_state.is_reported);
4093
4094        // Check the USDT balance details
4095        let usdt_balance = &account_state.balances[0];
4096        assert_eq!(
4097            usdt_balance.total,
4098            Money::new(94.42612990333333, Currency::USDT())
4099        );
4100        assert_eq!(
4101            usdt_balance.free,
4102            Money::new(94.42612990333333, Currency::USDT())
4103        );
4104        assert_eq!(usdt_balance.locked, Money::new(0.0, Currency::USDT()));
4105    }
4106
4107    #[rstest]
4108    fn test_parse_account_state_with_margins() {
4109        // Create test data with margin requirements
4110        let account_json = r#"{
4111            "adjEq": "10000.0",
4112            "borrowFroz": "0",
4113            "details": [{
4114                "accAvgPx": "",
4115                "availBal": "8000.0",
4116                "availEq": "8000.0",
4117                "borrowFroz": "0",
4118                "cashBal": "10000.0",
4119                "ccy": "USDT",
4120                "clSpotInUseAmt": "0",
4121                "coinUsdPrice": "1.0",
4122                "colBorrAutoConversion": "0",
4123                "collateralEnabled": false,
4124                "collateralRestrict": false,
4125                "crossLiab": "0",
4126                "disEq": "10000.0",
4127                "eq": "10000.0",
4128                "eqUsd": "10000.0",
4129                "fixedBal": "0",
4130                "frozenBal": "2000.0",
4131                "imr": "0",
4132                "interest": "0",
4133                "isoEq": "0",
4134                "isoLiab": "0",
4135                "isoUpl": "0",
4136                "liab": "0",
4137                "maxLoan": "0",
4138                "mgnRatio": "0",
4139                "maxSpotInUseAmt": "0",
4140                "mmr": "0",
4141                "notionalLever": "0",
4142                "openAvgPx": "",
4143                "ordFrozen": "2000.0",
4144                "rewardBal": "0",
4145                "smtSyncEq": "0",
4146                "spotBal": "0",
4147                "spotCopyTradingEq": "0",
4148                "spotInUseAmt": "0",
4149                "spotIsoBal": "0",
4150                "spotUpl": "0",
4151                "spotUplRatio": "0",
4152                "stgyEq": "0",
4153                "totalPnl": "0",
4154                "totalPnlRatio": "0",
4155                "twap": "0",
4156                "uTime": "1704067200000",
4157                "upl": "0",
4158                "uplLiab": "0"
4159            }],
4160            "imr": "500.25",
4161            "isoEq": "0",
4162            "mgnRatio": "20.5",
4163            "mmr": "250.75",
4164            "notionalUsd": "5000.0",
4165            "notionalUsdForBorrow": "0",
4166            "notionalUsdForFutures": "0",
4167            "notionalUsdForOption": "0",
4168            "notionalUsdForSwap": "5000.0",
4169            "ordFroz": "2000.0",
4170            "totalEq": "10000.0",
4171            "uTime": "1704067200000",
4172            "upl": "0"
4173        }"#;
4174
4175        let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
4176        let account_id = AccountId::new("OKX-001");
4177        let account_state =
4178            parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
4179
4180        // Verify account details
4181        assert_eq!(account_state.account_id, account_id);
4182        assert_eq!(account_state.account_type, AccountType::Margin);
4183        assert_eq!(account_state.balances.len(), 1);
4184
4185        // Verify margin information was parsed
4186        assert_eq!(account_state.margins.len(), 1);
4187        let margin = &account_state.margins[0];
4188
4189        // Check margin values
4190        assert_eq!(margin.initial, Money::new(500.25, Currency::USD()));
4191        assert_eq!(margin.maintenance, Money::new(250.75, Currency::USD()));
4192        assert_eq!(margin.currency, Currency::USD());
4193        assert!(margin.instrument_id.is_none());
4194
4195        // Check the USDT balance details
4196        let usdt_balance = &account_state.balances[0];
4197        assert_eq!(usdt_balance.total, Money::new(10000.0, Currency::USDT()));
4198        assert_eq!(usdt_balance.free, Money::new(8000.0, Currency::USDT()));
4199        assert_eq!(usdt_balance.locked, Money::new(2000.0, Currency::USDT()));
4200    }
4201
4202    #[rstest]
4203    fn test_parse_account_state_empty_margins() {
4204        // Create test data with empty margin strings (common for spot accounts)
4205        let account_json = r#"{
4206            "adjEq": "",
4207            "borrowFroz": "",
4208            "details": [{
4209                "accAvgPx": "",
4210                "availBal": "1000.0",
4211                "availEq": "1000.0",
4212                "borrowFroz": "0",
4213                "cashBal": "1000.0",
4214                "ccy": "BTC",
4215                "clSpotInUseAmt": "0",
4216                "coinUsdPrice": "50000.0",
4217                "colBorrAutoConversion": "0",
4218                "collateralEnabled": false,
4219                "collateralRestrict": false,
4220                "crossLiab": "0",
4221                "disEq": "50000.0",
4222                "eq": "1000.0",
4223                "eqUsd": "50000.0",
4224                "fixedBal": "0",
4225                "frozenBal": "0",
4226                "imr": "0",
4227                "interest": "0",
4228                "isoEq": "0",
4229                "isoLiab": "0",
4230                "isoUpl": "0",
4231                "liab": "0",
4232                "maxLoan": "0",
4233                "mgnRatio": "0",
4234                "maxSpotInUseAmt": "0",
4235                "mmr": "0",
4236                "notionalLever": "0",
4237                "openAvgPx": "",
4238                "ordFrozen": "0",
4239                "rewardBal": "0",
4240                "smtSyncEq": "0",
4241                "spotBal": "0",
4242                "spotCopyTradingEq": "0",
4243                "spotInUseAmt": "0",
4244                "spotIsoBal": "0",
4245                "spotUpl": "0",
4246                "spotUplRatio": "0",
4247                "stgyEq": "0",
4248                "totalPnl": "0",
4249                "totalPnlRatio": "0",
4250                "twap": "0",
4251                "uTime": "1704067200000",
4252                "upl": "0",
4253                "uplLiab": "0"
4254            }],
4255            "imr": "",
4256            "isoEq": "0",
4257            "mgnRatio": "",
4258            "mmr": "",
4259            "notionalUsd": "",
4260            "notionalUsdForBorrow": "",
4261            "notionalUsdForFutures": "",
4262            "notionalUsdForOption": "",
4263            "notionalUsdForSwap": "",
4264            "ordFroz": "",
4265            "totalEq": "50000.0",
4266            "uTime": "1704067200000",
4267            "upl": "0"
4268        }"#;
4269
4270        let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
4271        let account_id = AccountId::new("OKX-SPOT");
4272        let account_state =
4273            parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
4274
4275        // Verify no margins are created when fields are empty
4276        assert_eq!(account_state.margins.len(), 0);
4277        assert_eq!(account_state.balances.len(), 1);
4278
4279        // Check the BTC balance
4280        let btc_balance = &account_state.balances[0];
4281        assert_eq!(btc_balance.total, Money::new(1000.0, Currency::BTC()));
4282    }
4283
4284    #[rstest]
4285    fn test_parse_account_state_empty_balance_account() {
4286        // Reproduces GH-3772: OKX returns empty strings for numeric fields
4287        // when the account has zero balance and no positions
4288        let account_json = r#"{
4289            "adjEq": "",
4290            "borrowFroz": "",
4291            "details": [],
4292            "imr": "",
4293            "isoEq": "0",
4294            "mgnRatio": "",
4295            "mmr": "",
4296            "notionalUsd": "",
4297            "notionalUsdForBorrow": "",
4298            "notionalUsdForFutures": "",
4299            "notionalUsdForOption": "",
4300            "notionalUsdForSwap": "",
4301            "ordFroz": "",
4302            "totalEq": "0",
4303            "uTime": "1774795570586",
4304            "upl": ""
4305        }"#;
4306
4307        let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
4308        let account_id = AccountId::new("OKX-001");
4309        let account_state =
4310            parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
4311
4312        assert_eq!(account_state.account_id, account_id);
4313        assert_eq!(account_state.account_type, AccountType::Margin);
4314        assert_eq!(account_state.margins.len(), 0);
4315
4316        assert_eq!(account_state.balances.len(), 1);
4317        let balance = &account_state.balances[0];
4318        assert_eq!(balance.total, Money::new(0.0, Currency::USD()));
4319        assert_eq!(balance.free, Money::new(0.0, Currency::USD()));
4320        assert_eq!(balance.locked, Money::new(0.0, Currency::USD()));
4321    }
4322
4323    #[rstest]
4324    fn test_parse_order_status_report() {
4325        let json_data = load_test_json("http_get_orders_history.json");
4326        let response: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
4327        let okx_order = response
4328            .data
4329            .first()
4330            .expect("Test data must have an order")
4331            .clone();
4332
4333        let account_id = AccountId::new("OKX-001");
4334        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4335        let order_report = parse_order_status_report(
4336            &okx_order,
4337            account_id,
4338            instrument_id,
4339            2,
4340            8,
4341            UnixNanos::default(),
4342        )
4343        .unwrap();
4344
4345        assert_eq!(order_report.account_id, account_id);
4346        assert_eq!(order_report.instrument_id, instrument_id);
4347        assert_eq!(order_report.quantity, Quantity::from("0.03000000"));
4348        assert_eq!(order_report.filled_qty, Quantity::from("0.03000000"));
4349        assert_eq!(order_report.order_side, OrderSide::Buy.into());
4350        assert_eq!(order_report.order_type, OrderType::Market);
4351        assert_eq!(order_report.order_status, OrderStatus::Filled);
4352    }
4353
4354    #[rstest]
4355    fn test_parse_triggered_order_history_preserves_parent_identity() {
4356        let json_data = load_test_json("http_get_orders_history.json");
4357        let response: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
4358        let mut okx_order = response
4359            .data
4360            .first()
4361            .expect("Test data must have an order")
4362            .clone();
4363        okx_order.cl_ord_id = Ustr::from("706620792746729474_0");
4364        okx_order.algo_cl_ord_id = Some(Ustr::from("STOP003BTCUSDT20250120"));
4365        okx_order.ord_id = Ustr::from("706620792746729999");
4366
4367        let order_report = parse_order_status_report(
4368            &okx_order,
4369            AccountId::new("OKX-001"),
4370            InstrumentId::from("BTC-USDT-SWAP.OKX"),
4371            2,
4372            8,
4373            UnixNanos::default(),
4374        )
4375        .unwrap();
4376
4377        assert_eq!(
4378            order_report.client_order_id,
4379            Some(ClientOrderId::from("STOP003BTCUSDT20250120"))
4380        );
4381        assert_eq!(
4382            order_report.venue_order_id,
4383            VenueOrderId::from("706620792746729999")
4384        );
4385    }
4386
4387    #[rstest]
4388    fn test_parse_position_status_report() {
4389        let json_data = load_test_json("http_get_positions.json");
4390        let response: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
4391        let okx_position = response
4392            .data
4393            .first()
4394            .expect("Test data must have a position")
4395            .clone();
4396
4397        let account_id = AccountId::new("OKX-001");
4398        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
4399        let position_report = parse_position_status_report(
4400            &okx_position,
4401            account_id,
4402            instrument_id,
4403            8,
4404            UnixNanos::default(),
4405        )
4406        .unwrap();
4407
4408        assert_eq!(position_report.account_id, account_id);
4409        assert_eq!(position_report.instrument_id, instrument_id);
4410    }
4411
4412    #[rstest]
4413    fn test_parse_trade_tick() {
4414        let json_data = load_test_json("http_get_trades.json");
4415        let response: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
4416        let okx_trade = response.data.first().expect("Test data must have a trade");
4417
4418        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
4419        let trade_tick =
4420            parse_trade_tick(okx_trade, instrument_id, 2, 8, UnixNanos::default()).unwrap();
4421
4422        assert_eq!(trade_tick.instrument_id, instrument_id);
4423        assert_eq!(trade_tick.price, Price::from("102537.90"));
4424        assert_eq!(trade_tick.size, Quantity::from("0.00013669"));
4425        assert_eq!(trade_tick.aggressor_side, AggressorSide::Sell);
4426        assert_eq!(trade_tick.trade_id, TradeId::new("734864333"));
4427    }
4428
4429    #[rstest]
4430    fn test_parse_mark_price_update() {
4431        let json_data = load_test_json("http_get_mark_price.json");
4432        let response: OKXResponse<crate::http::models::OKXMarkPrice> =
4433            serde_json::from_str(&json_data).unwrap();
4434        let okx_mark_price = response
4435            .data
4436            .first()
4437            .expect("Test data must have a mark price");
4438
4439        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4440        let mark_price_update =
4441            parse_mark_price_update(okx_mark_price, instrument_id, 2, UnixNanos::default())
4442                .unwrap();
4443
4444        assert_eq!(mark_price_update.instrument_id, instrument_id);
4445        assert_eq!(mark_price_update.value, Price::from("84660.10"));
4446        assert_eq!(
4447            mark_price_update.ts_event,
4448            UnixNanos::from(1744590349506000000)
4449        );
4450    }
4451
4452    #[rstest]
4453    fn test_parse_index_price_update() {
4454        let json_data = load_test_json("http_get_index_price.json");
4455        let response: OKXResponse<crate::http::models::OKXIndexTicker> =
4456            serde_json::from_str(&json_data).unwrap();
4457        let okx_index_ticker = response
4458            .data
4459            .first()
4460            .expect("Test data must have an index ticker");
4461
4462        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
4463        let index_price_update =
4464            parse_index_price_update(okx_index_ticker, instrument_id, 2, UnixNanos::default())
4465                .unwrap();
4466
4467        assert_eq!(index_price_update.instrument_id, instrument_id);
4468        assert_eq!(index_price_update.value, Price::from("103895.00"));
4469        assert_eq!(
4470            index_price_update.ts_event,
4471            UnixNanos::from(1746942707815000000)
4472        );
4473    }
4474
4475    #[rstest]
4476    fn test_parse_candlestick() {
4477        let json_data = load_test_json("http_get_candlesticks.json");
4478        let response: OKXResponse<crate::http::models::OKXCandlestick> =
4479            serde_json::from_str(&json_data).unwrap();
4480        let okx_candlestick = response
4481            .data
4482            .first()
4483            .expect("Test data must have a candlestick");
4484
4485        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
4486        let bar_type = BarType::new(
4487            instrument_id,
4488            BAR_SPEC_1_DAY_LAST,
4489            AggregationSource::External,
4490        );
4491        let bar = parse_candlestick(okx_candlestick, bar_type, 2, 8, UnixNanos::default()).unwrap();
4492
4493        assert_eq!(bar.bar_type, bar_type);
4494        assert_eq!(bar.open, Price::from("33528.60"));
4495        assert_eq!(bar.high, Price::from("33870.00"));
4496        assert_eq!(bar.low, Price::from("33528.60"));
4497        assert_eq!(bar.close, Price::from("33783.90"));
4498        assert_eq!(bar.volume, Quantity::from("778.83800000"));
4499        assert_eq!(bar.ts_event, UnixNanos::from(1625097600000000000));
4500    }
4501
4502    #[rstest]
4503    fn test_parse_millisecond_timestamp() {
4504        let timestamp_ms = 1625097600000u64;
4505        let result = parse_millisecond_timestamp(timestamp_ms);
4506        assert_eq!(result, UnixNanos::from(1625097600000000000));
4507    }
4508
4509    #[rstest]
4510    fn test_parse_rfc3339_timestamp() {
4511        let timestamp_str = "2021-07-01T00:00:00.000Z";
4512        let result = parse_rfc3339_timestamp(timestamp_str).unwrap();
4513        assert_eq!(result, UnixNanos::from(1625097600000000000));
4514
4515        // Test with timezone
4516        let timestamp_str_tz = "2021-07-01T08:00:00.000+08:00";
4517        let result_tz = parse_rfc3339_timestamp(timestamp_str_tz).unwrap();
4518        assert_eq!(result_tz, UnixNanos::from(1625097600000000000));
4519
4520        // Test error case
4521        let invalid_timestamp = "invalid-timestamp";
4522        parse_rfc3339_timestamp(invalid_timestamp).unwrap_err();
4523    }
4524
4525    #[rstest]
4526    fn test_parse_price() {
4527        let price_str = "42219.5";
4528        let precision = 2;
4529        let result = parse_price(price_str, precision).unwrap();
4530        assert_eq!(result, Price::from("42219.50"));
4531
4532        // Test error case
4533        let invalid_price = "invalid-price";
4534        parse_price(invalid_price, precision).unwrap_err();
4535    }
4536
4537    #[rstest]
4538    fn test_parse_quantity() {
4539        let quantity_str = "0.12345678";
4540        let precision = 8;
4541        let result = parse_quantity(quantity_str, precision).unwrap();
4542        assert_eq!(result, Quantity::from("0.12345678"));
4543
4544        // Test error case
4545        let invalid_quantity = "invalid-quantity";
4546        parse_quantity(invalid_quantity, precision).unwrap_err();
4547    }
4548
4549    #[rstest]
4550    fn test_parse_aggressor_side() {
4551        assert_eq!(
4552            parse_aggressor_side(&Some(OKXSide::Buy)),
4553            AggressorSide::Buy
4554        );
4555        assert_eq!(
4556            parse_aggressor_side(&Some(OKXSide::Sell)),
4557            AggressorSide::Sell
4558        );
4559        assert_eq!(parse_aggressor_side(&None), AggressorSide::NoAggressor);
4560    }
4561
4562    #[rstest]
4563    fn test_parse_execution_type() {
4564        assert_eq!(
4565            parse_execution_type(&Some(OKXExecType::Maker)),
4566            LiquiditySide::Maker
4567        );
4568        assert_eq!(
4569            parse_execution_type(&Some(OKXExecType::Taker)),
4570            LiquiditySide::Taker
4571        );
4572        assert_eq!(parse_execution_type(&None), LiquiditySide::NoLiquiditySide);
4573    }
4574
4575    #[rstest]
4576    fn test_parse_position_side() {
4577        assert_eq!(parse_position_side(Some(100)), PositionSide::Long);
4578        assert_eq!(parse_position_side(Some(-100)), PositionSide::Short);
4579        assert_eq!(parse_position_side(Some(0)), PositionSide::Flat);
4580        assert_eq!(parse_position_side(None), PositionSide::Flat);
4581    }
4582
4583    #[rstest]
4584    fn test_parse_client_order_id() {
4585        let valid_id = "client_order_123";
4586        let result = parse_client_order_id(valid_id);
4587        assert_eq!(result, Some(ClientOrderId::new(valid_id)));
4588
4589        let empty_id = "";
4590        let result_empty = parse_client_order_id(empty_id);
4591        assert_eq!(result_empty, None);
4592    }
4593
4594    #[rstest]
4595    fn test_deserialize_empty_string_as_none() {
4596        let json_with_empty = r#""""#;
4597        let result: Option<String> = serde_json::from_str(json_with_empty).unwrap();
4598        let processed = result.filter(|s| !s.is_empty());
4599        assert_eq!(processed, None);
4600
4601        let json_with_value = r#""test_value""#;
4602        let result: Option<String> = serde_json::from_str(json_with_value).unwrap();
4603        let processed = result.filter(|s| !s.is_empty());
4604        assert_eq!(processed, Some("test_value".to_string()));
4605    }
4606
4607    #[rstest]
4608    fn test_deserialize_string_to_u64() {
4609        use serde::Deserialize;
4610
4611        #[derive(Deserialize)]
4612        struct TestStruct {
4613            #[serde(deserialize_with = "deserialize_string_to_u64")]
4614            value: u64,
4615        }
4616
4617        let json_value = r#"{"value": "12345"}"#;
4618        let result: TestStruct = serde_json::from_str(json_value).unwrap();
4619        assert_eq!(result.value, 12345);
4620
4621        let json_empty = r#"{"value": ""}"#;
4622        let result_empty: TestStruct = serde_json::from_str(json_empty).unwrap();
4623        assert_eq!(result_empty.value, 0);
4624    }
4625
4626    #[rstest]
4627    fn test_fill_report_parsing() {
4628        // Create a mock transaction detail for testing
4629        let transaction_detail = crate::http::models::OKXTransactionDetail {
4630            inst_type: OKXInstrumentType::Spot,
4631            inst_id: Ustr::from("BTC-USDT"),
4632            trade_id: Ustr::from("12345"),
4633            ord_id: Ustr::from("67890"),
4634            cl_ord_id: Ustr::from("client_123"),
4635            bill_id: Ustr::from("bill_456"),
4636            fill_px: "42219.5".to_string(),
4637            fill_sz: "0.001".to_string(),
4638            side: OKXSide::Buy,
4639            exec_type: OKXExecType::Taker,
4640            fee_ccy: "USDT".to_string(),
4641            fee: Some("0.042".to_string()),
4642            ts: 1625097600000,
4643        };
4644
4645        let account_id = AccountId::new("OKX-001");
4646        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
4647        let fill_report = parse_fill_report(
4648            &transaction_detail,
4649            account_id,
4650            instrument_id,
4651            2,
4652            8,
4653            UnixNanos::default(),
4654        )
4655        .unwrap();
4656
4657        assert_eq!(fill_report.account_id, account_id);
4658        assert_eq!(fill_report.instrument_id, instrument_id);
4659        assert_eq!(fill_report.trade_id, TradeId::new("12345"));
4660        assert_eq!(fill_report.venue_order_id, VenueOrderId::new("67890"));
4661        assert_eq!(fill_report.order_side, OrderSide::Buy);
4662        assert_eq!(fill_report.last_px, Price::from("42219.50"));
4663        assert_eq!(fill_report.last_qty, Quantity::from("0.00100000"));
4664        assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
4665        assert_eq!(
4666            fill_report.commission,
4667            Money::from_decimal(dec!(-0.042), Currency::USDT()).unwrap()
4668        );
4669    }
4670
4671    #[rstest]
4672    fn test_parse_fee_rejects_missing_or_empty() {
4673        let currency = Currency::USDT();
4674
4675        let missing = parse_fee(None, currency).unwrap_err();
4676        assert!(missing.to_string().contains("missing fee"));
4677
4678        let empty = parse_fee(Some(""), currency).unwrap_err();
4679        assert!(empty.to_string().contains("missing fee"));
4680
4681        let blank = parse_fee(Some("   "), currency).unwrap_err();
4682        assert!(blank.to_string().contains("missing fee"));
4683    }
4684
4685    #[rstest]
4686    fn test_parse_fill_report_rejects_missing_fee() {
4687        let json_data = load_test_json("http_transaction_detail_empty_fee.json");
4688        let detail: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
4689        let error = parse_fill_report(
4690            &detail,
4691            AccountId::new("OKX-001"),
4692            InstrumentId::from("BTC-USDT.OKX"),
4693            2,
4694            8,
4695            UnixNanos::default(),
4696        )
4697        .unwrap_err();
4698
4699        let message = format!("{error:#}");
4700        assert!(message.contains("missing fee"), "was {message}");
4701    }
4702
4703    #[rstest]
4704    fn test_parse_spread_fill_report_rejects_missing_fee() {
4705        let detail = OKXSpreadTrade {
4706            sprd_id: Ustr::from("ETH-USD-SWAP_ETH-USD-231229"),
4707            trade_id: Ustr::from("9001"),
4708            ord_id: Ustr::from("12345"),
4709            cl_ord_id: Ustr::from("O-spread-entry"),
4710            fill_px: "1.20".to_string(),
4711            fill_sz: "5".to_string(),
4712            side: OKXSide::Buy,
4713            exec_type: OKXExecType::Taker,
4714            fee_ccy: "USDT".to_string(),
4715            fee: None,
4716            ts: 1_700_000_001_000,
4717        };
4718        let error = parse_spread_fill_report(
4719            &detail,
4720            AccountId::new("OKX-001"),
4721            InstrumentId::from("ETH-USD-SWAP_ETH-USD-231229.OKX"),
4722            2,
4723            0,
4724            UnixNanos::default(),
4725        )
4726        .unwrap_err();
4727
4728        let message = format!("{error:#}");
4729        assert!(message.contains("missing fee"), "was {message}");
4730    }
4731
4732    #[rstest]
4733    fn test_bar_type_identity_preserved_through_parse() {
4734        use std::str::FromStr;
4735
4736        use crate::http::models::OKXCandlestick;
4737
4738        // Create a BarType
4739        let bar_type = BarType::from_str("ETH-USDT-SWAP.OKX-1-MINUTE-LAST-EXTERNAL").unwrap();
4740
4741        // Create sample candlestick data
4742        let raw_candlestick = OKXCandlestick(
4743            "1721807460000".to_string(), // timestamp
4744            "3177.9".to_string(),        // open
4745            "3177.9".to_string(),        // high
4746            "3177.7".to_string(),        // low
4747            "3177.8".to_string(),        // close
4748            "18.603".to_string(),        // volume
4749            "59054.8231".to_string(),    // turnover
4750            "18.603".to_string(),        // base_volume
4751            "1".to_string(),             // count
4752        );
4753
4754        // Parse the candlestick
4755        let bar =
4756            parse_candlestick(&raw_candlestick, bar_type, 1, 3, UnixNanos::default()).unwrap();
4757
4758        // Verify that the BarType is preserved exactly
4759        assert_eq!(
4760            bar.bar_type, bar_type,
4761            "BarType must be preserved exactly through parsing"
4762        );
4763    }
4764
4765    #[rstest]
4766    fn test_deserialize_vip_level_all_formats() {
4767        use serde::Deserialize;
4768        use serde_json;
4769
4770        #[derive(Deserialize)]
4771        struct TestFeeRate {
4772            #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
4773            level: OKXVipLevel,
4774        }
4775
4776        // Test VIP prefix format
4777        let json = r#"{"level":"VIP4"}"#;
4778        let result: TestFeeRate = serde_json::from_str(json).unwrap();
4779        assert_eq!(result.level, OKXVipLevel::Vip4);
4780
4781        let json = r#"{"level":"VIP5"}"#;
4782        let result: TestFeeRate = serde_json::from_str(json).unwrap();
4783        assert_eq!(result.level, OKXVipLevel::Vip5);
4784
4785        // Test Lv prefix format
4786        let json = r#"{"level":"Lv1"}"#;
4787        let result: TestFeeRate = serde_json::from_str(json).unwrap();
4788        assert_eq!(result.level, OKXVipLevel::Vip1);
4789
4790        let json = r#"{"level":"Lv0"}"#;
4791        let result: TestFeeRate = serde_json::from_str(json).unwrap();
4792        assert_eq!(result.level, OKXVipLevel::Vip0);
4793
4794        let json = r#"{"level":"Lv9"}"#;
4795        let result: TestFeeRate = serde_json::from_str(json).unwrap();
4796        assert_eq!(result.level, OKXVipLevel::Vip9);
4797    }
4798
4799    #[rstest]
4800    fn test_deserialize_vip_level_empty_string() {
4801        use serde::Deserialize;
4802        use serde_json;
4803
4804        #[derive(Deserialize)]
4805        struct TestFeeRate {
4806            #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
4807            level: OKXVipLevel,
4808        }
4809
4810        // Empty string should default to VIP0
4811        let json = r#"{"level":""}"#;
4812        let result: TestFeeRate = serde_json::from_str(json).unwrap();
4813        assert_eq!(result.level, OKXVipLevel::Vip0);
4814    }
4815
4816    #[rstest]
4817    fn test_deserialize_vip_level_without_prefix() {
4818        use serde::Deserialize;
4819        use serde_json;
4820
4821        #[derive(Deserialize)]
4822        struct TestFeeRate {
4823            #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
4824            level: OKXVipLevel,
4825        }
4826
4827        let json = r#"{"level":"5"}"#;
4828        let result: TestFeeRate = serde_json::from_str(json).unwrap();
4829        assert_eq!(result.level, OKXVipLevel::Vip5);
4830    }
4831
4832    #[rstest]
4833    fn test_parse_position_status_report_net_mode_long() {
4834        // Test Net mode: positive quantity = Long position
4835        let position = OKXPosition {
4836            inst_id: Ustr::from("BTC-USDT-SWAP"),
4837            inst_type: OKXInstrumentType::Swap,
4838            mgn_mode: OKXMarginMode::Cross,
4839            pos_id: Some(Ustr::from("12345")),
4840            pos_side: OKXPositionSide::Net, // Net mode
4841            pos: "1.5".to_string(),         // Positive = Long
4842            base_bal: "1.5".to_string(),
4843            ccy: "BTC".to_string(),
4844            fee: "0.01".to_string(),
4845            lever: "10.0".to_string(),
4846            last: "50000".to_string(),
4847            mark_px: "50000".to_string(),
4848            liq_px: "45000".to_string(),
4849            mmr: "0.1".to_string(),
4850            interest: "0".to_string(),
4851            trade_id: Ustr::from("111"),
4852            notional_usd: "75000".to_string(),
4853            avg_px: "50000".to_string(),
4854            upl: "0".to_string(),
4855            upl_ratio: "0".to_string(),
4856            u_time: 1622559930237,
4857            margin: "0.5".to_string(),
4858            mgn_ratio: "0.01".to_string(),
4859            adl: "0".to_string(),
4860            c_time: "1622559930237".to_string(),
4861            realized_pnl: "0".to_string(),
4862            upl_last_px: "0".to_string(),
4863            upl_ratio_last_px: "0".to_string(),
4864            avail_pos: "1.5".to_string(),
4865            be_px: "0".to_string(),
4866            funding_fee: "0".to_string(),
4867            idx_px: "0".to_string(),
4868            liq_penalty: "0".to_string(),
4869            opt_val: "0".to_string(),
4870            pending_close_ord_liab_val: "0".to_string(),
4871            pnl: "0".to_string(),
4872            pos_ccy: "BTC".to_string(),
4873            quote_bal: "75000".to_string(),
4874            quote_borrowed: "0".to_string(),
4875            quote_interest: "0".to_string(),
4876            spot_in_use_amt: "0".to_string(),
4877            spot_in_use_ccy: "BTC".to_string(),
4878            usd_px: "50000".to_string(),
4879            delta_bs: String::new(),
4880            gamma_bs: String::new(),
4881            theta_bs: String::new(),
4882            vega_bs: String::new(),
4883        };
4884
4885        let account_id = AccountId::new("OKX-001");
4886        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4887        let report = parse_position_status_report(
4888            &position,
4889            account_id,
4890            instrument_id,
4891            8,
4892            UnixNanos::default(),
4893        )
4894        .unwrap();
4895
4896        assert_eq!(report.account_id, account_id);
4897        assert_eq!(report.instrument_id, instrument_id);
4898        assert_eq!(report.position_side, PositionSide::Long);
4899        assert_eq!(report.quantity, Quantity::from("1.5"));
4900        // Net mode: venue_position_id is None (signals NETTING OMS)
4901        assert_eq!(report.venue_position_id, None);
4902    }
4903
4904    #[rstest]
4905    fn test_parse_position_status_report_net_mode_short() {
4906        // Test Net mode: negative quantity = Short position
4907        let position = OKXPosition {
4908            inst_id: Ustr::from("BTC-USDT-SWAP"),
4909            inst_type: OKXInstrumentType::Swap,
4910            mgn_mode: OKXMarginMode::Isolated,
4911            pos_id: Some(Ustr::from("67890")),
4912            pos_side: OKXPositionSide::Net, // Net mode
4913            pos: "-2.3".to_string(),        // Negative = Short
4914            base_bal: "2.3".to_string(),
4915            ccy: "BTC".to_string(),
4916            fee: "0.02".to_string(),
4917            lever: "5.0".to_string(),
4918            last: "50000".to_string(),
4919            mark_px: "50000".to_string(),
4920            liq_px: "55000".to_string(),
4921            mmr: "0.2".to_string(),
4922            interest: "0".to_string(),
4923            trade_id: Ustr::from("222"),
4924            notional_usd: "115000".to_string(),
4925            avg_px: "50000".to_string(),
4926            upl: "0".to_string(),
4927            upl_ratio: "0".to_string(),
4928            u_time: 1622559930237,
4929            margin: "1.0".to_string(),
4930            mgn_ratio: "0.02".to_string(),
4931            adl: "0".to_string(),
4932            c_time: "1622559930237".to_string(),
4933            realized_pnl: "0".to_string(),
4934            upl_last_px: "0".to_string(),
4935            upl_ratio_last_px: "0".to_string(),
4936            avail_pos: "2.3".to_string(),
4937            be_px: "0".to_string(),
4938            funding_fee: "0".to_string(),
4939            idx_px: "0".to_string(),
4940            liq_penalty: "0".to_string(),
4941            opt_val: "0".to_string(),
4942            pending_close_ord_liab_val: "0".to_string(),
4943            pnl: "0".to_string(),
4944            pos_ccy: "BTC".to_string(),
4945            quote_bal: "115000".to_string(),
4946            quote_borrowed: "0".to_string(),
4947            quote_interest: "0".to_string(),
4948            spot_in_use_amt: "0".to_string(),
4949            spot_in_use_ccy: "BTC".to_string(),
4950            usd_px: "50000".to_string(),
4951            delta_bs: String::new(),
4952            gamma_bs: String::new(),
4953            theta_bs: String::new(),
4954            vega_bs: String::new(),
4955        };
4956
4957        let account_id = AccountId::new("OKX-001");
4958        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4959        let report = parse_position_status_report(
4960            &position,
4961            account_id,
4962            instrument_id,
4963            8,
4964            UnixNanos::default(),
4965        )
4966        .unwrap();
4967
4968        assert_eq!(report.account_id, account_id);
4969        assert_eq!(report.instrument_id, instrument_id);
4970        assert_eq!(report.position_side, PositionSide::Short);
4971        assert_eq!(report.quantity, Quantity::from("2.3")); // Absolute value
4972        // Net mode: venue_position_id is None (signals NETTING OMS)
4973        assert_eq!(report.venue_position_id, None);
4974    }
4975
4976    #[rstest]
4977    fn test_parse_position_status_report_net_mode_flat() {
4978        // Test Net mode: zero quantity = Flat position
4979        let position = OKXPosition {
4980            inst_id: Ustr::from("ETH-USDT-SWAP"),
4981            inst_type: OKXInstrumentType::Swap,
4982            mgn_mode: OKXMarginMode::Cross,
4983            pos_id: Some(Ustr::from("99999")),
4984            pos_side: OKXPositionSide::Net, // Net mode
4985            pos: "0".to_string(),           // Zero = Flat
4986            base_bal: "0".to_string(),
4987            ccy: "ETH".to_string(),
4988            fee: "0".to_string(),
4989            lever: "10.0".to_string(),
4990            last: "3000".to_string(),
4991            mark_px: "3000".to_string(),
4992            liq_px: "0".to_string(),
4993            mmr: "0".to_string(),
4994            interest: "0".to_string(),
4995            trade_id: Ustr::from("333"),
4996            notional_usd: "0".to_string(),
4997            avg_px: String::new(),
4998            upl: "0".to_string(),
4999            upl_ratio: "0".to_string(),
5000            u_time: 1622559930237,
5001            margin: "0".to_string(),
5002            mgn_ratio: "0".to_string(),
5003            adl: "0".to_string(),
5004            c_time: "1622559930237".to_string(),
5005            realized_pnl: "0".to_string(),
5006            upl_last_px: "0".to_string(),
5007            upl_ratio_last_px: "0".to_string(),
5008            avail_pos: "0".to_string(),
5009            be_px: "0".to_string(),
5010            funding_fee: "0".to_string(),
5011            idx_px: "0".to_string(),
5012            liq_penalty: "0".to_string(),
5013            opt_val: "0".to_string(),
5014            pending_close_ord_liab_val: "0".to_string(),
5015            pnl: "0".to_string(),
5016            pos_ccy: "ETH".to_string(),
5017            quote_bal: "0".to_string(),
5018            quote_borrowed: "0".to_string(),
5019            quote_interest: "0".to_string(),
5020            spot_in_use_amt: "0".to_string(),
5021            spot_in_use_ccy: "ETH".to_string(),
5022            usd_px: "3000".to_string(),
5023            delta_bs: String::new(),
5024            gamma_bs: String::new(),
5025            theta_bs: String::new(),
5026            vega_bs: String::new(),
5027        };
5028
5029        let account_id = AccountId::new("OKX-001");
5030        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
5031        let report = parse_position_status_report(
5032            &position,
5033            account_id,
5034            instrument_id,
5035            8,
5036            UnixNanos::default(),
5037        )
5038        .unwrap();
5039
5040        assert_eq!(report.account_id, account_id);
5041        assert_eq!(report.instrument_id, instrument_id);
5042        assert_eq!(report.position_side, PositionSide::Flat);
5043        assert_eq!(report.quantity, Quantity::from("0"));
5044        // Net mode: venue_position_id is None (signals NETTING OMS)
5045        assert_eq!(report.venue_position_id, None);
5046    }
5047
5048    #[rstest]
5049    fn test_parse_position_status_report_long_short_mode_long() {
5050        // Test Long/Short mode: posSide="long" with positive quantity
5051        let position = OKXPosition {
5052            inst_id: Ustr::from("BTC-USDT-SWAP"),
5053            inst_type: OKXInstrumentType::Swap,
5054            mgn_mode: OKXMarginMode::Cross,
5055            pos_id: Some(Ustr::from("11111")),
5056            pos_side: OKXPositionSide::Long, // Long/Short mode - Long leg
5057            pos: "3.2".to_string(),          // Positive quantity (always positive in this mode)
5058            base_bal: "3.2".to_string(),
5059            ccy: "BTC".to_string(),
5060            fee: "0.01".to_string(),
5061            lever: "10.0".to_string(),
5062            last: "50000".to_string(),
5063            mark_px: "50000".to_string(),
5064            liq_px: "45000".to_string(),
5065            mmr: "0.1".to_string(),
5066            interest: "0".to_string(),
5067            trade_id: Ustr::from("444"),
5068            notional_usd: "160000".to_string(),
5069            avg_px: "50000".to_string(),
5070            upl: "0".to_string(),
5071            upl_ratio: "0".to_string(),
5072            u_time: 1622559930237,
5073            margin: "1.6".to_string(),
5074            mgn_ratio: "0.01".to_string(),
5075            adl: "0".to_string(),
5076            c_time: "1622559930237".to_string(),
5077            realized_pnl: "0".to_string(),
5078            upl_last_px: "0".to_string(),
5079            upl_ratio_last_px: "0".to_string(),
5080            avail_pos: "3.2".to_string(),
5081            be_px: "0".to_string(),
5082            funding_fee: "0".to_string(),
5083            idx_px: "0".to_string(),
5084            liq_penalty: "0".to_string(),
5085            opt_val: "0".to_string(),
5086            pending_close_ord_liab_val: "0".to_string(),
5087            pnl: "0".to_string(),
5088            pos_ccy: "BTC".to_string(),
5089            quote_bal: "160000".to_string(),
5090            quote_borrowed: "0".to_string(),
5091            quote_interest: "0".to_string(),
5092            spot_in_use_amt: "0".to_string(),
5093            spot_in_use_ccy: "BTC".to_string(),
5094            usd_px: "50000".to_string(),
5095            delta_bs: String::new(),
5096            gamma_bs: String::new(),
5097            theta_bs: String::new(),
5098            vega_bs: String::new(),
5099        };
5100
5101        let account_id = AccountId::new("OKX-001");
5102        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
5103        let report = parse_position_status_report(
5104            &position,
5105            account_id,
5106            instrument_id,
5107            8,
5108            UnixNanos::default(),
5109        )
5110        .unwrap();
5111
5112        assert_eq!(report.account_id, account_id);
5113        assert_eq!(report.instrument_id, instrument_id);
5114        assert_eq!(report.position_side, PositionSide::Long);
5115        assert_eq!(report.quantity, Quantity::from("3.2"));
5116        // Long/Short mode - Long leg: "-LONG" suffix
5117        assert_eq!(
5118            report.venue_position_id,
5119            Some(PositionId::new("11111-LONG"))
5120        );
5121    }
5122
5123    #[rstest]
5124    fn test_parse_position_status_report_long_short_mode_short() {
5125        // Test Long/Short mode: posSide="short" with positive quantity
5126        // This is the critical test - positive quantity but SHORT side!
5127        let position = OKXPosition {
5128            inst_id: Ustr::from("BTC-USDT-SWAP"),
5129            inst_type: OKXInstrumentType::Swap,
5130            mgn_mode: OKXMarginMode::Cross,
5131            pos_id: Some(Ustr::from("22222")),
5132            pos_side: OKXPositionSide::Short, // Long/Short mode - Short leg
5133            pos: "1.8".to_string(),           // Positive quantity (always positive in this mode)
5134            base_bal: "1.8".to_string(),
5135            ccy: "BTC".to_string(),
5136            fee: "0.02".to_string(),
5137            lever: "10.0".to_string(),
5138            last: "50000".to_string(),
5139            mark_px: "50000".to_string(),
5140            liq_px: "55000".to_string(),
5141            mmr: "0.2".to_string(),
5142            interest: "0".to_string(),
5143            trade_id: Ustr::from("555"),
5144            notional_usd: "90000".to_string(),
5145            avg_px: "50000".to_string(),
5146            upl: "0".to_string(),
5147            upl_ratio: "0".to_string(),
5148            u_time: 1622559930237,
5149            margin: "0.9".to_string(),
5150            mgn_ratio: "0.02".to_string(),
5151            adl: "0".to_string(),
5152            c_time: "1622559930237".to_string(),
5153            realized_pnl: "0".to_string(),
5154            upl_last_px: "0".to_string(),
5155            upl_ratio_last_px: "0".to_string(),
5156            avail_pos: "1.8".to_string(),
5157            be_px: "0".to_string(),
5158            funding_fee: "0".to_string(),
5159            idx_px: "0".to_string(),
5160            liq_penalty: "0".to_string(),
5161            opt_val: "0".to_string(),
5162            pending_close_ord_liab_val: "0".to_string(),
5163            pnl: "0".to_string(),
5164            pos_ccy: "BTC".to_string(),
5165            quote_bal: "90000".to_string(),
5166            quote_borrowed: "0".to_string(),
5167            quote_interest: "0".to_string(),
5168            spot_in_use_amt: "0".to_string(),
5169            spot_in_use_ccy: "BTC".to_string(),
5170            usd_px: "50000".to_string(),
5171            delta_bs: String::new(),
5172            gamma_bs: String::new(),
5173            theta_bs: String::new(),
5174            vega_bs: String::new(),
5175        };
5176
5177        let account_id = AccountId::new("OKX-001");
5178        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
5179        let report = parse_position_status_report(
5180            &position,
5181            account_id,
5182            instrument_id,
5183            8,
5184            UnixNanos::default(),
5185        )
5186        .unwrap();
5187
5188        assert_eq!(report.account_id, account_id);
5189        assert_eq!(report.instrument_id, instrument_id);
5190        // This is the critical assertion: positive quantity but SHORT side
5191        assert_eq!(report.position_side, PositionSide::Short);
5192        assert_eq!(report.quantity, Quantity::from("1.8"));
5193        // Long/Short mode - Short leg: "-SHORT" suffix
5194        assert_eq!(
5195            report.venue_position_id,
5196            Some(PositionId::new("22222-SHORT"))
5197        );
5198    }
5199
5200    #[rstest]
5201    fn test_parse_position_status_report_margin_long() {
5202        // Test MARGIN long position: pos_ccy = base currency (ETH)
5203        let position = OKXPosition {
5204            inst_id: Ustr::from("ETH-USDT"),
5205            inst_type: OKXInstrumentType::Margin,
5206            mgn_mode: OKXMarginMode::Cross,
5207            pos_id: Some(Ustr::from("margin-long-1")),
5208            pos_side: OKXPositionSide::Net,
5209            pos: "1.5".to_string(), // Total position (may include pending)
5210            base_bal: "1.5".to_string(),
5211            ccy: "ETH".to_string(),
5212            fee: "0".to_string(),
5213            lever: "3".to_string(),
5214            last: "4000".to_string(),
5215            mark_px: "4000".to_string(),
5216            liq_px: "3500".to_string(),
5217            mmr: "0.1".to_string(),
5218            interest: "0".to_string(),
5219            trade_id: Ustr::from("trade1"),
5220            notional_usd: "6000".to_string(),
5221            avg_px: "3800".to_string(), // Bought at 3800
5222            upl: "300".to_string(),
5223            upl_ratio: "0.05".to_string(),
5224            u_time: 1622559930237,
5225            margin: "2000".to_string(),
5226            mgn_ratio: "0.33".to_string(),
5227            adl: "0".to_string(),
5228            c_time: "1622559930237".to_string(),
5229            realized_pnl: "0".to_string(),
5230            upl_last_px: "300".to_string(),
5231            upl_ratio_last_px: "0.05".to_string(),
5232            avail_pos: "1.5".to_string(),
5233            be_px: "3800".to_string(),
5234            funding_fee: "0".to_string(),
5235            idx_px: "4000".to_string(),
5236            liq_penalty: "0".to_string(),
5237            opt_val: "0".to_string(),
5238            pending_close_ord_liab_val: "0".to_string(),
5239            pnl: "300".to_string(),
5240            pos_ccy: "ETH".to_string(), // pos_ccy = base = LONG
5241            quote_bal: "0".to_string(),
5242            quote_borrowed: "0".to_string(),
5243            quote_interest: "0".to_string(),
5244            spot_in_use_amt: "0".to_string(),
5245            spot_in_use_ccy: String::new(),
5246            usd_px: "4000".to_string(),
5247            delta_bs: String::new(),
5248            gamma_bs: String::new(),
5249            theta_bs: String::new(),
5250            vega_bs: String::new(),
5251        };
5252
5253        let account_id = AccountId::new("OKX-001");
5254        let instrument_id = InstrumentId::from("ETH-USDT.OKX");
5255        let report = parse_position_status_report(
5256            &position,
5257            account_id,
5258            instrument_id,
5259            4,
5260            UnixNanos::default(),
5261        )
5262        .unwrap();
5263
5264        assert_eq!(report.account_id, account_id);
5265        assert_eq!(report.instrument_id, instrument_id);
5266        assert_eq!(report.position_side, PositionSide::Long);
5267        assert_eq!(report.quantity, Quantity::from("1.5")); // 1.5 ETH in base
5268        assert_eq!(report.venue_position_id, None); // Net mode
5269    }
5270
5271    #[rstest]
5272    fn test_parse_position_status_report_margin_short() {
5273        // Test MARGIN short position: pos_ccy = quote currency (USDT)
5274        // pos is in quote currency and needs conversion to base
5275        let position = OKXPosition {
5276            inst_id: Ustr::from("ETH-USDT"),
5277            inst_type: OKXInstrumentType::Margin,
5278            mgn_mode: OKXMarginMode::Cross,
5279            pos_id: Some(Ustr::from("margin-short-1")),
5280            pos_side: OKXPositionSide::Net,
5281            pos: "244.56".to_string(), // Position in quote currency (USDT)
5282            base_bal: "0".to_string(),
5283            ccy: "USDT".to_string(),
5284            fee: "0".to_string(),
5285            lever: "3".to_string(),
5286            last: "4092".to_string(),
5287            mark_px: "4092".to_string(),
5288            liq_px: "4500".to_string(),
5289            mmr: "0.1".to_string(),
5290            interest: "0".to_string(),
5291            trade_id: Ustr::from("trade2"),
5292            notional_usd: "244.56".to_string(),
5293            avg_px: "4092".to_string(), // Shorted at 4092
5294            upl: "-10".to_string(),
5295            upl_ratio: "-0.04".to_string(),
5296            u_time: 1622559930237,
5297            margin: "100".to_string(),
5298            mgn_ratio: "0.4".to_string(),
5299            adl: "0".to_string(),
5300            c_time: "1622559930237".to_string(),
5301            realized_pnl: "0".to_string(),
5302            upl_last_px: "-10".to_string(),
5303            upl_ratio_last_px: "-0.04".to_string(),
5304            avail_pos: "244.56".to_string(),
5305            be_px: "4092".to_string(),
5306            funding_fee: "0".to_string(),
5307            idx_px: "4092".to_string(),
5308            liq_penalty: "0".to_string(),
5309            opt_val: "0".to_string(),
5310            pending_close_ord_liab_val: "0".to_string(),
5311            pnl: "-10".to_string(),
5312            pos_ccy: "USDT".to_string(), // pos_ccy = quote indicates SHORT, pos in USDT
5313            quote_bal: "244.56".to_string(),
5314            quote_borrowed: "0".to_string(),
5315            quote_interest: "0".to_string(),
5316            spot_in_use_amt: "0".to_string(),
5317            spot_in_use_ccy: String::new(),
5318            usd_px: "4092".to_string(),
5319            delta_bs: String::new(),
5320            gamma_bs: String::new(),
5321            theta_bs: String::new(),
5322            vega_bs: String::new(),
5323        };
5324
5325        let account_id = AccountId::new("OKX-001");
5326        let instrument_id = InstrumentId::from("ETH-USDT.OKX");
5327        let report = parse_position_status_report(
5328            &position,
5329            account_id,
5330            instrument_id,
5331            4,
5332            UnixNanos::default(),
5333        )
5334        .unwrap();
5335
5336        assert_eq!(report.account_id, account_id);
5337        assert_eq!(report.instrument_id, instrument_id);
5338        assert_eq!(report.position_side, PositionSide::Short);
5339        // Position is 244.56 USDT / 4092 USDT/ETH = 0.0597... ETH
5340        assert_eq!(report.quantity.to_string(), "0.0598");
5341        assert_eq!(report.venue_position_id, None); // Net mode
5342    }
5343
5344    #[rstest]
5345    fn test_parse_position_status_report_margin_short_rounds_to_size_precision() {
5346        // 100.00 USDT / 3333.33 USDT/ETH = 0.030000030000... ETH
5347        // Without round_dp this exceeds size_precision=4 and fails
5348        let position = OKXPosition {
5349            inst_id: Ustr::from("ETH-USDT"),
5350            inst_type: OKXInstrumentType::Margin,
5351            mgn_mode: OKXMarginMode::Cross,
5352            pos_id: Some(Ustr::from("margin-short-2")),
5353            pos_side: OKXPositionSide::Net,
5354            pos: "100.00".to_string(),
5355            base_bal: "0".to_string(),
5356            ccy: "USDT".to_string(),
5357            fee: "0".to_string(),
5358            lever: "3".to_string(),
5359            last: "3333.33".to_string(),
5360            mark_px: "3333.33".to_string(),
5361            liq_px: "3500".to_string(),
5362            mmr: "0.1".to_string(),
5363            interest: "0".to_string(),
5364            trade_id: Ustr::from("trade-round"),
5365            notional_usd: "100.00".to_string(),
5366            avg_px: "3333.33".to_string(),
5367            upl: "0".to_string(),
5368            upl_ratio: "0".to_string(),
5369            u_time: 1622559930237,
5370            margin: "50".to_string(),
5371            mgn_ratio: "0.5".to_string(),
5372            adl: "0".to_string(),
5373            c_time: "1622559930237".to_string(),
5374            realized_pnl: "0".to_string(),
5375            upl_last_px: "0".to_string(),
5376            upl_ratio_last_px: "0".to_string(),
5377            avail_pos: "100.00".to_string(),
5378            be_px: "3333.33".to_string(),
5379            funding_fee: "0".to_string(),
5380            idx_px: "3333.33".to_string(),
5381            liq_penalty: "0".to_string(),
5382            opt_val: "0".to_string(),
5383            pending_close_ord_liab_val: "0".to_string(),
5384            pnl: "0".to_string(),
5385            pos_ccy: "USDT".to_string(),
5386            quote_bal: "100.00".to_string(),
5387            quote_borrowed: "0".to_string(),
5388            quote_interest: "0".to_string(),
5389            spot_in_use_amt: "0".to_string(),
5390            spot_in_use_ccy: String::new(),
5391            usd_px: "3333.33".to_string(),
5392            delta_bs: String::new(),
5393            gamma_bs: String::new(),
5394            theta_bs: String::new(),
5395            vega_bs: String::new(),
5396        };
5397
5398        let report = parse_position_status_report(
5399            &position,
5400            AccountId::new("OKX-001"),
5401            InstrumentId::from("ETH-USDT.OKX"),
5402            4, // size_precision=4
5403            UnixNanos::default(),
5404        )
5405        .unwrap();
5406
5407        assert_eq!(report.position_side, PositionSide::Short);
5408        assert_eq!(report.quantity.to_string(), "0.0300");
5409    }
5410
5411    #[rstest]
5412    fn test_parse_rfc3339_timestamp_rejects_pre_epoch() {
5413        let result = parse_rfc3339_timestamp("1960-01-01T00:00:00Z");
5414        assert!(result.is_err());
5415        assert!(
5416            result
5417                .unwrap_err()
5418                .to_string()
5419                .contains("Negative nanosecond timestamp")
5420        );
5421    }
5422
5423    #[rstest]
5424    fn test_parse_position_status_report_margin_flat() {
5425        // Test MARGIN flat position: pos_ccy is empty string
5426        let position = OKXPosition {
5427            inst_id: Ustr::from("ETH-USDT"),
5428            inst_type: OKXInstrumentType::Margin,
5429            mgn_mode: OKXMarginMode::Cross,
5430            pos_id: Some(Ustr::from("margin-flat-1")),
5431            pos_side: OKXPositionSide::Net,
5432            pos: "0".to_string(),
5433            base_bal: "0".to_string(),
5434            ccy: "ETH".to_string(),
5435            fee: "0".to_string(),
5436            lever: "0".to_string(),
5437            last: "4000".to_string(),
5438            mark_px: "4000".to_string(),
5439            liq_px: "0".to_string(),
5440            mmr: "0".to_string(),
5441            interest: "0".to_string(),
5442            trade_id: Ustr::from(""),
5443            notional_usd: "0".to_string(),
5444            avg_px: String::new(),
5445            upl: "0".to_string(),
5446            upl_ratio: "0".to_string(),
5447            u_time: 1622559930237,
5448            margin: "0".to_string(),
5449            mgn_ratio: "0".to_string(),
5450            adl: "0".to_string(),
5451            c_time: "1622559930237".to_string(),
5452            realized_pnl: "0".to_string(),
5453            upl_last_px: "0".to_string(),
5454            upl_ratio_last_px: "0".to_string(),
5455            avail_pos: "0".to_string(),
5456            be_px: "0".to_string(),
5457            funding_fee: "0".to_string(),
5458            idx_px: "0".to_string(),
5459            liq_penalty: "0".to_string(),
5460            opt_val: "0".to_string(),
5461            pending_close_ord_liab_val: "0".to_string(),
5462            pnl: "0".to_string(),
5463            pos_ccy: String::new(), // Empty pos_ccy = FLAT
5464            quote_bal: "0".to_string(),
5465            quote_borrowed: "0".to_string(),
5466            quote_interest: "0".to_string(),
5467            spot_in_use_amt: "0".to_string(),
5468            spot_in_use_ccy: String::new(),
5469            usd_px: "0".to_string(),
5470            delta_bs: String::new(),
5471            gamma_bs: String::new(),
5472            theta_bs: String::new(),
5473            vega_bs: String::new(),
5474        };
5475
5476        let account_id = AccountId::new("OKX-001");
5477        let instrument_id = InstrumentId::from("ETH-USDT.OKX");
5478        let report = parse_position_status_report(
5479            &position,
5480            account_id,
5481            instrument_id,
5482            4,
5483            UnixNanos::default(),
5484        )
5485        .unwrap();
5486
5487        assert_eq!(report.account_id, account_id);
5488        assert_eq!(report.instrument_id, instrument_id);
5489        assert_eq!(report.position_side, PositionSide::Flat);
5490        assert_eq!(report.quantity, Quantity::from("0"));
5491        assert_eq!(report.venue_position_id, None); // Net mode
5492    }
5493
5494    #[rstest]
5495    fn test_parse_swap_instrument_empty_underlying_returns_error() {
5496        let instrument = OKXInstrument {
5497            inst_type: OKXInstrumentType::Swap,
5498            inst_id: Ustr::from("ETH-USD_UM-SWAP"),
5499            uly: Ustr::from(""), // Empty underlying
5500            inst_family: Ustr::from(""),
5501            series_id: None,
5502            inst_category: None,
5503            init_px_lmt_pct: String::new(),
5504            float_px_lmt_pct: String::new(),
5505            max_px_lmt_pct: String::new(),
5506            base_ccy: Ustr::from(""),
5507            quote_ccy: Ustr::from(""),
5508            settle_ccy: Ustr::from("USD"),
5509            ct_val: "1".to_string(),
5510            ct_mult: "1".to_string(),
5511            ct_val_ccy: "USD".to_string(),
5512            opt_type: crate::common::enums::OKXOptionType::None,
5513            stk: String::new(),
5514            list_time: None,
5515            exp_time: None,
5516            lever: String::new(),
5517            tick_sz: "0.1".to_string(),
5518            lot_sz: "1".to_string(),
5519            min_sz: "1".to_string(),
5520            ct_type: OKXContractType::Linear,
5521            state: crate::common::enums::OKXInstrumentStatus::Preopen,
5522            rule_type: String::new(),
5523            max_lmt_sz: String::new(),
5524            max_mkt_sz: String::new(),
5525            max_lmt_amt: String::new(),
5526            max_mkt_amt: String::new(),
5527            max_twap_sz: String::new(),
5528            max_iceberg_sz: String::new(),
5529            max_trigger_sz: String::new(),
5530            max_stop_sz: String::new(),
5531            inst_id_code: None,
5532            rpi: None,
5533            rpi_min_level: None,
5534            rpi_min_px_band: None,
5535        };
5536
5537        let result =
5538            parse_swap_instrument(&instrument, None, None, None, None, UnixNanos::default());
5539        assert!(result.is_err());
5540        assert!(result.unwrap_err().to_string().contains("Empty underlying"));
5541    }
5542
5543    #[rstest]
5544    fn test_parse_futures_instrument_empty_underlying_returns_error() {
5545        let instrument = OKXInstrument {
5546            inst_type: OKXInstrumentType::Futures,
5547            inst_id: Ustr::from("ETH-USD_UM-250328"),
5548            uly: Ustr::from(""), // Empty underlying
5549            inst_family: Ustr::from(""),
5550            series_id: None,
5551            inst_category: None,
5552            init_px_lmt_pct: String::new(),
5553            float_px_lmt_pct: String::new(),
5554            max_px_lmt_pct: String::new(),
5555            base_ccy: Ustr::from(""),
5556            quote_ccy: Ustr::from(""),
5557            settle_ccy: Ustr::from("USD"),
5558            ct_val: "1".to_string(),
5559            ct_mult: "1".to_string(),
5560            ct_val_ccy: "USD".to_string(),
5561            opt_type: crate::common::enums::OKXOptionType::None,
5562            stk: String::new(),
5563            list_time: None,
5564            exp_time: Some(1743004800000),
5565            lever: String::new(),
5566            tick_sz: "0.1".to_string(),
5567            lot_sz: "1".to_string(),
5568            min_sz: "1".to_string(),
5569            ct_type: OKXContractType::Linear,
5570            state: crate::common::enums::OKXInstrumentStatus::Preopen,
5571            rule_type: String::new(),
5572            max_lmt_sz: String::new(),
5573            max_mkt_sz: String::new(),
5574            max_lmt_amt: String::new(),
5575            max_mkt_amt: String::new(),
5576            max_twap_sz: String::new(),
5577            max_iceberg_sz: String::new(),
5578            max_trigger_sz: String::new(),
5579            max_stop_sz: String::new(),
5580            inst_id_code: None,
5581            rpi: None,
5582            rpi_min_level: None,
5583            rpi_min_px_band: None,
5584        };
5585
5586        let result =
5587            parse_futures_instrument(&instrument, None, None, None, None, UnixNanos::default());
5588        assert!(result.is_err());
5589        assert!(result.unwrap_err().to_string().contains("Empty underlying"));
5590    }
5591
5592    #[rstest]
5593    fn test_parse_option_instrument_empty_opt_type_returns_error() {
5594        let instrument = OKXInstrument {
5595            inst_type: OKXInstrumentType::Option,
5596            inst_id: Ustr::from("BTC-USD-250328-50000-C"),
5597            uly: Ustr::from("BTC-USD"),
5598            inst_family: Ustr::from("BTC-USD"),
5599            series_id: None,
5600            inst_category: None,
5601            init_px_lmt_pct: String::new(),
5602            float_px_lmt_pct: String::new(),
5603            max_px_lmt_pct: String::new(),
5604            base_ccy: Ustr::from(""),
5605            quote_ccy: Ustr::from(""),
5606            settle_ccy: Ustr::from("USD"),
5607            ct_val: "0.01".to_string(),
5608            ct_mult: "1".to_string(),
5609            ct_val_ccy: "BTC".to_string(),
5610            // OKX sends `optType=""` for non-option instruments and the
5611            // occasional malformed payload, which deserializes to `None`.
5612            opt_type: crate::common::enums::OKXOptionType::None,
5613            stk: "50000".to_string(),
5614            list_time: None,
5615            exp_time: Some(1743004800000),
5616            lever: String::new(),
5617            tick_sz: "0.0005".to_string(),
5618            lot_sz: "0.1".to_string(),
5619            min_sz: "0.1".to_string(),
5620            ct_type: OKXContractType::Linear,
5621            state: crate::common::enums::OKXInstrumentStatus::Preopen,
5622            rule_type: String::new(),
5623            max_lmt_sz: String::new(),
5624            max_mkt_sz: String::new(),
5625            max_lmt_amt: String::new(),
5626            max_mkt_amt: String::new(),
5627            max_twap_sz: String::new(),
5628            max_iceberg_sz: String::new(),
5629            max_trigger_sz: String::new(),
5630            max_stop_sz: String::new(),
5631            inst_id_code: None,
5632            rpi: None,
5633            rpi_min_level: None,
5634            rpi_min_px_band: None,
5635        };
5636
5637        let result =
5638            parse_option_instrument(&instrument, None, None, None, None, UnixNanos::default());
5639        assert!(result.is_err());
5640        let err_msg = result.unwrap_err().to_string();
5641        assert!(
5642            err_msg.contains("Unsupported") && err_msg.contains("optType"),
5643            "expected Unsupported optType error, was: {err_msg}"
5644        );
5645    }
5646
5647    #[rstest]
5648    fn test_parse_option_instrument_empty_underlying_returns_error() {
5649        let instrument = OKXInstrument {
5650            inst_type: OKXInstrumentType::Option,
5651            inst_id: Ustr::from("BTC-USD-250328-50000-C"),
5652            uly: Ustr::from(""), // Empty underlying
5653            inst_family: Ustr::from(""),
5654            series_id: None,
5655            inst_category: None,
5656            init_px_lmt_pct: String::new(),
5657            float_px_lmt_pct: String::new(),
5658            max_px_lmt_pct: String::new(),
5659            base_ccy: Ustr::from(""),
5660            quote_ccy: Ustr::from(""),
5661            settle_ccy: Ustr::from("USD"),
5662            ct_val: "0.01".to_string(),
5663            ct_mult: "1".to_string(),
5664            ct_val_ccy: "BTC".to_string(),
5665            opt_type: crate::common::enums::OKXOptionType::Call,
5666            stk: "50000".to_string(),
5667            list_time: None,
5668            exp_time: Some(1743004800000),
5669            lever: String::new(),
5670            tick_sz: "0.0005".to_string(),
5671            lot_sz: "0.1".to_string(),
5672            min_sz: "0.1".to_string(),
5673            ct_type: OKXContractType::Linear,
5674            state: crate::common::enums::OKXInstrumentStatus::Preopen,
5675            rule_type: String::new(),
5676            max_lmt_sz: String::new(),
5677            max_mkt_sz: String::new(),
5678            max_lmt_amt: String::new(),
5679            max_mkt_amt: String::new(),
5680            max_twap_sz: String::new(),
5681            max_iceberg_sz: String::new(),
5682            max_trigger_sz: String::new(),
5683            max_stop_sz: String::new(),
5684            inst_id_code: None,
5685            rpi: None,
5686            rpi_min_level: None,
5687            rpi_min_px_band: None,
5688        };
5689
5690        let result =
5691            parse_option_instrument(&instrument, None, None, None, None, UnixNanos::default());
5692        assert!(result.is_err());
5693        assert!(result.unwrap_err().to_string().contains("Empty underlying"));
5694    }
5695
5696    #[rstest]
5697    fn test_parse_spot_margin_position_from_balance_short_usdt() {
5698        let balance = OKXBalanceDetail {
5699            ccy: Ustr::from("ENA"),
5700            liab: "130047.3610487126".to_string(),
5701            spot_in_use_amt: "-129950".to_string(),
5702            cross_liab: "130047.3610487126".to_string(),
5703            eq: "-130047.3610487126".to_string(),
5704            u_time: 1704067200000,
5705            avail_bal: "0".to_string(),
5706            avail_eq: "0".to_string(),
5707            borrow_froz: "0".to_string(),
5708            cash_bal: "0".to_string(),
5709            dis_eq: "0".to_string(),
5710            eq_usd: "0".to_string(),
5711            smt_sync_eq: "0".to_string(),
5712            spot_copy_trading_eq: "0".to_string(),
5713            fixed_bal: "0".to_string(),
5714            frozen_bal: "0".to_string(),
5715            imr: "0".to_string(),
5716            interest: "0".to_string(),
5717            iso_eq: "0".to_string(),
5718            iso_liab: "0".to_string(),
5719            iso_upl: "0".to_string(),
5720            max_loan: "0".to_string(),
5721            mgn_ratio: "0".to_string(),
5722            mmr: "0".to_string(),
5723            notional_lever: "0".to_string(),
5724            ord_frozen: "0".to_string(),
5725            reward_bal: "0".to_string(),
5726            cl_spot_in_use_amt: "0".to_string(),
5727            max_spot_in_use_amt: "0".to_string(),
5728            spot_iso_bal: "0".to_string(),
5729            stgy_eq: "0".to_string(),
5730            twap: "0".to_string(),
5731            upl: "0".to_string(),
5732            upl_liab: "0".to_string(),
5733            spot_bal: "0".to_string(),
5734            open_avg_px: "0".to_string(),
5735            acc_avg_px: "0".to_string(),
5736            spot_upl: "0".to_string(),
5737            spot_upl_ratio: "0".to_string(),
5738            total_pnl: "0".to_string(),
5739            total_pnl_ratio: "0".to_string(),
5740        };
5741
5742        let account_id = AccountId::new("OKX-001");
5743        let size_precision = 2;
5744        let ts_init = UnixNanos::default();
5745
5746        let result = parse_spot_margin_position_from_balance(
5747            &balance,
5748            account_id,
5749            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
5750            size_precision,
5751            ts_init,
5752        )
5753        .unwrap();
5754
5755        assert!(result.is_some());
5756        let report = result.unwrap();
5757        assert_eq!(report.account_id, account_id);
5758        assert_eq!(report.instrument_id.to_string(), "ENA-USDT.OKX".to_string());
5759        assert_eq!(report.position_side, PositionSide::Short);
5760        assert_eq!(report.quantity.to_string(), "129950.00");
5761    }
5762
5763    #[rstest]
5764    fn test_parse_spot_margin_position_from_balance_long() {
5765        let balance = OKXBalanceDetail {
5766            ccy: Ustr::from("BTC"),
5767            liab: "1.5".to_string(),
5768            spot_in_use_amt: "1.2".to_string(),
5769            cross_liab: "1.5".to_string(),
5770            eq: "1.2".to_string(),
5771            u_time: 1704067200000,
5772            avail_bal: "0".to_string(),
5773            avail_eq: "0".to_string(),
5774            borrow_froz: "0".to_string(),
5775            cash_bal: "0".to_string(),
5776            dis_eq: "0".to_string(),
5777            eq_usd: "0".to_string(),
5778            smt_sync_eq: "0".to_string(),
5779            spot_copy_trading_eq: "0".to_string(),
5780            fixed_bal: "0".to_string(),
5781            frozen_bal: "0".to_string(),
5782            imr: "0".to_string(),
5783            interest: "0".to_string(),
5784            iso_eq: "0".to_string(),
5785            iso_liab: "0".to_string(),
5786            iso_upl: "0".to_string(),
5787            max_loan: "0".to_string(),
5788            mgn_ratio: "0".to_string(),
5789            mmr: "0".to_string(),
5790            notional_lever: "0".to_string(),
5791            ord_frozen: "0".to_string(),
5792            reward_bal: "0".to_string(),
5793            cl_spot_in_use_amt: "0".to_string(),
5794            max_spot_in_use_amt: "0".to_string(),
5795            spot_iso_bal: "0".to_string(),
5796            stgy_eq: "0".to_string(),
5797            twap: "0".to_string(),
5798            upl: "0".to_string(),
5799            upl_liab: "0".to_string(),
5800            spot_bal: "0".to_string(),
5801            open_avg_px: "0".to_string(),
5802            acc_avg_px: "0".to_string(),
5803            spot_upl: "0".to_string(),
5804            spot_upl_ratio: "0".to_string(),
5805            total_pnl: "0".to_string(),
5806            total_pnl_ratio: "0".to_string(),
5807        };
5808
5809        let account_id = AccountId::new("OKX-001");
5810        let size_precision = 8;
5811        let ts_init = UnixNanos::default();
5812
5813        let result = parse_spot_margin_position_from_balance(
5814            &balance,
5815            account_id,
5816            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
5817            size_precision,
5818            ts_init,
5819        )
5820        .unwrap();
5821
5822        assert!(result.is_some());
5823        let report = result.unwrap();
5824        assert_eq!(report.position_side, PositionSide::Long);
5825        assert_eq!(report.quantity.to_string(), "1.20000000");
5826    }
5827
5828    #[rstest]
5829    fn test_parse_spot_margin_position_from_balance_usdc_quote() {
5830        let balance = OKXBalanceDetail {
5831            ccy: Ustr::from("ETH"),
5832            liab: "10.5".to_string(),
5833            spot_in_use_amt: "-10.0".to_string(),
5834            cross_liab: "10.5".to_string(),
5835            eq: "-10.0".to_string(),
5836            u_time: 1704067200000,
5837            avail_bal: "0".to_string(),
5838            avail_eq: "0".to_string(),
5839            borrow_froz: "0".to_string(),
5840            cash_bal: "0".to_string(),
5841            dis_eq: "0".to_string(),
5842            eq_usd: "0".to_string(),
5843            smt_sync_eq: "0".to_string(),
5844            spot_copy_trading_eq: "0".to_string(),
5845            fixed_bal: "0".to_string(),
5846            frozen_bal: "0".to_string(),
5847            imr: "0".to_string(),
5848            interest: "0".to_string(),
5849            iso_eq: "0".to_string(),
5850            iso_liab: "0".to_string(),
5851            iso_upl: "0".to_string(),
5852            max_loan: "0".to_string(),
5853            mgn_ratio: "0".to_string(),
5854            mmr: "0".to_string(),
5855            notional_lever: "0".to_string(),
5856            ord_frozen: "0".to_string(),
5857            reward_bal: "0".to_string(),
5858            cl_spot_in_use_amt: "0".to_string(),
5859            max_spot_in_use_amt: "0".to_string(),
5860            spot_iso_bal: "0".to_string(),
5861            stgy_eq: "0".to_string(),
5862            twap: "0".to_string(),
5863            upl: "0".to_string(),
5864            upl_liab: "0".to_string(),
5865            spot_bal: "0".to_string(),
5866            open_avg_px: "0".to_string(),
5867            acc_avg_px: "0".to_string(),
5868            spot_upl: "0".to_string(),
5869            spot_upl_ratio: "0".to_string(),
5870            total_pnl: "0".to_string(),
5871            total_pnl_ratio: "0".to_string(),
5872        };
5873
5874        let account_id = AccountId::new("OKX-001");
5875        let size_precision = 6;
5876        let ts_init = UnixNanos::default();
5877
5878        let result = parse_spot_margin_position_from_balance(
5879            &balance,
5880            account_id,
5881            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
5882            size_precision,
5883            ts_init,
5884        )
5885        .unwrap();
5886
5887        assert!(result.is_some());
5888        let report = result.unwrap();
5889        assert_eq!(report.position_side, PositionSide::Short);
5890        assert_eq!(report.quantity.to_string(), "10.000000");
5891        assert!(report.instrument_id.to_string().contains("ETH-"));
5892    }
5893
5894    #[rstest]
5895    fn test_parse_spot_margin_position_from_balance_no_position() {
5896        let balance = OKXBalanceDetail {
5897            ccy: Ustr::from("USDT"),
5898            liab: "0".to_string(),
5899            spot_in_use_amt: "0".to_string(),
5900            cross_liab: "0".to_string(),
5901            eq: "1000.5".to_string(),
5902            u_time: 1704067200000,
5903            avail_bal: "1000.5".to_string(),
5904            avail_eq: "1000.5".to_string(),
5905            borrow_froz: "0".to_string(),
5906            cash_bal: "1000.5".to_string(),
5907            dis_eq: "0".to_string(),
5908            eq_usd: "1000.5".to_string(),
5909            smt_sync_eq: "0".to_string(),
5910            spot_copy_trading_eq: "0".to_string(),
5911            fixed_bal: "0".to_string(),
5912            frozen_bal: "0".to_string(),
5913            imr: "0".to_string(),
5914            interest: "0".to_string(),
5915            iso_eq: "0".to_string(),
5916            iso_liab: "0".to_string(),
5917            iso_upl: "0".to_string(),
5918            max_loan: "0".to_string(),
5919            mgn_ratio: "0".to_string(),
5920            mmr: "0".to_string(),
5921            notional_lever: "0".to_string(),
5922            ord_frozen: "0".to_string(),
5923            reward_bal: "0".to_string(),
5924            cl_spot_in_use_amt: "0".to_string(),
5925            max_spot_in_use_amt: "0".to_string(),
5926            spot_iso_bal: "0".to_string(),
5927            stgy_eq: "0".to_string(),
5928            twap: "0".to_string(),
5929            upl: "0".to_string(),
5930            upl_liab: "0".to_string(),
5931            spot_bal: "1000.5".to_string(),
5932            open_avg_px: "0".to_string(),
5933            acc_avg_px: "0".to_string(),
5934            spot_upl: "0".to_string(),
5935            spot_upl_ratio: "0".to_string(),
5936            total_pnl: "0".to_string(),
5937            total_pnl_ratio: "0".to_string(),
5938        };
5939
5940        let account_id = AccountId::new("OKX-001");
5941        let size_precision = 2;
5942        let ts_init = UnixNanos::default();
5943
5944        let result = parse_spot_margin_position_from_balance(
5945            &balance,
5946            account_id,
5947            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
5948            size_precision,
5949            ts_init,
5950        )
5951        .unwrap();
5952
5953        assert!(result.is_none());
5954    }
5955
5956    #[rstest]
5957    fn test_parse_spot_margin_position_from_balance_liability_no_spot_in_use() {
5958        let balance = OKXBalanceDetail {
5959            ccy: Ustr::from("BTC"),
5960            liab: "0.5".to_string(),
5961            spot_in_use_amt: "0".to_string(),
5962            cross_liab: "0.5".to_string(),
5963            eq: "0".to_string(),
5964            u_time: 1704067200000,
5965            avail_bal: "0".to_string(),
5966            avail_eq: "0".to_string(),
5967            borrow_froz: "0".to_string(),
5968            cash_bal: "0".to_string(),
5969            dis_eq: "0".to_string(),
5970            eq_usd: "0".to_string(),
5971            smt_sync_eq: "0".to_string(),
5972            spot_copy_trading_eq: "0".to_string(),
5973            fixed_bal: "0".to_string(),
5974            frozen_bal: "0".to_string(),
5975            imr: "0".to_string(),
5976            interest: "0".to_string(),
5977            iso_eq: "0".to_string(),
5978            iso_liab: "0".to_string(),
5979            iso_upl: "0".to_string(),
5980            max_loan: "0".to_string(),
5981            mgn_ratio: "0".to_string(),
5982            mmr: "0".to_string(),
5983            notional_lever: "0".to_string(),
5984            ord_frozen: "0".to_string(),
5985            reward_bal: "0".to_string(),
5986            cl_spot_in_use_amt: "0".to_string(),
5987            max_spot_in_use_amt: "0".to_string(),
5988            spot_iso_bal: "0".to_string(),
5989            stgy_eq: "0".to_string(),
5990            twap: "0".to_string(),
5991            upl: "0".to_string(),
5992            upl_liab: "0".to_string(),
5993            spot_bal: "0".to_string(),
5994            open_avg_px: "0".to_string(),
5995            acc_avg_px: "0".to_string(),
5996            spot_upl: "0".to_string(),
5997            spot_upl_ratio: "0".to_string(),
5998            total_pnl: "0".to_string(),
5999            total_pnl_ratio: "0".to_string(),
6000        };
6001
6002        let account_id = AccountId::new("OKX-001");
6003        let size_precision = 8;
6004        let ts_init = UnixNanos::default();
6005
6006        let result = parse_spot_margin_position_from_balance(
6007            &balance,
6008            account_id,
6009            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
6010            size_precision,
6011            ts_init,
6012        )
6013        .unwrap();
6014
6015        assert!(result.is_none());
6016    }
6017
6018    #[rstest]
6019    fn test_parse_spot_margin_position_from_balance_empty_strings() {
6020        let balance = OKXBalanceDetail {
6021            ccy: Ustr::from("USDT"),
6022            liab: String::new(),
6023            spot_in_use_amt: String::new(),
6024            cross_liab: String::new(),
6025            eq: "5000.25".to_string(),
6026            u_time: 1704067200000,
6027            avail_bal: "5000.25".to_string(),
6028            avail_eq: "5000.25".to_string(),
6029            borrow_froz: String::new(),
6030            cash_bal: "5000.25".to_string(),
6031            dis_eq: String::new(),
6032            eq_usd: "5000.25".to_string(),
6033            smt_sync_eq: String::new(),
6034            spot_copy_trading_eq: String::new(),
6035            fixed_bal: String::new(),
6036            frozen_bal: String::new(),
6037            imr: String::new(),
6038            interest: String::new(),
6039            iso_eq: String::new(),
6040            iso_liab: String::new(),
6041            iso_upl: String::new(),
6042            max_loan: String::new(),
6043            mgn_ratio: String::new(),
6044            mmr: String::new(),
6045            notional_lever: String::new(),
6046            ord_frozen: String::new(),
6047            reward_bal: String::new(),
6048            cl_spot_in_use_amt: String::new(),
6049            max_spot_in_use_amt: String::new(),
6050            spot_iso_bal: String::new(),
6051            stgy_eq: String::new(),
6052            twap: String::new(),
6053            upl: String::new(),
6054            upl_liab: String::new(),
6055            spot_bal: "5000.25".to_string(),
6056            open_avg_px: String::new(),
6057            acc_avg_px: String::new(),
6058            spot_upl: String::new(),
6059            spot_upl_ratio: String::new(),
6060            total_pnl: String::new(),
6061            total_pnl_ratio: String::new(),
6062        };
6063
6064        let account_id = AccountId::new("OKX-001");
6065        let size_precision = 2;
6066        let ts_init = UnixNanos::default();
6067
6068        let result = parse_spot_margin_position_from_balance(
6069            &balance,
6070            account_id,
6071            InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
6072            size_precision,
6073            ts_init,
6074        )
6075        .unwrap();
6076
6077        // Empty strings should be treated as zero, returning None (no margin position)
6078        assert!(result.is_none());
6079    }
6080
6081    #[rstest]
6082    #[case::fok_maps_to_fok_tif(OKXOrderType::Fok, TimeInForce::Fok)]
6083    #[case::ioc_maps_to_ioc_tif(OKXOrderType::Ioc, TimeInForce::Ioc)]
6084    #[case::optimal_limit_ioc_maps_to_ioc_tif(OKXOrderType::OptimalLimitIoc, TimeInForce::Ioc)]
6085    #[case::market_maps_to_gtc(OKXOrderType::Market, TimeInForce::Gtc)]
6086    #[case::limit_maps_to_gtc(OKXOrderType::Limit, TimeInForce::Gtc)]
6087    #[case::post_only_maps_to_gtc(OKXOrderType::PostOnly, TimeInForce::Gtc)]
6088    #[case::trigger_maps_to_gtc(OKXOrderType::Trigger, TimeInForce::Gtc)]
6089    fn test_okx_order_type_to_time_in_force(
6090        #[case] okx_ord_type: OKXOrderType,
6091        #[case] expected_tif: TimeInForce,
6092    ) {
6093        let time_in_force = match okx_ord_type {
6094            OKXOrderType::Fok | OKXOrderType::OpFok => TimeInForce::Fok,
6095            OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
6096            _ => TimeInForce::Gtc,
6097        };
6098
6099        assert_eq!(
6100            time_in_force, expected_tif,
6101            "OKXOrderType::{okx_ord_type:?} should map to TimeInForce::{expected_tif:?}"
6102        );
6103    }
6104
6105    #[rstest]
6106    fn test_fok_order_type_serialization() {
6107        let ord_type = OKXOrderType::Fok;
6108        let json = serde_json::to_string(&ord_type).expect("serialize");
6109        assert_eq!(json, "\"fok\"", "FOK should serialize to 'fok'");
6110    }
6111
6112    #[rstest]
6113    fn test_ioc_order_type_serialization() {
6114        let ord_type = OKXOrderType::Ioc;
6115        let json = serde_json::to_string(&ord_type).expect("serialize");
6116        assert_eq!(json, "\"ioc\"", "IOC should serialize to 'ioc'");
6117    }
6118
6119    #[rstest]
6120    fn test_optimal_limit_ioc_serialization() {
6121        let ord_type = OKXOrderType::OptimalLimitIoc;
6122        let json = serde_json::to_string(&ord_type).expect("serialize");
6123        assert_eq!(
6124            json, "\"optimal_limit_ioc\"",
6125            "OptimalLimitIoc should serialize to 'optimal_limit_ioc'"
6126        );
6127    }
6128
6129    #[rstest]
6130    fn test_fok_order_type_deserialization() {
6131        let json = "\"fok\"";
6132        let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
6133        assert_eq!(ord_type, OKXOrderType::Fok);
6134    }
6135
6136    #[rstest]
6137    fn test_ioc_order_type_deserialization() {
6138        let json = "\"ioc\"";
6139        let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
6140        assert_eq!(ord_type, OKXOrderType::Ioc);
6141    }
6142
6143    #[rstest]
6144    fn test_optimal_limit_ioc_deserialization() {
6145        let json = "\"optimal_limit_ioc\"";
6146        let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
6147        assert_eq!(ord_type, OKXOrderType::OptimalLimitIoc);
6148    }
6149
6150    #[rstest]
6151    #[case(TimeInForce::Fok, OKXOrderType::Fok)]
6152    #[case(TimeInForce::Ioc, OKXOrderType::Ioc)]
6153    fn test_time_in_force_round_trip(
6154        #[case] original_tif: TimeInForce,
6155        #[case] expected_okx_type: OKXOrderType,
6156    ) {
6157        let okx_ord_type = match original_tif {
6158            TimeInForce::Fok => OKXOrderType::Fok,
6159            TimeInForce::Ioc => OKXOrderType::Ioc,
6160            TimeInForce::Gtc => OKXOrderType::Limit,
6161            _ => OKXOrderType::Limit,
6162        };
6163        assert_eq!(okx_ord_type, expected_okx_type);
6164
6165        let parsed_tif = match okx_ord_type {
6166            OKXOrderType::Fok | OKXOrderType::OpFok => TimeInForce::Fok,
6167            OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
6168            _ => TimeInForce::Gtc,
6169        };
6170        assert_eq!(parsed_tif, original_tif);
6171    }
6172
6173    #[rstest]
6174    #[case::limit_fok(
6175        OrderType::Limit,
6176        TimeInForce::Fok,
6177        OKXOrderType::Fok,
6178        "Limit + FOK should map to Fok"
6179    )]
6180    #[case::limit_ioc(
6181        OrderType::Limit,
6182        TimeInForce::Ioc,
6183        OKXOrderType::Ioc,
6184        "Limit + IOC should map to Ioc"
6185    )]
6186    #[case::market_ioc(
6187        OrderType::Market,
6188        TimeInForce::Ioc,
6189        OKXOrderType::OptimalLimitIoc,
6190        "Market + IOC should map to OptimalLimitIoc"
6191    )]
6192    #[case::limit_gtc(
6193        OrderType::Limit,
6194        TimeInForce::Gtc,
6195        OKXOrderType::Limit,
6196        "Limit + GTC should map to Limit"
6197    )]
6198    #[case::market_gtc(
6199        OrderType::Market,
6200        TimeInForce::Gtc,
6201        OKXOrderType::Market,
6202        "Market + GTC should map to Market"
6203    )]
6204    fn test_order_type_time_in_force_combinations(
6205        #[case] order_type: OrderType,
6206        #[case] tif: TimeInForce,
6207        #[case] expected_okx_type: OKXOrderType,
6208        #[case] description: &str,
6209    ) {
6210        let okx_ord_type = match (order_type, tif) {
6211            (OrderType::Market, TimeInForce::Ioc) => OKXOrderType::OptimalLimitIoc,
6212            (OrderType::Limit, TimeInForce::Fok) => OKXOrderType::Fok,
6213            (OrderType::Limit, TimeInForce::Ioc) => OKXOrderType::Ioc,
6214            _ => OKXOrderType::from(order_type),
6215        };
6216
6217        assert_eq!(okx_ord_type, expected_okx_type, "{description}");
6218    }
6219
6220    #[rstest]
6221    fn test_market_fok_not_supported() {
6222        let order_type = OrderType::Market;
6223        let tif = TimeInForce::Fok;
6224
6225        let is_market_fok = matches!((order_type, tif), (OrderType::Market, TimeInForce::Fok));
6226        assert!(
6227            is_market_fok,
6228            "Market + FOK combination should be identified for rejection"
6229        );
6230    }
6231
6232    #[rstest]
6233    #[case::empty_string("", true)]
6234    #[case::zero("0", true)]
6235    #[case::minus_one("-1", true)]
6236    #[case::minus_two("-2", true)]
6237    #[case::normal_price("100.5", false)]
6238    #[case::another_price("0.001", false)]
6239    fn test_is_market_price(#[case] price: &str, #[case] expected: bool) {
6240        assert_eq!(is_market_price(price), expected);
6241    }
6242
6243    #[rstest]
6244    #[case::fok_market(OKXOrderType::Fok, "", OrderType::Market)]
6245    #[case::fok_limit(OKXOrderType::Fok, "100.5", OrderType::Limit)]
6246    #[case::ioc_market(OKXOrderType::Ioc, "", OrderType::Market)]
6247    #[case::ioc_limit(OKXOrderType::Ioc, "100.5", OrderType::Limit)]
6248    #[case::optimal_limit_ioc_market(OKXOrderType::OptimalLimitIoc, "", OrderType::Market)]
6249    #[case::optimal_limit_ioc_market_zero(OKXOrderType::OptimalLimitIoc, "0", OrderType::Market)]
6250    #[case::optimal_limit_ioc_market_minus_one(
6251        OKXOrderType::OptimalLimitIoc,
6252        "-1",
6253        OrderType::Market
6254    )]
6255    #[case::optimal_limit_ioc_limit(OKXOrderType::OptimalLimitIoc, "100.5", OrderType::Limit)]
6256    #[case::market_passthrough(OKXOrderType::Market, "", OrderType::Market)]
6257    #[case::limit_passthrough(OKXOrderType::Limit, "100.5", OrderType::Limit)]
6258    fn test_determine_order_type(
6259        #[case] okx_ord_type: OKXOrderType,
6260        #[case] price: &str,
6261        #[case] expected: OrderType,
6262    ) {
6263        assert_eq!(determine_order_type(okx_ord_type, price).unwrap(), expected);
6264    }
6265
6266    #[rstest]
6267    fn test_determine_order_type_rejects_unknown_order_type() {
6268        assert!(determine_order_type(OKXOrderType::Other, "100.5").is_err());
6269    }
6270
6271    #[rstest]
6272    #[case::option("BTC-USD-250328-92000-C", "BTC-USD")]
6273    #[case::swap("BTC-USDT-SWAP", "BTC-USDT")]
6274    #[case::futures("ETH-USD-250328", "ETH-USD")]
6275    #[case::spot("BTC-USDT", "BTC-USDT")]
6276    fn test_extract_inst_family(#[case] symbol: &str, #[case] expected: &str) {
6277        let family = extract_inst_family(symbol).unwrap();
6278        assert_eq!(family.as_str(), expected);
6279    }
6280
6281    #[rstest]
6282    fn test_extract_inst_family_single_segment_fails() {
6283        extract_inst_family("BTC").unwrap_err();
6284    }
6285
6286    #[rstest]
6287    #[case("BTC-USDT", OKXInstrumentType::Spot)]
6288    #[case("BTC-USDT-SWAP", OKXInstrumentType::Swap)]
6289    #[case("BTC-USDT-250328", OKXInstrumentType::Futures)]
6290    #[case("BTC-USD-250328-50000-C", OKXInstrumentType::Option)]
6291    #[case("BTC-ABOVE-DAILY-260224-1600-65000", OKXInstrumentType::Events)]
6292    fn test_okx_instrument_type_from_symbol(
6293        #[case] symbol: &str,
6294        #[case] expected: OKXInstrumentType,
6295    ) {
6296        assert_eq!(okx_instrument_type_from_symbol(symbol), expected);
6297    }
6298
6299    #[rstest]
6300    #[case(OKXInstrumentStatus::Live, MarketStatusAction::Trading)]
6301    #[case(OKXInstrumentStatus::Suspend, MarketStatusAction::Suspend)]
6302    #[case(OKXInstrumentStatus::Preopen, MarketStatusAction::PreOpen)]
6303    #[case(OKXInstrumentStatus::Test, MarketStatusAction::NotAvailableForTrading)]
6304    #[case(OKXInstrumentStatus::PostOnly, MarketStatusAction::Quoting)]
6305    #[case(
6306        OKXInstrumentStatus::Rebase,
6307        MarketStatusAction::NotAvailableForTrading
6308    )]
6309    #[case(
6310        OKXInstrumentStatus::Settling,
6311        MarketStatusAction::NotAvailableForTrading
6312    )]
6313    #[case(
6314        OKXInstrumentStatus::Unknown,
6315        MarketStatusAction::NotAvailableForTrading
6316    )]
6317    fn test_okx_status_to_market_action(
6318        #[case] status: OKXInstrumentStatus,
6319        #[case] expected: MarketStatusAction,
6320    ) {
6321        assert_eq!(okx_status_to_market_action(status), expected);
6322    }
6323
6324    #[rstest]
6325    #[case::future_state("\"future_state_xyz\"")]
6326    #[case::frozen("\"frozen\"")]
6327    #[case::delisting("\"delisting\"")]
6328    fn test_okx_unknown_status_falls_back(#[case] json: &str) {
6329        let parsed: OKXInstrumentStatus = serde_json::from_str(json).unwrap();
6330        assert_eq!(parsed, OKXInstrumentStatus::Unknown);
6331        assert_eq!(
6332            okx_status_to_market_action(parsed),
6333            MarketStatusAction::NotAvailableForTrading
6334        );
6335    }
6336
6337    #[rstest]
6338    #[case::crypto("\"1\"", OKXInstrumentCategory::Crypto, AssetClass::Cryptocurrency)]
6339    #[case::equity("\"3\"", OKXInstrumentCategory::Equity, AssetClass::Equity)]
6340    #[case::commodity("\"4\"", OKXInstrumentCategory::Commodity, AssetClass::Commodity)]
6341    #[case::fx("\"5\"", OKXInstrumentCategory::Fx, AssetClass::FX)]
6342    #[case::debt("\"6\"", OKXInstrumentCategory::Debt, AssetClass::Debt)]
6343    #[case::unknown_code("\"2\"", OKXInstrumentCategory::Unknown, AssetClass::Alternative)]
6344    fn test_okx_inst_category_parsing_and_asset_class(
6345        #[case] json: &str,
6346        #[case] expected: OKXInstrumentCategory,
6347        #[case] asset_class: AssetClass,
6348    ) {
6349        let parsed: OKXInstrumentCategory = serde_json::from_str(json).unwrap();
6350        assert_eq!(parsed, expected);
6351        assert_eq!(okx_inst_category_to_asset_class(Some(parsed)), asset_class);
6352    }
6353
6354    #[rstest]
6355    fn test_okx_instrument_reads_inst_category_and_ignores_legacy_category() {
6356        // OKX sends both `category` (deprecated) and `instCategory`; the model
6357        // must read `instCategory` and ignore `category`.
6358        let json = crate::common::testing::load_test_json("http_get_instruments_spot.json");
6359        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
6360        let item = &mut value["data"][0];
6361        assert_eq!(item["category"], serde_json::json!("1"));
6362        item["instCategory"] = serde_json::json!("3"); // must differ from category to prove it wins
6363
6364        let instrument: OKXInstrument = serde_json::from_value(item.clone()).unwrap();
6365        assert_eq!(
6366            instrument.inst_category,
6367            Some(OKXInstrumentCategory::Equity)
6368        );
6369        assert_eq!(
6370            okx_inst_category_to_asset_class(instrument.inst_category),
6371            AssetClass::Equity
6372        );
6373    }
6374
6375    #[rstest]
6376    fn test_rpi_instrument_permission_parses_current_and_legacy_fields() {
6377        let json = load_test_json("http_get_instruments_spot.json");
6378        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
6379        let mut current = value["data"][0].clone();
6380        current["rpi"] = serde_json::json!("2");
6381        let current: OKXInstrument = serde_json::from_value(current).unwrap();
6382
6383        let legacy = &mut value["data"][1];
6384        legacy["elp"] = serde_json::json!("1");
6385        let legacy: OKXInstrument = serde_json::from_value(legacy.clone()).unwrap();
6386
6387        assert_eq!(
6388            current.rpi,
6389            Some(crate::common::enums::OKXRpiPermission::Permitted)
6390        );
6391        assert_eq!(
6392            legacy.rpi,
6393            Some(crate::common::enums::OKXRpiPermission::Enabled)
6394        );
6395    }
6396
6397    #[rstest]
6398    fn test_rpi_instrument_spacing_fields_are_typed_and_reachable() {
6399        let json = load_test_json("http_get_instruments_spot.json");
6400        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json).unwrap();
6401        let okx_inst = response
6402            .data
6403            .first()
6404            .expect("Test data must have an instrument");
6405
6406        assert_eq!(okx_inst.rpi_min_level, Some(5));
6407        assert_eq!(okx_inst.rpi_min_px_band, Some(Decimal::from(20)));
6408
6409        let instrument =
6410            parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
6411        let InstrumentAny::CurrencyPair(pair) = instrument else {
6412            panic!("expected CurrencyPair");
6413        };
6414        let info = pair.info.expect("RPI spacing info must be set");
6415
6416        assert_eq!(info.get_u64("okx_rpi_min_level"), Some(5));
6417        assert_eq!(info.get_str("okx_rpi_min_px_band"), Some("20"));
6418    }
6419}