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, OptionKind, OrderSide},
29 events::AccountState,
30 identifiers::{AccountId, InstrumentId, Symbol, TradeId, Venue},
31 instruments::{CryptoFuture, CryptoOption, CryptoPerpetual, CurrencyPair, any::InstrumentAny},
32 orderbook::OrderBook,
33 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
34};
35use rust_decimal::Decimal;
36
37use crate::{
38 common::{
39 consts::DERIBIT_VENUE,
40 enums::{DeribitOptionType, DeribitProductType},
41 },
42 http::models::{
43 DeribitAccountSummary, DeribitInstrument, DeribitOrderBook, DeribitPublicTrade,
44 DeribitTradingViewChartData,
45 },
46 websocket::messages::DeribitPortfolioMsg,
47};
48
49#[must_use]
64pub fn parse_instrument_kind_currency(instrument_id: &InstrumentId) -> (String, String) {
65 let symbol = instrument_id.symbol.as_str();
66
67 let kind = if symbol.contains("PERPETUAL") {
70 "future" } else if symbol.ends_with("-C") || symbol.ends_with("-P") {
72 "option"
74 } else if symbol.contains('_') && !symbol.contains('-') {
75 "spot"
77 } else {
78 "future"
80 };
81
82 let currency = if let Some(idx) = symbol.find('-') {
85 let first_part = &symbol[..idx];
88 if let Some(underscore_idx) = first_part.find('_') {
89 first_part[..underscore_idx].to_string()
90 } else {
91 first_part.to_string()
92 }
93 } else if let Some(idx) = symbol.find('_') {
94 symbol[..idx].to_string()
96 } else {
97 "any".to_string()
98 };
99
100 (kind.to_string(), currency)
101}
102
103pub fn extract_server_timestamp(us_out: Option<u64>) -> anyhow::Result<UnixNanos> {
109 let us_out =
110 us_out.ok_or_else(|| anyhow::anyhow!("Missing server timestamp (us_out) in response"))?;
111 Ok(UnixNanos::from(us_out * NANOSECONDS_IN_MICROSECOND))
112}
113
114pub fn parse_deribit_instrument_any(
125 instrument: &DeribitInstrument,
126 ts_init: UnixNanos,
127 ts_event: UnixNanos,
128) -> anyhow::Result<Option<InstrumentAny>> {
129 match instrument.kind {
130 DeribitProductType::Spot => parse_spot_instrument(instrument, ts_init, ts_event).map(Some),
131 DeribitProductType::Future => {
132 if instrument.instrument_name.as_str().contains("PERPETUAL") {
134 parse_perpetual_instrument(instrument, ts_init, ts_event).map(Some)
135 } else {
136 parse_future_instrument(instrument, ts_init, ts_event).map(Some)
137 }
138 }
139 DeribitProductType::Option => {
140 parse_option_instrument(instrument, ts_init, ts_event).map(Some)
141 }
142 DeribitProductType::FutureCombo | DeribitProductType::OptionCombo => {
143 log::debug!(
144 "Skipping combo instrument: {} (kind={:?})",
145 instrument.instrument_name,
146 instrument.kind
147 );
148 Ok(None)
149 }
150 }
151}
152
153fn parse_spot_instrument(
155 instrument: &DeribitInstrument,
156 ts_init: UnixNanos,
157 ts_event: UnixNanos,
158) -> anyhow::Result<InstrumentAny> {
159 let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
160
161 let base_currency = Currency::get_or_create_crypto(instrument.base_currency);
162 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
163
164 let price_increment = Price::from_decimal(instrument.tick_size)?;
165 let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
166 let min_quantity = Quantity::from_decimal(instrument.min_trade_amount)?;
167
168 let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
169 .context("Failed to parse maker_commission")?;
170 let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
171 .context("Failed to parse taker_commission")?;
172
173 let currency_pair = CurrencyPair::new(
174 instrument_id,
175 instrument.instrument_name.into(),
176 base_currency,
177 quote_currency,
178 price_increment.precision,
179 size_increment.precision,
180 price_increment,
181 size_increment,
182 None, None, None, Some(min_quantity),
186 None, None, None, None, None, None, Some(maker_fee),
193 Some(taker_fee),
194 ts_event,
195 ts_init,
196 );
197
198 Ok(InstrumentAny::CurrencyPair(currency_pair))
199}
200
201fn parse_perpetual_instrument(
203 instrument: &DeribitInstrument,
204 ts_init: UnixNanos,
205 ts_event: UnixNanos,
206) -> anyhow::Result<InstrumentAny> {
207 let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
208
209 let base_currency = Currency::get_or_create_crypto(instrument.base_currency);
210 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
211 let settlement_currency = instrument
212 .settlement_currency
213 .map_or(base_currency, Currency::get_or_create_crypto);
214
215 let is_inverse = instrument
216 .instrument_type
217 .as_ref()
218 .is_some_and(|t| t == "reversed");
219
220 let price_increment = Price::from_decimal(instrument.tick_size)?;
221 let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
222 let min_quantity = Quantity::from_decimal(instrument.min_trade_amount)?;
223
224 let multiplier = Some(Quantity::from_decimal(instrument.contract_size)?);
226 let lot_size = Some(size_increment);
227
228 let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
229 .context("Failed to parse maker_commission")?;
230 let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
231 .context("Failed to parse taker_commission")?;
232
233 let perpetual = CryptoPerpetual::new(
234 instrument_id,
235 instrument.instrument_name.into(),
236 base_currency,
237 quote_currency,
238 settlement_currency,
239 is_inverse,
240 price_increment.precision,
241 size_increment.precision,
242 price_increment,
243 size_increment,
244 multiplier,
245 lot_size,
246 None, Some(min_quantity),
248 None, None, None, None, None, None, Some(maker_fee),
255 Some(taker_fee),
256 ts_event,
257 ts_init,
258 );
259
260 Ok(InstrumentAny::CryptoPerpetual(perpetual))
261}
262
263fn parse_future_instrument(
265 instrument: &DeribitInstrument,
266 ts_init: UnixNanos,
267 ts_event: UnixNanos,
268) -> anyhow::Result<InstrumentAny> {
269 let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
270
271 let underlying = Currency::get_or_create_crypto(instrument.base_currency);
272 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
273 let settlement_currency = instrument
274 .settlement_currency
275 .map_or(underlying, Currency::get_or_create_crypto);
276
277 let is_inverse = instrument
278 .instrument_type
279 .as_ref()
280 .is_some_and(|t| t == "reversed");
281
282 let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
284 let expiration_ns = instrument
285 .expiration_timestamp
286 .context("Missing expiration_timestamp for future")? as u64
287 * 1_000_000; let price_increment = Price::from_decimal(instrument.tick_size)?;
290 let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
291 let min_quantity = Quantity::from_decimal(instrument.min_trade_amount)?;
292
293 let multiplier = Some(Quantity::from_decimal(instrument.contract_size)?);
295 let lot_size = Some(size_increment); let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
298 .context("Failed to parse maker_commission")?;
299 let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
300 .context("Failed to parse taker_commission")?;
301
302 let future = CryptoFuture::new(
303 instrument_id,
304 instrument.instrument_name.into(),
305 underlying,
306 quote_currency,
307 settlement_currency,
308 is_inverse,
309 UnixNanos::from(activation_ns),
310 UnixNanos::from(expiration_ns),
311 price_increment.precision,
312 size_increment.precision,
313 price_increment,
314 size_increment,
315 multiplier,
316 lot_size,
317 None, Some(min_quantity),
319 None, None, None, None, None, None, Some(maker_fee),
326 Some(taker_fee),
327 ts_event,
328 ts_init,
329 );
330
331 Ok(InstrumentAny::CryptoFuture(future))
332}
333
334fn parse_option_instrument(
336 instrument: &DeribitInstrument,
337 ts_init: UnixNanos,
338 ts_event: UnixNanos,
339) -> anyhow::Result<InstrumentAny> {
340 let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
341 let underlying = Currency::get_or_create_crypto(instrument.base_currency);
342 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
343 let settlement = instrument
344 .settlement_currency
345 .unwrap_or(instrument.base_currency);
346 let settlement_currency = Currency::get_or_create_crypto(settlement);
347
348 let is_inverse = instrument
350 .instrument_type
351 .as_ref()
352 .is_some_and(|t| t == "reversed");
353
354 let option_kind = match instrument.option_type {
356 Some(DeribitOptionType::Call) => OptionKind::Call,
357 Some(DeribitOptionType::Put) => OptionKind::Put,
358 None => anyhow::bail!("Missing option_type for option instrument"),
359 };
360
361 let strike = instrument.strike.context("Missing strike for option")?;
363 let strike_price = Price::from_decimal(strike)?;
364
365 let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
367 let expiration_ns = instrument
368 .expiration_timestamp
369 .context("Missing expiration_timestamp for option")? as u64
370 * 1_000_000;
371
372 let price_increment = Price::from_decimal(instrument.tick_size)?;
373
374 let multiplier = Quantity::from_decimal(instrument.contract_size)?;
376 let lot_size = Quantity::from_decimal(instrument.min_trade_amount)?;
377 let min_trade_amount = Quantity::from_decimal(instrument.min_trade_amount)?;
378
379 let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
380 .context("Failed to parse maker_commission")?;
381 let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
382 .context("Failed to parse taker_commission")?;
383
384 let option = CryptoOption::new(
385 instrument_id,
386 instrument.instrument_name.into(),
387 underlying,
388 quote_currency,
389 settlement_currency,
390 is_inverse,
391 option_kind,
392 strike_price,
393 UnixNanos::from(activation_ns),
394 UnixNanos::from(expiration_ns),
395 price_increment.precision,
396 lot_size.precision,
397 price_increment,
398 lot_size,
399 Some(multiplier),
400 Some(lot_size),
401 None,
402 Some(min_trade_amount),
403 None,
404 None,
405 None,
406 None,
407 None,
408 None,
409 Some(maker_fee),
410 Some(taker_fee),
411 ts_event,
412 ts_init,
413 );
414
415 Ok(InstrumentAny::CryptoOption(option))
416}
417
418pub fn parse_account_state(
428 summaries: &[DeribitAccountSummary],
429 account_id: AccountId,
430 ts_init: UnixNanos,
431 ts_event: UnixNanos,
432) -> anyhow::Result<AccountState> {
433 let mut balances = Vec::new();
434 let mut margins = Vec::new();
435
436 for summary in summaries {
438 let ccy_str = summary.currency.as_str().trim();
439
440 if ccy_str.is_empty() {
442 log::debug!("Skipping balance detail with empty currency code | raw_data={summary:?}");
443 continue;
444 }
445
446 let currency = Currency::get_or_create_crypto_with_context(
447 ccy_str,
448 Some("DERIBIT - Parsing account state"),
449 );
450
451 let total = Money::from_decimal(summary.margin_balance, currency)?;
458 let free = Money::from_decimal(summary.available_funds, currency)?;
459 let locked = Money::from_raw(total.raw - free.raw, currency);
460
461 let balance = AccountBalance::new(total, locked, free);
462 balances.push(balance);
463
464 if let (Some(initial_margin), Some(maintenance_margin)) =
466 (summary.initial_margin, summary.maintenance_margin)
467 {
468 if !initial_margin.is_zero() || !maintenance_margin.is_zero() {
470 let initial = Money::from_decimal(initial_margin, currency)?;
471 let maintenance = Money::from_decimal(maintenance_margin, currency)?;
472
473 let margin_instrument_id = InstrumentId::new(
477 Symbol::from_str_unchecked(format!("ACCOUNT-{}", summary.currency)),
478 Venue::new("DERIBIT"),
479 );
480
481 margins.push(MarginBalance::new(
482 initial,
483 maintenance,
484 margin_instrument_id,
485 ));
486 }
487 }
488 }
489
490 if balances.is_empty() {
492 let zero_currency = Currency::USD();
493 let zero_money = Money::new(0.0, zero_currency);
494 let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
495 balances.push(zero_balance);
496 }
497
498 let account_type = AccountType::Margin;
499 let is_reported = true;
500
501 Ok(AccountState::new(
502 account_id,
503 account_type,
504 balances,
505 margins,
506 is_reported,
507 UUID4::new(),
508 ts_event,
509 ts_init,
510 None,
511 ))
512}
513
514pub fn parse_portfolio_to_account_state(
527 portfolio: &DeribitPortfolioMsg,
528 account_id: AccountId,
529 ts_init: UnixNanos,
530) -> anyhow::Result<AccountState> {
531 let ccy_str = portfolio.currency.trim();
532
533 if ccy_str.is_empty() {
535 anyhow::bail!("Portfolio message has empty currency code");
536 }
537
538 let currency = Currency::get_or_create_crypto_with_context(
539 ccy_str,
540 Some("DERIBIT - Parsing portfolio update"),
541 );
542
543 let total = Money::from_decimal(portfolio.margin_balance, currency)?;
554 let free = Money::from_decimal(portfolio.available_funds, currency)?;
555 let locked = Money::from_raw(total.raw - free.raw, currency);
556
557 let balance = AccountBalance::new(total, locked, free);
558 let balances = vec![balance];
559
560 let mut margins = Vec::new();
562 let initial_margin = portfolio.initial_margin;
563 let maintenance_margin = portfolio.maintenance_margin;
564
565 if !initial_margin.is_zero() || !maintenance_margin.is_zero() {
567 let initial = Money::from_decimal(initial_margin, currency)?;
568 let maintenance = Money::from_decimal(maintenance_margin, currency)?;
569
570 let margin_instrument_id = InstrumentId::new(
572 Symbol::from_str_unchecked(format!("ACCOUNT-{}", portfolio.currency)),
573 Venue::new("DERIBIT"),
574 );
575
576 margins.push(MarginBalance::new(
577 initial,
578 maintenance,
579 margin_instrument_id,
580 ));
581 }
582
583 let account_type = AccountType::Margin;
584 let is_reported = true;
585
586 Ok(AccountState::new(
587 account_id,
588 account_type,
589 balances,
590 margins,
591 is_reported,
592 UUID4::new(),
593 ts_init, ts_init,
595 None,
596 ))
597}
598
599pub fn parse_trade_tick(
607 trade: &DeribitPublicTrade,
608 instrument_id: InstrumentId,
609 price_precision: u8,
610 size_precision: u8,
611 ts_init: UnixNanos,
612) -> anyhow::Result<TradeTick> {
613 let aggressor_side = match trade.direction.as_str() {
615 "buy" => AggressorSide::Buyer,
616 "sell" => AggressorSide::Seller,
617 other => anyhow::bail!("Invalid trade direction: {other}"),
618 };
619 let price = Price::from_decimal_dp(trade.price, price_precision)?;
620 let size = Quantity::from_decimal_dp(trade.amount, size_precision)?;
621 let ts_event = UnixNanos::from((trade.timestamp as u64) * NANOSECONDS_IN_MILLISECOND);
622 let trade_id = TradeId::new(&trade.trade_id);
623
624 Ok(TradeTick::new(
625 instrument_id,
626 price,
627 size,
628 aggressor_side,
629 trade_id,
630 ts_event,
631 ts_init,
632 ))
633}
634
635pub fn parse_bars(
647 chart_data: &DeribitTradingViewChartData,
648 bar_type: BarType,
649 price_precision: u8,
650 size_precision: u8,
651 ts_init: UnixNanos,
652) -> anyhow::Result<Vec<Bar>> {
653 if chart_data.status != "ok" {
655 anyhow::bail!(
656 "Chart data status is '{}', expected 'ok'",
657 chart_data.status
658 );
659 }
660
661 let num_bars = chart_data.ticks.len();
662
663 anyhow::ensure!(
665 chart_data.open.len() == num_bars
666 && chart_data.high.len() == num_bars
667 && chart_data.low.len() == num_bars
668 && chart_data.close.len() == num_bars
669 && chart_data.volume.len() == num_bars,
670 "Inconsistent array lengths in chart data"
671 );
672
673 if num_bars == 0 {
674 return Ok(Vec::new());
675 }
676
677 let mut bars = Vec::with_capacity(num_bars);
678
679 for i in 0..num_bars {
680 let open = Price::new_checked(chart_data.open[i], price_precision)
681 .with_context(|| format!("Invalid open price at index {i}"))?;
682 let high = Price::new_checked(chart_data.high[i], price_precision)
683 .with_context(|| format!("Invalid high price at index {i}"))?;
684 let low = Price::new_checked(chart_data.low[i], price_precision)
685 .with_context(|| format!("Invalid low price at index {i}"))?;
686 let close = Price::new_checked(chart_data.close[i], price_precision)
687 .with_context(|| format!("Invalid close price at index {i}"))?;
688 let volume = Quantity::new_checked(chart_data.volume[i], size_precision)
689 .with_context(|| format!("Invalid volume at index {i}"))?;
690
691 let ts_event = UnixNanos::from((chart_data.ticks[i] as u64) * NANOSECONDS_IN_MILLISECOND);
693
694 let bar = Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
695 .with_context(|| format!("Invalid OHLC bar at index {i}"))?;
696 bars.push(bar);
697 }
698
699 Ok(bars)
700}
701
702pub fn parse_order_book(
711 order_book_data: &DeribitOrderBook,
712 instrument_id: InstrumentId,
713 price_precision: u8,
714 size_precision: u8,
715 ts_init: UnixNanos,
716) -> anyhow::Result<OrderBook> {
717 let ts_event = UnixNanos::from((order_book_data.timestamp as u64) * NANOSECONDS_IN_MILLISECOND);
718 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
719
720 for (idx, [price, amount]) in order_book_data.bids.iter().enumerate() {
721 let order = BookOrder::new(
722 OrderSide::Buy,
723 Price::new(*price, price_precision),
724 Quantity::new(*amount, size_precision),
725 idx as u64,
726 );
727 book.add(order, 0, idx as u64, ts_event);
728 }
729
730 let bids_len = order_book_data.bids.len();
731 for (idx, [price, amount]) in order_book_data.asks.iter().enumerate() {
732 let order = BookOrder::new(
733 OrderSide::Sell,
734 Price::new(*price, price_precision),
735 Quantity::new(*amount, size_precision),
736 (bids_len + idx) as u64,
737 );
738 book.add(order, 0, (bids_len + idx) as u64, ts_event);
739 }
740
741 book.ts_last = ts_init;
742
743 Ok(book)
744}
745
746pub fn bar_spec_to_resolution(bar_type: &BarType) -> String {
750 use nautilus_model::enums::BarAggregation;
751
752 let spec = bar_type.spec();
753 match spec.aggregation {
754 BarAggregation::Minute => {
755 let step = spec.step.get();
756 match step {
758 1 => "1".to_string(),
759 2..=3 => "3".to_string(),
760 4..=5 => "5".to_string(),
761 6..=10 => "10".to_string(),
762 11..=15 => "15".to_string(),
763 16..=30 => "30".to_string(),
764 31..=60 => "60".to_string(),
765 61..=120 => "120".to_string(),
766 121..=180 => "180".to_string(),
767 181..=360 => "360".to_string(),
768 361..=720 => "720".to_string(),
769 _ => "1D".to_string(),
770 }
771 }
772 BarAggregation::Hour => {
773 let step = spec.step.get();
774 match step {
775 1 => "60".to_string(),
776 2 => "120".to_string(),
777 3 => "180".to_string(),
778 4..=6 => "360".to_string(),
779 7..=12 => "720".to_string(),
780 _ => "1D".to_string(),
781 }
782 }
783 BarAggregation::Day => "1D".to_string(),
784 _ => {
785 log::warn!(
786 "Unsupported bar aggregation {:?}, defaulting to 1 minute",
787 spec.aggregation
788 );
789 "1".to_string()
790 }
791 }
792}
793
794#[cfg(test)]
795mod tests {
796 use nautilus_model::instruments::Instrument;
797 use rstest::rstest;
798 use rust_decimal_macros::dec;
799
800 use super::*;
801 use crate::{
802 common::testing::load_test_json,
803 http::models::{
804 DeribitAccountSummariesResponse, DeribitJsonRpcResponse, DeribitTradesResponse,
805 },
806 };
807
808 #[rstest]
809 fn test_parse_perpetual_instrument() {
810 let json_data = load_test_json("http_get_instrument.json");
811 let response: DeribitJsonRpcResponse<DeribitInstrument> =
812 serde_json::from_str(&json_data).unwrap();
813 let deribit_inst = response.result.expect("Test data must have result");
814
815 let instrument_any =
816 parse_deribit_instrument_any(&deribit_inst, UnixNanos::default(), UnixNanos::default())
817 .unwrap();
818 let instrument = instrument_any.expect("Should parse perpetual instrument");
819
820 let InstrumentAny::CryptoPerpetual(perpetual) = instrument else {
821 panic!("Expected CryptoPerpetual, was {instrument:?}");
822 };
823 assert_eq!(perpetual.id(), InstrumentId::from("BTC-PERPETUAL.DERIBIT"));
824 assert_eq!(perpetual.raw_symbol(), Symbol::from("BTC-PERPETUAL"));
825 assert_eq!(perpetual.base_currency().unwrap().code, "BTC");
826 assert_eq!(perpetual.quote_currency().code, "USD");
827 assert_eq!(perpetual.settlement_currency().code, "BTC");
828 assert!(perpetual.is_inverse());
829 assert_eq!(perpetual.price_precision(), 1);
830 assert_eq!(perpetual.size_precision(), 0);
831 assert_eq!(perpetual.price_increment(), Price::from("0.5"));
832 assert_eq!(perpetual.size_increment(), Quantity::from("10"));
833 assert_eq!(perpetual.multiplier(), Quantity::from("10"));
834 assert_eq!(perpetual.lot_size(), Some(Quantity::from("10")));
835 assert_eq!(perpetual.maker_fee(), dec!(0));
836 assert_eq!(perpetual.taker_fee(), dec!(0.0005));
837 assert_eq!(perpetual.max_quantity(), None);
838 assert_eq!(perpetual.min_quantity(), Some(Quantity::from("10")));
839 }
840
841 #[rstest]
842 fn test_parse_future_instrument() {
843 let json_data = load_test_json("http_get_instruments.json");
844 let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
845 serde_json::from_str(&json_data).unwrap();
846 let instruments = response.result.expect("Test data must have result");
847 let deribit_inst = instruments
848 .iter()
849 .find(|i| i.instrument_name.as_str() == "BTC-27DEC24")
850 .expect("Test data must contain BTC-27DEC24");
851
852 let instrument_any =
853 parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
854 .unwrap();
855 let instrument = instrument_any.expect("Should parse future instrument");
856
857 let InstrumentAny::CryptoFuture(future) = instrument else {
858 panic!("Expected CryptoFuture, was {instrument:?}");
859 };
860 assert_eq!(future.id(), InstrumentId::from("BTC-27DEC24.DERIBIT"));
861 assert_eq!(future.raw_symbol(), Symbol::from("BTC-27DEC24"));
862 assert_eq!(future.underlying().unwrap(), "BTC");
863 assert_eq!(future.quote_currency().code, "USD");
864 assert_eq!(future.settlement_currency().code, "BTC");
865 assert!(future.is_inverse());
866
867 assert_eq!(
869 future.activation_ns(),
870 Some(UnixNanos::from(1719561600000_u64 * 1_000_000))
871 );
872 assert_eq!(
873 future.expiration_ns(),
874 Some(UnixNanos::from(1735300800000_u64 * 1_000_000))
875 );
876 assert_eq!(future.price_precision(), 1);
877 assert_eq!(future.size_precision(), 0);
878 assert_eq!(future.price_increment(), Price::from("0.5"));
879 assert_eq!(future.size_increment(), Quantity::from("10"));
880 assert_eq!(future.multiplier(), Quantity::from("10"));
881 assert_eq!(future.lot_size(), Some(Quantity::from("10")));
882 assert_eq!(future.maker_fee, dec!(0));
883 assert_eq!(future.taker_fee, dec!(0.0005));
884 }
885
886 #[rstest]
887 fn test_parse_option_instrument() {
888 let json_data = load_test_json("http_get_instruments.json");
889 let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
890 serde_json::from_str(&json_data).unwrap();
891 let instruments = response.result.expect("Test data must have result");
892 let deribit_inst = instruments
893 .iter()
894 .find(|i| i.instrument_name.as_str() == "BTC-27DEC24-100000-C")
895 .expect("Test data must contain BTC-27DEC24-100000-C");
896
897 let instrument_any =
898 parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
899 .unwrap();
900 let instrument = instrument_any.expect("Should parse option instrument");
901
902 let InstrumentAny::CryptoOption(option) = instrument else {
904 panic!("Expected CryptoOption, was {instrument:?}");
905 };
906
907 assert_eq!(
908 option.id(),
909 InstrumentId::from("BTC-27DEC24-100000-C.DERIBIT")
910 );
911 assert_eq!(option.raw_symbol(), Symbol::from("BTC-27DEC24-100000-C"));
912 assert_eq!(option.underlying.code.as_str(), "BTC");
913 assert_eq!(option.quote_currency.code.as_str(), "BTC");
914 assert_eq!(option.settlement_currency.code.as_str(), "BTC");
915 assert!(option.is_inverse);
916 assert_eq!(option.option_kind, OptionKind::Call);
917 assert_eq!(option.strike_price, Price::from("100000"));
918 assert_eq!(
919 option.activation_ns,
920 UnixNanos::from(1719561600000_u64 * 1_000_000)
921 );
922 assert_eq!(
923 option.expiration_ns,
924 UnixNanos::from(1735300800000_u64 * 1_000_000)
925 );
926 assert_eq!(option.price_precision, 4);
927 assert_eq!(option.price_increment, Price::from("0.0005"));
928 assert_eq!(option.size_precision, 1);
929 assert_eq!(option.size_increment, Quantity::from("0.1"));
930 assert_eq!(option.multiplier, Quantity::from("1"));
931 assert_eq!(option.lot_size, Quantity::from("0.1"));
932 assert_eq!(option.maker_fee, dec!(0.0003));
933 assert_eq!(option.taker_fee, dec!(0.0003));
934 }
935
936 #[rstest]
937 fn test_parse_account_state_with_positions() {
938 let json_data = load_test_json("http_get_account_summaries.json");
939 let response: DeribitJsonRpcResponse<DeribitAccountSummariesResponse> =
940 serde_json::from_str(&json_data).unwrap();
941 let result = response.result.expect("Test data must have result");
942
943 let account_id = AccountId::from("DERIBIT-001");
944
945 let ts_event =
947 extract_server_timestamp(response.us_out).expect("Test data must have us_out");
948 let ts_init = UnixNanos::default();
949
950 let account_state = parse_account_state(&result.summaries, account_id, ts_init, ts_event)
951 .expect("Should parse account state");
952
953 assert_eq!(account_state.balances.len(), 2);
955
956 let btc_balance = account_state
958 .balances
959 .iter()
960 .find(|b| b.currency.code == "BTC")
961 .expect("BTC balance should exist");
962
963 assert_eq!(btc_balance.total.as_f64(), 302.62729214);
972 assert_eq!(btc_balance.free.as_f64(), 301.38059622);
973
974 let locked = btc_balance.locked.as_f64();
976 assert!(
977 locked > 0.0,
978 "Locked should be positive when positions exist"
979 );
980 assert!(
981 (locked - 1.24669592).abs() < 0.0001,
982 "Locked ({locked}) should equal initial_margin (1.24669592)"
983 );
984
985 let eth_balance = account_state
987 .balances
988 .iter()
989 .find(|b| b.currency.code == "ETH")
990 .expect("ETH balance should exist");
991
992 assert_eq!(eth_balance.total.as_f64(), 100.0);
997 assert_eq!(eth_balance.free.as_f64(), 99.999598);
998 assert_eq!(eth_balance.locked.as_f64(), 0.000402);
999
1000 assert_eq!(account_state.account_id, account_id);
1002 assert_eq!(account_state.account_type, AccountType::Margin);
1003 assert!(account_state.is_reported);
1004
1005 let expected_ts_event = UnixNanos::from(1687352432005000_u64 * NANOSECONDS_IN_MICROSECOND);
1007 assert_eq!(
1008 account_state.ts_event, expected_ts_event,
1009 "ts_event should match server timestamp from response"
1010 );
1011 }
1012
1013 #[rstest]
1014 fn test_parse_trade_tick_sell() {
1015 let json_data = load_test_json("http_get_last_trades.json");
1016 let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1017 serde_json::from_str(&json_data).unwrap();
1018 let result = response.result.expect("Test data must have result");
1019
1020 assert!(result.has_more, "has_more should be true");
1021 assert_eq!(result.trades.len(), 10, "Should have 10 trades");
1022
1023 let raw_trade = &result.trades[0];
1024 let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
1025 let ts_init = UnixNanos::from(1766335632425576_u64 * 1000); let trade = parse_trade_tick(raw_trade, instrument_id, 1, 0, ts_init)
1028 .expect("Should parse trade tick");
1029
1030 assert_eq!(trade.instrument_id, instrument_id);
1031 assert_eq!(trade.price, Price::from("2968.3"));
1032 assert_eq!(trade.size, Quantity::from("1"));
1033 assert_eq!(trade.aggressor_side, AggressorSide::Seller);
1034 assert_eq!(trade.trade_id, TradeId::new("ETH-284830839"));
1035 assert_eq!(
1037 trade.ts_event,
1038 UnixNanos::from(1766332040636_u64 * 1_000_000)
1039 );
1040 assert_eq!(trade.ts_init, ts_init);
1041 }
1042
1043 #[rstest]
1044 fn test_parse_trade_tick_buy() {
1045 let json_data = load_test_json("http_get_last_trades.json");
1046 let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1047 serde_json::from_str(&json_data).unwrap();
1048 let result = response.result.expect("Test data must have result");
1049
1050 let raw_trade = &result.trades[9];
1052 let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
1053 let ts_init = UnixNanos::default();
1054
1055 let trade = parse_trade_tick(raw_trade, instrument_id, 1, 0, ts_init)
1056 .expect("Should parse trade tick");
1057
1058 assert_eq!(trade.instrument_id, instrument_id);
1059 assert_eq!(trade.price, Price::from("2968.3"));
1060 assert_eq!(trade.size, Quantity::from("106"));
1061 assert_eq!(trade.aggressor_side, AggressorSide::Buyer);
1062 assert_eq!(trade.trade_id, TradeId::new("ETH-284830854"));
1063 }
1064
1065 #[rstest]
1066 fn test_parse_bars() {
1067 let json_data = load_test_json("http_get_tradingview_chart_data.json");
1068 let response: DeribitJsonRpcResponse<DeribitTradingViewChartData> =
1069 serde_json::from_str(&json_data).unwrap();
1070 let chart_data = response.result.expect("Test data must have result");
1071
1072 let bar_type = BarType::from("BTC-PERPETUAL.DERIBIT-1-MINUTE-LAST-EXTERNAL");
1073 let ts_init = UnixNanos::from(1766487086146245_u64 * NANOSECONDS_IN_MICROSECOND);
1074
1075 let bars = parse_bars(&chart_data, bar_type, 1, 8, ts_init).expect("Should parse bars");
1076
1077 assert_eq!(bars.len(), 5, "Should parse 5 bars");
1078
1079 let first_bar = &bars[0];
1081 assert_eq!(first_bar.bar_type, bar_type);
1082 assert_eq!(first_bar.open, Price::from("87451.0"));
1083 assert_eq!(first_bar.high, Price::from("87456.5"));
1084 assert_eq!(first_bar.low, Price::from("87451.0"));
1085 assert_eq!(first_bar.close, Price::from("87456.5"));
1086 assert_eq!(first_bar.volume, Quantity::from("2.94375216"));
1087 assert_eq!(
1088 first_bar.ts_event,
1089 UnixNanos::from(1766483460000_u64 * NANOSECONDS_IN_MILLISECOND)
1090 );
1091 assert_eq!(first_bar.ts_init, ts_init);
1092
1093 let last_bar = &bars[4];
1095 assert_eq!(last_bar.open, Price::from("87456.0"));
1096 assert_eq!(last_bar.high, Price::from("87456.5"));
1097 assert_eq!(last_bar.low, Price::from("87456.0"));
1098 assert_eq!(last_bar.close, Price::from("87456.0"));
1099 assert_eq!(last_bar.volume, Quantity::from("0.1018798"));
1100 assert_eq!(
1101 last_bar.ts_event,
1102 UnixNanos::from(1766483700000_u64 * NANOSECONDS_IN_MILLISECOND)
1103 );
1104 }
1105
1106 #[rstest]
1107 fn test_parse_order_book() {
1108 let json_data = load_test_json("http_get_order_book.json");
1109 let response: DeribitJsonRpcResponse<DeribitOrderBook> =
1110 serde_json::from_str(&json_data).unwrap();
1111 let order_book_data = response.result.expect("Test data must have result");
1112
1113 let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1114 let ts_init = UnixNanos::from(1766554855146274_u64 * NANOSECONDS_IN_MICROSECOND);
1115
1116 let book = parse_order_book(&order_book_data, instrument_id, 1, 0, ts_init)
1117 .expect("Should parse order book");
1118
1119 assert_eq!(book.instrument_id, instrument_id);
1121 assert_eq!(book.book_type, BookType::L2_MBP);
1122 assert_eq!(book.ts_last, ts_init);
1123
1124 assert!(book.has_bid(), "Book should have bids");
1126 assert!(book.has_ask(), "Book should have asks");
1127
1128 assert_eq!(
1130 book.best_bid_price(),
1131 Some(Price::from("87002.5")),
1132 "Best bid price should match"
1133 );
1134 assert_eq!(
1135 book.best_bid_size(),
1136 Some(Quantity::from("199190")),
1137 "Best bid size should match"
1138 );
1139
1140 assert_eq!(
1142 book.best_ask_price(),
1143 Some(Price::from("87003.0")),
1144 "Best ask price should match"
1145 );
1146 assert_eq!(
1147 book.best_ask_size(),
1148 Some(Quantity::from("125090")),
1149 "Best ask size should match"
1150 );
1151
1152 let spread = book.spread().expect("Spread should exist");
1154 assert!(
1155 (spread - 0.5).abs() < 0.0001,
1156 "Spread should be 0.5, was {spread}"
1157 );
1158
1159 let midpoint = book.midpoint().expect("Midpoint should exist");
1161 assert!(
1162 (midpoint - 87002.75).abs() < 0.0001,
1163 "Midpoint should be 87002.75, was {midpoint}"
1164 );
1165
1166 let bid_count = book.bids(None).count();
1168 let ask_count = book.asks(None).count();
1169 assert_eq!(
1170 bid_count,
1171 order_book_data.bids.len(),
1172 "Bid levels count should match input data"
1173 );
1174 assert_eq!(
1175 ask_count,
1176 order_book_data.asks.len(),
1177 "Ask levels count should match input data"
1178 );
1179 assert_eq!(bid_count, 20, "Should have 20 bid levels");
1180 assert_eq!(ask_count, 20, "Should have 20 ask levels");
1181
1182 assert_eq!(
1184 book.bids(Some(5)).count(),
1185 5,
1186 "Should limit to 5 bid levels"
1187 );
1188 assert_eq!(
1189 book.asks(Some(5)).count(),
1190 5,
1191 "Should limit to 5 ask levels"
1192 );
1193
1194 let bids_map = book.bids_as_map(None);
1196 let asks_map = book.asks_as_map(None);
1197 assert_eq!(bids_map.len(), 20, "Bids map should have 20 entries");
1198 assert_eq!(asks_map.len(), 20, "Asks map should have 20 entries");
1199
1200 assert!(
1202 bids_map.contains_key(&dec!(87002.5)),
1203 "Bids map should contain best bid price"
1204 );
1205 assert!(
1206 asks_map.contains_key(&dec!(87003.0)),
1207 "Asks map should contain best ask price"
1208 );
1209
1210 assert!(
1212 bids_map.contains_key(&dec!(86980.0)),
1213 "Bids map should contain worst bid price"
1214 );
1215 assert!(
1216 asks_map.contains_key(&dec!(87031.5)),
1217 "Asks map should contain worst ask price"
1218 );
1219 }
1220
1221 fn make_instrument_id(symbol: &str) -> InstrumentId {
1222 InstrumentId::new(Symbol::from(symbol), Venue::from("DERIBIT"))
1223 }
1224
1225 #[rstest]
1226 fn test_parse_futures_and_perpetuals() {
1227 let cases = [
1229 ("BTC-PERPETUAL", "future", "BTC"),
1230 ("ETH-PERPETUAL", "future", "ETH"),
1231 ("SOL-PERPETUAL", "future", "SOL"),
1232 ("BTC-25MAR23", "future", "BTC"),
1234 ("BTC-5AUG23", "future", "BTC"), ("ETH-28MAR25", "future", "ETH"),
1236 ];
1237
1238 for (symbol, expected_kind, expected_currency) in cases {
1239 let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1240 assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1241 assert_eq!(
1242 currency, expected_currency,
1243 "currency mismatch for {symbol}"
1244 );
1245 }
1246 }
1247
1248 #[rstest]
1249 fn test_parse_options() {
1250 let cases = [
1251 ("BTC-25MAR23-420-C", "option", "BTC"),
1253 ("BTC-5AUG23-580-P", "option", "BTC"),
1254 ("ETH-28MAR25-4000-C", "option", "ETH"),
1255 ("XRP_USDC-30JUN23-0d625-C", "option", "XRP"),
1257 ];
1258
1259 for (symbol, expected_kind, expected_currency) in cases {
1260 let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1261 assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1262 assert_eq!(
1263 currency, expected_currency,
1264 "currency mismatch for {symbol}"
1265 );
1266 }
1267 }
1268
1269 #[rstest]
1270 fn test_parse_spot() {
1271 let cases = [
1272 ("BTC_USDC", "spot", "BTC"),
1273 ("ETH_USDT", "spot", "ETH"),
1274 ("SOL_USDC", "spot", "SOL"),
1275 ];
1276
1277 for (symbol, expected_kind, expected_currency) in cases {
1278 let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1279 assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1280 assert_eq!(
1281 currency, expected_currency,
1282 "currency mismatch for {symbol}"
1283 );
1284 }
1285 }
1286
1287 #[rstest]
1288 fn test_parse_portfolio_to_account_state() {
1289 let json_data = load_test_json("ws_portfolio.json");
1290 let notification: serde_json::Value = serde_json::from_str(&json_data).unwrap();
1291
1292 let data = notification
1294 .get("params")
1295 .and_then(|p| p.get("data"))
1296 .expect("Test data must have params.data");
1297
1298 let portfolio: DeribitPortfolioMsg =
1299 serde_json::from_value(data.clone()).expect("Should deserialize portfolio message");
1300
1301 assert_eq!(portfolio.currency, "USDT");
1303 assert_eq!(portfolio.equity, dec!(55.00055));
1304 assert_eq!(portfolio.balance, dec!(55.00055));
1305 assert_eq!(portfolio.available_funds, dec!(53.868247));
1306 assert_eq!(portfolio.margin_balance, dec!(54.968258));
1307 assert_eq!(portfolio.initial_margin, dec!(1.100011));
1308 assert_eq!(portfolio.maintenance_margin, dec!(0.0));
1309
1310 let account_id = AccountId::new("DERIBIT-master");
1312 let ts_init = UnixNanos::from(1700000000000000000_u64);
1313
1314 let account_state =
1315 parse_portfolio_to_account_state(&portfolio, account_id, ts_init).unwrap();
1316
1317 assert_eq!(account_state.account_id, account_id);
1319 assert_eq!(account_state.account_type, AccountType::Margin);
1320 assert!(account_state.is_reported);
1321
1322 assert_eq!(account_state.balances.len(), 1);
1324 let balance = &account_state.balances[0];
1325 assert_eq!(balance.currency.code, "USDT");
1326 assert_eq!(balance.total.as_f64(), 54.968258); assert_eq!(balance.free.as_f64(), 53.868247); let locked = balance.locked.as_f64();
1331 assert!(
1332 (locked - 1.100011).abs() < 0.0001,
1333 "Locked ({locked}) should be close to 1.100011 (initial_margin)"
1334 );
1335
1336 assert_eq!(account_state.margins.len(), 1);
1338 let margin = &account_state.margins[0];
1339 assert_eq!(margin.initial.as_f64(), 1.100011);
1340 assert_eq!(margin.maintenance.as_f64(), 0.0);
1341 assert_eq!(
1342 margin.instrument_id,
1343 InstrumentId::from("ACCOUNT-USDT.DERIBIT")
1344 );
1345 }
1346
1347 #[rstest]
1348 #[case::minute_1(1, "MINUTE", "1")]
1349 #[case::minute_2(2, "MINUTE", "3")]
1350 #[case::minute_3(3, "MINUTE", "3")]
1351 #[case::minute_4(4, "MINUTE", "5")]
1352 #[case::minute_5(5, "MINUTE", "5")]
1353 #[case::minute_6(6, "MINUTE", "10")]
1354 #[case::minute_10(10, "MINUTE", "10")]
1355 #[case::minute_11(11, "MINUTE", "15")]
1356 #[case::minute_15(15, "MINUTE", "15")]
1357 #[case::minute_16(16, "MINUTE", "30")]
1358 #[case::minute_30(30, "MINUTE", "30")]
1359 #[case::minute_31(31, "MINUTE", "60")]
1360 #[case::minute_60(60, "MINUTE", "60")]
1361 #[case::minute_61(61, "MINUTE", "120")]
1362 #[case::minute_120(120, "MINUTE", "120")]
1363 #[case::minute_121(121, "MINUTE", "180")]
1364 #[case::minute_180(180, "MINUTE", "180")]
1365 #[case::minute_181(181, "MINUTE", "360")]
1366 #[case::minute_360(360, "MINUTE", "360")]
1367 #[case::minute_361(361, "MINUTE", "720")]
1368 #[case::minute_720(720, "MINUTE", "720")]
1369 #[case::minute_721(721, "MINUTE", "1D")]
1370 #[case::hour_1(1, "HOUR", "60")]
1371 #[case::hour_2(2, "HOUR", "120")]
1372 #[case::hour_3(3, "HOUR", "180")]
1373 #[case::hour_4(4, "HOUR", "360")]
1374 #[case::hour_6(6, "HOUR", "360")]
1375 #[case::hour_7(7, "HOUR", "720")]
1376 #[case::hour_12(12, "HOUR", "720")]
1377 #[case::hour_13(13, "HOUR", "1D")]
1378 #[case::day_1(1, "DAY", "1D")]
1379 fn test_bar_spec_to_resolution(
1380 #[case] step: u64,
1381 #[case] aggregation: &str,
1382 #[case] expected: &str,
1383 ) {
1384 let bar_type_str = format!("BTC-PERPETUAL.DERIBIT-{step}-{aggregation}-LAST-EXTERNAL");
1385 let bar_type = BarType::from(bar_type_str.as_str());
1386 let resolution = bar_spec_to_resolution(&bar_type);
1387 assert_eq!(resolution, expected);
1388 }
1389}