nautilus_analysis/statistics/
tracking_error.rs1use std::fmt::Display;
19
20use nautilus_model::position::Position;
21
22use crate::{Returns, statistic::PortfolioStatistic};
23
24#[repr(C)]
40#[derive(Debug, Clone)]
41#[cfg_attr(
42 feature = "python",
43 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.analysis", from_py_object)
44)]
45#[cfg_attr(
46 feature = "python",
47 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
48)]
49pub struct TrackingError {
50 period: usize,
52}
53
54impl TrackingError {
55 #[must_use]
57 pub fn new(period: Option<usize>) -> Self {
58 Self {
59 period: period.unwrap_or(252),
60 }
61 }
62}
63
64impl Display for TrackingError {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 write!(f, "Tracking Error ({} days)", self.period)
67 }
68}
69
70impl PortfolioStatistic for TrackingError {
71 type Item = f64;
72
73 fn name(&self) -> String {
74 self.to_string()
75 }
76
77 fn calculate_from_returns(&self, _returns: &Returns) -> Option<Self::Item> {
78 None
79 }
80
81 fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
82 None
83 }
84
85 fn calculate_from_positions(&self, _positions: &[Position]) -> Option<Self::Item> {
86 None
87 }
88
89 fn calculate_from_returns_with_benchmark(
90 &self,
91 returns: &Returns,
92 benchmark: &Returns,
93 ) -> Option<Self::Item> {
94 let (r, b) = self.align_returns(returns, benchmark);
95 if r.len() < 2 {
96 return Some(f64::NAN);
97 }
98
99 let (_, std_active) = active_return_stats(&r, &b);
100 Some(std_active * (self.period as f64).sqrt())
101 }
102}
103
104pub(crate) fn active_return_stats(r: &[f64], b: &[f64]) -> (f64, f64) {
109 let n = r.len() as f64;
110 let mean = r
111 .iter()
112 .zip(b.iter())
113 .map(|(&ri, &bi)| ri - bi)
114 .sum::<f64>()
115 / n;
116 let variance = r
117 .iter()
118 .zip(b.iter())
119 .map(|(&ri, &bi)| (ri - bi - mean).powi(2))
120 .sum::<f64>()
121 / (n - 1.0);
122 (mean, variance.sqrt())
123}
124
125#[cfg(test)]
126mod tests {
127 use std::collections::BTreeMap;
128
129 use nautilus_core::{UnixNanos, approx_eq};
130 use rstest::rstest;
131
132 use super::*;
133
134 fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
135 let mut new_return = BTreeMap::new();
136 let one_day_in_nanos = 86_400_000_000_000;
137 let start_time = 1_600_000_000_000_000_000;
138
139 for (i, &value) in values.iter().enumerate() {
140 let timestamp = start_time + i as u64 * one_day_in_nanos;
141 new_return.insert(UnixNanos::from(timestamp), value);
142 }
143
144 new_return
145 }
146
147 #[rstest]
148 fn test_name() {
149 let stat = TrackingError::new(None);
150 assert_eq!(stat.name(), "Tracking Error (252 days)");
151 }
152
153 #[rstest]
154 fn test_name_non_default_period() {
155 let stat = TrackingError::new(Some(63));
156 assert_eq!(stat.name(), "Tracking Error (63 days)");
157 }
158
159 #[rstest]
160 fn test_known_value_nonzero_benchmark() {
161 let returns = create_returns(&[0.03, -0.01, 0.02, 0.04]);
170 let benchmark = create_returns(&[0.01, 0.005, 0.005, 0.01]);
171 let stat = TrackingError::new(Some(252));
172 let result = stat
173 .calculate_from_returns_with_benchmark(&returns, &benchmark)
174 .unwrap();
175 assert!(approx_eq!(f64, result, 0.30740852297878796, epsilon = 1e-9));
176 }
177
178 #[rstest]
179 fn test_known_value() {
180 let benchmark = create_returns(&[0.00, 0.00, 0.00]);
183 let returns = create_returns(&[0.01, 0.02, 0.03]);
184 let stat = TrackingError::new(Some(4));
185 let result = stat
186 .calculate_from_returns_with_benchmark(&returns, &benchmark)
187 .unwrap();
188 assert!(approx_eq!(f64, result, 0.02, epsilon = 1e-12));
189 }
190
191 #[rstest]
192 fn test_zero_active_is_zero() {
193 let benchmark = create_returns(&[0.01, -0.02, 0.015, -0.005]);
195 let returns = create_returns(&[0.01, -0.02, 0.015, -0.005]);
196 let stat = TrackingError::new(None);
197 let result = stat
198 .calculate_from_returns_with_benchmark(&returns, &benchmark)
199 .unwrap();
200 assert!(approx_eq!(f64, result, 0.0, epsilon = 1e-12));
201 }
202
203 #[rstest]
204 fn test_empty_returns_is_nan() {
205 let stat = TrackingError::new(None);
206 let result = stat
207 .calculate_from_returns_with_benchmark(&create_returns(&[]), &create_returns(&[]))
208 .unwrap();
209 assert!(result.is_nan());
210 }
211
212 #[rstest]
213 fn test_single_overlap_is_nan() {
214 let benchmark = create_returns(&[0.01, -0.02, 0.015]);
215 let returns = create_returns(&[0.02]);
216 let stat = TrackingError::new(None);
217 let result = stat
218 .calculate_from_returns_with_benchmark(&returns, &benchmark)
219 .unwrap();
220 assert!(result.is_nan());
221 }
222}