Skip to main content

wickra_core/indicators/
win_rate.rs

1//! Win Rate — the fraction of winning returns over a rolling window.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Win Rate — the fraction of strictly-positive returns among the last `period`
9/// returns, in `[0, 1]`.
10///
11/// ```text
12/// WinRate = #(rᵢ > 0) / period
13/// ```
14///
15/// Feed a stream of per-trade or per-bar returns (or `PnL`); the indicator reports
16/// the rolling hit rate. A return of exactly `0` is treated as a non-win (a
17/// flat / scratch), so `WinRate` is the share of the window that strictly made
18/// money — the most basic performance statistic and a building block for
19/// [`Expectancy`](crate::Expectancy), Kelly sizing, and confidence filters.
20///
21/// Each `update` is O(1): the count of wins in the window is maintained
22/// incrementally.
23///
24/// # Example
25///
26/// ```
27/// use wickra_core::{Indicator, WinRate};
28///
29/// let mut indicator = WinRate::new(4).unwrap();
30/// // returns: +, -, +, +  -> 3 of 4 win -> 0.75.
31/// let out = indicator.batch(&[1.0, -1.0, 2.0, 1.0]);
32/// # use wickra_core::BatchExt;
33/// assert_eq!(out[3], Some(0.75));
34/// ```
35#[derive(Debug, Clone)]
36pub struct WinRate {
37    period: usize,
38    window: VecDeque<f64>,
39    wins: usize,
40}
41
42impl WinRate {
43    /// Construct a new Win Rate over the given window.
44    ///
45    /// # Errors
46    /// Returns [`Error::PeriodZero`] if `period == 0`.
47    pub fn new(period: usize) -> Result<Self> {
48        if period == 0 {
49            return Err(Error::PeriodZero);
50        }
51        Ok(Self {
52            period,
53            window: VecDeque::with_capacity(period),
54            wins: 0,
55        })
56    }
57
58    /// Configured period.
59    pub const fn period(&self) -> usize {
60        self.period
61    }
62}
63
64impl Indicator for WinRate {
65    type Input = f64;
66    type Output = f64;
67
68    fn update(&mut self, ret: f64) -> Option<f64> {
69        if !ret.is_finite() {
70            return None;
71        }
72        if self.window.len() == self.period {
73            let old = self.window.pop_front().expect("window is non-empty");
74            if old > 0.0 {
75                self.wins -= 1;
76            }
77        }
78        self.window.push_back(ret);
79        if ret > 0.0 {
80            self.wins += 1;
81        }
82        if self.window.len() < self.period {
83            return None;
84        }
85        Some(self.wins as f64 / self.period as f64)
86    }
87
88    fn reset(&mut self) {
89        self.window.clear();
90        self.wins = 0;
91    }
92
93    fn warmup_period(&self) -> usize {
94        self.period
95    }
96
97    fn is_ready(&self) -> bool {
98        self.window.len() == self.period
99    }
100
101    fn name(&self) -> &'static str {
102        "WinRate"
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::traits::BatchExt;
110    use approx::assert_relative_eq;
111
112    #[test]
113    fn rejects_zero_period() {
114        assert!(matches!(WinRate::new(0), Err(Error::PeriodZero)));
115    }
116
117    #[test]
118    fn accessors_and_metadata() {
119        let wr = WinRate::new(20).unwrap();
120        assert_eq!(wr.period(), 20);
121        assert_eq!(wr.warmup_period(), 20);
122        assert_eq!(wr.name(), "WinRate");
123        assert!(!wr.is_ready());
124    }
125
126    #[test]
127    fn reference_value() {
128        // +, -, +, + -> 3 wins of 4 -> 0.75.
129        let mut wr = WinRate::new(4).unwrap();
130        let out = wr.batch(&[1.0, -1.0, 2.0, 1.0]);
131        assert_relative_eq!(out[3].unwrap(), 0.75, epsilon = 1e-12);
132    }
133
134    #[test]
135    fn all_wins_is_one() {
136        let mut wr = WinRate::new(5).unwrap();
137        for v in wr.batch(&[1.0; 10]).into_iter().flatten() {
138            assert_relative_eq!(v, 1.0, epsilon = 1e-12);
139        }
140    }
141
142    #[test]
143    fn all_losses_is_zero() {
144        let mut wr = WinRate::new(5).unwrap();
145        for v in wr.batch(&[-1.0; 10]).into_iter().flatten() {
146            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
147        }
148    }
149
150    #[test]
151    fn flat_returns_are_not_wins() {
152        // Zeros count as non-wins: 2 wins, 2 flats -> 0.5.
153        let mut wr = WinRate::new(4).unwrap();
154        let out = wr.batch(&[1.0, 0.0, 2.0, 0.0]);
155        assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12);
156    }
157
158    #[test]
159    fn rolling_window_drops_old_wins() {
160        // period 3: after [+,+,+] -> 1.0, then three losses slide the wins out.
161        let mut wr = WinRate::new(3).unwrap();
162        let out = wr.batch(&[1.0, 1.0, 1.0, -1.0, -1.0, -1.0]);
163        assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12);
164        assert_relative_eq!(out[5].unwrap(), 0.0, epsilon = 1e-12);
165    }
166
167    #[test]
168    fn output_within_bounds() {
169        let mut wr = WinRate::new(20).unwrap();
170        let rets: Vec<f64> = (0..200).map(|i| (f64::from(i) * 0.7).sin()).collect();
171        for v in wr.batch(&rets).into_iter().flatten() {
172            assert!((0.0..=1.0).contains(&v), "out of bounds: {v}");
173        }
174    }
175
176    #[test]
177    fn reset_clears_state() {
178        let mut wr = WinRate::new(5).unwrap();
179        wr.batch(&[1.0, -1.0, 1.0, -1.0, 1.0]);
180        assert!(wr.is_ready());
181        wr.reset();
182        assert!(!wr.is_ready());
183        assert_eq!(wr.update(1.0), None);
184    }
185
186    #[test]
187    fn batch_equals_streaming() {
188        let rets: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.5).sin() * 2.0).collect();
189        let batch = WinRate::new(14).unwrap().batch(&rets);
190        let mut b = WinRate::new(14).unwrap();
191        let streamed: Vec<_> = rets.iter().map(|p| b.update(*p)).collect();
192        assert_eq!(batch, streamed);
193    }
194}