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