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.
|
||||
|
||||
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:
|
||||
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"
|
||||
).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)
|
||||
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 {
|
||||
"cumulative_return": 0.0,
|
||||
"sharpe_annual": 0.0,
|
||||
"turnover_annual": 0.0,
|
||||
"max_drawdown": 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_returns = (weights * returns).sum(axis=1) / weights.abs().sum(axis=1)
|
||||
# Daily portfolio return = sum(w_t * r_{t+1}) / sum(|w_t|).
|
||||
# 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 = 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
|
||||
# Daily turnover = sum(|w_t - w_{t-1}|) / sum(|w_{t-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
|
||||
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,
|
||||
"max_drawdown": max_drawdown,
|
||||
"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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def signal(self, close: pd.DataFrame) -> pd.DataFrame:
|
||||
reversal = -close.pct_change(self.lookback)
|
||||
vol = close.pct_change().rolling(self.vol_window).std()
|
||||
reversal = -close.pct_change(self.lookback, fill_method=None)
|
||||
vol = close.pct_change(fill_method=None).rolling(self.vol_window).std()
|
||||
return reversal / vol
|
||||
|
||||
Reference in New Issue
Block a user