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    )
1113}
1114
1115/// Parses a Deribit order message into an `OrderExpired` event.
1116///
1117/// This should be called when an order transitions to "expired" state
1118/// (e.g., GTD orders that reached their expiry time).
1119#[must_use]
1120pub fn parse_order_expired(
1121    msg: &DeribitOrderMsg,
1122    instrument: &InstrumentAny,
1123    account_id: AccountId,
1124    trader_id: TraderId,
1125    strategy_id: StrategyId,
1126    ts_init: UnixNanos,
1127) -> OrderExpired {
1128    let client_order_id =
1129        extract_client_order_id(msg).unwrap_or_else(|| ClientOrderId::new(&msg.order_id));
1130    parse_order_expired_with_client_order_id(
1131        msg,
1132        instrument,
1133        account_id,
1134        trader_id,
1135        strategy_id,
1136        client_order_id,
1137        ts_init,
1138    )
1139}
1140
1141#[must_use]
1142pub(crate) fn parse_order_expired_with_client_order_id(
1143    msg: &DeribitOrderMsg,
1144    instrument: &InstrumentAny,
1145    account_id: AccountId,
1146    trader_id: TraderId,
1147    strategy_id: StrategyId,
1148    client_order_id: ClientOrderId,
1149    ts_init: UnixNanos,
1150) -> OrderExpired {
1151    let instrument_id = instrument.id();
1152    let venue_order_id = VenueOrderId::new(&msg.order_id);
1153    let ts_event = UnixNanos::new(msg.last_update_timestamp * NANOSECONDS_IN_MILLISECOND);
1154
1155    OrderExpired::new(
1156        trader_id,
1157        strategy_id,
1158        instrument_id,
1159        client_order_id,
1160        nautilus_core::UUID4::new(),
1161        ts_event,
1162        ts_init,
1163        false, // reconciliation
1164        Some(venue_order_id),
1165        Some(account_id),
1166    )
1167}
1168
1169/// Parses a Deribit order message into an `OrderUpdated` event.
1170///
1171/// This should be called when an order is amended (price or quantity changed).
1172#[must_use]
1173pub fn parse_order_updated(
1174    msg: &DeribitOrderMsg,
1175    instrument: &InstrumentAny,
1176    account_id: AccountId,
1177    trader_id: TraderId,
1178    strategy_id: StrategyId,
1179    ts_init: UnixNanos,
1180) -> OrderUpdated {
1181    let client_order_id =
1182        extract_client_order_id(msg).unwrap_or_else(|| ClientOrderId::new(&msg.order_id));
1183    parse_order_updated_with_client_order_id(
1184        msg,
1185        instrument,
1186        account_id,
1187        trader_id,
1188        strategy_id,
1189        client_order_id,
1190        ts_init,
1191    )
1192}
1193
1194#[must_use]
1195pub(crate) fn parse_order_updated_with_client_order_id(
1196    msg: &DeribitOrderMsg,
1197    instrument: &InstrumentAny,
1198    account_id: AccountId,
1199    trader_id: TraderId,
1200    strategy_id: StrategyId,
1201    client_order_id: ClientOrderId,
1202    ts_init: UnixNanos,
1203) -> OrderUpdated {
1204    let instrument_id = instrument.id();
1205    let price_precision = instrument.price_precision();
1206    let size_precision = instrument.size_precision();
1207
1208    let venue_order_id = VenueOrderId::new(&msg.order_id);
1209    let quantity = Quantity::from_decimal_dp(msg.amount, size_precision)
1210        .unwrap_or_else(|_| Quantity::zero(size_precision));
1211    let price = msg
1212        .price
1213        .and_then(|p| Price::from_decimal_dp(p, price_precision).ok());
1214    let trigger_price = msg
1215        .trigger_price
1216        .and_then(|p| Price::from_decimal_dp(p, price_precision).ok());
1217    let ts_event = UnixNanos::new(msg.last_update_timestamp * NANOSECONDS_IN_MILLISECOND);
1218
1219    OrderUpdated::new(
1220        trader_id,
1221        strategy_id,
1222        instrument_id,
1223        client_order_id,
1224        quantity,
1225        nautilus_core::UUID4::new(),
1226        ts_event,
1227        ts_init,
1228        false, // reconciliation
1229        Some(venue_order_id),
1230        Some(account_id),
1231        price,
1232        trigger_price,
1233        None,  // protection_price
1234        false, // is_quote_quantity
1235    )
1236}
1237
1238/// Determines the appropriate order event based on the Deribit order state.
1239///
1240/// This function analyzes the order state and returns the corresponding event type.
1241/// It's used by the handler to determine which event to emit for a given order update.
1242///
1243/// # Arguments
1244/// - `order_state` - The Deribit order state string ("open", "filled", "cancelled", etc.)
1245/// - `is_new_order` - Whether this is the first time we're seeing this order
1246/// - `was_amended` - Whether this update is due to an amendment (edit) operation
1247///
1248/// # Returns
1249/// The type of event that should be emitted, or `None` if no event should be emitted.
1250#[must_use]
1251pub fn determine_order_event_type(
1252    order_state: &str,
1253    is_new_order: bool,
1254    was_amended: bool,
1255) -> OrderEventType {
1256    match order_state {
1257        "open" | "untriggered" => {
1258            if was_amended {
1259                OrderEventType::Updated
1260            } else if is_new_order {
1261                OrderEventType::Accepted
1262            } else {
1263                // Order is still open, no event needed (partial fill handled separately)
1264                OrderEventType::None
1265            }
1266        }
1267        "cancelled" => OrderEventType::Canceled,
1268        "expired" => OrderEventType::Expired,
1269        "filled" => {
1270            // Fills are handled through the user.trades channel
1271            OrderEventType::None
1272        }
1273        "rejected" => {
1274            // Rejections are handled separately via OrderRejected
1275            OrderEventType::None
1276        }
1277        other => {
1278            log::warn!("Unknown Deribit order_state '{other}' in event routing, dropping");
1279            OrderEventType::None
1280        }
1281    }
1282}
1283
1284/// Order event type to be emitted.
1285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1286pub enum OrderEventType {
1287    /// Emit OrderAccepted event.
1288    Accepted,
1289    /// Emit OrderCanceled event.
1290    Canceled,
1291    /// Emit OrderExpired event.
1292    Expired,
1293    /// Emit OrderUpdated event.
1294    Updated,
1295    /// No event to emit.
1296    None,
1297}
1298
1299#[cfg(test)]
1300mod tests {
1301    use rstest::rstest;
1302    use rust_decimal_macros::dec;
1303
1304    use super::*;
1305    use crate::{
1306        common::{parse::parse_deribit_instrument_any, testing::load_test_json},
1307        http::models::{DeribitInstrument, DeribitJsonRpcResponse},
1308    };
1309
1310    /// Helper function to create a test instrument (BTC-PERPETUAL).
1311    fn test_perpetual_instrument() -> InstrumentAny {
1312        let json = load_test_json("http_get_instruments.json");
1313        let response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1314            serde_json::from_str(&json).unwrap();
1315        let instrument = &response.result.unwrap()[0];
1316        parse_deribit_instrument_any(instrument, UnixNanos::default(), UnixNanos::default())
1317            .unwrap()
1318            .unwrap()
1319    }
1320
1321    #[rstest]
1322    fn test_parse_trade_msg_sell() {
1323        let instrument = test_perpetual_instrument();
1324        let json = load_test_json("ws_trades.json");
1325        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1326        let trades: Vec<DeribitTradeMsg> =
1327            serde_json::from_value(response["params"]["data"].clone()).unwrap();
1328        let msg = &trades[0];
1329
1330        let tick = parse_trade_msg(msg, &instrument, UnixNanos::default()).unwrap();
1331
1332        assert_eq!(tick.instrument_id, instrument.id());
1333        assert_eq!(tick.price, instrument.make_price(92294.5));
1334        assert_eq!(tick.size, instrument.make_qty(10.0, None));
1335        assert_eq!(tick.aggressor_side, AggressorSide::Sell);
1336        assert_eq!(tick.trade_id.to_string(), "403691824");
1337        assert_eq!(tick.ts_event, UnixNanos::new(1_765_531_356_452_000_000));
1338    }
1339
1340    #[rstest]
1341    fn test_parse_trade_msg_buy() {
1342        let instrument = test_perpetual_instrument();
1343        let json = load_test_json("ws_trades.json");
1344        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1345        let trades: Vec<DeribitTradeMsg> =
1346            serde_json::from_value(response["params"]["data"].clone()).unwrap();
1347        let msg = &trades[1];
1348
1349        let tick = parse_trade_msg(msg, &instrument, UnixNanos::default()).unwrap();
1350
1351        assert_eq!(tick.instrument_id, instrument.id());
1352        assert_eq!(tick.price, instrument.make_price(92288.5));
1353        assert_eq!(tick.size, instrument.make_qty(750.0, None));
1354        assert_eq!(tick.aggressor_side, AggressorSide::Sell);
1355        assert_eq!(tick.trade_id.to_string(), "403691825");
1356    }
1357
1358    fn make_trade_msg(
1359        instrument_name: &str,
1360        trade_id: &str,
1361        block_trade_id: Option<&str>,
1362        block_rfq_id: Option<i64>,
1363        combo_id: Option<&str>,
1364    ) -> DeribitTradeMsg {
1365        let raw = serde_json::json!({
1366            "trade_id": trade_id,
1367            "instrument_name": instrument_name,
1368            "price": 92294.5,
1369            "amount": 10.0,
1370            "direction": "buy",
1371            "timestamp": 1_765_531_356_452_u64,
1372            "trade_seq": 1,
1373            "tick_direction": 0,
1374            "index_price": 92276.75,
1375            "mark_price": 92287.11,
1376            "block_trade_id": block_trade_id,
1377            "block_rfq_id": block_rfq_id,
1378            "combo_id": combo_id,
1379        });
1380        serde_json::from_value(raw).unwrap()
1381    }
1382
1383    #[rstest]
1384    fn test_parse_trade_msg_tags_block_trade() {
1385        let instrument = test_perpetual_instrument();
1386        let msg = make_trade_msg("BTC-PERPETUAL", "244343055", Some("12345"), None, None);
1387        let tick = parse_trade_msg(&msg, &instrument, UnixNanos::default()).unwrap();
1388        assert_eq!(tick.trade_id.to_string(), "BLK-244343055");
1389    }
1390
1391    #[rstest]
1392    fn test_parse_trade_msg_tags_block_rfq() {
1393        let instrument = test_perpetual_instrument();
1394        let msg = make_trade_msg("BTC-PERPETUAL", "244343055", None, Some(99), None);
1395        let tick = parse_trade_msg(&msg, &instrument, UnixNanos::default()).unwrap();
1396        assert_eq!(tick.trade_id.to_string(), "RFQ-244343055");
1397    }
1398
1399    #[rstest]
1400    fn test_parse_trade_msg_tags_combo_leg() {
1401        // Per-leg trade originating from a combo: combo_id is set by Deribit
1402        // even though the instrument is a plain perp / option, so downstream
1403        // sees `COMBO-` and can detect combo-origin fills.
1404        let instrument = test_perpetual_instrument();
1405        let msg = make_trade_msg(
1406            "BTC-PERPETUAL",
1407            "244343055",
1408            None,
1409            None,
1410            Some("BTC-FS-25DEC26_PERP"),
1411        );
1412        let tick = parse_trade_msg(&msg, &instrument, UnixNanos::default()).unwrap();
1413        assert_eq!(tick.trade_id.to_string(), "COMBO-244343055");
1414    }
1415
1416    fn load_combo_option_instrument() -> InstrumentAny {
1417        let combo_json = load_test_json("http_get_instruments_option_combo.json");
1418        let combo_response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1419            serde_json::from_str(&combo_json).unwrap();
1420        let combo_raw = combo_response
1421            .result
1422            .unwrap()
1423            .into_iter()
1424            .find(|i| i.instrument_name.as_str() == "BTC-CS-19MAY26-70000_75000")
1425            .expect("fixture must contain BTC-CS-19MAY26-70000_75000");
1426        parse_deribit_instrument_any(&combo_raw, UnixNanos::default(), UnixNanos::default())
1427            .unwrap()
1428            .unwrap()
1429    }
1430
1431    fn load_combo_trade_msgs() -> Vec<DeribitTradeMsg> {
1432        let json = load_test_json("ws_trades_option_combo.json");
1433        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1434        serde_json::from_value(response["params"]["data"].clone()).unwrap()
1435    }
1436
1437    #[rstest]
1438    fn test_parse_trades_data_combo_emits_single_tick() {
1439        // Step 1 finding: combo legs already publish on their own per-leg
1440        // streams. Parsing a combo trade message should produce exactly one
1441        // TradeTick (for the combo), not N+1.
1442        let combo_inst = load_combo_option_instrument();
1443        let mut cache: AHashMap<Ustr, InstrumentAny> = AHashMap::new();
1444        cache.insert(Ustr::from("BTC-CS-19MAY26-70000_75000"), combo_inst.clone());
1445
1446        let trades = load_combo_trade_msgs();
1447        // Sanity: the combo message itself carries the legs array.
1448        let legs = trades[0].legs.as_ref().expect("combo trade must have legs");
1449        assert_eq!(legs.len(), 2);
1450
1451        let data = parse_trades_data(&trades, &cache, UnixNanos::default());
1452        assert_eq!(data.len(), 1, "should emit one tick for the combo only");
1453
1454        let Data::Trade(tick) = &data[0] else {
1455            panic!("expected Data::Trade");
1456        };
1457        assert_eq!(tick.instrument_id, combo_inst.id());
1458        // Exact values from fixture; independent of the instrument under test
1459        // so a regression in price/size precision is caught here too.
1460        assert_eq!(tick.price, Price::from("0.0639"));
1461        assert_eq!(tick.size, Quantity::from("0.1"));
1462        assert_eq!(tick.aggressor_side, AggressorSide::Sell);
1463        assert_eq!(tick.trade_id.to_string(), "244365193");
1464    }
1465
1466    #[rstest]
1467    fn test_parse_trades_data_combo_not_cached_emits_no_tick() {
1468        // When the combo InstrumentAny is not in the WS handler cache,
1469        // parse_trades_data must drop the message rather than panic or
1470        // synthesise a tick against an unknown instrument.
1471        let cache: AHashMap<Ustr, InstrumentAny> = AHashMap::new();
1472        let trades = load_combo_trade_msgs();
1473        let data = parse_trades_data(&trades, &cache, UnixNanos::default());
1474        assert!(data.is_empty(), "uncached combo must not emit ticks");
1475    }
1476
1477    #[rstest]
1478    fn test_parse_trades_data_mixed_combo_and_per_leg() {
1479        // Combo trade and a plain per-leg trade on the underlying perpetual,
1480        // both cached, must each produce a tick against their own instrument.
1481        let combo_inst = load_combo_option_instrument();
1482        let perp_inst = test_perpetual_instrument();
1483
1484        let mut cache: AHashMap<Ustr, InstrumentAny> = AHashMap::new();
1485        cache.insert(Ustr::from("BTC-CS-19MAY26-70000_75000"), combo_inst.clone());
1486        cache.insert(Ustr::from("BTC-PERPETUAL"), perp_inst.clone());
1487
1488        let mut trades = load_combo_trade_msgs();
1489        let perp_msgs: Vec<DeribitTradeMsg> = {
1490            let json = load_test_json("ws_trades.json");
1491            let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1492            serde_json::from_value(response["params"]["data"].clone()).unwrap()
1493        };
1494        trades.extend(perp_msgs);
1495
1496        let data = parse_trades_data(&trades, &cache, UnixNanos::default());
1497        // 1 combo + 2 plain BTC-PERPETUAL trades from the existing fixture.
1498        assert_eq!(data.len(), 3);
1499
1500        let mut combo_ticks = 0;
1501        let mut perp_ticks = 0;
1502
1503        for item in &data {
1504            let Data::Trade(tick) = item else {
1505                panic!("expected Data::Trade, was {item:?}");
1506            };
1507
1508            if tick.instrument_id == combo_inst.id() {
1509                combo_ticks += 1;
1510                assert_eq!(tick.trade_id.to_string(), "244365193");
1511            } else if tick.instrument_id == perp_inst.id() {
1512                perp_ticks += 1;
1513            } else {
1514                panic!("unexpected instrument_id: {}", tick.instrument_id);
1515            }
1516        }
1517        assert_eq!(combo_ticks, 1);
1518        assert_eq!(perp_ticks, 2);
1519    }
1520
1521    #[rstest]
1522    fn test_parse_book_snapshot() {
1523        let instrument = test_perpetual_instrument();
1524        let json = load_test_json("ws_book_snapshot.json");
1525        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1526        let msg: DeribitBookMsg =
1527            serde_json::from_value(response["params"]["data"].clone()).unwrap();
1528
1529        let deltas = parse_book_snapshot(&msg, &instrument, UnixNanos::default()).unwrap();
1530
1531        assert_eq!(deltas.instrument_id, instrument.id());
1532        // Should have CLEAR + 5 bids + 5 asks = 11 deltas
1533        assert_eq!(deltas.deltas.len(), 11);
1534
1535        // First delta should be CLEAR
1536        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1537
1538        // Check first bid
1539        let first_bid = &deltas.deltas[1];
1540        assert_eq!(first_bid.action, BookAction::Add);
1541        assert_eq!(first_bid.order.side, OrderSide::Buy.into());
1542        assert_eq!(first_bid.order.price, instrument.make_price(42500.0));
1543        assert_eq!(first_bid.order.size, instrument.make_qty(1000.0, None));
1544
1545        // Check first ask
1546        let first_ask = &deltas.deltas[6];
1547        assert_eq!(first_ask.action, BookAction::Add);
1548        assert_eq!(first_ask.order.side, OrderSide::Sell.into());
1549        assert_eq!(first_ask.order.price, instrument.make_price(42501.0));
1550        assert_eq!(first_ask.order.size, instrument.make_qty(800.0, None));
1551
1552        // Check F_LAST flag on last delta
1553        let last = deltas.deltas.last().unwrap();
1554        assert_eq!(
1555            last.flags & RecordFlag::F_LAST as u8,
1556            RecordFlag::F_LAST as u8
1557        );
1558    }
1559
1560    #[rstest]
1561    fn test_parse_book_delta() {
1562        let instrument = test_perpetual_instrument();
1563        let json = load_test_json("ws_book_delta.json");
1564        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1565        let msg: DeribitBookMsg =
1566            serde_json::from_value(response["params"]["data"].clone()).unwrap();
1567
1568        let deltas = parse_book_delta(&msg, &instrument, UnixNanos::default()).unwrap();
1569
1570        assert_eq!(deltas.instrument_id, instrument.id());
1571        // Should have 2 bid deltas + 2 ask deltas = 4 deltas
1572        assert_eq!(deltas.deltas.len(), 4);
1573
1574        // Check first bid - "change" action
1575        let bid_change = &deltas.deltas[0];
1576        assert_eq!(bid_change.action, BookAction::Update);
1577        assert_eq!(bid_change.order.side, OrderSide::Buy.into());
1578        assert_eq!(bid_change.order.price, instrument.make_price(42500.0));
1579        assert_eq!(bid_change.order.size, instrument.make_qty(950.0, None));
1580
1581        // Check second bid - "new" action
1582        let bid_new = &deltas.deltas[1];
1583        assert_eq!(bid_new.action, BookAction::Add);
1584        assert_eq!(bid_new.order.side, OrderSide::Buy.into());
1585        assert_eq!(bid_new.order.price, instrument.make_price(42498.5));
1586        assert_eq!(bid_new.order.size, instrument.make_qty(300.0, None));
1587
1588        // Check first ask - "delete" action
1589        let ask_delete = &deltas.deltas[2];
1590        assert_eq!(ask_delete.action, BookAction::Delete);
1591        assert_eq!(ask_delete.order.side, OrderSide::Sell.into());
1592        assert_eq!(ask_delete.order.price, instrument.make_price(42501.0));
1593        assert_eq!(ask_delete.order.size, instrument.make_qty(0.0, None));
1594
1595        // Check second ask - "change" action
1596        let ask_change = &deltas.deltas[3];
1597        assert_eq!(ask_change.action, BookAction::Update);
1598        assert_eq!(ask_change.order.side, OrderSide::Sell.into());
1599        assert_eq!(ask_change.order.price, instrument.make_price(42501.5));
1600        assert_eq!(ask_change.order.size, instrument.make_qty(700.0, None));
1601
1602        // Check F_LAST flag on last delta
1603        let last = deltas.deltas.last().unwrap();
1604        assert_eq!(
1605            last.flags & RecordFlag::F_LAST as u8,
1606            RecordFlag::F_LAST as u8
1607        );
1608    }
1609
1610    #[rstest]
1611    fn test_parse_ticker_to_quote() {
1612        let instrument = test_perpetual_instrument();
1613        let json = load_test_json("ws_ticker.json");
1614        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1615        let msg: DeribitTickerMsg =
1616            serde_json::from_value(response["params"]["data"].clone()).unwrap();
1617
1618        // Verify the message was deserialized correctly
1619        assert_eq!(msg.instrument_name.as_str(), "BTC-PERPETUAL");
1620        assert_eq!(msg.timestamp, 1_765_541_474_086);
1621        assert_eq!(msg.best_bid_price, Some(dec!(92283.5)));
1622        assert_eq!(msg.best_ask_price, Some(dec!(92284.0)));
1623        assert_eq!(msg.best_bid_amount, Some(dec!(117660.0)));
1624        assert_eq!(msg.best_ask_amount, Some(dec!(186520.0)));
1625        assert_eq!(msg.mark_price, dec!(92281.78));
1626        assert_eq!(msg.index_price, dec!(92263.55));
1627        assert_eq!(msg.open_interest, dec!(1132329370.0));
1628
1629        let quote = parse_ticker_to_quote(&msg, &instrument, UnixNanos::default()).unwrap();
1630
1631        assert_eq!(quote.instrument_id, instrument.id());
1632        assert_eq!(quote.bid_price, instrument.make_price(92283.5));
1633        assert_eq!(quote.ask_price, instrument.make_price(92284.0));
1634        assert_eq!(quote.bid_size, instrument.make_qty(117660.0, None));
1635        assert_eq!(quote.ask_size, instrument.make_qty(186520.0, None));
1636        assert_eq!(quote.ts_event, UnixNanos::new(1_765_541_474_086_000_000));
1637    }
1638
1639    #[rstest]
1640    fn test_parse_quote_msg() {
1641        let instrument = test_perpetual_instrument();
1642        let json = load_test_json("ws_quote.json");
1643        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1644        let msg: DeribitQuoteMsg =
1645            serde_json::from_value(response["params"]["data"].clone()).unwrap();
1646
1647        // Verify the message was deserialized correctly
1648        assert_eq!(msg.instrument_name.as_str(), "BTC-PERPETUAL");
1649        assert_eq!(msg.timestamp, 1_765_541_767_174);
1650        assert_eq!(msg.best_bid_price, dec!(92288.0));
1651        assert_eq!(msg.best_ask_price, dec!(92288.5));
1652        assert_eq!(msg.best_bid_amount, dec!(133440.0));
1653        assert_eq!(msg.best_ask_amount, dec!(99470.0));
1654
1655        let quote = parse_quote_msg(&msg, &instrument, UnixNanos::default()).unwrap();
1656
1657        assert_eq!(quote.instrument_id, instrument.id());
1658        assert_eq!(quote.bid_price, instrument.make_price(92288.0));
1659        assert_eq!(quote.ask_price, instrument.make_price(92288.5));
1660        assert_eq!(quote.bid_size, instrument.make_qty(133440.0, None));
1661        assert_eq!(quote.ask_size, instrument.make_qty(99470.0, None));
1662        assert_eq!(quote.ts_event, UnixNanos::new(1_765_541_767_174_000_000));
1663    }
1664
1665    #[rstest]
1666    fn test_parse_book_msg_snapshot() {
1667        let instrument = test_perpetual_instrument();
1668        let json = load_test_json("ws_book_snapshot.json");
1669        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1670        let msg: DeribitBookMsg =
1671            serde_json::from_value(response["params"]["data"].clone()).unwrap();
1672
1673        // Validate raw message format - snapshots use 3-element arrays: ["new", price, amount]
1674        assert_eq!(
1675            msg.bids[0].len(),
1676            3,
1677            "Snapshot bids should have 3 elements: [action, price, amount]"
1678        );
1679        assert_eq!(
1680            msg.bids[0][0].as_str(),
1681            Some("new"),
1682            "First element should be 'new' action for snapshot"
1683        );
1684        assert_eq!(
1685            msg.asks[0].len(),
1686            3,
1687            "Snapshot asks should have 3 elements: [action, price, amount]"
1688        );
1689        assert_eq!(
1690            msg.asks[0][0].as_str(),
1691            Some("new"),
1692            "First element should be 'new' action for snapshot"
1693        );
1694
1695        let deltas = parse_book_msg(&msg, &instrument, UnixNanos::default()).unwrap();
1696
1697        assert_eq!(deltas.instrument_id, instrument.id());
1698        // Should have CLEAR + 5 bids + 5 asks = 11 deltas
1699        assert_eq!(deltas.deltas.len(), 11);
1700
1701        // First delta should be CLEAR
1702        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1703
1704        // Verify first bid was parsed correctly from ["new", 42500.0, 1000.0]
1705        let first_bid = &deltas.deltas[1];
1706        assert_eq!(first_bid.action, BookAction::Add);
1707        assert_eq!(first_bid.order.side, OrderSide::Buy.into());
1708        assert_eq!(first_bid.order.price, instrument.make_price(42500.0));
1709        assert_eq!(first_bid.order.size, instrument.make_qty(1000.0, None));
1710
1711        // Verify first ask was parsed correctly from ["new", 42501.0, 800.0]
1712        let first_ask = &deltas.deltas[6];
1713        assert_eq!(first_ask.action, BookAction::Add);
1714        assert_eq!(first_ask.order.side, OrderSide::Sell.into());
1715        assert_eq!(first_ask.order.price, instrument.make_price(42501.0));
1716        assert_eq!(first_ask.order.size, instrument.make_qty(800.0, None));
1717    }
1718
1719    #[rstest]
1720    fn test_parse_book_msg_delta() {
1721        let instrument = test_perpetual_instrument();
1722        let json = load_test_json("ws_book_delta.json");
1723        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1724        let msg: DeribitBookMsg =
1725            serde_json::from_value(response["params"]["data"].clone()).unwrap();
1726
1727        // Validate raw message format - deltas use 3-element arrays: [action, price, amount]
1728        assert_eq!(
1729            msg.bids[0].len(),
1730            3,
1731            "Delta bids should have 3 elements: [action, price, amount]"
1732        );
1733        assert_eq!(
1734            msg.bids[0][0].as_str(),
1735            Some("change"),
1736            "First bid should be 'change' action"
1737        );
1738        assert_eq!(
1739            msg.bids[1][0].as_str(),
1740            Some("new"),
1741            "Second bid should be 'new' action"
1742        );
1743        assert_eq!(
1744            msg.asks[0].len(),
1745            3,
1746            "Delta asks should have 3 elements: [action, price, amount]"
1747        );
1748        assert_eq!(
1749            msg.asks[0][0].as_str(),
1750            Some("delete"),
1751            "First ask should be 'delete' action"
1752        );
1753
1754        let deltas = parse_book_msg(&msg, &instrument, UnixNanos::default()).unwrap();
1755
1756        assert_eq!(deltas.instrument_id, instrument.id());
1757        // Should have 2 bid deltas + 2 ask deltas = 4 deltas
1758        assert_eq!(deltas.deltas.len(), 4);
1759
1760        // Delta should not have CLEAR action
1761        assert_ne!(deltas.deltas[0].action, BookAction::Clear);
1762
1763        // Verify first bid "change" action was parsed correctly from ["change", 42500.0, 950.0]
1764        let bid_change = &deltas.deltas[0];
1765        assert_eq!(bid_change.action, BookAction::Update);
1766        assert_eq!(bid_change.order.side, OrderSide::Buy.into());
1767        assert_eq!(bid_change.order.price, instrument.make_price(42500.0));
1768        assert_eq!(bid_change.order.size, instrument.make_qty(950.0, None));
1769
1770        // Verify second bid "new" action was parsed correctly from ["new", 42498.5, 300.0]
1771        let bid_new = &deltas.deltas[1];
1772        assert_eq!(bid_new.action, BookAction::Add);
1773        assert_eq!(bid_new.order.side, OrderSide::Buy.into());
1774        assert_eq!(bid_new.order.price, instrument.make_price(42498.5));
1775        assert_eq!(bid_new.order.size, instrument.make_qty(300.0, None));
1776
1777        // Verify first ask "delete" action was parsed correctly from ["delete", 42501.0, 0.0]
1778        let ask_delete = &deltas.deltas[2];
1779        assert_eq!(ask_delete.action, BookAction::Delete);
1780        assert_eq!(ask_delete.order.side, OrderSide::Sell.into());
1781        assert_eq!(ask_delete.order.price, instrument.make_price(42501.0));
1782
1783        // Verify second ask "change" action was parsed correctly from ["change", 42501.5, 700.0]
1784        let ask_change = &deltas.deltas[3];
1785        assert_eq!(ask_change.action, BookAction::Update);
1786        assert_eq!(ask_change.order.side, OrderSide::Sell.into());
1787        assert_eq!(ask_change.order.price, instrument.make_price(42501.5));
1788        assert_eq!(ask_change.order.size, instrument.make_qty(700.0, None));
1789    }
1790
1791    #[rstest]
1792    fn test_parse_book_grouped_snapshot() {
1793        // Test parsing grouped book channel format: book.{instrument}.{group}.{depth}.{interval}
1794        // This format has NO type field and uses 2-element arrays [price, amount]
1795        let instrument = test_perpetual_instrument();
1796        let json = load_test_json("ws_book_grouped_snapshot.json");
1797        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1798        let msg: DeribitBookMsg =
1799            serde_json::from_value(response["params"]["data"].clone()).unwrap();
1800
1801        // Validate raw message format - grouped channel uses 2-element arrays: [price, amount]
1802        assert_eq!(
1803            msg.bids[0].len(),
1804            2,
1805            "Grouped bids should have 2 elements: [price, amount]"
1806        );
1807        assert_eq!(
1808            msg.asks[0].len(),
1809            2,
1810            "Grouped asks should have 2 elements: [price, amount]"
1811        );
1812
1813        // Verify msg_type defaults to Snapshot (grouped channel has no type field)
1814        assert_eq!(
1815            msg.msg_type,
1816            DeribitBookMsgType::Snapshot,
1817            "Grouped channel should default to Snapshot type"
1818        );
1819
1820        let deltas = parse_book_snapshot(&msg, &instrument, UnixNanos::default()).unwrap();
1821
1822        assert_eq!(deltas.instrument_id, instrument.id());
1823        // Should have CLEAR + 10 bids + 10 asks = 21 deltas
1824        assert_eq!(deltas.deltas.len(), 21);
1825
1826        // First delta should be CLEAR
1827        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1828
1829        // Verify first bid was parsed correctly from [89532.5, 254900.0]
1830        let first_bid = &deltas.deltas[1];
1831        assert_eq!(first_bid.action, BookAction::Add);
1832        assert_eq!(first_bid.order.side, OrderSide::Buy.into());
1833        assert_eq!(first_bid.order.price, instrument.make_price(89532.5));
1834        assert_eq!(first_bid.order.size, instrument.make_qty(254900.0, None));
1835
1836        // Verify first ask was parsed correctly from [89533.0, 91570.0]
1837        let first_ask = &deltas.deltas[11];
1838        assert_eq!(first_ask.action, BookAction::Add);
1839        assert_eq!(first_ask.order.side, OrderSide::Sell.into());
1840        assert_eq!(first_ask.order.price, instrument.make_price(89533.0));
1841        assert_eq!(first_ask.order.size, instrument.make_qty(91570.0, None));
1842
1843        // Check F_LAST flag on last delta
1844        let last = deltas.deltas.last().unwrap();
1845        assert_eq!(
1846            last.flags & RecordFlag::F_LAST as u8,
1847            RecordFlag::F_LAST as u8
1848        );
1849    }
1850
1851    #[rstest]
1852    fn test_parse_ticker_to_mark_price() {
1853        let instrument = test_perpetual_instrument();
1854        let json = load_test_json("ws_ticker.json");
1855        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1856        let msg: DeribitTickerMsg =
1857            serde_json::from_value(response["params"]["data"].clone()).unwrap();
1858
1859        let mark_price =
1860            parse_ticker_to_mark_price(&msg, &instrument, UnixNanos::default()).unwrap();
1861
1862        assert_eq!(mark_price.instrument_id, instrument.id());
1863        assert_eq!(mark_price.value, instrument.make_price(92281.78));
1864        assert_eq!(
1865            mark_price.ts_event,
1866            UnixNanos::new(1_765_541_474_086_000_000)
1867        );
1868    }
1869
1870    #[rstest]
1871    fn test_parse_ticker_to_index_price() {
1872        let instrument = test_perpetual_instrument();
1873        let json = load_test_json("ws_ticker.json");
1874        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1875        let msg: DeribitTickerMsg =
1876            serde_json::from_value(response["params"]["data"].clone()).unwrap();
1877
1878        let index_price =
1879            parse_ticker_to_index_price(&msg, &instrument, UnixNanos::default()).unwrap();
1880
1881        assert_eq!(index_price.instrument_id, instrument.id());
1882        assert_eq!(index_price.value, instrument.make_price(92263.55));
1883        assert_eq!(
1884            index_price.ts_event,
1885            UnixNanos::new(1_765_541_474_086_000_000)
1886        );
1887    }
1888
1889    #[rstest]
1890    fn test_parse_ticker_to_funding_rate() {
1891        let instrument = test_perpetual_instrument();
1892        let json = load_test_json("ws_ticker.json");
1893        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1894        let msg: DeribitTickerMsg =
1895            serde_json::from_value(response["params"]["data"].clone()).unwrap();
1896
1897        // Verify current_funding exists in the message
1898        assert!(msg.current_funding.is_some());
1899
1900        let funding_rate =
1901            parse_ticker_to_funding_rate(&msg, &instrument, UnixNanos::default()).unwrap();
1902
1903        assert_eq!(funding_rate.instrument_id, instrument.id());
1904        // The test fixture has current_funding value
1905        assert_eq!(
1906            funding_rate.ts_event,
1907            UnixNanos::new(1_765_541_474_086_000_000)
1908        );
1909        assert!(funding_rate.interval.is_none());
1910        assert!(funding_rate.next_funding_ns.is_none()); // Not available in ticker
1911    }
1912
1913    #[rstest]
1914    fn test_resolution_to_bar_type_1_minute() {
1915        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1916        let bar_type = resolution_to_bar_type(instrument_id, "1").unwrap();
1917
1918        assert_eq!(bar_type.instrument_id(), instrument_id);
1919        assert_eq!(bar_type.spec().step.get(), 1);
1920        assert_eq!(bar_type.spec().aggregation, BarAggregation::Minute);
1921        assert_eq!(bar_type.spec().price_type, PriceType::Last);
1922        assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1923    }
1924
1925    #[rstest]
1926    fn test_resolution_to_bar_type_60_minute() {
1927        let instrument_id = InstrumentId::from("ETH-PERPETUAL.DERIBIT");
1928        let bar_type = resolution_to_bar_type(instrument_id, "60").unwrap();
1929
1930        assert_eq!(bar_type.instrument_id(), instrument_id);
1931        assert_eq!(bar_type.spec().step.get(), 1);
1932        assert_eq!(bar_type.spec().aggregation, BarAggregation::Hour);
1933    }
1934
1935    #[rstest]
1936    fn test_resolution_to_bar_type_daily() {
1937        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1938        let bar_type = resolution_to_bar_type(instrument_id, "1D").unwrap();
1939
1940        assert_eq!(bar_type.instrument_id(), instrument_id);
1941        assert_eq!(bar_type.spec().step.get(), 1);
1942        assert_eq!(bar_type.spec().aggregation, BarAggregation::Day);
1943    }
1944
1945    #[rstest]
1946    fn test_resolution_to_bar_type_invalid() {
1947        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1948        let result = resolution_to_bar_type(instrument_id, "invalid");
1949
1950        assert!(result.is_err());
1951        assert!(
1952            result
1953                .unwrap_err()
1954                .to_string()
1955                .contains("Unsupported Deribit resolution")
1956        );
1957    }
1958
1959    #[rstest]
1960    fn test_parse_chart_msg_uses_cost() {
1961        let instrument = test_perpetual_instrument();
1962        assert!(
1963            instrument.is_inverse(),
1964            "test fixture is expected to be an inverse perp"
1965        );
1966
1967        let json = load_test_json("ws_chart.json");
1968        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
1969        let chart_msg: DeribitChartMsg =
1970            serde_json::from_value(response["params"]["data"].clone()).unwrap();
1971
1972        // Verify chart message was deserialized correctly
1973        assert_eq!(chart_msg.tick, 1_767_200_040_000);
1974        assert_eq!(chart_msg.open, 87490.0);
1975        assert_eq!(chart_msg.high, 87500.0);
1976        assert_eq!(chart_msg.low, 87465.0);
1977        assert_eq!(chart_msg.close, 87474.0);
1978        assert_eq!(chart_msg.volume, 0.95978896);
1979        assert_eq!(chart_msg.cost, 83970.0);
1980
1981        let bar_type = resolution_to_bar_type(instrument.id(), "1").unwrap();
1982
1983        // Test with timestamp_on_close=true (default)
1984        let bar = parse_chart_msg(
1985            &chart_msg,
1986            bar_type,
1987            instrument.price_precision(),
1988            instrument.size_precision(),
1989            true, // use_cost_for_volume
1990            true,
1991            UnixNanos::default(),
1992        )
1993        .unwrap();
1994
1995        assert_eq!(bar.bar_type, bar_type);
1996        assert_eq!(bar.open, instrument.make_price(87490.0));
1997        assert_eq!(bar.high, instrument.make_price(87500.0));
1998        assert_eq!(bar.low, instrument.make_price(87465.0));
1999        assert_eq!(bar.close, instrument.make_price(87474.0));
2000        assert_eq!(bar.volume, instrument.make_qty(83970.0, None));
2001
2002        // ts_event should be close time (open + 1 minute)
2003        assert_eq!(bar.ts_event, UnixNanos::new(1_767_200_100_000_000_000));
2004    }
2005
2006    #[rstest]
2007    fn test_parse_order_buy_response() {
2008        let instrument = test_perpetual_instrument();
2009        let json = load_test_json("ws_order_buy_response.json");
2010        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
2011
2012        // Parse the order from the response (buy/sell responses wrap order in {"order": ...})
2013        let order_msg: DeribitOrderMsg =
2014            serde_json::from_value(response["result"]["order"].clone()).unwrap();
2015
2016        // Verify deserialization
2017        assert_eq!(order_msg.order_id, "USDC-104819327443");
2018        assert_eq!(
2019            order_msg.label,
2020            Some("O-19700101-000000-001-001-1".to_string())
2021        );
2022        assert_eq!(order_msg.direction, "buy");
2023        assert_eq!(order_msg.order_state, "open");
2024        assert_eq!(order_msg.order_type, "limit");
2025        assert_eq!(order_msg.price, Some(dec!(2973.55)));
2026        assert_eq!(order_msg.amount, dec!(0.001));
2027        assert_eq!(order_msg.filled_amount, rust_decimal::Decimal::ZERO);
2028        assert!(order_msg.post_only);
2029        assert!(!order_msg.reduce_only);
2030
2031        // Test parse_order_accepted
2032        let account_id = AccountId::new("DERIBIT-001");
2033        let trader_id = TraderId::new("TRADER-001");
2034        let strategy_id = StrategyId::new("PMM-001");
2035
2036        let accepted = parse_order_accepted(
2037            &order_msg,
2038            &instrument,
2039            account_id,
2040            trader_id,
2041            strategy_id,
2042            UnixNanos::default(),
2043        );
2044
2045        assert_eq!(
2046            accepted.client_order_id.to_string(),
2047            "O-19700101-000000-001-001-1"
2048        );
2049        assert_eq!(accepted.venue_order_id.to_string(), "USDC-104819327443");
2050        assert_eq!(accepted.trader_id, trader_id);
2051        assert_eq!(accepted.strategy_id, strategy_id);
2052        assert_eq!(accepted.account_id, account_id);
2053    }
2054
2055    #[rstest]
2056    fn test_parse_order_sell_response() {
2057        let instrument = test_perpetual_instrument();
2058        let json = load_test_json("ws_order_sell_response.json");
2059        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
2060
2061        let order_msg: DeribitOrderMsg =
2062            serde_json::from_value(response["result"]["order"].clone()).unwrap();
2063
2064        // Verify deserialization
2065        assert_eq!(order_msg.order_id, "USDC-104819327458");
2066        assert_eq!(
2067            order_msg.label,
2068            Some("O-19700101-000000-001-001-2".to_string())
2069        );
2070        assert_eq!(order_msg.direction, "sell");
2071        assert_eq!(order_msg.order_state, "open");
2072        assert_eq!(order_msg.price, Some(dec!(3286.7)));
2073        assert_eq!(order_msg.amount, dec!(0.001));
2074
2075        // Test parse_order_accepted for sell order
2076        let account_id = AccountId::new("DERIBIT-001");
2077        let trader_id = TraderId::new("TRADER-001");
2078        let strategy_id = StrategyId::new("PMM-001");
2079
2080        let accepted = parse_order_accepted(
2081            &order_msg,
2082            &instrument,
2083            account_id,
2084            trader_id,
2085            strategy_id,
2086            UnixNanos::default(),
2087        );
2088
2089        assert_eq!(
2090            accepted.client_order_id.to_string(),
2091            "O-19700101-000000-001-001-2"
2092        );
2093        assert_eq!(accepted.venue_order_id.to_string(), "USDC-104819327458");
2094        assert_eq!(accepted.trader_id, trader_id);
2095        assert_eq!(accepted.strategy_id, strategy_id);
2096        assert_eq!(accepted.account_id, account_id);
2097    }
2098
2099    #[rstest]
2100    fn test_parse_order_edit_response() {
2101        let instrument = test_perpetual_instrument();
2102        let json = load_test_json("ws_order_edit_response.json");
2103        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
2104
2105        let order_msg: DeribitOrderMsg =
2106            serde_json::from_value(response["result"]["order"].clone()).unwrap();
2107
2108        // Verify deserialization - edit response has replaced=true in raw JSON
2109        assert_eq!(order_msg.order_id, "USDC-104819327443");
2110        assert_eq!(
2111            order_msg.label,
2112            Some("O-19700101-000000-001-001-1".to_string())
2113        );
2114        assert_eq!(order_msg.direction, "buy");
2115        assert_eq!(order_msg.order_state, "open");
2116        assert!(order_msg.replaced);
2117        assert_eq!(order_msg.price, Some(dec!(3067.2))); // New price after edit
2118
2119        // Test parse_order_updated
2120        let account_id = AccountId::new("DERIBIT-001");
2121        let trader_id = TraderId::new("TRADER-001");
2122        let strategy_id = StrategyId::new("PMM-001");
2123
2124        let updated = parse_order_updated(
2125            &order_msg,
2126            &instrument,
2127            account_id,
2128            trader_id,
2129            strategy_id,
2130            UnixNanos::default(),
2131        );
2132
2133        assert_eq!(
2134            updated.client_order_id.to_string(),
2135            "O-19700101-000000-001-001-1"
2136        );
2137        assert_eq!(
2138            updated.venue_order_id.unwrap().to_string(),
2139            "USDC-104819327443"
2140        );
2141        // Note: 0.001 truncates to 0.0 due to BTC-PERPETUAL size_precision=0
2142        assert_eq!(updated.quantity.as_f64(), 0.0);
2143    }
2144
2145    #[rstest]
2146    fn test_parse_order_cancel_response() {
2147        let instrument = test_perpetual_instrument();
2148        let json = load_test_json("ws_order_cancel_response.json");
2149        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
2150
2151        // Cancel response has order fields directly in result (not wrapped)
2152        let order_msg: DeribitOrderMsg =
2153            serde_json::from_value(response["result"].clone()).unwrap();
2154
2155        // Verify deserialization
2156        assert_eq!(order_msg.order_id, "USDC-104819327443");
2157        assert_eq!(
2158            order_msg.label,
2159            Some("O-19700101-000000-001-001-1".to_string())
2160        );
2161        assert_eq!(order_msg.order_state, "cancelled");
2162        assert_eq!(order_msg.cancel_reason, Some("user_request".to_string()));
2163
2164        // Test parse_order_canceled
2165        let account_id = AccountId::new("DERIBIT-001");
2166        let trader_id = TraderId::new("TRADER-001");
2167        let strategy_id = StrategyId::new("PMM-001");
2168
2169        let canceled = parse_order_canceled(
2170            &order_msg,
2171            &instrument,
2172            account_id,
2173            trader_id,
2174            strategy_id,
2175            UnixNanos::default(),
2176        );
2177
2178        assert_eq!(
2179            canceled.client_order_id.to_string(),
2180            "O-19700101-000000-001-001-1"
2181        );
2182        assert_eq!(
2183            canceled.venue_order_id.unwrap().to_string(),
2184            "USDC-104819327443"
2185        );
2186        assert_eq!(canceled.trader_id, trader_id);
2187        assert_eq!(canceled.strategy_id, strategy_id);
2188    }
2189
2190    #[rstest]
2191    fn test_parse_order_stop_market_response() {
2192        // Regression for https://github.com/nautechsystems/nautilus_trader/issues/3925
2193        // Deribit returns the literal string "market_price" for the price of
2194        // trigger market orders; the deserializer must map this to None rather
2195        // than failing with "Invalid decimal: unknown character".
2196        let instrument = test_perpetual_instrument();
2197        let json = load_test_json("ws_order_stop_market_response.json");
2198        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
2199
2200        let order_msg: DeribitOrderMsg =
2201            serde_json::from_value(response["result"]["order"].clone()).unwrap();
2202
2203        assert_eq!(order_msg.order_id, "USDC-104819327499");
2204        assert_eq!(order_msg.order_type, "stop_market");
2205        assert_eq!(order_msg.order_state, "untriggered");
2206        assert_eq!(order_msg.price, None);
2207        assert_eq!(order_msg.trigger_price, Some(dec!(2228.0)));
2208        assert_eq!(order_msg.trigger.as_deref(), Some("mark_price"));
2209        assert!(order_msg.reduce_only);
2210
2211        let account_id = AccountId::new("DERIBIT-001");
2212        let report =
2213            parse_user_order_msg(&order_msg, &instrument, account_id, UnixNanos::default())
2214                .unwrap();
2215
2216        assert_eq!(report.order_type, OrderType::StopMarket);
2217        assert_eq!(report.order_status, OrderStatus::Accepted);
2218        assert!(report.price.is_none());
2219        assert!(report.trigger_price.is_some());
2220        assert!(report.reduce_only);
2221    }
2222
2223    #[rstest]
2224    fn test_parse_order_stop_market_response_missing_filled_amount() {
2225        // Regression for https://github.com/nautechsystems/nautilus_trader/issues/3995
2226        // Deribit omits `filled_amount` for untriggered trigger market orders;
2227        // the deserializer must treat the missing field as zero rather than
2228        // failing with "missing field `filled_amount`".
2229        let instrument = test_perpetual_instrument();
2230        let json = load_test_json("ws_order_stop_market_no_filled_amount.json");
2231        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
2232
2233        let order_msg: DeribitOrderMsg =
2234            serde_json::from_value(response["result"]["order"].clone()).unwrap();
2235
2236        assert_eq!(order_msg.order_id, "USDC-SLMB-19641");
2237        assert_eq!(order_msg.order_type, "stop_market");
2238        assert_eq!(order_msg.order_state, "untriggered");
2239        assert_eq!(order_msg.filled_amount, rust_decimal::Decimal::ZERO);
2240        assert_eq!(order_msg.average_price, None);
2241
2242        let account_id = AccountId::new("DERIBIT-001");
2243        let report =
2244            parse_user_order_msg(&order_msg, &instrument, account_id, UnixNanos::default())
2245                .unwrap();
2246
2247        assert_eq!(report.order_type, OrderType::StopMarket);
2248        assert_eq!(report.order_status, OrderStatus::Accepted);
2249        assert_eq!(report.filled_qty.as_f64(), 0.0);
2250    }
2251
2252    #[rstest]
2253    fn test_parse_user_order_msg_to_status_report() {
2254        let instrument = test_perpetual_instrument();
2255        let json = load_test_json("ws_order_buy_response.json");
2256        let response: serde_json::Value = serde_json::from_str(&json).unwrap();
2257
2258        let order_msg: DeribitOrderMsg =
2259            serde_json::from_value(response["result"]["order"].clone()).unwrap();
2260
2261        let account_id = AccountId::new("DERIBIT-001");
2262        let report =
2263            parse_user_order_msg(&order_msg, &instrument, account_id, UnixNanos::default())
2264                .unwrap();
2265
2266        assert_eq!(report.venue_order_id.to_string(), "USDC-104819327443");
2267        assert_eq!(
2268            report.client_order_id.unwrap().to_string(),
2269            "O-19700101-000000-001-001-1"
2270        );
2271        assert_eq!(report.order_side, OrderSide::Buy.into());
2272        assert_eq!(report.order_type, OrderType::Limit);
2273        assert_eq!(report.time_in_force, TimeInForce::Gtc);
2274        assert_eq!(report.order_status, OrderStatus::Accepted);
2275        // Note: 0.001 truncates to 0.0 due to BTC-PERPETUAL size_precision=0
2276        assert_eq!(report.quantity.as_f64(), 0.0);
2277        assert_eq!(report.filled_qty.as_f64(), 0.0);
2278        assert!(report.post_only);
2279        assert!(!report.reduce_only);
2280    }
2281
2282    #[rstest]
2283    fn test_determine_order_event_type() {
2284        // New order -> Accepted
2285        assert_eq!(
2286            determine_order_event_type("open", true, false),
2287            OrderEventType::Accepted
2288        );
2289
2290        // Amended order -> Updated
2291        assert_eq!(
2292            determine_order_event_type("open", false, true),
2293            OrderEventType::Updated
2294        );
2295
2296        // Cancelled order -> Canceled
2297        assert_eq!(
2298            determine_order_event_type("cancelled", false, false),
2299            OrderEventType::Canceled
2300        );
2301
2302        // Expired order -> Expired
2303        assert_eq!(
2304            determine_order_event_type("expired", false, false),
2305            OrderEventType::Expired
2306        );
2307
2308        // Filled order -> None (handled via trades)
2309        assert_eq!(
2310            determine_order_event_type("filled", false, false),
2311            OrderEventType::None
2312        );
2313    }
2314}