192 lines
6.8 KiB
Python
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 derived/feature parquet file or 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}")
|