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        if window > crate::error::MAX_PERIOD {
56            return Err(Error::InvalidPeriod {
57                message: crate::error::PERIOD_ABOVE_MAX,
58            });
59        }
60        Ok(Self {
61            window,
62            history: VecDeque::with_capacity(window),
63            buy_sum: 0.0,
64            sell_sum: 0.0,
65        })
66    }
67
68    /// The configured window length, in trades.
69    pub fn window(&self) -> usize {
70        self.window
71    }
72}
73
74impl Indicator for TradeImbalance {
75    type Input = Trade;
76    type Output = f64;
77
78    #[inline]
79    fn update(&mut self, trade: Trade) -> Option<f64> {
80        let (buy, sell) = if trade.side.sign() > 0.0 {
81            (trade.size, 0.0)
82        } else {
83            (0.0, trade.size)
84        };
85        self.history.push_back((buy, sell));
86        self.buy_sum += buy;
87        self.sell_sum += sell;
88        if self.history.len() > self.window {
89            let (old_buy, old_sell) = self.history.pop_front().expect("window >= 1, len > window");
90            self.buy_sum -= old_buy;
91            self.sell_sum -= old_sell;
92        }
93        if self.history.len() < self.window {
94            return None;
95        }
96        let total = self.buy_sum + self.sell_sum;
97        if total <= 0.0 {
98            return Some(0.0);
99        }
100        Some((self.buy_sum - self.sell_sum) / total)
101    }
102
103    fn reset(&mut self) {
104        self.history.clear();
105        self.buy_sum = 0.0;
106        self.sell_sum = 0.0;
107    }
108
109    #[inline]
110    fn warmup_period(&self) -> usize {
111        self.window
112    }
113
114    #[inline]
115    fn is_ready(&self) -> bool {
116        self.history.len() >= self.window
117    }
118
119    #[inline]
120    fn name(&self) -> &'static str {
121        "TradeImbalance"
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::microstructure::Side;
129    use crate::traits::BatchExt;
130
131    fn trade(size: f64, side: Side, ts: i64) -> Trade {
132        Trade::new(100.0, size, side, ts).unwrap()
133    }
134
135    #[test]
136    fn rejects_zero_window() {
137        assert!(matches!(TradeImbalance::new(0), Err(Error::PeriodZero)));
138    }
139
140    #[test]
141    fn accessors_and_metadata() {
142        let ti = TradeImbalance::new(5).unwrap();
143        assert_eq!(ti.name(), "TradeImbalance");
144        assert_eq!(ti.warmup_period(), 5);
145        assert_eq!(ti.window(), 5);
146        assert!(!ti.is_ready());
147    }
148
149    #[test]
150    fn warms_up_then_emits() {
151        let mut ti = TradeImbalance::new(2).unwrap();
152        assert_eq!(ti.update(trade(3.0, Side::Buy, 0)), None);
153        assert!(!ti.is_ready());
154        // Window full: buyVol 3, sellVol 1 -> 0.5.
155        assert_eq!(ti.update(trade(1.0, Side::Sell, 1)), Some(0.5));
156        assert!(ti.is_ready());
157    }
158
159    #[test]
160    fn rolls_off_old_trades() {
161        let mut ti = TradeImbalance::new(2).unwrap();
162        ti.update(trade(3.0, Side::Buy, 0));
163        ti.update(trade(1.0, Side::Sell, 1)); // [buy 3, sell 1] -> 0.5
164                                              // Third trade drops the first: window now [sell 1, buy 5] -> (5-1)/6.
165        let out = ti.update(trade(5.0, Side::Buy, 2)).unwrap();
166        assert!((out - (4.0 / 6.0)).abs() < 1e-12);
167    }
168
169    #[test]
170    fn zero_volume_window_is_zero() {
171        let mut ti = TradeImbalance::new(2).unwrap();
172        ti.update(trade(0.0, Side::Buy, 0));
173        assert_eq!(ti.update(trade(0.0, Side::Sell, 1)), Some(0.0));
174    }
175
176    #[test]
177    fn batch_equals_streaming() {
178        let trades: Vec<Trade> = (0..30)
179            .map(|i| {
180                let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
181                trade(1.0 + (i % 5) as f64, side, i)
182            })
183            .collect();
184        let mut a = TradeImbalance::new(5).unwrap();
185        let mut b = TradeImbalance::new(5).unwrap();
186        assert_eq!(
187            a.batch(&trades),
188            trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
189        );
190    }
191
192    #[test]
193    fn reset_clears_state() {
194        let mut ti = TradeImbalance::new(2).unwrap();
195        ti.update(trade(3.0, Side::Buy, 0));
196        ti.update(trade(1.0, Side::Sell, 1));
197        assert!(ti.is_ready());
198        ti.reset();
199        assert!(!ti.is_ready());
200        assert_eq!(ti.update(trade(2.0, Side::Buy, 2)), None);
201    }
202}