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)
+3 -3
View File
@@ -7,9 +7,9 @@ across dates (positions are stateful, unlike alphas/combos), discretizing and
repairing each day's target into a tradable integer book.
Return-convention note: weights here are *target allocations*. The research
evaluation in :mod:`pipeline.portfolio.research` marks them close-to-close on the
*next* period (no look-ahead); the execution simulator marks the actually-filled
book at the next open. See those modules for details.
evaluation in :mod:`pipeline.portfolio.research` marks them from next open to
the following open (no look-ahead); the execution simulator marks the
actually-filled book at the next open. See those modules for details.
"""
from __future__ import annotations
+13 -12
View File
@@ -6,9 +6,10 @@ trading constraints. Metrics are return / Sharpe / turnover / max-drawdown /
convention that an alpha is a position weight, not a return predictor.
Return convention (documented): the target weight formed from information at
date ``t`` earns the *next* period's close-to-close return, i.e. weights are
shifted one day relative to realized returns, so there is no look-ahead:
``R_t = sum_i w_{i,t} · r_{i,t+1}`` normalized by gross exposure.
date ``t`` is assumed tradable at ``open[t+1]`` and held until ``open[t+2]``.
This is a costless approximation of the next-open execution path: no lots,
constraints, or costs, but no credit for an overnight gap that the new signal
could not have owned.
"""
from __future__ import annotations
@@ -26,27 +27,27 @@ def evaluate_portfolio(positions_df: pd.DataFrame, data_df: pd.DataFrame) -> dic
Args:
positions_df: POSITION_COLUMNS (uses ``target_weight``; zero-gross
construction carry dates remain flat in this research view).
data_df: DATA_COLUMNS (uses ``close`` for returns).
data_df: DATA_COLUMNS (uses ``open`` for returns).
Returns:
Dict with ``cumulative_return, sharpe_annual, turnover_annual,
max_drawdown, fitness, hit_rate, n_dates``. No IC key.
"""
close = data_df.pivot_table(
index="date", columns="symbol_id", values="close", aggfunc="first"
open_ = data_df.pivot_table(
index="date", columns="symbol_id", values="open", aggfunc="first"
).sort_index()
returns = close.pct_change(fill_method=None)
fwd = open_.shift(-2).divide(open_.shift(-1)) - 1.0
weights = positions_df.pivot_table(
index="date", columns="symbol_id", values="target_weight", aggfunc="first"
).sort_index()
common = weights.index.intersection(returns.index)
common = weights.index.intersection(open_.index)
weights = weights.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)
# signal dates. This preserves the next available open-to-open holding
# interval when the signal grid is sparser than the data grid.
fwd = fwd.reindex(common)
empty = {
"cumulative_return": 0.0, "sharpe_annual": 0.0, "turnover_annual": 0.0,
@@ -58,7 +59,7 @@ def evaluate_portfolio(positions_df: pd.DataFrame, data_df: pd.DataFrame) -> dic
return empty
gross = weights.abs().sum(axis=1)
# Weights at t earn the return from t to t+1.
# Weights at t earn the costless tradable interval open[t+1] -> open[t+2].
daily = (
(weights * fwd).sum(axis=1, min_count=1)
/ gross.replace(0.0, np.nan)