wickra_core/indicators/
win_rate.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
36pub struct WinRate {
37 period: usize,
38 window: VecDeque<f64>,
39 wins: usize,
40}
41
42impl WinRate {
43 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 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 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 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 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}