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
+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)