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