Skip to main content

wickra_core/indicators/
trade_imbalance.rs

1//! Trade Imbalance — rolling buy/sell volume imbalance over a trade window.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::microstructure::Trade;
7use crate::traits::Indicator;
8
9/// Trade Imbalance — the signed buy/sell volume imbalance over the trailing
10/// window of `window` trades.
11///
12/// ```text
13/// buyVol  = Σ size of buyer-initiated trades in the window
14/// sellVol = Σ size of seller-initiated trades in the window
15/// imbalance = (buyVol − sellVol) / (buyVol + sellVol)
16/// ```
17///
18/// The output lies in `[−1, +1]`: `+1` means the window was all aggressive
19/// buying, `−1` all aggressive selling, `0` balanced (or no volume). The
20/// indicator warms up for `window` trades — `update` returns `None` until the
21/// window is full — then emits the rolling imbalance, maintained in O(1) per
22/// trade.
23///
24/// `Input = Trade`, `Output = f64`.
25///
26/// # Example
27///
28/// ```
29/// use wickra_core::{Indicator, Side, Trade, TradeImbalance};
30///
31/// let mut ti = TradeImbalance::new(2).unwrap();
32/// assert_eq!(ti.update(Trade::new(100.0, 3.0, Side::Buy, 0).unwrap()), None);
33/// // Window full: buyVol 3, sellVol 1 -> (3 - 1) / 4 = 0.5.
34/// let out = ti.update(Trade::new(100.0, 1.0, Side::Sell, 1).unwrap());
35/// assert_eq!(out, Some(0.5));
36/// ```
37#[derive(Debug, Clone)]
38pub struct TradeImbalance {
39    window: usize,
40    history: VecDeque<(f64, f64)>,
41    buy_sum: f64,
42    sell_sum: f64,
43}
44
45impl TradeImbalance {
46    /// Construct a trade-imbalance indicator over a window of `window` trades.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`Error::PeriodZero`] if `window` is zero.
51    pub fn new(window: usize) -> Result<Self> {
52        if window == 0 {
53            return Err(Error::PeriodZero);
54        }
55        Ok(Self {
56            window,
57            history: VecDeque::with_capacity(window),
58            buy_sum: 0.0,
59            sell_sum: 0.0,
60        })
61    }
62
63    /// The configured window length, in trades.
64    pub fn window(&self) -> usize {
65        self.window
66    }
67}
68
69impl Indicator for TradeImbalance {
70    type Input = Trade;
71    type Output = f64;
72
73    fn update(&mut self, trade: Trade) -> Option<f64> {
74        let (buy, sell) = if trade.side.sign() > 0.0 {
75            (trade.size, 0.0)
76        } else {
77            (0.0, trade.size)
78        };
79        self.history.push_back((buy, sell));
80        self.buy_sum += buy;
81        self.sell_sum += sell;
82        if self.history.len() > self.window {
83            let (old_buy, old_sell) = self.history.pop_front().expect("window >= 1, len > window");
84            self.buy_sum -= old_buy;
85            self.sell_sum -= old_sell;
86        }
87        if self.history.len() < self.window {
88            return None;
89        }
90        let total = self.buy_sum + self.sell_sum;
91        if total <= 0.0 {
92            return Some(0.0);
93        }
94        Some((self.buy_sum - self.sell_sum) / total)
95    }
96
97    fn reset(&mut self) {
98        self.history.clear();
99        self.buy_sum = 0.0;
100        self.sell_sum = 0.0;
101    }
102
103    fn warmup_period(&self) -> usize {
104        self.window
105    }
106
107    fn is_ready(&self) -> bool {
108        self.history.len() >= self.window
109    }
110
111    fn name(&self) -> &'static str {
112        "TradeImbalance"
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::microstructure::Side;
120    use crate::traits::BatchExt;
121
122    fn trade(size: f64, side: Side, ts: i64) -> Trade {
123        Trade::new(100.0, size, side, ts).unwrap()
124    }
125
126    #[test]
127    fn rejects_zero_window() {
128        assert!(matches!(TradeImbalance::new(0), Err(Error::PeriodZero)));
129    }
130
131    #[test]
132    fn accessors_and_metadata() {
133        let ti = TradeImbalance::new(5).unwrap();
134        assert_eq!(ti.name(), "TradeImbalance");
135        assert_eq!(ti.warmup_period(), 5);
136        assert_eq!(ti.window(), 5);
137        assert!(!ti.is_ready());
138    }
139
140    #[test]
141    fn warms_up_then_emits() {
142        let mut ti = TradeImbalance::new(2).unwrap();
143        assert_eq!(ti.update(trade(3.0, Side::Buy, 0)), None);
144        assert!(!ti.is_ready());
145        // Window full: buyVol 3, sellVol 1 -> 0.5.
146        assert_eq!(ti.update(trade(1.0, Side::Sell, 1)), Some(0.5));
147        assert!(ti.is_ready());
148    }
149
150    #[test]
151    fn rolls_off_old_trades() {
152        let mut ti = TradeImbalance::new(2).unwrap();
153        ti.update(trade(3.0, Side::Buy, 0));
154        ti.update(trade(1.0, Side::Sell, 1)); // [buy 3, sell 1] -> 0.5
155                                              // Third trade drops the first: window now [sell 1, buy 5] -> (5-1)/6.
156        let out = ti.update(trade(5.0, Side::Buy, 2)).unwrap();
157        assert!((out - (4.0 / 6.0)).abs() < 1e-12);
158    }
159
160    #[test]
161    fn zero_volume_window_is_zero() {
162        let mut ti = TradeImbalance::new(2).unwrap();
163        ti.update(trade(0.0, Side::Buy, 0));
164        assert_eq!(ti.update(trade(0.0, Side::Sell, 1)), Some(0.0));
165    }
166
167    #[test]
168    fn batch_equals_streaming() {
169        let trades: Vec<Trade> = (0..30)
170            .map(|i| {
171                let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
172                trade(1.0 + (i % 5) as f64, side, i)
173            })
174            .collect();
175        let mut a = TradeImbalance::new(5).unwrap();
176        let mut b = TradeImbalance::new(5).unwrap();
177        assert_eq!(
178            a.batch(&trades),
179            trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
180        );
181    }
182
183    #[test]
184    fn reset_clears_state() {
185        let mut ti = TradeImbalance::new(2).unwrap();
186        ti.update(trade(3.0, Side::Buy, 0));
187        ti.update(trade(1.0, Side::Sell, 1));
188        assert!(ti.is_ready());
189        ti.reset();
190        assert!(!ti.is_ready());
191        assert_eq!(ti.update(trade(2.0, Side::Buy, 2)), None);
192    }
193}