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
+29 -9
View File
@@ -83,7 +83,10 @@ def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict:
Computes return, annualized Sharpe, annualized turnover, max drawdown. Computes return, annualized Sharpe, annualized turnover, max drawdown.
Alpha is interpreted as POSITION WEIGHTS, not predictions. Alpha is interpreted as POSITION WEIGHTS, not predictions.
Return on date t = sum(weight[s,t] * realized_return[s,t]) / sum(abs(weight[s,t])) Return on date t = sum(weight[s,t] * realized_return[s,t+1]) /
sum(abs(weight[s,t])). This matches the close-derived signal convention:
weights formed with close[t] earn the next close-to-close return, avoiding
look-ahead.
Args: Args:
alpha_df: DataFrame with ALPHA_COLUMNS. alpha_df: DataFrame with ALPHA_COLUMNS.
@@ -101,23 +104,40 @@ def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict:
index="date", columns="symbol_id", values="weight", aggfunc="first" index="date", columns="symbol_id", values="weight", aggfunc="first"
).sort_index() ).sort_index()
# Align dates # Align weights to signal dates that exist on the market calendar. Compute
# forward returns on the full market calendar first, so sparse signal grids
# still earn the next available data date instead of the next signal date.
common_dates = weights.index.intersection(returns.index) common_dates = weights.index.intersection(returns.index)
weights = weights.loc[common_dates] weights = weights.loc[common_dates]
returns = returns.loc[common_dates] fwd_returns = returns.shift(-1).reindex(common_dates)
if len(common_dates) < 2: if len(common_dates) < 1:
return { return {
"cumulative_return": 0.0, "cumulative_return": 0.0,
"sharpe_annual": 0.0, "sharpe_annual": 0.0,
"turnover_annual": 0.0, "turnover_annual": 0.0,
"max_drawdown": 0.0, "max_drawdown": 0.0,
"hit_rate": 0.0, "hit_rate": 0.0,
"n_dates": len(common_dates), "n_dates": 0,
} }
# Daily portfolio return = sum(w * r) / sum(|w|) — normalized by gross exposure # Daily portfolio return = sum(w_t * r_{t+1}) / sum(|w_t|).
daily_returns = (weights * returns).sum(axis=1) / weights.abs().sum(axis=1) # The last signal date has no next-period return and is dropped below.
gross = weights.abs().sum(axis=1)
daily_returns = (
(weights * fwd_returns).sum(axis=1, min_count=1)
/ gross.replace(0.0, np.nan)
)
daily_returns = daily_returns.dropna()
if len(daily_returns) < 2:
return {
"cumulative_return": 0.0,
"sharpe_annual": 0.0,
"turnover_annual": 0.0,
"max_drawdown": 0.0,
"hit_rate": 0.0,
"n_dates": int(len(daily_returns)),
}
# Cumulative return # Cumulative return
cumulative_return = float((1.0 + daily_returns).prod() - 1.0) cumulative_return = float((1.0 + daily_returns).prod() - 1.0)
@@ -130,7 +150,7 @@ def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict:
# Annualized turnover: avg daily turnover * 252 # Annualized turnover: avg daily turnover * 252
# Daily turnover = sum(|w_t - w_{t-1}|) / sum(|w_{t-1}|) # Daily turnover = sum(|w_t - w_{t-1}|) / sum(|w_{t-1}|)
weight_change = weights.diff().abs().sum(axis=1) weight_change = weights.diff().abs().sum(axis=1)
gross_exposure = weights.abs().sum(axis=1).shift(1) gross_exposure = gross.shift(1)
daily_turnover = weight_change / gross_exposure daily_turnover = weight_change / gross_exposure
turnover_annual = float(daily_turnover.mean() * 252) turnover_annual = float(daily_turnover.mean() * 252)
@@ -149,5 +169,5 @@ def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict:
"turnover_annual": turnover_annual, "turnover_annual": turnover_annual,
"max_drawdown": max_drawdown, "max_drawdown": max_drawdown,
"hit_rate": hit_rate, "hit_rate": hit_rate,
"n_dates": len(common_dates), "n_dates": int(len(daily_returns)),
} }
+1 -1
View File
@@ -15,4 +15,4 @@ class MomentumAlpha(BaseAlpha):
self.lookback = lookback self.lookback = lookback
def signal(self, close: pd.DataFrame) -> pd.DataFrame: def signal(self, close: pd.DataFrame) -> pd.DataFrame:
return close.pct_change(self.lookback) return close.pct_change(self.lookback, fill_method=None)
+1 -1
View File
@@ -15,4 +15,4 @@ class ReversalAlpha(BaseAlpha):
self.lookback = lookback self.lookback = lookback
def signal(self, close: pd.DataFrame) -> pd.DataFrame: def signal(self, close: pd.DataFrame) -> pd.DataFrame:
return -close.pct_change(self.lookback) return -close.pct_change(self.lookback, fill_method=None)
+2 -2
View File
@@ -21,6 +21,6 @@ class ReversalVolAlpha(BaseAlpha):
self.vol_window = vol_window self.vol_window = vol_window
def signal(self, close: pd.DataFrame) -> pd.DataFrame: def signal(self, close: pd.DataFrame) -> pd.DataFrame:
reversal = -close.pct_change(self.lookback) reversal = -close.pct_change(self.lookback, fill_method=None)
vol = close.pct_change().rolling(self.vol_window).std() vol = close.pct_change(fill_method=None).rolling(self.vol_window).std()
return reversal / vol return reversal / vol
+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( close = data_df.pivot_table(
index="date", columns="symbol_id", values="close", aggfunc="first" index="date", columns="symbol_id", values="close", aggfunc="first"
).sort_index() ).sort_index()
returns = close.pct_change() returns = close.pct_change(fill_method=None)
weights = positions_df.pivot_table( weights = positions_df.pivot_table(
index="date", columns="symbol_id", values="target_weight", aggfunc="first" 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) common = weights.index.intersection(returns.index)
weights = weights.loc[common] 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 = { empty = {
"cumulative_return": 0.0, "sharpe_annual": 0.0, "turnover_annual": 0.0, "cumulative_return": 0.0, "sharpe_annual": 0.0, "turnover_annual": 0.0,
"max_drawdown": 0.0, "fitness": 0.0, "hit_rate": 0.0, "max_drawdown": 0.0, "fitness": 0.0, "hit_rate": 0.0,
"n_dates": len(common), "n_dates": len(common),
} }
if len(common) < 3: if len(common) < 1:
empty["n_dates"] = 0
return empty return empty
gross = weights.abs().sum(axis=1) gross = weights.abs().sum(axis=1)
# Weights at t earn the return from t to t+1: shift returns back by one. # Weights at t earn the return from t to t+1.
fwd = returns.shift(-1) daily = (
daily = (weights * fwd).sum(axis=1) / gross.replace(0.0, np.nan) (weights * fwd).sum(axis=1, min_count=1)
/ gross.replace(0.0, np.nan)
)
daily = daily.dropna() daily = daily.dropna()
if len(daily) < 2: if len(daily) < 2:
empty["n_dates"] = int(len(daily))
return empty return empty
cumulative_return = float((1.0 + daily).prod() - 1.0) cumulative_return = float((1.0 + daily).prod() - 1.0)
+79 -2
View File
@@ -51,7 +51,7 @@ def test_reversal_sign_matches_negative_trailing_return():
data = _make_data() data = _make_data()
alpha = compute_alpha(data, "rev5", "reversal", lookback=5) alpha = compute_alpha(data, "rev5", "reversal", lookback=5)
close = data.pivot_table(index="date", columns="symbol_id", values="close").sort_index() close = data.pivot_table(index="date", columns="symbol_id", values="close").sort_index()
raw = -close.pct_change(5) raw = -close.pct_change(5, fill_method=None)
last = raw.index[-1] last = raw.index[-1]
expected_top = raw.loc[last].idxmax() expected_top = raw.loc[last].idxmax()
got = alpha[alpha["date"] == last].set_index("symbol_id")["weight"].idxmax() got = alpha[alpha["date"] == last].set_index("symbol_id")["weight"].idxmax()
@@ -74,6 +74,83 @@ def test_evaluate_alpha_keys():
assert key in metrics assert key in metrics
def test_evaluate_alpha_uses_next_period_returns():
dates = pd.date_range("2024-01-01", periods=4)
data = pd.concat([
pd.DataFrame({
"symbol_id": "sh600000",
"symbol_name": "sh600000",
"date": dates,
"open": [100.0, 200.0, 200.0, 200.0],
"high": [100.0, 200.0, 200.0, 200.0],
"low": [100.0, 200.0, 200.0, 200.0],
"close": [100.0, 200.0, 200.0, 200.0],
"volume": 1_000.0,
"amount": 1_000.0,
}),
pd.DataFrame({
"symbol_id": "sz000001",
"symbol_name": "sz000001",
"date": dates,
"open": [100.0, 100.0, 200.0, 200.0],
"high": [100.0, 100.0, 200.0, 200.0],
"low": [100.0, 100.0, 200.0, 200.0],
"close": [100.0, 100.0, 200.0, 200.0],
"volume": 1_000.0,
"amount": 1_000.0,
}),
], ignore_index=True)
alpha = pd.DataFrame({
"symbol_id": ["sh600000", "sz000001", "sh600000", "sz000001"],
"date": [dates[1], dates[1], dates[2], dates[2]],
"alpha_name": ["toy"] * 4,
"weight": [-1.0, 1.0, 1.0, -1.0],
})
metrics = evaluate_alpha(alpha, data)
assert metrics["n_dates"] == 2
assert np.isclose(metrics["cumulative_return"], 0.5)
def test_evaluate_alpha_excludes_signal_without_forward_return():
dates = pd.date_range("2024-01-01", periods=3)
data = pd.concat([
pd.DataFrame({
"symbol_id": "sh600000",
"symbol_name": "sh600000",
"date": dates,
"open": [100.0, 100.0, 200.0],
"high": [100.0, 100.0, 200.0],
"low": [100.0, 100.0, 200.0],
"close": [100.0, 100.0, 200.0],
"volume": 1_000.0,
"amount": 1_000.0,
}),
pd.DataFrame({
"symbol_id": "sz000001",
"symbol_name": "sz000001",
"date": dates,
"open": [100.0, 100.0, 100.0],
"high": [100.0, 100.0, 100.0],
"low": [100.0, 100.0, 100.0],
"close": [100.0, 100.0, 100.0],
"volume": 1_000.0,
"amount": 1_000.0,
}),
], ignore_index=True)
alpha = pd.DataFrame({
"symbol_id": ["sh600000", "sz000001", "sh600000", "sz000001"],
"date": [dates[1], dates[1], dates[2], dates[2]],
"alpha_name": ["toy"] * 4,
"weight": [1.0, -1.0, -1.0, 1.0],
})
metrics = evaluate_alpha(alpha, data)
assert metrics["n_dates"] == 1
def test_equal_weight_is_mean_of_alphas(): def test_equal_weight_is_mean_of_alphas():
data = _make_data() data = _make_data()
a = compute_alpha(data, "rev", "reversal", lookback=5) a = compute_alpha(data, "rev", "reversal", lookback=5)
@@ -163,7 +240,7 @@ def test_load_external_alpha_module(tmp_path):
self.span = span self.span = span
def signal(self, close: pd.DataFrame) -> pd.DataFrame: def signal(self, close: pd.DataFrame) -> pd.DataFrame:
return -close.pct_change(self.span) return -close.pct_change(self.span, fill_method=None)
''')) '''))
load_alpha_module(str(module_path)) load_alpha_module(str(module_path))
+25 -1
View File
@@ -65,7 +65,7 @@ def _make_data(n_days: int = 40, symbols=_SYMBOLS, start="2024-01-01",
def _make_weights(data: pd.DataFrame, name="combo") -> pd.DataFrame: def _make_weights(data: pd.DataFrame, name="combo") -> pd.DataFrame:
"""Demeaned per-date signed weights so the cross-section is dollar-neutral.""" """Demeaned per-date signed weights so the cross-section is dollar-neutral."""
close = data.pivot_table(index="date", columns="symbol_id", values="close").sort_index() close = data.pivot_table(index="date", columns="symbol_id", values="close").sort_index()
raw = -close.pct_change(5) raw = -close.pct_change(5, fill_method=None)
demeaned = raw.sub(raw.mean(axis=1), axis=0) demeaned = raw.sub(raw.mean(axis=1), axis=0)
long = demeaned.reset_index().melt(id_vars="date", var_name="symbol_id", long = demeaned.reset_index().melt(id_vars="date", var_name="symbol_id",
value_name="weight").dropna() value_name="weight").dropna()
@@ -535,3 +535,27 @@ def test_evaluate_portfolio_keys_no_ic():
assert key in metrics assert key in metrics
assert "ic" not in metrics assert "ic" not in metrics
assert "rank_ic" not in metrics assert "rank_ic" not in metrics
def test_evaluate_portfolio_excludes_signal_without_forward_return():
dates = pd.date_range("2024-01-01", periods=3)
data = pd.DataFrame([
{"symbol_id": sym, "date": d, "close": price}
for d, prices in zip(dates, [(100.0, 100.0), (100.0, 100.0), (200.0, 100.0)])
for sym, price in zip(("sh600000", "sz000001"), prices)
])
positions = pd.DataFrame({
"symbol_id": ["sh600000", "sz000001", "sh600000", "sz000001"],
"date": [dates[1], dates[1], dates[2], dates[2]],
"portfolio_name": ["run1"] * 4,
"target_weight": [0.5, -0.5, -0.5, 0.5],
"target_value": [500.0, -500.0, -500.0, 500.0],
"target_shares": [5.0, -5.0, -2.5, 5.0],
"position_shares": [5, -5, -2, 5],
"position_value": [500.0, -500.0, -400.0, 500.0],
"price": [100.0, 100.0, 200.0, 100.0],
})
metrics = evaluate_portfolio(positions, data)
assert metrics["n_dates"] == 1