Files
signal-platform/scripts/run_research_matrix.py
T
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

759 lines
28 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.
"""Phase-A research matrix: max-hold, vol targeting, next-open fill, corr caps.
Promotion rule (pre-registered — do not edit after a run starts)
----------------------------------------------------------------
An arm may be promoted over the close-fill production **control** only if ALL of:
1. Validation-window (entries ≥ ``--validation-split``, default 2024-07-01)
Sharpe ≥ control validation Sharpe.
2. Validation max drawdown is not worse than control by more than 2 percentage
points (higher DD is worse).
3. Train-window Sharpe is not worse than control train Sharpe (both-windows
consistency — same standard as the min_rr sweep).
4. Report whether the validation Sharpe delta exceeds 1 × SE (control or arm);
most arms will fail this distinguishability check — that is expected and is
the reason SE/PSR ship on every row. Failing the 1-SE bar does **not** alone
veto promotion under (1)(3), but it must be stated.
Naming: the post-split window is called **validation**, not "holdout". It has
been opened by prior experiments; treat it as a disciplined check, not a
pristine sample.
Arms (pre-registered; N used for Deflated Sharpe)
-------------------------------------------------
- A0 control: production gate/rank/trail, hold=30, close fill, no vol target, no corr cap
- A2 max-hold: hold ∈ {30, 45, 60, 90} (30 is the control row; listed once)
- A3 vol-target: target ∈ {15%, 20%, 25%} × clamp {[0.5,1.5], [0.25,2.0]} at lookback 60;
plus sensitivity lookbacks {20, 126} at target 20% / clamp [0.5,1.5] only
- A4 next-open fill (measurement + portfolio consequence vs control)
- A5 corr cap: threshold ∈ {0.6, 0.7, 0.8} × action ∈ {skip, half-size}
DSR uses N = number of pre-registered strategy arms in this matrix (see
``PRE_REGISTERED_ARM_IDS``). Standalone backtests do not invent a DSR.
Calendar truncation
-------------------
The simulator always cuts the equity calendar at last_signal + hold_days
(+1 for next-open). The runner asserts validation end_date ≤ last price date and
that the sim end is within hold_days+pad of the last admitted signal so a 90d
arm cannot sit in trailing flat cash.
Usage
-----
python scripts/run_research_matrix.py backtest_snapshots/prod.sqlite \\
--workers 7 --allow-spawn --candidate-cache reports/.cache/research-cands.pkl
python scripts/run_research_matrix.py ... --only a2,a3
python scripts/run_research_matrix.py ... --skip a4
"""
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,
)
CACHE_VERSION = "research-matrix-v1-daily-prod"
# Pre-registered arm catalogue (order is report order). Control is a0.
# Count N for DSR excludes pure measurement-only rows if any; every arm below
# is a portfolio book and counts.
PRE_REGISTERED_ARMS: tuple[dict[str, Any], ...] = (
{
"id": "a0_control",
"group": "a0",
"label": "Control: close fill, hold 30, risk 1%, no corr/vol",
"hold_days": 30,
"fill_mode": "close",
},
# A2 — max hold (30 is control; still emitted as a2 for the sweep table)
{"id": "a2_hold_30", "group": "a2", "label": "Max hold 30", "hold_days": 30},
{"id": "a2_hold_45", "group": "a2", "label": "Max hold 45", "hold_days": 45},
{"id": "a2_hold_60", "group": "a2", "label": "Max hold 60", "hold_days": 60},
{"id": "a2_hold_90", "group": "a2", "label": "Max hold 90", "hold_days": 90},
# A3 — vol targeting
{
"id": "a3_vt15_c05_15_lb60",
"group": "a3",
"label": "Vol target 15% clamp[0.5,1.5] lb60",
"vol_target": 0.15,
"vol_clamp": (0.5, 1.5),
"vol_lookback": 60,
},
{
"id": "a3_vt20_c05_15_lb60",
"group": "a3",
"label": "Vol target 20% clamp[0.5,1.5] lb60",
"vol_target": 0.20,
"vol_clamp": (0.5, 1.5),
"vol_lookback": 60,
},
{
"id": "a3_vt25_c05_15_lb60",
"group": "a3",
"label": "Vol target 25% clamp[0.5,1.5] lb60",
"vol_target": 0.25,
"vol_clamp": (0.5, 1.5),
"vol_lookback": 60,
},
{
"id": "a3_vt15_c025_20_lb60",
"group": "a3",
"label": "Vol target 15% clamp[0.25,2.0] lb60",
"vol_target": 0.15,
"vol_clamp": (0.25, 2.0),
"vol_lookback": 60,
},
{
"id": "a3_vt20_c025_20_lb60",
"group": "a3",
"label": "Vol target 20% clamp[0.25,2.0] lb60",
"vol_target": 0.20,
"vol_clamp": (0.25, 2.0),
"vol_lookback": 60,
},
{
"id": "a3_vt25_c025_20_lb60",
"group": "a3",
"label": "Vol target 25% clamp[0.25,2.0] lb60",
"vol_target": 0.25,
"vol_clamp": (0.25, 2.0),
"vol_lookback": 60,
},
{
"id": "a3_vt20_c05_15_lb20",
"group": "a3",
"label": "Vol target 20% clamp[0.5,1.5] lb20 (sensitivity)",
"vol_target": 0.20,
"vol_clamp": (0.5, 1.5),
"vol_lookback": 20,
},
{
"id": "a3_vt20_c05_15_lb126",
"group": "a3",
"label": "Vol target 20% clamp[0.5,1.5] lb126 (sensitivity)",
"vol_target": 0.20,
"vol_clamp": (0.5, 1.5),
"vol_lookback": 126,
},
# A4 — next-open fill
{
"id": "a4_next_open",
"group": "a4",
"label": "Next-open fill (t+1 open, stop from fill1.5 ATR)",
"fill_mode": "next_open",
},
# A5 — correlation caps
{
"id": "a5_corr06_skip",
"group": "a5",
"label": "Corr max 0.6 skip",
"corr_max": 0.6,
"corr_action": "skip",
},
{
"id": "a5_corr07_skip",
"group": "a5",
"label": "Corr max 0.7 skip",
"corr_max": 0.7,
"corr_action": "skip",
},
{
"id": "a5_corr08_skip",
"group": "a5",
"label": "Corr max 0.8 skip",
"corr_max": 0.8,
"corr_action": "skip",
},
{
"id": "a5_corr06_half",
"group": "a5",
"label": "Corr max 0.6 half-size",
"corr_max": 0.6,
"corr_action": "half_size",
},
{
"id": "a5_corr07_half",
"group": "a5",
"label": "Corr max 0.7 half-size",
"corr_max": 0.7,
"corr_action": "half_size",
},
{
"id": "a5_corr08_half",
"group": "a5",
"label": "Corr max 0.8 half-size",
"corr_max": 0.8,
"corr_action": "half_size",
},
)
PRE_REGISTERED_ARM_IDS = tuple(arm["id"] for arm in PRE_REGISTERED_ARMS)
PRE_REGISTERED_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:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("snapshot", help="SQLite backtest snapshot.")
parser.add_argument("--workers", type=int, default=6)
parser.add_argument(
"--allow-spawn",
action="store_true",
help="Allow spawn multiprocessing (needed on Windows).",
)
parser.add_argument("--out", default=None, help="JSON report path.")
parser.add_argument(
"--candidate-cache",
default=None,
help="Optional pickle cache for the daily qualified candidate set.",
)
parser.add_argument(
"--validation-split",
default="2024-07-01",
help="Train/validation entry split (YYYY-MM-DD). Validation = entries on/after.",
)
parser.add_argument(
"--only",
default=None,
help="Comma-separated arm groups or ids to run (e.g. a2,a3 or a0_control,a4_next_open).",
)
parser.add_argument(
"--skip",
default=None,
help="Comma-separated arm groups or ids to skip.",
)
parser.add_argument("--quiet", action="store_true")
parser.add_argument(
"--cadence",
choices=("daily", "weekly"),
default="daily",
help="Candidate replay cadence. Daily matches the re-entry matrix production arm.",
)
return parser.parse_args()
def _default_output_path() -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
return Path("reports") / f"research-matrix-{stamp}.json"
def _parse_selector(raw: str | None) -> set[str] | None:
if raw is None or not raw.strip():
return None
return {part.strip().lower() for part in raw.split(",") if part.strip()}
def _arm_selected(arm: dict[str, Any], only: set[str] | None, skip: set[str] | None) -> bool:
arm_id = str(arm["id"]).lower()
group = str(arm["group"]).lower()
if skip and (arm_id in skip or group in skip):
return False
if only is None:
return True
return arm_id in only or group in only
def _write_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)
md_path = path.with_suffix(".md")
md_path.write_text(_markdown_table(report), encoding="utf-8")
def _markdown_table(report: dict) -> str:
lines = [
f"# Research matrix — {report.get('generated_at', '')}",
"",
f"Validation split: **{report.get('validation_split')}**. "
f"Pre-registered N for DSR: **{report.get('n_trials')}**.",
"",
"## Promotion rule",
"",
report.get("promotion_rule", ""),
"",
"## Arms",
"",
"| arm | window | Sharpe | SE | PSR | DSR | CAGR | MaxDD | Calmar | trades | avg scalar |",
"|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|",
]
for arm in report.get("arms") or []:
for window_row in arm.get("windows") or []:
lines.append(
"| {arm} | {window} | {sharpe} | {se} | {psr} | {dsr} | {cagr} | {dd} | {calmar} | {trades} | {scalar} |".format(
arm=arm.get("id"),
window=window_row.get("window"),
sharpe=_fmt(window_row.get("sharpe")),
se=_fmt(window_row.get("sharpe_se")),
psr=_fmt(window_row.get("psr")),
dsr=_fmt(window_row.get("dsr")),
cagr=_fmt(window_row.get("cagr_pct")),
dd=_fmt(window_row.get("max_drawdown_pct")),
calmar=_fmt(window_row.get("calmar")),
trades=_fmt(window_row.get("trades")),
scalar=_fmt(window_row.get("avg_vol_scalar")),
)
)
promo = report.get("promotion") or {}
lines.extend(["", "## Promotion decisions", ""])
if not promo:
lines.append("_No arms graded yet._")
else:
for arm_id, decision in promo.items():
lines.append(
f"- **{arm_id}**: {'PROMOTE' if decision.get('promote') else 'reject'} — "
f"{decision.get('reason')}"
)
lines.append("")
return "\n".join(lines)
def _fmt(value: Any) -> str:
if value is None:
return "—"
if isinstance(value, float):
return f"{value:.3g}"
return str(value)
def _grade_promotion(control: dict, arm: dict, se_ref: float | None) -> dict:
"""Apply the pre-registered promotion rule. control/arm are arm result dicts."""
c_val = _window(control, "validation")
a_val = _window(arm, "validation")
c_train = _window(control, "train")
a_train = _window(arm, "train")
if not c_val or not a_val or not c_train or not a_train:
return {"promote": False, "reason": "missing train/validation rows"}
c_s = c_val.get("sharpe")
a_s = a_val.get("sharpe")
c_dd = c_val.get("max_drawdown_pct")
a_dd = a_val.get("max_drawdown_pct")
c_ts = c_train.get("sharpe")
a_ts = a_train.get("sharpe")
if None in (c_s, a_s, c_dd, a_dd, c_ts, a_ts):
return {"promote": False, "reason": "missing Sharpe/DD on a required window"}
delta = float(a_s) - float(c_s)
se = se_ref
if se is None:
se = a_val.get("sharpe_se") or c_val.get("sharpe_se")
exceeds_1se = se is not None and abs(delta) > float(se)
checks = {
"validation_sharpe_ge_control": float(a_s) >= float(c_s),
"validation_dd_not_worse_by_2pp": float(a_dd) <= float(c_dd) + 2.0,
"train_sharpe_not_worse": float(a_ts) >= float(c_ts),
"delta_exceeds_1se": exceeds_1se,
"validation_sharpe_delta": round(delta, 4),
"se_used": se,
}
promote = (
checks["validation_sharpe_ge_control"]
and checks["validation_dd_not_worse_by_2pp"]
and checks["train_sharpe_not_worse"]
)
if promote:
reason = (
f"validation Sharpe {a_s} ≥ control {c_s}; "
f"DD {a_dd} within +2pp of {c_dd}; train Sharpe {a_ts}{c_ts}"
)
if not exceeds_1se:
reason += " (delta ≤ 1 SE — distinguishable noise bar not cleared)"
else:
reason += " (delta > 1 SE)"
else:
failed = [k for k, v in checks.items() if k.startswith(("validation", "train")) and v is False]
reason = "failed: " + ", ".join(failed) if failed else "failed promotion checks"
return {"promote": promote, "reason": reason, "checks": checks}
def _window(arm_result: dict, name: str) -> dict | None:
for row in arm_result.get("windows") or []:
if row.get("window") == name:
return row
return None
def _assert_calendar_truncation(sim: dict, hold_days: int, fill_mode: str) -> None:
"""Guard against trailing flat-cash after the last resolvable signal."""
start = sim.get("start_date")
end = sim.get("end_date")
if not start or not end:
return
# Soft check: book span should not massively exceed hold window beyond data needs.
# Hard assert lives on entry-end vs sim end when trade_details present.
details = sim.get("trade_details") or []
if not details:
return
last_entry = max(date.fromisoformat(t["entry_date"]) for t in details)
sim_end = date.fromisoformat(str(end))
pad = hold_days + (1 if fill_mode == "next_open" else 0)
# Allow calendar days ≈ trading-day pad with weekend slack (2×).
max_slack_days = pad * 2 + 5
if (sim_end - last_entry).days > max_slack_days:
raise AssertionError(
f"calendar truncation failed: last entry {last_entry} but sim end "
f"{sim_end} (hold_days={hold_days}, fill_mode={fill_mode})"
)
async def _main() -> None:
args = _parse_args()
snapshot = Path(args.snapshot)
if not snapshot.exists():
raise SystemExit(f"Snapshot not found: {snapshot}")
if args.workers < 1:
raise SystemExit("--workers must be positive")
only = _parse_selector(args.only)
skip = _parse_selector(args.skip)
validation_split = date.fromisoformat(args.validation_split)
out_path = Path(args.out) if args.out else _default_output_path()
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()
if not prices:
raise SystemExit("No price columns loaded from snapshot")
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
fip_signal_eval: list[dict] | None = None
if cache_path is not None and cache_path.exists():
with cache_path.open("rb") as handle:
cached = pickle.load(handle) # noqa: S301 - trusted local cache
if cached.get("key") == cache_key:
qualified = list(cached["qualified_candidates"])
entry_candidate_count = int(cached["entry_candidate_count"])
fip_signal_eval = cached.get("fip_signal_eval")
if not args.quiet:
print(f"loaded candidate cache: {cache_path}", flush=True)
elif not args.quiet:
print(f"candidate cache mismatch; rebuilding: {cache_path}", flush=True)
if qualified is None:
replay_start = date(1900, 1, 1)
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,
replay_start,
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)} tickers", 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-universe ranking (same semantics as run_daily_reentry_matrix).
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 = {
key: value
for key, value in setup.items()
if not key.startswith("_universe_")
}
identity = (str(setup["symbol"]), str(setup["date"]))
rank = live_ranks.get(identity)
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)
# fip_id fingerprint via the shared weekly signal harness.
collected: dict = {}
for symbol, columns in prices.items():
# Rebuild minimal records for signal eval from column arrays.
ords, _o, highs, _l, closes, _v = columns
records = [
type("R", (), {"date": date.fromordinal(int(ords[i])), "close": closes[i], "high": highs[i]})()
for i in range(len(ords))
]
series = bt._signal_series(records, benchmark_closes)
for name, weeks in series.items():
bucket = collected.setdefault(name, {})
for week_key, pairs in weeks.items():
bucket.setdefault(week_key, []).extend(pairs)
fip_signal_eval = [
row for row in bt._signal_evaluation(collected) if row.get("signal") == "fip_id"
]
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,
"fip_signal_eval": fip_signal_eval,
},
handle,
protocol=pickle.HIGHEST_PROTOCOL,
)
if not args.quiet:
print(f"wrote candidate cache: {cache_path}", flush=True)
if not qualified:
raise SystemExit("No qualified long candidates after replay")
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"]))
if entry_config is None:
raise RuntimeError("Production entry configuration missing")
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"
)
default_hold = 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_reentry_fn = bt._make_gate_reset_reentry_fn(
qualified,
prices,
cadence=args.cadence,
ranking_key=ranking_key,
)
selected_arms = [
arm for arm in PRE_REGISTERED_ARMS if _arm_selected(arm, only, skip)
]
# Always include control when grading promotions for non-control arms.
if selected_arms and not any(a["id"] == "a0_control" for a in selected_arms):
if only is None or "a0" in (only or set()) or "a0_control" in (only or set()):
pass
else:
# Force control into the run for comparison baselines.
control_arm = next(a for a in PRE_REGISTERED_ARMS if a["id"] == "a0_control")
selected_arms = [control_arm, *selected_arms]
if not selected_arms:
raise SystemExit("No arms selected — check --only / --skip")
report: dict[str, Any] = {
"generated_at": datetime.now().isoformat(),
"snapshot": str(snapshot.resolve()),
"cadence": args.cadence,
"validation_split": validation_split.isoformat(),
"n_trials": PRE_REGISTERED_N_TRIALS,
"pre_registered_arm_ids": list(PRE_REGISTERED_ARM_IDS),
"selected_arm_ids": [a["id"] for a in selected_arms],
"entry_candidate_count": entry_candidate_count,
"qualified_longs": len(qualified),
"promotion_rule": (
"Promote only if validation Sharpe ≥ control, validation DD not worse "
"by >2pp, and train Sharpe not worse. Always report whether validation "
"Sharpe delta exceeds 1 SE (expect most will not)."
),
"fip_id_fingerprint": fip_signal_eval,
"arms": [],
"promotion": {},
}
_write_checkpoint(out_path, report)
control_result: dict | None = None
def run_arm(arm: dict[str, Any]) -> dict:
hold_days = int(arm.get("hold_days", default_hold))
fill_mode = str(arm.get("fill_mode", bt.FILL_MODE_CLOSE))
windows: list[dict] = []
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_reentry_fn,
start_date=start,
end_date=end,
fill_mode=fill_mode,
vol_target=arm.get("vol_target"),
vol_lookback=int(arm.get("vol_lookback", bt.VOL_TARGET_LOOKBACK_HEADLINE)),
vol_clamp=tuple(arm.get("vol_clamp", bt.VOL_TARGET_CLAMP_HEADLINE)),
corr_max=arm.get("corr_max"),
corr_action=str(arm.get("corr_action", "skip")),
include_trades=True,
)
if sim is None:
windows.append({"window": window_name, "error": "no_trades"})
continue
_assert_calendar_truncation(sim, hold_days, fill_mode)
dsr = bt.deflated_sharpe_ratio(
sim.get("sharpe"),
sim.get("sharpe_se"),
PRE_REGISTERED_N_TRIALS,
n_returns=sim.get("n_returns"),
return_skew=sim.get("return_skew"),
return_kurtosis=sim.get("return_kurtosis"),
)
# Drop heavy trade lists from the checkpointed JSON.
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})
return {
"id": arm["id"],
"group": arm["group"],
"label": arm["label"],
"config": {
key: arm[key]
for key in arm
if key not in {"id", "group", "label"}
},
"windows": windows,
}
for arm in selected_arms:
if not args.quiet:
print(f"running arm {arm['id']} ...", flush=True)
result = run_arm(arm)
report["arms"].append(result)
if arm["id"] == "a0_control":
control_result = result
elif control_result is not None:
report["promotion"][arm["id"]] = _grade_promotion(
control_result, result, None
)
_write_checkpoint(out_path, report)
if not args.quiet:
val = _window(result, "validation") or {}
print(
f" done {arm['id']}: validation Sharpe={val.get('sharpe')} "
f"DD={val.get('max_drawdown_pct')} trades={val.get('trades')}",
flush=True,
)
# Re-grade all arms once control is known (handles --only without ordering issues).
if control_result is not None:
for result in report["arms"]:
if result["id"] == "a0_control":
continue
report["promotion"][result["id"]] = _grade_promotion(
control_result, result, None
)
_write_checkpoint(out_path, report)
if not args.quiet:
print(f"wrote {out_path}", flush=True)
print(f"wrote {out_path.with_suffix('.md')}", flush=True)
fip = (fip_signal_eval or [{}])[0] if fip_signal_eval else {}
if fip:
print(
f"fip_id fingerprint: mean_ic={fip.get('mean_ic')} "
f"t={fip.get('ic_t_stat')} (target ≈ -0.045 / -2.9)",
flush=True,
)
if __name__ == "__main__":
asyncio.run(_main())