Files
signal-platform/scripts/run_prod_book_universe_matrix.py
T
dennisthiessen 9171e366ee research: prepare prod book universe x horizon 4-arm matrix
Pre-register A-D (4y/2016 x 505/505+liquid) with unchanged production knobs.
Runner caches full GTL candidates then re-ranks per arm; MacBook entry via
run_tier1_macbook.sh --prod-book-matrix.
2026-07-19 11:58:52 +02:00

707 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 collections import defaultdict
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())