wickra_core/indicators/
footprint.rs1use std::collections::BTreeMap;
4
5use crate::error::{Error, Result};
6use crate::microstructure::Trade;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct FootprintLevel {
13 pub price: f64,
15 pub bid_vol: f64,
17 pub ask_vol: f64,
19}
20
21#[derive(Debug, Clone, PartialEq, Default)]
24pub struct FootprintOutput {
25 pub levels: Vec<FootprintLevel>,
27}
28
29#[derive(Debug, Clone)]
66pub struct Footprint {
67 tick_size: f64,
68 buckets: BTreeMap<i64, (f64, f64)>,
70 has_emitted: bool,
71}
72
73impl Footprint {
74 pub fn new(tick_size: f64) -> Result<Self> {
81 if !tick_size.is_finite() || tick_size <= 0.0 {
82 return Err(Error::InvalidTick {
83 message: "footprint tick_size must be finite and positive",
84 });
85 }
86 Ok(Self {
87 tick_size,
88 buckets: BTreeMap::new(),
89 has_emitted: false,
90 })
91 }
92
93 pub const fn tick_size(&self) -> f64 {
95 self.tick_size
96 }
97
98 fn bucket_index(&self, price: f64) -> i64 {
99 #[allow(clippy::cast_possible_truncation)]
103 {
104 (price / self.tick_size).round() as i64
105 }
106 }
107
108 fn snapshot(&self) -> FootprintOutput {
109 let levels = self
110 .buckets
111 .iter()
112 .map(|(&index, &(bid_vol, ask_vol))| FootprintLevel {
113 price: index as f64 * self.tick_size,
114 bid_vol,
115 ask_vol,
116 })
117 .collect();
118 FootprintOutput { levels }
119 }
120}
121
122impl Indicator for Footprint {
123 type Input = Trade;
124 type Output = FootprintOutput;
125
126 fn update(&mut self, trade: Trade) -> Option<FootprintOutput> {
127 self.has_emitted = true;
128 let index = self.bucket_index(trade.price);
129 let entry = self.buckets.entry(index).or_insert((0.0, 0.0));
130 if trade.side.sign() > 0.0 {
131 entry.1 += trade.size;
132 } else {
133 entry.0 += trade.size;
134 }
135 Some(self.snapshot())
136 }
137
138 fn reset(&mut self) {
139 self.buckets.clear();
140 self.has_emitted = false;
141 }
142
143 fn warmup_period(&self) -> usize {
144 1
145 }
146
147 fn is_ready(&self) -> bool {
148 self.has_emitted
149 }
150
151 fn name(&self) -> &'static str {
152 "Footprint"
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::microstructure::Side;
160 use crate::traits::BatchExt;
161
162 fn trade(price: f64, size: f64, side: Side) -> Trade {
163 Trade::new(price, size, side, 0).unwrap()
164 }
165
166 #[test]
167 fn rejects_bad_tick_size() {
168 assert!(matches!(
169 Footprint::new(0.0),
170 Err(Error::InvalidTick { .. })
171 ));
172 assert!(matches!(
173 Footprint::new(-1.0),
174 Err(Error::InvalidTick { .. })
175 ));
176 assert!(matches!(
177 Footprint::new(f64::NAN),
178 Err(Error::InvalidTick { .. })
179 ));
180 assert!(Footprint::new(0.5).is_ok());
181 }
182
183 #[test]
184 fn accessors_and_metadata() {
185 let fp = Footprint::new(0.25).unwrap();
186 assert_eq!(fp.name(), "Footprint");
187 assert_eq!(fp.warmup_period(), 1);
188 assert_eq!(fp.tick_size(), 0.25);
189 assert!(!fp.is_ready());
190 }
191
192 #[test]
193 fn buckets_buy_and_sell_volume() {
194 let mut fp = Footprint::new(1.0).unwrap();
195 fp.update(trade(100.2, 2.0, Side::Buy));
196 fp.update(trade(100.7, 3.0, Side::Sell));
197 let out = fp.update(trade(100.1, 1.0, Side::Buy)).unwrap();
198 assert!(fp.is_ready());
199 assert_eq!(out.levels.len(), 2);
201 assert_eq!(out.levels[0].price, 100.0);
202 assert_eq!(out.levels[0].ask_vol, 3.0);
203 assert_eq!(out.levels[0].bid_vol, 0.0);
204 assert_eq!(out.levels[1].price, 101.0);
205 assert_eq!(out.levels[1].bid_vol, 3.0);
206 assert_eq!(out.levels[1].ask_vol, 0.0);
207 }
208
209 #[test]
210 fn levels_sorted_ascending_by_price() {
211 let mut fp = Footprint::new(1.0).unwrap();
212 fp.update(trade(103.0, 1.0, Side::Buy));
213 fp.update(trade(100.0, 1.0, Side::Sell));
214 let out = fp.update(trade(101.0, 1.0, Side::Buy)).unwrap();
215 let prices: Vec<f64> = out.levels.iter().map(|l| l.price).collect();
216 assert_eq!(prices, vec![100.0, 101.0, 103.0]);
217 }
218
219 #[test]
220 fn sub_tick_prices_share_a_bucket() {
221 let mut fp = Footprint::new(0.5).unwrap();
222 fp.update(trade(100.20, 1.0, Side::Buy)); let out = fp.update(trade(100.10, 2.0, Side::Buy)).unwrap(); assert_eq!(out.levels.len(), 1);
227 assert_eq!(out.levels[0].price, 100.0);
228 assert_eq!(out.levels[0].ask_vol, 3.0);
229 }
230
231 #[test]
232 fn reset_clears_the_footprint() {
233 let mut fp = Footprint::new(1.0).unwrap();
234 fp.update(trade(100.0, 5.0, Side::Buy));
235 assert!(fp.is_ready());
236 fp.reset();
237 assert!(!fp.is_ready());
238 let out = fp.update(trade(200.0, 1.0, Side::Sell)).unwrap();
239 assert_eq!(out.levels.len(), 1);
240 assert_eq!(out.levels[0].price, 200.0);
241 assert_eq!(out.levels[0].bid_vol, 1.0);
242 }
243
244 #[test]
245 fn batch_equals_streaming() {
246 let trades: Vec<Trade> = (0..30)
247 .map(|i| {
248 let side = if i % 3 == 0 { Side::Sell } else { Side::Buy };
249 trade(100.0 + f64::from(i % 5), 1.0 + f64::from(i % 4), side)
250 })
251 .collect();
252 let mut a = Footprint::new(1.0).unwrap();
253 let mut b = Footprint::new(1.0).unwrap();
254 assert_eq!(
255 a.batch(&trades),
256 trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
257 );
258 }
259}