Skip to main content

nautilus_deribit/websocket/
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 converting Deribit WebSocket messages to Nautilus domain types.
17
18use ahash::AHashMap;
19use anyhow::Context;
20use jiff::{Span, Timestamp, tz::Offset};
21use nautilus_core::{
22    UUID4, UnixNanos, datetime::NANOSECONDS_IN_MILLISECOND,
23    serialization::deserialize_decimal_token_borrowed,
24};
25use nautilus_model::{
26    data::{
27        Bar, BarType, BookOrder, Data, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
28        OrderBookDelta, OrderBookDeltas, QuoteTick, TradeTick, bar::BarSpecification,
29        option_chain::OptionGreeks,
30    },
31    enums::{
32        AggregationSource, AggressorSide, BarAggregation, BookAction, GreeksConvention,
33        LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSide, PriceType, RecordFlag,
34        TimeInForce,
35    },
36    events::{OrderAccepted, OrderCanceled, OrderExpired, OrderUpdated},
37    identifiers::{
38        AccountId, ClientOrderId, InstrumentId, StrategyId, TradeId, TraderId, VenueOrderId,
39    },
40    instruments::{Instrument, InstrumentAny},
41    reports::{FillReport, OrderStatusReport, PositionStatusReport},
42    types::{Currency, Money, Price, Quantity},
43};
44use rust_decimal::{Decimal, prelude::ToPrimitive};
45use serde_json::value::RawValue;
46use ustr::Ustr;
47
48use super::{
49    enums::{DeribitBookAction, DeribitBookMsgType},
50    messages::{
51        DeribitBookMsg, DeribitChartMsg, DeribitOrderMsg, DeribitPerpetualMsg, DeribitQuoteMsg,
52        DeribitTickerMsg, DeribitTradeMsg, DeribitUserTradeMsg,
53    },
54};
55use crate::{common::parse::build_public_trade_id, http::models::DeribitPosition};
56
57fn next_8_utc(from_ns: UnixNanos) -> anyhow::Result<UnixNanos> {
58    let from_secs = from_ns.as_u64() / 1_000_000_000;
59    let timestamp = Timestamp::from_second(i64::try_from(from_secs)?)?;
60    let dt = Offset::UTC.to_datetime(timestamp);
61    let date = if dt.hour() < 8 {
62        dt.date()
63    } else {
64        dt.date().checked_add(Span::new().days(1))?
65    };
66    let next_8 = Offset::UTC.to_timestamp(date.at(8, 0, 0, 0))?;
67    let nanos = u64::try_from(next_8.as_nanosecond())
68        .context("GTD expiry timestamp out of UnixNanos range")?;
69    Ok(UnixNanos::from(nanos))
70}
71
72/// Parses a Deribit trade message into a Nautilus `TradeTick`.
73///
74/// # Errors
75///
76/// Returns an error if the trade cannot be parsed.
77pub fn parse_trade_msg(
78    msg: &DeribitTradeMsg,
79    instrument: &InstrumentAny,
80    ts_init: UnixNanos,
81) -> anyhow::Result<TradeTick> {
82    let instrument_id = instrument.id();
83    let price_precision = instrument.price_precision();
84    let size_precision = instrument.size_precision();
85
86    let price = Price::from_decimal_dp(msg.price, price_precision)?;
87    let size = Quantity::from_decimal_dp(msg.amount.abs(), size_precision)?;
88
89    let aggressor_side = match msg.direction.as_str() {
90        "buy" => AggressorSide::Buy,
91        "sell" => AggressorSide::Sell,
92        _ => AggressorSide::NoAggressor,
93    };
94
95    let trade_id = build_public_trade_id(
96        &msg.trade_id,
97        msg.block_rfq_id,
98        msg.block_trade_id.as_deref(),
99        msg.combo_id.as_deref(),
100    );
101    let ts_event = UnixNanos::new(msg.timestamp * NANOSECONDS_IN_MILLISECOND);
102
103    TradeTick::new_checked(
104        instrument_id,
105        price,
106        size,
107        aggressor_side,
108        trade_id,
109        ts_event,
110        ts_init,
111    )
112}
113
114/// Parses a vector of Deribit trade messages into Nautilus `Data` items.
115pub fn parse_trades_data(
116    trades: &[DeribitTradeMsg],
117    instruments_cache: &AHashMap<Ustr, InstrumentAny>,
118    ts_init: UnixNanos,
119) -> Vec<Data> {
120    trades
121        .iter()
122        .filter_map(|msg| {
123            instruments_cache
124                .get(&msg.instrument_name)
125                .and_then(|inst| parse_trade_msg(msg, inst, ts_init).ok())
126                .map(Data::Trade)
127        })
128        .collect()
129}
130
131fn parse_snapshot_level(
132    level: &[&RawValue],
133    index: usize,
134    side: &str,
135    instrument_name: &str,
136) -> Option<(Decimal, Decimal)> {
137    let (price_val, amount_val) = if level.len() >= 3 {
138        let price = deserialize_decimal_token_borrowed(level[1])
139            .ok()
140            .or_else(|| {
141                log::warn!(
142                    "Failed to parse {side} price at index {index} for {instrument_name}: {level:?}"
143                );
144                None
145            })?;
146
147        let amount = deserialize_decimal_token_borrowed(level[2]).ok().or_else(|| {
148            log::warn!(
149                "Failed to parse {side} amount at index {index} for {instrument_name}: {level:?}"
150            );
151            None
152        })?;
153        (price, amount)
154    } else if level.len() >= 2 {
155        let price = deserialize_decimal_token_borrowed(level[0])
156            .ok()
157            .or_else(|| {
158                log::warn!(
159                    "Failed to parse {side} price at index {index} for {instrument_name}: {level:?}"
160                );
161                None
162            })?;
163
164        let amount = deserialize_decimal_token_borrowed(level[1]).ok().or_else(|| {
165            log::warn!(
166                "Failed to parse {side} amount at index {index} for {instrument_name}: {level:?}"
167            );
168            None
169        })?;
170        (price, amount)
171    } else {
172        log::warn!(
173            "Invalid {side} format at index {index} for {instrument_name}: expected 2-3 elements, was {}",
174            level.len()
175        );
176        return None;
177    };
178
179    if price_val <= Decimal::ZERO {
180        log::warn!(
181            "Invalid {side} price {price_val} at index {index} for {instrument_name}: {level:?}"
182        );
183        return None;
184    }
185
186    Some((price_val, amount_val))
187}
188
189fn parse_delta_level(
190    level: &[&RawValue],
191    index: usize,
192    side: &str,
193    instrument_name: &str,
194) -> Option<(BookAction, Decimal, Decimal)> {
195    if level.len() < 3 {
196        log::warn!(
197            "Invalid {side} delta format at index {index} for {instrument_name}: expected 3 elements, was {}",
198            level.len()
199        );
200        return None;
201    }
202
203    let action_str = serde_json::from_str::<&str>(level[0].get())
204        .ok()
205        .or_else(|| {
206            log::warn!(
207                "Failed to parse {side} action at index {index} for {instrument_name}: {level:?}"
208            );
209            None
210        })?;
211
212    let deribit_action: DeribitBookAction = action_str.parse().ok().or_else(|| {
213        log::warn!(
214            "Unknown {side} action '{action_str}' at index {index} for {instrument_name}: {level:?}"
215        );
216        None
217    })?;
218
219    let price_val = deserialize_decimal_token_borrowed(level[1])
220        .ok()
221        .or_else(|| {
222            log::warn!(
223                "Failed to parse {side} price at index {index} for {instrument_name}: {level:?}"
224            );
225            None
226        })?;
227
228    let amount_val = deserialize_decimal_token_borrowed(level[2])
229        .ok()
230        .or_else(|| {
231            log::warn!(
232                "Failed to parse {side} amount at index {index} for {instrument_name}: {level:?}"
233            );
234            None
235        })?;
236
237    if price_val <= Decimal::ZERO {
238        log::warn!(
239            "Invalid {side} price {price_val} at index {index} for {instrument_name}: {level:?}"
240        );
241        return None;
242    }
243
244    Some((deribit_action.into(), price_val, amount_val))
245}
246
247/// Parses a Deribit order book snapshot into Nautilus `OrderBookDeltas`.
248///
249/// Skips a malformed level, and a level whose amount is not positive or rounds to zero at the
250/// instrument's size precision.
251///
252/// # Errors
253///
254/// Returns an error if a level's price or amount cannot convert to `Price` or `Quantity`, such as a
255/// value outside their range.
256pub fn parse_book_snapshot(
257    msg: &DeribitBookMsg<'_>,
258    instrument: &InstrumentAny,
259    ts_init: UnixNanos,
260) -> anyhow::Result<OrderBookDeltas> {
261    let instrument_id = instrument.id();
262    let price_precision = instrument.price_precision();
263    let size_precision = instrument.size_precision();
264    let ts_event = UnixNanos::new(msg.timestamp * NANOSECONDS_IN_MILLISECOND);
265
266    let mut deltas = Vec::new();
267
268    let has_levels = !msg.bids.is_empty() || !msg.asks.is_empty();
269
270    // All snapshot deltas get F_SNAPSHOT; CLEAR also gets F_LAST if no levels follow
271    let clear_flags = if has_levels {
272        RecordFlag::F_SNAPSHOT as u8
273    } else {
274        RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
275    };
276
277    deltas.push(OrderBookDelta::new(
278        instrument_id,
279        BookAction::Clear,
280        BookOrder::default(),
281        clear_flags,
282        msg.change_id,
283        ts_event,
284        ts_init,
285    ));
286
287    let sides = [
288        (OrderSide::Buy, "bid", &msg.bids, 0),
289        (OrderSide::Sell, "ask", &msg.asks, msg.bids.len()),
290    ];
291
292    for (order_side, side, levels, order_id_offset) in sides {
293        for (i, level) in levels.iter().enumerate() {
294            let Some((price_val, amount_val)) =
295                parse_snapshot_level(level, i, side, msg.instrument_name.as_str())
296            else {
297                continue;
298            };
299
300            if amount_val <= Decimal::ZERO {
301                continue;
302            }
303
304            let price = Price::from_decimal_dp(price_val, price_precision)?;
305            let size = Quantity::from_decimal_dp(amount_val, size_precision)?;
306
307            if is_zero_at_size_precision(size, amount_val, side, i, msg.instrument_name.as_str()) {
308                continue;
309            }
310
311            deltas.push(OrderBookDelta::new_checked(
312                instrument_id,
313                BookAction::Add,
314                BookOrder::new(order_side, price, size, (order_id_offset + i) as u64),
315                RecordFlag::F_SNAPSHOT as u8,
316                msg.change_id,
317                ts_event,
318                ts_init,
319            )?);
320        }
321    }
322
323    if let Some(last) = deltas.last_mut() {
324        *last = OrderBookDelta::new(
325            last.instrument_id,
326            last.action,
327            last.order,
328            RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8,
329            last.sequence,
330            last.ts_event,
331            last.ts_init,
332        );
333    }
334
335    Ok(OrderBookDeltas::new(instrument_id, deltas))
336}
337
338/// Parses a Deribit order book change (delta) into Nautilus `OrderBookDeltas`.
339///
340/// Skips a malformed level. A `new` or `change` level whose amount rounds to zero at the
341/// instrument's size precision becomes a delete.
342///
343/// # Errors
344///
345/// Returns an error if a level's price or amount cannot convert to `Price` or `Quantity`, such as a
346/// value outside their range.
347pub fn parse_book_delta(
348    msg: &DeribitBookMsg<'_>,
349    instrument: &InstrumentAny,
350    ts_init: UnixNanos,
351) -> anyhow::Result<OrderBookDeltas> {
352    let instrument_id = instrument.id();
353    let price_precision = instrument.price_precision();
354    let size_precision = instrument.size_precision();
355    let ts_event = UnixNanos::new(msg.timestamp * NANOSECONDS_IN_MILLISECOND);
356
357    let mut deltas = Vec::new();
358
359    let sides = [
360        (OrderSide::Buy, "bid", &msg.bids, 0),
361        (OrderSide::Sell, "ask", &msg.asks, msg.bids.len()),
362    ];
363
364    for (order_side, side, levels, order_id_offset) in sides {
365        for (i, level) in levels.iter().enumerate() {
366            let Some((action, price_val, amount_val)) =
367                parse_delta_level(level, i, side, msg.instrument_name.as_str())
368            else {
369                continue;
370            };
371
372            let price = Price::from_decimal_dp(price_val, price_precision)?;
373            let size = Quantity::from_decimal_dp(amount_val.abs(), size_precision)?;
374
375            let action = if action != BookAction::Delete
376                && is_zero_at_size_precision(
377                    size,
378                    amount_val,
379                    side,
380                    i,
381                    msg.instrument_name.as_str(),
382                ) {
383                BookAction::Delete
384            } else {
385                action
386            };
387
388            deltas.push(OrderBookDelta::new_checked(
389                instrument_id,
390                action,
391                BookOrder::new(order_side, price, size, (order_id_offset + i) as u64),
392                0,
393                msg.change_id,
394                ts_event,
395                ts_init,
396            )?);
397        }
398    }
399
400    // Set F_LAST flag on the last delta
401    if let Some(last) = deltas.last_mut() {
402        *last = OrderBookDelta::new(
403            last.instrument_id,
404            last.action,
405            last.order,
406            RecordFlag::F_LAST as u8,
407            last.sequence,
408            last.ts_event,
409            last.ts_init,
410        );
411    }
412
413    Ok(OrderBookDeltas::new(instrument_id, deltas))
414}
415
416/// Parses a Deribit order book message (snapshot or delta) into Nautilus `OrderBookDeltas`.
417///
418/// # Errors
419///
420/// Returns an error if a level's price or amount cannot convert to `Price` or `Quantity`, such as a
421/// value outside their range.
422pub fn parse_book_msg(
423    msg: &DeribitBookMsg<'_>,
424    instrument: &InstrumentAny,
425    ts_init: UnixNanos,
426) -> anyhow::Result<OrderBookDeltas> {
427    match msg.msg_type {
428        DeribitBookMsgType::Snapshot => parse_book_snapshot(msg, instrument, ts_init),
429        DeribitBookMsgType::Change => parse_book_delta(msg, instrument, ts_init),
430    }
431}
432
433// An amount below half the size increment rounds to zero, which a book level cannot hold, so
434// callers treat the level as absent.
435fn is_zero_at_size_precision(
436    size: Quantity,
437    amount: Decimal,
438    side: &str,
439    index: usize,
440    instrument_name: &str,
441) -> bool {
442    if size.is_positive() {
443        return false;
444    }
445
446    log::warn!(
447        "Book {side} amount {amount} at index {index} for {instrument_name} is zero at size precision {}, treating level as absent",
448        size.precision
449    );
450    true
451}
452
453/// Parses a Deribit ticker message into a Nautilus `QuoteTick`.
454///
455/// # Errors
456///
457/// Returns an error if the quote cannot be parsed or prices are missing.
458pub fn parse_ticker_to_quote(
459    msg: &DeribitTickerMsg,
460    instrument: &InstrumentAny,
461    ts_init: UnixNanos,
462) -> anyhow::Result<QuoteTick> {
463    let instrument_id = instrument.id();
464    let price_precision = instrument.price_precision();
465    let size_precision = instrument.size_precision();
466
467    let bid_price_val = msg
468        .best_bid_price
469        .context("Missing best_bid_price in ticker")?;
470    let ask_price_val = msg
471        .best_ask_price
472        .context("Missing best_ask_price in ticker")?;
473
474    let bid_price = Price::from_decimal_dp(bid_price_val, price_precision)?;
475    let ask_price = Price::from_decimal_dp(ask_price_val, price_precision)?;
476    let bid_size =
477        Quantity::from_decimal_dp(msg.best_bid_amount.unwrap_or_default(), size_precision)?;
478    let ask_size =
479        Quantity::from_decimal_dp(msg.best_ask_amount.unwrap_or_default(), size_precision)?;
480    let ts_event = UnixNanos::new(msg.timestamp * NANOSECONDS_IN_MILLISECOND);
481
482    QuoteTick::new_checked(
483        instrument_id,
484        bid_price,
485        ask_price,
486        bid_size,
487        ask_size,
488        ts_event,
489        ts_init,
490    )
491}
492
493/// Parses a Deribit quote message into a Nautilus `QuoteTick`.
494///
495/// # Errors
496///
497/// Returns an error if the quote cannot be parsed.
498pub fn parse_quote_msg(
499    msg: &DeribitQuoteMsg,
500    instrument: &InstrumentAny,
501    ts_init: UnixNanos,
502) -> anyhow::Result<QuoteTick> {
503    let instrument_id = instrument.id();
504    let price_precision = instrument.price_precision();
505    let size_precision = instrument.size_precision();
506
507    let bid_price = Price::from_decimal_dp(msg.best_bid_price, price_precision)?;
508    let ask_price = Price::from_decimal_dp(msg.best_ask_price, price_precision)?;
509    let bid_size = Quantity::from_decimal_dp(msg.best_bid_amount, size_precision)?;
510    let ask_size = Quantity::from_decimal_dp(msg.best_ask_amount, size_precision)?;
511    let ts_event = UnixNanos::new(msg.timestamp * NANOSECONDS_IN_MILLISECOND);
512
513    QuoteTick::new_checked(
514        instrument_id,
515        bid_price,
516        ask_price,
517        bid_size,
518        ask_size,
519        ts_event,
520        ts_init,
521    )
522}
523
524/// Parses a Deribit ticker message into a Nautilus `MarkPriceUpdate`.
525///
526/// # Errors
527///
528/// Returns an error if the price cannot be converted to the required precision.
529pub fn parse_ticker_to_mark_price(
530    msg: &DeribitTickerMsg,
531    instrument: &InstrumentAny,
532    ts_init: UnixNanos,
533) -> anyhow::Result<MarkPriceUpdate> {
534    let instrument_id = instrument.id();
535    let price_precision = instrument.price_precision();
536    let value = Price::from_decimal_dp(msg.mark_price, price_precision)?;
537    let ts_event = UnixNanos::new(msg.timestamp * NANOSECONDS_IN_MILLISECOND);
538
539    Ok(MarkPriceUpdate::new(
540        instrument_id,
541        value,
542        ts_event,
543        ts_init,
544    ))
545}
546
547/// Parses a Deribit ticker message into a Nautilus `IndexPriceUpdate`.
548///
549/// # Errors
550///
551/// Returns an error if the price cannot be converted to the required precision.
552pub fn parse_ticker_to_index_price(
553    msg: &DeribitTickerMsg,
554    instrument: &InstrumentAny,
555    ts_init: UnixNanos,
556) -> anyhow::Result<IndexPriceUpdate> {
557    let instrument_id = instrument.id();
558    let price_precision = instrument.price_precision();
559    let value = Price::from_decimal_dp(msg.index_price, price_precision)?;
560    let ts_event = UnixNanos::new(msg.timestamp * NANOSECONDS_IN_MILLISECOND);
561
562    Ok(IndexPriceUpdate::new(
563        instrument_id,
564        value,
565        ts_event,
566        ts_init,
567    ))
568}
569
570/// Parses a Deribit ticker message into a Nautilus `FundingRateUpdate`.
571///
572/// Returns `None` if the instrument is not a perpetual or the funding rate is not available.
573#[must_use]
574pub fn parse_ticker_to_funding_rate(
575    msg: &DeribitTickerMsg,
576    instrument: &InstrumentAny,
577    ts_init: UnixNanos,
578) -> Option<FundingRateUpdate> {
579    // current_funding is only available for perpetual instruments
580    let rate = msg.current_funding?;
581    let instrument_id = instrument.id();
582    let ts_event = UnixNanos::new(msg.timestamp * NANOSECONDS_IN_MILLISECOND);
583
584    // Deribit ticker doesn't include next_funding_time, set to None
585    Some(FundingRateUpdate::new(
586        instrument_id,
587        rate,
588        None, // Deribit exchanges funding every few seconds, instead of in set intervals like other exchanges
589        None, // next_funding_ns not available in ticker
590        ts_event,
591        ts_init,
592    ))
593}
594
595/// Parses a Deribit ticker message into a Nautilus `OptionGreeks`.
596///
597/// Returns `None` if the ticker message does not contain Greeks (non-option instrument).
598#[must_use]
599pub fn parse_ticker_to_option_greeks(
600    msg: &DeribitTickerMsg,
601    instrument: &InstrumentAny,
602    ts_init: UnixNanos,
603) -> Option<OptionGreeks> {
604    let deribit_greeks = msg.greeks.as_ref()?;
605    let instrument_id = instrument.id();
606    let ts_event = UnixNanos::new(msg.timestamp * NANOSECONDS_IN_MILLISECOND);
607
608    Some(OptionGreeks {
609        instrument_id,
610        convention: GreeksConvention::BlackScholes,
611        greeks: deribit_greeks.to_greek_values(),
612        mark_iv: msg.mark_iv.and_then(|v| v.to_f64()),
613        bid_iv: msg.bid_iv.and_then(|v| v.to_f64()),
614        ask_iv: msg.ask_iv.and_then(|v| v.to_f64()),
615        underlying_price: msg.underlying_price.and_then(|v| v.to_f64()),
616        open_interest: Some(msg.open_interest.to_f64().unwrap_or(0.0)),
617        ts_event,
618        ts_init,
619    })
620}
621
622/// Parses a Deribit perpetual channel message into a Nautilus `FundingRateUpdate`.
623///
624/// The perpetual channel (`perpetual.{instrument}.{interval}`) provides dedicated
625/// funding rate updates with the `interest` field representing the current funding rate.
626#[must_use]
627pub fn parse_perpetual_to_funding_rate(
628    msg: &DeribitPerpetualMsg,
629    instrument: &InstrumentAny,
630    ts_init: UnixNanos,
631) -> FundingRateUpdate {
632    let instrument_id = instrument.id();
633    let ts_event = UnixNanos::new(msg.timestamp * NANOSECONDS_IN_MILLISECOND);
634
635    FundingRateUpdate::new(
636        instrument_id,
637        msg.interest,
638        None, // Deribit exchanges funding every few seconds, instead of in set intervals like other exchanges
639        None, // next_funding_ns not available in perpetual channel
640        ts_event,
641        ts_init,
642    )
643}
644
645/// Converts a Deribit chart resolution and instrument to a Nautilus BarType.
646///
647/// Deribit resolutions: "1", "3", "5", "10", "15", "30", "60", "120", "180", "360", "720", "1D"
648///
649/// # Errors
650///
651/// Returns an error if the resolution string is invalid or BarType construction fails.
652pub fn resolution_to_bar_type(
653    instrument_id: InstrumentId,
654    resolution: &str,
655) -> anyhow::Result<BarType> {
656    let (step, aggregation) = match resolution {
657        "1" => (1, BarAggregation::Minute),
658        "3" => (3, BarAggregation::Minute),
659        "5" => (5, BarAggregation::Minute),
660        "10" => (10, BarAggregation::Minute),
661        "15" => (15, BarAggregation::Minute),
662        "30" => (30, BarAggregation::Minute),
663        "60" => (1, BarAggregation::Hour),
664        "120" => (2, BarAggregation::Hour),
665        "180" => (3, BarAggregation::Hour),
666        "360" => (6, BarAggregation::Hour),
667        "720" => (12, BarAggregation::Hour),
668        "1D" => (1, BarAggregation::Day),
669        _ => anyhow::bail!("Unsupported Deribit resolution: {resolution}"),
670    };
671
672    let spec = BarSpecification::new_checked(step, aggregation, PriceType::Last)
673        .context("invalid Deribit bar resolution")?;
674    Ok(BarType::new(
675        instrument_id,
676        spec,
677        AggregationSource::External,
678    ))
679}
680
681/// Parses a Deribit chart message from a WebSocket subscription into a [`Bar`].
682///
683/// Converts a single OHLCV data point from the `chart.trades.{instrument}.{resolution}` channel
684/// into a Nautilus Bar object.
685///
686/// When `use_cost_for_volume` is true, `Bar.volume` is populated from `chart_msg.cost` (USD) to
687/// match instruments whose trade `amount` is in USD (inverse perpetuals / inverse futures).
688/// Otherwise `chart_msg.volume` (base currency) is used. Callers derive this from the instrument
689/// via [`crate::common::parse::use_cost_for_bar_volume`].
690///
691/// # Errors
692///
693/// Returns an error if:
694/// - Price or volume values are invalid.
695/// - Bar construction fails validation.
696pub fn parse_chart_msg(
697    chart_msg: &DeribitChartMsg,
698    bar_type: BarType,
699    price_precision: u8,
700    size_precision: u8,
701    use_cost_for_volume: bool,
702    timestamp_on_close: bool,
703    ts_init: UnixNanos,
704) -> anyhow::Result<Bar> {
705    let open =
706        Price::from_decimal_dp(chart_msg.open, price_precision).context("Invalid open price")?;
707    let high =
708        Price::from_decimal_dp(chart_msg.high, price_precision).context("Invalid high price")?;
709    let low =
710        Price::from_decimal_dp(chart_msg.low, price_precision).context("Invalid low price")?;
711    let close =
712        Price::from_decimal_dp(chart_msg.close, price_precision).context("Invalid close price")?;
713
714    let raw_volume = if use_cost_for_volume {
715        chart_msg.cost
716    } else {
717        chart_msg.volume
718    };
719
720    let volume = Quantity::from_decimal_dp(raw_volume, size_precision).context("Invalid volume")?;
721
722    // Convert timestamp from milliseconds to nanoseconds
723    let mut ts_event = UnixNanos::from(chart_msg.tick * NANOSECONDS_IN_MILLISECOND);
724
725    // Adjust timestamp to close time if configured
726    if timestamp_on_close {
727        let interval_ns = bar_type.spec().timedelta().as_nanos();
728        let interval_ns = u64::try_from(interval_ns)
729            .context("bar interval overflowed the u64 range for nanoseconds")?;
730        let updated = ts_event
731            .as_u64()
732            .checked_add(interval_ns)
733            .context("bar timestamp overflowed when adjusting to close time")?;
734        ts_event = UnixNanos::from(updated);
735    }
736
737    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
738        .context("Invalid OHLC bar")
739}
740
741/// Parses a Deribit user order message into a Nautilus `OrderStatusReport`.
742///
743/// # Errors
744///
745/// Returns an error if the order data cannot be parsed.
746pub fn parse_user_order_msg(
747    msg: &DeribitOrderMsg,
748    instrument: &InstrumentAny,
749    account_id: AccountId,
750    ts_init: UnixNanos,
751) -> anyhow::Result<OrderStatusReport> {
752    let instrument_id = instrument.id();
753    let venue_order_id = VenueOrderId::new(&msg.order_id);
754
755    let order_side = match msg.direction.as_str() {
756        "buy" => OrderSide::Buy,
757        "sell" => OrderSide::Sell,
758        _ => anyhow::bail!("Unknown order direction: {}", msg.direction),
759    };
760
761    // Map Deribit order type to Nautilus
762    let order_type = parse_deribit_order_type(&msg.order_type);
763
764    // Deribit supports: good_til_cancelled, good_til_day, fill_or_kill, immediate_or_cancel
765    let time_in_force = match msg.time_in_force.as_str() {
766        "good_til_cancelled" => TimeInForce::Gtc,
767        "good_til_day" => TimeInForce::Gtd,
768        "fill_or_kill" => TimeInForce::Fok,
769        "immediate_or_cancel" => TimeInForce::Ioc,
770        other => {
771            log::warn!("Unknown time_in_force '{other}', defaulting to GTC");
772            TimeInForce::Gtc
773        }
774    };
775
776    // Map Deribit order state to Nautilus status
777    let order_status = match msg.order_state.as_str() {
778        "open" => {
779            if msg.filled_amount.is_zero() {
780                OrderStatus::Accepted
781            } else {
782                OrderStatus::PartiallyFilled
783            }
784        }
785        "filled" => OrderStatus::Filled,
786        "rejected" => OrderStatus::Rejected,
787        "cancelled" => OrderStatus::Canceled,
788        "untriggered" => OrderStatus::Accepted, // Pending trigger
789        other => {
790            log::warn!("Unknown Deribit order_state '{other}', defaulting to Accepted");
791            OrderStatus::Accepted
792        }
793    };
794
795    let price_precision = instrument.price_precision();
796    let size_precision = instrument.size_precision();
797
798    let quantity = Quantity::from_decimal_dp(msg.amount, size_precision)?;
799    let filled_qty = Quantity::from_decimal_dp(msg.filled_amount, size_precision)?;
800
801    let ts_accepted = UnixNanos::new(msg.creation_timestamp * NANOSECONDS_IN_MILLISECOND);
802    let ts_last = UnixNanos::new(msg.last_update_timestamp * NANOSECONDS_IN_MILLISECOND);
803
804    let mut report = OrderStatusReport::new(
805        account_id,
806        instrument_id,
807        None, // order_list_id
808        venue_order_id,
809        order_side.into(),
810        order_type,
811        time_in_force,
812        order_status,
813        quantity,
814        filled_qty,
815        ts_accepted,
816        ts_last,
817        ts_init,
818        Some(UUID4::new()),
819    );
820
821    // Add client order ID if present
822    if let Some(ref label) = msg.label
823        && !label.is_empty()
824    {
825        report = report.with_client_order_id(ClientOrderId::new(label));
826    }
827
828    // Add price for limit orders
829    if let Some(price_val) = msg.price
830        && !price_val.is_zero()
831    {
832        let price = Price::from_decimal_dp(price_val, price_precision)?;
833        report = report.with_price(price);
834    }
835
836    if time_in_force == TimeInForce::Gtd {
837        let expire_time = next_8_utc(ts_accepted)?;
838        report = report.with_expire_time(expire_time);
839    }
840
841    // Add average price if filled
842    if let Some(avg_price) = msg.average_price
843        && !avg_price.is_zero()
844    {
845        report = report.with_avg_px(avg_price);
846    }
847
848    // Add trigger price for stop/take orders
849    if let Some(trigger_price) = msg.trigger_price
850        && !trigger_price.is_zero()
851    {
852        let trigger = Price::from_decimal_dp(trigger_price, price_precision)?;
853        report = report.with_trigger_price(trigger);
854    }
855
856    if msg.post_only {
857        report = report.with_post_only(true);
858    }
859
860    if msg.reduce_only {
861        report = report.with_reduce_only(true);
862    }
863
864    // Add cancel/reject reason
865    if let Some(ref reason) = msg.reject_reason {
866        report = report.with_cancel_reason(reason.clone());
867    } else if let Some(ref reason) = msg.cancel_reason {
868        report = report.with_cancel_reason(reason.clone());
869    }
870
871    Ok(report)
872}
873
874#[must_use]
875pub(crate) fn parse_deribit_order_type(order_type: &str) -> OrderType {
876    match order_type {
877        "limit" => OrderType::Limit,
878        "market" => OrderType::Market,
879        "stop_limit" => OrderType::StopLimit,
880        "stop_market" => OrderType::StopMarket,
881        "take_limit" => OrderType::LimitIfTouched,
882        "take_market" => OrderType::MarketIfTouched,
883        other => {
884            log::warn!("Unknown Deribit order_type '{other}', defaulting to Limit");
885            OrderType::Limit
886        }
887    }
888}
889
890/// Parses a Deribit user trade message into a Nautilus `FillReport`.
891///
892/// # Errors
893///
894/// Returns an error if the trade data cannot be parsed.
895pub fn parse_user_trade_msg(
896    msg: &DeribitUserTradeMsg,
897    instrument: &InstrumentAny,
898    account_id: AccountId,
899    ts_init: UnixNanos,
900) -> anyhow::Result<FillReport> {
901    let instrument_id = instrument.id();
902    let venue_order_id = VenueOrderId::new(&msg.order_id);
903    let trade_id = TradeId::new(&msg.trade_id);
904
905    // Deribit marks liquidation-triggered trades with "M" (maker liquidated),
906    // "T" (taker liquidated), or "MT" (both). Absent means a normal trade.
907    if let Some(liq) = msg.liquidation.as_deref().filter(|s| !s.is_empty()) {
908        let who = match liq {
909            "M" => "maker",
910            "T" => "taker",
911            "MT" => "both",
912            _ => liq,
913        };
914        log::warn!(
915            "Liquidation trade: {} trade_id={} order_id={} liquidation_side={} direction={} amount={} price={}",
916            instrument_id,
917            msg.trade_id,
918            msg.order_id,
919            who,
920            msg.direction,
921            msg.amount,
922            msg.price,
923        );
924    }
925
926    let order_side = match msg.direction.as_str() {
927        "buy" => OrderSide::Buy,
928        "sell" => OrderSide::Sell,
929        _ => anyhow::bail!("Unknown trade direction: {}", msg.direction),
930    };
931
932    let price_precision = instrument.price_precision();
933    let size_precision = instrument.size_precision();
934
935    let last_qty = Quantity::from_decimal_dp(msg.amount, size_precision)?;
936    let last_px = Price::from_decimal_dp(msg.price, price_precision)?;
937
938    let liquidity_side = match msg.liquidity.as_str() {
939        "M" => LiquiditySide::Maker,
940        "T" => LiquiditySide::Taker,
941        _ => LiquiditySide::NoLiquiditySide,
942    };
943
944    // Get fee currency from the fee_currency field
945    let fee_currency = Currency::from(&msg.fee_currency);
946    let commission = Money::from_decimal(msg.fee, fee_currency)?;
947
948    let ts_event = UnixNanos::new(msg.timestamp * NANOSECONDS_IN_MILLISECOND);
949
950    let client_order_id = msg
951        .label
952        .as_ref()
953        .filter(|l| !l.is_empty())
954        .map(ClientOrderId::new);
955
956    Ok(FillReport::new(
957        account_id,
958        instrument_id,
959        venue_order_id,
960        trade_id,
961        order_side,
962        last_qty,
963        last_px,
964        commission,
965        liquidity_side,
966        client_order_id,
967        None, // venue_position_id
968        ts_event,
969        ts_init,
970        None, // report_id
971    ))
972}
973
974/// Parses a Deribit position into a Nautilus `PositionStatusReport`.
975///
976/// # Arguments
977/// - `position` - The Deribit position data from `/private/get_positions`
978/// - `instrument` - The corresponding Nautilus instrument
979/// - `account_id` - The account ID for the report
980/// - `ts_init` - Initialization timestamp
981///
982/// # Returns
983/// A `PositionStatusReport` representing the current position state.
984#[must_use]
985pub fn parse_position_status_report(
986    position: &DeribitPosition,
987    instrument: &InstrumentAny,
988    account_id: AccountId,
989    ts_init: UnixNanos,
990) -> PositionStatusReport {
991    let instrument_id = instrument.id();
992    let size_precision = instrument.size_precision();
993
994    let signed_qty = Quantity::from_decimal_dp(position.size.abs(), size_precision)
995        .unwrap_or_else(|_| Quantity::zero(size_precision));
996
997    let position_side = match position.direction.as_str() {
998        "buy" => PositionSide::Long,
999        "sell" => PositionSide::Short,
1000        _ => PositionSide::Flat,
1001    };
1002
1003    // Use average_price directly as it's already a Decimal
1004    let avg_px_open = Some(position.average_price);
1005
1006    PositionStatusReport::new(
1007        account_id,
1008        instrument_id,
1009        position_side,
1010        signed_qty,
1011        ts_init,
1012        ts_init,
1013        Some(UUID4::new()),
1014        None, // venue_position_id
1015        avg_px_open,
1016    )
1017}
1018
1019/// Parsed order event result from a Deribit order message.
1020///
1021/// This enum represents the discrete order events that can be derived from
1022/// Deribit order state transitions.
1023#[derive(Debug, Clone)]
1024pub enum ParsedOrderEvent {
1025    /// Order was accepted by the venue.
1026    Accepted(OrderAccepted),
1027    /// Order was canceled.
1028    Canceled(OrderCanceled),
1029    /// Order expired.
1030    Expired(OrderExpired),
1031    /// Order was updated (amended).
1032    Updated(OrderUpdated),
1033    /// No event to emit (e.g., already processed or intermediate state).
1034    None,
1035}
1036
1037/// Extracts the client order ID from a Deribit order message label.
1038fn extract_client_order_id(msg: &DeribitOrderMsg) -> Option<ClientOrderId> {
1039    msg.label
1040        .as_ref()
1041        .filter(|l| !l.is_empty())
1042        .map(ClientOrderId::new)
1043}
1044
1045/// Parses a Deribit order message into an `OrderAccepted` event.
1046///
1047/// This should be called when an order transitions to "open" state for the first time
1048/// or when a buy/sell response is received successfully.
1049#[must_use]
1050pub fn parse_order_accepted(
1051    msg: &DeribitOrderMsg,
1052    instrument: &InstrumentAny,
1053    account_id: AccountId,
1054    trader_id: TraderId,
1055    strategy_id: StrategyId,
1056    ts_init: UnixNanos,
1057) -> OrderAccepted {
1058    let client_order_id =
1059        extract_client_order_id(msg).unwrap_or_else(|| ClientOrderId::new(&msg.order_id));
1060    parse_order_accepted_with_client_order_id(
1061        msg,
1062        instrument,
1063        account_id,
1064        trader_id,
1065        strategy_id,
1066        client_order_id,
1067        ts_init,
1068    )
1069}
1070
1071#[must_use]
1072pub(crate) fn parse_order_accepted_with_client_order_id(
1073    msg: &DeribitOrderMsg,
1074    instrument: &InstrumentAny,
1075    account_id: AccountId,
1076    trader_id: TraderId,
1077    strategy_id: StrategyId,
1078    client_order_id: ClientOrderId,
1079    ts_init: UnixNanos,
1080) -> OrderAccepted {
1081    let instrument_id = instrument.id();
1082    let venue_order_id = VenueOrderId::new(&msg.order_id);
1083    let ts_event = UnixNanos::new(msg.last_update_timestamp * NANOSECONDS_IN_MILLISECOND);
1084
1085    OrderAccepted::new(
1086        trader_id,
1087        strategy_id,
1088        instrument_id,
1089        client_order_id,
1090        venue_order_id,
1091        account_id,
1092        nautilus_core::UUID4::new(),
1093        ts_event,
1094        ts_init,
1095        false, // reconciliation
1096    )
1097}
1098
1099/// Parses a Deribit order message into an `OrderCanceled` event.
1100///
1101/// This should be called when an order transitions to "cancelled" state.
1102#[must_use]
1103pub fn parse_order_canceled(
1104    msg: &DeribitOrderMsg,
1105    instrument: &InstrumentAny,
1106    account_id: AccountId,
1107    trader_id: TraderId,
1108    strategy_id: StrategyId,
1109    ts_init: UnixNanos,
1110) -> OrderCanceled {
1111    let client_order_id =
1112        extract_client_order_id(msg).unwrap_or_else(|| ClientOrderId::new(&msg.order_id));
1113    parse_order_canceled_with_client_order_id(
1114        msg,
1115        instrument,
1116        account_id,
1117        trader_id,
1118        strategy_id,
1119        client_order_id,
1120        ts_init,
1121    )
1122}
1123
1124#[must_use]
1125pub(crate) fn parse_order_canceled_with_client_order_id(
1126    msg: &DeribitOrderMsg,
1127    instrument: &InstrumentAny,
1128    account_id: AccountId,
1129    trader_id: TraderId,
1130    strategy_id: StrategyId,
1131    client_order_id: ClientOrderId,
1132    ts_init: UnixNanos,
1133) -> OrderCanceled {
1134    let instrument_id = instrument.id();
1135    let venue_order_id = VenueOrderId::new(&msg.order_id);
1136    let ts_event = UnixNanos::new(msg.last_update_timestamp * NANOSECONDS_IN_MILLISECOND);
1137
1138    OrderCanceled::new(
1139        trader_id,
1140        strategy_id,
1141        instrument_id,
1142        client_order_id,
1143        nautilus_core::UUID4::new(),
1144        ts_event,
1145        ts_init,
1146        false, // reconciliation
1147        Some(venue_order_id),
1148        Some(account_id),
1149        msg.cancel_reason.as_deref().map(Ustr::from),
1150    )
1151}
1152
1153/// Parses a Deribit order message into an `OrderExpired` event.
1154///
1155/// This should be called when an order transitions to "expired" state
1156/// (e.g., GTD orders that reached their expiry time).
1157#[must_use]
1158pub fn parse_order_expired(
1159    msg: &DeribitOrderMsg,
1160    instrument: &InstrumentAny,
1161    account_id: AccountId,
1162    trader_id: TraderId,
1163    strategy_id: StrategyId,
1164    ts_init: UnixNanos,
1165) -> OrderExpired {
1166    let client_order_id =
1167        extract_client_order_id(msg).unwrap_or_else(|| ClientOrderId::new(&msg.order_id));
1168    parse_order_expired_with_client_order_id(
1169        msg,
1170        instrument,
1171        account_id,
1172        trader_id,
1173        strategy_id,
1174        client_order_id,
1175        ts_init,
1176    )
1177}
1178
1179#[must_use]
1180pub(crate) fn parse_order_expired_with_client_order_id(
1181    msg: &DeribitOrderMsg,
1182    instrument: &InstrumentAny,
1183    account_id: AccountId,
1184    trader_id: TraderId,
1185    strategy_id: StrategyId,
1186    client_order_id: ClientOrderId,
1187    ts_init: UnixNanos,
1188) -> OrderExpired {
1189    let instrument_id = instrument.id();
1190    let venue_order_id = VenueOrderId::new(&msg.order_id);
1191    let ts_event = UnixNanos::new(msg.last_update_timestamp * NANOSECONDS_IN_MILLISECOND);
1192
1193    OrderExpired::new(
1194        trader_id,
1195        strategy_id,
1196        instrument_id,
1197        client_order_id,
1198        nautilus_core::UUID4::new(),
1199        ts_event,
1200        ts_init,
1201        false, // reconciliation
1202        Some(venue_order_id),
1203        Some(account_id),
1204    )
1205}
1206
1207/// Parses a Deribit order message into an `OrderUpdated` event.
1208///
1209/// This should be called when an order is amended (price or quantity changed).
1210#[must_use]
1211pub fn parse_order_updated(
1212    msg: &DeribitOrderMsg,
1213    instrument: &InstrumentAny,
1214    account_id: AccountId,
1215    trader_id: TraderId,
1216    strategy_id: StrategyId,
1217    ts_init: UnixNanos,
1218) -> OrderUpdated {
1219    let client_order_id =
1220        extract_client_order_id(msg).unwrap_or_else(|| ClientOrderId::new(&msg.order_id));
1221    parse_order_updated_with_client_order_id(
1222        msg,
1223        instrument,
1224        account_id,
1225        trader_id,
1226        strategy_id,
1227        client_order_id,
1228        ts_init,
1229    )
1230}
1231
1232#[must_use]
1233pub(crate) fn parse_order_updated_with_client_order_id(
1234    msg: &DeribitOrderMsg,
1235    instrument: &InstrumentAny,
1236    account_id: AccountId,
1237    trader_id: TraderId,
1238    strategy_id: StrategyId,
1239    client_order_id: ClientOrderId,
1240    ts_init: UnixNanos,
1241) -> OrderUpdated {
1242    let instrument_id = instrument.id();
1243    let price_precision = instrument.price_precision();
1244    let size_precision = instrument.size_precision();
1245
1246    let venue_order_id = VenueOrderId::new(&msg.order_id);
1247    let quantity = Quantity::from_decimal_dp(msg.amount, size_precision)
1248        .unwrap_or_else(|_| Quantity::zero(size_precision));
1249    let price = msg
1250        .price
1251        .and_then(|p| Price::from_decimal_dp(p, price_precision).ok());
1252    let trigger_price = msg
1253        .trigger_price
1254        .and_then(|p| Price::from_decimal_dp(p, price_precision).ok());
1255    let ts_event = UnixNanos::new(msg.last_update_timestamp * NANOSECONDS_IN_MILLISECOND);
1256
1257    OrderUpdated::new(
1258        trader_id,
1259        strategy_id,
1260        instrument_id,
1261        client_order_id,
1262        quantity,
1263        nautilus_core::UUID4::new(),
1264        ts_event,
1265        ts_init,
1266        false, // reconciliation
1267        Some(venue_order_id),
1268        Some(account_id),
1269        price,
1270        trigger_price,
1271        None,  // protection_price
1272        false, // is_quote_quantity
1273    )
1274}
1275
1276/// Determines the appropriate order event based on the Deribit order state.
1277///
1278/// This function analyzes the order state and returns the corresponding event type.
1279/// It's used by the handler to determine which event to emit for a given order update.
1280///
1281/// # Arguments
1282/// - `order_state` - The Deribit order state string ("open", "filled", "cancelled", etc.)
1283/// - `is_new_order` - Whether this is the first time we're seeing this order
1284/// - `was_amended` - Whether this update is due to an amendment (edit) operation
1285///
1286/// # Returns
1287/// The type of event that should be emitted, or `None` if no event should be emitted.
1288#[must_use]
1289pub fn determine_order_event_type(
1290    order_state: &str,
1291    is_new_order: bool,
1292    was_amended: bool,
1293) -> OrderEventType {
1294    match order_state {
1295        "open" | "untriggered" => {
1296            if was_amended {
1297                OrderEventType::Updated
1298            } else if is_new_order {
1299                OrderEventType::Accepted
1300            } else {
1301                // Order is still open, no event needed (partial fill handled separately)
1302                OrderEventType::None
1303            }
1304        }
1305        "cancelled" => OrderEventType::Canceled,
1306        "expired" => OrderEventType::Expired,
1307        "filled" => {
1308            // Fills are handled through the user.trades channel
1309            OrderEventType::None
1310        }
1311        "rejected" => {
1312            // Rejections are handled separately via OrderRejected
1313            OrderEventType::None
1314        }
1315        other => {
1316            log::warn!("Unknown Deribit order_state '{other}' in event routing, dropping");
1317            OrderEventType::None
1318        }
1319    }
1320}
1321
1322/// Order event type to be emitted.
1323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1324pub enum OrderEventType {
1325    /// Emit OrderAccepted event.
1326    Accepted,
1327    /// Emit OrderCanceled event.
1328    Canceled,
1329    /// Emit OrderExpired event.
1330    Expired,
1331    /// Emit OrderUpdated event.
1332    Updated,
1333    /// No event to emit.
1334    None,
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339    use rstest::rstest;
1340    use rust_decimal_macros::dec;
1341    use serde::de::DeserializeOwned;
1342
1343    use super::*;
1344    use crate::{
1345        common::{
1346            parse::{parse_deribit_instrument_any, parse_portfolio_to_account_state},
1347            testing::load_test_json,
1348        },
1349        http::models::{DeribitInstrument, DeribitJsonRpcResponse},
1350        websocket::messages::{DeribitPortfolioMsg, DeribitWsMessage, parse_raw_message},
1351    };
1352
1353    /// Creates a BTC-PERPETUAL test instrument.
1354    fn test_perpetual_instrument() -> InstrumentAny {
1355        let json = load_test_json("http_get_instruments.json");
1356        let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1357            serde_json::from_str(&json).unwrap();
1358        let instrument = &response.result.unwrap()[0];
1359        parse_deribit_instrument_any(instrument, UnixNanos::default(), UnixNanos::default())
1360            .unwrap()
1361            .unwrap()
1362    }
1363
1364    #[rstest]
1365    fn test_parse_trade_msg_sell() {
1366        let instrument = test_perpetual_instrument();
1367        let json = load_test_json("ws_trades.json");
1368        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1369        let trades: Vec<DeribitTradeMsg> =
1370            serde_json::from_str(&response["params"]["data"].to_string()).unwrap();
1371        let msg = &trades[0];
1372
1373        let tick = parse_trade_msg(msg, &instrument, UnixNanos::default()).unwrap();
1374
1375        assert_eq!(tick.instrument_id, instrument.id());
1376        assert_eq!(tick.price, instrument.make_price(92294.5));
1377        assert_eq!(tick.size, instrument.make_qty(10.0, None));
1378        assert_eq!(tick.aggressor_side, AggressorSide::Sell);
1379        assert_eq!(tick.trade_id.to_string(), "403691824");
1380        assert_eq!(tick.ts_event, UnixNanos::new(1_765_531_356_452_000_000));
1381    }
1382
1383    #[rstest]
1384    fn test_parse_trade_msg_buy() {
1385        let instrument = test_perpetual_instrument();
1386        let json = load_test_json("ws_trades.json");
1387        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1388        let trades: Vec<DeribitTradeMsg> =
1389            serde_json::from_str(&response["params"]["data"].to_string()).unwrap();
1390        let msg = &trades[1];
1391
1392        let tick = parse_trade_msg(msg, &instrument, UnixNanos::default()).unwrap();
1393
1394        assert_eq!(tick.instrument_id, instrument.id());
1395        assert_eq!(tick.price, instrument.make_price(92288.5));
1396        assert_eq!(tick.size, instrument.make_qty(750.0, None));
1397        assert_eq!(tick.aggressor_side, AggressorSide::Sell);
1398        assert_eq!(tick.trade_id.to_string(), "403691825");
1399    }
1400
1401    fn make_trade_msg(
1402        instrument_name: &str,
1403        trade_id: &str,
1404        block_trade_id: Option<&str>,
1405        block_rfq_id: Option<i64>,
1406        combo_id: Option<&str>,
1407    ) -> DeribitTradeMsg {
1408        let raw = serde_json::json!({
1409            "trade_id": trade_id,
1410            "instrument_name": instrument_name,
1411            "price": 92294.5,
1412            "amount": 10.0,
1413            "direction": "buy",
1414            "timestamp": 1_765_531_356_452_u64,
1415            "trade_seq": 1,
1416            "tick_direction": 0,
1417            "index_price": 92276.75,
1418            "mark_price": 92287.11,
1419            "block_trade_id": block_trade_id,
1420            "block_rfq_id": block_rfq_id,
1421            "combo_id": combo_id,
1422        });
1423        serde_json::from_str(&raw.to_string()).unwrap()
1424    }
1425
1426    #[rstest]
1427    fn test_parse_trade_msg_tags_block_trade() {
1428        let instrument = test_perpetual_instrument();
1429        let msg = make_trade_msg("BTC-PERPETUAL", "244343055", Some("12345"), None, None);
1430        let tick = parse_trade_msg(&msg, &instrument, UnixNanos::default()).unwrap();
1431        assert_eq!(tick.trade_id.to_string(), "BLK-244343055");
1432    }
1433
1434    #[rstest]
1435    fn test_parse_trade_msg_tags_block_rfq() {
1436        let instrument = test_perpetual_instrument();
1437        let msg = make_trade_msg("BTC-PERPETUAL", "244343055", None, Some(99), None);
1438        let tick = parse_trade_msg(&msg, &instrument, UnixNanos::default()).unwrap();
1439        assert_eq!(tick.trade_id.to_string(), "RFQ-244343055");
1440    }
1441
1442    #[rstest]
1443    fn test_parse_trade_msg_tags_combo_leg() {
1444        // Per-leg trade originating from a combo: combo_id is set by Deribit
1445        // even though the instrument is a plain perp / option, so downstream
1446        // sees `COMBO-` and can detect combo-origin fills.
1447        let instrument = test_perpetual_instrument();
1448        let msg = make_trade_msg(
1449            "BTC-PERPETUAL",
1450            "244343055",
1451            None,
1452            None,
1453            Some("BTC-FS-25DEC26_PERP"),
1454        );
1455        let tick = parse_trade_msg(&msg, &instrument, UnixNanos::default()).unwrap();
1456        assert_eq!(tick.trade_id.to_string(), "COMBO-244343055");
1457    }
1458
1459    fn load_combo_option_instrument() -> InstrumentAny {
1460        let combo_json = load_test_json("http_get_instruments_option_combo.json");
1461        let combo_response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1462            serde_json::from_str(&combo_json).unwrap();
1463        let combo_raw = combo_response
1464            .result
1465            .unwrap()
1466            .into_iter()
1467            .find(|i| i.instrument_name == "BTC-CS-19MAY26-70000_75000")
1468            .expect("fixture must contain BTC-CS-19MAY26-70000_75000");
1469        parse_deribit_instrument_any(&combo_raw, UnixNanos::default(), UnixNanos::default())
1470            .unwrap()
1471            .unwrap()
1472    }
1473
1474    fn load_combo_trade_msgs() -> Vec<DeribitTradeMsg> {
1475        let json = load_test_json("ws_trades_option_combo.json");
1476        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1477        serde_json::from_str(&response["params"]["data"].to_string()).unwrap()
1478    }
1479
1480    #[rstest]
1481    fn test_parse_trades_data_combo_emits_single_tick() {
1482        // Step 1 finding: combo legs already publish on their own per-leg
1483        // streams. Parsing a combo trade message should produce exactly one
1484        // TradeTick (for the combo), not N+1.
1485        let combo_inst = load_combo_option_instrument();
1486        let mut cache: AHashMap<Ustr, InstrumentAny> = AHashMap::new();
1487        cache.insert(Ustr::from("BTC-CS-19MAY26-70000_75000"), combo_inst.clone());
1488
1489        let trades = load_combo_trade_msgs();
1490        // Sanity: the combo message itself carries the legs array.
1491        let legs = trades[0].legs.as_ref().expect("combo trade must have legs");
1492        assert_eq!(legs.len(), 2);
1493
1494        let data = parse_trades_data(&trades, &cache, UnixNanos::default());
1495        assert_eq!(data.len(), 1, "should emit one tick for the combo only");
1496
1497        let Data::Trade(tick) = &data[0] else {
1498            panic!("expected Data::Trade");
1499        };
1500        assert_eq!(tick.instrument_id, combo_inst.id());
1501        // Exact values from fixture; independent of the instrument under test
1502        // so a regression in price/size precision is caught here too.
1503        assert_eq!(tick.price, Price::from("0.0639"));
1504        assert_eq!(tick.size, Quantity::from("0.1"));
1505        assert_eq!(tick.aggressor_side, AggressorSide::Sell);
1506        assert_eq!(tick.trade_id.to_string(), "244365193");
1507    }
1508
1509    #[rstest]
1510    fn test_parse_trades_data_combo_not_cached_emits_no_tick() {
1511        // When the combo InstrumentAny is not in the WS handler cache,
1512        // parse_trades_data must drop the message rather than panic or
1513        // synthesize a tick against an unknown instrument.
1514        let cache: AHashMap<Ustr, InstrumentAny> = AHashMap::new();
1515        let trades = load_combo_trade_msgs();
1516        let data = parse_trades_data(&trades, &cache, UnixNanos::default());
1517        assert!(data.is_empty(), "uncached combo must not emit ticks");
1518    }
1519
1520    #[rstest]
1521    fn test_parse_trades_data_mixed_combo_and_per_leg() {
1522        // Combo trade and a plain per-leg trade on the underlying perpetual,
1523        // both cached, must each produce a tick against their own instrument.
1524        let combo_inst = load_combo_option_instrument();
1525        let perp_inst = test_perpetual_instrument();
1526
1527        let mut cache: AHashMap<Ustr, InstrumentAny> = AHashMap::new();
1528        cache.insert(Ustr::from("BTC-CS-19MAY26-70000_75000"), combo_inst.clone());
1529        cache.insert(Ustr::from("BTC-PERPETUAL"), perp_inst.clone());
1530
1531        let mut trades = load_combo_trade_msgs();
1532        let perp_msgs: Vec<DeribitTradeMsg> = {
1533            let json = load_test_json("ws_trades.json");
1534            let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1535            serde_json::from_str(&response["params"]["data"].to_string()).unwrap()
1536        };
1537        trades.extend(perp_msgs);
1538
1539        let data = parse_trades_data(&trades, &cache, UnixNanos::default());
1540        // 1 combo + 2 plain BTC-PERPETUAL trades from the existing fixture.
1541        assert_eq!(data.len(), 3);
1542
1543        let mut combo_ticks = 0;
1544        let mut perp_ticks = 0;
1545
1546        for item in &data {
1547            let Data::Trade(tick) = item else {
1548                panic!("expected Data::Trade, was {item:?}");
1549            };
1550
1551            if tick.instrument_id == combo_inst.id() {
1552                combo_ticks += 1;
1553                assert_eq!(tick.trade_id.to_string(), "244365193");
1554            } else if tick.instrument_id == perp_inst.id() {
1555                perp_ticks += 1;
1556            } else {
1557                panic!("unexpected instrument_id: {}", tick.instrument_id);
1558            }
1559        }
1560        assert_eq!(combo_ticks, 1);
1561        assert_eq!(perp_ticks, 2);
1562    }
1563
1564    #[rstest]
1565    fn test_parse_book_snapshot() {
1566        let instrument = test_perpetual_instrument();
1567        let json = load_test_json("ws_book_snapshot.json");
1568        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1569        let data = response["params"]["data"].to_string();
1570        let msg: DeribitBookMsg = serde_json::from_str(&data).unwrap();
1571
1572        let deltas = parse_book_snapshot(&msg, &instrument, UnixNanos::default()).unwrap();
1573
1574        assert_eq!(deltas.instrument_id, instrument.id());
1575        // Should have CLEAR + 5 bids + 5 asks = 11 deltas
1576        assert_eq!(deltas.deltas.len(), 11);
1577
1578        // First delta should be CLEAR
1579        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1580
1581        // Check first bid
1582        let first_bid = &deltas.deltas[1];
1583        assert_eq!(first_bid.action, BookAction::Add);
1584        assert_eq!(first_bid.order.side, OrderSide::Buy.into());
1585        assert_eq!(first_bid.order.price, instrument.make_price(42500.0));
1586        assert_eq!(first_bid.order.size, instrument.make_qty(1000.0, None));
1587
1588        // Check first ask
1589        let first_ask = &deltas.deltas[6];
1590        assert_eq!(first_ask.action, BookAction::Add);
1591        assert_eq!(first_ask.order.side, OrderSide::Sell.into());
1592        assert_eq!(first_ask.order.price, instrument.make_price(42501.0));
1593        assert_eq!(first_ask.order.size, instrument.make_qty(800.0, None));
1594
1595        // Check F_LAST flag on last delta
1596        let last = deltas.deltas.last().unwrap();
1597        assert_eq!(
1598            last.flags & RecordFlag::F_LAST as u8,
1599            RecordFlag::F_LAST as u8
1600        );
1601    }
1602
1603    #[rstest]
1604    fn test_parse_book_delta() {
1605        let instrument = test_perpetual_instrument();
1606        let json = load_test_json("ws_book_delta.json");
1607        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1608        let data = response["params"]["data"].to_string();
1609        let msg: DeribitBookMsg = serde_json::from_str(&data).unwrap();
1610
1611        let deltas = parse_book_delta(&msg, &instrument, UnixNanos::default()).unwrap();
1612
1613        assert_eq!(deltas.instrument_id, instrument.id());
1614        // Should have 2 bid deltas + 2 ask deltas = 4 deltas
1615        assert_eq!(deltas.deltas.len(), 4);
1616
1617        // Check first bid - "change" action
1618        let bid_change = &deltas.deltas[0];
1619        assert_eq!(bid_change.action, BookAction::Update);
1620        assert_eq!(bid_change.order.side, OrderSide::Buy.into());
1621        assert_eq!(bid_change.order.price, instrument.make_price(42500.0));
1622        assert_eq!(bid_change.order.size, instrument.make_qty(950.0, None));
1623
1624        // Check second bid - "new" action
1625        let bid_new = &deltas.deltas[1];
1626        assert_eq!(bid_new.action, BookAction::Add);
1627        assert_eq!(bid_new.order.side, OrderSide::Buy.into());
1628        assert_eq!(bid_new.order.price, instrument.make_price(42498.5));
1629        assert_eq!(bid_new.order.size, instrument.make_qty(300.0, None));
1630
1631        // Check first ask - "delete" action
1632        let ask_delete = &deltas.deltas[2];
1633        assert_eq!(ask_delete.action, BookAction::Delete);
1634        assert_eq!(ask_delete.order.side, OrderSide::Sell.into());
1635        assert_eq!(ask_delete.order.price, instrument.make_price(42501.0));
1636        assert_eq!(ask_delete.order.size, instrument.make_qty(0.0, None));
1637
1638        // Check second ask - "change" action
1639        let ask_change = &deltas.deltas[3];
1640        assert_eq!(ask_change.action, BookAction::Update);
1641        assert_eq!(ask_change.order.side, OrderSide::Sell.into());
1642        assert_eq!(ask_change.order.price, instrument.make_price(42501.5));
1643        assert_eq!(ask_change.order.size, instrument.make_qty(700.0, None));
1644
1645        // Check F_LAST flag on last delta
1646        let last = deltas.deltas.last().unwrap();
1647        assert_eq!(
1648            last.flags & RecordFlag::F_LAST as u8,
1649            RecordFlag::F_LAST as u8
1650        );
1651    }
1652
1653    #[rstest]
1654    fn test_parse_ticker_to_quote() {
1655        let instrument = test_perpetual_instrument();
1656        let json = load_test_json("ws_ticker.json");
1657        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1658        let msg: DeribitTickerMsg =
1659            serde_json::from_str(&response["params"]["data"].to_string()).unwrap();
1660
1661        // Verify the message was deserialized correctly
1662        assert_eq!(msg.instrument_name, "BTC-PERPETUAL");
1663        assert_eq!(msg.timestamp, 1_765_541_474_086);
1664        assert_eq!(msg.best_bid_price, Some(dec!(92283.5)));
1665        assert_eq!(msg.best_ask_price, Some(dec!(92284.0)));
1666        assert_eq!(msg.best_bid_amount, Some(dec!(117660.0)));
1667        assert_eq!(msg.best_ask_amount, Some(dec!(186520.0)));
1668        assert_eq!(msg.mark_price, dec!(92281.78));
1669        assert_eq!(msg.index_price, dec!(92263.55));
1670        assert_eq!(msg.open_interest, dec!(1132329370.0));
1671
1672        let quote = parse_ticker_to_quote(&msg, &instrument, UnixNanos::default()).unwrap();
1673
1674        assert_eq!(quote.instrument_id, instrument.id());
1675        assert_eq!(quote.bid_price, instrument.make_price(92283.5));
1676        assert_eq!(quote.ask_price, instrument.make_price(92284.0));
1677        assert_eq!(quote.bid_size, instrument.make_qty(117660.0, None));
1678        assert_eq!(quote.ask_size, instrument.make_qty(186520.0, None));
1679        assert_eq!(quote.ts_event, UnixNanos::new(1_765_541_474_086_000_000));
1680    }
1681
1682    #[rstest]
1683    fn test_parse_quote_msg() {
1684        let instrument = test_perpetual_instrument();
1685        let json = load_test_json("ws_quote.json");
1686        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1687        let msg: DeribitQuoteMsg =
1688            serde_json::from_str(&response["params"]["data"].to_string()).unwrap();
1689
1690        // Verify the message was deserialized correctly
1691        assert_eq!(msg.instrument_name, "BTC-PERPETUAL");
1692        assert_eq!(msg.timestamp, 1_765_541_767_174);
1693        assert_eq!(msg.best_bid_price, dec!(92288.0));
1694        assert_eq!(msg.best_ask_price, dec!(92288.5));
1695        assert_eq!(msg.best_bid_amount, dec!(133440.0));
1696        assert_eq!(msg.best_ask_amount, dec!(99470.0));
1697
1698        let quote = parse_quote_msg(&msg, &instrument, UnixNanos::default()).unwrap();
1699
1700        assert_eq!(quote.instrument_id, instrument.id());
1701        assert_eq!(quote.bid_price, instrument.make_price(92288.0));
1702        assert_eq!(quote.ask_price, instrument.make_price(92288.5));
1703        assert_eq!(quote.bid_size, instrument.make_qty(133440.0, None));
1704        assert_eq!(quote.ask_size, instrument.make_qty(99470.0, None));
1705        assert_eq!(quote.ts_event, UnixNanos::new(1_765_541_767_174_000_000));
1706    }
1707
1708    #[rstest]
1709    fn test_parse_book_msg_snapshot() {
1710        let instrument = test_perpetual_instrument();
1711        let json = load_test_json("ws_book_snapshot.json");
1712        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1713        let data = response["params"]["data"].to_string();
1714        let msg: DeribitBookMsg = serde_json::from_str(&data).unwrap();
1715
1716        // Validate raw message format - snapshots use 3-element arrays: ["new", price, amount]
1717        assert_eq!(
1718            msg.bids[0].len(),
1719            3,
1720            "Snapshot bids should have 3 elements: [action, price, amount]"
1721        );
1722        assert_eq!(
1723            msg.bids[0][0].get(),
1724            r#""new""#,
1725            "First element should be 'new' action for snapshot"
1726        );
1727        assert_eq!(
1728            msg.asks[0].len(),
1729            3,
1730            "Snapshot asks should have 3 elements: [action, price, amount]"
1731        );
1732        assert_eq!(
1733            msg.asks[0][0].get(),
1734            r#""new""#,
1735            "First element should be 'new' action for snapshot"
1736        );
1737
1738        let deltas = parse_book_msg(&msg, &instrument, UnixNanos::default()).unwrap();
1739
1740        assert_eq!(deltas.instrument_id, instrument.id());
1741        // Should have CLEAR + 5 bids + 5 asks = 11 deltas
1742        assert_eq!(deltas.deltas.len(), 11);
1743
1744        // First delta should be CLEAR
1745        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1746
1747        // Verify first bid was parsed correctly from ["new", 42500.0, 1000.0]
1748        let first_bid = &deltas.deltas[1];
1749        assert_eq!(first_bid.action, BookAction::Add);
1750        assert_eq!(first_bid.order.side, OrderSide::Buy.into());
1751        assert_eq!(first_bid.order.price, instrument.make_price(42500.0));
1752        assert_eq!(first_bid.order.size, instrument.make_qty(1000.0, None));
1753
1754        // Verify first ask was parsed correctly from ["new", 42501.0, 800.0]
1755        let first_ask = &deltas.deltas[6];
1756        assert_eq!(first_ask.action, BookAction::Add);
1757        assert_eq!(first_ask.order.side, OrderSide::Sell.into());
1758        assert_eq!(first_ask.order.price, instrument.make_price(42501.0));
1759        assert_eq!(first_ask.order.size, instrument.make_qty(800.0, None));
1760    }
1761
1762    #[rstest]
1763    fn test_parse_book_msg_delta() {
1764        let instrument = test_perpetual_instrument();
1765        let json = load_test_json("ws_book_delta.json");
1766        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1767        let data = response["params"]["data"].to_string();
1768        let msg: DeribitBookMsg = serde_json::from_str(&data).unwrap();
1769
1770        // Validate raw message format - deltas use 3-element arrays: [action, price, amount]
1771        assert_eq!(
1772            msg.bids[0].len(),
1773            3,
1774            "Delta bids should have 3 elements: [action, price, amount]"
1775        );
1776        assert_eq!(
1777            msg.bids[0][0].get(),
1778            r#""change""#,
1779            "First bid should be 'change' action"
1780        );
1781        assert_eq!(
1782            msg.bids[1][0].get(),
1783            r#""new""#,
1784            "Second bid should be 'new' action"
1785        );
1786        assert_eq!(
1787            msg.asks[0].len(),
1788            3,
1789            "Delta asks should have 3 elements: [action, price, amount]"
1790        );
1791        assert_eq!(
1792            msg.asks[0][0].get(),
1793            r#""delete""#,
1794            "First ask should be 'delete' action"
1795        );
1796
1797        let deltas = parse_book_msg(&msg, &instrument, UnixNanos::default()).unwrap();
1798
1799        assert_eq!(deltas.instrument_id, instrument.id());
1800        // Should have 2 bid deltas + 2 ask deltas = 4 deltas
1801        assert_eq!(deltas.deltas.len(), 4);
1802
1803        // Delta should not have CLEAR action
1804        assert_ne!(deltas.deltas[0].action, BookAction::Clear);
1805
1806        // Verify first bid "change" action was parsed correctly from ["change", 42500.0, 950.0]
1807        let bid_change = &deltas.deltas[0];
1808        assert_eq!(bid_change.action, BookAction::Update);
1809        assert_eq!(bid_change.order.side, OrderSide::Buy.into());
1810        assert_eq!(bid_change.order.price, instrument.make_price(42500.0));
1811        assert_eq!(bid_change.order.size, instrument.make_qty(950.0, None));
1812
1813        // Verify second bid "new" action was parsed correctly from ["new", 42498.5, 300.0]
1814        let bid_new = &deltas.deltas[1];
1815        assert_eq!(bid_new.action, BookAction::Add);
1816        assert_eq!(bid_new.order.side, OrderSide::Buy.into());
1817        assert_eq!(bid_new.order.price, instrument.make_price(42498.5));
1818        assert_eq!(bid_new.order.size, instrument.make_qty(300.0, None));
1819
1820        // Verify first ask "delete" action was parsed correctly from ["delete", 42501.0, 0.0]
1821        let ask_delete = &deltas.deltas[2];
1822        assert_eq!(ask_delete.action, BookAction::Delete);
1823        assert_eq!(ask_delete.order.side, OrderSide::Sell.into());
1824        assert_eq!(ask_delete.order.price, instrument.make_price(42501.0));
1825
1826        // Verify second ask "change" action was parsed correctly from ["change", 42501.5, 700.0]
1827        let ask_change = &deltas.deltas[3];
1828        assert_eq!(ask_change.action, BookAction::Update);
1829        assert_eq!(ask_change.order.side, OrderSide::Sell.into());
1830        assert_eq!(ask_change.order.price, instrument.make_price(42501.5));
1831        assert_eq!(ask_change.order.size, instrument.make_qty(700.0, None));
1832    }
1833
1834    #[rstest]
1835    fn test_parse_book_grouped_snapshot() {
1836        // Test parsing grouped book channel format: book.{instrument}.{group}.{depth}.{interval}
1837        // This format has NO type field and uses 2-element arrays [price, amount]
1838        let instrument = test_perpetual_instrument();
1839        let json = load_test_json("ws_book_grouped_snapshot.json");
1840        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1841        let data = response["params"]["data"].to_string();
1842        let msg: DeribitBookMsg = serde_json::from_str(&data).unwrap();
1843
1844        // Validate raw message format - grouped channel uses 2-element arrays: [price, amount]
1845        assert_eq!(
1846            msg.bids[0].len(),
1847            2,
1848            "Grouped bids should have 2 elements: [price, amount]"
1849        );
1850        assert_eq!(
1851            msg.asks[0].len(),
1852            2,
1853            "Grouped asks should have 2 elements: [price, amount]"
1854        );
1855
1856        // Verify msg_type defaults to Snapshot (grouped channel has no type field)
1857        assert_eq!(
1858            msg.msg_type,
1859            DeribitBookMsgType::Snapshot,
1860            "Grouped channel should default to Snapshot type"
1861        );
1862
1863        let deltas = parse_book_snapshot(&msg, &instrument, UnixNanos::default()).unwrap();
1864
1865        assert_eq!(deltas.instrument_id, instrument.id());
1866        // Should have CLEAR + 10 bids + 10 asks = 21 deltas
1867        assert_eq!(deltas.deltas.len(), 21);
1868
1869        // First delta should be CLEAR
1870        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1871
1872        // Verify first bid was parsed correctly from [89532.5, 254900.0]
1873        let first_bid = &deltas.deltas[1];
1874        assert_eq!(first_bid.action, BookAction::Add);
1875        assert_eq!(first_bid.order.side, OrderSide::Buy.into());
1876        assert_eq!(first_bid.order.price, instrument.make_price(89532.5));
1877        assert_eq!(first_bid.order.size, instrument.make_qty(254900.0, None));
1878
1879        // Verify first ask was parsed correctly from [89533.0, 91570.0]
1880        let first_ask = &deltas.deltas[11];
1881        assert_eq!(first_ask.action, BookAction::Add);
1882        assert_eq!(first_ask.order.side, OrderSide::Sell.into());
1883        assert_eq!(first_ask.order.price, instrument.make_price(89533.0));
1884        assert_eq!(first_ask.order.size, instrument.make_qty(91570.0, None));
1885
1886        // Check F_LAST flag on last delta
1887        let last = deltas.deltas.last().unwrap();
1888        assert_eq!(
1889            last.flags & RecordFlag::F_LAST as u8,
1890            RecordFlag::F_LAST as u8
1891        );
1892    }
1893
1894    #[rstest]
1895    fn test_parse_ticker_to_mark_price() {
1896        let instrument = test_perpetual_instrument();
1897        let json = load_test_json("ws_ticker.json");
1898        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1899        let msg: DeribitTickerMsg =
1900            serde_json::from_str(&response["params"]["data"].to_string()).unwrap();
1901
1902        let mark_price =
1903            parse_ticker_to_mark_price(&msg, &instrument, UnixNanos::default()).unwrap();
1904
1905        assert_eq!(mark_price.instrument_id, instrument.id());
1906        assert_eq!(mark_price.value, instrument.make_price(92281.78));
1907        assert_eq!(
1908            mark_price.ts_event,
1909            UnixNanos::new(1_765_541_474_086_000_000)
1910        );
1911    }
1912
1913    #[rstest]
1914    fn test_parse_ticker_to_index_price() {
1915        let instrument = test_perpetual_instrument();
1916        let json = load_test_json("ws_ticker.json");
1917        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1918        let msg: DeribitTickerMsg =
1919            serde_json::from_str(&response["params"]["data"].to_string()).unwrap();
1920
1921        let index_price =
1922            parse_ticker_to_index_price(&msg, &instrument, UnixNanos::default()).unwrap();
1923
1924        assert_eq!(index_price.instrument_id, instrument.id());
1925        assert_eq!(index_price.value, instrument.make_price(92263.55));
1926        assert_eq!(
1927            index_price.ts_event,
1928            UnixNanos::new(1_765_541_474_086_000_000)
1929        );
1930    }
1931
1932    #[rstest]
1933    fn test_parse_ticker_to_funding_rate() {
1934        let instrument = test_perpetual_instrument();
1935        let json = load_test_json("ws_ticker.json");
1936        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1937        let msg: DeribitTickerMsg =
1938            serde_json::from_str(&response["params"]["data"].to_string()).unwrap();
1939
1940        // Verify current_funding exists in the message
1941        assert!(msg.current_funding.is_some());
1942
1943        let funding_rate =
1944            parse_ticker_to_funding_rate(&msg, &instrument, UnixNanos::default()).unwrap();
1945
1946        assert_eq!(funding_rate.instrument_id, instrument.id());
1947        // The test fixture has current_funding value
1948        assert_eq!(
1949            funding_rate.ts_event,
1950            UnixNanos::new(1_765_541_474_086_000_000)
1951        );
1952        assert!(funding_rate.interval.is_none());
1953        assert!(funding_rate.next_funding_ns.is_none()); // Not available in ticker
1954    }
1955
1956    #[rstest]
1957    fn test_resolution_to_bar_type_1_minute() {
1958        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1959        let bar_type = resolution_to_bar_type(instrument_id, "1").unwrap();
1960
1961        assert_eq!(bar_type.instrument_id(), instrument_id);
1962        assert_eq!(bar_type.spec().step.get(), 1);
1963        assert_eq!(bar_type.spec().aggregation, BarAggregation::Minute);
1964        assert_eq!(bar_type.spec().price_type, PriceType::Last);
1965        assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1966    }
1967
1968    #[rstest]
1969    fn test_resolution_to_bar_type_60_minute() {
1970        let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
1971        let bar_type = resolution_to_bar_type(instrument_id, "60").unwrap();
1972
1973        assert_eq!(bar_type.instrument_id(), instrument_id);
1974        assert_eq!(bar_type.spec().step.get(), 1);
1975        assert_eq!(bar_type.spec().aggregation, BarAggregation::Hour);
1976    }
1977
1978    #[rstest]
1979    fn test_resolution_to_bar_type_daily() {
1980        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1981        let bar_type = resolution_to_bar_type(instrument_id, "1D").unwrap();
1982
1983        assert_eq!(bar_type.instrument_id(), instrument_id);
1984        assert_eq!(bar_type.spec().step.get(), 1);
1985        assert_eq!(bar_type.spec().aggregation, BarAggregation::Day);
1986    }
1987
1988    #[rstest]
1989    fn test_resolution_to_bar_type_invalid() {
1990        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1991        let result = resolution_to_bar_type(instrument_id, "invalid");
1992
1993        assert!(result.is_err());
1994        assert!(
1995            result
1996                .unwrap_err()
1997                .to_string()
1998                .contains("Unsupported Deribit resolution")
1999        );
2000    }
2001
2002    #[rstest]
2003    fn test_parse_chart_msg_uses_cost() {
2004        let instrument = test_perpetual_instrument();
2005        assert!(
2006            instrument.is_inverse(),
2007            "test fixture is expected to be an inverse perp"
2008        );
2009
2010        let json = load_test_json("ws_chart.json");
2011        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
2012        let chart_msg: DeribitChartMsg =
2013            serde_json::from_str(&response["params"]["data"].to_string()).unwrap();
2014
2015        // Verify chart message was deserialized correctly
2016        assert_eq!(chart_msg.tick, 1_767_200_040_000);
2017        assert_eq!(chart_msg.open, dec!(87490.0));
2018        assert_eq!(chart_msg.high, dec!(87500.0));
2019        assert_eq!(chart_msg.low, dec!(87465.0));
2020        assert_eq!(chart_msg.close, dec!(87474.0));
2021        assert_eq!(chart_msg.volume, dec!(0.95978896));
2022        assert_eq!(chart_msg.cost, dec!(83970.0));
2023
2024        let bar_type = resolution_to_bar_type(instrument.id(), "1").unwrap();
2025
2026        // Test with timestamp_on_close=true (default)
2027        let bar = parse_chart_msg(
2028            &chart_msg,
2029            bar_type,
2030            instrument.price_precision(),
2031            instrument.size_precision(),
2032            true, // use_cost_for_volume
2033            true,
2034            UnixNanos::default(),
2035        )
2036        .unwrap();
2037
2038        assert_eq!(bar.bar_type, bar_type);
2039        assert_eq!(bar.open, instrument.make_price(87490.0));
2040        assert_eq!(bar.high, instrument.make_price(87500.0));
2041        assert_eq!(bar.low, instrument.make_price(87465.0));
2042        assert_eq!(bar.close, instrument.make_price(87474.0));
2043        assert_eq!(bar.volume, instrument.make_qty(83970.0, None));
2044
2045        // ts_event should be close time (open + 1 minute)
2046        assert_eq!(bar.ts_event, UnixNanos::new(1_767_200_100_000_000_000));
2047    }
2048
2049    #[rstest]
2050    fn test_parse_order_buy_response() {
2051        let instrument = test_perpetual_instrument();
2052        let json = load_test_json("ws_order_buy_response.json");
2053        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
2054
2055        // Parse the order from the response (buy/sell responses wrap order in {"order": ...})
2056        let order_msg: DeribitOrderMsg =
2057            serde_json::from_str(&response["result"]["order"].to_string()).unwrap();
2058
2059        // Verify deserialization
2060        assert_eq!(order_msg.order_id, "USDC-104819327443");
2061        assert_eq!(
2062            order_msg.label,
2063            Some("O-19700101-000000-001-001-1".to_string())
2064        );
2065        assert_eq!(order_msg.direction, "buy");
2066        assert_eq!(order_msg.order_state, "open");
2067        assert_eq!(order_msg.order_type, "limit");
2068        assert_eq!(order_msg.price, Some(dec!(2973.55)));
2069        assert_eq!(order_msg.amount, dec!(0.001));
2070        assert_eq!(order_msg.filled_amount, rust_decimal::Decimal::ZERO);
2071        assert!(order_msg.post_only);
2072        assert!(!order_msg.reduce_only);
2073
2074        // Test parse_order_accepted
2075        let account_id = AccountId::new("DERIBIT-001");
2076        let trader_id = TraderId::new("TRADER-001");
2077        let strategy_id = StrategyId::new("PMM-001");
2078
2079        let accepted = parse_order_accepted(
2080            &order_msg,
2081            &instrument,
2082            account_id,
2083            trader_id,
2084            strategy_id,
2085            UnixNanos::default(),
2086        );
2087
2088        assert_eq!(
2089            accepted.client_order_id.to_string(),
2090            "O-19700101-000000-001-001-1"
2091        );
2092        assert_eq!(accepted.venue_order_id.to_string(), "USDC-104819327443");
2093        assert_eq!(accepted.trader_id, trader_id);
2094        assert_eq!(accepted.strategy_id, strategy_id);
2095        assert_eq!(accepted.account_id, account_id);
2096    }
2097
2098    #[rstest]
2099    fn test_parse_order_sell_response() {
2100        let instrument = test_perpetual_instrument();
2101        let json = load_test_json("ws_order_sell_response.json");
2102        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
2103
2104        let order_msg: DeribitOrderMsg =
2105            serde_json::from_str(&response["result"]["order"].to_string()).unwrap();
2106
2107        // Verify deserialization
2108        assert_eq!(order_msg.order_id, "USDC-104819327458");
2109        assert_eq!(
2110            order_msg.label,
2111            Some("O-19700101-000000-001-001-2".to_string())
2112        );
2113        assert_eq!(order_msg.direction, "sell");
2114        assert_eq!(order_msg.order_state, "open");
2115        assert_eq!(order_msg.price, Some(dec!(3286.7)));
2116        assert_eq!(order_msg.amount, dec!(0.001));
2117
2118        // Test parse_order_accepted for sell order
2119        let account_id = AccountId::new("DERIBIT-001");
2120        let trader_id = TraderId::new("TRADER-001");
2121        let strategy_id = StrategyId::new("PMM-001");
2122
2123        let accepted = parse_order_accepted(
2124            &order_msg,
2125            &instrument,
2126            account_id,
2127            trader_id,
2128            strategy_id,
2129            UnixNanos::default(),
2130        );
2131
2132        assert_eq!(
2133            accepted.client_order_id.to_string(),
2134            "O-19700101-000000-001-001-2"
2135        );
2136        assert_eq!(accepted.venue_order_id.to_string(), "USDC-104819327458");
2137        assert_eq!(accepted.trader_id, trader_id);
2138        assert_eq!(accepted.strategy_id, strategy_id);
2139        assert_eq!(accepted.account_id, account_id);
2140    }
2141
2142    #[rstest]
2143    fn test_parse_order_edit_response() {
2144        let instrument = test_perpetual_instrument();
2145        let json = load_test_json("ws_order_edit_response.json");
2146        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
2147
2148        let order_msg: DeribitOrderMsg =
2149            serde_json::from_str(&response["result"]["order"].to_string()).unwrap();
2150
2151        // Verify deserialization - edit response has replaced=true in raw JSON
2152        assert_eq!(order_msg.order_id, "USDC-104819327443");
2153        assert_eq!(
2154            order_msg.label,
2155            Some("O-19700101-000000-001-001-1".to_string())
2156        );
2157        assert_eq!(order_msg.direction, "buy");
2158        assert_eq!(order_msg.order_state, "open");
2159        assert!(order_msg.replaced);
2160        assert_eq!(order_msg.price, Some(dec!(3067.2))); // New price after edit
2161
2162        // Test parse_order_updated
2163        let account_id = AccountId::new("DERIBIT-001");
2164        let trader_id = TraderId::new("TRADER-001");
2165        let strategy_id = StrategyId::new("PMM-001");
2166
2167        let updated = parse_order_updated(
2168            &order_msg,
2169            &instrument,
2170            account_id,
2171            trader_id,
2172            strategy_id,
2173            UnixNanos::default(),
2174        );
2175
2176        assert_eq!(
2177            updated.client_order_id.to_string(),
2178            "O-19700101-000000-001-001-1"
2179        );
2180        assert_eq!(
2181            updated.venue_order_id.unwrap().to_string(),
2182            "USDC-104819327443"
2183        );
2184        // Note: 0.001 truncates to 0.0 due to BTC-PERPETUAL size_precision=0
2185        assert_eq!(updated.quantity.as_f64(), 0.0);
2186    }
2187
2188    #[rstest]
2189    fn test_parse_order_cancel_response() {
2190        let instrument = test_perpetual_instrument();
2191        let json = load_test_json("ws_order_cancel_response.json");
2192        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
2193
2194        // Cancel response has order fields directly in result (not wrapped)
2195        let order_msg: DeribitOrderMsg =
2196            serde_json::from_str(&response["result"].to_string()).unwrap();
2197
2198        // Verify deserialization
2199        assert_eq!(order_msg.order_id, "USDC-104819327443");
2200        assert_eq!(
2201            order_msg.label,
2202            Some("O-19700101-000000-001-001-1".to_string())
2203        );
2204        assert_eq!(order_msg.order_state, "cancelled");
2205        assert_eq!(order_msg.cancel_reason, Some("user_request".to_string()));
2206
2207        // Test parse_order_canceled
2208        let account_id = AccountId::new("DERIBIT-001");
2209        let trader_id = TraderId::new("TRADER-001");
2210        let strategy_id = StrategyId::new("PMM-001");
2211
2212        let canceled = parse_order_canceled(
2213            &order_msg,
2214            &instrument,
2215            account_id,
2216            trader_id,
2217            strategy_id,
2218            UnixNanos::default(),
2219        );
2220
2221        assert_eq!(
2222            canceled.client_order_id.to_string(),
2223            "O-19700101-000000-001-001-1"
2224        );
2225        assert_eq!(
2226            canceled.venue_order_id.unwrap().to_string(),
2227            "USDC-104819327443"
2228        );
2229        assert_eq!(canceled.trader_id, trader_id);
2230        assert_eq!(canceled.strategy_id, strategy_id);
2231    }
2232
2233    #[rstest]
2234    fn test_parse_order_stop_market_response() {
2235        // Regression for https://github.com/nautechsystems/nautilus_trader/issues/3925
2236        // Deribit returns the literal string "market_price" for the price of
2237        // trigger market orders; the deserializer must map this to None rather
2238        // than failing with "Invalid decimal: unknown character".
2239        let instrument = test_perpetual_instrument();
2240        let json = load_test_json("ws_order_stop_market_response.json");
2241        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
2242
2243        let order_msg: DeribitOrderMsg =
2244            serde_json::from_str(&response["result"]["order"].to_string()).unwrap();
2245
2246        assert_eq!(order_msg.order_id, "USDC-104819327499");
2247        assert_eq!(order_msg.order_type, "stop_market");
2248        assert_eq!(order_msg.order_state, "untriggered");
2249        assert_eq!(order_msg.price, None);
2250        assert_eq!(order_msg.trigger_price, Some(dec!(2228.0)));
2251        assert_eq!(order_msg.trigger.as_deref(), Some("mark_price"));
2252        assert!(order_msg.reduce_only);
2253
2254        let account_id = AccountId::new("DERIBIT-001");
2255        let report =
2256            parse_user_order_msg(&order_msg, &instrument, account_id, UnixNanos::default())
2257                .unwrap();
2258
2259        assert_eq!(report.order_type, OrderType::StopMarket);
2260        assert_eq!(report.order_status, OrderStatus::Accepted);
2261        assert!(report.price.is_none());
2262        assert!(report.trigger_price.is_some());
2263        assert!(report.reduce_only);
2264    }
2265
2266    #[rstest]
2267    fn test_parse_order_stop_market_response_missing_filled_amount() {
2268        // Regression for https://github.com/nautechsystems/nautilus_trader/issues/3995
2269        // Deribit omits `filled_amount` for untriggered trigger market orders;
2270        // the deserializer must treat the missing field as zero rather than
2271        // failing with "missing field `filled_amount`".
2272        let instrument = test_perpetual_instrument();
2273        let json = load_test_json("ws_order_stop_market_no_filled_amount.json");
2274        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
2275
2276        let order_msg: DeribitOrderMsg =
2277            serde_json::from_str(&response["result"]["order"].to_string()).unwrap();
2278
2279        assert_eq!(order_msg.order_id, "USDC-SLMB-19641");
2280        assert_eq!(order_msg.order_type, "stop_market");
2281        assert_eq!(order_msg.order_state, "untriggered");
2282        assert_eq!(order_msg.filled_amount, rust_decimal::Decimal::ZERO);
2283        assert_eq!(order_msg.average_price, None);
2284
2285        let account_id = AccountId::new("DERIBIT-001");
2286        let report =
2287            parse_user_order_msg(&order_msg, &instrument, account_id, UnixNanos::default())
2288                .unwrap();
2289
2290        assert_eq!(report.order_type, OrderType::StopMarket);
2291        assert_eq!(report.order_status, OrderStatus::Accepted);
2292        assert_eq!(report.filled_qty.as_f64(), 0.0);
2293    }
2294
2295    #[rstest]
2296    fn test_parse_user_order_msg_to_status_report() {
2297        let instrument = test_perpetual_instrument();
2298        let json = load_test_json("ws_order_buy_response.json");
2299        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
2300
2301        let order_msg: DeribitOrderMsg =
2302            serde_json::from_str(&response["result"]["order"].to_string()).unwrap();
2303
2304        let account_id = AccountId::new("DERIBIT-001");
2305        let report =
2306            parse_user_order_msg(&order_msg, &instrument, account_id, UnixNanos::default())
2307                .unwrap();
2308
2309        assert_eq!(report.venue_order_id.to_string(), "USDC-104819327443");
2310        assert_eq!(
2311            report.client_order_id.unwrap().to_string(),
2312            "O-19700101-000000-001-001-1"
2313        );
2314        assert_eq!(report.order_side, OrderSide::Buy.into());
2315        assert_eq!(report.order_type, OrderType::Limit);
2316        assert_eq!(report.time_in_force, TimeInForce::Gtc);
2317        assert_eq!(report.order_status, OrderStatus::Accepted);
2318        // Note: 0.001 truncates to 0.0 due to BTC-PERPETUAL size_precision=0
2319        assert_eq!(report.quantity.as_f64(), 0.0);
2320        assert_eq!(report.filled_qty.as_f64(), 0.0);
2321        assert!(report.post_only);
2322        assert!(!report.reduce_only);
2323    }
2324
2325    #[rstest]
2326    fn test_determine_order_event_type() {
2327        // New order -> Accepted
2328        assert_eq!(
2329            determine_order_event_type("open", true, false),
2330            OrderEventType::Accepted
2331        );
2332
2333        // Amended order -> Updated
2334        assert_eq!(
2335            determine_order_event_type("open", false, true),
2336            OrderEventType::Updated
2337        );
2338
2339        // Cancelled order -> Canceled
2340        assert_eq!(
2341            determine_order_event_type("cancelled", false, false),
2342            OrderEventType::Canceled
2343        );
2344
2345        // Expired order -> Expired
2346        assert_eq!(
2347            determine_order_event_type("expired", false, false),
2348            OrderEventType::Expired
2349        );
2350
2351        // Filled order -> None (handled via trades)
2352        assert_eq!(
2353            determine_order_event_type("filled", false, false),
2354            OrderEventType::None
2355        );
2356    }
2357
2358    fn perpetual_instrument_with_increments(
2359        tick_size: &str,
2360        min_trade_amount: &str,
2361    ) -> InstrumentAny {
2362        let json = load_test_json("http_get_instruments.json");
2363        let mut response: serde_json::Value = serde_json::from_str(&json).unwrap();
2364        response["result"][0]["tick_size"] = serde_json::json!(tick_size);
2365        response["result"][0]["min_trade_amount"] = serde_json::json!(min_trade_amount);
2366        let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
2367            serde_json::from_str(&response.to_string()).unwrap();
2368        let instrument = &response.result.unwrap()[0];
2369        parse_deribit_instrument_any(instrument, UnixNanos::default(), UnixNanos::default())
2370            .unwrap()
2371            .unwrap()
2372    }
2373
2374    fn parse_notification_data<T: DeserializeOwned>(fixture: &str) -> T {
2375        serde_json::from_str(&notification_data(fixture)).unwrap()
2376    }
2377
2378    fn notification_data(fixture: &str) -> String {
2379        let json = load_test_json(fixture);
2380
2381        let DeribitWsMessage::Notification(notification) = parse_raw_message(&json).unwrap() else {
2382            panic!("Expected notification from {fixture}");
2383        };
2384
2385        notification.params.data.get().to_string()
2386    }
2387
2388    #[rstest]
2389    fn test_parse_user_trade_msg_keeps_decimals_that_collapse_in_f64() {
2390        let instrument = perpetual_instrument_with_increments("0.000000001", "0.000000001");
2391        let trades: Vec<DeribitUserTradeMsg> = parse_notification_data("ws_user_trades_exact.json");
2392        let account_id = AccountId::new("DERIBIT-001");
2393        let btc = Currency::BTC();
2394
2395        let first = parse_user_trade_msg(&trades[0], &instrument, account_id, UnixNanos::default())
2396            .unwrap();
2397        let second =
2398            parse_user_trade_msg(&trades[1], &instrument, account_id, UnixNanos::default())
2399                .unwrap();
2400
2401        let fee_f64 = "1000000000.12345678".parse::<f64>().unwrap();
2402        let next_fee_f64 = "1000000000.12345679".parse::<f64>().unwrap();
2403        assert_eq!(fee_f64.to_bits(), next_fee_f64.to_bits());
2404        assert_eq!(instrument.price_precision(), 9);
2405        assert_eq!(instrument.size_precision(), 9);
2406        assert_eq!(first.last_px, Price::from("100000000.123456789"));
2407        assert_eq!(first.last_qty, Quantity::from("100000000.123456789"));
2408        assert_eq!(
2409            first.commission,
2410            Money::from_decimal(dec!(1000000000.12345678), btc).unwrap()
2411        );
2412        assert_eq!(second.last_px, Price::from("100000000.123456788"));
2413        assert_eq!(second.last_qty, Quantity::from("100000000.123456788"));
2414        assert_eq!(
2415            second.commission,
2416            Money::from_decimal(dec!(1000000000.12345679), btc).unwrap()
2417        );
2418        assert_eq!(trades[0].mark_price, dec!(100000000.123456788));
2419        assert_eq!(trades[0].profit_loss, Some(dec!(0.30000000000000004)));
2420    }
2421
2422    #[rstest]
2423    fn test_parse_user_trade_msg_rounds_exact_token_half_even() {
2424        let instrument = perpetual_instrument_with_increments("0.01", "0.01");
2425        let trades: Vec<DeribitUserTradeMsg> = parse_notification_data("ws_user_trades_exact.json");
2426        let account_id = AccountId::new("DERIBIT-001");
2427
2428        let fill = parse_user_trade_msg(&trades[2], &instrument, account_id, UnixNanos::default())
2429            .unwrap();
2430
2431        let token_f64 = "100000000.005000001".parse::<f64>().unwrap();
2432        let via_f64 = Price::from_decimal_dp(Decimal::try_from(token_f64).unwrap(), 2).unwrap();
2433        assert_eq!(via_f64, Price::from("100000000.00"));
2434        assert_eq!(fill.last_px, Price::from("100000000.01"));
2435        assert_eq!(fill.last_qty, Quantity::from("100000000.01"));
2436        assert_eq!(
2437            fill.commission,
2438            Money::from_decimal(dec!(0.00011403), Currency::BTC()).unwrap()
2439        );
2440    }
2441
2442    #[rstest]
2443    fn test_parse_portfolio_keeps_decimals_that_collapse_in_f64() {
2444        let portfolio: DeribitPortfolioMsg = parse_notification_data("ws_portfolio_exact.json");
2445        let account_id = AccountId::new("DERIBIT-001");
2446        let btc = Currency::BTC();
2447
2448        let state =
2449            parse_portfolio_to_account_state(&portfolio, account_id, UnixNanos::default()).unwrap();
2450
2451        assert_eq!(state.balances.len(), 1);
2452        assert_eq!(
2453            state.balances[0].total,
2454            Money::from_decimal(dec!(1000000000.12345679), btc).unwrap()
2455        );
2456        assert_eq!(
2457            state.balances[0].free,
2458            Money::from_decimal(dec!(1000000000.12345678), btc).unwrap()
2459        );
2460        assert_eq!(
2461            state.balances[0].locked,
2462            Money::from_decimal(dec!(0.00000001), btc).unwrap()
2463        );
2464        assert_eq!(state.margins.len(), 1);
2465        assert_eq!(
2466            state.margins[0].initial,
2467            Money::from_decimal(dec!(1000000000.12345678), btc).unwrap()
2468        );
2469        assert_eq!(state.margins[0].maintenance, Money::zero(btc));
2470        assert_eq!(
2471            portfolio.maintenance_margin,
2472            dec!(0.0000000000000000555111512313)
2473        );
2474    }
2475
2476    #[rstest]
2477    fn test_parse_book_snapshot_keeps_decimals_that_collapse_in_f64() {
2478        let instrument = perpetual_instrument_with_increments("0.000000001", "0.000000001");
2479        let data = notification_data("ws_book_snapshot_exact.json");
2480        let msg: DeribitBookMsg = serde_json::from_str(&data).unwrap();
2481
2482        let deltas = parse_book_msg(&msg, &instrument, UnixNanos::default()).unwrap();
2483
2484        assert_eq!(deltas.deltas.len(), 3);
2485        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
2486        assert_eq!(
2487            deltas.deltas[1].order.price,
2488            Price::from("100000000.123456789")
2489        );
2490        assert_eq!(
2491            deltas.deltas[1].order.size,
2492            Quantity::from("100000000.123456789")
2493        );
2494        assert_eq!(
2495            deltas.deltas[2].order.price,
2496            Price::from("100000000.123456788")
2497        );
2498        assert_eq!(
2499            deltas.deltas[2].order.size,
2500            Quantity::from("100000000.123456788")
2501        );
2502    }
2503
2504    #[rstest]
2505    fn test_parse_book_delta_rounds_levels_beyond_decimal_scale() {
2506        let instrument = perpetual_instrument_with_increments("0.000000001", "0.000000001");
2507        let data = notification_data("ws_book_delta_exact.json");
2508        let msg: DeribitBookMsg = serde_json::from_str(&data).unwrap();
2509
2510        let deltas = parse_book_msg(&msg, &instrument, UnixNanos::default()).unwrap();
2511
2512        assert_eq!(deltas.deltas.len(), 2);
2513        assert_eq!(deltas.deltas[0].action, BookAction::Update);
2514        assert_eq!(
2515            deltas.deltas[0].order.price,
2516            Price::from("100000000.123456789")
2517        );
2518        assert_eq!(deltas.deltas[0].order.size, Quantity::from("0.123456789"));
2519        assert_eq!(deltas.deltas[1].action, BookAction::Add);
2520        assert_eq!(
2521            deltas.deltas[1].order.price,
2522            Price::from("100000000.123456788")
2523        );
2524        assert_eq!(deltas.deltas[1].order.size, Quantity::from("0.000012346"));
2525    }
2526
2527    #[rstest]
2528    fn test_parse_book_delta_maps_zero_rounding_amounts_to_delete() {
2529        let instrument = perpetual_instrument_with_increments("0.5", "1");
2530        let data = r#"{"type":"change","instrument_name":"BTC-PERPETUAL","timestamp":1700000000000,
2531            "change_id":101,"prev_change_id":100,
2532            "bids":[["change",42500.0,1.1102230246251565e-16],["new",42499.5,0.4]],
2533            "asks":[["change",42500.5,20.0],["delete",42501.0,0.0]]}"#;
2534        let msg: DeribitBookMsg = serde_json::from_str(data).unwrap();
2535
2536        let deltas = parse_book_msg(&msg, &instrument, UnixNanos::default()).unwrap();
2537
2538        let actual: Vec<_> = deltas
2539            .deltas
2540            .iter()
2541            .map(|delta| {
2542                (
2543                    delta.action,
2544                    delta.order.side,
2545                    delta.order.price,
2546                    delta.order.size,
2547                    delta.order.order_id,
2548                    delta.flags,
2549                    delta.sequence,
2550                )
2551            })
2552            .collect();
2553
2554        assert_eq!(
2555            actual,
2556            vec![
2557                (
2558                    BookAction::Delete,
2559                    Some(OrderSide::Buy),
2560                    Price::from("42500.0"),
2561                    Quantity::from("0"),
2562                    0,
2563                    0,
2564                    101,
2565                ),
2566                (
2567                    BookAction::Delete,
2568                    Some(OrderSide::Buy),
2569                    Price::from("42499.5"),
2570                    Quantity::from("0"),
2571                    1,
2572                    0,
2573                    101,
2574                ),
2575                (
2576                    BookAction::Update,
2577                    Some(OrderSide::Sell),
2578                    Price::from("42500.5"),
2579                    Quantity::from("20"),
2580                    2,
2581                    0,
2582                    101,
2583                ),
2584                (
2585                    BookAction::Delete,
2586                    Some(OrderSide::Sell),
2587                    Price::from("42501.0"),
2588                    Quantity::from("0"),
2589                    3,
2590                    RecordFlag::F_LAST as u8,
2591                    101,
2592                ),
2593            ]
2594        );
2595    }
2596
2597    #[rstest]
2598    fn test_parse_book_snapshot_skips_zero_rounding_amounts() {
2599        let instrument = perpetual_instrument_with_increments("0.5", "1");
2600        let data = r#"{"type":"snapshot","instrument_name":"BTC-PERPETUAL","timestamp":1700000000000,
2601            "change_id":100,
2602            "bids":[["new",42500.0,1.1102230246251565e-16],["new",42499.5,10.0]],
2603            "asks":[["new",42500.5,0.4]]}"#;
2604        let msg: DeribitBookMsg = serde_json::from_str(data).unwrap();
2605
2606        let deltas = parse_book_msg(&msg, &instrument, UnixNanos::default()).unwrap();
2607
2608        let actual: Vec<_> = deltas
2609            .deltas
2610            .iter()
2611            .map(|delta| {
2612                (
2613                    delta.action,
2614                    delta.order.side,
2615                    delta.order.price,
2616                    delta.order.size,
2617                    delta.order.order_id,
2618                    delta.flags,
2619                )
2620            })
2621            .collect();
2622
2623        assert_eq!(
2624            actual,
2625            vec![
2626                (
2627                    BookAction::Clear,
2628                    None,
2629                    Price::from("0"),
2630                    Quantity::from("0"),
2631                    0,
2632                    RecordFlag::F_SNAPSHOT as u8,
2633                ),
2634                (
2635                    BookAction::Add,
2636                    Some(OrderSide::Buy),
2637                    Price::from("42499.5"),
2638                    Quantity::from("10"),
2639                    1,
2640                    RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8,
2641                ),
2642            ]
2643        );
2644    }
2645
2646    #[rstest]
2647    #[case::snapshot(
2648        r#"{"type":"snapshot","instrument_name":"BTC-PERPETUAL","timestamp":1700000000000,
2649            "change_id":100,"bids":[["new",100000000000000000000.0,10.0]],"asks":[]}"#
2650    )]
2651    #[case::delta(
2652        r#"{"type":"change","instrument_name":"BTC-PERPETUAL","timestamp":1700000000000,
2653            "change_id":101,"prev_change_id":100,
2654            "bids":[["new",100000000000000000000.0,10.0]],"asks":[]}"#
2655    )]
2656    fn test_parse_book_msg_rejects_unconvertible_price(#[case] data: &str) {
2657        let instrument = perpetual_instrument_with_increments("0.5", "1");
2658        let msg: DeribitBookMsg = serde_json::from_str(data).unwrap();
2659
2660        let error = parse_book_msg(&msg, &instrument, UnixNanos::default()).unwrap_err();
2661
2662        let expected = Price::from_decimal_dp(dec!(100000000000000000000.0), 1).unwrap_err();
2663        assert_eq!(error.to_string(), expected.to_string());
2664    }
2665}