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