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