wickra_core/indicators/
upside_potential_ratio.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
45pub struct UpsidePotentialRatio {
46 period: usize,
47 mar: f64,
48 window: VecDeque<f64>,
49 sum_upside: f64,
50 sum_downside_sq: f64,
51}
52
53impl UpsidePotentialRatio {
54 pub fn new(period: usize, mar: f64) -> Result<Self> {
62 if period < 2 {
63 return Err(Error::InvalidPeriod {
64 message: "upside potential ratio needs period >= 2",
65 });
66 }
67 if period > crate::error::MAX_PERIOD {
68 return Err(Error::InvalidPeriod {
69 message: crate::error::PERIOD_ABOVE_MAX,
70 });
71 }
72 if !mar.is_finite() {
73 return Err(Error::InvalidParameter {
74 message: "mar must be finite",
75 });
76 }
77 Ok(Self {
78 period,
79 mar,
80 window: VecDeque::with_capacity(period),
81 sum_upside: 0.0,
82 sum_downside_sq: 0.0,
83 })
84 }
85
86 pub const fn period(&self) -> usize {
88 self.period
89 }
90
91 pub const fn mar(&self) -> f64 {
93 self.mar
94 }
95}
96
97impl Indicator for UpsidePotentialRatio {
98 type Input = f64;
99 type Output = f64;
100
101 #[inline]
102 fn update(&mut self, ret: f64) -> Option<f64> {
103 if !ret.is_finite() {
104 return None;
105 }
106 if self.window.len() == self.period {
107 let old = self.window.pop_front().expect("non-empty");
108 let excess = old - self.mar;
109 self.sum_upside -= excess.max(0.0);
110 self.sum_downside_sq -= excess.min(0.0).powi(2);
111 }
112 let excess = ret - self.mar;
113 self.sum_upside += excess.max(0.0);
114 self.sum_downside_sq += excess.min(0.0).powi(2);
115 self.window.push_back(ret);
116 if self.window.len() < self.period {
117 return None;
118 }
119 let n = self.period as f64;
120 let upside_mean = self.sum_upside / n;
121 let downside_dev = (self.sum_downside_sq / n).sqrt();
122 if downside_dev > 0.0 {
123 Some(upside_mean / downside_dev)
124 } else {
125 Some(0.0)
126 }
127 }
128
129 fn reset(&mut self) {
130 self.window.clear();
131 self.sum_upside = 0.0;
132 self.sum_downside_sq = 0.0;
133 }
134
135 #[inline]
136 fn warmup_period(&self) -> usize {
137 self.period
138 }
139
140 #[inline]
141 fn is_ready(&self) -> bool {
142 self.window.len() == self.period
143 }
144
145 #[inline]
146 fn name(&self) -> &'static str {
147 "UpsidePotentialRatio"
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::traits::BatchExt;
155 use approx::assert_relative_eq;
156
157 #[test]
158 fn rejects_period_less_than_two() {
159 assert!(matches!(
160 UpsidePotentialRatio::new(1, 0.0),
161 Err(Error::InvalidPeriod { .. })
162 ));
163 }
164
165 #[test]
166 fn rejects_non_finite_mar() {
167 assert!(matches!(
168 UpsidePotentialRatio::new(10, f64::NAN),
169 Err(Error::InvalidParameter { .. })
170 ));
171 }
172
173 #[test]
174 fn accessors_and_metadata() {
175 let upr = UpsidePotentialRatio::new(20, 0.001).unwrap();
176 assert_eq!(upr.period(), 20);
177 assert_relative_eq!(upr.mar(), 0.001, epsilon = 1e-12);
178 assert_eq!(upr.warmup_period(), 20);
179 assert_eq!(upr.name(), "UpsidePotentialRatio");
180 }
181
182 #[test]
183 fn reference_value() {
184 let mut upr = UpsidePotentialRatio::new(4, 0.0).unwrap();
189 let out = upr.batch(&[0.02, -0.01, 0.03, -0.02]);
190 let expected = 0.0125_f64 / (0.000_125_f64).sqrt();
191 assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-9);
192 }
193
194 #[test]
195 fn no_downside_is_zero() {
196 let mut upr = UpsidePotentialRatio::new(3, 0.0).unwrap();
197 let last = upr
198 .batch(&[0.01, 0.02, 0.03])
199 .into_iter()
200 .flatten()
201 .last()
202 .unwrap();
203 assert_relative_eq!(last, 0.0, epsilon = 1e-12);
204 }
205
206 #[test]
207 fn ignores_non_finite_input() {
208 let mut upr = UpsidePotentialRatio::new(3, 0.0).unwrap();
209 assert_eq!(upr.update(0.01), None);
210 assert_eq!(upr.update(f64::INFINITY), None);
211 assert_eq!(upr.update(-0.02), None);
212 assert!(upr.update(0.03).is_some());
213 }
214
215 #[test]
216 fn reset_clears_state() {
217 let mut upr = UpsidePotentialRatio::new(2, 0.0).unwrap();
218 upr.batch(&[0.02, -0.01]);
219 assert!(upr.is_ready());
220 upr.reset();
221 assert!(!upr.is_ready());
222 assert_eq!(upr.update(0.01), None);
223 }
224
225 #[test]
226 fn batch_equals_streaming() {
227 let rets: Vec<f64> = (0..60)
228 .map(|i| (f64::from(i) * 0.25).sin() * 0.02)
229 .collect();
230 let batch = UpsidePotentialRatio::new(12, 0.0).unwrap().batch(&rets);
231 let mut streamer = UpsidePotentialRatio::new(12, 0.0).unwrap();
232 let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
233 assert_eq!(batch, streamed);
234 }
235}