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, AssetClass, CurrencyType, LiquiditySide,
42 OptionKind, OrderSide, OrderStatus, OrderType, PositionSide, TimeInForce,
43 },
44 events::AccountState,
45 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, Venue, VenueOrderId},
46 instruments::{CryptoFuture, CryptoPerpetual, CurrencyPair, InstrumentAny, OptionContract},
47 reports::{FillReport, OrderStatusReport, PositionStatusReport},
48 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
49};
50use rust_decimal::Decimal;
51use serde::{Deserialize, Deserializer, de::DeserializeOwned};
52use ustr::Ustr;
53
54use super::enums::OKXContractType;
55use crate::{
56 common::{
57 consts::OKX_VENUE,
58 enums::{
59 OKXExecType, OKXInstrumentType, OKXOrderStatus, OKXOrderType, OKXPositionSide, OKXSide,
60 OKXVipLevel,
61 },
62 models::OKXInstrument,
63 },
64 http::models::{
65 OKXAccount, OKXCandlestick, OKXIndexTicker, OKXMarkPrice, OKXOrderHistory, OKXPosition,
66 OKXTrade, OKXTransactionDetail,
67 },
68 websocket::{enums::OKXWsChannel, messages::OKXFundingRateMsg},
69};
70
71pub fn deserialize_empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
89where
90 D: Deserializer<'de>,
91{
92 let opt = Option::<String>::deserialize(deserializer)?;
93 Ok(opt.filter(|s| !s.is_empty()))
94}
95
96pub fn deserialize_empty_ustr_as_none<'de, D>(deserializer: D) -> Result<Option<Ustr>, D::Error>
102where
103 D: Deserializer<'de>,
104{
105 let opt = Option::<Ustr>::deserialize(deserializer)?;
106 Ok(opt.filter(|s| !s.is_empty()))
107}
108
109pub fn deserialize_string_to_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
115where
116 D: Deserializer<'de>,
117{
118 let s = String::deserialize(deserializer)?;
119 if s.is_empty() {
120 Ok(0)
121 } else {
122 s.parse::<u64>().map_err(serde::de::Error::custom)
123 }
124}
125
126pub fn deserialize_optional_string_to_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
132where
133 D: Deserializer<'de>,
134{
135 let s: Option<String> = Option::deserialize(deserializer)?;
136 match s {
137 Some(s) if s.is_empty() => Ok(None),
138 Some(s) => s.parse().map(Some).map_err(serde::de::Error::custom),
139 None => Ok(None),
140 }
141}
142
143pub fn deserialize_vip_level<'de, D>(deserializer: D) -> Result<OKXVipLevel, D::Error>
152where
153 D: Deserializer<'de>,
154{
155 let s = String::deserialize(deserializer)?;
156
157 let level_str = s
159 .strip_prefix("Lv")
160 .or_else(|| s.strip_prefix("lv"))
161 .unwrap_or(&s);
162
163 let level_num = level_str
165 .parse::<u8>()
166 .map_err(|e| serde::de::Error::custom(format!("Invalid VIP level '{s}': {e}")))?;
167
168 Ok(OKXVipLevel::from(level_num))
170}
171
172fn get_currency(code: &str) -> Currency {
174 CURRENCY_MAP
175 .lock()
176 .unwrap()
177 .get(code)
178 .copied()
179 .unwrap_or(Currency::new(code, 8, 0, code, CurrencyType::Crypto))
180}
181
182pub fn okx_instrument_type(instrument: &InstrumentAny) -> anyhow::Result<OKXInstrumentType> {
189 match instrument {
190 InstrumentAny::CurrencyPair(_) => Ok(OKXInstrumentType::Spot),
191 InstrumentAny::CryptoPerpetual(_) => Ok(OKXInstrumentType::Swap),
192 InstrumentAny::CryptoFuture(_) => Ok(OKXInstrumentType::Futures),
193 InstrumentAny::CryptoOption(_) => Ok(OKXInstrumentType::Option),
194 _ => anyhow::bail!("Invalid instrument type for OKX: {instrument:?}"),
195 }
196}
197
198#[must_use]
200pub fn parse_instrument_id(symbol: Ustr) -> InstrumentId {
201 InstrumentId::new(Symbol::from_ustr_unchecked(symbol), *OKX_VENUE)
202}
203
204#[must_use]
206pub fn parse_client_order_id(value: &str) -> Option<ClientOrderId> {
207 if value.is_empty() {
208 None
209 } else {
210 Some(ClientOrderId::new(value))
211 }
212}
213
214#[must_use]
217pub fn parse_millisecond_timestamp(timestamp_ms: u64) -> UnixNanos {
218 UnixNanos::from(timestamp_ms * NANOSECONDS_IN_MILLISECOND)
219}
220
221pub fn parse_rfc3339_timestamp(timestamp: &str) -> anyhow::Result<UnixNanos> {
228 let dt = chrono::DateTime::parse_from_rfc3339(timestamp)?;
229 let nanos = dt.timestamp_nanos_opt().ok_or_else(|| {
230 anyhow::anyhow!("Failed to extract nanoseconds from timestamp: {timestamp}")
231 })?;
232 Ok(UnixNanos::from(nanos as u64))
233}
234
235pub fn parse_price(value: &str, precision: u8) -> anyhow::Result<Price> {
242 Price::new_checked(value.parse::<f64>()?, precision)
243}
244
245pub fn parse_quantity(value: &str, precision: u8) -> anyhow::Result<Quantity> {
252 Quantity::new_checked(value.parse::<f64>()?, precision)
253}
254
255pub fn parse_fee(value: Option<&str>, currency: Currency) -> anyhow::Result<Money> {
265 let fee_f64 = value.unwrap_or("0").parse::<f64>()?;
267 Money::new_checked(-fee_f64, currency)
268}
269
270pub fn parse_aggressor_side(side: &Option<OKXSide>) -> AggressorSide {
272 match side {
273 Some(OKXSide::Buy) => AggressorSide::Buyer,
274 Some(OKXSide::Sell) => AggressorSide::Seller,
275 None => AggressorSide::NoAggressor,
276 }
277}
278
279pub fn parse_execution_type(liquidity: &Option<OKXExecType>) -> LiquiditySide {
281 match liquidity {
282 Some(OKXExecType::Maker) => LiquiditySide::Maker,
283 Some(OKXExecType::Taker) => LiquiditySide::Taker,
284 _ => LiquiditySide::NoLiquiditySide,
285 }
286}
287
288pub fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
290 match current_qty {
291 Some(qty) if qty > 0 => PositionSide::Long,
292 Some(qty) if qty < 0 => PositionSide::Short,
293 _ => PositionSide::Flat,
294 }
295}
296
297pub fn parse_mark_price_update(
304 raw: &OKXMarkPrice,
305 instrument_id: InstrumentId,
306 price_precision: u8,
307 ts_init: UnixNanos,
308) -> anyhow::Result<MarkPriceUpdate> {
309 let ts_event = parse_millisecond_timestamp(raw.ts);
310 let price = parse_price(&raw.mark_px, price_precision)?;
311 Ok(MarkPriceUpdate::new(
312 instrument_id,
313 price,
314 ts_event,
315 ts_init,
316 ))
317}
318
319pub fn parse_index_price_update(
326 raw: &OKXIndexTicker,
327 instrument_id: InstrumentId,
328 price_precision: u8,
329 ts_init: UnixNanos,
330) -> anyhow::Result<IndexPriceUpdate> {
331 let ts_event = parse_millisecond_timestamp(raw.ts);
332 let price = parse_price(&raw.idx_px, price_precision)?;
333 Ok(IndexPriceUpdate::new(
334 instrument_id,
335 price,
336 ts_event,
337 ts_init,
338 ))
339}
340
341pub fn parse_funding_rate_msg(
348 msg: &OKXFundingRateMsg,
349 instrument_id: InstrumentId,
350 ts_init: UnixNanos,
351) -> anyhow::Result<FundingRateUpdate> {
352 let funding_rate = msg
353 .funding_rate
354 .as_str()
355 .parse::<Decimal>()
356 .map_err(|e| anyhow::anyhow!("Invalid funding_rate value: {e}"))?
357 .normalize();
358
359 let funding_time = Some(parse_millisecond_timestamp(msg.funding_time));
360 let ts_event = parse_millisecond_timestamp(msg.ts);
361
362 Ok(FundingRateUpdate::new(
363 instrument_id,
364 funding_rate,
365 funding_time,
366 ts_event,
367 ts_init,
368 ))
369}
370
371pub fn parse_trade_tick(
378 raw: &OKXTrade,
379 instrument_id: InstrumentId,
380 price_precision: u8,
381 size_precision: u8,
382 ts_init: UnixNanos,
383) -> anyhow::Result<TradeTick> {
384 let ts_event = parse_millisecond_timestamp(raw.ts);
385 let price = parse_price(&raw.px, price_precision)?;
386 let size = parse_quantity(&raw.sz, size_precision)?;
387 let aggressor: AggressorSide = raw.side.into();
388 let trade_id = TradeId::new(raw.trade_id);
389
390 TradeTick::new_checked(
391 instrument_id,
392 price,
393 size,
394 aggressor,
395 trade_id,
396 ts_event,
397 ts_init,
398 )
399}
400
401pub fn parse_candlestick(
408 raw: &OKXCandlestick,
409 bar_type: BarType,
410 price_precision: u8,
411 size_precision: u8,
412 ts_init: UnixNanos,
413) -> anyhow::Result<Bar> {
414 let ts_event = parse_millisecond_timestamp(raw.0.parse()?);
415 let open = parse_price(&raw.1, price_precision)?;
416 let high = parse_price(&raw.2, price_precision)?;
417 let low = parse_price(&raw.3, price_precision)?;
418 let close = parse_price(&raw.4, price_precision)?;
419 let volume = parse_quantity(&raw.5, size_precision)?;
420
421 Ok(Bar::new(
422 bar_type, open, high, low, close, volume, ts_event, ts_init,
423 ))
424}
425
426#[allow(clippy::too_many_lines)]
428pub fn parse_order_status_report(
429 order: &OKXOrderHistory,
430 account_id: AccountId,
431 instrument_id: InstrumentId,
432 price_precision: u8,
433 size_precision: u8,
434 ts_init: UnixNanos,
435) -> OrderStatusReport {
436 let quantity = order
437 .sz
438 .parse::<f64>()
439 .ok()
440 .map(|v| Quantity::new(v, size_precision))
441 .unwrap_or_default();
442 let filled_qty = order
443 .acc_fill_sz
444 .parse::<f64>()
445 .ok()
446 .map(|v| Quantity::new(v, size_precision))
447 .unwrap_or_default();
448 let order_side: OrderSide = order.side.into();
449 let okx_status: OKXOrderStatus = order.state;
450 let order_status: OrderStatus = okx_status.into();
451 let okx_ord_type: OKXOrderType = order.ord_type;
452 let order_type: OrderType = okx_ord_type.into();
453 let time_in_force = TimeInForce::Gtc;
455
456 let mut client_order_id = if order.cl_ord_id.is_empty() {
458 None
459 } else {
460 Some(ClientOrderId::new(order.cl_ord_id.as_str()))
461 };
462
463 let mut linked_ids = Vec::new();
464
465 if let Some(algo_cl_ord_id) = order
466 .algo_cl_ord_id
467 .as_ref()
468 .filter(|value| !value.as_str().is_empty())
469 {
470 let algo_client_id = ClientOrderId::new(algo_cl_ord_id.as_str());
471 match &client_order_id {
472 Some(existing) if existing == &algo_client_id => {}
473 Some(_) => linked_ids.push(algo_client_id),
474 None => client_order_id = Some(algo_client_id),
475 }
476 }
477
478 let venue_order_id = if order.ord_id.is_empty() {
479 if let Some(algo_id) = order
480 .algo_id
481 .as_ref()
482 .filter(|value| !value.as_str().is_empty())
483 {
484 VenueOrderId::new(algo_id.as_str())
485 } else if !order.cl_ord_id.is_empty() {
486 VenueOrderId::new(order.cl_ord_id.as_str())
487 } else {
488 let synthetic_id = format!("{}:{}", account_id, order.c_time);
489 VenueOrderId::new(&synthetic_id)
490 }
491 } else {
492 VenueOrderId::new(order.ord_id.as_str())
493 };
494
495 let ts_accepted = parse_millisecond_timestamp(order.c_time);
496 let ts_last = UnixNanos::from(order.u_time * NANOSECONDS_IN_MILLISECOND);
497
498 let mut report = OrderStatusReport::new(
499 account_id,
500 instrument_id,
501 client_order_id,
502 venue_order_id,
503 order_side,
504 order_type,
505 time_in_force,
506 order_status,
507 quantity,
508 filled_qty,
509 ts_accepted,
510 ts_last,
511 ts_init,
512 None,
513 );
514
515 if !order.px.is_empty()
517 && let Ok(p) = order.px.parse::<f64>()
518 {
519 report = report.with_price(Price::new(p, price_precision));
520 }
521 if !order.avg_px.is_empty()
522 && let Ok(avg) = order.avg_px.parse::<f64>()
523 {
524 report = report.with_avg_px(avg);
525 }
526 if order.ord_type == OKXOrderType::PostOnly {
527 report = report.with_post_only(true);
528 }
529 if order.reduce_only == "true" {
530 report = report.with_reduce_only(true);
531 }
532
533 if !linked_ids.is_empty() {
534 report = report.with_linked_order_ids(linked_ids);
535 }
536
537 report
538}
539
540#[allow(clippy::too_many_lines)]
550pub fn parse_position_status_report(
551 position: OKXPosition,
552 account_id: AccountId,
553 instrument_id: InstrumentId,
554 size_precision: u8,
555 ts_init: UnixNanos,
556) -> anyhow::Result<PositionStatusReport> {
557 let pos_value = position.pos.parse::<f64>().unwrap_or_else(|e| {
558 panic!(
559 "Failed to parse position quantity '{}' for instrument {}: {:?}",
560 position.pos, instrument_id, e
561 )
562 });
563
564 let position_side = match position.pos_side {
566 OKXPositionSide::Net => {
567 if pos_value > 0.0 {
568 PositionSide::Long
569 } else if pos_value < 0.0 {
570 PositionSide::Short
571 } else {
572 PositionSide::Flat
573 }
574 }
575 _ => position.pos_side.into(),
576 }
577 .as_specified();
578
579 let quantity = Quantity::new(pos_value.abs(), size_precision);
581 let venue_position_id = None; let avg_px_open = if position.avg_px.is_empty() {
584 None
585 } else {
586 Some(Decimal::from_str(&position.avg_px)?)
587 };
588 let ts_last = parse_millisecond_timestamp(position.u_time);
589
590 Ok(PositionStatusReport::new(
591 account_id,
592 instrument_id,
593 position_side,
594 quantity,
595 ts_last,
596 ts_init,
597 None, venue_position_id,
599 avg_px_open,
600 ))
601}
602
603pub fn parse_fill_report(
609 detail: OKXTransactionDetail,
610 account_id: AccountId,
611 instrument_id: InstrumentId,
612 price_precision: u8,
613 size_precision: u8,
614 ts_init: UnixNanos,
615) -> anyhow::Result<FillReport> {
616 let client_order_id = if detail.cl_ord_id.is_empty() {
617 None
618 } else {
619 Some(ClientOrderId::new(detail.cl_ord_id))
620 };
621 let venue_order_id = VenueOrderId::new(detail.ord_id);
622 let trade_id = TradeId::new(detail.trade_id);
623 let order_side: OrderSide = detail.side.into();
624 let last_px = parse_price(&detail.fill_px, price_precision)?;
625 let last_qty = parse_quantity(&detail.fill_sz, size_precision)?;
626 let fee_f64 = detail.fee.as_deref().unwrap_or("0").parse::<f64>()?;
627 let commission = Money::new(-fee_f64, Currency::from(&detail.fee_ccy));
628 let liquidity_side: LiquiditySide = detail.exec_type.into();
629 let ts_event = parse_millisecond_timestamp(detail.ts);
630
631 Ok(FillReport::new(
632 account_id,
633 instrument_id,
634 venue_order_id,
635 trade_id,
636 order_side,
637 last_qty,
638 last_px,
639 commission,
640 liquidity_side,
641 client_order_id,
642 None, ts_event,
644 ts_init,
645 None, ))
647}
648
649pub fn parse_message_vec<T, R, F, W>(
659 data: serde_json::Value,
660 parser: F,
661 wrapper: W,
662) -> anyhow::Result<Vec<Data>>
663where
664 T: DeserializeOwned,
665 F: Fn(&T) -> anyhow::Result<R>,
666 W: Fn(R) -> Data,
667{
668 let items = match data {
669 serde_json::Value::Array(items) => items,
670 other => {
671 let raw = serde_json::to_string(&other).unwrap_or_else(|_| other.to_string());
672 let mut snippet: String = raw.chars().take(512).collect();
673 if raw.len() > snippet.len() {
674 snippet.push_str("...");
675 }
676 anyhow::bail!("Expected array payload, received {snippet}");
677 }
678 };
679
680 let mut results = Vec::with_capacity(items.len());
681
682 for item in items {
683 let message: T = serde_json::from_value(item)?;
684 let parsed = parser(&message)?;
685 results.push(wrapper(parsed));
686 }
687
688 Ok(results)
689}
690
691pub fn bar_spec_as_okx_channel(bar_spec: BarSpecification) -> anyhow::Result<OKXWsChannel> {
698 let channel = match bar_spec {
699 BAR_SPEC_1_SECOND_LAST => OKXWsChannel::Candle1Second,
700 BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::Candle1Minute,
701 BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::Candle3Minute,
702 BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::Candle5Minute,
703 BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::Candle15Minute,
704 BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::Candle30Minute,
705 BAR_SPEC_1_HOUR_LAST => OKXWsChannel::Candle1Hour,
706 BAR_SPEC_2_HOUR_LAST => OKXWsChannel::Candle2Hour,
707 BAR_SPEC_4_HOUR_LAST => OKXWsChannel::Candle4Hour,
708 BAR_SPEC_6_HOUR_LAST => OKXWsChannel::Candle6Hour,
709 BAR_SPEC_12_HOUR_LAST => OKXWsChannel::Candle12Hour,
710 BAR_SPEC_1_DAY_LAST => OKXWsChannel::Candle1Day,
711 BAR_SPEC_2_DAY_LAST => OKXWsChannel::Candle2Day,
712 BAR_SPEC_3_DAY_LAST => OKXWsChannel::Candle3Day,
713 BAR_SPEC_5_DAY_LAST => OKXWsChannel::Candle5Day,
714 BAR_SPEC_1_WEEK_LAST => OKXWsChannel::Candle1Week,
715 BAR_SPEC_1_MONTH_LAST => OKXWsChannel::Candle1Month,
716 BAR_SPEC_3_MONTH_LAST => OKXWsChannel::Candle3Month,
717 BAR_SPEC_6_MONTH_LAST => OKXWsChannel::Candle6Month,
718 BAR_SPEC_12_MONTH_LAST => OKXWsChannel::Candle1Year,
719 _ => anyhow::bail!("Invalid `BarSpecification` for channel, was {bar_spec}"),
720 };
721 Ok(channel)
722}
723
724pub fn bar_spec_as_okx_mark_price_channel(
731 bar_spec: BarSpecification,
732) -> anyhow::Result<OKXWsChannel> {
733 let channel = match bar_spec {
734 BAR_SPEC_1_SECOND_LAST => OKXWsChannel::MarkPriceCandle1Second,
735 BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::MarkPriceCandle1Minute,
736 BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::MarkPriceCandle3Minute,
737 BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::MarkPriceCandle5Minute,
738 BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::MarkPriceCandle15Minute,
739 BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::MarkPriceCandle30Minute,
740 BAR_SPEC_1_HOUR_LAST => OKXWsChannel::MarkPriceCandle1Hour,
741 BAR_SPEC_2_HOUR_LAST => OKXWsChannel::MarkPriceCandle2Hour,
742 BAR_SPEC_4_HOUR_LAST => OKXWsChannel::MarkPriceCandle4Hour,
743 BAR_SPEC_6_HOUR_LAST => OKXWsChannel::MarkPriceCandle6Hour,
744 BAR_SPEC_12_HOUR_LAST => OKXWsChannel::MarkPriceCandle12Hour,
745 BAR_SPEC_1_DAY_LAST => OKXWsChannel::MarkPriceCandle1Day,
746 BAR_SPEC_2_DAY_LAST => OKXWsChannel::MarkPriceCandle2Day,
747 BAR_SPEC_3_DAY_LAST => OKXWsChannel::MarkPriceCandle3Day,
748 BAR_SPEC_5_DAY_LAST => OKXWsChannel::MarkPriceCandle5Day,
749 BAR_SPEC_1_WEEK_LAST => OKXWsChannel::MarkPriceCandle1Week,
750 BAR_SPEC_1_MONTH_LAST => OKXWsChannel::MarkPriceCandle1Month,
751 BAR_SPEC_3_MONTH_LAST => OKXWsChannel::MarkPriceCandle3Month,
752 _ => anyhow::bail!("Invalid `BarSpecification` for mark price channel, was {bar_spec}"),
753 };
754 Ok(channel)
755}
756
757pub fn bar_spec_as_okx_timeframe(bar_spec: BarSpecification) -> anyhow::Result<&'static str> {
764 let timeframe = match bar_spec {
765 BAR_SPEC_1_SECOND_LAST => "1s",
766 BAR_SPEC_1_MINUTE_LAST => "1m",
767 BAR_SPEC_3_MINUTE_LAST => "3m",
768 BAR_SPEC_5_MINUTE_LAST => "5m",
769 BAR_SPEC_15_MINUTE_LAST => "15m",
770 BAR_SPEC_30_MINUTE_LAST => "30m",
771 BAR_SPEC_1_HOUR_LAST => "1H",
772 BAR_SPEC_2_HOUR_LAST => "2H",
773 BAR_SPEC_4_HOUR_LAST => "4H",
774 BAR_SPEC_6_HOUR_LAST => "6H",
775 BAR_SPEC_12_HOUR_LAST => "12H",
776 BAR_SPEC_1_DAY_LAST => "1D",
777 BAR_SPEC_2_DAY_LAST => "2D",
778 BAR_SPEC_3_DAY_LAST => "3D",
779 BAR_SPEC_5_DAY_LAST => "5D",
780 BAR_SPEC_1_WEEK_LAST => "1W",
781 BAR_SPEC_1_MONTH_LAST => "1M",
782 BAR_SPEC_3_MONTH_LAST => "3M",
783 BAR_SPEC_6_MONTH_LAST => "6M",
784 BAR_SPEC_12_MONTH_LAST => "1Y",
785 _ => anyhow::bail!("Invalid `BarSpecification` for timeframe, was {bar_spec}"),
786 };
787 Ok(timeframe)
788}
789
790pub fn okx_timeframe_as_bar_spec(timeframe: &str) -> anyhow::Result<BarSpecification> {
796 let bar_spec = match timeframe {
797 "1s" => BAR_SPEC_1_SECOND_LAST,
798 "1m" => BAR_SPEC_1_MINUTE_LAST,
799 "3m" => BAR_SPEC_3_MINUTE_LAST,
800 "5m" => BAR_SPEC_5_MINUTE_LAST,
801 "15m" => BAR_SPEC_15_MINUTE_LAST,
802 "30m" => BAR_SPEC_30_MINUTE_LAST,
803 "1H" => BAR_SPEC_1_HOUR_LAST,
804 "2H" => BAR_SPEC_2_HOUR_LAST,
805 "4H" => BAR_SPEC_4_HOUR_LAST,
806 "6H" => BAR_SPEC_6_HOUR_LAST,
807 "12H" => BAR_SPEC_12_HOUR_LAST,
808 "1D" => BAR_SPEC_1_DAY_LAST,
809 "2D" => BAR_SPEC_2_DAY_LAST,
810 "3D" => BAR_SPEC_3_DAY_LAST,
811 "5D" => BAR_SPEC_5_DAY_LAST,
812 "1W" => BAR_SPEC_1_WEEK_LAST,
813 "1M" => BAR_SPEC_1_MONTH_LAST,
814 "3M" => BAR_SPEC_3_MONTH_LAST,
815 "6M" => BAR_SPEC_6_MONTH_LAST,
816 "1Y" => BAR_SPEC_12_MONTH_LAST,
817 _ => anyhow::bail!("Invalid timeframe for `BarSpecification`, was {timeframe}"),
818 };
819 Ok(bar_spec)
820}
821
822pub fn okx_bar_type_from_timeframe(
830 instrument_id: InstrumentId,
831 timeframe: &str,
832) -> anyhow::Result<BarType> {
833 let bar_spec = okx_timeframe_as_bar_spec(timeframe)?;
834 Ok(BarType::new(
835 instrument_id,
836 bar_spec,
837 AggregationSource::External,
838 ))
839}
840
841pub fn okx_channel_to_bar_spec(channel: &OKXWsChannel) -> Option<BarSpecification> {
843 use OKXWsChannel::*;
844 match channel {
845 Candle1Second | MarkPriceCandle1Second => Some(BAR_SPEC_1_SECOND_LAST),
846 Candle1Minute | MarkPriceCandle1Minute => Some(BAR_SPEC_1_MINUTE_LAST),
847 Candle3Minute | MarkPriceCandle3Minute => Some(BAR_SPEC_3_MINUTE_LAST),
848 Candle5Minute | MarkPriceCandle5Minute => Some(BAR_SPEC_5_MINUTE_LAST),
849 Candle15Minute | MarkPriceCandle15Minute => Some(BAR_SPEC_15_MINUTE_LAST),
850 Candle30Minute | MarkPriceCandle30Minute => Some(BAR_SPEC_30_MINUTE_LAST),
851 Candle1Hour | MarkPriceCandle1Hour => Some(BAR_SPEC_1_HOUR_LAST),
852 Candle2Hour | MarkPriceCandle2Hour => Some(BAR_SPEC_2_HOUR_LAST),
853 Candle4Hour | MarkPriceCandle4Hour => Some(BAR_SPEC_4_HOUR_LAST),
854 Candle6Hour | MarkPriceCandle6Hour => Some(BAR_SPEC_6_HOUR_LAST),
855 Candle12Hour | MarkPriceCandle12Hour => Some(BAR_SPEC_12_HOUR_LAST),
856 Candle1Day | MarkPriceCandle1Day => Some(BAR_SPEC_1_DAY_LAST),
857 Candle2Day | MarkPriceCandle2Day => Some(BAR_SPEC_2_DAY_LAST),
858 Candle3Day | MarkPriceCandle3Day => Some(BAR_SPEC_3_DAY_LAST),
859 Candle5Day | MarkPriceCandle5Day => Some(BAR_SPEC_5_DAY_LAST),
860 Candle1Week | MarkPriceCandle1Week => Some(BAR_SPEC_1_WEEK_LAST),
861 Candle1Month | MarkPriceCandle1Month => Some(BAR_SPEC_1_MONTH_LAST),
862 Candle3Month | MarkPriceCandle3Month => Some(BAR_SPEC_3_MONTH_LAST),
863 Candle6Month => Some(BAR_SPEC_6_MONTH_LAST),
864 Candle1Year => Some(BAR_SPEC_12_MONTH_LAST),
865 _ => None,
866 }
867}
868
869pub fn parse_instrument_any(
875 instrument: &OKXInstrument,
876 ts_init: UnixNanos,
877) -> anyhow::Result<Option<InstrumentAny>> {
878 match instrument.inst_type {
879 OKXInstrumentType::Spot => {
880 parse_spot_instrument(instrument, None, None, None, None, ts_init).map(Some)
881 }
882 OKXInstrumentType::Swap => {
883 parse_swap_instrument(instrument, None, None, None, None, ts_init).map(Some)
884 }
885 OKXInstrumentType::Futures => {
886 parse_futures_instrument(instrument, None, None, None, None, ts_init).map(Some)
887 }
888 OKXInstrumentType::Option => {
889 parse_option_instrument(instrument, None, None, None, None, ts_init).map(Some)
890 }
891 _ => Ok(None),
892 }
893}
894
895#[derive(Debug)]
897struct CommonInstrumentData {
898 instrument_id: InstrumentId,
899 raw_symbol: Symbol,
900 price_increment: Price,
901 size_increment: Quantity,
902 lot_size: Option<Quantity>,
903 max_quantity: Option<Quantity>,
904 min_quantity: Option<Quantity>,
905 max_notional: Option<Money>,
906 min_notional: Option<Money>,
907 max_price: Option<Price>,
908 min_price: Option<Price>,
909}
910
911struct MarginAndFees {
913 margin_init: Option<Decimal>,
914 margin_maint: Option<Decimal>,
915 maker_fee: Option<Decimal>,
916 taker_fee: Option<Decimal>,
917}
918
919trait InstrumentParser {
921 fn parse_specific_fields(
923 &self,
924 definition: &OKXInstrument,
925 common: CommonInstrumentData,
926 margin_fees: MarginAndFees,
927 ts_init: UnixNanos,
928 ) -> anyhow::Result<InstrumentAny>;
929}
930
931fn parse_common_instrument_data(
933 definition: &OKXInstrument,
934) -> anyhow::Result<CommonInstrumentData> {
935 let instrument_id = parse_instrument_id(definition.inst_id);
936 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
937
938 let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
939 anyhow::anyhow!(
940 "Failed to parse tick_sz '{}' into Price: {}",
941 definition.tick_sz,
942 e
943 )
944 })?;
945
946 let size_increment = Quantity::from(&definition.lot_sz);
947 let lot_size = Some(Quantity::from(&definition.lot_sz));
948 let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
949 let min_quantity = Some(Quantity::from(&definition.min_sz));
950 let max_notional: Option<Money> = None;
951 let min_notional: Option<Money> = None;
952 let max_price = None; let min_price = None; Ok(CommonInstrumentData {
956 instrument_id,
957 raw_symbol,
958 price_increment,
959 size_increment,
960 lot_size,
961 max_quantity,
962 min_quantity,
963 max_notional,
964 min_notional,
965 max_price,
966 min_price,
967 })
968}
969
970fn parse_instrument_with_parser<P: InstrumentParser>(
972 definition: &OKXInstrument,
973 parser: P,
974 margin_init: Option<Decimal>,
975 margin_maint: Option<Decimal>,
976 maker_fee: Option<Decimal>,
977 taker_fee: Option<Decimal>,
978 ts_init: UnixNanos,
979) -> anyhow::Result<InstrumentAny> {
980 let common = parse_common_instrument_data(definition)?;
981 parser.parse_specific_fields(
982 definition,
983 common,
984 MarginAndFees {
985 margin_init,
986 margin_maint,
987 maker_fee,
988 taker_fee,
989 },
990 ts_init,
991 )
992}
993
994struct SpotInstrumentParser;
996
997impl InstrumentParser for SpotInstrumentParser {
998 fn parse_specific_fields(
999 &self,
1000 definition: &OKXInstrument,
1001 common: CommonInstrumentData,
1002 margin_fees: MarginAndFees,
1003 ts_init: UnixNanos,
1004 ) -> anyhow::Result<InstrumentAny> {
1005 let base_currency = get_currency(&definition.base_ccy);
1006 let quote_currency = get_currency(&definition.quote_ccy);
1007
1008 let instrument = CurrencyPair::new(
1009 common.instrument_id,
1010 common.raw_symbol,
1011 base_currency,
1012 quote_currency,
1013 common.price_increment.precision,
1014 common.size_increment.precision,
1015 common.price_increment,
1016 common.size_increment,
1017 None,
1018 common.lot_size,
1019 common.max_quantity,
1020 common.min_quantity,
1021 common.max_notional,
1022 common.min_notional,
1023 common.max_price,
1024 common.min_price,
1025 margin_fees.margin_init,
1026 margin_fees.margin_maint,
1027 margin_fees.maker_fee,
1028 margin_fees.taker_fee,
1029 ts_init,
1030 ts_init,
1031 );
1032
1033 Ok(InstrumentAny::CurrencyPair(instrument))
1034 }
1035}
1036
1037pub fn parse_spot_instrument(
1043 definition: &OKXInstrument,
1044 margin_init: Option<Decimal>,
1045 margin_maint: Option<Decimal>,
1046 maker_fee: Option<Decimal>,
1047 taker_fee: Option<Decimal>,
1048 ts_init: UnixNanos,
1049) -> anyhow::Result<InstrumentAny> {
1050 parse_instrument_with_parser(
1051 definition,
1052 SpotInstrumentParser,
1053 margin_init,
1054 margin_maint,
1055 maker_fee,
1056 taker_fee,
1057 ts_init,
1058 )
1059}
1060
1061pub fn parse_swap_instrument(
1067 definition: &OKXInstrument,
1068 margin_init: Option<Decimal>,
1069 margin_maint: Option<Decimal>,
1070 maker_fee: Option<Decimal>,
1071 taker_fee: Option<Decimal>,
1072 ts_init: UnixNanos,
1073) -> anyhow::Result<InstrumentAny> {
1074 let instrument_id = parse_instrument_id(definition.inst_id);
1075 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1076 let (base_currency, quote_currency) = definition
1077 .uly
1078 .split_once('-')
1079 .ok_or_else(|| anyhow::anyhow!("Invalid underlying for swap: {}", definition.uly))?;
1080 let base_currency = get_currency(base_currency);
1081 let quote_currency = get_currency(quote_currency);
1082 let settlement_currency = get_currency(&definition.settle_ccy);
1083 let is_inverse = match definition.ct_type {
1084 OKXContractType::Linear => false,
1085 OKXContractType::Inverse => true,
1086 OKXContractType::None => {
1087 anyhow::bail!("Invalid contract type for swap: {}", definition.ct_type)
1088 }
1089 };
1090 let price_increment = match Price::from_str(&definition.tick_sz) {
1091 Ok(price) => price,
1092 Err(e) => {
1093 anyhow::bail!(
1094 "Failed to parse tick_size '{}' into Price: {}",
1095 definition.tick_sz,
1096 e
1097 );
1098 }
1099 };
1100 let size_increment = Quantity::from(&definition.lot_sz);
1101 let multiplier = Some(Quantity::from(&definition.ct_mult));
1102 let lot_size = Some(Quantity::from(&definition.lot_sz));
1103 let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1104 let min_quantity = Some(Quantity::from(&definition.min_sz));
1105 let max_notional: Option<Money> = None;
1106 let min_notional: Option<Money> = None;
1107 let max_price = None; let min_price = None; let (size_precision, adjusted_size_increment) = if is_inverse {
1115 (size_increment.precision, size_increment)
1117 } else {
1118 let precision = 8u8;
1121 let adjusted_increment = Quantity::new(1.0, precision); (precision, adjusted_increment)
1123 };
1124
1125 let instrument = CryptoPerpetual::new(
1126 instrument_id,
1127 raw_symbol,
1128 base_currency,
1129 quote_currency,
1130 settlement_currency,
1131 is_inverse,
1132 price_increment.precision,
1133 size_precision,
1134 price_increment,
1135 adjusted_size_increment,
1136 multiplier,
1137 lot_size,
1138 max_quantity,
1139 min_quantity,
1140 max_notional,
1141 min_notional,
1142 max_price,
1143 min_price,
1144 margin_init,
1145 margin_maint,
1146 maker_fee,
1147 taker_fee,
1148 ts_init, ts_init,
1150 );
1151
1152 Ok(InstrumentAny::CryptoPerpetual(instrument))
1153}
1154
1155pub fn parse_futures_instrument(
1161 definition: &OKXInstrument,
1162 margin_init: Option<Decimal>,
1163 margin_maint: Option<Decimal>,
1164 maker_fee: Option<Decimal>,
1165 taker_fee: Option<Decimal>,
1166 ts_init: UnixNanos,
1167) -> anyhow::Result<InstrumentAny> {
1168 let instrument_id = parse_instrument_id(definition.inst_id);
1169 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1170 let underlying = get_currency(&definition.uly);
1171 let (_, quote_currency) = definition
1172 .uly
1173 .split_once('-')
1174 .ok_or_else(|| anyhow::anyhow!("Invalid underlying for Swap: {}", definition.uly))?;
1175 let quote_currency = get_currency(quote_currency);
1176 let settlement_currency = get_currency(&definition.settle_ccy);
1177 let is_inverse = match definition.ct_type {
1178 OKXContractType::Linear => false,
1179 OKXContractType::Inverse => true,
1180 OKXContractType::None => {
1181 anyhow::bail!("Invalid contract type for futures: {}", definition.ct_type)
1182 }
1183 };
1184 let listing_time = definition
1185 .list_time
1186 .ok_or_else(|| anyhow::anyhow!("`listing_time` is required to parse Swap instrument"))?;
1187 let expiry_time = definition
1188 .exp_time
1189 .ok_or_else(|| anyhow::anyhow!("`expiry_time` is required to parse Swap instrument"))?;
1190 let activation_ns = UnixNanos::from(millis_to_nanos(listing_time as f64));
1191 let expiration_ns = UnixNanos::from(millis_to_nanos(expiry_time as f64));
1192 let price_increment = Price::from(definition.tick_sz.to_string());
1193 let size_increment = Quantity::from(&definition.lot_sz);
1194 let multiplier = Some(Quantity::from(&definition.ct_mult));
1195 let lot_size = Some(Quantity::from(&definition.lot_sz));
1196 let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1197 let min_quantity = Some(Quantity::from(&definition.min_sz));
1198 let max_notional: Option<Money> = None;
1199 let min_notional: Option<Money> = None;
1200 let max_price = None; let min_price = None; let instrument = CryptoFuture::new(
1204 instrument_id,
1205 raw_symbol,
1206 underlying,
1207 quote_currency,
1208 settlement_currency,
1209 is_inverse,
1210 activation_ns,
1211 expiration_ns,
1212 price_increment.precision,
1213 size_increment.precision,
1214 price_increment,
1215 size_increment,
1216 multiplier,
1217 lot_size,
1218 max_quantity,
1219 min_quantity,
1220 max_notional,
1221 min_notional,
1222 max_price,
1223 min_price,
1224 margin_init,
1225 margin_maint,
1226 maker_fee,
1227 taker_fee,
1228 ts_init, ts_init,
1230 );
1231
1232 Ok(InstrumentAny::CryptoFuture(instrument))
1233}
1234
1235pub fn parse_option_instrument(
1241 definition: &OKXInstrument,
1242 margin_init: Option<Decimal>,
1243 margin_maint: Option<Decimal>,
1244 maker_fee: Option<Decimal>,
1245 taker_fee: Option<Decimal>,
1246 ts_init: UnixNanos,
1247) -> anyhow::Result<InstrumentAny> {
1248 let instrument_id = parse_instrument_id(definition.inst_id);
1249 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1250 let asset_class = AssetClass::Cryptocurrency;
1251 let exchange = Some(Ustr::from("OKX"));
1252 let underlying = Ustr::from(&definition.uly);
1253 let option_kind: OptionKind = definition.opt_type.into();
1254 let strike_price = Price::from(&definition.stk);
1255 let currency = definition
1256 .uly
1257 .split_once('-')
1258 .map(|(_, quote_ccy)| get_currency(quote_ccy))
1259 .ok_or_else(|| {
1260 anyhow::anyhow!(
1261 "Invalid underlying for Option instrument: {}",
1262 definition.uly
1263 )
1264 })?;
1265 let listing_time = definition
1266 .list_time
1267 .ok_or_else(|| anyhow::anyhow!("`listing_time` is required to parse Option instrument"))?;
1268 let expiry_time = definition
1269 .exp_time
1270 .ok_or_else(|| anyhow::anyhow!("`expiry_time` is required to parse Option instrument"))?;
1271 let activation_ns = UnixNanos::from(millis_to_nanos(listing_time as f64));
1272 let expiration_ns = UnixNanos::from(millis_to_nanos(expiry_time as f64));
1273 let price_increment = Price::from(definition.tick_sz.to_string());
1274 let multiplier = Quantity::from(&definition.ct_mult);
1275 let lot_size = Quantity::from(&definition.lot_sz);
1276 let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1277 let min_quantity = Some(Quantity::from(&definition.min_sz));
1278 let max_price = None; let min_price = None; let instrument = OptionContract::new(
1282 instrument_id,
1283 raw_symbol,
1284 asset_class,
1285 exchange,
1286 underlying,
1287 option_kind,
1288 strike_price,
1289 currency,
1290 activation_ns,
1291 expiration_ns,
1292 price_increment.precision,
1293 price_increment,
1294 multiplier,
1295 lot_size,
1296 max_quantity,
1297 min_quantity,
1298 max_price,
1299 min_price,
1300 margin_init,
1301 margin_maint,
1302 maker_fee,
1303 taker_fee,
1304 ts_init, ts_init,
1306 );
1307
1308 Ok(InstrumentAny::OptionContract(instrument))
1309}
1310
1311pub fn parse_account_state(
1317 okx_account: &OKXAccount,
1318 account_id: AccountId,
1319 ts_init: UnixNanos,
1320) -> anyhow::Result<AccountState> {
1321 let mut balances = Vec::new();
1322 for b in &okx_account.details {
1323 if b.ccy.is_empty() {
1325 tracing::warn!("Skipping balance detail with empty currency code");
1326 continue;
1327 }
1328
1329 let currency = Currency::from(b.ccy);
1330 let total = Money::new(b.cash_bal.parse::<f64>()?, currency);
1331 let free = Money::new(b.avail_bal.parse::<f64>()?, currency);
1332 let locked = total - free;
1333 let balance = AccountBalance::new(total, locked, free);
1334 balances.push(balance);
1335 }
1336
1337 if balances.is_empty() {
1340 let zero_currency = Currency::USD();
1341 let zero_money = Money::new(0.0, zero_currency);
1342 let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
1343 balances.push(zero_balance);
1344 }
1345
1346 let mut margins = Vec::new();
1347
1348 if !okx_account.imr.is_empty() && !okx_account.mmr.is_empty() {
1350 match (
1351 okx_account.imr.parse::<f64>(),
1352 okx_account.mmr.parse::<f64>(),
1353 ) {
1354 (Ok(imr_value), Ok(mmr_value)) => {
1355 if imr_value > 0.0 || mmr_value > 0.0 {
1356 let margin_currency = Currency::USD();
1357 let margin_instrument_id =
1358 InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("OKX"));
1359
1360 let initial_margin = Money::new(imr_value, margin_currency);
1361 let maintenance_margin = Money::new(mmr_value, margin_currency);
1362
1363 let margin_balance = MarginBalance::new(
1364 initial_margin,
1365 maintenance_margin,
1366 margin_instrument_id,
1367 );
1368
1369 margins.push(margin_balance);
1370 }
1371 }
1372 (Err(e1), _) => {
1373 tracing::warn!(
1374 "Failed to parse initial margin requirement '{}': {}",
1375 okx_account.imr,
1376 e1
1377 );
1378 }
1379 (_, Err(e2)) => {
1380 tracing::warn!(
1381 "Failed to parse maintenance margin requirement '{}': {}",
1382 okx_account.mmr,
1383 e2
1384 );
1385 }
1386 }
1387 }
1388
1389 let account_type = AccountType::Margin;
1390 let is_reported = true;
1391 let event_id = UUID4::new();
1392 let ts_event = UnixNanos::from(millis_to_nanos(okx_account.u_time as f64));
1393
1394 Ok(AccountState::new(
1395 account_id,
1396 account_type,
1397 balances,
1398 margins,
1399 is_reported,
1400 event_id,
1401 ts_event,
1402 ts_init,
1403 None,
1404 ))
1405}
1406
1407#[cfg(test)]
1412mod tests {
1413 use nautilus_model::instruments::Instrument;
1414 use rstest::rstest;
1415
1416 use super::*;
1417 use crate::{
1418 common::{enums::OKXMarginMode, testing::load_test_json},
1419 http::{
1420 client::OKXResponse,
1421 models::{
1422 OKXAccount, OKXBalanceDetail, OKXCandlestick, OKXIndexTicker, OKXMarkPrice,
1423 OKXOrderHistory, OKXPlaceOrderResponse, OKXPosition, OKXPositionHistory,
1424 OKXPositionTier, OKXTrade, OKXTransactionDetail,
1425 },
1426 },
1427 };
1428
1429 #[rstest]
1430 fn test_parse_trades() {
1431 let json_data = load_test_json("http_get_trades.json");
1432 let parsed: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
1433
1434 assert_eq!(parsed.code, "0");
1436 assert_eq!(parsed.msg, "");
1437 assert_eq!(parsed.data.len(), 2);
1438
1439 let trade0 = &parsed.data[0];
1441 assert_eq!(trade0.inst_id, "BTC-USDT");
1442 assert_eq!(trade0.px, "102537.9");
1443 assert_eq!(trade0.sz, "0.00013669");
1444 assert_eq!(trade0.side, OKXSide::Sell);
1445 assert_eq!(trade0.trade_id, "734864333");
1446 assert_eq!(trade0.ts, 1747087163557);
1447
1448 let trade1 = &parsed.data[1];
1450 assert_eq!(trade1.inst_id, "BTC-USDT");
1451 assert_eq!(trade1.px, "102537.9");
1452 assert_eq!(trade1.sz, "0.0000125");
1453 assert_eq!(trade1.side, OKXSide::Buy);
1454 assert_eq!(trade1.trade_id, "734864332");
1455 assert_eq!(trade1.ts, 1747087161666);
1456 }
1457
1458 #[rstest]
1459 fn test_parse_candlesticks() {
1460 let json_data = load_test_json("http_get_candlesticks.json");
1461 let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
1462
1463 assert_eq!(parsed.code, "0");
1465 assert_eq!(parsed.msg, "");
1466 assert_eq!(parsed.data.len(), 2);
1467
1468 let bar0 = &parsed.data[0];
1469 assert_eq!(bar0.0, "1625097600000");
1470 assert_eq!(bar0.1, "33528.6");
1471 assert_eq!(bar0.2, "33870.0");
1472 assert_eq!(bar0.3, "33528.6");
1473 assert_eq!(bar0.4, "33783.9");
1474 assert_eq!(bar0.5, "778.838");
1475
1476 let bar1 = &parsed.data[1];
1477 assert_eq!(bar1.0, "1625097660000");
1478 assert_eq!(bar1.1, "33783.9");
1479 assert_eq!(bar1.2, "33783.9");
1480 assert_eq!(bar1.3, "33782.1");
1481 assert_eq!(bar1.4, "33782.1");
1482 assert_eq!(bar1.5, "0.123");
1483 }
1484
1485 #[rstest]
1486 fn test_parse_candlesticks_full() {
1487 let json_data = load_test_json("http_get_candlesticks_full.json");
1488 let parsed: OKXResponse<OKXCandlestick> = serde_json::from_str(&json_data).unwrap();
1489
1490 assert_eq!(parsed.code, "0");
1492 assert_eq!(parsed.msg, "");
1493 assert_eq!(parsed.data.len(), 2);
1494
1495 let bar0 = &parsed.data[0];
1497 assert_eq!(bar0.0, "1747094040000");
1498 assert_eq!(bar0.1, "102806.1");
1499 assert_eq!(bar0.2, "102820.4");
1500 assert_eq!(bar0.3, "102806.1");
1501 assert_eq!(bar0.4, "102820.4");
1502 assert_eq!(bar0.5, "1040.37");
1503 assert_eq!(bar0.6, "10.4037");
1504 assert_eq!(bar0.7, "1069603.34883");
1505 assert_eq!(bar0.8, "1");
1506
1507 let bar1 = &parsed.data[1];
1509 assert_eq!(bar1.0, "1747093980000");
1510 assert_eq!(bar1.5, "7164.04");
1511 assert_eq!(bar1.6, "71.6404");
1512 assert_eq!(bar1.7, "7364701.57952");
1513 assert_eq!(bar1.8, "1");
1514 }
1515
1516 #[rstest]
1517 fn test_parse_mark_price() {
1518 let json_data = load_test_json("http_get_mark_price.json");
1519 let parsed: OKXResponse<OKXMarkPrice> = serde_json::from_str(&json_data).unwrap();
1520
1521 assert_eq!(parsed.code, "0");
1523 assert_eq!(parsed.msg, "");
1524 assert_eq!(parsed.data.len(), 1);
1525
1526 let mark_price = &parsed.data[0];
1528
1529 assert_eq!(mark_price.inst_id, "BTC-USDT-SWAP");
1530 assert_eq!(mark_price.mark_px, "84660.1");
1531 assert_eq!(mark_price.ts, 1744590349506);
1532 }
1533
1534 #[rstest]
1535 fn test_parse_index_price() {
1536 let json_data = load_test_json("http_get_index_price.json");
1537 let parsed: OKXResponse<OKXIndexTicker> = serde_json::from_str(&json_data).unwrap();
1538
1539 assert_eq!(parsed.code, "0");
1541 assert_eq!(parsed.msg, "");
1542 assert_eq!(parsed.data.len(), 1);
1543
1544 let index_price = &parsed.data[0];
1546
1547 assert_eq!(index_price.inst_id, "BTC-USDT");
1548 assert_eq!(index_price.idx_px, "103895");
1549 assert_eq!(index_price.ts, 1746942707815);
1550 }
1551
1552 #[rstest]
1553 fn test_parse_account() {
1554 let json_data = load_test_json("http_get_account_balance.json");
1555 let parsed: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
1556
1557 assert_eq!(parsed.code, "0");
1559 assert_eq!(parsed.msg, "");
1560 assert_eq!(parsed.data.len(), 1);
1561
1562 let account = &parsed.data[0];
1564 assert_eq!(account.adj_eq, "");
1565 assert_eq!(account.borrow_froz, "");
1566 assert_eq!(account.imr, "");
1567 assert_eq!(account.iso_eq, "5.4682385526666675");
1568 assert_eq!(account.mgn_ratio, "");
1569 assert_eq!(account.mmr, "");
1570 assert_eq!(account.notional_usd, "");
1571 assert_eq!(account.notional_usd_for_borrow, "");
1572 assert_eq!(account.notional_usd_for_futures, "");
1573 assert_eq!(account.notional_usd_for_option, "");
1574 assert_eq!(account.notional_usd_for_swap, "");
1575 assert_eq!(account.ord_froz, "");
1576 assert_eq!(account.total_eq, "99.88870288820581");
1577 assert_eq!(account.upl, "");
1578 assert_eq!(account.u_time, 1744499648556);
1579 assert_eq!(account.details.len(), 1);
1580
1581 let detail = &account.details[0];
1582 assert_eq!(detail.ccy, "USDT");
1583 assert_eq!(detail.avail_bal, "94.42612990333333");
1584 assert_eq!(detail.avail_eq, "94.42612990333333");
1585 assert_eq!(detail.cash_bal, "94.42612990333333");
1586 assert_eq!(detail.dis_eq, "5.4682385526666675");
1587 assert_eq!(detail.eq, "99.89469657000001");
1588 assert_eq!(detail.eq_usd, "99.88870288820581");
1589 assert_eq!(detail.fixed_bal, "0");
1590 assert_eq!(detail.frozen_bal, "5.468566666666667");
1591 assert_eq!(detail.imr, "0");
1592 assert_eq!(detail.iso_eq, "5.468566666666667");
1593 assert_eq!(detail.iso_upl, "-0.0273000000000002");
1594 assert_eq!(detail.mmr, "0");
1595 assert_eq!(detail.notional_lever, "0");
1596 assert_eq!(detail.ord_frozen, "0");
1597 assert_eq!(detail.reward_bal, "0");
1598 assert_eq!(detail.smt_sync_eq, "0");
1599 assert_eq!(detail.spot_copy_trading_eq, "0");
1600 assert_eq!(detail.spot_iso_bal, "0");
1601 assert_eq!(detail.stgy_eq, "0");
1602 assert_eq!(detail.twap, "0");
1603 assert_eq!(detail.upl, "-0.0273000000000002");
1604 assert_eq!(detail.u_time, 1744498994783);
1605 }
1606
1607 #[rstest]
1608 fn test_parse_order_history() {
1609 let json_data = load_test_json("http_get_orders_history.json");
1610 let parsed: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
1611
1612 assert_eq!(parsed.code, "0");
1614 assert_eq!(parsed.msg, "");
1615 assert_eq!(parsed.data.len(), 1);
1616
1617 let order = &parsed.data[0];
1619 assert_eq!(order.ord_id, "2497956918703120384");
1620 assert_eq!(order.fill_sz, "0.03");
1621 assert_eq!(order.acc_fill_sz, "0.03");
1622 assert_eq!(order.state, OKXOrderStatus::Filled);
1623 assert!(order.fill_fee.is_none());
1624 }
1625
1626 #[rstest]
1627 fn test_parse_position() {
1628 let json_data = load_test_json("http_get_positions.json");
1629 let parsed: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
1630
1631 assert_eq!(parsed.code, "0");
1633 assert_eq!(parsed.msg, "");
1634 assert_eq!(parsed.data.len(), 1);
1635
1636 let pos = &parsed.data[0];
1638 assert_eq!(pos.inst_id, "BTC-USDT-SWAP");
1639 assert_eq!(pos.pos_side, OKXPositionSide::Long);
1640 assert_eq!(pos.pos, "0.5");
1641 assert_eq!(pos.base_bal, "0.5");
1642 assert_eq!(pos.quote_bal, "5000");
1643 assert_eq!(pos.u_time, 1622559930237);
1644 }
1645
1646 #[rstest]
1647 fn test_parse_position_history() {
1648 let json_data = load_test_json("http_get_account_positions-history.json");
1649 let parsed: OKXResponse<OKXPositionHistory> = serde_json::from_str(&json_data).unwrap();
1650
1651 assert_eq!(parsed.code, "0");
1653 assert_eq!(parsed.msg, "");
1654 assert_eq!(parsed.data.len(), 1);
1655
1656 let hist = &parsed.data[0];
1658 assert_eq!(hist.inst_id, "ETH-USDT-SWAP");
1659 assert_eq!(hist.inst_type, OKXInstrumentType::Swap);
1660 assert_eq!(hist.mgn_mode, OKXMarginMode::Isolated);
1661 assert_eq!(hist.pos_side, OKXPositionSide::Long);
1662 assert_eq!(hist.lever, "3.0");
1663 assert_eq!(hist.open_avg_px, "3226.93");
1664 assert_eq!(hist.close_avg_px.as_deref(), Some("3224.8"));
1665 assert_eq!(hist.pnl.as_deref(), Some("-0.0213"));
1666 assert!(!hist.c_time.is_empty());
1667 assert!(hist.u_time > 0);
1668 }
1669
1670 #[rstest]
1671 fn test_parse_position_tiers() {
1672 let json_data = load_test_json("http_get_position_tiers.json");
1673 let parsed: OKXResponse<OKXPositionTier> = serde_json::from_str(&json_data).unwrap();
1674
1675 assert_eq!(parsed.code, "0");
1677 assert_eq!(parsed.msg, "");
1678 assert_eq!(parsed.data.len(), 1);
1679
1680 let tier = &parsed.data[0];
1682 assert_eq!(tier.inst_id, "BTC-USDT");
1683 assert_eq!(tier.tier, "1");
1684 assert_eq!(tier.min_sz, "0");
1685 assert_eq!(tier.max_sz, "50");
1686 assert_eq!(tier.imr, "0.1");
1687 assert_eq!(tier.mmr, "0.03");
1688 }
1689
1690 #[rstest]
1691 fn test_parse_account_field_name_compatibility() {
1692 let json_new = load_test_json("http_balance_detail_new_fields.json");
1694 let detail_new: OKXBalanceDetail = serde_json::from_str(&json_new).unwrap();
1695 assert_eq!(detail_new.max_spot_in_use_amt, "50.0");
1696 assert_eq!(detail_new.spot_in_use_amt, "30.0");
1697 assert_eq!(detail_new.cl_spot_in_use_amt, "25.0");
1698
1699 let json_old = load_test_json("http_balance_detail_old_fields.json");
1701 let detail_old: OKXBalanceDetail = serde_json::from_str(&json_old).unwrap();
1702 assert_eq!(detail_old.max_spot_in_use_amt, "75.0");
1703 assert_eq!(detail_old.spot_in_use_amt, "40.0");
1704 assert_eq!(detail_old.cl_spot_in_use_amt, "35.0");
1705 }
1706
1707 #[rstest]
1708 fn test_parse_place_order_response() {
1709 let json_data = load_test_json("http_place_order_response.json");
1710 let parsed: OKXPlaceOrderResponse = serde_json::from_str(&json_data).unwrap();
1711 assert_eq!(
1712 parsed.ord_id,
1713 Some(ustr::Ustr::from("12345678901234567890"))
1714 );
1715 assert_eq!(parsed.cl_ord_id, Some(ustr::Ustr::from("client_order_123")));
1716 assert_eq!(parsed.tag, Some("".to_string()));
1717 }
1718
1719 #[rstest]
1720 fn test_parse_transaction_details() {
1721 let json_data = load_test_json("http_transaction_detail.json");
1722 let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
1723 assert_eq!(parsed.inst_type, OKXInstrumentType::Spot);
1724 assert_eq!(parsed.inst_id, Ustr::from("BTC-USDT"));
1725 assert_eq!(parsed.trade_id, Ustr::from("123456789"));
1726 assert_eq!(parsed.ord_id, Ustr::from("987654321"));
1727 assert_eq!(parsed.cl_ord_id, Ustr::from("client_123"));
1728 assert_eq!(parsed.bill_id, Ustr::from("bill_456"));
1729 assert_eq!(parsed.fill_px, "42000.5");
1730 assert_eq!(parsed.fill_sz, "0.001");
1731 assert_eq!(parsed.side, OKXSide::Buy);
1732 assert_eq!(parsed.exec_type, OKXExecType::Taker);
1733 assert_eq!(parsed.fee_ccy, "USDT");
1734 assert_eq!(parsed.fee, Some("0.042".to_string()));
1735 assert_eq!(parsed.ts, 1625097600000);
1736 }
1737
1738 #[rstest]
1739 fn test_parse_empty_fee_field() {
1740 let json_data = load_test_json("http_transaction_detail_empty_fee.json");
1741 let parsed: OKXTransactionDetail = serde_json::from_str(&json_data).unwrap();
1742 assert_eq!(parsed.fee, None);
1743 }
1744
1745 #[rstest]
1746 fn test_parse_optional_string_to_u64() {
1747 use serde::Deserialize;
1748
1749 #[derive(Deserialize)]
1750 struct TestStruct {
1751 #[serde(deserialize_with = "crate::common::parse::deserialize_optional_string_to_u64")]
1752 value: Option<u64>,
1753 }
1754
1755 let json_cases = load_test_json("common_optional_string_to_u64.json");
1756 let cases: Vec<TestStruct> = serde_json::from_str(&json_cases).unwrap();
1757
1758 assert_eq!(cases[0].value, Some(12345));
1759 assert_eq!(cases[1].value, None);
1760 assert_eq!(cases[2].value, None);
1761 }
1762
1763 #[rstest]
1764 fn test_parse_error_handling() {
1765 let invalid_price = "invalid-price";
1767 let result = crate::common::parse::parse_price(invalid_price, 2);
1768 assert!(result.is_err());
1769
1770 let invalid_quantity = "invalid-quantity";
1772 let result = crate::common::parse::parse_quantity(invalid_quantity, 8);
1773 assert!(result.is_err());
1774 }
1775
1776 #[rstest]
1777 fn test_parse_spot_instrument() {
1778 let json_data = load_test_json("http_get_instruments_spot.json");
1779 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1780 let okx_inst: &OKXInstrument = response
1781 .data
1782 .first()
1783 .expect("Test data must have an instrument");
1784
1785 let instrument =
1786 parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
1787
1788 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD.OKX"));
1789 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD"));
1790 assert_eq!(instrument.underlying(), None);
1791 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
1792 assert_eq!(instrument.quote_currency(), Currency::USD());
1793 assert_eq!(instrument.price_precision(), 1);
1794 assert_eq!(instrument.size_precision(), 8);
1795 assert_eq!(instrument.price_increment(), Price::from("0.1"));
1796 assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
1797 }
1798
1799 #[rstest]
1800 fn test_parse_margin_instrument() {
1801 let json_data = load_test_json("http_get_instruments_margin.json");
1802 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1803 let okx_inst: &OKXInstrument = response
1804 .data
1805 .first()
1806 .expect("Test data must have an instrument");
1807
1808 let instrument =
1809 parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
1810
1811 assert_eq!(instrument.id(), InstrumentId::from("BTC-USDT.OKX"));
1812 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USDT"));
1813 assert_eq!(instrument.underlying(), None);
1814 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
1815 assert_eq!(instrument.quote_currency(), Currency::USDT());
1816 assert_eq!(instrument.price_precision(), 1);
1817 assert_eq!(instrument.size_precision(), 8);
1818 assert_eq!(instrument.price_increment(), Price::from("0.1"));
1819 assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
1820 }
1821
1822 #[rstest]
1823 fn test_parse_swap_instrument() {
1824 let json_data = load_test_json("http_get_instruments_swap.json");
1825 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1826 let okx_inst: &OKXInstrument = response
1827 .data
1828 .first()
1829 .expect("Test data must have an instrument");
1830
1831 let instrument =
1832 parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
1833
1834 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-SWAP.OKX"));
1835 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-SWAP"));
1836 assert_eq!(instrument.underlying(), None);
1837 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
1838 assert_eq!(instrument.quote_currency(), Currency::USD());
1839 assert_eq!(instrument.price_precision(), 1);
1840 assert_eq!(instrument.size_precision(), 0);
1841 assert_eq!(instrument.price_increment(), Price::from("0.1"));
1842 assert_eq!(instrument.size_increment(), Quantity::from(1));
1843 }
1844
1845 #[rstest]
1846 fn test_parse_futures_instrument() {
1847 let json_data = load_test_json("http_get_instruments_futures.json");
1848 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1849 let okx_inst: &OKXInstrument = response
1850 .data
1851 .first()
1852 .expect("Test data must have an instrument");
1853
1854 let instrument =
1855 parse_futures_instrument(okx_inst, None, None, None, None, UnixNanos::default())
1856 .unwrap();
1857
1858 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-241220.OKX"));
1859 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-241220"));
1860 assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
1861 assert_eq!(instrument.quote_currency(), Currency::USD());
1862 assert_eq!(instrument.price_precision(), 1);
1863 assert_eq!(instrument.size_precision(), 0);
1864 assert_eq!(instrument.price_increment(), Price::from("0.1"));
1865 assert_eq!(instrument.size_increment(), Quantity::from(1));
1866 }
1867
1868 #[rstest]
1869 fn test_parse_option_instrument() {
1870 let json_data = load_test_json("http_get_instruments_option.json");
1871 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1872 let okx_inst: &OKXInstrument = response
1873 .data
1874 .first()
1875 .expect("Test data must have an instrument");
1876
1877 let instrument =
1878 parse_option_instrument(okx_inst, None, None, None, None, UnixNanos::default())
1879 .unwrap();
1880
1881 assert_eq!(
1882 instrument.id(),
1883 InstrumentId::from("BTC-USD-241217-92000-C.OKX")
1884 );
1885 assert_eq!(
1886 instrument.raw_symbol(),
1887 Symbol::from("BTC-USD-241217-92000-C")
1888 );
1889 assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
1890 assert_eq!(instrument.quote_currency(), Currency::USD());
1891 assert_eq!(instrument.price_precision(), 4);
1892 assert_eq!(instrument.size_precision(), 0);
1893 assert_eq!(instrument.price_increment(), Price::from("0.0001"));
1894 assert_eq!(instrument.size_increment(), Quantity::from(1));
1895 }
1896
1897 #[rstest]
1898 fn test_parse_account_state() {
1899 let json_data = load_test_json("http_get_account_balance.json");
1900 let response: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
1901 let okx_account = response
1902 .data
1903 .first()
1904 .expect("Test data must have an account");
1905
1906 let account_id = AccountId::new("OKX-001");
1907 let account_state =
1908 parse_account_state(okx_account, account_id, UnixNanos::default()).unwrap();
1909
1910 assert_eq!(account_state.account_id, account_id);
1911 assert_eq!(account_state.account_type, AccountType::Margin);
1912 assert_eq!(account_state.balances.len(), 1);
1913 assert_eq!(account_state.margins.len(), 0); assert!(account_state.is_reported);
1915
1916 let usdt_balance = &account_state.balances[0];
1918 assert_eq!(
1919 usdt_balance.total,
1920 Money::new(94.42612990333333, Currency::USDT())
1921 );
1922 assert_eq!(
1923 usdt_balance.free,
1924 Money::new(94.42612990333333, Currency::USDT())
1925 );
1926 assert_eq!(usdt_balance.locked, Money::new(0.0, Currency::USDT()));
1927 }
1928
1929 #[rstest]
1930 fn test_parse_account_state_with_margins() {
1931 let account_json = r#"{
1933 "adjEq": "10000.0",
1934 "borrowFroz": "0",
1935 "details": [{
1936 "accAvgPx": "",
1937 "availBal": "8000.0",
1938 "availEq": "8000.0",
1939 "borrowFroz": "0",
1940 "cashBal": "10000.0",
1941 "ccy": "USDT",
1942 "clSpotInUseAmt": "0",
1943 "coinUsdPrice": "1.0",
1944 "colBorrAutoConversion": "0",
1945 "collateralEnabled": false,
1946 "collateralRestrict": false,
1947 "crossLiab": "0",
1948 "disEq": "10000.0",
1949 "eq": "10000.0",
1950 "eqUsd": "10000.0",
1951 "fixedBal": "0",
1952 "frozenBal": "2000.0",
1953 "imr": "0",
1954 "interest": "0",
1955 "isoEq": "0",
1956 "isoLiab": "0",
1957 "isoUpl": "0",
1958 "liab": "0",
1959 "maxLoan": "0",
1960 "mgnRatio": "0",
1961 "maxSpotInUseAmt": "0",
1962 "mmr": "0",
1963 "notionalLever": "0",
1964 "openAvgPx": "",
1965 "ordFrozen": "2000.0",
1966 "rewardBal": "0",
1967 "smtSyncEq": "0",
1968 "spotBal": "0",
1969 "spotCopyTradingEq": "0",
1970 "spotInUseAmt": "0",
1971 "spotIsoBal": "0",
1972 "spotUpl": "0",
1973 "spotUplRatio": "0",
1974 "stgyEq": "0",
1975 "totalPnl": "0",
1976 "totalPnlRatio": "0",
1977 "twap": "0",
1978 "uTime": "1704067200000",
1979 "upl": "0",
1980 "uplLiab": "0"
1981 }],
1982 "imr": "500.25",
1983 "isoEq": "0",
1984 "mgnRatio": "20.5",
1985 "mmr": "250.75",
1986 "notionalUsd": "5000.0",
1987 "notionalUsdForBorrow": "0",
1988 "notionalUsdForFutures": "0",
1989 "notionalUsdForOption": "0",
1990 "notionalUsdForSwap": "5000.0",
1991 "ordFroz": "2000.0",
1992 "totalEq": "10000.0",
1993 "uTime": "1704067200000",
1994 "upl": "0"
1995 }"#;
1996
1997 let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
1998 let account_id = AccountId::new("OKX-001");
1999 let account_state =
2000 parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
2001
2002 assert_eq!(account_state.account_id, account_id);
2004 assert_eq!(account_state.account_type, AccountType::Margin);
2005 assert_eq!(account_state.balances.len(), 1);
2006
2007 assert_eq!(account_state.margins.len(), 1);
2009 let margin = &account_state.margins[0];
2010
2011 assert_eq!(margin.initial, Money::new(500.25, Currency::USD()));
2013 assert_eq!(margin.maintenance, Money::new(250.75, Currency::USD()));
2014 assert_eq!(margin.currency, Currency::USD());
2015 assert_eq!(margin.instrument_id.symbol.as_str(), "ACCOUNT");
2016 assert_eq!(margin.instrument_id.venue.as_str(), "OKX");
2017
2018 let usdt_balance = &account_state.balances[0];
2020 assert_eq!(usdt_balance.total, Money::new(10000.0, Currency::USDT()));
2021 assert_eq!(usdt_balance.free, Money::new(8000.0, Currency::USDT()));
2022 assert_eq!(usdt_balance.locked, Money::new(2000.0, Currency::USDT()));
2023 }
2024
2025 #[rstest]
2026 fn test_parse_account_state_empty_margins() {
2027 let account_json = r#"{
2029 "adjEq": "",
2030 "borrowFroz": "",
2031 "details": [{
2032 "accAvgPx": "",
2033 "availBal": "1000.0",
2034 "availEq": "1000.0",
2035 "borrowFroz": "0",
2036 "cashBal": "1000.0",
2037 "ccy": "BTC",
2038 "clSpotInUseAmt": "0",
2039 "coinUsdPrice": "50000.0",
2040 "colBorrAutoConversion": "0",
2041 "collateralEnabled": false,
2042 "collateralRestrict": false,
2043 "crossLiab": "0",
2044 "disEq": "50000.0",
2045 "eq": "1000.0",
2046 "eqUsd": "50000.0",
2047 "fixedBal": "0",
2048 "frozenBal": "0",
2049 "imr": "0",
2050 "interest": "0",
2051 "isoEq": "0",
2052 "isoLiab": "0",
2053 "isoUpl": "0",
2054 "liab": "0",
2055 "maxLoan": "0",
2056 "mgnRatio": "0",
2057 "maxSpotInUseAmt": "0",
2058 "mmr": "0",
2059 "notionalLever": "0",
2060 "openAvgPx": "",
2061 "ordFrozen": "0",
2062 "rewardBal": "0",
2063 "smtSyncEq": "0",
2064 "spotBal": "0",
2065 "spotCopyTradingEq": "0",
2066 "spotInUseAmt": "0",
2067 "spotIsoBal": "0",
2068 "spotUpl": "0",
2069 "spotUplRatio": "0",
2070 "stgyEq": "0",
2071 "totalPnl": "0",
2072 "totalPnlRatio": "0",
2073 "twap": "0",
2074 "uTime": "1704067200000",
2075 "upl": "0",
2076 "uplLiab": "0"
2077 }],
2078 "imr": "",
2079 "isoEq": "0",
2080 "mgnRatio": "",
2081 "mmr": "",
2082 "notionalUsd": "",
2083 "notionalUsdForBorrow": "",
2084 "notionalUsdForFutures": "",
2085 "notionalUsdForOption": "",
2086 "notionalUsdForSwap": "",
2087 "ordFroz": "",
2088 "totalEq": "50000.0",
2089 "uTime": "1704067200000",
2090 "upl": "0"
2091 }"#;
2092
2093 let okx_account: OKXAccount = serde_json::from_str(account_json).unwrap();
2094 let account_id = AccountId::new("OKX-SPOT");
2095 let account_state =
2096 parse_account_state(&okx_account, account_id, UnixNanos::default()).unwrap();
2097
2098 assert_eq!(account_state.margins.len(), 0);
2100 assert_eq!(account_state.balances.len(), 1);
2101
2102 let btc_balance = &account_state.balances[0];
2104 assert_eq!(btc_balance.total, Money::new(1000.0, Currency::BTC()));
2105 }
2106
2107 #[rstest]
2108 fn test_parse_order_status_report() {
2109 let json_data = load_test_json("http_get_orders_history.json");
2110 let response: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
2111 let okx_order = response
2112 .data
2113 .first()
2114 .expect("Test data must have an order")
2115 .clone();
2116
2117 let account_id = AccountId::new("OKX-001");
2118 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2119 let order_report = parse_order_status_report(
2120 &okx_order,
2121 account_id,
2122 instrument_id,
2123 2,
2124 8,
2125 UnixNanos::default(),
2126 );
2127
2128 assert_eq!(order_report.account_id, account_id);
2129 assert_eq!(order_report.instrument_id, instrument_id);
2130 assert_eq!(order_report.quantity, Quantity::from("0.03000000"));
2131 assert_eq!(order_report.filled_qty, Quantity::from("0.03000000"));
2132 assert_eq!(order_report.order_side, OrderSide::Buy);
2133 assert_eq!(order_report.order_type, OrderType::Market);
2134 assert_eq!(order_report.order_status, OrderStatus::Filled);
2135 }
2136
2137 #[rstest]
2138 fn test_parse_position_status_report() {
2139 let json_data = load_test_json("http_get_positions.json");
2140 let response: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
2141 let okx_position = response
2142 .data
2143 .first()
2144 .expect("Test data must have a position")
2145 .clone();
2146
2147 let account_id = AccountId::new("OKX-001");
2148 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2149 let position_report = parse_position_status_report(
2150 okx_position,
2151 account_id,
2152 instrument_id,
2153 8,
2154 UnixNanos::default(),
2155 )
2156 .unwrap();
2157
2158 assert_eq!(position_report.account_id, account_id);
2159 assert_eq!(position_report.instrument_id, instrument_id);
2160 }
2161
2162 #[rstest]
2163 fn test_parse_trade_tick() {
2164 let json_data = load_test_json("http_get_trades.json");
2165 let response: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
2166 let okx_trade = response.data.first().expect("Test data must have a trade");
2167
2168 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2169 let trade_tick =
2170 parse_trade_tick(okx_trade, instrument_id, 2, 8, UnixNanos::default()).unwrap();
2171
2172 assert_eq!(trade_tick.instrument_id, instrument_id);
2173 assert_eq!(trade_tick.price, Price::from("102537.90"));
2174 assert_eq!(trade_tick.size, Quantity::from("0.00013669"));
2175 assert_eq!(trade_tick.aggressor_side, AggressorSide::Seller);
2176 assert_eq!(trade_tick.trade_id, TradeId::new("734864333"));
2177 }
2178
2179 #[rstest]
2180 fn test_parse_mark_price_update() {
2181 let json_data = load_test_json("http_get_mark_price.json");
2182 let response: OKXResponse<crate::http::models::OKXMarkPrice> =
2183 serde_json::from_str(&json_data).unwrap();
2184 let okx_mark_price = response
2185 .data
2186 .first()
2187 .expect("Test data must have a mark price");
2188
2189 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2190 let mark_price_update =
2191 parse_mark_price_update(okx_mark_price, instrument_id, 2, UnixNanos::default())
2192 .unwrap();
2193
2194 assert_eq!(mark_price_update.instrument_id, instrument_id);
2195 assert_eq!(mark_price_update.value, Price::from("84660.10"));
2196 assert_eq!(
2197 mark_price_update.ts_event,
2198 UnixNanos::from(1744590349506000000)
2199 );
2200 }
2201
2202 #[rstest]
2203 fn test_parse_index_price_update() {
2204 let json_data = load_test_json("http_get_index_price.json");
2205 let response: OKXResponse<crate::http::models::OKXIndexTicker> =
2206 serde_json::from_str(&json_data).unwrap();
2207 let okx_index_ticker = response
2208 .data
2209 .first()
2210 .expect("Test data must have an index ticker");
2211
2212 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2213 let index_price_update =
2214 parse_index_price_update(okx_index_ticker, instrument_id, 2, UnixNanos::default())
2215 .unwrap();
2216
2217 assert_eq!(index_price_update.instrument_id, instrument_id);
2218 assert_eq!(index_price_update.value, Price::from("103895.00"));
2219 assert_eq!(
2220 index_price_update.ts_event,
2221 UnixNanos::from(1746942707815000000)
2222 );
2223 }
2224
2225 #[rstest]
2226 fn test_parse_candlestick() {
2227 let json_data = load_test_json("http_get_candlesticks.json");
2228 let response: OKXResponse<crate::http::models::OKXCandlestick> =
2229 serde_json::from_str(&json_data).unwrap();
2230 let okx_candlestick = response
2231 .data
2232 .first()
2233 .expect("Test data must have a candlestick");
2234
2235 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2236 let bar_type = BarType::new(
2237 instrument_id,
2238 BAR_SPEC_1_DAY_LAST,
2239 AggregationSource::External,
2240 );
2241 let bar = parse_candlestick(okx_candlestick, bar_type, 2, 8, UnixNanos::default()).unwrap();
2242
2243 assert_eq!(bar.bar_type, bar_type);
2244 assert_eq!(bar.open, Price::from("33528.60"));
2245 assert_eq!(bar.high, Price::from("33870.00"));
2246 assert_eq!(bar.low, Price::from("33528.60"));
2247 assert_eq!(bar.close, Price::from("33783.90"));
2248 assert_eq!(bar.volume, Quantity::from("778.83800000"));
2249 assert_eq!(bar.ts_event, UnixNanos::from(1625097600000000000));
2250 }
2251
2252 #[rstest]
2253 fn test_parse_millisecond_timestamp() {
2254 let timestamp_ms = 1625097600000u64;
2255 let result = parse_millisecond_timestamp(timestamp_ms);
2256 assert_eq!(result, UnixNanos::from(1625097600000000000));
2257 }
2258
2259 #[rstest]
2260 fn test_parse_rfc3339_timestamp() {
2261 let timestamp_str = "2021-07-01T00:00:00.000Z";
2262 let result = parse_rfc3339_timestamp(timestamp_str).unwrap();
2263 assert_eq!(result, UnixNanos::from(1625097600000000000));
2264
2265 let timestamp_str_tz = "2021-07-01T08:00:00.000+08:00";
2267 let result_tz = parse_rfc3339_timestamp(timestamp_str_tz).unwrap();
2268 assert_eq!(result_tz, UnixNanos::from(1625097600000000000));
2269
2270 let invalid_timestamp = "invalid-timestamp";
2272 assert!(parse_rfc3339_timestamp(invalid_timestamp).is_err());
2273 }
2274
2275 #[rstest]
2276 fn test_parse_price() {
2277 let price_str = "42219.5";
2278 let precision = 2;
2279 let result = parse_price(price_str, precision).unwrap();
2280 assert_eq!(result, Price::from("42219.50"));
2281
2282 let invalid_price = "invalid-price";
2284 assert!(parse_price(invalid_price, precision).is_err());
2285 }
2286
2287 #[rstest]
2288 fn test_parse_quantity() {
2289 let quantity_str = "0.12345678";
2290 let precision = 8;
2291 let result = parse_quantity(quantity_str, precision).unwrap();
2292 assert_eq!(result, Quantity::from("0.12345678"));
2293
2294 let invalid_quantity = "invalid-quantity";
2296 assert!(parse_quantity(invalid_quantity, precision).is_err());
2297 }
2298
2299 #[rstest]
2300 fn test_parse_aggressor_side() {
2301 assert_eq!(
2302 parse_aggressor_side(&Some(OKXSide::Buy)),
2303 AggressorSide::Buyer
2304 );
2305 assert_eq!(
2306 parse_aggressor_side(&Some(OKXSide::Sell)),
2307 AggressorSide::Seller
2308 );
2309 assert_eq!(parse_aggressor_side(&None), AggressorSide::NoAggressor);
2310 }
2311
2312 #[rstest]
2313 fn test_parse_execution_type() {
2314 assert_eq!(
2315 parse_execution_type(&Some(OKXExecType::Maker)),
2316 LiquiditySide::Maker
2317 );
2318 assert_eq!(
2319 parse_execution_type(&Some(OKXExecType::Taker)),
2320 LiquiditySide::Taker
2321 );
2322 assert_eq!(parse_execution_type(&None), LiquiditySide::NoLiquiditySide);
2323 }
2324
2325 #[rstest]
2326 fn test_parse_position_side() {
2327 assert_eq!(parse_position_side(Some(100)), PositionSide::Long);
2328 assert_eq!(parse_position_side(Some(-100)), PositionSide::Short);
2329 assert_eq!(parse_position_side(Some(0)), PositionSide::Flat);
2330 assert_eq!(parse_position_side(None), PositionSide::Flat);
2331 }
2332
2333 #[rstest]
2334 fn test_parse_client_order_id() {
2335 let valid_id = "client_order_123";
2336 let result = parse_client_order_id(valid_id);
2337 assert_eq!(result, Some(ClientOrderId::new(valid_id)));
2338
2339 let empty_id = "";
2340 let result_empty = parse_client_order_id(empty_id);
2341 assert_eq!(result_empty, None);
2342 }
2343
2344 #[rstest]
2345 fn test_deserialize_empty_string_as_none() {
2346 let json_with_empty = r#""""#;
2347 let result: Option<String> = serde_json::from_str(json_with_empty).unwrap();
2348 let processed = result.filter(|s| !s.is_empty());
2349 assert_eq!(processed, None);
2350
2351 let json_with_value = r#""test_value""#;
2352 let result: Option<String> = serde_json::from_str(json_with_value).unwrap();
2353 let processed = result.filter(|s| !s.is_empty());
2354 assert_eq!(processed, Some("test_value".to_string()));
2355 }
2356
2357 #[rstest]
2358 fn test_deserialize_string_to_u64() {
2359 use serde::Deserialize;
2360
2361 #[derive(Deserialize)]
2362 struct TestStruct {
2363 #[serde(deserialize_with = "deserialize_string_to_u64")]
2364 value: u64,
2365 }
2366
2367 let json_value = r#"{"value": "12345"}"#;
2368 let result: TestStruct = serde_json::from_str(json_value).unwrap();
2369 assert_eq!(result.value, 12345);
2370
2371 let json_empty = r#"{"value": ""}"#;
2372 let result_empty: TestStruct = serde_json::from_str(json_empty).unwrap();
2373 assert_eq!(result_empty.value, 0);
2374 }
2375
2376 #[rstest]
2377 fn test_fill_report_parsing() {
2378 let transaction_detail = crate::http::models::OKXTransactionDetail {
2380 inst_type: OKXInstrumentType::Spot,
2381 inst_id: Ustr::from("BTC-USDT"),
2382 trade_id: Ustr::from("12345"),
2383 ord_id: Ustr::from("67890"),
2384 cl_ord_id: Ustr::from("client_123"),
2385 bill_id: Ustr::from("bill_456"),
2386 fill_px: "42219.5".to_string(),
2387 fill_sz: "0.001".to_string(),
2388 side: OKXSide::Buy,
2389 exec_type: OKXExecType::Taker,
2390 fee_ccy: "USDT".to_string(),
2391 fee: Some("0.042".to_string()),
2392 ts: 1625097600000,
2393 };
2394
2395 let account_id = AccountId::new("OKX-001");
2396 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2397 let fill_report = parse_fill_report(
2398 transaction_detail,
2399 account_id,
2400 instrument_id,
2401 2,
2402 8,
2403 UnixNanos::default(),
2404 )
2405 .unwrap();
2406
2407 assert_eq!(fill_report.account_id, account_id);
2408 assert_eq!(fill_report.instrument_id, instrument_id);
2409 assert_eq!(fill_report.trade_id, TradeId::new("12345"));
2410 assert_eq!(fill_report.venue_order_id, VenueOrderId::new("67890"));
2411 assert_eq!(fill_report.order_side, OrderSide::Buy);
2412 assert_eq!(fill_report.last_px, Price::from("42219.50"));
2413 assert_eq!(fill_report.last_qty, Quantity::from("0.00100000"));
2414 assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
2415 }
2416
2417 #[rstest]
2418 fn test_bar_type_identity_preserved_through_parse() {
2419 use std::str::FromStr;
2420
2421 use crate::http::models::OKXCandlestick;
2422
2423 let bar_type = BarType::from_str("ETH-USDT-SWAP.OKX-1-MINUTE-LAST-EXTERNAL").unwrap();
2425
2426 let raw_candlestick = OKXCandlestick(
2428 "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(), );
2438
2439 let bar =
2441 parse_candlestick(&raw_candlestick, bar_type, 1, 3, UnixNanos::default()).unwrap();
2442
2443 assert_eq!(
2445 bar.bar_type, bar_type,
2446 "BarType must be preserved exactly through parsing"
2447 );
2448 }
2449
2450 #[rstest]
2451 fn test_deserialize_vip_level_with_lv_prefix() {
2452 use serde::Deserialize;
2453 use serde_json;
2454
2455 #[derive(Deserialize)]
2456 struct TestFeeRate {
2457 #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
2458 level: OKXVipLevel,
2459 }
2460
2461 let json = r#"{"level":"Lv4"}"#;
2462 let result: TestFeeRate = serde_json::from_str(json).unwrap();
2463 assert_eq!(result.level, OKXVipLevel::Vip4);
2464
2465 let json = r#"{"level":"Lv0"}"#;
2466 let result: TestFeeRate = serde_json::from_str(json).unwrap();
2467 assert_eq!(result.level, OKXVipLevel::Vip0);
2468
2469 let json = r#"{"level":"Lv9"}"#;
2470 let result: TestFeeRate = serde_json::from_str(json).unwrap();
2471 assert_eq!(result.level, OKXVipLevel::Vip9);
2472 }
2473
2474 #[rstest]
2475 fn test_deserialize_vip_level_without_prefix() {
2476 use serde::Deserialize;
2477 use serde_json;
2478
2479 #[derive(Deserialize)]
2480 struct TestFeeRate {
2481 #[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
2482 level: OKXVipLevel,
2483 }
2484
2485 let json = r#"{"level":"5"}"#;
2486 let result: TestFeeRate = serde_json::from_str(json).unwrap();
2487 assert_eq!(result.level, OKXVipLevel::Vip5);
2488 }
2489}