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