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::traits::Indicator;
7
8/// Common Sense Ratio over a trailing window of `period` returns.
9///
10/// ```text
11/// ProfitFactor = Σ gains / Σ |losses|              over the window
12/// TailRatio    = P95(returns) / |P5(returns)|      over the window
13/// CSR          = ProfitFactor · TailRatio
14/// ```
15///
16/// The Common Sense Ratio fuses two views of a return series into one number. The
17/// [profit factor](crate::ProfitFactor) captures the *body* of the distribution —
18/// how much you make per unit you lose on the average bar. The
19/// [`TailRatio`](crate::TailRatio) captures the *extremes* — whether the largest
20/// gains outweigh the largest losses. Multiplying them produces a ratio that is
21/// only comfortably above `1.0` when a strategy wins on both fronts: a respectable
22/// profit factor can still hide catastrophic left-tail risk, and a fat right tail
23/// means little if the body bleeds. Above `1.0` the strategy is sound on a
24/// common-sense basis; below `1.0` something — body or tail — is working against it.
25///
26/// Percentiles use linear interpolation over the sorted window. A window with no
27/// losses (zero profit-factor denominator) or no left tail (zero P5) reports `0.0`
28/// rather than dividing by zero.
29///
30/// The first value lands after `period` returns; each `update` re-sorts the window
31/// (O(period log period)), which is O(1) in the length of the overall series.
32///
33/// # Example
34///
35/// ```
36/// use wickra_core::{Indicator, CommonSenseRatio};
37///
38/// let mut indicator = CommonSenseRatio::new(20).unwrap();
39/// let mut last = None;
40/// for i in 0..40 {
41///     last = indicator.update((f64::from(i) * 0.3).sin() * 0.02);
42/// }
43/// assert!(last.is_some());
44/// ```
45#[derive(Debug, Clone)]
46pub struct CommonSenseRatio {
47    period: usize,
48    window: VecDeque<f64>,
49    /// Reusable scratch buffer to avoid allocating per `update`.
50    scratch: Vec<f64>,
51}
52
53impl CommonSenseRatio {
54    /// Construct a Common Sense Ratio over `period` returns.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`Error::InvalidPeriod`] if `period < 2` (percentiles need at least
59    /// two observations).
60    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    /// Configured window of returns.
79    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
106/// Linear-interpolation percentile of an ascending, non-empty slice.
107fn 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    // `rank` lies in `[0, last_index]`, so its floor is a valid in-bounds index.
113    #[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        // window [-0.04, -0.02, 0.0, 0.02, 0.04].
188        // gains = 0.06, losses = 0.06 -> profit factor 1.0.
189        // P95 = 0.036, |P5| = 0.036 -> tail ratio 1.0. CSR = 1.0.
190        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        // All zeros: no losses denominator -> zero (the gains/losses guard fires).
210        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        // The rank floor reaching the final index returns the largest element.
248        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        // One loss but a 5th percentile of exactly zero: the tail term collapses
254        // and the indicator reports 0.0 rather than dividing by zero. With period
255        // 21 the 5% rank lands on sorted index 1, which is 0.0 here.
256        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}