1use std::str::FromStr;
19
20use nautilus_core::{
21 UUID4,
22 datetime::{NANOSECONDS_IN_MILLISECOND, millis_to_nanos},
23 nanos::UnixNanos,
24};
25use nautilus_model::{
26 currencies::CURRENCY_MAP,
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, OptionKind, OrderSide,
42 OrderStatus, OrderType, PositionSide, TimeInForce,
43 },
44 events::AccountState,
45 identifiers::{
46 AccountId, ClientOrderId, InstrumentId, PositionId, Symbol, TradeId, Venue, 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, OKXInstrumentType, OKXOrderStatus, OKXOrderType, OKXPositionSide, OKXSide,
62 OKXTargetCurrency, OKXVipLevel,
63 },
64 models::OKXInstrument,
65 },
66 http::models::{
67 OKXAccount, OKXCandlestick, OKXIndexTicker, OKXMarkPrice, OKXOrderHistory, OKXPosition,
68 OKXTrade, OKXTransactionDetail,
69 },
70 websocket::{enums::OKXWsChannel, messages::OKXFundingRateMsg},
71};
72
73pub fn deserialize_empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
91where
92 D: Deserializer<'de>,
93{
94 let opt = Option::<String>::deserialize(deserializer)?;
95 Ok(opt.filter(|s| !s.is_empty()))
96}
97
98pub fn deserialize_empty_ustr_as_none<'de, D>(deserializer: D) -> Result<Option<Ustr>, D::Error>
104where
105 D: Deserializer<'de>,
106{
107 let opt = Option::<Ustr>::deserialize(deserializer)?;
108 Ok(opt.filter(|s| !s.is_empty()))
109}
110
111pub fn deserialize_target_currency_as_none<'de, D>(
117 deserializer: D,
118) -> Result<Option<crate::common::enums::OKXTargetCurrency>, D::Error>
119where
120 D: Deserializer<'de>,
121{
122 let s = String::deserialize(deserializer)?;
123 if s.is_empty() {
124 Ok(None)
125 } else {
126 s.parse().map(Some).map_err(serde::de::Error::custom)
127 }
128}
129
130pub fn deserialize_string_to_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
136where
137 D: Deserializer<'de>,
138{
139 let s = String::deserialize(deserializer)?;
140 if s.is_empty() {
141 Ok(0)
142 } else {
143 s.parse::<u64>().map_err(serde::de::Error::custom)
144 }
145}
146
147pub fn deserialize_optional_string_to_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
153where
154 D: Deserializer<'de>,
155{
156 let s: Option<String> = Option::deserialize(deserializer)?;
157 match s {
158 Some(s) if s.is_empty() => Ok(None),
159 Some(s) => s.parse().map(Some).map_err(serde::de::Error::custom),
160 None => Ok(None),
161 }
162}
163
164pub fn deserialize_vip_level<'de, D>(deserializer: D) -> Result<OKXVipLevel, D::Error>
178where
179 D: Deserializer<'de>,
180{
181 let s = String::deserialize(deserializer)?;
182
183 if s.is_empty() {
184 return Ok(OKXVipLevel::Vip0);
185 }
186
187 let s_lower = s.to_lowercase();
188 let level_str = s_lower
189 .strip_prefix("vip")
190 .or_else(|| s_lower.strip_prefix("lv"))
191 .unwrap_or(&s_lower);
192
193 let level_num = level_str
194 .parse::<u8>()
195 .map_err(|e| serde::de::Error::custom(format!("Invalid VIP level '{s}': {e}")))?;
196
197 Ok(OKXVipLevel::from(level_num))
198}
199
200fn get_currency_with_context(code: &str, context: Option<&str>) -> Currency {
205 let trimmed = code.trim();
206 let ctx = context.unwrap_or("unknown");
207
208 if trimmed.is_empty() {
209 tracing::warn!(
210 "get_currency called with empty code (context: {ctx}), using USDT as fallback"
211 );
212 return Currency::USDT();
213 }
214
215 CURRENCY_MAP
216 .lock()
217 .unwrap()
218 .get(trimmed)
219 .copied()
220 .unwrap_or_else(|| {
221 use nautilus_model::enums::CurrencyType;
224 Currency::new(trimmed, 8, 0, trimmed, CurrencyType::Crypto)
225 })
226}
227
228pub fn okx_instrument_type(instrument: &InstrumentAny) -> anyhow::Result<OKXInstrumentType> {
235 match instrument {
236 InstrumentAny::CurrencyPair(_) => Ok(OKXInstrumentType::Spot),
237 InstrumentAny::CryptoPerpetual(_) => Ok(OKXInstrumentType::Swap),
238 InstrumentAny::CryptoFuture(_) => Ok(OKXInstrumentType::Futures),
239 InstrumentAny::CryptoOption(_) => Ok(OKXInstrumentType::Option),
240 _ => anyhow::bail!("Invalid instrument type for OKX: {instrument:?}"),
241 }
242}
243
244pub fn okx_instrument_type_from_symbol(symbol: &str) -> OKXInstrumentType {
253 let parts: Vec<&str> = symbol.split('-').collect();
255
256 match parts.len() {
257 2 => OKXInstrumentType::Spot,
258 3 => {
259 let suffix = parts[2];
260 if suffix == "SWAP" {
261 OKXInstrumentType::Swap
262 } else if suffix.len() == 6 && suffix.chars().all(|c| c.is_ascii_digit()) {
263 OKXInstrumentType::Futures
265 } else {
266 OKXInstrumentType::Spot
267 }
268 }
269 5 => OKXInstrumentType::Option,
270 _ => OKXInstrumentType::Spot, }
272}
273
274#[must_use]
276pub fn parse_instrument_id(symbol: Ustr) -> InstrumentId {
277 InstrumentId::new(Symbol::from_ustr_unchecked(symbol), *OKX_VENUE)
278}
279
280#[must_use]
282pub fn parse_client_order_id(value: &str) -> Option<ClientOrderId> {
283 if value.is_empty() {
284 None
285 } else {
286 Some(ClientOrderId::new(value))
287 }
288}
289
290#[must_use]
293pub fn parse_millisecond_timestamp(timestamp_ms: u64) -> UnixNanos {
294 UnixNanos::from(timestamp_ms * NANOSECONDS_IN_MILLISECOND)
295}
296
297pub fn parse_rfc3339_timestamp(timestamp: &str) -> anyhow::Result<UnixNanos> {
304 let dt = chrono::DateTime::parse_from_rfc3339(timestamp)?;
305 let nanos = dt.timestamp_nanos_opt().ok_or_else(|| {
306 anyhow::anyhow!("Failed to extract nanoseconds from timestamp: {timestamp}")
307 })?;
308 Ok(UnixNanos::from(nanos as u64))
309}
310
311pub fn parse_price(value: &str, precision: u8) -> anyhow::Result<Price> {
318 Price::new_checked(value.parse::<f64>()?, precision)
319}
320
321pub fn parse_quantity(value: &str, precision: u8) -> anyhow::Result<Quantity> {
328 Quantity::new_checked(value.parse::<f64>()?, precision)
329}
330
331pub fn parse_fee(value: Option<&str>, currency: Currency) -> anyhow::Result<Money> {
341 let fee_f64 = value.unwrap_or("0").parse::<f64>()?;
343 Money::new_checked(-fee_f64, currency)
344}
345
346pub fn parse_fee_currency(
351 fee_ccy: &str,
352 fee_amount: f64,
353 context: impl FnOnce() -> String,
354) -> Currency {
355 let trimmed = fee_ccy.trim();
356 if trimmed.is_empty() {
357 if fee_amount != 0.0 {
358 let ctx = context();
359 tracing::warn!(
360 "Empty fee_ccy in {ctx} with non-zero fee={fee_amount}, using USDT as fallback"
361 );
362 }
363 return Currency::USDT();
364 }
365
366 get_currency_with_context(trimmed, Some(&context()))
367}
368
369pub fn parse_aggressor_side(side: &Option<OKXSide>) -> AggressorSide {
371 match side {
372 Some(OKXSide::Buy) => AggressorSide::Buyer,
373 Some(OKXSide::Sell) => AggressorSide::Seller,
374 None => AggressorSide::NoAggressor,
375 }
376}
377
378pub fn parse_execution_type(liquidity: &Option<OKXExecType>) -> LiquiditySide {
380 match liquidity {
381 Some(OKXExecType::Maker) => LiquiditySide::Maker,
382 Some(OKXExecType::Taker) => LiquiditySide::Taker,
383 _ => LiquiditySide::NoLiquiditySide,
384 }
385}
386
387pub fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
389 match current_qty {
390 Some(qty) if qty > 0 => PositionSide::Long,
391 Some(qty) if qty < 0 => PositionSide::Short,
392 _ => PositionSide::Flat,
393 }
394}
395
396pub fn parse_mark_price_update(
403 raw: &OKXMarkPrice,
404 instrument_id: InstrumentId,
405 price_precision: u8,
406 ts_init: UnixNanos,
407) -> anyhow::Result<MarkPriceUpdate> {
408 let ts_event = parse_millisecond_timestamp(raw.ts);
409 let price = parse_price(&raw.mark_px, price_precision)?;
410 Ok(MarkPriceUpdate::new(
411 instrument_id,
412 price,
413 ts_event,
414 ts_init,
415 ))
416}
417
418pub fn parse_index_price_update(
425 raw: &OKXIndexTicker,
426 instrument_id: InstrumentId,
427 price_precision: u8,
428 ts_init: UnixNanos,
429) -> anyhow::Result<IndexPriceUpdate> {
430 let ts_event = parse_millisecond_timestamp(raw.ts);
431 let price = parse_price(&raw.idx_px, price_precision)?;
432 Ok(IndexPriceUpdate::new(
433 instrument_id,
434 price,
435 ts_event,
436 ts_init,
437 ))
438}
439
440pub fn parse_funding_rate_msg(
447 msg: &OKXFundingRateMsg,
448 instrument_id: InstrumentId,
449 ts_init: UnixNanos,
450) -> anyhow::Result<FundingRateUpdate> {
451 let funding_rate = msg
452 .funding_rate
453 .as_str()
454 .parse::<Decimal>()
455 .map_err(|e| anyhow::anyhow!("Invalid funding_rate value: {e}"))?
456 .normalize();
457
458 let funding_time = Some(parse_millisecond_timestamp(msg.funding_time));
459 let ts_event = parse_millisecond_timestamp(msg.ts);
460
461 Ok(FundingRateUpdate::new(
462 instrument_id,
463 funding_rate,
464 funding_time,
465 ts_event,
466 ts_init,
467 ))
468}
469
470pub fn parse_trade_tick(
477 raw: &OKXTrade,
478 instrument_id: InstrumentId,
479 price_precision: u8,
480 size_precision: u8,
481 ts_init: UnixNanos,
482) -> anyhow::Result<TradeTick> {
483 let ts_event = parse_millisecond_timestamp(raw.ts);
484 let price = parse_price(&raw.px, price_precision)?;
485 let size = parse_quantity(&raw.sz, size_precision)?;
486 let aggressor: AggressorSide = raw.side.into();
487 let trade_id = TradeId::new(raw.trade_id);
488
489 TradeTick::new_checked(
490 instrument_id,
491 price,
492 size,
493 aggressor,
494 trade_id,
495 ts_event,
496 ts_init,
497 )
498}
499
500pub fn parse_candlestick(
507 raw: &OKXCandlestick,
508 bar_type: BarType,
509 price_precision: u8,
510 size_precision: u8,
511 ts_init: UnixNanos,
512) -> anyhow::Result<Bar> {
513 let ts_event = parse_millisecond_timestamp(raw.0.parse()?);
514 let open = parse_price(&raw.1, price_precision)?;
515 let high = parse_price(&raw.2, price_precision)?;
516 let low = parse_price(&raw.3, price_precision)?;
517 let close = parse_price(&raw.4, price_precision)?;
518 let volume = parse_quantity(&raw.5, size_precision)?;
519
520 Ok(Bar::new(
521 bar_type, open, high, low, close, volume, ts_event, ts_init,
522 ))
523}
524
525#[allow(clippy::too_many_lines)]
527pub fn parse_order_status_report(
528 order: &OKXOrderHistory,
529 account_id: AccountId,
530 instrument_id: InstrumentId,
531 price_precision: u8,
532 size_precision: u8,
533 ts_init: UnixNanos,
534) -> OrderStatusReport {
535 let is_quote_qty_explicit = order.tgt_ccy == Some(OKXTargetCurrency::QuoteCcy);
541
542 let is_quote_qty_heuristic = order.tgt_ccy.is_none()
547 && (order.inst_type == OKXInstrumentType::Spot
548 || order.inst_type == OKXInstrumentType::Margin)
549 && order.side == OKXSide::Buy
550 && order.ord_type == OKXOrderType::Market;
551
552 let (quantity, filled_qty) = if is_quote_qty_explicit || is_quote_qty_heuristic {
553 let sz_quote = order.sz.parse::<f64>().unwrap_or(0.0);
555
556 let conversion_price = if !order.px.is_empty() && order.px != "0" {
559 order.px.parse::<f64>().unwrap_or(0.0)
561 } else if !order.avg_px.is_empty() && order.avg_px != "0" {
562 order.avg_px.parse::<f64>().unwrap_or(0.0)
564 } else {
565 log::warn!(
566 "No price available for conversion: ord_id={}, px='{}', avg_px='{}'",
567 order.ord_id.as_str(),
568 order.px,
569 order.avg_px
570 );
571 0.0
572 };
573
574 let quantity_base = if conversion_price > 0.0 {
576 Quantity::new(sz_quote / conversion_price, size_precision)
577 } else {
578 log::warn!(
580 "Cannot convert, using sz as-is: ord_id={}, sz={}",
581 order.ord_id.as_str(),
582 order.sz
583 );
584 order
585 .sz
586 .parse::<f64>()
587 .ok()
588 .map(|v| Quantity::new(v, size_precision))
589 .unwrap_or_default()
590 };
591
592 let filled_qty = order
593 .acc_fill_sz
594 .parse::<f64>()
595 .ok()
596 .map(|v| Quantity::new(v, size_precision))
597 .unwrap_or_default();
598
599 (quantity_base, filled_qty)
600 } else {
601 let quantity = order
603 .sz
604 .parse::<f64>()
605 .ok()
606 .map(|v| Quantity::new(v, size_precision))
607 .unwrap_or_default();
608 let filled_qty = order
609 .acc_fill_sz
610 .parse::<f64>()
611 .ok()
612 .map(|v| Quantity::new(v, size_precision))
613 .unwrap_or_default();
614
615 (quantity, filled_qty)
616 };
617
618 let (quantity, filled_qty) = if (is_quote_qty_explicit || is_quote_qty_heuristic)
621 && order.state == OKXOrderStatus::Filled
622 && filled_qty.is_positive()
623 {
624 (filled_qty, filled_qty)
625 } else {
626 (quantity, filled_qty)
627 };
628
629 let order_side: OrderSide = order.side.into();
630 let okx_status: OKXOrderStatus = order.state;
631 let order_status: OrderStatus = okx_status.into();
632 let okx_ord_type: OKXOrderType = order.ord_type;
633 let order_type: OrderType = okx_ord_type.into();
634 let time_in_force = TimeInForce::Gtc;
636
637 let mut client_order_id = if order.cl_ord_id.is_empty() {
639 None
640 } else {
641 Some(ClientOrderId::new(order.cl_ord_id.as_str()))
642 };
643
644 let mut linked_ids = Vec::new();
645
646 if let Some(algo_cl_ord_id) = order
647 .algo_cl_ord_id
648 .as_ref()
649 .filter(|value| !value.as_str().is_empty())
650 {
651 let algo_client_id = ClientOrderId::new(algo_cl_ord_id.as_str());
652 match &client_order_id {
653 Some(existing) if existing == &algo_client_id => {}
654 Some(_) => linked_ids.push(algo_client_id),
655 None => client_order_id = Some(algo_client_id),
656 }
657 }
658
659 let venue_order_id = if order.ord_id.is_empty() {
660 if let Some(algo_id) = order
661 .algo_id
662 .as_ref()
663 .filter(|value| !value.as_str().is_empty())
664 {
665 VenueOrderId::new(algo_id.as_str())
666 } else if !order.cl_ord_id.is_empty() {
667 VenueOrderId::new(order.cl_ord_id.as_str())
668 } else {
669 let synthetic_id = format!("{}:{}", account_id, order.c_time);
670 VenueOrderId::new(&synthetic_id)
671 }
672 } else {
673 VenueOrderId::new(order.ord_id.as_str())
674 };
675
676 let ts_accepted = parse_millisecond_timestamp(order.c_time);
677 let ts_last = UnixNanos::from(order.u_time * NANOSECONDS_IN_MILLISECOND);
678
679 let mut report = OrderStatusReport::new(
680 account_id,
681 instrument_id,
682 client_order_id,
683 venue_order_id,
684 order_side,
685 order_type,
686 time_in_force,
687 order_status,
688 quantity,
689 filled_qty,
690 ts_accepted,
691 ts_last,
692 ts_init,
693 None,
694 );
695
696 if !order.px.is_empty()
698 && let Ok(p) = order.px.parse::<f64>()
699 {
700 report = report.with_price(Price::new(p, price_precision));
701 }
702 if !order.avg_px.is_empty()
703 && let Ok(avg) = order.avg_px.parse::<f64>()
704 {
705 report = report.with_avg_px(avg);
706 }
707 if order.ord_type == OKXOrderType::PostOnly {
708 report = report.with_post_only(true);
709 }
710 if order.reduce_only == "true" {
711 report = report.with_reduce_only(true);
712 }
713
714 if !linked_ids.is_empty() {
715 report = report.with_linked_order_ids(linked_ids);
716 }
717
718 report
719}
720
721#[allow(clippy::too_many_lines)]
744pub fn parse_position_status_report(
745 position: OKXPosition,
746 account_id: AccountId,
747 instrument_id: InstrumentId,
748 size_precision: u8,
749 ts_init: UnixNanos,
750) -> anyhow::Result<PositionStatusReport> {
751 let pos_value = position.pos.parse::<f64>().unwrap_or_else(|e| {
752 panic!(
753 "Failed to parse position quantity '{}' for instrument {}: {:?}",
754 position.pos, instrument_id, e
755 )
756 });
757
758 let position_side = match position.pos_side {
762 OKXPositionSide::Net => {
763 if pos_value > 0.0 {
765 PositionSide::Long
766 } else if pos_value < 0.0 {
767 PositionSide::Short
768 } else {
769 PositionSide::Flat
770 }
771 }
772 OKXPositionSide::Long => {
773 PositionSide::Long
775 }
776 OKXPositionSide::Short => {
777 PositionSide::Short
779 }
780 OKXPositionSide::None => {
781 if pos_value > 0.0 {
783 PositionSide::Long
784 } else if pos_value < 0.0 {
785 PositionSide::Short
786 } else {
787 PositionSide::Flat
788 }
789 }
790 }
791 .as_specified();
792
793 let quantity = Quantity::new(pos_value.abs(), size_precision);
795
796 let venue_position_id = match position.pos_side {
799 OKXPositionSide::Long => {
800 position
802 .pos_id
803 .map(|pos_id| PositionId::new(format!("{pos_id}-LONG")))
804 }
805 OKXPositionSide::Short => {
806 position
808 .pos_id
809 .map(|pos_id| PositionId::new(format!("{pos_id}-SHORT")))
810 }
811 OKXPositionSide::Net | OKXPositionSide::None => {
812 None
814 }
815 };
816
817 let avg_px_open = if position.avg_px.is_empty() {
818 None
819 } else {
820 Some(Decimal::from_str(&position.avg_px)?)
821 };
822 let ts_last = parse_millisecond_timestamp(position.u_time);
823
824 Ok(PositionStatusReport::new(
825 account_id,
826 instrument_id,
827 position_side,
828 quantity,
829 ts_last,
830 ts_init,
831 None, venue_position_id,
833 avg_px_open,
834 ))
835}
836
837pub fn parse_fill_report(
843 detail: OKXTransactionDetail,
844 account_id: AccountId,
845 instrument_id: InstrumentId,
846 price_precision: u8,
847 size_precision: u8,
848 ts_init: UnixNanos,
849) -> anyhow::Result<FillReport> {
850 let client_order_id = if detail.cl_ord_id.is_empty() {
851 None
852 } else {
853 Some(ClientOrderId::new(detail.cl_ord_id))
854 };
855 let venue_order_id = VenueOrderId::new(detail.ord_id);
856 let trade_id = TradeId::new(detail.trade_id);
857 let order_side: OrderSide = detail.side.into();
858 let last_px = parse_price(&detail.fill_px, price_precision)?;
859 let last_qty = parse_quantity(&detail.fill_sz, size_precision)?;
860 let fee_f64 = detail.fee.as_deref().unwrap_or("0").parse::<f64>()?;
861 let fee_currency = parse_fee_currency(&detail.fee_ccy, fee_f64, || {
862 format!("fill report for instrument_id={}", instrument_id)
863 });
864 let commission = Money::new(-fee_f64, fee_currency);
865 let liquidity_side: LiquiditySide = detail.exec_type.into();
866 let ts_event = parse_millisecond_timestamp(detail.ts);
867
868 Ok(FillReport::new(
869 account_id,
870 instrument_id,
871 venue_order_id,
872 trade_id,
873 order_side,
874 last_qty,
875 last_px,
876 commission,
877 liquidity_side,
878 client_order_id,
879 None, ts_event,
881 ts_init,
882 None, ))
884}
885
886pub fn parse_message_vec<T, R, F, W>(
896 data: serde_json::Value,
897 parser: F,
898 wrapper: W,
899) -> anyhow::Result<Vec<Data>>
900where
901 T: DeserializeOwned,
902 F: Fn(&T) -> anyhow::Result<R>,
903 W: Fn(R) -> Data,
904{
905 let items = match data {
906 serde_json::Value::Array(items) => items,
907 other => {
908 let raw = serde_json::to_string(&other).unwrap_or_else(|_| other.to_string());
909 let mut snippet: String = raw.chars().take(512).collect();
910 if raw.len() > snippet.len() {
911 snippet.push_str("...");
912 }
913 anyhow::bail!("Expected array payload, received {snippet}");
914 }
915 };
916
917 let mut results = Vec::with_capacity(items.len());
918
919 for item in items {
920 let message: T = serde_json::from_value(item)?;
921 let parsed = parser(&message)?;
922 results.push(wrapper(parsed));
923 }
924
925 Ok(results)
926}
927
928pub fn bar_spec_as_okx_channel(bar_spec: BarSpecification) -> anyhow::Result<OKXWsChannel> {
935 let channel = match bar_spec {
936 BAR_SPEC_1_SECOND_LAST => OKXWsChannel::Candle1Second,
937 BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::Candle1Minute,
938 BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::Candle3Minute,
939 BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::Candle5Minute,
940 BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::Candle15Minute,
941 BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::Candle30Minute,
942 BAR_SPEC_1_HOUR_LAST => OKXWsChannel::Candle1Hour,
943 BAR_SPEC_2_HOUR_LAST => OKXWsChannel::Candle2Hour,
944 BAR_SPEC_4_HOUR_LAST => OKXWsChannel::Candle4Hour,
945 BAR_SPEC_6_HOUR_LAST => OKXWsChannel::Candle6Hour,
946 BAR_SPEC_12_HOUR_LAST => OKXWsChannel::Candle12Hour,
947 BAR_SPEC_1_DAY_LAST => OKXWsChannel::Candle1Day,
948 BAR_SPEC_2_DAY_LAST => OKXWsChannel::Candle2Day,
949 BAR_SPEC_3_DAY_LAST => OKXWsChannel::Candle3Day,
950 BAR_SPEC_5_DAY_LAST => OKXWsChannel::Candle5Day,
951 BAR_SPEC_1_WEEK_LAST => OKXWsChannel::Candle1Week,
952 BAR_SPEC_1_MONTH_LAST => OKXWsChannel::Candle1Month,
953 BAR_SPEC_3_MONTH_LAST => OKXWsChannel::Candle3Month,
954 BAR_SPEC_6_MONTH_LAST => OKXWsChannel::Candle6Month,
955 BAR_SPEC_12_MONTH_LAST => OKXWsChannel::Candle1Year,
956 _ => anyhow::bail!("Invalid `BarSpecification` for channel, was {bar_spec}"),
957 };
958 Ok(channel)
959}
960
961pub fn bar_spec_as_okx_mark_price_channel(
968 bar_spec: BarSpecification,
969) -> anyhow::Result<OKXWsChannel> {
970 let channel = match bar_spec {
971 BAR_SPEC_1_SECOND_LAST => OKXWsChannel::MarkPriceCandle1Second,
972 BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::MarkPriceCandle1Minute,
973 BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::MarkPriceCandle3Minute,
974 BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::MarkPriceCandle5Minute,
975 BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::MarkPriceCandle15Minute,
976 BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::MarkPriceCandle30Minute,
977 BAR_SPEC_1_HOUR_LAST => OKXWsChannel::MarkPriceCandle1Hour,
978 BAR_SPEC_2_HOUR_LAST => OKXWsChannel::MarkPriceCandle2Hour,
979 BAR_SPEC_4_HOUR_LAST => OKXWsChannel::MarkPriceCandle4Hour,
980 BAR_SPEC_6_HOUR_LAST => OKXWsChannel::MarkPriceCandle6Hour,
981 BAR_SPEC_12_HOUR_LAST => OKXWsChannel::MarkPriceCandle12Hour,
982 BAR_SPEC_1_DAY_LAST => OKXWsChannel::MarkPriceCandle1Day,
983 BAR_SPEC_2_DAY_LAST => OKXWsChannel::MarkPriceCandle2Day,
984 BAR_SPEC_3_DAY_LAST => OKXWsChannel::MarkPriceCandle3Day,
985 BAR_SPEC_5_DAY_LAST => OKXWsChannel::MarkPriceCandle5Day,
986 BAR_SPEC_1_WEEK_LAST => OKXWsChannel::MarkPriceCandle1Week,
987 BAR_SPEC_1_MONTH_LAST => OKXWsChannel::MarkPriceCandle1Month,
988 BAR_SPEC_3_MONTH_LAST => OKXWsChannel::MarkPriceCandle3Month,
989 _ => anyhow::bail!("Invalid `BarSpecification` for mark price channel, was {bar_spec}"),
990 };
991 Ok(channel)
992}
993
994pub fn bar_spec_as_okx_timeframe(bar_spec: BarSpecification) -> anyhow::Result<&'static str> {
1001 let timeframe = match bar_spec {
1002 BAR_SPEC_1_SECOND_LAST => "1s",
1003 BAR_SPEC_1_MINUTE_LAST => "1m",
1004 BAR_SPEC_3_MINUTE_LAST => "3m",
1005 BAR_SPEC_5_MINUTE_LAST => "5m",
1006 BAR_SPEC_15_MINUTE_LAST => "15m",
1007 BAR_SPEC_30_MINUTE_LAST => "30m",
1008 BAR_SPEC_1_HOUR_LAST => "1H",
1009 BAR_SPEC_2_HOUR_LAST => "2H",
1010 BAR_SPEC_4_HOUR_LAST => "4H",
1011 BAR_SPEC_6_HOUR_LAST => "6H",
1012 BAR_SPEC_12_HOUR_LAST => "12H",
1013 BAR_SPEC_1_DAY_LAST => "1D",
1014 BAR_SPEC_2_DAY_LAST => "2D",
1015 BAR_SPEC_3_DAY_LAST => "3D",
1016 BAR_SPEC_5_DAY_LAST => "5D",
1017 BAR_SPEC_1_WEEK_LAST => "1W",
1018 BAR_SPEC_1_MONTH_LAST => "1M",
1019 BAR_SPEC_3_MONTH_LAST => "3M",
1020 BAR_SPEC_6_MONTH_LAST => "6M",
1021 BAR_SPEC_12_MONTH_LAST => "1Y",
1022 _ => anyhow::bail!("Invalid `BarSpecification` for timeframe, was {bar_spec}"),
1023 };
1024 Ok(timeframe)
1025}
1026
1027pub fn okx_timeframe_as_bar_spec(timeframe: &str) -> anyhow::Result<BarSpecification> {
1033 let bar_spec = match timeframe {
1034 "1s" => BAR_SPEC_1_SECOND_LAST,
1035 "1m" => BAR_SPEC_1_MINUTE_LAST,
1036 "3m" => BAR_SPEC_3_MINUTE_LAST,
1037 "5m" => BAR_SPEC_5_MINUTE_LAST,
1038 "15m" => BAR_SPEC_15_MINUTE_LAST,
1039 "30m" => BAR_SPEC_30_MINUTE_LAST,
1040 "1H" => BAR_SPEC_1_HOUR_LAST,
1041 "2H" => BAR_SPEC_2_HOUR_LAST,
1042 "4H" => BAR_SPEC_4_HOUR_LAST,
1043 "6H" => BAR_SPEC_6_HOUR_LAST,
1044 "12H" => BAR_SPEC_12_HOUR_LAST,
1045 "1D" => BAR_SPEC_1_DAY_LAST,
1046 "2D" => BAR_SPEC_2_DAY_LAST,
1047 "3D" => BAR_SPEC_3_DAY_LAST,
1048 "5D" => BAR_SPEC_5_DAY_LAST,
1049 "1W" => BAR_SPEC_1_WEEK_LAST,
1050 "1M" => BAR_SPEC_1_MONTH_LAST,
1051 "3M" => BAR_SPEC_3_MONTH_LAST,
1052 "6M" => BAR_SPEC_6_MONTH_LAST,
1053 "1Y" => BAR_SPEC_12_MONTH_LAST,
1054 _ => anyhow::bail!("Invalid timeframe for `BarSpecification`, was {timeframe}"),
1055 };
1056 Ok(bar_spec)
1057}
1058
1059pub fn okx_bar_type_from_timeframe(
1067 instrument_id: InstrumentId,
1068 timeframe: &str,
1069) -> anyhow::Result<BarType> {
1070 let bar_spec = okx_timeframe_as_bar_spec(timeframe)?;
1071 Ok(BarType::new(
1072 instrument_id,
1073 bar_spec,
1074 AggregationSource::External,
1075 ))
1076}
1077
1078pub fn okx_channel_to_bar_spec(channel: &OKXWsChannel) -> Option<BarSpecification> {
1080 use OKXWsChannel::*;
1081 match channel {
1082 Candle1Second | MarkPriceCandle1Second => Some(BAR_SPEC_1_SECOND_LAST),
1083 Candle1Minute | MarkPriceCandle1Minute => Some(BAR_SPEC_1_MINUTE_LAST),
1084 Candle3Minute | MarkPriceCandle3Minute => Some(BAR_SPEC_3_MINUTE_LAST),
1085 Candle5Minute | MarkPriceCandle5Minute => Some(BAR_SPEC_5_MINUTE_LAST),
1086 Candle15Minute | MarkPriceCandle15Minute => Some(BAR_SPEC_15_MINUTE_LAST),
1087 Candle30Minute | MarkPriceCandle30Minute => Some(BAR_SPEC_30_MINUTE_LAST),
1088 Candle1Hour | MarkPriceCandle1Hour => Some(BAR_SPEC_1_HOUR_LAST),
1089 Candle2Hour | MarkPriceCandle2Hour => Some(BAR_SPEC_2_HOUR_LAST),
1090 Candle4Hour | MarkPriceCandle4Hour => Some(BAR_SPEC_4_HOUR_LAST),
1091 Candle6Hour | MarkPriceCandle6Hour => Some(BAR_SPEC_6_HOUR_LAST),
1092 Candle12Hour | MarkPriceCandle12Hour => Some(BAR_SPEC_12_HOUR_LAST),
1093 Candle1Day | MarkPriceCandle1Day => Some(BAR_SPEC_1_DAY_LAST),
1094 Candle2Day | MarkPriceCandle2Day => Some(BAR_SPEC_2_DAY_LAST),
1095 Candle3Day | MarkPriceCandle3Day => Some(BAR_SPEC_3_DAY_LAST),
1096 Candle5Day | MarkPriceCandle5Day => Some(BAR_SPEC_5_DAY_LAST),
1097 Candle1Week | MarkPriceCandle1Week => Some(BAR_SPEC_1_WEEK_LAST),
1098 Candle1Month | MarkPriceCandle1Month => Some(BAR_SPEC_1_MONTH_LAST),
1099 Candle3Month | MarkPriceCandle3Month => Some(BAR_SPEC_3_MONTH_LAST),
1100 Candle6Month => Some(BAR_SPEC_6_MONTH_LAST),
1101 Candle1Year => Some(BAR_SPEC_12_MONTH_LAST),
1102 _ => None,
1103 }
1104}
1105
1106pub fn parse_instrument_any(
1112 instrument: &OKXInstrument,
1113 margin_init: Option<Decimal>,
1114 margin_maint: Option<Decimal>,
1115 maker_fee: Option<Decimal>,
1116 taker_fee: Option<Decimal>,
1117 ts_init: UnixNanos,
1118) -> anyhow::Result<Option<InstrumentAny>> {
1119 match instrument.inst_type {
1120 OKXInstrumentType::Spot => parse_spot_instrument(
1121 instrument,
1122 margin_init,
1123 margin_maint,
1124 maker_fee,
1125 taker_fee,
1126 ts_init,
1127 )
1128 .map(Some),
1129 OKXInstrumentType::Margin => parse_spot_instrument(
1130 instrument,
1131 margin_init,
1132 margin_maint,
1133 maker_fee,
1134 taker_fee,
1135 ts_init,
1136 )
1137 .map(Some),
1138 OKXInstrumentType::Swap => parse_swap_instrument(
1139 instrument,
1140 margin_init,
1141 margin_maint,
1142 maker_fee,
1143 taker_fee,
1144 ts_init,
1145 )
1146 .map(Some),
1147 OKXInstrumentType::Futures => parse_futures_instrument(
1148 instrument,
1149 margin_init,
1150 margin_maint,
1151 maker_fee,
1152 taker_fee,
1153 ts_init,
1154 )
1155 .map(Some),
1156 OKXInstrumentType::Option => parse_option_instrument(
1157 instrument,
1158 margin_init,
1159 margin_maint,
1160 maker_fee,
1161 taker_fee,
1162 ts_init,
1163 )
1164 .map(Some),
1165 _ => Ok(None),
1166 }
1167}
1168
1169#[derive(Debug)]
1171struct CommonInstrumentData {
1172 instrument_id: InstrumentId,
1173 raw_symbol: Symbol,
1174 price_increment: Price,
1175 size_increment: Quantity,
1176 lot_size: Option<Quantity>,
1177 max_quantity: Option<Quantity>,
1178 min_quantity: Option<Quantity>,
1179 max_notional: Option<Money>,
1180 min_notional: Option<Money>,
1181 max_price: Option<Price>,
1182 min_price: Option<Price>,
1183}
1184
1185struct MarginAndFees {
1187 margin_init: Option<Decimal>,
1188 margin_maint: Option<Decimal>,
1189 maker_fee: Option<Decimal>,
1190 taker_fee: Option<Decimal>,
1191}
1192
1193fn parse_multiplier_product(definition: &OKXInstrument) -> anyhow::Result<Option<Quantity>> {
1198 if definition.ct_mult.is_empty() && definition.ct_val.is_empty() {
1199 return Ok(None);
1200 }
1201
1202 let mult_value = if definition.ct_mult.is_empty() {
1203 Decimal::ONE
1204 } else {
1205 Decimal::from_str(&definition.ct_mult).map_err(|e| {
1206 anyhow::anyhow!(
1207 "Failed to parse `ct_mult` '{}' for {}: {e}",
1208 definition.ct_mult,
1209 definition.inst_id
1210 )
1211 })?
1212 };
1213
1214 let val_value = if definition.ct_val.is_empty() {
1215 Decimal::ONE
1216 } else {
1217 Decimal::from_str(&definition.ct_val).map_err(|e| {
1218 anyhow::anyhow!(
1219 "Failed to parse `ct_val` '{}' for {}: {e}",
1220 definition.ct_val,
1221 definition.inst_id
1222 )
1223 })?
1224 };
1225
1226 let product = mult_value * val_value;
1227 Ok(Some(Quantity::from(product.to_string().as_str())))
1228}
1229
1230trait InstrumentParser {
1232 fn parse_specific_fields(
1234 &self,
1235 definition: &OKXInstrument,
1236 common: CommonInstrumentData,
1237 margin_fees: MarginAndFees,
1238 ts_init: UnixNanos,
1239 ) -> anyhow::Result<InstrumentAny>;
1240}
1241
1242fn parse_common_instrument_data(
1244 definition: &OKXInstrument,
1245) -> anyhow::Result<CommonInstrumentData> {
1246 let instrument_id = parse_instrument_id(definition.inst_id);
1247 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1248
1249 if definition.tick_sz.is_empty() {
1250 anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1251 }
1252
1253 let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
1254 anyhow::anyhow!(
1255 "Failed to parse `tick_sz` '{}' into Price for {}: {e}",
1256 definition.tick_sz,
1257 definition.inst_id,
1258 )
1259 })?;
1260
1261 let size_increment = Quantity::from(&definition.lot_sz);
1262 let lot_size = Some(Quantity::from(&definition.lot_sz));
1263 let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1264 let min_quantity = Some(Quantity::from(&definition.min_sz));
1265 let max_notional: Option<Money> = None;
1266 let min_notional: Option<Money> = None;
1267 let max_price = None; let min_price = None; Ok(CommonInstrumentData {
1271 instrument_id,
1272 raw_symbol,
1273 price_increment,
1274 size_increment,
1275 lot_size,
1276 max_quantity,
1277 min_quantity,
1278 max_notional,
1279 min_notional,
1280 max_price,
1281 min_price,
1282 })
1283}
1284
1285fn parse_instrument_with_parser<P: InstrumentParser>(
1287 definition: &OKXInstrument,
1288 parser: P,
1289 margin_init: Option<Decimal>,
1290 margin_maint: Option<Decimal>,
1291 maker_fee: Option<Decimal>,
1292 taker_fee: Option<Decimal>,
1293 ts_init: UnixNanos,
1294) -> anyhow::Result<InstrumentAny> {
1295 let common = parse_common_instrument_data(definition)?;
1296 parser.parse_specific_fields(
1297 definition,
1298 common,
1299 MarginAndFees {
1300 margin_init,
1301 margin_maint,
1302 maker_fee,
1303 taker_fee,
1304 },
1305 ts_init,
1306 )
1307}
1308
1309struct SpotInstrumentParser;
1311
1312impl InstrumentParser for SpotInstrumentParser {
1313 fn parse_specific_fields(
1314 &self,
1315 definition: &OKXInstrument,
1316 common: CommonInstrumentData,
1317 margin_fees: MarginAndFees,
1318 ts_init: UnixNanos,
1319 ) -> anyhow::Result<InstrumentAny> {
1320 let context = format!("{} instrument {}", definition.inst_type, definition.inst_id);
1321 let base_currency = get_currency_with_context(&definition.base_ccy, Some(&context));
1322 let quote_currency = get_currency_with_context(&definition.quote_ccy, Some(&context));
1323
1324 let multiplier = parse_multiplier_product(definition)?;
1326
1327 let instrument = CurrencyPair::new(
1328 common.instrument_id,
1329 common.raw_symbol,
1330 base_currency,
1331 quote_currency,
1332 common.price_increment.precision,
1333 common.size_increment.precision,
1334 common.price_increment,
1335 common.size_increment,
1336 multiplier,
1337 common.lot_size,
1338 common.max_quantity,
1339 common.min_quantity,
1340 common.max_notional,
1341 common.min_notional,
1342 common.max_price,
1343 common.min_price,
1344 margin_fees.margin_init,
1345 margin_fees.margin_maint,
1346 margin_fees.maker_fee,
1347 margin_fees.taker_fee,
1348 ts_init,
1349 ts_init,
1350 );
1351
1352 Ok(InstrumentAny::CurrencyPair(instrument))
1353 }
1354}
1355
1356pub fn parse_spot_instrument(
1362 definition: &OKXInstrument,
1363 margin_init: Option<Decimal>,
1364 margin_maint: Option<Decimal>,
1365 maker_fee: Option<Decimal>,
1366 taker_fee: Option<Decimal>,
1367 ts_init: UnixNanos,
1368) -> anyhow::Result<InstrumentAny> {
1369 parse_instrument_with_parser(
1370 definition,
1371 SpotInstrumentParser,
1372 margin_init,
1373 margin_maint,
1374 maker_fee,
1375 taker_fee,
1376 ts_init,
1377 )
1378}
1379
1380pub fn parse_swap_instrument(
1386 definition: &OKXInstrument,
1387 margin_init: Option<Decimal>,
1388 margin_maint: Option<Decimal>,
1389 maker_fee: Option<Decimal>,
1390 taker_fee: Option<Decimal>,
1391 ts_init: UnixNanos,
1392) -> anyhow::Result<InstrumentAny> {
1393 let instrument_id = parse_instrument_id(definition.inst_id);
1394 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1395 let context = format!("SWAP instrument {}", definition.inst_id);
1396 let (base_currency, quote_currency) = definition.uly.split_once('-').ok_or_else(|| {
1397 anyhow::anyhow!(
1398 "Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
1399 definition.uly,
1400 definition.inst_id
1401 )
1402 })?;
1403 let base_currency = get_currency_with_context(base_currency, Some(&context));
1404 let quote_currency = get_currency_with_context(quote_currency, Some(&context));
1405 let settlement_currency = get_currency_with_context(&definition.settle_ccy, Some(&context));
1406 let is_inverse = match definition.ct_type {
1407 OKXContractType::Linear => false,
1408 OKXContractType::Inverse => true,
1409 OKXContractType::None => {
1410 anyhow::bail!(
1411 "Invalid contract type '{}' for {}: expected 'linear' or 'inverse'",
1412 definition.ct_type,
1413 definition.inst_id
1414 )
1415 }
1416 };
1417
1418 if definition.tick_sz.is_empty() {
1419 anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1420 }
1421
1422 let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
1423 anyhow::anyhow!(
1424 "Failed to parse `tick_sz` '{}' into Price for {}: {e}",
1425 definition.tick_sz,
1426 definition.inst_id
1427 )
1428 })?;
1429 let size_increment = Quantity::from(&definition.lot_sz);
1430 let multiplier = parse_multiplier_product(definition)?;
1431 let lot_size = Some(Quantity::from(&definition.lot_sz));
1432 let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1433 let min_quantity = Some(Quantity::from(&definition.min_sz));
1434 let max_notional: Option<Money> = None;
1435 let min_notional: Option<Money> = None;
1436 let max_price = None; let min_price = None; let instrument = CryptoPerpetual::new(
1440 instrument_id,
1441 raw_symbol,
1442 base_currency,
1443 quote_currency,
1444 settlement_currency,
1445 is_inverse,
1446 price_increment.precision,
1447 size_increment.precision,
1448 price_increment,
1449 size_increment,
1450 multiplier,
1451 lot_size,
1452 max_quantity,
1453 min_quantity,
1454 max_notional,
1455 min_notional,
1456 max_price,
1457 min_price,
1458 margin_init,
1459 margin_maint,
1460 maker_fee,
1461 taker_fee,
1462 ts_init, ts_init,
1464 );
1465
1466 Ok(InstrumentAny::CryptoPerpetual(instrument))
1467}
1468
1469pub fn parse_futures_instrument(
1475 definition: &OKXInstrument,
1476 margin_init: Option<Decimal>,
1477 margin_maint: Option<Decimal>,
1478 maker_fee: Option<Decimal>,
1479 taker_fee: Option<Decimal>,
1480 ts_init: UnixNanos,
1481) -> anyhow::Result<InstrumentAny> {
1482 let instrument_id = parse_instrument_id(definition.inst_id);
1483 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1484 let context = format!("FUTURES instrument {}", definition.inst_id);
1485 let underlying = get_currency_with_context(&definition.uly, Some(&context));
1486 let (_, quote_currency) = definition.uly.split_once('-').ok_or_else(|| {
1487 anyhow::anyhow!(
1488 "Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
1489 definition.uly,
1490 definition.inst_id
1491 )
1492 })?;
1493 let quote_currency = get_currency_with_context(quote_currency, Some(&context));
1494 let settlement_currency = get_currency_with_context(&definition.settle_ccy, Some(&context));
1495 let is_inverse = match definition.ct_type {
1496 OKXContractType::Linear => false,
1497 OKXContractType::Inverse => true,
1498 OKXContractType::None => {
1499 anyhow::bail!(
1500 "Invalid contract type '{}' for {}: expected 'linear' or 'inverse'",
1501 definition.ct_type,
1502 definition.inst_id
1503 )
1504 }
1505 };
1506 let listing_time = definition
1507 .list_time
1508 .ok_or_else(|| anyhow::anyhow!("`list_time` is required for {}", definition.inst_id))?;
1509 let expiry_time = definition
1510 .exp_time
1511 .ok_or_else(|| anyhow::anyhow!("`exp_time` is required for {}", definition.inst_id))?;
1512 let activation_ns = UnixNanos::from(millis_to_nanos(listing_time as f64));
1513 let expiration_ns = UnixNanos::from(millis_to_nanos(expiry_time as f64));
1514
1515 if definition.tick_sz.is_empty() {
1516 anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1517 }
1518
1519 let price_increment = Price::from(definition.tick_sz.clone());
1520 let size_increment = Quantity::from(&definition.lot_sz);
1521 let multiplier = parse_multiplier_product(definition)?;
1522 let lot_size = Some(Quantity::from(&definition.lot_sz));
1523 let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1524 let min_quantity = Some(Quantity::from(&definition.min_sz));
1525 let max_notional: Option<Money> = None;
1526 let min_notional: Option<Money> = None;
1527 let max_price = None; let min_price = None; let instrument = CryptoFuture::new(
1531 instrument_id,
1532 raw_symbol,
1533 underlying,
1534 quote_currency,
1535 settlement_currency,
1536 is_inverse,
1537 activation_ns,
1538 expiration_ns,
1539 price_increment.precision,
1540 size_increment.precision,
1541 price_increment,
1542 size_increment,
1543 multiplier,
1544 lot_size,
1545 max_quantity,
1546 min_quantity,
1547 max_notional,
1548 min_notional,
1549 max_price,
1550 min_price,
1551 margin_init,
1552 margin_maint,
1553 maker_fee,
1554 taker_fee,
1555 ts_init, ts_init,
1557 );
1558
1559 Ok(InstrumentAny::CryptoFuture(instrument))
1560}
1561
1562pub fn parse_option_instrument(
1568 definition: &OKXInstrument,
1569 margin_init: Option<Decimal>,
1570 margin_maint: Option<Decimal>,
1571 maker_fee: Option<Decimal>,
1572 taker_fee: Option<Decimal>,
1573 ts_init: UnixNanos,
1574) -> anyhow::Result<InstrumentAny> {
1575 let instrument_id = parse_instrument_id(definition.inst_id);
1576 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1577 let option_kind: OptionKind = definition.opt_type.into();
1578 let strike_price = Price::from(&definition.stk);
1579 let context = format!("OPTION instrument {}", definition.inst_id);
1580
1581 let (underlying_str, quote_ccy_str) = definition.uly.split_once('-').ok_or_else(|| {
1582 anyhow::anyhow!(
1583 "Invalid underlying '{}' for {}: expected format 'BASE-QUOTE'",
1584 definition.uly,
1585 definition.inst_id
1586 )
1587 })?;
1588
1589 let underlying = get_currency_with_context(underlying_str, Some(&context));
1590 let quote_currency = get_currency_with_context(quote_ccy_str, Some(&context));
1591 let settlement_currency = get_currency_with_context(&definition.settle_ccy, Some(&context));
1592
1593 let is_inverse = if definition.ct_type == OKXContractType::None {
1594 settlement_currency == underlying
1595 } else {
1596 matches!(definition.ct_type, OKXContractType::Inverse)
1597 };
1598
1599 let listing_time = definition
1600 .list_time
1601 .ok_or_else(|| anyhow::anyhow!("`list_time` is required for {}", definition.inst_id))?;
1602 let expiry_time = definition
1603 .exp_time
1604 .ok_or_else(|| anyhow::anyhow!("`exp_time` is required for {}", definition.inst_id))?;
1605 let activation_ns = UnixNanos::from(millis_to_nanos(listing_time as f64));
1606 let expiration_ns = UnixNanos::from(millis_to_nanos(expiry_time as f64));
1607
1608 if definition.tick_sz.is_empty() {
1609 anyhow::bail!("`tick_sz` is empty for {}", definition.inst_id);
1610 }
1611
1612 let price_increment = Price::from(definition.tick_sz.clone());
1613 let size_increment = Quantity::from(&definition.lot_sz);
1614 let multiplier = parse_multiplier_product(definition)?;
1615 let lot_size = Quantity::from(&definition.lot_sz);
1616 let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1617 let min_quantity = Some(Quantity::from(&definition.min_sz));
1618 let max_notional = None;
1619 let min_notional = None;
1620 let max_price = None;
1621 let min_price = None;
1622
1623 let instrument = CryptoOption::new(
1624 instrument_id,
1625 raw_symbol,
1626 underlying,
1627 quote_currency,
1628 settlement_currency,
1629 is_inverse,
1630 option_kind,
1631 strike_price,
1632 activation_ns,
1633 expiration_ns,
1634 price_increment.precision,
1635 size_increment.precision,
1636 price_increment,
1637 size_increment,
1638 multiplier,
1639 Some(lot_size),
1640 max_quantity,
1641 min_quantity,
1642 max_notional,
1643 min_notional,
1644 max_price,
1645 min_price,
1646 margin_init,
1647 margin_maint,
1648 maker_fee,
1649 taker_fee,
1650 ts_init,
1651 ts_init,
1652 );
1653
1654 Ok(InstrumentAny::CryptoOption(instrument))
1655}
1656
1657fn parse_balance_field(
1660 value_str: &str,
1661 field_name: &str,
1662 currency: Currency,
1663 ccy_str: &str,
1664) -> Option<Money> {
1665 match value_str.parse::<f64>() {
1666 Ok(v) => Some(Money::new(v, currency)),
1667 Err(e) => {
1668 tracing::warn!(
1669 "Skipping balance detail for {ccy_str} with invalid {field_name} '{value_str}': {e}"
1670 );
1671 None
1672 }
1673 }
1674}
1675
1676pub fn parse_account_state(
1680 okx_account: &OKXAccount,
1681 account_id: AccountId,
1682 ts_init: UnixNanos,
1683) -> anyhow::Result<AccountState> {
1684 let mut balances = Vec::new();
1685 for b in &okx_account.details {
1686 let ccy_str = b.ccy.as_str().trim();
1688 if ccy_str.is_empty() {
1689 tracing::debug!(
1690 "Skipping balance detail with empty currency code | raw_data={:?}",
1691 b
1692 );
1693 continue;
1694 }
1695
1696 let currency = get_currency_with_context(ccy_str, Some("balance detail"));
1698
1699 let Some(total) = parse_balance_field(&b.cash_bal, "cash_bal", currency, ccy_str) else {
1701 continue;
1702 };
1703
1704 let Some(free) = parse_balance_field(&b.avail_bal, "avail_bal", currency, ccy_str) else {
1705 continue;
1706 };
1707
1708 let locked = total - free;
1709 let balance = AccountBalance::new(total, locked, free);
1710 balances.push(balance);
1711 }
1712
1713 if balances.is_empty() {
1716 let zero_currency = Currency::USD();
1717 let zero_money = Money::new(0.0, zero_currency);
1718 let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
1719 balances.push(zero_balance);
1720 }
1721
1722 let mut margins = Vec::new();
1723
1724 if !okx_account.imr.is_empty() && !okx_account.mmr.is_empty() {
1726 match (
1727 okx_account.imr.parse::<f64>(),
1728 okx_account.mmr.parse::<f64>(),
1729 ) {
1730 (Ok(imr_value), Ok(mmr_value)) => {
1731 if imr_value > 0.0 || mmr_value > 0.0 {
1732 let margin_currency = Currency::USD();
1733 let margin_instrument_id =
1734 InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("OKX"));
1735
1736 let initial_margin = Money::new(imr_value, margin_currency);
1737 let maintenance_margin = Money::new(mmr_value, margin_currency);
1738
1739 let margin_balance = MarginBalance::new(
1740 initial_margin,
1741 maintenance_margin,
1742 margin_instrument_id,
1743 );
1744
1745 margins.push(margin_balance);
1746 }
1747 }
1748 (Err(e1), _) => {
1749 tracing::warn!(
1750 "Failed to parse initial margin requirement '{}': {}",
1751 okx_account.imr,
1752 e1
1753 );
1754 }
1755 (_, Err(e2)) => {
1756 tracing::warn!(
1757 "Failed to parse maintenance margin requirement '{}': {}",
1758 okx_account.mmr,
1759 e2
1760 );
1761 }
1762 }
1763 }
1764
1765 let account_type = AccountType::Margin;
1766 let is_reported = true;
1767 let event_id = UUID4::new();
1768 let ts_event = UnixNanos::from(millis_to_nanos(okx_account.u_time as f64));
1769
1770 Ok(AccountState::new(
1771 account_id,
1772 account_type,
1773 balances,
1774 margins,
1775 is_reported,
1776 event_id,
1777 ts_event,
1778 ts_init,
1779 None,
1780 ))
1781}
1782
1783#[cfg(test)]
1788mod tests {
1789 use nautilus_model::{identifiers::PositionId, instruments::Instrument};
1790 use rstest::rstest;
1791
1792 use super::*;
1793 use crate::{
1794 OKXPositionSide,
1795 common::{enums::OKXMarginMode, testing::load_test_json},
1796 http::{
1797 client::OKXResponse,
1798 models::{
1799 OKXAccount, OKXBalanceDetail, OKXCandlestick, OKXIndexTicker, OKXMarkPrice,
1800 OKXOrderHistory, OKXPlaceOrderResponse, OKXPosition, OKXPositionHistory,
1801 OKXPositionTier, OKXTrade, OKXTransactionDetail,
1802 },
1803 },
1804 };
1805
1806 #[rstest]
1807 fn test_parse_fee_currency_with_zero_fee_empty_string() {
1808 let result = parse_fee_currency("", 0.0, || "test context".to_string());
1809 assert_eq!(result, Currency::USDT());
1810 }
1811
1812 #[rstest]
1813 fn test_parse_fee_currency_with_zero_fee_valid_currency() {
1814 let result = parse_fee_currency("BTC", 0.0, || "test context".to_string());
1815 assert_eq!(result, Currency::BTC());
1816 }
1817
1818 #[rstest]
1819 fn test_parse_fee_currency_with_valid_currency() {
1820 let result = parse_fee_currency("BTC", 0.001, || "test context".to_string());
1821 assert_eq!(result, Currency::BTC());
1822 }
1823
1824 #[rstest]
1825 fn test_parse_fee_currency_with_empty_string_nonzero_fee() {
1826 let result = parse_fee_currency("", 0.5, || "test context".to_string());
1827 assert_eq!(result, Currency::USDT());
1828 }
1829
1830 #[rstest]
1831 fn test_parse_fee_currency_with_whitespace() {
1832 let result = parse_fee_currency(" ETH ", 0.002, || "test context".to_string());
1833 assert_eq!(result, Currency::ETH());
1834 }
1835
1836 #[rstest]
1837 fn test_parse_fee_currency_with_unknown_code() {
1838 let result = parse_fee_currency("NEWTOKEN", 0.5, || "test context".to_string());
1840 assert_eq!(result.code.as_str(), "NEWTOKEN");
1841 assert_eq!(result.precision, 8);
1842 }
1843
1844 #[rstest]
1845 fn test_get_currency_with_context_valid() {
1846 let result = get_currency_with_context("BTC", Some("test context"));
1847 assert_eq!(result, Currency::BTC());
1848 }
1849
1850 #[rstest]
1851 fn test_get_currency_with_context_empty() {
1852 let result = get_currency_with_context("", Some("test context"));
1853 assert_eq!(result, Currency::USDT());
1854 }
1855
1856 #[rstest]
1857 fn test_get_currency_with_context_whitespace() {
1858 let result = get_currency_with_context(" ", Some("test context"));
1859 assert_eq!(result, Currency::USDT());
1860 }
1861
1862 #[rstest]
1863 fn test_get_currency_with_context_unknown() {
1864 let result = get_currency_with_context("NEWCOIN", Some("test context"));
1866 assert_eq!(result.code.as_str(), "NEWCOIN");
1867 assert_eq!(result.precision, 8);
1868 }
1869
1870 #[rstest]
1871 fn test_parse_balance_field_valid() {
1872 let result = parse_balance_field("100.5", "test_field", Currency::BTC(), "BTC");
1873 assert!(result.is_some());
1874 assert_eq!(result.unwrap().as_f64(), 100.5);
1875 }
1876
1877 #[rstest]
1878 fn test_parse_balance_field_invalid_numeric() {
1879 let result = parse_balance_field("not_a_number", "test_field", Currency::BTC(), "BTC");
1880 assert!(result.is_none());
1881 }
1882
1883 #[rstest]
1884 fn test_parse_balance_field_empty() {
1885 let result = parse_balance_field("", "test_field", Currency::BTC(), "BTC");
1886 assert!(result.is_none());
1887 }
1888
1889 #[rstest]
1893 fn test_parse_trades() {
1894 let json_data = load_test_json("http_get_trades.json");
1895 let parsed: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
1896
1897 assert_eq!(parsed.code, "0");
1899 assert_eq!(parsed.msg, "");
1900 assert_eq!(parsed.data.len(), 2);
1901
1902 let trade0 = &parsed.data[0];
1904 assert_eq!(trade0.inst_id, "BTC-USDT");
1905 assert_eq!(trade0.px, "102537.9");
1906 assert_eq!(trade0.sz, "0.00013669");
1907 assert_eq!(trade0.side, OKXSide::Sell);
1908 assert_eq!(trade0.trade_id, "734864333");
1909 assert_eq!(trade0.ts, 1747087163557);
1910
1911 let trade1 = &parsed.data[1];
1913 assert_eq!(trade1.inst_id, "BTC-USDT");
1914 assert_eq!(trade1.px, "102537.9");
1915 assert_eq!(trade1.sz, "0.0000125");
1916 assert_eq!(trade1.side, OKXSide::Buy);
1917 assert_eq!(trade1.trade_id, "734864332");
1918 assert_eq!(trade1.ts, 1747087161666);
1919 }
1920
1921 #[rstest]
1922 fn test_parse_candlesticks() {
1923 let json_data = load_test_json("http_get_candlesticks.json");
1924 let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
1925
1926 assert_eq!(parsed.code, "0");
1928 assert_eq!(parsed.msg, "");
1929 assert_eq!(parsed.data.len(), 2);
1930
1931 let bar0 = &parsed.data[0];
1932 assert_eq!(bar0.0, "1625097600000");
1933 assert_eq!(bar0.1, "33528.6");
1934 assert_eq!(bar0.2, "33870.0");
1935 assert_eq!(bar0.3, "33528.6");
1936 assert_eq!(bar0.4, "33783.9");
1937 assert_eq!(bar0.5, "778.838");
1938
1939 let bar1 = &parsed.data[1];
1940 assert_eq!(bar1.0, "1625097660000");
1941 assert_eq!(bar1.1, "33783.9");
1942 assert_eq!(bar1.2, "33783.9");
1943 assert_eq!(bar1.3, "33782.1");
1944 assert_eq!(bar1.4, "33782.1");
1945 assert_eq!(bar1.5, "0.123");
1946 }
1947
1948 #[rstest]
1949 fn test_parse_candlesticks_full() {
1950 let json_data = load_test_json("http_get_candlesticks_full.json");
1951 let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
1952
1953 assert_eq!(parsed.code, "0");
1955 assert_eq!(parsed.msg, "");
1956 assert_eq!(parsed.data.len(), 2);
1957
1958 let bar0 = &parsed.data[0];
1960 assert_eq!(bar0.0, "1747094040000");
1961 assert_eq!(bar0.1, "102806.1");
1962 assert_eq!(bar0.2, "102820.4");
1963 assert_eq!(bar0.3, "102806.1");
1964 assert_eq!(bar0.4, "102820.4");
1965 assert_eq!(bar0.5, "1040.37");
1966 assert_eq!(bar0.6, "10.4037");
1967 assert_eq!(bar0.7, "1069603.34883");
1968 assert_eq!(bar0.8, "1");
1969
1970 let bar1 = &parsed.data[1];
1972 assert_eq!(bar1.0, "1747093980000");
1973 assert_eq!(bar1.5, "7164.04");
1974 assert_eq!(bar1.6, "71.6404");
1975 assert_eq!(bar1.7, "7364701.57952");
1976 assert_eq!(bar1.8, "1");
1977 }
1978
1979 #[rstest]
1980 fn test_parse_mark_price() {
1981 let json_data = load_test_json("http_get_mark_price.json");
1982 let parsed: OKXResponse<OKXMarkPrice> = serde_json::from_str(&json_data).unwrap();
1983
1984 assert_eq!(parsed.code, "0");
1986 assert_eq!(parsed.msg, "");
1987 assert_eq!(parsed.data.len(), 1);
1988
1989 let mark_price = &parsed.data[0];
1991
1992 assert_eq!(mark_price.inst_id, "BTC-USDT-SWAP");
1993 assert_eq!(mark_price.mark_px, "84660.1");
1994 assert_eq!(mark_price.ts, 1744590349506);
1995 }
1996
1997 #[rstest]
1998 fn test_parse_index_price() {
1999 let json_data = load_test_json("http_get_index_price.json");
2000 let parsed: OKXResponse<OKXIndexTicker> = serde_json::from_str(&json_data).unwrap();
2001
2002 assert_eq!(parsed.code, "0");
2004 assert_eq!(parsed.msg, "");
2005 assert_eq!(parsed.data.len(), 1);
2006
2007 let index_price = &parsed.data[0];
2009
2010 assert_eq!(index_price.inst_id, "BTC-USDT");
2011 assert_eq!(index_price.idx_px, "103895");
2012 assert_eq!(index_price.ts, 1746942707815);
2013 }
2014
2015 #[rstest]
2016 fn test_parse_account() {
2017 let json_data = load_test_json("http_get_account_balance.json");
2018 let parsed: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
2019
2020 assert_eq!(parsed.code, "0");
2022 assert_eq!(parsed.msg, "");
2023 assert_eq!(parsed.data.len(), 1);
2024
2025 let account = &parsed.data[0];
2027 assert_eq!(account.adj_eq, "");
2028 assert_eq!(account.borrow_froz, "");
2029 assert_eq!(account.imr, "");
2030 assert_eq!(account.iso_eq, "5.4682385526666675");
2031 assert_eq!(account.mgn_ratio, "");
2032 assert_eq!(account.mmr, "");
2033 assert_eq!(account.notional_usd, "");
2034 assert_eq!(account.notional_usd_for_borrow, "");
2035 assert_eq!(account.notional_usd_for_futures, "");
2036 assert_eq!(account.notional_usd_for_option, "");
2037 assert_eq!(account.notional_usd_for_swap, "");
2038 assert_eq!(account.ord_froz, "");
2039 assert_eq!(account.total_eq, "99.88870288820581");
2040 assert_eq!(account.upl, "");
2041 assert_eq!(account.u_time, 1744499648556);
2042 assert_eq!(account.details.len(), 1);
2043
2044 let detail = &account.details[0];
2045 assert_eq!(detail.ccy, "USDT");
2046 assert_eq!(detail.avail_bal, "94.42612990333333");
2047 assert_eq!(detail.avail_eq, "94.42612990333333");
2048 assert_eq!(detail.cash_bal, "94.42612990333333");
2049 assert_eq!(detail.dis_eq, "5.4682385526666675");
2050 assert_eq!(detail.eq, "99.89469657000001");
2051 assert_eq!(detail.eq_usd, "99.88870288820581");
2052 assert_eq!(detail.fixed_bal, "0");
2053 assert_eq!(detail.frozen_bal, "5.468566666666667");
2054 assert_eq!(detail.imr, "0");
2055 assert_eq!(detail.iso_eq, "5.468566666666667");
2056 assert_eq!(detail.iso_upl, "-0.0273000000000002");
2057 assert_eq!(detail.mmr, "0");
2058 assert_eq!(detail.notional_lever, "0");
2059 assert_eq!(detail.ord_frozen, "0");
2060 assert_eq!(detail.reward_bal, "0");
2061 assert_eq!(detail.smt_sync_eq, "0");
2062 assert_eq!(detail.spot_copy_trading_eq, "0");
2063 assert_eq!(detail.spot_iso_bal, "0");
2064 assert_eq!(detail.stgy_eq, "0");
2065 assert_eq!(detail.twap, "0");
2066 assert_eq!(detail.upl, "-0.0273000000000002");
2067 assert_eq!(detail.u_time, 1744498994783);
2068 }
2069
2070 #[rstest]
2071 fn test_parse_order_history() {
2072 let json_data = load_test_json("http_get_orders_history.json");
2073 let parsed: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
2074
2075 assert_eq!(parsed.code, "0");
2077 assert_eq!(parsed.msg, "");
2078 assert_eq!(parsed.data.len(), 1);
2079
2080 let order = &parsed.data[0];
2082 assert_eq!(order.ord_id, "2497956918703120384");
2083 assert_eq!(order.fill_sz, "0.03");
2084 assert_eq!(order.acc_fill_sz, "0.03");
2085 assert_eq!(order.state, OKXOrderStatus::Filled);
2086 assert!(order.fill_fee.is_none());
2087 }
2088
2089 #[rstest]
2090 fn test_parse_position() {
2091 let json_data = load_test_json("http_get_positions.json");
2092 let parsed: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
2093
2094 assert_eq!(parsed.code, "0");
2096 assert_eq!(parsed.msg, "");
2097 assert_eq!(parsed.data.len(), 1);
2098
2099 let pos = &parsed.data[0];
2101 assert_eq!(pos.inst_id, "BTC-USDT-SWAP");
2102 assert_eq!(pos.pos_side, OKXPositionSide::Long);
2103 assert_eq!(pos.pos, "0.5");
2104 assert_eq!(pos.base_bal, "0.5");
2105 assert_eq!(pos.quote_bal, "5000");
2106 assert_eq!(pos.u_time, 1622559930237);
2107 }
2108
2109 #[rstest]
2110 fn test_parse_position_history() {
2111 let json_data = load_test_json("http_get_account_positions-history.json");
2112 let parsed: OKXResponse<OKXPositionHistory> = serde_json::from_str(&json_data).unwrap();
2113
2114 assert_eq!(parsed.code, "0");
2116 assert_eq!(parsed.msg, "");
2117 assert_eq!(parsed.data.len(), 1);
2118
2119 let hist = &parsed.data[0];
2121 assert_eq!(hist.inst_id, "ETH-USDT-SWAP");
2122 assert_eq!(hist.inst_type, OKXInstrumentType::Swap);
2123 assert_eq!(hist.mgn_mode, OKXMarginMode::Isolated);
2124 assert_eq!(hist.pos_side, OKXPositionSide::Long);
2125 assert_eq!(hist.lever, "3.0");
2126 assert_eq!(hist.open_avg_px, "3226.93");
2127 assert_eq!(hist.close_avg_px.as_deref(), Some("3224.8"));
2128 assert_eq!(hist.pnl.as_deref(), Some("-0.0213"));
2129 assert!(!hist.c_time.is_empty());
2130 assert!(hist.u_time > 0);
2131 }
2132
2133 #[rstest]
2134 fn test_parse_position_tiers() {
2135 let json_data = load_test_json("http_get_position_tiers.json");
2136 let parsed: OKXResponse<OKXPositionTier> = serde_json::from_str(&json_data).unwrap();
2137
2138 assert_eq!(parsed.code, "0");
2140 assert_eq!(parsed.msg, "");
2141 assert_eq!(parsed.data.len(), 1);
2142
2143 let tier = &parsed.data[0];
2145 assert_eq!(tier.inst_id, "BTC-USDT");
2146 assert_eq!(tier.tier, "1");
2147 assert_eq!(tier.min_sz, "0");
2148 assert_eq!(tier.max_sz, "50");
2149 assert_eq!(tier.imr, "0.1");
2150 assert_eq!(tier.mmr, "0.03");
2151 }
2152
2153 #[rstest]
2154 fn test_parse_account_field_name_compatibility() {
2155 let json_new = load_test_json("http_balance_detail_new_fields.json");
2157 let detail_new: OKXBalanceDetail = serde_json::from_str(&json_new).unwrap();
2158 assert_eq!(detail_new.max_spot_in_use_amt, "50.0");
2159 assert_eq!(detail_new.spot_in_use_amt, "30.0");
2160 assert_eq!(detail_new.cl_spot_in_use_amt, "25.0");
2161
2162 let json_old = load_test_json("http_balance_detail_old_fields.json");
2164 let detail_old: OKXBalanceDetail = serde_json::from_str(&json_old).unwrap();
2165 assert_eq!(detail_old.max_spot_in_use_amt, "75.0");
2166 assert_eq!(detail_old.spot_in_use_amt, "40.0");
2167 assert_eq!(detail_old.cl_spot_in_use_amt, "35.0");
2168 }
2169
2170 #[rstest]
2171 fn test_parse_place_order_response() {
2172 let json_data = load_test_json("http_place_order_response.json");
2173 let parsed: OKXPlaceOrderResponse = serde_json::from_str(&json_data).unwrap();
2174 assert_eq!(
2175 parsed.ord_id,
2176 Some(ustr::Ustr::from("12345678901234567890"))
2177 );
2178 assert_eq!(parsed.cl_ord_id, Some(ustr::Ustr::from("client_order_123")));
2179 assert_eq!(parsed.tag, Some("".to_string()));
2180 }
2181
2182 #[rstest]
2183 fn test_parse_transaction_details() {
2184 let json_data = load_test_json("http_transaction_detail.json");
2185 let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
2186 assert_eq!(parsed.inst_type, OKXInstrumentType::Spot);
2187 assert_eq!(parsed.inst_id, Ustr::from("BTC-USDT"));
2188 assert_eq!(parsed.trade_id, Ustr::from("123456789"));
2189 assert_eq!(parsed.ord_id, Ustr::from("987654321"));
2190 assert_eq!(parsed.cl_ord_id, Ustr::from("client_123"));
2191 assert_eq!(parsed.bill_id, Ustr::from("bill_456"));
2192 assert_eq!(parsed.fill_px, "42000.5");
2193 assert_eq!(parsed.fill_sz, "0.001");
2194 assert_eq!(parsed.side, OKXSide::Buy);
2195 assert_eq!(parsed.exec_type, OKXExecType::Taker);
2196 assert_eq!(parsed.fee_ccy, "USDT");
2197 assert_eq!(parsed.fee, Some("0.042".to_string()));
2198 assert_eq!(parsed.ts, 1625097600000);
2199 }
2200
2201 #[rstest]
2202 fn test_parse_empty_fee_field() {
2203 let json_data = load_test_json("http_transaction_detail_empty_fee.json");
2204 let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
2205 assert_eq!(parsed.fee, None);
2206 }
2207
2208 #[rstest]
2209 fn test_parse_optional_string_to_u64() {
2210 use serde::Deserialize;
2211
2212 #[derive(Deserialize)]
2213 struct TestStruct {
2214 #[serde(deserialize_with = "crate::common::parse::deserialize_optional_string_to_u64")]
2215 value: Option<u64>,
2216 }
2217
2218 let json_cases = load_test_json("common_optional_string_to_u64.json");
2219 let cases: Vec<TestStruct> = serde_json::from_str(&json_cases).unwrap();
2220
2221 assert_eq!(cases[0].value, Some(12345));
2222 assert_eq!(cases[1].value, None);
2223 assert_eq!(cases[2].value, None);
2224 }
2225
2226 #[rstest]
2227 fn test_parse_error_handling() {
2228 let invalid_price = "invalid-price";
2230 let result = crate::common::parse::parse_price(invalid_price, 2);
2231 assert!(result.is_err());
2232
2233 let invalid_quantity = "invalid-quantity";
2235 let result = crate::common::parse::parse_quantity(invalid_quantity, 8);
2236 assert!(result.is_err());
2237 }
2238
2239 #[rstest]
2240 fn test_parse_spot_instrument() {
2241 let json_data = load_test_json("http_get_instruments_spot.json");
2242 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2243 let okx_inst: &OKXInstrument = response
2244 .data
2245 .first()
2246 .expect("Test data must have an instrument");
2247
2248 let instrument =
2249 parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2250
2251 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD.OKX"));
2252 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD"));
2253 assert_eq!(instrument.underlying(), None);
2254 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2255 assert_eq!(instrument.quote_currency(), Currency::USD());
2256 assert_eq!(instrument.settlement_currency(), Currency::USD());
2257 assert_eq!(instrument.price_precision(), 1);
2258 assert_eq!(instrument.size_precision(), 8);
2259 assert_eq!(instrument.price_increment(), Price::from("0.1"));
2260 assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
2261 assert_eq!(instrument.multiplier(), Quantity::from(1));
2262 assert_eq!(instrument.lot_size(), Some(Quantity::from("0.00000001")));
2263 assert_eq!(instrument.max_quantity(), Some(Quantity::from(1000000)));
2264 assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.00001")));
2265 assert_eq!(instrument.max_notional(), None);
2266 assert_eq!(instrument.min_notional(), None);
2267 assert_eq!(instrument.max_price(), None);
2268 assert_eq!(instrument.min_price(), None);
2269 }
2270
2271 #[rstest]
2272 fn test_parse_margin_instrument() {
2273 let json_data = load_test_json("http_get_instruments_margin.json");
2274 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2275 let okx_inst: &OKXInstrument = response
2276 .data
2277 .first()
2278 .expect("Test data must have an instrument");
2279
2280 let instrument =
2281 parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2282
2283 assert_eq!(instrument.id(), InstrumentId::from("BTC-USDT.OKX"));
2284 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USDT"));
2285 assert_eq!(instrument.underlying(), None);
2286 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2287 assert_eq!(instrument.quote_currency(), Currency::USDT());
2288 assert_eq!(instrument.settlement_currency(), Currency::USDT());
2289 assert_eq!(instrument.price_precision(), 1);
2290 assert_eq!(instrument.size_precision(), 8);
2291 assert_eq!(instrument.price_increment(), Price::from("0.1"));
2292 assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
2293 assert_eq!(instrument.multiplier(), Quantity::from(1));
2294 assert_eq!(instrument.lot_size(), Some(Quantity::from("0.00000001")));
2295 assert_eq!(instrument.max_quantity(), Some(Quantity::from(1000000)));
2296 assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.00001")));
2297 assert_eq!(instrument.max_notional(), None);
2298 assert_eq!(instrument.min_notional(), None);
2299 assert_eq!(instrument.max_price(), None);
2300 assert_eq!(instrument.min_price(), None);
2301 }
2302
2303 #[rstest]
2304 fn test_parse_spot_instrument_with_valid_ct_mult() {
2305 let json_data = load_test_json("http_get_instruments_spot.json");
2306 let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2307
2308 if let Some(inst) = response.data.first_mut() {
2310 inst.ct_mult = "0.01".to_string();
2311 }
2312
2313 let okx_inst = response.data.first().unwrap();
2314 let instrument =
2315 parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2316
2317 if let InstrumentAny::CurrencyPair(pair) = instrument {
2319 assert_eq!(pair.multiplier, Quantity::from("0.01"));
2320 } else {
2321 panic!("Expected CurrencyPair instrument");
2322 }
2323 }
2324
2325 #[rstest]
2326 fn test_parse_spot_instrument_with_invalid_ct_mult() {
2327 let json_data = load_test_json("http_get_instruments_spot.json");
2328 let mut response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2329
2330 if let Some(inst) = response.data.first_mut() {
2332 inst.ct_mult = "invalid_number".to_string();
2333 }
2334
2335 let okx_inst = response.data.first().unwrap();
2336 let result = parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default());
2337
2338 assert!(result.is_err());
2340 assert!(
2341 result
2342 .unwrap_err()
2343 .to_string()
2344 .contains("Failed to parse `ct_mult`")
2345 );
2346 }
2347
2348 #[rstest]
2349 fn test_parse_spot_instrument_with_fees() {
2350 let json_data = load_test_json("http_get_instruments_spot.json");
2351 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2352 let okx_inst = response.data.first().unwrap();
2353
2354 let maker_fee = Some(Decimal::new(8, 4)); let taker_fee = Some(Decimal::new(10, 4)); let instrument = parse_spot_instrument(
2358 okx_inst,
2359 None,
2360 None,
2361 maker_fee,
2362 taker_fee,
2363 UnixNanos::default(),
2364 )
2365 .unwrap();
2366
2367 if let InstrumentAny::CurrencyPair(pair) = instrument {
2369 assert_eq!(pair.maker_fee, Decimal::new(8, 4));
2370 assert_eq!(pair.taker_fee, Decimal::new(10, 4));
2371 } else {
2372 panic!("Expected CurrencyPair instrument");
2373 }
2374 }
2375
2376 #[rstest]
2377 fn test_parse_swap_instrument() {
2378 let json_data = load_test_json("http_get_instruments_swap.json");
2379 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2380 let okx_inst: &OKXInstrument = response
2381 .data
2382 .first()
2383 .expect("Test data must have an instrument");
2384
2385 let instrument =
2386 parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2387
2388 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-SWAP.OKX"));
2389 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-SWAP"));
2390 assert_eq!(instrument.underlying(), None);
2391 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2392 assert_eq!(instrument.quote_currency(), Currency::USD());
2393 assert_eq!(instrument.settlement_currency(), Currency::BTC());
2394 assert!(instrument.is_inverse());
2395 assert_eq!(instrument.price_precision(), 1);
2396 assert_eq!(instrument.size_precision(), 0);
2397 assert_eq!(instrument.price_increment(), Price::from("0.1"));
2398 assert_eq!(instrument.size_increment(), Quantity::from(1));
2399 assert_eq!(instrument.multiplier(), Quantity::from(100));
2400 assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2401 assert_eq!(instrument.max_quantity(), Some(Quantity::from(30000)));
2402 assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2403 assert_eq!(instrument.max_notional(), None);
2404 assert_eq!(instrument.min_notional(), None);
2405 assert_eq!(instrument.max_price(), None);
2406 assert_eq!(instrument.min_price(), None);
2407 }
2408
2409 #[rstest]
2410 fn test_parse_linear_swap_instrument() {
2411 let json_data = load_test_json("http_get_instruments_swap.json");
2412 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2413
2414 let okx_inst = response
2415 .data
2416 .iter()
2417 .find(|i| i.inst_id == "ETH-USDT-SWAP")
2418 .expect("ETH-USDT-SWAP must be in test data");
2419
2420 let instrument =
2421 parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
2422
2423 assert_eq!(instrument.id(), InstrumentId::from("ETH-USDT-SWAP.OKX"));
2424 assert_eq!(instrument.raw_symbol(), Symbol::from("ETH-USDT-SWAP"));
2425 assert_eq!(instrument.base_currency(), Some(Currency::ETH()));
2426 assert_eq!(instrument.quote_currency(), Currency::USDT());
2427 assert_eq!(instrument.settlement_currency(), Currency::USDT());
2428 assert!(!instrument.is_inverse());
2429 assert_eq!(instrument.multiplier(), Quantity::from("0.1"));
2430 assert_eq!(instrument.price_precision(), 2);
2431 assert_eq!(instrument.size_precision(), 2);
2432 assert_eq!(instrument.price_increment(), Price::from("0.01"));
2433 assert_eq!(instrument.size_increment(), Quantity::from("0.01"));
2434 assert_eq!(instrument.lot_size(), Some(Quantity::from("0.01")));
2435 assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.01")));
2436 assert_eq!(instrument.max_quantity(), Some(Quantity::from(20000)));
2437 }
2438
2439 #[rstest]
2440 fn test_fee_field_selection_for_contract_types() {
2441 use rust_decimal::Decimal;
2442
2443 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;
2451 let (maker_str, taker_str) = if is_usdt_margined {
2452 (maker_usdt, taker_usdt)
2453 } else {
2454 (maker_crypto, taker_crypto)
2455 };
2456
2457 assert_eq!(maker_str, "0.0008");
2458 assert_eq!(taker_str, "0.0010");
2459
2460 let maker_fee = Decimal::from_str(maker_str).unwrap();
2461 let taker_fee = Decimal::from_str(taker_str).unwrap();
2462
2463 assert_eq!(maker_fee, Decimal::new(8, 4));
2464 assert_eq!(taker_fee, Decimal::new(10, 4));
2465
2466 let is_usdt_margined = false;
2468 let (maker_str, taker_str) = if is_usdt_margined {
2469 (maker_usdt, taker_usdt)
2470 } else {
2471 (maker_crypto, taker_crypto)
2472 };
2473
2474 assert_eq!(maker_str, "0.0002");
2475 assert_eq!(taker_str, "0.0005");
2476
2477 let maker_fee = Decimal::from_str(maker_str).unwrap();
2478 let taker_fee = Decimal::from_str(taker_str).unwrap();
2479
2480 assert_eq!(maker_fee, Decimal::new(2, 4));
2481 assert_eq!(taker_fee, Decimal::new(5, 4));
2482 }
2483
2484 #[rstest]
2485 fn test_parse_futures_instrument() {
2486 let json_data = load_test_json("http_get_instruments_futures.json");
2487 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2488 let okx_inst: &OKXInstrument = response
2489 .data
2490 .first()
2491 .expect("Test data must have an instrument");
2492
2493 let instrument =
2494 parse_futures_instrument(okx_inst, None, None, None, None, UnixNanos::default())
2495 .unwrap();
2496
2497 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-241220.OKX"));
2498 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-241220"));
2499 assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
2500 assert_eq!(instrument.quote_currency(), Currency::USD());
2501 assert_eq!(instrument.settlement_currency(), Currency::BTC());
2502 assert!(instrument.is_inverse());
2503 assert_eq!(instrument.price_precision(), 1);
2504 assert_eq!(instrument.size_precision(), 0);
2505 assert_eq!(instrument.price_increment(), Price::from("0.1"));
2506 assert_eq!(instrument.size_increment(), Quantity::from(1));
2507 assert_eq!(instrument.multiplier(), Quantity::from(100));
2508 assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2509 assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2510 assert_eq!(instrument.max_quantity(), Some(Quantity::from(10000)));
2511 }
2512
2513 #[rstest]
2514 fn test_parse_option_instrument() {
2515 let json_data = load_test_json("http_get_instruments_option.json");
2516 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
2517 let okx_inst: &OKXInstrument = response
2518 .data
2519 .first()
2520 .expect("Test data must have an instrument");
2521
2522 let instrument =
2523 parse_option_instrument(okx_inst, None, None, None, None, UnixNanos::default())
2524 .unwrap();
2525
2526 assert_eq!(
2527 instrument.id(),
2528 InstrumentId::from("BTC-USD-241217-92000-C.OKX")
2529 );
2530 assert_eq!(
2531 instrument.raw_symbol(),
2532 Symbol::from("BTC-USD-241217-92000-C")
2533 );
2534 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
2535 assert_eq!(instrument.quote_currency(), Currency::USD());
2536 assert_eq!(instrument.settlement_currency(), Currency::BTC());
2537 assert!(instrument.is_inverse());
2538 assert_eq!(instrument.price_precision(), 4);
2539 assert_eq!(instrument.size_precision(), 0);
2540 assert_eq!(instrument.price_increment(), Price::from("0.0001"));
2541 assert_eq!(instrument.size_increment(), Quantity::from(1));
2542 assert_eq!(instrument.multiplier(), Quantity::from("0.01"));
2543 assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
2544 assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
2545 assert_eq!(instrument.max_quantity(), Some(Quantity::from(5000)));
2546 assert_eq!(instrument.max_notional(), None);
2547 assert_eq!(instrument.min_notional(), None);
2548 assert_eq!(instrument.max_price(), None);
2549 assert_eq!(instrument.min_price(), None);
2550 }
2551
2552 #[rstest]
2553 fn test_parse_account_state() {
2554 let json_data = load_test_json("http_get_account_balance.json");
2555 let response: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
2556 let okx_account = response
2557 .data
2558 .first()
2559 .expect("Test data must have an account");
2560
2561 let account_id = AccountId::new("OKX-001");
2562 let account_state =
2563 parse_account_state(okx_account, account_id, UnixNanos::default()).unwrap();
2564
2565 assert_eq!(account_state.account_id, account_id);
2566 assert_eq!(account_state.account_type, AccountType::Margin);
2567 assert_eq!(account_state.balances.len(), 1);
2568 assert_eq!(account_state.margins.len(), 0); assert!(account_state.is_reported);
2570
2571 let usdt_balance = &account_state.balances[0];
2573 assert_eq!(
2574 usdt_balance.total,
2575 Money::new(94.42612990333333, Currency::USDT())
2576 );
2577 assert_eq!(
2578 usdt_balance.free,
2579 Money::new(94.42612990333333, Currency::USDT())
2580 );
2581 assert_eq!(usdt_balance.locked, Money::new(0.0, Currency::USDT()));
2582 }
2583
2584 #[rstest]
2585 fn test_parse_account_state_with_margins() {
2586 let account_json = r#"{
2588 "adjEq": "10000.0",
2589 "borrowFroz": "0",
2590 "details": [{
2591 "accAvgPx": "",
2592 "availBal": "8000.0",
2593 "availEq": "8000.0",
2594 "borrowFroz": "0",
2595 "cashBal": "10000.0",
2596 "ccy": "USDT",
2597 "clSpotInUseAmt": "0",
2598 "coinUsdPrice": "1.0",
2599 "colBorrAutoConversion": "0",
2600 "collateralEnabled": false,
2601 "collateralRestrict": false,
2602 "crossLiab": "0",
2603 "disEq": "10000.0",
2604 "eq": "10000.0",
2605 "eqUsd": "10000.0",
2606 "fixedBal": "0",
2607 "frozenBal": "2000.0",
2608 "imr": "0",
2609 "interest": "0",
2610 "isoEq": "0",
2611 "isoLiab": "0",
2612 "isoUpl": "0",
2613 "liab": "0",
2614 "maxLoan": "0",
2615 "mgnRatio": "0",
2616 "maxSpotInUseAmt": "0",
2617 "mmr": "0",
2618 "notionalLever": "0",
2619 "openAvgPx": "",
2620 "ordFrozen": "2000.0",
2621 "rewardBal": "0",
2622 "smtSyncEq": "0",
2623 "spotBal": "0",
2624 "spotCopyTradingEq": "0",
2625 "spotInUseAmt": "0",
2626 "spotIsoBal": "0",
2627 "spotUpl": "0",
2628 "spotUplRatio": "0",
2629 "stgyEq": "0",
2630 "totalPnl": "0",
2631 "totalPnlRatio": "0",
2632 "twap": "0",
2633 "uTime": "1704067200000",
2634 "upl": "0",
2635 "uplLiab": "0"
2636 }],
2637 "imr": "500.25",
2638 "isoEq": "0",
2639 "mgnRatio": "20.5",
2640 "mmr": "250.75",
2641 "notionalUsd": "5000.0",
2642 "notionalUsdForBorrow": "0",
2643 "notionalUsdForFutures": "0",
2644 "notionalUsdForOption": "0",
2645 "notionalUsdForSwap": "5000.0",
2646 "ordFroz": "2000.0",
2647 "totalEq": "10000.0",
2648 "uTime": "1704067200000",
2649 "upl": "0"
2650 }"#;
2651
2652 let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
2653 let account_id = AccountId::new("OKX-001");
2654 let account_state =
2655 parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
2656
2657 assert_eq!(account_state.account_id, account_id);
2659 assert_eq!(account_state.account_type, AccountType::Margin);
2660 assert_eq!(account_state.balances.len(), 1);
2661
2662 assert_eq!(account_state.margins.len(), 1);
2664 let margin = &account_state.margins[0];
2665
2666 assert_eq!(margin.initial, Money::new(500.25, Currency::USD()));
2668 assert_eq!(margin.maintenance, Money::new(250.75, Currency::USD()));
2669 assert_eq!(margin.currency, Currency::USD());
2670 assert_eq!(margin.instrument_id.symbol.as_str(), "ACCOUNT");
2671 assert_eq!(margin.instrument_id.venue.as_str(), "OKX");
2672
2673 let usdt_balance = &account_state.balances[0];
2675 assert_eq!(usdt_balance.total, Money::new(10000.0, Currency::USDT()));
2676 assert_eq!(usdt_balance.free, Money::new(8000.0, Currency::USDT()));
2677 assert_eq!(usdt_balance.locked, Money::new(2000.0, Currency::USDT()));
2678 }
2679
2680 #[rstest]
2681 fn test_parse_account_state_empty_margins() {
2682 let account_json = r#"{
2684 "adjEq": "",
2685 "borrowFroz": "",
2686 "details": [{
2687 "accAvgPx": "",
2688 "availBal": "1000.0",
2689 "availEq": "1000.0",
2690 "borrowFroz": "0",
2691 "cashBal": "1000.0",
2692 "ccy": "BTC",
2693 "clSpotInUseAmt": "0",
2694 "coinUsdPrice": "50000.0",
2695 "colBorrAutoConversion": "0",
2696 "collateralEnabled": false,
2697 "collateralRestrict": false,
2698 "crossLiab": "0",
2699 "disEq": "50000.0",
2700 "eq": "1000.0",
2701 "eqUsd": "50000.0",
2702 "fixedBal": "0",
2703 "frozenBal": "0",
2704 "imr": "0",
2705 "interest": "0",
2706 "isoEq": "0",
2707 "isoLiab": "0",
2708 "isoUpl": "0",
2709 "liab": "0",
2710 "maxLoan": "0",
2711 "mgnRatio": "0",
2712 "maxSpotInUseAmt": "0",
2713 "mmr": "0",
2714 "notionalLever": "0",
2715 "openAvgPx": "",
2716 "ordFrozen": "0",
2717 "rewardBal": "0",
2718 "smtSyncEq": "0",
2719 "spotBal": "0",
2720 "spotCopyTradingEq": "0",
2721 "spotInUseAmt": "0",
2722 "spotIsoBal": "0",
2723 "spotUpl": "0",
2724 "spotUplRatio": "0",
2725 "stgyEq": "0",
2726 "totalPnl": "0",
2727 "totalPnlRatio": "0",
2728 "twap": "0",
2729 "uTime": "1704067200000",
2730 "upl": "0",
2731 "uplLiab": "0"
2732 }],
2733 "imr": "",
2734 "isoEq": "0",
2735 "mgnRatio": "",
2736 "mmr": "",
2737 "notionalUsd": "",
2738 "notionalUsdForBorrow": "",
2739 "notionalUsdForFutures": "",
2740 "notionalUsdForOption": "",
2741 "notionalUsdForSwap": "",
2742 "ordFroz": "",
2743 "totalEq": "50000.0",
2744 "uTime": "1704067200000",
2745 "upl": "0"
2746 }"#;
2747
2748 let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
2749 let account_id = AccountId::new("OKX-SPOT");
2750 let account_state =
2751 parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
2752
2753 assert_eq!(account_state.margins.len(), 0);
2755 assert_eq!(account_state.balances.len(), 1);
2756
2757 let btc_balance = &account_state.balances[0];
2759 assert_eq!(btc_balance.total, Money::new(1000.0, Currency::BTC()));
2760 }
2761
2762 #[rstest]
2763 fn test_parse_order_status_report() {
2764 let json_data = load_test_json("http_get_orders_history.json");
2765 let response: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
2766 let okx_order = response
2767 .data
2768 .first()
2769 .expect("Test data must have an order")
2770 .clone();
2771
2772 let account_id = AccountId::new("OKX-001");
2773 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2774 let order_report = parse_order_status_report(
2775 &okx_order,
2776 account_id,
2777 instrument_id,
2778 2,
2779 8,
2780 UnixNanos::default(),
2781 );
2782
2783 assert_eq!(order_report.account_id, account_id);
2784 assert_eq!(order_report.instrument_id, instrument_id);
2785 assert_eq!(order_report.quantity, Quantity::from("0.03000000"));
2786 assert_eq!(order_report.filled_qty, Quantity::from("0.03000000"));
2787 assert_eq!(order_report.order_side, OrderSide::Buy);
2788 assert_eq!(order_report.order_type, OrderType::Market);
2789 assert_eq!(order_report.order_status, OrderStatus::Filled);
2790 }
2791
2792 #[rstest]
2793 fn test_parse_position_status_report() {
2794 let json_data = load_test_json("http_get_positions.json");
2795 let response: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
2796 let okx_position = response
2797 .data
2798 .first()
2799 .expect("Test data must have a position")
2800 .clone();
2801
2802 let account_id = AccountId::new("OKX-001");
2803 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2804 let position_report = parse_position_status_report(
2805 okx_position,
2806 account_id,
2807 instrument_id,
2808 8,
2809 UnixNanos::default(),
2810 )
2811 .unwrap();
2812
2813 assert_eq!(position_report.account_id, account_id);
2814 assert_eq!(position_report.instrument_id, instrument_id);
2815 }
2816
2817 #[rstest]
2818 fn test_parse_trade_tick() {
2819 let json_data = load_test_json("http_get_trades.json");
2820 let response: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
2821 let okx_trade = response.data.first().expect("Test data must have a trade");
2822
2823 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2824 let trade_tick =
2825 parse_trade_tick(okx_trade, instrument_id, 2, 8, UnixNanos::default()).unwrap();
2826
2827 assert_eq!(trade_tick.instrument_id, instrument_id);
2828 assert_eq!(trade_tick.price, Price::from("102537.90"));
2829 assert_eq!(trade_tick.size, Quantity::from("0.00013669"));
2830 assert_eq!(trade_tick.aggressor_side, AggressorSide::Seller);
2831 assert_eq!(trade_tick.trade_id, TradeId::new("734864333"));
2832 }
2833
2834 #[rstest]
2835 fn test_parse_mark_price_update() {
2836 let json_data = load_test_json("http_get_mark_price.json");
2837 let response: OKXResponse<crate::http::models::OKXMarkPrice> =
2838 serde_json::from_str(&json_data).unwrap();
2839 let okx_mark_price = response
2840 .data
2841 .first()
2842 .expect("Test data must have a mark price");
2843
2844 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2845 let mark_price_update =
2846 parse_mark_price_update(okx_mark_price, instrument_id, 2, UnixNanos::default())
2847 .unwrap();
2848
2849 assert_eq!(mark_price_update.instrument_id, instrument_id);
2850 assert_eq!(mark_price_update.value, Price::from("84660.10"));
2851 assert_eq!(
2852 mark_price_update.ts_event,
2853 UnixNanos::from(1744590349506000000)
2854 );
2855 }
2856
2857 #[rstest]
2858 fn test_parse_index_price_update() {
2859 let json_data = load_test_json("http_get_index_price.json");
2860 let response: OKXResponse<crate::http::models::OKXIndexTicker> =
2861 serde_json::from_str(&json_data).unwrap();
2862 let okx_index_ticker = response
2863 .data
2864 .first()
2865 .expect("Test data must have an index ticker");
2866
2867 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2868 let index_price_update =
2869 parse_index_price_update(okx_index_ticker, instrument_id, 2, UnixNanos::default())
2870 .unwrap();
2871
2872 assert_eq!(index_price_update.instrument_id, instrument_id);
2873 assert_eq!(index_price_update.value, Price::from("103895.00"));
2874 assert_eq!(
2875 index_price_update.ts_event,
2876 UnixNanos::from(1746942707815000000)
2877 );
2878 }
2879
2880 #[rstest]
2881 fn test_parse_candlestick() {
2882 let json_data = load_test_json("http_get_candlesticks.json");
2883 let response: OKXResponse<crate::http::models::OKXCandlestick> =
2884 serde_json::from_str(&json_data).unwrap();
2885 let okx_candlestick = response
2886 .data
2887 .first()
2888 .expect("Test data must have a candlestick");
2889
2890 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2891 let bar_type = BarType::new(
2892 instrument_id,
2893 BAR_SPEC_1_DAY_LAST,
2894 AggregationSource::External,
2895 );
2896 let bar = parse_candlestick(okx_candlestick, bar_type, 2, 8, UnixNanos::default()).unwrap();
2897
2898 assert_eq!(bar.bar_type, bar_type);
2899 assert_eq!(bar.open, Price::from("33528.60"));
2900 assert_eq!(bar.high, Price::from("33870.00"));
2901 assert_eq!(bar.low, Price::from("33528.60"));
2902 assert_eq!(bar.close, Price::from("33783.90"));
2903 assert_eq!(bar.volume, Quantity::from("778.83800000"));
2904 assert_eq!(bar.ts_event, UnixNanos::from(1625097600000000000));
2905 }
2906
2907 #[rstest]
2908 fn test_parse_millisecond_timestamp() {
2909 let timestamp_ms = 1625097600000u64;
2910 let result = parse_millisecond_timestamp(timestamp_ms);
2911 assert_eq!(result, UnixNanos::from(1625097600000000000));
2912 }
2913
2914 #[rstest]
2915 fn test_parse_rfc3339_timestamp() {
2916 let timestamp_str = "2021-07-01T00:00:00.000Z";
2917 let result = parse_rfc3339_timestamp(timestamp_str).unwrap();
2918 assert_eq!(result, UnixNanos::from(1625097600000000000));
2919
2920 let timestamp_str_tz = "2021-07-01T08:00:00.000+08:00";
2922 let result_tz = parse_rfc3339_timestamp(timestamp_str_tz).unwrap();
2923 assert_eq!(result_tz, UnixNanos::from(1625097600000000000));
2924
2925 let invalid_timestamp = "invalid-timestamp";
2927 assert!(parse_rfc3339_timestamp(invalid_timestamp).is_err());
2928 }
2929
2930 #[rstest]
2931 fn test_parse_price() {
2932 let price_str = "42219.5";
2933 let precision = 2;
2934 let result = parse_price(price_str, precision).unwrap();
2935 assert_eq!(result, Price::from("42219.50"));
2936
2937 let invalid_price = "invalid-price";
2939 assert!(parse_price(invalid_price, precision).is_err());
2940 }
2941
2942 #[rstest]
2943 fn test_parse_quantity() {
2944 let quantity_str = "0.12345678";
2945 let precision = 8;
2946 let result = parse_quantity(quantity_str, precision).unwrap();
2947 assert_eq!(result, Quantity::from("0.12345678"));
2948
2949 let invalid_quantity = "invalid-quantity";
2951 assert!(parse_quantity(invalid_quantity, precision).is_err());
2952 }
2953
2954 #[rstest]
2955 fn test_parse_aggressor_side() {
2956 assert_eq!(
2957 parse_aggressor_side(&Some(OKXSide::Buy)),
2958 AggressorSide::Buyer
2959 );
2960 assert_eq!(
2961 parse_aggressor_side(&Some(OKXSide::Sell)),
2962 AggressorSide::Seller
2963 );
2964 assert_eq!(parse_aggressor_side(&None), AggressorSide::NoAggressor);
2965 }
2966
2967 #[rstest]
2968 fn test_parse_execution_type() {
2969 assert_eq!(
2970 parse_execution_type(&Some(OKXExecType::Maker)),
2971 LiquiditySide::Maker
2972 );
2973 assert_eq!(
2974 parse_execution_type(&Some(OKXExecType::Taker)),
2975 LiquiditySide::Taker
2976 );
2977 assert_eq!(parse_execution_type(&None), LiquiditySide::NoLiquiditySide);
2978 }
2979
2980 #[rstest]
2981 fn test_parse_position_side() {
2982 assert_eq!(parse_position_side(Some(100)), PositionSide::Long);
2983 assert_eq!(parse_position_side(Some(-100)), PositionSide::Short);
2984 assert_eq!(parse_position_side(Some(0)), PositionSide::Flat);
2985 assert_eq!(parse_position_side(None), PositionSide::Flat);
2986 }
2987
2988 #[rstest]
2989 fn test_parse_client_order_id() {
2990 let valid_id = "client_order_123";
2991 let result = parse_client_order_id(valid_id);
2992 assert_eq!(result, Some(ClientOrderId::new(valid_id)));
2993
2994 let empty_id = "";
2995 let result_empty = parse_client_order_id(empty_id);
2996 assert_eq!(result_empty, None);
2997 }
2998
2999 #[rstest]
3000 fn test_deserialize_empty_string_as_none() {
3001 let json_with_empty = r#""""#;
3002 let result: Option<String> = serde_json::from_str(json_with_empty).unwrap();
3003 let processed = result.filter(|s| !s.is_empty());
3004 assert_eq!(processed, None);
3005
3006 let json_with_value = r#""test_value""#;
3007 let result: Option<String> = serde_json::from_str(json_with_value).unwrap();
3008 let processed = result.filter(|s| !s.is_empty());
3009 assert_eq!(processed, Some("test_value".to_string()));
3010 }
3011
3012 #[rstest]
3013 fn test_deserialize_string_to_u64() {
3014 use serde::Deserialize;
3015
3016 #[derive(Deserialize)]
3017 struct TestStruct {
3018 #[serde(deserialize_with = "deserialize_string_to_u64")]
3019 value: u64,
3020 }
3021
3022 let json_value = r#"{"value": "12345"}"#;
3023 let result: TestStruct = serde_json::from_str(json_value).unwrap();
3024 assert_eq!(result.value, 12345);
3025
3026 let json_empty = r#"{"value": ""}"#;
3027 let result_empty: TestStruct = serde_json::from_str(json_empty).unwrap();
3028 assert_eq!(result_empty.value, 0);
3029 }
3030
3031 #[rstest]
3032 fn test_fill_report_parsing() {
3033 let transaction_detail = crate::http::models::OKXTransactionDetail {
3035 inst_type: OKXInstrumentType::Spot,
3036 inst_id: Ustr::from("BTC-USDT"),
3037 trade_id: Ustr::from("12345"),
3038 ord_id: Ustr::from("67890"),
3039 cl_ord_id: Ustr::from("client_123"),
3040 bill_id: Ustr::from("bill_456"),
3041 fill_px: "42219.5".to_string(),
3042 fill_sz: "0.001".to_string(),
3043 side: OKXSide::Buy,
3044 exec_type: OKXExecType::Taker,
3045 fee_ccy: "USDT".to_string(),
3046 fee: Some("0.042".to_string()),
3047 ts: 1625097600000,
3048 };
3049
3050 let account_id = AccountId::new("OKX-001");
3051 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3052 let fill_report = parse_fill_report(
3053 transaction_detail,
3054 account_id,
3055 instrument_id,
3056 2,
3057 8,
3058 UnixNanos::default(),
3059 )
3060 .unwrap();
3061
3062 assert_eq!(fill_report.account_id, account_id);
3063 assert_eq!(fill_report.instrument_id, instrument_id);
3064 assert_eq!(fill_report.trade_id, TradeId::new("12345"));
3065 assert_eq!(fill_report.venue_order_id, VenueOrderId::new("67890"));
3066 assert_eq!(fill_report.order_side, OrderSide::Buy);
3067 assert_eq!(fill_report.last_px, Price::from("42219.50"));
3068 assert_eq!(fill_report.last_qty, Quantity::from("0.00100000"));
3069 assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
3070 }
3071
3072 #[rstest]
3073 fn test_bar_type_identity_preserved_through_parse() {
3074 use std::str::FromStr;
3075
3076 use crate::http::models::OKXCandlestick;
3077
3078 let bar_type = BarType::from_str("ETH-USDT-SWAP.OKX-1-MINUTE-LAST-EXTERNAL").unwrap();
3080
3081 let raw_candlestick = OKXCandlestick(
3083 "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(), );
3093
3094 let bar =
3096 parse_candlestick(&raw_candlestick, bar_type, 1, 3, UnixNanos::default()).unwrap();
3097
3098 assert_eq!(
3100 bar.bar_type, bar_type,
3101 "BarType must be preserved exactly through parsing"
3102 );
3103 }
3104
3105 #[rstest]
3106 fn test_deserialize_vip_level_all_formats() {
3107 use serde::Deserialize;
3108 use serde_json;
3109
3110 #[derive(Deserialize)]
3111 struct TestFeeRate {
3112 #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3113 level: OKXVipLevel,
3114 }
3115
3116 let json = r#"{"level":"VIP4"}"#;
3118 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3119 assert_eq!(result.level, OKXVipLevel::Vip4);
3120
3121 let json = r#"{"level":"VIP5"}"#;
3122 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3123 assert_eq!(result.level, OKXVipLevel::Vip5);
3124
3125 let json = r#"{"level":"Lv1"}"#;
3127 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3128 assert_eq!(result.level, OKXVipLevel::Vip1);
3129
3130 let json = r#"{"level":"Lv0"}"#;
3131 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3132 assert_eq!(result.level, OKXVipLevel::Vip0);
3133
3134 let json = r#"{"level":"Lv9"}"#;
3135 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3136 assert_eq!(result.level, OKXVipLevel::Vip9);
3137 }
3138
3139 #[rstest]
3140 fn test_deserialize_vip_level_empty_string() {
3141 use serde::Deserialize;
3142 use serde_json;
3143
3144 #[derive(Deserialize)]
3145 struct TestFeeRate {
3146 #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3147 level: OKXVipLevel,
3148 }
3149
3150 let json = r#"{"level":""}"#;
3152 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3153 assert_eq!(result.level, OKXVipLevel::Vip0);
3154 }
3155
3156 #[rstest]
3157 fn test_deserialize_vip_level_without_prefix() {
3158 use serde::Deserialize;
3159 use serde_json;
3160
3161 #[derive(Deserialize)]
3162 struct TestFeeRate {
3163 #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
3164 level: OKXVipLevel,
3165 }
3166
3167 let json = r#"{"level":"5"}"#;
3168 let result: TestFeeRate = serde_json::from_str(json).unwrap();
3169 assert_eq!(result.level, OKXVipLevel::Vip5);
3170 }
3171
3172 #[rstest]
3173 fn test_parse_position_status_report_net_mode_long() {
3174 let position = OKXPosition {
3176 inst_id: Ustr::from("BTC-USDT-SWAP"),
3177 inst_type: OKXInstrumentType::Swap,
3178 mgn_mode: OKXMarginMode::Cross,
3179 pos_id: Some(Ustr::from("12345")),
3180 pos_side: OKXPositionSide::Net, pos: "1.5".to_string(), base_bal: "1.5".to_string(),
3183 ccy: "BTC".to_string(),
3184 fee: "0.01".to_string(),
3185 lever: "10.0".to_string(),
3186 last: "50000".to_string(),
3187 mark_px: "50000".to_string(),
3188 liq_px: "45000".to_string(),
3189 mmr: "0.1".to_string(),
3190 interest: "0".to_string(),
3191 trade_id: Ustr::from("111"),
3192 notional_usd: "75000".to_string(),
3193 avg_px: "50000".to_string(),
3194 upl: "0".to_string(),
3195 upl_ratio: "0".to_string(),
3196 u_time: 1622559930237,
3197 margin: "0.5".to_string(),
3198 mgn_ratio: "0.01".to_string(),
3199 adl: "0".to_string(),
3200 c_time: "1622559930237".to_string(),
3201 realized_pnl: "0".to_string(),
3202 upl_last_px: "0".to_string(),
3203 upl_ratio_last_px: "0".to_string(),
3204 avail_pos: "1.5".to_string(),
3205 be_px: "0".to_string(),
3206 funding_fee: "0".to_string(),
3207 idx_px: "0".to_string(),
3208 liq_penalty: "0".to_string(),
3209 opt_val: "0".to_string(),
3210 pending_close_ord_liab_val: "0".to_string(),
3211 pnl: "0".to_string(),
3212 pos_ccy: "BTC".to_string(),
3213 quote_bal: "75000".to_string(),
3214 quote_borrowed: "0".to_string(),
3215 quote_interest: "0".to_string(),
3216 spot_in_use_amt: "0".to_string(),
3217 spot_in_use_ccy: "BTC".to_string(),
3218 usd_px: "50000".to_string(),
3219 };
3220
3221 let account_id = AccountId::new("OKX-001");
3222 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3223 let report = parse_position_status_report(
3224 position,
3225 account_id,
3226 instrument_id,
3227 8,
3228 UnixNanos::default(),
3229 )
3230 .unwrap();
3231
3232 assert_eq!(report.account_id, account_id);
3233 assert_eq!(report.instrument_id, instrument_id);
3234 assert_eq!(report.position_side, PositionSide::Long.as_specified());
3235 assert_eq!(report.quantity, Quantity::from("1.5"));
3236 assert_eq!(report.venue_position_id, None);
3238 }
3239
3240 #[rstest]
3241 fn test_parse_position_status_report_net_mode_short() {
3242 let position = OKXPosition {
3244 inst_id: Ustr::from("BTC-USDT-SWAP"),
3245 inst_type: OKXInstrumentType::Swap,
3246 mgn_mode: OKXMarginMode::Isolated,
3247 pos_id: Some(Ustr::from("67890")),
3248 pos_side: OKXPositionSide::Net, pos: "-2.3".to_string(), base_bal: "2.3".to_string(),
3251 ccy: "BTC".to_string(),
3252 fee: "0.02".to_string(),
3253 lever: "5.0".to_string(),
3254 last: "50000".to_string(),
3255 mark_px: "50000".to_string(),
3256 liq_px: "55000".to_string(),
3257 mmr: "0.2".to_string(),
3258 interest: "0".to_string(),
3259 trade_id: Ustr::from("222"),
3260 notional_usd: "115000".to_string(),
3261 avg_px: "50000".to_string(),
3262 upl: "0".to_string(),
3263 upl_ratio: "0".to_string(),
3264 u_time: 1622559930237,
3265 margin: "1.0".to_string(),
3266 mgn_ratio: "0.02".to_string(),
3267 adl: "0".to_string(),
3268 c_time: "1622559930237".to_string(),
3269 realized_pnl: "0".to_string(),
3270 upl_last_px: "0".to_string(),
3271 upl_ratio_last_px: "0".to_string(),
3272 avail_pos: "2.3".to_string(),
3273 be_px: "0".to_string(),
3274 funding_fee: "0".to_string(),
3275 idx_px: "0".to_string(),
3276 liq_penalty: "0".to_string(),
3277 opt_val: "0".to_string(),
3278 pending_close_ord_liab_val: "0".to_string(),
3279 pnl: "0".to_string(),
3280 pos_ccy: "BTC".to_string(),
3281 quote_bal: "115000".to_string(),
3282 quote_borrowed: "0".to_string(),
3283 quote_interest: "0".to_string(),
3284 spot_in_use_amt: "0".to_string(),
3285 spot_in_use_ccy: "BTC".to_string(),
3286 usd_px: "50000".to_string(),
3287 };
3288
3289 let account_id = AccountId::new("OKX-001");
3290 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3291 let report = parse_position_status_report(
3292 position,
3293 account_id,
3294 instrument_id,
3295 8,
3296 UnixNanos::default(),
3297 )
3298 .unwrap();
3299
3300 assert_eq!(report.account_id, account_id);
3301 assert_eq!(report.instrument_id, instrument_id);
3302 assert_eq!(report.position_side, PositionSide::Short.as_specified());
3303 assert_eq!(report.quantity, Quantity::from("2.3")); assert_eq!(report.venue_position_id, None);
3306 }
3307
3308 #[rstest]
3309 fn test_parse_position_status_report_net_mode_flat() {
3310 let position = OKXPosition {
3312 inst_id: Ustr::from("ETH-USDT-SWAP"),
3313 inst_type: OKXInstrumentType::Swap,
3314 mgn_mode: OKXMarginMode::Cross,
3315 pos_id: Some(Ustr::from("99999")),
3316 pos_side: OKXPositionSide::Net, pos: "0".to_string(), base_bal: "0".to_string(),
3319 ccy: "ETH".to_string(),
3320 fee: "0".to_string(),
3321 lever: "10.0".to_string(),
3322 last: "3000".to_string(),
3323 mark_px: "3000".to_string(),
3324 liq_px: "0".to_string(),
3325 mmr: "0".to_string(),
3326 interest: "0".to_string(),
3327 trade_id: Ustr::from("333"),
3328 notional_usd: "0".to_string(),
3329 avg_px: "".to_string(),
3330 upl: "0".to_string(),
3331 upl_ratio: "0".to_string(),
3332 u_time: 1622559930237,
3333 margin: "0".to_string(),
3334 mgn_ratio: "0".to_string(),
3335 adl: "0".to_string(),
3336 c_time: "1622559930237".to_string(),
3337 realized_pnl: "0".to_string(),
3338 upl_last_px: "0".to_string(),
3339 upl_ratio_last_px: "0".to_string(),
3340 avail_pos: "0".to_string(),
3341 be_px: "0".to_string(),
3342 funding_fee: "0".to_string(),
3343 idx_px: "0".to_string(),
3344 liq_penalty: "0".to_string(),
3345 opt_val: "0".to_string(),
3346 pending_close_ord_liab_val: "0".to_string(),
3347 pnl: "0".to_string(),
3348 pos_ccy: "ETH".to_string(),
3349 quote_bal: "0".to_string(),
3350 quote_borrowed: "0".to_string(),
3351 quote_interest: "0".to_string(),
3352 spot_in_use_amt: "0".to_string(),
3353 spot_in_use_ccy: "ETH".to_string(),
3354 usd_px: "3000".to_string(),
3355 };
3356
3357 let account_id = AccountId::new("OKX-001");
3358 let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
3359 let report = parse_position_status_report(
3360 position,
3361 account_id,
3362 instrument_id,
3363 8,
3364 UnixNanos::default(),
3365 )
3366 .unwrap();
3367
3368 assert_eq!(report.account_id, account_id);
3369 assert_eq!(report.instrument_id, instrument_id);
3370 assert_eq!(report.position_side, PositionSide::Flat.as_specified());
3371 assert_eq!(report.quantity, Quantity::from("0"));
3372 assert_eq!(report.venue_position_id, None);
3374 }
3375
3376 #[rstest]
3377 fn test_parse_position_status_report_long_short_mode_long() {
3378 let position = OKXPosition {
3380 inst_id: Ustr::from("BTC-USDT-SWAP"),
3381 inst_type: OKXInstrumentType::Swap,
3382 mgn_mode: OKXMarginMode::Cross,
3383 pos_id: Some(Ustr::from("11111")),
3384 pos_side: OKXPositionSide::Long, pos: "3.2".to_string(), base_bal: "3.2".to_string(),
3387 ccy: "BTC".to_string(),
3388 fee: "0.01".to_string(),
3389 lever: "10.0".to_string(),
3390 last: "50000".to_string(),
3391 mark_px: "50000".to_string(),
3392 liq_px: "45000".to_string(),
3393 mmr: "0.1".to_string(),
3394 interest: "0".to_string(),
3395 trade_id: Ustr::from("444"),
3396 notional_usd: "160000".to_string(),
3397 avg_px: "50000".to_string(),
3398 upl: "0".to_string(),
3399 upl_ratio: "0".to_string(),
3400 u_time: 1622559930237,
3401 margin: "1.6".to_string(),
3402 mgn_ratio: "0.01".to_string(),
3403 adl: "0".to_string(),
3404 c_time: "1622559930237".to_string(),
3405 realized_pnl: "0".to_string(),
3406 upl_last_px: "0".to_string(),
3407 upl_ratio_last_px: "0".to_string(),
3408 avail_pos: "3.2".to_string(),
3409 be_px: "0".to_string(),
3410 funding_fee: "0".to_string(),
3411 idx_px: "0".to_string(),
3412 liq_penalty: "0".to_string(),
3413 opt_val: "0".to_string(),
3414 pending_close_ord_liab_val: "0".to_string(),
3415 pnl: "0".to_string(),
3416 pos_ccy: "BTC".to_string(),
3417 quote_bal: "160000".to_string(),
3418 quote_borrowed: "0".to_string(),
3419 quote_interest: "0".to_string(),
3420 spot_in_use_amt: "0".to_string(),
3421 spot_in_use_ccy: "BTC".to_string(),
3422 usd_px: "50000".to_string(),
3423 };
3424
3425 let account_id = AccountId::new("OKX-001");
3426 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3427 let report = parse_position_status_report(
3428 position,
3429 account_id,
3430 instrument_id,
3431 8,
3432 UnixNanos::default(),
3433 )
3434 .unwrap();
3435
3436 assert_eq!(report.account_id, account_id);
3437 assert_eq!(report.instrument_id, instrument_id);
3438 assert_eq!(report.position_side, PositionSide::Long.as_specified());
3439 assert_eq!(report.quantity, Quantity::from("3.2"));
3440 assert_eq!(
3442 report.venue_position_id,
3443 Some(PositionId::new("11111-LONG"))
3444 );
3445 }
3446
3447 #[rstest]
3448 fn test_parse_position_status_report_long_short_mode_short() {
3449 let position = OKXPosition {
3452 inst_id: Ustr::from("BTC-USDT-SWAP"),
3453 inst_type: OKXInstrumentType::Swap,
3454 mgn_mode: OKXMarginMode::Cross,
3455 pos_id: Some(Ustr::from("22222")),
3456 pos_side: OKXPositionSide::Short, pos: "1.8".to_string(), base_bal: "1.8".to_string(),
3459 ccy: "BTC".to_string(),
3460 fee: "0.02".to_string(),
3461 lever: "10.0".to_string(),
3462 last: "50000".to_string(),
3463 mark_px: "50000".to_string(),
3464 liq_px: "55000".to_string(),
3465 mmr: "0.2".to_string(),
3466 interest: "0".to_string(),
3467 trade_id: Ustr::from("555"),
3468 notional_usd: "90000".to_string(),
3469 avg_px: "50000".to_string(),
3470 upl: "0".to_string(),
3471 upl_ratio: "0".to_string(),
3472 u_time: 1622559930237,
3473 margin: "0.9".to_string(),
3474 mgn_ratio: "0.02".to_string(),
3475 adl: "0".to_string(),
3476 c_time: "1622559930237".to_string(),
3477 realized_pnl: "0".to_string(),
3478 upl_last_px: "0".to_string(),
3479 upl_ratio_last_px: "0".to_string(),
3480 avail_pos: "1.8".to_string(),
3481 be_px: "0".to_string(),
3482 funding_fee: "0".to_string(),
3483 idx_px: "0".to_string(),
3484 liq_penalty: "0".to_string(),
3485 opt_val: "0".to_string(),
3486 pending_close_ord_liab_val: "0".to_string(),
3487 pnl: "0".to_string(),
3488 pos_ccy: "BTC".to_string(),
3489 quote_bal: "90000".to_string(),
3490 quote_borrowed: "0".to_string(),
3491 quote_interest: "0".to_string(),
3492 spot_in_use_amt: "0".to_string(),
3493 spot_in_use_ccy: "BTC".to_string(),
3494 usd_px: "50000".to_string(),
3495 };
3496
3497 let account_id = AccountId::new("OKX-001");
3498 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3499 let report = parse_position_status_report(
3500 position,
3501 account_id,
3502 instrument_id,
3503 8,
3504 UnixNanos::default(),
3505 )
3506 .unwrap();
3507
3508 assert_eq!(report.account_id, account_id);
3509 assert_eq!(report.instrument_id, instrument_id);
3510 assert_eq!(report.position_side, PositionSide::Short.as_specified());
3512 assert_eq!(report.quantity, Quantity::from("1.8"));
3513 assert_eq!(
3515 report.venue_position_id,
3516 Some(PositionId::new("22222-SHORT"))
3517 );
3518 }
3519}