From 70a070b47e5fe7fd1190764beb578161840f6fff Mon Sep 17 00:00:00 2001 From: jungsoo-choi Date: Thu, 12 Feb 2026 16:13:28 +0900 Subject: [PATCH] BUG: Fix CAGR and annualized return calculations CAGR was dividing calendar days by `annual_trading_days` (e.g. 252) instead of 365.25, inflating the time period and underreporting CAGR. For weekly/monthly data this was drastically wrong (e.g. 365/52=7 years instead of 1 year). Return (Ann.) was slightly underestimated because `pct_change()` produces a leading NaN that `geometric_mean()` counted in the denominator via `fillna(0)`, diluting the geometric mean. Co-Authored-By: Claude Opus 4.6 --- backtesting/_stats.py | 4 ++-- backtesting/test/_test.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backtesting/_stats.py b/backtesting/_stats.py index 1f01c5a3..18f26198 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -129,7 +129,7 @@ def _round_timedelta(value, _period=_data_period(index)): 1 if freq_days == 365 else (365 if have_weekends else 252)) freq = {7: 'W', 31: 'ME', 365: 'YE'}.get(freq_days, 'D') - day_returns = equity_df['Equity'].resample(freq).last().dropna().pct_change() + day_returns = equity_df['Equity'].resample(freq).last().dropna().pct_change().dropna() gmean_day_return = geometric_mean(day_returns) # Annualized return and risk metrics are computed based on the (mostly correct) @@ -142,7 +142,7 @@ def _round_timedelta(value, _period=_data_period(index)): # s.loc['Return (Ann.) [%]'] = gmean_day_return * annual_trading_days * 100 # s.loc['Risk (Ann.) [%]'] = day_returns.std(ddof=1) * np.sqrt(annual_trading_days) * 100 if is_datetime_index: - time_in_years = (s.loc['Duration'].days + s.loc['Duration'].seconds / 86400) / annual_trading_days + time_in_years = (s.loc['Duration'].days + s.loc['Duration'].seconds / 86400) / 365.25 s.loc['CAGR [%]'] = ((s.loc['Equity Final [$]'] / equity[0])**(1 / time_in_years) - 1) * 100 if time_in_years else np.nan # noqa: E501 # Our Sharpe mismatches `empyrical.sharpe_ratio()` because they use arithmetic mean return diff --git a/backtesting/test/_test.py b/backtesting/test/_test.py index 63045ce1..5bc5838c 100644 --- a/backtesting/test/_test.py +++ b/backtesting/test/_test.py @@ -319,7 +319,7 @@ def test_compute_stats(self): 'Avg. Trade [%]': 2.531715975158555, 'Best Trade [%]': 53.59595229490424, 'Buy & Hold Return [%]': 522.0601851851852, - 'Calmar Ratio': 0.4414380935608377, + 'Calmar Ratio': 0.44166409202994605, 'Duration': pd.Timedelta('3116 days 00:00:00'), 'End': pd.Timestamp('2013-03-01 00:00:00'), 'Equity Final [$]': 51422.98999999996, @@ -330,14 +330,14 @@ def test_compute_stats(self): 'Max. Drawdown [%]': -47.98012705007589, 'Max. Trade Duration': pd.Timedelta('183 days 00:00:00'), 'Profit Factor': 2.167945974262033, - 'Return (Ann.) [%]': 21.180255813792282, + 'Return (Ann.) [%]': 21.191099249053224, 'Return [%]': 414.2298999999996, - 'Volatility (Ann.) [%]': 36.49390889140787, - 'CAGR [%]': 14.159843619607383, + 'Volatility (Ann.) [%]': 36.49716090702389, + 'CAGR [%]': 21.160245705962623, 'SQN': 1.0766187356697705, 'Kelly Criterion': 0.1518705127029717, - 'Sharpe Ratio': 0.5803778344714113, - 'Sortino Ratio': 1.0847880675854096, + 'Sharpe Ratio': 0.5806232244485349, + 'Sortino Ratio': 1.0853434352488662, 'Start': pd.Timestamp('2004-08-19 00:00:00'), 'Win Rate [%]': 46.96969696969697, 'Worst Trade [%]': -18.39887353835481,