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:
+13
-1
@@ -64,8 +64,17 @@ def list_(alpha_modules):
|
|||||||
"--param", "extra_params", multiple=True,
|
"--param", "extra_params", multiple=True,
|
||||||
help="Extra alpha constructor param as name=value (repeatable)",
|
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,
|
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."""
|
"""Compute one alpha from raw data and save as parquet."""
|
||||||
for spec in alpha_modules:
|
for spec in alpha_modules:
|
||||||
load_alpha_module(spec)
|
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 = {"lookback": lookback, "vol_window": vol_window}
|
||||||
params.update(_parse_params(extra_params))
|
params.update(_parse_params(extra_params))
|
||||||
|
|
||||||
|
universe = {"top_n": universe_top_n} if liquid_universe else None
|
||||||
|
|
||||||
data = pd.read_parquet(data_path)
|
data = pd.read_parquet(data_path)
|
||||||
click.echo(f"Loaded data: {len(data):,} rows from {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,
|
data=data,
|
||||||
alpha_name=alpha_name,
|
alpha_name=alpha_name,
|
||||||
alpha_type=alpha_type,
|
alpha_type=alpha_type,
|
||||||
|
universe=universe,
|
||||||
**params,
|
**params,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -27,13 +27,71 @@ def _pivot_close(df: pd.DataFrame) -> pd.DataFrame:
|
|||||||
|
|
||||||
def _daily_returns(close: pd.DataFrame) -> pd.DataFrame:
|
def _daily_returns(close: pd.DataFrame) -> pd.DataFrame:
|
||||||
"""Compute daily returns from wide close 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(
|
def compute_alpha(
|
||||||
data: pd.DataFrame,
|
data: pd.DataFrame,
|
||||||
alpha_name: str,
|
alpha_name: str,
|
||||||
alpha_type: str,
|
alpha_type: str,
|
||||||
|
universe: dict | None = None,
|
||||||
**params,
|
**params,
|
||||||
) -> pd.DataFrame:
|
) -> pd.DataFrame:
|
||||||
"""Compute alpha weights from raw data.
|
"""Compute alpha weights from raw data.
|
||||||
@@ -42,6 +100,11 @@ def compute_alpha(
|
|||||||
data: DataFrame with DATA_COLUMNS.
|
data: DataFrame with DATA_COLUMNS.
|
||||||
alpha_name: Label stored in the ``alpha_name`` output column.
|
alpha_name: Label stored in the ``alpha_name`` output column.
|
||||||
alpha_type: Registry key of the alpha class (e.g. ``reversal``).
|
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``,
|
**params: Constructor parameters for the alpha (e.g. ``lookback``,
|
||||||
``vol_window``). Only the params the alpha's ``__init__`` accepts are
|
``vol_window``). Only the params the alpha's ``__init__`` accepts are
|
||||||
used; extras are ignored.
|
used; extras are ignored.
|
||||||
@@ -54,7 +117,12 @@ def compute_alpha(
|
|||||||
"""
|
"""
|
||||||
alpha = get_alpha(alpha_type, **params)
|
alpha = get_alpha(alpha_type, **params)
|
||||||
close = _pivot_close(data)
|
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
|
# Melt to long format
|
||||||
weights_melted = weights.reset_index().melt(
|
weights_melted = weights.reset_index().melt(
|
||||||
|
|||||||
@@ -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
|
the ``@register_alpha`` decorator. Add a new built-in by dropping a module here
|
||||||
and importing it below.
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -26,8 +26,8 @@ def combo():
|
|||||||
def combine(alpha_paths, combo_name, method, output_dir):
|
def combine(alpha_paths, combo_name, method, output_dir):
|
||||||
"""Combine multiple alphas and save as parquet."""
|
"""Combine multiple alphas and save as parquet."""
|
||||||
paths = [p.strip() for p in alpha_paths.split(",") if p.strip()]
|
paths = [p.strip() for p in alpha_paths.split(",") if p.strip()]
|
||||||
if len(paths) < 2:
|
if len(paths) < 1:
|
||||||
click.echo("Error: --alpha-paths requires at least 2 comma-separated paths", err=True)
|
click.echo("Error: --alpha-paths requires at least 1 path", err=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
result = combine_alphas(
|
result = combine_alphas(
|
||||||
|
|||||||
+95
-2
@@ -6,7 +6,11 @@ import pandas as pd
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pipeline.alpha.base import BaseAlpha
|
from pipeline.alpha.base import BaseAlpha
|
||||||
from pipeline.alpha.compute import compute_alpha, evaluate_alpha
|
from pipeline.alpha.compute import (
|
||||||
|
compute_alpha,
|
||||||
|
evaluate_alpha,
|
||||||
|
investable_universe_mask,
|
||||||
|
)
|
||||||
from pipeline.alpha.registry import (
|
from pipeline.alpha.registry import (
|
||||||
available_alphas,
|
available_alphas,
|
||||||
get_alpha,
|
get_alpha,
|
||||||
@@ -172,10 +176,25 @@ def test_combine_alphas_schema(tmp_path):
|
|||||||
assert (combo["combo_name"] == "eq").all()
|
assert (combo["combo_name"] == "eq").all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_combine_single_alpha_is_identity(tmp_path):
|
||||||
|
data = _make_data()
|
||||||
|
a = compute_alpha(data, "rev", "reversal", lookback=5)
|
||||||
|
a_path = tmp_path / "a.pq"
|
||||||
|
a.to_parquet(a_path, index=False)
|
||||||
|
|
||||||
|
combo = combine_alphas([str(a_path)], "rev_combo", method="equal_weight")
|
||||||
|
|
||||||
|
expected = a[["symbol_id", "date", "weight"]].reset_index(drop=True)
|
||||||
|
got = combo[["symbol_id", "date", "weight"]].reset_index(drop=True)
|
||||||
|
pd.testing.assert_frame_equal(got, expected)
|
||||||
|
assert list(combo.columns) == COMBO_COLUMNS
|
||||||
|
assert (combo["combo_name"] == "rev_combo").all()
|
||||||
|
|
||||||
|
|
||||||
# --- registry / factory -----------------------------------------------------
|
# --- registry / factory -----------------------------------------------------
|
||||||
|
|
||||||
def test_builtins_are_registered():
|
def test_builtins_are_registered():
|
||||||
assert {"reversal", "reversal_vol", "momentum"} <= set(available_alphas())
|
assert {"reversal", "reversal_vol", "momentum", "reversal_rank"} <= set(available_alphas())
|
||||||
|
|
||||||
|
|
||||||
def test_get_alpha_filters_unaccepted_params():
|
def test_get_alpha_filters_unaccepted_params():
|
||||||
@@ -255,3 +274,77 @@ def test_load_external_alpha_module(tmp_path):
|
|||||||
assert list(result.columns) == ALPHA_COLUMNS
|
assert list(result.columns) == ALPHA_COLUMNS
|
||||||
assert (result["alpha_name"] == "ext").all()
|
assert (result["alpha_name"] == "ext").all()
|
||||||
|
|
||||||
|
|
||||||
|
# --- rank reversal + investable universe filter ------------------------------
|
||||||
|
|
||||||
|
def _make_rich_data(n_days: int = 70, symbols=("sh600000", "sz000001", "sh600519", "sz300750")):
|
||||||
|
"""Long-format data with the columns the universe filter needs."""
|
||||||
|
dates = pd.date_range("2024-01-01", periods=n_days)
|
||||||
|
rng = np.random.default_rng(1)
|
||||||
|
frames = []
|
||||||
|
for i, sym in enumerate(symbols):
|
||||||
|
close = 100.0 + i * 5 + np.cumsum(rng.standard_normal(n_days))
|
||||||
|
frames.append(pd.DataFrame({
|
||||||
|
"symbol_id": sym,
|
||||||
|
"symbol_name": sym,
|
||||||
|
"date": dates,
|
||||||
|
"open": close, "high": close, "low": close, "close": close,
|
||||||
|
"volume": 1_000.0,
|
||||||
|
"amount": (1_000.0 + i * 5_000.0) * close, # higher i = more liquid
|
||||||
|
"isST": 0,
|
||||||
|
"tradestatus": 1,
|
||||||
|
}))
|
||||||
|
return pd.concat(frames, ignore_index=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reversal_rank_registered_and_bounded():
|
||||||
|
data = _make_data(n_days=30)
|
||||||
|
alpha = compute_alpha(data, "rr", "reversal_rank", lookback=5)
|
||||||
|
assert list(alpha.columns) == ALPHA_COLUMNS
|
||||||
|
# Rank-demeaned weights are per-date zero-mean and bounded by the
|
||||||
|
# cross-section size, never blowing up the way a z-score outlier can.
|
||||||
|
per_date_mean = alpha.groupby("date")["weight"].mean().abs()
|
||||||
|
assert (per_date_mean < 1e-9).all()
|
||||||
|
assert alpha["weight"].abs().max() <= len(data["symbol_id"].unique())
|
||||||
|
|
||||||
|
|
||||||
|
def test_investable_universe_mask_excludes_st_and_suspended():
|
||||||
|
data = _make_rich_data()
|
||||||
|
# Flag one name ST throughout, and suspend another on the last date.
|
||||||
|
data.loc[data["symbol_id"] == "sh600000", "isST"] = 1
|
||||||
|
last = data["date"].max()
|
||||||
|
data.loc[(data["symbol_id"] == "sz000001") & (data["date"] == last), "tradestatus"] = 0
|
||||||
|
|
||||||
|
close = data.pivot_table(index="date", columns="symbol_id", values="close").sort_index()
|
||||||
|
mask = investable_universe_mask(data, close, top_n=10, min_history=5)
|
||||||
|
|
||||||
|
assert not mask["sh600000"].any() # ST excluded on every date
|
||||||
|
assert not bool(mask.loc[last, "sz000001"]) # suspended on the last date
|
||||||
|
assert bool(mask.loc[last, "sh600519"]) # a normal name stays investable
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_alpha_universe_filter_zeros_excluded_names():
|
||||||
|
data = _make_rich_data()
|
||||||
|
data.loc[data["symbol_id"] == "sh600000", "isST"] = 1
|
||||||
|
|
||||||
|
alpha = compute_alpha(
|
||||||
|
data, "rr_liq", "reversal_rank", lookback=5,
|
||||||
|
universe={"top_n": 10, "min_history": 5},
|
||||||
|
)
|
||||||
|
# The ST name is never held; an investable name is.
|
||||||
|
st_w = alpha.loc[alpha["symbol_id"] == "sh600000", "weight"]
|
||||||
|
assert (st_w.fillna(0.0) == 0.0).all()
|
||||||
|
assert alpha.loc[alpha["symbol_id"] == "sz300750", "weight"].abs().sum() > 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_universe_filter_does_not_corrupt_signal_history():
|
||||||
|
# Masking happens on the signal, not the price history, so weights on
|
||||||
|
# investable names match the unfiltered weights restricted to that set.
|
||||||
|
data = _make_rich_data()
|
||||||
|
universe = {"top_n": 2, "min_history": 5} # keep only the 2 most liquid names
|
||||||
|
|
||||||
|
filtered = compute_alpha(data, "f", "reversal_rank", lookback=5, universe=universe)
|
||||||
|
held = set(filtered.loc[filtered["weight"] != 0.0, "symbol_id"].unique())
|
||||||
|
# The two most liquid names (highest amount) are sh600519, sz300750.
|
||||||
|
assert held == {"sh600519", "sz300750"}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user