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