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:
@@ -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)),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user