Skip to main content

fin_primitives/tick/
mod.rs

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