1use 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
49pub 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
72pub 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
86pub 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
100pub 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
116pub 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
190pub 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
244pub 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
265pub 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
334pub 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
373pub 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
499pub 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
521pub 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
543pub 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
570pub 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
592pub 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
632pub 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
683pub 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 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 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 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}