Skip to main content

wickra_core/indicators/
gain_loss_ratio.rs

1//! Rolling Gain/Loss Ratio.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rolling Gain/Loss Ratio.
9///
10/// Over the trailing window:
11///
12/// ```text
13/// avg_win  = mean(r for r in window if r > 0)
14/// avg_loss = mean(−r for r in window if r < 0)
15/// GLR      = avg_win / avg_loss
16/// ```
17///
18/// Where Profit Factor sums gains and losses, the Gain/Loss Ratio averages
19/// them: it answers "for the typical winning bar, how big is the win
20/// compared to the typical losing bar?".
21///
22/// # Unbounded output
23///
24/// A window with winners but no losers has no denominator, and the indicator
25/// returns `f64::INFINITY`. This is not an edge case to be discovered in
26/// production: any `period`-bar window without a single down bar produces it,
27/// which on a trending instrument happens routinely. The value is correct --
28/// the ratio really is unbounded -- but it propagates, and `inf - inf` is
29/// `NaN`, so a caller feeding this into further arithmetic should test for it.
30/// `f64::is_finite` is the guard.
31///
32/// A window with neither winners nor losers is break-even and returns `1.0`,
33/// the same value a window whose typical win matches its typical loss returns.
34/// It used to return `0.0`, which is also what a window that lost on every
35/// single bar returns -- the two are opposite states and were indistinguishable.
36///
37/// Each `update` is O(period).
38#[derive(Debug, Clone)]
39pub struct GainLossRatio {
40    period: usize,
41    window: VecDeque<f64>,
42}
43
44impl GainLossRatio {
45    /// Construct a new rolling Gain/Loss Ratio.
46    ///
47    /// # Errors
48    /// Returns [`Error::PeriodZero`] if `period == 0`.
49    pub fn new(period: usize) -> Result<Self> {
50        if period == 0 {
51            return Err(Error::PeriodZero);
52        }
53        if period > crate::error::MAX_PERIOD {
54            return Err(Error::InvalidPeriod {
55                message: crate::error::PERIOD_ABOVE_MAX,
56            });
57        }
58        Ok(Self {
59            period,
60            window: VecDeque::with_capacity(period),
61        })
62    }
63
64    /// Configured window length.
65    pub const fn period(&self) -> usize {
66        self.period
67    }
68}
69
70impl Indicator for GainLossRatio {
71    type Input = f64;
72    type Output = f64;
73
74    #[inline]
75    fn update(&mut self, input: f64) -> Option<f64> {
76        if !input.is_finite() {
77            return None;
78        }
79        if self.window.len() == self.period {
80            self.window.pop_front();
81        }
82        self.window.push_back(input);
83        if self.window.len() < self.period {
84            return None;
85        }
86        let mut sum_win = 0.0_f64;
87        let mut n_win = 0_u32;
88        let mut sum_loss = 0.0_f64;
89        let mut n_loss = 0_u32;
90        for &r in &self.window {
91            if r > 0.0 {
92                sum_win += r;
93                n_win += 1;
94            } else if r < 0.0 {
95                sum_loss += -r;
96                n_loss += 1;
97            }
98        }
99        if n_loss == 0 {
100            // Neither gains nor losses: the window is break-even, which is
101            // what 1.0 means here. Returning 0.0 made a flat window
102            // indistinguishable from one that lost on every bar.
103            return Some(if n_win == 0 { 1.0 } else { f64::INFINITY });
104        }
105        let avg_win = if n_win == 0 {
106            0.0
107        } else {
108            sum_win / f64::from(n_win)
109        };
110        let avg_loss = sum_loss / f64::from(n_loss);
111        Some(avg_win / avg_loss)
112    }
113
114    fn reset(&mut self) {
115        self.window.clear();
116    }
117
118    #[inline]
119    fn warmup_period(&self) -> usize {
120        self.period
121    }
122
123    #[inline]
124    fn is_ready(&self) -> bool {
125        self.window.len() == self.period
126    }
127
128    #[inline]
129    fn name(&self) -> &'static str {
130        "GainLossRatio"
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::traits::BatchExt;
138    use approx::assert_relative_eq;
139
140    #[test]
141    fn rejects_zero_period() {
142        assert!(matches!(GainLossRatio::new(0), Err(Error::PeriodZero)));
143    }
144
145    #[test]
146    fn accessors_and_metadata() {
147        let g = GainLossRatio::new(10).unwrap();
148        assert_eq!(g.period(), 10);
149        assert_eq!(g.name(), "GainLossRatio");
150        assert_eq!(g.warmup_period(), 10);
151    }
152
153    #[test]
154    fn reference_value() {
155        // returns = [0.02, -0.01, 0.04, -0.03]
156        // avg_win = 0.03, avg_loss = 0.02, GLR = 1.5.
157        let mut g = GainLossRatio::new(4).unwrap();
158        let out = g.batch(&[0.02, -0.01, 0.04, -0.03]);
159        assert_relative_eq!(out[3].unwrap(), 1.5, epsilon = 1e-9);
160    }
161
162    #[test]
163    fn no_losses_yields_infinity() {
164        let mut g = GainLossRatio::new(3).unwrap();
165        let out = g.batch(&[0.01, 0.02, 0.03]);
166        assert!(out[2].unwrap().is_infinite());
167    }
168
169    #[test]
170    fn flat_window_is_break_even() {
171        let mut g = GainLossRatio::new(3).unwrap();
172        let out = g.batch(&[0.0_f64; 3]);
173        assert_eq!(out[2], Some(1.0));
174    }
175
176    #[test]
177    fn ignores_non_finite_input() {
178        let mut g = GainLossRatio::new(3).unwrap();
179        assert_eq!(g.update(f64::NAN), None);
180        assert_eq!(g.update(f64::INFINITY), None);
181    }
182
183    #[test]
184    fn no_wins_but_losses_yields_zero() {
185        // Window with only losses: avg_win is 0, GLR = 0.
186        let mut g = GainLossRatio::new(3).unwrap();
187        let out = g.batch(&[-0.01, -0.02, -0.03]);
188        assert_eq!(out[2], Some(0.0));
189    }
190
191    #[test]
192    fn reset_clears_state() {
193        let mut g = GainLossRatio::new(3).unwrap();
194        g.batch(&[0.01, -0.02, 0.03]);
195        assert!(g.is_ready());
196        g.reset();
197        assert!(!g.is_ready());
198        assert_eq!(g.update(0.01), None);
199    }
200
201    #[test]
202    fn batch_equals_streaming() {
203        let returns: Vec<f64> = (0..40).map(|i| (f64::from(i) * 0.3).sin() * 0.01).collect();
204        let batch = GainLossRatio::new(10).unwrap().batch(&returns);
205        let mut s = GainLossRatio::new(10).unwrap();
206        let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
207        assert_eq!(batch, streamed);
208    }
209    /// A flat window and a window that lost on every single bar are opposite
210    /// states, and both used to report `0.0`. Asserting each value on its own
211    /// could never catch that; asserting they differ is the property that
212    /// matters.
213    #[test]
214    fn a_flat_window_is_not_confused_with_an_all_losing_one() {
215        let flat = [0.0_f64; 20];
216        let losing = [-0.01_f64; 20];
217
218        let mut a = GainLossRatio::new(14).unwrap();
219        let mut b = GainLossRatio::new(14).unwrap();
220        let (mut flat_value, mut losing_value) = (None, None);
221        for i in 0..flat.len() {
222            flat_value = a.update(flat[i]).or(flat_value);
223            losing_value = b.update(losing[i]).or(losing_value);
224        }
225
226        assert_eq!(flat_value, Some(1.0), "a flat window is break-even");
227        assert_eq!(losing_value, Some(0.0), "an all-losing window has no gains");
228        assert_ne!(flat_value, losing_value);
229    }
230}