1use super::RuleStatus;
9use crate::market::Trade;
10use crate::metrics::MetricId;
11use std::collections::HashMap;
12
13pub type Reading = Result<Option<f64>, RuleStatus>;
16
17#[derive(Debug, Clone, Default, PartialEq)]
19struct Memory {
20 last_trade_ms: Option<u64>,
22 volume_24h: Option<f64>,
24}
25
26#[derive(Debug, Clone, PartialEq)]
28pub struct Readings {
29 pub largest_trade: Reading,
30 pub volume_change_pct: Reading,
31}
32
33impl Readings {
34 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#[derive(Debug, Clone, Default, PartialEq)]
46pub struct History {
47 markets: HashMap<String, Memory>,
48}
49
50impl History {
51 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
73fn 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 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), 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 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 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 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}