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