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
16use std::str::FromStr;
17
18use nautilus_core::{
19    UUID4,
20    datetime::{NANOSECONDS_IN_MILLISECOND, millis_to_nanos},
21    nanos::UnixNanos,
22};
23use nautilus_model::{
24    currencies::CURRENCY_MAP,
25    data::{
26        Bar, BarSpecification, BarType, Data, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
27        TradeTick,
28        bar::{
29            BAR_SPEC_1_DAY_LAST, BAR_SPEC_1_HOUR_LAST, BAR_SPEC_1_MINUTE_LAST,
30            BAR_SPEC_1_MONTH_LAST, BAR_SPEC_1_SECOND_LAST, BAR_SPEC_1_WEEK_LAST,
31            BAR_SPEC_2_DAY_LAST, BAR_SPEC_2_HOUR_LAST, BAR_SPEC_3_DAY_LAST, BAR_SPEC_3_MINUTE_LAST,
32            BAR_SPEC_3_MONTH_LAST, BAR_SPEC_4_HOUR_LAST, BAR_SPEC_5_DAY_LAST,
33            BAR_SPEC_5_MINUTE_LAST, BAR_SPEC_6_HOUR_LAST, BAR_SPEC_6_MONTH_LAST,
34            BAR_SPEC_12_HOUR_LAST, BAR_SPEC_12_MONTH_LAST, BAR_SPEC_15_MINUTE_LAST,
35            BAR_SPEC_30_MINUTE_LAST,
36        },
37    },
38    enums::{
39        AccountType, AggregationSource, AggressorSide, AssetClass, CurrencyType, LiquiditySide,
40        OptionKind, OrderSide, OrderStatus, OrderType, PositionSide, TimeInForce,
41    },
42    events::AccountState,
43    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
44    instruments::{CryptoFuture, CryptoPerpetual, CurrencyPair, InstrumentAny, OptionContract},
45    reports::{FillReport, OrderStatusReport, PositionStatusReport},
46    types::{AccountBalance, Currency, Money, Price, Quantity},
47};
48use rust_decimal::Decimal;
49use serde::{Deserialize, Deserializer, de::DeserializeOwned};
50use ustr::Ustr;
51
52use super::enums::OKXContractType;
53use crate::{
54    common::{
55        consts::OKX_VENUE,
56        enums::{
57            OKXExecType, OKXInstrumentType, OKXOrderStatus, OKXOrderType, OKXPositionSide, OKXSide,
58        },
59        models::OKXInstrument,
60    },
61    http::models::{
62        OKXAccount, OKXCandlestick, OKXIndexTicker, OKXMarkPrice, OKXOrderHistory, OKXPosition,
63        OKXTrade, OKXTransactionDetail,
64    },
65    websocket::{enums::OKXWsChannel, messages::OKXFundingRateMsg},
66};
67
68/// Deserializes an empty string into [`None`].
69///
70/// OKX frequently represents *null* string fields as an empty string (`""`).
71/// When such a payload is mapped onto `Option<String>` the default behaviour
72/// would yield `Some("")`, which is semantically different from the intended
73/// absence of a value.  Applying this helper via
74///
75/// ```rust
76/// #[serde(deserialize_with = "crate::common::parse::deserialize_empty_string_as_none")]
77/// pub cl_ord_id: Option<String>,
78/// ```
79///
80/// ensures that empty strings are normalised to `None` during deserialization.
81///
82/// # Errors
83///
84/// Returns an error if the JSON value cannot be deserialised into a string.
85pub fn deserialize_empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
86where
87    D: Deserializer<'de>,
88{
89    let opt = Option::<String>::deserialize(deserializer)?;
90    Ok(opt.filter(|s| !s.is_empty()))
91}
92
93/// Deserializes an empty [`Ustr`] into [`None`].
94///
95/// # Errors
96///
97/// Returns an error if the JSON value cannot be deserialised into a string.
98pub fn deserialize_empty_ustr_as_none<'de, D>(deserializer: D) -> Result<Option<Ustr>, D::Error>
99where
100    D: Deserializer<'de>,
101{
102    let opt = Option::<Ustr>::deserialize(deserializer)?;
103    Ok(opt.filter(|s| !s.is_empty()))
104}
105
106/// Deserializes a numeric string into a `u64`.
107///
108/// # Errors
109///
110/// Returns an error if the string cannot be parsed into a `u64`.
111pub fn deserialize_string_to_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
112where
113    D: Deserializer<'de>,
114{
115    let s = String::deserialize(deserializer)?;
116    if s.is_empty() {
117        Ok(0)
118    } else {
119        s.parse::<u64>().map_err(serde::de::Error::custom)
120    }
121}
122
123/// Deserializes an optional numeric string into `Option<u64>`.
124///
125/// # Errors
126///
127/// Returns an error under the same cases as [`deserialize_string_to_u64`].
128pub fn deserialize_optional_string_to_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
129where
130    D: Deserializer<'de>,
131{
132    let s: Option<String> = Option::deserialize(deserializer)?;
133    match s {
134        Some(s) if s.is_empty() => Ok(None),
135        Some(s) => s.parse().map(Some).map_err(serde::de::Error::custom),
136        None => Ok(None),
137    }
138}
139
140/// Returns the currency either from the internal currency map or creates a default crypto.
141fn get_currency(code: &str) -> Currency {
142    CURRENCY_MAP
143        .lock()
144        .unwrap()
145        .get(code)
146        .copied()
147        .unwrap_or(Currency::new(code, 8, 0, code, CurrencyType::Crypto))
148}
149
150/// Returns the [`OKXInstrumentType`] that corresponds to the supplied
151/// [`InstrumentAny`].
152///
153/// # Errors
154///
155/// Returns an error if the instrument variant is not supported by OKX.
156pub fn okx_instrument_type(instrument: &InstrumentAny) -> anyhow::Result<OKXInstrumentType> {
157    match instrument {
158        InstrumentAny::CurrencyPair(_) => Ok(OKXInstrumentType::Spot),
159        InstrumentAny::CryptoPerpetual(_) => Ok(OKXInstrumentType::Swap),
160        InstrumentAny::CryptoFuture(_) => Ok(OKXInstrumentType::Futures),
161        InstrumentAny::CryptoOption(_) => Ok(OKXInstrumentType::Option),
162        _ => anyhow::bail!("Invalid instrument type for OKX: {instrument:?}"),
163    }
164}
165
166/// Parses a Nautilus instrument ID from the given OKX `symbol` value.
167#[must_use]
168pub fn parse_instrument_id(symbol: Ustr) -> InstrumentId {
169    InstrumentId::new(Symbol::from_ustr_unchecked(symbol), *OKX_VENUE)
170}
171
172/// Parses a Nautilus client order ID from the given OKX `clOrdId` value.
173#[must_use]
174pub fn parse_client_order_id(value: &str) -> Option<ClientOrderId> {
175    if value.is_empty() {
176        None
177    } else {
178        Some(ClientOrderId::new(value))
179    }
180}
181
182/// Converts a millisecond-based timestamp (as returned by OKX) into
183/// [`UnixNanos`].
184#[must_use]
185pub fn parse_millisecond_timestamp(timestamp_ms: u64) -> UnixNanos {
186    UnixNanos::from(timestamp_ms * NANOSECONDS_IN_MILLISECOND)
187}
188
189/// Parses an RFC 3339 timestamp string into [`UnixNanos`].
190///
191/// # Errors
192///
193/// Returns an error if the string is not a valid RFC 3339 datetime or if the
194/// timestamp cannot be represented in nanoseconds.
195pub fn parse_rfc3339_timestamp(timestamp: &str) -> anyhow::Result<UnixNanos> {
196    let dt = chrono::DateTime::parse_from_rfc3339(timestamp)?;
197    let nanos = dt.timestamp_nanos_opt().ok_or_else(|| {
198        anyhow::anyhow!("Failed to extract nanoseconds from timestamp: {timestamp}")
199    })?;
200    Ok(UnixNanos::from(nanos as u64))
201}
202
203/// Converts a textual price to a [`Price`] using the given precision.
204///
205/// # Errors
206///
207/// Returns an error if the string fails to parse into `f64` or if the number
208/// of decimal places exceeds `precision`.
209pub fn parse_price(value: &str, precision: u8) -> anyhow::Result<Price> {
210    Price::new_checked(value.parse::<f64>()?, precision)
211}
212
213/// Converts a textual quantity to a [`Quantity`].
214///
215/// # Errors
216///
217/// Returns an error for the same reasons as [`parse_price`] – parsing failure or invalid
218/// precision.
219pub fn parse_quantity(value: &str, precision: u8) -> anyhow::Result<Quantity> {
220    Quantity::new_checked(value.parse::<f64>()?, precision)
221}
222
223/// Converts a textual fee amount into a [`Money`] value.
224///
225/// OKX represents *charges* as positive numbers but they reduce the account
226/// balance, hence the value is negated.
227///
228/// # Errors
229///
230/// Returns an error if the fee cannot be parsed into `f64` or fails internal
231/// validation in [`Money::new_checked`].
232pub fn parse_fee(value: Option<&str>, currency: Currency) -> anyhow::Result<Money> {
233    // OKX report positive fees with negative signs (i.e., fee charged)
234    let fee_f64 = value.unwrap_or("0").parse::<f64>()?;
235    Money::new_checked(-fee_f64, currency)
236}
237
238/// Parses OKX side to Nautilus aggressor side.
239pub fn parse_aggressor_side(side: &Option<OKXSide>) -> AggressorSide {
240    match side {
241        Some(OKXSide::Buy) => AggressorSide::Buyer,
242        Some(OKXSide::Sell) => AggressorSide::Seller,
243        None => AggressorSide::NoAggressor,
244    }
245}
246
247/// Parses OKX execution type to Nautilus liquidity side.
248pub fn parse_execution_type(liquidity: &Option<OKXExecType>) -> LiquiditySide {
249    match liquidity {
250        Some(OKXExecType::Maker) => LiquiditySide::Maker,
251        Some(OKXExecType::Taker) => LiquiditySide::Taker,
252        _ => LiquiditySide::NoLiquiditySide,
253    }
254}
255
256/// Parses quantity to Nautilus position side.
257pub fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
258    match current_qty {
259        Some(qty) if qty > 0 => PositionSide::Long,
260        Some(qty) if qty < 0 => PositionSide::Short,
261        _ => PositionSide::Flat,
262    }
263}
264
265/// Parses an OKX mark price record into a Nautilus [`MarkPriceUpdate`].
266///
267/// # Errors
268///
269/// Returns an error if `raw.mark_px` cannot be parsed into a [`Price`] with
270/// the specified precision.
271pub fn parse_mark_price_update(
272    raw: &OKXMarkPrice,
273    instrument_id: InstrumentId,
274    price_precision: u8,
275    ts_init: UnixNanos,
276) -> anyhow::Result<MarkPriceUpdate> {
277    let ts_event = parse_millisecond_timestamp(raw.ts);
278    let price = parse_price(&raw.mark_px, price_precision)?;
279    Ok(MarkPriceUpdate::new(
280        instrument_id,
281        price,
282        ts_event,
283        ts_init,
284    ))
285}
286
287/// Parses an OKX index ticker record into a Nautilus [`IndexPriceUpdate`].
288///
289/// # Errors
290///
291/// Returns an error if `raw.idx_px` cannot be parsed into a [`Price`] with the
292/// specified precision.
293pub fn parse_index_price_update(
294    raw: &OKXIndexTicker,
295    instrument_id: InstrumentId,
296    price_precision: u8,
297    ts_init: UnixNanos,
298) -> anyhow::Result<IndexPriceUpdate> {
299    let ts_event = parse_millisecond_timestamp(raw.ts);
300    let price = parse_price(&raw.idx_px, price_precision)?;
301    Ok(IndexPriceUpdate::new(
302        instrument_id,
303        price,
304        ts_event,
305        ts_init,
306    ))
307}
308
309/// Parses an [`OKXFundingRateMsg`] into a [`FundingRateUpdate`].
310///
311/// # Errors
312///
313/// Returns an error if the `funding_rate` or `next_funding_rate` fields fail
314/// to parse into Decimal values.
315pub fn parse_funding_rate_msg(
316    msg: &OKXFundingRateMsg,
317    instrument_id: InstrumentId,
318    ts_init: UnixNanos,
319) -> anyhow::Result<FundingRateUpdate> {
320    let funding_rate = msg
321        .funding_rate
322        .as_str()
323        .parse::<Decimal>()
324        .map_err(|e| anyhow::anyhow!("Invalid funding_rate value: {e}"))?
325        .normalize();
326
327    let funding_time = Some(parse_millisecond_timestamp(msg.funding_time));
328    let ts_event = parse_millisecond_timestamp(msg.ts);
329
330    Ok(FundingRateUpdate::new(
331        instrument_id,
332        funding_rate,
333        funding_time,
334        ts_event,
335        ts_init,
336    ))
337}
338
339/// Parses an OKX trade record into a Nautilus [`TradeTick`].
340///
341/// # Errors
342///
343/// Returns an error if the price or quantity strings cannot be parsed, or if
344/// [`TradeTick::new_checked`] validation fails.
345pub fn parse_trade_tick(
346    raw: &OKXTrade,
347    instrument_id: InstrumentId,
348    price_precision: u8,
349    size_precision: u8,
350    ts_init: UnixNanos,
351) -> anyhow::Result<TradeTick> {
352    let ts_event = parse_millisecond_timestamp(raw.ts);
353    let price = parse_price(&raw.px, price_precision)?;
354    let size = parse_quantity(&raw.sz, size_precision)?;
355    let aggressor: AggressorSide = raw.side.into();
356    let trade_id = TradeId::new(raw.trade_id);
357
358    TradeTick::new_checked(
359        instrument_id,
360        price,
361        size,
362        aggressor,
363        trade_id,
364        ts_event,
365        ts_init,
366    )
367}
368
369/// Parses an OKX historical candlestick record into a Nautilus [`Bar`].
370///
371/// # Errors
372///
373/// Returns an error if any of the price or volume strings cannot be parsed or
374/// if [`Bar::new`] validation fails.
375pub fn parse_candlestick(
376    raw: &OKXCandlestick,
377    bar_type: BarType,
378    price_precision: u8,
379    size_precision: u8,
380    ts_init: UnixNanos,
381) -> anyhow::Result<Bar> {
382    let ts_event = parse_millisecond_timestamp(raw.0.parse()?);
383    let open = parse_price(&raw.1, price_precision)?;
384    let high = parse_price(&raw.2, price_precision)?;
385    let low = parse_price(&raw.3, price_precision)?;
386    let close = parse_price(&raw.4, price_precision)?;
387    let volume = parse_quantity(&raw.5, size_precision)?;
388
389    Ok(Bar::new(
390        bar_type, open, high, low, close, volume, ts_event, ts_init,
391    ))
392}
393
394/// Parses an OKX order history record into a Nautilus [`OrderStatusReport`].
395#[allow(clippy::too_many_lines)]
396pub fn parse_order_status_report(
397    order: &OKXOrderHistory,
398    account_id: AccountId,
399    instrument_id: InstrumentId,
400    price_precision: u8,
401    size_precision: u8,
402    ts_init: UnixNanos,
403) -> OrderStatusReport {
404    let quantity = order
405        .sz
406        .parse::<f64>()
407        .ok()
408        .map(|v| Quantity::new(v, size_precision))
409        .unwrap_or_default();
410    let filled_qty = order
411        .acc_fill_sz
412        .parse::<f64>()
413        .ok()
414        .map(|v| Quantity::new(v, size_precision))
415        .unwrap_or_default();
416    let order_side: OrderSide = order.side.into();
417    let okx_status: OKXOrderStatus = match order.state.as_str() {
418        "live" => OKXOrderStatus::Live,
419        "partially_filled" => OKXOrderStatus::PartiallyFilled,
420        "filled" => OKXOrderStatus::Filled,
421        "canceled" => OKXOrderStatus::Canceled,
422        "mmp_canceled" => OKXOrderStatus::MmpCanceled,
423        _ => OKXOrderStatus::Live, // Default fallback
424    };
425    let order_status: OrderStatus = okx_status.into();
426    let okx_ord_type: OKXOrderType = match order.ord_type.as_str() {
427        "market" => OKXOrderType::Market,
428        "limit" => OKXOrderType::Limit,
429        "post_only" => OKXOrderType::PostOnly,
430        "fok" => OKXOrderType::Fok,
431        "ioc" => OKXOrderType::Ioc,
432        "optimal_limit_ioc" => OKXOrderType::OptimalLimitIoc,
433        "mmp" => OKXOrderType::Mmp,
434        "mmp_and_post_only" => OKXOrderType::MmpAndPostOnly,
435        _ => OKXOrderType::Limit, // Default fallback
436    };
437    let order_type: OrderType = okx_ord_type.into();
438    // Note: OKX uses ordType for type and liquidity instructions; time-in-force not explicitly represented here
439    let time_in_force = TimeInForce::Gtc;
440
441    // Build report
442    let client_ord = if order.cl_ord_id.is_empty() {
443        None
444    } else {
445        Some(ClientOrderId::new(order.cl_ord_id))
446    };
447
448    let ts_accepted = parse_millisecond_timestamp(order.c_time);
449    let ts_last = UnixNanos::from(order.u_time * NANOSECONDS_IN_MILLISECOND);
450
451    let mut report = OrderStatusReport::new(
452        account_id,
453        instrument_id,
454        client_ord,
455        VenueOrderId::new(order.ord_id),
456        order_side,
457        order_type,
458        time_in_force,
459        order_status,
460        quantity,
461        filled_qty,
462        ts_accepted,
463        ts_last,
464        ts_init,
465        None,
466    );
467
468    // Optional fields
469    if !order.px.is_empty()
470        && let Ok(p) = order.px.parse::<f64>()
471    {
472        report = report.with_price(Price::new(p, price_precision));
473    }
474    if !order.avg_px.is_empty()
475        && let Ok(avg) = order.avg_px.parse::<f64>()
476    {
477        report = report.with_avg_px(avg);
478    }
479    if order.ord_type == "post_only" {
480        report = report.with_post_only(true);
481    }
482    if order.reduce_only == "true" {
483        report = report.with_reduce_only(true);
484    }
485    report
486}
487
488/// Parses an OKX position into a Nautilus [`PositionStatusReport`].
489///
490/// # Panics
491///
492/// Panics if position quantity is invalid and cannot be parsed.
493#[allow(clippy::too_many_lines)]
494pub fn parse_position_status_report(
495    position: OKXPosition,
496    account_id: AccountId,
497    instrument_id: InstrumentId,
498    size_precision: u8,
499    ts_init: UnixNanos,
500) -> PositionStatusReport {
501    let pos_value = position.pos.parse::<f64>().unwrap_or_else(|e| {
502        panic!(
503            "Failed to parse position quantity '{}' for instrument {}: {:?}",
504            position.pos, instrument_id, e
505        )
506    });
507
508    // For Net position mode, determine side based on position sign
509    let position_side = match position.pos_side {
510        OKXPositionSide::Net => {
511            if pos_value > 0.0 {
512                PositionSide::Long
513            } else if pos_value < 0.0 {
514                PositionSide::Short
515            } else {
516                PositionSide::Flat
517            }
518        }
519        _ => position.pos_side.into(),
520    }
521    .as_specified();
522
523    // Convert to absolute quantity (positions are always positive in Nautilus)
524    let quantity = Quantity::new(pos_value.abs(), size_precision);
525    let venue_position_id = None; // TODO: Only support netting for now
526    // let venue_position_id = Some(PositionId::new(position.pos_id));
527    let ts_last = parse_millisecond_timestamp(position.u_time);
528
529    PositionStatusReport::new(
530        account_id,
531        instrument_id,
532        position_side,
533        quantity,
534        venue_position_id,
535        ts_last,
536        ts_init,
537        None,
538    )
539}
540
541/// Parses an OKX transaction detail into a Nautilus `FillReport`.
542///
543/// # Errors
544///
545/// This function will return an error if the OKX transaction detail cannot be parsed.
546pub fn parse_fill_report(
547    detail: OKXTransactionDetail,
548    account_id: AccountId,
549    instrument_id: InstrumentId,
550    price_precision: u8,
551    size_precision: u8,
552    ts_init: UnixNanos,
553) -> anyhow::Result<FillReport> {
554    let client_order_id = if detail.cl_ord_id.is_empty() {
555        None
556    } else {
557        Some(ClientOrderId::new(detail.cl_ord_id))
558    };
559    let venue_order_id = VenueOrderId::new(detail.ord_id);
560    let trade_id = TradeId::new(detail.trade_id);
561    let order_side: OrderSide = detail.side.into();
562    let last_px = parse_price(&detail.fill_px, price_precision)?;
563    let last_qty = parse_quantity(&detail.fill_sz, size_precision)?;
564    let fee_f64 = detail.fee.as_deref().unwrap_or("0").parse::<f64>()?;
565    let commission = Money::new(-fee_f64, Currency::from(&detail.fee_ccy));
566    let liquidity_side: LiquiditySide = detail.exec_type.into();
567    let ts_event = parse_millisecond_timestamp(detail.ts);
568
569    Ok(FillReport::new(
570        account_id,
571        instrument_id,
572        venue_order_id,
573        trade_id,
574        order_side,
575        last_qty,
576        last_px,
577        commission,
578        liquidity_side,
579        client_order_id,
580        None, // venue_position_id not provided by OKX fills
581        ts_event,
582        ts_init,
583        None, // Will generate a new UUID4
584    ))
585}
586
587/// Parses vector messages from OKX WebSocket data.
588///
589/// Reduces code duplication by providing a common pattern for deserializing JSON arrays,
590/// parsing each message, and wrapping results in Nautilus Data enum variants.
591pub fn parse_message_vec<T, R, F, W>(
592    data: serde_json::Value,
593    parser: F,
594    wrapper: W,
595) -> anyhow::Result<Vec<Data>>
596where
597    T: DeserializeOwned,
598    F: Fn(&T) -> anyhow::Result<R>,
599    W: Fn(R) -> Data,
600{
601    let msgs: Vec<T> = serde_json::from_value(data)?;
602    let mut results = Vec::with_capacity(msgs.len());
603
604    for msg in msgs {
605        let parsed = parser(&msg)?;
606        results.push(wrapper(parsed));
607    }
608
609    Ok(results)
610}
611
612pub fn bar_spec_as_okx_channel(bar_spec: BarSpecification) -> anyhow::Result<OKXWsChannel> {
613    let channel = match bar_spec {
614        BAR_SPEC_1_SECOND_LAST => OKXWsChannel::Candle1Second,
615        BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::Candle1Minute,
616        BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::Candle3Minute,
617        BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::Candle5Minute,
618        BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::Candle15Minute,
619        BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::Candle30Minute,
620        BAR_SPEC_1_HOUR_LAST => OKXWsChannel::Candle1Hour,
621        BAR_SPEC_2_HOUR_LAST => OKXWsChannel::Candle2Hour,
622        BAR_SPEC_4_HOUR_LAST => OKXWsChannel::Candle4Hour,
623        BAR_SPEC_6_HOUR_LAST => OKXWsChannel::Candle6Hour,
624        BAR_SPEC_12_HOUR_LAST => OKXWsChannel::Candle12Hour,
625        BAR_SPEC_1_DAY_LAST => OKXWsChannel::Candle1Day,
626        BAR_SPEC_2_DAY_LAST => OKXWsChannel::Candle2Day,
627        BAR_SPEC_3_DAY_LAST => OKXWsChannel::Candle3Day,
628        BAR_SPEC_5_DAY_LAST => OKXWsChannel::Candle5Day,
629        BAR_SPEC_1_WEEK_LAST => OKXWsChannel::Candle1Week,
630        BAR_SPEC_1_MONTH_LAST => OKXWsChannel::Candle1Month,
631        BAR_SPEC_3_MONTH_LAST => OKXWsChannel::Candle3Month,
632        BAR_SPEC_6_MONTH_LAST => OKXWsChannel::Candle6Month,
633        BAR_SPEC_12_MONTH_LAST => OKXWsChannel::Candle1Year,
634        _ => anyhow::bail!("Invalid `BarSpecification` for channel, was {bar_spec}"),
635    };
636    Ok(channel)
637}
638
639/// Converts Nautilus bar specification to OKX mark price channel.
640pub fn bar_spec_as_okx_mark_price_channel(
641    bar_spec: BarSpecification,
642) -> anyhow::Result<OKXWsChannel> {
643    let channel = match bar_spec {
644        BAR_SPEC_1_SECOND_LAST => OKXWsChannel::MarkPriceCandle1Second,
645        BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::MarkPriceCandle1Minute,
646        BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::MarkPriceCandle3Minute,
647        BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::MarkPriceCandle5Minute,
648        BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::MarkPriceCandle15Minute,
649        BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::MarkPriceCandle30Minute,
650        BAR_SPEC_1_HOUR_LAST => OKXWsChannel::MarkPriceCandle1Hour,
651        BAR_SPEC_2_HOUR_LAST => OKXWsChannel::MarkPriceCandle2Hour,
652        BAR_SPEC_4_HOUR_LAST => OKXWsChannel::MarkPriceCandle4Hour,
653        BAR_SPEC_6_HOUR_LAST => OKXWsChannel::MarkPriceCandle6Hour,
654        BAR_SPEC_12_HOUR_LAST => OKXWsChannel::MarkPriceCandle12Hour,
655        BAR_SPEC_1_DAY_LAST => OKXWsChannel::MarkPriceCandle1Day,
656        BAR_SPEC_2_DAY_LAST => OKXWsChannel::MarkPriceCandle2Day,
657        BAR_SPEC_3_DAY_LAST => OKXWsChannel::MarkPriceCandle3Day,
658        BAR_SPEC_5_DAY_LAST => OKXWsChannel::MarkPriceCandle5Day,
659        BAR_SPEC_1_WEEK_LAST => OKXWsChannel::MarkPriceCandle1Week,
660        BAR_SPEC_1_MONTH_LAST => OKXWsChannel::MarkPriceCandle1Month,
661        BAR_SPEC_3_MONTH_LAST => OKXWsChannel::MarkPriceCandle3Month,
662        _ => anyhow::bail!("Invalid `BarSpecification` for mark price channel, was {bar_spec}"),
663    };
664    Ok(channel)
665}
666
667/// Converts Nautilus bar specification to OKX timeframe string.
668pub fn bar_spec_as_okx_timeframe(bar_spec: BarSpecification) -> anyhow::Result<&'static str> {
669    let timeframe = match bar_spec {
670        BAR_SPEC_1_SECOND_LAST => "1s",
671        BAR_SPEC_1_MINUTE_LAST => "1m",
672        BAR_SPEC_3_MINUTE_LAST => "3m",
673        BAR_SPEC_5_MINUTE_LAST => "5m",
674        BAR_SPEC_15_MINUTE_LAST => "15m",
675        BAR_SPEC_30_MINUTE_LAST => "30m",
676        BAR_SPEC_1_HOUR_LAST => "1H",
677        BAR_SPEC_2_HOUR_LAST => "2H",
678        BAR_SPEC_4_HOUR_LAST => "4H",
679        BAR_SPEC_6_HOUR_LAST => "6H",
680        BAR_SPEC_12_HOUR_LAST => "12H",
681        BAR_SPEC_1_DAY_LAST => "1D",
682        BAR_SPEC_2_DAY_LAST => "2D",
683        BAR_SPEC_3_DAY_LAST => "3D",
684        BAR_SPEC_5_DAY_LAST => "5D",
685        BAR_SPEC_1_WEEK_LAST => "1W",
686        BAR_SPEC_1_MONTH_LAST => "1M",
687        BAR_SPEC_3_MONTH_LAST => "3M",
688        BAR_SPEC_6_MONTH_LAST => "6M",
689        BAR_SPEC_12_MONTH_LAST => "1Y",
690        _ => anyhow::bail!("Invalid `BarSpecification` for timeframe, was {bar_spec}"),
691    };
692    Ok(timeframe)
693}
694
695/// Converts OKX timeframe string to Nautilus bar specification.
696pub fn okx_timeframe_as_bar_spec(timeframe: &str) -> anyhow::Result<BarSpecification> {
697    let bar_spec = match timeframe {
698        "1s" => BAR_SPEC_1_SECOND_LAST,
699        "1m" => BAR_SPEC_1_MINUTE_LAST,
700        "3m" => BAR_SPEC_3_MINUTE_LAST,
701        "5m" => BAR_SPEC_5_MINUTE_LAST,
702        "15m" => BAR_SPEC_15_MINUTE_LAST,
703        "30m" => BAR_SPEC_30_MINUTE_LAST,
704        "1H" => BAR_SPEC_1_HOUR_LAST,
705        "2H" => BAR_SPEC_2_HOUR_LAST,
706        "4H" => BAR_SPEC_4_HOUR_LAST,
707        "6H" => BAR_SPEC_6_HOUR_LAST,
708        "12H" => BAR_SPEC_12_HOUR_LAST,
709        "1D" => BAR_SPEC_1_DAY_LAST,
710        "2D" => BAR_SPEC_2_DAY_LAST,
711        "3D" => BAR_SPEC_3_DAY_LAST,
712        "5D" => BAR_SPEC_5_DAY_LAST,
713        "1W" => BAR_SPEC_1_WEEK_LAST,
714        "1M" => BAR_SPEC_1_MONTH_LAST,
715        "3M" => BAR_SPEC_3_MONTH_LAST,
716        "6M" => BAR_SPEC_6_MONTH_LAST,
717        "1Y" => BAR_SPEC_12_MONTH_LAST,
718        _ => anyhow::bail!("Invalid timeframe for `BarSpecification`, was {timeframe}"),
719    };
720    Ok(bar_spec)
721}
722
723/// Constructs a properly formatted BarType from OKX instrument ID and timeframe string.
724/// This ensures the BarType uses canonical Nautilus format instead of raw OKX strings.
725pub fn okx_bar_type_from_timeframe(
726    instrument_id: InstrumentId,
727    timeframe: &str,
728) -> anyhow::Result<BarType> {
729    let bar_spec = okx_timeframe_as_bar_spec(timeframe)?;
730    Ok(BarType::new(
731        instrument_id,
732        bar_spec,
733        AggregationSource::External,
734    ))
735}
736
737/// Converts OKX WebSocket channel to bar specification if it's a candle channel.
738pub fn okx_channel_to_bar_spec(channel: &OKXWsChannel) -> Option<BarSpecification> {
739    use OKXWsChannel::*;
740    match channel {
741        Candle1Second | MarkPriceCandle1Second => Some(BAR_SPEC_1_SECOND_LAST),
742        Candle1Minute | MarkPriceCandle1Minute => Some(BAR_SPEC_1_MINUTE_LAST),
743        Candle3Minute | MarkPriceCandle3Minute => Some(BAR_SPEC_3_MINUTE_LAST),
744        Candle5Minute | MarkPriceCandle5Minute => Some(BAR_SPEC_5_MINUTE_LAST),
745        Candle15Minute | MarkPriceCandle15Minute => Some(BAR_SPEC_15_MINUTE_LAST),
746        Candle30Minute | MarkPriceCandle30Minute => Some(BAR_SPEC_30_MINUTE_LAST),
747        Candle1Hour | MarkPriceCandle1Hour => Some(BAR_SPEC_1_HOUR_LAST),
748        Candle2Hour | MarkPriceCandle2Hour => Some(BAR_SPEC_2_HOUR_LAST),
749        Candle4Hour | MarkPriceCandle4Hour => Some(BAR_SPEC_4_HOUR_LAST),
750        Candle6Hour | MarkPriceCandle6Hour => Some(BAR_SPEC_6_HOUR_LAST),
751        Candle12Hour | MarkPriceCandle12Hour => Some(BAR_SPEC_12_HOUR_LAST),
752        Candle1Day | MarkPriceCandle1Day => Some(BAR_SPEC_1_DAY_LAST),
753        Candle2Day | MarkPriceCandle2Day => Some(BAR_SPEC_2_DAY_LAST),
754        Candle3Day | MarkPriceCandle3Day => Some(BAR_SPEC_3_DAY_LAST),
755        Candle5Day | MarkPriceCandle5Day => Some(BAR_SPEC_5_DAY_LAST),
756        Candle1Week | MarkPriceCandle1Week => Some(BAR_SPEC_1_WEEK_LAST),
757        Candle1Month | MarkPriceCandle1Month => Some(BAR_SPEC_1_MONTH_LAST),
758        Candle3Month | MarkPriceCandle3Month => Some(BAR_SPEC_3_MONTH_LAST),
759        Candle6Month => Some(BAR_SPEC_6_MONTH_LAST),
760        Candle1Year => Some(BAR_SPEC_12_MONTH_LAST),
761        _ => None,
762    }
763}
764
765/// Parses an OKX instrument definition into a Nautilus instrument.
766pub fn parse_instrument_any(
767    instrument: &OKXInstrument,
768    ts_init: UnixNanos,
769) -> anyhow::Result<Option<InstrumentAny>> {
770    match instrument.inst_type {
771        OKXInstrumentType::Spot => {
772            parse_spot_instrument(instrument, None, None, None, None, ts_init).map(Some)
773        }
774        OKXInstrumentType::Swap => {
775            parse_swap_instrument(instrument, None, None, None, None, ts_init).map(Some)
776        }
777        OKXInstrumentType::Futures => {
778            parse_futures_instrument(instrument, None, None, None, None, ts_init).map(Some)
779        }
780        OKXInstrumentType::Option => {
781            parse_option_instrument(instrument, None, None, None, None, ts_init).map(Some)
782        }
783        _ => Ok(None),
784    }
785}
786
787/// Common parsed instrument data extracted from OKX definitions.
788#[derive(Debug)]
789struct CommonInstrumentData {
790    instrument_id: InstrumentId,
791    raw_symbol: Symbol,
792    price_increment: Price,
793    size_increment: Quantity,
794    lot_size: Option<Quantity>,
795    max_quantity: Option<Quantity>,
796    min_quantity: Option<Quantity>,
797    max_notional: Option<Money>,
798    min_notional: Option<Money>,
799    max_price: Option<Price>,
800    min_price: Option<Price>,
801}
802
803/// Margin and fee configuration for an instrument.
804struct MarginAndFees {
805    margin_init: Option<Decimal>,
806    margin_maint: Option<Decimal>,
807    maker_fee: Option<Decimal>,
808    taker_fee: Option<Decimal>,
809}
810
811/// Trait for instrument-specific parsing logic.
812trait InstrumentParser {
813    /// Parses instrument-specific fields and creates the final instrument.
814    fn parse_specific_fields(
815        &self,
816        definition: &OKXInstrument,
817        common: CommonInstrumentData,
818        margin_fees: MarginAndFees,
819        ts_init: UnixNanos,
820    ) -> anyhow::Result<InstrumentAny>;
821}
822
823/// Extracts common fields shared across all instrument types.
824fn parse_common_instrument_data(
825    definition: &OKXInstrument,
826) -> anyhow::Result<CommonInstrumentData> {
827    let instrument_id = parse_instrument_id(definition.inst_id);
828    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
829
830    let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
831        anyhow::anyhow!(
832            "Failed to parse tick_sz '{}' into Price: {}",
833            definition.tick_sz,
834            e
835        )
836    })?;
837
838    let size_increment = Quantity::from(&definition.lot_sz);
839    let lot_size = Some(Quantity::from(&definition.lot_sz));
840    let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
841    let min_quantity = Some(Quantity::from(&definition.min_sz));
842    let max_notional: Option<Money> = None;
843    let min_notional: Option<Money> = None;
844    let max_price = None; // TBD
845    let min_price = None; // TBD
846
847    Ok(CommonInstrumentData {
848        instrument_id,
849        raw_symbol,
850        price_increment,
851        size_increment,
852        lot_size,
853        max_quantity,
854        min_quantity,
855        max_notional,
856        min_notional,
857        max_price,
858        min_price,
859    })
860}
861
862/// Generic instrument parsing function that delegates to type-specific parsers.
863fn parse_instrument_with_parser<P: InstrumentParser>(
864    definition: &OKXInstrument,
865    parser: P,
866    margin_init: Option<Decimal>,
867    margin_maint: Option<Decimal>,
868    maker_fee: Option<Decimal>,
869    taker_fee: Option<Decimal>,
870    ts_init: UnixNanos,
871) -> anyhow::Result<InstrumentAny> {
872    let common = parse_common_instrument_data(definition)?;
873    parser.parse_specific_fields(
874        definition,
875        common,
876        MarginAndFees {
877            margin_init,
878            margin_maint,
879            maker_fee,
880            taker_fee,
881        },
882        ts_init,
883    )
884}
885
886/// Parser for spot trading pairs (CurrencyPair).
887struct SpotInstrumentParser;
888
889impl InstrumentParser for SpotInstrumentParser {
890    fn parse_specific_fields(
891        &self,
892        definition: &OKXInstrument,
893        common: CommonInstrumentData,
894        margin_fees: MarginAndFees,
895        ts_init: UnixNanos,
896    ) -> anyhow::Result<InstrumentAny> {
897        let base_currency = get_currency(&definition.base_ccy);
898        let quote_currency = get_currency(&definition.quote_ccy);
899
900        let instrument = CurrencyPair::new(
901            common.instrument_id,
902            common.raw_symbol,
903            base_currency,
904            quote_currency,
905            common.price_increment.precision,
906            common.size_increment.precision,
907            common.price_increment,
908            common.size_increment,
909            None,
910            common.lot_size,
911            common.max_quantity,
912            common.min_quantity,
913            common.max_notional,
914            common.min_notional,
915            common.max_price,
916            common.min_price,
917            margin_fees.margin_init,
918            margin_fees.margin_maint,
919            margin_fees.maker_fee,
920            margin_fees.taker_fee,
921            ts_init,
922            ts_init,
923        );
924
925        Ok(InstrumentAny::CurrencyPair(instrument))
926    }
927}
928
929/// Parses an OKX spot instrument definition into a Nautilus currency pair.
930pub fn parse_spot_instrument(
931    definition: &OKXInstrument,
932    margin_init: Option<Decimal>,
933    margin_maint: Option<Decimal>,
934    maker_fee: Option<Decimal>,
935    taker_fee: Option<Decimal>,
936    ts_init: UnixNanos,
937) -> anyhow::Result<InstrumentAny> {
938    parse_instrument_with_parser(
939        definition,
940        SpotInstrumentParser,
941        margin_init,
942        margin_maint,
943        maker_fee,
944        taker_fee,
945        ts_init,
946    )
947}
948
949/// Parses an OKX swap instrument definition into a Nautilus crypto perpetual.
950pub fn parse_swap_instrument(
951    definition: &OKXInstrument,
952    margin_init: Option<Decimal>,
953    margin_maint: Option<Decimal>,
954    maker_fee: Option<Decimal>,
955    taker_fee: Option<Decimal>,
956    ts_init: UnixNanos,
957) -> anyhow::Result<InstrumentAny> {
958    let instrument_id = parse_instrument_id(definition.inst_id);
959    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
960    let (base_currency, quote_currency) = definition
961        .uly
962        .split_once('-')
963        .ok_or_else(|| anyhow::anyhow!("Invalid underlying for swap: {}", definition.uly))?;
964    let base_currency = get_currency(base_currency);
965    let quote_currency = get_currency(quote_currency);
966    let settlement_currency = get_currency(&definition.settle_ccy);
967    let is_inverse = match definition.ct_type {
968        OKXContractType::Linear => false,
969        OKXContractType::Inverse => true,
970        OKXContractType::None => {
971            anyhow::bail!("Invalid contract type for swap: {}", definition.ct_type)
972        }
973    };
974    let price_increment = match Price::from_str(&definition.tick_sz) {
975        Ok(price) => price,
976        Err(e) => {
977            anyhow::bail!(
978                "Failed to parse tick_size '{}' into Price: {}",
979                definition.tick_sz,
980                e
981            );
982        }
983    };
984    let size_increment = Quantity::from(&definition.lot_sz);
985    let multiplier = Some(Quantity::from(&definition.ct_mult));
986    let lot_size = Some(Quantity::from(&definition.lot_sz));
987    let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
988    let min_quantity = Some(Quantity::from(&definition.min_sz));
989    let max_notional: Option<Money> = None;
990    let min_notional: Option<Money> = None;
991    let max_price = None; // TBD
992    let min_price = None; // TBD
993
994    // For linear swaps (USDT-margined), trades are in base currency units (e.g., BTC)
995    // For inverse swaps (coin-margined), trades are in contract units
996    // The lotSz represents minimum contract size, but actual trade sizes for linear swaps
997    // are in fractional base currency amounts requiring higher precision
998    let (size_precision, adjusted_size_increment) = if is_inverse {
999        // For inverse swaps, use the lot size precision as trades are in contract units
1000        (size_increment.precision, size_increment)
1001    } else {
1002        // For linear swaps, use base currency precision (typically 8 for crypto)
1003        // and adjust the size increment to match this precision
1004        let precision = 8u8;
1005        let adjusted_increment = Quantity::new(1.0, precision); // Minimum trade size of 0.00000001
1006        (precision, adjusted_increment)
1007    };
1008
1009    let instrument = CryptoPerpetual::new(
1010        instrument_id,
1011        raw_symbol,
1012        base_currency,
1013        quote_currency,
1014        settlement_currency,
1015        is_inverse,
1016        price_increment.precision,
1017        size_precision,
1018        price_increment,
1019        adjusted_size_increment,
1020        multiplier,
1021        lot_size,
1022        max_quantity,
1023        min_quantity,
1024        max_notional,
1025        min_notional,
1026        max_price,
1027        min_price,
1028        margin_init,
1029        margin_maint,
1030        maker_fee,
1031        taker_fee,
1032        ts_init, // No ts_event for response
1033        ts_init,
1034    );
1035
1036    Ok(InstrumentAny::CryptoPerpetual(instrument))
1037}
1038
1039/// Parses an OKX futures instrument definition into a Nautilus crypto future.
1040pub fn parse_futures_instrument(
1041    definition: &OKXInstrument,
1042    margin_init: Option<Decimal>,
1043    margin_maint: Option<Decimal>,
1044    maker_fee: Option<Decimal>,
1045    taker_fee: Option<Decimal>,
1046    ts_init: UnixNanos,
1047) -> anyhow::Result<InstrumentAny> {
1048    let instrument_id = parse_instrument_id(definition.inst_id);
1049    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1050    let underlying = get_currency(&definition.uly);
1051    let (_, quote_currency) = definition
1052        .uly
1053        .split_once('-')
1054        .ok_or_else(|| anyhow::anyhow!("Invalid underlying for Swap: {}", definition.uly))?;
1055    let quote_currency = get_currency(quote_currency);
1056    let settlement_currency = get_currency(&definition.settle_ccy);
1057    let is_inverse = match definition.ct_type {
1058        OKXContractType::Linear => false,
1059        OKXContractType::Inverse => true,
1060        OKXContractType::None => {
1061            anyhow::bail!("Invalid contract type for futures: {}", definition.ct_type)
1062        }
1063    };
1064    let listing_time = definition
1065        .list_time
1066        .ok_or_else(|| anyhow::anyhow!("`listing_time` is required to parse Swap instrument"))?;
1067    let expiry_time = definition
1068        .exp_time
1069        .ok_or_else(|| anyhow::anyhow!("`expiry_time` is required to parse Swap instrument"))?;
1070    let activation_ns = UnixNanos::from(millis_to_nanos(listing_time as f64));
1071    let expiration_ns = UnixNanos::from(millis_to_nanos(expiry_time as f64));
1072    let price_increment = Price::from(definition.tick_sz.to_string());
1073    let size_increment = Quantity::from(&definition.lot_sz);
1074    let multiplier = Some(Quantity::from(&definition.ct_mult));
1075    let lot_size = Some(Quantity::from(&definition.lot_sz));
1076    let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1077    let min_quantity = Some(Quantity::from(&definition.min_sz));
1078    let max_notional: Option<Money> = None;
1079    let min_notional: Option<Money> = None;
1080    let max_price = None; // TBD
1081    let min_price = None; // TBD
1082
1083    let instrument = CryptoFuture::new(
1084        instrument_id,
1085        raw_symbol,
1086        underlying,
1087        quote_currency,
1088        settlement_currency,
1089        is_inverse,
1090        activation_ns,
1091        expiration_ns,
1092        price_increment.precision,
1093        size_increment.precision,
1094        price_increment,
1095        size_increment,
1096        multiplier,
1097        lot_size,
1098        max_quantity,
1099        min_quantity,
1100        max_notional,
1101        min_notional,
1102        max_price,
1103        min_price,
1104        margin_init,
1105        margin_maint,
1106        maker_fee,
1107        taker_fee,
1108        ts_init, // No ts_event for response
1109        ts_init,
1110    );
1111
1112    Ok(InstrumentAny::CryptoFuture(instrument))
1113}
1114
1115/// Parses an OKX option instrument definition into a Nautilus option contract.
1116pub fn parse_option_instrument(
1117    definition: &OKXInstrument,
1118    margin_init: Option<Decimal>,
1119    margin_maint: Option<Decimal>,
1120    maker_fee: Option<Decimal>,
1121    taker_fee: Option<Decimal>,
1122    ts_init: UnixNanos,
1123) -> anyhow::Result<InstrumentAny> {
1124    let instrument_id = parse_instrument_id(definition.inst_id);
1125    let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1126    let asset_class = AssetClass::Cryptocurrency;
1127    let exchange = Some(Ustr::from("OKX"));
1128    let underlying = Ustr::from(&definition.uly);
1129    let option_kind: OptionKind = definition.opt_type.into();
1130    let strike_price = Price::from(&definition.stk);
1131    let currency = definition
1132        .uly
1133        .split_once('-')
1134        .map(|(_, quote_ccy)| get_currency(quote_ccy))
1135        .ok_or_else(|| {
1136            anyhow::anyhow!(
1137                "Invalid underlying for Option instrument: {}",
1138                definition.uly
1139            )
1140        })?;
1141    let listing_time = definition
1142        .list_time
1143        .ok_or_else(|| anyhow::anyhow!("`listing_time` is required to parse Option instrument"))?;
1144    let expiry_time = definition
1145        .exp_time
1146        .ok_or_else(|| anyhow::anyhow!("`expiry_time` is required to parse Option instrument"))?;
1147    let activation_ns = UnixNanos::from(millis_to_nanos(listing_time as f64));
1148    let expiration_ns = UnixNanos::from(millis_to_nanos(expiry_time as f64));
1149    let price_increment = Price::from(definition.tick_sz.to_string());
1150    let multiplier = Quantity::from(&definition.ct_mult);
1151    let lot_size = Quantity::from(&definition.lot_sz);
1152    let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1153    let min_quantity = Some(Quantity::from(&definition.min_sz));
1154    let max_price = None; // TBD
1155    let min_price = None; // TBD
1156
1157    let instrument = OptionContract::new(
1158        instrument_id,
1159        raw_symbol,
1160        asset_class,
1161        exchange,
1162        underlying,
1163        option_kind,
1164        strike_price,
1165        currency,
1166        activation_ns,
1167        expiration_ns,
1168        price_increment.precision,
1169        price_increment,
1170        multiplier,
1171        lot_size,
1172        max_quantity,
1173        min_quantity,
1174        max_price,
1175        min_price,
1176        margin_init,
1177        margin_maint,
1178        maker_fee,
1179        taker_fee,
1180        ts_init, // No ts_event for response
1181        ts_init,
1182    );
1183
1184    Ok(InstrumentAny::OptionContract(instrument))
1185}
1186
1187/// Parses an OKX account into a Nautilus account state.
1188pub fn parse_account_state(
1189    okx_account: &OKXAccount,
1190    account_id: AccountId,
1191    ts_init: UnixNanos,
1192) -> anyhow::Result<AccountState> {
1193    let mut balances = Vec::new();
1194    for b in &okx_account.details {
1195        let currency = Currency::from(b.ccy);
1196        let total = Money::new(b.cash_bal.parse::<f64>()?, currency);
1197        let free = Money::new(b.avail_bal.parse::<f64>()?, currency);
1198        let locked = total - free;
1199        let balance = AccountBalance::new(total, locked, free);
1200        balances.push(balance);
1201    }
1202    let margins = vec![]; // TBD
1203
1204    let account_type = AccountType::Margin;
1205    let is_reported = true;
1206    let event_id = UUID4::new();
1207    let ts_event = UnixNanos::from(millis_to_nanos(okx_account.u_time as f64));
1208
1209    Ok(AccountState::new(
1210        account_id,
1211        account_type,
1212        balances,
1213        margins,
1214        is_reported,
1215        event_id,
1216        ts_event,
1217        ts_init,
1218        None,
1219    ))
1220}
1221
1222////////////////////////////////////////////////////////////////////////////////
1223// Tests
1224////////////////////////////////////////////////////////////////////////////////
1225
1226#[cfg(test)]
1227mod tests {
1228    use nautilus_model::{
1229        enums::AggregationSource, identifiers::InstrumentId, instruments::Instrument,
1230    };
1231    use rstest::rstest;
1232
1233    use super::*;
1234    use crate::{common::testing::load_test_json, http::client::OKXResponse};
1235
1236    #[rstest]
1237    fn test_parse_spot_instrument() {
1238        let json_data = load_test_json("http_get_instruments_spot.json");
1239        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1240        let okx_inst: &OKXInstrument = response
1241            .data
1242            .first()
1243            .expect("Test data must have an instrument");
1244
1245        let instrument =
1246            parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
1247
1248        assert_eq!(instrument.id(), InstrumentId::from("BTC-USD.OKX"));
1249        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD"));
1250        assert_eq!(instrument.underlying(), None);
1251        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
1252        assert_eq!(instrument.quote_currency(), Currency::USD());
1253        assert_eq!(instrument.price_precision(), 1);
1254        assert_eq!(instrument.size_precision(), 8);
1255        assert_eq!(instrument.price_increment(), Price::from("0.1"));
1256        assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
1257    }
1258
1259    #[rstest]
1260    fn test_parse_margin_instrument() {
1261        let json_data = load_test_json("http_get_instruments_margin.json");
1262        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1263        let okx_inst: &OKXInstrument = response
1264            .data
1265            .first()
1266            .expect("Test data must have an instrument");
1267
1268        let instrument =
1269            parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
1270
1271        assert_eq!(instrument.id(), InstrumentId::from("BTC-USDT.OKX"));
1272        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USDT"));
1273        assert_eq!(instrument.underlying(), None);
1274        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
1275        assert_eq!(instrument.quote_currency(), Currency::USDT());
1276        assert_eq!(instrument.price_precision(), 1);
1277        assert_eq!(instrument.size_precision(), 8);
1278        assert_eq!(instrument.price_increment(), Price::from("0.1"));
1279        assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
1280    }
1281
1282    #[rstest]
1283    fn test_parse_swap_instrument() {
1284        let json_data = load_test_json("http_get_instruments_swap.json");
1285        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1286        let okx_inst: &OKXInstrument = response
1287            .data
1288            .first()
1289            .expect("Test data must have an instrument");
1290
1291        let instrument =
1292            parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
1293
1294        assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-SWAP.OKX"));
1295        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-SWAP"));
1296        assert_eq!(instrument.underlying(), None);
1297        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
1298        assert_eq!(instrument.quote_currency(), Currency::USD());
1299        assert_eq!(instrument.price_precision(), 1);
1300        assert_eq!(instrument.size_precision(), 0);
1301        assert_eq!(instrument.price_increment(), Price::from("0.1"));
1302        assert_eq!(instrument.size_increment(), Quantity::from(1));
1303    }
1304
1305    #[rstest]
1306    fn test_parse_futures_instrument() {
1307        let json_data = load_test_json("http_get_instruments_futures.json");
1308        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1309        let okx_inst: &OKXInstrument = response
1310            .data
1311            .first()
1312            .expect("Test data must have an instrument");
1313
1314        let instrument =
1315            parse_futures_instrument(okx_inst, None, None, None, None, UnixNanos::default())
1316                .unwrap();
1317
1318        assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-241220.OKX"));
1319        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-241220"));
1320        assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
1321        assert_eq!(instrument.quote_currency(), Currency::USD());
1322        assert_eq!(instrument.price_precision(), 1);
1323        assert_eq!(instrument.size_precision(), 0);
1324        assert_eq!(instrument.price_increment(), Price::from("0.1"));
1325        assert_eq!(instrument.size_increment(), Quantity::from(1));
1326    }
1327
1328    #[rstest]
1329    fn test_parse_option_instrument() {
1330        let json_data = load_test_json("http_get_instruments_option.json");
1331        let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1332        let okx_inst: &OKXInstrument = response
1333            .data
1334            .first()
1335            .expect("Test data must have an instrument");
1336
1337        let instrument =
1338            parse_option_instrument(okx_inst, None, None, None, None, UnixNanos::default())
1339                .unwrap();
1340
1341        assert_eq!(
1342            instrument.id(),
1343            InstrumentId::from("BTC-USD-241217-92000-C.OKX")
1344        );
1345        assert_eq!(
1346            instrument.raw_symbol(),
1347            Symbol::from("BTC-USD-241217-92000-C")
1348        );
1349        assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
1350        assert_eq!(instrument.quote_currency(), Currency::USD());
1351        assert_eq!(instrument.price_precision(), 4);
1352        assert_eq!(instrument.size_precision(), 0);
1353        assert_eq!(instrument.price_increment(), Price::from("0.0001"));
1354        assert_eq!(instrument.size_increment(), Quantity::from(1));
1355    }
1356
1357    #[rstest]
1358    fn test_parse_account_state() {
1359        let json_data = load_test_json("http_get_account_balance.json");
1360        let response: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
1361        let okx_account = response
1362            .data
1363            .first()
1364            .expect("Test data must have an account");
1365
1366        let account_id = AccountId::new("OKX-001");
1367        let account_state =
1368            parse_account_state(okx_account, account_id, UnixNanos::default()).unwrap();
1369
1370        assert_eq!(account_state.account_id, account_id);
1371        assert_eq!(account_state.account_type, AccountType::Margin);
1372        assert_eq!(account_state.balances.len(), 1);
1373        assert_eq!(account_state.margins.len(), 0); // TBD in implementation
1374        assert!(account_state.is_reported);
1375
1376        // Check the USDT balance details
1377        let usdt_balance = &account_state.balances[0];
1378        assert_eq!(
1379            usdt_balance.total,
1380            Money::new(94.42612990333333, Currency::USDT())
1381        );
1382        assert_eq!(
1383            usdt_balance.free,
1384            Money::new(94.42612990333333, Currency::USDT())
1385        );
1386        assert_eq!(usdt_balance.locked, Money::new(0.0, Currency::USDT()));
1387    }
1388
1389    #[rstest]
1390    fn test_parse_order_status_report() {
1391        let json_data = load_test_json("http_get_orders_history.json");
1392        let response: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
1393        let okx_order = response
1394            .data
1395            .first()
1396            .expect("Test data must have an order")
1397            .clone();
1398
1399        let account_id = AccountId::new("OKX-001");
1400        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
1401        let order_report = parse_order_status_report(
1402            &okx_order,
1403            account_id,
1404            instrument_id,
1405            2,
1406            8,
1407            UnixNanos::default(),
1408        );
1409
1410        assert_eq!(order_report.account_id, account_id);
1411        assert_eq!(order_report.instrument_id, instrument_id);
1412        assert_eq!(order_report.quantity, Quantity::from("0.03000000"));
1413        assert_eq!(order_report.filled_qty, Quantity::from("0.03000000"));
1414        assert_eq!(order_report.order_side, OrderSide::Buy);
1415        assert_eq!(order_report.order_type, OrderType::Market);
1416        assert_eq!(order_report.order_status, OrderStatus::Filled);
1417    }
1418
1419    #[rstest]
1420    fn test_parse_position_status_report() {
1421        let json_data = load_test_json("http_get_positions.json");
1422        let response: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
1423        let okx_position = response
1424            .data
1425            .first()
1426            .expect("Test data must have a position")
1427            .clone();
1428
1429        let account_id = AccountId::new("OKX-001");
1430        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1431        let position_report = parse_position_status_report(
1432            okx_position,
1433            account_id,
1434            instrument_id,
1435            8,
1436            UnixNanos::default(),
1437        );
1438
1439        assert_eq!(position_report.account_id, account_id);
1440        assert_eq!(position_report.instrument_id, instrument_id);
1441    }
1442
1443    #[rstest]
1444    fn test_parse_trade_tick() {
1445        let json_data = load_test_json("http_get_trades.json");
1446        let response: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
1447        let okx_trade = response.data.first().expect("Test data must have a trade");
1448
1449        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1450        let trade_tick =
1451            parse_trade_tick(okx_trade, instrument_id, 2, 8, UnixNanos::default()).unwrap();
1452
1453        assert_eq!(trade_tick.instrument_id, instrument_id);
1454        assert_eq!(trade_tick.price, Price::from("102537.90"));
1455        assert_eq!(trade_tick.size, Quantity::from("0.00013669"));
1456        assert_eq!(trade_tick.aggressor_side, AggressorSide::Seller);
1457        assert_eq!(trade_tick.trade_id, TradeId::new("734864333"));
1458    }
1459
1460    #[rstest]
1461    fn test_parse_mark_price_update() {
1462        let json_data = load_test_json("http_get_mark_price.json");
1463        let response: OKXResponse<crate::http::models::OKXMarkPrice> =
1464            serde_json::from_str(&json_data).unwrap();
1465        let okx_mark_price = response
1466            .data
1467            .first()
1468            .expect("Test data must have a mark price");
1469
1470        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
1471        let mark_price_update =
1472            parse_mark_price_update(okx_mark_price, instrument_id, 2, UnixNanos::default())
1473                .unwrap();
1474
1475        assert_eq!(mark_price_update.instrument_id, instrument_id);
1476        assert_eq!(mark_price_update.value, Price::from("84660.10"));
1477        assert_eq!(
1478            mark_price_update.ts_event,
1479            UnixNanos::from(1744590349506000000)
1480        );
1481    }
1482
1483    #[rstest]
1484    fn test_parse_index_price_update() {
1485        let json_data = load_test_json("http_get_index_price.json");
1486        let response: OKXResponse<crate::http::models::OKXIndexTicker> =
1487            serde_json::from_str(&json_data).unwrap();
1488        let okx_index_ticker = response
1489            .data
1490            .first()
1491            .expect("Test data must have an index ticker");
1492
1493        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1494        let index_price_update =
1495            parse_index_price_update(okx_index_ticker, instrument_id, 2, UnixNanos::default())
1496                .unwrap();
1497
1498        assert_eq!(index_price_update.instrument_id, instrument_id);
1499        assert_eq!(index_price_update.value, Price::from("103895.00"));
1500        assert_eq!(
1501            index_price_update.ts_event,
1502            UnixNanos::from(1746942707815000000)
1503        );
1504    }
1505
1506    #[rstest]
1507    fn test_parse_candlestick() {
1508        let json_data = load_test_json("http_get_candlesticks.json");
1509        let response: OKXResponse<crate::http::models::OKXCandlestick> =
1510            serde_json::from_str(&json_data).unwrap();
1511        let okx_candlestick = response
1512            .data
1513            .first()
1514            .expect("Test data must have a candlestick");
1515
1516        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1517        let bar_type = BarType::new(
1518            instrument_id,
1519            BAR_SPEC_1_DAY_LAST,
1520            AggregationSource::External,
1521        );
1522        let bar = parse_candlestick(okx_candlestick, bar_type, 2, 8, UnixNanos::default()).unwrap();
1523
1524        assert_eq!(bar.bar_type, bar_type);
1525        assert_eq!(bar.open, Price::from("33528.60"));
1526        assert_eq!(bar.high, Price::from("33870.00"));
1527        assert_eq!(bar.low, Price::from("33528.60"));
1528        assert_eq!(bar.close, Price::from("33783.90"));
1529        assert_eq!(bar.volume, Quantity::from("778.83800000"));
1530        assert_eq!(bar.ts_event, UnixNanos::from(1625097600000000000));
1531    }
1532
1533    #[rstest]
1534    fn test_parse_millisecond_timestamp() {
1535        let timestamp_ms = 1625097600000u64;
1536        let result = parse_millisecond_timestamp(timestamp_ms);
1537        assert_eq!(result, UnixNanos::from(1625097600000000000));
1538    }
1539
1540    #[rstest]
1541    fn test_parse_rfc3339_timestamp() {
1542        let timestamp_str = "2021-07-01T00:00:00.000Z";
1543        let result = parse_rfc3339_timestamp(timestamp_str).unwrap();
1544        assert_eq!(result, UnixNanos::from(1625097600000000000));
1545
1546        // Test with timezone
1547        let timestamp_str_tz = "2021-07-01T08:00:00.000+08:00";
1548        let result_tz = parse_rfc3339_timestamp(timestamp_str_tz).unwrap();
1549        assert_eq!(result_tz, UnixNanos::from(1625097600000000000));
1550
1551        // Test error case
1552        let invalid_timestamp = "invalid-timestamp";
1553        assert!(parse_rfc3339_timestamp(invalid_timestamp).is_err());
1554    }
1555
1556    #[rstest]
1557    fn test_parse_price() {
1558        let price_str = "42219.5";
1559        let precision = 2;
1560        let result = parse_price(price_str, precision).unwrap();
1561        assert_eq!(result, Price::from("42219.50"));
1562
1563        // Test error case
1564        let invalid_price = "invalid-price";
1565        assert!(parse_price(invalid_price, precision).is_err());
1566    }
1567
1568    #[rstest]
1569    fn test_parse_quantity() {
1570        let quantity_str = "0.12345678";
1571        let precision = 8;
1572        let result = parse_quantity(quantity_str, precision).unwrap();
1573        assert_eq!(result, Quantity::from("0.12345678"));
1574
1575        // Test error case
1576        let invalid_quantity = "invalid-quantity";
1577        assert!(parse_quantity(invalid_quantity, precision).is_err());
1578    }
1579
1580    #[rstest]
1581    fn test_parse_aggressor_side() {
1582        assert_eq!(
1583            parse_aggressor_side(&Some(OKXSide::Buy)),
1584            AggressorSide::Buyer
1585        );
1586        assert_eq!(
1587            parse_aggressor_side(&Some(OKXSide::Sell)),
1588            AggressorSide::Seller
1589        );
1590        assert_eq!(parse_aggressor_side(&None), AggressorSide::NoAggressor);
1591    }
1592
1593    #[rstest]
1594    fn test_parse_execution_type() {
1595        assert_eq!(
1596            parse_execution_type(&Some(OKXExecType::Maker)),
1597            LiquiditySide::Maker
1598        );
1599        assert_eq!(
1600            parse_execution_type(&Some(OKXExecType::Taker)),
1601            LiquiditySide::Taker
1602        );
1603        assert_eq!(parse_execution_type(&None), LiquiditySide::NoLiquiditySide);
1604    }
1605
1606    #[rstest]
1607    fn test_parse_position_side() {
1608        assert_eq!(parse_position_side(Some(100)), PositionSide::Long);
1609        assert_eq!(parse_position_side(Some(-100)), PositionSide::Short);
1610        assert_eq!(parse_position_side(Some(0)), PositionSide::Flat);
1611        assert_eq!(parse_position_side(None), PositionSide::Flat);
1612    }
1613
1614    #[rstest]
1615    fn test_parse_client_order_id() {
1616        let valid_id = "client_order_123";
1617        let result = parse_client_order_id(valid_id);
1618        assert_eq!(result, Some(ClientOrderId::new(valid_id)));
1619
1620        let empty_id = "";
1621        let result_empty = parse_client_order_id(empty_id);
1622        assert_eq!(result_empty, None);
1623    }
1624
1625    #[rstest]
1626    fn test_deserialize_empty_string_as_none() {
1627        let json_with_empty = r#""""#;
1628        let result: Option<String> = serde_json::from_str(json_with_empty).unwrap();
1629        let processed = result.filter(|s| !s.is_empty());
1630        assert_eq!(processed, None);
1631
1632        let json_with_value = r#""test_value""#;
1633        let result: Option<String> = serde_json::from_str(json_with_value).unwrap();
1634        let processed = result.filter(|s| !s.is_empty());
1635        assert_eq!(processed, Some("test_value".to_string()));
1636    }
1637
1638    #[rstest]
1639    fn test_deserialize_string_to_u64() {
1640        use serde::Deserialize;
1641
1642        #[derive(Deserialize)]
1643        struct TestStruct {
1644            #[serde(deserialize_with = "deserialize_string_to_u64")]
1645            value: u64,
1646        }
1647
1648        let json_value = r#"{"value": "12345"}"#;
1649        let result: TestStruct = serde_json::from_str(json_value).unwrap();
1650        assert_eq!(result.value, 12345);
1651
1652        let json_empty = r#"{"value": ""}"#;
1653        let result_empty: TestStruct = serde_json::from_str(json_empty).unwrap();
1654        assert_eq!(result_empty.value, 0);
1655    }
1656
1657    #[rstest]
1658    fn test_fill_report_parsing() {
1659        // Create a mock transaction detail for testing
1660        let transaction_detail = crate::http::models::OKXTransactionDetail {
1661            inst_type: OKXInstrumentType::Spot,
1662            inst_id: Ustr::from("BTC-USDT"),
1663            trade_id: Ustr::from("12345"),
1664            ord_id: Ustr::from("67890"),
1665            cl_ord_id: Ustr::from("client_123"),
1666            bill_id: Ustr::from("bill_456"),
1667            fill_px: "42219.5".to_string(),
1668            fill_sz: "0.001".to_string(),
1669            side: OKXSide::Buy,
1670            exec_type: OKXExecType::Taker,
1671            fee_ccy: "USDT".to_string(),
1672            fee: Some("0.042".to_string()),
1673            ts: 1625097600000,
1674        };
1675
1676        let account_id = AccountId::new("OKX-001");
1677        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1678        let fill_report = parse_fill_report(
1679            transaction_detail,
1680            account_id,
1681            instrument_id,
1682            2,
1683            8,
1684            UnixNanos::default(),
1685        )
1686        .unwrap();
1687
1688        assert_eq!(fill_report.account_id, account_id);
1689        assert_eq!(fill_report.instrument_id, instrument_id);
1690        assert_eq!(fill_report.trade_id, TradeId::new("12345"));
1691        assert_eq!(fill_report.venue_order_id, VenueOrderId::new("67890"));
1692        assert_eq!(fill_report.order_side, OrderSide::Buy);
1693        assert_eq!(fill_report.last_px, Price::from("42219.50"));
1694        assert_eq!(fill_report.last_qty, Quantity::from("0.00100000"));
1695        assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
1696    }
1697
1698    #[rstest]
1699    fn test_bar_type_identity_preserved_through_parse() {
1700        use std::str::FromStr;
1701
1702        use crate::http::models::OKXCandlestick;
1703
1704        // Create a BarType
1705        let bar_type = BarType::from_str("ETH-USDT-SWAP.OKX-1-MINUTE-LAST-EXTERNAL").unwrap();
1706
1707        // Create sample candlestick data
1708        let raw_candlestick = OKXCandlestick(
1709            "1721807460000".to_string(), // timestamp
1710            "3177.9".to_string(),        // open
1711            "3177.9".to_string(),        // high
1712            "3177.7".to_string(),        // low
1713            "3177.8".to_string(),        // close
1714            "18.603".to_string(),        // volume
1715            "59054.8231".to_string(),    // turnover
1716            "18.603".to_string(),        // base_volume
1717            "1".to_string(),             // count
1718        );
1719
1720        // Parse the candlestick
1721        let bar =
1722            parse_candlestick(&raw_candlestick, bar_type, 1, 3, UnixNanos::default()).unwrap();
1723
1724        // Verify that the BarType is preserved exactly
1725        assert_eq!(
1726            bar.bar_type, bar_type,
1727            "BarType must be preserved exactly through parsing"
1728        );
1729    }
1730}