Skip to main content

wickra_core/indicators/
amihud_illiquidity.rs

1//! Amihud Illiquidity — average price impact per unit traded value.
2
3use std::collections::VecDeque;
4
5use crate::indicators::rolling_moments::RollingSum;
6use crate::microstructure::Trade;
7use crate::traits::Indicator;
8use crate::{Error, Result};
9
10/// Amihud Illiquidity — the average absolute log return per unit of traded
11/// value over the last `period` trades (Amihud, 2002).
12///
13/// ```text
14/// rₜ      = ln(priceₜ / priceₜ₋₁)
15/// ILLIQₜ  = |rₜ| / (priceₜ · sizeₜ)        (return per dollar of volume)
16/// Amihud  = mean of ILLIQ over the last `period` trades
17/// ```
18///
19/// Amihud's measure captures how much the price moves for a given amount of
20/// traded value: a **high** reading means small volume already shifts the price
21/// a lot (an illiquid, easily-moved market), a **low** reading means it takes
22/// large volume to move the price (a deep, liquid market). It is the workhorse
23/// cross-sectional liquidity proxy in market-microstructure research.
24///
25/// `Input = Trade`. Trades with zero size carry no traded value and are skipped
26/// (the ratio is undefined); the last value is returned and state is untouched.
27/// The first valid trade only seeds the reference price.
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Indicator, Side, Trade, AmihudIlliquidity};
33///
34/// let mut amihud = AmihudIlliquidity::new(20).unwrap();
35/// assert_eq!(amihud.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap()), None);
36/// ```
37#[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    /// Construct a new Amihud Illiquidity over the given trade window.
48    ///
49    /// # Errors
50    /// Returns [`Error::PeriodZero`] if `period == 0`.
51    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    /// Configured period.
70    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        // A zero-size trade has no traded value: the ratio is undefined, so the
82        // trade is skipped without touching the reference price.
83        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        // `prev` and `trade.price` are both finite and strictly positive
92        // (enforced by `Trade::new`), so the log return is well-defined and the
93        // traded value is strictly positive.
94        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        // period 1. Seed at 100, then 101 with size 10:
164        // |ln(101/100)| / (101 * 10).
165        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        // Same price move on smaller volume => larger illiquidity reading.
175        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        // A zero-size trade is ignored; the previous reference price is kept.
202        assert_eq!(a.update(trade(200.0, 0.0)), Some(baseline));
203        // The next real trade still references price 101, not 200.
204        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}