1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
//! Rolling-window Sharpe stability — is the deflated edge one lucky window?
//!
//! The Deflated Sharpe is a single pooled-track number; a high value can still
//! hide an edge that lives in one good stretch and is flat-to-negative the rest
//! of the time. This module slides a fixed window over the pooled returns and
//! reports the **worst-window** (non-annualized) Sharpe and the **fraction of
//! windows that are positive**. A robust agent has many positive windows and a
//! worst window that isn't catastrophic; a one-window fluke does not.
use crate::deflated_sharpe::sharpe_ratio;
/// Rolling per-window Sharpe summary over a single return series.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RollingSharpe {
/// The worst (minimum) per-window Sharpe observed.
pub min_sharpe: f64,
/// Fraction of windows whose Sharpe is strictly positive, in [0, 1].
pub frac_positive: f64,
/// Number of overlapping windows behind both reported statistics.
pub n_windows: usize,
}
/// Compute the worst-window Sharpe and fraction-of-positive-windows over
/// `returns`, using overlapping windows of length `window` (step 1). Returns
/// `None` when the series is shorter than one full window (`window < 2` is
/// treated as no window).
pub fn rolling_sharpe(returns: &[f64], window: usize) -> Option<RollingSharpe> {
if window < 2 || returns.len() < window {
return None;
}
let n_windows = returns.len() - window + 1;
let mut min_sharpe = f64::INFINITY;
let mut positive = 0usize;
for start in 0..n_windows {
let s = sharpe_ratio(&returns[start..start + window]);
if s < min_sharpe {
min_sharpe = s;
}
if s > 0.0 {
positive += 1;
}
}
Some(RollingSharpe {
min_sharpe,
frac_positive: positive as f64 / n_windows as f64,
n_windows,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn too_short_returns_none() {
assert!(rolling_sharpe(&[0.1, 0.2], 5).is_none());
assert!(rolling_sharpe(&[0.1, 0.2, 0.3], 1).is_none());
}
#[test]
fn steady_edge_is_all_positive() {
// A consistent positive drift → every window has a positive Sharpe and the
// worst window is still clearly above zero.
let r: Vec<f64> = (0..60)
.map(|i| 0.002 + 0.0005 * (i as f64 * 0.7).sin())
.collect();
let rs = rolling_sharpe(&r, 21).expect("long enough");
assert!(
(rs.frac_positive - 1.0).abs() < 1e-12,
"frac_positive={}",
rs.frac_positive
);
assert!(rs.min_sharpe > 0.0, "worst window should be positive");
}
#[test]
fn one_lucky_window_drags_min_and_frac() {
// A single spectacular stretch followed by a long flat-to-negative tail.
let mut r = vec![0.05; 21]; // one great window
r.extend(vec![-0.001; 60]); // long bad tail
let rs = rolling_sharpe(&r, 21).expect("long enough");
// The worst window is the all-negative one → negative Sharpe.
assert!(rs.min_sharpe < 0.0, "min_sharpe={}", rs.min_sharpe);
// Most windows live in the negative tail → far from all-positive.
assert!(rs.frac_positive < 0.5, "frac_positive={}", rs.frac_positive);
}
}