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