Use next-open returns for research eval

This commit is contained in:
Yuxuan Yan
2026-06-12 18:41:18 +08:00
parent 16b4988f16
commit 3c58a1372e
10 changed files with 552 additions and 270 deletions
+31 -16
View File
@@ -25,9 +25,22 @@ def _pivot_close(df: pd.DataFrame) -> pd.DataFrame:
return pivot.sort_index()
def _daily_returns(close: pd.DataFrame) -> pd.DataFrame:
"""Compute daily returns from wide close DataFrame."""
return close.pct_change(fill_method=None)
def _pivot_open(df: pd.DataFrame) -> pd.DataFrame:
"""Pivot data to wide format: date index, columns = symbol_id, values = open."""
pivot = df.pivot_table(
index="date", columns="symbol_id", values="open", aggfunc="first"
)
return pivot.sort_index()
def _forward_open_to_open_returns(open_: pd.DataFrame) -> pd.DataFrame:
"""Return earned by a close-formed signal after next-open execution.
A weight formed after close on date t can first be traded at open[t+1].
With daily retargeting it is then held until open[t+2], so the signal-date
forward return is open[t+2] / open[t+1] - 1.
"""
return open_.shift(-2).divide(open_.shift(-1)) - 1.0
def investable_universe_mask(
@@ -150,11 +163,11 @@ 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+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.
Alpha is interpreted as POSITION WEIGHTS, not predictions. A close-formed
weight on date t is assumed tradable at open[t+1] and held until open[t+2].
Return on signal date t = sum(weight[s,t] * open_to_open_return[s,t]) /
sum(abs(weight[s,t])). This matches the execution convention without
crediting the new signal for the overnight gap before it can be traded.
Args:
alpha_df: DataFrame with ALPHA_COLUMNS.
@@ -164,8 +177,8 @@ def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict:
Dict with metrics: cumulative_return, sharpe_annual, turnover_annual,
max_drawdown, hit_rate, n_dates.
"""
close = _pivot_close(data_df)
returns = _daily_returns(close)
open_ = _pivot_open(data_df)
fwd_returns_all = _forward_open_to_open_returns(open_)
# Pivot alpha weights to wide format
weights = alpha_df.pivot_table(
@@ -173,11 +186,12 @@ def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict:
).sort_index()
# 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)
# forward open-to-open returns on the full market calendar first, so sparse
# signal grids still earn the next available open-to-open interval instead
# of the next signal date.
common_dates = weights.index.intersection(open_.index)
weights = weights.loc[common_dates]
fwd_returns = returns.shift(-1).reindex(common_dates)
fwd_returns = fwd_returns_all.reindex(common_dates)
if len(common_dates) < 1:
return {
@@ -189,8 +203,9 @@ def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict:
"n_dates": 0,
}
# 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.
# Daily portfolio return = sum(w_t * r_open[t+1→t+2]) / sum(|w_t|).
# The final two signal dates have no complete next-open holding interval
# and are dropped below.
gross = weights.abs().sum(axis=1)
daily_returns = (
(weights * fwd_returns).sum(axis=1, min_count=1)