Skip to main content

wickra_core/indicators/
granger_causality.rs

1//! Granger causality F-statistic: does series `b` help predict series `a`?
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Granger causality of `b` on `a` over a rolling window, as an F-statistic.
9///
10/// Each `update` takes one `(a, b)` pair. Over the trailing window of `period`
11/// observations the indicator fits two autoregressions of `a` and compares them
12/// with an F-test:
13///
14/// ```text
15/// restricted:    aₜ = c + Σ φᵢ·aₜ₋ᵢ                       (a's own lags only)
16/// unrestricted:  aₜ = c + Σ φᵢ·aₜ₋ᵢ + Σ ψᵢ·bₜ₋ᵢ          (+ b's lags)
17/// F = ((RSSᵣ − RSSᵤ) / lag) / (RSSᵤ / (n − 2·lag − 1))
18/// ```
19///
20/// If adding `b`'s lags significantly reduces the residual sum of squares, `b`
21/// **Granger-causes** `a`: past values of `b` carry information about the future
22/// of `a` beyond what `a`'s own past holds. A **larger** F means stronger
23/// predictive causality (lead–lag structure a stat-arb model can trade); a
24/// value near `0` means `b` adds nothing. Note Granger causality is purely
25/// predictive — it is not structural cause and effect.
26///
27/// The statistic is `0` when a regression is degenerate — a collinear or flat
28/// window makes the normal equations singular. The output is always `≥ 0`.
29///
30/// Each `update` is `O(period · lag² + lag³)`, bounded by the fixed parameters.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{GrangerCausality, Indicator};
36///
37/// let mut g = GrangerCausality::new(60, 1).unwrap();
38/// let mut last = None;
39/// for t in 0..120 {
40///     let drive = (f64::from(t) * 0.3).sin();
41///     // a echoes b's previous value plus noise ⇒ b Granger-causes a.
42///     let b = drive;
43///     let a = 0.5 * (f64::from(t.max(1) - 1) * 0.3).sin() + 0.1 * (f64::from(t) * 0.9).cos();
44///     last = g.update((a, b));
45/// }
46/// assert!(last.unwrap() >= 0.0);
47/// ```
48#[derive(Debug, Clone)]
49pub struct GrangerCausality {
50    period: usize,
51    lag: usize,
52    window: VecDeque<(f64, f64)>,
53    /// Regressand: the value of channel `a` at each observation.
54    target: Vec<f64>,
55    /// Design matrix of the restricted model, row-major with stride `lag + 1`.
56    restricted: Vec<f64>,
57    /// Design matrix of the unrestricted model, stride `2 * lag + 1`.
58    unrestricted: Vec<f64>,
59    /// Normal-equation workspace, sized for the larger of the two models:
60    /// `xtx` is row-major with stride `num_reg`.
61    xtx: Vec<f64>,
62    xty: Vec<f64>,
63    theta: Vec<f64>,
64}
65
66impl GrangerCausality {
67    /// Construct a new Granger causality test.
68    ///
69    /// `period` is the look-back window; `lag` is the autoregressive order
70    /// (number of own/cross lags in each model).
71    ///
72    /// # Errors
73    /// Returns [`Error::InvalidPeriod`] if `lag < 1` or if `period < 3·lag + 2`
74    /// (the smallest window that leaves the unrestricted regression at least one
75    /// residual degree of freedom).
76    pub fn new(period: usize, lag: usize) -> Result<Self> {
77        if lag < 1 {
78            return Err(Error::InvalidPeriod {
79                message: "granger causality needs lag >= 1",
80            });
81        }
82        if lag > crate::error::MAX_PERIOD {
83            return Err(Error::InvalidPeriod {
84                message: crate::error::PERIOD_ABOVE_MAX,
85            });
86        }
87        if period < 3 * lag + 2 {
88            return Err(Error::InvalidPeriod {
89                message: "granger causality needs period >= 3*lag + 2",
90            });
91        }
92        Ok(Self {
93            period,
94            lag,
95            window: VecDeque::with_capacity(period),
96            target: Vec::with_capacity(period),
97            restricted: Vec::with_capacity(period * (lag + 1)),
98            unrestricted: Vec::with_capacity(period * (2 * lag + 1)),
99            xtx: Vec::with_capacity((2 * lag + 1) * (2 * lag + 1)),
100            xty: Vec::with_capacity(2 * lag + 1),
101            theta: Vec::with_capacity(2 * lag + 1),
102        })
103    }
104
105    /// Configured look-back window.
106    pub const fn period(&self) -> usize {
107        self.period
108    }
109
110    /// Configured autoregressive order.
111    pub const fn lag(&self) -> usize {
112        self.lag
113    }
114}
115
116impl Indicator for GrangerCausality {
117    type Input = (f64, f64);
118    type Output = f64;
119
120    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
121        if !input.0.is_finite() || !input.1.is_finite() {
122            return None;
123        }
124        if self.window.len() == self.period {
125            self.window.pop_front();
126        }
127        self.window.push_back(input);
128        if self.window.len() < self.period {
129            return None;
130        }
131        let lag = self.lag;
132        let num_obs = self.period - lag;
133
134        // The channels are read at single positions, which a `VecDeque` indexes
135        // directly; the design matrices are flat and row-major so neither the
136        // rows nor the matrices themselves need an allocation per update.
137        self.target.clear();
138        self.restricted.clear();
139        self.unrestricted.clear();
140        for k in 0..num_obs {
141            let now = lag + k;
142            self.target.push(self.window[now].0);
143            self.restricted.push(1.0);
144            for back in 1..=lag {
145                self.restricted.push(self.window[now - back].0);
146            }
147            // The unrestricted row is the restricted one with the cross-channel
148            // lags appended, exactly as the cloned row built it before.
149            let row_start = k * (lag + 1);
150            for offset in 0..=lag {
151                let value = self.restricted[row_start + offset];
152                self.unrestricted.push(value);
153            }
154            for back in 1..=lag {
155                self.unrestricted.push(self.window[now - back].1);
156            }
157        }
158
159        let Self {
160            target,
161            restricted,
162            unrestricted,
163            xtx,
164            xty,
165            theta,
166            ..
167        } = self;
168        let Some(rss_r) = ols_rss(restricted, lag + 1, target, xtx, xty, theta) else {
169            return Some(0.0);
170        };
171        let Some(rss_u) = ols_rss(unrestricted, 2 * lag + 1, target, xtx, xty, theta) else {
172            return Some(0.0);
173        };
174        let dof = (num_obs - (2 * lag + 1)) as f64;
175        let numerator = (rss_r - rss_u) / lag as f64;
176        let denominator = rss_u / dof;
177        Some((numerator / denominator).max(0.0))
178    }
179
180    fn reset(&mut self) {
181        self.window.clear();
182        self.target.clear();
183        self.restricted.clear();
184        self.unrestricted.clear();
185        self.xtx.clear();
186        self.xty.clear();
187        self.theta.clear();
188    }
189
190    #[inline]
191    fn warmup_period(&self) -> usize {
192        self.period
193    }
194
195    #[inline]
196    fn is_ready(&self) -> bool {
197        self.window.len() == self.period
198    }
199
200    #[inline]
201    fn name(&self) -> &'static str {
202        "GrangerCausality"
203    }
204}
205
206/// Residual sum of squares of the OLS fit of `target` on the design `rows`,
207/// row-major with stride `num_reg`. The workspace buffers are supplied by the
208/// caller so nothing is allocated per update. Returns `None` if the normal
209/// equations are singular.
210fn ols_rss(
211    rows: &[f64],
212    num_reg: usize,
213    target: &[f64],
214    xtx: &mut Vec<f64>,
215    xty: &mut Vec<f64>,
216    theta: &mut Vec<f64>,
217) -> Option<f64> {
218    xtx.clear();
219    xtx.resize(num_reg * num_reg, 0.0);
220    xty.clear();
221    xty.resize(num_reg, 0.0);
222    for (row, &observed) in rows.chunks_exact(num_reg).zip(target) {
223        for (ri, &left) in row.iter().enumerate() {
224            xty[ri] += left * observed;
225            for (ci, &right) in row.iter().enumerate() {
226                xtx[ri * num_reg + ci] += left * right;
227            }
228        }
229    }
230    solve(xtx, num_reg, xty, theta)?;
231    let mut rss = 0.0;
232    for (row, &observed) in rows.chunks_exact(num_reg).zip(target) {
233        let pred: f64 = row
234            .iter()
235            .zip(theta.iter())
236            .map(|(coeff, value)| coeff * value)
237            .sum();
238        let resid = observed - pred;
239        rss += resid * resid;
240    }
241    Some(rss)
242}
243
244/// Solve the linear system `mat·x = rhs` by Gaussian elimination, writing the
245/// solution into `sol` and returning `None` if the matrix is (numerically)
246/// singular. `mat` is row-major with the given stride, and both it and `rhs`
247/// are consumed in place.
248fn solve(mat: &mut [f64], stride: usize, rhs: &mut [f64], sol: &mut Vec<f64>) -> Option<()> {
249    let dim = rhs.len();
250    for col in 0..dim {
251        let pivot = mat[col * stride + col];
252        if pivot.abs() < 1e-12 {
253            return None;
254        }
255        // The pivot row is only read and the rows below it only written, so
256        // splitting the matrix hands out both without copying the pivot row.
257        let (above, below) = mat.split_at_mut((col + 1) * stride);
258        let pivot_row = &above[col * stride..col * stride + stride];
259        for row in (col + 1)..dim {
260            let offset = (row - col - 1) * stride;
261            let target_row = &mut below[offset..offset + stride];
262            let factor = target_row[col] / pivot;
263            for (cell, &value) in target_row.iter_mut().zip(pivot_row).skip(col) {
264                *cell -= factor * value;
265            }
266            rhs[row] -= factor * rhs[col];
267        }
268    }
269    sol.clear();
270    sol.resize(dim, 0.0);
271    for row in (0..dim).rev() {
272        let known: f64 = mat[row * stride..row * stride + stride]
273            .iter()
274            .zip(sol.iter())
275            .skip(row + 1)
276            .map(|(coeff, value)| coeff * value)
277            .sum();
278        sol[row] = (rhs[row] - known) / mat[row * stride + row];
279    }
280    Some(())
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::traits::BatchExt;
287
288    #[test]
289    fn rejects_bad_parameters() {
290        assert!(GrangerCausality::new(10, 0).is_err()); // lag must be >= 1
291        assert!(GrangerCausality::new(4, 1).is_err()); // period must be >= 3*lag + 2
292        assert!(GrangerCausality::new(5, 1).is_ok());
293    }
294
295    #[test]
296    fn accessors_and_metadata() {
297        let g = GrangerCausality::new(60, 2).unwrap();
298        assert_eq!(g.period(), 60);
299        assert_eq!(g.lag(), 2);
300        assert_eq!(g.warmup_period(), 60);
301        assert_eq!(g.name(), "GrangerCausality");
302        assert!(!g.is_ready());
303    }
304
305    #[test]
306    fn warmup_returns_none() {
307        let mut g = GrangerCausality::new(5, 1).unwrap();
308        for t in 0..4 {
309            assert_eq!(g.update((f64::from(t), f64::from(t) * 0.5)), None);
310        }
311        assert!(g.update((4.0, 2.0)).is_some());
312        assert!(g.is_ready());
313    }
314
315    #[test]
316    fn b_leading_a_has_positive_statistic() {
317        // a[t] is driven by b[t-1] plus a little of its own past ⇒ b helps.
318        let mut prev_drive = 0.0;
319        let pairs: Vec<(f64, f64)> = (0..120)
320            .map(|t| {
321                let drive = (f64::from(t) * 0.3).sin() + 0.4 * (f64::from(t) * 0.11).cos();
322                let a = 0.8 * prev_drive + 0.05 * (f64::from(t) * 0.7).sin();
323                prev_drive = drive;
324                (a, drive)
325            })
326            .collect();
327        let last = GrangerCausality::new(60, 1)
328            .unwrap()
329            .batch(&pairs)
330            .into_iter()
331            .flatten()
332            .last()
333            .unwrap();
334        assert!(last > 1.0, "F {last}");
335    }
336
337    #[test]
338    fn constant_b_is_singular_and_returns_zero() {
339        // b is constant ⇒ its lag columns are collinear with the intercept ⇒
340        // the unrestricted normal equations are singular ⇒ 0.
341        let pairs: Vec<(f64, f64)> = (0..40)
342            .map(|t| (f64::from(t) + (f64::from(t) * 0.6).sin(), 3.0))
343            .collect();
344        let last = GrangerCausality::new(20, 1)
345            .unwrap()
346            .batch(&pairs)
347            .into_iter()
348            .flatten()
349            .last()
350            .unwrap();
351        assert_eq!(last, 0.0);
352    }
353
354    #[test]
355    fn constant_a_restricted_singular_returns_zero() {
356        // a is constant ⇒ its own lag columns are collinear with the intercept
357        // ⇒ the restricted normal equations are singular ⇒ 0.
358        let pairs: Vec<(f64, f64)> = (0..40).map(|t| (5.0, (f64::from(t) * 0.4).sin())).collect();
359        let last = GrangerCausality::new(20, 1)
360            .unwrap()
361            .batch(&pairs)
362            .into_iter()
363            .flatten()
364            .last()
365            .unwrap();
366        assert_eq!(last, 0.0);
367    }
368
369    #[test]
370    fn reset_clears_state() {
371        let mut g = GrangerCausality::new(8, 1).unwrap();
372        for t in 0..12 {
373            g.update((
374                f64::from(t) + (f64::from(t) * 0.7).sin(),
375                (f64::from(t) * 0.3).cos(),
376            ));
377        }
378        assert!(g.is_ready());
379        g.reset();
380        assert!(!g.is_ready());
381        assert_eq!(g.update((1.0, 1.0)), None);
382    }
383
384    #[test]
385    fn batch_equals_streaming() {
386        let pairs: Vec<(f64, f64)> = (0..80)
387            .map(|t| {
388                let b = (f64::from(t) * 0.4).sin();
389                (
390                    0.6 * (f64::from(t.max(1) - 1) * 0.4).sin() + 0.1 * f64::from(t % 3),
391                    b,
392                )
393            })
394            .collect();
395        let batch = GrangerCausality::new(30, 2).unwrap().batch(&pairs);
396        let mut g = GrangerCausality::new(30, 2).unwrap();
397        let streamed: Vec<_> = pairs.iter().map(|p| g.update(*p)).collect();
398        assert_eq!(batch, streamed);
399    }
400
401    #[test]
402    fn non_finite_input_returns_none() {
403        let mut g = GrangerCausality::new(5, 1).unwrap();
404        assert_eq!(g.update((f64::NAN, 1.0)), None);
405        assert_eq!(g.update((1.0, f64::INFINITY)), None);
406        // The rejected ticks leave no trace: a fresh window still warms up.
407        for t in 0..4 {
408            assert_eq!(g.update((f64::from(t), f64::from(t) * 0.5)), None);
409        }
410        assert!(g.update((4.0, 2.0)).is_some());
411    }
412}