Evaluate weights against next-period returns to avoid look-ahead

Weights formed from close[t] now earn the t→t+1 return: forward returns
are computed on the full market calendar before selecting signal dates,
so a sparse signal grid earns the next available return rather than the
next signal date, and the final signal date (no forward return) is
dropped. Signal pct_change uses fill_method=None so suspended names do
not inherit stale prices.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Yuxuan Yan
2026-06-11 17:39:55 +08:00
parent 534b91aaa4
commit 0a6f367fbf
7 changed files with 150 additions and 22 deletions
+13 -6
View File
@@ -35,7 +35,7 @@ def evaluate_portfolio(positions_df: pd.DataFrame, data_df: pd.DataFrame) -> dic
close = data_df.pivot_table(
index="date", columns="symbol_id", values="close", aggfunc="first"
).sort_index()
returns = close.pct_change()
returns = close.pct_change(fill_method=None)
weights = positions_df.pivot_table(
index="date", columns="symbol_id", values="target_weight", aggfunc="first"
@@ -43,22 +43,29 @@ def evaluate_portfolio(positions_df: pd.DataFrame, data_df: pd.DataFrame) -> dic
common = weights.index.intersection(returns.index)
weights = weights.loc[common]
returns = returns.loc[common]
# Compute forward returns on the full market calendar before selecting
# signal dates. This preserves next-period returns when the signal grid is
# sparser than the data grid.
fwd = returns.shift(-1).reindex(common)
empty = {
"cumulative_return": 0.0, "sharpe_annual": 0.0, "turnover_annual": 0.0,
"max_drawdown": 0.0, "fitness": 0.0, "hit_rate": 0.0,
"n_dates": len(common),
}
if len(common) < 3:
if len(common) < 1:
empty["n_dates"] = 0
return empty
gross = weights.abs().sum(axis=1)
# Weights at t earn the return from t to t+1: shift returns back by one.
fwd = returns.shift(-1)
daily = (weights * fwd).sum(axis=1) / gross.replace(0.0, np.nan)
# Weights at t earn the return from t to t+1.
daily = (
(weights * fwd).sum(axis=1, min_count=1)
/ gross.replace(0.0, np.nan)
)
daily = daily.dropna()
if len(daily) < 2:
empty["n_dates"] = int(len(daily))
return empty
cumulative_return = float((1.0 + daily).prod() - 1.0)