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"))?;
298 Money::from_decimal(-decimal, currency)
299}
300
301pub fn parse_fee_currency(
306 fee_ccy: &str,
307 fee_amount: Decimal,
308 context: impl FnOnce() -> String,
309) -> Currency {
310 let trimmed = fee_ccy.trim();
311 if trimmed.is_empty() {
312 if !fee_amount.is_zero() {
313 let ctx = context();
314 log::warn!(
315 "Empty fee_ccy in {ctx} with non-zero fee={fee_amount}, using USDT as fallback"
316 );
317 }
318 return Currency::USDT();
319 }
320
321 Currency::get_or_create_crypto_with_context(trimmed, Some(&context()))
322}
323
324pub fn parse_aggressor_side(side: &Option<OKXSide>) -> AggressorSide {
326 match side {
327 Some(OKXSide::Buy) => AggressorSide::Buyer,
328 Some(OKXSide::Sell) => AggressorSide::Seller,
329 None => AggressorSide::NoAggressor,
330 }
331}
332
333pub fn parse_execution_type(liquidity: &Option<OKXExecType>) -> LiquiditySide {
335 match liquidity {
336 Some(OKXExecType::Maker) => LiquiditySide::Maker,
337 Some(OKXExecType::Taker) => LiquiditySide::Taker,
338 _ => LiquiditySide::NoLiquiditySide,
339 }
340}
341
342pub fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
344 match current_qty {
345 Some(qty) if qty > 0 => PositionSide::Long,
346 Some(qty) if qty < 0 => PositionSide::Short,
347 _ => PositionSide::Flat,
348 }
349}
350
351pub fn parse_mark_price_update(
358 raw: &OKXMarkPrice,
359 instrument_id: InstrumentId,
360 price_precision: u8,
361 ts_init: UnixNanos,
362) -> anyhow::Result<MarkPriceUpdate> {
363 let ts_event = parse_millisecond_timestamp(raw.ts);
364 let price = parse_price(&raw.mark_px, price_precision)?;
365 Ok(MarkPriceUpdate::new(
366 instrument_id,
367 price,
368 ts_event,
369 ts_init,
370 ))
371}
372
373pub fn parse_index_price_update(
380 raw: &OKXIndexTicker,
381 instrument_id: InstrumentId,
382 price_precision: u8,
383 ts_init: UnixNanos,
384) -> anyhow::Result<IndexPriceUpdate> {
385 let ts_event = parse_millisecond_timestamp(raw.ts);
386 let price = parse_price(&raw.idx_px, price_precision)?;
387 Ok(IndexPriceUpdate::new(
388 instrument_id,
389 price,
390 ts_event,
391 ts_init,
392 ))
393}
394
395pub fn parse_funding_rate_msg(
402 msg: &OKXFundingRateMsg,
403 instrument_id: InstrumentId,
404 ts_init: UnixNanos,
405) -> anyhow::Result<FundingRateUpdate> {
406 let funding_rate = msg
407 .funding_rate
408 .as_str()
409 .parse::<Decimal>()
410 .map_err(|e| anyhow::anyhow!("Invalid funding_rate value: {e}"))?;
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_parse_inst_id_code_from_swap_instrument() {
2579 let json_data = load_test_json("http_get_instruments_swap.json");
2580 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2581
2582 let btc_usd_swap = response
2584 .data
2585 .iter()
2586 .find(|i| i.inst_id == "BTC-USD-SWAP")
2587 .expect("BTC-USD-SWAP must be in test data");
2588 assert_eq!(btc_usd_swap.inst_id_code, Some(10458));
2589
2590 let eth_usdt_swap = response
2592 .data
2593 .iter()
2594 .find(|i| i.inst_id == "ETH-USDT-SWAP")
2595 .expect("ETH-USDT-SWAP must be in test data");
2596 assert_eq!(eth_usdt_swap.inst_id_code, Some(10461));
2597
2598 let btc_usdt_swap = response
2600 .data
2601 .iter()
2602 .find(|i| i.inst_id == "BTC-USDT-SWAP")
2603 .expect("BTC-USDT-SWAP must be in test data");
2604 assert_eq!(btc_usdt_swap.inst_id_code, Some(10459));
2605 }
2606
2607 #[rstest]
2608 fn test_fee_field_selection_for_contract_types() {
2609 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;
2617 let (maker_str, taker_str) = if is_usdt_margined {
2618 (maker_usdt, taker_usdt)
2619 } else {
2620 (maker_crypto, taker_crypto)
2621 };
2622
2623 assert_eq!(maker_str, "0.0008");
2624 assert_eq!(taker_str, "0.0010");
2625
2626 let maker_fee = Decimal::from_str(maker_str).unwrap();
2627 let taker_fee = Decimal::from_str(taker_str).unwrap();
2628
2629 assert_eq!(maker_fee, dec!(0.0008));
2630 assert_eq!(taker_fee, dec!(0.0010));
2631
2632 let is_usdt_margined = false;
2634 let (maker_str, taker_str) = if is_usdt_margined {
2635 (maker_usdt, taker_usdt)
2636 } else {
2637 (maker_crypto, taker_crypto)
2638 };
2639
2640 assert_eq!(maker_str, "0.0002");
2641 assert_eq!(taker_str, "0.0005");
2642
2643 let maker_fee = Decimal::from_str(maker_str).unwrap();
2644 let taker_fee = Decimal::from_str(taker_str).unwrap();
2645
2646 assert_eq!(maker_fee, dec!(0.0002));
2647 assert_eq!(taker_fee, dec!(0.0005));
2648 }
2649
2650 #[rstest]
2651 fn test_parse_futures_instrument() {
2652 let json_data = load_test_json("http_get_instruments_futures.json");
2653 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2654 let okx_inst: &OKXInstrument = response
2655 .data
2656 .first()
2657 .expect("Test data must have an instrument");
2658
2659 let instrument =
2660 parse_futures_instrument(okx_inst, None, None, None, None, UnixNanos::default())
2661 .unwrap();
2662
2663 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-241220.OKX"));
2664 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-241220"));
2665 assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
2666 assert_eq!(instrument.quote_currency(), Currency::USD());
2667 assert_eq!(instrument.settlement_currency(), Currency::BTC());
2668 assert!(instrument.is_inverse());
2669 assert_eq!(instrument.price_precision(), 1);
2670 assert_eq!(instrument.size_precision(), 0);
2671 assert_eq!(instrument.price_increment(), Price::from("0.1"));
2672 assert_eq!(instrument.size_increment(), Quantity::from(1));
2673 assert_eq!(instrument.multiplier(), Quantity::from(100));
2674 assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2675 assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2676 assert_eq!(instrument.max_quantity(), Some(Quantity::from(10000)));
2677 }
2678
2679 #[rstest]
2680 fn test_parse_option_instrument() {
2681 let json_data = load_test_json("http_get_instruments_option.json");
2682 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2683 let okx_inst: &OKXInstrument = response
2684 .data
2685 .first()
2686 .expect("Test data must have an instrument");
2687
2688 let instrument =
2689 parse_option_instrument(okx_inst, None, None, None, None, UnixNanos::default())
2690 .unwrap();
2691
2692 assert_eq!(
2693 instrument.id(),
2694 InstrumentId::from("BTC-USD-241217-92000-C.OKX")
2695 );
2696 assert_eq!(
2697 instrument.raw_symbol(),
2698 Symbol::from("BTC-USD-241217-92000-C")
2699 );
2700 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2701 assert_eq!(instrument.quote_currency(), Currency::USD());
2702 assert_eq!(instrument.settlement_currency(), Currency::BTC());
2703 assert!(instrument.is_inverse());
2704 assert_eq!(instrument.price_precision(), 4);
2705 assert_eq!(instrument.size_precision(), 0);
2706 assert_eq!(instrument.price_increment(), Price::from("0.0001"));
2707 assert_eq!(instrument.size_increment(), Quantity::from(1));
2708 assert_eq!(instrument.multiplier(), Quantity::from("0.01"));
2709 assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2710 assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2711 assert_eq!(instrument.max_quantity(), Some(Quantity::from(5000)));
2712 assert_eq!(instrument.max_notional(), None);
2713 assert_eq!(instrument.min_notional(), None);
2714 assert_eq!(instrument.max_price(), None);
2715 assert_eq!(instrument.min_price(), None);
2716 }
2717
2718 #[rstest]
2719 fn test_parse_account_state() {
2720 let json_data = load_test_json("http_get_account_balance.json");
2721 let response: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
2722 let okx_account = response
2723 .data
2724 .first()
2725 .expect("Test data must have an account");
2726
2727 let account_id = AccountId::new("OKX-001");
2728 let account_state =
2729 parse_account_state(okx_account, account_id, UnixNanos::default()).unwrap();
2730
2731 assert_eq!(account_state.account_id, account_id);
2732 assert_eq!(account_state.account_type, AccountType::Margin);
2733 assert_eq!(account_state.balances.len(), 1);
2734 assert_eq!(account_state.margins.len(), 0); assert!(account_state.is_reported);
2736
2737 let usdt_balance = &account_state.balances[0];
2739 assert_eq!(
2740 usdt_balance.total,
2741 Money::new(94.42612990333333, Currency::USDT())
2742 );
2743 assert_eq!(
2744 usdt_balance.free,
2745 Money::new(94.42612990333333, Currency::USDT())
2746 );
2747 assert_eq!(usdt_balance.locked, Money::new(0.0, Currency::USDT()));
2748 }
2749
2750 #[rstest]
2751 fn test_parse_account_state_with_margins() {
2752 let account_json = r#"{
2754 "adjEq": "10000.0",
2755 "borrowFroz": "0",
2756 "details": [{
2757 "accAvgPx": "",
2758 "availBal": "8000.0",
2759 "availEq": "8000.0",
2760 "borrowFroz": "0",
2761 "cashBal": "10000.0",
2762 "ccy": "USDT",
2763 "clSpotInUseAmt": "0",
2764 "coinUsdPrice": "1.0",
2765 "colBorrAutoConversion": "0",
2766 "collateralEnabled": false,
2767 "collateralRestrict": false,
2768 "crossLiab": "0",
2769 "disEq": "10000.0",
2770 "eq": "10000.0",
2771 "eqUsd": "10000.0",
2772 "fixedBal": "0",
2773 "frozenBal": "2000.0",
2774 "imr": "0",
2775 "interest": "0",
2776 "isoEq": "0",
2777 "isoLiab": "0",
2778 "isoUpl": "0",
2779 "liab": "0",
2780 "maxLoan": "0",
2781 "mgnRatio": "0",
2782 "maxSpotInUseAmt": "0",
2783 "mmr": "0",
2784 "notionalLever": "0",
2785 "openAvgPx": "",
2786 "ordFrozen": "2000.0",
2787 "rewardBal": "0",
2788 "smtSyncEq": "0",
2789 "spotBal": "0",
2790 "spotCopyTradingEq": "0",
2791 "spotInUseAmt": "0",
2792 "spotIsoBal": "0",
2793 "spotUpl": "0",
2794 "spotUplRatio": "0",
2795 "stgyEq": "0",
2796 "totalPnl": "0",
2797 "totalPnlRatio": "0",
2798 "twap": "0",
2799 "uTime": "1704067200000",
2800 "upl": "0",
2801 "uplLiab": "0"
2802 }],
2803 "imr": "500.25",
2804 "isoEq": "0",
2805 "mgnRatio": "20.5",
2806 "mmr": "250.75",
2807 "notionalUsd": "5000.0",
2808 "notionalUsdForBorrow": "0",
2809 "notionalUsdForFutures": "0",
2810 "notionalUsdForOption": "0",
2811 "notionalUsdForSwap": "5000.0",
2812 "ordFroz": "2000.0",
2813 "totalEq": "10000.0",
2814 "uTime": "1704067200000",
2815 "upl": "0"
2816 }"#;
2817
2818 let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
2819 let account_id = AccountId::new("OKX-001");
2820 let account_state =
2821 parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
2822
2823 assert_eq!(account_state.account_id, account_id);
2825 assert_eq!(account_state.account_type, AccountType::Margin);
2826 assert_eq!(account_state.balances.len(), 1);
2827
2828 assert_eq!(account_state.margins.len(), 1);
2830 let margin = &account_state.margins[0];
2831
2832 assert_eq!(margin.initial, Money::new(500.25, Currency::USD()));
2834 assert_eq!(margin.maintenance, Money::new(250.75, Currency::USD()));
2835 assert_eq!(margin.currency, Currency::USD());
2836 assert_eq!(margin.instrument_id.symbol.as_str(), "ACCOUNT");
2837 assert_eq!(margin.instrument_id.venue.as_str(), "OKX");
2838
2839 let usdt_balance = &account_state.balances[0];
2841 assert_eq!(usdt_balance.total, Money::new(10000.0, Currency::USDT()));
2842 assert_eq!(usdt_balance.free, Money::new(8000.0, Currency::USDT()));
2843 assert_eq!(usdt_balance.locked, Money::new(2000.0, Currency::USDT()));
2844 }
2845
2846 #[rstest]
2847 fn test_parse_account_state_empty_margins() {
2848 let account_json = r#"{
2850 "adjEq": "",
2851 "borrowFroz": "",
2852 "details": [{
2853 "accAvgPx": "",
2854 "availBal": "1000.0",
2855 "availEq": "1000.0",
2856 "borrowFroz": "0",
2857 "cashBal": "1000.0",
2858 "ccy": "BTC",
2859 "clSpotInUseAmt": "0",
2860 "coinUsdPrice": "50000.0",
2861 "colBorrAutoConversion": "0",
2862 "collateralEnabled": false,
2863 "collateralRestrict": false,
2864 "crossLiab": "0",
2865 "disEq": "50000.0",
2866 "eq": "1000.0",
2867 "eqUsd": "50000.0",
2868 "fixedBal": "0",
2869 "frozenBal": "0",
2870 "imr": "0",
2871 "interest": "0",
2872 "isoEq": "0",
2873 "isoLiab": "0",
2874 "isoUpl": "0",
2875 "liab": "0",
2876 "maxLoan": "0",
2877 "mgnRatio": "0",
2878 "maxSpotInUseAmt": "0",
2879 "mmr": "0",
2880 "notionalLever": "0",
2881 "openAvgPx": "",
2882 "ordFrozen": "0",
2883 "rewardBal": "0",
2884 "smtSyncEq": "0",
2885 "spotBal": "0",
2886 "spotCopyTradingEq": "0",
2887 "spotInUseAmt": "0",
2888 "spotIsoBal": "0",
2889 "spotUpl": "0",
2890 "spotUplRatio": "0",
2891 "stgyEq": "0",
2892 "totalPnl": "0",
2893 "totalPnlRatio": "0",
2894 "twap": "0",
2895 "uTime": "1704067200000",
2896 "upl": "0",
2897 "uplLiab": "0"
2898 }],
2899 "imr": "",
2900 "isoEq": "0",
2901 "mgnRatio": "",
2902 "mmr": "",
2903 "notionalUsd": "",
2904 "notionalUsdForBorrow": "",
2905 "notionalUsdForFutures": "",
2906 "notionalUsdForOption": "",
2907 "notionalUsdForSwap": "",
2908 "ordFroz": "",
2909 "totalEq": "50000.0",
2910 "uTime": "1704067200000",
2911 "upl": "0"
2912 }"#;
2913
2914 let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
2915 let account_id = AccountId::new("OKX-SPOT");
2916 let account_state =
2917 parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
2918
2919 assert_eq!(account_state.margins.len(), 0);
2921 assert_eq!(account_state.balances.len(), 1);
2922
2923 let btc_balance = &account_state.balances[0];
2925 assert_eq!(btc_balance.total, Money::new(1000.0, Currency::BTC()));
2926 }
2927
2928 #[rstest]
2929 fn test_parse_order_status_report() {
2930 let json_data = load_test_json("http_get_orders_history.json");
2931 let response: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
2932 let okx_order = response
2933 .data
2934 .first()
2935 .expect("Test data must have an order")
2936 .clone();
2937
2938 let account_id = AccountId::new("OKX-001");
2939 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2940 let order_report = parse_order_status_report(
2941 &okx_order,
2942 account_id,
2943 instrument_id,
2944 2,
2945 8,
2946 UnixNanos::default(),
2947 )
2948 .unwrap();
2949
2950 assert_eq!(order_report.account_id, account_id);
2951 assert_eq!(order_report.instrument_id, instrument_id);
2952 assert_eq!(order_report.quantity, Quantity::from("0.03000000"));
2953 assert_eq!(order_report.filled_qty, Quantity::from("0.03000000"));
2954 assert_eq!(order_report.order_side, OrderSide::Buy);
2955 assert_eq!(order_report.order_type, OrderType::Market);
2956 assert_eq!(order_report.order_status, OrderStatus::Filled);
2957 }
2958
2959 #[rstest]
2960 fn test_parse_position_status_report() {
2961 let json_data = load_test_json("http_get_positions.json");
2962 let response: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
2963 let okx_position = response
2964 .data
2965 .first()
2966 .expect("Test data must have a position")
2967 .clone();
2968
2969 let account_id = AccountId::new("OKX-001");
2970 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2971 let position_report = parse_position_status_report(
2972 okx_position,
2973 account_id,
2974 instrument_id,
2975 8,
2976 UnixNanos::default(),
2977 )
2978 .unwrap();
2979
2980 assert_eq!(position_report.account_id, account_id);
2981 assert_eq!(position_report.instrument_id, instrument_id);
2982 }
2983
2984 #[rstest]
2985 fn test_parse_trade_tick() {
2986 let json_data = load_test_json("http_get_trades.json");
2987 let response: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
2988 let okx_trade = response.data.first().expect("Test data must have a trade");
2989
2990 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2991 let trade_tick =
2992 parse_trade_tick(okx_trade, instrument_id, 2, 8, UnixNanos::default()).unwrap();
2993
2994 assert_eq!(trade_tick.instrument_id, instrument_id);
2995 assert_eq!(trade_tick.price, Price::from("102537.90"));
2996 assert_eq!(trade_tick.size, Quantity::from("0.00013669"));
2997 assert_eq!(trade_tick.aggressor_side, AggressorSide::Seller);
2998 assert_eq!(trade_tick.trade_id, TradeId::new("734864333"));
2999 }
3000
3001 #[rstest]
3002 fn test_parse_mark_price_update() {
3003 let json_data = load_test_json("http_get_mark_price.json");
3004 let response: OKXResponse<crate::http::models::OKXMarkPrice> =
3005 serde_json::from_str(&json_data).unwrap();
3006 let okx_mark_price = response
3007 .data
3008 .first()
3009 .expect("Test data must have a mark price");
3010
3011 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3012 let mark_price_update =
3013 parse_mark_price_update(okx_mark_price, instrument_id, 2, UnixNanos::default())
3014 .unwrap();
3015
3016 assert_eq!(mark_price_update.instrument_id, instrument_id);
3017 assert_eq!(mark_price_update.value, Price::from("84660.10"));
3018 assert_eq!(
3019 mark_price_update.ts_event,
3020 UnixNanos::from(1744590349506000000)
3021 );
3022 }
3023
3024 #[rstest]
3025 fn test_parse_index_price_update() {
3026 let json_data = load_test_json("http_get_index_price.json");
3027 let response: OKXResponse<crate::http::models::OKXIndexTicker> =
3028 serde_json::from_str(&json_data).unwrap();
3029 let okx_index_ticker = response
3030 .data
3031 .first()
3032 .expect("Test data must have an index ticker");
3033
3034 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3035 let index_price_update =
3036 parse_index_price_update(okx_index_ticker, instrument_id, 2, UnixNanos::default())
3037 .unwrap();
3038
3039 assert_eq!(index_price_update.instrument_id, instrument_id);
3040 assert_eq!(index_price_update.value, Price::from("103895.00"));
3041 assert_eq!(
3042 index_price_update.ts_event,
3043 UnixNanos::from(1746942707815000000)
3044 );
3045 }
3046
3047 #[rstest]
3048 fn test_parse_candlestick() {
3049 let json_data = load_test_json("http_get_candlesticks.json");
3050 let response: OKXResponse<crate::http::models::OKXCandlestick> =
3051 serde_json::from_str(&json_data).unwrap();
3052 let okx_candlestick = response
3053 .data
3054 .first()
3055 .expect("Test data must have a candlestick");
3056
3057 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3058 let bar_type = BarType::new(
3059 instrument_id,
3060 BAR_SPEC_1_DAY_LAST,
3061 AggregationSource::External,
3062 );
3063 let bar = parse_candlestick(okx_candlestick, bar_type, 2, 8, UnixNanos::default()).unwrap();
3064
3065 assert_eq!(bar.bar_type, bar_type);
3066 assert_eq!(bar.open, Price::from("33528.60"));
3067 assert_eq!(bar.high, Price::from("33870.00"));
3068 assert_eq!(bar.low, Price::from("33528.60"));
3069 assert_eq!(bar.close, Price::from("33783.90"));
3070 assert_eq!(bar.volume, Quantity::from("778.83800000"));
3071 assert_eq!(bar.ts_event, UnixNanos::from(1625097600000000000));
3072 }
3073
3074 #[rstest]
3075 fn test_parse_millisecond_timestamp() {
3076 let timestamp_ms = 1625097600000u64;
3077 let result = parse_millisecond_timestamp(timestamp_ms);
3078 assert_eq!(result, UnixNanos::from(1625097600000000000));
3079 }
3080
3081 #[rstest]
3082 fn test_parse_rfc3339_timestamp() {
3083 let timestamp_str = "2021-07-01T00:00:00.000Z";
3084 let result = parse_rfc3339_timestamp(timestamp_str).unwrap();
3085 assert_eq!(result, UnixNanos::from(1625097600000000000));
3086
3087 let timestamp_str_tz = "2021-07-01T08:00:00.000+08:00";
3089 let result_tz = parse_rfc3339_timestamp(timestamp_str_tz).unwrap();
3090 assert_eq!(result_tz, UnixNanos::from(1625097600000000000));
3091
3092 let invalid_timestamp = "invalid-timestamp";
3094 assert!(parse_rfc3339_timestamp(invalid_timestamp).is_err());
3095 }
3096
3097 #[rstest]
3098 fn test_parse_price() {
3099 let price_str = "42219.5";
3100 let precision = 2;
3101 let result = parse_price(price_str, precision).unwrap();
3102 assert_eq!(result, Price::from("42219.50"));
3103
3104 let invalid_price = "invalid-price";
3106 assert!(parse_price(invalid_price, precision).is_err());
3107 }
3108
3109 #[rstest]
3110 fn test_parse_quantity() {
3111 let quantity_str = "0.12345678";
3112 let precision = 8;
3113 let result = parse_quantity(quantity_str, precision).unwrap();
3114 assert_eq!(result, Quantity::from("0.12345678"));
3115
3116 let invalid_quantity = "invalid-quantity";
3118 assert!(parse_quantity(invalid_quantity, precision).is_err());
3119 }
3120
3121 #[rstest]
3122 fn test_parse_aggressor_side() {
3123 assert_eq!(
3124 parse_aggressor_side(&Some(OKXSide::Buy)),
3125 AggressorSide::Buyer
3126 );
3127 assert_eq!(
3128 parse_aggressor_side(&Some(OKXSide::Sell)),
3129 AggressorSide::Seller
3130 );
3131 assert_eq!(parse_aggressor_side(&None), AggressorSide::NoAggressor);
3132 }
3133
3134 #[rstest]
3135 fn test_parse_execution_type() {
3136 assert_eq!(
3137 parse_execution_type(&Some(OKXExecType::Maker)),
3138 LiquiditySide::Maker
3139 );
3140 assert_eq!(
3141 parse_execution_type(&Some(OKXExecType::Taker)),
3142 LiquiditySide::Taker
3143 );
3144 assert_eq!(parse_execution_type(&None), LiquiditySide::NoLiquiditySide);
3145 }
3146
3147 #[rstest]
3148 fn test_parse_position_side() {
3149 assert_eq!(parse_position_side(Some(100)), PositionSide::Long);
3150 assert_eq!(parse_position_side(Some(-100)), PositionSide::Short);
3151 assert_eq!(parse_position_side(Some(0)), PositionSide::Flat);
3152 assert_eq!(parse_position_side(None), PositionSide::Flat);
3153 }
3154
3155 #[rstest]
3156 fn test_parse_client_order_id() {
3157 let valid_id = "client_order_123";
3158 let result = parse_client_order_id(valid_id);
3159 assert_eq!(result, Some(ClientOrderId::new(valid_id)));
3160
3161 let empty_id = "";
3162 let result_empty = parse_client_order_id(empty_id);
3163 assert_eq!(result_empty, None);
3164 }
3165
3166 #[rstest]
3167 fn test_deserialize_empty_string_as_none() {
3168 let json_with_empty = r#""""#;
3169 let result: Option<String> = serde_json::from_str(json_with_empty).unwrap();
3170 let processed = result.filter(|s| !s.is_empty());
3171 assert_eq!(processed, None);
3172
3173 let json_with_value = r#""test_value""#;
3174 let result: Option<String> = serde_json::from_str(json_with_value).unwrap();
3175 let processed = result.filter(|s| !s.is_empty());
3176 assert_eq!(processed, Some("test_value".to_string()));
3177 }
3178
3179 #[rstest]
3180 fn test_deserialize_string_to_u64() {
3181 use serde::Deserialize;
3182
3183 #[derive(Deserialize)]
3184 struct TestStruct {
3185 #[serde(deserialize_with = "deserialize_string_to_u64")]
3186 value: u64,
3187 }
3188
3189 let json_value = r#"{"value": "12345"}"#;
3190 let result: TestStruct = serde_json::from_str(json_value).unwrap();
3191 assert_eq!(result.value, 12345);
3192
3193 let json_empty = r#"{"value": ""}"#;
3194 let result_empty: TestStruct = serde_json::from_str(json_empty).unwrap();
3195 assert_eq!(result_empty.value, 0);
3196 }
3197
3198 #[rstest]
3199 fn test_fill_report_parsing() {
3200 let transaction_detail = crate::http::models::OKXTransactionDetail {
3202 inst_type: OKXInstrumentType::Spot,
3203 inst_id: Ustr::from("BTC-USDT"),
3204 trade_id: Ustr::from("12345"),
3205 ord_id: Ustr::from("67890"),
3206 cl_ord_id: Ustr::from("client_123"),
3207 bill_id: Ustr::from("bill_456"),
3208 fill_px: "42219.5".to_string(),
3209 fill_sz: "0.001".to_string(),
3210 side: OKXSide::Buy,
3211 exec_type: OKXExecType::Taker,
3212 fee_ccy: "USDT".to_string(),
3213 fee: Some("0.042".to_string()),
3214 ts: 1625097600000,
3215 };
3216
3217 let account_id = AccountId::new("OKX-001");
3218 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3219 let fill_report = parse_fill_report(
3220 transaction_detail,
3221 account_id,
3222 instrument_id,
3223 2,
3224 8,
3225 UnixNanos::default(),
3226 )
3227 .unwrap();
3228
3229 assert_eq!(fill_report.account_id, account_id);
3230 assert_eq!(fill_report.instrument_id, instrument_id);
3231 assert_eq!(fill_report.trade_id, TradeId::new("12345"));
3232 assert_eq!(fill_report.venue_order_id, VenueOrderId::new("67890"));
3233 assert_eq!(fill_report.order_side, OrderSide::Buy);
3234 assert_eq!(fill_report.last_px, Price::from("42219.50"));
3235 assert_eq!(fill_report.last_qty, Quantity::from("0.00100000"));
3236 assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
3237 }
3238
3239 #[rstest]
3240 fn test_bar_type_identity_preserved_through_parse() {
3241 use std::str::FromStr;
3242
3243 use crate::http::models::OKXCandlestick;
3244
3245 let bar_type = BarType::from_str("ETH-USDT-SWAP.OKX-1-MINUTE-LAST-EXTERNAL").unwrap();
3247
3248 let raw_candlestick = OKXCandlestick(
3250 "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(), );
3260
3261 let bar =
3263 parse_candlestick(&raw_candlestick, bar_type, 1, 3, UnixNanos::default()).unwrap();
3264
3265 assert_eq!(
3267 bar.bar_type, bar_type,
3268 "BarType must be preserved exactly through parsing"
3269 );
3270 }
3271
3272 #[rstest]
3273 fn test_deserialize_vip_level_all_formats() {
3274 use serde::Deserialize;
3275 use serde_json;
3276
3277 #[derive(Deserialize)]
3278 struct TestFeeRate {
3279 #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3280 level: OKXVipLevel,
3281 }
3282
3283 let json = r#"{"level":"VIP4"}"#;
3285 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3286 assert_eq!(result.level, OKXVipLevel::Vip4);
3287
3288 let json = r#"{"level":"VIP5"}"#;
3289 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3290 assert_eq!(result.level, OKXVipLevel::Vip5);
3291
3292 let json = r#"{"level":"Lv1"}"#;
3294 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3295 assert_eq!(result.level, OKXVipLevel::Vip1);
3296
3297 let json = r#"{"level":"Lv0"}"#;
3298 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3299 assert_eq!(result.level, OKXVipLevel::Vip0);
3300
3301 let json = r#"{"level":"Lv9"}"#;
3302 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3303 assert_eq!(result.level, OKXVipLevel::Vip9);
3304 }
3305
3306 #[rstest]
3307 fn test_deserialize_vip_level_empty_string() {
3308 use serde::Deserialize;
3309 use serde_json;
3310
3311 #[derive(Deserialize)]
3312 struct TestFeeRate {
3313 #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3314 level: OKXVipLevel,
3315 }
3316
3317 let json = r#"{"level":""}"#;
3319 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3320 assert_eq!(result.level, OKXVipLevel::Vip0);
3321 }
3322
3323 #[rstest]
3324 fn test_deserialize_vip_level_without_prefix() {
3325 use serde::Deserialize;
3326 use serde_json;
3327
3328 #[derive(Deserialize)]
3329 struct TestFeeRate {
3330 #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3331 level: OKXVipLevel,
3332 }
3333
3334 let json = r#"{"level":"5"}"#;
3335 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3336 assert_eq!(result.level, OKXVipLevel::Vip5);
3337 }
3338
3339 #[rstest]
3340 fn test_parse_position_status_report_net_mode_long() {
3341 let position = OKXPosition {
3343 inst_id: Ustr::from("BTC-USDT-SWAP"),
3344 inst_type: OKXInstrumentType::Swap,
3345 mgn_mode: OKXMarginMode::Cross,
3346 pos_id: Some(Ustr::from("12345")),
3347 pos_side: OKXPositionSide::Net, pos: "1.5".to_string(), base_bal: "1.5".to_string(),
3350 ccy: "BTC".to_string(),
3351 fee: "0.01".to_string(),
3352 lever: "10.0".to_string(),
3353 last: "50000".to_string(),
3354 mark_px: "50000".to_string(),
3355 liq_px: "45000".to_string(),
3356 mmr: "0.1".to_string(),
3357 interest: "0".to_string(),
3358 trade_id: Ustr::from("111"),
3359 notional_usd: "75000".to_string(),
3360 avg_px: "50000".to_string(),
3361 upl: "0".to_string(),
3362 upl_ratio: "0".to_string(),
3363 u_time: 1622559930237,
3364 margin: "0.5".to_string(),
3365 mgn_ratio: "0.01".to_string(),
3366 adl: "0".to_string(),
3367 c_time: "1622559930237".to_string(),
3368 realized_pnl: "0".to_string(),
3369 upl_last_px: "0".to_string(),
3370 upl_ratio_last_px: "0".to_string(),
3371 avail_pos: "1.5".to_string(),
3372 be_px: "0".to_string(),
3373 funding_fee: "0".to_string(),
3374 idx_px: "0".to_string(),
3375 liq_penalty: "0".to_string(),
3376 opt_val: "0".to_string(),
3377 pending_close_ord_liab_val: "0".to_string(),
3378 pnl: "0".to_string(),
3379 pos_ccy: "BTC".to_string(),
3380 quote_bal: "75000".to_string(),
3381 quote_borrowed: "0".to_string(),
3382 quote_interest: "0".to_string(),
3383 spot_in_use_amt: "0".to_string(),
3384 spot_in_use_ccy: "BTC".to_string(),
3385 usd_px: "50000".to_string(),
3386 };
3387
3388 let account_id = AccountId::new("OKX-001");
3389 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3390 let report = parse_position_status_report(
3391 position,
3392 account_id,
3393 instrument_id,
3394 8,
3395 UnixNanos::default(),
3396 )
3397 .unwrap();
3398
3399 assert_eq!(report.account_id, account_id);
3400 assert_eq!(report.instrument_id, instrument_id);
3401 assert_eq!(report.position_side, PositionSide::Long.as_specified());
3402 assert_eq!(report.quantity, Quantity::from("1.5"));
3403 assert_eq!(report.venue_position_id, None);
3405 }
3406
3407 #[rstest]
3408 fn test_parse_position_status_report_net_mode_short() {
3409 let position = OKXPosition {
3411 inst_id: Ustr::from("BTC-USDT-SWAP"),
3412 inst_type: OKXInstrumentType::Swap,
3413 mgn_mode: OKXMarginMode::Isolated,
3414 pos_id: Some(Ustr::from("67890")),
3415 pos_side: OKXPositionSide::Net, pos: "-2.3".to_string(), base_bal: "2.3".to_string(),
3418 ccy: "BTC".to_string(),
3419 fee: "0.02".to_string(),
3420 lever: "5.0".to_string(),
3421 last: "50000".to_string(),
3422 mark_px: "50000".to_string(),
3423 liq_px: "55000".to_string(),
3424 mmr: "0.2".to_string(),
3425 interest: "0".to_string(),
3426 trade_id: Ustr::from("222"),
3427 notional_usd: "115000".to_string(),
3428 avg_px: "50000".to_string(),
3429 upl: "0".to_string(),
3430 upl_ratio: "0".to_string(),
3431 u_time: 1622559930237,
3432 margin: "1.0".to_string(),
3433 mgn_ratio: "0.02".to_string(),
3434 adl: "0".to_string(),
3435 c_time: "1622559930237".to_string(),
3436 realized_pnl: "0".to_string(),
3437 upl_last_px: "0".to_string(),
3438 upl_ratio_last_px: "0".to_string(),
3439 avail_pos: "2.3".to_string(),
3440 be_px: "0".to_string(),
3441 funding_fee: "0".to_string(),
3442 idx_px: "0".to_string(),
3443 liq_penalty: "0".to_string(),
3444 opt_val: "0".to_string(),
3445 pending_close_ord_liab_val: "0".to_string(),
3446 pnl: "0".to_string(),
3447 pos_ccy: "BTC".to_string(),
3448 quote_bal: "115000".to_string(),
3449 quote_borrowed: "0".to_string(),
3450 quote_interest: "0".to_string(),
3451 spot_in_use_amt: "0".to_string(),
3452 spot_in_use_ccy: "BTC".to_string(),
3453 usd_px: "50000".to_string(),
3454 };
3455
3456 let account_id = AccountId::new("OKX-001");
3457 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3458 let report = parse_position_status_report(
3459 position,
3460 account_id,
3461 instrument_id,
3462 8,
3463 UnixNanos::default(),
3464 )
3465 .unwrap();
3466
3467 assert_eq!(report.account_id, account_id);
3468 assert_eq!(report.instrument_id, instrument_id);
3469 assert_eq!(report.position_side, PositionSide::Short.as_specified());
3470 assert_eq!(report.quantity, Quantity::from("2.3")); assert_eq!(report.venue_position_id, None);
3473 }
3474
3475 #[rstest]
3476 fn test_parse_position_status_report_net_mode_flat() {
3477 let position = OKXPosition {
3479 inst_id: Ustr::from("ETH-USDT-SWAP"),
3480 inst_type: OKXInstrumentType::Swap,
3481 mgn_mode: OKXMarginMode::Cross,
3482 pos_id: Some(Ustr::from("99999")),
3483 pos_side: OKXPositionSide::Net, pos: "0".to_string(), base_bal: "0".to_string(),
3486 ccy: "ETH".to_string(),
3487 fee: "0".to_string(),
3488 lever: "10.0".to_string(),
3489 last: "3000".to_string(),
3490 mark_px: "3000".to_string(),
3491 liq_px: "0".to_string(),
3492 mmr: "0".to_string(),
3493 interest: "0".to_string(),
3494 trade_id: Ustr::from("333"),
3495 notional_usd: "0".to_string(),
3496 avg_px: String::new(),
3497 upl: "0".to_string(),
3498 upl_ratio: "0".to_string(),
3499 u_time: 1622559930237,
3500 margin: "0".to_string(),
3501 mgn_ratio: "0".to_string(),
3502 adl: "0".to_string(),
3503 c_time: "1622559930237".to_string(),
3504 realized_pnl: "0".to_string(),
3505 upl_last_px: "0".to_string(),
3506 upl_ratio_last_px: "0".to_string(),
3507 avail_pos: "0".to_string(),
3508 be_px: "0".to_string(),
3509 funding_fee: "0".to_string(),
3510 idx_px: "0".to_string(),
3511 liq_penalty: "0".to_string(),
3512 opt_val: "0".to_string(),
3513 pending_close_ord_liab_val: "0".to_string(),
3514 pnl: "0".to_string(),
3515 pos_ccy: "ETH".to_string(),
3516 quote_bal: "0".to_string(),
3517 quote_borrowed: "0".to_string(),
3518 quote_interest: "0".to_string(),
3519 spot_in_use_amt: "0".to_string(),
3520 spot_in_use_ccy: "ETH".to_string(),
3521 usd_px: "3000".to_string(),
3522 };
3523
3524 let account_id = AccountId::new("OKX-001");
3525 let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
3526 let report = parse_position_status_report(
3527 position,
3528 account_id,
3529 instrument_id,
3530 8,
3531 UnixNanos::default(),
3532 )
3533 .unwrap();
3534
3535 assert_eq!(report.account_id, account_id);
3536 assert_eq!(report.instrument_id, instrument_id);
3537 assert_eq!(report.position_side, PositionSide::Flat.as_specified());
3538 assert_eq!(report.quantity, Quantity::from("0"));
3539 assert_eq!(report.venue_position_id, None);
3541 }
3542
3543 #[rstest]
3544 fn test_parse_position_status_report_long_short_mode_long() {
3545 let position = OKXPosition {
3547 inst_id: Ustr::from("BTC-USDT-SWAP"),
3548 inst_type: OKXInstrumentType::Swap,
3549 mgn_mode: OKXMarginMode::Cross,
3550 pos_id: Some(Ustr::from("11111")),
3551 pos_side: OKXPositionSide::Long, pos: "3.2".to_string(), base_bal: "3.2".to_string(),
3554 ccy: "BTC".to_string(),
3555 fee: "0.01".to_string(),
3556 lever: "10.0".to_string(),
3557 last: "50000".to_string(),
3558 mark_px: "50000".to_string(),
3559 liq_px: "45000".to_string(),
3560 mmr: "0.1".to_string(),
3561 interest: "0".to_string(),
3562 trade_id: Ustr::from("444"),
3563 notional_usd: "160000".to_string(),
3564 avg_px: "50000".to_string(),
3565 upl: "0".to_string(),
3566 upl_ratio: "0".to_string(),
3567 u_time: 1622559930237,
3568 margin: "1.6".to_string(),
3569 mgn_ratio: "0.01".to_string(),
3570 adl: "0".to_string(),
3571 c_time: "1622559930237".to_string(),
3572 realized_pnl: "0".to_string(),
3573 upl_last_px: "0".to_string(),
3574 upl_ratio_last_px: "0".to_string(),
3575 avail_pos: "3.2".to_string(),
3576 be_px: "0".to_string(),
3577 funding_fee: "0".to_string(),
3578 idx_px: "0".to_string(),
3579 liq_penalty: "0".to_string(),
3580 opt_val: "0".to_string(),
3581 pending_close_ord_liab_val: "0".to_string(),
3582 pnl: "0".to_string(),
3583 pos_ccy: "BTC".to_string(),
3584 quote_bal: "160000".to_string(),
3585 quote_borrowed: "0".to_string(),
3586 quote_interest: "0".to_string(),
3587 spot_in_use_amt: "0".to_string(),
3588 spot_in_use_ccy: "BTC".to_string(),
3589 usd_px: "50000".to_string(),
3590 };
3591
3592 let account_id = AccountId::new("OKX-001");
3593 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3594 let report = parse_position_status_report(
3595 position,
3596 account_id,
3597 instrument_id,
3598 8,
3599 UnixNanos::default(),
3600 )
3601 .unwrap();
3602
3603 assert_eq!(report.account_id, account_id);
3604 assert_eq!(report.instrument_id, instrument_id);
3605 assert_eq!(report.position_side, PositionSide::Long.as_specified());
3606 assert_eq!(report.quantity, Quantity::from("3.2"));
3607 assert_eq!(
3609 report.venue_position_id,
3610 Some(PositionId::new("11111-LONG"))
3611 );
3612 }
3613
3614 #[rstest]
3615 fn test_parse_position_status_report_long_short_mode_short() {
3616 let position = OKXPosition {
3619 inst_id: Ustr::from("BTC-USDT-SWAP"),
3620 inst_type: OKXInstrumentType::Swap,
3621 mgn_mode: OKXMarginMode::Cross,
3622 pos_id: Some(Ustr::from("22222")),
3623 pos_side: OKXPositionSide::Short, pos: "1.8".to_string(), base_bal: "1.8".to_string(),
3626 ccy: "BTC".to_string(),
3627 fee: "0.02".to_string(),
3628 lever: "10.0".to_string(),
3629 last: "50000".to_string(),
3630 mark_px: "50000".to_string(),
3631 liq_px: "55000".to_string(),
3632 mmr: "0.2".to_string(),
3633 interest: "0".to_string(),
3634 trade_id: Ustr::from("555"),
3635 notional_usd: "90000".to_string(),
3636 avg_px: "50000".to_string(),
3637 upl: "0".to_string(),
3638 upl_ratio: "0".to_string(),
3639 u_time: 1622559930237,
3640 margin: "0.9".to_string(),
3641 mgn_ratio: "0.02".to_string(),
3642 adl: "0".to_string(),
3643 c_time: "1622559930237".to_string(),
3644 realized_pnl: "0".to_string(),
3645 upl_last_px: "0".to_string(),
3646 upl_ratio_last_px: "0".to_string(),
3647 avail_pos: "1.8".to_string(),
3648 be_px: "0".to_string(),
3649 funding_fee: "0".to_string(),
3650 idx_px: "0".to_string(),
3651 liq_penalty: "0".to_string(),
3652 opt_val: "0".to_string(),
3653 pending_close_ord_liab_val: "0".to_string(),
3654 pnl: "0".to_string(),
3655 pos_ccy: "BTC".to_string(),
3656 quote_bal: "90000".to_string(),
3657 quote_borrowed: "0".to_string(),
3658 quote_interest: "0".to_string(),
3659 spot_in_use_amt: "0".to_string(),
3660 spot_in_use_ccy: "BTC".to_string(),
3661 usd_px: "50000".to_string(),
3662 };
3663
3664 let account_id = AccountId::new("OKX-001");
3665 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3666 let report = parse_position_status_report(
3667 position,
3668 account_id,
3669 instrument_id,
3670 8,
3671 UnixNanos::default(),
3672 )
3673 .unwrap();
3674
3675 assert_eq!(report.account_id, account_id);
3676 assert_eq!(report.instrument_id, instrument_id);
3677 assert_eq!(report.position_side, PositionSide::Short.as_specified());
3679 assert_eq!(report.quantity, Quantity::from("1.8"));
3680 assert_eq!(
3682 report.venue_position_id,
3683 Some(PositionId::new("22222-SHORT"))
3684 );
3685 }
3686
3687 #[rstest]
3688 fn test_parse_position_status_report_margin_long() {
3689 let position = OKXPosition {
3691 inst_id: Ustr::from("ETH-USDT"),
3692 inst_type: OKXInstrumentType::Margin,
3693 mgn_mode: OKXMarginMode::Cross,
3694 pos_id: Some(Ustr::from("margin-long-1")),
3695 pos_side: OKXPositionSide::Net,
3696 pos: "1.5".to_string(), base_bal: "1.5".to_string(),
3698 ccy: "ETH".to_string(),
3699 fee: "0".to_string(),
3700 lever: "3".to_string(),
3701 last: "4000".to_string(),
3702 mark_px: "4000".to_string(),
3703 liq_px: "3500".to_string(),
3704 mmr: "0.1".to_string(),
3705 interest: "0".to_string(),
3706 trade_id: Ustr::from("trade1"),
3707 notional_usd: "6000".to_string(),
3708 avg_px: "3800".to_string(), upl: "300".to_string(),
3710 upl_ratio: "0.05".to_string(),
3711 u_time: 1622559930237,
3712 margin: "2000".to_string(),
3713 mgn_ratio: "0.33".to_string(),
3714 adl: "0".to_string(),
3715 c_time: "1622559930237".to_string(),
3716 realized_pnl: "0".to_string(),
3717 upl_last_px: "300".to_string(),
3718 upl_ratio_last_px: "0.05".to_string(),
3719 avail_pos: "1.5".to_string(),
3720 be_px: "3800".to_string(),
3721 funding_fee: "0".to_string(),
3722 idx_px: "4000".to_string(),
3723 liq_penalty: "0".to_string(),
3724 opt_val: "0".to_string(),
3725 pending_close_ord_liab_val: "0".to_string(),
3726 pnl: "300".to_string(),
3727 pos_ccy: "ETH".to_string(), quote_bal: "0".to_string(),
3729 quote_borrowed: "0".to_string(),
3730 quote_interest: "0".to_string(),
3731 spot_in_use_amt: "0".to_string(),
3732 spot_in_use_ccy: String::new(),
3733 usd_px: "4000".to_string(),
3734 };
3735
3736 let account_id = AccountId::new("OKX-001");
3737 let instrument_id = InstrumentId::from("ETH-USDT.OKX");
3738 let report = parse_position_status_report(
3739 position,
3740 account_id,
3741 instrument_id,
3742 4,
3743 UnixNanos::default(),
3744 )
3745 .unwrap();
3746
3747 assert_eq!(report.account_id, account_id);
3748 assert_eq!(report.instrument_id, instrument_id);
3749 assert_eq!(report.position_side, PositionSide::Long.as_specified());
3750 assert_eq!(report.quantity, Quantity::from("1.5")); assert_eq!(report.venue_position_id, None); }
3753
3754 #[rstest]
3755 fn test_parse_position_status_report_margin_short() {
3756 let position = OKXPosition {
3759 inst_id: Ustr::from("ETH-USDT"),
3760 inst_type: OKXInstrumentType::Margin,
3761 mgn_mode: OKXMarginMode::Cross,
3762 pos_id: Some(Ustr::from("margin-short-1")),
3763 pos_side: OKXPositionSide::Net,
3764 pos: "244.56".to_string(), base_bal: "0".to_string(),
3766 ccy: "USDT".to_string(),
3767 fee: "0".to_string(),
3768 lever: "3".to_string(),
3769 last: "4092".to_string(),
3770 mark_px: "4092".to_string(),
3771 liq_px: "4500".to_string(),
3772 mmr: "0.1".to_string(),
3773 interest: "0".to_string(),
3774 trade_id: Ustr::from("trade2"),
3775 notional_usd: "244.56".to_string(),
3776 avg_px: "4092".to_string(), upl: "-10".to_string(),
3778 upl_ratio: "-0.04".to_string(),
3779 u_time: 1622559930237,
3780 margin: "100".to_string(),
3781 mgn_ratio: "0.4".to_string(),
3782 adl: "0".to_string(),
3783 c_time: "1622559930237".to_string(),
3784 realized_pnl: "0".to_string(),
3785 upl_last_px: "-10".to_string(),
3786 upl_ratio_last_px: "-0.04".to_string(),
3787 avail_pos: "244.56".to_string(),
3788 be_px: "4092".to_string(),
3789 funding_fee: "0".to_string(),
3790 idx_px: "4092".to_string(),
3791 liq_penalty: "0".to_string(),
3792 opt_val: "0".to_string(),
3793 pending_close_ord_liab_val: "0".to_string(),
3794 pnl: "-10".to_string(),
3795 pos_ccy: "USDT".to_string(), quote_bal: "244.56".to_string(),
3797 quote_borrowed: "0".to_string(),
3798 quote_interest: "0".to_string(),
3799 spot_in_use_amt: "0".to_string(),
3800 spot_in_use_ccy: String::new(),
3801 usd_px: "4092".to_string(),
3802 };
3803
3804 let account_id = AccountId::new("OKX-001");
3805 let instrument_id = InstrumentId::from("ETH-USDT.OKX");
3806 let report = parse_position_status_report(
3807 position,
3808 account_id,
3809 instrument_id,
3810 4,
3811 UnixNanos::default(),
3812 )
3813 .unwrap();
3814
3815 assert_eq!(report.account_id, account_id);
3816 assert_eq!(report.instrument_id, instrument_id);
3817 assert_eq!(report.position_side, PositionSide::Short.as_specified());
3818 assert_eq!(report.quantity.to_string(), "0.0598");
3820 assert_eq!(report.venue_position_id, None); }
3822
3823 #[rstest]
3824 fn test_parse_position_status_report_margin_flat() {
3825 let position = OKXPosition {
3827 inst_id: Ustr::from("ETH-USDT"),
3828 inst_type: OKXInstrumentType::Margin,
3829 mgn_mode: OKXMarginMode::Cross,
3830 pos_id: Some(Ustr::from("margin-flat-1")),
3831 pos_side: OKXPositionSide::Net,
3832 pos: "0".to_string(),
3833 base_bal: "0".to_string(),
3834 ccy: "ETH".to_string(),
3835 fee: "0".to_string(),
3836 lever: "0".to_string(),
3837 last: "4000".to_string(),
3838 mark_px: "4000".to_string(),
3839 liq_px: "0".to_string(),
3840 mmr: "0".to_string(),
3841 interest: "0".to_string(),
3842 trade_id: Ustr::from(""),
3843 notional_usd: "0".to_string(),
3844 avg_px: String::new(),
3845 upl: "0".to_string(),
3846 upl_ratio: "0".to_string(),
3847 u_time: 1622559930237,
3848 margin: "0".to_string(),
3849 mgn_ratio: "0".to_string(),
3850 adl: "0".to_string(),
3851 c_time: "1622559930237".to_string(),
3852 realized_pnl: "0".to_string(),
3853 upl_last_px: "0".to_string(),
3854 upl_ratio_last_px: "0".to_string(),
3855 avail_pos: "0".to_string(),
3856 be_px: "0".to_string(),
3857 funding_fee: "0".to_string(),
3858 idx_px: "0".to_string(),
3859 liq_penalty: "0".to_string(),
3860 opt_val: "0".to_string(),
3861 pending_close_ord_liab_val: "0".to_string(),
3862 pnl: "0".to_string(),
3863 pos_ccy: String::new(), quote_bal: "0".to_string(),
3865 quote_borrowed: "0".to_string(),
3866 quote_interest: "0".to_string(),
3867 spot_in_use_amt: "0".to_string(),
3868 spot_in_use_ccy: String::new(),
3869 usd_px: "0".to_string(),
3870 };
3871
3872 let account_id = AccountId::new("OKX-001");
3873 let instrument_id = InstrumentId::from("ETH-USDT.OKX");
3874 let report = parse_position_status_report(
3875 position,
3876 account_id,
3877 instrument_id,
3878 4,
3879 UnixNanos::default(),
3880 )
3881 .unwrap();
3882
3883 assert_eq!(report.account_id, account_id);
3884 assert_eq!(report.instrument_id, instrument_id);
3885 assert_eq!(report.position_side, PositionSide::Flat.as_specified());
3886 assert_eq!(report.quantity, Quantity::from("0"));
3887 assert_eq!(report.venue_position_id, None); }
3889
3890 #[rstest]
3891 fn test_parse_swap_instrument_empty_underlying_returns_error() {
3892 let instrument = OKXInstrument {
3893 inst_type: OKXInstrumentType::Swap,
3894 inst_id: Ustr::from("ETH-USD_UM-SWAP"),
3895 uly: Ustr::from(""), inst_family: Ustr::from(""),
3897 base_ccy: Ustr::from(""),
3898 quote_ccy: Ustr::from(""),
3899 settle_ccy: Ustr::from("USD"),
3900 ct_val: "1".to_string(),
3901 ct_mult: "1".to_string(),
3902 ct_val_ccy: "USD".to_string(),
3903 opt_type: crate::common::enums::OKXOptionType::None,
3904 stk: String::new(),
3905 list_time: None,
3906 exp_time: None,
3907 lever: String::new(),
3908 tick_sz: "0.1".to_string(),
3909 lot_sz: "1".to_string(),
3910 min_sz: "1".to_string(),
3911 ct_type: OKXContractType::Linear,
3912 state: crate::common::enums::OKXInstrumentStatus::Preopen,
3913 rule_type: String::new(),
3914 max_lmt_sz: String::new(),
3915 max_mkt_sz: String::new(),
3916 max_lmt_amt: String::new(),
3917 max_mkt_amt: String::new(),
3918 max_twap_sz: String::new(),
3919 max_iceberg_sz: String::new(),
3920 max_trigger_sz: String::new(),
3921 max_stop_sz: String::new(),
3922 inst_id_code: None,
3923 };
3924
3925 let result =
3926 parse_swap_instrument(&instrument, None, None, None, None, UnixNanos::default());
3927 assert!(result.is_err());
3928 assert!(result.unwrap_err().to_string().contains("Empty underlying"));
3929 }
3930
3931 #[rstest]
3932 fn test_parse_futures_instrument_empty_underlying_returns_error() {
3933 let instrument = OKXInstrument {
3934 inst_type: OKXInstrumentType::Futures,
3935 inst_id: Ustr::from("ETH-USD_UM-250328"),
3936 uly: Ustr::from(""), inst_family: Ustr::from(""),
3938 base_ccy: Ustr::from(""),
3939 quote_ccy: Ustr::from(""),
3940 settle_ccy: Ustr::from("USD"),
3941 ct_val: "1".to_string(),
3942 ct_mult: "1".to_string(),
3943 ct_val_ccy: "USD".to_string(),
3944 opt_type: crate::common::enums::OKXOptionType::None,
3945 stk: String::new(),
3946 list_time: None,
3947 exp_time: Some(1743004800000),
3948 lever: String::new(),
3949 tick_sz: "0.1".to_string(),
3950 lot_sz: "1".to_string(),
3951 min_sz: "1".to_string(),
3952 ct_type: OKXContractType::Linear,
3953 state: crate::common::enums::OKXInstrumentStatus::Preopen,
3954 rule_type: String::new(),
3955 max_lmt_sz: String::new(),
3956 max_mkt_sz: String::new(),
3957 max_lmt_amt: String::new(),
3958 max_mkt_amt: String::new(),
3959 max_twap_sz: String::new(),
3960 max_iceberg_sz: String::new(),
3961 max_trigger_sz: String::new(),
3962 max_stop_sz: String::new(),
3963 inst_id_code: None,
3964 };
3965
3966 let result =
3967 parse_futures_instrument(&instrument, None, None, None, None, UnixNanos::default());
3968 assert!(result.is_err());
3969 assert!(result.unwrap_err().to_string().contains("Empty underlying"));
3970 }
3971
3972 #[rstest]
3973 fn test_parse_option_instrument_empty_underlying_returns_error() {
3974 let instrument = OKXInstrument {
3975 inst_type: OKXInstrumentType::Option,
3976 inst_id: Ustr::from("BTC-USD-250328-50000-C"),
3977 uly: Ustr::from(""), inst_family: Ustr::from(""),
3979 base_ccy: Ustr::from(""),
3980 quote_ccy: Ustr::from(""),
3981 settle_ccy: Ustr::from("USD"),
3982 ct_val: "0.01".to_string(),
3983 ct_mult: "1".to_string(),
3984 ct_val_ccy: "BTC".to_string(),
3985 opt_type: crate::common::enums::OKXOptionType::Call,
3986 stk: "50000".to_string(),
3987 list_time: None,
3988 exp_time: Some(1743004800000),
3989 lever: String::new(),
3990 tick_sz: "0.0005".to_string(),
3991 lot_sz: "0.1".to_string(),
3992 min_sz: "0.1".to_string(),
3993 ct_type: OKXContractType::Linear,
3994 state: crate::common::enums::OKXInstrumentStatus::Preopen,
3995 rule_type: String::new(),
3996 max_lmt_sz: String::new(),
3997 max_mkt_sz: String::new(),
3998 max_lmt_amt: String::new(),
3999 max_mkt_amt: String::new(),
4000 max_twap_sz: String::new(),
4001 max_iceberg_sz: String::new(),
4002 max_trigger_sz: String::new(),
4003 max_stop_sz: String::new(),
4004 inst_id_code: None,
4005 };
4006
4007 let result =
4008 parse_option_instrument(&instrument, None, None, None, None, UnixNanos::default());
4009 assert!(result.is_err());
4010 assert!(result.unwrap_err().to_string().contains("Empty underlying"));
4011 }
4012
4013 #[rstest]
4014 fn test_parse_spot_margin_position_from_balance_short_usdt() {
4015 let balance = OKXBalanceDetail {
4016 ccy: Ustr::from("ENA"),
4017 liab: "130047.3610487126".to_string(),
4018 spot_in_use_amt: "-129950".to_string(),
4019 cross_liab: "130047.3610487126".to_string(),
4020 eq: "-130047.3610487126".to_string(),
4021 u_time: 1704067200000,
4022 avail_bal: "0".to_string(),
4023 avail_eq: "0".to_string(),
4024 borrow_froz: "0".to_string(),
4025 cash_bal: "0".to_string(),
4026 dis_eq: "0".to_string(),
4027 eq_usd: "0".to_string(),
4028 smt_sync_eq: "0".to_string(),
4029 spot_copy_trading_eq: "0".to_string(),
4030 fixed_bal: "0".to_string(),
4031 frozen_bal: "0".to_string(),
4032 imr: "0".to_string(),
4033 interest: "0".to_string(),
4034 iso_eq: "0".to_string(),
4035 iso_liab: "0".to_string(),
4036 iso_upl: "0".to_string(),
4037 max_loan: "0".to_string(),
4038 mgn_ratio: "0".to_string(),
4039 mmr: "0".to_string(),
4040 notional_lever: "0".to_string(),
4041 ord_frozen: "0".to_string(),
4042 reward_bal: "0".to_string(),
4043 cl_spot_in_use_amt: "0".to_string(),
4044 max_spot_in_use_amt: "0".to_string(),
4045 spot_iso_bal: "0".to_string(),
4046 stgy_eq: "0".to_string(),
4047 twap: "0".to_string(),
4048 upl: "0".to_string(),
4049 upl_liab: "0".to_string(),
4050 spot_bal: "0".to_string(),
4051 open_avg_px: "0".to_string(),
4052 acc_avg_px: "0".to_string(),
4053 spot_upl: "0".to_string(),
4054 spot_upl_ratio: "0".to_string(),
4055 total_pnl: "0".to_string(),
4056 total_pnl_ratio: "0".to_string(),
4057 };
4058
4059 let account_id = AccountId::new("OKX-001");
4060 let size_precision = 2;
4061 let ts_init = UnixNanos::default();
4062
4063 let result = parse_spot_margin_position_from_balance(
4064 &balance,
4065 account_id,
4066 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4067 size_precision,
4068 ts_init,
4069 )
4070 .unwrap();
4071
4072 assert!(result.is_some());
4073 let report = result.unwrap();
4074 assert_eq!(report.account_id, account_id);
4075 assert_eq!(report.instrument_id.to_string(), "ENA-USDT.OKX".to_string());
4076 assert_eq!(report.position_side, PositionSide::Short.as_specified());
4077 assert_eq!(report.quantity.to_string(), "129950.00");
4078 }
4079
4080 #[rstest]
4081 fn test_parse_spot_margin_position_from_balance_long() {
4082 let balance = OKXBalanceDetail {
4083 ccy: Ustr::from("BTC"),
4084 liab: "1.5".to_string(),
4085 spot_in_use_amt: "1.2".to_string(),
4086 cross_liab: "1.5".to_string(),
4087 eq: "1.2".to_string(),
4088 u_time: 1704067200000,
4089 avail_bal: "0".to_string(),
4090 avail_eq: "0".to_string(),
4091 borrow_froz: "0".to_string(),
4092 cash_bal: "0".to_string(),
4093 dis_eq: "0".to_string(),
4094 eq_usd: "0".to_string(),
4095 smt_sync_eq: "0".to_string(),
4096 spot_copy_trading_eq: "0".to_string(),
4097 fixed_bal: "0".to_string(),
4098 frozen_bal: "0".to_string(),
4099 imr: "0".to_string(),
4100 interest: "0".to_string(),
4101 iso_eq: "0".to_string(),
4102 iso_liab: "0".to_string(),
4103 iso_upl: "0".to_string(),
4104 max_loan: "0".to_string(),
4105 mgn_ratio: "0".to_string(),
4106 mmr: "0".to_string(),
4107 notional_lever: "0".to_string(),
4108 ord_frozen: "0".to_string(),
4109 reward_bal: "0".to_string(),
4110 cl_spot_in_use_amt: "0".to_string(),
4111 max_spot_in_use_amt: "0".to_string(),
4112 spot_iso_bal: "0".to_string(),
4113 stgy_eq: "0".to_string(),
4114 twap: "0".to_string(),
4115 upl: "0".to_string(),
4116 upl_liab: "0".to_string(),
4117 spot_bal: "0".to_string(),
4118 open_avg_px: "0".to_string(),
4119 acc_avg_px: "0".to_string(),
4120 spot_upl: "0".to_string(),
4121 spot_upl_ratio: "0".to_string(),
4122 total_pnl: "0".to_string(),
4123 total_pnl_ratio: "0".to_string(),
4124 };
4125
4126 let account_id = AccountId::new("OKX-001");
4127 let size_precision = 8;
4128 let ts_init = UnixNanos::default();
4129
4130 let result = parse_spot_margin_position_from_balance(
4131 &balance,
4132 account_id,
4133 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4134 size_precision,
4135 ts_init,
4136 )
4137 .unwrap();
4138
4139 assert!(result.is_some());
4140 let report = result.unwrap();
4141 assert_eq!(report.position_side, PositionSide::Long.as_specified());
4142 assert_eq!(report.quantity.to_string(), "1.20000000");
4143 }
4144
4145 #[rstest]
4146 fn test_parse_spot_margin_position_from_balance_usdc_quote() {
4147 let balance = OKXBalanceDetail {
4148 ccy: Ustr::from("ETH"),
4149 liab: "10.5".to_string(),
4150 spot_in_use_amt: "-10.0".to_string(),
4151 cross_liab: "10.5".to_string(),
4152 eq: "-10.0".to_string(),
4153 u_time: 1704067200000,
4154 avail_bal: "0".to_string(),
4155 avail_eq: "0".to_string(),
4156 borrow_froz: "0".to_string(),
4157 cash_bal: "0".to_string(),
4158 dis_eq: "0".to_string(),
4159 eq_usd: "0".to_string(),
4160 smt_sync_eq: "0".to_string(),
4161 spot_copy_trading_eq: "0".to_string(),
4162 fixed_bal: "0".to_string(),
4163 frozen_bal: "0".to_string(),
4164 imr: "0".to_string(),
4165 interest: "0".to_string(),
4166 iso_eq: "0".to_string(),
4167 iso_liab: "0".to_string(),
4168 iso_upl: "0".to_string(),
4169 max_loan: "0".to_string(),
4170 mgn_ratio: "0".to_string(),
4171 mmr: "0".to_string(),
4172 notional_lever: "0".to_string(),
4173 ord_frozen: "0".to_string(),
4174 reward_bal: "0".to_string(),
4175 cl_spot_in_use_amt: "0".to_string(),
4176 max_spot_in_use_amt: "0".to_string(),
4177 spot_iso_bal: "0".to_string(),
4178 stgy_eq: "0".to_string(),
4179 twap: "0".to_string(),
4180 upl: "0".to_string(),
4181 upl_liab: "0".to_string(),
4182 spot_bal: "0".to_string(),
4183 open_avg_px: "0".to_string(),
4184 acc_avg_px: "0".to_string(),
4185 spot_upl: "0".to_string(),
4186 spot_upl_ratio: "0".to_string(),
4187 total_pnl: "0".to_string(),
4188 total_pnl_ratio: "0".to_string(),
4189 };
4190
4191 let account_id = AccountId::new("OKX-001");
4192 let size_precision = 6;
4193 let ts_init = UnixNanos::default();
4194
4195 let result = parse_spot_margin_position_from_balance(
4196 &balance,
4197 account_id,
4198 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4199 size_precision,
4200 ts_init,
4201 )
4202 .unwrap();
4203
4204 assert!(result.is_some());
4205 let report = result.unwrap();
4206 assert_eq!(report.position_side, PositionSide::Short.as_specified());
4207 assert_eq!(report.quantity.to_string(), "10.000000");
4208 assert!(report.instrument_id.to_string().contains("ETH-"));
4209 }
4210
4211 #[rstest]
4212 fn test_parse_spot_margin_position_from_balance_no_position() {
4213 let balance = OKXBalanceDetail {
4214 ccy: Ustr::from("USDT"),
4215 liab: "0".to_string(),
4216 spot_in_use_amt: "0".to_string(),
4217 cross_liab: "0".to_string(),
4218 eq: "1000.5".to_string(),
4219 u_time: 1704067200000,
4220 avail_bal: "1000.5".to_string(),
4221 avail_eq: "1000.5".to_string(),
4222 borrow_froz: "0".to_string(),
4223 cash_bal: "1000.5".to_string(),
4224 dis_eq: "0".to_string(),
4225 eq_usd: "1000.5".to_string(),
4226 smt_sync_eq: "0".to_string(),
4227 spot_copy_trading_eq: "0".to_string(),
4228 fixed_bal: "0".to_string(),
4229 frozen_bal: "0".to_string(),
4230 imr: "0".to_string(),
4231 interest: "0".to_string(),
4232 iso_eq: "0".to_string(),
4233 iso_liab: "0".to_string(),
4234 iso_upl: "0".to_string(),
4235 max_loan: "0".to_string(),
4236 mgn_ratio: "0".to_string(),
4237 mmr: "0".to_string(),
4238 notional_lever: "0".to_string(),
4239 ord_frozen: "0".to_string(),
4240 reward_bal: "0".to_string(),
4241 cl_spot_in_use_amt: "0".to_string(),
4242 max_spot_in_use_amt: "0".to_string(),
4243 spot_iso_bal: "0".to_string(),
4244 stgy_eq: "0".to_string(),
4245 twap: "0".to_string(),
4246 upl: "0".to_string(),
4247 upl_liab: "0".to_string(),
4248 spot_bal: "1000.5".to_string(),
4249 open_avg_px: "0".to_string(),
4250 acc_avg_px: "0".to_string(),
4251 spot_upl: "0".to_string(),
4252 spot_upl_ratio: "0".to_string(),
4253 total_pnl: "0".to_string(),
4254 total_pnl_ratio: "0".to_string(),
4255 };
4256
4257 let account_id = AccountId::new("OKX-001");
4258 let size_precision = 2;
4259 let ts_init = UnixNanos::default();
4260
4261 let result = parse_spot_margin_position_from_balance(
4262 &balance,
4263 account_id,
4264 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4265 size_precision,
4266 ts_init,
4267 )
4268 .unwrap();
4269
4270 assert!(result.is_none());
4271 }
4272
4273 #[rstest]
4274 fn test_parse_spot_margin_position_from_balance_liability_no_spot_in_use() {
4275 let balance = OKXBalanceDetail {
4276 ccy: Ustr::from("BTC"),
4277 liab: "0.5".to_string(),
4278 spot_in_use_amt: "0".to_string(),
4279 cross_liab: "0.5".to_string(),
4280 eq: "0".to_string(),
4281 u_time: 1704067200000,
4282 avail_bal: "0".to_string(),
4283 avail_eq: "0".to_string(),
4284 borrow_froz: "0".to_string(),
4285 cash_bal: "0".to_string(),
4286 dis_eq: "0".to_string(),
4287 eq_usd: "0".to_string(),
4288 smt_sync_eq: "0".to_string(),
4289 spot_copy_trading_eq: "0".to_string(),
4290 fixed_bal: "0".to_string(),
4291 frozen_bal: "0".to_string(),
4292 imr: "0".to_string(),
4293 interest: "0".to_string(),
4294 iso_eq: "0".to_string(),
4295 iso_liab: "0".to_string(),
4296 iso_upl: "0".to_string(),
4297 max_loan: "0".to_string(),
4298 mgn_ratio: "0".to_string(),
4299 mmr: "0".to_string(),
4300 notional_lever: "0".to_string(),
4301 ord_frozen: "0".to_string(),
4302 reward_bal: "0".to_string(),
4303 cl_spot_in_use_amt: "0".to_string(),
4304 max_spot_in_use_amt: "0".to_string(),
4305 spot_iso_bal: "0".to_string(),
4306 stgy_eq: "0".to_string(),
4307 twap: "0".to_string(),
4308 upl: "0".to_string(),
4309 upl_liab: "0".to_string(),
4310 spot_bal: "0".to_string(),
4311 open_avg_px: "0".to_string(),
4312 acc_avg_px: "0".to_string(),
4313 spot_upl: "0".to_string(),
4314 spot_upl_ratio: "0".to_string(),
4315 total_pnl: "0".to_string(),
4316 total_pnl_ratio: "0".to_string(),
4317 };
4318
4319 let account_id = AccountId::new("OKX-001");
4320 let size_precision = 8;
4321 let ts_init = UnixNanos::default();
4322
4323 let result = parse_spot_margin_position_from_balance(
4324 &balance,
4325 account_id,
4326 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4327 size_precision,
4328 ts_init,
4329 )
4330 .unwrap();
4331
4332 assert!(result.is_none());
4333 }
4334
4335 #[rstest]
4336 fn test_parse_spot_margin_position_from_balance_empty_strings() {
4337 let balance = OKXBalanceDetail {
4338 ccy: Ustr::from("USDT"),
4339 liab: String::new(),
4340 spot_in_use_amt: String::new(),
4341 cross_liab: String::new(),
4342 eq: "5000.25".to_string(),
4343 u_time: 1704067200000,
4344 avail_bal: "5000.25".to_string(),
4345 avail_eq: "5000.25".to_string(),
4346 borrow_froz: String::new(),
4347 cash_bal: "5000.25".to_string(),
4348 dis_eq: String::new(),
4349 eq_usd: "5000.25".to_string(),
4350 smt_sync_eq: String::new(),
4351 spot_copy_trading_eq: String::new(),
4352 fixed_bal: String::new(),
4353 frozen_bal: String::new(),
4354 imr: String::new(),
4355 interest: String::new(),
4356 iso_eq: String::new(),
4357 iso_liab: String::new(),
4358 iso_upl: String::new(),
4359 max_loan: String::new(),
4360 mgn_ratio: String::new(),
4361 mmr: String::new(),
4362 notional_lever: String::new(),
4363 ord_frozen: String::new(),
4364 reward_bal: String::new(),
4365 cl_spot_in_use_amt: String::new(),
4366 max_spot_in_use_amt: String::new(),
4367 spot_iso_bal: String::new(),
4368 stgy_eq: String::new(),
4369 twap: String::new(),
4370 upl: String::new(),
4371 upl_liab: String::new(),
4372 spot_bal: "5000.25".to_string(),
4373 open_avg_px: String::new(),
4374 acc_avg_px: String::new(),
4375 spot_upl: String::new(),
4376 spot_upl_ratio: String::new(),
4377 total_pnl: String::new(),
4378 total_pnl_ratio: String::new(),
4379 };
4380
4381 let account_id = AccountId::new("OKX-001");
4382 let size_precision = 2;
4383 let ts_init = UnixNanos::default();
4384
4385 let result = parse_spot_margin_position_from_balance(
4386 &balance,
4387 account_id,
4388 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4389 size_precision,
4390 ts_init,
4391 )
4392 .unwrap();
4393
4394 assert!(result.is_none());
4396 }
4397
4398 #[rstest]
4399 #[case::fok_maps_to_fok_tif(OKXOrderType::Fok, TimeInForce::Fok)]
4400 #[case::ioc_maps_to_ioc_tif(OKXOrderType::Ioc, TimeInForce::Ioc)]
4401 #[case::optimal_limit_ioc_maps_to_ioc_tif(OKXOrderType::OptimalLimitIoc, TimeInForce::Ioc)]
4402 #[case::market_maps_to_gtc(OKXOrderType::Market, TimeInForce::Gtc)]
4403 #[case::limit_maps_to_gtc(OKXOrderType::Limit, TimeInForce::Gtc)]
4404 #[case::post_only_maps_to_gtc(OKXOrderType::PostOnly, TimeInForce::Gtc)]
4405 #[case::trigger_maps_to_gtc(OKXOrderType::Trigger, TimeInForce::Gtc)]
4406 fn test_okx_order_type_to_time_in_force(
4407 #[case] okx_ord_type: OKXOrderType,
4408 #[case] expected_tif: TimeInForce,
4409 ) {
4410 let time_in_force = match okx_ord_type {
4411 OKXOrderType::Fok => TimeInForce::Fok,
4412 OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
4413 _ => TimeInForce::Gtc,
4414 };
4415
4416 assert_eq!(
4417 time_in_force, expected_tif,
4418 "OKXOrderType::{okx_ord_type:?} should map to TimeInForce::{expected_tif:?}"
4419 );
4420 }
4421
4422 #[rstest]
4423 fn test_fok_order_type_serialization() {
4424 let ord_type = OKXOrderType::Fok;
4425 let json = serde_json::to_string(&ord_type).expect("serialize");
4426 assert_eq!(json, "\"fok\"", "FOK should serialize to 'fok'");
4427 }
4428
4429 #[rstest]
4430 fn test_ioc_order_type_serialization() {
4431 let ord_type = OKXOrderType::Ioc;
4432 let json = serde_json::to_string(&ord_type).expect("serialize");
4433 assert_eq!(json, "\"ioc\"", "IOC should serialize to 'ioc'");
4434 }
4435
4436 #[rstest]
4437 fn test_optimal_limit_ioc_serialization() {
4438 let ord_type = OKXOrderType::OptimalLimitIoc;
4439 let json = serde_json::to_string(&ord_type).expect("serialize");
4440 assert_eq!(
4441 json, "\"optimal_limit_ioc\"",
4442 "OptimalLimitIoc should serialize to 'optimal_limit_ioc'"
4443 );
4444 }
4445
4446 #[rstest]
4447 fn test_fok_order_type_deserialization() {
4448 let json = "\"fok\"";
4449 let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
4450 assert_eq!(ord_type, OKXOrderType::Fok);
4451 }
4452
4453 #[rstest]
4454 fn test_ioc_order_type_deserialization() {
4455 let json = "\"ioc\"";
4456 let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
4457 assert_eq!(ord_type, OKXOrderType::Ioc);
4458 }
4459
4460 #[rstest]
4461 fn test_optimal_limit_ioc_deserialization() {
4462 let json = "\"optimal_limit_ioc\"";
4463 let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
4464 assert_eq!(ord_type, OKXOrderType::OptimalLimitIoc);
4465 }
4466
4467 #[rstest]
4468 #[case(TimeInForce::Fok, OKXOrderType::Fok)]
4469 #[case(TimeInForce::Ioc, OKXOrderType::Ioc)]
4470 fn test_time_in_force_round_trip(
4471 #[case] original_tif: TimeInForce,
4472 #[case] expected_okx_type: OKXOrderType,
4473 ) {
4474 let okx_ord_type = match original_tif {
4475 TimeInForce::Fok => OKXOrderType::Fok,
4476 TimeInForce::Ioc => OKXOrderType::Ioc,
4477 TimeInForce::Gtc => OKXOrderType::Limit,
4478 _ => OKXOrderType::Limit,
4479 };
4480 assert_eq!(okx_ord_type, expected_okx_type);
4481
4482 let parsed_tif = match okx_ord_type {
4483 OKXOrderType::Fok => TimeInForce::Fok,
4484 OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
4485 _ => TimeInForce::Gtc,
4486 };
4487 assert_eq!(parsed_tif, original_tif);
4488 }
4489
4490 #[rstest]
4491 #[case::limit_fok(
4492 OrderType::Limit,
4493 TimeInForce::Fok,
4494 OKXOrderType::Fok,
4495 "Limit + FOK should map to Fok"
4496 )]
4497 #[case::limit_ioc(
4498 OrderType::Limit,
4499 TimeInForce::Ioc,
4500 OKXOrderType::Ioc,
4501 "Limit + IOC should map to Ioc"
4502 )]
4503 #[case::market_ioc(
4504 OrderType::Market,
4505 TimeInForce::Ioc,
4506 OKXOrderType::OptimalLimitIoc,
4507 "Market + IOC should map to OptimalLimitIoc"
4508 )]
4509 #[case::limit_gtc(
4510 OrderType::Limit,
4511 TimeInForce::Gtc,
4512 OKXOrderType::Limit,
4513 "Limit + GTC should map to Limit"
4514 )]
4515 #[case::market_gtc(
4516 OrderType::Market,
4517 TimeInForce::Gtc,
4518 OKXOrderType::Market,
4519 "Market + GTC should map to Market"
4520 )]
4521 fn test_order_type_time_in_force_combinations(
4522 #[case] order_type: OrderType,
4523 #[case] tif: TimeInForce,
4524 #[case] expected_okx_type: OKXOrderType,
4525 #[case] description: &str,
4526 ) {
4527 let okx_ord_type = match (order_type, tif) {
4528 (OrderType::Market, TimeInForce::Ioc) => OKXOrderType::OptimalLimitIoc,
4529 (OrderType::Limit, TimeInForce::Fok) => OKXOrderType::Fok,
4530 (OrderType::Limit, TimeInForce::Ioc) => OKXOrderType::Ioc,
4531 _ => OKXOrderType::from(order_type),
4532 };
4533
4534 assert_eq!(okx_ord_type, expected_okx_type, "{description}");
4535 }
4536
4537 #[rstest]
4538 fn test_market_fok_not_supported() {
4539 let order_type = OrderType::Market;
4540 let tif = TimeInForce::Fok;
4541
4542 let is_market_fok = matches!((order_type, tif), (OrderType::Market, TimeInForce::Fok));
4543 assert!(
4544 is_market_fok,
4545 "Market + FOK combination should be identified for rejection"
4546 );
4547 }
4548
4549 #[rstest]
4550 #[case::empty_string("", true)]
4551 #[case::zero("0", true)]
4552 #[case::minus_one("-1", true)]
4553 #[case::minus_two("-2", true)]
4554 #[case::normal_price("100.5", false)]
4555 #[case::another_price("0.001", false)]
4556 fn test_is_market_price(#[case] price: &str, #[case] expected: bool) {
4557 assert_eq!(is_market_price(price), expected);
4558 }
4559
4560 #[rstest]
4561 #[case::fok_market(OKXOrderType::Fok, "", OrderType::Market)]
4562 #[case::fok_limit(OKXOrderType::Fok, "100.5", OrderType::Limit)]
4563 #[case::ioc_market(OKXOrderType::Ioc, "", OrderType::Market)]
4564 #[case::ioc_limit(OKXOrderType::Ioc, "100.5", OrderType::Limit)]
4565 #[case::optimal_limit_ioc_market(OKXOrderType::OptimalLimitIoc, "", OrderType::Market)]
4566 #[case::optimal_limit_ioc_market_zero(OKXOrderType::OptimalLimitIoc, "0", OrderType::Market)]
4567 #[case::optimal_limit_ioc_market_minus_one(
4568 OKXOrderType::OptimalLimitIoc,
4569 "-1",
4570 OrderType::Market
4571 )]
4572 #[case::optimal_limit_ioc_limit(OKXOrderType::OptimalLimitIoc, "100.5", OrderType::Limit)]
4573 #[case::market_passthrough(OKXOrderType::Market, "", OrderType::Market)]
4574 #[case::limit_passthrough(OKXOrderType::Limit, "100.5", OrderType::Limit)]
4575 fn test_determine_order_type(
4576 #[case] okx_ord_type: OKXOrderType,
4577 #[case] price: &str,
4578 #[case] expected: OrderType,
4579 ) {
4580 assert_eq!(determine_order_type(okx_ord_type, price), expected);
4581 }
4582}