wickra_core/indicators/
common_sense_ratio.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
46pub struct CommonSenseRatio {
47 period: usize,
48 window: VecDeque<f64>,
49 scratch: Vec<f64>,
51}
52
53impl CommonSenseRatio {
54 pub fn new(period: usize) -> Result<Self> {
61 if period < 2 {
62 return Err(Error::InvalidPeriod {
63 message: "common sense ratio needs period >= 2",
64 });
65 }
66 if period > crate::error::MAX_PERIOD {
67 return Err(Error::InvalidPeriod {
68 message: crate::error::PERIOD_ABOVE_MAX,
69 });
70 }
71 Ok(Self {
72 period,
73 window: VecDeque::with_capacity(period),
74 scratch: Vec::with_capacity(period),
75 })
76 }
77
78 pub const fn period(&self) -> usize {
80 self.period
81 }
82
83 fn compute(&mut self) -> f64 {
84 let mut gains = 0.0;
85 let mut losses = 0.0;
86 for ret in &self.window {
87 gains += ret.max(0.0);
88 losses += (-ret).max(0.0);
89 }
90 if losses <= 0.0 {
91 return 0.0;
92 }
93 self.scratch.clear();
94 self.scratch.extend(self.window.iter().copied());
95 self.scratch.sort_unstable_by(f64::total_cmp);
96 let lower_tail = percentile(&self.scratch, 5.0).abs();
97 if lower_tail <= 0.0 {
98 return 0.0;
99 }
100 let profit_factor = gains / losses;
101 let tail_ratio = percentile(&self.scratch, 95.0) / lower_tail;
102 profit_factor * tail_ratio
103 }
104}
105
106fn percentile(sorted: &[f64], pct: f64) -> f64 {
108 let last_index = sorted.len() - 1;
109 #[allow(clippy::cast_precision_loss)]
110 let rank = pct / 100.0 * last_index as f64;
111 let floor = rank.floor();
112 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
114 let lower = floor as usize;
115 if lower >= last_index {
116 return sorted[last_index];
117 }
118 let frac = rank - floor;
119 sorted[lower] + frac * (sorted[lower + 1] - sorted[lower])
120}
121
122impl Indicator for CommonSenseRatio {
123 type Input = f64;
124 type Output = f64;
125
126 #[inline]
127 fn update(&mut self, ret: f64) -> Option<f64> {
128 if !ret.is_finite() {
129 return None;
130 }
131 if self.window.len() == self.period {
132 self.window.pop_front();
133 }
134 self.window.push_back(ret);
135 if self.window.len() < self.period {
136 return None;
137 }
138 Some(self.compute())
139 }
140
141 fn reset(&mut self) {
142 self.window.clear();
143 self.scratch.clear();
144 }
145
146 #[inline]
147 fn warmup_period(&self) -> usize {
148 self.period
149 }
150
151 #[inline]
152 fn is_ready(&self) -> bool {
153 self.window.len() == self.period
154 }
155
156 #[inline]
157 fn name(&self) -> &'static str {
158 "CommonSenseRatio"
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::traits::BatchExt;
166 use approx::assert_relative_eq;
167
168 #[test]
169 fn rejects_period_less_than_two() {
170 assert!(matches!(
171 CommonSenseRatio::new(1),
172 Err(Error::InvalidPeriod { .. })
173 ));
174 }
175
176 #[test]
177 fn accessors_and_metadata() {
178 let csr = CommonSenseRatio::new(20).unwrap();
179 assert_eq!(csr.period(), 20);
180 assert_eq!(csr.warmup_period(), 20);
181 assert_eq!(csr.name(), "CommonSenseRatio");
182 assert!(!csr.is_ready());
183 }
184
185 #[test]
186 fn reference_value() {
187 let mut csr = CommonSenseRatio::new(5).unwrap();
191 let out = csr.batch(&[-0.04, -0.02, 0.0, 0.02, 0.04]);
192 assert_relative_eq!(out[4].unwrap(), 1.0, epsilon = 1e-9);
193 }
194
195 #[test]
196 fn no_losses_is_zero() {
197 let mut csr = CommonSenseRatio::new(3).unwrap();
198 let last = csr
199 .batch(&[0.01, 0.02, 0.03])
200 .into_iter()
201 .flatten()
202 .last()
203 .unwrap();
204 assert_relative_eq!(last, 0.0, epsilon = 1e-12);
205 }
206
207 #[test]
208 fn flat_window_is_zero() {
209 let mut csr = CommonSenseRatio::new(4).unwrap();
211 let last = csr.batch(&[0.0; 4]).into_iter().flatten().last().unwrap();
212 assert_relative_eq!(last, 0.0, epsilon = 1e-12);
213 }
214
215 #[test]
216 fn ignores_non_finite_input() {
217 let mut csr = CommonSenseRatio::new(3).unwrap();
218 assert_eq!(csr.update(0.01), None);
219 assert_eq!(csr.update(f64::NAN), None);
220 assert_eq!(csr.update(-0.02), None);
221 assert!(csr.update(0.03).is_some());
222 }
223
224 #[test]
225 fn reset_clears_state() {
226 let mut csr = CommonSenseRatio::new(3).unwrap();
227 csr.batch(&[-0.01, 0.0, 0.02]);
228 assert!(csr.is_ready());
229 csr.reset();
230 assert!(!csr.is_ready());
231 assert_eq!(csr.update(0.01), None);
232 }
233
234 #[test]
235 fn batch_equals_streaming() {
236 let rets: Vec<f64> = (0..60)
237 .map(|i| (f64::from(i) * 0.25).sin() * 0.02)
238 .collect();
239 let batch = CommonSenseRatio::new(15).unwrap().batch(&rets);
240 let mut streamer = CommonSenseRatio::new(15).unwrap();
241 let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
242 assert_eq!(batch, streamed);
243 }
244
245 #[test]
246 fn percentile_at_top_returns_last() {
247 assert_relative_eq!(percentile(&[1.0, 2.0, 3.0], 100.0), 3.0, epsilon = 1e-12);
249 }
250
251 #[test]
252 fn zero_lower_tail_is_zero() {
253 let mut returns = vec![0.0; 21];
257 returns[0] = -0.1;
258 let mut csr = CommonSenseRatio::new(21).unwrap();
259 let last = csr.batch(&returns).into_iter().flatten().last().unwrap();
260 assert_relative_eq!(last, 0.0, epsilon = 1e-12);
261 }
262}