1use anyhow::Context;
19use nautilus_core::{
20 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, OrderBookDepth10, QuoteTick, TradeTick,
27 depth::DEPTH10_LEN, 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;
34use ustr::Ustr;
35
36use super::messages::{
37 DeriveOrderbookData, DeriveOrderbookLevel, DeriveOrderbookMsg, DerivePublicWsData,
38 DeriveTickerData, DeriveTickerMsg, DeriveTradesMsg, WsSubscriptionPayload,
39};
40use crate::{
41 common::{enums::DeriveOrderSide, parse::format_instrument_id},
42 http::models::{
43 DerivePublicCandle, DerivePublicFundingRate, DerivePublicTrade, DeriveTickerSnapshot,
44 },
45};
46
47pub fn parse_public_ws_data(payload: &WsSubscriptionPayload) -> anyhow::Result<DerivePublicWsData> {
54 let channel = payload.channel.as_str();
55
56 if channel.starts_with("orderbook.") {
57 return parse_orderbook_msg(payload).map(DerivePublicWsData::Orderbook);
58 }
59
60 if channel.starts_with("trades.") {
61 return parse_trades_msg(payload).map(DerivePublicWsData::Trades);
62 }
63
64 if channel.starts_with("ticker_slim.") || channel.starts_with("ticker.") {
65 return parse_ticker_msg(payload).map(|msg| DerivePublicWsData::Ticker(Box::new(msg)));
66 }
67
68 anyhow::bail!("unsupported Derive public WS channel `{}`", payload.channel)
69}
70
71pub fn parse_orderbook_msg(payload: &WsSubscriptionPayload) -> anyhow::Result<DeriveOrderbookMsg> {
77 let data = serde_json::from_str::<DeriveOrderbookData>(payload.data.get())
78 .context("failed to decode Derive orderbook data")?;
79 Ok(DeriveOrderbookMsg {
80 channel: Ustr::from(payload.channel.as_str()),
81 data,
82 })
83}
84
85pub fn parse_trades_msg(payload: &WsSubscriptionPayload) -> anyhow::Result<DeriveTradesMsg> {
91 let trades = serde_json::from_str::<Vec<DerivePublicTrade>>(payload.data.get())
92 .context("failed to decode Derive trades data")?;
93 Ok(DeriveTradesMsg {
94 channel: Ustr::from(payload.channel.as_str()),
95 trades,
96 })
97}
98
99pub fn parse_ticker_msg(payload: &WsSubscriptionPayload) -> anyhow::Result<DeriveTickerMsg> {
105 let mut data = serde_json::from_str::<DeriveTickerData>(payload.data.get())
106 .context("failed to decode Derive ticker data")?;
107 data.apply_channel_context(payload.channel.as_str())
108 .map_err(anyhow::Error::msg)?;
109 Ok(DeriveTickerMsg {
110 channel: Ustr::from(payload.channel.as_str()),
111 data,
112 })
113}
114
115pub fn parse_orderbook_deltas(
129 msg: &DeriveOrderbookMsg,
130 price_precision: u8,
131 size_precision: u8,
132 ts_init: UnixNanos,
133) -> anyhow::Result<OrderBookDeltas> {
134 let instrument_id = msg.data.instrument_id();
135 let timestamp =
136 u64::try_from(msg.data.timestamp).context("negative Derive orderbook timestamp")?;
137 let ts_event = timestamp_millis_to_nanos(timestamp, "timestamp")?;
138 let sequence = timestamp;
139 let context = BookDeltaContext {
140 instrument_id,
141 sequence,
142 price_precision,
143 size_precision,
144 ts_event,
145 ts_init,
146 };
147
148 let mut deltas = Vec::with_capacity(1 + msg.data.bids.len() + msg.data.asks.len());
149 let clear_flags = if msg.data.bids.is_empty() && msg.data.asks.is_empty() {
150 RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
151 } else {
152 RecordFlag::F_SNAPSHOT as u8
153 };
154 deltas.push(OrderBookDelta::new_checked(
155 context.instrument_id,
156 BookAction::Clear,
157 BookOrder::default(),
158 clear_flags,
159 context.sequence,
160 context.ts_event,
161 context.ts_init,
162 )?);
163
164 for (idx, level) in msg.data.bids.iter().enumerate() {
165 push_level_delta(&mut deltas, &context, OrderSide::Buy, level, idx as u64)?;
166 }
167
168 let bid_count = msg.data.bids.len();
169 for (idx, level) in msg.data.asks.iter().enumerate() {
170 push_level_delta(
171 &mut deltas,
172 &context,
173 OrderSide::Sell,
174 level,
175 (bid_count + idx) as u64,
176 )?;
177 }
178
179 if let Some(last) = deltas.last_mut() {
180 last.flags |= RecordFlag::F_LAST as u8;
181 }
182
183 OrderBookDeltas::new_checked(context.instrument_id, deltas)
184}
185
186pub fn parse_orderbook_depth10(
195 msg: &DeriveOrderbookMsg,
196 price_precision: u8,
197 size_precision: u8,
198 ts_init: UnixNanos,
199) -> anyhow::Result<OrderBookDepth10> {
200 let instrument_id = msg.data.instrument_id();
201 let timestamp =
202 u64::try_from(msg.data.timestamp).context("negative Derive orderbook timestamp")?;
203 let ts_event = timestamp_millis_to_nanos(timestamp, "timestamp")?;
204
205 let mut bids = [BookOrder::default(); DEPTH10_LEN];
206 let mut asks = [BookOrder::default(); DEPTH10_LEN];
207 let mut bid_counts = [0; DEPTH10_LEN];
208 let mut ask_counts = [0; DEPTH10_LEN];
209
210 fill_depth_side(
211 &mut bids,
212 &mut bid_counts,
213 &msg.data.bids,
214 OrderSide::Buy,
215 price_precision,
216 size_precision,
217 )?;
218 fill_depth_side(
219 &mut asks,
220 &mut ask_counts,
221 &msg.data.asks,
222 OrderSide::Sell,
223 price_precision,
224 size_precision,
225 )?;
226
227 Ok(OrderBookDepth10::new(
228 instrument_id,
229 bids,
230 asks,
231 bid_counts,
232 ask_counts,
233 RecordFlag::F_SNAPSHOT as u8,
234 timestamp,
235 ts_event,
236 ts_init,
237 ))
238}
239
240pub fn parse_trade_tick(
249 trade: &DerivePublicTrade,
250 price_precision: u8,
251 size_precision: u8,
252 ts_init: UnixNanos,
253) -> anyhow::Result<TradeTick> {
254 let instrument_id = format_instrument_id(trade.instrument_name.as_str());
255 let price = Price::from_decimal_dp(trade.trade_price, price_precision)
256 .with_context(|| format!("invalid trade price for {}", trade.instrument_name))?;
257 let size = Quantity::from_decimal_dp(trade.trade_amount, size_precision)
258 .with_context(|| format!("invalid trade amount for {}", trade.instrument_name))?;
259 let aggressor_side = match trade.direction {
260 DeriveOrderSide::Buy => AggressorSide::Buyer,
261 DeriveOrderSide::Sell => AggressorSide::Seller,
262 };
263 let trade_id = TradeId::new(&trade.trade_id);
264 let timestamp = u64::try_from(trade.timestamp).context("negative Derive trade timestamp")?;
265 let ts_event = timestamp_millis_to_nanos(timestamp, "timestamp")?;
266
267 TradeTick::new_checked(
268 instrument_id,
269 price,
270 size,
271 aggressor_side,
272 trade_id,
273 ts_event,
274 ts_init,
275 )
276}
277
278pub fn parse_ticker_quote(
287 msg: &DeriveTickerMsg,
288 price_precision: u8,
289 size_precision: u8,
290 ts_init: UnixNanos,
291) -> anyhow::Result<QuoteTick> {
292 let instrument_id = msg.data.instrument_id();
293 let instrument_name = msg.data.instrument_name().as_str();
294 let bid_price = Price::from_decimal_dp(msg.data.best_bid_price(), price_precision)
295 .with_context(|| format!("invalid bid price for {instrument_name}"))?;
296 let ask_price = Price::from_decimal_dp(msg.data.best_ask_price(), price_precision)
297 .with_context(|| format!("invalid ask price for {instrument_name}"))?;
298 let bid_size = Quantity::from_decimal_dp(msg.data.best_bid_amount(), size_precision)
299 .with_context(|| format!("invalid bid amount for {instrument_name}"))?;
300 let ask_size = Quantity::from_decimal_dp(msg.data.best_ask_amount(), size_precision)
301 .with_context(|| format!("invalid ask amount for {instrument_name}"))?;
302 let timestamp =
303 u64::try_from(msg.data.timestamp()).context("negative Derive ticker timestamp")?;
304 let ts_event = timestamp_millis_to_nanos(timestamp, "timestamp")?;
305
306 QuoteTick::new_checked(
307 instrument_id,
308 bid_price,
309 ask_price,
310 bid_size,
311 ask_size,
312 ts_event,
313 ts_init,
314 )
315}
316
317pub fn parse_ticker_quote_from_rest(
323 ticker: &DeriveTickerSnapshot,
324 price_precision: u8,
325 size_precision: u8,
326 ts_init: UnixNanos,
327) -> anyhow::Result<QuoteTick> {
328 let instrument_id = format_instrument_id(ticker.instrument_name.as_str());
329 let instrument_name = ticker.instrument_name.as_str();
330 let bid_price = Price::from_decimal_dp(ticker.best_bid_price, price_precision)
331 .with_context(|| format!("invalid bid price for {instrument_name}"))?;
332 let ask_price = Price::from_decimal_dp(ticker.best_ask_price, price_precision)
333 .with_context(|| format!("invalid ask price for {instrument_name}"))?;
334 let bid_size = Quantity::from_decimal_dp(ticker.best_bid_amount, size_precision)
335 .with_context(|| format!("invalid bid amount for {instrument_name}"))?;
336 let ask_size = Quantity::from_decimal_dp(ticker.best_ask_amount, size_precision)
337 .with_context(|| format!("invalid ask amount for {instrument_name}"))?;
338 let timestamp = u64::try_from(ticker.timestamp).context("negative Derive ticker timestamp")?;
339 let ts_event = timestamp_millis_to_nanos(timestamp, "timestamp")?;
340
341 QuoteTick::new_checked(
342 instrument_id,
343 bid_price,
344 ask_price,
345 bid_size,
346 ask_size,
347 ts_event,
348 ts_init,
349 )
350}
351
352#[derive(Debug, Clone, Copy)]
353struct BookDeltaContext {
354 instrument_id: InstrumentId,
355 sequence: u64,
356 price_precision: u8,
357 size_precision: u8,
358 ts_event: UnixNanos,
359 ts_init: UnixNanos,
360}
361
362fn push_level_delta(
363 deltas: &mut Vec<OrderBookDelta>,
364 context: &BookDeltaContext,
365 side: OrderSide,
366 level: &DeriveOrderbookLevel,
367 order_id: u64,
368) -> anyhow::Result<()> {
369 if level.amount().is_zero() {
370 return Ok(());
371 }
372
373 let price = Price::from_decimal_dp(level.price(), context.price_precision)
374 .context("invalid Derive orderbook price")?;
375 let size = Quantity::from_decimal_dp(level.amount(), context.size_precision)
376 .context("invalid Derive orderbook amount")?;
377 let order = BookOrder::new(side, price, size, order_id);
378 deltas.push(OrderBookDelta::new_checked(
379 context.instrument_id,
380 BookAction::Add,
381 order,
382 RecordFlag::F_SNAPSHOT as u8,
383 context.sequence,
384 context.ts_event,
385 context.ts_init,
386 )?);
387 Ok(())
388}
389
390fn fill_depth_side(
391 orders: &mut [BookOrder; DEPTH10_LEN],
392 counts: &mut [u32; DEPTH10_LEN],
393 levels: &[DeriveOrderbookLevel],
394 side: OrderSide,
395 price_precision: u8,
396 size_precision: u8,
397) -> anyhow::Result<()> {
398 let mut index = 0;
399
400 for level in levels {
401 let price = Price::from_decimal_dp(level.price(), price_precision)
402 .context("invalid Derive orderbook price")?;
403 let size = Quantity::from_decimal_dp(level.amount(), size_precision)
404 .context("invalid Derive orderbook amount")?;
405
406 if size.is_zero() {
407 continue;
408 }
409
410 orders[index] = BookOrder::new(side, price, size, 0);
411 counts[index] = 1;
412 index += 1;
413
414 if index == DEPTH10_LEN {
415 break;
416 }
417 }
418
419 for order in orders.iter_mut().skip(index) {
420 *order = BookOrder::new(
421 side,
422 Price::zero(price_precision),
423 Quantity::zero(size_precision),
424 0,
425 );
426 }
427
428 Ok(())
429}
430
431fn timestamp_millis_to_nanos(value: u64, field: &str) -> anyhow::Result<UnixNanos> {
432 let nanos = value
433 .checked_mul(NANOSECONDS_IN_MILLISECOND)
434 .with_context(|| format!("Derive {field} overflows nanoseconds"))?;
435 Ok(UnixNanos::from(nanos))
436}
437
438fn ticker_ts_event(timestamp_ms: i64) -> anyhow::Result<UnixNanos> {
439 let timestamp = u64::try_from(timestamp_ms).context("negative Derive ticker timestamp")?;
440 timestamp_millis_to_nanos(timestamp, "timestamp")
441}
442
443pub fn parse_mark_price(
449 msg: &DeriveTickerMsg,
450 price_precision: u8,
451 ts_init: UnixNanos,
452) -> anyhow::Result<Option<MarkPriceUpdate>> {
453 let instrument_id = msg.data.instrument_id();
454 let value = Price::from_decimal_dp(msg.data.mark_price(), price_precision)
455 .with_context(|| format!("invalid Derive mark price for {instrument_id}"))?;
456 let ts_event = ticker_ts_event(msg.data.timestamp())?;
457 Ok(Some(MarkPriceUpdate::new(
458 instrument_id,
459 value,
460 ts_event,
461 ts_init,
462 )))
463}
464
465pub fn parse_index_price(
471 msg: &DeriveTickerMsg,
472 price_precision: u8,
473 ts_init: UnixNanos,
474) -> anyhow::Result<Option<IndexPriceUpdate>> {
475 let instrument_id = msg.data.instrument_id();
476 let value = Price::from_decimal_dp(msg.data.index_price(), price_precision)
477 .with_context(|| format!("invalid Derive index price for {instrument_id}"))?;
478 let ts_event = ticker_ts_event(msg.data.timestamp())?;
479 Ok(Some(IndexPriceUpdate::new(
480 instrument_id,
481 value,
482 ts_event,
483 ts_init,
484 )))
485}
486
487pub fn parse_funding_rate(
495 msg: &DeriveTickerMsg,
496 ts_init: UnixNanos,
497) -> anyhow::Result<Option<FundingRateUpdate>> {
498 let Some(rate) = msg.data.funding_rate() else {
499 return Ok(None);
500 };
501 let instrument_id = msg.data.instrument_id();
502 let ts_event = ticker_ts_event(msg.data.timestamp())?;
503 Ok(Some(FundingRateUpdate::new(
504 instrument_id,
505 rate,
506 None,
507 None,
508 ts_event,
509 ts_init,
510 )))
511}
512
513pub fn parse_funding_rate_history_record(
519 record: &DerivePublicFundingRate,
520 instrument_id: InstrumentId,
521 interval: Option<u16>,
522 ts_init: UnixNanos,
523) -> anyhow::Result<FundingRateUpdate> {
524 let ts_event = ticker_ts_event(record.timestamp)?;
525 Ok(FundingRateUpdate::new(
526 instrument_id,
527 record.funding_rate,
528 interval,
529 None,
530 ts_event,
531 ts_init,
532 ))
533}
534
535pub fn parse_candle_record(
546 record: &DerivePublicCandle,
547 bar_type: BarType,
548 price_precision: u8,
549 size_precision: u8,
550 ts_init: UnixNanos,
551) -> anyhow::Result<Bar> {
552 let open = Price::from_decimal_dp(record.open_price, price_precision)
553 .context("invalid Derive candle open price")?;
554 let high = Price::from_decimal_dp(record.high_price, price_precision)
555 .context("invalid Derive candle high price")?;
556 let low = Price::from_decimal_dp(record.low_price, price_precision)
557 .context("invalid Derive candle low price")?;
558 let close = Price::from_decimal_dp(record.close_price, price_precision)
559 .context("invalid Derive candle close price")?;
560 let volume = Quantity::from_decimal_dp(record.volume_contracts, size_precision)
561 .context("invalid Derive candle volume")?;
562 let timestamp =
563 u64::try_from(record.timestamp_bucket).context("negative Derive candle timestamp")?;
564 let ts_event = timestamp_seconds_to_nanos(timestamp, "candle timestamp_bucket")?;
565
566 Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
567 .context("failed to construct Bar from Derive candle record")
568}
569
570pub fn bar_spec_to_derive_period(aggregation: BarAggregation, step: u64) -> anyhow::Result<u32> {
580 match aggregation {
581 BarAggregation::Minute => match step {
582 1 => Ok(60),
583 5 => Ok(300),
584 15 => Ok(900),
585 30 => Ok(1800),
586 _ => anyhow::bail!(
587 "Derive only supports minute intervals 1, 5, 15, 30 (use HOUR for >= 60)"
588 ),
589 },
590 BarAggregation::Hour => match step {
591 1 => Ok(3600),
592 4 => Ok(14400),
593 8 => Ok(28800),
594 _ => anyhow::bail!("Derive only supports hour intervals 1, 4, 8"),
595 },
596 BarAggregation::Day => {
597 if step != 1 {
598 anyhow::bail!("Derive only supports 1 DAY interval bars");
599 }
600 Ok(86400)
601 }
602 BarAggregation::Week => {
603 if step != 1 {
604 anyhow::bail!("Derive only supports 1 WEEK interval bars");
605 }
606 Ok(604800)
607 }
608 _ => anyhow::bail!("Derive does not support {aggregation:?} bars"),
609 }
610}
611
612fn timestamp_seconds_to_nanos(value: u64, field: &str) -> anyhow::Result<UnixNanos> {
613 let nanos = value
614 .checked_mul(NANOSECONDS_IN_SECOND)
615 .with_context(|| format!("Derive {field} overflows nanoseconds"))?;
616 Ok(UnixNanos::from(nanos))
617}
618
619pub fn parse_option_greeks(
627 msg: &DeriveTickerMsg,
628 ts_init: UnixNanos,
629) -> anyhow::Result<Option<OptionGreeks>> {
630 let Some(pricing) = msg.data.option_pricing() else {
631 return Ok(None);
632 };
633 let instrument_id = msg.data.instrument_id();
634 let ts_event = ticker_ts_event(msg.data.timestamp())?;
635 let to_f64 = |label: &str, value: rust_decimal::Decimal| {
636 value
637 .to_f64()
638 .ok_or_else(|| anyhow::anyhow!("Derive {label} cannot be represented as f64"))
639 };
640
641 Ok(Some(OptionGreeks {
642 instrument_id,
643 convention: GreeksConvention::BlackScholes,
644 greeks: OptionGreekValues {
645 delta: to_f64("delta", pricing.delta)?,
646 gamma: to_f64("gamma", pricing.gamma)?,
647 vega: to_f64("vega", pricing.vega)?,
648 theta: to_f64("theta", pricing.theta)?,
649 rho: to_f64("rho", pricing.rho)?,
650 },
651 mark_iv: Some(to_f64("iv", pricing.iv)?),
652 bid_iv: Some(to_f64("bid_iv", pricing.bid_iv)?),
653 ask_iv: Some(to_f64("ask_iv", pricing.ask_iv)?),
654 underlying_price: Some(to_f64("forward_price", pricing.forward_price)?),
655 open_interest: msg
656 .data
657 .stats()
658 .map(|s| to_f64("open_interest", s.open_interest))
659 .transpose()?,
660 ts_event,
661 ts_init,
662 }))
663}
664
665#[cfg(test)]
666mod tests {
667 use std::{path::PathBuf, str::FromStr};
668
669 use nautilus_model::{
670 enums::{AggressorSide, BookAction, OrderSide, RecordFlag},
671 identifiers::{InstrumentId, TradeId},
672 types::{Price, Quantity},
673 };
674 use rstest::rstest;
675 use rust_decimal::Decimal;
676 use serde_json::{Value, json};
677
678 use super::*;
679 use crate::websocket::messages::DeriveWsFrame;
680
681 const PRICE_PRECISION: u8 = 2;
682 const SIZE_PRECISION: u8 = 3;
683 const INVALID_PRECISION: u8 = u8::MAX;
684
685 fn data_path() -> PathBuf {
686 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data")
687 }
688
689 fn load_json(filename: &str) -> Value {
690 let content = std::fs::read_to_string(data_path().join(filename))
691 .unwrap_or_else(|_| panic!("failed to read {filename}"));
692 serde_json::from_str(&content).expect("invalid json")
693 }
694
695 fn subscription_payload(frame: &Value) -> WsSubscriptionPayload {
696 match DeriveWsFrame::parse(&frame.to_string()).unwrap() {
697 DeriveWsFrame::Subscription(payload) => payload,
698 other => panic!("expected subscription frame, was {other:?}"),
699 }
700 }
701
702 fn subscription_data_payload(channel: &str, data: &Value) -> WsSubscriptionPayload {
703 subscription_payload(&json!({
704 "jsonrpc": "2.0",
705 "method": "subscription",
706 "params": {
707 "channel": channel,
708 "data": data
709 }
710 }))
711 }
712
713 fn orderbook_json(timestamp: i64, bids: &Value, asks: &Value) -> Value {
714 let mut value = load_json("perps/ws_orderbook_eth.json");
715 value["timestamp"] = json!(timestamp);
716 value["bids"] = bids.clone();
717 value["asks"] = asks.clone();
718 value
719 }
720
721 fn trade_json(timestamp: i64, direction: &str) -> Value {
722 trade_json_with_values(timestamp, direction, "3500.2", "0.25")
723 }
724
725 fn trade_json_with_values(
726 timestamp: i64,
727 direction: &str,
728 trade_price: &str,
729 trade_amount: &str,
730 ) -> Value {
731 let mut value = load_json("perps/ws_trade_eth.json");
732 value["direction"] = json!(direction);
733 value["timestamp"] = json!(timestamp);
734 value["trade_amount"] = json!(trade_amount);
735 value["trade_id"] = json!("trade-1");
736 value["trade_price"] = json!(trade_price);
737 value
738 }
739
740 fn ticker_json_with_timestamp(timestamp: i64) -> Value {
741 let mut value = load_json("perps/ws_ticker_eth.json");
742 value["best_ask_amount"] = json!("1.20");
743 value["best_ask_price"] = json!("3501.00");
744 value["best_bid_amount"] = json!("0.80");
745 value["best_bid_price"] = json!("3499.50");
746 value["timestamp"] = json!(timestamp);
747 value
748 }
749
750 fn ticker_json() -> Value {
751 ticker_json_with_timestamp(1_700_000_000_000)
752 }
753
754 fn price(value: &str) -> Price {
755 Price::from_decimal_dp(Decimal::from_str(value).unwrap(), PRICE_PRECISION).unwrap()
756 }
757
758 fn quantity(value: &str) -> Quantity {
759 Quantity::from_decimal_dp(Decimal::from_str(value).unwrap(), SIZE_PRECISION).unwrap()
760 }
761
762 #[rstest]
763 fn test_parse_public_orderbook_frame() {
764 let payload = subscription_data_payload(
765 "orderbook.ETH-PERP.1.10",
766 &orderbook_json(
767 1_700_000_000_000,
768 &json!([["3499.50", "1.20"], ["3499.00", "0.40"]]),
769 &json!([["3501.00", "0.80"]]),
770 ),
771 );
772
773 let msg = parse_orderbook_msg(&payload).unwrap();
774 let deltas =
775 parse_orderbook_deltas(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
776 .unwrap();
777
778 assert_eq!(msg.channel.as_str(), "orderbook.ETH-PERP.1.10");
779 assert_eq!(
780 msg.data.instrument_id(),
781 InstrumentId::from("ETH-PERP.DERIVE")
782 );
783 assert_eq!(msg.data.bids[0].price().to_string(), "3499.50");
784 assert_eq!(deltas.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
785 assert_eq!(deltas.deltas.len(), 4);
786 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
787 assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy);
788 assert_eq!(deltas.deltas[1].order.price, price("3499.50"));
789 assert_eq!(deltas.deltas[1].order.size, quantity("1.20"));
790 assert_eq!(deltas.deltas[3].order.side, OrderSide::Sell);
791 assert_eq!(
792 deltas.deltas[3].flags,
793 RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
794 );
795 }
796
797 #[rstest]
798 fn test_parse_public_trades_frame() {
799 let payload = subscription_data_payload(
800 "trades.perp.ETH",
801 &json!([trade_json(1_700_000_000_001, "buy")]),
802 );
803
804 let msg = parse_trades_msg(&payload).unwrap();
805 let tick = parse_trade_tick(
806 &msg.trades[0],
807 PRICE_PRECISION,
808 SIZE_PRECISION,
809 UnixNanos::from(456),
810 )
811 .unwrap();
812
813 assert_eq!(msg.channel.as_str(), "trades.perp.ETH");
814 assert_eq!(msg.trades.len(), 1);
815 assert_eq!(
816 format_instrument_id(msg.trades[0].instrument_name.as_str()),
817 InstrumentId::from("ETH-PERP.DERIVE")
818 );
819 assert_eq!(tick.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
820 assert_eq!(tick.price, price("3500.2"));
821 assert_eq!(tick.size, quantity("0.25"));
822 assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
823 assert_eq!(tick.trade_id, TradeId::from("trade-1"));
824 assert_eq!(tick.ts_event, UnixNanos::from(1_700_000_000_001_000_000));
825 }
826
827 #[rstest]
828 fn test_parse_public_ticker_frame() {
829 let payload = subscription_data_payload(
830 "ticker_slim.ETH-PERP.1000",
831 &load_json("perps/ws_ticker_slim_eth.json"),
832 );
833
834 let msg = parse_ticker_msg(&payload).unwrap();
835 let quote = parse_ticker_quote(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(789))
836 .unwrap();
837
838 assert_eq!(msg.channel.as_str(), "ticker_slim.ETH-PERP.1000");
839 assert_eq!(
840 msg.data.instrument_id(),
841 InstrumentId::from("ETH-PERP.DERIVE")
842 );
843 assert_eq!(msg.data.timestamp(), 1_779_953_796_714);
844 assert_eq!(quote.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
845 assert_eq!(quote.bid_price, price("1992.36"));
846 assert_eq!(quote.ask_price, price("1992.37"));
847 assert_eq!(quote.bid_size, quantity("1.505"));
848 assert_eq!(quote.ask_size, quantity("1.505"));
849 assert_eq!(quote.ts_event, UnixNanos::from(1_779_953_796_714_000_000));
850 }
851
852 #[rstest]
853 fn test_parse_spot_orderbook_frame() {
854 let mut data = load_json("spot/ws_orderbook_eth.json");
855 data["bids"] = json!([["2050.0", "1.20"], ["2049.5", "0.40"]]);
856 data["asks"] = json!([["2051.0", "0.80"]]);
857 let payload = subscription_data_payload("orderbook.ETH-USDC.1.10", &data);
858
859 let msg = parse_orderbook_msg(&payload).unwrap();
860 let deltas =
861 parse_orderbook_deltas(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
862 .unwrap();
863
864 assert_eq!(msg.channel.as_str(), "orderbook.ETH-USDC.1.10");
865 assert_eq!(
866 msg.data.instrument_id(),
867 InstrumentId::from("ETH-USDC.DERIVE")
868 );
869 assert_eq!(deltas.instrument_id, InstrumentId::from("ETH-USDC.DERIVE"));
870 assert_eq!(deltas.deltas.len(), 4);
871 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
872 assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy);
873 assert_eq!(deltas.deltas[1].order.price, price("2050.0"));
874 assert_eq!(deltas.deltas[1].order.size, quantity("1.20"));
875 assert_eq!(deltas.deltas[3].order.side, OrderSide::Sell);
876 assert_eq!(
877 deltas.deltas[3].flags,
878 RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
879 );
880 }
881
882 #[rstest]
883 fn test_parse_spot_trades_frame() {
884 let payload = subscription_data_payload(
885 "trades.erc20.ETH",
886 &json!([load_json("spot/ws_trade_eth.json")]),
887 );
888
889 let msg = parse_trades_msg(&payload).unwrap();
890 let tick = parse_trade_tick(
891 &msg.trades[0],
892 PRICE_PRECISION,
893 SIZE_PRECISION,
894 UnixNanos::from(456),
895 )
896 .unwrap();
897
898 assert_eq!(msg.channel.as_str(), "trades.erc20.ETH");
899 assert_eq!(msg.trades.len(), 1);
900 assert_eq!(tick.instrument_id, InstrumentId::from("ETH-USDC.DERIVE"));
901 assert_eq!(tick.price, price("2050"));
902 assert_eq!(tick.size, quantity("0.1"));
903 assert_eq!(tick.aggressor_side, AggressorSide::Seller);
904 assert_eq!(
905 tick.trade_id,
906 TradeId::from("0445f96a-10fb-4fdc-a0f9-eed94a2f32e1")
907 );
908 }
909
910 #[rstest]
911 fn test_parse_spot_ticker_slim_frame_handles_null_funding() {
912 let payload = subscription_data_payload(
913 "ticker_slim.ETH-USDC.1000",
914 &load_json("spot/ws_ticker_slim_eth.json"),
915 );
916
917 let msg = parse_ticker_msg(&payload).unwrap();
918 let quote = parse_ticker_quote(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(789))
919 .unwrap();
920
921 assert_eq!(msg.channel.as_str(), "ticker_slim.ETH-USDC.1000");
922 assert_eq!(
923 msg.data.instrument_id(),
924 InstrumentId::from("ETH-USDC.DERIVE")
925 );
926 assert_eq!(quote.instrument_id, InstrumentId::from("ETH-USDC.DERIVE"));
927
928 assert!(
929 parse_funding_rate(&msg, UnixNanos::from(789))
930 .unwrap()
931 .is_none()
932 );
933 let mark = parse_mark_price(&msg, PRICE_PRECISION, UnixNanos::from(789))
934 .unwrap()
935 .expect("spot slim ticker carries mark price");
936 let index = parse_index_price(&msg, PRICE_PRECISION, UnixNanos::from(789))
937 .unwrap()
938 .expect("spot slim ticker carries index price");
939 assert_eq!(mark.instrument_id, InstrumentId::from("ETH-USDC.DERIVE"));
940 assert_eq!(index.instrument_id, InstrumentId::from("ETH-USDC.DERIVE"));
941 }
942
943 #[rstest]
944 fn test_parse_public_ticker_direct_payload() {
945 let payload = subscription_data_payload(
946 "ticker.ETH-PERP.1000",
947 &ticker_json_with_timestamp(1_700_000_000_011),
948 );
949
950 let msg = parse_ticker_msg(&payload).unwrap();
951 let quote = parse_ticker_quote(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(790))
952 .unwrap();
953
954 assert_eq!(msg.channel.as_str(), "ticker.ETH-PERP.1000");
955 assert_eq!(msg.data.timestamp(), 1_700_000_000_011);
956 assert_eq!(
957 msg.data.instrument_id(),
958 InstrumentId::from("ETH-PERP.DERIVE")
959 );
960 assert_eq!(quote.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
961 assert_eq!(quote.ts_event, UnixNanos::from(1_700_000_000_011_000_000));
962 }
963
964 #[rstest]
965 fn test_parse_ticker_quote_uses_supplied_precision_when_wire_scale_varies() {
966 let mut ticker = ticker_json_with_timestamp(1_700_000_000_012);
967 ticker["best_bid_price"] = json!("3500");
968 ticker["best_ask_price"] = json!("3501");
969 ticker["best_bid_amount"] = json!("1");
970 ticker["best_ask_amount"] = json!("2");
971 let payload = subscription_data_payload("ticker.ETH-PERP.1000", &ticker);
972
973 let msg = parse_ticker_msg(&payload).unwrap();
974 let quote = parse_ticker_quote(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(790))
975 .unwrap();
976
977 assert_eq!(quote.bid_price, price("3500"));
978 assert_eq!(quote.ask_price, price("3501"));
979 assert_eq!(quote.bid_size, quantity("1"));
980 assert_eq!(quote.ask_size, quantity("2"));
981 assert_eq!(quote.bid_price.precision, PRICE_PRECISION);
982 assert_eq!(quote.bid_size.precision, SIZE_PRECISION);
983 }
984
985 #[rstest]
986 fn test_parse_ticker_quote_from_rest_emits_quote() {
987 let ticker: DeriveTickerSnapshot =
988 serde_json::from_value(ticker_json_with_timestamp(1_700_000_000_013)).unwrap();
989
990 let quote = parse_ticker_quote_from_rest(
991 &ticker,
992 PRICE_PRECISION,
993 SIZE_PRECISION,
994 UnixNanos::from(791),
995 )
996 .unwrap();
997
998 assert_eq!(quote.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
999 assert_eq!(quote.bid_price, price("3499.50"));
1000 assert_eq!(quote.ask_price, price("3501.00"));
1001 assert_eq!(quote.bid_size, quantity("0.80"));
1002 assert_eq!(quote.ask_size, quantity("1.20"));
1003 assert_eq!(quote.ts_event, UnixNanos::from(1_700_000_000_013_000_000));
1004 }
1005
1006 #[rstest]
1007 fn test_parse_ticker_quote_from_rest_rejects_negative_timestamp() {
1008 let mut value = ticker_json_with_timestamp(1_700_000_000_013);
1009 value["timestamp"] = json!(-1_i64);
1010 let ticker: DeriveTickerSnapshot = serde_json::from_value(value).unwrap();
1011
1012 let err = parse_ticker_quote_from_rest(
1013 &ticker,
1014 PRICE_PRECISION,
1015 SIZE_PRECISION,
1016 UnixNanos::from(791),
1017 )
1018 .expect_err("must reject negative timestamp");
1019 assert!(err.to_string().contains("negative Derive ticker timestamp"));
1020 }
1021
1022 #[rstest]
1023 fn test_parse_orderbook_deltas_empty_book_marks_clear_last() {
1024 let payload = subscription_data_payload(
1025 "orderbook.ETH-PERP.1.10",
1026 &orderbook_json(1_700_000_000_000, &json!([]), &json!([])),
1027 );
1028
1029 let msg = parse_orderbook_msg(&payload).unwrap();
1030 let deltas =
1031 parse_orderbook_deltas(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
1032 .unwrap();
1033
1034 assert_eq!(deltas.deltas.len(), 1);
1035 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1036 assert_eq!(
1037 deltas.deltas[0].flags,
1038 RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1039 );
1040 }
1041
1042 #[rstest]
1043 fn test_parse_orderbook_deltas_skips_zero_size_levels() {
1044 let payload = subscription_data_payload(
1045 "orderbook.ETH-PERP.1.10",
1046 &orderbook_json(
1047 1_700_000_000_000,
1048 &json!([["3499.50", "0"], ["3499.00", "0.40"]]),
1049 &json!([["3501.00", "0"]]),
1050 ),
1051 );
1052
1053 let msg = parse_orderbook_msg(&payload).unwrap();
1054 let deltas =
1055 parse_orderbook_deltas(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
1056 .unwrap();
1057
1058 assert_eq!(deltas.deltas.len(), 2);
1059 assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy);
1060 assert_eq!(deltas.deltas[1].order.price, price("3499.00"));
1061 assert_eq!(deltas.deltas[1].order.size, quantity("0.40"));
1062 assert_eq!(deltas.deltas[1].order.order_id, 1);
1063 assert_eq!(
1064 deltas.deltas[1].flags,
1065 RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1066 );
1067 }
1068
1069 #[rstest]
1070 fn test_parse_orderbook_deltas_uses_supplied_precision_when_wire_scale_varies() {
1071 let payload = subscription_data_payload(
1072 "orderbook.ETH-PERP.1.10",
1073 &orderbook_json(
1074 1_700_000_000_000,
1075 &json!([["3500", "1"]]),
1076 &json!([["3501", "2"]]),
1077 ),
1078 );
1079
1080 let msg = parse_orderbook_msg(&payload).unwrap();
1081 let deltas =
1082 parse_orderbook_deltas(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
1083 .unwrap();
1084
1085 assert_eq!(deltas.deltas[1].order.price, price("3500"));
1086 assert_eq!(deltas.deltas[1].order.size, quantity("1"));
1087 assert_eq!(deltas.deltas[2].order.price, price("3501"));
1088 assert_eq!(deltas.deltas[2].order.size, quantity("2"));
1089 assert_eq!(deltas.deltas[1].order.price.precision, PRICE_PRECISION);
1090 assert_eq!(deltas.deltas[1].order.size.precision, SIZE_PRECISION);
1091 }
1092
1093 #[rstest]
1094 fn test_parse_orderbook_depth10_skips_zero_sizes_caps_and_zero_fills() {
1095 let bids = Value::Array(
1096 (0..12)
1097 .map(|i| {
1098 let size = if i == 1 { "0" } else { "1" };
1099 json!([format!("{}", 3500 - i), size])
1100 })
1101 .collect(),
1102 );
1103 let asks = json!([["3501", "2"], ["3502", "0"], ["3503", "3"]]);
1104 let payload = subscription_data_payload(
1105 "orderbook.ETH-PERP.1.10",
1106 &orderbook_json(1_700_000_000_000, &bids, &asks),
1107 );
1108
1109 let msg = parse_orderbook_msg(&payload).unwrap();
1110 let depth =
1111 parse_orderbook_depth10(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
1112 .unwrap();
1113
1114 assert_eq!(depth.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
1115 assert_eq!(depth.bids[0].price, price("3500"));
1116 assert_eq!(depth.bids[1].price, price("3498"));
1117 assert_eq!(depth.bids[9].price, price("3490"));
1118 assert_eq!(depth.bid_counts[0], 1);
1119 assert_eq!(depth.bid_counts[9], 1);
1120 assert_eq!(depth.asks[0].price, price("3501"));
1121 assert_eq!(depth.asks[1].price, price("3503"));
1122 assert_eq!(depth.asks[2].price, Price::zero(PRICE_PRECISION));
1123 assert_eq!(depth.asks[2].size, Quantity::zero(SIZE_PRECISION));
1124 assert_eq!(depth.ask_counts[0], 1);
1125 assert_eq!(depth.ask_counts[1], 1);
1126 assert_eq!(depth.ask_counts[2], 0);
1127 assert_eq!(depth.sequence, 1_700_000_000_000);
1128 assert_eq!(depth.flags, RecordFlag::F_SNAPSHOT as u8);
1129 assert_eq!(depth.ts_event, UnixNanos::from(1_700_000_000_000_000_000));
1130 }
1131
1132 #[rstest]
1133 fn test_parse_trade_tick_maps_sell_direction() {
1134 let payload = subscription_data_payload(
1135 "trades.perp.ETH",
1136 &json!([trade_json(1_700_000_000_001, "sell")]),
1137 );
1138
1139 let msg = parse_trades_msg(&payload).unwrap();
1140 let tick = parse_trade_tick(
1141 &msg.trades[0],
1142 PRICE_PRECISION,
1143 SIZE_PRECISION,
1144 UnixNanos::from(456),
1145 )
1146 .unwrap();
1147
1148 assert_eq!(tick.aggressor_side, AggressorSide::Seller);
1149 }
1150
1151 #[rstest]
1152 fn test_parse_trade_tick_uses_supplied_precision_when_wire_scale_varies() {
1153 let payload = subscription_data_payload(
1154 "trades.perp.ETH",
1155 &json!([trade_json_with_values(
1156 1_700_000_000_001,
1157 "buy",
1158 "3500",
1159 "1"
1160 )]),
1161 );
1162
1163 let msg = parse_trades_msg(&payload).unwrap();
1164 let tick = parse_trade_tick(
1165 &msg.trades[0],
1166 PRICE_PRECISION,
1167 SIZE_PRECISION,
1168 UnixNanos::from(456),
1169 )
1170 .unwrap();
1171
1172 assert_eq!(tick.price, price("3500"));
1173 assert_eq!(tick.size, quantity("1"));
1174 assert_eq!(tick.price.precision, PRICE_PRECISION);
1175 assert_eq!(tick.size.precision, SIZE_PRECISION);
1176 }
1177
1178 #[rstest]
1179 fn test_parse_public_ws_data_dispatches_orderbook_channel() {
1180 let payload = subscription_data_payload(
1181 "orderbook.ETH-PERP.1.10",
1182 &orderbook_json(1_700_000_000_000, &json!([]), &json!([])),
1183 );
1184
1185 let parsed = parse_public_ws_data(&payload).unwrap();
1186
1187 match parsed {
1188 DerivePublicWsData::Orderbook(msg) => {
1189 assert_eq!(msg.channel.as_str(), "orderbook.ETH-PERP.1.10");
1190 assert_eq!(
1191 msg.data.instrument_id(),
1192 InstrumentId::from("ETH-PERP.DERIVE")
1193 );
1194 }
1195 other => panic!("expected orderbook data, was {other:?}"),
1196 }
1197 }
1198
1199 #[rstest]
1200 fn test_parse_public_ws_data_dispatches_trades_channel() {
1201 let payload = subscription_data_payload("trades.perp.ETH", &json!([]));
1202
1203 let parsed = parse_public_ws_data(&payload).unwrap();
1204
1205 match parsed {
1206 DerivePublicWsData::Trades(msg) => assert!(msg.trades.is_empty()),
1207 other => panic!("expected trades data, was {other:?}"),
1208 }
1209 }
1210
1211 #[rstest]
1212 fn test_parse_public_ws_data_dispatches_ticker_channel() {
1213 let payload = subscription_data_payload(
1214 "ticker_slim.ETH-PERP.1000",
1215 &load_json("perps/ws_ticker_slim_eth.json"),
1216 );
1217
1218 let parsed = parse_public_ws_data(&payload).unwrap();
1219
1220 match parsed {
1221 DerivePublicWsData::Ticker(msg) => {
1222 assert_eq!(msg.channel.as_str(), "ticker_slim.ETH-PERP.1000");
1223 assert_eq!(
1224 msg.data.instrument_id(),
1225 InstrumentId::from("ETH-PERP.DERIVE")
1226 );
1227 }
1228 other => panic!("expected ticker data, was {other:?}"),
1229 }
1230 }
1231
1232 #[rstest]
1233 fn test_parse_orderbook_msg_rejects_malformed_payload() {
1234 let payload = subscription_data_payload(
1235 "orderbook.ETH-PERP.1.10",
1236 &json!({
1237 "instrument_name": "ETH-PERP",
1238 "timestamp": 1_700_000_000_000_i64,
1239 "bids": []
1240 }),
1241 );
1242
1243 let err = parse_orderbook_msg(&payload).expect_err("must reject malformed orderbook");
1244
1245 assert!(
1246 err.to_string()
1247 .contains("failed to decode Derive orderbook data")
1248 );
1249 }
1250
1251 #[rstest]
1252 fn test_parse_trades_msg_rejects_malformed_payload() {
1253 let payload = subscription_data_payload("trades.perp.ETH", &json!({}));
1254
1255 let err = parse_trades_msg(&payload).expect_err("must reject malformed trades");
1256
1257 assert!(
1258 err.to_string()
1259 .contains("failed to decode Derive trades data")
1260 );
1261 }
1262
1263 #[rstest]
1264 fn test_parse_ticker_msg_rejects_malformed_payload() {
1265 let payload = subscription_data_payload(
1266 "ticker.ETH-PERP.1000",
1267 &json!({
1268 "timestamp": 1_700_000_000_010_i64
1269 }),
1270 );
1271
1272 let err = parse_ticker_msg(&payload).expect_err("must reject malformed ticker");
1273
1274 assert!(
1275 err.to_string()
1276 .contains("failed to decode Derive ticker data")
1277 );
1278 }
1279
1280 #[rstest]
1281 #[case("ticker_slim.ETH-PERP")]
1282 #[case("ticker_slim..1000")]
1283 fn test_parse_ticker_msg_rejects_malformed_slim_channel(#[case] channel: &str) {
1284 let payload =
1285 subscription_data_payload(channel, &load_json("perps/ws_ticker_slim_eth.json"));
1286
1287 let err = parse_ticker_msg(&payload).expect_err("must reject malformed slim channel");
1288
1289 assert!(err.to_string().contains("invalid Derive ticker channel"));
1290 }
1291
1292 #[rstest]
1293 fn test_parse_orderbook_deltas_rejects_negative_timestamp() {
1294 let payload = subscription_data_payload(
1295 "orderbook.ETH-PERP.1.10",
1296 &orderbook_json(-1, &json!([]), &json!([])),
1297 );
1298
1299 let msg = parse_orderbook_msg(&payload).unwrap();
1300 let err =
1301 parse_orderbook_deltas(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
1302 .expect_err("must reject negative orderbook timestamp");
1303
1304 assert!(
1305 err.to_string()
1306 .contains("negative Derive orderbook timestamp")
1307 );
1308 }
1309
1310 #[rstest]
1311 fn test_parse_orderbook_deltas_rejects_timestamp_overflow() {
1312 let payload = subscription_data_payload(
1313 "orderbook.ETH-PERP.1.10",
1314 &orderbook_json(i64::MAX, &json!([]), &json!([])),
1315 );
1316
1317 let msg = parse_orderbook_msg(&payload).unwrap();
1318 let err =
1319 parse_orderbook_deltas(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(123))
1320 .expect_err("must reject overflowing orderbook timestamp");
1321
1322 assert!(
1323 err.to_string()
1324 .contains("Derive timestamp overflows nanoseconds")
1325 );
1326 }
1327
1328 #[rstest]
1329 fn test_parse_orderbook_deltas_rejects_invalid_size_precision() {
1330 let payload = subscription_data_payload(
1331 "orderbook.ETH-PERP.1.10",
1332 &orderbook_json(
1333 1_700_000_000_000,
1334 &json!([["3500", "1"]]),
1335 &json!([["3501", "2"]]),
1336 ),
1337 );
1338
1339 let msg = parse_orderbook_msg(&payload).unwrap();
1340 let err = parse_orderbook_deltas(
1341 &msg,
1342 PRICE_PRECISION,
1343 INVALID_PRECISION,
1344 UnixNanos::from(123),
1345 )
1346 .expect_err("must reject invalid orderbook size precision");
1347
1348 assert!(err.to_string().contains("invalid Derive orderbook amount"));
1349 }
1350
1351 #[rstest]
1352 fn test_parse_trade_tick_rejects_negative_timestamp() {
1353 let payload = subscription_data_payload("trades.perp.ETH", &json!([trade_json(-1, "buy")]));
1354
1355 let msg = parse_trades_msg(&payload).unwrap();
1356 let err = parse_trade_tick(
1357 &msg.trades[0],
1358 PRICE_PRECISION,
1359 SIZE_PRECISION,
1360 UnixNanos::from(456),
1361 )
1362 .expect_err("must reject negative trade timestamp");
1363
1364 assert!(err.to_string().contains("negative Derive trade timestamp"));
1365 }
1366
1367 #[rstest]
1368 fn test_parse_trade_tick_rejects_timestamp_overflow() {
1369 let payload =
1370 subscription_data_payload("trades.perp.ETH", &json!([trade_json(i64::MAX, "buy")]));
1371
1372 let msg = parse_trades_msg(&payload).unwrap();
1373 let err = parse_trade_tick(
1374 &msg.trades[0],
1375 PRICE_PRECISION,
1376 SIZE_PRECISION,
1377 UnixNanos::from(456),
1378 )
1379 .expect_err("must reject overflowing trade timestamp");
1380
1381 assert!(
1382 err.to_string()
1383 .contains("Derive timestamp overflows nanoseconds")
1384 );
1385 }
1386
1387 #[rstest]
1388 fn test_parse_trade_tick_rejects_invalid_price_precision() {
1389 let payload = subscription_data_payload(
1390 "trades.perp.ETH",
1391 &json!([trade_json(1_700_000_000_001, "buy")]),
1392 );
1393
1394 let msg = parse_trades_msg(&payload).unwrap();
1395 let err = parse_trade_tick(
1396 &msg.trades[0],
1397 INVALID_PRECISION,
1398 SIZE_PRECISION,
1399 UnixNanos::from(456),
1400 )
1401 .expect_err("must reject invalid trade price precision");
1402
1403 assert!(err.to_string().contains("invalid trade price for ETH-PERP"));
1404 }
1405
1406 #[rstest]
1407 fn test_parse_ticker_quote_rejects_negative_timestamp() {
1408 let payload = subscription_data_payload(
1409 "ticker.ETH-PERP.1000",
1410 &json!({
1411 "timestamp": -1_i64,
1412 "instrument_ticker": ticker_json()
1413 }),
1414 );
1415
1416 let msg = parse_ticker_msg(&payload).unwrap();
1417 let err = parse_ticker_quote(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(789))
1418 .expect_err("must reject negative ticker timestamp");
1419
1420 assert!(err.to_string().contains("negative Derive ticker timestamp"));
1421 }
1422
1423 #[rstest]
1424 fn test_parse_ticker_quote_rejects_timestamp_overflow() {
1425 let payload = subscription_data_payload(
1426 "ticker.ETH-PERP.1000",
1427 &json!({
1428 "timestamp": i64::MAX,
1429 "instrument_ticker": ticker_json()
1430 }),
1431 );
1432
1433 let msg = parse_ticker_msg(&payload).unwrap();
1434 let err = parse_ticker_quote(&msg, PRICE_PRECISION, SIZE_PRECISION, UnixNanos::from(789))
1435 .expect_err("must reject overflowing ticker timestamp");
1436
1437 assert!(
1438 err.to_string()
1439 .contains("Derive timestamp overflows nanoseconds")
1440 );
1441 }
1442
1443 #[rstest]
1444 fn test_parse_public_ws_data_rejects_unknown_channel() {
1445 let payload = WsSubscriptionPayload {
1446 channel: Ustr::from("wallet.ETH"),
1447 data: serde_json::value::to_raw_value(&json!({})).unwrap(),
1448 };
1449
1450 let err = parse_public_ws_data(&payload).expect_err("must reject unknown channel");
1451
1452 assert!(
1453 err.to_string()
1454 .contains("unsupported Derive public WS channel")
1455 );
1456 }
1457
1458 fn option_ticker_json(timestamp: i64) -> Value {
1459 let mut value = load_json("options/http_ticker_eth_snapshot.json");
1460 value["timestamp"] = json!(timestamp);
1461 value
1462 }
1463
1464 fn perp_envelope_payload(timestamp: i64) -> WsSubscriptionPayload {
1465 subscription_data_payload(
1466 "ticker.ETH-PERP.1000",
1467 &json!({
1468 "timestamp": timestamp,
1469 "instrument_ticker": ticker_json_with_timestamp(timestamp),
1470 }),
1471 )
1472 }
1473
1474 fn option_envelope_payload(timestamp: i64) -> WsSubscriptionPayload {
1475 let mut option_data = option_ticker_json(timestamp);
1476 option_data["instrument_name"] = json!("ETH-20260627-3500-C");
1477 subscription_data_payload(
1478 "ticker.ETH-20260627-3500-C.1000",
1479 &json!({
1480 "timestamp": timestamp,
1481 "instrument_ticker": option_data,
1482 }),
1483 )
1484 }
1485
1486 fn slim_payload() -> WsSubscriptionPayload {
1487 subscription_data_payload(
1488 "ticker_slim.ETH-PERP.1000",
1489 &load_json("perps/ws_ticker_slim_eth.json"),
1490 )
1491 }
1492
1493 #[rstest]
1494 fn test_parse_mark_price_maps_slim_variant() {
1495 let msg = parse_ticker_msg(&slim_payload()).unwrap();
1496
1497 let update = parse_mark_price(&msg, PRICE_PRECISION, UnixNanos::from(789))
1498 .unwrap()
1499 .expect("slim ticker carries mark price");
1500
1501 assert_eq!(update.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
1502 assert_eq!(update.value, price("1992.49"));
1503 assert_eq!(update.ts_event, UnixNanos::from(1_779_953_796_714_000_000));
1504 assert_eq!(update.ts_init, UnixNanos::from(789));
1505 }
1506
1507 #[rstest]
1508 fn test_parse_index_price_maps_slim_variant() {
1509 let msg = parse_ticker_msg(&slim_payload()).unwrap();
1510
1511 let update = parse_index_price(&msg, PRICE_PRECISION, UnixNanos::from(789))
1512 .unwrap()
1513 .expect("slim ticker carries index price");
1514
1515 assert_eq!(update.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
1516 assert_eq!(update.value, price("1991.79"));
1517 assert_eq!(update.ts_event, UnixNanos::from(1_779_953_796_714_000_000));
1518 assert_eq!(update.ts_init, UnixNanos::from(789));
1519 }
1520
1521 #[rstest]
1522 fn test_parse_funding_rate_maps_slim_variant() {
1523 let msg = parse_ticker_msg(&slim_payload()).unwrap();
1524
1525 let update = parse_funding_rate(&msg, UnixNanos::from(789))
1526 .unwrap()
1527 .expect("slim ticker carries perp funding");
1528
1529 assert_eq!(update.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
1530 assert_eq!(update.rate, Decimal::from_str("0.000012500").unwrap());
1531 assert_eq!(update.ts_event, UnixNanos::from(1_779_953_796_714_000_000));
1532 assert_eq!(update.ts_init, UnixNanos::from(789));
1533 }
1534
1535 #[rstest]
1536 fn test_parse_option_greeks_returns_none_for_slim_variant_without_option_pricing() {
1537 let msg = parse_ticker_msg(&slim_payload()).unwrap();
1538
1539 let result = parse_option_greeks(&msg, UnixNanos::from(789)).unwrap();
1540
1541 assert!(result.is_none());
1542 }
1543
1544 fn option_slim_payload(filename: &str, instrument_name: &str) -> WsSubscriptionPayload {
1545 subscription_data_payload(
1546 &format!("ticker_slim.{instrument_name}.1000"),
1547 &load_json(filename),
1548 )
1549 }
1550
1551 #[rstest]
1552 fn test_parse_option_greeks_maps_slim_variant() {
1553 let msg = parse_ticker_msg(&option_slim_payload(
1554 "options/ws_ticker_slim_eth_call.json",
1555 "ETH-20260612-1600-C",
1556 ))
1557 .unwrap();
1558
1559 let greeks = parse_option_greeks(&msg, UnixNanos::from(789))
1560 .unwrap()
1561 .expect("slim ticker carries option pricing");
1562
1563 assert_eq!(
1564 greeks.instrument_id,
1565 InstrumentId::from("ETH-20260612-1600-C.DERIVE")
1566 );
1567 assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
1568 assert!((greeks.greeks.delta - 0.95222).abs() < 1e-9);
1569 assert!((greeks.greeks.gamma - 0.00036344).abs() < 1e-9);
1570 assert_eq!(greeks.mark_iv, Some(0.67698));
1571 assert_eq!(greeks.bid_iv, Some(0.0));
1572 assert_eq!(greeks.ask_iv, Some(0.88815));
1573 assert_eq!(greeks.underlying_price, Some(1992.6));
1574 assert_eq!(greeks.open_interest, Some(0.0));
1575 assert_eq!(greeks.ts_event, UnixNanos::from(1_779_953_796_231_000_000));
1576 assert_eq!(greeks.ts_init, UnixNanos::from(789));
1577 }
1578
1579 #[rstest]
1580 fn test_parse_option_greeks_maps_slim_put_variant() {
1581 let msg = parse_ticker_msg(&option_slim_payload(
1582 "options/ws_ticker_slim_eth_put.json",
1583 "ETH-20260612-1900-P",
1584 ))
1585 .unwrap();
1586
1587 let greeks = parse_option_greeks(&msg, UnixNanos::from(789))
1588 .unwrap()
1589 .expect("slim ticker carries put option pricing");
1590
1591 assert_eq!(
1592 greeks.instrument_id,
1593 InstrumentId::from("ETH-20260612-1900-P.DERIVE")
1594 );
1595 assert!((greeks.greeks.delta + 0.30438).abs() < 1e-9);
1596 assert!((greeks.greeks.gamma - 0.00169741).abs() < 1e-9);
1597 assert_eq!(greeks.mark_iv, Some(0.51012));
1598 assert_eq!(greeks.bid_iv, Some(0.48229));
1599 assert_eq!(greeks.ask_iv, Some(0.52063));
1600 assert_eq!(greeks.underlying_price, Some(1992.6));
1601 assert_eq!(greeks.open_interest, Some(42.13));
1602 assert_eq!(greeks.ts_event, UnixNanos::from(1_779_953_797_040_000_000));
1603 assert_eq!(greeks.ts_init, UnixNanos::from(789));
1604 }
1605
1606 #[rstest]
1607 fn test_parse_funding_rate_returns_none_for_option_payload() {
1608 let msg = parse_ticker_msg(&option_envelope_payload(1_700_000_000_010)).unwrap();
1609
1610 let result = parse_funding_rate(&msg, UnixNanos::from(789)).unwrap();
1611
1612 assert!(result.is_none());
1613 }
1614
1615 #[rstest]
1616 fn test_parse_option_greeks_returns_none_for_perp_payload() {
1617 let msg = parse_ticker_msg(&perp_envelope_payload(1_700_000_000_010)).unwrap();
1618
1619 let result = parse_option_greeks(&msg, UnixNanos::from(789)).unwrap();
1620
1621 assert!(result.is_none());
1622 }
1623
1624 #[rstest]
1625 fn test_parse_option_greeks_open_interest_none_when_stats_absent() {
1626 let timestamp = 1_700_000_000_010_i64;
1630 let mut option_data = option_ticker_json(timestamp);
1631 option_data["instrument_name"] = json!("ETH-20260627-3500-C");
1632 option_data["stats"] = json!(null);
1633 let payload = subscription_data_payload(
1634 "ticker.ETH-20260627-3500-C.1000",
1635 &json!({
1636 "timestamp": timestamp,
1637 "instrument_ticker": option_data,
1638 }),
1639 );
1640 let msg = parse_ticker_msg(&payload).unwrap();
1641
1642 let greeks = parse_option_greeks(&msg, UnixNanos::from(789))
1643 .unwrap()
1644 .expect("option greeks present when option_pricing is set");
1645 assert!(greeks.open_interest.is_none());
1646 assert!((greeks.greeks.delta - 0.55).abs() < 1e-9);
1648 assert!(greeks.mark_iv.is_some());
1649 assert!(greeks.underlying_price.is_some());
1650 }
1651
1652 #[rstest]
1653 fn test_parse_mark_price_rejects_negative_timestamp() {
1654 let payload = subscription_data_payload(
1655 "ticker.ETH-PERP.1000",
1656 &json!({
1657 "timestamp": -1_i64,
1658 "instrument_ticker": ticker_json(),
1659 }),
1660 );
1661 let msg = parse_ticker_msg(&payload).unwrap();
1662
1663 let err = parse_mark_price(&msg, PRICE_PRECISION, UnixNanos::from(789))
1664 .expect_err("must reject negative ticker timestamp");
1665
1666 assert!(err.to_string().contains("negative Derive ticker timestamp"));
1667 }
1668
1669 #[rstest]
1670 fn test_parse_mark_price_rejects_timestamp_overflow() {
1671 let payload = subscription_data_payload(
1672 "ticker.ETH-PERP.1000",
1673 &json!({
1674 "timestamp": i64::MAX,
1675 "instrument_ticker": ticker_json(),
1676 }),
1677 );
1678 let msg = parse_ticker_msg(&payload).unwrap();
1679
1680 let err = parse_mark_price(&msg, PRICE_PRECISION, UnixNanos::from(789))
1681 .expect_err("must reject overflowing ticker timestamp");
1682
1683 assert!(
1684 err.to_string()
1685 .contains("Derive timestamp overflows nanoseconds")
1686 );
1687 }
1688
1689 #[rstest]
1690 fn test_parse_index_price_rejects_negative_timestamp() {
1691 let payload = subscription_data_payload(
1692 "ticker.ETH-PERP.1000",
1693 &json!({
1694 "timestamp": -1_i64,
1695 "instrument_ticker": ticker_json(),
1696 }),
1697 );
1698 let msg = parse_ticker_msg(&payload).unwrap();
1699
1700 let err = parse_index_price(&msg, PRICE_PRECISION, UnixNanos::from(789))
1701 .expect_err("must reject negative ticker timestamp");
1702
1703 assert!(err.to_string().contains("negative Derive ticker timestamp"));
1704 }
1705
1706 #[rstest]
1707 fn test_parse_index_price_rejects_timestamp_overflow() {
1708 let payload = subscription_data_payload(
1709 "ticker.ETH-PERP.1000",
1710 &json!({
1711 "timestamp": i64::MAX,
1712 "instrument_ticker": ticker_json(),
1713 }),
1714 );
1715 let msg = parse_ticker_msg(&payload).unwrap();
1716
1717 let err = parse_index_price(&msg, PRICE_PRECISION, UnixNanos::from(789))
1718 .expect_err("must reject overflowing ticker timestamp");
1719
1720 assert!(
1721 err.to_string()
1722 .contains("Derive timestamp overflows nanoseconds")
1723 );
1724 }
1725
1726 #[rstest]
1727 fn test_parse_funding_rate_rejects_negative_timestamp() {
1728 let payload = subscription_data_payload(
1729 "ticker.ETH-PERP.1000",
1730 &json!({
1731 "timestamp": -1_i64,
1732 "instrument_ticker": ticker_json(),
1733 }),
1734 );
1735 let msg = parse_ticker_msg(&payload).unwrap();
1736
1737 let err = parse_funding_rate(&msg, UnixNanos::from(789))
1738 .expect_err("must reject negative ticker timestamp");
1739
1740 assert!(err.to_string().contains("negative Derive ticker timestamp"));
1741 }
1742
1743 #[rstest]
1744 fn test_parse_funding_rate_rejects_timestamp_overflow() {
1745 let payload = subscription_data_payload(
1746 "ticker.ETH-PERP.1000",
1747 &json!({
1748 "timestamp": i64::MAX,
1749 "instrument_ticker": ticker_json(),
1750 }),
1751 );
1752 let msg = parse_ticker_msg(&payload).unwrap();
1753
1754 let err = parse_funding_rate(&msg, UnixNanos::from(789))
1755 .expect_err("must reject overflowing ticker timestamp");
1756
1757 assert!(
1758 err.to_string()
1759 .contains("Derive timestamp overflows nanoseconds")
1760 );
1761 }
1762
1763 #[rstest]
1764 fn test_parse_funding_rate_history_record_maps_fields() {
1765 let record = DerivePublicFundingRate {
1766 funding_rate: Decimal::from_str("0.00015").unwrap(),
1767 timestamp: 1_700_000_000_000,
1768 };
1769 let instrument_id = InstrumentId::from("ETH-PERP.DERIVE");
1770
1771 let update = parse_funding_rate_history_record(
1772 &record,
1773 instrument_id,
1774 Some(60),
1775 UnixNanos::from(789),
1776 )
1777 .unwrap();
1778
1779 assert_eq!(update.instrument_id, instrument_id);
1780 assert_eq!(update.rate, Decimal::from_str("0.00015").unwrap());
1781 assert_eq!(update.interval, Some(60));
1782 assert!(update.next_funding_ns.is_none());
1783 assert_eq!(update.ts_event, UnixNanos::from(1_700_000_000_000_000_000));
1784 assert_eq!(update.ts_init, UnixNanos::from(789));
1785 }
1786
1787 #[rstest]
1788 fn test_parse_funding_rate_history_record_rejects_negative_timestamp() {
1789 let record = DerivePublicFundingRate {
1790 funding_rate: Decimal::from_str("0.0001").unwrap(),
1791 timestamp: -1,
1792 };
1793 let err = parse_funding_rate_history_record(
1794 &record,
1795 InstrumentId::from("ETH-PERP.DERIVE"),
1796 None,
1797 UnixNanos::from(789),
1798 )
1799 .expect_err("must reject negative timestamp");
1800
1801 assert!(err.to_string().contains("negative Derive ticker timestamp"));
1802 }
1803
1804 #[rstest]
1805 fn test_parse_candle_record_maps_fields() {
1806 let record = DerivePublicCandle {
1809 open_price: Decimal::from_str("3500.0").unwrap(),
1810 high_price: Decimal::from_str("3501.5").unwrap(),
1811 low_price: Decimal::from_str("3499.0").unwrap(),
1812 close_price: Decimal::from_str("3501.0").unwrap(),
1813 volume_usd: Decimal::from_str("12345.6").unwrap(),
1814 volume_contracts: Decimal::from_str("3.527").unwrap(),
1815 timestamp: 1_700_000_007,
1816 timestamp_bucket: 1_700_000_000,
1817 };
1818 let bar_type = BarType::from("ETH-PERP.DERIVE-1-MINUTE-LAST-EXTERNAL");
1819
1820 let bar = parse_candle_record(
1821 &record,
1822 bar_type,
1823 PRICE_PRECISION,
1824 SIZE_PRECISION,
1825 UnixNanos::from(789),
1826 )
1827 .unwrap();
1828
1829 assert_eq!(bar.bar_type, bar_type);
1830 assert_eq!(bar.open, Price::from_str("3500.00").unwrap());
1831 assert_eq!(bar.high, Price::from_str("3501.50").unwrap());
1832 assert_eq!(bar.low, Price::from_str("3499.00").unwrap());
1833 assert_eq!(bar.close, Price::from_str("3501.00").unwrap());
1834 assert_eq!(bar.volume, Quantity::from_str("3.527").unwrap());
1835 assert_eq!(bar.ts_event, UnixNanos::from(1_700_000_000_000_000_000));
1836 assert_eq!(bar.ts_init, UnixNanos::from(789));
1837 }
1838
1839 #[rstest]
1840 fn test_parse_candle_record_rejects_negative_timestamp() {
1841 let record = DerivePublicCandle {
1842 open_price: Decimal::from_str("1").unwrap(),
1843 high_price: Decimal::from_str("1").unwrap(),
1844 low_price: Decimal::from_str("1").unwrap(),
1845 close_price: Decimal::from_str("1").unwrap(),
1846 volume_usd: Decimal::ZERO,
1847 volume_contracts: Decimal::ZERO,
1848 timestamp: 1_700_000_000,
1849 timestamp_bucket: -1,
1850 };
1851 let err = parse_candle_record(
1852 &record,
1853 BarType::from("ETH-PERP.DERIVE-1-MINUTE-LAST-EXTERNAL"),
1854 PRICE_PRECISION,
1855 SIZE_PRECISION,
1856 UnixNanos::from(789),
1857 )
1858 .expect_err("must reject negative timestamp");
1859
1860 assert!(err.to_string().contains("negative Derive candle timestamp"));
1861 }
1862
1863 #[rstest]
1864 fn test_parse_candle_record_rejects_timestamp_overflow() {
1865 let record = DerivePublicCandle {
1866 open_price: Decimal::from_str("1").unwrap(),
1867 high_price: Decimal::from_str("1").unwrap(),
1868 low_price: Decimal::from_str("1").unwrap(),
1869 close_price: Decimal::from_str("1").unwrap(),
1870 volume_usd: Decimal::ZERO,
1871 volume_contracts: Decimal::ZERO,
1872 timestamp: 1_700_000_000,
1873 timestamp_bucket: i64::MAX,
1874 };
1875 let err = parse_candle_record(
1876 &record,
1877 BarType::from("ETH-PERP.DERIVE-1-MINUTE-LAST-EXTERNAL"),
1878 PRICE_PRECISION,
1879 SIZE_PRECISION,
1880 UnixNanos::from(789),
1881 )
1882 .expect_err("must reject overflowing timestamp");
1883
1884 assert!(
1885 err.to_string()
1886 .contains("Derive candle timestamp_bucket overflows nanoseconds"),
1887 "{err}",
1888 );
1889 }
1890
1891 #[rstest]
1892 #[case(BarAggregation::Minute, 1, 60)]
1893 #[case(BarAggregation::Minute, 5, 300)]
1894 #[case(BarAggregation::Minute, 15, 900)]
1895 #[case(BarAggregation::Minute, 30, 1800)]
1896 #[case(BarAggregation::Hour, 1, 3600)]
1897 #[case(BarAggregation::Hour, 4, 14400)]
1898 #[case(BarAggregation::Hour, 8, 28800)]
1899 #[case(BarAggregation::Day, 1, 86400)]
1900 #[case(BarAggregation::Week, 1, 604800)]
1901 fn test_bar_spec_to_derive_period_maps_supported_intervals(
1902 #[case] aggregation: BarAggregation,
1903 #[case] step: u64,
1904 #[case] expected: u32,
1905 ) {
1906 assert_eq!(
1907 bar_spec_to_derive_period(aggregation, step).unwrap(),
1908 expected
1909 );
1910 }
1911
1912 #[rstest]
1913 #[case(BarAggregation::Minute, 2, "minute intervals")]
1914 #[case(BarAggregation::Hour, 2, "hour intervals")]
1915 #[case(BarAggregation::Day, 7, "1 DAY interval")]
1916 #[case(BarAggregation::Week, 2, "1 WEEK interval")]
1917 #[case(BarAggregation::Second, 1, "does not support")]
1918 fn test_bar_spec_to_derive_period_rejects_unsupported(
1919 #[case] aggregation: BarAggregation,
1920 #[case] step: u64,
1921 #[case] expected_msg: &str,
1922 ) {
1923 let err =
1924 bar_spec_to_derive_period(aggregation, step).expect_err("must reject unsupported spec");
1925 assert!(
1926 err.to_string().contains(expected_msg),
1927 "expected {expected_msg:?}, was {err}",
1928 );
1929 }
1930
1931 #[rstest]
1932 fn test_parse_option_greeks_rejects_negative_timestamp() {
1933 let mut option_data = option_ticker_json(1_700_000_000_000);
1934 option_data["instrument_name"] = json!("ETH-20260627-3500-C");
1935 let payload = subscription_data_payload(
1936 "ticker.ETH-20260627-3500-C.1000",
1937 &json!({
1938 "timestamp": -1_i64,
1939 "instrument_ticker": option_data,
1940 }),
1941 );
1942 let msg = parse_ticker_msg(&payload).unwrap();
1943
1944 let err = parse_option_greeks(&msg, UnixNanos::from(789))
1945 .expect_err("must reject negative ticker timestamp");
1946
1947 assert!(err.to_string().contains("negative Derive ticker timestamp"));
1948 }
1949
1950 #[rstest]
1951 fn test_parse_option_greeks_rejects_timestamp_overflow() {
1952 let mut option_data = option_ticker_json(1_700_000_000_000);
1953 option_data["instrument_name"] = json!("ETH-20260627-3500-C");
1954 let payload = subscription_data_payload(
1955 "ticker.ETH-20260627-3500-C.1000",
1956 &json!({
1957 "timestamp": i64::MAX,
1958 "instrument_ticker": option_data,
1959 }),
1960 );
1961 let msg = parse_ticker_msg(&payload).unwrap();
1962
1963 let err = parse_option_greeks(&msg, UnixNanos::from(789))
1964 .expect_err("must reject overflowing ticker timestamp");
1965
1966 assert!(
1967 err.to_string()
1968 .contains("Derive timestamp overflows nanoseconds")
1969 );
1970 }
1971}