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