"""Targeted offline study of a gate-conditioned initial-stop refresh. The study replays production entries only for the requested period. Whenever an initial stop is touched, it rebuilds that ticker's setup using bars through the previous close and recomputes the production momentum gate across the whole historical universe. If the gate still passes and the new setup has a lower valid stop, the simulator adopts it and checks it against the same day's low. This is causal: no value from the stop day's eventual close is used to cancel an intraday stop. The snapshot is read-only and no live settings are changed. """ from __future__ import annotations import argparse import asyncio import bisect import json import multiprocessing import os import sys from collections import Counter from concurrent.futures import ProcessPoolExecutor, as_completed from datetime import date, datetime from pathlib import Path from types import SimpleNamespace 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)) 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") parser.add_argument("--start-date", default="2024-07-01") parser.add_argument("--workers", type=int, default=6) parser.add_argument("--out", default=None) 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"gate-protected-stop-{stamp}.json" class GateStopRefresher: """Point-in-time gate and replacement-stop calculator for stop events.""" def __init__( self, prices: dict[str, tuple], recommendation_config: dict, activation: dict, benchmark_closes: dict[date, float], ) -> None: from app.services import backtest_service as bt self.bt = bt self.prices = prices self.recommendation_config = recommendation_config self.activation = activation self.benchmark_closes = benchmark_closes self.threshold = float(activation.get("min_momentum_percentile", 80.0)) self.dates = { symbol: [date.fromordinal(value) for value in columns[0]] for symbol, columns in prices.items() } self.index_of = { symbol: {value: index for index, value in enumerate(columns[0])} for symbol, columns in prices.items() } self.percentile_cache: dict[int, dict[str, float]] = {} self.setup_cache: dict[tuple[str, int], dict | None] = {} self.events: list[dict[str, Any]] = [] def _momentum_percentiles(self, asof_ord: int) -> dict[str, float]: cached = self.percentile_cache.get(asof_ord) if cached is not None: return cached values: dict[str, float] = {} for symbol, columns in self.prices.items(): idx = bisect.bisect_right(columns[0], asof_ord) - 1 if idx < 252: continue closes = columns[4] value = self.bt._residual_momentum_12_1( self.dates[symbol], closes, idx, self.benchmark_closes ) if value is None and closes[idx - 252] > 0: value = closes[idx - 21] / closes[idx - 252] - 1.0 if value is not None: values[symbol] = float(value) ordered = sorted(values, key=lambda symbol: values[symbol]) denominator = len(ordered) - 1 percentiles = { symbol: (rank / denominator * 100.0) if denominator > 0 else 100.0 for rank, symbol in enumerate(ordered) } self.percentile_cache[asof_ord] = percentiles return percentiles def _long_setup(self, symbol: str, asof_idx: int) -> dict | None: columns = self.prices[symbol] asof_ord = columns[0][asof_idx] key = (symbol, asof_ord) if key in self.setup_cache: return self.setup_cache[key] records = [ SimpleNamespace( date=date.fromordinal(o), open=op, high=high, low=low, close=close, volume=volume, ) for o, op, high, low, close, volume in zip( columns[0][: asof_idx + 1], columns[1][: asof_idx + 1], columns[2][: asof_idx + 1], columns[3][: asof_idx + 1], columns[4][: asof_idx + 1], columns[5][: asof_idx + 1], ) ] setups = self.bt._window_setups( records, self.recommendation_config, self.activation ) setup = next((row for row in setups if row["direction"] == "long"), None) self.setup_cache[key] = setup return setup def __call__( self, symbol: str, stop_ord: int, active_stop: float, position: dict, bar: Any, ) -> float | None: columns = self.prices[symbol] stop_idx = self.index_of[symbol].get(stop_ord) if stop_idx is None: stop_idx = bisect.bisect_left(columns[0], stop_ord) asof_idx = stop_idx - 1 if asof_idx < self.bt.MIN_LOOKBACK - 1: return None asof_ord = columns[0][asof_idx] setup = self._long_setup(symbol, asof_idx) momentum_pct = self._momentum_percentiles(asof_ord).get(symbol) gate_passed = bool( setup is not None and self.bt._momentum_qualifies( { "meets_core": setup["meets_core"], "direction": "long", self.bt.PRODUCTION_PERCENTILE_KEY: momentum_pct, }, self.threshold, ) ) new_stop = float(setup["stop"]) if gate_passed and setup is not None else None lower_stop = bool(new_stop is not None and new_stop < active_stop - 1e-9) original_risk = float(position["entry"] - position["initial_stop"]) replacement_risk_r = ( (float(position["entry"]) - new_stop) / original_risk if lower_stop and original_risk > 0 and new_stop is not None else None ) self.events.append({ "symbol": symbol, "stop_date": date.fromordinal(stop_ord).isoformat(), "gate_asof_date": date.fromordinal(asof_ord).isoformat(), "momentum_percentile": round(momentum_pct, 2) if momentum_pct is not None else None, "gate_core_passed": bool(setup and setup["meets_core"]), "gate_passed": gate_passed, "active_stop": round(active_stop, 4), "replacement_stop": round(new_stop, 4) if new_stop is not None else None, "lower_stop": lower_stop, "same_bar_survives": bool(lower_stop and bar.low > new_stop), "replacement_risk_r": round(replacement_risk_r, 3) if replacement_risk_r is not None else None, }) return new_stop def _arm(label: str, sim: dict) -> dict: trade_details = sim.pop("trade_details", None) row = {"arm": label, **sim} if trade_details is not None: row["trade_details"] = trade_details return row def _rescued_trade_summary(trades: list[dict]) -> dict: rescued = [trade for trade in trades if trade.get("stop_refreshes", 0) > 0] rs = [float(trade["r"]) for trade in rescued] return { "trades": len(rescued), "wins": sum(value > 0 for value in rs), "win_rate": round(sum(value > 0 for value in rs) / len(rs) * 100.0, 1) if rs else None, "avg_r": round(sum(rs) / len(rs), 3) if rs else None, "total_r": round(sum(rs), 2) if rs else None, "worst_r": round(min(rs), 2) if rs else None, "best_r": round(max(rs), 2) if rs else None, "exit_reasons": dict(Counter(trade["reason"] for trade in rescued)), } async def _main() -> None: args = _parse_args() snapshot = Path(args.snapshot) if not snapshot.exists(): raise SystemExit(f"Snapshot not found: {snapshot}") try: start_date = date.fromisoformat(args.start_date) except ValueError as exc: raise SystemExit("--start-date must use YYYY-MM-DD") from exc 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 engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True) Session = async_sessionmaker(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 engine.dispose() candidates: list[dict] = [] workers = max(1, min(int(args.workers), multiprocessing.cpu_count() - 1)) context = 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, start_date, ): symbol for symbol, columns in prices.items() } for index, future in enumerate(as_completed(futures), 1): candidates.extend(future.result()) if not args.quiet and index % 25 == 0: print(f"replayed tickers: {index}/{len(futures)}", flush=True) bt._assign_momentum_percentiles(candidates) bt._assign_residual_momentum_percentiles(candidates) bt._assign_low_volatility_percentiles(candidates) bt._assign_activation_momentum_percentiles(candidates) bt._assign_residual_high_vol_blend(candidates) threshold = float(activation.get("min_momentum_percentile", 80.0)) for candidate in candidates: candidate["qualified"] = bt._momentum_qualifies(candidate, threshold) 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") 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) ) sim_kwargs = { "qualified_fn": None, "ranking_key": str( entry_config.get("ranking_key") or entry_config["percentile_key"] ), "max_positions": int(entry_config["max_positions"]), "risk_per_trade": float(entry_config["risk_per_trade"]), "atr_trail_multiplier": trail_multiplier, "start_date": start_date, } baseline = bt._simulate_portfolio( candidates, prices, benchmark_closes, exit_policy, hold_days, **sim_kwargs ) cooldown_5 = bt._simulate_portfolio( candidates, prices, benchmark_closes, exit_policy, hold_days, reentry_cooldown_sessions=5, **sim_kwargs, ) cooldown_10 = bt._simulate_portfolio( candidates, prices, benchmark_closes, exit_policy, hold_days, reentry_cooldown_sessions=10, **sim_kwargs, ) refresher = GateStopRefresher( prices, recommendation_config, activation, benchmark_closes ) gate_protected = bt._simulate_portfolio( candidates, prices, benchmark_closes, exit_policy, hold_days, initial_stop_refresh_fn=refresher, include_trades=True, **sim_kwargs, ) if any(row is None for row in (baseline, cooldown_5, cooldown_10, gate_protected)): raise RuntimeError("A study arm produced no trades") gate_trades = list(gate_protected.get("trade_details") or []) event_counts = Counter() for event in refresher.events: event_counts["stop_touches"] += 1 if event["gate_passed"]: event_counts["gate_passed"] += 1 if event["lower_stop"]: event_counts["lower_stop"] += 1 if event["same_bar_survives"]: event_counts["same_bar_survives"] += 1 report = { "generated_at": datetime.now().astimezone().isoformat(), "snapshot": str(snapshot.resolve()), "period_start": start_date.isoformat(), "tickers": len(prices), "entry_candidates": len(candidates), "qualified_candidates": sum(bool(row["qualified"]) for row in candidates), "params": { "entry_cadence_days": bt.STEP_DAYS, "setup_stop_atr_multiplier": bt.ATR_MULTIPLIER, "exit_policy": exit_policy, "exit_atr_multiplier": trail_multiplier, "hold_days": hold_days, "momentum_percentile_floor": threshold, "gate_refresh_information_cutoff": "previous close", }, "arms": [ _arm("baseline", baseline), _arm("cooldown_5", cooldown_5), _arm("cooldown_10", cooldown_10), _arm("gate_protected_stop", gate_protected), ], "gate_stop_events": { **dict(event_counts), "unique_symbols": len({event["symbol"] for event in refresher.events}), "rescued_trade_outcomes": _rescued_trade_summary(gate_trades), "events": refresher.events, }, "note": ( "The gate-protected arm recalculates the gate at an initial-stop touch " "using only data available through the previous close. It accepts only " "a lower stop from a newly valid long setup and checks that replacement " "against the same bar. It does not cancel stops using the later same-day close." ), } output = Path(args.out) if args.out else _default_output_path() 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 arm in report["arms"]: print( f"{arm['arm']}: Sharpe {arm['sharpe']}, CAGR {arm['cagr_pct']}%, " f"DD {arm['max_drawdown_pct']}%, trades {arm['trades']}" ) print(f"gate stop events: {dict(event_counts)}") print(f"rescued outcomes: {report['gate_stop_events']['rescued_trade_outcomes']}") if __name__ == "__main__": asyncio.run(_main())