diff --git a/src/backtest.py b/src/backtest.py index 41ec45c..11e9860 100644 --- a/src/backtest.py +++ b/src/backtest.py @@ -86,7 +86,11 @@ def __init__( benchmark_portfolios ) - self._dates = returns_dict["dates"] + self._dates = pd.to_datetime(returns_dict["dates"]) + ( + self._cumulative_dates, + self._prepend_cumulative_anchor, + ) = self._build_cumulative_dates(returns_dict) self._return_type = returns_dict["return_type"] self._return_mean = returns_dict["mean"] self._covariance = returns_dict["covariance"] @@ -109,6 +113,28 @@ def __init__( "max drawdown", ] + @property + def cumulative_dates(self): + """Dates corresponding to the cumulative return/value series.""" + return self._cumulative_dates + + def _build_cumulative_dates(self, returns_dict): + """Build cumulative-value dates, including an explicit start anchor.""" + cumulative_dates = pd.DatetimeIndex(self._dates) + regime = returns_dict.get("regime", {}) + regime_range = regime.get("range") if isinstance(regime, dict) else None + + if len(cumulative_dates) == 0 or not regime_range: + return cumulative_dates, False + + start_date = pd.Timestamp(regime_range[0]) + first_return_date = pd.Timestamp(cumulative_dates[0]) + if start_date < first_return_date: + cumulative_dates = pd.DatetimeIndex([start_date]).append(cumulative_dates) + return cumulative_dates, True + + return cumulative_dates, False + def _generate_benchmark_portfolios(self, benchmark_portfolios): """ Generate benchmark portfolios from input specification. @@ -325,7 +351,9 @@ def backtest_against_benchmarks( # Prepare data for plotting cumulative_returns_dataframe = pd.DataFrame( - [], index=pd.to_datetime(self._dates), columns=backtest_results.index + [], + index=pd.to_datetime(self.cumulative_dates), + columns=backtest_results.index, ) for ptf_name, row in backtest_results.iterrows(): @@ -435,12 +463,16 @@ def backtest_against_benchmarks( if len(self._dates) > 0: try: # Try to use strftime if it's a datetime object - start_date = self._dates[0].strftime("%Y%m%d") - end_date = self._dates[-1].strftime("%Y%m%d") + start_date = self.cumulative_dates[0].strftime("%Y%m%d") + end_date = self.cumulative_dates[-1].strftime("%Y%m%d") except AttributeError: # If it's a string, convert to datetime first - start_date = pd.to_datetime(self._dates[0]).strftime("%Y%m%d") - end_date = pd.to_datetime(self._dates[-1]).strftime("%Y%m%d") + start_date = pd.to_datetime(self.cumulative_dates[0]).strftime( + "%Y%m%d" + ) + end_date = pd.to_datetime(self.cumulative_dates[-1]).strftime( + "%Y%m%d" + ) else: start_date = "unknown" end_date = "unknown" @@ -521,6 +553,7 @@ def _compute_return_metrics(self, portfolio_name, returns, cash): """ mean_return = np.mean(returns) + cash * self.risk_free_rate excess_returns = returns - self.risk_free_rate + returns_array = returns.to_numpy() if hasattr(returns, "to_numpy") else returns if self._return_type == "LINEAR": # Linear returns compound multiplicatively: (1+r1)*(1+r2)*... cumulative_returns = np.cumprod(1 + returns) @@ -540,14 +573,17 @@ def _compute_return_metrics(self, portfolio_name, returns, cash): f"Return type '{self._return_type}' not supported for cumulative returns" ) + if self._prepend_cumulative_anchor: + cumulative_returns = np.concatenate(([1.0], np.asarray(cumulative_returns))) + sharpe = self.sharpe_ratio(excess_returns) sortino = self.sortino_ratio(excess_returns) mdd = self.max_drawdown(cumulative_returns) result = pd.Series( [ - returns.to_numpy(), - cumulative_returns.to_numpy(), + np.asarray(returns_array), + np.asarray(cumulative_returns), portfolio_name, mean_return, sharpe, diff --git a/src/cvar_utils.py b/src/cvar_utils.py index ab84193..adb5681 100644 --- a/src/cvar_utils.py +++ b/src/cvar_utils.py @@ -68,6 +68,8 @@ def generate_samples_kde( """ if kde_settings is None: kde_settings = KDESettings() + elif isinstance(kde_settings, dict): + kde_settings = KDESettings(**kde_settings) kde_device = kde_settings.device bandwidth = kde_settings.bandwidth diff --git a/src/rebalance.py b/src/rebalance.py index 971c44b..f6ffe02 100644 --- a/src/rebalance.py +++ b/src/rebalance.py @@ -306,14 +306,16 @@ def re_optimize( backtest_result["cumulative returns"].values[0] * portfolio_value ) - cumulative_portfolio_value_array = np.concatenate( - (cumulative_portfolio_value_array, cur_cumulative_portfolio_returns) + ( + cumulative_portfolio_value_array, + cumulative_portfolio_value_dates, + ) = self._append_cumulative_period( + cumulative_portfolio_value_array, + cumulative_portfolio_value_dates, + cur_cumulative_portfolio_returns, + backtester.cumulative_dates, ) - # Extract actual trading dates from the backtester object - backtest_period_dates = backtester._dates - cumulative_portfolio_value_dates.extend(backtest_period_dates) - # update portfolio value portfolio_value_pct_change = self._calculate_pct_change(backtest_result) transaction_cost = self._calculate_transaction_cost( @@ -363,13 +365,15 @@ def re_optimize( current_portfolio, tail_returns, benchmark_portfolios=None ) tail_result = tail_bt.backtest_single_portfolio(current_portfolio) - cumulative_portfolio_value_array = np.concatenate( - ( - cumulative_portfolio_value_array, - tail_result["cumulative returns"].values[0] * portfolio_value, - ) + ( + cumulative_portfolio_value_array, + cumulative_portfolio_value_dates, + ) = self._append_cumulative_period( + cumulative_portfolio_value_array, + cumulative_portfolio_value_dates, + tail_result["cumulative returns"].values[0] * portfolio_value, + tail_bt.cumulative_dates, ) - cumulative_portfolio_value_dates.extend(tail_bt._dates) # Convert to pandas Series with dates as index, ensuring proper datetime format cumulative_portfolio_value_dates_clean = pd.to_datetime( @@ -406,6 +410,29 @@ def re_optimize( return results_dataframe, re_optimize_dates, cumulative_portfolio_value + @staticmethod + def _append_cumulative_period( + cumulative_values, + cumulative_dates, + period_values, + period_dates, + ): + """Append an anchored cumulative period without duplicating boundaries.""" + period_values = np.asarray(period_values) + period_dates = list(pd.to_datetime(period_dates)) + + if cumulative_dates and period_dates: + if pd.Timestamp(cumulative_dates[-1]) == pd.Timestamp(period_dates[0]): + period_values = period_values[1:] + period_dates = period_dates[1:] + + if len(period_values) == 0: + return cumulative_values, cumulative_dates + + cumulative_values = np.concatenate((cumulative_values, period_values)) + cumulative_dates.extend(period_dates) + return cumulative_values, cumulative_dates + def _calculate_pct_change(self, backtest_result: pd.DataFrame): """Calculate percentage change in portfolio value over backtest period. diff --git a/tests/test_core.py b/tests/test_core.py index bd9865d..fa349eb 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -471,6 +471,47 @@ def test_max_drawdown_bounded(self, backtest_returns_dict): mdd = results.loc["test", "max drawdown"] assert 0 <= mdd <= 1, "max drawdown should be between 0 and 1" + def test_cumulative_returns_anchor_to_regime_start(self): + dates = pd.to_datetime( + ["2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05"] + ) + prices = pd.DataFrame( + { + "AAPL": [100.0, 101.0, 102.0, 103.0], + "GOOGL": [50.0, 49.0, 50.0, 51.0], + "MSFT": [200.0, 200.0, 202.0, 204.0], + }, + index=dates, + ) + returns_dict = calculate_returns( + prices, + regime_dict={"name": "test", "range": ("2024-01-02", "2024-01-05")}, + returns_compute_settings=ReturnsComputeSettings( + return_type="LINEAR", freq=1 + ), + ) + portfolio = Portfolio( + name="test", + tickers=TICKERS, + weights=np.array([0.5, 0.5, 0.0]), + cash=0.0, + ) + bt = portfolio_backtester( + test_portfolio=portfolio, + returns_dict=returns_dict, + risk_free_rate=0.0, + benchmark_portfolios=[], + ) + + result = bt.backtest_single_portfolio(portfolio) + cumulative_returns = result["cumulative returns"].iloc[0] + + assert bt.cumulative_dates[0] == pd.Timestamp("2024-01-02") + assert bt.cumulative_dates[1] == pd.Timestamp("2024-01-03") + assert len(cumulative_returns) == len(bt.cumulative_dates) + assert cumulative_returns[0] == pytest.approx(1.0) + assert cumulative_returns[1] == pytest.approx(0.995) + def test_drawdown_known_series(self): values = np.array([1.0, 1.1, 1.05, 0.9, 0.95, 1.0]) bt = portfolio_backtester.__new__(portfolio_backtester)