Skip to main content

wickra_core/indicators/
treynor_ratio.rs

1//! Rolling Treynor Ratio.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rolling Treynor Ratio.
9///
10/// Each `update` receives one `(asset_return, benchmark_return)` pair. Over
11/// the trailing window of `period` pairs:
12///
13/// ```text
14/// cov_ab = (1/n) · Σ a·b − ā·b̄
15/// var_b  = (1/n) · Σ b² − b̄²
16/// Beta   = cov_ab / var_b
17/// Treynor = (mean(asset) − risk_free) / Beta
18/// ```
19///
20/// Treynor is Sharpe's market-risk cousin: it divides excess return by the
21/// asset's sensitivity to the benchmark (Beta) rather than by the asset's
22/// own volatility. Useful for diversified portfolios where idiosyncratic
23/// volatility has been mostly diversified away and the dominant remaining
24/// risk is systematic / market exposure.
25///
26/// A flat benchmark window has zero variance and the indicator returns
27/// `0.0` rather than `NaN`. A near-zero `Beta` makes the ratio explode by
28/// construction; callers should treat extreme values with the usual care.
29///
30/// Each `update` is O(1) — running sums maintain `Σa`, `Σb`, `Σb²`, `Σa·b`
31/// as the window slides.
32#[derive(Debug, Clone)]
33pub struct TreynorRatio {
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 TreynorRatio {
44    /// Construct a new rolling Treynor Ratio.
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: "treynor ratio 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 TreynorRatio {
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            return Some(0.0);
106        }
107        let cov_ab = (self.sum_ab / n) - mean_a * mean_b;
108        let beta = cov_ab / var_b;
109        if beta == 0.0 {
110            return Some(0.0);
111        }
112        Some((mean_a - self.risk_free) / beta)
113    }
114
115    fn reset(&mut self) {
116        self.window.clear();
117        self.sum_a = 0.0;
118        self.sum_b = 0.0;
119        self.sum_bb = 0.0;
120        self.sum_ab = 0.0;
121    }
122
123    fn warmup_period(&self) -> usize {
124        self.period
125    }
126
127    fn is_ready(&self) -> bool {
128        self.window.len() == self.period
129    }
130
131    fn name(&self) -> &'static str {
132        "TreynorRatio"
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::traits::BatchExt;
140    use approx::assert_relative_eq;
141
142    #[test]
143    fn rejects_period_less_than_two() {
144        assert!(matches!(
145            TreynorRatio::new(1, 0.0),
146            Err(Error::InvalidPeriod { .. })
147        ));
148    }
149
150    #[test]
151    fn accessors_and_metadata() {
152        let t = TreynorRatio::new(20, 0.001).unwrap();
153        assert_eq!(t.period(), 20);
154        assert_relative_eq!(t.risk_free(), 0.001, epsilon = 1e-12);
155        assert_eq!(t.name(), "TreynorRatio");
156        assert_eq!(t.warmup_period(), 20);
157    }
158
159    #[test]
160    fn reference_beta_two_payoff() {
161        // a_i = 2 * b_i with non-zero mean.
162        // Beta should be 2; mean_a = 2 * mean_b; Treynor = mean_b.
163        let mut t = TreynorRatio::new(20, 0.0).unwrap();
164        let inputs: Vec<(f64, f64)> = (1..=20)
165            .map(|i| (2.0 * f64::from(i) * 0.01, f64::from(i) * 0.01))
166            .collect();
167        let out = t.batch(&inputs);
168        let last = out[19].unwrap();
169        let expected = inputs.iter().map(|(_, b)| *b).sum::<f64>() / 20.0;
170        assert_relative_eq!(last, expected, epsilon = 1e-9);
171    }
172
173    #[test]
174    fn flat_benchmark_yields_zero() {
175        // Benchmark all 0 -> var_b = 0 -> indicator returns 0.0.
176        let mut t = TreynorRatio::new(4, 0.0).unwrap();
177        let out = t.batch(&[(0.01, 0.0), (0.02, 0.0), (-0.01, 0.0), (0.03, 0.0)]);
178        assert_eq!(out[3], Some(0.0));
179    }
180
181    #[test]
182    fn ignores_non_finite_input() {
183        let mut t = TreynorRatio::new(3, 0.0).unwrap();
184        assert_eq!(t.update((f64::NAN, 0.0)), None);
185        assert_eq!(t.update((0.0, f64::INFINITY)), None);
186    }
187
188    #[test]
189    fn reset_clears_state() {
190        let mut t = TreynorRatio::new(3, 0.0).unwrap();
191        t.batch(&[(0.01, 0.005), (0.02, 0.01), (-0.01, -0.005)]);
192        assert!(t.is_ready());
193        t.reset();
194        assert!(!t.is_ready());
195        assert_eq!(t.update((0.01, 0.005)), None);
196    }
197
198    #[test]
199    fn batch_equals_streaming() {
200        let inputs: Vec<(f64, f64)> = (0..50)
201            .map(|i| {
202                let b = (f64::from(i) * 0.2).sin() * 0.01;
203                (1.5 * b + 0.001, b)
204            })
205            .collect();
206        let batch = TreynorRatio::new(10, 0.0).unwrap().batch(&inputs);
207        let mut s = TreynorRatio::new(10, 0.0).unwrap();
208        let streamed: Vec<_> = inputs.iter().map(|x| s.update(*x)).collect();
209        assert_eq!(batch, streamed);
210    }
211
212    #[test]
213    fn zero_beta_returns_zero() {
214        // Constant asset returns vs varying benchmark force cov(a,b) = 0,
215        // hence beta = 0 — the explicit zero-beta short-circuit.
216        let mut t = TreynorRatio::new(4, 0.0).unwrap();
217        let pairs: [(f64, f64); 4] = [(0.01, 0.005), (0.01, -0.002), (0.01, 0.001), (0.01, 0.003)];
218        let mut last = None;
219        for p in pairs {
220            last = t.update(p);
221        }
222        assert_eq!(last, Some(0.0));
223    }
224}