Skip to main content

fin_stream/tick/
mod.rs

1//! Tick normalization — raw exchange messages → canonical NormalizedTick.
2//!
3//! ## Responsibility
4//! Convert heterogeneous exchange tick formats (Binance, Coinbase, Alpaca,
5//! Polygon) into a single canonical representation. This stage must add
6//! <1μs overhead per tick on the hot path.
7//!
8//! ## Guarantees
9//! - Deterministic: same raw bytes always produce the same NormalizedTick
10//! - Non-allocating hot path: NormalizedTick is stack-allocated
11//! - Thread-safe: TickNormalizer is Send + Sync
12
13use crate::error::StreamError;
14use chrono::DateTime;
15use rust_decimal::Decimal;
16use std::str::FromStr;
17use tracing::trace;
18
19/// Supported exchanges.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
21pub enum Exchange {
22    /// Binance spot/futures WebSocket feed.
23    Binance,
24    /// Coinbase Advanced Trade WebSocket feed.
25    Coinbase,
26    /// Alpaca Markets data stream.
27    Alpaca,
28    /// Polygon.io WebSocket feed.
29    Polygon,
30}
31
32impl Exchange {
33    /// Returns a slice of all supported exchanges.
34    ///
35    /// Useful for iterating all exchanges to register feeds, build UI dropdowns,
36    /// or validate config values at startup.
37    pub fn all() -> &'static [Exchange] {
38        &[
39            Exchange::Binance,
40            Exchange::Coinbase,
41            Exchange::Alpaca,
42            Exchange::Polygon,
43        ]
44    }
45
46    /// Returns `true` if this exchange primarily trades cryptocurrency spot/futures.
47    ///
48    /// [`Binance`](Exchange::Binance) and [`Coinbase`](Exchange::Coinbase) are
49    /// crypto venues. [`Alpaca`](Exchange::Alpaca) and [`Polygon`](Exchange::Polygon)
50    /// are primarily equity/US-market feeds.
51    pub fn is_crypto(self) -> bool {
52        matches!(self, Exchange::Binance | Exchange::Coinbase)
53    }
54}
55
56impl std::fmt::Display for Exchange {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            Exchange::Binance => write!(f, "Binance"),
60            Exchange::Coinbase => write!(f, "Coinbase"),
61            Exchange::Alpaca => write!(f, "Alpaca"),
62            Exchange::Polygon => write!(f, "Polygon"),
63        }
64    }
65}
66
67impl FromStr for Exchange {
68    type Err = StreamError;
69    fn from_str(s: &str) -> Result<Self, Self::Err> {
70        match s.to_lowercase().as_str() {
71            "binance" => Ok(Exchange::Binance),
72            "coinbase" => Ok(Exchange::Coinbase),
73            "alpaca" => Ok(Exchange::Alpaca),
74            "polygon" => Ok(Exchange::Polygon),
75            _ => Err(StreamError::UnknownExchange(s.to_string())),
76        }
77    }
78}
79
80/// Raw tick — unprocessed bytes from an exchange WebSocket.
81#[derive(Debug, Clone)]
82pub struct RawTick {
83    /// Source exchange.
84    pub exchange: Exchange,
85    /// Instrument symbol as reported by the exchange.
86    pub symbol: String,
87    /// Raw JSON payload from the WebSocket frame.
88    pub payload: serde_json::Value,
89    /// System-clock timestamp (ms since Unix epoch) when the tick was received.
90    pub received_at_ms: u64,
91}
92
93impl RawTick {
94    /// Construct a new [`RawTick`], stamping `received_at_ms` from the system clock.
95    pub fn new(exchange: Exchange, symbol: impl Into<String>, payload: serde_json::Value) -> Self {
96        Self {
97            exchange,
98            symbol: symbol.into(),
99            payload,
100            received_at_ms: now_ms(),
101        }
102    }
103}
104
105/// Canonical normalized tick — exchange-agnostic.
106#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
107pub struct NormalizedTick {
108    /// Source exchange.
109    pub exchange: Exchange,
110    /// Instrument symbol in the canonical form used by this crate.
111    pub symbol: String,
112    /// Trade price (exact decimal, never `f64`).
113    pub price: Decimal,
114    /// Trade quantity (exact decimal).
115    pub quantity: Decimal,
116    /// Direction of the aggressing order, if available from the exchange.
117    pub side: Option<TradeSide>,
118    /// Exchange-assigned trade identifier, if available.
119    pub trade_id: Option<String>,
120    /// Exchange-side timestamp (ms since Unix epoch), if included in the feed.
121    pub exchange_ts_ms: Option<u64>,
122    /// Local system-clock timestamp when this tick was received.
123    pub received_at_ms: u64,
124}
125
126/// Direction of trade that generated the tick.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
128pub enum TradeSide {
129    /// Buyer was the aggressor.
130    Buy,
131    /// Seller was the aggressor.
132    Sell,
133}
134
135impl FromStr for TradeSide {
136    type Err = StreamError;
137
138    fn from_str(s: &str) -> Result<Self, Self::Err> {
139        match s.to_lowercase().as_str() {
140            "buy" => Ok(TradeSide::Buy),
141            "sell" => Ok(TradeSide::Sell),
142            _ => Err(StreamError::ParseError {
143                exchange: "TradeSide".into(),
144                reason: format!("unknown trade side '{s}'"),
145            }),
146        }
147    }
148}
149
150impl std::fmt::Display for TradeSide {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        match self {
153            TradeSide::Buy => write!(f, "buy"),
154            TradeSide::Sell => write!(f, "sell"),
155        }
156    }
157}
158
159impl TradeSide {
160    /// Returns `true` if this side is [`TradeSide::Buy`].
161    pub fn is_buy(self) -> bool {
162        self == TradeSide::Buy
163    }
164
165    /// Returns `true` if this side is [`TradeSide::Sell`].
166    pub fn is_sell(self) -> bool {
167        self == TradeSide::Sell
168    }
169}
170
171impl NormalizedTick {
172    /// Notional trade value: `price × quantity`.
173    ///
174    /// Returns the total value transferred in this trade. Useful for VWAP
175    /// calculations and volume-weighted aggregations without requiring callers
176    /// to multiply at every use site.
177    ///
178    /// See also [`volume_notional`](Self::volume_notional), [`notional_value`](Self::notional_value),
179    /// and [`dollar_value`](Self::dollar_value), which are all aliases.
180    pub fn value(&self) -> Decimal {
181        self.price * self.quantity
182    }
183
184    /// Age of this tick in milliseconds relative to `now_ms`.
185    ///
186    /// Returns `now_ms - received_at_ms`, saturating at zero (never negative).
187    /// Useful for staleness checks: `tick.age_ms(now) > threshold_ms`.
188    ///
189    /// See also [`quote_age_ms`](Self::quote_age_ms), which is an alias.
190    pub fn age_ms(&self, now_ms: u64) -> u64 {
191        now_ms.saturating_sub(self.received_at_ms)
192    }
193
194    /// Returns `true` if this tick is older than `threshold_ms` relative to `now_ms`.
195    ///
196    /// Equivalent to `self.age_ms(now_ms) > threshold_ms`. Use this for filtering
197    /// stale ticks before passing them into aggregation pipelines.
198    pub fn is_stale(&self, now_ms: u64, threshold_ms: u64) -> bool {
199        self.age_ms(now_ms) > threshold_ms
200    }
201
202    /// Returns `true` if the tick is a buyer-initiated trade.
203    ///
204    /// Returns `false` if side is `Sell` or `None` (side unknown).
205    pub fn is_buy(&self) -> bool {
206        self.side == Some(TradeSide::Buy)
207    }
208
209    /// Returns `true` if the tick is a seller-initiated trade.
210    ///
211    /// Returns `false` if side is `Buy` or `None` (side unknown).
212    pub fn is_sell(&self) -> bool {
213        self.side == Some(TradeSide::Sell)
214    }
215
216    /// Returns `true` if the trade side is unknown (`None`).
217    ///
218    /// Many exchanges do not report the aggressor side. This method lets
219    /// callers explicitly test for the "no side information" case rather than
220    /// relying on both `is_buy()` and `is_sell()` returning `false`.
221    pub fn is_neutral(&self) -> bool {
222        self.side.is_none()
223    }
224
225    /// Returns `true` if the traded quantity meets or exceeds `threshold`.
226    ///
227    /// Useful for isolating institutional-size trades ("block trades") from
228    /// the general flow. The threshold is in the same units as `quantity`.
229    pub fn is_large_trade(&self, threshold: Decimal) -> bool {
230        self.quantity >= threshold
231    }
232
233    /// Return a copy of this tick with the trade side set to `side`.
234    ///
235    /// Useful in tests and feed normalizers that determine the aggressor side
236    /// after initial tick construction.
237    pub fn with_side(mut self, side: TradeSide) -> Self {
238        self.side = Some(side);
239        self
240    }
241
242    /// Return a copy of this tick with the exchange timestamp set to `ts_ms`.
243    ///
244    /// Useful in tests and feed normalizers to inject an authoritative exchange
245    /// timestamp after the tick has already been constructed.
246    pub fn with_exchange_ts(mut self, ts_ms: u64) -> Self {
247        self.exchange_ts_ms = Some(ts_ms);
248        self
249    }
250
251    /// Signed price movement from `prev` to this tick: `self.price − prev.price`.
252    ///
253    /// Positive when price rose, negative when price fell, zero when unchanged.
254    /// Only meaningful when both ticks share the same symbol and exchange.
255    pub fn price_move_from(&self, prev: &NormalizedTick) -> Decimal {
256        self.price - prev.price
257    }
258
259    /// Returns `true` if this tick arrived more recently than `other`.
260    ///
261    /// Compares `received_at_ms` timestamps. Equal timestamps return `false`.
262    pub fn is_more_recent_than(&self, other: &NormalizedTick) -> bool {
263        self.received_at_ms > other.received_at_ms
264    }
265
266    /// Transport latency in milliseconds: `received_at_ms − exchange_ts_ms`.
267    ///
268    /// Returns `None` if the exchange timestamp is unavailable. A positive
269    /// value indicates how long the tick took to travel from exchange to this
270    /// system; negative values suggest clock skew between exchange and consumer.
271    ///
272    /// See also [`exchange_latency_ms`](Self::exchange_latency_ms), which is an alias.
273    pub fn latency_ms(&self) -> Option<i64> {
274        let exchange_ts = self.exchange_ts_ms? as i64;
275        Some(self.received_at_ms as i64 - exchange_ts)
276    }
277
278    /// Notional value of this trade: `price × quantity`.
279    ///
280    /// Alias for [`value`](Self::value).
281    pub fn volume_notional(&self) -> rust_decimal::Decimal {
282        self.value()
283    }
284
285    /// Returns `true` if this tick carries an exchange-provided timestamp.
286    ///
287    /// When `false`, only the local `received_at_ms` is available. Use
288    /// [`latency_ms`](Self::latency_ms) to measure round-trip latency when
289    /// this returns `true`.
290    pub fn has_exchange_ts(&self) -> bool {
291        self.exchange_ts_ms.is_some()
292    }
293
294    /// Human-readable trade direction: `"buy"`, `"sell"`, or `"unknown"`.
295    pub fn side_str(&self) -> &'static str {
296        match self.side {
297            Some(TradeSide::Buy) => "buy",
298            Some(TradeSide::Sell) => "sell",
299            None => "unknown",
300        }
301    }
302
303    /// Returns `true` if the quantity is a whole number (no fractional part).
304    ///
305    /// Useful for detecting atypical fractional order sizes, or as a simple
306    /// round-lot check in integer-quantity markets.
307    pub fn is_round_lot(&self) -> bool {
308        self.quantity.fract().is_zero()
309    }
310
311    /// Returns `true` if this tick's symbol matches `other`'s symbol exactly.
312    pub fn is_same_symbol_as(&self, other: &NormalizedTick) -> bool {
313        self.symbol == other.symbol
314    }
315
316    /// Absolute price difference between this tick and `other`.
317    ///
318    /// Returns `|self.price - other.price|`. Useful for computing price drift
319    /// between two ticks of the same instrument without caring about direction.
320    pub fn price_distance_from(&self, other: &NormalizedTick) -> Decimal {
321        (self.price - other.price).abs()
322    }
323
324    /// Signed latency between the local receipt timestamp and the exchange
325    /// timestamp, in milliseconds.
326    ///
327    /// Alias for [`latency_ms`](Self::latency_ms).
328    pub fn exchange_latency_ms(&self) -> Option<i64> {
329        self.latency_ms()
330    }
331
332    /// Returns `true` if the notional value of this trade (`price × quantity`)
333    /// exceeds `threshold`.
334    ///
335    /// Unlike [`is_large_trade`](Self::is_large_trade) (which compares raw
336    /// quantity), this method uses the trade's dollar value, making it useful
337    /// for comparing block-trade size across instruments with different prices.
338    pub fn is_notional_large_trade(&self, threshold: Decimal) -> bool {
339        self.volume_notional() > threshold
340    }
341
342    /// Returns `true` if this tick's price is zero.
343    ///
344    /// A zero price typically indicates a malformed or uninitialized tick.
345    pub fn is_zero_price(&self) -> bool {
346        self.price.is_zero()
347    }
348
349    /// Returns `true` if the tick is still fresh relative to `now_ms`.
350    ///
351    /// "Fresh" means the tick arrived within the last `max_age_ms` milliseconds.
352    /// Returns `false` when `now_ms < ts_ms` (clock skew guard).
353    pub fn is_fresh(&self, now_ms: u64, max_age_ms: u64) -> bool {
354        now_ms.saturating_sub(self.received_at_ms) <= max_age_ms
355    }
356
357    /// Returns `true` if this tick's price is strictly above `price`.
358    pub fn is_above(&self, price: Decimal) -> bool {
359        self.price > price
360    }
361
362    /// Returns `true` if this tick's price is strictly below `price`.
363    pub fn is_below(&self, price: Decimal) -> bool {
364        self.price < price
365    }
366
367    /// Returns `true` if this tick's price equals `price`.
368    pub fn is_at(&self, price: Decimal) -> bool {
369        self.price == price
370    }
371
372    /// Returns `true` if the tick has a definite direction (buy or sell).
373    ///
374    /// Neutral ticks (where `side` is `None`) return `false`.
375    pub fn is_aggressive(&self) -> bool {
376        self.side.is_some()
377    }
378
379    /// Signed price difference: `self.price - other.price`.
380    ///
381    /// Alias for [`price_move_from`](Self::price_move_from).
382    #[deprecated(since = "2.2.0", note = "Use `price_move_from` instead")]
383    pub fn price_diff_from(&self, other: &NormalizedTick) -> Decimal {
384        self.price_move_from(other)
385    }
386
387    /// Returns `true` if the trade quantity is strictly less than `threshold`.
388    ///
389    /// The inverse of [`is_large_trade`](Self::is_large_trade).
390    pub fn is_micro_trade(&self, threshold: Decimal) -> bool {
391        self.quantity < threshold
392    }
393
394    /// Returns `true` if this tick occurred above the given midpoint price.
395    ///
396    /// A tick above the midpoint is typically associated with buying pressure.
397    pub fn is_buying_pressure(&self, midpoint: Decimal) -> bool {
398        self.price > midpoint
399    }
400
401    /// Age of this tick in seconds: `(now_ms - received_at_ms) / 1000.0`.
402    ///
403    /// Returns `0.0` if `now_ms` is before `received_at_ms`.
404    pub fn age_secs(&self, now_ms: u64) -> f64 {
405        now_ms.saturating_sub(self.received_at_ms) as f64 / 1_000.0
406    }
407
408    /// Returns `true` if this tick originated from the same exchange as `other`.
409    pub fn is_same_exchange_as(&self, other: &NormalizedTick) -> bool {
410        self.exchange == other.exchange
411    }
412
413    /// Age of this tick in milliseconds: `now_ms - received_at_ms`.
414    ///
415    /// Alias for [`age_ms`](Self::age_ms).
416    #[deprecated(since = "2.2.0", note = "Use `age_ms` instead")]
417    pub fn quote_age_ms(&self, now_ms: u64) -> u64 {
418        self.age_ms(now_ms)
419    }
420
421    /// Notional value of this tick: `price × quantity`.
422    ///
423    /// Alias for [`value`](Self::value).
424    #[deprecated(since = "2.2.0", note = "Use `value` instead")]
425    pub fn notional_value(&self) -> Decimal {
426        self.value()
427    }
428
429    /// Returns `true` if the notional value (`price × quantity`) exceeds `threshold`.
430    ///
431    /// Alias for [`is_notional_large_trade`](Self::is_notional_large_trade).
432    #[deprecated(since = "2.2.0", note = "Use `is_notional_large_trade` instead")]
433    pub fn is_high_value_tick(&self, threshold: Decimal) -> bool {
434        self.is_notional_large_trade(threshold)
435    }
436
437    /// Returns the trade side as a string slice: `"buy"`, `"sell"`, or `None`.
438    pub fn side_as_str(&self) -> Option<&'static str> {
439        match self.side {
440            Some(TradeSide::Buy) => Some("buy"),
441            Some(TradeSide::Sell) => Some("sell"),
442            None => None,
443        }
444    }
445
446    /// Returns `true` if this tick's price is strictly above `reference`.
447    ///
448    /// Alias for [`is_above`](Self::is_above).
449    #[deprecated(since = "2.2.0", note = "Use `is_above` instead")]
450    pub fn is_above_price(&self, reference: Decimal) -> bool {
451        self.is_above(reference)
452    }
453
454    /// Signed price change relative to `reference`: `self.price - reference`.
455    pub fn price_change_from(&self, reference: Decimal) -> Decimal {
456        self.price - reference
457    }
458
459    /// Returns `true` if this tick's `received_at_ms` falls within a trading session window.
460    pub fn is_market_open_tick(&self, session_start_ms: u64, session_end_ms: u64) -> bool {
461        self.received_at_ms >= session_start_ms && self.received_at_ms < session_end_ms
462    }
463
464    /// Returns `true` if this tick's price exactly equals `target`.
465    ///
466    /// Alias for [`is_at`](Self::is_at).
467    #[deprecated(since = "2.2.0", note = "Use `is_at` instead")]
468    pub fn is_at_price(&self, target: Decimal) -> bool {
469        self.is_at(target)
470    }
471
472    /// Returns `true` if this tick's price is strictly below `reference`.
473    ///
474    /// Alias for [`is_below`](Self::is_below).
475    #[deprecated(since = "2.2.0", note = "Use `is_below` instead")]
476    pub fn is_below_price(&self, reference: Decimal) -> bool {
477        self.is_below(reference)
478    }
479
480    /// Returns `true` if this tick's price is divisible by `step` with no remainder.
481    ///
482    /// Useful for identifying round-number price levels (e.g., `step = 100`).
483    /// Returns `false` if `step` is zero.
484    pub fn is_round_number(&self, step: Decimal) -> bool {
485        if step.is_zero() {
486            return false;
487        }
488        (self.price % step).is_zero()
489    }
490
491    /// Returns the trade quantity signed by side: `+quantity` for Buy, `-quantity` for Sell, `0` for unknown.
492    pub fn signed_quantity(&self) -> Decimal {
493        match self.side {
494            Some(TradeSide::Buy) => self.quantity,
495            Some(TradeSide::Sell) => -self.quantity,
496            None => Decimal::ZERO,
497        }
498    }
499
500    /// Returns `(price, quantity)` as a convenient tuple.
501    pub fn as_price_level(&self) -> (Decimal, Decimal) {
502        (self.price, self.quantity)
503    }
504
505    /// Returns `true` if this tick's quantity is strictly above `threshold`.
506    pub fn quantity_above(&self, threshold: Decimal) -> bool {
507        self.quantity > threshold
508    }
509
510    /// Returns `true` if this tick was received within `threshold_ms` of `now_ms`.
511    ///
512    /// Alias for [`is_fresh(now_ms, threshold_ms)`](Self::is_fresh).
513    #[deprecated(since = "2.2.0", note = "Use `is_fresh(now_ms, threshold_ms)` instead")]
514    pub fn is_recent(&self, threshold_ms: u64, now_ms: u64) -> bool {
515        self.is_fresh(now_ms, threshold_ms)
516    }
517
518    /// Returns `true` if this tick is on the buy side.
519    ///
520    /// Alias for [`is_buy`](Self::is_buy).
521    #[deprecated(since = "2.2.0", note = "Use `is_buy` instead")]
522    pub fn is_buy_side(&self) -> bool {
523        self.is_buy()
524    }
525
526    /// Returns `true` if this tick is on the sell side.
527    ///
528    /// Alias for [`is_sell`](Self::is_sell).
529    #[deprecated(since = "2.2.0", note = "Use `is_sell` instead")]
530    pub fn is_sell_side(&self) -> bool {
531        self.is_sell()
532    }
533
534    /// Returns `true` if this tick's quantity is zero (may indicate a cancel or correction).
535    pub fn is_zero_quantity(&self) -> bool {
536        self.quantity.is_zero()
537    }
538
539    /// Returns `true` if this tick's price is strictly between `bid` and `ask`.
540    pub fn is_within_spread(&self, bid: Decimal, ask: Decimal) -> bool {
541        self.price > bid && self.price < ask
542    }
543
544    /// Returns `true` if this tick's price deviates from `reference` by more than `threshold`.
545    pub fn is_away_from_price(&self, reference: Decimal, threshold: Decimal) -> bool {
546        (self.price - reference).abs() > threshold
547    }
548
549    /// Returns `true` if this tick's quantity is strictly above `threshold`.
550    ///
551    /// Note: unlike [`is_large_trade`](Self::is_large_trade) which uses `>=`,
552    /// this method uses strict `>` for backwards compatibility.
553    #[deprecated(since = "2.2.0", note = "Use `is_large_trade` instead")]
554    pub fn is_large_tick(&self, threshold: Decimal) -> bool {
555        self.quantity > threshold
556    }
557
558    /// Returns `true` if this tick's price is within `[low, high]` (inclusive).
559    pub fn price_in_range(&self, low: Decimal, high: Decimal) -> bool {
560        self.price >= low && self.price <= high
561    }
562
563    /// Price rounded down to the nearest multiple of `tick_size`.
564    ///
565    /// Returns the original price if `tick_size` is zero.
566    pub fn rounded_price(&self, tick_size: Decimal) -> Decimal {
567        if tick_size.is_zero() {
568            return self.price;
569        }
570        (self.price / tick_size).floor() * tick_size
571    }
572
573    /// Returns `true` if the absolute price difference from `other` exceeds `threshold`.
574    pub fn is_large_spread_from(&self, other: &NormalizedTick, threshold: Decimal) -> bool {
575        (self.price - other.price).abs() > threshold
576    }
577
578    /// Notional value of this tick as `f64` (`price × quantity`).
579    pub fn volume_notional_f64(&self) -> f64 {
580        use rust_decimal::prelude::ToPrimitive;
581        self.volume_notional().to_f64().unwrap_or(0.0)
582    }
583
584    /// Rate of price change relative to a prior tick: `(price - prev.price) / dt_ms`.
585    ///
586    /// Returns `None` if `dt_ms` is zero (same timestamp).
587    pub fn price_velocity(&self, prev: &NormalizedTick, dt_ms: u64) -> Option<Decimal> {
588        if dt_ms == 0 { return None; }
589        Some((self.price - prev.price) / Decimal::from(dt_ms))
590    }
591
592    /// Returns `true` if price reversed direction by at least `min_move` from `prev`.
593    ///
594    /// A reversal means the direction of `(self.price - prev.price)` is opposite to
595    /// the direction of `(prev.price - prev_prev.price)`, and the magnitude ≥ `min_move`.
596    /// This two-argument form checks: `|self.price - prev.price| >= min_move`.
597    pub fn is_reversal(&self, prev: &NormalizedTick, min_move: Decimal) -> bool {
598        let move_size = (self.price - prev.price).abs();
599        move_size >= min_move
600    }
601
602    /// Returns `true` if a trade crossed the spread: `bid_tick.price >= ask_tick.price`.
603    ///
604    /// A spread-crossed condition indicates an aggressive order consumed
605    /// the best opposing quote.
606    pub fn spread_crossed(bid_tick: &NormalizedTick, ask_tick: &NormalizedTick) -> bool {
607        bid_tick.price >= ask_tick.price
608    }
609
610    /// Dollar (notional) value of this tick: `price * quantity`.
611    ///
612    /// Alias for [`value`](Self::value).
613    pub fn dollar_value(&self) -> Decimal {
614        self.value()
615    }
616
617    /// Contract value using a futures/options multiplier: `price * quantity * multiplier`.
618    pub fn contract_value(&self, multiplier: Decimal) -> Decimal {
619        self.value() * multiplier
620    }
621
622    /// Tick imbalance: `(buy_qty - sell_qty) / total_qty` across a tick slice.
623    ///
624    /// Buy ticks are those with `side == Some(TradeSide::Buy)`.
625    /// Returns `None` if total quantity is zero.
626    pub fn tick_imbalance(ticks: &[NormalizedTick]) -> Option<f64> {
627        use rust_decimal::prelude::ToPrimitive;
628        let buy_qty: Decimal = ticks.iter()
629            .filter(|t| matches!(t.side, Some(TradeSide::Buy)))
630            .map(|t| t.quantity)
631            .sum();
632        let total_qty: Decimal = ticks.iter().map(|t| t.quantity).sum();
633        if total_qty.is_zero() { return None; }
634        let sell_qty = total_qty - buy_qty;
635        ((buy_qty - sell_qty) / total_qty).to_f64()
636    }
637
638    /// Theoretical quote midpoint: `(bid.price + ask.price) / 2`.
639    ///
640    /// Returns `None` if either tick has a non-positive price or if the bid
641    /// price exceeds the ask price (crossed market).
642    pub fn quote_midpoint(bid: &NormalizedTick, ask: &NormalizedTick) -> Option<Decimal> {
643        if bid.price <= Decimal::ZERO || ask.price <= Decimal::ZERO {
644            return None;
645        }
646        if bid.price > ask.price {
647            return None;
648        }
649        Some((bid.price + ask.price) / Decimal::TWO)
650    }
651
652    /// Total quantity across all buy-initiated ticks in a slice.
653    ///
654    /// Filters ticks where `side == Some(TradeSide::Buy)` and sums their quantities.
655    /// Returns `Decimal::ZERO` for an empty slice or one with no buy ticks.
656    pub fn buy_volume(ticks: &[NormalizedTick]) -> Decimal {
657        ticks
658            .iter()
659            .filter(|t| t.side == Some(TradeSide::Buy))
660            .map(|t| t.quantity)
661            .sum()
662    }
663
664    /// Total quantity across all sell-initiated ticks in a slice.
665    ///
666    /// Filters ticks where `side == Some(TradeSide::Sell)` and sums their quantities.
667    /// Returns `Decimal::ZERO` for an empty slice or one with no sell ticks.
668    pub fn sell_volume(ticks: &[NormalizedTick]) -> Decimal {
669        ticks
670            .iter()
671            .filter(|t| t.side == Some(TradeSide::Sell))
672            .map(|t| t.quantity)
673            .sum()
674    }
675
676    /// Price range across a slice of ticks: `max(price) − min(price)`.
677    ///
678    /// Returns `None` if the slice is empty.
679    pub fn price_range(ticks: &[NormalizedTick]) -> Option<Decimal> {
680        if ticks.is_empty() {
681            return None;
682        }
683        let max = ticks.iter().map(|t| t.price).max()?;
684        let min = ticks.iter().map(|t| t.price).min()?;
685        Some(max - min)
686    }
687
688    /// Arithmetic mean price across a slice of ticks.
689    ///
690    /// Returns `None` if the slice is empty.
691    pub fn average_price(ticks: &[NormalizedTick]) -> Option<Decimal> {
692        if ticks.is_empty() {
693            return None;
694        }
695        let sum: Decimal = ticks.iter().map(|t| t.price).sum();
696        Some(sum / Decimal::from(ticks.len() as u64))
697    }
698
699    /// Volume-weighted average price (VWAP) across a slice of ticks.
700    ///
701    /// Computes `Σ(price × quantity) / Σ(quantity)`.
702    /// Returns `None` if the slice is empty or total volume is zero.
703    pub fn vwap(ticks: &[NormalizedTick]) -> Option<Decimal> {
704        let volume: Decimal = ticks.iter().map(|t| t.quantity).sum();
705        if volume.is_zero() {
706            return None;
707        }
708        Some(Self::total_notional(ticks) / volume)
709    }
710
711    /// Count of ticks whose price is strictly above `threshold`.
712    pub fn count_above_price(ticks: &[NormalizedTick], threshold: Decimal) -> usize {
713        ticks.iter().filter(|t| t.price > threshold).count()
714    }
715
716    /// Count of ticks whose price is strictly below `threshold`.
717    pub fn count_below_price(ticks: &[NormalizedTick], threshold: Decimal) -> usize {
718        ticks.iter().filter(|t| t.price < threshold).count()
719    }
720
721    /// Total notional value (`Σ price × quantity`) across all ticks.
722    pub fn total_notional(ticks: &[NormalizedTick]) -> Decimal {
723        ticks.iter().map(|t| t.value()).sum()
724    }
725
726    /// Total notional for buy-side ticks (`side == Some(TradeSide::Buy)`).
727    pub fn buy_notional(ticks: &[NormalizedTick]) -> Decimal {
728        ticks.iter()
729            .filter(|t| t.side == Some(TradeSide::Buy))
730            .map(|t| t.value())
731            .sum()
732    }
733
734    /// Total notional for sell-side ticks (`side == Some(TradeSide::Sell)`).
735    pub fn sell_notional(ticks: &[NormalizedTick]) -> Decimal {
736        ticks.iter()
737            .filter(|t| t.side == Some(TradeSide::Sell))
738            .map(|t| t.value())
739            .sum()
740    }
741
742    /// Median price across a slice of ticks.
743    ///
744    /// Sorts tick prices and returns the middle value (or mean of two middle
745    /// values for an even-length slice). Returns `None` for an empty slice.
746    pub fn median_price(ticks: &[NormalizedTick]) -> Option<Decimal> {
747        if ticks.is_empty() {
748            return None;
749        }
750        let mut prices: Vec<Decimal> = ticks.iter().map(|t| t.price).collect();
751        prices.sort();
752        let n = prices.len();
753        if n % 2 == 1 {
754            Some(prices[n / 2])
755        } else {
756            Some((prices[n / 2 - 1] + prices[n / 2]) / Decimal::from(2u64))
757        }
758    }
759
760    /// Net volume: `buy_volume − sell_volume`.
761    ///
762    /// Positive means net buying pressure; negative means net selling pressure.
763    pub fn net_volume(ticks: &[NormalizedTick]) -> Decimal {
764        Self::buy_volume(ticks) - Self::sell_volume(ticks)
765    }
766
767    /// Average trade quantity: `total_volume / tick_count`.
768    ///
769    /// Returns `None` if the slice is empty.
770    pub fn average_quantity(ticks: &[NormalizedTick]) -> Option<Decimal> {
771        if ticks.is_empty() {
772            return None;
773        }
774        let total: Decimal = ticks.iter().map(|t| t.quantity).sum();
775        Some(total / Decimal::from(ticks.len() as u64))
776    }
777
778    /// Maximum single-trade quantity across the slice.
779    ///
780    /// Returns `None` if the slice is empty.
781    pub fn max_quantity(ticks: &[NormalizedTick]) -> Option<Decimal> {
782        ticks.iter().map(|t| t.quantity).reduce(Decimal::max)
783    }
784
785    /// Minimum single-trade quantity across the slice.
786    ///
787    /// Returns `None` if the slice is empty.
788    pub fn min_quantity(ticks: &[NormalizedTick]) -> Option<Decimal> {
789        ticks.iter().map(|t| t.quantity).reduce(Decimal::min)
790    }
791
792    /// Number of buy-side ticks in the slice.
793    pub fn buy_count(ticks: &[NormalizedTick]) -> usize {
794        ticks.iter().filter(|t| t.is_buy()).count()
795    }
796
797    /// Number of sell-side ticks in the slice.
798    pub fn sell_count(ticks: &[NormalizedTick]) -> usize {
799        ticks.iter().filter(|t| t.is_sell()).count()
800    }
801
802    /// Percentage price change from the first to the last tick.
803    ///
804    /// Returns `None` if the slice has fewer than 2 ticks or the first
805    /// tick's price is zero.
806    pub fn price_momentum(ticks: &[NormalizedTick]) -> Option<f64> {
807        use rust_decimal::prelude::ToPrimitive;
808        let n = ticks.len();
809        if n < 2 {
810            return None;
811        }
812        let first = ticks[0].price;
813        let last = ticks[n - 1].price;
814        if first.is_zero() {
815            return None;
816        }
817        ((last - first) / first).to_f64()
818    }
819
820    /// Minimum price across the slice.
821    ///
822    /// Returns `None` if the slice is empty.
823    pub fn min_price(ticks: &[NormalizedTick]) -> Option<Decimal> {
824        ticks.iter().map(|t| t.price).reduce(Decimal::min)
825    }
826
827    /// Maximum price across the slice.
828    ///
829    /// Returns `None` if the slice is empty.
830    pub fn max_price(ticks: &[NormalizedTick]) -> Option<Decimal> {
831        ticks.iter().map(|t| t.price).reduce(Decimal::max)
832    }
833
834    /// Standard deviation of tick prices across the slice.
835    ///
836    /// Returns `None` if the slice has fewer than 2 elements.
837    pub fn price_std_dev(ticks: &[NormalizedTick]) -> Option<f64> {
838        use rust_decimal::prelude::ToPrimitive;
839        let n = ticks.len();
840        if n < 2 { return None; }
841        let vals: Vec<f64> = ticks.iter().filter_map(|t| t.price.to_f64()).collect();
842        if vals.len() < 2 { return None; }
843        let mean = vals.iter().sum::<f64>() / vals.len() as f64;
844        let variance = vals.iter().map(|p| (p - mean).powi(2)).sum::<f64>() / (vals.len() - 1) as f64;
845        Some(variance.sqrt())
846    }
847
848    /// Ratio of buy volume to sell volume.
849    ///
850    /// Returns `None` if sell volume is zero.
851    pub fn buy_sell_ratio(ticks: &[NormalizedTick]) -> Option<f64> {
852        use rust_decimal::prelude::ToPrimitive;
853        let sell = Self::sell_volume(ticks);
854        if sell.is_zero() {
855            return None;
856        }
857        (Self::buy_volume(ticks) / sell).to_f64()
858    }
859
860    /// Returns the tick with the highest quantity in the slice.
861    ///
862    /// Returns `None` if the slice is empty.
863    pub fn largest_trade(ticks: &[NormalizedTick]) -> Option<&NormalizedTick> {
864        ticks.iter().max_by(|a, b| a.quantity.cmp(&b.quantity))
865    }
866
867    /// Count of ticks whose quantity strictly exceeds `threshold`.
868    pub fn large_trade_count(ticks: &[NormalizedTick], threshold: Decimal) -> usize {
869        ticks.iter().filter(|t| t.quantity > threshold).count()
870    }
871
872    /// Interquartile range (Q3 − Q1) of tick prices.
873    ///
874    /// Returns `None` if the slice has fewer than 4 elements.
875    pub fn price_iqr(ticks: &[NormalizedTick]) -> Option<Decimal> {
876        let n = ticks.len();
877        if n < 4 {
878            return None;
879        }
880        let mut prices: Vec<Decimal> = ticks.iter().map(|t| t.price).collect();
881        prices.sort();
882        let q1_idx = n / 4;
883        let q3_idx = 3 * n / 4;
884        Some(prices[q3_idx] - prices[q1_idx])
885    }
886
887    /// Fraction of ticks that are buy-side.
888    ///
889    /// Returns `None` if the slice is empty.
890    pub fn fraction_buy(ticks: &[NormalizedTick]) -> Option<f64> {
891        if ticks.is_empty() {
892            return None;
893        }
894        Some(Self::buy_count(ticks) as f64 / ticks.len() as f64)
895    }
896
897    /// Standard deviation of trade quantities across the slice.
898    ///
899    /// Returns `None` if the slice has fewer than 2 elements.
900    pub fn std_quantity(ticks: &[NormalizedTick]) -> Option<f64> {
901        use rust_decimal::prelude::ToPrimitive;
902        let vals: Vec<f64> = ticks.iter().filter_map(|t| t.quantity.to_f64()).collect();
903        Self::sample_std_dev_f64(&vals)
904    }
905
906    /// Fraction of sided volume that is buy-initiated: buy_vol / (buy_vol + sell_vol).
907    ///
908    /// Returns `None` if there are no sided ticks.
909    pub fn buy_pressure(ticks: &[NormalizedTick]) -> Option<f64> {
910        use rust_decimal::prelude::ToPrimitive;
911        let buy = Self::buy_volume(ticks);
912        let sell = Self::sell_volume(ticks);
913        let total = buy + sell;
914        if total.is_zero() {
915            return None;
916        }
917        (buy / total).to_f64()
918    }
919
920    /// Mean notional value (price × quantity) per trade across the slice.
921    ///
922    /// Returns `None` if the slice is empty.
923    pub fn average_notional(ticks: &[NormalizedTick]) -> Option<Decimal> {
924        if ticks.is_empty() {
925            return None;
926        }
927        Some(Self::total_notional(ticks) / Decimal::from(ticks.len() as u64))
928    }
929
930    /// Count of ticks with no known side (neither buy nor sell).
931    pub fn count_neutral(ticks: &[NormalizedTick]) -> usize {
932        ticks.iter().filter(|t| t.is_neutral()).count()
933    }
934
935    /// Returns the last `n` ticks from the slice.
936    ///
937    /// If `n >= ticks.len()`, returns the full slice.
938    pub fn recent(ticks: &[NormalizedTick], n: usize) -> &[NormalizedTick] {
939        let len = ticks.len();
940        if n >= len { ticks } else { &ticks[len - n..] }
941    }
942
943    /// OLS linear regression slope of price over tick index.
944    ///
945    /// A positive slope indicates prices are rising across the slice.
946    /// Returns `None` if the slice has fewer than 2 ticks.
947    pub fn price_linear_slope(ticks: &[NormalizedTick]) -> Option<f64> {
948        use rust_decimal::prelude::ToPrimitive;
949        let n = ticks.len();
950        if n < 2 {
951            return None;
952        }
953        let n_f = n as f64;
954        let xs: Vec<f64> = (0..n).map(|i| i as f64).collect();
955        let ys: Vec<f64> = ticks.iter().filter_map(|t| t.price.to_f64()).collect();
956        if ys.len() < 2 {
957            return None;
958        }
959        let x_mean = xs.iter().sum::<f64>() / n_f;
960        let y_mean = ys.iter().sum::<f64>() / ys.len() as f64;
961        let numerator: f64 = xs.iter().zip(ys.iter()).map(|(&x, &y)| (x - x_mean) * (y - y_mean)).sum();
962        let denominator: f64 = xs.iter().map(|&x| (x - x_mean).powi(2)).sum();
963        if denominator == 0.0 {
964            return None;
965        }
966        Some(numerator / denominator)
967    }
968
969    /// Standard deviation of per-trade notional values (price × quantity).
970    ///
971    /// Returns `None` if the slice has fewer than 2 elements.
972    pub fn notional_std_dev(ticks: &[NormalizedTick]) -> Option<f64> {
973        use rust_decimal::prelude::ToPrimitive;
974        let vals: Vec<f64> = ticks.iter().filter_map(|t| t.value().to_f64()).collect();
975        Self::sample_std_dev_f64(&vals)
976    }
977
978    /// Returns `true` if prices in the slice are non-decreasing (each ≥ previous).
979    ///
980    /// Returns `true` for slices with 0 or 1 ticks.
981    pub fn monotone_up(ticks: &[NormalizedTick]) -> bool {
982        ticks.windows(2).all(|w| w[1].price >= w[0].price)
983    }
984
985    /// Returns `true` if prices in the slice are non-increasing (each ≤ previous).
986    ///
987    /// Returns `true` for slices with 0 or 1 ticks.
988    pub fn monotone_down(ticks: &[NormalizedTick]) -> bool {
989        ticks.windows(2).all(|w| w[1].price <= w[0].price)
990    }
991
992    /// Total quantity traded at exactly `price`.
993    pub fn volume_at_price(ticks: &[NormalizedTick], price: Decimal) -> Decimal {
994        ticks.iter().filter(|t| t.price == price).map(|t| t.quantity).sum()
995    }
996
997    /// Price of the most recent tick in the slice.
998    ///
999    /// Returns `None` if the slice is empty.
1000    pub fn last_price(ticks: &[NormalizedTick]) -> Option<Decimal> {
1001        ticks.last().map(|t| t.price)
1002    }
1003
1004    /// Length of the longest consecutive run of buy-sided ticks.
1005    pub fn longest_buy_streak(ticks: &[NormalizedTick]) -> usize {
1006        let mut max = 0usize;
1007        let mut current = 0usize;
1008        for t in ticks {
1009            if t.is_buy() {
1010                current += 1;
1011                max = max.max(current);
1012            } else {
1013                current = 0;
1014            }
1015        }
1016        max
1017    }
1018
1019    /// Length of the longest consecutive run of sell-sided ticks.
1020    pub fn longest_sell_streak(ticks: &[NormalizedTick]) -> usize {
1021        let mut max = 0usize;
1022        let mut current = 0usize;
1023        for t in ticks {
1024            if t.is_sell() {
1025                current += 1;
1026                max = max.max(current);
1027            } else {
1028                current = 0;
1029            }
1030        }
1031        max
1032    }
1033
1034    /// Price level with the highest total traded quantity in the slice.
1035    ///
1036    /// Returns `None` if the slice is empty.
1037    pub fn price_at_max_volume(ticks: &[NormalizedTick]) -> Option<Decimal> {
1038        use std::collections::HashMap;
1039        if ticks.is_empty() {
1040            return None;
1041        }
1042        let mut volume_by_price: HashMap<String, (Decimal, Decimal)> = HashMap::new();
1043        for t in ticks {
1044            let key = t.price.to_string();
1045            let entry = volume_by_price.entry(key).or_insert((t.price, Decimal::ZERO));
1046            entry.1 += t.quantity;
1047        }
1048        volume_by_price
1049            .values()
1050            .max_by(|a, b| a.1.cmp(&b.1))
1051            .map(|(price, _)| *price)
1052    }
1053
1054    /// Total quantity of the last `n` ticks.
1055    ///
1056    /// If `n >= ticks.len()`, returns total volume of all ticks.
1057    pub fn recent_volume(ticks: &[NormalizedTick], n: usize) -> Decimal {
1058        Self::recent(ticks, n).iter().map(|t| t.quantity).sum()
1059    }
1060
1061    /// Sample standard deviation of an `f64` slice.
1062    ///
1063    /// Returns `None` if `vals` has fewer than 2 elements.
1064    fn sample_std_dev_f64(vals: &[f64]) -> Option<f64> {
1065        let n = vals.len();
1066        if n < 2 {
1067            return None;
1068        }
1069        let mean = vals.iter().sum::<f64>() / n as f64;
1070        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
1071        Some(variance.sqrt())
1072    }
1073
1074    /// Price of the first tick in the slice.
1075    ///
1076    /// Returns `None` if the slice is empty.
1077    pub fn first_price(ticks: &[NormalizedTick]) -> Option<Decimal> {
1078        ticks.first().map(|t| t.price)
1079    }
1080
1081    /// Percentage return from first to last tick price: (last − first) / first.
1082    ///
1083    /// Returns `None` if the slice has fewer than 2 ticks or the first price is zero.
1084    pub fn price_return_pct(ticks: &[NormalizedTick]) -> Option<f64> {
1085        use rust_decimal::prelude::ToPrimitive;
1086        let n = ticks.len();
1087        if n < 2 { return None; }
1088        let first = ticks[0].price;
1089        if first.is_zero() { return None; }
1090        ((ticks[n - 1].price - first) / first).to_f64()
1091    }
1092
1093    /// Total quantity traded strictly above `price`.
1094    pub fn volume_above_price(ticks: &[NormalizedTick], price: Decimal) -> Decimal {
1095        ticks.iter().filter(|t| t.price > price).map(|t| t.quantity).sum()
1096    }
1097
1098    /// Total quantity traded strictly below `price`.
1099    pub fn volume_below_price(ticks: &[NormalizedTick], price: Decimal) -> Decimal {
1100        ticks.iter().filter(|t| t.price < price).map(|t| t.quantity).sum()
1101    }
1102
1103    /// Quantity-weighted average price (VWAP) across all ticks.
1104    ///
1105    /// Returns `None` if the slice is empty or total quantity is zero.
1106    pub fn quantity_weighted_avg_price(ticks: &[NormalizedTick]) -> Option<Decimal> {
1107        if ticks.is_empty() {
1108            return None;
1109        }
1110        let total_qty: Decimal = ticks.iter().map(|t| t.quantity).sum();
1111        if total_qty.is_zero() {
1112            return None;
1113        }
1114        let weighted: Decimal = ticks.iter().map(|t| t.price * t.quantity).sum();
1115        Some(weighted / total_qty)
1116    }
1117
1118    /// Number of ticks with price strictly above `price`.
1119    pub fn tick_count_above_price(ticks: &[NormalizedTick], price: Decimal) -> usize {
1120        ticks.iter().filter(|t| t.price > price).count()
1121    }
1122
1123    /// Number of ticks with price strictly below `price`.
1124    pub fn tick_count_below_price(ticks: &[NormalizedTick], price: Decimal) -> usize {
1125        ticks.iter().filter(|t| t.price < price).count()
1126    }
1127
1128    /// Approximate price at the given percentile (0.0–1.0) by sorting tick prices.
1129    ///
1130    /// Returns `None` if the slice is empty or `percentile` is outside [0, 1].
1131    pub fn price_at_percentile(ticks: &[NormalizedTick], percentile: f64) -> Option<Decimal> {
1132        if ticks.is_empty() || !(0.0..=1.0).contains(&percentile) {
1133            return None;
1134        }
1135        let mut prices: Vec<Decimal> = ticks.iter().map(|t| t.price).collect();
1136        prices.sort();
1137        let idx = ((prices.len() - 1) as f64 * percentile).round() as usize;
1138        Some(prices[idx])
1139    }
1140
1141    /// Number of distinct prices in the tick slice.
1142    pub fn unique_price_count(ticks: &[NormalizedTick]) -> usize {
1143        use std::collections::HashSet;
1144        ticks.iter().map(|t| t.price.to_string()).collect::<HashSet<_>>().len()
1145    }
1146
1147    /// Average absolute price difference between consecutive ticks.
1148    ///
1149    /// Returns `None` if fewer than 2 ticks.
1150    pub fn avg_inter_tick_spread(ticks: &[NormalizedTick]) -> Option<f64> {
1151        use rust_decimal::prelude::ToPrimitive;
1152        if ticks.len() < 2 {
1153            return None;
1154        }
1155        let sum: Decimal = ticks.windows(2).map(|w| (w[1].price - w[0].price).abs()).sum();
1156        (sum / Decimal::from((ticks.len() - 1) as u32)).to_f64()
1157    }
1158
1159    /// Largest single-trade quantity among sell-side ticks.
1160    ///
1161    /// Returns `None` if there are no sell-side ticks.
1162    pub fn largest_sell(ticks: &[NormalizedTick]) -> Option<Decimal> {
1163        ticks.iter().filter(|t| t.is_sell()).map(|t| t.quantity).reduce(Decimal::max)
1164    }
1165
1166    /// Largest single-trade quantity among buy-side ticks.
1167    ///
1168    /// Returns `None` if there are no buy-side ticks.
1169    pub fn largest_buy(ticks: &[NormalizedTick]) -> Option<Decimal> {
1170        ticks.iter().filter(|t| t.is_buy()).map(|t| t.quantity).reduce(Decimal::max)
1171    }
1172
1173    /// Total number of ticks in the slice (alias for `slice.len()`).
1174    pub fn trade_count(ticks: &[NormalizedTick]) -> usize {
1175        ticks.len()
1176    }
1177
1178    /// Price acceleration: change in price velocity between consecutive ticks.
1179    ///
1180    /// Returns the difference of the last two consecutive price changes.
1181    /// Returns `None` if fewer than 3 ticks.
1182    pub fn price_acceleration(ticks: &[NormalizedTick]) -> Option<f64> {
1183        use rust_decimal::prelude::ToPrimitive;
1184        let n = ticks.len();
1185        if n < 3 {
1186            return None;
1187        }
1188        let v1 = (ticks[n - 2].price - ticks[n - 3].price).to_f64()?;
1189        let v2 = (ticks[n - 1].price - ticks[n - 2].price).to_f64()?;
1190        Some(v2 - v1)
1191    }
1192
1193    /// Net difference between buy volume and sell volume.
1194    ///
1195    /// Positive means more buying pressure, negative means more selling pressure.
1196    pub fn buy_sell_diff(ticks: &[NormalizedTick]) -> Decimal {
1197        Self::buy_volume(ticks) - Self::sell_volume(ticks)
1198    }
1199
1200    /// Returns `true` if the tick is a buy that exceeds the average buy quantity.
1201    pub fn is_aggressive_buy(tick: &NormalizedTick, avg_buy_qty: Decimal) -> bool {
1202        tick.is_buy() && tick.quantity > avg_buy_qty
1203    }
1204
1205    /// Returns `true` if the tick is a sell that exceeds the average sell quantity.
1206    pub fn is_aggressive_sell(tick: &NormalizedTick, avg_sell_qty: Decimal) -> bool {
1207        tick.is_sell() && tick.quantity > avg_sell_qty
1208    }
1209
1210    /// Total notional value: sum of `price * quantity` across all ticks.
1211    pub fn notional_volume(ticks: &[NormalizedTick]) -> Decimal {
1212        ticks.iter().map(|t| t.price * t.quantity).sum()
1213    }
1214
1215    /// Weighted side score: buy_volume - sell_volume normalized by total volume.
1216    ///
1217    /// Returns a value in [-1, 1], or `None` if total volume is zero.
1218    pub fn weighted_side_score(ticks: &[NormalizedTick]) -> Option<f64> {
1219        use rust_decimal::prelude::ToPrimitive;
1220        let total: Decimal = ticks.iter().map(|t| t.quantity).sum();
1221        if total.is_zero() {
1222            return None;
1223        }
1224        let diff = Self::buy_volume(ticks) - Self::sell_volume(ticks);
1225        (diff / total).to_f64()
1226    }
1227
1228    /// Time span in milliseconds between the first and last tick.
1229    ///
1230    /// Returns `None` if fewer than 2 ticks.
1231    pub fn time_span_ms(ticks: &[NormalizedTick]) -> Option<u64> {
1232        if ticks.len() < 2 {
1233            return None;
1234        }
1235        Some(ticks.last()?.received_at_ms.saturating_sub(ticks.first()?.received_at_ms))
1236    }
1237
1238    /// Count of ticks with price above the VWAP of the slice.
1239    ///
1240    /// Returns `None` if VWAP cannot be computed (empty or zero total volume).
1241    pub fn price_above_vwap_count(ticks: &[NormalizedTick]) -> Option<usize> {
1242        let vwap = Self::vwap(ticks)?;
1243        Some(ticks.iter().filter(|t| t.price > vwap).count())
1244    }
1245
1246    /// Mean quantity per trade across all ticks.
1247    ///
1248    /// Returns `None` if the slice is empty.
1249    pub fn avg_trade_size(ticks: &[NormalizedTick]) -> Option<Decimal> {
1250        if ticks.is_empty() {
1251            return None;
1252        }
1253        let total: Decimal = ticks.iter().map(|t| t.quantity).sum();
1254        Some(total / Decimal::from(ticks.len() as u32))
1255    }
1256
1257    /// Fraction of total volume contributed by the largest quarter of trades.
1258    ///
1259    /// Trades are sorted by quantity descending; the top 25% (rounded up) are
1260    /// summed and divided by total volume. Returns `None` if total volume is
1261    /// zero or the slice is empty.
1262    pub fn volume_concentration(ticks: &[NormalizedTick]) -> Option<f64> {
1263        use rust_decimal::prelude::ToPrimitive;
1264        if ticks.is_empty() {
1265            return None;
1266        }
1267        let total: Decimal = ticks.iter().map(|t| t.quantity).sum();
1268        if total.is_zero() {
1269            return None;
1270        }
1271        let mut qtys: Vec<Decimal> = ticks.iter().map(|t| t.quantity).collect();
1272        qtys.sort_by(|a, b| b.cmp(a));
1273        let top_n = ((ticks.len() + 3) / 4).max(1);
1274        let top_vol: Decimal = qtys.iter().take(top_n).copied().sum();
1275        (top_vol / total).to_f64()
1276    }
1277
1278    /// Signed trade imbalance: `(buy_count − sell_count) / total`.
1279    ///
1280    /// Returns a value in [-1, 1]. Returns `None` if the slice is empty.
1281    pub fn trade_imbalance_score(ticks: &[NormalizedTick]) -> Option<f64> {
1282        if ticks.is_empty() {
1283            return None;
1284        }
1285        let n = ticks.len() as f64;
1286        let buys = Self::buy_count(ticks) as f64;
1287        let sells = Self::sell_count(ticks) as f64;
1288        Some((buys - sells) / n)
1289    }
1290
1291    /// Average price of buy-side ticks.
1292    ///
1293    /// Returns `None` if there are no buy-side ticks.
1294    pub fn buy_avg_price(ticks: &[NormalizedTick]) -> Option<Decimal> {
1295        let buys: Vec<_> = ticks.iter().filter(|t| t.is_buy()).collect();
1296        if buys.is_empty() {
1297            return None;
1298        }
1299        let sum: Decimal = buys.iter().map(|t| t.price).sum();
1300        Some(sum / Decimal::from(buys.len() as u32))
1301    }
1302
1303    /// Average price of sell-side ticks.
1304    ///
1305    /// Returns `None` if there are no sell-side ticks.
1306    pub fn sell_avg_price(ticks: &[NormalizedTick]) -> Option<Decimal> {
1307        let sells: Vec<_> = ticks.iter().filter(|t| t.is_sell()).collect();
1308        if sells.is_empty() {
1309            return None;
1310        }
1311        let sum: Decimal = sells.iter().map(|t| t.price).sum();
1312        Some(sum / Decimal::from(sells.len() as u32))
1313    }
1314
1315    /// Skewness of the price distribution across ticks.
1316    ///
1317    /// Uses the standard moment-based formula. Returns `None` if fewer than 3
1318    /// ticks or if the standard deviation is zero.
1319    pub fn price_skewness(ticks: &[NormalizedTick]) -> Option<f64> {
1320        use rust_decimal::prelude::ToPrimitive;
1321        let n = ticks.len();
1322        if n < 3 {
1323            return None;
1324        }
1325        let prices: Vec<f64> = ticks.iter().filter_map(|t| t.price.to_f64()).collect();
1326        if prices.len() != n {
1327            return None;
1328        }
1329        let nf = n as f64;
1330        let mean = prices.iter().sum::<f64>() / nf;
1331        let variance = prices.iter().map(|p| (p - mean).powi(2)).sum::<f64>() / nf;
1332        if variance == 0.0 {
1333            return None;
1334        }
1335        let std_dev = variance.sqrt();
1336        let skew = prices.iter().map(|p| ((p - mean) / std_dev).powi(3)).sum::<f64>() / nf;
1337        Some(skew)
1338    }
1339
1340    /// Skewness of the quantity distribution across ticks.
1341    ///
1342    /// Uses the standard moment-based formula. Returns `None` if fewer than 3
1343    /// ticks or if the standard deviation is zero.
1344    pub fn quantity_skewness(ticks: &[NormalizedTick]) -> Option<f64> {
1345        use rust_decimal::prelude::ToPrimitive;
1346        let n = ticks.len();
1347        if n < 3 {
1348            return None;
1349        }
1350        let qtys: Vec<f64> = ticks.iter().filter_map(|t| t.quantity.to_f64()).collect();
1351        if qtys.len() != n {
1352            return None;
1353        }
1354        let nf = n as f64;
1355        let mean = qtys.iter().sum::<f64>() / nf;
1356        let variance = qtys.iter().map(|q| (q - mean).powi(2)).sum::<f64>() / nf;
1357        if variance == 0.0 {
1358            return None;
1359        }
1360        let std_dev = variance.sqrt();
1361        let skew = qtys.iter().map(|q| ((q - mean) / std_dev).powi(3)).sum::<f64>() / nf;
1362        Some(skew)
1363    }
1364
1365    /// Shannon entropy of the price distribution across ticks.
1366    ///
1367    /// Each unique price is treated as a category. Returns `None` if the
1368    /// slice is empty or all ticks have the same price (zero entropy is
1369    /// returned as `Some(0.0)`).
1370    pub fn price_entropy(ticks: &[NormalizedTick]) -> Option<f64> {
1371        if ticks.is_empty() {
1372            return None;
1373        }
1374        let n = ticks.len() as f64;
1375        let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1376        for t in ticks {
1377            *counts.entry(t.price.to_string()).or_insert(0) += 1;
1378        }
1379        let entropy = counts.values().map(|&c| {
1380            let p = c as f64 / n;
1381            -p * p.ln()
1382        }).sum();
1383        Some(entropy)
1384    }
1385
1386    /// Excess kurtosis of the price distribution across ticks.
1387    ///
1388    /// Returns `None` if fewer than 4 ticks or if the standard deviation is
1389    /// zero.
1390    pub fn price_kurtosis(ticks: &[NormalizedTick]) -> Option<f64> {
1391        use rust_decimal::prelude::ToPrimitive;
1392        let n = ticks.len();
1393        if n < 4 {
1394            return None;
1395        }
1396        let prices: Vec<f64> = ticks.iter().filter_map(|t| t.price.to_f64()).collect();
1397        if prices.len() != n {
1398            return None;
1399        }
1400        let nf = n as f64;
1401        let mean = prices.iter().sum::<f64>() / nf;
1402        let variance = prices.iter().map(|p| (p - mean).powi(2)).sum::<f64>() / nf;
1403        if variance == 0.0 {
1404            return None;
1405        }
1406        let std_dev = variance.sqrt();
1407        let kurt = prices.iter().map(|p| ((p - mean) / std_dev).powi(4)).sum::<f64>() / nf - 3.0;
1408        Some(kurt)
1409    }
1410
1411    /// Count of ticks whose quantity exceeds `threshold`.
1412    pub fn high_volume_tick_count(ticks: &[NormalizedTick], threshold: Decimal) -> usize {
1413        ticks.iter().filter(|t| t.quantity > threshold).count()
1414    }
1415
1416    /// Difference between the buy-side average price and the sell-side average
1417    /// price (buy_avg - sell_avg).
1418    ///
1419    /// A positive value means buyers paid more on average than sellers; a
1420    /// negative value is unusual (market microstructure artifact). Returns
1421    /// `None` if either side has no ticks.
1422    pub fn vwap_spread(ticks: &[NormalizedTick]) -> Option<Decimal> {
1423        let buy = Self::buy_avg_price(ticks)?;
1424        let sell = Self::sell_avg_price(ticks)?;
1425        Some(buy - sell)
1426    }
1427
1428    /// Mean quantity of buy-side ticks.
1429    ///
1430    /// Returns `None` if there are no buy-side ticks.
1431    pub fn avg_buy_quantity(ticks: &[NormalizedTick]) -> Option<Decimal> {
1432        let buys: Vec<_> = ticks.iter().filter(|t| t.is_buy()).collect();
1433        if buys.is_empty() {
1434            return None;
1435        }
1436        let total: Decimal = buys.iter().map(|t| t.quantity).sum();
1437        Some(total / Decimal::from(buys.len() as u32))
1438    }
1439
1440    /// Mean quantity of sell-side ticks.
1441    ///
1442    /// Returns `None` if there are no sell-side ticks.
1443    pub fn avg_sell_quantity(ticks: &[NormalizedTick]) -> Option<Decimal> {
1444        let sells: Vec<_> = ticks.iter().filter(|t| t.is_sell()).collect();
1445        if sells.is_empty() {
1446            return None;
1447        }
1448        let total: Decimal = sells.iter().map(|t| t.quantity).sum();
1449        Some(total / Decimal::from(sells.len() as u32))
1450    }
1451
1452    /// Fraction of prices that are below the VWAP (mean-reversion pressure).
1453    ///
1454    /// Values close to 0.5 suggest equilibrium; values far from 0.5 suggest
1455    /// directional bias. Returns `None` if VWAP cannot be computed.
1456    pub fn price_mean_reversion_score(ticks: &[NormalizedTick]) -> Option<f64> {
1457        let vwap = Self::vwap(ticks)?;
1458        let below = ticks.iter().filter(|t| t.price < vwap).count();
1459        Some(below as f64 / ticks.len() as f64)
1460    }
1461
1462    /// Largest absolute price move between consecutive ticks.
1463    ///
1464    /// Returns `None` if fewer than 2 ticks.
1465    pub fn largest_price_move(ticks: &[NormalizedTick]) -> Option<Decimal> {
1466        if ticks.len() < 2 {
1467            return None;
1468        }
1469        ticks.windows(2).map(|w| (w[1].price - w[0].price).abs()).reduce(Decimal::max)
1470    }
1471
1472    /// Number of ticks per millisecond of elapsed time.
1473    ///
1474    /// Returns `None` if fewer than 2 ticks or the time span is zero.
1475    pub fn tick_rate(ticks: &[NormalizedTick]) -> Option<f64> {
1476        let span = Self::time_span_ms(ticks)? as f64;
1477        if span == 0.0 {
1478            return None;
1479        }
1480        Some(ticks.len() as f64 / span)
1481    }
1482
1483    /// Fraction of total notional volume that comes from buy-side trades.
1484    ///
1485    /// Returns `None` if total notional volume is zero or the slice is empty.
1486    pub fn buy_notional_fraction(ticks: &[NormalizedTick]) -> Option<f64> {
1487        use rust_decimal::prelude::ToPrimitive;
1488        let total = Self::total_notional(ticks);
1489        if total.is_zero() {
1490            return None;
1491        }
1492        let buy = Self::buy_notional(ticks);
1493        (buy / total).to_f64()
1494    }
1495
1496    /// Price range as a percentage of the minimum price.
1497    ///
1498    /// `(max_price - min_price) / min_price * 100`. Returns `None` if the
1499    /// slice is empty or the minimum price is zero.
1500    pub fn price_range_pct(ticks: &[NormalizedTick]) -> Option<f64> {
1501        use rust_decimal::prelude::ToPrimitive;
1502        let min = Self::min_price(ticks)?;
1503        let max = Self::max_price(ticks)?;
1504        if min.is_zero() {
1505            return None;
1506        }
1507        ((max - min) / min * Decimal::ONE_HUNDRED).to_f64()
1508    }
1509
1510    /// Buy-side dominance: `buy_volume / (buy_volume + sell_volume)`.
1511    ///
1512    /// Returns a value in [0, 1]. Returns `None` if both sides are zero.
1513    pub fn buy_side_dominance(ticks: &[NormalizedTick]) -> Option<f64> {
1514        use rust_decimal::prelude::ToPrimitive;
1515        let buy = Self::buy_volume(ticks);
1516        let sell = Self::sell_volume(ticks);
1517        let total = buy + sell;
1518        if total.is_zero() {
1519            return None;
1520        }
1521        (buy / total).to_f64()
1522    }
1523
1524    /// Standard deviation of price weighted by quantity.
1525    ///
1526    /// Uses VWAP as the mean. Returns `None` if empty or VWAP cannot be
1527    /// computed.
1528    pub fn volume_weighted_price_std(ticks: &[NormalizedTick]) -> Option<f64> {
1529        use rust_decimal::prelude::ToPrimitive;
1530        let vwap = Self::vwap(ticks)?;
1531        let total_qty: Decimal = ticks.iter().map(|t| t.quantity).sum();
1532        if total_qty.is_zero() {
1533            return None;
1534        }
1535        let variance: Decimal = ticks.iter()
1536            .map(|t| {
1537                let diff = t.price - vwap;
1538                diff * diff * t.quantity
1539            })
1540            .sum::<Decimal>() / total_qty;
1541        variance.to_f64().map(f64::sqrt)
1542    }
1543
1544    /// VWAP computed over just the last `n` ticks.
1545    ///
1546    /// Returns `None` if `n` is 0 or the slice has no ticks, or if total
1547    /// volume of the window is zero.
1548    pub fn last_n_vwap(ticks: &[NormalizedTick], n: usize) -> Option<Decimal> {
1549        if n == 0 || ticks.is_empty() {
1550            return None;
1551        }
1552        let window = &ticks[ticks.len().saturating_sub(n)..];
1553        Self::vwap(window)
1554    }
1555
1556    /// Lag-1 autocorrelation of the price series across ticks.
1557    ///
1558    /// Uses the Pearson formula on consecutive price pairs. Returns `None` if
1559    /// fewer than 3 ticks or if the variance is zero.
1560    pub fn price_autocorrelation(ticks: &[NormalizedTick]) -> Option<f64> {
1561        use rust_decimal::prelude::ToPrimitive;
1562        let n = ticks.len();
1563        if n < 3 {
1564            return None;
1565        }
1566        let prices: Vec<f64> = ticks.iter().filter_map(|t| t.price.to_f64()).collect();
1567        if prices.len() != n {
1568            return None;
1569        }
1570        let nf = (n - 1) as f64;
1571        let mean = prices.iter().sum::<f64>() / n as f64;
1572        let var = prices.iter().map(|p| (p - mean).powi(2)).sum::<f64>() / n as f64;
1573        if var == 0.0 {
1574            return None;
1575        }
1576        let cov: f64 = prices.windows(2).map(|w| (w[0] - mean) * (w[1] - mean)).sum::<f64>() / nf;
1577        Some(cov / var)
1578    }
1579
1580    /// Net trade direction score: `(buy_count - sell_count)` as a signed integer.
1581    pub fn net_trade_direction(ticks: &[NormalizedTick]) -> i64 {
1582        Self::buy_count(ticks) as i64 - Self::sell_count(ticks) as i64
1583    }
1584
1585    /// Fraction of total notional volume that comes from sell-side trades.
1586    ///
1587    /// Returns `None` if total notional volume is zero or the slice is empty.
1588    pub fn sell_side_notional_fraction(ticks: &[NormalizedTick]) -> Option<f64> {
1589        use rust_decimal::prelude::ToPrimitive;
1590        let total = Self::total_notional(ticks);
1591        if total.is_zero() {
1592            return None;
1593        }
1594        let sell = Self::sell_notional(ticks);
1595        (sell / total).to_f64()
1596    }
1597
1598    /// Count of price direction reversals (sign changes in consecutive moves).
1599    ///
1600    /// Returns 0 if fewer than 3 ticks.
1601    pub fn price_oscillation_count(ticks: &[NormalizedTick]) -> usize {
1602        if ticks.len() < 3 {
1603            return 0;
1604        }
1605        ticks.windows(3).filter(|w| {
1606            let d1 = w[1].price.cmp(&w[0].price);
1607            let d2 = w[2].price.cmp(&w[1].price);
1608            use std::cmp::Ordering::*;
1609            matches!((d1, d2), (Greater, Less) | (Less, Greater))
1610        }).count()
1611    }
1612
1613    /// Realized spread: mean buy price minus mean sell price.
1614    ///
1615    /// A positive value suggests buys are executed at higher prices than sells
1616    /// (typical in a two-sided market). Returns `None` if either side has no
1617    /// ticks.
1618    pub fn realized_spread(ticks: &[NormalizedTick]) -> Option<Decimal> {
1619        let buy_avg = Self::buy_avg_price(ticks)?;
1620        let sell_avg = Self::sell_avg_price(ticks)?;
1621        Some(buy_avg - sell_avg)
1622    }
1623
1624    /// Adverse selection score: fraction of large trades that moved the price
1625    /// against the initiator (proxy for informed trading).
1626    ///
1627    /// A "large" trade is one with quantity above the window median quantity.
1628    /// Returns `None` if the slice has fewer than 3 ticks or median is zero.
1629    pub fn adverse_selection_score(ticks: &[NormalizedTick]) -> Option<f64> {
1630        if ticks.len() < 3 {
1631            return None;
1632        }
1633        let median_qty = Self::median_price(
1634            &ticks.iter().map(|t| {
1635                let mut cloned = t.clone();
1636                cloned.price = t.quantity;
1637                cloned
1638            }).collect::<Vec<_>>()
1639        )?;
1640        if median_qty.is_zero() {
1641            return None;
1642        }
1643        let large_trades: Vec<_> = ticks.windows(2)
1644            .filter(|w| w[0].quantity > median_qty)
1645            .collect();
1646        if large_trades.is_empty() {
1647            return None;
1648        }
1649        // Adverse if the next price moved against the initiating side
1650        let adverse = large_trades.iter().filter(|w| {
1651            let price_moved_up = w[1].price > w[0].price;
1652            match w[0].side {
1653                Some(TradeSide::Buy) => !price_moved_up,  // buy but price fell
1654                Some(TradeSide::Sell) => price_moved_up,  // sell but price rose
1655                None => false,
1656            }
1657        }).count();
1658        Some(adverse as f64 / large_trades.len() as f64)
1659    }
1660
1661    /// Price impact per unit of volume: `|price_return| / total_volume`.
1662    ///
1663    /// Returns `None` if fewer than 2 ticks or total volume is zero.
1664    pub fn price_impact_per_unit(ticks: &[NormalizedTick]) -> Option<f64> {
1665        use rust_decimal::prelude::ToPrimitive;
1666        let ret = (Self::price_return_pct(ticks)?.abs()) as f64;
1667        let vol = Self::buy_volume(ticks) + Self::sell_volume(ticks);
1668        if vol.is_zero() {
1669            return None;
1670        }
1671        vol.to_f64().map(|v| ret / v)
1672    }
1673
1674    /// Volume-weighted return: VWAP of returns weighted by quantity.
1675    ///
1676    /// Computes `(p_i - p_{i-1}) / p_{i-1}` for each consecutive pair,
1677    /// weighted by `qty_i`, then sums weighted returns. Returns `None` if
1678    /// fewer than 2 ticks or total quantity is zero.
1679    pub fn volume_weighted_return(ticks: &[NormalizedTick]) -> Option<f64> {
1680        use rust_decimal::prelude::ToPrimitive;
1681        if ticks.len() < 2 {
1682            return None;
1683        }
1684        let total_qty: Decimal = ticks[1..].iter().map(|t| t.quantity).sum();
1685        if total_qty.is_zero() {
1686            return None;
1687        }
1688        let weighted: f64 = ticks.windows(2).filter_map(|w| {
1689            if w[0].price.is_zero() { return None; }
1690            let ret = ((w[1].price - w[0].price) / w[0].price).to_f64()?;
1691            let qty = w[1].quantity.to_f64()?;
1692            Some(ret * qty)
1693        }).sum::<f64>();
1694        total_qty.to_f64().map(|tq| weighted / tq)
1695    }
1696
1697    /// Gini-coefficient-style quantity concentration.
1698    ///
1699    /// `sum_i sum_j |q_i - q_j| / (2 * n^2 * mean_q)`. Returns `None` if
1700    /// the slice is empty or the mean quantity is zero.
1701    pub fn quantity_concentration(ticks: &[NormalizedTick]) -> Option<f64> {
1702        use rust_decimal::prelude::ToPrimitive;
1703        let n = ticks.len();
1704        if n == 0 {
1705            return None;
1706        }
1707        let total: Decimal = ticks.iter().map(|t| t.quantity).sum();
1708        if total.is_zero() {
1709            return None;
1710        }
1711        let mean = total / Decimal::from(n as u32);
1712        let mut sum = Decimal::ZERO;
1713        for i in 0..n {
1714            for j in 0..n {
1715                sum += (ticks[i].quantity - ticks[j].quantity).abs();
1716            }
1717        }
1718        let denom = mean * Decimal::from((2 * n * n) as u32);
1719        if denom.is_zero() {
1720            return None;
1721        }
1722        (sum / denom).to_f64()
1723    }
1724
1725    /// Total volume traded at a specific price level.
1726    pub fn price_level_volume(ticks: &[NormalizedTick], price: Decimal) -> Decimal {
1727        ticks.iter().filter(|t| t.price == price).map(|t| t.quantity).sum()
1728    }
1729
1730    /// Drift of the mid-price proxy across ticks.
1731    ///
1732    /// Defined as `(last_price - first_price) / time_span_ms`. Returns `None`
1733    /// if fewer than 2 ticks or the time span is zero.
1734    pub fn mid_price_drift(ticks: &[NormalizedTick]) -> Option<f64> {
1735        use rust_decimal::prelude::ToPrimitive;
1736        let first = Self::first_price(ticks)?;
1737        let last = Self::last_price(ticks)?;
1738        let span = Self::time_span_ms(ticks)? as f64;
1739        if span == 0.0 {
1740            return None;
1741        }
1742        (last - first).to_f64().map(|d| d / span)
1743    }
1744
1745    /// Tick direction bias: fraction of consecutive moves in the same direction.
1746    ///
1747    /// Counts windows where `price[i+1]` moved in the same direction as
1748    /// `price[i]` vs `price[i-1]`. Returns `None` if fewer than 3 ticks.
1749    pub fn tick_direction_bias(ticks: &[NormalizedTick]) -> Option<f64> {
1750        if ticks.len() < 3 {
1751            return None;
1752        }
1753        let total = ticks.len() - 2;
1754        let same = ticks.windows(3).filter(|w| {
1755            let d1 = w[1].price.cmp(&w[0].price);
1756            let d2 = w[2].price.cmp(&w[1].price);
1757            d1 == d2 && d1 != std::cmp::Ordering::Equal
1758        }).count();
1759        Some(same as f64 / total as f64)
1760    }
1761
1762    /// Median trade quantity across all ticks.
1763    ///
1764    /// Returns `None` if the slice is empty.
1765    pub fn median_quantity(ticks: &[NormalizedTick]) -> Option<Decimal> {
1766        if ticks.is_empty() {
1767            return None;
1768        }
1769        let mut qtys: Vec<Decimal> = ticks.iter().map(|t| t.quantity).collect();
1770        qtys.sort();
1771        let n = qtys.len();
1772        if n % 2 == 1 {
1773            Some(qtys[n / 2])
1774        } else {
1775            Some((qtys[n / 2 - 1] + qtys[n / 2]) / Decimal::TWO)
1776        }
1777    }
1778
1779    /// Total volume from ticks priced strictly above the VWAP of the slice.
1780    ///
1781    /// Returns `None` if VWAP cannot be computed (empty or zero total quantity).
1782    pub fn volume_above_vwap(ticks: &[NormalizedTick]) -> Option<Decimal> {
1783        let vwap = Self::vwap(ticks)?;
1784        Some(ticks.iter().filter(|t| t.price > vwap).map(|t| t.quantity).sum())
1785    }
1786
1787    /// Variance of inter-tick arrival times in milliseconds.
1788    ///
1789    /// Returns `None` if fewer than 3 ticks are provided (need ≥ 2 intervals).
1790    pub fn inter_arrival_variance(ticks: &[NormalizedTick]) -> Option<f64> {
1791        if ticks.len() < 3 {
1792            return None;
1793        }
1794        let intervals: Vec<f64> = ticks.windows(2)
1795            .filter_map(|w| {
1796                let dt = w[1].received_at_ms.checked_sub(w[0].received_at_ms)?;
1797                Some(dt as f64)
1798            })
1799            .collect();
1800        if intervals.len() < 2 {
1801            return None;
1802        }
1803        let n = intervals.len() as f64;
1804        let mean = intervals.iter().sum::<f64>() / n;
1805        let variance = intervals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
1806        Some(variance)
1807    }
1808
1809    /// Price spread efficiency: net price move divided by total path length.
1810    ///
1811    /// A value of 1.0 means prices moved directly with no reversals; values
1812    /// near 0.0 indicate heavy oscillation. Returns `None` if the slice has
1813    /// fewer than 2 ticks or the path length is zero.
1814    pub fn spread_efficiency(ticks: &[NormalizedTick]) -> Option<f64> {
1815        use rust_decimal::prelude::ToPrimitive;
1816        if ticks.len() < 2 {
1817            return None;
1818        }
1819        let path: Decimal = ticks.windows(2)
1820            .map(|w| (w[1].price - w[0].price).abs())
1821            .sum();
1822        if path.is_zero() {
1823            return None;
1824        }
1825        let net = (ticks.last()?.price - ticks.first()?.price).abs();
1826        (net / path).to_f64()
1827    }
1828
1829    /// Ratio of average buy quantity to average sell quantity.
1830    ///
1831    /// Returns `None` if there are no buy ticks or no sell ticks, or if avg
1832    /// sell quantity is zero.
1833    pub fn buy_sell_size_ratio(ticks: &[NormalizedTick]) -> Option<f64> {
1834        use rust_decimal::prelude::ToPrimitive;
1835        let avg_buy = Self::avg_buy_quantity(ticks)?;
1836        let avg_sell = Self::avg_sell_quantity(ticks)?;
1837        if avg_sell.is_zero() {
1838            return None;
1839        }
1840        (avg_buy / avg_sell).to_f64()
1841    }
1842
1843    /// Standard deviation of trade quantities across all ticks.
1844    ///
1845    /// Returns `None` if fewer than 2 ticks are provided.
1846    pub fn trade_size_dispersion(ticks: &[NormalizedTick]) -> Option<f64> {
1847        use rust_decimal::prelude::ToPrimitive;
1848        if ticks.len() < 2 {
1849            return None;
1850        }
1851        let vals: Vec<f64> = ticks.iter().filter_map(|t| t.quantity.to_f64()).collect();
1852        if vals.len() < 2 {
1853            return None;
1854        }
1855        let n = vals.len() as f64;
1856        let mean = vals.iter().sum::<f64>() / n;
1857        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
1858        Some(variance.sqrt())
1859    }
1860
1861    // ── round-79 ─────────────────────────────────────────────────────────────
1862
1863    /// Fraction of ticks for which the trade side is known (non-`None`).
1864    ///
1865    /// Returns `None` for an empty slice.
1866    ///
1867    /// A value near 1.0 indicates the feed reliably reports aggressor side;
1868    /// a value near 0.0 means most ticks are neutral.
1869    pub fn aggressor_fraction(ticks: &[NormalizedTick]) -> Option<f64> {
1870        if ticks.is_empty() {
1871            return None;
1872        }
1873        let known = ticks.iter().filter(|t| t.side.is_some()).count();
1874        Some(known as f64 / ticks.len() as f64)
1875    }
1876
1877    /// Signed volume imbalance: `(buy_vol − sell_vol) / (buy_vol + sell_vol)`.
1878    ///
1879    /// Returns a value in `(−1, +1)`. Positive means net buying pressure,
1880    /// negative means net selling pressure. Returns `None` when the total
1881    /// known-side volume is zero (all ticks are neutral or the slice is empty).
1882    pub fn volume_imbalance_ratio(ticks: &[NormalizedTick]) -> Option<f64> {
1883        use rust_decimal::prelude::ToPrimitive;
1884        let buy = Self::buy_volume(ticks);
1885        let sell = Self::sell_volume(ticks);
1886        let total = buy + sell;
1887        if total.is_zero() {
1888            return None;
1889        }
1890        ((buy - sell) / total).to_f64()
1891    }
1892
1893    /// Covariance between price and quantity across the tick slice.
1894    ///
1895    /// Returns `None` if fewer than 2 ticks are provided or if any value
1896    /// cannot be converted to `f64`.
1897    ///
1898    /// A positive covariance indicates that larger trades tend to occur at
1899    /// higher prices; negative means larger trades skew toward lower prices.
1900    pub fn price_quantity_covariance(ticks: &[NormalizedTick]) -> Option<f64> {
1901        use rust_decimal::prelude::ToPrimitive;
1902        if ticks.len() < 2 {
1903            return None;
1904        }
1905        let prices: Vec<f64> = ticks.iter().filter_map(|t| t.price.to_f64()).collect();
1906        let qtys: Vec<f64> = ticks.iter().filter_map(|t| t.quantity.to_f64()).collect();
1907        if prices.len() != ticks.len() || qtys.len() != ticks.len() {
1908            return None;
1909        }
1910        let n = prices.len() as f64;
1911        let mean_p = prices.iter().sum::<f64>() / n;
1912        let mean_q = qtys.iter().sum::<f64>() / n;
1913        let cov = prices
1914            .iter()
1915            .zip(qtys.iter())
1916            .map(|(p, q)| (p - mean_p) * (q - mean_q))
1917            .sum::<f64>()
1918            / (n - 1.0);
1919        Some(cov)
1920    }
1921
1922    /// Fraction of ticks whose quantity meets or exceeds `threshold`.
1923    ///
1924    /// Returns `None` for an empty slice. The result is in `[0.0, 1.0]`.
1925    /// Useful for characterising how "institutional" the flow is.
1926    pub fn large_trade_fraction(ticks: &[NormalizedTick], threshold: Decimal) -> Option<f64> {
1927        if ticks.is_empty() {
1928            return None;
1929        }
1930        let count = Self::large_trade_count(ticks, threshold);
1931        Some(count as f64 / ticks.len() as f64)
1932    }
1933
1934    /// Number of unique price levels per unit of price range.
1935    ///
1936    /// Computed as `unique_price_count / price_range`. Returns `None` when
1937    /// the slice is empty or the price range is zero (all ticks at one price).
1938    ///
1939    /// High density implies granular price action; low density implies
1940    /// price jumps between a few discrete levels.
1941    pub fn price_level_density(ticks: &[NormalizedTick]) -> Option<f64> {
1942        use rust_decimal::prelude::ToPrimitive;
1943        let range = Self::price_range(ticks)?;
1944        if range.is_zero() {
1945            return None;
1946        }
1947        let unique = Self::unique_price_count(ticks) as f64;
1948        (Decimal::from(Self::unique_price_count(ticks) as i64) / range).to_f64()
1949            .or_else(|| Some(unique / range.to_f64()?))
1950    }
1951
1952    /// Ratio of buy-side notional to sell-side notional.
1953    ///
1954    /// Returns `None` when there are no sell-side ticks or sell notional is
1955    /// zero. A value above 1.0 means buy-side dollar flow dominates.
1956    pub fn notional_buy_sell_ratio(ticks: &[NormalizedTick]) -> Option<f64> {
1957        use rust_decimal::prelude::ToPrimitive;
1958        let buy_n = Self::buy_notional(ticks);
1959        let sell_n = Self::sell_notional(ticks);
1960        if sell_n.is_zero() {
1961            return None;
1962        }
1963        (buy_n / sell_n).to_f64()
1964    }
1965
1966    /// Mean of tick-to-tick log returns: `mean(ln(p_i / p_{i-1}))`.
1967    ///
1968    /// Returns `None` if fewer than 2 ticks are provided or if any price
1969    /// is zero (which would make the log undefined).
1970    pub fn log_return_mean(ticks: &[NormalizedTick]) -> Option<f64> {
1971        if ticks.len() < 2 {
1972            return None;
1973        }
1974        let returns: Vec<f64> = ticks
1975            .windows(2)
1976            .filter_map(|w| {
1977                use rust_decimal::prelude::ToPrimitive;
1978                let prev = w[0].price.to_f64()?;
1979                let curr = w[1].price.to_f64()?;
1980                if prev <= 0.0 || curr <= 0.0 {
1981                    return None;
1982                }
1983                Some((curr / prev).ln())
1984            })
1985            .collect();
1986        if returns.is_empty() {
1987            return None;
1988        }
1989        Some(returns.iter().sum::<f64>() / returns.len() as f64)
1990    }
1991
1992    /// Standard deviation of tick-to-tick log returns.
1993    ///
1994    /// Returns `None` if fewer than 3 ticks are provided (need at least 2
1995    /// returns for a meaningful std-dev) or if any price is zero.
1996    pub fn log_return_std(ticks: &[NormalizedTick]) -> Option<f64> {
1997        if ticks.len() < 3 {
1998            return None;
1999        }
2000        let returns: Vec<f64> = ticks
2001            .windows(2)
2002            .filter_map(|w| {
2003                use rust_decimal::prelude::ToPrimitive;
2004                let prev = w[0].price.to_f64()?;
2005                let curr = w[1].price.to_f64()?;
2006                if prev <= 0.0 || curr <= 0.0 {
2007                    return None;
2008                }
2009                Some((curr / prev).ln())
2010            })
2011            .collect();
2012        if returns.len() < 2 {
2013            return None;
2014        }
2015        let n = returns.len() as f64;
2016        let mean = returns.iter().sum::<f64>() / n;
2017        let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
2018        Some(variance.sqrt())
2019    }
2020
2021    /// Ratio of the maximum price to the last price: `max_price / last_price`.
2022    ///
2023    /// Returns `None` for an empty slice or if the last price is zero.
2024    ///
2025    /// A value above 1.0 indicates that the price overshot the closing
2026    /// level during the window — useful as an intrabar momentum signal.
2027    pub fn price_overshoot_ratio(ticks: &[NormalizedTick]) -> Option<f64> {
2028        use rust_decimal::prelude::ToPrimitive;
2029        let max_p = Self::max_price(ticks)?;
2030        let last_p = Self::last_price(ticks)?;
2031        if last_p.is_zero() {
2032            return None;
2033        }
2034        (max_p / last_p).to_f64()
2035    }
2036
2037    /// Ratio of the first price to the minimum price: `first_price / min_price`.
2038    ///
2039    /// Returns `None` for an empty slice or if the minimum price is zero.
2040    ///
2041    /// A value above 1.0 indicates the window opened above its trough,
2042    /// meaning the price undershot the opening level at some point.
2043    pub fn price_undershoot_ratio(ticks: &[NormalizedTick]) -> Option<f64> {
2044        use rust_decimal::prelude::ToPrimitive;
2045        let first_p = Self::first_price(ticks)?;
2046        let min_p = Self::min_price(ticks)?;
2047        if min_p.is_zero() {
2048            return None;
2049        }
2050        (first_p / min_p).to_f64()
2051    }
2052
2053    // ── round-80 ─────────────────────────────────────────────────────────────
2054
2055    /// Net notional: `buy_notional − sell_notional` across the slice.
2056    ///
2057    /// Positive means net buying pressure in dollar terms; negative means
2058    /// net selling pressure. Returns `Decimal::ZERO` for empty slices or
2059    /// slices with no sided ticks.
2060    pub fn net_notional(ticks: &[NormalizedTick]) -> Decimal {
2061        Self::buy_notional(ticks) - Self::sell_notional(ticks)
2062    }
2063
2064    /// Count of price direction reversals across the slice.
2065    ///
2066    /// A reversal occurs when consecutive price moves change sign (up→down or
2067    /// down→up), ignoring flat moves. Returns 0 for fewer than 3 ticks.
2068    pub fn price_reversal_count(ticks: &[NormalizedTick]) -> usize {
2069        if ticks.len() < 3 {
2070            return 0;
2071        }
2072        let mut count = 0usize;
2073        for w in ticks.windows(3) {
2074            let d1 = w[1].price - w[0].price;
2075            let d2 = w[2].price - w[1].price;
2076            if (d1 > Decimal::ZERO && d2 < Decimal::ZERO)
2077                || (d1 < Decimal::ZERO && d2 > Decimal::ZERO)
2078            {
2079                count += 1;
2080            }
2081        }
2082        count
2083    }
2084
2085    /// Excess kurtosis of trade quantities across the slice.
2086    ///
2087    /// `kurtosis = (Σ((q − mean)⁴ / n) / std_dev⁴) − 3`
2088    ///
2089    /// Returns `None` if the slice has fewer than 4 ticks or std dev is zero.
2090    pub fn quantity_kurtosis(ticks: &[NormalizedTick]) -> Option<f64> {
2091        use rust_decimal::prelude::ToPrimitive;
2092        if ticks.len() < 4 {
2093            return None;
2094        }
2095        let vals: Vec<f64> = ticks.iter().filter_map(|t| t.quantity.to_f64()).collect();
2096        if vals.len() < 4 {
2097            return None;
2098        }
2099        let n_f = vals.len() as f64;
2100        let mean = vals.iter().sum::<f64>() / n_f;
2101        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n_f;
2102        let std_dev = variance.sqrt();
2103        if std_dev == 0.0 {
2104            return None;
2105        }
2106        Some(vals.iter().map(|v| ((v - mean) / std_dev).powi(4)).sum::<f64>() / n_f - 3.0)
2107    }
2108
2109    /// Reference to the tick with the highest notional value (`price × quantity`).
2110    ///
2111    /// Unlike [`largest_trade`](Self::largest_trade) which ranks by raw quantity,
2112    /// this method ranks by dollar value, making it suitable for comparing
2113    /// trades across different price levels.
2114    ///
2115    /// Returns `None` if the slice is empty.
2116    pub fn largest_notional_trade(ticks: &[NormalizedTick]) -> Option<&NormalizedTick> {
2117        ticks.iter().max_by(|a, b| a.value().cmp(&b.value()))
2118    }
2119
2120    /// Time-weighted average price (TWAP) using `received_at_ms` timestamps.
2121    ///
2122    /// Each price is weighted by the time interval to the next tick. The last
2123    /// tick carries zero weight (no interval after it). Returns `None` if
2124    /// fewer than 2 ticks are provided or the total time span is zero.
2125    pub fn twap(ticks: &[NormalizedTick]) -> Option<Decimal> {
2126        if ticks.len() < 2 {
2127            return None;
2128        }
2129        let mut weighted_sum = Decimal::ZERO;
2130        let mut total_time = 0u64;
2131        for w in ticks.windows(2) {
2132            let dt = w[1].received_at_ms.saturating_sub(w[0].received_at_ms);
2133            weighted_sum += w[0].price * Decimal::from(dt);
2134            total_time += dt;
2135        }
2136        if total_time == 0 {
2137            return None;
2138        }
2139        Some(weighted_sum / Decimal::from(total_time))
2140    }
2141
2142    /// Fraction of ticks whose side is `None` (no aggressor information).
2143    ///
2144    /// Returns `None` for an empty slice.
2145    /// Complement of [`aggressor_fraction`](Self::aggressor_fraction).
2146    pub fn neutral_fraction(ticks: &[NormalizedTick]) -> Option<f64> {
2147        if ticks.is_empty() {
2148            return None;
2149        }
2150        Some(Self::count_neutral(ticks) as f64 / ticks.len() as f64)
2151    }
2152
2153    /// Variance of tick-to-tick log returns: `var(ln(p_i / p_{i-1}))`.
2154    ///
2155    /// Returns `None` if fewer than 3 ticks or any price is non-positive.
2156    pub fn log_return_variance(ticks: &[NormalizedTick]) -> Option<f64> {
2157        if ticks.len() < 3 {
2158            return None;
2159        }
2160        let returns: Vec<f64> = ticks
2161            .windows(2)
2162            .filter_map(|w| {
2163                use rust_decimal::prelude::ToPrimitive;
2164                let prev = w[0].price.to_f64()?;
2165                let curr = w[1].price.to_f64()?;
2166                if prev <= 0.0 || curr <= 0.0 {
2167                    return None;
2168                }
2169                Some((curr / prev).ln())
2170            })
2171            .collect();
2172        if returns.len() < 2 {
2173            return None;
2174        }
2175        let n = returns.len() as f64;
2176        let mean = returns.iter().sum::<f64>() / n;
2177        Some(returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0))
2178    }
2179
2180    /// Total quantity traded at prices within `tolerance` of the VWAP.
2181    ///
2182    /// Returns `Decimal::ZERO` if the slice is empty, VWAP cannot be computed,
2183    /// or no ticks fall within the tolerance band.
2184    pub fn volume_at_vwap(ticks: &[NormalizedTick], tolerance: Decimal) -> Decimal {
2185        let vwap = match Self::vwap(ticks) {
2186            Some(v) => v,
2187            None => return Decimal::ZERO,
2188        };
2189        ticks
2190            .iter()
2191            .filter(|t| (t.price - vwap).abs() <= tolerance)
2192            .map(|t| t.quantity)
2193            .sum()
2194    }
2195
2196    // ── round-81 ─────────────────────────────────────────────────────────────
2197
2198    /// Cumulative volume as a `Vec` of running totals, one entry per tick.
2199    ///
2200    /// The first entry equals `ticks[0].quantity`; the last equals the total
2201    /// volume. Returns an empty `Vec` for an empty slice.
2202    pub fn cumulative_volume(ticks: &[NormalizedTick]) -> Vec<Decimal> {
2203        let mut acc = Decimal::ZERO;
2204        ticks
2205            .iter()
2206            .map(|t| {
2207                acc += t.quantity;
2208                acc
2209            })
2210            .collect()
2211    }
2212
2213    /// Ratio of price range to mean price: `(max − min) / mean`.
2214    ///
2215    /// A dimensionless measure of relative price dispersion across the slice.
2216    /// Returns `None` for an empty slice or if the mean is zero.
2217    pub fn price_volatility_ratio(ticks: &[NormalizedTick]) -> Option<f64> {
2218        use rust_decimal::prelude::ToPrimitive;
2219        let range = Self::price_range(ticks)?;
2220        let mean = Self::average_price(ticks)?;
2221        if mean.is_zero() {
2222            return None;
2223        }
2224        (range / mean).to_f64()
2225    }
2226
2227    /// Mean notional value per tick: `total_notional / tick_count`.
2228    ///
2229    /// Alias for [`average_notional`](Self::average_notional) expressed as
2230    /// `f64` for ML feature pipelines.
2231    ///
2232    /// Returns `None` for an empty slice.
2233    pub fn notional_per_tick(ticks: &[NormalizedTick]) -> Option<f64> {
2234        use rust_decimal::prelude::ToPrimitive;
2235        Self::average_notional(ticks)?.to_f64()
2236    }
2237
2238    /// Fraction of total volume (buy + sell + neutral) that is buy-initiated.
2239    ///
2240    /// Returns `None` for an empty slice or when total volume is zero.
2241    pub fn buy_to_total_volume_ratio(ticks: &[NormalizedTick]) -> Option<f64> {
2242        use rust_decimal::prelude::ToPrimitive;
2243        if ticks.is_empty() {
2244            return None;
2245        }
2246        let total: Decimal = ticks.iter().map(|t| t.quantity).sum();
2247        if total.is_zero() {
2248            return None;
2249        }
2250        (Self::buy_volume(ticks) / total).to_f64()
2251    }
2252
2253    /// Mean transport latency (ms) for ticks that carry an exchange timestamp.
2254    ///
2255    /// Averages `received_at_ms − exchange_ts_ms` over ticks where
2256    /// `exchange_ts_ms` is `Some`. Returns `None` if no such ticks exist.
2257    pub fn avg_latency_ms(ticks: &[NormalizedTick]) -> Option<f64> {
2258        let latencies: Vec<i64> = ticks.iter().filter_map(|t| t.latency_ms()).collect();
2259        if latencies.is_empty() {
2260            return None;
2261        }
2262        Some(latencies.iter().sum::<i64>() as f64 / latencies.len() as f64)
2263    }
2264
2265    /// Gini coefficient of trade prices across the slice.
2266    ///
2267    /// Measures price inequality: 0 means all trades at the same price,
2268    /// 1 means maximum price dispersion. Returns `None` if the slice is
2269    /// empty or all prices are zero.
2270    pub fn price_gini(ticks: &[NormalizedTick]) -> Option<f64> {
2271        use rust_decimal::prelude::ToPrimitive;
2272        if ticks.is_empty() {
2273            return None;
2274        }
2275        let mut prices: Vec<f64> = ticks.iter().filter_map(|t| t.price.to_f64()).collect();
2276        if prices.is_empty() {
2277            return None;
2278        }
2279        prices.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2280        let n = prices.len() as f64;
2281        let sum: f64 = prices.iter().sum();
2282        if sum == 0.0 {
2283            return None;
2284        }
2285        let weighted_sum: f64 = prices
2286            .iter()
2287            .enumerate()
2288            .map(|(i, &p)| (2.0 * (i + 1) as f64 - n - 1.0) * p)
2289            .sum();
2290        Some(weighted_sum / (n * sum))
2291    }
2292
2293    /// Rate of tick arrival: `tick_count / time_span_ms`.
2294    ///
2295    /// Returns ticks per millisecond. Returns `None` if the slice has fewer
2296    /// than 2 ticks or the time span is zero.
2297    pub fn trade_velocity(ticks: &[NormalizedTick]) -> Option<f64> {
2298        let span_ms = Self::time_span_ms(ticks)?;
2299        if span_ms == 0 {
2300            return None;
2301        }
2302        Some(ticks.len() as f64 / span_ms as f64)
2303    }
2304
2305    /// Minimum price across the slice.
2306    ///
2307    /// Semantic alias for [`min_price`](Self::min_price) that matches the
2308    /// "floor" framing used in support/resistance analysis.
2309    ///
2310    /// Returns `None` if the slice is empty.
2311    pub fn floor_price(ticks: &[NormalizedTick]) -> Option<Decimal> {
2312        Self::min_price(ticks)
2313    }
2314
2315    // ── round-82 ─────────────────────────────────────────────────────────────
2316
2317    /// Quantity-weighted mean of signed price changes; positive = net upward momentum.
2318    pub fn price_momentum_score(ticks: &[NormalizedTick]) -> Option<f64> {
2319        use rust_decimal::prelude::ToPrimitive;
2320        if ticks.len() < 2 {
2321            return None;
2322        }
2323        let mut num = 0f64;
2324        let mut den = 0f64;
2325        for w in ticks.windows(2) {
2326            let dp = (w[1].price - w[0].price).to_f64()?;
2327            let q = w[1].quantity.to_f64()?;
2328            num += dp * q;
2329            den += q;
2330        }
2331        if den == 0.0 { None } else { Some(num / den) }
2332    }
2333
2334    /// Std dev of prices weighted by quantity (dispersion around VWAP).
2335    pub fn vwap_std(ticks: &[NormalizedTick]) -> Option<f64> {
2336        use rust_decimal::prelude::ToPrimitive;
2337        if ticks.len() < 2 {
2338            return None;
2339        }
2340        let vwap = Self::vwap(ticks)?.to_f64()?;
2341        let total_vol: f64 = ticks.iter().filter_map(|t| t.quantity.to_f64()).sum();
2342        if total_vol == 0.0 {
2343            return None;
2344        }
2345        let var: f64 = ticks
2346            .iter()
2347            .filter_map(|t| {
2348                let p = t.price.to_f64()?;
2349                let q = t.quantity.to_f64()?;
2350                Some((p - vwap).powi(2) * q)
2351            })
2352            .sum::<f64>()
2353            / total_vol;
2354        Some(var.sqrt())
2355    }
2356
2357    /// Fraction of ticks that set a new running high or low (price range expansion events).
2358    pub fn price_range_expansion(ticks: &[NormalizedTick]) -> Option<f64> {
2359        if ticks.is_empty() {
2360            return None;
2361        }
2362        let mut hi = ticks[0].price;
2363        let mut lo = ticks[0].price;
2364        let mut count = 0usize;
2365        for t in ticks.iter().skip(1) {
2366            if t.price > hi {
2367                hi = t.price;
2368                count += 1;
2369            } else if t.price < lo {
2370                lo = t.price;
2371                count += 1;
2372            }
2373        }
2374        Some(count as f64 / ticks.len() as f64)
2375    }
2376
2377    /// Fraction of total volume classified as sell-side; complement of `buy_to_total_volume_ratio`.
2378    pub fn sell_to_total_volume_ratio(ticks: &[NormalizedTick]) -> Option<f64> {
2379        use rust_decimal::prelude::ToPrimitive;
2380        if ticks.is_empty() {
2381            return None;
2382        }
2383        let sell_vol: Decimal = ticks
2384            .iter()
2385            .filter(|t| t.side == Some(crate::tick::TradeSide::Sell))
2386            .map(|t| t.quantity)
2387            .sum();
2388        let total: Decimal = ticks.iter().map(|t| t.quantity).sum();
2389        if total.is_zero() {
2390            return Some(0.0);
2391        }
2392        sell_vol.to_f64().zip(total.to_f64()).map(|(s, tot)| s / tot)
2393    }
2394
2395    /// Std dev of per-tick notional (`price × quantity`); requires ≥ 2 ticks.
2396    pub fn notional_std(ticks: &[NormalizedTick]) -> Option<f64> {
2397        use rust_decimal::prelude::ToPrimitive;
2398        if ticks.len() < 2 {
2399            return None;
2400        }
2401        let notionals: Vec<f64> = ticks
2402            .iter()
2403            .filter_map(|t| (t.price * t.quantity).to_f64())
2404            .collect();
2405        let n = notionals.len() as f64;
2406        if n < 2.0 {
2407            return None;
2408        }
2409        let mean = notionals.iter().sum::<f64>() / n;
2410        let var = notionals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
2411        Some(var.sqrt())
2412    }
2413
2414    // ── round-83 ─────────────────────────────────────────────────────────────
2415
2416    /// Autocorrelation of trade sizes at lag 1.
2417    ///
2418    /// Measures whether large (or small) trades tend to follow each other.
2419    /// Returns `None` if fewer than 3 ticks or variance is zero.
2420    pub fn quantity_autocorrelation(ticks: &[NormalizedTick]) -> Option<f64> {
2421        use rust_decimal::prelude::ToPrimitive;
2422        if ticks.len() < 3 {
2423            return None;
2424        }
2425        let vals: Vec<f64> = ticks.iter().filter_map(|t| t.quantity.to_f64()).collect();
2426        let n = vals.len() as f64;
2427        let mean = vals.iter().sum::<f64>() / n;
2428        let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
2429        if var == 0.0 {
2430            return None;
2431        }
2432        let cov = vals.windows(2).map(|w| (w[0] - mean) * (w[1] - mean)).sum::<f64>() / n;
2433        Some(cov / var)
2434    }
2435
2436    /// Fraction of ticks where price strictly exceeds the VWAP.
2437    ///
2438    /// Returns `None` for empty slices or when VWAP cannot be computed.
2439    pub fn fraction_above_vwap(ticks: &[NormalizedTick]) -> Option<f64> {
2440        let vwap = Self::vwap(ticks)?;
2441        if ticks.is_empty() {
2442            return None;
2443        }
2444        let above = ticks.iter().filter(|t| t.price > vwap).count();
2445        Some(above as f64 / ticks.len() as f64)
2446    }
2447
2448    /// Maximum consecutive buy ticks (by side) within the slice.
2449    ///
2450    /// Returns 0 if no buy ticks are present.
2451    pub fn max_buy_streak(ticks: &[NormalizedTick]) -> usize {
2452        let mut max = 0usize;
2453        let mut current = 0usize;
2454        for t in ticks {
2455            if t.side == Some(TradeSide::Buy) {
2456                current += 1;
2457                if current > max {
2458                    max = current;
2459                }
2460            } else {
2461                current = 0;
2462            }
2463        }
2464        max
2465    }
2466
2467    /// Maximum consecutive sell ticks (by side) within the slice.
2468    ///
2469    /// Returns 0 if no sell ticks are present.
2470    pub fn max_sell_streak(ticks: &[NormalizedTick]) -> usize {
2471        let mut max = 0usize;
2472        let mut current = 0usize;
2473        for t in ticks {
2474            if t.side == Some(TradeSide::Sell) {
2475                current += 1;
2476                if current > max {
2477                    max = current;
2478                }
2479            } else {
2480                current = 0;
2481            }
2482        }
2483        max
2484    }
2485
2486    /// Entropy of the trade side distribution across buy, sell, and neutral.
2487    ///
2488    /// Computed as `-Σ p_i * ln(p_i)` over the three categories. Zero
2489    /// entropy means all ticks share the same side; higher = more mixed.
2490    /// Returns `None` for empty slices.
2491    pub fn side_entropy(ticks: &[NormalizedTick]) -> Option<f64> {
2492        if ticks.is_empty() {
2493            return None;
2494        }
2495        let n = ticks.len() as f64;
2496        let buys = Self::buy_count(ticks) as f64;
2497        let sells = Self::sell_count(ticks) as f64;
2498        let neutrals = Self::count_neutral(ticks) as f64;
2499        let entropy = [buys, sells, neutrals]
2500            .iter()
2501            .filter(|&&c| c > 0.0)
2502            .map(|&c| {
2503                let p = c / n;
2504                -p * p.ln()
2505            })
2506            .sum::<f64>();
2507        Some(entropy)
2508    }
2509
2510    /// Mean time gap between consecutive ticks in milliseconds.
2511    ///
2512    /// Uses `received_at_ms`. Returns `None` for fewer than 2 ticks or
2513    /// if all ticks have identical timestamps.
2514    pub fn mean_inter_tick_gap_ms(ticks: &[NormalizedTick]) -> Option<f64> {
2515        if ticks.len() < 2 {
2516            return None;
2517        }
2518        let gaps: Vec<f64> = ticks
2519            .windows(2)
2520            .map(|w| w[1].received_at_ms.saturating_sub(w[0].received_at_ms) as f64)
2521            .collect();
2522        let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
2523        Some(mean)
2524    }
2525
2526    /// Fraction of ticks whose price is a round number divisible by `step`.
2527    ///
2528    /// Returns `None` for empty slices or zero `step`.
2529    pub fn round_number_fraction(ticks: &[NormalizedTick], step: Decimal) -> Option<f64> {
2530        if ticks.is_empty() || step.is_zero() {
2531            return None;
2532        }
2533        let round = ticks.iter().filter(|t| (t.price % step).is_zero()).count();
2534        Some(round as f64 / ticks.len() as f64)
2535    }
2536
2537    /// Geometric mean of trade quantities.
2538    ///
2539    /// Computed as `exp(mean(ln(q_i)))`. Returns `None` for empty slices
2540    /// or if any quantity is non-positive.
2541    pub fn geometric_mean_quantity(ticks: &[NormalizedTick]) -> Option<f64> {
2542        use rust_decimal::prelude::ToPrimitive;
2543        if ticks.is_empty() {
2544            return None;
2545        }
2546        let log_sum: f64 = ticks
2547            .iter()
2548            .map(|t| {
2549                let q = t.quantity.to_f64()?;
2550                if q <= 0.0 { None } else { Some(q.ln()) }
2551            })
2552            .try_fold(0.0f64, |acc, v| v.map(|x| acc + x))?;
2553        Some((log_sum / ticks.len() as f64).exp())
2554    }
2555
2556    /// Maximum price return (best single tick-to-tick gain) in the slice.
2557    ///
2558    /// Returns `None` for fewer than 2 ticks.
2559    pub fn max_tick_return(ticks: &[NormalizedTick]) -> Option<f64> {
2560        use rust_decimal::prelude::ToPrimitive;
2561        if ticks.len() < 2 {
2562            return None;
2563        }
2564        ticks
2565            .windows(2)
2566            .filter_map(|w| {
2567                let prev = w[0].price.to_f64()?;
2568                if prev == 0.0 { return None; }
2569                let curr = w[1].price.to_f64()?;
2570                Some((curr - prev) / prev)
2571            })
2572            .reduce(f64::max)
2573    }
2574
2575    /// Minimum price return (worst single tick-to-tick drop) in the slice.
2576    ///
2577    /// Returns `None` for fewer than 2 ticks.
2578    pub fn min_tick_return(ticks: &[NormalizedTick]) -> Option<f64> {
2579        use rust_decimal::prelude::ToPrimitive;
2580        if ticks.len() < 2 {
2581            return None;
2582        }
2583        ticks
2584            .windows(2)
2585            .filter_map(|w| {
2586                let prev = w[0].price.to_f64()?;
2587                if prev == 0.0 { return None; }
2588                let curr = w[1].price.to_f64()?;
2589                Some((curr - prev) / prev)
2590            })
2591            .reduce(f64::min)
2592    }
2593
2594    // ── round-83 ─────────────────────────────────────────────────────────────
2595
2596    /// Mean price of buy-side ticks only; `None` if no buy ticks present.
2597    pub fn buy_price_mean(ticks: &[NormalizedTick]) -> Option<Decimal> {
2598        let buys: Vec<Decimal> = ticks
2599            .iter()
2600            .filter(|t| t.side == Some(crate::tick::TradeSide::Buy))
2601            .map(|t| t.price)
2602            .collect();
2603        if buys.is_empty() {
2604            return None;
2605        }
2606        Some(buys.iter().copied().sum::<Decimal>() / Decimal::from(buys.len()))
2607    }
2608
2609    /// Mean price of sell-side ticks only; `None` if no sell ticks present.
2610    pub fn sell_price_mean(ticks: &[NormalizedTick]) -> Option<Decimal> {
2611        let sells: Vec<Decimal> = ticks
2612            .iter()
2613            .filter(|t| t.side == Some(crate::tick::TradeSide::Sell))
2614            .map(|t| t.price)
2615            .collect();
2616        if sells.is_empty() {
2617            return None;
2618        }
2619        Some(sells.iter().copied().sum::<Decimal>() / Decimal::from(sells.len()))
2620    }
2621
2622    /// `|last_price − first_price| / Σ|price_i − price_{i-1}|`; 1.0 = perfectly directional, near 0 = noisy.
2623    pub fn price_efficiency(ticks: &[NormalizedTick]) -> Option<f64> {
2624        use rust_decimal::prelude::ToPrimitive;
2625        if ticks.len() < 2 {
2626            return None;
2627        }
2628        let total_path: Decimal = ticks
2629            .windows(2)
2630            .map(|w| (w[1].price - w[0].price).abs())
2631            .sum();
2632        if total_path.is_zero() {
2633            return None;
2634        }
2635        let net = (ticks.last()?.price - ticks.first()?.price).abs();
2636        (net / total_path).to_f64()
2637    }
2638
2639    /// Skewness of tick-to-tick log returns; requires ≥ 5 ticks.
2640    pub fn price_return_skewness(ticks: &[NormalizedTick]) -> Option<f64> {
2641        use rust_decimal::prelude::ToPrimitive;
2642        if ticks.len() < 5 {
2643            return None;
2644        }
2645        let returns: Vec<f64> = ticks
2646            .windows(2)
2647            .filter_map(|w| {
2648                let prev = w[0].price.to_f64()?;
2649                if prev <= 0.0 { return None; }
2650                let curr = w[1].price.to_f64()?;
2651                Some((curr / prev).ln())
2652            })
2653            .collect();
2654        let n = returns.len() as f64;
2655        if n < 4.0 {
2656            return None;
2657        }
2658        let mean = returns.iter().sum::<f64>() / n;
2659        let var = returns.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
2660        let std = var.sqrt();
2661        if std == 0.0 {
2662            return None;
2663        }
2664        let skew = returns.iter().map(|v| ((v - mean) / std).powi(3)).sum::<f64>() / n;
2665        Some(skew)
2666    }
2667
2668    /// Buy VWAP minus sell VWAP; positive = buyers paid more than sellers received.
2669    pub fn buy_sell_vwap_spread(ticks: &[NormalizedTick]) -> Option<f64> {
2670        use rust_decimal::prelude::ToPrimitive;
2671        let (buy_pv, buy_v): (Decimal, Decimal) = ticks
2672            .iter()
2673            .filter(|t| t.side == Some(crate::tick::TradeSide::Buy))
2674            .fold((Decimal::ZERO, Decimal::ZERO), |(pv, v), t| {
2675                (pv + t.price * t.quantity, v + t.quantity)
2676            });
2677        let (sell_pv, sell_v): (Decimal, Decimal) = ticks
2678            .iter()
2679            .filter(|t| t.side == Some(crate::tick::TradeSide::Sell))
2680            .fold((Decimal::ZERO, Decimal::ZERO), |(pv, v), t| {
2681                (pv + t.price * t.quantity, v + t.quantity)
2682            });
2683        if buy_v.is_zero() || sell_v.is_zero() {
2684            return None;
2685        }
2686        let buy_vwap = (buy_pv / buy_v).to_f64()?;
2687        let sell_vwap = (sell_pv / sell_v).to_f64()?;
2688        Some(buy_vwap - sell_vwap)
2689    }
2690
2691    /// Fraction of ticks with quantity above the mean quantity.
2692    pub fn above_mean_quantity_fraction(ticks: &[NormalizedTick]) -> Option<f64> {
2693        if ticks.is_empty() {
2694            return None;
2695        }
2696        let total: Decimal = ticks.iter().map(|t| t.quantity).sum();
2697        let mean = total / Decimal::from(ticks.len());
2698        let count = ticks.iter().filter(|t| t.quantity > mean).count();
2699        Some(count as f64 / ticks.len() as f64)
2700    }
2701
2702    /// Fraction of ticks where price equals the previous tick's price (unchanged price).
2703    pub fn price_unchanged_fraction(ticks: &[NormalizedTick]) -> Option<f64> {
2704        if ticks.len() < 2 {
2705            return None;
2706        }
2707        let unchanged = ticks.windows(2).filter(|w| w[0].price == w[1].price).count();
2708        Some(unchanged as f64 / (ticks.len() - 1) as f64)
2709    }
2710
2711    /// Quantity-weighted price range: `Σ(price_i × qty_i) / Σ(qty_i)` applied to max/min spread.
2712    /// Returns the difference between quantity-weighted high and low prices.
2713    pub fn qty_weighted_range(ticks: &[NormalizedTick]) -> Option<f64> {
2714        use rust_decimal::prelude::ToPrimitive;
2715        if ticks.is_empty() {
2716            return None;
2717        }
2718        let hi = ticks.iter().map(|t| t.price).max()?;
2719        let lo = ticks.iter().map(|t| t.price).min()?;
2720        (hi - lo).to_f64()
2721    }
2722
2723    // ── round-84 ─────────────────────────────────────────────────────────────
2724
2725    /// Fraction of total notional that is sell-side; complement of `buy_notional_fraction`.
2726    pub fn sell_notional_fraction(ticks: &[NormalizedTick]) -> Option<f64> {
2727        use rust_decimal::prelude::ToPrimitive;
2728        if ticks.is_empty() {
2729            return None;
2730        }
2731        let total: Decimal = ticks.iter().map(|t| t.price * t.quantity).sum();
2732        if total.is_zero() {
2733            return Some(0.0);
2734        }
2735        let sell_notional: Decimal = ticks
2736            .iter()
2737            .filter(|t| t.side == Some(crate::tick::TradeSide::Sell))
2738            .map(|t| t.price * t.quantity)
2739            .sum();
2740        sell_notional.to_f64().zip(total.to_f64()).map(|(s, t)| s / t)
2741    }
2742
2743    /// Maximum absolute price jump between consecutive ticks.
2744    pub fn max_price_gap(ticks: &[NormalizedTick]) -> Option<Decimal> {
2745        if ticks.len() < 2 {
2746            return None;
2747        }
2748        ticks.windows(2).map(|w| (w[1].price - w[0].price).abs()).max()
2749    }
2750
2751    /// Rate of price range expansion: `(high - low) / time_span_ms`; requires ≥ 2 ticks with different timestamps.
2752    pub fn price_range_velocity(ticks: &[NormalizedTick]) -> Option<f64> {
2753        use rust_decimal::prelude::ToPrimitive;
2754        if ticks.len() < 2 {
2755            return None;
2756        }
2757        let time_span = ticks.last()?.received_at_ms.saturating_sub(ticks.first()?.received_at_ms);
2758        if time_span == 0 {
2759            return None;
2760        }
2761        let hi = ticks.iter().map(|t| t.price).max()?;
2762        let lo = ticks.iter().map(|t| t.price).min()?;
2763        let range = (hi - lo).to_f64()?;
2764        Some(range / time_span as f64)
2765    }
2766
2767    /// Number of ticks per millisecond of the slice's time span.
2768    pub fn tick_count_per_ms(ticks: &[NormalizedTick]) -> Option<f64> {
2769        if ticks.len() < 2 {
2770            return None;
2771        }
2772        let span = ticks.last()?.received_at_ms.saturating_sub(ticks.first()?.received_at_ms);
2773        if span == 0 {
2774            return None;
2775        }
2776        Some(ticks.len() as f64 / span as f64)
2777    }
2778
2779    /// Fraction of total quantity attributable to buy-side trades.
2780    pub fn buy_quantity_fraction(ticks: &[NormalizedTick]) -> Option<f64> {
2781        use rust_decimal::prelude::ToPrimitive;
2782        if ticks.is_empty() {
2783            return None;
2784        }
2785        let total: Decimal = ticks.iter().map(|t| t.quantity).sum();
2786        if total.is_zero() {
2787            return Some(0.0);
2788        }
2789        let buy_qty: Decimal = ticks
2790            .iter()
2791            .filter(|t| t.side == Some(crate::tick::TradeSide::Buy))
2792            .map(|t| t.quantity)
2793            .sum();
2794        buy_qty.to_f64().zip(total.to_f64()).map(|(b, tot)| b / tot)
2795    }
2796
2797    /// Fraction of total quantity attributable to sell-side trades.
2798    pub fn sell_quantity_fraction(ticks: &[NormalizedTick]) -> Option<f64> {
2799        use rust_decimal::prelude::ToPrimitive;
2800        if ticks.is_empty() {
2801            return None;
2802        }
2803        let total: Decimal = ticks.iter().map(|t| t.quantity).sum();
2804        if total.is_zero() {
2805            return Some(0.0);
2806        }
2807        let sell_qty: Decimal = ticks
2808            .iter()
2809            .filter(|t| t.side == Some(crate::tick::TradeSide::Sell))
2810            .map(|t| t.quantity)
2811            .sum();
2812        sell_qty.to_f64().zip(total.to_f64()).map(|(s, tot)| s / tot)
2813    }
2814
2815    /// Number of times the price crosses through (or touches) its own window mean.
2816    pub fn price_mean_crossover_count(ticks: &[NormalizedTick]) -> Option<usize> {
2817        use rust_decimal::prelude::ToPrimitive;
2818        if ticks.len() < 2 {
2819            return None;
2820        }
2821        let sum: Decimal = ticks.iter().map(|t| t.price).sum();
2822        let mean = sum / Decimal::from(ticks.len() as i64);
2823        let crossovers = ticks
2824            .windows(2)
2825            .filter(|w| {
2826                let prev = w[0].price - mean;
2827                let curr = w[1].price - mean;
2828                prev.is_sign_negative() != curr.is_sign_negative()
2829            })
2830            .count();
2831        let _ = mean.to_f64(); // ensure mean is convertible (always true for Decimal)
2832        Some(crossovers)
2833    }
2834
2835    /// Skewness of per-tick notional values (`price × quantity`).
2836    pub fn notional_skewness(ticks: &[NormalizedTick]) -> Option<f64> {
2837        use rust_decimal::prelude::ToPrimitive;
2838        if ticks.len() < 3 {
2839            return None;
2840        }
2841        let vals: Vec<f64> = ticks
2842            .iter()
2843            .filter_map(|t| (t.price * t.quantity).to_f64())
2844            .collect();
2845        let n = vals.len() as f64;
2846        if n < 3.0 {
2847            return None;
2848        }
2849        let mean = vals.iter().sum::<f64>() / n;
2850        let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
2851        let std = var.sqrt();
2852        if std < 1e-12 {
2853            return None;
2854        }
2855        let skew = vals.iter().map(|v| ((v - mean) / std).powi(3)).sum::<f64>() / n;
2856        Some(skew)
2857    }
2858
2859    /// Volume-weighted midpoint of the price range: `sum(price * quantity) / sum(quantity)`.
2860    /// Equivalent to VWAP but emphasises price centrality.
2861    pub fn volume_weighted_mid_price(ticks: &[NormalizedTick]) -> Option<Decimal> {
2862        if ticks.is_empty() {
2863            return None;
2864        }
2865        let total_qty: Decimal = ticks.iter().map(|t| t.quantity).sum();
2866        if total_qty.is_zero() {
2867            return None;
2868        }
2869        let pv: Decimal = ticks.iter().map(|t| t.price * t.quantity).sum();
2870        Some(pv / total_qty)
2871    }
2872
2873    // ── round-85 ─────────────────────────────────────────────────────────────
2874
2875    /// Count of ticks with no aggressor side (side == None).
2876    pub fn neutral_count(ticks: &[NormalizedTick]) -> usize {
2877        ticks.iter().filter(|t| t.side.is_none()).count()
2878    }
2879
2880    /// `max_price − min_price`; raw price spread across the slice.
2881    pub fn price_dispersion(ticks: &[NormalizedTick]) -> Option<Decimal> {
2882        if ticks.is_empty() {
2883            return None;
2884        }
2885        let hi = ticks.iter().map(|t| t.price).max()?;
2886        let lo = ticks.iter().map(|t| t.price).min()?;
2887        Some(hi - lo)
2888    }
2889
2890    /// Maximum per-tick notional (`price × quantity`) in the slice.
2891    pub fn max_notional(ticks: &[NormalizedTick]) -> Option<Decimal> {
2892        ticks.iter().map(|t| t.price * t.quantity).max()
2893    }
2894
2895    /// Minimum per-tick notional (`price × quantity`) in the slice.
2896    pub fn min_notional(ticks: &[NormalizedTick]) -> Option<Decimal> {
2897        ticks.iter().map(|t| t.price * t.quantity).min()
2898    }
2899
2900    /// Fraction of ticks with price below the slice VWAP.
2901    pub fn below_vwap_fraction(ticks: &[NormalizedTick]) -> Option<f64> {
2902        if ticks.is_empty() {
2903            return None;
2904        }
2905        let vwap = Self::vwap(ticks)?;
2906        let count = ticks.iter().filter(|t| t.price < vwap).count();
2907        Some(count as f64 / ticks.len() as f64)
2908    }
2909
2910    /// Standard deviation of per-tick notionals (`price × quantity`); requires ≥ 2 ticks.
2911    ///
2912    /// Distinct from `notional_std_dev` (which refers to the `notional` field); this uses
2913    /// `price * quantity` directly.
2914    pub fn trade_notional_std(ticks: &[NormalizedTick]) -> Option<f64> {
2915        use rust_decimal::prelude::ToPrimitive;
2916        if ticks.len() < 2 {
2917            return None;
2918        }
2919        let vals: Vec<f64> = ticks
2920            .iter()
2921            .filter_map(|t| (t.price * t.quantity).to_f64())
2922            .collect();
2923        let n = vals.len() as f64;
2924        if n < 2.0 {
2925            return None;
2926        }
2927        let mean = vals.iter().sum::<f64>() / n;
2928        let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
2929        Some(var.sqrt())
2930    }
2931
2932    /// Ratio of buy count to sell count; `None` if there are no sell ticks.
2933    pub fn buy_sell_count_ratio(ticks: &[NormalizedTick]) -> Option<f64> {
2934        let sells = Self::sell_count(ticks);
2935        if sells == 0 {
2936            return None;
2937        }
2938        Some(Self::buy_count(ticks) as f64 / sells as f64)
2939    }
2940
2941    /// Mean absolute deviation of prices from the price mean.
2942    pub fn price_mad(ticks: &[NormalizedTick]) -> Option<f64> {
2943        use rust_decimal::prelude::ToPrimitive;
2944        if ticks.is_empty() {
2945            return None;
2946        }
2947        let sum: Decimal = ticks.iter().map(|t| t.price).sum();
2948        let mean = sum / Decimal::from(ticks.len() as i64);
2949        let mad: f64 = ticks
2950            .iter()
2951            .filter_map(|t| (t.price - mean).abs().to_f64())
2952            .sum::<f64>() / ticks.len() as f64;
2953        Some(mad)
2954    }
2955
2956    /// Price range expressed as a percentage of the first tick's price.
2957    pub fn price_range_pct_of_open(ticks: &[NormalizedTick]) -> Option<f64> {
2958        use rust_decimal::prelude::ToPrimitive;
2959        if ticks.is_empty() {
2960            return None;
2961        }
2962        let first_price = ticks.first()?.price;
2963        if first_price.is_zero() {
2964            return None;
2965        }
2966        let hi = ticks.iter().map(|t| t.price).max()?;
2967        let lo = ticks.iter().map(|t| t.price).min()?;
2968        ((hi - lo) / first_price).to_f64()
2969    }
2970
2971    // ── round-86 ─────────────────────────────────────────────────────────────
2972
2973    /// Mean price of the ticks; equivalent to arithmetic average of all `price` values.
2974    pub fn price_mean(ticks: &[NormalizedTick]) -> Option<Decimal> {
2975        if ticks.is_empty() {
2976            return None;
2977        }
2978        let sum: Decimal = ticks.iter().map(|t| t.price).sum();
2979        Some(sum / Decimal::from(ticks.len() as i64))
2980    }
2981
2982    /// Number of ticks where `price > previous_tick.price` (upticks).
2983    pub fn uptick_count(ticks: &[NormalizedTick]) -> usize {
2984        if ticks.len() < 2 {
2985            return 0;
2986        }
2987        ticks.windows(2).filter(|w| w[1].price > w[0].price).count()
2988    }
2989
2990    /// Number of ticks where `price < previous_tick.price` (downticks).
2991    pub fn downtick_count(ticks: &[NormalizedTick]) -> usize {
2992        if ticks.len() < 2 {
2993            return 0;
2994        }
2995        ticks.windows(2).filter(|w| w[1].price < w[0].price).count()
2996    }
2997
2998    /// Fraction of tick intervals that are upticks.
2999    pub fn uptick_fraction(ticks: &[NormalizedTick]) -> Option<f64> {
3000        if ticks.len() < 2 {
3001            return None;
3002        }
3003        let intervals = (ticks.len() - 1) as f64;
3004        Some(Self::uptick_count(ticks) as f64 / intervals)
3005    }
3006
3007    /// Standard deviation of quantities across ticks; requires ≥ 2 ticks.
3008    pub fn quantity_std(ticks: &[NormalizedTick]) -> Option<f64> {
3009        use rust_decimal::prelude::ToPrimitive;
3010        if ticks.len() < 2 {
3011            return None;
3012        }
3013        let vals: Vec<f64> = ticks.iter().filter_map(|t| t.quantity.to_f64()).collect();
3014        let n = vals.len() as f64;
3015        if n < 2.0 {
3016            return None;
3017        }
3018        let mean = vals.iter().sum::<f64>() / n;
3019        let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
3020        Some(var.sqrt())
3021    }
3022
3023    // ── round-87 ─────────────────────────────────────────────────────────────
3024
3025    /// Standard deviation of (price − VWAP) across ticks; measures how dispersed
3026    /// individual trade prices are around the session VWAP.  Returns `None` if
3027    /// fewer than 2 ticks or total quantity is zero.
3028    pub fn vwap_deviation_std(ticks: &[NormalizedTick]) -> Option<f64> {
3029        use rust_decimal::prelude::ToPrimitive;
3030        if ticks.len() < 2 {
3031            return None;
3032        }
3033        let total_qty: Decimal = ticks.iter().map(|t| t.quantity).sum();
3034        if total_qty.is_zero() {
3035            return None;
3036        }
3037        let vwap = ticks
3038            .iter()
3039            .map(|t| t.price * t.quantity)
3040            .sum::<Decimal>()
3041            / total_qty;
3042        let deviations: Vec<f64> = ticks
3043            .iter()
3044            .filter_map(|t| (t.price - vwap).to_f64())
3045            .collect();
3046        let n = deviations.len() as f64;
3047        if n < 2.0 {
3048            return None;
3049        }
3050        let mean_dev = deviations.iter().sum::<f64>() / n;
3051        let var = deviations
3052            .iter()
3053            .map(|d| (d - mean_dev).powi(2))
3054            .sum::<f64>()
3055            / (n - 1.0);
3056        Some(var.sqrt())
3057    }
3058
3059    /// Length of the longest run of trades on the same side (Buy or Sell).
3060    /// Ticks with no side are skipped.  Returns `0` if no sided ticks.
3061    pub fn max_consecutive_side_run(ticks: &[NormalizedTick]) -> usize {
3062        let mut max_run = 0usize;
3063        let mut current_run = 0usize;
3064        let mut last_side: Option<TradeSide> = None;
3065        for t in ticks {
3066            if let Some(side) = t.side {
3067                if Some(side) == last_side {
3068                    current_run += 1;
3069                } else {
3070                    current_run = 1;
3071                    last_side = Some(side);
3072                }
3073                if current_run > max_run {
3074                    max_run = current_run;
3075                }
3076            }
3077        }
3078        max_run
3079    }
3080
3081    /// Coefficient of variation of inter-arrival times (std dev / mean).
3082    /// Measures burstiness of trade arrival.  Returns `None` if fewer than
3083    /// 2 ticks with `received_at_ms` or if mean inter-arrival is zero.
3084    pub fn inter_arrival_cv(ticks: &[NormalizedTick]) -> Option<f64> {
3085        if ticks.len() < 2 {
3086            return None;
3087        }
3088        let intervals: Vec<f64> = ticks
3089            .windows(2)
3090            .filter_map(|w| {
3091                let dt = w[1].received_at_ms.checked_sub(w[0].received_at_ms)?;
3092                Some(dt as f64)
3093            })
3094            .collect();
3095        if intervals.len() < 2 {
3096            return None;
3097        }
3098        let n = intervals.len() as f64;
3099        let mean = intervals.iter().sum::<f64>() / n;
3100        if mean == 0.0 {
3101            return None;
3102        }
3103        let var = intervals
3104            .iter()
3105            .map(|v| (v - mean).powi(2))
3106            .sum::<f64>()
3107            / (n - 1.0);
3108        Some(var.sqrt() / mean)
3109    }
3110
3111    /// Total traded quantity divided by elapsed milliseconds.
3112    /// Returns `None` if fewer than 2 ticks or elapsed time is zero.
3113    pub fn volume_per_ms(ticks: &[NormalizedTick]) -> Option<f64> {
3114        use rust_decimal::prelude::ToPrimitive;
3115        if ticks.len() < 2 {
3116            return None;
3117        }
3118        let first_ms = ticks.first()?.received_at_ms;
3119        let last_ms = ticks.last()?.received_at_ms;
3120        let elapsed = last_ms.checked_sub(first_ms)? as f64;
3121        if elapsed == 0.0 {
3122            return None;
3123        }
3124        let total_qty: f64 = ticks
3125            .iter()
3126            .filter_map(|t| t.quantity.to_f64())
3127            .sum();
3128        Some(total_qty / elapsed)
3129    }
3130
3131    /// Total notional (price × quantity) divided by elapsed seconds.
3132    /// Returns `None` if fewer than 2 ticks or elapsed time is zero.
3133    pub fn notional_per_second(ticks: &[NormalizedTick]) -> Option<f64> {
3134        use rust_decimal::prelude::ToPrimitive;
3135        if ticks.len() < 2 {
3136            return None;
3137        }
3138        let first_ms = ticks.first()?.received_at_ms;
3139        let last_ms = ticks.last()?.received_at_ms;
3140        let elapsed_sec = last_ms.checked_sub(first_ms)? as f64 / 1000.0;
3141        if elapsed_sec == 0.0 {
3142            return None;
3143        }
3144        let total_notional: f64 = ticks
3145            .iter()
3146            .filter_map(|t| (t.price * t.quantity).to_f64())
3147            .sum();
3148        Some(total_notional / elapsed_sec)
3149    }
3150
3151    // ── round-88 ─────────────────────────────────────────────────────────────
3152
3153    /// Net order-flow imbalance: `(buy_qty − sell_qty) / total_qty`.
3154    ///
3155    /// Returns a value in `[−1, 1]`: +1 = all buys, −1 = all sells.
3156    /// Returns `None` for empty slices or zero total quantity.
3157    pub fn order_flow_imbalance(ticks: &[NormalizedTick]) -> Option<f64> {
3158        use rust_decimal::prelude::ToPrimitive;
3159        if ticks.is_empty() {
3160            return None;
3161        }
3162        let buy_qty: Decimal = ticks
3163            .iter()
3164            .filter(|t| t.side == Some(crate::tick::TradeSide::Buy))
3165            .map(|t| t.quantity)
3166            .sum();
3167        let sell_qty: Decimal = ticks
3168            .iter()
3169            .filter(|t| t.side == Some(crate::tick::TradeSide::Sell))
3170            .map(|t| t.quantity)
3171            .sum();
3172        let total: Decimal = ticks.iter().map(|t| t.quantity).sum();
3173        if total.is_zero() {
3174            return None;
3175        }
3176        (buy_qty - sell_qty).to_f64().zip(total.to_f64()).map(|(n, d)| n / d)
3177    }
3178
3179    /// Fraction of consecutive tick pairs where both price and quantity increased.
3180    ///
3181    /// Returns `None` for fewer than 2 ticks.
3182    pub fn price_qty_up_fraction(ticks: &[NormalizedTick]) -> Option<f64> {
3183        if ticks.len() < 2 {
3184            return None;
3185        }
3186        let count = ticks
3187            .windows(2)
3188            .filter(|w| w[1].price > w[0].price && w[1].quantity > w[0].quantity)
3189            .count();
3190        Some(count as f64 / (ticks.len() - 1) as f64)
3191    }
3192
3193    /// Count of ticks where price is at an all-time high within the slice (including first tick).
3194    pub fn running_high_count(ticks: &[NormalizedTick]) -> usize {
3195        if ticks.is_empty() {
3196            return 0;
3197        }
3198        let mut hi = ticks[0].price;
3199        let mut count = 1usize;
3200        for t in ticks.iter().skip(1) {
3201            if t.price >= hi {
3202                hi = t.price;
3203                count += 1;
3204            }
3205        }
3206        count
3207    }
3208
3209    /// Count of ticks where price is at an all-time low within the slice (including first tick).
3210    pub fn running_low_count(ticks: &[NormalizedTick]) -> usize {
3211        if ticks.is_empty() {
3212            return 0;
3213        }
3214        let mut lo = ticks[0].price;
3215        let mut count = 1usize;
3216        for t in ticks.iter().skip(1) {
3217            if t.price <= lo {
3218                lo = t.price;
3219                count += 1;
3220            }
3221        }
3222        count
3223    }
3224
3225    /// Mean quantity of buy ticks divided by mean quantity of sell ticks.
3226    ///
3227    /// Returns `None` if no buy or sell ticks, or if sell mean is zero.
3228    pub fn buy_sell_avg_qty_ratio(ticks: &[NormalizedTick]) -> Option<f64> {
3229        use rust_decimal::prelude::ToPrimitive;
3230        let buys: Vec<Decimal> = ticks
3231            .iter()
3232            .filter(|t| t.side == Some(crate::tick::TradeSide::Buy))
3233            .map(|t| t.quantity)
3234            .collect();
3235        let sells: Vec<Decimal> = ticks
3236            .iter()
3237            .filter(|t| t.side == Some(crate::tick::TradeSide::Sell))
3238            .map(|t| t.quantity)
3239            .collect();
3240        if buys.is_empty() || sells.is_empty() {
3241            return None;
3242        }
3243        let buy_mean = buys.iter().copied().sum::<Decimal>() / Decimal::from(buys.len() as i64);
3244        let sell_mean = sells.iter().copied().sum::<Decimal>() / Decimal::from(sells.len() as i64);
3245        if sell_mean.is_zero() {
3246            return None;
3247        }
3248        (buy_mean / sell_mean).to_f64()
3249    }
3250
3251    /// Largest price drop between any two consecutive ticks (always ≥ 0).
3252    ///
3253    /// Returns `None` for fewer than 2 ticks.
3254    pub fn max_price_drop(ticks: &[NormalizedTick]) -> Option<Decimal> {
3255        if ticks.len() < 2 {
3256            return None;
3257        }
3258        ticks
3259            .windows(2)
3260            .map(|w| (w[0].price - w[1].price).max(Decimal::ZERO))
3261            .max()
3262    }
3263
3264    /// Largest price rise between any two consecutive ticks (always ≥ 0).
3265    ///
3266    /// Returns `None` for fewer than 2 ticks.
3267    pub fn max_price_rise(ticks: &[NormalizedTick]) -> Option<Decimal> {
3268        if ticks.len() < 2 {
3269            return None;
3270        }
3271        ticks
3272            .windows(2)
3273            .map(|w| (w[1].price - w[0].price).max(Decimal::ZERO))
3274            .max()
3275    }
3276
3277    /// Count of ticks classified as buy-side trades.
3278    pub fn buy_trade_count(ticks: &[NormalizedTick]) -> usize {
3279        ticks
3280            .iter()
3281            .filter(|t| t.side == Some(TradeSide::Buy))
3282            .count()
3283    }
3284
3285    /// Count of ticks classified as sell-side trades.
3286    pub fn sell_trade_count(ticks: &[NormalizedTick]) -> usize {
3287        ticks
3288            .iter()
3289            .filter(|t| t.side == Some(TradeSide::Sell))
3290            .count()
3291    }
3292
3293    /// Fraction of consecutive tick pairs that reverse price direction.
3294    /// A reversal is when (price[i+1] > price[i]) differs from (price[i] > price[i-1]).
3295    /// Returns `None` for fewer than 3 ticks.
3296    pub fn price_reversal_fraction(ticks: &[NormalizedTick]) -> Option<f64> {
3297        if ticks.len() < 3 {
3298            return None;
3299        }
3300        let reversals = ticks
3301            .windows(3)
3302            .filter(|w| {
3303                let up1 = w[1].price > w[0].price;
3304                let up2 = w[2].price > w[1].price;
3305                up1 != up2
3306            })
3307            .count();
3308        Some(reversals as f64 / (ticks.len() - 2) as f64)
3309    }
3310
3311    // ── round-89 ─────────────────────────────────────────────────────────────
3312
3313    /// Fraction of ticks within `band` of the slice VWAP.
3314    ///
3315    /// Returns `None` for empty slices or when VWAP cannot be computed.
3316    pub fn near_vwap_fraction(ticks: &[NormalizedTick], band: Decimal) -> Option<f64> {
3317        if ticks.is_empty() {
3318            return None;
3319        }
3320        let vwap = Self::vwap(ticks)?;
3321        let count = ticks.iter().filter(|t| (t.price - vwap).abs() <= band).count();
3322        Some(count as f64 / ticks.len() as f64)
3323    }
3324
3325    /// Mean signed return: `mean(price_i - price_{i-1}) / price_{i-1}` across all consecutive pairs.
3326    ///
3327    /// Returns `None` for fewer than 2 ticks.
3328    pub fn mean_tick_return(ticks: &[NormalizedTick]) -> Option<f64> {
3329        use rust_decimal::prelude::ToPrimitive;
3330        if ticks.len() < 2 {
3331            return None;
3332        }
3333        let returns: Vec<f64> = ticks
3334            .windows(2)
3335            .filter_map(|w| {
3336                let prev = w[0].price.to_f64()?;
3337                if prev == 0.0 { return None; }
3338                let curr = w[1].price.to_f64()?;
3339                Some((curr - prev) / prev)
3340            })
3341            .collect();
3342        if returns.is_empty() {
3343            return None;
3344        }
3345        Some(returns.iter().sum::<f64>() / returns.len() as f64)
3346    }
3347
3348    /// Count of ticks where `side == Buy` and price is strictly below VWAP (passive buy).
3349    ///
3350    /// Returns 0 if VWAP cannot be computed.
3351    pub fn passive_buy_count(ticks: &[NormalizedTick]) -> usize {
3352        let vwap = match Self::vwap(ticks) {
3353            Some(v) => v,
3354            None => return 0,
3355        };
3356        ticks
3357            .iter()
3358            .filter(|t| t.side == Some(TradeSide::Buy) && t.price < vwap)
3359            .count()
3360    }
3361
3362    /// Count of ticks where `side == Sell` and price is strictly above VWAP (passive sell).
3363    ///
3364    /// Returns 0 if VWAP cannot be computed.
3365    pub fn passive_sell_count(ticks: &[NormalizedTick]) -> usize {
3366        let vwap = match Self::vwap(ticks) {
3367            Some(v) => v,
3368            None => return 0,
3369        };
3370        ticks
3371            .iter()
3372            .filter(|t| t.side == Some(TradeSide::Sell) && t.price > vwap)
3373            .count()
3374    }
3375
3376    /// Interquartile range of quantities (Q3 − Q1).
3377    ///
3378    /// Returns `None` for fewer than 4 ticks.
3379    pub fn quantity_iqr(ticks: &[NormalizedTick]) -> Option<Decimal> {
3380        if ticks.len() < 4 {
3381            return None;
3382        }
3383        let mut qtys: Vec<Decimal> = ticks.iter().map(|t| t.quantity).collect();
3384        qtys.sort();
3385        let n = qtys.len();
3386        let q1 = qtys[n / 4];
3387        let q3 = qtys[(3 * n) / 4];
3388        Some(q3 - q1)
3389    }
3390
3391    /// Fraction of ticks with price above the 75th percentile of all tick prices in the slice.
3392    ///
3393    /// Returns `None` for fewer than 4 ticks.
3394    pub fn top_quartile_price_fraction(ticks: &[NormalizedTick]) -> Option<f64> {
3395        if ticks.len() < 4 {
3396            return None;
3397        }
3398        let mut prices: Vec<Decimal> = ticks.iter().map(|t| t.price).collect();
3399        prices.sort();
3400        let q3 = prices[(3 * prices.len()) / 4];
3401        let count = ticks.iter().filter(|t| t.price > q3).count();
3402        Some(count as f64 / ticks.len() as f64)
3403    }
3404
3405    /// Ratio of buy-side notional to total notional (`buy_qty·price / total_qty·price`).
3406    /// Returns `None` for empty slices or zero total notional.
3407    pub fn buy_notional_ratio(ticks: &[NormalizedTick]) -> Option<f64> {
3408        use rust_decimal::prelude::ToPrimitive;
3409        if ticks.is_empty() {
3410            return None;
3411        }
3412        let total_notional: Decimal = ticks.iter().map(|t| t.price * t.quantity).sum();
3413        if total_notional.is_zero() {
3414            return None;
3415        }
3416        let buy_notional: Decimal = ticks
3417            .iter()
3418            .filter(|t| t.side == Some(TradeSide::Buy))
3419            .map(|t| t.price * t.quantity)
3420            .sum();
3421        (buy_notional / total_notional).to_f64()
3422    }
3423
3424    /// Standard deviation of tick-to-tick signed returns.
3425    /// Returns `None` for fewer than 3 ticks (need ≥ 2 returns).
3426    pub fn return_std(ticks: &[NormalizedTick]) -> Option<f64> {
3427        use rust_decimal::prelude::ToPrimitive;
3428        if ticks.len() < 3 {
3429            return None;
3430        }
3431        let returns: Vec<f64> = ticks
3432            .windows(2)
3433            .filter_map(|w| {
3434                let prev = w[0].price.to_f64()?;
3435                if prev == 0.0 { return None; }
3436                Some((w[1].price.to_f64()? - prev) / prev)
3437            })
3438            .collect();
3439        if returns.len() < 2 {
3440            return None;
3441        }
3442        let n = returns.len() as f64;
3443        let mean = returns.iter().sum::<f64>() / n;
3444        let var = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
3445        Some(var.sqrt())
3446    }
3447
3448    // ── round-90 ─────────────────────────────────────────────────────────────
3449
3450    /// Maximum price drawdown from peak: `max(peak − price) / peak` over the slice.
3451    ///
3452    /// Returns `None` for an empty slice or a zero peak price.
3453    pub fn max_drawdown(ticks: &[NormalizedTick]) -> Option<f64> {
3454        use rust_decimal::prelude::ToPrimitive;
3455        if ticks.is_empty() {
3456            return None;
3457        }
3458        let mut peak = ticks[0].price;
3459        let mut max_dd = Decimal::ZERO;
3460        for t in ticks {
3461            if t.price > peak {
3462                peak = t.price;
3463            }
3464            let dd = peak - t.price;
3465            if dd > max_dd {
3466                max_dd = dd;
3467            }
3468        }
3469        if peak.is_zero() {
3470            return None;
3471        }
3472        (max_dd / peak).to_f64()
3473    }
3474
3475    /// Ratio of the highest price to the lowest price in the slice.
3476    ///
3477    /// Returns `None` for an empty slice or a zero minimum price.
3478    pub fn high_to_low_ratio(ticks: &[NormalizedTick]) -> Option<f64> {
3479        use rust_decimal::prelude::ToPrimitive;
3480        if ticks.is_empty() {
3481            return None;
3482        }
3483        let high = ticks.iter().map(|t| t.price).max()?;
3484        let low = ticks.iter().map(|t| t.price).min()?;
3485        if low.is_zero() {
3486            return None;
3487        }
3488        (high / low).to_f64()
3489    }
3490
3491    /// Tick arrival rate: `tick_count / time_span_ms`.
3492    ///
3493    /// Returns `None` when the slice has fewer than 2 ticks or the time span is zero.
3494    pub fn tick_velocity(ticks: &[NormalizedTick]) -> Option<f64> {
3495        if ticks.len() < 2 {
3496            return None;
3497        }
3498        let first_ms = ticks.first()?.received_at_ms;
3499        let last_ms = ticks.last()?.received_at_ms;
3500        let span = last_ms.saturating_sub(first_ms);
3501        if span == 0 {
3502            return None;
3503        }
3504        Some(ticks.len() as f64 / span as f64)
3505    }
3506
3507    /// Ratio of second-half notional to first-half notional.
3508    ///
3509    /// Measures whether trading activity is accelerating (`> 1`) or decelerating (`< 1`).
3510    /// Returns `None` for fewer than 2 ticks or zero first-half notional.
3511    pub fn notional_decay(ticks: &[NormalizedTick]) -> Option<f64> {
3512        use rust_decimal::prelude::ToPrimitive;
3513        if ticks.len() < 2 {
3514            return None;
3515        }
3516        let mid = ticks.len() / 2;
3517        let first_half: Decimal = ticks[..mid].iter().map(|t| t.price * t.quantity).sum();
3518        let second_half: Decimal = ticks[mid..].iter().map(|t| t.price * t.quantity).sum();
3519        if first_half.is_zero() {
3520            return None;
3521        }
3522        (second_half / first_half).to_f64()
3523    }
3524
3525    /// Momentum of the second half vs the first half: `(mean_price_2 − mean_price_1) / mean_price_1`.
3526    ///
3527    /// Returns `None` for fewer than 2 ticks or a zero first-half mean.
3528    pub fn late_price_momentum(ticks: &[NormalizedTick]) -> Option<f64> {
3529        use rust_decimal::prelude::ToPrimitive;
3530        if ticks.len() < 2 {
3531            return None;
3532        }
3533        let mid = ticks.len() / 2;
3534        let n1 = mid as u32;
3535        let n2 = (ticks.len() - mid) as u32;
3536        if n1 == 0 || n2 == 0 {
3537            return None;
3538        }
3539        let mean1: Decimal = ticks[..mid].iter().map(|t| t.price).sum::<Decimal>()
3540            / Decimal::from(n1);
3541        let mean2: Decimal = ticks[mid..].iter().map(|t| t.price).sum::<Decimal>()
3542            / Decimal::from(n2);
3543        if mean1.is_zero() {
3544            return None;
3545        }
3546        ((mean2 - mean1) / mean1).to_f64()
3547    }
3548
3549    /// Maximum run of consecutive buy-side ticks.
3550    ///
3551    /// Returns `0` for an empty slice or one with no buy ticks.
3552    pub fn consecutive_buys_max(ticks: &[NormalizedTick]) -> usize {
3553        let mut max_run = 0usize;
3554        let mut run = 0usize;
3555        for t in ticks {
3556            if t.side == Some(TradeSide::Buy) {
3557                run += 1;
3558                if run > max_run {
3559                    max_run = run;
3560                }
3561            } else {
3562                run = 0;
3563            }
3564        }
3565        max_run
3566    }
3567
3568}
3569
3570
3571impl std::fmt::Display for NormalizedTick {
3572    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3573        let side = match self.side {
3574            Some(s) => s.to_string(),
3575            None => "?".to_string(),
3576        };
3577        write!(
3578            f,
3579            "{} {} {} x {} {} @{}ms",
3580            self.exchange, self.symbol, self.price, self.quantity, side, self.received_at_ms
3581        )
3582    }
3583}
3584
3585/// Normalizes raw ticks from any supported exchange into [`NormalizedTick`] form.
3586///
3587/// `TickNormalizer` is stateless and cheap to clone; a single instance can be
3588/// shared across threads via `Arc` or constructed per-task.
3589pub struct TickNormalizer;
3590
3591impl TickNormalizer {
3592    /// Create a new normalizer. This is a zero-cost constructor.
3593    pub fn new() -> Self {
3594        Self
3595    }
3596
3597    /// Normalize a raw tick into canonical form.
3598    ///
3599    /// # Errors
3600    ///
3601    /// Returns [`StreamError::ParseError`] if required fields are missing or
3602    /// malformed, and [`StreamError::InvalidTick`] if price is not positive or
3603    /// quantity is negative.
3604    pub fn normalize(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
3605        let tick = match raw.exchange {
3606            Exchange::Binance => self.normalize_binance(raw),
3607            Exchange::Coinbase => self.normalize_coinbase(raw),
3608            Exchange::Alpaca => self.normalize_alpaca(raw),
3609            Exchange::Polygon => self.normalize_polygon(raw),
3610        }?;
3611        if tick.price <= Decimal::ZERO {
3612            return Err(StreamError::InvalidTick {
3613                reason: format!("price must be positive, got {}", tick.price),
3614            });
3615        }
3616        if tick.quantity < Decimal::ZERO {
3617            return Err(StreamError::InvalidTick {
3618                reason: format!("quantity must be non-negative, got {}", tick.quantity),
3619            });
3620        }
3621        trace!(
3622            exchange = %tick.exchange,
3623            symbol = %tick.symbol,
3624            price = %tick.price,
3625            exchange_ts_ms = ?tick.exchange_ts_ms,
3626            "tick normalized"
3627        );
3628        Ok(tick)
3629    }
3630
3631    fn normalize_binance(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
3632        let p = &raw.payload;
3633        let price = parse_decimal_field(p, "p", &raw.exchange.to_string())?;
3634        let qty = parse_decimal_field(p, "q", &raw.exchange.to_string())?;
3635        let side = p.get("m").and_then(|v| v.as_bool()).map(|maker| {
3636            if maker {
3637                TradeSide::Sell
3638            } else {
3639                TradeSide::Buy
3640            }
3641        });
3642        let trade_id = p.get("t").and_then(|v| v.as_u64()).map(|id| id.to_string());
3643        let exchange_ts = p.get("T").and_then(|v| v.as_u64());
3644        Ok(NormalizedTick {
3645            exchange: raw.exchange,
3646            symbol: raw.symbol,
3647            price,
3648            quantity: qty,
3649            side,
3650            trade_id,
3651            exchange_ts_ms: exchange_ts,
3652            received_at_ms: raw.received_at_ms,
3653        })
3654    }
3655
3656    fn normalize_coinbase(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
3657        let p = &raw.payload;
3658        let price = parse_decimal_field(p, "price", &raw.exchange.to_string())?;
3659        let qty = parse_decimal_field(p, "size", &raw.exchange.to_string())?;
3660        let side = p.get("side").and_then(|v| v.as_str()).map(|s| {
3661            if s == "buy" {
3662                TradeSide::Buy
3663            } else {
3664                TradeSide::Sell
3665            }
3666        });
3667        let trade_id = p
3668            .get("trade_id")
3669            .and_then(|v| v.as_str())
3670            .map(str::to_string);
3671        // Coinbase Advanced Trade sends an ISO 8601 timestamp in the "time" field.
3672        let exchange_ts_ms = p
3673            .get("time")
3674            .and_then(|v| v.as_str())
3675            .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
3676            .map(|dt| dt.timestamp_millis() as u64);
3677        Ok(NormalizedTick {
3678            exchange: raw.exchange,
3679            symbol: raw.symbol,
3680            price,
3681            quantity: qty,
3682            side,
3683            trade_id,
3684            exchange_ts_ms,
3685            received_at_ms: raw.received_at_ms,
3686        })
3687    }
3688
3689    fn normalize_alpaca(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
3690        let p = &raw.payload;
3691        let price = parse_decimal_field(p, "p", &raw.exchange.to_string())?;
3692        let qty = parse_decimal_field(p, "s", &raw.exchange.to_string())?;
3693        let trade_id = p.get("i").and_then(|v| v.as_u64()).map(|id| id.to_string());
3694        // Alpaca sends RFC 3339 timestamps in the "t" field (e.g. "2023-11-15T10:00:00.000Z").
3695        let exchange_ts_ms = p
3696            .get("t")
3697            .and_then(|v| v.as_str())
3698            .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
3699            .map(|dt| dt.timestamp_millis() as u64);
3700        Ok(NormalizedTick {
3701            exchange: raw.exchange,
3702            symbol: raw.symbol,
3703            price,
3704            quantity: qty,
3705            side: None,
3706            trade_id,
3707            exchange_ts_ms,
3708            received_at_ms: raw.received_at_ms,
3709        })
3710    }
3711
3712    fn normalize_polygon(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
3713        let p = &raw.payload;
3714        let price = parse_decimal_field(p, "p", &raw.exchange.to_string())?;
3715        let qty = parse_decimal_field(p, "s", &raw.exchange.to_string())?;
3716        let trade_id = p.get("i").and_then(|v| v.as_str()).map(str::to_string);
3717        // Polygon sends nanoseconds since epoch in the "t" field; convert to milliseconds.
3718        let exchange_ts = p
3719            .get("t")
3720            .and_then(|v| v.as_u64())
3721            .map(|t_ns| t_ns / 1_000_000);
3722        Ok(NormalizedTick {
3723            exchange: raw.exchange,
3724            symbol: raw.symbol,
3725            price,
3726            quantity: qty,
3727            side: None,
3728            trade_id,
3729            exchange_ts_ms: exchange_ts,
3730            received_at_ms: raw.received_at_ms,
3731        })
3732    }
3733}
3734
3735impl Default for TickNormalizer {
3736    fn default() -> Self {
3737        Self::new()
3738    }
3739}
3740
3741fn parse_decimal_field(
3742    v: &serde_json::Value,
3743    field: &str,
3744    exchange: &str,
3745) -> Result<Decimal, StreamError> {
3746    let raw = v.get(field).ok_or_else(|| StreamError::ParseError {
3747        exchange: exchange.to_string(),
3748        reason: format!("missing field '{}'", field),
3749    })?;
3750    // Use the JSON-native string representation for both string and number
3751    // values. For JSON strings this is a direct parse. For JSON numbers we use
3752    // serde_json::Number::to_string(), which preserves the original text (e.g.
3753    // "50000.12345678") rather than round-tripping through f64 and losing
3754    // sub-microsecond precision.
3755    let s: String = match raw {
3756        serde_json::Value::String(s) => s.clone(),
3757        serde_json::Value::Number(n) => n.to_string(),
3758        _ => {
3759            return Err(StreamError::ParseError {
3760                exchange: exchange.to_string(),
3761                reason: format!("field '{}' is not a string or number", field),
3762            });
3763        }
3764    };
3765    Decimal::from_str(&s).map_err(|e| StreamError::ParseError {
3766        exchange: exchange.to_string(),
3767        reason: format!("field '{}' parse error: {}", field, e),
3768    })
3769}
3770
3771fn now_ms() -> u64 {
3772    std::time::SystemTime::now()
3773        .duration_since(std::time::UNIX_EPOCH)
3774        .map(|d| d.as_millis() as u64)
3775        .unwrap_or(0)
3776}
3777
3778#[cfg(test)]
3779mod tests {
3780    use super::*;
3781    use serde_json::json;
3782
3783    fn normalizer() -> TickNormalizer {
3784        TickNormalizer::new()
3785    }
3786
3787    fn binance_tick(symbol: &str) -> RawTick {
3788        RawTick {
3789            exchange: Exchange::Binance,
3790            symbol: symbol.to_string(),
3791            payload: json!({ "p": "50000.12", "q": "0.001", "m": false, "t": 12345, "T": 1700000000000u64 }),
3792            received_at_ms: 1700000000001,
3793        }
3794    }
3795
3796    fn coinbase_tick(symbol: &str) -> RawTick {
3797        RawTick {
3798            exchange: Exchange::Coinbase,
3799            symbol: symbol.to_string(),
3800            payload: json!({ "price": "50001.00", "size": "0.5", "side": "buy", "trade_id": "abc123" }),
3801            received_at_ms: 1700000000002,
3802        }
3803    }
3804
3805    fn alpaca_tick(symbol: &str) -> RawTick {
3806        RawTick {
3807            exchange: Exchange::Alpaca,
3808            symbol: symbol.to_string(),
3809            payload: json!({ "p": "180.50", "s": "10", "i": 99 }),
3810            received_at_ms: 1700000000003,
3811        }
3812    }
3813
3814    fn polygon_tick(symbol: &str) -> RawTick {
3815        RawTick {
3816            exchange: Exchange::Polygon,
3817            symbol: symbol.to_string(),
3818            // Polygon sends nanoseconds; 1_700_000_000_000_000_000 ns = 1_700_000_000_000 ms
3819            payload: json!({ "p": "180.51", "s": "5", "i": "XYZ-001", "t": 1_700_000_000_000_000_000u64 }),
3820            received_at_ms: 1700000000005,
3821        }
3822    }
3823
3824    #[test]
3825    fn test_exchange_from_str_valid() {
3826        assert_eq!("binance".parse::<Exchange>().unwrap(), Exchange::Binance);
3827        assert_eq!("Coinbase".parse::<Exchange>().unwrap(), Exchange::Coinbase);
3828        assert_eq!("ALPACA".parse::<Exchange>().unwrap(), Exchange::Alpaca);
3829        assert_eq!("polygon".parse::<Exchange>().unwrap(), Exchange::Polygon);
3830    }
3831
3832    #[test]
3833    fn test_exchange_from_str_unknown_returns_error() {
3834        let result = "Kraken".parse::<Exchange>();
3835        assert!(matches!(result, Err(StreamError::UnknownExchange(_))));
3836    }
3837
3838    #[test]
3839    fn test_exchange_display() {
3840        assert_eq!(Exchange::Binance.to_string(), "Binance");
3841        assert_eq!(Exchange::Coinbase.to_string(), "Coinbase");
3842    }
3843
3844    #[test]
3845    fn test_normalize_binance_tick_price_and_qty() {
3846        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
3847        assert_eq!(tick.price, Decimal::from_str("50000.12").unwrap());
3848        assert_eq!(tick.quantity, Decimal::from_str("0.001").unwrap());
3849        assert_eq!(tick.exchange, Exchange::Binance);
3850        assert_eq!(tick.symbol, "BTCUSDT");
3851    }
3852
3853    #[test]
3854    fn test_normalize_binance_side_maker_false_is_buy() {
3855        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
3856        assert_eq!(tick.side, Some(TradeSide::Buy));
3857    }
3858
3859    #[test]
3860    fn test_normalize_binance_side_maker_true_is_sell() {
3861        let raw = RawTick {
3862            exchange: Exchange::Binance,
3863            symbol: "BTCUSDT".into(),
3864            payload: json!({ "p": "50000", "q": "1", "m": true }),
3865            received_at_ms: 0,
3866        };
3867        let tick = normalizer().normalize(raw).unwrap();
3868        assert_eq!(tick.side, Some(TradeSide::Sell));
3869    }
3870
3871    #[test]
3872    fn test_normalize_binance_trade_id_and_ts() {
3873        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
3874        assert_eq!(tick.trade_id, Some("12345".to_string()));
3875        assert_eq!(tick.exchange_ts_ms, Some(1700000000000));
3876    }
3877
3878    #[test]
3879    fn test_normalize_coinbase_tick() {
3880        let tick = normalizer().normalize(coinbase_tick("BTC-USD")).unwrap();
3881        assert_eq!(tick.price, Decimal::from_str("50001.00").unwrap());
3882        assert_eq!(tick.quantity, Decimal::from_str("0.5").unwrap());
3883        assert_eq!(tick.side, Some(TradeSide::Buy));
3884        assert_eq!(tick.trade_id, Some("abc123".to_string()));
3885    }
3886
3887    #[test]
3888    fn test_normalize_coinbase_sell_side() {
3889        let raw = RawTick {
3890            exchange: Exchange::Coinbase,
3891            symbol: "BTC-USD".into(),
3892            payload: json!({ "price": "50000", "size": "1", "side": "sell" }),
3893            received_at_ms: 0,
3894        };
3895        let tick = normalizer().normalize(raw).unwrap();
3896        assert_eq!(tick.side, Some(TradeSide::Sell));
3897    }
3898
3899    #[test]
3900    fn test_normalize_alpaca_tick() {
3901        let tick = normalizer().normalize(alpaca_tick("AAPL")).unwrap();
3902        assert_eq!(tick.price, Decimal::from_str("180.50").unwrap());
3903        assert_eq!(tick.quantity, Decimal::from_str("10").unwrap());
3904        assert_eq!(tick.trade_id, Some("99".to_string()));
3905        assert_eq!(tick.side, None);
3906    }
3907
3908    #[test]
3909    fn test_normalize_polygon_tick() {
3910        let tick = normalizer().normalize(polygon_tick("AAPL")).unwrap();
3911        assert_eq!(tick.price, Decimal::from_str("180.51").unwrap());
3912        // 1_700_000_000_000_000_000 ns / 1_000_000 = 1_700_000_000_000 ms
3913        assert_eq!(tick.exchange_ts_ms, Some(1_700_000_000_000u64));
3914        assert_eq!(tick.trade_id, Some("XYZ-001".to_string()));
3915    }
3916
3917    #[test]
3918    fn test_normalize_alpaca_rfc3339_timestamp() {
3919        let raw = RawTick {
3920            exchange: Exchange::Alpaca,
3921            symbol: "AAPL".into(),
3922            payload: json!({ "p": "180.50", "s": "10", "i": 99, "t": "2023-11-15T00:00:00Z" }),
3923            received_at_ms: 1700000000003,
3924        };
3925        let tick = normalizer().normalize(raw).unwrap();
3926        assert!(tick.exchange_ts_ms.is_some(), "Alpaca 't' field should be parsed");
3927        // 2023-11-15T00:00:00Z = 1700006400000 ms
3928        assert_eq!(tick.exchange_ts_ms, Some(1700006400000u64));
3929    }
3930
3931    #[test]
3932    fn test_normalize_alpaca_no_timestamp_field() {
3933        let tick = normalizer().normalize(alpaca_tick("AAPL")).unwrap();
3934        assert_eq!(tick.exchange_ts_ms, None, "missing 't' field means no exchange_ts_ms");
3935    }
3936
3937    #[test]
3938    fn test_normalize_missing_price_field_returns_parse_error() {
3939        let raw = RawTick {
3940            exchange: Exchange::Binance,
3941            symbol: "BTCUSDT".into(),
3942            payload: json!({ "q": "1" }),
3943            received_at_ms: 0,
3944        };
3945        let result = normalizer().normalize(raw);
3946        assert!(matches!(result, Err(StreamError::ParseError { .. })));
3947    }
3948
3949    #[test]
3950    fn test_normalize_invalid_decimal_returns_parse_error() {
3951        let raw = RawTick {
3952            exchange: Exchange::Coinbase,
3953            symbol: "BTC-USD".into(),
3954            payload: json!({ "price": "not-a-number", "size": "1" }),
3955            received_at_ms: 0,
3956        };
3957        let result = normalizer().normalize(raw);
3958        assert!(matches!(result, Err(StreamError::ParseError { .. })));
3959    }
3960
3961    #[test]
3962    fn test_raw_tick_new_sets_received_at() {
3963        let raw = RawTick::new(Exchange::Binance, "BTCUSDT", json!({}));
3964        assert!(raw.received_at_ms > 0);
3965    }
3966
3967    #[test]
3968    fn test_normalize_numeric_price_field() {
3969        let raw = RawTick {
3970            exchange: Exchange::Binance,
3971            symbol: "BTCUSDT".into(),
3972            payload: json!({ "p": 50000.0, "q": 1.0 }),
3973            received_at_ms: 0,
3974        };
3975        let tick = normalizer().normalize(raw).unwrap();
3976        assert!(tick.price > Decimal::ZERO);
3977    }
3978
3979    #[test]
3980    fn test_trade_side_from_str_buy() {
3981        assert_eq!("buy".parse::<TradeSide>().unwrap(), TradeSide::Buy);
3982        assert_eq!("Buy".parse::<TradeSide>().unwrap(), TradeSide::Buy);
3983        assert_eq!("BUY".parse::<TradeSide>().unwrap(), TradeSide::Buy);
3984    }
3985
3986    #[test]
3987    fn test_trade_side_from_str_sell() {
3988        assert_eq!("sell".parse::<TradeSide>().unwrap(), TradeSide::Sell);
3989        assert_eq!("Sell".parse::<TradeSide>().unwrap(), TradeSide::Sell);
3990        assert_eq!("SELL".parse::<TradeSide>().unwrap(), TradeSide::Sell);
3991    }
3992
3993    #[test]
3994    fn test_trade_side_from_str_invalid() {
3995        let err = "long".parse::<TradeSide>().unwrap_err();
3996        assert!(matches!(err, StreamError::ParseError { .. }));
3997    }
3998
3999    #[test]
4000    fn test_trade_side_display() {
4001        assert_eq!(TradeSide::Buy.to_string(), "buy");
4002        assert_eq!(TradeSide::Sell.to_string(), "sell");
4003    }
4004
4005    #[test]
4006    fn test_normalize_zero_price_returns_invalid_tick() {
4007        let raw = RawTick {
4008            exchange: Exchange::Binance,
4009            symbol: "BTCUSDT".into(),
4010            payload: json!({ "p": "0", "q": "1" }),
4011            received_at_ms: 0,
4012        };
4013        let err = normalizer().normalize(raw).unwrap_err();
4014        assert!(matches!(err, StreamError::InvalidTick { .. }));
4015    }
4016
4017    #[test]
4018    fn test_normalize_negative_price_returns_invalid_tick() {
4019        let raw = RawTick {
4020            exchange: Exchange::Binance,
4021            symbol: "BTCUSDT".into(),
4022            payload: json!({ "p": "-1", "q": "1" }),
4023            received_at_ms: 0,
4024        };
4025        let err = normalizer().normalize(raw).unwrap_err();
4026        assert!(matches!(err, StreamError::InvalidTick { .. }));
4027    }
4028
4029    #[test]
4030    fn test_normalize_negative_quantity_returns_invalid_tick() {
4031        let raw = RawTick {
4032            exchange: Exchange::Binance,
4033            symbol: "BTCUSDT".into(),
4034            payload: json!({ "p": "100", "q": "-1" }),
4035            received_at_ms: 0,
4036        };
4037        let err = normalizer().normalize(raw).unwrap_err();
4038        assert!(matches!(err, StreamError::InvalidTick { .. }));
4039    }
4040
4041    #[test]
4042    fn test_normalize_zero_quantity_is_valid() {
4043        // Zero quantity is allowed (e.g., remove from book), just not negative
4044        let raw = RawTick {
4045            exchange: Exchange::Binance,
4046            symbol: "BTCUSDT".into(),
4047            payload: json!({ "p": "100", "q": "0" }),
4048            received_at_ms: 0,
4049        };
4050        let tick = normalizer().normalize(raw).unwrap();
4051        assert_eq!(tick.quantity, Decimal::ZERO);
4052    }
4053
4054    #[test]
4055    fn test_trade_side_is_buy() {
4056        assert!(TradeSide::Buy.is_buy());
4057        assert!(!TradeSide::Buy.is_sell());
4058    }
4059
4060    #[test]
4061    fn test_trade_side_is_sell() {
4062        assert!(TradeSide::Sell.is_sell());
4063        assert!(!TradeSide::Sell.is_buy());
4064    }
4065
4066    #[test]
4067    fn test_normalized_tick_display() {
4068        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
4069        let s = tick.to_string();
4070        assert!(s.contains("Binance"));
4071        assert!(s.contains("BTCUSDT"));
4072        assert!(s.contains("50000"));
4073    }
4074
4075    #[test]
4076    fn test_normalized_tick_value_is_price_times_qty() {
4077        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
4078        // binance_tick sets price=50000, quantity=0.001
4079        let expected = tick.price * tick.quantity;
4080        assert_eq!(tick.volume_notional(), expected);
4081    }
4082
4083    #[test]
4084    fn test_normalized_tick_age_ms_positive() {
4085        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
4086        // received_at_ms is set to 1_000_000 in binance_tick helper? Let's check
4087        // Actually the helper uses now_ms() so we can't predict. Use a manual tick.
4088        let raw = RawTick {
4089            exchange: Exchange::Binance,
4090            symbol: "BTCUSDT".into(),
4091            payload: serde_json::json!({"p": "50000", "q": "0.001", "m": false}),
4092            received_at_ms: 1_000_000,
4093        };
4094        let tick = normalizer().normalize(raw).unwrap();
4095        assert_eq!(tick.age_ms(1_001_000), 1_000);
4096    }
4097
4098    #[test]
4099    fn test_normalized_tick_age_ms_zero_when_now_equals_received() {
4100        let raw = RawTick {
4101            exchange: Exchange::Binance,
4102            symbol: "BTCUSDT".into(),
4103            payload: serde_json::json!({"p": "50000", "q": "0.001", "m": false}),
4104            received_at_ms: 5_000,
4105        };
4106        let tick = normalizer().normalize(raw).unwrap();
4107        assert_eq!(tick.age_ms(5_000), 0);
4108        // saturating_sub: now < received → 0
4109        assert_eq!(tick.age_ms(4_000), 0);
4110    }
4111
4112    #[test]
4113    fn test_normalized_tick_value_zero_qty_is_zero() {
4114        use rust_decimal_macros::dec;
4115        let raw = RawTick {
4116            exchange: Exchange::Binance,
4117            symbol: "BTCUSDT".into(),
4118            payload: serde_json::json!({
4119                "p": "50000",
4120                "q": "0",
4121                "m": false,
4122            }),
4123            received_at_ms: 1000,
4124        };
4125        let tick = normalizer().normalize(raw).unwrap();
4126        assert_eq!(tick.value(), dec!(0));
4127    }
4128
4129    // ── NormalizedTick::is_stale ──────────────────────────────────────────────
4130
4131    fn make_tick_at(received_at_ms: u64) -> NormalizedTick {
4132        NormalizedTick {
4133            exchange: Exchange::Binance,
4134            symbol: "BTCUSDT".into(),
4135            price: rust_decimal_macros::dec!(100),
4136            quantity: rust_decimal_macros::dec!(1),
4137            side: None,
4138            trade_id: None,
4139            exchange_ts_ms: None,
4140            received_at_ms,
4141        }
4142    }
4143
4144    #[test]
4145    fn test_is_stale_true_when_age_exceeds_threshold() {
4146        let tick = make_tick_at(1_000);
4147        // now=6000, age=5000, threshold=4000 → stale
4148        assert!(tick.is_stale(6_000, 4_000));
4149    }
4150
4151    #[test]
4152    fn test_is_stale_false_when_age_equals_threshold() {
4153        let tick = make_tick_at(1_000);
4154        // now=5000, age=4000, threshold=4000 → NOT stale (> not >=)
4155        assert!(!tick.is_stale(5_000, 4_000));
4156    }
4157
4158    #[test]
4159    fn test_is_stale_false_for_fresh_tick() {
4160        let tick = make_tick_at(10_000);
4161        assert!(!tick.is_stale(10_500, 1_000));
4162    }
4163
4164    // ── NormalizedTick::is_buy / is_sell ──────────────────────────────────────
4165
4166    #[test]
4167    fn test_is_buy_true_for_buy_side() {
4168        let mut tick = make_tick_at(1_000);
4169        tick.side = Some(TradeSide::Buy);
4170        assert!(tick.is_buy());
4171        assert!(!tick.is_sell());
4172    }
4173
4174    #[test]
4175    fn test_is_sell_true_for_sell_side() {
4176        let mut tick = make_tick_at(1_000);
4177        tick.side = Some(TradeSide::Sell);
4178        assert!(tick.is_sell());
4179        assert!(!tick.is_buy());
4180    }
4181
4182    #[test]
4183    fn test_is_buy_false_for_unknown_side() {
4184        let mut tick = make_tick_at(1_000);
4185        tick.side = None;
4186        assert!(!tick.is_buy());
4187        assert!(!tick.is_sell());
4188    }
4189
4190    // ── NormalizedTick::with_exchange_ts ──────────────────────────────────────
4191
4192    #[test]
4193    fn test_with_exchange_ts_sets_field() {
4194        let tick = make_tick_at(5_000).with_exchange_ts(3_000);
4195        assert_eq!(tick.exchange_ts_ms, Some(3_000));
4196        assert_eq!(tick.received_at_ms, 5_000); // unchanged
4197    }
4198
4199    #[test]
4200    fn test_with_exchange_ts_overrides_existing() {
4201        let tick = make_tick_at(1_000).with_exchange_ts(999).with_exchange_ts(888);
4202        assert_eq!(tick.exchange_ts_ms, Some(888));
4203    }
4204
4205    // ── NormalizedTick::price_move_from / is_more_recent_than ─────────────────
4206
4207    #[test]
4208    fn test_price_move_from_positive() {
4209        let prev = make_tick_at(1_000);
4210        let mut curr = make_tick_at(2_000);
4211        curr.price = prev.price + rust_decimal_macros::dec!(5);
4212        assert_eq!(curr.price_move_from(&prev), rust_decimal_macros::dec!(5));
4213    }
4214
4215    #[test]
4216    fn test_price_move_from_negative() {
4217        let prev = make_tick_at(1_000);
4218        let mut curr = make_tick_at(2_000);
4219        curr.price = prev.price - rust_decimal_macros::dec!(3);
4220        assert_eq!(curr.price_move_from(&prev), rust_decimal_macros::dec!(-3));
4221    }
4222
4223    #[test]
4224    fn test_price_move_from_zero_when_same() {
4225        let tick = make_tick_at(1_000);
4226        assert_eq!(tick.price_move_from(&tick), rust_decimal_macros::dec!(0));
4227    }
4228
4229    #[test]
4230    fn test_is_more_recent_than_true() {
4231        let older = make_tick_at(1_000);
4232        let newer = make_tick_at(2_000);
4233        assert!(newer.is_more_recent_than(&older));
4234    }
4235
4236    #[test]
4237    fn test_is_more_recent_than_false_when_older() {
4238        let older = make_tick_at(1_000);
4239        let newer = make_tick_at(2_000);
4240        assert!(!older.is_more_recent_than(&newer));
4241    }
4242
4243    #[test]
4244    fn test_is_more_recent_than_false_when_equal() {
4245        let tick = make_tick_at(1_000);
4246        assert!(!tick.is_more_recent_than(&tick));
4247    }
4248
4249    // ── NormalizedTick::with_side ─────────────────────────────────────────────
4250
4251    #[test]
4252    fn test_with_side_sets_buy() {
4253        let tick = make_tick_at(1_000).with_side(TradeSide::Buy);
4254        assert_eq!(tick.side, Some(TradeSide::Buy));
4255    }
4256
4257    #[test]
4258    fn test_with_side_sets_sell() {
4259        let tick = make_tick_at(1_000).with_side(TradeSide::Sell);
4260        assert_eq!(tick.side, Some(TradeSide::Sell));
4261    }
4262
4263    #[test]
4264    fn test_with_side_overrides_existing() {
4265        let tick = make_tick_at(1_000).with_side(TradeSide::Buy).with_side(TradeSide::Sell);
4266        assert_eq!(tick.side, Some(TradeSide::Sell));
4267    }
4268
4269    // ── NormalizedTick::is_neutral ────────────────────────────────────────────
4270
4271    #[test]
4272    fn test_is_neutral_true_when_no_side() {
4273        let mut tick = make_tick_at(1_000);
4274        tick.side = None;
4275        assert!(tick.is_neutral());
4276    }
4277
4278    #[test]
4279    fn test_is_neutral_false_when_buy() {
4280        let tick = make_tick_at(1_000).with_side(TradeSide::Buy);
4281        assert!(!tick.is_neutral());
4282    }
4283
4284    #[test]
4285    fn test_is_neutral_false_when_sell() {
4286        let tick = make_tick_at(1_000).with_side(TradeSide::Sell);
4287        assert!(!tick.is_neutral());
4288    }
4289
4290    // ── NormalizedTick::is_large_trade ────────────────────────────────────────
4291
4292    #[test]
4293    fn test_is_large_trade_above_threshold() {
4294        let mut tick = make_tick_at(1_000);
4295        tick.quantity = rust_decimal_macros::dec!(100);
4296        assert!(tick.is_large_trade(rust_decimal_macros::dec!(50)));
4297    }
4298
4299    #[test]
4300    fn test_is_large_trade_at_threshold() {
4301        let mut tick = make_tick_at(1_000);
4302        tick.quantity = rust_decimal_macros::dec!(50);
4303        assert!(tick.is_large_trade(rust_decimal_macros::dec!(50)));
4304    }
4305
4306    #[test]
4307    fn test_is_large_trade_below_threshold() {
4308        let mut tick = make_tick_at(1_000);
4309        tick.quantity = rust_decimal_macros::dec!(10);
4310        assert!(!tick.is_large_trade(rust_decimal_macros::dec!(50)));
4311    }
4312
4313    #[test]
4314    fn test_volume_notional_is_price_times_quantity() {
4315        let mut tick = make_tick_at(1_000);
4316        tick.price = rust_decimal_macros::dec!(200);
4317        tick.quantity = rust_decimal_macros::dec!(3);
4318        assert_eq!(tick.volume_notional(), rust_decimal_macros::dec!(600));
4319    }
4320
4321    // ── NormalizedTick::is_above ──────────────────────────────────────────────
4322
4323    #[test]
4324    fn test_is_above_returns_true_when_price_higher() {
4325        let mut tick = make_tick_at(1_000);
4326        tick.price = rust_decimal_macros::dec!(200);
4327        assert!(tick.is_above(rust_decimal_macros::dec!(150)));
4328    }
4329
4330    #[test]
4331    fn test_is_above_returns_false_when_price_equal() {
4332        let mut tick = make_tick_at(1_000);
4333        tick.price = rust_decimal_macros::dec!(200);
4334        assert!(!tick.is_above(rust_decimal_macros::dec!(200)));
4335    }
4336
4337    #[test]
4338    fn test_is_above_returns_false_when_price_lower() {
4339        let mut tick = make_tick_at(1_000);
4340        tick.price = rust_decimal_macros::dec!(100);
4341        assert!(!tick.is_above(rust_decimal_macros::dec!(200)));
4342    }
4343
4344    // ── NormalizedTick::is_below ──────────────────────────────────────────────
4345
4346    #[test]
4347    fn test_is_below_returns_true_when_price_lower() {
4348        let mut tick = make_tick_at(1_000);
4349        tick.price = rust_decimal_macros::dec!(100);
4350        assert!(tick.is_below(rust_decimal_macros::dec!(150)));
4351    }
4352
4353    #[test]
4354    fn test_is_below_returns_false_when_price_equal() {
4355        let mut tick = make_tick_at(1_000);
4356        tick.price = rust_decimal_macros::dec!(100);
4357        assert!(!tick.is_below(rust_decimal_macros::dec!(100)));
4358    }
4359
4360    #[test]
4361    fn test_is_below_returns_false_when_price_higher() {
4362        let mut tick = make_tick_at(1_000);
4363        tick.price = rust_decimal_macros::dec!(200);
4364        assert!(!tick.is_below(rust_decimal_macros::dec!(100)));
4365    }
4366
4367    // --- has_exchange_ts ---
4368
4369    #[test]
4370    fn test_has_exchange_ts_false_when_none() {
4371        let tick = make_tick_at(1_000);
4372        assert!(!tick.has_exchange_ts());
4373    }
4374
4375    #[test]
4376    fn test_has_exchange_ts_true_when_some() {
4377        let tick = make_tick_at(1_000).with_exchange_ts(900);
4378        assert!(tick.has_exchange_ts());
4379    }
4380
4381    // ── NormalizedTick::is_at ─────────────────────────────────────────────────
4382
4383    #[test]
4384    fn test_is_at_returns_true_when_equal() {
4385        let mut tick = make_tick_at(1_000);
4386        tick.price = rust_decimal_macros::dec!(100);
4387        assert!(tick.is_at(rust_decimal_macros::dec!(100)));
4388    }
4389
4390    #[test]
4391    fn test_is_at_returns_false_when_higher() {
4392        let mut tick = make_tick_at(1_000);
4393        tick.price = rust_decimal_macros::dec!(101);
4394        assert!(!tick.is_at(rust_decimal_macros::dec!(100)));
4395    }
4396
4397    #[test]
4398    fn test_is_at_returns_false_when_lower() {
4399        let mut tick = make_tick_at(1_000);
4400        tick.price = rust_decimal_macros::dec!(99);
4401        assert!(!tick.is_at(rust_decimal_macros::dec!(100)));
4402    }
4403
4404    // ── NormalizedTick::is_buy ────────────────────────────────────────────────
4405
4406    #[test]
4407    fn test_is_buy_true_when_side_is_buy() {
4408        let mut tick = make_tick_at(1_000);
4409        tick.side = Some(TradeSide::Buy);
4410        assert!(tick.is_buy());
4411    }
4412
4413    #[test]
4414    fn test_is_buy_false_when_side_is_sell() {
4415        let mut tick = make_tick_at(1_000);
4416        tick.side = Some(TradeSide::Sell);
4417        assert!(!tick.is_buy());
4418    }
4419
4420    #[test]
4421    fn test_is_buy_false_when_side_is_none() {
4422        let mut tick = make_tick_at(1_000);
4423        tick.side = None;
4424        assert!(!tick.is_buy());
4425    }
4426
4427    // --- side_str / is_round_lot ---
4428
4429    #[test]
4430    fn test_side_str_buy() {
4431        let mut tick = make_tick_at(1_000);
4432        tick.side = Some(TradeSide::Buy);
4433        assert_eq!(tick.side_str(), "buy");
4434    }
4435
4436    #[test]
4437    fn test_side_str_sell() {
4438        let mut tick = make_tick_at(1_000);
4439        tick.side = Some(TradeSide::Sell);
4440        assert_eq!(tick.side_str(), "sell");
4441    }
4442
4443    #[test]
4444    fn test_side_str_unknown_when_none() {
4445        let mut tick = make_tick_at(1_000);
4446        tick.side = None;
4447        assert_eq!(tick.side_str(), "unknown");
4448    }
4449
4450    #[test]
4451    fn test_is_round_lot_true_for_integer_quantity() {
4452        let mut tick = make_tick_at(1_000);
4453        tick.quantity = rust_decimal_macros::dec!(100);
4454        assert!(tick.is_round_lot());
4455    }
4456
4457    #[test]
4458    fn test_is_round_lot_false_for_fractional_quantity() {
4459        let mut tick = make_tick_at(1_000);
4460        tick.quantity = rust_decimal_macros::dec!(0.5);
4461        assert!(!tick.is_round_lot());
4462    }
4463
4464    // --- is_same_symbol_as / price_distance_from ---
4465
4466    #[test]
4467    fn test_is_same_symbol_as_true_when_symbols_match() {
4468        let t1 = make_tick_at(1_000);
4469        let t2 = make_tick_at(2_000);
4470        assert!(t1.is_same_symbol_as(&t2));
4471    }
4472
4473    #[test]
4474    fn test_is_same_symbol_as_false_when_symbols_differ() {
4475        let t1 = make_tick_at(1_000);
4476        let mut t2 = make_tick_at(2_000);
4477        t2.symbol = "ETH-USD".to_string();
4478        assert!(!t1.is_same_symbol_as(&t2));
4479    }
4480
4481    #[test]
4482    fn test_price_distance_from_is_absolute() {
4483        let mut t1 = make_tick_at(1_000);
4484        let mut t2 = make_tick_at(2_000);
4485        t1.price = rust_decimal_macros::dec!(100);
4486        t2.price = rust_decimal_macros::dec!(110);
4487        assert_eq!(t1.price_distance_from(&t2), rust_decimal_macros::dec!(10));
4488        assert_eq!(t2.price_distance_from(&t1), rust_decimal_macros::dec!(10));
4489    }
4490
4491    #[test]
4492    fn test_price_distance_from_zero_when_equal() {
4493        let t1 = make_tick_at(1_000);
4494        let t2 = make_tick_at(2_000);
4495        assert!(t1.price_distance_from(&t2).is_zero());
4496    }
4497
4498    // ── NormalizedTick::is_sell ───────────────────────────────────────────────
4499
4500    #[test]
4501    fn test_is_sell_true_when_side_is_sell() {
4502        let mut tick = make_tick_at(1_000);
4503        tick.side = Some(TradeSide::Sell);
4504        assert!(tick.is_sell());
4505    }
4506
4507    #[test]
4508    fn test_is_sell_false_when_side_is_buy() {
4509        let mut tick = make_tick_at(1_000);
4510        tick.side = Some(TradeSide::Buy);
4511        assert!(!tick.is_sell());
4512    }
4513
4514    #[test]
4515    fn test_is_sell_false_when_side_is_none() {
4516        let mut tick = make_tick_at(1_000);
4517        tick.side = None;
4518        assert!(!tick.is_sell());
4519    }
4520
4521    // --- exchange_latency_ms / is_notional_large_trade ---
4522
4523    #[test]
4524    fn test_exchange_latency_ms_positive_for_normal_delivery() {
4525        let mut tick = make_tick_at(1_100);
4526        tick.exchange_ts_ms = Some(1_000);
4527        assert_eq!(tick.exchange_latency_ms(), Some(100));
4528    }
4529
4530    #[test]
4531    fn test_exchange_latency_ms_negative_for_clock_skew() {
4532        let mut tick = make_tick_at(1_000);
4533        tick.exchange_ts_ms = Some(1_100);
4534        assert_eq!(tick.exchange_latency_ms(), Some(-100));
4535    }
4536
4537    #[test]
4538    fn test_exchange_latency_ms_none_when_no_exchange_ts() {
4539        let mut tick = make_tick_at(1_000);
4540        tick.exchange_ts_ms = None;
4541        assert!(tick.exchange_latency_ms().is_none());
4542    }
4543
4544    #[test]
4545    fn test_is_notional_large_trade_true_when_above_threshold() {
4546        let mut tick = make_tick_at(1_000);
4547        tick.price = rust_decimal_macros::dec!(100);
4548        tick.quantity = rust_decimal_macros::dec!(10);
4549        // notional = 1000, threshold = 500 → true
4550        assert!(tick.is_notional_large_trade(rust_decimal_macros::dec!(500)));
4551    }
4552
4553    #[test]
4554    fn test_is_notional_large_trade_false_when_at_or_below_threshold() {
4555        let mut tick = make_tick_at(1_000);
4556        tick.price = rust_decimal_macros::dec!(100);
4557        tick.quantity = rust_decimal_macros::dec!(5);
4558        // notional = 500, threshold = 500 → false (strictly greater)
4559        assert!(!tick.is_notional_large_trade(rust_decimal_macros::dec!(500)));
4560    }
4561
4562    #[test]
4563    fn test_is_aggressive_true_when_buy() {
4564        let mut tick = make_tick_at(1_000);
4565        tick.side = Some(TradeSide::Buy);
4566        assert!(tick.is_aggressive());
4567    }
4568
4569    #[test]
4570    fn test_is_aggressive_true_when_sell() {
4571        let mut tick = make_tick_at(1_000);
4572        tick.side = Some(TradeSide::Sell);
4573        assert!(tick.is_aggressive());
4574    }
4575
4576    #[test]
4577    fn test_is_aggressive_false_when_neutral() {
4578        let tick = make_tick_at(1_000); // side = None
4579        assert!(!tick.is_aggressive());
4580    }
4581
4582    #[test]
4583    fn test_price_diff_from_positive_when_higher() {
4584        let mut t1 = make_tick_at(1_000);
4585        let mut t2 = make_tick_at(1_000);
4586        t1.price = rust_decimal_macros::dec!(105);
4587        t2.price = rust_decimal_macros::dec!(100);
4588        assert_eq!(t1.price_diff_from(&t2), rust_decimal_macros::dec!(5));
4589    }
4590
4591    #[test]
4592    fn test_price_diff_from_negative_when_lower() {
4593        let mut t1 = make_tick_at(1_000);
4594        let mut t2 = make_tick_at(1_000);
4595        t1.price = rust_decimal_macros::dec!(95);
4596        t2.price = rust_decimal_macros::dec!(100);
4597        assert_eq!(t1.price_diff_from(&t2), rust_decimal_macros::dec!(-5));
4598    }
4599
4600    #[test]
4601    fn test_is_micro_trade_true_when_below_threshold() {
4602        let mut tick = make_tick_at(1_000);
4603        tick.quantity = rust_decimal_macros::dec!(0.5);
4604        assert!(tick.is_micro_trade(rust_decimal_macros::dec!(1)));
4605    }
4606
4607    #[test]
4608    fn test_is_micro_trade_false_when_equal_threshold() {
4609        let mut tick = make_tick_at(1_000);
4610        tick.quantity = rust_decimal_macros::dec!(1);
4611        assert!(!tick.is_micro_trade(rust_decimal_macros::dec!(1)));
4612    }
4613
4614    #[test]
4615    fn test_is_micro_trade_false_when_above_threshold() {
4616        let mut tick = make_tick_at(1_000);
4617        tick.quantity = rust_decimal_macros::dec!(2);
4618        assert!(!tick.is_micro_trade(rust_decimal_macros::dec!(1)));
4619    }
4620
4621    // --- is_zero_price / is_fresh ---
4622
4623    #[test]
4624    fn test_is_zero_price_true_for_zero() {
4625        let mut tick = make_tick_at(1_000);
4626        tick.price = rust_decimal_macros::dec!(0);
4627        assert!(tick.is_zero_price());
4628    }
4629
4630    #[test]
4631    fn test_is_zero_price_false_for_nonzero() {
4632        let tick = make_tick_at(1_000); // price set by make_tick_at
4633        assert!(!tick.is_zero_price());
4634    }
4635
4636    #[test]
4637    fn test_is_fresh_true_when_within_age() {
4638        let tick = make_tick_at(1_000);
4639        // received_at = 1000, now = 2000, max_age = 1500 → 1000 <= 1500 → fresh
4640        assert!(tick.is_fresh(2_000, 1_500));
4641    }
4642
4643    #[test]
4644    fn test_is_fresh_false_when_too_old() {
4645        let tick = make_tick_at(1_000);
4646        // received_at = 1000, now = 5000, max_age = 2000 → 4000 > 2000 → not fresh
4647        assert!(!tick.is_fresh(5_000, 2_000));
4648    }
4649
4650    #[test]
4651    fn test_is_fresh_true_when_now_less_than_received() {
4652        // Clock skew: now < received_at → saturating_sub = 0 ≤ max_age
4653        let tick = make_tick_at(5_000);
4654        assert!(tick.is_fresh(3_000, 100));
4655    }
4656
4657    // --- NormalizedTick::age_ms ---
4658    #[test]
4659    fn test_age_ms_correct_elapsed() {
4660        let tick = make_tick_at(10_000);
4661        assert_eq!(tick.age_ms(10_500), 500);
4662    }
4663
4664    #[test]
4665    fn test_age_ms_zero_when_now_equals_received() {
4666        let tick = make_tick_at(10_000);
4667        assert_eq!(tick.age_ms(10_000), 0);
4668    }
4669
4670    #[test]
4671    fn test_age_ms_zero_when_now_before_received() {
4672        let tick = make_tick_at(10_000);
4673        assert_eq!(tick.age_ms(9_000), 0);
4674    }
4675
4676    // --- NormalizedTick::is_buying_pressure ---
4677    #[test]
4678    fn test_is_buying_pressure_true_above_midpoint() {
4679        use rust_decimal_macros::dec;
4680        let mut tick = make_tick_at(0);
4681        tick.price = dec!(100.50);
4682        assert!(tick.is_buying_pressure(dec!(100)));
4683    }
4684
4685    #[test]
4686    fn test_is_buying_pressure_false_below_midpoint() {
4687        use rust_decimal_macros::dec;
4688        let mut tick = make_tick_at(0);
4689        tick.price = dec!(99.50);
4690        assert!(!tick.is_buying_pressure(dec!(100)));
4691    }
4692
4693    #[test]
4694    fn test_is_buying_pressure_false_at_midpoint() {
4695        use rust_decimal_macros::dec;
4696        let mut tick = make_tick_at(0);
4697        tick.price = dec!(100);
4698        assert!(!tick.is_buying_pressure(dec!(100)));
4699    }
4700
4701    // --- NormalizedTick::rounded_price ---
4702    #[test]
4703    fn test_rounded_price_rounds_to_nearest_tick() {
4704        use rust_decimal_macros::dec;
4705        let mut tick = make_tick_at(0);
4706        tick.price = dec!(100.37);
4707        // tick_size = 0.25 → 100.25
4708        assert_eq!(tick.rounded_price(dec!(0.25)), dec!(100.25));
4709    }
4710
4711    #[test]
4712    fn test_rounded_price_unchanged_when_already_aligned() {
4713        use rust_decimal_macros::dec;
4714        let mut tick = make_tick_at(0);
4715        tick.price = dec!(100.50);
4716        assert_eq!(tick.rounded_price(dec!(0.25)), dec!(100.50));
4717    }
4718
4719    #[test]
4720    fn test_rounded_price_returns_original_for_zero_tick_size() {
4721        use rust_decimal_macros::dec;
4722        let mut tick = make_tick_at(0);
4723        tick.price = dec!(99.99);
4724        assert_eq!(tick.rounded_price(dec!(0)), dec!(99.99));
4725    }
4726
4727    // --- NormalizedTick::is_large_spread_from ---
4728    #[test]
4729    fn test_is_large_spread_from_true_when_large() {
4730        use rust_decimal_macros::dec;
4731        let mut t1 = make_tick_at(0);
4732        let mut t2 = make_tick_at(0);
4733        t1.price = dec!(100);
4734        t2.price = dec!(110);
4735        assert!(t1.is_large_spread_from(&t2, dec!(5)));
4736    }
4737
4738    #[test]
4739    fn test_is_large_spread_from_false_when_small() {
4740        use rust_decimal_macros::dec;
4741        let mut t1 = make_tick_at(0);
4742        let mut t2 = make_tick_at(0);
4743        t1.price = dec!(100);
4744        t2.price = dec!(101);
4745        assert!(!t1.is_large_spread_from(&t2, dec!(5)));
4746    }
4747
4748    // ── NormalizedTick::age_secs ──────────────────────────────────────────────
4749
4750    #[test]
4751    fn test_age_secs_correct() {
4752        let tick = make_tick_at(1_000);
4753        assert!((tick.age_secs(3_000) - 2.0).abs() < 1e-9);
4754    }
4755
4756    #[test]
4757    fn test_age_secs_zero_when_now_equals_received() {
4758        let tick = make_tick_at(5_000);
4759        assert_eq!(tick.age_secs(5_000), 0.0);
4760    }
4761
4762    #[test]
4763    fn test_age_secs_zero_when_now_before_received() {
4764        let tick = make_tick_at(5_000);
4765        assert_eq!(tick.age_secs(1_000), 0.0);
4766    }
4767
4768    // ── NormalizedTick::is_same_exchange_as ───────────────────────────────────
4769
4770    #[test]
4771    fn test_is_same_exchange_as_true_when_matching() {
4772        let t1 = make_tick_at(1_000); // Binance
4773        let t2 = make_tick_at(2_000); // Binance
4774        assert!(t1.is_same_exchange_as(&t2));
4775    }
4776
4777    #[test]
4778    fn test_is_same_exchange_as_false_when_different() {
4779        let t1 = make_tick_at(1_000); // Binance
4780        let mut t2 = make_tick_at(2_000);
4781        t2.exchange = Exchange::Coinbase;
4782        assert!(!t1.is_same_exchange_as(&t2));
4783    }
4784
4785    // ── NormalizedTick::quote_age_ms / notional_value / is_high_value_tick ──
4786
4787    #[test]
4788    fn test_quote_age_ms_correct() {
4789        let tick = make_tick_at(1_000);
4790        assert_eq!(tick.quote_age_ms(3_000), 2_000);
4791    }
4792
4793    #[test]
4794    fn test_quote_age_ms_zero_when_now_before_received() {
4795        let tick = make_tick_at(5_000);
4796        assert_eq!(tick.quote_age_ms(1_000), 0);
4797    }
4798
4799    #[test]
4800    fn test_notional_value_correct() {
4801        use rust_decimal_macros::dec;
4802        let mut tick = make_tick_at(0);
4803        tick.price = dec!(100);
4804        tick.quantity = dec!(5);
4805        assert_eq!(tick.notional_value(), dec!(500));
4806    }
4807
4808    #[test]
4809    fn test_is_high_value_tick_true_when_above_threshold() {
4810        use rust_decimal_macros::dec;
4811        let mut tick = make_tick_at(0);
4812        tick.price = dec!(100);
4813        tick.quantity = dec!(10);
4814        // notional = 1000 > 500
4815        assert!(tick.is_high_value_tick(dec!(500)));
4816    }
4817
4818    #[test]
4819    fn test_is_high_value_tick_false_when_below_threshold() {
4820        use rust_decimal_macros::dec;
4821        let mut tick = make_tick_at(0);
4822        tick.price = dec!(10);
4823        tick.quantity = dec!(2);
4824        // notional = 20 < 100
4825        assert!(!tick.is_high_value_tick(dec!(100)));
4826    }
4827
4828    // ── NormalizedTick::is_buy_side / is_sell_side / price_in_range ─────────
4829
4830    #[test]
4831    fn test_is_buy_side_true_when_buy() {
4832        let mut tick = make_tick_at(0);
4833        tick.side = Some(TradeSide::Buy);
4834        assert!(tick.is_buy_side());
4835    }
4836
4837    #[test]
4838    fn test_is_buy_side_false_when_sell() {
4839        let mut tick = make_tick_at(0);
4840        tick.side = Some(TradeSide::Sell);
4841        assert!(!tick.is_buy_side());
4842    }
4843
4844    #[test]
4845    fn test_is_buy_side_false_when_none() {
4846        let mut tick = make_tick_at(0);
4847        tick.side = None;
4848        assert!(!tick.is_buy_side());
4849    }
4850
4851    #[test]
4852    fn test_is_sell_side_true_when_sell() {
4853        let mut tick = make_tick_at(0);
4854        tick.side = Some(TradeSide::Sell);
4855        assert!(tick.is_sell_side());
4856    }
4857
4858    #[test]
4859    fn test_price_in_range_true_when_within() {
4860        use rust_decimal_macros::dec;
4861        let mut tick = make_tick_at(0);
4862        tick.price = dec!(100);
4863        assert!(tick.price_in_range(dec!(90), dec!(110)));
4864    }
4865
4866    #[test]
4867    fn test_price_in_range_false_when_below() {
4868        use rust_decimal_macros::dec;
4869        let mut tick = make_tick_at(0);
4870        tick.price = dec!(80);
4871        assert!(!tick.price_in_range(dec!(90), dec!(110)));
4872    }
4873
4874    #[test]
4875    fn test_price_in_range_true_at_boundary() {
4876        use rust_decimal_macros::dec;
4877        let mut tick = make_tick_at(0);
4878        tick.price = dec!(90);
4879        assert!(tick.price_in_range(dec!(90), dec!(110)));
4880    }
4881
4882    // ── NormalizedTick::is_zero_quantity ──────────────────────────────────────
4883
4884    #[test]
4885    fn test_is_zero_quantity_true_when_zero() {
4886        let mut tick = make_tick_at(0);
4887        tick.quantity = Decimal::ZERO;
4888        assert!(tick.is_zero_quantity());
4889    }
4890
4891    #[test]
4892    fn test_is_zero_quantity_false_when_nonzero() {
4893        let mut tick = make_tick_at(0);
4894        tick.quantity = Decimal::ONE;
4895        assert!(!tick.is_zero_quantity());
4896    }
4897
4898    // ── NormalizedTick::is_large_tick ─────────────────────────────────────────
4899
4900    #[test]
4901    fn test_is_large_tick_true_when_above_threshold() {
4902        let mut tick = make_tick_at(0);
4903        tick.quantity = Decimal::from(10u32);
4904        assert!(tick.is_large_tick(Decimal::from(5u32)));
4905    }
4906
4907    #[test]
4908    fn test_is_large_tick_false_when_at_threshold() {
4909        let mut tick = make_tick_at(0);
4910        tick.quantity = Decimal::from(5u32);
4911        assert!(!tick.is_large_tick(Decimal::from(5u32)));
4912    }
4913
4914    #[test]
4915    fn test_is_large_tick_false_when_below_threshold() {
4916        let mut tick = make_tick_at(0);
4917        tick.quantity = Decimal::from(1u32);
4918        assert!(!tick.is_large_tick(Decimal::from(5u32)));
4919    }
4920
4921    // ── NormalizedTick::is_away_from_price ───────────────────────────────────
4922
4923    #[test]
4924    fn test_is_away_from_price_true_when_beyond_threshold() {
4925        let mut tick = make_tick_at(0);
4926        tick.price = Decimal::from(110u32);
4927        // |110 - 100| = 10 > 5
4928        assert!(tick.is_away_from_price(Decimal::from(100u32), Decimal::from(5u32)));
4929    }
4930
4931    #[test]
4932    fn test_is_away_from_price_false_when_at_threshold() {
4933        let mut tick = make_tick_at(0);
4934        tick.price = Decimal::from(105u32);
4935        // |105 - 100| = 5, not > 5
4936        assert!(!tick.is_away_from_price(Decimal::from(100u32), Decimal::from(5u32)));
4937    }
4938
4939    #[test]
4940    fn test_is_away_from_price_false_when_equal() {
4941        let mut tick = make_tick_at(0);
4942        tick.price = Decimal::from(100u32);
4943        assert!(!tick.is_away_from_price(Decimal::from(100u32), Decimal::from(1u32)));
4944    }
4945
4946    // ── NormalizedTick::is_within_spread ──────────────────────────────────────
4947
4948    #[test]
4949    fn test_is_within_spread_true_when_between() {
4950        let mut tick = make_tick_at(0);
4951        tick.price = Decimal::from(100u32);
4952        assert!(tick.is_within_spread(Decimal::from(99u32), Decimal::from(101u32)));
4953    }
4954
4955    #[test]
4956    fn test_is_within_spread_false_when_at_bid() {
4957        let mut tick = make_tick_at(0);
4958        tick.price = Decimal::from(99u32);
4959        assert!(!tick.is_within_spread(Decimal::from(99u32), Decimal::from(101u32)));
4960    }
4961
4962    #[test]
4963    fn test_is_within_spread_false_when_above_ask() {
4964        let mut tick = make_tick_at(0);
4965        tick.price = Decimal::from(102u32);
4966        assert!(!tick.is_within_spread(Decimal::from(99u32), Decimal::from(101u32)));
4967    }
4968
4969    // ── NormalizedTick::is_recent ─────────────────────────────────────────────
4970
4971    #[test]
4972    fn test_is_recent_true_when_within_threshold() {
4973        let tick = make_tick_at(9_500);
4974        // now=10000, threshold=1000 → age=500ms ≤ 1000ms
4975        assert!(tick.is_recent(1_000, 10_000));
4976    }
4977
4978    #[test]
4979    fn test_is_recent_false_when_beyond_threshold() {
4980        let tick = make_tick_at(8_000);
4981        // now=10000, threshold=1000 → age=2000ms > 1000ms
4982        assert!(!tick.is_recent(1_000, 10_000));
4983    }
4984
4985    #[test]
4986    fn test_is_recent_true_at_exact_threshold() {
4987        let tick = make_tick_at(9_000);
4988        // age=1000ms, threshold=1000ms → exactly at threshold
4989        assert!(tick.is_recent(1_000, 10_000));
4990    }
4991
4992    // ── NormalizedTick::side_as_str ───────────────────────────────────────────
4993
4994    #[test]
4995    fn test_side_as_str_buy() {
4996        let mut tick = make_tick_at(0);
4997        tick.side = Some(TradeSide::Buy);
4998        assert_eq!(tick.side_as_str(), Some("buy"));
4999    }
5000
5001    #[test]
5002    fn test_side_as_str_sell() {
5003        let mut tick = make_tick_at(0);
5004        tick.side = Some(TradeSide::Sell);
5005        assert_eq!(tick.side_as_str(), Some("sell"));
5006    }
5007
5008    #[test]
5009    fn test_side_as_str_none_when_unknown() {
5010        let mut tick = make_tick_at(0);
5011        tick.side = None;
5012        assert!(tick.side_as_str().is_none());
5013    }
5014
5015    // ── is_above_price ────────────────────────────────────────────────────────
5016
5017    #[test]
5018    fn test_is_above_price_true_when_strictly_above() {
5019        let tick = make_tick_at(0); // price=100
5020        assert!(tick.is_above_price(rust_decimal_macros::dec!(99)));
5021    }
5022
5023    #[test]
5024    fn test_is_above_price_false_when_equal() {
5025        let tick = make_tick_at(0); // price=100
5026        assert!(!tick.is_above_price(rust_decimal_macros::dec!(100)));
5027    }
5028
5029    #[test]
5030    fn test_is_above_price_false_when_below() {
5031        let tick = make_tick_at(0); // price=100
5032        assert!(!tick.is_above_price(rust_decimal_macros::dec!(101)));
5033    }
5034
5035    // ── price_change_from ─────────────────────────────────────────────────────
5036
5037    #[test]
5038    fn test_price_change_from_positive_when_above_reference() {
5039        let tick = make_tick_at(0); // price=100
5040        assert_eq!(tick.price_change_from(rust_decimal_macros::dec!(90)), rust_decimal_macros::dec!(10));
5041    }
5042
5043    #[test]
5044    fn test_price_change_from_negative_when_below_reference() {
5045        let tick = make_tick_at(0); // price=100
5046        assert_eq!(tick.price_change_from(rust_decimal_macros::dec!(110)), rust_decimal_macros::dec!(-10));
5047    }
5048
5049    #[test]
5050    fn test_price_change_from_zero_when_equal() {
5051        let tick = make_tick_at(0); // price=100
5052        assert_eq!(tick.price_change_from(rust_decimal_macros::dec!(100)), rust_decimal_macros::dec!(0));
5053    }
5054
5055    // ── is_below_price ────────────────────────────────────────────────────────
5056
5057    #[test]
5058    fn test_is_below_price_true_when_strictly_below() {
5059        let tick = make_tick_at(0); // price=100
5060        assert!(tick.is_below_price(rust_decimal_macros::dec!(101)));
5061    }
5062
5063    #[test]
5064    fn test_is_below_price_false_when_equal() {
5065        let tick = make_tick_at(0); // price=100
5066        assert!(!tick.is_below_price(rust_decimal_macros::dec!(100)));
5067    }
5068
5069    // ── quantity_above ────────────────────────────────────────────────────────
5070
5071    #[test]
5072    fn test_quantity_above_true_when_quantity_exceeds_threshold() {
5073        let tick = make_tick_at(0); // quantity=1
5074        assert!(tick.quantity_above(rust_decimal_macros::dec!(0)));
5075    }
5076
5077    #[test]
5078    fn test_quantity_above_false_when_quantity_equals_threshold() {
5079        let tick = make_tick_at(0); // quantity=1
5080        assert!(!tick.quantity_above(rust_decimal_macros::dec!(1)));
5081    }
5082
5083    // ── is_at_price ───────────────────────────────────────────────────────────
5084
5085    #[test]
5086    fn test_is_at_price_true_when_equal() {
5087        let tick = make_tick_at(0); // price=100
5088        assert!(tick.is_at_price(rust_decimal_macros::dec!(100)));
5089    }
5090
5091    #[test]
5092    fn test_is_at_price_false_when_different() {
5093        let tick = make_tick_at(0); // price=100
5094        assert!(!tick.is_at_price(rust_decimal_macros::dec!(101)));
5095    }
5096
5097    // ── is_round_number ───────────────────────────────────────────────────────
5098
5099    #[test]
5100    fn test_is_round_number_true_when_divisible() {
5101        let tick = make_tick_at(0); // price=100
5102        assert!(tick.is_round_number(rust_decimal_macros::dec!(10)));
5103        assert!(tick.is_round_number(rust_decimal_macros::dec!(100)));
5104    }
5105
5106    #[test]
5107    fn test_is_round_number_false_when_not_divisible() {
5108        let tick = make_tick_at(0); // price=100
5109        assert!(!tick.is_round_number(rust_decimal_macros::dec!(3)));
5110    }
5111
5112    #[test]
5113    fn test_is_round_number_false_when_step_zero() {
5114        let tick = make_tick_at(0);
5115        assert!(!tick.is_round_number(rust_decimal_macros::dec!(0)));
5116    }
5117
5118    // ── is_market_open_tick ───────────────────────────────────────────────────
5119
5120    #[test]
5121    fn test_is_market_open_tick_true_when_within_session() {
5122        let tick = make_tick_at(500); // received at ms=500
5123        assert!(tick.is_market_open_tick(100, 1_000));
5124    }
5125
5126    #[test]
5127    fn test_is_market_open_tick_false_when_before_session() {
5128        let tick = make_tick_at(50);
5129        assert!(!tick.is_market_open_tick(100, 1_000));
5130    }
5131
5132    #[test]
5133    fn test_is_market_open_tick_false_when_at_session_end() {
5134        let tick = make_tick_at(1_000);
5135        assert!(!tick.is_market_open_tick(100, 1_000)); // exclusive end
5136    }
5137
5138    // ── signed_quantity ───────────────────────────────────────────────────────
5139
5140    #[test]
5141    fn test_signed_quantity_positive_for_buy() {
5142        let mut tick = make_tick_at(0);
5143        tick.side = Some(TradeSide::Buy);
5144        assert!(tick.signed_quantity() > rust_decimal::Decimal::ZERO);
5145    }
5146
5147    #[test]
5148    fn test_signed_quantity_negative_for_sell() {
5149        let mut tick = make_tick_at(0);
5150        tick.side = Some(TradeSide::Sell);
5151        assert!(tick.signed_quantity() < rust_decimal::Decimal::ZERO);
5152    }
5153
5154    #[test]
5155    fn test_signed_quantity_zero_for_unknown() {
5156        let tick = make_tick_at(0); // side=None
5157        assert_eq!(tick.signed_quantity(), rust_decimal::Decimal::ZERO);
5158    }
5159
5160    // ── as_price_level ────────────────────────────────────────────────────────
5161
5162    #[test]
5163    fn test_as_price_level_returns_price_and_quantity() {
5164        let tick = make_tick_at(0); // price=100, qty=1
5165        let (p, q) = tick.as_price_level();
5166        assert_eq!(p, rust_decimal_macros::dec!(100));
5167        assert_eq!(q, rust_decimal_macros::dec!(1));
5168    }
5169
5170    // ── buy_volume / sell_volume ───────────────────────────────────────────────
5171
5172    fn make_sided_tick(qty: rust_decimal::Decimal, side: Option<TradeSide>) -> NormalizedTick {
5173        NormalizedTick {
5174            exchange: Exchange::Binance,
5175            symbol: "BTCUSDT".into(),
5176            price: rust_decimal_macros::dec!(100),
5177            quantity: qty,
5178            side,
5179            trade_id: None,
5180            exchange_ts_ms: None,
5181            received_at_ms: 0,
5182        }
5183    }
5184
5185    #[test]
5186    fn test_buy_volume_zero_for_empty_slice() {
5187        assert_eq!(NormalizedTick::buy_volume(&[]), rust_decimal::Decimal::ZERO);
5188    }
5189
5190    #[test]
5191    fn test_buy_volume_sums_only_buy_ticks() {
5192        let buy1 = make_sided_tick(rust_decimal_macros::dec!(2), Some(TradeSide::Buy));
5193        let sell = make_sided_tick(rust_decimal_macros::dec!(3), Some(TradeSide::Sell));
5194        let buy2 = make_sided_tick(rust_decimal_macros::dec!(5), Some(TradeSide::Buy));
5195        let unknown = make_sided_tick(rust_decimal_macros::dec!(10), None);
5196        assert_eq!(
5197            NormalizedTick::buy_volume(&[buy1, sell, buy2, unknown]),
5198            rust_decimal_macros::dec!(7)
5199        );
5200    }
5201
5202    #[test]
5203    fn test_sell_volume_zero_for_empty_slice() {
5204        assert_eq!(NormalizedTick::sell_volume(&[]), rust_decimal::Decimal::ZERO);
5205    }
5206
5207    #[test]
5208    fn test_sell_volume_sums_only_sell_ticks() {
5209        let buy = make_sided_tick(rust_decimal_macros::dec!(2), Some(TradeSide::Buy));
5210        let sell1 = make_sided_tick(rust_decimal_macros::dec!(3), Some(TradeSide::Sell));
5211        let sell2 = make_sided_tick(rust_decimal_macros::dec!(4), Some(TradeSide::Sell));
5212        assert_eq!(
5213            NormalizedTick::sell_volume(&[buy, sell1, sell2]),
5214            rust_decimal_macros::dec!(7)
5215        );
5216    }
5217
5218    #[test]
5219    fn test_buy_sell_volumes_dont_include_unknown_side() {
5220        let buy = make_sided_tick(rust_decimal_macros::dec!(5), Some(TradeSide::Buy));
5221        let sell = make_sided_tick(rust_decimal_macros::dec!(3), Some(TradeSide::Sell));
5222        let unknown = make_sided_tick(rust_decimal_macros::dec!(2), None);
5223        let ticks = [buy, sell, unknown];
5224        let total: rust_decimal::Decimal = ticks.iter().map(|t| t.quantity).sum();
5225        let accounted = NormalizedTick::buy_volume(&ticks) + NormalizedTick::sell_volume(&ticks);
5226        // 5 + 3 = 8, total = 10 (unknown 2 not counted)
5227        assert_eq!(accounted, rust_decimal_macros::dec!(8));
5228        assert!(accounted < total);
5229    }
5230
5231    // ── price_range / average_price ───────────────────────────────────────────
5232
5233    fn make_tick_with_price(price: rust_decimal::Decimal) -> NormalizedTick {
5234        NormalizedTick {
5235            exchange: Exchange::Binance,
5236            symbol: "BTCUSDT".into(),
5237            price,
5238            quantity: rust_decimal_macros::dec!(1),
5239            side: None,
5240            trade_id: None,
5241            exchange_ts_ms: None,
5242            received_at_ms: 0,
5243        }
5244    }
5245
5246    #[test]
5247    fn test_price_range_none_for_empty_slice() {
5248        assert!(NormalizedTick::price_range(&[]).is_none());
5249    }
5250
5251    #[test]
5252    fn test_price_range_zero_for_single_tick() {
5253        let tick = make_tick_with_price(rust_decimal_macros::dec!(100));
5254        assert_eq!(NormalizedTick::price_range(&[tick]), Some(rust_decimal_macros::dec!(0)));
5255    }
5256
5257    #[test]
5258    fn test_price_range_correct_for_multiple_ticks() {
5259        let t1 = make_tick_with_price(rust_decimal_macros::dec!(95));
5260        let t2 = make_tick_with_price(rust_decimal_macros::dec!(105));
5261        let t3 = make_tick_with_price(rust_decimal_macros::dec!(100));
5262        assert_eq!(NormalizedTick::price_range(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(10)));
5263    }
5264
5265    #[test]
5266    fn test_average_price_none_for_empty_slice() {
5267        assert!(NormalizedTick::average_price(&[]).is_none());
5268    }
5269
5270    #[test]
5271    fn test_average_price_equals_price_for_single_tick() {
5272        let tick = make_tick_with_price(rust_decimal_macros::dec!(200));
5273        assert_eq!(NormalizedTick::average_price(&[tick]), Some(rust_decimal_macros::dec!(200)));
5274    }
5275
5276    #[test]
5277    fn test_average_price_correct_for_multiple_ticks() {
5278        let t1 = make_tick_with_price(rust_decimal_macros::dec!(90));
5279        let t2 = make_tick_with_price(rust_decimal_macros::dec!(100));
5280        let t3 = make_tick_with_price(rust_decimal_macros::dec!(110));
5281        // (90 + 100 + 110) / 3 = 100
5282        assert_eq!(NormalizedTick::average_price(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(100)));
5283    }
5284
5285    // ── vwap ─────────────────────────────────────────────────────────────────
5286
5287    fn make_tick_pq(price: rust_decimal::Decimal, qty: rust_decimal::Decimal) -> NormalizedTick {
5288        NormalizedTick {
5289            exchange: Exchange::Binance,
5290            symbol: "BTCUSDT".into(),
5291            price,
5292            quantity: qty,
5293            side: None,
5294            trade_id: None,
5295            exchange_ts_ms: None,
5296            received_at_ms: 0,
5297        }
5298    }
5299
5300    #[test]
5301    fn test_vwap_none_for_empty_slice() {
5302        assert!(NormalizedTick::vwap(&[]).is_none());
5303    }
5304
5305    #[test]
5306    fn test_vwap_equals_price_for_single_tick() {
5307        let tick = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5));
5308        assert_eq!(NormalizedTick::vwap(&[tick]), Some(rust_decimal_macros::dec!(100)));
5309    }
5310
5311    #[test]
5312    fn test_vwap_weighted_correctly() {
5313        // 100 × 1 + 200 × 3 = 700; total qty = 4; VWAP = 175
5314        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5315        let t2 = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(3));
5316        assert_eq!(NormalizedTick::vwap(&[t1, t2]), Some(rust_decimal_macros::dec!(175)));
5317    }
5318
5319    #[test]
5320    fn test_vwap_none_for_zero_total_volume() {
5321        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(0));
5322        let t2 = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(0));
5323        assert!(NormalizedTick::vwap(&[t1, t2]).is_none());
5324    }
5325
5326    // ── count_above_price / count_below_price ─────────────────────────────────
5327
5328    #[test]
5329    fn test_count_above_price_zero_for_empty_slice() {
5330        assert_eq!(NormalizedTick::count_above_price(&[], rust_decimal_macros::dec!(100)), 0);
5331    }
5332
5333    #[test]
5334    fn test_count_above_price_correct() {
5335        let t1 = make_tick_with_price(rust_decimal_macros::dec!(90));
5336        let t2 = make_tick_with_price(rust_decimal_macros::dec!(100));
5337        let t3 = make_tick_with_price(rust_decimal_macros::dec!(110));
5338        assert_eq!(NormalizedTick::count_above_price(&[t1, t2, t3], rust_decimal_macros::dec!(100)), 1);
5339    }
5340
5341    #[test]
5342    fn test_count_below_price_correct() {
5343        let t1 = make_tick_with_price(rust_decimal_macros::dec!(90));
5344        let t2 = make_tick_with_price(rust_decimal_macros::dec!(100));
5345        let t3 = make_tick_with_price(rust_decimal_macros::dec!(110));
5346        assert_eq!(NormalizedTick::count_below_price(&[t1, t2, t3], rust_decimal_macros::dec!(100)), 1);
5347    }
5348
5349    #[test]
5350    fn test_count_above_at_threshold_excluded() {
5351        let tick = make_tick_with_price(rust_decimal_macros::dec!(100));
5352        assert_eq!(NormalizedTick::count_above_price(&[tick], rust_decimal_macros::dec!(100)), 0);
5353    }
5354
5355    #[test]
5356    fn test_count_below_at_threshold_excluded() {
5357        let tick = make_tick_with_price(rust_decimal_macros::dec!(100));
5358        assert_eq!(NormalizedTick::count_below_price(&[tick], rust_decimal_macros::dec!(100)), 0);
5359    }
5360
5361    // ── total_notional / buy_notional / sell_notional ─────────────────────────
5362
5363    #[test]
5364    fn test_total_notional_zero_for_empty_slice() {
5365        assert_eq!(NormalizedTick::total_notional(&[]), rust_decimal::Decimal::ZERO);
5366    }
5367
5368    #[test]
5369    fn test_total_notional_sums_all_ticks() {
5370        // 100 × 2 + 200 × 3 = 200 + 600 = 800
5371        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
5372        let t2 = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(3));
5373        assert_eq!(NormalizedTick::total_notional(&[t1, t2]), rust_decimal_macros::dec!(800));
5374    }
5375
5376    #[test]
5377    fn test_buy_notional_only_includes_buy_side() {
5378        let buy = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
5379        let sell = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(3));
5380        let buy_with_side = NormalizedTick { side: Some(TradeSide::Buy), ..buy };
5381        let sell_with_side = NormalizedTick { side: Some(TradeSide::Sell), ..sell };
5382        // buy notional = 100 × 2 = 200
5383        assert_eq!(NormalizedTick::buy_notional(&[buy_with_side, sell_with_side]), rust_decimal_macros::dec!(200));
5384    }
5385
5386    #[test]
5387    fn test_sell_notional_only_includes_sell_side() {
5388        let buy = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
5389        let sell = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(3));
5390        let buy_with_side = NormalizedTick { side: Some(TradeSide::Buy), ..buy };
5391        let sell_with_side = NormalizedTick { side: Some(TradeSide::Sell), ..sell };
5392        // sell notional = 200 × 3 = 600
5393        assert_eq!(NormalizedTick::sell_notional(&[buy_with_side, sell_with_side]), rust_decimal_macros::dec!(600));
5394    }
5395
5396    // ── median_price ──────────────────────────────────────────────────────────
5397
5398    #[test]
5399    fn test_median_price_none_for_empty_slice() {
5400        assert!(NormalizedTick::median_price(&[]).is_none());
5401    }
5402
5403    #[test]
5404    fn test_median_price_single_tick() {
5405        let tick = make_tick_with_price(rust_decimal_macros::dec!(150));
5406        assert_eq!(NormalizedTick::median_price(&[tick]), Some(rust_decimal_macros::dec!(150)));
5407    }
5408
5409    #[test]
5410    fn test_median_price_odd_count() {
5411        let t1 = make_tick_with_price(rust_decimal_macros::dec!(90));
5412        let t2 = make_tick_with_price(rust_decimal_macros::dec!(100));
5413        let t3 = make_tick_with_price(rust_decimal_macros::dec!(110));
5414        assert_eq!(NormalizedTick::median_price(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(100)));
5415    }
5416
5417    #[test]
5418    fn test_median_price_even_count() {
5419        let t1 = make_tick_with_price(rust_decimal_macros::dec!(90));
5420        let t2 = make_tick_with_price(rust_decimal_macros::dec!(100));
5421        // median = (90+100)/2 = 95
5422        assert_eq!(NormalizedTick::median_price(&[t1, t2]), Some(rust_decimal_macros::dec!(95)));
5423    }
5424
5425    // ── net_volume ────────────────────────────────────────────────────────────
5426
5427    #[test]
5428    fn test_net_volume_zero_for_empty_slice() {
5429        assert_eq!(NormalizedTick::net_volume(&[]), rust_decimal::Decimal::ZERO);
5430    }
5431
5432    #[test]
5433    fn test_net_volume_positive_when_more_buys() {
5434        let buy = NormalizedTick {
5435            side: Some(TradeSide::Buy),
5436            quantity: rust_decimal_macros::dec!(5),
5437            ..make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5))
5438        };
5439        let sell = NormalizedTick {
5440            side: Some(TradeSide::Sell),
5441            quantity: rust_decimal_macros::dec!(3),
5442            ..make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(3))
5443        };
5444        assert_eq!(NormalizedTick::net_volume(&[buy, sell]), rust_decimal_macros::dec!(2));
5445    }
5446
5447    #[test]
5448    fn test_net_volume_negative_when_more_sells() {
5449        let buy = NormalizedTick {
5450            side: Some(TradeSide::Buy),
5451            quantity: rust_decimal_macros::dec!(2),
5452            ..make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2))
5453        };
5454        let sell = NormalizedTick {
5455            side: Some(TradeSide::Sell),
5456            quantity: rust_decimal_macros::dec!(7),
5457            ..make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(7))
5458        };
5459        assert_eq!(NormalizedTick::net_volume(&[buy, sell]), rust_decimal_macros::dec!(-5));
5460    }
5461
5462    // ── average_quantity / max_quantity ───────────────────────────────────────
5463
5464    #[test]
5465    fn test_average_quantity_none_for_empty_slice() {
5466        assert!(NormalizedTick::average_quantity(&[]).is_none());
5467    }
5468
5469    #[test]
5470    fn test_average_quantity_single_tick() {
5471        let tick = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5));
5472        assert_eq!(NormalizedTick::average_quantity(&[tick]), Some(rust_decimal_macros::dec!(5)));
5473    }
5474
5475    #[test]
5476    fn test_average_quantity_multiple_ticks() {
5477        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
5478        let t2 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(4));
5479        // (2 + 4) / 2 = 3
5480        assert_eq!(NormalizedTick::average_quantity(&[t1, t2]), Some(rust_decimal_macros::dec!(3)));
5481    }
5482
5483    #[test]
5484    fn test_max_quantity_none_for_empty_slice() {
5485        assert!(NormalizedTick::max_quantity(&[]).is_none());
5486    }
5487
5488    #[test]
5489    fn test_max_quantity_returns_largest() {
5490        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
5491        let t2 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(10));
5492        let t3 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5));
5493        assert_eq!(NormalizedTick::max_quantity(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(10)));
5494    }
5495
5496    #[test]
5497    fn test_min_quantity_none_for_empty_slice() {
5498        assert!(NormalizedTick::min_quantity(&[]).is_none());
5499    }
5500
5501    #[test]
5502    fn test_min_quantity_returns_smallest() {
5503        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5));
5504        let t2 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5505        let t3 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(3));
5506        assert_eq!(NormalizedTick::min_quantity(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(1)));
5507    }
5508
5509    #[test]
5510    fn test_buy_count_zero_for_empty_slice() {
5511        assert_eq!(NormalizedTick::buy_count(&[]), 0);
5512    }
5513
5514    #[test]
5515    fn test_buy_count_counts_only_buys() {
5516        use rust_decimal_macros::dec;
5517        let mut buy = make_tick_pq(dec!(100), dec!(1));
5518        buy.side = Some(TradeSide::Buy);
5519        let mut sell = make_tick_pq(dec!(100), dec!(1));
5520        sell.side = Some(TradeSide::Sell);
5521        let neutral = make_tick_pq(dec!(100), dec!(1));
5522        assert_eq!(NormalizedTick::buy_count(&[buy, sell, neutral]), 1);
5523    }
5524
5525    #[test]
5526    fn test_sell_count_zero_for_empty_slice() {
5527        assert_eq!(NormalizedTick::sell_count(&[]), 0);
5528    }
5529
5530    #[test]
5531    fn test_sell_count_counts_only_sells() {
5532        use rust_decimal_macros::dec;
5533        let mut buy = make_tick_pq(dec!(100), dec!(1));
5534        buy.side = Some(TradeSide::Buy);
5535        let mut sell1 = make_tick_pq(dec!(100), dec!(1));
5536        sell1.side = Some(TradeSide::Sell);
5537        let mut sell2 = make_tick_pq(dec!(100), dec!(1));
5538        sell2.side = Some(TradeSide::Sell);
5539        assert_eq!(NormalizedTick::sell_count(&[buy, sell1, sell2]), 2);
5540    }
5541
5542    #[test]
5543    fn test_price_momentum_none_for_empty_slice() {
5544        assert!(NormalizedTick::price_momentum(&[]).is_none());
5545    }
5546
5547    #[test]
5548    fn test_price_momentum_none_for_single_tick() {
5549        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5550        assert!(NormalizedTick::price_momentum(&[t]).is_none());
5551    }
5552
5553    #[test]
5554    fn test_price_momentum_positive_when_price_rises() {
5555        use rust_decimal_macros::dec;
5556        let t1 = make_tick_pq(dec!(100), dec!(1));
5557        let t2 = make_tick_pq(dec!(110), dec!(1));
5558        let mom = NormalizedTick::price_momentum(&[t1, t2]).unwrap();
5559        assert!((mom - 0.1).abs() < 1e-9);
5560    }
5561
5562    #[test]
5563    fn test_price_momentum_negative_when_price_falls() {
5564        use rust_decimal_macros::dec;
5565        let t1 = make_tick_pq(dec!(100), dec!(1));
5566        let t2 = make_tick_pq(dec!(90), dec!(1));
5567        let mom = NormalizedTick::price_momentum(&[t1, t2]).unwrap();
5568        assert!(mom < 0.0);
5569    }
5570
5571    #[test]
5572    fn test_min_price_none_for_empty_slice() {
5573        assert!(NormalizedTick::min_price(&[]).is_none());
5574    }
5575
5576    #[test]
5577    fn test_min_price_returns_lowest() {
5578        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5579        let t2 = make_tick_pq(rust_decimal_macros::dec!(90), rust_decimal_macros::dec!(1));
5580        let t3 = make_tick_pq(rust_decimal_macros::dec!(110), rust_decimal_macros::dec!(1));
5581        assert_eq!(NormalizedTick::min_price(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(90)));
5582    }
5583
5584    #[test]
5585    fn test_max_price_none_for_empty_slice() {
5586        assert!(NormalizedTick::max_price(&[]).is_none());
5587    }
5588
5589    #[test]
5590    fn test_max_price_returns_highest() {
5591        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5592        let t2 = make_tick_pq(rust_decimal_macros::dec!(90), rust_decimal_macros::dec!(1));
5593        let t3 = make_tick_pq(rust_decimal_macros::dec!(110), rust_decimal_macros::dec!(1));
5594        assert_eq!(NormalizedTick::max_price(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(110)));
5595    }
5596
5597    #[test]
5598    fn test_price_std_dev_none_for_empty_slice() {
5599        assert!(NormalizedTick::price_std_dev(&[]).is_none());
5600    }
5601
5602    #[test]
5603    fn test_price_std_dev_none_for_single_tick() {
5604        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5605        assert!(NormalizedTick::price_std_dev(&[t]).is_none());
5606    }
5607
5608    #[test]
5609    fn test_price_std_dev_two_equal_prices_is_zero() {
5610        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5611        let t2 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5612        assert_eq!(NormalizedTick::price_std_dev(&[t1, t2]), Some(0.0));
5613    }
5614
5615    #[test]
5616    fn test_price_std_dev_positive_for_varying_prices() {
5617        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5618        let t2 = make_tick_pq(rust_decimal_macros::dec!(110), rust_decimal_macros::dec!(1));
5619        let t3 = make_tick_pq(rust_decimal_macros::dec!(90), rust_decimal_macros::dec!(1));
5620        let std = NormalizedTick::price_std_dev(&[t1, t2, t3]).unwrap();
5621        assert!(std > 0.0);
5622    }
5623
5624    #[test]
5625    fn test_buy_sell_ratio_none_for_empty_slice() {
5626        assert!(NormalizedTick::buy_sell_ratio(&[]).is_none());
5627    }
5628
5629    #[test]
5630    fn test_buy_sell_ratio_none_when_no_sells() {
5631        let mut t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5632        t.side = Some(TradeSide::Buy);
5633        assert!(NormalizedTick::buy_sell_ratio(&[t]).is_none());
5634    }
5635
5636    #[test]
5637    fn test_buy_sell_ratio_two_to_one() {
5638        use rust_decimal_macros::dec;
5639        let mut buy1 = make_tick_pq(dec!(100), dec!(2));
5640        buy1.side = Some(TradeSide::Buy);
5641        let mut buy2 = make_tick_pq(dec!(100), dec!(2));
5642        buy2.side = Some(TradeSide::Buy);
5643        let mut sell = make_tick_pq(dec!(100), dec!(2));
5644        sell.side = Some(TradeSide::Sell);
5645        let ratio = NormalizedTick::buy_sell_ratio(&[buy1, buy2, sell]).unwrap();
5646        assert!((ratio - 2.0).abs() < 1e-9);
5647    }
5648
5649    #[test]
5650    fn test_largest_trade_none_for_empty_slice() {
5651        assert!(NormalizedTick::largest_trade(&[]).is_none());
5652    }
5653
5654    #[test]
5655    fn test_largest_trade_returns_max_quantity_tick() {
5656        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
5657        let t2 = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(10));
5658        let t3 = make_tick_pq(rust_decimal_macros::dec!(150), rust_decimal_macros::dec!(5));
5659        let ticks = [t1, t2, t3];
5660        let largest = NormalizedTick::largest_trade(&ticks).unwrap();
5661        assert_eq!(largest.quantity, rust_decimal_macros::dec!(10));
5662    }
5663
5664    #[test]
5665    fn test_large_trade_count_zero_for_empty_slice() {
5666        assert_eq!(NormalizedTick::large_trade_count(&[], rust_decimal_macros::dec!(1)), 0);
5667    }
5668
5669    #[test]
5670    fn test_large_trade_count_counts_trades_above_threshold() {
5671        use rust_decimal_macros::dec;
5672        let t1 = make_tick_pq(dec!(100), dec!(0.5));
5673        let t2 = make_tick_pq(dec!(100), dec!(5));
5674        let t3 = make_tick_pq(dec!(100), dec!(10));
5675        assert_eq!(NormalizedTick::large_trade_count(&[t1, t2, t3], dec!(1)), 2);
5676    }
5677
5678    #[test]
5679    fn test_large_trade_count_strict_greater_than() {
5680        use rust_decimal_macros::dec;
5681        let t = make_tick_pq(dec!(100), dec!(1));
5682        // quantity == threshold → not counted (strict >)
5683        assert_eq!(NormalizedTick::large_trade_count(&[t], dec!(1)), 0);
5684    }
5685
5686    #[test]
5687    fn test_price_iqr_none_for_small_slice() {
5688        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5689        assert!(NormalizedTick::price_iqr(&[t.clone(), t.clone(), t]).is_none());
5690    }
5691
5692    #[test]
5693    fn test_price_iqr_positive_for_varied_prices() {
5694        use rust_decimal_macros::dec;
5695        let ticks: Vec<_> = [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50), dec!(60), dec!(70), dec!(80)]
5696            .iter()
5697            .map(|&p| make_tick_pq(p, dec!(1)))
5698            .collect();
5699        let iqr = NormalizedTick::price_iqr(&ticks).unwrap();
5700        assert!(iqr > dec!(0));
5701    }
5702
5703    #[test]
5704    fn test_fraction_buy_none_for_empty_slice() {
5705        assert!(NormalizedTick::fraction_buy(&[]).is_none());
5706    }
5707
5708    #[test]
5709    fn test_fraction_buy_zero_when_no_buys() {
5710        use rust_decimal_macros::dec;
5711        let mut t = make_tick_pq(dec!(100), dec!(1));
5712        t.side = Some(TradeSide::Sell);
5713        assert_eq!(NormalizedTick::fraction_buy(&[t]), Some(0.0));
5714    }
5715
5716    #[test]
5717    fn test_fraction_buy_one_when_all_buys() {
5718        use rust_decimal_macros::dec;
5719        let mut t = make_tick_pq(dec!(100), dec!(1));
5720        t.side = Some(TradeSide::Buy);
5721        assert_eq!(NormalizedTick::fraction_buy(&[t]), Some(1.0));
5722    }
5723
5724    #[test]
5725    fn test_fraction_buy_half_for_equal_mix() {
5726        use rust_decimal_macros::dec;
5727        let mut buy = make_tick_pq(dec!(100), dec!(1));
5728        buy.side = Some(TradeSide::Buy);
5729        let mut sell = make_tick_pq(dec!(100), dec!(1));
5730        sell.side = Some(TradeSide::Sell);
5731        let frac = NormalizedTick::fraction_buy(&[buy, sell]).unwrap();
5732        assert!((frac - 0.5).abs() < 1e-9);
5733    }
5734
5735    #[test]
5736    fn test_std_quantity_none_for_empty_slice() {
5737        assert!(NormalizedTick::std_quantity(&[]).is_none());
5738    }
5739
5740    #[test]
5741    fn test_std_quantity_none_for_single_tick() {
5742        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5));
5743        assert!(NormalizedTick::std_quantity(&[t]).is_none());
5744    }
5745
5746    #[test]
5747    fn test_std_quantity_zero_for_identical_quantities() {
5748        use rust_decimal_macros::dec;
5749        let t1 = make_tick_pq(dec!(100), dec!(5));
5750        let t2 = make_tick_pq(dec!(100), dec!(5));
5751        assert_eq!(NormalizedTick::std_quantity(&[t1, t2]), Some(0.0));
5752    }
5753
5754    #[test]
5755    fn test_std_quantity_positive_for_varied_quantities() {
5756        use rust_decimal_macros::dec;
5757        let t1 = make_tick_pq(dec!(100), dec!(1));
5758        let t2 = make_tick_pq(dec!(100), dec!(10));
5759        let std = NormalizedTick::std_quantity(&[t1, t2]).unwrap();
5760        assert!(std > 0.0);
5761    }
5762
5763    #[test]
5764    fn test_buy_pressure_none_for_empty_slice() {
5765        assert!(NormalizedTick::buy_pressure(&[]).is_none());
5766    }
5767
5768    #[test]
5769    fn test_buy_pressure_none_for_unsided_ticks() {
5770        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5771        assert!(NormalizedTick::buy_pressure(&[t]).is_none());
5772    }
5773
5774    #[test]
5775    fn test_buy_pressure_one_for_all_buys() {
5776        use rust_decimal_macros::dec;
5777        let mut t = make_tick_pq(dec!(100), dec!(1));
5778        t.side = Some(TradeSide::Buy);
5779        let bp = NormalizedTick::buy_pressure(&[t]).unwrap();
5780        assert!((bp - 1.0).abs() < 1e-9);
5781    }
5782
5783    #[test]
5784    fn test_buy_pressure_half_for_equal_volume() {
5785        use rust_decimal_macros::dec;
5786        let mut buy = make_tick_pq(dec!(100), dec!(5));
5787        buy.side = Some(TradeSide::Buy);
5788        let mut sell = make_tick_pq(dec!(100), dec!(5));
5789        sell.side = Some(TradeSide::Sell);
5790        let bp = NormalizedTick::buy_pressure(&[buy, sell]).unwrap();
5791        assert!((bp - 0.5).abs() < 1e-9);
5792    }
5793
5794    #[test]
5795    fn test_average_notional_none_for_empty_slice() {
5796        assert!(NormalizedTick::average_notional(&[]).is_none());
5797    }
5798
5799    #[test]
5800    fn test_average_notional_single_tick() {
5801        use rust_decimal_macros::dec;
5802        let t = make_tick_pq(dec!(100), dec!(2));
5803        assert_eq!(NormalizedTick::average_notional(&[t]), Some(dec!(200)));
5804    }
5805
5806    #[test]
5807    fn test_average_notional_multiple_ticks() {
5808        use rust_decimal_macros::dec;
5809        let t1 = make_tick_pq(dec!(100), dec!(1)); // notional = 100
5810        let t2 = make_tick_pq(dec!(200), dec!(1)); // notional = 200
5811        // avg = (100 + 200) / 2 = 150
5812        assert_eq!(NormalizedTick::average_notional(&[t1, t2]), Some(dec!(150)));
5813    }
5814
5815    #[test]
5816    fn test_count_neutral_zero_for_empty_slice() {
5817        assert_eq!(NormalizedTick::count_neutral(&[]), 0);
5818    }
5819
5820    #[test]
5821    fn test_count_neutral_counts_sideless_ticks() {
5822        use rust_decimal_macros::dec;
5823        let neutral = make_tick_pq(dec!(100), dec!(1)); // side = None
5824        let mut buy = make_tick_pq(dec!(100), dec!(1));
5825        buy.side = Some(TradeSide::Buy);
5826        assert_eq!(NormalizedTick::count_neutral(&[neutral, buy]), 1);
5827    }
5828
5829    #[test]
5830    fn test_recent_returns_all_when_n_exceeds_len() {
5831        use rust_decimal_macros::dec;
5832        let ticks = vec![
5833            make_tick_pq(dec!(100), dec!(1)),
5834            make_tick_pq(dec!(110), dec!(1)),
5835        ];
5836        assert_eq!(NormalizedTick::recent(&ticks, 10).len(), 2);
5837    }
5838
5839    #[test]
5840    fn test_recent_returns_last_n() {
5841        use rust_decimal_macros::dec;
5842        let ticks: Vec<_> = [dec!(100), dec!(110), dec!(120), dec!(130)]
5843            .iter()
5844            .map(|&p| make_tick_pq(p, dec!(1)))
5845            .collect();
5846        let recent = NormalizedTick::recent(&ticks, 2);
5847        assert_eq!(recent.len(), 2);
5848        assert_eq!(recent[0].price, dec!(120));
5849        assert_eq!(recent[1].price, dec!(130));
5850    }
5851
5852    #[test]
5853    fn test_price_linear_slope_none_for_single_tick() {
5854        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5855        assert!(NormalizedTick::price_linear_slope(&[t]).is_none());
5856    }
5857
5858    #[test]
5859    fn test_price_linear_slope_positive_for_rising_prices() {
5860        use rust_decimal_macros::dec;
5861        let ticks: Vec<_> = [dec!(100), dec!(110), dec!(120)]
5862            .iter()
5863            .map(|&p| make_tick_pq(p, dec!(1)))
5864            .collect();
5865        let slope = NormalizedTick::price_linear_slope(&ticks).unwrap();
5866        assert!(slope > 0.0);
5867    }
5868
5869    #[test]
5870    fn test_price_linear_slope_negative_for_falling_prices() {
5871        use rust_decimal_macros::dec;
5872        let ticks: Vec<_> = [dec!(120), dec!(110), dec!(100)]
5873            .iter()
5874            .map(|&p| make_tick_pq(p, dec!(1)))
5875            .collect();
5876        let slope = NormalizedTick::price_linear_slope(&ticks).unwrap();
5877        assert!(slope < 0.0);
5878    }
5879
5880    #[test]
5881    fn test_notional_std_dev_none_for_single_tick() {
5882        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5883        assert!(NormalizedTick::notional_std_dev(&[t]).is_none());
5884    }
5885
5886    #[test]
5887    fn test_notional_std_dev_zero_for_identical_notionals() {
5888        use rust_decimal_macros::dec;
5889        let t1 = make_tick_pq(dec!(100), dec!(1)); // notional=100
5890        let t2 = make_tick_pq(dec!(100), dec!(1)); // notional=100
5891        assert_eq!(NormalizedTick::notional_std_dev(&[t1, t2]), Some(0.0));
5892    }
5893
5894    #[test]
5895    fn test_notional_std_dev_positive_for_varied_notionals() {
5896        use rust_decimal_macros::dec;
5897        let t1 = make_tick_pq(dec!(100), dec!(1)); // notional=100
5898        let t2 = make_tick_pq(dec!(200), dec!(2)); // notional=400
5899        let std = NormalizedTick::notional_std_dev(&[t1, t2]).unwrap();
5900        assert!(std > 0.0);
5901    }
5902
5903    #[test]
5904    fn test_monotone_up_true_for_empty_slice() {
5905        assert!(NormalizedTick::monotone_up(&[]));
5906    }
5907
5908    #[test]
5909    fn test_monotone_up_true_for_non_decreasing_prices() {
5910        use rust_decimal_macros::dec;
5911        let ticks: Vec<_> = [dec!(100), dec!(100), dec!(110), dec!(120)]
5912            .iter().map(|&p| make_tick_pq(p, dec!(1))).collect();
5913        assert!(NormalizedTick::monotone_up(&ticks));
5914    }
5915
5916    #[test]
5917    fn test_monotone_up_false_for_any_decrease() {
5918        use rust_decimal_macros::dec;
5919        let ticks: Vec<_> = [dec!(100), dec!(110), dec!(105)]
5920            .iter().map(|&p| make_tick_pq(p, dec!(1))).collect();
5921        assert!(!NormalizedTick::monotone_up(&ticks));
5922    }
5923
5924    #[test]
5925    fn test_monotone_down_true_for_non_increasing_prices() {
5926        use rust_decimal_macros::dec;
5927        let ticks: Vec<_> = [dec!(120), dec!(110), dec!(110), dec!(100)]
5928            .iter().map(|&p| make_tick_pq(p, dec!(1))).collect();
5929        assert!(NormalizedTick::monotone_down(&ticks));
5930    }
5931
5932    #[test]
5933    fn test_monotone_down_false_for_any_increase() {
5934        use rust_decimal_macros::dec;
5935        let ticks: Vec<_> = [dec!(100), dec!(90), dec!(95)]
5936            .iter().map(|&p| make_tick_pq(p, dec!(1))).collect();
5937        assert!(!NormalizedTick::monotone_down(&ticks));
5938    }
5939
5940    #[test]
5941    fn test_volume_at_price_zero_for_empty_slice() {
5942        assert_eq!(NormalizedTick::volume_at_price(&[], rust_decimal_macros::dec!(100)), rust_decimal_macros::dec!(0));
5943    }
5944
5945    #[test]
5946    fn test_volume_at_price_sums_matching_ticks() {
5947        use rust_decimal_macros::dec;
5948        let t1 = make_tick_pq(dec!(100), dec!(2));
5949        let t2 = make_tick_pq(dec!(100), dec!(3));
5950        let t3 = make_tick_pq(dec!(110), dec!(5));
5951        assert_eq!(NormalizedTick::volume_at_price(&[t1, t2, t3], dec!(100)), dec!(5));
5952    }
5953
5954    #[test]
5955    fn test_last_price_none_for_empty_slice() {
5956        assert!(NormalizedTick::last_price(&[]).is_none());
5957    }
5958
5959    #[test]
5960    fn test_last_price_returns_last_tick_price() {
5961        use rust_decimal_macros::dec;
5962        let t1 = make_tick_pq(dec!(100), dec!(1));
5963        let t2 = make_tick_pq(dec!(110), dec!(1));
5964        assert_eq!(NormalizedTick::last_price(&[t1, t2]), Some(dec!(110)));
5965    }
5966
5967    #[test]
5968    fn test_longest_buy_streak_zero_for_empty() {
5969        assert_eq!(NormalizedTick::longest_buy_streak(&[]), 0);
5970    }
5971
5972    #[test]
5973    fn test_longest_buy_streak_counts_consecutive_buys() {
5974        use rust_decimal_macros::dec;
5975        let mut b1 = make_tick_pq(dec!(100), dec!(1)); b1.side = Some(TradeSide::Buy);
5976        let mut b2 = make_tick_pq(dec!(100), dec!(1)); b2.side = Some(TradeSide::Buy);
5977        let mut s  = make_tick_pq(dec!(100), dec!(1)); s.side = Some(TradeSide::Sell);
5978        let mut b3 = make_tick_pq(dec!(100), dec!(1)); b3.side = Some(TradeSide::Buy);
5979        // streaks: [2, 1] → max = 2
5980        assert_eq!(NormalizedTick::longest_buy_streak(&[b1, b2, s, b3]), 2);
5981    }
5982
5983    #[test]
5984    fn test_longest_sell_streak_zero_for_no_sells() {
5985        use rust_decimal_macros::dec;
5986        let mut b = make_tick_pq(dec!(100), dec!(1)); b.side = Some(TradeSide::Buy);
5987        assert_eq!(NormalizedTick::longest_sell_streak(&[b]), 0);
5988    }
5989
5990    #[test]
5991    fn test_longest_sell_streak_correct() {
5992        use rust_decimal_macros::dec;
5993        let mut b  = make_tick_pq(dec!(100), dec!(1)); b.side = Some(TradeSide::Buy);
5994        let mut s1 = make_tick_pq(dec!(100), dec!(1)); s1.side = Some(TradeSide::Sell);
5995        let mut s2 = make_tick_pq(dec!(100), dec!(1)); s2.side = Some(TradeSide::Sell);
5996        let mut s3 = make_tick_pq(dec!(100), dec!(1)); s3.side = Some(TradeSide::Sell);
5997        assert_eq!(NormalizedTick::longest_sell_streak(&[b, s1, s2, s3]), 3);
5998    }
5999
6000    #[test]
6001    fn test_price_at_max_volume_none_for_empty() {
6002        assert!(NormalizedTick::price_at_max_volume(&[]).is_none());
6003    }
6004
6005    #[test]
6006    fn test_price_at_max_volume_returns_dominant_price() {
6007        use rust_decimal_macros::dec;
6008        let t1 = make_tick_pq(dec!(100), dec!(1));
6009        let t2 = make_tick_pq(dec!(200), dec!(5));
6010        let t3 = make_tick_pq(dec!(200), dec!(3));
6011        // price 200 has total vol 8 > price 100 vol 1
6012        assert_eq!(NormalizedTick::price_at_max_volume(&[t1, t2, t3]), Some(dec!(200)));
6013    }
6014
6015    #[test]
6016    fn test_recent_volume_zero_for_empty_slice() {
6017        assert_eq!(NormalizedTick::recent_volume(&[], 5), rust_decimal_macros::dec!(0));
6018    }
6019
6020    #[test]
6021    fn test_recent_volume_sums_last_n_ticks() {
6022        use rust_decimal_macros::dec;
6023        let ticks: Vec<_> = [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)]
6024            .iter().map(|&q| make_tick_pq(dec!(100), q)).collect();
6025        // last 3 ticks: qty 3+4+5 = 12
6026        assert_eq!(NormalizedTick::recent_volume(&ticks, 3), dec!(12));
6027    }
6028
6029    // ── NormalizedTick::first_price ───────────────────────────────────────────
6030
6031    #[test]
6032    fn test_first_price_none_for_empty_slice() {
6033        assert!(NormalizedTick::first_price(&[]).is_none());
6034    }
6035
6036    #[test]
6037    fn test_first_price_returns_first_tick_price() {
6038        use rust_decimal_macros::dec;
6039        let ticks = vec![make_tick_pq(dec!(50), dec!(1)), make_tick_pq(dec!(60), dec!(1))];
6040        assert_eq!(NormalizedTick::first_price(&ticks), Some(dec!(50)));
6041    }
6042
6043    // ── NormalizedTick::price_return_pct ─────────────────────────────────────
6044
6045    #[test]
6046    fn test_price_return_pct_none_for_single_tick() {
6047        use rust_decimal_macros::dec;
6048        assert!(NormalizedTick::price_return_pct(&[make_tick_pq(dec!(100), dec!(1))]).is_none());
6049    }
6050
6051    #[test]
6052    fn test_price_return_pct_positive_for_rising_price() {
6053        use rust_decimal_macros::dec;
6054        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(110), dec!(1))];
6055        let pct = NormalizedTick::price_return_pct(&ticks).unwrap();
6056        assert!((pct - 0.1).abs() < 1e-9);
6057    }
6058
6059    #[test]
6060    fn test_price_return_pct_negative_for_falling_price() {
6061        use rust_decimal_macros::dec;
6062        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(90), dec!(1))];
6063        let pct = NormalizedTick::price_return_pct(&ticks).unwrap();
6064        assert!((pct - (-0.1)).abs() < 1e-9);
6065    }
6066
6067    // ── NormalizedTick::volume_above_price / volume_below_price ──────────────
6068
6069    #[test]
6070    fn test_volume_above_price_zero_for_empty_slice() {
6071        use rust_decimal_macros::dec;
6072        assert_eq!(NormalizedTick::volume_above_price(&[], dec!(100)), dec!(0));
6073    }
6074
6075    #[test]
6076    fn test_volume_above_price_sums_above_threshold() {
6077        use rust_decimal_macros::dec;
6078        let ticks = vec![
6079            make_tick_pq(dec!(90), dec!(5)),
6080            make_tick_pq(dec!(100), dec!(10)),
6081            make_tick_pq(dec!(110), dec!(3)),
6082        ];
6083        // only price=110 is above 100
6084        assert_eq!(NormalizedTick::volume_above_price(&ticks, dec!(100)), dec!(3));
6085    }
6086
6087    #[test]
6088    fn test_volume_below_price_sums_below_threshold() {
6089        use rust_decimal_macros::dec;
6090        let ticks = vec![
6091            make_tick_pq(dec!(90), dec!(5)),
6092            make_tick_pq(dec!(100), dec!(10)),
6093            make_tick_pq(dec!(110), dec!(3)),
6094        ];
6095        // only price=90 is below 100
6096        assert_eq!(NormalizedTick::volume_below_price(&ticks, dec!(100)), dec!(5));
6097    }
6098
6099    // ── NormalizedTick::quantity_weighted_avg_price ───────────────────────────
6100
6101    #[test]
6102    fn test_qwap_none_for_empty_slice() {
6103        assert!(NormalizedTick::quantity_weighted_avg_price(&[]).is_none());
6104    }
6105
6106    #[test]
6107    fn test_qwap_correct_for_equal_quantities() {
6108        use rust_decimal_macros::dec;
6109        // equal qty → simple average of prices
6110        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(200), dec!(1))];
6111        assert_eq!(NormalizedTick::quantity_weighted_avg_price(&ticks), Some(dec!(150)));
6112    }
6113
6114    #[test]
6115    fn test_qwap_weighted_towards_higher_volume() {
6116        use rust_decimal_macros::dec;
6117        // price=100 qty=1, price=200 qty=3 → (100+600)/4 = 175
6118        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(200), dec!(3))];
6119        assert_eq!(NormalizedTick::quantity_weighted_avg_price(&ticks), Some(dec!(175)));
6120    }
6121
6122    // ── NormalizedTick::tick_count_above_price / tick_count_below_price ───────
6123
6124    #[test]
6125    fn test_tick_count_above_price_zero_for_empty_slice() {
6126        use rust_decimal_macros::dec;
6127        assert_eq!(NormalizedTick::tick_count_above_price(&[], dec!(100)), 0);
6128    }
6129
6130    #[test]
6131    fn test_tick_count_above_price_correct() {
6132        use rust_decimal_macros::dec;
6133        let ticks = vec![
6134            make_tick_pq(dec!(90), dec!(1)),
6135            make_tick_pq(dec!(100), dec!(1)),
6136            make_tick_pq(dec!(110), dec!(1)),
6137            make_tick_pq(dec!(120), dec!(1)),
6138        ];
6139        assert_eq!(NormalizedTick::tick_count_above_price(&ticks, dec!(100)), 2);
6140    }
6141
6142    #[test]
6143    fn test_tick_count_below_price_correct() {
6144        use rust_decimal_macros::dec;
6145        let ticks = vec![
6146            make_tick_pq(dec!(90), dec!(1)),
6147            make_tick_pq(dec!(100), dec!(1)),
6148            make_tick_pq(dec!(110), dec!(1)),
6149        ];
6150        assert_eq!(NormalizedTick::tick_count_below_price(&ticks, dec!(100)), 1);
6151    }
6152
6153    // ── NormalizedTick::price_at_percentile ──────────────────────────────────
6154
6155    #[test]
6156    fn test_price_at_percentile_none_for_empty_slice() {
6157        use rust_decimal_macros::dec;
6158        assert!(NormalizedTick::price_at_percentile(&[], 0.5).is_none());
6159    }
6160
6161    #[test]
6162    fn test_price_at_percentile_none_for_out_of_range() {
6163        use rust_decimal_macros::dec;
6164        let ticks = vec![make_tick_pq(dec!(100), dec!(1))];
6165        assert!(NormalizedTick::price_at_percentile(&ticks, 1.5).is_none());
6166    }
6167
6168    #[test]
6169    fn test_price_at_percentile_median_for_sorted_prices() {
6170        use rust_decimal_macros::dec;
6171        let ticks = vec![
6172            make_tick_pq(dec!(10), dec!(1)),
6173            make_tick_pq(dec!(20), dec!(1)),
6174            make_tick_pq(dec!(30), dec!(1)),
6175            make_tick_pq(dec!(40), dec!(1)),
6176            make_tick_pq(dec!(50), dec!(1)),
6177        ];
6178        // 50th percentile → index 2 → price=30
6179        assert_eq!(NormalizedTick::price_at_percentile(&ticks, 0.5), Some(dec!(30)));
6180    }
6181
6182    // ── NormalizedTick::unique_price_count ────────────────────────────────────
6183
6184    #[test]
6185    fn test_unique_price_count_zero_for_empty() {
6186        assert_eq!(NormalizedTick::unique_price_count(&[]), 0);
6187    }
6188
6189    #[test]
6190    fn test_unique_price_count_counts_distinct_prices() {
6191        use rust_decimal_macros::dec;
6192        let ticks = vec![
6193            make_tick_pq(dec!(100), dec!(1)),
6194            make_tick_pq(dec!(100), dec!(2)),
6195            make_tick_pq(dec!(110), dec!(1)),
6196            make_tick_pq(dec!(120), dec!(1)),
6197        ];
6198        assert_eq!(NormalizedTick::unique_price_count(&ticks), 3);
6199    }
6200
6201    // ── NormalizedTick::sell_volume / buy_volume ──────────────────────────────
6202
6203    #[test]
6204    fn test_sell_volume_zero_for_empty() {
6205        assert_eq!(NormalizedTick::sell_volume(&[]), rust_decimal_macros::dec!(0));
6206    }
6207
6208    #[test]
6209    fn test_sell_volume_sums_sell_side_only() {
6210        use rust_decimal_macros::dec;
6211        let mut buy_tick = make_tick_pq(dec!(100), dec!(5));
6212        buy_tick.side = Some(TradeSide::Buy);
6213        let mut sell_tick = make_tick_pq(dec!(100), dec!(3));
6214        sell_tick.side = Some(TradeSide::Sell);
6215        let no_side_tick = make_tick_pq(dec!(100), dec!(10));
6216        let ticks = [buy_tick, sell_tick, no_side_tick];
6217        assert_eq!(NormalizedTick::sell_volume(&ticks), dec!(3));
6218        assert_eq!(NormalizedTick::buy_volume(&ticks), dec!(5));
6219    }
6220
6221    // ── NormalizedTick::avg_inter_tick_spread ─────────────────────────────────
6222
6223    #[test]
6224    fn test_avg_inter_tick_spread_none_for_single_tick() {
6225        use rust_decimal_macros::dec;
6226        assert!(NormalizedTick::avg_inter_tick_spread(&[make_tick_pq(dec!(100), dec!(1))]).is_none());
6227    }
6228
6229    #[test]
6230    fn test_avg_inter_tick_spread_correct_for_uniform_moves() {
6231        use rust_decimal_macros::dec;
6232        // prices: 100, 102, 104 → diffs: 2, 2 → avg = 2.0
6233        let ticks = vec![
6234            make_tick_pq(dec!(100), dec!(1)),
6235            make_tick_pq(dec!(102), dec!(1)),
6236            make_tick_pq(dec!(104), dec!(1)),
6237        ];
6238        let spread = NormalizedTick::avg_inter_tick_spread(&ticks).unwrap();
6239        assert!((spread - 2.0).abs() < 1e-9);
6240    }
6241
6242    // ── NormalizedTick::price_range ───────────────────────────────────────────
6243
6244    #[test]
6245    fn test_price_range_none_for_empty() {
6246        assert!(NormalizedTick::price_range(&[]).is_none());
6247    }
6248
6249    #[test]
6250    fn test_price_range_correct() {
6251        use rust_decimal_macros::dec;
6252        let ticks = vec![
6253            make_tick_pq(dec!(90), dec!(1)),
6254            make_tick_pq(dec!(110), dec!(1)),
6255            make_tick_pq(dec!(100), dec!(1)),
6256        ];
6257        assert_eq!(NormalizedTick::price_range(&ticks), Some(dec!(20)));
6258    }
6259
6260    // ── NormalizedTick::median_price ──────────────────────────────────────────
6261
6262    #[test]
6263    fn test_median_price_none_for_empty() {
6264        assert!(NormalizedTick::median_price(&[]).is_none());
6265    }
6266
6267    #[test]
6268    fn test_median_price_returns_middle_value() {
6269        use rust_decimal_macros::dec;
6270        let ticks = vec![
6271            make_tick_pq(dec!(10), dec!(1)),
6272            make_tick_pq(dec!(30), dec!(1)),
6273            make_tick_pq(dec!(20), dec!(1)),
6274        ];
6275        // sorted: 10,20,30 → idx 1 = 20
6276        assert_eq!(NormalizedTick::median_price(&ticks), Some(dec!(20)));
6277    }
6278
6279    // ── NormalizedTick::largest_sell / largest_buy ────────────────────────────
6280
6281    #[test]
6282    fn test_largest_sell_none_for_no_sell_ticks() {
6283        use rust_decimal_macros::dec;
6284        let mut t = make_tick_pq(dec!(100), dec!(5));
6285        t.side = Some(TradeSide::Buy);
6286        assert!(NormalizedTick::largest_sell(&[t]).is_none());
6287    }
6288
6289    #[test]
6290    fn test_largest_sell_returns_max_sell_qty() {
6291        use rust_decimal_macros::dec;
6292        let mut t1 = make_tick_pq(dec!(100), dec!(3));
6293        t1.side = Some(TradeSide::Sell);
6294        let mut t2 = make_tick_pq(dec!(100), dec!(7));
6295        t2.side = Some(TradeSide::Sell);
6296        assert_eq!(NormalizedTick::largest_sell(&[t1, t2]), Some(dec!(7)));
6297    }
6298
6299    #[test]
6300    fn test_largest_buy_returns_max_buy_qty() {
6301        use rust_decimal_macros::dec;
6302        let mut t1 = make_tick_pq(dec!(100), dec!(2));
6303        t1.side = Some(TradeSide::Buy);
6304        let mut t2 = make_tick_pq(dec!(100), dec!(9));
6305        t2.side = Some(TradeSide::Buy);
6306        assert_eq!(NormalizedTick::largest_buy(&[t1, t2]), Some(dec!(9)));
6307    }
6308
6309    // ── NormalizedTick::trade_count ───────────────────────────────────────────
6310
6311    #[test]
6312    fn test_trade_count_zero_for_empty() {
6313        assert_eq!(NormalizedTick::trade_count(&[]), 0);
6314    }
6315
6316    #[test]
6317    fn test_trade_count_matches_slice_length() {
6318        use rust_decimal_macros::dec;
6319        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(2))];
6320        assert_eq!(NormalizedTick::trade_count(&ticks), 2);
6321    }
6322
6323    // ── NormalizedTick::price_acceleration ───────────────────────────────────
6324
6325    #[test]
6326    fn test_price_acceleration_none_for_fewer_than_3() {
6327        use rust_decimal_macros::dec;
6328        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(1))];
6329        assert!(NormalizedTick::price_acceleration(&ticks).is_none());
6330    }
6331
6332    #[test]
6333    fn test_price_acceleration_zero_for_constant_velocity() {
6334        use rust_decimal_macros::dec;
6335        // prices: 100, 102, 104 → v1=2, v2=2 → accel=0
6336        let ticks = vec![
6337            make_tick_pq(dec!(100), dec!(1)),
6338            make_tick_pq(dec!(102), dec!(1)),
6339            make_tick_pq(dec!(104), dec!(1)),
6340        ];
6341        let acc = NormalizedTick::price_acceleration(&ticks).unwrap();
6342        assert!((acc - 0.0).abs() < 1e-9);
6343    }
6344
6345    #[test]
6346    fn test_price_acceleration_positive_when_speeding_up() {
6347        use rust_decimal_macros::dec;
6348        // prices: 100, 101, 103 → v1=1, v2=2 → accel=1
6349        let ticks = vec![
6350            make_tick_pq(dec!(100), dec!(1)),
6351            make_tick_pq(dec!(101), dec!(1)),
6352            make_tick_pq(dec!(103), dec!(1)),
6353        ];
6354        let acc = NormalizedTick::price_acceleration(&ticks).unwrap();
6355        assert!((acc - 1.0).abs() < 1e-9);
6356    }
6357
6358    // ── NormalizedTick::buy_sell_diff ─────────────────────────────────────────
6359
6360    #[test]
6361    fn test_buy_sell_diff_zero_for_empty() {
6362        assert_eq!(NormalizedTick::buy_sell_diff(&[]), rust_decimal_macros::dec!(0));
6363    }
6364
6365    #[test]
6366    fn test_buy_sell_diff_positive_for_net_buying() {
6367        use rust_decimal_macros::dec;
6368        let mut t1 = make_tick_pq(dec!(100), dec!(10));
6369        t1.side = Some(TradeSide::Buy);
6370        let mut t2 = make_tick_pq(dec!(100), dec!(3));
6371        t2.side = Some(TradeSide::Sell);
6372        assert_eq!(NormalizedTick::buy_sell_diff(&[t1, t2]), dec!(7));
6373    }
6374
6375    // ── NormalizedTick::is_aggressive_buy / is_aggressive_sell ───────────────
6376
6377    #[test]
6378    fn test_is_aggressive_buy_true_when_exceeds_avg() {
6379        use rust_decimal_macros::dec;
6380        let mut t = make_tick_pq(dec!(100), dec!(15));
6381        t.side = Some(TradeSide::Buy);
6382        assert!(NormalizedTick::is_aggressive_buy(&t, dec!(10)));
6383    }
6384
6385    #[test]
6386    fn test_is_aggressive_buy_false_when_not_buy_side() {
6387        use rust_decimal_macros::dec;
6388        let mut t = make_tick_pq(dec!(100), dec!(15));
6389        t.side = Some(TradeSide::Sell);
6390        assert!(!NormalizedTick::is_aggressive_buy(&t, dec!(10)));
6391    }
6392
6393    #[test]
6394    fn test_is_aggressive_sell_true_when_exceeds_avg() {
6395        use rust_decimal_macros::dec;
6396        let mut t = make_tick_pq(dec!(100), dec!(20));
6397        t.side = Some(TradeSide::Sell);
6398        assert!(NormalizedTick::is_aggressive_sell(&t, dec!(10)));
6399    }
6400
6401    // ── NormalizedTick::notional_volume ───────────────────────────────────────
6402
6403    #[test]
6404    fn test_notional_volume_zero_for_empty() {
6405        assert_eq!(NormalizedTick::notional_volume(&[]), rust_decimal_macros::dec!(0));
6406    }
6407
6408    #[test]
6409    fn test_notional_volume_correct() {
6410        use rust_decimal_macros::dec;
6411        let ticks = vec![
6412            make_tick_pq(dec!(100), dec!(2)),  // 200
6413            make_tick_pq(dec!(50), dec!(4)),   // 200
6414        ];
6415        assert_eq!(NormalizedTick::notional_volume(&ticks), dec!(400));
6416    }
6417
6418    // ── NormalizedTick::weighted_side_score ───────────────────────────────────
6419
6420    #[test]
6421    fn test_weighted_side_score_none_for_empty() {
6422        assert!(NormalizedTick::weighted_side_score(&[]).is_none());
6423    }
6424
6425    #[test]
6426    fn test_weighted_side_score_correct_for_all_buys() {
6427        use rust_decimal_macros::dec;
6428        let mut t = make_tick_pq(dec!(100), dec!(10));
6429        t.side = Some(TradeSide::Buy);
6430        // buy=10, sell=0, total=10 → score=1.0
6431        let score = NormalizedTick::weighted_side_score(&[t]).unwrap();
6432        assert!((score - 1.0).abs() < 1e-9);
6433    }
6434
6435    // ── NormalizedTick::time_span_ms ──────────────────────────────────────────
6436
6437    #[test]
6438    fn test_time_span_none_for_single_tick() {
6439        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
6440        assert!(NormalizedTick::time_span_ms(&[t]).is_none());
6441    }
6442
6443    #[test]
6444    fn test_time_span_correct_for_two_ticks() {
6445        use rust_decimal_macros::dec;
6446        let mut t1 = make_tick_pq(dec!(100), dec!(1));
6447        t1.received_at_ms = 1000;
6448        let mut t2 = make_tick_pq(dec!(101), dec!(1));
6449        t2.received_at_ms = 5000;
6450        assert_eq!(NormalizedTick::time_span_ms(&[t1, t2]), Some(4000));
6451    }
6452
6453    // ── NormalizedTick::price_above_vwap_count ────────────────────────────────
6454
6455    #[test]
6456    fn test_price_above_vwap_count_none_for_empty() {
6457        assert!(NormalizedTick::price_above_vwap_count(&[]).is_none());
6458    }
6459
6460    #[test]
6461    fn test_price_above_vwap_count_correct() {
6462        use rust_decimal_macros::dec;
6463        // Equal quantities: VWAP = (90+100+110)/3 = 100; above: 110 = 1 tick
6464        let ticks = vec![
6465            make_tick_pq(dec!(90), dec!(1)),
6466            make_tick_pq(dec!(100), dec!(1)),
6467            make_tick_pq(dec!(110), dec!(1)),
6468        ];
6469        assert_eq!(NormalizedTick::price_above_vwap_count(&ticks), Some(1));
6470    }
6471
6472    // ── NormalizedTick::avg_trade_size ────────────────────────────────────────
6473
6474    #[test]
6475    fn test_avg_trade_size_none_for_empty() {
6476        assert!(NormalizedTick::avg_trade_size(&[]).is_none());
6477    }
6478
6479    #[test]
6480    fn test_avg_trade_size_correct() {
6481        use rust_decimal_macros::dec;
6482        let ticks = vec![
6483            make_tick_pq(dec!(100), dec!(2)),
6484            make_tick_pq(dec!(101), dec!(4)),
6485        ];
6486        assert_eq!(NormalizedTick::avg_trade_size(&ticks), Some(dec!(3)));
6487    }
6488
6489    // ── NormalizedTick::volume_concentration ─────────────────────────────────
6490
6491    #[test]
6492    fn test_volume_concentration_none_for_empty() {
6493        assert!(NormalizedTick::volume_concentration(&[]).is_none());
6494    }
6495
6496    #[test]
6497    fn test_volume_concentration_is_one_for_single_tick() {
6498        use rust_decimal_macros::dec;
6499        let ticks = vec![make_tick_pq(dec!(100), dec!(5))];
6500        let c = NormalizedTick::volume_concentration(&ticks).unwrap();
6501        assert!((c - 1.0).abs() < 1e-9);
6502    }
6503
6504    #[test]
6505    fn test_volume_concentration_in_range() {
6506        use rust_decimal_macros::dec;
6507        let ticks = vec![
6508            make_tick_pq(dec!(100), dec!(1)),
6509            make_tick_pq(dec!(101), dec!(1)),
6510            make_tick_pq(dec!(102), dec!(1)),
6511            make_tick_pq(dec!(103), dec!(10)),
6512        ];
6513        let c = NormalizedTick::volume_concentration(&ticks).unwrap();
6514        assert!(c > 0.0 && c <= 1.0, "expected value in (0,1], got {}", c);
6515    }
6516
6517    // ── NormalizedTick::trade_imbalance_score ─────────────────────────────────
6518
6519    #[test]
6520    fn test_trade_imbalance_score_none_for_empty() {
6521        assert!(NormalizedTick::trade_imbalance_score(&[]).is_none());
6522    }
6523
6524    #[test]
6525    fn test_trade_imbalance_score_positive_for_all_buys() {
6526        use rust_decimal_macros::dec;
6527        let mut t = make_tick_pq(dec!(100), dec!(1));
6528        t.side = Some(TradeSide::Buy);
6529        let score = NormalizedTick::trade_imbalance_score(&[t]).unwrap();
6530        assert!(score > 0.0);
6531    }
6532
6533    #[test]
6534    fn test_trade_imbalance_score_negative_for_all_sells() {
6535        use rust_decimal_macros::dec;
6536        let mut t = make_tick_pq(dec!(100), dec!(1));
6537        t.side = Some(TradeSide::Sell);
6538        let score = NormalizedTick::trade_imbalance_score(&[t]).unwrap();
6539        assert!(score < 0.0);
6540    }
6541
6542    // ── NormalizedTick::price_entropy ─────────────────────────────────────────
6543
6544    #[test]
6545    fn test_price_entropy_none_for_empty() {
6546        assert!(NormalizedTick::price_entropy(&[]).is_none());
6547    }
6548
6549    #[test]
6550    fn test_price_entropy_zero_for_single_price() {
6551        use rust_decimal_macros::dec;
6552        let ticks = vec![
6553            make_tick_pq(dec!(100), dec!(1)),
6554            make_tick_pq(dec!(100), dec!(2)),
6555        ];
6556        let e = NormalizedTick::price_entropy(&ticks).unwrap();
6557        assert!((e - 0.0).abs() < 1e-9, "identical prices should have zero entropy, got {}", e);
6558    }
6559
6560    #[test]
6561    fn test_price_entropy_positive_for_varied_prices() {
6562        use rust_decimal_macros::dec;
6563        let ticks = vec![
6564            make_tick_pq(dec!(100), dec!(1)),
6565            make_tick_pq(dec!(101), dec!(1)),
6566            make_tick_pq(dec!(102), dec!(1)),
6567        ];
6568        let e = NormalizedTick::price_entropy(&ticks).unwrap();
6569        assert!(e > 0.0, "varied prices should have positive entropy, got {}", e);
6570    }
6571
6572    // ── NormalizedTick::buy_avg_price / sell_avg_price ────────────────────────
6573
6574    #[test]
6575    fn test_buy_avg_price_none_for_no_buys() {
6576        use rust_decimal_macros::dec;
6577        let mut t = make_tick_pq(dec!(100), dec!(1));
6578        t.side = Some(TradeSide::Sell);
6579        assert!(NormalizedTick::buy_avg_price(&[t]).is_none());
6580    }
6581
6582    #[test]
6583    fn test_buy_avg_price_correct() {
6584        use rust_decimal_macros::dec;
6585        let mut t1 = make_tick_pq(dec!(100), dec!(1)); t1.side = Some(TradeSide::Buy);
6586        let mut t2 = make_tick_pq(dec!(110), dec!(1)); t2.side = Some(TradeSide::Buy);
6587        assert_eq!(NormalizedTick::buy_avg_price(&[t1, t2]), Some(dec!(105)));
6588    }
6589
6590    #[test]
6591    fn test_sell_avg_price_none_for_no_sells() {
6592        use rust_decimal_macros::dec;
6593        let mut t = make_tick_pq(dec!(100), dec!(1));
6594        t.side = Some(TradeSide::Buy);
6595        assert!(NormalizedTick::sell_avg_price(&[t]).is_none());
6596    }
6597
6598    #[test]
6599    fn test_sell_avg_price_correct() {
6600        use rust_decimal_macros::dec;
6601        let mut t1 = make_tick_pq(dec!(90), dec!(1)); t1.side = Some(TradeSide::Sell);
6602        let mut t2 = make_tick_pq(dec!(100), dec!(1)); t2.side = Some(TradeSide::Sell);
6603        assert_eq!(NormalizedTick::sell_avg_price(&[t1, t2]), Some(dec!(95)));
6604    }
6605
6606    // ── NormalizedTick::price_skewness ────────────────────────────────────────
6607
6608    #[test]
6609    fn test_price_skewness_none_for_fewer_than_3() {
6610        use rust_decimal_macros::dec;
6611        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(1))];
6612        assert!(NormalizedTick::price_skewness(&ticks).is_none());
6613    }
6614
6615    #[test]
6616    fn test_price_skewness_zero_for_symmetric() {
6617        use rust_decimal_macros::dec;
6618        // symmetric distribution: 1,2,3
6619        let ticks = vec![
6620            make_tick_pq(dec!(1), dec!(1)),
6621            make_tick_pq(dec!(2), dec!(1)),
6622            make_tick_pq(dec!(3), dec!(1)),
6623        ];
6624        let s = NormalizedTick::price_skewness(&ticks).unwrap();
6625        assert!(s.abs() < 1e-9, "symmetric should have near-zero skew, got {}", s);
6626    }
6627
6628    // ── NormalizedTick::quantity_skewness ─────────────────────────────────────
6629
6630    #[test]
6631    fn test_quantity_skewness_none_for_fewer_than_3() {
6632        use rust_decimal_macros::dec;
6633        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(2))];
6634        assert!(NormalizedTick::quantity_skewness(&ticks).is_none());
6635    }
6636
6637    #[test]
6638    fn test_quantity_skewness_positive_for_right_skewed() {
6639        use rust_decimal_macros::dec;
6640        // most quantities small, one very large: right-skewed
6641        let ticks = vec![
6642            make_tick_pq(dec!(100), dec!(1)),
6643            make_tick_pq(dec!(101), dec!(1)),
6644            make_tick_pq(dec!(102), dec!(100)),
6645        ];
6646        let s = NormalizedTick::quantity_skewness(&ticks).unwrap();
6647        assert!(s > 0.0, "right-skewed distribution should have positive skewness, got {}", s);
6648    }
6649
6650    // ── NormalizedTick::price_kurtosis ────────────────────────────────────────
6651
6652    #[test]
6653    fn test_price_kurtosis_none_for_fewer_than_4() {
6654        use rust_decimal_macros::dec;
6655        let ticks = vec![
6656            make_tick_pq(dec!(1), dec!(1)),
6657            make_tick_pq(dec!(2), dec!(1)),
6658            make_tick_pq(dec!(3), dec!(1)),
6659        ];
6660        assert!(NormalizedTick::price_kurtosis(&ticks).is_none());
6661    }
6662
6663    #[test]
6664    fn test_price_kurtosis_returns_some_for_varied_prices() {
6665        use rust_decimal_macros::dec;
6666        let ticks = vec![
6667            make_tick_pq(dec!(1), dec!(1)),
6668            make_tick_pq(dec!(2), dec!(1)),
6669            make_tick_pq(dec!(3), dec!(1)),
6670            make_tick_pq(dec!(4), dec!(1)),
6671        ];
6672        assert!(NormalizedTick::price_kurtosis(&ticks).is_some());
6673    }
6674
6675    // ── NormalizedTick::high_volume_tick_count ────────────────────────────────
6676
6677    #[test]
6678    fn test_high_volume_tick_count_zero_for_empty() {
6679        use rust_decimal_macros::dec;
6680        assert_eq!(NormalizedTick::high_volume_tick_count(&[], dec!(1)), 0);
6681    }
6682
6683    #[test]
6684    fn test_high_volume_tick_count_correct() {
6685        use rust_decimal_macros::dec;
6686        let ticks = vec![
6687            make_tick_pq(dec!(100), dec!(1)),
6688            make_tick_pq(dec!(101), dec!(5)),
6689            make_tick_pq(dec!(102), dec!(10)),
6690        ];
6691        assert_eq!(NormalizedTick::high_volume_tick_count(&ticks, dec!(4)), 2);
6692    }
6693
6694    // ── NormalizedTick::vwap_spread ───────────────────────────────────────────
6695
6696    #[test]
6697    fn test_vwap_spread_none_when_no_buys_or_sells() {
6698        use rust_decimal_macros::dec;
6699        let t = make_tick_pq(dec!(100), dec!(1));
6700        assert!(NormalizedTick::vwap_spread(&[t]).is_none());
6701    }
6702
6703    #[test]
6704    fn test_vwap_spread_positive_when_buys_priced_higher() {
6705        use rust_decimal_macros::dec;
6706        let mut buy = make_tick_pq(dec!(105), dec!(1)); buy.side = Some(TradeSide::Buy);
6707        let mut sell = make_tick_pq(dec!(100), dec!(1)); sell.side = Some(TradeSide::Sell);
6708        let spread = NormalizedTick::vwap_spread(&[buy, sell]).unwrap();
6709        assert!(spread > dec!(0), "expected positive spread, got {}", spread);
6710    }
6711
6712    // ── NormalizedTick::avg_buy_quantity / avg_sell_quantity ──────────────────
6713
6714    #[test]
6715    fn test_avg_buy_quantity_none_for_no_buys() {
6716        use rust_decimal_macros::dec;
6717        let mut t = make_tick_pq(dec!(100), dec!(2)); t.side = Some(TradeSide::Sell);
6718        assert!(NormalizedTick::avg_buy_quantity(&[t]).is_none());
6719    }
6720
6721    #[test]
6722    fn test_avg_buy_quantity_correct() {
6723        use rust_decimal_macros::dec;
6724        let mut t1 = make_tick_pq(dec!(100), dec!(2)); t1.side = Some(TradeSide::Buy);
6725        let mut t2 = make_tick_pq(dec!(101), dec!(4)); t2.side = Some(TradeSide::Buy);
6726        assert_eq!(NormalizedTick::avg_buy_quantity(&[t1, t2]), Some(dec!(3)));
6727    }
6728
6729    #[test]
6730    fn test_avg_sell_quantity_correct() {
6731        use rust_decimal_macros::dec;
6732        let mut t1 = make_tick_pq(dec!(100), dec!(6)); t1.side = Some(TradeSide::Sell);
6733        let mut t2 = make_tick_pq(dec!(101), dec!(2)); t2.side = Some(TradeSide::Sell);
6734        assert_eq!(NormalizedTick::avg_sell_quantity(&[t1, t2]), Some(dec!(4)));
6735    }
6736
6737    // ── NormalizedTick::price_mean_reversion_score ────────────────────────────
6738
6739    #[test]
6740    fn test_price_mean_reversion_score_none_for_empty() {
6741        assert!(NormalizedTick::price_mean_reversion_score(&[]).is_none());
6742    }
6743
6744    #[test]
6745    fn test_price_mean_reversion_score_in_range() {
6746        use rust_decimal_macros::dec;
6747        let ticks = vec![
6748            make_tick_pq(dec!(90), dec!(1)),
6749            make_tick_pq(dec!(100), dec!(1)),
6750            make_tick_pq(dec!(110), dec!(1)),
6751        ];
6752        let score = NormalizedTick::price_mean_reversion_score(&ticks).unwrap();
6753        assert!(score >= 0.0 && score <= 1.0, "score should be in [0, 1], got {}", score);
6754    }
6755
6756    // ── NormalizedTick::largest_price_move ────────────────────────────────────
6757
6758    #[test]
6759    fn test_largest_price_move_none_for_single_tick() {
6760        use rust_decimal_macros::dec;
6761        let t = make_tick_pq(dec!(100), dec!(1));
6762        assert!(NormalizedTick::largest_price_move(&[t]).is_none());
6763    }
6764
6765    #[test]
6766    fn test_largest_price_move_correct() {
6767        use rust_decimal_macros::dec;
6768        let ticks = vec![
6769            make_tick_pq(dec!(100), dec!(1)),
6770            make_tick_pq(dec!(105), dec!(1)),  // move = 5
6771            make_tick_pq(dec!(102), dec!(1)),  // move = 3
6772        ];
6773        assert_eq!(NormalizedTick::largest_price_move(&ticks), Some(dec!(5)));
6774    }
6775
6776    // ── NormalizedTick::tick_rate ─────────────────────────────────────────────
6777
6778    #[test]
6779    fn test_tick_rate_none_for_single_tick() {
6780        use rust_decimal_macros::dec;
6781        let t = make_tick_pq(dec!(100), dec!(1));
6782        assert!(NormalizedTick::tick_rate(&[t]).is_none());
6783    }
6784
6785    #[test]
6786    fn test_tick_rate_correct() {
6787        use rust_decimal_macros::dec;
6788        let mut t1 = make_tick_pq(dec!(100), dec!(1)); t1.received_at_ms = 0;
6789        let mut t2 = make_tick_pq(dec!(101), dec!(1)); t2.received_at_ms = 2;
6790        let mut t3 = make_tick_pq(dec!(102), dec!(1)); t3.received_at_ms = 4;
6791        // 3 ticks over 4ms → 0.75 ticks/ms
6792        let rate = NormalizedTick::tick_rate(&[t1, t2, t3]).unwrap();
6793        assert!((rate - 0.75).abs() < 1e-9, "expected 0.75 ticks/ms, got {}", rate);
6794    }
6795
6796    // ── NormalizedTick::buy_notional_fraction ─────────────────────────────────
6797
6798    #[test]
6799    fn test_buy_notional_fraction_none_for_empty() {
6800        assert!(NormalizedTick::buy_notional_fraction(&[]).is_none());
6801    }
6802
6803    #[test]
6804    fn test_buy_notional_fraction_one_when_all_buys() {
6805        use rust_decimal_macros::dec;
6806        let mut t = make_tick_pq(dec!(100), dec!(5)); t.side = Some(TradeSide::Buy);
6807        let frac = NormalizedTick::buy_notional_fraction(&[t]).unwrap();
6808        assert!((frac - 1.0).abs() < 1e-9, "all buys should give fraction=1.0, got {}", frac);
6809    }
6810
6811    #[test]
6812    fn test_buy_notional_fraction_in_range_for_mixed() {
6813        use rust_decimal_macros::dec;
6814        let mut buy = make_tick_pq(dec!(100), dec!(3)); buy.side = Some(TradeSide::Buy);
6815        let mut sell = make_tick_pq(dec!(100), dec!(1)); sell.side = Some(TradeSide::Sell);
6816        let frac = NormalizedTick::buy_notional_fraction(&[buy, sell]).unwrap();
6817        assert!(frac > 0.0 && frac < 1.0, "mixed ticks should be in (0,1), got {}", frac);
6818    }
6819
6820    // ── NormalizedTick::price_range_pct ───────────────────────────────────────
6821
6822    #[test]
6823    fn test_price_range_pct_none_for_empty() {
6824        assert!(NormalizedTick::price_range_pct(&[]).is_none());
6825    }
6826
6827    #[test]
6828    fn test_price_range_pct_correct() {
6829        use rust_decimal_macros::dec;
6830        let ticks = vec![
6831            make_tick_pq(dec!(100), dec!(1)),
6832            make_tick_pq(dec!(110), dec!(1)),
6833        ];
6834        // (110 - 100) / 100 * 100 = 10%
6835        let pct = NormalizedTick::price_range_pct(&ticks).unwrap();
6836        assert!((pct - 10.0).abs() < 1e-6, "expected 10.0%, got {}", pct);
6837    }
6838
6839    // ── NormalizedTick::buy_side_dominance ────────────────────────────────────
6840
6841    #[test]
6842    fn test_buy_side_dominance_none_when_no_sides() {
6843        use rust_decimal_macros::dec;
6844        let t = make_tick_pq(dec!(100), dec!(1)); // side=None
6845        assert!(NormalizedTick::buy_side_dominance(&[t]).is_none());
6846    }
6847
6848    #[test]
6849    fn test_buy_side_dominance_one_when_all_buys() {
6850        use rust_decimal_macros::dec;
6851        let mut t = make_tick_pq(dec!(100), dec!(5)); t.side = Some(TradeSide::Buy);
6852        let d = NormalizedTick::buy_side_dominance(&[t]).unwrap();
6853        assert!((d - 1.0).abs() < 1e-9, "all buys should give 1.0, got {}", d);
6854    }
6855
6856    // ── NormalizedTick::volume_weighted_price_std ─────────────────────────────
6857
6858    #[test]
6859    fn test_volume_weighted_price_std_none_for_empty() {
6860        assert!(NormalizedTick::volume_weighted_price_std(&[]).is_none());
6861    }
6862
6863    #[test]
6864    fn test_volume_weighted_price_std_zero_for_same_price() {
6865        use rust_decimal_macros::dec;
6866        let ticks = vec![
6867            make_tick_pq(dec!(100), dec!(2)),
6868            make_tick_pq(dec!(100), dec!(3)),
6869        ];
6870        let std = NormalizedTick::volume_weighted_price_std(&ticks).unwrap();
6871        assert!((std - 0.0).abs() < 1e-9, "same price should give 0 std, got {}", std);
6872    }
6873
6874    // ── NormalizedTick::last_n_vwap ───────────────────────────────────────────
6875
6876    #[test]
6877    fn test_last_n_vwap_none_for_zero_n() {
6878        use rust_decimal_macros::dec;
6879        let t = make_tick_pq(dec!(100), dec!(1));
6880        assert!(NormalizedTick::last_n_vwap(&[t], 0).is_none());
6881    }
6882
6883    #[test]
6884    fn test_last_n_vwap_uses_last_n_ticks() {
6885        use rust_decimal_macros::dec;
6886        // first tick at 50, last 2 at 100 equal qty → last_n_vwap(n=2) = 100
6887        let ticks = vec![
6888            make_tick_pq(dec!(50), dec!(10)),
6889            make_tick_pq(dec!(100), dec!(5)),
6890            make_tick_pq(dec!(100), dec!(5)),
6891        ];
6892        let v = NormalizedTick::last_n_vwap(&ticks, 2).unwrap();
6893        assert_eq!(v, dec!(100));
6894    }
6895
6896    // ── NormalizedTick::price_autocorrelation ─────────────────────────────────
6897
6898    #[test]
6899    fn test_price_autocorrelation_none_for_fewer_than_3() {
6900        use rust_decimal_macros::dec;
6901        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(1))];
6902        assert!(NormalizedTick::price_autocorrelation(&ticks).is_none());
6903    }
6904
6905    #[test]
6906    fn test_price_autocorrelation_positive_for_trending_prices() {
6907        use rust_decimal_macros::dec;
6908        let ticks = vec![
6909            make_tick_pq(dec!(100), dec!(1)),
6910            make_tick_pq(dec!(102), dec!(1)),
6911            make_tick_pq(dec!(104), dec!(1)),
6912            make_tick_pq(dec!(106), dec!(1)),
6913        ];
6914        let ac = NormalizedTick::price_autocorrelation(&ticks).unwrap();
6915        assert!(ac > 0.0, "trending prices should have positive AC, got {}", ac);
6916    }
6917
6918    // ── NormalizedTick::net_trade_direction ───────────────────────────────────
6919
6920    #[test]
6921    fn test_net_trade_direction_zero_for_empty() {
6922        assert_eq!(NormalizedTick::net_trade_direction(&[]), 0);
6923    }
6924
6925    #[test]
6926    fn test_net_trade_direction_positive_for_more_buys() {
6927        use rust_decimal_macros::dec;
6928        let mut b1 = make_tick_pq(dec!(100), dec!(1)); b1.side = Some(TradeSide::Buy);
6929        let mut b2 = make_tick_pq(dec!(100), dec!(1)); b2.side = Some(TradeSide::Buy);
6930        let mut s1 = make_tick_pq(dec!(100), dec!(1)); s1.side = Some(TradeSide::Sell);
6931        assert_eq!(NormalizedTick::net_trade_direction(&[b1, b2, s1]), 1);
6932    }
6933
6934    // ── NormalizedTick::sell_side_notional_fraction ───────────────────────────
6935
6936    #[test]
6937    fn test_sell_side_notional_fraction_none_for_empty() {
6938        assert!(NormalizedTick::sell_side_notional_fraction(&[]).is_none());
6939    }
6940
6941    #[test]
6942    fn test_sell_side_notional_fraction_one_when_all_sells() {
6943        use rust_decimal_macros::dec;
6944        let mut t = make_tick_pq(dec!(100), dec!(5)); t.side = Some(TradeSide::Sell);
6945        let f = NormalizedTick::sell_side_notional_fraction(&[t]).unwrap();
6946        assert!((f - 1.0).abs() < 1e-9, "all sells should give 1.0, got {}", f);
6947    }
6948
6949    // ── NormalizedTick::price_oscillation_count ───────────────────────────────
6950
6951    #[test]
6952    fn test_price_oscillation_count_zero_for_monotone() {
6953        use rust_decimal_macros::dec;
6954        let ticks = vec![
6955            make_tick_pq(dec!(100), dec!(1)),
6956            make_tick_pq(dec!(101), dec!(1)),
6957            make_tick_pq(dec!(102), dec!(1)),
6958        ];
6959        assert_eq!(NormalizedTick::price_oscillation_count(&ticks), 0);
6960    }
6961
6962    #[test]
6963    fn test_price_oscillation_count_detects_reversals() {
6964        use rust_decimal_macros::dec;
6965        // up-down-up: 100 → 105 → 102 → 107
6966        // windows(3): [100,105,102] (up-down) + [105,102,107] (down-up) → 2 reversals
6967        let ticks = vec![
6968            make_tick_pq(dec!(100), dec!(1)),
6969            make_tick_pq(dec!(105), dec!(1)),
6970            make_tick_pq(dec!(102), dec!(1)),
6971            make_tick_pq(dec!(107), dec!(1)),
6972        ];
6973        assert_eq!(NormalizedTick::price_oscillation_count(&ticks), 2);
6974    }
6975
6976    // ── NormalizedTick::realized_spread ───────────────────────────────────────
6977
6978    #[test]
6979    fn test_realized_spread_none_when_no_sides() {
6980        use rust_decimal_macros::dec;
6981        let t = make_tick_pq(dec!(100), dec!(1));
6982        assert!(NormalizedTick::realized_spread(&[t]).is_none());
6983    }
6984
6985    #[test]
6986    fn test_realized_spread_positive_when_buys_higher() {
6987        use rust_decimal_macros::dec;
6988        let mut b = make_tick_pq(dec!(105), dec!(1)); b.side = Some(TradeSide::Buy);
6989        let mut s = make_tick_pq(dec!(100), dec!(1)); s.side = Some(TradeSide::Sell);
6990        let spread = NormalizedTick::realized_spread(&[b, s]).unwrap();
6991        assert!(spread > dec!(0), "expected positive spread, got {}", spread);
6992    }
6993
6994    // ── NormalizedTick::price_impact_per_unit ────────────────────────────────
6995
6996    #[test]
6997    fn test_price_impact_per_unit_none_for_single_tick() {
6998        use rust_decimal_macros::dec;
6999        let t = make_tick_pq(dec!(100), dec!(1));
7000        assert!(NormalizedTick::price_impact_per_unit(&[t]).is_none());
7001    }
7002
7003    // ── NormalizedTick::volume_weighted_return ────────────────────────────────
7004
7005    #[test]
7006    fn test_volume_weighted_return_none_for_single_tick() {
7007        use rust_decimal_macros::dec;
7008        let t = make_tick_pq(dec!(100), dec!(1));
7009        assert!(NormalizedTick::volume_weighted_return(&[t]).is_none());
7010    }
7011
7012    #[test]
7013    fn test_volume_weighted_return_zero_for_constant_price() {
7014        use rust_decimal_macros::dec;
7015        let ticks = vec![
7016            make_tick_pq(dec!(100), dec!(5)),
7017            make_tick_pq(dec!(100), dec!(5)),
7018        ];
7019        let r = NormalizedTick::volume_weighted_return(&ticks).unwrap();
7020        assert!((r - 0.0).abs() < 1e-9, "constant price should give 0 return, got {}", r);
7021    }
7022
7023    // ── NormalizedTick::quantity_concentration ────────────────────────────────
7024
7025    #[test]
7026    fn test_quantity_concentration_none_for_empty() {
7027        assert!(NormalizedTick::quantity_concentration(&[]).is_none());
7028    }
7029
7030    #[test]
7031    fn test_quantity_concentration_zero_for_identical_quantities() {
7032        use rust_decimal_macros::dec;
7033        let ticks = vec![
7034            make_tick_pq(dec!(100), dec!(5)),
7035            make_tick_pq(dec!(101), dec!(5)),
7036        ];
7037        let c = NormalizedTick::quantity_concentration(&ticks).unwrap();
7038        assert!((c - 0.0).abs() < 1e-9, "identical quantities should give 0 concentration, got {}", c);
7039    }
7040
7041    // ── NormalizedTick::price_level_volume ────────────────────────────────────
7042
7043    #[test]
7044    fn test_price_level_volume_zero_for_no_match() {
7045        use rust_decimal_macros::dec;
7046        let ticks = vec![make_tick_pq(dec!(100), dec!(5))];
7047        let v = NormalizedTick::price_level_volume(&ticks, dec!(200));
7048        assert_eq!(v, dec!(0));
7049    }
7050
7051    #[test]
7052    fn test_price_level_volume_sums_matching_ticks() {
7053        use rust_decimal_macros::dec;
7054        let ticks = vec![
7055            make_tick_pq(dec!(100), dec!(3)),
7056            make_tick_pq(dec!(101), dec!(7)),
7057            make_tick_pq(dec!(100), dec!(2)),
7058        ];
7059        assert_eq!(NormalizedTick::price_level_volume(&ticks, dec!(100)), dec!(5));
7060    }
7061
7062    // ── NormalizedTick::mid_price_drift ───────────────────────────────────────
7063
7064    #[test]
7065    fn test_mid_price_drift_none_for_single_tick() {
7066        use rust_decimal_macros::dec;
7067        let t = make_tick_pq(dec!(100), dec!(1));
7068        assert!(NormalizedTick::mid_price_drift(&[t]).is_none());
7069    }
7070
7071    // ── NormalizedTick::tick_direction_bias ───────────────────────────────────
7072
7073    #[test]
7074    fn test_tick_direction_bias_none_for_fewer_than_3() {
7075        use rust_decimal_macros::dec;
7076        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(1))];
7077        assert!(NormalizedTick::tick_direction_bias(&ticks).is_none());
7078    }
7079
7080    #[test]
7081    fn test_tick_direction_bias_one_for_monotone() {
7082        use rust_decimal_macros::dec;
7083        let ticks = vec![
7084            make_tick_pq(dec!(100), dec!(1)),
7085            make_tick_pq(dec!(101), dec!(1)),
7086            make_tick_pq(dec!(102), dec!(1)),
7087            make_tick_pq(dec!(103), dec!(1)),
7088        ];
7089        let bias = NormalizedTick::tick_direction_bias(&ticks).unwrap();
7090        assert!((bias - 1.0).abs() < 1e-9, "monotone should give bias=1.0, got {}", bias);
7091    }
7092
7093    #[test]
7094    fn test_buy_sell_size_ratio_none_for_empty() {
7095        assert!(NormalizedTick::buy_sell_size_ratio(&[]).is_none());
7096    }
7097
7098    #[test]
7099    fn test_buy_sell_size_ratio_positive() {
7100        use rust_decimal_macros::dec;
7101        let buy = NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(4)) };
7102        let sell = NormalizedTick { side: Some(TradeSide::Sell), ..make_tick_pq(dec!(100), dec!(2)) };
7103        let r = NormalizedTick::buy_sell_size_ratio(&[buy, sell]).unwrap();
7104        assert!((r - 2.0).abs() < 1e-6, "ratio should be 2.0, got {}", r);
7105    }
7106
7107    #[test]
7108    fn test_trade_size_dispersion_none_for_single_tick() {
7109        use rust_decimal_macros::dec;
7110        let ticks = vec![make_tick_pq(dec!(100), dec!(5))];
7111        assert!(NormalizedTick::trade_size_dispersion(&ticks).is_none());
7112    }
7113
7114    #[test]
7115    fn test_trade_size_dispersion_zero_for_identical() {
7116        use rust_decimal_macros::dec;
7117        let ticks = vec![
7118            make_tick_pq(dec!(100), dec!(5)),
7119            make_tick_pq(dec!(101), dec!(5)),
7120            make_tick_pq(dec!(102), dec!(5)),
7121        ];
7122        let d = NormalizedTick::trade_size_dispersion(&ticks).unwrap();
7123        assert!(d.abs() < 1e-9, "identical sizes → dispersion=0, got {}", d);
7124    }
7125
7126    #[test]
7127    fn test_first_last_price_none_for_empty() {
7128        assert!(NormalizedTick::first_price(&[]).is_none());
7129        assert!(NormalizedTick::last_price(&[]).is_none());
7130    }
7131
7132    #[test]
7133    fn test_first_last_price_correct() {
7134        use rust_decimal_macros::dec;
7135        let ticks = vec![
7136            make_tick_pq(dec!(100), dec!(1)),
7137            make_tick_pq(dec!(105), dec!(1)),
7138            make_tick_pq(dec!(110), dec!(1)),
7139        ];
7140        assert_eq!(NormalizedTick::first_price(&ticks).unwrap(), dec!(100));
7141        assert_eq!(NormalizedTick::last_price(&ticks).unwrap(), dec!(110));
7142    }
7143
7144    #[test]
7145    fn test_median_quantity_none_for_empty() {
7146        assert!(NormalizedTick::median_quantity(&[]).is_none());
7147    }
7148
7149    #[test]
7150    fn test_median_quantity_odd_count() {
7151        use rust_decimal_macros::dec;
7152        let ticks = vec![
7153            make_tick_pq(dec!(100), dec!(3)),
7154            make_tick_pq(dec!(101), dec!(1)),
7155            make_tick_pq(dec!(102), dec!(5)),
7156        ];
7157        // sorted: 1, 3, 5 → median = 3
7158        assert_eq!(NormalizedTick::median_quantity(&ticks).unwrap(), dec!(3));
7159    }
7160
7161    #[test]
7162    fn test_volume_above_vwap_none_for_empty() {
7163        assert!(NormalizedTick::volume_above_vwap(&[]).is_none());
7164    }
7165
7166    #[test]
7167    fn test_volume_above_vwap_none_when_all_at_vwap() {
7168        use rust_decimal_macros::dec;
7169        // All same price → VWAP = price, nothing strictly above
7170        let ticks = vec![
7171            make_tick_pq(dec!(100), dec!(5)),
7172            make_tick_pq(dec!(100), dec!(5)),
7173        ];
7174        let v = NormalizedTick::volume_above_vwap(&ticks).unwrap();
7175        assert_eq!(v, dec!(0));
7176    }
7177
7178    #[test]
7179    fn test_inter_arrival_variance_none_for_fewer_than_3() {
7180        use rust_decimal_macros::dec;
7181        let t = make_tick_pq(dec!(100), dec!(1));
7182        assert!(NormalizedTick::inter_arrival_variance(&[t]).is_none());
7183    }
7184
7185    #[test]
7186    fn test_spread_efficiency_none_for_single_tick() {
7187        use rust_decimal_macros::dec;
7188        let ticks = vec![make_tick_pq(dec!(100), dec!(1))];
7189        assert!(NormalizedTick::spread_efficiency(&ticks).is_none());
7190    }
7191
7192    #[test]
7193    fn test_spread_efficiency_one_for_monotone() {
7194        use rust_decimal_macros::dec;
7195        let ticks = vec![
7196            make_tick_pq(dec!(100), dec!(1)),
7197            make_tick_pq(dec!(101), dec!(1)),
7198            make_tick_pq(dec!(102), dec!(1)),
7199        ];
7200        // monotone up → efficiency = 1.0
7201        let e = NormalizedTick::spread_efficiency(&ticks).unwrap();
7202        assert!((e - 1.0).abs() < 1e-9, "expected 1.0, got {}", e);
7203    }
7204
7205    // ── round-79 ─────────────────────────────────────────────────────────────
7206
7207    // ── NormalizedTick::aggressor_fraction ────────────────────────────────────
7208
7209    #[test]
7210    fn test_aggressor_fraction_none_for_empty() {
7211        assert!(NormalizedTick::aggressor_fraction(&[]).is_none());
7212    }
7213
7214    #[test]
7215    fn test_aggressor_fraction_zero_when_all_neutral() {
7216        use rust_decimal_macros::dec;
7217        let ticks = vec![
7218            make_tick_pq(dec!(100), dec!(1)),
7219            make_tick_pq(dec!(101), dec!(1)),
7220        ];
7221        let f = NormalizedTick::aggressor_fraction(&ticks).unwrap();
7222        assert!((f - 0.0).abs() < 1e-9, "all neutral → fraction=0, got {}", f);
7223    }
7224
7225    #[test]
7226    fn test_aggressor_fraction_one_when_all_known() {
7227        use rust_decimal_macros::dec;
7228        let ticks = vec![
7229            NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(1)) },
7230            NormalizedTick { side: Some(TradeSide::Sell), ..make_tick_pq(dec!(101), dec!(1)) },
7231        ];
7232        let f = NormalizedTick::aggressor_fraction(&ticks).unwrap();
7233        assert!((f - 1.0).abs() < 1e-9, "all known → fraction=1, got {}", f);
7234    }
7235
7236    // ── NormalizedTick::volume_imbalance_ratio ────────────────────────────────
7237
7238    #[test]
7239    fn test_volume_imbalance_ratio_none_for_neutral_ticks() {
7240        use rust_decimal_macros::dec;
7241        let ticks = vec![make_tick_pq(dec!(100), dec!(5))];
7242        assert!(NormalizedTick::volume_imbalance_ratio(&ticks).is_none());
7243    }
7244
7245    #[test]
7246    fn test_volume_imbalance_ratio_positive_for_all_buys() {
7247        use rust_decimal_macros::dec;
7248        let ticks = vec![
7249            NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(4)) },
7250        ];
7251        let r = NormalizedTick::volume_imbalance_ratio(&ticks).unwrap();
7252        assert!((r - 1.0).abs() < 1e-9, "all buys → ratio=1.0, got {}", r);
7253    }
7254
7255    #[test]
7256    fn test_volume_imbalance_ratio_zero_for_equal_sides() {
7257        use rust_decimal_macros::dec;
7258        let ticks = vec![
7259            NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(5)) },
7260            NormalizedTick { side: Some(TradeSide::Sell), ..make_tick_pq(dec!(100), dec!(5)) },
7261        ];
7262        let r = NormalizedTick::volume_imbalance_ratio(&ticks).unwrap();
7263        assert!(r.abs() < 1e-9, "equal buy/sell → ratio=0, got {}", r);
7264    }
7265
7266    // ── NormalizedTick::price_quantity_covariance ─────────────────────────────
7267
7268    #[test]
7269    fn test_price_quantity_covariance_none_for_single_tick() {
7270        use rust_decimal_macros::dec;
7271        let ticks = vec![make_tick_pq(dec!(100), dec!(1))];
7272        assert!(NormalizedTick::price_quantity_covariance(&ticks).is_none());
7273    }
7274
7275    #[test]
7276    fn test_price_quantity_covariance_positive_when_correlated() {
7277        use rust_decimal_macros::dec;
7278        let ticks = vec![
7279            make_tick_pq(dec!(100), dec!(1)),
7280            make_tick_pq(dec!(200), dec!(2)),
7281            make_tick_pq(dec!(300), dec!(3)),
7282        ];
7283        let c = NormalizedTick::price_quantity_covariance(&ticks).unwrap();
7284        assert!(c > 0.0, "price and qty both rise → positive cov, got {}", c);
7285    }
7286
7287    // ── NormalizedTick::large_trade_fraction ──────────────────────────────────
7288
7289    #[test]
7290    fn test_large_trade_fraction_none_for_empty() {
7291        use rust_decimal_macros::dec;
7292        assert!(NormalizedTick::large_trade_fraction(&[], dec!(10)).is_none());
7293    }
7294
7295    #[test]
7296    fn test_large_trade_fraction_zero_when_all_small() {
7297        use rust_decimal_macros::dec;
7298        let ticks = vec![
7299            make_tick_pq(dec!(100), dec!(1)),
7300            make_tick_pq(dec!(101), dec!(2)),
7301        ];
7302        let f = NormalizedTick::large_trade_fraction(&ticks, dec!(10)).unwrap();
7303        assert!((f - 0.0).abs() < 1e-9, "all small → fraction=0, got {}", f);
7304    }
7305
7306    #[test]
7307    fn test_large_trade_fraction_one_when_all_large() {
7308        use rust_decimal_macros::dec;
7309        let ticks = vec![
7310            make_tick_pq(dec!(100), dec!(20)),
7311            make_tick_pq(dec!(101), dec!(30)),
7312        ];
7313        let f = NormalizedTick::large_trade_fraction(&ticks, dec!(10)).unwrap();
7314        assert!((f - 1.0).abs() < 1e-9, "all large → fraction=1, got {}", f);
7315    }
7316
7317    // ── NormalizedTick::price_level_density ───────────────────────────────────
7318
7319    #[test]
7320    fn test_price_level_density_none_for_empty() {
7321        assert!(NormalizedTick::price_level_density(&[]).is_none());
7322    }
7323
7324    #[test]
7325    fn test_price_level_density_none_when_range_zero() {
7326        use rust_decimal_macros::dec;
7327        let ticks = vec![
7328            make_tick_pq(dec!(100), dec!(1)),
7329            make_tick_pq(dec!(100), dec!(2)),
7330        ];
7331        assert!(NormalizedTick::price_level_density(&ticks).is_none());
7332    }
7333
7334    #[test]
7335    fn test_price_level_density_positive_for_varied_prices() {
7336        use rust_decimal_macros::dec;
7337        let ticks = vec![
7338            make_tick_pq(dec!(100), dec!(1)),
7339            make_tick_pq(dec!(110), dec!(1)),
7340            make_tick_pq(dec!(120), dec!(1)),
7341        ];
7342        let d = NormalizedTick::price_level_density(&ticks).unwrap();
7343        assert!(d > 0.0, "should be positive, got {}", d);
7344    }
7345
7346    // ── NormalizedTick::notional_buy_sell_ratio ───────────────────────────────
7347
7348    #[test]
7349    fn test_notional_buy_sell_ratio_none_when_no_sells() {
7350        use rust_decimal_macros::dec;
7351        let ticks = vec![
7352            NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(5)) },
7353        ];
7354        assert!(NormalizedTick::notional_buy_sell_ratio(&ticks).is_none());
7355    }
7356
7357    #[test]
7358    fn test_notional_buy_sell_ratio_one_for_equal_notional() {
7359        use rust_decimal_macros::dec;
7360        let ticks = vec![
7361            NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(5)) },
7362            NormalizedTick { side: Some(TradeSide::Sell), ..make_tick_pq(dec!(100), dec!(5)) },
7363        ];
7364        let r = NormalizedTick::notional_buy_sell_ratio(&ticks).unwrap();
7365        assert!((r - 1.0).abs() < 1e-9, "equal notional → ratio=1, got {}", r);
7366    }
7367
7368    // ── NormalizedTick::log_return_mean ───────────────────────────────────────
7369
7370    #[test]
7371    fn test_log_return_mean_none_for_single_tick() {
7372        use rust_decimal_macros::dec;
7373        assert!(NormalizedTick::log_return_mean(&[make_tick_pq(dec!(100), dec!(1))]).is_none());
7374    }
7375
7376    #[test]
7377    fn test_log_return_mean_zero_for_constant_price() {
7378        use rust_decimal_macros::dec;
7379        let ticks = vec![
7380            make_tick_pq(dec!(100), dec!(1)),
7381            make_tick_pq(dec!(100), dec!(1)),
7382            make_tick_pq(dec!(100), dec!(1)),
7383        ];
7384        let m = NormalizedTick::log_return_mean(&ticks).unwrap();
7385        assert!(m.abs() < 1e-9, "constant price → mean log return=0, got {}", m);
7386    }
7387
7388    // ── NormalizedTick::log_return_std ────────────────────────────────────────
7389
7390    #[test]
7391    fn test_log_return_std_none_for_fewer_than_3_ticks() {
7392        use rust_decimal_macros::dec;
7393        let ticks = vec![
7394            make_tick_pq(dec!(100), dec!(1)),
7395            make_tick_pq(dec!(101), dec!(1)),
7396        ];
7397        assert!(NormalizedTick::log_return_std(&ticks).is_none());
7398    }
7399
7400    #[test]
7401    fn test_log_return_std_zero_for_constant_price() {
7402        use rust_decimal_macros::dec;
7403        let ticks = vec![
7404            make_tick_pq(dec!(100), dec!(1)),
7405            make_tick_pq(dec!(100), dec!(1)),
7406            make_tick_pq(dec!(100), dec!(1)),
7407            make_tick_pq(dec!(100), dec!(1)),
7408        ];
7409        let s = NormalizedTick::log_return_std(&ticks).unwrap();
7410        assert!(s.abs() < 1e-9, "constant price → std=0, got {}", s);
7411    }
7412
7413    // ── NormalizedTick::price_overshoot_ratio ─────────────────────────────────
7414
7415    #[test]
7416    fn test_price_overshoot_ratio_none_for_empty() {
7417        assert!(NormalizedTick::price_overshoot_ratio(&[]).is_none());
7418    }
7419
7420    #[test]
7421    fn test_price_overshoot_ratio_one_for_monotone_up() {
7422        use rust_decimal_macros::dec;
7423        let ticks = vec![
7424            make_tick_pq(dec!(100), dec!(1)),
7425            make_tick_pq(dec!(105), dec!(1)),
7426            make_tick_pq(dec!(110), dec!(1)),
7427        ];
7428        // max=110 == last=110 → ratio=1
7429        let r = NormalizedTick::price_overshoot_ratio(&ticks).unwrap();
7430        assert!((r - 1.0).abs() < 1e-9, "monotone up → ratio=1, got {}", r);
7431    }
7432
7433    #[test]
7434    fn test_price_overshoot_ratio_above_one_when_price_retreats() {
7435        use rust_decimal_macros::dec;
7436        let ticks = vec![
7437            make_tick_pq(dec!(100), dec!(1)),
7438            make_tick_pq(dec!(120), dec!(1)),
7439            make_tick_pq(dec!(110), dec!(1)),
7440        ];
7441        // max=120, last=110 → ratio > 1
7442        let r = NormalizedTick::price_overshoot_ratio(&ticks).unwrap();
7443        assert!(r > 1.0, "price retreated → ratio>1, got {}", r);
7444    }
7445
7446    // ── NormalizedTick::price_undershoot_ratio ────────────────────────────────
7447
7448    #[test]
7449    fn test_price_undershoot_ratio_none_for_empty() {
7450        assert!(NormalizedTick::price_undershoot_ratio(&[]).is_none());
7451    }
7452
7453    #[test]
7454    fn test_price_undershoot_ratio_one_for_monotone_down() {
7455        use rust_decimal_macros::dec;
7456        let ticks = vec![
7457            make_tick_pq(dec!(110), dec!(1)),
7458            make_tick_pq(dec!(105), dec!(1)),
7459            make_tick_pq(dec!(100), dec!(1)),
7460        ];
7461        // first=110, min=100 → ratio > 1 (price undershot opening)
7462        let r = NormalizedTick::price_undershoot_ratio(&ticks).unwrap();
7463        assert!(r > 1.0, "monotone down → ratio>1, got {}", r);
7464    }
7465
7466    #[test]
7467    fn test_price_undershoot_ratio_one_for_monotone_up() {
7468        use rust_decimal_macros::dec;
7469        let ticks = vec![
7470            make_tick_pq(dec!(100), dec!(1)),
7471            make_tick_pq(dec!(105), dec!(1)),
7472            make_tick_pq(dec!(110), dec!(1)),
7473        ];
7474        // first=100 == min=100 → ratio=1 (never went below open)
7475        let r = NormalizedTick::price_undershoot_ratio(&ticks).unwrap();
7476        assert!((r - 1.0).abs() < 1e-9, "monotone up → ratio=1, got {}", r);
7477    }
7478
7479    // ── round-80 tests ────────────────────────────────────────────────────────
7480
7481    #[test]
7482    fn test_net_notional_empty_is_zero() {
7483        assert_eq!(NormalizedTick::net_notional(&[]), Decimal::ZERO);
7484    }
7485
7486    #[test]
7487    fn test_net_notional_positive_buy() {
7488        use rust_decimal_macros::dec;
7489        let ticks = vec![
7490            make_tick_pq(dec!(100), dec!(5)).with_side(TradeSide::Buy),
7491            make_tick_pq(dec!(100), dec!(2)).with_side(TradeSide::Sell),
7492        ];
7493        assert_eq!(NormalizedTick::net_notional(&ticks), dec!(300));
7494    }
7495
7496    #[test]
7497    fn test_price_reversal_count_empty_is_zero() {
7498        assert_eq!(NormalizedTick::price_reversal_count(&[]), 0);
7499    }
7500
7501    #[test]
7502    fn test_price_reversal_count_monotone_is_zero() {
7503        use rust_decimal_macros::dec;
7504        let ticks = vec![
7505            make_tick_pq(dec!(100), dec!(1)),
7506            make_tick_pq(dec!(101), dec!(1)),
7507            make_tick_pq(dec!(102), dec!(1)),
7508        ];
7509        assert_eq!(NormalizedTick::price_reversal_count(&ticks), 0);
7510    }
7511
7512    #[test]
7513    fn test_price_reversal_count_zigzag() {
7514        use rust_decimal_macros::dec;
7515        let ticks = vec![
7516            make_tick_pq(dec!(100), dec!(1)),
7517            make_tick_pq(dec!(105), dec!(1)),
7518            make_tick_pq(dec!(100), dec!(1)),
7519            make_tick_pq(dec!(105), dec!(1)),
7520        ];
7521        assert_eq!(NormalizedTick::price_reversal_count(&ticks), 2);
7522    }
7523
7524    #[test]
7525    fn test_quantity_kurtosis_none_for_few_ticks() {
7526        use rust_decimal_macros::dec;
7527        let t = make_tick_pq(dec!(100), dec!(1));
7528        assert!(NormalizedTick::quantity_kurtosis(&[t]).is_none());
7529    }
7530
7531    #[test]
7532    fn test_quantity_kurtosis_some_for_sufficient() {
7533        use rust_decimal_macros::dec;
7534        let ticks = vec![
7535            make_tick_pq(dec!(100), dec!(1)),
7536            make_tick_pq(dec!(101), dec!(2)),
7537            make_tick_pq(dec!(102), dec!(3)),
7538            make_tick_pq(dec!(103), dec!(4)),
7539        ];
7540        assert!(NormalizedTick::quantity_kurtosis(&ticks).is_some());
7541    }
7542
7543    #[test]
7544    fn test_largest_notional_trade_none_for_empty() {
7545        assert!(NormalizedTick::largest_notional_trade(&[]).is_none());
7546    }
7547
7548    #[test]
7549    fn test_largest_notional_trade_correct() {
7550        use rust_decimal_macros::dec;
7551        let ticks = vec![
7552            make_tick_pq(dec!(100), dec!(1)),   // notional = 100
7553            make_tick_pq(dec!(50), dec!(10)),   // notional = 500 ← max
7554            make_tick_pq(dec!(200), dec!(1)),   // notional = 200
7555        ];
7556        let t = NormalizedTick::largest_notional_trade(&ticks).unwrap();
7557        assert_eq!(t.price, dec!(50));
7558    }
7559
7560    #[test]
7561    fn test_twap_none_for_single_tick() {
7562        use rust_decimal_macros::dec;
7563        assert!(NormalizedTick::twap(&[make_tick_pq(dec!(100), dec!(1))]).is_none());
7564    }
7565
7566    #[test]
7567    fn test_twap_two_equal_intervals() {
7568        use rust_decimal_macros::dec;
7569        let mut t1 = make_tick_pq(dec!(100), dec!(1));
7570        t1.received_at_ms = 0;
7571        let mut t2 = make_tick_pq(dec!(200), dec!(1));
7572        t2.received_at_ms = 1000;
7573        let mut t3 = make_tick_pq(dec!(300), dec!(1));
7574        t3.received_at_ms = 2000;
7575        // weights: t1 * 1000ms, t2 * 1000ms → TWAP = (100*1000 + 200*1000)/2000 = 150
7576        let twap = NormalizedTick::twap(&[t1, t2, t3]).unwrap();
7577        assert_eq!(twap, dec!(150));
7578    }
7579
7580    #[test]
7581    fn test_neutral_fraction_all_neutral() {
7582        use rust_decimal_macros::dec;
7583        let ticks = vec![
7584            make_tick_pq(dec!(100), dec!(1)),
7585            make_tick_pq(dec!(101), dec!(1)),
7586        ];
7587        let f = NormalizedTick::neutral_fraction(&ticks).unwrap();
7588        assert!((f - 1.0).abs() < 1e-9, "all neutral → fraction=1, got {}", f);
7589    }
7590
7591    #[test]
7592    fn test_log_return_variance_none_for_few_ticks() {
7593        use rust_decimal_macros::dec;
7594        let t = make_tick_pq(dec!(100), dec!(1));
7595        assert!(NormalizedTick::log_return_variance(&[t]).is_none());
7596    }
7597
7598    #[test]
7599    fn test_log_return_variance_zero_for_flat_prices() {
7600        use rust_decimal_macros::dec;
7601        let ticks = vec![
7602            make_tick_pq(dec!(100), dec!(1)),
7603            make_tick_pq(dec!(100), dec!(1)),
7604            make_tick_pq(dec!(100), dec!(1)),
7605        ];
7606        let v = NormalizedTick::log_return_variance(&ticks).unwrap();
7607        assert!(v.abs() < 1e-9, "flat prices → variance=0, got {}", v);
7608    }
7609
7610    #[test]
7611    fn test_volume_at_vwap_zero_for_empty() {
7612        assert_eq!(
7613            NormalizedTick::volume_at_vwap(&[], rust_decimal_macros::dec!(1)),
7614            Decimal::ZERO
7615        );
7616    }
7617
7618    // ── NormalizedTick::cumulative_volume ─────────────────────────────────────
7619
7620    #[test]
7621    fn test_cumulative_volume_empty_for_empty_slice() {
7622        assert!(NormalizedTick::cumulative_volume(&[]).is_empty());
7623    }
7624
7625    #[test]
7626    fn test_cumulative_volume_last_equals_total() {
7627        use rust_decimal_macros::dec;
7628        let ticks = vec![
7629            make_tick_pq(dec!(100), dec!(2)),
7630            make_tick_pq(dec!(101), dec!(3)),
7631            make_tick_pq(dec!(102), dec!(5)),
7632        ];
7633        let cv = NormalizedTick::cumulative_volume(&ticks);
7634        assert_eq!(cv.last().copied().unwrap(), dec!(10));
7635        assert_eq!(cv[0], dec!(2));
7636    }
7637
7638    // ── NormalizedTick::price_volatility_ratio ────────────────────────────────
7639
7640    #[test]
7641    fn test_price_volatility_ratio_none_for_empty() {
7642        assert!(NormalizedTick::price_volatility_ratio(&[]).is_none());
7643    }
7644
7645    #[test]
7646    fn test_price_volatility_ratio_zero_for_constant_price() {
7647        use rust_decimal_macros::dec;
7648        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(100), dec!(1))];
7649        let r = NormalizedTick::price_volatility_ratio(&ticks).unwrap();
7650        assert!(r.abs() < 1e-9, "constant price → ratio=0, got {}", r);
7651    }
7652
7653    // ── NormalizedTick::notional_per_tick ─────────────────────────────────────
7654
7655    #[test]
7656    fn test_notional_per_tick_none_for_empty() {
7657        assert!(NormalizedTick::notional_per_tick(&[]).is_none());
7658    }
7659
7660    #[test]
7661    fn test_notional_per_tick_equals_single_tick_notional() {
7662        use rust_decimal_macros::dec;
7663        let ticks = vec![make_tick_pq(dec!(100), dec!(5))];
7664        let n = NormalizedTick::notional_per_tick(&ticks).unwrap();
7665        assert!((n - 500.0).abs() < 1e-6, "100×5=500, got {}", n);
7666    }
7667
7668    // ── NormalizedTick::buy_to_total_volume_ratio ─────────────────────────────
7669
7670    #[test]
7671    fn test_buy_to_total_volume_ratio_none_for_empty() {
7672        assert!(NormalizedTick::buy_to_total_volume_ratio(&[]).is_none());
7673    }
7674
7675    #[test]
7676    fn test_buy_to_total_volume_ratio_zero_for_all_neutral() {
7677        use rust_decimal_macros::dec;
7678        let ticks = vec![make_tick_pq(dec!(100), dec!(5)), make_tick_pq(dec!(101), dec!(3))];
7679        let r = NormalizedTick::buy_to_total_volume_ratio(&ticks).unwrap();
7680        assert!(r.abs() < 1e-9, "neutral ticks → buy ratio=0, got {}", r);
7681    }
7682
7683    // ── NormalizedTick::avg_latency_ms ────────────────────────────────────────
7684
7685    #[test]
7686    fn test_avg_latency_ms_none_when_no_exchange_ts() {
7687        use rust_decimal_macros::dec;
7688        let ticks = vec![make_tick_pq(dec!(100), dec!(1))];
7689        assert!(NormalizedTick::avg_latency_ms(&ticks).is_none());
7690    }
7691
7692    // ── NormalizedTick::price_gini ────────────────────────────────────────────
7693
7694    #[test]
7695    fn test_price_gini_none_for_empty() {
7696        assert!(NormalizedTick::price_gini(&[]).is_none());
7697    }
7698
7699    #[test]
7700    fn test_price_gini_zero_for_uniform_prices() {
7701        use rust_decimal_macros::dec;
7702        let ticks = vec![
7703            make_tick_pq(dec!(100), dec!(1)),
7704            make_tick_pq(dec!(100), dec!(1)),
7705            make_tick_pq(dec!(100), dec!(1)),
7706        ];
7707        let g = NormalizedTick::price_gini(&ticks).unwrap();
7708        assert!(g.abs() < 1e-9, "uniform prices → gini=0, got {}", g);
7709    }
7710
7711    // ── NormalizedTick::trade_velocity ────────────────────────────────────────
7712
7713    #[test]
7714    fn test_trade_velocity_none_for_same_timestamp() {
7715        use rust_decimal_macros::dec;
7716        let ticks = vec![
7717            make_tick_pq(dec!(100), dec!(1)),
7718            make_tick_pq(dec!(101), dec!(1)),
7719        ];
7720        assert!(NormalizedTick::trade_velocity(&ticks).is_none());
7721    }
7722
7723    // ── NormalizedTick::floor_price ───────────────────────────────────────────
7724
7725    #[test]
7726    fn test_floor_price_none_for_empty() {
7727        assert!(NormalizedTick::floor_price(&[]).is_none());
7728    }
7729
7730    #[test]
7731    fn test_floor_price_equals_min_price() {
7732        use rust_decimal_macros::dec;
7733        let ticks = vec![
7734            make_tick_pq(dec!(105), dec!(1)),
7735            make_tick_pq(dec!(100), dec!(1)),
7736            make_tick_pq(dec!(103), dec!(1)),
7737        ];
7738        assert_eq!(NormalizedTick::floor_price(&ticks), NormalizedTick::min_price(&ticks));
7739    }
7740
7741    // ── round-82 tests ────────────────────────────────────────────────────────
7742
7743    #[test]
7744    fn test_price_momentum_score_none_for_single_tick() {
7745        use rust_decimal_macros::dec;
7746        let t = make_tick_pq(dec!(100), dec!(1));
7747        assert!(NormalizedTick::price_momentum_score(&[t]).is_none());
7748    }
7749
7750    #[test]
7751    fn test_price_momentum_score_positive_for_rising_prices() {
7752        use rust_decimal_macros::dec;
7753        let ticks = vec![
7754            make_tick_pq(dec!(100), dec!(1)),
7755            make_tick_pq(dec!(102), dec!(2)),
7756            make_tick_pq(dec!(104), dec!(2)),
7757        ];
7758        let s = NormalizedTick::price_momentum_score(&ticks).unwrap();
7759        assert!(s > 0.0, "rising prices → positive momentum, got {}", s);
7760    }
7761
7762    #[test]
7763    fn test_vwap_std_none_for_single_tick() {
7764        use rust_decimal_macros::dec;
7765        let t = make_tick_pq(dec!(100), dec!(1));
7766        assert!(NormalizedTick::vwap_std(&[t]).is_none());
7767    }
7768
7769    #[test]
7770    fn test_vwap_std_zero_for_constant_price() {
7771        use rust_decimal_macros::dec;
7772        let ticks = vec![
7773            make_tick_pq(dec!(100), dec!(1)),
7774            make_tick_pq(dec!(100), dec!(2)),
7775            make_tick_pq(dec!(100), dec!(3)),
7776        ];
7777        let s = NormalizedTick::vwap_std(&ticks).unwrap();
7778        assert!(s.abs() < 1e-9, "constant price → vwap_std=0, got {}", s);
7779    }
7780
7781    #[test]
7782    fn test_price_range_expansion_none_for_empty() {
7783        assert!(NormalizedTick::price_range_expansion(&[]).is_none());
7784    }
7785
7786    #[test]
7787    fn test_price_range_expansion_monotone_rising() {
7788        use rust_decimal_macros::dec;
7789        let ticks = vec![
7790            make_tick_pq(dec!(100), dec!(1)),
7791            make_tick_pq(dec!(101), dec!(1)),
7792            make_tick_pq(dec!(102), dec!(1)),
7793            make_tick_pq(dec!(103), dec!(1)),
7794        ];
7795        let f = NormalizedTick::price_range_expansion(&ticks).unwrap();
7796        // Every tick after the first sets a new high → count=3, total=4 → 0.75
7797        assert!((f - 0.75).abs() < 1e-9, "expected 0.75, got {}", f);
7798    }
7799
7800    #[test]
7801    fn test_sell_to_total_volume_ratio_none_for_empty() {
7802        assert!(NormalizedTick::sell_to_total_volume_ratio(&[]).is_none());
7803    }
7804
7805    #[test]
7806    fn test_sell_to_total_volume_ratio_zero_for_all_buys() {
7807        use rust_decimal_macros::dec;
7808        let mut t1 = make_tick_pq(dec!(100), dec!(5));
7809        t1.side = Some(crate::tick::TradeSide::Buy);
7810        let mut t2 = make_tick_pq(dec!(101), dec!(3));
7811        t2.side = Some(crate::tick::TradeSide::Buy);
7812        let r = NormalizedTick::sell_to_total_volume_ratio(&[t1, t2]).unwrap();
7813        assert!(r.abs() < 1e-9, "all buys → sell ratio=0, got {}", r);
7814    }
7815
7816    #[test]
7817    fn test_notional_std_none_for_single_tick() {
7818        use rust_decimal_macros::dec;
7819        let t = make_tick_pq(dec!(100), dec!(1));
7820        assert!(NormalizedTick::notional_std(&[t]).is_none());
7821    }
7822
7823    #[test]
7824    fn test_notional_std_zero_for_identical_notionals() {
7825        use rust_decimal_macros::dec;
7826        let t1 = make_tick_pq(dec!(100), dec!(2));
7827        let t2 = make_tick_pq(dec!(100), dec!(2));
7828        let s = NormalizedTick::notional_std(&[t1, t2]).unwrap();
7829        assert!(s.abs() < 1e-9, "identical notionals → std=0, got {}", s);
7830    }
7831
7832    // ── round-83 tests ────────────────────────────────────────────────────────
7833
7834    #[test]
7835    fn test_buy_price_mean_none_when_no_buys() {
7836        use rust_decimal_macros::dec;
7837        let t = make_tick_pq(dec!(100), dec!(1)); // side=None
7838        assert!(NormalizedTick::buy_price_mean(&[t]).is_none());
7839    }
7840
7841    #[test]
7842    fn test_buy_price_mean_correct_value() {
7843        use rust_decimal_macros::dec;
7844        let mut t1 = make_tick_pq(dec!(100), dec!(1));
7845        t1.side = Some(crate::tick::TradeSide::Buy);
7846        let mut t2 = make_tick_pq(dec!(102), dec!(1));
7847        t2.side = Some(crate::tick::TradeSide::Buy);
7848        let mean = NormalizedTick::buy_price_mean(&[t1, t2]).unwrap();
7849        assert_eq!(mean, dec!(101));
7850    }
7851
7852    #[test]
7853    fn test_sell_price_mean_none_when_no_sells() {
7854        use rust_decimal_macros::dec;
7855        let t = make_tick_pq(dec!(100), dec!(1));
7856        assert!(NormalizedTick::sell_price_mean(&[t]).is_none());
7857    }
7858
7859    #[test]
7860    fn test_price_efficiency_none_for_single_tick() {
7861        use rust_decimal_macros::dec;
7862        let t = make_tick_pq(dec!(100), dec!(1));
7863        assert!(NormalizedTick::price_efficiency(&[t]).is_none());
7864    }
7865
7866    #[test]
7867    fn test_price_efficiency_one_for_directional() {
7868        use rust_decimal_macros::dec;
7869        let ticks = vec![
7870            make_tick_pq(dec!(100), dec!(1)),
7871            make_tick_pq(dec!(102), dec!(1)),
7872            make_tick_pq(dec!(104), dec!(1)),
7873        ];
7874        let e = NormalizedTick::price_efficiency(&ticks).unwrap();
7875        assert!((e - 1.0).abs() < 1e-9, "monotone rising → efficiency=1, got {}", e);
7876    }
7877
7878    #[test]
7879    fn test_price_return_skewness_none_for_few_ticks() {
7880        use rust_decimal_macros::dec;
7881        let ticks = vec![
7882            make_tick_pq(dec!(100), dec!(1)),
7883            make_tick_pq(dec!(101), dec!(1)),
7884            make_tick_pq(dec!(102), dec!(1)),
7885        ];
7886        assert!(NormalizedTick::price_return_skewness(&ticks).is_none());
7887    }
7888
7889    #[test]
7890    fn test_buy_sell_vwap_spread_none_when_no_sides() {
7891        use rust_decimal_macros::dec;
7892        let ticks = vec![
7893            make_tick_pq(dec!(100), dec!(1)),
7894            make_tick_pq(dec!(101), dec!(1)),
7895        ];
7896        assert!(NormalizedTick::buy_sell_vwap_spread(&ticks).is_none());
7897    }
7898
7899    #[test]
7900    fn test_above_mean_quantity_fraction_none_for_empty() {
7901        assert!(NormalizedTick::above_mean_quantity_fraction(&[]).is_none());
7902    }
7903
7904    #[test]
7905    fn test_above_mean_quantity_fraction_in_range() {
7906        use rust_decimal_macros::dec;
7907        let ticks = vec![
7908            make_tick_pq(dec!(100), dec!(1)),
7909            make_tick_pq(dec!(100), dec!(5)),
7910            make_tick_pq(dec!(100), dec!(3)),
7911        ];
7912        let f = NormalizedTick::above_mean_quantity_fraction(&ticks).unwrap();
7913        assert!(f >= 0.0 && f <= 1.0, "fraction in [0,1], got {}", f);
7914    }
7915
7916    #[test]
7917    fn test_price_unchanged_fraction_none_for_single_tick() {
7918        use rust_decimal_macros::dec;
7919        let t = make_tick_pq(dec!(100), dec!(1));
7920        assert!(NormalizedTick::price_unchanged_fraction(&[t]).is_none());
7921    }
7922
7923    #[test]
7924    fn test_price_unchanged_fraction_zero_for_all_changing() {
7925        use rust_decimal_macros::dec;
7926        let ticks = vec![
7927            make_tick_pq(dec!(100), dec!(1)),
7928            make_tick_pq(dec!(101), dec!(1)),
7929            make_tick_pq(dec!(102), dec!(1)),
7930        ];
7931        let f = NormalizedTick::price_unchanged_fraction(&ticks).unwrap();
7932        assert!(f.abs() < 1e-9, "all prices different → unchanged=0, got {}", f);
7933    }
7934
7935    #[test]
7936    fn test_qty_weighted_range_none_for_empty() {
7937        assert!(NormalizedTick::qty_weighted_range(&[]).is_none());
7938    }
7939
7940    #[test]
7941    fn test_qty_weighted_range_zero_for_single_tick() {
7942        use rust_decimal_macros::dec;
7943        let t = make_tick_pq(dec!(100), dec!(2));
7944        let r = NormalizedTick::qty_weighted_range(&[t]).unwrap();
7945        assert!(r.abs() < 1e-9, "single tick → range=0, got {}", r);
7946    }
7947
7948    // ── round-84 tests ────────────────────────────────────────────────────────
7949
7950    #[test]
7951    fn test_sell_notional_fraction_none_for_empty() {
7952        assert!(NormalizedTick::sell_notional_fraction(&[]).is_none());
7953    }
7954
7955    #[test]
7956    fn test_sell_notional_fraction_zero_for_all_buys() {
7957        use rust_decimal_macros::dec;
7958        let mut t1 = make_tick_pq(dec!(100), dec!(3));
7959        t1.side = Some(crate::tick::TradeSide::Buy);
7960        let r = NormalizedTick::sell_notional_fraction(&[t1]).unwrap();
7961        assert!(r.abs() < 1e-9, "all buys → sell fraction=0, got {}", r);
7962    }
7963
7964    #[test]
7965    fn test_max_price_gap_none_for_single_tick() {
7966        use rust_decimal_macros::dec;
7967        let t = make_tick_pq(dec!(100), dec!(1));
7968        assert!(NormalizedTick::max_price_gap(&[t]).is_none());
7969    }
7970
7971    #[test]
7972    fn test_max_price_gap_correct_value() {
7973        use rust_decimal_macros::dec;
7974        let ticks = vec![
7975            make_tick_pq(dec!(100), dec!(1)),
7976            make_tick_pq(dec!(105), dec!(1)),
7977            make_tick_pq(dec!(103), dec!(1)),
7978        ];
7979        assert_eq!(NormalizedTick::max_price_gap(&ticks).unwrap(), dec!(5));
7980    }
7981
7982    #[test]
7983    fn test_price_range_velocity_none_for_single_tick() {
7984        use rust_decimal_macros::dec;
7985        let t = make_tick_pq(dec!(100), dec!(1));
7986        assert!(NormalizedTick::price_range_velocity(&[t]).is_none());
7987    }
7988
7989    #[test]
7990    fn test_tick_count_per_ms_none_for_single_tick() {
7991        use rust_decimal_macros::dec;
7992        let t = make_tick_pq(dec!(100), dec!(1));
7993        assert!(NormalizedTick::tick_count_per_ms(&[t]).is_none());
7994    }
7995
7996    #[test]
7997    fn test_buy_quantity_fraction_none_for_empty() {
7998        assert!(NormalizedTick::buy_quantity_fraction(&[]).is_none());
7999    }
8000
8001    #[test]
8002    fn test_buy_quantity_fraction_one_for_all_buys() {
8003        use rust_decimal_macros::dec;
8004        let mut t = make_tick_pq(dec!(100), dec!(5));
8005        t.side = Some(crate::tick::TradeSide::Buy);
8006        let f = NormalizedTick::buy_quantity_fraction(&[t]).unwrap();
8007        assert!((f - 1.0).abs() < 1e-9, "all buys → buy fraction=1, got {}", f);
8008    }
8009
8010    #[test]
8011    fn test_sell_quantity_fraction_none_for_empty() {
8012        assert!(NormalizedTick::sell_quantity_fraction(&[]).is_none());
8013    }
8014
8015    #[test]
8016    fn test_sell_quantity_fraction_one_for_all_sells() {
8017        use rust_decimal_macros::dec;
8018        let mut t = make_tick_pq(dec!(100), dec!(5));
8019        t.side = Some(crate::tick::TradeSide::Sell);
8020        let f = NormalizedTick::sell_quantity_fraction(&[t]).unwrap();
8021        assert!((f - 1.0).abs() < 1e-9, "all sells → sell fraction=1, got {}", f);
8022    }
8023
8024    #[test]
8025    fn test_price_mean_crossover_count_none_for_single_tick() {
8026        use rust_decimal_macros::dec;
8027        let t = make_tick_pq(dec!(100), dec!(1));
8028        assert!(NormalizedTick::price_mean_crossover_count(&[t]).is_none());
8029    }
8030
8031    #[test]
8032    fn test_price_mean_crossover_count_in_range() {
8033        use rust_decimal_macros::dec;
8034        let ticks = vec![
8035            make_tick_pq(dec!(90), dec!(1)),
8036            make_tick_pq(dec!(110), dec!(1)),
8037            make_tick_pq(dec!(90), dec!(1)),
8038        ];
8039        let c = NormalizedTick::price_mean_crossover_count(&ticks).unwrap();
8040        assert!(c >= 1, "expect at least 1 crossover, got {}", c);
8041    }
8042
8043    #[test]
8044    fn test_notional_skewness_none_for_two_ticks() {
8045        use rust_decimal_macros::dec;
8046        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(1))];
8047        assert!(NormalizedTick::notional_skewness(&ticks).is_none());
8048    }
8049
8050    #[test]
8051    fn test_volume_weighted_mid_price_none_for_empty() {
8052        assert!(NormalizedTick::volume_weighted_mid_price(&[]).is_none());
8053    }
8054
8055    #[test]
8056    fn test_volume_weighted_mid_price_equals_price_for_single_tick() {
8057        use rust_decimal_macros::dec;
8058        let t = make_tick_pq(dec!(123), dec!(5));
8059        let mid = NormalizedTick::volume_weighted_mid_price(&[t]).unwrap();
8060        assert_eq!(mid, dec!(123));
8061    }
8062
8063    // ── round-85 tests ────────────────────────────────────────────────────────
8064
8065    #[test]
8066    fn test_neutral_count_zero_when_all_sided() {
8067        use rust_decimal_macros::dec;
8068        let mut t = make_tick_pq(dec!(100), dec!(1));
8069        t.side = Some(crate::tick::TradeSide::Buy);
8070        assert_eq!(NormalizedTick::neutral_count(&[t]), 0);
8071    }
8072
8073    #[test]
8074    fn test_neutral_count_all_when_no_side() {
8075        use rust_decimal_macros::dec;
8076        let t1 = make_tick_pq(dec!(100), dec!(1));
8077        let t2 = make_tick_pq(dec!(101), dec!(1));
8078        assert_eq!(NormalizedTick::neutral_count(&[t1, t2]), 2);
8079    }
8080
8081    #[test]
8082    fn test_price_dispersion_none_for_empty() {
8083        assert!(NormalizedTick::price_dispersion(&[]).is_none());
8084    }
8085
8086    #[test]
8087    fn test_price_dispersion_zero_for_single() {
8088        use rust_decimal_macros::dec;
8089        let t = make_tick_pq(dec!(100), dec!(1));
8090        assert_eq!(NormalizedTick::price_dispersion(&[t]).unwrap(), dec!(0));
8091    }
8092
8093    #[test]
8094    fn test_max_notional_none_for_empty() {
8095        assert!(NormalizedTick::max_notional(&[]).is_none());
8096    }
8097
8098    #[test]
8099    fn test_max_notional_selects_largest() {
8100        use rust_decimal_macros::dec;
8101        let t1 = make_tick_pq(dec!(100), dec!(2)); // 200
8102        let t2 = make_tick_pq(dec!(50), dec!(5));  // 250
8103        assert_eq!(NormalizedTick::max_notional(&[t1, t2]).unwrap(), dec!(250));
8104    }
8105
8106    #[test]
8107    fn test_min_notional_none_for_empty() {
8108        assert!(NormalizedTick::min_notional(&[]).is_none());
8109    }
8110
8111    #[test]
8112    fn test_below_vwap_fraction_none_for_empty() {
8113        assert!(NormalizedTick::below_vwap_fraction(&[]).is_none());
8114    }
8115
8116    #[test]
8117    fn test_trade_notional_std_none_for_single() {
8118        use rust_decimal_macros::dec;
8119        let t = make_tick_pq(dec!(100), dec!(1));
8120        assert!(NormalizedTick::trade_notional_std(&[t]).is_none());
8121    }
8122
8123    #[test]
8124    fn test_buy_sell_count_ratio_none_for_no_sells() {
8125        use rust_decimal_macros::dec;
8126        let mut t = make_tick_pq(dec!(100), dec!(1));
8127        t.side = Some(crate::tick::TradeSide::Buy);
8128        assert!(NormalizedTick::buy_sell_count_ratio(&[t]).is_none());
8129    }
8130
8131    #[test]
8132    fn test_buy_sell_count_ratio_correct() {
8133        use rust_decimal_macros::dec;
8134        let mut t1 = make_tick_pq(dec!(100), dec!(1));
8135        t1.side = Some(crate::tick::TradeSide::Buy);
8136        let mut t2 = make_tick_pq(dec!(100), dec!(1));
8137        t2.side = Some(crate::tick::TradeSide::Sell);
8138        let r = NormalizedTick::buy_sell_count_ratio(&[t1, t2]).unwrap();
8139        assert!((r - 1.0).abs() < 1e-9, "1 buy / 1 sell = 1.0, got {}", r);
8140    }
8141
8142    #[test]
8143    fn test_price_mad_none_for_empty() {
8144        assert!(NormalizedTick::price_mad(&[]).is_none());
8145    }
8146
8147    #[test]
8148    fn test_price_mad_zero_for_constant_price() {
8149        use rust_decimal_macros::dec;
8150        let ticks = vec![
8151            make_tick_pq(dec!(100), dec!(1)),
8152            make_tick_pq(dec!(100), dec!(2)),
8153        ];
8154        let m = NormalizedTick::price_mad(&ticks).unwrap();
8155        assert!(m.abs() < 1e-9, "constant price → MAD=0, got {}", m);
8156    }
8157
8158    #[test]
8159    fn test_price_range_pct_of_open_none_for_empty() {
8160        assert!(NormalizedTick::price_range_pct_of_open(&[]).is_none());
8161    }
8162
8163    #[test]
8164    fn test_price_range_pct_of_open_zero_for_constant() {
8165        use rust_decimal_macros::dec;
8166        let ticks = vec![
8167            make_tick_pq(dec!(100), dec!(1)),
8168            make_tick_pq(dec!(100), dec!(1)),
8169        ];
8170        let p = NormalizedTick::price_range_pct_of_open(&ticks).unwrap();
8171        assert!(p.abs() < 1e-9, "constant → range_pct=0, got {}", p);
8172    }
8173
8174    // ── round-86 tests ────────────────────────────────────────────────────────
8175
8176    #[test]
8177    fn test_price_mean_none_for_empty() {
8178        assert!(NormalizedTick::price_mean(&[]).is_none());
8179    }
8180
8181    #[test]
8182    fn test_price_mean_correct() {
8183        use rust_decimal_macros::dec;
8184        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(200), dec!(1))];
8185        assert_eq!(NormalizedTick::price_mean(&ticks).unwrap(), dec!(150));
8186    }
8187
8188    #[test]
8189    fn test_uptick_count_zero_for_single() {
8190        use rust_decimal_macros::dec;
8191        let t = make_tick_pq(dec!(100), dec!(1));
8192        assert_eq!(NormalizedTick::uptick_count(&[t]), 0);
8193    }
8194
8195    #[test]
8196    fn test_uptick_count_correct() {
8197        use rust_decimal_macros::dec;
8198        let ticks = vec![
8199            make_tick_pq(dec!(100), dec!(1)),
8200            make_tick_pq(dec!(101), dec!(1)),
8201            make_tick_pq(dec!(100), dec!(1)),
8202        ];
8203        assert_eq!(NormalizedTick::uptick_count(&ticks), 1);
8204    }
8205
8206    #[test]
8207    fn test_downtick_count_zero_for_all_up() {
8208        use rust_decimal_macros::dec;
8209        let ticks = vec![
8210            make_tick_pq(dec!(100), dec!(1)),
8211            make_tick_pq(dec!(101), dec!(1)),
8212            make_tick_pq(dec!(102), dec!(1)),
8213        ];
8214        assert_eq!(NormalizedTick::downtick_count(&ticks), 0);
8215    }
8216
8217    #[test]
8218    fn test_uptick_fraction_none_for_single() {
8219        use rust_decimal_macros::dec;
8220        let t = make_tick_pq(dec!(100), dec!(1));
8221        assert!(NormalizedTick::uptick_fraction(&[t]).is_none());
8222    }
8223
8224    #[test]
8225    fn test_quantity_std_none_for_single() {
8226        use rust_decimal_macros::dec;
8227        let t = make_tick_pq(dec!(100), dec!(1));
8228        assert!(NormalizedTick::quantity_std(&[t]).is_none());
8229    }
8230
8231    #[test]
8232    fn test_quantity_std_zero_for_constant_qty() {
8233        use rust_decimal_macros::dec;
8234        let ticks = vec![
8235            make_tick_pq(dec!(100), dec!(5)),
8236            make_tick_pq(dec!(101), dec!(5)),
8237        ];
8238        let s = NormalizedTick::quantity_std(&ticks).unwrap();
8239        assert!(s.abs() < 1e-9, "constant quantity → std=0, got {}", s);
8240    }
8241
8242    // ── round-87 tests ────────────────────────────────────────────────────────
8243
8244    #[test]
8245    fn test_vwap_deviation_std_none_for_single() {
8246        use rust_decimal_macros::dec;
8247        let t = make_tick_pq(dec!(100), dec!(1));
8248        assert!(NormalizedTick::vwap_deviation_std(&[t]).is_none());
8249    }
8250
8251    #[test]
8252    fn test_vwap_deviation_std_zero_for_single_price() {
8253        use rust_decimal_macros::dec;
8254        let ticks = vec![
8255            make_tick_pq(dec!(100), dec!(1)),
8256            make_tick_pq(dec!(100), dec!(2)),
8257        ];
8258        // All prices equal VWAP, so deviation std = 0
8259        let s = NormalizedTick::vwap_deviation_std(&ticks).unwrap();
8260        assert!(s.abs() < 1e-9, "all at VWAP → std=0, got {}", s);
8261    }
8262
8263    #[test]
8264    fn test_vwap_deviation_std_positive_for_varied_prices() {
8265        use rust_decimal_macros::dec;
8266        let ticks = vec![
8267            make_tick_pq(dec!(100), dec!(1)),
8268            make_tick_pq(dec!(110), dec!(1)),
8269            make_tick_pq(dec!(90), dec!(1)),
8270        ];
8271        let s = NormalizedTick::vwap_deviation_std(&ticks).unwrap();
8272        assert!(s > 0.0, "varied prices → std > 0, got {}", s);
8273    }
8274
8275    #[test]
8276    fn test_max_consecutive_side_run_zero_for_no_side() {
8277        use rust_decimal_macros::dec;
8278        let ticks = vec![
8279            make_tick_pq(dec!(100), dec!(1)),
8280            make_tick_pq(dec!(101), dec!(1)),
8281        ];
8282        assert_eq!(NormalizedTick::max_consecutive_side_run(&ticks), 0);
8283    }
8284
8285    #[test]
8286    fn test_max_consecutive_side_run_with_sides() {
8287        use rust_decimal_macros::dec;
8288        let mut t1 = make_tick_pq(dec!(100), dec!(1));
8289        t1.side = Some(TradeSide::Buy);
8290        let mut t2 = make_tick_pq(dec!(101), dec!(1));
8291        t2.side = Some(TradeSide::Buy);
8292        let mut t3 = make_tick_pq(dec!(102), dec!(1));
8293        t3.side = Some(TradeSide::Sell);
8294        assert_eq!(NormalizedTick::max_consecutive_side_run(&[t1, t2, t3]), 2);
8295    }
8296
8297    #[test]
8298    fn test_inter_arrival_cv_none_for_single() {
8299        use rust_decimal_macros::dec;
8300        let t = make_tick_pq(dec!(100), dec!(1));
8301        assert!(NormalizedTick::inter_arrival_cv(&[t]).is_none());
8302    }
8303
8304    #[test]
8305    fn test_inter_arrival_cv_zero_for_uniform_spacing() {
8306        use rust_decimal_macros::dec;
8307        let mut t1 = make_tick_pq(dec!(100), dec!(1));
8308        t1.received_at_ms = 1000;
8309        let mut t2 = make_tick_pq(dec!(101), dec!(1));
8310        t2.received_at_ms = 2000;
8311        let mut t3 = make_tick_pq(dec!(102), dec!(1));
8312        t3.received_at_ms = 3000;
8313        // All intervals = 1000ms → std=0, cv=0
8314        let cv = NormalizedTick::inter_arrival_cv(&[t1, t2, t3]).unwrap();
8315        assert!(cv.abs() < 1e-9, "uniform spacing → cv=0, got {}", cv);
8316    }
8317
8318    #[test]
8319    fn test_volume_per_ms_none_for_single() {
8320        use rust_decimal_macros::dec;
8321        let t = make_tick_pq(dec!(100), dec!(5));
8322        assert!(NormalizedTick::volume_per_ms(&[t]).is_none());
8323    }
8324
8325    #[test]
8326    fn test_volume_per_ms_correct() {
8327        use rust_decimal_macros::dec;
8328        let mut t1 = make_tick_pq(dec!(100), dec!(5));
8329        t1.received_at_ms = 1000;
8330        let mut t2 = make_tick_pq(dec!(101), dec!(5));
8331        t2.received_at_ms = 2000;
8332        // 10 qty / 1000 ms = 0.01
8333        let r = NormalizedTick::volume_per_ms(&[t1, t2]).unwrap();
8334        assert!((r - 0.01).abs() < 1e-9, "expected 0.01, got {}", r);
8335    }
8336
8337    #[test]
8338    fn test_notional_per_second_none_for_single() {
8339        use rust_decimal_macros::dec;
8340        let t = make_tick_pq(dec!(100), dec!(1));
8341        assert!(NormalizedTick::notional_per_second(&[t]).is_none());
8342    }
8343
8344    #[test]
8345    fn test_notional_per_second_positive() {
8346        use rust_decimal_macros::dec;
8347        let mut t1 = make_tick_pq(dec!(100), dec!(1));
8348        t1.received_at_ms = 0;
8349        let mut t2 = make_tick_pq(dec!(100), dec!(1));
8350        t2.received_at_ms = 1000; // 1 second
8351        // 100 + 100 = 200 notional in 1s
8352        let r = NormalizedTick::notional_per_second(&[t1, t2]).unwrap();
8353        assert!((r - 200.0).abs() < 1e-9, "expected 200, got {}", r);
8354    }
8355
8356    // ── round-88 tests ────────────────────────────────────────────────────────
8357
8358    #[test]
8359    fn test_order_flow_imbalance_none_for_empty() {
8360        assert!(NormalizedTick::order_flow_imbalance(&[]).is_none());
8361    }
8362
8363    #[test]
8364    fn test_order_flow_imbalance_pos_one_for_all_buys() {
8365        use rust_decimal_macros::dec;
8366        let mut t = make_tick_pq(dec!(100), dec!(5));
8367        t.side = Some(crate::tick::TradeSide::Buy);
8368        let r = NormalizedTick::order_flow_imbalance(&[t]).unwrap();
8369        assert!((r - 1.0).abs() < 1e-9, "all buys → OFI=+1, got {}", r);
8370    }
8371
8372    #[test]
8373    fn test_order_flow_imbalance_neg_one_for_all_sells() {
8374        use rust_decimal_macros::dec;
8375        let mut t = make_tick_pq(dec!(100), dec!(5));
8376        t.side = Some(crate::tick::TradeSide::Sell);
8377        let r = NormalizedTick::order_flow_imbalance(&[t]).unwrap();
8378        assert!((r + 1.0).abs() < 1e-9, "all sells → OFI=-1, got {}", r);
8379    }
8380
8381    #[test]
8382    fn test_price_qty_up_fraction_none_for_single() {
8383        use rust_decimal_macros::dec;
8384        let t = make_tick_pq(dec!(100), dec!(1));
8385        assert!(NormalizedTick::price_qty_up_fraction(&[t]).is_none());
8386    }
8387
8388    #[test]
8389    fn test_running_high_count_single_tick() {
8390        use rust_decimal_macros::dec;
8391        let t = make_tick_pq(dec!(100), dec!(1));
8392        assert_eq!(NormalizedTick::running_high_count(&[t]), 1);
8393    }
8394
8395    #[test]
8396    fn test_running_low_count_single_tick() {
8397        use rust_decimal_macros::dec;
8398        let t = make_tick_pq(dec!(100), dec!(1));
8399        assert_eq!(NormalizedTick::running_low_count(&[t]), 1);
8400    }
8401
8402    #[test]
8403    fn test_buy_sell_avg_qty_ratio_none_for_no_sells() {
8404        use rust_decimal_macros::dec;
8405        let mut t = make_tick_pq(dec!(100), dec!(5));
8406        t.side = Some(crate::tick::TradeSide::Buy);
8407        assert!(NormalizedTick::buy_sell_avg_qty_ratio(&[t]).is_none());
8408    }
8409
8410    #[test]
8411    fn test_max_price_drop_none_for_single() {
8412        use rust_decimal_macros::dec;
8413        let t = make_tick_pq(dec!(100), dec!(1));
8414        assert!(NormalizedTick::max_price_drop(&[t]).is_none());
8415    }
8416
8417    #[test]
8418    fn test_max_price_rise_none_for_single() {
8419        use rust_decimal_macros::dec;
8420        let t = make_tick_pq(dec!(100), dec!(1));
8421        assert!(NormalizedTick::max_price_rise(&[t]).is_none());
8422    }
8423
8424    #[test]
8425    fn test_max_price_drop_correct() {
8426        use rust_decimal_macros::dec;
8427        let ticks = vec![
8428            make_tick_pq(dec!(100), dec!(1)),
8429            make_tick_pq(dec!(90), dec!(1)),  // drop = 10
8430            make_tick_pq(dec!(95), dec!(1)),  // rise
8431        ];
8432        assert_eq!(NormalizedTick::max_price_drop(&ticks).unwrap(), dec!(10));
8433    }
8434
8435    #[test]
8436    fn test_max_price_rise_correct() {
8437        use rust_decimal_macros::dec;
8438        let ticks = vec![
8439            make_tick_pq(dec!(90), dec!(1)),
8440            make_tick_pq(dec!(105), dec!(1)),  // rise = 15
8441            make_tick_pq(dec!(100), dec!(1)),
8442        ];
8443        assert_eq!(NormalizedTick::max_price_rise(&ticks).unwrap(), dec!(15));
8444    }
8445
8446    #[test]
8447    fn test_buy_trade_count_zero_for_no_sides() {
8448        use rust_decimal_macros::dec;
8449        let t = make_tick_pq(dec!(100), dec!(1));
8450        assert_eq!(NormalizedTick::buy_trade_count(&[t]), 0);
8451    }
8452
8453    #[test]
8454    fn test_buy_trade_count_correct() {
8455        use rust_decimal_macros::dec;
8456        let mut t1 = make_tick_pq(dec!(100), dec!(1));
8457        t1.side = Some(TradeSide::Buy);
8458        let mut t2 = make_tick_pq(dec!(100), dec!(1));
8459        t2.side = Some(TradeSide::Sell);
8460        assert_eq!(NormalizedTick::buy_trade_count(&[t1, t2]), 1);
8461    }
8462
8463    #[test]
8464    fn test_sell_trade_count_correct() {
8465        use rust_decimal_macros::dec;
8466        let mut t1 = make_tick_pq(dec!(100), dec!(1));
8467        t1.side = Some(TradeSide::Buy);
8468        let mut t2 = make_tick_pq(dec!(100), dec!(1));
8469        t2.side = Some(TradeSide::Sell);
8470        assert_eq!(NormalizedTick::sell_trade_count(&[t1, t2]), 1);
8471    }
8472
8473    #[test]
8474    fn test_price_reversal_fraction_none_for_two_ticks() {
8475        use rust_decimal_macros::dec;
8476        let t1 = make_tick_pq(dec!(100), dec!(1));
8477        let t2 = make_tick_pq(dec!(101), dec!(1));
8478        assert!(NormalizedTick::price_reversal_fraction(&[t1, t2]).is_none());
8479    }
8480
8481    #[test]
8482    fn test_price_reversal_fraction_one_for_zigzag() {
8483        use rust_decimal_macros::dec;
8484        // up-down-up: 2 reversals out of 2 pairs → 1.0
8485        let ticks = vec![
8486            make_tick_pq(dec!(100), dec!(1)),
8487            make_tick_pq(dec!(110), dec!(1)),
8488            make_tick_pq(dec!(105), dec!(1)),
8489            make_tick_pq(dec!(115), dec!(1)),
8490        ];
8491        let f = NormalizedTick::price_reversal_fraction(&ticks).unwrap();
8492        assert!((f - 1.0).abs() < 1e-9, "perfect zigzag → 1.0, got {}", f);
8493    }
8494
8495    // ── round-89 tests ────────────────────────────────────────────────────────
8496
8497    #[test]
8498    fn test_near_vwap_fraction_none_for_empty() {
8499        use rust_decimal_macros::dec;
8500        assert!(NormalizedTick::near_vwap_fraction(&[], dec!(1)).is_none());
8501    }
8502
8503    #[test]
8504    fn test_near_vwap_fraction_one_for_all_at_vwap() {
8505        use rust_decimal_macros::dec;
8506        // All ticks at same price → price == VWAP, band = 0 → fraction = 1
8507        let ticks = vec![
8508            make_tick_pq(dec!(100), dec!(1)),
8509            make_tick_pq(dec!(100), dec!(1)),
8510        ];
8511        let f = NormalizedTick::near_vwap_fraction(&ticks, dec!(0)).unwrap();
8512        assert!((f - 1.0).abs() < 1e-9, "all at VWAP → 1.0, got {}", f);
8513    }
8514
8515    #[test]
8516    fn test_mean_tick_return_none_for_single() {
8517        use rust_decimal_macros::dec;
8518        let t = make_tick_pq(dec!(100), dec!(1));
8519        assert!(NormalizedTick::mean_tick_return(&[t]).is_none());
8520    }
8521
8522    #[test]
8523    fn test_mean_tick_return_zero_for_constant_price() {
8524        use rust_decimal_macros::dec;
8525        let ticks = vec![
8526            make_tick_pq(dec!(100), dec!(1)),
8527            make_tick_pq(dec!(100), dec!(1)),
8528            make_tick_pq(dec!(100), dec!(1)),
8529        ];
8530        let r = NormalizedTick::mean_tick_return(&ticks).unwrap();
8531        assert!(r.abs() < 1e-9, "constant price → mean_return=0, got {}", r);
8532    }
8533
8534    #[test]
8535    fn test_passive_buy_count_zero_for_no_sides() {
8536        use rust_decimal_macros::dec;
8537        let t = make_tick_pq(dec!(100), dec!(1));
8538        assert_eq!(NormalizedTick::passive_buy_count(&[t]), 0);
8539    }
8540
8541    #[test]
8542    fn test_quantity_iqr_none_for_small_slice() {
8543        use rust_decimal_macros::dec;
8544        let ticks = vec![
8545            make_tick_pq(dec!(100), dec!(1)),
8546            make_tick_pq(dec!(101), dec!(2)),
8547        ];
8548        assert!(NormalizedTick::quantity_iqr(&ticks).is_none());
8549    }
8550
8551    #[test]
8552    fn test_quantity_iqr_positive_for_varied_quantities() {
8553        use rust_decimal_macros::dec;
8554        let ticks: Vec<_> = [dec!(1), dec!(2), dec!(8), dec!(16), dec!(32), dec!(64), dec!(128), dec!(256)]
8555            .iter()
8556            .map(|&q| make_tick_pq(dec!(100), q))
8557            .collect();
8558        let iqr = NormalizedTick::quantity_iqr(&ticks).unwrap();
8559        assert!(iqr > dec!(0));
8560    }
8561
8562    #[test]
8563    fn test_top_quartile_price_fraction_none_for_small_slice() {
8564        use rust_decimal_macros::dec;
8565        let ticks = vec![
8566            make_tick_pq(dec!(100), dec!(1)),
8567            make_tick_pq(dec!(101), dec!(1)),
8568        ];
8569        assert!(NormalizedTick::top_quartile_price_fraction(&ticks).is_none());
8570    }
8571
8572    #[test]
8573    fn test_buy_notional_ratio_none_for_empty() {
8574        assert!(NormalizedTick::buy_notional_ratio(&[]).is_none());
8575    }
8576
8577    #[test]
8578    fn test_buy_notional_ratio_one_for_all_buys() {
8579        use rust_decimal_macros::dec;
8580        let mut t = make_tick_pq(dec!(100), dec!(1));
8581        t.side = Some(TradeSide::Buy);
8582        let r = NormalizedTick::buy_notional_ratio(&[t]).unwrap();
8583        assert!((r - 1.0).abs() < 1e-9, "all buys → ratio=1, got {}", r);
8584    }
8585
8586    #[test]
8587    fn test_return_std_none_for_two_ticks() {
8588        use rust_decimal_macros::dec;
8589        let t1 = make_tick_pq(dec!(100), dec!(1));
8590        let t2 = make_tick_pq(dec!(101), dec!(1));
8591        assert!(NormalizedTick::return_std(&[t1, t2]).is_none());
8592    }
8593
8594    #[test]
8595    fn test_return_std_zero_for_constant_price() {
8596        use rust_decimal_macros::dec;
8597        let ticks = vec![
8598            make_tick_pq(dec!(100), dec!(1)),
8599            make_tick_pq(dec!(100), dec!(1)),
8600            make_tick_pq(dec!(100), dec!(1)),
8601        ];
8602        let s = NormalizedTick::return_std(&ticks).unwrap();
8603        assert!(s.abs() < 1e-9, "constant price → return_std=0, got {}", s);
8604    }
8605
8606    // ── round-90 tests ────────────────────────────────────────────────────────
8607
8608    #[test]
8609    fn test_max_drawdown_none_for_empty() {
8610        assert!(NormalizedTick::max_drawdown(&[]).is_none());
8611    }
8612
8613    #[test]
8614    fn test_max_drawdown_zero_for_rising_prices() {
8615        use rust_decimal_macros::dec;
8616        let ticks = vec![
8617            make_tick_pq(dec!(100), dec!(1)),
8618            make_tick_pq(dec!(110), dec!(1)),
8619            make_tick_pq(dec!(120), dec!(1)),
8620        ];
8621        let dd = NormalizedTick::max_drawdown(&ticks).unwrap();
8622        assert!(dd.abs() < 1e-9, "monotone rise → drawdown=0, got {}", dd);
8623    }
8624
8625    #[test]
8626    fn test_max_drawdown_positive_after_peak() {
8627        use rust_decimal_macros::dec;
8628        let ticks = vec![
8629            make_tick_pq(dec!(100), dec!(1)),
8630            make_tick_pq(dec!(120), dec!(1)),
8631            make_tick_pq(dec!(90), dec!(1)),
8632        ];
8633        let dd = NormalizedTick::max_drawdown(&ticks).unwrap();
8634        // peak=120, trough=90 → dd = 30/120 = 0.25
8635        assert!((dd - 0.25).abs() < 1e-6, "expected 0.25, got {}", dd);
8636    }
8637
8638    #[test]
8639    fn test_high_to_low_ratio_none_for_empty() {
8640        assert!(NormalizedTick::high_to_low_ratio(&[]).is_none());
8641    }
8642
8643    #[test]
8644    fn test_high_to_low_ratio_one_for_constant_price() {
8645        use rust_decimal_macros::dec;
8646        let ticks = vec![
8647            make_tick_pq(dec!(100), dec!(1)),
8648            make_tick_pq(dec!(100), dec!(1)),
8649        ];
8650        let r = NormalizedTick::high_to_low_ratio(&ticks).unwrap();
8651        assert!((r - 1.0).abs() < 1e-9, "constant price → ratio=1, got {}", r);
8652    }
8653
8654    #[test]
8655    fn test_tick_velocity_none_for_single_tick() {
8656        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
8657        assert!(NormalizedTick::tick_velocity(&[t]).is_none());
8658    }
8659
8660    #[test]
8661    fn test_notional_decay_none_for_single_tick() {
8662        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
8663        assert!(NormalizedTick::notional_decay(&[t]).is_none());
8664    }
8665
8666    #[test]
8667    fn test_notional_decay_one_for_balanced_halves() {
8668        use rust_decimal_macros::dec;
8669        let t1 = make_tick_pq(dec!(100), dec!(1));
8670        let t2 = make_tick_pq(dec!(100), dec!(1));
8671        let r = NormalizedTick::notional_decay(&[t1, t2]).unwrap();
8672        assert!((r - 1.0).abs() < 1e-9, "equal halves → ratio=1, got {}", r);
8673    }
8674
8675    #[test]
8676    fn test_late_price_momentum_none_for_single_tick() {
8677        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
8678        assert!(NormalizedTick::late_price_momentum(&[t]).is_none());
8679    }
8680
8681    #[test]
8682    fn test_consecutive_buys_max_zero_for_empty() {
8683        assert_eq!(NormalizedTick::consecutive_buys_max(&[]), 0);
8684    }
8685
8686    #[test]
8687    fn test_consecutive_buys_max_two_for_run_of_two() {
8688        use rust_decimal_macros::dec;
8689        let mut buy1 = make_tick_pq(dec!(100), dec!(1));
8690        buy1.side = Some(TradeSide::Buy);
8691        let mut buy2 = make_tick_pq(dec!(101), dec!(1));
8692        buy2.side = Some(TradeSide::Buy);
8693        let mut sell = make_tick_pq(dec!(102), dec!(1));
8694        sell.side = Some(TradeSide::Sell);
8695        assert_eq!(NormalizedTick::consecutive_buys_max(&[buy1, buy2, sell]), 2);
8696    }
8697}