| """Command-line interface. |
| |
| python -m algotrader.cli lab --symbol SPY --strategy sma_cross |
| python -m algotrader.cli arena --symbol BTC-USD --start 2018-01-01 |
| python -m algotrader.cli strategies |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import sys |
| from typing import Dict, List |
|
|
| from . import __version__ |
| from .lab import LabConfig, run_arena, run_lab |
| from .strategies import REGISTRY, get_strategy |
|
|
|
|
| def _parse_params(pairs: List[str] | None) -> Dict[str, float]: |
| params: Dict[str, float] = {} |
| for pair in pairs or []: |
| if "=" not in pair: |
| raise SystemExit(f"--param expects name=value, got '{pair}'") |
| name, _, value = pair.partition("=") |
| params[name.strip()] = float(value) |
| return params |
|
|
|
|
| def _add_common(parser: argparse.ArgumentParser) -> None: |
| parser.add_argument("--symbol", default="SPY") |
| parser.add_argument("--start", default="2015-01-01") |
| parser.add_argument("--end", default=None) |
| parser.add_argument("--interval", default="1d") |
| parser.add_argument( |
| "--source", default="yahoo", choices=["yahoo", "live", "auto", "cache", "synthetic"], |
| help="'yahoo' requires a Yahoo download. 'synthetic' is offline tests only. 'auto' falls back to the simulator.", |
| ) |
| parser.add_argument("--commission-bps", type=float, default=1.0) |
| parser.add_argument("--slippage-bps", type=float, default=2.0) |
| parser.add_argument("--no-short", action="store_true", help="Long/flat only.") |
|
|
|
|
| def _config_from(args: argparse.Namespace, **overrides) -> LabConfig: |
| return LabConfig( |
| symbol=args.symbol, |
| start=args.start, |
| end=args.end, |
| interval=args.interval, |
| source=args.source, |
| commission_bps=args.commission_bps, |
| slippage_bps=args.slippage_bps, |
| allow_short=not args.no_short, |
| **overrides, |
| ) |
|
|
|
|
| def _cmd_lab(args: argparse.Namespace) -> int: |
| cfg = _config_from( |
| args, |
| strategy=args.strategy, |
| params=_parse_params(args.param), |
| n_permutations=args.permutations, |
| permutation_method=args.null, |
| wf_folds=args.folds, |
| ) |
| progress = None if args.quiet else (lambda f, m: print(f" [{f:5.0%}] {m}", file=sys.stderr)) |
| report = run_lab(cfg, progress=progress) |
|
|
| if args.json: |
| payload = { |
| "symbol": report.market.symbol, |
| "source": report.market.source, |
| "strategy": report.strategy.key, |
| "params": report.params, |
| "metrics": report.backtest.metrics, |
| "benchmark_metrics": report.backtest.benchmark_metrics, |
| "p_value": report.permutation.p_value if report.permutation else None, |
| "deflated_sharpe": report.dsr.get("dsr"), |
| "pbo": report.pbo.get("pbo"), |
| "walkforward_efficiency": report.walkforward.get("efficiency"), |
| "cost_stress": report.cost_stress, |
| "verdict": {k: v for k, v in report.verdict.items()}, |
| } |
| print(json.dumps(payload, indent=2, default=str)) |
| return 0 |
|
|
| v, m, b = report.verdict, report.backtest.metrics, report.backtest.benchmark_metrics |
| bar = "=" * 66 |
| print(f"\n{bar}") |
| print(f" {report.strategy.name} on {report.market.symbol} [{report.market.source} data]") |
| print(f" {report.market.start.date()} to {report.market.end.date()} · {len(report.market.df):,} bars") |
| print(bar) |
| print(f" REALITY SCORE {v['score']:.1f} / 100 GRADE {v['grade']}") |
| print(f" {v['headline']}") |
| print(bar) |
| print(f" Total return {m['total_return']:>9.1%} buy & hold {b['total_return']:>8.1%}") |
| print(f" CAGR {m['cagr']:>9.1%} buy & hold {b['cagr']:>8.1%}") |
| print(f" Sharpe {m['sharpe']:>9.2f} buy & hold {b['sharpe']:>8.2f}") |
| print(f" Max drawdown {m['max_drawdown']:>9.1%}") |
| print(f" Trades {int(m.get('n_trades', 0)):>9,}") |
| print(bar) |
| if report.permutation: |
| print(f" Permutation p {report.permutation.p_value:>9.3f} ({report.permutation.n_permutations} shuffled markets)") |
| print(f" Deflated Sharpe {report.dsr.get('dsr', 0):>9.2f} (after {report.trials.get('n', 1)} variants)") |
| pbo = report.pbo.get("pbo") |
| print(f" Overfit prob. {pbo:>9.2f}" if pbo == pbo else " Overfit prob. n/a") |
| print(f" Walk-forward eff. {report.walkforward.get('efficiency', 0):>9.2f}") |
| print(f" Sharpe at 3x cost {report.cost_stress.get('sharpe_3x', 0):>9.2f}") |
| print(bar) |
| for flag in v["flags"]: |
| print(f" ! {flag}") |
| if v["flags"]: |
| print(bar) |
| print(f" {v['verdict']}\n") |
| return 0 |
|
|
|
|
| def _cmd_arena(args: argparse.Namespace) -> int: |
| cfg = _config_from(args) |
| progress = None if args.quiet else (lambda f, m: print(f" [{f:5.0%}] {m}", file=sys.stderr)) |
| table, market, _ = run_arena(cfg, n_permutations=args.permutations, progress=progress) |
|
|
| if args.json: |
| print(table.to_json(orient="records", indent=2)) |
| return 0 |
|
|
| print(f"\n {market.symbol} [{market.source} data] " |
| f"{market.start.date()} to {market.end.date()}\n") |
| display = table.drop(columns=["key"]).copy() |
| for col in ("Return", "CAGR", "MaxDD"): |
| display[col] = display[col].map("{:.1%}".format) |
| for col in ("Sharpe", "DSR", "Evidence"): |
| display[col] = display[col].map("{:.2f}".format) |
| display["p-value"] = display["p-value"].map(lambda v: "—" if v != v else f"{v:.3f}") |
| print(display.to_string(index=False)) |
| print("\n Ranked by evidence = (1 - p) x deflated Sharpe, not by return.\n") |
| return 0 |
|
|
|
|
| def _cmd_portfolio(args: argparse.Namespace) -> int: |
| from .portfolio_lab import DEFAULT_UNIVERSE, PortfolioLabConfig, run_portfolio_lab |
|
|
| symbols = [s.strip() for s in args.symbols.split(",") if s.strip()] if args.symbols else DEFAULT_UNIVERSE |
| cfg = PortfolioLabConfig( |
| symbols=symbols, |
| start=args.start, |
| end=args.end, |
| interval=args.interval, |
| source=args.source, |
| strategy=args.strategy, |
| params=_parse_params(args.param), |
| commission_bps=args.commission_bps, |
| slippage_bps=args.slippage_bps, |
| allow_short=not args.no_short, |
| rebalance=args.rebalance, |
| n_permutations=args.permutations, |
| wf_folds=args.folds, |
| ) |
| progress = None if args.quiet else (lambda f, m: print(f" [{f:5.0%}] {m}", file=sys.stderr)) |
| report = run_portfolio_lab(cfg, progress=progress) |
|
|
| if args.json: |
| print(json.dumps({ |
| "symbols": report.panel.symbols, |
| "strategy": report.strategy.key, |
| "params": report.params, |
| "metrics": report.backtest.metrics, |
| "p_value": report.permutation.p_value if report.permutation else None, |
| "deflated_sharpe": report.dsr.get("dsr"), |
| "pbo": report.pbo.get("pbo"), |
| "walkforward_efficiency": report.walkforward.get("efficiency"), |
| "attribution": report.attribution, |
| "survivorship": report.survivorship.__dict__, |
| "verdict": dict(report.verdict), |
| }, indent=2, default=str)) |
| return 0 |
|
|
| v, m, b = report.verdict, report.backtest.metrics, report.backtest.benchmark_metrics |
| bar = "=" * 72 |
| print(f"\n{bar}") |
| print(f" {report.strategy.name} on {len(report.panel.symbols)} symbols [{report.panel.interval}]") |
| print(f" {report.panel.index[0].date()} to {report.panel.index[-1].date()} · " |
| f"{len(report.panel):,} bars · rebalance {report.config.rebalance}") |
| print(bar) |
| print(f" REALITY SCORE {v['score']:.1f} / 100 GRADE {v['grade']}") |
| print(f" {v['headline']}") |
| print(bar) |
| print(f" Total return {m['total_return']:>9.1%} equal weight {b['total_return']:>8.1%}") |
| print(f" CAGR {m['cagr']:>9.1%} equal weight {b['cagr']:>8.1%}") |
| print(f" Sharpe {m['sharpe']:>9.2f} equal weight {b['sharpe']:>8.2f}") |
| print(f" Max drawdown {m['max_drawdown']:>9.1%}") |
| print(f" Gross / net exp. {m.get('gross_exposure', 0):>9.2f} / {m.get('net_exposure', 0):.2f}") |
| print(f" Avg positions {m.get('avg_positions', 0):>9.1f} turnover {m.get('turnover_ann', 0):.1f}x/yr") |
| print(bar) |
| if report.permutation: |
| print(f" Name-shuffle p {report.permutation.p_value:>9.3f} " |
| f"({report.permutation.n_permutations} shuffles of which names got which weights)") |
| print(f" Deflated Sharpe {report.dsr.get('dsr', 0):>9.2f} (after {report.trials.get('n', 1)} variants)") |
| pbo = report.pbo.get("pbo") |
| print(f" Overfit prob. {pbo:>9.2f}" if pbo == pbo else " Overfit prob. n/a") |
| print(f" Walk-forward eff. {report.walkforward.get('efficiency', 0):>9.2f}") |
| if report.attribution.get("available"): |
| print(f" Style alpha {report.attribution['alpha_annual']:>9.1%} " |
| f"t = {report.attribution['alpha_t_stat']:.2f}, R² = {report.attribution['r_squared']:.2f}") |
| print(f" Survivorship {report.survivorship.survival_rate:>9.0%} " |
| f"({report.survivorship.n_delisted} of {report.survivorship.n_symbols} stopped trading)") |
| print(bar) |
| for flag in v["flags"]: |
| print(f" ! {flag}") |
| if v["flags"]: |
| print(bar) |
| print(f" {v['verdict']}\n") |
| return 0 |
|
|
|
|
| def _cmd_strategies(args: argparse.Namespace) -> int: |
| from .cross_sectional import XS_REGISTRY |
|
|
| for title, registry in (("Single asset", REGISTRY), ("Cross-sectional", XS_REGISTRY)): |
| print(f"\n {title}\n {'-' * len(title)}") |
| for key, strategy in registry.items(): |
| params = ", ".join(f"{p.name}={p.default:g}" for p in strategy.params) or "no parameters" |
| print(f" {key:<22} {strategy.name:<28} [{strategy.family}]") |
| print(f" {'':<22} {strategy.description}") |
| print(f" {'':<22} defaults: {params}\n") |
| return 0 |
|
|
|
|
| def main(argv: List[str] | None = None) -> int: |
| parser = argparse.ArgumentParser( |
| prog="algotrader", |
| description="Backtest a trading rule, then try to prove the result was luck.", |
| ) |
| parser.add_argument("--version", action="version", version=f"algotrader {__version__}") |
| sub = parser.add_subparsers(dest="command", required=True) |
|
|
| lab = sub.add_parser("lab", help="Full reality check for one strategy.") |
| _add_common(lab) |
| lab.add_argument("--strategy", default="sma_cross", choices=sorted(REGISTRY)) |
| lab.add_argument("--param", action="append", metavar="NAME=VALUE", |
| help="Override a strategy parameter. Repeatable.") |
| lab.add_argument("--permutations", type=int, default=250) |
| lab.add_argument("--null", default="permute", choices=["permute", "block"]) |
| lab.add_argument("--folds", type=int, default=5) |
| lab.add_argument("--json", action="store_true") |
| lab.add_argument("--quiet", "-q", action="store_true") |
| lab.set_defaults(func=_cmd_lab) |
|
|
| arena = sub.add_parser("arena", help="Race every strategy on one market.") |
| _add_common(arena) |
| arena.add_argument("--permutations", type=int, default=120) |
| arena.add_argument("--json", action="store_true") |
| arena.add_argument("--quiet", "-q", action="store_true") |
| arena.set_defaults(func=_cmd_arena) |
|
|
| from .cross_sectional import XS_REGISTRY |
|
|
| portfolio = sub.add_parser( |
| "portfolio", help="Reality check for a cross-sectional (multi-asset) strategy." |
| ) |
| _add_common(portfolio) |
| portfolio.add_argument( |
| "--symbols", default=None, |
| help="Comma-separated universe, e.g. SPY,QQQ,AAPL. Defaults to a 12-name universe.", |
| ) |
| portfolio.add_argument("--strategy", default="xs_momentum", choices=sorted(XS_REGISTRY)) |
| portfolio.add_argument("--param", action="append", metavar="NAME=VALUE") |
| portfolio.add_argument("--rebalance", default="M", help="D, W, M, Q, or a number of bars.") |
| portfolio.add_argument("--permutations", type=int, default=150) |
| portfolio.add_argument("--folds", type=int, default=4) |
| portfolio.add_argument("--json", action="store_true") |
| portfolio.add_argument("--quiet", "-q", action="store_true") |
| portfolio.set_defaults(func=_cmd_portfolio) |
|
|
| listing = sub.add_parser("strategies", help="List the strategy zoo.") |
| listing.set_defaults(func=_cmd_strategies) |
|
|
| args = parser.parse_args(argv) |
| try: |
| return args.func(args) |
| except KeyboardInterrupt: |
| return 130 |
| except Exception as exc: |
| print(f"error: {exc}", file=sys.stderr) |
| return 1 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|