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 scratch: Vec<f64>,
48}
49
50impl TailRatio {
51 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 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
94fn 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 #[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 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 assert_relative_eq!(percentile(&[1.0, 2.0, 3.0], 100.0), 3.0, epsilon = 1e-12);
237 }
238}