wickra_core/indicators/
trade_imbalance.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::microstructure::Trade;
7use crate::traits::Indicator;
8
9#[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 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 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 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)); 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}