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