#!/usr/bin/env python3 """Production book × universe × horizon matrix (research only). Four pre-registered arms — same live strategy knobs; only entry start date and tradable universe change. See docs/research/prod-book-universe-horizon.md. A 2022-07-01 prod ~505 B 2022-07-01 prod ∪ liquid top-1500 C 2016-07-01 prod ~505 D 2016-07-01 prod ∪ liquid top-1500 Example (MacBook, deep research.sqlite) --------------------------------------- python scripts/run_prod_book_universe_matrix.py \\ --snapshot backtest_snapshots/research.sqlite \\ --workers 8 --allow-spawn \\ --candidate-cache reports/.cache/prod-book-univ-cands.pkl """ from __future__ import annotations import argparse import asyncio import json import os import pickle import sys import time from concurrent.futures import ProcessPoolExecutor from datetime import date, datetime from pathlib import Path from typing import Any from sqlalchemy import create_engine, text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from app.ssl_bootstrap import bootstrap_ssl # noqa: E402 bootstrap_ssl() SHORT_START = date(2022, 7, 1) LONG_START = date(2016, 7, 1) LIQUID_TOP_N = 1500 LIQUID_MIN_PRICE = 5.0 CACHE_VERSION = "prod-book-universe-horizon-v1" ARMS: tuple[dict[str, Any], ...] = ( { "id": "A_prod_4y_505", "label": "Prod book · ~4y · 505 only", "start": SHORT_START, "universe": "prod_505", }, { "id": "B_prod_4y_505_liquid", "label": "Prod book · ~4y · 505 + liquid top-1500", "start": SHORT_START, "universe": "prod_plus_liquid", }, { "id": "C_prod_2016_505", "label": "Prod book · since 2016-07 · 505 only", "start": LONG_START, "universe": "prod_505", }, { "id": "D_prod_2016_505_liquid", "label": "Prod book · since 2016-07 · 505 + liquid top-1500", "start": LONG_START, "universe": "prod_plus_liquid", }, ) def _parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--snapshot", default="backtest_snapshots/research.sqlite") p.add_argument("--workers", type=int, default=8) p.add_argument("--allow-spawn", action="store_true") p.add_argument("--quiet", action="store_true") p.add_argument( "--candidate-cache", default="reports/.cache/prod-book-universe-cands.pkl", help="Pickle cache for full GTL candidate pass (expensive).", ) p.add_argument( "--rebuild-cache", action="store_true", help="Ignore existing candidate cache.", ) p.add_argument("--out", default=None) p.add_argument( "--skip-race-guard", action="store_true", help="Allow run without completion manifest (not recommended).", ) return p.parse_args() def _sqlite_url(path: Path) -> str: return f"sqlite+aiosqlite:///{path.resolve().as_posix()}" def _load_prod_and_all_symbols(snapshot: Path) -> tuple[set[str], list[str]]: engine = create_engine( f"sqlite:///{snapshot.resolve().as_posix()}", future=True, ) try: with engine.connect() as conn: all_syms = [ str(r[0]).upper() for r in conn.execute(text("SELECT symbol FROM tickers ORDER BY 1")) ] try: rank_only = { str(r[0]).upper() for r in conn.execute(text("SELECT symbol FROM research_rank_only")) } except Exception: rank_only = set() finally: engine.dispose() prod = {s for s in all_syms if s not in rank_only} return prod, all_syms def _median(xs: list[float]) -> float | None: if len(xs) < 20: return None s = sorted(xs) mid = len(s) // 2 if len(s) % 2: return s[mid] return 0.5 * (s[mid - 1] + s[mid]) def _build_liquid_membership( prices: dict[str, tuple], *, top_n: int, min_price: float, ) -> dict[date, set[str]]: """For each calendar date present in any series, top-N by 63d median $vol.""" # Collect per-symbol (date -> (close, dvol63)) per_sym: dict[str, dict[date, tuple[float, float | None]]] = {} all_dates: set[date] = set() for sym, cols in prices.items(): ords, _o, _h, _l, closes, vols = cols dates = [date.fromordinal(int(o)) for o in ords] n = len(dates) series: dict[date, tuple[float, float | None]] = {} for i in range(n): d = dates[i] c = float(closes[i]) dvol = None if i + 1 >= 63: dvs = [] for k in range(i - 62, i + 1): ck = float(closes[k]) vk = float(vols[k] or 0) if ck > 0 and vk >= 0: dvs.append(ck * vk) dvol = _median(dvs) series[d] = (c, dvol) all_dates.add(d) per_sym[sym] = series membership: dict[date, set[str]] = {} for d in sorted(all_dates): eligible: list[tuple[float, str]] = [] for sym, series in per_sym.items(): row = series.get(d) if row is None: continue c, dvol = row if c < min_price or dvol is None or dvol <= 0: continue eligible.append((-dvol, sym)) # highest dvol first eligible.sort() membership[d] = {sym for _, sym in eligible[:top_n]} return membership def _worker_replay( symbol: str, columns: tuple, config: dict, activation: dict, spy: dict, cadence: str, ) -> list[dict]: """Picklable full GTL+signals candidate replay (no signal-only).""" from app.services import backtest_service as bt cands, _series = bt._replay_and_signals( symbol, columns, config, activation, spy, bt.PRODUCTION_GTL_TARGET_MODEL, cadence, False, # always full replay for book matrix None, None, ) return cands async def _load_or_build_candidates( snapshot: Path, *, cache_path: Path | None, rebuild: bool, workers: int, quiet: bool, ) -> tuple[list[dict], dict[str, tuple], dict, set[str], dict]: from app.config import settings from app.services import backtest_service as bt from app.services.admin_service import get_activation_config from app.services.recommendation_service import get_recommendation_config from app.services.paper_trade_service import get_exit_policy from app.services.benchmark_service import load_benchmark_closes from app.models.ticker import Ticker from sqlalchemy import select os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1" settings.backtest_workers = max(1, workers) prod_set, all_syms = _load_prod_and_all_symbols(snapshot) print(f"Symbols: all={len(all_syms)} prod_505={len(prod_set)}") cache_key = { "version": CACHE_VERSION, "snapshot": str(snapshot.resolve()), "prod_n": len(prod_set), "all_n": len(all_syms), } if cache_path and cache_path.exists() and not rebuild: with cache_path.open("rb") as fh: blob = pickle.load(fh) if blob.get("key") == cache_key and blob.get("candidates"): print(f"Loaded candidate cache: {cache_path} ({len(blob['candidates'])} rows)") return ( blob["candidates"], blob["prices"], blob["spy"], set(blob["prod_set"]), blob["exit_config"], ) print("Cache key mismatch — rebuilding candidates") engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True) Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) candidates: list[dict] = [] prices: dict[str, tuple] = {} try: async with Session() as db: config = await get_recommendation_config(db) activation = await get_activation_config(db) exit_config = await get_exit_policy(db) spy = await load_benchmark_closes(db, "SPY") tickers = list( (await db.execute(select(Ticker).order_by(Ticker.symbol))).scalars() ) # Fetch all price columns first (I/O). for idx, t in enumerate(tickers): if not quiet and idx % 100 == 0: print(f" fetch prices {idx}/{len(tickers)}", end="\r", flush=True) cols = await bt._fetch_columns(db, t.symbol) if cols is not None: prices[t.symbol.upper()] = cols if not quiet: print() # Parallel GTL replay for every symbol with prices. syms = sorted(prices) print(f"GTL replay on {len(syms)} symbols (workers={workers})…") t0 = time.monotonic() if workers <= 1: for i, sym in enumerate(syms): if not quiet and i % 50 == 0: print(f" replay {i}/{len(syms)}", end="\r", flush=True) candidates.extend( _worker_replay( sym, prices[sym], config, activation, spy, "weekly" ) ) else: # Process pool: pass column batches. import multiprocessing as mp ctx = mp.get_context("spawn") chunk = max(1, workers * 2) with ProcessPoolExecutor(max_workers=workers, mp_context=ctx) as pool: for start in range(0, len(syms), chunk): batch = syms[start : start + chunk] futs = [ pool.submit( _worker_replay, sym, prices[sym], config, activation, spy, "weekly", ) for sym in batch ] for fut in futs: try: candidates.extend(fut.result()) except Exception as exc: print(f" worker error: {exc}") if not quiet: print( f" replay {min(start+chunk, len(syms))}/{len(syms)} " f"cands={len(candidates)} " f"elapsed={(time.monotonic()-t0)/60:.1f}m", end="\r", flush=True, ) if not quiet: print() finally: await engine.dispose() print(f"Total raw candidates: {len(candidates)}") if cache_path: cache_path.parent.mkdir(parents=True, exist_ok=True) with cache_path.open("wb") as fh: pickle.dump( { "key": cache_key, "candidates": candidates, "prices": prices, "spy": spy, "prod_set": sorted(prod_set), "exit_config": exit_config, }, fh, protocol=pickle.HIGHEST_PROTOCOL, ) print(f"Wrote cache {cache_path}") return candidates, prices, spy, prod_set, exit_config def _candidate_eligible( cand: dict, *, prod_set: set[str], universe: str, liquid_by_date: dict[date, set[str]], ) -> bool: if cand.get("direction") != "long": return False sym = str(cand.get("symbol") or "").upper() if not sym: return False if universe == "prod_505": return sym in prod_set # prod_plus_liquid if sym in prod_set: return True try: d = date.fromisoformat(str(cand["date"])[:10]) except Exception: return False return sym in (liquid_by_date.get(d) or set()) def _run_arm( arm: dict[str, Any], *, all_candidates: list[dict], prices: dict[str, tuple], spy: dict, prod_set: set[str], liquid_by_date: dict[date, set[str]], exit_config: dict, ) -> dict[str, Any]: from app.services import backtest_service as bt start: date = arm["start"] universe: str = arm["universe"] filtered: list[dict] = [] for c in all_candidates: try: d = date.fromisoformat(str(c["date"])[:10]) except Exception: continue if d < start: continue if not _candidate_eligible( c, prod_set=prod_set, universe=universe, liquid_by_date=liquid_by_date ): continue filtered.append(dict(c)) # Re-rank inside this arm's universe (production percentile logic). bt._assign_momentum_percentiles(filtered) bt._assign_residual_momentum_percentiles(filtered) bt._assign_low_volatility_percentiles(filtered) bt._assign_activation_momentum_percentiles(filtered) bt._assign_residual_high_vol_blend(filtered) for c in filtered: c["qualified"] = bt._momentum_qualifies(c, 80.0) longs = [ c for c in filtered if c.get("qualified") and c.get("direction") == "long" ] strategy = next(s for s in bt.PORTFOLIO_MONITOR_STRATEGIES if s.get("is_production")) entry_cfg = bt._entry_variant_config(str(strategy["entry_variant"])) assert entry_cfg is not None ranking_key = str( entry_cfg.get("ranking_key") or entry_cfg["percentile_key"] ) exit_policy = bt.LIVE_EXIT_MODE_TO_SIM.get( str(exit_config.get("mode", "atr_trailing")), "atr_trail3" ) hold_days = int(exit_config.get("hold_days", 30)) trail = float(exit_config.get("atr_multiplier", bt.ATR_TRAIL_MULTIPLIER)) risk = float(entry_cfg["risk_per_trade"]) max_pos = int(entry_cfg["max_positions"]) reentry = bt._make_gate_reset_reentry_fn( longs, prices, cadence="weekly", ranking_key=ranking_key ) sim = bt._simulate_portfolio( longs, prices, spy, exit_policy, hold_days, ranking_key=ranking_key, max_positions=max_pos, risk_per_trade=risk, atr_trail_multiplier=trail, post_stop_reentry_fn=reentry, start_date=start, end_date=None, fill_mode=bt.FILL_MODE_CLOSE, include_trades=False, ) if sim is None: return { "id": arm["id"], "label": arm["label"], "start": start.isoformat(), "universe": universe, "n_candidates": len(filtered), "n_qualified_longs": 0, "error": "no_trades", } keep = { k: sim.get(k) for k in ( "sharpe", "sharpe_se", "cagr_pct", "max_drawdown_pct", "total_return_pct", "calmar", "trades", "win_rate", "n_returns", "psr", "start_date", "end_date", "spy_return_pct", "final_equity", ) } return { "id": arm["id"], "label": arm["label"], "start": start.isoformat(), "universe": universe, "n_candidates": len(filtered), "n_qualified_longs": len(longs), "fill_mode": "close", "ranking_key": ranking_key, "exit_policy": exit_policy, "hold_days": hold_days, **keep, } def _write_outputs(payload: dict, out_json: Path, doc_path: Path) -> None: out_json.parent.mkdir(parents=True, exist_ok=True) out_json.write_text( json.dumps(payload, indent=2, default=str) + "\n", encoding="utf-8" ) lines = [ "# Production book × universe × horizon — results", "", f"Generated: `{payload.get('generated_at')}`", "", "> Survivorship: today's constituents backfilled. Compare arms relatively; " "do not treat deep CAGR/Sharpe levels as deployable forecasts.", "", "## Arms", "", "| arm | universe | entry start | Sharpe | SE | CAGR % | max DD % | ret % | trades | qual longs | span |", "|---|---|---|---:|---:|---:|---:|---:|---:|---:|---|", ] for row in payload.get("arms") or []: if row.get("error"): lines.append( f"| {row.get('id')} | {row.get('universe')} | {row.get('start')} | " f"ERR | | | | | | {row.get('n_qualified_longs')} | {row.get('error')} |" ) continue lines.append( f"| {row.get('id')} | {row.get('universe')} | {row.get('start')} | " f"{row.get('sharpe')} | {row.get('sharpe_se')} | {row.get('cagr_pct')} | " f"{row.get('max_drawdown_pct')} | {row.get('total_return_pct')} | " f"{row.get('trades')} | {row.get('n_qualified_longs')} | " f"{row.get('start_date')}→{row.get('end_date')} |" ) lines.extend([ "", "## Config (production, unchanged)", "", f"```json\n{json.dumps(payload.get('strategy') or {}, indent=2)}\n```", "", "## Snapshot", "", f"```json\n{json.dumps(payload.get('snapshot_meta') or {}, indent=2, default=str)}\n```", "", "PENDING_HUMAN — descriptive matrix only; no auto promotion.", "", f"JSON: `{out_json.as_posix()}`", "", ]) out_json.with_suffix(".md").write_text("\n".join(lines) + "\n", encoding="utf-8") # Fill results section of the research doc. if doc_path.exists(): text = doc_path.read_text(encoding="utf-8") marker = "## Results" idx = text.find(marker) header = text[:idx] if idx >= 0 else text # Drop old results/verdict tail for m in ("## Results", "## Verdict"): pass body = [ header.rstrip(), "", "## Results", "", f"Generated: `{payload.get('generated_at')}`", "", "| arm | universe | entry start | Sharpe | SE | CAGR % | max DD % | trades |", "|---|---|---|---:|---:|---:|---:|---:|", ] for row in payload.get("arms") or []: body.append( f"| {row.get('id')} | {row.get('universe')} | {row.get('start')} | " f"{row.get('sharpe', '')} | {row.get('sharpe_se', '')} | " f"{row.get('cagr_pct', '')} | {row.get('max_drawdown_pct', '')} | " f"{row.get('trades', '')} |" ) body.extend([ "", f"Full report: `{out_json.as_posix()}`", "", "## Verdict", "", "**PENDING_HUMAN** — descriptive only; production knobs unchanged.", "", ]) doc_path.write_text("\n".join(body) + "\n", encoding="utf-8") async def _main() -> None: args = _parse_args() snapshot = Path(args.snapshot) if not snapshot.exists(): raise SystemExit(f"Missing snapshot: {snapshot}") if args.allow_spawn: os.environ["BACKTEST_ALLOW_SPAWN"] = "1" if not args.skip_race_guard: try: from scripts.research_snapshot_manifest import ( assert_research_snapshot_complete, ) manifest = assert_research_snapshot_complete(snapshot) print( f"Race guard OK: tickers={manifest.get('ticker_count')} " f"ohlcv={manifest.get('ohlcv_row_count')}" ) except SystemExit as exc: # Prod-only snapshot without manifest: allow with warning if ~505. engine = create_engine( f"sqlite:///{snapshot.resolve().as_posix()}", future=True, ) try: with engine.connect() as conn: n = int(conn.execute(text("SELECT COUNT(*) FROM tickers")).scalar_one()) finally: engine.dispose() if n < 400: raise print(f"WARNING: no research manifest ({exc}); proceeding n_tickers={n}") cache = Path(args.candidate_cache) if args.candidate_cache else None candidates, prices, spy, prod_set, exit_config = await _load_or_build_candidates( snapshot, cache_path=cache, rebuild=args.rebuild_cache, workers=args.workers, quiet=args.quiet, ) print("Building PIT liquid membership (top-1500, price≥5)…") t0 = time.monotonic() liquid_by_date = _build_liquid_membership( prices, top_n=LIQUID_TOP_N, min_price=LIQUID_MIN_PRICE ) print( f" liquid dates={len(liquid_by_date)} " f"elapsed={(time.monotonic()-t0)/60:.1f}m" ) arms_out = [] for arm in ARMS: print(f"Running arm {arm['id']}…") row = _run_arm( arm, all_candidates=candidates, prices=prices, spy=spy, prod_set=prod_set, liquid_by_date=liquid_by_date, exit_config=exit_config, ) arms_out.append(row) print( f" Sharpe={row.get('sharpe')} CAGR={row.get('cagr_pct')} " f"DD={row.get('max_drawdown_pct')} trades={row.get('trades')} " f"qual={row.get('n_qualified_longs')}" ) stamp = datetime.now().strftime("%Y%m%d-%H%M%S") out = ( Path(args.out) if args.out else Path("reports") / f"prod-book-universe-horizon-{stamp}.json" ) payload = { "generated_at": datetime.now().isoformat(), "snapshot": str(snapshot.resolve()), "snapshot_meta": { "prod_universe_n": len(prod_set), "price_symbols_n": len(prices), "raw_candidates": len(candidates), "liquid_top_n": LIQUID_TOP_N, "liquid_min_price": LIQUID_MIN_PRICE, "short_start": SHORT_START.isoformat(), "long_start": LONG_START.isoformat(), }, "strategy": { "note": "Live production knobs — no modifications", "momentum": "residual_12_1 gate 80", "rank": "residual_high_vol_blend_80_20", "fill_mode": "close", "cost_per_side": 0.001, "exit": exit_config, "max_positions": 10, "risk_per_trade": 0.01, "reentry": "gate_reset", }, "arms": arms_out, "survivorship_banner": ( "Today's constituents backfilled. Relative arm comparison only." ), "pending_human": True, } _write_outputs( payload, out, Path("docs/research/prod-book-universe-horizon.md"), ) print(f"Wrote {out}") print(f"Wrote {out.with_suffix('.md')}") if __name__ == "__main__": asyncio.run(_main())