1use std::str::FromStr;
17
18use nautilus_core::{
19 UUID4,
20 datetime::{NANOSECONDS_IN_MILLISECOND, millis_to_nanos},
21 nanos::UnixNanos,
22};
23use nautilus_model::{
24 currencies::CURRENCY_MAP,
25 data::{
26 Bar, BarSpecification, BarType, Data, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
27 TradeTick,
28 bar::{
29 BAR_SPEC_1_DAY_LAST, BAR_SPEC_1_HOUR_LAST, BAR_SPEC_1_MINUTE_LAST,
30 BAR_SPEC_1_MONTH_LAST, BAR_SPEC_1_SECOND_LAST, BAR_SPEC_1_WEEK_LAST,
31 BAR_SPEC_2_DAY_LAST, BAR_SPEC_2_HOUR_LAST, BAR_SPEC_3_DAY_LAST, BAR_SPEC_3_MINUTE_LAST,
32 BAR_SPEC_3_MONTH_LAST, BAR_SPEC_4_HOUR_LAST, BAR_SPEC_5_DAY_LAST,
33 BAR_SPEC_5_MINUTE_LAST, BAR_SPEC_6_HOUR_LAST, BAR_SPEC_6_MONTH_LAST,
34 BAR_SPEC_12_HOUR_LAST, BAR_SPEC_12_MONTH_LAST, BAR_SPEC_15_MINUTE_LAST,
35 BAR_SPEC_30_MINUTE_LAST,
36 },
37 },
38 enums::{
39 AccountType, AggregationSource, AggressorSide, AssetClass, CurrencyType, LiquiditySide,
40 OptionKind, OrderSide, OrderStatus, OrderType, PositionSide, TimeInForce,
41 },
42 events::AccountState,
43 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
44 instruments::{CryptoFuture, CryptoPerpetual, CurrencyPair, InstrumentAny, OptionContract},
45 reports::{FillReport, OrderStatusReport, PositionStatusReport},
46 types::{AccountBalance, Currency, Money, Price, Quantity},
47};
48use rust_decimal::Decimal;
49use serde::{Deserialize, Deserializer, de::DeserializeOwned};
50use ustr::Ustr;
51
52use super::enums::OKXContractType;
53use crate::{
54 common::{
55 consts::OKX_VENUE,
56 enums::{
57 OKXExecType, OKXInstrumentType, OKXOrderStatus, OKXOrderType, OKXPositionSide, OKXSide,
58 },
59 models::OKXInstrument,
60 },
61 http::models::{
62 OKXAccount, OKXCandlestick, OKXIndexTicker, OKXMarkPrice, OKXOrderHistory, OKXPosition,
63 OKXTrade, OKXTransactionDetail,
64 },
65 websocket::{enums::OKXWsChannel, messages::OKXFundingRateMsg},
66};
67
68pub fn deserialize_empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
86where
87 D: Deserializer<'de>,
88{
89 let opt = Option::<String>::deserialize(deserializer)?;
90 Ok(opt.filter(|s| !s.is_empty()))
91}
92
93pub fn deserialize_empty_ustr_as_none<'de, D>(deserializer: D) -> Result<Option<Ustr>, D::Error>
99where
100 D: Deserializer<'de>,
101{
102 let opt = Option::<Ustr>::deserialize(deserializer)?;
103 Ok(opt.filter(|s| !s.is_empty()))
104}
105
106pub fn deserialize_string_to_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
112where
113 D: Deserializer<'de>,
114{
115 let s = String::deserialize(deserializer)?;
116 if s.is_empty() {
117 Ok(0)
118 } else {
119 s.parse::<u64>().map_err(serde::de::Error::custom)
120 }
121}
122
123pub fn deserialize_optional_string_to_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
129where
130 D: Deserializer<'de>,
131{
132 let s: Option<String> = Option::deserialize(deserializer)?;
133 match s {
134 Some(s) if s.is_empty() => Ok(None),
135 Some(s) => s.parse().map(Some).map_err(serde::de::Error::custom),
136 None => Ok(None),
137 }
138}
139
140fn get_currency(code: &str) -> Currency {
142 CURRENCY_MAP
143 .lock()
144 .unwrap()
145 .get(code)
146 .copied()
147 .unwrap_or(Currency::new(code, 8, 0, code, CurrencyType::Crypto))
148}
149
150pub fn okx_instrument_type(instrument: &InstrumentAny) -> anyhow::Result<OKXInstrumentType> {
157 match instrument {
158 InstrumentAny::CurrencyPair(_) => Ok(OKXInstrumentType::Spot),
159 InstrumentAny::CryptoPerpetual(_) => Ok(OKXInstrumentType::Swap),
160 InstrumentAny::CryptoFuture(_) => Ok(OKXInstrumentType::Futures),
161 InstrumentAny::CryptoOption(_) => Ok(OKXInstrumentType::Option),
162 _ => anyhow::bail!("Invalid instrument type for OKX: {instrument:?}"),
163 }
164}
165
166#[must_use]
168pub fn parse_instrument_id(symbol: Ustr) -> InstrumentId {
169 InstrumentId::new(Symbol::from_ustr_unchecked(symbol), *OKX_VENUE)
170}
171
172#[must_use]
174pub fn parse_client_order_id(value: &str) -> Option<ClientOrderId> {
175 if value.is_empty() {
176 None
177 } else {
178 Some(ClientOrderId::new(value))
179 }
180}
181
182#[must_use]
185pub fn parse_millisecond_timestamp(timestamp_ms: u64) -> UnixNanos {
186 UnixNanos::from(timestamp_ms * NANOSECONDS_IN_MILLISECOND)
187}
188
189pub fn parse_rfc3339_timestamp(timestamp: &str) -> anyhow::Result<UnixNanos> {
196 let dt = chrono::DateTime::parse_from_rfc3339(timestamp)?;
197 let nanos = dt.timestamp_nanos_opt().ok_or_else(|| {
198 anyhow::anyhow!("Failed to extract nanoseconds from timestamp: {timestamp}")
199 })?;
200 Ok(UnixNanos::from(nanos as u64))
201}
202
203pub fn parse_price(value: &str, precision: u8) -> anyhow::Result<Price> {
210 Price::new_checked(value.parse::<f64>()?, precision)
211}
212
213pub fn parse_quantity(value: &str, precision: u8) -> anyhow::Result<Quantity> {
220 Quantity::new_checked(value.parse::<f64>()?, precision)
221}
222
223pub fn parse_fee(value: Option<&str>, currency: Currency) -> anyhow::Result<Money> {
233 let fee_f64 = value.unwrap_or("0").parse::<f64>()?;
235 Money::new_checked(-fee_f64, currency)
236}
237
238pub fn parse_aggressor_side(side: &Option<OKXSide>) -> AggressorSide {
240 match side {
241 Some(OKXSide::Buy) => AggressorSide::Buyer,
242 Some(OKXSide::Sell) => AggressorSide::Seller,
243 None => AggressorSide::NoAggressor,
244 }
245}
246
247pub fn parse_execution_type(liquidity: &Option<OKXExecType>) -> LiquiditySide {
249 match liquidity {
250 Some(OKXExecType::Maker) => LiquiditySide::Maker,
251 Some(OKXExecType::Taker) => LiquiditySide::Taker,
252 _ => LiquiditySide::NoLiquiditySide,
253 }
254}
255
256pub fn parse_position_side(current_qty: Option<i64>) -> PositionSide {
258 match current_qty {
259 Some(qty) if qty > 0 => PositionSide::Long,
260 Some(qty) if qty < 0 => PositionSide::Short,
261 _ => PositionSide::Flat,
262 }
263}
264
265pub fn parse_mark_price_update(
272 raw: &OKXMarkPrice,
273 instrument_id: InstrumentId,
274 price_precision: u8,
275 ts_init: UnixNanos,
276) -> anyhow::Result<MarkPriceUpdate> {
277 let ts_event = parse_millisecond_timestamp(raw.ts);
278 let price = parse_price(&raw.mark_px, price_precision)?;
279 Ok(MarkPriceUpdate::new(
280 instrument_id,
281 price,
282 ts_event,
283 ts_init,
284 ))
285}
286
287pub fn parse_index_price_update(
294 raw: &OKXIndexTicker,
295 instrument_id: InstrumentId,
296 price_precision: u8,
297 ts_init: UnixNanos,
298) -> anyhow::Result<IndexPriceUpdate> {
299 let ts_event = parse_millisecond_timestamp(raw.ts);
300 let price = parse_price(&raw.idx_px, price_precision)?;
301 Ok(IndexPriceUpdate::new(
302 instrument_id,
303 price,
304 ts_event,
305 ts_init,
306 ))
307}
308
309pub fn parse_funding_rate_msg(
316 msg: &OKXFundingRateMsg,
317 instrument_id: InstrumentId,
318 ts_init: UnixNanos,
319) -> anyhow::Result<FundingRateUpdate> {
320 let funding_rate = msg
321 .funding_rate
322 .as_str()
323 .parse::<Decimal>()
324 .map_err(|e| anyhow::anyhow!("Invalid funding_rate value: {e}"))?
325 .normalize();
326
327 let funding_time = Some(parse_millisecond_timestamp(msg.funding_time));
328 let ts_event = parse_millisecond_timestamp(msg.ts);
329
330 Ok(FundingRateUpdate::new(
331 instrument_id,
332 funding_rate,
333 funding_time,
334 ts_event,
335 ts_init,
336 ))
337}
338
339pub fn parse_trade_tick(
346 raw: &OKXTrade,
347 instrument_id: InstrumentId,
348 price_precision: u8,
349 size_precision: u8,
350 ts_init: UnixNanos,
351) -> anyhow::Result<TradeTick> {
352 let ts_event = parse_millisecond_timestamp(raw.ts);
353 let price = parse_price(&raw.px, price_precision)?;
354 let size = parse_quantity(&raw.sz, size_precision)?;
355 let aggressor: AggressorSide = raw.side.into();
356 let trade_id = TradeId::new(raw.trade_id);
357
358 TradeTick::new_checked(
359 instrument_id,
360 price,
361 size,
362 aggressor,
363 trade_id,
364 ts_event,
365 ts_init,
366 )
367}
368
369pub fn parse_candlestick(
376 raw: &OKXCandlestick,
377 bar_type: BarType,
378 price_precision: u8,
379 size_precision: u8,
380 ts_init: UnixNanos,
381) -> anyhow::Result<Bar> {
382 let ts_event = parse_millisecond_timestamp(raw.0.parse()?);
383 let open = parse_price(&raw.1, price_precision)?;
384 let high = parse_price(&raw.2, price_precision)?;
385 let low = parse_price(&raw.3, price_precision)?;
386 let close = parse_price(&raw.4, price_precision)?;
387 let volume = parse_quantity(&raw.5, size_precision)?;
388
389 Ok(Bar::new(
390 bar_type, open, high, low, close, volume, ts_event, ts_init,
391 ))
392}
393
394#[allow(clippy::too_many_lines)]
396pub fn parse_order_status_report(
397 order: &OKXOrderHistory,
398 account_id: AccountId,
399 instrument_id: InstrumentId,
400 price_precision: u8,
401 size_precision: u8,
402 ts_init: UnixNanos,
403) -> OrderStatusReport {
404 let quantity = order
405 .sz
406 .parse::<f64>()
407 .ok()
408 .map(|v| Quantity::new(v, size_precision))
409 .unwrap_or_default();
410 let filled_qty = order
411 .acc_fill_sz
412 .parse::<f64>()
413 .ok()
414 .map(|v| Quantity::new(v, size_precision))
415 .unwrap_or_default();
416 let order_side: OrderSide = order.side.into();
417 let okx_status: OKXOrderStatus = match order.state.as_str() {
418 "live" => OKXOrderStatus::Live,
419 "partially_filled" => OKXOrderStatus::PartiallyFilled,
420 "filled" => OKXOrderStatus::Filled,
421 "canceled" => OKXOrderStatus::Canceled,
422 "mmp_canceled" => OKXOrderStatus::MmpCanceled,
423 _ => OKXOrderStatus::Live, };
425 let order_status: OrderStatus = okx_status.into();
426 let okx_ord_type: OKXOrderType = match order.ord_type.as_str() {
427 "market" => OKXOrderType::Market,
428 "limit" => OKXOrderType::Limit,
429 "post_only" => OKXOrderType::PostOnly,
430 "fok" => OKXOrderType::Fok,
431 "ioc" => OKXOrderType::Ioc,
432 "optimal_limit_ioc" => OKXOrderType::OptimalLimitIoc,
433 "mmp" => OKXOrderType::Mmp,
434 "mmp_and_post_only" => OKXOrderType::MmpAndPostOnly,
435 _ => OKXOrderType::Limit, };
437 let order_type: OrderType = okx_ord_type.into();
438 let time_in_force = TimeInForce::Gtc;
440
441 let client_ord = if order.cl_ord_id.is_empty() {
443 None
444 } else {
445 Some(ClientOrderId::new(order.cl_ord_id))
446 };
447
448 let ts_accepted = parse_millisecond_timestamp(order.c_time);
449 let ts_last = UnixNanos::from(order.u_time * NANOSECONDS_IN_MILLISECOND);
450
451 let mut report = OrderStatusReport::new(
452 account_id,
453 instrument_id,
454 client_ord,
455 VenueOrderId::new(order.ord_id),
456 order_side,
457 order_type,
458 time_in_force,
459 order_status,
460 quantity,
461 filled_qty,
462 ts_accepted,
463 ts_last,
464 ts_init,
465 None,
466 );
467
468 if !order.px.is_empty()
470 && let Ok(p) = order.px.parse::<f64>()
471 {
472 report = report.with_price(Price::new(p, price_precision));
473 }
474 if !order.avg_px.is_empty()
475 && let Ok(avg) = order.avg_px.parse::<f64>()
476 {
477 report = report.with_avg_px(avg);
478 }
479 if order.ord_type == "post_only" {
480 report = report.with_post_only(true);
481 }
482 if order.reduce_only == "true" {
483 report = report.with_reduce_only(true);
484 }
485 report
486}
487
488#[allow(clippy::too_many_lines)]
494pub fn parse_position_status_report(
495 position: OKXPosition,
496 account_id: AccountId,
497 instrument_id: InstrumentId,
498 size_precision: u8,
499 ts_init: UnixNanos,
500) -> PositionStatusReport {
501 let pos_value = position.pos.parse::<f64>().unwrap_or_else(|e| {
502 panic!(
503 "Failed to parse position quantity '{}' for instrument {}: {:?}",
504 position.pos, instrument_id, e
505 )
506 });
507
508 let position_side = match position.pos_side {
510 OKXPositionSide::Net => {
511 if pos_value > 0.0 {
512 PositionSide::Long
513 } else if pos_value < 0.0 {
514 PositionSide::Short
515 } else {
516 PositionSide::Flat
517 }
518 }
519 _ => position.pos_side.into(),
520 }
521 .as_specified();
522
523 let quantity = Quantity::new(pos_value.abs(), size_precision);
525 let venue_position_id = None; let ts_last = parse_millisecond_timestamp(position.u_time);
528
529 PositionStatusReport::new(
530 account_id,
531 instrument_id,
532 position_side,
533 quantity,
534 venue_position_id,
535 ts_last,
536 ts_init,
537 None,
538 )
539}
540
541pub fn parse_fill_report(
547 detail: OKXTransactionDetail,
548 account_id: AccountId,
549 instrument_id: InstrumentId,
550 price_precision: u8,
551 size_precision: u8,
552 ts_init: UnixNanos,
553) -> anyhow::Result<FillReport> {
554 let client_order_id = if detail.cl_ord_id.is_empty() {
555 None
556 } else {
557 Some(ClientOrderId::new(detail.cl_ord_id))
558 };
559 let venue_order_id = VenueOrderId::new(detail.ord_id);
560 let trade_id = TradeId::new(detail.trade_id);
561 let order_side: OrderSide = detail.side.into();
562 let last_px = parse_price(&detail.fill_px, price_precision)?;
563 let last_qty = parse_quantity(&detail.fill_sz, size_precision)?;
564 let fee_f64 = detail.fee.as_deref().unwrap_or("0").parse::<f64>()?;
565 let commission = Money::new(-fee_f64, Currency::from(&detail.fee_ccy));
566 let liquidity_side: LiquiditySide = detail.exec_type.into();
567 let ts_event = parse_millisecond_timestamp(detail.ts);
568
569 Ok(FillReport::new(
570 account_id,
571 instrument_id,
572 venue_order_id,
573 trade_id,
574 order_side,
575 last_qty,
576 last_px,
577 commission,
578 liquidity_side,
579 client_order_id,
580 None, ts_event,
582 ts_init,
583 None, ))
585}
586
587pub fn parse_message_vec<T, R, F, W>(
592 data: serde_json::Value,
593 parser: F,
594 wrapper: W,
595) -> anyhow::Result<Vec<Data>>
596where
597 T: DeserializeOwned,
598 F: Fn(&T) -> anyhow::Result<R>,
599 W: Fn(R) -> Data,
600{
601 let msgs: Vec<T> = serde_json::from_value(data)?;
602 let mut results = Vec::with_capacity(msgs.len());
603
604 for msg in msgs {
605 let parsed = parser(&msg)?;
606 results.push(wrapper(parsed));
607 }
608
609 Ok(results)
610}
611
612pub fn bar_spec_as_okx_channel(bar_spec: BarSpecification) -> anyhow::Result<OKXWsChannel> {
613 let channel = match bar_spec {
614 BAR_SPEC_1_SECOND_LAST => OKXWsChannel::Candle1Second,
615 BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::Candle1Minute,
616 BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::Candle3Minute,
617 BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::Candle5Minute,
618 BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::Candle15Minute,
619 BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::Candle30Minute,
620 BAR_SPEC_1_HOUR_LAST => OKXWsChannel::Candle1Hour,
621 BAR_SPEC_2_HOUR_LAST => OKXWsChannel::Candle2Hour,
622 BAR_SPEC_4_HOUR_LAST => OKXWsChannel::Candle4Hour,
623 BAR_SPEC_6_HOUR_LAST => OKXWsChannel::Candle6Hour,
624 BAR_SPEC_12_HOUR_LAST => OKXWsChannel::Candle12Hour,
625 BAR_SPEC_1_DAY_LAST => OKXWsChannel::Candle1Day,
626 BAR_SPEC_2_DAY_LAST => OKXWsChannel::Candle2Day,
627 BAR_SPEC_3_DAY_LAST => OKXWsChannel::Candle3Day,
628 BAR_SPEC_5_DAY_LAST => OKXWsChannel::Candle5Day,
629 BAR_SPEC_1_WEEK_LAST => OKXWsChannel::Candle1Week,
630 BAR_SPEC_1_MONTH_LAST => OKXWsChannel::Candle1Month,
631 BAR_SPEC_3_MONTH_LAST => OKXWsChannel::Candle3Month,
632 BAR_SPEC_6_MONTH_LAST => OKXWsChannel::Candle6Month,
633 BAR_SPEC_12_MONTH_LAST => OKXWsChannel::Candle1Year,
634 _ => anyhow::bail!("Invalid `BarSpecification` for channel, was {bar_spec}"),
635 };
636 Ok(channel)
637}
638
639pub fn bar_spec_as_okx_mark_price_channel(
641 bar_spec: BarSpecification,
642) -> anyhow::Result<OKXWsChannel> {
643 let channel = match bar_spec {
644 BAR_SPEC_1_SECOND_LAST => OKXWsChannel::MarkPriceCandle1Second,
645 BAR_SPEC_1_MINUTE_LAST => OKXWsChannel::MarkPriceCandle1Minute,
646 BAR_SPEC_3_MINUTE_LAST => OKXWsChannel::MarkPriceCandle3Minute,
647 BAR_SPEC_5_MINUTE_LAST => OKXWsChannel::MarkPriceCandle5Minute,
648 BAR_SPEC_15_MINUTE_LAST => OKXWsChannel::MarkPriceCandle15Minute,
649 BAR_SPEC_30_MINUTE_LAST => OKXWsChannel::MarkPriceCandle30Minute,
650 BAR_SPEC_1_HOUR_LAST => OKXWsChannel::MarkPriceCandle1Hour,
651 BAR_SPEC_2_HOUR_LAST => OKXWsChannel::MarkPriceCandle2Hour,
652 BAR_SPEC_4_HOUR_LAST => OKXWsChannel::MarkPriceCandle4Hour,
653 BAR_SPEC_6_HOUR_LAST => OKXWsChannel::MarkPriceCandle6Hour,
654 BAR_SPEC_12_HOUR_LAST => OKXWsChannel::MarkPriceCandle12Hour,
655 BAR_SPEC_1_DAY_LAST => OKXWsChannel::MarkPriceCandle1Day,
656 BAR_SPEC_2_DAY_LAST => OKXWsChannel::MarkPriceCandle2Day,
657 BAR_SPEC_3_DAY_LAST => OKXWsChannel::MarkPriceCandle3Day,
658 BAR_SPEC_5_DAY_LAST => OKXWsChannel::MarkPriceCandle5Day,
659 BAR_SPEC_1_WEEK_LAST => OKXWsChannel::MarkPriceCandle1Week,
660 BAR_SPEC_1_MONTH_LAST => OKXWsChannel::MarkPriceCandle1Month,
661 BAR_SPEC_3_MONTH_LAST => OKXWsChannel::MarkPriceCandle3Month,
662 _ => anyhow::bail!("Invalid `BarSpecification` for mark price channel, was {bar_spec}"),
663 };
664 Ok(channel)
665}
666
667pub fn bar_spec_as_okx_timeframe(bar_spec: BarSpecification) -> anyhow::Result<&'static str> {
669 let timeframe = match bar_spec {
670 BAR_SPEC_1_SECOND_LAST => "1s",
671 BAR_SPEC_1_MINUTE_LAST => "1m",
672 BAR_SPEC_3_MINUTE_LAST => "3m",
673 BAR_SPEC_5_MINUTE_LAST => "5m",
674 BAR_SPEC_15_MINUTE_LAST => "15m",
675 BAR_SPEC_30_MINUTE_LAST => "30m",
676 BAR_SPEC_1_HOUR_LAST => "1H",
677 BAR_SPEC_2_HOUR_LAST => "2H",
678 BAR_SPEC_4_HOUR_LAST => "4H",
679 BAR_SPEC_6_HOUR_LAST => "6H",
680 BAR_SPEC_12_HOUR_LAST => "12H",
681 BAR_SPEC_1_DAY_LAST => "1D",
682 BAR_SPEC_2_DAY_LAST => "2D",
683 BAR_SPEC_3_DAY_LAST => "3D",
684 BAR_SPEC_5_DAY_LAST => "5D",
685 BAR_SPEC_1_WEEK_LAST => "1W",
686 BAR_SPEC_1_MONTH_LAST => "1M",
687 BAR_SPEC_3_MONTH_LAST => "3M",
688 BAR_SPEC_6_MONTH_LAST => "6M",
689 BAR_SPEC_12_MONTH_LAST => "1Y",
690 _ => anyhow::bail!("Invalid `BarSpecification` for timeframe, was {bar_spec}"),
691 };
692 Ok(timeframe)
693}
694
695pub fn okx_timeframe_as_bar_spec(timeframe: &str) -> anyhow::Result<BarSpecification> {
697 let bar_spec = match timeframe {
698 "1s" => BAR_SPEC_1_SECOND_LAST,
699 "1m" => BAR_SPEC_1_MINUTE_LAST,
700 "3m" => BAR_SPEC_3_MINUTE_LAST,
701 "5m" => BAR_SPEC_5_MINUTE_LAST,
702 "15m" => BAR_SPEC_15_MINUTE_LAST,
703 "30m" => BAR_SPEC_30_MINUTE_LAST,
704 "1H" => BAR_SPEC_1_HOUR_LAST,
705 "2H" => BAR_SPEC_2_HOUR_LAST,
706 "4H" => BAR_SPEC_4_HOUR_LAST,
707 "6H" => BAR_SPEC_6_HOUR_LAST,
708 "12H" => BAR_SPEC_12_HOUR_LAST,
709 "1D" => BAR_SPEC_1_DAY_LAST,
710 "2D" => BAR_SPEC_2_DAY_LAST,
711 "3D" => BAR_SPEC_3_DAY_LAST,
712 "5D" => BAR_SPEC_5_DAY_LAST,
713 "1W" => BAR_SPEC_1_WEEK_LAST,
714 "1M" => BAR_SPEC_1_MONTH_LAST,
715 "3M" => BAR_SPEC_3_MONTH_LAST,
716 "6M" => BAR_SPEC_6_MONTH_LAST,
717 "1Y" => BAR_SPEC_12_MONTH_LAST,
718 _ => anyhow::bail!("Invalid timeframe for `BarSpecification`, was {timeframe}"),
719 };
720 Ok(bar_spec)
721}
722
723pub fn okx_bar_type_from_timeframe(
726 instrument_id: InstrumentId,
727 timeframe: &str,
728) -> anyhow::Result<BarType> {
729 let bar_spec = okx_timeframe_as_bar_spec(timeframe)?;
730 Ok(BarType::new(
731 instrument_id,
732 bar_spec,
733 AggregationSource::External,
734 ))
735}
736
737pub fn okx_channel_to_bar_spec(channel: &OKXWsChannel) -> Option<BarSpecification> {
739 use OKXWsChannel::*;
740 match channel {
741 Candle1Second | MarkPriceCandle1Second => Some(BAR_SPEC_1_SECOND_LAST),
742 Candle1Minute | MarkPriceCandle1Minute => Some(BAR_SPEC_1_MINUTE_LAST),
743 Candle3Minute | MarkPriceCandle3Minute => Some(BAR_SPEC_3_MINUTE_LAST),
744 Candle5Minute | MarkPriceCandle5Minute => Some(BAR_SPEC_5_MINUTE_LAST),
745 Candle15Minute | MarkPriceCandle15Minute => Some(BAR_SPEC_15_MINUTE_LAST),
746 Candle30Minute | MarkPriceCandle30Minute => Some(BAR_SPEC_30_MINUTE_LAST),
747 Candle1Hour | MarkPriceCandle1Hour => Some(BAR_SPEC_1_HOUR_LAST),
748 Candle2Hour | MarkPriceCandle2Hour => Some(BAR_SPEC_2_HOUR_LAST),
749 Candle4Hour | MarkPriceCandle4Hour => Some(BAR_SPEC_4_HOUR_LAST),
750 Candle6Hour | MarkPriceCandle6Hour => Some(BAR_SPEC_6_HOUR_LAST),
751 Candle12Hour | MarkPriceCandle12Hour => Some(BAR_SPEC_12_HOUR_LAST),
752 Candle1Day | MarkPriceCandle1Day => Some(BAR_SPEC_1_DAY_LAST),
753 Candle2Day | MarkPriceCandle2Day => Some(BAR_SPEC_2_DAY_LAST),
754 Candle3Day | MarkPriceCandle3Day => Some(BAR_SPEC_3_DAY_LAST),
755 Candle5Day | MarkPriceCandle5Day => Some(BAR_SPEC_5_DAY_LAST),
756 Candle1Week | MarkPriceCandle1Week => Some(BAR_SPEC_1_WEEK_LAST),
757 Candle1Month | MarkPriceCandle1Month => Some(BAR_SPEC_1_MONTH_LAST),
758 Candle3Month | MarkPriceCandle3Month => Some(BAR_SPEC_3_MONTH_LAST),
759 Candle6Month => Some(BAR_SPEC_6_MONTH_LAST),
760 Candle1Year => Some(BAR_SPEC_12_MONTH_LAST),
761 _ => None,
762 }
763}
764
765pub fn parse_instrument_any(
767 instrument: &OKXInstrument,
768 ts_init: UnixNanos,
769) -> anyhow::Result<Option<InstrumentAny>> {
770 match instrument.inst_type {
771 OKXInstrumentType::Spot => {
772 parse_spot_instrument(instrument, None, None, None, None, ts_init).map(Some)
773 }
774 OKXInstrumentType::Swap => {
775 parse_swap_instrument(instrument, None, None, None, None, ts_init).map(Some)
776 }
777 OKXInstrumentType::Futures => {
778 parse_futures_instrument(instrument, None, None, None, None, ts_init).map(Some)
779 }
780 OKXInstrumentType::Option => {
781 parse_option_instrument(instrument, None, None, None, None, ts_init).map(Some)
782 }
783 _ => Ok(None),
784 }
785}
786
787#[derive(Debug)]
789struct CommonInstrumentData {
790 instrument_id: InstrumentId,
791 raw_symbol: Symbol,
792 price_increment: Price,
793 size_increment: Quantity,
794 lot_size: Option<Quantity>,
795 max_quantity: Option<Quantity>,
796 min_quantity: Option<Quantity>,
797 max_notional: Option<Money>,
798 min_notional: Option<Money>,
799 max_price: Option<Price>,
800 min_price: Option<Price>,
801}
802
803struct MarginAndFees {
805 margin_init: Option<Decimal>,
806 margin_maint: Option<Decimal>,
807 maker_fee: Option<Decimal>,
808 taker_fee: Option<Decimal>,
809}
810
811trait InstrumentParser {
813 fn parse_specific_fields(
815 &self,
816 definition: &OKXInstrument,
817 common: CommonInstrumentData,
818 margin_fees: MarginAndFees,
819 ts_init: UnixNanos,
820 ) -> anyhow::Result<InstrumentAny>;
821}
822
823fn parse_common_instrument_data(
825 definition: &OKXInstrument,
826) -> anyhow::Result<CommonInstrumentData> {
827 let instrument_id = parse_instrument_id(definition.inst_id);
828 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
829
830 let price_increment = Price::from_str(&definition.tick_sz).map_err(|e| {
831 anyhow::anyhow!(
832 "Failed to parse tick_sz '{}' into Price: {}",
833 definition.tick_sz,
834 e
835 )
836 })?;
837
838 let size_increment = Quantity::from(&definition.lot_sz);
839 let lot_size = Some(Quantity::from(&definition.lot_sz));
840 let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
841 let min_quantity = Some(Quantity::from(&definition.min_sz));
842 let max_notional: Option<Money> = None;
843 let min_notional: Option<Money> = None;
844 let max_price = None; let min_price = None; Ok(CommonInstrumentData {
848 instrument_id,
849 raw_symbol,
850 price_increment,
851 size_increment,
852 lot_size,
853 max_quantity,
854 min_quantity,
855 max_notional,
856 min_notional,
857 max_price,
858 min_price,
859 })
860}
861
862fn parse_instrument_with_parser<P: InstrumentParser>(
864 definition: &OKXInstrument,
865 parser: P,
866 margin_init: Option<Decimal>,
867 margin_maint: Option<Decimal>,
868 maker_fee: Option<Decimal>,
869 taker_fee: Option<Decimal>,
870 ts_init: UnixNanos,
871) -> anyhow::Result<InstrumentAny> {
872 let common = parse_common_instrument_data(definition)?;
873 parser.parse_specific_fields(
874 definition,
875 common,
876 MarginAndFees {
877 margin_init,
878 margin_maint,
879 maker_fee,
880 taker_fee,
881 },
882 ts_init,
883 )
884}
885
886struct SpotInstrumentParser;
888
889impl InstrumentParser for SpotInstrumentParser {
890 fn parse_specific_fields(
891 &self,
892 definition: &OKXInstrument,
893 common: CommonInstrumentData,
894 margin_fees: MarginAndFees,
895 ts_init: UnixNanos,
896 ) -> anyhow::Result<InstrumentAny> {
897 let base_currency = get_currency(&definition.base_ccy);
898 let quote_currency = get_currency(&definition.quote_ccy);
899
900 let instrument = CurrencyPair::new(
901 common.instrument_id,
902 common.raw_symbol,
903 base_currency,
904 quote_currency,
905 common.price_increment.precision,
906 common.size_increment.precision,
907 common.price_increment,
908 common.size_increment,
909 None,
910 common.lot_size,
911 common.max_quantity,
912 common.min_quantity,
913 common.max_notional,
914 common.min_notional,
915 common.max_price,
916 common.min_price,
917 margin_fees.margin_init,
918 margin_fees.margin_maint,
919 margin_fees.maker_fee,
920 margin_fees.taker_fee,
921 ts_init,
922 ts_init,
923 );
924
925 Ok(InstrumentAny::CurrencyPair(instrument))
926 }
927}
928
929pub fn parse_spot_instrument(
931 definition: &OKXInstrument,
932 margin_init: Option<Decimal>,
933 margin_maint: Option<Decimal>,
934 maker_fee: Option<Decimal>,
935 taker_fee: Option<Decimal>,
936 ts_init: UnixNanos,
937) -> anyhow::Result<InstrumentAny> {
938 parse_instrument_with_parser(
939 definition,
940 SpotInstrumentParser,
941 margin_init,
942 margin_maint,
943 maker_fee,
944 taker_fee,
945 ts_init,
946 )
947}
948
949pub fn parse_swap_instrument(
951 definition: &OKXInstrument,
952 margin_init: Option<Decimal>,
953 margin_maint: Option<Decimal>,
954 maker_fee: Option<Decimal>,
955 taker_fee: Option<Decimal>,
956 ts_init: UnixNanos,
957) -> anyhow::Result<InstrumentAny> {
958 let instrument_id = parse_instrument_id(definition.inst_id);
959 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
960 let (base_currency, quote_currency) = definition
961 .uly
962 .split_once('-')
963 .ok_or_else(|| anyhow::anyhow!("Invalid underlying for swap: {}", definition.uly))?;
964 let base_currency = get_currency(base_currency);
965 let quote_currency = get_currency(quote_currency);
966 let settlement_currency = get_currency(&definition.settle_ccy);
967 let is_inverse = match definition.ct_type {
968 OKXContractType::Linear => false,
969 OKXContractType::Inverse => true,
970 OKXContractType::None => {
971 anyhow::bail!("Invalid contract type for swap: {}", definition.ct_type)
972 }
973 };
974 let price_increment = match Price::from_str(&definition.tick_sz) {
975 Ok(price) => price,
976 Err(e) => {
977 anyhow::bail!(
978 "Failed to parse tick_size '{}' into Price: {}",
979 definition.tick_sz,
980 e
981 );
982 }
983 };
984 let size_increment = Quantity::from(&definition.lot_sz);
985 let multiplier = Some(Quantity::from(&definition.ct_mult));
986 let lot_size = Some(Quantity::from(&definition.lot_sz));
987 let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
988 let min_quantity = Some(Quantity::from(&definition.min_sz));
989 let max_notional: Option<Money> = None;
990 let min_notional: Option<Money> = None;
991 let max_price = None; let min_price = None; let (size_precision, adjusted_size_increment) = if is_inverse {
999 (size_increment.precision, size_increment)
1001 } else {
1002 let precision = 8u8;
1005 let adjusted_increment = Quantity::new(1.0, precision); (precision, adjusted_increment)
1007 };
1008
1009 let instrument = CryptoPerpetual::new(
1010 instrument_id,
1011 raw_symbol,
1012 base_currency,
1013 quote_currency,
1014 settlement_currency,
1015 is_inverse,
1016 price_increment.precision,
1017 size_precision,
1018 price_increment,
1019 adjusted_size_increment,
1020 multiplier,
1021 lot_size,
1022 max_quantity,
1023 min_quantity,
1024 max_notional,
1025 min_notional,
1026 max_price,
1027 min_price,
1028 margin_init,
1029 margin_maint,
1030 maker_fee,
1031 taker_fee,
1032 ts_init, ts_init,
1034 );
1035
1036 Ok(InstrumentAny::CryptoPerpetual(instrument))
1037}
1038
1039pub fn parse_futures_instrument(
1041 definition: &OKXInstrument,
1042 margin_init: Option<Decimal>,
1043 margin_maint: Option<Decimal>,
1044 maker_fee: Option<Decimal>,
1045 taker_fee: Option<Decimal>,
1046 ts_init: UnixNanos,
1047) -> anyhow::Result<InstrumentAny> {
1048 let instrument_id = parse_instrument_id(definition.inst_id);
1049 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1050 let underlying = get_currency(&definition.uly);
1051 let (_, quote_currency) = definition
1052 .uly
1053 .split_once('-')
1054 .ok_or_else(|| anyhow::anyhow!("Invalid underlying for Swap: {}", definition.uly))?;
1055 let quote_currency = get_currency(quote_currency);
1056 let settlement_currency = get_currency(&definition.settle_ccy);
1057 let is_inverse = match definition.ct_type {
1058 OKXContractType::Linear => false,
1059 OKXContractType::Inverse => true,
1060 OKXContractType::None => {
1061 anyhow::bail!("Invalid contract type for futures: {}", definition.ct_type)
1062 }
1063 };
1064 let listing_time = definition
1065 .list_time
1066 .ok_or_else(|| anyhow::anyhow!("`listing_time` is required to parse Swap instrument"))?;
1067 let expiry_time = definition
1068 .exp_time
1069 .ok_or_else(|| anyhow::anyhow!("`expiry_time` is required to parse Swap instrument"))?;
1070 let activation_ns = UnixNanos::from(millis_to_nanos(listing_time as f64));
1071 let expiration_ns = UnixNanos::from(millis_to_nanos(expiry_time as f64));
1072 let price_increment = Price::from(definition.tick_sz.to_string());
1073 let size_increment = Quantity::from(&definition.lot_sz);
1074 let multiplier = Some(Quantity::from(&definition.ct_mult));
1075 let lot_size = Some(Quantity::from(&definition.lot_sz));
1076 let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1077 let min_quantity = Some(Quantity::from(&definition.min_sz));
1078 let max_notional: Option<Money> = None;
1079 let min_notional: Option<Money> = None;
1080 let max_price = None; let min_price = None; let instrument = CryptoFuture::new(
1084 instrument_id,
1085 raw_symbol,
1086 underlying,
1087 quote_currency,
1088 settlement_currency,
1089 is_inverse,
1090 activation_ns,
1091 expiration_ns,
1092 price_increment.precision,
1093 size_increment.precision,
1094 price_increment,
1095 size_increment,
1096 multiplier,
1097 lot_size,
1098 max_quantity,
1099 min_quantity,
1100 max_notional,
1101 min_notional,
1102 max_price,
1103 min_price,
1104 margin_init,
1105 margin_maint,
1106 maker_fee,
1107 taker_fee,
1108 ts_init, ts_init,
1110 );
1111
1112 Ok(InstrumentAny::CryptoFuture(instrument))
1113}
1114
1115pub fn parse_option_instrument(
1117 definition: &OKXInstrument,
1118 margin_init: Option<Decimal>,
1119 margin_maint: Option<Decimal>,
1120 maker_fee: Option<Decimal>,
1121 taker_fee: Option<Decimal>,
1122 ts_init: UnixNanos,
1123) -> anyhow::Result<InstrumentAny> {
1124 let instrument_id = parse_instrument_id(definition.inst_id);
1125 let raw_symbol = Symbol::from_ustr_unchecked(definition.inst_id);
1126 let asset_class = AssetClass::Cryptocurrency;
1127 let exchange = Some(Ustr::from("OKX"));
1128 let underlying = Ustr::from(&definition.uly);
1129 let option_kind: OptionKind = definition.opt_type.into();
1130 let strike_price = Price::from(&definition.stk);
1131 let currency = definition
1132 .uly
1133 .split_once('-')
1134 .map(|(_, quote_ccy)| get_currency(quote_ccy))
1135 .ok_or_else(|| {
1136 anyhow::anyhow!(
1137 "Invalid underlying for Option instrument: {}",
1138 definition.uly
1139 )
1140 })?;
1141 let listing_time = definition
1142 .list_time
1143 .ok_or_else(|| anyhow::anyhow!("`listing_time` is required to parse Option instrument"))?;
1144 let expiry_time = definition
1145 .exp_time
1146 .ok_or_else(|| anyhow::anyhow!("`expiry_time` is required to parse Option instrument"))?;
1147 let activation_ns = UnixNanos::from(millis_to_nanos(listing_time as f64));
1148 let expiration_ns = UnixNanos::from(millis_to_nanos(expiry_time as f64));
1149 let price_increment = Price::from(definition.tick_sz.to_string());
1150 let multiplier = Quantity::from(&definition.ct_mult);
1151 let lot_size = Quantity::from(&definition.lot_sz);
1152 let max_quantity = Some(Quantity::from(&definition.max_mkt_sz));
1153 let min_quantity = Some(Quantity::from(&definition.min_sz));
1154 let max_price = None; let min_price = None; let instrument = OptionContract::new(
1158 instrument_id,
1159 raw_symbol,
1160 asset_class,
1161 exchange,
1162 underlying,
1163 option_kind,
1164 strike_price,
1165 currency,
1166 activation_ns,
1167 expiration_ns,
1168 price_increment.precision,
1169 price_increment,
1170 multiplier,
1171 lot_size,
1172 max_quantity,
1173 min_quantity,
1174 max_price,
1175 min_price,
1176 margin_init,
1177 margin_maint,
1178 maker_fee,
1179 taker_fee,
1180 ts_init, ts_init,
1182 );
1183
1184 Ok(InstrumentAny::OptionContract(instrument))
1185}
1186
1187pub fn parse_account_state(
1189 okx_account: &OKXAccount,
1190 account_id: AccountId,
1191 ts_init: UnixNanos,
1192) -> anyhow::Result<AccountState> {
1193 let mut balances = Vec::new();
1194 for b in &okx_account.details {
1195 let currency = Currency::from(b.ccy);
1196 let total = Money::new(b.cash_bal.parse::<f64>()?, currency);
1197 let free = Money::new(b.avail_bal.parse::<f64>()?, currency);
1198 let locked = total - free;
1199 let balance = AccountBalance::new(total, locked, free);
1200 balances.push(balance);
1201 }
1202 let margins = vec![]; let account_type = AccountType::Margin;
1205 let is_reported = true;
1206 let event_id = UUID4::new();
1207 let ts_event = UnixNanos::from(millis_to_nanos(okx_account.u_time as f64));
1208
1209 Ok(AccountState::new(
1210 account_id,
1211 account_type,
1212 balances,
1213 margins,
1214 is_reported,
1215 event_id,
1216 ts_event,
1217 ts_init,
1218 None,
1219 ))
1220}
1221
1222#[cfg(test)]
1227mod tests {
1228 use nautilus_model::{
1229 enums::AggregationSource, identifiers::InstrumentId, instruments::Instrument,
1230 };
1231 use rstest::rstest;
1232
1233 use super::*;
1234 use crate::{common::testing::load_test_json, http::client::OKXResponse};
1235
1236 #[rstest]
1237 fn test_parse_spot_instrument() {
1238 let json_data = load_test_json("http_get_instruments_spot.json");
1239 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1240 let okx_inst: &OKXInstrument = response
1241 .data
1242 .first()
1243 .expect("Test data must have an instrument");
1244
1245 let instrument =
1246 parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
1247
1248 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD.OKX"));
1249 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD"));
1250 assert_eq!(instrument.underlying(), None);
1251 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
1252 assert_eq!(instrument.quote_currency(), Currency::USD());
1253 assert_eq!(instrument.price_precision(), 1);
1254 assert_eq!(instrument.size_precision(), 8);
1255 assert_eq!(instrument.price_increment(), Price::from("0.1"));
1256 assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
1257 }
1258
1259 #[rstest]
1260 fn test_parse_margin_instrument() {
1261 let json_data = load_test_json("http_get_instruments_margin.json");
1262 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1263 let okx_inst: &OKXInstrument = response
1264 .data
1265 .first()
1266 .expect("Test data must have an instrument");
1267
1268 let instrument =
1269 parse_spot_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
1270
1271 assert_eq!(instrument.id(), InstrumentId::from("BTC-USDT.OKX"));
1272 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USDT"));
1273 assert_eq!(instrument.underlying(), None);
1274 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
1275 assert_eq!(instrument.quote_currency(), Currency::USDT());
1276 assert_eq!(instrument.price_precision(), 1);
1277 assert_eq!(instrument.size_precision(), 8);
1278 assert_eq!(instrument.price_increment(), Price::from("0.1"));
1279 assert_eq!(instrument.size_increment(), Quantity::from("0.00000001"));
1280 }
1281
1282 #[rstest]
1283 fn test_parse_swap_instrument() {
1284 let json_data = load_test_json("http_get_instruments_swap.json");
1285 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1286 let okx_inst: &OKXInstrument = response
1287 .data
1288 .first()
1289 .expect("Test data must have an instrument");
1290
1291 let instrument =
1292 parse_swap_instrument(okx_inst, None, None, None, None, UnixNanos::default()).unwrap();
1293
1294 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-SWAP.OKX"));
1295 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-SWAP"));
1296 assert_eq!(instrument.underlying(), None);
1297 assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
1298 assert_eq!(instrument.quote_currency(), Currency::USD());
1299 assert_eq!(instrument.price_precision(), 1);
1300 assert_eq!(instrument.size_precision(), 0);
1301 assert_eq!(instrument.price_increment(), Price::from("0.1"));
1302 assert_eq!(instrument.size_increment(), Quantity::from(1));
1303 }
1304
1305 #[rstest]
1306 fn test_parse_futures_instrument() {
1307 let json_data = load_test_json("http_get_instruments_futures.json");
1308 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1309 let okx_inst: &OKXInstrument = response
1310 .data
1311 .first()
1312 .expect("Test data must have an instrument");
1313
1314 let instrument =
1315 parse_futures_instrument(okx_inst, None, None, None, None, UnixNanos::default())
1316 .unwrap();
1317
1318 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-241220.OKX"));
1319 assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-USD-241220"));
1320 assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
1321 assert_eq!(instrument.quote_currency(), Currency::USD());
1322 assert_eq!(instrument.price_precision(), 1);
1323 assert_eq!(instrument.size_precision(), 0);
1324 assert_eq!(instrument.price_increment(), Price::from("0.1"));
1325 assert_eq!(instrument.size_increment(), Quantity::from(1));
1326 }
1327
1328 #[rstest]
1329 fn test_parse_option_instrument() {
1330 let json_data = load_test_json("http_get_instruments_option.json");
1331 let response: OKXResponse<OKXInstrument> = serde_json::from_str(&json_data).unwrap();
1332 let okx_inst: &OKXInstrument = response
1333 .data
1334 .first()
1335 .expect("Test data must have an instrument");
1336
1337 let instrument =
1338 parse_option_instrument(okx_inst, None, None, None, None, UnixNanos::default())
1339 .unwrap();
1340
1341 assert_eq!(
1342 instrument.id(),
1343 InstrumentId::from("BTC-USD-241217-92000-C.OKX")
1344 );
1345 assert_eq!(
1346 instrument.raw_symbol(),
1347 Symbol::from("BTC-USD-241217-92000-C")
1348 );
1349 assert_eq!(instrument.underlying(), Some(Ustr::from("BTC-USD")));
1350 assert_eq!(instrument.quote_currency(), Currency::USD());
1351 assert_eq!(instrument.price_precision(), 4);
1352 assert_eq!(instrument.size_precision(), 0);
1353 assert_eq!(instrument.price_increment(), Price::from("0.0001"));
1354 assert_eq!(instrument.size_increment(), Quantity::from(1));
1355 }
1356
1357 #[rstest]
1358 fn test_parse_account_state() {
1359 let json_data = load_test_json("http_get_account_balance.json");
1360 let response: OKXResponse<OKXAccount> = serde_json::from_str(&json_data).unwrap();
1361 let okx_account = response
1362 .data
1363 .first()
1364 .expect("Test data must have an account");
1365
1366 let account_id = AccountId::new("OKX-001");
1367 let account_state =
1368 parse_account_state(okx_account, account_id, UnixNanos::default()).unwrap();
1369
1370 assert_eq!(account_state.account_id, account_id);
1371 assert_eq!(account_state.account_type, AccountType::Margin);
1372 assert_eq!(account_state.balances.len(), 1);
1373 assert_eq!(account_state.margins.len(), 0); assert!(account_state.is_reported);
1375
1376 let usdt_balance = &account_state.balances[0];
1378 assert_eq!(
1379 usdt_balance.total,
1380 Money::new(94.42612990333333, Currency::USDT())
1381 );
1382 assert_eq!(
1383 usdt_balance.free,
1384 Money::new(94.42612990333333, Currency::USDT())
1385 );
1386 assert_eq!(usdt_balance.locked, Money::new(0.0, Currency::USDT()));
1387 }
1388
1389 #[rstest]
1390 fn test_parse_order_status_report() {
1391 let json_data = load_test_json("http_get_orders_history.json");
1392 let response: OKXResponse<OKXOrderHistory> = serde_json::from_str(&json_data).unwrap();
1393 let okx_order = response
1394 .data
1395 .first()
1396 .expect("Test data must have an order")
1397 .clone();
1398
1399 let account_id = AccountId::new("OKX-001");
1400 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
1401 let order_report = parse_order_status_report(
1402 &okx_order,
1403 account_id,
1404 instrument_id,
1405 2,
1406 8,
1407 UnixNanos::default(),
1408 );
1409
1410 assert_eq!(order_report.account_id, account_id);
1411 assert_eq!(order_report.instrument_id, instrument_id);
1412 assert_eq!(order_report.quantity, Quantity::from("0.03000000"));
1413 assert_eq!(order_report.filled_qty, Quantity::from("0.03000000"));
1414 assert_eq!(order_report.order_side, OrderSide::Buy);
1415 assert_eq!(order_report.order_type, OrderType::Market);
1416 assert_eq!(order_report.order_status, OrderStatus::Filled);
1417 }
1418
1419 #[rstest]
1420 fn test_parse_position_status_report() {
1421 let json_data = load_test_json("http_get_positions.json");
1422 let response: OKXResponse<OKXPosition> = serde_json::from_str(&json_data).unwrap();
1423 let okx_position = response
1424 .data
1425 .first()
1426 .expect("Test data must have a position")
1427 .clone();
1428
1429 let account_id = AccountId::new("OKX-001");
1430 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1431 let position_report = parse_position_status_report(
1432 okx_position,
1433 account_id,
1434 instrument_id,
1435 8,
1436 UnixNanos::default(),
1437 );
1438
1439 assert_eq!(position_report.account_id, account_id);
1440 assert_eq!(position_report.instrument_id, instrument_id);
1441 }
1442
1443 #[rstest]
1444 fn test_parse_trade_tick() {
1445 let json_data = load_test_json("http_get_trades.json");
1446 let response: OKXResponse<OKXTrade> = serde_json::from_str(&json_data).unwrap();
1447 let okx_trade = response.data.first().expect("Test data must have a trade");
1448
1449 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1450 let trade_tick =
1451 parse_trade_tick(okx_trade, instrument_id, 2, 8, UnixNanos::default()).unwrap();
1452
1453 assert_eq!(trade_tick.instrument_id, instrument_id);
1454 assert_eq!(trade_tick.price, Price::from("102537.90"));
1455 assert_eq!(trade_tick.size, Quantity::from("0.00013669"));
1456 assert_eq!(trade_tick.aggressor_side, AggressorSide::Seller);
1457 assert_eq!(trade_tick.trade_id, TradeId::new("734864333"));
1458 }
1459
1460 #[rstest]
1461 fn test_parse_mark_price_update() {
1462 let json_data = load_test_json("http_get_mark_price.json");
1463 let response: OKXResponse<crate::http::models::OKXMarkPrice> =
1464 serde_json::from_str(&json_data).unwrap();
1465 let okx_mark_price = response
1466 .data
1467 .first()
1468 .expect("Test data must have a mark price");
1469
1470 let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
1471 let mark_price_update =
1472 parse_mark_price_update(okx_mark_price, instrument_id, 2, UnixNanos::default())
1473 .unwrap();
1474
1475 assert_eq!(mark_price_update.instrument_id, instrument_id);
1476 assert_eq!(mark_price_update.value, Price::from("84660.10"));
1477 assert_eq!(
1478 mark_price_update.ts_event,
1479 UnixNanos::from(1744590349506000000)
1480 );
1481 }
1482
1483 #[rstest]
1484 fn test_parse_index_price_update() {
1485 let json_data = load_test_json("http_get_index_price.json");
1486 let response: OKXResponse<crate::http::models::OKXIndexTicker> =
1487 serde_json::from_str(&json_data).unwrap();
1488 let okx_index_ticker = response
1489 .data
1490 .first()
1491 .expect("Test data must have an index ticker");
1492
1493 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1494 let index_price_update =
1495 parse_index_price_update(okx_index_ticker, instrument_id, 2, UnixNanos::default())
1496 .unwrap();
1497
1498 assert_eq!(index_price_update.instrument_id, instrument_id);
1499 assert_eq!(index_price_update.value, Price::from("103895.00"));
1500 assert_eq!(
1501 index_price_update.ts_event,
1502 UnixNanos::from(1746942707815000000)
1503 );
1504 }
1505
1506 #[rstest]
1507 fn test_parse_candlestick() {
1508 let json_data = load_test_json("http_get_candlesticks.json");
1509 let response: OKXResponse<crate::http::models::OKXCandlestick> =
1510 serde_json::from_str(&json_data).unwrap();
1511 let okx_candlestick = response
1512 .data
1513 .first()
1514 .expect("Test data must have a candlestick");
1515
1516 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1517 let bar_type = BarType::new(
1518 instrument_id,
1519 BAR_SPEC_1_DAY_LAST,
1520 AggregationSource::External,
1521 );
1522 let bar = parse_candlestick(okx_candlestick, bar_type, 2, 8, UnixNanos::default()).unwrap();
1523
1524 assert_eq!(bar.bar_type, bar_type);
1525 assert_eq!(bar.open, Price::from("33528.60"));
1526 assert_eq!(bar.high, Price::from("33870.00"));
1527 assert_eq!(bar.low, Price::from("33528.60"));
1528 assert_eq!(bar.close, Price::from("33783.90"));
1529 assert_eq!(bar.volume, Quantity::from("778.83800000"));
1530 assert_eq!(bar.ts_event, UnixNanos::from(1625097600000000000));
1531 }
1532
1533 #[rstest]
1534 fn test_parse_millisecond_timestamp() {
1535 let timestamp_ms = 1625097600000u64;
1536 let result = parse_millisecond_timestamp(timestamp_ms);
1537 assert_eq!(result, UnixNanos::from(1625097600000000000));
1538 }
1539
1540 #[rstest]
1541 fn test_parse_rfc3339_timestamp() {
1542 let timestamp_str = "2021-07-01T00:00:00.000Z";
1543 let result = parse_rfc3339_timestamp(timestamp_str).unwrap();
1544 assert_eq!(result, UnixNanos::from(1625097600000000000));
1545
1546 let timestamp_str_tz = "2021-07-01T08:00:00.000+08:00";
1548 let result_tz = parse_rfc3339_timestamp(timestamp_str_tz).unwrap();
1549 assert_eq!(result_tz, UnixNanos::from(1625097600000000000));
1550
1551 let invalid_timestamp = "invalid-timestamp";
1553 assert!(parse_rfc3339_timestamp(invalid_timestamp).is_err());
1554 }
1555
1556 #[rstest]
1557 fn test_parse_price() {
1558 let price_str = "42219.5";
1559 let precision = 2;
1560 let result = parse_price(price_str, precision).unwrap();
1561 assert_eq!(result, Price::from("42219.50"));
1562
1563 let invalid_price = "invalid-price";
1565 assert!(parse_price(invalid_price, precision).is_err());
1566 }
1567
1568 #[rstest]
1569 fn test_parse_quantity() {
1570 let quantity_str = "0.12345678";
1571 let precision = 8;
1572 let result = parse_quantity(quantity_str, precision).unwrap();
1573 assert_eq!(result, Quantity::from("0.12345678"));
1574
1575 let invalid_quantity = "invalid-quantity";
1577 assert!(parse_quantity(invalid_quantity, precision).is_err());
1578 }
1579
1580 #[rstest]
1581 fn test_parse_aggressor_side() {
1582 assert_eq!(
1583 parse_aggressor_side(&Some(OKXSide::Buy)),
1584 AggressorSide::Buyer
1585 );
1586 assert_eq!(
1587 parse_aggressor_side(&Some(OKXSide::Sell)),
1588 AggressorSide::Seller
1589 );
1590 assert_eq!(parse_aggressor_side(&None), AggressorSide::NoAggressor);
1591 }
1592
1593 #[rstest]
1594 fn test_parse_execution_type() {
1595 assert_eq!(
1596 parse_execution_type(&Some(OKXExecType::Maker)),
1597 LiquiditySide::Maker
1598 );
1599 assert_eq!(
1600 parse_execution_type(&Some(OKXExecType::Taker)),
1601 LiquiditySide::Taker
1602 );
1603 assert_eq!(parse_execution_type(&None), LiquiditySide::NoLiquiditySide);
1604 }
1605
1606 #[rstest]
1607 fn test_parse_position_side() {
1608 assert_eq!(parse_position_side(Some(100)), PositionSide::Long);
1609 assert_eq!(parse_position_side(Some(-100)), PositionSide::Short);
1610 assert_eq!(parse_position_side(Some(0)), PositionSide::Flat);
1611 assert_eq!(parse_position_side(None), PositionSide::Flat);
1612 }
1613
1614 #[rstest]
1615 fn test_parse_client_order_id() {
1616 let valid_id = "client_order_123";
1617 let result = parse_client_order_id(valid_id);
1618 assert_eq!(result, Some(ClientOrderId::new(valid_id)));
1619
1620 let empty_id = "";
1621 let result_empty = parse_client_order_id(empty_id);
1622 assert_eq!(result_empty, None);
1623 }
1624
1625 #[rstest]
1626 fn test_deserialize_empty_string_as_none() {
1627 let json_with_empty = r#""""#;
1628 let result: Option<String> = serde_json::from_str(json_with_empty).unwrap();
1629 let processed = result.filter(|s| !s.is_empty());
1630 assert_eq!(processed, None);
1631
1632 let json_with_value = r#""test_value""#;
1633 let result: Option<String> = serde_json::from_str(json_with_value).unwrap();
1634 let processed = result.filter(|s| !s.is_empty());
1635 assert_eq!(processed, Some("test_value".to_string()));
1636 }
1637
1638 #[rstest]
1639 fn test_deserialize_string_to_u64() {
1640 use serde::Deserialize;
1641
1642 #[derive(Deserialize)]
1643 struct TestStruct {
1644 #[serde(deserialize_with = "deserialize_string_to_u64")]
1645 value: u64,
1646 }
1647
1648 let json_value = r#"{"value": "12345"}"#;
1649 let result: TestStruct = serde_json::from_str(json_value).unwrap();
1650 assert_eq!(result.value, 12345);
1651
1652 let json_empty = r#"{"value": ""}"#;
1653 let result_empty: TestStruct = serde_json::from_str(json_empty).unwrap();
1654 assert_eq!(result_empty.value, 0);
1655 }
1656
1657 #[rstest]
1658 fn test_fill_report_parsing() {
1659 let transaction_detail = crate::http::models::OKXTransactionDetail {
1661 inst_type: OKXInstrumentType::Spot,
1662 inst_id: Ustr::from("BTC-USDT"),
1663 trade_id: Ustr::from("12345"),
1664 ord_id: Ustr::from("67890"),
1665 cl_ord_id: Ustr::from("client_123"),
1666 bill_id: Ustr::from("bill_456"),
1667 fill_px: "42219.5".to_string(),
1668 fill_sz: "0.001".to_string(),
1669 side: OKXSide::Buy,
1670 exec_type: OKXExecType::Taker,
1671 fee_ccy: "USDT".to_string(),
1672 fee: Some("0.042".to_string()),
1673 ts: 1625097600000,
1674 };
1675
1676 let account_id = AccountId::new("OKX-001");
1677 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1678 let fill_report = parse_fill_report(
1679 transaction_detail,
1680 account_id,
1681 instrument_id,
1682 2,
1683 8,
1684 UnixNanos::default(),
1685 )
1686 .unwrap();
1687
1688 assert_eq!(fill_report.account_id, account_id);
1689 assert_eq!(fill_report.instrument_id, instrument_id);
1690 assert_eq!(fill_report.trade_id, TradeId::new("12345"));
1691 assert_eq!(fill_report.venue_order_id, VenueOrderId::new("67890"));
1692 assert_eq!(fill_report.order_side, OrderSide::Buy);
1693 assert_eq!(fill_report.last_px, Price::from("42219.50"));
1694 assert_eq!(fill_report.last_qty, Quantity::from("0.00100000"));
1695 assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
1696 }
1697
1698 #[rstest]
1699 fn test_bar_type_identity_preserved_through_parse() {
1700 use std::str::FromStr;
1701
1702 use crate::http::models::OKXCandlestick;
1703
1704 let bar_type = BarType::from_str("ETH-USDT-SWAP.OKX-1-MINUTE-LAST-EXTERNAL").unwrap();
1706
1707 let raw_candlestick = OKXCandlestick(
1709 "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(), );
1719
1720 let bar =
1722 parse_candlestick(&raw_candlestick, bar_type, 1, 3, UnixNanos::default()).unwrap();
1723
1724 assert_eq!(
1726 bar.bar_type, bar_type,
1727 "BarType must be preserved exactly through parsing"
1728 );
1729 }
1730}