Files
signal-platform/scripts/run_daily_reentry_matrix.py
T
dennisthiessenandClaude Opus 5 f5d4b516ab
Deploy / lint (push) Successful in 10s
Deploy / test (push) Successful in 1m21s
Deploy / deploy (push) Successful in 37s
docs: land capacity-study evidence and share the rank-map helper
Brings the durable artifacts of research/portfolio-capacity-rebalancing onto
main so the rationale for raising the count cap lives with the code that cites
it. The matrix runner, the research simulator hooks and the study's unit tests
are deliberately left behind; they remain at tag research/portfolio-capacity-final.

Corrects conclusions that were reached on EV per trade and are now superseded:
the findings doc's decisions 1 (keep cap 10) and 4 (run the risk-floor A/B) are
struck through and answered in a new correction section, and the research README
and phase-A matrix entries are updated to match. The frozen specification itself
is untouched -- its recorded SHA-256 f1e37783 still verifies.

effective-risk-floor-ab.md is retained but marked CLOSED/NEGATIVE: the study it
proposes is already answered by cap15 vs cash_unbounded (-0.753pp CAGR while
EV/trade rises), and its EV-based pass rule would have shipped it.

scripts/research_rankings.py replaces a fourth copy of the historical rank-map
helper; run_research_matrix, run_execution_recovery_matrix and
run_daily_reentry_matrix now share it. The shared version adds a duplicate
observation guard and a deterministic symbol tie-break the copies lacked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:08:29 +02:00

788 lines
31 KiB
Python

