Skip to main content

wickra_core/indicators/
information_ratio.rs

1//! Rolling Information Ratio.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rolling Information Ratio.
9///
10/// Each `update` receives one `(asset_return, benchmark_return)` pair. Over
11/// the trailing window of `period` pairs:
12///
13/// ```text
14/// active_t       = asset_t − benchmark_t
15/// tracking_error = stddev(active over window)            (sample)
16/// IR             = mean(active) / tracking_error
17/// ```
18///
19/// The Information Ratio quantifies skill in beating a benchmark per unit
20/// of active-return volatility. A high IR means consistent (low-noise)
21/// outperformance; a near-zero IR means the asset moves with the benchmark
22/// regardless of any small alpha.
23///
24/// If the tracking error is zero (asset perfectly tracks the benchmark over
25/// the window) the indicator returns `0.0` rather than `NaN`.
26///
27/// Each `update` is O(1).
28#[derive(Debug, Clone)]
29pub struct InformationRatio {
30    period: usize,
31    window: VecDeque<f64>,
32    sum: f64,
33    sum_sq: f64,
34}
35
36impl InformationRatio {
37    /// Construct a new rolling Information Ratio.
38    ///
39    /// # Errors
40    /// Returns [`Error::InvalidPeriod`] if `period < 2`.
41    pub fn new(period: usize) -> Result<Self> {
42        if period < 2 {
43            return Err(Error::InvalidPeriod {
44                message: "information ratio needs period >= 2",
45            });
46        }
47        Ok(Self {
48            period,
49            window: VecDeque::with_capacity(period),
50            sum: 0.0,
51            sum_sq: 0.0,
52        })
53    }
54
55    /// Configured window length.
56    pub const fn period(&self) -> usize {
57        self.period
58    }
59}
60
61impl Indicator for InformationRatio {
62    type Input = (f64, f64);
63    type Output = f64;
64
65    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
66        let (a, b) = input;
67        if !a.is_finite() || !b.is_finite() {
68            return None;
69        }
70        let active = a - b;
71        if self.window.len() == self.period {
72            let old = self.window.pop_front().expect("non-empty");
73            self.sum -= old;
74            self.sum_sq -= old * old;
75        }
76        self.window.push_back(active);
77        self.sum += active;
78        self.sum_sq += active * active;
79        if self.window.len() < self.period {
80            return None;
81        }
82        let n = self.period as f64;
83        let mean = self.sum / n;
84        let var = ((self.sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
85        let te = var.sqrt();
86        if te == 0.0 {
87            return Some(0.0);
88        }
89        Some(mean / te)
90    }
91
92    fn reset(&mut self) {
93        self.window.clear();
94        self.sum = 0.0;
95        self.sum_sq = 0.0;
96    }
97
98    fn warmup_period(&self) -> usize {
99        self.period
100    }
101
102    fn is_ready(&self) -> bool {
103        self.window.len() == self.period
104    }
105
106    fn name(&self) -> &'static str {
107        "InformationRatio"
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::traits::BatchExt;
115    use approx::assert_relative_eq;
116
117    #[test]
118    fn rejects_period_less_than_two() {
119        assert!(matches!(
120            InformationRatio::new(1),
121            Err(Error::InvalidPeriod { .. })
122        ));
123    }
124
125    #[test]
126    fn accessors_and_metadata() {
127        let i = InformationRatio::new(10).unwrap();
128        assert_eq!(i.period(), 10);
129        assert_eq!(i.name(), "InformationRatio");
130        assert_eq!(i.warmup_period(), 10);
131    }
132
133    #[test]
134    fn perfect_tracking_yields_zero() {
135        // asset == benchmark every bar -> active = 0 -> te = 0 -> 0.
136        let mut i = InformationRatio::new(5).unwrap();
137        let inputs: Vec<(f64, f64)> = (0..5)
138            .map(|j| (f64::from(j) * 0.01, f64::from(j) * 0.01))
139            .collect();
140        let out = i.batch(&inputs);
141        assert_eq!(out[4], Some(0.0));
142    }
143
144    #[test]
145    fn reference_value() {
146        // asset=[0.02,0.04,0.06,0.08], bench=[0.01,0.02,0.03,0.04].
147        // active=[0.01,0.02,0.03,0.04]; mean=0.025;
148        // var = ((0.01-.025)^2 + ... ) / 3 = 0.0001666...;
149        // te = sqrt(0.0001666...); IR = 0.025/te.
150        let mut i = InformationRatio::new(4).unwrap();
151        let inputs = vec![(0.02, 0.01), (0.04, 0.02), (0.06, 0.03), (0.08, 0.04)];
152        let out = i.batch(&inputs);
153        let expected = 0.025 / (0.000_166_666_666_666_666_67_f64).sqrt();
154        assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-9);
155    }
156
157    #[test]
158    fn ignores_non_finite_input() {
159        let mut i = InformationRatio::new(3).unwrap();
160        assert_eq!(i.update((f64::NAN, 0.01)), None);
161        assert_eq!(i.update((0.01, f64::INFINITY)), None);
162    }
163
164    #[test]
165    fn reset_clears_state() {
166        let mut i = InformationRatio::new(3).unwrap();
167        i.batch(&[(0.01, 0.005), (0.02, 0.01), (-0.01, -0.005)]);
168        assert!(i.is_ready());
169        i.reset();
170        assert!(!i.is_ready());
171        assert_eq!(i.update((0.01, 0.005)), None);
172    }
173
174    #[test]
175    fn batch_equals_streaming() {
176        let inputs: Vec<(f64, f64)> = (0..50)
177            .map(|j| {
178                let b = (f64::from(j) * 0.2).sin() * 0.01;
179                (b + 0.001, b)
180            })
181            .collect();
182        let batch = InformationRatio::new(10).unwrap().batch(&inputs);
183        let mut s = InformationRatio::new(10).unwrap();
184        let streamed: Vec<_> = inputs.iter().map(|x| s.update(*x)).collect();
185        assert_eq!(batch, streamed);
186    }
187}