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, InstrumentClass, OptionKind, OrderSide},
29    events::AccountState,
30    identifiers::{AccountId, InstrumentId, Symbol, TradeId},
31    instruments::{
32        CryptoFuture, CryptoFuturesSpread, CryptoOption, CryptoOptionSpread, CryptoPerpetual,
33        CurrencyPair, Instrument, any::InstrumentAny,
34    },
35    orderbook::OrderBook,
36    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
37};
38use rust_decimal::Decimal;
39use ustr::Ustr;
40
41use crate::{
42    common::{
43        consts::DERIBIT_VENUE,
44        enums::{DeribitOptionType, DeribitProductType},
45    },
46    http::models::{
47        DeribitAccountSummary, DeribitInstrument, DeribitOrderBook, DeribitPublicTrade,
48        DeribitTradingViewChartData,
49    },
50    websocket::messages::DeribitPortfolioMsg,
51};
52
53/// Parses a Deribit instrument ID into kind and currency for WebSocket channel subscription.
54///
55/// Deribit instrument naming conventions (per Deribit docs):
56/// - **Future**: `{CURRENCY}-{DMMMYY}` (e.g., "BTC-25MAR23", "BTC-5AUG23")
57/// - **Perpetual**: `{CURRENCY}-PERPETUAL` (e.g., "BTC-PERPETUAL")
58/// - **Option**: `{CURRENCY}-{DMMMYY}-{STRIKE}-{C|P}` (e.g., "BTC-25MAR23-420-C", "BTC-5AUG23-580-P")
59/// - **Linear Option**: `{BASE}_{QUOTE}-{DMMMYY}-{STRIKE}-{C|P}` (e.g., "XRP_USDC-30JUN23-0d625-C")
60///   - Note: `d` is used as decimal point for decimal strikes (0d625 = 0.625)
61/// - **Future combo**: `{CURRENCY}-FS-{LEG_A}_{LEG_B}` (e.g., "BTC-FS-19MAY26_PERP")
62/// - **Option combo**: `{CURRENCY}-{STRATEGY}-{DMMMYY}-{STRIKES}` (e.g., "BTC-CS-19MAY26-70000_75000",
63///   "BTC-STRG-29MAY26-72000_80000", "BTC-STRD-29MAY26-77000", "BTC-BOX-25DEC26-58000_60000")
64/// - **Spot**: `{BASE}_{QUOTE}` (e.g., "BTC_USDC")
65///
66/// Returns `(kind, currency)` tuple for `instrument.state.{kind}.{currency}` channel.
67///
68/// Valid kinds: `future`, `option`, `spot`, `future_combo`, `option_combo`, `any`
69/// Valid currencies: `BTC`, `ETH`, `USDC`, `USDT`, `EURR`, `any`
70#[must_use]
71pub fn parse_instrument_kind_currency(instrument_id: &InstrumentId) -> (String, String) {
72    let symbol = instrument_id.symbol.as_str();
73
74    // Determine kind from instrument name pattern
75    // Order matters: check most specific patterns first
76    let kind = if symbol.contains("PERPETUAL") {
77        "future" // Perpetuals are treated as futures in Deribit API
78    } else if symbol.ends_with("-C") || symbol.ends_with("-P") {
79        // Options end with -C (call) or -P (put)
80        "option"
81    } else if symbol.contains('_') && !symbol.contains('-') {
82        // Spot pairs have underscore but no dash (e.g., "BTC_USDC")
83        "spot"
84    } else if is_combo_symbol(symbol) {
85        // Combos have an alphabetic strategy code as the second segment.
86        // "FS" -> future spread (e.g., BTC-FS-19MAY26_PERP);
87        // any other alpha code -> option combo (CS, STRG, STRD, BOX, RR, ...).
88        match second_segment(symbol) {
89            Some("FS") => "future_combo",
90            _ => "option_combo",
91        }
92    } else {
93        // Default to future for expiry dates like "BTC-25MAR23"
94        "future"
95    };
96
97    // Extract currency (first part before '-' or '_')
98    // For most instruments, currency is the first segment
99    let currency = if let Some(idx) = symbol.find('-') {
100        // Futures, perpetuals, options: "BTC-..." → "BTC"
101        // Linear options: "XRP_USDC-..." → extract base currency "XRP"
102        let first_part = &symbol[..idx];
103        if let Some(underscore_idx) = first_part.find('_') {
104            first_part[..underscore_idx].to_string()
105        } else {
106            first_part.to_string()
107        }
108    } else if let Some(idx) = symbol.find('_') {
109        // Spot: "BTC_USDC" → "BTC"
110        symbol[..idx].to_string()
111    } else {
112        "any".to_string()
113    };
114
115    (kind.to_string(), currency)
116}
117
118/// Returns the segment of a Deribit symbol immediately after the currency.
119///
120/// For `BTC-FS-19MAY26_PERP` returns `Some("FS")`. For `BTC-PERPETUAL` returns
121/// `Some("PERPETUAL")`. Returns `None` for symbols without two `-`-delimited
122/// segments.
123fn second_segment(symbol: &str) -> Option<&str> {
124    let mut parts = symbol.split('-');
125    parts.next()?;
126    parts.next()
127}
128
129/// Returns `true` when the symbol matches a Deribit option- or future-combo pattern.
130///
131/// A combo has an alphabetic strategy code in the second segment that is not
132/// `PERPETUAL` and does not look like a date (date segments start with a digit,
133/// e.g., `25MAR23`).
134fn is_combo_symbol(symbol: &str) -> bool {
135    let Some(seg) = second_segment(symbol) else {
136        return false;
137    };
138
139    if seg.is_empty() || seg == "PERPETUAL" {
140        return false;
141    }
142    // Combo strategy codes are alphabetic; date segments start with a digit.
143    seg.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
144        && seg.chars().all(|c| c.is_ascii_alphabetic())
145}
146
147/// Extracts server timestamp from response and converts to UnixNanos.
148///
149/// # Errors
150///
151/// Returns an error if the server timestamp (us_out) is missing from the response.
152pub fn extract_server_timestamp(us_out: Option<u64>) -> anyhow::Result<UnixNanos> {
153    let us_out =
154        us_out.ok_or_else(|| anyhow::anyhow!("Missing server timestamp (us_out) in response"))?;
155    Ok(UnixNanos::from(us_out * NANOSECONDS_IN_MICROSECOND))
156}
157
158/// Parses a Deribit instrument into a Nautilus [`InstrumentAny`].
159///
160/// Returns `Ok(None)` for unsupported instrument types.
161///
162/// # Errors
163///
164/// Returns an error if:
165/// - Required fields are missing (e.g., strike price for options)
166/// - Timestamp conversion fails
167/// - Decimal conversion fails for fees
168pub fn parse_deribit_instrument_any(
169    instrument: &DeribitInstrument,
170    ts_init: UnixNanos,
171    ts_event: UnixNanos,
172) -> anyhow::Result<Option<InstrumentAny>> {
173    match instrument.kind {
174        DeribitProductType::Spot => parse_spot_instrument(instrument, ts_init, ts_event).map(Some),
175        DeribitProductType::Future => {
176            // Check if it's a perpetual
177            if instrument.instrument_name.as_str().contains("PERPETUAL") {
178                parse_perpetual_instrument(instrument, ts_init, ts_event).map(Some)
179            } else {
180                parse_future_instrument(instrument, ts_init, ts_event).map(Some)
181            }
182        }
183        DeribitProductType::Option => {
184            parse_option_instrument(instrument, ts_init, ts_event).map(Some)
185        }
186        DeribitProductType::FutureCombo => {
187            parse_future_combo_instrument(instrument, ts_init, ts_event).map(Some)
188        }
189        DeribitProductType::OptionCombo => {
190            parse_option_combo_instrument(instrument, ts_init, ts_event).map(Some)
191        }
192    }
193}
194
195/// Parses a spot instrument into a [`CurrencyPair`].
196fn parse_spot_instrument(
197    instrument: &DeribitInstrument,
198    ts_init: UnixNanos,
199    ts_event: UnixNanos,
200) -> anyhow::Result<InstrumentAny> {
201    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
202
203    let base_currency = Currency::get_or_create_crypto(instrument.base_currency);
204    let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
205
206    let price_increment = Price::from_decimal(instrument.tick_size)?;
207    let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
208    let min_quantity = Quantity::from_decimal(instrument.min_trade_amount)?;
209
210    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
211        .context("Failed to parse maker_commission")?;
212    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
213        .context("Failed to parse taker_commission")?;
214
215    let currency_pair = CurrencyPair::new(
216        instrument_id,
217        instrument.instrument_name.into(),
218        base_currency,
219        quote_currency,
220        price_increment.precision,
221        size_increment.precision,
222        price_increment,
223        size_increment,
224        None, // multiplier
225        None, // lot_size
226        None, // max_quantity
227        Some(min_quantity),
228        None, // max_notional
229        None, // min_notional
230        None, // max_price
231        None, // min_price
232        None, // margin_init
233        None, // margin_maint
234        Some(maker_fee),
235        Some(taker_fee),
236        None,
237        None,
238        ts_event,
239        ts_init,
240    );
241
242    Ok(InstrumentAny::CurrencyPair(currency_pair))
243}
244
245/// Parses a perpetual swap instrument into a [`CryptoPerpetual`].
246fn parse_perpetual_instrument(
247    instrument: &DeribitInstrument,
248    ts_init: UnixNanos,
249    ts_event: UnixNanos,
250) -> anyhow::Result<InstrumentAny> {
251    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
252
253    let base_currency = Currency::get_or_create_crypto(instrument.base_currency);
254    let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
255    let settlement_currency = instrument
256        .settlement_currency
257        .map_or(base_currency, Currency::get_or_create_crypto);
258
259    let is_inverse = instrument
260        .instrument_type
261        .as_ref()
262        .is_some_and(|t| t == "reversed");
263
264    let price_increment = Price::from_decimal(instrument.tick_size)?;
265    let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
266    let min_quantity = Quantity::from_decimal(instrument.min_trade_amount)?;
267
268    let multiplier = Some(deribit_amount_quantity_multiplier());
269    let lot_size = Some(size_increment);
270
271    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
272        .context("Failed to parse maker_commission")?;
273    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
274        .context("Failed to parse taker_commission")?;
275
276    let perpetual = CryptoPerpetual::new(
277        instrument_id,
278        instrument.instrument_name.into(),
279        base_currency,
280        quote_currency,
281        settlement_currency,
282        is_inverse,
283        price_increment.precision,
284        size_increment.precision,
285        price_increment,
286        size_increment,
287        multiplier,
288        lot_size,
289        None, // max_quantity - Deribit doesn't specify a hard max
290        Some(min_quantity),
291        None, // max_notional
292        None, // min_notional
293        None, // max_price
294        None, // min_price
295        None, // margin_init
296        None, // margin_maint
297        Some(maker_fee),
298        Some(taker_fee),
299        None,
300        None,
301        ts_event,
302        ts_init,
303    );
304
305    Ok(InstrumentAny::CryptoPerpetual(perpetual))
306}
307
308/// Parses a futures instrument into a [`CryptoFuture`].
309fn parse_future_instrument(
310    instrument: &DeribitInstrument,
311    ts_init: UnixNanos,
312    ts_event: UnixNanos,
313) -> anyhow::Result<InstrumentAny> {
314    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
315
316    let underlying = Currency::get_or_create_crypto(instrument.base_currency);
317    let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
318    let settlement_currency = instrument
319        .settlement_currency
320        .map_or(underlying, Currency::get_or_create_crypto);
321
322    let is_inverse = instrument
323        .instrument_type
324        .as_ref()
325        .is_some_and(|t| t == "reversed");
326
327    // Convert timestamps from milliseconds to nanoseconds
328    let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
329    let expiration_ns = instrument
330        .expiration_timestamp
331        .context("Missing expiration_timestamp for future")? as u64
332        * 1_000_000; // milliseconds to nanoseconds
333
334    let price_increment = Price::from_decimal(instrument.tick_size)?;
335    let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
336    let min_quantity = Quantity::from_decimal(instrument.min_trade_amount)?;
337
338    let multiplier = Some(deribit_amount_quantity_multiplier());
339    let lot_size = Some(size_increment); // Use min_trade_amount as lot size
340
341    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
342        .context("Failed to parse maker_commission")?;
343    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
344        .context("Failed to parse taker_commission")?;
345
346    let future = CryptoFuture::new(
347        instrument_id,
348        instrument.instrument_name.into(),
349        underlying,
350        quote_currency,
351        settlement_currency,
352        is_inverse,
353        UnixNanos::from(activation_ns),
354        UnixNanos::from(expiration_ns),
355        price_increment.precision,
356        size_increment.precision,
357        price_increment,
358        size_increment,
359        multiplier,
360        lot_size,
361        None, // max_quantity - Deribit doesn't specify a hard max
362        Some(min_quantity),
363        None, // max_notional
364        None, // min_notional
365        None, // max_price
366        None, // min_price
367        None, // margin_init
368        None, // margin_maint
369        Some(maker_fee),
370        Some(taker_fee),
371        None,
372        None,
373        ts_event,
374        ts_init,
375    );
376
377    Ok(InstrumentAny::CryptoFuture(future))
378}
379
380/// Parses an options instrument into a [`CryptoOption`].
381fn parse_option_instrument(
382    instrument: &DeribitInstrument,
383    ts_init: UnixNanos,
384    ts_event: UnixNanos,
385) -> anyhow::Result<InstrumentAny> {
386    let instrument_id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
387    let underlying = Currency::get_or_create_crypto(instrument.base_currency);
388    let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
389    let settlement = instrument
390        .settlement_currency
391        .unwrap_or(instrument.base_currency);
392    let settlement_currency = Currency::get_or_create_crypto(settlement);
393
394    // Determine if inverse (settled in base currency) or linear (settled in quote/USDC)
395    let is_inverse = instrument
396        .instrument_type
397        .as_ref()
398        .is_some_and(|t| t == "reversed");
399
400    // Determine option kind
401    let option_kind = match instrument.option_type {
402        Some(DeribitOptionType::Call) => OptionKind::Call,
403        Some(DeribitOptionType::Put) => OptionKind::Put,
404        None => anyhow::bail!("Missing option_type for option instrument"),
405    };
406
407    // Parse strike price
408    let strike = instrument.strike.context("Missing strike for option")?;
409    let strike_price = Price::from_decimal(strike)?;
410
411    // Convert timestamps from milliseconds to nanoseconds
412    let activation_ns = (instrument.creation_timestamp as u64) * 1_000_000;
413    let expiration_ns = instrument
414        .expiration_timestamp
415        .context("Missing expiration_timestamp for option")? as u64
416        * 1_000_000;
417
418    let price_increment = Price::from_decimal(instrument.tick_size)?;
419
420    let multiplier = deribit_amount_quantity_multiplier();
421    let lot_size = Quantity::from_decimal(instrument.min_trade_amount)?;
422    let min_trade_amount = Quantity::from_decimal(instrument.min_trade_amount)?;
423
424    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
425        .context("Failed to parse maker_commission")?;
426    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
427        .context("Failed to parse taker_commission")?;
428
429    let option = CryptoOption::new(
430        instrument_id,
431        instrument.instrument_name.into(),
432        underlying,
433        quote_currency,
434        settlement_currency,
435        is_inverse,
436        option_kind,
437        strike_price,
438        UnixNanos::from(activation_ns),
439        UnixNanos::from(expiration_ns),
440        price_increment.precision,
441        lot_size.precision,
442        price_increment,
443        lot_size,
444        Some(multiplier),
445        Some(lot_size),
446        None,
447        Some(min_trade_amount),
448        None,
449        None,
450        None,
451        None,
452        None,
453        None,
454        Some(maker_fee),
455        Some(taker_fee),
456        None,
457        None,
458        ts_event,
459        ts_init,
460    );
461
462    Ok(InstrumentAny::CryptoOption(option))
463}
464
465/// Parses a Deribit option combo into a [`CryptoOptionSpread`].
466fn parse_option_combo_instrument(
467    instrument: &DeribitInstrument,
468    ts_init: UnixNanos,
469    ts_event: UnixNanos,
470) -> anyhow::Result<InstrumentAny> {
471    let spread = build_spread_common(instrument, ts_init, ts_event)?;
472    let option_spread = CryptoOptionSpread::new(
473        spread.id,
474        spread.raw_symbol,
475        spread.underlying,
476        spread.quote_currency,
477        spread.settlement_currency,
478        spread.is_inverse,
479        spread.strategy_type,
480        spread.activation_ns,
481        spread.expiration_ns,
482        spread.price_precision,
483        spread.size_precision,
484        spread.price_increment,
485        spread.size_increment,
486        Some(spread.multiplier),
487        Some(spread.lot_size),
488        None,
489        Some(spread.size_increment),
490        None,
491        None,
492        None,
493        None,
494        None,
495        None,
496        Some(spread.maker_fee),
497        Some(spread.taker_fee),
498        None,
499        None,
500        ts_event,
501        ts_init,
502    );
503    Ok(InstrumentAny::CryptoOptionSpread(option_spread))
504}
505
506/// Parses a Deribit future combo into a [`CryptoFuturesSpread`].
507fn parse_future_combo_instrument(
508    instrument: &DeribitInstrument,
509    ts_init: UnixNanos,
510    ts_event: UnixNanos,
511) -> anyhow::Result<InstrumentAny> {
512    let spread = build_spread_common(instrument, ts_init, ts_event)?;
513    let futures_spread = CryptoFuturesSpread::new(
514        spread.id,
515        spread.raw_symbol,
516        spread.underlying,
517        spread.quote_currency,
518        spread.settlement_currency,
519        spread.is_inverse,
520        spread.strategy_type,
521        spread.activation_ns,
522        spread.expiration_ns,
523        spread.price_precision,
524        spread.size_precision,
525        spread.price_increment,
526        spread.size_increment,
527        Some(spread.multiplier),
528        Some(spread.lot_size),
529        None,
530        Some(spread.size_increment),
531        None,
532        None,
533        None,
534        None,
535        None,
536        None,
537        Some(spread.maker_fee),
538        Some(spread.taker_fee),
539        None,
540        None,
541        ts_event,
542        ts_init,
543    );
544    Ok(InstrumentAny::CryptoFuturesSpread(futures_spread))
545}
546
547/// Fields shared by [`CryptoOptionSpread`] and [`CryptoFuturesSpread`] construction
548/// from a Deribit combo instrument response.
549struct DeribitSpreadCommon {
550    id: InstrumentId,
551    raw_symbol: Symbol,
552    underlying: Currency,
553    quote_currency: Currency,
554    settlement_currency: Currency,
555    is_inverse: bool,
556    strategy_type: Ustr,
557    activation_ns: UnixNanos,
558    expiration_ns: UnixNanos,
559    price_precision: u8,
560    price_increment: Price,
561    size_precision: u8,
562    size_increment: Quantity,
563    multiplier: Quantity,
564    lot_size: Quantity,
565    maker_fee: Decimal,
566    taker_fee: Decimal,
567}
568
569fn build_spread_common(
570    instrument: &DeribitInstrument,
571    _ts_init: UnixNanos,
572    _ts_event: UnixNanos,
573) -> anyhow::Result<DeribitSpreadCommon> {
574    let id = InstrumentId::new(Symbol::new(instrument.instrument_name), *DERIBIT_VENUE);
575    let raw_symbol = Symbol::new(instrument.instrument_name);
576    let underlying = Currency::get_or_create_crypto(instrument.base_currency);
577    let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency);
578    let settlement_currency = instrument
579        .settlement_currency
580        .map_or(underlying, Currency::get_or_create_crypto);
581    let is_inverse = instrument
582        .instrument_type
583        .as_ref()
584        .is_some_and(|t| t == "reversed");
585    let strategy_type = second_segment(instrument.instrument_name.as_str())
586        .map_or_else(|| Ustr::from("SPREAD"), Ustr::from);
587
588    let activation_ns = UnixNanos::from((instrument.creation_timestamp as u64) * 1_000_000);
589    let expiration_ns = UnixNanos::from(
590        instrument
591            .expiration_timestamp
592            .context("Missing expiration_timestamp for combo")? as u64
593            * 1_000_000,
594    );
595
596    let price_increment = Price::from_decimal(instrument.tick_size)?;
597    let size_increment = Quantity::from_decimal(instrument.min_trade_amount)?;
598    let multiplier = deribit_amount_quantity_multiplier();
599
600    let maker_fee = Decimal::from_str(&instrument.maker_commission.to_string())
601        .context("Failed to parse maker_commission")?;
602    let taker_fee = Decimal::from_str(&instrument.taker_commission.to_string())
603        .context("Failed to parse taker_commission")?;
604
605    Ok(DeribitSpreadCommon {
606        id,
607        raw_symbol,
608        underlying,
609        quote_currency,
610        settlement_currency,
611        is_inverse,
612        strategy_type,
613        activation_ns,
614        expiration_ns,
615        price_precision: price_increment.precision,
616        price_increment,
617        size_precision: size_increment.precision,
618        size_increment,
619        multiplier,
620        lot_size: size_increment,
621        maker_fee,
622        taker_fee,
623    })
624}
625
626fn deribit_amount_quantity_multiplier() -> Quantity {
627    // Deribit quantities use `amount`; `contract_size` converts amount to contract count
628    Quantity::from(1)
629}
630
631/// Parses Deribit account summaries into a Nautilus [`AccountState`].
632///
633/// Processes multiple currency summaries and creates balance entries for each currency.
634///
635/// # Errors
636///
637/// Returns an error if:
638/// - Money conversion fails for any balance field
639/// - Decimal conversion fails for margin values
640pub fn parse_account_state(
641    summaries: &[DeribitAccountSummary],
642    account_id: AccountId,
643    ts_init: UnixNanos,
644    ts_event: UnixNanos,
645) -> anyhow::Result<AccountState> {
646    let mut balances = Vec::new();
647    let mut margins = Vec::new();
648
649    // Parse each currency summary
650    for summary in summaries {
651        let ccy_str = summary.currency.as_str().trim();
652
653        // Skip balances with empty currency codes
654        if ccy_str.is_empty() {
655            log::debug!("Skipping balance detail with empty currency code | raw_data={summary:?}");
656            continue;
657        }
658
659        let currency = Currency::get_or_create_crypto_with_context(
660            ccy_str,
661            Some("DERIBIT - Parsing account state"),
662        );
663
664        // Segregated mode: `margin_balance` and `available_funds` are per-currency scoped.
665        // Cross-margin mode: both are the cross-collateral portfolio value re-denominated
666        // in this currency, summing them across currencies N-fold overcounts the same value.
667        // Use `equity` (actual per-currency holdings) for total and `available_withdrawal_funds`
668        // (per-currency withdrawable, ~ equity minus fee buffer) for free.
669        //
670        // Trade-off: in cross-margin, the risk engine reads `balance.free` as buying power
671        // for new orders (see `Account::balance_free`), so this is conservative versus the
672        // venue-reported `available_funds` which includes cross-collateral. Preserving that
673        // value would require breaking the `total = locked + free` invariant or re-introducing
674        // the cross-denominated overcount, so per-currency consistency wins here.
675        let is_cross_margin = summary.cross_collateral_enabled.unwrap_or(false);
676        let (total, free) = if is_cross_margin {
677            (
678                summary.equity,
679                summary.available_withdrawal_funds.unwrap_or(Decimal::ZERO),
680            )
681        } else {
682            (summary.margin_balance, summary.available_funds)
683        };
684        let balance = AccountBalance::from_total_and_free(total, free, currency)?;
685        balances.push(balance);
686
687        // Parse margin balances if present
688        if let (Some(initial_margin), Some(maintenance_margin)) =
689            (summary.initial_margin, summary.maintenance_margin)
690            && (!initial_margin.is_zero() || !maintenance_margin.is_zero())
691        {
692            let initial = Money::from_decimal(initial_margin, currency)?;
693            let maintenance = Money::from_decimal(maintenance_margin, currency)?;
694            // Deribit reports cross-margin per collateral currency; emit as an
695            // account-wide entry keyed by that currency.
696            margins.push(MarginBalance::new(initial, maintenance, None));
697        }
698    }
699
700    // Ensure at least one balance exists (Nautilus requires non-empty balances)
701    if balances.is_empty() {
702        let zero_currency = Currency::USD();
703        let zero_money = Money::zero(zero_currency);
704        let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
705        balances.push(zero_balance);
706    }
707
708    let account_type = AccountType::Margin;
709    let is_reported = true;
710
711    Ok(AccountState::new(
712        account_id,
713        account_type,
714        balances,
715        margins,
716        is_reported,
717        UUID4::new(),
718        ts_event,
719        ts_init,
720        None,
721    ))
722}
723
724/// Parses a Deribit WebSocket portfolio message into a Nautilus [`AccountState`].
725///
726/// This function converts real-time portfolio updates from the `user.portfolio.{currency}`
727/// subscription channel into Nautilus account state events.
728///
729/// # Returns
730///
731/// An `AccountState` containing balances and margin information.
732///
733/// # Errors
734///
735/// Returns an error if Money conversion fails for any balance field.
736pub fn parse_portfolio_to_account_state(
737    portfolio: &DeribitPortfolioMsg,
738    account_id: AccountId,
739    ts_init: UnixNanos,
740) -> anyhow::Result<AccountState> {
741    let ccy_str = portfolio.currency.trim();
742
743    // Skip empty currency codes
744    if ccy_str.is_empty() {
745        anyhow::bail!("Portfolio message has empty currency code");
746    }
747
748    let currency = Currency::get_or_create_crypto_with_context(
749        ccy_str,
750        Some("DERIBIT - Parsing portfolio update"),
751    );
752
753    // See `parse_account_state` for the rationale: cross-margin uses equity and
754    // `available_withdrawal_funds` (per-currency consistency, conservative free balance);
755    // segregated uses `margin_balance` and `available_funds` (per-currency scoped).
756    let is_cross_margin = portfolio.cross_collateral_enabled.unwrap_or(false);
757    let (total, free) = if is_cross_margin {
758        (
759            portfolio.equity,
760            portfolio
761                .available_withdrawal_funds
762                .unwrap_or(Decimal::ZERO),
763        )
764    } else {
765        (portfolio.margin_balance, portfolio.available_funds)
766    };
767    let balance = AccountBalance::from_total_and_free(total, free, currency)?;
768    let balances = vec![balance];
769
770    // Parse margin balances
771    let mut margins = Vec::new();
772    let initial_margin = portfolio.initial_margin;
773    let maintenance_margin = portfolio.maintenance_margin;
774
775    // Only create margin balance if there are actual margin requirements
776    if !initial_margin.is_zero() || !maintenance_margin.is_zero() {
777        let initial = Money::from_decimal(initial_margin, currency)?;
778        let maintenance = Money::from_decimal(maintenance_margin, currency)?;
779        // Deribit reports cross-margin per collateral currency; emit as an
780        // account-wide entry keyed by that currency.
781        margins.push(MarginBalance::new(initial, maintenance, None));
782    }
783
784    let account_type = AccountType::Margin;
785    let is_reported = true;
786
787    Ok(AccountState::new(
788        account_id,
789        account_type,
790        balances,
791        margins,
792        is_reported,
793        UUID4::new(),
794        ts_init, // Use ts_init for both since we don't have server timestamp in portfolio msg
795        ts_init,
796        None,
797    ))
798}
799
800/// Builds a [`TradeId`] for a Deribit public trade, prefixing the venue ID with
801/// the trade's provenance when applicable.
802///
803/// Strategies that need to distinguish RFQ-, block-, or combo-origin trades from
804/// plain trades can pattern-match the prefix on the resulting `TradeId`. The
805/// raw Deribit `trade_id` is preserved after the prefix so correlation back to
806/// the venue is straightforward via a prefix strip.
807///
808/// Precedence (most specific wins): `RFQ-` > `BLK-` > `COMBO-` > unprefixed.
809/// Block RFQs are themselves block trades on Deribit, so the `RFQ-` tag is the
810/// stronger signal; combo trades executed as blocks are tagged `BLK-` since
811/// the block flow is the more important reconciliation signal.
812#[must_use]
813pub fn build_public_trade_id(
814    trade_id: &str,
815    block_rfq_id: Option<i64>,
816    block_trade_id: Option<&str>,
817    combo_id: Option<&str>,
818) -> TradeId {
819    if block_rfq_id.is_some() {
820        TradeId::new(format!("RFQ-{trade_id}"))
821    } else if block_trade_id.is_some() {
822        TradeId::new(format!("BLK-{trade_id}"))
823    } else if combo_id.is_some() {
824        TradeId::new(format!("COMBO-{trade_id}"))
825    } else {
826        TradeId::new(trade_id)
827    }
828}
829
830// Parses a Deribit public trade into a Nautilus [`TradeTick`].
831///
832/// # Errors
833///
834/// Returns an error if:
835/// - The direction is not "buy" or "sell"
836/// - Decimal conversion fails for price or size
837pub fn parse_trade_tick(
838    trade: &DeribitPublicTrade,
839    instrument_id: InstrumentId,
840    price_precision: u8,
841    size_precision: u8,
842    ts_init: UnixNanos,
843) -> anyhow::Result<TradeTick> {
844    // Parse aggressor side from direction
845    let aggressor_side = match trade.direction.as_str() {
846        "buy" => AggressorSide::Buyer,
847        "sell" => AggressorSide::Seller,
848        other => anyhow::bail!("Invalid trade direction: {other}"),
849    };
850    let price = Price::from_decimal_dp(trade.price, price_precision)?;
851    let size = Quantity::from_decimal_dp(trade.amount, size_precision)?;
852    let ts_event = UnixNanos::from((trade.timestamp as u64) * NANOSECONDS_IN_MILLISECOND);
853    let trade_id = build_public_trade_id(
854        &trade.trade_id,
855        trade.block_rfq_id,
856        trade.block_trade_id.as_deref(),
857        trade.combo_id.as_deref(),
858    );
859
860    Ok(TradeTick::new(
861        instrument_id,
862        price,
863        size,
864        aggressor_side,
865        trade_id,
866        ts_event,
867        ts_init,
868    ))
869}
870
871/// Returns true when `Bar.volume` should be populated from the chart `cost` field (USD) instead
872/// of the `volume` field (base currency).
873///
874/// Deribit's `trades.{instrument}` channel reports each trade's `amount` in USD for inverse
875/// perpetuals and inverse futures, and in the underlying base currency for options and linear
876/// futures. To keep `Bar.volume` and `TradeTick.size` on a single unit per instrument, route
877/// inverse non-option products through `cost`. Options and option spreads stay on `volume` even
878/// when flagged `is_inverse`, because their trade `amount` is reported in base currency.
879///
880/// Reference: <https://docs.deribit.com/api-reference/market-data/public-get_last_trades_by_currency>
881#[must_use]
882pub fn use_cost_for_bar_volume(instrument: &InstrumentAny) -> bool {
883    if !instrument.is_inverse() {
884        return false;
885    }
886    !matches!(
887        instrument.instrument_class(),
888        InstrumentClass::Option | InstrumentClass::OptionSpread
889    )
890}
891
892/// Parses Deribit TradingView chart data into Nautilus [`Bar`]s.
893///
894/// Converts OHLCV arrays from the `public/get_tradingview_chart_data` endpoint
895/// into a vector of [`Bar`] objects.
896///
897/// When `use_cost_for_volume` is true, `Bar.volume` is populated from `chart_data.cost` (USD)
898/// instead of `chart_data.volume` (base currency) — see [`use_cost_for_bar_volume`].
899///
900/// # Errors
901///
902/// Returns an error if:
903/// - The status is not "ok"
904/// - Array lengths are inconsistent
905/// - No data points are present
906pub fn parse_bars(
907    chart_data: &DeribitTradingViewChartData,
908    bar_type: BarType,
909    price_precision: u8,
910    size_precision: u8,
911    use_cost_for_volume: bool,
912    ts_init: UnixNanos,
913) -> anyhow::Result<Vec<Bar>> {
914    // Check status
915    if chart_data.status != "ok" {
916        anyhow::bail!(
917            "Chart data status is '{}', expected 'ok'",
918            chart_data.status
919        );
920    }
921
922    let num_bars = chart_data.ticks.len();
923
924    // Verify array lengths match
925    anyhow::ensure!(
926        chart_data.open.len() == num_bars
927            && chart_data.high.len() == num_bars
928            && chart_data.low.len() == num_bars
929            && chart_data.close.len() == num_bars
930            && chart_data.volume.len() == num_bars
931            && chart_data.cost.len() == num_bars,
932        "Inconsistent array lengths in chart data"
933    );
934
935    if num_bars == 0 {
936        return Ok(Vec::new());
937    }
938
939    let mut bars = Vec::with_capacity(num_bars);
940
941    for i in 0..num_bars {
942        let open = Price::new_checked(chart_data.open[i], price_precision)
943            .with_context(|| format!("Invalid open price at index {i}"))?;
944        let high = Price::new_checked(chart_data.high[i], price_precision)
945            .with_context(|| format!("Invalid high price at index {i}"))?;
946        let low = Price::new_checked(chart_data.low[i], price_precision)
947            .with_context(|| format!("Invalid low price at index {i}"))?;
948        let close = Price::new_checked(chart_data.close[i], price_precision)
949            .with_context(|| format!("Invalid close price at index {i}"))?;
950        let raw_volume = if use_cost_for_volume {
951            chart_data.cost[i]
952        } else {
953            chart_data.volume[i]
954        };
955        let volume = Quantity::new_checked(raw_volume, size_precision)
956            .with_context(|| format!("Invalid volume at index {i}"))?;
957
958        // Convert timestamp from milliseconds to nanoseconds
959        let ts_event = UnixNanos::from((chart_data.ticks[i] as u64) * NANOSECONDS_IN_MILLISECOND);
960
961        let bar = Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
962            .with_context(|| format!("Invalid OHLC bar at index {i}"))?;
963        bars.push(bar);
964    }
965
966    Ok(bars)
967}
968
969/// Parses Deribit order book data into a Nautilus [`OrderBook`].
970///
971/// Converts bids and asks from the `public/get_order_book` endpoint
972/// into an L2_MBP order book.
973///
974/// # Errors
975///
976/// Returns an error if order book creation fails.
977pub fn parse_order_book(
978    order_book_data: &DeribitOrderBook,
979    instrument_id: InstrumentId,
980    price_precision: u8,
981    size_precision: u8,
982    ts_init: UnixNanos,
983) -> anyhow::Result<OrderBook> {
984    let ts_event = UnixNanos::from((order_book_data.timestamp as u64) * NANOSECONDS_IN_MILLISECOND);
985    let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
986
987    for (idx, [price, amount]) in order_book_data.bids.iter().enumerate() {
988        let order = BookOrder::new(
989            OrderSide::Buy,
990            Price::new(*price, price_precision),
991            Quantity::new(*amount, size_precision),
992            idx as u64,
993        );
994        book.add(order, 0, idx as u64, ts_event);
995    }
996
997    let bids_len = order_book_data.bids.len();
998    for (idx, [price, amount]) in order_book_data.asks.iter().enumerate() {
999        let order = BookOrder::new(
1000            OrderSide::Sell,
1001            Price::new(*price, price_precision),
1002            Quantity::new(*amount, size_precision),
1003            (bids_len + idx) as u64,
1004        );
1005        book.add(order, 0, (bids_len + idx) as u64, ts_event);
1006    }
1007
1008    book.ts_last = ts_init;
1009
1010    Ok(book)
1011}
1012
1013/// Converts a Nautilus BarType to a Deribit chart resolution.
1014///
1015/// Deribit resolutions: "1", "3", "5", "10", "15", "30", "60", "120", "180", "360", "720", "1D"
1016pub fn bar_spec_to_resolution(bar_type: &BarType) -> String {
1017    use nautilus_model::enums::BarAggregation;
1018
1019    let spec = bar_type.spec();
1020    match spec.aggregation {
1021        BarAggregation::Minute => {
1022            let step = spec.step.get();
1023            // Map to nearest Deribit resolution
1024            match step {
1025                1 => "1".to_string(),
1026                2..=3 => "3".to_string(),
1027                4..=5 => "5".to_string(),
1028                6..=10 => "10".to_string(),
1029                11..=15 => "15".to_string(),
1030                16..=30 => "30".to_string(),
1031                31..=60 => "60".to_string(),
1032                61..=120 => "120".to_string(),
1033                121..=180 => "180".to_string(),
1034                181..=360 => "360".to_string(),
1035                361..=720 => "720".to_string(),
1036                _ => "1D".to_string(),
1037            }
1038        }
1039        BarAggregation::Hour => {
1040            let step = spec.step.get();
1041            match step {
1042                1 => "60".to_string(),
1043                2 => "120".to_string(),
1044                3 => "180".to_string(),
1045                4..=6 => "360".to_string(),
1046                7..=12 => "720".to_string(),
1047                _ => "1D".to_string(),
1048            }
1049        }
1050        BarAggregation::Day => "1D".to_string(),
1051        _ => {
1052            log::warn!(
1053                "Unsupported bar aggregation {:?}, defaulting to 1 minute",
1054                spec.aggregation
1055            );
1056            "1".to_string()
1057        }
1058    }
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063    use nautilus_model::{instruments::Instrument, types::Money};
1064    use rstest::rstest;
1065    use rust_decimal_macros::dec;
1066
1067    use super::*;
1068    use crate::{
1069        common::testing::load_test_json,
1070        http::models::{
1071            DeribitAccountSummariesResponse, DeribitJsonRpcResponse, DeribitTradesResponse,
1072        },
1073    };
1074
1075    #[rstest]
1076    fn test_parse_perpetual_instrument() {
1077        let json_data = load_test_json("http_get_instrument.json");
1078        let response: DeribitJsonRpcResponse<DeribitInstrument> =
1079            serde_json::from_str(&json_data).unwrap();
1080        let deribit_inst = response.result.expect("Test data must have result");
1081
1082        let instrument_any =
1083            parse_deribit_instrument_any(&deribit_inst, UnixNanos::default(), UnixNanos::default())
1084                .unwrap();
1085        let instrument = instrument_any.expect("Should parse perpetual instrument");
1086
1087        let InstrumentAny::CryptoPerpetual(perpetual) = instrument else {
1088            panic!("Expected CryptoPerpetual, was {instrument:?}");
1089        };
1090        assert_eq!(perpetual.id(), InstrumentId::from("BTC-PERPETUAL.DERIBIT"));
1091        assert_eq!(perpetual.raw_symbol(), Symbol::from("BTC-PERPETUAL"));
1092        assert_eq!(perpetual.base_currency().unwrap().code, "BTC");
1093        assert_eq!(perpetual.quote_currency().code, "USD");
1094        assert_eq!(perpetual.settlement_currency().code, "BTC");
1095        assert!(perpetual.is_inverse());
1096        assert_eq!(perpetual.price_precision(), 1);
1097        assert_eq!(perpetual.size_precision(), 0);
1098        assert_eq!(perpetual.price_increment(), Price::from("0.5"));
1099        assert_eq!(perpetual.size_increment(), Quantity::from("10"));
1100        assert_eq!(perpetual.multiplier(), Quantity::from("1"));
1101        assert_eq!(
1102            perpetual.calculate_notional_value(
1103                Quantity::from("10"),
1104                Price::from("50000"),
1105                Some(false)
1106            ),
1107            Money::from("0.0002 BTC")
1108        );
1109        assert_eq!(
1110            perpetual.calculate_notional_value(
1111                Quantity::from("10"),
1112                Price::from("50000"),
1113                Some(true)
1114            ),
1115            Money::from("10 USD")
1116        );
1117        assert_eq!(perpetual.lot_size(), Some(Quantity::from("10")));
1118        assert_eq!(perpetual.maker_fee(), dec!(0));
1119        assert_eq!(perpetual.taker_fee(), dec!(0.0005));
1120        assert_eq!(perpetual.max_quantity(), None);
1121        assert_eq!(perpetual.min_quantity(), Some(Quantity::from("10")));
1122    }
1123
1124    #[rstest]
1125    fn test_parse_future_instrument() {
1126        let json_data = load_test_json("http_get_instruments.json");
1127        let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1128            serde_json::from_str(&json_data).unwrap();
1129        let instruments = response.result.expect("Test data must have result");
1130        let deribit_inst = instruments
1131            .iter()
1132            .find(|i| i.instrument_name.as_str() == "BTC-27DEC24")
1133            .expect("Test data must contain BTC-27DEC24");
1134
1135        let instrument_any =
1136            parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
1137                .unwrap();
1138        let instrument = instrument_any.expect("Should parse future instrument");
1139
1140        let InstrumentAny::CryptoFuture(future) = instrument else {
1141            panic!("Expected CryptoFuture, was {instrument:?}");
1142        };
1143        assert_eq!(future.id(), InstrumentId::from("BTC-27DEC24.DERIBIT"));
1144        assert_eq!(future.raw_symbol(), Symbol::from("BTC-27DEC24"));
1145        assert_eq!(future.underlying().unwrap(), "BTC");
1146        assert_eq!(future.quote_currency().code, "USD");
1147        assert_eq!(future.settlement_currency().code, "BTC");
1148        assert!(future.is_inverse());
1149
1150        // Verify timestamps
1151        assert_eq!(
1152            future.activation_ns(),
1153            Some(UnixNanos::from(1719561600000_u64 * 1_000_000))
1154        );
1155        assert_eq!(
1156            future.expiration_ns(),
1157            Some(UnixNanos::from(1735300800000_u64 * 1_000_000))
1158        );
1159        assert_eq!(future.price_precision(), 1);
1160        assert_eq!(future.size_precision(), 0);
1161        assert_eq!(future.price_increment(), Price::from("0.5"));
1162        assert_eq!(future.size_increment(), Quantity::from("10"));
1163        assert_eq!(future.multiplier(), Quantity::from("1"));
1164        assert_eq!(future.lot_size(), Some(Quantity::from("10")));
1165        assert_eq!(future.maker_fee, dec!(0));
1166        assert_eq!(future.taker_fee, dec!(0.0005));
1167    }
1168
1169    #[rstest]
1170    fn test_parse_option_instrument() {
1171        let json_data = load_test_json("http_get_instruments.json");
1172        let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1173            serde_json::from_str(&json_data).unwrap();
1174        let instruments = response.result.expect("Test data must have result");
1175        let deribit_inst = instruments
1176            .iter()
1177            .find(|i| i.instrument_name.as_str() == "BTC-27DEC24-100000-C")
1178            .expect("Test data must contain BTC-27DEC24-100000-C");
1179
1180        let instrument_any =
1181            parse_deribit_instrument_any(deribit_inst, UnixNanos::default(), UnixNanos::default())
1182                .unwrap();
1183        let instrument = instrument_any.expect("Should parse option instrument");
1184
1185        // Verify it's a CryptoOption
1186        let InstrumentAny::CryptoOption(option) = instrument else {
1187            panic!("Expected CryptoOption, was {instrument:?}");
1188        };
1189
1190        assert_eq!(
1191            option.id(),
1192            InstrumentId::from("BTC-27DEC24-100000-C.DERIBIT")
1193        );
1194        assert_eq!(option.raw_symbol(), Symbol::from("BTC-27DEC24-100000-C"));
1195        assert_eq!(option.underlying.code.as_str(), "BTC");
1196        assert_eq!(option.quote_currency.code.as_str(), "BTC");
1197        assert_eq!(option.settlement_currency.code.as_str(), "BTC");
1198        assert!(option.is_inverse);
1199        assert_eq!(option.option_kind, OptionKind::Call);
1200        assert_eq!(option.strike_price, Price::from("100000"));
1201        assert_eq!(
1202            option.activation_ns,
1203            UnixNanos::from(1719561600000_u64 * 1_000_000)
1204        );
1205        assert_eq!(
1206            option.expiration_ns,
1207            UnixNanos::from(1735300800000_u64 * 1_000_000)
1208        );
1209        assert_eq!(option.price_precision, 4);
1210        assert_eq!(option.price_increment, Price::from("0.0005"));
1211        assert_eq!(option.size_precision, 1);
1212        assert_eq!(option.size_increment, Quantity::from("0.1"));
1213        assert_eq!(option.multiplier, Quantity::from("1"));
1214        assert_eq!(option.lot_size, Quantity::from("0.1"));
1215        assert_eq!(option.maker_fee, dec!(0.0003));
1216        assert_eq!(option.taker_fee, dec!(0.0003));
1217    }
1218
1219    #[rstest]
1220    fn test_parse_account_state_with_positions() {
1221        let json_data = load_test_json("http_get_account_summaries.json");
1222        let response: DeribitJsonRpcResponse<DeribitAccountSummariesResponse> =
1223            serde_json::from_str(&json_data).unwrap();
1224        let result = response.result.expect("Test data must have result");
1225
1226        let account_id = AccountId::from("DERIBIT-001");
1227
1228        // Extract server timestamp from response
1229        let ts_event =
1230            extract_server_timestamp(response.us_out).expect("Test data must have us_out");
1231        let ts_init = UnixNanos::default();
1232
1233        let account_state = parse_account_state(&result.summaries, account_id, ts_init, ts_event)
1234            .expect("Should parse account state");
1235
1236        // Verify we got 2 currencies (BTC and ETH)
1237        assert_eq!(account_state.balances.len(), 2);
1238
1239        // Test BTC balance (has open positions with unrealized PnL)
1240        let btc_balance = account_state
1241            .balances
1242            .iter()
1243            .find(|b| b.currency.code == "BTC")
1244            .expect("BTC balance should exist");
1245
1246        // From test data:
1247        // margin_balance: 302.62729214, available_funds: 301.38059622
1248        // initial_margin: 1.24669592
1249        //
1250        // Using margin_balance:
1251        // total = margin_balance = 302.62729214
1252        // free = available_funds = 301.38059622
1253        // locked = total - free = 302.62729214 - 301.38059622 = 1.24669592 (exactly initial_margin!)
1254        assert_eq!(btc_balance.total.as_f64(), 302.62729214);
1255        assert_eq!(btc_balance.free.as_f64(), 301.38059622);
1256
1257        // Verify locked equals initial_margin exactly
1258        let locked = btc_balance.locked.as_f64();
1259        assert!(
1260            locked > 0.0,
1261            "Locked should be positive when positions exist"
1262        );
1263        assert!(
1264            (locked - 1.24669592).abs() < 0.0001,
1265            "Locked ({locked}) should equal initial_margin (1.24669592)"
1266        );
1267
1268        // Test ETH balance (no positions)
1269        let eth_balance = account_state
1270            .balances
1271            .iter()
1272            .find(|b| b.currency.code == "ETH")
1273            .expect("ETH balance should exist");
1274
1275        // From test data: margin_balance: 100, available_funds: 99.999598, initial_margin: 0.000402
1276        // total = margin_balance = 100
1277        // free = available_funds = 99.999598
1278        // locked = 100 - 99.999598 = 0.000402 (equals initial_margin)
1279        assert_eq!(eth_balance.total.as_f64(), 100.0);
1280        assert_eq!(eth_balance.free.as_f64(), 99.999598);
1281        assert_eq!(eth_balance.locked.as_f64(), 0.000402);
1282
1283        // Verify account metadata
1284        assert_eq!(account_state.account_id, account_id);
1285        assert_eq!(account_state.account_type, AccountType::Margin);
1286        assert!(account_state.is_reported);
1287
1288        // Verify ts_event matches server timestamp (us_out = 1687352432005000 microseconds)
1289        let expected_ts_event = UnixNanos::from(1687352432005000_u64 * NANOSECONDS_IN_MICROSECOND);
1290        assert_eq!(
1291            account_state.ts_event, expected_ts_event,
1292            "ts_event should match server timestamp from response"
1293        );
1294    }
1295
1296    #[rstest]
1297    fn test_parse_account_state_cross_margin() {
1298        let json_data = load_test_json("http_get_account_summaries_cross_margin.json");
1299        let response: DeribitJsonRpcResponse<DeribitAccountSummariesResponse> =
1300            serde_json::from_str(&json_data).unwrap();
1301        let result = response.result.expect("Test data must have result");
1302
1303        let account_id = AccountId::from("DERIBIT-001");
1304        let ts_event =
1305            extract_server_timestamp(response.us_out).expect("Test data must have us_out");
1306        let ts_init = UnixNanos::default();
1307
1308        let account_state = parse_account_state(&result.summaries, account_id, ts_init, ts_event)
1309            .expect("Should parse cross-margin account state");
1310
1311        // All 4 currencies in fixture have cross_collateral_enabled=true (cross_pm mode)
1312        // BTC: equity=2.288e-5 (actual holding), margin_balance=3.1639e-4 (portfolio-wide)
1313        // USDT: equity=23.61869 (actual holding), margin_balance=25.713074 (portfolio-wide)
1314        // SOL: equity=0 (no holding), margin_balance=0.29488918 (phantom)
1315        // ETH: equity=8.6e-5 (small holding), margin_balance=0.01089 (portfolio-wide)
1316
1317        assert_eq!(account_state.balances.len(), 4);
1318
1319        // BTC: total should be equity (2.288e-5), NOT margin_balance (3.1639e-4)
1320        let btc = account_state
1321            .balances
1322            .iter()
1323            .find(|b| b.currency.code == "BTC")
1324            .expect("BTC balance should exist");
1325        assert_eq!(btc.total.as_f64(), 2.288e-5);
1326        assert_eq!(btc.free.as_f64(), 2.288e-5); // available_withdrawal_funds
1327        assert_eq!(btc.locked.as_f64(), 0.0);
1328
1329        // USDT: total should be equity (23.61869), NOT margin_balance (25.713074)
1330        let usdt = account_state
1331            .balances
1332            .iter()
1333            .find(|b| b.currency.code == "USDT")
1334            .expect("USDT balance should exist");
1335        assert_eq!(usdt.total.as_f64(), 23.61869);
1336        assert_eq!(usdt.free.as_f64(), 23.618645); // available_withdrawal_funds
1337        let usdt_locked = usdt.locked.as_f64();
1338        assert!(
1339            (usdt_locked - 0.000045).abs() < 0.001,
1340            "USDT locked ({usdt_locked}) should be close to 0.000045"
1341        );
1342
1343        // SOL: equity=0, should produce zero balance (not margin_balance=0.29488918)
1344        let sol = account_state
1345            .balances
1346            .iter()
1347            .find(|b| b.currency.code == "SOL")
1348            .expect("SOL balance should exist");
1349        assert_eq!(sol.total.as_f64(), 0.0);
1350        assert_eq!(sol.free.as_f64(), 0.0);
1351
1352        // ETH: equity=8.6e-5 (small holding), NOT margin_balance=0.01089 (portfolio-wide)
1353        let eth = account_state
1354            .balances
1355            .iter()
1356            .find(|b| b.currency.code == "ETH")
1357            .expect("ETH balance should exist");
1358        assert_eq!(eth.total.as_f64(), 8.6e-5);
1359        assert_eq!(eth.free.as_f64(), 8.5e-5); // available_withdrawal_funds
1360
1361        // Verify account metadata
1362        assert_eq!(account_state.account_type, AccountType::Margin);
1363        assert!(account_state.is_reported);
1364    }
1365
1366    #[rstest]
1367    fn test_parse_trade_tick_sell() {
1368        let json_data = load_test_json("http_get_last_trades.json");
1369        let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1370            serde_json::from_str(&json_data).unwrap();
1371        let result = response.result.expect("Test data must have result");
1372
1373        assert!(result.has_more, "has_more should be true");
1374        assert_eq!(result.trades.len(), 10, "Should have 10 trades");
1375
1376        let raw_trade = &result.trades[0];
1377        let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
1378        let ts_init = UnixNanos::from(1766335632425576_u64 * 1000); // from usOut
1379
1380        let trade = parse_trade_tick(raw_trade, instrument_id, 1, 0, ts_init)
1381            .expect("Should parse trade tick");
1382
1383        assert_eq!(trade.instrument_id, instrument_id);
1384        assert_eq!(trade.price, Price::from("2968.3"));
1385        assert_eq!(trade.size, Quantity::from("1"));
1386        assert_eq!(trade.aggressor_side, AggressorSide::Seller);
1387        assert_eq!(trade.trade_id, TradeId::new("ETH-284830839"));
1388        // timestamp 1766332040636 ms -> ns
1389        assert_eq!(
1390            trade.ts_event,
1391            UnixNanos::from(1766332040636_u64 * 1_000_000)
1392        );
1393        assert_eq!(trade.ts_init, ts_init);
1394    }
1395
1396    #[rstest]
1397    fn test_parse_trade_tick_buy() {
1398        let json_data = load_test_json("http_get_last_trades.json");
1399        let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1400            serde_json::from_str(&json_data).unwrap();
1401        let result = response.result.expect("Test data must have result");
1402
1403        // Last trade is a buy with amount 106
1404        let raw_trade = &result.trades[9];
1405        let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
1406        let ts_init = UnixNanos::default();
1407
1408        let trade = parse_trade_tick(raw_trade, instrument_id, 1, 0, ts_init)
1409            .expect("Should parse trade tick");
1410
1411        assert_eq!(trade.instrument_id, instrument_id);
1412        assert_eq!(trade.price, Price::from("2968.3"));
1413        assert_eq!(trade.size, Quantity::from("106"));
1414        assert_eq!(trade.aggressor_side, AggressorSide::Buyer);
1415        assert_eq!(trade.trade_id, TradeId::new("ETH-284830854"));
1416    }
1417
1418    /// Builds a minimal [`DeribitPublicTrade`] via JSON to exercise the HTTP
1419    /// trade-tick path. Mirrors the WS-side `make_trade_msg` helper.
1420    fn make_public_trade(
1421        trade_id: &str,
1422        block_trade_id: Option<&str>,
1423        block_rfq_id: Option<i64>,
1424        combo_id: Option<&str>,
1425    ) -> DeribitPublicTrade {
1426        let raw = serde_json::json!({
1427            "trade_id": trade_id,
1428            "instrument_name": "BTC-PERPETUAL",
1429            "price": 77000.0,
1430            "amount": 10.0,
1431            "direction": "buy",
1432            "timestamp": 1_779_107_386_210_i64,
1433            "trade_seq": 1,
1434            "tick_direction": 0,
1435            "block_trade_id": block_trade_id,
1436            "block_rfq_id": block_rfq_id,
1437            "combo_id": combo_id,
1438        });
1439        serde_json::from_value(raw).unwrap()
1440    }
1441
1442    #[rstest]
1443    #[case::block_rfq(None, Some(99_i64), None, "RFQ-244343055")]
1444    #[case::block_trade(Some("12345"), None, None, "BLK-244343055")]
1445    #[case::combo_leg(None, None, Some("BTC-FS-25DEC26_PERP"), "COMBO-244343055")]
1446    fn test_parse_trade_tick_provenance_prefix(
1447        #[case] block_trade_id: Option<&str>,
1448        #[case] block_rfq_id: Option<i64>,
1449        #[case] combo_id: Option<&str>,
1450        #[case] expected_trade_id: &str,
1451    ) {
1452        let trade = make_public_trade("244343055", block_trade_id, block_rfq_id, combo_id);
1453        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1454        let tick = parse_trade_tick(&trade, instrument_id, 1, 0, UnixNanos::default())
1455            .expect("Should parse trade tick");
1456        assert_eq!(tick.trade_id, TradeId::new(expected_trade_id));
1457    }
1458
1459    #[rstest]
1460    fn test_use_cost_for_bar_volume() {
1461        // Inverse perpetual: BTC-PERPETUAL → cost (USD)
1462        let perp_json = load_test_json("http_get_instrument.json");
1463        let perp_response: DeribitJsonRpcResponse<DeribitInstrument> =
1464            serde_json::from_str(&perp_json).unwrap();
1465        let perp_inst = perp_response.result.expect("Test data must have result");
1466        let perp =
1467            parse_deribit_instrument_any(&perp_inst, UnixNanos::default(), UnixNanos::default())
1468                .unwrap()
1469                .expect("Should parse perpetual");
1470        assert!(perp.is_inverse());
1471        assert!(use_cost_for_bar_volume(&perp));
1472
1473        // BTC inverse option: is_inverse, but trade amount is in BTC, so stay on volume
1474        let instruments_json = load_test_json("http_get_instruments.json");
1475        let instruments_response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1476            serde_json::from_str(&instruments_json).unwrap();
1477        let instruments = instruments_response
1478            .result
1479            .expect("Test data must have result");
1480
1481        let option_inst = instruments
1482            .iter()
1483            .find(|i| i.instrument_name.as_str() == "BTC-27DEC24-100000-C")
1484            .expect("Test data must contain BTC-27DEC24-100000-C");
1485        let option =
1486            parse_deribit_instrument_any(option_inst, UnixNanos::default(), UnixNanos::default())
1487                .unwrap()
1488                .expect("Should parse option");
1489        assert!(option.is_inverse());
1490        assert!(
1491            !use_cost_for_bar_volume(&option),
1492            "options report trade amount in base currency, must keep using volume",
1493        );
1494
1495        // Inverse future: same convention as perp — cost (USD)
1496        let future_inst = instruments
1497            .iter()
1498            .find(|i| i.instrument_name.as_str() == "BTC-27DEC24")
1499            .expect("Test data must contain BTC-27DEC24");
1500        let future =
1501            parse_deribit_instrument_any(future_inst, UnixNanos::default(), UnixNanos::default())
1502                .unwrap()
1503                .expect("Should parse future");
1504        assert!(future.is_inverse());
1505        assert!(use_cost_for_bar_volume(&future));
1506    }
1507
1508    #[rstest]
1509    fn test_parse_bars_uses_volume_field() {
1510        let json_data = load_test_json("http_get_tradingview_chart_data.json");
1511        let response: DeribitJsonRpcResponse<DeribitTradingViewChartData> =
1512            serde_json::from_str(&json_data).unwrap();
1513        let chart_data = response.result.expect("Test data must have result");
1514
1515        let bar_type = BarType::from("BTC-PERPETUAL.DERIBIT-1-MINUTE-LAST-EXTERNAL");
1516        let ts_init = UnixNanos::from(1766487086146245_u64 * NANOSECONDS_IN_MICROSECOND);
1517
1518        let bars =
1519            parse_bars(&chart_data, bar_type, 1, 8, false, ts_init).expect("Should parse bars");
1520
1521        assert_eq!(bars.len(), 5, "Should parse 5 bars");
1522
1523        // Verify first bar
1524        let first_bar = &bars[0];
1525        assert_eq!(first_bar.bar_type, bar_type);
1526        assert_eq!(first_bar.open, Price::from("87451.0"));
1527        assert_eq!(first_bar.high, Price::from("87456.5"));
1528        assert_eq!(first_bar.low, Price::from("87451.0"));
1529        assert_eq!(first_bar.close, Price::from("87456.5"));
1530        assert_eq!(first_bar.volume, Quantity::from("2.94375216"));
1531        assert_eq!(
1532            first_bar.ts_event,
1533            UnixNanos::from(1766483460000_u64 * NANOSECONDS_IN_MILLISECOND)
1534        );
1535        assert_eq!(first_bar.ts_init, ts_init);
1536
1537        // Verify last bar
1538        let last_bar = &bars[4];
1539        assert_eq!(last_bar.open, Price::from("87456.0"));
1540        assert_eq!(last_bar.high, Price::from("87456.5"));
1541        assert_eq!(last_bar.low, Price::from("87456.0"));
1542        assert_eq!(last_bar.close, Price::from("87456.0"));
1543        assert_eq!(last_bar.volume, Quantity::from("0.1018798"));
1544        assert_eq!(
1545            last_bar.ts_event,
1546            UnixNanos::from(1766483700000_u64 * NANOSECONDS_IN_MILLISECOND)
1547        );
1548    }
1549
1550    #[rstest]
1551    fn test_parse_bars_cost_path() {
1552        let json_data = load_test_json("http_get_tradingview_chart_data.json");
1553        let response: DeribitJsonRpcResponse<DeribitTradingViewChartData> =
1554            serde_json::from_str(&json_data).unwrap();
1555        let chart_data = response.result.expect("Test data must have result");
1556
1557        let bar_type = BarType::from("BTC-PERPETUAL.DERIBIT-1-MINUTE-LAST-EXTERNAL");
1558        let ts_init = UnixNanos::from(1766487086146245_u64 * NANOSECONDS_IN_MICROSECOND);
1559
1560        // Cost path picks `cost` (USD), matching trade `amount` on inverse perps/futures.
1561        let bars =
1562            parse_bars(&chart_data, bar_type, 1, 0, true, ts_init).expect("Should parse bars");
1563        assert_eq!(bars.len(), 5);
1564        assert_eq!(bars[0].volume, Quantity::from("257490"));
1565        assert_eq!(bars[4].volume, Quantity::from("8910"));
1566    }
1567
1568    #[rstest]
1569    fn test_parse_order_book() {
1570        let json_data = load_test_json("http_get_order_book.json");
1571        let response: DeribitJsonRpcResponse<DeribitOrderBook> =
1572            serde_json::from_str(&json_data).unwrap();
1573        let order_book_data = response.result.expect("Test data must have result");
1574
1575        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1576        let ts_init = UnixNanos::from(1766554855146274_u64 * NANOSECONDS_IN_MICROSECOND);
1577
1578        let book = parse_order_book(&order_book_data, instrument_id, 1, 0, ts_init)
1579            .expect("Should parse order book");
1580
1581        // Verify book metadata
1582        assert_eq!(book.instrument_id, instrument_id);
1583        assert_eq!(book.book_type, BookType::L2_MBP);
1584        assert_eq!(book.ts_last, ts_init);
1585
1586        // Verify book has both sides
1587        assert!(book.has_bid(), "Book should have bids");
1588        assert!(book.has_ask(), "Book should have asks");
1589
1590        // Verify best bid using OrderBook methods
1591        assert_eq!(
1592            book.best_bid_price(),
1593            Some(Price::from("87002.5")),
1594            "Best bid price should match"
1595        );
1596        assert_eq!(
1597            book.best_bid_size(),
1598            Some(Quantity::from("199190")),
1599            "Best bid size should match"
1600        );
1601
1602        // Verify best ask using OrderBook methods
1603        assert_eq!(
1604            book.best_ask_price(),
1605            Some(Price::from("87003.0")),
1606            "Best ask price should match"
1607        );
1608        assert_eq!(
1609            book.best_ask_size(),
1610            Some(Quantity::from("125090")),
1611            "Best ask size should match"
1612        );
1613
1614        // Verify spread (best_ask - best_bid = 87003.0 - 87002.5 = 0.5)
1615        let spread = book.spread().expect("Spread should exist");
1616        assert!(
1617            (spread - 0.5).abs() < 0.0001,
1618            "Spread should be 0.5, was {spread}"
1619        );
1620
1621        // Verify midpoint ((87003.0 + 87002.5) / 2 = 87002.75)
1622        let midpoint = book.midpoint().expect("Midpoint should exist");
1623        assert!(
1624            (midpoint - 87002.75).abs() < 0.0001,
1625            "Midpoint should be 87002.75, was {midpoint}"
1626        );
1627
1628        // Verify level counts match input data
1629        let bid_count = book.bids(None).count();
1630        let ask_count = book.asks(None).count();
1631        assert_eq!(
1632            bid_count,
1633            order_book_data.bids.len(),
1634            "Bid levels count should match input data"
1635        );
1636        assert_eq!(
1637            ask_count,
1638            order_book_data.asks.len(),
1639            "Ask levels count should match input data"
1640        );
1641        assert_eq!(bid_count, 20, "Should have 20 bid levels");
1642        assert_eq!(ask_count, 20, "Should have 20 ask levels");
1643
1644        // Verify depth limiting works (get top 5 levels)
1645        assert_eq!(
1646            book.bids(Some(5)).count(),
1647            5,
1648            "Should limit to 5 bid levels"
1649        );
1650        assert_eq!(
1651            book.asks(Some(5)).count(),
1652            5,
1653            "Should limit to 5 ask levels"
1654        );
1655
1656        // Verify bids_as_map and asks_as_map
1657        let bids_map = book.bids_as_map(None);
1658        let asks_map = book.asks_as_map(None);
1659        assert_eq!(bids_map.len(), 20, "Bids map should have 20 entries");
1660        assert_eq!(asks_map.len(), 20, "Asks map should have 20 entries");
1661
1662        // Verify specific prices exist in maps
1663        assert!(
1664            bids_map.contains_key(&dec!(87002.5)),
1665            "Bids map should contain best bid price"
1666        );
1667        assert!(
1668            asks_map.contains_key(&dec!(87003.0)),
1669            "Asks map should contain best ask price"
1670        );
1671
1672        // Verify worst levels exist
1673        assert!(
1674            bids_map.contains_key(&dec!(86980.0)),
1675            "Bids map should contain worst bid price"
1676        );
1677        assert!(
1678            asks_map.contains_key(&dec!(87031.5)),
1679            "Asks map should contain worst ask price"
1680        );
1681    }
1682
1683    fn make_instrument_id(symbol: &str) -> InstrumentId {
1684        InstrumentId::new(Symbol::from(symbol), *DERIBIT_VENUE)
1685    }
1686
1687    #[rstest]
1688    fn test_parse_futures_and_perpetuals() {
1689        // Perpetuals are classified as "future" in Deribit API
1690        let cases = [
1691            ("BTC-PERPETUAL", "future", "BTC"),
1692            ("ETH-PERPETUAL", "future", "ETH"),
1693            ("SOL-PERPETUAL", "future", "SOL"),
1694            // Futures with expiry dates
1695            ("BTC-25MAR23", "future", "BTC"),
1696            ("BTC-5AUG23", "future", "BTC"), // Single digit day
1697            ("ETH-28MAR25", "future", "ETH"),
1698        ];
1699
1700        for (symbol, expected_kind, expected_currency) in cases {
1701            let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1702            assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1703            assert_eq!(
1704                currency, expected_currency,
1705                "currency mismatch for {symbol}"
1706            );
1707        }
1708    }
1709
1710    #[rstest]
1711    fn test_parse_options() {
1712        let cases = [
1713            // Standard options: {CURRENCY}-{DMMMYY}-{STRIKE}-{C|P}
1714            ("BTC-25MAR23-420-C", "option", "BTC"),
1715            ("BTC-5AUG23-580-P", "option", "BTC"),
1716            ("ETH-28MAR25-4000-C", "option", "ETH"),
1717            // Linear option with decimal strike (d = decimal point)
1718            ("XRP_USDC-30JUN23-0d625-C", "option", "XRP"),
1719        ];
1720
1721        for (symbol, expected_kind, expected_currency) in cases {
1722            let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1723            assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1724            assert_eq!(
1725                currency, expected_currency,
1726                "currency mismatch for {symbol}"
1727            );
1728        }
1729    }
1730
1731    #[rstest]
1732    // Future combos: {CURRENCY}-FS-...
1733    #[case::future_combo_vs_perp("BTC-FS-19MAY26_PERP", "future_combo", "BTC")]
1734    #[case::future_combo_inter_month("BTC-FS-22MAY26_19MAY26", "future_combo", "BTC")]
1735    #[case::future_combo_eth("ETH-FS-26JUN26_PERP", "future_combo", "ETH")]
1736    // Option combos: {CURRENCY}-{STRATEGY}-...
1737    #[case::option_combo_call_spread("BTC-CS-19MAY26-70000_75000", "option_combo", "BTC")]
1738    #[case::option_combo_strangle("BTC-STRG-19MAY26-74000_79000", "option_combo", "BTC")]
1739    #[case::option_combo_straddle("BTC-STRD-29MAY26-77000", "option_combo", "BTC")]
1740    #[case::option_combo_box("BTC-BOX-25DEC26-58000_60000", "option_combo", "BTC")]
1741    #[case::option_combo_put_spread_eth("ETH-PS-26JUN26-3500_4000", "option_combo", "ETH")]
1742    fn test_parse_combo_kinds(
1743        #[case] symbol: &str,
1744        #[case] expected_kind: &str,
1745        #[case] expected_currency: &str,
1746    ) {
1747        let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
1748        assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
1749        assert_eq!(
1750            currency, expected_currency,
1751            "currency mismatch for {symbol}"
1752        );
1753    }
1754
1755    #[rstest]
1756    fn test_parse_option_combo_instrument() {
1757        let json_data = load_test_json("http_get_instruments_option_combo.json");
1758        let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1759            serde_json::from_str(&json_data).unwrap();
1760        let instruments = response.result.expect("Test data must have result");
1761        let raw = instruments
1762            .iter()
1763            .find(|i| i.instrument_name.as_str() == "BTC-STRG-19MAY26-74000_79000")
1764            .expect("fixture must contain BTC-STRG-19MAY26-74000_79000");
1765
1766        let any = parse_deribit_instrument_any(raw, UnixNanos::default(), UnixNanos::default())
1767            .unwrap()
1768            .expect("Should parse option combo");
1769
1770        let InstrumentAny::CryptoOptionSpread(spread) = any else {
1771            panic!("Expected CryptoOptionSpread, was {any:?}");
1772        };
1773        assert_eq!(
1774            spread.id,
1775            InstrumentId::from("BTC-STRG-19MAY26-74000_79000.DERIBIT")
1776        );
1777        assert_eq!(spread.underlying.code.as_str(), "BTC");
1778        assert_eq!(spread.strategy_type.as_str(), "STRG");
1779        assert_eq!(spread.quote_currency.code.as_str(), "BTC");
1780        assert_eq!(spread.settlement_currency.code.as_str(), "BTC");
1781        assert!(spread.is_inverse);
1782        assert_eq!(spread.price_precision, 4);
1783        assert_eq!(spread.price_increment, Price::from("0.0001"));
1784        assert_eq!(spread.size_precision, 1);
1785        assert_eq!(spread.size_increment, Quantity::from("0.1"));
1786        assert_eq!(spread.multiplier, Quantity::from("1"));
1787        assert_eq!(spread.lot_size, Quantity::from("0.1"));
1788        assert_eq!(
1789            spread.expiration_ns,
1790            UnixNanos::from(1779177600000_u64 * 1_000_000)
1791        );
1792        assert_eq!(
1793            spread.activation_ns,
1794            UnixNanos::from(1779100724000_u64 * 1_000_000)
1795        );
1796        assert_eq!(spread.maker_fee, dec!(0));
1797        assert_eq!(spread.taker_fee, dec!(0));
1798    }
1799
1800    #[rstest]
1801    fn test_deserialize_option_combo_trade_with_legs() {
1802        let json_data = load_test_json("http_get_last_trades_option_combo.json");
1803        let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1804            serde_json::from_str(&json_data).unwrap();
1805        let result = response.result.expect("Test data must have result");
1806
1807        let combo_trade = &result.trades[0];
1808        assert_eq!(combo_trade.trade_id, "244365193");
1809        assert_eq!(combo_trade.instrument_name, "BTC-CS-19MAY26-70000_75000");
1810        assert_eq!(combo_trade.combo_id.as_deref(), None);
1811        assert_eq!(combo_trade.combo_trade_id.as_deref(), None);
1812
1813        let legs = combo_trade
1814            .legs
1815            .as_ref()
1816            .expect("Combo trade must have legs");
1817        assert_eq!(legs.len(), 2);
1818
1819        let leg_75c = &legs[0];
1820        assert_eq!(leg_75c.instrument_name, "BTC-19MAY26-75000-C");
1821        assert_eq!(leg_75c.trade_id, "244365195");
1822        assert_eq!(leg_75c.combo_trade_id, "244365193");
1823        assert_eq!(leg_75c.combo_id, "BTC-CS-19MAY26-70000_75000");
1824        assert_eq!(leg_75c.direction, "buy");
1825        assert_eq!(leg_75c.price, dec!(0.0174));
1826        assert_eq!(leg_75c.amount, dec!(0.1));
1827        assert_eq!(leg_75c.iv, Some(dec!(41.01)));
1828
1829        let leg_70c = &legs[1];
1830        assert_eq!(leg_70c.instrument_name, "BTC-19MAY26-70000-C");
1831        assert_eq!(leg_70c.trade_id, "244365194");
1832        assert_eq!(leg_70c.direction, "sell");
1833        assert_eq!(leg_70c.iv, Some(dec!(83.39)));
1834    }
1835
1836    #[rstest]
1837    fn test_deserialize_future_combo_trade_with_legs() {
1838        let json_data = load_test_json("http_get_last_trades_future_combo.json");
1839        let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1840            serde_json::from_str(&json_data).unwrap();
1841        let result = response.result.expect("Test data must have result");
1842
1843        let combo_trade = &result.trades[0];
1844        assert_eq!(combo_trade.trade_id, "244343053");
1845        assert_eq!(combo_trade.instrument_name, "BTC-FS-25DEC26_PERP");
1846        assert_eq!(combo_trade.price, dec!(1320.0));
1847        assert_eq!(combo_trade.amount, dec!(10.0));
1848
1849        let legs = combo_trade
1850            .legs
1851            .as_ref()
1852            .expect("Future combo trade must have legs");
1853        assert_eq!(legs.len(), 2);
1854
1855        // Leg 1: BTC-25DEC26 sell. Exact field values rather than is_empty.
1856        let leg_25dec = &legs[0];
1857        assert_eq!(leg_25dec.instrument_name, "BTC-25DEC26");
1858        assert_eq!(leg_25dec.trade_id, "244343055");
1859        assert_eq!(leg_25dec.combo_id, "BTC-FS-25DEC26_PERP");
1860        assert_eq!(leg_25dec.combo_trade_id, "244343053");
1861        assert_eq!(leg_25dec.direction, "sell");
1862        assert_eq!(leg_25dec.price, dec!(78624.0));
1863        assert_eq!(leg_25dec.amount, dec!(10.0));
1864        assert_eq!(leg_25dec.contracts, Some(dec!(1.0)));
1865        assert!(leg_25dec.iv.is_none(), "future leg must not carry iv");
1866
1867        // Leg 2: BTC-PERPETUAL buy.
1868        let leg_perp = &legs[1];
1869        assert_eq!(leg_perp.instrument_name, "BTC-PERPETUAL");
1870        assert_eq!(leg_perp.trade_id, "244343054");
1871        assert_eq!(leg_perp.combo_id, "BTC-FS-25DEC26_PERP");
1872        assert_eq!(leg_perp.combo_trade_id, "244343053");
1873        assert_eq!(leg_perp.direction, "buy");
1874        assert_eq!(leg_perp.price, dec!(77304.0));
1875        assert!(leg_perp.iv.is_none(), "future leg must not carry iv");
1876    }
1877
1878    #[rstest]
1879    fn test_deserialize_historical_combo_leg_with_missing_optional_fields() {
1880        // Pins the review-fix loop's optionality decisions on DeribitTradeLeg.
1881        // Synthesised fixture: one combo trade where the parent omits
1882        // contracts/index_price/mark_price (already optional pre-patch), and
1883        // legs omit varying subsets of the same Option<Decimal> fields plus
1884        // `iv`. A regression that re-tightens any of them will fail here.
1885        let json_data = load_test_json("http_get_last_trades_historical_combo.json");
1886        let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1887            serde_json::from_str(&json_data).unwrap();
1888        let result = response.result.expect("Test data must have result");
1889
1890        let combo_trade = &result.trades[0];
1891        assert_eq!(combo_trade.trade_id, "999000000");
1892        assert_eq!(combo_trade.instrument_name, "BTC-CS-19MAY26-70000_75000");
1893        // Parent-level optional fields all absent on this historical sample.
1894        assert!(combo_trade.contracts.is_none());
1895        assert!(combo_trade.index_price.is_none());
1896        assert!(combo_trade.mark_price.is_none());
1897
1898        let legs = combo_trade
1899            .legs
1900            .as_ref()
1901            .expect("Combo trade must have legs");
1902        assert_eq!(legs.len(), 2);
1903
1904        // Leg 1: every Option<Decimal> field absent (contracts, index_price,
1905        // mark_price, iv). Required fields still strong-asserted.
1906        let leg1 = &legs[0];
1907        assert_eq!(leg1.instrument_name, "BTC-19MAY26-75000-C");
1908        assert_eq!(leg1.trade_id, "999000001");
1909        assert_eq!(leg1.combo_id, "BTC-CS-19MAY26-70000_75000");
1910        assert_eq!(leg1.combo_trade_id, "999000000");
1911        assert_eq!(leg1.direction, "buy");
1912        assert_eq!(leg1.price, dec!(0.0174));
1913        assert_eq!(leg1.amount, dec!(0.1));
1914        assert!(leg1.contracts.is_none());
1915        assert!(leg1.index_price.is_none());
1916        assert!(leg1.mark_price.is_none());
1917        assert!(leg1.iv.is_none());
1918
1919        // Leg 2: contracts and mark_price absent; iv and index_price present.
1920        // Confirms the optional fields are independently parsed.
1921        let leg2 = &legs[1];
1922        assert_eq!(leg2.instrument_name, "BTC-19MAY26-70000-C");
1923        assert_eq!(leg2.trade_id, "999000002");
1924        assert!(leg2.contracts.is_none());
1925        assert!(leg2.mark_price.is_none());
1926        assert_eq!(leg2.index_price, Some(dec!(76185.14)));
1927        assert_eq!(leg2.iv, Some(dec!(83.39)));
1928    }
1929
1930    #[rstest]
1931    fn test_parse_combo_instrument_missing_expiration_errors() {
1932        // Locks invariant I9: build_spread_common must reject combo
1933        // instruments without an expiration_timestamp rather than producing a
1934        // zero-expiration InstrumentAny.
1935        let json_data = load_test_json("http_get_instruments_option_combo.json");
1936        let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1937            serde_json::from_str(&json_data).unwrap();
1938        let mut instruments = response.result.expect("Test data must have result");
1939        let raw = instruments
1940            .iter_mut()
1941            .find(|i| i.instrument_name.as_str() == "BTC-STRG-19MAY26-74000_79000")
1942            .expect("fixture must contain BTC-STRG-19MAY26-74000_79000");
1943        raw.expiration_timestamp = None;
1944
1945        let result = parse_deribit_instrument_any(raw, UnixNanos::default(), UnixNanos::default());
1946        let err = result.expect_err("Should error when expiration_timestamp is missing");
1947        let msg = format!("{err:#}");
1948        assert!(
1949            msg.contains("Missing expiration_timestamp for combo"),
1950            "unexpected error: {msg}"
1951        );
1952    }
1953
1954    #[rstest]
1955    fn test_deserialize_perpetual_combo_leg_tags() {
1956        // Per-leg stream carries combo_id + combo_trade_id when the leg
1957        // originated from a combo (gating evidence from Step 1).
1958        let json_data = load_test_json("http_get_last_trades_perpetual_with_combo_tags.json");
1959        let response: DeribitJsonRpcResponse<DeribitTradesResponse> =
1960            serde_json::from_str(&json_data).unwrap();
1961        let result = response.result.expect("Test data must have result");
1962
1963        for trade in &result.trades {
1964            assert_eq!(trade.instrument_name, "BTC-PERPETUAL");
1965            assert_eq!(trade.combo_id.as_deref(), Some("BTC-FS-25DEC26_PERP"));
1966            assert!(
1967                trade.combo_trade_id.is_some(),
1968                "Per-leg trade should carry combo_trade_id"
1969            );
1970            // Per-leg stream entries never carry a nested `legs` array.
1971            assert!(trade.legs.is_none());
1972        }
1973    }
1974
1975    #[rstest]
1976    fn test_parse_future_combo_instrument() {
1977        let json_data = load_test_json("http_get_instruments_future_combo.json");
1978        let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1979            serde_json::from_str(&json_data).unwrap();
1980        let instruments = response.result.expect("Test data must have result");
1981        let raw = instruments
1982            .iter()
1983            .find(|i| i.instrument_name.as_str() == "BTC-FS-19MAY26_PERP")
1984            .expect("fixture must contain BTC-FS-19MAY26_PERP");
1985
1986        let any = parse_deribit_instrument_any(raw, UnixNanos::default(), UnixNanos::default())
1987            .unwrap()
1988            .expect("Should parse future combo");
1989
1990        let InstrumentAny::CryptoFuturesSpread(spread) = any else {
1991            panic!("Expected CryptoFuturesSpread, was {any:?}");
1992        };
1993        assert_eq!(spread.id, InstrumentId::from("BTC-FS-19MAY26_PERP.DERIBIT"));
1994        assert_eq!(spread.underlying.code.as_str(), "BTC");
1995        assert_eq!(spread.strategy_type.as_str(), "FS");
1996        // Future combo quote_currency on BTC contracts is USD.
1997        assert_eq!(spread.quote_currency.code.as_str(), "USD");
1998        assert_eq!(spread.settlement_currency.code.as_str(), "BTC");
1999        assert!(spread.is_inverse);
2000        assert_eq!(spread.price_precision, 1);
2001        assert_eq!(spread.price_increment, Price::from("0.5"));
2002        assert_eq!(spread.size_precision, 0);
2003        assert_eq!(spread.size_increment, Quantity::from("10"));
2004        assert_eq!(spread.multiplier, Quantity::from("1"));
2005        assert_eq!(spread.lot_size, Quantity::from("10"));
2006        assert_eq!(
2007            spread.expiration_ns,
2008            UnixNanos::from(1779177600000_u64 * 1_000_000)
2009        );
2010    }
2011
2012    #[rstest]
2013    fn test_build_public_trade_id_plain() {
2014        let id = build_public_trade_id("244343053", None, None, None);
2015        assert_eq!(id.as_str(), "244343053");
2016    }
2017
2018    #[rstest]
2019    fn test_build_public_trade_id_combo_only() {
2020        let id = build_public_trade_id("244365195", None, None, Some("BTC-CS-19MAY26-70000_75000"));
2021        assert_eq!(id.as_str(), "COMBO-244365195");
2022    }
2023
2024    #[rstest]
2025    fn test_build_public_trade_id_block_only() {
2026        let id = build_public_trade_id("244343053", None, Some("12345"), None);
2027        assert_eq!(id.as_str(), "BLK-244343053");
2028    }
2029
2030    #[rstest]
2031    fn test_build_public_trade_id_rfq_only() {
2032        let id = build_public_trade_id("244343053", Some(99), None, None);
2033        assert_eq!(id.as_str(), "RFQ-244343053");
2034    }
2035
2036    #[rstest]
2037    fn test_build_public_trade_id_precedence_block_beats_combo() {
2038        // A combo executed as a block carries both combo_id and block_trade_id.
2039        // The block tag wins because it is the more important reconciliation signal.
2040        let id = build_public_trade_id(
2041            "244343053",
2042            None,
2043            Some("12345"),
2044            Some("BTC-FS-25DEC26_PERP"),
2045        );
2046        assert_eq!(id.as_str(), "BLK-244343053");
2047    }
2048
2049    #[rstest]
2050    fn test_build_public_trade_id_precedence_rfq_beats_all() {
2051        let id = build_public_trade_id(
2052            "244343053",
2053            Some(99),
2054            Some("12345"),
2055            Some("BTC-FS-25DEC26_PERP"),
2056        );
2057        assert_eq!(id.as_str(), "RFQ-244343053");
2058    }
2059
2060    #[rstest]
2061    fn test_parse_spot() {
2062        let cases = [
2063            ("BTC_USDC", "spot", "BTC"),
2064            ("ETH_USDT", "spot", "ETH"),
2065            ("SOL_USDC", "spot", "SOL"),
2066        ];
2067
2068        for (symbol, expected_kind, expected_currency) in cases {
2069            let (kind, currency) = parse_instrument_kind_currency(&make_instrument_id(symbol));
2070            assert_eq!(kind, expected_kind, "kind mismatch for {symbol}");
2071            assert_eq!(
2072                currency, expected_currency,
2073                "currency mismatch for {symbol}"
2074            );
2075        }
2076    }
2077
2078    #[rstest]
2079    fn test_parse_portfolio_to_account_state() {
2080        let json_data = load_test_json("ws_portfolio.json");
2081        let notification: serde_json::Value = serde_json::from_str(&json_data).unwrap();
2082
2083        // Extract the data field from the notification
2084        let data = notification
2085            .get("params")
2086            .and_then(|p| p.get("data"))
2087            .expect("Test data must have params.data");
2088
2089        let portfolio: DeribitPortfolioMsg =
2090            serde_json::from_value(data.clone()).expect("Should deserialize portfolio message");
2091
2092        // Verify deserialization
2093        assert_eq!(portfolio.currency, "USDT");
2094        assert_eq!(portfolio.equity, dec!(55.00055));
2095        assert_eq!(portfolio.balance, dec!(55.00055));
2096        assert_eq!(portfolio.available_funds, dec!(53.868247));
2097        assert_eq!(portfolio.margin_balance, dec!(54.968258));
2098        assert_eq!(portfolio.initial_margin, dec!(1.100011));
2099        assert_eq!(portfolio.maintenance_margin, dec!(0.0));
2100        assert_eq!(portfolio.cross_collateral_enabled, Some(true));
2101        assert_eq!(portfolio.margin_model.as_deref(), Some("cross_sm"));
2102
2103        // Test parsing to AccountState
2104        let account_id = AccountId::new("DERIBIT-master");
2105        let ts_init = UnixNanos::from(1700000000000000000_u64);
2106
2107        let account_state =
2108            parse_portfolio_to_account_state(&portfolio, account_id, ts_init).unwrap();
2109
2110        // Verify account state
2111        assert_eq!(account_state.account_id, account_id);
2112        assert_eq!(account_state.account_type, AccountType::Margin);
2113        assert!(account_state.is_reported);
2114
2115        // Verify balances (should have 1 balance for USDT)
2116        // cross_collateral_enabled=true so total=equity, free=available_withdrawal_funds
2117        assert_eq!(account_state.balances.len(), 1);
2118        let balance = &account_state.balances[0];
2119        assert_eq!(balance.currency.code, "USDT");
2120        assert_eq!(balance.total.as_f64(), 55.00055); // equity (not margin_balance)
2121        assert_eq!(balance.free.as_f64(), 54.968257); // available_withdrawal_funds (not available_funds)
2122
2123        // locked = total - free = 55.00055 - 54.968257 = 0.032293
2124        let locked = balance.locked.as_f64();
2125        assert!(
2126            (locked - 0.032293).abs() < 0.001,
2127            "Locked ({locked}) should be close to 0.032293"
2128        );
2129
2130        // Verify margins (should have 1 margin since initial_margin > 0)
2131        assert_eq!(account_state.margins.len(), 1);
2132        let margin = &account_state.margins[0];
2133        assert_eq!(margin.initial.as_f64(), 1.100011);
2134        assert_eq!(margin.maintenance.as_f64(), 0.0);
2135        assert!(margin.instrument_id.is_none());
2136        assert_eq!(margin.currency.code.as_str(), "USDT");
2137    }
2138
2139    #[rstest]
2140    #[case::minute_1(1, "MINUTE", "1")]
2141    #[case::minute_2(2, "MINUTE", "3")]
2142    #[case::minute_3(3, "MINUTE", "3")]
2143    #[case::minute_4(4, "MINUTE", "5")]
2144    #[case::minute_5(5, "MINUTE", "5")]
2145    #[case::minute_6(6, "MINUTE", "10")]
2146    #[case::minute_10(10, "MINUTE", "10")]
2147    #[case::minute_12(12, "MINUTE", "15")]
2148    #[case::minute_15(15, "MINUTE", "15")]
2149    #[case::minute_20(20, "MINUTE", "30")]
2150    #[case::minute_30(30, "MINUTE", "30")]
2151    #[case::hour_1(1, "HOUR", "60")]
2152    #[case::hour_2(2, "HOUR", "120")]
2153    #[case::hour_3(3, "HOUR", "180")]
2154    #[case::hour_4(4, "HOUR", "360")]
2155    #[case::hour_6(6, "HOUR", "360")]
2156    #[case::hour_12(12, "HOUR", "720")]
2157    #[case::day_1(1, "DAY", "1D")]
2158    fn test_bar_spec_to_resolution(
2159        #[case] step: u64,
2160        #[case] aggregation: &str,
2161        #[case] expected: &str,
2162    ) {
2163        let bar_type_str = format!("BTC-PERPETUAL.DERIBIT-{step}-{aggregation}-LAST-EXTERNAL");
2164        let bar_type = BarType::from(bar_type_str.as_str());
2165        let resolution = bar_spec_to_resolution(&bar_type);
2166        assert_eq!(resolution, expected);
2167    }
2168}