Skip to main content

wickra_core/indicators/
profit_factor.rs

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