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