Files
chinese-equity-quant/tests/test_alpha.py
T
Yuxuan Yan 1caa63faeb refactor: class-based alpha factory + month-partitioned data pipeline
Replace the old signal/strategy/backtest modules with a decoupled
data → alpha → combo pipeline (parquet between phases, .pq extension).

Alphas:
- BaseAlpha + @register_alpha factory/plugin registry; one file per
  built-in (reversal, reversal_vol, momentum); external alphas via
  --alpha-module. Alphas are z-scored position weights, not predictors.

Data:
- baostock primary / akshare fallback, treated consistently.
- New --universe all (~5000 A-shares via query_all_stock, filtered).
- login-once batch downloader; empty-string OHLCV coerced to NaN.
- Month-partitioned dataset {output_dir}/{universe}/month=YYYY-MM/*.pq
  with chunked durability flushes; --data-path is the dataset dir.

CLI logs at INFO by default (--log-level) so progress is visible.
Docs (README, CLAUDE.md) updated incl. pipeline diagram and roadmap
TODOs for portfolio construction / backtest / paper trading.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 14:07:07 +08:00

181 lines
6.2 KiB
Python

"""Tests for pipeline alpha computation and combination (no network)."""
import textwrap
import numpy as np
import pandas as pd
import pytest
from pipeline.alpha.base import BaseAlpha
from pipeline.alpha.compute import compute_alpha, evaluate_alpha
from pipeline.alpha.registry import (
available_alphas,
get_alpha,
load_alpha_module,
register_alpha,
)
from pipeline.combo.combine import combine_alphas, _equal_weight
from pipeline.common.schema import ALPHA_COLUMNS, COMBO_COLUMNS
def _make_data(n_days: int = 30, symbols=("sh600000", "sz000001", "sh600519")) -> pd.DataFrame:
"""Build a synthetic long-format DATA_COLUMNS frame with deterministic prices."""
dates = pd.date_range("2024-01-01", periods=n_days)
rng = np.random.default_rng(0)
frames = []
for i, sym in enumerate(symbols):
# Distinct drift per symbol so the cross-section is non-degenerate.
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 * close,
}))
return pd.concat(frames, ignore_index=True)
def test_compute_alpha_schema_and_naming():
alpha = compute_alpha(_make_data(), "rev5", "reversal", lookback=5)
assert list(alpha.columns) == ALPHA_COLUMNS
assert (alpha["alpha_name"] == "rev5").all()
def test_reversal_sign_matches_negative_trailing_return():
# Cross-sectional z-score preserves the sign relative to the cross-section,
# so the stock with the most negative trailing return ranks highest.
data = _make_data()
alpha = compute_alpha(data, "rev5", "reversal", lookback=5)
close = data.pivot_table(index="date", columns="symbol_id", values="close").sort_index()
raw = -close.pct_change(5)
last = raw.index[-1]
expected_top = raw.loc[last].idxmax()
got = alpha[alpha["date"] == last].set_index("symbol_id")["weight"].idxmax()
assert got == expected_top
def test_weights_are_cross_sectional_zscore():
# Each date's weights are a z-score, so the per-date mean is ~0.
alpha = compute_alpha(_make_data(), "rev5", "reversal", lookback=5)
per_date_mean = alpha.groupby("date")["weight"].mean().abs()
assert (per_date_mean < 1e-9).all()
def test_evaluate_alpha_keys():
data = _make_data()
alpha = compute_alpha(data, "rev5", "reversal", lookback=5)
metrics = evaluate_alpha(alpha, data)
for key in ("cumulative_return", "sharpe_annual", "turnover_annual",
"max_drawdown", "hit_rate", "n_dates"):
assert key in metrics
def test_equal_weight_is_mean_of_alphas():
data = _make_data()
a = compute_alpha(data, "rev", "reversal", lookback=5)
b = compute_alpha(data, "mom", "momentum", lookback=5)
combo = _equal_weight([a, b])
# reversal = -momentum before z-scoring, but after independent per-date
# z-scoring they are exact negatives, so the equal-weight mean is ~0.
assert combo["weight"].abs().max() < 1e-9
def test_combine_alphas_schema(tmp_path):
data = _make_data()
a_path = tmp_path / "a.pq"
b_path = tmp_path / "b.pq"
compute_alpha(data, "rev", "reversal", lookback=5).to_parquet(a_path, index=False)
compute_alpha(data, "revvol", "reversal_vol", lookback=5, vol_window=10).to_parquet(b_path, index=False)
combo = combine_alphas([str(a_path), str(b_path)], "eq", method="equal_weight")
assert list(combo.columns) == COMBO_COLUMNS
assert (combo["combo_name"] == "eq").all()
# --- registry / factory -----------------------------------------------------
def test_builtins_are_registered():
assert {"reversal", "reversal_vol", "momentum"} <= set(available_alphas())
def test_get_alpha_filters_unaccepted_params():
# reversal only accepts lookback; passing vol_window too must not error.
alpha = get_alpha("reversal", lookback=7, vol_window=99)
assert alpha.name == "reversal"
assert alpha.lookback == 7
assert not hasattr(alpha, "vol_window")
def test_get_alpha_unknown_raises():
with pytest.raises(KeyError):
get_alpha("does_not_exist")
def test_register_duplicate_name_raises():
available_alphas() # ensure built-ins loaded
with pytest.raises(ValueError):
@register_alpha
class Dup(BaseAlpha):
name = "reversal"
def signal(self, close):
return close
def test_register_rejects_non_basealpha():
with pytest.raises(TypeError):
register_alpha(object) # type: ignore[arg-type]
# --- base class --------------------------------------------------------------
def test_to_weights_are_per_date_zscore():
class _Const(BaseAlpha):
name = "_const_test"
def signal(self, close):
return close # arbitrary finite signal
close = _make_data().pivot_table(index="date", columns="symbol_id", values="close")
weights = _Const().weights(close.sort_index())
# Each date demeaned to ~0.
assert (weights.mean(axis=1).abs() < 1e-9).all()
# --- external plugin loading -------------------------------------------------
def test_load_external_alpha_module(tmp_path):
module_path = tmp_path / "my_external_alpha.py"
module_path.write_text(textwrap.dedent('''
import pandas as pd
from pipeline.alpha.base import BaseAlpha
from pipeline.alpha.registry import register_alpha
@register_alpha
class ExternalDemoAlpha(BaseAlpha):
name = "external_demo"
def __init__(self, span: int = 3):
self.span = span
def signal(self, close: pd.DataFrame) -> pd.DataFrame:
return -close.pct_change(self.span)
'''))
load_alpha_module(str(module_path))
assert "external_demo" in available_alphas()
# The factory forwards the external alpha's own param (`span`).
instance = get_alpha("external_demo", span=4, lookback=99)
assert instance.span == 4
# And it works end-to-end through compute_alpha.
result = compute_alpha(_make_data(), "ext", "external_demo", span=4)
assert list(result.columns) == ALPHA_COLUMNS
assert (result["alpha_name"] == "ext").all()