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 #[inline]
127 fn update(&mut self, trade: Trade) -> Option<FootprintOutput> {
128 self.has_emitted = true;
129 let index = self.bucket_index(trade.price);
130 let entry = self.buckets.entry(index).or_insert((0.0, 0.0));
131 if trade.side.sign() > 0.0 {
132 entry.1 += trade.size;
133 } else {
134 entry.0 += trade.size;
135 }
136 Some(self.snapshot())
137 }
138
139 fn reset(&mut self) {
140 self.buckets.clear();
141 self.has_emitted = false;
142 }
143
144 #[inline]
145 fn warmup_period(&self) -> usize {
146 1
147 }
148
149 #[inline]
150 fn is_ready(&self) -> bool {
151 self.has_emitted
152 }
153
154 #[inline]
155 fn name(&self) -> &'static str {
156 "Footprint"
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use crate::microstructure::Side;
164 use crate::traits::BatchExt;
165
166 fn trade(price: f64, size: f64, side: Side) -> Trade {
167 Trade::new(price, size, side, 0).unwrap()
168 }
169
170 #[test]
171 fn rejects_bad_tick_size() {
172 assert!(matches!(
173 Footprint::new(0.0),
174 Err(Error::InvalidTick { .. })
175 ));
176 assert!(matches!(
177 Footprint::new(-1.0),
178 Err(Error::InvalidTick { .. })
179 ));
180 assert!(matches!(
181 Footprint::new(f64::NAN),
182 Err(Error::InvalidTick { .. })
183 ));
184 assert!(Footprint::new(0.5).is_ok());
185 }
186
187 #[test]
188 fn accessors_and_metadata() {
189 let fp = Footprint::new(0.25).unwrap();
190 assert_eq!(fp.name(), "Footprint");
191 assert_eq!(fp.warmup_period(), 1);
192 assert_eq!(fp.tick_size(), 0.25);
193 assert!(!fp.is_ready());
194 }
195
196 #[test]
197 fn buckets_buy_and_sell_volume() {
198 let mut fp = Footprint::new(1.0).unwrap();
199 fp.update(trade(100.2, 2.0, Side::Buy));
200 fp.update(trade(100.7, 3.0, Side::Sell));
201 let out = fp.update(trade(100.1, 1.0, Side::Buy)).unwrap();
202 assert!(fp.is_ready());
203 assert_eq!(out.levels.len(), 2);
205 assert_eq!(out.levels[0].price, 100.0);
206 assert_eq!(out.levels[0].ask_vol, 3.0);
207 assert_eq!(out.levels[0].bid_vol, 0.0);
208 assert_eq!(out.levels[1].price, 101.0);
209 assert_eq!(out.levels[1].bid_vol, 3.0);
210 assert_eq!(out.levels[1].ask_vol, 0.0);
211 }
212
213 #[test]
214 fn levels_sorted_ascending_by_price() {
215 let mut fp = Footprint::new(1.0).unwrap();
216 fp.update(trade(103.0, 1.0, Side::Buy));
217 fp.update(trade(100.0, 1.0, Side::Sell));
218 let out = fp.update(trade(101.0, 1.0, Side::Buy)).unwrap();
219 let prices: Vec<f64> = out.levels.iter().map(|l| l.price).collect();
220 assert_eq!(prices, vec![100.0, 101.0, 103.0]);
221 }
222
223 #[test]
224 fn sub_tick_prices_share_a_bucket() {
225 let mut fp = Footprint::new(0.5).unwrap();
226 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);
231 assert_eq!(out.levels[0].price, 100.0);
232 assert_eq!(out.levels[0].ask_vol, 3.0);
233 }
234
235 #[test]
236 fn reset_clears_the_footprint() {
237 let mut fp = Footprint::new(1.0).unwrap();
238 fp.update(trade(100.0, 5.0, Side::Buy));
239 assert!(fp.is_ready());
240 fp.reset();
241 assert!(!fp.is_ready());
242 let out = fp.update(trade(200.0, 1.0, Side::Sell)).unwrap();
243 assert_eq!(out.levels.len(), 1);
244 assert_eq!(out.levels[0].price, 200.0);
245 assert_eq!(out.levels[0].bid_vol, 1.0);
246 }
247
248 #[test]
249 fn batch_equals_streaming() {
250 let trades: Vec<Trade> = (0..30)
251 .map(|i| {
252 let side = if i % 3 == 0 { Side::Sell } else { Side::Buy };
253 trade(100.0 + f64::from(i % 5), 1.0 + f64::from(i % 4), side)
254 })
255 .collect();
256 let mut a = Footprint::new(1.0).unwrap();
257 let mut b = Footprint::new(1.0).unwrap();
258 assert_eq!(
259 a.batch(&trades),
260 trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
261 );
262 }
263}