Files
chinese-equity-quant/pipeline/alpha/compute.py
T
Yuxuan Yan 0a6f367fbf 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>
2026-06-11 17:39:55 +08:00

174 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Alpha computation and evaluation.
Alphas are position WEIGHTS — positive=long, negative=short. They are NOT
predictors of future returns. Concrete alphas are classes that live in
``pipeline/alpha/library/`` (or any external module) and are resolved by name
through :mod:`pipeline.alpha.registry`.
"""
import logging
import numpy as np
import pandas as pd
from pipeline.alpha.registry import get_alpha
from pipeline.common.schema import ALPHA_COLUMNS
logger = logging.getLogger(__name__)
def _pivot_close(df: pd.DataFrame) -> pd.DataFrame:
"""Pivot data to wide format: date index, columns = symbol_id, values = close."""
pivot = df.pivot_table(
index="date", columns="symbol_id", values="close", aggfunc="first"
)
return pivot.sort_index()
def _daily_returns(close: pd.DataFrame) -> pd.DataFrame:
"""Compute daily returns from wide close DataFrame."""
return close.pct_change()
def compute_alpha(
data: pd.DataFrame,
alpha_name: str,
alpha_type: str,
**params,
) -> pd.DataFrame:
"""Compute alpha weights from raw data.
Args:
data: DataFrame with DATA_COLUMNS.
alpha_name: Label stored in the ``alpha_name`` output column.
alpha_type: Registry key of the alpha class (e.g. ``reversal``).
**params: Constructor parameters for the alpha (e.g. ``lookback``,
``vol_window``). Only the params the alpha's ``__init__`` accepts are
used; extras are ignored.
Returns:
DataFrame with ALPHA_COLUMNS.
Raises:
KeyError: If ``alpha_type`` is not registered.
"""
alpha = get_alpha(alpha_type, **params)
close = _pivot_close(data)
weights = alpha.weights(close)
# Melt to long format
weights_melted = weights.reset_index().melt(
id_vars="date", var_name="symbol_id", value_name="weight"
)
weights_melted["alpha_name"] = alpha_name
weights_melted = weights_melted[ALPHA_COLUMNS]
weights_melted = weights_melted.dropna(subset=["weight"])
weights_melted = weights_melted.sort_values(["symbol_id", "date"]).reset_index(drop=True)
logger.info(
"Alpha '%s' (%r): %d symbols × %d dates, weight range [%.4f, %.4f]",
alpha_name,
alpha,
weights_melted["symbol_id"].nunique(),
weights_melted["date"].nunique(),
weights_melted["weight"].min(),
weights_melted["weight"].max(),
)
return weights_melted
def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict:
"""Evaluate an alpha's performance as position weights.
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.
Args:
alpha_df: DataFrame with ALPHA_COLUMNS.
data_df: DataFrame with DATA_COLUMNS (for price data).
Returns:
Dict with metrics: cumulative_return, sharpe_annual, turnover_annual,
max_drawdown, hit_rate, n_dates.
"""
close = _pivot_close(data_df)
returns = _daily_returns(close)
# Pivot alpha weights to wide format
weights = alpha_df.pivot_table(
index="date", columns="symbol_id", values="weight", aggfunc="first"
).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)
weights = weights.loc[common_dates]
fwd_returns = returns.shift(-1).reindex(common_dates)
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": 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.
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)
# Annualized Sharpe (sqrt(252) * mean / std)
mu = daily_returns.mean()
sigma = daily_returns.std()
sharpe_annual = float(np.sqrt(252) * mu / sigma) if sigma > 0 else 0.0
# 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 = gross.shift(1)
daily_turnover = weight_change / gross_exposure
turnover_annual = float(daily_turnover.mean() * 252)
# Max drawdown
equity = (1.0 + daily_returns).cumprod()
peak = equity.cummax()
drawdown = (equity - peak) / peak
max_drawdown = float(drawdown.min())
# Hit rate
hit_rate = float((daily_returns > 0).mean())
return {
"cumulative_return": cumulative_return,
"sharpe_annual": sharpe_annual,
"turnover_annual": turnover_annual,
"max_drawdown": max_drawdown,
"hit_rate": hit_rate,
"n_dates": int(len(daily_returns)),
}