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