1use std::str::FromStr;
19
20pub use nautilus_core::serialization::{
21 deserialize_empty_string_as_none, deserialize_empty_ustr_as_none,
22 deserialize_optional_string_to_u64, deserialize_string_to_u64,
23};
24use nautilus_core::{
25 UUID4,
26 datetime::{NANOSECONDS_IN_MILLISECOND, millis_to_nanos_unchecked},
27 nanos::UnixNanos,
28};
29use nautilus_model::{
30 data::{
31 Bar, BarSpecification, BarType, Data, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
32 TradeTick,
33 bar::{
34 BAR_SPEC_1_DAY_LAST, BAR_SPEC_1_HOUR_LAST, BAR_SPEC_1_MINUTE_LAST,
35 BAR_SPEC_1_MONTH_LAST, BAR_SPEC_1_SECOND_LAST, BAR_SPEC_1_WEEK_LAST,
36 BAR_SPEC_2_DAY_LAST, BAR_SPEC_2_HOUR_LAST, BAR_SPEC_3_DAY_LAST, BAR_SPEC_3_MINUTE_LAST,
37 BAR_SPEC_3_MONTH_LAST, BAR_SPEC_4_HOUR_LAST, BAR_SPEC_5_DAY_LAST,
38 BAR_SPEC_5_MINUTE_LAST, BAR_SPEC_6_HOUR_LAST, BAR_SPEC_6_MONTH_LAST,
39 BAR_SPEC_12_HOUR_LAST, BAR_SPEC_12_MONTH_LAST, BAR_SPEC_15_MINUTE_LAST,
40 BAR_SPEC_30_MINUTE_LAST,
41 },
42 },
43 enums::{
44 AccountType, AggregationSource, AggressorSide, LiquiditySide, OptionKind, OrderSide,
45 OrderStatus, OrderType, PositionSide, TimeInForce,
46 },
47 events::AccountState,
48 identifiers::{
49 AccountId, ClientOrderId, InstrumentId, PositionId, Symbol, TradeId, Venue, VenueOrderId,
50 },
51 instruments::{CryptoFuture, CryptoOption, CryptoPerpetual, CurrencyPair, InstrumentAny},
52 reports::{FillReport, OrderStatusReport, PositionStatusReport},
53 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
54};
55use rust_decimal::{Decimal, prelude::ToPrimitive};
56use serde::{Deserialize, Deserializer, de::DeserializeOwned};
57use ustr::Ustr;
58
59use super::enums::OKXContractType;
60use crate::{
61 common::{
62 consts::OKX_VENUE,
63 enums::{
64 OKXExecType, OKXInstrumentType, OKXOrderStatus, OKXOrderType, OKXPositionSide, OKXSide,
65 OKXTargetCurrency, OKXVipLevel,
66 },
67 models::OKXInstrument,
68 },
69 http::models::{
70 OKXAccount, OKXBalanceDetail, OKXCandlestick, OKXIndexTicker, OKXMarkPrice,
71 OKXOrderHistory, OKXPosition, OKXTrade, OKXTransactionDetail,
72 },
73 websocket::{enums::OKXWsChannel, messages::OKXFundingRateMsg},
74};
75
76pub fn is_market_price(px: &str) -> bool {
84 px.is_empty() || px == "0" || px == "-1" || px == "-2"
85}
86
87pub fn determine_order_type(okx_ord_type: OKXOrderType, px: &str) -> OrderType {
92 match okx_ord_type {
93 OKXOrderType::Fok | OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => {
94 if is_market_price(px) {
95 OrderType::Market
96 } else {
97 OrderType::Limit
98 }
99 }
100 _ => okx_ord_type.into(),
101 }
102}
103
104pub fn deserialize_target_currency_as_none<'de, D>(
110 deserializer: D,
111) -> Result<Option<OKXTargetCurrency>, D::Error>
112where
113 D: Deserializer<'de>,
114{
115 let s = String::deserialize(deserializer)?;
116 if s.is_empty() {
117 Ok(None)
118 } else {
119 s.parse().map(Some).map_err(serde::de::Error::custom)
120 }
121}
122
123pub fn deserialize_vip_level<'de, D>(deserializer: D) -> Result<OKXVipLevel, D::Error>
137where
138 D: Deserializer<'de>,
139{
140 let s = String::deserialize(deserializer)?;
141
142 if s.is_empty() {
143 return Ok(OKXVipLevel::Vip0);
144 }
145
146 let level_str = if s.len() >= 3 && s[..3].eq_ignore_ascii_case("vip") {
147 &s[3..]
148 } else if s.len() >= 2 && s[..2].eq_ignore_ascii_case("lv") {
149 &s[2..]
150 } else {
151 &s
152 };
153
154 let level_num = level_str
155 .parse::<u8>()
156 .map_err(|e| serde::de::Error::custom(format!("Invalid VIP level '{s}': {e}")))?;
157
158 Ok(OKXVipLevel::from(level_num))
159}
160
161pub fn okx_instrument_type(instrument: &InstrumentAny) -> anyhow::Result<OKXInstrumentType> {
168 match instrument {
169 InstrumentAny::CurrencyPair(_) => Ok(OKXInstrumentType::Spot),
170 InstrumentAny::CryptoPerpetual(_) => Ok(OKXInstrumentType::Swap),
171 InstrumentAny::CryptoFuture(_) => Ok(OKXInstrumentType::Futures),
172 InstrumentAny::CryptoOption(_) => Ok(OKXInstrumentType::Option),
173 _ => anyhow::bail!("Invalid instrument type for OKX: {instrument:?}"),
174 }
175}
176
177pub fn okx_instrument_type_from_symbol(symbol: &str) -> OKXInstrumentType {
186 let dash_count = symbol.bytes().filter(|&b| b == b'-').count();
188
189 match dash_count {
190 1 => OKXInstrumentType::Spot, 2 => {
192 let suffix = symbol.rsplit('-').next().unwrap_or("");
194 if suffix == "SWAP" {
195 OKXInstrumentType::Swap
196 } else if suffix.len() == 6 && suffix.bytes().all(|b| b.is_ascii_digit()) {
197 OKXInstrumentType::Futures
199 } else {
200 OKXInstrumentType::Spot
201 }
202 }
203 4 => OKXInstrumentType::Option, _ => OKXInstrumentType::Spot, }
206}
207
208pub fn parse_base_quote_from_symbol(symbol: &str) -> anyhow::Result<(&str, &str)> {
216 let mut parts = symbol.split('-');
217 let base = parts.next().ok_or_else(|| {
218 anyhow::anyhow!("Invalid symbol format: missing base currency in '{symbol}'")
219 })?;
220 let quote = parts.next().ok_or_else(|| {
221 anyhow::anyhow!("Invalid symbol format: missing quote currency in '{symbol}'")
222 })?;
223 Ok((base, quote))
224}
225
226#[must_use]
228pub fn parse_instrument_id(symbol: Ustr) -> InstrumentId {
229 InstrumentId::new(Symbol::from_ustr_unchecked(symbol), *OKX_VENUE)
230}
231
232#[must_use]
234pub fn parse_client_order_id(value: &str) -> Option<ClientOrderId> {
235 if value.is_empty() {
236 None
237 } else {
238 Some(ClientOrderId::new(value))
239 }
240}
241
242#[must_use]
245pub fn parse_millisecond_timestamp(timestamp_ms: u64) -> UnixNanos {
246 UnixNanos::from(timestamp_ms * NANOSECONDS_IN_MILLISECOND)
247}
248
249pub fn parse_rfc3339_timestamp(timestamp: &str) -> anyhow::Result<UnixNanos> {
256 let dt = chrono::DateTime::parse_from_rfc3339(timestamp)?;
257 let nanos = dt.timestamp_nanos_opt().ok_or_else(|| {
258 anyhow::anyhow!("Failed to extract nanoseconds from timestamp: {timestamp}")
259 })?;
260 Ok(UnixNanos::from(nanos as u64))
261}
262
263pub fn parse_price(value: &str, precision: u8) -> anyhow::Result<Price> {
270 let decimal = Decimal::from_str(value)?;
271 Price::from_decimal_dp(decimal, precision)
272}
273
274pub fn parse_quantity(value: &str, precision: u8) -> anyhow::Result<Quantity> {
281 let decimal = Decimal::from_str(value)?;
282 Quantity::from_decimal_dp(decimal, precision)
283}
284
285pub fn parse_fee(value: Option<&str>, currency: Currency) -> anyhow::Result<Money> {
295 let decimal = Decimal::from_str(value.unwrap_or("0"))?;
297 Money::from_decimal(-decimal, currency)
298}
299
300pub fn parse_fee_currency(
305 fee_ccy: &str,
306 fee_amount: Decimal,
307 context: impl FnOnce() -> String,
308) -> Currency {
309 let trimmed = fee_ccy.trim();
310 if trimmed.is_empty() {
311 if !fee_amount.is_zero() {
312 let ctx = context();
313 log::warn!(
314 "Empty fee_ccy in {ctx} with non-zero fee={fee_amount}, using USDT as fallback"
315 );
316 }
317 return Currency::USDT();
318 }
319
320 Currency::get_or_create_crypto_with_context(trimmed, Some(&context()))
321}
322
323pub fn parse_aggressor_side(side: &Option<OKXSide>) -> AggressorSide {
325 match side {
326 Some(OKXSide::Buy) => AggressorSide::Buyer,
327 Some(OKXSide::Sell) => AggressorSide::Seller,
328 None => AggressorSide::NoAggressor,
329 }
330}
331
332pub fn parse_execution_type(liquidity: &Option<OKXExecType>) -> LiquiditySide {
334 match liquidity {
335 Some(OKXExecType::Maker) => LiquiditySide::Maker,
336 Some(OKXExecType::Taker) => LiquiditySide::Taker,
337 _ => LiquiditySide::NoLiquiditySide,
338 }
339}
340
341pub fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
343 match current_qty {
344 Some(qty) if qty > 0 => PositionSide::Long,
345 Some(qty) if qty < 0 => PositionSide::Short,
346 _ => PositionSide::Flat,
347 }
348}
349
350pub fn parse_mark_price_update(
357 raw: &OKXMarkPrice,
358 instrument_id: InstrumentId,
359 price_precision: u8,
360 ts_init: UnixNanos,
361) -> anyhow::Result<MarkPriceUpdate> {
362 let ts_event = parse_millisecond_timestamp(raw.ts);
363 let price = parse_price(&raw.mark_px, price_precision)?;
364 Ok(MarkPriceUpdate::new(
365 instrument_id,
366 price,
367 ts_event,
368 ts_init,
369 ))
370}
371
372pub fn parse_index_price_update(
379 raw: &OKXIndexTicker,
380 instrument_id: InstrumentId,
381 price_precision: u8,
382 ts_init: UnixNanos,
383) -> anyhow::Result<IndexPriceUpdate> {
384 let ts_event = parse_millisecond_timestamp(raw.ts);
385 let price = parse_price(&raw.idx_px, price_precision)?;
386 Ok(IndexPriceUpdate::new(
387 instrument_id,
388 price,
389 ts_event,
390 ts_init,
391 ))
392}
393
394pub fn parse_funding_rate_msg(
401 msg: &OKXFundingRateMsg,
402 instrument_id: InstrumentId,
403 ts_init: UnixNanos,
404) -> anyhow::Result<FundingRateUpdate> {
405 let funding_rate = msg
406 .funding_rate
407 .as_str()
408 .parse::<Decimal>()
409 .map_err(|e| anyhow::anyhow!("Invalid funding_rate value: {e}"))?
410 .normalize();
411
412 let funding_time = Some(parse_millisecond_timestamp(msg.funding_time));
413 let ts_event = parse_millisecond_timestamp(msg.ts);
414
415 Ok(FundingRateUpdate::new(
416 instrument_id,
417 funding_rate,
418 funding_time,
419 ts_event,
420 ts_init,
421 ))
422}
423
424pub fn parse_trade_tick(
431 raw: &OKXTrade,
432 instrument_id: InstrumentId,
433 price_precision: u8,
434 size_precision: u8,
435 ts_init: UnixNanos,
436) -> anyhow::Result<TradeTick> {
437 let ts_event = parse_millisecond_timestamp(raw.ts);
438 let price = parse_price(&raw.px, price_precision)?;
439 let size = parse_quantity(&raw.sz, size_precision)?;
440 let aggressor: AggressorSide = raw.side.into();
441 let trade_id = TradeId::new(raw.trade_id);
442
443 TradeTick::new_checked(
444 instrument_id,
445 price,
446 size,
447 aggressor,
448 trade_id,
449 ts_event,
450 ts_init,
451 )
452}
453
454pub fn parse_candlestick(
461 raw: &OKXCandlestick,
462 bar_type: BarType,
463 price_precision: u8,
464 size_precision: u8,
465 ts_init: UnixNanos,
466) -> anyhow::Result<Bar> {
467 let ts_event = parse_millisecond_timestamp(raw.0.parse()?);
468 let open = parse_price(&raw.1, price_precision)?;
469 let high = parse_price(&raw.2, price_precision)?;
470 let low = parse_price(&raw.3, price_precision)?;
471 let close = parse_price(&raw.4, price_precision)?;
472 let volume = parse_quantity(&raw.5, size_precision)?;
473
474 Ok(Bar::new(
475 bar_type, open, high, low, close, volume, ts_event, ts_init,
476 ))
477}
478
479#[allow(clippy::too_many_lines)]
485pub fn parse_order_status_report(
486 order: &OKXOrderHistory,
487 account_id: AccountId,
488 instrument_id: InstrumentId,
489 price_precision: u8,
490 size_precision: u8,
491 ts_init: UnixNanos,
492) -> anyhow::Result<OrderStatusReport> {
493 let okx_ord_type: OKXOrderType = order.ord_type;
494 let order_type = determine_order_type(okx_ord_type, &order.px);
495
496 let is_quote_qty_explicit = order.tgt_ccy == Some(OKXTargetCurrency::QuoteCcy);
502
503 let is_quote_qty_heuristic = order.tgt_ccy.is_none()
508 && (order.inst_type == OKXInstrumentType::Spot
509 || order.inst_type == OKXInstrumentType::Margin)
510 && order.side == OKXSide::Buy
511 && order_type == OrderType::Market;
512
513 let (quantity, filled_qty) = if is_quote_qty_explicit || is_quote_qty_heuristic {
514 let sz_quote_dec = Decimal::from_str(&order.sz).ok();
516
517 let conversion_price_dec = if !order.px.is_empty() && order.px != "0" {
520 Decimal::from_str(&order.px).ok()
522 } else if !order.avg_px.is_empty() && order.avg_px != "0" {
523 Decimal::from_str(&order.avg_px).ok()
525 } else {
526 log::warn!(
527 "No price available for conversion: ord_id={}, px='{}', avg_px='{}'",
528 order.ord_id.as_str(),
529 order.px,
530 order.avg_px
531 );
532 None
533 };
534
535 let quantity_base = if let (Some(sz), Some(price)) = (sz_quote_dec, conversion_price_dec) {
537 if price.is_zero() {
538 log::warn!(
539 "Cannot convert quote quantity with zero price: ord_id={}, sz={}, using sz as-is",
540 order.ord_id.as_str(),
541 order.sz
542 );
543 Quantity::from_str(&order.sz).map_err(|e| {
544 anyhow::anyhow!(
545 "Failed to parse fallback quantity for ord_id={}, sz='{}': {e}",
546 order.ord_id.as_str(),
547 order.sz
548 )
549 })?
550 } else {
551 let quantity_dec = sz / price;
552 Quantity::from_decimal_dp(quantity_dec, size_precision).map_err(|e| {
553 anyhow::anyhow!(
554 "Failed to convert quote-to-base quantity for ord_id={}, sz={sz}, price={price}, quantity_dec={quantity_dec}: {e}",
555 order.ord_id.as_str()
556 )
557 })?
558 }
559 } else {
560 log::warn!(
561 "Cannot convert quote quantity without price: ord_id={}, sz={}, px='{}', avg_px='{}', using sz as-is",
562 order.ord_id.as_str(),
563 order.sz,
564 order.px,
565 order.avg_px
566 );
567 Quantity::from_str(&order.sz).map_err(|e| {
568 anyhow::anyhow!(
569 "Failed to parse fallback quantity for ord_id={}, sz='{}': {e}",
570 order.ord_id.as_str(),
571 order.sz
572 )
573 })?
574 };
575
576 let filled_qty_dec = parse_quantity(&order.acc_fill_sz, size_precision).map_err(|e| {
577 anyhow::anyhow!(
578 "Failed to parse filled quantity for ord_id={}, acc_fill_sz='{}': {e}",
579 order.ord_id.as_str(),
580 order.acc_fill_sz
581 )
582 })?;
583
584 (quantity_base, filled_qty_dec)
585 } else {
586 let quantity_dec = parse_quantity(&order.sz, size_precision).map_err(|e| {
588 anyhow::anyhow!(
589 "Failed to parse base quantity for ord_id={}, sz='{}': {e}",
590 order.ord_id.as_str(),
591 order.sz
592 )
593 })?;
594 let filled_qty_dec = parse_quantity(&order.acc_fill_sz, size_precision).map_err(|e| {
595 anyhow::anyhow!(
596 "Failed to parse filled quantity for ord_id={}, acc_fill_sz='{}': {e}",
597 order.ord_id.as_str(),
598 order.acc_fill_sz
599 )
600 })?;
601
602 (quantity_dec, filled_qty_dec)
603 };
604
605 let (quantity, filled_qty) = if (is_quote_qty_explicit || is_quote_qty_heuristic)
608 && order.state == OKXOrderStatus::Filled
609 && filled_qty.is_positive()
610 {
611 (filled_qty, filled_qty)
612 } else {
613 (quantity, filled_qty)
614 };
615
616 let order_side: OrderSide = order.side.into();
617 let okx_status: OKXOrderStatus = order.state;
618 let order_status: OrderStatus = okx_status.into();
619 let time_in_force = match okx_ord_type {
620 OKXOrderType::Fok => TimeInForce::Fok,
621 OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
622 _ => TimeInForce::Gtc,
623 };
624
625 let mut client_order_id = if order.cl_ord_id.is_empty() {
626 None
627 } else {
628 Some(ClientOrderId::new(order.cl_ord_id.as_str()))
629 };
630
631 let mut linked_ids = Vec::new();
632
633 if let Some(algo_cl_ord_id) = order
634 .algo_cl_ord_id
635 .as_ref()
636 .filter(|value| !value.as_str().is_empty())
637 {
638 let algo_client_id = ClientOrderId::new(algo_cl_ord_id.as_str());
639 match &client_order_id {
640 Some(existing) if existing == &algo_client_id => {}
641 Some(_) => linked_ids.push(algo_client_id),
642 None => client_order_id = Some(algo_client_id),
643 }
644 }
645
646 let venue_order_id = if order.ord_id.is_empty() {
647 if let Some(algo_id) = order
648 .algo_id
649 .as_ref()
650 .filter(|value| !value.as_str().is_empty())
651 {
652 VenueOrderId::new(algo_id.as_str())
653 } else if !order.cl_ord_id.is_empty() {
654 VenueOrderId::new(order.cl_ord_id.as_str())
655 } else {
656 let synthetic_id = format!("{}:{}", account_id, order.c_time);
657 VenueOrderId::new(&synthetic_id)
658 }
659 } else {
660 VenueOrderId::new(order.ord_id.as_str())
661 };
662
663 let ts_accepted = parse_millisecond_timestamp(order.c_time);
664 let ts_last = UnixNanos::from(order.u_time * NANOSECONDS_IN_MILLISECOND);
665
666 let mut report = OrderStatusReport::new(
667 account_id,
668 instrument_id,
669 client_order_id,
670 venue_order_id,
671 order_side,
672 order_type,
673 time_in_force,
674 order_status,
675 quantity,
676 filled_qty,
677 ts_accepted,
678 ts_last,
679 ts_init,
680 None,
681 );
682
683 if !order.px.is_empty()
685 && let Ok(decimal) = Decimal::from_str(&order.px)
686 && let Ok(price) = Price::from_decimal_dp(decimal, price_precision)
687 {
688 report = report.with_price(price);
689 }
690
691 if !order.avg_px.is_empty()
692 && let Ok(decimal) = Decimal::from_str(&order.avg_px)
693 {
694 report = report.with_avg_px(decimal.to_f64().unwrap_or(0.0))?;
695 }
696
697 if order.ord_type == OKXOrderType::PostOnly {
698 report = report.with_post_only(true);
699 }
700
701 if order.reduce_only == "true" {
702 report = report.with_reduce_only(true);
703 }
704
705 if !linked_ids.is_empty() {
706 report = report.with_linked_order_ids(linked_ids);
707 }
708
709 Ok(report)
710}
711
712pub fn parse_spot_margin_position_from_balance(
728 balance: &OKXBalanceDetail,
729 account_id: AccountId,
730 instrument_id: InstrumentId,
731 size_precision: u8,
732 ts_init: UnixNanos,
733) -> anyhow::Result<Option<PositionStatusReport>> {
734 let liab_str = if balance.liab.trim().is_empty() {
736 "0"
737 } else {
738 balance.liab.trim()
739 };
740 let spot_in_use_str = if balance.spot_in_use_amt.trim().is_empty() {
741 "0"
742 } else {
743 balance.spot_in_use_amt.trim()
744 };
745
746 let liab_dec = Decimal::from_str(liab_str)
747 .map_err(|e| anyhow::anyhow!("Failed to parse liab '{liab_str}': {e}"))?;
748 let spot_in_use_dec = Decimal::from_str(spot_in_use_str)
749 .map_err(|e| anyhow::anyhow!("Failed to parse spotInUseAmt '{spot_in_use_str}': {e}"))?;
750
751 if liab_dec.is_zero() && spot_in_use_dec.is_zero() {
753 return Ok(None);
754 }
755
756 if spot_in_use_dec.is_zero() {
758 return Ok(None);
760 }
761
762 let (position_side, quantity_dec) = if spot_in_use_dec.is_sign_negative() {
764 (PositionSide::Short, spot_in_use_dec.abs())
766 } else {
767 (PositionSide::Long, spot_in_use_dec)
769 };
770
771 let quantity = Quantity::from_decimal_dp(quantity_dec, size_precision)
772 .map_err(|e| anyhow::anyhow!("Failed to create quantity from {quantity_dec}: {e}"))?;
773
774 let ts_last = parse_millisecond_timestamp(balance.u_time);
775
776 Ok(Some(PositionStatusReport::new(
777 account_id,
778 instrument_id,
779 position_side.as_specified(),
780 quantity,
781 ts_last,
782 ts_init,
783 None, None, None, )))
787}
788
789#[allow(clippy::too_many_lines)]
808pub fn parse_position_status_report(
809 position: OKXPosition,
810 account_id: AccountId,
811 instrument_id: InstrumentId,
812 size_precision: u8,
813 ts_init: UnixNanos,
814) -> anyhow::Result<PositionStatusReport> {
815 let pos_dec = Decimal::from_str(&position.pos).map_err(|e| {
816 anyhow::anyhow!(
817 "Failed to parse position quantity '{}' for instrument {}: {e:?}",
818 position.pos,
819 instrument_id
820 )
821 })?;
822
823 let (position_side, quantity_dec) = if position.inst_type == OKXInstrumentType::Spot
828 || position.inst_type == OKXInstrumentType::Margin
829 {
830 let (base_ccy, quote_ccy) = parse_base_quote_from_symbol(instrument_id.symbol.as_str())?;
832
833 let pos_ccy = position.pos_ccy.as_str();
834
835 if pos_ccy.is_empty() || pos_dec.is_zero() {
836 (PositionSide::Flat, Decimal::ZERO)
838 } else if pos_ccy == base_ccy {
839 (PositionSide::Long, pos_dec.abs())
841 } else if pos_ccy == quote_ccy {
842 let avg_px_str = if position.avg_px.is_empty() {
845 &position.mark_px
847 } else {
848 &position.avg_px
849 };
850 let avg_px_dec = Decimal::from_str(avg_px_str)?;
851
852 if avg_px_dec.is_zero() {
853 anyhow::bail!(
854 "Cannot convert SHORT position from quote to base: avg_px is zero for {instrument_id}"
855 );
856 }
857
858 let quantity_dec = pos_dec.abs() / avg_px_dec;
859 (PositionSide::Short, quantity_dec)
860 } else {
861 anyhow::bail!(
862 "Unknown position currency '{pos_ccy}' for instrument {instrument_id} (base={base_ccy}, quote={quote_ccy})"
863 );
864 }
865 } else {
866 let side = match position.pos_side {
871 OKXPositionSide::Net | OKXPositionSide::None => {
872 if pos_dec.is_sign_positive() && !pos_dec.is_zero() {
874 PositionSide::Long
875 } else if pos_dec.is_sign_negative() {
876 PositionSide::Short
877 } else {
878 PositionSide::Flat
879 }
880 }
881 OKXPositionSide::Long => {
882 PositionSide::Long
884 }
885 OKXPositionSide::Short => {
886 PositionSide::Short
888 }
889 };
890 (side, pos_dec.abs())
891 };
892
893 let position_side = position_side.as_specified();
894
895 let quantity = Quantity::from_decimal_dp(quantity_dec, size_precision)?;
897
898 let venue_position_id = match position.pos_side {
901 OKXPositionSide::Long => {
902 position
904 .pos_id
905 .map(|pos_id| PositionId::new(format!("{pos_id}-LONG")))
906 }
907 OKXPositionSide::Short => {
908 position
910 .pos_id
911 .map(|pos_id| PositionId::new(format!("{pos_id}-SHORT")))
912 }
913 OKXPositionSide::Net | OKXPositionSide::None => {
914 None
916 }
917 };
918
919 let avg_px_open = if position.avg_px.is_empty() {
920 None
921 } else {
922 Some(Decimal::from_str(&position.avg_px)?)
923 };
924 let ts_last = parse_millisecond_timestamp(position.u_time);
925
926 Ok(PositionStatusReport::new(
927 account_id,
928 instrument_id,
929 position_side,
930 quantity,
931 ts_last,
932 ts_init,
933 None, venue_position_id,
935 avg_px_open,
936 ))
937}
938
939pub fn parse_fill_report(
945 detail: OKXTransactionDetail,
946 account_id: AccountId,
947 instrument_id: InstrumentId,
948 price_precision: u8,
949 size_precision: u8,
950 ts_init: UnixNanos,
951) -> anyhow::Result<FillReport> {
952 let client_order_id = if detail.cl_ord_id.is_empty() {
953 None
954 } else {
955 Some(ClientOrderId::new(detail.cl_ord_id))
956 };
957 let venue_order_id = VenueOrderId::new(detail.ord_id);
958 let trade_id = TradeId::new(detail.trade_id);
959 let order_side: OrderSide = detail.side.into();
960 let last_px = parse_price(&detail.fill_px, price_precision)?;
961 let last_qty = parse_quantity(&detail.fill_sz, size_precision)?;
962 let fee_dec = Decimal::from_str(detail.fee.as_deref().unwrap_or("0"))?;
963 let fee_currency = parse_fee_currency(&detail.fee_ccy, fee_dec, || {
964 format!("fill report for instrument_id={instrument_id}")
965 });
966 let commission = Money::from_decimal(-fee_dec, fee_currency)?;
967 let liquidity_side: LiquiditySide = detail.exec_type.into();
968 let ts_event = parse_millisecond_timestamp(detail.ts);
969
970 Ok(FillReport::new(
971 account_id,
972 instrument_id,
973 venue_order_id,
974 trade_id,
975 order_side,
976 last_qty,
977 last_px,
978 commission,
979 liquidity_side,
980 client_order_id,
981 None, ts_event,
983 ts_init,
984 None, ))
986}
987
988pub fn parse_message_vec<T, R, F, W>(
998 data: serde_json::Value,
999 parser: F,
1000 wrapper: W,
1001) -> anyhow::Result<Vec<Data>>
1002where
1003 T: DeserializeOwned,
1004 F: Fn(&T) -> anyhow::Result<R>,
1005 W: Fn(R) -> Data,
1006{
1007 let messages: Vec<T> =
1008 serde_json::from_value(data).map_err(|e| anyhow::anyhow!("Expected array payload: {e}"))?;
1009
1010 let mut results = Vec::with_capacity(messages.len());
1011
1012 for message in &messages {
1013 let parsed = parser(message)?;
1014 results.push(wrapper(parsed));
1015 }
1016
1017 Ok(results)
1018}
1019
1020pub fn bar_spec_as_okx_channel(bar_spec: BarSpecification) -> anyhow::Result<OKXWsChannel> {
1027 let channel = match bar_spec {
1028 BAR_SPEC_1_SECOND_LAST => OKXWsChannel::Candle1Second,
1029 BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::Candle1Minute,
1030 BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::Candle3Minute,
1031 BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::Candle5Minute,
1032 BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::Candle15Minute,
1033 BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::Candle30Minute,
1034 BAR_SPEC_1_HOUR_LAST => OKXWsChannel::Candle1Hour,
1035 BAR_SPEC_2_HOUR_LAST => OKXWsChannel::Candle2Hour,
1036 BAR_SPEC_4_HOUR_LAST => OKXWsChannel::Candle4Hour,
1037 BAR_SPEC_6_HOUR_LAST => OKXWsChannel::Candle6Hour,
1038 BAR_SPEC_12_HOUR_LAST => OKXWsChannel::Candle12Hour,
1039 BAR_SPEC_1_DAY_LAST => OKXWsChannel::Candle1Day,
1040 BAR_SPEC_2_DAY_LAST => OKXWsChannel::Candle2Day,
1041 BAR_SPEC_3_DAY_LAST => OKXWsChannel::Candle3Day,
1042 BAR_SPEC_5_DAY_LAST => OKXWsChannel::Candle5Day,
1043 BAR_SPEC_1_WEEK_LAST => OKXWsChannel::Candle1Week,
1044 BAR_SPEC_1_MONTH_LAST => OKXWsChannel::Candle1Month,
1045 BAR_SPEC_3_MONTH_LAST => OKXWsChannel::Candle3Month,
1046 BAR_SPEC_6_MONTH_LAST => OKXWsChannel::Candle6Month,
1047 BAR_SPEC_12_MONTH_LAST => OKXWsChannel::Candle1Year,
1048 _ => anyhow::bail!("Invalid `BarSpecification` for channel, was {bar_spec}"),
1049 };
1050 Ok(channel)
1051}
1052
1053pub fn bar_spec_as_okx_mark_price_channel(
1060 bar_spec: BarSpecification,
1061) -> anyhow::Result<OKXWsChannel> {
1062 let channel = match bar_spec {
1063 BAR_SPEC_1_SECOND_LAST => OKXWsChannel::MarkPriceCandle1Second,
1064 BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::MarkPriceCandle1Minute,
1065 BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::MarkPriceCandle3Minute,
1066 BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::MarkPriceCandle5Minute,
1067 BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::MarkPriceCandle15Minute,
1068 BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::MarkPriceCandle30Minute,
1069 BAR_SPEC_1_HOUR_LAST => OKXWsChannel::MarkPriceCandle1Hour,
1070 BAR_SPEC_2_HOUR_LAST => OKXWsChannel::MarkPriceCandle2Hour,
1071 BAR_SPEC_4_HOUR_LAST => OKXWsChannel::MarkPriceCandle4Hour,
1072 BAR_SPEC_6_HOUR_LAST => OKXWsChannel::MarkPriceCandle6Hour,
1073 BAR_SPEC_12_HOUR_LAST => OKXWsChannel::MarkPriceCandle12Hour,
1074 BAR_SPEC_1_DAY_LAST => OKXWsChannel::MarkPriceCandle1Day,
1075 BAR_SPEC_2_DAY_LAST => OKXWsChannel::MarkPriceCandle2Day,
1076 BAR_SPEC_3_DAY_LAST => OKXWsChannel::MarkPriceCandle3Day,
1077 BAR_SPEC_5_DAY_LAST => OKXWsChannel::MarkPriceCandle5Day,
1078 BAR_SPEC_1_WEEK_LAST => OKXWsChannel::MarkPriceCandle1Week,
1079 BAR_SPEC_1_MONTH_LAST => OKXWsChannel::MarkPriceCandle1Month,
1080 BAR_SPEC_3_MONTH_LAST => OKXWsChannel::MarkPriceCandle3Month,
1081 _ => anyhow::bail!("Invalid `BarSpecification` for mark price channel, was {bar_spec}"),
1082 };
1083 Ok(channel)
1084}
1085
1086pub fn bar_spec_as_okx_timeframe(bar_spec: BarSpecification) -> anyhow::Result<&'static str> {
1093 let timeframe = match bar_spec {
1094 BAR_SPEC_1_SECOND_LAST => "1s",
1095 BAR_SPEC_1_MINUTE_LAST => "1m",
1096 BAR_SPEC_3_MINUTE_LAST => "3m",
1097 BAR_SPEC_5_MINUTE_LAST => "5m",
1098 BAR_SPEC_15_MINUTE_LAST => "15m",
1099 BAR_SPEC_30_MINUTE_LAST => "30m",
1100 BAR_SPEC_1_HOUR_LAST => "1H",
1101 BAR_SPEC_2_HOUR_LAST => "2H",
1102 BAR_SPEC_4_HOUR_LAST => "4H",
1103 BAR_SPEC_6_HOUR_LAST => "6H",
1104 BAR_SPEC_12_HOUR_LAST => "12H",
1105 BAR_SPEC_1_DAY_LAST => "1D",
1106 BAR_SPEC_2_DAY_LAST => "2D",
1107 BAR_SPEC_3_DAY_LAST => "3D",
1108 BAR_SPEC_5_DAY_LAST => "5D",
1109 BAR_SPEC_1_WEEK_LAST => "1W",
1110 BAR_SPEC_1_MONTH_LAST => "1M",
1111 BAR_SPEC_3_MONTH_LAST => "3M",
1112 BAR_SPEC_6_MONTH_LAST => "6M",
1113 BAR_SPEC_12_MONTH_LAST => "1Y",
1114 _ => anyhow::bail!("Invalid `BarSpecification` for timeframe, was {bar_spec}"),
1115 };
1116 Ok(timeframe)
1117}
1118
1119pub fn okx_timeframe_as_bar_spec(timeframe: &str) -> anyhow::Result<BarSpecification> {
1125 let bar_spec = match timeframe {
1126 "1s" => BAR_SPEC_1_SECOND_LAST,
1127 "1m" => BAR_SPEC_1_MINUTE_LAST,
1128 "3m" => BAR_SPEC_3_MINUTE_LAST,
1129 "5m" => BAR_SPEC_5_MINUTE_LAST,
1130 "15m" => BAR_SPEC_15_MINUTE_LAST,
1131 "30m" => BAR_SPEC_30_MINUTE_LAST,
1132 "1H" => BAR_SPEC_1_HOUR_LAST,
1133 "2H" => BAR_SPEC_2_HOUR_LAST,
1134 "4H" => BAR_SPEC_4_HOUR_LAST,
1135 "6H" => BAR_SPEC_6_HOUR_LAST,
1136 "12H" => BAR_SPEC_12_HOUR_LAST,
1137 "1D" => BAR_SPEC_1_DAY_LAST,
1138 "2D" => BAR_SPEC_2_DAY_LAST,
1139 "3D" => BAR_SPEC_3_DAY_LAST,
1140 "5D" => BAR_SPEC_5_DAY_LAST,
1141 "1W" => BAR_SPEC_1_WEEK_LAST,
1142 "1M" => BAR_SPEC_1_MONTH_LAST,
1143 "3M" => BAR_SPEC_3_MONTH_LAST,
1144 "6M" => BAR_SPEC_6_MONTH_LAST,
1145 "1Y" => BAR_SPEC_12_MONTH_LAST,
1146 _ => anyhow::bail!("Invalid timeframe for `BarSpecification`, was {timeframe}"),
1147 };
1148 Ok(bar_spec)
1149}
1150
1151pub fn okx_bar_type_from_timeframe(
1159 instrument_id: InstrumentId,
1160 timeframe: &str,
1161) -> anyhow::Result<BarType> {
1162 let bar_spec = okx_timeframe_as_bar_spec(timeframe)?;
1163 Ok(BarType::new(
1164 instrument_id,
1165 bar_spec,
1166 AggregationSource::External,
1167 ))
1168}
1169
1170pub fn okx_channel_to_bar_spec(channel: &OKXWsChannel) -> Option<BarSpecification> {
1172 use OKXWsChannel::*;
1173 match channel {
1174 Candle1Second | MarkPriceCandle1Second => Some(BAR_SPEC_1_SECOND_LAST),
1175 Candle1Minute | MarkPriceCandle1Minute => Some(BAR_SPEC_1_MINUTE_LAST),
1176 Candle3Minute | MarkPriceCandle3Minute => Some(BAR_SPEC_3_MINUTE_LAST),
1177 Candle5Minute | MarkPriceCandle5Minute => Some(BAR_SPEC_5_MINUTE_LAST),
1178 Candle15Minute | MarkPriceCandle15Minute => Some(BAR_SPEC_15_MINUTE_LAST),
1179 Candle30Minute | MarkPriceCandle30Minute => Some(BAR_SPEC_30_MINUTE_LAST),
1180 Candle1Hour | MarkPriceCandle1Hour => Some(BAR_SPEC_1_HOUR_LAST),
1181 Candle2Hour | MarkPriceCandle2Hour => Some(BAR_SPEC_2_HOUR_LAST),
1182 Candle4Hour | MarkPriceCandle4Hour => Some(BAR_SPEC_4_HOUR_LAST),
1183 Candle6Hour | MarkPriceCandle6Hour => Some(BAR_SPEC_6_HOUR_LAST),
1184 Candle12Hour | MarkPriceCandle12Hour => Some(BAR_SPEC_12_HOUR_LAST),
1185 Candle1Day | MarkPriceCandle1Day => Some(BAR_SPEC_1_DAY_LAST),
1186 Candle2Day | MarkPriceCandle2Day => Some(BAR_SPEC_2_DAY_LAST),
1187 Candle3Day | MarkPriceCandle3Day => Some(BAR_SPEC_3_DAY_LAST),
1188 Candle5Day | MarkPriceCandle5Day => Some(BAR_SPEC_5_DAY_LAST),
1189 Candle1Week | MarkPriceCandle1Week => Some(BAR_SPEC_1_WEEK_LAST),
1190 Candle1Month | MarkPriceCandle1Month => Some(BAR_SPEC_1_MONTH_LAST),
1191 Candle3Month | MarkPriceCandle3Month => Some(BAR_SPEC_3_MONTH_LAST),
1192 Candle6Month => Some(BAR_SPEC_6_MONTH_LAST),
1193 Candle1Year => Some(BAR_SPEC_12_MONTH_LAST),
1194 _ => None,
1195 }
1196}
1197
1198pub fn parse_instrument_any(
1204 instrument: &OKXInstrument,
1205 margin_init: Option<Decimal>,
1206 margin_maint: Option<Decimal>,
1207 maker_fee: Option<Decimal>,
1208 taker_fee: Option<Decimal>,
1209 ts_init: UnixNanos,
1210) -> anyhow::Result<Option<InstrumentAny>> {
1211 match instrument.inst_type {
1212 OKXInstrumentType::Spot => parse_spot_instrument(
1213 instrument,
1214 margin_init,
1215 margin_maint,
1216 maker_fee,
1217 taker_fee,
1218 ts_init,
1219 )
1220 .map(Some),
1221 OKXInstrumentType::Margin => parse_spot_instrument(
1222 instrument,
1223 margin_init,
1224 margin_maint,
1225 maker_fee,
1226 taker_fee,
1227 ts_init,
1228 )
1229 .map(Some),
1230 OKXInstrumentType::Swap => parse_swap_instrument(
1231 instrument,
1232 margin_init,
1233 margin_maint,
1234 maker_fee,
1235 taker_fee,
1236 ts_init,
1237 )
1238 .map(Some),
1239 OKXInstrumentType::Futures => parse_futures_instrument(
1240 instrument,
1241 margin_init,
1242 margin_maint,
1243 maker_fee,
1244 taker_fee,
1245 ts_init,
1246 )
1247 .map(Some),
1248 OKXInstrumentType::Option => parse_option_instrument(
1249 instrument,
1250 margin_init,
1251 margin_maint,
1252 maker_fee,
1253 taker_fee,
1254 ts_init,
1255 )
1256 .map(Some),
1257 _ => Ok(None),
1258 }
1259}
1260
1261#[derive(Debug)]
1263struct CommonInstrumentData {
1264 instrument_id: InstrumentId,
1265 raw_symbol: Symbol,
1266 price_increment: Price,
1267 size_increment: Quantity,
1268 lot_size: Option<Quantity>,
1269 max_quantity: Option<Quantity>,
1270 min_quantity: Option<Quantity>,
1271 max_notional: Option<Money>,
1272 min_notional: Option<Money>,
1273 max_price: Option<Price>,
1274 min_price: Option<Price>,
1275}
1276
1277struct MarginAndFees {
1279 margin_init: Option<Decimal>,
1280 margin_maint: Option<Decimal>,
1281 maker_fee: Option<Decimal>,
1282 taker_fee: Option<Decimal>,
1283}
1284
1285fn parse_multiplier_product(definition: &OKXInstrument) -> anyhow::Result<Option<Quantity>> {
1290 if definition.ct_mult.is_empty() && definition.ct_val.is_empty() {
1291 return Ok(None);
1292 }
1293
1294 let mult_value = if definition.ct_mult.is_empty() {
1295 Decimal::ONE
1296 } else {
1297 Decimal::from_str(&definition.ct_mult).map_err(|e| {
1298 anyhow::anyhow!(
1299 "Failed to parse `ct_mult` '{}' for {}: {e}",
1300 definition.ct_mult,
1301 definition.inst_id
1302 )
1303 })?
1304 };
1305
1306 let val_value = if definition.ct_val.is_empty() {
1307 Decimal::ONE
1308 } else {
1309 Decimal::from_str(&definition.ct_val).map_err(|e| {
1310 anyhow::anyhow!(
1311 "Failed to parse `ct_val` '{}' for {}: {e}",
1312 definition.ct_val,
1313 definition.inst_id
1314 )
1315 })?
1316 };
1317
1318 let product = mult_value * val_value;
1319 Ok(Some(Quantity::from(product.to_string())))
1320}
1321
1322trait InstrumentParser {
1324 fn parse_specific_fields(
1326 &self,
1327 definition: &OKXInstrument,
1328 common: CommonInstrumentData,
1329 margin_fees: MarginAndFees,
1330 ts_init: UnixNanos,
1331 ) -> anyhow::Result<InstrumentAny>;
1332}
1333
1334fn parse_common_instrument_data(
1336 definition: &OKXInstrument,
1337) -> anyhow::Result<CommonInstrumentData> {
1338 let instrument_id = parse_instrument_id(definition.inst_id);
1339 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1340
1341 if definition.tick_sz.is_empty() {
1342 anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1343 }
1344
1345 let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
1346 anyhow::anyhow!(
1347 "Failed to parse `tick_sz` '{}' into Price for {}: {e}",
1348 definition.tick_sz,
1349 definition.inst_id,
1350 )
1351 })?;
1352
1353 if definition.lot_sz.is_empty() {
1354 anyhow::bail!("`lot_sz` is empty for {}", definition.inst_id);
1355 }
1356
1357 let size_increment = Quantity::from(&definition.lot_sz);
1358 let lot_size = Some(Quantity::from(&definition.lot_sz));
1359 let max_quantity = if definition.max_mkt_sz.is_empty() {
1360 None
1361 } else {
1362 Some(Quantity::from(&definition.max_mkt_sz))
1363 };
1364 let min_quantity = if definition.min_sz.is_empty() {
1365 None
1366 } else {
1367 Some(Quantity::from(&definition.min_sz))
1368 };
1369 let max_notional: Option<Money> = None;
1370 let min_notional: Option<Money> = None;
1371 let max_price = None; let min_price = None; Ok(CommonInstrumentData {
1375 instrument_id,
1376 raw_symbol,
1377 price_increment,
1378 size_increment,
1379 lot_size,
1380 max_quantity,
1381 min_quantity,
1382 max_notional,
1383 min_notional,
1384 max_price,
1385 min_price,
1386 })
1387}
1388
1389fn parse_instrument_with_parser<P: InstrumentParser>(
1391 definition: &OKXInstrument,
1392 parser: P,
1393 margin_init: Option<Decimal>,
1394 margin_maint: Option<Decimal>,
1395 maker_fee: Option<Decimal>,
1396 taker_fee: Option<Decimal>,
1397 ts_init: UnixNanos,
1398) -> anyhow::Result<InstrumentAny> {
1399 let common = parse_common_instrument_data(definition)?;
1400 parser.parse_specific_fields(
1401 definition,
1402 common,
1403 MarginAndFees {
1404 margin_init,
1405 margin_maint,
1406 maker_fee,
1407 taker_fee,
1408 },
1409 ts_init,
1410 )
1411}
1412
1413struct SpotInstrumentParser;
1415
1416impl InstrumentParser for SpotInstrumentParser {
1417 fn parse_specific_fields(
1418 &self,
1419 definition: &OKXInstrument,
1420 common: CommonInstrumentData,
1421 margin_fees: MarginAndFees,
1422 ts_init: UnixNanos,
1423 ) -> anyhow::Result<InstrumentAny> {
1424 let context = format!("{} instrument {}", definition.inst_type, definition.inst_id);
1425 let base_currency =
1426 Currency::get_or_create_crypto_with_context(definition.base_ccy, Some(&context));
1427 let quote_currency =
1428 Currency::get_or_create_crypto_with_context(definition.quote_ccy, Some(&context));
1429
1430 let multiplier = parse_multiplier_product(definition)?;
1432
1433 let instrument = CurrencyPair::new(
1434 common.instrument_id,
1435 common.raw_symbol,
1436 base_currency,
1437 quote_currency,
1438 common.price_increment.precision,
1439 common.size_increment.precision,
1440 common.price_increment,
1441 common.size_increment,
1442 multiplier,
1443 common.lot_size,
1444 common.max_quantity,
1445 common.min_quantity,
1446 common.max_notional,
1447 common.min_notional,
1448 common.max_price,
1449 common.min_price,
1450 margin_fees.margin_init,
1451 margin_fees.margin_maint,
1452 margin_fees.maker_fee,
1453 margin_fees.taker_fee,
1454 ts_init,
1455 ts_init,
1456 );
1457
1458 Ok(InstrumentAny::CurrencyPair(instrument))
1459 }
1460}
1461
1462pub fn parse_spot_instrument(
1468 definition: &OKXInstrument,
1469 margin_init: Option<Decimal>,
1470 margin_maint: Option<Decimal>,
1471 maker_fee: Option<Decimal>,
1472 taker_fee: Option<Decimal>,
1473 ts_init: UnixNanos,
1474) -> anyhow::Result<InstrumentAny> {
1475 parse_instrument_with_parser(
1476 definition,
1477 SpotInstrumentParser,
1478 margin_init,
1479 margin_maint,
1480 maker_fee,
1481 taker_fee,
1482 ts_init,
1483 )
1484}
1485
1486fn validate_underlying(inst_id: Ustr, uly: Ustr) -> anyhow::Result<()> {
1493 if uly.is_empty() {
1494 anyhow::bail!(
1495 "Empty underlying for {inst_id}: instrument may be pre-open or misconfigured"
1496 );
1497 }
1498 Ok(())
1499}
1500
1501pub fn parse_swap_instrument(
1507 definition: &OKXInstrument,
1508 margin_init: Option<Decimal>,
1509 margin_maint: Option<Decimal>,
1510 maker_fee: Option<Decimal>,
1511 taker_fee: Option<Decimal>,
1512 ts_init: UnixNanos,
1513) -> anyhow::Result<InstrumentAny> {
1514 validate_underlying(definition.inst_id, definition.uly)?;
1515
1516 let context = format!("SWAP instrument {}", definition.inst_id);
1517 let (base_currency, quote_currency) = definition.uly.split_once('-').ok_or_else(|| {
1518 anyhow::anyhow!(
1519 "Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
1520 definition.uly,
1521 definition.inst_id
1522 )
1523 })?;
1524
1525 let instrument_id = parse_instrument_id(definition.inst_id);
1526 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1527 let base_currency = Currency::get_or_create_crypto_with_context(base_currency, Some(&context));
1528 let quote_currency =
1529 Currency::get_or_create_crypto_with_context(quote_currency, Some(&context));
1530 let settlement_currency =
1531 Currency::get_or_create_crypto_with_context(definition.settle_ccy, Some(&context));
1532 let is_inverse = match definition.ct_type {
1533 OKXContractType::Linear => false,
1534 OKXContractType::Inverse => true,
1535 OKXContractType::None => {
1536 anyhow::bail!(
1537 "Invalid contract type '{}' for {}: expected 'linear' or 'inverse'",
1538 definition.ct_type,
1539 definition.inst_id
1540 )
1541 }
1542 };
1543
1544 if definition.tick_sz.is_empty() {
1545 anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1546 }
1547
1548 let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
1549 anyhow::anyhow!(
1550 "Failed to parse `tick_sz` '{}' into Price for {}: {e}",
1551 definition.tick_sz,
1552 definition.inst_id
1553 )
1554 })?;
1555 let size_increment = Quantity::from(&definition.lot_sz);
1556 let multiplier = parse_multiplier_product(definition)?;
1557 let lot_size = Some(Quantity::from(&definition.lot_sz));
1558 let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1559 let min_quantity = Some(Quantity::from(&definition.min_sz));
1560 let max_notional: Option<Money> = None;
1561 let min_notional: Option<Money> = None;
1562 let max_price = None; let min_price = None; let instrument = CryptoPerpetual::new(
1566 instrument_id,
1567 raw_symbol,
1568 base_currency,
1569 quote_currency,
1570 settlement_currency,
1571 is_inverse,
1572 price_increment.precision,
1573 size_increment.precision,
1574 price_increment,
1575 size_increment,
1576 multiplier,
1577 lot_size,
1578 max_quantity,
1579 min_quantity,
1580 max_notional,
1581 min_notional,
1582 max_price,
1583 min_price,
1584 margin_init,
1585 margin_maint,
1586 maker_fee,
1587 taker_fee,
1588 ts_init, ts_init,
1590 );
1591
1592 Ok(InstrumentAny::CryptoPerpetual(instrument))
1593}
1594
1595pub fn parse_futures_instrument(
1601 definition: &OKXInstrument,
1602 margin_init: Option<Decimal>,
1603 margin_maint: Option<Decimal>,
1604 maker_fee: Option<Decimal>,
1605 taker_fee: Option<Decimal>,
1606 ts_init: UnixNanos,
1607) -> anyhow::Result<InstrumentAny> {
1608 validate_underlying(definition.inst_id, definition.uly)?;
1609
1610 let context = format!("FUTURES instrument {}", definition.inst_id);
1611 let (_, quote_currency) = definition.uly.split_once('-').ok_or_else(|| {
1612 anyhow::anyhow!(
1613 "Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
1614 definition.uly,
1615 definition.inst_id
1616 )
1617 })?;
1618
1619 let instrument_id = parse_instrument_id(definition.inst_id);
1620 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1621 let underlying = Currency::get_or_create_crypto_with_context(definition.uly, Some(&context));
1622 let quote_currency =
1623 Currency::get_or_create_crypto_with_context(quote_currency, Some(&context));
1624 let settlement_currency =
1625 Currency::get_or_create_crypto_with_context(definition.settle_ccy, Some(&context));
1626 let is_inverse = match definition.ct_type {
1627 OKXContractType::Linear => false,
1628 OKXContractType::Inverse => true,
1629 OKXContractType::None => {
1630 anyhow::bail!(
1631 "Invalid contract type '{}' for {}: expected 'linear' or 'inverse'",
1632 definition.ct_type,
1633 definition.inst_id
1634 )
1635 }
1636 };
1637 let listing_time = definition
1638 .list_time
1639 .ok_or_else(|| anyhow::anyhow!("`list_time` is required for {}", definition.inst_id))?;
1640 let expiry_time = definition
1641 .exp_time
1642 .ok_or_else(|| anyhow::anyhow!("`exp_time` is required for {}", definition.inst_id))?;
1643 let activation_ns = UnixNanos::from(millis_to_nanos_unchecked(listing_time as f64));
1644 let expiration_ns = UnixNanos::from(millis_to_nanos_unchecked(expiry_time as f64));
1645
1646 if definition.tick_sz.is_empty() {
1647 anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1648 }
1649
1650 let price_increment = Price::from(definition.tick_sz.clone());
1651 let size_increment = Quantity::from(&definition.lot_sz);
1652 let multiplier = parse_multiplier_product(definition)?;
1653 let lot_size = Some(Quantity::from(&definition.lot_sz));
1654 let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1655 let min_quantity = Some(Quantity::from(&definition.min_sz));
1656 let max_notional: Option<Money> = None;
1657 let min_notional: Option<Money> = None;
1658 let max_price = None; let min_price = None; let instrument = CryptoFuture::new(
1662 instrument_id,
1663 raw_symbol,
1664 underlying,
1665 quote_currency,
1666 settlement_currency,
1667 is_inverse,
1668 activation_ns,
1669 expiration_ns,
1670 price_increment.precision,
1671 size_increment.precision,
1672 price_increment,
1673 size_increment,
1674 multiplier,
1675 lot_size,
1676 max_quantity,
1677 min_quantity,
1678 max_notional,
1679 min_notional,
1680 max_price,
1681 min_price,
1682 margin_init,
1683 margin_maint,
1684 maker_fee,
1685 taker_fee,
1686 ts_init, ts_init,
1688 );
1689
1690 Ok(InstrumentAny::CryptoFuture(instrument))
1691}
1692
1693pub fn parse_option_instrument(
1699 definition: &OKXInstrument,
1700 margin_init: Option<Decimal>,
1701 margin_maint: Option<Decimal>,
1702 maker_fee: Option<Decimal>,
1703 taker_fee: Option<Decimal>,
1704 ts_init: UnixNanos,
1705) -> anyhow::Result<InstrumentAny> {
1706 validate_underlying(definition.inst_id, definition.uly)?;
1707
1708 let context = format!("OPTION instrument {}", definition.inst_id);
1709 let (underlying_str, quote_ccy_str) = definition.uly.split_once('-').ok_or_else(|| {
1710 anyhow::anyhow!(
1711 "Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
1712 definition.uly,
1713 definition.inst_id
1714 )
1715 })?;
1716
1717 let instrument_id = parse_instrument_id(definition.inst_id);
1718 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1719 let underlying = Currency::get_or_create_crypto_with_context(underlying_str, Some(&context));
1720 let option_kind: OptionKind = definition.opt_type.into();
1721 let strike_price = Price::from(&definition.stk);
1722 let quote_currency = Currency::get_or_create_crypto_with_context(quote_ccy_str, Some(&context));
1723 let settlement_currency =
1724 Currency::get_or_create_crypto_with_context(definition.settle_ccy, Some(&context));
1725
1726 let is_inverse = if definition.ct_type == OKXContractType::None {
1727 settlement_currency == underlying
1728 } else {
1729 matches!(definition.ct_type, OKXContractType::Inverse)
1730 };
1731
1732 let listing_time = definition
1733 .list_time
1734 .ok_or_else(|| anyhow::anyhow!("`list_time` is required for {}", definition.inst_id))?;
1735 let expiry_time = definition
1736 .exp_time
1737 .ok_or_else(|| anyhow::anyhow!("`exp_time` is required for {}", definition.inst_id))?;
1738 let activation_ns = UnixNanos::from(millis_to_nanos_unchecked(listing_time as f64));
1739 let expiration_ns = UnixNanos::from(millis_to_nanos_unchecked(expiry_time as f64));
1740
1741 if definition.tick_sz.is_empty() {
1742 anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1743 }
1744
1745 let price_increment = Price::from(definition.tick_sz.clone());
1746 let size_increment = Quantity::from(&definition.lot_sz);
1747 let multiplier = parse_multiplier_product(definition)?;
1748 let lot_size = Quantity::from(&definition.lot_sz);
1749 let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1750 let min_quantity = Some(Quantity::from(&definition.min_sz));
1751 let max_notional = None;
1752 let min_notional = None;
1753 let max_price = None;
1754 let min_price = None;
1755
1756 let instrument = CryptoOption::new(
1757 instrument_id,
1758 raw_symbol,
1759 underlying,
1760 quote_currency,
1761 settlement_currency,
1762 is_inverse,
1763 option_kind,
1764 strike_price,
1765 activation_ns,
1766 expiration_ns,
1767 price_increment.precision,
1768 size_increment.precision,
1769 price_increment,
1770 size_increment,
1771 multiplier,
1772 Some(lot_size),
1773 max_quantity,
1774 min_quantity,
1775 max_notional,
1776 min_notional,
1777 max_price,
1778 min_price,
1779 margin_init,
1780 margin_maint,
1781 maker_fee,
1782 taker_fee,
1783 ts_init,
1784 ts_init,
1785 );
1786
1787 Ok(InstrumentAny::CryptoOption(instrument))
1788}
1789
1790fn parse_balance_field(
1793 value_str: &str,
1794 field_name: &str,
1795 currency: Currency,
1796 ccy_str: &str,
1797) -> Option<Money> {
1798 match Decimal::from_str(value_str) {
1799 Ok(decimal) => Money::from_decimal(decimal, currency).ok(),
1800 Err(e) => {
1801 log::warn!(
1802 "Skipping balance detail for {ccy_str} with invalid {field_name} '{value_str}': {e}"
1803 );
1804 None
1805 }
1806 }
1807}
1808
1809pub fn parse_account_state(
1813 okx_account: &OKXAccount,
1814 account_id: AccountId,
1815 ts_init: UnixNanos,
1816) -> anyhow::Result<AccountState> {
1817 let mut balances = Vec::new();
1818 for b in &okx_account.details {
1819 let ccy_str = b.ccy.as_str().trim();
1821 if ccy_str.is_empty() {
1822 log::debug!("Skipping balance detail with empty currency code | raw_data={b:?}");
1823 continue;
1824 }
1825
1826 let currency = Currency::get_or_create_crypto_with_context(ccy_str, Some("balance detail"));
1828
1829 let Some(total) = parse_balance_field(&b.cash_bal, "cash_bal", currency, ccy_str) else {
1831 continue;
1832 };
1833
1834 let Some(free) = parse_balance_field(&b.avail_bal, "avail_bal", currency, ccy_str) else {
1835 continue;
1836 };
1837
1838 let locked = total - free;
1839 let balance = AccountBalance::new(total, locked, free);
1840 balances.push(balance);
1841 }
1842
1843 if balances.is_empty() {
1846 let zero_currency = Currency::USD();
1847 let zero_money = Money::new(0.0, zero_currency);
1848 let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
1849 balances.push(zero_balance);
1850 }
1851
1852 let mut margins = Vec::new();
1853
1854 if !okx_account.imr.is_empty() && !okx_account.mmr.is_empty() {
1856 match (
1857 Decimal::from_str(&okx_account.imr),
1858 Decimal::from_str(&okx_account.mmr),
1859 ) {
1860 (Ok(imr_dec), Ok(mmr_dec)) => {
1861 if !imr_dec.is_zero() || !mmr_dec.is_zero() {
1862 let margin_currency = Currency::USD();
1863 let margin_instrument_id =
1864 InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("OKX"));
1865
1866 let initial_margin = Money::from_decimal(imr_dec, margin_currency)
1867 .unwrap_or_else(|e| {
1868 log::error!("Failed to create initial margin: {e}");
1869 Money::zero(margin_currency)
1870 });
1871 let maintenance_margin = Money::from_decimal(mmr_dec, margin_currency)
1872 .unwrap_or_else(|e| {
1873 log::error!("Failed to create maintenance margin: {e}");
1874 Money::zero(margin_currency)
1875 });
1876
1877 let margin_balance = MarginBalance::new(
1878 initial_margin,
1879 maintenance_margin,
1880 margin_instrument_id,
1881 );
1882
1883 margins.push(margin_balance);
1884 }
1885 }
1886 (Err(e1), _) => {
1887 log::warn!(
1888 "Failed to parse initial margin requirement '{}': {}",
1889 okx_account.imr,
1890 e1
1891 );
1892 }
1893 (_, Err(e2)) => {
1894 log::warn!(
1895 "Failed to parse maintenance margin requirement '{}': {}",
1896 okx_account.mmr,
1897 e2
1898 );
1899 }
1900 }
1901 }
1902
1903 let account_type = AccountType::Margin;
1904 let is_reported = true;
1905 let event_id = UUID4::new();
1906 let ts_event = UnixNanos::from(millis_to_nanos_unchecked(okx_account.u_time as f64));
1907
1908 Ok(AccountState::new(
1909 account_id,
1910 account_type,
1911 balances,
1912 margins,
1913 is_reported,
1914 event_id,
1915 ts_event,
1916 ts_init,
1917 None,
1918 ))
1919}
1920
1921#[cfg(test)]
1922mod tests {
1923 use nautilus_model::{identifiers::PositionId, instruments::Instrument};
1924 use rstest::rstest;
1925 use rust_decimal_macros::dec;
1926
1927 use super::*;
1928 use crate::{
1929 OKXPositionSide,
1930 common::{enums::OKXMarginMode, testing::load_test_json},
1931 http::{
1932 client::OKXResponse,
1933 models::{
1934 OKXAccount, OKXBalanceDetail, OKXCandlestick, OKXIndexTicker, OKXMarkPrice,
1935 OKXOrderHistory, OKXPlaceOrderResponse, OKXPosition, OKXPositionHistory,
1936 OKXPositionTier, OKXTrade, OKXTransactionDetail,
1937 },
1938 },
1939 };
1940
1941 #[rstest]
1942 fn test_parse_fee_currency_with_zero_fee_empty_string() {
1943 let result = parse_fee_currency("", Decimal::ZERO, || "test context".to_string());
1944 assert_eq!(result, Currency::USDT());
1945 }
1946
1947 #[rstest]
1948 fn test_parse_fee_currency_with_zero_fee_valid_currency() {
1949 let result = parse_fee_currency("BTC", Decimal::ZERO, || "test context".to_string());
1950 assert_eq!(result, Currency::BTC());
1951 }
1952
1953 #[rstest]
1954 fn test_parse_fee_currency_with_valid_currency() {
1955 let result = parse_fee_currency("BTC", dec!(0.001), || "test context".to_string());
1956 assert_eq!(result, Currency::BTC());
1957 }
1958
1959 #[rstest]
1960 fn test_parse_fee_currency_with_empty_string_nonzero_fee() {
1961 let result = parse_fee_currency("", dec!(0.5), || "test context".to_string());
1962 assert_eq!(result, Currency::USDT());
1963 }
1964
1965 #[rstest]
1966 fn test_parse_fee_currency_with_whitespace() {
1967 let result = parse_fee_currency(" ETH ", dec!(0.002), || "test context".to_string());
1968 assert_eq!(result, Currency::ETH());
1969 }
1970
1971 #[rstest]
1972 fn test_parse_fee_currency_with_unknown_code() {
1973 let result = parse_fee_currency("NEWTOKEN", dec!(0.5), || "test context".to_string());
1975 assert_eq!(result.code.as_str(), "NEWTOKEN");
1976 assert_eq!(result.precision, 8);
1977 }
1978
1979 #[rstest]
1980 fn test_parse_balance_field_valid() {
1981 let result = parse_balance_field("100.5", "test_field", Currency::BTC(), "BTC");
1982 assert!(result.is_some());
1983 assert_eq!(result.unwrap().as_f64(), 100.5);
1984 }
1985
1986 #[rstest]
1987 fn test_parse_balance_field_invalid_numeric() {
1988 let result = parse_balance_field("not_a_number", "test_field", Currency::BTC(), "BTC");
1989 assert!(result.is_none());
1990 }
1991
1992 #[rstest]
1993 fn test_parse_balance_field_empty() {
1994 let result = parse_balance_field("", "test_field", Currency::BTC(), "BTC");
1995 assert!(result.is_none());
1996 }
1997
1998 #[rstest]
2002 fn test_parse_trades() {
2003 let json_data = load_test_json("http_get_trades.json");
2004 let parsed: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
2005
2006 assert_eq!(parsed.code, "0");
2008 assert_eq!(parsed.msg, "");
2009 assert_eq!(parsed.data.len(), 2);
2010
2011 let trade0 = &parsed.data[0];
2013 assert_eq!(trade0.inst_id, "BTC-USDT");
2014 assert_eq!(trade0.px, "102537.9");
2015 assert_eq!(trade0.sz, "0.00013669");
2016 assert_eq!(trade0.side, OKXSide::Sell);
2017 assert_eq!(trade0.trade_id, "734864333");
2018 assert_eq!(trade0.ts, 1747087163557);
2019
2020 let trade1 = &parsed.data[1];
2022 assert_eq!(trade1.inst_id, "BTC-USDT");
2023 assert_eq!(trade1.px, "102537.9");
2024 assert_eq!(trade1.sz, "0.0000125");
2025 assert_eq!(trade1.side, OKXSide::Buy);
2026 assert_eq!(trade1.trade_id, "734864332");
2027 assert_eq!(trade1.ts, 1747087161666);
2028 }
2029
2030 #[rstest]
2031 fn test_parse_candlesticks() {
2032 let json_data = load_test_json("http_get_candlesticks.json");
2033 let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
2034
2035 assert_eq!(parsed.code, "0");
2037 assert_eq!(parsed.msg, "");
2038 assert_eq!(parsed.data.len(), 2);
2039
2040 let bar0 = &parsed.data[0];
2041 assert_eq!(bar0.0, "1625097600000");
2042 assert_eq!(bar0.1, "33528.6");
2043 assert_eq!(bar0.2, "33870.0");
2044 assert_eq!(bar0.3, "33528.6");
2045 assert_eq!(bar0.4, "33783.9");
2046 assert_eq!(bar0.5, "778.838");
2047
2048 let bar1 = &parsed.data[1];
2049 assert_eq!(bar1.0, "1625097660000");
2050 assert_eq!(bar1.1, "33783.9");
2051 assert_eq!(bar1.2, "33783.9");
2052 assert_eq!(bar1.3, "33782.1");
2053 assert_eq!(bar1.4, "33782.1");
2054 assert_eq!(bar1.5, "0.123");
2055 }
2056
2057 #[rstest]
2058 fn test_parse_candlesticks_full() {
2059 let json_data = load_test_json("http_get_candlesticks_full.json");
2060 let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
2061
2062 assert_eq!(parsed.code, "0");
2064 assert_eq!(parsed.msg, "");
2065 assert_eq!(parsed.data.len(), 2);
2066
2067 let bar0 = &parsed.data[0];
2069 assert_eq!(bar0.0, "1747094040000");
2070 assert_eq!(bar0.1, "102806.1");
2071 assert_eq!(bar0.2, "102820.4");
2072 assert_eq!(bar0.3, "102806.1");
2073 assert_eq!(bar0.4, "102820.4");
2074 assert_eq!(bar0.5, "1040.37");
2075 assert_eq!(bar0.6, "10.4037");
2076 assert_eq!(bar0.7, "1069603.34883");
2077 assert_eq!(bar0.8, "1");
2078
2079 let bar1 = &parsed.data[1];
2081 assert_eq!(bar1.0, "1747093980000");
2082 assert_eq!(bar1.5, "7164.04");
2083 assert_eq!(bar1.6, "71.6404");
2084 assert_eq!(bar1.7, "7364701.57952");
2085 assert_eq!(bar1.8, "1");
2086 }
2087
2088 #[rstest]
2089 fn test_parse_mark_price() {
2090 let json_data = load_test_json("http_get_mark_price.json");
2091 let parsed: OKXResponse<OKXMarkPrice> = serde_json::from_str(&json_data).unwrap();
2092
2093 assert_eq!(parsed.code, "0");
2095 assert_eq!(parsed.msg, "");
2096 assert_eq!(parsed.data.len(), 1);
2097
2098 let mark_price = &parsed.data[0];
2100
2101 assert_eq!(mark_price.inst_id, "BTC-USDT-SWAP");
2102 assert_eq!(mark_price.mark_px, "84660.1");
2103 assert_eq!(mark_price.ts, 1744590349506);
2104 }
2105
2106 #[rstest]
2107 fn test_parse_index_price() {
2108 let json_data = load_test_json("http_get_index_price.json");
2109 let parsed: OKXResponse<OKXIndexTicker> = serde_json::from_str(&json_data).unwrap();
2110
2111 assert_eq!(parsed.code, "0");
2113 assert_eq!(parsed.msg, "");
2114 assert_eq!(parsed.data.len(), 1);
2115
2116 let index_price = &parsed.data[0];
2118
2119 assert_eq!(index_price.inst_id, "BTC-USDT");
2120 assert_eq!(index_price.idx_px, "103895");
2121 assert_eq!(index_price.ts, 1746942707815);
2122 }
2123
2124 #[rstest]
2125 fn test_parse_account() {
2126 let json_data = load_test_json("http_get_account_balance.json");
2127 let parsed: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
2128
2129 assert_eq!(parsed.code, "0");
2131 assert_eq!(parsed.msg, "");
2132 assert_eq!(parsed.data.len(), 1);
2133
2134 let account = &parsed.data[0];
2136 assert_eq!(account.adj_eq, "");
2137 assert_eq!(account.borrow_froz, "");
2138 assert_eq!(account.imr, "");
2139 assert_eq!(account.iso_eq, "5.4682385526666675");
2140 assert_eq!(account.mgn_ratio, "");
2141 assert_eq!(account.mmr, "");
2142 assert_eq!(account.notional_usd, "");
2143 assert_eq!(account.notional_usd_for_borrow, "");
2144 assert_eq!(account.notional_usd_for_futures, "");
2145 assert_eq!(account.notional_usd_for_option, "");
2146 assert_eq!(account.notional_usd_for_swap, "");
2147 assert_eq!(account.ord_froz, "");
2148 assert_eq!(account.total_eq, "99.88870288820581");
2149 assert_eq!(account.upl, "");
2150 assert_eq!(account.u_time, 1744499648556);
2151 assert_eq!(account.details.len(), 1);
2152
2153 let detail = &account.details[0];
2154 assert_eq!(detail.ccy, "USDT");
2155 assert_eq!(detail.avail_bal, "94.42612990333333");
2156 assert_eq!(detail.avail_eq, "94.42612990333333");
2157 assert_eq!(detail.cash_bal, "94.42612990333333");
2158 assert_eq!(detail.dis_eq, "5.4682385526666675");
2159 assert_eq!(detail.eq, "99.89469657000001");
2160 assert_eq!(detail.eq_usd, "99.88870288820581");
2161 assert_eq!(detail.fixed_bal, "0");
2162 assert_eq!(detail.frozen_bal, "5.468566666666667");
2163 assert_eq!(detail.imr, "0");
2164 assert_eq!(detail.iso_eq, "5.468566666666667");
2165 assert_eq!(detail.iso_upl, "-0.0273000000000002");
2166 assert_eq!(detail.mmr, "0");
2167 assert_eq!(detail.notional_lever, "0");
2168 assert_eq!(detail.ord_frozen, "0");
2169 assert_eq!(detail.reward_bal, "0");
2170 assert_eq!(detail.smt_sync_eq, "0");
2171 assert_eq!(detail.spot_copy_trading_eq, "0");
2172 assert_eq!(detail.spot_iso_bal, "0");
2173 assert_eq!(detail.stgy_eq, "0");
2174 assert_eq!(detail.twap, "0");
2175 assert_eq!(detail.upl, "-0.0273000000000002");
2176 assert_eq!(detail.u_time, 1744498994783);
2177 }
2178
2179 #[rstest]
2180 fn test_parse_order_history() {
2181 let json_data = load_test_json("http_get_orders_history.json");
2182 let parsed: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
2183
2184 assert_eq!(parsed.code, "0");
2186 assert_eq!(parsed.msg, "");
2187 assert_eq!(parsed.data.len(), 1);
2188
2189 let order = &parsed.data[0];
2191 assert_eq!(order.ord_id, "2497956918703120384");
2192 assert_eq!(order.fill_sz, "0.03");
2193 assert_eq!(order.acc_fill_sz, "0.03");
2194 assert_eq!(order.state, OKXOrderStatus::Filled);
2195 assert!(order.fill_fee.is_none());
2196 }
2197
2198 #[rstest]
2199 fn test_parse_position() {
2200 let json_data = load_test_json("http_get_positions.json");
2201 let parsed: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
2202
2203 assert_eq!(parsed.code, "0");
2205 assert_eq!(parsed.msg, "");
2206 assert_eq!(parsed.data.len(), 1);
2207
2208 let pos = &parsed.data[0];
2210 assert_eq!(pos.inst_id, "BTC-USDT-SWAP");
2211 assert_eq!(pos.pos_side, OKXPositionSide::Long);
2212 assert_eq!(pos.pos, "0.5");
2213 assert_eq!(pos.base_bal, "0.5");
2214 assert_eq!(pos.quote_bal, "5000");
2215 assert_eq!(pos.u_time, 1622559930237);
2216 }
2217
2218 #[rstest]
2219 fn test_parse_position_history() {
2220 let json_data = load_test_json("http_get_account_positions-history.json");
2221 let parsed: OKXResponse<OKXPositionHistory> = serde_json::from_str(&json_data).unwrap();
2222
2223 assert_eq!(parsed.code, "0");
2225 assert_eq!(parsed.msg, "");
2226 assert_eq!(parsed.data.len(), 1);
2227
2228 let hist = &parsed.data[0];
2230 assert_eq!(hist.inst_id, "ETH-USDT-SWAP");
2231 assert_eq!(hist.inst_type, OKXInstrumentType::Swap);
2232 assert_eq!(hist.mgn_mode, OKXMarginMode::Isolated);
2233 assert_eq!(hist.pos_side, OKXPositionSide::Long);
2234 assert_eq!(hist.lever, "3.0");
2235 assert_eq!(hist.open_avg_px, "3226.93");
2236 assert_eq!(hist.close_avg_px.as_deref(), Some("3224.8"));
2237 assert_eq!(hist.pnl.as_deref(), Some("-0.0213"));
2238 assert!(!hist.c_time.is_empty());
2239 assert!(hist.u_time > 0);
2240 }
2241
2242 #[rstest]
2243 fn test_parse_position_tiers() {
2244 let json_data = load_test_json("http_get_position_tiers.json");
2245 let parsed: OKXResponse<OKXPositionTier> = serde_json::from_str(&json_data).unwrap();
2246
2247 assert_eq!(parsed.code, "0");
2249 assert_eq!(parsed.msg, "");
2250 assert_eq!(parsed.data.len(), 1);
2251
2252 let tier = &parsed.data[0];
2254 assert_eq!(tier.inst_id, "BTC-USDT");
2255 assert_eq!(tier.tier, "1");
2256 assert_eq!(tier.min_sz, "0");
2257 assert_eq!(tier.max_sz, "50");
2258 assert_eq!(tier.imr, "0.1");
2259 assert_eq!(tier.mmr, "0.03");
2260 }
2261
2262 #[rstest]
2263 fn test_parse_account_field_name_compatibility() {
2264 let json_new = load_test_json("http_balance_detail_new_fields.json");
2266 let detail_new: OKXBalanceDetail = serde_json::from_str(&json_new).unwrap();
2267 assert_eq!(detail_new.max_spot_in_use_amt, "50.0");
2268 assert_eq!(detail_new.spot_in_use_amt, "30.0");
2269 assert_eq!(detail_new.cl_spot_in_use_amt, "25.0");
2270
2271 let json_old = load_test_json("http_balance_detail_old_fields.json");
2273 let detail_old: OKXBalanceDetail = serde_json::from_str(&json_old).unwrap();
2274 assert_eq!(detail_old.max_spot_in_use_amt, "75.0");
2275 assert_eq!(detail_old.spot_in_use_amt, "40.0");
2276 assert_eq!(detail_old.cl_spot_in_use_amt, "35.0");
2277 }
2278
2279 #[rstest]
2280 fn test_parse_place_order_response() {
2281 let json_data = load_test_json("http_place_order_response.json");
2282 let parsed: OKXPlaceOrderResponse = serde_json::from_str(&json_data).unwrap();
2283 assert_eq!(parsed.ord_id, Some(Ustr::from("12345678901234567890")));
2284 assert_eq!(parsed.cl_ord_id, Some(Ustr::from("client_order_123")));
2285 assert_eq!(parsed.tag, Some(String::new()));
2286 }
2287
2288 #[rstest]
2289 fn test_parse_transaction_details() {
2290 let json_data = load_test_json("http_transaction_detail.json");
2291 let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
2292 assert_eq!(parsed.inst_type, OKXInstrumentType::Spot);
2293 assert_eq!(parsed.inst_id, Ustr::from("BTC-USDT"));
2294 assert_eq!(parsed.trade_id, Ustr::from("123456789"));
2295 assert_eq!(parsed.ord_id, Ustr::from("987654321"));
2296 assert_eq!(parsed.cl_ord_id, Ustr::from("client_123"));
2297 assert_eq!(parsed.bill_id, Ustr::from("bill_456"));
2298 assert_eq!(parsed.fill_px, "42000.5");
2299 assert_eq!(parsed.fill_sz, "0.001");
2300 assert_eq!(parsed.side, OKXSide::Buy);
2301 assert_eq!(parsed.exec_type, OKXExecType::Taker);
2302 assert_eq!(parsed.fee_ccy, "USDT");
2303 assert_eq!(parsed.fee, Some("0.042".to_string()));
2304 assert_eq!(parsed.ts, 1625097600000);
2305 }
2306
2307 #[rstest]
2308 fn test_parse_empty_fee_field() {
2309 let json_data = load_test_json("http_transaction_detail_empty_fee.json");
2310 let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
2311 assert_eq!(parsed.fee, None);
2312 }
2313
2314 #[rstest]
2315 fn test_parse_optional_string_to_u64() {
2316 use serde::Deserialize;
2317
2318 #[derive(Deserialize)]
2319 struct TestStruct {
2320 #[serde(deserialize_with = "crate::common::parse::deserialize_optional_string_to_u64")]
2321 value: Option<u64>,
2322 }
2323
2324 let json_cases = load_test_json("common_optional_string_to_u64.json");
2325 let cases: Vec<TestStruct> = serde_json::from_str(&json_cases).unwrap();
2326
2327 assert_eq!(cases[0].value, Some(12345));
2328 assert_eq!(cases[1].value, None);
2329 assert_eq!(cases[2].value, None);
2330 }
2331
2332 #[rstest]
2333 fn test_parse_error_handling() {
2334 let invalid_price = "invalid-price";
2336 let result = crate::common::parse::parse_price(invalid_price, 2);
2337 assert!(result.is_err());
2338
2339 let invalid_quantity = "invalid-quantity";
2341 let result = crate::common::parse::parse_quantity(invalid_quantity, 8);
2342 assert!(result.is_err());
2343 }
2344
2345 #[rstest]
2346 fn test_parse_spot_instrument() {
2347 let json_data = load_test_json("http_get_instruments_spot.json");
2348 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2349 let okx_inst: &OKXInstrument = response
2350 .data
2351 .first()
2352 .expect("Test data must have an instrument");
2353
2354 let instrument =
2355 parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2356
2357 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD.OKX"));
2358 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD"));
2359 assert_eq!(instrument.underlying(), None);
2360 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2361 assert_eq!(instrument.quote_currency(), Currency::USD());
2362 assert_eq!(instrument.settlement_currency(), Currency::USD());
2363 assert_eq!(instrument.price_precision(), 1);
2364 assert_eq!(instrument.size_precision(), 8);
2365 assert_eq!(instrument.price_increment(), Price::from("0.1"));
2366 assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
2367 assert_eq!(instrument.multiplier(), Quantity::from(1));
2368 assert_eq!(instrument.lot_size(), Some(Quantity::from("0.00000001")));
2369 assert_eq!(instrument.max_quantity(), Some(Quantity::from(1000000)));
2370 assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.00001")));
2371 assert_eq!(instrument.max_notional(), None);
2372 assert_eq!(instrument.min_notional(), None);
2373 assert_eq!(instrument.max_price(), None);
2374 assert_eq!(instrument.min_price(), None);
2375 }
2376
2377 #[rstest]
2378 fn test_parse_margin_instrument() {
2379 let json_data = load_test_json("http_get_instruments_margin.json");
2380 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2381 let okx_inst: &OKXInstrument = response
2382 .data
2383 .first()
2384 .expect("Test data must have an instrument");
2385
2386 let instrument =
2387 parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2388
2389 assert_eq!(instrument.id(), InstrumentId::from("BTC-USDT.OKX"));
2390 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USDT"));
2391 assert_eq!(instrument.underlying(), None);
2392 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2393 assert_eq!(instrument.quote_currency(), Currency::USDT());
2394 assert_eq!(instrument.settlement_currency(), Currency::USDT());
2395 assert_eq!(instrument.price_precision(), 1);
2396 assert_eq!(instrument.size_precision(), 8);
2397 assert_eq!(instrument.price_increment(), Price::from("0.1"));
2398 assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
2399 assert_eq!(instrument.multiplier(), Quantity::from(1));
2400 assert_eq!(instrument.lot_size(), Some(Quantity::from("0.00000001")));
2401 assert_eq!(instrument.max_quantity(), Some(Quantity::from(1000000)));
2402 assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.00001")));
2403 assert_eq!(instrument.max_notional(), None);
2404 assert_eq!(instrument.min_notional(), None);
2405 assert_eq!(instrument.max_price(), None);
2406 assert_eq!(instrument.min_price(), None);
2407 }
2408
2409 #[rstest]
2410 fn test_parse_spot_instrument_with_valid_ct_mult() {
2411 let json_data = load_test_json("http_get_instruments_spot.json");
2412 let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2413
2414 if let Some(inst) = response.data.first_mut() {
2416 inst.ct_mult = "0.01".to_string();
2417 }
2418
2419 let okx_inst = response.data.first().unwrap();
2420 let instrument =
2421 parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2422
2423 if let InstrumentAny::CurrencyPair(pair) = instrument {
2425 assert_eq!(pair.multiplier, Quantity::from("0.01"));
2426 } else {
2427 panic!("Expected CurrencyPair instrument");
2428 }
2429 }
2430
2431 #[rstest]
2432 fn test_parse_spot_instrument_with_invalid_ct_mult() {
2433 let json_data = load_test_json("http_get_instruments_spot.json");
2434 let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2435
2436 if let Some(inst) = response.data.first_mut() {
2438 inst.ct_mult = "invalid_number".to_string();
2439 }
2440
2441 let okx_inst = response.data.first().unwrap();
2442 let result = parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default());
2443
2444 assert!(result.is_err());
2446 assert!(
2447 result
2448 .unwrap_err()
2449 .to_string()
2450 .contains("Failed to parse `ct_mult`")
2451 );
2452 }
2453
2454 #[rstest]
2455 fn test_parse_spot_instrument_with_fees() {
2456 let json_data = load_test_json("http_get_instruments_spot.json");
2457 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2458 let okx_inst = response.data.first().unwrap();
2459
2460 let maker_fee = Some(dec!(0.0008));
2461 let taker_fee = Some(dec!(0.0010));
2462
2463 let instrument = parse_spot_instrument(
2464 okx_inst,
2465 None,
2466 None,
2467 maker_fee,
2468 taker_fee,
2469 UnixNanos::default(),
2470 )
2471 .unwrap();
2472
2473 if let InstrumentAny::CurrencyPair(pair) = instrument {
2475 assert_eq!(pair.maker_fee, dec!(0.0008));
2476 assert_eq!(pair.taker_fee, dec!(0.0010));
2477 } else {
2478 panic!("Expected CurrencyPair instrument");
2479 }
2480 }
2481
2482 #[rstest]
2483 fn test_parse_instrument_any_passes_through_fees() {
2484 let json_data = load_test_json("http_get_instruments_spot.json");
2487 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2488 let okx_inst = response.data.first().unwrap();
2489
2490 let maker_fee = Some(dec!(-0.00025)); let taker_fee = Some(dec!(0.00050)); let instrument = parse_instrument_any(
2495 okx_inst,
2496 None,
2497 None,
2498 maker_fee,
2499 taker_fee,
2500 UnixNanos::default(),
2501 )
2502 .unwrap()
2503 .expect("Should parse spot instrument");
2504
2505 if let InstrumentAny::CurrencyPair(pair) = instrument {
2507 assert_eq!(pair.maker_fee, dec!(-0.00025));
2508 assert_eq!(pair.taker_fee, dec!(0.00050));
2509 } else {
2510 panic!("Expected CurrencyPair instrument");
2511 }
2512 }
2513
2514 #[rstest]
2515 fn test_parse_swap_instrument() {
2516 let json_data = load_test_json("http_get_instruments_swap.json");
2517 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2518 let okx_inst: &OKXInstrument = response
2519 .data
2520 .first()
2521 .expect("Test data must have an instrument");
2522
2523 let instrument =
2524 parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2525
2526 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-SWAP.OKX"));
2527 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-SWAP"));
2528 assert_eq!(instrument.underlying(), None);
2529 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2530 assert_eq!(instrument.quote_currency(), Currency::USD());
2531 assert_eq!(instrument.settlement_currency(), Currency::BTC());
2532 assert!(instrument.is_inverse());
2533 assert_eq!(instrument.price_precision(), 1);
2534 assert_eq!(instrument.size_precision(), 0);
2535 assert_eq!(instrument.price_increment(), Price::from("0.1"));
2536 assert_eq!(instrument.size_increment(), Quantity::from(1));
2537 assert_eq!(instrument.multiplier(), Quantity::from(100));
2538 assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2539 assert_eq!(instrument.max_quantity(), Some(Quantity::from(30000)));
2540 assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2541 assert_eq!(instrument.max_notional(), None);
2542 assert_eq!(instrument.min_notional(), None);
2543 assert_eq!(instrument.max_price(), None);
2544 assert_eq!(instrument.min_price(), None);
2545 }
2546
2547 #[rstest]
2548 fn test_parse_linear_swap_instrument() {
2549 let json_data = load_test_json("http_get_instruments_swap.json");
2550 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2551
2552 let okx_inst = response
2553 .data
2554 .iter()
2555 .find(|i| i.inst_id == "ETH-USDT-SWAP")
2556 .expect("ETH-USDT-SWAP must be in test data");
2557
2558 let instrument =
2559 parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2560
2561 assert_eq!(instrument.id(), InstrumentId::from("ETH-USDT-SWAP.OKX"));
2562 assert_eq!(instrument.raw_symbol(), Symbol::from("ETH-USDT-SWAP"));
2563 assert_eq!(instrument.base_currency(), Some(Currency::ETH()));
2564 assert_eq!(instrument.quote_currency(), Currency::USDT());
2565 assert_eq!(instrument.settlement_currency(), Currency::USDT());
2566 assert!(!instrument.is_inverse());
2567 assert_eq!(instrument.multiplier(), Quantity::from("0.1"));
2568 assert_eq!(instrument.price_precision(), 2);
2569 assert_eq!(instrument.size_precision(), 2);
2570 assert_eq!(instrument.price_increment(), Price::from("0.01"));
2571 assert_eq!(instrument.size_increment(), Quantity::from("0.01"));
2572 assert_eq!(instrument.lot_size(), Some(Quantity::from("0.01")));
2573 assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.01")));
2574 assert_eq!(instrument.max_quantity(), Some(Quantity::from(20000)));
2575 }
2576
2577 #[rstest]
2578 fn test_fee_field_selection_for_contract_types() {
2579 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;
2587 let (maker_str, taker_str) = if is_usdt_margined {
2588 (maker_usdt, taker_usdt)
2589 } else {
2590 (maker_crypto, taker_crypto)
2591 };
2592
2593 assert_eq!(maker_str, "0.0008");
2594 assert_eq!(taker_str, "0.0010");
2595
2596 let maker_fee = Decimal::from_str(maker_str).unwrap();
2597 let taker_fee = Decimal::from_str(taker_str).unwrap();
2598
2599 assert_eq!(maker_fee, dec!(0.0008));
2600 assert_eq!(taker_fee, dec!(0.0010));
2601
2602 let is_usdt_margined = false;
2604 let (maker_str, taker_str) = if is_usdt_margined {
2605 (maker_usdt, taker_usdt)
2606 } else {
2607 (maker_crypto, taker_crypto)
2608 };
2609
2610 assert_eq!(maker_str, "0.0002");
2611 assert_eq!(taker_str, "0.0005");
2612
2613 let maker_fee = Decimal::from_str(maker_str).unwrap();
2614 let taker_fee = Decimal::from_str(taker_str).unwrap();
2615
2616 assert_eq!(maker_fee, dec!(0.0002));
2617 assert_eq!(taker_fee, dec!(0.0005));
2618 }
2619
2620 #[rstest]
2621 fn test_parse_futures_instrument() {
2622 let json_data = load_test_json("http_get_instruments_futures.json");
2623 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2624 let okx_inst: &OKXInstrument = response
2625 .data
2626 .first()
2627 .expect("Test data must have an instrument");
2628
2629 let instrument =
2630 parse_futures_instrument(okx_inst, None, None, None, None, UnixNanos::default())
2631 .unwrap();
2632
2633 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-241220.OKX"));
2634 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-241220"));
2635 assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
2636 assert_eq!(instrument.quote_currency(), Currency::USD());
2637 assert_eq!(instrument.settlement_currency(), Currency::BTC());
2638 assert!(instrument.is_inverse());
2639 assert_eq!(instrument.price_precision(), 1);
2640 assert_eq!(instrument.size_precision(), 0);
2641 assert_eq!(instrument.price_increment(), Price::from("0.1"));
2642 assert_eq!(instrument.size_increment(), Quantity::from(1));
2643 assert_eq!(instrument.multiplier(), Quantity::from(100));
2644 assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2645 assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2646 assert_eq!(instrument.max_quantity(), Some(Quantity::from(10000)));
2647 }
2648
2649 #[rstest]
2650 fn test_parse_option_instrument() {
2651 let json_data = load_test_json("http_get_instruments_option.json");
2652 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2653 let okx_inst: &OKXInstrument = response
2654 .data
2655 .first()
2656 .expect("Test data must have an instrument");
2657
2658 let instrument =
2659 parse_option_instrument(okx_inst, None, None, None, None, UnixNanos::default())
2660 .unwrap();
2661
2662 assert_eq!(
2663 instrument.id(),
2664 InstrumentId::from("BTC-USD-241217-92000-C.OKX")
2665 );
2666 assert_eq!(
2667 instrument.raw_symbol(),
2668 Symbol::from("BTC-USD-241217-92000-C")
2669 );
2670 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2671 assert_eq!(instrument.quote_currency(), Currency::USD());
2672 assert_eq!(instrument.settlement_currency(), Currency::BTC());
2673 assert!(instrument.is_inverse());
2674 assert_eq!(instrument.price_precision(), 4);
2675 assert_eq!(instrument.size_precision(), 0);
2676 assert_eq!(instrument.price_increment(), Price::from("0.0001"));
2677 assert_eq!(instrument.size_increment(), Quantity::from(1));
2678 assert_eq!(instrument.multiplier(), Quantity::from("0.01"));
2679 assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2680 assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2681 assert_eq!(instrument.max_quantity(), Some(Quantity::from(5000)));
2682 assert_eq!(instrument.max_notional(), None);
2683 assert_eq!(instrument.min_notional(), None);
2684 assert_eq!(instrument.max_price(), None);
2685 assert_eq!(instrument.min_price(), None);
2686 }
2687
2688 #[rstest]
2689 fn test_parse_account_state() {
2690 let json_data = load_test_json("http_get_account_balance.json");
2691 let response: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
2692 let okx_account = response
2693 .data
2694 .first()
2695 .expect("Test data must have an account");
2696
2697 let account_id = AccountId::new("OKX-001");
2698 let account_state =
2699 parse_account_state(okx_account, account_id, UnixNanos::default()).unwrap();
2700
2701 assert_eq!(account_state.account_id, account_id);
2702 assert_eq!(account_state.account_type, AccountType::Margin);
2703 assert_eq!(account_state.balances.len(), 1);
2704 assert_eq!(account_state.margins.len(), 0); assert!(account_state.is_reported);
2706
2707 let usdt_balance = &account_state.balances[0];
2709 assert_eq!(
2710 usdt_balance.total,
2711 Money::new(94.42612990333333, Currency::USDT())
2712 );
2713 assert_eq!(
2714 usdt_balance.free,
2715 Money::new(94.42612990333333, Currency::USDT())
2716 );
2717 assert_eq!(usdt_balance.locked, Money::new(0.0, Currency::USDT()));
2718 }
2719
2720 #[rstest]
2721 fn test_parse_account_state_with_margins() {
2722 let account_json = r#"{
2724 "adjEq": "10000.0",
2725 "borrowFroz": "0",
2726 "details": [{
2727 "accAvgPx": "",
2728 "availBal": "8000.0",
2729 "availEq": "8000.0",
2730 "borrowFroz": "0",
2731 "cashBal": "10000.0",
2732 "ccy": "USDT",
2733 "clSpotInUseAmt": "0",
2734 "coinUsdPrice": "1.0",
2735 "colBorrAutoConversion": "0",
2736 "collateralEnabled": false,
2737 "collateralRestrict": false,
2738 "crossLiab": "0",
2739 "disEq": "10000.0",
2740 "eq": "10000.0",
2741 "eqUsd": "10000.0",
2742 "fixedBal": "0",
2743 "frozenBal": "2000.0",
2744 "imr": "0",
2745 "interest": "0",
2746 "isoEq": "0",
2747 "isoLiab": "0",
2748 "isoUpl": "0",
2749 "liab": "0",
2750 "maxLoan": "0",
2751 "mgnRatio": "0",
2752 "maxSpotInUseAmt": "0",
2753 "mmr": "0",
2754 "notionalLever": "0",
2755 "openAvgPx": "",
2756 "ordFrozen": "2000.0",
2757 "rewardBal": "0",
2758 "smtSyncEq": "0",
2759 "spotBal": "0",
2760 "spotCopyTradingEq": "0",
2761 "spotInUseAmt": "0",
2762 "spotIsoBal": "0",
2763 "spotUpl": "0",
2764 "spotUplRatio": "0",
2765 "stgyEq": "0",
2766 "totalPnl": "0",
2767 "totalPnlRatio": "0",
2768 "twap": "0",
2769 "uTime": "1704067200000",
2770 "upl": "0",
2771 "uplLiab": "0"
2772 }],
2773 "imr": "500.25",
2774 "isoEq": "0",
2775 "mgnRatio": "20.5",
2776 "mmr": "250.75",
2777 "notionalUsd": "5000.0",
2778 "notionalUsdForBorrow": "0",
2779 "notionalUsdForFutures": "0",
2780 "notionalUsdForOption": "0",
2781 "notionalUsdForSwap": "5000.0",
2782 "ordFroz": "2000.0",
2783 "totalEq": "10000.0",
2784 "uTime": "1704067200000",
2785 "upl": "0"
2786 }"#;
2787
2788 let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
2789 let account_id = AccountId::new("OKX-001");
2790 let account_state =
2791 parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
2792
2793 assert_eq!(account_state.account_id, account_id);
2795 assert_eq!(account_state.account_type, AccountType::Margin);
2796 assert_eq!(account_state.balances.len(), 1);
2797
2798 assert_eq!(account_state.margins.len(), 1);
2800 let margin = &account_state.margins[0];
2801
2802 assert_eq!(margin.initial, Money::new(500.25, Currency::USD()));
2804 assert_eq!(margin.maintenance, Money::new(250.75, Currency::USD()));
2805 assert_eq!(margin.currency, Currency::USD());
2806 assert_eq!(margin.instrument_id.symbol.as_str(), "ACCOUNT");
2807 assert_eq!(margin.instrument_id.venue.as_str(), "OKX");
2808
2809 let usdt_balance = &account_state.balances[0];
2811 assert_eq!(usdt_balance.total, Money::new(10000.0, Currency::USDT()));
2812 assert_eq!(usdt_balance.free, Money::new(8000.0, Currency::USDT()));
2813 assert_eq!(usdt_balance.locked, Money::new(2000.0, Currency::USDT()));
2814 }
2815
2816 #[rstest]
2817 fn test_parse_account_state_empty_margins() {
2818 let account_json = r#"{
2820 "adjEq": "",
2821 "borrowFroz": "",
2822 "details": [{
2823 "accAvgPx": "",
2824 "availBal": "1000.0",
2825 "availEq": "1000.0",
2826 "borrowFroz": "0",
2827 "cashBal": "1000.0",
2828 "ccy": "BTC",
2829 "clSpotInUseAmt": "0",
2830 "coinUsdPrice": "50000.0",
2831 "colBorrAutoConversion": "0",
2832 "collateralEnabled": false,
2833 "collateralRestrict": false,
2834 "crossLiab": "0",
2835 "disEq": "50000.0",
2836 "eq": "1000.0",
2837 "eqUsd": "50000.0",
2838 "fixedBal": "0",
2839 "frozenBal": "0",
2840 "imr": "0",
2841 "interest": "0",
2842 "isoEq": "0",
2843 "isoLiab": "0",
2844 "isoUpl": "0",
2845 "liab": "0",
2846 "maxLoan": "0",
2847 "mgnRatio": "0",
2848 "maxSpotInUseAmt": "0",
2849 "mmr": "0",
2850 "notionalLever": "0",
2851 "openAvgPx": "",
2852 "ordFrozen": "0",
2853 "rewardBal": "0",
2854 "smtSyncEq": "0",
2855 "spotBal": "0",
2856 "spotCopyTradingEq": "0",
2857 "spotInUseAmt": "0",
2858 "spotIsoBal": "0",
2859 "spotUpl": "0",
2860 "spotUplRatio": "0",
2861 "stgyEq": "0",
2862 "totalPnl": "0",
2863 "totalPnlRatio": "0",
2864 "twap": "0",
2865 "uTime": "1704067200000",
2866 "upl": "0",
2867 "uplLiab": "0"
2868 }],
2869 "imr": "",
2870 "isoEq": "0",
2871 "mgnRatio": "",
2872 "mmr": "",
2873 "notionalUsd": "",
2874 "notionalUsdForBorrow": "",
2875 "notionalUsdForFutures": "",
2876 "notionalUsdForOption": "",
2877 "notionalUsdForSwap": "",
2878 "ordFroz": "",
2879 "totalEq": "50000.0",
2880 "uTime": "1704067200000",
2881 "upl": "0"
2882 }"#;
2883
2884 let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
2885 let account_id = AccountId::new("OKX-SPOT");
2886 let account_state =
2887 parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
2888
2889 assert_eq!(account_state.margins.len(), 0);
2891 assert_eq!(account_state.balances.len(), 1);
2892
2893 let btc_balance = &account_state.balances[0];
2895 assert_eq!(btc_balance.total, Money::new(1000.0, Currency::BTC()));
2896 }
2897
2898 #[rstest]
2899 fn test_parse_order_status_report() {
2900 let json_data = load_test_json("http_get_orders_history.json");
2901 let response: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
2902 let okx_order = response
2903 .data
2904 .first()
2905 .expect("Test data must have an order")
2906 .clone();
2907
2908 let account_id = AccountId::new("OKX-001");
2909 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2910 let order_report = parse_order_status_report(
2911 &okx_order,
2912 account_id,
2913 instrument_id,
2914 2,
2915 8,
2916 UnixNanos::default(),
2917 )
2918 .unwrap();
2919
2920 assert_eq!(order_report.account_id, account_id);
2921 assert_eq!(order_report.instrument_id, instrument_id);
2922 assert_eq!(order_report.quantity, Quantity::from("0.03000000"));
2923 assert_eq!(order_report.filled_qty, Quantity::from("0.03000000"));
2924 assert_eq!(order_report.order_side, OrderSide::Buy);
2925 assert_eq!(order_report.order_type, OrderType::Market);
2926 assert_eq!(order_report.order_status, OrderStatus::Filled);
2927 }
2928
2929 #[rstest]
2930 fn test_parse_position_status_report() {
2931 let json_data = load_test_json("http_get_positions.json");
2932 let response: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
2933 let okx_position = response
2934 .data
2935 .first()
2936 .expect("Test data must have a position")
2937 .clone();
2938
2939 let account_id = AccountId::new("OKX-001");
2940 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2941 let position_report = parse_position_status_report(
2942 okx_position,
2943 account_id,
2944 instrument_id,
2945 8,
2946 UnixNanos::default(),
2947 )
2948 .unwrap();
2949
2950 assert_eq!(position_report.account_id, account_id);
2951 assert_eq!(position_report.instrument_id, instrument_id);
2952 }
2953
2954 #[rstest]
2955 fn test_parse_trade_tick() {
2956 let json_data = load_test_json("http_get_trades.json");
2957 let response: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
2958 let okx_trade = response.data.first().expect("Test data must have a trade");
2959
2960 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2961 let trade_tick =
2962 parse_trade_tick(okx_trade, instrument_id, 2, 8, UnixNanos::default()).unwrap();
2963
2964 assert_eq!(trade_tick.instrument_id, instrument_id);
2965 assert_eq!(trade_tick.price, Price::from("102537.90"));
2966 assert_eq!(trade_tick.size, Quantity::from("0.00013669"));
2967 assert_eq!(trade_tick.aggressor_side, AggressorSide::Seller);
2968 assert_eq!(trade_tick.trade_id, TradeId::new("734864333"));
2969 }
2970
2971 #[rstest]
2972 fn test_parse_mark_price_update() {
2973 let json_data = load_test_json("http_get_mark_price.json");
2974 let response: OKXResponse<crate::http::models::OKXMarkPrice> =
2975 serde_json::from_str(&json_data).unwrap();
2976 let okx_mark_price = response
2977 .data
2978 .first()
2979 .expect("Test data must have a mark price");
2980
2981 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2982 let mark_price_update =
2983 parse_mark_price_update(okx_mark_price, instrument_id, 2, UnixNanos::default())
2984 .unwrap();
2985
2986 assert_eq!(mark_price_update.instrument_id, instrument_id);
2987 assert_eq!(mark_price_update.value, Price::from("84660.10"));
2988 assert_eq!(
2989 mark_price_update.ts_event,
2990 UnixNanos::from(1744590349506000000)
2991 );
2992 }
2993
2994 #[rstest]
2995 fn test_parse_index_price_update() {
2996 let json_data = load_test_json("http_get_index_price.json");
2997 let response: OKXResponse<crate::http::models::OKXIndexTicker> =
2998 serde_json::from_str(&json_data).unwrap();
2999 let okx_index_ticker = response
3000 .data
3001 .first()
3002 .expect("Test data must have an index ticker");
3003
3004 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3005 let index_price_update =
3006 parse_index_price_update(okx_index_ticker, instrument_id, 2, UnixNanos::default())
3007 .unwrap();
3008
3009 assert_eq!(index_price_update.instrument_id, instrument_id);
3010 assert_eq!(index_price_update.value, Price::from("103895.00"));
3011 assert_eq!(
3012 index_price_update.ts_event,
3013 UnixNanos::from(1746942707815000000)
3014 );
3015 }
3016
3017 #[rstest]
3018 fn test_parse_candlestick() {
3019 let json_data = load_test_json("http_get_candlesticks.json");
3020 let response: OKXResponse<crate::http::models::OKXCandlestick> =
3021 serde_json::from_str(&json_data).unwrap();
3022 let okx_candlestick = response
3023 .data
3024 .first()
3025 .expect("Test data must have a candlestick");
3026
3027 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3028 let bar_type = BarType::new(
3029 instrument_id,
3030 BAR_SPEC_1_DAY_LAST,
3031 AggregationSource::External,
3032 );
3033 let bar = parse_candlestick(okx_candlestick, bar_type, 2, 8, UnixNanos::default()).unwrap();
3034
3035 assert_eq!(bar.bar_type, bar_type);
3036 assert_eq!(bar.open, Price::from("33528.60"));
3037 assert_eq!(bar.high, Price::from("33870.00"));
3038 assert_eq!(bar.low, Price::from("33528.60"));
3039 assert_eq!(bar.close, Price::from("33783.90"));
3040 assert_eq!(bar.volume, Quantity::from("778.83800000"));
3041 assert_eq!(bar.ts_event, UnixNanos::from(1625097600000000000));
3042 }
3043
3044 #[rstest]
3045 fn test_parse_millisecond_timestamp() {
3046 let timestamp_ms = 1625097600000u64;
3047 let result = parse_millisecond_timestamp(timestamp_ms);
3048 assert_eq!(result, UnixNanos::from(1625097600000000000));
3049 }
3050
3051 #[rstest]
3052 fn test_parse_rfc3339_timestamp() {
3053 let timestamp_str = "2021-07-01T00:00:00.000Z";
3054 let result = parse_rfc3339_timestamp(timestamp_str).unwrap();
3055 assert_eq!(result, UnixNanos::from(1625097600000000000));
3056
3057 let timestamp_str_tz = "2021-07-01T08:00:00.000+08:00";
3059 let result_tz = parse_rfc3339_timestamp(timestamp_str_tz).unwrap();
3060 assert_eq!(result_tz, UnixNanos::from(1625097600000000000));
3061
3062 let invalid_timestamp = "invalid-timestamp";
3064 assert!(parse_rfc3339_timestamp(invalid_timestamp).is_err());
3065 }
3066
3067 #[rstest]
3068 fn test_parse_price() {
3069 let price_str = "42219.5";
3070 let precision = 2;
3071 let result = parse_price(price_str, precision).unwrap();
3072 assert_eq!(result, Price::from("42219.50"));
3073
3074 let invalid_price = "invalid-price";
3076 assert!(parse_price(invalid_price, precision).is_err());
3077 }
3078
3079 #[rstest]
3080 fn test_parse_quantity() {
3081 let quantity_str = "0.12345678";
3082 let precision = 8;
3083 let result = parse_quantity(quantity_str, precision).unwrap();
3084 assert_eq!(result, Quantity::from("0.12345678"));
3085
3086 let invalid_quantity = "invalid-quantity";
3088 assert!(parse_quantity(invalid_quantity, precision).is_err());
3089 }
3090
3091 #[rstest]
3092 fn test_parse_aggressor_side() {
3093 assert_eq!(
3094 parse_aggressor_side(&Some(OKXSide::Buy)),
3095 AggressorSide::Buyer
3096 );
3097 assert_eq!(
3098 parse_aggressor_side(&Some(OKXSide::Sell)),
3099 AggressorSide::Seller
3100 );
3101 assert_eq!(parse_aggressor_side(&None), AggressorSide::NoAggressor);
3102 }
3103
3104 #[rstest]
3105 fn test_parse_execution_type() {
3106 assert_eq!(
3107 parse_execution_type(&Some(OKXExecType::Maker)),
3108 LiquiditySide::Maker
3109 );
3110 assert_eq!(
3111 parse_execution_type(&Some(OKXExecType::Taker)),
3112 LiquiditySide::Taker
3113 );
3114 assert_eq!(parse_execution_type(&None), LiquiditySide::NoLiquiditySide);
3115 }
3116
3117 #[rstest]
3118 fn test_parse_position_side() {
3119 assert_eq!(parse_position_side(Some(100)), PositionSide::Long);
3120 assert_eq!(parse_position_side(Some(-100)), PositionSide::Short);
3121 assert_eq!(parse_position_side(Some(0)), PositionSide::Flat);
3122 assert_eq!(parse_position_side(None), PositionSide::Flat);
3123 }
3124
3125 #[rstest]
3126 fn test_parse_client_order_id() {
3127 let valid_id = "client_order_123";
3128 let result = parse_client_order_id(valid_id);
3129 assert_eq!(result, Some(ClientOrderId::new(valid_id)));
3130
3131 let empty_id = "";
3132 let result_empty = parse_client_order_id(empty_id);
3133 assert_eq!(result_empty, None);
3134 }
3135
3136 #[rstest]
3137 fn test_deserialize_empty_string_as_none() {
3138 let json_with_empty = r#""""#;
3139 let result: Option<String> = serde_json::from_str(json_with_empty).unwrap();
3140 let processed = result.filter(|s| !s.is_empty());
3141 assert_eq!(processed, None);
3142
3143 let json_with_value = r#""test_value""#;
3144 let result: Option<String> = serde_json::from_str(json_with_value).unwrap();
3145 let processed = result.filter(|s| !s.is_empty());
3146 assert_eq!(processed, Some("test_value".to_string()));
3147 }
3148
3149 #[rstest]
3150 fn test_deserialize_string_to_u64() {
3151 use serde::Deserialize;
3152
3153 #[derive(Deserialize)]
3154 struct TestStruct {
3155 #[serde(deserialize_with = "deserialize_string_to_u64")]
3156 value: u64,
3157 }
3158
3159 let json_value = r#"{"value": "12345"}"#;
3160 let result: TestStruct = serde_json::from_str(json_value).unwrap();
3161 assert_eq!(result.value, 12345);
3162
3163 let json_empty = r#"{"value": ""}"#;
3164 let result_empty: TestStruct = serde_json::from_str(json_empty).unwrap();
3165 assert_eq!(result_empty.value, 0);
3166 }
3167
3168 #[rstest]
3169 fn test_fill_report_parsing() {
3170 let transaction_detail = crate::http::models::OKXTransactionDetail {
3172 inst_type: OKXInstrumentType::Spot,
3173 inst_id: Ustr::from("BTC-USDT"),
3174 trade_id: Ustr::from("12345"),
3175 ord_id: Ustr::from("67890"),
3176 cl_ord_id: Ustr::from("client_123"),
3177 bill_id: Ustr::from("bill_456"),
3178 fill_px: "42219.5".to_string(),
3179 fill_sz: "0.001".to_string(),
3180 side: OKXSide::Buy,
3181 exec_type: OKXExecType::Taker,
3182 fee_ccy: "USDT".to_string(),
3183 fee: Some("0.042".to_string()),
3184 ts: 1625097600000,
3185 };
3186
3187 let account_id = AccountId::new("OKX-001");
3188 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3189 let fill_report = parse_fill_report(
3190 transaction_detail,
3191 account_id,
3192 instrument_id,
3193 2,
3194 8,
3195 UnixNanos::default(),
3196 )
3197 .unwrap();
3198
3199 assert_eq!(fill_report.account_id, account_id);
3200 assert_eq!(fill_report.instrument_id, instrument_id);
3201 assert_eq!(fill_report.trade_id, TradeId::new("12345"));
3202 assert_eq!(fill_report.venue_order_id, VenueOrderId::new("67890"));
3203 assert_eq!(fill_report.order_side, OrderSide::Buy);
3204 assert_eq!(fill_report.last_px, Price::from("42219.50"));
3205 assert_eq!(fill_report.last_qty, Quantity::from("0.00100000"));
3206 assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
3207 }
3208
3209 #[rstest]
3210 fn test_bar_type_identity_preserved_through_parse() {
3211 use std::str::FromStr;
3212
3213 use crate::http::models::OKXCandlestick;
3214
3215 let bar_type = BarType::from_str("ETH-USDT-SWAP.OKX-1-MINUTE-LAST-EXTERNAL").unwrap();
3217
3218 let raw_candlestick = OKXCandlestick(
3220 "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(), );
3230
3231 let bar =
3233 parse_candlestick(&raw_candlestick, bar_type, 1, 3, UnixNanos::default()).unwrap();
3234
3235 assert_eq!(
3237 bar.bar_type, bar_type,
3238 "BarType must be preserved exactly through parsing"
3239 );
3240 }
3241
3242 #[rstest]
3243 fn test_deserialize_vip_level_all_formats() {
3244 use serde::Deserialize;
3245 use serde_json;
3246
3247 #[derive(Deserialize)]
3248 struct TestFeeRate {
3249 #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3250 level: OKXVipLevel,
3251 }
3252
3253 let json = r#"{"level":"VIP4"}"#;
3255 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3256 assert_eq!(result.level, OKXVipLevel::Vip4);
3257
3258 let json = r#"{"level":"VIP5"}"#;
3259 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3260 assert_eq!(result.level, OKXVipLevel::Vip5);
3261
3262 let json = r#"{"level":"Lv1"}"#;
3264 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3265 assert_eq!(result.level, OKXVipLevel::Vip1);
3266
3267 let json = r#"{"level":"Lv0"}"#;
3268 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3269 assert_eq!(result.level, OKXVipLevel::Vip0);
3270
3271 let json = r#"{"level":"Lv9"}"#;
3272 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3273 assert_eq!(result.level, OKXVipLevel::Vip9);
3274 }
3275
3276 #[rstest]
3277 fn test_deserialize_vip_level_empty_string() {
3278 use serde::Deserialize;
3279 use serde_json;
3280
3281 #[derive(Deserialize)]
3282 struct TestFeeRate {
3283 #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3284 level: OKXVipLevel,
3285 }
3286
3287 let json = r#"{"level":""}"#;
3289 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3290 assert_eq!(result.level, OKXVipLevel::Vip0);
3291 }
3292
3293 #[rstest]
3294 fn test_deserialize_vip_level_without_prefix() {
3295 use serde::Deserialize;
3296 use serde_json;
3297
3298 #[derive(Deserialize)]
3299 struct TestFeeRate {
3300 #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3301 level: OKXVipLevel,
3302 }
3303
3304 let json = r#"{"level":"5"}"#;
3305 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3306 assert_eq!(result.level, OKXVipLevel::Vip5);
3307 }
3308
3309 #[rstest]
3310 fn test_parse_position_status_report_net_mode_long() {
3311 let position = OKXPosition {
3313 inst_id: Ustr::from("BTC-USDT-SWAP"),
3314 inst_type: OKXInstrumentType::Swap,
3315 mgn_mode: OKXMarginMode::Cross,
3316 pos_id: Some(Ustr::from("12345")),
3317 pos_side: OKXPositionSide::Net, pos: "1.5".to_string(), base_bal: "1.5".to_string(),
3320 ccy: "BTC".to_string(),
3321 fee: "0.01".to_string(),
3322 lever: "10.0".to_string(),
3323 last: "50000".to_string(),
3324 mark_px: "50000".to_string(),
3325 liq_px: "45000".to_string(),
3326 mmr: "0.1".to_string(),
3327 interest: "0".to_string(),
3328 trade_id: Ustr::from("111"),
3329 notional_usd: "75000".to_string(),
3330 avg_px: "50000".to_string(),
3331 upl: "0".to_string(),
3332 upl_ratio: "0".to_string(),
3333 u_time: 1622559930237,
3334 margin: "0.5".to_string(),
3335 mgn_ratio: "0.01".to_string(),
3336 adl: "0".to_string(),
3337 c_time: "1622559930237".to_string(),
3338 realized_pnl: "0".to_string(),
3339 upl_last_px: "0".to_string(),
3340 upl_ratio_last_px: "0".to_string(),
3341 avail_pos: "1.5".to_string(),
3342 be_px: "0".to_string(),
3343 funding_fee: "0".to_string(),
3344 idx_px: "0".to_string(),
3345 liq_penalty: "0".to_string(),
3346 opt_val: "0".to_string(),
3347 pending_close_ord_liab_val: "0".to_string(),
3348 pnl: "0".to_string(),
3349 pos_ccy: "BTC".to_string(),
3350 quote_bal: "75000".to_string(),
3351 quote_borrowed: "0".to_string(),
3352 quote_interest: "0".to_string(),
3353 spot_in_use_amt: "0".to_string(),
3354 spot_in_use_ccy: "BTC".to_string(),
3355 usd_px: "50000".to_string(),
3356 };
3357
3358 let account_id = AccountId::new("OKX-001");
3359 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3360 let report = parse_position_status_report(
3361 position,
3362 account_id,
3363 instrument_id,
3364 8,
3365 UnixNanos::default(),
3366 )
3367 .unwrap();
3368
3369 assert_eq!(report.account_id, account_id);
3370 assert_eq!(report.instrument_id, instrument_id);
3371 assert_eq!(report.position_side, PositionSide::Long.as_specified());
3372 assert_eq!(report.quantity, Quantity::from("1.5"));
3373 assert_eq!(report.venue_position_id, None);
3375 }
3376
3377 #[rstest]
3378 fn test_parse_position_status_report_net_mode_short() {
3379 let position = OKXPosition {
3381 inst_id: Ustr::from("BTC-USDT-SWAP"),
3382 inst_type: OKXInstrumentType::Swap,
3383 mgn_mode: OKXMarginMode::Isolated,
3384 pos_id: Some(Ustr::from("67890")),
3385 pos_side: OKXPositionSide::Net, pos: "-2.3".to_string(), base_bal: "2.3".to_string(),
3388 ccy: "BTC".to_string(),
3389 fee: "0.02".to_string(),
3390 lever: "5.0".to_string(),
3391 last: "50000".to_string(),
3392 mark_px: "50000".to_string(),
3393 liq_px: "55000".to_string(),
3394 mmr: "0.2".to_string(),
3395 interest: "0".to_string(),
3396 trade_id: Ustr::from("222"),
3397 notional_usd: "115000".to_string(),
3398 avg_px: "50000".to_string(),
3399 upl: "0".to_string(),
3400 upl_ratio: "0".to_string(),
3401 u_time: 1622559930237,
3402 margin: "1.0".to_string(),
3403 mgn_ratio: "0.02".to_string(),
3404 adl: "0".to_string(),
3405 c_time: "1622559930237".to_string(),
3406 realized_pnl: "0".to_string(),
3407 upl_last_px: "0".to_string(),
3408 upl_ratio_last_px: "0".to_string(),
3409 avail_pos: "2.3".to_string(),
3410 be_px: "0".to_string(),
3411 funding_fee: "0".to_string(),
3412 idx_px: "0".to_string(),
3413 liq_penalty: "0".to_string(),
3414 opt_val: "0".to_string(),
3415 pending_close_ord_liab_val: "0".to_string(),
3416 pnl: "0".to_string(),
3417 pos_ccy: "BTC".to_string(),
3418 quote_bal: "115000".to_string(),
3419 quote_borrowed: "0".to_string(),
3420 quote_interest: "0".to_string(),
3421 spot_in_use_amt: "0".to_string(),
3422 spot_in_use_ccy: "BTC".to_string(),
3423 usd_px: "50000".to_string(),
3424 };
3425
3426 let account_id = AccountId::new("OKX-001");
3427 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3428 let report = parse_position_status_report(
3429 position,
3430 account_id,
3431 instrument_id,
3432 8,
3433 UnixNanos::default(),
3434 )
3435 .unwrap();
3436
3437 assert_eq!(report.account_id, account_id);
3438 assert_eq!(report.instrument_id, instrument_id);
3439 assert_eq!(report.position_side, PositionSide::Short.as_specified());
3440 assert_eq!(report.quantity, Quantity::from("2.3")); assert_eq!(report.venue_position_id, None);
3443 }
3444
3445 #[rstest]
3446 fn test_parse_position_status_report_net_mode_flat() {
3447 let position = OKXPosition {
3449 inst_id: Ustr::from("ETH-USDT-SWAP"),
3450 inst_type: OKXInstrumentType::Swap,
3451 mgn_mode: OKXMarginMode::Cross,
3452 pos_id: Some(Ustr::from("99999")),
3453 pos_side: OKXPositionSide::Net, pos: "0".to_string(), base_bal: "0".to_string(),
3456 ccy: "ETH".to_string(),
3457 fee: "0".to_string(),
3458 lever: "10.0".to_string(),
3459 last: "3000".to_string(),
3460 mark_px: "3000".to_string(),
3461 liq_px: "0".to_string(),
3462 mmr: "0".to_string(),
3463 interest: "0".to_string(),
3464 trade_id: Ustr::from("333"),
3465 notional_usd: "0".to_string(),
3466 avg_px: String::new(),
3467 upl: "0".to_string(),
3468 upl_ratio: "0".to_string(),
3469 u_time: 1622559930237,
3470 margin: "0".to_string(),
3471 mgn_ratio: "0".to_string(),
3472 adl: "0".to_string(),
3473 c_time: "1622559930237".to_string(),
3474 realized_pnl: "0".to_string(),
3475 upl_last_px: "0".to_string(),
3476 upl_ratio_last_px: "0".to_string(),
3477 avail_pos: "0".to_string(),
3478 be_px: "0".to_string(),
3479 funding_fee: "0".to_string(),
3480 idx_px: "0".to_string(),
3481 liq_penalty: "0".to_string(),
3482 opt_val: "0".to_string(),
3483 pending_close_ord_liab_val: "0".to_string(),
3484 pnl: "0".to_string(),
3485 pos_ccy: "ETH".to_string(),
3486 quote_bal: "0".to_string(),
3487 quote_borrowed: "0".to_string(),
3488 quote_interest: "0".to_string(),
3489 spot_in_use_amt: "0".to_string(),
3490 spot_in_use_ccy: "ETH".to_string(),
3491 usd_px: "3000".to_string(),
3492 };
3493
3494 let account_id = AccountId::new("OKX-001");
3495 let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
3496 let report = parse_position_status_report(
3497 position,
3498 account_id,
3499 instrument_id,
3500 8,
3501 UnixNanos::default(),
3502 )
3503 .unwrap();
3504
3505 assert_eq!(report.account_id, account_id);
3506 assert_eq!(report.instrument_id, instrument_id);
3507 assert_eq!(report.position_side, PositionSide::Flat.as_specified());
3508 assert_eq!(report.quantity, Quantity::from("0"));
3509 assert_eq!(report.venue_position_id, None);
3511 }
3512
3513 #[rstest]
3514 fn test_parse_position_status_report_long_short_mode_long() {
3515 let position = OKXPosition {
3517 inst_id: Ustr::from("BTC-USDT-SWAP"),
3518 inst_type: OKXInstrumentType::Swap,
3519 mgn_mode: OKXMarginMode::Cross,
3520 pos_id: Some(Ustr::from("11111")),
3521 pos_side: OKXPositionSide::Long, pos: "3.2".to_string(), base_bal: "3.2".to_string(),
3524 ccy: "BTC".to_string(),
3525 fee: "0.01".to_string(),
3526 lever: "10.0".to_string(),
3527 last: "50000".to_string(),
3528 mark_px: "50000".to_string(),
3529 liq_px: "45000".to_string(),
3530 mmr: "0.1".to_string(),
3531 interest: "0".to_string(),
3532 trade_id: Ustr::from("444"),
3533 notional_usd: "160000".to_string(),
3534 avg_px: "50000".to_string(),
3535 upl: "0".to_string(),
3536 upl_ratio: "0".to_string(),
3537 u_time: 1622559930237,
3538 margin: "1.6".to_string(),
3539 mgn_ratio: "0.01".to_string(),
3540 adl: "0".to_string(),
3541 c_time: "1622559930237".to_string(),
3542 realized_pnl: "0".to_string(),
3543 upl_last_px: "0".to_string(),
3544 upl_ratio_last_px: "0".to_string(),
3545 avail_pos: "3.2".to_string(),
3546 be_px: "0".to_string(),
3547 funding_fee: "0".to_string(),
3548 idx_px: "0".to_string(),
3549 liq_penalty: "0".to_string(),
3550 opt_val: "0".to_string(),
3551 pending_close_ord_liab_val: "0".to_string(),
3552 pnl: "0".to_string(),
3553 pos_ccy: "BTC".to_string(),
3554 quote_bal: "160000".to_string(),
3555 quote_borrowed: "0".to_string(),
3556 quote_interest: "0".to_string(),
3557 spot_in_use_amt: "0".to_string(),
3558 spot_in_use_ccy: "BTC".to_string(),
3559 usd_px: "50000".to_string(),
3560 };
3561
3562 let account_id = AccountId::new("OKX-001");
3563 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3564 let report = parse_position_status_report(
3565 position,
3566 account_id,
3567 instrument_id,
3568 8,
3569 UnixNanos::default(),
3570 )
3571 .unwrap();
3572
3573 assert_eq!(report.account_id, account_id);
3574 assert_eq!(report.instrument_id, instrument_id);
3575 assert_eq!(report.position_side, PositionSide::Long.as_specified());
3576 assert_eq!(report.quantity, Quantity::from("3.2"));
3577 assert_eq!(
3579 report.venue_position_id,
3580 Some(PositionId::new("11111-LONG"))
3581 );
3582 }
3583
3584 #[rstest]
3585 fn test_parse_position_status_report_long_short_mode_short() {
3586 let position = OKXPosition {
3589 inst_id: Ustr::from("BTC-USDT-SWAP"),
3590 inst_type: OKXInstrumentType::Swap,
3591 mgn_mode: OKXMarginMode::Cross,
3592 pos_id: Some(Ustr::from("22222")),
3593 pos_side: OKXPositionSide::Short, pos: "1.8".to_string(), base_bal: "1.8".to_string(),
3596 ccy: "BTC".to_string(),
3597 fee: "0.02".to_string(),
3598 lever: "10.0".to_string(),
3599 last: "50000".to_string(),
3600 mark_px: "50000".to_string(),
3601 liq_px: "55000".to_string(),
3602 mmr: "0.2".to_string(),
3603 interest: "0".to_string(),
3604 trade_id: Ustr::from("555"),
3605 notional_usd: "90000".to_string(),
3606 avg_px: "50000".to_string(),
3607 upl: "0".to_string(),
3608 upl_ratio: "0".to_string(),
3609 u_time: 1622559930237,
3610 margin: "0.9".to_string(),
3611 mgn_ratio: "0.02".to_string(),
3612 adl: "0".to_string(),
3613 c_time: "1622559930237".to_string(),
3614 realized_pnl: "0".to_string(),
3615 upl_last_px: "0".to_string(),
3616 upl_ratio_last_px: "0".to_string(),
3617 avail_pos: "1.8".to_string(),
3618 be_px: "0".to_string(),
3619 funding_fee: "0".to_string(),
3620 idx_px: "0".to_string(),
3621 liq_penalty: "0".to_string(),
3622 opt_val: "0".to_string(),
3623 pending_close_ord_liab_val: "0".to_string(),
3624 pnl: "0".to_string(),
3625 pos_ccy: "BTC".to_string(),
3626 quote_bal: "90000".to_string(),
3627 quote_borrowed: "0".to_string(),
3628 quote_interest: "0".to_string(),
3629 spot_in_use_amt: "0".to_string(),
3630 spot_in_use_ccy: "BTC".to_string(),
3631 usd_px: "50000".to_string(),
3632 };
3633
3634 let account_id = AccountId::new("OKX-001");
3635 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3636 let report = parse_position_status_report(
3637 position,
3638 account_id,
3639 instrument_id,
3640 8,
3641 UnixNanos::default(),
3642 )
3643 .unwrap();
3644
3645 assert_eq!(report.account_id, account_id);
3646 assert_eq!(report.instrument_id, instrument_id);
3647 assert_eq!(report.position_side, PositionSide::Short.as_specified());
3649 assert_eq!(report.quantity, Quantity::from("1.8"));
3650 assert_eq!(
3652 report.venue_position_id,
3653 Some(PositionId::new("22222-SHORT"))
3654 );
3655 }
3656
3657 #[rstest]
3658 fn test_parse_position_status_report_margin_long() {
3659 let position = OKXPosition {
3661 inst_id: Ustr::from("ETH-USDT"),
3662 inst_type: OKXInstrumentType::Margin,
3663 mgn_mode: OKXMarginMode::Cross,
3664 pos_id: Some(Ustr::from("margin-long-1")),
3665 pos_side: OKXPositionSide::Net,
3666 pos: "1.5".to_string(), base_bal: "1.5".to_string(),
3668 ccy: "ETH".to_string(),
3669 fee: "0".to_string(),
3670 lever: "3".to_string(),
3671 last: "4000".to_string(),
3672 mark_px: "4000".to_string(),
3673 liq_px: "3500".to_string(),
3674 mmr: "0.1".to_string(),
3675 interest: "0".to_string(),
3676 trade_id: Ustr::from("trade1"),
3677 notional_usd: "6000".to_string(),
3678 avg_px: "3800".to_string(), upl: "300".to_string(),
3680 upl_ratio: "0.05".to_string(),
3681 u_time: 1622559930237,
3682 margin: "2000".to_string(),
3683 mgn_ratio: "0.33".to_string(),
3684 adl: "0".to_string(),
3685 c_time: "1622559930237".to_string(),
3686 realized_pnl: "0".to_string(),
3687 upl_last_px: "300".to_string(),
3688 upl_ratio_last_px: "0.05".to_string(),
3689 avail_pos: "1.5".to_string(),
3690 be_px: "3800".to_string(),
3691 funding_fee: "0".to_string(),
3692 idx_px: "4000".to_string(),
3693 liq_penalty: "0".to_string(),
3694 opt_val: "0".to_string(),
3695 pending_close_ord_liab_val: "0".to_string(),
3696 pnl: "300".to_string(),
3697 pos_ccy: "ETH".to_string(), quote_bal: "0".to_string(),
3699 quote_borrowed: "0".to_string(),
3700 quote_interest: "0".to_string(),
3701 spot_in_use_amt: "0".to_string(),
3702 spot_in_use_ccy: String::new(),
3703 usd_px: "4000".to_string(),
3704 };
3705
3706 let account_id = AccountId::new("OKX-001");
3707 let instrument_id = InstrumentId::from("ETH-USDT.OKX");
3708 let report = parse_position_status_report(
3709 position,
3710 account_id,
3711 instrument_id,
3712 4,
3713 UnixNanos::default(),
3714 )
3715 .unwrap();
3716
3717 assert_eq!(report.account_id, account_id);
3718 assert_eq!(report.instrument_id, instrument_id);
3719 assert_eq!(report.position_side, PositionSide::Long.as_specified());
3720 assert_eq!(report.quantity, Quantity::from("1.5")); assert_eq!(report.venue_position_id, None); }
3723
3724 #[rstest]
3725 fn test_parse_position_status_report_margin_short() {
3726 let position = OKXPosition {
3729 inst_id: Ustr::from("ETH-USDT"),
3730 inst_type: OKXInstrumentType::Margin,
3731 mgn_mode: OKXMarginMode::Cross,
3732 pos_id: Some(Ustr::from("margin-short-1")),
3733 pos_side: OKXPositionSide::Net,
3734 pos: "244.56".to_string(), base_bal: "0".to_string(),
3736 ccy: "USDT".to_string(),
3737 fee: "0".to_string(),
3738 lever: "3".to_string(),
3739 last: "4092".to_string(),
3740 mark_px: "4092".to_string(),
3741 liq_px: "4500".to_string(),
3742 mmr: "0.1".to_string(),
3743 interest: "0".to_string(),
3744 trade_id: Ustr::from("trade2"),
3745 notional_usd: "244.56".to_string(),
3746 avg_px: "4092".to_string(), upl: "-10".to_string(),
3748 upl_ratio: "-0.04".to_string(),
3749 u_time: 1622559930237,
3750 margin: "100".to_string(),
3751 mgn_ratio: "0.4".to_string(),
3752 adl: "0".to_string(),
3753 c_time: "1622559930237".to_string(),
3754 realized_pnl: "0".to_string(),
3755 upl_last_px: "-10".to_string(),
3756 upl_ratio_last_px: "-0.04".to_string(),
3757 avail_pos: "244.56".to_string(),
3758 be_px: "4092".to_string(),
3759 funding_fee: "0".to_string(),
3760 idx_px: "4092".to_string(),
3761 liq_penalty: "0".to_string(),
3762 opt_val: "0".to_string(),
3763 pending_close_ord_liab_val: "0".to_string(),
3764 pnl: "-10".to_string(),
3765 pos_ccy: "USDT".to_string(), quote_bal: "244.56".to_string(),
3767 quote_borrowed: "0".to_string(),
3768 quote_interest: "0".to_string(),
3769 spot_in_use_amt: "0".to_string(),
3770 spot_in_use_ccy: String::new(),
3771 usd_px: "4092".to_string(),
3772 };
3773
3774 let account_id = AccountId::new("OKX-001");
3775 let instrument_id = InstrumentId::from("ETH-USDT.OKX");
3776 let report = parse_position_status_report(
3777 position,
3778 account_id,
3779 instrument_id,
3780 4,
3781 UnixNanos::default(),
3782 )
3783 .unwrap();
3784
3785 assert_eq!(report.account_id, account_id);
3786 assert_eq!(report.instrument_id, instrument_id);
3787 assert_eq!(report.position_side, PositionSide::Short.as_specified());
3788 assert_eq!(report.quantity.to_string(), "0.0598");
3790 assert_eq!(report.venue_position_id, None); }
3792
3793 #[rstest]
3794 fn test_parse_position_status_report_margin_flat() {
3795 let position = OKXPosition {
3797 inst_id: Ustr::from("ETH-USDT"),
3798 inst_type: OKXInstrumentType::Margin,
3799 mgn_mode: OKXMarginMode::Cross,
3800 pos_id: Some(Ustr::from("margin-flat-1")),
3801 pos_side: OKXPositionSide::Net,
3802 pos: "0".to_string(),
3803 base_bal: "0".to_string(),
3804 ccy: "ETH".to_string(),
3805 fee: "0".to_string(),
3806 lever: "0".to_string(),
3807 last: "4000".to_string(),
3808 mark_px: "4000".to_string(),
3809 liq_px: "0".to_string(),
3810 mmr: "0".to_string(),
3811 interest: "0".to_string(),
3812 trade_id: Ustr::from(""),
3813 notional_usd: "0".to_string(),
3814 avg_px: String::new(),
3815 upl: "0".to_string(),
3816 upl_ratio: "0".to_string(),
3817 u_time: 1622559930237,
3818 margin: "0".to_string(),
3819 mgn_ratio: "0".to_string(),
3820 adl: "0".to_string(),
3821 c_time: "1622559930237".to_string(),
3822 realized_pnl: "0".to_string(),
3823 upl_last_px: "0".to_string(),
3824 upl_ratio_last_px: "0".to_string(),
3825 avail_pos: "0".to_string(),
3826 be_px: "0".to_string(),
3827 funding_fee: "0".to_string(),
3828 idx_px: "0".to_string(),
3829 liq_penalty: "0".to_string(),
3830 opt_val: "0".to_string(),
3831 pending_close_ord_liab_val: "0".to_string(),
3832 pnl: "0".to_string(),
3833 pos_ccy: String::new(), quote_bal: "0".to_string(),
3835 quote_borrowed: "0".to_string(),
3836 quote_interest: "0".to_string(),
3837 spot_in_use_amt: "0".to_string(),
3838 spot_in_use_ccy: String::new(),
3839 usd_px: "0".to_string(),
3840 };
3841
3842 let account_id = AccountId::new("OKX-001");
3843 let instrument_id = InstrumentId::from("ETH-USDT.OKX");
3844 let report = parse_position_status_report(
3845 position,
3846 account_id,
3847 instrument_id,
3848 4,
3849 UnixNanos::default(),
3850 )
3851 .unwrap();
3852
3853 assert_eq!(report.account_id, account_id);
3854 assert_eq!(report.instrument_id, instrument_id);
3855 assert_eq!(report.position_side, PositionSide::Flat.as_specified());
3856 assert_eq!(report.quantity, Quantity::from("0"));
3857 assert_eq!(report.venue_position_id, None); }
3859
3860 #[rstest]
3861 fn test_parse_swap_instrument_empty_underlying_returns_error() {
3862 let instrument = OKXInstrument {
3863 inst_type: OKXInstrumentType::Swap,
3864 inst_id: Ustr::from("ETH-USD_UM-SWAP"),
3865 uly: Ustr::from(""), inst_family: Ustr::from(""),
3867 base_ccy: Ustr::from(""),
3868 quote_ccy: Ustr::from(""),
3869 settle_ccy: Ustr::from("USD"),
3870 ct_val: "1".to_string(),
3871 ct_mult: "1".to_string(),
3872 ct_val_ccy: "USD".to_string(),
3873 opt_type: crate::common::enums::OKXOptionType::None,
3874 stk: String::new(),
3875 list_time: None,
3876 exp_time: None,
3877 lever: String::new(),
3878 tick_sz: "0.1".to_string(),
3879 lot_sz: "1".to_string(),
3880 min_sz: "1".to_string(),
3881 ct_type: OKXContractType::Linear,
3882 state: crate::common::enums::OKXInstrumentStatus::Preopen,
3883 rule_type: String::new(),
3884 max_lmt_sz: String::new(),
3885 max_mkt_sz: String::new(),
3886 max_lmt_amt: String::new(),
3887 max_mkt_amt: String::new(),
3888 max_twap_sz: String::new(),
3889 max_iceberg_sz: String::new(),
3890 max_trigger_sz: String::new(),
3891 max_stop_sz: String::new(),
3892 };
3893
3894 let result =
3895 parse_swap_instrument(&instrument, None, None, None, None, UnixNanos::default());
3896 assert!(result.is_err());
3897 assert!(result.unwrap_err().to_string().contains("Empty underlying"));
3898 }
3899
3900 #[rstest]
3901 fn test_parse_futures_instrument_empty_underlying_returns_error() {
3902 let instrument = OKXInstrument {
3903 inst_type: OKXInstrumentType::Futures,
3904 inst_id: Ustr::from("ETH-USD_UM-250328"),
3905 uly: Ustr::from(""), inst_family: Ustr::from(""),
3907 base_ccy: Ustr::from(""),
3908 quote_ccy: Ustr::from(""),
3909 settle_ccy: Ustr::from("USD"),
3910 ct_val: "1".to_string(),
3911 ct_mult: "1".to_string(),
3912 ct_val_ccy: "USD".to_string(),
3913 opt_type: crate::common::enums::OKXOptionType::None,
3914 stk: String::new(),
3915 list_time: None,
3916 exp_time: Some(1743004800000),
3917 lever: String::new(),
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::Preopen,
3923 rule_type: String::new(),
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 };
3933
3934 let result =
3935 parse_futures_instrument(&instrument, None, None, None, None, UnixNanos::default());
3936 assert!(result.is_err());
3937 assert!(result.unwrap_err().to_string().contains("Empty underlying"));
3938 }
3939
3940 #[rstest]
3941 fn test_parse_option_instrument_empty_underlying_returns_error() {
3942 let instrument = OKXInstrument {
3943 inst_type: OKXInstrumentType::Option,
3944 inst_id: Ustr::from("BTC-USD-250328-50000-C"),
3945 uly: Ustr::from(""), inst_family: Ustr::from(""),
3947 base_ccy: Ustr::from(""),
3948 quote_ccy: Ustr::from(""),
3949 settle_ccy: Ustr::from("USD"),
3950 ct_val: "0.01".to_string(),
3951 ct_mult: "1".to_string(),
3952 ct_val_ccy: "BTC".to_string(),
3953 opt_type: crate::common::enums::OKXOptionType::Call,
3954 stk: "50000".to_string(),
3955 list_time: None,
3956 exp_time: Some(1743004800000),
3957 lever: String::new(),
3958 tick_sz: "0.0005".to_string(),
3959 lot_sz: "0.1".to_string(),
3960 min_sz: "0.1".to_string(),
3961 ct_type: OKXContractType::Linear,
3962 state: crate::common::enums::OKXInstrumentStatus::Preopen,
3963 rule_type: String::new(),
3964 max_lmt_sz: String::new(),
3965 max_mkt_sz: String::new(),
3966 max_lmt_amt: String::new(),
3967 max_mkt_amt: String::new(),
3968 max_twap_sz: String::new(),
3969 max_iceberg_sz: String::new(),
3970 max_trigger_sz: String::new(),
3971 max_stop_sz: String::new(),
3972 };
3973
3974 let result =
3975 parse_option_instrument(&instrument, None, None, None, None, UnixNanos::default());
3976 assert!(result.is_err());
3977 assert!(result.unwrap_err().to_string().contains("Empty underlying"));
3978 }
3979
3980 #[rstest]
3981 fn test_parse_spot_margin_position_from_balance_short_usdt() {
3982 let balance = OKXBalanceDetail {
3983 ccy: Ustr::from("ENA"),
3984 liab: "130047.3610487126".to_string(),
3985 spot_in_use_amt: "-129950".to_string(),
3986 cross_liab: "130047.3610487126".to_string(),
3987 eq: "-130047.3610487126".to_string(),
3988 u_time: 1704067200000,
3989 avail_bal: "0".to_string(),
3990 avail_eq: "0".to_string(),
3991 borrow_froz: "0".to_string(),
3992 cash_bal: "0".to_string(),
3993 dis_eq: "0".to_string(),
3994 eq_usd: "0".to_string(),
3995 smt_sync_eq: "0".to_string(),
3996 spot_copy_trading_eq: "0".to_string(),
3997 fixed_bal: "0".to_string(),
3998 frozen_bal: "0".to_string(),
3999 imr: "0".to_string(),
4000 interest: "0".to_string(),
4001 iso_eq: "0".to_string(),
4002 iso_liab: "0".to_string(),
4003 iso_upl: "0".to_string(),
4004 max_loan: "0".to_string(),
4005 mgn_ratio: "0".to_string(),
4006 mmr: "0".to_string(),
4007 notional_lever: "0".to_string(),
4008 ord_frozen: "0".to_string(),
4009 reward_bal: "0".to_string(),
4010 cl_spot_in_use_amt: "0".to_string(),
4011 max_spot_in_use_amt: "0".to_string(),
4012 spot_iso_bal: "0".to_string(),
4013 stgy_eq: "0".to_string(),
4014 twap: "0".to_string(),
4015 upl: "0".to_string(),
4016 upl_liab: "0".to_string(),
4017 spot_bal: "0".to_string(),
4018 open_avg_px: "0".to_string(),
4019 acc_avg_px: "0".to_string(),
4020 spot_upl: "0".to_string(),
4021 spot_upl_ratio: "0".to_string(),
4022 total_pnl: "0".to_string(),
4023 total_pnl_ratio: "0".to_string(),
4024 };
4025
4026 let account_id = AccountId::new("OKX-001");
4027 let size_precision = 2;
4028 let ts_init = UnixNanos::default();
4029
4030 let result = parse_spot_margin_position_from_balance(
4031 &balance,
4032 account_id,
4033 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4034 size_precision,
4035 ts_init,
4036 )
4037 .unwrap();
4038
4039 assert!(result.is_some());
4040 let report = result.unwrap();
4041 assert_eq!(report.account_id, account_id);
4042 assert_eq!(report.instrument_id.to_string(), "ENA-USDT.OKX".to_string());
4043 assert_eq!(report.position_side, PositionSide::Short.as_specified());
4044 assert_eq!(report.quantity.to_string(), "129950.00");
4045 }
4046
4047 #[rstest]
4048 fn test_parse_spot_margin_position_from_balance_long() {
4049 let balance = OKXBalanceDetail {
4050 ccy: Ustr::from("BTC"),
4051 liab: "1.5".to_string(),
4052 spot_in_use_amt: "1.2".to_string(),
4053 cross_liab: "1.5".to_string(),
4054 eq: "1.2".to_string(),
4055 u_time: 1704067200000,
4056 avail_bal: "0".to_string(),
4057 avail_eq: "0".to_string(),
4058 borrow_froz: "0".to_string(),
4059 cash_bal: "0".to_string(),
4060 dis_eq: "0".to_string(),
4061 eq_usd: "0".to_string(),
4062 smt_sync_eq: "0".to_string(),
4063 spot_copy_trading_eq: "0".to_string(),
4064 fixed_bal: "0".to_string(),
4065 frozen_bal: "0".to_string(),
4066 imr: "0".to_string(),
4067 interest: "0".to_string(),
4068 iso_eq: "0".to_string(),
4069 iso_liab: "0".to_string(),
4070 iso_upl: "0".to_string(),
4071 max_loan: "0".to_string(),
4072 mgn_ratio: "0".to_string(),
4073 mmr: "0".to_string(),
4074 notional_lever: "0".to_string(),
4075 ord_frozen: "0".to_string(),
4076 reward_bal: "0".to_string(),
4077 cl_spot_in_use_amt: "0".to_string(),
4078 max_spot_in_use_amt: "0".to_string(),
4079 spot_iso_bal: "0".to_string(),
4080 stgy_eq: "0".to_string(),
4081 twap: "0".to_string(),
4082 upl: "0".to_string(),
4083 upl_liab: "0".to_string(),
4084 spot_bal: "0".to_string(),
4085 open_avg_px: "0".to_string(),
4086 acc_avg_px: "0".to_string(),
4087 spot_upl: "0".to_string(),
4088 spot_upl_ratio: "0".to_string(),
4089 total_pnl: "0".to_string(),
4090 total_pnl_ratio: "0".to_string(),
4091 };
4092
4093 let account_id = AccountId::new("OKX-001");
4094 let size_precision = 8;
4095 let ts_init = UnixNanos::default();
4096
4097 let result = parse_spot_margin_position_from_balance(
4098 &balance,
4099 account_id,
4100 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4101 size_precision,
4102 ts_init,
4103 )
4104 .unwrap();
4105
4106 assert!(result.is_some());
4107 let report = result.unwrap();
4108 assert_eq!(report.position_side, PositionSide::Long.as_specified());
4109 assert_eq!(report.quantity.to_string(), "1.20000000");
4110 }
4111
4112 #[rstest]
4113 fn test_parse_spot_margin_position_from_balance_usdc_quote() {
4114 let balance = OKXBalanceDetail {
4115 ccy: Ustr::from("ETH"),
4116 liab: "10.5".to_string(),
4117 spot_in_use_amt: "-10.0".to_string(),
4118 cross_liab: "10.5".to_string(),
4119 eq: "-10.0".to_string(),
4120 u_time: 1704067200000,
4121 avail_bal: "0".to_string(),
4122 avail_eq: "0".to_string(),
4123 borrow_froz: "0".to_string(),
4124 cash_bal: "0".to_string(),
4125 dis_eq: "0".to_string(),
4126 eq_usd: "0".to_string(),
4127 smt_sync_eq: "0".to_string(),
4128 spot_copy_trading_eq: "0".to_string(),
4129 fixed_bal: "0".to_string(),
4130 frozen_bal: "0".to_string(),
4131 imr: "0".to_string(),
4132 interest: "0".to_string(),
4133 iso_eq: "0".to_string(),
4134 iso_liab: "0".to_string(),
4135 iso_upl: "0".to_string(),
4136 max_loan: "0".to_string(),
4137 mgn_ratio: "0".to_string(),
4138 mmr: "0".to_string(),
4139 notional_lever: "0".to_string(),
4140 ord_frozen: "0".to_string(),
4141 reward_bal: "0".to_string(),
4142 cl_spot_in_use_amt: "0".to_string(),
4143 max_spot_in_use_amt: "0".to_string(),
4144 spot_iso_bal: "0".to_string(),
4145 stgy_eq: "0".to_string(),
4146 twap: "0".to_string(),
4147 upl: "0".to_string(),
4148 upl_liab: "0".to_string(),
4149 spot_bal: "0".to_string(),
4150 open_avg_px: "0".to_string(),
4151 acc_avg_px: "0".to_string(),
4152 spot_upl: "0".to_string(),
4153 spot_upl_ratio: "0".to_string(),
4154 total_pnl: "0".to_string(),
4155 total_pnl_ratio: "0".to_string(),
4156 };
4157
4158 let account_id = AccountId::new("OKX-001");
4159 let size_precision = 6;
4160 let ts_init = UnixNanos::default();
4161
4162 let result = parse_spot_margin_position_from_balance(
4163 &balance,
4164 account_id,
4165 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4166 size_precision,
4167 ts_init,
4168 )
4169 .unwrap();
4170
4171 assert!(result.is_some());
4172 let report = result.unwrap();
4173 assert_eq!(report.position_side, PositionSide::Short.as_specified());
4174 assert_eq!(report.quantity.to_string(), "10.000000");
4175 assert!(report.instrument_id.to_string().contains("ETH-"));
4176 }
4177
4178 #[rstest]
4179 fn test_parse_spot_margin_position_from_balance_no_position() {
4180 let balance = OKXBalanceDetail {
4181 ccy: Ustr::from("USDT"),
4182 liab: "0".to_string(),
4183 spot_in_use_amt: "0".to_string(),
4184 cross_liab: "0".to_string(),
4185 eq: "1000.5".to_string(),
4186 u_time: 1704067200000,
4187 avail_bal: "1000.5".to_string(),
4188 avail_eq: "1000.5".to_string(),
4189 borrow_froz: "0".to_string(),
4190 cash_bal: "1000.5".to_string(),
4191 dis_eq: "0".to_string(),
4192 eq_usd: "1000.5".to_string(),
4193 smt_sync_eq: "0".to_string(),
4194 spot_copy_trading_eq: "0".to_string(),
4195 fixed_bal: "0".to_string(),
4196 frozen_bal: "0".to_string(),
4197 imr: "0".to_string(),
4198 interest: "0".to_string(),
4199 iso_eq: "0".to_string(),
4200 iso_liab: "0".to_string(),
4201 iso_upl: "0".to_string(),
4202 max_loan: "0".to_string(),
4203 mgn_ratio: "0".to_string(),
4204 mmr: "0".to_string(),
4205 notional_lever: "0".to_string(),
4206 ord_frozen: "0".to_string(),
4207 reward_bal: "0".to_string(),
4208 cl_spot_in_use_amt: "0".to_string(),
4209 max_spot_in_use_amt: "0".to_string(),
4210 spot_iso_bal: "0".to_string(),
4211 stgy_eq: "0".to_string(),
4212 twap: "0".to_string(),
4213 upl: "0".to_string(),
4214 upl_liab: "0".to_string(),
4215 spot_bal: "1000.5".to_string(),
4216 open_avg_px: "0".to_string(),
4217 acc_avg_px: "0".to_string(),
4218 spot_upl: "0".to_string(),
4219 spot_upl_ratio: "0".to_string(),
4220 total_pnl: "0".to_string(),
4221 total_pnl_ratio: "0".to_string(),
4222 };
4223
4224 let account_id = AccountId::new("OKX-001");
4225 let size_precision = 2;
4226 let ts_init = UnixNanos::default();
4227
4228 let result = parse_spot_margin_position_from_balance(
4229 &balance,
4230 account_id,
4231 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4232 size_precision,
4233 ts_init,
4234 )
4235 .unwrap();
4236
4237 assert!(result.is_none());
4238 }
4239
4240 #[rstest]
4241 fn test_parse_spot_margin_position_from_balance_liability_no_spot_in_use() {
4242 let balance = OKXBalanceDetail {
4243 ccy: Ustr::from("BTC"),
4244 liab: "0.5".to_string(),
4245 spot_in_use_amt: "0".to_string(),
4246 cross_liab: "0.5".to_string(),
4247 eq: "0".to_string(),
4248 u_time: 1704067200000,
4249 avail_bal: "0".to_string(),
4250 avail_eq: "0".to_string(),
4251 borrow_froz: "0".to_string(),
4252 cash_bal: "0".to_string(),
4253 dis_eq: "0".to_string(),
4254 eq_usd: "0".to_string(),
4255 smt_sync_eq: "0".to_string(),
4256 spot_copy_trading_eq: "0".to_string(),
4257 fixed_bal: "0".to_string(),
4258 frozen_bal: "0".to_string(),
4259 imr: "0".to_string(),
4260 interest: "0".to_string(),
4261 iso_eq: "0".to_string(),
4262 iso_liab: "0".to_string(),
4263 iso_upl: "0".to_string(),
4264 max_loan: "0".to_string(),
4265 mgn_ratio: "0".to_string(),
4266 mmr: "0".to_string(),
4267 notional_lever: "0".to_string(),
4268 ord_frozen: "0".to_string(),
4269 reward_bal: "0".to_string(),
4270 cl_spot_in_use_amt: "0".to_string(),
4271 max_spot_in_use_amt: "0".to_string(),
4272 spot_iso_bal: "0".to_string(),
4273 stgy_eq: "0".to_string(),
4274 twap: "0".to_string(),
4275 upl: "0".to_string(),
4276 upl_liab: "0".to_string(),
4277 spot_bal: "0".to_string(),
4278 open_avg_px: "0".to_string(),
4279 acc_avg_px: "0".to_string(),
4280 spot_upl: "0".to_string(),
4281 spot_upl_ratio: "0".to_string(),
4282 total_pnl: "0".to_string(),
4283 total_pnl_ratio: "0".to_string(),
4284 };
4285
4286 let account_id = AccountId::new("OKX-001");
4287 let size_precision = 8;
4288 let ts_init = UnixNanos::default();
4289
4290 let result = parse_spot_margin_position_from_balance(
4291 &balance,
4292 account_id,
4293 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4294 size_precision,
4295 ts_init,
4296 )
4297 .unwrap();
4298
4299 assert!(result.is_none());
4300 }
4301
4302 #[rstest]
4303 fn test_parse_spot_margin_position_from_balance_empty_strings() {
4304 let balance = OKXBalanceDetail {
4305 ccy: Ustr::from("USDT"),
4306 liab: String::new(),
4307 spot_in_use_amt: String::new(),
4308 cross_liab: String::new(),
4309 eq: "5000.25".to_string(),
4310 u_time: 1704067200000,
4311 avail_bal: "5000.25".to_string(),
4312 avail_eq: "5000.25".to_string(),
4313 borrow_froz: String::new(),
4314 cash_bal: "5000.25".to_string(),
4315 dis_eq: String::new(),
4316 eq_usd: "5000.25".to_string(),
4317 smt_sync_eq: String::new(),
4318 spot_copy_trading_eq: String::new(),
4319 fixed_bal: String::new(),
4320 frozen_bal: String::new(),
4321 imr: String::new(),
4322 interest: String::new(),
4323 iso_eq: String::new(),
4324 iso_liab: String::new(),
4325 iso_upl: String::new(),
4326 max_loan: String::new(),
4327 mgn_ratio: String::new(),
4328 mmr: String::new(),
4329 notional_lever: String::new(),
4330 ord_frozen: String::new(),
4331 reward_bal: String::new(),
4332 cl_spot_in_use_amt: String::new(),
4333 max_spot_in_use_amt: String::new(),
4334 spot_iso_bal: String::new(),
4335 stgy_eq: String::new(),
4336 twap: String::new(),
4337 upl: String::new(),
4338 upl_liab: String::new(),
4339 spot_bal: "5000.25".to_string(),
4340 open_avg_px: String::new(),
4341 acc_avg_px: String::new(),
4342 spot_upl: String::new(),
4343 spot_upl_ratio: String::new(),
4344 total_pnl: String::new(),
4345 total_pnl_ratio: String::new(),
4346 };
4347
4348 let account_id = AccountId::new("OKX-001");
4349 let size_precision = 2;
4350 let ts_init = UnixNanos::default();
4351
4352 let result = parse_spot_margin_position_from_balance(
4353 &balance,
4354 account_id,
4355 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4356 size_precision,
4357 ts_init,
4358 )
4359 .unwrap();
4360
4361 assert!(result.is_none());
4363 }
4364
4365 #[rstest]
4366 #[case::fok_maps_to_fok_tif(OKXOrderType::Fok, TimeInForce::Fok)]
4367 #[case::ioc_maps_to_ioc_tif(OKXOrderType::Ioc, TimeInForce::Ioc)]
4368 #[case::optimal_limit_ioc_maps_to_ioc_tif(OKXOrderType::OptimalLimitIoc, TimeInForce::Ioc)]
4369 #[case::market_maps_to_gtc(OKXOrderType::Market, TimeInForce::Gtc)]
4370 #[case::limit_maps_to_gtc(OKXOrderType::Limit, TimeInForce::Gtc)]
4371 #[case::post_only_maps_to_gtc(OKXOrderType::PostOnly, TimeInForce::Gtc)]
4372 #[case::trigger_maps_to_gtc(OKXOrderType::Trigger, TimeInForce::Gtc)]
4373 fn test_okx_order_type_to_time_in_force(
4374 #[case] okx_ord_type: OKXOrderType,
4375 #[case] expected_tif: TimeInForce,
4376 ) {
4377 let time_in_force = match okx_ord_type {
4378 OKXOrderType::Fok => TimeInForce::Fok,
4379 OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
4380 _ => TimeInForce::Gtc,
4381 };
4382
4383 assert_eq!(
4384 time_in_force, expected_tif,
4385 "OKXOrderType::{okx_ord_type:?} should map to TimeInForce::{expected_tif:?}"
4386 );
4387 }
4388
4389 #[rstest]
4390 fn test_fok_order_type_serialization() {
4391 let ord_type = OKXOrderType::Fok;
4392 let json = serde_json::to_string(&ord_type).expect("serialize");
4393 assert_eq!(json, "\"fok\"", "FOK should serialize to 'fok'");
4394 }
4395
4396 #[rstest]
4397 fn test_ioc_order_type_serialization() {
4398 let ord_type = OKXOrderType::Ioc;
4399 let json = serde_json::to_string(&ord_type).expect("serialize");
4400 assert_eq!(json, "\"ioc\"", "IOC should serialize to 'ioc'");
4401 }
4402
4403 #[rstest]
4404 fn test_optimal_limit_ioc_serialization() {
4405 let ord_type = OKXOrderType::OptimalLimitIoc;
4406 let json = serde_json::to_string(&ord_type).expect("serialize");
4407 assert_eq!(
4408 json, "\"optimal_limit_ioc\"",
4409 "OptimalLimitIoc should serialize to 'optimal_limit_ioc'"
4410 );
4411 }
4412
4413 #[rstest]
4414 fn test_fok_order_type_deserialization() {
4415 let json = "\"fok\"";
4416 let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
4417 assert_eq!(ord_type, OKXOrderType::Fok);
4418 }
4419
4420 #[rstest]
4421 fn test_ioc_order_type_deserialization() {
4422 let json = "\"ioc\"";
4423 let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
4424 assert_eq!(ord_type, OKXOrderType::Ioc);
4425 }
4426
4427 #[rstest]
4428 fn test_optimal_limit_ioc_deserialization() {
4429 let json = "\"optimal_limit_ioc\"";
4430 let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
4431 assert_eq!(ord_type, OKXOrderType::OptimalLimitIoc);
4432 }
4433
4434 #[rstest]
4435 #[case(TimeInForce::Fok, OKXOrderType::Fok)]
4436 #[case(TimeInForce::Ioc, OKXOrderType::Ioc)]
4437 fn test_time_in_force_round_trip(
4438 #[case] original_tif: TimeInForce,
4439 #[case] expected_okx_type: OKXOrderType,
4440 ) {
4441 let okx_ord_type = match original_tif {
4442 TimeInForce::Fok => OKXOrderType::Fok,
4443 TimeInForce::Ioc => OKXOrderType::Ioc,
4444 TimeInForce::Gtc => OKXOrderType::Limit,
4445 _ => OKXOrderType::Limit,
4446 };
4447 assert_eq!(okx_ord_type, expected_okx_type);
4448
4449 let parsed_tif = match okx_ord_type {
4450 OKXOrderType::Fok => TimeInForce::Fok,
4451 OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
4452 _ => TimeInForce::Gtc,
4453 };
4454 assert_eq!(parsed_tif, original_tif);
4455 }
4456
4457 #[rstest]
4458 #[case::limit_fok(
4459 OrderType::Limit,
4460 TimeInForce::Fok,
4461 OKXOrderType::Fok,
4462 "Limit + FOK should map to Fok"
4463 )]
4464 #[case::limit_ioc(
4465 OrderType::Limit,
4466 TimeInForce::Ioc,
4467 OKXOrderType::Ioc,
4468 "Limit + IOC should map to Ioc"
4469 )]
4470 #[case::market_ioc(
4471 OrderType::Market,
4472 TimeInForce::Ioc,
4473 OKXOrderType::OptimalLimitIoc,
4474 "Market + IOC should map to OptimalLimitIoc"
4475 )]
4476 #[case::limit_gtc(
4477 OrderType::Limit,
4478 TimeInForce::Gtc,
4479 OKXOrderType::Limit,
4480 "Limit + GTC should map to Limit"
4481 )]
4482 #[case::market_gtc(
4483 OrderType::Market,
4484 TimeInForce::Gtc,
4485 OKXOrderType::Market,
4486 "Market + GTC should map to Market"
4487 )]
4488 fn test_order_type_time_in_force_combinations(
4489 #[case] order_type: OrderType,
4490 #[case] tif: TimeInForce,
4491 #[case] expected_okx_type: OKXOrderType,
4492 #[case] description: &str,
4493 ) {
4494 let okx_ord_type = match (order_type, tif) {
4495 (OrderType::Market, TimeInForce::Ioc) => OKXOrderType::OptimalLimitIoc,
4496 (OrderType::Limit, TimeInForce::Fok) => OKXOrderType::Fok,
4497 (OrderType::Limit, TimeInForce::Ioc) => OKXOrderType::Ioc,
4498 _ => OKXOrderType::from(order_type),
4499 };
4500
4501 assert_eq!(okx_ord_type, expected_okx_type, "{description}");
4502 }
4503
4504 #[rstest]
4505 fn test_market_fok_not_supported() {
4506 let order_type = OrderType::Market;
4507 let tif = TimeInForce::Fok;
4508
4509 let is_market_fok = matches!((order_type, tif), (OrderType::Market, TimeInForce::Fok));
4510 assert!(
4511 is_market_fok,
4512 "Market + FOK combination should be identified for rejection"
4513 );
4514 }
4515
4516 #[rstest]
4517 #[case::empty_string("", true)]
4518 #[case::zero("0", true)]
4519 #[case::minus_one("-1", true)]
4520 #[case::minus_two("-2", true)]
4521 #[case::normal_price("100.5", false)]
4522 #[case::another_price("0.001", false)]
4523 fn test_is_market_price(#[case] price: &str, #[case] expected: bool) {
4524 assert_eq!(is_market_price(price), expected);
4525 }
4526
4527 #[rstest]
4528 #[case::fok_market(OKXOrderType::Fok, "", OrderType::Market)]
4529 #[case::fok_limit(OKXOrderType::Fok, "100.5", OrderType::Limit)]
4530 #[case::ioc_market(OKXOrderType::Ioc, "", OrderType::Market)]
4531 #[case::ioc_limit(OKXOrderType::Ioc, "100.5", OrderType::Limit)]
4532 #[case::optimal_limit_ioc_market(OKXOrderType::OptimalLimitIoc, "", OrderType::Market)]
4533 #[case::optimal_limit_ioc_market_zero(OKXOrderType::OptimalLimitIoc, "0", OrderType::Market)]
4534 #[case::optimal_limit_ioc_market_minus_one(
4535 OKXOrderType::OptimalLimitIoc,
4536 "-1",
4537 OrderType::Market
4538 )]
4539 #[case::optimal_limit_ioc_limit(OKXOrderType::OptimalLimitIoc, "100.5", OrderType::Limit)]
4540 #[case::market_passthrough(OKXOrderType::Market, "", OrderType::Market)]
4541 #[case::limit_passthrough(OKXOrderType::Limit, "100.5", OrderType::Limit)]
4542 fn test_determine_order_type(
4543 #[case] okx_ord_type: OKXOrderType,
4544 #[case] price: &str,
4545 #[case] expected: OrderType,
4546 ) {
4547 assert_eq!(determine_order_type(okx_ord_type, price), expected);
4548 }
4549}