Skip to main content

wickra_core/indicators/
expectancy.rs

1//! Expectancy — expected return per unit of average loss (R-multiple).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Expectancy — the expected return per trade expressed in units of average
9/// loss (the "R-multiple" expectancy) over the last `period` returns.
10///
11/// ```text
12/// mean    = average of the `period` returns
13/// avgLoss = average of the absolute losing returns (rᵢ < 0)
14/// E       = mean / avgLoss          (0 when there are no losing returns)
15/// ```
16///
17/// Feed a stream of per-trade or per-bar returns. Expectancy answers "how much
18/// do I make per trade for every unit I typically risk": `E = 0.3` means the
19/// system nets `0.3R` per trade on average, where `R` is the average loss.
20/// Dividing the mean return by the average loss makes the figure comparable
21/// across systems with different bet sizes — unlike the raw mean return (which
22/// is just an SMA of the series). A positive `E` is a profitable edge, a
23/// negative `E` a losing one.
24///
25/// When the window contains **no** losing returns there is no risk reference to
26/// normalise against, so the indicator returns `0` (undefined R-multiple)
27/// rather than dividing by zero.
28///
29/// Each `update` is O(1): the running sum and the loss aggregates are
30/// maintained incrementally.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{BatchExt, Indicator, Expectancy};
36///
37/// let mut indicator = Expectancy::new(4).unwrap();
38/// // returns +2, -1, +2, -1: mean 0.5, avg loss 1 -> E = 0.5.
39/// let out = indicator.batch(&[2.0, -1.0, 2.0, -1.0]);
40/// assert_eq!(out[3], Some(0.5));
41/// ```
42#[derive(Debug, Clone)]
43pub struct Expectancy {
44    period: usize,
45    window: VecDeque<f64>,
46    sum: f64,
47    sum_abs_loss: f64,
48    loss_count: usize,
49}
50
51impl Expectancy {
52    /// Construct a new Expectancy over the given window.
53    ///
54    /// # Errors
55    /// Returns [`Error::PeriodZero`] if `period == 0`.
56    pub fn new(period: usize) -> Result<Self> {
57        if period == 0 {
58            return Err(Error::PeriodZero);
59        }
60        if period > crate::error::MAX_PERIOD {
61            return Err(Error::InvalidPeriod {
62                message: crate::error::PERIOD_ABOVE_MAX,
63            });
64        }
65        Ok(Self {
66            period,
67            window: VecDeque::with_capacity(period),
68            sum: 0.0,
69            sum_abs_loss: 0.0,
70            loss_count: 0,
71        })
72    }
73
74    /// Configured period.
75    pub const fn period(&self) -> usize {
76        self.period
77    }
78}
79
80impl Indicator for Expectancy {
81    type Input = f64;
82    type Output = f64;
83
84    #[inline]
85    fn update(&mut self, ret: f64) -> Option<f64> {
86        if !ret.is_finite() {
87            return None;
88        }
89        if self.window.len() == self.period {
90            let old = self.window.pop_front().expect("window is non-empty");
91            self.sum -= old;
92            if old < 0.0 {
93                self.sum_abs_loss -= -old;
94                self.loss_count -= 1;
95            }
96        }
97        self.window.push_back(ret);
98        self.sum += ret;
99        if ret < 0.0 {
100            self.sum_abs_loss += -ret;
101            self.loss_count += 1;
102        }
103        if self.window.len() < self.period {
104            return None;
105        }
106        if self.loss_count == 0 {
107            // No losing returns: no risk reference to express the edge in.
108            return Some(0.0);
109        }
110        let mean = self.sum / self.period as f64;
111        let avg_loss = self.sum_abs_loss / self.loss_count as f64;
112        Some(mean / avg_loss)
113    }
114
115    fn reset(&mut self) {
116        self.window.clear();
117        self.sum = 0.0;
118        self.sum_abs_loss = 0.0;
119        self.loss_count = 0;
120    }
121
122    #[inline]
123    fn warmup_period(&self) -> usize {
124        self.period
125    }
126
127    #[inline]
128    fn is_ready(&self) -> bool {
129        self.window.len() == self.period
130    }
131
132    #[inline]
133    fn name(&self) -> &'static str {
134        "Expectancy"
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::traits::BatchExt;
142    use approx::assert_relative_eq;
143
144    #[test]
145    fn rejects_zero_period() {
146        assert!(matches!(Expectancy::new(0), Err(Error::PeriodZero)));
147    }
148
149    #[test]
150    fn accessors_and_metadata() {
151        let e = Expectancy::new(20).unwrap();
152        assert_eq!(e.period(), 20);
153        assert_eq!(e.warmup_period(), 20);
154        assert_eq!(e.name(), "Expectancy");
155        assert!(!e.is_ready());
156    }
157
158    #[test]
159    fn positive_edge() {
160        // +2, -1, +2, -1: mean 0.5, avgLoss 1 -> 0.5.
161        let mut e = Expectancy::new(4).unwrap();
162        let out = e.batch(&[2.0, -1.0, 2.0, -1.0]);
163        assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12);
164    }
165
166    #[test]
167    fn negative_edge() {
168        // +1, -2, +1, -2: mean -0.5, avgLoss 2 -> -0.25.
169        let mut e = Expectancy::new(4).unwrap();
170        let out = e.batch(&[1.0, -2.0, 1.0, -2.0]);
171        assert_relative_eq!(out[3].unwrap(), -0.25, epsilon = 1e-12);
172    }
173
174    #[test]
175    fn no_losses_returns_zero() {
176        // All winning returns: no risk reference -> 0.
177        let mut e = Expectancy::new(5).unwrap();
178        for v in e.batch(&[1.0, 2.0, 3.0, 1.0, 2.0]).into_iter().flatten() {
179            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
180        }
181    }
182
183    #[test]
184    fn flat_returns_are_not_losses() {
185        // Zeros are not losses: mean (2+0+2+0)/4 = 1, but no losing returns
186        // -> 0 (undefined R-multiple).
187        let mut e = Expectancy::new(4).unwrap();
188        let out = e.batch(&[2.0, 0.0, 2.0, 0.0]);
189        assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12);
190    }
191
192    #[test]
193    fn rolling_window_evicts_old_losses() {
194        // period 4. Window [+2,-1,+2,-1] -> 0.5; then push +3,+3,+3,+3 to evict
195        // all losses -> no losses -> 0.
196        let mut e = Expectancy::new(4).unwrap();
197        let out = e.batch(&[2.0, -1.0, 2.0, -1.0, 3.0, 3.0, 3.0, 3.0]);
198        assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12);
199        assert_relative_eq!(out[7].unwrap(), 0.0, epsilon = 1e-12);
200    }
201
202    #[test]
203    fn reset_clears_state() {
204        let mut e = Expectancy::new(5).unwrap();
205        e.batch(&[1.0, -1.0, 2.0, -2.0, 1.0]);
206        assert!(e.is_ready());
207        e.reset();
208        assert!(!e.is_ready());
209        assert_eq!(e.update(1.0), None);
210    }
211
212    #[test]
213    fn batch_equals_streaming() {
214        let rets: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.5).sin() * 2.0).collect();
215        let batch = Expectancy::new(14).unwrap().batch(&rets);
216        let mut b = Expectancy::new(14).unwrap();
217        let streamed: Vec<_> = rets.iter().map(|p| b.update(*p)).collect();
218        assert_eq!(batch, streamed);
219    }
220}