Skip to main content

wickra_core/indicators/
alpha.rs

1//! Rolling Jensen's Alpha (CAPM).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rolling Jensen's Alpha.
9///
10/// Each `update` receives one `(asset_return, benchmark_return)` pair. Over
11/// the trailing window of `period` pairs:
12///
13/// ```text
14/// Beta  = cov(asset, bench) / var(bench)
15/// Alpha = mean(asset) − ( risk_free + Beta · (mean(bench) − risk_free) )
16/// ```
17///
18/// Alpha is the *risk-adjusted excess return* — the slice of the asset's
19/// performance that cannot be explained by simple exposure to the
20/// benchmark. A positive alpha indicates outperformance net of the market
21/// premium implied by the asset's beta; negative alpha is the opposite.
22///
23/// Population covariance and variance are used (matching common
24/// implementations in pandas-ta / quantstats); the rolling estimator stays
25/// unbiased in the steady state for fixed `period`.
26///
27/// If the benchmark is flat (`var(bench) = 0`) the indicator falls back to
28/// `alpha = mean(asset) − risk_free` — the asset's mean excess return, with
29/// no market-risk adjustment, since the regression slope is undefined.
30///
31/// Each `update` is O(1).
32#[derive(Debug, Clone)]
33pub struct Alpha {
34    period: usize,
35    risk_free: f64,
36    window: VecDeque<(f64, f64)>,
37    sum_a: f64,
38    sum_b: f64,
39    sum_bb: f64,
40    sum_ab: f64,
41}
42
43impl Alpha {
44    /// Construct a new rolling Alpha.
45    ///
46    /// # Errors
47    /// Returns [`Error::InvalidPeriod`] if `period < 2`.
48    pub fn new(period: usize, risk_free: f64) -> Result<Self> {
49        if period < 2 {
50            return Err(Error::InvalidPeriod {
51                message: "alpha needs period >= 2",
52            });
53        }
54        Ok(Self {
55            period,
56            risk_free,
57            window: VecDeque::with_capacity(period),
58            sum_a: 0.0,
59            sum_b: 0.0,
60            sum_bb: 0.0,
61            sum_ab: 0.0,
62        })
63    }
64
65    /// Configured window length.
66    pub const fn period(&self) -> usize {
67        self.period
68    }
69
70    /// Configured per-period risk-free rate.
71    pub const fn risk_free(&self) -> f64 {
72        self.risk_free
73    }
74}
75
76impl Indicator for Alpha {
77    type Input = (f64, f64);
78    type Output = f64;
79
80    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
81        let (a, b) = input;
82        if !a.is_finite() || !b.is_finite() {
83            return None;
84        }
85        if self.window.len() == self.period {
86            let (oa, ob) = self.window.pop_front().expect("non-empty");
87            self.sum_a -= oa;
88            self.sum_b -= ob;
89            self.sum_bb -= ob * ob;
90            self.sum_ab -= oa * ob;
91        }
92        self.window.push_back((a, b));
93        self.sum_a += a;
94        self.sum_b += b;
95        self.sum_bb += b * b;
96        self.sum_ab += a * b;
97        if self.window.len() < self.period {
98            return None;
99        }
100        let n = self.period as f64;
101        let mean_a = self.sum_a / n;
102        let mean_b = self.sum_b / n;
103        let var_b = (self.sum_bb / n) - mean_b * mean_b;
104        if var_b <= 0.0 {
105            // Undefined beta: report unadjusted excess.
106            return Some(mean_a - self.risk_free);
107        }
108        let cov_ab = (self.sum_ab / n) - mean_a * mean_b;
109        let beta = cov_ab / var_b;
110        Some(mean_a - (self.risk_free + beta * (mean_b - self.risk_free)))
111    }
112
113    fn reset(&mut self) {
114        self.window.clear();
115        self.sum_a = 0.0;
116        self.sum_b = 0.0;
117        self.sum_bb = 0.0;
118        self.sum_ab = 0.0;
119    }
120
121    fn warmup_period(&self) -> usize {
122        self.period
123    }
124
125    fn is_ready(&self) -> bool {
126        self.window.len() == self.period
127    }
128
129    fn name(&self) -> &'static str {
130        "Alpha"
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::traits::BatchExt;
138    use approx::assert_relative_eq;
139
140    #[test]
141    fn rejects_period_less_than_two() {
142        assert!(matches!(
143            Alpha::new(1, 0.0),
144            Err(Error::InvalidPeriod { .. })
145        ));
146    }
147
148    #[test]
149    fn accessors_and_metadata() {
150        let a = Alpha::new(20, 0.001).unwrap();
151        assert_eq!(a.period(), 20);
152        assert_relative_eq!(a.risk_free(), 0.001, epsilon = 1e-12);
153        assert_eq!(a.name(), "Alpha");
154        assert_eq!(a.warmup_period(), 20);
155    }
156
157    #[test]
158    fn capm_perfect_fit_yields_zero_alpha() {
159        // asset = 2 * bench - constant beta of 2, no alpha; with rf = 0 the
160        // CAPM-implied return matches the asset's mean perfectly.
161        let mut a = Alpha::new(20, 0.0).unwrap();
162        let inputs: Vec<(f64, f64)> = (1..=20)
163            .map(|i| (2.0 * f64::from(i) * 0.01, f64::from(i) * 0.01))
164            .collect();
165        let out = a.batch(&inputs);
166        assert_relative_eq!(out[19].unwrap(), 0.0, epsilon = 1e-12);
167    }
168
169    #[test]
170    fn constant_alpha_offset_recovered() {
171        // asset = bench + 0.005 (additive alpha of 0.5%), beta == 1.
172        // Expected alpha = 0.005.
173        let mut a = Alpha::new(20, 0.0).unwrap();
174        let inputs: Vec<(f64, f64)> = (1..=20)
175            .map(|i| (f64::from(i) * 0.01 + 0.005, f64::from(i) * 0.01))
176            .collect();
177        let out = a.batch(&inputs);
178        assert_relative_eq!(out[19].unwrap(), 0.005, epsilon = 1e-9);
179    }
180
181    #[test]
182    fn flat_benchmark_falls_back_to_excess_return() {
183        // Benchmark all 0 -> beta undefined -> alpha = mean_a - rf.
184        let mut a = Alpha::new(4, 0.001).unwrap();
185        let out = a.batch(&[(0.01, 0.0), (0.02, 0.0), (-0.01, 0.0), (0.04, 0.0)]);
186        let mean = (0.01 + 0.02 - 0.01 + 0.04) / 4.0;
187        assert_relative_eq!(out[3].unwrap(), mean - 0.001, epsilon = 1e-12);
188    }
189
190    #[test]
191    fn ignores_non_finite_input() {
192        let mut a = Alpha::new(3, 0.0).unwrap();
193        assert_eq!(a.update((f64::NAN, 0.0)), None);
194        assert_eq!(a.update((0.0, f64::INFINITY)), None);
195    }
196
197    #[test]
198    fn reset_clears_state() {
199        let mut a = Alpha::new(3, 0.0).unwrap();
200        a.batch(&[(0.01, 0.005), (0.02, 0.01), (-0.01, -0.005)]);
201        assert!(a.is_ready());
202        a.reset();
203        assert!(!a.is_ready());
204        assert_eq!(a.update((0.01, 0.005)), None);
205    }
206
207    #[test]
208    fn batch_equals_streaming() {
209        let inputs: Vec<(f64, f64)> = (0..50)
210            .map(|i| {
211                let b = (f64::from(i) * 0.2).sin() * 0.01;
212                (1.5 * b + 0.002, b)
213            })
214            .collect();
215        let batch = Alpha::new(10, 0.0).unwrap().batch(&inputs);
216        let mut s = Alpha::new(10, 0.0).unwrap();
217        let streamed: Vec<_> = inputs.iter().map(|x| s.update(*x)).collect();
218        assert_eq!(batch, streamed);
219    }
220}