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