Skip to main content

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::{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/// 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        DeribitProductType::Spot => parse_spot_instrument(instrument, ts_init, ts_event).map(Some),
131        DeribitProductType::Future => {
132            // Check if it's a perpetual
133            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
153/// Parses a spot instrument into a [`CurrencyPair`].
154fn 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, // multiplier
183        None, // lot_size
184        None, // max_quantity
185        Some(min_quantity),
186        None, // max_notional
187        None, // min_notional
188        None, // max_price
189        None, // min_price
190        None, // margin_init
191        None, // margin_maint
192        Some(maker_fee),
193        Some(taker_fee),
194        ts_event,
195        ts_init,
196    );
197
198    Ok(InstrumentAny::CurrencyPair(currency_pair))
199}
200
201/// Parses a perpetual swap instrument into a [`CryptoPerpetual`].
202fn 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    // Contract size represents the multiplier (e.g., 10 USD per contract for BTC-PERPETUAL)
225    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, // max_quantity - Deribit doesn't specify a hard max
247        Some(min_quantity),
248        None, // max_notional
249        None, // min_notional
250        None, // max_price
251        None, // min_price
252        None, // margin_init
253        None, // margin_maint
254        Some(maker_fee),
255        Some(taker_fee),
256        ts_event,
257        ts_init,
258    );
259
260    Ok(InstrumentAny::CryptoPerpetual(perpetual))
261}
262
263/// Parses a futures instrument into a [`CryptoFuture`].
264fn 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    // Convert timestamps from milliseconds to nanoseconds
283    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; // milliseconds to nanoseconds
288
289    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    // Contract size represents the multiplier
294    let multiplier = Some(Quantity::from_decimal(instrument.contract_size)?);
295    let lot_size = Some(size_increment); // Use min_trade_amount as lot size
296
297    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, // max_quantity - Deribit doesn't specify a hard max
318        Some(min_quantity),
319        None, // max_notional
320        None, // min_notional
321        None, // max_price
322        None, // min_price
323        None, // margin_init
324        None, // margin_maint
325        Some(maker_fee),
326        Some(taker_fee),
327        ts_event,
328        ts_init,
329    );
330
331    Ok(InstrumentAny::CryptoFuture(future))
332}
333
334/// Parses an options instrument into a [`CryptoOption`].
335fn 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    // Determine if inverse (settled in base currency) or linear (settled in quote/USDC)
349    let is_inverse = instrument
350        .instrument_type
351        .as_ref()
352        .is_some_and(|t| t == "reversed");
353
354    // Determine option kind
355    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    // Parse strike price
362    let strike = instrument.strike.context("Missing strike for option")?;
363    let strike_price = Price::from_decimal(strike)?;
364
365    // Convert timestamps from milliseconds to nanoseconds
366    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    // Contract size is the multiplier (e.g., 1.0 for BTC options)
375    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
418/// Parses Deribit account summaries into a Nautilus [`AccountState`].
419///
420/// Processes multiple currency summaries and creates balance entries for each currency.
421///
422/// # Errors
423///
424/// Returns an error if:
425/// - Money conversion fails for any balance field
426/// - Decimal conversion fails for margin values
427pub 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    // Parse each currency summary
437    for summary in summaries {
438        let ccy_str = summary.currency.as_str().trim();
439
440        // Skip balances with empty currency codes
441        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        // Parse balance using margin_balance (not equity):
452        // - total: margin_balance (equity minus fee reserves)
453        // - free: available_funds
454        // - locked: total - free = initial_margin
455        //
456        // Key: available_funds = margin_balance - initial_margin
457        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        // Parse margin balances if present
465        if let (Some(initial_margin), Some(maintenance_margin)) =
466            (summary.initial_margin, summary.maintenance_margin)
467        {
468            // Only create margin balance if there are actual margin requirements
469            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                // Create a synthetic instrument_id for account-level margins
474                // SAFETY: Format string "ACCOUNT-{currency}" always produces valid ASCII
475                // symbol since currency codes are uppercase alphanumeric (e.g., BTC, ETH, USDT)
476                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    // Ensure at least one balance exists (Nautilus requires non-empty balances)
491    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
514/// Parses a Deribit WebSocket portfolio message into a Nautilus [`AccountState`].
515///
516/// This function converts real-time portfolio updates from the `user.portfolio.{currency}`
517/// subscription channel into Nautilus account state events.
518///
519/// # Returns
520///
521/// An `AccountState` containing balances and margin information.
522///
523/// # Errors
524///
525/// Returns an error if Money conversion fails for any balance field.
526pub 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    // Skip empty currency codes
534    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    // Parse balance using margin_balance (not equity):
544    // - total: margin_balance (equity minus fee reserves, used for margin calculations)
545    // - free: available_funds (what can be used for new orders)
546    // - locked: derived as (total - free) which equals initial_margin
547    //
548    // Key relationship: available_funds = margin_balance - initial_margin
549    // So: locked = margin_balance - available_funds = initial_margin
550    //
551    // Using margin_balance instead of equity ensures locked is always non-negative
552    // and accurately reflects the margin requirement.
553    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    // Parse margin balances
561    let mut margins = Vec::new();
562    let initial_margin = portfolio.initial_margin;
563    let maintenance_margin = portfolio.maintenance_margin;
564
565    // Only create margin balance if there are actual margin requirements
566    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        // Create a synthetic instrument_id for account-level margins
571        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, // Use ts_init for both since we don't have server timestamp in portfolio msg
594        ts_init,
595        None,
596    ))
597}
598
599// Parses a Deribit public trade into a Nautilus [`TradeTick`].
600///
601/// # Errors
602///
603/// Returns an error if:
604/// - The direction is not "buy" or "sell"
605/// - Decimal conversion fails for price or size
606pub 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    // Parse aggressor side from direction
614    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
635/// Parses Deribit TradingView chart data into Nautilus [`Bar`]s.
636///
637/// Converts OHLCV arrays from the `public/get_tradingview_chart_data` endpoint
638/// into a vector of [`Bar`] objects.
639///
640/// # Errors
641///
642/// Returns an error if:
643/// - The status is not "ok"
644/// - Array lengths are inconsistent
645/// - No data points are present
646pub 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    // Check status
654    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    // Verify array lengths match
664    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        // Convert timestamp from milliseconds to nanoseconds
692        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
702/// Parses Deribit order book data into a Nautilus [`OrderBook`].
703///
704/// Converts bids and asks from the `public/get_order_book` endpoint
705/// into an L2_MBP order book.
706///
707/// # Errors
708///
709/// Returns an error if order book creation fails.
710pub 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
746/// Converts a Nautilus BarType to a Deribit chart resolution.
747///
748/// Deribit resolutions: "1", "3", "5", "10", "15", "30", "60", "120", "180", "360", "720", "1D"
749pub 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            // Map to nearest Deribit resolution
757            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        // Verify timestamps
868        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        // Verify it's a CryptoOption
903        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        // Extract server timestamp from response
946        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        // Verify we got 2 currencies (BTC and ETH)
954        assert_eq!(account_state.balances.len(), 2);
955
956        // Test BTC balance (has open positions with unrealized PnL)
957        let btc_balance = account_state
958            .balances
959            .iter()
960            .find(|b| b.currency.code == "BTC")
961            .expect("BTC balance should exist");
962
963        // From test data:
964        // margin_balance: 302.62729214, available_funds: 301.38059622
965        // initial_margin: 1.24669592
966        //
967        // Using margin_balance:
968        // total = margin_balance = 302.62729214
969        // free = available_funds = 301.38059622
970        // locked = total - free = 302.62729214 - 301.38059622 = 1.24669592 (exactly initial_margin!)
971        assert_eq!(btc_balance.total.as_f64(), 302.62729214);
972        assert_eq!(btc_balance.free.as_f64(), 301.38059622);
973
974        // Verify locked equals initial_margin exactly
975        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        // Test ETH balance (no positions)
986        let eth_balance = account_state
987            .balances
988            .iter()
989            .find(|b| b.currency.code == "ETH")
990            .expect("ETH balance should exist");
991
992        // From test data: margin_balance: 100, available_funds: 99.999598, initial_margin: 0.000402
993        // total = margin_balance = 100
994        // free = available_funds = 99.999598
995        // locked = 100 - 99.999598 = 0.000402 (equals initial_margin)
996        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        // Verify account metadata
1001        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        // Verify ts_event matches server timestamp (us_out = 1687352432005000 microseconds)
1006        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); // from usOut
1026
1027        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        // timestamp 1766332040636 ms -> ns
1036        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        // Last trade is a buy with amount 106
1051        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        // Verify first bar
1080        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        // Verify last bar
1094        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        // Verify book metadata
1120        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        // Verify book has both sides
1125        assert!(book.has_bid(), "Book should have bids");
1126        assert!(book.has_ask(), "Book should have asks");
1127
1128        // Verify best bid using OrderBook methods
1129        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        // Verify best ask using OrderBook methods
1141        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        // Verify spread (best_ask - best_bid = 87003.0 - 87002.5 = 0.5)
1153        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        // Verify midpoint ((87003.0 + 87002.5) / 2 = 87002.75)
1160        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        // Verify level counts match input data
1167        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        // Verify depth limiting works (get top 5 levels)
1183        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        // Verify bids_as_map and asks_as_map
1195        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        // Verify specific prices exist in maps
1201        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        // Verify worst levels exist
1211        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        // Perpetuals are classified as "future" in Deribit API
1228        let cases = [
1229            ("BTC-PERPETUAL", "future", "BTC"),
1230            ("ETH-PERPETUAL", "future", "ETH"),
1231            ("SOL-PERPETUAL", "future", "SOL"),
1232            // Futures with expiry dates
1233            ("BTC-25MAR23", "future", "BTC"),
1234            ("BTC-5AUG23", "future", "BTC"), // Single digit day
1235            ("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            // Standard options: {CURRENCY}-{DMMMYY}-{STRIKE}-{C|P}
1252            ("BTC-25MAR23-420-C", "option", "BTC"),
1253            ("BTC-5AUG23-580-P", "option", "BTC"),
1254            ("ETH-28MAR25-4000-C", "option", "ETH"),
1255            // Linear option with decimal strike (d = decimal point)
1256            ("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        // Extract the data field from the notification
1293        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        // Verify deserialization
1302        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        // Test parsing to AccountState
1311        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        // Verify account state
1318        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        // Verify balances (should have 1 balance for USDT)
1323        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); // margin_balance
1327        assert_eq!(balance.free.as_f64(), 53.868247); // available_funds
1328
1329        // locked = total - free = 54.968258 - 53.868247 = 1.100011 (equals initial_margin)
1330        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        // Verify margins (should have 1 margin since initial_margin > 0)
1337        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}