"""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)") @click.option("--report-dir", default="reports", help="Directory to save JSON report") def eval_(alpha_path, data_path, report_dir): """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(report_dir, exist_ok=True) alpha_name = alpha_df["alpha_name"].iloc[0] json_path = os.path.join(report_dir, f"{alpha_name}_eval.json") with open(json_path, "w") as f: json.dump(metrics, f, indent=2) click.echo(f"\nReport saved: {json_path}")