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