wickra_core/indicators/
tail_ratio.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
43pub struct TailRatio {
44 period: usize,
45 window: VecDeque<f64>,
46}
47
48impl TailRatio {
49 pub fn new(period: usize) -> Result<Self> {
56 if period < 2 {
57 return Err(Error::InvalidPeriod {
58 message: "tail ratio needs period >= 2",
59 });
60 }
61 Ok(Self {
62 period,
63 window: VecDeque::with_capacity(period),
64 })
65 }
66
67 pub const fn period(&self) -> usize {
69 self.period
70 }
71
72 fn compute(&self) -> f64 {
73 let mut sorted: Vec<f64> = self.window.iter().copied().collect();
74 sorted.sort_unstable_by(f64::total_cmp);
75 let upper = percentile(&sorted, 95.0);
76 let lower = percentile(&sorted, 5.0).abs();
77 if lower > 0.0 {
78 upper / lower
79 } else {
80 0.0
81 }
82 }
83}
84
85fn percentile(sorted: &[f64], pct: f64) -> f64 {
87 let last_index = sorted.len() - 1;
88 #[allow(clippy::cast_precision_loss)]
89 let rank = pct / 100.0 * last_index as f64;
90 let floor = rank.floor();
91 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
93 let lower = floor as usize;
94 if lower >= last_index {
95 return sorted[last_index];
96 }
97 let frac = rank - floor;
98 sorted[lower] + frac * (sorted[lower + 1] - sorted[lower])
99}
100
101impl Indicator for TailRatio {
102 type Input = f64;
103 type Output = f64;
104
105 fn update(&mut self, ret: f64) -> Option<f64> {
106 if !ret.is_finite() {
107 return None;
108 }
109 if self.window.len() == self.period {
110 self.window.pop_front();
111 }
112 self.window.push_back(ret);
113 if self.window.len() < self.period {
114 return None;
115 }
116 Some(self.compute())
117 }
118
119 fn reset(&mut self) {
120 self.window.clear();
121 }
122
123 fn warmup_period(&self) -> usize {
124 self.period
125 }
126
127 fn is_ready(&self) -> bool {
128 self.window.len() == self.period
129 }
130
131 fn name(&self) -> &'static str {
132 "TailRatio"
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::traits::BatchExt;
140 use approx::assert_relative_eq;
141
142 #[test]
143 fn rejects_period_less_than_two() {
144 assert!(matches!(
145 TailRatio::new(1),
146 Err(Error::InvalidPeriod { .. })
147 ));
148 assert!(matches!(
149 TailRatio::new(0),
150 Err(Error::InvalidPeriod { .. })
151 ));
152 }
153
154 #[test]
155 fn accessors_and_metadata() {
156 let tr = TailRatio::new(20).unwrap();
157 assert_eq!(tr.period(), 20);
158 assert_eq!(tr.warmup_period(), 20);
159 assert_eq!(tr.name(), "TailRatio");
160 assert!(!tr.is_ready());
161 }
162
163 #[test]
164 fn reference_value() {
165 let mut tr = TailRatio::new(5).unwrap();
170 let out = tr.batch(&[-0.04, -0.02, 0.0, 0.02, 0.04]);
171 assert_relative_eq!(out[4].unwrap(), 1.0, epsilon = 1e-9);
172 }
173
174 #[test]
175 fn fatter_right_tail_exceeds_one() {
176 let mut tr = TailRatio::new(5).unwrap();
177 let out = tr.batch(&[-0.01, 0.0, 0.01, 0.02, 0.10]);
178 assert!(out[4].unwrap() > 1.0);
179 }
180
181 #[test]
182 fn flat_window_is_zero() {
183 let mut tr = TailRatio::new(4).unwrap();
184 let last = tr.batch(&[0.0; 4]).into_iter().flatten().last().unwrap();
185 assert_relative_eq!(last, 0.0, epsilon = 1e-12);
186 }
187
188 #[test]
189 fn ignores_non_finite_input() {
190 let mut tr = TailRatio::new(3).unwrap();
191 assert_eq!(tr.update(0.01), None);
192 assert_eq!(tr.update(f64::NAN), None);
193 assert_eq!(tr.update(0.02), None);
194 assert!(tr.update(0.03).is_some());
195 }
196
197 #[test]
198 fn reset_clears_state() {
199 let mut tr = TailRatio::new(3).unwrap();
200 tr.batch(&[-0.01, 0.0, 0.02]);
201 assert!(tr.is_ready());
202 tr.reset();
203 assert!(!tr.is_ready());
204 assert_eq!(tr.update(0.01), None);
205 }
206
207 #[test]
208 fn batch_equals_streaming() {
209 let rets: Vec<f64> = (0..60)
210 .map(|i| (f64::from(i) * 0.25).sin() * 0.02)
211 .collect();
212 let batch = TailRatio::new(15).unwrap().batch(&rets);
213 let mut streamer = TailRatio::new(15).unwrap();
214 let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
215 assert_eq!(batch, streamed);
216 }
217
218 #[test]
219 fn percentile_at_top_returns_last() {
220 assert_relative_eq!(percentile(&[1.0, 2.0, 3.0], 100.0), 3.0, epsilon = 1e-12);
223 }
224}