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