Use next-open returns for research eval
This commit is contained in:
+31
-16
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user