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