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