wickra_core/indicators/
common_sense_ratio.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::sorted_window;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
47pub struct CommonSenseRatio {
48 period: usize,
49 window: VecDeque<f64>,
50 scratch: Vec<f64>,
54 sorted_live: bool,
58}
59
60impl CommonSenseRatio {
61 pub fn new(period: usize) -> Result<Self> {
68 if period < 2 {
69 return Err(Error::InvalidPeriod {
70 message: "common sense ratio needs period >= 2",
71 });
72 }
73 if period > crate::error::MAX_PERIOD {
74 return Err(Error::InvalidPeriod {
75 message: crate::error::PERIOD_ABOVE_MAX,
76 });
77 }
78 Ok(Self {
79 period,
80 window: VecDeque::with_capacity(period),
81 scratch: Vec::with_capacity(period),
82 sorted_live: true,
83 })
84 }
85
86 pub const fn period(&self) -> usize {
88 self.period
89 }
90
91 fn compute(&mut self) -> f64 {
92 let mut gains = 0.0;
93 let mut losses = 0.0;
94 for ret in &self.window {
95 gains += ret.max(0.0);
96 losses += (-ret).max(0.0);
97 }
98 if losses <= 0.0 {
99 self.sorted_live = false;
100 return 0.0;
101 }
102 if !self.sorted_live {
103 self.scratch.clear();
104 self.scratch.extend(self.window.iter().copied());
105 self.scratch.sort_by(f64::total_cmp);
106 self.sorted_live = true;
107 }
108 let lower_tail = percentile(&self.scratch, 5.0).abs();
109 if lower_tail <= 0.0 {
110 return 0.0;
111 }
112 let profit_factor = gains / losses;
113 let tail_ratio = percentile(&self.scratch, 95.0) / lower_tail;
114 profit_factor * tail_ratio
115 }
116}
117
118fn percentile(sorted: &[f64], pct: f64) -> f64 {
120 let last_index = sorted.len() - 1;
121 #[allow(clippy::cast_precision_loss)]
122 let rank = pct / 100.0 * last_index as f64;
123 let floor = rank.floor();
124 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
126 let lower = floor as usize;
127 if lower >= last_index {
128 return sorted[last_index];
129 }
130 let frac = rank - floor;
131 sorted[lower] + frac * (sorted[lower + 1] - sorted[lower])
132}
133
134impl Indicator for CommonSenseRatio {
135 type Input = f64;
136 type Output = f64;
137
138 #[inline]
139 fn update(&mut self, ret: f64) -> Option<f64> {
140 if !ret.is_finite() {
141 return None;
142 }
143 if self.window.len() == self.period {
144 let oldest = self.window.pop_front().expect("window is full");
145 if self.sorted_live {
146 sorted_window::remove(&mut self.scratch, oldest);
147 }
148 }
149 self.window.push_back(ret);
150 if self.sorted_live {
151 sorted_window::insert(&mut self.scratch, ret);
152 }
153 if self.window.len() < self.period {
154 return None;
155 }
156 Some(self.compute())
157 }
158
159 fn reset(&mut self) {
160 self.window.clear();
161 self.scratch.clear();
162 self.sorted_live = true;
163 }
164
165 #[inline]
166 fn warmup_period(&self) -> usize {
167 self.period
168 }
169
170 #[inline]
171 fn is_ready(&self) -> bool {
172 self.window.len() == self.period
173 }
174
175 #[inline]
176 fn name(&self) -> &'static str {
177 "CommonSenseRatio"
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use crate::traits::BatchExt;
185 use approx::assert_relative_eq;
186
187 fn from_scratch(window: &[f64]) -> f64 {
190 let gains: f64 = window.iter().map(|r| r.max(0.0)).sum();
191 let losses: f64 = window.iter().map(|r| (-r).max(0.0)).sum();
192 if losses <= 0.0 {
193 return 0.0;
194 }
195 let mut sorted = window.to_vec();
196 sorted.sort_by(f64::total_cmp);
197 let lower_tail = percentile(&sorted, 5.0).abs();
198 if lower_tail <= 0.0 {
199 return 0.0;
200 }
201 gains / losses * (percentile(&sorted, 95.0) / lower_tail)
202 }
203
204 #[test]
205 fn the_sorted_copy_survives_a_stretch_without_losses() {
206 let returns: Vec<f64> = (0..120)
209 .map(|i| {
210 let t = f64::from(i);
211 if (40..70).contains(&i) {
212 0.001 + (t * 0.3).sin().abs() * 0.01
213 } else {
214 (t * 0.9).sin() * 0.02
215 }
216 })
217 .collect();
218 let period = 10;
219 let mut csr = CommonSenseRatio::new(period).unwrap();
220 for (i, &r) in returns.iter().enumerate() {
221 let got = csr.update(r);
222 if i + 1 >= period {
223 let want = from_scratch(&returns[i + 1 - period..=i]);
224 assert_eq!(got.unwrap().to_bits(), want.to_bits(), "at {i}");
225 }
226 }
227 }
228
229 #[test]
230 fn rejects_period_less_than_two() {
231 assert!(matches!(
232 CommonSenseRatio::new(1),
233 Err(Error::InvalidPeriod { .. })
234 ));
235 }
236
237 #[test]
238 fn accessors_and_metadata() {
239 let csr = CommonSenseRatio::new(20).unwrap();
240 assert_eq!(csr.period(), 20);
241 assert_eq!(csr.warmup_period(), 20);
242 assert_eq!(csr.name(), "CommonSenseRatio");
243 assert!(!csr.is_ready());
244 }
245
246 #[test]
247 fn reference_value() {
248 let mut csr = CommonSenseRatio::new(5).unwrap();
252 let out = csr.batch(&[-0.04, -0.02, 0.0, 0.02, 0.04]);
253 assert_relative_eq!(out[4].unwrap(), 1.0, epsilon = 1e-9);
254 }
255
256 #[test]
257 fn no_losses_is_zero() {
258 let mut csr = CommonSenseRatio::new(3).unwrap();
259 let last = csr
260 .batch(&[0.01, 0.02, 0.03])
261 .into_iter()
262 .flatten()
263 .last()
264 .unwrap();
265 assert_relative_eq!(last, 0.0, epsilon = 1e-12);
266 }
267
268 #[test]
269 fn flat_window_is_zero() {
270 let mut csr = CommonSenseRatio::new(4).unwrap();
272 let last = csr.batch(&[0.0; 4]).into_iter().flatten().last().unwrap();
273 assert_relative_eq!(last, 0.0, epsilon = 1e-12);
274 }
275
276 #[test]
277 fn ignores_non_finite_input() {
278 let mut csr = CommonSenseRatio::new(3).unwrap();
279 assert_eq!(csr.update(0.01), None);
280 assert_eq!(csr.update(f64::NAN), None);
281 assert_eq!(csr.update(-0.02), None);
282 assert!(csr.update(0.03).is_some());
283 }
284
285 #[test]
286 fn reset_clears_state() {
287 let mut csr = CommonSenseRatio::new(3).unwrap();
288 csr.batch(&[-0.01, 0.0, 0.02]);
289 assert!(csr.is_ready());
290 csr.reset();
291 assert!(!csr.is_ready());
292 assert_eq!(csr.update(0.01), None);
293 }
294
295 #[test]
296 fn batch_equals_streaming() {
297 let rets: Vec<f64> = (0..60)
298 .map(|i| (f64::from(i) * 0.25).sin() * 0.02)
299 .collect();
300 let batch = CommonSenseRatio::new(15).unwrap().batch(&rets);
301 let mut streamer = CommonSenseRatio::new(15).unwrap();
302 let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
303 assert_eq!(batch, streamed);
304 }
305
306 #[test]
307 fn percentile_at_top_returns_last() {
308 assert_relative_eq!(percentile(&[1.0, 2.0, 3.0], 100.0), 3.0, epsilon = 1e-12);
310 }
311
312 #[test]
313 fn zero_lower_tail_is_zero() {
314 let mut returns = vec![0.0; 21];
318 returns[0] = -0.1;
319 let mut csr = CommonSenseRatio::new(21).unwrap();
320 let last = csr.batch(&returns).into_iter().flatten().last().unwrap();
321 assert_relative_eq!(last, 0.0, epsilon = 1e-12);
322 }
323}