Skip to main content

nautilus_analysis/statistics/
beta_ratio.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Beta statistic (benchmark-relative).
17
18use std::fmt::Display;
19
20use nautilus_model::position::Position;
21
22use crate::{Returns, statistic::PortfolioStatistic};
23
24/// Calculates the beta of portfolio returns relative to a benchmark.
25///
26/// Beta measures the systematic risk (market sensitivity) of a portfolio and is
27/// calculated as the covariance of the portfolio and benchmark returns divided by
28/// the variance of the benchmark returns:
29///
30/// `Beta = Cov(portfolio, benchmark) / Var(benchmark)`
31///
32/// Sample (Bessel-corrected, `ddof = 1`) covariance and variance are used to match
33/// the standard deviation convention elsewhere in this crate. Beta is not annualized.
34///
35/// # References
36///
37/// - Sharpe, W. F. (1964). "Capital Asset Prices: A Theory of Market Equilibrium under
38///   Conditions of Risk". *Journal of Finance*, 19(3), 425-442.
39/// - CFA Institute Investment Foundations, 3rd Edition
40#[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    /// Creates a new [`BetaRatio`] instance.
54    #[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
100/// Computes sample (`ddof = 1`) beta of `r` against `b`.
101///
102/// Returns `f64::NAN` when the benchmark variance is below `f64::EPSILON` (e.g. a flat
103/// benchmark), which would otherwise produce a division by zero. Callers must ensure
104/// `r.len() == b.len() >= 2`.
105pub(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        // strategy = 2 * benchmark exactly, so beta = 2.0.
156        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        // Identical series -> beta of 1.0.
168        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        // Only one shared timestamp after inner join -> n < 2 -> NaN.
200        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        // Strategy spans days 0..5, benchmark days 2..7 -> overlap on days 2,3,4 only.
212        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        // Overlap: strategy[2,3,4] = [0.030, -0.010, 0.050];
225        //          benchmark[2,3,4] = [0.015, -0.005, 0.025] (here strategy == 2*benchmark).
226        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}