wickra_core/indicators/
profit_factor.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
51pub struct ProfitFactor {
52 period: usize,
53 window: VecDeque<f64>,
54}
55
56impl ProfitFactor {
57 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 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 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 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 #[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}