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},
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 None,
195 ts_event,
196 ts_init,
197 );
198
199 Ok(InstrumentAny::CurrencyPair(currency_pair))
200}
201
202fn parse_perpetual_instrument(
204 instrument: &DeribitInstrument,
205 ts_init: UnixNanos,
206 ts_event: UnixNanos,
207) -> anyhow::Result<InstrumentAny> {
208 let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
209
210 let base_currency = Currency::get_or_create_crypto(instrument.base_currency);
211 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
212 let settlement_currency = instrument
213 .settlement_currency
214 .map_or(base_currency, Currency::get_or_create_crypto);
215
216 let is_inverse = instrument
217 .instrument_type
218 .as_ref()
219 .is_some_and(|t| t == "reversed");
220
221 let price_increment = Price::from_decimal(instrument.tick_size)?;
222 let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
223 let min_quantity = Quantity::from_decimal(instrument.min_trade_amount)?;
224
225 let multiplier = Some(Quantity::from_decimal(instrument.contract_size)?);
227 let lot_size = Some(size_increment);
228
229 let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
230 .context("Failed to parse maker_commission")?;
231 let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
232 .context("Failed to parse taker_commission")?;
233
234 let perpetual = CryptoPerpetual::new(
235 instrument_id,
236 instrument.instrument_name.into(),
237 base_currency,
238 quote_currency,
239 settlement_currency,
240 is_inverse,
241 price_increment.precision,
242 size_increment.precision,
243 price_increment,
244 size_increment,
245 multiplier,
246 lot_size,
247 None, Some(min_quantity),
249 None, None, None, None, None, None, Some(maker_fee),
256 Some(taker_fee),
257 None,
258 ts_event,
259 ts_init,
260 );
261
262 Ok(InstrumentAny::CryptoPerpetual(perpetual))
263}
264
265fn parse_future_instrument(
267 instrument: &DeribitInstrument,
268 ts_init: UnixNanos,
269 ts_event: UnixNanos,
270) -> anyhow::Result<InstrumentAny> {
271 let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
272
273 let underlying = Currency::get_or_create_crypto(instrument.base_currency);
274 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
275 let settlement_currency = instrument
276 .settlement_currency
277 .map_or(underlying, Currency::get_or_create_crypto);
278
279 let is_inverse = instrument
280 .instrument_type
281 .as_ref()
282 .is_some_and(|t| t == "reversed");
283
284 let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
286 let expiration_ns = instrument
287 .expiration_timestamp
288 .context("Missing expiration_timestamp for future")? as u64
289 * 1_000_000; let price_increment = Price::from_decimal(instrument.tick_size)?;
292 let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
293 let min_quantity = Quantity::from_decimal(instrument.min_trade_amount)?;
294
295 let multiplier = Some(Quantity::from_decimal(instrument.contract_size)?);
297 let lot_size = Some(size_increment); let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
300 .context("Failed to parse maker_commission")?;
301 let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
302 .context("Failed to parse taker_commission")?;
303
304 let future = CryptoFuture::new(
305 instrument_id,
306 instrument.instrument_name.into(),
307 underlying,
308 quote_currency,
309 settlement_currency,
310 is_inverse,
311 UnixNanos::from(activation_ns),
312 UnixNanos::from(expiration_ns),
313 price_increment.precision,
314 size_increment.precision,
315 price_increment,
316 size_increment,
317 multiplier,
318 lot_size,
319 None, Some(min_quantity),
321 None, None, None, None, None, None, Some(maker_fee),
328 Some(taker_fee),
329 None,
330 ts_event,
331 ts_init,
332 );
333
334 Ok(InstrumentAny::CryptoFuture(future))
335}
336
337fn parse_option_instrument(
339 instrument: &DeribitInstrument,
340 ts_init: UnixNanos,
341 ts_event: UnixNanos,
342) -> anyhow::Result<InstrumentAny> {
343 let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
344 let underlying = Currency::get_or_create_crypto(instrument.base_currency);
345 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
346 let settlement = instrument
347 .settlement_currency
348 .unwrap_or(instrument.base_currency);
349 let settlement_currency = Currency::get_or_create_crypto(settlement);
350
351 let is_inverse = instrument
353 .instrument_type
354 .as_ref()
355 .is_some_and(|t| t == "reversed");
356
357 let option_kind = match instrument.option_type {
359 Some(DeribitOptionType::Call) => OptionKind::Call,
360 Some(DeribitOptionType::Put) => OptionKind::Put,
361 None => anyhow::bail!("Missing option_type for option instrument"),
362 };
363
364 let strike = instrument.strike.context("Missing strike for option")?;
366 let strike_price = Price::from_decimal(strike)?;
367
368 let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
370 let expiration_ns = instrument
371 .expiration_timestamp
372 .context("Missing expiration_timestamp for option")? as u64
373 * 1_000_000;
374
375 let price_increment = Price::from_decimal(instrument.tick_size)?;
376
377 let multiplier = Quantity::from_decimal(instrument.contract_size)?;
379 let lot_size = Quantity::from_decimal(instrument.min_trade_amount)?;
380 let min_trade_amount = Quantity::from_decimal(instrument.min_trade_amount)?;
381
382 let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
383 .context("Failed to parse maker_commission")?;
384 let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
385 .context("Failed to parse taker_commission")?;
386
387 let option = CryptoOption::new(
388 instrument_id,
389 instrument.instrument_name.into(),
390 underlying,
391 quote_currency,
392 settlement_currency,
393 is_inverse,
394 option_kind,
395 strike_price,
396 UnixNanos::from(activation_ns),
397 UnixNanos::from(expiration_ns),
398 price_increment.precision,
399 lot_size.precision,
400 price_increment,
401 lot_size,
402 Some(multiplier),
403 Some(lot_size),
404 None,
405 Some(min_trade_amount),
406 None,
407 None,
408 None,
409 None,
410 None,
411 None,
412 Some(maker_fee),
413 Some(taker_fee),
414 None,
415 ts_event,
416 ts_init,
417 );
418
419 Ok(InstrumentAny::CryptoOption(option))
420}
421
422pub fn parse_account_state(
432 summaries: &[DeribitAccountSummary],
433 account_id: AccountId,
434 ts_init: UnixNanos,
435 ts_event: UnixNanos,
436) -> anyhow::Result<AccountState> {
437 let mut balances = Vec::new();
438 let mut margins = Vec::new();
439
440 for summary in summaries {
442 let ccy_str = summary.currency.as_str().trim();
443
444 if ccy_str.is_empty() {
446 log::debug!("Skipping balance detail with empty currency code | raw_data={summary:?}");
447 continue;
448 }
449
450 let currency = Currency::get_or_create_crypto_with_context(
451 ccy_str,
452 Some("DERIBIT - Parsing account state"),
453 );
454
455 let balance = AccountBalance::from_total_and_free(
461 summary.margin_balance,
462 summary.available_funds,
463 currency,
464 )?;
465 balances.push(balance);
466
467 if let (Some(initial_margin), Some(maintenance_margin)) =
469 (summary.initial_margin, summary.maintenance_margin)
470 && (!initial_margin.is_zero() || !maintenance_margin.is_zero())
471 {
472 let initial = Money::from_decimal(initial_margin, currency)?;
473 let maintenance = Money::from_decimal(maintenance_margin, currency)?;
474 margins.push(MarginBalance::new(initial, maintenance, None));
477 }
478 }
479
480 if balances.is_empty() {
482 let zero_currency = Currency::USD();
483 let zero_money = Money::new(0.0, zero_currency);
484 let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
485 balances.push(zero_balance);
486 }
487
488 let account_type = AccountType::Margin;
489 let is_reported = true;
490
491 Ok(AccountState::new(
492 account_id,
493 account_type,
494 balances,
495 margins,
496 is_reported,
497 UUID4::new(),
498 ts_event,
499 ts_init,
500 None,
501 ))
502}
503
504pub fn parse_portfolio_to_account_state(
517 portfolio: &DeribitPortfolioMsg,
518 account_id: AccountId,
519 ts_init: UnixNanos,
520) -> anyhow::Result<AccountState> {
521 let ccy_str = portfolio.currency.trim();
522
523 if ccy_str.is_empty() {
525 anyhow::bail!("Portfolio message has empty currency code");
526 }
527
528 let currency = Currency::get_or_create_crypto_with_context(
529 ccy_str,
530 Some("DERIBIT - Parsing portfolio update"),
531 );
532
533 let balance = AccountBalance::from_total_and_free(
539 portfolio.margin_balance,
540 portfolio.available_funds,
541 currency,
542 )?;
543 let balances = vec![balance];
544
545 let mut margins = Vec::new();
547 let initial_margin = portfolio.initial_margin;
548 let maintenance_margin = portfolio.maintenance_margin;
549
550 if !initial_margin.is_zero() || !maintenance_margin.is_zero() {
552 let initial = Money::from_decimal(initial_margin, currency)?;
553 let maintenance = Money::from_decimal(maintenance_margin, currency)?;
554 margins.push(MarginBalance::new(initial, maintenance, None));
557 }
558
559 let account_type = AccountType::Margin;
560 let is_reported = true;
561
562 Ok(AccountState::new(
563 account_id,
564 account_type,
565 balances,
566 margins,
567 is_reported,
568 UUID4::new(),
569 ts_init, ts_init,
571 None,
572 ))
573}
574
575pub fn parse_trade_tick(
583 trade: &DeribitPublicTrade,
584 instrument_id: InstrumentId,
585 price_precision: u8,
586 size_precision: u8,
587 ts_init: UnixNanos,
588) -> anyhow::Result<TradeTick> {
589 let aggressor_side = match trade.direction.as_str() {
591 "buy" => AggressorSide::Buyer,
592 "sell" => AggressorSide::Seller,
593 other => anyhow::bail!("Invalid trade direction: {other}"),
594 };
595 let price = Price::from_decimal_dp(trade.price, price_precision)?;
596 let size = Quantity::from_decimal_dp(trade.amount, size_precision)?;
597 let ts_event = UnixNanos::from((trade.timestamp as u64) * NANOSECONDS_IN_MILLISECOND);
598 let trade_id = TradeId::new(&trade.trade_id);
599
600 Ok(TradeTick::new(
601 instrument_id,
602 price,
603 size,
604 aggressor_side,
605 trade_id,
606 ts_event,
607 ts_init,
608 ))
609}
610
611pub fn parse_bars(
623 chart_data: &DeribitTradingViewChartData,
624 bar_type: BarType,
625 price_precision: u8,
626 size_precision: u8,
627 ts_init: UnixNanos,
628) -> anyhow::Result<Vec<Bar>> {
629 if chart_data.status != "ok" {
631 anyhow::bail!(
632 "Chart data status is '{}', expected 'ok'",
633 chart_data.status
634 );
635 }
636
637 let num_bars = chart_data.ticks.len();
638
639 anyhow::ensure!(
641 chart_data.open.len() == num_bars
642 && chart_data.high.len() == num_bars
643 && chart_data.low.len() == num_bars
644 && chart_data.close.len() == num_bars
645 && chart_data.volume.len() == num_bars,
646 "Inconsistent array lengths in chart data"
647 );
648
649 if num_bars == 0 {
650 return Ok(Vec::new());
651 }
652
653 let mut bars = Vec::with_capacity(num_bars);
654
655 for i in 0..num_bars {
656 let open = Price::new_checked(chart_data.open[i], price_precision)
657 .with_context(|| format!("Invalid open price at index {i}"))?;
658 let high = Price::new_checked(chart_data.high[i], price_precision)
659 .with_context(|| format!("Invalid high price at index {i}"))?;
660 let low = Price::new_checked(chart_data.low[i], price_precision)
661 .with_context(|| format!("Invalid low price at index {i}"))?;
662 let close = Price::new_checked(chart_data.close[i], price_precision)
663 .with_context(|| format!("Invalid close price at index {i}"))?;
664 let volume = Quantity::new_checked(chart_data.volume[i], size_precision)
665 .with_context(|| format!("Invalid volume at index {i}"))?;
666
667 let ts_event = UnixNanos::from((chart_data.ticks[i] as u64) * NANOSECONDS_IN_MILLISECOND);
669
670 let bar = Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
671 .with_context(|| format!("Invalid OHLC bar at index {i}"))?;
672 bars.push(bar);
673 }
674
675 Ok(bars)
676}
677
678pub fn parse_order_book(
687 order_book_data: &DeribitOrderBook,
688 instrument_id: InstrumentId,
689 price_precision: u8,
690 size_precision: u8,
691 ts_init: UnixNanos,
692) -> anyhow::Result<OrderBook> {
693 let ts_event = UnixNanos::from((order_book_data.timestamp as u64) * NANOSECONDS_IN_MILLISECOND);
694 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
695
696 for (idx, [price, amount]) in order_book_data.bids.iter().enumerate() {
697 let order = BookOrder::new(
698 OrderSide::Buy,
699 Price::new(*price, price_precision),
700 Quantity::new(*amount, size_precision),
701 idx as u64,
702 );
703 book.add(order, 0, idx as u64, ts_event);
704 }
705
706 let bids_len = order_book_data.bids.len();
707 for (idx, [price, amount]) in order_book_data.asks.iter().enumerate() {
708 let order = BookOrder::new(
709 OrderSide::Sell,
710 Price::new(*price, price_precision),
711 Quantity::new(*amount, size_precision),
712 (bids_len + idx) as u64,
713 );
714 book.add(order, 0, (bids_len + idx) as u64, ts_event);
715 }
716
717 book.ts_last = ts_init;
718
719 Ok(book)
720}
721
722pub fn bar_spec_to_resolution(bar_type: &BarType) -> String {
726 use nautilus_model::enums::BarAggregation;
727
728 let spec = bar_type.spec();
729 match spec.aggregation {
730 BarAggregation::Minute => {
731 let step = spec.step.get();
732 match step {
734 1 => "1".to_string(),
735 2..=3 => "3".to_string(),
736 4..=5 => "5".to_string(),
737 6..=10 => "10".to_string(),
738 11..=15 => "15".to_string(),
739 16..=30 => "30".to_string(),
740 31..=60 => "60".to_string(),
741 61..=120 => "120".to_string(),
742 121..=180 => "180".to_string(),
743 181..=360 => "360".to_string(),
744 361..=720 => "720".to_string(),
745 _ => "1D".to_string(),
746 }
747 }
748 BarAggregation::Hour => {
749 let step = spec.step.get();
750 match step {
751 1 => "60".to_string(),
752 2 => "120".to_string(),
753 3 => "180".to_string(),
754 4..=6 => "360".to_string(),
755 7..=12 => "720".to_string(),
756 _ => "1D".to_string(),
757 }
758 }
759 BarAggregation::Day => "1D".to_string(),
760 _ => {
761 log::warn!(
762 "Unsupported bar aggregation {:?}, defaulting to 1 minute",
763 spec.aggregation
764 );
765 "1".to_string()
766 }
767 }
768}
769
770#[cfg(test)]
771mod tests {
772 use nautilus_model::{identifiers::Venue, instruments::Instrument};
773 use rstest::rstest;
774 use rust_decimal_macros::dec;
775
776 use super::*;
777 use crate::{
778 common::testing::load_test_json,
779 http::models::{
780 DeribitAccountSummariesResponse, DeribitJsonRpcResponse, DeribitTradesResponse,
781 },
782 };
783
784 #[rstest]
785 fn test_parse_perpetual_instrument() {
786 let json_data = load_test_json("http_get_instrument.json");
787 let response: DeribitJsonRpcResponse<DeribitInstrument> =
788 serde_json::from_str(&json_data).unwrap();
789 let deribit_inst = response.result.expect("Test data must have result");
790
791 let instrument_any =
792 parse_deribit_instrument_any(&deribit_inst, UnixNanos::default(), UnixNanos::default())
793 .unwrap();
794 let instrument = instrument_any.expect("Should parse perpetual instrument");
795
796 let InstrumentAny::CryptoPerpetual(perpetual) = instrument else {
797 panic!("Expected CryptoPerpetual, was {instrument:?}");
798 };
799 assert_eq!(perpetual.id(), InstrumentId::from("BTC-PERPETUAL.DERIBIT"));
800 assert_eq!(perpetual.raw_symbol(), Symbol::from("BTC-PERPETUAL"));
801 assert_eq!(perpetual.base_currency().unwrap().code, "BTC");
802 assert_eq!(perpetual.quote_currency().code, "USD");
803 assert_eq!(perpetual.settlement_currency().code, "BTC");
804 assert!(perpetual.is_inverse());
805 assert_eq!(perpetual.price_precision(), 1);
806 assert_eq!(perpetual.size_precision(), 0);
807 assert_eq!(perpetual.price_increment(), Price::from("0.5"));
808 assert_eq!(perpetual.size_increment(), Quantity::from("10"));
809 assert_eq!(perpetual.multiplier(), Quantity::from("10"));
810 assert_eq!(perpetual.lot_size(), Some(Quantity::from("10")));
811 assert_eq!(perpetual.maker_fee(), dec!(0));
812 assert_eq!(perpetual.taker_fee(), dec!(0.0005));
813 assert_eq!(perpetual.max_quantity(), None);
814 assert_eq!(perpetual.min_quantity(), Some(Quantity::from("10")));
815 }
816
817 #[rstest]
818 fn test_parse_future_instrument() {
819 let json_data = load_test_json("http_get_instruments.json");
820 let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
821 serde_json::from_str(&json_data).unwrap();
822 let instruments = response.result.expect("Test data must have result");
823 let deribit_inst = instruments
824 .iter()
825 .find(|i| i.instrument_name.as_str() == "BTC-27DEC24")
826 .expect("Test data must contain BTC-27DEC24");
827
828 let instrument_any =
829 parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
830 .unwrap();
831 let instrument = instrument_any.expect("Should parse future instrument");
832
833 let InstrumentAny::CryptoFuture(future) = instrument else {
834 panic!("Expected CryptoFuture, was {instrument:?}");
835 };
836 assert_eq!(future.id(), InstrumentId::from("BTC-27DEC24.DERIBIT"));
837 assert_eq!(future.raw_symbol(), Symbol::from("BTC-27DEC24"));
838 assert_eq!(future.underlying().unwrap(), "BTC");
839 assert_eq!(future.quote_currency().code, "USD");
840 assert_eq!(future.settlement_currency().code, "BTC");
841 assert!(future.is_inverse());
842
843 assert_eq!(
845 future.activation_ns(),
846 Some(UnixNanos::from(1719561600000_u64 * 1_000_000))
847 );
848 assert_eq!(
849 future.expiration_ns(),
850 Some(UnixNanos::from(1735300800000_u64 * 1_000_000))
851 );
852 assert_eq!(future.price_precision(), 1);
853 assert_eq!(future.size_precision(), 0);
854 assert_eq!(future.price_increment(), Price::from("0.5"));
855 assert_eq!(future.size_increment(), Quantity::from("10"));
856 assert_eq!(future.multiplier(), Quantity::from("10"));
857 assert_eq!(future.lot_size(), Some(Quantity::from("10")));
858 assert_eq!(future.maker_fee, dec!(0));
859 assert_eq!(future.taker_fee, dec!(0.0005));
860 }
861
862 #[rstest]
863 fn test_parse_option_instrument() {
864 let json_data = load_test_json("http_get_instruments.json");
865 let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
866 serde_json::from_str(&json_data).unwrap();
867 let instruments = response.result.expect("Test data must have result");
868 let deribit_inst = instruments
869 .iter()
870 .find(|i| i.instrument_name.as_str() == "BTC-27DEC24-100000-C")
871 .expect("Test data must contain BTC-27DEC24-100000-C");
872
873 let instrument_any =
874 parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
875 .unwrap();
876 let instrument = instrument_any.expect("Should parse option instrument");
877
878 let InstrumentAny::CryptoOption(option) = instrument else {
880 panic!("Expected CryptoOption, was {instrument:?}");
881 };
882
883 assert_eq!(
884 option.id(),
885 InstrumentId::from("BTC-27DEC24-100000-C.DERIBIT")
886 );
887 assert_eq!(option.raw_symbol(), Symbol::from("BTC-27DEC24-100000-C"));
888 assert_eq!(option.underlying.code.as_str(), "BTC");
889 assert_eq!(option.quote_currency.code.as_str(), "BTC");
890 assert_eq!(option.settlement_currency.code.as_str(), "BTC");
891 assert!(option.is_inverse);
892 assert_eq!(option.option_kind, OptionKind::Call);
893 assert_eq!(option.strike_price, Price::from("100000"));
894 assert_eq!(
895 option.activation_ns,
896 UnixNanos::from(1719561600000_u64 * 1_000_000)
897 );
898 assert_eq!(
899 option.expiration_ns,
900 UnixNanos::from(1735300800000_u64 * 1_000_000)
901 );
902 assert_eq!(option.price_precision, 4);
903 assert_eq!(option.price_increment, Price::from("0.0005"));
904 assert_eq!(option.size_precision, 1);
905 assert_eq!(option.size_increment, Quantity::from("0.1"));
906 assert_eq!(option.multiplier, Quantity::from("1"));
907 assert_eq!(option.lot_size, Quantity::from("0.1"));
908 assert_eq!(option.maker_fee, dec!(0.0003));
909 assert_eq!(option.taker_fee, dec!(0.0003));
910 }
911
912 #[rstest]
913 fn test_parse_account_state_with_positions() {
914 let json_data = load_test_json("http_get_account_summaries.json");
915 let response: DeribitJsonRpcResponse<DeribitAccountSummariesResponse> =
916 serde_json::from_str(&json_data).unwrap();
917 let result = response.result.expect("Test data must have result");
918
919 let account_id = AccountId::from("DERIBIT-001");
920
921 let ts_event =
923 extract_server_timestamp(response.us_out).expect("Test data must have us_out");
924 let ts_init = UnixNanos::default();
925
926 let account_state = parse_account_state(&result.summaries, account_id, ts_init, ts_event)
927 .expect("Should parse account state");
928
929 assert_eq!(account_state.balances.len(), 2);
931
932 let btc_balance = account_state
934 .balances
935 .iter()
936 .find(|b| b.currency.code == "BTC")
937 .expect("BTC balance should exist");
938
939 assert_eq!(btc_balance.total.as_f64(), 302.62729214);
948 assert_eq!(btc_balance.free.as_f64(), 301.38059622);
949
950 let locked = btc_balance.locked.as_f64();
952 assert!(
953 locked > 0.0,
954 "Locked should be positive when positions exist"
955 );
956 assert!(
957 (locked - 1.24669592).abs() < 0.0001,
958 "Locked ({locked}) should equal initial_margin (1.24669592)"
959 );
960
961 let eth_balance = account_state
963 .balances
964 .iter()
965 .find(|b| b.currency.code == "ETH")
966 .expect("ETH balance should exist");
967
968 assert_eq!(eth_balance.total.as_f64(), 100.0);
973 assert_eq!(eth_balance.free.as_f64(), 99.999598);
974 assert_eq!(eth_balance.locked.as_f64(), 0.000402);
975
976 assert_eq!(account_state.account_id, account_id);
978 assert_eq!(account_state.account_type, AccountType::Margin);
979 assert!(account_state.is_reported);
980
981 let expected_ts_event = UnixNanos::from(1687352432005000_u64 * NANOSECONDS_IN_MICROSECOND);
983 assert_eq!(
984 account_state.ts_event, expected_ts_event,
985 "ts_event should match server timestamp from response"
986 );
987 }
988
989 #[rstest]
990 fn test_parse_trade_tick_sell() {
991 let json_data = load_test_json("http_get_last_trades.json");
992 let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
993 serde_json::from_str(&json_data).unwrap();
994 let result = response.result.expect("Test data must have result");
995
996 assert!(result.has_more, "has_more should be true");
997 assert_eq!(result.trades.len(), 10, "Should have 10 trades");
998
999 let raw_trade = &result.trades[0];
1000 let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
1001 let ts_init = UnixNanos::from(1766335632425576_u64 * 1000); let trade = parse_trade_tick(raw_trade, instrument_id, 1, 0, ts_init)
1004 .expect("Should parse trade tick");
1005
1006 assert_eq!(trade.instrument_id, instrument_id);
1007 assert_eq!(trade.price, Price::from("2968.3"));
1008 assert_eq!(trade.size, Quantity::from("1"));
1009 assert_eq!(trade.aggressor_side, AggressorSide::Seller);
1010 assert_eq!(trade.trade_id, TradeId::new("ETH-284830839"));
1011 assert_eq!(
1013 trade.ts_event,
1014 UnixNanos::from(1766332040636_u64 * 1_000_000)
1015 );
1016 assert_eq!(trade.ts_init, ts_init);
1017 }
1018
1019 #[rstest]
1020 fn test_parse_trade_tick_buy() {
1021 let json_data = load_test_json("http_get_last_trades.json");
1022 let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1023 serde_json::from_str(&json_data).unwrap();
1024 let result = response.result.expect("Test data must have result");
1025
1026 let raw_trade = &result.trades[9];
1028 let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
1029 let ts_init = UnixNanos::default();
1030
1031 let trade = parse_trade_tick(raw_trade, instrument_id, 1, 0, ts_init)
1032 .expect("Should parse trade tick");
1033
1034 assert_eq!(trade.instrument_id, instrument_id);
1035 assert_eq!(trade.price, Price::from("2968.3"));
1036 assert_eq!(trade.size, Quantity::from("106"));
1037 assert_eq!(trade.aggressor_side, AggressorSide::Buyer);
1038 assert_eq!(trade.trade_id, TradeId::new("ETH-284830854"));
1039 }
1040
1041 #[rstest]
1042 fn test_parse_bars() {
1043 let json_data = load_test_json("http_get_tradingview_chart_data.json");
1044 let response: DeribitJsonRpcResponse<DeribitTradingViewChartData> =
1045 serde_json::from_str(&json_data).unwrap();
1046 let chart_data = response.result.expect("Test data must have result");
1047
1048 let bar_type = BarType::from("BTC-PERPETUAL.DERIBIT-1-MINUTE-LAST-EXTERNAL");
1049 let ts_init = UnixNanos::from(1766487086146245_u64 * NANOSECONDS_IN_MICROSECOND);
1050
1051 let bars = parse_bars(&chart_data, bar_type, 1, 8, ts_init).expect("Should parse bars");
1052
1053 assert_eq!(bars.len(), 5, "Should parse 5 bars");
1054
1055 let first_bar = &bars[0];
1057 assert_eq!(first_bar.bar_type, bar_type);
1058 assert_eq!(first_bar.open, Price::from("87451.0"));
1059 assert_eq!(first_bar.high, Price::from("87456.5"));
1060 assert_eq!(first_bar.low, Price::from("87451.0"));
1061 assert_eq!(first_bar.close, Price::from("87456.5"));
1062 assert_eq!(first_bar.volume, Quantity::from("2.94375216"));
1063 assert_eq!(
1064 first_bar.ts_event,
1065 UnixNanos::from(1766483460000_u64 * NANOSECONDS_IN_MILLISECOND)
1066 );
1067 assert_eq!(first_bar.ts_init, ts_init);
1068
1069 let last_bar = &bars[4];
1071 assert_eq!(last_bar.open, Price::from("87456.0"));
1072 assert_eq!(last_bar.high, Price::from("87456.5"));
1073 assert_eq!(last_bar.low, Price::from("87456.0"));
1074 assert_eq!(last_bar.close, Price::from("87456.0"));
1075 assert_eq!(last_bar.volume, Quantity::from("0.1018798"));
1076 assert_eq!(
1077 last_bar.ts_event,
1078 UnixNanos::from(1766483700000_u64 * NANOSECONDS_IN_MILLISECOND)
1079 );
1080 }
1081
1082 #[rstest]
1083 fn test_parse_order_book() {
1084 let json_data = load_test_json("http_get_order_book.json");
1085 let response: DeribitJsonRpcResponse<DeribitOrderBook> =
1086 serde_json::from_str(&json_data).unwrap();
1087 let order_book_data = response.result.expect("Test data must have result");
1088
1089 let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1090 let ts_init = UnixNanos::from(1766554855146274_u64 * NANOSECONDS_IN_MICROSECOND);
1091
1092 let book = parse_order_book(&order_book_data, instrument_id, 1, 0, ts_init)
1093 .expect("Should parse order book");
1094
1095 assert_eq!(book.instrument_id, instrument_id);
1097 assert_eq!(book.book_type, BookType::L2_MBP);
1098 assert_eq!(book.ts_last, ts_init);
1099
1100 assert!(book.has_bid(), "Book should have bids");
1102 assert!(book.has_ask(), "Book should have asks");
1103
1104 assert_eq!(
1106 book.best_bid_price(),
1107 Some(Price::from("87002.5")),
1108 "Best bid price should match"
1109 );
1110 assert_eq!(
1111 book.best_bid_size(),
1112 Some(Quantity::from("199190")),
1113 "Best bid size should match"
1114 );
1115
1116 assert_eq!(
1118 book.best_ask_price(),
1119 Some(Price::from("87003.0")),
1120 "Best ask price should match"
1121 );
1122 assert_eq!(
1123 book.best_ask_size(),
1124 Some(Quantity::from("125090")),
1125 "Best ask size should match"
1126 );
1127
1128 let spread = book.spread().expect("Spread should exist");
1130 assert!(
1131 (spread - 0.5).abs() < 0.0001,
1132 "Spread should be 0.5, was {spread}"
1133 );
1134
1135 let midpoint = book.midpoint().expect("Midpoint should exist");
1137 assert!(
1138 (midpoint - 87002.75).abs() < 0.0001,
1139 "Midpoint should be 87002.75, was {midpoint}"
1140 );
1141
1142 let bid_count = book.bids(None).count();
1144 let ask_count = book.asks(None).count();
1145 assert_eq!(
1146 bid_count,
1147 order_book_data.bids.len(),
1148 "Bid levels count should match input data"
1149 );
1150 assert_eq!(
1151 ask_count,
1152 order_book_data.asks.len(),
1153 "Ask levels count should match input data"
1154 );
1155 assert_eq!(bid_count, 20, "Should have 20 bid levels");
1156 assert_eq!(ask_count, 20, "Should have 20 ask levels");
1157
1158 assert_eq!(
1160 book.bids(Some(5)).count(),
1161 5,
1162 "Should limit to 5 bid levels"
1163 );
1164 assert_eq!(
1165 book.asks(Some(5)).count(),
1166 5,
1167 "Should limit to 5 ask levels"
1168 );
1169
1170 let bids_map = book.bids_as_map(None);
1172 let asks_map = book.asks_as_map(None);
1173 assert_eq!(bids_map.len(), 20, "Bids map should have 20 entries");
1174 assert_eq!(asks_map.len(), 20, "Asks map should have 20 entries");
1175
1176 assert!(
1178 bids_map.contains_key(&dec!(87002.5)),
1179 "Bids map should contain best bid price"
1180 );
1181 assert!(
1182 asks_map.contains_key(&dec!(87003.0)),
1183 "Asks map should contain best ask price"
1184 );
1185
1186 assert!(
1188 bids_map.contains_key(&dec!(86980.0)),
1189 "Bids map should contain worst bid price"
1190 );
1191 assert!(
1192 asks_map.contains_key(&dec!(87031.5)),
1193 "Asks map should contain worst ask price"
1194 );
1195 }
1196
1197 fn make_instrument_id(symbol: &str) -> InstrumentId {
1198 InstrumentId::new(Symbol::from(symbol), Venue::from("DERIBIT"))
1199 }
1200
1201 #[rstest]
1202 fn test_parse_futures_and_perpetuals() {
1203 let cases = [
1205 ("BTC-PERPETUAL", "future", "BTC"),
1206 ("ETH-PERPETUAL", "future", "ETH"),
1207 ("SOL-PERPETUAL", "future", "SOL"),
1208 ("BTC-25MAR23", "future", "BTC"),
1210 ("BTC-5AUG23", "future", "BTC"), ("ETH-28MAR25", "future", "ETH"),
1212 ];
1213
1214 for (symbol, expected_kind, expected_currency) in cases {
1215 let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1216 assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1217 assert_eq!(
1218 currency, expected_currency,
1219 "currency mismatch for {symbol}"
1220 );
1221 }
1222 }
1223
1224 #[rstest]
1225 fn test_parse_options() {
1226 let cases = [
1227 ("BTC-25MAR23-420-C", "option", "BTC"),
1229 ("BTC-5AUG23-580-P", "option", "BTC"),
1230 ("ETH-28MAR25-4000-C", "option", "ETH"),
1231 ("XRP_USDC-30JUN23-0d625-C", "option", "XRP"),
1233 ];
1234
1235 for (symbol, expected_kind, expected_currency) in cases {
1236 let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1237 assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1238 assert_eq!(
1239 currency, expected_currency,
1240 "currency mismatch for {symbol}"
1241 );
1242 }
1243 }
1244
1245 #[rstest]
1246 fn test_parse_spot() {
1247 let cases = [
1248 ("BTC_USDC", "spot", "BTC"),
1249 ("ETH_USDT", "spot", "ETH"),
1250 ("SOL_USDC", "spot", "SOL"),
1251 ];
1252
1253 for (symbol, expected_kind, expected_currency) in cases {
1254 let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1255 assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1256 assert_eq!(
1257 currency, expected_currency,
1258 "currency mismatch for {symbol}"
1259 );
1260 }
1261 }
1262
1263 #[rstest]
1264 fn test_parse_portfolio_to_account_state() {
1265 let json_data = load_test_json("ws_portfolio.json");
1266 let notification: serde_json::Value = serde_json::from_str(&json_data).unwrap();
1267
1268 let data = notification
1270 .get("params")
1271 .and_then(|p| p.get("data"))
1272 .expect("Test data must have params.data");
1273
1274 let portfolio: DeribitPortfolioMsg =
1275 serde_json::from_value(data.clone()).expect("Should deserialize portfolio message");
1276
1277 assert_eq!(portfolio.currency, "USDT");
1279 assert_eq!(portfolio.equity, dec!(55.00055));
1280 assert_eq!(portfolio.balance, dec!(55.00055));
1281 assert_eq!(portfolio.available_funds, dec!(53.868247));
1282 assert_eq!(portfolio.margin_balance, dec!(54.968258));
1283 assert_eq!(portfolio.initial_margin, dec!(1.100011));
1284 assert_eq!(portfolio.maintenance_margin, dec!(0.0));
1285
1286 let account_id = AccountId::new("DERIBIT-master");
1288 let ts_init = UnixNanos::from(1700000000000000000_u64);
1289
1290 let account_state =
1291 parse_portfolio_to_account_state(&portfolio, account_id, ts_init).unwrap();
1292
1293 assert_eq!(account_state.account_id, account_id);
1295 assert_eq!(account_state.account_type, AccountType::Margin);
1296 assert!(account_state.is_reported);
1297
1298 assert_eq!(account_state.balances.len(), 1);
1300 let balance = &account_state.balances[0];
1301 assert_eq!(balance.currency.code, "USDT");
1302 assert_eq!(balance.total.as_f64(), 54.968258); assert_eq!(balance.free.as_f64(), 53.868247); let locked = balance.locked.as_f64();
1307 assert!(
1308 (locked - 1.100011).abs() < 0.0001,
1309 "Locked ({locked}) should be close to 1.100011 (initial_margin)"
1310 );
1311
1312 assert_eq!(account_state.margins.len(), 1);
1314 let margin = &account_state.margins[0];
1315 assert_eq!(margin.initial.as_f64(), 1.100011);
1316 assert_eq!(margin.maintenance.as_f64(), 0.0);
1317 assert!(margin.instrument_id.is_none());
1318 assert_eq!(margin.currency.code.as_str(), "USDT");
1319 }
1320
1321 #[rstest]
1322 #[case::minute_1(1, "MINUTE", "1")]
1323 #[case::minute_2(2, "MINUTE", "3")]
1324 #[case::minute_3(3, "MINUTE", "3")]
1325 #[case::minute_4(4, "MINUTE", "5")]
1326 #[case::minute_5(5, "MINUTE", "5")]
1327 #[case::minute_6(6, "MINUTE", "10")]
1328 #[case::minute_10(10, "MINUTE", "10")]
1329 #[case::minute_11(11, "MINUTE", "15")]
1330 #[case::minute_15(15, "MINUTE", "15")]
1331 #[case::minute_16(16, "MINUTE", "30")]
1332 #[case::minute_30(30, "MINUTE", "30")]
1333 #[case::minute_31(31, "MINUTE", "60")]
1334 #[case::minute_60(60, "MINUTE", "60")]
1335 #[case::minute_61(61, "MINUTE", "120")]
1336 #[case::minute_120(120, "MINUTE", "120")]
1337 #[case::minute_121(121, "MINUTE", "180")]
1338 #[case::minute_180(180, "MINUTE", "180")]
1339 #[case::minute_181(181, "MINUTE", "360")]
1340 #[case::minute_360(360, "MINUTE", "360")]
1341 #[case::minute_361(361, "MINUTE", "720")]
1342 #[case::minute_720(720, "MINUTE", "720")]
1343 #[case::minute_721(721, "MINUTE", "1D")]
1344 #[case::hour_1(1, "HOUR", "60")]
1345 #[case::hour_2(2, "HOUR", "120")]
1346 #[case::hour_3(3, "HOUR", "180")]
1347 #[case::hour_4(4, "HOUR", "360")]
1348 #[case::hour_6(6, "HOUR", "360")]
1349 #[case::hour_7(7, "HOUR", "720")]
1350 #[case::hour_12(12, "HOUR", "720")]
1351 #[case::hour_13(13, "HOUR", "1D")]
1352 #[case::day_1(1, "DAY", "1D")]
1353 fn test_bar_spec_to_resolution(
1354 #[case] step: u64,
1355 #[case] aggregation: &str,
1356 #[case] expected: &str,
1357 ) {
1358 let bar_type_str = format!("BTC-PERPETUAL.DERIBIT-{step}-{aggregation}-LAST-EXTERNAL");
1359 let bar_type = BarType::from(bar_type_str.as_str());
1360 let resolution = bar_spec_to_resolution(&bar_type);
1361 assert_eq!(resolution, expected);
1362 }
1363}