Add outlier-robust reversal_rank alpha and investable-universe filter

reversal_rank weights the 5-day reversal signal by bounded cross-sectional
rank instead of z-score, so a few extreme A-share pct_change outliers
(newly listed / post-suspension / limit-up names) can no longer dominate
the book. compute_alpha gains an optional per-date investable-universe
mask (tradable, non-ST, seasoned, top-liquidity) applied to the signal
before weighting, exposed via --liquid-universe/--universe-top-n.

combo combine now accepts a single alpha as an identity passthrough so a
one-alpha pipeline run needs no synthetic second input.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Yuxuan Yan
2026-06-11 17:40:28 +08:00
parent 0a6f367fbf
commit 07ed6ad917
6 changed files with 219 additions and 8 deletions
+13 -1
View File
@@ -64,8 +64,17 @@ def list_(alpha_modules):
"--param", "extra_params", multiple=True,
help="Extra alpha constructor param as name=value (repeatable)",
)
@click.option(
"--liquid-universe", is_flag=True, default=False,
help="Mask weights to a per-date investable universe (tradable, non-ST, "
"seasoned, top liquidity) before normalization",
)
@click.option(
"--universe-top-n", default=1000, type=int,
help="Most-liquid names kept per date when --liquid-universe is set",
)
def compute(data_path, alpha_name, alpha_type, output_dir, lookback, vol_window,
alpha_modules, extra_params):
alpha_modules, extra_params, liquid_universe, universe_top_n):
"""Compute one alpha from raw data and save as parquet."""
for spec in alpha_modules:
load_alpha_module(spec)
@@ -81,6 +90,8 @@ def compute(data_path, alpha_name, alpha_type, output_dir, lookback, vol_window,
params = {"lookback": lookback, "vol_window": vol_window}
params.update(_parse_params(extra_params))
universe = {"top_n": universe_top_n} if liquid_universe else None
data = pd.read_parquet(data_path)
click.echo(f"Loaded data: {len(data):,} rows from {data_path}")
@@ -88,6 +99,7 @@ def compute(data_path, alpha_name, alpha_type, output_dir, lookback, vol_window,
data=data,
alpha_name=alpha_name,
alpha_type=alpha_type,
universe=universe,
**params,
)
+70 -2
View File
@@ -27,13 +27,71 @@ def _pivot_close(df: pd.DataFrame) -> pd.DataFrame:
def _daily_returns(close: pd.DataFrame) -> pd.DataFrame:
"""Compute daily returns from wide close DataFrame."""
return close.pct_change()
return close.pct_change(fill_method=None)
def investable_universe_mask(
data: pd.DataFrame,
template: pd.DataFrame,
*,
top_n: int = 1000,
min_history: int = 60,
require_tradable: bool = True,
exclude_st: bool = True,
) -> pd.DataFrame:
"""Build a per-date investable-universe mask aligned to ``template``.
A ``(date, symbol_id)`` cell is ``True`` when the name is, on that date,
seasoned (at least ``min_history`` prior closes), currently tradable
(``tradestatus == 1``), not flagged ST (``isST == 0``), and inside the
``top_n`` most liquid names by trailing 20-day mean ``amount``. The mask is
applied to the *signal* (computed on full contiguous prices), so it
restricts only what is *held*, never the price history used to form the
signal — that keeps ``pct_change`` correct and look-ahead free.
Args:
data: Long DataFrame with at least ``symbol_id``, ``date``, ``close``,
``amount``, ``isST``, ``tradestatus``.
template: Wide signal (date index × ``symbol_id`` columns) to align to.
top_n: Keep this many most-liquid names per date.
min_history: Minimum number of observed closes before a name is eligible.
require_tradable: Require ``tradestatus == 1`` on the date.
exclude_st: Drop names flagged ``isST == 1``.
Returns:
Boolean wide DataFrame aligned to ``template``.
"""
def _wide(col: str) -> pd.DataFrame:
return (
data.pivot_table(index="date", columns="symbol_id", values=col, aggfunc="first")
.sort_index()
.reindex(index=template.index, columns=template.columns)
)
close = _wide("close")
mask = close.notna()
seasoned = close.notna().cumsum() >= min_history
mask &= seasoned
if exclude_st and "isST" in data.columns:
mask &= _wide("isST").fillna(1) == 0
if require_tradable and "tradestatus" in data.columns:
mask &= _wide("tradestatus").fillna(0) == 1
amount = _wide("amount")
amt_ma = amount.rolling(20, min_periods=10).mean()
liquid_rank = amt_ma.rank(axis=1, ascending=False)
mask &= liquid_rank <= top_n
return mask.fillna(False)
def compute_alpha(
data: pd.DataFrame,
alpha_name: str,
alpha_type: str,
universe: dict | None = None,
**params,
) -> pd.DataFrame:
"""Compute alpha weights from raw data.
@@ -42,6 +100,11 @@ def compute_alpha(
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``).
universe: Optional investable-universe filter. When given, the alpha's
raw signal is masked to the investable set (see
:func:`investable_universe_mask`) *before* it is turned into
weights, so unheld names get weight 0. Keys are forwarded as keyword
arguments to :func:`investable_universe_mask`.
**params: Constructor parameters for the alpha (e.g. ``lookback``,
``vol_window``). Only the params the alpha's ``__init__`` accepts are
used; extras are ignored.
@@ -54,7 +117,12 @@ def compute_alpha(
"""
alpha = get_alpha(alpha_type, **params)
close = _pivot_close(data)
weights = alpha.weights(close)
if universe is None:
weights = alpha.weights(close)
else:
signal = alpha.signal(close)
mask = investable_universe_mask(data, signal, **universe)
weights = alpha.to_weights(signal.where(mask))
# Melt to long format
weights_melted = weights.reset_index().melt(
+6 -1
View File
@@ -4,4 +4,9 @@ Importing this package imports each alpha module, which registers the alpha via
the ``@register_alpha`` decorator. Add a new built-in by dropping a module here
and importing it below.
"""
from pipeline.alpha.library import momentum, reversal, reversal_vol # noqa: F401
from pipeline.alpha.library import ( # noqa: F401
momentum,
reversal,
reversal_rank,
reversal_vol,
)
+33
View File
@@ -0,0 +1,33 @@
"""Outlier-robust short-horizon reversal alpha."""
import pandas as pd
from pipeline.alpha.base import BaseAlpha
from pipeline.alpha.registry import register_alpha
@register_alpha
class ReversalRankAlpha(BaseAlpha):
"""Reversal weighted by cross-sectional rank instead of z-score.
The signal is the same trailing-return reversal as :class:`ReversalAlpha`,
but :meth:`to_weights` converts it with a cross-sectional rank that is then
demeaned. Rank weighting is bounded and monotone, so it does not dump the
book into a handful of extreme movers the way raw z-scoring does — the
failure mode that makes plain ``reversal`` collapse on the A-share universe,
where newly listed / post-suspension / limit-up names produce huge
``pct_change`` outliers.
"""
name = "reversal_rank"
def __init__(self, lookback: int = 5):
self.lookback = lookback
def signal(self, close: pd.DataFrame) -> pd.DataFrame:
return -close.pct_change(self.lookback, fill_method=None)
def to_weights(self, signal: pd.DataFrame) -> pd.DataFrame:
signal = signal.dropna(how="all")
ranks = signal.rank(axis=1)
weights = ranks.subtract(ranks.mean(axis=1), axis=0)
return weights.fillna(0.0)
+2 -2
View File
@@ -26,8 +26,8 @@ def combo():
def combine(alpha_paths, combo_name, method, output_dir):
"""Combine multiple alphas and save as parquet."""
paths = [p.strip() for p in alpha_paths.split(",") if p.strip()]
if len(paths) < 2:
click.echo("Error: --alpha-paths requires at least 2 comma-separated paths", err=True)
if len(paths) < 1:
click.echo("Error: --alpha-paths requires at least 1 path", err=True)
return
result = combine_alphas(