nautilus_analysis/statistics/
beta_ratio.rs1use std::fmt::Display;
19
20use nautilus_model::position::Position;
21
22use crate::{Returns, statistic::PortfolioStatistic};
23
24#[repr(C)]
41#[derive(Debug, Clone, Default)]
42#[cfg_attr(
43 feature = "python",
44 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.analysis", from_py_object)
45)]
46#[cfg_attr(
47 feature = "python",
48 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
49)]
50pub struct BetaRatio {}
51
52impl BetaRatio {
53 #[must_use]
55 pub fn new() -> Self {
56 Self {}
57 }
58}
59
60impl Display for BetaRatio {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 write!(f, "Beta")
63 }
64}
65
66impl PortfolioStatistic for BetaRatio {
67 type Item = f64;
68
69 fn name(&self) -> String {
70 self.to_string()
71 }
72
73 fn calculate_from_returns(&self, _returns: &Returns) -> Option<Self::Item> {
74 None
75 }
76
77 fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
78 None
79 }
80
81 fn calculate_from_positions(&self, _positions: &[Position]) -> Option<Self::Item> {
82 None
83 }
84
85 fn calculate_from_returns_with_benchmark(
86 &self,
87 returns: &Returns,
88 benchmark: &Returns,
89 ) -> Option<Self::Item> {
90 let (r, b) = self.align_returns(returns, benchmark);
91 let n = r.len();
92 if n < 2 {
93 return Some(f64::NAN);
94 }
95
96 Some(beta(&r, &b))
97 }
98}
99
100pub(crate) fn beta(r: &[f64], b: &[f64]) -> f64 {
106 let n = r.len() as f64;
107 let mean_r = r.iter().sum::<f64>() / n;
108 let mean_b = b.iter().sum::<f64>() / n;
109
110 let covariance = r
111 .iter()
112 .zip(b.iter())
113 .map(|(&ri, &bi)| (ri - mean_r) * (bi - mean_b))
114 .sum::<f64>()
115 / (n - 1.0);
116 let variance_b = b.iter().map(|&bi| (bi - mean_b).powi(2)).sum::<f64>() / (n - 1.0);
117
118 if variance_b < f64::EPSILON {
119 return f64::NAN;
120 }
121
122 covariance / variance_b
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 = BetaRatio::new();
150 assert_eq!(stat.name(), "Beta");
151 }
152
153 #[rstest]
154 fn test_known_value() {
155 let benchmark = create_returns(&[0.01, -0.02, 0.015, -0.005, 0.025]);
157 let returns = create_returns(&[0.02, -0.04, 0.030, -0.010, 0.050]);
158 let stat = BetaRatio::new();
159 let result = stat
160 .calculate_from_returns_with_benchmark(&returns, &benchmark)
161 .unwrap();
162 assert!(approx_eq!(f64, result, 2.0, epsilon = 1e-12));
163 }
164
165 #[rstest]
166 fn test_unit_beta() {
167 let benchmark = create_returns(&[0.01, -0.02, 0.015, -0.005, 0.025]);
169 let returns = create_returns(&[0.01, -0.02, 0.015, -0.005, 0.025]);
170 let stat = BetaRatio::new();
171 let result = stat
172 .calculate_from_returns_with_benchmark(&returns, &benchmark)
173 .unwrap();
174 assert!(approx_eq!(f64, result, 1.0, epsilon = 1e-12));
175 }
176
177 #[rstest]
178 fn test_flat_benchmark_is_nan() {
179 let benchmark = create_returns(&[0.01, 0.01, 0.01, 0.01, 0.01]);
180 let returns = create_returns(&[0.02, -0.04, 0.030, -0.010, 0.050]);
181 let stat = BetaRatio::new();
182 let result = stat
183 .calculate_from_returns_with_benchmark(&returns, &benchmark)
184 .unwrap();
185 assert!(result.is_nan());
186 }
187
188 #[rstest]
189 fn test_empty_returns_is_nan() {
190 let stat = BetaRatio::new();
191 let result = stat
192 .calculate_from_returns_with_benchmark(&create_returns(&[]), &create_returns(&[]))
193 .unwrap();
194 assert!(result.is_nan());
195 }
196
197 #[rstest]
198 fn test_single_overlap_is_nan() {
199 let benchmark = create_returns(&[0.01, -0.02, 0.015]);
201 let returns = create_returns(&[0.02]);
202 let stat = BetaRatio::new();
203 let result = stat
204 .calculate_from_returns_with_benchmark(&returns, &benchmark)
205 .unwrap();
206 assert!(result.is_nan());
207 }
208
209 #[rstest]
210 fn test_partial_overlap_inner_join() {
211 let one_day = 86_400_000_000_000_u64;
213 let start = 1_600_000_000_000_000_000_u64;
214
215 let mut returns = BTreeMap::new();
216 for (i, v) in [0.02, -0.04, 0.030, -0.010, 0.050].iter().enumerate() {
217 returns.insert(UnixNanos::from(start + i as u64 * one_day), *v);
218 }
219 let mut benchmark = BTreeMap::new();
220 for (i, v) in [0.015, -0.005, 0.025, 0.01, -0.02].iter().enumerate() {
221 benchmark.insert(UnixNanos::from(start + (i as u64 + 2) * one_day), *v);
222 }
223
224 let stat = BetaRatio::new();
227 let result = stat
228 .calculate_from_returns_with_benchmark(&returns, &benchmark)
229 .unwrap();
230 assert!(approx_eq!(f64, result, 2.0, epsilon = 1e-12));
231 }
232}