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>
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
"""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}")
|
||||
Reference in New Issue
Block a user