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