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