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        if period > crate::error::MAX_PERIOD {
52            return Err(Error::InvalidPeriod {
53                message: crate::error::PERIOD_ABOVE_MAX,
54            });
55        }
56        Ok(Self {
57            period,
58            window: VecDeque::with_capacity(period),
59            wins: 0,
60        })
61    }
62
63    /// Configured period.
64    pub const fn period(&self) -> usize {
65        self.period
66    }
67}
68
69impl Indicator for WinRate {
70    type Input = f64;
71    type Output = f64;
72
73    #[inline]
74    fn update(&mut self, ret: f64) -> Option<f64> {
75        if !ret.is_finite() {
76            return None;
77        }
78        if self.window.len() == self.period {
79            let old = self.window.pop_front().expect("window is non-empty");
80            if old > 0.0 {
81                self.wins -= 1;
82            }
83        }
84        self.window.push_back(ret);
85        if ret > 0.0 {
86            self.wins += 1;
87        }
88        if self.window.len() < self.period {
89            return None;
90        }
91        Some(self.wins as f64 / self.period as f64)
92    }
93
94    fn reset(&mut self) {
95        self.window.clear();
96        self.wins = 0;
97    }
98
99    #[inline]
100    fn warmup_period(&self) -> usize {
101        self.period
102    }
103
104    #[inline]
105    fn is_ready(&self) -> bool {
106        self.window.len() == self.period
107    }
108
109    #[inline]
110    fn name(&self) -> &'static str {
111        "WinRate"
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::traits::BatchExt;
119    use approx::assert_relative_eq;
120
121    #[test]
122    fn rejects_zero_period() {
123        assert!(matches!(WinRate::new(0), Err(Error::PeriodZero)));
124    }
125
126    #[test]
127    fn accessors_and_metadata() {
128        let wr = WinRate::new(20).unwrap();
129        assert_eq!(wr.period(), 20);
130        assert_eq!(wr.warmup_period(), 20);
131        assert_eq!(wr.name(), "WinRate");
132        assert!(!wr.is_ready());
133    }
134
135    #[test]
136    fn reference_value() {
137        // +, -, +, + -> 3 wins of 4 -> 0.75.
138        let mut wr = WinRate::new(4).unwrap();
139        let out = wr.batch(&[1.0, -1.0, 2.0, 1.0]);
140        assert_relative_eq!(out[3].unwrap(), 0.75, epsilon = 1e-12);
141    }
142
143    #[test]
144    fn all_wins_is_one() {
145        let mut wr = WinRate::new(5).unwrap();
146        for v in wr.batch(&[1.0; 10]).into_iter().flatten() {
147            assert_relative_eq!(v, 1.0, epsilon = 1e-12);
148        }
149    }
150
151    #[test]
152    fn all_losses_is_zero() {
153        let mut wr = WinRate::new(5).unwrap();
154        for v in wr.batch(&[-1.0; 10]).into_iter().flatten() {
155            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
156        }
157    }
158
159    #[test]
160    fn flat_returns_are_not_wins() {
161        // Zeros count as non-wins: 2 wins, 2 flats -> 0.5.
162        let mut wr = WinRate::new(4).unwrap();
163        let out = wr.batch(&[1.0, 0.0, 2.0, 0.0]);
164        assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12);
165    }
166
167    #[test]
168    fn rolling_window_drops_old_wins() {
169        // period 3: after [+,+,+] -> 1.0, then three losses slide the wins out.
170        let mut wr = WinRate::new(3).unwrap();
171        let out = wr.batch(&[1.0, 1.0, 1.0, -1.0, -1.0, -1.0]);
172        assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12);
173        assert_relative_eq!(out[5].unwrap(), 0.0, epsilon = 1e-12);
174    }
175
176    #[test]
177    fn output_within_bounds() {
178        let mut wr = WinRate::new(20).unwrap();
179        let rets: Vec<f64> = (0..200).map(|i| (f64::from(i) * 0.7).sin()).collect();
180        for v in wr.batch(&rets).into_iter().flatten() {
181            assert!((0.0..=1.0).contains(&v), "out of bounds: {v}");
182        }
183    }
184
185    #[test]
186    fn reset_clears_state() {
187        let mut wr = WinRate::new(5).unwrap();
188        wr.batch(&[1.0, -1.0, 1.0, -1.0, 1.0]);
189        assert!(wr.is_ready());
190        wr.reset();
191        assert!(!wr.is_ready());
192        assert_eq!(wr.update(1.0), None);
193    }
194
195    #[test]
196    fn batch_equals_streaming() {
197        let rets: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.5).sin() * 2.0).collect();
198        let batch = WinRate::new(14).unwrap().batch(&rets);
199        let mut b = WinRate::new(14).unwrap();
200        let streamed: Vec<_> = rets.iter().map(|p| b.update(*p)).collect();
201        assert_eq!(batch, streamed);
202    }
203}