Skip to main content

scope/watch/
history.rs

1//! What a run remembers from one tick to the next, for the metrics that
2//! compare a tick with the one before ([`MetricId::since_last_tick`]).
3//!
4//! The memory is per market: a target on one venue, named by its label
5//! (`USDC@binance`). A target that watches several venues keeps one memory
6//! for each.
7
8use super::RuleStatus;
9use crate::market::Trade;
10use crate::metrics::MetricId;
11use std::collections::HashMap;
12
13/// One reading of a since-last-tick metric: its value (`None` when the
14/// source gave none), or the status its cell shows instead.
15pub type Reading = Result<Option<f64>, RuleStatus>;
16
17/// What a run remembers of one market.
18#[derive(Debug, Clone, Default, PartialEq)]
19struct Memory {
20    /// The newest trade time seen on the tape, in ms since the epoch.
21    last_trade_ms: Option<u64>,
22    /// The 24h volume of the last tick that had one.
23    volume_24h: Option<f64>,
24}
25
26/// The since-last-tick readings of one market for one tick.
27#[derive(Debug, Clone, PartialEq)]
28pub struct Readings {
29    pub largest_trade: Reading,
30    pub volume_change_pct: Reading,
31}
32
33impl Readings {
34    /// The reading of `id`, if it is a since-last-tick metric.
35    pub fn get(&self, id: MetricId) -> Option<&Reading> {
36        match id {
37            MetricId::LargestTrade => Some(&self.largest_trade),
38            MetricId::VolumeChangePct => Some(&self.volume_change_pct),
39            _ => None,
40        }
41    }
42}
43
44/// What a run remembers from tick to tick, per market.
45#[derive(Debug, Clone, Default, PartialEq)]
46pub struct History {
47    markets: HashMap<String, Memory>,
48}
49
50impl History {
51    /// Reads this tick of `market` against the last one and remembers it.
52    ///
53    /// `trades` is the venue's tape (or why there is none) and `volume_24h`
54    /// its 24h volume. The first tick has no tick before it: its largest
55    /// trade is over the last `window_ms` (one interval) before `now_ms`,
56    /// and its volume change waits for a second reading.
57    pub fn read(
58        &mut self,
59        market: &str,
60        trades: Result<&[Trade], &str>,
61        volume_24h: Option<f64>,
62        now_ms: u64,
63        window_ms: u64,
64    ) -> Readings {
65        let memory = self.markets.entry(market.to_string()).or_default();
66        Readings {
67            largest_trade: largest_trade(memory, trades, now_ms, window_ms),
68            volume_change_pct: volume_change(memory, volume_24h),
69        }
70    }
71}
72
73/// A trade's size in the quote asset.
74fn quote_value(t: &Trade) -> f64 {
75    t.quote_quantity.unwrap_or(t.price * t.quantity)
76}
77
78fn largest_trade(
79    memory: &mut Memory,
80    trades: Result<&[Trade], &str>,
81    now_ms: u64,
82    window_ms: u64,
83) -> Reading {
84    let trades = trades
85        .map_err(|why| RuleStatus::Error(format!("{}: {why}", MetricId::LargestTrade.name())))?;
86    let after = memory
87        .last_trade_ms
88        .unwrap_or_else(|| now_ms.saturating_sub(window_ms));
89    // No trade since the last tick is a reading of its own: nothing traded.
90    let largest = trades
91        .iter()
92        .filter(|t| t.timestamp_ms > after)
93        .map(quote_value)
94        .fold(0.0, f64::max);
95    let newest = trades.iter().map(|t| t.timestamp_ms).max();
96    memory.last_trade_ms = memory.last_trade_ms.max(newest);
97    Ok(Some(largest))
98}
99
100fn volume_change(memory: &mut Memory, volume_24h: Option<f64>) -> Reading {
101    let name = MetricId::VolumeChangePct.name();
102    let Some(now) = volume_24h else {
103        return Ok(None);
104    };
105    let before = memory.volume_24h.replace(now);
106    match before {
107        None => Err(RuleStatus::NotApplicable(format!(
108            "{name}: needs a second reading"
109        ))),
110        Some(before) if before <= 0.0 => Err(RuleStatus::NotApplicable(format!(
111            "{name}: the last 24h volume was 0"
112        ))),
113        Some(before) => Ok(Some((now - before) / before * 100.0)),
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::market::TradeSide;
121
122    fn trade(ms: u64, price: f64, quantity: f64) -> Trade {
123        Trade {
124            price,
125            quantity,
126            quote_quantity: None,
127            timestamp_ms: ms,
128            side: TradeSide::Buy,
129            id: None,
130        }
131    }
132
133    const MIN: u64 = 60_000;
134
135    #[test]
136    fn the_first_tick_reads_one_interval_back_and_later_ticks_only_new_trades() {
137        let mut h = History::default();
138        let now = 10 * MIN;
139        let tape = [
140            trade(now - 5 * MIN, 1.0, 90_000.0), // before the window
141            trade(now - 30_000, 1.0, 2_000.0),
142            trade(now - 10_000, 1.0, 500.0),
143        ];
144        let first = h.read("USDC@binance", Ok(&tape), None, now, MIN);
145        assert_eq!(first.largest_trade, Ok(Some(2_000.0)));
146
147        // Next tick: the old tape again plus one new trade.
148        let mut next = tape.to_vec();
149        next.push(trade(now + 20_000, 1.0, 700.0));
150        let second = h.read("USDC@binance", Ok(&next), None, now + MIN, MIN);
151        assert_eq!(second.largest_trade, Ok(Some(700.0)));
152
153        // No new trade: nothing traded, which is a value, not an error.
154        let third = h.read("USDC@binance", Ok(&next), None, now + 2 * MIN, MIN);
155        assert_eq!(third.largest_trade, Ok(Some(0.0)));
156    }
157
158    #[test]
159    fn a_trade_uses_its_quote_quantity_when_the_venue_gives_one() {
160        let mut big = trade(5_000, 2.0, 10.0);
161        big.quote_quantity = Some(19.5);
162        let mut h = History::default();
163        let r = h.read("X@v", Ok(&[big]), None, 6_000, MIN);
164        assert_eq!(r.largest_trade, Ok(Some(19.5)));
165    }
166
167    #[test]
168    fn a_venue_without_a_tape_says_why() {
169        let mut h = History::default();
170        let r = h.read(
171            "X@dex",
172            Err("a DEX pool has no trade tape here"),
173            None,
174            0,
175            MIN,
176        );
177        assert_eq!(
178            r.largest_trade,
179            Err(RuleStatus::Error(
180                "largest_trade: a DEX pool has no trade tape here".into()
181            ))
182        );
183    }
184
185    #[test]
186    fn volume_change_waits_for_a_second_reading_then_compares_ticks() {
187        let mut h = History::default();
188        let first = h.read("X@v", Ok(&[]), Some(1_000.0), 0, MIN);
189        assert!(matches!(
190            first.volume_change_pct,
191            Err(RuleStatus::NotApplicable(_))
192        ));
193        let second = h.read("X@v", Ok(&[]), Some(1_500.0), MIN, MIN);
194        assert_eq!(second.volume_change_pct, Ok(Some(50.0)));
195        // A tick with no volume gives none and keeps the last one.
196        let gap = h.read("X@v", Ok(&[]), None, 2 * MIN, MIN);
197        assert_eq!(gap.volume_change_pct, Ok(None));
198        let third = h.read("X@v", Ok(&[]), Some(750.0), 3 * MIN, MIN);
199        assert_eq!(third.volume_change_pct, Ok(Some(-50.0)));
200    }
201
202    #[test]
203    fn a_zero_volume_before_gives_no_percent() {
204        let mut h = History::default();
205        h.read("X@v", Ok(&[]), Some(0.0), 0, MIN);
206        let r = h.read("X@v", Ok(&[]), Some(10.0), MIN, MIN);
207        assert!(
208            matches!(r.volume_change_pct, Err(RuleStatus::NotApplicable(m)) if m.contains("was 0"))
209        );
210    }
211
212    #[test]
213    fn each_market_has_its_own_memory() {
214        let mut h = History::default();
215        h.read("X@a", Ok(&[]), Some(100.0), 0, MIN);
216        let other = h.read("X@b", Ok(&[]), Some(200.0), MIN, MIN);
217        assert!(matches!(
218            other.volume_change_pct,
219            Err(RuleStatus::NotApplicable(_))
220        ));
221        let again = h.read("X@a", Ok(&[]), Some(110.0), MIN, MIN);
222        let pct = again.volume_change_pct.clone().unwrap().unwrap();
223        assert!((pct - 10.0).abs() < 1e-9, "{pct}");
224        assert_eq!(again.get(MetricId::VolumeChangePct), Some(&Ok(Some(pct))));
225        assert_eq!(again.get(MetricId::Spread), None);
226    }
227}