nautilus_analysis/statistics/
tail_ratio.rs1use nautilus_model::position::Position;
19
20use crate::{Returns, statistic::PortfolioStatistic};
21
22#[repr(C)]
42#[derive(Debug, Clone, Default)]
43#[cfg_attr(
44 feature = "python",
45 pyo3::pyclass(module = "nautilus_trader.analysis", from_py_object)
46)]
47#[cfg_attr(
48 feature = "python",
49 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
50)]
51pub struct TailRatio {}
52
53impl TailRatio {
54 #[must_use]
56 pub fn new() -> Self {
57 Self {}
58 }
59}
60
61fn percentile_linear(sorted_values: &[f64], q: f64) -> f64 {
67 debug_assert!(
68 !sorted_values.is_empty(),
69 "percentile requires a non-empty slice"
70 );
71 let n = sorted_values.len();
72 if n == 1 {
73 return sorted_values[0];
74 }
75
76 let rank = (q / 100.0) * (n - 1) as f64;
77 let lower = rank.floor() as usize;
78 let upper = rank.ceil() as usize;
79 if lower == upper {
80 return sorted_values[lower];
81 }
82
83 let weight = rank - lower as f64;
84 (sorted_values[upper] - sorted_values[lower]).mul_add(weight, sorted_values[lower])
86}
87
88impl PortfolioStatistic for TailRatio {
89 type Item = f64;
90
91 fn name(&self) -> String {
92 "Tail Ratio".to_string()
93 }
94
95 fn calculate_from_returns(&self, raw_returns: &Returns) -> Option<Self::Item> {
96 if !self.check_valid_returns(raw_returns) {
97 return Some(f64::NAN);
98 }
99
100 let returns = self.downsample_to_daily_bins(raw_returns);
101 let n = returns.len();
102 if n < 2 {
103 return Some(f64::NAN);
104 }
105
106 let mut values: Vec<f64> = returns.values().copied().collect();
107 values.sort_by(f64::total_cmp);
108
109 let p95 = percentile_linear(&values, 95.0);
110 let p5 = percentile_linear(&values, 5.0);
111 if p5 == 0.0 || !p5.is_finite() {
112 return Some(f64::NAN);
113 }
114
115 Some((p95 / p5).abs())
116 }
117
118 fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
119 None
120 }
121
122 fn calculate_from_positions(&self, _positions: &[Position]) -> Option<Self::Item> {
123 None
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use std::collections::BTreeMap;
130
131 use nautilus_core::{UnixNanos, approx_eq};
132 use rstest::rstest;
133
134 use super::*;
135
136 fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
137 let mut new_return = BTreeMap::new();
138 let one_day_in_nanos = 86_400_000_000_000;
139 let start_time = 1_600_000_000_000_000_000;
140
141 for (i, &value) in values.iter().enumerate() {
142 let timestamp = start_time + i as u64 * one_day_in_nanos;
143 new_return.insert(UnixNanos::from(timestamp), value);
144 }
145
146 new_return
147 }
148
149 #[rstest]
150 fn test_name() {
151 let tail_ratio = TailRatio::new();
152 assert_eq!(tail_ratio.name(), "Tail Ratio");
153 }
154
155 #[rstest]
156 fn test_empty_returns() {
157 let tail_ratio = TailRatio::new();
158 let returns = create_returns(&[]);
159 let result = tail_ratio.calculate_from_returns(&returns);
160 assert!(result.is_some());
161 assert!(result.unwrap().is_nan());
162 }
163
164 #[rstest]
165 fn test_insufficient_data() {
166 let tail_ratio = TailRatio::new();
167 let returns = create_returns(&[0.01]);
168 let result = tail_ratio.calculate_from_returns(&returns);
169 assert!(result.is_some());
170 assert!(result.unwrap().is_nan());
171 }
172
173 #[rstest]
174 fn test_tail_ratio_calculation() {
175 let tail_ratio = TailRatio::new();
178 let returns = create_returns(&[
179 0.01, -0.02, 0.03, -0.01, 0.02, 0.04, -0.03, 0.05, -0.04, 0.02,
180 ]);
181 let result = tail_ratio.calculate_from_returns(&returns);
182 assert!(result.is_some());
183 assert!(approx_eq!(
184 f64,
185 result.unwrap(),
186 1.2816901408450704,
187 epsilon = 1e-12
188 ));
189 }
190
191 #[rstest]
192 fn test_symmetric_returns_ratio_near_one() {
193 let tail_ratio = TailRatio::new();
195 let returns = create_returns(&[-0.03, -0.02, -0.01, 0.0, 0.01, 0.02, 0.03]);
196 let result = tail_ratio.calculate_from_returns(&returns);
197 assert!(result.is_some());
198 assert!(approx_eq!(f64, result.unwrap(), 1.0, epsilon = 1e-12));
199 }
200}