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