Files
signal-platform/scripts/run_daily_reentry_matrix.py
T

862 lines
34 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))
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"
def _period_percentiles(
observations: list[dict], value_key: str
) -> dict[tuple[str, str], float]:
"""Production-style percentiles, one deterministic symbol row per period."""
by_period: dict[tuple, list[dict]] = {}
seen: set[tuple[str, str]] = set()
for row in observations:
identity = (str(row["symbol"]), str(row["date"]))
if identity in seen:
raise ValueError(f"Duplicate universe rank observation: {identity}")
seen.add(identity)
if row.get(value_key) is None:
continue
period = tuple(row["ranking_period"])
by_period.setdefault(period, []).append(row)
result: dict[tuple[str, str], float] = {}
for group in by_period.values():
ordered = sorted(
group,
key=lambda row: (float(row[value_key]), str(row["symbol"])),
)
denominator = len(ordered) - 1
for rank, row in enumerate(ordered):
result[(str(row["symbol"]), str(row["date"]))] = round(
rank / denominator * 100.0 if denominator > 0 else 100.0,
2,
)
return result
def _live_universe_rank_map(
observations: list[dict],
benchmark_closes: dict[date, float],
momentum_weight: float,
) -> dict[tuple[str, str], dict[str, float | None]]:
"""Historical equivalent of ``compute_activation_ranks``.
Every ticker contributes at most once per session. Residual momentum starts
only once 252 benchmark closes were point-in-time available; earlier dates
use the same raw-momentum fallback as production.
"""
identities = [(str(row["symbol"]), str(row["date"])) for row in observations]
if len(identities) != len(set(identities)):
raise ValueError("Universe ranking requires one observation per ticker/date")
raw_pct = _period_percentiles(observations, "momentum")
residual_pct = _period_percentiles(observations, "residual_momentum")
vol_pct = _period_percentiles(observations, "vol_6m")
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
for row in observations:
identity = (str(row["symbol"]), str(row["date"]))
asof_ord = date.fromisoformat(identity[1]).toordinal()
momentum_pct = (
residual_pct.get(identity)
if residual_start_ord is not None and asof_ord >= residual_start_ord
else raw_pct.get(identity)
)
volatility_pct = vol_pct.get(identity)
strategy_rank = (
round(
momentum_pct * momentum_weight
+ volatility_pct * (1.0 - momentum_weight),
2,
)
if momentum_pct is not None and volatility_pct is not None
else momentum_pct
)
ranks[identity] = {
"momentum_percentile": momentum_pct,
"volatility_percentile": volatility_pct,
"strategy_rank": strategy_rank,
}
return ranks
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())