diff --git a/alembic/versions/022_add_paper_trade_reentry_gate_reset.py b/alembic/versions/022_add_paper_trade_reentry_gate_reset.py new file mode 100644 index 0000000..f50d541 --- /dev/null +++ b/alembic/versions/022_add_paper_trade_reentry_gate_reset.py @@ -0,0 +1,37 @@ +"""add persistent post-stop gate-reset observation + +Revision ID: 022 +Revises: 021 +Create Date: 2026-07-17 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "022" +down_revision: Union[str, None] = "021" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "paper_trades", + sa.Column("reentry_gate_failed_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "paper_trades", + sa.Column( + "reentry_gate_requalified_at", + sa.DateTime(timezone=True), + nullable=True, + ), + ) + + +def downgrade() -> None: + op.drop_column("paper_trades", "reentry_gate_requalified_at") + op.drop_column("paper_trades", "reentry_gate_failed_at") diff --git a/app/models/paper_trade.py b/app/models/paper_trade.py index a533c5b..f41ac4a 100644 --- a/app/models/paper_trade.py +++ b/app/models/paper_trade.py @@ -36,3 +36,13 @@ class PaperTrade(Base): closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) # How the trade was closed: "time" | "trailing" | "stop" | "target" | "manual". close_reason: Mapped[str | None] = mapped_column(String(10), nullable=True) + # A trade stopped at its initial stop starts a re-entry gate-reset episode. + # The daily full-universe scanner records both state transitions: the first + # failed gate observation and a later fresh qualification. Re-entry remains + # non-actionable until both timestamps exist. + reentry_gate_failed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + reentry_gate_requalified_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) diff --git a/app/routers/trades.py b/app/routers/trades.py index 66b35f8..562868f 100644 --- a/app/routers/trades.py +++ b/app/routers/trades.py @@ -36,7 +36,7 @@ async def list_trade_setups( recommended_action=recommended_action, live_recommendation=True, exclude_open_trade_tickers=True, - exclude_reentry_lockdown_tickers=True, + exclude_reentry_gate_locked_tickers=True, ) data = [] @@ -99,7 +99,7 @@ async def get_ticker_trade_setups( db, symbol=symbol, live_recommendation=True, - include_reentry_lockdown=True, + include_reentry_gate_lock=True, ) data = [] for row in rows: diff --git a/app/schemas/trade_setup.py b/app/schemas/trade_setup.py index 06920e2..7b213d9 100644 --- a/app/schemas/trade_setup.py +++ b/app/schemas/trade_setup.py @@ -59,6 +59,6 @@ class TradeSetupResponse(BaseModel): momentum_percentile: float | None = None strategy_rank: float | None = None volatility_percentile: float | None = None - reentry_lockdown_remaining_sessions: int | None = None + reentry_gate_reset_required: bool = False context_as_of: TradeSetupContextAsOfResponse | None = None recommendation_summary: RecommendationSummaryResponse | None = None diff --git a/app/services/alert_service.py b/app/services/alert_service.py index 0928c6c..d20769f 100644 --- a/app/services/alert_service.py +++ b/app/services/alert_service.py @@ -282,7 +282,7 @@ async def _qualified_setups(db: AsyncSession) -> list[dict]: db, live_recommendation=True, exclude_open_trade_tickers=True, - exclude_reentry_lockdown_tickers=True, + exclude_reentry_gate_locked_tickers=True, ) config = await get_activation_config(db) return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)] diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index a86e8d4..f3e9566 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -94,7 +94,6 @@ from app.services.scoring_service import ( compute_technical_from_arrays, ) from app.services.sr_service import detect_gate_target_ladder, detect_sr_levels -from app.services.trade_policy import REENTRY_LOCKDOWN_SESSIONS logger = logging.getLogger(__name__) @@ -103,6 +102,7 @@ KEY_REPORT = "backtest_report" WEEKLY_BACKTEST_CADENCE = "weekly" DAILY_BACKTEST_CADENCE = "daily" DEFAULT_BACKTEST_CADENCE = WEEKLY_BACKTEST_CADENCE +PRODUCTION_REENTRY_POLICY = "gate_reset" BACKTEST_CADENCE_SESSIONS = { WEEKLY_BACKTEST_CADENCE: 5, DAILY_BACKTEST_CADENCE: 1, @@ -1429,6 +1429,72 @@ LIVE_EXIT_MODE_TO_SIM = { } +def _make_gate_reset_reentry_fn( + candidates: list[dict], + prices: dict[str, tuple], + *, + cadence: str, + qualified_fn: Callable[[dict], bool] | None = None, + ranking_key: str = PRODUCTION_PERCENTILE_KEY, +) -> Callable[[str, int, dict, Any], dict | None]: + """Build the production post-stop gate-reset callback. + + Missing candidates count as a gate failure only on dates on which that + ticker was actually evaluated at the selected replay cadence. This keeps a + weekly backtest from treating the four non-evaluation sessions between two + weekly observations as false gate exits. + """ + cadence = validate_backtest_cadence(cadence) + if qualified_fn is None: + def _default_qualified(candidate: dict) -> bool: + return bool(candidate.get("qualified")) + + qualified_fn = _default_qualified + + evaluation_ords: dict[str, set[int]] = {} + step_sessions = backtest_step_sessions(cadence) + for symbol, columns in prices.items(): + ordinals = columns[0] + evaluation_ords[symbol] = { + int(ordinals[index]) + for index in range(MIN_LOOKBACK - 1, len(ordinals) - HORIZON, step_sessions) + } + + qualified_by_symbol_date: dict[tuple[str, int], dict] = {} + for candidate in candidates: + if candidate.get("direction") != "long" or not qualified_fn(candidate): + continue + key = ( + str(candidate["symbol"]), + date.fromisoformat(str(candidate["date"])).toordinal(), + ) + previous = qualified_by_symbol_date.get(key) + if previous is None or float(candidate.get(ranking_key) or 0.0) > float( + previous.get(ranking_key) or 0.0 + ): + qualified_by_symbol_date[key] = candidate + + def _gate_reset( + symbol: str, + asof_ord: int, + state: dict, + _bar: Any, + ) -> dict | None: + if asof_ord not in evaluation_ords.get(symbol, set()): + return None + candidate = qualified_by_symbol_date.get((symbol, asof_ord)) + if candidate is None: + state["gate_went_unqualified"] = True + return None + if not state.get("gate_went_unqualified"): + return None + emitted = dict(candidate) + emitted["_reentry_reason"] = "gate_failed_then_requalified" + return emitted + + return _gate_reset + + def _simulate_portfolio( candidates: list[dict], prices: dict[str, tuple], @@ -2325,35 +2391,35 @@ PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = ( "exit_policy": "hold", }, { - "strategy": "production_live_no_lockdown", - "label": "Live setup + 3x ATR trail (no re-entry lockdown)", + "strategy": "production_live_immediate", + "label": "Live setup + 3x ATR trail (immediate re-entry)", "description": ( "Exact live activation, ordering, and Admin exit policy, with only " - "the post-stop re-entry lockdown disabled as the comparison baseline." + "the post-stop gate reset disabled as the comparison baseline." ), "entry_variant": "residual80_highvol_blend80_20_fixed10", "exit_policy": "atr_trail3", - "reentry_lockdown_sessions": 0, + "reentry_policy": "immediate", "use_live_config": True, - "comparison_arm": "live_no_lockdown", + "comparison_arm": "live_immediate", }, { "strategy": PRODUCTION_PORTFOLIO_STRATEGY, - "label": "Production: residual/high-vol 80/20 + 3x ATR trail + 5-session lockdown", + "label": "Production: residual/high-vol 80/20 + 3x ATR trail + gate reset", "description": ( "The live strategy: production activation gate and Admin exit policy " - "as currently configured, 80/20 residual/high-vol rank, and a " - "five-session re-entry lockdown after an initial-stop exit." + "as currently configured, 80/20 residual/high-vol rank, and re-entry " + "only after the gate fails and later qualifies again." ), "entry_variant": "residual80_highvol_blend80_20_fixed10", "exit_policy": "atr_trail3", - "reentry_lockdown_sessions": REENTRY_LOCKDOWN_SESSIONS, + "reentry_policy": PRODUCTION_REENTRY_POLICY, # The production row replays what the platform actually does right now: # the live qualification flag (runtime Admin activation settings) and the # live Admin exit policy, instead of the frozen research-variant gate. "use_live_config": True, "is_production": True, - "comparison_arm": "live_lockdown_5", + "comparison_arm": "live_gate_reset", }, ) @@ -2456,6 +2522,7 @@ def _min_rr_sweep( threshold: float, hold_days: int, live_exit_policy: dict | None = None, + cadence: str = DEFAULT_BACKTEST_CADENCE, ) -> dict: """Portfolio economics of the production book at each R:R floor. @@ -2472,9 +2539,7 @@ def _min_rr_sweep( exit_policy = str(strategy["exit_policy"]) row_hold_days = hold_days trail_multiplier = ATR_TRAIL_MULTIPLIER - reentry_lockdown_sessions = int( - strategy.get("reentry_lockdown_sessions", 0) - ) + reentry_policy = str(strategy.get("reentry_policy", "immediate")) if strategy.get("use_live_config") and live_exit_policy is not None: exit_policy = LIVE_EXIT_MODE_TO_SIM.get( str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3" @@ -2514,7 +2579,19 @@ def _min_rr_sweep( max_positions=int(entry_cfg["max_positions"]), risk_per_trade=float(entry_cfg["risk_per_trade"]), atr_trail_multiplier=trail_multiplier, - reentry_cooldown_sessions=reentry_lockdown_sessions, + post_stop_reentry_fn=( + _make_gate_reset_reentry_fn( + candidates, + prices, + cadence=cadence, + qualified_fn=qualified_fn, + ranking_key=str( + entry_cfg.get("ranking_key") or entry_cfg["percentile_key"] + ), + ) + if reentry_policy == "gate_reset" + else None + ), start_date=sweep_start, ) if sim is None: @@ -2539,7 +2616,7 @@ def _min_rr_sweep( "live_qualified_setups": live_qualified, "reproduces_production_gate": reproduces, "exit_policy": exit_policy, - "reentry_lockdown_sessions": reentry_lockdown_sessions, + "reentry_policy": reentry_policy, "entries_from": sweep_start.isoformat() if sweep_start else None, "window": "out-of-sample (test)" if sweep_start else "full history (in-sample)", "rows": rows, @@ -2573,6 +2650,7 @@ def _holdout_evaluation( hold_days: int, split: date, live_exit_policy: dict | None = None, + cadence: str = DEFAULT_BACKTEST_CADENCE, ) -> dict: """The production strategy simulated on entries BEFORE the split (train) and on entries ON/AFTER it (test), as separate books. @@ -2595,9 +2673,7 @@ def _holdout_evaluation( exit_policy = str(strategy["exit_policy"]) row_hold_days = hold_days trail_multiplier = ATR_TRAIL_MULTIPLIER - reentry_lockdown_sessions = int( - strategy.get("reentry_lockdown_sessions", 0) - ) + reentry_policy = str(strategy.get("reentry_policy", "immediate")) if strategy.get("use_live_config") and live_exit_policy is not None: exit_policy = LIVE_EXIT_MODE_TO_SIM.get( str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3" @@ -2610,6 +2686,20 @@ def _holdout_evaluation( None if strategy.get("use_live_config") else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config) ) + ranking_key = str( + entry_cfg.get("ranking_key") or entry_cfg["percentile_key"] + ) + post_stop_reentry_fn = ( + _make_gate_reset_reentry_fn( + candidates, + prices, + cadence=cadence, + qualified_fn=qualified_fn, + ranking_key=ranking_key, + ) + if reentry_policy == "gate_reset" + else None + ) rows: list[dict] = [] for window, start, end in ( @@ -2623,11 +2713,11 @@ def _holdout_evaluation( exit_policy, row_hold_days, qualified_fn=qualified_fn, - ranking_key=str(entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]), + ranking_key=ranking_key, max_positions=int(entry_cfg["max_positions"]), risk_per_trade=float(entry_cfg["risk_per_trade"]), atr_trail_multiplier=trail_multiplier, - reentry_cooldown_sessions=reentry_lockdown_sessions, + post_stop_reentry_fn=post_stop_reentry_fn, start_date=start, end_date=end, include_curve=True, @@ -2639,7 +2729,7 @@ def _holdout_evaluation( return { "split_date": split.isoformat(), "strategy": strategy["strategy"], - "reentry_lockdown_sessions": reentry_lockdown_sessions, + "reentry_policy": reentry_policy, "rows": rows, "note": ( "Train = entries before the split; test = entries on/after it. The two " @@ -2655,6 +2745,7 @@ def _portfolio_monitor( _spy_closes: dict[date, float] | None, hold_days: int, live_exit_policy: dict | None = None, + cadence: str = DEFAULT_BACKTEST_CADENCE, ) -> dict: latest_ord = max((max(cols[0]) for cols in prices.values() if cols[0]), default=None) rows: list[dict] = [] @@ -2672,9 +2763,7 @@ def _portfolio_monitor( # policy. The overlay opts into this deliberately so only ordering # changes relative to the production row. use_live = bool(strategy.get("use_live_config")) - reentry_lockdown_sessions = int( - strategy.get("reentry_lockdown_sessions", 0) - ) + reentry_policy = str(strategy.get("reentry_policy", "immediate")) exit_policy = str(strategy["exit_policy"]) row_hold_days = hold_days trail_multiplier = ATR_TRAIL_MULTIPLIER @@ -2690,6 +2779,17 @@ def _portfolio_monitor( None if use_live else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config) ) + post_stop_reentry_fn = ( + _make_gate_reset_reentry_fn( + candidates, + prices, + cadence=cadence, + qualified_fn=qualified_fn, + ranking_key=ranking_key, + ) + if reentry_policy == "gate_reset" + else None + ) for lookback in PORTFOLIO_MONITOR_LOOKBACKS: start = _lookback_start(latest_ord, lookback["days"]) sim = _simulate_portfolio( @@ -2703,7 +2803,7 @@ def _portfolio_monitor( max_positions=int(entry_cfg["max_positions"]), risk_per_trade=float(entry_cfg["risk_per_trade"]), atr_trail_multiplier=trail_multiplier, - reentry_cooldown_sessions=reentry_lockdown_sessions, + post_stop_reentry_fn=post_stop_reentry_fn, start_date=start, include_curve=True, ) @@ -2719,7 +2819,7 @@ def _portfolio_monitor( "ranking_key": ranking_key, "exit_policy": exit_policy, "live_exit_mode": live_exit_mode, - "reentry_lockdown_sessions": reentry_lockdown_sessions, + "reentry_policy": reentry_policy, "lookback": lookback["lookback"], "lookback_label": lookback["label"], **sim, @@ -2733,9 +2833,7 @@ def _portfolio_monitor( "description": s["description"], "is_production": bool(s.get("is_production")), "comparison_arm": s.get("comparison_arm"), - "reentry_lockdown_sessions": int( - s.get("reentry_lockdown_sessions", 0) - ), + "reentry_policy": str(s.get("reentry_policy", "immediate")), } for s in strategies ], @@ -2749,7 +2847,7 @@ def _portfolio_monitor( "The structural overlay appears only in its explicit research arm and changes " "ordering, not production qualification. Local snapshot backtests remain the " "research surface for broad variant sweeps. The production row applies the " - "same five-session post-initial-stop re-entry lockdown as the live setup list." + "same post-initial-stop gate-reset rule as the live setup list." ), } @@ -2758,7 +2856,7 @@ def _production_cadence_comparison( monitor: dict | None, cadence: str, ) -> dict | None: - """Compact full-history live/no-lockdown vs live/5-session comparison.""" + """Compact full-history live/immediate vs live/gate-reset comparison.""" if not monitor: return None arms: list[dict] = [] @@ -2773,23 +2871,23 @@ def _production_cadence_comparison( } arm_name = ( "prod_live_setup" - if comparison_arm == "live_no_lockdown" - else "cooldown_5" + if comparison_arm == "live_immediate" + else "gate_reset" ) compact["arm"] = f"{arm_name}_{cadence}" compact["entry_cadence"] = cadence arms.append(compact) if not arms: return None - arms.sort(key=lambda row: int(row.get("reentry_lockdown_sessions", 0))) + arms.sort(key=lambda row: row.get("reentry_policy") != "immediate") return { "entry_cadence": cadence, "lookback": "all", "arms": arms, "note": ( "Both arms use the exact same live gate, ordering, Admin exit policy, " - "fees, and candidate cadence. Only the five-session post-stop " - "re-entry lockdown changes." + "fees, and candidate cadence. Only the post-stop gate-reset rule " + "changes." ), } @@ -3002,8 +3100,8 @@ def _build_recommendation(report: dict) -> dict: if production_row is not None: headline = ( "Production baseline: residual/high-vol 80/20 entry rank with a " - "3x ATR trailing exit, 30-trading-day max hold, and 5-session " - "re-entry lockdown after an initial stop." + "3x ATR trailing exit, 30-trading-day max hold, and re-entry only " + "after the gate fails and later qualifies again." ) if ( production_row.get("cagr_pct") is not None @@ -3357,17 +3455,19 @@ async def run_backtest( portfolio_monitor_report = _portfolio_monitor( candidates, price_columns, spy_closes, hold_horizon, live_exit_policy=live_exit_policy, + cadence=cadence, ) split = _holdout_split() if split is not None: holdout_report = _holdout_evaluation( candidates, price_columns, spy_closes, hold_horizon, split, live_exit_policy=live_exit_policy, + cadence=cadence, ) if _min_rr_sweep_enabled(): min_rr_sweep_report = _min_rr_sweep( candidates, price_columns, spy_closes, activation, current_min_pct, - hold_horizon, live_exit_policy=live_exit_policy, + hold_horizon, live_exit_policy=live_exit_policy, cadence=cadence, ) except Exception: logger.exception("Portfolio simulation failed") @@ -3390,7 +3490,7 @@ async def run_backtest( "target_model": target_model, "target_model_label": BACKTEST_TARGET_MODELS[target_model], "is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL, - "production_reentry_lockdown_sessions": REENTRY_LOCKDOWN_SESSIONS, + "production_reentry_policy": PRODUCTION_REENTRY_POLICY, }, "activation": activation, "overall_qualified": _bucket_stats(qualified), diff --git a/app/services/paper_trade_service.py b/app/services/paper_trade_service.py index b170e30..1e4b368 100644 --- a/app/services/paper_trade_service.py +++ b/app/services/paper_trade_service.py @@ -20,7 +20,7 @@ from app.services.outcome_service import ( Bar, evaluate_setup_against_bars, ) -from app.services.trade_policy import get_reentry_lockdowns +from app.services.trade_policy import get_reentry_gate_locks # Exit policy for OPEN paper trades (auto-close). Production defaults to the # July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max @@ -319,12 +319,9 @@ async def create_trade( raise ValidationError("shares and entry_price must be positive") ticker = await _get_ticker(db, symbol) - remaining_sessions = (await get_reentry_lockdowns(db)).get(ticker.id) - if remaining_sessions is not None: - suffix = "session" if remaining_sessions == 1 else "sessions" + if ticker.id in await get_reentry_gate_locks(db): raise ValidationError( - f"{ticker.symbol} is in a post-stop re-entry lockdown: " - f"{remaining_sessions} market {suffix} remaining" + f"{ticker.symbol} requires a post-stop gate reset before re-entry" ) trade = PaperTrade( user_id=user_id, diff --git a/app/services/rr_scanner_service.py b/app/services/rr_scanner_service.py index 21becf0..f3ba0d9 100644 --- a/app/services/rr_scanner_service.py +++ b/app/services/rr_scanner_service.py @@ -28,8 +28,12 @@ from app.models.ticker import Ticker from app.models.trade_setup import TradeSetup from app.services.indicator_service import _extract_ohlcv, compute_atr from app.services.price_service import query_ohlcv +from app.services.qualification import setup_qualifies from app.services.sr_service import detect_gate_target_ladder -from app.services.trade_policy import get_reentry_lockdowns +from app.services.trade_policy import ( + get_reentry_gate_locks, + observe_reentry_gate_transitions, +) from app.services.recommendation_service import ( _risk_level_from_conflicts, build_recommendation_snapshot, @@ -700,12 +704,24 @@ async def scan_all_tickers( ``progress_callback(processed, total, current_symbol)`` is invoked as each ticker is scanned so callers (e.g. the scheduler) can surface live progress. """ - # Plain strings, not Ticker instances: the rollbacks below expire any ORM - # objects held across them, and touching an expired attribute afterwards + # Plain ids/strings, not Ticker instances: the rollbacks below expire any + # ORM objects held across them, and touching an expired attribute afterwards # triggers sync lazy-loading, which raises on an AsyncSession. - result = await db.execute(select(Ticker.symbol).order_by(Ticker.symbol)) - symbols = list(result.scalars().all()) - total = len(symbols) + result = await db.execute(select(Ticker.id, Ticker.symbol).order_by(Ticker.symbol)) + ticker_rows = [(int(ticker_id), symbol) for ticker_id, symbol in result.all()] + total = len(ticker_rows) + + # Gate-reset observations must use the same runtime activation settings as + # the live setup list. If the config cannot be loaded, scan normally but do + # not mutate reset state from an evaluation whose rules are unknown. + activation: dict | None = None + try: + from app.services.admin_service import get_activation_config + + activation = await get_activation_config(db) + except Exception: + await db.rollback() + logger.exception("Activation config load for re-entry gate reset failed") # Rank the universe up front so each new setup carries both the residual # activation gate percentile and the promoted production ordering score. @@ -721,7 +737,10 @@ async def scan_all_tickers( ranks = {} all_setups: list[TradeSetup] = [] - for index, symbol in enumerate(symbols): + evaluated_ticker_ids: set[int] = set() + qualified_ticker_ids: set[int] = set() + gate_observation_started_at = datetime.now(timezone.utc) + for index, (ticker_id, symbol) in enumerate(ticker_rows): if progress_callback is not None: progress_callback(index, total, symbol) # Refresh scores first so the scheduled scan works off current data. @@ -754,10 +773,33 @@ async def scan_all_tickers( primary_min_rr=PRIMARY_TARGET_MIN_RR, ) all_setups.extend(setups) + if activation is not None: + try: + if any(setup_qualifies(setup, activation) for setup in setups): + qualified_ticker_ids.add(ticker_id) + evaluated_ticker_ids.add(ticker_id) + except Exception: + logger.exception( + "Gate-reset qualification observation failed for %s", symbol + ) except Exception: await db.rollback() logger.exception("Error scanning ticker %s", symbol) + if activation is not None: + transitioned_ticker_ids = await observe_reentry_gate_transitions( + db, + evaluated_ticker_ids=evaluated_ticker_ids, + qualified_ticker_ids=qualified_ticker_ids, + observed_at=gate_observation_started_at, + ) + await db.commit() + if transitioned_ticker_ids: + logger.info( + "Updated post-stop gate-reset state for %d ticker(s)", + len(transitioned_ticker_ids), + ) + if progress_callback is not None and total: progress_callback(total, total, "") @@ -772,8 +814,8 @@ async def get_trade_setups( symbol: str | None = None, live_recommendation: bool = False, exclude_open_trade_tickers: bool = False, - exclude_reentry_lockdown_tickers: bool = False, - include_reentry_lockdown: bool = False, + exclude_reentry_gate_locked_tickers: bool = False, + include_reentry_gate_lock: bool = False, ) -> list[dict]: """Get latest stored trade setups, optionally filtered. @@ -798,7 +840,7 @@ async def get_trade_setups( if recommended_action is not None and not live_recommendation: stmt = stmt.where(TradeSetup.recommended_action == recommended_action) excluded_ticker_ids: set[int] = set() - reentry_lockdowns: dict[int, int] = {} + reentry_gate_locks: dict[int, datetime] = {} if exclude_open_trade_tickers: open_trade_result = await db.execute( select(PaperTrade.ticker_id) @@ -808,10 +850,10 @@ async def get_trade_setups( excluded_ticker_ids.update( ticker_id for ticker_id, in open_trade_result.all() ) - if exclude_reentry_lockdown_tickers or include_reentry_lockdown: - reentry_lockdowns = await get_reentry_lockdowns(db) - if exclude_reentry_lockdown_tickers: - excluded_ticker_ids.update(reentry_lockdowns) + if exclude_reentry_gate_locked_tickers or include_reentry_gate_lock: + reentry_gate_locks = await get_reentry_gate_locks(db) + if exclude_reentry_gate_locked_tickers: + excluded_ticker_ids.update(reentry_gate_locks) if excluded_ticker_ids: stmt = stmt.where(~TradeSetup.ticker_id.in_(excluded_ticker_ids)) @@ -866,14 +908,14 @@ async def get_trade_setups( ), reverse=True, ) - if include_reentry_lockdown: + if include_reentry_gate_lock: ticker_by_setup_id = { setup.id: setup.ticker_id for setup, _ in latest_rows } for row in rows_out: ticker_id = ticker_by_setup_id.get(row["id"]) - row["reentry_lockdown_remaining_sessions"] = ( - reentry_lockdowns.get(ticker_id) if ticker_id is not None else None + row["reentry_gate_reset_required"] = ( + ticker_id in reentry_gate_locks if ticker_id is not None else False ) return rows_out diff --git a/app/services/trade_policy.py b/app/services/trade_policy.py index d9773ca..7ba10b2 100644 --- a/app/services/trade_policy.py +++ b/app/services/trade_policy.py @@ -1,120 +1,93 @@ -"""Shared live/backtest trading-policy constants and availability checks.""" +"""Shared live trading-policy state and availability checks.""" from __future__ import annotations -from collections import defaultdict -from datetime import date, datetime, timezone +from collections.abc import Iterable +from datetime import datetime, timezone -from sqlalchemy import func, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.models.benchmark_price import BenchmarkPrice -from app.models.ohlcv import OHLCVRecord from app.models.paper_trade import PaperTrade -from app.services.benchmark_service import BENCHMARK_SYMBOL - -# A ticker stopped at its initial stop may qualify again immediately, but the -# July 2026 event study showed that waiting five market sessions materially -# improved the production book. The stop session is wait_session=0; the first -# permitted re-entry is wait_session=5, provided the normal gate still passes. -REENTRY_LOCKDOWN_SESSIONS = 5 -async def get_reentry_lockdowns( +async def _latest_initial_stop_trades( db: AsyncSession, *, - as_of: date | None = None, - sessions: int = REENTRY_LOCKDOWN_SESSIONS, -) -> dict[int, int]: - """Return ``{ticker_id: remaining_sessions}`` for active lockdowns. - - SPY is the canonical calendar for the platform's US-equity universe. When - the stored benchmark history does not reach an older stop, only that - ticker's own OHLCV dates are used as a conservative fallback. Unrelated - ticker dates can therefore never shorten a lockdown. - """ - sessions = max(0, int(sessions)) - if sessions == 0: - return {} - - session_cutoff = as_of or datetime.now(timezone.utc).date() - stop_result = await db.execute( - select( - PaperTrade.ticker_id, - func.max(PaperTrade.closed_at).label("last_stop_at"), - ) + closed_before: datetime | None = None, +) -> dict[int, PaperTrade]: + """Return the most recent initial-stop trade for each ticker.""" + stmt = ( + select(PaperTrade) .where( PaperTrade.status == "closed", PaperTrade.close_reason == "stop", PaperTrade.closed_at.is_not(None), ) - .group_by(PaperTrade.ticker_id) + .order_by( + PaperTrade.ticker_id.asc(), + PaperTrade.closed_at.desc(), + PaperTrade.id.desc(), + ) ) - stop_dates = { - ticker_id: stopped_at.date() - for ticker_id, stopped_at in stop_result.all() - if stopped_at is not None and stopped_at.date() <= session_cutoff + if closed_before is not None: + stmt = stmt.where(PaperTrade.closed_at <= closed_before) + result = await db.execute(stmt) + latest: dict[int, PaperTrade] = {} + for trade in result.scalars(): + latest.setdefault(trade.ticker_id, trade) + return latest + + +async def get_reentry_gate_locks(db: AsyncSession) -> dict[int, datetime]: + """Return tickers still waiting for a post-stop gate failure. + + A later qualified setup is actionable only after the daily scanner has + observed an unqualified evaluation after the latest initial-stop exit and + then a fresh qualification. The returned timestamp is the stop time and is + useful for diagnostics; callers normally only need the keys. + """ + latest = await _latest_initial_stop_trades(db) + return { + ticker_id: trade.closed_at + for ticker_id, trade in latest.items() + if trade.reentry_gate_requalified_at is None and trade.closed_at is not None } - if not stop_dates: - return {} - - benchmark_result = await db.execute( - select(BenchmarkPrice.date) - .where( - BenchmarkPrice.symbol == BENCHMARK_SYMBOL, - BenchmarkPrice.date <= session_cutoff, - ) - .order_by(BenchmarkPrice.date.asc()) - ) - benchmark_dates = [row[0] for row in benchmark_result.all()] - - lockdowns: dict[int, int] = {} - fallback_stops: dict[int, date] = {} - first_benchmark_date = benchmark_dates[0] if benchmark_dates else None - for ticker_id, stop_date in stop_dates.items(): - completed = sum(day > stop_date for day in benchmark_dates) - if completed >= sessions: - continue - if first_benchmark_date is not None and first_benchmark_date <= stop_date: - lockdowns[ticker_id] = sessions - completed - else: - # The benchmark table starts after this stop (or is empty), so it - # cannot prove how many sessions elapsed. Resolve only this ticker - # against its own bars instead of using universe-wide dates. - fallback_stops[ticker_id] = stop_date - - if fallback_stops: - own_session_result = await db.execute( - select(OHLCVRecord.ticker_id, OHLCVRecord.date) - .where( - OHLCVRecord.ticker_id.in_(fallback_stops), - OHLCVRecord.date > min(fallback_stops.values()), - OHLCVRecord.date <= session_cutoff, - ) - .distinct() - ) - own_dates: dict[int, set[date]] = defaultdict(set) - for ticker_id, market_date in own_session_result.all(): - own_dates[ticker_id].add(market_date) - for ticker_id, stop_date in fallback_stops.items(): - completed = sum(day > stop_date for day in own_dates[ticker_id]) - if completed < sessions: - lockdowns[ticker_id] = sessions - completed - - return lockdowns -async def get_reentry_lockdown_ticker_ids( +async def observe_reentry_gate_transitions( db: AsyncSession, *, - as_of: date | None = None, - sessions: int = REENTRY_LOCKDOWN_SESSIONS, + evaluated_ticker_ids: Iterable[int], + qualified_ticker_ids: Iterable[int], + observed_at: datetime | None = None, ) -> set[int]: - """Compatibility wrapper for callers that only need blocked ticker ids.""" - return set( - await get_reentry_lockdowns( - db, - as_of=as_of, - sessions=sessions, - ) - ) + """Persist gate-failure and later requalification observations. + + Only tickers whose scan completed successfully belong in + ``evaluated_ticker_ids``. This prevents a scanner exception from being + mistaken for a real gate exit. The caller owns the transaction; this helper + flushes so the new state is immediately visible in that transaction. + """ + evaluated = {int(ticker_id) for ticker_id in evaluated_ticker_ids} + if not evaluated: + return set() + qualified = {int(ticker_id) for ticker_id in qualified_ticker_ids} + timestamp = observed_at or datetime.now(timezone.utc) + latest = await _latest_initial_stop_trades(db, closed_before=timestamp) + updated: set[int] = set() + for ticker_id in evaluated: + trade = latest.get(ticker_id) + if trade is None or trade.reentry_gate_requalified_at is not None: + continue + if trade.reentry_gate_failed_at is None: + if ticker_id not in qualified: + trade.reentry_gate_failed_at = timestamp + updated.add(ticker_id) + elif ticker_id in qualified: + trade.reentry_gate_requalified_at = timestamp + updated.add(ticker_id) + + if updated: + await db.flush() + return updated diff --git a/frontend/src/components/signals/BacktestPanel.tsx b/frontend/src/components/signals/BacktestPanel.tsx index c111299..3b76fad 100644 --- a/frontend/src/components/signals/BacktestPanel.tsx +++ b/frontend/src/components/signals/BacktestPanel.tsx @@ -373,8 +373,8 @@ export function BacktestPanel() {
Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '} {fmtR(monitorRun.worst_trade_r)} · Avg P&L per trade {fmtMoney(monitorRun.avg_trade_pnl)} - {monitorRun.reentry_lockdown_sessions ? ( - <> · Re-entry lockdown {monitorRun.reentry_lockdown_sessions} market sessions after initial stop> + {monitorRun.reentry_policy === 'gate_reset' ? ( + <> · Re-entry after gate failure and fresh qualification> ) : null}
diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index 2c1a8e9..06ddbb8 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -67,14 +67,13 @@ function entryDrift(setup: TradeSetup, currentPrice?: number) { } type NotActionableState = - | { kind: 'lockdown'; remainingSessions: number } + | { kind: 'gate-reset' } | { kind: 'invalidated' } | null; function notActionableState(setup: TradeSetup, currentPrice?: number) { - const remainingSessions = setup.reentry_lockdown_remaining_sessions ?? 0; - if (remainingSessions > 0) { - return { kind: 'lockdown', remainingSessions } satisfies NotActionableState; + if (setup.reentry_gate_reset_required) { + return { kind: 'gate-reset' } satisfies NotActionableState; } if (currentPrice == null) return null; if (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null; @@ -271,20 +270,17 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele }; const inactiveState = notActionableState(setup, currentPrice); - if (inactiveState?.kind === 'lockdown') { - const remaining = inactiveState.remainingSessions; + if (inactiveState?.kind === 'gate-reset') { return (- This setup remains visible for context but cannot be marked as taken. Once the lockdown expires, - the scanner recalculates the normal gate before it can become actionable again. + This setup remains visible for context but cannot be marked as taken. The ticker must first fail + the production gate; only a later fresh qualification can become actionable again.