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::builder()
216 .instrument_id(instrument_id)
217 .raw_symbol(instrument.instrument_name.into())
218 .base_currency(base_currency)
219 .quote_currency(quote_currency)
220 .price_precision(price_increment.precision)
221 .size_precision(size_increment.precision)
222 .price_increment(price_increment)
223 .size_increment(size_increment)
224 .min_quantity(min_quantity)
225 .maker_fee(maker_fee)
226 .taker_fee(taker_fee)
227 .ts_event(ts_event)
228 .ts_init(ts_init)
229 .build()
230 .unwrap();
231
232 Ok(InstrumentAny::CurrencyPair(currency_pair))
233}
234
235fn parse_perpetual_instrument(
237 instrument: &DeribitInstrument,
238 ts_init: UnixNanos,
239 ts_event: UnixNanos,
240) -> anyhow::Result<InstrumentAny> {
241 let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
242
243 let base_currency = Currency::get_or_create_crypto(instrument.base_currency);
244 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
245 let settlement_currency = instrument
246 .settlement_currency
247 .map_or(base_currency, Currency::get_or_create_crypto);
248
249 let is_inverse = instrument
250 .instrument_type
251 .as_ref()
252 .is_some_and(|t| t == "reversed");
253
254 let price_increment = Price::from_decimal(instrument.tick_size)?;
255 let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
256 let min_quantity = Quantity::from_decimal(instrument.min_trade_amount)?;
257
258 let multiplier = Some(deribit_amount_quantity_multiplier());
259 let lot_size = Some(size_increment);
260
261 let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
262 .context("Failed to parse maker_commission")?;
263 let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
264 .context("Failed to parse taker_commission")?;
265
266 let perpetual = CryptoPerpetual::builder()
267 .instrument_id(instrument_id)
268 .raw_symbol(instrument.instrument_name.into())
269 .base_currency(base_currency)
270 .quote_currency(quote_currency)
271 .settlement_currency(settlement_currency)
272 .is_inverse(is_inverse)
273 .price_precision(price_increment.precision)
274 .size_precision(size_increment.precision)
275 .price_increment(price_increment)
276 .size_increment(size_increment)
277 .maybe_multiplier(multiplier)
278 .maybe_lot_size(lot_size)
279 .min_quantity(min_quantity)
281 .maker_fee(maker_fee)
282 .taker_fee(taker_fee)
283 .ts_event(ts_event)
284 .ts_init(ts_init)
285 .build()
286 .unwrap();
287
288 Ok(InstrumentAny::CryptoPerpetual(perpetual))
289}
290
291fn parse_future_instrument(
293 instrument: &DeribitInstrument,
294 ts_init: UnixNanos,
295 ts_event: UnixNanos,
296) -> anyhow::Result<InstrumentAny> {
297 let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
298
299 let underlying = Currency::get_or_create_crypto(instrument.base_currency);
300 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
301 let settlement_currency = instrument
302 .settlement_currency
303 .map_or(underlying, Currency::get_or_create_crypto);
304
305 let is_inverse = instrument
306 .instrument_type
307 .as_ref()
308 .is_some_and(|t| t == "reversed");
309
310 let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
312 let expiration_ns = instrument
313 .expiration_timestamp
314 .context("Missing expiration_timestamp for future")? as u64
315 * 1_000_000; let price_increment = Price::from_decimal(instrument.tick_size)?;
318 let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
319 let min_quantity = Quantity::from_decimal(instrument.min_trade_amount)?;
320
321 let multiplier = Some(deribit_amount_quantity_multiplier());
322 let lot_size = Some(size_increment); let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
325 .context("Failed to parse maker_commission")?;
326 let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
327 .context("Failed to parse taker_commission")?;
328
329 let future = CryptoFuture::builder()
330 .instrument_id(instrument_id)
331 .raw_symbol(instrument.instrument_name.into())
332 .underlying(underlying)
333 .quote_currency(quote_currency)
334 .settlement_currency(settlement_currency)
335 .is_inverse(is_inverse)
336 .activation_ns(UnixNanos::from(activation_ns))
337 .expiration_ns(UnixNanos::from(expiration_ns))
338 .price_precision(price_increment.precision)
339 .size_precision(size_increment.precision)
340 .price_increment(price_increment)
341 .size_increment(size_increment)
342 .maybe_multiplier(multiplier)
343 .maybe_lot_size(lot_size)
344 .min_quantity(min_quantity)
346 .maker_fee(maker_fee)
347 .taker_fee(taker_fee)
348 .ts_event(ts_event)
349 .ts_init(ts_init)
350 .build()
351 .unwrap();
352
353 Ok(InstrumentAny::CryptoFuture(future))
354}
355
356fn parse_option_instrument(
358 instrument: &DeribitInstrument,
359 ts_init: UnixNanos,
360 ts_event: UnixNanos,
361) -> anyhow::Result<InstrumentAny> {
362 let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
363 let underlying = Currency::get_or_create_crypto(instrument.base_currency);
364 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
365 let settlement = instrument
366 .settlement_currency
367 .unwrap_or(instrument.base_currency);
368 let settlement_currency = Currency::get_or_create_crypto(settlement);
369
370 let is_inverse = instrument
372 .instrument_type
373 .as_ref()
374 .is_some_and(|t| t == "reversed");
375
376 let option_kind = match instrument.option_type {
378 Some(DeribitOptionType::Call) => OptionKind::Call,
379 Some(DeribitOptionType::Put) => OptionKind::Put,
380 None => anyhow::bail!("Missing option_type for option instrument"),
381 };
382
383 let strike = instrument.strike.context("Missing strike for option")?;
385 let strike_price = Price::from_decimal(strike)?;
386
387 let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
389 let expiration_ns = instrument
390 .expiration_timestamp
391 .context("Missing expiration_timestamp for option")? as u64
392 * 1_000_000;
393
394 let price_increment = Price::from_decimal(instrument.tick_size)?;
395
396 let multiplier = deribit_amount_quantity_multiplier();
397 let lot_size = Quantity::from_decimal(instrument.min_trade_amount)?;
398 let min_trade_amount = Quantity::from_decimal(instrument.min_trade_amount)?;
399
400 let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
401 .context("Failed to parse maker_commission")?;
402 let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
403 .context("Failed to parse taker_commission")?;
404
405 let option = CryptoOption::builder()
406 .instrument_id(instrument_id)
407 .raw_symbol(instrument.instrument_name.into())
408 .underlying(underlying)
409 .quote_currency(quote_currency)
410 .settlement_currency(settlement_currency)
411 .is_inverse(is_inverse)
412 .option_kind(option_kind)
413 .strike_price(strike_price)
414 .activation_ns(UnixNanos::from(activation_ns))
415 .expiration_ns(UnixNanos::from(expiration_ns))
416 .price_precision(price_increment.precision)
417 .size_precision(lot_size.precision)
418 .price_increment(price_increment)
419 .size_increment(lot_size)
420 .multiplier(multiplier)
421 .lot_size(lot_size)
422 .min_quantity(min_trade_amount)
423 .maker_fee(maker_fee)
424 .taker_fee(taker_fee)
425 .ts_event(ts_event)
426 .ts_init(ts_init)
427 .build()
428 .unwrap();
429
430 Ok(InstrumentAny::CryptoOption(option))
431}
432
433fn parse_option_combo_instrument(
435 instrument: &DeribitInstrument,
436 ts_init: UnixNanos,
437 ts_event: UnixNanos,
438) -> anyhow::Result<InstrumentAny> {
439 let spread = build_spread_common(instrument, ts_init, ts_event)?;
440 let option_spread = CryptoOptionSpread::builder()
441 .instrument_id(spread.id)
442 .raw_symbol(spread.raw_symbol)
443 .underlying(spread.underlying)
444 .quote_currency(spread.quote_currency)
445 .settlement_currency(spread.settlement_currency)
446 .is_inverse(spread.is_inverse)
447 .strategy_type(spread.strategy_type)
448 .activation_ns(spread.activation_ns)
449 .expiration_ns(spread.expiration_ns)
450 .price_precision(spread.price_precision)
451 .size_precision(spread.size_precision)
452 .price_increment(spread.price_increment)
453 .size_increment(spread.size_increment)
454 .multiplier(spread.multiplier)
455 .lot_size(spread.lot_size)
456 .min_quantity(spread.size_increment)
457 .maker_fee(spread.maker_fee)
458 .taker_fee(spread.taker_fee)
459 .ts_event(ts_event)
460 .ts_init(ts_init)
461 .build()
462 .unwrap();
463 Ok(InstrumentAny::CryptoOptionSpread(option_spread))
464}
465
466fn parse_future_combo_instrument(
468 instrument: &DeribitInstrument,
469 ts_init: UnixNanos,
470 ts_event: UnixNanos,
471) -> anyhow::Result<InstrumentAny> {
472 let spread = build_spread_common(instrument, ts_init, ts_event)?;
473 let futures_spread = CryptoFuturesSpread::builder()
474 .instrument_id(spread.id)
475 .raw_symbol(spread.raw_symbol)
476 .underlying(spread.underlying)
477 .quote_currency(spread.quote_currency)
478 .settlement_currency(spread.settlement_currency)
479 .is_inverse(spread.is_inverse)
480 .strategy_type(spread.strategy_type)
481 .activation_ns(spread.activation_ns)
482 .expiration_ns(spread.expiration_ns)
483 .price_precision(spread.price_precision)
484 .size_precision(spread.size_precision)
485 .price_increment(spread.price_increment)
486 .size_increment(spread.size_increment)
487 .multiplier(spread.multiplier)
488 .lot_size(spread.lot_size)
489 .min_quantity(spread.size_increment)
490 .maker_fee(spread.maker_fee)
491 .taker_fee(spread.taker_fee)
492 .ts_event(ts_event)
493 .ts_init(ts_init)
494 .build()
495 .unwrap();
496 Ok(InstrumentAny::CryptoFuturesSpread(futures_spread))
497}
498
499struct DeribitSpreadCommon {
502 id: InstrumentId,
503 raw_symbol: Symbol,
504 underlying: Currency,
505 quote_currency: Currency,
506 settlement_currency: Currency,
507 is_inverse: bool,
508 strategy_type: Ustr,
509 activation_ns: UnixNanos,
510 expiration_ns: UnixNanos,
511 price_precision: u8,
512 price_increment: Price,
513 size_precision: u8,
514 size_increment: Quantity,
515 multiplier: Quantity,
516 lot_size: Quantity,
517 maker_fee: Decimal,
518 taker_fee: Decimal,
519}
520
521fn build_spread_common(
522 instrument: &DeribitInstrument,
523 _ts_init: UnixNanos,
524 _ts_event: UnixNanos,
525) -> anyhow::Result<DeribitSpreadCommon> {
526 let id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
527 let raw_symbol = Symbol::new(instrument.instrument_name);
528 let underlying = Currency::get_or_create_crypto(instrument.base_currency);
529 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
530 let settlement_currency = instrument
531 .settlement_currency
532 .map_or(underlying, Currency::get_or_create_crypto);
533 let is_inverse = instrument
534 .instrument_type
535 .as_ref()
536 .is_some_and(|t| t == "reversed");
537 let strategy_type = second_segment(instrument.instrument_name.as_str())
538 .map_or_else(|| Ustr::from("SPREAD"), Ustr::from);
539
540 let activation_ns = UnixNanos::from((instrument.creation_timestamp as u64) * 1_000_000);
541 let expiration_ns = UnixNanos::from(
542 instrument
543 .expiration_timestamp
544 .context("Missing expiration_timestamp for combo")? as u64
545 * 1_000_000,
546 );
547
548 let price_increment = Price::from_decimal(instrument.tick_size)?;
549 let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
550 let multiplier = deribit_amount_quantity_multiplier();
551
552 let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
553 .context("Failed to parse maker_commission")?;
554 let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
555 .context("Failed to parse taker_commission")?;
556
557 Ok(DeribitSpreadCommon {
558 id,
559 raw_symbol,
560 underlying,
561 quote_currency,
562 settlement_currency,
563 is_inverse,
564 strategy_type,
565 activation_ns,
566 expiration_ns,
567 price_precision: price_increment.precision,
568 price_increment,
569 size_precision: size_increment.precision,
570 size_increment,
571 multiplier,
572 lot_size: size_increment,
573 maker_fee,
574 taker_fee,
575 })
576}
577
578fn deribit_amount_quantity_multiplier() -> Quantity {
579 Quantity::from(1)
581}
582
583pub fn parse_account_state(
593 summaries: &[DeribitAccountSummary],
594 account_id: AccountId,
595 ts_init: UnixNanos,
596 ts_event: UnixNanos,
597) -> anyhow::Result<AccountState> {
598 let mut balances = Vec::new();
599 let mut margins = Vec::new();
600
601 for summary in summaries {
603 let ccy_str = summary.currency.as_str().trim();
604
605 if ccy_str.is_empty() {
607 log::debug!("Skipping balance detail with empty currency code | raw_data={summary:?}");
608 continue;
609 }
610
611 let currency = Currency::get_or_create_crypto_with_context(
612 ccy_str,
613 Some("DERIBIT - Parsing account state"),
614 );
615
616 let is_cross_margin = summary.cross_collateral_enabled.unwrap_or(false);
628 let (total, free) = if is_cross_margin {
629 (
630 summary.equity,
631 summary.available_withdrawal_funds.unwrap_or(Decimal::ZERO),
632 )
633 } else {
634 (summary.margin_balance, summary.available_funds)
635 };
636 let balance = AccountBalance::from_total_and_free(total, free, currency)?;
637 balances.push(balance);
638
639 if let (Some(initial_margin), Some(maintenance_margin)) =
641 (summary.initial_margin, summary.maintenance_margin)
642 && (!initial_margin.is_zero() || !maintenance_margin.is_zero())
643 {
644 let initial = Money::from_decimal(initial_margin, currency)?;
645 let maintenance = Money::from_decimal(maintenance_margin, currency)?;
646 margins.push(MarginBalance::new(initial, maintenance, None));
649 }
650 }
651
652 if balances.is_empty() {
654 let zero_currency = Currency::USD();
655 let zero_money = Money::zero(zero_currency);
656 let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
657 balances.push(zero_balance);
658 }
659
660 let account_type = AccountType::Margin;
661 let is_reported = true;
662
663 Ok(AccountState::new(
664 account_id,
665 account_type,
666 balances,
667 margins,
668 is_reported,
669 UUID4::new(),
670 ts_event,
671 ts_init,
672 None,
673 ))
674}
675
676pub fn parse_portfolio_to_account_state(
689 portfolio: &DeribitPortfolioMsg,
690 account_id: AccountId,
691 ts_init: UnixNanos,
692) -> anyhow::Result<AccountState> {
693 let ccy_str = portfolio.currency.trim();
694
695 if ccy_str.is_empty() {
697 anyhow::bail!("Portfolio message has empty currency code");
698 }
699
700 let currency = Currency::get_or_create_crypto_with_context(
701 ccy_str,
702 Some("DERIBIT - Parsing portfolio update"),
703 );
704
705 let is_cross_margin = portfolio.cross_collateral_enabled.unwrap_or(false);
709 let (total, free) = if is_cross_margin {
710 (
711 portfolio.equity,
712 portfolio
713 .available_withdrawal_funds
714 .unwrap_or(Decimal::ZERO),
715 )
716 } else {
717 (portfolio.margin_balance, portfolio.available_funds)
718 };
719 let balance = AccountBalance::from_total_and_free(total, free, currency)?;
720 let balances = vec![balance];
721
722 let mut margins = Vec::new();
724 let initial_margin = portfolio.initial_margin;
725 let maintenance_margin = portfolio.maintenance_margin;
726
727 if !initial_margin.is_zero() || !maintenance_margin.is_zero() {
729 let initial = Money::from_decimal(initial_margin, currency)?;
730 let maintenance = Money::from_decimal(maintenance_margin, currency)?;
731 margins.push(MarginBalance::new(initial, maintenance, None));
734 }
735
736 let account_type = AccountType::Margin;
737 let is_reported = true;
738
739 Ok(AccountState::new(
740 account_id,
741 account_type,
742 balances,
743 margins,
744 is_reported,
745 UUID4::new(),
746 ts_init, ts_init,
748 None,
749 ))
750}
751
752#[must_use]
765pub fn build_public_trade_id(
766 trade_id: &str,
767 block_rfq_id: Option<i64>,
768 block_trade_id: Option<&str>,
769 combo_id: Option<&str>,
770) -> TradeId {
771 if block_rfq_id.is_some() {
772 TradeId::new(format!("RFQ-{trade_id}"))
773 } else if block_trade_id.is_some() {
774 TradeId::new(format!("BLK-{trade_id}"))
775 } else if combo_id.is_some() {
776 TradeId::new(format!("COMBO-{trade_id}"))
777 } else {
778 TradeId::new(trade_id)
779 }
780}
781
782pub fn parse_trade_tick(
790 trade: &DeribitPublicTrade,
791 instrument_id: InstrumentId,
792 price_precision: u8,
793 size_precision: u8,
794 ts_init: UnixNanos,
795) -> anyhow::Result<TradeTick> {
796 let aggressor_side = match trade.direction.as_str() {
798 "buy" => AggressorSide::Buy,
799 "sell" => AggressorSide::Sell,
800 other => anyhow::bail!("Invalid trade direction: {other}"),
801 };
802 let price = Price::from_decimal_dp(trade.price, price_precision)?;
803 let size = Quantity::from_decimal_dp(trade.amount, size_precision)?;
804 let ts_event = UnixNanos::from((trade.timestamp as u64) * NANOSECONDS_IN_MILLISECOND);
805 let trade_id = build_public_trade_id(
806 &trade.trade_id,
807 trade.block_rfq_id,
808 trade.block_trade_id.as_deref(),
809 trade.combo_id.as_deref(),
810 );
811
812 Ok(TradeTick::new(
813 instrument_id,
814 price,
815 size,
816 aggressor_side,
817 trade_id,
818 ts_event,
819 ts_init,
820 ))
821}
822
823#[must_use]
834pub fn use_cost_for_bar_volume(instrument: &InstrumentAny) -> bool {
835 if !instrument.is_inverse() {
836 return false;
837 }
838 !matches!(
839 instrument.instrument_class(),
840 InstrumentClass::Option | InstrumentClass::OptionSpread
841 )
842}
843
844pub fn parse_bars(
859 chart_data: &DeribitTradingViewChartData,
860 bar_type: BarType,
861 price_precision: u8,
862 size_precision: u8,
863 use_cost_for_volume: bool,
864 ts_init: UnixNanos,
865) -> anyhow::Result<Vec<Bar>> {
866 if chart_data.status != "ok" {
868 anyhow::bail!(
869 "Chart data status is '{}', expected 'ok'",
870 chart_data.status
871 );
872 }
873
874 let num_bars = chart_data.ticks.len();
875
876 anyhow::ensure!(
878 chart_data.open.len() == num_bars
879 && chart_data.high.len() == num_bars
880 && chart_data.low.len() == num_bars
881 && chart_data.close.len() == num_bars
882 && chart_data.volume.len() == num_bars
883 && chart_data.cost.len() == num_bars,
884 "Inconsistent array lengths in chart data"
885 );
886
887 if num_bars == 0 {
888 return Ok(Vec::new());
889 }
890
891 let mut bars = Vec::with_capacity(num_bars);
892
893 for i in 0..num_bars {
894 let open = Price::new_checked(chart_data.open[i], price_precision)
895 .with_context(|| format!("Invalid open price at index {i}"))?;
896 let high = Price::new_checked(chart_data.high[i], price_precision)
897 .with_context(|| format!("Invalid high price at index {i}"))?;
898 let low = Price::new_checked(chart_data.low[i], price_precision)
899 .with_context(|| format!("Invalid low price at index {i}"))?;
900 let close = Price::new_checked(chart_data.close[i], price_precision)
901 .with_context(|| format!("Invalid close price at index {i}"))?;
902 let raw_volume = if use_cost_for_volume {
903 chart_data.cost[i]
904 } else {
905 chart_data.volume[i]
906 };
907 let volume = Quantity::new_checked(raw_volume, size_precision)
908 .with_context(|| format!("Invalid volume at index {i}"))?;
909
910 let ts_event = UnixNanos::from((chart_data.ticks[i] as u64) * NANOSECONDS_IN_MILLISECOND);
912
913 let bar = Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
914 .with_context(|| format!("Invalid OHLC bar at index {i}"))?;
915 bars.push(bar);
916 }
917
918 Ok(bars)
919}
920
921pub fn parse_order_book(
930 order_book_data: &DeribitOrderBook,
931 instrument_id: InstrumentId,
932 price_precision: u8,
933 size_precision: u8,
934 ts_init: UnixNanos,
935) -> anyhow::Result<OrderBook> {
936 let ts_event = UnixNanos::from((order_book_data.timestamp as u64) * NANOSECONDS_IN_MILLISECOND);
937 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
938
939 for (idx, [price, amount]) in order_book_data.bids.iter().enumerate() {
940 let order = BookOrder::new(
941 OrderSide::Buy,
942 Price::new(*price, price_precision),
943 Quantity::new(*amount, size_precision),
944 idx as u64,
945 );
946 book.add(order, 0, idx as u64, ts_event);
947 }
948
949 let bids_len = order_book_data.bids.len();
950 for (idx, [price, amount]) in order_book_data.asks.iter().enumerate() {
951 let order = BookOrder::new(
952 OrderSide::Sell,
953 Price::new(*price, price_precision),
954 Quantity::new(*amount, size_precision),
955 (bids_len + idx) as u64,
956 );
957 book.add(order, 0, (bids_len + idx) as u64, ts_event);
958 }
959
960 book.ts_last = ts_init;
961
962 Ok(book)
963}
964
965pub fn bar_spec_to_resolution(bar_type: &BarType) -> String {
969 use nautilus_model::enums::BarAggregation;
970
971 let spec = bar_type.spec();
972 match spec.aggregation {
973 BarAggregation::Minute => {
974 let step = spec.step.get();
975 match step {
977 1 => "1".to_string(),
978 2..=3 => "3".to_string(),
979 4..=5 => "5".to_string(),
980 6..=10 => "10".to_string(),
981 11..=15 => "15".to_string(),
982 16..=30 => "30".to_string(),
983 31..=60 => "60".to_string(),
984 61..=120 => "120".to_string(),
985 121..=180 => "180".to_string(),
986 181..=360 => "360".to_string(),
987 361..=720 => "720".to_string(),
988 _ => "1D".to_string(),
989 }
990 }
991 BarAggregation::Hour => {
992 let step = spec.step.get();
993 match step {
994 1 => "60".to_string(),
995 2 => "120".to_string(),
996 3 => "180".to_string(),
997 4..=6 => "360".to_string(),
998 7..=12 => "720".to_string(),
999 _ => "1D".to_string(),
1000 }
1001 }
1002 BarAggregation::Day => "1D".to_string(),
1003 _ => {
1004 log::warn!(
1005 "Unsupported bar aggregation {:?}, defaulting to 1 minute",
1006 spec.aggregation
1007 );
1008 "1".to_string()
1009 }
1010 }
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015 use nautilus_model::{instruments::Instrument, types::Money};
1016 use rstest::rstest;
1017 use rust_decimal_macros::dec;
1018
1019 use super::*;
1020 use crate::{
1021 common::testing::load_test_json,
1022 http::models::{
1023 DeribitAccountSummariesResponse, DeribitJsonRpcResponse, DeribitTradesResponse,
1024 },
1025 };
1026
1027 #[rstest]
1028 fn test_parse_perpetual_instrument() {
1029 let json_data = load_test_json("http_get_instrument.json");
1030 let response: DeribitJsonRpcResponse<DeribitInstrument> =
1031 serde_json::from_str(&json_data).unwrap();
1032 let deribit_inst = response.result.expect("Test data must have result");
1033
1034 let instrument_any =
1035 parse_deribit_instrument_any(&deribit_inst, UnixNanos::default(), UnixNanos::default())
1036 .unwrap();
1037 let instrument = instrument_any.expect("Should parse perpetual instrument");
1038
1039 let InstrumentAny::CryptoPerpetual(perpetual) = instrument else {
1040 panic!("Expected CryptoPerpetual, was {instrument:?}");
1041 };
1042 assert_eq!(perpetual.id(), InstrumentId::from("BTC-PERPETUAL.DERIBIT"));
1043 assert_eq!(perpetual.raw_symbol(), Symbol::from("BTC-PERPETUAL"));
1044 assert_eq!(perpetual.base_currency().unwrap().code, "BTC");
1045 assert_eq!(perpetual.quote_currency().code, "USD");
1046 assert_eq!(perpetual.settlement_currency().code, "BTC");
1047 assert!(perpetual.is_inverse());
1048 assert_eq!(perpetual.price_precision(), 1);
1049 assert_eq!(perpetual.size_precision(), 0);
1050 assert_eq!(perpetual.price_increment(), Price::from("0.5"));
1051 assert_eq!(perpetual.size_increment(), Quantity::from("10"));
1052 assert_eq!(perpetual.multiplier(), Quantity::from("1"));
1053 assert_eq!(
1054 perpetual.calculate_notional_value(
1055 Quantity::from("10"),
1056 Price::from("50000"),
1057 Some(false)
1058 ),
1059 Money::from("0.0002 BTC")
1060 );
1061 assert_eq!(
1062 perpetual.calculate_notional_value(
1063 Quantity::from("10"),
1064 Price::from("50000"),
1065 Some(true)
1066 ),
1067 Money::from("10 USD")
1068 );
1069 assert_eq!(perpetual.lot_size(), Some(Quantity::from("10")));
1070 assert_eq!(perpetual.maker_fee(), dec!(0));
1071 assert_eq!(perpetual.taker_fee(), dec!(0.0005));
1072 assert_eq!(perpetual.max_quantity(), None);
1073 assert_eq!(perpetual.min_quantity(), Some(Quantity::from("10")));
1074 }
1075
1076 #[rstest]
1077 fn test_parse_future_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.as_str() == "BTC-27DEC24")
1085 .expect("Test data must contain BTC-27DEC24");
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 future instrument");
1091
1092 let InstrumentAny::CryptoFuture(future) = instrument else {
1093 panic!("Expected CryptoFuture, was {instrument:?}");
1094 };
1095 assert_eq!(future.id(), InstrumentId::from("BTC-27DEC24.DERIBIT"));
1096 assert_eq!(future.raw_symbol(), Symbol::from("BTC-27DEC24"));
1097 assert_eq!(future.underlying().unwrap(), "BTC");
1098 assert_eq!(future.quote_currency().code, "USD");
1099 assert_eq!(future.settlement_currency().code, "BTC");
1100 assert!(future.is_inverse());
1101
1102 assert_eq!(
1104 future.activation_ns(),
1105 Some(UnixNanos::from(1719561600000_u64 * 1_000_000))
1106 );
1107 assert_eq!(
1108 future.expiration_ns(),
1109 Some(UnixNanos::from(1735300800000_u64 * 1_000_000))
1110 );
1111 assert_eq!(future.price_precision(), 1);
1112 assert_eq!(future.size_precision(), 0);
1113 assert_eq!(future.price_increment(), Price::from("0.5"));
1114 assert_eq!(future.size_increment(), Quantity::from("10"));
1115 assert_eq!(future.multiplier(), Quantity::from("1"));
1116 assert_eq!(future.lot_size(), Some(Quantity::from("10")));
1117 assert_eq!(future.maker_fee, dec!(0));
1118 assert_eq!(future.taker_fee, dec!(0.0005));
1119 }
1120
1121 #[rstest]
1122 fn test_parse_option_instrument() {
1123 let json_data = load_test_json("http_get_instruments.json");
1124 let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1125 serde_json::from_str(&json_data).unwrap();
1126 let instruments = response.result.expect("Test data must have result");
1127 let deribit_inst = instruments
1128 .iter()
1129 .find(|i| i.instrument_name.as_str() == "BTC-27DEC24-100000-C")
1130 .expect("Test data must contain BTC-27DEC24-100000-C");
1131
1132 let instrument_any =
1133 parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
1134 .unwrap();
1135 let instrument = instrument_any.expect("Should parse option instrument");
1136
1137 let InstrumentAny::CryptoOption(option) = instrument else {
1139 panic!("Expected CryptoOption, was {instrument:?}");
1140 };
1141
1142 assert_eq!(
1143 option.id(),
1144 InstrumentId::from("BTC-27DEC24-100000-C.DERIBIT")
1145 );
1146 assert_eq!(option.raw_symbol(), Symbol::from("BTC-27DEC24-100000-C"));
1147 assert_eq!(option.underlying.code.as_str(), "BTC");
1148 assert_eq!(option.quote_currency.code.as_str(), "BTC");
1149 assert_eq!(option.settlement_currency.code.as_str(), "BTC");
1150 assert!(option.is_inverse);
1151 assert_eq!(option.option_kind, OptionKind::Call);
1152 assert_eq!(option.strike_price, Price::from("100000"));
1153 assert_eq!(
1154 option.activation_ns,
1155 UnixNanos::from(1719561600000_u64 * 1_000_000)
1156 );
1157 assert_eq!(
1158 option.expiration_ns,
1159 UnixNanos::from(1735300800000_u64 * 1_000_000)
1160 );
1161 assert_eq!(option.price_precision, 4);
1162 assert_eq!(option.price_increment, Price::from("0.0005"));
1163 assert_eq!(option.size_precision, 1);
1164 assert_eq!(option.size_increment, Quantity::from("0.1"));
1165 assert_eq!(option.multiplier, Quantity::from("1"));
1166 assert_eq!(option.lot_size, Quantity::from("0.1"));
1167 assert_eq!(option.maker_fee, dec!(0.0003));
1168 assert_eq!(option.taker_fee, dec!(0.0003));
1169 }
1170
1171 #[rstest]
1172 fn test_parse_account_state_with_positions() {
1173 let json_data = load_test_json("http_get_account_summaries.json");
1174 let response: DeribitJsonRpcResponse<DeribitAccountSummariesResponse> =
1175 serde_json::from_str(&json_data).unwrap();
1176 let result = response.result.expect("Test data must have result");
1177
1178 let account_id = AccountId::from("DERIBIT-001");
1179
1180 let ts_event =
1182 extract_server_timestamp(response.us_out).expect("Test data must have us_out");
1183 let ts_init = UnixNanos::default();
1184
1185 let account_state = parse_account_state(&result.summaries, account_id, ts_init, ts_event)
1186 .expect("Should parse account state");
1187
1188 assert_eq!(account_state.balances.len(), 2);
1190
1191 let btc_balance = account_state
1193 .balances
1194 .iter()
1195 .find(|b| b.currency.code == "BTC")
1196 .expect("BTC balance should exist");
1197
1198 assert_eq!(btc_balance.total.as_f64(), 302.62729214);
1207 assert_eq!(btc_balance.free.as_f64(), 301.38059622);
1208
1209 let locked = btc_balance.locked.as_f64();
1211 assert!(
1212 locked > 0.0,
1213 "Locked should be positive when positions exist"
1214 );
1215 assert!(
1216 (locked - 1.24669592).abs() < 0.0001,
1217 "Locked ({locked}) should equal initial_margin (1.24669592)"
1218 );
1219
1220 let eth_balance = account_state
1222 .balances
1223 .iter()
1224 .find(|b| b.currency.code == "ETH")
1225 .expect("ETH balance should exist");
1226
1227 assert_eq!(eth_balance.total.as_f64(), 100.0);
1232 assert_eq!(eth_balance.free.as_f64(), 99.999598);
1233 assert_eq!(eth_balance.locked.as_f64(), 0.000402);
1234
1235 assert_eq!(account_state.account_id, account_id);
1237 assert_eq!(account_state.account_type, AccountType::Margin);
1238 assert!(account_state.is_reported);
1239
1240 let expected_ts_event = UnixNanos::from(1687352432005000_u64 * NANOSECONDS_IN_MICROSECOND);
1242 assert_eq!(
1243 account_state.ts_event, expected_ts_event,
1244 "ts_event should match server timestamp from response"
1245 );
1246 }
1247
1248 #[rstest]
1249 fn test_parse_account_state_cross_margin() {
1250 let json_data = load_test_json("http_get_account_summaries_cross_margin.json");
1251 let response: DeribitJsonRpcResponse<DeribitAccountSummariesResponse> =
1252 serde_json::from_str(&json_data).unwrap();
1253 let result = response.result.expect("Test data must have result");
1254
1255 let account_id = AccountId::from("DERIBIT-001");
1256 let ts_event =
1257 extract_server_timestamp(response.us_out).expect("Test data must have us_out");
1258 let ts_init = UnixNanos::default();
1259
1260 let account_state = parse_account_state(&result.summaries, account_id, ts_init, ts_event)
1261 .expect("Should parse cross-margin account state");
1262
1263 assert_eq!(account_state.balances.len(), 4);
1270
1271 let btc = account_state
1273 .balances
1274 .iter()
1275 .find(|b| b.currency.code == "BTC")
1276 .expect("BTC balance should exist");
1277 assert_eq!(btc.total.as_f64(), 2.288e-5);
1278 assert_eq!(btc.free.as_f64(), 2.288e-5); assert_eq!(btc.locked.as_f64(), 0.0);
1280
1281 let usdt = account_state
1283 .balances
1284 .iter()
1285 .find(|b| b.currency.code == "USDT")
1286 .expect("USDT balance should exist");
1287 assert_eq!(usdt.total.as_f64(), 23.61869);
1288 assert_eq!(usdt.free.as_f64(), 23.618645); let usdt_locked = usdt.locked.as_f64();
1290 assert!(
1291 (usdt_locked - 0.000045).abs() < 0.001,
1292 "USDT locked ({usdt_locked}) should be close to 0.000045"
1293 );
1294
1295 let sol = account_state
1297 .balances
1298 .iter()
1299 .find(|b| b.currency.code == "SOL")
1300 .expect("SOL balance should exist");
1301 assert_eq!(sol.total.as_f64(), 0.0);
1302 assert_eq!(sol.free.as_f64(), 0.0);
1303
1304 let eth = account_state
1306 .balances
1307 .iter()
1308 .find(|b| b.currency.code == "ETH")
1309 .expect("ETH balance should exist");
1310 assert_eq!(eth.total.as_f64(), 8.6e-5);
1311 assert_eq!(eth.free.as_f64(), 8.5e-5); assert_eq!(account_state.account_type, AccountType::Margin);
1315 assert!(account_state.is_reported);
1316 }
1317
1318 #[rstest]
1319 fn test_parse_trade_tick_sell() {
1320 let json_data = load_test_json("http_get_last_trades.json");
1321 let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1322 serde_json::from_str(&json_data).unwrap();
1323 let result = response.result.expect("Test data must have result");
1324
1325 assert!(result.has_more, "has_more should be true");
1326 assert_eq!(result.trades.len(), 10, "Should have 10 trades");
1327
1328 let raw_trade = &result.trades[0];
1329 let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
1330 let ts_init = UnixNanos::from(1766335632425576_u64 * 1000); let trade = parse_trade_tick(raw_trade, instrument_id, 1, 0, ts_init)
1333 .expect("Should parse trade tick");
1334
1335 assert_eq!(trade.instrument_id, instrument_id);
1336 assert_eq!(trade.price, Price::from("2968.3"));
1337 assert_eq!(trade.size, Quantity::from("1"));
1338 assert_eq!(trade.aggressor_side, AggressorSide::Sell);
1339 assert_eq!(trade.trade_id, TradeId::new("ETH-284830839"));
1340 assert_eq!(
1342 trade.ts_event,
1343 UnixNanos::from(1766332040636_u64 * 1_000_000)
1344 );
1345 assert_eq!(trade.ts_init, ts_init);
1346 }
1347
1348 #[rstest]
1349 fn test_parse_trade_tick_buy() {
1350 let json_data = load_test_json("http_get_last_trades.json");
1351 let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1352 serde_json::from_str(&json_data).unwrap();
1353 let result = response.result.expect("Test data must have result");
1354
1355 let raw_trade = &result.trades[9];
1357 let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
1358 let ts_init = UnixNanos::default();
1359
1360 let trade = parse_trade_tick(raw_trade, instrument_id, 1, 0, ts_init)
1361 .expect("Should parse trade tick");
1362
1363 assert_eq!(trade.instrument_id, instrument_id);
1364 assert_eq!(trade.price, Price::from("2968.3"));
1365 assert_eq!(trade.size, Quantity::from("106"));
1366 assert_eq!(trade.aggressor_side, AggressorSide::Buy);
1367 assert_eq!(trade.trade_id, TradeId::new("ETH-284830854"));
1368 }
1369
1370 fn make_public_trade(
1373 trade_id: &str,
1374 block_trade_id: Option<&str>,
1375 block_rfq_id: Option<i64>,
1376 combo_id: Option<&str>,
1377 ) -> DeribitPublicTrade {
1378 let raw = serde_json::json!({
1379 "trade_id": trade_id,
1380 "instrument_name": "BTC-PERPETUAL",
1381 "price": 77000.0,
1382 "amount": 10.0,
1383 "direction": "buy",
1384 "timestamp": 1_779_107_386_210_i64,
1385 "trade_seq": 1,
1386 "tick_direction": 0,
1387 "block_trade_id": block_trade_id,
1388 "block_rfq_id": block_rfq_id,
1389 "combo_id": combo_id,
1390 });
1391 serde_json::from_value(raw).unwrap()
1392 }
1393
1394 #[rstest]
1395 #[case::block_rfq(None, Some(99_i64), None, "RFQ-244343055")]
1396 #[case::block_trade(Some("12345"), None, None, "BLK-244343055")]
1397 #[case::combo_leg(None, None, Some("BTC-FS-25DEC26_PERP"), "COMBO-244343055")]
1398 fn test_parse_trade_tick_provenance_prefix(
1399 #[case] block_trade_id: Option<&str>,
1400 #[case] block_rfq_id: Option<i64>,
1401 #[case] combo_id: Option<&str>,
1402 #[case] expected_trade_id: &str,
1403 ) {
1404 let trade = make_public_trade("244343055", block_trade_id, block_rfq_id, combo_id);
1405 let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1406 let tick = parse_trade_tick(&trade, instrument_id, 1, 0, UnixNanos::default())
1407 .expect("Should parse trade tick");
1408 assert_eq!(tick.trade_id, TradeId::new(expected_trade_id));
1409 }
1410
1411 #[rstest]
1412 fn test_use_cost_for_bar_volume() {
1413 let perp_json = load_test_json("http_get_instrument.json");
1415 let perp_response: DeribitJsonRpcResponse<DeribitInstrument> =
1416 serde_json::from_str(&perp_json).unwrap();
1417 let perp_inst = perp_response.result.expect("Test data must have result");
1418 let perp =
1419 parse_deribit_instrument_any(&perp_inst, UnixNanos::default(), UnixNanos::default())
1420 .unwrap()
1421 .expect("Should parse perpetual");
1422 assert!(perp.is_inverse());
1423 assert!(use_cost_for_bar_volume(&perp));
1424
1425 let instruments_json = load_test_json("http_get_instruments.json");
1427 let instruments_response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1428 serde_json::from_str(&instruments_json).unwrap();
1429 let instruments = instruments_response
1430 .result
1431 .expect("Test data must have result");
1432
1433 let option_inst = instruments
1434 .iter()
1435 .find(|i| i.instrument_name.as_str() == "BTC-27DEC24-100000-C")
1436 .expect("Test data must contain BTC-27DEC24-100000-C");
1437 let option =
1438 parse_deribit_instrument_any(option_inst, UnixNanos::default(), UnixNanos::default())
1439 .unwrap()
1440 .expect("Should parse option");
1441 assert!(option.is_inverse());
1442 assert!(
1443 !use_cost_for_bar_volume(&option),
1444 "options report trade amount in base currency, must keep using volume",
1445 );
1446
1447 let future_inst = instruments
1449 .iter()
1450 .find(|i| i.instrument_name.as_str() == "BTC-27DEC24")
1451 .expect("Test data must contain BTC-27DEC24");
1452 let future =
1453 parse_deribit_instrument_any(future_inst, UnixNanos::default(), UnixNanos::default())
1454 .unwrap()
1455 .expect("Should parse future");
1456 assert!(future.is_inverse());
1457 assert!(use_cost_for_bar_volume(&future));
1458 }
1459
1460 #[rstest]
1461 fn test_parse_bars_uses_volume_field() {
1462 let json_data = load_test_json("http_get_tradingview_chart_data.json");
1463 let response: DeribitJsonRpcResponse<DeribitTradingViewChartData> =
1464 serde_json::from_str(&json_data).unwrap();
1465 let chart_data = response.result.expect("Test data must have result");
1466
1467 let bar_type = BarType::from("BTC-PERPETUAL.DERIBIT-1-MINUTE-LAST-EXTERNAL");
1468 let ts_init = UnixNanos::from(1766487086146245_u64 * NANOSECONDS_IN_MICROSECOND);
1469
1470 let bars =
1471 parse_bars(&chart_data, bar_type, 1, 8, false, ts_init).expect("Should parse bars");
1472
1473 assert_eq!(bars.len(), 5, "Should parse 5 bars");
1474
1475 let first_bar = &bars[0];
1477 assert_eq!(first_bar.bar_type, bar_type);
1478 assert_eq!(first_bar.open, Price::from("87451.0"));
1479 assert_eq!(first_bar.high, Price::from("87456.5"));
1480 assert_eq!(first_bar.low, Price::from("87451.0"));
1481 assert_eq!(first_bar.close, Price::from("87456.5"));
1482 assert_eq!(first_bar.volume, Quantity::from("2.94375216"));
1483 assert_eq!(
1484 first_bar.ts_event,
1485 UnixNanos::from(1766483460000_u64 * NANOSECONDS_IN_MILLISECOND)
1486 );
1487 assert_eq!(first_bar.ts_init, ts_init);
1488
1489 let last_bar = &bars[4];
1491 assert_eq!(last_bar.open, Price::from("87456.0"));
1492 assert_eq!(last_bar.high, Price::from("87456.5"));
1493 assert_eq!(last_bar.low, Price::from("87456.0"));
1494 assert_eq!(last_bar.close, Price::from("87456.0"));
1495 assert_eq!(last_bar.volume, Quantity::from("0.1018798"));
1496 assert_eq!(
1497 last_bar.ts_event,
1498 UnixNanos::from(1766483700000_u64 * NANOSECONDS_IN_MILLISECOND)
1499 );
1500 }
1501
1502 #[rstest]
1503 fn test_parse_bars_cost_path() {
1504 let json_data = load_test_json("http_get_tradingview_chart_data.json");
1505 let response: DeribitJsonRpcResponse<DeribitTradingViewChartData> =
1506 serde_json::from_str(&json_data).unwrap();
1507 let chart_data = response.result.expect("Test data must have result");
1508
1509 let bar_type = BarType::from("BTC-PERPETUAL.DERIBIT-1-MINUTE-LAST-EXTERNAL");
1510 let ts_init = UnixNanos::from(1766487086146245_u64 * NANOSECONDS_IN_MICROSECOND);
1511
1512 let bars =
1514 parse_bars(&chart_data, bar_type, 1, 0, true, ts_init).expect("Should parse bars");
1515 assert_eq!(bars.len(), 5);
1516 assert_eq!(bars[0].volume, Quantity::from("257490"));
1517 assert_eq!(bars[4].volume, Quantity::from("8910"));
1518 }
1519
1520 #[rstest]
1521 fn test_parse_order_book() {
1522 let json_data = load_test_json("http_get_order_book.json");
1523 let response: DeribitJsonRpcResponse<DeribitOrderBook> =
1524 serde_json::from_str(&json_data).unwrap();
1525 let order_book_data = response.result.expect("Test data must have result");
1526
1527 let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1528 let ts_init = UnixNanos::from(1766554855146274_u64 * NANOSECONDS_IN_MICROSECOND);
1529
1530 let book = parse_order_book(&order_book_data, instrument_id, 1, 0, ts_init)
1531 .expect("Should parse order book");
1532
1533 assert_eq!(book.instrument_id, instrument_id);
1535 assert_eq!(book.book_type, BookType::L2_MBP);
1536 assert_eq!(book.ts_last, ts_init);
1537
1538 assert!(book.has_bid(), "Book should have bids");
1540 assert!(book.has_ask(), "Book should have asks");
1541
1542 assert_eq!(
1544 book.best_bid_price(),
1545 Some(Price::from("87002.5")),
1546 "Best bid price should match"
1547 );
1548 assert_eq!(
1549 book.best_bid_size(),
1550 Some(Quantity::from("199190")),
1551 "Best bid size should match"
1552 );
1553
1554 assert_eq!(
1556 book.best_ask_price(),
1557 Some(Price::from("87003.0")),
1558 "Best ask price should match"
1559 );
1560 assert_eq!(
1561 book.best_ask_size(),
1562 Some(Quantity::from("125090")),
1563 "Best ask size should match"
1564 );
1565
1566 let spread = book.spread().expect("Spread should exist");
1568 assert!(
1569 (spread - 0.5).abs() < 0.0001,
1570 "Spread should be 0.5, was {spread}"
1571 );
1572
1573 let midpoint = book.midpoint().expect("Midpoint should exist");
1575 assert!(
1576 (midpoint - 87002.75).abs() < 0.0001,
1577 "Midpoint should be 87002.75, was {midpoint}"
1578 );
1579
1580 let bid_count = book.bids(None).count();
1582 let ask_count = book.asks(None).count();
1583 assert_eq!(
1584 bid_count,
1585 order_book_data.bids.len(),
1586 "Bid levels count should match input data"
1587 );
1588 assert_eq!(
1589 ask_count,
1590 order_book_data.asks.len(),
1591 "Ask levels count should match input data"
1592 );
1593 assert_eq!(bid_count, 20, "Should have 20 bid levels");
1594 assert_eq!(ask_count, 20, "Should have 20 ask levels");
1595
1596 assert_eq!(
1598 book.bids(Some(5)).count(),
1599 5,
1600 "Should limit to 5 bid levels"
1601 );
1602 assert_eq!(
1603 book.asks(Some(5)).count(),
1604 5,
1605 "Should limit to 5 ask levels"
1606 );
1607
1608 let bids_map = book.bids_as_map(None);
1610 let asks_map = book.asks_as_map(None);
1611 assert_eq!(bids_map.len(), 20, "Bids map should have 20 entries");
1612 assert_eq!(asks_map.len(), 20, "Asks map should have 20 entries");
1613
1614 assert!(
1616 bids_map.contains_key(&dec!(87002.5)),
1617 "Bids map should contain best bid price"
1618 );
1619 assert!(
1620 asks_map.contains_key(&dec!(87003.0)),
1621 "Asks map should contain best ask price"
1622 );
1623
1624 assert!(
1626 bids_map.contains_key(&dec!(86980.0)),
1627 "Bids map should contain worst bid price"
1628 );
1629 assert!(
1630 asks_map.contains_key(&dec!(87031.5)),
1631 "Asks map should contain worst ask price"
1632 );
1633 }
1634
1635 fn make_instrument_id(symbol: &str) -> InstrumentId {
1636 InstrumentId::new(Symbol::from(symbol), *DERIBIT_VENUE)
1637 }
1638
1639 #[rstest]
1640 fn test_parse_futures_and_perpetuals() {
1641 let cases = [
1643 ("BTC-PERPETUAL", "future", "BTC"),
1644 ("ETH-PERPETUAL", "future", "ETH"),
1645 ("SOL-PERPETUAL", "future", "SOL"),
1646 ("BTC-25MAR23", "future", "BTC"),
1648 ("BTC-5AUG23", "future", "BTC"), ("ETH-28MAR25", "future", "ETH"),
1650 ];
1651
1652 for (symbol, expected_kind, expected_currency) in cases {
1653 let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1654 assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1655 assert_eq!(
1656 currency, expected_currency,
1657 "currency mismatch for {symbol}"
1658 );
1659 }
1660 }
1661
1662 #[rstest]
1663 fn test_parse_options() {
1664 let cases = [
1665 ("BTC-25MAR23-420-C", "option", "BTC"),
1667 ("BTC-5AUG23-580-P", "option", "BTC"),
1668 ("ETH-28MAR25-4000-C", "option", "ETH"),
1669 ("XRP_USDC-30JUN23-0d625-C", "option", "XRP"),
1671 ];
1672
1673 for (symbol, expected_kind, expected_currency) in cases {
1674 let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1675 assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1676 assert_eq!(
1677 currency, expected_currency,
1678 "currency mismatch for {symbol}"
1679 );
1680 }
1681 }
1682
1683 #[rstest]
1684 #[case::future_combo_vs_perp("BTC-FS-19MAY26_PERP", "future_combo", "BTC")]
1686 #[case::future_combo_inter_month("BTC-FS-22MAY26_19MAY26", "future_combo", "BTC")]
1687 #[case::future_combo_eth("ETH-FS-26JUN26_PERP", "future_combo", "ETH")]
1688 #[case::option_combo_call_spread("BTC-CS-19MAY26-70000_75000", "option_combo", "BTC")]
1690 #[case::option_combo_strangle("BTC-STRG-19MAY26-74000_79000", "option_combo", "BTC")]
1691 #[case::option_combo_straddle("BTC-STRD-29MAY26-77000", "option_combo", "BTC")]
1692 #[case::option_combo_box("BTC-BOX-25DEC26-58000_60000", "option_combo", "BTC")]
1693 #[case::option_combo_put_spread_eth("ETH-PS-26JUN26-3500_4000", "option_combo", "ETH")]
1694 fn test_parse_combo_kinds(
1695 #[case] symbol: &str,
1696 #[case] expected_kind: &str,
1697 #[case] expected_currency: &str,
1698 ) {
1699 let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1700 assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1701 assert_eq!(
1702 currency, expected_currency,
1703 "currency mismatch for {symbol}"
1704 );
1705 }
1706
1707 #[rstest]
1708 fn test_parse_option_combo_instrument() {
1709 let json_data = load_test_json("http_get_instruments_option_combo.json");
1710 let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1711 serde_json::from_str(&json_data).unwrap();
1712 let instruments = response.result.expect("Test data must have result");
1713 let raw = instruments
1714 .iter()
1715 .find(|i| i.instrument_name.as_str() == "BTC-STRG-19MAY26-74000_79000")
1716 .expect("fixture must contain BTC-STRG-19MAY26-74000_79000");
1717
1718 let any = parse_deribit_instrument_any(raw, UnixNanos::default(), UnixNanos::default())
1719 .unwrap()
1720 .expect("Should parse option combo");
1721
1722 let InstrumentAny::CryptoOptionSpread(spread) = any else {
1723 panic!("Expected CryptoOptionSpread, was {any:?}");
1724 };
1725 assert_eq!(
1726 spread.id,
1727 InstrumentId::from("BTC-STRG-19MAY26-74000_79000.DERIBIT")
1728 );
1729 assert_eq!(spread.underlying.code.as_str(), "BTC");
1730 assert_eq!(spread.strategy_type.as_str(), "STRG");
1731 assert_eq!(spread.quote_currency.code.as_str(), "BTC");
1732 assert_eq!(spread.settlement_currency.code.as_str(), "BTC");
1733 assert!(spread.is_inverse);
1734 assert_eq!(spread.price_precision, 4);
1735 assert_eq!(spread.price_increment, Price::from("0.0001"));
1736 assert_eq!(spread.size_precision, 1);
1737 assert_eq!(spread.size_increment, Quantity::from("0.1"));
1738 assert_eq!(spread.multiplier, Quantity::from("1"));
1739 assert_eq!(spread.lot_size, Quantity::from("0.1"));
1740 assert_eq!(
1741 spread.expiration_ns,
1742 UnixNanos::from(1779177600000_u64 * 1_000_000)
1743 );
1744 assert_eq!(
1745 spread.activation_ns,
1746 UnixNanos::from(1779100724000_u64 * 1_000_000)
1747 );
1748 assert_eq!(spread.maker_fee, dec!(0));
1749 assert_eq!(spread.taker_fee, dec!(0));
1750 }
1751
1752 #[rstest]
1753 fn test_deserialize_option_combo_trade_with_legs() {
1754 let json_data = load_test_json("http_get_last_trades_option_combo.json");
1755 let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1756 serde_json::from_str(&json_data).unwrap();
1757 let result = response.result.expect("Test data must have result");
1758
1759 let combo_trade = &result.trades[0];
1760 assert_eq!(combo_trade.trade_id, "244365193");
1761 assert_eq!(combo_trade.instrument_name, "BTC-CS-19MAY26-70000_75000");
1762 assert_eq!(combo_trade.combo_id.as_deref(), None);
1763 assert_eq!(combo_trade.combo_trade_id.as_deref(), None);
1764
1765 let legs = combo_trade
1766 .legs
1767 .as_ref()
1768 .expect("Combo trade must have legs");
1769 assert_eq!(legs.len(), 2);
1770
1771 let leg_75c = &legs[0];
1772 assert_eq!(leg_75c.instrument_name, "BTC-19MAY26-75000-C");
1773 assert_eq!(leg_75c.trade_id, "244365195");
1774 assert_eq!(leg_75c.combo_trade_id, "244365193");
1775 assert_eq!(leg_75c.combo_id, "BTC-CS-19MAY26-70000_75000");
1776 assert_eq!(leg_75c.direction, "buy");
1777 assert_eq!(leg_75c.price, dec!(0.0174));
1778 assert_eq!(leg_75c.amount, dec!(0.1));
1779 assert_eq!(leg_75c.iv, Some(dec!(41.01)));
1780
1781 let leg_70c = &legs[1];
1782 assert_eq!(leg_70c.instrument_name, "BTC-19MAY26-70000-C");
1783 assert_eq!(leg_70c.trade_id, "244365194");
1784 assert_eq!(leg_70c.direction, "sell");
1785 assert_eq!(leg_70c.iv, Some(dec!(83.39)));
1786 }
1787
1788 #[rstest]
1789 fn test_deserialize_future_combo_trade_with_legs() {
1790 let json_data = load_test_json("http_get_last_trades_future_combo.json");
1791 let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1792 serde_json::from_str(&json_data).unwrap();
1793 let result = response.result.expect("Test data must have result");
1794
1795 let combo_trade = &result.trades[0];
1796 assert_eq!(combo_trade.trade_id, "244343053");
1797 assert_eq!(combo_trade.instrument_name, "BTC-FS-25DEC26_PERP");
1798 assert_eq!(combo_trade.price, dec!(1320.0));
1799 assert_eq!(combo_trade.amount, dec!(10.0));
1800
1801 let legs = combo_trade
1802 .legs
1803 .as_ref()
1804 .expect("Future combo trade must have legs");
1805 assert_eq!(legs.len(), 2);
1806
1807 let leg_25dec = &legs[0];
1809 assert_eq!(leg_25dec.instrument_name, "BTC-25DEC26");
1810 assert_eq!(leg_25dec.trade_id, "244343055");
1811 assert_eq!(leg_25dec.combo_id, "BTC-FS-25DEC26_PERP");
1812 assert_eq!(leg_25dec.combo_trade_id, "244343053");
1813 assert_eq!(leg_25dec.direction, "sell");
1814 assert_eq!(leg_25dec.price, dec!(78624.0));
1815 assert_eq!(leg_25dec.amount, dec!(10.0));
1816 assert_eq!(leg_25dec.contracts, Some(dec!(1.0)));
1817 assert!(leg_25dec.iv.is_none(), "future leg must not carry iv");
1818
1819 let leg_perp = &legs[1];
1821 assert_eq!(leg_perp.instrument_name, "BTC-PERPETUAL");
1822 assert_eq!(leg_perp.trade_id, "244343054");
1823 assert_eq!(leg_perp.combo_id, "BTC-FS-25DEC26_PERP");
1824 assert_eq!(leg_perp.combo_trade_id, "244343053");
1825 assert_eq!(leg_perp.direction, "buy");
1826 assert_eq!(leg_perp.price, dec!(77304.0));
1827 assert!(leg_perp.iv.is_none(), "future leg must not carry iv");
1828 }
1829
1830 #[rstest]
1831 fn test_deserialize_historical_combo_leg_with_missing_optional_fields() {
1832 let json_data = load_test_json("http_get_last_trades_historical_combo.json");
1838 let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1839 serde_json::from_str(&json_data).unwrap();
1840 let result = response.result.expect("Test data must have result");
1841
1842 let combo_trade = &result.trades[0];
1843 assert_eq!(combo_trade.trade_id, "999000000");
1844 assert_eq!(combo_trade.instrument_name, "BTC-CS-19MAY26-70000_75000");
1845 assert!(combo_trade.contracts.is_none());
1847 assert!(combo_trade.index_price.is_none());
1848 assert!(combo_trade.mark_price.is_none());
1849
1850 let legs = combo_trade
1851 .legs
1852 .as_ref()
1853 .expect("Combo trade must have legs");
1854 assert_eq!(legs.len(), 2);
1855
1856 let leg1 = &legs[0];
1859 assert_eq!(leg1.instrument_name, "BTC-19MAY26-75000-C");
1860 assert_eq!(leg1.trade_id, "999000001");
1861 assert_eq!(leg1.combo_id, "BTC-CS-19MAY26-70000_75000");
1862 assert_eq!(leg1.combo_trade_id, "999000000");
1863 assert_eq!(leg1.direction, "buy");
1864 assert_eq!(leg1.price, dec!(0.0174));
1865 assert_eq!(leg1.amount, dec!(0.1));
1866 assert!(leg1.contracts.is_none());
1867 assert!(leg1.index_price.is_none());
1868 assert!(leg1.mark_price.is_none());
1869 assert!(leg1.iv.is_none());
1870
1871 let leg2 = &legs[1];
1874 assert_eq!(leg2.instrument_name, "BTC-19MAY26-70000-C");
1875 assert_eq!(leg2.trade_id, "999000002");
1876 assert!(leg2.contracts.is_none());
1877 assert!(leg2.mark_price.is_none());
1878 assert_eq!(leg2.index_price, Some(dec!(76185.14)));
1879 assert_eq!(leg2.iv, Some(dec!(83.39)));
1880 }
1881
1882 #[rstest]
1883 fn test_parse_combo_instrument_missing_expiration_errors() {
1884 let json_data = load_test_json("http_get_instruments_option_combo.json");
1888 let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1889 serde_json::from_str(&json_data).unwrap();
1890 let mut instruments = response.result.expect("Test data must have result");
1891 let raw = instruments
1892 .iter_mut()
1893 .find(|i| i.instrument_name.as_str() == "BTC-STRG-19MAY26-74000_79000")
1894 .expect("fixture must contain BTC-STRG-19MAY26-74000_79000");
1895 raw.expiration_timestamp = None;
1896
1897 let result = parse_deribit_instrument_any(raw, UnixNanos::default(), UnixNanos::default());
1898 let err = result.expect_err("Should error when expiration_timestamp is missing");
1899 let msg = format!("{err:#}");
1900 assert!(
1901 msg.contains("Missing expiration_timestamp for combo"),
1902 "unexpected error: {msg}"
1903 );
1904 }
1905
1906 #[rstest]
1907 fn test_deserialize_perpetual_combo_leg_tags() {
1908 let json_data = load_test_json("http_get_last_trades_perpetual_with_combo_tags.json");
1911 let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1912 serde_json::from_str(&json_data).unwrap();
1913 let result = response.result.expect("Test data must have result");
1914
1915 for trade in &result.trades {
1916 assert_eq!(trade.instrument_name, "BTC-PERPETUAL");
1917 assert_eq!(trade.combo_id.as_deref(), Some("BTC-FS-25DEC26_PERP"));
1918 assert!(
1919 trade.combo_trade_id.is_some(),
1920 "Per-leg trade should carry combo_trade_id"
1921 );
1922 assert!(trade.legs.is_none());
1924 }
1925 }
1926
1927 #[rstest]
1928 fn test_parse_future_combo_instrument() {
1929 let json_data = load_test_json("http_get_instruments_future_combo.json");
1930 let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1931 serde_json::from_str(&json_data).unwrap();
1932 let instruments = response.result.expect("Test data must have result");
1933 let raw = instruments
1934 .iter()
1935 .find(|i| i.instrument_name.as_str() == "BTC-FS-19MAY26_PERP")
1936 .expect("fixture must contain BTC-FS-19MAY26_PERP");
1937
1938 let any = parse_deribit_instrument_any(raw, UnixNanos::default(), UnixNanos::default())
1939 .unwrap()
1940 .expect("Should parse future combo");
1941
1942 let InstrumentAny::CryptoFuturesSpread(spread) = any else {
1943 panic!("Expected CryptoFuturesSpread, was {any:?}");
1944 };
1945 assert_eq!(spread.id, InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT"));
1946 assert_eq!(spread.underlying.code.as_str(), "BTC");
1947 assert_eq!(spread.strategy_type.as_str(), "FS");
1948 assert_eq!(spread.quote_currency.code.as_str(), "USD");
1950 assert_eq!(spread.settlement_currency.code.as_str(), "BTC");
1951 assert!(spread.is_inverse);
1952 assert_eq!(spread.price_precision, 1);
1953 assert_eq!(spread.price_increment, Price::from("0.5"));
1954 assert_eq!(spread.size_precision, 0);
1955 assert_eq!(spread.size_increment, Quantity::from("10"));
1956 assert_eq!(spread.multiplier, Quantity::from("1"));
1957 assert_eq!(spread.lot_size, Quantity::from("10"));
1958 assert_eq!(
1959 spread.expiration_ns,
1960 UnixNanos::from(1779177600000_u64 * 1_000_000)
1961 );
1962 }
1963
1964 #[rstest]
1965 fn test_build_public_trade_id_plain() {
1966 let id = build_public_trade_id("244343053", None, None, None);
1967 assert_eq!(id.as_str(), "244343053");
1968 }
1969
1970 #[rstest]
1971 fn test_build_public_trade_id_combo_only() {
1972 let id = build_public_trade_id("244365195", None, None, Some("BTC-CS-19MAY26-70000_75000"));
1973 assert_eq!(id.as_str(), "COMBO-244365195");
1974 }
1975
1976 #[rstest]
1977 fn test_build_public_trade_id_block_only() {
1978 let id = build_public_trade_id("244343053", None, Some("12345"), None);
1979 assert_eq!(id.as_str(), "BLK-244343053");
1980 }
1981
1982 #[rstest]
1983 fn test_build_public_trade_id_rfq_only() {
1984 let id = build_public_trade_id("244343053", Some(99), None, None);
1985 assert_eq!(id.as_str(), "RFQ-244343053");
1986 }
1987
1988 #[rstest]
1989 fn test_build_public_trade_id_precedence_block_beats_combo() {
1990 let id = build_public_trade_id(
1993 "244343053",
1994 None,
1995 Some("12345"),
1996 Some("BTC-FS-25DEC26_PERP"),
1997 );
1998 assert_eq!(id.as_str(), "BLK-244343053");
1999 }
2000
2001 #[rstest]
2002 fn test_build_public_trade_id_precedence_rfq_beats_all() {
2003 let id = build_public_trade_id(
2004 "244343053",
2005 Some(99),
2006 Some("12345"),
2007 Some("BTC-FS-25DEC26_PERP"),
2008 );
2009 assert_eq!(id.as_str(), "RFQ-244343053");
2010 }
2011
2012 #[rstest]
2013 fn test_parse_spot() {
2014 let cases = [
2015 ("BTC_USDC", "spot", "BTC"),
2016 ("ETH_USDT", "spot", "ETH"),
2017 ("SOL_USDC", "spot", "SOL"),
2018 ];
2019
2020 for (symbol, expected_kind, expected_currency) in cases {
2021 let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
2022 assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
2023 assert_eq!(
2024 currency, expected_currency,
2025 "currency mismatch for {symbol}"
2026 );
2027 }
2028 }
2029
2030 #[rstest]
2031 fn test_parse_portfolio_to_account_state() {
2032 let json_data = load_test_json("ws_portfolio.json");
2033 let notification: serde_json::Value = serde_json::from_str(&json_data).unwrap();
2034
2035 let data = notification
2037 .get("params")
2038 .and_then(|p| p.get("data"))
2039 .expect("Test data must have params.data");
2040
2041 let portfolio: DeribitPortfolioMsg =
2042 serde_json::from_value(data.clone()).expect("Should deserialize portfolio message");
2043
2044 assert_eq!(portfolio.currency, "USDT");
2046 assert_eq!(portfolio.equity, dec!(55.00055));
2047 assert_eq!(portfolio.balance, dec!(55.00055));
2048 assert_eq!(portfolio.available_funds, dec!(53.868247));
2049 assert_eq!(portfolio.margin_balance, dec!(54.968258));
2050 assert_eq!(portfolio.initial_margin, dec!(1.100011));
2051 assert_eq!(portfolio.maintenance_margin, dec!(0.0));
2052 assert_eq!(portfolio.cross_collateral_enabled, Some(true));
2053 assert_eq!(portfolio.margin_model.as_deref(), Some("cross_sm"));
2054
2055 let account_id = AccountId::new("DERIBIT-master");
2057 let ts_init = UnixNanos::from(1700000000000000000_u64);
2058
2059 let account_state =
2060 parse_portfolio_to_account_state(&portfolio, account_id, ts_init).unwrap();
2061
2062 assert_eq!(account_state.account_id, account_id);
2064 assert_eq!(account_state.account_type, AccountType::Margin);
2065 assert!(account_state.is_reported);
2066
2067 assert_eq!(account_state.balances.len(), 1);
2070 let balance = &account_state.balances[0];
2071 assert_eq!(balance.currency.code, "USDT");
2072 assert_eq!(balance.total.as_f64(), 55.00055); assert_eq!(balance.free.as_f64(), 54.968257); let locked = balance.locked.as_f64();
2077 assert!(
2078 (locked - 0.032293).abs() < 0.001,
2079 "Locked ({locked}) should be close to 0.032293"
2080 );
2081
2082 assert_eq!(account_state.margins.len(), 1);
2084 let margin = &account_state.margins[0];
2085 assert_eq!(margin.initial.as_f64(), 1.100011);
2086 assert_eq!(margin.maintenance.as_f64(), 0.0);
2087 assert!(margin.instrument_id.is_none());
2088 assert_eq!(margin.currency.code.as_str(), "USDT");
2089 }
2090
2091 #[rstest]
2092 #[case::minute_1(1, "MINUTE", "1")]
2093 #[case::minute_2(2, "MINUTE", "3")]
2094 #[case::minute_3(3, "MINUTE", "3")]
2095 #[case::minute_4(4, "MINUTE", "5")]
2096 #[case::minute_5(5, "MINUTE", "5")]
2097 #[case::minute_6(6, "MINUTE", "10")]
2098 #[case::minute_10(10, "MINUTE", "10")]
2099 #[case::minute_12(12, "MINUTE", "15")]
2100 #[case::minute_15(15, "MINUTE", "15")]
2101 #[case::minute_20(20, "MINUTE", "30")]
2102 #[case::minute_30(30, "MINUTE", "30")]
2103 #[case::hour_1(1, "HOUR", "60")]
2104 #[case::hour_2(2, "HOUR", "120")]
2105 #[case::hour_3(3, "HOUR", "180")]
2106 #[case::hour_4(4, "HOUR", "360")]
2107 #[case::hour_6(6, "HOUR", "360")]
2108 #[case::hour_12(12, "HOUR", "720")]
2109 #[case::day_1(1, "DAY", "1D")]
2110 fn test_bar_spec_to_resolution(
2111 #[case] step: u64,
2112 #[case] aggregation: &str,
2113 #[case] expected: &str,
2114 ) {
2115 let bar_type_str = format!("BTC-PERPETUAL.DERIBIT-{step}-{aggregation}-LAST-EXTERNAL");
2116 let bar_type = BarType::from(bar_type_str.as_str());
2117 let resolution = bar_spec_to_resolution(&bar_type);
2118 assert_eq!(resolution, expected);
2119 }
2120}