Skip to main content

wickra_core/indicators/
tail_ratio.rs

1//! Tail Ratio — the right tail (95th percentile) over the absolute left tail (5th percentile).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Tail Ratio over a trailing window of `period` returns.
9///
10/// ```text
11/// TailRatio = P95(returns) / |P5(returns)|
12/// ```
13///
14/// The Tail Ratio contrasts the magnitude of the best outcomes against the worst:
15/// the 95th percentile of the return distribution divided by the absolute value of
16/// the 5th percentile. A value above `1.0` means the right tail (upside surprises)
17/// is fatter than the left tail (downside surprises); below `1.0` means crashes are
18/// larger than rallies. It is a distribution-shape statistic, distinct from the
19/// average-based [`SharpeRatio`](crate::SharpeRatio): two series with the same mean
20/// and variance can have very different tail ratios.
21///
22/// Percentiles are computed by linear interpolation over the sorted window
23/// (the same rule `NumPy` uses by default). A window whose 5th percentile is exactly
24/// zero has no measurable left tail and the indicator reports `0.0` rather than
25/// dividing by zero.
26///
27/// The first value lands after `period` returns; each `update` re-sorts the window
28/// (O(period log period)), which is O(1) in the length of the overall series.
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{Indicator, TailRatio};
34///
35/// let mut indicator = TailRatio::new(20).unwrap();
36/// let mut last = None;
37/// for i in 0..40 {
38///     last = indicator.update((f64::from(i) * 0.3).sin() * 0.02);
39/// }
40/// assert!(last.is_some());
41/// ```
42#[derive(Debug, Clone)]
43pub struct TailRatio {
44    period: usize,
45    window: VecDeque<f64>,
46    /// Reusable scratch buffer to avoid allocating per `update`.
47    scratch: Vec<f64>,
48}
49
50impl TailRatio {
51    /// Construct a Tail Ratio over `period` returns.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`Error::InvalidPeriod`] if `period < 2` (percentiles need at least
56    /// two observations to interpolate).
57    pub fn new(period: usize) -> Result<Self> {
58        if period < 2 {
59            return Err(Error::InvalidPeriod {
60                message: "tail ratio needs period >= 2",
61            });
62        }
63        if period > crate::error::MAX_PERIOD {
64            return Err(Error::InvalidPeriod {
65                message: crate::error::PERIOD_ABOVE_MAX,
66            });
67        }
68        Ok(Self {
69            period,
70            window: VecDeque::with_capacity(period),
71            scratch: Vec::with_capacity(period),
72        })
73    }
74
75    /// Configured window of returns.
76    pub const fn period(&self) -> usize {
77        self.period
78    }
79
80    fn compute(&mut self) -> f64 {
81        self.scratch.clear();
82        self.scratch.extend(self.window.iter().copied());
83        self.scratch.sort_unstable_by(f64::total_cmp);
84        let upper = percentile(&self.scratch, 95.0);
85        let lower = percentile(&self.scratch, 5.0).abs();
86        if lower > 0.0 {
87            upper / lower
88        } else {
89            0.0
90        }
91    }
92}
93
94/// Linear-interpolation percentile of an ascending, non-empty slice.
95fn percentile(sorted: &[f64], pct: f64) -> f64 {
96    let last_index = sorted.len() - 1;
97    #[allow(clippy::cast_precision_loss)]
98    let rank = pct / 100.0 * last_index as f64;
99    let floor = rank.floor();
100    // `rank` lies in `[0, last_index]`, so its floor is a valid in-bounds index.
101    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
102    let lower = floor as usize;
103    if lower >= last_index {
104        return sorted[last_index];
105    }
106    let frac = rank - floor;
107    sorted[lower] + frac * (sorted[lower + 1] - sorted[lower])
108}
109
110impl Indicator for TailRatio {
111    type Input = f64;
112    type Output = f64;
113
114    #[inline]
115    fn update(&mut self, ret: f64) -> Option<f64> {
116        if !ret.is_finite() {
117            return None;
118        }
119        if self.window.len() == self.period {
120            self.window.pop_front();
121        }
122        self.window.push_back(ret);
123        if self.window.len() < self.period {
124            return None;
125        }
126        Some(self.compute())
127    }
128
129    fn reset(&mut self) {
130        self.window.clear();
131        self.scratch.clear();
132    }
133
134    #[inline]
135    fn warmup_period(&self) -> usize {
136        self.period
137    }
138
139    #[inline]
140    fn is_ready(&self) -> bool {
141        self.window.len() == self.period
142    }
143
144    #[inline]
145    fn name(&self) -> &'static str {
146        "TailRatio"
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::traits::BatchExt;
154    use approx::assert_relative_eq;
155
156    #[test]
157    fn rejects_period_less_than_two() {
158        assert!(matches!(
159            TailRatio::new(1),
160            Err(Error::InvalidPeriod { .. })
161        ));
162        assert!(matches!(
163            TailRatio::new(0),
164            Err(Error::InvalidPeriod { .. })
165        ));
166    }
167
168    #[test]
169    fn accessors_and_metadata() {
170        let tr = TailRatio::new(20).unwrap();
171        assert_eq!(tr.period(), 20);
172        assert_eq!(tr.warmup_period(), 20);
173        assert_eq!(tr.name(), "TailRatio");
174        assert!(!tr.is_ready());
175    }
176
177    #[test]
178    fn reference_value() {
179        // sorted window [-0.04, -0.02, 0.0, 0.02, 0.04], last_index = 4.
180        // P95: rank 3.8 -> 0.02 + 0.8*(0.04-0.02) = 0.036.
181        // P5:  rank 0.2 -> -0.04 + 0.2*(0.02)     = -0.036, abs 0.036.
182        // ratio = 0.036 / 0.036 = 1.0.
183        let mut tr = TailRatio::new(5).unwrap();
184        let out = tr.batch(&[-0.04, -0.02, 0.0, 0.02, 0.04]);
185        assert_relative_eq!(out[4].unwrap(), 1.0, epsilon = 1e-9);
186    }
187
188    #[test]
189    fn fatter_right_tail_exceeds_one() {
190        let mut tr = TailRatio::new(5).unwrap();
191        let out = tr.batch(&[-0.01, 0.0, 0.01, 0.02, 0.10]);
192        assert!(out[4].unwrap() > 1.0);
193    }
194
195    #[test]
196    fn flat_window_is_zero() {
197        let mut tr = TailRatio::new(4).unwrap();
198        let last = tr.batch(&[0.0; 4]).into_iter().flatten().last().unwrap();
199        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
200    }
201
202    #[test]
203    fn ignores_non_finite_input() {
204        let mut tr = TailRatio::new(3).unwrap();
205        assert_eq!(tr.update(0.01), None);
206        assert_eq!(tr.update(f64::NAN), None);
207        assert_eq!(tr.update(0.02), None);
208        assert!(tr.update(0.03).is_some());
209    }
210
211    #[test]
212    fn reset_clears_state() {
213        let mut tr = TailRatio::new(3).unwrap();
214        tr.batch(&[-0.01, 0.0, 0.02]);
215        assert!(tr.is_ready());
216        tr.reset();
217        assert!(!tr.is_ready());
218        assert_eq!(tr.update(0.01), None);
219    }
220
221    #[test]
222    fn batch_equals_streaming() {
223        let rets: Vec<f64> = (0..60)
224            .map(|i| (f64::from(i) * 0.25).sin() * 0.02)
225            .collect();
226        let batch = TailRatio::new(15).unwrap().batch(&rets);
227        let mut streamer = TailRatio::new(15).unwrap();
228        let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
229        assert_eq!(batch, streamed);
230    }
231
232    #[test]
233    fn percentile_at_top_returns_last() {
234        // When the rank floor reaches the final index (the 100th percentile), the
235        // helper returns the largest element without interpolating past the end.
236        assert_relative_eq!(percentile(&[1.0, 2.0, 3.0], 100.0), 3.0, epsilon = 1e-12);
237    }
238}