1use std::str::FromStr;
19
20use anyhow::Context;
21pub use nautilus_core::serialization::{
22 deserialize_empty_string_as_none, deserialize_empty_ustr_as_none,
23 deserialize_optional_string_to_u64, deserialize_string_to_u64,
24};
25use nautilus_core::{UUID4, datetime::NANOSECONDS_IN_MILLISECOND, nanos::UnixNanos};
26use nautilus_model::{
27 data::{
28 Bar, BarSpecification, BarType, Data, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
29 TradeTick,
30 bar::{
31 BAR_SPEC_1_DAY_LAST, BAR_SPEC_1_HOUR_LAST, BAR_SPEC_1_MINUTE_LAST,
32 BAR_SPEC_1_MONTH_LAST, BAR_SPEC_1_SECOND_LAST, BAR_SPEC_1_WEEK_LAST,
33 BAR_SPEC_2_DAY_LAST, BAR_SPEC_2_HOUR_LAST, BAR_SPEC_3_DAY_LAST, BAR_SPEC_3_MINUTE_LAST,
34 BAR_SPEC_3_MONTH_LAST, BAR_SPEC_4_HOUR_LAST, BAR_SPEC_5_DAY_LAST,
35 BAR_SPEC_5_MINUTE_LAST, BAR_SPEC_6_HOUR_LAST, BAR_SPEC_6_MONTH_LAST,
36 BAR_SPEC_12_HOUR_LAST, BAR_SPEC_12_MONTH_LAST, BAR_SPEC_15_MINUTE_LAST,
37 BAR_SPEC_30_MINUTE_LAST,
38 },
39 },
40 enums::{
41 AccountType, AggregationSource, AggressorSide, LiquiditySide, MarketStatusAction,
42 OptionKind, OrderSide, OrderStatus, OrderType, PositionSide, TimeInForce,
43 },
44 events::AccountState,
45 identifiers::{
46 AccountId, ClientOrderId, InstrumentId, PositionId, Symbol, TradeId, VenueOrderId,
47 },
48 instruments::{CryptoFuture, CryptoOption, CryptoPerpetual, CurrencyPair, InstrumentAny},
49 reports::{FillReport, OrderStatusReport, PositionStatusReport},
50 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
51};
52use rust_decimal::Decimal;
53use serde::{Deserialize, Deserializer, de::DeserializeOwned};
54use ustr::Ustr;
55
56use super::enums::OKXContractType;
57use crate::{
58 common::{
59 consts::OKX_VENUE,
60 enums::{
61 OKXExecType, OKXInstrumentStatus, OKXInstrumentType, OKXOrderCategory, OKXOrderStatus,
62 OKXOrderType, OKXPositionSide, OKXSide, OKXTargetCurrency, OKXVipLevel,
63 },
64 models::OKXInstrument,
65 },
66 http::models::{
67 OKXAccount, OKXBalanceDetail, OKXCandlestick, OKXFundingRateHistory, OKXIndexTicker,
68 OKXMarkPrice, OKXOrderHistory, OKXPosition, OKXTrade, OKXTransactionDetail,
69 },
70 websocket::{enums::OKXWsChannel, messages::OKXFundingRateMsg},
71};
72
73pub fn is_market_price(px: &str) -> bool {
81 px.is_empty() || px == "0" || px == "-1" || px == "-2"
82}
83
84pub fn determine_order_type(okx_ord_type: OKXOrderType, px: &str) -> OrderType {
89 determine_order_type_with_alt(okx_ord_type, px, "", "")
90}
91
92pub fn determine_order_type_with_alt(
98 okx_ord_type: OKXOrderType,
99 px: &str,
100 px_vol: &str,
101 px_usd: &str,
102) -> OrderType {
103 match okx_ord_type {
104 OKXOrderType::OpFok => OrderType::Limit,
105 OKXOrderType::Fok | OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => {
106 let has_alt_price = !px_vol.is_empty() || !px_usd.is_empty();
107 if has_alt_price || !is_market_price(px) {
108 OrderType::Limit
109 } else {
110 OrderType::Market
111 }
112 }
113 _ => okx_ord_type.into(),
114 }
115}
116
117pub fn deserialize_target_currency_as_none<'de, D>(
123 deserializer: D,
124) -> Result<Option<OKXTargetCurrency>, D::Error>
125where
126 D: Deserializer<'de>,
127{
128 let s = String::deserialize(deserializer)?;
129 if s.is_empty() {
130 Ok(None)
131 } else {
132 s.parse().map(Some).map_err(serde::de::Error::custom)
133 }
134}
135
136pub fn deserialize_vip_level<'de, D>(deserializer: D) -> Result<OKXVipLevel, D::Error>
150where
151 D: Deserializer<'de>,
152{
153 let s = String::deserialize(deserializer)?;
154
155 if s.is_empty() {
156 return Ok(OKXVipLevel::Vip0);
157 }
158
159 let level_str = if s.len() >= 3 && s[..3].eq_ignore_ascii_case("vip") {
160 &s[3..]
161 } else if s.len() >= 2 && s[..2].eq_ignore_ascii_case("lv") {
162 &s[2..]
163 } else {
164 &s
165 };
166
167 let level_num = level_str
168 .parse::<u8>()
169 .map_err(|e| serde::de::Error::custom(format!("Invalid VIP level '{s}': {e}")))?;
170
171 Ok(OKXVipLevel::from(level_num))
172}
173
174pub fn okx_instrument_type(instrument: &InstrumentAny) -> anyhow::Result<OKXInstrumentType> {
181 match instrument {
182 InstrumentAny::CurrencyPair(_) => Ok(OKXInstrumentType::Spot),
183 InstrumentAny::CryptoPerpetual(_) => Ok(OKXInstrumentType::Swap),
184 InstrumentAny::CryptoFuture(_) => Ok(OKXInstrumentType::Futures),
185 InstrumentAny::CryptoOption(_) => Ok(OKXInstrumentType::Option),
186 _ => anyhow::bail!("Invalid instrument type for OKX: {instrument:?}"),
187 }
188}
189
190pub fn okx_instrument_type_from_symbol(symbol: &str) -> OKXInstrumentType {
199 let dash_count = symbol.bytes().filter(|&b| b == b'-').count();
201
202 match dash_count {
203 1 => OKXInstrumentType::Spot, 2 => {
205 let suffix = symbol.rsplit('-').next().unwrap_or("");
207 if suffix == "SWAP" {
208 OKXInstrumentType::Swap
209 } else if suffix.len() == 6 && suffix.bytes().all(|b| b.is_ascii_digit()) {
210 OKXInstrumentType::Futures
212 } else {
213 OKXInstrumentType::Spot
214 }
215 }
216 4 => OKXInstrumentType::Option, _ => OKXInstrumentType::Spot, }
219}
220
221pub fn parse_base_quote_from_symbol(symbol: &str) -> anyhow::Result<(&str, &str)> {
229 let mut parts = symbol.split('-');
230 let base = parts.next().ok_or_else(|| {
231 anyhow::anyhow!("Invalid symbol format: missing base currency in '{symbol}'")
232 })?;
233 let quote = parts.next().ok_or_else(|| {
234 anyhow::anyhow!("Invalid symbol format: missing quote currency in '{symbol}'")
235 })?;
236 Ok((base, quote))
237}
238
239pub fn extract_inst_family(symbol: &str) -> anyhow::Result<Ustr> {
248 let (base, quote) = parse_base_quote_from_symbol(symbol)?;
249 Ok(Ustr::from(&format!("{base}-{quote}")))
250}
251
252#[must_use]
254pub fn okx_status_to_market_action(status: OKXInstrumentStatus) -> MarketStatusAction {
255 match status {
256 OKXInstrumentStatus::Live => MarketStatusAction::Trading,
257 OKXInstrumentStatus::Suspend => MarketStatusAction::Suspend,
258 OKXInstrumentStatus::Preopen => MarketStatusAction::PreOpen,
259 OKXInstrumentStatus::Test => MarketStatusAction::NotAvailableForTrading,
260 }
261}
262
263#[must_use]
265pub fn parse_instrument_id(symbol: Ustr) -> InstrumentId {
266 InstrumentId::new(Symbol::from_ustr_unchecked(symbol), *OKX_VENUE)
267}
268
269#[must_use]
271pub fn parse_client_order_id(value: &str) -> Option<ClientOrderId> {
272 if value.is_empty() {
273 None
274 } else {
275 Some(ClientOrderId::new(value))
276 }
277}
278
279#[must_use]
282pub fn parse_millisecond_timestamp(timestamp_ms: u64) -> UnixNanos {
283 UnixNanos::from(timestamp_ms * NANOSECONDS_IN_MILLISECOND)
284}
285
286pub fn parse_rfc3339_timestamp(timestamp: &str) -> anyhow::Result<UnixNanos> {
293 let dt = chrono::DateTime::parse_from_rfc3339(timestamp)?;
294 let nanos = dt.timestamp_nanos_opt().ok_or_else(|| {
295 anyhow::anyhow!("Failed to extract nanoseconds from timestamp: {timestamp}")
296 })?;
297
298 if nanos < 0 {
299 anyhow::bail!("Negative nanosecond timestamp from: {timestamp}");
300 }
301 Ok(UnixNanos::from(nanos as u64))
302}
303
304pub fn parse_price(value: &str, precision: u8) -> anyhow::Result<Price> {
311 let decimal = Decimal::from_str(value)?;
312 Price::from_decimal_dp(decimal, precision).map_err(Into::into)
313}
314
315pub fn parse_quantity(value: &str, precision: u8) -> anyhow::Result<Quantity> {
322 let decimal = Decimal::from_str(value)?;
323 Quantity::from_decimal_dp(decimal, precision).map_err(Into::into)
324}
325
326pub fn parse_fee(value: Option<&str>, currency: Currency) -> anyhow::Result<Money> {
336 let decimal = Decimal::from_str(value.unwrap_or("0"))?;
339 Money::from_decimal(-decimal, currency).map_err(Into::into)
340}
341
342pub fn parse_fee_currency(
347 fee_ccy: &str,
348 fee_amount: Decimal,
349 context: impl FnOnce() -> String,
350) -> Currency {
351 let trimmed = fee_ccy.trim();
352 if trimmed.is_empty() {
353 if !fee_amount.is_zero() {
354 let ctx = context();
355 log::warn!(
356 "Empty fee_ccy in {ctx} with non-zero fee={fee_amount}, using USDT as fallback"
357 );
358 }
359 return Currency::USDT();
360 }
361
362 Currency::get_or_create_crypto_with_context(trimmed, Some(&context()))
363}
364
365pub fn parse_aggressor_side(side: &Option<OKXSide>) -> AggressorSide {
367 match side {
368 Some(OKXSide::Buy) => AggressorSide::Buyer,
369 Some(OKXSide::Sell) => AggressorSide::Seller,
370 None => AggressorSide::NoAggressor,
371 }
372}
373
374pub fn parse_execution_type(liquidity: &Option<OKXExecType>) -> LiquiditySide {
376 match liquidity {
377 Some(OKXExecType::Maker) => LiquiditySide::Maker,
378 Some(OKXExecType::Taker) => LiquiditySide::Taker,
379 _ => LiquiditySide::NoLiquiditySide,
380 }
381}
382
383pub fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
385 match current_qty {
386 Some(qty) if qty > 0 => PositionSide::Long,
387 Some(qty) if qty < 0 => PositionSide::Short,
388 _ => PositionSide::Flat,
389 }
390}
391
392pub fn parse_mark_price_update(
399 raw: &OKXMarkPrice,
400 instrument_id: InstrumentId,
401 price_precision: u8,
402 ts_init: UnixNanos,
403) -> anyhow::Result<MarkPriceUpdate> {
404 let ts_event = parse_millisecond_timestamp(raw.ts);
405 let price = parse_price(&raw.mark_px, price_precision)?;
406 Ok(MarkPriceUpdate::new(
407 instrument_id,
408 price,
409 ts_event,
410 ts_init,
411 ))
412}
413
414pub fn parse_index_price_update(
421 raw: &OKXIndexTicker,
422 instrument_id: InstrumentId,
423 price_precision: u8,
424 ts_init: UnixNanos,
425) -> anyhow::Result<IndexPriceUpdate> {
426 let ts_event = parse_millisecond_timestamp(raw.ts);
427 let price = parse_price(&raw.idx_px, price_precision)?;
428 Ok(IndexPriceUpdate::new(
429 instrument_id,
430 price,
431 ts_event,
432 ts_init,
433 ))
434}
435
436pub fn parse_funding_rate_msg(
443 msg: &OKXFundingRateMsg,
444 instrument_id: InstrumentId,
445 ts_init: UnixNanos,
446) -> anyhow::Result<FundingRateUpdate> {
447 let funding_rate = msg
448 .funding_rate
449 .as_str()
450 .parse::<Decimal>()
451 .map_err(|e| anyhow::anyhow!("Invalid funding_rate value: {e}"))?;
452
453 let funding_time = parse_millisecond_timestamp(msg.funding_time);
454 let next_funding_time = parse_millisecond_timestamp(msg.next_funding_time);
455 let funding_interval_nanos =
456 next_funding_time
457 .duration_since(&funding_time)
458 .ok_or(anyhow::anyhow!(
459 "Invalid funding_interval, cannot be negative"
460 ))?;
461 let funding_interval = u16::try_from(funding_interval_nanos / 60_000_000_000)
462 .context("funding_interval out of bounds")?;
463 let ts_event = parse_millisecond_timestamp(msg.ts);
464
465 Ok(FundingRateUpdate::new(
466 instrument_id,
467 funding_rate,
468 Some(funding_interval),
469 Some(funding_time),
470 ts_event,
471 ts_init,
472 ))
473}
474
475pub fn parse_funding_rate(
482 raw: &OKXFundingRateHistory,
483 instrument_id: InstrumentId,
484 interval_millis: Option<u64>,
485) -> anyhow::Result<FundingRateUpdate> {
486 let funding_rate =
487 Decimal::from_str(&raw.funding_rate).context("invalid funding_rate value")?;
488 let ts_event = UnixNanos::from(raw.funding_time * NANOSECONDS_IN_MILLISECOND);
489 let interval = interval_millis
490 .map(|ms| u16::try_from(ms / 60_000).context("interval milliseconds out of bounds"))
491 .transpose()?;
492
493 Ok(FundingRateUpdate::new(
494 instrument_id,
495 funding_rate,
496 interval,
497 None,
498 ts_event,
499 ts_event,
500 ))
501}
502
503pub fn parse_trade_tick(
510 raw: &OKXTrade,
511 instrument_id: InstrumentId,
512 price_precision: u8,
513 size_precision: u8,
514 ts_init: UnixNanos,
515) -> anyhow::Result<TradeTick> {
516 let ts_event = parse_millisecond_timestamp(raw.ts);
517 let price = parse_price(&raw.px, price_precision)?;
518 let size = parse_quantity(&raw.sz, size_precision)?;
519 let aggressor: AggressorSide = raw.side.into();
520 let trade_id = TradeId::new(raw.trade_id);
521
522 TradeTick::new_checked(
523 instrument_id,
524 price,
525 size,
526 aggressor,
527 trade_id,
528 ts_event,
529 ts_init,
530 )
531}
532
533pub fn parse_candlestick(
540 raw: &OKXCandlestick,
541 bar_type: BarType,
542 price_precision: u8,
543 size_precision: u8,
544 ts_init: UnixNanos,
545) -> anyhow::Result<Bar> {
546 let ts_event = parse_millisecond_timestamp(raw.0.parse()?);
547 let open = parse_price(&raw.1, price_precision)?;
548 let high = parse_price(&raw.2, price_precision)?;
549 let low = parse_price(&raw.3, price_precision)?;
550 let close = parse_price(&raw.4, price_precision)?;
551 let volume = parse_quantity(&raw.5, size_precision)?;
552
553 Ok(Bar::new(
554 bar_type, open, high, low, close, volume, ts_event, ts_init,
555 ))
556}
557
558#[expect(clippy::too_many_lines)]
564pub fn parse_order_status_report(
565 order: &OKXOrderHistory,
566 account_id: AccountId,
567 instrument_id: InstrumentId,
568 price_precision: u8,
569 size_precision: u8,
570 ts_init: UnixNanos,
571) -> anyhow::Result<OrderStatusReport> {
572 match order.category {
573 OKXOrderCategory::FullLiquidation | OKXOrderCategory::PartialLiquidation => {
574 log::warn!(
575 "Liquidation order (HTTP history): ord_id={}, category={:?}, inst_id={}, state={:?}, side={:?}, sz={}, fill_sz={}",
576 order.ord_id,
577 order.category,
578 instrument_id,
579 order.state,
580 order.side,
581 order.sz,
582 order.acc_fill_sz,
583 );
584 }
585 OKXOrderCategory::Adl => {
586 log::warn!(
587 "ADL (Auto-Deleveraging) order (HTTP history): ord_id={}, inst_id={}, state={:?}, side={:?}, sz={}, fill_sz={}",
588 order.ord_id,
589 instrument_id,
590 order.state,
591 order.side,
592 order.sz,
593 order.acc_fill_sz,
594 );
595 }
596 _ => {}
597 }
598
599 let okx_ord_type: OKXOrderType = order.ord_type;
600 let order_type =
601 determine_order_type_with_alt(okx_ord_type, &order.px, &order.px_vol, &order.px_usd);
602
603 let is_quote_qty_explicit = order.tgt_ccy == Some(OKXTargetCurrency::QuoteCcy);
609
610 let is_quote_qty_heuristic = order.tgt_ccy.is_none()
615 && (order.inst_type == OKXInstrumentType::Spot
616 || order.inst_type == OKXInstrumentType::Margin)
617 && order.side == OKXSide::Buy
618 && order_type == OrderType::Market;
619
620 let (quantity, filled_qty) = if is_quote_qty_explicit || is_quote_qty_heuristic {
621 let sz_quote_dec = Decimal::from_str(&order.sz).ok();
623
624 let conversion_price_dec = if !order.px.is_empty() && order.px != "0" {
627 Decimal::from_str(&order.px).ok()
629 } else if !order.avg_px.is_empty() && order.avg_px != "0" {
630 Decimal::from_str(&order.avg_px).ok()
632 } else {
633 log::warn!(
634 "No price available for conversion: ord_id={}, px='{}', avg_px='{}'",
635 order.ord_id.as_str(),
636 order.px,
637 order.avg_px
638 );
639 None
640 };
641
642 let quantity_base = if let (Some(sz), Some(price)) = (sz_quote_dec, conversion_price_dec) {
644 if price.is_zero() {
645 log::warn!(
646 "Cannot convert quote quantity with zero price: ord_id={}, sz={}, using sz as-is",
647 order.ord_id.as_str(),
648 order.sz
649 );
650 Quantity::from_str(&order.sz).map_err(|e| {
651 anyhow::anyhow!(
652 "Failed to parse fallback quantity for ord_id={}, sz='{}': {e}",
653 order.ord_id.as_str(),
654 order.sz
655 )
656 })?
657 } else {
658 let quantity_dec = sz / price;
659 Quantity::from_decimal_dp(quantity_dec, size_precision).map_err(|e| {
660 anyhow::anyhow!(
661 "Failed to convert quote-to-base quantity for ord_id={}, sz={sz}, price={price}, quantity_dec={quantity_dec}: {e}",
662 order.ord_id.as_str()
663 )
664 })?
665 }
666 } else {
667 log::warn!(
668 "Cannot convert quote quantity to base without price, using raw sz: \
669 ord_id={}, sz={}, px='{}', avg_px='{}'",
670 order.ord_id.as_str(),
671 order.sz,
672 order.px,
673 order.avg_px
674 );
675 Quantity::from_str(&order.sz).map_err(|e| {
676 anyhow::anyhow!(
677 "Failed to parse fallback quantity for ord_id={}, sz='{}': {e}",
678 order.ord_id.as_str(),
679 order.sz
680 )
681 })?
682 };
683
684 let filled_qty_dec = parse_quantity(&order.acc_fill_sz, size_precision).map_err(|e| {
685 anyhow::anyhow!(
686 "Failed to parse filled quantity for ord_id={}, acc_fill_sz='{}': {e}",
687 order.ord_id.as_str(),
688 order.acc_fill_sz
689 )
690 })?;
691
692 (quantity_base, filled_qty_dec)
693 } else {
694 let quantity_dec = parse_quantity(&order.sz, size_precision).map_err(|e| {
696 anyhow::anyhow!(
697 "Failed to parse base quantity for ord_id={}, sz='{}': {e}",
698 order.ord_id.as_str(),
699 order.sz
700 )
701 })?;
702 let filled_qty_dec = parse_quantity(&order.acc_fill_sz, size_precision).map_err(|e| {
703 anyhow::anyhow!(
704 "Failed to parse filled quantity for ord_id={}, acc_fill_sz='{}': {e}",
705 order.ord_id.as_str(),
706 order.acc_fill_sz
707 )
708 })?;
709
710 (quantity_dec, filled_qty_dec)
711 };
712
713 let (quantity, filled_qty) = if (is_quote_qty_explicit || is_quote_qty_heuristic)
716 && order.state == OKXOrderStatus::Filled
717 && filled_qty.is_positive()
718 {
719 (filled_qty, filled_qty)
720 } else {
721 (quantity, filled_qty)
722 };
723
724 let order_side: OrderSide = order.side.into();
725 let okx_status: OKXOrderStatus = order.state;
726 let order_status: OrderStatus = okx_status.into();
727 let time_in_force = match okx_ord_type {
728 OKXOrderType::Fok | OKXOrderType::OpFok => TimeInForce::Fok,
729 OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
730 _ => TimeInForce::Gtc,
731 };
732
733 let mut client_order_id = if order.cl_ord_id.is_empty() {
734 None
735 } else {
736 Some(ClientOrderId::new(order.cl_ord_id.as_str()))
737 };
738
739 let mut linked_ids = Vec::new();
740
741 if let Some(algo_cl_ord_id) = order
742 .algo_cl_ord_id
743 .as_ref()
744 .filter(|value| !value.as_str().is_empty())
745 {
746 let algo_client_id = ClientOrderId::new(algo_cl_ord_id.as_str());
747 match &client_order_id {
748 Some(existing) if existing == &algo_client_id => {}
749 Some(_) => linked_ids.push(algo_client_id),
750 None => client_order_id = Some(algo_client_id),
751 }
752 }
753
754 if let Some(attach_algo_cl_ord_id) = order
755 .attach_algo_cl_ord_id
756 .as_ref()
757 .filter(|value| !value.as_str().is_empty())
758 {
759 let attach_client_id = ClientOrderId::new(attach_algo_cl_ord_id.as_str());
760 match &client_order_id {
761 Some(existing) if existing == &attach_client_id => {}
762 _ if linked_ids.contains(&attach_client_id) => {}
763 _ => linked_ids.push(attach_client_id),
764 }
765 }
766
767 for attach_algo in &order.attach_algo_ords {
768 if attach_algo.attach_algo_cl_ord_id.is_empty() {
769 continue;
770 }
771
772 let attach_client_id = ClientOrderId::new(attach_algo.attach_algo_cl_ord_id.as_str());
773 match &client_order_id {
774 Some(existing) if existing == &attach_client_id => {}
775 _ if linked_ids.contains(&attach_client_id) => {}
776 _ => linked_ids.push(attach_client_id),
777 }
778 }
779
780 let venue_order_id = if order.ord_id.is_empty() {
781 if let Some(algo_id) = order
782 .algo_id
783 .as_ref()
784 .filter(|value| !value.as_str().is_empty())
785 {
786 VenueOrderId::new(algo_id.as_str())
787 } else if !order.cl_ord_id.is_empty() {
788 VenueOrderId::new(order.cl_ord_id.as_str())
789 } else {
790 let synthetic_id = format!("{}:{}", account_id, order.c_time);
791 VenueOrderId::new(&synthetic_id)
792 }
793 } else {
794 VenueOrderId::new(order.ord_id.as_str())
795 };
796
797 let ts_accepted = parse_millisecond_timestamp(order.c_time);
798 let ts_last = UnixNanos::from(order.u_time * NANOSECONDS_IN_MILLISECOND);
799
800 let mut report = OrderStatusReport::new(
801 account_id,
802 instrument_id,
803 client_order_id,
804 venue_order_id,
805 order_side,
806 order_type,
807 time_in_force,
808 order_status,
809 quantity,
810 filled_qty,
811 ts_accepted,
812 ts_last,
813 ts_init,
814 None,
815 );
816
817 if !order.px.is_empty()
819 && let Ok(decimal) = Decimal::from_str(&order.px)
820 && let Ok(price) = Price::from_decimal_dp(decimal, price_precision)
821 {
822 report = report.with_price(price);
823 }
824
825 if !order.avg_px.is_empty()
826 && let Ok(decimal) = Decimal::from_str(&order.avg_px)
827 {
828 report.avg_px = Some(decimal);
829 }
830
831 if order.ord_type == OKXOrderType::PostOnly {
832 report = report.with_post_only(true);
833 }
834
835 if order.reduce_only == "true" {
836 report = report.with_reduce_only(true);
837 }
838
839 if !linked_ids.is_empty() {
840 report = report.with_linked_order_ids(linked_ids);
841 }
842
843 Ok(report)
844}
845
846pub fn parse_spot_margin_position_from_balance(
862 balance: &OKXBalanceDetail,
863 account_id: AccountId,
864 instrument_id: InstrumentId,
865 size_precision: u8,
866 ts_init: UnixNanos,
867) -> anyhow::Result<Option<PositionStatusReport>> {
868 let liab_str = if balance.liab.trim().is_empty() {
870 "0"
871 } else {
872 balance.liab.trim()
873 };
874 let spot_in_use_str = if balance.spot_in_use_amt.trim().is_empty() {
875 "0"
876 } else {
877 balance.spot_in_use_amt.trim()
878 };
879
880 let liab_dec = Decimal::from_str(liab_str)
881 .map_err(|e| anyhow::anyhow!("Failed to parse liab '{liab_str}': {e}"))?;
882 let spot_in_use_dec = Decimal::from_str(spot_in_use_str)
883 .map_err(|e| anyhow::anyhow!("Failed to parse spotInUseAmt '{spot_in_use_str}': {e}"))?;
884
885 if liab_dec.is_zero() && spot_in_use_dec.is_zero() {
887 return Ok(None);
888 }
889
890 if spot_in_use_dec.is_zero() {
892 return Ok(None);
894 }
895
896 let (position_side, quantity_dec) = if spot_in_use_dec.is_sign_negative() {
898 (PositionSide::Short, spot_in_use_dec.abs())
900 } else {
901 (PositionSide::Long, spot_in_use_dec)
903 };
904
905 let quantity = Quantity::from_decimal_dp(quantity_dec, size_precision)
906 .map_err(|e| anyhow::anyhow!("Failed to create quantity from {quantity_dec}: {e}"))?;
907
908 let ts_last = parse_millisecond_timestamp(balance.u_time);
909
910 Ok(Some(PositionStatusReport::new(
911 account_id,
912 instrument_id,
913 position_side.as_specified(),
914 quantity,
915 ts_last,
916 ts_init,
917 None, None, None, )))
921}
922
923pub fn parse_position_status_report(
942 position: &OKXPosition,
943 account_id: AccountId,
944 instrument_id: InstrumentId,
945 size_precision: u8,
946 ts_init: UnixNanos,
947) -> anyhow::Result<PositionStatusReport> {
948 let pos_dec = Decimal::from_str(&position.pos).map_err(|e| {
949 anyhow::anyhow!(
950 "Failed to parse position quantity '{}' for instrument {}: {e:?}",
951 position.pos,
952 instrument_id
953 )
954 })?;
955
956 let (position_side, quantity_dec) = if position.inst_type == OKXInstrumentType::Spot
961 || position.inst_type == OKXInstrumentType::Margin
962 {
963 let (base_ccy, quote_ccy) = parse_base_quote_from_symbol(instrument_id.symbol.as_str())?;
965
966 let pos_ccy = position.pos_ccy.as_str();
967
968 if pos_ccy.is_empty() || pos_dec.is_zero() {
969 (PositionSide::Flat, Decimal::ZERO)
971 } else if pos_ccy == base_ccy {
972 (PositionSide::Long, pos_dec.abs())
974 } else if pos_ccy == quote_ccy {
975 let avg_px_str = if position.avg_px.is_empty() {
978 &position.mark_px
980 } else {
981 &position.avg_px
982 };
983 let avg_px_dec = Decimal::from_str(avg_px_str)?;
984
985 if avg_px_dec.is_zero() {
986 anyhow::bail!(
987 "Cannot convert SHORT position from quote to base: avg_px is zero for {instrument_id}"
988 );
989 }
990
991 let quantity_dec = (pos_dec.abs() / avg_px_dec).round_dp(size_precision as u32);
992 (PositionSide::Short, quantity_dec)
993 } else {
994 anyhow::bail!(
995 "Unknown position currency '{pos_ccy}' for instrument {instrument_id} (base={base_ccy}, quote={quote_ccy})"
996 );
997 }
998 } else {
999 let side = match position.pos_side {
1004 OKXPositionSide::Net | OKXPositionSide::None => {
1005 if pos_dec.is_sign_positive() && !pos_dec.is_zero() {
1007 PositionSide::Long
1008 } else if pos_dec.is_sign_negative() {
1009 PositionSide::Short
1010 } else {
1011 PositionSide::Flat
1012 }
1013 }
1014 OKXPositionSide::Long => {
1015 PositionSide::Long
1017 }
1018 OKXPositionSide::Short => {
1019 PositionSide::Short
1021 }
1022 };
1023 (side, pos_dec.abs())
1024 };
1025
1026 let position_side = position_side.as_specified();
1027
1028 let quantity = Quantity::from_decimal_dp(quantity_dec, size_precision)?;
1030
1031 let venue_position_id = match position.pos_side {
1034 OKXPositionSide::Long => {
1035 position
1037 .pos_id
1038 .map(|pos_id| PositionId::new(format!("{pos_id}-LONG")))
1039 }
1040 OKXPositionSide::Short => {
1041 position
1043 .pos_id
1044 .map(|pos_id| PositionId::new(format!("{pos_id}-SHORT")))
1045 }
1046 OKXPositionSide::Net | OKXPositionSide::None => {
1047 None
1049 }
1050 };
1051
1052 let avg_px_open = if position.avg_px.is_empty() {
1053 None
1054 } else {
1055 Some(Decimal::from_str(&position.avg_px)?)
1056 };
1057 let ts_last = parse_millisecond_timestamp(position.u_time);
1058
1059 Ok(PositionStatusReport::new(
1060 account_id,
1061 instrument_id,
1062 position_side,
1063 quantity,
1064 ts_last,
1065 ts_init,
1066 None, venue_position_id,
1068 avg_px_open,
1069 ))
1070}
1071
1072pub fn parse_fill_report(
1078 detail: &OKXTransactionDetail,
1079 account_id: AccountId,
1080 instrument_id: InstrumentId,
1081 price_precision: u8,
1082 size_precision: u8,
1083 ts_init: UnixNanos,
1084) -> anyhow::Result<FillReport> {
1085 let client_order_id = if detail.cl_ord_id.is_empty() {
1086 None
1087 } else {
1088 Some(ClientOrderId::new(detail.cl_ord_id))
1089 };
1090 let venue_order_id = VenueOrderId::new(detail.ord_id);
1091 let trade_id = TradeId::new(detail.trade_id);
1092 let order_side: OrderSide = detail.side.into();
1093 let last_px = parse_price(&detail.fill_px, price_precision)?;
1094 let last_qty = parse_quantity(&detail.fill_sz, size_precision)?;
1095 let fee_dec = Decimal::from_str(detail.fee.as_deref().unwrap_or("0"))?;
1096 let fee_currency = parse_fee_currency(&detail.fee_ccy, fee_dec, || {
1097 format!("fill report for instrument_id={instrument_id}")
1098 });
1099 let commission = Money::from_decimal(-fee_dec, fee_currency)?;
1100 let liquidity_side: LiquiditySide = detail.exec_type.into();
1101 let ts_event = parse_millisecond_timestamp(detail.ts);
1102
1103 Ok(FillReport::new(
1104 account_id,
1105 instrument_id,
1106 venue_order_id,
1107 trade_id,
1108 order_side,
1109 last_qty,
1110 last_px,
1111 commission,
1112 liquidity_side,
1113 client_order_id,
1114 None, ts_event,
1116 ts_init,
1117 None, ))
1119}
1120
1121pub fn parse_message_vec<T, R, F, W>(
1131 data: serde_json::Value,
1132 parser: F,
1133 wrapper: W,
1134) -> anyhow::Result<Vec<Data>>
1135where
1136 T: DeserializeOwned,
1137 F: Fn(&T) -> anyhow::Result<R>,
1138 W: Fn(R) -> Data,
1139{
1140 let messages: Vec<T> =
1141 serde_json::from_value(data).map_err(|e| anyhow::anyhow!("Expected array payload: {e}"))?;
1142
1143 let mut results = Vec::with_capacity(messages.len());
1144
1145 for message in &messages {
1146 let parsed = parser(message)?;
1147 results.push(wrapper(parsed));
1148 }
1149
1150 Ok(results)
1151}
1152
1153pub fn bar_spec_as_okx_channel(bar_spec: BarSpecification) -> anyhow::Result<OKXWsChannel> {
1160 let channel = match bar_spec {
1161 BAR_SPEC_1_SECOND_LAST => OKXWsChannel::Candle1Second,
1162 BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::Candle1Minute,
1163 BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::Candle3Minute,
1164 BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::Candle5Minute,
1165 BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::Candle15Minute,
1166 BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::Candle30Minute,
1167 BAR_SPEC_1_HOUR_LAST => OKXWsChannel::Candle1Hour,
1168 BAR_SPEC_2_HOUR_LAST => OKXWsChannel::Candle2Hour,
1169 BAR_SPEC_4_HOUR_LAST => OKXWsChannel::Candle4Hour,
1170 BAR_SPEC_6_HOUR_LAST => OKXWsChannel::Candle6Hour,
1171 BAR_SPEC_12_HOUR_LAST => OKXWsChannel::Candle12Hour,
1172 BAR_SPEC_1_DAY_LAST => OKXWsChannel::Candle1Day,
1173 BAR_SPEC_2_DAY_LAST => OKXWsChannel::Candle2Day,
1174 BAR_SPEC_3_DAY_LAST => OKXWsChannel::Candle3Day,
1175 BAR_SPEC_5_DAY_LAST => OKXWsChannel::Candle5Day,
1176 BAR_SPEC_1_WEEK_LAST => OKXWsChannel::Candle1Week,
1177 BAR_SPEC_1_MONTH_LAST => OKXWsChannel::Candle1Month,
1178 BAR_SPEC_3_MONTH_LAST => OKXWsChannel::Candle3Month,
1179 BAR_SPEC_6_MONTH_LAST => OKXWsChannel::Candle6Month,
1180 BAR_SPEC_12_MONTH_LAST => OKXWsChannel::Candle1Year,
1181 _ => anyhow::bail!("Invalid `BarSpecification` for channel, was {bar_spec}"),
1182 };
1183 Ok(channel)
1184}
1185
1186pub fn bar_spec_as_okx_mark_price_channel(
1193 bar_spec: BarSpecification,
1194) -> anyhow::Result<OKXWsChannel> {
1195 let channel = match bar_spec {
1196 BAR_SPEC_1_SECOND_LAST => OKXWsChannel::MarkPriceCandle1Second,
1197 BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::MarkPriceCandle1Minute,
1198 BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::MarkPriceCandle3Minute,
1199 BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::MarkPriceCandle5Minute,
1200 BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::MarkPriceCandle15Minute,
1201 BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::MarkPriceCandle30Minute,
1202 BAR_SPEC_1_HOUR_LAST => OKXWsChannel::MarkPriceCandle1Hour,
1203 BAR_SPEC_2_HOUR_LAST => OKXWsChannel::MarkPriceCandle2Hour,
1204 BAR_SPEC_4_HOUR_LAST => OKXWsChannel::MarkPriceCandle4Hour,
1205 BAR_SPEC_6_HOUR_LAST => OKXWsChannel::MarkPriceCandle6Hour,
1206 BAR_SPEC_12_HOUR_LAST => OKXWsChannel::MarkPriceCandle12Hour,
1207 BAR_SPEC_1_DAY_LAST => OKXWsChannel::MarkPriceCandle1Day,
1208 BAR_SPEC_2_DAY_LAST => OKXWsChannel::MarkPriceCandle2Day,
1209 BAR_SPEC_3_DAY_LAST => OKXWsChannel::MarkPriceCandle3Day,
1210 BAR_SPEC_5_DAY_LAST => OKXWsChannel::MarkPriceCandle5Day,
1211 BAR_SPEC_1_WEEK_LAST => OKXWsChannel::MarkPriceCandle1Week,
1212 BAR_SPEC_1_MONTH_LAST => OKXWsChannel::MarkPriceCandle1Month,
1213 BAR_SPEC_3_MONTH_LAST => OKXWsChannel::MarkPriceCandle3Month,
1214 _ => anyhow::bail!("Invalid `BarSpecification` for mark price channel, was {bar_spec}"),
1215 };
1216 Ok(channel)
1217}
1218
1219pub fn bar_spec_as_okx_timeframe(bar_spec: BarSpecification) -> anyhow::Result<&'static str> {
1226 let timeframe = match bar_spec {
1227 BAR_SPEC_1_SECOND_LAST => "1s",
1228 BAR_SPEC_1_MINUTE_LAST => "1m",
1229 BAR_SPEC_3_MINUTE_LAST => "3m",
1230 BAR_SPEC_5_MINUTE_LAST => "5m",
1231 BAR_SPEC_15_MINUTE_LAST => "15m",
1232 BAR_SPEC_30_MINUTE_LAST => "30m",
1233 BAR_SPEC_1_HOUR_LAST => "1H",
1234 BAR_SPEC_2_HOUR_LAST => "2H",
1235 BAR_SPEC_4_HOUR_LAST => "4H",
1236 BAR_SPEC_6_HOUR_LAST => "6H",
1237 BAR_SPEC_12_HOUR_LAST => "12H",
1238 BAR_SPEC_1_DAY_LAST => "1D",
1239 BAR_SPEC_2_DAY_LAST => "2D",
1240 BAR_SPEC_3_DAY_LAST => "3D",
1241 BAR_SPEC_5_DAY_LAST => "5D",
1242 BAR_SPEC_1_WEEK_LAST => "1W",
1243 BAR_SPEC_1_MONTH_LAST => "1M",
1244 BAR_SPEC_3_MONTH_LAST => "3M",
1245 BAR_SPEC_6_MONTH_LAST => "6M",
1246 BAR_SPEC_12_MONTH_LAST => "1Y",
1247 _ => anyhow::bail!("Invalid `BarSpecification` for timeframe, was {bar_spec}"),
1248 };
1249 Ok(timeframe)
1250}
1251
1252pub fn okx_timeframe_as_bar_spec(timeframe: &str) -> anyhow::Result<BarSpecification> {
1258 let bar_spec = match timeframe {
1259 "1s" => BAR_SPEC_1_SECOND_LAST,
1260 "1m" => BAR_SPEC_1_MINUTE_LAST,
1261 "3m" => BAR_SPEC_3_MINUTE_LAST,
1262 "5m" => BAR_SPEC_5_MINUTE_LAST,
1263 "15m" => BAR_SPEC_15_MINUTE_LAST,
1264 "30m" => BAR_SPEC_30_MINUTE_LAST,
1265 "1H" => BAR_SPEC_1_HOUR_LAST,
1266 "2H" => BAR_SPEC_2_HOUR_LAST,
1267 "4H" => BAR_SPEC_4_HOUR_LAST,
1268 "6H" => BAR_SPEC_6_HOUR_LAST,
1269 "12H" => BAR_SPEC_12_HOUR_LAST,
1270 "1D" => BAR_SPEC_1_DAY_LAST,
1271 "2D" => BAR_SPEC_2_DAY_LAST,
1272 "3D" => BAR_SPEC_3_DAY_LAST,
1273 "5D" => BAR_SPEC_5_DAY_LAST,
1274 "1W" => BAR_SPEC_1_WEEK_LAST,
1275 "1M" => BAR_SPEC_1_MONTH_LAST,
1276 "3M" => BAR_SPEC_3_MONTH_LAST,
1277 "6M" => BAR_SPEC_6_MONTH_LAST,
1278 "1Y" => BAR_SPEC_12_MONTH_LAST,
1279 _ => anyhow::bail!("Invalid timeframe for `BarSpecification`, was {timeframe}"),
1280 };
1281 Ok(bar_spec)
1282}
1283
1284pub fn okx_bar_type_from_timeframe(
1292 instrument_id: InstrumentId,
1293 timeframe: &str,
1294) -> anyhow::Result<BarType> {
1295 let bar_spec = okx_timeframe_as_bar_spec(timeframe)?;
1296 Ok(BarType::new(
1297 instrument_id,
1298 bar_spec,
1299 AggregationSource::External,
1300 ))
1301}
1302
1303pub fn okx_channel_to_bar_spec(channel: &OKXWsChannel) -> Option<BarSpecification> {
1305 use OKXWsChannel::*;
1306
1307 match channel {
1308 Candle1Second | MarkPriceCandle1Second => Some(BAR_SPEC_1_SECOND_LAST),
1309 Candle1Minute | MarkPriceCandle1Minute => Some(BAR_SPEC_1_MINUTE_LAST),
1310 Candle3Minute | MarkPriceCandle3Minute => Some(BAR_SPEC_3_MINUTE_LAST),
1311 Candle5Minute | MarkPriceCandle5Minute => Some(BAR_SPEC_5_MINUTE_LAST),
1312 Candle15Minute | MarkPriceCandle15Minute => Some(BAR_SPEC_15_MINUTE_LAST),
1313 Candle30Minute | MarkPriceCandle30Minute => Some(BAR_SPEC_30_MINUTE_LAST),
1314 Candle1Hour | MarkPriceCandle1Hour => Some(BAR_SPEC_1_HOUR_LAST),
1315 Candle2Hour | MarkPriceCandle2Hour => Some(BAR_SPEC_2_HOUR_LAST),
1316 Candle4Hour | MarkPriceCandle4Hour => Some(BAR_SPEC_4_HOUR_LAST),
1317 Candle6Hour | MarkPriceCandle6Hour => Some(BAR_SPEC_6_HOUR_LAST),
1318 Candle12Hour | MarkPriceCandle12Hour => Some(BAR_SPEC_12_HOUR_LAST),
1319 Candle1Day | MarkPriceCandle1Day => Some(BAR_SPEC_1_DAY_LAST),
1320 Candle2Day | MarkPriceCandle2Day => Some(BAR_SPEC_2_DAY_LAST),
1321 Candle3Day | MarkPriceCandle3Day => Some(BAR_SPEC_3_DAY_LAST),
1322 Candle5Day | MarkPriceCandle5Day => Some(BAR_SPEC_5_DAY_LAST),
1323 Candle1Week | MarkPriceCandle1Week => Some(BAR_SPEC_1_WEEK_LAST),
1324 Candle1Month | MarkPriceCandle1Month => Some(BAR_SPEC_1_MONTH_LAST),
1325 Candle3Month | MarkPriceCandle3Month => Some(BAR_SPEC_3_MONTH_LAST),
1326 Candle6Month => Some(BAR_SPEC_6_MONTH_LAST),
1327 Candle1Year => Some(BAR_SPEC_12_MONTH_LAST),
1328 _ => None,
1329 }
1330}
1331
1332pub fn parse_instrument_any(
1338 instrument: &OKXInstrument,
1339 margin_init: Option<Decimal>,
1340 margin_maint: Option<Decimal>,
1341 maker_fee: Option<Decimal>,
1342 taker_fee: Option<Decimal>,
1343 ts_init: UnixNanos,
1344) -> anyhow::Result<Option<InstrumentAny>> {
1345 match instrument.inst_type {
1346 OKXInstrumentType::Spot => parse_spot_instrument(
1347 instrument,
1348 margin_init,
1349 margin_maint,
1350 maker_fee,
1351 taker_fee,
1352 ts_init,
1353 )
1354 .map(Some),
1355 OKXInstrumentType::Margin => parse_spot_instrument(
1356 instrument,
1357 margin_init,
1358 margin_maint,
1359 maker_fee,
1360 taker_fee,
1361 ts_init,
1362 )
1363 .map(Some),
1364 OKXInstrumentType::Swap => parse_swap_instrument(
1365 instrument,
1366 margin_init,
1367 margin_maint,
1368 maker_fee,
1369 taker_fee,
1370 ts_init,
1371 )
1372 .map(Some),
1373 OKXInstrumentType::Futures => parse_futures_instrument(
1374 instrument,
1375 margin_init,
1376 margin_maint,
1377 maker_fee,
1378 taker_fee,
1379 ts_init,
1380 )
1381 .map(Some),
1382 OKXInstrumentType::Option => parse_option_instrument(
1383 instrument,
1384 margin_init,
1385 margin_maint,
1386 maker_fee,
1387 taker_fee,
1388 ts_init,
1389 )
1390 .map(Some),
1391 _ => Ok(None),
1392 }
1393}
1394
1395#[derive(Debug)]
1397struct CommonInstrumentData {
1398 instrument_id: InstrumentId,
1399 raw_symbol: Symbol,
1400 price_increment: Price,
1401 size_increment: Quantity,
1402 lot_size: Option<Quantity>,
1403 max_quantity: Option<Quantity>,
1404 min_quantity: Option<Quantity>,
1405 max_notional: Option<Money>,
1406 min_notional: Option<Money>,
1407 max_price: Option<Price>,
1408 min_price: Option<Price>,
1409}
1410
1411struct MarginAndFees {
1413 margin_init: Option<Decimal>,
1414 margin_maint: Option<Decimal>,
1415 maker_fee: Option<Decimal>,
1416 taker_fee: Option<Decimal>,
1417}
1418
1419fn parse_multiplier_product(definition: &OKXInstrument) -> anyhow::Result<Option<Quantity>> {
1424 if definition.ct_mult.is_empty() && definition.ct_val.is_empty() {
1425 return Ok(None);
1426 }
1427
1428 let mult_value = if definition.ct_mult.is_empty() {
1429 Decimal::ONE
1430 } else {
1431 Decimal::from_str(&definition.ct_mult).map_err(|e| {
1432 anyhow::anyhow!(
1433 "Failed to parse `ct_mult` '{}' for {}: {e}",
1434 definition.ct_mult,
1435 definition.inst_id
1436 )
1437 })?
1438 };
1439
1440 let val_value = if definition.ct_val.is_empty() {
1441 Decimal::ONE
1442 } else {
1443 Decimal::from_str(&definition.ct_val).map_err(|e| {
1444 anyhow::anyhow!(
1445 "Failed to parse `ct_val` '{}' for {}: {e}",
1446 definition.ct_val,
1447 definition.inst_id
1448 )
1449 })?
1450 };
1451
1452 let product = mult_value * val_value;
1453 Ok(Some(Quantity::from(product.to_string())))
1454}
1455
1456trait InstrumentParser {
1458 fn parse_specific_fields(
1460 &self,
1461 definition: &OKXInstrument,
1462 common: CommonInstrumentData,
1463 margin_fees: MarginAndFees,
1464 ts_init: UnixNanos,
1465 ) -> anyhow::Result<InstrumentAny>;
1466}
1467
1468fn parse_common_instrument_data(
1470 definition: &OKXInstrument,
1471) -> anyhow::Result<CommonInstrumentData> {
1472 let instrument_id = parse_instrument_id(definition.inst_id);
1473 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1474
1475 if definition.tick_sz.is_empty() {
1476 anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1477 }
1478
1479 let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
1480 anyhow::anyhow!(
1481 "Failed to parse `tick_sz` '{}' into Price for {}: {e}",
1482 definition.tick_sz,
1483 definition.inst_id,
1484 )
1485 })?;
1486
1487 if definition.lot_sz.is_empty() {
1488 anyhow::bail!("`lot_sz` is empty for {}", definition.inst_id);
1489 }
1490
1491 let size_increment = Quantity::from_str(&definition.lot_sz).map_err(|e| {
1492 anyhow::anyhow!(
1493 "Failed to parse `lot_sz` '{}' for {}: {e}",
1494 definition.lot_sz,
1495 definition.inst_id,
1496 )
1497 })?;
1498 let lot_size = Some(size_increment);
1499 let max_quantity = if definition.max_mkt_sz.is_empty() {
1500 None
1501 } else {
1502 Some(Quantity::from_str(&definition.max_mkt_sz).map_err(|e| {
1503 anyhow::anyhow!(
1504 "Failed to parse `max_mkt_sz` '{}' for {}: {e}",
1505 definition.max_mkt_sz,
1506 definition.inst_id,
1507 )
1508 })?)
1509 };
1510 let min_quantity = if definition.min_sz.is_empty() {
1511 None
1512 } else {
1513 Some(Quantity::from_str(&definition.min_sz).map_err(|e| {
1514 anyhow::anyhow!(
1515 "Failed to parse `min_sz` '{}' for {}: {e}",
1516 definition.min_sz,
1517 definition.inst_id,
1518 )
1519 })?)
1520 };
1521 let max_notional: Option<Money> = None;
1522 let min_notional: Option<Money> = None;
1523 let max_price = None; let min_price = None; Ok(CommonInstrumentData {
1527 instrument_id,
1528 raw_symbol,
1529 price_increment,
1530 size_increment,
1531 lot_size,
1532 max_quantity,
1533 min_quantity,
1534 max_notional,
1535 min_notional,
1536 max_price,
1537 min_price,
1538 })
1539}
1540
1541fn parse_instrument_with_parser<P: InstrumentParser>(
1543 definition: &OKXInstrument,
1544 parser: &P,
1545 margin_init: Option<Decimal>,
1546 margin_maint: Option<Decimal>,
1547 maker_fee: Option<Decimal>,
1548 taker_fee: Option<Decimal>,
1549 ts_init: UnixNanos,
1550) -> anyhow::Result<InstrumentAny> {
1551 let common = parse_common_instrument_data(definition)?;
1552 parser.parse_specific_fields(
1553 definition,
1554 common,
1555 MarginAndFees {
1556 margin_init,
1557 margin_maint,
1558 maker_fee,
1559 taker_fee,
1560 },
1561 ts_init,
1562 )
1563}
1564
1565struct SpotInstrumentParser;
1567
1568impl InstrumentParser for SpotInstrumentParser {
1569 fn parse_specific_fields(
1570 &self,
1571 definition: &OKXInstrument,
1572 common: CommonInstrumentData,
1573 margin_fees: MarginAndFees,
1574 ts_init: UnixNanos,
1575 ) -> anyhow::Result<InstrumentAny> {
1576 let context = format!("{} instrument {}", definition.inst_type, definition.inst_id);
1577 let base_currency =
1578 Currency::get_or_create_crypto_with_context(definition.base_ccy, Some(&context));
1579 let quote_currency =
1580 Currency::get_or_create_crypto_with_context(definition.quote_ccy, Some(&context));
1581
1582 let multiplier = parse_multiplier_product(definition)?;
1584
1585 let instrument = CurrencyPair::new(
1586 common.instrument_id,
1587 common.raw_symbol,
1588 base_currency,
1589 quote_currency,
1590 common.price_increment.precision,
1591 common.size_increment.precision,
1592 common.price_increment,
1593 common.size_increment,
1594 multiplier,
1595 common.lot_size,
1596 common.max_quantity,
1597 common.min_quantity,
1598 common.max_notional,
1599 common.min_notional,
1600 common.max_price,
1601 common.min_price,
1602 margin_fees.margin_init,
1603 margin_fees.margin_maint,
1604 margin_fees.maker_fee,
1605 margin_fees.taker_fee,
1606 None,
1607 ts_init,
1608 ts_init,
1609 );
1610
1611 Ok(InstrumentAny::CurrencyPair(instrument))
1612 }
1613}
1614
1615pub fn parse_spot_instrument(
1621 definition: &OKXInstrument,
1622 margin_init: Option<Decimal>,
1623 margin_maint: Option<Decimal>,
1624 maker_fee: Option<Decimal>,
1625 taker_fee: Option<Decimal>,
1626 ts_init: UnixNanos,
1627) -> anyhow::Result<InstrumentAny> {
1628 parse_instrument_with_parser(
1629 definition,
1630 &SpotInstrumentParser,
1631 margin_init,
1632 margin_maint,
1633 maker_fee,
1634 taker_fee,
1635 ts_init,
1636 )
1637}
1638
1639fn validate_underlying(inst_id: Ustr, uly: Ustr) -> anyhow::Result<()> {
1646 if uly.is_empty() {
1647 anyhow::bail!(
1648 "Empty underlying for {inst_id}: instrument may be pre-open or misconfigured"
1649 );
1650 }
1651 Ok(())
1652}
1653
1654pub fn parse_swap_instrument(
1660 definition: &OKXInstrument,
1661 margin_init: Option<Decimal>,
1662 margin_maint: Option<Decimal>,
1663 maker_fee: Option<Decimal>,
1664 taker_fee: Option<Decimal>,
1665 ts_init: UnixNanos,
1666) -> anyhow::Result<InstrumentAny> {
1667 validate_underlying(definition.inst_id, definition.uly)?;
1668
1669 let context = format!("SWAP instrument {}", definition.inst_id);
1670 let (base_currency, quote_currency) = definition.uly.split_once('-').ok_or_else(|| {
1671 anyhow::anyhow!(
1672 "Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
1673 definition.uly,
1674 definition.inst_id
1675 )
1676 })?;
1677
1678 let instrument_id = parse_instrument_id(definition.inst_id);
1679 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1680 let base_currency = Currency::get_or_create_crypto_with_context(base_currency, Some(&context));
1681 let quote_currency =
1682 Currency::get_or_create_crypto_with_context(quote_currency, Some(&context));
1683 let settlement_currency =
1684 Currency::get_or_create_crypto_with_context(definition.settle_ccy, Some(&context));
1685 let is_inverse = match definition.ct_type {
1686 OKXContractType::Linear => false,
1687 OKXContractType::Inverse => true,
1688 OKXContractType::None => {
1689 anyhow::bail!(
1690 "Invalid contract type '{}' for {}: expected 'linear' or 'inverse'",
1691 definition.ct_type,
1692 definition.inst_id
1693 )
1694 }
1695 };
1696
1697 if definition.tick_sz.is_empty() {
1698 anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1699 }
1700
1701 let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
1702 anyhow::anyhow!(
1703 "Failed to parse `tick_sz` '{}' into Price for {}: {e}",
1704 definition.tick_sz,
1705 definition.inst_id
1706 )
1707 })?;
1708
1709 if definition.lot_sz.is_empty() {
1710 anyhow::bail!("`lot_sz` is empty for {}", definition.inst_id);
1711 }
1712 let size_increment = Quantity::from_str(&definition.lot_sz).map_err(|e| {
1713 anyhow::anyhow!(
1714 "Failed to parse `lot_sz` '{}' for {}: {e}",
1715 definition.lot_sz,
1716 definition.inst_id
1717 )
1718 })?;
1719 let multiplier = parse_multiplier_product(definition)?;
1720 let lot_size = Some(size_increment);
1721 let max_quantity = if definition.max_mkt_sz.is_empty() {
1722 None
1723 } else {
1724 Some(Quantity::from_str(&definition.max_mkt_sz).map_err(|e| {
1725 anyhow::anyhow!(
1726 "Failed to parse `max_mkt_sz` '{}' for {}: {e}",
1727 definition.max_mkt_sz,
1728 definition.inst_id
1729 )
1730 })?)
1731 };
1732 let min_quantity = if definition.min_sz.is_empty() {
1733 None
1734 } else {
1735 Some(Quantity::from_str(&definition.min_sz).map_err(|e| {
1736 anyhow::anyhow!(
1737 "Failed to parse `min_sz` '{}' for {}: {e}",
1738 definition.min_sz,
1739 definition.inst_id
1740 )
1741 })?)
1742 };
1743 let max_notional: Option<Money> = None;
1744 let min_notional: Option<Money> = None;
1745 let max_price = None; let min_price = None; let instrument = CryptoPerpetual::new(
1749 instrument_id,
1750 raw_symbol,
1751 base_currency,
1752 quote_currency,
1753 settlement_currency,
1754 is_inverse,
1755 price_increment.precision,
1756 size_increment.precision,
1757 price_increment,
1758 size_increment,
1759 multiplier,
1760 lot_size,
1761 max_quantity,
1762 min_quantity,
1763 max_notional,
1764 min_notional,
1765 max_price,
1766 min_price,
1767 margin_init,
1768 margin_maint,
1769 maker_fee,
1770 taker_fee,
1771 None,
1772 ts_init, ts_init,
1774 );
1775
1776 Ok(InstrumentAny::CryptoPerpetual(instrument))
1777}
1778
1779pub fn parse_futures_instrument(
1785 definition: &OKXInstrument,
1786 margin_init: Option<Decimal>,
1787 margin_maint: Option<Decimal>,
1788 maker_fee: Option<Decimal>,
1789 taker_fee: Option<Decimal>,
1790 ts_init: UnixNanos,
1791) -> anyhow::Result<InstrumentAny> {
1792 validate_underlying(definition.inst_id, definition.uly)?;
1793
1794 let context = format!("FUTURES instrument {}", definition.inst_id);
1795 let (_, quote_currency) = definition.uly.split_once('-').ok_or_else(|| {
1796 anyhow::anyhow!(
1797 "Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
1798 definition.uly,
1799 definition.inst_id
1800 )
1801 })?;
1802
1803 let instrument_id = parse_instrument_id(definition.inst_id);
1804 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1805 let underlying = Currency::get_or_create_crypto_with_context(definition.uly, Some(&context));
1806 let quote_currency =
1807 Currency::get_or_create_crypto_with_context(quote_currency, Some(&context));
1808 let settlement_currency =
1809 Currency::get_or_create_crypto_with_context(definition.settle_ccy, Some(&context));
1810 let is_inverse = match definition.ct_type {
1811 OKXContractType::Linear => false,
1812 OKXContractType::Inverse => true,
1813 OKXContractType::None => {
1814 anyhow::bail!(
1815 "Invalid contract type '{}' for {}: expected 'linear' or 'inverse'",
1816 definition.ct_type,
1817 definition.inst_id
1818 )
1819 }
1820 };
1821 let listing_time = definition
1822 .list_time
1823 .ok_or_else(|| anyhow::anyhow!("`list_time` is required for {}", definition.inst_id))?;
1824 let expiry_time = definition
1825 .exp_time
1826 .ok_or_else(|| anyhow::anyhow!("`exp_time` is required for {}", definition.inst_id))?;
1827 let activation_ns = parse_millisecond_timestamp(listing_time);
1828 let expiration_ns = parse_millisecond_timestamp(expiry_time);
1829
1830 if definition.tick_sz.is_empty() {
1831 anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1832 }
1833
1834 let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
1835 anyhow::anyhow!(
1836 "Failed to parse `tick_sz` '{}' for {}: {e}",
1837 definition.tick_sz,
1838 definition.inst_id
1839 )
1840 })?;
1841
1842 if definition.lot_sz.is_empty() {
1843 anyhow::bail!("`lot_sz` is empty for {}", definition.inst_id);
1844 }
1845 let size_increment = Quantity::from_str(&definition.lot_sz).map_err(|e| {
1846 anyhow::anyhow!(
1847 "Failed to parse `lot_sz` '{}' for {}: {e}",
1848 definition.lot_sz,
1849 definition.inst_id
1850 )
1851 })?;
1852 let multiplier = parse_multiplier_product(definition)?;
1853 let lot_size = Some(size_increment);
1854 let max_quantity = if definition.max_mkt_sz.is_empty() {
1855 None
1856 } else {
1857 Some(Quantity::from_str(&definition.max_mkt_sz).map_err(|e| {
1858 anyhow::anyhow!(
1859 "Failed to parse `max_mkt_sz` '{}' for {}: {e}",
1860 definition.max_mkt_sz,
1861 definition.inst_id
1862 )
1863 })?)
1864 };
1865 let min_quantity = if definition.min_sz.is_empty() {
1866 None
1867 } else {
1868 Some(Quantity::from_str(&definition.min_sz).map_err(|e| {
1869 anyhow::anyhow!(
1870 "Failed to parse `min_sz` '{}' for {}: {e}",
1871 definition.min_sz,
1872 definition.inst_id
1873 )
1874 })?)
1875 };
1876 let max_notional: Option<Money> = None;
1877 let min_notional: Option<Money> = None;
1878 let max_price = None; let min_price = None; let instrument = CryptoFuture::new(
1882 instrument_id,
1883 raw_symbol,
1884 underlying,
1885 quote_currency,
1886 settlement_currency,
1887 is_inverse,
1888 activation_ns,
1889 expiration_ns,
1890 price_increment.precision,
1891 size_increment.precision,
1892 price_increment,
1893 size_increment,
1894 multiplier,
1895 lot_size,
1896 max_quantity,
1897 min_quantity,
1898 max_notional,
1899 min_notional,
1900 max_price,
1901 min_price,
1902 margin_init,
1903 margin_maint,
1904 maker_fee,
1905 taker_fee,
1906 None,
1907 ts_init, ts_init,
1909 );
1910
1911 Ok(InstrumentAny::CryptoFuture(instrument))
1912}
1913
1914pub fn parse_option_instrument(
1920 definition: &OKXInstrument,
1921 margin_init: Option<Decimal>,
1922 margin_maint: Option<Decimal>,
1923 maker_fee: Option<Decimal>,
1924 taker_fee: Option<Decimal>,
1925 ts_init: UnixNanos,
1926) -> anyhow::Result<InstrumentAny> {
1927 validate_underlying(definition.inst_id, definition.uly)?;
1928
1929 let context = format!("OPTION instrument {}", definition.inst_id);
1930 let (underlying_str, quote_ccy_str) = definition.uly.split_once('-').ok_or_else(|| {
1931 anyhow::anyhow!(
1932 "Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
1933 definition.uly,
1934 definition.inst_id
1935 )
1936 })?;
1937
1938 let instrument_id = parse_instrument_id(definition.inst_id);
1939 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1940 let underlying = Currency::get_or_create_crypto_with_context(underlying_str, Some(&context));
1941 let option_kind: OptionKind = OptionKind::try_from(definition.opt_type).map_err(|kind| {
1942 anyhow::anyhow!(
1943 "Unsupported `optType` '{kind:?}' for {}: cannot map to Nautilus OptionKind",
1944 definition.inst_id
1945 )
1946 })?;
1947 let strike_price = Price::from_str(&definition.stk).map_err(|e| {
1948 anyhow::anyhow!(
1949 "Failed to parse `stk` '{}' for {}: {e}",
1950 definition.stk,
1951 definition.inst_id
1952 )
1953 })?;
1954 let quote_currency = Currency::get_or_create_crypto_with_context(quote_ccy_str, Some(&context));
1955 let settlement_currency =
1956 Currency::get_or_create_crypto_with_context(definition.settle_ccy, Some(&context));
1957
1958 let is_inverse = if definition.ct_type == OKXContractType::None {
1959 settlement_currency == underlying
1960 } else {
1961 matches!(definition.ct_type, OKXContractType::Inverse)
1962 };
1963
1964 let listing_time = definition
1965 .list_time
1966 .ok_or_else(|| anyhow::anyhow!("`list_time` is required for {}", definition.inst_id))?;
1967 let expiry_time = definition
1968 .exp_time
1969 .ok_or_else(|| anyhow::anyhow!("`exp_time` is required for {}", definition.inst_id))?;
1970 let activation_ns = parse_millisecond_timestamp(listing_time);
1971 let expiration_ns = parse_millisecond_timestamp(expiry_time);
1972
1973 if definition.tick_sz.is_empty() {
1974 anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1975 }
1976
1977 let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
1978 anyhow::anyhow!(
1979 "Failed to parse `tick_sz` '{}' for {}: {e}",
1980 definition.tick_sz,
1981 definition.inst_id
1982 )
1983 })?;
1984
1985 if definition.lot_sz.is_empty() {
1986 anyhow::bail!("`lot_sz` is empty for {}", definition.inst_id);
1987 }
1988 let size_increment = Quantity::from_str(&definition.lot_sz).map_err(|e| {
1989 anyhow::anyhow!(
1990 "Failed to parse `lot_sz` '{}' for {}: {e}",
1991 definition.lot_sz,
1992 definition.inst_id
1993 )
1994 })?;
1995 let multiplier = parse_multiplier_product(definition)?;
1996 let lot_size = size_increment;
1997 let max_quantity = if definition.max_mkt_sz.is_empty() {
1998 None
1999 } else {
2000 Some(Quantity::from_str(&definition.max_mkt_sz).map_err(|e| {
2001 anyhow::anyhow!(
2002 "Failed to parse `max_mkt_sz` '{}' for {}: {e}",
2003 definition.max_mkt_sz,
2004 definition.inst_id
2005 )
2006 })?)
2007 };
2008 let min_quantity = if definition.min_sz.is_empty() {
2009 None
2010 } else {
2011 Some(Quantity::from_str(&definition.min_sz).map_err(|e| {
2012 anyhow::anyhow!(
2013 "Failed to parse `min_sz` '{}' for {}: {e}",
2014 definition.min_sz,
2015 definition.inst_id
2016 )
2017 })?)
2018 };
2019 let max_notional = None;
2020 let min_notional = None;
2021 let max_price = None;
2022 let min_price = None;
2023
2024 let instrument = CryptoOption::new(
2025 instrument_id,
2026 raw_symbol,
2027 underlying,
2028 quote_currency,
2029 settlement_currency,
2030 is_inverse,
2031 option_kind,
2032 strike_price,
2033 activation_ns,
2034 expiration_ns,
2035 price_increment.precision,
2036 size_increment.precision,
2037 price_increment,
2038 size_increment,
2039 multiplier,
2040 Some(lot_size),
2041 max_quantity,
2042 min_quantity,
2043 max_notional,
2044 min_notional,
2045 max_price,
2046 min_price,
2047 margin_init,
2048 margin_maint,
2049 maker_fee,
2050 taker_fee,
2051 None,
2052 ts_init,
2053 ts_init,
2054 );
2055
2056 Ok(InstrumentAny::CryptoOption(instrument))
2057}
2058
2059fn parse_balance_field(value_str: &str, field_name: &str, ccy_str: &str) -> Option<Decimal> {
2062 match Decimal::from_str(value_str) {
2063 Ok(decimal) => Some(decimal),
2064 Err(e) => {
2065 log::warn!(
2066 "Skipping balance detail for {ccy_str} with invalid {field_name} '{value_str}': {e}"
2067 );
2068 None
2069 }
2070 }
2071}
2072
2073pub fn parse_account_state(
2077 okx_account: &OKXAccount,
2078 account_id: AccountId,
2079 ts_init: UnixNanos,
2080) -> anyhow::Result<AccountState> {
2081 let mut balances = Vec::new();
2082
2083 for b in &okx_account.details {
2084 let ccy_str = b.ccy.as_str().trim();
2086 if ccy_str.is_empty() {
2087 log::debug!("Skipping balance detail with empty currency code | raw_data={b:?}");
2088 continue;
2089 }
2090
2091 let currency = Currency::get_or_create_crypto_with_context(ccy_str, Some("balance detail"));
2093
2094 let Some(total) = parse_balance_field(&b.cash_bal, "cash_bal", ccy_str) else {
2096 continue;
2097 };
2098
2099 let Some(free) = parse_balance_field(&b.avail_bal, "avail_bal", ccy_str) else {
2100 continue;
2101 };
2102
2103 match AccountBalance::from_total_and_free(total, free, currency) {
2104 Ok(balance) => balances.push(balance),
2105 Err(e) => {
2106 log::warn!("Skipping balance detail for {ccy_str} with invalid total/free: {e}");
2107 }
2108 }
2109 }
2110
2111 if balances.is_empty() {
2114 let zero_currency = Currency::USD();
2115 let zero_money = Money::new(0.0, zero_currency);
2116 let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
2117 balances.push(zero_balance);
2118 }
2119
2120 let mut margins = Vec::new();
2121
2122 if !okx_account.imr.is_empty() && !okx_account.mmr.is_empty() {
2125 match (
2126 Decimal::from_str(&okx_account.imr),
2127 Decimal::from_str(&okx_account.mmr),
2128 ) {
2129 (Ok(imr_dec), Ok(mmr_dec)) => {
2130 if !imr_dec.is_zero() || !mmr_dec.is_zero() {
2131 let margin_currency = Currency::USD();
2132
2133 let initial_margin = Money::from_decimal(imr_dec, margin_currency)
2134 .unwrap_or_else(|e| {
2135 log::error!("Failed to create initial margin: {e}");
2136 Money::zero(margin_currency)
2137 });
2138 let maintenance_margin = Money::from_decimal(mmr_dec, margin_currency)
2139 .unwrap_or_else(|e| {
2140 log::error!("Failed to create maintenance margin: {e}");
2141 Money::zero(margin_currency)
2142 });
2143
2144 margins.push(MarginBalance::new(initial_margin, maintenance_margin, None));
2145 }
2146 }
2147 (Err(e1), _) => {
2148 log::warn!(
2149 "Failed to parse initial margin requirement '{}': {}",
2150 okx_account.imr,
2151 e1
2152 );
2153 }
2154 (_, Err(e2)) => {
2155 log::warn!(
2156 "Failed to parse maintenance margin requirement '{}': {}",
2157 okx_account.mmr,
2158 e2
2159 );
2160 }
2161 }
2162 }
2163
2164 let account_type = AccountType::Margin;
2165 let is_reported = true;
2166 let event_id = UUID4::new();
2167 let ts_event = parse_millisecond_timestamp(okx_account.u_time);
2168
2169 Ok(AccountState::new(
2170 account_id,
2171 account_type,
2172 balances,
2173 margins,
2174 is_reported,
2175 event_id,
2176 ts_event,
2177 ts_init,
2178 None,
2179 ))
2180}
2181
2182pub fn nanos_to_datetime(value: Option<UnixNanos>) -> Option<chrono::DateTime<chrono::Utc>> {
2184 value.map(|nanos| nanos.to_datetime_utc())
2185}
2186
2187#[cfg(test)]
2188mod tests {
2189 use nautilus_model::{identifiers::PositionId, instruments::Instrument};
2190 use rstest::rstest;
2191 use rust_decimal_macros::dec;
2192
2193 use super::*;
2194 use crate::{
2195 OKXPositionSide,
2196 common::{enums::OKXMarginMode, testing::load_test_json},
2197 http::{
2198 client::OKXResponse,
2199 models::{
2200 OKXAccount, OKXBalanceDetail, OKXCandlestick, OKXIndexTicker, OKXMarkPrice,
2201 OKXOrderHistory, OKXPlaceOrderResponse, OKXPosition, OKXPositionHistory,
2202 OKXPositionTier, OKXTrade, OKXTransactionDetail,
2203 },
2204 },
2205 };
2206
2207 #[rstest]
2208 fn test_parse_fee_currency_with_zero_fee_empty_string() {
2209 let result = parse_fee_currency("", Decimal::ZERO, || "test context".to_string());
2210 assert_eq!(result, Currency::USDT());
2211 }
2212
2213 #[rstest]
2214 fn test_parse_fee_currency_with_zero_fee_valid_currency() {
2215 let result = parse_fee_currency("BTC", Decimal::ZERO, || "test context".to_string());
2216 assert_eq!(result, Currency::BTC());
2217 }
2218
2219 #[rstest]
2220 fn test_parse_fee_currency_with_valid_currency() {
2221 let result = parse_fee_currency("BTC", dec!(0.001), || "test context".to_string());
2222 assert_eq!(result, Currency::BTC());
2223 }
2224
2225 #[rstest]
2226 fn test_parse_fee_currency_with_empty_string_nonzero_fee() {
2227 let result = parse_fee_currency("", dec!(0.5), || "test context".to_string());
2228 assert_eq!(result, Currency::USDT());
2229 }
2230
2231 #[rstest]
2232 fn test_parse_fee_currency_with_whitespace() {
2233 let result = parse_fee_currency(" ETH ", dec!(0.002), || "test context".to_string());
2234 assert_eq!(result, Currency::ETH());
2235 }
2236
2237 #[rstest]
2238 fn test_parse_fee_currency_with_unknown_code() {
2239 let result = parse_fee_currency("NEWTOKEN", dec!(0.5), || "test context".to_string());
2241 assert_eq!(result.code.as_str(), "NEWTOKEN");
2242 assert_eq!(result.precision, 8);
2243 }
2244
2245 #[rstest]
2246 fn test_parse_balance_field_valid() {
2247 let result = parse_balance_field("100.5", "test_field", "BTC");
2248 assert_eq!(result, Some(dec!(100.5)));
2249 }
2250
2251 #[rstest]
2252 fn test_parse_balance_field_invalid_numeric() {
2253 let result = parse_balance_field("not_a_number", "test_field", "BTC");
2254 assert!(result.is_none());
2255 }
2256
2257 #[rstest]
2258 fn test_parse_balance_field_empty() {
2259 let result = parse_balance_field("", "test_field", "BTC");
2260 assert!(result.is_none());
2261 }
2262
2263 #[rstest]
2267 fn test_parse_trades() {
2268 let json_data = load_test_json("http_get_trades.json");
2269 let parsed: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
2270
2271 assert_eq!(parsed.code, "0");
2273 assert_eq!(parsed.msg, "");
2274 assert_eq!(parsed.data.len(), 2);
2275
2276 let trade0 = &parsed.data[0];
2278 assert_eq!(trade0.inst_id, "BTC-USDT");
2279 assert_eq!(trade0.px, "102537.9");
2280 assert_eq!(trade0.sz, "0.00013669");
2281 assert_eq!(trade0.side, OKXSide::Sell);
2282 assert_eq!(trade0.trade_id, "734864333");
2283 assert_eq!(trade0.ts, 1747087163557);
2284
2285 let trade1 = &parsed.data[1];
2287 assert_eq!(trade1.inst_id, "BTC-USDT");
2288 assert_eq!(trade1.px, "102537.9");
2289 assert_eq!(trade1.sz, "0.0000125");
2290 assert_eq!(trade1.side, OKXSide::Buy);
2291 assert_eq!(trade1.trade_id, "734864332");
2292 assert_eq!(trade1.ts, 1747087161666);
2293 }
2294
2295 #[rstest]
2296 fn test_parse_candlesticks() {
2297 let json_data = load_test_json("http_get_candlesticks.json");
2298 let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
2299
2300 assert_eq!(parsed.code, "0");
2302 assert_eq!(parsed.msg, "");
2303 assert_eq!(parsed.data.len(), 2);
2304
2305 let bar0 = &parsed.data[0];
2306 assert_eq!(bar0.0, "1625097600000");
2307 assert_eq!(bar0.1, "33528.6");
2308 assert_eq!(bar0.2, "33870.0");
2309 assert_eq!(bar0.3, "33528.6");
2310 assert_eq!(bar0.4, "33783.9");
2311 assert_eq!(bar0.5, "778.838");
2312
2313 let bar1 = &parsed.data[1];
2314 assert_eq!(bar1.0, "1625097660000");
2315 assert_eq!(bar1.1, "33783.9");
2316 assert_eq!(bar1.2, "33783.9");
2317 assert_eq!(bar1.3, "33782.1");
2318 assert_eq!(bar1.4, "33782.1");
2319 assert_eq!(bar1.5, "0.123");
2320 }
2321
2322 #[rstest]
2323 fn test_parse_candlesticks_full() {
2324 let json_data = load_test_json("http_get_candlesticks_full.json");
2325 let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
2326
2327 assert_eq!(parsed.code, "0");
2329 assert_eq!(parsed.msg, "");
2330 assert_eq!(parsed.data.len(), 2);
2331
2332 let bar0 = &parsed.data[0];
2334 assert_eq!(bar0.0, "1747094040000");
2335 assert_eq!(bar0.1, "102806.1");
2336 assert_eq!(bar0.2, "102820.4");
2337 assert_eq!(bar0.3, "102806.1");
2338 assert_eq!(bar0.4, "102820.4");
2339 assert_eq!(bar0.5, "1040.37");
2340 assert_eq!(bar0.6, "10.4037");
2341 assert_eq!(bar0.7, "1069603.34883");
2342 assert_eq!(bar0.8, "1");
2343
2344 let bar1 = &parsed.data[1];
2346 assert_eq!(bar1.0, "1747093980000");
2347 assert_eq!(bar1.5, "7164.04");
2348 assert_eq!(bar1.6, "71.6404");
2349 assert_eq!(bar1.7, "7364701.57952");
2350 assert_eq!(bar1.8, "1");
2351 }
2352
2353 #[rstest]
2354 fn test_parse_mark_price() {
2355 let json_data = load_test_json("http_get_mark_price.json");
2356 let parsed: OKXResponse<OKXMarkPrice> = serde_json::from_str(&json_data).unwrap();
2357
2358 assert_eq!(parsed.code, "0");
2360 assert_eq!(parsed.msg, "");
2361 assert_eq!(parsed.data.len(), 1);
2362
2363 let mark_price = &parsed.data[0];
2365
2366 assert_eq!(mark_price.inst_id, "BTC-USDT-SWAP");
2367 assert_eq!(mark_price.mark_px, "84660.1");
2368 assert_eq!(mark_price.ts, 1744590349506);
2369 }
2370
2371 #[rstest]
2372 fn test_parse_index_price() {
2373 let json_data = load_test_json("http_get_index_price.json");
2374 let parsed: OKXResponse<OKXIndexTicker> = serde_json::from_str(&json_data).unwrap();
2375
2376 assert_eq!(parsed.code, "0");
2378 assert_eq!(parsed.msg, "");
2379 assert_eq!(parsed.data.len(), 1);
2380
2381 let index_price = &parsed.data[0];
2383
2384 assert_eq!(index_price.inst_id, "BTC-USDT");
2385 assert_eq!(index_price.idx_px, "103895");
2386 assert_eq!(index_price.ts, 1746942707815);
2387 }
2388
2389 #[rstest]
2390 fn test_parse_account() {
2391 let json_data = load_test_json("http_get_account_balance.json");
2392 let parsed: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
2393
2394 assert_eq!(parsed.code, "0");
2396 assert_eq!(parsed.msg, "");
2397 assert_eq!(parsed.data.len(), 1);
2398
2399 let account = &parsed.data[0];
2401 assert_eq!(account.adj_eq, "");
2402 assert_eq!(account.borrow_froz, "");
2403 assert_eq!(account.imr, "");
2404 assert_eq!(account.iso_eq, "5.4682385526666675");
2405 assert_eq!(account.mgn_ratio, "");
2406 assert_eq!(account.mmr, "");
2407 assert_eq!(account.notional_usd, "");
2408 assert_eq!(account.notional_usd_for_borrow, "");
2409 assert_eq!(account.notional_usd_for_futures, "");
2410 assert_eq!(account.notional_usd_for_option, "");
2411 assert_eq!(account.notional_usd_for_swap, "");
2412 assert_eq!(account.ord_froz, "");
2413 assert_eq!(account.total_eq, "99.88870288820581");
2414 assert_eq!(account.upl, "");
2415 assert_eq!(account.u_time, 1744499648556);
2416 assert_eq!(account.details.len(), 1);
2417
2418 let detail = &account.details[0];
2419 assert_eq!(detail.ccy, "USDT");
2420 assert_eq!(detail.avail_bal, "94.42612990333333");
2421 assert_eq!(detail.avail_eq, "94.42612990333333");
2422 assert_eq!(detail.cash_bal, "94.42612990333333");
2423 assert_eq!(detail.dis_eq, "5.4682385526666675");
2424 assert_eq!(detail.eq, "99.89469657000001");
2425 assert_eq!(detail.eq_usd, "99.88870288820581");
2426 assert_eq!(detail.fixed_bal, "0");
2427 assert_eq!(detail.frozen_bal, "5.468566666666667");
2428 assert_eq!(detail.imr, "0");
2429 assert_eq!(detail.iso_eq, "5.468566666666667");
2430 assert_eq!(detail.iso_upl, "-0.0273000000000002");
2431 assert_eq!(detail.mmr, "0");
2432 assert_eq!(detail.notional_lever, "0");
2433 assert_eq!(detail.ord_frozen, "0");
2434 assert_eq!(detail.reward_bal, "0");
2435 assert_eq!(detail.smt_sync_eq, "0");
2436 assert_eq!(detail.spot_copy_trading_eq, "0");
2437 assert_eq!(detail.spot_iso_bal, "0");
2438 assert_eq!(detail.stgy_eq, "0");
2439 assert_eq!(detail.twap, "0");
2440 assert_eq!(detail.upl, "-0.0273000000000002");
2441 assert_eq!(detail.u_time, 1744498994783);
2442 }
2443
2444 #[rstest]
2445 fn test_parse_order_history() {
2446 let json_data = load_test_json("http_get_orders_history.json");
2447 let parsed: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
2448
2449 assert_eq!(parsed.code, "0");
2451 assert_eq!(parsed.msg, "");
2452 assert_eq!(parsed.data.len(), 1);
2453
2454 let order = &parsed.data[0];
2456 assert_eq!(order.ord_id, "2497956918703120384");
2457 assert_eq!(order.fill_sz, "0.03");
2458 assert_eq!(order.acc_fill_sz, "0.03");
2459 assert_eq!(order.state, OKXOrderStatus::Filled);
2460 assert!(order.fill_fee.is_none());
2461 }
2462
2463 #[rstest]
2464 fn test_parse_position() {
2465 let json_data = load_test_json("http_get_positions.json");
2466 let parsed: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
2467
2468 assert_eq!(parsed.code, "0");
2470 assert_eq!(parsed.msg, "");
2471 assert_eq!(parsed.data.len(), 1);
2472
2473 let pos = &parsed.data[0];
2475 assert_eq!(pos.inst_id, "BTC-USDT-SWAP");
2476 assert_eq!(pos.pos_side, OKXPositionSide::Long);
2477 assert_eq!(pos.pos, "0.5");
2478 assert_eq!(pos.base_bal, "0.5");
2479 assert_eq!(pos.quote_bal, "5000");
2480 assert_eq!(pos.u_time, 1622559930237);
2481 }
2482
2483 #[rstest]
2484 fn test_parse_position_history() {
2485 let json_data = load_test_json("http_get_account_positions-history.json");
2486 let parsed: OKXResponse<OKXPositionHistory> = serde_json::from_str(&json_data).unwrap();
2487
2488 assert_eq!(parsed.code, "0");
2490 assert_eq!(parsed.msg, "");
2491 assert_eq!(parsed.data.len(), 1);
2492
2493 let hist = &parsed.data[0];
2495 assert_eq!(hist.inst_id, "ETH-USDT-SWAP");
2496 assert_eq!(hist.inst_type, OKXInstrumentType::Swap);
2497 assert_eq!(hist.mgn_mode, OKXMarginMode::Isolated);
2498 assert_eq!(hist.pos_side, OKXPositionSide::Long);
2499 assert_eq!(hist.lever, "3.0");
2500 assert_eq!(hist.open_avg_px, "3226.93");
2501 assert_eq!(hist.close_avg_px.as_deref(), Some("3224.8"));
2502 assert_eq!(hist.pnl.as_deref(), Some("-0.0213"));
2503 assert!(!hist.c_time.is_empty());
2504 assert!(hist.u_time > 0);
2505 }
2506
2507 #[rstest]
2508 fn test_parse_position_tiers() {
2509 let json_data = load_test_json("http_get_position_tiers.json");
2510 let parsed: OKXResponse<OKXPositionTier> = serde_json::from_str(&json_data).unwrap();
2511
2512 assert_eq!(parsed.code, "0");
2514 assert_eq!(parsed.msg, "");
2515 assert_eq!(parsed.data.len(), 1);
2516
2517 let tier = &parsed.data[0];
2519 assert_eq!(tier.inst_id, "BTC-USDT");
2520 assert_eq!(tier.tier, "1");
2521 assert_eq!(tier.min_sz, "0");
2522 assert_eq!(tier.max_sz, "50");
2523 assert_eq!(tier.imr, "0.1");
2524 assert_eq!(tier.mmr, "0.03");
2525 }
2526
2527 #[rstest]
2528 fn test_parse_account_field_name_compatibility() {
2529 let json_new = load_test_json("http_balance_detail_new_fields.json");
2531 let detail_new: OKXBalanceDetail = serde_json::from_str(&json_new).unwrap();
2532 assert_eq!(detail_new.max_spot_in_use_amt, "50.0");
2533 assert_eq!(detail_new.spot_in_use_amt, "30.0");
2534 assert_eq!(detail_new.cl_spot_in_use_amt, "25.0");
2535
2536 let json_old = load_test_json("http_balance_detail_old_fields.json");
2538 let detail_old: OKXBalanceDetail = serde_json::from_str(&json_old).unwrap();
2539 assert_eq!(detail_old.max_spot_in_use_amt, "75.0");
2540 assert_eq!(detail_old.spot_in_use_amt, "40.0");
2541 assert_eq!(detail_old.cl_spot_in_use_amt, "35.0");
2542 }
2543
2544 #[rstest]
2545 fn test_parse_place_order_response() {
2546 let json_data = load_test_json("http_place_order_response.json");
2547 let parsed: OKXPlaceOrderResponse = serde_json::from_str(&json_data).unwrap();
2548 assert_eq!(parsed.ord_id, Some(Ustr::from("12345678901234567890")));
2549 assert_eq!(parsed.cl_ord_id, Some(Ustr::from("client_order_123")));
2550 assert_eq!(parsed.tag, Some(String::new()));
2551 }
2552
2553 #[rstest]
2554 fn test_parse_transaction_details() {
2555 let json_data = load_test_json("http_transaction_detail.json");
2556 let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
2557 assert_eq!(parsed.inst_type, OKXInstrumentType::Spot);
2558 assert_eq!(parsed.inst_id, Ustr::from("BTC-USDT"));
2559 assert_eq!(parsed.trade_id, Ustr::from("123456789"));
2560 assert_eq!(parsed.ord_id, Ustr::from("987654321"));
2561 assert_eq!(parsed.cl_ord_id, Ustr::from("client_123"));
2562 assert_eq!(parsed.bill_id, Ustr::from("bill_456"));
2563 assert_eq!(parsed.fill_px, "42000.5");
2564 assert_eq!(parsed.fill_sz, "0.001");
2565 assert_eq!(parsed.side, OKXSide::Buy);
2566 assert_eq!(parsed.exec_type, OKXExecType::Taker);
2567 assert_eq!(parsed.fee_ccy, "USDT");
2568 assert_eq!(parsed.fee, Some("0.042".to_string()));
2569 assert_eq!(parsed.ts, 1625097600000);
2570 }
2571
2572 #[rstest]
2573 fn test_parse_empty_fee_field() {
2574 let json_data = load_test_json("http_transaction_detail_empty_fee.json");
2575 let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
2576 assert_eq!(parsed.fee, None);
2577 }
2578
2579 #[rstest]
2580 fn test_parse_optional_string_to_u64() {
2581 use serde::Deserialize;
2582
2583 #[derive(Deserialize)]
2584 struct TestStruct {
2585 #[serde(deserialize_with = "crate::common::parse::deserialize_optional_string_to_u64")]
2586 value: Option<u64>,
2587 }
2588
2589 let json_cases = load_test_json("common_optional_string_to_u64.json");
2590 let cases: Vec<TestStruct> = serde_json::from_str(&json_cases).unwrap();
2591
2592 assert_eq!(cases[0].value, Some(12345));
2593 assert_eq!(cases[1].value, None);
2594 assert_eq!(cases[2].value, None);
2595 }
2596
2597 #[rstest]
2598 fn test_parse_error_handling() {
2599 let invalid_price = "invalid-price";
2601 let result = crate::common::parse::parse_price(invalid_price, 2);
2602 assert!(result.is_err());
2603
2604 let invalid_quantity = "invalid-quantity";
2606 let result = crate::common::parse::parse_quantity(invalid_quantity, 8);
2607 assert!(result.is_err());
2608 }
2609
2610 #[rstest]
2611 fn test_parse_spot_instrument() {
2612 let json_data = load_test_json("http_get_instruments_spot.json");
2613 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2614 let okx_inst: &OKXInstrument = response
2615 .data
2616 .first()
2617 .expect("Test data must have an instrument");
2618
2619 let instrument =
2620 parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2621
2622 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD.OKX"));
2623 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD"));
2624 assert_eq!(instrument.underlying(), None);
2625 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2626 assert_eq!(instrument.quote_currency(), Currency::USD());
2627 assert_eq!(instrument.settlement_currency(), Currency::USD());
2628 assert_eq!(instrument.price_precision(), 1);
2629 assert_eq!(instrument.size_precision(), 8);
2630 assert_eq!(instrument.price_increment(), Price::from("0.1"));
2631 assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
2632 assert_eq!(instrument.multiplier(), Quantity::from(1));
2633 assert_eq!(instrument.lot_size(), Some(Quantity::from("0.00000001")));
2634 assert_eq!(instrument.max_quantity(), Some(Quantity::from(1000000)));
2635 assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.00001")));
2636 assert_eq!(instrument.max_notional(), None);
2637 assert_eq!(instrument.min_notional(), None);
2638 assert_eq!(instrument.max_price(), None);
2639 assert_eq!(instrument.min_price(), None);
2640 }
2641
2642 #[rstest]
2643 fn test_parse_margin_instrument() {
2644 let json_data = load_test_json("http_get_instruments_margin.json");
2645 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2646 let okx_inst: &OKXInstrument = response
2647 .data
2648 .first()
2649 .expect("Test data must have an instrument");
2650
2651 let instrument =
2652 parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2653
2654 assert_eq!(instrument.id(), InstrumentId::from("BTC-USDT.OKX"));
2655 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USDT"));
2656 assert_eq!(instrument.underlying(), None);
2657 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2658 assert_eq!(instrument.quote_currency(), Currency::USDT());
2659 assert_eq!(instrument.settlement_currency(), Currency::USDT());
2660 assert_eq!(instrument.price_precision(), 1);
2661 assert_eq!(instrument.size_precision(), 8);
2662 assert_eq!(instrument.price_increment(), Price::from("0.1"));
2663 assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
2664 assert_eq!(instrument.multiplier(), Quantity::from(1));
2665 assert_eq!(instrument.lot_size(), Some(Quantity::from("0.00000001")));
2666 assert_eq!(instrument.max_quantity(), Some(Quantity::from(1000000)));
2667 assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.00001")));
2668 assert_eq!(instrument.max_notional(), None);
2669 assert_eq!(instrument.min_notional(), None);
2670 assert_eq!(instrument.max_price(), None);
2671 assert_eq!(instrument.min_price(), None);
2672 }
2673
2674 #[rstest]
2675 fn test_parse_spot_instrument_with_valid_ct_mult() {
2676 let json_data = load_test_json("http_get_instruments_spot.json");
2677 let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2678
2679 if let Some(inst) = response.data.first_mut() {
2681 inst.ct_mult = "0.01".to_string();
2682 }
2683
2684 let okx_inst = response.data.first().unwrap();
2685 let instrument =
2686 parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2687
2688 if let InstrumentAny::CurrencyPair(pair) = instrument {
2690 assert_eq!(pair.multiplier, Quantity::from("0.01"));
2691 } else {
2692 panic!("Expected CurrencyPair instrument");
2693 }
2694 }
2695
2696 #[rstest]
2697 fn test_parse_spot_instrument_with_invalid_ct_mult() {
2698 let json_data = load_test_json("http_get_instruments_spot.json");
2699 let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2700
2701 if let Some(inst) = response.data.first_mut() {
2703 inst.ct_mult = "invalid_number".to_string();
2704 }
2705
2706 let okx_inst = response.data.first().unwrap();
2707 let result = parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default());
2708
2709 assert!(result.is_err());
2711 assert!(
2712 result
2713 .unwrap_err()
2714 .to_string()
2715 .contains("Failed to parse `ct_mult`")
2716 );
2717 }
2718
2719 #[rstest]
2720 fn test_parse_spot_instrument_with_fees() {
2721 let json_data = load_test_json("http_get_instruments_spot.json");
2722 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2723 let okx_inst = response.data.first().unwrap();
2724
2725 let maker_fee = Some(dec!(0.0008));
2726 let taker_fee = Some(dec!(0.0010));
2727
2728 let instrument = parse_spot_instrument(
2729 okx_inst,
2730 None,
2731 None,
2732 maker_fee,
2733 taker_fee,
2734 UnixNanos::default(),
2735 )
2736 .unwrap();
2737
2738 if let InstrumentAny::CurrencyPair(pair) = instrument {
2740 assert_eq!(pair.maker_fee, dec!(0.0008));
2741 assert_eq!(pair.taker_fee, dec!(0.0010));
2742 } else {
2743 panic!("Expected CurrencyPair instrument");
2744 }
2745 }
2746
2747 #[rstest]
2748 fn test_parse_instrument_any_passes_through_fees() {
2749 let json_data = load_test_json("http_get_instruments_spot.json");
2752 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2753 let okx_inst = response.data.first().unwrap();
2754
2755 let maker_fee = Some(dec!(-0.00025)); let taker_fee = Some(dec!(0.00050)); let instrument = parse_instrument_any(
2760 okx_inst,
2761 None,
2762 None,
2763 maker_fee,
2764 taker_fee,
2765 UnixNanos::default(),
2766 )
2767 .unwrap()
2768 .expect("Should parse spot instrument");
2769
2770 if let InstrumentAny::CurrencyPair(pair) = instrument {
2772 assert_eq!(pair.maker_fee, dec!(-0.00025));
2773 assert_eq!(pair.taker_fee, dec!(0.00050));
2774 } else {
2775 panic!("Expected CurrencyPair instrument");
2776 }
2777 }
2778
2779 #[rstest]
2780 fn test_parse_swap_instrument() {
2781 let json_data = load_test_json("http_get_instruments_swap.json");
2782 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2783 let okx_inst: &OKXInstrument = response
2784 .data
2785 .first()
2786 .expect("Test data must have an instrument");
2787
2788 let instrument =
2789 parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2790
2791 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-SWAP.OKX"));
2792 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-SWAP"));
2793 assert_eq!(instrument.underlying(), None);
2794 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2795 assert_eq!(instrument.quote_currency(), Currency::USD());
2796 assert_eq!(instrument.settlement_currency(), Currency::BTC());
2797 assert!(instrument.is_inverse());
2798 assert_eq!(instrument.price_precision(), 1);
2799 assert_eq!(instrument.size_precision(), 0);
2800 assert_eq!(instrument.price_increment(), Price::from("0.1"));
2801 assert_eq!(instrument.size_increment(), Quantity::from(1));
2802 assert_eq!(instrument.multiplier(), Quantity::from(100));
2803 assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2804 assert_eq!(instrument.max_quantity(), Some(Quantity::from(30000)));
2805 assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2806 assert_eq!(instrument.max_notional(), None);
2807 assert_eq!(instrument.min_notional(), None);
2808 assert_eq!(instrument.max_price(), None);
2809 assert_eq!(instrument.min_price(), None);
2810 }
2811
2812 #[rstest]
2813 fn test_parse_linear_swap_instrument() {
2814 let json_data = load_test_json("http_get_instruments_swap.json");
2815 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2816
2817 let okx_inst = response
2818 .data
2819 .iter()
2820 .find(|i| i.inst_id == "ETH-USDT-SWAP")
2821 .expect("ETH-USDT-SWAP must be in test data");
2822
2823 let instrument =
2824 parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2825
2826 assert_eq!(instrument.id(), InstrumentId::from("ETH-USDT-SWAP.OKX"));
2827 assert_eq!(instrument.raw_symbol(), Symbol::from("ETH-USDT-SWAP"));
2828 assert_eq!(instrument.base_currency(), Some(Currency::ETH()));
2829 assert_eq!(instrument.quote_currency(), Currency::USDT());
2830 assert_eq!(instrument.settlement_currency(), Currency::USDT());
2831 assert!(!instrument.is_inverse());
2832 assert_eq!(instrument.multiplier(), Quantity::from("0.1"));
2833 assert_eq!(instrument.price_precision(), 2);
2834 assert_eq!(instrument.size_precision(), 2);
2835 assert_eq!(instrument.price_increment(), Price::from("0.01"));
2836 assert_eq!(instrument.size_increment(), Quantity::from("0.01"));
2837 assert_eq!(instrument.lot_size(), Some(Quantity::from("0.01")));
2838 assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.01")));
2839 assert_eq!(instrument.max_quantity(), Some(Quantity::from(20000)));
2840 }
2841
2842 #[rstest]
2843 fn test_parse_inst_id_code_from_swap_instrument() {
2844 let json_data = load_test_json("http_get_instruments_swap.json");
2845 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2846
2847 let btc_usd_swap = response
2849 .data
2850 .iter()
2851 .find(|i| i.inst_id == "BTC-USD-SWAP")
2852 .expect("BTC-USD-SWAP must be in test data");
2853 assert_eq!(btc_usd_swap.inst_id_code, Some(10458));
2854
2855 let eth_usdt_swap = response
2857 .data
2858 .iter()
2859 .find(|i| i.inst_id == "ETH-USDT-SWAP")
2860 .expect("ETH-USDT-SWAP must be in test data");
2861 assert_eq!(eth_usdt_swap.inst_id_code, Some(10461));
2862
2863 let btc_usdt_swap = response
2865 .data
2866 .iter()
2867 .find(|i| i.inst_id == "BTC-USDT-SWAP")
2868 .expect("BTC-USDT-SWAP must be in test data");
2869 assert_eq!(btc_usdt_swap.inst_id_code, Some(10459));
2870 }
2871
2872 #[rstest]
2873 fn test_fee_field_selection_for_contract_types() {
2874 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;
2882 let (maker_str, taker_str) = if is_usdt_margined {
2883 (maker_usdt, taker_usdt)
2884 } else {
2885 (maker_crypto, taker_crypto)
2886 };
2887
2888 assert_eq!(maker_str, "0.0008");
2889 assert_eq!(taker_str, "0.0010");
2890
2891 let maker_fee = Decimal::from_str(maker_str).unwrap();
2892 let taker_fee = Decimal::from_str(taker_str).unwrap();
2893
2894 assert_eq!(maker_fee, dec!(0.0008));
2895 assert_eq!(taker_fee, dec!(0.0010));
2896
2897 let is_usdt_margined = false;
2899 let (maker_str, taker_str) = if is_usdt_margined {
2900 (maker_usdt, taker_usdt)
2901 } else {
2902 (maker_crypto, taker_crypto)
2903 };
2904
2905 assert_eq!(maker_str, "0.0002");
2906 assert_eq!(taker_str, "0.0005");
2907
2908 let maker_fee = Decimal::from_str(maker_str).unwrap();
2909 let taker_fee = Decimal::from_str(taker_str).unwrap();
2910
2911 assert_eq!(maker_fee, dec!(0.0002));
2912 assert_eq!(taker_fee, dec!(0.0005));
2913 }
2914
2915 #[rstest]
2916 fn test_parse_futures_instrument() {
2917 let json_data = load_test_json("http_get_instruments_futures.json");
2918 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2919 let okx_inst: &OKXInstrument = response
2920 .data
2921 .first()
2922 .expect("Test data must have an instrument");
2923
2924 let instrument =
2925 parse_futures_instrument(okx_inst, None, None, None, None, UnixNanos::default())
2926 .unwrap();
2927
2928 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-241220.OKX"));
2929 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-241220"));
2930 assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
2931 assert_eq!(instrument.quote_currency(), Currency::USD());
2932 assert_eq!(instrument.settlement_currency(), Currency::BTC());
2933 assert!(instrument.is_inverse());
2934 assert_eq!(instrument.price_precision(), 1);
2935 assert_eq!(instrument.size_precision(), 0);
2936 assert_eq!(instrument.price_increment(), Price::from("0.1"));
2937 assert_eq!(instrument.size_increment(), Quantity::from(1));
2938 assert_eq!(instrument.multiplier(), Quantity::from(100));
2939 assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2940 assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2941 assert_eq!(instrument.max_quantity(), Some(Quantity::from(10000)));
2942 }
2943
2944 #[rstest]
2945 fn test_parse_option_instrument() {
2946 let json_data = load_test_json("http_get_instruments_option.json");
2947 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2948 let okx_inst: &OKXInstrument = response
2949 .data
2950 .first()
2951 .expect("Test data must have an instrument");
2952
2953 let instrument =
2954 parse_option_instrument(okx_inst, None, None, None, None, UnixNanos::default())
2955 .unwrap();
2956
2957 assert_eq!(
2958 instrument.id(),
2959 InstrumentId::from("BTC-USD-241217-92000-C.OKX")
2960 );
2961 assert_eq!(
2962 instrument.raw_symbol(),
2963 Symbol::from("BTC-USD-241217-92000-C")
2964 );
2965 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2966 assert_eq!(instrument.quote_currency(), Currency::USD());
2967 assert_eq!(instrument.settlement_currency(), Currency::BTC());
2968 assert!(instrument.is_inverse());
2969 assert_eq!(instrument.price_precision(), 4);
2970 assert_eq!(instrument.size_precision(), 0);
2971 assert_eq!(instrument.price_increment(), Price::from("0.0001"));
2972 assert_eq!(instrument.size_increment(), Quantity::from(1));
2973 assert_eq!(instrument.multiplier(), Quantity::from("0.01"));
2974 assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2975 assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2976 assert_eq!(instrument.max_quantity(), Some(Quantity::from(5000)));
2977 assert_eq!(instrument.max_notional(), None);
2978 assert_eq!(instrument.min_notional(), None);
2979 assert_eq!(instrument.max_price(), None);
2980 assert_eq!(instrument.min_price(), None);
2981 }
2982
2983 #[rstest]
2984 fn test_parse_account_state() {
2985 let json_data = load_test_json("http_get_account_balance.json");
2986 let response: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
2987 let okx_account = response
2988 .data
2989 .first()
2990 .expect("Test data must have an account");
2991
2992 let account_id = AccountId::new("OKX-001");
2993 let account_state =
2994 parse_account_state(okx_account, account_id, UnixNanos::default()).unwrap();
2995
2996 assert_eq!(account_state.account_id, account_id);
2997 assert_eq!(account_state.account_type, AccountType::Margin);
2998 assert_eq!(account_state.balances.len(), 1);
2999 assert_eq!(account_state.margins.len(), 0); assert!(account_state.is_reported);
3001
3002 let usdt_balance = &account_state.balances[0];
3004 assert_eq!(
3005 usdt_balance.total,
3006 Money::new(94.42612990333333, Currency::USDT())
3007 );
3008 assert_eq!(
3009 usdt_balance.free,
3010 Money::new(94.42612990333333, Currency::USDT())
3011 );
3012 assert_eq!(usdt_balance.locked, Money::new(0.0, Currency::USDT()));
3013 }
3014
3015 #[rstest]
3016 fn test_parse_account_state_with_margins() {
3017 let account_json = r#"{
3019 "adjEq": "10000.0",
3020 "borrowFroz": "0",
3021 "details": [{
3022 "accAvgPx": "",
3023 "availBal": "8000.0",
3024 "availEq": "8000.0",
3025 "borrowFroz": "0",
3026 "cashBal": "10000.0",
3027 "ccy": "USDT",
3028 "clSpotInUseAmt": "0",
3029 "coinUsdPrice": "1.0",
3030 "colBorrAutoConversion": "0",
3031 "collateralEnabled": false,
3032 "collateralRestrict": false,
3033 "crossLiab": "0",
3034 "disEq": "10000.0",
3035 "eq": "10000.0",
3036 "eqUsd": "10000.0",
3037 "fixedBal": "0",
3038 "frozenBal": "2000.0",
3039 "imr": "0",
3040 "interest": "0",
3041 "isoEq": "0",
3042 "isoLiab": "0",
3043 "isoUpl": "0",
3044 "liab": "0",
3045 "maxLoan": "0",
3046 "mgnRatio": "0",
3047 "maxSpotInUseAmt": "0",
3048 "mmr": "0",
3049 "notionalLever": "0",
3050 "openAvgPx": "",
3051 "ordFrozen": "2000.0",
3052 "rewardBal": "0",
3053 "smtSyncEq": "0",
3054 "spotBal": "0",
3055 "spotCopyTradingEq": "0",
3056 "spotInUseAmt": "0",
3057 "spotIsoBal": "0",
3058 "spotUpl": "0",
3059 "spotUplRatio": "0",
3060 "stgyEq": "0",
3061 "totalPnl": "0",
3062 "totalPnlRatio": "0",
3063 "twap": "0",
3064 "uTime": "1704067200000",
3065 "upl": "0",
3066 "uplLiab": "0"
3067 }],
3068 "imr": "500.25",
3069 "isoEq": "0",
3070 "mgnRatio": "20.5",
3071 "mmr": "250.75",
3072 "notionalUsd": "5000.0",
3073 "notionalUsdForBorrow": "0",
3074 "notionalUsdForFutures": "0",
3075 "notionalUsdForOption": "0",
3076 "notionalUsdForSwap": "5000.0",
3077 "ordFroz": "2000.0",
3078 "totalEq": "10000.0",
3079 "uTime": "1704067200000",
3080 "upl": "0"
3081 }"#;
3082
3083 let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
3084 let account_id = AccountId::new("OKX-001");
3085 let account_state =
3086 parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
3087
3088 assert_eq!(account_state.account_id, account_id);
3090 assert_eq!(account_state.account_type, AccountType::Margin);
3091 assert_eq!(account_state.balances.len(), 1);
3092
3093 assert_eq!(account_state.margins.len(), 1);
3095 let margin = &account_state.margins[0];
3096
3097 assert_eq!(margin.initial, Money::new(500.25, Currency::USD()));
3099 assert_eq!(margin.maintenance, Money::new(250.75, Currency::USD()));
3100 assert_eq!(margin.currency, Currency::USD());
3101 assert!(margin.instrument_id.is_none());
3102
3103 let usdt_balance = &account_state.balances[0];
3105 assert_eq!(usdt_balance.total, Money::new(10000.0, Currency::USDT()));
3106 assert_eq!(usdt_balance.free, Money::new(8000.0, Currency::USDT()));
3107 assert_eq!(usdt_balance.locked, Money::new(2000.0, Currency::USDT()));
3108 }
3109
3110 #[rstest]
3111 fn test_parse_account_state_empty_margins() {
3112 let account_json = r#"{
3114 "adjEq": "",
3115 "borrowFroz": "",
3116 "details": [{
3117 "accAvgPx": "",
3118 "availBal": "1000.0",
3119 "availEq": "1000.0",
3120 "borrowFroz": "0",
3121 "cashBal": "1000.0",
3122 "ccy": "BTC",
3123 "clSpotInUseAmt": "0",
3124 "coinUsdPrice": "50000.0",
3125 "colBorrAutoConversion": "0",
3126 "collateralEnabled": false,
3127 "collateralRestrict": false,
3128 "crossLiab": "0",
3129 "disEq": "50000.0",
3130 "eq": "1000.0",
3131 "eqUsd": "50000.0",
3132 "fixedBal": "0",
3133 "frozenBal": "0",
3134 "imr": "0",
3135 "interest": "0",
3136 "isoEq": "0",
3137 "isoLiab": "0",
3138 "isoUpl": "0",
3139 "liab": "0",
3140 "maxLoan": "0",
3141 "mgnRatio": "0",
3142 "maxSpotInUseAmt": "0",
3143 "mmr": "0",
3144 "notionalLever": "0",
3145 "openAvgPx": "",
3146 "ordFrozen": "0",
3147 "rewardBal": "0",
3148 "smtSyncEq": "0",
3149 "spotBal": "0",
3150 "spotCopyTradingEq": "0",
3151 "spotInUseAmt": "0",
3152 "spotIsoBal": "0",
3153 "spotUpl": "0",
3154 "spotUplRatio": "0",
3155 "stgyEq": "0",
3156 "totalPnl": "0",
3157 "totalPnlRatio": "0",
3158 "twap": "0",
3159 "uTime": "1704067200000",
3160 "upl": "0",
3161 "uplLiab": "0"
3162 }],
3163 "imr": "",
3164 "isoEq": "0",
3165 "mgnRatio": "",
3166 "mmr": "",
3167 "notionalUsd": "",
3168 "notionalUsdForBorrow": "",
3169 "notionalUsdForFutures": "",
3170 "notionalUsdForOption": "",
3171 "notionalUsdForSwap": "",
3172 "ordFroz": "",
3173 "totalEq": "50000.0",
3174 "uTime": "1704067200000",
3175 "upl": "0"
3176 }"#;
3177
3178 let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
3179 let account_id = AccountId::new("OKX-SPOT");
3180 let account_state =
3181 parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
3182
3183 assert_eq!(account_state.margins.len(), 0);
3185 assert_eq!(account_state.balances.len(), 1);
3186
3187 let btc_balance = &account_state.balances[0];
3189 assert_eq!(btc_balance.total, Money::new(1000.0, Currency::BTC()));
3190 }
3191
3192 #[rstest]
3193 fn test_parse_account_state_empty_balance_account() {
3194 let account_json = r#"{
3197 "adjEq": "",
3198 "borrowFroz": "",
3199 "details": [],
3200 "imr": "",
3201 "isoEq": "0",
3202 "mgnRatio": "",
3203 "mmr": "",
3204 "notionalUsd": "",
3205 "notionalUsdForBorrow": "",
3206 "notionalUsdForFutures": "",
3207 "notionalUsdForOption": "",
3208 "notionalUsdForSwap": "",
3209 "ordFroz": "",
3210 "totalEq": "0",
3211 "uTime": "1774795570586",
3212 "upl": ""
3213 }"#;
3214
3215 let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
3216 let account_id = AccountId::new("OKX-001");
3217 let account_state =
3218 parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
3219
3220 assert_eq!(account_state.account_id, account_id);
3221 assert_eq!(account_state.account_type, AccountType::Margin);
3222 assert_eq!(account_state.margins.len(), 0);
3223
3224 assert_eq!(account_state.balances.len(), 1);
3225 let balance = &account_state.balances[0];
3226 assert_eq!(balance.total, Money::new(0.0, Currency::USD()));
3227 assert_eq!(balance.free, Money::new(0.0, Currency::USD()));
3228 assert_eq!(balance.locked, Money::new(0.0, Currency::USD()));
3229 }
3230
3231 #[rstest]
3232 fn test_parse_order_status_report() {
3233 let json_data = load_test_json("http_get_orders_history.json");
3234 let response: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
3235 let okx_order = response
3236 .data
3237 .first()
3238 .expect("Test data must have an order")
3239 .clone();
3240
3241 let account_id = AccountId::new("OKX-001");
3242 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3243 let order_report = parse_order_status_report(
3244 &okx_order,
3245 account_id,
3246 instrument_id,
3247 2,
3248 8,
3249 UnixNanos::default(),
3250 )
3251 .unwrap();
3252
3253 assert_eq!(order_report.account_id, account_id);
3254 assert_eq!(order_report.instrument_id, instrument_id);
3255 assert_eq!(order_report.quantity, Quantity::from("0.03000000"));
3256 assert_eq!(order_report.filled_qty, Quantity::from("0.03000000"));
3257 assert_eq!(order_report.order_side, OrderSide::Buy);
3258 assert_eq!(order_report.order_type, OrderType::Market);
3259 assert_eq!(order_report.order_status, OrderStatus::Filled);
3260 }
3261
3262 #[rstest]
3263 fn test_parse_position_status_report() {
3264 let json_data = load_test_json("http_get_positions.json");
3265 let response: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
3266 let okx_position = response
3267 .data
3268 .first()
3269 .expect("Test data must have a position")
3270 .clone();
3271
3272 let account_id = AccountId::new("OKX-001");
3273 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3274 let position_report = parse_position_status_report(
3275 &okx_position,
3276 account_id,
3277 instrument_id,
3278 8,
3279 UnixNanos::default(),
3280 )
3281 .unwrap();
3282
3283 assert_eq!(position_report.account_id, account_id);
3284 assert_eq!(position_report.instrument_id, instrument_id);
3285 }
3286
3287 #[rstest]
3288 fn test_parse_trade_tick() {
3289 let json_data = load_test_json("http_get_trades.json");
3290 let response: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
3291 let okx_trade = response.data.first().expect("Test data must have a trade");
3292
3293 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3294 let trade_tick =
3295 parse_trade_tick(okx_trade, instrument_id, 2, 8, UnixNanos::default()).unwrap();
3296
3297 assert_eq!(trade_tick.instrument_id, instrument_id);
3298 assert_eq!(trade_tick.price, Price::from("102537.90"));
3299 assert_eq!(trade_tick.size, Quantity::from("0.00013669"));
3300 assert_eq!(trade_tick.aggressor_side, AggressorSide::Seller);
3301 assert_eq!(trade_tick.trade_id, TradeId::new("734864333"));
3302 }
3303
3304 #[rstest]
3305 fn test_parse_mark_price_update() {
3306 let json_data = load_test_json("http_get_mark_price.json");
3307 let response: OKXResponse<crate::http::models::OKXMarkPrice> =
3308 serde_json::from_str(&json_data).unwrap();
3309 let okx_mark_price = response
3310 .data
3311 .first()
3312 .expect("Test data must have a mark price");
3313
3314 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3315 let mark_price_update =
3316 parse_mark_price_update(okx_mark_price, instrument_id, 2, UnixNanos::default())
3317 .unwrap();
3318
3319 assert_eq!(mark_price_update.instrument_id, instrument_id);
3320 assert_eq!(mark_price_update.value, Price::from("84660.10"));
3321 assert_eq!(
3322 mark_price_update.ts_event,
3323 UnixNanos::from(1744590349506000000)
3324 );
3325 }
3326
3327 #[rstest]
3328 fn test_parse_index_price_update() {
3329 let json_data = load_test_json("http_get_index_price.json");
3330 let response: OKXResponse<crate::http::models::OKXIndexTicker> =
3331 serde_json::from_str(&json_data).unwrap();
3332 let okx_index_ticker = response
3333 .data
3334 .first()
3335 .expect("Test data must have an index ticker");
3336
3337 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3338 let index_price_update =
3339 parse_index_price_update(okx_index_ticker, instrument_id, 2, UnixNanos::default())
3340 .unwrap();
3341
3342 assert_eq!(index_price_update.instrument_id, instrument_id);
3343 assert_eq!(index_price_update.value, Price::from("103895.00"));
3344 assert_eq!(
3345 index_price_update.ts_event,
3346 UnixNanos::from(1746942707815000000)
3347 );
3348 }
3349
3350 #[rstest]
3351 fn test_parse_candlestick() {
3352 let json_data = load_test_json("http_get_candlesticks.json");
3353 let response: OKXResponse<crate::http::models::OKXCandlestick> =
3354 serde_json::from_str(&json_data).unwrap();
3355 let okx_candlestick = response
3356 .data
3357 .first()
3358 .expect("Test data must have a candlestick");
3359
3360 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3361 let bar_type = BarType::new(
3362 instrument_id,
3363 BAR_SPEC_1_DAY_LAST,
3364 AggregationSource::External,
3365 );
3366 let bar = parse_candlestick(okx_candlestick, bar_type, 2, 8, UnixNanos::default()).unwrap();
3367
3368 assert_eq!(bar.bar_type, bar_type);
3369 assert_eq!(bar.open, Price::from("33528.60"));
3370 assert_eq!(bar.high, Price::from("33870.00"));
3371 assert_eq!(bar.low, Price::from("33528.60"));
3372 assert_eq!(bar.close, Price::from("33783.90"));
3373 assert_eq!(bar.volume, Quantity::from("778.83800000"));
3374 assert_eq!(bar.ts_event, UnixNanos::from(1625097600000000000));
3375 }
3376
3377 #[rstest]
3378 fn test_parse_millisecond_timestamp() {
3379 let timestamp_ms = 1625097600000u64;
3380 let result = parse_millisecond_timestamp(timestamp_ms);
3381 assert_eq!(result, UnixNanos::from(1625097600000000000));
3382 }
3383
3384 #[rstest]
3385 fn test_parse_rfc3339_timestamp() {
3386 let timestamp_str = "2021-07-01T00:00:00.000Z";
3387 let result = parse_rfc3339_timestamp(timestamp_str).unwrap();
3388 assert_eq!(result, UnixNanos::from(1625097600000000000));
3389
3390 let timestamp_str_tz = "2021-07-01T08:00:00.000+08:00";
3392 let result_tz = parse_rfc3339_timestamp(timestamp_str_tz).unwrap();
3393 assert_eq!(result_tz, UnixNanos::from(1625097600000000000));
3394
3395 let invalid_timestamp = "invalid-timestamp";
3397 assert!(parse_rfc3339_timestamp(invalid_timestamp).is_err());
3398 }
3399
3400 #[rstest]
3401 fn test_parse_price() {
3402 let price_str = "42219.5";
3403 let precision = 2;
3404 let result = parse_price(price_str, precision).unwrap();
3405 assert_eq!(result, Price::from("42219.50"));
3406
3407 let invalid_price = "invalid-price";
3409 assert!(parse_price(invalid_price, precision).is_err());
3410 }
3411
3412 #[rstest]
3413 fn test_parse_quantity() {
3414 let quantity_str = "0.12345678";
3415 let precision = 8;
3416 let result = parse_quantity(quantity_str, precision).unwrap();
3417 assert_eq!(result, Quantity::from("0.12345678"));
3418
3419 let invalid_quantity = "invalid-quantity";
3421 assert!(parse_quantity(invalid_quantity, precision).is_err());
3422 }
3423
3424 #[rstest]
3425 fn test_parse_aggressor_side() {
3426 assert_eq!(
3427 parse_aggressor_side(&Some(OKXSide::Buy)),
3428 AggressorSide::Buyer
3429 );
3430 assert_eq!(
3431 parse_aggressor_side(&Some(OKXSide::Sell)),
3432 AggressorSide::Seller
3433 );
3434 assert_eq!(parse_aggressor_side(&None), AggressorSide::NoAggressor);
3435 }
3436
3437 #[rstest]
3438 fn test_parse_execution_type() {
3439 assert_eq!(
3440 parse_execution_type(&Some(OKXExecType::Maker)),
3441 LiquiditySide::Maker
3442 );
3443 assert_eq!(
3444 parse_execution_type(&Some(OKXExecType::Taker)),
3445 LiquiditySide::Taker
3446 );
3447 assert_eq!(parse_execution_type(&None), LiquiditySide::NoLiquiditySide);
3448 }
3449
3450 #[rstest]
3451 fn test_parse_position_side() {
3452 assert_eq!(parse_position_side(Some(100)), PositionSide::Long);
3453 assert_eq!(parse_position_side(Some(-100)), PositionSide::Short);
3454 assert_eq!(parse_position_side(Some(0)), PositionSide::Flat);
3455 assert_eq!(parse_position_side(None), PositionSide::Flat);
3456 }
3457
3458 #[rstest]
3459 fn test_parse_client_order_id() {
3460 let valid_id = "client_order_123";
3461 let result = parse_client_order_id(valid_id);
3462 assert_eq!(result, Some(ClientOrderId::new(valid_id)));
3463
3464 let empty_id = "";
3465 let result_empty = parse_client_order_id(empty_id);
3466 assert_eq!(result_empty, None);
3467 }
3468
3469 #[rstest]
3470 fn test_deserialize_empty_string_as_none() {
3471 let json_with_empty = r#""""#;
3472 let result: Option<String> = serde_json::from_str(json_with_empty).unwrap();
3473 let processed = result.filter(|s| !s.is_empty());
3474 assert_eq!(processed, None);
3475
3476 let json_with_value = r#""test_value""#;
3477 let result: Option<String> = serde_json::from_str(json_with_value).unwrap();
3478 let processed = result.filter(|s| !s.is_empty());
3479 assert_eq!(processed, Some("test_value".to_string()));
3480 }
3481
3482 #[rstest]
3483 fn test_deserialize_string_to_u64() {
3484 use serde::Deserialize;
3485
3486 #[derive(Deserialize)]
3487 struct TestStruct {
3488 #[serde(deserialize_with = "deserialize_string_to_u64")]
3489 value: u64,
3490 }
3491
3492 let json_value = r#"{"value": "12345"}"#;
3493 let result: TestStruct = serde_json::from_str(json_value).unwrap();
3494 assert_eq!(result.value, 12345);
3495
3496 let json_empty = r#"{"value": ""}"#;
3497 let result_empty: TestStruct = serde_json::from_str(json_empty).unwrap();
3498 assert_eq!(result_empty.value, 0);
3499 }
3500
3501 #[rstest]
3502 fn test_fill_report_parsing() {
3503 let transaction_detail = crate::http::models::OKXTransactionDetail {
3505 inst_type: OKXInstrumentType::Spot,
3506 inst_id: Ustr::from("BTC-USDT"),
3507 trade_id: Ustr::from("12345"),
3508 ord_id: Ustr::from("67890"),
3509 cl_ord_id: Ustr::from("client_123"),
3510 bill_id: Ustr::from("bill_456"),
3511 fill_px: "42219.5".to_string(),
3512 fill_sz: "0.001".to_string(),
3513 side: OKXSide::Buy,
3514 exec_type: OKXExecType::Taker,
3515 fee_ccy: "USDT".to_string(),
3516 fee: Some("0.042".to_string()),
3517 ts: 1625097600000,
3518 };
3519
3520 let account_id = AccountId::new("OKX-001");
3521 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3522 let fill_report = parse_fill_report(
3523 &transaction_detail,
3524 account_id,
3525 instrument_id,
3526 2,
3527 8,
3528 UnixNanos::default(),
3529 )
3530 .unwrap();
3531
3532 assert_eq!(fill_report.account_id, account_id);
3533 assert_eq!(fill_report.instrument_id, instrument_id);
3534 assert_eq!(fill_report.trade_id, TradeId::new("12345"));
3535 assert_eq!(fill_report.venue_order_id, VenueOrderId::new("67890"));
3536 assert_eq!(fill_report.order_side, OrderSide::Buy);
3537 assert_eq!(fill_report.last_px, Price::from("42219.50"));
3538 assert_eq!(fill_report.last_qty, Quantity::from("0.00100000"));
3539 assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
3540 }
3541
3542 #[rstest]
3543 fn test_bar_type_identity_preserved_through_parse() {
3544 use std::str::FromStr;
3545
3546 use crate::http::models::OKXCandlestick;
3547
3548 let bar_type = BarType::from_str("ETH-USDT-SWAP.OKX-1-MINUTE-LAST-EXTERNAL").unwrap();
3550
3551 let raw_candlestick = OKXCandlestick(
3553 "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(), );
3563
3564 let bar =
3566 parse_candlestick(&raw_candlestick, bar_type, 1, 3, UnixNanos::default()).unwrap();
3567
3568 assert_eq!(
3570 bar.bar_type, bar_type,
3571 "BarType must be preserved exactly through parsing"
3572 );
3573 }
3574
3575 #[rstest]
3576 fn test_deserialize_vip_level_all_formats() {
3577 use serde::Deserialize;
3578 use serde_json;
3579
3580 #[derive(Deserialize)]
3581 struct TestFeeRate {
3582 #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3583 level: OKXVipLevel,
3584 }
3585
3586 let json = r#"{"level":"VIP4"}"#;
3588 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3589 assert_eq!(result.level, OKXVipLevel::Vip4);
3590
3591 let json = r#"{"level":"VIP5"}"#;
3592 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3593 assert_eq!(result.level, OKXVipLevel::Vip5);
3594
3595 let json = r#"{"level":"Lv1"}"#;
3597 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3598 assert_eq!(result.level, OKXVipLevel::Vip1);
3599
3600 let json = r#"{"level":"Lv0"}"#;
3601 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3602 assert_eq!(result.level, OKXVipLevel::Vip0);
3603
3604 let json = r#"{"level":"Lv9"}"#;
3605 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3606 assert_eq!(result.level, OKXVipLevel::Vip9);
3607 }
3608
3609 #[rstest]
3610 fn test_deserialize_vip_level_empty_string() {
3611 use serde::Deserialize;
3612 use serde_json;
3613
3614 #[derive(Deserialize)]
3615 struct TestFeeRate {
3616 #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3617 level: OKXVipLevel,
3618 }
3619
3620 let json = r#"{"level":""}"#;
3622 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3623 assert_eq!(result.level, OKXVipLevel::Vip0);
3624 }
3625
3626 #[rstest]
3627 fn test_deserialize_vip_level_without_prefix() {
3628 use serde::Deserialize;
3629 use serde_json;
3630
3631 #[derive(Deserialize)]
3632 struct TestFeeRate {
3633 #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3634 level: OKXVipLevel,
3635 }
3636
3637 let json = r#"{"level":"5"}"#;
3638 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3639 assert_eq!(result.level, OKXVipLevel::Vip5);
3640 }
3641
3642 #[rstest]
3643 fn test_parse_position_status_report_net_mode_long() {
3644 let position = OKXPosition {
3646 inst_id: Ustr::from("BTC-USDT-SWAP"),
3647 inst_type: OKXInstrumentType::Swap,
3648 mgn_mode: OKXMarginMode::Cross,
3649 pos_id: Some(Ustr::from("12345")),
3650 pos_side: OKXPositionSide::Net, pos: "1.5".to_string(), base_bal: "1.5".to_string(),
3653 ccy: "BTC".to_string(),
3654 fee: "0.01".to_string(),
3655 lever: "10.0".to_string(),
3656 last: "50000".to_string(),
3657 mark_px: "50000".to_string(),
3658 liq_px: "45000".to_string(),
3659 mmr: "0.1".to_string(),
3660 interest: "0".to_string(),
3661 trade_id: Ustr::from("111"),
3662 notional_usd: "75000".to_string(),
3663 avg_px: "50000".to_string(),
3664 upl: "0".to_string(),
3665 upl_ratio: "0".to_string(),
3666 u_time: 1622559930237,
3667 margin: "0.5".to_string(),
3668 mgn_ratio: "0.01".to_string(),
3669 adl: "0".to_string(),
3670 c_time: "1622559930237".to_string(),
3671 realized_pnl: "0".to_string(),
3672 upl_last_px: "0".to_string(),
3673 upl_ratio_last_px: "0".to_string(),
3674 avail_pos: "1.5".to_string(),
3675 be_px: "0".to_string(),
3676 funding_fee: "0".to_string(),
3677 idx_px: "0".to_string(),
3678 liq_penalty: "0".to_string(),
3679 opt_val: "0".to_string(),
3680 pending_close_ord_liab_val: "0".to_string(),
3681 pnl: "0".to_string(),
3682 pos_ccy: "BTC".to_string(),
3683 quote_bal: "75000".to_string(),
3684 quote_borrowed: "0".to_string(),
3685 quote_interest: "0".to_string(),
3686 spot_in_use_amt: "0".to_string(),
3687 spot_in_use_ccy: "BTC".to_string(),
3688 usd_px: "50000".to_string(),
3689 delta_bs: String::new(),
3690 gamma_bs: String::new(),
3691 theta_bs: String::new(),
3692 vega_bs: String::new(),
3693 };
3694
3695 let account_id = AccountId::new("OKX-001");
3696 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3697 let report = parse_position_status_report(
3698 &position,
3699 account_id,
3700 instrument_id,
3701 8,
3702 UnixNanos::default(),
3703 )
3704 .unwrap();
3705
3706 assert_eq!(report.account_id, account_id);
3707 assert_eq!(report.instrument_id, instrument_id);
3708 assert_eq!(report.position_side, PositionSide::Long.as_specified());
3709 assert_eq!(report.quantity, Quantity::from("1.5"));
3710 assert_eq!(report.venue_position_id, None);
3712 }
3713
3714 #[rstest]
3715 fn test_parse_position_status_report_net_mode_short() {
3716 let position = OKXPosition {
3718 inst_id: Ustr::from("BTC-USDT-SWAP"),
3719 inst_type: OKXInstrumentType::Swap,
3720 mgn_mode: OKXMarginMode::Isolated,
3721 pos_id: Some(Ustr::from("67890")),
3722 pos_side: OKXPositionSide::Net, pos: "-2.3".to_string(), base_bal: "2.3".to_string(),
3725 ccy: "BTC".to_string(),
3726 fee: "0.02".to_string(),
3727 lever: "5.0".to_string(),
3728 last: "50000".to_string(),
3729 mark_px: "50000".to_string(),
3730 liq_px: "55000".to_string(),
3731 mmr: "0.2".to_string(),
3732 interest: "0".to_string(),
3733 trade_id: Ustr::from("222"),
3734 notional_usd: "115000".to_string(),
3735 avg_px: "50000".to_string(),
3736 upl: "0".to_string(),
3737 upl_ratio: "0".to_string(),
3738 u_time: 1622559930237,
3739 margin: "1.0".to_string(),
3740 mgn_ratio: "0.02".to_string(),
3741 adl: "0".to_string(),
3742 c_time: "1622559930237".to_string(),
3743 realized_pnl: "0".to_string(),
3744 upl_last_px: "0".to_string(),
3745 upl_ratio_last_px: "0".to_string(),
3746 avail_pos: "2.3".to_string(),
3747 be_px: "0".to_string(),
3748 funding_fee: "0".to_string(),
3749 idx_px: "0".to_string(),
3750 liq_penalty: "0".to_string(),
3751 opt_val: "0".to_string(),
3752 pending_close_ord_liab_val: "0".to_string(),
3753 pnl: "0".to_string(),
3754 pos_ccy: "BTC".to_string(),
3755 quote_bal: "115000".to_string(),
3756 quote_borrowed: "0".to_string(),
3757 quote_interest: "0".to_string(),
3758 spot_in_use_amt: "0".to_string(),
3759 spot_in_use_ccy: "BTC".to_string(),
3760 usd_px: "50000".to_string(),
3761 delta_bs: String::new(),
3762 gamma_bs: String::new(),
3763 theta_bs: String::new(),
3764 vega_bs: String::new(),
3765 };
3766
3767 let account_id = AccountId::new("OKX-001");
3768 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3769 let report = parse_position_status_report(
3770 &position,
3771 account_id,
3772 instrument_id,
3773 8,
3774 UnixNanos::default(),
3775 )
3776 .unwrap();
3777
3778 assert_eq!(report.account_id, account_id);
3779 assert_eq!(report.instrument_id, instrument_id);
3780 assert_eq!(report.position_side, PositionSide::Short.as_specified());
3781 assert_eq!(report.quantity, Quantity::from("2.3")); assert_eq!(report.venue_position_id, None);
3784 }
3785
3786 #[rstest]
3787 fn test_parse_position_status_report_net_mode_flat() {
3788 let position = OKXPosition {
3790 inst_id: Ustr::from("ETH-USDT-SWAP"),
3791 inst_type: OKXInstrumentType::Swap,
3792 mgn_mode: OKXMarginMode::Cross,
3793 pos_id: Some(Ustr::from("99999")),
3794 pos_side: OKXPositionSide::Net, pos: "0".to_string(), base_bal: "0".to_string(),
3797 ccy: "ETH".to_string(),
3798 fee: "0".to_string(),
3799 lever: "10.0".to_string(),
3800 last: "3000".to_string(),
3801 mark_px: "3000".to_string(),
3802 liq_px: "0".to_string(),
3803 mmr: "0".to_string(),
3804 interest: "0".to_string(),
3805 trade_id: Ustr::from("333"),
3806 notional_usd: "0".to_string(),
3807 avg_px: String::new(),
3808 upl: "0".to_string(),
3809 upl_ratio: "0".to_string(),
3810 u_time: 1622559930237,
3811 margin: "0".to_string(),
3812 mgn_ratio: "0".to_string(),
3813 adl: "0".to_string(),
3814 c_time: "1622559930237".to_string(),
3815 realized_pnl: "0".to_string(),
3816 upl_last_px: "0".to_string(),
3817 upl_ratio_last_px: "0".to_string(),
3818 avail_pos: "0".to_string(),
3819 be_px: "0".to_string(),
3820 funding_fee: "0".to_string(),
3821 idx_px: "0".to_string(),
3822 liq_penalty: "0".to_string(),
3823 opt_val: "0".to_string(),
3824 pending_close_ord_liab_val: "0".to_string(),
3825 pnl: "0".to_string(),
3826 pos_ccy: "ETH".to_string(),
3827 quote_bal: "0".to_string(),
3828 quote_borrowed: "0".to_string(),
3829 quote_interest: "0".to_string(),
3830 spot_in_use_amt: "0".to_string(),
3831 spot_in_use_ccy: "ETH".to_string(),
3832 usd_px: "3000".to_string(),
3833 delta_bs: String::new(),
3834 gamma_bs: String::new(),
3835 theta_bs: String::new(),
3836 vega_bs: String::new(),
3837 };
3838
3839 let account_id = AccountId::new("OKX-001");
3840 let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
3841 let report = parse_position_status_report(
3842 &position,
3843 account_id,
3844 instrument_id,
3845 8,
3846 UnixNanos::default(),
3847 )
3848 .unwrap();
3849
3850 assert_eq!(report.account_id, account_id);
3851 assert_eq!(report.instrument_id, instrument_id);
3852 assert_eq!(report.position_side, PositionSide::Flat.as_specified());
3853 assert_eq!(report.quantity, Quantity::from("0"));
3854 assert_eq!(report.venue_position_id, None);
3856 }
3857
3858 #[rstest]
3859 fn test_parse_position_status_report_long_short_mode_long() {
3860 let position = OKXPosition {
3862 inst_id: Ustr::from("BTC-USDT-SWAP"),
3863 inst_type: OKXInstrumentType::Swap,
3864 mgn_mode: OKXMarginMode::Cross,
3865 pos_id: Some(Ustr::from("11111")),
3866 pos_side: OKXPositionSide::Long, pos: "3.2".to_string(), base_bal: "3.2".to_string(),
3869 ccy: "BTC".to_string(),
3870 fee: "0.01".to_string(),
3871 lever: "10.0".to_string(),
3872 last: "50000".to_string(),
3873 mark_px: "50000".to_string(),
3874 liq_px: "45000".to_string(),
3875 mmr: "0.1".to_string(),
3876 interest: "0".to_string(),
3877 trade_id: Ustr::from("444"),
3878 notional_usd: "160000".to_string(),
3879 avg_px: "50000".to_string(),
3880 upl: "0".to_string(),
3881 upl_ratio: "0".to_string(),
3882 u_time: 1622559930237,
3883 margin: "1.6".to_string(),
3884 mgn_ratio: "0.01".to_string(),
3885 adl: "0".to_string(),
3886 c_time: "1622559930237".to_string(),
3887 realized_pnl: "0".to_string(),
3888 upl_last_px: "0".to_string(),
3889 upl_ratio_last_px: "0".to_string(),
3890 avail_pos: "3.2".to_string(),
3891 be_px: "0".to_string(),
3892 funding_fee: "0".to_string(),
3893 idx_px: "0".to_string(),
3894 liq_penalty: "0".to_string(),
3895 opt_val: "0".to_string(),
3896 pending_close_ord_liab_val: "0".to_string(),
3897 pnl: "0".to_string(),
3898 pos_ccy: "BTC".to_string(),
3899 quote_bal: "160000".to_string(),
3900 quote_borrowed: "0".to_string(),
3901 quote_interest: "0".to_string(),
3902 spot_in_use_amt: "0".to_string(),
3903 spot_in_use_ccy: "BTC".to_string(),
3904 usd_px: "50000".to_string(),
3905 delta_bs: String::new(),
3906 gamma_bs: String::new(),
3907 theta_bs: String::new(),
3908 vega_bs: String::new(),
3909 };
3910
3911 let account_id = AccountId::new("OKX-001");
3912 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3913 let report = parse_position_status_report(
3914 &position,
3915 account_id,
3916 instrument_id,
3917 8,
3918 UnixNanos::default(),
3919 )
3920 .unwrap();
3921
3922 assert_eq!(report.account_id, account_id);
3923 assert_eq!(report.instrument_id, instrument_id);
3924 assert_eq!(report.position_side, PositionSide::Long.as_specified());
3925 assert_eq!(report.quantity, Quantity::from("3.2"));
3926 assert_eq!(
3928 report.venue_position_id,
3929 Some(PositionId::new("11111-LONG"))
3930 );
3931 }
3932
3933 #[rstest]
3934 fn test_parse_position_status_report_long_short_mode_short() {
3935 let position = OKXPosition {
3938 inst_id: Ustr::from("BTC-USDT-SWAP"),
3939 inst_type: OKXInstrumentType::Swap,
3940 mgn_mode: OKXMarginMode::Cross,
3941 pos_id: Some(Ustr::from("22222")),
3942 pos_side: OKXPositionSide::Short, pos: "1.8".to_string(), base_bal: "1.8".to_string(),
3945 ccy: "BTC".to_string(),
3946 fee: "0.02".to_string(),
3947 lever: "10.0".to_string(),
3948 last: "50000".to_string(),
3949 mark_px: "50000".to_string(),
3950 liq_px: "55000".to_string(),
3951 mmr: "0.2".to_string(),
3952 interest: "0".to_string(),
3953 trade_id: Ustr::from("555"),
3954 notional_usd: "90000".to_string(),
3955 avg_px: "50000".to_string(),
3956 upl: "0".to_string(),
3957 upl_ratio: "0".to_string(),
3958 u_time: 1622559930237,
3959 margin: "0.9".to_string(),
3960 mgn_ratio: "0.02".to_string(),
3961 adl: "0".to_string(),
3962 c_time: "1622559930237".to_string(),
3963 realized_pnl: "0".to_string(),
3964 upl_last_px: "0".to_string(),
3965 upl_ratio_last_px: "0".to_string(),
3966 avail_pos: "1.8".to_string(),
3967 be_px: "0".to_string(),
3968 funding_fee: "0".to_string(),
3969 idx_px: "0".to_string(),
3970 liq_penalty: "0".to_string(),
3971 opt_val: "0".to_string(),
3972 pending_close_ord_liab_val: "0".to_string(),
3973 pnl: "0".to_string(),
3974 pos_ccy: "BTC".to_string(),
3975 quote_bal: "90000".to_string(),
3976 quote_borrowed: "0".to_string(),
3977 quote_interest: "0".to_string(),
3978 spot_in_use_amt: "0".to_string(),
3979 spot_in_use_ccy: "BTC".to_string(),
3980 usd_px: "50000".to_string(),
3981 delta_bs: String::new(),
3982 gamma_bs: String::new(),
3983 theta_bs: String::new(),
3984 vega_bs: String::new(),
3985 };
3986
3987 let account_id = AccountId::new("OKX-001");
3988 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3989 let report = parse_position_status_report(
3990 &position,
3991 account_id,
3992 instrument_id,
3993 8,
3994 UnixNanos::default(),
3995 )
3996 .unwrap();
3997
3998 assert_eq!(report.account_id, account_id);
3999 assert_eq!(report.instrument_id, instrument_id);
4000 assert_eq!(report.position_side, PositionSide::Short.as_specified());
4002 assert_eq!(report.quantity, Quantity::from("1.8"));
4003 assert_eq!(
4005 report.venue_position_id,
4006 Some(PositionId::new("22222-SHORT"))
4007 );
4008 }
4009
4010 #[rstest]
4011 fn test_parse_position_status_report_margin_long() {
4012 let position = OKXPosition {
4014 inst_id: Ustr::from("ETH-USDT"),
4015 inst_type: OKXInstrumentType::Margin,
4016 mgn_mode: OKXMarginMode::Cross,
4017 pos_id: Some(Ustr::from("margin-long-1")),
4018 pos_side: OKXPositionSide::Net,
4019 pos: "1.5".to_string(), base_bal: "1.5".to_string(),
4021 ccy: "ETH".to_string(),
4022 fee: "0".to_string(),
4023 lever: "3".to_string(),
4024 last: "4000".to_string(),
4025 mark_px: "4000".to_string(),
4026 liq_px: "3500".to_string(),
4027 mmr: "0.1".to_string(),
4028 interest: "0".to_string(),
4029 trade_id: Ustr::from("trade1"),
4030 notional_usd: "6000".to_string(),
4031 avg_px: "3800".to_string(), upl: "300".to_string(),
4033 upl_ratio: "0.05".to_string(),
4034 u_time: 1622559930237,
4035 margin: "2000".to_string(),
4036 mgn_ratio: "0.33".to_string(),
4037 adl: "0".to_string(),
4038 c_time: "1622559930237".to_string(),
4039 realized_pnl: "0".to_string(),
4040 upl_last_px: "300".to_string(),
4041 upl_ratio_last_px: "0.05".to_string(),
4042 avail_pos: "1.5".to_string(),
4043 be_px: "3800".to_string(),
4044 funding_fee: "0".to_string(),
4045 idx_px: "4000".to_string(),
4046 liq_penalty: "0".to_string(),
4047 opt_val: "0".to_string(),
4048 pending_close_ord_liab_val: "0".to_string(),
4049 pnl: "300".to_string(),
4050 pos_ccy: "ETH".to_string(), quote_bal: "0".to_string(),
4052 quote_borrowed: "0".to_string(),
4053 quote_interest: "0".to_string(),
4054 spot_in_use_amt: "0".to_string(),
4055 spot_in_use_ccy: String::new(),
4056 usd_px: "4000".to_string(),
4057 delta_bs: String::new(),
4058 gamma_bs: String::new(),
4059 theta_bs: String::new(),
4060 vega_bs: String::new(),
4061 };
4062
4063 let account_id = AccountId::new("OKX-001");
4064 let instrument_id = InstrumentId::from("ETH-USDT.OKX");
4065 let report = parse_position_status_report(
4066 &position,
4067 account_id,
4068 instrument_id,
4069 4,
4070 UnixNanos::default(),
4071 )
4072 .unwrap();
4073
4074 assert_eq!(report.account_id, account_id);
4075 assert_eq!(report.instrument_id, instrument_id);
4076 assert_eq!(report.position_side, PositionSide::Long.as_specified());
4077 assert_eq!(report.quantity, Quantity::from("1.5")); assert_eq!(report.venue_position_id, None); }
4080
4081 #[rstest]
4082 fn test_parse_position_status_report_margin_short() {
4083 let position = OKXPosition {
4086 inst_id: Ustr::from("ETH-USDT"),
4087 inst_type: OKXInstrumentType::Margin,
4088 mgn_mode: OKXMarginMode::Cross,
4089 pos_id: Some(Ustr::from("margin-short-1")),
4090 pos_side: OKXPositionSide::Net,
4091 pos: "244.56".to_string(), base_bal: "0".to_string(),
4093 ccy: "USDT".to_string(),
4094 fee: "0".to_string(),
4095 lever: "3".to_string(),
4096 last: "4092".to_string(),
4097 mark_px: "4092".to_string(),
4098 liq_px: "4500".to_string(),
4099 mmr: "0.1".to_string(),
4100 interest: "0".to_string(),
4101 trade_id: Ustr::from("trade2"),
4102 notional_usd: "244.56".to_string(),
4103 avg_px: "4092".to_string(), upl: "-10".to_string(),
4105 upl_ratio: "-0.04".to_string(),
4106 u_time: 1622559930237,
4107 margin: "100".to_string(),
4108 mgn_ratio: "0.4".to_string(),
4109 adl: "0".to_string(),
4110 c_time: "1622559930237".to_string(),
4111 realized_pnl: "0".to_string(),
4112 upl_last_px: "-10".to_string(),
4113 upl_ratio_last_px: "-0.04".to_string(),
4114 avail_pos: "244.56".to_string(),
4115 be_px: "4092".to_string(),
4116 funding_fee: "0".to_string(),
4117 idx_px: "4092".to_string(),
4118 liq_penalty: "0".to_string(),
4119 opt_val: "0".to_string(),
4120 pending_close_ord_liab_val: "0".to_string(),
4121 pnl: "-10".to_string(),
4122 pos_ccy: "USDT".to_string(), quote_bal: "244.56".to_string(),
4124 quote_borrowed: "0".to_string(),
4125 quote_interest: "0".to_string(),
4126 spot_in_use_amt: "0".to_string(),
4127 spot_in_use_ccy: String::new(),
4128 usd_px: "4092".to_string(),
4129 delta_bs: String::new(),
4130 gamma_bs: String::new(),
4131 theta_bs: String::new(),
4132 vega_bs: String::new(),
4133 };
4134
4135 let account_id = AccountId::new("OKX-001");
4136 let instrument_id = InstrumentId::from("ETH-USDT.OKX");
4137 let report = parse_position_status_report(
4138 &position,
4139 account_id,
4140 instrument_id,
4141 4,
4142 UnixNanos::default(),
4143 )
4144 .unwrap();
4145
4146 assert_eq!(report.account_id, account_id);
4147 assert_eq!(report.instrument_id, instrument_id);
4148 assert_eq!(report.position_side, PositionSide::Short.as_specified());
4149 assert_eq!(report.quantity.to_string(), "0.0598");
4151 assert_eq!(report.venue_position_id, None); }
4153
4154 #[rstest]
4155 fn test_parse_position_status_report_margin_short_rounds_to_size_precision() {
4156 let position = OKXPosition {
4159 inst_id: Ustr::from("ETH-USDT"),
4160 inst_type: OKXInstrumentType::Margin,
4161 mgn_mode: OKXMarginMode::Cross,
4162 pos_id: Some(Ustr::from("margin-short-2")),
4163 pos_side: OKXPositionSide::Net,
4164 pos: "100.00".to_string(),
4165 base_bal: "0".to_string(),
4166 ccy: "USDT".to_string(),
4167 fee: "0".to_string(),
4168 lever: "3".to_string(),
4169 last: "3333.33".to_string(),
4170 mark_px: "3333.33".to_string(),
4171 liq_px: "3500".to_string(),
4172 mmr: "0.1".to_string(),
4173 interest: "0".to_string(),
4174 trade_id: Ustr::from("trade-round"),
4175 notional_usd: "100.00".to_string(),
4176 avg_px: "3333.33".to_string(),
4177 upl: "0".to_string(),
4178 upl_ratio: "0".to_string(),
4179 u_time: 1622559930237,
4180 margin: "50".to_string(),
4181 mgn_ratio: "0.5".to_string(),
4182 adl: "0".to_string(),
4183 c_time: "1622559930237".to_string(),
4184 realized_pnl: "0".to_string(),
4185 upl_last_px: "0".to_string(),
4186 upl_ratio_last_px: "0".to_string(),
4187 avail_pos: "100.00".to_string(),
4188 be_px: "3333.33".to_string(),
4189 funding_fee: "0".to_string(),
4190 idx_px: "3333.33".to_string(),
4191 liq_penalty: "0".to_string(),
4192 opt_val: "0".to_string(),
4193 pending_close_ord_liab_val: "0".to_string(),
4194 pnl: "0".to_string(),
4195 pos_ccy: "USDT".to_string(),
4196 quote_bal: "100.00".to_string(),
4197 quote_borrowed: "0".to_string(),
4198 quote_interest: "0".to_string(),
4199 spot_in_use_amt: "0".to_string(),
4200 spot_in_use_ccy: String::new(),
4201 usd_px: "3333.33".to_string(),
4202 delta_bs: String::new(),
4203 gamma_bs: String::new(),
4204 theta_bs: String::new(),
4205 vega_bs: String::new(),
4206 };
4207
4208 let report = parse_position_status_report(
4209 &position,
4210 AccountId::new("OKX-001"),
4211 InstrumentId::from("ETH-USDT.OKX"),
4212 4, UnixNanos::default(),
4214 )
4215 .unwrap();
4216
4217 assert_eq!(report.position_side, PositionSide::Short.as_specified());
4218 assert_eq!(report.quantity.to_string(), "0.0300");
4219 }
4220
4221 #[rstest]
4222 fn test_parse_rfc3339_timestamp_rejects_pre_epoch() {
4223 let result = parse_rfc3339_timestamp("1960-01-01T00:00:00Z");
4224 assert!(result.is_err());
4225 assert!(
4226 result
4227 .unwrap_err()
4228 .to_string()
4229 .contains("Negative nanosecond timestamp")
4230 );
4231 }
4232
4233 #[rstest]
4234 fn test_parse_position_status_report_margin_flat() {
4235 let position = OKXPosition {
4237 inst_id: Ustr::from("ETH-USDT"),
4238 inst_type: OKXInstrumentType::Margin,
4239 mgn_mode: OKXMarginMode::Cross,
4240 pos_id: Some(Ustr::from("margin-flat-1")),
4241 pos_side: OKXPositionSide::Net,
4242 pos: "0".to_string(),
4243 base_bal: "0".to_string(),
4244 ccy: "ETH".to_string(),
4245 fee: "0".to_string(),
4246 lever: "0".to_string(),
4247 last: "4000".to_string(),
4248 mark_px: "4000".to_string(),
4249 liq_px: "0".to_string(),
4250 mmr: "0".to_string(),
4251 interest: "0".to_string(),
4252 trade_id: Ustr::from(""),
4253 notional_usd: "0".to_string(),
4254 avg_px: String::new(),
4255 upl: "0".to_string(),
4256 upl_ratio: "0".to_string(),
4257 u_time: 1622559930237,
4258 margin: "0".to_string(),
4259 mgn_ratio: "0".to_string(),
4260 adl: "0".to_string(),
4261 c_time: "1622559930237".to_string(),
4262 realized_pnl: "0".to_string(),
4263 upl_last_px: "0".to_string(),
4264 upl_ratio_last_px: "0".to_string(),
4265 avail_pos: "0".to_string(),
4266 be_px: "0".to_string(),
4267 funding_fee: "0".to_string(),
4268 idx_px: "0".to_string(),
4269 liq_penalty: "0".to_string(),
4270 opt_val: "0".to_string(),
4271 pending_close_ord_liab_val: "0".to_string(),
4272 pnl: "0".to_string(),
4273 pos_ccy: String::new(), quote_bal: "0".to_string(),
4275 quote_borrowed: "0".to_string(),
4276 quote_interest: "0".to_string(),
4277 spot_in_use_amt: "0".to_string(),
4278 spot_in_use_ccy: String::new(),
4279 usd_px: "0".to_string(),
4280 delta_bs: String::new(),
4281 gamma_bs: String::new(),
4282 theta_bs: String::new(),
4283 vega_bs: String::new(),
4284 };
4285
4286 let account_id = AccountId::new("OKX-001");
4287 let instrument_id = InstrumentId::from("ETH-USDT.OKX");
4288 let report = parse_position_status_report(
4289 &position,
4290 account_id,
4291 instrument_id,
4292 4,
4293 UnixNanos::default(),
4294 )
4295 .unwrap();
4296
4297 assert_eq!(report.account_id, account_id);
4298 assert_eq!(report.instrument_id, instrument_id);
4299 assert_eq!(report.position_side, PositionSide::Flat.as_specified());
4300 assert_eq!(report.quantity, Quantity::from("0"));
4301 assert_eq!(report.venue_position_id, None); }
4303
4304 #[rstest]
4305 fn test_parse_swap_instrument_empty_underlying_returns_error() {
4306 let instrument = OKXInstrument {
4307 inst_type: OKXInstrumentType::Swap,
4308 inst_id: Ustr::from("ETH-USD_UM-SWAP"),
4309 uly: Ustr::from(""), inst_family: Ustr::from(""),
4311 base_ccy: Ustr::from(""),
4312 quote_ccy: Ustr::from(""),
4313 settle_ccy: Ustr::from("USD"),
4314 ct_val: "1".to_string(),
4315 ct_mult: "1".to_string(),
4316 ct_val_ccy: "USD".to_string(),
4317 opt_type: crate::common::enums::OKXOptionType::None,
4318 stk: String::new(),
4319 list_time: None,
4320 exp_time: None,
4321 lever: String::new(),
4322 tick_sz: "0.1".to_string(),
4323 lot_sz: "1".to_string(),
4324 min_sz: "1".to_string(),
4325 ct_type: OKXContractType::Linear,
4326 state: crate::common::enums::OKXInstrumentStatus::Preopen,
4327 rule_type: String::new(),
4328 max_lmt_sz: String::new(),
4329 max_mkt_sz: String::new(),
4330 max_lmt_amt: String::new(),
4331 max_mkt_amt: String::new(),
4332 max_twap_sz: String::new(),
4333 max_iceberg_sz: String::new(),
4334 max_trigger_sz: String::new(),
4335 max_stop_sz: String::new(),
4336 inst_id_code: None,
4337 };
4338
4339 let result =
4340 parse_swap_instrument(&instrument, None, None, None, None, UnixNanos::default());
4341 assert!(result.is_err());
4342 assert!(result.unwrap_err().to_string().contains("Empty underlying"));
4343 }
4344
4345 #[rstest]
4346 fn test_parse_futures_instrument_empty_underlying_returns_error() {
4347 let instrument = OKXInstrument {
4348 inst_type: OKXInstrumentType::Futures,
4349 inst_id: Ustr::from("ETH-USD_UM-250328"),
4350 uly: Ustr::from(""), inst_family: Ustr::from(""),
4352 base_ccy: Ustr::from(""),
4353 quote_ccy: Ustr::from(""),
4354 settle_ccy: Ustr::from("USD"),
4355 ct_val: "1".to_string(),
4356 ct_mult: "1".to_string(),
4357 ct_val_ccy: "USD".to_string(),
4358 opt_type: crate::common::enums::OKXOptionType::None,
4359 stk: String::new(),
4360 list_time: None,
4361 exp_time: Some(1743004800000),
4362 lever: String::new(),
4363 tick_sz: "0.1".to_string(),
4364 lot_sz: "1".to_string(),
4365 min_sz: "1".to_string(),
4366 ct_type: OKXContractType::Linear,
4367 state: crate::common::enums::OKXInstrumentStatus::Preopen,
4368 rule_type: String::new(),
4369 max_lmt_sz: String::new(),
4370 max_mkt_sz: String::new(),
4371 max_lmt_amt: String::new(),
4372 max_mkt_amt: String::new(),
4373 max_twap_sz: String::new(),
4374 max_iceberg_sz: String::new(),
4375 max_trigger_sz: String::new(),
4376 max_stop_sz: String::new(),
4377 inst_id_code: None,
4378 };
4379
4380 let result =
4381 parse_futures_instrument(&instrument, None, None, None, None, UnixNanos::default());
4382 assert!(result.is_err());
4383 assert!(result.unwrap_err().to_string().contains("Empty underlying"));
4384 }
4385
4386 #[rstest]
4387 fn test_parse_option_instrument_empty_opt_type_returns_error() {
4388 let instrument = OKXInstrument {
4389 inst_type: OKXInstrumentType::Option,
4390 inst_id: Ustr::from("BTC-USD-250328-50000-C"),
4391 uly: Ustr::from("BTC-USD"),
4392 inst_family: Ustr::from("BTC-USD"),
4393 base_ccy: Ustr::from(""),
4394 quote_ccy: Ustr::from(""),
4395 settle_ccy: Ustr::from("USD"),
4396 ct_val: "0.01".to_string(),
4397 ct_mult: "1".to_string(),
4398 ct_val_ccy: "BTC".to_string(),
4399 opt_type: crate::common::enums::OKXOptionType::None,
4402 stk: "50000".to_string(),
4403 list_time: None,
4404 exp_time: Some(1743004800000),
4405 lever: String::new(),
4406 tick_sz: "0.0005".to_string(),
4407 lot_sz: "0.1".to_string(),
4408 min_sz: "0.1".to_string(),
4409 ct_type: OKXContractType::Linear,
4410 state: crate::common::enums::OKXInstrumentStatus::Preopen,
4411 rule_type: String::new(),
4412 max_lmt_sz: String::new(),
4413 max_mkt_sz: String::new(),
4414 max_lmt_amt: String::new(),
4415 max_mkt_amt: String::new(),
4416 max_twap_sz: String::new(),
4417 max_iceberg_sz: String::new(),
4418 max_trigger_sz: String::new(),
4419 max_stop_sz: String::new(),
4420 inst_id_code: None,
4421 };
4422
4423 let result =
4424 parse_option_instrument(&instrument, None, None, None, None, UnixNanos::default());
4425 assert!(result.is_err());
4426 let err_msg = result.unwrap_err().to_string();
4427 assert!(
4428 err_msg.contains("Unsupported") && err_msg.contains("optType"),
4429 "expected Unsupported optType error, was: {err_msg}"
4430 );
4431 }
4432
4433 #[rstest]
4434 fn test_parse_option_instrument_empty_underlying_returns_error() {
4435 let instrument = OKXInstrument {
4436 inst_type: OKXInstrumentType::Option,
4437 inst_id: Ustr::from("BTC-USD-250328-50000-C"),
4438 uly: Ustr::from(""), inst_family: Ustr::from(""),
4440 base_ccy: Ustr::from(""),
4441 quote_ccy: Ustr::from(""),
4442 settle_ccy: Ustr::from("USD"),
4443 ct_val: "0.01".to_string(),
4444 ct_mult: "1".to_string(),
4445 ct_val_ccy: "BTC".to_string(),
4446 opt_type: crate::common::enums::OKXOptionType::Call,
4447 stk: "50000".to_string(),
4448 list_time: None,
4449 exp_time: Some(1743004800000),
4450 lever: String::new(),
4451 tick_sz: "0.0005".to_string(),
4452 lot_sz: "0.1".to_string(),
4453 min_sz: "0.1".to_string(),
4454 ct_type: OKXContractType::Linear,
4455 state: crate::common::enums::OKXInstrumentStatus::Preopen,
4456 rule_type: String::new(),
4457 max_lmt_sz: String::new(),
4458 max_mkt_sz: String::new(),
4459 max_lmt_amt: String::new(),
4460 max_mkt_amt: String::new(),
4461 max_twap_sz: String::new(),
4462 max_iceberg_sz: String::new(),
4463 max_trigger_sz: String::new(),
4464 max_stop_sz: String::new(),
4465 inst_id_code: None,
4466 };
4467
4468 let result =
4469 parse_option_instrument(&instrument, None, None, None, None, UnixNanos::default());
4470 assert!(result.is_err());
4471 assert!(result.unwrap_err().to_string().contains("Empty underlying"));
4472 }
4473
4474 #[rstest]
4475 fn test_parse_spot_margin_position_from_balance_short_usdt() {
4476 let balance = OKXBalanceDetail {
4477 ccy: Ustr::from("ENA"),
4478 liab: "130047.3610487126".to_string(),
4479 spot_in_use_amt: "-129950".to_string(),
4480 cross_liab: "130047.3610487126".to_string(),
4481 eq: "-130047.3610487126".to_string(),
4482 u_time: 1704067200000,
4483 avail_bal: "0".to_string(),
4484 avail_eq: "0".to_string(),
4485 borrow_froz: "0".to_string(),
4486 cash_bal: "0".to_string(),
4487 dis_eq: "0".to_string(),
4488 eq_usd: "0".to_string(),
4489 smt_sync_eq: "0".to_string(),
4490 spot_copy_trading_eq: "0".to_string(),
4491 fixed_bal: "0".to_string(),
4492 frozen_bal: "0".to_string(),
4493 imr: "0".to_string(),
4494 interest: "0".to_string(),
4495 iso_eq: "0".to_string(),
4496 iso_liab: "0".to_string(),
4497 iso_upl: "0".to_string(),
4498 max_loan: "0".to_string(),
4499 mgn_ratio: "0".to_string(),
4500 mmr: "0".to_string(),
4501 notional_lever: "0".to_string(),
4502 ord_frozen: "0".to_string(),
4503 reward_bal: "0".to_string(),
4504 cl_spot_in_use_amt: "0".to_string(),
4505 max_spot_in_use_amt: "0".to_string(),
4506 spot_iso_bal: "0".to_string(),
4507 stgy_eq: "0".to_string(),
4508 twap: "0".to_string(),
4509 upl: "0".to_string(),
4510 upl_liab: "0".to_string(),
4511 spot_bal: "0".to_string(),
4512 open_avg_px: "0".to_string(),
4513 acc_avg_px: "0".to_string(),
4514 spot_upl: "0".to_string(),
4515 spot_upl_ratio: "0".to_string(),
4516 total_pnl: "0".to_string(),
4517 total_pnl_ratio: "0".to_string(),
4518 };
4519
4520 let account_id = AccountId::new("OKX-001");
4521 let size_precision = 2;
4522 let ts_init = UnixNanos::default();
4523
4524 let result = parse_spot_margin_position_from_balance(
4525 &balance,
4526 account_id,
4527 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4528 size_precision,
4529 ts_init,
4530 )
4531 .unwrap();
4532
4533 assert!(result.is_some());
4534 let report = result.unwrap();
4535 assert_eq!(report.account_id, account_id);
4536 assert_eq!(report.instrument_id.to_string(), "ENA-USDT.OKX".to_string());
4537 assert_eq!(report.position_side, PositionSide::Short.as_specified());
4538 assert_eq!(report.quantity.to_string(), "129950.00");
4539 }
4540
4541 #[rstest]
4542 fn test_parse_spot_margin_position_from_balance_long() {
4543 let balance = OKXBalanceDetail {
4544 ccy: Ustr::from("BTC"),
4545 liab: "1.5".to_string(),
4546 spot_in_use_amt: "1.2".to_string(),
4547 cross_liab: "1.5".to_string(),
4548 eq: "1.2".to_string(),
4549 u_time: 1704067200000,
4550 avail_bal: "0".to_string(),
4551 avail_eq: "0".to_string(),
4552 borrow_froz: "0".to_string(),
4553 cash_bal: "0".to_string(),
4554 dis_eq: "0".to_string(),
4555 eq_usd: "0".to_string(),
4556 smt_sync_eq: "0".to_string(),
4557 spot_copy_trading_eq: "0".to_string(),
4558 fixed_bal: "0".to_string(),
4559 frozen_bal: "0".to_string(),
4560 imr: "0".to_string(),
4561 interest: "0".to_string(),
4562 iso_eq: "0".to_string(),
4563 iso_liab: "0".to_string(),
4564 iso_upl: "0".to_string(),
4565 max_loan: "0".to_string(),
4566 mgn_ratio: "0".to_string(),
4567 mmr: "0".to_string(),
4568 notional_lever: "0".to_string(),
4569 ord_frozen: "0".to_string(),
4570 reward_bal: "0".to_string(),
4571 cl_spot_in_use_amt: "0".to_string(),
4572 max_spot_in_use_amt: "0".to_string(),
4573 spot_iso_bal: "0".to_string(),
4574 stgy_eq: "0".to_string(),
4575 twap: "0".to_string(),
4576 upl: "0".to_string(),
4577 upl_liab: "0".to_string(),
4578 spot_bal: "0".to_string(),
4579 open_avg_px: "0".to_string(),
4580 acc_avg_px: "0".to_string(),
4581 spot_upl: "0".to_string(),
4582 spot_upl_ratio: "0".to_string(),
4583 total_pnl: "0".to_string(),
4584 total_pnl_ratio: "0".to_string(),
4585 };
4586
4587 let account_id = AccountId::new("OKX-001");
4588 let size_precision = 8;
4589 let ts_init = UnixNanos::default();
4590
4591 let result = parse_spot_margin_position_from_balance(
4592 &balance,
4593 account_id,
4594 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4595 size_precision,
4596 ts_init,
4597 )
4598 .unwrap();
4599
4600 assert!(result.is_some());
4601 let report = result.unwrap();
4602 assert_eq!(report.position_side, PositionSide::Long.as_specified());
4603 assert_eq!(report.quantity.to_string(), "1.20000000");
4604 }
4605
4606 #[rstest]
4607 fn test_parse_spot_margin_position_from_balance_usdc_quote() {
4608 let balance = OKXBalanceDetail {
4609 ccy: Ustr::from("ETH"),
4610 liab: "10.5".to_string(),
4611 spot_in_use_amt: "-10.0".to_string(),
4612 cross_liab: "10.5".to_string(),
4613 eq: "-10.0".to_string(),
4614 u_time: 1704067200000,
4615 avail_bal: "0".to_string(),
4616 avail_eq: "0".to_string(),
4617 borrow_froz: "0".to_string(),
4618 cash_bal: "0".to_string(),
4619 dis_eq: "0".to_string(),
4620 eq_usd: "0".to_string(),
4621 smt_sync_eq: "0".to_string(),
4622 spot_copy_trading_eq: "0".to_string(),
4623 fixed_bal: "0".to_string(),
4624 frozen_bal: "0".to_string(),
4625 imr: "0".to_string(),
4626 interest: "0".to_string(),
4627 iso_eq: "0".to_string(),
4628 iso_liab: "0".to_string(),
4629 iso_upl: "0".to_string(),
4630 max_loan: "0".to_string(),
4631 mgn_ratio: "0".to_string(),
4632 mmr: "0".to_string(),
4633 notional_lever: "0".to_string(),
4634 ord_frozen: "0".to_string(),
4635 reward_bal: "0".to_string(),
4636 cl_spot_in_use_amt: "0".to_string(),
4637 max_spot_in_use_amt: "0".to_string(),
4638 spot_iso_bal: "0".to_string(),
4639 stgy_eq: "0".to_string(),
4640 twap: "0".to_string(),
4641 upl: "0".to_string(),
4642 upl_liab: "0".to_string(),
4643 spot_bal: "0".to_string(),
4644 open_avg_px: "0".to_string(),
4645 acc_avg_px: "0".to_string(),
4646 spot_upl: "0".to_string(),
4647 spot_upl_ratio: "0".to_string(),
4648 total_pnl: "0".to_string(),
4649 total_pnl_ratio: "0".to_string(),
4650 };
4651
4652 let account_id = AccountId::new("OKX-001");
4653 let size_precision = 6;
4654 let ts_init = UnixNanos::default();
4655
4656 let result = parse_spot_margin_position_from_balance(
4657 &balance,
4658 account_id,
4659 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4660 size_precision,
4661 ts_init,
4662 )
4663 .unwrap();
4664
4665 assert!(result.is_some());
4666 let report = result.unwrap();
4667 assert_eq!(report.position_side, PositionSide::Short.as_specified());
4668 assert_eq!(report.quantity.to_string(), "10.000000");
4669 assert!(report.instrument_id.to_string().contains("ETH-"));
4670 }
4671
4672 #[rstest]
4673 fn test_parse_spot_margin_position_from_balance_no_position() {
4674 let balance = OKXBalanceDetail {
4675 ccy: Ustr::from("USDT"),
4676 liab: "0".to_string(),
4677 spot_in_use_amt: "0".to_string(),
4678 cross_liab: "0".to_string(),
4679 eq: "1000.5".to_string(),
4680 u_time: 1704067200000,
4681 avail_bal: "1000.5".to_string(),
4682 avail_eq: "1000.5".to_string(),
4683 borrow_froz: "0".to_string(),
4684 cash_bal: "1000.5".to_string(),
4685 dis_eq: "0".to_string(),
4686 eq_usd: "1000.5".to_string(),
4687 smt_sync_eq: "0".to_string(),
4688 spot_copy_trading_eq: "0".to_string(),
4689 fixed_bal: "0".to_string(),
4690 frozen_bal: "0".to_string(),
4691 imr: "0".to_string(),
4692 interest: "0".to_string(),
4693 iso_eq: "0".to_string(),
4694 iso_liab: "0".to_string(),
4695 iso_upl: "0".to_string(),
4696 max_loan: "0".to_string(),
4697 mgn_ratio: "0".to_string(),
4698 mmr: "0".to_string(),
4699 notional_lever: "0".to_string(),
4700 ord_frozen: "0".to_string(),
4701 reward_bal: "0".to_string(),
4702 cl_spot_in_use_amt: "0".to_string(),
4703 max_spot_in_use_amt: "0".to_string(),
4704 spot_iso_bal: "0".to_string(),
4705 stgy_eq: "0".to_string(),
4706 twap: "0".to_string(),
4707 upl: "0".to_string(),
4708 upl_liab: "0".to_string(),
4709 spot_bal: "1000.5".to_string(),
4710 open_avg_px: "0".to_string(),
4711 acc_avg_px: "0".to_string(),
4712 spot_upl: "0".to_string(),
4713 spot_upl_ratio: "0".to_string(),
4714 total_pnl: "0".to_string(),
4715 total_pnl_ratio: "0".to_string(),
4716 };
4717
4718 let account_id = AccountId::new("OKX-001");
4719 let size_precision = 2;
4720 let ts_init = UnixNanos::default();
4721
4722 let result = parse_spot_margin_position_from_balance(
4723 &balance,
4724 account_id,
4725 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4726 size_precision,
4727 ts_init,
4728 )
4729 .unwrap();
4730
4731 assert!(result.is_none());
4732 }
4733
4734 #[rstest]
4735 fn test_parse_spot_margin_position_from_balance_liability_no_spot_in_use() {
4736 let balance = OKXBalanceDetail {
4737 ccy: Ustr::from("BTC"),
4738 liab: "0.5".to_string(),
4739 spot_in_use_amt: "0".to_string(),
4740 cross_liab: "0.5".to_string(),
4741 eq: "0".to_string(),
4742 u_time: 1704067200000,
4743 avail_bal: "0".to_string(),
4744 avail_eq: "0".to_string(),
4745 borrow_froz: "0".to_string(),
4746 cash_bal: "0".to_string(),
4747 dis_eq: "0".to_string(),
4748 eq_usd: "0".to_string(),
4749 smt_sync_eq: "0".to_string(),
4750 spot_copy_trading_eq: "0".to_string(),
4751 fixed_bal: "0".to_string(),
4752 frozen_bal: "0".to_string(),
4753 imr: "0".to_string(),
4754 interest: "0".to_string(),
4755 iso_eq: "0".to_string(),
4756 iso_liab: "0".to_string(),
4757 iso_upl: "0".to_string(),
4758 max_loan: "0".to_string(),
4759 mgn_ratio: "0".to_string(),
4760 mmr: "0".to_string(),
4761 notional_lever: "0".to_string(),
4762 ord_frozen: "0".to_string(),
4763 reward_bal: "0".to_string(),
4764 cl_spot_in_use_amt: "0".to_string(),
4765 max_spot_in_use_amt: "0".to_string(),
4766 spot_iso_bal: "0".to_string(),
4767 stgy_eq: "0".to_string(),
4768 twap: "0".to_string(),
4769 upl: "0".to_string(),
4770 upl_liab: "0".to_string(),
4771 spot_bal: "0".to_string(),
4772 open_avg_px: "0".to_string(),
4773 acc_avg_px: "0".to_string(),
4774 spot_upl: "0".to_string(),
4775 spot_upl_ratio: "0".to_string(),
4776 total_pnl: "0".to_string(),
4777 total_pnl_ratio: "0".to_string(),
4778 };
4779
4780 let account_id = AccountId::new("OKX-001");
4781 let size_precision = 8;
4782 let ts_init = UnixNanos::default();
4783
4784 let result = parse_spot_margin_position_from_balance(
4785 &balance,
4786 account_id,
4787 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4788 size_precision,
4789 ts_init,
4790 )
4791 .unwrap();
4792
4793 assert!(result.is_none());
4794 }
4795
4796 #[rstest]
4797 fn test_parse_spot_margin_position_from_balance_empty_strings() {
4798 let balance = OKXBalanceDetail {
4799 ccy: Ustr::from("USDT"),
4800 liab: String::new(),
4801 spot_in_use_amt: String::new(),
4802 cross_liab: String::new(),
4803 eq: "5000.25".to_string(),
4804 u_time: 1704067200000,
4805 avail_bal: "5000.25".to_string(),
4806 avail_eq: "5000.25".to_string(),
4807 borrow_froz: String::new(),
4808 cash_bal: "5000.25".to_string(),
4809 dis_eq: String::new(),
4810 eq_usd: "5000.25".to_string(),
4811 smt_sync_eq: String::new(),
4812 spot_copy_trading_eq: String::new(),
4813 fixed_bal: String::new(),
4814 frozen_bal: String::new(),
4815 imr: String::new(),
4816 interest: String::new(),
4817 iso_eq: String::new(),
4818 iso_liab: String::new(),
4819 iso_upl: String::new(),
4820 max_loan: String::new(),
4821 mgn_ratio: String::new(),
4822 mmr: String::new(),
4823 notional_lever: String::new(),
4824 ord_frozen: String::new(),
4825 reward_bal: String::new(),
4826 cl_spot_in_use_amt: String::new(),
4827 max_spot_in_use_amt: String::new(),
4828 spot_iso_bal: String::new(),
4829 stgy_eq: String::new(),
4830 twap: String::new(),
4831 upl: String::new(),
4832 upl_liab: String::new(),
4833 spot_bal: "5000.25".to_string(),
4834 open_avg_px: String::new(),
4835 acc_avg_px: String::new(),
4836 spot_upl: String::new(),
4837 spot_upl_ratio: String::new(),
4838 total_pnl: String::new(),
4839 total_pnl_ratio: String::new(),
4840 };
4841
4842 let account_id = AccountId::new("OKX-001");
4843 let size_precision = 2;
4844 let ts_init = UnixNanos::default();
4845
4846 let result = parse_spot_margin_position_from_balance(
4847 &balance,
4848 account_id,
4849 InstrumentId::from_str(&format!("{}-USDT.OKX", balance.ccy.as_str())).unwrap(),
4850 size_precision,
4851 ts_init,
4852 )
4853 .unwrap();
4854
4855 assert!(result.is_none());
4857 }
4858
4859 #[rstest]
4860 #[case::fok_maps_to_fok_tif(OKXOrderType::Fok, TimeInForce::Fok)]
4861 #[case::ioc_maps_to_ioc_tif(OKXOrderType::Ioc, TimeInForce::Ioc)]
4862 #[case::optimal_limit_ioc_maps_to_ioc_tif(OKXOrderType::OptimalLimitIoc, TimeInForce::Ioc)]
4863 #[case::market_maps_to_gtc(OKXOrderType::Market, TimeInForce::Gtc)]
4864 #[case::limit_maps_to_gtc(OKXOrderType::Limit, TimeInForce::Gtc)]
4865 #[case::post_only_maps_to_gtc(OKXOrderType::PostOnly, TimeInForce::Gtc)]
4866 #[case::trigger_maps_to_gtc(OKXOrderType::Trigger, TimeInForce::Gtc)]
4867 fn test_okx_order_type_to_time_in_force(
4868 #[case] okx_ord_type: OKXOrderType,
4869 #[case] expected_tif: TimeInForce,
4870 ) {
4871 let time_in_force = match okx_ord_type {
4872 OKXOrderType::Fok | OKXOrderType::OpFok => TimeInForce::Fok,
4873 OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
4874 _ => TimeInForce::Gtc,
4875 };
4876
4877 assert_eq!(
4878 time_in_force, expected_tif,
4879 "OKXOrderType::{okx_ord_type:?} should map to TimeInForce::{expected_tif:?}"
4880 );
4881 }
4882
4883 #[rstest]
4884 fn test_fok_order_type_serialization() {
4885 let ord_type = OKXOrderType::Fok;
4886 let json = serde_json::to_string(&ord_type).expect("serialize");
4887 assert_eq!(json, "\"fok\"", "FOK should serialize to 'fok'");
4888 }
4889
4890 #[rstest]
4891 fn test_ioc_order_type_serialization() {
4892 let ord_type = OKXOrderType::Ioc;
4893 let json = serde_json::to_string(&ord_type).expect("serialize");
4894 assert_eq!(json, "\"ioc\"", "IOC should serialize to 'ioc'");
4895 }
4896
4897 #[rstest]
4898 fn test_optimal_limit_ioc_serialization() {
4899 let ord_type = OKXOrderType::OptimalLimitIoc;
4900 let json = serde_json::to_string(&ord_type).expect("serialize");
4901 assert_eq!(
4902 json, "\"optimal_limit_ioc\"",
4903 "OptimalLimitIoc should serialize to 'optimal_limit_ioc'"
4904 );
4905 }
4906
4907 #[rstest]
4908 fn test_fok_order_type_deserialization() {
4909 let json = "\"fok\"";
4910 let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
4911 assert_eq!(ord_type, OKXOrderType::Fok);
4912 }
4913
4914 #[rstest]
4915 fn test_ioc_order_type_deserialization() {
4916 let json = "\"ioc\"";
4917 let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
4918 assert_eq!(ord_type, OKXOrderType::Ioc);
4919 }
4920
4921 #[rstest]
4922 fn test_optimal_limit_ioc_deserialization() {
4923 let json = "\"optimal_limit_ioc\"";
4924 let ord_type: OKXOrderType = serde_json::from_str(json).expect("deserialize");
4925 assert_eq!(ord_type, OKXOrderType::OptimalLimitIoc);
4926 }
4927
4928 #[rstest]
4929 #[case(TimeInForce::Fok, OKXOrderType::Fok)]
4930 #[case(TimeInForce::Ioc, OKXOrderType::Ioc)]
4931 fn test_time_in_force_round_trip(
4932 #[case] original_tif: TimeInForce,
4933 #[case] expected_okx_type: OKXOrderType,
4934 ) {
4935 let okx_ord_type = match original_tif {
4936 TimeInForce::Fok => OKXOrderType::Fok,
4937 TimeInForce::Ioc => OKXOrderType::Ioc,
4938 TimeInForce::Gtc => OKXOrderType::Limit,
4939 _ => OKXOrderType::Limit,
4940 };
4941 assert_eq!(okx_ord_type, expected_okx_type);
4942
4943 let parsed_tif = match okx_ord_type {
4944 OKXOrderType::Fok | OKXOrderType::OpFok => TimeInForce::Fok,
4945 OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
4946 _ => TimeInForce::Gtc,
4947 };
4948 assert_eq!(parsed_tif, original_tif);
4949 }
4950
4951 #[rstest]
4952 #[case::limit_fok(
4953 OrderType::Limit,
4954 TimeInForce::Fok,
4955 OKXOrderType::Fok,
4956 "Limit + FOK should map to Fok"
4957 )]
4958 #[case::limit_ioc(
4959 OrderType::Limit,
4960 TimeInForce::Ioc,
4961 OKXOrderType::Ioc,
4962 "Limit + IOC should map to Ioc"
4963 )]
4964 #[case::market_ioc(
4965 OrderType::Market,
4966 TimeInForce::Ioc,
4967 OKXOrderType::OptimalLimitIoc,
4968 "Market + IOC should map to OptimalLimitIoc"
4969 )]
4970 #[case::limit_gtc(
4971 OrderType::Limit,
4972 TimeInForce::Gtc,
4973 OKXOrderType::Limit,
4974 "Limit + GTC should map to Limit"
4975 )]
4976 #[case::market_gtc(
4977 OrderType::Market,
4978 TimeInForce::Gtc,
4979 OKXOrderType::Market,
4980 "Market + GTC should map to Market"
4981 )]
4982 fn test_order_type_time_in_force_combinations(
4983 #[case] order_type: OrderType,
4984 #[case] tif: TimeInForce,
4985 #[case] expected_okx_type: OKXOrderType,
4986 #[case] description: &str,
4987 ) {
4988 let okx_ord_type = match (order_type, tif) {
4989 (OrderType::Market, TimeInForce::Ioc) => OKXOrderType::OptimalLimitIoc,
4990 (OrderType::Limit, TimeInForce::Fok) => OKXOrderType::Fok,
4991 (OrderType::Limit, TimeInForce::Ioc) => OKXOrderType::Ioc,
4992 _ => OKXOrderType::from(order_type),
4993 };
4994
4995 assert_eq!(okx_ord_type, expected_okx_type, "{description}");
4996 }
4997
4998 #[rstest]
4999 fn test_market_fok_not_supported() {
5000 let order_type = OrderType::Market;
5001 let tif = TimeInForce::Fok;
5002
5003 let is_market_fok = matches!((order_type, tif), (OrderType::Market, TimeInForce::Fok));
5004 assert!(
5005 is_market_fok,
5006 "Market + FOK combination should be identified for rejection"
5007 );
5008 }
5009
5010 #[rstest]
5011 #[case::empty_string("", true)]
5012 #[case::zero("0", true)]
5013 #[case::minus_one("-1", true)]
5014 #[case::minus_two("-2", true)]
5015 #[case::normal_price("100.5", false)]
5016 #[case::another_price("0.001", false)]
5017 fn test_is_market_price(#[case] price: &str, #[case] expected: bool) {
5018 assert_eq!(is_market_price(price), expected);
5019 }
5020
5021 #[rstest]
5022 #[case::fok_market(OKXOrderType::Fok, "", OrderType::Market)]
5023 #[case::fok_limit(OKXOrderType::Fok, "100.5", OrderType::Limit)]
5024 #[case::ioc_market(OKXOrderType::Ioc, "", OrderType::Market)]
5025 #[case::ioc_limit(OKXOrderType::Ioc, "100.5", OrderType::Limit)]
5026 #[case::optimal_limit_ioc_market(OKXOrderType::OptimalLimitIoc, "", OrderType::Market)]
5027 #[case::optimal_limit_ioc_market_zero(OKXOrderType::OptimalLimitIoc, "0", OrderType::Market)]
5028 #[case::optimal_limit_ioc_market_minus_one(
5029 OKXOrderType::OptimalLimitIoc,
5030 "-1",
5031 OrderType::Market
5032 )]
5033 #[case::optimal_limit_ioc_limit(OKXOrderType::OptimalLimitIoc, "100.5", OrderType::Limit)]
5034 #[case::market_passthrough(OKXOrderType::Market, "", OrderType::Market)]
5035 #[case::limit_passthrough(OKXOrderType::Limit, "100.5", OrderType::Limit)]
5036 fn test_determine_order_type(
5037 #[case] okx_ord_type: OKXOrderType,
5038 #[case] price: &str,
5039 #[case] expected: OrderType,
5040 ) {
5041 assert_eq!(determine_order_type(okx_ord_type, price), expected);
5042 }
5043
5044 #[rstest]
5045 #[case::option("BTC-USD-250328-92000-C", "BTC-USD")]
5046 #[case::swap("BTC-USDT-SWAP", "BTC-USDT")]
5047 #[case::futures("ETH-USD-250328", "ETH-USD")]
5048 #[case::spot("BTC-USDT", "BTC-USDT")]
5049 fn test_extract_inst_family(#[case] symbol: &str, #[case] expected: &str) {
5050 let family = extract_inst_family(symbol).unwrap();
5051 assert_eq!(family.as_str(), expected);
5052 }
5053
5054 #[rstest]
5055 fn test_extract_inst_family_single_segment_fails() {
5056 assert!(extract_inst_family("BTC").is_err());
5057 }
5058}