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    pub fn value(&self) -> Decimal {
178        self.price * self.quantity
179    }
180
181    /// Age of this tick in milliseconds relative to `now_ms`.
182    ///
183    /// Returns `now_ms - received_at_ms`, saturating at zero (never negative).
184    /// Useful for staleness checks: `tick.age_ms(now) > threshold_ms`.
185    pub fn age_ms(&self, now_ms: u64) -> u64 {
186        now_ms.saturating_sub(self.received_at_ms)
187    }
188
189    /// Returns `true` if this tick is older than `threshold_ms` relative to `now_ms`.
190    ///
191    /// Equivalent to `self.age_ms(now_ms) > threshold_ms`. Use this for filtering
192    /// stale ticks before passing them into aggregation pipelines.
193    pub fn is_stale(&self, now_ms: u64, threshold_ms: u64) -> bool {
194        self.age_ms(now_ms) > threshold_ms
195    }
196
197    /// Returns `true` if the tick is a buyer-initiated trade.
198    ///
199    /// Returns `false` if side is `Sell` or `None` (side unknown).
200    pub fn is_buy(&self) -> bool {
201        self.side == Some(TradeSide::Buy)
202    }
203
204    /// Returns `true` if the tick is a seller-initiated trade.
205    ///
206    /// Returns `false` if side is `Buy` or `None` (side unknown).
207    pub fn is_sell(&self) -> bool {
208        self.side == Some(TradeSide::Sell)
209    }
210
211    /// Returns `true` if the trade side is unknown (`None`).
212    ///
213    /// Many exchanges do not report the aggressor side. This method lets
214    /// callers explicitly test for the "no side information" case rather than
215    /// relying on both `is_buy()` and `is_sell()` returning `false`.
216    pub fn is_neutral(&self) -> bool {
217        self.side.is_none()
218    }
219
220    /// Returns `true` if the traded quantity meets or exceeds `threshold`.
221    ///
222    /// Useful for isolating institutional-size trades ("block trades") from
223    /// the general flow. The threshold is in the same units as `quantity`.
224    pub fn is_large_trade(&self, threshold: Decimal) -> bool {
225        self.quantity >= threshold
226    }
227
228    /// Return a copy of this tick with the trade side set to `side`.
229    ///
230    /// Useful in tests and feed normalizers that determine the aggressor side
231    /// after initial tick construction.
232    pub fn with_side(mut self, side: TradeSide) -> Self {
233        self.side = Some(side);
234        self
235    }
236
237    /// Return a copy of this tick with the exchange timestamp set to `ts_ms`.
238    ///
239    /// Useful in tests and feed normalizers to inject an authoritative exchange
240    /// timestamp after the tick has already been constructed.
241    pub fn with_exchange_ts(mut self, ts_ms: u64) -> Self {
242        self.exchange_ts_ms = Some(ts_ms);
243        self
244    }
245
246    /// Signed price movement from `prev` to this tick: `self.price − prev.price`.
247    ///
248    /// Positive when price rose, negative when price fell, zero when unchanged.
249    /// Only meaningful when both ticks share the same symbol and exchange.
250    pub fn price_move_from(&self, prev: &NormalizedTick) -> Decimal {
251        self.price - prev.price
252    }
253
254    /// Returns `true` if this tick arrived more recently than `other`.
255    ///
256    /// Compares `received_at_ms` timestamps. Equal timestamps return `false`.
257    pub fn is_more_recent_than(&self, other: &NormalizedTick) -> bool {
258        self.received_at_ms > other.received_at_ms
259    }
260
261    /// Transport latency in milliseconds: `received_at_ms − exchange_ts_ms`.
262    ///
263    /// Returns `None` if the exchange timestamp is unavailable. A positive
264    /// value indicates how long the tick took to travel from exchange to this
265    /// system; negative values suggest clock skew between exchange and consumer.
266    pub fn latency_ms(&self) -> Option<i64> {
267        let exchange_ts = self.exchange_ts_ms? as i64;
268        Some(self.received_at_ms as i64 - exchange_ts)
269    }
270
271    /// Notional value of this trade: `price × quantity`.
272    pub fn volume_notional(&self) -> rust_decimal::Decimal {
273        self.price * self.quantity
274    }
275
276    /// Returns `true` if this tick carries an exchange-provided timestamp.
277    ///
278    /// When `false`, only the local `received_at_ms` is available. Use
279    /// [`latency_ms`](Self::latency_ms) to measure round-trip latency when
280    /// this returns `true`.
281    pub fn has_exchange_ts(&self) -> bool {
282        self.exchange_ts_ms.is_some()
283    }
284
285    /// Human-readable trade direction: `"buy"`, `"sell"`, or `"unknown"`.
286    pub fn side_str(&self) -> &'static str {
287        match self.side {
288            Some(TradeSide::Buy) => "buy",
289            Some(TradeSide::Sell) => "sell",
290            None => "unknown",
291        }
292    }
293
294    /// Returns `true` if the quantity is a whole number (no fractional part).
295    ///
296    /// Useful for detecting atypical fractional order sizes, or as a simple
297    /// round-lot check in integer-quantity markets.
298    pub fn is_round_lot(&self) -> bool {
299        self.quantity.fract().is_zero()
300    }
301
302    /// Returns `true` if this tick's symbol matches `other`'s symbol exactly.
303    pub fn is_same_symbol_as(&self, other: &NormalizedTick) -> bool {
304        self.symbol == other.symbol
305    }
306
307    /// Absolute price difference between this tick and `other`.
308    ///
309    /// Returns `|self.price - other.price|`. Useful for computing price drift
310    /// between two ticks of the same instrument without caring about direction.
311    pub fn price_distance_from(&self, other: &NormalizedTick) -> Decimal {
312        (self.price - other.price).abs()
313    }
314
315    /// Signed latency between the local receipt timestamp and the exchange
316    /// timestamp, in milliseconds.
317    ///
318    /// Returns `ts_ms as i64 - exchange_ts_ms as i64`.  Positive values mean
319    /// the tick arrived at the local system after the exchange stamped it
320    /// (normal network latency).  Negative values indicate clock skew between
321    /// the exchange and local clock.  Returns `None` if `exchange_ts_ms` is
322    /// absent.
323    pub fn exchange_latency_ms(&self) -> Option<i64> {
324        self.exchange_ts_ms
325            .map(|e| self.received_at_ms as i64 - e as i64)
326    }
327
328    /// Returns `true` if the notional value of this trade (`price × quantity`)
329    /// exceeds `threshold`.
330    ///
331    /// Unlike [`is_large_trade`](Self::is_large_trade) (which compares raw
332    /// quantity), this method uses the trade's dollar value, making it useful
333    /// for comparing block-trade size across instruments with different prices.
334    pub fn is_notional_large_trade(&self, threshold: Decimal) -> bool {
335        self.volume_notional() > threshold
336    }
337
338    /// Returns `true` if this tick's price is zero.
339    ///
340    /// A zero price typically indicates a malformed or uninitialized tick.
341    pub fn is_zero_price(&self) -> bool {
342        self.price.is_zero()
343    }
344
345    /// Returns `true` if the tick is still fresh relative to `now_ms`.
346    ///
347    /// "Fresh" means the tick arrived within the last `max_age_ms` milliseconds.
348    /// Returns `false` when `now_ms < ts_ms` (clock skew guard).
349    pub fn is_fresh(&self, now_ms: u64, max_age_ms: u64) -> bool {
350        now_ms.saturating_sub(self.received_at_ms) <= max_age_ms
351    }
352
353    /// Returns `true` if this tick's price is strictly above `price`.
354    pub fn is_above(&self, price: Decimal) -> bool {
355        self.price > price
356    }
357
358    /// Returns `true` if this tick's price is strictly below `price`.
359    pub fn is_below(&self, price: Decimal) -> bool {
360        self.price < price
361    }
362
363    /// Returns `true` if this tick's price equals `price`.
364    pub fn is_at(&self, price: Decimal) -> bool {
365        self.price == price
366    }
367
368    /// Returns `true` if the tick has a definite direction (buy or sell).
369    ///
370    /// Neutral ticks (where `side` is `None`) return `false`.
371    pub fn is_aggressive(&self) -> bool {
372        self.side.is_some()
373    }
374
375    /// Signed price difference: `self.price - other.price`.
376    ///
377    /// Positive when this tick's price is higher than the other's.
378    pub fn price_diff_from(&self, other: &NormalizedTick) -> Decimal {
379        self.price - other.price
380    }
381
382    /// Returns `true` if the trade quantity is strictly less than `threshold`.
383    ///
384    /// The inverse of [`is_large_trade`](Self::is_large_trade).
385    pub fn is_micro_trade(&self, threshold: Decimal) -> bool {
386        self.quantity < threshold
387    }
388
389    /// Returns `true` if this tick occurred above the given midpoint price.
390    ///
391    /// A tick above the midpoint is typically associated with buying pressure.
392    pub fn is_buying_pressure(&self, midpoint: Decimal) -> bool {
393        self.price > midpoint
394    }
395
396    /// Age of this tick in seconds: `(now_ms - received_at_ms) / 1000.0`.
397    ///
398    /// Returns `0.0` if `now_ms` is before `received_at_ms`.
399    pub fn age_secs(&self, now_ms: u64) -> f64 {
400        now_ms.saturating_sub(self.received_at_ms) as f64 / 1_000.0
401    }
402
403    /// Returns `true` if this tick originated from the same exchange as `other`.
404    pub fn is_same_exchange_as(&self, other: &NormalizedTick) -> bool {
405        self.exchange == other.exchange
406    }
407
408    /// Age of this tick in milliseconds: `now_ms - received_at_ms`.
409    ///
410    /// Returns `0` if `now_ms` is before `received_at_ms`.
411    pub fn quote_age_ms(&self, now_ms: u64) -> u64 {
412        now_ms.saturating_sub(self.received_at_ms)
413    }
414
415    /// Notional value of this tick: `price × quantity`.
416    pub fn notional_value(&self) -> Decimal {
417        self.price * self.quantity
418    }
419
420    /// Returns `true` if the notional value (`price × quantity`) exceeds `threshold`.
421    pub fn is_high_value_tick(&self, threshold: Decimal) -> bool {
422        self.notional_value() > threshold
423    }
424
425    /// Returns the trade side as a string slice: `"buy"`, `"sell"`, or `None`.
426    pub fn side_as_str(&self) -> Option<&'static str> {
427        match self.side {
428            Some(TradeSide::Buy) => Some("buy"),
429            Some(TradeSide::Sell) => Some("sell"),
430            None => None,
431        }
432    }
433
434    /// Returns `true` if this tick's price is strictly above `reference`.
435    pub fn is_above_price(&self, reference: Decimal) -> bool {
436        self.price > reference
437    }
438
439    /// Signed price change relative to `reference`: `self.price - reference`.
440    pub fn price_change_from(&self, reference: Decimal) -> Decimal {
441        self.price - reference
442    }
443
444    /// Returns `true` if this tick's `received_at_ms` falls within a trading session window.
445    pub fn is_market_open_tick(&self, session_start_ms: u64, session_end_ms: u64) -> bool {
446        self.received_at_ms >= session_start_ms && self.received_at_ms < session_end_ms
447    }
448
449    /// Returns `true` if this tick's price exactly equals `target`.
450    pub fn is_at_price(&self, target: Decimal) -> bool {
451        self.price == target
452    }
453
454    /// Returns `true` if this tick's price is strictly below `reference`.
455    pub fn is_below_price(&self, reference: Decimal) -> bool {
456        self.price < reference
457    }
458
459    /// Returns `true` if this tick's price is divisible by `step` with no remainder.
460    ///
461    /// Useful for identifying round-number price levels (e.g., `step = 100`).
462    /// Returns `false` if `step` is zero.
463    pub fn is_round_number(&self, step: Decimal) -> bool {
464        if step.is_zero() {
465            return false;
466        }
467        (self.price % step).is_zero()
468    }
469
470    /// Returns the trade quantity signed by side: `+quantity` for Buy, `-quantity` for Sell, `0` for unknown.
471    pub fn signed_quantity(&self) -> Decimal {
472        match self.side {
473            Some(TradeSide::Buy) => self.quantity,
474            Some(TradeSide::Sell) => -self.quantity,
475            None => Decimal::ZERO,
476        }
477    }
478
479    /// Returns `(price, quantity)` as a convenient tuple.
480    pub fn as_price_level(&self) -> (Decimal, Decimal) {
481        (self.price, self.quantity)
482    }
483
484    /// Returns `true` if this tick's quantity is strictly above `threshold`.
485    pub fn quantity_above(&self, threshold: Decimal) -> bool {
486        self.quantity > threshold
487    }
488
489    /// Returns `true` if this tick was received within `threshold_ms` of `now_ms`.
490    pub fn is_recent(&self, threshold_ms: u64, now_ms: u64) -> bool {
491        now_ms.saturating_sub(self.received_at_ms) <= threshold_ms
492    }
493
494    /// Returns `true` if this tick is on the buy side.
495    ///
496    /// Returns `false` if the side is `Sell` or `None`.
497    pub fn is_buy_side(&self) -> bool {
498        self.side == Some(TradeSide::Buy)
499    }
500
501    /// Returns `true` if this tick is on the sell side.
502    ///
503    /// Returns `false` if the side is `Buy` or `None`.
504    pub fn is_sell_side(&self) -> bool {
505        self.side == Some(TradeSide::Sell)
506    }
507
508    /// Returns `true` if this tick's quantity is zero (may indicate a cancel or correction).
509    pub fn is_zero_quantity(&self) -> bool {
510        self.quantity.is_zero()
511    }
512
513    /// Returns `true` if this tick's price is strictly between `bid` and `ask`.
514    pub fn is_within_spread(&self, bid: Decimal, ask: Decimal) -> bool {
515        self.price > bid && self.price < ask
516    }
517
518    /// Returns `true` if this tick's price deviates from `reference` by more than `threshold`.
519    pub fn is_away_from_price(&self, reference: Decimal, threshold: Decimal) -> bool {
520        (self.price - reference).abs() > threshold
521    }
522
523    /// Returns `true` if this tick's quantity is strictly above `threshold`.
524    pub fn is_large_tick(&self, threshold: Decimal) -> bool {
525        self.quantity > threshold
526    }
527
528    /// Returns `true` if this tick's price is within `[low, high]` (inclusive).
529    pub fn price_in_range(&self, low: Decimal, high: Decimal) -> bool {
530        self.price >= low && self.price <= high
531    }
532
533    /// Price rounded down to the nearest multiple of `tick_size`.
534    ///
535    /// Returns the original price if `tick_size` is zero.
536    pub fn rounded_price(&self, tick_size: Decimal) -> Decimal {
537        if tick_size.is_zero() {
538            return self.price;
539        }
540        (self.price / tick_size).floor() * tick_size
541    }
542
543    /// Returns `true` if the absolute price difference from `other` exceeds `threshold`.
544    pub fn is_large_spread_from(&self, other: &NormalizedTick, threshold: Decimal) -> bool {
545        (self.price - other.price).abs() > threshold
546    }
547
548    /// Notional value of this tick as `f64` (`price × quantity`).
549    pub fn volume_notional_f64(&self) -> f64 {
550        use rust_decimal::prelude::ToPrimitive;
551        self.volume_notional().to_f64().unwrap_or(0.0)
552    }
553
554    /// Rate of price change relative to a prior tick: `(price - prev.price) / dt_ms`.
555    ///
556    /// Returns `None` if `dt_ms` is zero (same timestamp).
557    pub fn price_velocity(&self, prev: &NormalizedTick, dt_ms: u64) -> Option<Decimal> {
558        if dt_ms == 0 { return None; }
559        Some((self.price - prev.price) / Decimal::from(dt_ms))
560    }
561
562    /// Volume-weighted average price across a slice of ticks.
563    ///
564    /// Returns `None` if the slice is empty or total volume is zero.
565    pub fn vwap(ticks: &[NormalizedTick]) -> Option<Decimal> {
566        if ticks.is_empty() { return None; }
567        let total_vol: Decimal = ticks.iter().map(|t| t.quantity).sum();
568        if total_vol.is_zero() { return None; }
569        let total_notional: Decimal = ticks.iter().map(|t| t.price * t.quantity).sum();
570        Some(total_notional / total_vol)
571    }
572
573    /// Returns `true` if price reversed direction by at least `min_move` from `prev`.
574    ///
575    /// A reversal means the direction of `(self.price - prev.price)` is opposite to
576    /// the direction of `(prev.price - prev_prev.price)`, and the magnitude ≥ `min_move`.
577    /// This two-argument form checks: `|self.price - prev.price| >= min_move`.
578    pub fn is_reversal(&self, prev: &NormalizedTick, min_move: Decimal) -> bool {
579        let move_size = (self.price - prev.price).abs();
580        move_size >= min_move
581    }
582
583    /// Returns `true` if a trade crossed the spread: `bid_tick.price >= ask_tick.price`.
584    ///
585    /// A spread-crossed condition indicates an aggressive order consumed
586    /// the best opposing quote.
587    pub fn spread_crossed(bid_tick: &NormalizedTick, ask_tick: &NormalizedTick) -> bool {
588        bid_tick.price >= ask_tick.price
589    }
590
591    /// Dollar (notional) value of this tick: `price * quantity`.
592    pub fn dollar_value(&self) -> Decimal {
593        self.price * self.quantity
594    }
595
596    /// Contract value using a futures/options multiplier: `price * quantity * multiplier`.
597    pub fn contract_value(&self, multiplier: Decimal) -> Decimal {
598        self.price * self.quantity * multiplier
599    }
600
601    /// Tick imbalance: `(buy_qty - sell_qty) / total_qty` across a tick slice.
602    ///
603    /// Buy ticks are those with `side == Some(TradeSide::Buy)`.
604    /// Returns `None` if total quantity is zero.
605    pub fn tick_imbalance(ticks: &[NormalizedTick]) -> Option<f64> {
606        use rust_decimal::prelude::ToPrimitive;
607        let buy_qty: Decimal = ticks.iter()
608            .filter(|t| matches!(t.side, Some(TradeSide::Buy)))
609            .map(|t| t.quantity)
610            .sum();
611        let total_qty: Decimal = ticks.iter().map(|t| t.quantity).sum();
612        if total_qty.is_zero() { return None; }
613        let sell_qty = total_qty - buy_qty;
614        ((buy_qty - sell_qty) / total_qty).to_f64()
615    }
616
617    /// Theoretical quote midpoint: `(bid.price + ask.price) / 2`.
618    ///
619    /// Returns `None` if either tick has a non-positive price or if the bid
620    /// price exceeds the ask price (crossed market).
621    pub fn quote_midpoint(bid: &NormalizedTick, ask: &NormalizedTick) -> Option<Decimal> {
622        if bid.price <= Decimal::ZERO || ask.price <= Decimal::ZERO {
623            return None;
624        }
625        if bid.price > ask.price {
626            return None;
627        }
628        Some((bid.price + ask.price) / Decimal::TWO)
629    }
630
631}
632
633impl std::fmt::Display for NormalizedTick {
634    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
635        let side = match self.side {
636            Some(s) => s.to_string(),
637            None => "?".to_string(),
638        };
639        write!(
640            f,
641            "{} {} {} x {} {} @{}ms",
642            self.exchange, self.symbol, self.price, self.quantity, side, self.received_at_ms
643        )
644    }
645}
646
647/// Normalizes raw ticks from any supported exchange into [`NormalizedTick`] form.
648///
649/// `TickNormalizer` is stateless and cheap to clone; a single instance can be
650/// shared across threads via `Arc` or constructed per-task.
651pub struct TickNormalizer;
652
653impl TickNormalizer {
654    /// Create a new normalizer. This is a zero-cost constructor.
655    pub fn new() -> Self {
656        Self
657    }
658
659    /// Normalize a raw tick into canonical form.
660    ///
661    /// # Errors
662    ///
663    /// Returns [`StreamError::ParseError`] if required fields are missing or
664    /// malformed, and [`StreamError::InvalidTick`] if price is not positive or
665    /// quantity is negative.
666    pub fn normalize(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
667        let tick = match raw.exchange {
668            Exchange::Binance => self.normalize_binance(raw),
669            Exchange::Coinbase => self.normalize_coinbase(raw),
670            Exchange::Alpaca => self.normalize_alpaca(raw),
671            Exchange::Polygon => self.normalize_polygon(raw),
672        }?;
673        if tick.price <= Decimal::ZERO {
674            return Err(StreamError::InvalidTick {
675                reason: format!("price must be positive, got {}", tick.price),
676            });
677        }
678        if tick.quantity < Decimal::ZERO {
679            return Err(StreamError::InvalidTick {
680                reason: format!("quantity must be non-negative, got {}", tick.quantity),
681            });
682        }
683        trace!(
684            exchange = %tick.exchange,
685            symbol = %tick.symbol,
686            price = %tick.price,
687            exchange_ts_ms = ?tick.exchange_ts_ms,
688            "tick normalized"
689        );
690        Ok(tick)
691    }
692
693    fn normalize_binance(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
694        let p = &raw.payload;
695        let price = parse_decimal_field(p, "p", &raw.exchange.to_string())?;
696        let qty = parse_decimal_field(p, "q", &raw.exchange.to_string())?;
697        let side = p.get("m").and_then(|v| v.as_bool()).map(|maker| {
698            if maker {
699                TradeSide::Sell
700            } else {
701                TradeSide::Buy
702            }
703        });
704        let trade_id = p.get("t").and_then(|v| v.as_u64()).map(|id| id.to_string());
705        let exchange_ts = p.get("T").and_then(|v| v.as_u64());
706        Ok(NormalizedTick {
707            exchange: raw.exchange,
708            symbol: raw.symbol,
709            price,
710            quantity: qty,
711            side,
712            trade_id,
713            exchange_ts_ms: exchange_ts,
714            received_at_ms: raw.received_at_ms,
715        })
716    }
717
718    fn normalize_coinbase(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
719        let p = &raw.payload;
720        let price = parse_decimal_field(p, "price", &raw.exchange.to_string())?;
721        let qty = parse_decimal_field(p, "size", &raw.exchange.to_string())?;
722        let side = p.get("side").and_then(|v| v.as_str()).map(|s| {
723            if s == "buy" {
724                TradeSide::Buy
725            } else {
726                TradeSide::Sell
727            }
728        });
729        let trade_id = p
730            .get("trade_id")
731            .and_then(|v| v.as_str())
732            .map(str::to_string);
733        // Coinbase Advanced Trade sends an ISO 8601 timestamp in the "time" field.
734        let exchange_ts_ms = p
735            .get("time")
736            .and_then(|v| v.as_str())
737            .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
738            .map(|dt| dt.timestamp_millis() as u64);
739        Ok(NormalizedTick {
740            exchange: raw.exchange,
741            symbol: raw.symbol,
742            price,
743            quantity: qty,
744            side,
745            trade_id,
746            exchange_ts_ms,
747            received_at_ms: raw.received_at_ms,
748        })
749    }
750
751    fn normalize_alpaca(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
752        let p = &raw.payload;
753        let price = parse_decimal_field(p, "p", &raw.exchange.to_string())?;
754        let qty = parse_decimal_field(p, "s", &raw.exchange.to_string())?;
755        let trade_id = p.get("i").and_then(|v| v.as_u64()).map(|id| id.to_string());
756        // Alpaca sends RFC 3339 timestamps in the "t" field (e.g. "2023-11-15T10:00:00.000Z").
757        let exchange_ts_ms = p
758            .get("t")
759            .and_then(|v| v.as_str())
760            .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
761            .map(|dt| dt.timestamp_millis() as u64);
762        Ok(NormalizedTick {
763            exchange: raw.exchange,
764            symbol: raw.symbol,
765            price,
766            quantity: qty,
767            side: None,
768            trade_id,
769            exchange_ts_ms,
770            received_at_ms: raw.received_at_ms,
771        })
772    }
773
774    fn normalize_polygon(&self, raw: RawTick) -> Result<NormalizedTick, StreamError> {
775        let p = &raw.payload;
776        let price = parse_decimal_field(p, "p", &raw.exchange.to_string())?;
777        let qty = parse_decimal_field(p, "s", &raw.exchange.to_string())?;
778        let trade_id = p.get("i").and_then(|v| v.as_str()).map(str::to_string);
779        // Polygon sends nanoseconds since epoch in the "t" field; convert to milliseconds.
780        let exchange_ts = p
781            .get("t")
782            .and_then(|v| v.as_u64())
783            .map(|t_ns| t_ns / 1_000_000);
784        Ok(NormalizedTick {
785            exchange: raw.exchange,
786            symbol: raw.symbol,
787            price,
788            quantity: qty,
789            side: None,
790            trade_id,
791            exchange_ts_ms: exchange_ts,
792            received_at_ms: raw.received_at_ms,
793        })
794    }
795}
796
797impl Default for TickNormalizer {
798    fn default() -> Self {
799        Self::new()
800    }
801}
802
803fn parse_decimal_field(
804    v: &serde_json::Value,
805    field: &str,
806    exchange: &str,
807) -> Result<Decimal, StreamError> {
808    let raw = v.get(field).ok_or_else(|| StreamError::ParseError {
809        exchange: exchange.to_string(),
810        reason: format!("missing field '{}'", field),
811    })?;
812    // Use the JSON-native string representation for both string and number
813    // values. For JSON strings this is a direct parse. For JSON numbers we use
814    // serde_json::Number::to_string(), which preserves the original text (e.g.
815    // "50000.12345678") rather than round-tripping through f64 and losing
816    // sub-microsecond precision.
817    let s: String = match raw {
818        serde_json::Value::String(s) => s.clone(),
819        serde_json::Value::Number(n) => n.to_string(),
820        _ => {
821            return Err(StreamError::ParseError {
822                exchange: exchange.to_string(),
823                reason: format!("field '{}' is not a string or number", field),
824            });
825        }
826    };
827    Decimal::from_str(&s).map_err(|e| StreamError::ParseError {
828        exchange: exchange.to_string(),
829        reason: format!("field '{}' parse error: {}", field, e),
830    })
831}
832
833fn now_ms() -> u64 {
834    std::time::SystemTime::now()
835        .duration_since(std::time::UNIX_EPOCH)
836        .map(|d| d.as_millis() as u64)
837        .unwrap_or(0)
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843    use serde_json::json;
844
845    fn normalizer() -> TickNormalizer {
846        TickNormalizer::new()
847    }
848
849    fn binance_tick(symbol: &str) -> RawTick {
850        RawTick {
851            exchange: Exchange::Binance,
852            symbol: symbol.to_string(),
853            payload: json!({ "p": "50000.12", "q": "0.001", "m": false, "t": 12345, "T": 1700000000000u64 }),
854            received_at_ms: 1700000000001,
855        }
856    }
857
858    fn coinbase_tick(symbol: &str) -> RawTick {
859        RawTick {
860            exchange: Exchange::Coinbase,
861            symbol: symbol.to_string(),
862            payload: json!({ "price": "50001.00", "size": "0.5", "side": "buy", "trade_id": "abc123" }),
863            received_at_ms: 1700000000002,
864        }
865    }
866
867    fn alpaca_tick(symbol: &str) -> RawTick {
868        RawTick {
869            exchange: Exchange::Alpaca,
870            symbol: symbol.to_string(),
871            payload: json!({ "p": "180.50", "s": "10", "i": 99 }),
872            received_at_ms: 1700000000003,
873        }
874    }
875
876    fn polygon_tick(symbol: &str) -> RawTick {
877        RawTick {
878            exchange: Exchange::Polygon,
879            symbol: symbol.to_string(),
880            // Polygon sends nanoseconds; 1_700_000_000_000_000_000 ns = 1_700_000_000_000 ms
881            payload: json!({ "p": "180.51", "s": "5", "i": "XYZ-001", "t": 1_700_000_000_000_000_000u64 }),
882            received_at_ms: 1700000000005,
883        }
884    }
885
886    #[test]
887    fn test_exchange_from_str_valid() {
888        assert_eq!("binance".parse::<Exchange>().unwrap(), Exchange::Binance);
889        assert_eq!("Coinbase".parse::<Exchange>().unwrap(), Exchange::Coinbase);
890        assert_eq!("ALPACA".parse::<Exchange>().unwrap(), Exchange::Alpaca);
891        assert_eq!("polygon".parse::<Exchange>().unwrap(), Exchange::Polygon);
892    }
893
894    #[test]
895    fn test_exchange_from_str_unknown_returns_error() {
896        let result = "Kraken".parse::<Exchange>();
897        assert!(matches!(result, Err(StreamError::UnknownExchange(_))));
898    }
899
900    #[test]
901    fn test_exchange_display() {
902        assert_eq!(Exchange::Binance.to_string(), "Binance");
903        assert_eq!(Exchange::Coinbase.to_string(), "Coinbase");
904    }
905
906    #[test]
907    fn test_normalize_binance_tick_price_and_qty() {
908        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
909        assert_eq!(tick.price, Decimal::from_str("50000.12").unwrap());
910        assert_eq!(tick.quantity, Decimal::from_str("0.001").unwrap());
911        assert_eq!(tick.exchange, Exchange::Binance);
912        assert_eq!(tick.symbol, "BTCUSDT");
913    }
914
915    #[test]
916    fn test_normalize_binance_side_maker_false_is_buy() {
917        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
918        assert_eq!(tick.side, Some(TradeSide::Buy));
919    }
920
921    #[test]
922    fn test_normalize_binance_side_maker_true_is_sell() {
923        let raw = RawTick {
924            exchange: Exchange::Binance,
925            symbol: "BTCUSDT".into(),
926            payload: json!({ "p": "50000", "q": "1", "m": true }),
927            received_at_ms: 0,
928        };
929        let tick = normalizer().normalize(raw).unwrap();
930        assert_eq!(tick.side, Some(TradeSide::Sell));
931    }
932
933    #[test]
934    fn test_normalize_binance_trade_id_and_ts() {
935        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
936        assert_eq!(tick.trade_id, Some("12345".to_string()));
937        assert_eq!(tick.exchange_ts_ms, Some(1700000000000));
938    }
939
940    #[test]
941    fn test_normalize_coinbase_tick() {
942        let tick = normalizer().normalize(coinbase_tick("BTC-USD")).unwrap();
943        assert_eq!(tick.price, Decimal::from_str("50001.00").unwrap());
944        assert_eq!(tick.quantity, Decimal::from_str("0.5").unwrap());
945        assert_eq!(tick.side, Some(TradeSide::Buy));
946        assert_eq!(tick.trade_id, Some("abc123".to_string()));
947    }
948
949    #[test]
950    fn test_normalize_coinbase_sell_side() {
951        let raw = RawTick {
952            exchange: Exchange::Coinbase,
953            symbol: "BTC-USD".into(),
954            payload: json!({ "price": "50000", "size": "1", "side": "sell" }),
955            received_at_ms: 0,
956        };
957        let tick = normalizer().normalize(raw).unwrap();
958        assert_eq!(tick.side, Some(TradeSide::Sell));
959    }
960
961    #[test]
962    fn test_normalize_alpaca_tick() {
963        let tick = normalizer().normalize(alpaca_tick("AAPL")).unwrap();
964        assert_eq!(tick.price, Decimal::from_str("180.50").unwrap());
965        assert_eq!(tick.quantity, Decimal::from_str("10").unwrap());
966        assert_eq!(tick.trade_id, Some("99".to_string()));
967        assert_eq!(tick.side, None);
968    }
969
970    #[test]
971    fn test_normalize_polygon_tick() {
972        let tick = normalizer().normalize(polygon_tick("AAPL")).unwrap();
973        assert_eq!(tick.price, Decimal::from_str("180.51").unwrap());
974        // 1_700_000_000_000_000_000 ns / 1_000_000 = 1_700_000_000_000 ms
975        assert_eq!(tick.exchange_ts_ms, Some(1_700_000_000_000u64));
976        assert_eq!(tick.trade_id, Some("XYZ-001".to_string()));
977    }
978
979    #[test]
980    fn test_normalize_alpaca_rfc3339_timestamp() {
981        let raw = RawTick {
982            exchange: Exchange::Alpaca,
983            symbol: "AAPL".into(),
984            payload: json!({ "p": "180.50", "s": "10", "i": 99, "t": "2023-11-15T00:00:00Z" }),
985            received_at_ms: 1700000000003,
986        };
987        let tick = normalizer().normalize(raw).unwrap();
988        assert!(tick.exchange_ts_ms.is_some(), "Alpaca 't' field should be parsed");
989        // 2023-11-15T00:00:00Z = 1700006400000 ms
990        assert_eq!(tick.exchange_ts_ms, Some(1700006400000u64));
991    }
992
993    #[test]
994    fn test_normalize_alpaca_no_timestamp_field() {
995        let tick = normalizer().normalize(alpaca_tick("AAPL")).unwrap();
996        assert_eq!(tick.exchange_ts_ms, None, "missing 't' field means no exchange_ts_ms");
997    }
998
999    #[test]
1000    fn test_normalize_missing_price_field_returns_parse_error() {
1001        let raw = RawTick {
1002            exchange: Exchange::Binance,
1003            symbol: "BTCUSDT".into(),
1004            payload: json!({ "q": "1" }),
1005            received_at_ms: 0,
1006        };
1007        let result = normalizer().normalize(raw);
1008        assert!(matches!(result, Err(StreamError::ParseError { .. })));
1009    }
1010
1011    #[test]
1012    fn test_normalize_invalid_decimal_returns_parse_error() {
1013        let raw = RawTick {
1014            exchange: Exchange::Coinbase,
1015            symbol: "BTC-USD".into(),
1016            payload: json!({ "price": "not-a-number", "size": "1" }),
1017            received_at_ms: 0,
1018        };
1019        let result = normalizer().normalize(raw);
1020        assert!(matches!(result, Err(StreamError::ParseError { .. })));
1021    }
1022
1023    #[test]
1024    fn test_raw_tick_new_sets_received_at() {
1025        let raw = RawTick::new(Exchange::Binance, "BTCUSDT", json!({}));
1026        assert!(raw.received_at_ms > 0);
1027    }
1028
1029    #[test]
1030    fn test_normalize_numeric_price_field() {
1031        let raw = RawTick {
1032            exchange: Exchange::Binance,
1033            symbol: "BTCUSDT".into(),
1034            payload: json!({ "p": 50000.0, "q": 1.0 }),
1035            received_at_ms: 0,
1036        };
1037        let tick = normalizer().normalize(raw).unwrap();
1038        assert!(tick.price > Decimal::ZERO);
1039    }
1040
1041    #[test]
1042    fn test_trade_side_from_str_buy() {
1043        assert_eq!("buy".parse::<TradeSide>().unwrap(), TradeSide::Buy);
1044        assert_eq!("Buy".parse::<TradeSide>().unwrap(), TradeSide::Buy);
1045        assert_eq!("BUY".parse::<TradeSide>().unwrap(), TradeSide::Buy);
1046    }
1047
1048    #[test]
1049    fn test_trade_side_from_str_sell() {
1050        assert_eq!("sell".parse::<TradeSide>().unwrap(), TradeSide::Sell);
1051        assert_eq!("Sell".parse::<TradeSide>().unwrap(), TradeSide::Sell);
1052        assert_eq!("SELL".parse::<TradeSide>().unwrap(), TradeSide::Sell);
1053    }
1054
1055    #[test]
1056    fn test_trade_side_from_str_invalid() {
1057        let err = "long".parse::<TradeSide>().unwrap_err();
1058        assert!(matches!(err, StreamError::ParseError { .. }));
1059    }
1060
1061    #[test]
1062    fn test_trade_side_display() {
1063        assert_eq!(TradeSide::Buy.to_string(), "buy");
1064        assert_eq!(TradeSide::Sell.to_string(), "sell");
1065    }
1066
1067    #[test]
1068    fn test_normalize_zero_price_returns_invalid_tick() {
1069        let raw = RawTick {
1070            exchange: Exchange::Binance,
1071            symbol: "BTCUSDT".into(),
1072            payload: json!({ "p": "0", "q": "1" }),
1073            received_at_ms: 0,
1074        };
1075        let err = normalizer().normalize(raw).unwrap_err();
1076        assert!(matches!(err, StreamError::InvalidTick { .. }));
1077    }
1078
1079    #[test]
1080    fn test_normalize_negative_price_returns_invalid_tick() {
1081        let raw = RawTick {
1082            exchange: Exchange::Binance,
1083            symbol: "BTCUSDT".into(),
1084            payload: json!({ "p": "-1", "q": "1" }),
1085            received_at_ms: 0,
1086        };
1087        let err = normalizer().normalize(raw).unwrap_err();
1088        assert!(matches!(err, StreamError::InvalidTick { .. }));
1089    }
1090
1091    #[test]
1092    fn test_normalize_negative_quantity_returns_invalid_tick() {
1093        let raw = RawTick {
1094            exchange: Exchange::Binance,
1095            symbol: "BTCUSDT".into(),
1096            payload: json!({ "p": "100", "q": "-1" }),
1097            received_at_ms: 0,
1098        };
1099        let err = normalizer().normalize(raw).unwrap_err();
1100        assert!(matches!(err, StreamError::InvalidTick { .. }));
1101    }
1102
1103    #[test]
1104    fn test_normalize_zero_quantity_is_valid() {
1105        // Zero quantity is allowed (e.g., remove from book), just not negative
1106        let raw = RawTick {
1107            exchange: Exchange::Binance,
1108            symbol: "BTCUSDT".into(),
1109            payload: json!({ "p": "100", "q": "0" }),
1110            received_at_ms: 0,
1111        };
1112        let tick = normalizer().normalize(raw).unwrap();
1113        assert_eq!(tick.quantity, Decimal::ZERO);
1114    }
1115
1116    #[test]
1117    fn test_trade_side_is_buy() {
1118        assert!(TradeSide::Buy.is_buy());
1119        assert!(!TradeSide::Buy.is_sell());
1120    }
1121
1122    #[test]
1123    fn test_trade_side_is_sell() {
1124        assert!(TradeSide::Sell.is_sell());
1125        assert!(!TradeSide::Sell.is_buy());
1126    }
1127
1128    #[test]
1129    fn test_normalized_tick_display() {
1130        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
1131        let s = tick.to_string();
1132        assert!(s.contains("Binance"));
1133        assert!(s.contains("BTCUSDT"));
1134        assert!(s.contains("50000"));
1135    }
1136
1137    #[test]
1138    fn test_normalized_tick_value_is_price_times_qty() {
1139        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
1140        // binance_tick sets price=50000, quantity=0.001
1141        let expected = tick.price * tick.quantity;
1142        assert_eq!(tick.volume_notional(), expected);
1143    }
1144
1145    #[test]
1146    fn test_normalized_tick_age_ms_positive() {
1147        let tick = normalizer().normalize(binance_tick("BTCUSDT")).unwrap();
1148        // received_at_ms is set to 1_000_000 in binance_tick helper? Let's check
1149        // Actually the helper uses now_ms() so we can't predict. Use a manual tick.
1150        let raw = RawTick {
1151            exchange: Exchange::Binance,
1152            symbol: "BTCUSDT".into(),
1153            payload: serde_json::json!({"p": "50000", "q": "0.001", "m": false}),
1154            received_at_ms: 1_000_000,
1155        };
1156        let tick = normalizer().normalize(raw).unwrap();
1157        assert_eq!(tick.age_ms(1_001_000), 1_000);
1158    }
1159
1160    #[test]
1161    fn test_normalized_tick_age_ms_zero_when_now_equals_received() {
1162        let raw = RawTick {
1163            exchange: Exchange::Binance,
1164            symbol: "BTCUSDT".into(),
1165            payload: serde_json::json!({"p": "50000", "q": "0.001", "m": false}),
1166            received_at_ms: 5_000,
1167        };
1168        let tick = normalizer().normalize(raw).unwrap();
1169        assert_eq!(tick.age_ms(5_000), 0);
1170        // saturating_sub: now < received → 0
1171        assert_eq!(tick.age_ms(4_000), 0);
1172    }
1173
1174    #[test]
1175    fn test_normalized_tick_value_zero_qty_is_zero() {
1176        use rust_decimal_macros::dec;
1177        let raw = RawTick {
1178            exchange: Exchange::Binance,
1179            symbol: "BTCUSDT".into(),
1180            payload: serde_json::json!({
1181                "p": "50000",
1182                "q": "0",
1183                "m": false,
1184            }),
1185            received_at_ms: 1000,
1186        };
1187        let tick = normalizer().normalize(raw).unwrap();
1188        assert_eq!(tick.value(), dec!(0));
1189    }
1190
1191    // ── NormalizedTick::is_stale ──────────────────────────────────────────────
1192
1193    fn make_tick_at(received_at_ms: u64) -> NormalizedTick {
1194        NormalizedTick {
1195            exchange: Exchange::Binance,
1196            symbol: "BTCUSDT".into(),
1197            price: rust_decimal_macros::dec!(100),
1198            quantity: rust_decimal_macros::dec!(1),
1199            side: None,
1200            trade_id: None,
1201            exchange_ts_ms: None,
1202            received_at_ms,
1203        }
1204    }
1205
1206    #[test]
1207    fn test_is_stale_true_when_age_exceeds_threshold() {
1208        let tick = make_tick_at(1_000);
1209        // now=6000, age=5000, threshold=4000 → stale
1210        assert!(tick.is_stale(6_000, 4_000));
1211    }
1212
1213    #[test]
1214    fn test_is_stale_false_when_age_equals_threshold() {
1215        let tick = make_tick_at(1_000);
1216        // now=5000, age=4000, threshold=4000 → NOT stale (> not >=)
1217        assert!(!tick.is_stale(5_000, 4_000));
1218    }
1219
1220    #[test]
1221    fn test_is_stale_false_for_fresh_tick() {
1222        let tick = make_tick_at(10_000);
1223        assert!(!tick.is_stale(10_500, 1_000));
1224    }
1225
1226    // ── NormalizedTick::is_buy / is_sell ──────────────────────────────────────
1227
1228    #[test]
1229    fn test_is_buy_true_for_buy_side() {
1230        let mut tick = make_tick_at(1_000);
1231        tick.side = Some(TradeSide::Buy);
1232        assert!(tick.is_buy());
1233        assert!(!tick.is_sell());
1234    }
1235
1236    #[test]
1237    fn test_is_sell_true_for_sell_side() {
1238        let mut tick = make_tick_at(1_000);
1239        tick.side = Some(TradeSide::Sell);
1240        assert!(tick.is_sell());
1241        assert!(!tick.is_buy());
1242    }
1243
1244    #[test]
1245    fn test_is_buy_false_for_unknown_side() {
1246        let mut tick = make_tick_at(1_000);
1247        tick.side = None;
1248        assert!(!tick.is_buy());
1249        assert!(!tick.is_sell());
1250    }
1251
1252    // ── NormalizedTick::with_exchange_ts ──────────────────────────────────────
1253
1254    #[test]
1255    fn test_with_exchange_ts_sets_field() {
1256        let tick = make_tick_at(5_000).with_exchange_ts(3_000);
1257        assert_eq!(tick.exchange_ts_ms, Some(3_000));
1258        assert_eq!(tick.received_at_ms, 5_000); // unchanged
1259    }
1260
1261    #[test]
1262    fn test_with_exchange_ts_overrides_existing() {
1263        let tick = make_tick_at(1_000).with_exchange_ts(999).with_exchange_ts(888);
1264        assert_eq!(tick.exchange_ts_ms, Some(888));
1265    }
1266
1267    // ── NormalizedTick::price_move_from / is_more_recent_than ─────────────────
1268
1269    #[test]
1270    fn test_price_move_from_positive() {
1271        let prev = make_tick_at(1_000);
1272        let mut curr = make_tick_at(2_000);
1273        curr.price = prev.price + rust_decimal_macros::dec!(5);
1274        assert_eq!(curr.price_move_from(&prev), rust_decimal_macros::dec!(5));
1275    }
1276
1277    #[test]
1278    fn test_price_move_from_negative() {
1279        let prev = make_tick_at(1_000);
1280        let mut curr = make_tick_at(2_000);
1281        curr.price = prev.price - rust_decimal_macros::dec!(3);
1282        assert_eq!(curr.price_move_from(&prev), rust_decimal_macros::dec!(-3));
1283    }
1284
1285    #[test]
1286    fn test_price_move_from_zero_when_same() {
1287        let tick = make_tick_at(1_000);
1288        assert_eq!(tick.price_move_from(&tick), rust_decimal_macros::dec!(0));
1289    }
1290
1291    #[test]
1292    fn test_is_more_recent_than_true() {
1293        let older = make_tick_at(1_000);
1294        let newer = make_tick_at(2_000);
1295        assert!(newer.is_more_recent_than(&older));
1296    }
1297
1298    #[test]
1299    fn test_is_more_recent_than_false_when_older() {
1300        let older = make_tick_at(1_000);
1301        let newer = make_tick_at(2_000);
1302        assert!(!older.is_more_recent_than(&newer));
1303    }
1304
1305    #[test]
1306    fn test_is_more_recent_than_false_when_equal() {
1307        let tick = make_tick_at(1_000);
1308        assert!(!tick.is_more_recent_than(&tick));
1309    }
1310
1311    // ── NormalizedTick::with_side ─────────────────────────────────────────────
1312
1313    #[test]
1314    fn test_with_side_sets_buy() {
1315        let tick = make_tick_at(1_000).with_side(TradeSide::Buy);
1316        assert_eq!(tick.side, Some(TradeSide::Buy));
1317    }
1318
1319    #[test]
1320    fn test_with_side_sets_sell() {
1321        let tick = make_tick_at(1_000).with_side(TradeSide::Sell);
1322        assert_eq!(tick.side, Some(TradeSide::Sell));
1323    }
1324
1325    #[test]
1326    fn test_with_side_overrides_existing() {
1327        let tick = make_tick_at(1_000).with_side(TradeSide::Buy).with_side(TradeSide::Sell);
1328        assert_eq!(tick.side, Some(TradeSide::Sell));
1329    }
1330
1331    // ── NormalizedTick::is_neutral ────────────────────────────────────────────
1332
1333    #[test]
1334    fn test_is_neutral_true_when_no_side() {
1335        let mut tick = make_tick_at(1_000);
1336        tick.side = None;
1337        assert!(tick.is_neutral());
1338    }
1339
1340    #[test]
1341    fn test_is_neutral_false_when_buy() {
1342        let tick = make_tick_at(1_000).with_side(TradeSide::Buy);
1343        assert!(!tick.is_neutral());
1344    }
1345
1346    #[test]
1347    fn test_is_neutral_false_when_sell() {
1348        let tick = make_tick_at(1_000).with_side(TradeSide::Sell);
1349        assert!(!tick.is_neutral());
1350    }
1351
1352    // ── NormalizedTick::is_large_trade ────────────────────────────────────────
1353
1354    #[test]
1355    fn test_is_large_trade_above_threshold() {
1356        let mut tick = make_tick_at(1_000);
1357        tick.quantity = rust_decimal_macros::dec!(100);
1358        assert!(tick.is_large_trade(rust_decimal_macros::dec!(50)));
1359    }
1360
1361    #[test]
1362    fn test_is_large_trade_at_threshold() {
1363        let mut tick = make_tick_at(1_000);
1364        tick.quantity = rust_decimal_macros::dec!(50);
1365        assert!(tick.is_large_trade(rust_decimal_macros::dec!(50)));
1366    }
1367
1368    #[test]
1369    fn test_is_large_trade_below_threshold() {
1370        let mut tick = make_tick_at(1_000);
1371        tick.quantity = rust_decimal_macros::dec!(10);
1372        assert!(!tick.is_large_trade(rust_decimal_macros::dec!(50)));
1373    }
1374
1375    #[test]
1376    fn test_volume_notional_is_price_times_quantity() {
1377        let mut tick = make_tick_at(1_000);
1378        tick.price = rust_decimal_macros::dec!(200);
1379        tick.quantity = rust_decimal_macros::dec!(3);
1380        assert_eq!(tick.volume_notional(), rust_decimal_macros::dec!(600));
1381    }
1382
1383    // ── NormalizedTick::is_above ──────────────────────────────────────────────
1384
1385    #[test]
1386    fn test_is_above_returns_true_when_price_higher() {
1387        let mut tick = make_tick_at(1_000);
1388        tick.price = rust_decimal_macros::dec!(200);
1389        assert!(tick.is_above(rust_decimal_macros::dec!(150)));
1390    }
1391
1392    #[test]
1393    fn test_is_above_returns_false_when_price_equal() {
1394        let mut tick = make_tick_at(1_000);
1395        tick.price = rust_decimal_macros::dec!(200);
1396        assert!(!tick.is_above(rust_decimal_macros::dec!(200)));
1397    }
1398
1399    #[test]
1400    fn test_is_above_returns_false_when_price_lower() {
1401        let mut tick = make_tick_at(1_000);
1402        tick.price = rust_decimal_macros::dec!(100);
1403        assert!(!tick.is_above(rust_decimal_macros::dec!(200)));
1404    }
1405
1406    // ── NormalizedTick::is_below ──────────────────────────────────────────────
1407
1408    #[test]
1409    fn test_is_below_returns_true_when_price_lower() {
1410        let mut tick = make_tick_at(1_000);
1411        tick.price = rust_decimal_macros::dec!(100);
1412        assert!(tick.is_below(rust_decimal_macros::dec!(150)));
1413    }
1414
1415    #[test]
1416    fn test_is_below_returns_false_when_price_equal() {
1417        let mut tick = make_tick_at(1_000);
1418        tick.price = rust_decimal_macros::dec!(100);
1419        assert!(!tick.is_below(rust_decimal_macros::dec!(100)));
1420    }
1421
1422    #[test]
1423    fn test_is_below_returns_false_when_price_higher() {
1424        let mut tick = make_tick_at(1_000);
1425        tick.price = rust_decimal_macros::dec!(200);
1426        assert!(!tick.is_below(rust_decimal_macros::dec!(100)));
1427    }
1428
1429    // --- has_exchange_ts ---
1430
1431    #[test]
1432    fn test_has_exchange_ts_false_when_none() {
1433        let tick = make_tick_at(1_000);
1434        assert!(!tick.has_exchange_ts());
1435    }
1436
1437    #[test]
1438    fn test_has_exchange_ts_true_when_some() {
1439        let tick = make_tick_at(1_000).with_exchange_ts(900);
1440        assert!(tick.has_exchange_ts());
1441    }
1442
1443    // ── NormalizedTick::is_at ─────────────────────────────────────────────────
1444
1445    #[test]
1446    fn test_is_at_returns_true_when_equal() {
1447        let mut tick = make_tick_at(1_000);
1448        tick.price = rust_decimal_macros::dec!(100);
1449        assert!(tick.is_at(rust_decimal_macros::dec!(100)));
1450    }
1451
1452    #[test]
1453    fn test_is_at_returns_false_when_higher() {
1454        let mut tick = make_tick_at(1_000);
1455        tick.price = rust_decimal_macros::dec!(101);
1456        assert!(!tick.is_at(rust_decimal_macros::dec!(100)));
1457    }
1458
1459    #[test]
1460    fn test_is_at_returns_false_when_lower() {
1461        let mut tick = make_tick_at(1_000);
1462        tick.price = rust_decimal_macros::dec!(99);
1463        assert!(!tick.is_at(rust_decimal_macros::dec!(100)));
1464    }
1465
1466    // ── NormalizedTick::is_buy ────────────────────────────────────────────────
1467
1468    #[test]
1469    fn test_is_buy_true_when_side_is_buy() {
1470        let mut tick = make_tick_at(1_000);
1471        tick.side = Some(TradeSide::Buy);
1472        assert!(tick.is_buy());
1473    }
1474
1475    #[test]
1476    fn test_is_buy_false_when_side_is_sell() {
1477        let mut tick = make_tick_at(1_000);
1478        tick.side = Some(TradeSide::Sell);
1479        assert!(!tick.is_buy());
1480    }
1481
1482    #[test]
1483    fn test_is_buy_false_when_side_is_none() {
1484        let mut tick = make_tick_at(1_000);
1485        tick.side = None;
1486        assert!(!tick.is_buy());
1487    }
1488
1489    // --- side_str / is_round_lot ---
1490
1491    #[test]
1492    fn test_side_str_buy() {
1493        let mut tick = make_tick_at(1_000);
1494        tick.side = Some(TradeSide::Buy);
1495        assert_eq!(tick.side_str(), "buy");
1496    }
1497
1498    #[test]
1499    fn test_side_str_sell() {
1500        let mut tick = make_tick_at(1_000);
1501        tick.side = Some(TradeSide::Sell);
1502        assert_eq!(tick.side_str(), "sell");
1503    }
1504
1505    #[test]
1506    fn test_side_str_unknown_when_none() {
1507        let mut tick = make_tick_at(1_000);
1508        tick.side = None;
1509        assert_eq!(tick.side_str(), "unknown");
1510    }
1511
1512    #[test]
1513    fn test_is_round_lot_true_for_integer_quantity() {
1514        let mut tick = make_tick_at(1_000);
1515        tick.quantity = rust_decimal_macros::dec!(100);
1516        assert!(tick.is_round_lot());
1517    }
1518
1519    #[test]
1520    fn test_is_round_lot_false_for_fractional_quantity() {
1521        let mut tick = make_tick_at(1_000);
1522        tick.quantity = rust_decimal_macros::dec!(0.5);
1523        assert!(!tick.is_round_lot());
1524    }
1525
1526    // --- is_same_symbol_as / price_distance_from ---
1527
1528    #[test]
1529    fn test_is_same_symbol_as_true_when_symbols_match() {
1530        let t1 = make_tick_at(1_000);
1531        let t2 = make_tick_at(2_000);
1532        assert!(t1.is_same_symbol_as(&t2));
1533    }
1534
1535    #[test]
1536    fn test_is_same_symbol_as_false_when_symbols_differ() {
1537        let t1 = make_tick_at(1_000);
1538        let mut t2 = make_tick_at(2_000);
1539        t2.symbol = "ETH-USD".to_string();
1540        assert!(!t1.is_same_symbol_as(&t2));
1541    }
1542
1543    #[test]
1544    fn test_price_distance_from_is_absolute() {
1545        let mut t1 = make_tick_at(1_000);
1546        let mut t2 = make_tick_at(2_000);
1547        t1.price = rust_decimal_macros::dec!(100);
1548        t2.price = rust_decimal_macros::dec!(110);
1549        assert_eq!(t1.price_distance_from(&t2), rust_decimal_macros::dec!(10));
1550        assert_eq!(t2.price_distance_from(&t1), rust_decimal_macros::dec!(10));
1551    }
1552
1553    #[test]
1554    fn test_price_distance_from_zero_when_equal() {
1555        let t1 = make_tick_at(1_000);
1556        let t2 = make_tick_at(2_000);
1557        assert!(t1.price_distance_from(&t2).is_zero());
1558    }
1559
1560    // ── NormalizedTick::is_sell ───────────────────────────────────────────────
1561
1562    #[test]
1563    fn test_is_sell_true_when_side_is_sell() {
1564        let mut tick = make_tick_at(1_000);
1565        tick.side = Some(TradeSide::Sell);
1566        assert!(tick.is_sell());
1567    }
1568
1569    #[test]
1570    fn test_is_sell_false_when_side_is_buy() {
1571        let mut tick = make_tick_at(1_000);
1572        tick.side = Some(TradeSide::Buy);
1573        assert!(!tick.is_sell());
1574    }
1575
1576    #[test]
1577    fn test_is_sell_false_when_side_is_none() {
1578        let mut tick = make_tick_at(1_000);
1579        tick.side = None;
1580        assert!(!tick.is_sell());
1581    }
1582
1583    // --- exchange_latency_ms / is_notional_large_trade ---
1584
1585    #[test]
1586    fn test_exchange_latency_ms_positive_for_normal_delivery() {
1587        let mut tick = make_tick_at(1_100);
1588        tick.exchange_ts_ms = Some(1_000);
1589        assert_eq!(tick.exchange_latency_ms(), Some(100));
1590    }
1591
1592    #[test]
1593    fn test_exchange_latency_ms_negative_for_clock_skew() {
1594        let mut tick = make_tick_at(1_000);
1595        tick.exchange_ts_ms = Some(1_100);
1596        assert_eq!(tick.exchange_latency_ms(), Some(-100));
1597    }
1598
1599    #[test]
1600    fn test_exchange_latency_ms_none_when_no_exchange_ts() {
1601        let mut tick = make_tick_at(1_000);
1602        tick.exchange_ts_ms = None;
1603        assert!(tick.exchange_latency_ms().is_none());
1604    }
1605
1606    #[test]
1607    fn test_is_notional_large_trade_true_when_above_threshold() {
1608        let mut tick = make_tick_at(1_000);
1609        tick.price = rust_decimal_macros::dec!(100);
1610        tick.quantity = rust_decimal_macros::dec!(10);
1611        // notional = 1000, threshold = 500 → true
1612        assert!(tick.is_notional_large_trade(rust_decimal_macros::dec!(500)));
1613    }
1614
1615    #[test]
1616    fn test_is_notional_large_trade_false_when_at_or_below_threshold() {
1617        let mut tick = make_tick_at(1_000);
1618        tick.price = rust_decimal_macros::dec!(100);
1619        tick.quantity = rust_decimal_macros::dec!(5);
1620        // notional = 500, threshold = 500 → false (strictly greater)
1621        assert!(!tick.is_notional_large_trade(rust_decimal_macros::dec!(500)));
1622    }
1623
1624    #[test]
1625    fn test_is_aggressive_true_when_buy() {
1626        let mut tick = make_tick_at(1_000);
1627        tick.side = Some(TradeSide::Buy);
1628        assert!(tick.is_aggressive());
1629    }
1630
1631    #[test]
1632    fn test_is_aggressive_true_when_sell() {
1633        let mut tick = make_tick_at(1_000);
1634        tick.side = Some(TradeSide::Sell);
1635        assert!(tick.is_aggressive());
1636    }
1637
1638    #[test]
1639    fn test_is_aggressive_false_when_neutral() {
1640        let tick = make_tick_at(1_000); // side = None
1641        assert!(!tick.is_aggressive());
1642    }
1643
1644    #[test]
1645    fn test_price_diff_from_positive_when_higher() {
1646        let mut t1 = make_tick_at(1_000);
1647        let mut t2 = make_tick_at(1_000);
1648        t1.price = rust_decimal_macros::dec!(105);
1649        t2.price = rust_decimal_macros::dec!(100);
1650        assert_eq!(t1.price_diff_from(&t2), rust_decimal_macros::dec!(5));
1651    }
1652
1653    #[test]
1654    fn test_price_diff_from_negative_when_lower() {
1655        let mut t1 = make_tick_at(1_000);
1656        let mut t2 = make_tick_at(1_000);
1657        t1.price = rust_decimal_macros::dec!(95);
1658        t2.price = rust_decimal_macros::dec!(100);
1659        assert_eq!(t1.price_diff_from(&t2), rust_decimal_macros::dec!(-5));
1660    }
1661
1662    #[test]
1663    fn test_is_micro_trade_true_when_below_threshold() {
1664        let mut tick = make_tick_at(1_000);
1665        tick.quantity = rust_decimal_macros::dec!(0.5);
1666        assert!(tick.is_micro_trade(rust_decimal_macros::dec!(1)));
1667    }
1668
1669    #[test]
1670    fn test_is_micro_trade_false_when_equal_threshold() {
1671        let mut tick = make_tick_at(1_000);
1672        tick.quantity = rust_decimal_macros::dec!(1);
1673        assert!(!tick.is_micro_trade(rust_decimal_macros::dec!(1)));
1674    }
1675
1676    #[test]
1677    fn test_is_micro_trade_false_when_above_threshold() {
1678        let mut tick = make_tick_at(1_000);
1679        tick.quantity = rust_decimal_macros::dec!(2);
1680        assert!(!tick.is_micro_trade(rust_decimal_macros::dec!(1)));
1681    }
1682
1683    // --- is_zero_price / is_fresh ---
1684
1685    #[test]
1686    fn test_is_zero_price_true_for_zero() {
1687        let mut tick = make_tick_at(1_000);
1688        tick.price = rust_decimal_macros::dec!(0);
1689        assert!(tick.is_zero_price());
1690    }
1691
1692    #[test]
1693    fn test_is_zero_price_false_for_nonzero() {
1694        let tick = make_tick_at(1_000); // price set by make_tick_at
1695        assert!(!tick.is_zero_price());
1696    }
1697
1698    #[test]
1699    fn test_is_fresh_true_when_within_age() {
1700        let tick = make_tick_at(1_000);
1701        // received_at = 1000, now = 2000, max_age = 1500 → 1000 <= 1500 → fresh
1702        assert!(tick.is_fresh(2_000, 1_500));
1703    }
1704
1705    #[test]
1706    fn test_is_fresh_false_when_too_old() {
1707        let tick = make_tick_at(1_000);
1708        // received_at = 1000, now = 5000, max_age = 2000 → 4000 > 2000 → not fresh
1709        assert!(!tick.is_fresh(5_000, 2_000));
1710    }
1711
1712    #[test]
1713    fn test_is_fresh_true_when_now_less_than_received() {
1714        // Clock skew: now < received_at → saturating_sub = 0 ≤ max_age
1715        let tick = make_tick_at(5_000);
1716        assert!(tick.is_fresh(3_000, 100));
1717    }
1718
1719    // --- NormalizedTick::age_ms ---
1720    #[test]
1721    fn test_age_ms_correct_elapsed() {
1722        let tick = make_tick_at(10_000);
1723        assert_eq!(tick.age_ms(10_500), 500);
1724    }
1725
1726    #[test]
1727    fn test_age_ms_zero_when_now_equals_received() {
1728        let tick = make_tick_at(10_000);
1729        assert_eq!(tick.age_ms(10_000), 0);
1730    }
1731
1732    #[test]
1733    fn test_age_ms_zero_when_now_before_received() {
1734        let tick = make_tick_at(10_000);
1735        assert_eq!(tick.age_ms(9_000), 0);
1736    }
1737
1738    // --- NormalizedTick::is_buying_pressure ---
1739    #[test]
1740    fn test_is_buying_pressure_true_above_midpoint() {
1741        use rust_decimal_macros::dec;
1742        let mut tick = make_tick_at(0);
1743        tick.price = dec!(100.50);
1744        assert!(tick.is_buying_pressure(dec!(100)));
1745    }
1746
1747    #[test]
1748    fn test_is_buying_pressure_false_below_midpoint() {
1749        use rust_decimal_macros::dec;
1750        let mut tick = make_tick_at(0);
1751        tick.price = dec!(99.50);
1752        assert!(!tick.is_buying_pressure(dec!(100)));
1753    }
1754
1755    #[test]
1756    fn test_is_buying_pressure_false_at_midpoint() {
1757        use rust_decimal_macros::dec;
1758        let mut tick = make_tick_at(0);
1759        tick.price = dec!(100);
1760        assert!(!tick.is_buying_pressure(dec!(100)));
1761    }
1762
1763    // --- NormalizedTick::rounded_price ---
1764    #[test]
1765    fn test_rounded_price_rounds_to_nearest_tick() {
1766        use rust_decimal_macros::dec;
1767        let mut tick = make_tick_at(0);
1768        tick.price = dec!(100.37);
1769        // tick_size = 0.25 → 100.25
1770        assert_eq!(tick.rounded_price(dec!(0.25)), dec!(100.25));
1771    }
1772
1773    #[test]
1774    fn test_rounded_price_unchanged_when_already_aligned() {
1775        use rust_decimal_macros::dec;
1776        let mut tick = make_tick_at(0);
1777        tick.price = dec!(100.50);
1778        assert_eq!(tick.rounded_price(dec!(0.25)), dec!(100.50));
1779    }
1780
1781    #[test]
1782    fn test_rounded_price_returns_original_for_zero_tick_size() {
1783        use rust_decimal_macros::dec;
1784        let mut tick = make_tick_at(0);
1785        tick.price = dec!(99.99);
1786        assert_eq!(tick.rounded_price(dec!(0)), dec!(99.99));
1787    }
1788
1789    // --- NormalizedTick::is_large_spread_from ---
1790    #[test]
1791    fn test_is_large_spread_from_true_when_large() {
1792        use rust_decimal_macros::dec;
1793        let mut t1 = make_tick_at(0);
1794        let mut t2 = make_tick_at(0);
1795        t1.price = dec!(100);
1796        t2.price = dec!(110);
1797        assert!(t1.is_large_spread_from(&t2, dec!(5)));
1798    }
1799
1800    #[test]
1801    fn test_is_large_spread_from_false_when_small() {
1802        use rust_decimal_macros::dec;
1803        let mut t1 = make_tick_at(0);
1804        let mut t2 = make_tick_at(0);
1805        t1.price = dec!(100);
1806        t2.price = dec!(101);
1807        assert!(!t1.is_large_spread_from(&t2, dec!(5)));
1808    }
1809
1810    // ── NormalizedTick::age_secs ──────────────────────────────────────────────
1811
1812    #[test]
1813    fn test_age_secs_correct() {
1814        let tick = make_tick_at(1_000);
1815        assert!((tick.age_secs(3_000) - 2.0).abs() < 1e-9);
1816    }
1817
1818    #[test]
1819    fn test_age_secs_zero_when_now_equals_received() {
1820        let tick = make_tick_at(5_000);
1821        assert_eq!(tick.age_secs(5_000), 0.0);
1822    }
1823
1824    #[test]
1825    fn test_age_secs_zero_when_now_before_received() {
1826        let tick = make_tick_at(5_000);
1827        assert_eq!(tick.age_secs(1_000), 0.0);
1828    }
1829
1830    // ── NormalizedTick::is_same_exchange_as ───────────────────────────────────
1831
1832    #[test]
1833    fn test_is_same_exchange_as_true_when_matching() {
1834        let t1 = make_tick_at(1_000); // Binance
1835        let t2 = make_tick_at(2_000); // Binance
1836        assert!(t1.is_same_exchange_as(&t2));
1837    }
1838
1839    #[test]
1840    fn test_is_same_exchange_as_false_when_different() {
1841        let t1 = make_tick_at(1_000); // Binance
1842        let mut t2 = make_tick_at(2_000);
1843        t2.exchange = Exchange::Coinbase;
1844        assert!(!t1.is_same_exchange_as(&t2));
1845    }
1846
1847    // ── NormalizedTick::quote_age_ms / notional_value / is_high_value_tick ──
1848
1849    #[test]
1850    fn test_quote_age_ms_correct() {
1851        let tick = make_tick_at(1_000);
1852        assert_eq!(tick.quote_age_ms(3_000), 2_000);
1853    }
1854
1855    #[test]
1856    fn test_quote_age_ms_zero_when_now_before_received() {
1857        let tick = make_tick_at(5_000);
1858        assert_eq!(tick.quote_age_ms(1_000), 0);
1859    }
1860
1861    #[test]
1862    fn test_notional_value_correct() {
1863        use rust_decimal_macros::dec;
1864        let mut tick = make_tick_at(0);
1865        tick.price = dec!(100);
1866        tick.quantity = dec!(5);
1867        assert_eq!(tick.notional_value(), dec!(500));
1868    }
1869
1870    #[test]
1871    fn test_is_high_value_tick_true_when_above_threshold() {
1872        use rust_decimal_macros::dec;
1873        let mut tick = make_tick_at(0);
1874        tick.price = dec!(100);
1875        tick.quantity = dec!(10);
1876        // notional = 1000 > 500
1877        assert!(tick.is_high_value_tick(dec!(500)));
1878    }
1879
1880    #[test]
1881    fn test_is_high_value_tick_false_when_below_threshold() {
1882        use rust_decimal_macros::dec;
1883        let mut tick = make_tick_at(0);
1884        tick.price = dec!(10);
1885        tick.quantity = dec!(2);
1886        // notional = 20 < 100
1887        assert!(!tick.is_high_value_tick(dec!(100)));
1888    }
1889
1890    // ── NormalizedTick::is_buy_side / is_sell_side / price_in_range ─────────
1891
1892    #[test]
1893    fn test_is_buy_side_true_when_buy() {
1894        let mut tick = make_tick_at(0);
1895        tick.side = Some(TradeSide::Buy);
1896        assert!(tick.is_buy_side());
1897    }
1898
1899    #[test]
1900    fn test_is_buy_side_false_when_sell() {
1901        let mut tick = make_tick_at(0);
1902        tick.side = Some(TradeSide::Sell);
1903        assert!(!tick.is_buy_side());
1904    }
1905
1906    #[test]
1907    fn test_is_buy_side_false_when_none() {
1908        let mut tick = make_tick_at(0);
1909        tick.side = None;
1910        assert!(!tick.is_buy_side());
1911    }
1912
1913    #[test]
1914    fn test_is_sell_side_true_when_sell() {
1915        let mut tick = make_tick_at(0);
1916        tick.side = Some(TradeSide::Sell);
1917        assert!(tick.is_sell_side());
1918    }
1919
1920    #[test]
1921    fn test_price_in_range_true_when_within() {
1922        use rust_decimal_macros::dec;
1923        let mut tick = make_tick_at(0);
1924        tick.price = dec!(100);
1925        assert!(tick.price_in_range(dec!(90), dec!(110)));
1926    }
1927
1928    #[test]
1929    fn test_price_in_range_false_when_below() {
1930        use rust_decimal_macros::dec;
1931        let mut tick = make_tick_at(0);
1932        tick.price = dec!(80);
1933        assert!(!tick.price_in_range(dec!(90), dec!(110)));
1934    }
1935
1936    #[test]
1937    fn test_price_in_range_true_at_boundary() {
1938        use rust_decimal_macros::dec;
1939        let mut tick = make_tick_at(0);
1940        tick.price = dec!(90);
1941        assert!(tick.price_in_range(dec!(90), dec!(110)));
1942    }
1943
1944    // ── NormalizedTick::is_zero_quantity ──────────────────────────────────────
1945
1946    #[test]
1947    fn test_is_zero_quantity_true_when_zero() {
1948        let mut tick = make_tick_at(0);
1949        tick.quantity = Decimal::ZERO;
1950        assert!(tick.is_zero_quantity());
1951    }
1952
1953    #[test]
1954    fn test_is_zero_quantity_false_when_nonzero() {
1955        let mut tick = make_tick_at(0);
1956        tick.quantity = Decimal::ONE;
1957        assert!(!tick.is_zero_quantity());
1958    }
1959
1960    // ── NormalizedTick::is_large_tick ─────────────────────────────────────────
1961
1962    #[test]
1963    fn test_is_large_tick_true_when_above_threshold() {
1964        let mut tick = make_tick_at(0);
1965        tick.quantity = Decimal::from(10u32);
1966        assert!(tick.is_large_tick(Decimal::from(5u32)));
1967    }
1968
1969    #[test]
1970    fn test_is_large_tick_false_when_at_threshold() {
1971        let mut tick = make_tick_at(0);
1972        tick.quantity = Decimal::from(5u32);
1973        assert!(!tick.is_large_tick(Decimal::from(5u32)));
1974    }
1975
1976    #[test]
1977    fn test_is_large_tick_false_when_below_threshold() {
1978        let mut tick = make_tick_at(0);
1979        tick.quantity = Decimal::from(1u32);
1980        assert!(!tick.is_large_tick(Decimal::from(5u32)));
1981    }
1982
1983    // ── NormalizedTick::is_away_from_price ───────────────────────────────────
1984
1985    #[test]
1986    fn test_is_away_from_price_true_when_beyond_threshold() {
1987        let mut tick = make_tick_at(0);
1988        tick.price = Decimal::from(110u32);
1989        // |110 - 100| = 10 > 5
1990        assert!(tick.is_away_from_price(Decimal::from(100u32), Decimal::from(5u32)));
1991    }
1992
1993    #[test]
1994    fn test_is_away_from_price_false_when_at_threshold() {
1995        let mut tick = make_tick_at(0);
1996        tick.price = Decimal::from(105u32);
1997        // |105 - 100| = 5, not > 5
1998        assert!(!tick.is_away_from_price(Decimal::from(100u32), Decimal::from(5u32)));
1999    }
2000
2001    #[test]
2002    fn test_is_away_from_price_false_when_equal() {
2003        let mut tick = make_tick_at(0);
2004        tick.price = Decimal::from(100u32);
2005        assert!(!tick.is_away_from_price(Decimal::from(100u32), Decimal::from(1u32)));
2006    }
2007
2008    // ── NormalizedTick::is_within_spread ──────────────────────────────────────
2009
2010    #[test]
2011    fn test_is_within_spread_true_when_between() {
2012        let mut tick = make_tick_at(0);
2013        tick.price = Decimal::from(100u32);
2014        assert!(tick.is_within_spread(Decimal::from(99u32), Decimal::from(101u32)));
2015    }
2016
2017    #[test]
2018    fn test_is_within_spread_false_when_at_bid() {
2019        let mut tick = make_tick_at(0);
2020        tick.price = Decimal::from(99u32);
2021        assert!(!tick.is_within_spread(Decimal::from(99u32), Decimal::from(101u32)));
2022    }
2023
2024    #[test]
2025    fn test_is_within_spread_false_when_above_ask() {
2026        let mut tick = make_tick_at(0);
2027        tick.price = Decimal::from(102u32);
2028        assert!(!tick.is_within_spread(Decimal::from(99u32), Decimal::from(101u32)));
2029    }
2030
2031    // ── NormalizedTick::is_recent ─────────────────────────────────────────────
2032
2033    #[test]
2034    fn test_is_recent_true_when_within_threshold() {
2035        let tick = make_tick_at(9_500);
2036        // now=10000, threshold=1000 → age=500ms ≤ 1000ms
2037        assert!(tick.is_recent(1_000, 10_000));
2038    }
2039
2040    #[test]
2041    fn test_is_recent_false_when_beyond_threshold() {
2042        let tick = make_tick_at(8_000);
2043        // now=10000, threshold=1000 → age=2000ms > 1000ms
2044        assert!(!tick.is_recent(1_000, 10_000));
2045    }
2046
2047    #[test]
2048    fn test_is_recent_true_at_exact_threshold() {
2049        let tick = make_tick_at(9_000);
2050        // age=1000ms, threshold=1000ms → exactly at threshold
2051        assert!(tick.is_recent(1_000, 10_000));
2052    }
2053
2054    // ── NormalizedTick::side_as_str ───────────────────────────────────────────
2055
2056    #[test]
2057    fn test_side_as_str_buy() {
2058        let mut tick = make_tick_at(0);
2059        tick.side = Some(TradeSide::Buy);
2060        assert_eq!(tick.side_as_str(), Some("buy"));
2061    }
2062
2063    #[test]
2064    fn test_side_as_str_sell() {
2065        let mut tick = make_tick_at(0);
2066        tick.side = Some(TradeSide::Sell);
2067        assert_eq!(tick.side_as_str(), Some("sell"));
2068    }
2069
2070    #[test]
2071    fn test_side_as_str_none_when_unknown() {
2072        let mut tick = make_tick_at(0);
2073        tick.side = None;
2074        assert!(tick.side_as_str().is_none());
2075    }
2076
2077    // ── is_above_price ────────────────────────────────────────────────────────
2078
2079    #[test]
2080    fn test_is_above_price_true_when_strictly_above() {
2081        let tick = make_tick_at(0); // price=100
2082        assert!(tick.is_above_price(rust_decimal_macros::dec!(99)));
2083    }
2084
2085    #[test]
2086    fn test_is_above_price_false_when_equal() {
2087        let tick = make_tick_at(0); // price=100
2088        assert!(!tick.is_above_price(rust_decimal_macros::dec!(100)));
2089    }
2090
2091    #[test]
2092    fn test_is_above_price_false_when_below() {
2093        let tick = make_tick_at(0); // price=100
2094        assert!(!tick.is_above_price(rust_decimal_macros::dec!(101)));
2095    }
2096
2097    // ── price_change_from ─────────────────────────────────────────────────────
2098
2099    #[test]
2100    fn test_price_change_from_positive_when_above_reference() {
2101        let tick = make_tick_at(0); // price=100
2102        assert_eq!(tick.price_change_from(rust_decimal_macros::dec!(90)), rust_decimal_macros::dec!(10));
2103    }
2104
2105    #[test]
2106    fn test_price_change_from_negative_when_below_reference() {
2107        let tick = make_tick_at(0); // price=100
2108        assert_eq!(tick.price_change_from(rust_decimal_macros::dec!(110)), rust_decimal_macros::dec!(-10));
2109    }
2110
2111    #[test]
2112    fn test_price_change_from_zero_when_equal() {
2113        let tick = make_tick_at(0); // price=100
2114        assert_eq!(tick.price_change_from(rust_decimal_macros::dec!(100)), rust_decimal_macros::dec!(0));
2115    }
2116
2117    // ── is_below_price ────────────────────────────────────────────────────────
2118
2119    #[test]
2120    fn test_is_below_price_true_when_strictly_below() {
2121        let tick = make_tick_at(0); // price=100
2122        assert!(tick.is_below_price(rust_decimal_macros::dec!(101)));
2123    }
2124
2125    #[test]
2126    fn test_is_below_price_false_when_equal() {
2127        let tick = make_tick_at(0); // price=100
2128        assert!(!tick.is_below_price(rust_decimal_macros::dec!(100)));
2129    }
2130
2131    // ── quantity_above ────────────────────────────────────────────────────────
2132
2133    #[test]
2134    fn test_quantity_above_true_when_quantity_exceeds_threshold() {
2135        let tick = make_tick_at(0); // quantity=1
2136        assert!(tick.quantity_above(rust_decimal_macros::dec!(0)));
2137    }
2138
2139    #[test]
2140    fn test_quantity_above_false_when_quantity_equals_threshold() {
2141        let tick = make_tick_at(0); // quantity=1
2142        assert!(!tick.quantity_above(rust_decimal_macros::dec!(1)));
2143    }
2144
2145    // ── is_at_price ───────────────────────────────────────────────────────────
2146
2147    #[test]
2148    fn test_is_at_price_true_when_equal() {
2149        let tick = make_tick_at(0); // price=100
2150        assert!(tick.is_at_price(rust_decimal_macros::dec!(100)));
2151    }
2152
2153    #[test]
2154    fn test_is_at_price_false_when_different() {
2155        let tick = make_tick_at(0); // price=100
2156        assert!(!tick.is_at_price(rust_decimal_macros::dec!(101)));
2157    }
2158
2159    // ── is_round_number ───────────────────────────────────────────────────────
2160
2161    #[test]
2162    fn test_is_round_number_true_when_divisible() {
2163        let tick = make_tick_at(0); // price=100
2164        assert!(tick.is_round_number(rust_decimal_macros::dec!(10)));
2165        assert!(tick.is_round_number(rust_decimal_macros::dec!(100)));
2166    }
2167
2168    #[test]
2169    fn test_is_round_number_false_when_not_divisible() {
2170        let tick = make_tick_at(0); // price=100
2171        assert!(!tick.is_round_number(rust_decimal_macros::dec!(3)));
2172    }
2173
2174    #[test]
2175    fn test_is_round_number_false_when_step_zero() {
2176        let tick = make_tick_at(0);
2177        assert!(!tick.is_round_number(rust_decimal_macros::dec!(0)));
2178    }
2179
2180    // ── is_market_open_tick ───────────────────────────────────────────────────
2181
2182    #[test]
2183    fn test_is_market_open_tick_true_when_within_session() {
2184        let tick = make_tick_at(500); // received at ms=500
2185        assert!(tick.is_market_open_tick(100, 1_000));
2186    }
2187
2188    #[test]
2189    fn test_is_market_open_tick_false_when_before_session() {
2190        let tick = make_tick_at(50);
2191        assert!(!tick.is_market_open_tick(100, 1_000));
2192    }
2193
2194    #[test]
2195    fn test_is_market_open_tick_false_when_at_session_end() {
2196        let tick = make_tick_at(1_000);
2197        assert!(!tick.is_market_open_tick(100, 1_000)); // exclusive end
2198    }
2199
2200    // ── signed_quantity ───────────────────────────────────────────────────────
2201
2202    #[test]
2203    fn test_signed_quantity_positive_for_buy() {
2204        let mut tick = make_tick_at(0);
2205        tick.side = Some(TradeSide::Buy);
2206        assert!(tick.signed_quantity() > rust_decimal::Decimal::ZERO);
2207    }
2208
2209    #[test]
2210    fn test_signed_quantity_negative_for_sell() {
2211        let mut tick = make_tick_at(0);
2212        tick.side = Some(TradeSide::Sell);
2213        assert!(tick.signed_quantity() < rust_decimal::Decimal::ZERO);
2214    }
2215
2216    #[test]
2217    fn test_signed_quantity_zero_for_unknown() {
2218        let tick = make_tick_at(0); // side=None
2219        assert_eq!(tick.signed_quantity(), rust_decimal::Decimal::ZERO);
2220    }
2221
2222    // ── as_price_level ────────────────────────────────────────────────────────
2223
2224    #[test]
2225    fn test_as_price_level_returns_price_and_quantity() {
2226        let tick = make_tick_at(0); // price=100, qty=1
2227        let (p, q) = tick.as_price_level();
2228        assert_eq!(p, rust_decimal_macros::dec!(100));
2229        assert_eq!(q, rust_decimal_macros::dec!(1));
2230    }
2231}