wickra_core/indicators/
amihud_illiquidity.rs1use std::collections::VecDeque;
4
5use crate::microstructure::Trade;
6use crate::traits::Indicator;
7use crate::{Error, Result};
8
9#[derive(Debug, Clone)]
37pub struct AmihudIlliquidity {
38 period: usize,
39 prev_price: Option<f64>,
40 window: VecDeque<f64>,
41 sum: f64,
42 last: Option<f64>,
43}
44
45impl AmihudIlliquidity {
46 pub fn new(period: usize) -> Result<Self> {
51 if period == 0 {
52 return Err(Error::PeriodZero);
53 }
54 Ok(Self {
55 period,
56 prev_price: None,
57 window: VecDeque::with_capacity(period),
58 sum: 0.0,
59 last: None,
60 })
61 }
62
63 pub const fn period(&self) -> usize {
65 self.period
66 }
67}
68
69impl Indicator for AmihudIlliquidity {
70 type Input = Trade;
71 type Output = f64;
72
73 fn update(&mut self, trade: Trade) -> Option<f64> {
74 if trade.size == 0.0 {
77 return self.last;
78 }
79 let Some(prev) = self.prev_price else {
80 self.prev_price = Some(trade.price);
81 return None;
82 };
83 self.prev_price = Some(trade.price);
84 let ret = (trade.price / prev).ln().abs();
88 let illiq = ret / (trade.price * trade.size);
89 if self.window.len() == self.period {
90 let old = self.window.pop_front().expect("window is non-empty");
91 self.sum -= old;
92 }
93 self.window.push_back(illiq);
94 self.sum += illiq;
95 if self.window.len() < self.period {
96 return None;
97 }
98 let value = self.sum / self.period as f64;
99 self.last = Some(value);
100 Some(value)
101 }
102
103 fn reset(&mut self) {
104 self.prev_price = None;
105 self.window.clear();
106 self.sum = 0.0;
107 self.last = None;
108 }
109
110 fn warmup_period(&self) -> usize {
111 self.period + 1
112 }
113
114 fn is_ready(&self) -> bool {
115 self.last.is_some()
116 }
117
118 fn name(&self) -> &'static str {
119 "AmihudIlliquidity"
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use crate::microstructure::Side;
127 use crate::traits::BatchExt;
128 use approx::assert_relative_eq;
129
130 fn trade(price: f64, size: f64) -> Trade {
131 Trade::new(price, size, Side::Buy, 0).unwrap()
132 }
133
134 #[test]
135 fn rejects_zero_period() {
136 assert!(matches!(AmihudIlliquidity::new(0), Err(Error::PeriodZero)));
137 }
138
139 #[test]
140 fn accessors_and_metadata() {
141 let a = AmihudIlliquidity::new(20).unwrap();
142 assert_eq!(a.period(), 20);
143 assert_eq!(a.warmup_period(), 21);
144 assert_eq!(a.name(), "AmihudIlliquidity");
145 assert!(!a.is_ready());
146 }
147
148 #[test]
149 fn known_value() {
150 let mut a = AmihudIlliquidity::new(1).unwrap();
153 assert_eq!(a.update(trade(100.0, 10.0)), None);
154 let out = a.update(trade(101.0, 10.0)).unwrap();
155 let expected = (101.0_f64 / 100.0).ln().abs() / (101.0 * 10.0);
156 assert_relative_eq!(out, expected, epsilon = 1e-15);
157 }
158
159 #[test]
160 fn higher_for_thinner_volume() {
161 let thin = {
163 let mut a = AmihudIlliquidity::new(1).unwrap();
164 a.update(trade(100.0, 1.0));
165 a.update(trade(101.0, 1.0)).unwrap()
166 };
167 let thick = {
168 let mut a = AmihudIlliquidity::new(1).unwrap();
169 a.update(trade(100.0, 1000.0));
170 a.update(trade(101.0, 1000.0)).unwrap()
171 };
172 assert!(thin > thick, "thin {thin} should exceed thick {thick}");
173 }
174
175 #[test]
176 fn flat_price_is_zero() {
177 let mut a = AmihudIlliquidity::new(5).unwrap();
178 for v in a.batch(&[trade(100.0, 3.0); 20]).into_iter().flatten() {
179 assert_relative_eq!(v, 0.0, epsilon = 1e-15);
180 }
181 }
182
183 #[test]
184 fn skips_zero_size_trades() {
185 let mut a = AmihudIlliquidity::new(1).unwrap();
186 a.update(trade(100.0, 10.0));
187 let baseline = a.update(trade(101.0, 10.0)).unwrap();
188 assert_eq!(a.update(trade(200.0, 0.0)), Some(baseline));
190 let mut control = a.clone();
192 let after = a.update(trade(102.0, 10.0)).unwrap();
193 assert_eq!(control.update(trade(102.0, 10.0)).unwrap(), after);
194 }
195
196 #[test]
197 fn output_is_non_negative() {
198 let mut a = AmihudIlliquidity::new(10).unwrap();
199 let trades: Vec<Trade> = (0..100)
200 .map(|i| {
201 trade(
202 100.0 + (f64::from(i) * 0.3).sin() * 5.0,
203 1.0 + f64::from(i % 7),
204 )
205 })
206 .collect();
207 for v in a.batch(&trades).into_iter().flatten() {
208 assert!(v >= 0.0, "illiquidity must be non-negative, got {v}");
209 }
210 }
211
212 #[test]
213 fn reset_clears_state() {
214 let mut a = AmihudIlliquidity::new(5).unwrap();
215 for i in 0..20 {
216 a.update(trade(100.0 + f64::from(i), 2.0));
217 }
218 assert!(a.is_ready());
219 a.reset();
220 assert!(!a.is_ready());
221 assert_eq!(a.update(trade(100.0, 1.0)), None);
222 }
223
224 #[test]
225 fn batch_equals_streaming() {
226 let trades: Vec<Trade> = (0..80)
227 .map(|i| {
228 trade(
229 100.0 + (f64::from(i) * 0.25).sin() * 4.0,
230 1.0 + f64::from(i % 5),
231 )
232 })
233 .collect();
234 let batch = AmihudIlliquidity::new(14).unwrap().batch(&trades);
235 let mut b = AmihudIlliquidity::new(14).unwrap();
236 let streamed: Vec<_> = trades.iter().map(|t| b.update(*t)).collect();
237 assert_eq!(batch, streamed);
238 }
239}