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