wickra_core/indicators/
tail_ratio.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::sorted_window;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
44pub struct TailRatio {
45 period: usize,
46 window: VecDeque<f64>,
47 scratch: Vec<f64>,
50}
51
52impl TailRatio {
53 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 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
93fn 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 #[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 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 assert_relative_eq!(percentile(&[1.0, 2.0, 3.0], 100.0), 3.0, epsilon = 1e-12);
238 }
239}