Skip to content

Commit 2d093f9

Browse files
authored
Add benchmark-relative portfolio statistics (#4251)
* Add benchmark-relative portfolio statistics * Strengthen benchmark-statistic tests Replace degenerate-input unit tests with discriminating ones for the five benchmark-relative statistics so they fail under subtle formula errors: - InformationRatio/TrackingError: known-value tests with a non-zero benchmark so the active = r - b subtraction is exercised. - TreynorRatio: a case with period != n and rf != 0 to catch a wrong annualization exponent and a missing rf annualization. - Alpha: a non-zero-mean benchmark with a small period to exercise the beta * (mean_b - rf) term and the geometric annualization. - InformationRatio: partial-overlap inner-join and intraday->daily compounding cases covering align_returns end-to-end. - Non-default-period name() tests. Also align BetaRatio.__repr__ with the other wrappers via to_string(). * Wire benchmark statistics into analyzer and tearsheet Expose the five benchmark-relative statistics (Beta, Alpha, Information Ratio, Tracking Error, Treynor) through the PortfolioAnalyzer and the tearsheet so they are computed and displayed when a benchmark is given: - analyzer.rs: add a stateless `get_performance_stats_returns_vs_benchmark` that iterates registered statistics and calls `calculate_from_returns_with_benchmark` against the primary returns and the caller-supplied benchmark, collecting only `Some` values. No benchmark field is stored; `Default`, `reset`, and `calculate_statistics` are untouched. - python/analyzer.rs: register/deregister the five new class names and add the PyO3 getter `get_performance_stats_returns_vs_benchmark`, transforming the benchmark map into the returns type before delegating to Rust. - analyzer.py: add a thin delegating getter that dispatches to the registered PyO3 benchmark statistics (non-benchmark stats lack the method and are skipped); no parallel Python math. - tearsheet.py: when a benchmark series is present, merge the benchmark stats into the returns statistics shown, consistent with `stats_returns`. - analysis/__init__.py: export the five new classes. - nautilus_pyo3.pyi: add the five classes and the new getter to the stub. * Compute benchmark stats from resolved tearsheet returns * Share active-return stats between IR and tracking error * Refine benchmark-statistic docs and add analyzer tests Polish the benchmark-statistic stack ahead of review: - Complete the Sharpe (1964) citation and unify doc terminology on "portfolio" across the five new statistics. - Correct the trait doc for the None default and add a genuine zero-beta Treynor test alongside the renamed flat-benchmark case. - Rename the unused PyO3 stub parameter to _returns, alphabetize the register/deregister match arms and .pyi class blocks. - Guard the Python benchmark getter to pyo3 statistics only and build the dict conversions lazily; note the pd.Timestamp index requirement. - Document benchmark-relative statistics in the tearsheet docstring. - Add analyzer-level benchmark tests in Rust and Python.
1 parent 2223963 commit 2d093f9

22 files changed

Lines changed: 1953 additions & 14 deletions

RELEASES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Released on TBD (UTC).
1818
- Added negative price support for `Commodity` instruments in risk checks (#2330), thanks for reporting @fabz1
1919
- Added `add_native_exec_algorithm` and `ExecutionAlgorithmConfig` bindings to the Python v2 backtest engine
2020
- Added `Order::to_order_status_report` conversion in Rust
21+
- Added benchmark-relative portfolio statistics (Beta, Alpha, Information Ratio, Tracking Error, Treynor Ratio) (#4251), thanks @mahimn01
2122

2223
### Breaking Changes
2324
- Changed plug-in loader to reject build mismatches by default; opt out with `set_allow_build_mismatch` (Rust)

crates/analysis/src/analyzer.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,30 @@ impl PortfolioAnalyzer {
546546
self.calculate_returns_stats(self.portfolio_returns())
547547
}
548548

549+
/// Gets all benchmark-relative return statistics for the primary returns.
550+
///
551+
/// This is stateless: the `benchmark` series is supplied by the caller rather
552+
/// than stored on the analyzer. Only statistics that override
553+
/// [`PortfolioStatistic::calculate_from_returns_with_benchmark`] (the benchmark-relative
554+
/// statistics) contribute values; all others return `None` and are skipped.
555+
#[must_use]
556+
pub fn get_performance_stats_returns_vs_benchmark(
557+
&self,
558+
benchmark: &Returns,
559+
) -> AHashMap<String, f64> {
560+
let mut output = AHashMap::new();
561+
562+
for (name, stat) in &self.statistics {
563+
if let Some(value) =
564+
stat.calculate_from_returns_with_benchmark(self.returns(), benchmark)
565+
{
566+
output.insert(name.clone(), value);
567+
}
568+
}
569+
570+
output
571+
}
572+
549573
/// Gets general portfolio statistics.
550574
#[must_use]
551575
pub fn get_performance_stats_general(&self) -> AHashMap<String, f64> {
@@ -690,6 +714,7 @@ mod tests {
690714
use rstest::rstest;
691715

692716
use super::*;
717+
use crate::statistics::beta_ratio::BetaRatio;
693718

694719
/// Mock implementation of `PortfolioStatistic` for testing.
695720
#[derive(Debug)]
@@ -1422,4 +1447,38 @@ mod tests {
14221447
));
14231448
assert_eq!(returns_stats, portfolio_stats);
14241449
}
1450+
1451+
#[rstest]
1452+
fn test_get_performance_stats_returns_vs_benchmark() {
1453+
let mut analyzer = PortfolioAnalyzer::new();
1454+
analyzer.register_statistic(Arc::new(BetaRatio::new()));
1455+
analyzer.register_statistic(Arc::new(SharpeRatio::new(None)));
1456+
1457+
let one_day = 86_400_000_000_000_u64;
1458+
let start = 1_600_000_000_000_000_000_u64;
1459+
for (i, value) in [0.03, -0.01, 0.02, 0.04].iter().enumerate() {
1460+
analyzer.add_return(UnixNanos::from(start + i as u64 * one_day), *value);
1461+
}
1462+
1463+
let mut benchmark: Returns = BTreeMap::new();
1464+
for (i, value) in [0.01, 0.005, 0.005, 0.01].iter().enumerate() {
1465+
benchmark.insert(UnixNanos::from(start + i as u64 * one_day), *value);
1466+
}
1467+
1468+
let stats = analyzer.get_performance_stats_returns_vs_benchmark(&benchmark);
1469+
1470+
// r = [0.03, -0.01, 0.02, 0.04], b = [0.01, 0.005, 0.005, 0.01]:
1471+
// mean_r = 0.02, mean_b = 0.0075
1472+
// Cov = 1.5e-4 / 3 = 5e-5, Var(b) = 2.5e-5 / 3 -> beta = 6.0
1473+
// Only the benchmark-relative statistic contributes; SharpeRatio
1474+
// returns None from the default and is skipped.
1475+
assert_eq!(stats.len(), 1);
1476+
assert!(approx_eq!(
1477+
f64,
1478+
*stats.get("Beta").unwrap(),
1479+
6.0,
1480+
epsilon = 1e-9
1481+
));
1482+
assert!(!stats.contains_key("Sharpe Ratio (252 days)"));
1483+
}
14251484
}

crates/analysis/src/python/analyzer.rs

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
// limitations under the License.
1414
// -------------------------------------------------------------------------------------------------
1515

16-
use std::{collections::HashMap, sync::Arc};
16+
use std::{
17+
collections::{BTreeMap, HashMap},
18+
sync::Arc,
19+
};
1720

1821
use nautilus_core::{UnixNanos, python::to_pyvalue_err};
1922
use nautilus_model::{
@@ -24,13 +27,16 @@ use nautilus_model::{
2427
use pyo3::prelude::*;
2528

2629
use crate::{
30+
Returns,
2731
analyzer::{PortfolioAnalyzer, Statistic},
2832
statistics::{
29-
expectancy::Expectancy, long_ratio::LongRatio, loser_avg::AvgLoser, loser_max::MaxLoser,
30-
loser_min::MinLoser, profit_factor::ProfitFactor, returns_avg::ReturnsAverage,
31-
returns_avg_loss::ReturnsAverageLoss, returns_avg_win::ReturnsAverageWin,
32-
returns_volatility::ReturnsVolatility, risk_return_ratio::RiskReturnRatio,
33-
sharpe_ratio::SharpeRatio, sortino_ratio::SortinoRatio, win_rate::WinRate,
33+
alpha::Alpha, beta_ratio::BetaRatio, expectancy::Expectancy,
34+
information_ratio::InformationRatio, long_ratio::LongRatio, loser_avg::AvgLoser,
35+
loser_max::MaxLoser, loser_min::MinLoser, profit_factor::ProfitFactor,
36+
returns_avg::ReturnsAverage, returns_avg_loss::ReturnsAverageLoss,
37+
returns_avg_win::ReturnsAverageWin, returns_volatility::ReturnsVolatility,
38+
risk_return_ratio::RiskReturnRatio, sharpe_ratio::SharpeRatio, sortino_ratio::SortinoRatio,
39+
tracking_error::TrackingError, treynor_ratio::TreynorRatio, win_rate::WinRate,
3440
winner_avg::AvgWinner, winner_max::MaxWinner, winner_min::MinWinner,
3541
},
3642
};
@@ -81,6 +87,21 @@ impl PortfolioAnalyzer {
8187
.collect()
8288
}
8389

90+
/// Gets all benchmark-relative return statistics for the primary returns.
91+
#[pyo3(name = "get_performance_stats_returns_vs_benchmark")]
92+
fn py_get_performance_stats_returns_vs_benchmark(
93+
&self,
94+
benchmark: BTreeMap<u64, f64>,
95+
) -> HashMap<String, f64> {
96+
let benchmark: Returns = benchmark
97+
.into_iter()
98+
.map(|(k, v)| (UnixNanos::from(k), v))
99+
.collect();
100+
self.get_performance_stats_returns_vs_benchmark(&benchmark)
101+
.into_iter()
102+
.collect()
103+
}
104+
84105
#[pyo3(name = "get_performance_stats_pnls")]
85106
fn py_get_performance_stats_pnls(
86107
&self,
@@ -196,6 +217,26 @@ impl PortfolioAnalyzer {
196217
let stat = statistic.extract::<LongRatio>(py)?;
197218
self.register_statistic(Arc::new(stat));
198219
}
220+
"Alpha" => {
221+
let stat = statistic.extract::<Alpha>(py)?;
222+
self.register_statistic(Arc::new(stat));
223+
}
224+
"BetaRatio" => {
225+
let stat = statistic.extract::<BetaRatio>(py)?;
226+
self.register_statistic(Arc::new(stat));
227+
}
228+
"InformationRatio" => {
229+
let stat = statistic.extract::<InformationRatio>(py)?;
230+
self.register_statistic(Arc::new(stat));
231+
}
232+
"TrackingError" => {
233+
let stat = statistic.extract::<TrackingError>(py)?;
234+
self.register_statistic(Arc::new(stat));
235+
}
236+
"TreynorRatio" => {
237+
let stat = statistic.extract::<TreynorRatio>(py)?;
238+
self.register_statistic(Arc::new(stat));
239+
}
199240
_ => {
200241
return Err(to_pyvalue_err(format!(
201242
"Unknown statistic type: {type_name}"
@@ -284,6 +325,26 @@ impl PortfolioAnalyzer {
284325
let stat = statistic.extract::<LongRatio>(py)?;
285326
self.deregister_statistic(&(Arc::new(stat) as Statistic));
286327
}
328+
"Alpha" => {
329+
let stat = statistic.extract::<Alpha>(py)?;
330+
self.deregister_statistic(&(Arc::new(stat) as Statistic));
331+
}
332+
"BetaRatio" => {
333+
let stat = statistic.extract::<BetaRatio>(py)?;
334+
self.deregister_statistic(&(Arc::new(stat) as Statistic));
335+
}
336+
"InformationRatio" => {
337+
let stat = statistic.extract::<InformationRatio>(py)?;
338+
self.deregister_statistic(&(Arc::new(stat) as Statistic));
339+
}
340+
"TrackingError" => {
341+
let stat = statistic.extract::<TrackingError>(py)?;
342+
self.deregister_statistic(&(Arc::new(stat) as Statistic));
343+
}
344+
"TreynorRatio" => {
345+
let stat = statistic.extract::<TreynorRatio>(py)?;
346+
self.deregister_statistic(&(Arc::new(stat) as Statistic));
347+
}
287348
_ => {
288349
return Err(to_pyvalue_err(format!(
289350
"Unknown statistic type: {type_name}"

crates/analysis/src/python/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,12 @@ pub fn analysis(_: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
6262
// Statistics - Position-based
6363
m.add_class::<crate::statistics::long_ratio::LongRatio>()?;
6464

65+
// Statistics - Benchmark-relative
66+
m.add_class::<crate::statistics::alpha::Alpha>()?;
67+
m.add_class::<crate::statistics::beta_ratio::BetaRatio>()?;
68+
m.add_class::<crate::statistics::information_ratio::InformationRatio>()?;
69+
m.add_class::<crate::statistics::tracking_error::TrackingError>()?;
70+
m.add_class::<crate::statistics::treynor_ratio::TreynorRatio>()?;
71+
6572
Ok(())
6673
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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+
use std::collections::BTreeMap;
17+
18+
use pyo3::prelude::*;
19+
20+
use super::transform_returns;
21+
use crate::{statistic::PortfolioStatistic, statistics::alpha::Alpha};
22+
23+
#[pymethods]
24+
#[pyo3_stub_gen::derive::gen_stub_pymethods]
25+
impl Alpha {
26+
/// Calculates Jensen's alpha of portfolio returns relative to a benchmark.
27+
///
28+
/// `alpha = (mean_strategy - rf) - beta * (mean_benchmark - rf)` per period, then
29+
/// annualized geometrically over `period` (default 252). The risk-free rate `rf`
30+
/// is per period (default 0.0).
31+
#[new]
32+
#[pyo3(signature = (period=None, risk_free_rate=None))]
33+
fn py_new(period: Option<usize>, risk_free_rate: Option<f64>) -> Self {
34+
Self::new(period, risk_free_rate)
35+
}
36+
37+
fn __repr__(&self) -> String {
38+
self.to_string()
39+
}
40+
41+
#[getter]
42+
#[pyo3(name = "name")]
43+
fn py_name(&self) -> String {
44+
self.name()
45+
}
46+
47+
#[pyo3(name = "calculate_from_returns")]
48+
fn py_calculate_from_returns(&self, _returns: BTreeMap<u64, f64>) -> Option<f64> {
49+
None
50+
}
51+
52+
#[pyo3(name = "calculate_from_realized_pnls")]
53+
fn py_calculate_from_realized_pnls(&self, _realized_pnls: Vec<f64>) -> Option<f64> {
54+
None
55+
}
56+
57+
#[pyo3(name = "calculate_from_positions")]
58+
fn py_calculate_from_positions(&self, _positions: Vec<Py<PyAny>>) -> Option<f64> {
59+
None
60+
}
61+
62+
#[pyo3(name = "calculate_from_returns_with_benchmark")]
63+
#[expect(clippy::needless_pass_by_value)]
64+
fn py_calculate_from_returns_with_benchmark(
65+
&self,
66+
returns: BTreeMap<u64, f64>,
67+
benchmark: BTreeMap<u64, f64>,
68+
) -> Option<f64> {
69+
self.calculate_from_returns_with_benchmark(
70+
&transform_returns(&returns),
71+
&transform_returns(&benchmark),
72+
)
73+
}
74+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
use std::collections::BTreeMap;
17+
18+
use pyo3::prelude::*;
19+
20+
use super::transform_returns;
21+
use crate::{statistic::PortfolioStatistic, statistics::beta_ratio::BetaRatio};
22+
23+
#[pymethods]
24+
#[pyo3_stub_gen::derive::gen_stub_pymethods]
25+
impl BetaRatio {
26+
/// Calculates the beta of portfolio returns relative to a benchmark.
27+
///
28+
/// Beta measures the systematic risk (market sensitivity) of a portfolio:
29+
/// `Beta = Cov(strategy, benchmark) / Var(benchmark)` using sample (`ddof = 1`)
30+
/// covariance and variance. Beta is not annualized.
31+
#[new]
32+
fn py_new() -> Self {
33+
Self::new()
34+
}
35+
36+
fn __repr__(&self) -> String {
37+
self.to_string()
38+
}
39+
40+
#[getter]
41+
#[pyo3(name = "name")]
42+
fn py_name(&self) -> String {
43+
self.name()
44+
}
45+
46+
#[pyo3(name = "calculate_from_returns")]
47+
fn py_calculate_from_returns(&self, _returns: BTreeMap<u64, f64>) -> Option<f64> {
48+
None
49+
}
50+
51+
#[pyo3(name = "calculate_from_realized_pnls")]
52+
fn py_calculate_from_realized_pnls(&self, _realized_pnls: Vec<f64>) -> Option<f64> {
53+
None
54+
}
55+
56+
#[pyo3(name = "calculate_from_positions")]
57+
fn py_calculate_from_positions(&self, _positions: Vec<Py<PyAny>>) -> Option<f64> {
58+
None
59+
}
60+
61+
#[pyo3(name = "calculate_from_returns_with_benchmark")]
62+
#[expect(clippy::needless_pass_by_value)]
63+
fn py_calculate_from_returns_with_benchmark(
64+
&self,
65+
returns: BTreeMap<u64, f64>,
66+
benchmark: BTreeMap<u64, f64>,
67+
) -> Option<f64> {
68+
self.calculate_from_returns_with_benchmark(
69+
&transform_returns(&returns),
70+
&transform_returns(&benchmark),
71+
)
72+
}
73+
}

0 commit comments

Comments
 (0)