Skip to main content

wickra_core/indicators/
roll_measure.rs

1//! Roll Measure — effective spread implied by serial covariance of price changes.
2
3use std::collections::VecDeque;
4
5use crate::microstructure::Trade;
6use crate::traits::Indicator;
7use crate::{Error, Result};
8
9/// Roll Measure — the effective bid-ask spread implied by the negative
10/// first-order serial covariance of trade-price changes (Roll, 1984).
11///
12/// ```text
13/// Δpₜ  = priceₜ − priceₜ₋₁
14/// γ    = sample lag-1 autocovariance of Δp over the last `period` changes
15/// spread = 2 · √(−γ)   if γ < 0,   else 0
16/// ```
17///
18/// Roll's insight: in a frictionless market price changes are serially
19/// uncorrelated, but the *bid-ask bounce* — trades alternating between buying at
20/// the ask and selling at the bid — induces a **negative** autocovariance whose
21/// magnitude pins the spread. The measure recovers an effective spread from
22/// trade prices alone, with no quote data. When the serial covariance is
23/// non-negative (a trending or frictionless tape) the model implies no spread
24/// and the indicator returns `0`.
25///
26/// `Input = Trade` (only the price is used). Each `update` is `O(period)`: the
27/// autocovariance is recomputed from the window of price changes.
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Indicator, Side, Trade, RollMeasure};
33///
34/// let mut roll = RollMeasure::new(20).unwrap();
35/// let mut last = None;
36/// // A clean bid-ask bounce of ±0.5 around 100 implies a spread near 1.0.
37/// for i in 0..40 {
38///     let price = if i % 2 == 0 { 100.0 } else { 101.0 };
39///     last = roll.update(Trade::new(price, 1.0, Side::Buy, 0).unwrap());
40/// }
41/// assert!(last.unwrap() > 0.0);
42/// ```
43#[derive(Debug, Clone)]
44pub struct RollMeasure {
45    period: usize,
46    prev_price: Option<f64>,
47    window: VecDeque<f64>,
48}
49
50impl RollMeasure {
51    /// Construct a new Roll Measure over the given window of price changes.
52    ///
53    /// # Errors
54    /// Returns [`Error::InvalidPeriod`] if `period < 3` — the lag-1
55    /// autocovariance needs at least two consecutive change pairs.
56    pub fn new(period: usize) -> Result<Self> {
57        if period < 3 {
58            return Err(Error::InvalidPeriod {
59                message: "Roll measure needs period >= 3",
60            });
61        }
62        Ok(Self {
63            period,
64            prev_price: None,
65            window: VecDeque::with_capacity(period),
66        })
67    }
68
69    /// Configured period.
70    pub const fn period(&self) -> usize {
71        self.period
72    }
73}
74
75impl Indicator for RollMeasure {
76    type Input = Trade;
77    type Output = f64;
78
79    fn update(&mut self, trade: Trade) -> Option<f64> {
80        let Some(prev) = self.prev_price else {
81            self.prev_price = Some(trade.price);
82            return None;
83        };
84        let change = trade.price - prev;
85        self.prev_price = Some(trade.price);
86        if self.window.len() == self.period {
87            self.window.pop_front();
88        }
89        self.window.push_back(change);
90        if self.window.len() < self.period {
91            return None;
92        }
93        // Sample lag-1 autocovariance of the price changes over the window.
94        let changes: Vec<f64> = self.window.iter().copied().collect();
95        let count = changes.len() as f64;
96        let mean = changes.iter().sum::<f64>() / count;
97        let pairs = (changes.len() - 1) as f64;
98        let mut cov = 0.0;
99        for pair in changes.windows(2) {
100            cov += (pair[0] - mean) * (pair[1] - mean);
101        }
102        cov /= pairs;
103        let spread = if cov < 0.0 { 2.0 * (-cov).sqrt() } else { 0.0 };
104        Some(spread)
105    }
106
107    fn reset(&mut self) {
108        self.prev_price = None;
109        self.window.clear();
110    }
111
112    fn warmup_period(&self) -> usize {
113        self.period + 1
114    }
115
116    fn is_ready(&self) -> bool {
117        self.window.len() == self.period
118    }
119
120    fn name(&self) -> &'static str {
121        "RollMeasure"
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::microstructure::Side;
129    use crate::traits::BatchExt;
130    use approx::assert_relative_eq;
131
132    fn trade(price: f64) -> Trade {
133        Trade::new(price, 1.0, Side::Buy, 0).unwrap()
134    }
135
136    #[test]
137    fn rejects_period_below_three() {
138        assert!(matches!(
139            RollMeasure::new(2),
140            Err(Error::InvalidPeriod { .. })
141        ));
142        assert!(RollMeasure::new(3).is_ok());
143    }
144
145    #[test]
146    fn accessors_and_metadata() {
147        let roll = RollMeasure::new(20).unwrap();
148        assert_eq!(roll.period(), 20);
149        assert_eq!(roll.warmup_period(), 21);
150        assert_eq!(roll.name(), "RollMeasure");
151        assert!(!roll.is_ready());
152    }
153
154    #[test]
155    fn bid_ask_bounce_implies_spread() {
156        // Prices bounce 100/101 => Δp alternates +1/-1 => mean 0, lag-1
157        // autocov = -5/(6-1) = -1 over a 6-change window => spread = 2.
158        let mut roll = RollMeasure::new(6).unwrap();
159        let prices: Vec<Trade> = (0..20)
160            .map(|i| trade(if i % 2 == 0 { 100.0 } else { 101.0 }))
161            .collect();
162        let last = roll.batch(&prices).into_iter().flatten().last().unwrap();
163        assert_relative_eq!(last, 2.0, epsilon = 1e-12);
164    }
165
166    #[test]
167    fn trending_prices_imply_no_spread() {
168        // Monotone prices => constant Δp => zero-centred deviations => cov 0
169        // => spread 0.
170        let mut roll = RollMeasure::new(6).unwrap();
171        let prices: Vec<Trade> = (0..20).map(|i| trade(100.0 + f64::from(i))).collect();
172        for v in roll.batch(&prices).into_iter().flatten() {
173            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
174        }
175    }
176
177    #[test]
178    fn output_is_non_negative() {
179        let mut roll = RollMeasure::new(20).unwrap();
180        let prices: Vec<Trade> = (0..200)
181            .map(|i| trade(100.0 + (f64::from(i) * 0.7).sin() * 2.0))
182            .collect();
183        for v in roll.batch(&prices).into_iter().flatten() {
184            assert!(v >= 0.0, "spread must be non-negative, got {v}");
185        }
186    }
187
188    #[test]
189    fn reset_clears_state() {
190        let mut roll = RollMeasure::new(5).unwrap();
191        for i in 0..20 {
192            roll.update(trade(100.0 + f64::from(i % 2)));
193        }
194        assert!(roll.is_ready());
195        roll.reset();
196        assert!(!roll.is_ready());
197        assert_eq!(roll.update(trade(100.0)), None);
198    }
199
200    #[test]
201    fn batch_equals_streaming() {
202        let prices: Vec<Trade> = (0..80)
203            .map(|i| trade(100.0 + (f64::from(i) * 0.6).sin() * 3.0))
204            .collect();
205        let batch = RollMeasure::new(14).unwrap().batch(&prices);
206        let mut b = RollMeasure::new(14).unwrap();
207        let streamed: Vec<_> = prices.iter().map(|t| b.update(*t)).collect();
208        assert_eq!(batch, streamed);
209    }
210}