Files
chinese-equity-quant/pipeline/alpha/cli.py
T
2026-06-16 13:57:17 +08:00

192 lines
6.8 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(
"--feature-path", "feature_paths", multiple=True,
help="Daily feature parquet file/dataset to left-join on symbol_id,date (repeatable)",
)
@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)",
)
@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,
feature_paths, 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)
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))
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}")
result = compute_alpha(
data=data,
alpha_name=alpha_name,
alpha_type=alpha_type,
universe=universe,
feature_paths=feature_paths,
**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}")