1caa63faeb
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>
175 lines
6.1 KiB
Python
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}")
|