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//! - `TickFilter::matches` is pure (no side effects)
11//!
12//! ## NOT Responsible For
13//! - Persistence or serialization to external stores
14//! - Cross-symbol aggregation
15
16use crate::types::{NanoTimestamp, Price, Quantity, Side, Symbol};
17use rust_decimal::Decimal;
18
19/// A single market trade event.
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
21pub struct Tick {
22    /// The traded instrument.
23    pub symbol: Symbol,
24    /// The trade price (positive).
25    pub price: Price,
26    /// The trade quantity (non-negative).
27    pub quantity: Quantity,
28    /// Whether this was a bid-side or ask-side aggressor.
29    pub side: Side,
30    /// Exchange timestamp in nanoseconds.
31    pub timestamp: NanoTimestamp,
32}
33
34impl Tick {
35    /// Constructs a new `Tick`.
36    ///
37    /// # Arguments
38    /// * `symbol` - validated ticker symbol
39    /// * `price` - validated positive price
40    /// * `quantity` - validated non-negative quantity
41    /// * `side` - bid or ask
42    /// * `timestamp` - nanosecond UTC timestamp
43    pub fn new(
44        symbol: Symbol,
45        price: Price,
46        quantity: Quantity,
47        side: Side,
48        timestamp: NanoTimestamp,
49    ) -> Self {
50        Self {
51            symbol,
52            price,
53            quantity,
54            side,
55            timestamp,
56        }
57    }
58
59    /// Returns the notional value of this tick: `price * quantity`.
60    ///
61    /// # Returns
62    /// A `Decimal` representing the total traded value.
63    pub fn notional(&self) -> Decimal {
64        self.price.value() * self.quantity.value()
65    }
66}
67
68/// Filters ticks by optional symbol, side, and minimum quantity predicates.
69///
70/// All predicates are `ANDed` together. Unset predicates always pass.
71pub struct TickFilter {
72    symbol: Option<Symbol>,
73    side: Option<Side>,
74    min_qty: Option<Quantity>,
75}
76
77impl TickFilter {
78    /// Creates a new `TickFilter` with no predicates set (matches everything).
79    pub fn new() -> Self {
80        Self {
81            symbol: None,
82            side: None,
83            min_qty: None,
84        }
85    }
86
87    /// Restrict matches to ticks with this symbol.
88    #[must_use]
89    pub fn symbol(mut self, s: Symbol) -> Self {
90        self.symbol = Some(s);
91        self
92    }
93
94    /// Restrict matches to ticks on this side.
95    #[must_use]
96    pub fn side(mut self, s: Side) -> Self {
97        self.side = Some(s);
98        self
99    }
100
101    /// Restrict matches to ticks with quantity >= `q`.
102    #[must_use]
103    pub fn min_quantity(mut self, q: Quantity) -> Self {
104        self.min_qty = Some(q);
105        self
106    }
107
108    /// Returns `true` if the tick satisfies all configured predicates.
109    ///
110    /// # Arguments
111    /// * `tick` - the tick to evaluate
112    pub fn matches(&self, tick: &Tick) -> bool {
113        if let Some(ref sym) = self.symbol {
114            if tick.symbol != *sym {
115                return false;
116            }
117        }
118        if let Some(ref side) = self.side {
119            if tick.side != *side {
120                return false;
121            }
122        }
123        if let Some(ref min_qty) = self.min_qty {
124            if tick.quantity < *min_qty {
125                return false;
126            }
127        }
128        true
129    }
130}
131
132impl Default for TickFilter {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138/// Replays a collection of ticks in ascending timestamp order.
139pub struct TickReplayer {
140    ticks: Vec<Tick>,
141    index: usize,
142}
143
144impl TickReplayer {
145    /// Constructs a `TickReplayer`, sorting `ticks` by timestamp ascending.
146    pub fn new(mut ticks: Vec<Tick>) -> Self {
147        ticks.sort_by_key(|t| t.timestamp);
148        Self { ticks, index: 0 }
149    }
150
151    /// Returns the next tick in timestamp order, or `None` if exhausted.
152    pub fn next_tick(&mut self) -> Option<&Tick> {
153        let tick = self.ticks.get(self.index)?;
154        self.index += 1;
155        Some(tick)
156    }
157
158    /// Returns the number of ticks not yet yielded.
159    pub fn remaining(&self) -> usize {
160        self.ticks.len().saturating_sub(self.index)
161    }
162
163    /// Resets the replayer to the beginning of the tick sequence.
164    pub fn reset(&mut self) {
165        self.index = 0;
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use rust_decimal_macros::dec;
173
174    fn make_tick(sym: &str, price: &str, qty: &str, side: Side, ts: i64) -> Tick {
175        Tick::new(
176            Symbol::new(sym).unwrap(),
177            Price::new(dec_from_str(price)).unwrap(),
178            Quantity::new(dec_from_str(qty)).unwrap(),
179            side,
180            NanoTimestamp(ts),
181        )
182    }
183
184    fn dec_from_str(s: &str) -> Decimal {
185        s.parse().unwrap()
186    }
187
188    #[test]
189    fn test_tick_notional_is_price_times_quantity() {
190        let t = make_tick("AAPL", "150.00", "10", Side::Ask, 0);
191        assert_eq!(t.notional(), dec!(1500.00));
192    }
193
194    #[test]
195    fn test_tick_notional_zero_quantity() {
196        let t = make_tick("AAPL", "150.00", "0", Side::Ask, 0);
197        assert_eq!(t.notional(), dec!(0));
198    }
199
200    #[test]
201    fn test_tick_filter_no_predicates_matches_all() {
202        let f = TickFilter::new();
203        let t = make_tick("AAPL", "1", "1", Side::Bid, 0);
204        assert!(f.matches(&t));
205    }
206
207    #[test]
208    fn test_tick_filter_by_symbol() {
209        let sym = Symbol::new("AAPL").unwrap();
210        let f = TickFilter::new().symbol(sym);
211        let matching = make_tick("AAPL", "1", "1", Side::Bid, 0);
212        let non_matching = make_tick("TSLA", "1", "1", Side::Bid, 0);
213        assert!(f.matches(&matching));
214        assert!(!f.matches(&non_matching));
215    }
216
217    #[test]
218    fn test_tick_filter_by_side() {
219        let f = TickFilter::new().side(Side::Ask);
220        let ask_tick = make_tick("AAPL", "1", "1", Side::Ask, 0);
221        let bid_tick = make_tick("AAPL", "1", "1", Side::Bid, 0);
222        assert!(f.matches(&ask_tick));
223        assert!(!f.matches(&bid_tick));
224    }
225
226    #[test]
227    fn test_tick_filter_by_min_quantity() {
228        let min_qty = Quantity::new(dec!(5)).unwrap();
229        let f = TickFilter::new().min_quantity(min_qty);
230        let large = make_tick("AAPL", "1", "10", Side::Bid, 0);
231        let small = make_tick("AAPL", "1", "2", Side::Bid, 0);
232        assert!(f.matches(&large));
233        assert!(!f.matches(&small));
234    }
235
236    #[test]
237    fn test_tick_filter_combined_predicates() {
238        let sym = Symbol::new("AAPL").unwrap();
239        let min_qty = Quantity::new(dec!(5)).unwrap();
240        let f = TickFilter::new()
241            .symbol(sym)
242            .side(Side::Bid)
243            .min_quantity(min_qty);
244        let ok = make_tick("AAPL", "1", "10", Side::Bid, 0);
245        let wrong_sym = make_tick("TSLA", "1", "10", Side::Bid, 0);
246        let wrong_side = make_tick("AAPL", "1", "10", Side::Ask, 0);
247        let wrong_qty = make_tick("AAPL", "1", "1", Side::Bid, 0);
248        assert!(f.matches(&ok));
249        assert!(!f.matches(&wrong_sym));
250        assert!(!f.matches(&wrong_side));
251        assert!(!f.matches(&wrong_qty));
252    }
253
254    #[test]
255    fn test_tick_replayer_sorts_by_timestamp() {
256        let ticks = vec![
257            make_tick("A", "1", "1", Side::Bid, 300),
258            make_tick("A", "1", "1", Side::Bid, 100),
259            make_tick("A", "1", "1", Side::Bid, 200),
260        ];
261        let mut replayer = TickReplayer::new(ticks);
262        let t1 = replayer.next_tick().unwrap();
263        assert_eq!(t1.timestamp.0, 100);
264        let t2 = replayer.next_tick().unwrap();
265        assert_eq!(t2.timestamp.0, 200);
266        let t3 = replayer.next_tick().unwrap();
267        assert_eq!(t3.timestamp.0, 300);
268    }
269
270    #[test]
271    fn test_tick_replayer_next_tick_sequential() {
272        let ticks = vec![
273            make_tick("A", "1", "1", Side::Bid, 1),
274            make_tick("A", "1", "1", Side::Bid, 2),
275        ];
276        let mut replayer = TickReplayer::new(ticks);
277        assert!(replayer.next_tick().is_some());
278        assert!(replayer.next_tick().is_some());
279        assert!(replayer.next_tick().is_none());
280    }
281
282    #[test]
283    fn test_tick_replayer_reset_restarts() {
284        let ticks = vec![make_tick("A", "1", "1", Side::Bid, 1)];
285        let mut replayer = TickReplayer::new(ticks);
286        let _ = replayer.next_tick();
287        assert!(replayer.next_tick().is_none());
288        replayer.reset();
289        assert!(replayer.next_tick().is_some());
290    }
291
292    #[test]
293    fn test_tick_replayer_remaining() {
294        let ticks = vec![
295            make_tick("A", "1", "1", Side::Bid, 1),
296            make_tick("A", "1", "1", Side::Bid, 2),
297            make_tick("A", "1", "1", Side::Bid, 3),
298        ];
299        let mut replayer = TickReplayer::new(ticks);
300        assert_eq!(replayer.remaining(), 3);
301        let _ = replayer.next_tick();
302        assert_eq!(replayer.remaining(), 2);
303    }
304}