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}
2197
2198
2199impl std::fmt::Display for NormalizedTick {
2200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2201        let side = match self.side {
2202            Some(s) => s.to_string(),
2203            None => "?".to_string(),
2204        };
2205        write!(
2206            f,
2207            "{} {} {} x {} {} @{}ms",
2208            self.exchange, self.symbol, self.price, self.quantity, side, self.received_at_ms
2209        )
2210    }
2211}
2212
2213/// Normalizes raw ticks from any supported exchange into [`NormalizedTick`] form.
2214///
2215/// `TickNormalizer` is stateless and cheap to clone; a single instance can be
2216/// shared across threads via `Arc` or constructed per-task.
2217pub struct TickNormalizer;
2218
2219impl TickNormalizer {
2220    /// Create a new normalizer. This is a zero-cost constructor.
2221    pub fn new() -> Self {
2222        Self
2223    }
2224
2225    /// Normalize a raw tick into canonical form.
2226    ///
2227    /// # Errors
2228    ///
2229    /// Returns [`StreamError::ParseError`] if required fields are missing or
2230    /// malformed, and [`StreamError::InvalidTick`] if price is not positive or
2231    /// quantity is negative.
2232    pub fn normalize(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
2233        let tick = match raw.exchange {
2234            Exchange::Binance => self.normalize_binance(raw),
2235            Exchange::Coinbase => self.normalize_coinbase(raw),
2236            Exchange::Alpaca => self.normalize_alpaca(raw),
2237            Exchange::Polygon => self.normalize_polygon(raw),
2238        }?;
2239        if tick.price <= Decimal::ZERO {
2240            return Err(StreamError::InvalidTick {
2241                reason: format!("price must be positive, got {}", tick.price),
2242            });
2243        }
2244        if tick.quantity < Decimal::ZERO {
2245            return Err(StreamError::InvalidTick {
2246                reason: format!("quantity must be non-negative, got {}", tick.quantity),
2247            });
2248        }
2249        trace!(
2250            exchange = %tick.exchange,
2251            symbol = %tick.symbol,
2252            price = %tick.price,
2253            exchange_ts_ms = ?tick.exchange_ts_ms,
2254            "tick normalized"
2255        );
2256        Ok(tick)
2257    }
2258
2259    fn normalize_binance(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
2260        let p = &raw.payload;
2261        let price = parse_decimal_field(p, "p", &raw.exchange.to_string())?;
2262        let qty = parse_decimal_field(p, "q", &raw.exchange.to_string())?;
2263        let side = p.get("m").and_then(|v| v.as_bool()).map(|maker| {
2264            if maker {
2265                TradeSide::Sell
2266            } else {
2267                TradeSide::Buy
2268            }
2269        });
2270        let trade_id = p.get("t").and_then(|v| v.as_u64()).map(|id| id.to_string());
2271        let exchange_ts = p.get("T").and_then(|v| v.as_u64());
2272        Ok(NormalizedTick {
2273            exchange: raw.exchange,
2274            symbol: raw.symbol,
2275            price,
2276            quantity: qty,
2277            side,
2278            trade_id,
2279            exchange_ts_ms: exchange_ts,
2280            received_at_ms: raw.received_at_ms,
2281        })
2282    }
2283
2284    fn normalize_coinbase(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
2285        let p = &raw.payload;
2286        let price = parse_decimal_field(p, "price", &raw.exchange.to_string())?;
2287        let qty = parse_decimal_field(p, "size", &raw.exchange.to_string())?;
2288        let side = p.get("side").and_then(|v| v.as_str()).map(|s| {
2289            if s == "buy" {
2290                TradeSide::Buy
2291            } else {
2292                TradeSide::Sell
2293            }
2294        });
2295        let trade_id = p
2296            .get("trade_id")
2297            .and_then(|v| v.as_str())
2298            .map(str::to_string);
2299        // Coinbase Advanced Trade sends an ISO 8601 timestamp in the "time" field.
2300        let exchange_ts_ms = p
2301            .get("time")
2302            .and_then(|v| v.as_str())
2303            .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
2304            .map(|dt| dt.timestamp_millis() as u64);
2305        Ok(NormalizedTick {
2306            exchange: raw.exchange,
2307            symbol: raw.symbol,
2308            price,
2309            quantity: qty,
2310            side,
2311            trade_id,
2312            exchange_ts_ms,
2313            received_at_ms: raw.received_at_ms,
2314        })
2315    }
2316
2317    fn normalize_alpaca(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
2318        let p = &raw.payload;
2319        let price = parse_decimal_field(p, "p", &raw.exchange.to_string())?;
2320        let qty = parse_decimal_field(p, "s", &raw.exchange.to_string())?;
2321        let trade_id = p.get("i").and_then(|v| v.as_u64()).map(|id| id.to_string());
2322        // Alpaca sends RFC 3339 timestamps in the "t" field (e.g. "2023-11-15T10:00:00.000Z").
2323        let exchange_ts_ms = p
2324            .get("t")
2325            .and_then(|v| v.as_str())
2326            .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
2327            .map(|dt| dt.timestamp_millis() as u64);
2328        Ok(NormalizedTick {
2329            exchange: raw.exchange,
2330            symbol: raw.symbol,
2331            price,
2332            quantity: qty,
2333            side: None,
2334            trade_id,
2335            exchange_ts_ms,
2336            received_at_ms: raw.received_at_ms,
2337        })
2338    }
2339
2340    fn normalize_polygon(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
2341        let p = &raw.payload;
2342        let price = parse_decimal_field(p, "p", &raw.exchange.to_string())?;
2343        let qty = parse_decimal_field(p, "s", &raw.exchange.to_string())?;
2344        let trade_id = p.get("i").and_then(|v| v.as_str()).map(str::to_string);
2345        // Polygon sends nanoseconds since epoch in the "t" field; convert to milliseconds.
2346        let exchange_ts = p
2347            .get("t")
2348            .and_then(|v| v.as_u64())
2349            .map(|t_ns| t_ns / 1_000_000);
2350        Ok(NormalizedTick {
2351            exchange: raw.exchange,
2352            symbol: raw.symbol,
2353            price,
2354            quantity: qty,
2355            side: None,
2356            trade_id,
2357            exchange_ts_ms: exchange_ts,
2358            received_at_ms: raw.received_at_ms,
2359        })
2360    }
2361}
2362
2363impl Default for TickNormalizer {
2364    fn default() -> Self {
2365        Self::new()
2366    }
2367}
2368
2369fn parse_decimal_field(
2370    v: &serde_json::Value,
2371    field: &str,
2372    exchange: &str,
2373) -> Result<Decimal, StreamError> {
2374    let raw = v.get(field).ok_or_else(|| StreamError::ParseError {
2375        exchange: exchange.to_string(),
2376        reason: format!("missing field '{}'", field),
2377    })?;
2378    // Use the JSON-native string representation for both string and number
2379    // values. For JSON strings this is a direct parse. For JSON numbers we use
2380    // serde_json::Number::to_string(), which preserves the original text (e.g.
2381    // "50000.12345678") rather than round-tripping through f64 and losing
2382    // sub-microsecond precision.
2383    let s: String = match raw {
2384        serde_json::Value::String(s) => s.clone(),
2385        serde_json::Value::Number(n) => n.to_string(),
2386        _ => {
2387            return Err(StreamError::ParseError {
2388                exchange: exchange.to_string(),
2389                reason: format!("field '{}' is not a string or number", field),
2390            });
2391        }
2392    };
2393    Decimal::from_str(&s).map_err(|e| StreamError::ParseError {
2394        exchange: exchange.to_string(),
2395        reason: format!("field '{}' parse error: {}", field, e),
2396    })
2397}
2398
2399fn now_ms() -> u64 {
2400    std::time::SystemTime::now()
2401        .duration_since(std::time::UNIX_EPOCH)
2402        .map(|d| d.as_millis() as u64)
2403        .unwrap_or(0)
2404}
2405
2406#[cfg(test)]
2407mod tests {
2408    use super::*;
2409    use serde_json::json;
2410
2411    fn normalizer() -> TickNormalizer {
2412        TickNormalizer::new()
2413    }
2414
2415    fn binance_tick(symbol: &str) -> RawTick {
2416        RawTick {
2417            exchange: Exchange::Binance,
2418            symbol: symbol.to_string(),
2419            payload: json!({ "p": "50000.12", "q": "0.001", "m": false, "t": 12345, "T": 1700000000000u64 }),
2420            received_at_ms: 1700000000001,
2421        }
2422    }
2423
2424    fn coinbase_tick(symbol: &str) -> RawTick {
2425        RawTick {
2426            exchange: Exchange::Coinbase,
2427            symbol: symbol.to_string(),
2428            payload: json!({ "price": "50001.00", "size": "0.5", "side": "buy", "trade_id": "abc123" }),
2429            received_at_ms: 1700000000002,
2430        }
2431    }
2432
2433    fn alpaca_tick(symbol: &str) -> RawTick {
2434        RawTick {
2435            exchange: Exchange::Alpaca,
2436            symbol: symbol.to_string(),
2437            payload: json!({ "p": "180.50", "s": "10", "i": 99 }),
2438            received_at_ms: 1700000000003,
2439        }
2440    }
2441
2442    fn polygon_tick(symbol: &str) -> RawTick {
2443        RawTick {
2444            exchange: Exchange::Polygon,
2445            symbol: symbol.to_string(),
2446            // Polygon sends nanoseconds; 1_700_000_000_000_000_000 ns = 1_700_000_000_000 ms
2447            payload: json!({ "p": "180.51", "s": "5", "i": "XYZ-001", "t": 1_700_000_000_000_000_000u64 }),
2448            received_at_ms: 1700000000005,
2449        }
2450    }
2451
2452    #[test]
2453    fn test_exchange_from_str_valid() {
2454        assert_eq!("binance".parse::<Exchange>().unwrap(), Exchange::Binance);
2455        assert_eq!("Coinbase".parse::<Exchange>().unwrap(), Exchange::Coinbase);
2456        assert_eq!("ALPACA".parse::<Exchange>().unwrap(), Exchange::Alpaca);
2457        assert_eq!("polygon".parse::<Exchange>().unwrap(), Exchange::Polygon);
2458    }
2459
2460    #[test]
2461    fn test_exchange_from_str_unknown_returns_error() {
2462        let result = "Kraken".parse::<Exchange>();
2463        assert!(matches!(result, Err(StreamError::UnknownExchange(_))));
2464    }
2465
2466    #[test]
2467    fn test_exchange_display() {
2468        assert_eq!(Exchange::Binance.to_string(), "Binance");
2469        assert_eq!(Exchange::Coinbase.to_string(), "Coinbase");
2470    }
2471
2472    #[test]
2473    fn test_normalize_binance_tick_price_and_qty() {
2474        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
2475        assert_eq!(tick.price, Decimal::from_str("50000.12").unwrap());
2476        assert_eq!(tick.quantity, Decimal::from_str("0.001").unwrap());
2477        assert_eq!(tick.exchange, Exchange::Binance);
2478        assert_eq!(tick.symbol, "BTCUSDT");
2479    }
2480
2481    #[test]
2482    fn test_normalize_binance_side_maker_false_is_buy() {
2483        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
2484        assert_eq!(tick.side, Some(TradeSide::Buy));
2485    }
2486
2487    #[test]
2488    fn test_normalize_binance_side_maker_true_is_sell() {
2489        let raw = RawTick {
2490            exchange: Exchange::Binance,
2491            symbol: "BTCUSDT".into(),
2492            payload: json!({ "p": "50000", "q": "1", "m": true }),
2493            received_at_ms: 0,
2494        };
2495        let tick = normalizer().normalize(raw).unwrap();
2496        assert_eq!(tick.side, Some(TradeSide::Sell));
2497    }
2498
2499    #[test]
2500    fn test_normalize_binance_trade_id_and_ts() {
2501        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
2502        assert_eq!(tick.trade_id, Some("12345".to_string()));
2503        assert_eq!(tick.exchange_ts_ms, Some(1700000000000));
2504    }
2505
2506    #[test]
2507    fn test_normalize_coinbase_tick() {
2508        let tick = normalizer().normalize(coinbase_tick("BTC-USD")).unwrap();
2509        assert_eq!(tick.price, Decimal::from_str("50001.00").unwrap());
2510        assert_eq!(tick.quantity, Decimal::from_str("0.5").unwrap());
2511        assert_eq!(tick.side, Some(TradeSide::Buy));
2512        assert_eq!(tick.trade_id, Some("abc123".to_string()));
2513    }
2514
2515    #[test]
2516    fn test_normalize_coinbase_sell_side() {
2517        let raw = RawTick {
2518            exchange: Exchange::Coinbase,
2519            symbol: "BTC-USD".into(),
2520            payload: json!({ "price": "50000", "size": "1", "side": "sell" }),
2521            received_at_ms: 0,
2522        };
2523        let tick = normalizer().normalize(raw).unwrap();
2524        assert_eq!(tick.side, Some(TradeSide::Sell));
2525    }
2526
2527    #[test]
2528    fn test_normalize_alpaca_tick() {
2529        let tick = normalizer().normalize(alpaca_tick("AAPL")).unwrap();
2530        assert_eq!(tick.price, Decimal::from_str("180.50").unwrap());
2531        assert_eq!(tick.quantity, Decimal::from_str("10").unwrap());
2532        assert_eq!(tick.trade_id, Some("99".to_string()));
2533        assert_eq!(tick.side, None);
2534    }
2535
2536    #[test]
2537    fn test_normalize_polygon_tick() {
2538        let tick = normalizer().normalize(polygon_tick("AAPL")).unwrap();
2539        assert_eq!(tick.price, Decimal::from_str("180.51").unwrap());
2540        // 1_700_000_000_000_000_000 ns / 1_000_000 = 1_700_000_000_000 ms
2541        assert_eq!(tick.exchange_ts_ms, Some(1_700_000_000_000u64));
2542        assert_eq!(tick.trade_id, Some("XYZ-001".to_string()));
2543    }
2544
2545    #[test]
2546    fn test_normalize_alpaca_rfc3339_timestamp() {
2547        let raw = RawTick {
2548            exchange: Exchange::Alpaca,
2549            symbol: "AAPL".into(),
2550            payload: json!({ "p": "180.50", "s": "10", "i": 99, "t": "2023-11-15T00:00:00Z" }),
2551            received_at_ms: 1700000000003,
2552        };
2553        let tick = normalizer().normalize(raw).unwrap();
2554        assert!(tick.exchange_ts_ms.is_some(), "Alpaca 't' field should be parsed");
2555        // 2023-11-15T00:00:00Z = 1700006400000 ms
2556        assert_eq!(tick.exchange_ts_ms, Some(1700006400000u64));
2557    }
2558
2559    #[test]
2560    fn test_normalize_alpaca_no_timestamp_field() {
2561        let tick = normalizer().normalize(alpaca_tick("AAPL")).unwrap();
2562        assert_eq!(tick.exchange_ts_ms, None, "missing 't' field means no exchange_ts_ms");
2563    }
2564
2565    #[test]
2566    fn test_normalize_missing_price_field_returns_parse_error() {
2567        let raw = RawTick {
2568            exchange: Exchange::Binance,
2569            symbol: "BTCUSDT".into(),
2570            payload: json!({ "q": "1" }),
2571            received_at_ms: 0,
2572        };
2573        let result = normalizer().normalize(raw);
2574        assert!(matches!(result, Err(StreamError::ParseError { .. })));
2575    }
2576
2577    #[test]
2578    fn test_normalize_invalid_decimal_returns_parse_error() {
2579        let raw = RawTick {
2580            exchange: Exchange::Coinbase,
2581            symbol: "BTC-USD".into(),
2582            payload: json!({ "price": "not-a-number", "size": "1" }),
2583            received_at_ms: 0,
2584        };
2585        let result = normalizer().normalize(raw);
2586        assert!(matches!(result, Err(StreamError::ParseError { .. })));
2587    }
2588
2589    #[test]
2590    fn test_raw_tick_new_sets_received_at() {
2591        let raw = RawTick::new(Exchange::Binance, "BTCUSDT", json!({}));
2592        assert!(raw.received_at_ms > 0);
2593    }
2594
2595    #[test]
2596    fn test_normalize_numeric_price_field() {
2597        let raw = RawTick {
2598            exchange: Exchange::Binance,
2599            symbol: "BTCUSDT".into(),
2600            payload: json!({ "p": 50000.0, "q": 1.0 }),
2601            received_at_ms: 0,
2602        };
2603        let tick = normalizer().normalize(raw).unwrap();
2604        assert!(tick.price > Decimal::ZERO);
2605    }
2606
2607    #[test]
2608    fn test_trade_side_from_str_buy() {
2609        assert_eq!("buy".parse::<TradeSide>().unwrap(), TradeSide::Buy);
2610        assert_eq!("Buy".parse::<TradeSide>().unwrap(), TradeSide::Buy);
2611        assert_eq!("BUY".parse::<TradeSide>().unwrap(), TradeSide::Buy);
2612    }
2613
2614    #[test]
2615    fn test_trade_side_from_str_sell() {
2616        assert_eq!("sell".parse::<TradeSide>().unwrap(), TradeSide::Sell);
2617        assert_eq!("Sell".parse::<TradeSide>().unwrap(), TradeSide::Sell);
2618        assert_eq!("SELL".parse::<TradeSide>().unwrap(), TradeSide::Sell);
2619    }
2620
2621    #[test]
2622    fn test_trade_side_from_str_invalid() {
2623        let err = "long".parse::<TradeSide>().unwrap_err();
2624        assert!(matches!(err, StreamError::ParseError { .. }));
2625    }
2626
2627    #[test]
2628    fn test_trade_side_display() {
2629        assert_eq!(TradeSide::Buy.to_string(), "buy");
2630        assert_eq!(TradeSide::Sell.to_string(), "sell");
2631    }
2632
2633    #[test]
2634    fn test_normalize_zero_price_returns_invalid_tick() {
2635        let raw = RawTick {
2636            exchange: Exchange::Binance,
2637            symbol: "BTCUSDT".into(),
2638            payload: json!({ "p": "0", "q": "1" }),
2639            received_at_ms: 0,
2640        };
2641        let err = normalizer().normalize(raw).unwrap_err();
2642        assert!(matches!(err, StreamError::InvalidTick { .. }));
2643    }
2644
2645    #[test]
2646    fn test_normalize_negative_price_returns_invalid_tick() {
2647        let raw = RawTick {
2648            exchange: Exchange::Binance,
2649            symbol: "BTCUSDT".into(),
2650            payload: json!({ "p": "-1", "q": "1" }),
2651            received_at_ms: 0,
2652        };
2653        let err = normalizer().normalize(raw).unwrap_err();
2654        assert!(matches!(err, StreamError::InvalidTick { .. }));
2655    }
2656
2657    #[test]
2658    fn test_normalize_negative_quantity_returns_invalid_tick() {
2659        let raw = RawTick {
2660            exchange: Exchange::Binance,
2661            symbol: "BTCUSDT".into(),
2662            payload: json!({ "p": "100", "q": "-1" }),
2663            received_at_ms: 0,
2664        };
2665        let err = normalizer().normalize(raw).unwrap_err();
2666        assert!(matches!(err, StreamError::InvalidTick { .. }));
2667    }
2668
2669    #[test]
2670    fn test_normalize_zero_quantity_is_valid() {
2671        // Zero quantity is allowed (e.g., remove from book), just not negative
2672        let raw = RawTick {
2673            exchange: Exchange::Binance,
2674            symbol: "BTCUSDT".into(),
2675            payload: json!({ "p": "100", "q": "0" }),
2676            received_at_ms: 0,
2677        };
2678        let tick = normalizer().normalize(raw).unwrap();
2679        assert_eq!(tick.quantity, Decimal::ZERO);
2680    }
2681
2682    #[test]
2683    fn test_trade_side_is_buy() {
2684        assert!(TradeSide::Buy.is_buy());
2685        assert!(!TradeSide::Buy.is_sell());
2686    }
2687
2688    #[test]
2689    fn test_trade_side_is_sell() {
2690        assert!(TradeSide::Sell.is_sell());
2691        assert!(!TradeSide::Sell.is_buy());
2692    }
2693
2694    #[test]
2695    fn test_normalized_tick_display() {
2696        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
2697        let s = tick.to_string();
2698        assert!(s.contains("Binance"));
2699        assert!(s.contains("BTCUSDT"));
2700        assert!(s.contains("50000"));
2701    }
2702
2703    #[test]
2704    fn test_normalized_tick_value_is_price_times_qty() {
2705        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
2706        // binance_tick sets price=50000, quantity=0.001
2707        let expected = tick.price * tick.quantity;
2708        assert_eq!(tick.volume_notional(), expected);
2709    }
2710
2711    #[test]
2712    fn test_normalized_tick_age_ms_positive() {
2713        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
2714        // received_at_ms is set to 1_000_000 in binance_tick helper? Let's check
2715        // Actually the helper uses now_ms() so we can't predict. Use a manual tick.
2716        let raw = RawTick {
2717            exchange: Exchange::Binance,
2718            symbol: "BTCUSDT".into(),
2719            payload: serde_json::json!({"p": "50000", "q": "0.001", "m": false}),
2720            received_at_ms: 1_000_000,
2721        };
2722        let tick = normalizer().normalize(raw).unwrap();
2723        assert_eq!(tick.age_ms(1_001_000), 1_000);
2724    }
2725
2726    #[test]
2727    fn test_normalized_tick_age_ms_zero_when_now_equals_received() {
2728        let raw = RawTick {
2729            exchange: Exchange::Binance,
2730            symbol: "BTCUSDT".into(),
2731            payload: serde_json::json!({"p": "50000", "q": "0.001", "m": false}),
2732            received_at_ms: 5_000,
2733        };
2734        let tick = normalizer().normalize(raw).unwrap();
2735        assert_eq!(tick.age_ms(5_000), 0);
2736        // saturating_sub: now < received → 0
2737        assert_eq!(tick.age_ms(4_000), 0);
2738    }
2739
2740    #[test]
2741    fn test_normalized_tick_value_zero_qty_is_zero() {
2742        use rust_decimal_macros::dec;
2743        let raw = RawTick {
2744            exchange: Exchange::Binance,
2745            symbol: "BTCUSDT".into(),
2746            payload: serde_json::json!({
2747                "p": "50000",
2748                "q": "0",
2749                "m": false,
2750            }),
2751            received_at_ms: 1000,
2752        };
2753        let tick = normalizer().normalize(raw).unwrap();
2754        assert_eq!(tick.value(), dec!(0));
2755    }
2756
2757    // ── NormalizedTick::is_stale ──────────────────────────────────────────────
2758
2759    fn make_tick_at(received_at_ms: u64) -> NormalizedTick {
2760        NormalizedTick {
2761            exchange: Exchange::Binance,
2762            symbol: "BTCUSDT".into(),
2763            price: rust_decimal_macros::dec!(100),
2764            quantity: rust_decimal_macros::dec!(1),
2765            side: None,
2766            trade_id: None,
2767            exchange_ts_ms: None,
2768            received_at_ms,
2769        }
2770    }
2771
2772    #[test]
2773    fn test_is_stale_true_when_age_exceeds_threshold() {
2774        let tick = make_tick_at(1_000);
2775        // now=6000, age=5000, threshold=4000 → stale
2776        assert!(tick.is_stale(6_000, 4_000));
2777    }
2778
2779    #[test]
2780    fn test_is_stale_false_when_age_equals_threshold() {
2781        let tick = make_tick_at(1_000);
2782        // now=5000, age=4000, threshold=4000 → NOT stale (> not >=)
2783        assert!(!tick.is_stale(5_000, 4_000));
2784    }
2785
2786    #[test]
2787    fn test_is_stale_false_for_fresh_tick() {
2788        let tick = make_tick_at(10_000);
2789        assert!(!tick.is_stale(10_500, 1_000));
2790    }
2791
2792    // ── NormalizedTick::is_buy / is_sell ──────────────────────────────────────
2793
2794    #[test]
2795    fn test_is_buy_true_for_buy_side() {
2796        let mut tick = make_tick_at(1_000);
2797        tick.side = Some(TradeSide::Buy);
2798        assert!(tick.is_buy());
2799        assert!(!tick.is_sell());
2800    }
2801
2802    #[test]
2803    fn test_is_sell_true_for_sell_side() {
2804        let mut tick = make_tick_at(1_000);
2805        tick.side = Some(TradeSide::Sell);
2806        assert!(tick.is_sell());
2807        assert!(!tick.is_buy());
2808    }
2809
2810    #[test]
2811    fn test_is_buy_false_for_unknown_side() {
2812        let mut tick = make_tick_at(1_000);
2813        tick.side = None;
2814        assert!(!tick.is_buy());
2815        assert!(!tick.is_sell());
2816    }
2817
2818    // ── NormalizedTick::with_exchange_ts ──────────────────────────────────────
2819
2820    #[test]
2821    fn test_with_exchange_ts_sets_field() {
2822        let tick = make_tick_at(5_000).with_exchange_ts(3_000);
2823        assert_eq!(tick.exchange_ts_ms, Some(3_000));
2824        assert_eq!(tick.received_at_ms, 5_000); // unchanged
2825    }
2826
2827    #[test]
2828    fn test_with_exchange_ts_overrides_existing() {
2829        let tick = make_tick_at(1_000).with_exchange_ts(999).with_exchange_ts(888);
2830        assert_eq!(tick.exchange_ts_ms, Some(888));
2831    }
2832
2833    // ── NormalizedTick::price_move_from / is_more_recent_than ─────────────────
2834
2835    #[test]
2836    fn test_price_move_from_positive() {
2837        let prev = make_tick_at(1_000);
2838        let mut curr = make_tick_at(2_000);
2839        curr.price = prev.price + rust_decimal_macros::dec!(5);
2840        assert_eq!(curr.price_move_from(&prev), rust_decimal_macros::dec!(5));
2841    }
2842
2843    #[test]
2844    fn test_price_move_from_negative() {
2845        let prev = make_tick_at(1_000);
2846        let mut curr = make_tick_at(2_000);
2847        curr.price = prev.price - rust_decimal_macros::dec!(3);
2848        assert_eq!(curr.price_move_from(&prev), rust_decimal_macros::dec!(-3));
2849    }
2850
2851    #[test]
2852    fn test_price_move_from_zero_when_same() {
2853        let tick = make_tick_at(1_000);
2854        assert_eq!(tick.price_move_from(&tick), rust_decimal_macros::dec!(0));
2855    }
2856
2857    #[test]
2858    fn test_is_more_recent_than_true() {
2859        let older = make_tick_at(1_000);
2860        let newer = make_tick_at(2_000);
2861        assert!(newer.is_more_recent_than(&older));
2862    }
2863
2864    #[test]
2865    fn test_is_more_recent_than_false_when_older() {
2866        let older = make_tick_at(1_000);
2867        let newer = make_tick_at(2_000);
2868        assert!(!older.is_more_recent_than(&newer));
2869    }
2870
2871    #[test]
2872    fn test_is_more_recent_than_false_when_equal() {
2873        let tick = make_tick_at(1_000);
2874        assert!(!tick.is_more_recent_than(&tick));
2875    }
2876
2877    // ── NormalizedTick::with_side ─────────────────────────────────────────────
2878
2879    #[test]
2880    fn test_with_side_sets_buy() {
2881        let tick = make_tick_at(1_000).with_side(TradeSide::Buy);
2882        assert_eq!(tick.side, Some(TradeSide::Buy));
2883    }
2884
2885    #[test]
2886    fn test_with_side_sets_sell() {
2887        let tick = make_tick_at(1_000).with_side(TradeSide::Sell);
2888        assert_eq!(tick.side, Some(TradeSide::Sell));
2889    }
2890
2891    #[test]
2892    fn test_with_side_overrides_existing() {
2893        let tick = make_tick_at(1_000).with_side(TradeSide::Buy).with_side(TradeSide::Sell);
2894        assert_eq!(tick.side, Some(TradeSide::Sell));
2895    }
2896
2897    // ── NormalizedTick::is_neutral ────────────────────────────────────────────
2898
2899    #[test]
2900    fn test_is_neutral_true_when_no_side() {
2901        let mut tick = make_tick_at(1_000);
2902        tick.side = None;
2903        assert!(tick.is_neutral());
2904    }
2905
2906    #[test]
2907    fn test_is_neutral_false_when_buy() {
2908        let tick = make_tick_at(1_000).with_side(TradeSide::Buy);
2909        assert!(!tick.is_neutral());
2910    }
2911
2912    #[test]
2913    fn test_is_neutral_false_when_sell() {
2914        let tick = make_tick_at(1_000).with_side(TradeSide::Sell);
2915        assert!(!tick.is_neutral());
2916    }
2917
2918    // ── NormalizedTick::is_large_trade ────────────────────────────────────────
2919
2920    #[test]
2921    fn test_is_large_trade_above_threshold() {
2922        let mut tick = make_tick_at(1_000);
2923        tick.quantity = rust_decimal_macros::dec!(100);
2924        assert!(tick.is_large_trade(rust_decimal_macros::dec!(50)));
2925    }
2926
2927    #[test]
2928    fn test_is_large_trade_at_threshold() {
2929        let mut tick = make_tick_at(1_000);
2930        tick.quantity = rust_decimal_macros::dec!(50);
2931        assert!(tick.is_large_trade(rust_decimal_macros::dec!(50)));
2932    }
2933
2934    #[test]
2935    fn test_is_large_trade_below_threshold() {
2936        let mut tick = make_tick_at(1_000);
2937        tick.quantity = rust_decimal_macros::dec!(10);
2938        assert!(!tick.is_large_trade(rust_decimal_macros::dec!(50)));
2939    }
2940
2941    #[test]
2942    fn test_volume_notional_is_price_times_quantity() {
2943        let mut tick = make_tick_at(1_000);
2944        tick.price = rust_decimal_macros::dec!(200);
2945        tick.quantity = rust_decimal_macros::dec!(3);
2946        assert_eq!(tick.volume_notional(), rust_decimal_macros::dec!(600));
2947    }
2948
2949    // ── NormalizedTick::is_above ──────────────────────────────────────────────
2950
2951    #[test]
2952    fn test_is_above_returns_true_when_price_higher() {
2953        let mut tick = make_tick_at(1_000);
2954        tick.price = rust_decimal_macros::dec!(200);
2955        assert!(tick.is_above(rust_decimal_macros::dec!(150)));
2956    }
2957
2958    #[test]
2959    fn test_is_above_returns_false_when_price_equal() {
2960        let mut tick = make_tick_at(1_000);
2961        tick.price = rust_decimal_macros::dec!(200);
2962        assert!(!tick.is_above(rust_decimal_macros::dec!(200)));
2963    }
2964
2965    #[test]
2966    fn test_is_above_returns_false_when_price_lower() {
2967        let mut tick = make_tick_at(1_000);
2968        tick.price = rust_decimal_macros::dec!(100);
2969        assert!(!tick.is_above(rust_decimal_macros::dec!(200)));
2970    }
2971
2972    // ── NormalizedTick::is_below ──────────────────────────────────────────────
2973
2974    #[test]
2975    fn test_is_below_returns_true_when_price_lower() {
2976        let mut tick = make_tick_at(1_000);
2977        tick.price = rust_decimal_macros::dec!(100);
2978        assert!(tick.is_below(rust_decimal_macros::dec!(150)));
2979    }
2980
2981    #[test]
2982    fn test_is_below_returns_false_when_price_equal() {
2983        let mut tick = make_tick_at(1_000);
2984        tick.price = rust_decimal_macros::dec!(100);
2985        assert!(!tick.is_below(rust_decimal_macros::dec!(100)));
2986    }
2987
2988    #[test]
2989    fn test_is_below_returns_false_when_price_higher() {
2990        let mut tick = make_tick_at(1_000);
2991        tick.price = rust_decimal_macros::dec!(200);
2992        assert!(!tick.is_below(rust_decimal_macros::dec!(100)));
2993    }
2994
2995    // --- has_exchange_ts ---
2996
2997    #[test]
2998    fn test_has_exchange_ts_false_when_none() {
2999        let tick = make_tick_at(1_000);
3000        assert!(!tick.has_exchange_ts());
3001    }
3002
3003    #[test]
3004    fn test_has_exchange_ts_true_when_some() {
3005        let tick = make_tick_at(1_000).with_exchange_ts(900);
3006        assert!(tick.has_exchange_ts());
3007    }
3008
3009    // ── NormalizedTick::is_at ─────────────────────────────────────────────────
3010
3011    #[test]
3012    fn test_is_at_returns_true_when_equal() {
3013        let mut tick = make_tick_at(1_000);
3014        tick.price = rust_decimal_macros::dec!(100);
3015        assert!(tick.is_at(rust_decimal_macros::dec!(100)));
3016    }
3017
3018    #[test]
3019    fn test_is_at_returns_false_when_higher() {
3020        let mut tick = make_tick_at(1_000);
3021        tick.price = rust_decimal_macros::dec!(101);
3022        assert!(!tick.is_at(rust_decimal_macros::dec!(100)));
3023    }
3024
3025    #[test]
3026    fn test_is_at_returns_false_when_lower() {
3027        let mut tick = make_tick_at(1_000);
3028        tick.price = rust_decimal_macros::dec!(99);
3029        assert!(!tick.is_at(rust_decimal_macros::dec!(100)));
3030    }
3031
3032    // ── NormalizedTick::is_buy ────────────────────────────────────────────────
3033
3034    #[test]
3035    fn test_is_buy_true_when_side_is_buy() {
3036        let mut tick = make_tick_at(1_000);
3037        tick.side = Some(TradeSide::Buy);
3038        assert!(tick.is_buy());
3039    }
3040
3041    #[test]
3042    fn test_is_buy_false_when_side_is_sell() {
3043        let mut tick = make_tick_at(1_000);
3044        tick.side = Some(TradeSide::Sell);
3045        assert!(!tick.is_buy());
3046    }
3047
3048    #[test]
3049    fn test_is_buy_false_when_side_is_none() {
3050        let mut tick = make_tick_at(1_000);
3051        tick.side = None;
3052        assert!(!tick.is_buy());
3053    }
3054
3055    // --- side_str / is_round_lot ---
3056
3057    #[test]
3058    fn test_side_str_buy() {
3059        let mut tick = make_tick_at(1_000);
3060        tick.side = Some(TradeSide::Buy);
3061        assert_eq!(tick.side_str(), "buy");
3062    }
3063
3064    #[test]
3065    fn test_side_str_sell() {
3066        let mut tick = make_tick_at(1_000);
3067        tick.side = Some(TradeSide::Sell);
3068        assert_eq!(tick.side_str(), "sell");
3069    }
3070
3071    #[test]
3072    fn test_side_str_unknown_when_none() {
3073        let mut tick = make_tick_at(1_000);
3074        tick.side = None;
3075        assert_eq!(tick.side_str(), "unknown");
3076    }
3077
3078    #[test]
3079    fn test_is_round_lot_true_for_integer_quantity() {
3080        let mut tick = make_tick_at(1_000);
3081        tick.quantity = rust_decimal_macros::dec!(100);
3082        assert!(tick.is_round_lot());
3083    }
3084
3085    #[test]
3086    fn test_is_round_lot_false_for_fractional_quantity() {
3087        let mut tick = make_tick_at(1_000);
3088        tick.quantity = rust_decimal_macros::dec!(0.5);
3089        assert!(!tick.is_round_lot());
3090    }
3091
3092    // --- is_same_symbol_as / price_distance_from ---
3093
3094    #[test]
3095    fn test_is_same_symbol_as_true_when_symbols_match() {
3096        let t1 = make_tick_at(1_000);
3097        let t2 = make_tick_at(2_000);
3098        assert!(t1.is_same_symbol_as(&t2));
3099    }
3100
3101    #[test]
3102    fn test_is_same_symbol_as_false_when_symbols_differ() {
3103        let t1 = make_tick_at(1_000);
3104        let mut t2 = make_tick_at(2_000);
3105        t2.symbol = "ETH-USD".to_string();
3106        assert!(!t1.is_same_symbol_as(&t2));
3107    }
3108
3109    #[test]
3110    fn test_price_distance_from_is_absolute() {
3111        let mut t1 = make_tick_at(1_000);
3112        let mut t2 = make_tick_at(2_000);
3113        t1.price = rust_decimal_macros::dec!(100);
3114        t2.price = rust_decimal_macros::dec!(110);
3115        assert_eq!(t1.price_distance_from(&t2), rust_decimal_macros::dec!(10));
3116        assert_eq!(t2.price_distance_from(&t1), rust_decimal_macros::dec!(10));
3117    }
3118
3119    #[test]
3120    fn test_price_distance_from_zero_when_equal() {
3121        let t1 = make_tick_at(1_000);
3122        let t2 = make_tick_at(2_000);
3123        assert!(t1.price_distance_from(&t2).is_zero());
3124    }
3125
3126    // ── NormalizedTick::is_sell ───────────────────────────────────────────────
3127
3128    #[test]
3129    fn test_is_sell_true_when_side_is_sell() {
3130        let mut tick = make_tick_at(1_000);
3131        tick.side = Some(TradeSide::Sell);
3132        assert!(tick.is_sell());
3133    }
3134
3135    #[test]
3136    fn test_is_sell_false_when_side_is_buy() {
3137        let mut tick = make_tick_at(1_000);
3138        tick.side = Some(TradeSide::Buy);
3139        assert!(!tick.is_sell());
3140    }
3141
3142    #[test]
3143    fn test_is_sell_false_when_side_is_none() {
3144        let mut tick = make_tick_at(1_000);
3145        tick.side = None;
3146        assert!(!tick.is_sell());
3147    }
3148
3149    // --- exchange_latency_ms / is_notional_large_trade ---
3150
3151    #[test]
3152    fn test_exchange_latency_ms_positive_for_normal_delivery() {
3153        let mut tick = make_tick_at(1_100);
3154        tick.exchange_ts_ms = Some(1_000);
3155        assert_eq!(tick.exchange_latency_ms(), Some(100));
3156    }
3157
3158    #[test]
3159    fn test_exchange_latency_ms_negative_for_clock_skew() {
3160        let mut tick = make_tick_at(1_000);
3161        tick.exchange_ts_ms = Some(1_100);
3162        assert_eq!(tick.exchange_latency_ms(), Some(-100));
3163    }
3164
3165    #[test]
3166    fn test_exchange_latency_ms_none_when_no_exchange_ts() {
3167        let mut tick = make_tick_at(1_000);
3168        tick.exchange_ts_ms = None;
3169        assert!(tick.exchange_latency_ms().is_none());
3170    }
3171
3172    #[test]
3173    fn test_is_notional_large_trade_true_when_above_threshold() {
3174        let mut tick = make_tick_at(1_000);
3175        tick.price = rust_decimal_macros::dec!(100);
3176        tick.quantity = rust_decimal_macros::dec!(10);
3177        // notional = 1000, threshold = 500 → true
3178        assert!(tick.is_notional_large_trade(rust_decimal_macros::dec!(500)));
3179    }
3180
3181    #[test]
3182    fn test_is_notional_large_trade_false_when_at_or_below_threshold() {
3183        let mut tick = make_tick_at(1_000);
3184        tick.price = rust_decimal_macros::dec!(100);
3185        tick.quantity = rust_decimal_macros::dec!(5);
3186        // notional = 500, threshold = 500 → false (strictly greater)
3187        assert!(!tick.is_notional_large_trade(rust_decimal_macros::dec!(500)));
3188    }
3189
3190    #[test]
3191    fn test_is_aggressive_true_when_buy() {
3192        let mut tick = make_tick_at(1_000);
3193        tick.side = Some(TradeSide::Buy);
3194        assert!(tick.is_aggressive());
3195    }
3196
3197    #[test]
3198    fn test_is_aggressive_true_when_sell() {
3199        let mut tick = make_tick_at(1_000);
3200        tick.side = Some(TradeSide::Sell);
3201        assert!(tick.is_aggressive());
3202    }
3203
3204    #[test]
3205    fn test_is_aggressive_false_when_neutral() {
3206        let tick = make_tick_at(1_000); // side = None
3207        assert!(!tick.is_aggressive());
3208    }
3209
3210    #[test]
3211    fn test_price_diff_from_positive_when_higher() {
3212        let mut t1 = make_tick_at(1_000);
3213        let mut t2 = make_tick_at(1_000);
3214        t1.price = rust_decimal_macros::dec!(105);
3215        t2.price = rust_decimal_macros::dec!(100);
3216        assert_eq!(t1.price_diff_from(&t2), rust_decimal_macros::dec!(5));
3217    }
3218
3219    #[test]
3220    fn test_price_diff_from_negative_when_lower() {
3221        let mut t1 = make_tick_at(1_000);
3222        let mut t2 = make_tick_at(1_000);
3223        t1.price = rust_decimal_macros::dec!(95);
3224        t2.price = rust_decimal_macros::dec!(100);
3225        assert_eq!(t1.price_diff_from(&t2), rust_decimal_macros::dec!(-5));
3226    }
3227
3228    #[test]
3229    fn test_is_micro_trade_true_when_below_threshold() {
3230        let mut tick = make_tick_at(1_000);
3231        tick.quantity = rust_decimal_macros::dec!(0.5);
3232        assert!(tick.is_micro_trade(rust_decimal_macros::dec!(1)));
3233    }
3234
3235    #[test]
3236    fn test_is_micro_trade_false_when_equal_threshold() {
3237        let mut tick = make_tick_at(1_000);
3238        tick.quantity = rust_decimal_macros::dec!(1);
3239        assert!(!tick.is_micro_trade(rust_decimal_macros::dec!(1)));
3240    }
3241
3242    #[test]
3243    fn test_is_micro_trade_false_when_above_threshold() {
3244        let mut tick = make_tick_at(1_000);
3245        tick.quantity = rust_decimal_macros::dec!(2);
3246        assert!(!tick.is_micro_trade(rust_decimal_macros::dec!(1)));
3247    }
3248
3249    // --- is_zero_price / is_fresh ---
3250
3251    #[test]
3252    fn test_is_zero_price_true_for_zero() {
3253        let mut tick = make_tick_at(1_000);
3254        tick.price = rust_decimal_macros::dec!(0);
3255        assert!(tick.is_zero_price());
3256    }
3257
3258    #[test]
3259    fn test_is_zero_price_false_for_nonzero() {
3260        let tick = make_tick_at(1_000); // price set by make_tick_at
3261        assert!(!tick.is_zero_price());
3262    }
3263
3264    #[test]
3265    fn test_is_fresh_true_when_within_age() {
3266        let tick = make_tick_at(1_000);
3267        // received_at = 1000, now = 2000, max_age = 1500 → 1000 <= 1500 → fresh
3268        assert!(tick.is_fresh(2_000, 1_500));
3269    }
3270
3271    #[test]
3272    fn test_is_fresh_false_when_too_old() {
3273        let tick = make_tick_at(1_000);
3274        // received_at = 1000, now = 5000, max_age = 2000 → 4000 > 2000 → not fresh
3275        assert!(!tick.is_fresh(5_000, 2_000));
3276    }
3277
3278    #[test]
3279    fn test_is_fresh_true_when_now_less_than_received() {
3280        // Clock skew: now < received_at → saturating_sub = 0 ≤ max_age
3281        let tick = make_tick_at(5_000);
3282        assert!(tick.is_fresh(3_000, 100));
3283    }
3284
3285    // --- NormalizedTick::age_ms ---
3286    #[test]
3287    fn test_age_ms_correct_elapsed() {
3288        let tick = make_tick_at(10_000);
3289        assert_eq!(tick.age_ms(10_500), 500);
3290    }
3291
3292    #[test]
3293    fn test_age_ms_zero_when_now_equals_received() {
3294        let tick = make_tick_at(10_000);
3295        assert_eq!(tick.age_ms(10_000), 0);
3296    }
3297
3298    #[test]
3299    fn test_age_ms_zero_when_now_before_received() {
3300        let tick = make_tick_at(10_000);
3301        assert_eq!(tick.age_ms(9_000), 0);
3302    }
3303
3304    // --- NormalizedTick::is_buying_pressure ---
3305    #[test]
3306    fn test_is_buying_pressure_true_above_midpoint() {
3307        use rust_decimal_macros::dec;
3308        let mut tick = make_tick_at(0);
3309        tick.price = dec!(100.50);
3310        assert!(tick.is_buying_pressure(dec!(100)));
3311    }
3312
3313    #[test]
3314    fn test_is_buying_pressure_false_below_midpoint() {
3315        use rust_decimal_macros::dec;
3316        let mut tick = make_tick_at(0);
3317        tick.price = dec!(99.50);
3318        assert!(!tick.is_buying_pressure(dec!(100)));
3319    }
3320
3321    #[test]
3322    fn test_is_buying_pressure_false_at_midpoint() {
3323        use rust_decimal_macros::dec;
3324        let mut tick = make_tick_at(0);
3325        tick.price = dec!(100);
3326        assert!(!tick.is_buying_pressure(dec!(100)));
3327    }
3328
3329    // --- NormalizedTick::rounded_price ---
3330    #[test]
3331    fn test_rounded_price_rounds_to_nearest_tick() {
3332        use rust_decimal_macros::dec;
3333        let mut tick = make_tick_at(0);
3334        tick.price = dec!(100.37);
3335        // tick_size = 0.25 → 100.25
3336        assert_eq!(tick.rounded_price(dec!(0.25)), dec!(100.25));
3337    }
3338
3339    #[test]
3340    fn test_rounded_price_unchanged_when_already_aligned() {
3341        use rust_decimal_macros::dec;
3342        let mut tick = make_tick_at(0);
3343        tick.price = dec!(100.50);
3344        assert_eq!(tick.rounded_price(dec!(0.25)), dec!(100.50));
3345    }
3346
3347    #[test]
3348    fn test_rounded_price_returns_original_for_zero_tick_size() {
3349        use rust_decimal_macros::dec;
3350        let mut tick = make_tick_at(0);
3351        tick.price = dec!(99.99);
3352        assert_eq!(tick.rounded_price(dec!(0)), dec!(99.99));
3353    }
3354
3355    // --- NormalizedTick::is_large_spread_from ---
3356    #[test]
3357    fn test_is_large_spread_from_true_when_large() {
3358        use rust_decimal_macros::dec;
3359        let mut t1 = make_tick_at(0);
3360        let mut t2 = make_tick_at(0);
3361        t1.price = dec!(100);
3362        t2.price = dec!(110);
3363        assert!(t1.is_large_spread_from(&t2, dec!(5)));
3364    }
3365
3366    #[test]
3367    fn test_is_large_spread_from_false_when_small() {
3368        use rust_decimal_macros::dec;
3369        let mut t1 = make_tick_at(0);
3370        let mut t2 = make_tick_at(0);
3371        t1.price = dec!(100);
3372        t2.price = dec!(101);
3373        assert!(!t1.is_large_spread_from(&t2, dec!(5)));
3374    }
3375
3376    // ── NormalizedTick::age_secs ──────────────────────────────────────────────
3377
3378    #[test]
3379    fn test_age_secs_correct() {
3380        let tick = make_tick_at(1_000);
3381        assert!((tick.age_secs(3_000) - 2.0).abs() < 1e-9);
3382    }
3383
3384    #[test]
3385    fn test_age_secs_zero_when_now_equals_received() {
3386        let tick = make_tick_at(5_000);
3387        assert_eq!(tick.age_secs(5_000), 0.0);
3388    }
3389
3390    #[test]
3391    fn test_age_secs_zero_when_now_before_received() {
3392        let tick = make_tick_at(5_000);
3393        assert_eq!(tick.age_secs(1_000), 0.0);
3394    }
3395
3396    // ── NormalizedTick::is_same_exchange_as ───────────────────────────────────
3397
3398    #[test]
3399    fn test_is_same_exchange_as_true_when_matching() {
3400        let t1 = make_tick_at(1_000); // Binance
3401        let t2 = make_tick_at(2_000); // Binance
3402        assert!(t1.is_same_exchange_as(&t2));
3403    }
3404
3405    #[test]
3406    fn test_is_same_exchange_as_false_when_different() {
3407        let t1 = make_tick_at(1_000); // Binance
3408        let mut t2 = make_tick_at(2_000);
3409        t2.exchange = Exchange::Coinbase;
3410        assert!(!t1.is_same_exchange_as(&t2));
3411    }
3412
3413    // ── NormalizedTick::quote_age_ms / notional_value / is_high_value_tick ──
3414
3415    #[test]
3416    fn test_quote_age_ms_correct() {
3417        let tick = make_tick_at(1_000);
3418        assert_eq!(tick.quote_age_ms(3_000), 2_000);
3419    }
3420
3421    #[test]
3422    fn test_quote_age_ms_zero_when_now_before_received() {
3423        let tick = make_tick_at(5_000);
3424        assert_eq!(tick.quote_age_ms(1_000), 0);
3425    }
3426
3427    #[test]
3428    fn test_notional_value_correct() {
3429        use rust_decimal_macros::dec;
3430        let mut tick = make_tick_at(0);
3431        tick.price = dec!(100);
3432        tick.quantity = dec!(5);
3433        assert_eq!(tick.notional_value(), dec!(500));
3434    }
3435
3436    #[test]
3437    fn test_is_high_value_tick_true_when_above_threshold() {
3438        use rust_decimal_macros::dec;
3439        let mut tick = make_tick_at(0);
3440        tick.price = dec!(100);
3441        tick.quantity = dec!(10);
3442        // notional = 1000 > 500
3443        assert!(tick.is_high_value_tick(dec!(500)));
3444    }
3445
3446    #[test]
3447    fn test_is_high_value_tick_false_when_below_threshold() {
3448        use rust_decimal_macros::dec;
3449        let mut tick = make_tick_at(0);
3450        tick.price = dec!(10);
3451        tick.quantity = dec!(2);
3452        // notional = 20 < 100
3453        assert!(!tick.is_high_value_tick(dec!(100)));
3454    }
3455
3456    // ── NormalizedTick::is_buy_side / is_sell_side / price_in_range ─────────
3457
3458    #[test]
3459    fn test_is_buy_side_true_when_buy() {
3460        let mut tick = make_tick_at(0);
3461        tick.side = Some(TradeSide::Buy);
3462        assert!(tick.is_buy_side());
3463    }
3464
3465    #[test]
3466    fn test_is_buy_side_false_when_sell() {
3467        let mut tick = make_tick_at(0);
3468        tick.side = Some(TradeSide::Sell);
3469        assert!(!tick.is_buy_side());
3470    }
3471
3472    #[test]
3473    fn test_is_buy_side_false_when_none() {
3474        let mut tick = make_tick_at(0);
3475        tick.side = None;
3476        assert!(!tick.is_buy_side());
3477    }
3478
3479    #[test]
3480    fn test_is_sell_side_true_when_sell() {
3481        let mut tick = make_tick_at(0);
3482        tick.side = Some(TradeSide::Sell);
3483        assert!(tick.is_sell_side());
3484    }
3485
3486    #[test]
3487    fn test_price_in_range_true_when_within() {
3488        use rust_decimal_macros::dec;
3489        let mut tick = make_tick_at(0);
3490        tick.price = dec!(100);
3491        assert!(tick.price_in_range(dec!(90), dec!(110)));
3492    }
3493
3494    #[test]
3495    fn test_price_in_range_false_when_below() {
3496        use rust_decimal_macros::dec;
3497        let mut tick = make_tick_at(0);
3498        tick.price = dec!(80);
3499        assert!(!tick.price_in_range(dec!(90), dec!(110)));
3500    }
3501
3502    #[test]
3503    fn test_price_in_range_true_at_boundary() {
3504        use rust_decimal_macros::dec;
3505        let mut tick = make_tick_at(0);
3506        tick.price = dec!(90);
3507        assert!(tick.price_in_range(dec!(90), dec!(110)));
3508    }
3509
3510    // ── NormalizedTick::is_zero_quantity ──────────────────────────────────────
3511
3512    #[test]
3513    fn test_is_zero_quantity_true_when_zero() {
3514        let mut tick = make_tick_at(0);
3515        tick.quantity = Decimal::ZERO;
3516        assert!(tick.is_zero_quantity());
3517    }
3518
3519    #[test]
3520    fn test_is_zero_quantity_false_when_nonzero() {
3521        let mut tick = make_tick_at(0);
3522        tick.quantity = Decimal::ONE;
3523        assert!(!tick.is_zero_quantity());
3524    }
3525
3526    // ── NormalizedTick::is_large_tick ─────────────────────────────────────────
3527
3528    #[test]
3529    fn test_is_large_tick_true_when_above_threshold() {
3530        let mut tick = make_tick_at(0);
3531        tick.quantity = Decimal::from(10u32);
3532        assert!(tick.is_large_tick(Decimal::from(5u32)));
3533    }
3534
3535    #[test]
3536    fn test_is_large_tick_false_when_at_threshold() {
3537        let mut tick = make_tick_at(0);
3538        tick.quantity = Decimal::from(5u32);
3539        assert!(!tick.is_large_tick(Decimal::from(5u32)));
3540    }
3541
3542    #[test]
3543    fn test_is_large_tick_false_when_below_threshold() {
3544        let mut tick = make_tick_at(0);
3545        tick.quantity = Decimal::from(1u32);
3546        assert!(!tick.is_large_tick(Decimal::from(5u32)));
3547    }
3548
3549    // ── NormalizedTick::is_away_from_price ───────────────────────────────────
3550
3551    #[test]
3552    fn test_is_away_from_price_true_when_beyond_threshold() {
3553        let mut tick = make_tick_at(0);
3554        tick.price = Decimal::from(110u32);
3555        // |110 - 100| = 10 > 5
3556        assert!(tick.is_away_from_price(Decimal::from(100u32), Decimal::from(5u32)));
3557    }
3558
3559    #[test]
3560    fn test_is_away_from_price_false_when_at_threshold() {
3561        let mut tick = make_tick_at(0);
3562        tick.price = Decimal::from(105u32);
3563        // |105 - 100| = 5, not > 5
3564        assert!(!tick.is_away_from_price(Decimal::from(100u32), Decimal::from(5u32)));
3565    }
3566
3567    #[test]
3568    fn test_is_away_from_price_false_when_equal() {
3569        let mut tick = make_tick_at(0);
3570        tick.price = Decimal::from(100u32);
3571        assert!(!tick.is_away_from_price(Decimal::from(100u32), Decimal::from(1u32)));
3572    }
3573
3574    // ── NormalizedTick::is_within_spread ──────────────────────────────────────
3575
3576    #[test]
3577    fn test_is_within_spread_true_when_between() {
3578        let mut tick = make_tick_at(0);
3579        tick.price = Decimal::from(100u32);
3580        assert!(tick.is_within_spread(Decimal::from(99u32), Decimal::from(101u32)));
3581    }
3582
3583    #[test]
3584    fn test_is_within_spread_false_when_at_bid() {
3585        let mut tick = make_tick_at(0);
3586        tick.price = Decimal::from(99u32);
3587        assert!(!tick.is_within_spread(Decimal::from(99u32), Decimal::from(101u32)));
3588    }
3589
3590    #[test]
3591    fn test_is_within_spread_false_when_above_ask() {
3592        let mut tick = make_tick_at(0);
3593        tick.price = Decimal::from(102u32);
3594        assert!(!tick.is_within_spread(Decimal::from(99u32), Decimal::from(101u32)));
3595    }
3596
3597    // ── NormalizedTick::is_recent ─────────────────────────────────────────────
3598
3599    #[test]
3600    fn test_is_recent_true_when_within_threshold() {
3601        let tick = make_tick_at(9_500);
3602        // now=10000, threshold=1000 → age=500ms ≤ 1000ms
3603        assert!(tick.is_recent(1_000, 10_000));
3604    }
3605
3606    #[test]
3607    fn test_is_recent_false_when_beyond_threshold() {
3608        let tick = make_tick_at(8_000);
3609        // now=10000, threshold=1000 → age=2000ms > 1000ms
3610        assert!(!tick.is_recent(1_000, 10_000));
3611    }
3612
3613    #[test]
3614    fn test_is_recent_true_at_exact_threshold() {
3615        let tick = make_tick_at(9_000);
3616        // age=1000ms, threshold=1000ms → exactly at threshold
3617        assert!(tick.is_recent(1_000, 10_000));
3618    }
3619
3620    // ── NormalizedTick::side_as_str ───────────────────────────────────────────
3621
3622    #[test]
3623    fn test_side_as_str_buy() {
3624        let mut tick = make_tick_at(0);
3625        tick.side = Some(TradeSide::Buy);
3626        assert_eq!(tick.side_as_str(), Some("buy"));
3627    }
3628
3629    #[test]
3630    fn test_side_as_str_sell() {
3631        let mut tick = make_tick_at(0);
3632        tick.side = Some(TradeSide::Sell);
3633        assert_eq!(tick.side_as_str(), Some("sell"));
3634    }
3635
3636    #[test]
3637    fn test_side_as_str_none_when_unknown() {
3638        let mut tick = make_tick_at(0);
3639        tick.side = None;
3640        assert!(tick.side_as_str().is_none());
3641    }
3642
3643    // ── is_above_price ────────────────────────────────────────────────────────
3644
3645    #[test]
3646    fn test_is_above_price_true_when_strictly_above() {
3647        let tick = make_tick_at(0); // price=100
3648        assert!(tick.is_above_price(rust_decimal_macros::dec!(99)));
3649    }
3650
3651    #[test]
3652    fn test_is_above_price_false_when_equal() {
3653        let tick = make_tick_at(0); // price=100
3654        assert!(!tick.is_above_price(rust_decimal_macros::dec!(100)));
3655    }
3656
3657    #[test]
3658    fn test_is_above_price_false_when_below() {
3659        let tick = make_tick_at(0); // price=100
3660        assert!(!tick.is_above_price(rust_decimal_macros::dec!(101)));
3661    }
3662
3663    // ── price_change_from ─────────────────────────────────────────────────────
3664
3665    #[test]
3666    fn test_price_change_from_positive_when_above_reference() {
3667        let tick = make_tick_at(0); // price=100
3668        assert_eq!(tick.price_change_from(rust_decimal_macros::dec!(90)), rust_decimal_macros::dec!(10));
3669    }
3670
3671    #[test]
3672    fn test_price_change_from_negative_when_below_reference() {
3673        let tick = make_tick_at(0); // price=100
3674        assert_eq!(tick.price_change_from(rust_decimal_macros::dec!(110)), rust_decimal_macros::dec!(-10));
3675    }
3676
3677    #[test]
3678    fn test_price_change_from_zero_when_equal() {
3679        let tick = make_tick_at(0); // price=100
3680        assert_eq!(tick.price_change_from(rust_decimal_macros::dec!(100)), rust_decimal_macros::dec!(0));
3681    }
3682
3683    // ── is_below_price ────────────────────────────────────────────────────────
3684
3685    #[test]
3686    fn test_is_below_price_true_when_strictly_below() {
3687        let tick = make_tick_at(0); // price=100
3688        assert!(tick.is_below_price(rust_decimal_macros::dec!(101)));
3689    }
3690
3691    #[test]
3692    fn test_is_below_price_false_when_equal() {
3693        let tick = make_tick_at(0); // price=100
3694        assert!(!tick.is_below_price(rust_decimal_macros::dec!(100)));
3695    }
3696
3697    // ── quantity_above ────────────────────────────────────────────────────────
3698
3699    #[test]
3700    fn test_quantity_above_true_when_quantity_exceeds_threshold() {
3701        let tick = make_tick_at(0); // quantity=1
3702        assert!(tick.quantity_above(rust_decimal_macros::dec!(0)));
3703    }
3704
3705    #[test]
3706    fn test_quantity_above_false_when_quantity_equals_threshold() {
3707        let tick = make_tick_at(0); // quantity=1
3708        assert!(!tick.quantity_above(rust_decimal_macros::dec!(1)));
3709    }
3710
3711    // ── is_at_price ───────────────────────────────────────────────────────────
3712
3713    #[test]
3714    fn test_is_at_price_true_when_equal() {
3715        let tick = make_tick_at(0); // price=100
3716        assert!(tick.is_at_price(rust_decimal_macros::dec!(100)));
3717    }
3718
3719    #[test]
3720    fn test_is_at_price_false_when_different() {
3721        let tick = make_tick_at(0); // price=100
3722        assert!(!tick.is_at_price(rust_decimal_macros::dec!(101)));
3723    }
3724
3725    // ── is_round_number ───────────────────────────────────────────────────────
3726
3727    #[test]
3728    fn test_is_round_number_true_when_divisible() {
3729        let tick = make_tick_at(0); // price=100
3730        assert!(tick.is_round_number(rust_decimal_macros::dec!(10)));
3731        assert!(tick.is_round_number(rust_decimal_macros::dec!(100)));
3732    }
3733
3734    #[test]
3735    fn test_is_round_number_false_when_not_divisible() {
3736        let tick = make_tick_at(0); // price=100
3737        assert!(!tick.is_round_number(rust_decimal_macros::dec!(3)));
3738    }
3739
3740    #[test]
3741    fn test_is_round_number_false_when_step_zero() {
3742        let tick = make_tick_at(0);
3743        assert!(!tick.is_round_number(rust_decimal_macros::dec!(0)));
3744    }
3745
3746    // ── is_market_open_tick ───────────────────────────────────────────────────
3747
3748    #[test]
3749    fn test_is_market_open_tick_true_when_within_session() {
3750        let tick = make_tick_at(500); // received at ms=500
3751        assert!(tick.is_market_open_tick(100, 1_000));
3752    }
3753
3754    #[test]
3755    fn test_is_market_open_tick_false_when_before_session() {
3756        let tick = make_tick_at(50);
3757        assert!(!tick.is_market_open_tick(100, 1_000));
3758    }
3759
3760    #[test]
3761    fn test_is_market_open_tick_false_when_at_session_end() {
3762        let tick = make_tick_at(1_000);
3763        assert!(!tick.is_market_open_tick(100, 1_000)); // exclusive end
3764    }
3765
3766    // ── signed_quantity ───────────────────────────────────────────────────────
3767
3768    #[test]
3769    fn test_signed_quantity_positive_for_buy() {
3770        let mut tick = make_tick_at(0);
3771        tick.side = Some(TradeSide::Buy);
3772        assert!(tick.signed_quantity() > rust_decimal::Decimal::ZERO);
3773    }
3774
3775    #[test]
3776    fn test_signed_quantity_negative_for_sell() {
3777        let mut tick = make_tick_at(0);
3778        tick.side = Some(TradeSide::Sell);
3779        assert!(tick.signed_quantity() < rust_decimal::Decimal::ZERO);
3780    }
3781
3782    #[test]
3783    fn test_signed_quantity_zero_for_unknown() {
3784        let tick = make_tick_at(0); // side=None
3785        assert_eq!(tick.signed_quantity(), rust_decimal::Decimal::ZERO);
3786    }
3787
3788    // ── as_price_level ────────────────────────────────────────────────────────
3789
3790    #[test]
3791    fn test_as_price_level_returns_price_and_quantity() {
3792        let tick = make_tick_at(0); // price=100, qty=1
3793        let (p, q) = tick.as_price_level();
3794        assert_eq!(p, rust_decimal_macros::dec!(100));
3795        assert_eq!(q, rust_decimal_macros::dec!(1));
3796    }
3797
3798    // ── buy_volume / sell_volume ───────────────────────────────────────────────
3799
3800    fn make_sided_tick(qty: rust_decimal::Decimal, side: Option<TradeSide>) -> NormalizedTick {
3801        NormalizedTick {
3802            exchange: Exchange::Binance,
3803            symbol: "BTCUSDT".into(),
3804            price: rust_decimal_macros::dec!(100),
3805            quantity: qty,
3806            side,
3807            trade_id: None,
3808            exchange_ts_ms: None,
3809            received_at_ms: 0,
3810        }
3811    }
3812
3813    #[test]
3814    fn test_buy_volume_zero_for_empty_slice() {
3815        assert_eq!(NormalizedTick::buy_volume(&[]), rust_decimal::Decimal::ZERO);
3816    }
3817
3818    #[test]
3819    fn test_buy_volume_sums_only_buy_ticks() {
3820        let buy1 = make_sided_tick(rust_decimal_macros::dec!(2), Some(TradeSide::Buy));
3821        let sell = make_sided_tick(rust_decimal_macros::dec!(3), Some(TradeSide::Sell));
3822        let buy2 = make_sided_tick(rust_decimal_macros::dec!(5), Some(TradeSide::Buy));
3823        let unknown = make_sided_tick(rust_decimal_macros::dec!(10), None);
3824        assert_eq!(
3825            NormalizedTick::buy_volume(&[buy1, sell, buy2, unknown]),
3826            rust_decimal_macros::dec!(7)
3827        );
3828    }
3829
3830    #[test]
3831    fn test_sell_volume_zero_for_empty_slice() {
3832        assert_eq!(NormalizedTick::sell_volume(&[]), rust_decimal::Decimal::ZERO);
3833    }
3834
3835    #[test]
3836    fn test_sell_volume_sums_only_sell_ticks() {
3837        let buy = make_sided_tick(rust_decimal_macros::dec!(2), Some(TradeSide::Buy));
3838        let sell1 = make_sided_tick(rust_decimal_macros::dec!(3), Some(TradeSide::Sell));
3839        let sell2 = make_sided_tick(rust_decimal_macros::dec!(4), Some(TradeSide::Sell));
3840        assert_eq!(
3841            NormalizedTick::sell_volume(&[buy, sell1, sell2]),
3842            rust_decimal_macros::dec!(7)
3843        );
3844    }
3845
3846    #[test]
3847    fn test_buy_sell_volumes_dont_include_unknown_side() {
3848        let buy = make_sided_tick(rust_decimal_macros::dec!(5), Some(TradeSide::Buy));
3849        let sell = make_sided_tick(rust_decimal_macros::dec!(3), Some(TradeSide::Sell));
3850        let unknown = make_sided_tick(rust_decimal_macros::dec!(2), None);
3851        let ticks = [buy, sell, unknown];
3852        let total: rust_decimal::Decimal = ticks.iter().map(|t| t.quantity).sum();
3853        let accounted = NormalizedTick::buy_volume(&ticks) + NormalizedTick::sell_volume(&ticks);
3854        // 5 + 3 = 8, total = 10 (unknown 2 not counted)
3855        assert_eq!(accounted, rust_decimal_macros::dec!(8));
3856        assert!(accounted < total);
3857    }
3858
3859    // ── price_range / average_price ───────────────────────────────────────────
3860
3861    fn make_tick_with_price(price: rust_decimal::Decimal) -> NormalizedTick {
3862        NormalizedTick {
3863            exchange: Exchange::Binance,
3864            symbol: "BTCUSDT".into(),
3865            price,
3866            quantity: rust_decimal_macros::dec!(1),
3867            side: None,
3868            trade_id: None,
3869            exchange_ts_ms: None,
3870            received_at_ms: 0,
3871        }
3872    }
3873
3874    #[test]
3875    fn test_price_range_none_for_empty_slice() {
3876        assert!(NormalizedTick::price_range(&[]).is_none());
3877    }
3878
3879    #[test]
3880    fn test_price_range_zero_for_single_tick() {
3881        let tick = make_tick_with_price(rust_decimal_macros::dec!(100));
3882        assert_eq!(NormalizedTick::price_range(&[tick]), Some(rust_decimal_macros::dec!(0)));
3883    }
3884
3885    #[test]
3886    fn test_price_range_correct_for_multiple_ticks() {
3887        let t1 = make_tick_with_price(rust_decimal_macros::dec!(95));
3888        let t2 = make_tick_with_price(rust_decimal_macros::dec!(105));
3889        let t3 = make_tick_with_price(rust_decimal_macros::dec!(100));
3890        assert_eq!(NormalizedTick::price_range(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(10)));
3891    }
3892
3893    #[test]
3894    fn test_average_price_none_for_empty_slice() {
3895        assert!(NormalizedTick::average_price(&[]).is_none());
3896    }
3897
3898    #[test]
3899    fn test_average_price_equals_price_for_single_tick() {
3900        let tick = make_tick_with_price(rust_decimal_macros::dec!(200));
3901        assert_eq!(NormalizedTick::average_price(&[tick]), Some(rust_decimal_macros::dec!(200)));
3902    }
3903
3904    #[test]
3905    fn test_average_price_correct_for_multiple_ticks() {
3906        let t1 = make_tick_with_price(rust_decimal_macros::dec!(90));
3907        let t2 = make_tick_with_price(rust_decimal_macros::dec!(100));
3908        let t3 = make_tick_with_price(rust_decimal_macros::dec!(110));
3909        // (90 + 100 + 110) / 3 = 100
3910        assert_eq!(NormalizedTick::average_price(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(100)));
3911    }
3912
3913    // ── vwap ─────────────────────────────────────────────────────────────────
3914
3915    fn make_tick_pq(price: rust_decimal::Decimal, qty: rust_decimal::Decimal) -> NormalizedTick {
3916        NormalizedTick {
3917            exchange: Exchange::Binance,
3918            symbol: "BTCUSDT".into(),
3919            price,
3920            quantity: qty,
3921            side: None,
3922            trade_id: None,
3923            exchange_ts_ms: None,
3924            received_at_ms: 0,
3925        }
3926    }
3927
3928    #[test]
3929    fn test_vwap_none_for_empty_slice() {
3930        assert!(NormalizedTick::vwap(&[]).is_none());
3931    }
3932
3933    #[test]
3934    fn test_vwap_equals_price_for_single_tick() {
3935        let tick = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5));
3936        assert_eq!(NormalizedTick::vwap(&[tick]), Some(rust_decimal_macros::dec!(100)));
3937    }
3938
3939    #[test]
3940    fn test_vwap_weighted_correctly() {
3941        // 100 × 1 + 200 × 3 = 700; total qty = 4; VWAP = 175
3942        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
3943        let t2 = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(3));
3944        assert_eq!(NormalizedTick::vwap(&[t1, t2]), Some(rust_decimal_macros::dec!(175)));
3945    }
3946
3947    #[test]
3948    fn test_vwap_none_for_zero_total_volume() {
3949        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(0));
3950        let t2 = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(0));
3951        assert!(NormalizedTick::vwap(&[t1, t2]).is_none());
3952    }
3953
3954    // ── count_above_price / count_below_price ─────────────────────────────────
3955
3956    #[test]
3957    fn test_count_above_price_zero_for_empty_slice() {
3958        assert_eq!(NormalizedTick::count_above_price(&[], rust_decimal_macros::dec!(100)), 0);
3959    }
3960
3961    #[test]
3962    fn test_count_above_price_correct() {
3963        let t1 = make_tick_with_price(rust_decimal_macros::dec!(90));
3964        let t2 = make_tick_with_price(rust_decimal_macros::dec!(100));
3965        let t3 = make_tick_with_price(rust_decimal_macros::dec!(110));
3966        assert_eq!(NormalizedTick::count_above_price(&[t1, t2, t3], rust_decimal_macros::dec!(100)), 1);
3967    }
3968
3969    #[test]
3970    fn test_count_below_price_correct() {
3971        let t1 = make_tick_with_price(rust_decimal_macros::dec!(90));
3972        let t2 = make_tick_with_price(rust_decimal_macros::dec!(100));
3973        let t3 = make_tick_with_price(rust_decimal_macros::dec!(110));
3974        assert_eq!(NormalizedTick::count_below_price(&[t1, t2, t3], rust_decimal_macros::dec!(100)), 1);
3975    }
3976
3977    #[test]
3978    fn test_count_above_at_threshold_excluded() {
3979        let tick = make_tick_with_price(rust_decimal_macros::dec!(100));
3980        assert_eq!(NormalizedTick::count_above_price(&[tick], rust_decimal_macros::dec!(100)), 0);
3981    }
3982
3983    #[test]
3984    fn test_count_below_at_threshold_excluded() {
3985        let tick = make_tick_with_price(rust_decimal_macros::dec!(100));
3986        assert_eq!(NormalizedTick::count_below_price(&[tick], rust_decimal_macros::dec!(100)), 0);
3987    }
3988
3989    // ── total_notional / buy_notional / sell_notional ─────────────────────────
3990
3991    #[test]
3992    fn test_total_notional_zero_for_empty_slice() {
3993        assert_eq!(NormalizedTick::total_notional(&[]), rust_decimal::Decimal::ZERO);
3994    }
3995
3996    #[test]
3997    fn test_total_notional_sums_all_ticks() {
3998        // 100 × 2 + 200 × 3 = 200 + 600 = 800
3999        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
4000        let t2 = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(3));
4001        assert_eq!(NormalizedTick::total_notional(&[t1, t2]), rust_decimal_macros::dec!(800));
4002    }
4003
4004    #[test]
4005    fn test_buy_notional_only_includes_buy_side() {
4006        let buy = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
4007        let sell = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(3));
4008        let buy_with_side = NormalizedTick { side: Some(TradeSide::Buy), ..buy };
4009        let sell_with_side = NormalizedTick { side: Some(TradeSide::Sell), ..sell };
4010        // buy notional = 100 × 2 = 200
4011        assert_eq!(NormalizedTick::buy_notional(&[buy_with_side, sell_with_side]), rust_decimal_macros::dec!(200));
4012    }
4013
4014    #[test]
4015    fn test_sell_notional_only_includes_sell_side() {
4016        let buy = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
4017        let sell = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(3));
4018        let buy_with_side = NormalizedTick { side: Some(TradeSide::Buy), ..buy };
4019        let sell_with_side = NormalizedTick { side: Some(TradeSide::Sell), ..sell };
4020        // sell notional = 200 × 3 = 600
4021        assert_eq!(NormalizedTick::sell_notional(&[buy_with_side, sell_with_side]), rust_decimal_macros::dec!(600));
4022    }
4023
4024    // ── median_price ──────────────────────────────────────────────────────────
4025
4026    #[test]
4027    fn test_median_price_none_for_empty_slice() {
4028        assert!(NormalizedTick::median_price(&[]).is_none());
4029    }
4030
4031    #[test]
4032    fn test_median_price_single_tick() {
4033        let tick = make_tick_with_price(rust_decimal_macros::dec!(150));
4034        assert_eq!(NormalizedTick::median_price(&[tick]), Some(rust_decimal_macros::dec!(150)));
4035    }
4036
4037    #[test]
4038    fn test_median_price_odd_count() {
4039        let t1 = make_tick_with_price(rust_decimal_macros::dec!(90));
4040        let t2 = make_tick_with_price(rust_decimal_macros::dec!(100));
4041        let t3 = make_tick_with_price(rust_decimal_macros::dec!(110));
4042        assert_eq!(NormalizedTick::median_price(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(100)));
4043    }
4044
4045    #[test]
4046    fn test_median_price_even_count() {
4047        let t1 = make_tick_with_price(rust_decimal_macros::dec!(90));
4048        let t2 = make_tick_with_price(rust_decimal_macros::dec!(100));
4049        // median = (90+100)/2 = 95
4050        assert_eq!(NormalizedTick::median_price(&[t1, t2]), Some(rust_decimal_macros::dec!(95)));
4051    }
4052
4053    // ── net_volume ────────────────────────────────────────────────────────────
4054
4055    #[test]
4056    fn test_net_volume_zero_for_empty_slice() {
4057        assert_eq!(NormalizedTick::net_volume(&[]), rust_decimal::Decimal::ZERO);
4058    }
4059
4060    #[test]
4061    fn test_net_volume_positive_when_more_buys() {
4062        let buy = NormalizedTick {
4063            side: Some(TradeSide::Buy),
4064            quantity: rust_decimal_macros::dec!(5),
4065            ..make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5))
4066        };
4067        let sell = NormalizedTick {
4068            side: Some(TradeSide::Sell),
4069            quantity: rust_decimal_macros::dec!(3),
4070            ..make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(3))
4071        };
4072        assert_eq!(NormalizedTick::net_volume(&[buy, sell]), rust_decimal_macros::dec!(2));
4073    }
4074
4075    #[test]
4076    fn test_net_volume_negative_when_more_sells() {
4077        let buy = NormalizedTick {
4078            side: Some(TradeSide::Buy),
4079            quantity: rust_decimal_macros::dec!(2),
4080            ..make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2))
4081        };
4082        let sell = NormalizedTick {
4083            side: Some(TradeSide::Sell),
4084            quantity: rust_decimal_macros::dec!(7),
4085            ..make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(7))
4086        };
4087        assert_eq!(NormalizedTick::net_volume(&[buy, sell]), rust_decimal_macros::dec!(-5));
4088    }
4089
4090    // ── average_quantity / max_quantity ───────────────────────────────────────
4091
4092    #[test]
4093    fn test_average_quantity_none_for_empty_slice() {
4094        assert!(NormalizedTick::average_quantity(&[]).is_none());
4095    }
4096
4097    #[test]
4098    fn test_average_quantity_single_tick() {
4099        let tick = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5));
4100        assert_eq!(NormalizedTick::average_quantity(&[tick]), Some(rust_decimal_macros::dec!(5)));
4101    }
4102
4103    #[test]
4104    fn test_average_quantity_multiple_ticks() {
4105        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
4106        let t2 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(4));
4107        // (2 + 4) / 2 = 3
4108        assert_eq!(NormalizedTick::average_quantity(&[t1, t2]), Some(rust_decimal_macros::dec!(3)));
4109    }
4110
4111    #[test]
4112    fn test_max_quantity_none_for_empty_slice() {
4113        assert!(NormalizedTick::max_quantity(&[]).is_none());
4114    }
4115
4116    #[test]
4117    fn test_max_quantity_returns_largest() {
4118        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
4119        let t2 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(10));
4120        let t3 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5));
4121        assert_eq!(NormalizedTick::max_quantity(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(10)));
4122    }
4123
4124    #[test]
4125    fn test_min_quantity_none_for_empty_slice() {
4126        assert!(NormalizedTick::min_quantity(&[]).is_none());
4127    }
4128
4129    #[test]
4130    fn test_min_quantity_returns_smallest() {
4131        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5));
4132        let t2 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
4133        let t3 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(3));
4134        assert_eq!(NormalizedTick::min_quantity(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(1)));
4135    }
4136
4137    #[test]
4138    fn test_buy_count_zero_for_empty_slice() {
4139        assert_eq!(NormalizedTick::buy_count(&[]), 0);
4140    }
4141
4142    #[test]
4143    fn test_buy_count_counts_only_buys() {
4144        use rust_decimal_macros::dec;
4145        let mut buy = make_tick_pq(dec!(100), dec!(1));
4146        buy.side = Some(TradeSide::Buy);
4147        let mut sell = make_tick_pq(dec!(100), dec!(1));
4148        sell.side = Some(TradeSide::Sell);
4149        let neutral = make_tick_pq(dec!(100), dec!(1));
4150        assert_eq!(NormalizedTick::buy_count(&[buy, sell, neutral]), 1);
4151    }
4152
4153    #[test]
4154    fn test_sell_count_zero_for_empty_slice() {
4155        assert_eq!(NormalizedTick::sell_count(&[]), 0);
4156    }
4157
4158    #[test]
4159    fn test_sell_count_counts_only_sells() {
4160        use rust_decimal_macros::dec;
4161        let mut buy = make_tick_pq(dec!(100), dec!(1));
4162        buy.side = Some(TradeSide::Buy);
4163        let mut sell1 = make_tick_pq(dec!(100), dec!(1));
4164        sell1.side = Some(TradeSide::Sell);
4165        let mut sell2 = make_tick_pq(dec!(100), dec!(1));
4166        sell2.side = Some(TradeSide::Sell);
4167        assert_eq!(NormalizedTick::sell_count(&[buy, sell1, sell2]), 2);
4168    }
4169
4170    #[test]
4171    fn test_price_momentum_none_for_empty_slice() {
4172        assert!(NormalizedTick::price_momentum(&[]).is_none());
4173    }
4174
4175    #[test]
4176    fn test_price_momentum_none_for_single_tick() {
4177        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
4178        assert!(NormalizedTick::price_momentum(&[t]).is_none());
4179    }
4180
4181    #[test]
4182    fn test_price_momentum_positive_when_price_rises() {
4183        use rust_decimal_macros::dec;
4184        let t1 = make_tick_pq(dec!(100), dec!(1));
4185        let t2 = make_tick_pq(dec!(110), dec!(1));
4186        let mom = NormalizedTick::price_momentum(&[t1, t2]).unwrap();
4187        assert!((mom - 0.1).abs() < 1e-9);
4188    }
4189
4190    #[test]
4191    fn test_price_momentum_negative_when_price_falls() {
4192        use rust_decimal_macros::dec;
4193        let t1 = make_tick_pq(dec!(100), dec!(1));
4194        let t2 = make_tick_pq(dec!(90), dec!(1));
4195        let mom = NormalizedTick::price_momentum(&[t1, t2]).unwrap();
4196        assert!(mom < 0.0);
4197    }
4198
4199    #[test]
4200    fn test_min_price_none_for_empty_slice() {
4201        assert!(NormalizedTick::min_price(&[]).is_none());
4202    }
4203
4204    #[test]
4205    fn test_min_price_returns_lowest() {
4206        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
4207        let t2 = make_tick_pq(rust_decimal_macros::dec!(90), rust_decimal_macros::dec!(1));
4208        let t3 = make_tick_pq(rust_decimal_macros::dec!(110), rust_decimal_macros::dec!(1));
4209        assert_eq!(NormalizedTick::min_price(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(90)));
4210    }
4211
4212    #[test]
4213    fn test_max_price_none_for_empty_slice() {
4214        assert!(NormalizedTick::max_price(&[]).is_none());
4215    }
4216
4217    #[test]
4218    fn test_max_price_returns_highest() {
4219        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
4220        let t2 = make_tick_pq(rust_decimal_macros::dec!(90), rust_decimal_macros::dec!(1));
4221        let t3 = make_tick_pq(rust_decimal_macros::dec!(110), rust_decimal_macros::dec!(1));
4222        assert_eq!(NormalizedTick::max_price(&[t1, t2, t3]), Some(rust_decimal_macros::dec!(110)));
4223    }
4224
4225    #[test]
4226    fn test_price_std_dev_none_for_empty_slice() {
4227        assert!(NormalizedTick::price_std_dev(&[]).is_none());
4228    }
4229
4230    #[test]
4231    fn test_price_std_dev_none_for_single_tick() {
4232        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
4233        assert!(NormalizedTick::price_std_dev(&[t]).is_none());
4234    }
4235
4236    #[test]
4237    fn test_price_std_dev_two_equal_prices_is_zero() {
4238        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
4239        let t2 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
4240        assert_eq!(NormalizedTick::price_std_dev(&[t1, t2]), Some(0.0));
4241    }
4242
4243    #[test]
4244    fn test_price_std_dev_positive_for_varying_prices() {
4245        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
4246        let t2 = make_tick_pq(rust_decimal_macros::dec!(110), rust_decimal_macros::dec!(1));
4247        let t3 = make_tick_pq(rust_decimal_macros::dec!(90), rust_decimal_macros::dec!(1));
4248        let std = NormalizedTick::price_std_dev(&[t1, t2, t3]).unwrap();
4249        assert!(std > 0.0);
4250    }
4251
4252    #[test]
4253    fn test_buy_sell_ratio_none_for_empty_slice() {
4254        assert!(NormalizedTick::buy_sell_ratio(&[]).is_none());
4255    }
4256
4257    #[test]
4258    fn test_buy_sell_ratio_none_when_no_sells() {
4259        let mut t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
4260        t.side = Some(TradeSide::Buy);
4261        assert!(NormalizedTick::buy_sell_ratio(&[t]).is_none());
4262    }
4263
4264    #[test]
4265    fn test_buy_sell_ratio_two_to_one() {
4266        use rust_decimal_macros::dec;
4267        let mut buy1 = make_tick_pq(dec!(100), dec!(2));
4268        buy1.side = Some(TradeSide::Buy);
4269        let mut buy2 = make_tick_pq(dec!(100), dec!(2));
4270        buy2.side = Some(TradeSide::Buy);
4271        let mut sell = make_tick_pq(dec!(100), dec!(2));
4272        sell.side = Some(TradeSide::Sell);
4273        let ratio = NormalizedTick::buy_sell_ratio(&[buy1, buy2, sell]).unwrap();
4274        assert!((ratio - 2.0).abs() < 1e-9);
4275    }
4276
4277    #[test]
4278    fn test_largest_trade_none_for_empty_slice() {
4279        assert!(NormalizedTick::largest_trade(&[]).is_none());
4280    }
4281
4282    #[test]
4283    fn test_largest_trade_returns_max_quantity_tick() {
4284        let t1 = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(2));
4285        let t2 = make_tick_pq(rust_decimal_macros::dec!(200), rust_decimal_macros::dec!(10));
4286        let t3 = make_tick_pq(rust_decimal_macros::dec!(150), rust_decimal_macros::dec!(5));
4287        let ticks = [t1, t2, t3];
4288        let largest = NormalizedTick::largest_trade(&ticks).unwrap();
4289        assert_eq!(largest.quantity, rust_decimal_macros::dec!(10));
4290    }
4291
4292    #[test]
4293    fn test_large_trade_count_zero_for_empty_slice() {
4294        assert_eq!(NormalizedTick::large_trade_count(&[], rust_decimal_macros::dec!(1)), 0);
4295    }
4296
4297    #[test]
4298    fn test_large_trade_count_counts_trades_above_threshold() {
4299        use rust_decimal_macros::dec;
4300        let t1 = make_tick_pq(dec!(100), dec!(0.5));
4301        let t2 = make_tick_pq(dec!(100), dec!(5));
4302        let t3 = make_tick_pq(dec!(100), dec!(10));
4303        assert_eq!(NormalizedTick::large_trade_count(&[t1, t2, t3], dec!(1)), 2);
4304    }
4305
4306    #[test]
4307    fn test_large_trade_count_strict_greater_than() {
4308        use rust_decimal_macros::dec;
4309        let t = make_tick_pq(dec!(100), dec!(1));
4310        // quantity == threshold → not counted (strict >)
4311        assert_eq!(NormalizedTick::large_trade_count(&[t], dec!(1)), 0);
4312    }
4313
4314    #[test]
4315    fn test_price_iqr_none_for_small_slice() {
4316        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
4317        assert!(NormalizedTick::price_iqr(&[t.clone(), t.clone(), t]).is_none());
4318    }
4319
4320    #[test]
4321    fn test_price_iqr_positive_for_varied_prices() {
4322        use rust_decimal_macros::dec;
4323        let ticks: Vec<_> = [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50), dec!(60), dec!(70), dec!(80)]
4324            .iter()
4325            .map(|&p| make_tick_pq(p, dec!(1)))
4326            .collect();
4327        let iqr = NormalizedTick::price_iqr(&ticks).unwrap();
4328        assert!(iqr > dec!(0));
4329    }
4330
4331    #[test]
4332    fn test_fraction_buy_none_for_empty_slice() {
4333        assert!(NormalizedTick::fraction_buy(&[]).is_none());
4334    }
4335
4336    #[test]
4337    fn test_fraction_buy_zero_when_no_buys() {
4338        use rust_decimal_macros::dec;
4339        let mut t = make_tick_pq(dec!(100), dec!(1));
4340        t.side = Some(TradeSide::Sell);
4341        assert_eq!(NormalizedTick::fraction_buy(&[t]), Some(0.0));
4342    }
4343
4344    #[test]
4345    fn test_fraction_buy_one_when_all_buys() {
4346        use rust_decimal_macros::dec;
4347        let mut t = make_tick_pq(dec!(100), dec!(1));
4348        t.side = Some(TradeSide::Buy);
4349        assert_eq!(NormalizedTick::fraction_buy(&[t]), Some(1.0));
4350    }
4351
4352    #[test]
4353    fn test_fraction_buy_half_for_equal_mix() {
4354        use rust_decimal_macros::dec;
4355        let mut buy = make_tick_pq(dec!(100), dec!(1));
4356        buy.side = Some(TradeSide::Buy);
4357        let mut sell = make_tick_pq(dec!(100), dec!(1));
4358        sell.side = Some(TradeSide::Sell);
4359        let frac = NormalizedTick::fraction_buy(&[buy, sell]).unwrap();
4360        assert!((frac - 0.5).abs() < 1e-9);
4361    }
4362
4363    #[test]
4364    fn test_std_quantity_none_for_empty_slice() {
4365        assert!(NormalizedTick::std_quantity(&[]).is_none());
4366    }
4367
4368    #[test]
4369    fn test_std_quantity_none_for_single_tick() {
4370        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(5));
4371        assert!(NormalizedTick::std_quantity(&[t]).is_none());
4372    }
4373
4374    #[test]
4375    fn test_std_quantity_zero_for_identical_quantities() {
4376        use rust_decimal_macros::dec;
4377        let t1 = make_tick_pq(dec!(100), dec!(5));
4378        let t2 = make_tick_pq(dec!(100), dec!(5));
4379        assert_eq!(NormalizedTick::std_quantity(&[t1, t2]), Some(0.0));
4380    }
4381
4382    #[test]
4383    fn test_std_quantity_positive_for_varied_quantities() {
4384        use rust_decimal_macros::dec;
4385        let t1 = make_tick_pq(dec!(100), dec!(1));
4386        let t2 = make_tick_pq(dec!(100), dec!(10));
4387        let std = NormalizedTick::std_quantity(&[t1, t2]).unwrap();
4388        assert!(std > 0.0);
4389    }
4390
4391    #[test]
4392    fn test_buy_pressure_none_for_empty_slice() {
4393        assert!(NormalizedTick::buy_pressure(&[]).is_none());
4394    }
4395
4396    #[test]
4397    fn test_buy_pressure_none_for_unsided_ticks() {
4398        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
4399        assert!(NormalizedTick::buy_pressure(&[t]).is_none());
4400    }
4401
4402    #[test]
4403    fn test_buy_pressure_one_for_all_buys() {
4404        use rust_decimal_macros::dec;
4405        let mut t = make_tick_pq(dec!(100), dec!(1));
4406        t.side = Some(TradeSide::Buy);
4407        let bp = NormalizedTick::buy_pressure(&[t]).unwrap();
4408        assert!((bp - 1.0).abs() < 1e-9);
4409    }
4410
4411    #[test]
4412    fn test_buy_pressure_half_for_equal_volume() {
4413        use rust_decimal_macros::dec;
4414        let mut buy = make_tick_pq(dec!(100), dec!(5));
4415        buy.side = Some(TradeSide::Buy);
4416        let mut sell = make_tick_pq(dec!(100), dec!(5));
4417        sell.side = Some(TradeSide::Sell);
4418        let bp = NormalizedTick::buy_pressure(&[buy, sell]).unwrap();
4419        assert!((bp - 0.5).abs() < 1e-9);
4420    }
4421
4422    #[test]
4423    fn test_average_notional_none_for_empty_slice() {
4424        assert!(NormalizedTick::average_notional(&[]).is_none());
4425    }
4426
4427    #[test]
4428    fn test_average_notional_single_tick() {
4429        use rust_decimal_macros::dec;
4430        let t = make_tick_pq(dec!(100), dec!(2));
4431        assert_eq!(NormalizedTick::average_notional(&[t]), Some(dec!(200)));
4432    }
4433
4434    #[test]
4435    fn test_average_notional_multiple_ticks() {
4436        use rust_decimal_macros::dec;
4437        let t1 = make_tick_pq(dec!(100), dec!(1)); // notional = 100
4438        let t2 = make_tick_pq(dec!(200), dec!(1)); // notional = 200
4439        // avg = (100 + 200) / 2 = 150
4440        assert_eq!(NormalizedTick::average_notional(&[t1, t2]), Some(dec!(150)));
4441    }
4442
4443    #[test]
4444    fn test_count_neutral_zero_for_empty_slice() {
4445        assert_eq!(NormalizedTick::count_neutral(&[]), 0);
4446    }
4447
4448    #[test]
4449    fn test_count_neutral_counts_sideless_ticks() {
4450        use rust_decimal_macros::dec;
4451        let neutral = make_tick_pq(dec!(100), dec!(1)); // side = None
4452        let mut buy = make_tick_pq(dec!(100), dec!(1));
4453        buy.side = Some(TradeSide::Buy);
4454        assert_eq!(NormalizedTick::count_neutral(&[neutral, buy]), 1);
4455    }
4456
4457    #[test]
4458    fn test_recent_returns_all_when_n_exceeds_len() {
4459        use rust_decimal_macros::dec;
4460        let ticks = vec![
4461            make_tick_pq(dec!(100), dec!(1)),
4462            make_tick_pq(dec!(110), dec!(1)),
4463        ];
4464        assert_eq!(NormalizedTick::recent(&ticks, 10).len(), 2);
4465    }
4466
4467    #[test]
4468    fn test_recent_returns_last_n() {
4469        use rust_decimal_macros::dec;
4470        let ticks: Vec<_> = [dec!(100), dec!(110), dec!(120), dec!(130)]
4471            .iter()
4472            .map(|&p| make_tick_pq(p, dec!(1)))
4473            .collect();
4474        let recent = NormalizedTick::recent(&ticks, 2);
4475        assert_eq!(recent.len(), 2);
4476        assert_eq!(recent[0].price, dec!(120));
4477        assert_eq!(recent[1].price, dec!(130));
4478    }
4479
4480    #[test]
4481    fn test_price_linear_slope_none_for_single_tick() {
4482        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
4483        assert!(NormalizedTick::price_linear_slope(&[t]).is_none());
4484    }
4485
4486    #[test]
4487    fn test_price_linear_slope_positive_for_rising_prices() {
4488        use rust_decimal_macros::dec;
4489        let ticks: Vec<_> = [dec!(100), dec!(110), dec!(120)]
4490            .iter()
4491            .map(|&p| make_tick_pq(p, dec!(1)))
4492            .collect();
4493        let slope = NormalizedTick::price_linear_slope(&ticks).unwrap();
4494        assert!(slope > 0.0);
4495    }
4496
4497    #[test]
4498    fn test_price_linear_slope_negative_for_falling_prices() {
4499        use rust_decimal_macros::dec;
4500        let ticks: Vec<_> = [dec!(120), dec!(110), dec!(100)]
4501            .iter()
4502            .map(|&p| make_tick_pq(p, dec!(1)))
4503            .collect();
4504        let slope = NormalizedTick::price_linear_slope(&ticks).unwrap();
4505        assert!(slope < 0.0);
4506    }
4507
4508    #[test]
4509    fn test_notional_std_dev_none_for_single_tick() {
4510        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
4511        assert!(NormalizedTick::notional_std_dev(&[t]).is_none());
4512    }
4513
4514    #[test]
4515    fn test_notional_std_dev_zero_for_identical_notionals() {
4516        use rust_decimal_macros::dec;
4517        let t1 = make_tick_pq(dec!(100), dec!(1)); // notional=100
4518        let t2 = make_tick_pq(dec!(100), dec!(1)); // notional=100
4519        assert_eq!(NormalizedTick::notional_std_dev(&[t1, t2]), Some(0.0));
4520    }
4521
4522    #[test]
4523    fn test_notional_std_dev_positive_for_varied_notionals() {
4524        use rust_decimal_macros::dec;
4525        let t1 = make_tick_pq(dec!(100), dec!(1)); // notional=100
4526        let t2 = make_tick_pq(dec!(200), dec!(2)); // notional=400
4527        let std = NormalizedTick::notional_std_dev(&[t1, t2]).unwrap();
4528        assert!(std > 0.0);
4529    }
4530
4531    #[test]
4532    fn test_monotone_up_true_for_empty_slice() {
4533        assert!(NormalizedTick::monotone_up(&[]));
4534    }
4535
4536    #[test]
4537    fn test_monotone_up_true_for_non_decreasing_prices() {
4538        use rust_decimal_macros::dec;
4539        let ticks: Vec<_> = [dec!(100), dec!(100), dec!(110), dec!(120)]
4540            .iter().map(|&p| make_tick_pq(p, dec!(1))).collect();
4541        assert!(NormalizedTick::monotone_up(&ticks));
4542    }
4543
4544    #[test]
4545    fn test_monotone_up_false_for_any_decrease() {
4546        use rust_decimal_macros::dec;
4547        let ticks: Vec<_> = [dec!(100), dec!(110), dec!(105)]
4548            .iter().map(|&p| make_tick_pq(p, dec!(1))).collect();
4549        assert!(!NormalizedTick::monotone_up(&ticks));
4550    }
4551
4552    #[test]
4553    fn test_monotone_down_true_for_non_increasing_prices() {
4554        use rust_decimal_macros::dec;
4555        let ticks: Vec<_> = [dec!(120), dec!(110), dec!(110), dec!(100)]
4556            .iter().map(|&p| make_tick_pq(p, dec!(1))).collect();
4557        assert!(NormalizedTick::monotone_down(&ticks));
4558    }
4559
4560    #[test]
4561    fn test_monotone_down_false_for_any_increase() {
4562        use rust_decimal_macros::dec;
4563        let ticks: Vec<_> = [dec!(100), dec!(90), dec!(95)]
4564            .iter().map(|&p| make_tick_pq(p, dec!(1))).collect();
4565        assert!(!NormalizedTick::monotone_down(&ticks));
4566    }
4567
4568    #[test]
4569    fn test_volume_at_price_zero_for_empty_slice() {
4570        assert_eq!(NormalizedTick::volume_at_price(&[], rust_decimal_macros::dec!(100)), rust_decimal_macros::dec!(0));
4571    }
4572
4573    #[test]
4574    fn test_volume_at_price_sums_matching_ticks() {
4575        use rust_decimal_macros::dec;
4576        let t1 = make_tick_pq(dec!(100), dec!(2));
4577        let t2 = make_tick_pq(dec!(100), dec!(3));
4578        let t3 = make_tick_pq(dec!(110), dec!(5));
4579        assert_eq!(NormalizedTick::volume_at_price(&[t1, t2, t3], dec!(100)), dec!(5));
4580    }
4581
4582    #[test]
4583    fn test_last_price_none_for_empty_slice() {
4584        assert!(NormalizedTick::last_price(&[]).is_none());
4585    }
4586
4587    #[test]
4588    fn test_last_price_returns_last_tick_price() {
4589        use rust_decimal_macros::dec;
4590        let t1 = make_tick_pq(dec!(100), dec!(1));
4591        let t2 = make_tick_pq(dec!(110), dec!(1));
4592        assert_eq!(NormalizedTick::last_price(&[t1, t2]), Some(dec!(110)));
4593    }
4594
4595    #[test]
4596    fn test_longest_buy_streak_zero_for_empty() {
4597        assert_eq!(NormalizedTick::longest_buy_streak(&[]), 0);
4598    }
4599
4600    #[test]
4601    fn test_longest_buy_streak_counts_consecutive_buys() {
4602        use rust_decimal_macros::dec;
4603        let mut b1 = make_tick_pq(dec!(100), dec!(1)); b1.side = Some(TradeSide::Buy);
4604        let mut b2 = make_tick_pq(dec!(100), dec!(1)); b2.side = Some(TradeSide::Buy);
4605        let mut s  = make_tick_pq(dec!(100), dec!(1)); s.side = Some(TradeSide::Sell);
4606        let mut b3 = make_tick_pq(dec!(100), dec!(1)); b3.side = Some(TradeSide::Buy);
4607        // streaks: [2, 1] → max = 2
4608        assert_eq!(NormalizedTick::longest_buy_streak(&[b1, b2, s, b3]), 2);
4609    }
4610
4611    #[test]
4612    fn test_longest_sell_streak_zero_for_no_sells() {
4613        use rust_decimal_macros::dec;
4614        let mut b = make_tick_pq(dec!(100), dec!(1)); b.side = Some(TradeSide::Buy);
4615        assert_eq!(NormalizedTick::longest_sell_streak(&[b]), 0);
4616    }
4617
4618    #[test]
4619    fn test_longest_sell_streak_correct() {
4620        use rust_decimal_macros::dec;
4621        let mut b  = make_tick_pq(dec!(100), dec!(1)); b.side = Some(TradeSide::Buy);
4622        let mut s1 = make_tick_pq(dec!(100), dec!(1)); s1.side = Some(TradeSide::Sell);
4623        let mut s2 = make_tick_pq(dec!(100), dec!(1)); s2.side = Some(TradeSide::Sell);
4624        let mut s3 = make_tick_pq(dec!(100), dec!(1)); s3.side = Some(TradeSide::Sell);
4625        assert_eq!(NormalizedTick::longest_sell_streak(&[b, s1, s2, s3]), 3);
4626    }
4627
4628    #[test]
4629    fn test_price_at_max_volume_none_for_empty() {
4630        assert!(NormalizedTick::price_at_max_volume(&[]).is_none());
4631    }
4632
4633    #[test]
4634    fn test_price_at_max_volume_returns_dominant_price() {
4635        use rust_decimal_macros::dec;
4636        let t1 = make_tick_pq(dec!(100), dec!(1));
4637        let t2 = make_tick_pq(dec!(200), dec!(5));
4638        let t3 = make_tick_pq(dec!(200), dec!(3));
4639        // price 200 has total vol 8 > price 100 vol 1
4640        assert_eq!(NormalizedTick::price_at_max_volume(&[t1, t2, t3]), Some(dec!(200)));
4641    }
4642
4643    #[test]
4644    fn test_recent_volume_zero_for_empty_slice() {
4645        assert_eq!(NormalizedTick::recent_volume(&[], 5), rust_decimal_macros::dec!(0));
4646    }
4647
4648    #[test]
4649    fn test_recent_volume_sums_last_n_ticks() {
4650        use rust_decimal_macros::dec;
4651        let ticks: Vec<_> = [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)]
4652            .iter().map(|&q| make_tick_pq(dec!(100), q)).collect();
4653        // last 3 ticks: qty 3+4+5 = 12
4654        assert_eq!(NormalizedTick::recent_volume(&ticks, 3), dec!(12));
4655    }
4656
4657    // ── NormalizedTick::first_price ───────────────────────────────────────────
4658
4659    #[test]
4660    fn test_first_price_none_for_empty_slice() {
4661        assert!(NormalizedTick::first_price(&[]).is_none());
4662    }
4663
4664    #[test]
4665    fn test_first_price_returns_first_tick_price() {
4666        use rust_decimal_macros::dec;
4667        let ticks = vec![make_tick_pq(dec!(50), dec!(1)), make_tick_pq(dec!(60), dec!(1))];
4668        assert_eq!(NormalizedTick::first_price(&ticks), Some(dec!(50)));
4669    }
4670
4671    // ── NormalizedTick::price_return_pct ─────────────────────────────────────
4672
4673    #[test]
4674    fn test_price_return_pct_none_for_single_tick() {
4675        use rust_decimal_macros::dec;
4676        assert!(NormalizedTick::price_return_pct(&[make_tick_pq(dec!(100), dec!(1))]).is_none());
4677    }
4678
4679    #[test]
4680    fn test_price_return_pct_positive_for_rising_price() {
4681        use rust_decimal_macros::dec;
4682        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(110), dec!(1))];
4683        let pct = NormalizedTick::price_return_pct(&ticks).unwrap();
4684        assert!((pct - 0.1).abs() < 1e-9);
4685    }
4686
4687    #[test]
4688    fn test_price_return_pct_negative_for_falling_price() {
4689        use rust_decimal_macros::dec;
4690        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(90), dec!(1))];
4691        let pct = NormalizedTick::price_return_pct(&ticks).unwrap();
4692        assert!((pct - (-0.1)).abs() < 1e-9);
4693    }
4694
4695    // ── NormalizedTick::volume_above_price / volume_below_price ──────────────
4696
4697    #[test]
4698    fn test_volume_above_price_zero_for_empty_slice() {
4699        use rust_decimal_macros::dec;
4700        assert_eq!(NormalizedTick::volume_above_price(&[], dec!(100)), dec!(0));
4701    }
4702
4703    #[test]
4704    fn test_volume_above_price_sums_above_threshold() {
4705        use rust_decimal_macros::dec;
4706        let ticks = vec![
4707            make_tick_pq(dec!(90), dec!(5)),
4708            make_tick_pq(dec!(100), dec!(10)),
4709            make_tick_pq(dec!(110), dec!(3)),
4710        ];
4711        // only price=110 is above 100
4712        assert_eq!(NormalizedTick::volume_above_price(&ticks, dec!(100)), dec!(3));
4713    }
4714
4715    #[test]
4716    fn test_volume_below_price_sums_below_threshold() {
4717        use rust_decimal_macros::dec;
4718        let ticks = vec![
4719            make_tick_pq(dec!(90), dec!(5)),
4720            make_tick_pq(dec!(100), dec!(10)),
4721            make_tick_pq(dec!(110), dec!(3)),
4722        ];
4723        // only price=90 is below 100
4724        assert_eq!(NormalizedTick::volume_below_price(&ticks, dec!(100)), dec!(5));
4725    }
4726
4727    // ── NormalizedTick::quantity_weighted_avg_price ───────────────────────────
4728
4729    #[test]
4730    fn test_qwap_none_for_empty_slice() {
4731        assert!(NormalizedTick::quantity_weighted_avg_price(&[]).is_none());
4732    }
4733
4734    #[test]
4735    fn test_qwap_correct_for_equal_quantities() {
4736        use rust_decimal_macros::dec;
4737        // equal qty → simple average of prices
4738        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(200), dec!(1))];
4739        assert_eq!(NormalizedTick::quantity_weighted_avg_price(&ticks), Some(dec!(150)));
4740    }
4741
4742    #[test]
4743    fn test_qwap_weighted_towards_higher_volume() {
4744        use rust_decimal_macros::dec;
4745        // price=100 qty=1, price=200 qty=3 → (100+600)/4 = 175
4746        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(200), dec!(3))];
4747        assert_eq!(NormalizedTick::quantity_weighted_avg_price(&ticks), Some(dec!(175)));
4748    }
4749
4750    // ── NormalizedTick::tick_count_above_price / tick_count_below_price ───────
4751
4752    #[test]
4753    fn test_tick_count_above_price_zero_for_empty_slice() {
4754        use rust_decimal_macros::dec;
4755        assert_eq!(NormalizedTick::tick_count_above_price(&[], dec!(100)), 0);
4756    }
4757
4758    #[test]
4759    fn test_tick_count_above_price_correct() {
4760        use rust_decimal_macros::dec;
4761        let ticks = vec![
4762            make_tick_pq(dec!(90), dec!(1)),
4763            make_tick_pq(dec!(100), dec!(1)),
4764            make_tick_pq(dec!(110), dec!(1)),
4765            make_tick_pq(dec!(120), dec!(1)),
4766        ];
4767        assert_eq!(NormalizedTick::tick_count_above_price(&ticks, dec!(100)), 2);
4768    }
4769
4770    #[test]
4771    fn test_tick_count_below_price_correct() {
4772        use rust_decimal_macros::dec;
4773        let ticks = vec![
4774            make_tick_pq(dec!(90), dec!(1)),
4775            make_tick_pq(dec!(100), dec!(1)),
4776            make_tick_pq(dec!(110), dec!(1)),
4777        ];
4778        assert_eq!(NormalizedTick::tick_count_below_price(&ticks, dec!(100)), 1);
4779    }
4780
4781    // ── NormalizedTick::price_at_percentile ──────────────────────────────────
4782
4783    #[test]
4784    fn test_price_at_percentile_none_for_empty_slice() {
4785        use rust_decimal_macros::dec;
4786        assert!(NormalizedTick::price_at_percentile(&[], 0.5).is_none());
4787    }
4788
4789    #[test]
4790    fn test_price_at_percentile_none_for_out_of_range() {
4791        use rust_decimal_macros::dec;
4792        let ticks = vec![make_tick_pq(dec!(100), dec!(1))];
4793        assert!(NormalizedTick::price_at_percentile(&ticks, 1.5).is_none());
4794    }
4795
4796    #[test]
4797    fn test_price_at_percentile_median_for_sorted_prices() {
4798        use rust_decimal_macros::dec;
4799        let ticks = vec![
4800            make_tick_pq(dec!(10), dec!(1)),
4801            make_tick_pq(dec!(20), dec!(1)),
4802            make_tick_pq(dec!(30), dec!(1)),
4803            make_tick_pq(dec!(40), dec!(1)),
4804            make_tick_pq(dec!(50), dec!(1)),
4805        ];
4806        // 50th percentile → index 2 → price=30
4807        assert_eq!(NormalizedTick::price_at_percentile(&ticks, 0.5), Some(dec!(30)));
4808    }
4809
4810    // ── NormalizedTick::unique_price_count ────────────────────────────────────
4811
4812    #[test]
4813    fn test_unique_price_count_zero_for_empty() {
4814        assert_eq!(NormalizedTick::unique_price_count(&[]), 0);
4815    }
4816
4817    #[test]
4818    fn test_unique_price_count_counts_distinct_prices() {
4819        use rust_decimal_macros::dec;
4820        let ticks = vec![
4821            make_tick_pq(dec!(100), dec!(1)),
4822            make_tick_pq(dec!(100), dec!(2)),
4823            make_tick_pq(dec!(110), dec!(1)),
4824            make_tick_pq(dec!(120), dec!(1)),
4825        ];
4826        assert_eq!(NormalizedTick::unique_price_count(&ticks), 3);
4827    }
4828
4829    // ── NormalizedTick::sell_volume / buy_volume ──────────────────────────────
4830
4831    #[test]
4832    fn test_sell_volume_zero_for_empty() {
4833        assert_eq!(NormalizedTick::sell_volume(&[]), rust_decimal_macros::dec!(0));
4834    }
4835
4836    #[test]
4837    fn test_sell_volume_sums_sell_side_only() {
4838        use rust_decimal_macros::dec;
4839        let mut buy_tick = make_tick_pq(dec!(100), dec!(5));
4840        buy_tick.side = Some(TradeSide::Buy);
4841        let mut sell_tick = make_tick_pq(dec!(100), dec!(3));
4842        sell_tick.side = Some(TradeSide::Sell);
4843        let no_side_tick = make_tick_pq(dec!(100), dec!(10));
4844        let ticks = [buy_tick, sell_tick, no_side_tick];
4845        assert_eq!(NormalizedTick::sell_volume(&ticks), dec!(3));
4846        assert_eq!(NormalizedTick::buy_volume(&ticks), dec!(5));
4847    }
4848
4849    // ── NormalizedTick::avg_inter_tick_spread ─────────────────────────────────
4850
4851    #[test]
4852    fn test_avg_inter_tick_spread_none_for_single_tick() {
4853        use rust_decimal_macros::dec;
4854        assert!(NormalizedTick::avg_inter_tick_spread(&[make_tick_pq(dec!(100), dec!(1))]).is_none());
4855    }
4856
4857    #[test]
4858    fn test_avg_inter_tick_spread_correct_for_uniform_moves() {
4859        use rust_decimal_macros::dec;
4860        // prices: 100, 102, 104 → diffs: 2, 2 → avg = 2.0
4861        let ticks = vec![
4862            make_tick_pq(dec!(100), dec!(1)),
4863            make_tick_pq(dec!(102), dec!(1)),
4864            make_tick_pq(dec!(104), dec!(1)),
4865        ];
4866        let spread = NormalizedTick::avg_inter_tick_spread(&ticks).unwrap();
4867        assert!((spread - 2.0).abs() < 1e-9);
4868    }
4869
4870    // ── NormalizedTick::price_range ───────────────────────────────────────────
4871
4872    #[test]
4873    fn test_price_range_none_for_empty() {
4874        assert!(NormalizedTick::price_range(&[]).is_none());
4875    }
4876
4877    #[test]
4878    fn test_price_range_correct() {
4879        use rust_decimal_macros::dec;
4880        let ticks = vec![
4881            make_tick_pq(dec!(90), dec!(1)),
4882            make_tick_pq(dec!(110), dec!(1)),
4883            make_tick_pq(dec!(100), dec!(1)),
4884        ];
4885        assert_eq!(NormalizedTick::price_range(&ticks), Some(dec!(20)));
4886    }
4887
4888    // ── NormalizedTick::median_price ──────────────────────────────────────────
4889
4890    #[test]
4891    fn test_median_price_none_for_empty() {
4892        assert!(NormalizedTick::median_price(&[]).is_none());
4893    }
4894
4895    #[test]
4896    fn test_median_price_returns_middle_value() {
4897        use rust_decimal_macros::dec;
4898        let ticks = vec![
4899            make_tick_pq(dec!(10), dec!(1)),
4900            make_tick_pq(dec!(30), dec!(1)),
4901            make_tick_pq(dec!(20), dec!(1)),
4902        ];
4903        // sorted: 10,20,30 → idx 1 = 20
4904        assert_eq!(NormalizedTick::median_price(&ticks), Some(dec!(20)));
4905    }
4906
4907    // ── NormalizedTick::largest_sell / largest_buy ────────────────────────────
4908
4909    #[test]
4910    fn test_largest_sell_none_for_no_sell_ticks() {
4911        use rust_decimal_macros::dec;
4912        let mut t = make_tick_pq(dec!(100), dec!(5));
4913        t.side = Some(TradeSide::Buy);
4914        assert!(NormalizedTick::largest_sell(&[t]).is_none());
4915    }
4916
4917    #[test]
4918    fn test_largest_sell_returns_max_sell_qty() {
4919        use rust_decimal_macros::dec;
4920        let mut t1 = make_tick_pq(dec!(100), dec!(3));
4921        t1.side = Some(TradeSide::Sell);
4922        let mut t2 = make_tick_pq(dec!(100), dec!(7));
4923        t2.side = Some(TradeSide::Sell);
4924        assert_eq!(NormalizedTick::largest_sell(&[t1, t2]), Some(dec!(7)));
4925    }
4926
4927    #[test]
4928    fn test_largest_buy_returns_max_buy_qty() {
4929        use rust_decimal_macros::dec;
4930        let mut t1 = make_tick_pq(dec!(100), dec!(2));
4931        t1.side = Some(TradeSide::Buy);
4932        let mut t2 = make_tick_pq(dec!(100), dec!(9));
4933        t2.side = Some(TradeSide::Buy);
4934        assert_eq!(NormalizedTick::largest_buy(&[t1, t2]), Some(dec!(9)));
4935    }
4936
4937    // ── NormalizedTick::trade_count ───────────────────────────────────────────
4938
4939    #[test]
4940    fn test_trade_count_zero_for_empty() {
4941        assert_eq!(NormalizedTick::trade_count(&[]), 0);
4942    }
4943
4944    #[test]
4945    fn test_trade_count_matches_slice_length() {
4946        use rust_decimal_macros::dec;
4947        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(2))];
4948        assert_eq!(NormalizedTick::trade_count(&ticks), 2);
4949    }
4950
4951    // ── NormalizedTick::price_acceleration ───────────────────────────────────
4952
4953    #[test]
4954    fn test_price_acceleration_none_for_fewer_than_3() {
4955        use rust_decimal_macros::dec;
4956        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(1))];
4957        assert!(NormalizedTick::price_acceleration(&ticks).is_none());
4958    }
4959
4960    #[test]
4961    fn test_price_acceleration_zero_for_constant_velocity() {
4962        use rust_decimal_macros::dec;
4963        // prices: 100, 102, 104 → v1=2, v2=2 → accel=0
4964        let ticks = vec![
4965            make_tick_pq(dec!(100), dec!(1)),
4966            make_tick_pq(dec!(102), dec!(1)),
4967            make_tick_pq(dec!(104), dec!(1)),
4968        ];
4969        let acc = NormalizedTick::price_acceleration(&ticks).unwrap();
4970        assert!((acc - 0.0).abs() < 1e-9);
4971    }
4972
4973    #[test]
4974    fn test_price_acceleration_positive_when_speeding_up() {
4975        use rust_decimal_macros::dec;
4976        // prices: 100, 101, 103 → v1=1, v2=2 → accel=1
4977        let ticks = vec![
4978            make_tick_pq(dec!(100), dec!(1)),
4979            make_tick_pq(dec!(101), dec!(1)),
4980            make_tick_pq(dec!(103), dec!(1)),
4981        ];
4982        let acc = NormalizedTick::price_acceleration(&ticks).unwrap();
4983        assert!((acc - 1.0).abs() < 1e-9);
4984    }
4985
4986    // ── NormalizedTick::buy_sell_diff ─────────────────────────────────────────
4987
4988    #[test]
4989    fn test_buy_sell_diff_zero_for_empty() {
4990        assert_eq!(NormalizedTick::buy_sell_diff(&[]), rust_decimal_macros::dec!(0));
4991    }
4992
4993    #[test]
4994    fn test_buy_sell_diff_positive_for_net_buying() {
4995        use rust_decimal_macros::dec;
4996        let mut t1 = make_tick_pq(dec!(100), dec!(10));
4997        t1.side = Some(TradeSide::Buy);
4998        let mut t2 = make_tick_pq(dec!(100), dec!(3));
4999        t2.side = Some(TradeSide::Sell);
5000        assert_eq!(NormalizedTick::buy_sell_diff(&[t1, t2]), dec!(7));
5001    }
5002
5003    // ── NormalizedTick::is_aggressive_buy / is_aggressive_sell ───────────────
5004
5005    #[test]
5006    fn test_is_aggressive_buy_true_when_exceeds_avg() {
5007        use rust_decimal_macros::dec;
5008        let mut t = make_tick_pq(dec!(100), dec!(15));
5009        t.side = Some(TradeSide::Buy);
5010        assert!(NormalizedTick::is_aggressive_buy(&t, dec!(10)));
5011    }
5012
5013    #[test]
5014    fn test_is_aggressive_buy_false_when_not_buy_side() {
5015        use rust_decimal_macros::dec;
5016        let mut t = make_tick_pq(dec!(100), dec!(15));
5017        t.side = Some(TradeSide::Sell);
5018        assert!(!NormalizedTick::is_aggressive_buy(&t, dec!(10)));
5019    }
5020
5021    #[test]
5022    fn test_is_aggressive_sell_true_when_exceeds_avg() {
5023        use rust_decimal_macros::dec;
5024        let mut t = make_tick_pq(dec!(100), dec!(20));
5025        t.side = Some(TradeSide::Sell);
5026        assert!(NormalizedTick::is_aggressive_sell(&t, dec!(10)));
5027    }
5028
5029    // ── NormalizedTick::notional_volume ───────────────────────────────────────
5030
5031    #[test]
5032    fn test_notional_volume_zero_for_empty() {
5033        assert_eq!(NormalizedTick::notional_volume(&[]), rust_decimal_macros::dec!(0));
5034    }
5035
5036    #[test]
5037    fn test_notional_volume_correct() {
5038        use rust_decimal_macros::dec;
5039        let ticks = vec![
5040            make_tick_pq(dec!(100), dec!(2)),  // 200
5041            make_tick_pq(dec!(50), dec!(4)),   // 200
5042        ];
5043        assert_eq!(NormalizedTick::notional_volume(&ticks), dec!(400));
5044    }
5045
5046    // ── NormalizedTick::weighted_side_score ───────────────────────────────────
5047
5048    #[test]
5049    fn test_weighted_side_score_none_for_empty() {
5050        assert!(NormalizedTick::weighted_side_score(&[]).is_none());
5051    }
5052
5053    #[test]
5054    fn test_weighted_side_score_correct_for_all_buys() {
5055        use rust_decimal_macros::dec;
5056        let mut t = make_tick_pq(dec!(100), dec!(10));
5057        t.side = Some(TradeSide::Buy);
5058        // buy=10, sell=0, total=10 → score=1.0
5059        let score = NormalizedTick::weighted_side_score(&[t]).unwrap();
5060        assert!((score - 1.0).abs() < 1e-9);
5061    }
5062
5063    // ── NormalizedTick::time_span_ms ──────────────────────────────────────────
5064
5065    #[test]
5066    fn test_time_span_none_for_single_tick() {
5067        let t = make_tick_pq(rust_decimal_macros::dec!(100), rust_decimal_macros::dec!(1));
5068        assert!(NormalizedTick::time_span_ms(&[t]).is_none());
5069    }
5070
5071    #[test]
5072    fn test_time_span_correct_for_two_ticks() {
5073        use rust_decimal_macros::dec;
5074        let mut t1 = make_tick_pq(dec!(100), dec!(1));
5075        t1.received_at_ms = 1000;
5076        let mut t2 = make_tick_pq(dec!(101), dec!(1));
5077        t2.received_at_ms = 5000;
5078        assert_eq!(NormalizedTick::time_span_ms(&[t1, t2]), Some(4000));
5079    }
5080
5081    // ── NormalizedTick::price_above_vwap_count ────────────────────────────────
5082
5083    #[test]
5084    fn test_price_above_vwap_count_none_for_empty() {
5085        assert!(NormalizedTick::price_above_vwap_count(&[]).is_none());
5086    }
5087
5088    #[test]
5089    fn test_price_above_vwap_count_correct() {
5090        use rust_decimal_macros::dec;
5091        // Equal quantities: VWAP = (90+100+110)/3 = 100; above: 110 = 1 tick
5092        let ticks = vec![
5093            make_tick_pq(dec!(90), dec!(1)),
5094            make_tick_pq(dec!(100), dec!(1)),
5095            make_tick_pq(dec!(110), dec!(1)),
5096        ];
5097        assert_eq!(NormalizedTick::price_above_vwap_count(&ticks), Some(1));
5098    }
5099
5100    // ── NormalizedTick::avg_trade_size ────────────────────────────────────────
5101
5102    #[test]
5103    fn test_avg_trade_size_none_for_empty() {
5104        assert!(NormalizedTick::avg_trade_size(&[]).is_none());
5105    }
5106
5107    #[test]
5108    fn test_avg_trade_size_correct() {
5109        use rust_decimal_macros::dec;
5110        let ticks = vec![
5111            make_tick_pq(dec!(100), dec!(2)),
5112            make_tick_pq(dec!(101), dec!(4)),
5113        ];
5114        assert_eq!(NormalizedTick::avg_trade_size(&ticks), Some(dec!(3)));
5115    }
5116
5117    // ── NormalizedTick::volume_concentration ─────────────────────────────────
5118
5119    #[test]
5120    fn test_volume_concentration_none_for_empty() {
5121        assert!(NormalizedTick::volume_concentration(&[]).is_none());
5122    }
5123
5124    #[test]
5125    fn test_volume_concentration_is_one_for_single_tick() {
5126        use rust_decimal_macros::dec;
5127        let ticks = vec![make_tick_pq(dec!(100), dec!(5))];
5128        let c = NormalizedTick::volume_concentration(&ticks).unwrap();
5129        assert!((c - 1.0).abs() < 1e-9);
5130    }
5131
5132    #[test]
5133    fn test_volume_concentration_in_range() {
5134        use rust_decimal_macros::dec;
5135        let ticks = vec![
5136            make_tick_pq(dec!(100), dec!(1)),
5137            make_tick_pq(dec!(101), dec!(1)),
5138            make_tick_pq(dec!(102), dec!(1)),
5139            make_tick_pq(dec!(103), dec!(10)),
5140        ];
5141        let c = NormalizedTick::volume_concentration(&ticks).unwrap();
5142        assert!(c > 0.0 && c <= 1.0, "expected value in (0,1], got {}", c);
5143    }
5144
5145    // ── NormalizedTick::trade_imbalance_score ─────────────────────────────────
5146
5147    #[test]
5148    fn test_trade_imbalance_score_none_for_empty() {
5149        assert!(NormalizedTick::trade_imbalance_score(&[]).is_none());
5150    }
5151
5152    #[test]
5153    fn test_trade_imbalance_score_positive_for_all_buys() {
5154        use rust_decimal_macros::dec;
5155        let mut t = make_tick_pq(dec!(100), dec!(1));
5156        t.side = Some(TradeSide::Buy);
5157        let score = NormalizedTick::trade_imbalance_score(&[t]).unwrap();
5158        assert!(score > 0.0);
5159    }
5160
5161    #[test]
5162    fn test_trade_imbalance_score_negative_for_all_sells() {
5163        use rust_decimal_macros::dec;
5164        let mut t = make_tick_pq(dec!(100), dec!(1));
5165        t.side = Some(TradeSide::Sell);
5166        let score = NormalizedTick::trade_imbalance_score(&[t]).unwrap();
5167        assert!(score < 0.0);
5168    }
5169
5170    // ── NormalizedTick::price_entropy ─────────────────────────────────────────
5171
5172    #[test]
5173    fn test_price_entropy_none_for_empty() {
5174        assert!(NormalizedTick::price_entropy(&[]).is_none());
5175    }
5176
5177    #[test]
5178    fn test_price_entropy_zero_for_single_price() {
5179        use rust_decimal_macros::dec;
5180        let ticks = vec![
5181            make_tick_pq(dec!(100), dec!(1)),
5182            make_tick_pq(dec!(100), dec!(2)),
5183        ];
5184        let e = NormalizedTick::price_entropy(&ticks).unwrap();
5185        assert!((e - 0.0).abs() < 1e-9, "identical prices should have zero entropy, got {}", e);
5186    }
5187
5188    #[test]
5189    fn test_price_entropy_positive_for_varied_prices() {
5190        use rust_decimal_macros::dec;
5191        let ticks = vec![
5192            make_tick_pq(dec!(100), dec!(1)),
5193            make_tick_pq(dec!(101), dec!(1)),
5194            make_tick_pq(dec!(102), dec!(1)),
5195        ];
5196        let e = NormalizedTick::price_entropy(&ticks).unwrap();
5197        assert!(e > 0.0, "varied prices should have positive entropy, got {}", e);
5198    }
5199
5200    // ── NormalizedTick::buy_avg_price / sell_avg_price ────────────────────────
5201
5202    #[test]
5203    fn test_buy_avg_price_none_for_no_buys() {
5204        use rust_decimal_macros::dec;
5205        let mut t = make_tick_pq(dec!(100), dec!(1));
5206        t.side = Some(TradeSide::Sell);
5207        assert!(NormalizedTick::buy_avg_price(&[t]).is_none());
5208    }
5209
5210    #[test]
5211    fn test_buy_avg_price_correct() {
5212        use rust_decimal_macros::dec;
5213        let mut t1 = make_tick_pq(dec!(100), dec!(1)); t1.side = Some(TradeSide::Buy);
5214        let mut t2 = make_tick_pq(dec!(110), dec!(1)); t2.side = Some(TradeSide::Buy);
5215        assert_eq!(NormalizedTick::buy_avg_price(&[t1, t2]), Some(dec!(105)));
5216    }
5217
5218    #[test]
5219    fn test_sell_avg_price_none_for_no_sells() {
5220        use rust_decimal_macros::dec;
5221        let mut t = make_tick_pq(dec!(100), dec!(1));
5222        t.side = Some(TradeSide::Buy);
5223        assert!(NormalizedTick::sell_avg_price(&[t]).is_none());
5224    }
5225
5226    #[test]
5227    fn test_sell_avg_price_correct() {
5228        use rust_decimal_macros::dec;
5229        let mut t1 = make_tick_pq(dec!(90), dec!(1)); t1.side = Some(TradeSide::Sell);
5230        let mut t2 = make_tick_pq(dec!(100), dec!(1)); t2.side = Some(TradeSide::Sell);
5231        assert_eq!(NormalizedTick::sell_avg_price(&[t1, t2]), Some(dec!(95)));
5232    }
5233
5234    // ── NormalizedTick::price_skewness ────────────────────────────────────────
5235
5236    #[test]
5237    fn test_price_skewness_none_for_fewer_than_3() {
5238        use rust_decimal_macros::dec;
5239        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(1))];
5240        assert!(NormalizedTick::price_skewness(&ticks).is_none());
5241    }
5242
5243    #[test]
5244    fn test_price_skewness_zero_for_symmetric() {
5245        use rust_decimal_macros::dec;
5246        // symmetric distribution: 1,2,3
5247        let ticks = vec![
5248            make_tick_pq(dec!(1), dec!(1)),
5249            make_tick_pq(dec!(2), dec!(1)),
5250            make_tick_pq(dec!(3), dec!(1)),
5251        ];
5252        let s = NormalizedTick::price_skewness(&ticks).unwrap();
5253        assert!(s.abs() < 1e-9, "symmetric should have near-zero skew, got {}", s);
5254    }
5255
5256    // ── NormalizedTick::quantity_skewness ─────────────────────────────────────
5257
5258    #[test]
5259    fn test_quantity_skewness_none_for_fewer_than_3() {
5260        use rust_decimal_macros::dec;
5261        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(2))];
5262        assert!(NormalizedTick::quantity_skewness(&ticks).is_none());
5263    }
5264
5265    #[test]
5266    fn test_quantity_skewness_positive_for_right_skewed() {
5267        use rust_decimal_macros::dec;
5268        // most quantities small, one very large: right-skewed
5269        let ticks = vec![
5270            make_tick_pq(dec!(100), dec!(1)),
5271            make_tick_pq(dec!(101), dec!(1)),
5272            make_tick_pq(dec!(102), dec!(100)),
5273        ];
5274        let s = NormalizedTick::quantity_skewness(&ticks).unwrap();
5275        assert!(s > 0.0, "right-skewed distribution should have positive skewness, got {}", s);
5276    }
5277
5278    // ── NormalizedTick::price_kurtosis ────────────────────────────────────────
5279
5280    #[test]
5281    fn test_price_kurtosis_none_for_fewer_than_4() {
5282        use rust_decimal_macros::dec;
5283        let ticks = vec![
5284            make_tick_pq(dec!(1), dec!(1)),
5285            make_tick_pq(dec!(2), dec!(1)),
5286            make_tick_pq(dec!(3), dec!(1)),
5287        ];
5288        assert!(NormalizedTick::price_kurtosis(&ticks).is_none());
5289    }
5290
5291    #[test]
5292    fn test_price_kurtosis_returns_some_for_varied_prices() {
5293        use rust_decimal_macros::dec;
5294        let ticks = vec![
5295            make_tick_pq(dec!(1), dec!(1)),
5296            make_tick_pq(dec!(2), dec!(1)),
5297            make_tick_pq(dec!(3), dec!(1)),
5298            make_tick_pq(dec!(4), dec!(1)),
5299        ];
5300        assert!(NormalizedTick::price_kurtosis(&ticks).is_some());
5301    }
5302
5303    // ── NormalizedTick::high_volume_tick_count ────────────────────────────────
5304
5305    #[test]
5306    fn test_high_volume_tick_count_zero_for_empty() {
5307        use rust_decimal_macros::dec;
5308        assert_eq!(NormalizedTick::high_volume_tick_count(&[], dec!(1)), 0);
5309    }
5310
5311    #[test]
5312    fn test_high_volume_tick_count_correct() {
5313        use rust_decimal_macros::dec;
5314        let ticks = vec![
5315            make_tick_pq(dec!(100), dec!(1)),
5316            make_tick_pq(dec!(101), dec!(5)),
5317            make_tick_pq(dec!(102), dec!(10)),
5318        ];
5319        assert_eq!(NormalizedTick::high_volume_tick_count(&ticks, dec!(4)), 2);
5320    }
5321
5322    // ── NormalizedTick::vwap_spread ───────────────────────────────────────────
5323
5324    #[test]
5325    fn test_vwap_spread_none_when_no_buys_or_sells() {
5326        use rust_decimal_macros::dec;
5327        let t = make_tick_pq(dec!(100), dec!(1));
5328        assert!(NormalizedTick::vwap_spread(&[t]).is_none());
5329    }
5330
5331    #[test]
5332    fn test_vwap_spread_positive_when_buys_priced_higher() {
5333        use rust_decimal_macros::dec;
5334        let mut buy = make_tick_pq(dec!(105), dec!(1)); buy.side = Some(TradeSide::Buy);
5335        let mut sell = make_tick_pq(dec!(100), dec!(1)); sell.side = Some(TradeSide::Sell);
5336        let spread = NormalizedTick::vwap_spread(&[buy, sell]).unwrap();
5337        assert!(spread > dec!(0), "expected positive spread, got {}", spread);
5338    }
5339
5340    // ── NormalizedTick::avg_buy_quantity / avg_sell_quantity ──────────────────
5341
5342    #[test]
5343    fn test_avg_buy_quantity_none_for_no_buys() {
5344        use rust_decimal_macros::dec;
5345        let mut t = make_tick_pq(dec!(100), dec!(2)); t.side = Some(TradeSide::Sell);
5346        assert!(NormalizedTick::avg_buy_quantity(&[t]).is_none());
5347    }
5348
5349    #[test]
5350    fn test_avg_buy_quantity_correct() {
5351        use rust_decimal_macros::dec;
5352        let mut t1 = make_tick_pq(dec!(100), dec!(2)); t1.side = Some(TradeSide::Buy);
5353        let mut t2 = make_tick_pq(dec!(101), dec!(4)); t2.side = Some(TradeSide::Buy);
5354        assert_eq!(NormalizedTick::avg_buy_quantity(&[t1, t2]), Some(dec!(3)));
5355    }
5356
5357    #[test]
5358    fn test_avg_sell_quantity_correct() {
5359        use rust_decimal_macros::dec;
5360        let mut t1 = make_tick_pq(dec!(100), dec!(6)); t1.side = Some(TradeSide::Sell);
5361        let mut t2 = make_tick_pq(dec!(101), dec!(2)); t2.side = Some(TradeSide::Sell);
5362        assert_eq!(NormalizedTick::avg_sell_quantity(&[t1, t2]), Some(dec!(4)));
5363    }
5364
5365    // ── NormalizedTick::price_mean_reversion_score ────────────────────────────
5366
5367    #[test]
5368    fn test_price_mean_reversion_score_none_for_empty() {
5369        assert!(NormalizedTick::price_mean_reversion_score(&[]).is_none());
5370    }
5371
5372    #[test]
5373    fn test_price_mean_reversion_score_in_range() {
5374        use rust_decimal_macros::dec;
5375        let ticks = vec![
5376            make_tick_pq(dec!(90), dec!(1)),
5377            make_tick_pq(dec!(100), dec!(1)),
5378            make_tick_pq(dec!(110), dec!(1)),
5379        ];
5380        let score = NormalizedTick::price_mean_reversion_score(&ticks).unwrap();
5381        assert!(score >= 0.0 && score <= 1.0, "score should be in [0, 1], got {}", score);
5382    }
5383
5384    // ── NormalizedTick::largest_price_move ────────────────────────────────────
5385
5386    #[test]
5387    fn test_largest_price_move_none_for_single_tick() {
5388        use rust_decimal_macros::dec;
5389        let t = make_tick_pq(dec!(100), dec!(1));
5390        assert!(NormalizedTick::largest_price_move(&[t]).is_none());
5391    }
5392
5393    #[test]
5394    fn test_largest_price_move_correct() {
5395        use rust_decimal_macros::dec;
5396        let ticks = vec![
5397            make_tick_pq(dec!(100), dec!(1)),
5398            make_tick_pq(dec!(105), dec!(1)),  // move = 5
5399            make_tick_pq(dec!(102), dec!(1)),  // move = 3
5400        ];
5401        assert_eq!(NormalizedTick::largest_price_move(&ticks), Some(dec!(5)));
5402    }
5403
5404    // ── NormalizedTick::tick_rate ─────────────────────────────────────────────
5405
5406    #[test]
5407    fn test_tick_rate_none_for_single_tick() {
5408        use rust_decimal_macros::dec;
5409        let t = make_tick_pq(dec!(100), dec!(1));
5410        assert!(NormalizedTick::tick_rate(&[t]).is_none());
5411    }
5412
5413    #[test]
5414    fn test_tick_rate_correct() {
5415        use rust_decimal_macros::dec;
5416        let mut t1 = make_tick_pq(dec!(100), dec!(1)); t1.received_at_ms = 0;
5417        let mut t2 = make_tick_pq(dec!(101), dec!(1)); t2.received_at_ms = 2;
5418        let mut t3 = make_tick_pq(dec!(102), dec!(1)); t3.received_at_ms = 4;
5419        // 3 ticks over 4ms → 0.75 ticks/ms
5420        let rate = NormalizedTick::tick_rate(&[t1, t2, t3]).unwrap();
5421        assert!((rate - 0.75).abs() < 1e-9, "expected 0.75 ticks/ms, got {}", rate);
5422    }
5423
5424    // ── NormalizedTick::buy_notional_fraction ─────────────────────────────────
5425
5426    #[test]
5427    fn test_buy_notional_fraction_none_for_empty() {
5428        assert!(NormalizedTick::buy_notional_fraction(&[]).is_none());
5429    }
5430
5431    #[test]
5432    fn test_buy_notional_fraction_one_when_all_buys() {
5433        use rust_decimal_macros::dec;
5434        let mut t = make_tick_pq(dec!(100), dec!(5)); t.side = Some(TradeSide::Buy);
5435        let frac = NormalizedTick::buy_notional_fraction(&[t]).unwrap();
5436        assert!((frac - 1.0).abs() < 1e-9, "all buys should give fraction=1.0, got {}", frac);
5437    }
5438
5439    #[test]
5440    fn test_buy_notional_fraction_in_range_for_mixed() {
5441        use rust_decimal_macros::dec;
5442        let mut buy = make_tick_pq(dec!(100), dec!(3)); buy.side = Some(TradeSide::Buy);
5443        let mut sell = make_tick_pq(dec!(100), dec!(1)); sell.side = Some(TradeSide::Sell);
5444        let frac = NormalizedTick::buy_notional_fraction(&[buy, sell]).unwrap();
5445        assert!(frac > 0.0 && frac < 1.0, "mixed ticks should be in (0,1), got {}", frac);
5446    }
5447
5448    // ── NormalizedTick::price_range_pct ───────────────────────────────────────
5449
5450    #[test]
5451    fn test_price_range_pct_none_for_empty() {
5452        assert!(NormalizedTick::price_range_pct(&[]).is_none());
5453    }
5454
5455    #[test]
5456    fn test_price_range_pct_correct() {
5457        use rust_decimal_macros::dec;
5458        let ticks = vec![
5459            make_tick_pq(dec!(100), dec!(1)),
5460            make_tick_pq(dec!(110), dec!(1)),
5461        ];
5462        // (110 - 100) / 100 * 100 = 10%
5463        let pct = NormalizedTick::price_range_pct(&ticks).unwrap();
5464        assert!((pct - 10.0).abs() < 1e-6, "expected 10.0%, got {}", pct);
5465    }
5466
5467    // ── NormalizedTick::buy_side_dominance ────────────────────────────────────
5468
5469    #[test]
5470    fn test_buy_side_dominance_none_when_no_sides() {
5471        use rust_decimal_macros::dec;
5472        let t = make_tick_pq(dec!(100), dec!(1)); // side=None
5473        assert!(NormalizedTick::buy_side_dominance(&[t]).is_none());
5474    }
5475
5476    #[test]
5477    fn test_buy_side_dominance_one_when_all_buys() {
5478        use rust_decimal_macros::dec;
5479        let mut t = make_tick_pq(dec!(100), dec!(5)); t.side = Some(TradeSide::Buy);
5480        let d = NormalizedTick::buy_side_dominance(&[t]).unwrap();
5481        assert!((d - 1.0).abs() < 1e-9, "all buys should give 1.0, got {}", d);
5482    }
5483
5484    // ── NormalizedTick::volume_weighted_price_std ─────────────────────────────
5485
5486    #[test]
5487    fn test_volume_weighted_price_std_none_for_empty() {
5488        assert!(NormalizedTick::volume_weighted_price_std(&[]).is_none());
5489    }
5490
5491    #[test]
5492    fn test_volume_weighted_price_std_zero_for_same_price() {
5493        use rust_decimal_macros::dec;
5494        let ticks = vec![
5495            make_tick_pq(dec!(100), dec!(2)),
5496            make_tick_pq(dec!(100), dec!(3)),
5497        ];
5498        let std = NormalizedTick::volume_weighted_price_std(&ticks).unwrap();
5499        assert!((std - 0.0).abs() < 1e-9, "same price should give 0 std, got {}", std);
5500    }
5501
5502    // ── NormalizedTick::last_n_vwap ───────────────────────────────────────────
5503
5504    #[test]
5505    fn test_last_n_vwap_none_for_zero_n() {
5506        use rust_decimal_macros::dec;
5507        let t = make_tick_pq(dec!(100), dec!(1));
5508        assert!(NormalizedTick::last_n_vwap(&[t], 0).is_none());
5509    }
5510
5511    #[test]
5512    fn test_last_n_vwap_uses_last_n_ticks() {
5513        use rust_decimal_macros::dec;
5514        // first tick at 50, last 2 at 100 equal qty → last_n_vwap(n=2) = 100
5515        let ticks = vec![
5516            make_tick_pq(dec!(50), dec!(10)),
5517            make_tick_pq(dec!(100), dec!(5)),
5518            make_tick_pq(dec!(100), dec!(5)),
5519        ];
5520        let v = NormalizedTick::last_n_vwap(&ticks, 2).unwrap();
5521        assert_eq!(v, dec!(100));
5522    }
5523
5524    // ── NormalizedTick::price_autocorrelation ─────────────────────────────────
5525
5526    #[test]
5527    fn test_price_autocorrelation_none_for_fewer_than_3() {
5528        use rust_decimal_macros::dec;
5529        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(1))];
5530        assert!(NormalizedTick::price_autocorrelation(&ticks).is_none());
5531    }
5532
5533    #[test]
5534    fn test_price_autocorrelation_positive_for_trending_prices() {
5535        use rust_decimal_macros::dec;
5536        let ticks = vec![
5537            make_tick_pq(dec!(100), dec!(1)),
5538            make_tick_pq(dec!(102), dec!(1)),
5539            make_tick_pq(dec!(104), dec!(1)),
5540            make_tick_pq(dec!(106), dec!(1)),
5541        ];
5542        let ac = NormalizedTick::price_autocorrelation(&ticks).unwrap();
5543        assert!(ac > 0.0, "trending prices should have positive AC, got {}", ac);
5544    }
5545
5546    // ── NormalizedTick::net_trade_direction ───────────────────────────────────
5547
5548    #[test]
5549    fn test_net_trade_direction_zero_for_empty() {
5550        assert_eq!(NormalizedTick::net_trade_direction(&[]), 0);
5551    }
5552
5553    #[test]
5554    fn test_net_trade_direction_positive_for_more_buys() {
5555        use rust_decimal_macros::dec;
5556        let mut b1 = make_tick_pq(dec!(100), dec!(1)); b1.side = Some(TradeSide::Buy);
5557        let mut b2 = make_tick_pq(dec!(100), dec!(1)); b2.side = Some(TradeSide::Buy);
5558        let mut s1 = make_tick_pq(dec!(100), dec!(1)); s1.side = Some(TradeSide::Sell);
5559        assert_eq!(NormalizedTick::net_trade_direction(&[b1, b2, s1]), 1);
5560    }
5561
5562    // ── NormalizedTick::sell_side_notional_fraction ───────────────────────────
5563
5564    #[test]
5565    fn test_sell_side_notional_fraction_none_for_empty() {
5566        assert!(NormalizedTick::sell_side_notional_fraction(&[]).is_none());
5567    }
5568
5569    #[test]
5570    fn test_sell_side_notional_fraction_one_when_all_sells() {
5571        use rust_decimal_macros::dec;
5572        let mut t = make_tick_pq(dec!(100), dec!(5)); t.side = Some(TradeSide::Sell);
5573        let f = NormalizedTick::sell_side_notional_fraction(&[t]).unwrap();
5574        assert!((f - 1.0).abs() < 1e-9, "all sells should give 1.0, got {}", f);
5575    }
5576
5577    // ── NormalizedTick::price_oscillation_count ───────────────────────────────
5578
5579    #[test]
5580    fn test_price_oscillation_count_zero_for_monotone() {
5581        use rust_decimal_macros::dec;
5582        let ticks = vec![
5583            make_tick_pq(dec!(100), dec!(1)),
5584            make_tick_pq(dec!(101), dec!(1)),
5585            make_tick_pq(dec!(102), dec!(1)),
5586        ];
5587        assert_eq!(NormalizedTick::price_oscillation_count(&ticks), 0);
5588    }
5589
5590    #[test]
5591    fn test_price_oscillation_count_detects_reversals() {
5592        use rust_decimal_macros::dec;
5593        // up-down-up: 100 → 105 → 102 → 107
5594        // windows(3): [100,105,102] (up-down) + [105,102,107] (down-up) → 2 reversals
5595        let ticks = vec![
5596            make_tick_pq(dec!(100), dec!(1)),
5597            make_tick_pq(dec!(105), dec!(1)),
5598            make_tick_pq(dec!(102), dec!(1)),
5599            make_tick_pq(dec!(107), dec!(1)),
5600        ];
5601        assert_eq!(NormalizedTick::price_oscillation_count(&ticks), 2);
5602    }
5603
5604    // ── NormalizedTick::realized_spread ───────────────────────────────────────
5605
5606    #[test]
5607    fn test_realized_spread_none_when_no_sides() {
5608        use rust_decimal_macros::dec;
5609        let t = make_tick_pq(dec!(100), dec!(1));
5610        assert!(NormalizedTick::realized_spread(&[t]).is_none());
5611    }
5612
5613    #[test]
5614    fn test_realized_spread_positive_when_buys_higher() {
5615        use rust_decimal_macros::dec;
5616        let mut b = make_tick_pq(dec!(105), dec!(1)); b.side = Some(TradeSide::Buy);
5617        let mut s = make_tick_pq(dec!(100), dec!(1)); s.side = Some(TradeSide::Sell);
5618        let spread = NormalizedTick::realized_spread(&[b, s]).unwrap();
5619        assert!(spread > dec!(0), "expected positive spread, got {}", spread);
5620    }
5621
5622    // ── NormalizedTick::price_impact_per_unit ────────────────────────────────
5623
5624    #[test]
5625    fn test_price_impact_per_unit_none_for_single_tick() {
5626        use rust_decimal_macros::dec;
5627        let t = make_tick_pq(dec!(100), dec!(1));
5628        assert!(NormalizedTick::price_impact_per_unit(&[t]).is_none());
5629    }
5630
5631    // ── NormalizedTick::volume_weighted_return ────────────────────────────────
5632
5633    #[test]
5634    fn test_volume_weighted_return_none_for_single_tick() {
5635        use rust_decimal_macros::dec;
5636        let t = make_tick_pq(dec!(100), dec!(1));
5637        assert!(NormalizedTick::volume_weighted_return(&[t]).is_none());
5638    }
5639
5640    #[test]
5641    fn test_volume_weighted_return_zero_for_constant_price() {
5642        use rust_decimal_macros::dec;
5643        let ticks = vec![
5644            make_tick_pq(dec!(100), dec!(5)),
5645            make_tick_pq(dec!(100), dec!(5)),
5646        ];
5647        let r = NormalizedTick::volume_weighted_return(&ticks).unwrap();
5648        assert!((r - 0.0).abs() < 1e-9, "constant price should give 0 return, got {}", r);
5649    }
5650
5651    // ── NormalizedTick::quantity_concentration ────────────────────────────────
5652
5653    #[test]
5654    fn test_quantity_concentration_none_for_empty() {
5655        assert!(NormalizedTick::quantity_concentration(&[]).is_none());
5656    }
5657
5658    #[test]
5659    fn test_quantity_concentration_zero_for_identical_quantities() {
5660        use rust_decimal_macros::dec;
5661        let ticks = vec![
5662            make_tick_pq(dec!(100), dec!(5)),
5663            make_tick_pq(dec!(101), dec!(5)),
5664        ];
5665        let c = NormalizedTick::quantity_concentration(&ticks).unwrap();
5666        assert!((c - 0.0).abs() < 1e-9, "identical quantities should give 0 concentration, got {}", c);
5667    }
5668
5669    // ── NormalizedTick::price_level_volume ────────────────────────────────────
5670
5671    #[test]
5672    fn test_price_level_volume_zero_for_no_match() {
5673        use rust_decimal_macros::dec;
5674        let ticks = vec![make_tick_pq(dec!(100), dec!(5))];
5675        let v = NormalizedTick::price_level_volume(&ticks, dec!(200));
5676        assert_eq!(v, dec!(0));
5677    }
5678
5679    #[test]
5680    fn test_price_level_volume_sums_matching_ticks() {
5681        use rust_decimal_macros::dec;
5682        let ticks = vec![
5683            make_tick_pq(dec!(100), dec!(3)),
5684            make_tick_pq(dec!(101), dec!(7)),
5685            make_tick_pq(dec!(100), dec!(2)),
5686        ];
5687        assert_eq!(NormalizedTick::price_level_volume(&ticks, dec!(100)), dec!(5));
5688    }
5689
5690    // ── NormalizedTick::mid_price_drift ───────────────────────────────────────
5691
5692    #[test]
5693    fn test_mid_price_drift_none_for_single_tick() {
5694        use rust_decimal_macros::dec;
5695        let t = make_tick_pq(dec!(100), dec!(1));
5696        assert!(NormalizedTick::mid_price_drift(&[t]).is_none());
5697    }
5698
5699    // ── NormalizedTick::tick_direction_bias ───────────────────────────────────
5700
5701    #[test]
5702    fn test_tick_direction_bias_none_for_fewer_than_3() {
5703        use rust_decimal_macros::dec;
5704        let ticks = vec![make_tick_pq(dec!(100), dec!(1)), make_tick_pq(dec!(101), dec!(1))];
5705        assert!(NormalizedTick::tick_direction_bias(&ticks).is_none());
5706    }
5707
5708    #[test]
5709    fn test_tick_direction_bias_one_for_monotone() {
5710        use rust_decimal_macros::dec;
5711        let ticks = vec![
5712            make_tick_pq(dec!(100), dec!(1)),
5713            make_tick_pq(dec!(101), dec!(1)),
5714            make_tick_pq(dec!(102), dec!(1)),
5715            make_tick_pq(dec!(103), dec!(1)),
5716        ];
5717        let bias = NormalizedTick::tick_direction_bias(&ticks).unwrap();
5718        assert!((bias - 1.0).abs() < 1e-9, "monotone should give bias=1.0, got {}", bias);
5719    }
5720
5721    #[test]
5722    fn test_buy_sell_size_ratio_none_for_empty() {
5723        assert!(NormalizedTick::buy_sell_size_ratio(&[]).is_none());
5724    }
5725
5726    #[test]
5727    fn test_buy_sell_size_ratio_positive() {
5728        use rust_decimal_macros::dec;
5729        let buy = NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(4)) };
5730        let sell = NormalizedTick { side: Some(TradeSide::Sell), ..make_tick_pq(dec!(100), dec!(2)) };
5731        let r = NormalizedTick::buy_sell_size_ratio(&[buy, sell]).unwrap();
5732        assert!((r - 2.0).abs() < 1e-6, "ratio should be 2.0, got {}", r);
5733    }
5734
5735    #[test]
5736    fn test_trade_size_dispersion_none_for_single_tick() {
5737        use rust_decimal_macros::dec;
5738        let ticks = vec![make_tick_pq(dec!(100), dec!(5))];
5739        assert!(NormalizedTick::trade_size_dispersion(&ticks).is_none());
5740    }
5741
5742    #[test]
5743    fn test_trade_size_dispersion_zero_for_identical() {
5744        use rust_decimal_macros::dec;
5745        let ticks = vec![
5746            make_tick_pq(dec!(100), dec!(5)),
5747            make_tick_pq(dec!(101), dec!(5)),
5748            make_tick_pq(dec!(102), dec!(5)),
5749        ];
5750        let d = NormalizedTick::trade_size_dispersion(&ticks).unwrap();
5751        assert!(d.abs() < 1e-9, "identical sizes → dispersion=0, got {}", d);
5752    }
5753
5754    #[test]
5755    fn test_first_last_price_none_for_empty() {
5756        assert!(NormalizedTick::first_price(&[]).is_none());
5757        assert!(NormalizedTick::last_price(&[]).is_none());
5758    }
5759
5760    #[test]
5761    fn test_first_last_price_correct() {
5762        use rust_decimal_macros::dec;
5763        let ticks = vec![
5764            make_tick_pq(dec!(100), dec!(1)),
5765            make_tick_pq(dec!(105), dec!(1)),
5766            make_tick_pq(dec!(110), dec!(1)),
5767        ];
5768        assert_eq!(NormalizedTick::first_price(&ticks).unwrap(), dec!(100));
5769        assert_eq!(NormalizedTick::last_price(&ticks).unwrap(), dec!(110));
5770    }
5771
5772    #[test]
5773    fn test_median_quantity_none_for_empty() {
5774        assert!(NormalizedTick::median_quantity(&[]).is_none());
5775    }
5776
5777    #[test]
5778    fn test_median_quantity_odd_count() {
5779        use rust_decimal_macros::dec;
5780        let ticks = vec![
5781            make_tick_pq(dec!(100), dec!(3)),
5782            make_tick_pq(dec!(101), dec!(1)),
5783            make_tick_pq(dec!(102), dec!(5)),
5784        ];
5785        // sorted: 1, 3, 5 → median = 3
5786        assert_eq!(NormalizedTick::median_quantity(&ticks).unwrap(), dec!(3));
5787    }
5788
5789    #[test]
5790    fn test_volume_above_vwap_none_for_empty() {
5791        assert!(NormalizedTick::volume_above_vwap(&[]).is_none());
5792    }
5793
5794    #[test]
5795    fn test_volume_above_vwap_none_when_all_at_vwap() {
5796        use rust_decimal_macros::dec;
5797        // All same price → VWAP = price, nothing strictly above
5798        let ticks = vec![
5799            make_tick_pq(dec!(100), dec!(5)),
5800            make_tick_pq(dec!(100), dec!(5)),
5801        ];
5802        let v = NormalizedTick::volume_above_vwap(&ticks).unwrap();
5803        assert_eq!(v, dec!(0));
5804    }
5805
5806    #[test]
5807    fn test_inter_arrival_variance_none_for_fewer_than_3() {
5808        use rust_decimal_macros::dec;
5809        let t = make_tick_pq(dec!(100), dec!(1));
5810        assert!(NormalizedTick::inter_arrival_variance(&[t]).is_none());
5811    }
5812
5813    #[test]
5814    fn test_spread_efficiency_none_for_single_tick() {
5815        use rust_decimal_macros::dec;
5816        let ticks = vec![make_tick_pq(dec!(100), dec!(1))];
5817        assert!(NormalizedTick::spread_efficiency(&ticks).is_none());
5818    }
5819
5820    #[test]
5821    fn test_spread_efficiency_one_for_monotone() {
5822        use rust_decimal_macros::dec;
5823        let ticks = vec![
5824            make_tick_pq(dec!(100), dec!(1)),
5825            make_tick_pq(dec!(101), dec!(1)),
5826            make_tick_pq(dec!(102), dec!(1)),
5827        ];
5828        // monotone up → efficiency = 1.0
5829        let e = NormalizedTick::spread_efficiency(&ticks).unwrap();
5830        assert!((e - 1.0).abs() < 1e-9, "expected 1.0, got {}", e);
5831    }
5832
5833    // ── round-79 ─────────────────────────────────────────────────────────────
5834
5835    // ── NormalizedTick::aggressor_fraction ────────────────────────────────────
5836
5837    #[test]
5838    fn test_aggressor_fraction_none_for_empty() {
5839        assert!(NormalizedTick::aggressor_fraction(&[]).is_none());
5840    }
5841
5842    #[test]
5843    fn test_aggressor_fraction_zero_when_all_neutral() {
5844        use rust_decimal_macros::dec;
5845        let ticks = vec![
5846            make_tick_pq(dec!(100), dec!(1)),
5847            make_tick_pq(dec!(101), dec!(1)),
5848        ];
5849        let f = NormalizedTick::aggressor_fraction(&ticks).unwrap();
5850        assert!((f - 0.0).abs() < 1e-9, "all neutral → fraction=0, got {}", f);
5851    }
5852
5853    #[test]
5854    fn test_aggressor_fraction_one_when_all_known() {
5855        use rust_decimal_macros::dec;
5856        let ticks = vec![
5857            NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(1)) },
5858            NormalizedTick { side: Some(TradeSide::Sell), ..make_tick_pq(dec!(101), dec!(1)) },
5859        ];
5860        let f = NormalizedTick::aggressor_fraction(&ticks).unwrap();
5861        assert!((f - 1.0).abs() < 1e-9, "all known → fraction=1, got {}", f);
5862    }
5863
5864    // ── NormalizedTick::volume_imbalance_ratio ────────────────────────────────
5865
5866    #[test]
5867    fn test_volume_imbalance_ratio_none_for_neutral_ticks() {
5868        use rust_decimal_macros::dec;
5869        let ticks = vec![make_tick_pq(dec!(100), dec!(5))];
5870        assert!(NormalizedTick::volume_imbalance_ratio(&ticks).is_none());
5871    }
5872
5873    #[test]
5874    fn test_volume_imbalance_ratio_positive_for_all_buys() {
5875        use rust_decimal_macros::dec;
5876        let ticks = vec![
5877            NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(4)) },
5878        ];
5879        let r = NormalizedTick::volume_imbalance_ratio(&ticks).unwrap();
5880        assert!((r - 1.0).abs() < 1e-9, "all buys → ratio=1.0, got {}", r);
5881    }
5882
5883    #[test]
5884    fn test_volume_imbalance_ratio_zero_for_equal_sides() {
5885        use rust_decimal_macros::dec;
5886        let ticks = vec![
5887            NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(5)) },
5888            NormalizedTick { side: Some(TradeSide::Sell), ..make_tick_pq(dec!(100), dec!(5)) },
5889        ];
5890        let r = NormalizedTick::volume_imbalance_ratio(&ticks).unwrap();
5891        assert!(r.abs() < 1e-9, "equal buy/sell → ratio=0, got {}", r);
5892    }
5893
5894    // ── NormalizedTick::price_quantity_covariance ─────────────────────────────
5895
5896    #[test]
5897    fn test_price_quantity_covariance_none_for_single_tick() {
5898        use rust_decimal_macros::dec;
5899        let ticks = vec![make_tick_pq(dec!(100), dec!(1))];
5900        assert!(NormalizedTick::price_quantity_covariance(&ticks).is_none());
5901    }
5902
5903    #[test]
5904    fn test_price_quantity_covariance_positive_when_correlated() {
5905        use rust_decimal_macros::dec;
5906        let ticks = vec![
5907            make_tick_pq(dec!(100), dec!(1)),
5908            make_tick_pq(dec!(200), dec!(2)),
5909            make_tick_pq(dec!(300), dec!(3)),
5910        ];
5911        let c = NormalizedTick::price_quantity_covariance(&ticks).unwrap();
5912        assert!(c > 0.0, "price and qty both rise → positive cov, got {}", c);
5913    }
5914
5915    // ── NormalizedTick::large_trade_fraction ──────────────────────────────────
5916
5917    #[test]
5918    fn test_large_trade_fraction_none_for_empty() {
5919        use rust_decimal_macros::dec;
5920        assert!(NormalizedTick::large_trade_fraction(&[], dec!(10)).is_none());
5921    }
5922
5923    #[test]
5924    fn test_large_trade_fraction_zero_when_all_small() {
5925        use rust_decimal_macros::dec;
5926        let ticks = vec![
5927            make_tick_pq(dec!(100), dec!(1)),
5928            make_tick_pq(dec!(101), dec!(2)),
5929        ];
5930        let f = NormalizedTick::large_trade_fraction(&ticks, dec!(10)).unwrap();
5931        assert!((f - 0.0).abs() < 1e-9, "all small → fraction=0, got {}", f);
5932    }
5933
5934    #[test]
5935    fn test_large_trade_fraction_one_when_all_large() {
5936        use rust_decimal_macros::dec;
5937        let ticks = vec![
5938            make_tick_pq(dec!(100), dec!(20)),
5939            make_tick_pq(dec!(101), dec!(30)),
5940        ];
5941        let f = NormalizedTick::large_trade_fraction(&ticks, dec!(10)).unwrap();
5942        assert!((f - 1.0).abs() < 1e-9, "all large → fraction=1, got {}", f);
5943    }
5944
5945    // ── NormalizedTick::price_level_density ───────────────────────────────────
5946
5947    #[test]
5948    fn test_price_level_density_none_for_empty() {
5949        assert!(NormalizedTick::price_level_density(&[]).is_none());
5950    }
5951
5952    #[test]
5953    fn test_price_level_density_none_when_range_zero() {
5954        use rust_decimal_macros::dec;
5955        let ticks = vec![
5956            make_tick_pq(dec!(100), dec!(1)),
5957            make_tick_pq(dec!(100), dec!(2)),
5958        ];
5959        assert!(NormalizedTick::price_level_density(&ticks).is_none());
5960    }
5961
5962    #[test]
5963    fn test_price_level_density_positive_for_varied_prices() {
5964        use rust_decimal_macros::dec;
5965        let ticks = vec![
5966            make_tick_pq(dec!(100), dec!(1)),
5967            make_tick_pq(dec!(110), dec!(1)),
5968            make_tick_pq(dec!(120), dec!(1)),
5969        ];
5970        let d = NormalizedTick::price_level_density(&ticks).unwrap();
5971        assert!(d > 0.0, "should be positive, got {}", d);
5972    }
5973
5974    // ── NormalizedTick::notional_buy_sell_ratio ───────────────────────────────
5975
5976    #[test]
5977    fn test_notional_buy_sell_ratio_none_when_no_sells() {
5978        use rust_decimal_macros::dec;
5979        let ticks = vec![
5980            NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(5)) },
5981        ];
5982        assert!(NormalizedTick::notional_buy_sell_ratio(&ticks).is_none());
5983    }
5984
5985    #[test]
5986    fn test_notional_buy_sell_ratio_one_for_equal_notional() {
5987        use rust_decimal_macros::dec;
5988        let ticks = vec![
5989            NormalizedTick { side: Some(TradeSide::Buy), ..make_tick_pq(dec!(100), dec!(5)) },
5990            NormalizedTick { side: Some(TradeSide::Sell), ..make_tick_pq(dec!(100), dec!(5)) },
5991        ];
5992        let r = NormalizedTick::notional_buy_sell_ratio(&ticks).unwrap();
5993        assert!((r - 1.0).abs() < 1e-9, "equal notional → ratio=1, got {}", r);
5994    }
5995
5996    // ── NormalizedTick::log_return_mean ───────────────────────────────────────
5997
5998    #[test]
5999    fn test_log_return_mean_none_for_single_tick() {
6000        use rust_decimal_macros::dec;
6001        assert!(NormalizedTick::log_return_mean(&[make_tick_pq(dec!(100), dec!(1))]).is_none());
6002    }
6003
6004    #[test]
6005    fn test_log_return_mean_zero_for_constant_price() {
6006        use rust_decimal_macros::dec;
6007        let ticks = vec![
6008            make_tick_pq(dec!(100), dec!(1)),
6009            make_tick_pq(dec!(100), dec!(1)),
6010            make_tick_pq(dec!(100), dec!(1)),
6011        ];
6012        let m = NormalizedTick::log_return_mean(&ticks).unwrap();
6013        assert!(m.abs() < 1e-9, "constant price → mean log return=0, got {}", m);
6014    }
6015
6016    // ── NormalizedTick::log_return_std ────────────────────────────────────────
6017
6018    #[test]
6019    fn test_log_return_std_none_for_fewer_than_3_ticks() {
6020        use rust_decimal_macros::dec;
6021        let ticks = vec![
6022            make_tick_pq(dec!(100), dec!(1)),
6023            make_tick_pq(dec!(101), dec!(1)),
6024        ];
6025        assert!(NormalizedTick::log_return_std(&ticks).is_none());
6026    }
6027
6028    #[test]
6029    fn test_log_return_std_zero_for_constant_price() {
6030        use rust_decimal_macros::dec;
6031        let ticks = vec![
6032            make_tick_pq(dec!(100), dec!(1)),
6033            make_tick_pq(dec!(100), dec!(1)),
6034            make_tick_pq(dec!(100), dec!(1)),
6035            make_tick_pq(dec!(100), dec!(1)),
6036        ];
6037        let s = NormalizedTick::log_return_std(&ticks).unwrap();
6038        assert!(s.abs() < 1e-9, "constant price → std=0, got {}", s);
6039    }
6040
6041    // ── NormalizedTick::price_overshoot_ratio ─────────────────────────────────
6042
6043    #[test]
6044    fn test_price_overshoot_ratio_none_for_empty() {
6045        assert!(NormalizedTick::price_overshoot_ratio(&[]).is_none());
6046    }
6047
6048    #[test]
6049    fn test_price_overshoot_ratio_one_for_monotone_up() {
6050        use rust_decimal_macros::dec;
6051        let ticks = vec![
6052            make_tick_pq(dec!(100), dec!(1)),
6053            make_tick_pq(dec!(105), dec!(1)),
6054            make_tick_pq(dec!(110), dec!(1)),
6055        ];
6056        // max=110 == last=110 → ratio=1
6057        let r = NormalizedTick::price_overshoot_ratio(&ticks).unwrap();
6058        assert!((r - 1.0).abs() < 1e-9, "monotone up → ratio=1, got {}", r);
6059    }
6060
6061    #[test]
6062    fn test_price_overshoot_ratio_above_one_when_price_retreats() {
6063        use rust_decimal_macros::dec;
6064        let ticks = vec![
6065            make_tick_pq(dec!(100), dec!(1)),
6066            make_tick_pq(dec!(120), dec!(1)),
6067            make_tick_pq(dec!(110), dec!(1)),
6068        ];
6069        // max=120, last=110 → ratio > 1
6070        let r = NormalizedTick::price_overshoot_ratio(&ticks).unwrap();
6071        assert!(r > 1.0, "price retreated → ratio>1, got {}", r);
6072    }
6073
6074    // ── NormalizedTick::price_undershoot_ratio ────────────────────────────────
6075
6076    #[test]
6077    fn test_price_undershoot_ratio_none_for_empty() {
6078        assert!(NormalizedTick::price_undershoot_ratio(&[]).is_none());
6079    }
6080
6081    #[test]
6082    fn test_price_undershoot_ratio_one_for_monotone_down() {
6083        use rust_decimal_macros::dec;
6084        let ticks = vec![
6085            make_tick_pq(dec!(110), dec!(1)),
6086            make_tick_pq(dec!(105), dec!(1)),
6087            make_tick_pq(dec!(100), dec!(1)),
6088        ];
6089        // first=110, min=100 → ratio > 1 (price undershot opening)
6090        let r = NormalizedTick::price_undershoot_ratio(&ticks).unwrap();
6091        assert!(r > 1.0, "monotone down → ratio>1, got {}", r);
6092    }
6093
6094    #[test]
6095    fn test_price_undershoot_ratio_one_for_monotone_up() {
6096        use rust_decimal_macros::dec;
6097        let ticks = vec![
6098            make_tick_pq(dec!(100), dec!(1)),
6099            make_tick_pq(dec!(105), dec!(1)),
6100            make_tick_pq(dec!(110), dec!(1)),
6101        ];
6102        // first=100 == min=100 → ratio=1 (never went below open)
6103        let r = NormalizedTick::price_undershoot_ratio(&ticks).unwrap();
6104        assert!((r - 1.0).abs() < 1e-9, "monotone up → ratio=1, got {}", r);
6105    }
6106
6107    // ── round-80 tests ────────────────────────────────────────────────────────
6108
6109    #[test]
6110    fn test_net_notional_empty_is_zero() {
6111        assert_eq!(NormalizedTick::net_notional(&[]), Decimal::ZERO);
6112    }
6113
6114    #[test]
6115    fn test_net_notional_positive_buy() {
6116        use rust_decimal_macros::dec;
6117        let ticks = vec![
6118            make_tick_pq(dec!(100), dec!(5)).with_side(TradeSide::Buy),
6119            make_tick_pq(dec!(100), dec!(2)).with_side(TradeSide::Sell),
6120        ];
6121        assert_eq!(NormalizedTick::net_notional(&ticks), dec!(300));
6122    }
6123
6124    #[test]
6125    fn test_price_reversal_count_empty_is_zero() {
6126        assert_eq!(NormalizedTick::price_reversal_count(&[]), 0);
6127    }
6128
6129    #[test]
6130    fn test_price_reversal_count_monotone_is_zero() {
6131        use rust_decimal_macros::dec;
6132        let ticks = vec![
6133            make_tick_pq(dec!(100), dec!(1)),
6134            make_tick_pq(dec!(101), dec!(1)),
6135            make_tick_pq(dec!(102), dec!(1)),
6136        ];
6137        assert_eq!(NormalizedTick::price_reversal_count(&ticks), 0);
6138    }
6139
6140    #[test]
6141    fn test_price_reversal_count_zigzag() {
6142        use rust_decimal_macros::dec;
6143        let ticks = vec![
6144            make_tick_pq(dec!(100), dec!(1)),
6145            make_tick_pq(dec!(105), dec!(1)),
6146            make_tick_pq(dec!(100), dec!(1)),
6147            make_tick_pq(dec!(105), dec!(1)),
6148        ];
6149        assert_eq!(NormalizedTick::price_reversal_count(&ticks), 2);
6150    }
6151
6152    #[test]
6153    fn test_quantity_kurtosis_none_for_few_ticks() {
6154        use rust_decimal_macros::dec;
6155        let t = make_tick_pq(dec!(100), dec!(1));
6156        assert!(NormalizedTick::quantity_kurtosis(&[t]).is_none());
6157    }
6158
6159    #[test]
6160    fn test_quantity_kurtosis_some_for_sufficient() {
6161        use rust_decimal_macros::dec;
6162        let ticks = vec![
6163            make_tick_pq(dec!(100), dec!(1)),
6164            make_tick_pq(dec!(101), dec!(2)),
6165            make_tick_pq(dec!(102), dec!(3)),
6166            make_tick_pq(dec!(103), dec!(4)),
6167        ];
6168        assert!(NormalizedTick::quantity_kurtosis(&ticks).is_some());
6169    }
6170
6171    #[test]
6172    fn test_largest_notional_trade_none_for_empty() {
6173        assert!(NormalizedTick::largest_notional_trade(&[]).is_none());
6174    }
6175
6176    #[test]
6177    fn test_largest_notional_trade_correct() {
6178        use rust_decimal_macros::dec;
6179        let ticks = vec![
6180            make_tick_pq(dec!(100), dec!(1)),   // notional = 100
6181            make_tick_pq(dec!(50), dec!(10)),   // notional = 500 ← max
6182            make_tick_pq(dec!(200), dec!(1)),   // notional = 200
6183        ];
6184        let t = NormalizedTick::largest_notional_trade(&ticks).unwrap();
6185        assert_eq!(t.price, dec!(50));
6186    }
6187
6188    #[test]
6189    fn test_twap_none_for_single_tick() {
6190        use rust_decimal_macros::dec;
6191        assert!(NormalizedTick::twap(&[make_tick_pq(dec!(100), dec!(1))]).is_none());
6192    }
6193
6194    #[test]
6195    fn test_twap_two_equal_intervals() {
6196        use rust_decimal_macros::dec;
6197        let mut t1 = make_tick_pq(dec!(100), dec!(1));
6198        t1.received_at_ms = 0;
6199        let mut t2 = make_tick_pq(dec!(200), dec!(1));
6200        t2.received_at_ms = 1000;
6201        let mut t3 = make_tick_pq(dec!(300), dec!(1));
6202        t3.received_at_ms = 2000;
6203        // weights: t1 * 1000ms, t2 * 1000ms → TWAP = (100*1000 + 200*1000)/2000 = 150
6204        let twap = NormalizedTick::twap(&[t1, t2, t3]).unwrap();
6205        assert_eq!(twap, dec!(150));
6206    }
6207
6208    #[test]
6209    fn test_neutral_fraction_all_neutral() {
6210        use rust_decimal_macros::dec;
6211        let ticks = vec![
6212            make_tick_pq(dec!(100), dec!(1)),
6213            make_tick_pq(dec!(101), dec!(1)),
6214        ];
6215        let f = NormalizedTick::neutral_fraction(&ticks).unwrap();
6216        assert!((f - 1.0).abs() < 1e-9, "all neutral → fraction=1, got {}", f);
6217    }
6218
6219    #[test]
6220    fn test_log_return_variance_none_for_few_ticks() {
6221        use rust_decimal_macros::dec;
6222        let t = make_tick_pq(dec!(100), dec!(1));
6223        assert!(NormalizedTick::log_return_variance(&[t]).is_none());
6224    }
6225
6226    #[test]
6227    fn test_log_return_variance_zero_for_flat_prices() {
6228        use rust_decimal_macros::dec;
6229        let ticks = vec![
6230            make_tick_pq(dec!(100), dec!(1)),
6231            make_tick_pq(dec!(100), dec!(1)),
6232            make_tick_pq(dec!(100), dec!(1)),
6233        ];
6234        let v = NormalizedTick::log_return_variance(&ticks).unwrap();
6235        assert!(v.abs() < 1e-9, "flat prices → variance=0, got {}", v);
6236    }
6237
6238    #[test]
6239    fn test_volume_at_vwap_zero_for_empty() {
6240        assert_eq!(
6241            NormalizedTick::volume_at_vwap(&[], rust_decimal_macros::dec!(1)),
6242            Decimal::ZERO
6243        );
6244    }
6245}