From 3e98e5d94c60ed305cfd2e76db1b3379ca4790a3 Mon Sep 17 00:00:00 2001 From: Brad Smith Date: Thu, 11 Jun 2026 11:58:05 -0500 Subject: [PATCH] fix(backtesting): annualize bar-count equity curves by trading days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit annualized_return divided the period by 365.25 in both branches of calculate_all_metrics. That is correct when the equity curve has a datetime column (elapsed calendar days / calendar days per year), but wrong when it does not: there each row is a trading bar, and a trading-bar count divided by 365.25 over-annualizes the return (e.g. 252 bars at +20% reported ~30% instead of ~20%). Use self.trading_days to annualize the no-datetime (bar-count) branch; leave the calendar-days branch unchanged. trading_days was already honored by the Sharpe/Sortino/volatility calculations — this aligns annualized_return with it for bar-indexed curves. Verified: 252 bars at +20% total now yields 0.20 annualized (was ~0.30). Adds a regression test for the bar-count branch. Closes #12 Co-Authored-By: Claude Opus 4.8 --- src/backtesting/performance/metrics.py | 9 +++++++-- tests/backtesting/test_performance.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/backtesting/performance/metrics.py b/src/backtesting/performance/metrics.py index 3b0cbcd..e8e047e 100644 --- a/src/backtesting/performance/metrics.py +++ b/src/backtesting/performance/metrics.py @@ -54,11 +54,16 @@ def calculate_all_metrics(self, # Calculate period length for annualization if len(equity_curve) > 1: if 'datetime' in equity_curve.columns: + # Real timestamps: elapsed calendar time over calendar days/yr. days = (equity_curve['datetime'].iloc[-1] - equity_curve['datetime'].iloc[0]).days + years = max(days / 365.25, 1 / self.trading_days) else: - days = len(equity_curve) # Assume daily data + # No timestamps: each row is a trading bar, so annualize by + # trading days per year, not calendar days. Dividing a + # trading-bar count by 365.25 over-annualizes the return. + bars = len(equity_curve) + years = max(bars / self.trading_days, 1 / self.trading_days) - years = max(days / 365.25, 1/self.trading_days) # Minimum one trading day metrics['annualized_return'] = (1 + metrics['total_return']) ** (1/years) - 1 else: metrics['annualized_return'] = 0.0 diff --git a/tests/backtesting/test_performance.py b/tests/backtesting/test_performance.py index fd108f7..e0ee171 100644 --- a/tests/backtesting/test_performance.py +++ b/tests/backtesting/test_performance.py @@ -72,6 +72,17 @@ def test_sharpe_ratio_calculation(self): assert metrics['sharpe_ratio'] > 10, "Sharpe ratio should be high for consistent returns" assert metrics['annualized_return'] > 10, "Annualized return should be very high" + def test_annualized_return_without_datetime_uses_trading_days(self): + """Equity curves without a datetime column are trading bars and must be + annualized by trading_days, not calendar days (issue #12).""" + # 252 trading bars (one trading year), +20% total -> ~20% annualized. + equity_curve = pd.DataFrame({'total': np.linspace(100000, 120000, 252)}) + + metrics = self.calculator.calculate_all_metrics(equity_curve, pd.DataFrame()) + + # Old code divided 252 bars by 365.25, over-annualizing to ~30%. + assert abs(metrics['annualized_return'] - 0.20) < 0.01 + def test_max_drawdown_calculation(self): """Test maximum drawdown calculation""" # Create equity curve with known drawdown