Skip to main content

fin_primitives/tick/
mod.rs

1//! # Module: tick
2//!
3//! ## Responsibility
4//! Represents a single market trade (tick), provides filtering, and supports
5//! deterministic replay of tick sequences in timestamp order.
6//!
7//! ## Guarantees
8//! - `Tick::notional()` is always `price * quantity` without rounding
9//! - `TickReplayer` always produces ticks in ascending timestamp order
10//! - `TickReplayer` implements `Iterator<Item = Tick>` (yields cloned ticks)
11//! - `TickFilter::matches` is pure (no side effects)
12//!
13//! ## NOT Responsible For
14//! - Persistence or serialization to external stores
15//! - Cross-symbol aggregation
16
17use crate::types::{NanoTimestamp, Price, Quantity, Side, Symbol};
18use rust_decimal::Decimal;
19
20/// A single market trade event.
21#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
22pub struct Tick {
23    /// The traded instrument.
24    pub symbol: Symbol,
25    /// The trade price (positive).
26    pub price: Price,
27    /// The trade quantity (non-negative).
28    pub quantity: Quantity,
29    /// Whether this was a bid-side or ask-side aggressor.
30    pub side: Side,
31    /// Exchange timestamp in nanoseconds.
32    pub timestamp: NanoTimestamp,
33}
34
35impl Tick {
36    /// Constructs a new `Tick`.
37    pub fn new(
38        symbol: Symbol,
39        price: Price,
40        quantity: Quantity,
41        side: Side,
42        timestamp: NanoTimestamp,
43    ) -> Self {
44        Self {
45            symbol,
46            price,
47            quantity,
48            side,
49            timestamp,
50        }
51    }
52
53    /// Returns the notional value of this tick: `price * quantity`.
54    pub fn notional(&self) -> Decimal {
55        self.price.value() * self.quantity.value()
56    }
57
58    /// Returns the notional value using checked arithmetic, or `None` on overflow.
59    pub fn notional_checked(&self) -> Option<Decimal> {
60        self.price.checked_mul(self.quantity)
61    }
62
63    /// Returns `true` if this tick represents an aggressive buy (bid-side aggressor).
64    pub fn is_buy_aggressor(&self) -> bool {
65        self.side == Side::Bid
66    }
67
68    /// Returns `true` if this tick represents an aggressive sell (ask-side aggressor).
69    pub fn is_sell_aggressor(&self) -> bool {
70        self.side == Side::Ask
71    }
72
73    /// Returns `true` if this tick is on the buy (bid) side.
74    pub fn is_buy(&self) -> bool {
75        self.side == Side::Bid
76    }
77
78    /// Returns `true` if this tick is on the sell (ask) side.
79    pub fn is_sell(&self) -> bool {
80        self.side == Side::Ask
81    }
82
83    /// Returns `true` if this tick's price is strictly higher than `prev`.
84    pub fn is_uptick(&self, prev: &Tick) -> bool {
85        self.price.value() > prev.price.value()
86    }
87
88    /// Returns `true` if this tick's price is strictly lower than `prev`.
89    pub fn is_downtick(&self, prev: &Tick) -> bool {
90        self.price.value() < prev.price.value()
91    }
92
93    /// Returns buy volume minus sell volume for a slice of ticks.
94    ///
95    /// Positive delta indicates net buying pressure; negative indicates net selling.
96    /// Equivalent to `buy_volume - sell_volume`.
97    pub fn delta(ticks: &[Tick]) -> Decimal {
98        ticks.iter().map(|t| {
99            match t.side {
100                Side::Bid => t.quantity.value(),
101                Side::Ask => -t.quantity.value(),
102            }
103        }).sum()
104    }
105
106    /// Returns the running cumulative delta across a tick slice.
107    ///
108    /// Each entry in the returned `Vec` is the running total of
109    /// `buy_volume - sell_volume` up to and including that tick.
110    /// An empty slice returns an empty `Vec`.
111    pub fn cumulative_delta(ticks: &[Tick]) -> Vec<Decimal> {
112        let mut running = Decimal::ZERO;
113        ticks
114            .iter()
115            .map(|t| {
116                match t.side {
117                    Side::Bid => running += t.quantity.value(),
118                    Side::Ask => running -= t.quantity.value(),
119                }
120                running
121            })
122            .collect()
123    }
124
125    /// Returns the simple (unweighted) average price from a slice of ticks.
126    ///
127    /// Returns `None` if the slice is empty. For volume-weighted price, use [`Tick::vwap_from_slice`].
128    pub fn average_price(ticks: &[Tick]) -> Option<Decimal> {
129        if ticks.is_empty() {
130            return None;
131        }
132        #[allow(clippy::cast_possible_truncation)]
133        let sum: Decimal = ticks.iter().map(|t| t.price.value()).sum();
134        Some(sum / Decimal::from(ticks.len() as u32))
135    }
136
137    /// Returns the total bid-side (buy aggressor) volume from a slice of ticks.
138    ///
139    /// Useful for computing buy pressure and delta (buy volume − sell volume).
140    pub fn buy_volume(ticks: &[Tick]) -> Decimal {
141        ticks
142            .iter()
143            .filter(|t| t.side == Side::Bid)
144            .map(|t| t.quantity.value())
145            .sum()
146    }
147
148    /// Returns the total ask-side (sell aggressor) volume from a slice of ticks.
149    ///
150    /// Useful for computing sell pressure and delta (buy volume − sell volume).
151    pub fn sell_volume(ticks: &[Tick]) -> Decimal {
152        ticks
153            .iter()
154            .filter(|t| t.side == Side::Ask)
155            .map(|t| t.quantity.value())
156            .sum()
157    }
158
159    /// Computes the VWAP (volume-weighted average price) over a slice of ticks.
160    ///
161    /// `VWAP = Σ(price * quantity) / Σ(quantity)`
162    ///
163    /// Returns `None` when `ticks` is empty or total quantity is zero.
164    pub fn vwap_from_slice(ticks: &[Tick]) -> Option<Decimal> {
165        let total_qty: Decimal = ticks.iter().map(|t| t.quantity.value()).sum();
166        if total_qty.is_zero() {
167            return None;
168        }
169        let weighted: Decimal = ticks.iter().map(|t| t.price.value() * t.quantity.value()).sum();
170        Some(weighted / total_qty)
171    }
172
173    /// Returns the highest traded price in the slice, or `None` if empty.
174    pub fn max_price(ticks: &[Tick]) -> Option<Price> {
175        ticks.iter().map(|t| t.price).max_by(|a, b| a.value().cmp(&b.value()))
176    }
177
178    /// Returns the lowest traded price in the slice, or `None` if empty.
179    pub fn min_price(ticks: &[Tick]) -> Option<Price> {
180        ticks.iter().map(|t| t.price).min_by(|a, b| a.value().cmp(&b.value()))
181    }
182
183    /// Time-Weighted Average Price from a tick slice.
184    ///
185    /// Each price is weighted by the elapsed nanoseconds since the previous tick.
186    /// The first tick receives zero weight. Returns `None` for slices with fewer than
187    /// 2 ticks or zero total elapsed time.
188    pub fn time_weighted_avg_price(ticks: &[Tick]) -> Option<Decimal> {
189        if ticks.len() < 2 {
190            return None;
191        }
192        let mut total_weight = 0u128;
193        let mut weighted_sum = Decimal::ZERO;
194        for i in 1..ticks.len() {
195            let elapsed = ticks[i].timestamp.nanos()
196                .saturating_sub(ticks[i - 1].timestamp.nanos())
197                .max(0) as u128;
198            total_weight = total_weight.saturating_add(elapsed);
199            #[allow(clippy::cast_possible_truncation)]
200            let w = Decimal::from(elapsed as u64);
201            weighted_sum += ticks[i].price.value() * w;
202        }
203        if total_weight == 0 {
204            return None;
205        }
206        #[allow(clippy::cast_possible_truncation)]
207        Some(weighted_sum / Decimal::from(total_weight as u64))
208    }
209
210    /// Returns the tick with the highest notional value (`price × quantity`) in the slice.
211    ///
212    /// Returns `None` if the slice is empty.
213    pub fn largest_trade(ticks: &[Tick]) -> Option<&Tick> {
214        ticks.iter().max_by(|a, b| {
215            let na = a.price.value() * a.quantity.value();
216            let nb = b.price.value() * b.quantity.value();
217            na.cmp(&nb)
218        })
219    }
220
221    /// Returns a static label classifying the aggressor side of this tick.
222    ///
223    /// - `"market_buy"` when the aggressor is the buyer (`Side::Bid`)
224    /// - `"market_sell"` when the aggressor is the seller (`Side::Ask`)
225    ///
226    /// Useful for logging, display, and building aggressor-pressure histograms.
227    pub fn classify_aggressor(&self) -> &'static str {
228        match self.side {
229            Side::Bid => "market_buy",
230            Side::Ask => "market_sell",
231        }
232    }
233
234    /// Returns buy volume as a fraction of total volume: `buy_vol / (buy_vol + sell_vol)`.
235    ///
236    /// Result is in `[0.0, 1.0]`. Returns `None` when total volume is zero.
237    /// Values above `0.5` indicate net buying pressure; below `0.5` net selling pressure.
238    pub fn imbalance_ratio(ticks: &[Tick]) -> Option<Decimal> {
239        let total: Decimal = ticks.iter().map(|t| t.quantity.value()).sum();
240        if total.is_zero() {
241            return None;
242        }
243        let buy_vol = Self::buy_volume(ticks);
244        Some(buy_vol / total)
245    }
246
247    /// Returns `(buy_count, sell_count)` — tick counts by aggressor side.
248    ///
249    /// Useful for measuring trade-frequency imbalance independently of volume.
250    pub fn count_by_side(ticks: &[Tick]) -> (usize, usize) {
251        let buy = ticks.iter().filter(|t| t.side == Side::Bid).count();
252        let sell = ticks.len() - buy;
253        (buy, sell)
254    }
255
256    /// Returns the total notional value: `Σ(price × quantity)` across all ticks.
257    ///
258    /// Zero when the slice is empty.
259    pub fn notional_volume(ticks: &[Tick]) -> Decimal {
260        ticks.iter().map(|t| t.price.value() * t.quantity.value()).sum()
261    }
262
263    /// Returns tick direction for each tick relative to the prior: `+1` up, `-1` down, `0` flat.
264    ///
265    /// The first tick in the slice has no prior, so it is assigned `0`.
266    /// Returns an empty `Vec` when `ticks` is empty.
267    pub fn tick_direction_series(ticks: &[Tick]) -> Vec<i8> {
268        if ticks.is_empty() {
269            return vec![];
270        }
271        let mut result = Vec::with_capacity(ticks.len());
272        result.push(0i8);
273        for w in ticks.windows(2) {
274            let prev = w[0].price.value();
275            let curr = w[1].price.value();
276            result.push(if curr > prev { 1 } else if curr < prev { -1 } else { 0 });
277        }
278        result
279    }
280
281    /// Returns the median trade price across `ticks`.
282    ///
283    /// Sorts prices and returns the middle value (lower-middle for even counts).
284    /// Returns `None` when the slice is empty.
285    pub fn median_price(ticks: &[Tick]) -> Option<Decimal> {
286        if ticks.is_empty() {
287            return None;
288        }
289        let mut prices: Vec<Decimal> = ticks.iter().map(|t| t.price.value()).collect();
290        prices.sort_unstable_by(|a, b| a.cmp(b));
291        let mid = prices.len() / 2;
292        if prices.len() % 2 == 0 {
293            Some((prices[mid - 1] + prices[mid]) / Decimal::TWO)
294        } else {
295            Some(prices[mid])
296        }
297    }
298
299    /// Average signed price deviation from `ref_price`, weighted by trade size.
300    ///
301    /// `price_impact = Σ((price_i - ref_price) * qty_i) / Σ(qty_i)`
302    ///
303    /// Positive values indicate the flow of trades is above `ref_price` (buying pressure);
304    /// negative values indicate selling pressure below it.
305    ///
306    /// Returns `None` when `ticks` is empty or total quantity is zero.
307    pub fn price_impact(ticks: &[Tick], ref_price: Decimal) -> Option<Decimal> {
308        if ticks.is_empty() {
309            return None;
310        }
311        let total_qty: Decimal = ticks.iter().map(|t| t.quantity.value()).sum();
312        if total_qty.is_zero() {
313            return None;
314        }
315        let weighted_dev: Decimal = ticks
316            .iter()
317            .map(|t| (t.price.value() - ref_price) * t.quantity.value())
318            .sum();
319        Some(weighted_dev / total_qty)
320    }
321
322    /// Counts temporal clusters in a tick slice.
323    ///
324    /// A new cluster begins whenever the gap between consecutive tick timestamps
325    /// exceeds `gap_ns` nanoseconds. A single tick (or empty slice) counts as zero clusters.
326    ///
327    /// Returns `0` for an empty slice. Returns `1` for a single tick.
328    pub fn cluster_count(ticks: &[Tick], gap_ns: u64) -> usize {
329        if ticks.is_empty() {
330            return 0;
331        }
332        let mut clusters = 1usize;
333        for w in ticks.windows(2) {
334            let t0 = w[0].timestamp.nanos() as u64;
335            let t1 = w[1].timestamp.nanos() as u64;
336            if t1.saturating_sub(t0) > gap_ns {
337                clusters += 1;
338            }
339        }
340        clusters
341    }
342}
343
344/// Filters ticks by optional symbol, side, price range, and minimum quantity predicates.
345///
346/// All predicates are `ANDed` together. Unset predicates always pass.
347#[derive(Clone)]
348pub struct TickFilter {
349    symbol: Option<Symbol>,
350    side: Option<Side>,
351    min_qty: Option<Quantity>,
352    max_qty: Option<Quantity>,
353    min_price: Option<Price>,
354    max_price: Option<Price>,
355    min_notional: Option<rust_decimal::Decimal>,
356    max_notional: Option<rust_decimal::Decimal>,
357    from_ts: Option<NanoTimestamp>,
358    to_ts: Option<NanoTimestamp>,
359}
360
361impl TickFilter {
362    /// Creates a new `TickFilter` with no predicates set (matches everything).
363    pub fn new() -> Self {
364        Self {
365            symbol: None,
366            side: None,
367            min_qty: None,
368            max_qty: None,
369            min_price: None,
370            max_price: None,
371            min_notional: None,
372            max_notional: None,
373            from_ts: None,
374            to_ts: None,
375        }
376    }
377
378    /// Restrict matches to ticks with this symbol.
379    #[must_use]
380    pub fn symbol(mut self, s: Symbol) -> Self {
381        self.symbol = Some(s);
382        self
383    }
384
385    /// Restrict matches to ticks on this side.
386    #[must_use]
387    pub fn side(mut self, s: Side) -> Self {
388        self.side = Some(s);
389        self
390    }
391
392    /// Restrict matches to ticks with quantity >= `q`.
393    #[must_use]
394    pub fn min_quantity(mut self, q: Quantity) -> Self {
395        self.min_qty = Some(q);
396        self
397    }
398
399    /// Restrict matches to ticks with quantity <= `q`.
400    #[must_use]
401    pub fn max_quantity(mut self, q: Quantity) -> Self {
402        self.max_qty = Some(q);
403        self
404    }
405
406    /// Restrict matches to ticks with price >= `p`.
407    #[must_use]
408    pub fn min_price(mut self, p: Price) -> Self {
409        self.min_price = Some(p);
410        self
411    }
412
413    /// Restrict matches to ticks with price <= `p`.
414    #[must_use]
415    pub fn max_price(mut self, p: Price) -> Self {
416        self.max_price = Some(p);
417        self
418    }
419
420    /// Restrict matches to ticks with notional (`price * quantity`) >= `n`.
421    #[must_use]
422    pub fn min_notional(mut self, n: rust_decimal::Decimal) -> Self {
423        self.min_notional = Some(n);
424        self
425    }
426
427    /// Restrict matches to ticks with notional (`price * quantity`) <= `n`.
428    #[must_use]
429    pub fn max_notional(mut self, n: rust_decimal::Decimal) -> Self {
430        self.max_notional = Some(n);
431        self
432    }
433
434    /// Restrict matches to ticks whose timestamp falls within `[from, to]` (inclusive).
435    #[must_use]
436    pub fn timestamp_range(mut self, from: NanoTimestamp, to: NanoTimestamp) -> Self {
437        self.from_ts = Some(from);
438        self.to_ts = Some(to);
439        self
440    }
441
442    /// Returns `true` if a symbol predicate has been set on this filter.
443    pub fn has_symbol_filter(&self) -> bool {
444        self.symbol.is_some()
445    }
446
447    /// Returns `true` if a side predicate has been set on this filter.
448    pub fn has_side_filter(&self) -> bool {
449        self.side.is_some()
450    }
451
452    /// Returns `true` if a minimum quantity predicate has been set on this filter.
453    pub fn has_min_qty_filter(&self) -> bool {
454        self.min_qty.is_some()
455    }
456
457    /// Returns `true` if a price range predicate has been set on this filter.
458    pub fn has_price_filter(&self) -> bool {
459        self.min_price.is_some() || self.max_price.is_some()
460    }
461
462    /// Returns `true` if a notional (min or max) predicate has been set on this filter.
463    pub fn has_notional_filter(&self) -> bool {
464        self.min_notional.is_some() || self.max_notional.is_some()
465    }
466
467    /// Resets all predicates, returning a fresh filter that matches every tick.
468    ///
469    /// Allows reuse of a filter builder without allocating a new one.
470    pub fn clear(self) -> Self {
471        Self::new()
472    }
473
474    /// Returns `true` if no predicates are configured — the filter matches any tick.
475    ///
476    /// Callers can skip filter evaluation entirely when no constraints have been set,
477    /// avoiding unnecessary field comparisons on every tick.
478    pub fn is_empty(&self) -> bool {
479        self.symbol.is_none()
480            && self.side.is_none()
481            && self.min_qty.is_none()
482            && self.max_qty.is_none()
483            && self.min_price.is_none()
484            && self.max_price.is_none()
485            && self.min_notional.is_none()
486            && self.max_notional.is_none()
487            && self.from_ts.is_none()
488            && self.to_ts.is_none()
489    }
490
491    /// Returns `true` if the tick satisfies all configured predicates.
492    pub fn matches(&self, tick: &Tick) -> bool {
493        if let Some(ref sym) = self.symbol {
494            if tick.symbol != *sym {
495                return false;
496            }
497        }
498        if let Some(ref side) = self.side {
499            if tick.side != *side {
500                return false;
501            }
502        }
503        if let Some(ref min_qty) = self.min_qty {
504            if tick.quantity < *min_qty {
505                return false;
506            }
507        }
508        if let Some(ref max_qty) = self.max_qty {
509            if tick.quantity > *max_qty {
510                return false;
511            }
512        }
513        if let Some(ref min_p) = self.min_price {
514            if tick.price < *min_p {
515                return false;
516            }
517        }
518        if let Some(ref max_p) = self.max_price {
519            if tick.price > *max_p {
520                return false;
521            }
522        }
523        if let Some(ref min_n) = self.min_notional {
524            if tick.notional() < *min_n {
525                return false;
526            }
527        }
528        if let Some(ref max_n) = self.max_notional {
529            if tick.notional() > *max_n {
530                return false;
531            }
532        }
533        if let Some(from) = self.from_ts {
534            if tick.timestamp.is_before(from) {
535                return false;
536            }
537        }
538        if let Some(to) = self.to_ts {
539            if tick.timestamp.is_after(to) {
540                return false;
541            }
542        }
543        true
544    }
545
546    /// Returns the number of ticks in `ticks` that satisfy all predicates.
547    ///
548    /// Equivalent to `ticks.iter().filter(|t| self.matches(t)).count()` but
549    /// avoids allocating a filtered collection.
550    pub fn count_matches(&self, ticks: &[Tick]) -> usize {
551        ticks.iter().filter(|t| self.matches(t)).count()
552    }
553}
554
555impl Default for TickFilter {
556    fn default() -> Self {
557        Self::new()
558    }
559}
560
561/// Replays a collection of ticks in ascending timestamp order.
562pub struct TickReplayer {
563    ticks: Vec<Tick>,
564    index: usize,
565}
566
567impl TickReplayer {
568    /// Constructs a `TickReplayer`, sorting `ticks` by timestamp ascending.
569    pub fn new(mut ticks: Vec<Tick>) -> Self {
570        ticks.sort_by_key(|t| t.timestamp);
571        Self { ticks, index: 0 }
572    }
573
574    /// Returns the next tick in timestamp order, or `None` if exhausted.
575    pub fn next_tick(&mut self) -> Option<&Tick> {
576        let tick = self.ticks.get(self.index)?;
577        self.index += 1;
578        Some(tick)
579    }
580
581    /// Returns the number of ticks not yet yielded.
582    pub fn remaining(&self) -> usize {
583        self.ticks.len().saturating_sub(self.index)
584    }
585
586    /// Returns a reference to the next tick without advancing the position.
587    pub fn peek(&self) -> Option<&Tick> {
588        self.ticks.get(self.index)
589    }
590
591    /// Returns a shared reference to all ticks in sorted order.
592    pub fn ticks(&self) -> &[Tick] {
593        &self.ticks
594    }
595
596    /// Resets the replayer to the beginning of the tick sequence.
597    pub fn reset(&mut self) {
598        self.index = 0;
599    }
600
601    /// Returns the total number of ticks (including already-yielded ones).
602    pub fn count(&self) -> usize {
603        self.ticks.len()
604    }
605
606    /// Returns the volume-weighted average price (VWAP) across all ticks.
607    ///
608    /// `VWAP = Σ(price × quantity) / Σ(quantity)`.
609    ///
610    /// Returns `None` if no ticks are loaded or total volume is zero.
611    pub fn vwap(&self) -> Option<Decimal> {
612        let total_vol: Decimal = self.ticks.iter().map(|t| t.quantity.value()).sum();
613        if total_vol.is_zero() {
614            return None;
615        }
616        let total_notional: Decimal = self.ticks.iter().map(|t| t.notional()).sum();
617        Some(total_notional / total_vol)
618    }
619
620    /// Returns all ticks (from the full sorted slice) that match `filter`.
621    pub fn filter_ticks(&self, filter: &TickFilter) -> Vec<Tick> {
622        self.ticks
623            .iter()
624            .filter(|t| filter.matches(t))
625            .cloned()
626            .collect()
627    }
628
629    /// Returns all ticks whose timestamp falls within `[from, to]` (inclusive).
630    pub fn between(&self, from: NanoTimestamp, to: NanoTimestamp) -> Vec<Tick> {
631        self.ticks
632            .iter()
633            .filter(|t| !t.timestamp.is_before(from) && !t.timestamp.is_after(to))
634            .cloned()
635            .collect()
636    }
637
638    /// Returns the net delta: `buy_volume - sell_volume`.
639    ///
640    /// Positive = net buying pressure, negative = net selling pressure.
641    pub fn delta(&self) -> Decimal {
642        self.buy_volume() - self.sell_volume()
643    }
644
645    /// Returns the nanosecond time span from the first to the last tick.
646    ///
647    /// Returns `None` if there are fewer than 2 ticks.
648    pub fn time_span_nanos(&self) -> Option<i64> {
649        if self.ticks.len() < 2 {
650            return None;
651        }
652        let first = self.ticks.first().unwrap().timestamp;
653        let last = self.ticks.last().unwrap().timestamp;
654        Some(last.elapsed_since(first))
655    }
656
657    /// Returns the sum of notional values (`price × quantity`) across all ticks.
658    pub fn total_notional(&self) -> Decimal {
659        self.ticks.iter().map(|t| t.notional()).sum()
660    }
661
662    /// Returns the total volume of all bid-side (buy) ticks.
663    pub fn buy_volume(&self) -> Decimal {
664        self.ticks
665            .iter()
666            .filter(|t| t.side == Side::Bid)
667            .map(|t| t.quantity.value())
668            .sum()
669    }
670
671    /// Returns the total volume of all ask-side (sell) ticks.
672    pub fn sell_volume(&self) -> Decimal {
673        self.ticks
674            .iter()
675            .filter(|t| t.side == Side::Ask)
676            .map(|t| t.quantity.value())
677            .sum()
678    }
679
680    /// Returns a reference to the first tick in the replay sequence, or `None` if empty.
681    pub fn first(&self) -> Option<&Tick> {
682        self.ticks.first()
683    }
684
685    /// Returns a reference to the last tick in the replay sequence, or `None` if empty.
686    pub fn last(&self) -> Option<&Tick> {
687        self.ticks.last()
688    }
689
690    /// Returns the VWAP for bid-side and ask-side ticks separately.
691    ///
692    /// The tuple is `(bid_vwap, ask_vwap)`. Either element is `None` if there
693    /// are no ticks on that side or total volume for that side is zero.
694    pub fn vwap_by_side(&self) -> (Option<Decimal>, Option<Decimal>) {
695        let mut bid_notional = Decimal::ZERO;
696        let mut bid_vol = Decimal::ZERO;
697        let mut ask_notional = Decimal::ZERO;
698        let mut ask_vol = Decimal::ZERO;
699        for tick in &self.ticks {
700            let vol = tick.quantity.value();
701            let notional = tick.notional();
702            match tick.side {
703                Side::Bid => {
704                    bid_notional += notional;
705                    bid_vol += vol;
706                }
707                Side::Ask => {
708                    ask_notional += notional;
709                    ask_vol += vol;
710                }
711            }
712        }
713        let bid_vwap = if bid_vol.is_zero() { None } else { Some(bid_notional / bid_vol) };
714        let ask_vwap = if ask_vol.is_zero() { None } else { Some(ask_notional / ask_vol) };
715        (bid_vwap, ask_vwap)
716    }
717
718    /// Groups all ticks in this replayer by symbol.
719    ///
720    /// Returns a `HashMap` mapping each symbol to a `Vec<Tick>` in timestamp order.
721    /// Ticks are cloned.
722    pub fn collect_by_symbol(&self) -> std::collections::HashMap<Symbol, Vec<Tick>> {
723        let mut map: std::collections::HashMap<Symbol, Vec<Tick>> = std::collections::HashMap::new();
724        for tick in &self.ticks {
725            map.entry(tick.symbol.clone()).or_default().push(tick.clone());
726        }
727        map
728    }
729
730    /// Returns the price range across all ticks: `max_price - min_price`.
731    ///
732    /// Returns `None` if there are no ticks.
733    pub fn price_range(&self) -> Option<Decimal> {
734        let mut max_p = self.ticks.first()?.price.value();
735        let mut min_p = max_p;
736        for t in &self.ticks {
737            let p = t.price.value();
738            if p > max_p { max_p = p; }
739            if p < min_p { min_p = p; }
740        }
741        Some(max_p - min_p)
742    }
743
744    /// Returns a `(bid_count, ask_count)` tuple for the number of ticks on each side.
745    pub fn tick_count_by_side(&self) -> (usize, usize) {
746        let bid = self.ticks.iter().filter(|t| t.side == Side::Bid).count();
747        let ask = self.ticks.iter().filter(|t| t.side == Side::Ask).count();
748        (bid, ask)
749    }
750
751    /// Returns the median trade size (quantity) across all ticks.
752    ///
753    /// Uses the lower median for even-length sets. Returns `None` if empty.
754    pub fn median_trade_size(&self) -> Option<Decimal> {
755        if self.ticks.is_empty() {
756            return None;
757        }
758        let mut sizes: Vec<Decimal> = self.ticks.iter().map(|t| t.quantity.value()).collect();
759        sizes.sort();
760        Some(sizes[sizes.len() / 2])
761    }
762
763    /// Returns the arithmetic mean tick quantity across all ticks.
764    ///
765    /// Returns `None` if there are no ticks.
766    pub fn avg_trade_size(&self) -> Option<Decimal> {
767        if self.ticks.is_empty() {
768            return None;
769        }
770        let sum: Decimal = self.ticks.iter().map(|t| t.quantity.value()).sum();
771        #[allow(clippy::cast_possible_truncation)]
772        Some(sum / Decimal::from(self.ticks.len() as u64))
773    }
774
775    /// Returns the mean nanosecond interval between consecutive ticks.
776    ///
777    /// Returns `None` if there are fewer than 2 ticks.
778    pub fn tick_interval_mean_nanos(&self) -> Option<i64> {
779        if self.ticks.len() < 2 {
780            return None;
781        }
782        let total = self.ticks.last().unwrap().timestamp.elapsed_since(self.ticks.first().unwrap().timestamp);
783        Some(total / (self.ticks.len() as i64 - 1))
784    }
785
786    /// Returns the standard deviation of trade prices in the batch.
787    ///
788    /// Uses the sample standard deviation (`n - 1` denominator).
789    /// Returns `None` when fewer than 2 ticks are present.
790    #[allow(clippy::cast_possible_truncation)]
791    pub fn price_std(&self) -> Option<Decimal> {
792        if self.ticks.len() < 2 {
793            return None;
794        }
795        let prices: Vec<Decimal> = self.ticks.iter().map(|t| t.price.value()).collect();
796        let n = prices.len();
797        let mean = prices.iter().copied().sum::<Decimal>() / Decimal::from(n as u32);
798        let variance = prices
799            .iter()
800            .map(|p| { let d = *p - mean; d * d })
801            .sum::<Decimal>()
802            / Decimal::from((n - 1) as u32);
803        use rust_decimal::prelude::ToPrimitive;
804        let std = variance.to_f64()?.sqrt();
805        Decimal::try_from(std).ok()
806    }
807
808    /// Returns the bid-ask imbalance: `(bid_volume - ask_volume) / total_volume`.
809    ///
810    /// Values near +1 indicate heavy buying pressure; near -1 indicate heavy selling pressure.
811    /// Returns `None` if total volume is zero or there are no ticks.
812    pub fn bid_ask_imbalance(&self) -> Option<Decimal> {
813        let total: Decimal = self.ticks.iter().map(|t| t.quantity.value()).sum();
814        if total.is_zero() {
815            return None;
816        }
817        let bid_vol: Decimal = self
818            .ticks
819            .iter()
820            .filter(|t| t.side == Side::Bid)
821            .map(|t| t.quantity.value())
822            .sum();
823        let ask_vol = total - bid_vol;
824        Some((bid_vol - ask_vol) / total)
825    }
826
827    /// Returns tick count per second over the time span of the batch.
828    ///
829    /// Returns `None` if fewer than 2 ticks are present or the time span is zero.
830    pub fn tick_velocity_per_second(&self) -> Option<f64> {
831        if self.ticks.len() < 2 {
832            return None;
833        }
834        let span_nanos = self
835            .ticks
836            .last()
837            .unwrap()
838            .timestamp
839            .elapsed_since(self.ticks.first().unwrap().timestamp);
840        if span_nanos <= 0 {
841            return None;
842        }
843        Some(self.ticks.len() as f64 / (span_nanos as f64 / 1_000_000_000.0))
844    }
845}
846
847impl Iterator for TickReplayer {
848    type Item = Tick;
849
850    fn next(&mut self) -> Option<Self::Item> {
851        let tick = self.ticks.get(self.index)?.clone();
852        self.index += 1;
853        Some(tick)
854    }
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use rust_decimal_macros::dec;
861
862    fn make_tick(sym: &str, price: &str, qty: &str, side: Side, ts: i64) -> Tick {
863        Tick::new(
864            Symbol::new(sym).unwrap(),
865            Price::new(dec_from_str(price)).unwrap(),
866            Quantity::new(dec_from_str(qty)).unwrap(),
867            side,
868            NanoTimestamp::new(ts),
869        )
870    }
871
872    fn dec_from_str(s: &str) -> Decimal {
873        s.parse().unwrap()
874    }
875
876    #[test]
877    fn test_tick_notional_is_price_times_quantity() {
878        let t = make_tick("AAPL", "150.00", "10", Side::Ask, 0);
879        assert_eq!(t.notional(), dec!(1500.00));
880    }
881
882    #[test]
883    fn test_tick_notional_zero_quantity() {
884        let t = make_tick("AAPL", "150.00", "0", Side::Ask, 0);
885        assert_eq!(t.notional(), dec!(0));
886    }
887
888    #[test]
889    fn test_tick_filter_no_predicates_matches_all() {
890        let f = TickFilter::new();
891        let t = make_tick("AAPL", "1", "1", Side::Bid, 0);
892        assert!(f.matches(&t));
893    }
894
895    #[test]
896    fn test_tick_filter_by_symbol() {
897        let sym = Symbol::new("AAPL").unwrap();
898        let f = TickFilter::new().symbol(sym);
899        let matching = make_tick("AAPL", "1", "1", Side::Bid, 0);
900        let non_matching = make_tick("TSLA", "1", "1", Side::Bid, 0);
901        assert!(f.matches(&matching));
902        assert!(!f.matches(&non_matching));
903    }
904
905    #[test]
906    fn test_tick_filter_by_side() {
907        let f = TickFilter::new().side(Side::Ask);
908        let ask_tick = make_tick("AAPL", "1", "1", Side::Ask, 0);
909        let bid_tick = make_tick("AAPL", "1", "1", Side::Bid, 0);
910        assert!(f.matches(&ask_tick));
911        assert!(!f.matches(&bid_tick));
912    }
913
914    #[test]
915    fn test_tick_filter_by_min_quantity() {
916        let min_qty = Quantity::new(dec!(5)).unwrap();
917        let f = TickFilter::new().min_quantity(min_qty);
918        let large = make_tick("AAPL", "1", "10", Side::Bid, 0);
919        let small = make_tick("AAPL", "1", "2", Side::Bid, 0);
920        assert!(f.matches(&large));
921        assert!(!f.matches(&small));
922    }
923
924    #[test]
925    fn test_tick_filter_by_max_quantity() {
926        let max_qty = Quantity::new(dec!(5)).unwrap();
927        let f = TickFilter::new().max_quantity(max_qty);
928        let small = make_tick("AAPL", "1", "3", Side::Bid, 0);
929        let large = make_tick("AAPL", "1", "10", Side::Bid, 0);
930        assert!(f.matches(&small));
931        assert!(!f.matches(&large));
932    }
933
934    #[test]
935    fn test_tick_filter_quantity_range() {
936        let min_qty = Quantity::new(dec!(3)).unwrap();
937        let max_qty = Quantity::new(dec!(7)).unwrap();
938        let f = TickFilter::new().min_quantity(min_qty).max_quantity(max_qty);
939        assert!(f.matches(&make_tick("X", "1", "5", Side::Bid, 0)));
940        assert!(!f.matches(&make_tick("X", "1", "2", Side::Bid, 0)));
941        assert!(!f.matches(&make_tick("X", "1", "10", Side::Bid, 0)));
942    }
943
944    #[test]
945    fn test_tick_filter_by_min_price() {
946        let min_p = Price::new(dec!(100)).unwrap();
947        let f = TickFilter::new().min_price(min_p);
948        let high = make_tick("AAPL", "150", "1", Side::Bid, 0);
949        let low = make_tick("AAPL", "50", "1", Side::Bid, 0);
950        assert!(f.matches(&high));
951        assert!(!f.matches(&low));
952    }
953
954    #[test]
955    fn test_tick_filter_by_max_price() {
956        let max_p = Price::new(dec!(100)).unwrap();
957        let f = TickFilter::new().max_price(max_p);
958        let low = make_tick("AAPL", "50", "1", Side::Bid, 0);
959        let high = make_tick("AAPL", "150", "1", Side::Bid, 0);
960        assert!(f.matches(&low));
961        assert!(!f.matches(&high));
962    }
963
964    #[test]
965    fn test_tick_filter_price_range() {
966        let min_p = Price::new(dec!(90)).unwrap();
967        let max_p = Price::new(dec!(110)).unwrap();
968        let f = TickFilter::new().min_price(min_p).max_price(max_p);
969        assert!(f.matches(&make_tick("X", "100", "1", Side::Bid, 0)));
970        assert!(!f.matches(&make_tick("X", "80", "1", Side::Bid, 0)));
971        assert!(!f.matches(&make_tick("X", "120", "1", Side::Bid, 0)));
972    }
973
974    #[test]
975    fn test_tick_filter_combined_predicates() {
976        let sym = Symbol::new("AAPL").unwrap();
977        let min_qty = Quantity::new(dec!(5)).unwrap();
978        let f = TickFilter::new()
979            .symbol(sym)
980            .side(Side::Bid)
981            .min_quantity(min_qty);
982        let ok = make_tick("AAPL", "1", "10", Side::Bid, 0);
983        let wrong_sym = make_tick("TSLA", "1", "10", Side::Bid, 0);
984        let wrong_side = make_tick("AAPL", "1", "10", Side::Ask, 0);
985        let wrong_qty = make_tick("AAPL", "1", "1", Side::Bid, 0);
986        assert!(f.matches(&ok));
987        assert!(!f.matches(&wrong_sym));
988        assert!(!f.matches(&wrong_side));
989        assert!(!f.matches(&wrong_qty));
990    }
991
992    #[test]
993    fn test_tick_replayer_sorts_by_timestamp() {
994        let ticks = vec![
995            make_tick("A", "1", "1", Side::Bid, 300),
996            make_tick("A", "1", "1", Side::Bid, 100),
997            make_tick("A", "1", "1", Side::Bid, 200),
998        ];
999        let mut replayer = TickReplayer::new(ticks);
1000        let t1 = replayer.next_tick().unwrap();
1001        assert_eq!(t1.timestamp.nanos(), 100);
1002        let t2 = replayer.next_tick().unwrap();
1003        assert_eq!(t2.timestamp.nanos(), 200);
1004        let t3 = replayer.next_tick().unwrap();
1005        assert_eq!(t3.timestamp.nanos(), 300);
1006    }
1007
1008    #[test]
1009    fn test_tick_replayer_next_tick_sequential() {
1010        let ticks = vec![
1011            make_tick("A", "1", "1", Side::Bid, 1),
1012            make_tick("A", "1", "1", Side::Bid, 2),
1013        ];
1014        let mut replayer = TickReplayer::new(ticks);
1015        assert!(replayer.next_tick().is_some());
1016        assert!(replayer.next_tick().is_some());
1017        assert!(replayer.next_tick().is_none());
1018    }
1019
1020    #[test]
1021    fn test_tick_replayer_reset_restarts() {
1022        let ticks = vec![make_tick("A", "1", "1", Side::Bid, 1)];
1023        let mut replayer = TickReplayer::new(ticks);
1024        let _ = replayer.next_tick();
1025        assert!(replayer.next_tick().is_none());
1026        replayer.reset();
1027        assert!(replayer.next_tick().is_some());
1028    }
1029
1030    #[test]
1031    fn test_tick_replayer_remaining() {
1032        let ticks = vec![
1033            make_tick("A", "1", "1", Side::Bid, 1),
1034            make_tick("A", "1", "1", Side::Bid, 2),
1035            make_tick("A", "1", "1", Side::Bid, 3),
1036        ];
1037        let mut replayer = TickReplayer::new(ticks);
1038        assert_eq!(replayer.remaining(), 3);
1039        let _ = replayer.next_tick();
1040        assert_eq!(replayer.remaining(), 2);
1041    }
1042
1043    #[test]
1044    fn test_tick_replayer_iterator() {
1045        let ticks = vec![
1046            make_tick("A", "1", "1", Side::Bid, 1),
1047            make_tick("A", "2", "1", Side::Bid, 2),
1048            make_tick("A", "3", "1", Side::Bid, 3),
1049        ];
1050        let mut replayer = TickReplayer::new(ticks);
1051        let prices: Vec<_> = (&mut replayer).map(|t| t.price.value()).collect();
1052        assert_eq!(prices.len(), 3);
1053        assert_eq!(prices[0], dec!(1));
1054        assert_eq!(prices[1], dec!(2));
1055        assert_eq!(prices[2], dec!(3));
1056    }
1057
1058    #[test]
1059    fn test_tick_replayer_peek_does_not_advance() {
1060        let ticks = vec![
1061            make_tick("A", "1", "1", Side::Bid, 1),
1062            make_tick("A", "2", "1", Side::Bid, 2),
1063        ];
1064        let mut replayer = TickReplayer::new(ticks);
1065        let p1 = replayer.peek().map(|t| t.timestamp.nanos());
1066        let p2 = replayer.peek().map(|t| t.timestamp.nanos());
1067        assert_eq!(p1, p2, "peek must not advance the position");
1068        assert_eq!(replayer.remaining(), 2);
1069        let _ = replayer.next_tick();
1070        assert_eq!(replayer.remaining(), 1);
1071    }
1072
1073    #[test]
1074    fn test_tick_replayer_peek_none_when_exhausted() {
1075        let replayer = TickReplayer::new(vec![]);
1076        assert!(replayer.peek().is_none());
1077    }
1078
1079    #[test]
1080    fn test_tick_replayer_ticks_slice() {
1081        let ticks = vec![
1082            make_tick("A", "1", "1", Side::Bid, 2),
1083            make_tick("A", "2", "1", Side::Bid, 1),
1084        ];
1085        let replayer = TickReplayer::new(ticks);
1086        // ticks() returns sorted slice
1087        let slice = replayer.ticks();
1088        assert_eq!(slice.len(), 2);
1089        assert_eq!(slice[0].timestamp.nanos(), 1);
1090        assert_eq!(slice[1].timestamp.nanos(), 2);
1091    }
1092
1093    #[test]
1094    fn test_tick_filter_has_symbol_filter_false_when_unset() {
1095        let f = TickFilter::new();
1096        assert!(!f.has_symbol_filter());
1097    }
1098
1099    #[test]
1100    fn test_tick_filter_has_symbol_filter_true_when_set() {
1101        let f = TickFilter::new().symbol(Symbol::new("AAPL").unwrap());
1102        assert!(f.has_symbol_filter());
1103    }
1104
1105    #[test]
1106    fn test_tick_filter_has_side_filter_false_when_unset() {
1107        let f = TickFilter::new();
1108        assert!(!f.has_side_filter());
1109    }
1110
1111    #[test]
1112    fn test_tick_filter_has_side_filter_true_when_set() {
1113        let f = TickFilter::new().side(Side::Bid);
1114        assert!(f.has_side_filter());
1115    }
1116
1117    #[test]
1118    fn test_tick_filter_has_min_qty_filter() {
1119        let f = TickFilter::new().min_quantity(Quantity::new(dec!(1)).unwrap());
1120        assert!(f.has_min_qty_filter());
1121    }
1122
1123    #[test]
1124    fn test_tick_filter_has_price_filter_min() {
1125        let f = TickFilter::new().min_price(Price::new(dec!(10)).unwrap());
1126        assert!(f.has_price_filter());
1127    }
1128
1129    #[test]
1130    fn test_tick_filter_has_price_filter_max() {
1131        let f = TickFilter::new().max_price(Price::new(dec!(100)).unwrap());
1132        assert!(f.has_price_filter());
1133    }
1134
1135    #[test]
1136    fn test_tick_serde_roundtrip() {
1137        let tick = make_tick("AAPL", "150.50", "25", Side::Bid, 1_000_000_000);
1138        let json = serde_json::to_string(&tick).unwrap();
1139        let back: Tick = serde_json::from_str(&json).unwrap();
1140        assert_eq!(back.symbol, tick.symbol);
1141        assert_eq!(back.price, tick.price);
1142        assert_eq!(back.quantity, tick.quantity);
1143        assert_eq!(back.side, tick.side);
1144        assert_eq!(back.timestamp, tick.timestamp);
1145    }
1146
1147    #[test]
1148    fn test_tick_replayer_count() {
1149        let ticks = vec![
1150            make_tick("AAPL", "100", "1", Side::Bid, 1),
1151            make_tick("AAPL", "101", "1", Side::Ask, 2),
1152            make_tick("AAPL", "102", "1", Side::Bid, 3),
1153        ];
1154        let replayer = TickReplayer::new(ticks);
1155        assert_eq!(replayer.count(), 3);
1156    }
1157
1158    #[test]
1159    fn test_tick_replayer_count_empty() {
1160        let replayer = TickReplayer::new(vec![]);
1161        assert_eq!(replayer.count(), 0);
1162    }
1163
1164    #[test]
1165    fn test_tick_replayer_filter_by_side() {
1166        let ticks = vec![
1167            make_tick("AAPL", "100", "1", Side::Bid, 1),
1168            make_tick("AAPL", "101", "1", Side::Ask, 2),
1169            make_tick("AAPL", "102", "1", Side::Bid, 3),
1170        ];
1171        let replayer = TickReplayer::new(ticks);
1172        let filter = TickFilter::new().side(Side::Bid);
1173        let filtered = replayer.filter_ticks(&filter);
1174        assert_eq!(filtered.len(), 2);
1175        assert!(filtered.iter().all(|t| t.side == Side::Bid));
1176    }
1177
1178    #[test]
1179    fn test_tick_replayer_filter_no_matches() {
1180        let ticks = vec![make_tick("AAPL", "100", "1", Side::Bid, 1)];
1181        let replayer = TickReplayer::new(ticks);
1182        let filter = TickFilter::new().side(Side::Ask);
1183        let filtered = replayer.filter_ticks(&filter);
1184        assert!(filtered.is_empty());
1185    }
1186
1187    #[test]
1188    fn test_tick_filter_min_notional_passes_large() {
1189        let big = make_tick("AAPL", "100", "10", Side::Ask, 1); // notional = 1000
1190        let filter = TickFilter::new().min_notional(dec_from_str("500"));
1191        assert!(filter.matches(&big));
1192    }
1193
1194    #[test]
1195    fn test_tick_filter_min_notional_rejects_small() {
1196        let small = make_tick("AAPL", "100", "1", Side::Bid, 1); // notional = 100
1197        let filter = TickFilter::new().min_notional(dec_from_str("500"));
1198        assert!(!filter.matches(&small));
1199    }
1200
1201    #[test]
1202    fn test_tick_filter_is_empty_when_no_predicates() {
1203        let f = TickFilter::new();
1204        assert!(f.is_empty());
1205    }
1206
1207    #[test]
1208    fn test_tick_filter_not_empty_after_symbol_set() {
1209        let f = TickFilter::new().symbol(Symbol::new("AAPL").unwrap());
1210        assert!(!f.is_empty());
1211    }
1212
1213    #[test]
1214    fn test_tick_filter_not_empty_after_side_set() {
1215        let f = TickFilter::new().side(Side::Ask);
1216        assert!(!f.is_empty());
1217    }
1218
1219    #[test]
1220    fn test_tick_notional_checked_matches_notional() {
1221        let t = make_tick("AAPL", "150.50", "10", Side::Bid, 0);
1222        assert_eq!(t.notional_checked(), Some(t.notional()));
1223    }
1224
1225    #[test]
1226    fn test_tick_notional_checked_zero_qty() {
1227        let t = make_tick("AAPL", "100", "0", Side::Bid, 0);
1228        assert_eq!(t.notional_checked(), Some(dec!(0)));
1229    }
1230
1231    #[test]
1232    fn test_tick_is_buy_bid_side() {
1233        let t = make_tick("AAPL", "100", "1", Side::Bid, 0);
1234        assert!(t.is_buy());
1235        assert!(!t.is_sell());
1236    }
1237
1238    #[test]
1239    fn test_tick_is_sell_ask_side() {
1240        let t = make_tick("AAPL", "100", "1", Side::Ask, 0);
1241        assert!(t.is_sell());
1242        assert!(!t.is_buy());
1243    }
1244
1245    #[test]
1246    fn test_tick_replayer_between_inclusive() {
1247        let ticks = vec![
1248            make_tick("AAPL", "100", "1", Side::Bid, 1),
1249            make_tick("AAPL", "101", "1", Side::Ask, 5),
1250            make_tick("AAPL", "102", "1", Side::Bid, 10),
1251        ];
1252        let replayer = TickReplayer::new(ticks);
1253        let result = replayer.between(NanoTimestamp::new(1), NanoTimestamp::new(5));
1254        assert_eq!(result.len(), 2);
1255    }
1256
1257    #[test]
1258    fn test_tick_replayer_between_no_matches() {
1259        let ticks = vec![make_tick("AAPL", "100", "1", Side::Bid, 100)];
1260        let replayer = TickReplayer::new(ticks);
1261        let result = replayer.between(NanoTimestamp::new(1), NanoTimestamp::new(50));
1262        assert!(result.is_empty());
1263    }
1264
1265    #[test]
1266    fn test_tick_filter_timestamp_range() {
1267        let ticks = vec![
1268            make_tick("AAPL", "100", "1", Side::Bid, 1),
1269            make_tick("AAPL", "101", "1", Side::Ask, 5),
1270            make_tick("AAPL", "102", "1", Side::Bid, 10),
1271        ];
1272        let filter = TickFilter::new()
1273            .timestamp_range(NanoTimestamp::new(3), NanoTimestamp::new(10));
1274        let matched: Vec<_> = ticks.iter().filter(|t| filter.matches(t)).collect();
1275        assert_eq!(matched.len(), 2);
1276    }
1277
1278    #[test]
1279    fn test_tick_replayer_first_returns_earliest() {
1280        let ticks = vec![
1281            make_tick("AAPL", "100", "1", Side::Bid, 5),
1282            make_tick("AAPL", "101", "1", Side::Ask, 1),
1283            make_tick("AAPL", "102", "1", Side::Bid, 10),
1284        ];
1285        let replayer = TickReplayer::new(ticks);
1286        let first = replayer.first().unwrap();
1287        assert_eq!(first.timestamp, NanoTimestamp::new(1));
1288    }
1289
1290    #[test]
1291    fn test_tick_replayer_last_returns_latest() {
1292        let ticks = vec![
1293            make_tick("AAPL", "100", "1", Side::Bid, 5),
1294            make_tick("AAPL", "101", "1", Side::Ask, 1),
1295            make_tick("AAPL", "102", "1", Side::Bid, 10),
1296        ];
1297        let replayer = TickReplayer::new(ticks);
1298        let last = replayer.last().unwrap();
1299        assert_eq!(last.timestamp, NanoTimestamp::new(10));
1300    }
1301
1302    #[test]
1303    fn test_tick_replayer_first_none_when_empty() {
1304        let replayer = TickReplayer::new(vec![]);
1305        assert!(replayer.first().is_none());
1306    }
1307
1308    #[test]
1309    fn test_tick_replayer_last_none_when_empty() {
1310        let replayer = TickReplayer::new(vec![]);
1311        assert!(replayer.last().is_none());
1312    }
1313
1314    #[test]
1315    fn test_tick_replayer_vwap_by_side_correct_values() {
1316        let ticks = vec![
1317            make_tick("AAPL", "100", "10", Side::Bid, 1),  // bid: notional=1000, vol=10
1318            make_tick("AAPL", "200", "5", Side::Ask, 2),   // ask: notional=1000, vol=5
1319        ];
1320        let replayer = TickReplayer::new(ticks);
1321        let (bid_vwap, ask_vwap) = replayer.vwap_by_side();
1322        assert_eq!(bid_vwap, Some(dec_from_str("100")));
1323        assert_eq!(ask_vwap, Some(dec_from_str("200")));
1324    }
1325
1326    #[test]
1327    fn test_tick_replayer_vwap_by_side_no_asks_returns_none_ask() {
1328        let ticks = vec![make_tick("AAPL", "100", "10", Side::Bid, 1)];
1329        let replayer = TickReplayer::new(ticks);
1330        let (bid_vwap, ask_vwap) = replayer.vwap_by_side();
1331        assert!(bid_vwap.is_some());
1332        assert!(ask_vwap.is_none());
1333    }
1334
1335    #[test]
1336    fn test_tick_replayer_vwap_by_side_empty_returns_none_both() {
1337        let replayer = TickReplayer::new(vec![]);
1338        let (bid_vwap, ask_vwap) = replayer.vwap_by_side();
1339        assert!(bid_vwap.is_none());
1340        assert!(ask_vwap.is_none());
1341    }
1342
1343    #[test]
1344    fn test_tick_filter_clear_resets_all_predicates() {
1345        let f = TickFilter::new()
1346            .symbol(Symbol::new("AAPL").unwrap())
1347            .side(Side::Bid)
1348            .min_quantity(Quantity::new(dec!(1)).unwrap());
1349        let cleared = f.clear();
1350        assert!(cleared.is_empty());
1351    }
1352
1353    #[test]
1354    fn test_tick_filter_has_notional_filter_false_when_unset() {
1355        let f = TickFilter::new();
1356        assert!(!f.has_notional_filter());
1357    }
1358
1359    #[test]
1360    fn test_tick_filter_has_notional_filter_true_with_min() {
1361        let f = TickFilter::new().min_notional(dec_from_str("100"));
1362        assert!(f.has_notional_filter());
1363    }
1364
1365    #[test]
1366    fn test_tick_filter_has_notional_filter_true_with_max() {
1367        let f = TickFilter::new().max_notional(dec_from_str("1000"));
1368        assert!(f.has_notional_filter());
1369    }
1370
1371    #[test]
1372    fn test_tick_replayer_total_notional() {
1373        let ticks = vec![
1374            make_tick("AAPL", "100", "10", Side::Bid, 1),  // 100*10 = 1000
1375            make_tick("AAPL", "200", "5", Side::Ask, 2),   // 200*5  = 1000
1376        ];
1377        let replayer = TickReplayer::new(ticks);
1378        assert_eq!(replayer.total_notional(), dec_from_str("2000"));
1379    }
1380
1381    #[test]
1382    fn test_tick_replayer_total_notional_empty() {
1383        let replayer = TickReplayer::new(vec![]);
1384        assert_eq!(replayer.total_notional(), dec_from_str("0"));
1385    }
1386
1387    #[test]
1388    fn test_tick_replayer_buy_volume() {
1389        let ticks = vec![
1390            make_tick("AAPL", "100", "10", Side::Bid, 1),
1391            make_tick("AAPL", "100", "5", Side::Ask, 2),
1392        ];
1393        let replayer = TickReplayer::new(ticks);
1394        assert_eq!(replayer.buy_volume(), dec_from_str("10"));
1395    }
1396
1397    #[test]
1398    fn test_tick_replayer_sell_volume() {
1399        let ticks = vec![
1400            make_tick("AAPL", "100", "10", Side::Bid, 1),
1401            make_tick("AAPL", "100", "7", Side::Ask, 2),
1402        ];
1403        let replayer = TickReplayer::new(ticks);
1404        assert_eq!(replayer.sell_volume(), dec_from_str("7"));
1405    }
1406
1407    #[test]
1408    fn test_tick_replayer_delta_positive_when_more_buys() {
1409        let ticks = vec![
1410            make_tick("AAPL", "100", "10", Side::Bid, 1),
1411            make_tick("AAPL", "100", "3", Side::Ask, 2),
1412        ];
1413        let replayer = TickReplayer::new(ticks);
1414        assert_eq!(replayer.delta(), dec_from_str("7"));
1415    }
1416
1417    #[test]
1418    fn test_tick_replayer_delta_negative_when_more_sells() {
1419        let ticks = vec![
1420            make_tick("AAPL", "100", "2", Side::Bid, 1),
1421            make_tick("AAPL", "100", "8", Side::Ask, 2),
1422        ];
1423        let replayer = TickReplayer::new(ticks);
1424        assert_eq!(replayer.delta(), dec_from_str("-6"));
1425    }
1426
1427    #[test]
1428    fn test_tick_replayer_delta_zero_when_balanced() {
1429        let ticks = vec![
1430            make_tick("AAPL", "100", "5", Side::Bid, 1),
1431            make_tick("AAPL", "100", "5", Side::Ask, 2),
1432        ];
1433        let replayer = TickReplayer::new(ticks);
1434        assert_eq!(replayer.delta(), dec_from_str("0"));
1435    }
1436
1437    #[test]
1438    fn test_tick_replayer_time_span_nanos_correct() {
1439        let ticks = vec![
1440            make_tick("AAPL", "100", "1", Side::Bid, 1_000_000),
1441            make_tick("AAPL", "100", "1", Side::Ask, 3_000_000),
1442        ];
1443        let replayer = TickReplayer::new(ticks);
1444        assert_eq!(replayer.time_span_nanos(), Some(2_000_000));
1445    }
1446
1447    #[test]
1448    fn test_tick_replayer_time_span_nanos_none_for_single_tick() {
1449        let ticks = vec![make_tick("AAPL", "100", "1", Side::Bid, 1_000_000)];
1450        let replayer = TickReplayer::new(ticks);
1451        assert_eq!(replayer.time_span_nanos(), None);
1452    }
1453
1454    #[test]
1455    fn test_tick_replayer_time_span_nanos_none_for_empty() {
1456        let replayer = TickReplayer::new(vec![]);
1457        assert_eq!(replayer.time_span_nanos(), None);
1458    }
1459
1460    #[test]
1461    fn test_tick_replayer_price_range_returns_spread() {
1462        let ticks = vec![
1463            make_tick("AAPL", "100", "1", Side::Bid, 1),
1464            make_tick("AAPL", "105", "1", Side::Ask, 2),
1465            make_tick("AAPL", "98", "1", Side::Bid, 3),
1466        ];
1467        let replayer = TickReplayer::new(ticks);
1468        assert_eq!(replayer.price_range(), Some(dec_from_str("7")));
1469    }
1470
1471    #[test]
1472    fn test_tick_replayer_price_range_none_for_empty() {
1473        let replayer = TickReplayer::new(vec![]);
1474        assert_eq!(replayer.price_range(), None);
1475    }
1476
1477    #[test]
1478    fn test_tick_replayer_price_range_zero_for_single_price() {
1479        let ticks = vec![make_tick("AAPL", "100", "1", Side::Bid, 1)];
1480        let replayer = TickReplayer::new(ticks);
1481        assert_eq!(replayer.price_range(), Some(dec_from_str("0")));
1482    }
1483
1484    #[test]
1485    fn test_tick_replayer_tick_count_by_side() {
1486        let ticks = vec![
1487            make_tick("AAPL", "100", "1", Side::Bid, 1),
1488            make_tick("AAPL", "100", "1", Side::Bid, 2),
1489            make_tick("AAPL", "100", "1", Side::Ask, 3),
1490        ];
1491        let replayer = TickReplayer::new(ticks);
1492        assert_eq!(replayer.tick_count_by_side(), (2, 1));
1493    }
1494
1495    #[test]
1496    fn test_tick_replayer_tick_count_by_side_empty() {
1497        let replayer = TickReplayer::new(vec![]);
1498        assert_eq!(replayer.tick_count_by_side(), (0, 0));
1499    }
1500
1501    #[test]
1502    fn test_tick_replayer_median_trade_size_single() {
1503        let ticks = vec![make_tick("AAPL", "100", "5", Side::Bid, 1)];
1504        let replayer = TickReplayer::new(ticks);
1505        assert_eq!(replayer.median_trade_size(), Some(dec_from_str("5")));
1506    }
1507
1508    #[test]
1509    fn test_tick_replayer_median_trade_size_odd_count() {
1510        let ticks = vec![
1511            make_tick("AAPL", "100", "1", Side::Bid, 1),
1512            make_tick("AAPL", "100", "3", Side::Bid, 2),
1513            make_tick("AAPL", "100", "5", Side::Bid, 3),
1514        ];
1515        let replayer = TickReplayer::new(ticks);
1516        // Sorted: [1, 3, 5], median = index 1 = 3
1517        assert_eq!(replayer.median_trade_size(), Some(dec_from_str("3")));
1518    }
1519
1520    #[test]
1521    fn test_tick_replayer_median_trade_size_none_for_empty() {
1522        let replayer = TickReplayer::new(vec![]);
1523        assert_eq!(replayer.median_trade_size(), None);
1524    }
1525
1526    #[test]
1527    fn test_tick_replayer_total_notional_sum_two_trades() {
1528        let ticks = vec![
1529            make_tick("X", "100", "2", Side::Bid, 1),
1530            make_tick("X", "50", "4", Side::Ask, 2),
1531        ];
1532        let replayer = TickReplayer::new(ticks);
1533        // 100*2 + 50*4 = 200 + 200 = 400
1534        assert_eq!(replayer.total_notional(), dec_from_str("400"));
1535    }
1536
1537    #[test]
1538    fn test_tick_replayer_price_std_none_for_single_tick() {
1539        let ticks = vec![make_tick("X", "100", "1", Side::Bid, 1)];
1540        let replayer = TickReplayer::new(ticks);
1541        assert!(replayer.price_std().is_none());
1542    }
1543
1544    #[test]
1545    fn test_tick_replayer_price_std_zero_for_constant_prices() {
1546        let ticks = vec![
1547            make_tick("X", "100", "1", Side::Bid, 1),
1548            make_tick("X", "100", "2", Side::Bid, 2),
1549            make_tick("X", "100", "3", Side::Bid, 3),
1550        ];
1551        let replayer = TickReplayer::new(ticks);
1552        assert_eq!(replayer.price_std(), Some(Decimal::ZERO));
1553    }
1554
1555    #[test]
1556    fn test_tick_replayer_price_std_positive_for_varying_prices() {
1557        let ticks = vec![
1558            make_tick("X", "100", "1", Side::Bid, 1),
1559            make_tick("X", "110", "1", Side::Bid, 2),
1560            make_tick("X", "120", "1", Side::Bid, 3),
1561        ];
1562        let replayer = TickReplayer::new(ticks);
1563        let std = replayer.price_std().unwrap();
1564        assert!(std > Decimal::ZERO);
1565    }
1566}