ParallelLLC/algorithmic_trading
2732
1"""Command-line interface.2 3 python -m algotrader.cli lab --symbol SPY --strategy sma_cross4 python -m algotrader.cli arena --symbol BTC-USD --start 2018-01-015 python -m algotrader.cli strategies6"""7 8from __future__ import annotations9 10import argparse11import json12import sys13from typing import Dict, List14 15from . import __version__16from .lab import LabConfig, run_arena, run_lab17from .strategies import REGISTRY, get_strategy18 19 20def _parse_params(pairs: List[str] | None) -> Dict[str, float]:21 params: Dict[str, float] = {}22 for pair in pairs or []:23 if "=" not in pair:24 raise SystemExit(f"--param expects name=value, got '{pair}'")25 name, _, value = pair.partition("=")26 params[name.strip()] = float(value)27 return params28 29 30def _add_common(parser: argparse.ArgumentParser) -> None:31 parser.add_argument("--symbol", default="SPY")32 parser.add_argument("--start", default="2015-01-01")33 parser.add_argument("--end", default=None)34 parser.add_argument("--interval", default="1d")35 parser.add_argument(36 "--source", default="yahoo", choices=["yahoo", "live", "auto", "cache", "synthetic"],37 help="'yahoo' requires a Yahoo download. 'synthetic' is offline tests only. 'auto' falls back to the simulator.",38 )39 parser.add_argument("--commission-bps", type=float, default=1.0)40 parser.add_argument("--slippage-bps", type=float, default=2.0)41 parser.add_argument("--no-short", action="store_true", help="Long/flat only.")42 43 44def _config_from(args: argparse.Namespace, **overrides) -> LabConfig:45 return LabConfig(46 symbol=args.symbol,47 start=args.start,48 end=args.end,49 interval=args.interval,50 source=args.source,51 commission_bps=args.commission_bps,52 slippage_bps=args.slippage_bps,53 allow_short=not args.no_short,54 **overrides,55 )56 57 58def _cmd_lab(args: argparse.Namespace) -> int:59 cfg = _config_from(60 args,61 strategy=args.strategy,62 params=_parse_params(args.param),63 n_permutations=args.permutations,64 permutation_method=args.null,65 wf_folds=args.folds,66 )67 progress = None if args.quiet else (lambda f, m: print(f" [{f:5.0%}] {m}", file=sys.stderr))68 report = run_lab(cfg, progress=progress)69 70 if args.json:71 payload = {72 "symbol": report.market.symbol,73 "source": report.market.source,74 "strategy": report.strategy.key,75 "params": report.params,76 "metrics": report.backtest.metrics,77 "benchmark_metrics": report.backtest.benchmark_metrics,78 "p_value": report.permutation.p_value if report.permutation else None,79 "deflated_sharpe": report.dsr.get("dsr"),80 "pbo": report.pbo.get("pbo"),81 "walkforward_efficiency": report.walkforward.get("efficiency"),82 "cost_stress": report.cost_stress,83 "verdict": {k: v for k, v in report.verdict.items()},84 }85 print(json.dumps(payload, indent=2, default=str))86 return 087 88 v, m, b = report.verdict, report.backtest.metrics, report.backtest.benchmark_metrics89 bar = "=" * 6690 print(f"\n{bar}")91 print(f" {report.strategy.name} on {report.market.symbol} [{report.market.source} data]")92 print(f" {report.market.start.date()} to {report.market.end.date()} · {len(report.market.df):,} bars")93 print(bar)94 print(f" REALITY SCORE {v['score']:.1f} / 100 GRADE {v['grade']}")95 print(f" {v['headline']}")96 print(bar)97 print(f" Total return {m['total_return']:>9.1%} buy & hold {b['total_return']:>8.1%}")98 print(f" CAGR {m['cagr']:>9.1%} buy & hold {b['cagr']:>8.1%}")99 print(f" Sharpe {m['sharpe']:>9.2f} buy & hold {b['sharpe']:>8.2f}")100 print(f" Max drawdown {m['max_drawdown']:>9.1%}")101 print(f" Trades {int(m.get('n_trades', 0)):>9,}")102 print(bar)103 if report.permutation:104 print(f" Permutation p {report.permutation.p_value:>9.3f} ({report.permutation.n_permutations} shuffled markets)")105 print(f" Deflated Sharpe {report.dsr.get('dsr', 0):>9.2f} (after {report.trials.get('n', 1)} variants)")106 pbo = report.pbo.get("pbo")107 print(f" Overfit prob. {pbo:>9.2f}" if pbo == pbo else " Overfit prob. n/a")108 print(f" Walk-forward eff. {report.walkforward.get('efficiency', 0):>9.2f}")109 print(f" Sharpe at 3x cost {report.cost_stress.get('sharpe_3x', 0):>9.2f}")110 print(bar)111 for flag in v["flags"]:112 print(f" ! {flag}")113 if v["flags"]:114 print(bar)115 print(f" {v['verdict']}\n")116 return 0117 118 119def _cmd_arena(args: argparse.Namespace) -> int:120 cfg = _config_from(args)121 progress = None if args.quiet else (lambda f, m: print(f" [{f:5.0%}] {m}", file=sys.stderr))122 table, market, _ = run_arena(cfg, n_permutations=args.permutations, progress=progress)123 124 if args.json:125 print(table.to_json(orient="records", indent=2))126 return 0127 128 print(f"\n {market.symbol} [{market.source} data] "129 f"{market.start.date()} to {market.end.date()}\n")130 display = table.drop(columns=["key"]).copy()131 for col in ("Return", "CAGR", "MaxDD"):132 display[col] = display[col].map("{:.1%}".format)133 for col in ("Sharpe", "DSR", "Evidence"):134 display[col] = display[col].map("{:.2f}".format)135 display["p-value"] = display["p-value"].map(lambda v: "—" if v != v else f"{v:.3f}")136 print(display.to_string(index=False))137 print("\n Ranked by evidence = (1 - p) x deflated Sharpe, not by return.\n")138 return 0139 140 141def _cmd_portfolio(args: argparse.Namespace) -> int:142 from .portfolio_lab import DEFAULT_UNIVERSE, PortfolioLabConfig, run_portfolio_lab143 144 symbols = [s.strip() for s in args.symbols.split(",") if s.strip()] if args.symbols else DEFAULT_UNIVERSE145 cfg = PortfolioLabConfig(146 symbols=symbols,147 start=args.start,148 end=args.end,149 interval=args.interval,150 source=args.source,151 strategy=args.strategy,152 params=_parse_params(args.param),153 commission_bps=args.commission_bps,154 slippage_bps=args.slippage_bps,155 allow_short=not args.no_short,156 rebalance=args.rebalance,157 n_permutations=args.permutations,158 wf_folds=args.folds,159 )160 progress = None if args.quiet else (lambda f, m: print(f" [{f:5.0%}] {m}", file=sys.stderr))161 report = run_portfolio_lab(cfg, progress=progress)162 163 if args.json:164 print(json.dumps({165 "symbols": report.panel.symbols,166 "strategy": report.strategy.key,167 "params": report.params,168 "metrics": report.backtest.metrics,169 "p_value": report.permutation.p_value if report.permutation else None,170 "deflated_sharpe": report.dsr.get("dsr"),171 "pbo": report.pbo.get("pbo"),172 "walkforward_efficiency": report.walkforward.get("efficiency"),173 "attribution": report.attribution,174 "survivorship": report.survivorship.__dict__,175 "verdict": dict(report.verdict),176 }, indent=2, default=str))177 return 0178 179 v, m, b = report.verdict, report.backtest.metrics, report.backtest.benchmark_metrics180 bar = "=" * 72181 print(f"\n{bar}")182 print(f" {report.strategy.name} on {len(report.panel.symbols)} symbols [{report.panel.interval}]")183 print(f" {report.panel.index[0].date()} to {report.panel.index[-1].date()} · "184 f"{len(report.panel):,} bars · rebalance {report.config.rebalance}")185 print(bar)186 print(f" REALITY SCORE {v['score']:.1f} / 100 GRADE {v['grade']}")187 print(f" {v['headline']}")188 print(bar)189 print(f" Total return {m['total_return']:>9.1%} equal weight {b['total_return']:>8.1%}")190 print(f" CAGR {m['cagr']:>9.1%} equal weight {b['cagr']:>8.1%}")191 print(f" Sharpe {m['sharpe']:>9.2f} equal weight {b['sharpe']:>8.2f}")192 print(f" Max drawdown {m['max_drawdown']:>9.1%}")193 print(f" Gross / net exp. {m.get('gross_exposure', 0):>9.2f} / {m.get('net_exposure', 0):.2f}")194 print(f" Avg positions {m.get('avg_positions', 0):>9.1f} turnover {m.get('turnover_ann', 0):.1f}x/yr")195 print(bar)196 if report.permutation:197 print(f" Name-shuffle p {report.permutation.p_value:>9.3f} "198 f"({report.permutation.n_permutations} shuffles of which names got which weights)")199 print(f" Deflated Sharpe {report.dsr.get('dsr', 0):>9.2f} (after {report.trials.get('n', 1)} variants)")200 pbo = report.pbo.get("pbo")201 print(f" Overfit prob. {pbo:>9.2f}" if pbo == pbo else " Overfit prob. n/a")202 print(f" Walk-forward eff. {report.walkforward.get('efficiency', 0):>9.2f}")203 if report.attribution.get("available"):204 print(f" Style alpha {report.attribution['alpha_annual']:>9.1%} "205 f"t = {report.attribution['alpha_t_stat']:.2f}, R² = {report.attribution['r_squared']:.2f}")206 print(f" Survivorship {report.survivorship.survival_rate:>9.0%} "207 f"({report.survivorship.n_delisted} of {report.survivorship.n_symbols} stopped trading)")208 print(bar)209 for flag in v["flags"]:210 print(f" ! {flag}")211 if v["flags"]:212 print(bar)213 print(f" {v['verdict']}\n")214 return 0215 216 217def _cmd_strategies(args: argparse.Namespace) -> int:218 from .cross_sectional import XS_REGISTRY219 220 for title, registry in (("Single asset", REGISTRY), ("Cross-sectional", XS_REGISTRY)):221 print(f"\n {title}\n {'-' * len(title)}")222 for key, strategy in registry.items():223 params = ", ".join(f"{p.name}={p.default:g}" for p in strategy.params) or "no parameters"224 print(f" {key:<22} {strategy.name:<28} [{strategy.family}]")225 print(f" {'':<22} {strategy.description}")226 print(f" {'':<22} defaults: {params}\n")227 return 0228 229 230def main(argv: List[str] | None = None) -> int:231 parser = argparse.ArgumentParser(232 prog="algotrader",233 description="Backtest a trading rule, then try to prove the result was luck.",234 )235 parser.add_argument("--version", action="version", version=f"algotrader {__version__}")236 sub = parser.add_subparsers(dest="command", required=True)237 238 lab = sub.add_parser("lab", help="Full reality check for one strategy.")239 _add_common(lab)240 lab.add_argument("--strategy", default="sma_cross", choices=sorted(REGISTRY))241 lab.add_argument("--param", action="append", metavar="NAME=VALUE",242 help="Override a strategy parameter. Repeatable.")243 lab.add_argument("--permutations", type=int, default=250)244 lab.add_argument("--null", default="permute", choices=["permute", "block"])245 lab.add_argument("--folds", type=int, default=5)246 lab.add_argument("--json", action="store_true")247 lab.add_argument("--quiet", "-q", action="store_true")248 lab.set_defaults(func=_cmd_lab)249 250 arena = sub.add_parser("arena", help="Race every strategy on one market.")251 _add_common(arena)252 arena.add_argument("--permutations", type=int, default=120)253 arena.add_argument("--json", action="store_true")254 arena.add_argument("--quiet", "-q", action="store_true")255 arena.set_defaults(func=_cmd_arena)256 257 from .cross_sectional import XS_REGISTRY258 259 portfolio = sub.add_parser(260 "portfolio", help="Reality check for a cross-sectional (multi-asset) strategy."261 )262 _add_common(portfolio)263 portfolio.add_argument(264 "--symbols", default=None,265 help="Comma-separated universe, e.g. SPY,QQQ,AAPL. Defaults to a 12-name universe.",266 )267 portfolio.add_argument("--strategy", default="xs_momentum", choices=sorted(XS_REGISTRY))268 portfolio.add_argument("--param", action="append", metavar="NAME=VALUE")269 portfolio.add_argument("--rebalance", default="M", help="D, W, M, Q, or a number of bars.")270 portfolio.add_argument("--permutations", type=int, default=150)271 portfolio.add_argument("--folds", type=int, default=4)272 portfolio.add_argument("--json", action="store_true")273 portfolio.add_argument("--quiet", "-q", action="store_true")274 portfolio.set_defaults(func=_cmd_portfolio)275 276 listing = sub.add_parser("strategies", help="List the strategy zoo.")277 listing.set_defaults(func=_cmd_strategies)278 279 args = parser.parse_args(argv)280 try:281 return args.func(args)282 except KeyboardInterrupt:283 return 130284 except Exception as exc: # noqa: BLE001285 print(f"error: {exc}", file=sys.stderr)286 return 1287 288 289if __name__ == "__main__":290 raise SystemExit(main())291 