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