Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 44 additions & 8 deletions src/backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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.
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/cvar_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 39 additions & 12 deletions src/rebalance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.

Expand Down
41 changes: 41 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading