Skip to main content

nautilus_tardis/machine/
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
16use std::sync::Arc;
17
18use anyhow::Context;
19use chrono::{DateTime, Utc};
20use nautilus_core::UnixNanos;
21use nautilus_model::{
22    data::{
23        Bar, BarType, BookOrder, DEPTH10_LEN, Data, FundingRateUpdate, IndexPriceUpdate,
24        MarkPriceUpdate, NULL_ORDER, OptionGreekValues, OptionGreeks, OrderBookDelta,
25        OrderBookDeltas, OrderBookDeltas_API, OrderBookDepth10, QuoteTick, TradeTick,
26    },
27    enums::{AggregationSource, BookAction, GreeksConvention, OrderSide, RecordFlag},
28    identifiers::{InstrumentId, TradeId},
29    types::{Price, Quantity},
30};
31
32use super::{
33    message::{
34        BarMsg, BookChangeMsg, BookLevel, BookSnapshotMsg, DerivativeTickerMsg, OptionSummaryMsg,
35        TradeMsg, WsMessage,
36    },
37    types::TardisInstrumentMiniInfo,
38};
39use crate::{
40    common::parse::{
41        derive_trade_id, normalize_amount, parse_aggressor_side, parse_bar_spec, parse_book_action,
42    },
43    config::BookSnapshotOutput,
44};
45
46#[must_use]
47pub fn parse_tardis_ws_message(
48    msg: WsMessage,
49    info: &Arc<TardisInstrumentMiniInfo>,
50    book_snapshot_output: &BookSnapshotOutput,
51) -> Option<Data> {
52    match msg {
53        WsMessage::BookChange(msg) => {
54            if msg.bids.is_empty() && msg.asks.is_empty() {
55                let exchange = msg.exchange;
56                let symbol = &msg.symbol;
57                log::error!("Invalid book change for {exchange} {symbol} (empty bids and asks)");
58                return None;
59            }
60
61            match parse_book_change_msg_as_deltas(
62                &msg,
63                info.price_precision,
64                info.size_precision,
65                info.instrument_id,
66            ) {
67                Ok(deltas) => Some(Data::Deltas(deltas)),
68                Err(e) => {
69                    log::error!("Failed to parse book change message: {e}");
70                    None
71                }
72            }
73        }
74        WsMessage::BookSnapshot(msg) => match msg.depth {
75            1 => {
76                match parse_book_snapshot_msg_as_quote(
77                    &msg,
78                    info.price_precision,
79                    info.size_precision,
80                    info.instrument_id,
81                ) {
82                    Ok(quote) => Some(Data::Quote(quote)),
83                    Err(e) => {
84                        log::error!("Failed to parse book snapshot quote message: {e}");
85                        None
86                    }
87                }
88            }
89            _ => match book_snapshot_output {
90                BookSnapshotOutput::Depth10 => {
91                    match parse_book_snapshot_msg_as_depth10(
92                        &msg,
93                        info.price_precision,
94                        info.size_precision,
95                        info.instrument_id,
96                    ) {
97                        Ok(depth10) => Some(Data::Depth10(Box::new(depth10))),
98                        Err(e) => {
99                            log::error!("Failed to parse book snapshot as depth10: {e}");
100                            None
101                        }
102                    }
103                }
104                BookSnapshotOutput::Deltas => {
105                    match parse_book_snapshot_msg_as_deltas(
106                        &msg,
107                        info.price_precision,
108                        info.size_precision,
109                        info.instrument_id,
110                    ) {
111                        Ok(deltas) => Some(Data::Deltas(deltas)),
112                        Err(e) => {
113                            log::error!("Failed to parse book snapshot as deltas: {e}");
114                            None
115                        }
116                    }
117                }
118            },
119        },
120        WsMessage::Trade(msg) => {
121            match parse_trade_msg(
122                &msg,
123                info.price_precision,
124                info.size_precision,
125                info.instrument_id,
126            ) {
127                Ok(trade) => Some(Data::Trade(trade)),
128                Err(e) => {
129                    log::error!("Failed to parse trade message: {e}");
130                    None
131                }
132            }
133        }
134        WsMessage::TradeBar(msg) => {
135            match parse_bar_msg(
136                &msg,
137                info.price_precision,
138                info.size_precision,
139                info.instrument_id,
140            ) {
141                Ok(bar) => Some(Data::Bar(bar)),
142                Err(e) => {
143                    log::error!("Failed to parse bar message: {e}");
144                    None
145                }
146            }
147        }
148        WsMessage::OptionSummary(msg) => Some(Data::OptionGreeks(parse_option_summary_msg(
149            &msg,
150            info.instrument_id,
151        ))),
152        // Derivative ticker messages are handled through a separate callback path
153        // for FundingRateUpdate since they're not part of the Data enum.
154        WsMessage::DerivativeTicker(_) => None,
155        WsMessage::Disconnect(_) => None,
156    }
157}
158
159#[must_use]
160pub fn parse_tardis_ws_message_data(
161    msg: WsMessage,
162    info: &Arc<TardisInstrumentMiniInfo>,
163    book_snapshot_output: &BookSnapshotOutput,
164    extract_bbo_as_quotes: bool,
165) -> Vec<Data> {
166    match msg {
167        WsMessage::OptionSummary(msg) if extract_bbo_as_quotes => {
168            let mut data = Vec::with_capacity(2);
169
170            match parse_option_summary_msg_as_quote(
171                &msg,
172                info.price_precision,
173                info.size_precision,
174                info.instrument_id,
175            ) {
176                Ok(Some(quote)) => data.push(Data::Quote(quote)),
177                Ok(None) => {}
178                Err(e) => {
179                    log::error!("Failed to parse option summary quote message: {e}");
180                }
181            }
182
183            data.push(Data::OptionGreeks(parse_option_summary_msg(
184                &msg,
185                info.instrument_id,
186            )));
187            data
188        }
189        msg => parse_tardis_ws_message(msg, info, book_snapshot_output)
190            .into_iter()
191            .collect(),
192    }
193}
194
195/// Parses a Tardis option summary message into a Nautilus `OptionGreeks`.
196///
197/// Greeks absent from the exchange feed default to `0.0` (matching the existing exchange-greeks
198/// producers); implied volatilities, underlying price and open interest stay `None` when the
199/// exchange does not provide them.
200#[must_use]
201pub fn parse_option_summary_msg(
202    msg: &OptionSummaryMsg,
203    instrument_id: InstrumentId,
204) -> OptionGreeks {
205    OptionGreeks {
206        instrument_id,
207        convention: GreeksConvention::BlackScholes,
208        greeks: OptionGreekValues {
209            delta: msg.delta.unwrap_or(0.0),
210            gamma: msg.gamma.unwrap_or(0.0),
211            vega: msg.vega.unwrap_or(0.0),
212            theta: msg.theta.unwrap_or(0.0),
213            rho: msg.rho.unwrap_or(0.0),
214        },
215        mark_iv: msg.mark_iv,
216        bid_iv: msg.best_bid_iv,
217        ask_iv: msg.best_ask_iv,
218        underlying_price: msg.underlying_price,
219        open_interest: msg.open_interest,
220        ts_event: UnixNanos::from(msg.timestamp),
221        ts_init: UnixNanos::from(msg.local_timestamp),
222    }
223}
224
225/// Parses a Tardis option summary best bid/offer into a Nautilus `QuoteTick`.
226///
227/// Returns `Ok(None)` when any best bid/offer field is absent.
228///
229/// # Errors
230///
231/// Returns an error if a provided bid or ask price or size is invalid.
232pub fn parse_option_summary_msg_as_quote(
233    msg: &OptionSummaryMsg,
234    price_precision: u8,
235    size_precision: u8,
236    instrument_id: InstrumentId,
237) -> anyhow::Result<Option<QuoteTick>> {
238    let (Some(best_bid_price), Some(best_bid_amount), Some(best_ask_price), Some(best_ask_amount)) = (
239        msg.best_bid_price,
240        msg.best_bid_amount,
241        msg.best_ask_price,
242        msg.best_ask_amount,
243    ) else {
244        return Ok(None);
245    };
246
247    let bid_price = Price::new_checked(best_bid_price, price_precision)
248        .with_context(|| format!("invalid option summary bid price for message: {msg:?}"))?;
249    let ask_price = Price::new_checked(best_ask_price, price_precision)
250        .with_context(|| format!("invalid option summary ask price for message: {msg:?}"))?;
251    let bid_size = Quantity::non_zero_checked(best_bid_amount, size_precision)
252        .with_context(|| format!("invalid option summary bid size for message: {msg:?}"))?;
253    let ask_size = Quantity::non_zero_checked(best_ask_amount, size_precision)
254        .with_context(|| format!("invalid option summary ask size for message: {msg:?}"))?;
255
256    Ok(Some(QuoteTick::new(
257        instrument_id,
258        bid_price,
259        ask_price,
260        bid_size,
261        ask_size,
262        UnixNanos::from(msg.timestamp),
263        UnixNanos::from(msg.local_timestamp),
264    )))
265}
266
267/// Parse a Tardis WebSocket message specifically for funding rate updates.
268/// Returns `Some(FundingRateUpdate)` if the message contains funding rate data, `None` otherwise.
269#[must_use]
270pub fn parse_tardis_ws_message_funding_rate(
271    msg: WsMessage,
272    info: &Arc<TardisInstrumentMiniInfo>,
273) -> Option<FundingRateUpdate> {
274    match msg {
275        WsMessage::DerivativeTicker(msg) => {
276            match parse_derivative_ticker_msg(&msg, info.instrument_id) {
277                Ok(funding_rate) => funding_rate,
278                Err(e) => {
279                    log::error!("Failed to parse derivative ticker message for funding rate: {e}");
280                    None
281                }
282            }
283        }
284        _ => None, // Only derivative ticker messages can contain funding rates
285    }
286}
287
288/// Parse a book change message into order book deltas, returning an error if timestamps invalid.
289/// Parse a book change message into order book deltas.
290///
291/// # Errors
292///
293/// Returns an error if timestamp fields cannot be converted to nanoseconds.
294pub fn parse_book_change_msg_as_deltas(
295    msg: &BookChangeMsg,
296    price_precision: u8,
297    size_precision: u8,
298    instrument_id: InstrumentId,
299) -> anyhow::Result<OrderBookDeltas_API> {
300    parse_book_msg_as_deltas(
301        &msg.bids,
302        &msg.asks,
303        msg.is_snapshot,
304        price_precision,
305        size_precision,
306        instrument_id,
307        msg.timestamp,
308        msg.local_timestamp,
309    )
310}
311
312/// Parse a book snapshot message into order book deltas, returning an error if timestamps invalid.
313/// Parse a book snapshot message into order book deltas.
314///
315/// # Errors
316///
317/// Returns an error if timestamp fields cannot be converted to nanoseconds.
318pub fn parse_book_snapshot_msg_as_deltas(
319    msg: &BookSnapshotMsg,
320    price_precision: u8,
321    size_precision: u8,
322    instrument_id: InstrumentId,
323) -> anyhow::Result<OrderBookDeltas_API> {
324    parse_book_msg_as_deltas(
325        &msg.bids,
326        &msg.asks,
327        true,
328        price_precision,
329        size_precision,
330        instrument_id,
331        msg.timestamp,
332        msg.local_timestamp,
333    )
334}
335
336/// Parse a book snapshot message into an [`OrderBookDepth10`].
337///
338/// # Errors
339///
340/// Returns an error if timestamp fields cannot be converted to nanoseconds.
341pub fn parse_book_snapshot_msg_as_depth10(
342    msg: &BookSnapshotMsg,
343    price_precision: u8,
344    size_precision: u8,
345    instrument_id: InstrumentId,
346) -> anyhow::Result<OrderBookDepth10> {
347    let ts_event_nanos = msg
348        .timestamp
349        .timestamp_nanos_opt()
350        .context("invalid timestamp: cannot extract event nanoseconds")?;
351    anyhow::ensure!(
352        ts_event_nanos >= 0,
353        "invalid timestamp: event nanoseconds {ts_event_nanos} is before UNIX epoch"
354    );
355    let ts_event = UnixNanos::from(ts_event_nanos as u64);
356
357    let ts_init_nanos = msg
358        .local_timestamp
359        .timestamp_nanos_opt()
360        .context("invalid timestamp: cannot extract init nanoseconds")?;
361    anyhow::ensure!(
362        ts_init_nanos >= 0,
363        "invalid timestamp: init nanoseconds {ts_init_nanos} is before UNIX epoch"
364    );
365    let ts_init = UnixNanos::from(ts_init_nanos as u64);
366
367    let mut bids = [NULL_ORDER; DEPTH10_LEN];
368    let mut asks = [NULL_ORDER; DEPTH10_LEN];
369    let mut bid_counts = [0u32; DEPTH10_LEN];
370    let mut ask_counts = [0u32; DEPTH10_LEN];
371
372    for (i, level) in msg.bids.iter().take(DEPTH10_LEN).enumerate() {
373        bids[i] = BookOrder::new(
374            OrderSide::Buy,
375            Price::new(level.price, price_precision),
376            Quantity::new(level.amount, size_precision),
377            0,
378        );
379        bid_counts[i] = 1;
380    }
381
382    for (i, level) in msg.asks.iter().take(DEPTH10_LEN).enumerate() {
383        asks[i] = BookOrder::new(
384            OrderSide::Sell,
385            Price::new(level.price, price_precision),
386            Quantity::new(level.amount, size_precision),
387            0,
388        );
389        ask_counts[i] = 1;
390    }
391
392    Ok(OrderBookDepth10::new(
393        instrument_id,
394        bids,
395        asks,
396        bid_counts,
397        ask_counts,
398        RecordFlag::F_SNAPSHOT as u8,
399        0, // Sequence not available from Tardis
400        ts_event,
401        ts_init,
402    ))
403}
404
405/// Parse raw book levels into order book deltas, returning error for invalid timestamps.
406#[expect(clippy::too_many_arguments)]
407/// Parse raw book levels into order book deltas.
408///
409/// # Errors
410///
411/// Returns an error if timestamp fields cannot be converted to nanoseconds.
412pub fn parse_book_msg_as_deltas(
413    bids: &[BookLevel],
414    asks: &[BookLevel],
415    is_snapshot: bool,
416    price_precision: u8,
417    size_precision: u8,
418    instrument_id: InstrumentId,
419    timestamp: DateTime<Utc>,
420    local_timestamp: DateTime<Utc>,
421) -> anyhow::Result<OrderBookDeltas_API> {
422    let event_nanos = timestamp
423        .timestamp_nanos_opt()
424        .context("invalid timestamp: cannot extract event nanoseconds")?;
425    anyhow::ensure!(
426        event_nanos >= 0,
427        "invalid timestamp: event nanoseconds {event_nanos} is before UNIX epoch"
428    );
429    let ts_event = UnixNanos::from(event_nanos as u64);
430    let init_nanos = local_timestamp
431        .timestamp_nanos_opt()
432        .context("invalid timestamp: cannot extract init nanoseconds")?;
433    anyhow::ensure!(
434        init_nanos >= 0,
435        "invalid timestamp: init nanoseconds {init_nanos} is before UNIX epoch"
436    );
437    let ts_init = UnixNanos::from(init_nanos as u64);
438
439    let capacity = if is_snapshot {
440        bids.len() + asks.len() + 1
441    } else {
442        bids.len() + asks.len()
443    };
444    let mut deltas: Vec<OrderBookDelta> = Vec::with_capacity(capacity);
445
446    if is_snapshot {
447        deltas.push(OrderBookDelta::clear(instrument_id, 0, ts_event, ts_init));
448    }
449
450    for level in bids {
451        match parse_book_level(
452            instrument_id,
453            price_precision,
454            size_precision,
455            OrderSide::Buy,
456            level,
457            is_snapshot,
458            ts_event,
459            ts_init,
460        ) {
461            Ok(delta) => deltas.push(delta),
462            Err(e) => log::warn!("Skipping invalid bid level for {instrument_id}: {e}"),
463        }
464    }
465
466    for level in asks {
467        match parse_book_level(
468            instrument_id,
469            price_precision,
470            size_precision,
471            OrderSide::Sell,
472            level,
473            is_snapshot,
474            ts_event,
475            ts_init,
476        ) {
477            Ok(delta) => deltas.push(delta),
478            Err(e) => log::warn!("Skipping invalid ask level for {instrument_id}: {e}"),
479        }
480    }
481
482    if let Some(last_delta) = deltas.last_mut() {
483        last_delta.flags |= RecordFlag::F_LAST as u8;
484    }
485
486    // TODO: Opaque pointer wrapper necessary for Cython (remove once Cython gone)
487    Ok(OrderBookDeltas_API::new(OrderBookDeltas::new(
488        instrument_id,
489        deltas,
490    )))
491}
492
493/// Parse a single book level into an order book delta.
494///
495/// # Errors
496///
497/// Returns an error if a non-delete action has a zero size after normalization.
498#[expect(clippy::too_many_arguments)]
499pub fn parse_book_level(
500    instrument_id: InstrumentId,
501    price_precision: u8,
502    size_precision: u8,
503    side: OrderSide,
504    level: &BookLevel,
505    is_snapshot: bool,
506    ts_event: UnixNanos,
507    ts_init: UnixNanos,
508) -> anyhow::Result<OrderBookDelta> {
509    let amount = normalize_amount(level.amount, size_precision);
510    let action = parse_book_action(is_snapshot, amount);
511    let price = Price::new(level.price, price_precision);
512    let size = Quantity::new(amount, size_precision);
513    let order_id = 0; // Not applicable for L2 data
514    let order = BookOrder::new(side, price, size, order_id);
515    let flags = if is_snapshot {
516        RecordFlag::F_SNAPSHOT as u8
517    } else {
518        0
519    };
520    let sequence = 0; // Not available
521
522    anyhow::ensure!(
523        !(action != BookAction::Delete && size.is_zero()),
524        "Invalid zero size for {action}"
525    );
526
527    Ok(OrderBookDelta::new(
528        instrument_id,
529        action,
530        order,
531        flags,
532        sequence,
533        ts_event,
534        ts_init,
535    ))
536}
537
538/// Parse a book snapshot message into a quote tick, returning an error on invalid data.
539/// Parse a book snapshot message into a quote tick.
540///
541/// # Errors
542///
543/// Returns an error if missing bid/ask levels or invalid sizes.
544pub fn parse_book_snapshot_msg_as_quote(
545    msg: &BookSnapshotMsg,
546    price_precision: u8,
547    size_precision: u8,
548    instrument_id: InstrumentId,
549) -> anyhow::Result<QuoteTick> {
550    let ts_event = UnixNanos::from(msg.timestamp);
551    let ts_init = UnixNanos::from(msg.local_timestamp);
552
553    let best_bid = msg
554        .bids
555        .first()
556        .context("missing best bid level for quote message")?;
557    let bid_price = Price::new(best_bid.price, price_precision);
558    let bid_size = Quantity::non_zero_checked(best_bid.amount, size_precision)
559        .with_context(|| format!("Invalid bid size for message: {msg:?}"))?;
560
561    let best_ask = msg
562        .asks
563        .first()
564        .context("missing best ask level for quote message")?;
565    let ask_price = Price::new(best_ask.price, price_precision);
566    let ask_size = Quantity::non_zero_checked(best_ask.amount, size_precision)
567        .with_context(|| format!("Invalid ask size for message: {msg:?}"))?;
568
569    Ok(QuoteTick::new(
570        instrument_id,
571        bid_price,
572        ask_price,
573        bid_size,
574        ask_size,
575        ts_event,
576        ts_init,
577    ))
578}
579
580/// Parse a trade message into a trade tick, returning an error on invalid data.
581/// Parse a trade message into a trade tick.
582///
583/// # Errors
584///
585/// Returns an error if invalid trade size is encountered.
586pub fn parse_trade_msg(
587    msg: &TradeMsg,
588    price_precision: u8,
589    size_precision: u8,
590    instrument_id: InstrumentId,
591) -> anyhow::Result<TradeTick> {
592    let price = Price::new(msg.price, price_precision);
593    let size = Quantity::non_zero_checked(msg.amount, size_precision)
594        .with_context(|| format!("Invalid trade size in message: {msg:?}"))?;
595    let aggressor_side = parse_aggressor_side(&msg.side);
596    let ts_event = UnixNanos::from(msg.timestamp);
597    let ts_init = UnixNanos::from(msg.local_timestamp);
598    let trade_id = match msg.id.as_deref() {
599        Some(id) if !id.is_empty() => TradeId::new(id),
600        _ => derive_trade_id(
601            msg.symbol,
602            ts_event.as_u64(),
603            msg.price,
604            msg.amount,
605            &msg.side,
606        ),
607    };
608
609    Ok(TradeTick::new(
610        instrument_id,
611        price,
612        size,
613        aggressor_side,
614        trade_id,
615        ts_event,
616        ts_init,
617    ))
618}
619
620/// Parse a bar message into a Bar.
621///
622/// # Errors
623///
624/// Returns an error if the bar specification cannot be parsed.
625pub fn parse_bar_msg(
626    msg: &BarMsg,
627    price_precision: u8,
628    size_precision: u8,
629    instrument_id: InstrumentId,
630) -> anyhow::Result<Bar> {
631    let spec = parse_bar_spec(&msg.name)?;
632    let bar_type = BarType::new(instrument_id, spec, AggregationSource::External);
633
634    let open = Price::new(msg.open, price_precision);
635    let high = Price::new(msg.high, price_precision);
636    let low = Price::new(msg.low, price_precision);
637    let close = Price::new(msg.close, price_precision);
638    let volume = Quantity::non_zero(msg.volume, size_precision);
639    let ts_event = UnixNanos::from(msg.timestamp);
640    let ts_init = UnixNanos::from(msg.local_timestamp);
641
642    Ok(Bar::new(
643        bar_type, open, high, low, close, volume, ts_event, ts_init,
644    ))
645}
646
647/// Extracts event and init timestamps from a derivative ticker message.
648fn parse_derivative_ticker_timestamps(
649    msg: &DerivativeTickerMsg,
650) -> anyhow::Result<(UnixNanos, UnixNanos)> {
651    let ts_event_nanos = msg
652        .timestamp
653        .timestamp_nanos_opt()
654        .context("invalid timestamp: cannot extract event nanoseconds")?;
655    anyhow::ensure!(
656        ts_event_nanos >= 0,
657        "invalid timestamp: event nanoseconds {ts_event_nanos} is before UNIX epoch"
658    );
659
660    let ts_init_nanos = msg
661        .local_timestamp
662        .timestamp_nanos_opt()
663        .context("invalid timestamp: cannot extract init nanoseconds")?;
664    anyhow::ensure!(
665        ts_init_nanos >= 0,
666        "invalid timestamp: init nanoseconds {ts_init_nanos} is before UNIX epoch"
667    );
668
669    Ok((
670        UnixNanos::from(ts_event_nanos as u64),
671        UnixNanos::from(ts_init_nanos as u64),
672    ))
673}
674
675/// Parses a derivative ticker message into a funding rate update.
676///
677/// # Errors
678///
679/// Returns an error if timestamp conversion or decimal conversion fails.
680pub fn parse_derivative_ticker_msg(
681    msg: &DerivativeTickerMsg,
682    instrument_id: InstrumentId,
683) -> anyhow::Result<Option<FundingRateUpdate>> {
684    let funding_rate = match msg.funding_rate {
685        Some(rate) => rate,
686        None => return Ok(None),
687    };
688
689    let (ts_event, ts_init) = parse_derivative_ticker_timestamps(msg)?;
690    let rate = rust_decimal::Decimal::try_from(funding_rate)
691        .with_context(|| format!("failed to convert funding rate {funding_rate} to Decimal"))?;
692
693    Ok(Some(FundingRateUpdate::new(
694        instrument_id,
695        rate,
696        None,
697        None,
698        ts_event,
699        ts_init,
700    )))
701}
702
703/// Parses a derivative ticker message into a mark price update.
704///
705/// # Errors
706///
707/// Returns an error if timestamp conversion fails.
708pub fn parse_derivative_ticker_mark_price(
709    msg: &DerivativeTickerMsg,
710    instrument_id: InstrumentId,
711    price_precision: u8,
712) -> anyhow::Result<Option<MarkPriceUpdate>> {
713    let mark_price = match msg.mark_price {
714        Some(p) => p,
715        None => return Ok(None),
716    };
717
718    let (ts_event, ts_init) = parse_derivative_ticker_timestamps(msg)?;
719
720    Ok(Some(MarkPriceUpdate::new(
721        instrument_id,
722        Price::new(mark_price, price_precision),
723        ts_event,
724        ts_init,
725    )))
726}
727
728/// Parses a derivative ticker message into an index price update.
729///
730/// # Errors
731///
732/// Returns an error if timestamp conversion fails.
733pub fn parse_derivative_ticker_index_price(
734    msg: &DerivativeTickerMsg,
735    instrument_id: InstrumentId,
736    price_precision: u8,
737) -> anyhow::Result<Option<IndexPriceUpdate>> {
738    let index_price = match msg.index_price {
739        Some(p) => p,
740        None => return Ok(None),
741    };
742
743    let (ts_event, ts_init) = parse_derivative_ticker_timestamps(msg)?;
744
745    Ok(Some(IndexPriceUpdate::new(
746        instrument_id,
747        Price::new(index_price, price_precision),
748        ts_event,
749        ts_init,
750    )))
751}
752
753#[cfg(test)]
754mod tests {
755    use nautilus_model::enums::AggressorSide;
756    use rstest::rstest;
757
758    use super::*;
759    use crate::common::{enums::TardisExchange, testing::load_test_json};
760
761    #[rstest]
762    fn test_parse_book_change_message() {
763        let json_data = load_test_json("book_change.json");
764        let msg: BookChangeMsg = serde_json::from_str(&json_data).unwrap();
765
766        let price_precision = 0;
767        let size_precision = 0;
768        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
769        let deltas =
770            parse_book_change_msg_as_deltas(&msg, price_precision, size_precision, instrument_id)
771                .unwrap();
772
773        assert_eq!(deltas.deltas.len(), 1);
774        assert_eq!(deltas.instrument_id, instrument_id);
775        assert_eq!(deltas.flags, RecordFlag::F_LAST as u8);
776        assert_eq!(deltas.sequence, 0);
777        assert_eq!(deltas.ts_event, UnixNanos::from(1571830193469000000));
778        assert_eq!(deltas.ts_init, UnixNanos::from(1571830193469000000));
779        assert_eq!(
780            deltas.deltas[0].instrument_id,
781            InstrumentId::from("XBTUSD.BITMEX")
782        );
783        assert_eq!(deltas.deltas[0].action, BookAction::Update);
784        assert_eq!(deltas.deltas[0].order.price, Price::from("7985"));
785        assert_eq!(deltas.deltas[0].order.size, Quantity::from(283318));
786        assert_eq!(deltas.deltas[0].order.order_id, 0);
787        assert_eq!(deltas.deltas[0].flags, RecordFlag::F_LAST as u8);
788        assert_eq!(deltas.deltas[0].sequence, 0);
789        assert_eq!(
790            deltas.deltas[0].ts_event,
791            UnixNanos::from(1571830193469000000)
792        );
793        assert_eq!(
794            deltas.deltas[0].ts_init,
795            UnixNanos::from(1571830193469000000)
796        );
797    }
798
799    #[rstest]
800    fn test_parse_book_snapshot_message_as_deltas() {
801        let json_data = load_test_json("book_snapshot.json");
802        let msg: BookSnapshotMsg = serde_json::from_str(&json_data).unwrap();
803
804        let price_precision = 1;
805        let size_precision = 0;
806        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
807        let deltas =
808            parse_book_snapshot_msg_as_deltas(&msg, price_precision, size_precision, instrument_id)
809                .unwrap();
810
811        let clear_delta = deltas.deltas[0];
812        let bid_delta = deltas.deltas[1];
813        let ask_delta = deltas.deltas[3];
814
815        assert_eq!(deltas.deltas.len(), 5);
816        assert_eq!(deltas.instrument_id, instrument_id);
817        assert_eq!(
818            deltas.flags,
819            RecordFlag::F_LAST as u8 + RecordFlag::F_SNAPSHOT as u8
820        );
821        assert_eq!(deltas.sequence, 0);
822        assert_eq!(deltas.ts_event, UnixNanos::from(1572010786950000000));
823        assert_eq!(deltas.ts_init, UnixNanos::from(1572010786961000000));
824
825        // CLEAR delta
826        assert_eq!(clear_delta.instrument_id, instrument_id);
827        assert_eq!(clear_delta.action, BookAction::Clear);
828        assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
829        assert_eq!(clear_delta.sequence, 0);
830        assert_eq!(clear_delta.ts_event, UnixNanos::from(1572010786950000000));
831        assert_eq!(clear_delta.ts_init, UnixNanos::from(1572010786961000000));
832
833        // First bid delta
834        assert_eq!(bid_delta.instrument_id, instrument_id);
835        assert_eq!(bid_delta.action, BookAction::Add);
836        assert_eq!(bid_delta.order.side, OrderSide::Buy);
837        assert_eq!(bid_delta.order.price, Price::from("7633.5"));
838        assert_eq!(bid_delta.order.size, Quantity::from(1906067));
839        assert_eq!(bid_delta.order.order_id, 0);
840        assert_eq!(bid_delta.flags, RecordFlag::F_SNAPSHOT as u8);
841        assert_eq!(bid_delta.sequence, 0);
842        assert_eq!(bid_delta.ts_event, UnixNanos::from(1572010786950000000));
843        assert_eq!(bid_delta.ts_init, UnixNanos::from(1572010786961000000));
844
845        // First ask delta
846        assert_eq!(ask_delta.instrument_id, instrument_id);
847        assert_eq!(ask_delta.action, BookAction::Add);
848        assert_eq!(ask_delta.order.side, OrderSide::Sell);
849        assert_eq!(ask_delta.order.price, Price::from("7634.0"));
850        assert_eq!(ask_delta.order.size, Quantity::from(1467849));
851        assert_eq!(ask_delta.order.order_id, 0);
852        assert_eq!(ask_delta.flags, RecordFlag::F_SNAPSHOT as u8);
853        assert_eq!(ask_delta.sequence, 0);
854        assert_eq!(ask_delta.ts_event, UnixNanos::from(1572010786950000000));
855        assert_eq!(ask_delta.ts_init, UnixNanos::from(1572010786961000000));
856    }
857
858    #[rstest]
859    fn test_parse_book_snapshot_message_as_depth10() {
860        let json_data = load_test_json("book_snapshot.json");
861        let msg: BookSnapshotMsg = serde_json::from_str(&json_data).unwrap();
862
863        let price_precision = 1;
864        let size_precision = 0;
865        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
866
867        let depth10 = parse_book_snapshot_msg_as_depth10(
868            &msg,
869            price_precision,
870            size_precision,
871            instrument_id,
872        )
873        .unwrap();
874
875        assert_eq!(depth10.instrument_id, instrument_id);
876        assert_eq!(depth10.flags, RecordFlag::F_SNAPSHOT as u8);
877        assert_eq!(depth10.sequence, 0);
878        assert_eq!(depth10.ts_event, UnixNanos::from(1572010786950000000));
879        assert_eq!(depth10.ts_init, UnixNanos::from(1572010786961000000));
880
881        // Check first bid level
882        assert_eq!(depth10.bids[0].side, OrderSide::Buy);
883        assert_eq!(depth10.bids[0].price, Price::from("7633.5"));
884        assert_eq!(depth10.bids[0].size, Quantity::from(1906067));
885        assert_eq!(depth10.bids[0].order_id, 0);
886        assert_eq!(depth10.bid_counts[0], 1);
887
888        // Check second bid level
889        assert_eq!(depth10.bids[1].side, OrderSide::Buy);
890        assert_eq!(depth10.bids[1].price, Price::from("7633.0"));
891        assert_eq!(depth10.bids[1].size, Quantity::from(65319));
892        assert_eq!(depth10.bid_counts[1], 1);
893
894        // Check first ask level
895        assert_eq!(depth10.asks[0].side, OrderSide::Sell);
896        assert_eq!(depth10.asks[0].price, Price::from("7634.0"));
897        assert_eq!(depth10.asks[0].size, Quantity::from(1467849));
898        assert_eq!(depth10.asks[0].order_id, 0);
899        assert_eq!(depth10.ask_counts[0], 1);
900
901        // Check second ask level
902        assert_eq!(depth10.asks[1].side, OrderSide::Sell);
903        assert_eq!(depth10.asks[1].price, Price::from("7634.5"));
904        assert_eq!(depth10.asks[1].size, Quantity::from(67939));
905        assert_eq!(depth10.ask_counts[1], 1);
906
907        // Check empty levels are NULL_ORDER
908        assert_eq!(depth10.bids[2], NULL_ORDER);
909        assert_eq!(depth10.bid_counts[2], 0);
910        assert_eq!(depth10.asks[2], NULL_ORDER);
911        assert_eq!(depth10.ask_counts[2], 0);
912    }
913
914    #[rstest]
915    fn test_parse_book_snapshot_message_as_quote() {
916        let json_data = load_test_json("book_snapshot.json");
917        let msg: BookSnapshotMsg = serde_json::from_str(&json_data).unwrap();
918
919        let price_precision = 1;
920        let size_precision = 0;
921        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
922        let quote =
923            parse_book_snapshot_msg_as_quote(&msg, price_precision, size_precision, instrument_id)
924                .expect("Failed to parse book snapshot quote message");
925
926        assert_eq!(quote.instrument_id, instrument_id);
927        assert_eq!(quote.bid_price, Price::from("7633.5"));
928        assert_eq!(quote.bid_size, Quantity::from(1906067));
929        assert_eq!(quote.ask_price, Price::from("7634.0"));
930        assert_eq!(quote.ask_size, Quantity::from(1467849));
931        assert_eq!(quote.ts_event, UnixNanos::from(1572010786950000000));
932        assert_eq!(quote.ts_init, UnixNanos::from(1572010786961000000));
933    }
934
935    #[rstest]
936    fn test_parse_trade_message() {
937        let json_data = load_test_json("trade.json");
938        let msg: TradeMsg = serde_json::from_str(&json_data).unwrap();
939
940        let price_precision = 0;
941        let size_precision = 0;
942        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
943        let trade = parse_trade_msg(&msg, price_precision, size_precision, instrument_id)
944            .expect("Failed to parse trade message");
945
946        assert_eq!(trade.instrument_id, instrument_id);
947        assert_eq!(trade.price, Price::from("7996"));
948        assert_eq!(trade.size, Quantity::from(50));
949        assert_eq!(trade.aggressor_side, AggressorSide::Seller);
950        assert_eq!(trade.ts_event, UnixNanos::from(1571826769669000000));
951        assert_eq!(trade.ts_init, UnixNanos::from(1571826769740000000));
952    }
953
954    fn build_trade_msg_without_id() -> TradeMsg {
955        let json_data = load_test_json("trade.json");
956        let mut msg: TradeMsg = serde_json::from_str(&json_data).unwrap();
957        msg.id = None;
958        msg
959    }
960
961    #[rstest]
962    fn test_parse_trade_message_derives_trade_id_when_missing() {
963        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
964
965        let first = parse_trade_msg(&build_trade_msg_without_id(), 0, 0, instrument_id).unwrap();
966        let second = parse_trade_msg(&build_trade_msg_without_id(), 0, 0, instrument_id).unwrap();
967
968        assert_eq!(first.trade_id, second.trade_id, "derivation must be stable");
969        assert_eq!(first.trade_id.as_str().len(), 16);
970
971        let mut altered = build_trade_msg_without_id();
972        altered.price = 7997.0;
973        let altered_trade = parse_trade_msg(&altered, 0, 0, instrument_id).unwrap();
974        assert_ne!(first.trade_id, altered_trade.trade_id);
975    }
976
977    #[rstest]
978    fn test_parse_trade_message_derives_trade_id_when_empty() {
979        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
980
981        let mut msg = build_trade_msg_without_id();
982        msg.id = Some(String::new());
983
984        let trade = parse_trade_msg(&msg, 0, 0, instrument_id).unwrap();
985        let fallback = parse_trade_msg(&build_trade_msg_without_id(), 0, 0, instrument_id).unwrap();
986        assert_eq!(trade.trade_id, fallback.trade_id);
987    }
988
989    #[rstest]
990    fn test_parse_bar_message() {
991        let json_data = load_test_json("bar.json");
992        let msg: BarMsg = serde_json::from_str(&json_data).unwrap();
993
994        let price_precision = 1;
995        let size_precision = 0;
996        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
997        let bar = parse_bar_msg(&msg, price_precision, size_precision, instrument_id).unwrap();
998
999        assert_eq!(
1000            bar.bar_type,
1001            BarType::from("XBTUSD.BITMEX-10-SECOND-LAST-EXTERNAL")
1002        );
1003        assert_eq!(bar.open, Price::from("7623.5"));
1004        assert_eq!(bar.high, Price::from("7623.5"));
1005        assert_eq!(bar.low, Price::from("7623"));
1006        assert_eq!(bar.close, Price::from("7623.5"));
1007        assert_eq!(bar.volume, Quantity::from(37034));
1008        assert_eq!(bar.ts_event, UnixNanos::from(1572009100000000000));
1009        assert_eq!(bar.ts_init, UnixNanos::from(1572009100369000000));
1010    }
1011
1012    #[rstest]
1013    fn test_parse_tardis_ws_message_book_snapshot_routes_to_depth10() {
1014        let json_data = load_test_json("book_snapshot.json");
1015        let msg: BookSnapshotMsg = serde_json::from_str(&json_data).unwrap();
1016        let ws_msg = WsMessage::BookSnapshot(msg);
1017
1018        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1019        let info = Arc::new(TardisInstrumentMiniInfo::new(
1020            instrument_id,
1021            None,
1022            TardisExchange::Bitmex,
1023            1,
1024            0,
1025        ));
1026
1027        let result = parse_tardis_ws_message(ws_msg, &info, &BookSnapshotOutput::Depth10);
1028
1029        assert!(result.is_some());
1030        assert!(matches!(result.unwrap(), Data::Depth10(_)));
1031    }
1032
1033    #[rstest]
1034    fn test_parse_tardis_ws_message_sparse_book_snapshot_routes_to_depth10() {
1035        let json_data = r#"{
1036            "type": "book_snapshot",
1037            "symbol": "ETC",
1038            "exchange": "hyperliquid",
1039            "name": "book_snapshot_20_10s",
1040            "depth": 20,
1041            "interval": 10000,
1042            "bids": [{"price": 20.002, "amount": 5.81}],
1043            "asks": [{"price": 20.003, "amount": 162.45}, {}],
1044            "timestamp": "2025-03-03T10:48:10.000Z",
1045            "localTimestamp": "2025-03-03T10:48:10.596818Z"
1046        }"#;
1047        let msg: BookSnapshotMsg = serde_json::from_str(json_data).unwrap();
1048        let ws_msg = WsMessage::BookSnapshot(msg);
1049
1050        let instrument_id = InstrumentId::from("ETC.HYPERLIQUID");
1051        let info = Arc::new(TardisInstrumentMiniInfo::new(
1052            instrument_id,
1053            None,
1054            TardisExchange::Hyperliquid,
1055            3,
1056            2,
1057        ));
1058
1059        let result = parse_tardis_ws_message(ws_msg, &info, &BookSnapshotOutput::Depth10);
1060
1061        assert!(result.is_some());
1062        assert!(matches!(result.unwrap(), Data::Depth10(_)));
1063    }
1064
1065    #[rstest]
1066    fn test_parse_tardis_ws_message_book_snapshot_routes_to_deltas() {
1067        let json_data = load_test_json("book_snapshot.json");
1068        let msg: BookSnapshotMsg = serde_json::from_str(&json_data).unwrap();
1069        let ws_msg = WsMessage::BookSnapshot(msg);
1070
1071        let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1072        let info = Arc::new(TardisInstrumentMiniInfo::new(
1073            instrument_id,
1074            None,
1075            TardisExchange::Bitmex,
1076            1,
1077            0,
1078        ));
1079
1080        let result = parse_tardis_ws_message(ws_msg, &info, &BookSnapshotOutput::Deltas);
1081
1082        assert!(result.is_some());
1083        assert!(matches!(result.unwrap(), Data::Deltas(_)));
1084    }
1085
1086    #[rstest]
1087    fn test_parse_tardis_ws_message_option_summary_routes_to_option_greeks() {
1088        let json_data = load_test_json("option_summary.json");
1089        let msg: OptionSummaryMsg = serde_json::from_str(&json_data).unwrap();
1090        let ts_event = UnixNanos::from(msg.timestamp);
1091        let ts_init = UnixNanos::from(msg.local_timestamp);
1092        let ws_msg = WsMessage::OptionSummary(msg);
1093
1094        let instrument_id = InstrumentId::from("BTC-28JUN24-70000-C.DERIBIT");
1095        let info = Arc::new(TardisInstrumentMiniInfo::new(
1096            instrument_id,
1097            None,
1098            TardisExchange::Deribit,
1099            4,
1100            1,
1101        ));
1102
1103        let result = parse_tardis_ws_message(ws_msg, &info, &BookSnapshotOutput::Deltas);
1104
1105        let Some(Data::OptionGreeks(greeks)) = result else {
1106            panic!("Expected Data::OptionGreeks, was {result:?}");
1107        };
1108        assert_eq!(greeks.instrument_id, instrument_id);
1109        assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
1110        assert_eq!(greeks.greeks.delta, 0.25);
1111        assert_eq!(greeks.greeks.gamma, 0.00002);
1112        assert_eq!(greeks.greeks.vega, 45.5);
1113        assert_eq!(greeks.greeks.theta, -15.2);
1114        assert_eq!(greeks.greeks.rho, 0.05);
1115        assert_eq!(greeks.mark_iv, Some(0.565));
1116        assert_eq!(greeks.bid_iv, Some(0.55));
1117        assert_eq!(greeks.ask_iv, Some(0.58));
1118        assert_eq!(greeks.underlying_price, Some(63_500.0));
1119        assert_eq!(greeks.open_interest, Some(150.0));
1120        assert_eq!(greeks.ts_event, ts_event);
1121        assert_eq!(greeks.ts_init, ts_init);
1122    }
1123
1124    #[rstest]
1125    fn test_parse_tardis_ws_message_data_extracts_option_summary_quote_when_enabled() {
1126        let json_data = load_test_json("option_summary.json");
1127        let msg: OptionSummaryMsg = serde_json::from_str(&json_data).unwrap();
1128        let ts_event = UnixNanos::from(msg.timestamp);
1129        let ts_init = UnixNanos::from(msg.local_timestamp);
1130        let ws_msg = WsMessage::OptionSummary(msg);
1131
1132        let instrument_id = InstrumentId::from("BTC-28JUN24-70000-C.DERIBIT");
1133        let info = Arc::new(TardisInstrumentMiniInfo::new(
1134            instrument_id,
1135            None,
1136            TardisExchange::Deribit,
1137            4,
1138            1,
1139        ));
1140
1141        let data = parse_tardis_ws_message_data(ws_msg, &info, &BookSnapshotOutput::Deltas, true);
1142
1143        assert_eq!(data.len(), 2);
1144        let Data::Quote(quote) = &data[0] else {
1145            panic!("Expected first data item to be QuoteTick");
1146        };
1147        let Data::OptionGreeks(greeks) = &data[1] else {
1148            panic!("Expected second data item to be OptionGreeks");
1149        };
1150        assert_eq!(quote.instrument_id, instrument_id);
1151        assert_eq!(quote.bid_price, Price::from("0.035"));
1152        assert_eq!(quote.bid_size, Quantity::from("5"));
1153        assert_eq!(quote.ask_price, Price::from("0.04"));
1154        assert_eq!(quote.ask_size, Quantity::from("10"));
1155        assert_eq!(quote.ts_event, ts_event);
1156        assert_eq!(quote.ts_init, ts_init);
1157        assert_eq!(greeks.instrument_id, instrument_id);
1158        assert_eq!(greeks.ts_event, ts_event);
1159        assert_eq!(greeks.ts_init, ts_init);
1160    }
1161
1162    #[rstest]
1163    fn test_parse_tardis_ws_message_data_skips_option_summary_quote_when_disabled() {
1164        let json_data = load_test_json("option_summary.json");
1165        let msg: OptionSummaryMsg = serde_json::from_str(&json_data).unwrap();
1166        let ws_msg = WsMessage::OptionSummary(msg);
1167
1168        let instrument_id = InstrumentId::from("BTC-28JUN24-70000-C.DERIBIT");
1169        let info = Arc::new(TardisInstrumentMiniInfo::new(
1170            instrument_id,
1171            None,
1172            TardisExchange::Deribit,
1173            4,
1174            1,
1175        ));
1176
1177        let data = parse_tardis_ws_message_data(ws_msg, &info, &BookSnapshotOutput::Deltas, false);
1178
1179        assert_eq!(data.len(), 1);
1180        assert!(
1181            matches!(data[0], Data::OptionGreeks(_)),
1182            "Expected OptionGreeks, was {:?}",
1183            data[0]
1184        );
1185    }
1186
1187    #[rstest]
1188    fn test_parse_tardis_ws_message_data_skips_option_summary_quote_when_bbo_missing() {
1189        let json_data = load_test_json("option_summary.json");
1190        let mut msg: OptionSummaryMsg = serde_json::from_str(&json_data).unwrap();
1191        msg.best_ask_price = None;
1192        let ws_msg = WsMessage::OptionSummary(msg);
1193
1194        let instrument_id = InstrumentId::from("BTC-28JUN24-70000-C.DERIBIT");
1195        let info = Arc::new(TardisInstrumentMiniInfo::new(
1196            instrument_id,
1197            None,
1198            TardisExchange::Deribit,
1199            4,
1200            1,
1201        ));
1202
1203        let data = parse_tardis_ws_message_data(ws_msg, &info, &BookSnapshotOutput::Deltas, true);
1204
1205        assert_eq!(data.len(), 1);
1206        assert!(
1207            matches!(data[0], Data::OptionGreeks(_)),
1208            "Expected OptionGreeks, was {:?}",
1209            data[0]
1210        );
1211    }
1212
1213    #[rstest]
1214    fn test_parse_tardis_ws_message_data_keeps_option_summary_when_bbo_size_invalid() {
1215        let json_data = load_test_json("option_summary.json");
1216        let mut msg: OptionSummaryMsg = serde_json::from_str(&json_data).unwrap();
1217        let ts_event = UnixNanos::from(msg.timestamp);
1218        let ts_init = UnixNanos::from(msg.local_timestamp);
1219        msg.best_bid_amount = Some(0.0);
1220        let ws_msg = WsMessage::OptionSummary(msg);
1221
1222        let instrument_id = InstrumentId::from("BTC-28JUN24-70000-C.DERIBIT");
1223        let info = Arc::new(TardisInstrumentMiniInfo::new(
1224            instrument_id,
1225            None,
1226            TardisExchange::Deribit,
1227            4,
1228            1,
1229        ));
1230
1231        let data = parse_tardis_ws_message_data(ws_msg, &info, &BookSnapshotOutput::Deltas, true);
1232
1233        assert_eq!(data.len(), 1);
1234        let Data::OptionGreeks(greeks) = &data[0] else {
1235            panic!("Expected OptionGreeks, was {:?}", data[0]);
1236        };
1237        assert_eq!(greeks.instrument_id, instrument_id);
1238        assert_eq!(greeks.ts_event, ts_event);
1239        assert_eq!(greeks.ts_init, ts_init);
1240    }
1241
1242    #[rstest]
1243    fn test_parse_tardis_ws_message_data_keeps_option_summary_when_bbo_price_invalid() {
1244        let json_data = load_test_json("option_summary.json");
1245        let mut msg: OptionSummaryMsg = serde_json::from_str(&json_data).unwrap();
1246        let ts_event = UnixNanos::from(msg.timestamp);
1247        let ts_init = UnixNanos::from(msg.local_timestamp);
1248        msg.best_bid_price = Some(f64::MAX);
1249        let ws_msg = WsMessage::OptionSummary(msg);
1250
1251        let instrument_id = InstrumentId::from("BTC-28JUN24-70000-C.DERIBIT");
1252        let info = Arc::new(TardisInstrumentMiniInfo::new(
1253            instrument_id,
1254            None,
1255            TardisExchange::Deribit,
1256            4,
1257            1,
1258        ));
1259
1260        let data = parse_tardis_ws_message_data(ws_msg, &info, &BookSnapshotOutput::Deltas, true);
1261
1262        assert_eq!(data.len(), 1);
1263        let Data::OptionGreeks(greeks) = &data[0] else {
1264            panic!("Expected OptionGreeks, was {:?}", data[0]);
1265        };
1266        assert_eq!(greeks.instrument_id, instrument_id);
1267        assert_eq!(greeks.ts_event, ts_event);
1268        assert_eq!(greeks.ts_init, ts_init);
1269    }
1270
1271    #[rstest]
1272    fn test_parse_option_summary_msg_defaults_absent_fields() {
1273        let ts = DateTime::parse_from_rfc3339("2024-01-15T10:30:00.123Z")
1274            .unwrap()
1275            .with_timezone(&Utc);
1276        let msg = OptionSummaryMsg {
1277            symbol: ustr::Ustr::from("BTC-28JUN24-70000-C"),
1278            exchange: TardisExchange::Deribit,
1279            option_type: "call".to_string(),
1280            strike_price: 70_000.0,
1281            expiration_date: ts,
1282            best_bid_price: None,
1283            best_bid_amount: None,
1284            best_bid_iv: None,
1285            best_ask_price: None,
1286            best_ask_amount: None,
1287            best_ask_iv: None,
1288            last_price: None,
1289            open_interest: None,
1290            mark_price: None,
1291            mark_iv: None,
1292            delta: None,
1293            gamma: None,
1294            vega: None,
1295            theta: None,
1296            rho: None,
1297            underlying_price: None,
1298            underlying_index: "BTC-USD".to_string(),
1299            timestamp: ts,
1300            local_timestamp: ts,
1301        };
1302
1303        let instrument_id = InstrumentId::from("BTC-28JUN24-70000-C.DERIBIT");
1304        let greeks = parse_option_summary_msg(&msg, instrument_id);
1305
1306        // Absent greeks default to 0.0; absent IVs, underlying and open interest stay None.
1307        assert_eq!(greeks.greeks.delta, 0.0);
1308        assert_eq!(greeks.greeks.gamma, 0.0);
1309        assert_eq!(greeks.greeks.vega, 0.0);
1310        assert_eq!(greeks.greeks.theta, 0.0);
1311        assert_eq!(greeks.greeks.rho, 0.0);
1312        assert_eq!(greeks.mark_iv, None);
1313        assert_eq!(greeks.bid_iv, None);
1314        assert_eq!(greeks.ask_iv, None);
1315        assert_eq!(greeks.underlying_price, None);
1316        assert_eq!(greeks.open_interest, None);
1317    }
1318
1319    #[rstest]
1320    fn test_parse_derivative_ticker_funding_rate() {
1321        let json_data = load_test_json("derivative_ticker.json");
1322        let msg: DerivativeTickerMsg = serde_json::from_str(&json_data).unwrap();
1323
1324        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1325
1326        let result = parse_derivative_ticker_msg(&msg, instrument_id).unwrap();
1327        assert!(result.is_some());
1328
1329        let funding = result.unwrap();
1330        assert_eq!(funding.instrument_id, instrument_id);
1331        assert_eq!(funding.rate.to_string(), "-0.00001568");
1332        assert!(funding.ts_event.as_u64() > 0);
1333        assert!(funding.ts_init.as_u64() > 0);
1334    }
1335
1336    #[rstest]
1337    fn test_parse_derivative_ticker_mark_price() {
1338        let json_data = load_test_json("derivative_ticker.json");
1339        let msg: DerivativeTickerMsg = serde_json::from_str(&json_data).unwrap();
1340
1341        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1342        let price_precision = 2;
1343
1344        let result =
1345            parse_derivative_ticker_mark_price(&msg, instrument_id, price_precision).unwrap();
1346        assert!(result.is_some());
1347
1348        let mark = result.unwrap();
1349        assert_eq!(mark.instrument_id, instrument_id);
1350        assert_eq!(mark.value, Price::new(7987.56, price_precision));
1351        assert!(mark.ts_event.as_u64() > 0);
1352        assert!(mark.ts_init.as_u64() > 0);
1353    }
1354
1355    #[rstest]
1356    fn test_parse_derivative_ticker_index_price() {
1357        let json_data = load_test_json("derivative_ticker.json");
1358        let msg: DerivativeTickerMsg = serde_json::from_str(&json_data).unwrap();
1359
1360        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1361        let price_precision = 2;
1362
1363        let result =
1364            parse_derivative_ticker_index_price(&msg, instrument_id, price_precision).unwrap();
1365        assert!(result.is_some());
1366
1367        let index = result.unwrap();
1368        assert_eq!(index.instrument_id, instrument_id);
1369        assert_eq!(index.value, Price::new(7989.28, price_precision));
1370        assert!(index.ts_event.as_u64() > 0);
1371        assert!(index.ts_init.as_u64() > 0);
1372    }
1373
1374    #[rstest]
1375    fn test_parse_derivative_ticker_missing_fields() {
1376        // Test with minimal data (only funding_rate, no mark/index)
1377        let json = r#"{
1378            "type": "derivative_ticker",
1379            "symbol": "BTCUSD",
1380            "exchange": "bitmex",
1381            "lastPrice": null,
1382            "openInterest": null,
1383            "fundingRate": 0.0001,
1384            "indexPrice": null,
1385            "markPrice": null,
1386            "timestamp": "2024-01-01T00:00:00.000Z",
1387            "localTimestamp": "2024-01-01T00:00:00.100Z"
1388        }"#;
1389        let msg: DerivativeTickerMsg = serde_json::from_str(json).unwrap();
1390
1391        let instrument_id = InstrumentId::from("BTCUSD.BITMEX");
1392
1393        let funding = parse_derivative_ticker_msg(&msg, instrument_id).unwrap();
1394        assert!(funding.is_some());
1395
1396        let mark = parse_derivative_ticker_mark_price(&msg, instrument_id, 1).unwrap();
1397        assert!(mark.is_none());
1398
1399        let index = parse_derivative_ticker_index_price(&msg, instrument_id, 1).unwrap();
1400        assert!(index.is_none());
1401    }
1402}