Skip to main content

wickra_core/indicators/
m2_measure.rs

1//! M² / Modigliani–Modigliani measure — Sharpe expressed in benchmark return units.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// M² (Modigliani–Modigliani) measure over a trailing window of `period` returns.
9///
10/// ```text
11/// Sharpe = (mean(returns) − risk_free) / stddev(returns)
12/// M²     = risk_free + Sharpe · benchmark_stddev
13/// ```
14///
15/// The [`SharpeRatio`](crate::SharpeRatio) is dimensionless, which makes it hard to
16/// communicate: "0.8" means little to a client. M² rescales the Sharpe ratio back
17/// into *return units* by levering (or de-levering) the portfolio to the
18/// benchmark's volatility. The result answers a concrete question: "if this
19/// strategy had run at the market's risk level, what return would it have
20/// produced?" Two portfolios can then be ranked on the same risk-adjusted scale,
21/// and M² preserves the Sharpe ordering while being quoted as a percentage.
22///
23/// `stddev` is the sample standard deviation (Bessel's `n − 1`).
24/// `risk_free` is the per-period risk-free rate and `benchmark_stddev` the
25/// per-period volatility of the benchmark, both supplied by the caller at the
26/// return frequency. A flat window has zero volatility and the Sharpe ratio is
27/// undefined; the indicator returns `0.0` in that case rather than producing `NaN`.
28///
29/// Each `update` is O(1) — running sums maintain `Σr` and `Σr²` as the window slides.
30///
31/// # Example
32///
33/// ```
34/// use wickra_core::{Indicator, M2Measure};
35///
36/// let mut indicator = M2Measure::new(20, 0.0, 0.02).unwrap();
37/// let mut last = None;
38/// for i in 0..40 {
39///     last = indicator.update(0.001 + (f64::from(i) * 0.1).sin() * 0.01);
40/// }
41/// assert!(last.is_some());
42/// ```
43#[derive(Debug, Clone)]
44pub struct M2Measure {
45    period: usize,
46    risk_free: f64,
47    benchmark_stddev: f64,
48    window: VecDeque<f64>,
49    sum: f64,
50    sum_sq: f64,
51}
52
53impl M2Measure {
54    /// Construct an M² measure over `period` returns with the given per-period
55    /// risk-free rate and benchmark standard deviation.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`Error::InvalidPeriod`] if `period < 2`, or
60    /// [`Error::InvalidParameter`] if `risk_free` is not finite or
61    /// `benchmark_stddev` is negative or not finite.
62    pub fn new(period: usize, risk_free: f64, benchmark_stddev: f64) -> Result<Self> {
63        if period < 2 {
64            return Err(Error::InvalidPeriod {
65                message: "m2 measure needs period >= 2",
66            });
67        }
68        if !risk_free.is_finite() || !benchmark_stddev.is_finite() || benchmark_stddev < 0.0 {
69            return Err(Error::InvalidParameter {
70                message: "risk_free must be finite and benchmark_stddev finite and non-negative",
71            });
72        }
73        Ok(Self {
74            period,
75            risk_free,
76            benchmark_stddev,
77            window: VecDeque::with_capacity(period),
78            sum: 0.0,
79            sum_sq: 0.0,
80        })
81    }
82
83    /// Configured window of returns.
84    pub const fn period(&self) -> usize {
85        self.period
86    }
87
88    /// Configured per-period risk-free rate.
89    pub const fn risk_free(&self) -> f64 {
90        self.risk_free
91    }
92
93    /// Configured per-period benchmark standard deviation.
94    pub const fn benchmark_stddev(&self) -> f64 {
95        self.benchmark_stddev
96    }
97}
98
99impl Indicator for M2Measure {
100    type Input = f64;
101    type Output = f64;
102
103    fn update(&mut self, ret: f64) -> Option<f64> {
104        if !ret.is_finite() {
105            return None;
106        }
107        if self.window.len() == self.period {
108            let old = self.window.pop_front().expect("non-empty");
109            self.sum -= old;
110            self.sum_sq -= old * old;
111        }
112        self.window.push_back(ret);
113        self.sum += ret;
114        self.sum_sq += ret * ret;
115        if self.window.len() < self.period {
116            return None;
117        }
118        let n = self.period as f64;
119        let mean = self.sum / n;
120        let var = (self.sum_sq - n * mean * mean).max(0.0) / (n - 1.0);
121        let sd = var.sqrt();
122        if sd == 0.0 {
123            return Some(0.0);
124        }
125        let sharpe = (mean - self.risk_free) / sd;
126        Some(self.risk_free + sharpe * self.benchmark_stddev)
127    }
128
129    fn reset(&mut self) {
130        self.window.clear();
131        self.sum = 0.0;
132        self.sum_sq = 0.0;
133    }
134
135    fn warmup_period(&self) -> usize {
136        self.period
137    }
138
139    fn is_ready(&self) -> bool {
140        self.window.len() == self.period
141    }
142
143    fn name(&self) -> &'static str {
144        "M2Measure"
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::traits::BatchExt;
152    use approx::assert_relative_eq;
153
154    #[test]
155    fn rejects_period_less_than_two() {
156        assert!(matches!(
157            M2Measure::new(1, 0.0, 0.02),
158            Err(Error::InvalidPeriod { .. })
159        ));
160    }
161
162    #[test]
163    fn rejects_invalid_benchmark_stddev() {
164        assert!(matches!(
165            M2Measure::new(10, 0.0, -0.01),
166            Err(Error::InvalidParameter { .. })
167        ));
168        assert!(matches!(
169            M2Measure::new(10, f64::NAN, 0.02),
170            Err(Error::InvalidParameter { .. })
171        ));
172    }
173
174    #[test]
175    fn accessors_and_metadata() {
176        let m2 = M2Measure::new(20, 0.001, 0.02).unwrap();
177        assert_eq!(m2.period(), 20);
178        assert_relative_eq!(m2.risk_free(), 0.001, epsilon = 1e-12);
179        assert_relative_eq!(m2.benchmark_stddev(), 0.02, epsilon = 1e-12);
180        assert_eq!(m2.warmup_period(), 20);
181        assert_eq!(m2.name(), "M2Measure");
182    }
183
184    #[test]
185    fn reference_value() {
186        // returns [0.01, 0.02, 0.03, 0.04], rf = 0, benchmark_stddev = 0.02.
187        // mean = 0.025, sd = sqrt(0.000166666...), Sharpe = 0.025 / sd.
188        // M2 = 0 + Sharpe * 0.02.
189        let mut m2 = M2Measure::new(4, 0.0, 0.02).unwrap();
190        let out = m2.batch(&[0.01, 0.02, 0.03, 0.04]);
191        let sharpe = 0.025_f64 / (0.000_166_666_666_666_666_67_f64).sqrt();
192        assert_relative_eq!(out[3].unwrap(), sharpe * 0.02, epsilon = 1e-9);
193    }
194
195    #[test]
196    fn constant_returns_yield_zero() {
197        let mut m2 = M2Measure::new(5, 0.0, 0.02).unwrap();
198        for v in m2.batch(&[0.01; 10]).into_iter().flatten() {
199            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
200        }
201    }
202
203    #[test]
204    fn ignores_non_finite_input() {
205        let mut m2 = M2Measure::new(3, 0.0, 0.02).unwrap();
206        assert_eq!(m2.update(0.01), None);
207        assert_eq!(m2.update(f64::NAN), None);
208        assert_eq!(m2.update(0.02), None);
209        assert!(m2.update(0.03).is_some());
210    }
211
212    #[test]
213    fn reset_clears_state() {
214        let mut m2 = M2Measure::new(3, 0.0, 0.02).unwrap();
215        m2.batch(&[0.01, 0.02, 0.03]);
216        assert!(m2.is_ready());
217        m2.reset();
218        assert!(!m2.is_ready());
219        assert_eq!(m2.update(0.01), None);
220    }
221
222    #[test]
223    fn batch_equals_streaming() {
224        let rets: Vec<f64> = (0..50)
225            .map(|i| 0.001 + (f64::from(i) * 0.2).sin() * 0.01)
226            .collect();
227        let batch = M2Measure::new(10, 0.0, 0.02).unwrap().batch(&rets);
228        let mut streamer = M2Measure::new(10, 0.0, 0.02).unwrap();
229        let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
230        assert_eq!(batch, streamed);
231    }
232}