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