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
+95 -2
View File
@@ -6,7 +6,11 @@ import pandas as pd
import pytest
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 (
available_alphas,
get_alpha,
@@ -172,10 +176,25 @@ def test_combine_alphas_schema(tmp_path):
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 -----------------------------------------------------
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():
@@ -255,3 +274,77 @@ def test_load_external_alpha_module(tmp_path):
assert list(result.columns) == ALPHA_COLUMNS
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"}