nautilus_okx/common/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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 nautilus_core::{
21    UUID4,
22    datetime::{NANOSECONDS_IN_MILLISECOND, millis_to_nanos},
23    nanos::UnixNanos,
24};
25use nautilus_model::{
26    currencies::CURRENCY_MAP,
27    data::{
28        Bar, BarSpecification, BarType, Data, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
29        TradeTick,
30        bar::{
31            BAR_SPEC_1_DAY_LAST, BAR_SPEC_1_HOUR_LAST, BAR_SPEC_1_MINUTE_LAST,
32            BAR_SPEC_1_MONTH_LAST, BAR_SPEC_1_SECOND_LAST, BAR_SPEC_1_WEEK_LAST,
33            BAR_SPEC_2_DAY_LAST, BAR_SPEC_2_HOUR_LAST, BAR_SPEC_3_DAY_LAST, BAR_SPEC_3_MINUTE_LAST,
34            BAR_SPEC_3_MONTH_LAST, BAR_SPEC_4_HOUR_LAST, BAR_SPEC_5_DAY_LAST,
35            BAR_SPEC_5_MINUTE_LAST, BAR_SPEC_6_HOUR_LAST, BAR_SPEC_6_MONTH_LAST,
36            BAR_SPEC_12_HOUR_LAST, BAR_SPEC_12_MONTH_LAST, BAR_SPEC_15_MINUTE_LAST,
37            BAR_SPEC_30_MINUTE_LAST,
38        },
39    },
40    enums::{
41        AccountType, AggregationSource, AggressorSide, AssetClass, CurrencyType, LiquiditySide,
42        OptionKind, OrderSide, OrderStatus, OrderType, PositionSide, TimeInForce,
43    },
44    events::AccountState,
45    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, Venue, VenueOrderId},
46    instruments::{CryptoFuture, CryptoPerpetual, CurrencyPair, InstrumentAny, OptionContract},
47    reports::{FillReport, OrderStatusReport, PositionStatusReport},
48    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
49};
50use rust_decimal::Decimal;
51use serde::{Deserialize, Deserializer, de::DeserializeOwned};
52use ustr::Ustr;
53
54use super::enums::OKXContractType;
55use crate::{
56    common::{
57        consts::OKX_VENUE,
58        enums::{
59            OKXExecType, OKXInstrumentType, OKXOrderStatus, OKXOrderType, OKXPositionSide, OKXSide,
60            OKXVipLevel,
61        },
62        models::OKXInstrument,
63    },
64    http::models::{
65        OKXAccount, OKXCandlestick, OKXIndexTicker, OKXMarkPrice, OKXOrderHistory, OKXPosition,
66        OKXTrade, OKXTransactionDetail,
67    },
68    websocket::{enums::OKXWsChannel, messages::OKXFundingRateMsg},
69};
70
71/// Deserializes an empty string into [`None`].
72///
73/// OKX frequently represents *null* string fields as an empty string (`""`).
74/// When such a payload is mapped onto `Option<String>` the default behaviour
75/// would yield `Some("")`, which is semantically different from the intended
76/// absence of a value.  Applying this helper via
77///
78/// ```rust
79/// #[serde(deserialize_with = "crate::common::parse::deserialize_empty_string_as_none")]
80/// pub cl_ord_id: Option<String>,
81/// ```
82///
83/// ensures that empty strings are normalised to `None` during deserialization.
84///
85/// # Errors
86///
87/// Returns an error if the JSON value cannot be deserialised into a string.
88pub fn deserialize_empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
89where
90    D: Deserializer<'de>,
91{
92    let opt = Option::<String>::deserialize(deserializer)?;
93    Ok(opt.filter(|s| !s.is_empty()))
94}
95
96/// Deserializes an empty [`Ustr`] into [`None`].
97///
98/// # Errors
99///
100/// Returns an error if the JSON value cannot be deserialised into a string.
101pub fn deserialize_empty_ustr_as_none<'de, D>(deserializer: D) -> Result<Option<Ustr>, D::Error>
102where
103    D: Deserializer<'de>,
104{
105    let opt = Option::<Ustr>::deserialize(deserializer)?;
106    Ok(opt.filter(|s| !s.is_empty()))
107}
108
109/// Deserializes a numeric string into a `u64`.
110///
111/// # Errors
112///
113/// Returns an error if the string cannot be parsed into a `u64`.
114pub fn deserialize_string_to_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
115where
116    D: Deserializer<'de>,
117{
118    let s = String::deserialize(deserializer)?;
119    if s.is_empty() {
120        Ok(0)
121    } else {
122        s.parse::<u64>().map_err(serde::de::Error::custom)
123    }
124}
125
126/// Deserializes an optional numeric string into `Option<u64>`.
127///
128/// # Errors
129///
130/// Returns an error under the same cases as [`deserialize_string_to_u64`].
131pub fn deserialize_optional_string_to_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
132where
133    D: Deserializer<'de>,
134{
135    let s: Option<String> = Option::deserialize(deserializer)?;
136    match s {
137        Some(s) if s.is_empty() => Ok(None),
138        Some(s) => s.parse().map(Some).map_err(serde::de::Error::custom),
139        None => Ok(None),
140    }
141}
142
143/// Deserializes an OKX VIP level string (e.g., "Lv4") into [`OKXVipLevel`].
144///
145/// OKX returns VIP levels as strings like "Lv0", "Lv1", ..., "Lv9".
146/// This function strips the "Lv" prefix and parses the numeric value.
147///
148/// # Errors
149///
150/// Returns an error if the string cannot be parsed into a valid VIP level.
151pub fn deserialize_vip_level<'de, D>(deserializer: D) -> Result<OKXVipLevel, D::Error>
152where
153    D: Deserializer<'de>,
154{
155    let s = String::deserialize(deserializer)?;
156
157    // Strip "Lv" prefix if present
158    let level_str = s
159        .strip_prefix("Lv")
160        .or_else(|| s.strip_prefix("lv"))
161        .unwrap_or(&s);
162
163    // Parse the numeric value
164    let level_num = level_str
165        .parse::<u8>()
166        .map_err(|e| serde::de::Error::custom(format!("Invalid VIP level '{s}': {e}")))?;
167
168    // Convert to enum
169    Ok(OKXVipLevel::from(level_num))
170}
171
172/// Returns the currency either from the internal currency map or creates a default crypto.
173fn get_currency(code: &str) -> Currency {
174    CURRENCY_MAP
175        .lock()
176        .unwrap()
177        .get(code)
178        .copied()
179        .unwrap_or(Currency::new(code, 8, 0, code, CurrencyType::Crypto))
180}
181
182/// Returns the [`OKXInstrumentType`] that corresponds to the supplied
183/// [`InstrumentAny`].
184///
185/// # Errors
186///
187/// Returns an error if the instrument variant is not supported by OKX.
188pub fn okx_instrument_type(instrument: &InstrumentAny) -> anyhow::Result<OKXInstrumentType> {
189    match instrument {
190        InstrumentAny::CurrencyPair(_) => Ok(OKXInstrumentType::Spot),
191        InstrumentAny::CryptoPerpetual(_) => Ok(OKXInstrumentType::Swap),
192        InstrumentAny::CryptoFuture(_) => Ok(OKXInstrumentType::Futures),
193        InstrumentAny::CryptoOption(_) => Ok(OKXInstrumentType::Option),
194        _ => anyhow::bail!("Invalid instrument type for OKX: {instrument:?}"),
195    }
196}
197
198/// Parses a Nautilus instrument ID from the given OKX `symbol` value.
199#[must_use]
200pub fn parse_instrument_id(symbol: Ustr) -> InstrumentId {
201    InstrumentId::new(Symbol::from_ustr_unchecked(symbol), *OKX_VENUE)
202}
203
204/// Parses a Nautilus client order ID from the given OKX `clOrdId` value.
205#[must_use]
206pub fn parse_client_order_id(value: &str) -> Option<ClientOrderId> {
207    if value.is_empty() {
208        None
209    } else {
210        Some(ClientOrderId::new(value))
211    }
212}
213
214/// Converts a millisecond-based timestamp (as returned by OKX) into
215/// [`UnixNanos`].
216#[must_use]
217pub fn parse_millisecond_timestamp(timestamp_ms: u64) -> UnixNanos {
218    UnixNanos::from(timestamp_ms * NANOSECONDS_IN_MILLISECOND)
219}
220
221/// Parses an RFC 3339 timestamp string into [`UnixNanos`].
222///
223/// # Errors
224///
225/// Returns an error if the string is not a valid RFC 3339 datetime or if the
226/// timestamp cannot be represented in nanoseconds.
227pub fn parse_rfc3339_timestamp(timestamp: &str) -> anyhow::Result<UnixNanos> {
228    let dt = chrono::DateTime::parse_from_rfc3339(timestamp)?;
229    let nanos = dt.timestamp_nanos_opt().ok_or_else(|| {
230        anyhow::anyhow!("Failed to extract nanoseconds from timestamp: {timestamp}")
231    })?;
232    Ok(UnixNanos::from(nanos as u64))
233}
234
235/// Converts a textual price to a [`Price`] using the given precision.
236///
237/// # Errors
238///
239/// Returns an error if the string fails to parse into `f64` or if the number
240/// of decimal places exceeds `precision`.
241pub fn parse_price(value: &str, precision: u8) -> anyhow::Result<Price> {
242    Price::new_checked(value.parse::<f64>()?, precision)
243}
244
245/// Converts a textual quantity to a [`Quantity`].
246///
247/// # Errors
248///
249/// Returns an error for the same reasons as [`parse_price`] – parsing failure or invalid
250/// precision.
251pub fn parse_quantity(value: &str, precision: u8) -> anyhow::Result<Quantity> {
252    Quantity::new_checked(value.parse::<f64>()?, precision)
253}
254
255/// Converts a textual fee amount into a [`Money`] value.
256///
257/// OKX represents *charges* as positive numbers but they reduce the account
258/// balance, hence the value is negated.
259///
260/// # Errors
261///
262/// Returns an error if the fee cannot be parsed into `f64` or fails internal
263/// validation in [`Money::new_checked`].
264pub fn parse_fee(value: Option<&str>, currency: Currency) -> anyhow::Result<Money> {
265    // OKX report positive fees with negative signs (i.e., fee charged)
266    let fee_f64 = value.unwrap_or("0").parse::<f64>()?;
267    Money::new_checked(-fee_f64, currency)
268}
269
270/// Parses OKX side to Nautilus aggressor side.
271pub fn parse_aggressor_side(side: &Option<OKXSide>) -> AggressorSide {
272    match side {
273        Some(OKXSide::Buy) => AggressorSide::Buyer,
274        Some(OKXSide::Sell) => AggressorSide::Seller,
275        None => AggressorSide::NoAggressor,
276    }
277}
278
279/// Parses OKX execution type to Nautilus liquidity side.
280pub fn parse_execution_type(liquidity: &Option<OKXExecType>) -> LiquiditySide {
281    match liquidity {
282        Some(OKXExecType::Maker) => LiquiditySide::Maker,
283        Some(OKXExecType::Taker) => LiquiditySide::Taker,
284        _ => LiquiditySide::NoLiquiditySide,
285    }
286}
287
288/// Parses quantity to Nautilus position side.
289pub fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
290    match current_qty {
291        Some(qty) if qty > 0 => PositionSide::Long,
292        Some(qty) if qty < 0 => PositionSide::Short,
293        _ => PositionSide::Flat,
294    }
295}
296
297/// Parses an OKX mark price record into a Nautilus [`MarkPriceUpdate`].
298///
299/// # Errors
300///
301/// Returns an error if `raw.mark_px` cannot be parsed into a [`Price`] with
302/// the specified precision.
303pub fn parse_mark_price_update(
304    raw: &OKXMarkPrice,
305    instrument_id: InstrumentId,
306    price_precision: u8,
307    ts_init: UnixNanos,
308) -> anyhow::Result<MarkPriceUpdate> {
309    let ts_event = parse_millisecond_timestamp(raw.ts);
310    let price = parse_price(&raw.mark_px, price_precision)?;
311    Ok(MarkPriceUpdate::new(
312        instrument_id,
313        price,
314        ts_event,
315        ts_init,
316    ))
317}
318
319/// Parses an OKX index ticker record into a Nautilus [`IndexPriceUpdate`].
320///
321/// # Errors
322///
323/// Returns an error if `raw.idx_px` cannot be parsed into a [`Price`] with the
324/// specified precision.
325pub fn parse_index_price_update(
326    raw: &OKXIndexTicker,
327    instrument_id: InstrumentId,
328    price_precision: u8,
329    ts_init: UnixNanos,
330) -> anyhow::Result<IndexPriceUpdate> {
331    let ts_event = parse_millisecond_timestamp(raw.ts);
332    let price = parse_price(&raw.idx_px, price_precision)?;
333    Ok(IndexPriceUpdate::new(
334        instrument_id,
335        price,
336        ts_event,
337        ts_init,
338    ))
339}
340
341/// Parses an [`OKXFundingRateMsg`] into a [`FundingRateUpdate`].
342///
343/// # Errors
344///
345/// Returns an error if the `funding_rate` or `next_funding_rate` fields fail
346/// to parse into Decimal values.
347pub fn parse_funding_rate_msg(
348    msg: &OKXFundingRateMsg,
349    instrument_id: InstrumentId,
350    ts_init: UnixNanos,
351) -> anyhow::Result<FundingRateUpdate> {
352    let funding_rate = msg
353        .funding_rate
354        .as_str()
355        .parse::<Decimal>()
356        .map_err(|e| anyhow::anyhow!("Invalid funding_rate value: {e}"))?
357        .normalize();
358
359    let funding_time = Some(parse_millisecond_timestamp(msg.funding_time));
360    let ts_event = parse_millisecond_timestamp(msg.ts);
361
362    Ok(FundingRateUpdate::new(
363        instrument_id,
364        funding_rate,
365        funding_time,
366        ts_event,
367        ts_init,
368    ))
369}
370
371/// Parses an OKX trade record into a Nautilus [`TradeTick`].
372///
373/// # Errors
374///
375/// Returns an error if the price or quantity strings cannot be parsed, or if
376/// [`TradeTick::new_checked`] validation fails.
377pub fn parse_trade_tick(
378    raw: &OKXTrade,
379    instrument_id: InstrumentId,
380    price_precision: u8,
381    size_precision: u8,
382    ts_init: UnixNanos,
383) -> anyhow::Result<TradeTick> {
384    let ts_event = parse_millisecond_timestamp(raw.ts);
385    let price = parse_price(&raw.px, price_precision)?;
386    let size = parse_quantity(&raw.sz, size_precision)?;
387    let aggressor: AggressorSide = raw.side.into();
388    let trade_id = TradeId::new(raw.trade_id);
389
390    TradeTick::new_checked(
391        instrument_id,
392        price,
393        size,
394        aggressor,
395        trade_id,
396        ts_event,
397        ts_init,
398    )
399}
400
401/// Parses an OKX historical candlestick record into a Nautilus [`Bar`].
402///
403/// # Errors
404///
405/// Returns an error if any of the price or volume strings cannot be parsed or
406/// if [`Bar::new`] validation fails.
407pub fn parse_candlestick(
408    raw: &OKXCandlestick,
409    bar_type: BarType,
410    price_precision: u8,
411    size_precision: u8,
412    ts_init: UnixNanos,
413) -> anyhow::Result<Bar> {
414    let ts_event = parse_millisecond_timestamp(raw.0.parse()?);
415    let open = parse_price(&raw.1, price_precision)?;
416    let high = parse_price(&raw.2, price_precision)?;
417    let low = parse_price(&raw.3, price_precision)?;
418    let close = parse_price(&raw.4, price_precision)?;
419    let volume = parse_quantity(&raw.5, size_precision)?;
420
421    Ok(Bar::new(
422        bar_type, open, high, low, close, volume, ts_event, ts_init,
423    ))
424}
425
426/// Parses an OKX order history record into a Nautilus [`OrderStatusReport`].
427#[allow(clippy::too_many_lines)]
428pub fn parse_order_status_report(
429    order: &OKXOrderHistory,
430    account_id: AccountId,
431    instrument_id: InstrumentId,
432    price_precision: u8,
433    size_precision: u8,
434    ts_init: UnixNanos,
435) -> OrderStatusReport {
436    let quantity = order
437        .sz
438        .parse::<f64>()
439        .ok()
440        .map(|v| Quantity::new(v, size_precision))
441        .unwrap_or_default();
442    let filled_qty = order
443        .acc_fill_sz
444        .parse::<f64>()
445        .ok()
446        .map(|v| Quantity::new(v, size_precision))
447        .unwrap_or_default();
448    let order_side: OrderSide = order.side.into();
449    let okx_status: OKXOrderStatus = order.state;
450    let order_status: OrderStatus = okx_status.into();
451    let okx_ord_type: OKXOrderType = order.ord_type;
452    let order_type: OrderType = okx_ord_type.into();
453    // Note: OKX uses ordType for type and liquidity instructions; time-in-force not explicitly represented here
454    let time_in_force = TimeInForce::Gtc;
455
456    // Build report
457    let mut client_order_id = if order.cl_ord_id.is_empty() {
458        None
459    } else {
460        Some(ClientOrderId::new(order.cl_ord_id.as_str()))
461    };
462
463    let mut linked_ids = Vec::new();
464
465    if let Some(algo_cl_ord_id) = order
466        .algo_cl_ord_id
467        .as_ref()
468        .filter(|value| !value.as_str().is_empty())
469    {
470        let algo_client_id = ClientOrderId::new(algo_cl_ord_id.as_str());
471        match &client_order_id {
472            Some(existing) if existing == &algo_client_id => {}
473            Some(_) => linked_ids.push(algo_client_id),
474            None => client_order_id = Some(algo_client_id),
475        }
476    }
477
478    let venue_order_id = if order.ord_id.is_empty() {
479        if let Some(algo_id) = order
480            .algo_id
481            .as_ref()
482            .filter(|value| !value.as_str().is_empty())
483        {
484            VenueOrderId::new(algo_id.as_str())
485        } else if !order.cl_ord_id.is_empty() {
486            VenueOrderId::new(order.cl_ord_id.as_str())
487        } else {
488            let synthetic_id = format!("{}:{}", account_id, order.c_time);
489            VenueOrderId::new(&synthetic_id)
490        }
491    } else {
492        VenueOrderId::new(order.ord_id.as_str())
493    };
494
495    let ts_accepted = parse_millisecond_timestamp(order.c_time);
496    let ts_last = UnixNanos::from(order.u_time * NANOSECONDS_IN_MILLISECOND);
497
498    let mut report = OrderStatusReport::new(
499        account_id,
500        instrument_id,
501        client_order_id,
502        venue_order_id,
503        order_side,
504        order_type,
505        time_in_force,
506        order_status,
507        quantity,
508        filled_qty,
509        ts_accepted,
510        ts_last,
511        ts_init,
512        None,
513    );
514
515    // Optional fields
516    if !order.px.is_empty()
517        && let Ok(p) = order.px.parse::<f64>()
518    {
519        report = report.with_price(Price::new(p, price_precision));
520    }
521    if !order.avg_px.is_empty()
522        && let Ok(avg) = order.avg_px.parse::<f64>()
523    {
524        report = report.with_avg_px(avg);
525    }
526    if order.ord_type == OKXOrderType::PostOnly {
527        report = report.with_post_only(true);
528    }
529    if order.reduce_only == "true" {
530        report = report.with_reduce_only(true);
531    }
532
533    if !linked_ids.is_empty() {
534        report = report.with_linked_order_ids(linked_ids);
535    }
536
537    report
538}
539
540/// Parses an OKX position into a Nautilus [`PositionStatusReport`].
541///
542/// # Errors
543///
544/// Returns an error if any numeric fields cannot be parsed into their target types.
545///
546/// # Panics
547///
548/// Panics if position quantity is invalid and cannot be parsed.
549#[allow(clippy::too_many_lines)]
550pub fn parse_position_status_report(
551    position: OKXPosition,
552    account_id: AccountId,
553    instrument_id: InstrumentId,
554    size_precision: u8,
555    ts_init: UnixNanos,
556) -> anyhow::Result<PositionStatusReport> {
557    let pos_value = position.pos.parse::<f64>().unwrap_or_else(|e| {
558        panic!(
559            "Failed to parse position quantity '{}' for instrument {}: {:?}",
560            position.pos, instrument_id, e
561        )
562    });
563
564    // For Net position mode, determine side based on position sign
565    let position_side = match position.pos_side {
566        OKXPositionSide::Net => {
567            if pos_value > 0.0 {
568                PositionSide::Long
569            } else if pos_value < 0.0 {
570                PositionSide::Short
571            } else {
572                PositionSide::Flat
573            }
574        }
575        _ => position.pos_side.into(),
576    }
577    .as_specified();
578
579    // Convert to absolute quantity (positions are always positive in Nautilus)
580    let quantity = Quantity::new(pos_value.abs(), size_precision);
581    let venue_position_id = None; // TODO: Only support netting for now
582    // let venue_position_id = Some(PositionId::new(position.pos_id));
583    let avg_px_open = if position.avg_px.is_empty() {
584        None
585    } else {
586        Some(Decimal::from_str(&position.avg_px)?)
587    };
588    let ts_last = parse_millisecond_timestamp(position.u_time);
589
590    Ok(PositionStatusReport::new(
591        account_id,
592        instrument_id,
593        position_side,
594        quantity,
595        ts_last,
596        ts_init,
597        None, // Will generate a UUID4
598        venue_position_id,
599        avg_px_open,
600    ))
601}
602
603/// Parses an OKX transaction detail into a Nautilus `FillReport`.
604///
605/// # Errors
606///
607/// Returns an error if the OKX transaction detail cannot be parsed.
608pub fn parse_fill_report(
609    detail: OKXTransactionDetail,
610    account_id: AccountId,
611    instrument_id: InstrumentId,
612    price_precision: u8,
613    size_precision: u8,
614    ts_init: UnixNanos,
615) -> anyhow::Result<FillReport> {
616    let client_order_id = if detail.cl_ord_id.is_empty() {
617        None
618    } else {
619        Some(ClientOrderId::new(detail.cl_ord_id))
620    };
621    let venue_order_id = VenueOrderId::new(detail.ord_id);
622    let trade_id = TradeId::new(detail.trade_id);
623    let order_side: OrderSide = detail.side.into();
624    let last_px = parse_price(&detail.fill_px, price_precision)?;
625    let last_qty = parse_quantity(&detail.fill_sz, size_precision)?;
626    let fee_f64 = detail.fee.as_deref().unwrap_or("0").parse::<f64>()?;
627    let commission = Money::new(-fee_f64, Currency::from(&detail.fee_ccy));
628    let liquidity_side: LiquiditySide = detail.exec_type.into();
629    let ts_event = parse_millisecond_timestamp(detail.ts);
630
631    Ok(FillReport::new(
632        account_id,
633        instrument_id,
634        venue_order_id,
635        trade_id,
636        order_side,
637        last_qty,
638        last_px,
639        commission,
640        liquidity_side,
641        client_order_id,
642        None, // venue_position_id not provided by OKX fills
643        ts_event,
644        ts_init,
645        None, // Will generate a new UUID4
646    ))
647}
648
649/// Parses vector messages from OKX WebSocket data.
650///
651/// Reduces code duplication by providing a common pattern for deserializing JSON arrays,
652/// parsing each message, and wrapping results in Nautilus Data enum variants.
653///
654/// # Errors
655///
656/// Returns an error if the payload is not an array or if individual messages
657/// cannot be parsed.
658pub fn parse_message_vec<T, R, F, W>(
659    data: serde_json::Value,
660    parser: F,
661    wrapper: W,
662) -> anyhow::Result<Vec<Data>>
663where
664    T: DeserializeOwned,
665    F: Fn(&T) -> anyhow::Result<R>,
666    W: Fn(R) -> Data,
667{
668    let items = match data {
669        serde_json::Value::Array(items) => items,
670        other => {
671            let raw = serde_json::to_string(&other).unwrap_or_else(|_| other.to_string());
672            let mut snippet: String = raw.chars().take(512).collect();
673            if raw.len() > snippet.len() {
674                snippet.push_str("...");
675            }
676            anyhow::bail!("Expected array payload, received {snippet}");
677        }
678    };
679
680    let mut results = Vec::with_capacity(items.len());
681
682    for item in items {
683        let message: T = serde_json::from_value(item)?;
684        let parsed = parser(&message)?;
685        results.push(wrapper(parsed));
686    }
687
688    Ok(results)
689}
690
691/// Converts a Nautilus bar specification into the matching OKX candle channel.
692///
693/// # Errors
694///
695/// Returns an error if the provided bar specification does not have a matching
696/// OKX websocket channel.
697pub fn bar_spec_as_okx_channel(bar_spec: BarSpecification) -> anyhow::Result<OKXWsChannel> {
698    let channel = match bar_spec {
699        BAR_SPEC_1_SECOND_LAST => OKXWsChannel::Candle1Second,
700        BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::Candle1Minute,
701        BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::Candle3Minute,
702        BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::Candle5Minute,
703        BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::Candle15Minute,
704        BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::Candle30Minute,
705        BAR_SPEC_1_HOUR_LAST => OKXWsChannel::Candle1Hour,
706        BAR_SPEC_2_HOUR_LAST => OKXWsChannel::Candle2Hour,
707        BAR_SPEC_4_HOUR_LAST => OKXWsChannel::Candle4Hour,
708        BAR_SPEC_6_HOUR_LAST => OKXWsChannel::Candle6Hour,
709        BAR_SPEC_12_HOUR_LAST => OKXWsChannel::Candle12Hour,
710        BAR_SPEC_1_DAY_LAST => OKXWsChannel::Candle1Day,
711        BAR_SPEC_2_DAY_LAST => OKXWsChannel::Candle2Day,
712        BAR_SPEC_3_DAY_LAST => OKXWsChannel::Candle3Day,
713        BAR_SPEC_5_DAY_LAST => OKXWsChannel::Candle5Day,
714        BAR_SPEC_1_WEEK_LAST => OKXWsChannel::Candle1Week,
715        BAR_SPEC_1_MONTH_LAST => OKXWsChannel::Candle1Month,
716        BAR_SPEC_3_MONTH_LAST => OKXWsChannel::Candle3Month,
717        BAR_SPEC_6_MONTH_LAST => OKXWsChannel::Candle6Month,
718        BAR_SPEC_12_MONTH_LAST => OKXWsChannel::Candle1Year,
719        _ => anyhow::bail!("Invalid `BarSpecification` for channel, was {bar_spec}"),
720    };
721    Ok(channel)
722}
723
724/// Converts Nautilus bar specification to OKX mark price channel.
725///
726/// # Errors
727///
728/// Returns an error if the bar specification does not map to a mark price
729/// channel.
730pub fn bar_spec_as_okx_mark_price_channel(
731    bar_spec: BarSpecification,
732) -> anyhow::Result<OKXWsChannel> {
733    let channel = match bar_spec {
734        BAR_SPEC_1_SECOND_LAST => OKXWsChannel::MarkPriceCandle1Second,
735        BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::MarkPriceCandle1Minute,
736        BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::MarkPriceCandle3Minute,
737        BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::MarkPriceCandle5Minute,
738        BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::MarkPriceCandle15Minute,
739        BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::MarkPriceCandle30Minute,
740        BAR_SPEC_1_HOUR_LAST => OKXWsChannel::MarkPriceCandle1Hour,
741        BAR_SPEC_2_HOUR_LAST => OKXWsChannel::MarkPriceCandle2Hour,
742        BAR_SPEC_4_HOUR_LAST => OKXWsChannel::MarkPriceCandle4Hour,
743        BAR_SPEC_6_HOUR_LAST => OKXWsChannel::MarkPriceCandle6Hour,
744        BAR_SPEC_12_HOUR_LAST => OKXWsChannel::MarkPriceCandle12Hour,
745        BAR_SPEC_1_DAY_LAST => OKXWsChannel::MarkPriceCandle1Day,
746        BAR_SPEC_2_DAY_LAST => OKXWsChannel::MarkPriceCandle2Day,
747        BAR_SPEC_3_DAY_LAST => OKXWsChannel::MarkPriceCandle3Day,
748        BAR_SPEC_5_DAY_LAST => OKXWsChannel::MarkPriceCandle5Day,
749        BAR_SPEC_1_WEEK_LAST => OKXWsChannel::MarkPriceCandle1Week,
750        BAR_SPEC_1_MONTH_LAST => OKXWsChannel::MarkPriceCandle1Month,
751        BAR_SPEC_3_MONTH_LAST => OKXWsChannel::MarkPriceCandle3Month,
752        _ => anyhow::bail!("Invalid `BarSpecification` for mark price channel, was {bar_spec}"),
753    };
754    Ok(channel)
755}
756
757/// Converts Nautilus bar specification to OKX timeframe string.
758///
759/// # Errors
760///
761/// Returns an error if the bar specification does not have a corresponding
762/// OKX timeframe value.
763pub fn bar_spec_as_okx_timeframe(bar_spec: BarSpecification) -> anyhow::Result<&'static str> {
764    let timeframe = match bar_spec {
765        BAR_SPEC_1_SECOND_LAST => "1s",
766        BAR_SPEC_1_MINUTE_LAST => "1m",
767        BAR_SPEC_3_MINUTE_LAST => "3m",
768        BAR_SPEC_5_MINUTE_LAST => "5m",
769        BAR_SPEC_15_MINUTE_LAST => "15m",
770        BAR_SPEC_30_MINUTE_LAST => "30m",
771        BAR_SPEC_1_HOUR_LAST => "1H",
772        BAR_SPEC_2_HOUR_LAST => "2H",
773        BAR_SPEC_4_HOUR_LAST => "4H",
774        BAR_SPEC_6_HOUR_LAST => "6H",
775        BAR_SPEC_12_HOUR_LAST => "12H",
776        BAR_SPEC_1_DAY_LAST => "1D",
777        BAR_SPEC_2_DAY_LAST => "2D",
778        BAR_SPEC_3_DAY_LAST => "3D",
779        BAR_SPEC_5_DAY_LAST => "5D",
780        BAR_SPEC_1_WEEK_LAST => "1W",
781        BAR_SPEC_1_MONTH_LAST => "1M",
782        BAR_SPEC_3_MONTH_LAST => "3M",
783        BAR_SPEC_6_MONTH_LAST => "6M",
784        BAR_SPEC_12_MONTH_LAST => "1Y",
785        _ => anyhow::bail!("Invalid `BarSpecification` for timeframe, was {bar_spec}"),
786    };
787    Ok(timeframe)
788}
789
790/// Converts OKX timeframe string to Nautilus bar specification.
791///
792/// # Errors
793///
794/// Returns an error if the timeframe string is not recognized.
795pub fn okx_timeframe_as_bar_spec(timeframe: &str) -> anyhow::Result<BarSpecification> {
796    let bar_spec = match timeframe {
797        "1s" => BAR_SPEC_1_SECOND_LAST,
798        "1m" => BAR_SPEC_1_MINUTE_LAST,
799        "3m" => BAR_SPEC_3_MINUTE_LAST,
800        "5m" => BAR_SPEC_5_MINUTE_LAST,
801        "15m" => BAR_SPEC_15_MINUTE_LAST,
802        "30m" => BAR_SPEC_30_MINUTE_LAST,
803        "1H" => BAR_SPEC_1_HOUR_LAST,
804        "2H" => BAR_SPEC_2_HOUR_LAST,
805        "4H" => BAR_SPEC_4_HOUR_LAST,
806        "6H" => BAR_SPEC_6_HOUR_LAST,
807        "12H" => BAR_SPEC_12_HOUR_LAST,
808        "1D" => BAR_SPEC_1_DAY_LAST,
809        "2D" => BAR_SPEC_2_DAY_LAST,
810        "3D" => BAR_SPEC_3_DAY_LAST,
811        "5D" => BAR_SPEC_5_DAY_LAST,
812        "1W" => BAR_SPEC_1_WEEK_LAST,
813        "1M" => BAR_SPEC_1_MONTH_LAST,
814        "3M" => BAR_SPEC_3_MONTH_LAST,
815        "6M" => BAR_SPEC_6_MONTH_LAST,
816        "1Y" => BAR_SPEC_12_MONTH_LAST,
817        _ => anyhow::bail!("Invalid timeframe for `BarSpecification`, was {timeframe}"),
818    };
819    Ok(bar_spec)
820}
821
822/// Constructs a properly formatted BarType from OKX instrument ID and timeframe string.
823/// This ensures the BarType uses canonical Nautilus format instead of raw OKX strings.
824///
825/// # Errors
826///
827/// Returns an error if the timeframe cannot be converted into a
828/// `BarSpecification`.
829pub fn okx_bar_type_from_timeframe(
830    instrument_id: InstrumentId,
831    timeframe: &str,
832) -> anyhow::Result<BarType> {
833    let bar_spec = okx_timeframe_as_bar_spec(timeframe)?;
834    Ok(BarType::new(
835        instrument_id,
836        bar_spec,
837        AggregationSource::External,
838    ))
839}
840
841/// Converts OKX WebSocket channel to bar specification if it's a candle channel.
842pub fn okx_channel_to_bar_spec(channel: &OKXWsChannel) -> Option<BarSpecification> {
843    use OKXWsChannel::*;
844    match channel {
845        Candle1Second | MarkPriceCandle1Second => Some(BAR_SPEC_1_SECOND_LAST),
846        Candle1Minute | MarkPriceCandle1Minute => Some(BAR_SPEC_1_MINUTE_LAST),
847        Candle3Minute | MarkPriceCandle3Minute => Some(BAR_SPEC_3_MINUTE_LAST),
848        Candle5Minute | MarkPriceCandle5Minute => Some(BAR_SPEC_5_MINUTE_LAST),
849        Candle15Minute | MarkPriceCandle15Minute => Some(BAR_SPEC_15_MINUTE_LAST),
850        Candle30Minute | MarkPriceCandle30Minute => Some(BAR_SPEC_30_MINUTE_LAST),
851        Candle1Hour | MarkPriceCandle1Hour => Some(BAR_SPEC_1_HOUR_LAST),
852        Candle2Hour | MarkPriceCandle2Hour => Some(BAR_SPEC_2_HOUR_LAST),
853        Candle4Hour | MarkPriceCandle4Hour => Some(BAR_SPEC_4_HOUR_LAST),
854        Candle6Hour | MarkPriceCandle6Hour => Some(BAR_SPEC_6_HOUR_LAST),
855        Candle12Hour | MarkPriceCandle12Hour => Some(BAR_SPEC_12_HOUR_LAST),
856        Candle1Day | MarkPriceCandle1Day => Some(BAR_SPEC_1_DAY_LAST),
857        Candle2Day | MarkPriceCandle2Day => Some(BAR_SPEC_2_DAY_LAST),
858        Candle3Day | MarkPriceCandle3Day => Some(BAR_SPEC_3_DAY_LAST),
859        Candle5Day | MarkPriceCandle5Day => Some(BAR_SPEC_5_DAY_LAST),
860        Candle1Week | MarkPriceCandle1Week => Some(BAR_SPEC_1_WEEK_LAST),
861        Candle1Month | MarkPriceCandle1Month => Some(BAR_SPEC_1_MONTH_LAST),
862        Candle3Month | MarkPriceCandle3Month => Some(BAR_SPEC_3_MONTH_LAST),
863        Candle6Month => Some(BAR_SPEC_6_MONTH_LAST),
864        Candle1Year => Some(BAR_SPEC_12_MONTH_LAST),
865        _ => None,
866    }
867}
868
869/// Parses an OKX instrument definition into a Nautilus instrument.
870///
871/// # Errors
872///
873/// Returns an error if the instrument definition cannot be parsed.
874pub fn parse_instrument_any(
875    instrument: &OKXInstrument,
876    ts_init: UnixNanos,
877) -> anyhow::Result<Option<InstrumentAny>> {
878    match instrument.inst_type {
879        OKXInstrumentType::Spot => {
880            parse_spot_instrument(instrument, None, None, None, None, ts_init).map(Some)
881        }
882        OKXInstrumentType::Swap => {
883            parse_swap_instrument(instrument, None, None, None, None, ts_init).map(Some)
884        }
885        OKXInstrumentType::Futures => {
886            parse_futures_instrument(instrument, None, None, None, None, ts_init).map(Some)
887        }
888        OKXInstrumentType::Option => {
889            parse_option_instrument(instrument, None, None, None, None, ts_init).map(Some)
890        }
891        _ => Ok(None),
892    }
893}
894
895/// Common parsed instrument data extracted from OKX definitions.
896#[derive(Debug)]
897struct CommonInstrumentData {
898    instrument_id: InstrumentId,
899    raw_symbol: Symbol,
900    price_increment: Price,
901    size_increment: Quantity,
902    lot_size: Option<Quantity>,
903    max_quantity: Option<Quantity>,
904    min_quantity: Option<Quantity>,
905    max_notional: Option<Money>,
906    min_notional: Option<Money>,
907    max_price: Option<Price>,
908    min_price: Option<Price>,
909}
910
911/// Margin and fee configuration for an instrument.
912struct MarginAndFees {
913    margin_init: Option<Decimal>,
914    margin_maint: Option<Decimal>,
915    maker_fee: Option<Decimal>,
916    taker_fee: Option<Decimal>,
917}
918
919/// Trait for instrument-specific parsing logic.
920trait InstrumentParser {
921    /// Parses instrument-specific fields and creates the final instrument.
922    fn parse_specific_fields(
923        &self,
924        definition: &OKXInstrument,
925        common: CommonInstrumentData,
926        margin_fees: MarginAndFees,
927        ts_init: UnixNanos,
928    ) -> anyhow::Result<InstrumentAny>;
929}
930
931/// Extracts common fields shared across all instrument types.
932fn parse_common_instrument_data(
933    definition: &OKXInstrument,
934) -> anyhow::Result<CommonInstrumentData> {
935    let instrument_id = parse_instrument_id(definition.inst_id);
936    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
937
938    let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
939        anyhow::anyhow!(
940            "Failed to parse tick_sz '{}' into Price: {}",
941            definition.tick_sz,
942            e
943        )
944    })?;
945
946    let size_increment = Quantity::from(&definition.lot_sz);
947    let lot_size = Some(Quantity::from(&definition.lot_sz));
948    let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
949    let min_quantity = Some(Quantity::from(&definition.min_sz));
950    let max_notional: Option<Money> = None;
951    let min_notional: Option<Money> = None;
952    let max_price = None; // TBD
953    let min_price = None; // TBD
954
955    Ok(CommonInstrumentData {
956        instrument_id,
957        raw_symbol,
958        price_increment,
959        size_increment,
960        lot_size,
961        max_quantity,
962        min_quantity,
963        max_notional,
964        min_notional,
965        max_price,
966        min_price,
967    })
968}
969
970/// Generic instrument parsing function that delegates to type-specific parsers.
971fn parse_instrument_with_parser<P: InstrumentParser>(
972    definition: &OKXInstrument,
973    parser: P,
974    margin_init: Option<Decimal>,
975    margin_maint: Option<Decimal>,
976    maker_fee: Option<Decimal>,
977    taker_fee: Option<Decimal>,
978    ts_init: UnixNanos,
979) -> anyhow::Result<InstrumentAny> {
980    let common = parse_common_instrument_data(definition)?;
981    parser.parse_specific_fields(
982        definition,
983        common,
984        MarginAndFees {
985            margin_init,
986            margin_maint,
987            maker_fee,
988            taker_fee,
989        },
990        ts_init,
991    )
992}
993
994/// Parser for spot trading pairs (CurrencyPair).
995struct SpotInstrumentParser;
996
997impl InstrumentParser for SpotInstrumentParser {
998    fn parse_specific_fields(
999        &self,
1000        definition: &OKXInstrument,
1001        common: CommonInstrumentData,
1002        margin_fees: MarginAndFees,
1003        ts_init: UnixNanos,
1004    ) -> anyhow::Result<InstrumentAny> {
1005        let base_currency = get_currency(&definition.base_ccy);
1006        let quote_currency = get_currency(&definition.quote_ccy);
1007
1008        let instrument = CurrencyPair::new(
1009            common.instrument_id,
1010            common.raw_symbol,
1011            base_currency,
1012            quote_currency,
1013            common.price_increment.precision,
1014            common.size_increment.precision,
1015            common.price_increment,
1016            common.size_increment,
1017            None,
1018            common.lot_size,
1019            common.max_quantity,
1020            common.min_quantity,
1021            common.max_notional,
1022            common.min_notional,
1023            common.max_price,
1024            common.min_price,
1025            margin_fees.margin_init,
1026            margin_fees.margin_maint,
1027            margin_fees.maker_fee,
1028            margin_fees.taker_fee,
1029            ts_init,
1030            ts_init,
1031        );
1032
1033        Ok(InstrumentAny::CurrencyPair(instrument))
1034    }
1035}
1036
1037/// Parses an OKX spot instrument definition into a Nautilus currency pair.
1038///
1039/// # Errors
1040///
1041/// Returns an error if the instrument definition cannot be parsed.
1042pub fn parse_spot_instrument(
1043    definition: &OKXInstrument,
1044    margin_init: Option<Decimal>,
1045    margin_maint: Option<Decimal>,
1046    maker_fee: Option<Decimal>,
1047    taker_fee: Option<Decimal>,
1048    ts_init: UnixNanos,
1049) -> anyhow::Result<InstrumentAny> {
1050    parse_instrument_with_parser(
1051        definition,
1052        SpotInstrumentParser,
1053        margin_init,
1054        margin_maint,
1055        maker_fee,
1056        taker_fee,
1057        ts_init,
1058    )
1059}
1060
1061/// Parses an OKX swap instrument definition into a Nautilus crypto perpetual.
1062///
1063/// # Errors
1064///
1065/// Returns an error if the instrument definition cannot be parsed.
1066pub fn parse_swap_instrument(
1067    definition: &OKXInstrument,
1068    margin_init: Option<Decimal>,
1069    margin_maint: Option<Decimal>,
1070    maker_fee: Option<Decimal>,
1071    taker_fee: Option<Decimal>,
1072    ts_init: UnixNanos,
1073) -> anyhow::Result<InstrumentAny> {
1074    let instrument_id = parse_instrument_id(definition.inst_id);
1075    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1076    let (base_currency, quote_currency) = definition
1077        .uly
1078        .split_once('-')
1079        .ok_or_else(|| anyhow::anyhow!("Invalid underlying for swap: {}", definition.uly))?;
1080    let base_currency = get_currency(base_currency);
1081    let quote_currency = get_currency(quote_currency);
1082    let settlement_currency = get_currency(&definition.settle_ccy);
1083    let is_inverse = match definition.ct_type {
1084        OKXContractType::Linear => false,
1085        OKXContractType::Inverse => true,
1086        OKXContractType::None => {
1087            anyhow::bail!("Invalid contract type for swap: {}", definition.ct_type)
1088        }
1089    };
1090    let price_increment = match Price::from_str(&definition.tick_sz) {
1091        Ok(price) => price,
1092        Err(e) => {
1093            anyhow::bail!(
1094                "Failed to parse tick_size '{}' into Price: {}",
1095                definition.tick_sz,
1096                e
1097            );
1098        }
1099    };
1100    let size_increment = Quantity::from(&definition.lot_sz);
1101    let multiplier = Some(Quantity::from(&definition.ct_mult));
1102    let lot_size = Some(Quantity::from(&definition.lot_sz));
1103    let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1104    let min_quantity = Some(Quantity::from(&definition.min_sz));
1105    let max_notional: Option<Money> = None;
1106    let min_notional: Option<Money> = None;
1107    let max_price = None; // TBD
1108    let min_price = None; // TBD
1109
1110    // For linear swaps (USDT-margined), trades are in base currency units (e.g., BTC)
1111    // For inverse swaps (coin-margined), trades are in contract units
1112    // The lotSz represents minimum contract size, but actual trade sizes for linear swaps
1113    // are in fractional base currency amounts requiring higher precision
1114    let (size_precision, adjusted_size_increment) = if is_inverse {
1115        // For inverse swaps, use the lot size precision as trades are in contract units
1116        (size_increment.precision, size_increment)
1117    } else {
1118        // For linear swaps, use base currency precision (typically 8 for crypto)
1119        // and adjust the size increment to match this precision
1120        let precision = 8u8;
1121        let adjusted_increment = Quantity::new(1.0, precision); // Minimum trade size of 0.00000001
1122        (precision, adjusted_increment)
1123    };
1124
1125    let instrument = CryptoPerpetual::new(
1126        instrument_id,
1127        raw_symbol,
1128        base_currency,
1129        quote_currency,
1130        settlement_currency,
1131        is_inverse,
1132        price_increment.precision,
1133        size_precision,
1134        price_increment,
1135        adjusted_size_increment,
1136        multiplier,
1137        lot_size,
1138        max_quantity,
1139        min_quantity,
1140        max_notional,
1141        min_notional,
1142        max_price,
1143        min_price,
1144        margin_init,
1145        margin_maint,
1146        maker_fee,
1147        taker_fee,
1148        ts_init, // No ts_event for response
1149        ts_init,
1150    );
1151
1152    Ok(InstrumentAny::CryptoPerpetual(instrument))
1153}
1154
1155/// Parses an OKX futures instrument definition into a Nautilus crypto future.
1156///
1157/// # Errors
1158///
1159/// Returns an error if the instrument definition cannot be parsed.
1160pub fn parse_futures_instrument(
1161    definition: &OKXInstrument,
1162    margin_init: Option<Decimal>,
1163    margin_maint: Option<Decimal>,
1164    maker_fee: Option<Decimal>,
1165    taker_fee: Option<Decimal>,
1166    ts_init: UnixNanos,
1167) -> anyhow::Result<InstrumentAny> {
1168    let instrument_id = parse_instrument_id(definition.inst_id);
1169    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1170    let underlying = get_currency(&definition.uly);
1171    let (_, quote_currency) = definition
1172        .uly
1173        .split_once('-')
1174        .ok_or_else(|| anyhow::anyhow!("Invalid underlying for Swap: {}", definition.uly))?;
1175    let quote_currency = get_currency(quote_currency);
1176    let settlement_currency = get_currency(&definition.settle_ccy);
1177    let is_inverse = match definition.ct_type {
1178        OKXContractType::Linear => false,
1179        OKXContractType::Inverse => true,
1180        OKXContractType::None => {
1181            anyhow::bail!("Invalid contract type for futures: {}", definition.ct_type)
1182        }
1183    };
1184    let listing_time = definition
1185        .list_time
1186        .ok_or_else(|| anyhow::anyhow!("`listing_time` is required to parse Swap instrument"))?;
1187    let expiry_time = definition
1188        .exp_time
1189        .ok_or_else(|| anyhow::anyhow!("`expiry_time` is required to parse Swap instrument"))?;
1190    let activation_ns = UnixNanos::from(millis_to_nanos(listing_time as f64));
1191    let expiration_ns = UnixNanos::from(millis_to_nanos(expiry_time as f64));
1192    let price_increment = Price::from(definition.tick_sz.to_string());
1193    let size_increment = Quantity::from(&definition.lot_sz);
1194    let multiplier = Some(Quantity::from(&definition.ct_mult));
1195    let lot_size = Some(Quantity::from(&definition.lot_sz));
1196    let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1197    let min_quantity = Some(Quantity::from(&definition.min_sz));
1198    let max_notional: Option<Money> = None;
1199    let min_notional: Option<Money> = None;
1200    let max_price = None; // TBD
1201    let min_price = None; // TBD
1202
1203    let instrument = CryptoFuture::new(
1204        instrument_id,
1205        raw_symbol,
1206        underlying,
1207        quote_currency,
1208        settlement_currency,
1209        is_inverse,
1210        activation_ns,
1211        expiration_ns,
1212        price_increment.precision,
1213        size_increment.precision,
1214        price_increment,
1215        size_increment,
1216        multiplier,
1217        lot_size,
1218        max_quantity,
1219        min_quantity,
1220        max_notional,
1221        min_notional,
1222        max_price,
1223        min_price,
1224        margin_init,
1225        margin_maint,
1226        maker_fee,
1227        taker_fee,
1228        ts_init, // No ts_event for response
1229        ts_init,
1230    );
1231
1232    Ok(InstrumentAny::CryptoFuture(instrument))
1233}
1234
1235/// Parses an OKX option instrument definition into a Nautilus option contract.
1236///
1237/// # Errors
1238///
1239/// Returns an error if the instrument definition cannot be parsed.
1240pub fn parse_option_instrument(
1241    definition: &OKXInstrument,
1242    margin_init: Option<Decimal>,
1243    margin_maint: Option<Decimal>,
1244    maker_fee: Option<Decimal>,
1245    taker_fee: Option<Decimal>,
1246    ts_init: UnixNanos,
1247) -> anyhow::Result<InstrumentAny> {
1248    let instrument_id = parse_instrument_id(definition.inst_id);
1249    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1250    let asset_class = AssetClass::Cryptocurrency;
1251    let exchange = Some(Ustr::from("OKX"));
1252    let underlying = Ustr::from(&definition.uly);
1253    let option_kind: OptionKind = definition.opt_type.into();
1254    let strike_price = Price::from(&definition.stk);
1255    let currency = definition
1256        .uly
1257        .split_once('-')
1258        .map(|(_, quote_ccy)| get_currency(quote_ccy))
1259        .ok_or_else(|| {
1260            anyhow::anyhow!(
1261                "Invalid underlying for Option instrument: {}",
1262                definition.uly
1263            )
1264        })?;
1265    let listing_time = definition
1266        .list_time
1267        .ok_or_else(|| anyhow::anyhow!("`listing_time` is required to parse Option instrument"))?;
1268    let expiry_time = definition
1269        .exp_time
1270        .ok_or_else(|| anyhow::anyhow!("`expiry_time` is required to parse Option instrument"))?;
1271    let activation_ns = UnixNanos::from(millis_to_nanos(listing_time as f64));
1272    let expiration_ns = UnixNanos::from(millis_to_nanos(expiry_time as f64));
1273    let price_increment = Price::from(definition.tick_sz.to_string());
1274    let multiplier = Quantity::from(&definition.ct_mult);
1275    let lot_size = Quantity::from(&definition.lot_sz);
1276    let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1277    let min_quantity = Some(Quantity::from(&definition.min_sz));
1278    let max_price = None; // TBD
1279    let min_price = None; // TBD
1280
1281    let instrument = OptionContract::new(
1282        instrument_id,
1283        raw_symbol,
1284        asset_class,
1285        exchange,
1286        underlying,
1287        option_kind,
1288        strike_price,
1289        currency,
1290        activation_ns,
1291        expiration_ns,
1292        price_increment.precision,
1293        price_increment,
1294        multiplier,
1295        lot_size,
1296        max_quantity,
1297        min_quantity,
1298        max_price,
1299        min_price,
1300        margin_init,
1301        margin_maint,
1302        maker_fee,
1303        taker_fee,
1304        ts_init, // No ts_event for response
1305        ts_init,
1306    );
1307
1308    Ok(InstrumentAny::OptionContract(instrument))
1309}
1310
1311/// Parses an OKX account into a Nautilus account state.
1312///
1313/// # Errors
1314///
1315/// Returns an error if the data cannot be parsed.
1316pub fn parse_account_state(
1317    okx_account: &OKXAccount,
1318    account_id: AccountId,
1319    ts_init: UnixNanos,
1320) -> anyhow::Result<AccountState> {
1321    let mut balances = Vec::new();
1322    for b in &okx_account.details {
1323        // Skip balances with empty currency codes
1324        if b.ccy.is_empty() {
1325            tracing::warn!("Skipping balance detail with empty currency code");
1326            continue;
1327        }
1328
1329        let currency = Currency::from(b.ccy);
1330        let total = Money::new(b.cash_bal.parse::<f64>()?, currency);
1331        let free = Money::new(b.avail_bal.parse::<f64>()?, currency);
1332        let locked = total - free;
1333        let balance = AccountBalance::new(total, locked, free);
1334        balances.push(balance);
1335    }
1336
1337    // Ensure at least one balance exists (Nautilus requires non-empty balances)
1338    // OKX may return empty details for certain account configurations
1339    if balances.is_empty() {
1340        let zero_currency = Currency::USD();
1341        let zero_money = Money::new(0.0, zero_currency);
1342        let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
1343        balances.push(zero_balance);
1344    }
1345
1346    let mut margins = Vec::new();
1347
1348    // OKX provides account-level margin requirements (not per instrument)
1349    if !okx_account.imr.is_empty() && !okx_account.mmr.is_empty() {
1350        match (
1351            okx_account.imr.parse::<f64>(),
1352            okx_account.mmr.parse::<f64>(),
1353        ) {
1354            (Ok(imr_value), Ok(mmr_value)) => {
1355                if imr_value > 0.0 || mmr_value > 0.0 {
1356                    let margin_currency = Currency::USD();
1357                    let margin_instrument_id =
1358                        InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("OKX"));
1359
1360                    let initial_margin = Money::new(imr_value, margin_currency);
1361                    let maintenance_margin = Money::new(mmr_value, margin_currency);
1362
1363                    let margin_balance = MarginBalance::new(
1364                        initial_margin,
1365                        maintenance_margin,
1366                        margin_instrument_id,
1367                    );
1368
1369                    margins.push(margin_balance);
1370                }
1371            }
1372            (Err(e1), _) => {
1373                tracing::warn!(
1374                    "Failed to parse initial margin requirement '{}': {}",
1375                    okx_account.imr,
1376                    e1
1377                );
1378            }
1379            (_, Err(e2)) => {
1380                tracing::warn!(
1381                    "Failed to parse maintenance margin requirement '{}': {}",
1382                    okx_account.mmr,
1383                    e2
1384                );
1385            }
1386        }
1387    }
1388
1389    let account_type = AccountType::Margin;
1390    let is_reported = true;
1391    let event_id = UUID4::new();
1392    let ts_event = UnixNanos::from(millis_to_nanos(okx_account.u_time as f64));
1393
1394    Ok(AccountState::new(
1395        account_id,
1396        account_type,
1397        balances,
1398        margins,
1399        is_reported,
1400        event_id,
1401        ts_event,
1402        ts_init,
1403        None,
1404    ))
1405}
1406
1407////////////////////////////////////////////////////////////////////////////////
1408// Tests
1409////////////////////////////////////////////////////////////////////////////////
1410
1411#[cfg(test)]
1412mod tests {
1413    use nautilus_model::instruments::Instrument;
1414    use rstest::rstest;
1415
1416    use super::*;
1417    use crate::{
1418        common::{enums::OKXMarginMode, testing::load_test_json},
1419        http::{
1420            client::OKXResponse,
1421            models::{
1422                OKXAccount, OKXBalanceDetail, OKXCandlestick, OKXIndexTicker, OKXMarkPrice,
1423                OKXOrderHistory, OKXPlaceOrderResponse, OKXPosition, OKXPositionHistory,
1424                OKXPositionTier, OKXTrade, OKXTransactionDetail,
1425            },
1426        },
1427    };
1428
1429    #[rstest]
1430    fn test_parse_trades() {
1431        let json_data = load_test_json("http_get_trades.json");
1432        let parsed: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
1433
1434        // Basic response envelope
1435        assert_eq!(parsed.code, "0");
1436        assert_eq!(parsed.msg, "");
1437        assert_eq!(parsed.data.len(), 2);
1438
1439        // Inspect first record
1440        let trade0 = &parsed.data[0];
1441        assert_eq!(trade0.inst_id, "BTC-USDT");
1442        assert_eq!(trade0.px, "102537.9");
1443        assert_eq!(trade0.sz, "0.00013669");
1444        assert_eq!(trade0.side, OKXSide::Sell);
1445        assert_eq!(trade0.trade_id, "734864333");
1446        assert_eq!(trade0.ts, 1747087163557);
1447
1448        // Inspect second record
1449        let trade1 = &parsed.data[1];
1450        assert_eq!(trade1.inst_id, "BTC-USDT");
1451        assert_eq!(trade1.px, "102537.9");
1452        assert_eq!(trade1.sz, "0.0000125");
1453        assert_eq!(trade1.side, OKXSide::Buy);
1454        assert_eq!(trade1.trade_id, "734864332");
1455        assert_eq!(trade1.ts, 1747087161666);
1456    }
1457
1458    #[rstest]
1459    fn test_parse_candlesticks() {
1460        let json_data = load_test_json("http_get_candlesticks.json");
1461        let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
1462
1463        // Basic response envelope
1464        assert_eq!(parsed.code, "0");
1465        assert_eq!(parsed.msg, "");
1466        assert_eq!(parsed.data.len(), 2);
1467
1468        let bar0 = &parsed.data[0];
1469        assert_eq!(bar0.0, "1625097600000");
1470        assert_eq!(bar0.1, "33528.6");
1471        assert_eq!(bar0.2, "33870.0");
1472        assert_eq!(bar0.3, "33528.6");
1473        assert_eq!(bar0.4, "33783.9");
1474        assert_eq!(bar0.5, "778.838");
1475
1476        let bar1 = &parsed.data[1];
1477        assert_eq!(bar1.0, "1625097660000");
1478        assert_eq!(bar1.1, "33783.9");
1479        assert_eq!(bar1.2, "33783.9");
1480        assert_eq!(bar1.3, "33782.1");
1481        assert_eq!(bar1.4, "33782.1");
1482        assert_eq!(bar1.5, "0.123");
1483    }
1484
1485    #[rstest]
1486    fn test_parse_candlesticks_full() {
1487        let json_data = load_test_json("http_get_candlesticks_full.json");
1488        let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
1489
1490        // Basic response envelope
1491        assert_eq!(parsed.code, "0");
1492        assert_eq!(parsed.msg, "");
1493        assert_eq!(parsed.data.len(), 2);
1494
1495        // Inspect first record
1496        let bar0 = &parsed.data[0];
1497        assert_eq!(bar0.0, "1747094040000");
1498        assert_eq!(bar0.1, "102806.1");
1499        assert_eq!(bar0.2, "102820.4");
1500        assert_eq!(bar0.3, "102806.1");
1501        assert_eq!(bar0.4, "102820.4");
1502        assert_eq!(bar0.5, "1040.37");
1503        assert_eq!(bar0.6, "10.4037");
1504        assert_eq!(bar0.7, "1069603.34883");
1505        assert_eq!(bar0.8, "1");
1506
1507        // Inspect second record
1508        let bar1 = &parsed.data[1];
1509        assert_eq!(bar1.0, "1747093980000");
1510        assert_eq!(bar1.5, "7164.04");
1511        assert_eq!(bar1.6, "71.6404");
1512        assert_eq!(bar1.7, "7364701.57952");
1513        assert_eq!(bar1.8, "1");
1514    }
1515
1516    #[rstest]
1517    fn test_parse_mark_price() {
1518        let json_data = load_test_json("http_get_mark_price.json");
1519        let parsed: OKXResponse<OKXMarkPrice> = serde_json::from_str(&json_data).unwrap();
1520
1521        // Basic response envelope
1522        assert_eq!(parsed.code, "0");
1523        assert_eq!(parsed.msg, "");
1524        assert_eq!(parsed.data.len(), 1);
1525
1526        // Inspect first record
1527        let mark_price = &parsed.data[0];
1528
1529        assert_eq!(mark_price.inst_id, "BTC-USDT-SWAP");
1530        assert_eq!(mark_price.mark_px, "84660.1");
1531        assert_eq!(mark_price.ts, 1744590349506);
1532    }
1533
1534    #[rstest]
1535    fn test_parse_index_price() {
1536        let json_data = load_test_json("http_get_index_price.json");
1537        let parsed: OKXResponse<OKXIndexTicker> = serde_json::from_str(&json_data).unwrap();
1538
1539        // Basic response envelope
1540        assert_eq!(parsed.code, "0");
1541        assert_eq!(parsed.msg, "");
1542        assert_eq!(parsed.data.len(), 1);
1543
1544        // Inspect first record
1545        let index_price = &parsed.data[0];
1546
1547        assert_eq!(index_price.inst_id, "BTC-USDT");
1548        assert_eq!(index_price.idx_px, "103895");
1549        assert_eq!(index_price.ts, 1746942707815);
1550    }
1551
1552    #[rstest]
1553    fn test_parse_account() {
1554        let json_data = load_test_json("http_get_account_balance.json");
1555        let parsed: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
1556
1557        // Basic response envelope
1558        assert_eq!(parsed.code, "0");
1559        assert_eq!(parsed.msg, "");
1560        assert_eq!(parsed.data.len(), 1);
1561
1562        // Inspect first record
1563        let account = &parsed.data[0];
1564        assert_eq!(account.adj_eq, "");
1565        assert_eq!(account.borrow_froz, "");
1566        assert_eq!(account.imr, "");
1567        assert_eq!(account.iso_eq, "5.4682385526666675");
1568        assert_eq!(account.mgn_ratio, "");
1569        assert_eq!(account.mmr, "");
1570        assert_eq!(account.notional_usd, "");
1571        assert_eq!(account.notional_usd_for_borrow, "");
1572        assert_eq!(account.notional_usd_for_futures, "");
1573        assert_eq!(account.notional_usd_for_option, "");
1574        assert_eq!(account.notional_usd_for_swap, "");
1575        assert_eq!(account.ord_froz, "");
1576        assert_eq!(account.total_eq, "99.88870288820581");
1577        assert_eq!(account.upl, "");
1578        assert_eq!(account.u_time, 1744499648556);
1579        assert_eq!(account.details.len(), 1);
1580
1581        let detail = &account.details[0];
1582        assert_eq!(detail.ccy, "USDT");
1583        assert_eq!(detail.avail_bal, "94.42612990333333");
1584        assert_eq!(detail.avail_eq, "94.42612990333333");
1585        assert_eq!(detail.cash_bal, "94.42612990333333");
1586        assert_eq!(detail.dis_eq, "5.4682385526666675");
1587        assert_eq!(detail.eq, "99.89469657000001");
1588        assert_eq!(detail.eq_usd, "99.88870288820581");
1589        assert_eq!(detail.fixed_bal, "0");
1590        assert_eq!(detail.frozen_bal, "5.468566666666667");
1591        assert_eq!(detail.imr, "0");
1592        assert_eq!(detail.iso_eq, "5.468566666666667");
1593        assert_eq!(detail.iso_upl, "-0.0273000000000002");
1594        assert_eq!(detail.mmr, "0");
1595        assert_eq!(detail.notional_lever, "0");
1596        assert_eq!(detail.ord_frozen, "0");
1597        assert_eq!(detail.reward_bal, "0");
1598        assert_eq!(detail.smt_sync_eq, "0");
1599        assert_eq!(detail.spot_copy_trading_eq, "0");
1600        assert_eq!(detail.spot_iso_bal, "0");
1601        assert_eq!(detail.stgy_eq, "0");
1602        assert_eq!(detail.twap, "0");
1603        assert_eq!(detail.upl, "-0.0273000000000002");
1604        assert_eq!(detail.u_time, 1744498994783);
1605    }
1606
1607    #[rstest]
1608    fn test_parse_order_history() {
1609        let json_data = load_test_json("http_get_orders_history.json");
1610        let parsed: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
1611
1612        // Basic response envelope
1613        assert_eq!(parsed.code, "0");
1614        assert_eq!(parsed.msg, "");
1615        assert_eq!(parsed.data.len(), 1);
1616
1617        // Inspect first record
1618        let order = &parsed.data[0];
1619        assert_eq!(order.ord_id, "2497956918703120384");
1620        assert_eq!(order.fill_sz, "0.03");
1621        assert_eq!(order.acc_fill_sz, "0.03");
1622        assert_eq!(order.state, OKXOrderStatus::Filled);
1623        assert!(order.fill_fee.is_none());
1624    }
1625
1626    #[rstest]
1627    fn test_parse_position() {
1628        let json_data = load_test_json("http_get_positions.json");
1629        let parsed: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
1630
1631        // Basic response envelope
1632        assert_eq!(parsed.code, "0");
1633        assert_eq!(parsed.msg, "");
1634        assert_eq!(parsed.data.len(), 1);
1635
1636        // Inspect first record
1637        let pos = &parsed.data[0];
1638        assert_eq!(pos.inst_id, "BTC-USDT-SWAP");
1639        assert_eq!(pos.pos_side, OKXPositionSide::Long);
1640        assert_eq!(pos.pos, "0.5");
1641        assert_eq!(pos.base_bal, "0.5");
1642        assert_eq!(pos.quote_bal, "5000");
1643        assert_eq!(pos.u_time, 1622559930237);
1644    }
1645
1646    #[rstest]
1647    fn test_parse_position_history() {
1648        let json_data = load_test_json("http_get_account_positions-history.json");
1649        let parsed: OKXResponse<OKXPositionHistory> = serde_json::from_str(&json_data).unwrap();
1650
1651        // Basic response envelope
1652        assert_eq!(parsed.code, "0");
1653        assert_eq!(parsed.msg, "");
1654        assert_eq!(parsed.data.len(), 1);
1655
1656        // Inspect first record
1657        let hist = &parsed.data[0];
1658        assert_eq!(hist.inst_id, "ETH-USDT-SWAP");
1659        assert_eq!(hist.inst_type, OKXInstrumentType::Swap);
1660        assert_eq!(hist.mgn_mode, OKXMarginMode::Isolated);
1661        assert_eq!(hist.pos_side, OKXPositionSide::Long);
1662        assert_eq!(hist.lever, "3.0");
1663        assert_eq!(hist.open_avg_px, "3226.93");
1664        assert_eq!(hist.close_avg_px.as_deref(), Some("3224.8"));
1665        assert_eq!(hist.pnl.as_deref(), Some("-0.0213"));
1666        assert!(!hist.c_time.is_empty());
1667        assert!(hist.u_time > 0);
1668    }
1669
1670    #[rstest]
1671    fn test_parse_position_tiers() {
1672        let json_data = load_test_json("http_get_position_tiers.json");
1673        let parsed: OKXResponse<OKXPositionTier> = serde_json::from_str(&json_data).unwrap();
1674
1675        // Basic response envelope
1676        assert_eq!(parsed.code, "0");
1677        assert_eq!(parsed.msg, "");
1678        assert_eq!(parsed.data.len(), 1);
1679
1680        // Inspect first tier record
1681        let tier = &parsed.data[0];
1682        assert_eq!(tier.inst_id, "BTC-USDT");
1683        assert_eq!(tier.tier, "1");
1684        assert_eq!(tier.min_sz, "0");
1685        assert_eq!(tier.max_sz, "50");
1686        assert_eq!(tier.imr, "0.1");
1687        assert_eq!(tier.mmr, "0.03");
1688    }
1689
1690    #[rstest]
1691    fn test_parse_account_field_name_compatibility() {
1692        // Test with new field names (with Amt suffix)
1693        let json_new = load_test_json("http_balance_detail_new_fields.json");
1694        let detail_new: OKXBalanceDetail = serde_json::from_str(&json_new).unwrap();
1695        assert_eq!(detail_new.max_spot_in_use_amt, "50.0");
1696        assert_eq!(detail_new.spot_in_use_amt, "30.0");
1697        assert_eq!(detail_new.cl_spot_in_use_amt, "25.0");
1698
1699        // Test with old field names (without Amt suffix) - for backward compatibility
1700        let json_old = load_test_json("http_balance_detail_old_fields.json");
1701        let detail_old: OKXBalanceDetail = serde_json::from_str(&json_old).unwrap();
1702        assert_eq!(detail_old.max_spot_in_use_amt, "75.0");
1703        assert_eq!(detail_old.spot_in_use_amt, "40.0");
1704        assert_eq!(detail_old.cl_spot_in_use_amt, "35.0");
1705    }
1706
1707    #[rstest]
1708    fn test_parse_place_order_response() {
1709        let json_data = load_test_json("http_place_order_response.json");
1710        let parsed: OKXPlaceOrderResponse = serde_json::from_str(&json_data).unwrap();
1711        assert_eq!(
1712            parsed.ord_id,
1713            Some(ustr::Ustr::from("12345678901234567890"))
1714        );
1715        assert_eq!(parsed.cl_ord_id, Some(ustr::Ustr::from("client_order_123")));
1716        assert_eq!(parsed.tag, Some("".to_string()));
1717    }
1718
1719    #[rstest]
1720    fn test_parse_transaction_details() {
1721        let json_data = load_test_json("http_transaction_detail.json");
1722        let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
1723        assert_eq!(parsed.inst_type, OKXInstrumentType::Spot);
1724        assert_eq!(parsed.inst_id, Ustr::from("BTC-USDT"));
1725        assert_eq!(parsed.trade_id, Ustr::from("123456789"));
1726        assert_eq!(parsed.ord_id, Ustr::from("987654321"));
1727        assert_eq!(parsed.cl_ord_id, Ustr::from("client_123"));
1728        assert_eq!(parsed.bill_id, Ustr::from("bill_456"));
1729        assert_eq!(parsed.fill_px, "42000.5");
1730        assert_eq!(parsed.fill_sz, "0.001");
1731        assert_eq!(parsed.side, OKXSide::Buy);
1732        assert_eq!(parsed.exec_type, OKXExecType::Taker);
1733        assert_eq!(parsed.fee_ccy, "USDT");
1734        assert_eq!(parsed.fee, Some("0.042".to_string()));
1735        assert_eq!(parsed.ts, 1625097600000);
1736    }
1737
1738    #[rstest]
1739    fn test_parse_empty_fee_field() {
1740        let json_data = load_test_json("http_transaction_detail_empty_fee.json");
1741        let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
1742        assert_eq!(parsed.fee, None);
1743    }
1744
1745    #[rstest]
1746    fn test_parse_optional_string_to_u64() {
1747        use serde::Deserialize;
1748
1749        #[derive(Deserialize)]
1750        struct TestStruct {
1751            #[serde(deserialize_with = "crate::common::parse::deserialize_optional_string_to_u64")]
1752            value: Option<u64>,
1753        }
1754
1755        let json_cases = load_test_json("common_optional_string_to_u64.json");
1756        let cases: Vec<TestStruct> = serde_json::from_str(&json_cases).unwrap();
1757
1758        assert_eq!(cases[0].value, Some(12345));
1759        assert_eq!(cases[1].value, None);
1760        assert_eq!(cases[2].value, None);
1761    }
1762
1763    #[rstest]
1764    fn test_parse_error_handling() {
1765        // Test error handling with invalid price string
1766        let invalid_price = "invalid-price";
1767        let result = crate::common::parse::parse_price(invalid_price, 2);
1768        assert!(result.is_err());
1769
1770        // Test error handling with invalid quantity string
1771        let invalid_quantity = "invalid-quantity";
1772        let result = crate::common::parse::parse_quantity(invalid_quantity, 8);
1773        assert!(result.is_err());
1774    }
1775
1776    #[rstest]
1777    fn test_parse_spot_instrument() {
1778        let json_data = load_test_json("http_get_instruments_spot.json");
1779        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1780        let okx_inst: &OKXInstrument = response
1781            .data
1782            .first()
1783            .expect("Test data must have an instrument");
1784
1785        let instrument =
1786            parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
1787
1788        assert_eq!(instrument.id(), InstrumentId::from("BTC-USD.OKX"));
1789        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD"));
1790        assert_eq!(instrument.underlying(), None);
1791        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
1792        assert_eq!(instrument.quote_currency(), Currency::USD());
1793        assert_eq!(instrument.price_precision(), 1);
1794        assert_eq!(instrument.size_precision(), 8);
1795        assert_eq!(instrument.price_increment(), Price::from("0.1"));
1796        assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
1797    }
1798
1799    #[rstest]
1800    fn test_parse_margin_instrument() {
1801        let json_data = load_test_json("http_get_instruments_margin.json");
1802        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1803        let okx_inst: &OKXInstrument = response
1804            .data
1805            .first()
1806            .expect("Test data must have an instrument");
1807
1808        let instrument =
1809            parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
1810
1811        assert_eq!(instrument.id(), InstrumentId::from("BTC-USDT.OKX"));
1812        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USDT"));
1813        assert_eq!(instrument.underlying(), None);
1814        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
1815        assert_eq!(instrument.quote_currency(), Currency::USDT());
1816        assert_eq!(instrument.price_precision(), 1);
1817        assert_eq!(instrument.size_precision(), 8);
1818        assert_eq!(instrument.price_increment(), Price::from("0.1"));
1819        assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
1820    }
1821
1822    #[rstest]
1823    fn test_parse_swap_instrument() {
1824        let json_data = load_test_json("http_get_instruments_swap.json");
1825        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1826        let okx_inst: &OKXInstrument = response
1827            .data
1828            .first()
1829            .expect("Test data must have an instrument");
1830
1831        let instrument =
1832            parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
1833
1834        assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-SWAP.OKX"));
1835        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-SWAP"));
1836        assert_eq!(instrument.underlying(), None);
1837        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
1838        assert_eq!(instrument.quote_currency(), Currency::USD());
1839        assert_eq!(instrument.price_precision(), 1);
1840        assert_eq!(instrument.size_precision(), 0);
1841        assert_eq!(instrument.price_increment(), Price::from("0.1"));
1842        assert_eq!(instrument.size_increment(), Quantity::from(1));
1843    }
1844
1845    #[rstest]
1846    fn test_parse_futures_instrument() {
1847        let json_data = load_test_json("http_get_instruments_futures.json");
1848        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1849        let okx_inst: &OKXInstrument = response
1850            .data
1851            .first()
1852            .expect("Test data must have an instrument");
1853
1854        let instrument =
1855            parse_futures_instrument(okx_inst, None, None, None, None, UnixNanos::default())
1856                .unwrap();
1857
1858        assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-241220.OKX"));
1859        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-241220"));
1860        assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
1861        assert_eq!(instrument.quote_currency(), Currency::USD());
1862        assert_eq!(instrument.price_precision(), 1);
1863        assert_eq!(instrument.size_precision(), 0);
1864        assert_eq!(instrument.price_increment(), Price::from("0.1"));
1865        assert_eq!(instrument.size_increment(), Quantity::from(1));
1866    }
1867
1868    #[rstest]
1869    fn test_parse_option_instrument() {
1870        let json_data = load_test_json("http_get_instruments_option.json");
1871        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1872        let okx_inst: &OKXInstrument = response
1873            .data
1874            .first()
1875            .expect("Test data must have an instrument");
1876
1877        let instrument =
1878            parse_option_instrument(okx_inst, None, None, None, None, UnixNanos::default())
1879                .unwrap();
1880
1881        assert_eq!(
1882            instrument.id(),
1883            InstrumentId::from("BTC-USD-241217-92000-C.OKX")
1884        );
1885        assert_eq!(
1886            instrument.raw_symbol(),
1887            Symbol::from("BTC-USD-241217-92000-C")
1888        );
1889        assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
1890        assert_eq!(instrument.quote_currency(), Currency::USD());
1891        assert_eq!(instrument.price_precision(), 4);
1892        assert_eq!(instrument.size_precision(), 0);
1893        assert_eq!(instrument.price_increment(), Price::from("0.0001"));
1894        assert_eq!(instrument.size_increment(), Quantity::from(1));
1895    }
1896
1897    #[rstest]
1898    fn test_parse_account_state() {
1899        let json_data = load_test_json("http_get_account_balance.json");
1900        let response: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
1901        let okx_account = response
1902            .data
1903            .first()
1904            .expect("Test data must have an account");
1905
1906        let account_id = AccountId::new("OKX-001");
1907        let account_state =
1908            parse_account_state(okx_account, account_id, UnixNanos::default()).unwrap();
1909
1910        assert_eq!(account_state.account_id, account_id);
1911        assert_eq!(account_state.account_type, AccountType::Margin);
1912        assert_eq!(account_state.balances.len(), 1);
1913        assert_eq!(account_state.margins.len(), 0); // No margins in this test data (spot account)
1914        assert!(account_state.is_reported);
1915
1916        // Check the USDT balance details
1917        let usdt_balance = &account_state.balances[0];
1918        assert_eq!(
1919            usdt_balance.total,
1920            Money::new(94.42612990333333, Currency::USDT())
1921        );
1922        assert_eq!(
1923            usdt_balance.free,
1924            Money::new(94.42612990333333, Currency::USDT())
1925        );
1926        assert_eq!(usdt_balance.locked, Money::new(0.0, Currency::USDT()));
1927    }
1928
1929    #[rstest]
1930    fn test_parse_account_state_with_margins() {
1931        // Create test data with margin requirements
1932        let account_json = r#"{
1933            "adjEq": "10000.0",
1934            "borrowFroz": "0",
1935            "details": [{
1936                "accAvgPx": "",
1937                "availBal": "8000.0",
1938                "availEq": "8000.0",
1939                "borrowFroz": "0",
1940                "cashBal": "10000.0",
1941                "ccy": "USDT",
1942                "clSpotInUseAmt": "0",
1943                "coinUsdPrice": "1.0",
1944                "colBorrAutoConversion": "0",
1945                "collateralEnabled": false,
1946                "collateralRestrict": false,
1947                "crossLiab": "0",
1948                "disEq": "10000.0",
1949                "eq": "10000.0",
1950                "eqUsd": "10000.0",
1951                "fixedBal": "0",
1952                "frozenBal": "2000.0",
1953                "imr": "0",
1954                "interest": "0",
1955                "isoEq": "0",
1956                "isoLiab": "0",
1957                "isoUpl": "0",
1958                "liab": "0",
1959                "maxLoan": "0",
1960                "mgnRatio": "0",
1961                "maxSpotInUseAmt": "0",
1962                "mmr": "0",
1963                "notionalLever": "0",
1964                "openAvgPx": "",
1965                "ordFrozen": "2000.0",
1966                "rewardBal": "0",
1967                "smtSyncEq": "0",
1968                "spotBal": "0",
1969                "spotCopyTradingEq": "0",
1970                "spotInUseAmt": "0",
1971                "spotIsoBal": "0",
1972                "spotUpl": "0",
1973                "spotUplRatio": "0",
1974                "stgyEq": "0",
1975                "totalPnl": "0",
1976                "totalPnlRatio": "0",
1977                "twap": "0",
1978                "uTime": "1704067200000",
1979                "upl": "0",
1980                "uplLiab": "0"
1981            }],
1982            "imr": "500.25",
1983            "isoEq": "0",
1984            "mgnRatio": "20.5",
1985            "mmr": "250.75",
1986            "notionalUsd": "5000.0",
1987            "notionalUsdForBorrow": "0",
1988            "notionalUsdForFutures": "0",
1989            "notionalUsdForOption": "0",
1990            "notionalUsdForSwap": "5000.0",
1991            "ordFroz": "2000.0",
1992            "totalEq": "10000.0",
1993            "uTime": "1704067200000",
1994            "upl": "0"
1995        }"#;
1996
1997        let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
1998        let account_id = AccountId::new("OKX-001");
1999        let account_state =
2000            parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
2001
2002        // Verify account details
2003        assert_eq!(account_state.account_id, account_id);
2004        assert_eq!(account_state.account_type, AccountType::Margin);
2005        assert_eq!(account_state.balances.len(), 1);
2006
2007        // Verify margin information was parsed
2008        assert_eq!(account_state.margins.len(), 1);
2009        let margin = &account_state.margins[0];
2010
2011        // Check margin values
2012        assert_eq!(margin.initial, Money::new(500.25, Currency::USD()));
2013        assert_eq!(margin.maintenance, Money::new(250.75, Currency::USD()));
2014        assert_eq!(margin.currency, Currency::USD());
2015        assert_eq!(margin.instrument_id.symbol.as_str(), "ACCOUNT");
2016        assert_eq!(margin.instrument_id.venue.as_str(), "OKX");
2017
2018        // Check the USDT balance details
2019        let usdt_balance = &account_state.balances[0];
2020        assert_eq!(usdt_balance.total, Money::new(10000.0, Currency::USDT()));
2021        assert_eq!(usdt_balance.free, Money::new(8000.0, Currency::USDT()));
2022        assert_eq!(usdt_balance.locked, Money::new(2000.0, Currency::USDT()));
2023    }
2024
2025    #[rstest]
2026    fn test_parse_account_state_empty_margins() {
2027        // Create test data with empty margin strings (common for spot accounts)
2028        let account_json = r#"{
2029            "adjEq": "",
2030            "borrowFroz": "",
2031            "details": [{
2032                "accAvgPx": "",
2033                "availBal": "1000.0",
2034                "availEq": "1000.0",
2035                "borrowFroz": "0",
2036                "cashBal": "1000.0",
2037                "ccy": "BTC",
2038                "clSpotInUseAmt": "0",
2039                "coinUsdPrice": "50000.0",
2040                "colBorrAutoConversion": "0",
2041                "collateralEnabled": false,
2042                "collateralRestrict": false,
2043                "crossLiab": "0",
2044                "disEq": "50000.0",
2045                "eq": "1000.0",
2046                "eqUsd": "50000.0",
2047                "fixedBal": "0",
2048                "frozenBal": "0",
2049                "imr": "0",
2050                "interest": "0",
2051                "isoEq": "0",
2052                "isoLiab": "0",
2053                "isoUpl": "0",
2054                "liab": "0",
2055                "maxLoan": "0",
2056                "mgnRatio": "0",
2057                "maxSpotInUseAmt": "0",
2058                "mmr": "0",
2059                "notionalLever": "0",
2060                "openAvgPx": "",
2061                "ordFrozen": "0",
2062                "rewardBal": "0",
2063                "smtSyncEq": "0",
2064                "spotBal": "0",
2065                "spotCopyTradingEq": "0",
2066                "spotInUseAmt": "0",
2067                "spotIsoBal": "0",
2068                "spotUpl": "0",
2069                "spotUplRatio": "0",
2070                "stgyEq": "0",
2071                "totalPnl": "0",
2072                "totalPnlRatio": "0",
2073                "twap": "0",
2074                "uTime": "1704067200000",
2075                "upl": "0",
2076                "uplLiab": "0"
2077            }],
2078            "imr": "",
2079            "isoEq": "0",
2080            "mgnRatio": "",
2081            "mmr": "",
2082            "notionalUsd": "",
2083            "notionalUsdForBorrow": "",
2084            "notionalUsdForFutures": "",
2085            "notionalUsdForOption": "",
2086            "notionalUsdForSwap": "",
2087            "ordFroz": "",
2088            "totalEq": "50000.0",
2089            "uTime": "1704067200000",
2090            "upl": "0"
2091        }"#;
2092
2093        let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
2094        let account_id = AccountId::new("OKX-SPOT");
2095        let account_state =
2096            parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
2097
2098        // Verify no margins are created when fields are empty
2099        assert_eq!(account_state.margins.len(), 0);
2100        assert_eq!(account_state.balances.len(), 1);
2101
2102        // Check the BTC balance
2103        let btc_balance = &account_state.balances[0];
2104        assert_eq!(btc_balance.total, Money::new(1000.0, Currency::BTC()));
2105    }
2106
2107    #[rstest]
2108    fn test_parse_order_status_report() {
2109        let json_data = load_test_json("http_get_orders_history.json");
2110        let response: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
2111        let okx_order = response
2112            .data
2113            .first()
2114            .expect("Test data must have an order")
2115            .clone();
2116
2117        let account_id = AccountId::new("OKX-001");
2118        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2119        let order_report = parse_order_status_report(
2120            &okx_order,
2121            account_id,
2122            instrument_id,
2123            2,
2124            8,
2125            UnixNanos::default(),
2126        );
2127
2128        assert_eq!(order_report.account_id, account_id);
2129        assert_eq!(order_report.instrument_id, instrument_id);
2130        assert_eq!(order_report.quantity, Quantity::from("0.03000000"));
2131        assert_eq!(order_report.filled_qty, Quantity::from("0.03000000"));
2132        assert_eq!(order_report.order_side, OrderSide::Buy);
2133        assert_eq!(order_report.order_type, OrderType::Market);
2134        assert_eq!(order_report.order_status, OrderStatus::Filled);
2135    }
2136
2137    #[rstest]
2138    fn test_parse_position_status_report() {
2139        let json_data = load_test_json("http_get_positions.json");
2140        let response: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
2141        let okx_position = response
2142            .data
2143            .first()
2144            .expect("Test data must have a position")
2145            .clone();
2146
2147        let account_id = AccountId::new("OKX-001");
2148        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2149        let position_report = parse_position_status_report(
2150            okx_position,
2151            account_id,
2152            instrument_id,
2153            8,
2154            UnixNanos::default(),
2155        )
2156        .unwrap();
2157
2158        assert_eq!(position_report.account_id, account_id);
2159        assert_eq!(position_report.instrument_id, instrument_id);
2160    }
2161
2162    #[rstest]
2163    fn test_parse_trade_tick() {
2164        let json_data = load_test_json("http_get_trades.json");
2165        let response: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
2166        let okx_trade = response.data.first().expect("Test data must have a trade");
2167
2168        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2169        let trade_tick =
2170            parse_trade_tick(okx_trade, instrument_id, 2, 8, UnixNanos::default()).unwrap();
2171
2172        assert_eq!(trade_tick.instrument_id, instrument_id);
2173        assert_eq!(trade_tick.price, Price::from("102537.90"));
2174        assert_eq!(trade_tick.size, Quantity::from("0.00013669"));
2175        assert_eq!(trade_tick.aggressor_side, AggressorSide::Seller);
2176        assert_eq!(trade_tick.trade_id, TradeId::new("734864333"));
2177    }
2178
2179    #[rstest]
2180    fn test_parse_mark_price_update() {
2181        let json_data = load_test_json("http_get_mark_price.json");
2182        let response: OKXResponse<crate::http::models::OKXMarkPrice> =
2183            serde_json::from_str(&json_data).unwrap();
2184        let okx_mark_price = response
2185            .data
2186            .first()
2187            .expect("Test data must have a mark price");
2188
2189        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2190        let mark_price_update =
2191            parse_mark_price_update(okx_mark_price, instrument_id, 2, UnixNanos::default())
2192                .unwrap();
2193
2194        assert_eq!(mark_price_update.instrument_id, instrument_id);
2195        assert_eq!(mark_price_update.value, Price::from("84660.10"));
2196        assert_eq!(
2197            mark_price_update.ts_event,
2198            UnixNanos::from(1744590349506000000)
2199        );
2200    }
2201
2202    #[rstest]
2203    fn test_parse_index_price_update() {
2204        let json_data = load_test_json("http_get_index_price.json");
2205        let response: OKXResponse<crate::http::models::OKXIndexTicker> =
2206            serde_json::from_str(&json_data).unwrap();
2207        let okx_index_ticker = response
2208            .data
2209            .first()
2210            .expect("Test data must have an index ticker");
2211
2212        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2213        let index_price_update =
2214            parse_index_price_update(okx_index_ticker, instrument_id, 2, UnixNanos::default())
2215                .unwrap();
2216
2217        assert_eq!(index_price_update.instrument_id, instrument_id);
2218        assert_eq!(index_price_update.value, Price::from("103895.00"));
2219        assert_eq!(
2220            index_price_update.ts_event,
2221            UnixNanos::from(1746942707815000000)
2222        );
2223    }
2224
2225    #[rstest]
2226    fn test_parse_candlestick() {
2227        let json_data = load_test_json("http_get_candlesticks.json");
2228        let response: OKXResponse<crate::http::models::OKXCandlestick> =
2229            serde_json::from_str(&json_data).unwrap();
2230        let okx_candlestick = response
2231            .data
2232            .first()
2233            .expect("Test data must have a candlestick");
2234
2235        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2236        let bar_type = BarType::new(
2237            instrument_id,
2238            BAR_SPEC_1_DAY_LAST,
2239            AggregationSource::External,
2240        );
2241        let bar = parse_candlestick(okx_candlestick, bar_type, 2, 8, UnixNanos::default()).unwrap();
2242
2243        assert_eq!(bar.bar_type, bar_type);
2244        assert_eq!(bar.open, Price::from("33528.60"));
2245        assert_eq!(bar.high, Price::from("33870.00"));
2246        assert_eq!(bar.low, Price::from("33528.60"));
2247        assert_eq!(bar.close, Price::from("33783.90"));
2248        assert_eq!(bar.volume, Quantity::from("778.83800000"));
2249        assert_eq!(bar.ts_event, UnixNanos::from(1625097600000000000));
2250    }
2251
2252    #[rstest]
2253    fn test_parse_millisecond_timestamp() {
2254        let timestamp_ms = 1625097600000u64;
2255        let result = parse_millisecond_timestamp(timestamp_ms);
2256        assert_eq!(result, UnixNanos::from(1625097600000000000));
2257    }
2258
2259    #[rstest]
2260    fn test_parse_rfc3339_timestamp() {
2261        let timestamp_str = "2021-07-01T00:00:00.000Z";
2262        let result = parse_rfc3339_timestamp(timestamp_str).unwrap();
2263        assert_eq!(result, UnixNanos::from(1625097600000000000));
2264
2265        // Test with timezone
2266        let timestamp_str_tz = "2021-07-01T08:00:00.000+08:00";
2267        let result_tz = parse_rfc3339_timestamp(timestamp_str_tz).unwrap();
2268        assert_eq!(result_tz, UnixNanos::from(1625097600000000000));
2269
2270        // Test error case
2271        let invalid_timestamp = "invalid-timestamp";
2272        assert!(parse_rfc3339_timestamp(invalid_timestamp).is_err());
2273    }
2274
2275    #[rstest]
2276    fn test_parse_price() {
2277        let price_str = "42219.5";
2278        let precision = 2;
2279        let result = parse_price(price_str, precision).unwrap();
2280        assert_eq!(result, Price::from("42219.50"));
2281
2282        // Test error case
2283        let invalid_price = "invalid-price";
2284        assert!(parse_price(invalid_price, precision).is_err());
2285    }
2286
2287    #[rstest]
2288    fn test_parse_quantity() {
2289        let quantity_str = "0.12345678";
2290        let precision = 8;
2291        let result = parse_quantity(quantity_str, precision).unwrap();
2292        assert_eq!(result, Quantity::from("0.12345678"));
2293
2294        // Test error case
2295        let invalid_quantity = "invalid-quantity";
2296        assert!(parse_quantity(invalid_quantity, precision).is_err());
2297    }
2298
2299    #[rstest]
2300    fn test_parse_aggressor_side() {
2301        assert_eq!(
2302            parse_aggressor_side(&Some(OKXSide::Buy)),
2303            AggressorSide::Buyer
2304        );
2305        assert_eq!(
2306            parse_aggressor_side(&Some(OKXSide::Sell)),
2307            AggressorSide::Seller
2308        );
2309        assert_eq!(parse_aggressor_side(&None), AggressorSide::NoAggressor);
2310    }
2311
2312    #[rstest]
2313    fn test_parse_execution_type() {
2314        assert_eq!(
2315            parse_execution_type(&Some(OKXExecType::Maker)),
2316            LiquiditySide::Maker
2317        );
2318        assert_eq!(
2319            parse_execution_type(&Some(OKXExecType::Taker)),
2320            LiquiditySide::Taker
2321        );
2322        assert_eq!(parse_execution_type(&None), LiquiditySide::NoLiquiditySide);
2323    }
2324
2325    #[rstest]
2326    fn test_parse_position_side() {
2327        assert_eq!(parse_position_side(Some(100)), PositionSide::Long);
2328        assert_eq!(parse_position_side(Some(-100)), PositionSide::Short);
2329        assert_eq!(parse_position_side(Some(0)), PositionSide::Flat);
2330        assert_eq!(parse_position_side(None), PositionSide::Flat);
2331    }
2332
2333    #[rstest]
2334    fn test_parse_client_order_id() {
2335        let valid_id = "client_order_123";
2336        let result = parse_client_order_id(valid_id);
2337        assert_eq!(result, Some(ClientOrderId::new(valid_id)));
2338
2339        let empty_id = "";
2340        let result_empty = parse_client_order_id(empty_id);
2341        assert_eq!(result_empty, None);
2342    }
2343
2344    #[rstest]
2345    fn test_deserialize_empty_string_as_none() {
2346        let json_with_empty = r#""""#;
2347        let result: Option<String> = serde_json::from_str(json_with_empty).unwrap();
2348        let processed = result.filter(|s| !s.is_empty());
2349        assert_eq!(processed, None);
2350
2351        let json_with_value = r#""test_value""#;
2352        let result: Option<String> = serde_json::from_str(json_with_value).unwrap();
2353        let processed = result.filter(|s| !s.is_empty());
2354        assert_eq!(processed, Some("test_value".to_string()));
2355    }
2356
2357    #[rstest]
2358    fn test_deserialize_string_to_u64() {
2359        use serde::Deserialize;
2360
2361        #[derive(Deserialize)]
2362        struct TestStruct {
2363            #[serde(deserialize_with = "deserialize_string_to_u64")]
2364            value: u64,
2365        }
2366
2367        let json_value = r#"{"value": "12345"}"#;
2368        let result: TestStruct = serde_json::from_str(json_value).unwrap();
2369        assert_eq!(result.value, 12345);
2370
2371        let json_empty = r#"{"value": ""}"#;
2372        let result_empty: TestStruct = serde_json::from_str(json_empty).unwrap();
2373        assert_eq!(result_empty.value, 0);
2374    }
2375
2376    #[rstest]
2377    fn test_fill_report_parsing() {
2378        // Create a mock transaction detail for testing
2379        let transaction_detail = crate::http::models::OKXTransactionDetail {
2380            inst_type: OKXInstrumentType::Spot,
2381            inst_id: Ustr::from("BTC-USDT"),
2382            trade_id: Ustr::from("12345"),
2383            ord_id: Ustr::from("67890"),
2384            cl_ord_id: Ustr::from("client_123"),
2385            bill_id: Ustr::from("bill_456"),
2386            fill_px: "42219.5".to_string(),
2387            fill_sz: "0.001".to_string(),
2388            side: OKXSide::Buy,
2389            exec_type: OKXExecType::Taker,
2390            fee_ccy: "USDT".to_string(),
2391            fee: Some("0.042".to_string()),
2392            ts: 1625097600000,
2393        };
2394
2395        let account_id = AccountId::new("OKX-001");
2396        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2397        let fill_report = parse_fill_report(
2398            transaction_detail,
2399            account_id,
2400            instrument_id,
2401            2,
2402            8,
2403            UnixNanos::default(),
2404        )
2405        .unwrap();
2406
2407        assert_eq!(fill_report.account_id, account_id);
2408        assert_eq!(fill_report.instrument_id, instrument_id);
2409        assert_eq!(fill_report.trade_id, TradeId::new("12345"));
2410        assert_eq!(fill_report.venue_order_id, VenueOrderId::new("67890"));
2411        assert_eq!(fill_report.order_side, OrderSide::Buy);
2412        assert_eq!(fill_report.last_px, Price::from("42219.50"));
2413        assert_eq!(fill_report.last_qty, Quantity::from("0.00100000"));
2414        assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
2415    }
2416
2417    #[rstest]
2418    fn test_bar_type_identity_preserved_through_parse() {
2419        use std::str::FromStr;
2420
2421        use crate::http::models::OKXCandlestick;
2422
2423        // Create a BarType
2424        let bar_type = BarType::from_str("ETH-USDT-SWAP.OKX-1-MINUTE-LAST-EXTERNAL").unwrap();
2425
2426        // Create sample candlestick data
2427        let raw_candlestick = OKXCandlestick(
2428            "1721807460000".to_string(), // timestamp
2429            "3177.9".to_string(),        // open
2430            "3177.9".to_string(),        // high
2431            "3177.7".to_string(),        // low
2432            "3177.8".to_string(),        // close
2433            "18.603".to_string(),        // volume
2434            "59054.8231".to_string(),    // turnover
2435            "18.603".to_string(),        // base_volume
2436            "1".to_string(),             // count
2437        );
2438
2439        // Parse the candlestick
2440        let bar =
2441            parse_candlestick(&raw_candlestick, bar_type, 1, 3, UnixNanos::default()).unwrap();
2442
2443        // Verify that the BarType is preserved exactly
2444        assert_eq!(
2445            bar.bar_type, bar_type,
2446            "BarType must be preserved exactly through parsing"
2447        );
2448    }
2449
2450    #[rstest]
2451    fn test_deserialize_vip_level_with_lv_prefix() {
2452        use serde::Deserialize;
2453        use serde_json;
2454
2455        #[derive(Deserialize)]
2456        struct TestFeeRate {
2457            #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
2458            level: OKXVipLevel,
2459        }
2460
2461        let json = r#"{"level":"Lv4"}"#;
2462        let result: TestFeeRate = serde_json::from_str(json).unwrap();
2463        assert_eq!(result.level, OKXVipLevel::Vip4);
2464
2465        let json = r#"{"level":"Lv0"}"#;
2466        let result: TestFeeRate = serde_json::from_str(json).unwrap();
2467        assert_eq!(result.level, OKXVipLevel::Vip0);
2468
2469        let json = r#"{"level":"Lv9"}"#;
2470        let result: TestFeeRate = serde_json::from_str(json).unwrap();
2471        assert_eq!(result.level, OKXVipLevel::Vip9);
2472    }
2473
2474    #[rstest]
2475    fn test_deserialize_vip_level_without_prefix() {
2476        use serde::Deserialize;
2477        use serde_json;
2478
2479        #[derive(Deserialize)]
2480        struct TestFeeRate {
2481            #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
2482            level: OKXVipLevel,
2483        }
2484
2485        let json = r#"{"level":"5"}"#;
2486        let result: TestFeeRate = serde_json::from_str(json).unwrap();
2487        assert_eq!(result.level, OKXVipLevel::Vip5);
2488    }
2489}