1use std::str::FromStr;
19
20use anyhow::Context;
21use nautilus_core::{
22 datetime::{NANOSECONDS_IN_MICROSECOND, NANOSECONDS_IN_MILLISECOND},
23 nanos::UnixNanos,
24 uuid::UUID4,
25};
26use nautilus_model::{
27 data::{Bar, BarType, BookOrder, TradeTick},
28 enums::{AccountType, AggressorSide, BookType, InstrumentClass, OptionKind, OrderSide},
29 events::AccountState,
30 identifiers::{AccountId, InstrumentId, Symbol, TradeId},
31 instruments::{
32 CryptoFuture, CryptoFuturesSpread, CryptoOption, CryptoOptionSpread, CryptoPerpetual,
33 CurrencyPair, Instrument, any::InstrumentAny,
34 },
35 orderbook::OrderBook,
36 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
37};
38use rust_decimal::Decimal;
39use ustr::Ustr;
40
41use crate::{
42 common::{
43 consts::DERIBIT_VENUE,
44 enums::{DeribitOptionType, DeribitProductType},
45 },
46 http::models::{
47 DeribitAccountSummary, DeribitInstrument, DeribitOrderBook, DeribitPublicTrade,
48 DeribitTradingViewChartData,
49 },
50 websocket::messages::DeribitPortfolioMsg,
51};
52
53#[must_use]
71pub fn parse_instrument_kind_currency(instrument_id: &InstrumentId) -> (String, String) {
72 let symbol = instrument_id.symbol.as_str();
73
74 let kind = if symbol.contains("PERPETUAL") {
77 "future" } else if symbol.ends_with("-C") || symbol.ends_with("-P") {
79 "option"
81 } else if symbol.contains('_') && !symbol.contains('-') {
82 "spot"
84 } else if is_combo_symbol(symbol) {
85 match second_segment(symbol) {
89 Some("FS") => "future_combo",
90 _ => "option_combo",
91 }
92 } else {
93 "future"
95 };
96
97 let currency = if let Some(idx) = symbol.find('-') {
100 let first_part = &symbol[..idx];
103 if let Some(underscore_idx) = first_part.find('_') {
104 first_part[..underscore_idx].to_string()
105 } else {
106 first_part.to_string()
107 }
108 } else if let Some(idx) = symbol.find('_') {
109 symbol[..idx].to_string()
111 } else {
112 "any".to_string()
113 };
114
115 (kind.to_string(), currency)
116}
117
118fn second_segment(symbol: &str) -> Option<&str> {
124 let mut parts = symbol.split('-');
125 parts.next()?;
126 parts.next()
127}
128
129fn is_combo_symbol(symbol: &str) -> bool {
135 let Some(seg) = second_segment(symbol) else {
136 return false;
137 };
138
139 if seg.is_empty() || seg == "PERPETUAL" {
140 return false;
141 }
142 seg.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
144 && seg.chars().all(|c| c.is_ascii_alphabetic())
145}
146
147pub fn extract_server_timestamp(us_out: Option<u64>) -> anyhow::Result<UnixNanos> {
153 let us_out =
154 us_out.ok_or_else(|| anyhow::anyhow!("Missing server timestamp (us_out) in response"))?;
155 Ok(UnixNanos::from(us_out * NANOSECONDS_IN_MICROSECOND))
156}
157
158pub fn parse_deribit_instrument_any(
169 instrument: &DeribitInstrument,
170 ts_init: UnixNanos,
171 ts_event: UnixNanos,
172) -> anyhow::Result<Option<InstrumentAny>> {
173 match instrument.kind {
174 DeribitProductType::Spot => parse_spot_instrument(instrument, ts_init, ts_event).map(Some),
175 DeribitProductType::Future => {
176 if instrument.instrument_name.as_str().contains("PERPETUAL") {
178 parse_perpetual_instrument(instrument, ts_init, ts_event).map(Some)
179 } else {
180 parse_future_instrument(instrument, ts_init, ts_event).map(Some)
181 }
182 }
183 DeribitProductType::Option => {
184 parse_option_instrument(instrument, ts_init, ts_event).map(Some)
185 }
186 DeribitProductType::FutureCombo => {
187 parse_future_combo_instrument(instrument, ts_init, ts_event).map(Some)
188 }
189 DeribitProductType::OptionCombo => {
190 parse_option_combo_instrument(instrument, ts_init, ts_event).map(Some)
191 }
192 }
193}
194
195fn parse_spot_instrument(
197 instrument: &DeribitInstrument,
198 ts_init: UnixNanos,
199 ts_event: UnixNanos,
200) -> anyhow::Result<InstrumentAny> {
201 let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
202
203 let base_currency = Currency::get_or_create_crypto(instrument.base_currency);
204 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
205
206 let price_increment = Price::from_decimal(instrument.tick_size)?;
207 let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
208 let min_quantity = Quantity::from_decimal(instrument.min_trade_amount)?;
209
210 let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
211 .context("Failed to parse maker_commission")?;
212 let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
213 .context("Failed to parse taker_commission")?;
214
215 let currency_pair = CurrencyPair::new(
216 instrument_id,
217 instrument.instrument_name.into(),
218 base_currency,
219 quote_currency,
220 price_increment.precision,
221 size_increment.precision,
222 price_increment,
223 size_increment,
224 None, None, None, Some(min_quantity),
228 None, None, None, None, None, None, Some(maker_fee),
235 Some(taker_fee),
236 None,
237 None,
238 ts_event,
239 ts_init,
240 );
241
242 Ok(InstrumentAny::CurrencyPair(currency_pair))
243}
244
245fn parse_perpetual_instrument(
247 instrument: &DeribitInstrument,
248 ts_init: UnixNanos,
249 ts_event: UnixNanos,
250) -> anyhow::Result<InstrumentAny> {
251 let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
252
253 let base_currency = Currency::get_or_create_crypto(instrument.base_currency);
254 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
255 let settlement_currency = instrument
256 .settlement_currency
257 .map_or(base_currency, Currency::get_or_create_crypto);
258
259 let is_inverse = instrument
260 .instrument_type
261 .as_ref()
262 .is_some_and(|t| t == "reversed");
263
264 let price_increment = Price::from_decimal(instrument.tick_size)?;
265 let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
266 let min_quantity = Quantity::from_decimal(instrument.min_trade_amount)?;
267
268 let multiplier = Some(deribit_amount_quantity_multiplier());
269 let lot_size = Some(size_increment);
270
271 let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
272 .context("Failed to parse maker_commission")?;
273 let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
274 .context("Failed to parse taker_commission")?;
275
276 let perpetual = CryptoPerpetual::new(
277 instrument_id,
278 instrument.instrument_name.into(),
279 base_currency,
280 quote_currency,
281 settlement_currency,
282 is_inverse,
283 price_increment.precision,
284 size_increment.precision,
285 price_increment,
286 size_increment,
287 multiplier,
288 lot_size,
289 None, Some(min_quantity),
291 None, None, None, None, None, None, Some(maker_fee),
298 Some(taker_fee),
299 None,
300 None,
301 ts_event,
302 ts_init,
303 );
304
305 Ok(InstrumentAny::CryptoPerpetual(perpetual))
306}
307
308fn parse_future_instrument(
310 instrument: &DeribitInstrument,
311 ts_init: UnixNanos,
312 ts_event: UnixNanos,
313) -> anyhow::Result<InstrumentAny> {
314 let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
315
316 let underlying = Currency::get_or_create_crypto(instrument.base_currency);
317 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
318 let settlement_currency = instrument
319 .settlement_currency
320 .map_or(underlying, Currency::get_or_create_crypto);
321
322 let is_inverse = instrument
323 .instrument_type
324 .as_ref()
325 .is_some_and(|t| t == "reversed");
326
327 let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
329 let expiration_ns = instrument
330 .expiration_timestamp
331 .context("Missing expiration_timestamp for future")? as u64
332 * 1_000_000; let price_increment = Price::from_decimal(instrument.tick_size)?;
335 let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
336 let min_quantity = Quantity::from_decimal(instrument.min_trade_amount)?;
337
338 let multiplier = Some(deribit_amount_quantity_multiplier());
339 let lot_size = Some(size_increment); let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
342 .context("Failed to parse maker_commission")?;
343 let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
344 .context("Failed to parse taker_commission")?;
345
346 let future = CryptoFuture::new(
347 instrument_id,
348 instrument.instrument_name.into(),
349 underlying,
350 quote_currency,
351 settlement_currency,
352 is_inverse,
353 UnixNanos::from(activation_ns),
354 UnixNanos::from(expiration_ns),
355 price_increment.precision,
356 size_increment.precision,
357 price_increment,
358 size_increment,
359 multiplier,
360 lot_size,
361 None, Some(min_quantity),
363 None, None, None, None, None, None, Some(maker_fee),
370 Some(taker_fee),
371 None,
372 None,
373 ts_event,
374 ts_init,
375 );
376
377 Ok(InstrumentAny::CryptoFuture(future))
378}
379
380fn parse_option_instrument(
382 instrument: &DeribitInstrument,
383 ts_init: UnixNanos,
384 ts_event: UnixNanos,
385) -> anyhow::Result<InstrumentAny> {
386 let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
387 let underlying = Currency::get_or_create_crypto(instrument.base_currency);
388 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
389 let settlement = instrument
390 .settlement_currency
391 .unwrap_or(instrument.base_currency);
392 let settlement_currency = Currency::get_or_create_crypto(settlement);
393
394 let is_inverse = instrument
396 .instrument_type
397 .as_ref()
398 .is_some_and(|t| t == "reversed");
399
400 let option_kind = match instrument.option_type {
402 Some(DeribitOptionType::Call) => OptionKind::Call,
403 Some(DeribitOptionType::Put) => OptionKind::Put,
404 None => anyhow::bail!("Missing option_type for option instrument"),
405 };
406
407 let strike = instrument.strike.context("Missing strike for option")?;
409 let strike_price = Price::from_decimal(strike)?;
410
411 let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
413 let expiration_ns = instrument
414 .expiration_timestamp
415 .context("Missing expiration_timestamp for option")? as u64
416 * 1_000_000;
417
418 let price_increment = Price::from_decimal(instrument.tick_size)?;
419
420 let multiplier = deribit_amount_quantity_multiplier();
421 let lot_size = Quantity::from_decimal(instrument.min_trade_amount)?;
422 let min_trade_amount = Quantity::from_decimal(instrument.min_trade_amount)?;
423
424 let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
425 .context("Failed to parse maker_commission")?;
426 let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
427 .context("Failed to parse taker_commission")?;
428
429 let option = CryptoOption::new(
430 instrument_id,
431 instrument.instrument_name.into(),
432 underlying,
433 quote_currency,
434 settlement_currency,
435 is_inverse,
436 option_kind,
437 strike_price,
438 UnixNanos::from(activation_ns),
439 UnixNanos::from(expiration_ns),
440 price_increment.precision,
441 lot_size.precision,
442 price_increment,
443 lot_size,
444 Some(multiplier),
445 Some(lot_size),
446 None,
447 Some(min_trade_amount),
448 None,
449 None,
450 None,
451 None,
452 None,
453 None,
454 Some(maker_fee),
455 Some(taker_fee),
456 None,
457 None,
458 ts_event,
459 ts_init,
460 );
461
462 Ok(InstrumentAny::CryptoOption(option))
463}
464
465fn parse_option_combo_instrument(
467 instrument: &DeribitInstrument,
468 ts_init: UnixNanos,
469 ts_event: UnixNanos,
470) -> anyhow::Result<InstrumentAny> {
471 let spread = build_spread_common(instrument, ts_init, ts_event)?;
472 let option_spread = CryptoOptionSpread::new(
473 spread.id,
474 spread.raw_symbol,
475 spread.underlying,
476 spread.quote_currency,
477 spread.settlement_currency,
478 spread.is_inverse,
479 spread.strategy_type,
480 spread.activation_ns,
481 spread.expiration_ns,
482 spread.price_precision,
483 spread.size_precision,
484 spread.price_increment,
485 spread.size_increment,
486 Some(spread.multiplier),
487 Some(spread.lot_size),
488 None,
489 Some(spread.size_increment),
490 None,
491 None,
492 None,
493 None,
494 None,
495 None,
496 Some(spread.maker_fee),
497 Some(spread.taker_fee),
498 None,
499 None,
500 ts_event,
501 ts_init,
502 );
503 Ok(InstrumentAny::CryptoOptionSpread(option_spread))
504}
505
506fn parse_future_combo_instrument(
508 instrument: &DeribitInstrument,
509 ts_init: UnixNanos,
510 ts_event: UnixNanos,
511) -> anyhow::Result<InstrumentAny> {
512 let spread = build_spread_common(instrument, ts_init, ts_event)?;
513 let futures_spread = CryptoFuturesSpread::new(
514 spread.id,
515 spread.raw_symbol,
516 spread.underlying,
517 spread.quote_currency,
518 spread.settlement_currency,
519 spread.is_inverse,
520 spread.strategy_type,
521 spread.activation_ns,
522 spread.expiration_ns,
523 spread.price_precision,
524 spread.size_precision,
525 spread.price_increment,
526 spread.size_increment,
527 Some(spread.multiplier),
528 Some(spread.lot_size),
529 None,
530 Some(spread.size_increment),
531 None,
532 None,
533 None,
534 None,
535 None,
536 None,
537 Some(spread.maker_fee),
538 Some(spread.taker_fee),
539 None,
540 None,
541 ts_event,
542 ts_init,
543 );
544 Ok(InstrumentAny::CryptoFuturesSpread(futures_spread))
545}
546
547struct DeribitSpreadCommon {
550 id: InstrumentId,
551 raw_symbol: Symbol,
552 underlying: Currency,
553 quote_currency: Currency,
554 settlement_currency: Currency,
555 is_inverse: bool,
556 strategy_type: Ustr,
557 activation_ns: UnixNanos,
558 expiration_ns: UnixNanos,
559 price_precision: u8,
560 price_increment: Price,
561 size_precision: u8,
562 size_increment: Quantity,
563 multiplier: Quantity,
564 lot_size: Quantity,
565 maker_fee: Decimal,
566 taker_fee: Decimal,
567}
568
569fn build_spread_common(
570 instrument: &DeribitInstrument,
571 _ts_init: UnixNanos,
572 _ts_event: UnixNanos,
573) -> anyhow::Result<DeribitSpreadCommon> {
574 let id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
575 let raw_symbol = Symbol::new(instrument.instrument_name);
576 let underlying = Currency::get_or_create_crypto(instrument.base_currency);
577 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
578 let settlement_currency = instrument
579 .settlement_currency
580 .map_or(underlying, Currency::get_or_create_crypto);
581 let is_inverse = instrument
582 .instrument_type
583 .as_ref()
584 .is_some_and(|t| t == "reversed");
585 let strategy_type = second_segment(instrument.instrument_name.as_str())
586 .map_or_else(|| Ustr::from("SPREAD"), Ustr::from);
587
588 let activation_ns = UnixNanos::from((instrument.creation_timestamp as u64) * 1_000_000);
589 let expiration_ns = UnixNanos::from(
590 instrument
591 .expiration_timestamp
592 .context("Missing expiration_timestamp for combo")? as u64
593 * 1_000_000,
594 );
595
596 let price_increment = Price::from_decimal(instrument.tick_size)?;
597 let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
598 let multiplier = deribit_amount_quantity_multiplier();
599
600 let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
601 .context("Failed to parse maker_commission")?;
602 let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
603 .context("Failed to parse taker_commission")?;
604
605 Ok(DeribitSpreadCommon {
606 id,
607 raw_symbol,
608 underlying,
609 quote_currency,
610 settlement_currency,
611 is_inverse,
612 strategy_type,
613 activation_ns,
614 expiration_ns,
615 price_precision: price_increment.precision,
616 price_increment,
617 size_precision: size_increment.precision,
618 size_increment,
619 multiplier,
620 lot_size: size_increment,
621 maker_fee,
622 taker_fee,
623 })
624}
625
626fn deribit_amount_quantity_multiplier() -> Quantity {
627 Quantity::from(1)
629}
630
631pub fn parse_account_state(
641 summaries: &[DeribitAccountSummary],
642 account_id: AccountId,
643 ts_init: UnixNanos,
644 ts_event: UnixNanos,
645) -> anyhow::Result<AccountState> {
646 let mut balances = Vec::new();
647 let mut margins = Vec::new();
648
649 for summary in summaries {
651 let ccy_str = summary.currency.as_str().trim();
652
653 if ccy_str.is_empty() {
655 log::debug!("Skipping balance detail with empty currency code | raw_data={summary:?}");
656 continue;
657 }
658
659 let currency = Currency::get_or_create_crypto_with_context(
660 ccy_str,
661 Some("DERIBIT - Parsing account state"),
662 );
663
664 let is_cross_margin = summary.cross_collateral_enabled.unwrap_or(false);
676 let (total, free) = if is_cross_margin {
677 (
678 summary.equity,
679 summary.available_withdrawal_funds.unwrap_or(Decimal::ZERO),
680 )
681 } else {
682 (summary.margin_balance, summary.available_funds)
683 };
684 let balance = AccountBalance::from_total_and_free(total, free, currency)?;
685 balances.push(balance);
686
687 if let (Some(initial_margin), Some(maintenance_margin)) =
689 (summary.initial_margin, summary.maintenance_margin)
690 && (!initial_margin.is_zero() || !maintenance_margin.is_zero())
691 {
692 let initial = Money::from_decimal(initial_margin, currency)?;
693 let maintenance = Money::from_decimal(maintenance_margin, currency)?;
694 margins.push(MarginBalance::new(initial, maintenance, None));
697 }
698 }
699
700 if balances.is_empty() {
702 let zero_currency = Currency::USD();
703 let zero_money = Money::zero(zero_currency);
704 let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
705 balances.push(zero_balance);
706 }
707
708 let account_type = AccountType::Margin;
709 let is_reported = true;
710
711 Ok(AccountState::new(
712 account_id,
713 account_type,
714 balances,
715 margins,
716 is_reported,
717 UUID4::new(),
718 ts_event,
719 ts_init,
720 None,
721 ))
722}
723
724pub fn parse_portfolio_to_account_state(
737 portfolio: &DeribitPortfolioMsg,
738 account_id: AccountId,
739 ts_init: UnixNanos,
740) -> anyhow::Result<AccountState> {
741 let ccy_str = portfolio.currency.trim();
742
743 if ccy_str.is_empty() {
745 anyhow::bail!("Portfolio message has empty currency code");
746 }
747
748 let currency = Currency::get_or_create_crypto_with_context(
749 ccy_str,
750 Some("DERIBIT - Parsing portfolio update"),
751 );
752
753 let is_cross_margin = portfolio.cross_collateral_enabled.unwrap_or(false);
757 let (total, free) = if is_cross_margin {
758 (
759 portfolio.equity,
760 portfolio
761 .available_withdrawal_funds
762 .unwrap_or(Decimal::ZERO),
763 )
764 } else {
765 (portfolio.margin_balance, portfolio.available_funds)
766 };
767 let balance = AccountBalance::from_total_and_free(total, free, currency)?;
768 let balances = vec![balance];
769
770 let mut margins = Vec::new();
772 let initial_margin = portfolio.initial_margin;
773 let maintenance_margin = portfolio.maintenance_margin;
774
775 if !initial_margin.is_zero() || !maintenance_margin.is_zero() {
777 let initial = Money::from_decimal(initial_margin, currency)?;
778 let maintenance = Money::from_decimal(maintenance_margin, currency)?;
779 margins.push(MarginBalance::new(initial, maintenance, None));
782 }
783
784 let account_type = AccountType::Margin;
785 let is_reported = true;
786
787 Ok(AccountState::new(
788 account_id,
789 account_type,
790 balances,
791 margins,
792 is_reported,
793 UUID4::new(),
794 ts_init, ts_init,
796 None,
797 ))
798}
799
800#[must_use]
813pub fn build_public_trade_id(
814 trade_id: &str,
815 block_rfq_id: Option<i64>,
816 block_trade_id: Option<&str>,
817 combo_id: Option<&str>,
818) -> TradeId {
819 if block_rfq_id.is_some() {
820 TradeId::new(format!("RFQ-{trade_id}"))
821 } else if block_trade_id.is_some() {
822 TradeId::new(format!("BLK-{trade_id}"))
823 } else if combo_id.is_some() {
824 TradeId::new(format!("COMBO-{trade_id}"))
825 } else {
826 TradeId::new(trade_id)
827 }
828}
829
830pub fn parse_trade_tick(
838 trade: &DeribitPublicTrade,
839 instrument_id: InstrumentId,
840 price_precision: u8,
841 size_precision: u8,
842 ts_init: UnixNanos,
843) -> anyhow::Result<TradeTick> {
844 let aggressor_side = match trade.direction.as_str() {
846 "buy" => AggressorSide::Buyer,
847 "sell" => AggressorSide::Seller,
848 other => anyhow::bail!("Invalid trade direction: {other}"),
849 };
850 let price = Price::from_decimal_dp(trade.price, price_precision)?;
851 let size = Quantity::from_decimal_dp(trade.amount, size_precision)?;
852 let ts_event = UnixNanos::from((trade.timestamp as u64) * NANOSECONDS_IN_MILLISECOND);
853 let trade_id = build_public_trade_id(
854 &trade.trade_id,
855 trade.block_rfq_id,
856 trade.block_trade_id.as_deref(),
857 trade.combo_id.as_deref(),
858 );
859
860 Ok(TradeTick::new(
861 instrument_id,
862 price,
863 size,
864 aggressor_side,
865 trade_id,
866 ts_event,
867 ts_init,
868 ))
869}
870
871#[must_use]
882pub fn use_cost_for_bar_volume(instrument: &InstrumentAny) -> bool {
883 if !instrument.is_inverse() {
884 return false;
885 }
886 !matches!(
887 instrument.instrument_class(),
888 InstrumentClass::Option | InstrumentClass::OptionSpread
889 )
890}
891
892pub fn parse_bars(
907 chart_data: &DeribitTradingViewChartData,
908 bar_type: BarType,
909 price_precision: u8,
910 size_precision: u8,
911 use_cost_for_volume: bool,
912 ts_init: UnixNanos,
913) -> anyhow::Result<Vec<Bar>> {
914 if chart_data.status != "ok" {
916 anyhow::bail!(
917 "Chart data status is '{}', expected 'ok'",
918 chart_data.status
919 );
920 }
921
922 let num_bars = chart_data.ticks.len();
923
924 anyhow::ensure!(
926 chart_data.open.len() == num_bars
927 && chart_data.high.len() == num_bars
928 && chart_data.low.len() == num_bars
929 && chart_data.close.len() == num_bars
930 && chart_data.volume.len() == num_bars
931 && chart_data.cost.len() == num_bars,
932 "Inconsistent array lengths in chart data"
933 );
934
935 if num_bars == 0 {
936 return Ok(Vec::new());
937 }
938
939 let mut bars = Vec::with_capacity(num_bars);
940
941 for i in 0..num_bars {
942 let open = Price::new_checked(chart_data.open[i], price_precision)
943 .with_context(|| format!("Invalid open price at index {i}"))?;
944 let high = Price::new_checked(chart_data.high[i], price_precision)
945 .with_context(|| format!("Invalid high price at index {i}"))?;
946 let low = Price::new_checked(chart_data.low[i], price_precision)
947 .with_context(|| format!("Invalid low price at index {i}"))?;
948 let close = Price::new_checked(chart_data.close[i], price_precision)
949 .with_context(|| format!("Invalid close price at index {i}"))?;
950 let raw_volume = if use_cost_for_volume {
951 chart_data.cost[i]
952 } else {
953 chart_data.volume[i]
954 };
955 let volume = Quantity::new_checked(raw_volume, size_precision)
956 .with_context(|| format!("Invalid volume at index {i}"))?;
957
958 let ts_event = UnixNanos::from((chart_data.ticks[i] as u64) * NANOSECONDS_IN_MILLISECOND);
960
961 let bar = Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
962 .with_context(|| format!("Invalid OHLC bar at index {i}"))?;
963 bars.push(bar);
964 }
965
966 Ok(bars)
967}
968
969pub fn parse_order_book(
978 order_book_data: &DeribitOrderBook,
979 instrument_id: InstrumentId,
980 price_precision: u8,
981 size_precision: u8,
982 ts_init: UnixNanos,
983) -> anyhow::Result<OrderBook> {
984 let ts_event = UnixNanos::from((order_book_data.timestamp as u64) * NANOSECONDS_IN_MILLISECOND);
985 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
986
987 for (idx, [price, amount]) in order_book_data.bids.iter().enumerate() {
988 let order = BookOrder::new(
989 OrderSide::Buy,
990 Price::new(*price, price_precision),
991 Quantity::new(*amount, size_precision),
992 idx as u64,
993 );
994 book.add(order, 0, idx as u64, ts_event);
995 }
996
997 let bids_len = order_book_data.bids.len();
998 for (idx, [price, amount]) in order_book_data.asks.iter().enumerate() {
999 let order = BookOrder::new(
1000 OrderSide::Sell,
1001 Price::new(*price, price_precision),
1002 Quantity::new(*amount, size_precision),
1003 (bids_len + idx) as u64,
1004 );
1005 book.add(order, 0, (bids_len + idx) as u64, ts_event);
1006 }
1007
1008 book.ts_last = ts_init;
1009
1010 Ok(book)
1011}
1012
1013pub fn bar_spec_to_resolution(bar_type: &BarType) -> String {
1017 use nautilus_model::enums::BarAggregation;
1018
1019 let spec = bar_type.spec();
1020 match spec.aggregation {
1021 BarAggregation::Minute => {
1022 let step = spec.step.get();
1023 match step {
1025 1 => "1".to_string(),
1026 2..=3 => "3".to_string(),
1027 4..=5 => "5".to_string(),
1028 6..=10 => "10".to_string(),
1029 11..=15 => "15".to_string(),
1030 16..=30 => "30".to_string(),
1031 31..=60 => "60".to_string(),
1032 61..=120 => "120".to_string(),
1033 121..=180 => "180".to_string(),
1034 181..=360 => "360".to_string(),
1035 361..=720 => "720".to_string(),
1036 _ => "1D".to_string(),
1037 }
1038 }
1039 BarAggregation::Hour => {
1040 let step = spec.step.get();
1041 match step {
1042 1 => "60".to_string(),
1043 2 => "120".to_string(),
1044 3 => "180".to_string(),
1045 4..=6 => "360".to_string(),
1046 7..=12 => "720".to_string(),
1047 _ => "1D".to_string(),
1048 }
1049 }
1050 BarAggregation::Day => "1D".to_string(),
1051 _ => {
1052 log::warn!(
1053 "Unsupported bar aggregation {:?}, defaulting to 1 minute",
1054 spec.aggregation
1055 );
1056 "1".to_string()
1057 }
1058 }
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063 use nautilus_model::{instruments::Instrument, types::Money};
1064 use rstest::rstest;
1065 use rust_decimal_macros::dec;
1066
1067 use super::*;
1068 use crate::{
1069 common::testing::load_test_json,
1070 http::models::{
1071 DeribitAccountSummariesResponse, DeribitJsonRpcResponse, DeribitTradesResponse,
1072 },
1073 };
1074
1075 #[rstest]
1076 fn test_parse_perpetual_instrument() {
1077 let json_data = load_test_json("http_get_instrument.json");
1078 let response: DeribitJsonRpcResponse<DeribitInstrument> =
1079 serde_json::from_str(&json_data).unwrap();
1080 let deribit_inst = response.result.expect("Test data must have result");
1081
1082 let instrument_any =
1083 parse_deribit_instrument_any(&deribit_inst, UnixNanos::default(), UnixNanos::default())
1084 .unwrap();
1085 let instrument = instrument_any.expect("Should parse perpetual instrument");
1086
1087 let InstrumentAny::CryptoPerpetual(perpetual) = instrument else {
1088 panic!("Expected CryptoPerpetual, was {instrument:?}");
1089 };
1090 assert_eq!(perpetual.id(), InstrumentId::from("BTC-PERPETUAL.DERIBIT"));
1091 assert_eq!(perpetual.raw_symbol(), Symbol::from("BTC-PERPETUAL"));
1092 assert_eq!(perpetual.base_currency().unwrap().code, "BTC");
1093 assert_eq!(perpetual.quote_currency().code, "USD");
1094 assert_eq!(perpetual.settlement_currency().code, "BTC");
1095 assert!(perpetual.is_inverse());
1096 assert_eq!(perpetual.price_precision(), 1);
1097 assert_eq!(perpetual.size_precision(), 0);
1098 assert_eq!(perpetual.price_increment(), Price::from("0.5"));
1099 assert_eq!(perpetual.size_increment(), Quantity::from("10"));
1100 assert_eq!(perpetual.multiplier(), Quantity::from("1"));
1101 assert_eq!(
1102 perpetual.calculate_notional_value(
1103 Quantity::from("10"),
1104 Price::from("50000"),
1105 Some(false)
1106 ),
1107 Money::from("0.0002 BTC")
1108 );
1109 assert_eq!(
1110 perpetual.calculate_notional_value(
1111 Quantity::from("10"),
1112 Price::from("50000"),
1113 Some(true)
1114 ),
1115 Money::from("10 USD")
1116 );
1117 assert_eq!(perpetual.lot_size(), Some(Quantity::from("10")));
1118 assert_eq!(perpetual.maker_fee(), dec!(0));
1119 assert_eq!(perpetual.taker_fee(), dec!(0.0005));
1120 assert_eq!(perpetual.max_quantity(), None);
1121 assert_eq!(perpetual.min_quantity(), Some(Quantity::from("10")));
1122 }
1123
1124 #[rstest]
1125 fn test_parse_future_instrument() {
1126 let json_data = load_test_json("http_get_instruments.json");
1127 let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1128 serde_json::from_str(&json_data).unwrap();
1129 let instruments = response.result.expect("Test data must have result");
1130 let deribit_inst = instruments
1131 .iter()
1132 .find(|i| i.instrument_name.as_str() == "BTC-27DEC24")
1133 .expect("Test data must contain BTC-27DEC24");
1134
1135 let instrument_any =
1136 parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
1137 .unwrap();
1138 let instrument = instrument_any.expect("Should parse future instrument");
1139
1140 let InstrumentAny::CryptoFuture(future) = instrument else {
1141 panic!("Expected CryptoFuture, was {instrument:?}");
1142 };
1143 assert_eq!(future.id(), InstrumentId::from("BTC-27DEC24.DERIBIT"));
1144 assert_eq!(future.raw_symbol(), Symbol::from("BTC-27DEC24"));
1145 assert_eq!(future.underlying().unwrap(), "BTC");
1146 assert_eq!(future.quote_currency().code, "USD");
1147 assert_eq!(future.settlement_currency().code, "BTC");
1148 assert!(future.is_inverse());
1149
1150 assert_eq!(
1152 future.activation_ns(),
1153 Some(UnixNanos::from(1719561600000_u64 * 1_000_000))
1154 );
1155 assert_eq!(
1156 future.expiration_ns(),
1157 Some(UnixNanos::from(1735300800000_u64 * 1_000_000))
1158 );
1159 assert_eq!(future.price_precision(), 1);
1160 assert_eq!(future.size_precision(), 0);
1161 assert_eq!(future.price_increment(), Price::from("0.5"));
1162 assert_eq!(future.size_increment(), Quantity::from("10"));
1163 assert_eq!(future.multiplier(), Quantity::from("1"));
1164 assert_eq!(future.lot_size(), Some(Quantity::from("10")));
1165 assert_eq!(future.maker_fee, dec!(0));
1166 assert_eq!(future.taker_fee, dec!(0.0005));
1167 }
1168
1169 #[rstest]
1170 fn test_parse_option_instrument() {
1171 let json_data = load_test_json("http_get_instruments.json");
1172 let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1173 serde_json::from_str(&json_data).unwrap();
1174 let instruments = response.result.expect("Test data must have result");
1175 let deribit_inst = instruments
1176 .iter()
1177 .find(|i| i.instrument_name.as_str() == "BTC-27DEC24-100000-C")
1178 .expect("Test data must contain BTC-27DEC24-100000-C");
1179
1180 let instrument_any =
1181 parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
1182 .unwrap();
1183 let instrument = instrument_any.expect("Should parse option instrument");
1184
1185 let InstrumentAny::CryptoOption(option) = instrument else {
1187 panic!("Expected CryptoOption, was {instrument:?}");
1188 };
1189
1190 assert_eq!(
1191 option.id(),
1192 InstrumentId::from("BTC-27DEC24-100000-C.DERIBIT")
1193 );
1194 assert_eq!(option.raw_symbol(), Symbol::from("BTC-27DEC24-100000-C"));
1195 assert_eq!(option.underlying.code.as_str(), "BTC");
1196 assert_eq!(option.quote_currency.code.as_str(), "BTC");
1197 assert_eq!(option.settlement_currency.code.as_str(), "BTC");
1198 assert!(option.is_inverse);
1199 assert_eq!(option.option_kind, OptionKind::Call);
1200 assert_eq!(option.strike_price, Price::from("100000"));
1201 assert_eq!(
1202 option.activation_ns,
1203 UnixNanos::from(1719561600000_u64 * 1_000_000)
1204 );
1205 assert_eq!(
1206 option.expiration_ns,
1207 UnixNanos::from(1735300800000_u64 * 1_000_000)
1208 );
1209 assert_eq!(option.price_precision, 4);
1210 assert_eq!(option.price_increment, Price::from("0.0005"));
1211 assert_eq!(option.size_precision, 1);
1212 assert_eq!(option.size_increment, Quantity::from("0.1"));
1213 assert_eq!(option.multiplier, Quantity::from("1"));
1214 assert_eq!(option.lot_size, Quantity::from("0.1"));
1215 assert_eq!(option.maker_fee, dec!(0.0003));
1216 assert_eq!(option.taker_fee, dec!(0.0003));
1217 }
1218
1219 #[rstest]
1220 fn test_parse_account_state_with_positions() {
1221 let json_data = load_test_json("http_get_account_summaries.json");
1222 let response: DeribitJsonRpcResponse<DeribitAccountSummariesResponse> =
1223 serde_json::from_str(&json_data).unwrap();
1224 let result = response.result.expect("Test data must have result");
1225
1226 let account_id = AccountId::from("DERIBIT-001");
1227
1228 let ts_event =
1230 extract_server_timestamp(response.us_out).expect("Test data must have us_out");
1231 let ts_init = UnixNanos::default();
1232
1233 let account_state = parse_account_state(&result.summaries, account_id, ts_init, ts_event)
1234 .expect("Should parse account state");
1235
1236 assert_eq!(account_state.balances.len(), 2);
1238
1239 let btc_balance = account_state
1241 .balances
1242 .iter()
1243 .find(|b| b.currency.code == "BTC")
1244 .expect("BTC balance should exist");
1245
1246 assert_eq!(btc_balance.total.as_f64(), 302.62729214);
1255 assert_eq!(btc_balance.free.as_f64(), 301.38059622);
1256
1257 let locked = btc_balance.locked.as_f64();
1259 assert!(
1260 locked > 0.0,
1261 "Locked should be positive when positions exist"
1262 );
1263 assert!(
1264 (locked - 1.24669592).abs() < 0.0001,
1265 "Locked ({locked}) should equal initial_margin (1.24669592)"
1266 );
1267
1268 let eth_balance = account_state
1270 .balances
1271 .iter()
1272 .find(|b| b.currency.code == "ETH")
1273 .expect("ETH balance should exist");
1274
1275 assert_eq!(eth_balance.total.as_f64(), 100.0);
1280 assert_eq!(eth_balance.free.as_f64(), 99.999598);
1281 assert_eq!(eth_balance.locked.as_f64(), 0.000402);
1282
1283 assert_eq!(account_state.account_id, account_id);
1285 assert_eq!(account_state.account_type, AccountType::Margin);
1286 assert!(account_state.is_reported);
1287
1288 let expected_ts_event = UnixNanos::from(1687352432005000_u64 * NANOSECONDS_IN_MICROSECOND);
1290 assert_eq!(
1291 account_state.ts_event, expected_ts_event,
1292 "ts_event should match server timestamp from response"
1293 );
1294 }
1295
1296 #[rstest]
1297 fn test_parse_account_state_cross_margin() {
1298 let json_data = load_test_json("http_get_account_summaries_cross_margin.json");
1299 let response: DeribitJsonRpcResponse<DeribitAccountSummariesResponse> =
1300 serde_json::from_str(&json_data).unwrap();
1301 let result = response.result.expect("Test data must have result");
1302
1303 let account_id = AccountId::from("DERIBIT-001");
1304 let ts_event =
1305 extract_server_timestamp(response.us_out).expect("Test data must have us_out");
1306 let ts_init = UnixNanos::default();
1307
1308 let account_state = parse_account_state(&result.summaries, account_id, ts_init, ts_event)
1309 .expect("Should parse cross-margin account state");
1310
1311 assert_eq!(account_state.balances.len(), 4);
1318
1319 let btc = account_state
1321 .balances
1322 .iter()
1323 .find(|b| b.currency.code == "BTC")
1324 .expect("BTC balance should exist");
1325 assert_eq!(btc.total.as_f64(), 2.288e-5);
1326 assert_eq!(btc.free.as_f64(), 2.288e-5); assert_eq!(btc.locked.as_f64(), 0.0);
1328
1329 let usdt = account_state
1331 .balances
1332 .iter()
1333 .find(|b| b.currency.code == "USDT")
1334 .expect("USDT balance should exist");
1335 assert_eq!(usdt.total.as_f64(), 23.61869);
1336 assert_eq!(usdt.free.as_f64(), 23.618645); let usdt_locked = usdt.locked.as_f64();
1338 assert!(
1339 (usdt_locked - 0.000045).abs() < 0.001,
1340 "USDT locked ({usdt_locked}) should be close to 0.000045"
1341 );
1342
1343 let sol = account_state
1345 .balances
1346 .iter()
1347 .find(|b| b.currency.code == "SOL")
1348 .expect("SOL balance should exist");
1349 assert_eq!(sol.total.as_f64(), 0.0);
1350 assert_eq!(sol.free.as_f64(), 0.0);
1351
1352 let eth = account_state
1354 .balances
1355 .iter()
1356 .find(|b| b.currency.code == "ETH")
1357 .expect("ETH balance should exist");
1358 assert_eq!(eth.total.as_f64(), 8.6e-5);
1359 assert_eq!(eth.free.as_f64(), 8.5e-5); assert_eq!(account_state.account_type, AccountType::Margin);
1363 assert!(account_state.is_reported);
1364 }
1365
1366 #[rstest]
1367 fn test_parse_trade_tick_sell() {
1368 let json_data = load_test_json("http_get_last_trades.json");
1369 let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1370 serde_json::from_str(&json_data).unwrap();
1371 let result = response.result.expect("Test data must have result");
1372
1373 assert!(result.has_more, "has_more should be true");
1374 assert_eq!(result.trades.len(), 10, "Should have 10 trades");
1375
1376 let raw_trade = &result.trades[0];
1377 let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
1378 let ts_init = UnixNanos::from(1766335632425576_u64 * 1000); let trade = parse_trade_tick(raw_trade, instrument_id, 1, 0, ts_init)
1381 .expect("Should parse trade tick");
1382
1383 assert_eq!(trade.instrument_id, instrument_id);
1384 assert_eq!(trade.price, Price::from("2968.3"));
1385 assert_eq!(trade.size, Quantity::from("1"));
1386 assert_eq!(trade.aggressor_side, AggressorSide::Seller);
1387 assert_eq!(trade.trade_id, TradeId::new("ETH-284830839"));
1388 assert_eq!(
1390 trade.ts_event,
1391 UnixNanos::from(1766332040636_u64 * 1_000_000)
1392 );
1393 assert_eq!(trade.ts_init, ts_init);
1394 }
1395
1396 #[rstest]
1397 fn test_parse_trade_tick_buy() {
1398 let json_data = load_test_json("http_get_last_trades.json");
1399 let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1400 serde_json::from_str(&json_data).unwrap();
1401 let result = response.result.expect("Test data must have result");
1402
1403 let raw_trade = &result.trades[9];
1405 let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
1406 let ts_init = UnixNanos::default();
1407
1408 let trade = parse_trade_tick(raw_trade, instrument_id, 1, 0, ts_init)
1409 .expect("Should parse trade tick");
1410
1411 assert_eq!(trade.instrument_id, instrument_id);
1412 assert_eq!(trade.price, Price::from("2968.3"));
1413 assert_eq!(trade.size, Quantity::from("106"));
1414 assert_eq!(trade.aggressor_side, AggressorSide::Buyer);
1415 assert_eq!(trade.trade_id, TradeId::new("ETH-284830854"));
1416 }
1417
1418 fn make_public_trade(
1421 trade_id: &str,
1422 block_trade_id: Option<&str>,
1423 block_rfq_id: Option<i64>,
1424 combo_id: Option<&str>,
1425 ) -> DeribitPublicTrade {
1426 let raw = serde_json::json!({
1427 "trade_id": trade_id,
1428 "instrument_name": "BTC-PERPETUAL",
1429 "price": 77000.0,
1430 "amount": 10.0,
1431 "direction": "buy",
1432 "timestamp": 1_779_107_386_210_i64,
1433 "trade_seq": 1,
1434 "tick_direction": 0,
1435 "block_trade_id": block_trade_id,
1436 "block_rfq_id": block_rfq_id,
1437 "combo_id": combo_id,
1438 });
1439 serde_json::from_value(raw).unwrap()
1440 }
1441
1442 #[rstest]
1443 #[case::block_rfq(None, Some(99_i64), None, "RFQ-244343055")]
1444 #[case::block_trade(Some("12345"), None, None, "BLK-244343055")]
1445 #[case::combo_leg(None, None, Some("BTC-FS-25DEC26_PERP"), "COMBO-244343055")]
1446 fn test_parse_trade_tick_provenance_prefix(
1447 #[case] block_trade_id: Option<&str>,
1448 #[case] block_rfq_id: Option<i64>,
1449 #[case] combo_id: Option<&str>,
1450 #[case] expected_trade_id: &str,
1451 ) {
1452 let trade = make_public_trade("244343055", block_trade_id, block_rfq_id, combo_id);
1453 let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1454 let tick = parse_trade_tick(&trade, instrument_id, 1, 0, UnixNanos::default())
1455 .expect("Should parse trade tick");
1456 assert_eq!(tick.trade_id, TradeId::new(expected_trade_id));
1457 }
1458
1459 #[rstest]
1460 fn test_use_cost_for_bar_volume() {
1461 let perp_json = load_test_json("http_get_instrument.json");
1463 let perp_response: DeribitJsonRpcResponse<DeribitInstrument> =
1464 serde_json::from_str(&perp_json).unwrap();
1465 let perp_inst = perp_response.result.expect("Test data must have result");
1466 let perp =
1467 parse_deribit_instrument_any(&perp_inst, UnixNanos::default(), UnixNanos::default())
1468 .unwrap()
1469 .expect("Should parse perpetual");
1470 assert!(perp.is_inverse());
1471 assert!(use_cost_for_bar_volume(&perp));
1472
1473 let instruments_json = load_test_json("http_get_instruments.json");
1475 let instruments_response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1476 serde_json::from_str(&instruments_json).unwrap();
1477 let instruments = instruments_response
1478 .result
1479 .expect("Test data must have result");
1480
1481 let option_inst = instruments
1482 .iter()
1483 .find(|i| i.instrument_name.as_str() == "BTC-27DEC24-100000-C")
1484 .expect("Test data must contain BTC-27DEC24-100000-C");
1485 let option =
1486 parse_deribit_instrument_any(option_inst, UnixNanos::default(), UnixNanos::default())
1487 .unwrap()
1488 .expect("Should parse option");
1489 assert!(option.is_inverse());
1490 assert!(
1491 !use_cost_for_bar_volume(&option),
1492 "options report trade amount in base currency, must keep using volume",
1493 );
1494
1495 let future_inst = instruments
1497 .iter()
1498 .find(|i| i.instrument_name.as_str() == "BTC-27DEC24")
1499 .expect("Test data must contain BTC-27DEC24");
1500 let future =
1501 parse_deribit_instrument_any(future_inst, UnixNanos::default(), UnixNanos::default())
1502 .unwrap()
1503 .expect("Should parse future");
1504 assert!(future.is_inverse());
1505 assert!(use_cost_for_bar_volume(&future));
1506 }
1507
1508 #[rstest]
1509 fn test_parse_bars_uses_volume_field() {
1510 let json_data = load_test_json("http_get_tradingview_chart_data.json");
1511 let response: DeribitJsonRpcResponse<DeribitTradingViewChartData> =
1512 serde_json::from_str(&json_data).unwrap();
1513 let chart_data = response.result.expect("Test data must have result");
1514
1515 let bar_type = BarType::from("BTC-PERPETUAL.DERIBIT-1-MINUTE-LAST-EXTERNAL");
1516 let ts_init = UnixNanos::from(1766487086146245_u64 * NANOSECONDS_IN_MICROSECOND);
1517
1518 let bars =
1519 parse_bars(&chart_data, bar_type, 1, 8, false, ts_init).expect("Should parse bars");
1520
1521 assert_eq!(bars.len(), 5, "Should parse 5 bars");
1522
1523 let first_bar = &bars[0];
1525 assert_eq!(first_bar.bar_type, bar_type);
1526 assert_eq!(first_bar.open, Price::from("87451.0"));
1527 assert_eq!(first_bar.high, Price::from("87456.5"));
1528 assert_eq!(first_bar.low, Price::from("87451.0"));
1529 assert_eq!(first_bar.close, Price::from("87456.5"));
1530 assert_eq!(first_bar.volume, Quantity::from("2.94375216"));
1531 assert_eq!(
1532 first_bar.ts_event,
1533 UnixNanos::from(1766483460000_u64 * NANOSECONDS_IN_MILLISECOND)
1534 );
1535 assert_eq!(first_bar.ts_init, ts_init);
1536
1537 let last_bar = &bars[4];
1539 assert_eq!(last_bar.open, Price::from("87456.0"));
1540 assert_eq!(last_bar.high, Price::from("87456.5"));
1541 assert_eq!(last_bar.low, Price::from("87456.0"));
1542 assert_eq!(last_bar.close, Price::from("87456.0"));
1543 assert_eq!(last_bar.volume, Quantity::from("0.1018798"));
1544 assert_eq!(
1545 last_bar.ts_event,
1546 UnixNanos::from(1766483700000_u64 * NANOSECONDS_IN_MILLISECOND)
1547 );
1548 }
1549
1550 #[rstest]
1551 fn test_parse_bars_cost_path() {
1552 let json_data = load_test_json("http_get_tradingview_chart_data.json");
1553 let response: DeribitJsonRpcResponse<DeribitTradingViewChartData> =
1554 serde_json::from_str(&json_data).unwrap();
1555 let chart_data = response.result.expect("Test data must have result");
1556
1557 let bar_type = BarType::from("BTC-PERPETUAL.DERIBIT-1-MINUTE-LAST-EXTERNAL");
1558 let ts_init = UnixNanos::from(1766487086146245_u64 * NANOSECONDS_IN_MICROSECOND);
1559
1560 let bars =
1562 parse_bars(&chart_data, bar_type, 1, 0, true, ts_init).expect("Should parse bars");
1563 assert_eq!(bars.len(), 5);
1564 assert_eq!(bars[0].volume, Quantity::from("257490"));
1565 assert_eq!(bars[4].volume, Quantity::from("8910"));
1566 }
1567
1568 #[rstest]
1569 fn test_parse_order_book() {
1570 let json_data = load_test_json("http_get_order_book.json");
1571 let response: DeribitJsonRpcResponse<DeribitOrderBook> =
1572 serde_json::from_str(&json_data).unwrap();
1573 let order_book_data = response.result.expect("Test data must have result");
1574
1575 let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1576 let ts_init = UnixNanos::from(1766554855146274_u64 * NANOSECONDS_IN_MICROSECOND);
1577
1578 let book = parse_order_book(&order_book_data, instrument_id, 1, 0, ts_init)
1579 .expect("Should parse order book");
1580
1581 assert_eq!(book.instrument_id, instrument_id);
1583 assert_eq!(book.book_type, BookType::L2_MBP);
1584 assert_eq!(book.ts_last, ts_init);
1585
1586 assert!(book.has_bid(), "Book should have bids");
1588 assert!(book.has_ask(), "Book should have asks");
1589
1590 assert_eq!(
1592 book.best_bid_price(),
1593 Some(Price::from("87002.5")),
1594 "Best bid price should match"
1595 );
1596 assert_eq!(
1597 book.best_bid_size(),
1598 Some(Quantity::from("199190")),
1599 "Best bid size should match"
1600 );
1601
1602 assert_eq!(
1604 book.best_ask_price(),
1605 Some(Price::from("87003.0")),
1606 "Best ask price should match"
1607 );
1608 assert_eq!(
1609 book.best_ask_size(),
1610 Some(Quantity::from("125090")),
1611 "Best ask size should match"
1612 );
1613
1614 let spread = book.spread().expect("Spread should exist");
1616 assert!(
1617 (spread - 0.5).abs() < 0.0001,
1618 "Spread should be 0.5, was {spread}"
1619 );
1620
1621 let midpoint = book.midpoint().expect("Midpoint should exist");
1623 assert!(
1624 (midpoint - 87002.75).abs() < 0.0001,
1625 "Midpoint should be 87002.75, was {midpoint}"
1626 );
1627
1628 let bid_count = book.bids(None).count();
1630 let ask_count = book.asks(None).count();
1631 assert_eq!(
1632 bid_count,
1633 order_book_data.bids.len(),
1634 "Bid levels count should match input data"
1635 );
1636 assert_eq!(
1637 ask_count,
1638 order_book_data.asks.len(),
1639 "Ask levels count should match input data"
1640 );
1641 assert_eq!(bid_count, 20, "Should have 20 bid levels");
1642 assert_eq!(ask_count, 20, "Should have 20 ask levels");
1643
1644 assert_eq!(
1646 book.bids(Some(5)).count(),
1647 5,
1648 "Should limit to 5 bid levels"
1649 );
1650 assert_eq!(
1651 book.asks(Some(5)).count(),
1652 5,
1653 "Should limit to 5 ask levels"
1654 );
1655
1656 let bids_map = book.bids_as_map(None);
1658 let asks_map = book.asks_as_map(None);
1659 assert_eq!(bids_map.len(), 20, "Bids map should have 20 entries");
1660 assert_eq!(asks_map.len(), 20, "Asks map should have 20 entries");
1661
1662 assert!(
1664 bids_map.contains_key(&dec!(87002.5)),
1665 "Bids map should contain best bid price"
1666 );
1667 assert!(
1668 asks_map.contains_key(&dec!(87003.0)),
1669 "Asks map should contain best ask price"
1670 );
1671
1672 assert!(
1674 bids_map.contains_key(&dec!(86980.0)),
1675 "Bids map should contain worst bid price"
1676 );
1677 assert!(
1678 asks_map.contains_key(&dec!(87031.5)),
1679 "Asks map should contain worst ask price"
1680 );
1681 }
1682
1683 fn make_instrument_id(symbol: &str) -> InstrumentId {
1684 InstrumentId::new(Symbol::from(symbol), *DERIBIT_VENUE)
1685 }
1686
1687 #[rstest]
1688 fn test_parse_futures_and_perpetuals() {
1689 let cases = [
1691 ("BTC-PERPETUAL", "future", "BTC"),
1692 ("ETH-PERPETUAL", "future", "ETH"),
1693 ("SOL-PERPETUAL", "future", "SOL"),
1694 ("BTC-25MAR23", "future", "BTC"),
1696 ("BTC-5AUG23", "future", "BTC"), ("ETH-28MAR25", "future", "ETH"),
1698 ];
1699
1700 for (symbol, expected_kind, expected_currency) in cases {
1701 let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1702 assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1703 assert_eq!(
1704 currency, expected_currency,
1705 "currency mismatch for {symbol}"
1706 );
1707 }
1708 }
1709
1710 #[rstest]
1711 fn test_parse_options() {
1712 let cases = [
1713 ("BTC-25MAR23-420-C", "option", "BTC"),
1715 ("BTC-5AUG23-580-P", "option", "BTC"),
1716 ("ETH-28MAR25-4000-C", "option", "ETH"),
1717 ("XRP_USDC-30JUN23-0d625-C", "option", "XRP"),
1719 ];
1720
1721 for (symbol, expected_kind, expected_currency) in cases {
1722 let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1723 assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1724 assert_eq!(
1725 currency, expected_currency,
1726 "currency mismatch for {symbol}"
1727 );
1728 }
1729 }
1730
1731 #[rstest]
1732 #[case::future_combo_vs_perp("BTC-FS-19MAY26_PERP", "future_combo", "BTC")]
1734 #[case::future_combo_inter_month("BTC-FS-22MAY26_19MAY26", "future_combo", "BTC")]
1735 #[case::future_combo_eth("ETH-FS-26JUN26_PERP", "future_combo", "ETH")]
1736 #[case::option_combo_call_spread("BTC-CS-19MAY26-70000_75000", "option_combo", "BTC")]
1738 #[case::option_combo_strangle("BTC-STRG-19MAY26-74000_79000", "option_combo", "BTC")]
1739 #[case::option_combo_straddle("BTC-STRD-29MAY26-77000", "option_combo", "BTC")]
1740 #[case::option_combo_box("BTC-BOX-25DEC26-58000_60000", "option_combo", "BTC")]
1741 #[case::option_combo_put_spread_eth("ETH-PS-26JUN26-3500_4000", "option_combo", "ETH")]
1742 fn test_parse_combo_kinds(
1743 #[case] symbol: &str,
1744 #[case] expected_kind: &str,
1745 #[case] expected_currency: &str,
1746 ) {
1747 let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1748 assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1749 assert_eq!(
1750 currency, expected_currency,
1751 "currency mismatch for {symbol}"
1752 );
1753 }
1754
1755 #[rstest]
1756 fn test_parse_option_combo_instrument() {
1757 let json_data = load_test_json("http_get_instruments_option_combo.json");
1758 let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1759 serde_json::from_str(&json_data).unwrap();
1760 let instruments = response.result.expect("Test data must have result");
1761 let raw = instruments
1762 .iter()
1763 .find(|i| i.instrument_name.as_str() == "BTC-STRG-19MAY26-74000_79000")
1764 .expect("fixture must contain BTC-STRG-19MAY26-74000_79000");
1765
1766 let any = parse_deribit_instrument_any(raw, UnixNanos::default(), UnixNanos::default())
1767 .unwrap()
1768 .expect("Should parse option combo");
1769
1770 let InstrumentAny::CryptoOptionSpread(spread) = any else {
1771 panic!("Expected CryptoOptionSpread, was {any:?}");
1772 };
1773 assert_eq!(
1774 spread.id,
1775 InstrumentId::from("BTC-STRG-19MAY26-74000_79000.DERIBIT")
1776 );
1777 assert_eq!(spread.underlying.code.as_str(), "BTC");
1778 assert_eq!(spread.strategy_type.as_str(), "STRG");
1779 assert_eq!(spread.quote_currency.code.as_str(), "BTC");
1780 assert_eq!(spread.settlement_currency.code.as_str(), "BTC");
1781 assert!(spread.is_inverse);
1782 assert_eq!(spread.price_precision, 4);
1783 assert_eq!(spread.price_increment, Price::from("0.0001"));
1784 assert_eq!(spread.size_precision, 1);
1785 assert_eq!(spread.size_increment, Quantity::from("0.1"));
1786 assert_eq!(spread.multiplier, Quantity::from("1"));
1787 assert_eq!(spread.lot_size, Quantity::from("0.1"));
1788 assert_eq!(
1789 spread.expiration_ns,
1790 UnixNanos::from(1779177600000_u64 * 1_000_000)
1791 );
1792 assert_eq!(
1793 spread.activation_ns,
1794 UnixNanos::from(1779100724000_u64 * 1_000_000)
1795 );
1796 assert_eq!(spread.maker_fee, dec!(0));
1797 assert_eq!(spread.taker_fee, dec!(0));
1798 }
1799
1800 #[rstest]
1801 fn test_deserialize_option_combo_trade_with_legs() {
1802 let json_data = load_test_json("http_get_last_trades_option_combo.json");
1803 let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1804 serde_json::from_str(&json_data).unwrap();
1805 let result = response.result.expect("Test data must have result");
1806
1807 let combo_trade = &result.trades[0];
1808 assert_eq!(combo_trade.trade_id, "244365193");
1809 assert_eq!(combo_trade.instrument_name, "BTC-CS-19MAY26-70000_75000");
1810 assert_eq!(combo_trade.combo_id.as_deref(), None);
1811 assert_eq!(combo_trade.combo_trade_id.as_deref(), None);
1812
1813 let legs = combo_trade
1814 .legs
1815 .as_ref()
1816 .expect("Combo trade must have legs");
1817 assert_eq!(legs.len(), 2);
1818
1819 let leg_75c = &legs[0];
1820 assert_eq!(leg_75c.instrument_name, "BTC-19MAY26-75000-C");
1821 assert_eq!(leg_75c.trade_id, "244365195");
1822 assert_eq!(leg_75c.combo_trade_id, "244365193");
1823 assert_eq!(leg_75c.combo_id, "BTC-CS-19MAY26-70000_75000");
1824 assert_eq!(leg_75c.direction, "buy");
1825 assert_eq!(leg_75c.price, dec!(0.0174));
1826 assert_eq!(leg_75c.amount, dec!(0.1));
1827 assert_eq!(leg_75c.iv, Some(dec!(41.01)));
1828
1829 let leg_70c = &legs[1];
1830 assert_eq!(leg_70c.instrument_name, "BTC-19MAY26-70000-C");
1831 assert_eq!(leg_70c.trade_id, "244365194");
1832 assert_eq!(leg_70c.direction, "sell");
1833 assert_eq!(leg_70c.iv, Some(dec!(83.39)));
1834 }
1835
1836 #[rstest]
1837 fn test_deserialize_future_combo_trade_with_legs() {
1838 let json_data = load_test_json("http_get_last_trades_future_combo.json");
1839 let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1840 serde_json::from_str(&json_data).unwrap();
1841 let result = response.result.expect("Test data must have result");
1842
1843 let combo_trade = &result.trades[0];
1844 assert_eq!(combo_trade.trade_id, "244343053");
1845 assert_eq!(combo_trade.instrument_name, "BTC-FS-25DEC26_PERP");
1846 assert_eq!(combo_trade.price, dec!(1320.0));
1847 assert_eq!(combo_trade.amount, dec!(10.0));
1848
1849 let legs = combo_trade
1850 .legs
1851 .as_ref()
1852 .expect("Future combo trade must have legs");
1853 assert_eq!(legs.len(), 2);
1854
1855 let leg_25dec = &legs[0];
1857 assert_eq!(leg_25dec.instrument_name, "BTC-25DEC26");
1858 assert_eq!(leg_25dec.trade_id, "244343055");
1859 assert_eq!(leg_25dec.combo_id, "BTC-FS-25DEC26_PERP");
1860 assert_eq!(leg_25dec.combo_trade_id, "244343053");
1861 assert_eq!(leg_25dec.direction, "sell");
1862 assert_eq!(leg_25dec.price, dec!(78624.0));
1863 assert_eq!(leg_25dec.amount, dec!(10.0));
1864 assert_eq!(leg_25dec.contracts, Some(dec!(1.0)));
1865 assert!(leg_25dec.iv.is_none(), "future leg must not carry iv");
1866
1867 let leg_perp = &legs[1];
1869 assert_eq!(leg_perp.instrument_name, "BTC-PERPETUAL");
1870 assert_eq!(leg_perp.trade_id, "244343054");
1871 assert_eq!(leg_perp.combo_id, "BTC-FS-25DEC26_PERP");
1872 assert_eq!(leg_perp.combo_trade_id, "244343053");
1873 assert_eq!(leg_perp.direction, "buy");
1874 assert_eq!(leg_perp.price, dec!(77304.0));
1875 assert!(leg_perp.iv.is_none(), "future leg must not carry iv");
1876 }
1877
1878 #[rstest]
1879 fn test_deserialize_historical_combo_leg_with_missing_optional_fields() {
1880 let json_data = load_test_json("http_get_last_trades_historical_combo.json");
1886 let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1887 serde_json::from_str(&json_data).unwrap();
1888 let result = response.result.expect("Test data must have result");
1889
1890 let combo_trade = &result.trades[0];
1891 assert_eq!(combo_trade.trade_id, "999000000");
1892 assert_eq!(combo_trade.instrument_name, "BTC-CS-19MAY26-70000_75000");
1893 assert!(combo_trade.contracts.is_none());
1895 assert!(combo_trade.index_price.is_none());
1896 assert!(combo_trade.mark_price.is_none());
1897
1898 let legs = combo_trade
1899 .legs
1900 .as_ref()
1901 .expect("Combo trade must have legs");
1902 assert_eq!(legs.len(), 2);
1903
1904 let leg1 = &legs[0];
1907 assert_eq!(leg1.instrument_name, "BTC-19MAY26-75000-C");
1908 assert_eq!(leg1.trade_id, "999000001");
1909 assert_eq!(leg1.combo_id, "BTC-CS-19MAY26-70000_75000");
1910 assert_eq!(leg1.combo_trade_id, "999000000");
1911 assert_eq!(leg1.direction, "buy");
1912 assert_eq!(leg1.price, dec!(0.0174));
1913 assert_eq!(leg1.amount, dec!(0.1));
1914 assert!(leg1.contracts.is_none());
1915 assert!(leg1.index_price.is_none());
1916 assert!(leg1.mark_price.is_none());
1917 assert!(leg1.iv.is_none());
1918
1919 let leg2 = &legs[1];
1922 assert_eq!(leg2.instrument_name, "BTC-19MAY26-70000-C");
1923 assert_eq!(leg2.trade_id, "999000002");
1924 assert!(leg2.contracts.is_none());
1925 assert!(leg2.mark_price.is_none());
1926 assert_eq!(leg2.index_price, Some(dec!(76185.14)));
1927 assert_eq!(leg2.iv, Some(dec!(83.39)));
1928 }
1929
1930 #[rstest]
1931 fn test_parse_combo_instrument_missing_expiration_errors() {
1932 let json_data = load_test_json("http_get_instruments_option_combo.json");
1936 let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1937 serde_json::from_str(&json_data).unwrap();
1938 let mut instruments = response.result.expect("Test data must have result");
1939 let raw = instruments
1940 .iter_mut()
1941 .find(|i| i.instrument_name.as_str() == "BTC-STRG-19MAY26-74000_79000")
1942 .expect("fixture must contain BTC-STRG-19MAY26-74000_79000");
1943 raw.expiration_timestamp = None;
1944
1945 let result = parse_deribit_instrument_any(raw, UnixNanos::default(), UnixNanos::default());
1946 let err = result.expect_err("Should error when expiration_timestamp is missing");
1947 let msg = format!("{err:#}");
1948 assert!(
1949 msg.contains("Missing expiration_timestamp for combo"),
1950 "unexpected error: {msg}"
1951 );
1952 }
1953
1954 #[rstest]
1955 fn test_deserialize_perpetual_combo_leg_tags() {
1956 let json_data = load_test_json("http_get_last_trades_perpetual_with_combo_tags.json");
1959 let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1960 serde_json::from_str(&json_data).unwrap();
1961 let result = response.result.expect("Test data must have result");
1962
1963 for trade in &result.trades {
1964 assert_eq!(trade.instrument_name, "BTC-PERPETUAL");
1965 assert_eq!(trade.combo_id.as_deref(), Some("BTC-FS-25DEC26_PERP"));
1966 assert!(
1967 trade.combo_trade_id.is_some(),
1968 "Per-leg trade should carry combo_trade_id"
1969 );
1970 assert!(trade.legs.is_none());
1972 }
1973 }
1974
1975 #[rstest]
1976 fn test_parse_future_combo_instrument() {
1977 let json_data = load_test_json("http_get_instruments_future_combo.json");
1978 let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1979 serde_json::from_str(&json_data).unwrap();
1980 let instruments = response.result.expect("Test data must have result");
1981 let raw = instruments
1982 .iter()
1983 .find(|i| i.instrument_name.as_str() == "BTC-FS-19MAY26_PERP")
1984 .expect("fixture must contain BTC-FS-19MAY26_PERP");
1985
1986 let any = parse_deribit_instrument_any(raw, UnixNanos::default(), UnixNanos::default())
1987 .unwrap()
1988 .expect("Should parse future combo");
1989
1990 let InstrumentAny::CryptoFuturesSpread(spread) = any else {
1991 panic!("Expected CryptoFuturesSpread, was {any:?}");
1992 };
1993 assert_eq!(spread.id, InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT"));
1994 assert_eq!(spread.underlying.code.as_str(), "BTC");
1995 assert_eq!(spread.strategy_type.as_str(), "FS");
1996 assert_eq!(spread.quote_currency.code.as_str(), "USD");
1998 assert_eq!(spread.settlement_currency.code.as_str(), "BTC");
1999 assert!(spread.is_inverse);
2000 assert_eq!(spread.price_precision, 1);
2001 assert_eq!(spread.price_increment, Price::from("0.5"));
2002 assert_eq!(spread.size_precision, 0);
2003 assert_eq!(spread.size_increment, Quantity::from("10"));
2004 assert_eq!(spread.multiplier, Quantity::from("1"));
2005 assert_eq!(spread.lot_size, Quantity::from("10"));
2006 assert_eq!(
2007 spread.expiration_ns,
2008 UnixNanos::from(1779177600000_u64 * 1_000_000)
2009 );
2010 }
2011
2012 #[rstest]
2013 fn test_build_public_trade_id_plain() {
2014 let id = build_public_trade_id("244343053", None, None, None);
2015 assert_eq!(id.as_str(), "244343053");
2016 }
2017
2018 #[rstest]
2019 fn test_build_public_trade_id_combo_only() {
2020 let id = build_public_trade_id("244365195", None, None, Some("BTC-CS-19MAY26-70000_75000"));
2021 assert_eq!(id.as_str(), "COMBO-244365195");
2022 }
2023
2024 #[rstest]
2025 fn test_build_public_trade_id_block_only() {
2026 let id = build_public_trade_id("244343053", None, Some("12345"), None);
2027 assert_eq!(id.as_str(), "BLK-244343053");
2028 }
2029
2030 #[rstest]
2031 fn test_build_public_trade_id_rfq_only() {
2032 let id = build_public_trade_id("244343053", Some(99), None, None);
2033 assert_eq!(id.as_str(), "RFQ-244343053");
2034 }
2035
2036 #[rstest]
2037 fn test_build_public_trade_id_precedence_block_beats_combo() {
2038 let id = build_public_trade_id(
2041 "244343053",
2042 None,
2043 Some("12345"),
2044 Some("BTC-FS-25DEC26_PERP"),
2045 );
2046 assert_eq!(id.as_str(), "BLK-244343053");
2047 }
2048
2049 #[rstest]
2050 fn test_build_public_trade_id_precedence_rfq_beats_all() {
2051 let id = build_public_trade_id(
2052 "244343053",
2053 Some(99),
2054 Some("12345"),
2055 Some("BTC-FS-25DEC26_PERP"),
2056 );
2057 assert_eq!(id.as_str(), "RFQ-244343053");
2058 }
2059
2060 #[rstest]
2061 fn test_parse_spot() {
2062 let cases = [
2063 ("BTC_USDC", "spot", "BTC"),
2064 ("ETH_USDT", "spot", "ETH"),
2065 ("SOL_USDC", "spot", "SOL"),
2066 ];
2067
2068 for (symbol, expected_kind, expected_currency) in cases {
2069 let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
2070 assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
2071 assert_eq!(
2072 currency, expected_currency,
2073 "currency mismatch for {symbol}"
2074 );
2075 }
2076 }
2077
2078 #[rstest]
2079 fn test_parse_portfolio_to_account_state() {
2080 let json_data = load_test_json("ws_portfolio.json");
2081 let notification: serde_json::Value = serde_json::from_str(&json_data).unwrap();
2082
2083 let data = notification
2085 .get("params")
2086 .and_then(|p| p.get("data"))
2087 .expect("Test data must have params.data");
2088
2089 let portfolio: DeribitPortfolioMsg =
2090 serde_json::from_value(data.clone()).expect("Should deserialize portfolio message");
2091
2092 assert_eq!(portfolio.currency, "USDT");
2094 assert_eq!(portfolio.equity, dec!(55.00055));
2095 assert_eq!(portfolio.balance, dec!(55.00055));
2096 assert_eq!(portfolio.available_funds, dec!(53.868247));
2097 assert_eq!(portfolio.margin_balance, dec!(54.968258));
2098 assert_eq!(portfolio.initial_margin, dec!(1.100011));
2099 assert_eq!(portfolio.maintenance_margin, dec!(0.0));
2100 assert_eq!(portfolio.cross_collateral_enabled, Some(true));
2101 assert_eq!(portfolio.margin_model.as_deref(), Some("cross_sm"));
2102
2103 let account_id = AccountId::new("DERIBIT-master");
2105 let ts_init = UnixNanos::from(1700000000000000000_u64);
2106
2107 let account_state =
2108 parse_portfolio_to_account_state(&portfolio, account_id, ts_init).unwrap();
2109
2110 assert_eq!(account_state.account_id, account_id);
2112 assert_eq!(account_state.account_type, AccountType::Margin);
2113 assert!(account_state.is_reported);
2114
2115 assert_eq!(account_state.balances.len(), 1);
2118 let balance = &account_state.balances[0];
2119 assert_eq!(balance.currency.code, "USDT");
2120 assert_eq!(balance.total.as_f64(), 55.00055); assert_eq!(balance.free.as_f64(), 54.968257); let locked = balance.locked.as_f64();
2125 assert!(
2126 (locked - 0.032293).abs() < 0.001,
2127 "Locked ({locked}) should be close to 0.032293"
2128 );
2129
2130 assert_eq!(account_state.margins.len(), 1);
2132 let margin = &account_state.margins[0];
2133 assert_eq!(margin.initial.as_f64(), 1.100011);
2134 assert_eq!(margin.maintenance.as_f64(), 0.0);
2135 assert!(margin.instrument_id.is_none());
2136 assert_eq!(margin.currency.code.as_str(), "USDT");
2137 }
2138
2139 #[rstest]
2140 #[case::minute_1(1, "MINUTE", "1")]
2141 #[case::minute_2(2, "MINUTE", "3")]
2142 #[case::minute_3(3, "MINUTE", "3")]
2143 #[case::minute_4(4, "MINUTE", "5")]
2144 #[case::minute_5(5, "MINUTE", "5")]
2145 #[case::minute_6(6, "MINUTE", "10")]
2146 #[case::minute_10(10, "MINUTE", "10")]
2147 #[case::minute_12(12, "MINUTE", "15")]
2148 #[case::minute_15(15, "MINUTE", "15")]
2149 #[case::minute_20(20, "MINUTE", "30")]
2150 #[case::minute_30(30, "MINUTE", "30")]
2151 #[case::hour_1(1, "HOUR", "60")]
2152 #[case::hour_2(2, "HOUR", "120")]
2153 #[case::hour_3(3, "HOUR", "180")]
2154 #[case::hour_4(4, "HOUR", "360")]
2155 #[case::hour_6(6, "HOUR", "360")]
2156 #[case::hour_12(12, "HOUR", "720")]
2157 #[case::day_1(1, "DAY", "1D")]
2158 fn test_bar_spec_to_resolution(
2159 #[case] step: u64,
2160 #[case] aggregation: &str,
2161 #[case] expected: &str,
2162 ) {
2163 let bar_type_str = format!("BTC-PERPETUAL.DERIBIT-{step}-{aggregation}-LAST-EXTERNAL");
2164 let bar_type = BarType::from(bar_type_str.as_str());
2165 let resolution = bar_spec_to_resolution(&bar_type);
2166 assert_eq!(resolution, expected);
2167 }
2168}