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