Skip to main content

nautilus_derive/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//! Parsers for Derive public WebSocket subscription payloads.
17
18use anyhow::Context;
19use nautilus_core::{
20    DurationNanos, UnixNanos,
21    datetime::{NANOSECONDS_IN_MILLISECOND, NANOSECONDS_IN_SECOND},
22};
23use nautilus_model::{
24    data::{
25        Bar, BarType, BookOrder, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
26        OrderBookDelta, OrderBookDeltas, OrderBookDepth, QuoteTick, TradeTick, depth::DEPTH10_LEN,
27        greeks::OptionGreekValues, option_chain::OptionGreeks,
28    },
29    enums::{AggressorSide, BarAggregation, BookAction, GreeksConvention, OrderSide, RecordFlag},
30    identifiers::{InstrumentId, TradeId},
31    types::{Price, Quantity},
32};
33use rust_decimal::prelude::ToPrimitive;
34
35use super::messages::{
36    DeriveOrderbookData, DeriveOrderbookLevel, DeriveOrderbookMsg, DerivePublicWsData,
37    DeriveTickerData, DeriveTickerMsg, DeriveTradesMsg, WsSubscriptionPayload,
38};
39use crate::{
40    common::{
41        enums::{DeriveLiquidityRole, DeriveOrderSide},
42        parse::format_instrument_id,
43    },
44    http::models::{
45        DerivePublicCandle, DerivePublicFundingRate, DerivePublicTrade, DeriveTickerSnapshot,
46    },
47};
48
49/// Parses a Derive public subscription payload into a typed market data update.
50///
51/// # Errors
52///
53/// Returns an error if the channel is unsupported or `params.data` does not match the channel
54/// payload shape.
55pub fn parse_public_ws_data(payload: &WsSubscriptionPayload) -> anyhow::Result<DerivePublicWsData> {
56    let channel = payload.channel.as_str();
57    if channel.starts_with("orderbook.") {
58        return parse_orderbook_msg(payload).map(DerivePublicWsData::Orderbook);
59    }
60
61    if channel.starts_with("trades.") {
62        return parse_trades_msg(payload).map(DerivePublicWsData::Trades);
63    }
64
65    if channel.starts_with("ticker_slim.") || channel.starts_with("ticker.") {
66        return parse_ticker_msg(payload).map(|msg| DerivePublicWsData::Ticker(Box::new(msg)));
67    }
68
69    anyhow::bail!("unsupported Derive public WS channel `{}`", payload.channel)
70}
71
72/// Parses an order book subscription payload.
73///
74/// # Errors
75///
76/// Returns an error if `params.data` is not a Derive order book snapshot.
77pub fn parse_orderbook_msg(payload: &WsSubscriptionPayload) -> anyhow::Result<DeriveOrderbookMsg> {
78    let data = serde_json::from_str::<DeriveOrderbookData>(payload.data.get())
79        .context("failed to decode Derive orderbook data")?;
80    Ok(DeriveOrderbookMsg {
81        channel: payload.channel,
82        data,
83    })
84}
85
86/// Parses a public trades subscription payload.
87///
88/// # Errors
89///
90/// Returns an error if `params.data` is not a list of Derive public trades.
91pub fn parse_trades_msg(payload: &WsSubscriptionPayload) -> anyhow::Result<DeriveTradesMsg> {
92    let trades = serde_json::from_str::<Vec<DerivePublicTrade>>(payload.data.get())
93        .context("failed to decode Derive trades data")?;
94    Ok(DeriveTradesMsg {
95        channel: payload.channel,
96        trades,
97    })
98}
99
100/// Parses a ticker subscription payload.
101///
102/// # Errors
103///
104/// Returns an error if `params.data` is not a Derive ticker payload.
105pub fn parse_ticker_msg(payload: &WsSubscriptionPayload) -> anyhow::Result<DeriveTickerMsg> {
106    let mut data = serde_json::from_str::<DeriveTickerData>(payload.data.get())
107        .context("failed to decode Derive ticker data")?;
108    data.apply_channel_context(payload.channel.as_str())
109        .map_err(anyhow::Error::msg)?;
110    Ok(DeriveTickerMsg {
111        channel: payload.channel,
112        data,
113    })
114}
115
116/// Parses an order book snapshot message into Nautilus snapshot deltas.
117///
118/// Derive's grouped order book stream sends a full depth snapshot for the
119/// requested grouping and depth, so the output starts with a clear delta and
120/// marks the last add with `F_LAST`. The payload does not include a change ID,
121/// so this uses the feed timestamp as the snapshot sequence.
122///
123/// Pass price and size precision from the instrument definition rather than
124/// inferring them from the wire values, since Derive may trim trailing zeroes.
125///
126/// # Errors
127///
128/// Returns an error if a price, size, or timestamp cannot be converted.
129pub fn parse_orderbook_deltas(
130    msg: &DeriveOrderbookMsg,
131    price_precision: u8,
132    size_precision: u8,
133    ts_init: UnixNanos,
134) -> anyhow::Result<OrderBookDeltas> {
135    let instrument_id = msg.data.instrument_id()?;
136    let timestamp =
137        u64::try_from(msg.data.timestamp).context("negative Derive orderbook timestamp")?;
138    let ts_event = timestamp_millis_to_nanos(timestamp, "timestamp")?;
139    let sequence = timestamp;
140
141    let context = BookDeltaContext {
142        instrument_id,
143        sequence,
144        price_precision,
145        size_precision,
146        ts_event,
147        ts_init,
148    };
149
150    let mut deltas = Vec::with_capacity(1 + msg.data.bids.len() + msg.data.asks.len());
151
152    let clear_flags = if msg.data.bids.is_empty() && msg.data.asks.is_empty() {
153        RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
154    } else {
155        RecordFlag::F_SNAPSHOT as u8
156    };
157
158    deltas.push(OrderBookDelta::new_checked(
159        context.instrument_id,
160        BookAction::Clear,
161        BookOrder::default(),
162        clear_flags,
163        context.sequence,
164        context.ts_event,
165        context.ts_init,
166    )?);
167
168    for (idx, level) in msg.data.bids.iter().enumerate() {
169        push_level_delta(&mut deltas, &context, OrderSide::Buy, level, idx as u64)?;
170    }
171
172    let bid_count = msg.data.bids.len();
173    for (idx, level) in msg.data.asks.iter().enumerate() {
174        push_level_delta(
175            &mut deltas,
176            &context,
177            OrderSide::Sell,
178            level,
179            (bid_count + idx) as u64,
180        )?;
181    }
182
183    if let Some(last) = deltas.last_mut() {
184        last.flags |= RecordFlag::F_LAST as u8;
185    }
186
187    OrderBookDeltas::new_checked(context.instrument_id, deltas)
188}
189
190/// Parses an order book snapshot message into a fixed top-10 depth update.
191///
192/// Derive sends snapshots for the requested order book channel. Missing levels
193/// are filled with zero-size orders so the fixed arrays are always populated.
194///
195/// # Errors
196///
197/// Returns an error if a price, size, or timestamp cannot be converted.
198pub fn parse_orderbook_depth(
199    msg: &DeriveOrderbookMsg,
200    price_precision: u8,
201    size_precision: u8,
202    ts_init: UnixNanos,
203) -> anyhow::Result<OrderBookDepth> {
204    let instrument_id = msg.data.instrument_id()?;
205    let timestamp =
206        u64::try_from(msg.data.timestamp).context("negative Derive orderbook timestamp")?;
207    let ts_event = timestamp_millis_to_nanos(timestamp, "timestamp")?;
208
209    let mut bids = [BookOrder::default(); DEPTH10_LEN];
210    let mut asks = [BookOrder::default(); DEPTH10_LEN];
211    let mut bid_counts = [0; DEPTH10_LEN];
212    let mut ask_counts = [0; DEPTH10_LEN];
213
214    fill_depth_side(
215        &mut bids,
216        &mut bid_counts,
217        &msg.data.bids,
218        OrderSide::Buy,
219        price_precision,
220        size_precision,
221    )?;
222    fill_depth_side(
223        &mut asks,
224        &mut ask_counts,
225        &msg.data.asks,
226        OrderSide::Sell,
227        price_precision,
228        size_precision,
229    )?;
230
231    Ok(OrderBookDepth::new(
232        instrument_id,
233        bids,
234        asks,
235        bid_counts,
236        ask_counts,
237        RecordFlag::F_SNAPSHOT as u8,
238        timestamp,
239        ts_event,
240        ts_init,
241    ))
242}
243
244/// Parses a public trade message into a Nautilus trade tick.
245///
246/// Public rows carry the participant's `direction`. It maps directly to the aggressor
247/// side when the participant role is absent or taker. Maker rows use the opposite
248/// direction. An unrecognized role yields `AggressorSide::NoAggressor`.
249///
250/// Pass price and size precision from the instrument definition rather than
251/// inferring them from the wire values, since Derive may trim trailing zeroes.
252///
253/// # Errors
254///
255/// Returns an error if trade identifier, price, size, or timestamp conversion fails.
256pub fn parse_trade_tick(
257    trade: &DerivePublicTrade,
258    price_precision: u8,
259    size_precision: u8,
260    ts_init: UnixNanos,
261) -> anyhow::Result<TradeTick> {
262    parse_trade_tick_from_rest(trade, price_precision, size_precision, ts_init)
263}
264
265/// Parses a public participant trade row into a Nautilus trade tick.
266///
267/// Public rows can pair maker and taker participants under the same `trade_id`.
268/// Each row's `direction` is that participant's side: the aggressor side is the
269/// taker row's direction and the inverse of the maker row's. An absent role
270/// treats `direction` as the taker's side. An unrecognized role preserves the
271/// trade with no aggressor side.
272///
273/// Pass price and size precision from the instrument definition rather than
274/// inferring them from the wire values, since Derive may trim trailing zeroes.
275///
276/// # Errors
277///
278/// Returns an error if trade identifier, price, size, or timestamp conversion fails.
279pub fn parse_trade_tick_from_rest(
280    trade: &DerivePublicTrade,
281    price_precision: u8,
282    size_precision: u8,
283    ts_init: UnixNanos,
284) -> anyhow::Result<TradeTick> {
285    if trade.liquidity_role == Some(DeriveLiquidityRole::Unknown) {
286        log::warn!(
287            "Unknown Derive liquidity role for trade {} on {}, preserving no aggressor side",
288            trade.trade_id,
289            trade.instrument_name,
290        );
291    }
292
293    let aggressor_side = match (trade.liquidity_role, trade.direction) {
294        (Some(DeriveLiquidityRole::Unknown), _) => AggressorSide::NoAggressor,
295        (Some(DeriveLiquidityRole::Maker), DeriveOrderSide::Buy) => AggressorSide::Sell,
296        (Some(DeriveLiquidityRole::Maker), DeriveOrderSide::Sell) => AggressorSide::Buy,
297        (_, DeriveOrderSide::Buy) => AggressorSide::Buy,
298        (_, DeriveOrderSide::Sell) => AggressorSide::Sell,
299    };
300
301    build_trade_tick(
302        trade,
303        aggressor_side,
304        price_precision,
305        size_precision,
306        ts_init,
307    )
308}
309
310fn build_trade_tick(
311    trade: &DerivePublicTrade,
312    aggressor_side: AggressorSide,
313    price_precision: u8,
314    size_precision: u8,
315    ts_init: UnixNanos,
316) -> anyhow::Result<TradeTick> {
317    let instrument_id = format_instrument_id(trade.instrument_name)?;
318    let price = Price::from_decimal_dp(trade.trade_price, price_precision)
319        .with_context(|| format!("invalid trade price for {}", trade.instrument_name))?;
320    let size = Quantity::from_decimal_dp(trade.trade_amount, size_precision)
321        .with_context(|| format!("invalid trade amount for {}", trade.instrument_name))?;
322    let trade_id = TradeId::new_checked(&trade.trade_id).context("invalid Derive trade_id")?;
323    let timestamp = u64::try_from(trade.timestamp).context("negative Derive trade timestamp")?;
324    let ts_event = timestamp_millis_to_nanos(timestamp, "timestamp")?;
325
326    TradeTick::new_checked(
327        instrument_id,
328        price,
329        size,
330        aggressor_side,
331        trade_id,
332        ts_event,
333        ts_init,
334    )
335}
336
337/// Parses a ticker message into a Nautilus top-of-book quote.
338///
339/// Pass price and size precision from the instrument definition rather than
340/// inferring them from the wire values, since Derive may trim trailing zeroes.
341///
342/// # Errors
343///
344/// Returns an error if price, size, or timestamp conversion fails.
345pub fn parse_ticker_quote(
346    msg: &DeriveTickerMsg,
347    price_precision: u8,
348    size_precision: u8,
349    ts_init: UnixNanos,
350) -> anyhow::Result<QuoteTick> {
351    let instrument_id = msg.data.instrument_id()?;
352    let instrument_name = msg.data.instrument_name().as_str();
353    let bid_price = Price::from_decimal_dp(msg.data.best_bid_price(), price_precision)
354        .with_context(|| format!("invalid bid price for {instrument_name}"))?;
355    let ask_price = Price::from_decimal_dp(msg.data.best_ask_price(), price_precision)
356        .with_context(|| format!("invalid ask price for {instrument_name}"))?;
357    let bid_size = Quantity::from_decimal_dp(msg.data.best_bid_amount(), size_precision)
358        .with_context(|| format!("invalid bid amount for {instrument_name}"))?;
359    let ask_size = Quantity::from_decimal_dp(msg.data.best_ask_amount(), size_precision)
360        .with_context(|| format!("invalid ask amount for {instrument_name}"))?;
361    let timestamp =
362        u64::try_from(msg.data.timestamp()).context("negative Derive ticker timestamp")?;
363    let ts_event = timestamp_millis_to_nanos(timestamp, "timestamp")?;
364
365    QuoteTick::new_checked(
366        instrument_id,
367        bid_price,
368        ask_price,
369        bid_size,
370        ask_size,
371        ts_event,
372        ts_init,
373    )
374}
375
376/// Parses a REST `public/get_tickers` snapshot into a Nautilus top-of-book quote.
377///
378/// # Errors
379///
380/// Returns an error if price, size, or timestamp conversion fails.
381pub fn parse_ticker_quote_from_rest(
382    ticker: &DeriveTickerSnapshot,
383    price_precision: u8,
384    size_precision: u8,
385    ts_init: UnixNanos,
386) -> anyhow::Result<QuoteTick> {
387    let instrument_id = format_instrument_id(ticker.instrument_name)?;
388    let instrument_name = ticker.instrument_name.as_str();
389    let bid_price = Price::from_decimal_dp(ticker.best_bid_price, price_precision)
390        .with_context(|| format!("invalid bid price for {instrument_name}"))?;
391    let ask_price = Price::from_decimal_dp(ticker.best_ask_price, price_precision)
392        .with_context(|| format!("invalid ask price for {instrument_name}"))?;
393    let bid_size = Quantity::from_decimal_dp(ticker.best_bid_amount, size_precision)
394        .with_context(|| format!("invalid bid amount for {instrument_name}"))?;
395    let ask_size = Quantity::from_decimal_dp(ticker.best_ask_amount, size_precision)
396        .with_context(|| format!("invalid ask amount for {instrument_name}"))?;
397    let timestamp = u64::try_from(ticker.timestamp).context("negative Derive ticker timestamp")?;
398    let ts_event = timestamp_millis_to_nanos(timestamp, "timestamp")?;
399
400    QuoteTick::new_checked(
401        instrument_id,
402        bid_price,
403        ask_price,
404        bid_size,
405        ask_size,
406        ts_event,
407        ts_init,
408    )
409}
410
411#[derive(Debug, Clone, Copy)]
412struct BookDeltaContext {
413    instrument_id: InstrumentId,
414    sequence: u64,
415    price_precision: u8,
416    size_precision: u8,
417    ts_event: UnixNanos,
418    ts_init: UnixNanos,
419}
420
421fn push_level_delta(
422    deltas: &mut Vec<OrderBookDelta>,
423    context: &BookDeltaContext,
424    side: OrderSide,
425    level: &DeriveOrderbookLevel,
426    order_id: u64,
427) -> anyhow::Result<()> {
428    if level.amount().is_zero() {
429        return Ok(());
430    }
431
432    let price = Price::from_decimal_dp(level.price(), context.price_precision)
433        .context("invalid Derive orderbook price")?;
434    let size = Quantity::from_decimal_dp(level.amount(), context.size_precision)
435        .context("invalid Derive orderbook amount")?;
436    let order = BookOrder::new(side, price, size, order_id);
437    deltas.push(OrderBookDelta::new_checked(
438        context.instrument_id,
439        BookAction::Add,
440        order,
441        RecordFlag::F_SNAPSHOT as u8,
442        context.sequence,
443        context.ts_event,
444        context.ts_init,
445    )?);
446    Ok(())
447}
448
449fn fill_depth_side(
450    orders: &mut [BookOrder; DEPTH10_LEN],
451    counts: &mut [u32; DEPTH10_LEN],
452    levels: &[DeriveOrderbookLevel],
453    side: OrderSide,
454    price_precision: u8,
455    size_precision: u8,
456) -> anyhow::Result<()> {
457    let mut index = 0;
458
459    for level in levels {
460        let price = Price::from_decimal_dp(level.price(), price_precision)
461            .context("invalid Derive orderbook price")?;
462        let size = Quantity::from_decimal_dp(level.amount(), size_precision)
463            .context("invalid Derive orderbook amount")?;
464
465        if size.is_zero() {
466            continue;
467        }
468
469        orders[index] = BookOrder::new(side, price, size, 0);
470        counts[index] = 1;
471        index += 1;
472
473        if index == DEPTH10_LEN {
474            break;
475        }
476    }
477
478    for order in orders.iter_mut().skip(index) {
479        *order = BookOrder::new(
480            side,
481            Price::zero(price_precision),
482            Quantity::zero(size_precision),
483            0,
484        );
485    }
486
487    Ok(())
488}
489
490fn timestamp_millis_to_nanos(value: u64, field: &str) -> anyhow::Result<UnixNanos> {
491    let nanos = value
492        .checked_mul(NANOSECONDS_IN_MILLISECOND)
493        .with_context(|| format!("Derive {field} overflows nanoseconds"))?;
494    Ok(UnixNanos::from(nanos))
495}
496
497pub(crate) fn ticker_ts_event(timestamp_ms: i64) -> anyhow::Result<UnixNanos> {
498    let timestamp = u64::try_from(timestamp_ms).context("negative Derive ticker timestamp")?;
499    timestamp_millis_to_nanos(timestamp, "timestamp")
500}
501
502/// Parses a ticker payload into a [`MarkPriceUpdate`].
503///
504/// # Errors
505///
506/// Returns an error if the ticker timestamp is negative or overflows.
507pub fn parse_mark_price(
508    msg: &DeriveTickerMsg,
509    price_precision: u8,
510    ts_init: UnixNanos,
511) -> anyhow::Result<Option<MarkPriceUpdate>> {
512    let instrument_id = msg.data.instrument_id()?;
513    let value = Price::from_decimal_dp(msg.data.mark_price(), price_precision)
514        .with_context(|| format!("invalid Derive mark price for {instrument_id}"))?;
515    let ts_event = ticker_ts_event(msg.data.timestamp())?;
516    Ok(Some(MarkPriceUpdate::new(
517        instrument_id,
518        value,
519        ts_event,
520        ts_init,
521    )))
522}
523
524/// Parses a ticker payload into an [`IndexPriceUpdate`].
525///
526/// # Errors
527///
528/// Returns an error if the ticker timestamp is negative or overflows.
529pub fn parse_index_price(
530    msg: &DeriveTickerMsg,
531    price_precision: u8,
532    ts_init: UnixNanos,
533) -> anyhow::Result<Option<IndexPriceUpdate>> {
534    let instrument_id = msg.data.instrument_id()?;
535    let value = Price::from_decimal_dp(msg.data.index_price(), price_precision)
536        .with_context(|| format!("invalid Derive index price for {instrument_id}"))?;
537    let ts_event = ticker_ts_event(msg.data.timestamp())?;
538    Ok(Some(IndexPriceUpdate::new(
539        instrument_id,
540        value,
541        ts_event,
542        ts_init,
543    )))
544}
545
546/// Parses a perpetual ticker payload into a [`FundingRateUpdate`].
547///
548/// Returns `Ok(None)` when the ticker does not carry funding.
549///
550/// # Errors
551///
552/// Returns an error if the ticker timestamp is negative or overflows.
553pub fn parse_funding_rate(
554    msg: &DeriveTickerMsg,
555    ts_init: UnixNanos,
556) -> anyhow::Result<Option<FundingRateUpdate>> {
557    let Some(rate) = msg.data.funding_rate() else {
558        return Ok(None);
559    };
560
561    let instrument_id = msg.data.instrument_id()?;
562    let ts_event = ticker_ts_event(msg.data.timestamp())?;
563    Ok(Some(FundingRateUpdate::new(
564        instrument_id,
565        rate,
566        None,
567        None,
568        ts_event,
569        ts_init,
570    )))
571}
572
573/// Parses a `public/get_funding_rate_history` record into a [`FundingRateUpdate`].
574///
575/// # Errors
576///
577/// Returns an error if the record timestamp is negative or overflows.
578pub fn parse_funding_rate_history_record(
579    record: &DerivePublicFundingRate,
580    instrument_id: InstrumentId,
581    interval: Option<u16>,
582    ts_init: UnixNanos,
583) -> anyhow::Result<FundingRateUpdate> {
584    let ts_event = ticker_ts_event(record.timestamp)?;
585    Ok(FundingRateUpdate::new(
586        instrument_id,
587        record.funding_rate,
588        interval,
589        None,
590        ts_event,
591        ts_init,
592    ))
593}
594
595/// Parses a `public/get_tradingview_chart_data` record into a Nautilus [`Bar`].
596///
597/// Pass price and size precision from the instrument definition rather than
598/// inferring them from the wire values. The Derive `timestamp_bucket` is the
599/// bucket start in UNIX seconds; the returned bar's `ts_event` marks that
600/// bucket's close.
601///
602/// # Errors
603///
604/// Returns an error if price, size, or timestamp conversion fails.
605pub fn parse_candle_record(
606    record: &DerivePublicCandle,
607    bar_type: BarType,
608    price_precision: u8,
609    size_precision: u8,
610    ts_init: UnixNanos,
611) -> anyhow::Result<Bar> {
612    let open = Price::from_decimal_dp(record.open_price, price_precision)
613        .context("invalid Derive candle open price")?;
614    let high = Price::from_decimal_dp(record.high_price, price_precision)
615        .context("invalid Derive candle high price")?;
616    let low = Price::from_decimal_dp(record.low_price, price_precision)
617        .context("invalid Derive candle low price")?;
618    let close = Price::from_decimal_dp(record.close_price, price_precision)
619        .context("invalid Derive candle close price")?;
620    let volume = Quantity::from_decimal_dp(record.volume_contracts, size_precision)
621        .context("invalid Derive candle volume")?;
622    let timestamp =
623        u64::try_from(record.timestamp_bucket).context("negative Derive candle timestamp")?;
624    let bucket_start = timestamp_seconds_to_nanos(timestamp, "candle timestamp_bucket")?;
625    let interval_ns = DurationNanos::try_from(bar_type.spec().timedelta())
626        .context("bar interval overflowed the u64 range for nanoseconds")?;
627    let ts_event = bucket_start
628        .checked_add(interval_ns)
629        .context("bar timestamp overflowed when adjusting to close time")?;
630
631    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
632        .context("failed to construct Bar from Derive candle record")
633}
634
635/// Maps a Nautilus bar aggregation and step to the Derive `period` enum value
636/// (bucket size in seconds).
637///
638/// Derive supports the following bucket sizes: 60, 300, 900, 1800, 3600,
639/// 14400, 28800, 86400, 604800.
640///
641/// # Errors
642///
643/// Returns an error if the aggregation or step has no Derive equivalent.
644pub fn bar_spec_to_derive_period(aggregation: BarAggregation, step: u64) -> anyhow::Result<u32> {
645    match aggregation {
646        BarAggregation::Minute => match step {
647            1 => Ok(60),
648            5 => Ok(300),
649            15 => Ok(900),
650            30 => Ok(1800),
651            _ => anyhow::bail!(
652                "Derive only supports minute intervals 1, 5, 15, 30 (use HOUR for >= 60)"
653            ),
654        },
655        BarAggregation::Hour => match step {
656            1 => Ok(3600),
657            4 => Ok(14400),
658            8 => Ok(28800),
659            _ => anyhow::bail!("Derive only supports hour intervals 1, 4, 8"),
660        },
661        BarAggregation::Day => {
662            if step != 1 {
663                anyhow::bail!("Derive only supports 1 DAY interval bars");
664            }
665
666            Ok(86400)
667        }
668        BarAggregation::Week => {
669            if step != 1 {
670                anyhow::bail!("Derive only supports 1 WEEK interval bars");
671            }
672
673            Ok(604800)
674        }
675        _ => anyhow::bail!("Derive does not support {aggregation:?} bars"),
676    }
677}
678
679fn timestamp_seconds_to_nanos(value: u64, field: &str) -> anyhow::Result<UnixNanos> {
680    let nanos = value
681        .checked_mul(NANOSECONDS_IN_SECOND)
682        .with_context(|| format!("Derive {field} overflows nanoseconds"))?;
683    Ok(UnixNanos::from(nanos))
684}
685
686/// Parses an option ticker payload into [`OptionGreeks`].
687///
688/// Returns `Ok(None)` when the ticker does not carry option pricing.
689///
690/// # Errors
691///
692/// Returns an error if the ticker timestamp is negative or overflows.
693pub fn parse_option_greeks(
694    msg: &DeriveTickerMsg,
695    ts_init: UnixNanos,
696) -> anyhow::Result<Option<OptionGreeks>> {
697    let Some(pricing) = msg.data.option_pricing() else {
698        return Ok(None);
699    };
700
701    let instrument_id = msg.data.instrument_id()?;
702    let ts_event = ticker_ts_event(msg.data.timestamp())?;
703
704    let to_f64 = |label: &str, value: rust_decimal::Decimal| {
705        value
706            .to_f64()
707            .ok_or_else(|| anyhow::anyhow!("Derive {label} cannot be represented as f64"))
708    };
709
710    Ok(Some(OptionGreeks {
711        instrument_id,
712        convention: GreeksConvention::BlackScholes,
713        greeks: OptionGreekValues {
714            delta: to_f64("delta", pricing.delta)?,
715            gamma: to_f64("gamma", pricing.gamma)?,
716            vega: to_f64("vega", pricing.vega)?,
717            theta: to_f64("theta", pricing.theta)?,
718            rho: to_f64("rho", pricing.rho)?,
719        },
720        mark_iv: Some(to_f64("iv", pricing.iv)?),
721        bid_iv: Some(to_f64("bid_iv", pricing.bid_iv)?),
722        ask_iv: Some(to_f64("ask_iv", pricing.ask_iv)?),
723        underlying_price: Some(to_f64("forward_price", pricing.forward_price)?),
724        open_interest: msg
725            .data
726            .stats()
727            .map(|s| to_f64("open_interest", s.open_interest))
728            .transpose()?,
729        ts_event,
730        ts_init,
731    }))
732}
733
734#[cfg(test)]
735mod tests {
736    use std::{path::PathBuf, str::FromStr};
737
738    use nautilus_model::{
739        enums::{AggressorSide, BookAction, OrderSide, RecordFlag},
740        identifiers::{InstrumentId, TradeId},
741        types::{Price, Quantity},
742    };
743    use rstest::rstest;
744    use rust_decimal::Decimal;
745    use serde_json::{Value, json};
746    use ustr::Ustr;
747
748    use super::*;
749    use crate::websocket::messages::DeriveWsFrame;
750
751    const PRICE_PRECISION: u8 = 2;
752    const SIZE_PRECISION: u8 = 3;
753    const INVALID_PRECISION: u8 = u8::MAX;
754
755    fn data_path() -> PathBuf {
756        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data")
757    }
758
759    fn load_json(filename: &str) -> Value {
760        let content = std::fs::read_to_string(data_path().join(filename))
761            .unwrap_or_else(|_| panic!("failed to read {filename}"));
762        serde_json::from_str(&content).expect("invalid json")
763    }
764
765    fn subscription_payload(frame: &Value) -> WsSubscriptionPayload {
766        match DeriveWsFrame::parse(&frame.to_string()).unwrap() {
767            DeriveWsFrame::Subscription(payload) => payload,
768            other => panic!("expected subscription frame, was {other:?}"),
769        }
770    }
771
772    fn subscription_data_payload(channel: &str, data: &Value) -> WsSubscriptionPayload {
773        subscription_payload(&json!({
774            "jsonrpc": "2.0",
775            "method": "subscription",
776            "params": {
777                "channel": channel,
778                "data": data
779            }
780        }))
781    }
782
783    fn orderbook_json(timestamp: i64, bids: &Value, asks: &Value) -> Value {
784        let mut value = load_json("perps/ws_orderbook_eth.json");
785        value["timestamp"] = json!(timestamp);
786        value["bids"] = bids.clone();
787        value["asks"] = asks.clone();
788        value
789    }
790
791    fn trade_json(timestamp: i64, direction: &str) -> Value {
792        trade_json_with_values(timestamp, direction, "3500.2", "0.25")
793    }
794
795    fn trade_json_with_values(
796        timestamp: i64,
797        direction: &str,
798        trade_price: &str,
799        trade_amount: &str,
800    ) -> Value {
801        let mut value = load_json("perps/ws_trade_eth.json");
802        value["direction"] = json!(direction);
803        value["timestamp"] = json!(timestamp);
804        value["trade_amount"] = json!(trade_amount);
805        value["trade_id"] = json!("trade-1");
806        value["trade_price"] = json!(trade_price);
807        value
808    }
809
810    fn fixture_trade(filename: &str) -> DerivePublicTrade {
811        serde_json::from_value(load_json(filename)).expect("invalid Derive public trade")
812    }
813
814    fn ticker_json_with_timestamp(timestamp: i64) -> Value {
815        let mut value = load_json("perps/ws_ticker_eth.json");
816        value["best_ask_amount"] = json!("1.20");
817        value["best_ask_price"] = json!("3501.00");
818        value["best_bid_amount"] = json!("0.80");
819        value["best_bid_price"] = json!("3499.50");
820        value["timestamp"] = json!(timestamp);
821        value
822    }
823
824    fn ticker_json() -> Value {
825        ticker_json_with_timestamp(1_700_000_000_000)
826    }
827
828    fn price(value: &str) -> Price {
829        Price::from_decimal_dp(Decimal::from_str(value).unwrap(), PRICE_PRECISION).unwrap()
830    }
831
832    fn quantity(value: &str) -> Quantity {
833        Quantity::from_decimal_dp(Decimal::from_str(value).unwrap(), SIZE_PRECISION).unwrap()
834    }
835
836    #[rstest]
837    fn test_parse_public_orderbook_frame() {
838        let payload = subscription_data_payload(
839            "orderbook.ETH-PERP.1.10",
840            &orderbook_json(
841                1_700_000_000_000,
842                &json!([["3499.50", "1.20"], ["3499.00", "0.40"]]),
843                &json!([["3501.00", "0.80"]]),
844            ),
845        );
846
847        let msg = parse_orderbook_msg(&payload).unwrap();
848        let deltas =
849            parse_orderbook_deltas(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
850                .unwrap();
851
852        assert_eq!(msg.channel, "orderbook.ETH-PERP.1.10");
853        assert_eq!(
854            msg.data.instrument_id().unwrap(),
855            InstrumentId::from("ETH-PERP.DERIVE")
856        );
857        assert_eq!(msg.data.bids[0].price().to_string(), "3499.50");
858        assert_eq!(deltas.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
859        assert_eq!(deltas.deltas.len(), 4);
860        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
861        assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy.into());
862        assert_eq!(deltas.deltas[1].order.price, price("3499.50"));
863        assert_eq!(deltas.deltas[1].order.size, quantity("1.20"));
864        assert_eq!(deltas.deltas[3].order.side, OrderSide::Sell.into());
865        assert_eq!(
866            deltas.deltas[3].flags,
867            RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
868        );
869    }
870
871    #[rstest]
872    #[case::null_price(0, Value::Null)]
873    #[case::empty_price(0, json!(""))]
874    #[case::null_amount(1, Value::Null)]
875    #[case::empty_amount(1, json!(""))]
876    fn test_parse_orderbook_rejects_missing_numeric_values(
877        #[case] field: usize,
878        #[case] value: Value,
879        #[values("bids", "asks")] side: &str,
880        #[values(false, true)] depth: bool,
881    ) {
882        let mut data = load_json("perps/ws_orderbook_eth.json");
883        data[side][0][field] = value;
884        let payload = subscription_data_payload("orderbook.ETH-PERP.1.10", &data);
885
886        let result = parse_orderbook_msg(&payload).and_then(|msg| {
887            if depth {
888                parse_orderbook_depth(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
889                    .map(|_| ())
890            } else {
891                parse_orderbook_deltas(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
892                    .map(|_| ())
893            }
894        });
895
896        assert_eq!(
897            result.unwrap_err().to_string(),
898            "failed to decode Derive orderbook data"
899        );
900    }
901
902    #[rstest]
903    fn test_parse_public_trades_frame() {
904        let payload = subscription_data_payload(
905            "trades.perp.ETH",
906            &json!([trade_json(1_700_000_000_001, "buy")]),
907        );
908
909        let msg = parse_trades_msg(&payload).unwrap();
910        let tick = parse_trade_tick(
911            &msg.trades[0],
912            PRICE_PRECISION,
913            SIZE_PRECISION,
914            UnixNanos::from(456),
915        )
916        .unwrap();
917
918        assert_eq!(msg.channel, "trades.perp.ETH");
919        assert_eq!(msg.trades.len(), 1);
920        assert_eq!(
921            format_instrument_id(msg.trades[0].instrument_name).unwrap(),
922            InstrumentId::from("ETH-PERP.DERIVE")
923        );
924        assert_eq!(tick.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
925        assert_eq!(tick.price, price("3500.2"));
926        assert_eq!(tick.size, quantity("0.25"));
927        assert_eq!(tick.aggressor_side, AggressorSide::Buy);
928        assert_eq!(tick.trade_id, TradeId::from("trade-1"));
929        assert_eq!(tick.ts_event, UnixNanos::from(1_700_000_000_001_000_000));
930    }
931
932    #[rstest]
933    #[case("")]
934    #[case(" ")]
935    #[case("external-\u{03bb}")]
936    #[case("1234567890123456789012345678901234567")]
937    fn test_parse_public_trade_rejects_invalid_trade_id(#[case] trade_id: &str) {
938        let mut trade: DerivePublicTrade =
939            serde_json::from_value(load_json("perps/http_public_trade_eth_maker.json")).unwrap();
940        trade.trade_id = trade_id.to_string();
941        let result = parse_trade_tick_from_rest(
942            &trade,
943            PRICE_PRECISION,
944            SIZE_PRECISION,
945            UnixNanos::default(),
946        );
947
948        assert_eq!(result.unwrap_err().to_string(), "invalid Derive trade_id");
949    }
950
951    #[rstest]
952    fn test_parse_public_trade_preserves_maximum_trade_id() {
953        let trade_id = "123456789012345678901234567890123456";
954        let mut trade: DerivePublicTrade =
955            serde_json::from_value(load_json("perps/http_public_trade_eth_maker.json")).unwrap();
956        trade.trade_id = trade_id.to_string();
957        let tick = parse_trade_tick_from_rest(
958            &trade,
959            PRICE_PRECISION,
960            SIZE_PRECISION,
961            UnixNanos::from(123),
962        )
963        .unwrap();
964
965        assert_eq!(tick.trade_id.as_str(), trade_id);
966        assert_eq!(tick.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
967        assert_eq!(
968            tick.price.as_decimal(),
969            Decimal::from_str_exact("3499").unwrap()
970        );
971        assert_eq!(
972            tick.size.as_decimal(),
973            Decimal::from_str_exact("0.5").unwrap()
974        );
975        assert_eq!(tick.aggressor_side, AggressorSide::Buy);
976        assert_eq!(tick.ts_event, UnixNanos::from(1_700_000_000_000_000_000));
977        assert_eq!(tick.ts_init, UnixNanos::from(123));
978    }
979
980    #[rstest]
981    fn test_parse_public_ticker_frame() {
982        let payload = subscription_data_payload(
983            "ticker_slim.ETH-PERP.1000",
984            &load_json("perps/ws_ticker_slim_eth.json"),
985        );
986
987        let msg = parse_ticker_msg(&payload).unwrap();
988        let quote = parse_ticker_quote(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(789))
989            .unwrap();
990
991        assert_eq!(msg.channel, "ticker_slim.ETH-PERP.1000");
992        assert_eq!(
993            msg.data.instrument_id().unwrap(),
994            InstrumentId::from("ETH-PERP.DERIVE")
995        );
996        assert_eq!(msg.data.timestamp(), 1_779_953_796_714);
997        assert_eq!(quote.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
998        assert_eq!(quote.bid_price, price("1992.36"));
999        assert_eq!(quote.ask_price, price("1992.37"));
1000        assert_eq!(quote.bid_size, quantity("1.505"));
1001        assert_eq!(quote.ask_size, quantity("1.505"));
1002        assert_eq!(quote.ts_event, UnixNanos::from(1_779_953_796_714_000_000));
1003    }
1004
1005    #[rstest]
1006    fn test_parse_spot_orderbook_frame() {
1007        let mut data = load_json("spot/ws_orderbook_eth.json");
1008        data["bids"] = json!([["2050.0", "1.20"], ["2049.5", "0.40"]]);
1009        data["asks"] = json!([["2051.0", "0.80"]]);
1010        let payload = subscription_data_payload("orderbook.ETH-USDC.1.10", &data);
1011
1012        let msg = parse_orderbook_msg(&payload).unwrap();
1013        let deltas =
1014            parse_orderbook_deltas(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
1015                .unwrap();
1016
1017        assert_eq!(msg.channel, "orderbook.ETH-USDC.1.10");
1018        assert_eq!(
1019            msg.data.instrument_id().unwrap(),
1020            InstrumentId::from("ETH-USDC.DERIVE")
1021        );
1022        assert_eq!(deltas.instrument_id, InstrumentId::from("ETH-USDC.DERIVE"));
1023        assert_eq!(deltas.deltas.len(), 4);
1024        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1025        assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy.into());
1026        assert_eq!(deltas.deltas[1].order.price, price("2050.0"));
1027        assert_eq!(deltas.deltas[1].order.size, quantity("1.20"));
1028        assert_eq!(deltas.deltas[3].order.side, OrderSide::Sell.into());
1029        assert_eq!(
1030            deltas.deltas[3].flags,
1031            RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1032        );
1033    }
1034
1035    #[rstest]
1036    fn test_parse_spot_trades_frame() {
1037        let payload = subscription_data_payload(
1038            "trades.erc20.ETH",
1039            &json!([load_json("spot/ws_trade_eth.json")]),
1040        );
1041
1042        let msg = parse_trades_msg(&payload).unwrap();
1043        let tick = parse_trade_tick(
1044            &msg.trades[0],
1045            PRICE_PRECISION,
1046            SIZE_PRECISION,
1047            UnixNanos::from(456),
1048        )
1049        .unwrap();
1050
1051        assert_eq!(msg.channel, "trades.erc20.ETH");
1052        assert_eq!(msg.trades.len(), 1);
1053        assert_eq!(tick.instrument_id, InstrumentId::from("ETH-USDC.DERIVE"));
1054        assert_eq!(tick.price, price("2050"));
1055        assert_eq!(tick.size, quantity("0.1"));
1056        assert_eq!(tick.aggressor_side, AggressorSide::Buy);
1057        assert_eq!(
1058            tick.trade_id,
1059            TradeId::from("0445f96a-10fb-4fdc-a0f9-eed94a2f32e1")
1060        );
1061    }
1062
1063    #[rstest]
1064    fn test_parse_spot_ticker_slim_frame_handles_null_funding() {
1065        let payload = subscription_data_payload(
1066            "ticker_slim.ETH-USDC.1000",
1067            &load_json("spot/ws_ticker_slim_eth.json"),
1068        );
1069
1070        let msg = parse_ticker_msg(&payload).unwrap();
1071        let quote = parse_ticker_quote(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(789))
1072            .unwrap();
1073
1074        assert_eq!(msg.channel, "ticker_slim.ETH-USDC.1000");
1075        assert_eq!(
1076            msg.data.instrument_id().unwrap(),
1077            InstrumentId::from("ETH-USDC.DERIVE")
1078        );
1079        assert_eq!(quote.instrument_id, InstrumentId::from("ETH-USDC.DERIVE"));
1080
1081        assert!(
1082            parse_funding_rate(&msg, UnixNanos::from(789))
1083                .unwrap()
1084                .is_none()
1085        );
1086        let mark = parse_mark_price(&msg, PRICE_PRECISION, UnixNanos::from(789))
1087            .unwrap()
1088            .expect("spot slim ticker carries mark price");
1089        let index = parse_index_price(&msg, PRICE_PRECISION, UnixNanos::from(789))
1090            .unwrap()
1091            .expect("spot slim ticker carries index price");
1092        assert_eq!(mark.instrument_id, InstrumentId::from("ETH-USDC.DERIVE"));
1093        assert_eq!(index.instrument_id, InstrumentId::from("ETH-USDC.DERIVE"));
1094    }
1095
1096    #[rstest]
1097    fn test_parse_public_ticker_direct_payload() {
1098        let payload = subscription_data_payload(
1099            "ticker.ETH-PERP.1000",
1100            &ticker_json_with_timestamp(1_700_000_000_011),
1101        );
1102
1103        let msg = parse_ticker_msg(&payload).unwrap();
1104        let quote = parse_ticker_quote(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(790))
1105            .unwrap();
1106
1107        assert_eq!(msg.channel, "ticker.ETH-PERP.1000");
1108        assert_eq!(msg.data.timestamp(), 1_700_000_000_011);
1109        assert_eq!(
1110            msg.data.instrument_id().unwrap(),
1111            InstrumentId::from("ETH-PERP.DERIVE")
1112        );
1113        assert_eq!(quote.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
1114        assert_eq!(quote.ts_event, UnixNanos::from(1_700_000_000_011_000_000));
1115    }
1116
1117    #[rstest]
1118    fn test_parse_ticker_quote_uses_supplied_precision_when_wire_scale_varies() {
1119        let mut ticker = ticker_json_with_timestamp(1_700_000_000_012);
1120        ticker["best_bid_price"] = json!("3500");
1121        ticker["best_ask_price"] = json!("3501");
1122        ticker["best_bid_amount"] = json!("1");
1123        ticker["best_ask_amount"] = json!("2");
1124        let payload = subscription_data_payload("ticker.ETH-PERP.1000", &ticker);
1125
1126        let msg = parse_ticker_msg(&payload).unwrap();
1127        let quote = parse_ticker_quote(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(790))
1128            .unwrap();
1129
1130        assert_eq!(quote.bid_price, price("3500"));
1131        assert_eq!(quote.ask_price, price("3501"));
1132        assert_eq!(quote.bid_size, quantity("1"));
1133        assert_eq!(quote.ask_size, quantity("2"));
1134        assert_eq!(quote.bid_price.precision, PRICE_PRECISION);
1135        assert_eq!(quote.bid_size.precision, SIZE_PRECISION);
1136    }
1137
1138    #[rstest]
1139    fn test_parse_ticker_quote_from_rest_emits_quote() {
1140        let ticker: DeriveTickerSnapshot =
1141            serde_json::from_value(ticker_json_with_timestamp(1_700_000_000_013)).unwrap();
1142
1143        let quote = parse_ticker_quote_from_rest(
1144            &ticker,
1145            PRICE_PRECISION,
1146            SIZE_PRECISION,
1147            UnixNanos::from(791),
1148        )
1149        .unwrap();
1150
1151        assert_eq!(quote.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
1152        assert_eq!(quote.bid_price, price("3499.50"));
1153        assert_eq!(quote.ask_price, price("3501.00"));
1154        assert_eq!(quote.bid_size, quantity("0.80"));
1155        assert_eq!(quote.ask_size, quantity("1.20"));
1156        assert_eq!(quote.ts_event, UnixNanos::from(1_700_000_000_013_000_000));
1157    }
1158
1159    #[rstest]
1160    fn test_parse_ticker_quote_from_rest_rejects_negative_timestamp() {
1161        let mut value = ticker_json_with_timestamp(1_700_000_000_013);
1162        value["timestamp"] = json!(-1_i64);
1163        let ticker: DeriveTickerSnapshot = serde_json::from_value(value).unwrap();
1164
1165        let err = parse_ticker_quote_from_rest(
1166            &ticker,
1167            PRICE_PRECISION,
1168            SIZE_PRECISION,
1169            UnixNanos::from(791),
1170        )
1171        .expect_err("must reject negative timestamp");
1172        assert!(err.to_string().contains("negative Derive ticker timestamp"));
1173    }
1174
1175    #[rstest]
1176    fn test_parse_orderbook_deltas_empty_book_marks_clear_last() {
1177        let payload = subscription_data_payload(
1178            "orderbook.ETH-PERP.1.10",
1179            &orderbook_json(1_700_000_000_000, &json!([]), &json!([])),
1180        );
1181
1182        let msg = parse_orderbook_msg(&payload).unwrap();
1183        let deltas =
1184            parse_orderbook_deltas(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
1185                .unwrap();
1186
1187        assert_eq!(deltas.deltas.len(), 1);
1188        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1189        assert_eq!(
1190            deltas.deltas[0].flags,
1191            RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1192        );
1193    }
1194
1195    #[rstest]
1196    fn test_parse_orderbook_deltas_skips_zero_size_levels() {
1197        let payload = subscription_data_payload(
1198            "orderbook.ETH-PERP.1.10",
1199            &orderbook_json(
1200                1_700_000_000_000,
1201                &json!([["3499.50", "0"], ["3499.00", "0.40"]]),
1202                &json!([["3501.00", "0"]]),
1203            ),
1204        );
1205
1206        let msg = parse_orderbook_msg(&payload).unwrap();
1207        let deltas =
1208            parse_orderbook_deltas(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
1209                .unwrap();
1210
1211        assert_eq!(deltas.deltas.len(), 2);
1212        assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy.into());
1213        assert_eq!(deltas.deltas[1].order.price, price("3499.00"));
1214        assert_eq!(deltas.deltas[1].order.size, quantity("0.40"));
1215        assert_eq!(deltas.deltas[1].order.order_id, 1);
1216        assert_eq!(
1217            deltas.deltas[1].flags,
1218            RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1219        );
1220    }
1221
1222    #[rstest]
1223    fn test_parse_orderbook_deltas_uses_supplied_precision_when_wire_scale_varies() {
1224        let payload = subscription_data_payload(
1225            "orderbook.ETH-PERP.1.10",
1226            &orderbook_json(
1227                1_700_000_000_000,
1228                &json!([["3500", "1"]]),
1229                &json!([["3501", "2"]]),
1230            ),
1231        );
1232
1233        let msg = parse_orderbook_msg(&payload).unwrap();
1234        let deltas =
1235            parse_orderbook_deltas(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
1236                .unwrap();
1237
1238        assert_eq!(deltas.deltas[1].order.price, price("3500"));
1239        assert_eq!(deltas.deltas[1].order.size, quantity("1"));
1240        assert_eq!(deltas.deltas[2].order.price, price("3501"));
1241        assert_eq!(deltas.deltas[2].order.size, quantity("2"));
1242        assert_eq!(deltas.deltas[1].order.price.precision, PRICE_PRECISION);
1243        assert_eq!(deltas.deltas[1].order.size.precision, SIZE_PRECISION);
1244    }
1245
1246    #[rstest]
1247    fn test_parse_orderbook_depth_skips_zero_sizes_and_caps_levels() {
1248        let bids = Value::Array(
1249            (0..12)
1250                .map(|i| {
1251                    let size = if i == 1 { "0" } else { "1" };
1252                    json!([format!("{}", 3500 - i), size])
1253                })
1254                .collect(),
1255        );
1256
1257        let asks = json!([["3501", "2"], ["3502", "0"], ["3503", "3"]]);
1258        let payload = subscription_data_payload(
1259            "orderbook.ETH-PERP.1.10",
1260            &orderbook_json(1_700_000_000_000, &bids, &asks),
1261        );
1262
1263        let msg = parse_orderbook_msg(&payload).unwrap();
1264        let depth =
1265            parse_orderbook_depth(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
1266                .unwrap();
1267
1268        let expected_bids = [
1269            "3500", "3498", "3497", "3496", "3495", "3494", "3493", "3492", "3491", "3490",
1270        ];
1271        let expected_asks = [("3501", "2"), ("3503", "3")];
1272
1273        assert_eq!(depth.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
1274        assert_eq!(depth.bids.len(), expected_bids.len());
1275        assert_eq!(depth.asks.len(), expected_asks.len());
1276        assert_eq!(depth.bid_counts.as_slice(), &[1; 10]);
1277        assert_eq!(depth.ask_counts.as_slice(), &[1; 2]);
1278
1279        for (order, expected_price) in depth.bids.iter().zip(expected_bids) {
1280            assert_eq!(order.side, Some(OrderSide::Buy));
1281            assert_eq!(order.price, price(expected_price));
1282            assert_eq!(order.size, quantity("1"));
1283            assert_eq!(order.order_id, 0);
1284        }
1285
1286        for (order, (expected_price, size)) in depth.asks.iter().zip(expected_asks) {
1287            assert_eq!(order.side, Some(OrderSide::Sell));
1288            assert_eq!(order.price, price(expected_price));
1289            assert_eq!(order.size, quantity(size));
1290            assert_eq!(order.order_id, 0);
1291        }
1292
1293        assert_eq!(depth.sequence, 1_700_000_000_000);
1294        assert_eq!(depth.flags, RecordFlag::F_SNAPSHOT as u8);
1295        assert_eq!(depth.ts_event, UnixNanos::from(1_700_000_000_000_000_000));
1296        assert_eq!(depth.ts_init, UnixNanos::from(123));
1297    }
1298
1299    #[rstest]
1300    fn test_parse_trade_tick_maps_sell_direction() {
1301        let payload = subscription_data_payload(
1302            "trades.perp.ETH",
1303            &json!([trade_json(1_700_000_000_001, "sell")]),
1304        );
1305
1306        let msg = parse_trades_msg(&payload).unwrap();
1307        let tick = parse_trade_tick(
1308            &msg.trades[0],
1309            PRICE_PRECISION,
1310            SIZE_PRECISION,
1311            UnixNanos::from(456),
1312        )
1313        .unwrap();
1314
1315        assert_eq!(tick.aggressor_side, AggressorSide::Sell);
1316    }
1317
1318    #[rstest]
1319    fn test_parse_trade_tick_uses_supplied_precision_when_wire_scale_varies() {
1320        let payload = subscription_data_payload(
1321            "trades.perp.ETH",
1322            &json!([trade_json_with_values(
1323                1_700_000_000_001,
1324                "buy",
1325                "3500",
1326                "1"
1327            )]),
1328        );
1329
1330        let msg = parse_trades_msg(&payload).unwrap();
1331        let tick = parse_trade_tick(
1332            &msg.trades[0],
1333            PRICE_PRECISION,
1334            SIZE_PRECISION,
1335            UnixNanos::from(456),
1336        )
1337        .unwrap();
1338
1339        assert_eq!(tick.price, price("3500"));
1340        assert_eq!(tick.size, quantity("1"));
1341        assert_eq!(tick.price.precision, PRICE_PRECISION);
1342        assert_eq!(tick.size.precision, SIZE_PRECISION);
1343    }
1344
1345    #[rstest]
1346    #[case("buy", AggressorSide::Sell)]
1347    #[case("sell", AggressorSide::Buy)]
1348    fn test_parse_trade_tick_from_rest_inverts_maker_row(
1349        #[case] direction: &str,
1350        #[case] expected: AggressorSide,
1351    ) {
1352        let mut value = load_json("perps/http_public_trade_eth_maker.json");
1353        value["direction"] = json!(direction);
1354        let trade: DerivePublicTrade =
1355            serde_json::from_value(value).expect("invalid Derive public trade");
1356
1357        let tick = parse_trade_tick_from_rest(
1358            &trade,
1359            PRICE_PRECISION,
1360            SIZE_PRECISION,
1361            UnixNanos::from(456),
1362        )
1363        .unwrap();
1364
1365        assert_eq!(tick.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
1366        assert_eq!(tick.price, price("3499.0"));
1367        assert_eq!(tick.size, quantity("0.5"));
1368        assert_eq!(tick.aggressor_side, expected);
1369        assert_eq!(tick.trade_id, TradeId::from("trade-1"));
1370    }
1371
1372    #[rstest]
1373    fn test_parse_trade_tick_from_rest_maps_taker_row_directly() {
1374        let trade = fixture_trade("perps/http_public_trade_eth_sell.json");
1375
1376        let tick = parse_trade_tick_from_rest(
1377            &trade,
1378            PRICE_PRECISION,
1379            SIZE_PRECISION,
1380            UnixNanos::from(456),
1381        )
1382        .unwrap();
1383
1384        assert_eq!(tick.aggressor_side, AggressorSide::Sell);
1385        assert_eq!(tick.trade_id, TradeId::from("trade-1"));
1386    }
1387
1388    #[rstest]
1389    fn test_parse_trade_tick_from_rest_degrades_absent_role_to_taker_side() {
1390        let trade = fixture_trade("perps/ws_trade_eth_absent_role.json");
1391
1392        let tick = parse_trade_tick_from_rest(
1393            &trade,
1394            PRICE_PRECISION,
1395            SIZE_PRECISION,
1396            UnixNanos::from(456),
1397        )
1398        .unwrap();
1399
1400        assert_eq!(tick.aggressor_side, AggressorSide::Sell);
1401        assert_eq!(tick.trade_id, TradeId::from("perp-trade-1"));
1402    }
1403
1404    #[rstest]
1405    #[case::buy("buy")]
1406    #[case::sell("sell")]
1407    fn test_parse_trade_tick_from_rest_preserves_unknown_aggressor(#[case] direction: &str) {
1408        let mut value = load_json("perps/http_public_trade_eth_unknown_role.json");
1409        value["direction"] = json!(direction);
1410        let trade: DerivePublicTrade = serde_json::from_value(value).unwrap();
1411
1412        let tick = parse_trade_tick_from_rest(
1413            &trade,
1414            PRICE_PRECISION,
1415            SIZE_PRECISION,
1416            UnixNanos::from(456),
1417        )
1418        .unwrap();
1419
1420        let expected = TradeTick::new(
1421            InstrumentId::from("ETH-PERP.DERIVE"),
1422            price("3499.0"),
1423            quantity("0.5"),
1424            AggressorSide::NoAggressor,
1425            TradeId::from("trade-1"),
1426            UnixNanos::from(1_700_000_000_000_000_000),
1427            UnixNanos::from(456),
1428        );
1429        assert_eq!(tick, expected);
1430    }
1431
1432    #[rstest]
1433    fn test_parse_trade_tick_maps_absent_role_directly() {
1434        let trade = fixture_trade("perps/ws_trade_eth_absent_role.json");
1435
1436        let tick = parse_trade_tick(
1437            &trade,
1438            PRICE_PRECISION,
1439            SIZE_PRECISION,
1440            UnixNanos::from(456),
1441        )
1442        .unwrap();
1443
1444        assert_eq!(tick.aggressor_side, AggressorSide::Sell);
1445        assert_eq!(tick.trade_id, TradeId::from("perp-trade-1"));
1446    }
1447
1448    #[rstest]
1449    fn test_parse_public_ws_data_dispatches_orderbook_channel() {
1450        let payload = subscription_data_payload(
1451            "orderbook.ETH-PERP.1.10",
1452            &orderbook_json(1_700_000_000_000, &json!([]), &json!([])),
1453        );
1454
1455        let parsed = parse_public_ws_data(&payload).unwrap();
1456
1457        match parsed {
1458            DerivePublicWsData::Orderbook(msg) => {
1459                assert_eq!(msg.channel, "orderbook.ETH-PERP.1.10");
1460                assert_eq!(
1461                    msg.data.instrument_id().unwrap(),
1462                    InstrumentId::from("ETH-PERP.DERIVE")
1463                );
1464            }
1465            other => panic!("expected orderbook data, was {other:?}"),
1466        }
1467    }
1468
1469    #[rstest]
1470    fn test_parse_public_ws_data_dispatches_trades_channel() {
1471        let payload = subscription_data_payload("trades.perp.ETH", &json!([]));
1472
1473        let parsed = parse_public_ws_data(&payload).unwrap();
1474
1475        match parsed {
1476            DerivePublicWsData::Trades(msg) => assert!(msg.trades.is_empty()),
1477            other => panic!("expected trades data, was {other:?}"),
1478        }
1479    }
1480
1481    #[rstest]
1482    fn test_parse_public_ws_data_dispatches_ticker_channel() {
1483        let payload = subscription_data_payload(
1484            "ticker_slim.ETH-PERP.1000",
1485            &load_json("perps/ws_ticker_slim_eth.json"),
1486        );
1487
1488        let parsed = parse_public_ws_data(&payload).unwrap();
1489
1490        match parsed {
1491            DerivePublicWsData::Ticker(msg) => {
1492                assert_eq!(msg.channel, "ticker_slim.ETH-PERP.1000");
1493                assert_eq!(
1494                    msg.data.instrument_id().unwrap(),
1495                    InstrumentId::from("ETH-PERP.DERIVE")
1496                );
1497            }
1498            other => panic!("expected ticker data, was {other:?}"),
1499        }
1500    }
1501
1502    #[rstest]
1503    fn test_parse_orderbook_msg_rejects_malformed_payload() {
1504        let payload = subscription_data_payload(
1505            "orderbook.ETH-PERP.1.10",
1506            &json!({
1507                "instrument_name": "ETH-PERP",
1508                "timestamp": 1_700_000_000_000_i64,
1509                "bids": []
1510            }),
1511        );
1512
1513        let err = parse_orderbook_msg(&payload).expect_err("must reject malformed orderbook");
1514
1515        assert!(
1516            err.to_string()
1517                .contains("failed to decode Derive orderbook data")
1518        );
1519    }
1520
1521    #[rstest]
1522    fn test_parse_trades_msg_rejects_malformed_payload() {
1523        let payload = subscription_data_payload("trades.perp.ETH", &json!({}));
1524
1525        let err = parse_trades_msg(&payload).expect_err("must reject malformed trades");
1526
1527        assert!(
1528            err.to_string()
1529                .contains("failed to decode Derive trades data")
1530        );
1531    }
1532
1533    #[rstest]
1534    fn test_parse_ticker_msg_rejects_malformed_payload() {
1535        let payload = subscription_data_payload(
1536            "ticker.ETH-PERP.1000",
1537            &json!({
1538                "timestamp": 1_700_000_000_010_i64
1539            }),
1540        );
1541
1542        let err = parse_ticker_msg(&payload).expect_err("must reject malformed ticker");
1543
1544        assert!(
1545            err.to_string()
1546                .contains("failed to decode Derive ticker data")
1547        );
1548    }
1549
1550    #[rstest]
1551    #[case("ticker_slim.ETH-PERP")]
1552    #[case("ticker_slim..1000")]
1553    fn test_parse_ticker_msg_rejects_malformed_slim_channel(#[case] channel: &str) {
1554        let payload =
1555            subscription_data_payload(channel, &load_json("perps/ws_ticker_slim_eth.json"));
1556
1557        let err = parse_ticker_msg(&payload).expect_err("must reject malformed slim channel");
1558
1559        assert!(err.to_string().contains("invalid Derive ticker channel"));
1560    }
1561
1562    #[rstest]
1563    fn test_parse_orderbook_deltas_rejects_negative_timestamp() {
1564        let payload = subscription_data_payload(
1565            "orderbook.ETH-PERP.1.10",
1566            &orderbook_json(-1, &json!([]), &json!([])),
1567        );
1568
1569        let msg = parse_orderbook_msg(&payload).unwrap();
1570        let err =
1571            parse_orderbook_deltas(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
1572                .expect_err("must reject negative orderbook timestamp");
1573
1574        assert!(
1575            err.to_string()
1576                .contains("negative Derive orderbook timestamp")
1577        );
1578    }
1579
1580    #[rstest]
1581    fn test_parse_orderbook_deltas_rejects_timestamp_overflow() {
1582        let payload = subscription_data_payload(
1583            "orderbook.ETH-PERP.1.10",
1584            &orderbook_json(i64::MAX, &json!([]), &json!([])),
1585        );
1586
1587        let msg = parse_orderbook_msg(&payload).unwrap();
1588        let err =
1589            parse_orderbook_deltas(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
1590                .expect_err("must reject overflowing orderbook timestamp");
1591
1592        assert!(
1593            err.to_string()
1594                .contains("Derive timestamp overflows nanoseconds")
1595        );
1596    }
1597
1598    #[rstest]
1599    fn test_parse_orderbook_deltas_rejects_invalid_size_precision() {
1600        let payload = subscription_data_payload(
1601            "orderbook.ETH-PERP.1.10",
1602            &orderbook_json(
1603                1_700_000_000_000,
1604                &json!([["3500", "1"]]),
1605                &json!([["3501", "2"]]),
1606            ),
1607        );
1608
1609        let msg = parse_orderbook_msg(&payload).unwrap();
1610        let err = parse_orderbook_deltas(
1611            &msg,
1612            PRICE_PRECISION,
1613            INVALID_PRECISION,
1614            UnixNanos::from(123),
1615        )
1616        .expect_err("must reject invalid orderbook size precision");
1617
1618        assert!(err.to_string().contains("invalid Derive orderbook amount"));
1619    }
1620
1621    #[rstest]
1622    fn test_parse_trade_tick_rejects_negative_timestamp() {
1623        let payload = subscription_data_payload("trades.perp.ETH", &json!([trade_json(-1, "buy")]));
1624
1625        let msg = parse_trades_msg(&payload).unwrap();
1626        let err = parse_trade_tick(
1627            &msg.trades[0],
1628            PRICE_PRECISION,
1629            SIZE_PRECISION,
1630            UnixNanos::from(456),
1631        )
1632        .expect_err("must reject negative trade timestamp");
1633
1634        assert!(err.to_string().contains("negative Derive trade timestamp"));
1635    }
1636
1637    #[rstest]
1638    fn test_parse_trade_tick_rejects_timestamp_overflow() {
1639        let payload =
1640            subscription_data_payload("trades.perp.ETH", &json!([trade_json(i64::MAX, "buy")]));
1641
1642        let msg = parse_trades_msg(&payload).unwrap();
1643        let err = parse_trade_tick(
1644            &msg.trades[0],
1645            PRICE_PRECISION,
1646            SIZE_PRECISION,
1647            UnixNanos::from(456),
1648        )
1649        .expect_err("must reject overflowing trade timestamp");
1650
1651        assert!(
1652            err.to_string()
1653                .contains("Derive timestamp overflows nanoseconds")
1654        );
1655    }
1656
1657    #[rstest]
1658    fn test_parse_trade_tick_rejects_invalid_price_precision() {
1659        let payload = subscription_data_payload(
1660            "trades.perp.ETH",
1661            &json!([trade_json(1_700_000_000_001, "buy")]),
1662        );
1663
1664        let msg = parse_trades_msg(&payload).unwrap();
1665        let err = parse_trade_tick(
1666            &msg.trades[0],
1667            INVALID_PRECISION,
1668            SIZE_PRECISION,
1669            UnixNanos::from(456),
1670        )
1671        .expect_err("must reject invalid trade price precision");
1672
1673        assert!(err.to_string().contains("invalid trade price for ETH-PERP"));
1674    }
1675
1676    #[rstest]
1677    fn test_parse_ticker_quote_rejects_negative_timestamp() {
1678        let payload = subscription_data_payload(
1679            "ticker.ETH-PERP.1000",
1680            &json!({
1681                "timestamp": -1_i64,
1682                "instrument_ticker": ticker_json()
1683            }),
1684        );
1685
1686        let msg = parse_ticker_msg(&payload).unwrap();
1687        let err = parse_ticker_quote(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(789))
1688            .expect_err("must reject negative ticker timestamp");
1689
1690        assert!(err.to_string().contains("negative Derive ticker timestamp"));
1691    }
1692
1693    #[rstest]
1694    fn test_parse_ticker_quote_rejects_timestamp_overflow() {
1695        let payload = subscription_data_payload(
1696            "ticker.ETH-PERP.1000",
1697            &json!({
1698                "timestamp": i64::MAX,
1699                "instrument_ticker": ticker_json()
1700            }),
1701        );
1702
1703        let msg = parse_ticker_msg(&payload).unwrap();
1704        let err = parse_ticker_quote(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(789))
1705            .expect_err("must reject overflowing ticker timestamp");
1706
1707        assert!(
1708            err.to_string()
1709                .contains("Derive timestamp overflows nanoseconds")
1710        );
1711    }
1712
1713    #[rstest]
1714    fn test_parse_public_ws_data_rejects_unknown_channel() {
1715        let payload = WsSubscriptionPayload {
1716            channel: Ustr::from("wallet.ETH"),
1717            data: serde_json::value::to_raw_value(&json!({})).unwrap(),
1718        };
1719
1720        let err = parse_public_ws_data(&payload).expect_err("must reject unknown channel");
1721
1722        assert!(
1723            err.to_string()
1724                .contains("unsupported Derive public WS channel")
1725        );
1726    }
1727
1728    fn option_ticker_json(timestamp: i64) -> Value {
1729        let mut value = load_json("options/http_ticker_eth_snapshot.json");
1730        value["timestamp"] = json!(timestamp);
1731        value
1732    }
1733
1734    fn perp_envelope_payload(timestamp: i64) -> WsSubscriptionPayload {
1735        subscription_data_payload(
1736            "ticker.ETH-PERP.1000",
1737            &json!({
1738                "timestamp": timestamp,
1739                "instrument_ticker": ticker_json_with_timestamp(timestamp),
1740            }),
1741        )
1742    }
1743
1744    fn option_envelope_payload(timestamp: i64) -> WsSubscriptionPayload {
1745        let mut option_data = option_ticker_json(timestamp);
1746        option_data["instrument_name"] = json!("ETH-20260627-3500-C");
1747        subscription_data_payload(
1748            "ticker.ETH-20260627-3500-C.1000",
1749            &json!({
1750                "timestamp": timestamp,
1751                "instrument_ticker": option_data,
1752            }),
1753        )
1754    }
1755
1756    fn slim_payload() -> WsSubscriptionPayload {
1757        subscription_data_payload(
1758            "ticker_slim.ETH-PERP.1000",
1759            &load_json("perps/ws_ticker_slim_eth.json"),
1760        )
1761    }
1762
1763    #[rstest]
1764    fn test_parse_mark_price_maps_slim_variant() {
1765        let msg = parse_ticker_msg(&slim_payload()).unwrap();
1766
1767        let update = parse_mark_price(&msg, PRICE_PRECISION, UnixNanos::from(789))
1768            .unwrap()
1769            .expect("slim ticker carries mark price");
1770
1771        assert_eq!(update.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
1772        assert_eq!(update.value, price("1992.49"));
1773        assert_eq!(update.ts_event, UnixNanos::from(1_779_953_796_714_000_000));
1774        assert_eq!(update.ts_init, UnixNanos::from(789));
1775    }
1776
1777    #[rstest]
1778    fn test_parse_index_price_maps_slim_variant() {
1779        let msg = parse_ticker_msg(&slim_payload()).unwrap();
1780
1781        let update = parse_index_price(&msg, PRICE_PRECISION, UnixNanos::from(789))
1782            .unwrap()
1783            .expect("slim ticker carries index price");
1784
1785        assert_eq!(update.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
1786        assert_eq!(update.value, price("1991.79"));
1787        assert_eq!(update.ts_event, UnixNanos::from(1_779_953_796_714_000_000));
1788        assert_eq!(update.ts_init, UnixNanos::from(789));
1789    }
1790
1791    #[rstest]
1792    fn test_parse_funding_rate_maps_slim_variant() {
1793        let msg = parse_ticker_msg(&slim_payload()).unwrap();
1794
1795        let update = parse_funding_rate(&msg, UnixNanos::from(789))
1796            .unwrap()
1797            .expect("slim ticker carries perp funding");
1798
1799        assert_eq!(update.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
1800        assert_eq!(update.rate, Decimal::from_str("0.000012500").unwrap());
1801        assert_eq!(update.ts_event, UnixNanos::from(1_779_953_796_714_000_000));
1802        assert_eq!(update.ts_init, UnixNanos::from(789));
1803    }
1804
1805    #[rstest]
1806    fn test_parse_option_greeks_returns_none_for_slim_variant_without_option_pricing() {
1807        let msg = parse_ticker_msg(&slim_payload()).unwrap();
1808
1809        let result = parse_option_greeks(&msg, UnixNanos::from(789)).unwrap();
1810
1811        assert!(result.is_none());
1812    }
1813
1814    fn option_slim_payload(filename: &str, instrument_name: &str) -> WsSubscriptionPayload {
1815        subscription_data_payload(
1816            &format!("ticker_slim.{instrument_name}.1000"),
1817            &load_json(filename),
1818        )
1819    }
1820
1821    #[rstest]
1822    fn test_parse_option_greeks_maps_slim_variant() {
1823        let msg = parse_ticker_msg(&option_slim_payload(
1824            "options/ws_ticker_slim_eth_call.json",
1825            "ETH-20260612-1600-C",
1826        ))
1827        .unwrap();
1828
1829        let greeks = parse_option_greeks(&msg, UnixNanos::from(789))
1830            .unwrap()
1831            .expect("slim ticker carries option pricing");
1832
1833        assert_eq!(
1834            greeks.instrument_id,
1835            InstrumentId::from("ETH-20260612-1600-C.DERIVE")
1836        );
1837        assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
1838        assert!((greeks.greeks.delta - 0.95222).abs() < 1e-9);
1839        assert!((greeks.greeks.gamma - 0.00036344).abs() < 1e-9);
1840        assert_eq!(greeks.mark_iv, Some(0.67698));
1841        assert_eq!(greeks.bid_iv, Some(0.0));
1842        assert_eq!(greeks.ask_iv, Some(0.88815));
1843        assert_eq!(greeks.underlying_price, Some(1992.6));
1844        assert_eq!(greeks.open_interest, Some(0.0));
1845        assert_eq!(greeks.ts_event, UnixNanos::from(1_779_953_796_231_000_000));
1846        assert_eq!(greeks.ts_init, UnixNanos::from(789));
1847    }
1848
1849    #[rstest]
1850    fn test_parse_option_greeks_maps_slim_put_variant() {
1851        let msg = parse_ticker_msg(&option_slim_payload(
1852            "options/ws_ticker_slim_eth_put.json",
1853            "ETH-20260612-1900-P",
1854        ))
1855        .unwrap();
1856
1857        let greeks = parse_option_greeks(&msg, UnixNanos::from(789))
1858            .unwrap()
1859            .expect("slim ticker carries put option pricing");
1860
1861        assert_eq!(
1862            greeks.instrument_id,
1863            InstrumentId::from("ETH-20260612-1900-P.DERIVE")
1864        );
1865        assert!((greeks.greeks.delta + 0.30438).abs() < 1e-9);
1866        assert!((greeks.greeks.gamma - 0.00169741).abs() < 1e-9);
1867        assert_eq!(greeks.mark_iv, Some(0.51012));
1868        assert_eq!(greeks.bid_iv, Some(0.48229));
1869        assert_eq!(greeks.ask_iv, Some(0.52063));
1870        assert_eq!(greeks.underlying_price, Some(1992.6));
1871        assert_eq!(greeks.open_interest, Some(42.13));
1872        assert_eq!(greeks.ts_event, UnixNanos::from(1_779_953_797_040_000_000));
1873        assert_eq!(greeks.ts_init, UnixNanos::from(789));
1874    }
1875
1876    #[rstest]
1877    fn test_parse_funding_rate_returns_none_for_option_payload() {
1878        let msg = parse_ticker_msg(&option_envelope_payload(1_700_000_000_010)).unwrap();
1879
1880        let result = parse_funding_rate(&msg, UnixNanos::from(789)).unwrap();
1881
1882        assert!(result.is_none());
1883    }
1884
1885    #[rstest]
1886    fn test_parse_option_greeks_returns_none_for_perp_payload() {
1887        let msg = parse_ticker_msg(&perp_envelope_payload(1_700_000_000_010)).unwrap();
1888
1889        let result = parse_option_greeks(&msg, UnixNanos::from(789)).unwrap();
1890
1891        assert!(result.is_none());
1892    }
1893
1894    #[rstest]
1895    fn test_parse_option_greeks_open_interest_none_when_stats_absent() {
1896        // Legacy full ticker payloads may omit `stats`. When the WS path
1897        // receives one without stats, `open_interest` must degrade to
1898        // None while the remaining greek fields still populate normally.
1899        let timestamp = 1_700_000_000_010_i64;
1900        let mut option_data = option_ticker_json(timestamp);
1901        option_data["instrument_name"] = json!("ETH-20260627-3500-C");
1902        option_data["stats"] = json!(null);
1903        let payload = subscription_data_payload(
1904            "ticker.ETH-20260627-3500-C.1000",
1905            &json!({
1906                "timestamp": timestamp,
1907                "instrument_ticker": option_data,
1908            }),
1909        );
1910        let msg = parse_ticker_msg(&payload).unwrap();
1911
1912        let greeks = parse_option_greeks(&msg, UnixNanos::from(789))
1913            .unwrap()
1914            .expect("option greeks present when option_pricing is set");
1915        assert!(greeks.open_interest.is_none());
1916        // The other greek fields must still be populated from option_pricing
1917        assert!((greeks.greeks.delta - 0.55).abs() < 1e-9);
1918        assert!(greeks.mark_iv.is_some());
1919        assert!(greeks.underlying_price.is_some());
1920    }
1921
1922    #[rstest]
1923    fn test_parse_mark_price_rejects_negative_timestamp() {
1924        let payload = subscription_data_payload(
1925            "ticker.ETH-PERP.1000",
1926            &json!({
1927                "timestamp": -1_i64,
1928                "instrument_ticker": ticker_json(),
1929            }),
1930        );
1931        let msg = parse_ticker_msg(&payload).unwrap();
1932
1933        let err = parse_mark_price(&msg, PRICE_PRECISION, UnixNanos::from(789))
1934            .expect_err("must reject negative ticker timestamp");
1935
1936        assert!(err.to_string().contains("negative Derive ticker timestamp"));
1937    }
1938
1939    #[rstest]
1940    fn test_parse_mark_price_rejects_timestamp_overflow() {
1941        let payload = subscription_data_payload(
1942            "ticker.ETH-PERP.1000",
1943            &json!({
1944                "timestamp": i64::MAX,
1945                "instrument_ticker": ticker_json(),
1946            }),
1947        );
1948        let msg = parse_ticker_msg(&payload).unwrap();
1949
1950        let err = parse_mark_price(&msg, PRICE_PRECISION, UnixNanos::from(789))
1951            .expect_err("must reject overflowing ticker timestamp");
1952
1953        assert!(
1954            err.to_string()
1955                .contains("Derive timestamp overflows nanoseconds")
1956        );
1957    }
1958
1959    #[rstest]
1960    fn test_parse_index_price_rejects_negative_timestamp() {
1961        let payload = subscription_data_payload(
1962            "ticker.ETH-PERP.1000",
1963            &json!({
1964                "timestamp": -1_i64,
1965                "instrument_ticker": ticker_json(),
1966            }),
1967        );
1968        let msg = parse_ticker_msg(&payload).unwrap();
1969
1970        let err = parse_index_price(&msg, PRICE_PRECISION, UnixNanos::from(789))
1971            .expect_err("must reject negative ticker timestamp");
1972
1973        assert!(err.to_string().contains("negative Derive ticker timestamp"));
1974    }
1975
1976    #[rstest]
1977    fn test_parse_index_price_rejects_timestamp_overflow() {
1978        let payload = subscription_data_payload(
1979            "ticker.ETH-PERP.1000",
1980            &json!({
1981                "timestamp": i64::MAX,
1982                "instrument_ticker": ticker_json(),
1983            }),
1984        );
1985        let msg = parse_ticker_msg(&payload).unwrap();
1986
1987        let err = parse_index_price(&msg, PRICE_PRECISION, UnixNanos::from(789))
1988            .expect_err("must reject overflowing ticker timestamp");
1989
1990        assert!(
1991            err.to_string()
1992                .contains("Derive timestamp overflows nanoseconds")
1993        );
1994    }
1995
1996    #[rstest]
1997    fn test_parse_funding_rate_rejects_negative_timestamp() {
1998        let payload = subscription_data_payload(
1999            "ticker.ETH-PERP.1000",
2000            &json!({
2001                "timestamp": -1_i64,
2002                "instrument_ticker": ticker_json(),
2003            }),
2004        );
2005        let msg = parse_ticker_msg(&payload).unwrap();
2006
2007        let err = parse_funding_rate(&msg, UnixNanos::from(789))
2008            .expect_err("must reject negative ticker timestamp");
2009
2010        assert!(err.to_string().contains("negative Derive ticker timestamp"));
2011    }
2012
2013    #[rstest]
2014    fn test_parse_funding_rate_rejects_timestamp_overflow() {
2015        let payload = subscription_data_payload(
2016            "ticker.ETH-PERP.1000",
2017            &json!({
2018                "timestamp": i64::MAX,
2019                "instrument_ticker": ticker_json(),
2020            }),
2021        );
2022        let msg = parse_ticker_msg(&payload).unwrap();
2023
2024        let err = parse_funding_rate(&msg, UnixNanos::from(789))
2025            .expect_err("must reject overflowing ticker timestamp");
2026
2027        assert!(
2028            err.to_string()
2029                .contains("Derive timestamp overflows nanoseconds")
2030        );
2031    }
2032
2033    #[rstest]
2034    fn test_parse_funding_rate_history_record_maps_fields() {
2035        let record = DerivePublicFundingRate {
2036            funding_rate: Decimal::from_str("0.00015").unwrap(),
2037            timestamp: 1_700_000_000_000,
2038        };
2039
2040        let instrument_id = InstrumentId::from("ETH-PERP.DERIVE");
2041
2042        let update = parse_funding_rate_history_record(
2043            &record,
2044            instrument_id,
2045            Some(60),
2046            UnixNanos::from(789),
2047        )
2048        .unwrap();
2049
2050        assert_eq!(update.instrument_id, instrument_id);
2051        assert_eq!(update.rate, Decimal::from_str("0.00015").unwrap());
2052        assert_eq!(update.interval, Some(60));
2053        assert!(update.next_funding_ns.is_none());
2054        assert_eq!(update.ts_event, UnixNanos::from(1_700_000_000_000_000_000));
2055        assert_eq!(update.ts_init, UnixNanos::from(789));
2056    }
2057
2058    #[rstest]
2059    fn test_parse_funding_rate_history_record_rejects_negative_timestamp() {
2060        let record = DerivePublicFundingRate {
2061            funding_rate: Decimal::from_str("0.0001").unwrap(),
2062            timestamp: -1,
2063        };
2064
2065        let err = parse_funding_rate_history_record(
2066            &record,
2067            InstrumentId::from("ETH-PERP.DERIVE"),
2068            None,
2069            UnixNanos::from(789),
2070        )
2071        .expect_err("must reject negative timestamp");
2072
2073        assert!(err.to_string().contains("negative Derive ticker timestamp"));
2074    }
2075
2076    #[rstest]
2077    fn test_parse_candle_record_maps_fields() {
2078        // `timestamp` and `timestamp_bucket` differ so a swap from `timestamp_bucket`
2079        // to `timestamp` in the parser would shift ts_event and fail the assertion.
2080        let record = DerivePublicCandle {
2081            open_price: Decimal::from_str("3500.0").unwrap(),
2082            high_price: Decimal::from_str("3501.5").unwrap(),
2083            low_price: Decimal::from_str("3499.0").unwrap(),
2084            close_price: Decimal::from_str("3501.0").unwrap(),
2085            volume_usd: Decimal::from_str("12345.6").unwrap(),
2086            volume_contracts: Decimal::from_str("3.527").unwrap(),
2087            timestamp: 1_700_000_007,
2088            timestamp_bucket: 1_700_000_000,
2089        };
2090
2091        let bar_type = BarType::from("ETH-PERP.DERIVE-1-MINUTE-LAST-EXTERNAL");
2092
2093        let bar = parse_candle_record(
2094            &record,
2095            bar_type,
2096            PRICE_PRECISION,
2097            SIZE_PRECISION,
2098            UnixNanos::from(789),
2099        )
2100        .unwrap();
2101
2102        assert_eq!(bar.bar_type, bar_type);
2103        assert_eq!(bar.open, Price::from_str("3500.00").unwrap());
2104        assert_eq!(bar.high, Price::from_str("3501.50").unwrap());
2105        assert_eq!(bar.low, Price::from_str("3499.00").unwrap());
2106        assert_eq!(bar.close, Price::from_str("3501.00").unwrap());
2107        assert_eq!(bar.volume, Quantity::from_str("3.527").unwrap());
2108        assert_eq!(bar.ts_event, UnixNanos::from(1_700_000_060_000_000_000));
2109        assert_eq!(bar.ts_init, UnixNanos::from(789));
2110    }
2111
2112    #[rstest]
2113    fn test_parse_candle_record_rejects_negative_timestamp() {
2114        let record = DerivePublicCandle {
2115            open_price: Decimal::from_str("1").unwrap(),
2116            high_price: Decimal::from_str("1").unwrap(),
2117            low_price: Decimal::from_str("1").unwrap(),
2118            close_price: Decimal::from_str("1").unwrap(),
2119            volume_usd: Decimal::ZERO,
2120            volume_contracts: Decimal::ZERO,
2121            timestamp: 1_700_000_000,
2122            timestamp_bucket: -1,
2123        };
2124
2125        let err = parse_candle_record(
2126            &record,
2127            BarType::from("ETH-PERP.DERIVE-1-MINUTE-LAST-EXTERNAL"),
2128            PRICE_PRECISION,
2129            SIZE_PRECISION,
2130            UnixNanos::from(789),
2131        )
2132        .expect_err("must reject negative timestamp");
2133
2134        assert!(err.to_string().contains("negative Derive candle timestamp"));
2135    }
2136
2137    #[rstest]
2138    fn test_parse_candle_record_rejects_timestamp_overflow() {
2139        let record = DerivePublicCandle {
2140            open_price: Decimal::from_str("1").unwrap(),
2141            high_price: Decimal::from_str("1").unwrap(),
2142            low_price: Decimal::from_str("1").unwrap(),
2143            close_price: Decimal::from_str("1").unwrap(),
2144            volume_usd: Decimal::ZERO,
2145            volume_contracts: Decimal::ZERO,
2146            timestamp: 1_700_000_000,
2147            timestamp_bucket: i64::MAX,
2148        };
2149
2150        let err = parse_candle_record(
2151            &record,
2152            BarType::from("ETH-PERP.DERIVE-1-MINUTE-LAST-EXTERNAL"),
2153            PRICE_PRECISION,
2154            SIZE_PRECISION,
2155            UnixNanos::from(789),
2156        )
2157        .expect_err("must reject overflowing timestamp");
2158
2159        assert!(
2160            err.to_string()
2161                .contains("Derive candle timestamp_bucket overflows nanoseconds"),
2162            "{err}",
2163        );
2164    }
2165
2166    #[rstest]
2167    fn test_parse_candle_record_rejects_close_timestamp_overflow() {
2168        let record = DerivePublicCandle {
2169            open_price: Decimal::from_str("1").unwrap(),
2170            high_price: Decimal::from_str("1").unwrap(),
2171            low_price: Decimal::from_str("1").unwrap(),
2172            close_price: Decimal::from_str("1").unwrap(),
2173            volume_usd: Decimal::ZERO,
2174            volume_contracts: Decimal::ZERO,
2175            timestamp: 1_700_000_000,
2176            timestamp_bucket: (u64::MAX / NANOSECONDS_IN_SECOND) as i64,
2177        };
2178
2179        let err = parse_candle_record(
2180            &record,
2181            BarType::from("ETH-PERP.DERIVE-1-MINUTE-LAST-EXTERNAL"),
2182            PRICE_PRECISION,
2183            SIZE_PRECISION,
2184            UnixNanos::from(789),
2185        )
2186        .expect_err("must reject close timestamp overflow");
2187
2188        assert!(
2189            err.to_string()
2190                .contains("bar timestamp overflowed when adjusting to close time"),
2191            "{err}",
2192        );
2193    }
2194
2195    #[rstest]
2196    #[case(BarAggregation::Minute, 1, 60)]
2197    #[case(BarAggregation::Minute, 5, 300)]
2198    #[case(BarAggregation::Minute, 15, 900)]
2199    #[case(BarAggregation::Minute, 30, 1800)]
2200    #[case(BarAggregation::Hour, 1, 3600)]
2201    #[case(BarAggregation::Hour, 4, 14400)]
2202    #[case(BarAggregation::Hour, 8, 28800)]
2203    #[case(BarAggregation::Day, 1, 86400)]
2204    #[case(BarAggregation::Week, 1, 604800)]
2205    fn test_bar_spec_to_derive_period_maps_supported_intervals(
2206        #[case] aggregation: BarAggregation,
2207        #[case] step: u64,
2208        #[case] expected: u32,
2209    ) {
2210        assert_eq!(
2211            bar_spec_to_derive_period(aggregation, step).unwrap(),
2212            expected
2213        );
2214    }
2215
2216    #[rstest]
2217    #[case(BarAggregation::Minute, 2, "minute intervals")]
2218    #[case(BarAggregation::Hour, 2, "hour intervals")]
2219    #[case(BarAggregation::Day, 7, "1 DAY interval")]
2220    #[case(BarAggregation::Week, 2, "1 WEEK interval")]
2221    #[case(BarAggregation::Second, 1, "does not support")]
2222    fn test_bar_spec_to_derive_period_rejects_unsupported(
2223        #[case] aggregation: BarAggregation,
2224        #[case] step: u64,
2225        #[case] expected_msg: &str,
2226    ) {
2227        let err =
2228            bar_spec_to_derive_period(aggregation, step).expect_err("must reject unsupported spec");
2229        assert!(
2230            err.to_string().contains(expected_msg),
2231            "expected {expected_msg:?}, was {err}",
2232        );
2233    }
2234
2235    #[rstest]
2236    fn test_parse_option_greeks_rejects_negative_timestamp() {
2237        let mut option_data = option_ticker_json(1_700_000_000_000);
2238        option_data["instrument_name"] = json!("ETH-20260627-3500-C");
2239        let payload = subscription_data_payload(
2240            "ticker.ETH-20260627-3500-C.1000",
2241            &json!({
2242                "timestamp": -1_i64,
2243                "instrument_ticker": option_data,
2244            }),
2245        );
2246        let msg = parse_ticker_msg(&payload).unwrap();
2247
2248        let err = parse_option_greeks(&msg, UnixNanos::from(789))
2249            .expect_err("must reject negative ticker timestamp");
2250
2251        assert!(err.to_string().contains("negative Derive ticker timestamp"));
2252    }
2253
2254    #[rstest]
2255    fn test_parse_option_greeks_rejects_timestamp_overflow() {
2256        let mut option_data = option_ticker_json(1_700_000_000_000);
2257        option_data["instrument_name"] = json!("ETH-20260627-3500-C");
2258        let payload = subscription_data_payload(
2259            "ticker.ETH-20260627-3500-C.1000",
2260            &json!({
2261                "timestamp": i64::MAX,
2262                "instrument_ticker": option_data,
2263            }),
2264        );
2265        let msg = parse_ticker_msg(&payload).unwrap();
2266
2267        let err = parse_option_greeks(&msg, UnixNanos::from(789))
2268            .expect_err("must reject overflowing ticker timestamp");
2269
2270        assert!(
2271            err.to_string()
2272                .contains("Derive timestamp overflows nanoseconds")
2273        );
2274    }
2275}