Files
chinese-equity-quant/pipeline/alpha/cli.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

175 lines
6.1 KiB
Python

"""CLI for alpha computation and evaluation."""
import json
import os
import click
import pandas as pd
from pipeline.alpha.compute import compute_alpha, evaluate_alpha
from pipeline.alpha.registry import available_alphas, load_alpha_module
@click.group(name="alpha")
def alpha():
"""Compute and evaluate alpha weights."""
def _coerce(value: str):
"""Best-effort coercion of a CLI string to int, then float, else str."""
for cast in (int, float):
try:
return cast(value)
except ValueError:
continue
return value
def _parse_params(pairs: tuple[str, ...]) -> dict:
"""Parse repeated ``name=value`` options into a params dict."""
params: dict = {}
for pair in pairs:
if "=" not in pair:
raise click.BadParameter(f"--param must be name=value, got '{pair}'")
key, value = pair.split("=", 1)
params[key.strip()] = _coerce(value.strip())
return params
@alpha.command("list")
@click.option(
"--alpha-module", "alpha_modules", multiple=True,
help="External module(s) to import first (dotted path or .py file)",
)
def list_(alpha_modules):
"""List the registered alpha types."""
for spec in alpha_modules:
load_alpha_module(spec)
for name in available_alphas():
click.echo(name)
@alpha.command("compute")
@click.option("--data-path", required=True, help="Path to data parquet file")
@click.option("--alpha-name", required=True, help="Name for this alpha")
@click.option("--alpha-type", required=True, help="Registry key of the alpha class")
@click.option("--output-dir", default="alphas", help="Directory to save alpha parquet")
@click.option("--lookback", default=5, type=int, help="Lookback days")
@click.option("--vol-window", default=20, type=int, help="Volatility window (reversal_vol only)")
@click.option(
"--alpha-module", "alpha_modules", multiple=True,
help="External module(s) to import so their alphas register (dotted path or .py file)",
)
@click.option(
"--param", "extra_params", multiple=True,
help="Extra alpha constructor param as name=value (repeatable)",
)
def compute(data_path, alpha_name, alpha_type, output_dir, lookback, vol_window,
alpha_modules, extra_params):
"""Compute one alpha from raw data and save as parquet."""
for spec in alpha_modules:
load_alpha_module(spec)
options = available_alphas()
if alpha_type not in options:
raise click.BadParameter(
f"Unknown alpha-type '{alpha_type}'. Available: {options}. "
f"Use --alpha-module to register an external alpha.",
param_hint="--alpha-type",
)
params = {"lookback": lookback, "vol_window": vol_window}
params.update(_parse_params(extra_params))
data = pd.read_parquet(data_path)
click.echo(f"Loaded data: {len(data):,} rows from {data_path}")
result = compute_alpha(
data=data,
alpha_name=alpha_name,
alpha_type=alpha_type,
**params,
)
os.makedirs(output_dir, exist_ok=True)
out_path = f"{output_dir}/{alpha_name}.pq"
result.to_parquet(out_path, index=False)
click.echo(f"Saved alpha: {out_path} ({len(result):,} rows)")
click.echo(
f"Weight stats — min: {result['weight'].min():.4f}, "
f"max: {result['weight'].max():.4f}, "
f"mean: {result['weight'].mean():.4f}"
)
@alpha.command("reversal")
@click.option("--data-path", required=True, help="Path to data parquet file")
@click.option("--output-dir", default="alphas", help="Directory to save alpha parquet")
@click.option("--lookback", default=5, type=int, help="Lookback days")
def reversal(data_path, output_dir, lookback):
"""Shortcut: compute a reversal alpha."""
alpha_name = f"reversal_{lookback}d"
ctx = click.get_current_context()
ctx.invoke(
compute,
data_path=data_path,
alpha_name=alpha_name,
alpha_type="reversal",
output_dir=output_dir,
lookback=lookback,
)
@alpha.command("reversal-vol")
@click.option("--data-path", required=True, help="Path to data parquet file")
@click.option("--output-dir", default="alphas", help="Directory to save alpha parquet")
@click.option("--lookback", default=5, type=int, help="Lookback days")
@click.option("--vol-window", default=20, type=int, help="Volatility window")
def reversal_vol(data_path, output_dir, lookback, vol_window):
"""Shortcut: compute a volatility-scaled reversal alpha."""
alpha_name = f"reversal_vol_{lookback}d_{vol_window}d"
ctx = click.get_current_context()
ctx.invoke(
compute,
data_path=data_path,
alpha_name=alpha_name,
alpha_type="reversal_vol",
output_dir=output_dir,
lookback=lookback,
vol_window=vol_window,
)
@alpha.command("eval")
@click.option("--alpha-path", required=True, help="Path to alpha parquet file")
@click.option("--data-path", required=True, help="Path to data parquet (for price data)")
def eval_(alpha_path, data_path):
"""Evaluate an alpha's performance (return, Sharpe, turnover).
Alphas are interpreted as position WEIGHTS, not return predictors.
No IC/IR metrics — these are not predictors of future returns.
"""
alpha_df = pd.read_parquet(alpha_path)
data_df = pd.read_parquet(data_path)
metrics = evaluate_alpha(alpha_df, data_df)
click.echo("\n" + "=" * 50)
click.echo("ALPHA EVALUATION")
click.echo("=" * 50)
click.echo(f"Cumulative Return: {metrics['cumulative_return']:>10.4%}")
click.echo(f"Annual Sharpe: {metrics['sharpe_annual']:>10.4f}")
click.echo(f"Annual Turnover: {metrics['turnover_annual']:>10.2%}")
click.echo(f"Max Drawdown: {metrics['max_drawdown']:>10.4%}")
click.echo(f"Hit Rate: {metrics['hit_rate']:>10.2%}")
click.echo(f"Trading Days: {metrics['n_dates']:>10d}")
click.echo("=" * 50)
# Also dump JSON
os.makedirs("reports", exist_ok=True)
alpha_name = alpha_df["alpha_name"].iloc[0]
json_path = f"reports/{alpha_name}_eval.json"
with open(json_path, "w") as f:
json.dump(metrics, f, indent=2)
click.echo(f"\nReport saved: {json_path}")