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