Skip to main content

wickra_core/indicators/
common_sense_ratio.rs

1//! Common Sense Ratio (Schwager / Carver) — profit factor multiplied by the tail ratio.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::sorted_window;
7use crate::traits::Indicator;
8
9/// Common Sense Ratio over a trailing window of `period` returns.
10///
11/// ```text
12/// ProfitFactor = Σ gains / Σ |losses|              over the window
13/// TailRatio    = P95(returns) / |P5(returns)|      over the window
14/// CSR          = ProfitFactor · TailRatio
15/// ```
16///
17/// The Common Sense Ratio fuses two views of a return series into one number. The
18/// [profit factor](crate::ProfitFactor) captures the *body* of the distribution —
19/// how much you make per unit you lose on the average bar. The
20/// [`TailRatio`](crate::TailRatio) captures the *extremes* — whether the largest
21/// gains outweigh the largest losses. Multiplying them produces a ratio that is
22/// only comfortably above `1.0` when a strategy wins on both fronts: a respectable
23/// profit factor can still hide catastrophic left-tail risk, and a fat right tail
24/// means little if the body bleeds. Above `1.0` the strategy is sound on a
25/// common-sense basis; below `1.0` something — body or tail — is working against it.
26///
27/// Percentiles use linear interpolation over the sorted window. A window with no
28/// losses (zero profit-factor denominator) or no left tail (zero P5) reports `0.0`
29/// rather than dividing by zero.
30///
31/// The first value lands after `period` returns; each `update` re-sorts the window
32/// (O(period log period)), which is O(1) in the length of the overall series.
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{Indicator, CommonSenseRatio};
38///
39/// let mut indicator = CommonSenseRatio::new(20).unwrap();
40/// let mut last = None;
41/// for i in 0..40 {
42///     last = indicator.update((f64::from(i) * 0.3).sin() * 0.02);
43/// }
44/// assert!(last.is_some());
45/// ```
46#[derive(Debug, Clone)]
47pub struct CommonSenseRatio {
48    period: usize,
49    window: VecDeque<f64>,
50    /// The window's values in `total_cmp` order, kept sorted as it slides
51    /// while `sorted_live`: bit for bit what sorting a copy of the window would
52    /// give.
53    scratch: Vec<f64>,
54    /// Whether `scratch` mirrors the window. A window without losses never
55    /// reads its tails, so upkeep pauses there and the copy is rebuilt the next
56    /// time a loss makes them count.
57    sorted_live: bool,
58}
59
60impl CommonSenseRatio {
61    /// Construct a Common Sense Ratio over `period` returns.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`Error::InvalidPeriod`] if `period < 2` (percentiles need at least
66    /// two observations).
67    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    /// Configured window of returns.
87    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
118/// Linear-interpolation percentile of an ascending, non-empty slice.
119fn 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    // `rank` lies in `[0, last_index]`, so its floor is a valid in-bounds index.
125    #[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    /// The ratio from a freshly sorted copy of `window`, as each update used to
188    /// compute it.
189    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        // Mixed returns, then a gain-only stretch (upkeep pauses), then mixed
207        // again (the copy is rebuilt), then mixed after a gain-only window.
208        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        // window [-0.04, -0.02, 0.0, 0.02, 0.04].
249        // gains = 0.06, losses = 0.06 -> profit factor 1.0.
250        // P95 = 0.036, |P5| = 0.036 -> tail ratio 1.0. CSR = 1.0.
251        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        // All zeros: no losses denominator -> zero (the gains/losses guard fires).
271        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        // The rank floor reaching the final index returns the largest element.
309        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        // One loss but a 5th percentile of exactly zero: the tail term collapses
315        // and the indicator reports 0.0 rather than dividing by zero. With period
316        // 21 the 5% rank lands on sorted index 1, which is 0.0 here.
317        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}