wickra_core/indicators/
treynor_ratio.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::ShiftedPairMoments;
7use crate::traits::Indicator;
8
9#[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 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 pub const fn period(&self) -> usize {
67 self.period
68 }
69
70 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 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 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 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}