Files
signal-platform/scripts/run_execution_recovery_matrix.py
dennisthiessenandClaude Opus 5 1c6ccceb12 chore: make the whole tree ruff-clean, not just app/
CI only lints app/, so 11 findings had accumulated in tests/ and scripts/.

Mechanical and behaviour-neutral, but two were not auto-fixable and needed a
judgement call rather than `ruff --fix`:

- E741 in run_fip_breadth_diagnostics: `l` is the OHLCV low and is genuinely
  used, so this was a naming fix (`l` -> `lo`), not a deletion.
- F841 in the same file: `vol_ix`/`momr_ix` are assigned from a pure local
  `_index()` and never read, so removing them cannot change any output. Their
  upstream `vol_weeks`/`momr_weeks` maps *are* used further down and stay; the
  comment above `_index` was corrected to say so.

The rest are unused imports and f-strings without placeholders (literal
markdown table headers, so identical output).

Verified beyond the linter, since py_compile does not catch a removed-but-used
import: every removed symbol has zero remaining references, all scripts compile,
and the full unit suite passes (852 passed, 1 skipped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:43:46 +02:00

439 lines
17 KiB
Python
Raw Permalink 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.
"""Execution-recovery matrix: is the close→next_open gap recoverable by scheduling?
Hypothesis (from Phase A A4)
----------------------------
The 1.77 → 1.20 full-period Sharpe gap under next_open fill is mostly overnight
momentum drift that a 07:00-Berlin scanner cannot earn. Near-close / MOC-style
execution (scan ~15:45 ET, fill at/near that close) should recover it.
Pre-registered arms (N for DSR = 4)
----------------------------------
1. ``close_control`` — historical optimistic control (signal = fill at same close).
2. ``next_open`` — honest overnight scanner (decision baseline for future promotion).
3. ``stale_close`` — signal at t1 close, fill at t close (one-session-stale MOC proxy).
Expectation: ≈ close_control; if so, the gap is scheduling, not physics.
4. ``next_open_gap2`` — next_open but skip entries that open > +2% above signal close.
Measure whether large gap-ups are toxic or the best continuations.
Promotion / read rule (pre-registered)
--------------------------------------
- Decision baseline for *future* strategy work: ``next_open``.
- Recovery success for ``stale_close``: validation Sharpe within 0.5×SE of
``close_control`` **and** validation Sharpe ≥ ``next_open``; train Sharpe not
worse than close_control by more than 0.5×SE. State 1-SE distinguishability.
- ``next_open_gap2`` is measurement-only vs ``next_open`` (no auto-promote to live).
Reuses the same daily candidate cache as the Phase A matrix when the cache key
matches.
Usage
-----
python scripts/run_execution_recovery_matrix.py backtest_snapshots/prod.sqlite \\
--workers 7 --allow-spawn \\
--candidate-cache reports/.cache/research-cands.pkl \\
--out reports/execution-recovery-matrix.json
"""
from __future__ import annotations
import argparse
import asyncio
import json
import multiprocessing
import os
import pickle
import sys
from concurrent.futures import ProcessPoolExecutor, as_completed
from datetime import date, datetime
from pathlib import Path
from typing import Any
from sqlalchemy import select
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 scripts.research_rankings import ( # noqa: E402
_live_universe_rank_map,
)
# Must match Phase A cache when reusing research-cands.pkl
CACHE_VERSION = "research-matrix-v1-daily-prod"
PRE_REGISTERED_ARMS: tuple[dict[str, Any], ...] = (
{
"id": "close_control",
"label": "Close fill (historical control)",
"fill_mode": "close",
},
{
"id": "next_open",
"label": "Next-open fill (decision baseline)",
"fill_mode": "next_open",
},
{
"id": "stale_close",
"label": "Stale-signal close fill (MOC proxy: signal t-1, fill t close)",
"fill_mode": "stale_close",
},
{
"id": "next_open_gap2",
"label": "Next-open + skip gap-up > 2%",
"fill_mode": "next_open",
"max_entry_gap_pct": 0.02,
},
)
N_TRIALS = len(PRE_REGISTERED_ARMS)
def _sqlite_url(path: Path) -> str:
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("snapshot")
p.add_argument("--workers", type=int, default=6)
p.add_argument("--allow-spawn", action="store_true")
p.add_argument("--out", default=None)
p.add_argument("--candidate-cache", default=None)
p.add_argument("--validation-split", default="2024-07-01")
p.add_argument("--cadence", choices=("daily", "weekly"), default="daily")
p.add_argument("--quiet", action="store_true")
return p.parse_args()
def _window(arm: dict, name: str) -> dict | None:
for row in arm.get("windows") or []:
if row.get("window") == name:
return row
return None
def _grade_stale_close(close_arm: dict, next_arm: dict, stale_arm: dict) -> dict:
c_val = _window(close_arm, "validation") or {}
n_val = _window(next_arm, "validation") or {}
s_val = _window(stale_arm, "validation") or {}
c_tr = _window(close_arm, "train") or {}
s_tr = _window(stale_arm, "train") or {}
keys = ("sharpe", "sharpe_se")
if any(c_val.get(k) is None for k in keys) or s_val.get("sharpe") is None:
return {"recover": False, "reason": "missing Sharpe rows"}
se = float(c_val.get("sharpe_se") or s_val.get("sharpe_se") or 0.0)
half_se = 0.5 * se if se > 0 else 0.0
cs, ss, ns = float(c_val["sharpe"]), float(s_val["sharpe"]), n_val.get("sharpe")
cts, sts = c_tr.get("sharpe"), s_tr.get("sharpe")
near_close = abs(ss - cs) <= half_se if half_se > 0 else abs(ss - cs) < 0.05
beats_next = ns is None or ss >= float(ns)
train_ok = (
cts is None
or sts is None
or float(sts) >= float(cts) - half_se
)
recover = near_close and beats_next and train_ok
return {
"recover": recover,
"near_close_control": near_close,
"beats_next_open": beats_next,
"train_ok": train_ok,
"validation_delta_vs_close": round(ss - cs, 4),
"validation_delta_vs_next_open": (
round(ss - float(ns), 4) if ns is not None else None
),
"half_se": half_se,
"reason": (
"stale_close recovers close-fill economics (within 0.5 SE) and beats next_open"
if recover
else "stale_close does not meet recovery criteria — see flags"
),
}
def _markdown(report: dict) -> str:
lines = [
f"# Execution recovery matrix — {report.get('generated_at', '')}",
"",
f"Validation split: **{report.get('validation_split')}**. N for DSR: **{report.get('n_trials')}**.",
"",
"| arm | window | Sharpe | SE | CAGR | MaxDD | trades | gap/drift |",
"|---|---|---:|---:|---:|---:|---:|---|",
]
for arm in report.get("arms") or []:
for w in arm.get("windows") or []:
slip = w.get("overnight_slippage") or w.get("signal_to_fill_drift") or {}
slip_s = (
f"mean {slip.get('mean_pct')}% n={slip.get('n')}"
if slip
else "—"
)
if w.get("skipped_gap_cap") is not None:
slip_s += f"; gap_skips={w.get('skipped_gap_cap')}"
lines.append(
f"| {arm.get('id')} | {w.get('window')} | {w.get('sharpe')} | "
f"{w.get('sharpe_se')} | {w.get('cagr_pct')} | {w.get('max_drawdown_pct')} | "
f"{w.get('trades')} | {slip_s} |"
)
rec = report.get("recovery") or {}
lines.extend(
[
"",
"## Recovery decision (stale_close)",
"",
f"- **recover: {rec.get('recover')}** — {rec.get('reason')}",
f"- flags: { {k: rec.get(k) for k in ('near_close_control', 'beats_next_open', 'train_ok', 'validation_delta_vs_close', 'validation_delta_vs_next_open', 'half_se')} }",
"",
]
)
return "\n".join(lines)
def _checkpoint(path: Path, report: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
tmp.replace(path)
path.with_suffix(".md").write_text(_markdown(report), encoding="utf-8")
async def _main() -> None:
args = _parse_args()
snapshot = Path(args.snapshot)
if not snapshot.exists():
raise SystemExit(f"Snapshot not found: {snapshot}")
validation_split = date.fromisoformat(args.validation_split)
out_path = (
Path(args.out)
if args.out
else Path("reports")
/ f"execution-recovery-matrix-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
)
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
if args.allow_spawn:
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
from app.models.ticker import Ticker
from app.services import backtest_service as bt
from app.services.admin_service import get_activation_config
from app.services.paper_trade_service import get_exit_policy
from app.services.recommendation_service import get_recommendation_config
db_engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
Session = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
try:
async with Session() as db:
recommendation_config = await get_recommendation_config(db)
activation = await get_activation_config(db)
exit_config = await get_exit_policy(db)
benchmark_closes = await bt._load_benchmark_closes_for_backtest(
db, days=None, refresh=False
)
ticker_result = await db.execute(select(Ticker).order_by(Ticker.symbol))
symbols = [t.symbol for t in ticker_result.scalars().all()]
prices: dict[str, tuple] = {}
for index, symbol in enumerate(symbols, 1):
columns = await bt._fetch_columns(db, symbol)
if columns is not None:
prices[symbol] = columns
if not args.quiet and index % 50 == 0:
print(f"loaded prices: {index}/{len(symbols)}", flush=True)
finally:
await db_engine.dispose()
snapshot_stat = snapshot.stat()
cache_key = {
"version": CACHE_VERSION,
"snapshot": str(snapshot.resolve()),
"snapshot_size": snapshot_stat.st_size,
"snapshot_mtime_ns": snapshot_stat.st_mtime_ns,
"cadence": args.cadence,
"target_model": "production_gtl",
}
cache_path = Path(args.candidate_cache) if args.candidate_cache else None
qualified: list[dict] | None = None
entry_candidate_count = 0
if cache_path is not None and cache_path.exists():
with cache_path.open("rb") as handle:
cached = pickle.load(handle) # noqa: S301
if cached.get("key") == cache_key:
qualified = list(cached["qualified_candidates"])
entry_candidate_count = int(cached.get("entry_candidate_count") or 0)
if not args.quiet:
print(f"loaded candidate cache: {cache_path}", flush=True)
if qualified is None:
workers = max(1, min(int(args.workers), max(1, multiprocessing.cpu_count() - 1)))
context = bt._mp_context() or multiprocessing.get_context("spawn")
replay_rows: list[dict] = []
with ProcessPoolExecutor(max_workers=workers, mp_context=context) as pool:
futures = {
pool.submit(
bt._replay_candidates_for_period,
symbol,
columns,
recommendation_config,
activation,
benchmark_closes,
date(1900, 1, 1),
args.cadence,
True,
True,
): symbol
for symbol, columns in prices.items()
}
for index, future in enumerate(as_completed(futures), 1):
replay_rows.extend(future.result())
if not args.quiet and index % 25 == 0:
print(f"replay: {index}/{len(futures)}", flush=True)
setup_candidates = [row for row in replay_rows if not row.get("_rank_only")]
rank_observations = [
row for row in replay_rows if row.get("_universe_rank_observation")
]
entry_candidate_count = len(setup_candidates)
live_ranks = _live_universe_rank_map(
rank_observations,
benchmark_closes,
bt.STRATEGY_RANK_MOMENTUM_WEIGHT,
)
threshold = float(activation.get("min_momentum_percentile", 80.0))
qualified = []
for setup in setup_candidates:
if setup.get("direction") != "long":
continue
candidate = {
k: v
for k, v in setup.items()
if not k.startswith("_universe_")
}
rank = live_ranks.get((str(setup["symbol"]), str(setup["date"])))
if rank is None:
continue
candidate[bt.PRODUCTION_PERCENTILE_KEY] = rank["momentum_percentile"]
candidate[bt.VOL_PERCENTILE_KEY] = rank["volatility_percentile"]
candidate[bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY] = rank["strategy_rank"]
candidate["qualified"] = bt._momentum_qualifies(candidate, threshold)
if candidate["qualified"]:
qualified.append(candidate)
if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True)
with cache_path.open("wb") as handle:
pickle.dump(
{
"key": cache_key,
"entry_candidate_count": entry_candidate_count,
"qualified_candidates": qualified,
},
handle,
protocol=pickle.HIGHEST_PROTOCOL,
)
if not qualified:
raise SystemExit("No qualified candidates")
strategy = next(s for s in bt.PORTFOLIO_MONITOR_STRATEGIES if s.get("is_production"))
entry_config = bt._entry_variant_config(str(strategy["entry_variant"]))
assert entry_config is not None
ranking_key = str(entry_config.get("ranking_key") or entry_config["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_multiplier = float(exit_config.get("atr_multiplier", bt.ATR_TRAIL_MULTIPLIER))
risk_per_trade = float(entry_config["risk_per_trade"])
max_positions = int(entry_config["max_positions"])
post_stop = bt._make_gate_reset_reentry_fn(
qualified, prices, cadence=args.cadence, ranking_key=ranking_key
)
report: dict[str, Any] = {
"generated_at": datetime.now().isoformat(),
"snapshot": str(snapshot.resolve()),
"validation_split": validation_split.isoformat(),
"n_trials": N_TRIALS,
"hypothesis": (
"stale_close (signal t-1, fill t close) recovers close-fill economics; "
"the next_open haircut is scheduling, not lost edge"
),
"decision_baseline": "next_open",
"qualified_longs": len(qualified),
"arms": [],
"recovery": {},
}
_checkpoint(out_path, report)
by_id: dict[str, dict] = {}
for arm in PRE_REGISTERED_ARMS:
if not args.quiet:
print(f"running {arm['id']} ...", flush=True)
windows = []
for window_name, start, end in (
("train", None, validation_split),
("validation", validation_split, None),
("full", None, None),
):
sim = bt._simulate_portfolio(
qualified,
prices,
benchmark_closes,
exit_policy,
hold_days,
ranking_key=ranking_key,
max_positions=max_positions,
risk_per_trade=risk_per_trade,
atr_trail_multiplier=trail_multiplier,
post_stop_reentry_fn=post_stop,
start_date=start,
end_date=end,
fill_mode=str(arm["fill_mode"]),
max_entry_gap_pct=arm.get("max_entry_gap_pct"),
include_trades=True,
)
if sim is None:
windows.append({"window": window_name, "error": "no_trades"})
continue
dsr = bt.deflated_sharpe_ratio(
sim.get("sharpe"),
sim.get("sharpe_se"),
N_TRIALS,
n_returns=sim.get("n_returns"),
return_skew=sim.get("return_skew"),
return_kurtosis=sim.get("return_kurtosis"),
)
sim.pop("trade_details", None)
sim.pop("equity_curve", None)
sim.pop("benchmark_curve", None)
sim.pop("reentry_events", None)
windows.append({"window": window_name, "dsr": dsr, **sim})
row = {"id": arm["id"], "label": arm["label"], "config": {
k: arm[k] for k in arm if k not in {"id", "label"}
}, "windows": windows}
by_id[arm["id"]] = row
report["arms"].append(row)
_checkpoint(out_path, report)
if not args.quiet:
val = _window(row, "validation") or {}
print(
f" {arm['id']}: val Sharpe={val.get('sharpe')} "
f"DD={val.get('max_drawdown_pct')} trades={val.get('trades')}",
flush=True,
)
if all(k in by_id for k in ("close_control", "next_open", "stale_close")):
report["recovery"] = _grade_stale_close(
by_id["close_control"], by_id["next_open"], by_id["stale_close"]
)
_checkpoint(out_path, report)
if not args.quiet:
print(f"wrote {out_path}", flush=True)
print(f"recovery: {report.get('recovery')}", flush=True)
if __name__ == "__main__":
asyncio.run(_main())