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