nautilus_deribit/common/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parsing functions for Deribit API responses into Nautilus domain types.
17
18use 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::{
29        AccountType, AggressorSide, AssetClass, BookType, CurrencyType, OptionKind, OrderSide,
30    },
31    events::AccountState,
32    identifiers::{AccountId, InstrumentId, Symbol, TradeId, Venue},
33    instruments::{
34        CryptoFuture, CryptoPerpetual, CurrencyPair, OptionContract, any::InstrumentAny,
35    },
36    orderbook::OrderBook,
37    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
38};
39use rust_decimal::Decimal;
40
41use crate::{
42    common::consts::DERIBIT_VENUE,
43    http::models::{
44        DeribitAccountSummary, DeribitInstrument, DeribitInstrumentKind, DeribitOptionType,
45        DeribitOrderBook, DeribitPublicTrade, DeribitTradingViewChartData,
46    },
47};
48
49/// Parses a Deribit instrument ID into kind and currency for WebSocket channel subscription.
50///
51/// Deribit instrument naming conventions (per Deribit docs):
52/// - **Future**: `{CURRENCY}-{DMMMYY}` (e.g., "BTC-25MAR23", "BTC-5AUG23")
53/// - **Perpetual**: `{CURRENCY}-PERPETUAL` (e.g., "BTC-PERPETUAL")
54/// - **Option**: `{CURRENCY}-{DMMMYY}-{STRIKE}-{C|P}` (e.g., "BTC-25MAR23-420-C", "BTC-5AUG23-580-P")
55/// - **Linear Option**: `{BASE}_{QUOTE}-{DMMMYY}-{STRIKE}-{C|P}` (e.g., "XRP_USDC-30JUN23-0d625-C")
56///   - Note: `d` is used as decimal point for decimal strikes (0d625 = 0.625)
57/// - **Spot**: `{BASE}_{QUOTE}` (e.g., "BTC_USDC")
58///
59/// Returns `(kind, currency)` tuple for `instrument.state.{kind}.{currency}` channel.
60///
61/// Valid kinds: `future`, `option`, `spot`, `future_combo`, `option_combo`, `any`
62/// Valid currencies: `BTC`, `ETH`, `USDC`, `USDT`, `EURR`, `any`
63#[must_use]
64pub fn parse_instrument_kind_currency(instrument_id: &InstrumentId) -> (String, String) {
65    let symbol = instrument_id.symbol.as_str();
66
67    // Determine kind from instrument name pattern
68    // Order matters: check most specific patterns first
69    let kind = if symbol.contains("PERPETUAL") {
70        "future" // Perpetuals are treated as futures in Deribit API
71    } else if symbol.ends_with("-C") || symbol.ends_with("-P") {
72        // Options end with -C (call) or -P (put)
73        "option"
74    } else if symbol.contains('_') && !symbol.contains('-') {
75        // Spot pairs have underscore but no dash (e.g., "BTC_USDC")
76        "spot"
77    } else {
78        // Default to future for expiry dates like "BTC-25MAR23"
79        "future"
80    };
81
82    // Extract currency (first part before '-' or '_')
83    // For most instruments, currency is the first segment
84    let currency = if let Some(idx) = symbol.find('-') {
85        // Futures, perpetuals, options: "BTC-..." → "BTC"
86        // Linear options: "XRP_USDC-..." → extract base currency "XRP"
87        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        // Spot: "BTC_USDC" → "BTC"
95        symbol[..idx].to_string()
96    } else {
97        "any".to_string()
98    };
99
100    (kind.to_string(), currency)
101}
102
103/// Extracts server timestamp from response and converts to UnixNanos.
104///
105/// # Errors
106///
107/// Returns an error if the server timestamp (us_out) is missing from the response.
108pub 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
114/// Parses a Deribit instrument into a Nautilus [`InstrumentAny`].
115///
116/// Returns `Ok(None)` for unsupported instrument types (e.g., combos).
117///
118/// # Errors
119///
120/// Returns an error if:
121/// - Required fields are missing (e.g., strike price for options)
122/// - Timestamp conversion fails
123/// - Decimal conversion fails for fees
124pub 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        DeribitInstrumentKind::Spot => {
131            parse_spot_instrument(instrument, ts_init, ts_event).map(Some)
132        }
133        DeribitInstrumentKind::Future => {
134            // Check if it's a perpetual
135            if instrument.instrument_name.as_str().contains("PERPETUAL") {
136                parse_perpetual_instrument(instrument, ts_init, ts_event).map(Some)
137            } else {
138                parse_future_instrument(instrument, ts_init, ts_event).map(Some)
139            }
140        }
141        DeribitInstrumentKind::Option => {
142            parse_option_instrument(instrument, ts_init, ts_event).map(Some)
143        }
144        DeribitInstrumentKind::FutureCombo | DeribitInstrumentKind::OptionCombo => {
145            // Skip combos for initial implementation
146            Ok(None)
147        }
148    }
149}
150
151/// Parses a spot instrument into a [`CurrencyPair`].
152fn parse_spot_instrument(
153    instrument: &DeribitInstrument,
154    ts_init: UnixNanos,
155    ts_event: UnixNanos,
156) -> anyhow::Result<InstrumentAny> {
157    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
158
159    let base_currency = Currency::new(
160        instrument.base_currency,
161        8,
162        0,
163        instrument.base_currency,
164        CurrencyType::Crypto,
165    );
166    let quote_currency = Currency::new(
167        instrument.quote_currency,
168        8,
169        0,
170        instrument.quote_currency,
171        CurrencyType::Crypto,
172    );
173
174    let price_increment = Price::from(instrument.tick_size.to_string().as_str());
175    let size_increment = Quantity::from(instrument.min_trade_amount.to_string().as_str());
176    let min_quantity = Quantity::from(instrument.min_trade_amount.to_string().as_str());
177
178    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
179        .context("Failed to parse maker_commission")?;
180    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
181        .context("Failed to parse taker_commission")?;
182
183    let currency_pair = CurrencyPair::new(
184        instrument_id,
185        instrument.instrument_name.into(),
186        base_currency,
187        quote_currency,
188        price_increment.precision,
189        size_increment.precision,
190        price_increment,
191        size_increment,
192        None, // multiplier
193        None, // lot_size
194        None, // max_quantity
195        Some(min_quantity),
196        None, // max_notional
197        None, // min_notional
198        None, // max_price
199        None, // min_price
200        None, // margin_init
201        None, // margin_maint
202        Some(maker_fee),
203        Some(taker_fee),
204        ts_event,
205        ts_init,
206    );
207
208    Ok(InstrumentAny::CurrencyPair(currency_pair))
209}
210
211/// Parses a perpetual swap instrument into a [`CryptoPerpetual`].
212fn parse_perpetual_instrument(
213    instrument: &DeribitInstrument,
214    ts_init: UnixNanos,
215    ts_event: UnixNanos,
216) -> anyhow::Result<InstrumentAny> {
217    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
218
219    let base_currency = Currency::new(
220        instrument.base_currency,
221        8,
222        0,
223        instrument.base_currency,
224        CurrencyType::Crypto,
225    );
226    let quote_currency = Currency::new(
227        instrument.quote_currency,
228        8,
229        0,
230        instrument.quote_currency,
231        CurrencyType::Crypto,
232    );
233    let settlement_currency = instrument.settlement_currency.map_or(base_currency, |c| {
234        Currency::new(c, 8, 0, c, CurrencyType::Crypto)
235    });
236
237    let is_inverse = instrument
238        .instrument_type
239        .as_ref()
240        .is_some_and(|t| t == "reversed");
241
242    let price_increment = Price::from(instrument.tick_size.to_string().as_str());
243    let size_increment = Quantity::from(instrument.min_trade_amount.to_string().as_str());
244    let min_quantity = Quantity::from(instrument.min_trade_amount.to_string().as_str());
245
246    // Contract size represents the multiplier (e.g., 10 USD per contract for BTC-PERPETUAL)
247    let multiplier = Some(Quantity::from(
248        instrument.contract_size.to_string().as_str(),
249    ));
250    let lot_size = Some(size_increment);
251
252    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
253        .context("Failed to parse maker_commission")?;
254    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
255        .context("Failed to parse taker_commission")?;
256
257    let perpetual = CryptoPerpetual::new(
258        instrument_id,
259        instrument.instrument_name.into(),
260        base_currency,
261        quote_currency,
262        settlement_currency,
263        is_inverse,
264        price_increment.precision,
265        size_increment.precision,
266        price_increment,
267        size_increment,
268        multiplier,
269        lot_size,
270        None, // max_quantity - Deribit doesn't specify a hard max
271        Some(min_quantity),
272        None, // max_notional
273        None, // min_notional
274        None, // max_price
275        None, // min_price
276        None, // margin_init
277        None, // margin_maint
278        Some(maker_fee),
279        Some(taker_fee),
280        ts_event,
281        ts_init,
282    );
283
284    Ok(InstrumentAny::CryptoPerpetual(perpetual))
285}
286
287/// Parses a futures instrument into a [`CryptoFuture`].
288fn parse_future_instrument(
289    instrument: &DeribitInstrument,
290    ts_init: UnixNanos,
291    ts_event: UnixNanos,
292) -> anyhow::Result<InstrumentAny> {
293    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
294
295    let underlying = Currency::new(
296        instrument.base_currency,
297        8,
298        0,
299        instrument.base_currency,
300        CurrencyType::Crypto,
301    );
302    let quote_currency = Currency::new(
303        instrument.quote_currency,
304        8,
305        0,
306        instrument.quote_currency,
307        CurrencyType::Crypto,
308    );
309    let settlement_currency = instrument.settlement_currency.map_or(underlying, |c| {
310        Currency::new(c, 8, 0, c, CurrencyType::Crypto)
311    });
312
313    let is_inverse = instrument
314        .instrument_type
315        .as_ref()
316        .is_some_and(|t| t == "reversed");
317
318    // Convert timestamps from milliseconds to nanoseconds
319    let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
320    let expiration_ns = instrument
321        .expiration_timestamp
322        .context("Missing expiration_timestamp for future")? as u64
323        * 1_000_000; // milliseconds to nanoseconds
324
325    let price_increment = Price::from(instrument.tick_size.to_string().as_str());
326    let size_increment = Quantity::from(instrument.min_trade_amount.to_string().as_str());
327    let min_quantity = Quantity::from(instrument.min_trade_amount.to_string().as_str());
328
329    // Contract size represents the multiplier
330    let multiplier = Some(Quantity::from(
331        instrument.contract_size.to_string().as_str(),
332    ));
333    let lot_size = Some(size_increment); // Use min_trade_amount as lot size
334
335    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
336        .context("Failed to parse maker_commission")?;
337    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
338        .context("Failed to parse taker_commission")?;
339
340    let future = CryptoFuture::new(
341        instrument_id,
342        instrument.instrument_name.into(),
343        underlying,
344        quote_currency,
345        settlement_currency,
346        is_inverse,
347        UnixNanos::from(activation_ns),
348        UnixNanos::from(expiration_ns),
349        price_increment.precision,
350        size_increment.precision,
351        price_increment,
352        size_increment,
353        multiplier,
354        lot_size,
355        None, // max_quantity - Deribit doesn't specify a hard max
356        Some(min_quantity),
357        None, // max_notional
358        None, // min_notional
359        None, // max_price
360        None, // min_price
361        None, // margin_init
362        None, // margin_maint
363        Some(maker_fee),
364        Some(taker_fee),
365        ts_event,
366        ts_init,
367    );
368
369    Ok(InstrumentAny::CryptoFuture(future))
370}
371
372/// Parses an options instrument into an [`OptionContract`].
373fn parse_option_instrument(
374    instrument: &DeribitInstrument,
375    ts_init: UnixNanos,
376    ts_event: UnixNanos,
377) -> anyhow::Result<InstrumentAny> {
378    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
379
380    // Underlying is the base currency symbol (e.g., "BTC")
381    let underlying = instrument.base_currency;
382
383    // Settlement currency for Deribit options
384    let settlement = instrument
385        .settlement_currency
386        .unwrap_or(instrument.base_currency);
387    let currency = Currency::new(settlement, 8, 0, settlement, CurrencyType::Crypto);
388
389    // Determine option kind
390    let option_kind = match instrument.option_type {
391        Some(DeribitOptionType::Call) => OptionKind::Call,
392        Some(DeribitOptionType::Put) => OptionKind::Put,
393        None => anyhow::bail!("Missing option_type for option instrument"),
394    };
395
396    // Parse strike price
397    let strike = instrument.strike.context("Missing strike for option")?;
398    let strike_price = Price::from(strike.to_string().as_str());
399
400    // Convert timestamps from milliseconds to nanoseconds
401    let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
402    let expiration_ns = instrument
403        .expiration_timestamp
404        .context("Missing expiration_timestamp for option")? as u64
405        * 1_000_000;
406
407    let price_increment = Price::from(instrument.tick_size.to_string().as_str());
408
409    // Contract size is the multiplier (e.g., 1.0 for BTC options)
410    let multiplier = Quantity::from(instrument.contract_size.to_string().as_str());
411    let lot_size = Quantity::from(instrument.min_trade_amount.to_string().as_str());
412    let min_quantity = Quantity::from(instrument.min_trade_amount.to_string().as_str());
413
414    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
415        .context("Failed to parse maker_commission")?;
416    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
417        .context("Failed to parse taker_commission")?;
418
419    let option = OptionContract::new(
420        instrument_id,
421        instrument.instrument_name.into(),
422        AssetClass::Cryptocurrency,
423        None, // exchange - Deribit doesn't provide separate exchange field
424        underlying,
425        option_kind,
426        strike_price,
427        currency,
428        UnixNanos::from(activation_ns),
429        UnixNanos::from(expiration_ns),
430        price_increment.precision,
431        price_increment,
432        multiplier,
433        lot_size,
434        None, // max_quantity
435        Some(min_quantity),
436        None, // max_price
437        None, // min_price
438        None, // margin_init
439        None, // margin_maint
440        Some(maker_fee),
441        Some(taker_fee),
442        ts_event,
443        ts_init,
444    );
445
446    Ok(InstrumentAny::OptionContract(option))
447}
448
449/// Parses Deribit account summaries into a Nautilus [`AccountState`].
450///
451/// Processes multiple currency summaries and creates balance entries for each currency.
452///
453/// # Errors
454///
455/// Returns an error if:
456/// - Money conversion fails for any balance field
457/// - Decimal conversion fails for margin values
458pub fn parse_account_state(
459    summaries: &[DeribitAccountSummary],
460    account_id: AccountId,
461    ts_init: UnixNanos,
462    ts_event: UnixNanos,
463) -> anyhow::Result<AccountState> {
464    let mut balances = Vec::new();
465    let mut margins = Vec::new();
466
467    // Parse each currency summary
468    for summary in summaries {
469        let ccy_str = summary.currency.as_str().trim();
470
471        // Skip balances with empty currency codes
472        if ccy_str.is_empty() {
473            log::debug!("Skipping balance detail with empty currency code | raw_data={summary:?}");
474            continue;
475        }
476
477        let currency = Currency::get_or_create_crypto_with_context(
478            ccy_str,
479            Some("DERIBIT - Parsing account state"),
480        );
481
482        // Parse balance: total (equity includes unrealized PnL), locked, free
483        // Note: Deribit's available_funds = equity - initial_margin, so we must use equity for total
484        let total = Money::new(summary.equity, currency);
485        let free = Money::new(summary.available_funds, currency);
486        let locked = Money::from_raw(total.raw - free.raw, currency);
487
488        let balance = AccountBalance::new(total, locked, free);
489        balances.push(balance);
490
491        // Parse margin balances if present
492        if let (Some(initial_margin), Some(maintenance_margin)) =
493            (summary.initial_margin, summary.maintenance_margin)
494        {
495            // Only create margin balance if there are actual margin requirements
496            if initial_margin > 0.0 || maintenance_margin > 0.0 {
497                let initial = Money::new(initial_margin, currency);
498                let maintenance = Money::new(maintenance_margin, currency);
499
500                // Create a synthetic instrument_id for account-level margins
501                let margin_instrument_id = InstrumentId::new(
502                    Symbol::from_str_unchecked(format!("ACCOUNT-{}", summary.currency)),
503                    Venue::new("DERIBIT"),
504                );
505
506                margins.push(MarginBalance::new(
507                    initial,
508                    maintenance,
509                    margin_instrument_id,
510                ));
511            }
512        }
513    }
514
515    // Ensure at least one balance exists (Nautilus requires non-empty balances)
516    if balances.is_empty() {
517        let zero_currency = Currency::USD();
518        let zero_money = Money::new(0.0, zero_currency);
519        let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
520        balances.push(zero_balance);
521    }
522
523    let account_type = AccountType::Margin;
524    let is_reported = true;
525
526    Ok(AccountState::new(
527        account_id,
528        account_type,
529        balances,
530        margins,
531        is_reported,
532        UUID4::new(),
533        ts_event,
534        ts_init,
535        None,
536    ))
537}
538
539// Parses a Deribit public trade into a Nautilus [`TradeTick`].
540///
541/// # Errors
542///
543/// Returns an error if:
544/// - The direction is not "buy" or "sell"
545/// - Decimal conversion fails for price or size
546pub fn parse_trade_tick(
547    trade: &DeribitPublicTrade,
548    instrument_id: InstrumentId,
549    price_precision: u8,
550    size_precision: u8,
551    ts_init: UnixNanos,
552) -> anyhow::Result<TradeTick> {
553    // Parse aggressor side from direction
554    let aggressor_side = match trade.direction.as_str() {
555        "buy" => AggressorSide::Buyer,
556        "sell" => AggressorSide::Seller,
557        other => anyhow::bail!("Invalid trade direction: {other}"),
558    };
559    let price = Price::new(trade.price, price_precision);
560    let size = Quantity::new(trade.amount, size_precision);
561    let ts_event = UnixNanos::from((trade.timestamp as u64) * NANOSECONDS_IN_MILLISECOND);
562    let trade_id = TradeId::new(&trade.trade_id);
563
564    Ok(TradeTick::new(
565        instrument_id,
566        price,
567        size,
568        aggressor_side,
569        trade_id,
570        ts_event,
571        ts_init,
572    ))
573}
574
575/// Parses Deribit TradingView chart data into Nautilus [`Bar`]s.
576///
577/// Converts OHLCV arrays from the `public/get_tradingview_chart_data` endpoint
578/// into a vector of [`Bar`] objects.
579///
580/// # Errors
581///
582/// Returns an error if:
583/// - The status is not "ok"
584/// - Array lengths are inconsistent
585/// - No data points are present
586pub fn parse_bars(
587    chart_data: &DeribitTradingViewChartData,
588    bar_type: BarType,
589    price_precision: u8,
590    size_precision: u8,
591    ts_init: UnixNanos,
592) -> anyhow::Result<Vec<Bar>> {
593    // Check status
594    if chart_data.status != "ok" {
595        anyhow::bail!(
596            "Chart data status is '{}', expected 'ok'",
597            chart_data.status
598        );
599    }
600
601    let num_bars = chart_data.ticks.len();
602
603    // Verify array lengths match
604    anyhow::ensure!(
605        chart_data.open.len() == num_bars
606            && chart_data.high.len() == num_bars
607            && chart_data.low.len() == num_bars
608            && chart_data.close.len() == num_bars
609            && chart_data.volume.len() == num_bars,
610        "Inconsistent array lengths in chart data"
611    );
612
613    if num_bars == 0 {
614        return Ok(Vec::new());
615    }
616
617    let mut bars = Vec::with_capacity(num_bars);
618
619    for i in 0..num_bars {
620        let open = Price::new_checked(chart_data.open[i], price_precision)
621            .with_context(|| format!("Invalid open price at index {i}"))?;
622        let high = Price::new_checked(chart_data.high[i], price_precision)
623            .with_context(|| format!("Invalid high price at index {i}"))?;
624        let low = Price::new_checked(chart_data.low[i], price_precision)
625            .with_context(|| format!("Invalid low price at index {i}"))?;
626        let close = Price::new_checked(chart_data.close[i], price_precision)
627            .with_context(|| format!("Invalid close price at index {i}"))?;
628        let volume = Quantity::new_checked(chart_data.volume[i], size_precision)
629            .with_context(|| format!("Invalid volume at index {i}"))?;
630
631        // Convert timestamp from milliseconds to nanoseconds
632        let ts_event = UnixNanos::from((chart_data.ticks[i] as u64) * NANOSECONDS_IN_MILLISECOND);
633
634        let bar = Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
635            .with_context(|| format!("Invalid OHLC bar at index {i}"))?;
636        bars.push(bar);
637    }
638
639    Ok(bars)
640}
641
642/// Parses Deribit order book data into a Nautilus [`OrderBook`].
643///
644/// Converts bids and asks from the `public/get_order_book` endpoint
645/// into an L2_MBP order book.
646///
647/// # Errors
648///
649/// Returns an error if order book creation fails.
650pub fn parse_order_book(
651    order_book_data: &DeribitOrderBook,
652    instrument_id: InstrumentId,
653    price_precision: u8,
654    size_precision: u8,
655    ts_init: UnixNanos,
656) -> anyhow::Result<OrderBook> {
657    let ts_event = UnixNanos::from((order_book_data.timestamp as u64) * NANOSECONDS_IN_MILLISECOND);
658    let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
659
660    for (idx, [price, amount]) in order_book_data.bids.iter().enumerate() {
661        let order = BookOrder::new(
662            OrderSide::Buy,
663            Price::new(*price, price_precision),
664            Quantity::new(*amount, size_precision),
665            idx as u64,
666        );
667        book.add(order, 0, idx as u64, ts_event);
668    }
669
670    let bids_len = order_book_data.bids.len();
671    for (idx, [price, amount]) in order_book_data.asks.iter().enumerate() {
672        let order = BookOrder::new(
673            OrderSide::Sell,
674            Price::new(*price, price_precision),
675            Quantity::new(*amount, size_precision),
676            (bids_len + idx) as u64,
677        );
678        book.add(order, 0, (bids_len + idx) as u64, ts_event);
679    }
680
681    book.ts_last = ts_init;
682
683    Ok(book)
684}
685
686/// Converts a Nautilus BarType to a Deribit chart resolution.
687///
688/// Deribit resolutions: "1", "3", "5", "10", "15", "30", "60", "120", "180", "360", "720", "1D"
689pub fn bar_spec_to_resolution(bar_type: &BarType) -> String {
690    use nautilus_model::enums::BarAggregation;
691
692    let spec = bar_type.spec();
693    match spec.aggregation {
694        BarAggregation::Minute => {
695            let step = spec.step.get();
696            // Map to nearest Deribit resolution
697            match step {
698                1 => "1".to_string(),
699                2..=3 => "3".to_string(),
700                4..=5 => "5".to_string(),
701                6..=10 => "10".to_string(),
702                11..=15 => "15".to_string(),
703                16..=30 => "30".to_string(),
704                31..=60 => "60".to_string(),
705                61..=120 => "120".to_string(),
706                121..=180 => "180".to_string(),
707                181..=360 => "360".to_string(),
708                361..=720 => "720".to_string(),
709                _ => "1D".to_string(),
710            }
711        }
712        BarAggregation::Hour => {
713            let step = spec.step.get();
714            match step {
715                1 => "60".to_string(),
716                2 => "120".to_string(),
717                3 => "180".to_string(),
718                4..=6 => "360".to_string(),
719                7..=12 => "720".to_string(),
720                _ => "1D".to_string(),
721            }
722        }
723        BarAggregation::Day => "1D".to_string(),
724        _ => {
725            log::warn!(
726                "Unsupported bar aggregation {:?}, defaulting to 1 minute",
727                spec.aggregation
728            );
729            "1".to_string()
730        }
731    }
732}
733
734#[cfg(test)]
735mod tests {
736    use nautilus_model::instruments::Instrument;
737    use rstest::rstest;
738    use rust_decimal_macros::dec;
739
740    use super::*;
741    use crate::{
742        common::testing::load_test_json,
743        http::models::{
744            DeribitAccountSummariesResponse, DeribitJsonRpcResponse, DeribitTradesResponse,
745        },
746    };
747
748    #[rstest]
749    fn test_parse_perpetual_instrument() {
750        let json_data = load_test_json("http_get_instrument.json");
751        let response: DeribitJsonRpcResponse<DeribitInstrument> =
752            serde_json::from_str(&json_data).unwrap();
753        let deribit_inst = response.result.expect("Test data must have result");
754
755        let instrument_any =
756            parse_deribit_instrument_any(&deribit_inst, UnixNanos::default(), UnixNanos::default())
757                .unwrap();
758        let instrument = instrument_any.expect("Should parse perpetual instrument");
759
760        let InstrumentAny::CryptoPerpetual(perpetual) = instrument else {
761            panic!("Expected CryptoPerpetual, got {instrument:?}");
762        };
763        assert_eq!(perpetual.id(), InstrumentId::from("BTC-PERPETUAL.DERIBIT"));
764        assert_eq!(perpetual.raw_symbol(), Symbol::from("BTC-PERPETUAL"));
765        assert_eq!(perpetual.base_currency().unwrap().code, "BTC");
766        assert_eq!(perpetual.quote_currency().code, "USD");
767        assert_eq!(perpetual.settlement_currency().code, "BTC");
768        assert!(perpetual.is_inverse());
769        assert_eq!(perpetual.price_precision(), 1);
770        assert_eq!(perpetual.size_precision(), 0);
771        assert_eq!(perpetual.price_increment(), Price::from("0.5"));
772        assert_eq!(perpetual.size_increment(), Quantity::from("10"));
773        assert_eq!(perpetual.multiplier(), Quantity::from("10"));
774        assert_eq!(perpetual.lot_size(), Some(Quantity::from("10")));
775        assert_eq!(perpetual.maker_fee(), dec!(0));
776        assert_eq!(perpetual.taker_fee(), dec!(0.0005));
777        assert_eq!(perpetual.max_quantity(), None);
778        assert_eq!(perpetual.min_quantity(), Some(Quantity::from("10")));
779    }
780
781    #[rstest]
782    fn test_parse_future_instrument() {
783        let json_data = load_test_json("http_get_instruments.json");
784        let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
785            serde_json::from_str(&json_data).unwrap();
786        let instruments = response.result.expect("Test data must have result");
787        let deribit_inst = instruments
788            .iter()
789            .find(|i| i.instrument_name.as_str() == "BTC-27DEC24")
790            .expect("Test data must contain BTC-27DEC24");
791
792        let instrument_any =
793            parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
794                .unwrap();
795        let instrument = instrument_any.expect("Should parse future instrument");
796
797        let InstrumentAny::CryptoFuture(future) = instrument else {
798            panic!("Expected CryptoFuture, got {instrument:?}");
799        };
800        assert_eq!(future.id(), InstrumentId::from("BTC-27DEC24.DERIBIT"));
801        assert_eq!(future.raw_symbol(), Symbol::from("BTC-27DEC24"));
802        assert_eq!(future.underlying().unwrap(), "BTC");
803        assert_eq!(future.quote_currency().code, "USD");
804        assert_eq!(future.settlement_currency().code, "BTC");
805        assert!(future.is_inverse());
806
807        // Verify timestamps
808        assert_eq!(
809            future.activation_ns(),
810            Some(UnixNanos::from(1719561600000_u64 * 1_000_000))
811        );
812        assert_eq!(
813            future.expiration_ns(),
814            Some(UnixNanos::from(1735300800000_u64 * 1_000_000))
815        );
816        assert_eq!(future.price_precision(), 1);
817        assert_eq!(future.size_precision(), 0);
818        assert_eq!(future.price_increment(), Price::from("0.5"));
819        assert_eq!(future.size_increment(), Quantity::from("10"));
820        assert_eq!(future.multiplier(), Quantity::from("10"));
821        assert_eq!(future.lot_size(), Some(Quantity::from("10")));
822        assert_eq!(future.maker_fee, dec!(0));
823        assert_eq!(future.taker_fee, dec!(0.0005));
824    }
825
826    #[rstest]
827    fn test_parse_option_instrument() {
828        let json_data = load_test_json("http_get_instruments.json");
829        let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
830            serde_json::from_str(&json_data).unwrap();
831        let instruments = response.result.expect("Test data must have result");
832        let deribit_inst = instruments
833            .iter()
834            .find(|i| i.instrument_name.as_str() == "BTC-27DEC24-100000-C")
835            .expect("Test data must contain BTC-27DEC24-100000-C");
836
837        let instrument_any =
838            parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
839                .unwrap();
840        let instrument = instrument_any.expect("Should parse option instrument");
841
842        // Verify it's an OptionContract
843        let InstrumentAny::OptionContract(option) = instrument else {
844            panic!("Expected OptionContract, got {instrument:?}");
845        };
846
847        assert_eq!(
848            option.id(),
849            InstrumentId::from("BTC-27DEC24-100000-C.DERIBIT")
850        );
851        assert_eq!(option.raw_symbol(), Symbol::from("BTC-27DEC24-100000-C"));
852        assert_eq!(option.underlying(), Some("BTC".into()));
853        assert_eq!(option.asset_class(), AssetClass::Cryptocurrency);
854        assert_eq!(option.option_kind(), Some(OptionKind::Call));
855        assert_eq!(option.strike_price(), Some(Price::from("100000")));
856        assert_eq!(option.currency.code, "BTC");
857        assert_eq!(
858            option.activation_ns(),
859            Some(UnixNanos::from(1719561600000_u64 * 1_000_000))
860        );
861        assert_eq!(
862            option.expiration_ns(),
863            Some(UnixNanos::from(1735300800000_u64 * 1_000_000))
864        );
865        assert_eq!(option.price_precision(), 4);
866        assert_eq!(option.price_increment(), Price::from("0.0005"));
867        assert_eq!(option.multiplier(), Quantity::from("1"));
868        assert_eq!(option.lot_size(), Some(Quantity::from("0.1")));
869        assert_eq!(option.maker_fee, dec!(0.0003));
870        assert_eq!(option.taker_fee, dec!(0.0003));
871    }
872
873    #[rstest]
874    fn test_parse_account_state_with_positions() {
875        let json_data = load_test_json("http_get_account_summaries.json");
876        let response: DeribitJsonRpcResponse<DeribitAccountSummariesResponse> =
877            serde_json::from_str(&json_data).unwrap();
878        let result = response.result.expect("Test data must have result");
879
880        let account_id = AccountId::from("DERIBIT-001");
881
882        // Extract server timestamp from response
883        let ts_event =
884            extract_server_timestamp(response.us_out).expect("Test data must have us_out");
885        let ts_init = UnixNanos::default();
886
887        let account_state = parse_account_state(&result.summaries, account_id, ts_init, ts_event)
888            .expect("Should parse account state");
889
890        // Verify we got 2 currencies (BTC and ETH)
891        assert_eq!(account_state.balances.len(), 2);
892
893        // Test BTC balance (has open positions with unrealized PnL)
894        let btc_balance = account_state
895            .balances
896            .iter()
897            .find(|b| b.currency.code == "BTC")
898            .expect("BTC balance should exist");
899
900        // From test data:
901        // balance: 302.60065765, equity: 302.61869214, available_funds: 301.38059622
902        // initial_margin: 1.24669592, session_upl: 0.05271555
903        //
904        // Using equity (correct):
905        // total = equity = 302.61869214
906        // free = available_funds = 301.38059622
907        // locked = total - free = 302.61869214 - 301.38059622 = 1.23809592
908        //
909        // This is close to initial_margin (1.24669592), small difference due to other factors
910        assert_eq!(btc_balance.total.as_f64(), 302.61869214);
911        assert_eq!(btc_balance.free.as_f64(), 301.38059622);
912
913        // Verify locked is positive and close to initial_margin
914        let locked = btc_balance.locked.as_f64();
915        assert!(
916            locked > 0.0,
917            "Locked should be positive when positions exist"
918        );
919        assert!(
920            (locked - 1.24669592).abs() < 0.01,
921            "Locked ({locked}) should be close to initial_margin (1.24669592)"
922        );
923
924        // Test ETH balance (no positions)
925        let eth_balance = account_state
926            .balances
927            .iter()
928            .find(|b| b.currency.code == "ETH")
929            .expect("ETH balance should exist");
930
931        // From test data: balance: 100, equity: 100, available_funds: 99.999598
932        // total = equity = 100
933        // free = available_funds = 99.999598
934        // locked = 100 - 99.999598 = 0.000402 (matches initial_margin)
935        assert_eq!(eth_balance.total.as_f64(), 100.0);
936        assert_eq!(eth_balance.free.as_f64(), 99.999598);
937        assert_eq!(eth_balance.locked.as_f64(), 0.000402);
938
939        // Verify account metadata
940        assert_eq!(account_state.account_id, account_id);
941        assert_eq!(account_state.account_type, AccountType::Margin);
942        assert!(account_state.is_reported);
943
944        // Verify ts_event matches server timestamp (us_out = 1687352432005000 microseconds)
945        let expected_ts_event = UnixNanos::from(1687352432005000_u64 * NANOSECONDS_IN_MICROSECOND);
946        assert_eq!(
947            account_state.ts_event, expected_ts_event,
948            "ts_event should match server timestamp from response"
949        );
950    }
951
952    #[rstest]
953    fn test_parse_trade_tick_sell() {
954        let json_data = load_test_json("http_get_last_trades.json");
955        let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
956            serde_json::from_str(&json_data).unwrap();
957        let result = response.result.expect("Test data must have result");
958
959        assert!(result.has_more, "has_more should be true");
960        assert_eq!(result.trades.len(), 10, "Should have 10 trades");
961
962        let raw_trade = &result.trades[0];
963        let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
964        let ts_init = UnixNanos::from(1766335632425576_u64 * 1000); // from usOut
965
966        let trade = parse_trade_tick(raw_trade, instrument_id, 1, 0, ts_init)
967            .expect("Should parse trade tick");
968
969        assert_eq!(trade.instrument_id, instrument_id);
970        assert_eq!(trade.price, Price::from("2968.3"));
971        assert_eq!(trade.size, Quantity::from("1"));
972        assert_eq!(trade.aggressor_side, AggressorSide::Seller);
973        assert_eq!(trade.trade_id, TradeId::new("ETH-284830839"));
974        // timestamp 1766332040636 ms -> ns
975        assert_eq!(
976            trade.ts_event,
977            UnixNanos::from(1766332040636_u64 * 1_000_000)
978        );
979        assert_eq!(trade.ts_init, ts_init);
980    }
981
982    #[rstest]
983    fn test_parse_trade_tick_buy() {
984        let json_data = load_test_json("http_get_last_trades.json");
985        let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
986            serde_json::from_str(&json_data).unwrap();
987        let result = response.result.expect("Test data must have result");
988
989        // Last trade is a buy with amount 106
990        let raw_trade = &result.trades[9];
991        let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
992        let ts_init = UnixNanos::default();
993
994        let trade = parse_trade_tick(raw_trade, instrument_id, 1, 0, ts_init)
995            .expect("Should parse trade tick");
996
997        assert_eq!(trade.instrument_id, instrument_id);
998        assert_eq!(trade.price, Price::from("2968.3"));
999        assert_eq!(trade.size, Quantity::from("106"));
1000        assert_eq!(trade.aggressor_side, AggressorSide::Buyer);
1001        assert_eq!(trade.trade_id, TradeId::new("ETH-284830854"));
1002    }
1003
1004    #[rstest]
1005    fn test_parse_bars() {
1006        let json_data = load_test_json("http_get_tradingview_chart_data.json");
1007        let response: DeribitJsonRpcResponse<DeribitTradingViewChartData> =
1008            serde_json::from_str(&json_data).unwrap();
1009        let chart_data = response.result.expect("Test data must have result");
1010
1011        let bar_type = BarType::from("BTC-PERPETUAL.DERIBIT-1-MINUTE-LAST-EXTERNAL");
1012        let ts_init = UnixNanos::from(1766487086146245_u64 * NANOSECONDS_IN_MICROSECOND);
1013
1014        let bars = parse_bars(&chart_data, bar_type, 1, 8, ts_init).expect("Should parse bars");
1015
1016        assert_eq!(bars.len(), 5, "Should parse 5 bars");
1017
1018        // Verify first bar
1019        let first_bar = &bars[0];
1020        assert_eq!(first_bar.bar_type, bar_type);
1021        assert_eq!(first_bar.open, Price::from("87451.0"));
1022        assert_eq!(first_bar.high, Price::from("87456.5"));
1023        assert_eq!(first_bar.low, Price::from("87451.0"));
1024        assert_eq!(first_bar.close, Price::from("87456.5"));
1025        assert_eq!(first_bar.volume, Quantity::from("2.94375216"));
1026        assert_eq!(
1027            first_bar.ts_event,
1028            UnixNanos::from(1766483460000_u64 * NANOSECONDS_IN_MILLISECOND)
1029        );
1030        assert_eq!(first_bar.ts_init, ts_init);
1031
1032        // Verify last bar
1033        let last_bar = &bars[4];
1034        assert_eq!(last_bar.open, Price::from("87456.0"));
1035        assert_eq!(last_bar.high, Price::from("87456.5"));
1036        assert_eq!(last_bar.low, Price::from("87456.0"));
1037        assert_eq!(last_bar.close, Price::from("87456.0"));
1038        assert_eq!(last_bar.volume, Quantity::from("0.1018798"));
1039        assert_eq!(
1040            last_bar.ts_event,
1041            UnixNanos::from(1766483700000_u64 * NANOSECONDS_IN_MILLISECOND)
1042        );
1043    }
1044
1045    #[rstest]
1046    fn test_parse_order_book() {
1047        let json_data = load_test_json("http_get_order_book.json");
1048        let response: DeribitJsonRpcResponse<DeribitOrderBook> =
1049            serde_json::from_str(&json_data).unwrap();
1050        let order_book_data = response.result.expect("Test data must have result");
1051
1052        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1053        let ts_init = UnixNanos::from(1766554855146274_u64 * NANOSECONDS_IN_MICROSECOND);
1054
1055        let book = parse_order_book(&order_book_data, instrument_id, 1, 0, ts_init)
1056            .expect("Should parse order book");
1057
1058        // Verify book metadata
1059        assert_eq!(book.instrument_id, instrument_id);
1060        assert_eq!(book.book_type, BookType::L2_MBP);
1061        assert_eq!(book.ts_last, ts_init);
1062
1063        // Verify book has both sides
1064        assert!(book.has_bid(), "Book should have bids");
1065        assert!(book.has_ask(), "Book should have asks");
1066
1067        // Verify best bid using OrderBook methods
1068        assert_eq!(
1069            book.best_bid_price(),
1070            Some(Price::from("87002.5")),
1071            "Best bid price should match"
1072        );
1073        assert_eq!(
1074            book.best_bid_size(),
1075            Some(Quantity::from("199190")),
1076            "Best bid size should match"
1077        );
1078
1079        // Verify best ask using OrderBook methods
1080        assert_eq!(
1081            book.best_ask_price(),
1082            Some(Price::from("87003.0")),
1083            "Best ask price should match"
1084        );
1085        assert_eq!(
1086            book.best_ask_size(),
1087            Some(Quantity::from("125090")),
1088            "Best ask size should match"
1089        );
1090
1091        // Verify spread (best_ask - best_bid = 87003.0 - 87002.5 = 0.5)
1092        let spread = book.spread().expect("Spread should exist");
1093        assert!(
1094            (spread - 0.5).abs() < 0.0001,
1095            "Spread should be 0.5, got {spread}"
1096        );
1097
1098        // Verify midpoint ((87003.0 + 87002.5) / 2 = 87002.75)
1099        let midpoint = book.midpoint().expect("Midpoint should exist");
1100        assert!(
1101            (midpoint - 87002.75).abs() < 0.0001,
1102            "Midpoint should be 87002.75, got {midpoint}"
1103        );
1104
1105        // Verify level counts match input data
1106        let bid_count = book.bids(None).count();
1107        let ask_count = book.asks(None).count();
1108        assert_eq!(
1109            bid_count,
1110            order_book_data.bids.len(),
1111            "Bid levels count should match input data"
1112        );
1113        assert_eq!(
1114            ask_count,
1115            order_book_data.asks.len(),
1116            "Ask levels count should match input data"
1117        );
1118        assert_eq!(bid_count, 20, "Should have 20 bid levels");
1119        assert_eq!(ask_count, 20, "Should have 20 ask levels");
1120
1121        // Verify depth limiting works (get top 5 levels)
1122        assert_eq!(
1123            book.bids(Some(5)).count(),
1124            5,
1125            "Should limit to 5 bid levels"
1126        );
1127        assert_eq!(
1128            book.asks(Some(5)).count(),
1129            5,
1130            "Should limit to 5 ask levels"
1131        );
1132
1133        // Verify bids_as_map and asks_as_map
1134        let bids_map = book.bids_as_map(None);
1135        let asks_map = book.asks_as_map(None);
1136        assert_eq!(bids_map.len(), 20, "Bids map should have 20 entries");
1137        assert_eq!(asks_map.len(), 20, "Asks map should have 20 entries");
1138
1139        // Verify specific prices exist in maps
1140        assert!(
1141            bids_map.contains_key(&dec!(87002.5)),
1142            "Bids map should contain best bid price"
1143        );
1144        assert!(
1145            asks_map.contains_key(&dec!(87003.0)),
1146            "Asks map should contain best ask price"
1147        );
1148
1149        // Verify worst levels exist
1150        assert!(
1151            bids_map.contains_key(&dec!(86980.0)),
1152            "Bids map should contain worst bid price"
1153        );
1154        assert!(
1155            asks_map.contains_key(&dec!(87031.5)),
1156            "Asks map should contain worst ask price"
1157        );
1158    }
1159
1160    fn make_instrument_id(symbol: &str) -> InstrumentId {
1161        InstrumentId::new(Symbol::from(symbol), Venue::from("DERIBIT"))
1162    }
1163
1164    #[rstest]
1165    fn test_parse_futures_and_perpetuals() {
1166        // Perpetuals are classified as "future" in Deribit API
1167        let cases = [
1168            ("BTC-PERPETUAL", "future", "BTC"),
1169            ("ETH-PERPETUAL", "future", "ETH"),
1170            ("SOL-PERPETUAL", "future", "SOL"),
1171            // Futures with expiry dates
1172            ("BTC-25MAR23", "future", "BTC"),
1173            ("BTC-5AUG23", "future", "BTC"), // Single digit day
1174            ("ETH-28MAR25", "future", "ETH"),
1175        ];
1176
1177        for (symbol, expected_kind, expected_currency) in cases {
1178            let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1179            assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1180            assert_eq!(
1181                currency, expected_currency,
1182                "currency mismatch for {symbol}"
1183            );
1184        }
1185    }
1186
1187    #[rstest]
1188    fn test_parse_options() {
1189        let cases = [
1190            // Standard options: {CURRENCY}-{DMMMYY}-{STRIKE}-{C|P}
1191            ("BTC-25MAR23-420-C", "option", "BTC"),
1192            ("BTC-5AUG23-580-P", "option", "BTC"),
1193            ("ETH-28MAR25-4000-C", "option", "ETH"),
1194            // Linear option with decimal strike (d = decimal point)
1195            ("XRP_USDC-30JUN23-0d625-C", "option", "XRP"),
1196        ];
1197
1198        for (symbol, expected_kind, expected_currency) in cases {
1199            let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1200            assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1201            assert_eq!(
1202                currency, expected_currency,
1203                "currency mismatch for {symbol}"
1204            );
1205        }
1206    }
1207
1208    #[rstest]
1209    fn test_parse_spot() {
1210        let cases = [
1211            ("BTC_USDC", "spot", "BTC"),
1212            ("ETH_USDT", "spot", "ETH"),
1213            ("SOL_USDC", "spot", "SOL"),
1214        ];
1215
1216        for (symbol, expected_kind, expected_currency) in cases {
1217            let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1218            assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1219            assert_eq!(
1220                currency, expected_currency,
1221                "currency mismatch for {symbol}"
1222            );
1223        }
1224    }
1225}