"""Run the full daily post-stop re-entry study from one candidate replay.
The expensive point-in-time setup replay happens once. The result is ranked in
both the existing backtest candidate universe and a live-like one-row-per-ticker
universe. Every policy, lookback, transaction-cost, capacity, and holdout arm is
then evaluated under both ranking modes.
"""
from __future__ import annotations
import argparse
import asyncio
import copy
import json
import multiprocessing
import os
import pickle
import sys
from collections import Counter
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,
_period_percentiles,
)
POLICY_NAMES = (
"immediate",
"next_session",
"cooldown_2",
"cooldown_3",
"cooldown_5",
"gate_reset",
"strict_gate_reset",
"gate_reset_improved",
"two_session_confirmation",
)
RANKING_MODES = ("backtest_legacy", "live_universe")
CACHE_VERSION = "daily-reentry-matrix-v3-dual-ranking"
def _sqlite_url(path: Path) -> str:
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("snapshot", help="SQLite backtest snapshot.")
parser.add_argument(
"--start-date",
default=None,
help="Optional earliest replay/simulation date (YYYY-MM-DD).",
)
parser.add_argument("--workers", type=int, default=6)
parser.add_argument("--out", default=None)
parser.add_argument(
"--candidate-cache",
default=None,
help=(
"Optional pickle cache. It stores both ranked, long-only qualified "
"candidate sets, not the much larger raw replay."
),
)
parser.add_argument(
"--policies",
nargs="+",
choices=POLICY_NAMES,
default=list(POLICY_NAMES),
)
parser.add_argument(
"--ranking-modes",
nargs="+",
choices=RANKING_MODES,
default=list(RANKING_MODES),
help=(
"backtest_legacy reproduces the existing directional-candidate "
"ranking; live_universe ranks each ticker once per session."
),
)
parser.add_argument("--base-cost-per-side-pct", type=float, default=0.1)
parser.add_argument("--base-capacity", type=int, default=10)
parser.add_argument(
"--costs-per-side-pct",
type=float,
nargs="+",
default=[0.1, 0.2, 0.3],
)
parser.add_argument(
"--capacities", type=int, nargs="+", default=[5, 10, 15]
)
parser.add_argument(
"--holdout-split",
default="2025-01-01",
help="Train/test split date (YYYY-MM-DD), or 'none' to disable.",
)
parser.add_argument("--quiet", action="store_true")
return parser.parse_args()
def _default_output_path() -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
return Path("reports") / f"daily-reentry-matrix-{stamp}.json"
class PrecomputedDailyEngine:
"""Exact date/symbol lookup over the already-ranked production gate."""
def __init__(self, qualified_candidates: list[dict]) -> None:
self.by_key = {
(row["symbol"], date.fromisoformat(row["date"]).toordinal()): row
for row in qualified_candidates
}
def candidate(self, symbol: str, asof_ord: int) -> dict | None:
row = self.by_key.get((symbol, asof_ord))
return dict(row) if row is not None else None
class ReentryPolicy:
"""Stateful policy evaluated after every initial-stop exit."""
def __init__(
self,
name: str,
engine: PrecomputedDailyEngine,
ranking_key: str,
) -> None:
if name not in POLICY_NAMES:
raise ValueError(f"Unknown re-entry policy: {name}")
self.name = name
self.engine = engine
self.ranking_key = ranking_key
self.checks = 0
self.gate_passes = 0
self.emitted = Counter()
def __call__(
self,
symbol: str,
asof_ord: int,
state: dict,
_bar: Any,
) -> dict | None:
self.checks += 1
sessions = int(state["sessions_since_stop"])
candidate = self.engine.candidate(symbol, asof_ord)
if candidate is None:
# The strict reset deliberately ignores the stop-day gate state:
# it requires a later completed session to go unqualified before
# any requalification can trigger a new entry.
if self.name != "strict_gate_reset" or sessions >= 1:
state["gate_went_unqualified"] = True
state["qualified_streak"] = 0
return None
self.gate_passes += 1
# Two-session confirmation means two complete post-stop closes. The
# stop day's close (sessions=0) deliberately does not count.
if self.name == "two_session_confirmation" and sessions == 0:
state["qualified_streak"] = 0
return None
state["qualified_streak"] = int(state.get("qualified_streak", 0)) + 1
reason: str | None = None
if self.name == "immediate":
reason = "gate_still_or_again_qualified"
elif self.name == "next_session":
if sessions >= 1:
reason = "stop_day_block_complete"
elif self.name.startswith("cooldown_"):
cooldown_sessions = int(self.name.removeprefix("cooldown_"))
if sessions >= cooldown_sessions:
reason = f"{cooldown_sessions}_session_cooldown_complete"
elif self.name == "gate_reset":
if state["gate_went_unqualified"]:
reason = "gate_failed_then_requalified"
elif self.name == "strict_gate_reset":
if state["gate_went_unqualified"]:
reason = "post_stop_gate_failed_then_requalified"
elif self.name == "gate_reset_improved":
previous_rank = state.get("previous_rank")
current_rank = candidate.get(self.ranking_key)
rank_not_weaker = (
current_rank is not None
and (
previous_rank is None
or float(current_rank) >= float(previous_rank)
)
)
if (
state["gate_went_unqualified"]
and float(candidate["stop"]) > float(state["previous_stop"])
and rank_not_weaker
):
reason = "gate_reset_with_improved_stop_and_rank"
elif self.name == "two_session_confirmation":
if state["qualified_streak"] >= 2:
reason = "two_qualified_post_stop_closes"
if reason is None:
return None
emitted = dict(candidate)
emitted["_reentry_reason"] = reason
self.emitted[reason] += 1
return emitted
def summary(self) -> dict:
return {
"daily_checks": self.checks,
"qualified_checks": self.gate_passes,
"emitted_candidates_by_reason": dict(self.emitted),
}
def _trade_summary(trades: list[dict]) -> dict:
reentries = [trade for trade in trades if trade.get("is_reentry")]
waits = [
int(trade["reentry_wait_sessions"])
for trade in reentries
if trade.get("reentry_wait_sessions") is not None
]
return {
"transaction_cost": round(
sum(float(trade["transaction_cost"]) for trade in trades), 2
),
"reentry_trades": len(reentries),
"same_day_reentries": sum(wait == 0 for wait in waits),
"next_session_reentries": sum(wait == 1 for wait in waits),
"reentries_within_5_sessions": sum(wait <= 5 for wait in waits),
"avg_reentry_wait_sessions": (
round(sum(waits) / len(waits), 1) if waits else None
),
"reentry_win_rate": (
round(
sum(float(trade["pnl"]) > 0 for trade in reentries)
/ len(reentries)
* 100.0,
1,
)
if reentries
else None
),
"reentry_total_pnl": round(
sum(float(trade["pnl"]) for trade in reentries), 2
),
}
def _parse_optional_date(value: str | None, option: str) -> date | None:
if value is None or value.strip().lower() == "none":
return None
try:
return date.fromisoformat(value)
except ValueError as exc:
raise SystemExit(f"{option} must use YYYY-MM-DD or 'none'") from exc
def _max_date(left: date | None, right: date | None) -> date | None:
if left is None:
return right
if right is None:
return left
return max(left, right)
async def _main() -> None:
args = _parse_args()
snapshot = Path(args.snapshot)
if not snapshot.exists():
raise SystemExit(f"Snapshot not found: {snapshot}")
requested_start = _parse_optional_date(args.start_date, "--start-date")
holdout_split = _parse_optional_date(args.holdout_split, "--holdout-split")
if args.workers < 1:
raise SystemExit("--workers must be positive")
if args.base_capacity < 1 or any(value < 1 for value in args.capacities):
raise SystemExit("capacities must be positive")
all_costs = sorted(
set([args.base_cost_per_side_pct, *args.costs_per_side_pct])
)
if any(value < 0 or value >= 100 for value in all_costs):
raise SystemExit("cost percentages must be in [0, 100)")
all_capacities = sorted(set([args.base_capacity, *args.capacities]))
policies = tuple(dict.fromkeys(args.policies))
ranking_modes = tuple(dict.fromkeys(args.ranking_modes))
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
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 = [ticker.symbol for ticker 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()
replay_start = requested_start or date(1900, 1, 1)
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,
"start_date": replay_start.isoformat(),
"cadence": "daily",
"target_model": "production_gtl",
}
cache_path = Path(args.candidate_cache) if args.candidate_cache else None
qualified_candidates_by_mode: dict[str, list[dict]] | None = None
entry_candidate_count = 0
entry_candidates_by_direction: dict[str, int] = {}
universe_rank_observations = 0
last_eligible_replay_date: date | 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_candidates_by_mode = {
mode: list(rows)
for mode, rows in cached["qualified_candidates_by_mode"].items()
}
entry_candidate_count = int(cached["entry_candidate_count"])
entry_candidates_by_direction = dict(
cached["entry_candidates_by_direction"]
)
universe_rank_observations = int(cached["universe_rank_observations"])
last_eligible_replay_date = date.fromisoformat(
cached["last_eligible_replay_date"]
)
if not args.quiet:
print(f"loaded qualified candidate cache: {cache_path}", flush=True)
elif not args.quiet:
print(f"candidate cache mismatch; rebuilding: {cache_path}", flush=True)
if qualified_candidates_by_mode is None:
replay_rows: list[dict] = []
workers = max(1, min(int(args.workers), multiprocessing.cpu_count() - 1))
context = bt._mp_context() or multiprocessing.get_context("spawn")
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,
"daily",
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"daily 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)
entry_candidates_by_direction = dict(
Counter(row["direction"] for row in setup_candidates)
)
universe_rank_observations = len(rank_observations)
last_eligible_replay_date = max(
date.fromisoformat(row["date"]) for row in rank_observations
)
# Existing research-backtest semantics: rank every directional setup
# candidate, then apply the long-only production gate.
bt._assign_momentum_percentiles(setup_candidates)
bt._assign_residual_momentum_percentiles(setup_candidates)
bt._assign_low_volatility_percentiles(setup_candidates)
bt._assign_activation_momentum_percentiles(setup_candidates)
bt._assign_residual_high_vol_blend(setup_candidates)
threshold = float(activation.get("min_momentum_percentile", 80.0))
for candidate in setup_candidates:
candidate["qualified"] = bt._momentum_qualifies(candidate, threshold)
legacy_qualified = [
{
key: value
for key, value in candidate.items()
if not key.startswith("_universe_")
}
for candidate in setup_candidates
if candidate["qualified"] and candidate.get("direction") == "long"
]
# Live semantics: rank each ticker once per session, independent of
# whether it has a setup, and attach that ticker rank only to longs.
live_ranks = _live_universe_rank_map(
rank_observations,
benchmark_closes,
bt.STRATEGY_RANK_MOMENTUM_WEIGHT,
)
live_qualified: list[dict] = []
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_")
}
rank = live_ranks[(str(setup["symbol"]), str(setup["date"]))]
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"]:
live_qualified.append(candidate)
qualified_candidates_by_mode = {
"backtest_legacy": legacy_qualified,
"live_universe": live_qualified,
}
del replay_rows, setup_candidates, rank_observations, live_ranks
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,
"entry_candidates_by_direction": (
entry_candidates_by_direction
),
"universe_rank_observations": universe_rank_observations,
"last_eligible_replay_date": (
last_eligible_replay_date.isoformat()
),
"qualified_candidates_by_mode": (
qualified_candidates_by_mode
),
},
handle,
protocol=pickle.HIGHEST_PROTOCOL,
)
if not args.quiet:
print(f"wrote qualified candidate cache: {cache_path}", flush=True)
if last_eligible_replay_date is None or qualified_candidates_by_mode is None:
raise RuntimeError("Daily replay produced no ranking observations")
for mode in ranking_modes:
if not qualified_candidates_by_mode.get(mode):
raise RuntimeError(f"Daily replay produced no qualified candidates for {mode}")
strategy = next(
row for row in bt.PORTFOLIO_MONITOR_STRATEGIES if row.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"]
)
threshold = float(activation.get("min_momentum_percentile", 80.0))
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", max(bt.TIME_EXIT_DAYS)))
trail_multiplier = float(
exit_config.get("atr_multiplier", bt.ATR_TRAIL_MULTIPLIER)
)
if ranking_key != bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY:
raise RuntimeError(
"Daily matrix expects the production 80/20 strategy ranking key"
)
simulation_prices = prices
last_candidate_date = last_eligible_replay_date
latest_ord = max(max(columns[0]) for columns in simulation_prices.values())
latest_date = date.fromordinal(latest_ord)
if holdout_split is not None and not (
(requested_start or date.min) < holdout_split <= latest_date
):
raise SystemExit(
f"--holdout-split must be after the start and no later than {latest_date}"
)
total_completed_sims = 0
def run_ranking_mode(mode: str, qualified_candidates: list[dict]) -> dict:
nonlocal total_completed_sims
daily_engine = PrecomputedDailyEngine(qualified_candidates)
run_cache: dict[tuple, dict] = {}
completed_sims = 0
def run_policy(
policy_name: str,
*,
start_date: date | None,
end_date: date | None,
cost_pct: float,
capacity: int,
) -> dict:
nonlocal completed_sims, total_completed_sims
key = (policy_name, start_date, end_date, float(cost_pct), int(capacity))
if key in run_cache:
return copy.deepcopy(run_cache[key])
policy = ReentryPolicy(policy_name, daily_engine, ranking_key)
sim = bt._simulate_portfolio(
qualified_candidates,
simulation_prices,
benchmark_closes,
exit_policy,
hold_days,
ranking_key=ranking_key,
max_positions=capacity,
risk_per_trade=float(entry_config["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier,
cost_per_side=cost_pct / 100.0,
start_date=start_date,
end_date=end_date,
post_stop_reentry_fn=policy,
include_trades=True,
)
if sim is None:
raise RuntimeError(f"Policy {policy_name} produced no trades in {mode}")
trades = list(sim.pop("trade_details"))
events = list(sim.pop("reentry_events", []))
row = {
**sim,
"turnover": _trade_summary(trades),
"policy": policy.summary(),
"reentry_events": events,
}
run_cache[key] = row
completed_sims += 1
total_completed_sims += 1
if not args.quiet:
print(
f"portfolio simulations: {total_completed_sims} "
f"({mode}, {policy_name}, cost={cost_pct}%, capacity={capacity})",
flush=True,
)
return copy.deepcopy(row)
primary: list[dict] = []
for lookback in bt.PORTFOLIO_MONITOR_LOOKBACKS:
lookback_start = bt._lookback_start(latest_ord, lookback["days"])
sim_start = _max_date(requested_start, lookback_start)
for policy_name in policies:
row = run_policy(
policy_name,
start_date=sim_start,
end_date=None,
cost_pct=args.base_cost_per_side_pct,
capacity=args.base_capacity,
)
if lookback["lookback"] != "all":
row.pop("reentry_events", None)
primary.append({
"arm": policy_name,
"lookback": lookback["lookback"],
"lookback_label": lookback["label"],
"capacity": args.base_capacity,
**row,
})
immediate_baseline_parity: dict
if "immediate" in policies:
direct_daily_baseline = bt._simulate_portfolio(
qualified_candidates,
simulation_prices,
benchmark_closes,
exit_policy,
hold_days,
ranking_key=ranking_key,
max_positions=args.base_capacity,
risk_per_trade=float(entry_config["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier,
cost_per_side=args.base_cost_per_side_pct / 100.0,
start_date=requested_start,
)
if direct_daily_baseline is None:
raise RuntimeError(
f"Direct daily no-lockdown baseline produced no trades in {mode}"
)
immediate_all = next(
row
for row in primary
if row["lookback"] == "all" and row["arm"] == "immediate"
)
parity_fields = tuple(sorted(direct_daily_baseline))
parity_differences = {
field: {
"direct_daily_baseline": direct_daily_baseline.get(field),
"immediate_callback": immediate_all.get(field),
}
for field in parity_fields
if direct_daily_baseline.get(field) != immediate_all.get(field)
}
if parity_differences:
raise RuntimeError(
f"Immediate callback diverges in {mode}: {parity_differences}"
)
immediate_baseline_parity = {
"passed": True,
"compared_fields": list(parity_fields),
"direct_daily_baseline": direct_daily_baseline,
}
else:
immediate_baseline_parity = {
"passed": None,
"skipped": "immediate policy was not selected",
}
robustness: list[dict] = []
for cost_pct in all_costs:
for capacity in all_capacities:
for policy_name in policies:
row = run_policy(
policy_name,
start_date=requested_start,
end_date=None,
cost_pct=cost_pct,
capacity=capacity,
)
row.pop("reentry_events", None)
robustness.append({
"arm": policy_name,
"lookback": "all",
"cost_per_side_pct_requested": cost_pct,
"capacity": capacity,
**row,
})
holdout: list[dict] = []
if holdout_split is not None:
for segment, segment_start, segment_end in (
("train", requested_start, holdout_split),
("test", _max_date(requested_start, holdout_split), None),
):
for policy_name in policies:
row = run_policy(
policy_name,
start_date=segment_start,
end_date=segment_end,
cost_pct=args.base_cost_per_side_pct,
capacity=args.base_capacity,
)
row.pop("reentry_events", None)
holdout.append({
"arm": policy_name,
"segment": segment,
"split_date": holdout_split.isoformat(),
"capacity": args.base_capacity,
**row,
})
return {
"description": (
"Existing historical backtest approximation: directional setup "
"candidates form the rank cross-section; shorts never qualify."
if mode == "backtest_legacy"
else "Live-like historical rank: every ticker contributes once per "
"session before the long-only setup gate is applied."
),
"qualified_candidates": len(qualified_candidates),
"tickers_qualified": len({row["symbol"] for row in qualified_candidates}),
"primary_lookback_matrix": primary,
"immediate_baseline_parity": immediate_baseline_parity,
"cost_capacity_robustness": robustness,
"holdout": holdout,
"portfolio_simulations_executed": completed_sims,
}
ranking_results = {
mode: run_ranking_mode(mode, qualified_candidates_by_mode[mode])
for mode in ranking_modes
}
output = Path(args.out) if args.out else _default_output_path()
report = {
"generated_at": datetime.now().astimezone().isoformat(),
"snapshot": str(snapshot.resolve()),
"period_start_requested": (
requested_start.isoformat() if requested_start else None
),
"last_eligible_candidate_date": last_candidate_date.isoformat(),
"portfolio_asof_date": latest_date.isoformat(),
"tickers_loaded": len(prices),
"entry_candidates": entry_candidate_count,
"entry_candidates_by_direction": entry_candidates_by_direction,
"universe_rank_observations": universe_rank_observations,
"qualified_candidates_by_ranking_mode": {
mode: len(qualified_candidates_by_mode[mode]) for mode in ranking_modes
},
"params": {
"entry_cadence": "daily",
"target_model": "production_gtl",
"ranking_modes": list(ranking_modes),
"policies": list(policies),
"base_cost_per_side_pct": args.base_cost_per_side_pct,
"base_capacity": args.base_capacity,
"robustness_costs_per_side_pct": all_costs,
"robustness_capacities": all_capacities,
"holdout_split": (
holdout_split.isoformat() if holdout_split else None
),
"setup_stop_atr_multiplier": bt.ATR_MULTIPLIER,
"exit_policy": exit_policy,
"exit_atr_multiplier": trail_multiplier,
"hold_days": hold_days,
"risk_per_trade": float(entry_config["risk_per_trade"]),
"momentum_percentile_floor": threshold,
"ranking_key": ranking_key,
},
"ranking_results": ranking_results,
"portfolio_simulations_executed": total_completed_sims,
"validation_simulations_executed": (
len(ranking_modes) if "immediate" in policies else 0
),
"note": (
"The expensive point-in-time daily setup replay is executed once. "
"backtest_legacy preserves the existing candidate-rank approximation; "
"live_universe ranks every ticker once per session like production. "
"Both modes remain strictly long-only after ranking and then run the "
"same policy, lookback, cost, capacity, and holdout matrix. Each "
"immediate callback must match its direct no-lockdown simulation exactly. "
"Immediate is the daily no-lockdown baseline; next_session blocks only "
"the stop day; cooldown_N permits re-entry at wait_sessions=N; gate_reset "
"counts the stop-day gate state, while strict_gate_reset requires an "
"unqualified close on a later completed session before requalification; "
"gate_reset_improved additionally requires a higher stop and a non-weaker "
"production rank; two_session_confirmation requires two consecutive "
"qualified post-stop closes and excludes the stop day's close. Transaction "
"costs alter cash and position sizing, not just reported P&L."
),
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(f"Report written: {output}")
for mode, mode_result in ranking_results.items():
print(f"Ranking mode: {mode}")
for row in mode_result["primary_lookback_matrix"]:
if row["lookback"] == "all":
print(
f" {row['arm']}: Sharpe {row['sharpe']}, "
f"CAGR {row['cagr_pct']}%, DD {row['max_drawdown_pct']}%, "
f"trades {row['trades']}, "
f"reentries {row['turnover']['reentry_trades']}, "
f"fees ${row['turnover']['transaction_cost']}"
)
if __name__ == "__main__":
asyncio.run(_main())