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    // ── round-91 ─────────────────────────────────────────────────────────────
3569
3570    /// Fraction of ticks where quantity exceeds the mean quantity across the slice.
3571    ///
3572    /// Returns `None` for an empty slice.
3573    pub fn above_mean_qty_fraction(ticks: &[NormalizedTick]) -> Option<f64> {
3574        if ticks.is_empty() {
3575            return None;
3576        }
3577        let n = ticks.len() as u32;
3578        let mean_qty: Decimal =
3579            ticks.iter().map(|t| t.quantity).sum::<Decimal>() / Decimal::from(n);
3580        let count = ticks.iter().filter(|t| t.quantity > mean_qty).count();
3581        Some(count as f64 / ticks.len() as f64)
3582    }
3583
3584    /// Fraction of consecutive tick pairs where the trade side alternates.
3585    ///
3586    /// Only pairs where both ticks carry a known side are counted.
3587    /// Returns `None` when fewer than 2 side-annotated ticks are present.
3588    pub fn side_alternation_rate(ticks: &[NormalizedTick]) -> Option<f64> {
3589        let sided: Vec<TradeSide> = ticks.iter().filter_map(|t| t.side).collect();
3590        if sided.len() < 2 {
3591            return None;
3592        }
3593        let alternations = sided.windows(2).filter(|w| w[0] != w[1]).count();
3594        Some(alternations as f64 / (sided.len() - 1) as f64)
3595    }
3596
3597    /// Price range per tick: `(max_price − min_price) / tick_count`.
3598    ///
3599    /// Returns `None` for an empty slice.
3600    pub fn price_range_per_tick(ticks: &[NormalizedTick]) -> Option<f64> {
3601        use rust_decimal::prelude::ToPrimitive;
3602        if ticks.is_empty() {
3603            return None;
3604        }
3605        let high = ticks.iter().map(|t| t.price).max()?;
3606        let low = ticks.iter().map(|t| t.price).min()?;
3607        let range = (high - low).to_f64()?;
3608        Some(range / ticks.len() as f64)
3609    }
3610
3611    /// Quantity-weighted standard deviation of price (weighted σ around VWAP).
3612    ///
3613    /// Returns `None` for an empty slice or zero total quantity.
3614    pub fn qty_weighted_price_std(ticks: &[NormalizedTick]) -> Option<f64> {
3615        use rust_decimal::prelude::ToPrimitive;
3616        if ticks.is_empty() {
3617            return None;
3618        }
3619        let total_qty: Decimal = ticks.iter().map(|t| t.quantity).sum();
3620        if total_qty.is_zero() {
3621            return None;
3622        }
3623        let vwap: Decimal =
3624            ticks.iter().map(|t| t.price * t.quantity).sum::<Decimal>() / total_qty;
3625        let total_qty_f = total_qty.to_f64()?;
3626        let variance: f64 = ticks
3627            .iter()
3628            .filter_map(|t| {
3629                let diff = (t.price - vwap).to_f64()?;
3630                let w = t.quantity.to_f64()?;
3631                Some(w * diff * diff)
3632            })
3633            .sum::<f64>()
3634            / total_qty_f;
3635        Some(variance.sqrt())
3636    }
3637
3638}
3639
3640
3641impl std::fmt::Display for NormalizedTick {
3642    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3643        let side = match self.side {
3644            Some(s) => s.to_string(),
3645            None => "?".to_string(),
3646        };
3647        write!(
3648            f,
3649            "{} {} {} x {} {} @{}ms",
3650            self.exchange, self.symbol, self.price, self.quantity, side, self.received_at_ms
3651        )
3652    }
3653}
3654
3655/// Normalizes raw ticks from any supported exchange into [`NormalizedTick`] form.
3656///
3657/// `TickNormalizer` is stateless and cheap to clone; a single instance can be
3658/// shared across threads via `Arc` or constructed per-task.
3659pub struct TickNormalizer;
3660
3661impl TickNormalizer {
3662    /// Create a new normalizer. This is a zero-cost constructor.
3663    pub fn new() -> Self {
3664        Self
3665    }
3666
3667    /// Normalize a raw tick into canonical form.
3668    ///
3669    /// # Errors
3670    ///
3671    /// Returns [`StreamError::ParseError`] if required fields are missing or
3672    /// malformed, and [`StreamError::InvalidTick`] if price is not positive or
3673    /// quantity is negative.
3674    pub fn normalize(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
3675        let tick = match raw.exchange {
3676            Exchange::Binance => self.normalize_binance(raw),
3677            Exchange::Coinbase => self.normalize_coinbase(raw),
3678            Exchange::Alpaca => self.normalize_alpaca(raw),
3679            Exchange::Polygon => self.normalize_polygon(raw),
3680        }?;
3681        if tick.price <= Decimal::ZERO {
3682            return Err(StreamError::InvalidTick {
3683                reason: format!("price must be positive, got {}", tick.price),
3684            });
3685        }
3686        if tick.quantity < Decimal::ZERO {
3687            return Err(StreamError::InvalidTick {
3688                reason: format!("quantity must be non-negative, got {}", tick.quantity),
3689            });
3690        }
3691        trace!(
3692            exchange = %tick.exchange,
3693            symbol = %tick.symbol,
3694            price = %tick.price,
3695            exchange_ts_ms = ?tick.exchange_ts_ms,
3696            "tick normalized"
3697        );
3698        Ok(tick)
3699    }
3700
3701    fn normalize_binance(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
3702        let p = &raw.payload;
3703        let price = parse_decimal_field(p, "p", &raw.exchange.to_string())?;
3704        let qty = parse_decimal_field(p, "q", &raw.exchange.to_string())?;
3705        let side = p.get("m").and_then(|v| v.as_bool()).map(|maker| {
3706            if maker {
3707                TradeSide::Sell
3708            } else {
3709                TradeSide::Buy
3710            }
3711        });
3712        let trade_id = p.get("t").and_then(|v| v.as_u64()).map(|id| id.to_string());
3713        let exchange_ts = p.get("T").and_then(|v| v.as_u64());
3714        Ok(NormalizedTick {
3715            exchange: raw.exchange,
3716            symbol: raw.symbol,
3717            price,
3718            quantity: qty,
3719            side,
3720            trade_id,
3721            exchange_ts_ms: exchange_ts,
3722            received_at_ms: raw.received_at_ms,
3723        })
3724    }
3725
3726    fn normalize_coinbase(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
3727        let p = &raw.payload;
3728        let price = parse_decimal_field(p, "price", &raw.exchange.to_string())?;
3729        let qty = parse_decimal_field(p, "size", &raw.exchange.to_string())?;
3730        let side = p.get("side").and_then(|v| v.as_str()).map(|s| {
3731            if s == "buy" {
3732                TradeSide::Buy
3733            } else {
3734                TradeSide::Sell
3735            }
3736        });
3737        let trade_id = p
3738            .get("trade_id")
3739            .and_then(|v| v.as_str())
3740            .map(str::to_string);
3741        // Coinbase Advanced Trade sends an ISO 8601 timestamp in the "time" field.
3742        let exchange_ts_ms = p
3743            .get("time")
3744            .and_then(|v| v.as_str())
3745            .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
3746            .map(|dt| dt.timestamp_millis() as u64);
3747        Ok(NormalizedTick {
3748            exchange: raw.exchange,
3749            symbol: raw.symbol,
3750            price,
3751            quantity: qty,
3752            side,
3753            trade_id,
3754            exchange_ts_ms,
3755            received_at_ms: raw.received_at_ms,
3756        })
3757    }
3758
3759    fn normalize_alpaca(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
3760        let p = &raw.payload;
3761        let price = parse_decimal_field(p, "p", &raw.exchange.to_string())?;
3762        let qty = parse_decimal_field(p, "s", &raw.exchange.to_string())?;
3763        let trade_id = p.get("i").and_then(|v| v.as_u64()).map(|id| id.to_string());
3764        // Alpaca sends RFC 3339 timestamps in the "t" field (e.g. "2023-11-15T10:00:00.000Z").
3765        let exchange_ts_ms = p
3766            .get("t")
3767            .and_then(|v| v.as_str())
3768            .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
3769            .map(|dt| dt.timestamp_millis() as u64);
3770        Ok(NormalizedTick {
3771            exchange: raw.exchange,
3772            symbol: raw.symbol,
3773            price,
3774            quantity: qty,
3775            side: None,
3776            trade_id,
3777            exchange_ts_ms,
3778            received_at_ms: raw.received_at_ms,
3779        })
3780    }
3781
3782    fn normalize_polygon(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
3783        let p = &raw.payload;
3784        let price = parse_decimal_field(p, "p", &raw.exchange.to_string())?;
3785        let qty = parse_decimal_field(p, "s", &raw.exchange.to_string())?;
3786        let trade_id = p.get("i").and_then(|v| v.as_str()).map(str::to_string);
3787        // Polygon sends nanoseconds since epoch in the "t" field; convert to milliseconds.
3788        let exchange_ts = p
3789            .get("t")
3790            .and_then(|v| v.as_u64())
3791            .map(|t_ns| t_ns / 1_000_000);
3792        Ok(NormalizedTick {
3793            exchange: raw.exchange,
3794            symbol: raw.symbol,
3795            price,
3796            quantity: qty,
3797            side: None,
3798            trade_id,
3799            exchange_ts_ms: exchange_ts,
3800            received_at_ms: raw.received_at_ms,
3801        })
3802    }
3803}
3804
3805impl Default for TickNormalizer {
3806    fn default() -> Self {
3807        Self::new()
3808    }
3809}
3810
3811fn parse_decimal_field(
3812    v: &serde_json::Value,
3813    field: &str,
3814    exchange: &str,
3815) -> Result<Decimal, StreamError> {
3816    let raw = v.get(field).ok_or_else(|| StreamError::ParseError {
3817        exchange: exchange.to_string(),
3818        reason: format!("missing field '{}'", field),
3819    })?;
3820    // Use the JSON-native string representation for both string and number
3821    // values. For JSON strings this is a direct parse. For JSON numbers we use
3822    // serde_json::Number::to_string(), which preserves the original text (e.g.
3823    // "50000.12345678") rather than round-tripping through f64 and losing
3824    // sub-microsecond precision.
3825    let s: String = match raw {
3826        serde_json::Value::String(s) => s.clone(),
3827        serde_json::Value::Number(n) => n.to_string(),
3828        _ => {
3829            return Err(StreamError::ParseError {
3830                exchange: exchange.to_string(),
3831                reason: format!("field '{}' is not a string or number", field),
3832            });
3833        }
3834    };
3835    Decimal::from_str(&s).map_err(|e| StreamError::ParseError {
3836        exchange: exchange.to_string(),
3837        reason: format!("field '{}' parse error: {}", field, e),
3838    })
3839}
3840
3841fn now_ms() -> u64 {
3842    std::time::SystemTime::now()
3843        .duration_since(std::time::UNIX_EPOCH)
3844        .map(|d| d.as_millis() as u64)
3845        .unwrap_or(0)
3846}
3847
3848#[cfg(test)]
3849mod tests {
3850    use super::*;
3851    use serde_json::json;
3852
3853    fn normalizer() -> TickNormalizer {
3854        TickNormalizer::new()
3855    }
3856
3857    fn binance_tick(symbol: &str) -> RawTick {
3858        RawTick {
3859            exchange: Exchange::Binance,
3860            symbol: symbol.to_string(),
3861            payload: json!({ "p": "50000.12", "q": "0.001", "m": false, "t": 12345, "T": 1700000000000u64 }),
3862            received_at_ms: 1700000000001,
3863        }
3864    }
3865
3866    fn coinbase_tick(symbol: &str) -> RawTick {
3867        RawTick {
3868            exchange: Exchange::Coinbase,
3869            symbol: symbol.to_string(),
3870            payload: json!({ "price": "50001.00", "size": "0.5", "side": "buy", "trade_id": "abc123" }),
3871            received_at_ms: 1700000000002,
3872        }
3873    }
3874
3875    fn alpaca_tick(symbol: &str) -> RawTick {
3876        RawTick {
3877            exchange: Exchange::Alpaca,
3878            symbol: symbol.to_string(),
3879            payload: json!({ "p": "180.50", "s": "10", "i": 99 }),
3880            received_at_ms: 1700000000003,
3881        }
3882    }
3883
3884    fn polygon_tick(symbol: &str) -> RawTick {
3885        RawTick {
3886            exchange: Exchange::Polygon,
3887            symbol: symbol.to_string(),
3888            // Polygon sends nanoseconds; 1_700_000_000_000_000_000 ns = 1_700_000_000_000 ms
3889            payload: json!({ "p": "180.51", "s": "5", "i": "XYZ-001", "t": 1_700_000_000_000_000_000u64 }),
3890            received_at_ms: 1700000000005,
3891        }
3892    }
3893
3894    #[test]
3895    fn test_exchange_from_str_valid() {
3896        assert_eq!("binance".parse::<Exchange>().unwrap(), Exchange::Binance);
3897        assert_eq!("Coinbase".parse::<Exchange>().unwrap(), Exchange::Coinbase);
3898        assert_eq!("ALPACA".parse::<Exchange>().unwrap(), Exchange::Alpaca);
3899        assert_eq!("polygon".parse::<Exchange>().unwrap(), Exchange::Polygon);
3900    }
3901
3902    #[test]
3903    fn test_exchange_from_str_unknown_returns_error() {
3904        let result = "Kraken".parse::<Exchange>();
3905        assert!(matches!(result, Err(StreamError::UnknownExchange(_))));
3906    }
3907
3908    #[test]
3909    fn test_exchange_display() {
3910        assert_eq!(Exchange::Binance.to_string(), "Binance");
3911        assert_eq!(Exchange::Coinbase.to_string(), "Coinbase");
3912    }
3913
3914    #[test]
3915    fn test_normalize_binance_tick_price_and_qty() {
3916        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
3917        assert_eq!(tick.price, Decimal::from_str("50000.12").unwrap());
3918        assert_eq!(tick.quantity, Decimal::from_str("0.001").unwrap());
3919        assert_eq!(tick.exchange, Exchange::Binance);
3920        assert_eq!(tick.symbol, "BTCUSDT");
3921    }
3922
3923    #[test]
3924    fn test_normalize_binance_side_maker_false_is_buy() {
3925        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
3926        assert_eq!(tick.side, Some(TradeSide::Buy));
3927    }
3928
3929    #[test]
3930    fn test_normalize_binance_side_maker_true_is_sell() {
3931        let raw = RawTick {
3932            exchange: Exchange::Binance,
3933            symbol: "BTCUSDT".into(),
3934            payload: json!({ "p": "50000", "q": "1", "m": true }),
3935            received_at_ms: 0,
3936        };
3937        let tick = normalizer().normalize(raw).unwrap();
3938        assert_eq!(tick.side, Some(TradeSide::Sell));
3939    }
3940
3941    #[test]
3942    fn test_normalize_binance_trade_id_and_ts() {
3943        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
3944        assert_eq!(tick.trade_id, Some("12345".to_string()));
3945        assert_eq!(tick.exchange_ts_ms, Some(1700000000000));
3946    }
3947
3948    #[test]
3949    fn test_normalize_coinbase_tick() {
3950        let tick = normalizer().normalize(coinbase_tick("BTC-USD")).unwrap();
3951        assert_eq!(tick.price, Decimal::from_str("50001.00").unwrap());
3952        assert_eq!(tick.quantity, Decimal::from_str("0.5").unwrap());
3953        assert_eq!(tick.side, Some(TradeSide::Buy));
3954        assert_eq!(tick.trade_id, Some("abc123".to_string()));
3955    }
3956
3957    #[test]
3958    fn test_normalize_coinbase_sell_side() {
3959        let raw = RawTick {
3960            exchange: Exchange::Coinbase,
3961            symbol: "BTC-USD".into(),
3962            payload: json!({ "price": "50000", "size": "1", "side": "sell" }),
3963            received_at_ms: 0,
3964        };
3965        let tick = normalizer().normalize(raw).unwrap();
3966        assert_eq!(tick.side, Some(TradeSide::Sell));
3967    }
3968
3969    #[test]
3970    fn test_normalize_alpaca_tick() {
3971        let tick = normalizer().normalize(alpaca_tick("AAPL")).unwrap();
3972        assert_eq!(tick.price, Decimal::from_str("180.50").unwrap());
3973        assert_eq!(tick.quantity, Decimal::from_str("10").unwrap());
3974        assert_eq!(tick.trade_id, Some("99".to_string()));
3975        assert_eq!(tick.side, None);
3976    }
3977
3978    #[test]
3979    fn test_normalize_polygon_tick() {
3980        let tick = normalizer().normalize(polygon_tick("AAPL")).unwrap();
3981        assert_eq!(tick.price, Decimal::from_str("180.51").unwrap());
3982        // 1_700_000_000_000_000_000 ns / 1_000_000 = 1_700_000_000_000 ms
3983        assert_eq!(tick.exchange_ts_ms, Some(1_700_000_000_000u64));
3984        assert_eq!(tick.trade_id, Some("XYZ-001".to_string()));
3985    }
3986
3987    #[test]
3988    fn test_normalize_alpaca_rfc3339_timestamp() {
3989        let raw = RawTick {
3990            exchange: Exchange::Alpaca,
3991            symbol: "AAPL".into(),
3992            payload: json!({ "p": "180.50", "s": "10", "i": 99, "t": "2023-11-15T00:00:00Z" }),
3993            received_at_ms: 1700000000003,
3994        };
3995        let tick = normalizer().normalize(raw).unwrap();
3996        assert!(tick.exchange_ts_ms.is_some(), "Alpaca 't' field should be parsed");
3997        // 2023-11-15T00:00:00Z = 1700006400000 ms
3998        assert_eq!(tick.exchange_ts_ms, Some(1700006400000u64));
3999    }
4000
4001    #[test]
4002    fn test_normalize_alpaca_no_timestamp_field() {
4003        let tick = normalizer().normalize(alpaca_tick("AAPL")).unwrap();
4004        assert_eq!(tick.exchange_ts_ms, None, "missing 't' field means no exchange_ts_ms");
4005    }
4006
4007    #[test]
4008    fn test_normalize_missing_price_field_returns_parse_error() {
4009        let raw = RawTick {
4010            exchange: Exchange::Binance,
4011            symbol: "BTCUSDT".into(),
4012            payload: json!({ "q": "1" }),
4013            received_at_ms: 0,
4014        };
4015        let result = normalizer().normalize(raw);
4016        assert!(matches!(result, Err(StreamError::ParseError { .. })));
4017    }
4018
4019    #[test]
4020    fn test_normalize_invalid_decimal_returns_parse_error() {
4021        let raw = RawTick {
4022            exchange: Exchange::Coinbase,
4023            symbol: "BTC-USD".into(),
4024            payload: json!({ "price": "not-a-number", "size": "1" }),
4025            received_at_ms: 0,
4026        };
4027        let result = normalizer().normalize(raw);
4028        assert!(matches!(result, Err(StreamError::ParseError { .. })));
4029    }
4030
4031    #[test]
4032    fn test_raw_tick_new_sets_received_at() {
4033        let raw = RawTick::new(Exchange::Binance, "BTCUSDT", json!({}));
4034        assert!(raw.received_at_ms > 0);
4035    }
4036
4037    #[test]
4038    fn test_normalize_numeric_price_field() {
4039        let raw = RawTick {
4040            exchange: Exchange::Binance,
4041            symbol: "BTCUSDT".into(),
4042            payload: json!({ "p": 50000.0, "q": 1.0 }),
4043            received_at_ms: 0,
4044        };
4045        let tick = normalizer().normalize(raw).unwrap();
4046        assert!(tick.price > Decimal::ZERO);
4047    }
4048
4049    #[test]
4050    fn test_trade_side_from_str_buy() {
4051        assert_eq!("buy".parse::<TradeSide>().unwrap(), TradeSide::Buy);
4052        assert_eq!("Buy".parse::<TradeSide>().unwrap(), TradeSide::Buy);
4053        assert_eq!("BUY".parse::<TradeSide>().unwrap(), TradeSide::Buy);
4054    }
4055
4056    #[test]
4057    fn test_trade_side_from_str_sell() {
4058        assert_eq!("sell".parse::<TradeSide>().unwrap(), TradeSide::Sell);
4059        assert_eq!("Sell".parse::<TradeSide>().unwrap(), TradeSide::Sell);
4060        assert_eq!("SELL".parse::<TradeSide>().unwrap(), TradeSide::Sell);
4061    }
4062
4063    #[test]
4064    fn test_trade_side_from_str_invalid() {
4065        let err = "long".parse::<TradeSide>().unwrap_err();
4066        assert!(matches!(err, StreamError::ParseError { .. }));
4067    }
4068
4069    #[test]
4070    fn test_trade_side_display() {
4071        assert_eq!(TradeSide::Buy.to_string(), "buy");
4072        assert_eq!(TradeSide::Sell.to_string(), "sell");
4073    }
4074
4075    #[test]
4076    fn test_normalize_zero_price_returns_invalid_tick() {
4077        let raw = RawTick {
4078            exchange: Exchange::Binance,
4079            symbol: "BTCUSDT".into(),
4080            payload: json!({ "p": "0", "q": "1" }),
4081            received_at_ms: 0,
4082        };
4083        let err = normalizer().normalize(raw).unwrap_err();
4084        assert!(matches!(err, StreamError::InvalidTick { .. }));
4085    }
4086
4087    #[test]
4088    fn test_normalize_negative_price_returns_invalid_tick() {
4089        let raw = RawTick {
4090            exchange: Exchange::Binance,
4091            symbol: "BTCUSDT".into(),
4092            payload: json!({ "p": "-1", "q": "1" }),
4093            received_at_ms: 0,
4094        };
4095        let err = normalizer().normalize(raw).unwrap_err();
4096        assert!(matches!(err, StreamError::InvalidTick { .. }));
4097    }
4098
4099    #[test]
4100    fn test_normalize_negative_quantity_returns_invalid_tick() {
4101        let raw = RawTick {
4102            exchange: Exchange::Binance,
4103            symbol: "BTCUSDT".into(),
4104            payload: json!({ "p": "100", "q": "-1" }),
4105            received_at_ms: 0,
4106        };
4107        let err = normalizer().normalize(raw).unwrap_err();
4108        assert!(matches!(err, StreamError::InvalidTick { .. }));
4109    }
4110
4111    #[test]
4112    fn test_normalize_zero_quantity_is_valid() {
4113        // Zero quantity is allowed (e.g., remove from book), just not negative
4114        let raw = RawTick {
4115            exchange: Exchange::Binance,
4116            symbol: "BTCUSDT".into(),
4117            payload: json!({ "p": "100", "q": "0" }),
4118            received_at_ms: 0,
4119        };
4120        let tick = normalizer().normalize(raw).unwrap();
4121        assert_eq!(tick.quantity, Decimal::ZERO);
4122    }
4123
4124    #[test]
4125    fn test_trade_side_is_buy() {
4126        assert!(TradeSide::Buy.is_buy());
4127        assert!(!TradeSide::Buy.is_sell());
4128    }
4129
4130    #[test]
4131    fn test_trade_side_is_sell() {
4132        assert!(TradeSide::Sell.is_sell());
4133        assert!(!TradeSide::Sell.is_buy());
4134    }
4135
4136    #[test]
4137    fn test_normalized_tick_display() {
4138        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
4139        let s = tick.to_string();
4140        assert!(s.contains("Binance"));
4141        assert!(s.contains("BTCUSDT"));
4142        assert!(s.contains("50000"));
4143    }
4144
4145    #[test]
4146    fn test_normalized_tick_value_is_price_times_qty() {
4147        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
4148        // binance_tick sets price=50000, quantity=0.001
4149        let expected = tick.price * tick.quantity;
4150        assert_eq!(tick.volume_notional(), expected);
4151    }
4152
4153    #[test]
4154    fn test_normalized_tick_age_ms_positive() {
4155        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
4156        // received_at_ms is set to 1_000_000 in binance_tick helper? Let's check
4157        // Actually the helper uses now_ms() so we can't predict. Use a manual tick.
4158        let raw = RawTick {
4159            exchange: Exchange::Binance,
4160            symbol: "BTCUSDT".into(),
4161            payload: serde_json::json!({"p": "50000", "q": "0.001", "m": false}),
4162            received_at_ms: 1_000_000,
4163        };
4164        let tick = normalizer().normalize(raw).unwrap();
4165        assert_eq!(tick.age_ms(1_001_000), 1_000);
4166    }
4167
4168    #[test]
4169    fn test_normalized_tick_age_ms_zero_when_now_equals_received() {
4170        let raw = RawTick {
4171            exchange: Exchange::Binance,
4172            symbol: "BTCUSDT".into(),
4173            payload: serde_json::json!({"p": "50000", "q": "0.001", "m": false}),
4174            received_at_ms: 5_000,
4175        };
4176        let tick = normalizer().normalize(raw).unwrap();
4177        assert_eq!(tick.age_ms(5_000), 0);
4178        // saturating_sub: now < received → 0
4179        assert_eq!(tick.age_ms(4_000), 0);
4180    }
4181
4182    #[test]
4183    fn test_normalized_tick_value_zero_qty_is_zero() {
4184        use rust_decimal_macros::dec;
4185        let raw = RawTick {
4186            exchange: Exchange::Binance,
4187            symbol: "BTCUSDT".into(),
4188            payload: serde_json::json!({
4189                "p": "50000",
4190                "q": "0",
4191                "m": false,
4192            }),
4193            received_at_ms: 1000,
4194        };
4195        let tick = normalizer().normalize(raw).unwrap();
4196        assert_eq!(tick.value(), dec!(0));
4197    }
4198
4199    // ── NormalizedTick::is_stale ──────────────────────────────────────────────
4200
4201    fn make_tick_at(received_at_ms: u64) -> NormalizedTick {
4202        NormalizedTick {
4203            exchange: Exchange::Binance,
4204            symbol: "BTCUSDT".into(),
4205            price: rust_decimal_macros::dec!(100),
4206            quantity: rust_decimal_macros::dec!(1),
4207            side: None,
4208            trade_id: None,
4209            exchange_ts_ms: None,
4210            received_at_ms,
4211        }
4212    }
4213
4214    #[test]
4215    fn test_is_stale_true_when_age_exceeds_threshold() {
4216        let tick = make_tick_at(1_000);
4217        // now=6000, age=5000, threshold=4000 → stale
4218        assert!(tick.is_stale(6_000, 4_000));
4219    }
4220
4221    #[test]
4222    fn test_is_stale_false_when_age_equals_threshold() {
4223        let tick = make_tick_at(1_000);
4224        // now=5000, age=4000, threshold=4000 → NOT stale (> not >=)
4225        assert!(!tick.is_stale(5_000, 4_000));
4226    }
4227
4228    #[test]
4229    fn test_is_stale_false_for_fresh_tick() {
4230        let tick = make_tick_at(10_000);
4231        assert!(!tick.is_stale(10_500, 1_000));
4232    }
4233
4234    // ── NormalizedTick::is_buy / is_sell ──────────────────────────────────────
4235
4236    #[test]
4237    fn test_is_buy_true_for_buy_side() {
4238        let mut tick = make_tick_at(1_000);
4239        tick.side = Some(TradeSide::Buy);
4240        assert!(tick.is_buy());
4241        assert!(!tick.is_sell());
4242    }
4243
4244    #[test]
4245    fn test_is_sell_true_for_sell_side() {
4246        let mut tick = make_tick_at(1_000);
4247        tick.side = Some(TradeSide::Sell);
4248        assert!(tick.is_sell());
4249        assert!(!tick.is_buy());
4250    }
4251
4252    #[test]
4253    fn test_is_buy_false_for_unknown_side() {
4254        let mut tick = make_tick_at(1_000);
4255        tick.side = None;
4256        assert!(!tick.is_buy());
4257        assert!(!tick.is_sell());
4258    }
4259
4260    // ── NormalizedTick::with_exchange_ts ──────────────────────────────────────
4261
4262    #[test]
4263    fn test_with_exchange_ts_sets_field() {
4264        let tick = make_tick_at(5_000).with_exchange_ts(3_000);
4265        assert_eq!(tick.exchange_ts_ms, Some(3_000));
4266        assert_eq!(tick.received_at_ms, 5_000); // unchanged
4267    }
4268
4269    #[test]
4270    fn test_with_exchange_ts_overrides_existing() {
4271        let tick = make_tick_at(1_000).with_exchange_ts(999).with_exchange_ts(888);
4272        assert_eq!(tick.exchange_ts_ms, Some(888));
4273    }
4274
4275    // ── NormalizedTick::price_move_from / is_more_recent_than ─────────────────
4276
4277    #[test]
4278    fn test_price_move_from_positive() {
4279        let prev = make_tick_at(1_000);
4280        let mut curr = make_tick_at(2_000);
4281        curr.price = prev.price + rust_decimal_macros::dec!(5);
4282        assert_eq!(curr.price_move_from(&prev), rust_decimal_macros::dec!(5));
4283    }
4284
4285    #[test]
4286    fn test_price_move_from_negative() {
4287        let prev = make_tick_at(1_000);
4288        let mut curr = make_tick_at(2_000);
4289        curr.price = prev.price - rust_decimal_macros::dec!(3);
4290        assert_eq!(curr.price_move_from(&prev), rust_decimal_macros::dec!(-3));
4291    }
4292
4293    #[test]
4294    fn test_price_move_from_zero_when_same() {
4295        let tick = make_tick_at(1_000);
4296        assert_eq!(tick.price_move_from(&tick), rust_decimal_macros::dec!(0));
4297    }
4298
4299    #[test]
4300    fn test_is_more_recent_than_true() {
4301        let older = make_tick_at(1_000);
4302        let newer = make_tick_at(2_000);
4303        assert!(newer.is_more_recent_than(&older));
4304    }
4305
4306    #[test]
4307    fn test_is_more_recent_than_false_when_older() {
4308        let older = make_tick_at(1_000);
4309        let newer = make_tick_at(2_000);
4310        assert!(!older.is_more_recent_than(&newer));
4311    }
4312
4313    #[test]
4314    fn test_is_more_recent_than_false_when_equal() {
4315        let tick = make_tick_at(1_000);
4316        assert!(!tick.is_more_recent_than(&tick));
4317    }
4318
4319    // ── NormalizedTick::with_side ─────────────────────────────────────────────
4320
4321    #[test]
4322    fn test_with_side_sets_buy() {
4323        let tick = make_tick_at(1_000).with_side(TradeSide::Buy);
4324        assert_eq!(tick.side, Some(TradeSide::Buy));
4325    }
4326
4327    #[test]
4328    fn test_with_side_sets_sell() {
4329        let tick = make_tick_at(1_000).with_side(TradeSide::Sell);
4330        assert_eq!(tick.side, Some(TradeSide::Sell));
4331    }
4332
4333    #[test]
4334    fn test_with_side_overrides_existing() {
4335        let tick = make_tick_at(1_000).with_side(TradeSide::Buy).with_side(TradeSide::Sell);
4336        assert_eq!(tick.side, Some(TradeSide::Sell));
4337    }
4338
4339    // ── NormalizedTick::is_neutral ────────────────────────────────────────────
4340
4341    #[test]
4342    fn test_is_neutral_true_when_no_side() {
4343        let mut tick = make_tick_at(1_000);
4344        tick.side = None;
4345        assert!(tick.is_neutral());
4346    }
4347
4348    #[test]
4349    fn test_is_neutral_false_when_buy() {
4350        let tick = make_tick_at(1_000).with_side(TradeSide::Buy);
4351        assert!(!tick.is_neutral());
4352    }
4353
4354    #[test]
4355    fn test_is_neutral_false_when_sell() {
4356        let tick = make_tick_at(1_000).with_side(TradeSide::Sell);
4357        assert!(!tick.is_neutral());
4358    }
4359
4360    // ── NormalizedTick::is_large_trade ────────────────────────────────────────
4361
4362    #[test]
4363    fn test_is_large_trade_above_threshold() {
4364        let mut tick = make_tick_at(1_000);
4365        tick.quantity = rust_decimal_macros::dec!(100);
4366        assert!(tick.is_large_trade(rust_decimal_macros::dec!(50)));
4367    }
4368
4369    #[test]
4370    fn test_is_large_trade_at_threshold() {
4371        let mut tick = make_tick_at(1_000);
4372        tick.quantity = rust_decimal_macros::dec!(50);
4373        assert!(tick.is_large_trade(rust_decimal_macros::dec!(50)));
4374    }
4375
4376    #[test]
4377    fn test_is_large_trade_below_threshold() {
4378        let mut tick = make_tick_at(1_000);
4379        tick.quantity = rust_decimal_macros::dec!(10);
4380        assert!(!tick.is_large_trade(rust_decimal_macros::dec!(50)));
4381    }
4382
4383    #[test]
4384    fn test_volume_notional_is_price_times_quantity() {
4385        let mut tick = make_tick_at(1_000);
4386        tick.price = rust_decimal_macros::dec!(200);
4387        tick.quantity = rust_decimal_macros::dec!(3);
4388        assert_eq!(tick.volume_notional(), rust_decimal_macros::dec!(600));
4389    }
4390
4391    // ── NormalizedTick::is_above ──────────────────────────────────────────────
4392
4393    #[test]
4394    fn test_is_above_returns_true_when_price_higher() {
4395        let mut tick = make_tick_at(1_000);
4396        tick.price = rust_decimal_macros::dec!(200);
4397        assert!(tick.is_above(rust_decimal_macros::dec!(150)));
4398    }
4399
4400    #[test]
4401    fn test_is_above_returns_false_when_price_equal() {
4402        let mut tick = make_tick_at(1_000);
4403        tick.price = rust_decimal_macros::dec!(200);
4404        assert!(!tick.is_above(rust_decimal_macros::dec!(200)));
4405    }
4406
4407    #[test]
4408    fn test_is_above_returns_false_when_price_lower() {
4409        let mut tick = make_tick_at(1_000);
4410        tick.price = rust_decimal_macros::dec!(100);
4411        assert!(!tick.is_above(rust_decimal_macros::dec!(200)));
4412    }
4413
4414    // ── NormalizedTick::is_below ──────────────────────────────────────────────
4415
4416    #[test]
4417    fn test_is_below_returns_true_when_price_lower() {
4418        let mut tick = make_tick_at(1_000);
4419        tick.price = rust_decimal_macros::dec!(100);
4420        assert!(tick.is_below(rust_decimal_macros::dec!(150)));
4421    }
4422
4423    #[test]
4424    fn test_is_below_returns_false_when_price_equal() {
4425        let mut tick = make_tick_at(1_000);
4426        tick.price = rust_decimal_macros::dec!(100);
4427        assert!(!tick.is_below(rust_decimal_macros::dec!(100)));
4428    }
4429
4430    #[test]
4431    fn test_is_below_returns_false_when_price_higher() {
4432        let mut tick = make_tick_at(1_000);
4433        tick.price = rust_decimal_macros::dec!(200);
4434        assert!(!tick.is_below(rust_decimal_macros::dec!(100)));
4435    }
4436
4437    // --- has_exchange_ts ---
4438
4439    #[test]
4440    fn test_has_exchange_ts_false_when_none() {
4441        let tick = make_tick_at(1_000);
4442        assert!(!tick.has_exchange_ts());
4443    }
4444
4445    #[test]
4446    fn test_has_exchange_ts_true_when_some() {
4447        let tick = make_tick_at(1_000).with_exchange_ts(900);
4448        assert!(tick.has_exchange_ts());
4449    }
4450
4451    // ── NormalizedTick::is_at ─────────────────────────────────────────────────
4452
4453    #[test]
4454    fn test_is_at_returns_true_when_equal() {
4455        let mut tick = make_tick_at(1_000);
4456        tick.price = rust_decimal_macros::dec!(100);
4457        assert!(tick.is_at(rust_decimal_macros::dec!(100)));
4458    }
4459
4460    #[test]
4461    fn test_is_at_returns_false_when_higher() {
4462        let mut tick = make_tick_at(1_000);
4463        tick.price = rust_decimal_macros::dec!(101);
4464        assert!(!tick.is_at(rust_decimal_macros::dec!(100)));
4465    }
4466
4467    #[test]
4468    fn test_is_at_returns_false_when_lower() {
4469        let mut tick = make_tick_at(1_000);
4470        tick.price = rust_decimal_macros::dec!(99);
4471        assert!(!tick.is_at(rust_decimal_macros::dec!(100)));
4472    }
4473
4474    // ── NormalizedTick::is_buy ────────────────────────────────────────────────
4475
4476    #[test]
4477    fn test_is_buy_true_when_side_is_buy() {
4478        let mut tick = make_tick_at(1_000);
4479        tick.side = Some(TradeSide::Buy);
4480        assert!(tick.is_buy());
4481    }
4482
4483    #[test]
4484    fn test_is_buy_false_when_side_is_sell() {
4485        let mut tick = make_tick_at(1_000);
4486        tick.side = Some(TradeSide::Sell);
4487        assert!(!tick.is_buy());
4488    }
4489
4490    #[test]
4491    fn test_is_buy_false_when_side_is_none() {
4492        let mut tick = make_tick_at(1_000);
4493        tick.side = None;
4494        assert!(!tick.is_buy());
4495    }
4496
4497    // --- side_str / is_round_lot ---
4498
4499    #[test]
4500    fn test_side_str_buy() {
4501        let mut tick = make_tick_at(1_000);
4502        tick.side = Some(TradeSide::Buy);
4503        assert_eq!(tick.side_str(), "buy");
4504    }
4505
4506    #[test]
4507    fn test_side_str_sell() {
4508        let mut tick = make_tick_at(1_000);
4509        tick.side = Some(TradeSide::Sell);
4510        assert_eq!(tick.side_str(), "sell");
4511    }
4512
4513    #[test]
4514    fn test_side_str_unknown_when_none() {
4515        let mut tick = make_tick_at(1_000);
4516        tick.side = None;
4517        assert_eq!(tick.side_str(), "unknown");
4518    }
4519
4520    #[test]
4521    fn test_is_round_lot_true_for_integer_quantity() {
4522        let mut tick = make_tick_at(1_000);
4523        tick.quantity = rust_decimal_macros::dec!(100);
4524        assert!(tick.is_round_lot());
4525    }
4526
4527    #[test]
4528    fn test_is_round_lot_false_for_fractional_quantity() {
4529        let mut tick = make_tick_at(1_000);
4530        tick.quantity = rust_decimal_macros::dec!(0.5);
4531        assert!(!tick.is_round_lot());
4532    }
4533
4534    // --- is_same_symbol_as / price_distance_from ---
4535
4536    #[test]
4537    fn test_is_same_symbol_as_true_when_symbols_match() {
4538        let t1 = make_tick_at(1_000);
4539        let t2 = make_tick_at(2_000);
4540        assert!(t1.is_same_symbol_as(&t2));
4541    }
4542
4543    #[test]
4544    fn test_is_same_symbol_as_false_when_symbols_differ() {
4545        let t1 = make_tick_at(1_000);
4546        let mut t2 = make_tick_at(2_000);
4547        t2.symbol = "ETH-USD".to_string();
4548        assert!(!t1.is_same_symbol_as(&t2));
4549    }
4550
4551    #[test]
4552    fn test_price_distance_from_is_absolute() {
4553        let mut t1 = make_tick_at(1_000);
4554        let mut t2 = make_tick_at(2_000);
4555        t1.price = rust_decimal_macros::dec!(100);
4556        t2.price = rust_decimal_macros::dec!(110);
4557        assert_eq!(t1.price_distance_from(&t2), rust_decimal_macros::dec!(10));
4558        assert_eq!(t2.price_distance_from(&t1), rust_decimal_macros::dec!(10));
4559    }
4560
4561    #[test]
4562    fn test_price_distance_from_zero_when_equal() {
4563        let t1 = make_tick_at(1_000);
4564        let t2 = make_tick_at(2_000);
4565        assert!(t1.price_distance_from(&t2).is_zero());
4566    }
4567
4568    // ── NormalizedTick::is_sell ───────────────────────────────────────────────
4569
4570    #[test]
4571    fn test_is_sell_true_when_side_is_sell() {
4572        let mut tick = make_tick_at(1_000);
4573        tick.side = Some(TradeSide::Sell);
4574        assert!(tick.is_sell());
4575    }
4576
4577    #[test]
4578    fn test_is_sell_false_when_side_is_buy() {
4579        let mut tick = make_tick_at(1_000);
4580        tick.side = Some(TradeSide::Buy);
4581        assert!(!tick.is_sell());
4582    }
4583
4584    #[test]
4585    fn test_is_sell_false_when_side_is_none() {
4586        let mut tick = make_tick_at(1_000);
4587        tick.side = None;
4588        assert!(!tick.is_sell());
4589    }
4590
4591    // --- exchange_latency_ms / is_notional_large_trade ---
4592
4593    #[test]
4594    fn test_exchange_latency_ms_positive_for_normal_delivery() {
4595        let mut tick = make_tick_at(1_100);
4596        tick.exchange_ts_ms = Some(1_000);
4597        assert_eq!(tick.exchange_latency_ms(), Some(100));
4598    }
4599
4600    #[test]
4601    fn test_exchange_latency_ms_negative_for_clock_skew() {
4602        let mut tick = make_tick_at(1_000);
4603        tick.exchange_ts_ms = Some(1_100);
4604        assert_eq!(tick.exchange_latency_ms(), Some(-100));
4605    }
4606
4607    #[test]
4608    fn test_exchange_latency_ms_none_when_no_exchange_ts() {
4609        let mut tick = make_tick_at(1_000);
4610        tick.exchange_ts_ms = None;
4611        assert!(tick.exchange_latency_ms().is_none());
4612    }
4613
4614    #[test]
4615    fn test_is_notional_large_trade_true_when_above_threshold() {
4616        let mut tick = make_tick_at(1_000);
4617        tick.price = rust_decimal_macros::dec!(100);
4618        tick.quantity = rust_decimal_macros::dec!(10);
4619        // notional = 1000, threshold = 500 → true
4620        assert!(tick.is_notional_large_trade(rust_decimal_macros::dec!(500)));
4621    }
4622
4623    #[test]
4624    fn test_is_notional_large_trade_false_when_at_or_below_threshold() {
4625        let mut tick = make_tick_at(1_000);
4626        tick.price = rust_decimal_macros::dec!(100);
4627        tick.quantity = rust_decimal_macros::dec!(5);
4628        // notional = 500, threshold = 500 → false (strictly greater)
4629        assert!(!tick.is_notional_large_trade(rust_decimal_macros::dec!(500)));
4630    }
4631
4632    #[test]
4633    fn test_is_aggressive_true_when_buy() {
4634        let mut tick = make_tick_at(1_000);
4635        tick.side = Some(TradeSide::Buy);
4636        assert!(tick.is_aggressive());
4637    }
4638
4639    #[test]
4640    fn test_is_aggressive_true_when_sell() {
4641        let mut tick = make_tick_at(1_000);
4642        tick.side = Some(TradeSide::Sell);
4643        assert!(tick.is_aggressive());
4644    }
4645
4646    #[test]
4647    fn test_is_aggressive_false_when_neutral() {
4648        let tick = make_tick_at(1_000); // side = None
4649        assert!(!tick.is_aggressive());
4650    }
4651
4652    #[test]
4653    fn test_price_diff_from_positive_when_higher() {
4654        let mut t1 = make_tick_at(1_000);
4655        let mut t2 = make_tick_at(1_000);
4656        t1.price = rust_decimal_macros::dec!(105);
4657        t2.price = rust_decimal_macros::dec!(100);
4658        assert_eq!(t1.price_diff_from(&t2), rust_decimal_macros::dec!(5));
4659    }
4660
4661    #[test]
4662    fn test_price_diff_from_negative_when_lower() {
4663        let mut t1 = make_tick_at(1_000);
4664        let mut t2 = make_tick_at(1_000);
4665        t1.price = rust_decimal_macros::dec!(95);
4666        t2.price = rust_decimal_macros::dec!(100);
4667        assert_eq!(t1.price_diff_from(&t2), rust_decimal_macros::dec!(-5));
4668    }
4669
4670    #[test]
4671    fn test_is_micro_trade_true_when_below_threshold() {
4672        let mut tick = make_tick_at(1_000);
4673        tick.quantity = rust_decimal_macros::dec!(0.5);
4674        assert!(tick.is_micro_trade(rust_decimal_macros::dec!(1)));
4675    }
4676
4677    #[test]
4678    fn test_is_micro_trade_false_when_equal_threshold() {
4679        let mut tick = make_tick_at(1_000);
4680        tick.quantity = rust_decimal_macros::dec!(1);
4681        assert!(!tick.is_micro_trade(rust_decimal_macros::dec!(1)));
4682    }
4683
4684    #[test]
4685    fn test_is_micro_trade_false_when_above_threshold() {
4686        let mut tick = make_tick_at(1_000);
4687        tick.quantity = rust_decimal_macros::dec!(2);
4688        assert!(!tick.is_micro_trade(rust_decimal_macros::dec!(1)));
4689    }
4690
4691    // --- is_zero_price / is_fresh ---
4692
4693    #[test]
4694    fn test_is_zero_price_true_for_zero() {
4695        let mut tick = make_tick_at(1_000);
4696        tick.price = rust_decimal_macros::dec!(0);
4697        assert!(tick.is_zero_price());
4698    }
4699
4700    #[test]
4701    fn test_is_zero_price_false_for_nonzero() {
4702        let tick = make_tick_at(1_000); // price set by make_tick_at
4703        assert!(!tick.is_zero_price());
4704    }
4705
4706    #[test]
4707    fn test_is_fresh_true_when_within_age() {
4708        let tick = make_tick_at(1_000);
4709        // received_at = 1000, now = 2000, max_age = 1500 → 1000 <= 1500 → fresh
4710        assert!(tick.is_fresh(2_000, 1_500));
4711    }
4712
4713    #[test]
4714    fn test_is_fresh_false_when_too_old() {
4715        let tick = make_tick_at(1_000);
4716        // received_at = 1000, now = 5000, max_age = 2000 → 4000 > 2000 → not fresh
4717        assert!(!tick.is_fresh(5_000, 2_000));
4718    }
4719
4720    #[test]
4721    fn test_is_fresh_true_when_now_less_than_received() {
4722        // Clock skew: now < received_at → saturating_sub = 0 ≤ max_age
4723        let tick = make_tick_at(5_000);
4724        assert!(tick.is_fresh(3_000, 100));
4725    }
4726
4727    // --- NormalizedTick::age_ms ---
4728    #[test]
4729    fn test_age_ms_correct_elapsed() {
4730        let tick = make_tick_at(10_000);
4731        assert_eq!(tick.age_ms(10_500), 500);
4732    }
4733
4734    #[test]
4735    fn test_age_ms_zero_when_now_equals_received() {
4736        let tick = make_tick_at(10_000);
4737        assert_eq!(tick.age_ms(10_000), 0);
4738    }
4739
4740    #[test]
4741    fn test_age_ms_zero_when_now_before_received() {
4742        let tick = make_tick_at(10_000);
4743        assert_eq!(tick.age_ms(9_000), 0);
4744    }
4745
4746    // --- NormalizedTick::is_buying_pressure ---
4747    #[test]
4748    fn test_is_buying_pressure_true_above_midpoint() {
4749        use rust_decimal_macros::dec;
4750        let mut tick = make_tick_at(0);
4751        tick.price = dec!(100.50);
4752        assert!(tick.is_buying_pressure(dec!(100)));
4753    }
4754
4755    #[test]
4756    fn test_is_buying_pressure_false_below_midpoint() {
4757        use rust_decimal_macros::dec;
4758        let mut tick = make_tick_at(0);
4759        tick.price = dec!(99.50);
4760        assert!(!tick.is_buying_pressure(dec!(100)));
4761    }
4762
4763    #[test]
4764    fn test_is_buying_pressure_false_at_midpoint() {
4765        use rust_decimal_macros::dec;
4766        let mut tick = make_tick_at(0);
4767        tick.price = dec!(100);
4768        assert!(!tick.is_buying_pressure(dec!(100)));
4769    }
4770
4771    // --- NormalizedTick::rounded_price ---
4772    #[test]
4773    fn test_rounded_price_rounds_to_nearest_tick() {
4774        use rust_decimal_macros::dec;
4775        let mut tick = make_tick_at(0);
4776        tick.price = dec!(100.37);
4777        // tick_size = 0.25 → 100.25
4778        assert_eq!(tick.rounded_price(dec!(0.25)), dec!(100.25));
4779    }
4780
4781    #[test]
4782    fn test_rounded_price_unchanged_when_already_aligned() {
4783        use rust_decimal_macros::dec;
4784        let mut tick = make_tick_at(0);
4785        tick.price = dec!(100.50);
4786        assert_eq!(tick.rounded_price(dec!(0.25)), dec!(100.50));
4787    }
4788
4789    #[test]
4790    fn test_rounded_price_returns_original_for_zero_tick_size() {
4791        use rust_decimal_macros::dec;
4792        let mut tick = make_tick_at(0);
4793        tick.price = dec!(99.99);
4794        assert_eq!(tick.rounded_price(dec!(0)), dec!(99.99));
4795    }
4796
4797    // --- NormalizedTick::is_large_spread_from ---
4798    #[test]
4799    fn test_is_large_spread_from_true_when_large() {
4800        use rust_decimal_macros::dec;
4801        let mut t1 = make_tick_at(0);
4802        let mut t2 = make_tick_at(0);
4803        t1.price = dec!(100);
4804        t2.price = dec!(110);
4805        assert!(t1.is_large_spread_from(&t2, dec!(5)));
4806    }
4807
4808    #[test]
4809    fn test_is_large_spread_from_false_when_small() {
4810        use rust_decimal_macros::dec;
4811        let mut t1 = make_tick_at(0);
4812        let mut t2 = make_tick_at(0);
4813        t1.price = dec!(100);
4814        t2.price = dec!(101);
4815        assert!(!t1.is_large_spread_from(&t2, dec!(5)));
4816    }
4817
4818    // ── NormalizedTick::age_secs ──────────────────────────────────────────────
4819
4820    #[test]
4821    fn test_age_secs_correct() {
4822        let tick = make_tick_at(1_000);
4823        assert!((tick.age_secs(3_000) - 2.0).abs() < 1e-9);
4824    }
4825
4826    #[test]
4827    fn test_age_secs_zero_when_now_equals_received() {
4828        let tick = make_tick_at(5_000);
4829        assert_eq!(tick.age_secs(5_000), 0.0);
4830    }
4831
4832    #[test]
4833    fn test_age_secs_zero_when_now_before_received() {
4834        let tick = make_tick_at(5_000);
4835        assert_eq!(tick.age_secs(1_000), 0.0);
4836    }
4837
4838    // ── NormalizedTick::is_same_exchange_as ───────────────────────────────────
4839
4840    #[test]
4841    fn test_is_same_exchange_as_true_when_matching() {
4842        let t1 = make_tick_at(1_000); // Binance
4843        let t2 = make_tick_at(2_000); // Binance
4844        assert!(t1.is_same_exchange_as(&t2));
4845    }
4846
4847    #[test]
4848    fn test_is_same_exchange_as_false_when_different() {
4849        let t1 = make_tick_at(1_000); // Binance
4850        let mut t2 = make_tick_at(2_000);
4851        t2.exchange = Exchange::Coinbase;
4852        assert!(!t1.is_same_exchange_as(&t2));
4853    }
4854
4855    // ── NormalizedTick::quote_age_ms / notional_value / is_high_value_tick ──
4856
4857    #[test]
4858    fn test_quote_age_ms_correct() {
4859        let tick = make_tick_at(1_000);
4860        assert_eq!(tick.quote_age_ms(3_000), 2_000);
4861    }
4862
4863    #[test]
4864    fn test_quote_age_ms_zero_when_now_before_received() {
4865        let tick = make_tick_at(5_000);
4866        assert_eq!(tick.quote_age_ms(1_000), 0);
4867    }
4868
4869    #[test]
4870    fn test_notional_value_correct() {
4871        use rust_decimal_macros::dec;
4872        let mut tick = make_tick_at(0);
4873        tick.price = dec!(100);
4874        tick.quantity = dec!(5);
4875        assert_eq!(tick.notional_value(), dec!(500));
4876    }
4877
4878    #[test]
4879    fn test_is_high_value_tick_true_when_above_threshold() {
4880        use rust_decimal_macros::dec;
4881        let mut tick = make_tick_at(0);
4882        tick.price = dec!(100);
4883        tick.quantity = dec!(10);
4884        // notional = 1000 > 500
4885        assert!(tick.is_high_value_tick(dec!(500)));
4886    }
4887
4888    #[test]
4889    fn test_is_high_value_tick_false_when_below_threshold() {
4890        use rust_decimal_macros::dec;
4891        let mut tick = make_tick_at(0);
4892        tick.price = dec!(10);
4893        tick.quantity = dec!(2);
4894        // notional = 20 < 100
4895        assert!(!tick.is_high_value_tick(dec!(100)));
4896    }
4897
4898    // ── NormalizedTick::is_buy_side / is_sell_side / price_in_range ─────────
4899
4900    #[test]
4901    fn test_is_buy_side_true_when_buy() {
4902        let mut tick = make_tick_at(0);
4903        tick.side = Some(TradeSide::Buy);
4904        assert!(tick.is_buy_side());
4905    }
4906
4907    #[test]
4908    fn test_is_buy_side_false_when_sell() {
4909        let mut tick = make_tick_at(0);
4910        tick.side = Some(TradeSide::Sell);
4911        assert!(!tick.is_buy_side());
4912    }
4913
4914    #[test]
4915    fn test_is_buy_side_false_when_none() {
4916        let mut tick = make_tick_at(0);
4917        tick.side = None;
4918        assert!(!tick.is_buy_side());
4919    }
4920
4921    #[test]
4922    fn test_is_sell_side_true_when_sell() {
4923        let mut tick = make_tick_at(0);
4924        tick.side = Some(TradeSide::Sell);
4925        assert!(tick.is_sell_side());
4926    }
4927
4928    #[test]
4929    fn test_price_in_range_true_when_within() {
4930        use rust_decimal_macros::dec;
4931        let mut tick = make_tick_at(0);
4932        tick.price = dec!(100);
4933        assert!(tick.price_in_range(dec!(90), dec!(110)));
4934    }
4935
4936    #[test]
4937    fn test_price_in_range_false_when_below() {
4938        use rust_decimal_macros::dec;
4939        let mut tick = make_tick_at(0);
4940        tick.price = dec!(80);
4941        assert!(!tick.price_in_range(dec!(90), dec!(110)));
4942    }
4943
4944    #[test]
4945    fn test_price_in_range_true_at_boundary() {
4946        use rust_decimal_macros::dec;
4947        let mut tick = make_tick_at(0);
4948        tick.price = dec!(90);
4949        assert!(tick.price_in_range(dec!(90), dec!(110)));
4950    }
4951
4952    // ── NormalizedTick::is_zero_quantity ──────────────────────────────────────
4953
4954    #[test]
4955    fn test_is_zero_quantity_true_when_zero() {
4956        let mut tick = make_tick_at(0);
4957        tick.quantity = Decimal::ZERO;
4958        assert!(tick.is_zero_quantity());
4959    }
4960
4961    #[test]
4962    fn test_is_zero_quantity_false_when_nonzero() {
4963        let mut tick = make_tick_at(0);
4964        tick.quantity = Decimal::ONE;
4965        assert!(!tick.is_zero_quantity());
4966    }
4967
4968    // ── NormalizedTick::is_large_tick ─────────────────────────────────────────
4969
4970    #[test]
4971    fn test_is_large_tick_true_when_above_threshold() {
4972        let mut tick = make_tick_at(0);
4973        tick.quantity = Decimal::from(10u32);
4974        assert!(tick.is_large_tick(Decimal::from(5u32)));
4975    }
4976
4977    #[test]
4978    fn test_is_large_tick_false_when_at_threshold() {
4979        let mut tick = make_tick_at(0);
4980        tick.quantity = Decimal::from(5u32);
4981        assert!(!tick.is_large_tick(Decimal::from(5u32)));
4982    }
4983
4984    #[test]
4985    fn test_is_large_tick_false_when_below_threshold() {
4986        let mut tick = make_tick_at(0);
4987        tick.quantity = Decimal::from(1u32);
4988        assert!(!tick.is_large_tick(Decimal::from(5u32)));
4989    }
4990
4991    // ── NormalizedTick::is_away_from_price ───────────────────────────────────
4992
4993    #[test]
4994    fn test_is_away_from_price_true_when_beyond_threshold() {
4995        let mut tick = make_tick_at(0);
4996        tick.price = Decimal::from(110u32);
4997        // |110 - 100| = 10 > 5
4998        assert!(tick.is_away_from_price(Decimal::from(100u32), Decimal::from(5u32)));
4999    }
5000
5001    #[test]
5002    fn test_is_away_from_price_false_when_at_threshold() {
5003        let mut tick = make_tick_at(0);
5004        tick.price = Decimal::from(105u32);
5005        // |105 - 100| = 5, not > 5
5006        assert!(!tick.is_away_from_price(Decimal::from(100u32), Decimal::from(5u32)));
5007    }
5008
5009    #[test]
5010    fn test_is_away_from_price_false_when_equal() {
5011        let mut tick = make_tick_at(0);
5012        tick.price = Decimal::from(100u32);
5013        assert!(!tick.is_away_from_price(Decimal::from(100u32), Decimal::from(1u32)));
5014    }
5015
5016    // ── NormalizedTick::is_within_spread ──────────────────────────────────────
5017
5018    #[test]
5019    fn test_is_within_spread_true_when_between() {
5020        let mut tick = make_tick_at(0);
5021        tick.price = Decimal::from(100u32);
5022        assert!(tick.is_within_spread(Decimal::from(99u32), Decimal::from(101u32)));
5023    }
5024
5025    #[test]
5026    fn test_is_within_spread_false_when_at_bid() {
5027        let mut tick = make_tick_at(0);
5028        tick.price = Decimal::from(99u32);
5029        assert!(!tick.is_within_spread(Decimal::from(99u32), Decimal::from(101u32)));
5030    }
5031
5032    #[test]
5033    fn test_is_within_spread_false_when_above_ask() {
5034        let mut tick = make_tick_at(0);
5035        tick.price = Decimal::from(102u32);
5036        assert!(!tick.is_within_spread(Decimal::from(99u32), Decimal::from(101u32)));
5037    }
5038
5039    // ── NormalizedTick::is_recent ─────────────────────────────────────────────
5040
5041    #[test]
5042    fn test_is_recent_true_when_within_threshold() {
5043        let tick = make_tick_at(9_500);
5044        // now=10000, threshold=1000 → age=500ms ≤ 1000ms
5045        assert!(tick.is_recent(1_000, 10_000));
5046    }
5047
5048    #[test]
5049    fn test_is_recent_false_when_beyond_threshold() {
5050        let tick = make_tick_at(8_000);
5051        // now=10000, threshold=1000 → age=2000ms > 1000ms
5052        assert!(!tick.is_recent(1_000, 10_000));
5053    }
5054
5055    #[test]
5056    fn test_is_recent_true_at_exact_threshold() {
5057        let tick = make_tick_at(9_000);
5058        // age=1000ms, threshold=1000ms → exactly at threshold
5059        assert!(tick.is_recent(1_000, 10_000));
5060    }
5061
5062    // ── NormalizedTick::side_as_str ───────────────────────────────────────────
5063
5064    #[test]
5065    fn test_side_as_str_buy() {
5066        let mut tick = make_tick_at(0);
5067        tick.side = Some(TradeSide::Buy);
5068        assert_eq!(tick.side_as_str(), Some("buy"));
5069    }
5070
5071    #[test]
5072    fn test_side_as_str_sell() {
5073        let mut tick = make_tick_at(0);
5074        tick.side = Some(TradeSide::Sell);
5075        assert_eq!(tick.side_as_str(), Some("sell"));
5076    }
5077
5078    #[test]
5079    fn test_side_as_str_none_when_unknown() {
5080        let mut tick = make_tick_at(0);
5081        tick.side = None;
5082        assert!(tick.side_as_str().is_none());
5083    }
5084
5085    // ── is_above_price ────────────────────────────────────────────────────────
5086
5087    #[test]
5088    fn test_is_above_price_true_when_strictly_above() {
5089        let tick = make_tick_at(0); // price=100
5090        assert!(tick.is_above_price(rust_decimal_macros::dec!(99)));
5091    }
5092
5093    #[test]
5094    fn test_is_above_price_false_when_equal() {
5095        let tick = make_tick_at(0); // price=100
5096        assert!(!tick.is_above_price(rust_decimal_macros::dec!(100)));
5097    }
5098
5099    #[test]
5100    fn test_is_above_price_false_when_below() {
5101        let tick = make_tick_at(0); // price=100
5102        assert!(!tick.is_above_price(rust_decimal_macros::dec!(101)));
5103    }
5104
5105    // ── price_change_from ─────────────────────────────────────────────────────
5106
5107    #[test]
5108    fn test_price_change_from_positive_when_above_reference() {
5109        let tick = make_tick_at(0); // price=100
5110        assert_eq!(tick.price_change_from(rust_decimal_macros::dec!(90)), rust_decimal_macros::dec!(10));
5111    }
5112
5113    #[test]
5114    fn test_price_change_from_negative_when_below_reference() {
5115        let tick = make_tick_at(0); // price=100
5116        assert_eq!(tick.price_change_from(rust_decimal_macros::dec!(110)), rust_decimal_macros::dec!(-10));
5117    }
5118
5119    #[test]
5120    fn test_price_change_from_zero_when_equal() {
5121        let tick = make_tick_at(0); // price=100
5122        assert_eq!(tick.price_change_from(rust_decimal_macros::dec!(100)), rust_decimal_macros::dec!(0));
5123    }
5124
5125    // ── is_below_price ────────────────────────────────────────────────────────
5126
5127    #[test]
5128    fn test_is_below_price_true_when_strictly_below() {
5129        let tick = make_tick_at(0); // price=100
5130        assert!(tick.is_below_price(rust_decimal_macros::dec!(101)));
5131    }
5132
5133    #[test]
5134    fn test_is_below_price_false_when_equal() {
5135        let tick = make_tick_at(0); // price=100
5136        assert!(!tick.is_below_price(rust_decimal_macros::dec!(100)));
5137    }
5138
5139    // ── quantity_above ────────────────────────────────────────────────────────
5140
5141    #[test]
5142    fn test_quantity_above_true_when_quantity_exceeds_threshold() {
5143        let tick = make_tick_at(0); // quantity=1
5144        assert!(tick.quantity_above(rust_decimal_macros::dec!(0)));
5145    }
5146
5147    #[test]
5148    fn test_quantity_above_false_when_quantity_equals_threshold() {
5149        let tick = make_tick_at(0); // quantity=1
5150        assert!(!tick.quantity_above(rust_decimal_macros::dec!(1)));
5151    }
5152
5153    // ── is_at_price ───────────────────────────────────────────────────────────
5154
5155    #[test]
5156    fn test_is_at_price_true_when_equal() {
5157        let tick = make_tick_at(0); // price=100
5158        assert!(tick.is_at_price(rust_decimal_macros::dec!(100)));
5159    }
5160
5161    #[test]
5162    fn test_is_at_price_false_when_different() {
5163        let tick = make_tick_at(0); // price=100
5164        assert!(!tick.is_at_price(rust_decimal_macros::dec!(101)));
5165    }
5166
5167    // ── is_round_number ───────────────────────────────────────────────────────
5168
5169    #[test]
5170    fn test_is_round_number_true_when_divisible() {
5171        let tick = make_tick_at(0); // price=100
5172        assert!(tick.is_round_number(rust_decimal_macros::dec!(10)));
5173        assert!(tick.is_round_number(rust_decimal_macros::dec!(100)));
5174    }
5175
5176    #[test]
5177    fn test_is_round_number_false_when_not_divisible() {
5178        let tick = make_tick_at(0); // price=100
5179        assert!(!tick.is_round_number(rust_decimal_macros::dec!(3)));
5180    }
5181
5182    #[test]
5183    fn test_is_round_number_false_when_step_zero() {
5184        let tick = make_tick_at(0);
5185        assert!(!tick.is_round_number(rust_decimal_macros::dec!(0)));
5186    }
5187
5188    // ── is_market_open_tick ───────────────────────────────────────────────────
5189
5190    #[test]
5191    fn test_is_market_open_tick_true_when_within_session() {
5192        let tick = make_tick_at(500); // received at ms=500
5193        assert!(tick.is_market_open_tick(100, 1_000));
5194    }
5195
5196    #[test]
5197    fn test_is_market_open_tick_false_when_before_session() {
5198        let tick = make_tick_at(50);
5199        assert!(!tick.is_market_open_tick(100, 1_000));
5200    }
5201
5202    #[test]
5203    fn test_is_market_open_tick_false_when_at_session_end() {
5204        let tick = make_tick_at(1_000);
5205        assert!(!tick.is_market_open_tick(100, 1_000)); // exclusive end
5206    }
5207
5208    // ── signed_quantity ───────────────────────────────────────────────────────
5209
5210    #[test]
5211    fn test_signed_quantity_positive_for_buy() {
5212        let mut tick = make_tick_at(0);
5213        tick.side = Some(TradeSide::Buy);
5214        assert!(tick.signed_quantity() > rust_decimal::Decimal::ZERO);
5215    }
5216
5217    #[test]
5218    fn test_signed_quantity_negative_for_sell() {
5219        let mut tick = make_tick_at(0);
5220        tick.side = Some(TradeSide::Sell);
5221        assert!(tick.signed_quantity() < rust_decimal::Decimal::ZERO);
5222    }
5223
5224    #[test]
5225    fn test_signed_quantity_zero_for_unknown() {
5226        let tick = make_tick_at(0); // side=None
5227        assert_eq!(tick.signed_quantity(), rust_decimal::Decimal::ZERO);
5228    }
5229
5230    // ── as_price_level ────────────────────────────────────────────────────────
5231
5232    #[test]
5233    fn test_as_price_level_returns_price_and_quantity() {
5234        let tick = make_tick_at(0); // price=100, qty=1
5235        let (p, q) = tick.as_price_level();
5236        assert_eq!(p, rust_decimal_macros::dec!(100));
5237        assert_eq!(q, rust_decimal_macros::dec!(1));
5238    }
5239
5240    // ── buy_volume / sell_volume ───────────────────────────────────────────────
5241
5242    fn make_sided_tick(qty: rust_decimal::Decimal, side: Option<TradeSide>) -> NormalizedTick {
5243        NormalizedTick {
5244            exchange: Exchange::Binance,
5245            symbol: "BTCUSDT".into(),
5246            price: rust_decimal_macros::dec!(100),
5247            quantity: qty,
5248            side,
5249            trade_id: None,
5250            exchange_ts_ms: None,
5251            received_at_ms: 0,
5252        }
5253    }
5254
5255    #[test]
5256    fn test_buy_volume_zero_for_empty_slice() {
5257        assert_eq!(NormalizedTick::buy_volume(&[]), rust_decimal::Decimal::ZERO);
5258    }
5259
5260    #[test]
5261    fn test_buy_volume_sums_only_buy_ticks() {
5262        let buy1 = make_sided_tick(rust_decimal_macros::dec!(2), Some(TradeSide::Buy));
5263        let sell = make_sided_tick(rust_decimal_macros::dec!(3), Some(TradeSide::Sell));
5264        let buy2 = make_sided_tick(rust_decimal_macros::dec!(5), Some(TradeSide::Buy));
5265        let unknown = make_sided_tick(rust_decimal_macros::dec!(10), None);
5266        assert_eq!(
5267            NormalizedTick::buy_volume(&[buy1, sell, buy2, unknown]),
5268            rust_decimal_macros::dec!(7)
5269        );
5270    }
5271
5272    #[test]
5273    fn test_sell_volume_zero_for_empty_slice() {
5274        assert_eq!(NormalizedTick::sell_volume(&[]), rust_decimal::Decimal::ZERO);
5275    }
5276
5277    #[test]
5278    fn test_sell_volume_sums_only_sell_ticks() {
5279        let buy = make_sided_tick(rust_decimal_macros::dec!(2), Some(TradeSide::Buy));
5280        let sell1 = make_sided_tick(rust_decimal_macros::dec!(3), Some(TradeSide::Sell));
5281        let sell2 = make_sided_tick(rust_decimal_macros::dec!(4), Some(TradeSide::Sell));
5282        assert_eq!(
5283            NormalizedTick::sell_volume(&[buy, sell1, sell2]),
5284            rust_decimal_macros::dec!(7)
5285        );
5286    }
5287
5288    #[test]
5289    fn test_buy_sell_volumes_dont_include_unknown_side() {
5290        let buy = make_sided_tick(rust_decimal_macros::dec!(5), Some(TradeSide::Buy));
5291        let sell = make_sided_tick(rust_decimal_macros::dec!(3), Some(TradeSide::Sell));
5292        let unknown = make_sided_tick(rust_decimal_macros::dec!(2), None);
5293        let ticks = [buy, sell, unknown];
5294        let total: rust_decimal::Decimal = ticks.iter().map(|t| t.quantity).sum();
5295        let accounted = NormalizedTick::buy_volume(&ticks) + NormalizedTick::sell_volume(&ticks);
5296        // 5 + 3 = 8, total = 10 (unknown 2 not counted)
5297        assert_eq!(accounted, rust_decimal_macros::dec!(8));
5298        assert!(accounted < total);
5299    }
5300
5301    // ── price_range / average_price ───────────────────────────────────────────
5302
5303    fn make_tick_with_price(price: rust_decimal::Decimal) -> NormalizedTick {
5304        NormalizedTick {
5305            exchange: Exchange::Binance,
5306            symbol: "BTCUSDT".into(),
5307            price,
5308            quantity: rust_decimal_macros::dec!(1),
5309            side: None,
5310            trade_id: None,
5311            exchange_ts_ms: None,
5312            received_at_ms: 0,
5313        }
5314    }
5315
5316    #[test]
5317    fn test_price_range_none_for_empty_slice() {
5318        assert!(NormalizedTick::price_range(&[]).is_none());
5319    }
5320
5321    #[test]
5322    fn test_price_range_zero_for_single_tick() {
5323        let tick = make_tick_with_price(rust_decimal_macros::dec!(100));
5324        assert_eq!(NormalizedTick::price_range(&[tick]), Some(rust_decimal_macros::dec!(0)));
5325    }
5326
5327    #[test]
5328    fn test_price_range_correct_for_multiple_ticks() {
5329        let t1 = make_tick_with_price(rust_decimal_macros::dec!(95));
5330        let t2 = make_tick_with_price(rust_decimal_macros::dec!(105));
5331        let t3 = make_tick_with_price(rust_decimal_macros::dec!(100));
5332        assert_eq!(NormalizedTick::price_range(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(10)));
5333    }
5334
5335    #[test]
5336    fn test_average_price_none_for_empty_slice() {
5337        assert!(NormalizedTick::average_price(&[]).is_none());
5338    }
5339
5340    #[test]
5341    fn test_average_price_equals_price_for_single_tick() {
5342        let tick = make_tick_with_price(rust_decimal_macros::dec!(200));
5343        assert_eq!(NormalizedTick::average_price(&[tick]), Some(rust_decimal_macros::dec!(200)));
5344    }
5345
5346    #[test]
5347    fn test_average_price_correct_for_multiple_ticks() {
5348        let t1 = make_tick_with_price(rust_decimal_macros::dec!(90));
5349        let t2 = make_tick_with_price(rust_decimal_macros::dec!(100));
5350        let t3 = make_tick_with_price(rust_decimal_macros::dec!(110));
5351        // (90 + 100 + 110) / 3 = 100
5352        assert_eq!(NormalizedTick::average_price(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(100)));
5353    }
5354
5355    // ── vwap ─────────────────────────────────────────────────────────────────
5356
5357    fn make_tick_pq(price: rust_decimal::Decimal, qty: rust_decimal::Decimal) -> NormalizedTick {
5358        NormalizedTick {
5359            exchange: Exchange::Binance,
5360            symbol: "BTCUSDT".into(),
5361            price,
5362            quantity: qty,
5363            side: None,
5364            trade_id: None,
5365            exchange_ts_ms: None,
5366            received_at_ms: 0,
5367        }
5368    }
5369
5370    #[test]
5371    fn test_vwap_none_for_empty_slice() {
5372        assert!(NormalizedTick::vwap(&[]).is_none());
5373    }
5374
5375    #[test]
5376    fn test_vwap_equals_price_for_single_tick() {
5377        let tick = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5));
5378        assert_eq!(NormalizedTick::vwap(&[tick]), Some(rust_decimal_macros::dec!(100)));
5379    }
5380
5381    #[test]
5382    fn test_vwap_weighted_correctly() {
5383        // 100 × 1 + 200 × 3 = 700; total qty = 4; VWAP = 175
5384        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5385        let t2 = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(3));
5386        assert_eq!(NormalizedTick::vwap(&[t1, t2]), Some(rust_decimal_macros::dec!(175)));
5387    }
5388
5389    #[test]
5390    fn test_vwap_none_for_zero_total_volume() {
5391        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(0));
5392        let t2 = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(0));
5393        assert!(NormalizedTick::vwap(&[t1, t2]).is_none());
5394    }
5395
5396    // ── count_above_price / count_below_price ─────────────────────────────────
5397
5398    #[test]
5399    fn test_count_above_price_zero_for_empty_slice() {
5400        assert_eq!(NormalizedTick::count_above_price(&[], rust_decimal_macros::dec!(100)), 0);
5401    }
5402
5403    #[test]
5404    fn test_count_above_price_correct() {
5405        let t1 = make_tick_with_price(rust_decimal_macros::dec!(90));
5406        let t2 = make_tick_with_price(rust_decimal_macros::dec!(100));
5407        let t3 = make_tick_with_price(rust_decimal_macros::dec!(110));
5408        assert_eq!(NormalizedTick::count_above_price(&[t1, t2, t3], rust_decimal_macros::dec!(100)), 1);
5409    }
5410
5411    #[test]
5412    fn test_count_below_price_correct() {
5413        let t1 = make_tick_with_price(rust_decimal_macros::dec!(90));
5414        let t2 = make_tick_with_price(rust_decimal_macros::dec!(100));
5415        let t3 = make_tick_with_price(rust_decimal_macros::dec!(110));
5416        assert_eq!(NormalizedTick::count_below_price(&[t1, t2, t3], rust_decimal_macros::dec!(100)), 1);
5417    }
5418
5419    #[test]
5420    fn test_count_above_at_threshold_excluded() {
5421        let tick = make_tick_with_price(rust_decimal_macros::dec!(100));
5422        assert_eq!(NormalizedTick::count_above_price(&[tick], rust_decimal_macros::dec!(100)), 0);
5423    }
5424
5425    #[test]
5426    fn test_count_below_at_threshold_excluded() {
5427        let tick = make_tick_with_price(rust_decimal_macros::dec!(100));
5428        assert_eq!(NormalizedTick::count_below_price(&[tick], rust_decimal_macros::dec!(100)), 0);
5429    }
5430
5431    // ── total_notional / buy_notional / sell_notional ─────────────────────────
5432
5433    #[test]
5434    fn test_total_notional_zero_for_empty_slice() {
5435        assert_eq!(NormalizedTick::total_notional(&[]), rust_decimal::Decimal::ZERO);
5436    }
5437
5438    #[test]
5439    fn test_total_notional_sums_all_ticks() {
5440        // 100 × 2 + 200 × 3 = 200 + 600 = 800
5441        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
5442        let t2 = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(3));
5443        assert_eq!(NormalizedTick::total_notional(&[t1, t2]), rust_decimal_macros::dec!(800));
5444    }
5445
5446    #[test]
5447    fn test_buy_notional_only_includes_buy_side() {
5448        let buy = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
5449        let sell = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(3));
5450        let buy_with_side = NormalizedTick { side: Some(TradeSide::Buy), ..buy };
5451        let sell_with_side = NormalizedTick { side: Some(TradeSide::Sell), ..sell };
5452        // buy notional = 100 × 2 = 200
5453        assert_eq!(NormalizedTick::buy_notional(&[buy_with_side, sell_with_side]), rust_decimal_macros::dec!(200));
5454    }
5455
5456    #[test]
5457    fn test_sell_notional_only_includes_sell_side() {
5458        let buy = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
5459        let sell = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(3));
5460        let buy_with_side = NormalizedTick { side: Some(TradeSide::Buy), ..buy };
5461        let sell_with_side = NormalizedTick { side: Some(TradeSide::Sell), ..sell };
5462        // sell notional = 200 × 3 = 600
5463        assert_eq!(NormalizedTick::sell_notional(&[buy_with_side, sell_with_side]), rust_decimal_macros::dec!(600));
5464    }
5465
5466    // ── median_price ──────────────────────────────────────────────────────────
5467
5468    #[test]
5469    fn test_median_price_none_for_empty_slice() {
5470        assert!(NormalizedTick::median_price(&[]).is_none());
5471    }
5472
5473    #[test]
5474    fn test_median_price_single_tick() {
5475        let tick = make_tick_with_price(rust_decimal_macros::dec!(150));
5476        assert_eq!(NormalizedTick::median_price(&[tick]), Some(rust_decimal_macros::dec!(150)));
5477    }
5478
5479    #[test]
5480    fn test_median_price_odd_count() {
5481        let t1 = make_tick_with_price(rust_decimal_macros::dec!(90));
5482        let t2 = make_tick_with_price(rust_decimal_macros::dec!(100));
5483        let t3 = make_tick_with_price(rust_decimal_macros::dec!(110));
5484        assert_eq!(NormalizedTick::median_price(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(100)));
5485    }
5486
5487    #[test]
5488    fn test_median_price_even_count() {
5489        let t1 = make_tick_with_price(rust_decimal_macros::dec!(90));
5490        let t2 = make_tick_with_price(rust_decimal_macros::dec!(100));
5491        // median = (90+100)/2 = 95
5492        assert_eq!(NormalizedTick::median_price(&[t1, t2]), Some(rust_decimal_macros::dec!(95)));
5493    }
5494
5495    // ── net_volume ────────────────────────────────────────────────────────────
5496
5497    #[test]
5498    fn test_net_volume_zero_for_empty_slice() {
5499        assert_eq!(NormalizedTick::net_volume(&[]), rust_decimal::Decimal::ZERO);
5500    }
5501
5502    #[test]
5503    fn test_net_volume_positive_when_more_buys() {
5504        let buy = NormalizedTick {
5505            side: Some(TradeSide::Buy),
5506            quantity: rust_decimal_macros::dec!(5),
5507            ..make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5))
5508        };
5509        let sell = NormalizedTick {
5510            side: Some(TradeSide::Sell),
5511            quantity: rust_decimal_macros::dec!(3),
5512            ..make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(3))
5513        };
5514        assert_eq!(NormalizedTick::net_volume(&[buy, sell]), rust_decimal_macros::dec!(2));
5515    }
5516
5517    #[test]
5518    fn test_net_volume_negative_when_more_sells() {
5519        let buy = NormalizedTick {
5520            side: Some(TradeSide::Buy),
5521            quantity: rust_decimal_macros::dec!(2),
5522            ..make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2))
5523        };
5524        let sell = NormalizedTick {
5525            side: Some(TradeSide::Sell),
5526            quantity: rust_decimal_macros::dec!(7),
5527            ..make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(7))
5528        };
5529        assert_eq!(NormalizedTick::net_volume(&[buy, sell]), rust_decimal_macros::dec!(-5));
5530    }
5531
5532    // ── average_quantity / max_quantity ───────────────────────────────────────
5533
5534    #[test]
5535    fn test_average_quantity_none_for_empty_slice() {
5536        assert!(NormalizedTick::average_quantity(&[]).is_none());
5537    }
5538
5539    #[test]
5540    fn test_average_quantity_single_tick() {
5541        let tick = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5));
5542        assert_eq!(NormalizedTick::average_quantity(&[tick]), Some(rust_decimal_macros::dec!(5)));
5543    }
5544
5545    #[test]
5546    fn test_average_quantity_multiple_ticks() {
5547        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
5548        let t2 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(4));
5549        // (2 + 4) / 2 = 3
5550        assert_eq!(NormalizedTick::average_quantity(&[t1, t2]), Some(rust_decimal_macros::dec!(3)));
5551    }
5552
5553    #[test]
5554    fn test_max_quantity_none_for_empty_slice() {
5555        assert!(NormalizedTick::max_quantity(&[]).is_none());
5556    }
5557
5558    #[test]
5559    fn test_max_quantity_returns_largest() {
5560        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
5561        let t2 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(10));
5562        let t3 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5));
5563        assert_eq!(NormalizedTick::max_quantity(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(10)));
5564    }
5565
5566    #[test]
5567    fn test_min_quantity_none_for_empty_slice() {
5568        assert!(NormalizedTick::min_quantity(&[]).is_none());
5569    }
5570
5571    #[test]
5572    fn test_min_quantity_returns_smallest() {
5573        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5));
5574        let t2 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5575        let t3 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(3));
5576        assert_eq!(NormalizedTick::min_quantity(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(1)));
5577    }
5578
5579    #[test]
5580    fn test_buy_count_zero_for_empty_slice() {
5581        assert_eq!(NormalizedTick::buy_count(&[]), 0);
5582    }
5583
5584    #[test]
5585    fn test_buy_count_counts_only_buys() {
5586        use rust_decimal_macros::dec;
5587        let mut buy = make_tick_pq(dec!(100), dec!(1));
5588        buy.side = Some(TradeSide::Buy);
5589        let mut sell = make_tick_pq(dec!(100), dec!(1));
5590        sell.side = Some(TradeSide::Sell);
5591        let neutral = make_tick_pq(dec!(100), dec!(1));
5592        assert_eq!(NormalizedTick::buy_count(&[buy, sell, neutral]), 1);
5593    }
5594
5595    #[test]
5596    fn test_sell_count_zero_for_empty_slice() {
5597        assert_eq!(NormalizedTick::sell_count(&[]), 0);
5598    }
5599
5600    #[test]
5601    fn test_sell_count_counts_only_sells() {
5602        use rust_decimal_macros::dec;
5603        let mut buy = make_tick_pq(dec!(100), dec!(1));
5604        buy.side = Some(TradeSide::Buy);
5605        let mut sell1 = make_tick_pq(dec!(100), dec!(1));
5606        sell1.side = Some(TradeSide::Sell);
5607        let mut sell2 = make_tick_pq(dec!(100), dec!(1));
5608        sell2.side = Some(TradeSide::Sell);
5609        assert_eq!(NormalizedTick::sell_count(&[buy, sell1, sell2]), 2);
5610    }
5611
5612    #[test]
5613    fn test_price_momentum_none_for_empty_slice() {
5614        assert!(NormalizedTick::price_momentum(&[]).is_none());
5615    }
5616
5617    #[test]
5618    fn test_price_momentum_none_for_single_tick() {
5619        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5620        assert!(NormalizedTick::price_momentum(&[t]).is_none());
5621    }
5622
5623    #[test]
5624    fn test_price_momentum_positive_when_price_rises() {
5625        use rust_decimal_macros::dec;
5626        let t1 = make_tick_pq(dec!(100), dec!(1));
5627        let t2 = make_tick_pq(dec!(110), dec!(1));
5628        let mom = NormalizedTick::price_momentum(&[t1, t2]).unwrap();
5629        assert!((mom - 0.1).abs() < 1e-9);
5630    }
5631
5632    #[test]
5633    fn test_price_momentum_negative_when_price_falls() {
5634        use rust_decimal_macros::dec;
5635        let t1 = make_tick_pq(dec!(100), dec!(1));
5636        let t2 = make_tick_pq(dec!(90), dec!(1));
5637        let mom = NormalizedTick::price_momentum(&[t1, t2]).unwrap();
5638        assert!(mom < 0.0);
5639    }
5640
5641    #[test]
5642    fn test_min_price_none_for_empty_slice() {
5643        assert!(NormalizedTick::min_price(&[]).is_none());
5644    }
5645
5646    #[test]
5647    fn test_min_price_returns_lowest() {
5648        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5649        let t2 = make_tick_pq(rust_decimal_macros::dec!(90), rust_decimal_macros::dec!(1));
5650        let t3 = make_tick_pq(rust_decimal_macros::dec!(110), rust_decimal_macros::dec!(1));
5651        assert_eq!(NormalizedTick::min_price(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(90)));
5652    }
5653
5654    #[test]
5655    fn test_max_price_none_for_empty_slice() {
5656        assert!(NormalizedTick::max_price(&[]).is_none());
5657    }
5658
5659    #[test]
5660    fn test_max_price_returns_highest() {
5661        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5662        let t2 = make_tick_pq(rust_decimal_macros::dec!(90), rust_decimal_macros::dec!(1));
5663        let t3 = make_tick_pq(rust_decimal_macros::dec!(110), rust_decimal_macros::dec!(1));
5664        assert_eq!(NormalizedTick::max_price(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(110)));
5665    }
5666
5667    #[test]
5668    fn test_price_std_dev_none_for_empty_slice() {
5669        assert!(NormalizedTick::price_std_dev(&[]).is_none());
5670    }
5671
5672    #[test]
5673    fn test_price_std_dev_none_for_single_tick() {
5674        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5675        assert!(NormalizedTick::price_std_dev(&[t]).is_none());
5676    }
5677
5678    #[test]
5679    fn test_price_std_dev_two_equal_prices_is_zero() {
5680        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5681        let t2 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5682        assert_eq!(NormalizedTick::price_std_dev(&[t1, t2]), Some(0.0));
5683    }
5684
5685    #[test]
5686    fn test_price_std_dev_positive_for_varying_prices() {
5687        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5688        let t2 = make_tick_pq(rust_decimal_macros::dec!(110), rust_decimal_macros::dec!(1));
5689        let t3 = make_tick_pq(rust_decimal_macros::dec!(90), rust_decimal_macros::dec!(1));
5690        let std = NormalizedTick::price_std_dev(&[t1, t2, t3]).unwrap();
5691        assert!(std > 0.0);
5692    }
5693
5694    #[test]
5695    fn test_buy_sell_ratio_none_for_empty_slice() {
5696        assert!(NormalizedTick::buy_sell_ratio(&[]).is_none());
5697    }
5698
5699    #[test]
5700    fn test_buy_sell_ratio_none_when_no_sells() {
5701        let mut t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5702        t.side = Some(TradeSide::Buy);
5703        assert!(NormalizedTick::buy_sell_ratio(&[t]).is_none());
5704    }
5705
5706    #[test]
5707    fn test_buy_sell_ratio_two_to_one() {
5708        use rust_decimal_macros::dec;
5709        let mut buy1 = make_tick_pq(dec!(100), dec!(2));
5710        buy1.side = Some(TradeSide::Buy);
5711        let mut buy2 = make_tick_pq(dec!(100), dec!(2));
5712        buy2.side = Some(TradeSide::Buy);
5713        let mut sell = make_tick_pq(dec!(100), dec!(2));
5714        sell.side = Some(TradeSide::Sell);
5715        let ratio = NormalizedTick::buy_sell_ratio(&[buy1, buy2, sell]).unwrap();
5716        assert!((ratio - 2.0).abs() < 1e-9);
5717    }
5718
5719    #[test]
5720    fn test_largest_trade_none_for_empty_slice() {
5721        assert!(NormalizedTick::largest_trade(&[]).is_none());
5722    }
5723
5724    #[test]
5725    fn test_largest_trade_returns_max_quantity_tick() {
5726        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
5727        let t2 = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(10));
5728        let t3 = make_tick_pq(rust_decimal_macros::dec!(150), rust_decimal_macros::dec!(5));
5729        let ticks = [t1, t2, t3];
5730        let largest = NormalizedTick::largest_trade(&ticks).unwrap();
5731        assert_eq!(largest.quantity, rust_decimal_macros::dec!(10));
5732    }
5733
5734    #[test]
5735    fn test_large_trade_count_zero_for_empty_slice() {
5736        assert_eq!(NormalizedTick::large_trade_count(&[], rust_decimal_macros::dec!(1)), 0);
5737    }
5738
5739    #[test]
5740    fn test_large_trade_count_counts_trades_above_threshold() {
5741        use rust_decimal_macros::dec;
5742        let t1 = make_tick_pq(dec!(100), dec!(0.5));
5743        let t2 = make_tick_pq(dec!(100), dec!(5));
5744        let t3 = make_tick_pq(dec!(100), dec!(10));
5745        assert_eq!(NormalizedTick::large_trade_count(&[t1, t2, t3], dec!(1)), 2);
5746    }
5747
5748    #[test]
5749    fn test_large_trade_count_strict_greater_than() {
5750        use rust_decimal_macros::dec;
5751        let t = make_tick_pq(dec!(100), dec!(1));
5752        // quantity == threshold → not counted (strict >)
5753        assert_eq!(NormalizedTick::large_trade_count(&[t], dec!(1)), 0);
5754    }
5755
5756    #[test]
5757    fn test_price_iqr_none_for_small_slice() {
5758        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5759        assert!(NormalizedTick::price_iqr(&[t.clone(), t.clone(), t]).is_none());
5760    }
5761
5762    #[test]
5763    fn test_price_iqr_positive_for_varied_prices() {
5764        use rust_decimal_macros::dec;
5765        let ticks: Vec<_> = [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50), dec!(60), dec!(70), dec!(80)]
5766            .iter()
5767            .map(|&p| make_tick_pq(p, dec!(1)))
5768            .collect();
5769        let iqr = NormalizedTick::price_iqr(&ticks).unwrap();
5770        assert!(iqr > dec!(0));
5771    }
5772
5773    #[test]
5774    fn test_fraction_buy_none_for_empty_slice() {
5775        assert!(NormalizedTick::fraction_buy(&[]).is_none());
5776    }
5777
5778    #[test]
5779    fn test_fraction_buy_zero_when_no_buys() {
5780        use rust_decimal_macros::dec;
5781        let mut t = make_tick_pq(dec!(100), dec!(1));
5782        t.side = Some(TradeSide::Sell);
5783        assert_eq!(NormalizedTick::fraction_buy(&[t]), Some(0.0));
5784    }
5785
5786    #[test]
5787    fn test_fraction_buy_one_when_all_buys() {
5788        use rust_decimal_macros::dec;
5789        let mut t = make_tick_pq(dec!(100), dec!(1));
5790        t.side = Some(TradeSide::Buy);
5791        assert_eq!(NormalizedTick::fraction_buy(&[t]), Some(1.0));
5792    }
5793
5794    #[test]
5795    fn test_fraction_buy_half_for_equal_mix() {
5796        use rust_decimal_macros::dec;
5797        let mut buy = make_tick_pq(dec!(100), dec!(1));
5798        buy.side = Some(TradeSide::Buy);
5799        let mut sell = make_tick_pq(dec!(100), dec!(1));
5800        sell.side = Some(TradeSide::Sell);
5801        let frac = NormalizedTick::fraction_buy(&[buy, sell]).unwrap();
5802        assert!((frac - 0.5).abs() < 1e-9);
5803    }
5804
5805    #[test]
5806    fn test_std_quantity_none_for_empty_slice() {
5807        assert!(NormalizedTick::std_quantity(&[]).is_none());
5808    }
5809
5810    #[test]
5811    fn test_std_quantity_none_for_single_tick() {
5812        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5));
5813        assert!(NormalizedTick::std_quantity(&[t]).is_none());
5814    }
5815
5816    #[test]
5817    fn test_std_quantity_zero_for_identical_quantities() {
5818        use rust_decimal_macros::dec;
5819        let t1 = make_tick_pq(dec!(100), dec!(5));
5820        let t2 = make_tick_pq(dec!(100), dec!(5));
5821        assert_eq!(NormalizedTick::std_quantity(&[t1, t2]), Some(0.0));
5822    }
5823
5824    #[test]
5825    fn test_std_quantity_positive_for_varied_quantities() {
5826        use rust_decimal_macros::dec;
5827        let t1 = make_tick_pq(dec!(100), dec!(1));
5828        let t2 = make_tick_pq(dec!(100), dec!(10));
5829        let std = NormalizedTick::std_quantity(&[t1, t2]).unwrap();
5830        assert!(std > 0.0);
5831    }
5832
5833    #[test]
5834    fn test_buy_pressure_none_for_empty_slice() {
5835        assert!(NormalizedTick::buy_pressure(&[]).is_none());
5836    }
5837
5838    #[test]
5839    fn test_buy_pressure_none_for_unsided_ticks() {
5840        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5841        assert!(NormalizedTick::buy_pressure(&[t]).is_none());
5842    }
5843
5844    #[test]
5845    fn test_buy_pressure_one_for_all_buys() {
5846        use rust_decimal_macros::dec;
5847        let mut t = make_tick_pq(dec!(100), dec!(1));
5848        t.side = Some(TradeSide::Buy);
5849        let bp = NormalizedTick::buy_pressure(&[t]).unwrap();
5850        assert!((bp - 1.0).abs() < 1e-9);
5851    }
5852
5853    #[test]
5854    fn test_buy_pressure_half_for_equal_volume() {
5855        use rust_decimal_macros::dec;
5856        let mut buy = make_tick_pq(dec!(100), dec!(5));
5857        buy.side = Some(TradeSide::Buy);
5858        let mut sell = make_tick_pq(dec!(100), dec!(5));
5859        sell.side = Some(TradeSide::Sell);
5860        let bp = NormalizedTick::buy_pressure(&[buy, sell]).unwrap();
5861        assert!((bp - 0.5).abs() < 1e-9);
5862    }
5863
5864    #[test]
5865    fn test_average_notional_none_for_empty_slice() {
5866        assert!(NormalizedTick::average_notional(&[]).is_none());
5867    }
5868
5869    #[test]
5870    fn test_average_notional_single_tick() {
5871        use rust_decimal_macros::dec;
5872        let t = make_tick_pq(dec!(100), dec!(2));
5873        assert_eq!(NormalizedTick::average_notional(&[t]), Some(dec!(200)));
5874    }
5875
5876    #[test]
5877    fn test_average_notional_multiple_ticks() {
5878        use rust_decimal_macros::dec;
5879        let t1 = make_tick_pq(dec!(100), dec!(1)); // notional = 100
5880        let t2 = make_tick_pq(dec!(200), dec!(1)); // notional = 200
5881        // avg = (100 + 200) / 2 = 150
5882        assert_eq!(NormalizedTick::average_notional(&[t1, t2]), Some(dec!(150)));
5883    }
5884
5885    #[test]
5886    fn test_count_neutral_zero_for_empty_slice() {
5887        assert_eq!(NormalizedTick::count_neutral(&[]), 0);
5888    }
5889
5890    #[test]
5891    fn test_count_neutral_counts_sideless_ticks() {
5892        use rust_decimal_macros::dec;
5893        let neutral = make_tick_pq(dec!(100), dec!(1)); // side = None
5894        let mut buy = make_tick_pq(dec!(100), dec!(1));
5895        buy.side = Some(TradeSide::Buy);
5896        assert_eq!(NormalizedTick::count_neutral(&[neutral, buy]), 1);
5897    }
5898
5899    #[test]
5900    fn test_recent_returns_all_when_n_exceeds_len() {
5901        use rust_decimal_macros::dec;
5902        let ticks = vec![
5903            make_tick_pq(dec!(100), dec!(1)),
5904            make_tick_pq(dec!(110), dec!(1)),
5905        ];
5906        assert_eq!(NormalizedTick::recent(&ticks, 10).len(), 2);
5907    }
5908
5909    #[test]
5910    fn test_recent_returns_last_n() {
5911        use rust_decimal_macros::dec;
5912        let ticks: Vec<_> = [dec!(100), dec!(110), dec!(120), dec!(130)]
5913            .iter()
5914            .map(|&p| make_tick_pq(p, dec!(1)))
5915            .collect();
5916        let recent = NormalizedTick::recent(&ticks, 2);
5917        assert_eq!(recent.len(), 2);
5918        assert_eq!(recent[0].price, dec!(120));
5919        assert_eq!(recent[1].price, dec!(130));
5920    }
5921
5922    #[test]
5923    fn test_price_linear_slope_none_for_single_tick() {
5924        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5925        assert!(NormalizedTick::price_linear_slope(&[t]).is_none());
5926    }
5927
5928    #[test]
5929    fn test_price_linear_slope_positive_for_rising_prices() {
5930        use rust_decimal_macros::dec;
5931        let ticks: Vec<_> = [dec!(100), dec!(110), dec!(120)]
5932            .iter()
5933            .map(|&p| make_tick_pq(p, dec!(1)))
5934            .collect();
5935        let slope = NormalizedTick::price_linear_slope(&ticks).unwrap();
5936        assert!(slope > 0.0);
5937    }
5938
5939    #[test]
5940    fn test_price_linear_slope_negative_for_falling_prices() {
5941        use rust_decimal_macros::dec;
5942        let ticks: Vec<_> = [dec!(120), dec!(110), dec!(100)]
5943            .iter()
5944            .map(|&p| make_tick_pq(p, dec!(1)))
5945            .collect();
5946        let slope = NormalizedTick::price_linear_slope(&ticks).unwrap();
5947        assert!(slope < 0.0);
5948    }
5949
5950    #[test]
5951    fn test_notional_std_dev_none_for_single_tick() {
5952        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5953        assert!(NormalizedTick::notional_std_dev(&[t]).is_none());
5954    }
5955
5956    #[test]
5957    fn test_notional_std_dev_zero_for_identical_notionals() {
5958        use rust_decimal_macros::dec;
5959        let t1 = make_tick_pq(dec!(100), dec!(1)); // notional=100
5960        let t2 = make_tick_pq(dec!(100), dec!(1)); // notional=100
5961        assert_eq!(NormalizedTick::notional_std_dev(&[t1, t2]), Some(0.0));
5962    }
5963
5964    #[test]
5965    fn test_notional_std_dev_positive_for_varied_notionals() {
5966        use rust_decimal_macros::dec;
5967        let t1 = make_tick_pq(dec!(100), dec!(1)); // notional=100
5968        let t2 = make_tick_pq(dec!(200), dec!(2)); // notional=400
5969        let std = NormalizedTick::notional_std_dev(&[t1, t2]).unwrap();
5970        assert!(std > 0.0);
5971    }
5972
5973    #[test]
5974    fn test_monotone_up_true_for_empty_slice() {
5975        assert!(NormalizedTick::monotone_up(&[]));
5976    }
5977
5978    #[test]
5979    fn test_monotone_up_true_for_non_decreasing_prices() {
5980        use rust_decimal_macros::dec;
5981        let ticks: Vec<_> = [dec!(100), dec!(100), dec!(110), dec!(120)]
5982            .iter().map(|&p| make_tick_pq(p, dec!(1))).collect();
5983        assert!(NormalizedTick::monotone_up(&ticks));
5984    }
5985
5986    #[test]
5987    fn test_monotone_up_false_for_any_decrease() {
5988        use rust_decimal_macros::dec;
5989        let ticks: Vec<_> = [dec!(100), dec!(110), dec!(105)]
5990            .iter().map(|&p| make_tick_pq(p, dec!(1))).collect();
5991        assert!(!NormalizedTick::monotone_up(&ticks));
5992    }
5993
5994    #[test]
5995    fn test_monotone_down_true_for_non_increasing_prices() {
5996        use rust_decimal_macros::dec;
5997        let ticks: Vec<_> = [dec!(120), dec!(110), dec!(110), dec!(100)]
5998            .iter().map(|&p| make_tick_pq(p, dec!(1))).collect();
5999        assert!(NormalizedTick::monotone_down(&ticks));
6000    }
6001
6002    #[test]
6003    fn test_monotone_down_false_for_any_increase() {
6004        use rust_decimal_macros::dec;
6005        let ticks: Vec<_> = [dec!(100), dec!(90), dec!(95)]
6006            .iter().map(|&p| make_tick_pq(p, dec!(1))).collect();
6007        assert!(!NormalizedTick::monotone_down(&ticks));
6008    }
6009
6010    #[test]
6011    fn test_volume_at_price_zero_for_empty_slice() {
6012        assert_eq!(NormalizedTick::volume_at_price(&[], rust_decimal_macros::dec!(100)), rust_decimal_macros::dec!(0));
6013    }
6014
6015    #[test]
6016    fn test_volume_at_price_sums_matching_ticks() {
6017        use rust_decimal_macros::dec;
6018        let t1 = make_tick_pq(dec!(100), dec!(2));
6019        let t2 = make_tick_pq(dec!(100), dec!(3));
6020        let t3 = make_tick_pq(dec!(110), dec!(5));
6021        assert_eq!(NormalizedTick::volume_at_price(&[t1, t2, t3], dec!(100)), dec!(5));
6022    }
6023
6024    #[test]
6025    fn test_last_price_none_for_empty_slice() {
6026        assert!(NormalizedTick::last_price(&[]).is_none());
6027    }
6028
6029    #[test]
6030    fn test_last_price_returns_last_tick_price() {
6031        use rust_decimal_macros::dec;
6032        let t1 = make_tick_pq(dec!(100), dec!(1));
6033        let t2 = make_tick_pq(dec!(110), dec!(1));
6034        assert_eq!(NormalizedTick::last_price(&[t1, t2]), Some(dec!(110)));
6035    }
6036
6037    #[test]
6038    fn test_longest_buy_streak_zero_for_empty() {
6039        assert_eq!(NormalizedTick::longest_buy_streak(&[]), 0);
6040    }
6041
6042    #[test]
6043    fn test_longest_buy_streak_counts_consecutive_buys() {
6044        use rust_decimal_macros::dec;
6045        let mut b1 = make_tick_pq(dec!(100), dec!(1)); b1.side = Some(TradeSide::Buy);
6046        let mut b2 = make_tick_pq(dec!(100), dec!(1)); b2.side = Some(TradeSide::Buy);
6047        let mut s  = make_tick_pq(dec!(100), dec!(1)); s.side = Some(TradeSide::Sell);
6048        let mut b3 = make_tick_pq(dec!(100), dec!(1)); b3.side = Some(TradeSide::Buy);
6049        // streaks: [2, 1] → max = 2
6050        assert_eq!(NormalizedTick::longest_buy_streak(&[b1, b2, s, b3]), 2);
6051    }
6052
6053    #[test]
6054    fn test_longest_sell_streak_zero_for_no_sells() {
6055        use rust_decimal_macros::dec;
6056        let mut b = make_tick_pq(dec!(100), dec!(1)); b.side = Some(TradeSide::Buy);
6057        assert_eq!(NormalizedTick::longest_sell_streak(&[b]), 0);
6058    }
6059
6060    #[test]
6061    fn test_longest_sell_streak_correct() {
6062        use rust_decimal_macros::dec;
6063        let mut b  = make_tick_pq(dec!(100), dec!(1)); b.side = Some(TradeSide::Buy);
6064        let mut s1 = make_tick_pq(dec!(100), dec!(1)); s1.side = Some(TradeSide::Sell);
6065        let mut s2 = make_tick_pq(dec!(100), dec!(1)); s2.side = Some(TradeSide::Sell);
6066        let mut s3 = make_tick_pq(dec!(100), dec!(1)); s3.side = Some(TradeSide::Sell);
6067        assert_eq!(NormalizedTick::longest_sell_streak(&[b, s1, s2, s3]), 3);
6068    }
6069
6070    #[test]
6071    fn test_price_at_max_volume_none_for_empty() {
6072        assert!(NormalizedTick::price_at_max_volume(&[]).is_none());
6073    }
6074
6075    #[test]
6076    fn test_price_at_max_volume_returns_dominant_price() {
6077        use rust_decimal_macros::dec;
6078        let t1 = make_tick_pq(dec!(100), dec!(1));
6079        let t2 = make_tick_pq(dec!(200), dec!(5));
6080        let t3 = make_tick_pq(dec!(200), dec!(3));
6081        // price 200 has total vol 8 > price 100 vol 1
6082        assert_eq!(NormalizedTick::price_at_max_volume(&[t1, t2, t3]), Some(dec!(200)));
6083    }
6084
6085    #[test]
6086    fn test_recent_volume_zero_for_empty_slice() {
6087        assert_eq!(NormalizedTick::recent_volume(&[], 5), rust_decimal_macros::dec!(0));
6088    }
6089
6090    #[test]
6091    fn test_recent_volume_sums_last_n_ticks() {
6092        use rust_decimal_macros::dec;
6093        let ticks: Vec<_> = [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)]
6094            .iter().map(|&q| make_tick_pq(dec!(100), q)).collect();
6095        // last 3 ticks: qty 3+4+5 = 12
6096        assert_eq!(NormalizedTick::recent_volume(&ticks, 3), dec!(12));
6097    }
6098
6099    // ── NormalizedTick::first_price ───────────────────────────────────────────
6100
6101    #[test]
6102    fn test_first_price_none_for_empty_slice() {
6103        assert!(NormalizedTick::first_price(&[]).is_none());
6104    }
6105
6106    #[test]
6107    fn test_first_price_returns_first_tick_price() {
6108        use rust_decimal_macros::dec;
6109        let ticks = vec![make_tick_pq(dec!(50), dec!(1)), make_tick_pq(dec!(60), dec!(1))];
6110        assert_eq!(NormalizedTick::first_price(&ticks), Some(dec!(50)));
6111    }
6112
6113    // ── NormalizedTick::price_return_pct ─────────────────────────────────────
6114
6115    #[test]
6116    fn test_price_return_pct_none_for_single_tick() {
6117        use rust_decimal_macros::dec;
6118        assert!(NormalizedTick::price_return_pct(&[make_tick_pq(dec!(100), dec!(1))]).is_none());
6119    }
6120
6121    #[test]
6122    fn test_price_return_pct_positive_for_rising_price() {
6123        use rust_decimal_macros::dec;
6124        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(110), dec!(1))];
6125        let pct = NormalizedTick::price_return_pct(&ticks).unwrap();
6126        assert!((pct - 0.1).abs() < 1e-9);
6127    }
6128
6129    #[test]
6130    fn test_price_return_pct_negative_for_falling_price() {
6131        use rust_decimal_macros::dec;
6132        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(90), dec!(1))];
6133        let pct = NormalizedTick::price_return_pct(&ticks).unwrap();
6134        assert!((pct - (-0.1)).abs() < 1e-9);
6135    }
6136
6137    // ── NormalizedTick::volume_above_price / volume_below_price ──────────────
6138
6139    #[test]
6140    fn test_volume_above_price_zero_for_empty_slice() {
6141        use rust_decimal_macros::dec;
6142        assert_eq!(NormalizedTick::volume_above_price(&[], dec!(100)), dec!(0));
6143    }
6144
6145    #[test]
6146    fn test_volume_above_price_sums_above_threshold() {
6147        use rust_decimal_macros::dec;
6148        let ticks = vec![
6149            make_tick_pq(dec!(90), dec!(5)),
6150            make_tick_pq(dec!(100), dec!(10)),
6151            make_tick_pq(dec!(110), dec!(3)),
6152        ];
6153        // only price=110 is above 100
6154        assert_eq!(NormalizedTick::volume_above_price(&ticks, dec!(100)), dec!(3));
6155    }
6156
6157    #[test]
6158    fn test_volume_below_price_sums_below_threshold() {
6159        use rust_decimal_macros::dec;
6160        let ticks = vec![
6161            make_tick_pq(dec!(90), dec!(5)),
6162            make_tick_pq(dec!(100), dec!(10)),
6163            make_tick_pq(dec!(110), dec!(3)),
6164        ];
6165        // only price=90 is below 100
6166        assert_eq!(NormalizedTick::volume_below_price(&ticks, dec!(100)), dec!(5));
6167    }
6168
6169    // ── NormalizedTick::quantity_weighted_avg_price ───────────────────────────
6170
6171    #[test]
6172    fn test_qwap_none_for_empty_slice() {
6173        assert!(NormalizedTick::quantity_weighted_avg_price(&[]).is_none());
6174    }
6175
6176    #[test]
6177    fn test_qwap_correct_for_equal_quantities() {
6178        use rust_decimal_macros::dec;
6179        // equal qty → simple average of prices
6180        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(200), dec!(1))];
6181        assert_eq!(NormalizedTick::quantity_weighted_avg_price(&ticks), Some(dec!(150)));
6182    }
6183
6184    #[test]
6185    fn test_qwap_weighted_towards_higher_volume() {
6186        use rust_decimal_macros::dec;
6187        // price=100 qty=1, price=200 qty=3 → (100+600)/4 = 175
6188        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(200), dec!(3))];
6189        assert_eq!(NormalizedTick::quantity_weighted_avg_price(&ticks), Some(dec!(175)));
6190    }
6191
6192    // ── NormalizedTick::tick_count_above_price / tick_count_below_price ───────
6193
6194    #[test]
6195    fn test_tick_count_above_price_zero_for_empty_slice() {
6196        use rust_decimal_macros::dec;
6197        assert_eq!(NormalizedTick::tick_count_above_price(&[], dec!(100)), 0);
6198    }
6199
6200    #[test]
6201    fn test_tick_count_above_price_correct() {
6202        use rust_decimal_macros::dec;
6203        let ticks = vec![
6204            make_tick_pq(dec!(90), dec!(1)),
6205            make_tick_pq(dec!(100), dec!(1)),
6206            make_tick_pq(dec!(110), dec!(1)),
6207            make_tick_pq(dec!(120), dec!(1)),
6208        ];
6209        assert_eq!(NormalizedTick::tick_count_above_price(&ticks, dec!(100)), 2);
6210    }
6211
6212    #[test]
6213    fn test_tick_count_below_price_correct() {
6214        use rust_decimal_macros::dec;
6215        let ticks = vec![
6216            make_tick_pq(dec!(90), dec!(1)),
6217            make_tick_pq(dec!(100), dec!(1)),
6218            make_tick_pq(dec!(110), dec!(1)),
6219        ];
6220        assert_eq!(NormalizedTick::tick_count_below_price(&ticks, dec!(100)), 1);
6221    }
6222
6223    // ── NormalizedTick::price_at_percentile ──────────────────────────────────
6224
6225    #[test]
6226    fn test_price_at_percentile_none_for_empty_slice() {
6227        use rust_decimal_macros::dec;
6228        assert!(NormalizedTick::price_at_percentile(&[], 0.5).is_none());
6229    }
6230
6231    #[test]
6232    fn test_price_at_percentile_none_for_out_of_range() {
6233        use rust_decimal_macros::dec;
6234        let ticks = vec![make_tick_pq(dec!(100), dec!(1))];
6235        assert!(NormalizedTick::price_at_percentile(&ticks, 1.5).is_none());
6236    }
6237
6238    #[test]
6239    fn test_price_at_percentile_median_for_sorted_prices() {
6240        use rust_decimal_macros::dec;
6241        let ticks = vec![
6242            make_tick_pq(dec!(10), dec!(1)),
6243            make_tick_pq(dec!(20), dec!(1)),
6244            make_tick_pq(dec!(30), dec!(1)),
6245            make_tick_pq(dec!(40), dec!(1)),
6246            make_tick_pq(dec!(50), dec!(1)),
6247        ];
6248        // 50th percentile → index 2 → price=30
6249        assert_eq!(NormalizedTick::price_at_percentile(&ticks, 0.5), Some(dec!(30)));
6250    }
6251
6252    // ── NormalizedTick::unique_price_count ────────────────────────────────────
6253
6254    #[test]
6255    fn test_unique_price_count_zero_for_empty() {
6256        assert_eq!(NormalizedTick::unique_price_count(&[]), 0);
6257    }
6258
6259    #[test]
6260    fn test_unique_price_count_counts_distinct_prices() {
6261        use rust_decimal_macros::dec;
6262        let ticks = vec![
6263            make_tick_pq(dec!(100), dec!(1)),
6264            make_tick_pq(dec!(100), dec!(2)),
6265            make_tick_pq(dec!(110), dec!(1)),
6266            make_tick_pq(dec!(120), dec!(1)),
6267        ];
6268        assert_eq!(NormalizedTick::unique_price_count(&ticks), 3);
6269    }
6270
6271    // ── NormalizedTick::sell_volume / buy_volume ──────────────────────────────
6272
6273    #[test]
6274    fn test_sell_volume_zero_for_empty() {
6275        assert_eq!(NormalizedTick::sell_volume(&[]), rust_decimal_macros::dec!(0));
6276    }
6277
6278    #[test]
6279    fn test_sell_volume_sums_sell_side_only() {
6280        use rust_decimal_macros::dec;
6281        let mut buy_tick = make_tick_pq(dec!(100), dec!(5));
6282        buy_tick.side = Some(TradeSide::Buy);
6283        let mut sell_tick = make_tick_pq(dec!(100), dec!(3));
6284        sell_tick.side = Some(TradeSide::Sell);
6285        let no_side_tick = make_tick_pq(dec!(100), dec!(10));
6286        let ticks = [buy_tick, sell_tick, no_side_tick];
6287        assert_eq!(NormalizedTick::sell_volume(&ticks), dec!(3));
6288        assert_eq!(NormalizedTick::buy_volume(&ticks), dec!(5));
6289    }
6290
6291    // ── NormalizedTick::avg_inter_tick_spread ─────────────────────────────────
6292
6293    #[test]
6294    fn test_avg_inter_tick_spread_none_for_single_tick() {
6295        use rust_decimal_macros::dec;
6296        assert!(NormalizedTick::avg_inter_tick_spread(&[make_tick_pq(dec!(100), dec!(1))]).is_none());
6297    }
6298
6299    #[test]
6300    fn test_avg_inter_tick_spread_correct_for_uniform_moves() {
6301        use rust_decimal_macros::dec;
6302        // prices: 100, 102, 104 → diffs: 2, 2 → avg = 2.0
6303        let ticks = vec![
6304            make_tick_pq(dec!(100), dec!(1)),
6305            make_tick_pq(dec!(102), dec!(1)),
6306            make_tick_pq(dec!(104), dec!(1)),
6307        ];
6308        let spread = NormalizedTick::avg_inter_tick_spread(&ticks).unwrap();
6309        assert!((spread - 2.0).abs() < 1e-9);
6310    }
6311
6312    // ── NormalizedTick::price_range ───────────────────────────────────────────
6313
6314    #[test]
6315    fn test_price_range_none_for_empty() {
6316        assert!(NormalizedTick::price_range(&[]).is_none());
6317    }
6318
6319    #[test]
6320    fn test_price_range_correct() {
6321        use rust_decimal_macros::dec;
6322        let ticks = vec![
6323            make_tick_pq(dec!(90), dec!(1)),
6324            make_tick_pq(dec!(110), dec!(1)),
6325            make_tick_pq(dec!(100), dec!(1)),
6326        ];
6327        assert_eq!(NormalizedTick::price_range(&ticks), Some(dec!(20)));
6328    }
6329
6330    // ── NormalizedTick::median_price ──────────────────────────────────────────
6331
6332    #[test]
6333    fn test_median_price_none_for_empty() {
6334        assert!(NormalizedTick::median_price(&[]).is_none());
6335    }
6336
6337    #[test]
6338    fn test_median_price_returns_middle_value() {
6339        use rust_decimal_macros::dec;
6340        let ticks = vec![
6341            make_tick_pq(dec!(10), dec!(1)),
6342            make_tick_pq(dec!(30), dec!(1)),
6343            make_tick_pq(dec!(20), dec!(1)),
6344        ];
6345        // sorted: 10,20,30 → idx 1 = 20
6346        assert_eq!(NormalizedTick::median_price(&ticks), Some(dec!(20)));
6347    }
6348
6349    // ── NormalizedTick::largest_sell / largest_buy ────────────────────────────
6350
6351    #[test]
6352    fn test_largest_sell_none_for_no_sell_ticks() {
6353        use rust_decimal_macros::dec;
6354        let mut t = make_tick_pq(dec!(100), dec!(5));
6355        t.side = Some(TradeSide::Buy);
6356        assert!(NormalizedTick::largest_sell(&[t]).is_none());
6357    }
6358
6359    #[test]
6360    fn test_largest_sell_returns_max_sell_qty() {
6361        use rust_decimal_macros::dec;
6362        let mut t1 = make_tick_pq(dec!(100), dec!(3));
6363        t1.side = Some(TradeSide::Sell);
6364        let mut t2 = make_tick_pq(dec!(100), dec!(7));
6365        t2.side = Some(TradeSide::Sell);
6366        assert_eq!(NormalizedTick::largest_sell(&[t1, t2]), Some(dec!(7)));
6367    }
6368
6369    #[test]
6370    fn test_largest_buy_returns_max_buy_qty() {
6371        use rust_decimal_macros::dec;
6372        let mut t1 = make_tick_pq(dec!(100), dec!(2));
6373        t1.side = Some(TradeSide::Buy);
6374        let mut t2 = make_tick_pq(dec!(100), dec!(9));
6375        t2.side = Some(TradeSide::Buy);
6376        assert_eq!(NormalizedTick::largest_buy(&[t1, t2]), Some(dec!(9)));
6377    }
6378
6379    // ── NormalizedTick::trade_count ───────────────────────────────────────────
6380
6381    #[test]
6382    fn test_trade_count_zero_for_empty() {
6383        assert_eq!(NormalizedTick::trade_count(&[]), 0);
6384    }
6385
6386    #[test]
6387    fn test_trade_count_matches_slice_length() {
6388        use rust_decimal_macros::dec;
6389        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(2))];
6390        assert_eq!(NormalizedTick::trade_count(&ticks), 2);
6391    }
6392
6393    // ── NormalizedTick::price_acceleration ───────────────────────────────────
6394
6395    #[test]
6396    fn test_price_acceleration_none_for_fewer_than_3() {
6397        use rust_decimal_macros::dec;
6398        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(1))];
6399        assert!(NormalizedTick::price_acceleration(&ticks).is_none());
6400    }
6401
6402    #[test]
6403    fn test_price_acceleration_zero_for_constant_velocity() {
6404        use rust_decimal_macros::dec;
6405        // prices: 100, 102, 104 → v1=2, v2=2 → accel=0
6406        let ticks = vec![
6407            make_tick_pq(dec!(100), dec!(1)),
6408            make_tick_pq(dec!(102), dec!(1)),
6409            make_tick_pq(dec!(104), dec!(1)),
6410        ];
6411        let acc = NormalizedTick::price_acceleration(&ticks).unwrap();
6412        assert!((acc - 0.0).abs() < 1e-9);
6413    }
6414
6415    #[test]
6416    fn test_price_acceleration_positive_when_speeding_up() {
6417        use rust_decimal_macros::dec;
6418        // prices: 100, 101, 103 → v1=1, v2=2 → accel=1
6419        let ticks = vec![
6420            make_tick_pq(dec!(100), dec!(1)),
6421            make_tick_pq(dec!(101), dec!(1)),
6422            make_tick_pq(dec!(103), dec!(1)),
6423        ];
6424        let acc = NormalizedTick::price_acceleration(&ticks).unwrap();
6425        assert!((acc - 1.0).abs() < 1e-9);
6426    }
6427
6428    // ── NormalizedTick::buy_sell_diff ─────────────────────────────────────────
6429
6430    #[test]
6431    fn test_buy_sell_diff_zero_for_empty() {
6432        assert_eq!(NormalizedTick::buy_sell_diff(&[]), rust_decimal_macros::dec!(0));
6433    }
6434
6435    #[test]
6436    fn test_buy_sell_diff_positive_for_net_buying() {
6437        use rust_decimal_macros::dec;
6438        let mut t1 = make_tick_pq(dec!(100), dec!(10));
6439        t1.side = Some(TradeSide::Buy);
6440        let mut t2 = make_tick_pq(dec!(100), dec!(3));
6441        t2.side = Some(TradeSide::Sell);
6442        assert_eq!(NormalizedTick::buy_sell_diff(&[t1, t2]), dec!(7));
6443    }
6444
6445    // ── NormalizedTick::is_aggressive_buy / is_aggressive_sell ───────────────
6446
6447    #[test]
6448    fn test_is_aggressive_buy_true_when_exceeds_avg() {
6449        use rust_decimal_macros::dec;
6450        let mut t = make_tick_pq(dec!(100), dec!(15));
6451        t.side = Some(TradeSide::Buy);
6452        assert!(NormalizedTick::is_aggressive_buy(&t, dec!(10)));
6453    }
6454
6455    #[test]
6456    fn test_is_aggressive_buy_false_when_not_buy_side() {
6457        use rust_decimal_macros::dec;
6458        let mut t = make_tick_pq(dec!(100), dec!(15));
6459        t.side = Some(TradeSide::Sell);
6460        assert!(!NormalizedTick::is_aggressive_buy(&t, dec!(10)));
6461    }
6462
6463    #[test]
6464    fn test_is_aggressive_sell_true_when_exceeds_avg() {
6465        use rust_decimal_macros::dec;
6466        let mut t = make_tick_pq(dec!(100), dec!(20));
6467        t.side = Some(TradeSide::Sell);
6468        assert!(NormalizedTick::is_aggressive_sell(&t, dec!(10)));
6469    }
6470
6471    // ── NormalizedTick::notional_volume ───────────────────────────────────────
6472
6473    #[test]
6474    fn test_notional_volume_zero_for_empty() {
6475        assert_eq!(NormalizedTick::notional_volume(&[]), rust_decimal_macros::dec!(0));
6476    }
6477
6478    #[test]
6479    fn test_notional_volume_correct() {
6480        use rust_decimal_macros::dec;
6481        let ticks = vec![
6482            make_tick_pq(dec!(100), dec!(2)),  // 200
6483            make_tick_pq(dec!(50), dec!(4)),   // 200
6484        ];
6485        assert_eq!(NormalizedTick::notional_volume(&ticks), dec!(400));
6486    }
6487
6488    // ── NormalizedTick::weighted_side_score ───────────────────────────────────
6489
6490    #[test]
6491    fn test_weighted_side_score_none_for_empty() {
6492        assert!(NormalizedTick::weighted_side_score(&[]).is_none());
6493    }
6494
6495    #[test]
6496    fn test_weighted_side_score_correct_for_all_buys() {
6497        use rust_decimal_macros::dec;
6498        let mut t = make_tick_pq(dec!(100), dec!(10));
6499        t.side = Some(TradeSide::Buy);
6500        // buy=10, sell=0, total=10 → score=1.0
6501        let score = NormalizedTick::weighted_side_score(&[t]).unwrap();
6502        assert!((score - 1.0).abs() < 1e-9);
6503    }
6504
6505    // ── NormalizedTick::time_span_ms ──────────────────────────────────────────
6506
6507    #[test]
6508    fn test_time_span_none_for_single_tick() {
6509        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
6510        assert!(NormalizedTick::time_span_ms(&[t]).is_none());
6511    }
6512
6513    #[test]
6514    fn test_time_span_correct_for_two_ticks() {
6515        use rust_decimal_macros::dec;
6516        let mut t1 = make_tick_pq(dec!(100), dec!(1));
6517        t1.received_at_ms = 1000;
6518        let mut t2 = make_tick_pq(dec!(101), dec!(1));
6519        t2.received_at_ms = 5000;
6520        assert_eq!(NormalizedTick::time_span_ms(&[t1, t2]), Some(4000));
6521    }
6522
6523    // ── NormalizedTick::price_above_vwap_count ────────────────────────────────
6524
6525    #[test]
6526    fn test_price_above_vwap_count_none_for_empty() {
6527        assert!(NormalizedTick::price_above_vwap_count(&[]).is_none());
6528    }
6529
6530    #[test]
6531    fn test_price_above_vwap_count_correct() {
6532        use rust_decimal_macros::dec;
6533        // Equal quantities: VWAP = (90+100+110)/3 = 100; above: 110 = 1 tick
6534        let ticks = vec![
6535            make_tick_pq(dec!(90), dec!(1)),
6536            make_tick_pq(dec!(100), dec!(1)),
6537            make_tick_pq(dec!(110), dec!(1)),
6538        ];
6539        assert_eq!(NormalizedTick::price_above_vwap_count(&ticks), Some(1));
6540    }
6541
6542    // ── NormalizedTick::avg_trade_size ────────────────────────────────────────
6543
6544    #[test]
6545    fn test_avg_trade_size_none_for_empty() {
6546        assert!(NormalizedTick::avg_trade_size(&[]).is_none());
6547    }
6548
6549    #[test]
6550    fn test_avg_trade_size_correct() {
6551        use rust_decimal_macros::dec;
6552        let ticks = vec![
6553            make_tick_pq(dec!(100), dec!(2)),
6554            make_tick_pq(dec!(101), dec!(4)),
6555        ];
6556        assert_eq!(NormalizedTick::avg_trade_size(&ticks), Some(dec!(3)));
6557    }
6558
6559    // ── NormalizedTick::volume_concentration ─────────────────────────────────
6560
6561    #[test]
6562    fn test_volume_concentration_none_for_empty() {
6563        assert!(NormalizedTick::volume_concentration(&[]).is_none());
6564    }
6565
6566    #[test]
6567    fn test_volume_concentration_is_one_for_single_tick() {
6568        use rust_decimal_macros::dec;
6569        let ticks = vec![make_tick_pq(dec!(100), dec!(5))];
6570        let c = NormalizedTick::volume_concentration(&ticks).unwrap();
6571        assert!((c - 1.0).abs() < 1e-9);
6572    }
6573
6574    #[test]
6575    fn test_volume_concentration_in_range() {
6576        use rust_decimal_macros::dec;
6577        let ticks = vec![
6578            make_tick_pq(dec!(100), dec!(1)),
6579            make_tick_pq(dec!(101), dec!(1)),
6580            make_tick_pq(dec!(102), dec!(1)),
6581            make_tick_pq(dec!(103), dec!(10)),
6582        ];
6583        let c = NormalizedTick::volume_concentration(&ticks).unwrap();
6584        assert!(c > 0.0 && c <= 1.0, "expected value in (0,1], got {}", c);
6585    }
6586
6587    // ── NormalizedTick::trade_imbalance_score ─────────────────────────────────
6588
6589    #[test]
6590    fn test_trade_imbalance_score_none_for_empty() {
6591        assert!(NormalizedTick::trade_imbalance_score(&[]).is_none());
6592    }
6593
6594    #[test]
6595    fn test_trade_imbalance_score_positive_for_all_buys() {
6596        use rust_decimal_macros::dec;
6597        let mut t = make_tick_pq(dec!(100), dec!(1));
6598        t.side = Some(TradeSide::Buy);
6599        let score = NormalizedTick::trade_imbalance_score(&[t]).unwrap();
6600        assert!(score > 0.0);
6601    }
6602
6603    #[test]
6604    fn test_trade_imbalance_score_negative_for_all_sells() {
6605        use rust_decimal_macros::dec;
6606        let mut t = make_tick_pq(dec!(100), dec!(1));
6607        t.side = Some(TradeSide::Sell);
6608        let score = NormalizedTick::trade_imbalance_score(&[t]).unwrap();
6609        assert!(score < 0.0);
6610    }
6611
6612    // ── NormalizedTick::price_entropy ─────────────────────────────────────────
6613
6614    #[test]
6615    fn test_price_entropy_none_for_empty() {
6616        assert!(NormalizedTick::price_entropy(&[]).is_none());
6617    }
6618
6619    #[test]
6620    fn test_price_entropy_zero_for_single_price() {
6621        use rust_decimal_macros::dec;
6622        let ticks = vec![
6623            make_tick_pq(dec!(100), dec!(1)),
6624            make_tick_pq(dec!(100), dec!(2)),
6625        ];
6626        let e = NormalizedTick::price_entropy(&ticks).unwrap();
6627        assert!((e - 0.0).abs() < 1e-9, "identical prices should have zero entropy, got {}", e);
6628    }
6629
6630    #[test]
6631    fn test_price_entropy_positive_for_varied_prices() {
6632        use rust_decimal_macros::dec;
6633        let ticks = vec![
6634            make_tick_pq(dec!(100), dec!(1)),
6635            make_tick_pq(dec!(101), dec!(1)),
6636            make_tick_pq(dec!(102), dec!(1)),
6637        ];
6638        let e = NormalizedTick::price_entropy(&ticks).unwrap();
6639        assert!(e > 0.0, "varied prices should have positive entropy, got {}", e);
6640    }
6641
6642    // ── NormalizedTick::buy_avg_price / sell_avg_price ────────────────────────
6643
6644    #[test]
6645    fn test_buy_avg_price_none_for_no_buys() {
6646        use rust_decimal_macros::dec;
6647        let mut t = make_tick_pq(dec!(100), dec!(1));
6648        t.side = Some(TradeSide::Sell);
6649        assert!(NormalizedTick::buy_avg_price(&[t]).is_none());
6650    }
6651
6652    #[test]
6653    fn test_buy_avg_price_correct() {
6654        use rust_decimal_macros::dec;
6655        let mut t1 = make_tick_pq(dec!(100), dec!(1)); t1.side = Some(TradeSide::Buy);
6656        let mut t2 = make_tick_pq(dec!(110), dec!(1)); t2.side = Some(TradeSide::Buy);
6657        assert_eq!(NormalizedTick::buy_avg_price(&[t1, t2]), Some(dec!(105)));
6658    }
6659
6660    #[test]
6661    fn test_sell_avg_price_none_for_no_sells() {
6662        use rust_decimal_macros::dec;
6663        let mut t = make_tick_pq(dec!(100), dec!(1));
6664        t.side = Some(TradeSide::Buy);
6665        assert!(NormalizedTick::sell_avg_price(&[t]).is_none());
6666    }
6667
6668    #[test]
6669    fn test_sell_avg_price_correct() {
6670        use rust_decimal_macros::dec;
6671        let mut t1 = make_tick_pq(dec!(90), dec!(1)); t1.side = Some(TradeSide::Sell);
6672        let mut t2 = make_tick_pq(dec!(100), dec!(1)); t2.side = Some(TradeSide::Sell);
6673        assert_eq!(NormalizedTick::sell_avg_price(&[t1, t2]), Some(dec!(95)));
6674    }
6675
6676    // ── NormalizedTick::price_skewness ────────────────────────────────────────
6677
6678    #[test]
6679    fn test_price_skewness_none_for_fewer_than_3() {
6680        use rust_decimal_macros::dec;
6681        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(1))];
6682        assert!(NormalizedTick::price_skewness(&ticks).is_none());
6683    }
6684
6685    #[test]
6686    fn test_price_skewness_zero_for_symmetric() {
6687        use rust_decimal_macros::dec;
6688        // symmetric distribution: 1,2,3
6689        let ticks = vec![
6690            make_tick_pq(dec!(1), dec!(1)),
6691            make_tick_pq(dec!(2), dec!(1)),
6692            make_tick_pq(dec!(3), dec!(1)),
6693        ];
6694        let s = NormalizedTick::price_skewness(&ticks).unwrap();
6695        assert!(s.abs() < 1e-9, "symmetric should have near-zero skew, got {}", s);
6696    }
6697
6698    // ── NormalizedTick::quantity_skewness ─────────────────────────────────────
6699
6700    #[test]
6701    fn test_quantity_skewness_none_for_fewer_than_3() {
6702        use rust_decimal_macros::dec;
6703        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(2))];
6704        assert!(NormalizedTick::quantity_skewness(&ticks).is_none());
6705    }
6706
6707    #[test]
6708    fn test_quantity_skewness_positive_for_right_skewed() {
6709        use rust_decimal_macros::dec;
6710        // most quantities small, one very large: right-skewed
6711        let ticks = vec![
6712            make_tick_pq(dec!(100), dec!(1)),
6713            make_tick_pq(dec!(101), dec!(1)),
6714            make_tick_pq(dec!(102), dec!(100)),
6715        ];
6716        let s = NormalizedTick::quantity_skewness(&ticks).unwrap();
6717        assert!(s > 0.0, "right-skewed distribution should have positive skewness, got {}", s);
6718    }
6719
6720    // ── NormalizedTick::price_kurtosis ────────────────────────────────────────
6721
6722    #[test]
6723    fn test_price_kurtosis_none_for_fewer_than_4() {
6724        use rust_decimal_macros::dec;
6725        let ticks = vec![
6726            make_tick_pq(dec!(1), dec!(1)),
6727            make_tick_pq(dec!(2), dec!(1)),
6728            make_tick_pq(dec!(3), dec!(1)),
6729        ];
6730        assert!(NormalizedTick::price_kurtosis(&ticks).is_none());
6731    }
6732
6733    #[test]
6734    fn test_price_kurtosis_returns_some_for_varied_prices() {
6735        use rust_decimal_macros::dec;
6736        let ticks = vec![
6737            make_tick_pq(dec!(1), dec!(1)),
6738            make_tick_pq(dec!(2), dec!(1)),
6739            make_tick_pq(dec!(3), dec!(1)),
6740            make_tick_pq(dec!(4), dec!(1)),
6741        ];
6742        assert!(NormalizedTick::price_kurtosis(&ticks).is_some());
6743    }
6744
6745    // ── NormalizedTick::high_volume_tick_count ────────────────────────────────
6746
6747    #[test]
6748    fn test_high_volume_tick_count_zero_for_empty() {
6749        use rust_decimal_macros::dec;
6750        assert_eq!(NormalizedTick::high_volume_tick_count(&[], dec!(1)), 0);
6751    }
6752
6753    #[test]
6754    fn test_high_volume_tick_count_correct() {
6755        use rust_decimal_macros::dec;
6756        let ticks = vec![
6757            make_tick_pq(dec!(100), dec!(1)),
6758            make_tick_pq(dec!(101), dec!(5)),
6759            make_tick_pq(dec!(102), dec!(10)),
6760        ];
6761        assert_eq!(NormalizedTick::high_volume_tick_count(&ticks, dec!(4)), 2);
6762    }
6763
6764    // ── NormalizedTick::vwap_spread ───────────────────────────────────────────
6765
6766    #[test]
6767    fn test_vwap_spread_none_when_no_buys_or_sells() {
6768        use rust_decimal_macros::dec;
6769        let t = make_tick_pq(dec!(100), dec!(1));
6770        assert!(NormalizedTick::vwap_spread(&[t]).is_none());
6771    }
6772
6773    #[test]
6774    fn test_vwap_spread_positive_when_buys_priced_higher() {
6775        use rust_decimal_macros::dec;
6776        let mut buy = make_tick_pq(dec!(105), dec!(1)); buy.side = Some(TradeSide::Buy);
6777        let mut sell = make_tick_pq(dec!(100), dec!(1)); sell.side = Some(TradeSide::Sell);
6778        let spread = NormalizedTick::vwap_spread(&[buy, sell]).unwrap();
6779        assert!(spread > dec!(0), "expected positive spread, got {}", spread);
6780    }
6781
6782    // ── NormalizedTick::avg_buy_quantity / avg_sell_quantity ──────────────────
6783
6784    #[test]
6785    fn test_avg_buy_quantity_none_for_no_buys() {
6786        use rust_decimal_macros::dec;
6787        let mut t = make_tick_pq(dec!(100), dec!(2)); t.side = Some(TradeSide::Sell);
6788        assert!(NormalizedTick::avg_buy_quantity(&[t]).is_none());
6789    }
6790
6791    #[test]
6792    fn test_avg_buy_quantity_correct() {
6793        use rust_decimal_macros::dec;
6794        let mut t1 = make_tick_pq(dec!(100), dec!(2)); t1.side = Some(TradeSide::Buy);
6795        let mut t2 = make_tick_pq(dec!(101), dec!(4)); t2.side = Some(TradeSide::Buy);
6796        assert_eq!(NormalizedTick::avg_buy_quantity(&[t1, t2]), Some(dec!(3)));
6797    }
6798
6799    #[test]
6800    fn test_avg_sell_quantity_correct() {
6801        use rust_decimal_macros::dec;
6802        let mut t1 = make_tick_pq(dec!(100), dec!(6)); t1.side = Some(TradeSide::Sell);
6803        let mut t2 = make_tick_pq(dec!(101), dec!(2)); t2.side = Some(TradeSide::Sell);
6804        assert_eq!(NormalizedTick::avg_sell_quantity(&[t1, t2]), Some(dec!(4)));
6805    }
6806
6807    // ── NormalizedTick::price_mean_reversion_score ────────────────────────────
6808
6809    #[test]
6810    fn test_price_mean_reversion_score_none_for_empty() {
6811        assert!(NormalizedTick::price_mean_reversion_score(&[]).is_none());
6812    }
6813
6814    #[test]
6815    fn test_price_mean_reversion_score_in_range() {
6816        use rust_decimal_macros::dec;
6817        let ticks = vec![
6818            make_tick_pq(dec!(90), dec!(1)),
6819            make_tick_pq(dec!(100), dec!(1)),
6820            make_tick_pq(dec!(110), dec!(1)),
6821        ];
6822        let score = NormalizedTick::price_mean_reversion_score(&ticks).unwrap();
6823        assert!(score >= 0.0 && score <= 1.0, "score should be in [0, 1], got {}", score);
6824    }
6825
6826    // ── NormalizedTick::largest_price_move ────────────────────────────────────
6827
6828    #[test]
6829    fn test_largest_price_move_none_for_single_tick() {
6830        use rust_decimal_macros::dec;
6831        let t = make_tick_pq(dec!(100), dec!(1));
6832        assert!(NormalizedTick::largest_price_move(&[t]).is_none());
6833    }
6834
6835    #[test]
6836    fn test_largest_price_move_correct() {
6837        use rust_decimal_macros::dec;
6838        let ticks = vec![
6839            make_tick_pq(dec!(100), dec!(1)),
6840            make_tick_pq(dec!(105), dec!(1)),  // move = 5
6841            make_tick_pq(dec!(102), dec!(1)),  // move = 3
6842        ];
6843        assert_eq!(NormalizedTick::largest_price_move(&ticks), Some(dec!(5)));
6844    }
6845
6846    // ── NormalizedTick::tick_rate ─────────────────────────────────────────────
6847
6848    #[test]
6849    fn test_tick_rate_none_for_single_tick() {
6850        use rust_decimal_macros::dec;
6851        let t = make_tick_pq(dec!(100), dec!(1));
6852        assert!(NormalizedTick::tick_rate(&[t]).is_none());
6853    }
6854
6855    #[test]
6856    fn test_tick_rate_correct() {
6857        use rust_decimal_macros::dec;
6858        let mut t1 = make_tick_pq(dec!(100), dec!(1)); t1.received_at_ms = 0;
6859        let mut t2 = make_tick_pq(dec!(101), dec!(1)); t2.received_at_ms = 2;
6860        let mut t3 = make_tick_pq(dec!(102), dec!(1)); t3.received_at_ms = 4;
6861        // 3 ticks over 4ms → 0.75 ticks/ms
6862        let rate = NormalizedTick::tick_rate(&[t1, t2, t3]).unwrap();
6863        assert!((rate - 0.75).abs() < 1e-9, "expected 0.75 ticks/ms, got {}", rate);
6864    }
6865
6866    // ── NormalizedTick::buy_notional_fraction ─────────────────────────────────
6867
6868    #[test]
6869    fn test_buy_notional_fraction_none_for_empty() {
6870        assert!(NormalizedTick::buy_notional_fraction(&[]).is_none());
6871    }
6872
6873    #[test]
6874    fn test_buy_notional_fraction_one_when_all_buys() {
6875        use rust_decimal_macros::dec;
6876        let mut t = make_tick_pq(dec!(100), dec!(5)); t.side = Some(TradeSide::Buy);
6877        let frac = NormalizedTick::buy_notional_fraction(&[t]).unwrap();
6878        assert!((frac - 1.0).abs() < 1e-9, "all buys should give fraction=1.0, got {}", frac);
6879    }
6880
6881    #[test]
6882    fn test_buy_notional_fraction_in_range_for_mixed() {
6883        use rust_decimal_macros::dec;
6884        let mut buy = make_tick_pq(dec!(100), dec!(3)); buy.side = Some(TradeSide::Buy);
6885        let mut sell = make_tick_pq(dec!(100), dec!(1)); sell.side = Some(TradeSide::Sell);
6886        let frac = NormalizedTick::buy_notional_fraction(&[buy, sell]).unwrap();
6887        assert!(frac > 0.0 && frac < 1.0, "mixed ticks should be in (0,1), got {}", frac);
6888    }
6889
6890    // ── NormalizedTick::price_range_pct ───────────────────────────────────────
6891
6892    #[test]
6893    fn test_price_range_pct_none_for_empty() {
6894        assert!(NormalizedTick::price_range_pct(&[]).is_none());
6895    }
6896
6897    #[test]
6898    fn test_price_range_pct_correct() {
6899        use rust_decimal_macros::dec;
6900        let ticks = vec![
6901            make_tick_pq(dec!(100), dec!(1)),
6902            make_tick_pq(dec!(110), dec!(1)),
6903        ];
6904        // (110 - 100) / 100 * 100 = 10%
6905        let pct = NormalizedTick::price_range_pct(&ticks).unwrap();
6906        assert!((pct - 10.0).abs() < 1e-6, "expected 10.0%, got {}", pct);
6907    }
6908
6909    // ── NormalizedTick::buy_side_dominance ────────────────────────────────────
6910
6911    #[test]
6912    fn test_buy_side_dominance_none_when_no_sides() {
6913        use rust_decimal_macros::dec;
6914        let t = make_tick_pq(dec!(100), dec!(1)); // side=None
6915        assert!(NormalizedTick::buy_side_dominance(&[t]).is_none());
6916    }
6917
6918    #[test]
6919    fn test_buy_side_dominance_one_when_all_buys() {
6920        use rust_decimal_macros::dec;
6921        let mut t = make_tick_pq(dec!(100), dec!(5)); t.side = Some(TradeSide::Buy);
6922        let d = NormalizedTick::buy_side_dominance(&[t]).unwrap();
6923        assert!((d - 1.0).abs() < 1e-9, "all buys should give 1.0, got {}", d);
6924    }
6925
6926    // ── NormalizedTick::volume_weighted_price_std ─────────────────────────────
6927
6928    #[test]
6929    fn test_volume_weighted_price_std_none_for_empty() {
6930        assert!(NormalizedTick::volume_weighted_price_std(&[]).is_none());
6931    }
6932
6933    #[test]
6934    fn test_volume_weighted_price_std_zero_for_same_price() {
6935        use rust_decimal_macros::dec;
6936        let ticks = vec![
6937            make_tick_pq(dec!(100), dec!(2)),
6938            make_tick_pq(dec!(100), dec!(3)),
6939        ];
6940        let std = NormalizedTick::volume_weighted_price_std(&ticks).unwrap();
6941        assert!((std - 0.0).abs() < 1e-9, "same price should give 0 std, got {}", std);
6942    }
6943
6944    // ── NormalizedTick::last_n_vwap ───────────────────────────────────────────
6945
6946    #[test]
6947    fn test_last_n_vwap_none_for_zero_n() {
6948        use rust_decimal_macros::dec;
6949        let t = make_tick_pq(dec!(100), dec!(1));
6950        assert!(NormalizedTick::last_n_vwap(&[t], 0).is_none());
6951    }
6952
6953    #[test]
6954    fn test_last_n_vwap_uses_last_n_ticks() {
6955        use rust_decimal_macros::dec;
6956        // first tick at 50, last 2 at 100 equal qty → last_n_vwap(n=2) = 100
6957        let ticks = vec![
6958            make_tick_pq(dec!(50), dec!(10)),
6959            make_tick_pq(dec!(100), dec!(5)),
6960            make_tick_pq(dec!(100), dec!(5)),
6961        ];
6962        let v = NormalizedTick::last_n_vwap(&ticks, 2).unwrap();
6963        assert_eq!(v, dec!(100));
6964    }
6965
6966    // ── NormalizedTick::price_autocorrelation ─────────────────────────────────
6967
6968    #[test]
6969    fn test_price_autocorrelation_none_for_fewer_than_3() {
6970        use rust_decimal_macros::dec;
6971        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(1))];
6972        assert!(NormalizedTick::price_autocorrelation(&ticks).is_none());
6973    }
6974
6975    #[test]
6976    fn test_price_autocorrelation_positive_for_trending_prices() {
6977        use rust_decimal_macros::dec;
6978        let ticks = vec![
6979            make_tick_pq(dec!(100), dec!(1)),
6980            make_tick_pq(dec!(102), dec!(1)),
6981            make_tick_pq(dec!(104), dec!(1)),
6982            make_tick_pq(dec!(106), dec!(1)),
6983        ];
6984        let ac = NormalizedTick::price_autocorrelation(&ticks).unwrap();
6985        assert!(ac > 0.0, "trending prices should have positive AC, got {}", ac);
6986    }
6987
6988    // ── NormalizedTick::net_trade_direction ───────────────────────────────────
6989
6990    #[test]
6991    fn test_net_trade_direction_zero_for_empty() {
6992        assert_eq!(NormalizedTick::net_trade_direction(&[]), 0);
6993    }
6994
6995    #[test]
6996    fn test_net_trade_direction_positive_for_more_buys() {
6997        use rust_decimal_macros::dec;
6998        let mut b1 = make_tick_pq(dec!(100), dec!(1)); b1.side = Some(TradeSide::Buy);
6999        let mut b2 = make_tick_pq(dec!(100), dec!(1)); b2.side = Some(TradeSide::Buy);
7000        let mut s1 = make_tick_pq(dec!(100), dec!(1)); s1.side = Some(TradeSide::Sell);
7001        assert_eq!(NormalizedTick::net_trade_direction(&[b1, b2, s1]), 1);
7002    }
7003
7004    // ── NormalizedTick::sell_side_notional_fraction ───────────────────────────
7005
7006    #[test]
7007    fn test_sell_side_notional_fraction_none_for_empty() {
7008        assert!(NormalizedTick::sell_side_notional_fraction(&[]).is_none());
7009    }
7010
7011    #[test]
7012    fn test_sell_side_notional_fraction_one_when_all_sells() {
7013        use rust_decimal_macros::dec;
7014        let mut t = make_tick_pq(dec!(100), dec!(5)); t.side = Some(TradeSide::Sell);
7015        let f = NormalizedTick::sell_side_notional_fraction(&[t]).unwrap();
7016        assert!((f - 1.0).abs() < 1e-9, "all sells should give 1.0, got {}", f);
7017    }
7018
7019    // ── NormalizedTick::price_oscillation_count ───────────────────────────────
7020
7021    #[test]
7022    fn test_price_oscillation_count_zero_for_monotone() {
7023        use rust_decimal_macros::dec;
7024        let ticks = vec![
7025            make_tick_pq(dec!(100), dec!(1)),
7026            make_tick_pq(dec!(101), dec!(1)),
7027            make_tick_pq(dec!(102), dec!(1)),
7028        ];
7029        assert_eq!(NormalizedTick::price_oscillation_count(&ticks), 0);
7030    }
7031
7032    #[test]
7033    fn test_price_oscillation_count_detects_reversals() {
7034        use rust_decimal_macros::dec;
7035        // up-down-up: 100 → 105 → 102 → 107
7036        // windows(3): [100,105,102] (up-down) + [105,102,107] (down-up) → 2 reversals
7037        let ticks = vec![
7038            make_tick_pq(dec!(100), dec!(1)),
7039            make_tick_pq(dec!(105), dec!(1)),
7040            make_tick_pq(dec!(102), dec!(1)),
7041            make_tick_pq(dec!(107), dec!(1)),
7042        ];
7043        assert_eq!(NormalizedTick::price_oscillation_count(&ticks), 2);
7044    }
7045
7046    // ── NormalizedTick::realized_spread ───────────────────────────────────────
7047
7048    #[test]
7049    fn test_realized_spread_none_when_no_sides() {
7050        use rust_decimal_macros::dec;
7051        let t = make_tick_pq(dec!(100), dec!(1));
7052        assert!(NormalizedTick::realized_spread(&[t]).is_none());
7053    }
7054
7055    #[test]
7056    fn test_realized_spread_positive_when_buys_higher() {
7057        use rust_decimal_macros::dec;
7058        let mut b = make_tick_pq(dec!(105), dec!(1)); b.side = Some(TradeSide::Buy);
7059        let mut s = make_tick_pq(dec!(100), dec!(1)); s.side = Some(TradeSide::Sell);
7060        let spread = NormalizedTick::realized_spread(&[b, s]).unwrap();
7061        assert!(spread > dec!(0), "expected positive spread, got {}", spread);
7062    }
7063
7064    // ── NormalizedTick::price_impact_per_unit ────────────────────────────────
7065
7066    #[test]
7067    fn test_price_impact_per_unit_none_for_single_tick() {
7068        use rust_decimal_macros::dec;
7069        let t = make_tick_pq(dec!(100), dec!(1));
7070        assert!(NormalizedTick::price_impact_per_unit(&[t]).is_none());
7071    }
7072
7073    // ── NormalizedTick::volume_weighted_return ────────────────────────────────
7074
7075    #[test]
7076    fn test_volume_weighted_return_none_for_single_tick() {
7077        use rust_decimal_macros::dec;
7078        let t = make_tick_pq(dec!(100), dec!(1));
7079        assert!(NormalizedTick::volume_weighted_return(&[t]).is_none());
7080    }
7081
7082    #[test]
7083    fn test_volume_weighted_return_zero_for_constant_price() {
7084        use rust_decimal_macros::dec;
7085        let ticks = vec![
7086            make_tick_pq(dec!(100), dec!(5)),
7087            make_tick_pq(dec!(100), dec!(5)),
7088        ];
7089        let r = NormalizedTick::volume_weighted_return(&ticks).unwrap();
7090        assert!((r - 0.0).abs() < 1e-9, "constant price should give 0 return, got {}", r);
7091    }
7092
7093    // ── NormalizedTick::quantity_concentration ────────────────────────────────
7094
7095    #[test]
7096    fn test_quantity_concentration_none_for_empty() {
7097        assert!(NormalizedTick::quantity_concentration(&[]).is_none());
7098    }
7099
7100    #[test]
7101    fn test_quantity_concentration_zero_for_identical_quantities() {
7102        use rust_decimal_macros::dec;
7103        let ticks = vec![
7104            make_tick_pq(dec!(100), dec!(5)),
7105            make_tick_pq(dec!(101), dec!(5)),
7106        ];
7107        let c = NormalizedTick::quantity_concentration(&ticks).unwrap();
7108        assert!((c - 0.0).abs() < 1e-9, "identical quantities should give 0 concentration, got {}", c);
7109    }
7110
7111    // ── NormalizedTick::price_level_volume ────────────────────────────────────
7112
7113    #[test]
7114    fn test_price_level_volume_zero_for_no_match() {
7115        use rust_decimal_macros::dec;
7116        let ticks = vec![make_tick_pq(dec!(100), dec!(5))];
7117        let v = NormalizedTick::price_level_volume(&ticks, dec!(200));
7118        assert_eq!(v, dec!(0));
7119    }
7120
7121    #[test]
7122    fn test_price_level_volume_sums_matching_ticks() {
7123        use rust_decimal_macros::dec;
7124        let ticks = vec![
7125            make_tick_pq(dec!(100), dec!(3)),
7126            make_tick_pq(dec!(101), dec!(7)),
7127            make_tick_pq(dec!(100), dec!(2)),
7128        ];
7129        assert_eq!(NormalizedTick::price_level_volume(&ticks, dec!(100)), dec!(5));
7130    }
7131
7132    // ── NormalizedTick::mid_price_drift ───────────────────────────────────────
7133
7134    #[test]
7135    fn test_mid_price_drift_none_for_single_tick() {
7136        use rust_decimal_macros::dec;
7137        let t = make_tick_pq(dec!(100), dec!(1));
7138        assert!(NormalizedTick::mid_price_drift(&[t]).is_none());
7139    }
7140
7141    // ── NormalizedTick::tick_direction_bias ───────────────────────────────────
7142
7143    #[test]
7144    fn test_tick_direction_bias_none_for_fewer_than_3() {
7145        use rust_decimal_macros::dec;
7146        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(1))];
7147        assert!(NormalizedTick::tick_direction_bias(&ticks).is_none());
7148    }
7149
7150    #[test]
7151    fn test_tick_direction_bias_one_for_monotone() {
7152        use rust_decimal_macros::dec;
7153        let ticks = vec![
7154            make_tick_pq(dec!(100), dec!(1)),
7155            make_tick_pq(dec!(101), dec!(1)),
7156            make_tick_pq(dec!(102), dec!(1)),
7157            make_tick_pq(dec!(103), dec!(1)),
7158        ];
7159        let bias = NormalizedTick::tick_direction_bias(&ticks).unwrap();
7160        assert!((bias - 1.0).abs() < 1e-9, "monotone should give bias=1.0, got {}", bias);
7161    }
7162
7163    #[test]
7164    fn test_buy_sell_size_ratio_none_for_empty() {
7165        assert!(NormalizedTick::buy_sell_size_ratio(&[]).is_none());
7166    }
7167
7168    #[test]
7169    fn test_buy_sell_size_ratio_positive() {
7170        use rust_decimal_macros::dec;
7171        let buy = NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(4)) };
7172        let sell = NormalizedTick { side: Some(TradeSide::Sell), ..make_tick_pq(dec!(100), dec!(2)) };
7173        let r = NormalizedTick::buy_sell_size_ratio(&[buy, sell]).unwrap();
7174        assert!((r - 2.0).abs() < 1e-6, "ratio should be 2.0, got {}", r);
7175    }
7176
7177    #[test]
7178    fn test_trade_size_dispersion_none_for_single_tick() {
7179        use rust_decimal_macros::dec;
7180        let ticks = vec![make_tick_pq(dec!(100), dec!(5))];
7181        assert!(NormalizedTick::trade_size_dispersion(&ticks).is_none());
7182    }
7183
7184    #[test]
7185    fn test_trade_size_dispersion_zero_for_identical() {
7186        use rust_decimal_macros::dec;
7187        let ticks = vec![
7188            make_tick_pq(dec!(100), dec!(5)),
7189            make_tick_pq(dec!(101), dec!(5)),
7190            make_tick_pq(dec!(102), dec!(5)),
7191        ];
7192        let d = NormalizedTick::trade_size_dispersion(&ticks).unwrap();
7193        assert!(d.abs() < 1e-9, "identical sizes → dispersion=0, got {}", d);
7194    }
7195
7196    #[test]
7197    fn test_first_last_price_none_for_empty() {
7198        assert!(NormalizedTick::first_price(&[]).is_none());
7199        assert!(NormalizedTick::last_price(&[]).is_none());
7200    }
7201
7202    #[test]
7203    fn test_first_last_price_correct() {
7204        use rust_decimal_macros::dec;
7205        let ticks = vec![
7206            make_tick_pq(dec!(100), dec!(1)),
7207            make_tick_pq(dec!(105), dec!(1)),
7208            make_tick_pq(dec!(110), dec!(1)),
7209        ];
7210        assert_eq!(NormalizedTick::first_price(&ticks).unwrap(), dec!(100));
7211        assert_eq!(NormalizedTick::last_price(&ticks).unwrap(), dec!(110));
7212    }
7213
7214    #[test]
7215    fn test_median_quantity_none_for_empty() {
7216        assert!(NormalizedTick::median_quantity(&[]).is_none());
7217    }
7218
7219    #[test]
7220    fn test_median_quantity_odd_count() {
7221        use rust_decimal_macros::dec;
7222        let ticks = vec![
7223            make_tick_pq(dec!(100), dec!(3)),
7224            make_tick_pq(dec!(101), dec!(1)),
7225            make_tick_pq(dec!(102), dec!(5)),
7226        ];
7227        // sorted: 1, 3, 5 → median = 3
7228        assert_eq!(NormalizedTick::median_quantity(&ticks).unwrap(), dec!(3));
7229    }
7230
7231    #[test]
7232    fn test_volume_above_vwap_none_for_empty() {
7233        assert!(NormalizedTick::volume_above_vwap(&[]).is_none());
7234    }
7235
7236    #[test]
7237    fn test_volume_above_vwap_none_when_all_at_vwap() {
7238        use rust_decimal_macros::dec;
7239        // All same price → VWAP = price, nothing strictly above
7240        let ticks = vec![
7241            make_tick_pq(dec!(100), dec!(5)),
7242            make_tick_pq(dec!(100), dec!(5)),
7243        ];
7244        let v = NormalizedTick::volume_above_vwap(&ticks).unwrap();
7245        assert_eq!(v, dec!(0));
7246    }
7247
7248    #[test]
7249    fn test_inter_arrival_variance_none_for_fewer_than_3() {
7250        use rust_decimal_macros::dec;
7251        let t = make_tick_pq(dec!(100), dec!(1));
7252        assert!(NormalizedTick::inter_arrival_variance(&[t]).is_none());
7253    }
7254
7255    #[test]
7256    fn test_spread_efficiency_none_for_single_tick() {
7257        use rust_decimal_macros::dec;
7258        let ticks = vec![make_tick_pq(dec!(100), dec!(1))];
7259        assert!(NormalizedTick::spread_efficiency(&ticks).is_none());
7260    }
7261
7262    #[test]
7263    fn test_spread_efficiency_one_for_monotone() {
7264        use rust_decimal_macros::dec;
7265        let ticks = vec![
7266            make_tick_pq(dec!(100), dec!(1)),
7267            make_tick_pq(dec!(101), dec!(1)),
7268            make_tick_pq(dec!(102), dec!(1)),
7269        ];
7270        // monotone up → efficiency = 1.0
7271        let e = NormalizedTick::spread_efficiency(&ticks).unwrap();
7272        assert!((e - 1.0).abs() < 1e-9, "expected 1.0, got {}", e);
7273    }
7274
7275    // ── round-79 ─────────────────────────────────────────────────────────────
7276
7277    // ── NormalizedTick::aggressor_fraction ────────────────────────────────────
7278
7279    #[test]
7280    fn test_aggressor_fraction_none_for_empty() {
7281        assert!(NormalizedTick::aggressor_fraction(&[]).is_none());
7282    }
7283
7284    #[test]
7285    fn test_aggressor_fraction_zero_when_all_neutral() {
7286        use rust_decimal_macros::dec;
7287        let ticks = vec![
7288            make_tick_pq(dec!(100), dec!(1)),
7289            make_tick_pq(dec!(101), dec!(1)),
7290        ];
7291        let f = NormalizedTick::aggressor_fraction(&ticks).unwrap();
7292        assert!((f - 0.0).abs() < 1e-9, "all neutral → fraction=0, got {}", f);
7293    }
7294
7295    #[test]
7296    fn test_aggressor_fraction_one_when_all_known() {
7297        use rust_decimal_macros::dec;
7298        let ticks = vec![
7299            NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(1)) },
7300            NormalizedTick { side: Some(TradeSide::Sell), ..make_tick_pq(dec!(101), dec!(1)) },
7301        ];
7302        let f = NormalizedTick::aggressor_fraction(&ticks).unwrap();
7303        assert!((f - 1.0).abs() < 1e-9, "all known → fraction=1, got {}", f);
7304    }
7305
7306    // ── NormalizedTick::volume_imbalance_ratio ────────────────────────────────
7307
7308    #[test]
7309    fn test_volume_imbalance_ratio_none_for_neutral_ticks() {
7310        use rust_decimal_macros::dec;
7311        let ticks = vec![make_tick_pq(dec!(100), dec!(5))];
7312        assert!(NormalizedTick::volume_imbalance_ratio(&ticks).is_none());
7313    }
7314
7315    #[test]
7316    fn test_volume_imbalance_ratio_positive_for_all_buys() {
7317        use rust_decimal_macros::dec;
7318        let ticks = vec![
7319            NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(4)) },
7320        ];
7321        let r = NormalizedTick::volume_imbalance_ratio(&ticks).unwrap();
7322        assert!((r - 1.0).abs() < 1e-9, "all buys → ratio=1.0, got {}", r);
7323    }
7324
7325    #[test]
7326    fn test_volume_imbalance_ratio_zero_for_equal_sides() {
7327        use rust_decimal_macros::dec;
7328        let ticks = vec![
7329            NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(5)) },
7330            NormalizedTick { side: Some(TradeSide::Sell), ..make_tick_pq(dec!(100), dec!(5)) },
7331        ];
7332        let r = NormalizedTick::volume_imbalance_ratio(&ticks).unwrap();
7333        assert!(r.abs() < 1e-9, "equal buy/sell → ratio=0, got {}", r);
7334    }
7335
7336    // ── NormalizedTick::price_quantity_covariance ─────────────────────────────
7337
7338    #[test]
7339    fn test_price_quantity_covariance_none_for_single_tick() {
7340        use rust_decimal_macros::dec;
7341        let ticks = vec![make_tick_pq(dec!(100), dec!(1))];
7342        assert!(NormalizedTick::price_quantity_covariance(&ticks).is_none());
7343    }
7344
7345    #[test]
7346    fn test_price_quantity_covariance_positive_when_correlated() {
7347        use rust_decimal_macros::dec;
7348        let ticks = vec![
7349            make_tick_pq(dec!(100), dec!(1)),
7350            make_tick_pq(dec!(200), dec!(2)),
7351            make_tick_pq(dec!(300), dec!(3)),
7352        ];
7353        let c = NormalizedTick::price_quantity_covariance(&ticks).unwrap();
7354        assert!(c > 0.0, "price and qty both rise → positive cov, got {}", c);
7355    }
7356
7357    // ── NormalizedTick::large_trade_fraction ──────────────────────────────────
7358
7359    #[test]
7360    fn test_large_trade_fraction_none_for_empty() {
7361        use rust_decimal_macros::dec;
7362        assert!(NormalizedTick::large_trade_fraction(&[], dec!(10)).is_none());
7363    }
7364
7365    #[test]
7366    fn test_large_trade_fraction_zero_when_all_small() {
7367        use rust_decimal_macros::dec;
7368        let ticks = vec![
7369            make_tick_pq(dec!(100), dec!(1)),
7370            make_tick_pq(dec!(101), dec!(2)),
7371        ];
7372        let f = NormalizedTick::large_trade_fraction(&ticks, dec!(10)).unwrap();
7373        assert!((f - 0.0).abs() < 1e-9, "all small → fraction=0, got {}", f);
7374    }
7375
7376    #[test]
7377    fn test_large_trade_fraction_one_when_all_large() {
7378        use rust_decimal_macros::dec;
7379        let ticks = vec![
7380            make_tick_pq(dec!(100), dec!(20)),
7381            make_tick_pq(dec!(101), dec!(30)),
7382        ];
7383        let f = NormalizedTick::large_trade_fraction(&ticks, dec!(10)).unwrap();
7384        assert!((f - 1.0).abs() < 1e-9, "all large → fraction=1, got {}", f);
7385    }
7386
7387    // ── NormalizedTick::price_level_density ───────────────────────────────────
7388
7389    #[test]
7390    fn test_price_level_density_none_for_empty() {
7391        assert!(NormalizedTick::price_level_density(&[]).is_none());
7392    }
7393
7394    #[test]
7395    fn test_price_level_density_none_when_range_zero() {
7396        use rust_decimal_macros::dec;
7397        let ticks = vec![
7398            make_tick_pq(dec!(100), dec!(1)),
7399            make_tick_pq(dec!(100), dec!(2)),
7400        ];
7401        assert!(NormalizedTick::price_level_density(&ticks).is_none());
7402    }
7403
7404    #[test]
7405    fn test_price_level_density_positive_for_varied_prices() {
7406        use rust_decimal_macros::dec;
7407        let ticks = vec![
7408            make_tick_pq(dec!(100), dec!(1)),
7409            make_tick_pq(dec!(110), dec!(1)),
7410            make_tick_pq(dec!(120), dec!(1)),
7411        ];
7412        let d = NormalizedTick::price_level_density(&ticks).unwrap();
7413        assert!(d > 0.0, "should be positive, got {}", d);
7414    }
7415
7416    // ── NormalizedTick::notional_buy_sell_ratio ───────────────────────────────
7417
7418    #[test]
7419    fn test_notional_buy_sell_ratio_none_when_no_sells() {
7420        use rust_decimal_macros::dec;
7421        let ticks = vec![
7422            NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(5)) },
7423        ];
7424        assert!(NormalizedTick::notional_buy_sell_ratio(&ticks).is_none());
7425    }
7426
7427    #[test]
7428    fn test_notional_buy_sell_ratio_one_for_equal_notional() {
7429        use rust_decimal_macros::dec;
7430        let ticks = vec![
7431            NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(5)) },
7432            NormalizedTick { side: Some(TradeSide::Sell), ..make_tick_pq(dec!(100), dec!(5)) },
7433        ];
7434        let r = NormalizedTick::notional_buy_sell_ratio(&ticks).unwrap();
7435        assert!((r - 1.0).abs() < 1e-9, "equal notional → ratio=1, got {}", r);
7436    }
7437
7438    // ── NormalizedTick::log_return_mean ───────────────────────────────────────
7439
7440    #[test]
7441    fn test_log_return_mean_none_for_single_tick() {
7442        use rust_decimal_macros::dec;
7443        assert!(NormalizedTick::log_return_mean(&[make_tick_pq(dec!(100), dec!(1))]).is_none());
7444    }
7445
7446    #[test]
7447    fn test_log_return_mean_zero_for_constant_price() {
7448        use rust_decimal_macros::dec;
7449        let ticks = vec![
7450            make_tick_pq(dec!(100), dec!(1)),
7451            make_tick_pq(dec!(100), dec!(1)),
7452            make_tick_pq(dec!(100), dec!(1)),
7453        ];
7454        let m = NormalizedTick::log_return_mean(&ticks).unwrap();
7455        assert!(m.abs() < 1e-9, "constant price → mean log return=0, got {}", m);
7456    }
7457
7458    // ── NormalizedTick::log_return_std ────────────────────────────────────────
7459
7460    #[test]
7461    fn test_log_return_std_none_for_fewer_than_3_ticks() {
7462        use rust_decimal_macros::dec;
7463        let ticks = vec![
7464            make_tick_pq(dec!(100), dec!(1)),
7465            make_tick_pq(dec!(101), dec!(1)),
7466        ];
7467        assert!(NormalizedTick::log_return_std(&ticks).is_none());
7468    }
7469
7470    #[test]
7471    fn test_log_return_std_zero_for_constant_price() {
7472        use rust_decimal_macros::dec;
7473        let ticks = vec![
7474            make_tick_pq(dec!(100), dec!(1)),
7475            make_tick_pq(dec!(100), dec!(1)),
7476            make_tick_pq(dec!(100), dec!(1)),
7477            make_tick_pq(dec!(100), dec!(1)),
7478        ];
7479        let s = NormalizedTick::log_return_std(&ticks).unwrap();
7480        assert!(s.abs() < 1e-9, "constant price → std=0, got {}", s);
7481    }
7482
7483    // ── NormalizedTick::price_overshoot_ratio ─────────────────────────────────
7484
7485    #[test]
7486    fn test_price_overshoot_ratio_none_for_empty() {
7487        assert!(NormalizedTick::price_overshoot_ratio(&[]).is_none());
7488    }
7489
7490    #[test]
7491    fn test_price_overshoot_ratio_one_for_monotone_up() {
7492        use rust_decimal_macros::dec;
7493        let ticks = vec![
7494            make_tick_pq(dec!(100), dec!(1)),
7495            make_tick_pq(dec!(105), dec!(1)),
7496            make_tick_pq(dec!(110), dec!(1)),
7497        ];
7498        // max=110 == last=110 → ratio=1
7499        let r = NormalizedTick::price_overshoot_ratio(&ticks).unwrap();
7500        assert!((r - 1.0).abs() < 1e-9, "monotone up → ratio=1, got {}", r);
7501    }
7502
7503    #[test]
7504    fn test_price_overshoot_ratio_above_one_when_price_retreats() {
7505        use rust_decimal_macros::dec;
7506        let ticks = vec![
7507            make_tick_pq(dec!(100), dec!(1)),
7508            make_tick_pq(dec!(120), dec!(1)),
7509            make_tick_pq(dec!(110), dec!(1)),
7510        ];
7511        // max=120, last=110 → ratio > 1
7512        let r = NormalizedTick::price_overshoot_ratio(&ticks).unwrap();
7513        assert!(r > 1.0, "price retreated → ratio>1, got {}", r);
7514    }
7515
7516    // ── NormalizedTick::price_undershoot_ratio ────────────────────────────────
7517
7518    #[test]
7519    fn test_price_undershoot_ratio_none_for_empty() {
7520        assert!(NormalizedTick::price_undershoot_ratio(&[]).is_none());
7521    }
7522
7523    #[test]
7524    fn test_price_undershoot_ratio_one_for_monotone_down() {
7525        use rust_decimal_macros::dec;
7526        let ticks = vec![
7527            make_tick_pq(dec!(110), dec!(1)),
7528            make_tick_pq(dec!(105), dec!(1)),
7529            make_tick_pq(dec!(100), dec!(1)),
7530        ];
7531        // first=110, min=100 → ratio > 1 (price undershot opening)
7532        let r = NormalizedTick::price_undershoot_ratio(&ticks).unwrap();
7533        assert!(r > 1.0, "monotone down → ratio>1, got {}", r);
7534    }
7535
7536    #[test]
7537    fn test_price_undershoot_ratio_one_for_monotone_up() {
7538        use rust_decimal_macros::dec;
7539        let ticks = vec![
7540            make_tick_pq(dec!(100), dec!(1)),
7541            make_tick_pq(dec!(105), dec!(1)),
7542            make_tick_pq(dec!(110), dec!(1)),
7543        ];
7544        // first=100 == min=100 → ratio=1 (never went below open)
7545        let r = NormalizedTick::price_undershoot_ratio(&ticks).unwrap();
7546        assert!((r - 1.0).abs() < 1e-9, "monotone up → ratio=1, got {}", r);
7547    }
7548
7549    // ── round-80 tests ────────────────────────────────────────────────────────
7550
7551    #[test]
7552    fn test_net_notional_empty_is_zero() {
7553        assert_eq!(NormalizedTick::net_notional(&[]), Decimal::ZERO);
7554    }
7555
7556    #[test]
7557    fn test_net_notional_positive_buy() {
7558        use rust_decimal_macros::dec;
7559        let ticks = vec![
7560            make_tick_pq(dec!(100), dec!(5)).with_side(TradeSide::Buy),
7561            make_tick_pq(dec!(100), dec!(2)).with_side(TradeSide::Sell),
7562        ];
7563        assert_eq!(NormalizedTick::net_notional(&ticks), dec!(300));
7564    }
7565
7566    #[test]
7567    fn test_price_reversal_count_empty_is_zero() {
7568        assert_eq!(NormalizedTick::price_reversal_count(&[]), 0);
7569    }
7570
7571    #[test]
7572    fn test_price_reversal_count_monotone_is_zero() {
7573        use rust_decimal_macros::dec;
7574        let ticks = vec![
7575            make_tick_pq(dec!(100), dec!(1)),
7576            make_tick_pq(dec!(101), dec!(1)),
7577            make_tick_pq(dec!(102), dec!(1)),
7578        ];
7579        assert_eq!(NormalizedTick::price_reversal_count(&ticks), 0);
7580    }
7581
7582    #[test]
7583    fn test_price_reversal_count_zigzag() {
7584        use rust_decimal_macros::dec;
7585        let ticks = vec![
7586            make_tick_pq(dec!(100), dec!(1)),
7587            make_tick_pq(dec!(105), dec!(1)),
7588            make_tick_pq(dec!(100), dec!(1)),
7589            make_tick_pq(dec!(105), dec!(1)),
7590        ];
7591        assert_eq!(NormalizedTick::price_reversal_count(&ticks), 2);
7592    }
7593
7594    #[test]
7595    fn test_quantity_kurtosis_none_for_few_ticks() {
7596        use rust_decimal_macros::dec;
7597        let t = make_tick_pq(dec!(100), dec!(1));
7598        assert!(NormalizedTick::quantity_kurtosis(&[t]).is_none());
7599    }
7600
7601    #[test]
7602    fn test_quantity_kurtosis_some_for_sufficient() {
7603        use rust_decimal_macros::dec;
7604        let ticks = vec![
7605            make_tick_pq(dec!(100), dec!(1)),
7606            make_tick_pq(dec!(101), dec!(2)),
7607            make_tick_pq(dec!(102), dec!(3)),
7608            make_tick_pq(dec!(103), dec!(4)),
7609        ];
7610        assert!(NormalizedTick::quantity_kurtosis(&ticks).is_some());
7611    }
7612
7613    #[test]
7614    fn test_largest_notional_trade_none_for_empty() {
7615        assert!(NormalizedTick::largest_notional_trade(&[]).is_none());
7616    }
7617
7618    #[test]
7619    fn test_largest_notional_trade_correct() {
7620        use rust_decimal_macros::dec;
7621        let ticks = vec![
7622            make_tick_pq(dec!(100), dec!(1)),   // notional = 100
7623            make_tick_pq(dec!(50), dec!(10)),   // notional = 500 ← max
7624            make_tick_pq(dec!(200), dec!(1)),   // notional = 200
7625        ];
7626        let t = NormalizedTick::largest_notional_trade(&ticks).unwrap();
7627        assert_eq!(t.price, dec!(50));
7628    }
7629
7630    #[test]
7631    fn test_twap_none_for_single_tick() {
7632        use rust_decimal_macros::dec;
7633        assert!(NormalizedTick::twap(&[make_tick_pq(dec!(100), dec!(1))]).is_none());
7634    }
7635
7636    #[test]
7637    fn test_twap_two_equal_intervals() {
7638        use rust_decimal_macros::dec;
7639        let mut t1 = make_tick_pq(dec!(100), dec!(1));
7640        t1.received_at_ms = 0;
7641        let mut t2 = make_tick_pq(dec!(200), dec!(1));
7642        t2.received_at_ms = 1000;
7643        let mut t3 = make_tick_pq(dec!(300), dec!(1));
7644        t3.received_at_ms = 2000;
7645        // weights: t1 * 1000ms, t2 * 1000ms → TWAP = (100*1000 + 200*1000)/2000 = 150
7646        let twap = NormalizedTick::twap(&[t1, t2, t3]).unwrap();
7647        assert_eq!(twap, dec!(150));
7648    }
7649
7650    #[test]
7651    fn test_neutral_fraction_all_neutral() {
7652        use rust_decimal_macros::dec;
7653        let ticks = vec![
7654            make_tick_pq(dec!(100), dec!(1)),
7655            make_tick_pq(dec!(101), dec!(1)),
7656        ];
7657        let f = NormalizedTick::neutral_fraction(&ticks).unwrap();
7658        assert!((f - 1.0).abs() < 1e-9, "all neutral → fraction=1, got {}", f);
7659    }
7660
7661    #[test]
7662    fn test_log_return_variance_none_for_few_ticks() {
7663        use rust_decimal_macros::dec;
7664        let t = make_tick_pq(dec!(100), dec!(1));
7665        assert!(NormalizedTick::log_return_variance(&[t]).is_none());
7666    }
7667
7668    #[test]
7669    fn test_log_return_variance_zero_for_flat_prices() {
7670        use rust_decimal_macros::dec;
7671        let ticks = vec![
7672            make_tick_pq(dec!(100), dec!(1)),
7673            make_tick_pq(dec!(100), dec!(1)),
7674            make_tick_pq(dec!(100), dec!(1)),
7675        ];
7676        let v = NormalizedTick::log_return_variance(&ticks).unwrap();
7677        assert!(v.abs() < 1e-9, "flat prices → variance=0, got {}", v);
7678    }
7679
7680    #[test]
7681    fn test_volume_at_vwap_zero_for_empty() {
7682        assert_eq!(
7683            NormalizedTick::volume_at_vwap(&[], rust_decimal_macros::dec!(1)),
7684            Decimal::ZERO
7685        );
7686    }
7687
7688    // ── NormalizedTick::cumulative_volume ─────────────────────────────────────
7689
7690    #[test]
7691    fn test_cumulative_volume_empty_for_empty_slice() {
7692        assert!(NormalizedTick::cumulative_volume(&[]).is_empty());
7693    }
7694
7695    #[test]
7696    fn test_cumulative_volume_last_equals_total() {
7697        use rust_decimal_macros::dec;
7698        let ticks = vec![
7699            make_tick_pq(dec!(100), dec!(2)),
7700            make_tick_pq(dec!(101), dec!(3)),
7701            make_tick_pq(dec!(102), dec!(5)),
7702        ];
7703        let cv = NormalizedTick::cumulative_volume(&ticks);
7704        assert_eq!(cv.last().copied().unwrap(), dec!(10));
7705        assert_eq!(cv[0], dec!(2));
7706    }
7707
7708    // ── NormalizedTick::price_volatility_ratio ────────────────────────────────
7709
7710    #[test]
7711    fn test_price_volatility_ratio_none_for_empty() {
7712        assert!(NormalizedTick::price_volatility_ratio(&[]).is_none());
7713    }
7714
7715    #[test]
7716    fn test_price_volatility_ratio_zero_for_constant_price() {
7717        use rust_decimal_macros::dec;
7718        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(100), dec!(1))];
7719        let r = NormalizedTick::price_volatility_ratio(&ticks).unwrap();
7720        assert!(r.abs() < 1e-9, "constant price → ratio=0, got {}", r);
7721    }
7722
7723    // ── NormalizedTick::notional_per_tick ─────────────────────────────────────
7724
7725    #[test]
7726    fn test_notional_per_tick_none_for_empty() {
7727        assert!(NormalizedTick::notional_per_tick(&[]).is_none());
7728    }
7729
7730    #[test]
7731    fn test_notional_per_tick_equals_single_tick_notional() {
7732        use rust_decimal_macros::dec;
7733        let ticks = vec![make_tick_pq(dec!(100), dec!(5))];
7734        let n = NormalizedTick::notional_per_tick(&ticks).unwrap();
7735        assert!((n - 500.0).abs() < 1e-6, "100×5=500, got {}", n);
7736    }
7737
7738    // ── NormalizedTick::buy_to_total_volume_ratio ─────────────────────────────
7739
7740    #[test]
7741    fn test_buy_to_total_volume_ratio_none_for_empty() {
7742        assert!(NormalizedTick::buy_to_total_volume_ratio(&[]).is_none());
7743    }
7744
7745    #[test]
7746    fn test_buy_to_total_volume_ratio_zero_for_all_neutral() {
7747        use rust_decimal_macros::dec;
7748        let ticks = vec![make_tick_pq(dec!(100), dec!(5)), make_tick_pq(dec!(101), dec!(3))];
7749        let r = NormalizedTick::buy_to_total_volume_ratio(&ticks).unwrap();
7750        assert!(r.abs() < 1e-9, "neutral ticks → buy ratio=0, got {}", r);
7751    }
7752
7753    // ── NormalizedTick::avg_latency_ms ────────────────────────────────────────
7754
7755    #[test]
7756    fn test_avg_latency_ms_none_when_no_exchange_ts() {
7757        use rust_decimal_macros::dec;
7758        let ticks = vec![make_tick_pq(dec!(100), dec!(1))];
7759        assert!(NormalizedTick::avg_latency_ms(&ticks).is_none());
7760    }
7761
7762    // ── NormalizedTick::price_gini ────────────────────────────────────────────
7763
7764    #[test]
7765    fn test_price_gini_none_for_empty() {
7766        assert!(NormalizedTick::price_gini(&[]).is_none());
7767    }
7768
7769    #[test]
7770    fn test_price_gini_zero_for_uniform_prices() {
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!(1)),
7775            make_tick_pq(dec!(100), dec!(1)),
7776        ];
7777        let g = NormalizedTick::price_gini(&ticks).unwrap();
7778        assert!(g.abs() < 1e-9, "uniform prices → gini=0, got {}", g);
7779    }
7780
7781    // ── NormalizedTick::trade_velocity ────────────────────────────────────────
7782
7783    #[test]
7784    fn test_trade_velocity_none_for_same_timestamp() {
7785        use rust_decimal_macros::dec;
7786        let ticks = vec![
7787            make_tick_pq(dec!(100), dec!(1)),
7788            make_tick_pq(dec!(101), dec!(1)),
7789        ];
7790        assert!(NormalizedTick::trade_velocity(&ticks).is_none());
7791    }
7792
7793    // ── NormalizedTick::floor_price ───────────────────────────────────────────
7794
7795    #[test]
7796    fn test_floor_price_none_for_empty() {
7797        assert!(NormalizedTick::floor_price(&[]).is_none());
7798    }
7799
7800    #[test]
7801    fn test_floor_price_equals_min_price() {
7802        use rust_decimal_macros::dec;
7803        let ticks = vec![
7804            make_tick_pq(dec!(105), dec!(1)),
7805            make_tick_pq(dec!(100), dec!(1)),
7806            make_tick_pq(dec!(103), dec!(1)),
7807        ];
7808        assert_eq!(NormalizedTick::floor_price(&ticks), NormalizedTick::min_price(&ticks));
7809    }
7810
7811    // ── round-82 tests ────────────────────────────────────────────────────────
7812
7813    #[test]
7814    fn test_price_momentum_score_none_for_single_tick() {
7815        use rust_decimal_macros::dec;
7816        let t = make_tick_pq(dec!(100), dec!(1));
7817        assert!(NormalizedTick::price_momentum_score(&[t]).is_none());
7818    }
7819
7820    #[test]
7821    fn test_price_momentum_score_positive_for_rising_prices() {
7822        use rust_decimal_macros::dec;
7823        let ticks = vec![
7824            make_tick_pq(dec!(100), dec!(1)),
7825            make_tick_pq(dec!(102), dec!(2)),
7826            make_tick_pq(dec!(104), dec!(2)),
7827        ];
7828        let s = NormalizedTick::price_momentum_score(&ticks).unwrap();
7829        assert!(s > 0.0, "rising prices → positive momentum, got {}", s);
7830    }
7831
7832    #[test]
7833    fn test_vwap_std_none_for_single_tick() {
7834        use rust_decimal_macros::dec;
7835        let t = make_tick_pq(dec!(100), dec!(1));
7836        assert!(NormalizedTick::vwap_std(&[t]).is_none());
7837    }
7838
7839    #[test]
7840    fn test_vwap_std_zero_for_constant_price() {
7841        use rust_decimal_macros::dec;
7842        let ticks = vec![
7843            make_tick_pq(dec!(100), dec!(1)),
7844            make_tick_pq(dec!(100), dec!(2)),
7845            make_tick_pq(dec!(100), dec!(3)),
7846        ];
7847        let s = NormalizedTick::vwap_std(&ticks).unwrap();
7848        assert!(s.abs() < 1e-9, "constant price → vwap_std=0, got {}", s);
7849    }
7850
7851    #[test]
7852    fn test_price_range_expansion_none_for_empty() {
7853        assert!(NormalizedTick::price_range_expansion(&[]).is_none());
7854    }
7855
7856    #[test]
7857    fn test_price_range_expansion_monotone_rising() {
7858        use rust_decimal_macros::dec;
7859        let ticks = vec![
7860            make_tick_pq(dec!(100), dec!(1)),
7861            make_tick_pq(dec!(101), dec!(1)),
7862            make_tick_pq(dec!(102), dec!(1)),
7863            make_tick_pq(dec!(103), dec!(1)),
7864        ];
7865        let f = NormalizedTick::price_range_expansion(&ticks).unwrap();
7866        // Every tick after the first sets a new high → count=3, total=4 → 0.75
7867        assert!((f - 0.75).abs() < 1e-9, "expected 0.75, got {}", f);
7868    }
7869
7870    #[test]
7871    fn test_sell_to_total_volume_ratio_none_for_empty() {
7872        assert!(NormalizedTick::sell_to_total_volume_ratio(&[]).is_none());
7873    }
7874
7875    #[test]
7876    fn test_sell_to_total_volume_ratio_zero_for_all_buys() {
7877        use rust_decimal_macros::dec;
7878        let mut t1 = make_tick_pq(dec!(100), dec!(5));
7879        t1.side = Some(crate::tick::TradeSide::Buy);
7880        let mut t2 = make_tick_pq(dec!(101), dec!(3));
7881        t2.side = Some(crate::tick::TradeSide::Buy);
7882        let r = NormalizedTick::sell_to_total_volume_ratio(&[t1, t2]).unwrap();
7883        assert!(r.abs() < 1e-9, "all buys → sell ratio=0, got {}", r);
7884    }
7885
7886    #[test]
7887    fn test_notional_std_none_for_single_tick() {
7888        use rust_decimal_macros::dec;
7889        let t = make_tick_pq(dec!(100), dec!(1));
7890        assert!(NormalizedTick::notional_std(&[t]).is_none());
7891    }
7892
7893    #[test]
7894    fn test_notional_std_zero_for_identical_notionals() {
7895        use rust_decimal_macros::dec;
7896        let t1 = make_tick_pq(dec!(100), dec!(2));
7897        let t2 = make_tick_pq(dec!(100), dec!(2));
7898        let s = NormalizedTick::notional_std(&[t1, t2]).unwrap();
7899        assert!(s.abs() < 1e-9, "identical notionals → std=0, got {}", s);
7900    }
7901
7902    // ── round-83 tests ────────────────────────────────────────────────────────
7903
7904    #[test]
7905    fn test_buy_price_mean_none_when_no_buys() {
7906        use rust_decimal_macros::dec;
7907        let t = make_tick_pq(dec!(100), dec!(1)); // side=None
7908        assert!(NormalizedTick::buy_price_mean(&[t]).is_none());
7909    }
7910
7911    #[test]
7912    fn test_buy_price_mean_correct_value() {
7913        use rust_decimal_macros::dec;
7914        let mut t1 = make_tick_pq(dec!(100), dec!(1));
7915        t1.side = Some(crate::tick::TradeSide::Buy);
7916        let mut t2 = make_tick_pq(dec!(102), dec!(1));
7917        t2.side = Some(crate::tick::TradeSide::Buy);
7918        let mean = NormalizedTick::buy_price_mean(&[t1, t2]).unwrap();
7919        assert_eq!(mean, dec!(101));
7920    }
7921
7922    #[test]
7923    fn test_sell_price_mean_none_when_no_sells() {
7924        use rust_decimal_macros::dec;
7925        let t = make_tick_pq(dec!(100), dec!(1));
7926        assert!(NormalizedTick::sell_price_mean(&[t]).is_none());
7927    }
7928
7929    #[test]
7930    fn test_price_efficiency_none_for_single_tick() {
7931        use rust_decimal_macros::dec;
7932        let t = make_tick_pq(dec!(100), dec!(1));
7933        assert!(NormalizedTick::price_efficiency(&[t]).is_none());
7934    }
7935
7936    #[test]
7937    fn test_price_efficiency_one_for_directional() {
7938        use rust_decimal_macros::dec;
7939        let ticks = vec![
7940            make_tick_pq(dec!(100), dec!(1)),
7941            make_tick_pq(dec!(102), dec!(1)),
7942            make_tick_pq(dec!(104), dec!(1)),
7943        ];
7944        let e = NormalizedTick::price_efficiency(&ticks).unwrap();
7945        assert!((e - 1.0).abs() < 1e-9, "monotone rising → efficiency=1, got {}", e);
7946    }
7947
7948    #[test]
7949    fn test_price_return_skewness_none_for_few_ticks() {
7950        use rust_decimal_macros::dec;
7951        let ticks = vec![
7952            make_tick_pq(dec!(100), dec!(1)),
7953            make_tick_pq(dec!(101), dec!(1)),
7954            make_tick_pq(dec!(102), dec!(1)),
7955        ];
7956        assert!(NormalizedTick::price_return_skewness(&ticks).is_none());
7957    }
7958
7959    #[test]
7960    fn test_buy_sell_vwap_spread_none_when_no_sides() {
7961        use rust_decimal_macros::dec;
7962        let ticks = vec![
7963            make_tick_pq(dec!(100), dec!(1)),
7964            make_tick_pq(dec!(101), dec!(1)),
7965        ];
7966        assert!(NormalizedTick::buy_sell_vwap_spread(&ticks).is_none());
7967    }
7968
7969    #[test]
7970    fn test_above_mean_quantity_fraction_none_for_empty() {
7971        assert!(NormalizedTick::above_mean_quantity_fraction(&[]).is_none());
7972    }
7973
7974    #[test]
7975    fn test_above_mean_quantity_fraction_in_range() {
7976        use rust_decimal_macros::dec;
7977        let ticks = vec![
7978            make_tick_pq(dec!(100), dec!(1)),
7979            make_tick_pq(dec!(100), dec!(5)),
7980            make_tick_pq(dec!(100), dec!(3)),
7981        ];
7982        let f = NormalizedTick::above_mean_quantity_fraction(&ticks).unwrap();
7983        assert!(f >= 0.0 && f <= 1.0, "fraction in [0,1], got {}", f);
7984    }
7985
7986    #[test]
7987    fn test_price_unchanged_fraction_none_for_single_tick() {
7988        use rust_decimal_macros::dec;
7989        let t = make_tick_pq(dec!(100), dec!(1));
7990        assert!(NormalizedTick::price_unchanged_fraction(&[t]).is_none());
7991    }
7992
7993    #[test]
7994    fn test_price_unchanged_fraction_zero_for_all_changing() {
7995        use rust_decimal_macros::dec;
7996        let ticks = vec![
7997            make_tick_pq(dec!(100), dec!(1)),
7998            make_tick_pq(dec!(101), dec!(1)),
7999            make_tick_pq(dec!(102), dec!(1)),
8000        ];
8001        let f = NormalizedTick::price_unchanged_fraction(&ticks).unwrap();
8002        assert!(f.abs() < 1e-9, "all prices different → unchanged=0, got {}", f);
8003    }
8004
8005    #[test]
8006    fn test_qty_weighted_range_none_for_empty() {
8007        assert!(NormalizedTick::qty_weighted_range(&[]).is_none());
8008    }
8009
8010    #[test]
8011    fn test_qty_weighted_range_zero_for_single_tick() {
8012        use rust_decimal_macros::dec;
8013        let t = make_tick_pq(dec!(100), dec!(2));
8014        let r = NormalizedTick::qty_weighted_range(&[t]).unwrap();
8015        assert!(r.abs() < 1e-9, "single tick → range=0, got {}", r);
8016    }
8017
8018    // ── round-84 tests ────────────────────────────────────────────────────────
8019
8020    #[test]
8021    fn test_sell_notional_fraction_none_for_empty() {
8022        assert!(NormalizedTick::sell_notional_fraction(&[]).is_none());
8023    }
8024
8025    #[test]
8026    fn test_sell_notional_fraction_zero_for_all_buys() {
8027        use rust_decimal_macros::dec;
8028        let mut t1 = make_tick_pq(dec!(100), dec!(3));
8029        t1.side = Some(crate::tick::TradeSide::Buy);
8030        let r = NormalizedTick::sell_notional_fraction(&[t1]).unwrap();
8031        assert!(r.abs() < 1e-9, "all buys → sell fraction=0, got {}", r);
8032    }
8033
8034    #[test]
8035    fn test_max_price_gap_none_for_single_tick() {
8036        use rust_decimal_macros::dec;
8037        let t = make_tick_pq(dec!(100), dec!(1));
8038        assert!(NormalizedTick::max_price_gap(&[t]).is_none());
8039    }
8040
8041    #[test]
8042    fn test_max_price_gap_correct_value() {
8043        use rust_decimal_macros::dec;
8044        let ticks = vec![
8045            make_tick_pq(dec!(100), dec!(1)),
8046            make_tick_pq(dec!(105), dec!(1)),
8047            make_tick_pq(dec!(103), dec!(1)),
8048        ];
8049        assert_eq!(NormalizedTick::max_price_gap(&ticks).unwrap(), dec!(5));
8050    }
8051
8052    #[test]
8053    fn test_price_range_velocity_none_for_single_tick() {
8054        use rust_decimal_macros::dec;
8055        let t = make_tick_pq(dec!(100), dec!(1));
8056        assert!(NormalizedTick::price_range_velocity(&[t]).is_none());
8057    }
8058
8059    #[test]
8060    fn test_tick_count_per_ms_none_for_single_tick() {
8061        use rust_decimal_macros::dec;
8062        let t = make_tick_pq(dec!(100), dec!(1));
8063        assert!(NormalizedTick::tick_count_per_ms(&[t]).is_none());
8064    }
8065
8066    #[test]
8067    fn test_buy_quantity_fraction_none_for_empty() {
8068        assert!(NormalizedTick::buy_quantity_fraction(&[]).is_none());
8069    }
8070
8071    #[test]
8072    fn test_buy_quantity_fraction_one_for_all_buys() {
8073        use rust_decimal_macros::dec;
8074        let mut t = make_tick_pq(dec!(100), dec!(5));
8075        t.side = Some(crate::tick::TradeSide::Buy);
8076        let f = NormalizedTick::buy_quantity_fraction(&[t]).unwrap();
8077        assert!((f - 1.0).abs() < 1e-9, "all buys → buy fraction=1, got {}", f);
8078    }
8079
8080    #[test]
8081    fn test_sell_quantity_fraction_none_for_empty() {
8082        assert!(NormalizedTick::sell_quantity_fraction(&[]).is_none());
8083    }
8084
8085    #[test]
8086    fn test_sell_quantity_fraction_one_for_all_sells() {
8087        use rust_decimal_macros::dec;
8088        let mut t = make_tick_pq(dec!(100), dec!(5));
8089        t.side = Some(crate::tick::TradeSide::Sell);
8090        let f = NormalizedTick::sell_quantity_fraction(&[t]).unwrap();
8091        assert!((f - 1.0).abs() < 1e-9, "all sells → sell fraction=1, got {}", f);
8092    }
8093
8094    #[test]
8095    fn test_price_mean_crossover_count_none_for_single_tick() {
8096        use rust_decimal_macros::dec;
8097        let t = make_tick_pq(dec!(100), dec!(1));
8098        assert!(NormalizedTick::price_mean_crossover_count(&[t]).is_none());
8099    }
8100
8101    #[test]
8102    fn test_price_mean_crossover_count_in_range() {
8103        use rust_decimal_macros::dec;
8104        let ticks = vec![
8105            make_tick_pq(dec!(90), dec!(1)),
8106            make_tick_pq(dec!(110), dec!(1)),
8107            make_tick_pq(dec!(90), dec!(1)),
8108        ];
8109        let c = NormalizedTick::price_mean_crossover_count(&ticks).unwrap();
8110        assert!(c >= 1, "expect at least 1 crossover, got {}", c);
8111    }
8112
8113    #[test]
8114    fn test_notional_skewness_none_for_two_ticks() {
8115        use rust_decimal_macros::dec;
8116        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(1))];
8117        assert!(NormalizedTick::notional_skewness(&ticks).is_none());
8118    }
8119
8120    #[test]
8121    fn test_volume_weighted_mid_price_none_for_empty() {
8122        assert!(NormalizedTick::volume_weighted_mid_price(&[]).is_none());
8123    }
8124
8125    #[test]
8126    fn test_volume_weighted_mid_price_equals_price_for_single_tick() {
8127        use rust_decimal_macros::dec;
8128        let t = make_tick_pq(dec!(123), dec!(5));
8129        let mid = NormalizedTick::volume_weighted_mid_price(&[t]).unwrap();
8130        assert_eq!(mid, dec!(123));
8131    }
8132
8133    // ── round-85 tests ────────────────────────────────────────────────────────
8134
8135    #[test]
8136    fn test_neutral_count_zero_when_all_sided() {
8137        use rust_decimal_macros::dec;
8138        let mut t = make_tick_pq(dec!(100), dec!(1));
8139        t.side = Some(crate::tick::TradeSide::Buy);
8140        assert_eq!(NormalizedTick::neutral_count(&[t]), 0);
8141    }
8142
8143    #[test]
8144    fn test_neutral_count_all_when_no_side() {
8145        use rust_decimal_macros::dec;
8146        let t1 = make_tick_pq(dec!(100), dec!(1));
8147        let t2 = make_tick_pq(dec!(101), dec!(1));
8148        assert_eq!(NormalizedTick::neutral_count(&[t1, t2]), 2);
8149    }
8150
8151    #[test]
8152    fn test_price_dispersion_none_for_empty() {
8153        assert!(NormalizedTick::price_dispersion(&[]).is_none());
8154    }
8155
8156    #[test]
8157    fn test_price_dispersion_zero_for_single() {
8158        use rust_decimal_macros::dec;
8159        let t = make_tick_pq(dec!(100), dec!(1));
8160        assert_eq!(NormalizedTick::price_dispersion(&[t]).unwrap(), dec!(0));
8161    }
8162
8163    #[test]
8164    fn test_max_notional_none_for_empty() {
8165        assert!(NormalizedTick::max_notional(&[]).is_none());
8166    }
8167
8168    #[test]
8169    fn test_max_notional_selects_largest() {
8170        use rust_decimal_macros::dec;
8171        let t1 = make_tick_pq(dec!(100), dec!(2)); // 200
8172        let t2 = make_tick_pq(dec!(50), dec!(5));  // 250
8173        assert_eq!(NormalizedTick::max_notional(&[t1, t2]).unwrap(), dec!(250));
8174    }
8175
8176    #[test]
8177    fn test_min_notional_none_for_empty() {
8178        assert!(NormalizedTick::min_notional(&[]).is_none());
8179    }
8180
8181    #[test]
8182    fn test_below_vwap_fraction_none_for_empty() {
8183        assert!(NormalizedTick::below_vwap_fraction(&[]).is_none());
8184    }
8185
8186    #[test]
8187    fn test_trade_notional_std_none_for_single() {
8188        use rust_decimal_macros::dec;
8189        let t = make_tick_pq(dec!(100), dec!(1));
8190        assert!(NormalizedTick::trade_notional_std(&[t]).is_none());
8191    }
8192
8193    #[test]
8194    fn test_buy_sell_count_ratio_none_for_no_sells() {
8195        use rust_decimal_macros::dec;
8196        let mut t = make_tick_pq(dec!(100), dec!(1));
8197        t.side = Some(crate::tick::TradeSide::Buy);
8198        assert!(NormalizedTick::buy_sell_count_ratio(&[t]).is_none());
8199    }
8200
8201    #[test]
8202    fn test_buy_sell_count_ratio_correct() {
8203        use rust_decimal_macros::dec;
8204        let mut t1 = make_tick_pq(dec!(100), dec!(1));
8205        t1.side = Some(crate::tick::TradeSide::Buy);
8206        let mut t2 = make_tick_pq(dec!(100), dec!(1));
8207        t2.side = Some(crate::tick::TradeSide::Sell);
8208        let r = NormalizedTick::buy_sell_count_ratio(&[t1, t2]).unwrap();
8209        assert!((r - 1.0).abs() < 1e-9, "1 buy / 1 sell = 1.0, got {}", r);
8210    }
8211
8212    #[test]
8213    fn test_price_mad_none_for_empty() {
8214        assert!(NormalizedTick::price_mad(&[]).is_none());
8215    }
8216
8217    #[test]
8218    fn test_price_mad_zero_for_constant_price() {
8219        use rust_decimal_macros::dec;
8220        let ticks = vec![
8221            make_tick_pq(dec!(100), dec!(1)),
8222            make_tick_pq(dec!(100), dec!(2)),
8223        ];
8224        let m = NormalizedTick::price_mad(&ticks).unwrap();
8225        assert!(m.abs() < 1e-9, "constant price → MAD=0, got {}", m);
8226    }
8227
8228    #[test]
8229    fn test_price_range_pct_of_open_none_for_empty() {
8230        assert!(NormalizedTick::price_range_pct_of_open(&[]).is_none());
8231    }
8232
8233    #[test]
8234    fn test_price_range_pct_of_open_zero_for_constant() {
8235        use rust_decimal_macros::dec;
8236        let ticks = vec![
8237            make_tick_pq(dec!(100), dec!(1)),
8238            make_tick_pq(dec!(100), dec!(1)),
8239        ];
8240        let p = NormalizedTick::price_range_pct_of_open(&ticks).unwrap();
8241        assert!(p.abs() < 1e-9, "constant → range_pct=0, got {}", p);
8242    }
8243
8244    // ── round-86 tests ────────────────────────────────────────────────────────
8245
8246    #[test]
8247    fn test_price_mean_none_for_empty() {
8248        assert!(NormalizedTick::price_mean(&[]).is_none());
8249    }
8250
8251    #[test]
8252    fn test_price_mean_correct() {
8253        use rust_decimal_macros::dec;
8254        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(200), dec!(1))];
8255        assert_eq!(NormalizedTick::price_mean(&ticks).unwrap(), dec!(150));
8256    }
8257
8258    #[test]
8259    fn test_uptick_count_zero_for_single() {
8260        use rust_decimal_macros::dec;
8261        let t = make_tick_pq(dec!(100), dec!(1));
8262        assert_eq!(NormalizedTick::uptick_count(&[t]), 0);
8263    }
8264
8265    #[test]
8266    fn test_uptick_count_correct() {
8267        use rust_decimal_macros::dec;
8268        let ticks = vec![
8269            make_tick_pq(dec!(100), dec!(1)),
8270            make_tick_pq(dec!(101), dec!(1)),
8271            make_tick_pq(dec!(100), dec!(1)),
8272        ];
8273        assert_eq!(NormalizedTick::uptick_count(&ticks), 1);
8274    }
8275
8276    #[test]
8277    fn test_downtick_count_zero_for_all_up() {
8278        use rust_decimal_macros::dec;
8279        let ticks = vec![
8280            make_tick_pq(dec!(100), dec!(1)),
8281            make_tick_pq(dec!(101), dec!(1)),
8282            make_tick_pq(dec!(102), dec!(1)),
8283        ];
8284        assert_eq!(NormalizedTick::downtick_count(&ticks), 0);
8285    }
8286
8287    #[test]
8288    fn test_uptick_fraction_none_for_single() {
8289        use rust_decimal_macros::dec;
8290        let t = make_tick_pq(dec!(100), dec!(1));
8291        assert!(NormalizedTick::uptick_fraction(&[t]).is_none());
8292    }
8293
8294    #[test]
8295    fn test_quantity_std_none_for_single() {
8296        use rust_decimal_macros::dec;
8297        let t = make_tick_pq(dec!(100), dec!(1));
8298        assert!(NormalizedTick::quantity_std(&[t]).is_none());
8299    }
8300
8301    #[test]
8302    fn test_quantity_std_zero_for_constant_qty() {
8303        use rust_decimal_macros::dec;
8304        let ticks = vec![
8305            make_tick_pq(dec!(100), dec!(5)),
8306            make_tick_pq(dec!(101), dec!(5)),
8307        ];
8308        let s = NormalizedTick::quantity_std(&ticks).unwrap();
8309        assert!(s.abs() < 1e-9, "constant quantity → std=0, got {}", s);
8310    }
8311
8312    // ── round-87 tests ────────────────────────────────────────────────────────
8313
8314    #[test]
8315    fn test_vwap_deviation_std_none_for_single() {
8316        use rust_decimal_macros::dec;
8317        let t = make_tick_pq(dec!(100), dec!(1));
8318        assert!(NormalizedTick::vwap_deviation_std(&[t]).is_none());
8319    }
8320
8321    #[test]
8322    fn test_vwap_deviation_std_zero_for_single_price() {
8323        use rust_decimal_macros::dec;
8324        let ticks = vec![
8325            make_tick_pq(dec!(100), dec!(1)),
8326            make_tick_pq(dec!(100), dec!(2)),
8327        ];
8328        // All prices equal VWAP, so deviation std = 0
8329        let s = NormalizedTick::vwap_deviation_std(&ticks).unwrap();
8330        assert!(s.abs() < 1e-9, "all at VWAP → std=0, got {}", s);
8331    }
8332
8333    #[test]
8334    fn test_vwap_deviation_std_positive_for_varied_prices() {
8335        use rust_decimal_macros::dec;
8336        let ticks = vec![
8337            make_tick_pq(dec!(100), dec!(1)),
8338            make_tick_pq(dec!(110), dec!(1)),
8339            make_tick_pq(dec!(90), dec!(1)),
8340        ];
8341        let s = NormalizedTick::vwap_deviation_std(&ticks).unwrap();
8342        assert!(s > 0.0, "varied prices → std > 0, got {}", s);
8343    }
8344
8345    #[test]
8346    fn test_max_consecutive_side_run_zero_for_no_side() {
8347        use rust_decimal_macros::dec;
8348        let ticks = vec![
8349            make_tick_pq(dec!(100), dec!(1)),
8350            make_tick_pq(dec!(101), dec!(1)),
8351        ];
8352        assert_eq!(NormalizedTick::max_consecutive_side_run(&ticks), 0);
8353    }
8354
8355    #[test]
8356    fn test_max_consecutive_side_run_with_sides() {
8357        use rust_decimal_macros::dec;
8358        let mut t1 = make_tick_pq(dec!(100), dec!(1));
8359        t1.side = Some(TradeSide::Buy);
8360        let mut t2 = make_tick_pq(dec!(101), dec!(1));
8361        t2.side = Some(TradeSide::Buy);
8362        let mut t3 = make_tick_pq(dec!(102), dec!(1));
8363        t3.side = Some(TradeSide::Sell);
8364        assert_eq!(NormalizedTick::max_consecutive_side_run(&[t1, t2, t3]), 2);
8365    }
8366
8367    #[test]
8368    fn test_inter_arrival_cv_none_for_single() {
8369        use rust_decimal_macros::dec;
8370        let t = make_tick_pq(dec!(100), dec!(1));
8371        assert!(NormalizedTick::inter_arrival_cv(&[t]).is_none());
8372    }
8373
8374    #[test]
8375    fn test_inter_arrival_cv_zero_for_uniform_spacing() {
8376        use rust_decimal_macros::dec;
8377        let mut t1 = make_tick_pq(dec!(100), dec!(1));
8378        t1.received_at_ms = 1000;
8379        let mut t2 = make_tick_pq(dec!(101), dec!(1));
8380        t2.received_at_ms = 2000;
8381        let mut t3 = make_tick_pq(dec!(102), dec!(1));
8382        t3.received_at_ms = 3000;
8383        // All intervals = 1000ms → std=0, cv=0
8384        let cv = NormalizedTick::inter_arrival_cv(&[t1, t2, t3]).unwrap();
8385        assert!(cv.abs() < 1e-9, "uniform spacing → cv=0, got {}", cv);
8386    }
8387
8388    #[test]
8389    fn test_volume_per_ms_none_for_single() {
8390        use rust_decimal_macros::dec;
8391        let t = make_tick_pq(dec!(100), dec!(5));
8392        assert!(NormalizedTick::volume_per_ms(&[t]).is_none());
8393    }
8394
8395    #[test]
8396    fn test_volume_per_ms_correct() {
8397        use rust_decimal_macros::dec;
8398        let mut t1 = make_tick_pq(dec!(100), dec!(5));
8399        t1.received_at_ms = 1000;
8400        let mut t2 = make_tick_pq(dec!(101), dec!(5));
8401        t2.received_at_ms = 2000;
8402        // 10 qty / 1000 ms = 0.01
8403        let r = NormalizedTick::volume_per_ms(&[t1, t2]).unwrap();
8404        assert!((r - 0.01).abs() < 1e-9, "expected 0.01, got {}", r);
8405    }
8406
8407    #[test]
8408    fn test_notional_per_second_none_for_single() {
8409        use rust_decimal_macros::dec;
8410        let t = make_tick_pq(dec!(100), dec!(1));
8411        assert!(NormalizedTick::notional_per_second(&[t]).is_none());
8412    }
8413
8414    #[test]
8415    fn test_notional_per_second_positive() {
8416        use rust_decimal_macros::dec;
8417        let mut t1 = make_tick_pq(dec!(100), dec!(1));
8418        t1.received_at_ms = 0;
8419        let mut t2 = make_tick_pq(dec!(100), dec!(1));
8420        t2.received_at_ms = 1000; // 1 second
8421        // 100 + 100 = 200 notional in 1s
8422        let r = NormalizedTick::notional_per_second(&[t1, t2]).unwrap();
8423        assert!((r - 200.0).abs() < 1e-9, "expected 200, got {}", r);
8424    }
8425
8426    // ── round-88 tests ────────────────────────────────────────────────────────
8427
8428    #[test]
8429    fn test_order_flow_imbalance_none_for_empty() {
8430        assert!(NormalizedTick::order_flow_imbalance(&[]).is_none());
8431    }
8432
8433    #[test]
8434    fn test_order_flow_imbalance_pos_one_for_all_buys() {
8435        use rust_decimal_macros::dec;
8436        let mut t = make_tick_pq(dec!(100), dec!(5));
8437        t.side = Some(crate::tick::TradeSide::Buy);
8438        let r = NormalizedTick::order_flow_imbalance(&[t]).unwrap();
8439        assert!((r - 1.0).abs() < 1e-9, "all buys → OFI=+1, got {}", r);
8440    }
8441
8442    #[test]
8443    fn test_order_flow_imbalance_neg_one_for_all_sells() {
8444        use rust_decimal_macros::dec;
8445        let mut t = make_tick_pq(dec!(100), dec!(5));
8446        t.side = Some(crate::tick::TradeSide::Sell);
8447        let r = NormalizedTick::order_flow_imbalance(&[t]).unwrap();
8448        assert!((r + 1.0).abs() < 1e-9, "all sells → OFI=-1, got {}", r);
8449    }
8450
8451    #[test]
8452    fn test_price_qty_up_fraction_none_for_single() {
8453        use rust_decimal_macros::dec;
8454        let t = make_tick_pq(dec!(100), dec!(1));
8455        assert!(NormalizedTick::price_qty_up_fraction(&[t]).is_none());
8456    }
8457
8458    #[test]
8459    fn test_running_high_count_single_tick() {
8460        use rust_decimal_macros::dec;
8461        let t = make_tick_pq(dec!(100), dec!(1));
8462        assert_eq!(NormalizedTick::running_high_count(&[t]), 1);
8463    }
8464
8465    #[test]
8466    fn test_running_low_count_single_tick() {
8467        use rust_decimal_macros::dec;
8468        let t = make_tick_pq(dec!(100), dec!(1));
8469        assert_eq!(NormalizedTick::running_low_count(&[t]), 1);
8470    }
8471
8472    #[test]
8473    fn test_buy_sell_avg_qty_ratio_none_for_no_sells() {
8474        use rust_decimal_macros::dec;
8475        let mut t = make_tick_pq(dec!(100), dec!(5));
8476        t.side = Some(crate::tick::TradeSide::Buy);
8477        assert!(NormalizedTick::buy_sell_avg_qty_ratio(&[t]).is_none());
8478    }
8479
8480    #[test]
8481    fn test_max_price_drop_none_for_single() {
8482        use rust_decimal_macros::dec;
8483        let t = make_tick_pq(dec!(100), dec!(1));
8484        assert!(NormalizedTick::max_price_drop(&[t]).is_none());
8485    }
8486
8487    #[test]
8488    fn test_max_price_rise_none_for_single() {
8489        use rust_decimal_macros::dec;
8490        let t = make_tick_pq(dec!(100), dec!(1));
8491        assert!(NormalizedTick::max_price_rise(&[t]).is_none());
8492    }
8493
8494    #[test]
8495    fn test_max_price_drop_correct() {
8496        use rust_decimal_macros::dec;
8497        let ticks = vec![
8498            make_tick_pq(dec!(100), dec!(1)),
8499            make_tick_pq(dec!(90), dec!(1)),  // drop = 10
8500            make_tick_pq(dec!(95), dec!(1)),  // rise
8501        ];
8502        assert_eq!(NormalizedTick::max_price_drop(&ticks).unwrap(), dec!(10));
8503    }
8504
8505    #[test]
8506    fn test_max_price_rise_correct() {
8507        use rust_decimal_macros::dec;
8508        let ticks = vec![
8509            make_tick_pq(dec!(90), dec!(1)),
8510            make_tick_pq(dec!(105), dec!(1)),  // rise = 15
8511            make_tick_pq(dec!(100), dec!(1)),
8512        ];
8513        assert_eq!(NormalizedTick::max_price_rise(&ticks).unwrap(), dec!(15));
8514    }
8515
8516    #[test]
8517    fn test_buy_trade_count_zero_for_no_sides() {
8518        use rust_decimal_macros::dec;
8519        let t = make_tick_pq(dec!(100), dec!(1));
8520        assert_eq!(NormalizedTick::buy_trade_count(&[t]), 0);
8521    }
8522
8523    #[test]
8524    fn test_buy_trade_count_correct() {
8525        use rust_decimal_macros::dec;
8526        let mut t1 = make_tick_pq(dec!(100), dec!(1));
8527        t1.side = Some(TradeSide::Buy);
8528        let mut t2 = make_tick_pq(dec!(100), dec!(1));
8529        t2.side = Some(TradeSide::Sell);
8530        assert_eq!(NormalizedTick::buy_trade_count(&[t1, t2]), 1);
8531    }
8532
8533    #[test]
8534    fn test_sell_trade_count_correct() {
8535        use rust_decimal_macros::dec;
8536        let mut t1 = make_tick_pq(dec!(100), dec!(1));
8537        t1.side = Some(TradeSide::Buy);
8538        let mut t2 = make_tick_pq(dec!(100), dec!(1));
8539        t2.side = Some(TradeSide::Sell);
8540        assert_eq!(NormalizedTick::sell_trade_count(&[t1, t2]), 1);
8541    }
8542
8543    #[test]
8544    fn test_price_reversal_fraction_none_for_two_ticks() {
8545        use rust_decimal_macros::dec;
8546        let t1 = make_tick_pq(dec!(100), dec!(1));
8547        let t2 = make_tick_pq(dec!(101), dec!(1));
8548        assert!(NormalizedTick::price_reversal_fraction(&[t1, t2]).is_none());
8549    }
8550
8551    #[test]
8552    fn test_price_reversal_fraction_one_for_zigzag() {
8553        use rust_decimal_macros::dec;
8554        // up-down-up: 2 reversals out of 2 pairs → 1.0
8555        let ticks = vec![
8556            make_tick_pq(dec!(100), dec!(1)),
8557            make_tick_pq(dec!(110), dec!(1)),
8558            make_tick_pq(dec!(105), dec!(1)),
8559            make_tick_pq(dec!(115), dec!(1)),
8560        ];
8561        let f = NormalizedTick::price_reversal_fraction(&ticks).unwrap();
8562        assert!((f - 1.0).abs() < 1e-9, "perfect zigzag → 1.0, got {}", f);
8563    }
8564
8565    // ── round-89 tests ────────────────────────────────────────────────────────
8566
8567    #[test]
8568    fn test_near_vwap_fraction_none_for_empty() {
8569        use rust_decimal_macros::dec;
8570        assert!(NormalizedTick::near_vwap_fraction(&[], dec!(1)).is_none());
8571    }
8572
8573    #[test]
8574    fn test_near_vwap_fraction_one_for_all_at_vwap() {
8575        use rust_decimal_macros::dec;
8576        // All ticks at same price → price == VWAP, band = 0 → fraction = 1
8577        let ticks = vec![
8578            make_tick_pq(dec!(100), dec!(1)),
8579            make_tick_pq(dec!(100), dec!(1)),
8580        ];
8581        let f = NormalizedTick::near_vwap_fraction(&ticks, dec!(0)).unwrap();
8582        assert!((f - 1.0).abs() < 1e-9, "all at VWAP → 1.0, got {}", f);
8583    }
8584
8585    #[test]
8586    fn test_mean_tick_return_none_for_single() {
8587        use rust_decimal_macros::dec;
8588        let t = make_tick_pq(dec!(100), dec!(1));
8589        assert!(NormalizedTick::mean_tick_return(&[t]).is_none());
8590    }
8591
8592    #[test]
8593    fn test_mean_tick_return_zero_for_constant_price() {
8594        use rust_decimal_macros::dec;
8595        let ticks = vec![
8596            make_tick_pq(dec!(100), dec!(1)),
8597            make_tick_pq(dec!(100), dec!(1)),
8598            make_tick_pq(dec!(100), dec!(1)),
8599        ];
8600        let r = NormalizedTick::mean_tick_return(&ticks).unwrap();
8601        assert!(r.abs() < 1e-9, "constant price → mean_return=0, got {}", r);
8602    }
8603
8604    #[test]
8605    fn test_passive_buy_count_zero_for_no_sides() {
8606        use rust_decimal_macros::dec;
8607        let t = make_tick_pq(dec!(100), dec!(1));
8608        assert_eq!(NormalizedTick::passive_buy_count(&[t]), 0);
8609    }
8610
8611    #[test]
8612    fn test_quantity_iqr_none_for_small_slice() {
8613        use rust_decimal_macros::dec;
8614        let ticks = vec![
8615            make_tick_pq(dec!(100), dec!(1)),
8616            make_tick_pq(dec!(101), dec!(2)),
8617        ];
8618        assert!(NormalizedTick::quantity_iqr(&ticks).is_none());
8619    }
8620
8621    #[test]
8622    fn test_quantity_iqr_positive_for_varied_quantities() {
8623        use rust_decimal_macros::dec;
8624        let ticks: Vec<_> = [dec!(1), dec!(2), dec!(8), dec!(16), dec!(32), dec!(64), dec!(128), dec!(256)]
8625            .iter()
8626            .map(|&q| make_tick_pq(dec!(100), q))
8627            .collect();
8628        let iqr = NormalizedTick::quantity_iqr(&ticks).unwrap();
8629        assert!(iqr > dec!(0));
8630    }
8631
8632    #[test]
8633    fn test_top_quartile_price_fraction_none_for_small_slice() {
8634        use rust_decimal_macros::dec;
8635        let ticks = vec![
8636            make_tick_pq(dec!(100), dec!(1)),
8637            make_tick_pq(dec!(101), dec!(1)),
8638        ];
8639        assert!(NormalizedTick::top_quartile_price_fraction(&ticks).is_none());
8640    }
8641
8642    #[test]
8643    fn test_buy_notional_ratio_none_for_empty() {
8644        assert!(NormalizedTick::buy_notional_ratio(&[]).is_none());
8645    }
8646
8647    #[test]
8648    fn test_buy_notional_ratio_one_for_all_buys() {
8649        use rust_decimal_macros::dec;
8650        let mut t = make_tick_pq(dec!(100), dec!(1));
8651        t.side = Some(TradeSide::Buy);
8652        let r = NormalizedTick::buy_notional_ratio(&[t]).unwrap();
8653        assert!((r - 1.0).abs() < 1e-9, "all buys → ratio=1, got {}", r);
8654    }
8655
8656    #[test]
8657    fn test_return_std_none_for_two_ticks() {
8658        use rust_decimal_macros::dec;
8659        let t1 = make_tick_pq(dec!(100), dec!(1));
8660        let t2 = make_tick_pq(dec!(101), dec!(1));
8661        assert!(NormalizedTick::return_std(&[t1, t2]).is_none());
8662    }
8663
8664    #[test]
8665    fn test_return_std_zero_for_constant_price() {
8666        use rust_decimal_macros::dec;
8667        let ticks = vec![
8668            make_tick_pq(dec!(100), dec!(1)),
8669            make_tick_pq(dec!(100), dec!(1)),
8670            make_tick_pq(dec!(100), dec!(1)),
8671        ];
8672        let s = NormalizedTick::return_std(&ticks).unwrap();
8673        assert!(s.abs() < 1e-9, "constant price → return_std=0, got {}", s);
8674    }
8675
8676    // ── round-90 tests ────────────────────────────────────────────────────────
8677
8678    #[test]
8679    fn test_max_drawdown_none_for_empty() {
8680        assert!(NormalizedTick::max_drawdown(&[]).is_none());
8681    }
8682
8683    #[test]
8684    fn test_max_drawdown_zero_for_rising_prices() {
8685        use rust_decimal_macros::dec;
8686        let ticks = vec![
8687            make_tick_pq(dec!(100), dec!(1)),
8688            make_tick_pq(dec!(110), dec!(1)),
8689            make_tick_pq(dec!(120), dec!(1)),
8690        ];
8691        let dd = NormalizedTick::max_drawdown(&ticks).unwrap();
8692        assert!(dd.abs() < 1e-9, "monotone rise → drawdown=0, got {}", dd);
8693    }
8694
8695    #[test]
8696    fn test_max_drawdown_positive_after_peak() {
8697        use rust_decimal_macros::dec;
8698        let ticks = vec![
8699            make_tick_pq(dec!(100), dec!(1)),
8700            make_tick_pq(dec!(120), dec!(1)),
8701            make_tick_pq(dec!(90), dec!(1)),
8702        ];
8703        let dd = NormalizedTick::max_drawdown(&ticks).unwrap();
8704        // peak=120, trough=90 → dd = 30/120 = 0.25
8705        assert!((dd - 0.25).abs() < 1e-6, "expected 0.25, got {}", dd);
8706    }
8707
8708    #[test]
8709    fn test_high_to_low_ratio_none_for_empty() {
8710        assert!(NormalizedTick::high_to_low_ratio(&[]).is_none());
8711    }
8712
8713    #[test]
8714    fn test_high_to_low_ratio_one_for_constant_price() {
8715        use rust_decimal_macros::dec;
8716        let ticks = vec![
8717            make_tick_pq(dec!(100), dec!(1)),
8718            make_tick_pq(dec!(100), dec!(1)),
8719        ];
8720        let r = NormalizedTick::high_to_low_ratio(&ticks).unwrap();
8721        assert!((r - 1.0).abs() < 1e-9, "constant price → ratio=1, got {}", r);
8722    }
8723
8724    #[test]
8725    fn test_tick_velocity_none_for_single_tick() {
8726        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
8727        assert!(NormalizedTick::tick_velocity(&[t]).is_none());
8728    }
8729
8730    #[test]
8731    fn test_notional_decay_none_for_single_tick() {
8732        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
8733        assert!(NormalizedTick::notional_decay(&[t]).is_none());
8734    }
8735
8736    #[test]
8737    fn test_notional_decay_one_for_balanced_halves() {
8738        use rust_decimal_macros::dec;
8739        let t1 = make_tick_pq(dec!(100), dec!(1));
8740        let t2 = make_tick_pq(dec!(100), dec!(1));
8741        let r = NormalizedTick::notional_decay(&[t1, t2]).unwrap();
8742        assert!((r - 1.0).abs() < 1e-9, "equal halves → ratio=1, got {}", r);
8743    }
8744
8745    #[test]
8746    fn test_late_price_momentum_none_for_single_tick() {
8747        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
8748        assert!(NormalizedTick::late_price_momentum(&[t]).is_none());
8749    }
8750
8751    #[test]
8752    fn test_consecutive_buys_max_zero_for_empty() {
8753        assert_eq!(NormalizedTick::consecutive_buys_max(&[]), 0);
8754    }
8755
8756    #[test]
8757    fn test_consecutive_buys_max_two_for_run_of_two() {
8758        use rust_decimal_macros::dec;
8759        let mut buy1 = make_tick_pq(dec!(100), dec!(1));
8760        buy1.side = Some(TradeSide::Buy);
8761        let mut buy2 = make_tick_pq(dec!(101), dec!(1));
8762        buy2.side = Some(TradeSide::Buy);
8763        let mut sell = make_tick_pq(dec!(102), dec!(1));
8764        sell.side = Some(TradeSide::Sell);
8765        assert_eq!(NormalizedTick::consecutive_buys_max(&[buy1, buy2, sell]), 2);
8766    }
8767
8768    // ── round-91 tests ────────────────────────────────────────────────────────
8769
8770    #[test]
8771    fn test_price_acceleration_none_for_two_ticks() {
8772        use rust_decimal_macros::dec;
8773        let t1 = make_tick_pq(dec!(100), dec!(1));
8774        let t2 = make_tick_pq(dec!(101), dec!(1));
8775        assert!(NormalizedTick::price_acceleration(&[t1, t2]).is_none());
8776    }
8777
8778    #[test]
8779    fn test_price_acceleration_zero_for_linear_price() {
8780        use rust_decimal_macros::dec;
8781        // Linear: 100, 101, 102 → second differences all zero
8782        let ticks = vec![
8783            make_tick_pq(dec!(100), dec!(1)),
8784            make_tick_pq(dec!(101), dec!(1)),
8785            make_tick_pq(dec!(102), dec!(1)),
8786        ];
8787        let a = NormalizedTick::price_acceleration(&ticks).unwrap();
8788        assert!(a.abs() < 1e-9, "linear price → acceleration=0, got {}", a);
8789    }
8790
8791    #[test]
8792    fn test_large_trade_fraction_none_for_empty() {
8793        assert!(NormalizedTick::large_trade_fraction(&[]).is_none());
8794    }
8795
8796    #[test]
8797    fn test_large_trade_fraction_half_for_one_above_one_below() {
8798        use rust_decimal_macros::dec;
8799        let t1 = make_tick_pq(dec!(100), dec!(1)); // qty=1 < mean=2
8800        let t2 = make_tick_pq(dec!(100), dec!(3)); // qty=3 > mean=2
8801        let f = NormalizedTick::large_trade_fraction(&[t1, t2]).unwrap();
8802        assert!((f - 0.5).abs() < 1e-9, "one above, one below → 0.5, got {}", f);
8803    }
8804
8805    #[test]
8806    fn test_side_alternation_rate_none_for_no_sided_ticks() {
8807        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
8808        assert!(NormalizedTick::side_alternation_rate(&[t]).is_none());
8809    }
8810
8811    #[test]
8812    fn test_side_alternation_rate_one_for_full_alternation() {
8813        use rust_decimal_macros::dec;
8814        let mut b = make_tick_pq(dec!(100), dec!(1));
8815        b.side = Some(TradeSide::Buy);
8816        let mut s = make_tick_pq(dec!(101), dec!(1));
8817        s.side = Some(TradeSide::Sell);
8818        let mut b2 = make_tick_pq(dec!(102), dec!(1));
8819        b2.side = Some(TradeSide::Buy);
8820        let r = NormalizedTick::side_alternation_rate(&[b, s, b2]).unwrap();
8821        assert!((r - 1.0).abs() < 1e-9, "B-S-B → rate=1.0, got {}", r);
8822    }
8823
8824    #[test]
8825    fn test_price_range_per_tick_none_for_empty() {
8826        assert!(NormalizedTick::price_range_per_tick(&[]).is_none());
8827    }
8828
8829    #[test]
8830    fn test_price_range_per_tick_zero_for_constant_price() {
8831        use rust_decimal_macros::dec;
8832        let ticks = vec![
8833            make_tick_pq(dec!(100), dec!(1)),
8834            make_tick_pq(dec!(100), dec!(1)),
8835        ];
8836        let r = NormalizedTick::price_range_per_tick(&ticks).unwrap();
8837        assert!(r.abs() < 1e-9, "constant price → range_per_tick=0, got {}", r);
8838    }
8839
8840    #[test]
8841    fn test_qty_weighted_price_std_none_for_empty() {
8842        assert!(NormalizedTick::qty_weighted_price_std(&[]).is_none());
8843    }
8844
8845    #[test]
8846    fn test_qty_weighted_price_std_zero_for_constant_price() {
8847        use rust_decimal_macros::dec;
8848        let ticks = vec![
8849            make_tick_pq(dec!(100), dec!(2)),
8850            make_tick_pq(dec!(100), dec!(3)),
8851        ];
8852        let s = NormalizedTick::qty_weighted_price_std(&ticks).unwrap();
8853        assert!(s.abs() < 1e-9, "constant price → std=0, got {}", s);
8854    }
8855}