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