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