Skip to main content

wickra_core/indicators/
footprint.rs

1//! Footprint — buy/sell volume profile per price bucket within a bar.
2
3use std::collections::BTreeMap;
4
5use crate::error::{Error, Result};
6use crate::microstructure::Trade;
7use crate::traits::Indicator;
8
9/// One price bucket of a [`Footprint`]: the buy- and sell-initiated volume that
10/// traded there since the last reset.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct FootprintLevel {
13    /// Bucket price (the bucket index times the tick size).
14    pub price: f64,
15    /// Sell-initiated (bid-hitting) volume traded at this bucket.
16    pub bid_vol: f64,
17    /// Buy-initiated (ask-lifting) volume traded at this bucket.
18    pub ask_vol: f64,
19}
20
21/// The full footprint of a bar: one [`FootprintLevel`] per touched price
22/// bucket, sorted ascending by price.
23#[derive(Debug, Clone, PartialEq, Default)]
24pub struct FootprintOutput {
25    /// Touched price buckets, lowest price first.
26    pub levels: Vec<FootprintLevel>,
27}
28
29/// Footprint — the buy/sell volume profile of a bar, bucketed by price.
30///
31/// A footprint (a.k.a. bid/ask or volume cluster chart) decomposes the volume
32/// traded within a bar across the price levels at which it printed, splitting
33/// each level into buy-initiated (ask-lifting) and sell-initiated (bid-hitting)
34/// volume. It exposes *where* inside a bar the activity happened and which side
35/// was the aggressor there — the basis for absorption, imbalance and
36/// point-of-control analysis that a single OHLCV bar hides.
37///
38/// Each trade is assigned to the price bucket `round(price / tick_size)`; its
39/// size is added to that bucket's ask volume for a buy and bid volume for a
40/// sell. Every [`update`] returns the complete footprint accumulated since the
41/// last [`reset`], as a [`FootprintOutput`] whose `levels` are sorted ascending
42/// by price. Call [`reset`] at each bar (or session) boundary to start a fresh
43/// footprint.
44///
45/// `Input = Trade`, `Output = FootprintOutput`. Ready after the first trade.
46///
47/// [`update`]: crate::Indicator::update
48/// [`reset`]: crate::Indicator::reset
49///
50/// # Example
51///
52/// ```
53/// use wickra_core::{Footprint, Indicator, Side, Trade};
54///
55/// let mut fp = Footprint::new(1.0).unwrap();
56/// fp.update(Trade::new(100.2, 2.0, Side::Buy, 0).unwrap());
57/// let out = fp.update(Trade::new(100.7, 3.0, Side::Sell, 1).unwrap()).unwrap();
58/// // Two buckets: 100 (ask 2) and 101 (bid 3).
59/// assert_eq!(out.levels.len(), 2);
60/// assert_eq!(out.levels[0].price, 100.0);
61/// assert_eq!(out.levels[0].ask_vol, 2.0);
62/// assert_eq!(out.levels[1].price, 101.0);
63/// assert_eq!(out.levels[1].bid_vol, 3.0);
64/// ```
65#[derive(Debug, Clone)]
66pub struct Footprint {
67    tick_size: f64,
68    // bucket index -> (bid_vol = sell-initiated, ask_vol = buy-initiated).
69    buckets: BTreeMap<i64, (f64, f64)>,
70    has_emitted: bool,
71}
72
73impl Footprint {
74    /// Construct a footprint with the given price-bucket `tick_size`.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`Error::InvalidTick`] if `tick_size` is not a finite, strictly
79    /// positive number.
80    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    /// The configured price-bucket size.
94    pub const fn tick_size(&self) -> f64 {
95        self.tick_size
96    }
97
98    fn bucket_index(&self, price: f64) -> i64 {
99        // Float-to-int `as` saturates rather than wrapping, so an extreme
100        // price/tick ratio clamps to i64::MIN/MAX instead of misbehaving;
101        // realistic ratios fit comfortably.
102        #[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        // Bucket 100: buy 2 + buy 1 = ask 3, bid 0. Bucket 101: sell 3.
204        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        // 100.24 and 100.26 both round to bucket 200 (price 100.0)... check:
227        // 100.24/0.5 = 200.48 -> 200; 100.26/0.5 = 200.52 -> 201. Distinct.
228        fp.update(trade(100.20, 1.0, Side::Buy)); // 200.4 -> 200 -> price 100.0
229        let out = fp.update(trade(100.10, 2.0, Side::Buy)).unwrap(); // 200.2 -> 200
230        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}