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?". If there are no losers the
21/// indicator returns `f64::INFINITY`; if there are no winners and no losers
22/// it returns `0.0`.
23///
24/// Each `update` is O(period).
25#[derive(Debug, Clone)]
26pub struct GainLossRatio {
27    period: usize,
28    window: VecDeque<f64>,
29}
30
31impl GainLossRatio {
32    /// Construct a new rolling Gain/Loss Ratio.
33    ///
34    /// # Errors
35    /// Returns [`Error::PeriodZero`] if `period == 0`.
36    pub fn new(period: usize) -> Result<Self> {
37        if period == 0 {
38            return Err(Error::PeriodZero);
39        }
40        Ok(Self {
41            period,
42            window: VecDeque::with_capacity(period),
43        })
44    }
45
46    /// Configured window length.
47    pub const fn period(&self) -> usize {
48        self.period
49    }
50}
51
52impl Indicator for GainLossRatio {
53    type Input = f64;
54    type Output = f64;
55
56    fn update(&mut self, input: f64) -> Option<f64> {
57        if !input.is_finite() {
58            return None;
59        }
60        if self.window.len() == self.period {
61            self.window.pop_front();
62        }
63        self.window.push_back(input);
64        if self.window.len() < self.period {
65            return None;
66        }
67        let mut sum_win = 0.0_f64;
68        let mut n_win = 0_u32;
69        let mut sum_loss = 0.0_f64;
70        let mut n_loss = 0_u32;
71        for &r in &self.window {
72            if r > 0.0 {
73                sum_win += r;
74                n_win += 1;
75            } else if r < 0.0 {
76                sum_loss += -r;
77                n_loss += 1;
78            }
79        }
80        if n_loss == 0 {
81            return Some(if n_win == 0 { 0.0 } else { f64::INFINITY });
82        }
83        let avg_win = if n_win == 0 {
84            0.0
85        } else {
86            sum_win / f64::from(n_win)
87        };
88        let avg_loss = sum_loss / f64::from(n_loss);
89        Some(avg_win / avg_loss)
90    }
91
92    fn reset(&mut self) {
93        self.window.clear();
94    }
95
96    fn warmup_period(&self) -> usize {
97        self.period
98    }
99
100    fn is_ready(&self) -> bool {
101        self.window.len() == self.period
102    }
103
104    fn name(&self) -> &'static str {
105        "GainLossRatio"
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::traits::BatchExt;
113    use approx::assert_relative_eq;
114
115    #[test]
116    fn rejects_zero_period() {
117        assert!(matches!(GainLossRatio::new(0), Err(Error::PeriodZero)));
118    }
119
120    #[test]
121    fn accessors_and_metadata() {
122        let g = GainLossRatio::new(10).unwrap();
123        assert_eq!(g.period(), 10);
124        assert_eq!(g.name(), "GainLossRatio");
125        assert_eq!(g.warmup_period(), 10);
126    }
127
128    #[test]
129    fn reference_value() {
130        // returns = [0.02, -0.01, 0.04, -0.03]
131        // avg_win = 0.03, avg_loss = 0.02, GLR = 1.5.
132        let mut g = GainLossRatio::new(4).unwrap();
133        let out = g.batch(&[0.02, -0.01, 0.04, -0.03]);
134        assert_relative_eq!(out[3].unwrap(), 1.5, epsilon = 1e-9);
135    }
136
137    #[test]
138    fn no_losses_yields_infinity() {
139        let mut g = GainLossRatio::new(3).unwrap();
140        let out = g.batch(&[0.01, 0.02, 0.03]);
141        assert!(out[2].unwrap().is_infinite());
142    }
143
144    #[test]
145    fn flat_window_yields_zero() {
146        let mut g = GainLossRatio::new(3).unwrap();
147        let out = g.batch(&[0.0_f64; 3]);
148        assert_eq!(out[2], Some(0.0));
149    }
150
151    #[test]
152    fn ignores_non_finite_input() {
153        let mut g = GainLossRatio::new(3).unwrap();
154        assert_eq!(g.update(f64::NAN), None);
155        assert_eq!(g.update(f64::INFINITY), None);
156    }
157
158    #[test]
159    fn no_wins_but_losses_yields_zero() {
160        // Window with only losses: avg_win is 0, GLR = 0.
161        let mut g = GainLossRatio::new(3).unwrap();
162        let out = g.batch(&[-0.01, -0.02, -0.03]);
163        assert_eq!(out[2], Some(0.0));
164    }
165
166    #[test]
167    fn reset_clears_state() {
168        let mut g = GainLossRatio::new(3).unwrap();
169        g.batch(&[0.01, -0.02, 0.03]);
170        assert!(g.is_ready());
171        g.reset();
172        assert!(!g.is_ready());
173        assert_eq!(g.update(0.01), None);
174    }
175
176    #[test]
177    fn batch_equals_streaming() {
178        let returns: Vec<f64> = (0..40).map(|i| (f64::from(i) * 0.3).sin() * 0.01).collect();
179        let batch = GainLossRatio::new(10).unwrap().batch(&returns);
180        let mut s = GainLossRatio::new(10).unwrap();
181        let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
182        assert_eq!(batch, streamed);
183    }
184}