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