feat: require gate reset before post-stop reentry

This commit is contained in:
2026-07-17 19:30:40 +02:00
parent 1a6f82bf6d
commit 5155d00d9e
18 changed files with 577 additions and 278 deletions
@@ -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")
+10
View File
@@ -36,3 +36,13 @@ class PaperTrade(Base):
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# How the trade was closed: "time" | "trailing" | "stop" | "target" | "manual". # How the trade was closed: "time" | "trailing" | "stop" | "target" | "manual".
close_reason: Mapped[str | None] = mapped_column(String(10), nullable=True) 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
)
+2 -2
View File
@@ -36,7 +36,7 @@ async def list_trade_setups(
recommended_action=recommended_action, recommended_action=recommended_action,
live_recommendation=True, live_recommendation=True,
exclude_open_trade_tickers=True, exclude_open_trade_tickers=True,
exclude_reentry_lockdown_tickers=True, exclude_reentry_gate_locked_tickers=True,
) )
data = [] data = []
@@ -99,7 +99,7 @@ async def get_ticker_trade_setups(
db, db,
symbol=symbol, symbol=symbol,
live_recommendation=True, live_recommendation=True,
include_reentry_lockdown=True, include_reentry_gate_lock=True,
) )
data = [] data = []
for row in rows: for row in rows:
+1 -1
View File
@@ -59,6 +59,6 @@ class TradeSetupResponse(BaseModel):
momentum_percentile: float | None = None momentum_percentile: float | None = None
strategy_rank: float | None = None strategy_rank: float | None = None
volatility_percentile: 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 context_as_of: TradeSetupContextAsOfResponse | None = None
recommendation_summary: RecommendationSummaryResponse | None = None recommendation_summary: RecommendationSummaryResponse | None = None
+1 -1
View File
@@ -282,7 +282,7 @@ async def _qualified_setups(db: AsyncSession) -> list[dict]:
db, db,
live_recommendation=True, live_recommendation=True,
exclude_open_trade_tickers=True, exclude_open_trade_tickers=True,
exclude_reentry_lockdown_tickers=True, exclude_reentry_gate_locked_tickers=True,
) )
config = await get_activation_config(db) config = await get_activation_config(db)
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)] return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
+141 -41
View File
@@ -94,7 +94,6 @@ from app.services.scoring_service import (
compute_technical_from_arrays, compute_technical_from_arrays,
) )
from app.services.sr_service import detect_gate_target_ladder, detect_sr_levels 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__) logger = logging.getLogger(__name__)
@@ -103,6 +102,7 @@ KEY_REPORT = "backtest_report"
WEEKLY_BACKTEST_CADENCE = "weekly" WEEKLY_BACKTEST_CADENCE = "weekly"
DAILY_BACKTEST_CADENCE = "daily" DAILY_BACKTEST_CADENCE = "daily"
DEFAULT_BACKTEST_CADENCE = WEEKLY_BACKTEST_CADENCE DEFAULT_BACKTEST_CADENCE = WEEKLY_BACKTEST_CADENCE
PRODUCTION_REENTRY_POLICY = "gate_reset"
BACKTEST_CADENCE_SESSIONS = { BACKTEST_CADENCE_SESSIONS = {
WEEKLY_BACKTEST_CADENCE: 5, WEEKLY_BACKTEST_CADENCE: 5,
DAILY_BACKTEST_CADENCE: 1, 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( def _simulate_portfolio(
candidates: list[dict], candidates: list[dict],
prices: dict[str, tuple], prices: dict[str, tuple],
@@ -2325,35 +2391,35 @@ PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = (
"exit_policy": "hold", "exit_policy": "hold",
}, },
{ {
"strategy": "production_live_no_lockdown", "strategy": "production_live_immediate",
"label": "Live setup + 3x ATR trail (no re-entry lockdown)", "label": "Live setup + 3x ATR trail (immediate re-entry)",
"description": ( "description": (
"Exact live activation, ordering, and Admin exit policy, with only " "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", "entry_variant": "residual80_highvol_blend80_20_fixed10",
"exit_policy": "atr_trail3", "exit_policy": "atr_trail3",
"reentry_lockdown_sessions": 0, "reentry_policy": "immediate",
"use_live_config": True, "use_live_config": True,
"comparison_arm": "live_no_lockdown", "comparison_arm": "live_immediate",
}, },
{ {
"strategy": PRODUCTION_PORTFOLIO_STRATEGY, "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": ( "description": (
"The live strategy: production activation gate and Admin exit policy " "The live strategy: production activation gate and Admin exit policy "
"as currently configured, 80/20 residual/high-vol rank, and a " "as currently configured, 80/20 residual/high-vol rank, and re-entry "
"five-session re-entry lockdown after an initial-stop exit." "only after the gate fails and later qualifies again."
), ),
"entry_variant": "residual80_highvol_blend80_20_fixed10", "entry_variant": "residual80_highvol_blend80_20_fixed10",
"exit_policy": "atr_trail3", "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 production row replays what the platform actually does right now:
# the live qualification flag (runtime Admin activation settings) and the # the live qualification flag (runtime Admin activation settings) and the
# live Admin exit policy, instead of the frozen research-variant gate. # live Admin exit policy, instead of the frozen research-variant gate.
"use_live_config": True, "use_live_config": True,
"is_production": True, "is_production": True,
"comparison_arm": "live_lockdown_5", "comparison_arm": "live_gate_reset",
}, },
) )
@@ -2456,6 +2522,7 @@ def _min_rr_sweep(
threshold: float, threshold: float,
hold_days: int, hold_days: int,
live_exit_policy: dict | None = None, live_exit_policy: dict | None = None,
cadence: str = DEFAULT_BACKTEST_CADENCE,
) -> dict: ) -> dict:
"""Portfolio economics of the production book at each R:R floor. """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"]) exit_policy = str(strategy["exit_policy"])
row_hold_days = hold_days row_hold_days = hold_days
trail_multiplier = ATR_TRAIL_MULTIPLIER trail_multiplier = ATR_TRAIL_MULTIPLIER
reentry_lockdown_sessions = int( reentry_policy = str(strategy.get("reentry_policy", "immediate"))
strategy.get("reentry_lockdown_sessions", 0)
)
if strategy.get("use_live_config") and live_exit_policy is not None: if strategy.get("use_live_config") and live_exit_policy is not None:
exit_policy = LIVE_EXIT_MODE_TO_SIM.get( exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3" 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"]), max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]), risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier, 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, start_date=sweep_start,
) )
if sim is None: if sim is None:
@@ -2539,7 +2616,7 @@ def _min_rr_sweep(
"live_qualified_setups": live_qualified, "live_qualified_setups": live_qualified,
"reproduces_production_gate": reproduces, "reproduces_production_gate": reproduces,
"exit_policy": exit_policy, "exit_policy": exit_policy,
"reentry_lockdown_sessions": reentry_lockdown_sessions, "reentry_policy": reentry_policy,
"entries_from": sweep_start.isoformat() if sweep_start else None, "entries_from": sweep_start.isoformat() if sweep_start else None,
"window": "out-of-sample (test)" if sweep_start else "full history (in-sample)", "window": "out-of-sample (test)" if sweep_start else "full history (in-sample)",
"rows": rows, "rows": rows,
@@ -2573,6 +2650,7 @@ def _holdout_evaluation(
hold_days: int, hold_days: int,
split: date, split: date,
live_exit_policy: dict | None = None, live_exit_policy: dict | None = None,
cadence: str = DEFAULT_BACKTEST_CADENCE,
) -> dict: ) -> dict:
"""The production strategy simulated on entries BEFORE the split (train) and """The production strategy simulated on entries BEFORE the split (train) and
on entries ON/AFTER it (test), as separate books. on entries ON/AFTER it (test), as separate books.
@@ -2595,9 +2673,7 @@ def _holdout_evaluation(
exit_policy = str(strategy["exit_policy"]) exit_policy = str(strategy["exit_policy"])
row_hold_days = hold_days row_hold_days = hold_days
trail_multiplier = ATR_TRAIL_MULTIPLIER trail_multiplier = ATR_TRAIL_MULTIPLIER
reentry_lockdown_sessions = int( reentry_policy = str(strategy.get("reentry_policy", "immediate"))
strategy.get("reentry_lockdown_sessions", 0)
)
if strategy.get("use_live_config") and live_exit_policy is not None: if strategy.get("use_live_config") and live_exit_policy is not None:
exit_policy = LIVE_EXIT_MODE_TO_SIM.get( exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3" str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
@@ -2610,6 +2686,20 @@ def _holdout_evaluation(
None if strategy.get("use_live_config") None if strategy.get("use_live_config")
else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, 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] = [] rows: list[dict] = []
for window, start, end in ( for window, start, end in (
@@ -2623,11 +2713,11 @@ def _holdout_evaluation(
exit_policy, exit_policy,
row_hold_days, row_hold_days,
qualified_fn=qualified_fn, 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"]), max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]), risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier, atr_trail_multiplier=trail_multiplier,
reentry_cooldown_sessions=reentry_lockdown_sessions, post_stop_reentry_fn=post_stop_reentry_fn,
start_date=start, start_date=start,
end_date=end, end_date=end,
include_curve=True, include_curve=True,
@@ -2639,7 +2729,7 @@ def _holdout_evaluation(
return { return {
"split_date": split.isoformat(), "split_date": split.isoformat(),
"strategy": strategy["strategy"], "strategy": strategy["strategy"],
"reentry_lockdown_sessions": reentry_lockdown_sessions, "reentry_policy": reentry_policy,
"rows": rows, "rows": rows,
"note": ( "note": (
"Train = entries before the split; test = entries on/after it. The two " "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, _spy_closes: dict[date, float] | None,
hold_days: int, hold_days: int,
live_exit_policy: dict | None = None, live_exit_policy: dict | None = None,
cadence: str = DEFAULT_BACKTEST_CADENCE,
) -> dict: ) -> dict:
latest_ord = max((max(cols[0]) for cols in prices.values() if cols[0]), default=None) latest_ord = max((max(cols[0]) for cols in prices.values() if cols[0]), default=None)
rows: list[dict] = [] rows: list[dict] = []
@@ -2672,9 +2763,7 @@ def _portfolio_monitor(
# policy. The overlay opts into this deliberately so only ordering # policy. The overlay opts into this deliberately so only ordering
# changes relative to the production row. # changes relative to the production row.
use_live = bool(strategy.get("use_live_config")) use_live = bool(strategy.get("use_live_config"))
reentry_lockdown_sessions = int( reentry_policy = str(strategy.get("reentry_policy", "immediate"))
strategy.get("reentry_lockdown_sessions", 0)
)
exit_policy = str(strategy["exit_policy"]) exit_policy = str(strategy["exit_policy"])
row_hold_days = hold_days row_hold_days = hold_days
trail_multiplier = ATR_TRAIL_MULTIPLIER trail_multiplier = ATR_TRAIL_MULTIPLIER
@@ -2690,6 +2779,17 @@ def _portfolio_monitor(
None if use_live None if use_live
else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config) 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: for lookback in PORTFOLIO_MONITOR_LOOKBACKS:
start = _lookback_start(latest_ord, lookback["days"]) start = _lookback_start(latest_ord, lookback["days"])
sim = _simulate_portfolio( sim = _simulate_portfolio(
@@ -2703,7 +2803,7 @@ def _portfolio_monitor(
max_positions=int(entry_cfg["max_positions"]), max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]), risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier, atr_trail_multiplier=trail_multiplier,
reentry_cooldown_sessions=reentry_lockdown_sessions, post_stop_reentry_fn=post_stop_reentry_fn,
start_date=start, start_date=start,
include_curve=True, include_curve=True,
) )
@@ -2719,7 +2819,7 @@ def _portfolio_monitor(
"ranking_key": ranking_key, "ranking_key": ranking_key,
"exit_policy": exit_policy, "exit_policy": exit_policy,
"live_exit_mode": live_exit_mode, "live_exit_mode": live_exit_mode,
"reentry_lockdown_sessions": reentry_lockdown_sessions, "reentry_policy": reentry_policy,
"lookback": lookback["lookback"], "lookback": lookback["lookback"],
"lookback_label": lookback["label"], "lookback_label": lookback["label"],
**sim, **sim,
@@ -2733,9 +2833,7 @@ def _portfolio_monitor(
"description": s["description"], "description": s["description"],
"is_production": bool(s.get("is_production")), "is_production": bool(s.get("is_production")),
"comparison_arm": s.get("comparison_arm"), "comparison_arm": s.get("comparison_arm"),
"reentry_lockdown_sessions": int( "reentry_policy": str(s.get("reentry_policy", "immediate")),
s.get("reentry_lockdown_sessions", 0)
),
} }
for s in strategies for s in strategies
], ],
@@ -2749,7 +2847,7 @@ def _portfolio_monitor(
"The structural overlay appears only in its explicit research arm and changes " "The structural overlay appears only in its explicit research arm and changes "
"ordering, not production qualification. Local snapshot backtests remain the " "ordering, not production qualification. Local snapshot backtests remain the "
"research surface for broad variant sweeps. The production row applies 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, monitor: dict | None,
cadence: str, cadence: str,
) -> dict | None: ) -> 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: if not monitor:
return None return None
arms: list[dict] = [] arms: list[dict] = []
@@ -2773,23 +2871,23 @@ def _production_cadence_comparison(
} }
arm_name = ( arm_name = (
"prod_live_setup" "prod_live_setup"
if comparison_arm == "live_no_lockdown" if comparison_arm == "live_immediate"
else "cooldown_5" else "gate_reset"
) )
compact["arm"] = f"{arm_name}_{cadence}" compact["arm"] = f"{arm_name}_{cadence}"
compact["entry_cadence"] = cadence compact["entry_cadence"] = cadence
arms.append(compact) arms.append(compact)
if not arms: if not arms:
return None 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 { return {
"entry_cadence": cadence, "entry_cadence": cadence,
"lookback": "all", "lookback": "all",
"arms": arms, "arms": arms,
"note": ( "note": (
"Both arms use the exact same live gate, ordering, Admin exit policy, " "Both arms use the exact same live gate, ordering, Admin exit policy, "
"fees, and candidate cadence. Only the five-session post-stop " "fees, and candidate cadence. Only the post-stop gate-reset rule "
"re-entry lockdown changes." "changes."
), ),
} }
@@ -3002,8 +3100,8 @@ def _build_recommendation(report: dict) -> dict:
if production_row is not None: if production_row is not None:
headline = ( headline = (
"Production baseline: residual/high-vol 80/20 entry rank with a " "Production baseline: residual/high-vol 80/20 entry rank with a "
"3x ATR trailing exit, 30-trading-day max hold, and 5-session " "3x ATR trailing exit, 30-trading-day max hold, and re-entry only "
"re-entry lockdown after an initial stop." "after the gate fails and later qualifies again."
) )
if ( if (
production_row.get("cagr_pct") is not None production_row.get("cagr_pct") is not None
@@ -3357,17 +3455,19 @@ async def run_backtest(
portfolio_monitor_report = _portfolio_monitor( portfolio_monitor_report = _portfolio_monitor(
candidates, price_columns, spy_closes, hold_horizon, candidates, price_columns, spy_closes, hold_horizon,
live_exit_policy=live_exit_policy, live_exit_policy=live_exit_policy,
cadence=cadence,
) )
split = _holdout_split() split = _holdout_split()
if split is not None: if split is not None:
holdout_report = _holdout_evaluation( holdout_report = _holdout_evaluation(
candidates, price_columns, spy_closes, hold_horizon, split, candidates, price_columns, spy_closes, hold_horizon, split,
live_exit_policy=live_exit_policy, live_exit_policy=live_exit_policy,
cadence=cadence,
) )
if _min_rr_sweep_enabled(): if _min_rr_sweep_enabled():
min_rr_sweep_report = _min_rr_sweep( min_rr_sweep_report = _min_rr_sweep(
candidates, price_columns, spy_closes, activation, current_min_pct, 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: except Exception:
logger.exception("Portfolio simulation failed") logger.exception("Portfolio simulation failed")
@@ -3390,7 +3490,7 @@ async def run_backtest(
"target_model": target_model, "target_model": target_model,
"target_model_label": BACKTEST_TARGET_MODELS[target_model], "target_model_label": BACKTEST_TARGET_MODELS[target_model],
"is_production_target_model": target_model == PRODUCTION_GTL_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, "activation": activation,
"overall_qualified": _bucket_stats(qualified), "overall_qualified": _bucket_stats(qualified),
+3 -6
View File
@@ -20,7 +20,7 @@ from app.services.outcome_service import (
Bar, Bar,
evaluate_setup_against_bars, 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 # 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 # 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") raise ValidationError("shares and entry_price must be positive")
ticker = await _get_ticker(db, symbol) ticker = await _get_ticker(db, symbol)
remaining_sessions = (await get_reentry_lockdowns(db)).get(ticker.id) if ticker.id in await get_reentry_gate_locks(db):
if remaining_sessions is not None:
suffix = "session" if remaining_sessions == 1 else "sessions"
raise ValidationError( raise ValidationError(
f"{ticker.symbol} is in a post-stop re-entry lockdown: " f"{ticker.symbol} requires a post-stop gate reset before re-entry"
f"{remaining_sessions} market {suffix} remaining"
) )
trade = PaperTrade( trade = PaperTrade(
user_id=user_id, user_id=user_id,
+59 -17
View File
@@ -28,8 +28,12 @@ from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup from app.models.trade_setup import TradeSetup
from app.services.indicator_service import _extract_ohlcv, compute_atr from app.services.indicator_service import _extract_ohlcv, compute_atr
from app.services.price_service import query_ohlcv 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.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 ( from app.services.recommendation_service import (
_risk_level_from_conflicts, _risk_level_from_conflicts,
build_recommendation_snapshot, build_recommendation_snapshot,
@@ -700,12 +704,24 @@ async def scan_all_tickers(
``progress_callback(processed, total, current_symbol)`` is invoked as each ``progress_callback(processed, total, current_symbol)`` is invoked as each
ticker is scanned so callers (e.g. the scheduler) can surface live progress. ticker is scanned so callers (e.g. the scheduler) can surface live progress.
""" """
# Plain strings, not Ticker instances: the rollbacks below expire any ORM # Plain ids/strings, not Ticker instances: the rollbacks below expire any
# objects held across them, and touching an expired attribute afterwards # ORM objects held across them, and touching an expired attribute afterwards
# triggers sync lazy-loading, which raises on an AsyncSession. # triggers sync lazy-loading, which raises on an AsyncSession.
result = await db.execute(select(Ticker.symbol).order_by(Ticker.symbol)) result = await db.execute(select(Ticker.id, Ticker.symbol).order_by(Ticker.symbol))
symbols = list(result.scalars().all()) ticker_rows = [(int(ticker_id), symbol) for ticker_id, symbol in result.all()]
total = len(symbols) 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 # Rank the universe up front so each new setup carries both the residual
# activation gate percentile and the promoted production ordering score. # activation gate percentile and the promoted production ordering score.
@@ -721,7 +737,10 @@ async def scan_all_tickers(
ranks = {} ranks = {}
all_setups: list[TradeSetup] = [] 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: if progress_callback is not None:
progress_callback(index, total, symbol) progress_callback(index, total, symbol)
# Refresh scores first so the scheduled scan works off current data. # 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, primary_min_rr=PRIMARY_TARGET_MIN_RR,
) )
all_setups.extend(setups) 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: except Exception:
await db.rollback() await db.rollback()
logger.exception("Error scanning ticker %s", symbol) 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: if progress_callback is not None and total:
progress_callback(total, total, "") progress_callback(total, total, "")
@@ -772,8 +814,8 @@ async def get_trade_setups(
symbol: str | None = None, symbol: str | None = None,
live_recommendation: bool = False, live_recommendation: bool = False,
exclude_open_trade_tickers: bool = False, exclude_open_trade_tickers: bool = False,
exclude_reentry_lockdown_tickers: bool = False, exclude_reentry_gate_locked_tickers: bool = False,
include_reentry_lockdown: bool = False, include_reentry_gate_lock: bool = False,
) -> list[dict]: ) -> list[dict]:
"""Get latest stored trade setups, optionally filtered. """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: if recommended_action is not None and not live_recommendation:
stmt = stmt.where(TradeSetup.recommended_action == recommended_action) stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
excluded_ticker_ids: set[int] = set() excluded_ticker_ids: set[int] = set()
reentry_lockdowns: dict[int, int] = {} reentry_gate_locks: dict[int, datetime] = {}
if exclude_open_trade_tickers: if exclude_open_trade_tickers:
open_trade_result = await db.execute( open_trade_result = await db.execute(
select(PaperTrade.ticker_id) select(PaperTrade.ticker_id)
@@ -808,10 +850,10 @@ async def get_trade_setups(
excluded_ticker_ids.update( excluded_ticker_ids.update(
ticker_id for ticker_id, in open_trade_result.all() ticker_id for ticker_id, in open_trade_result.all()
) )
if exclude_reentry_lockdown_tickers or include_reentry_lockdown: if exclude_reentry_gate_locked_tickers or include_reentry_gate_lock:
reentry_lockdowns = await get_reentry_lockdowns(db) reentry_gate_locks = await get_reentry_gate_locks(db)
if exclude_reentry_lockdown_tickers: if exclude_reentry_gate_locked_tickers:
excluded_ticker_ids.update(reentry_lockdowns) excluded_ticker_ids.update(reentry_gate_locks)
if excluded_ticker_ids: if excluded_ticker_ids:
stmt = stmt.where(~TradeSetup.ticker_id.in_(excluded_ticker_ids)) stmt = stmt.where(~TradeSetup.ticker_id.in_(excluded_ticker_ids))
@@ -866,14 +908,14 @@ async def get_trade_setups(
), ),
reverse=True, reverse=True,
) )
if include_reentry_lockdown: if include_reentry_gate_lock:
ticker_by_setup_id = { ticker_by_setup_id = {
setup.id: setup.ticker_id for setup, _ in latest_rows setup.id: setup.ticker_id for setup, _ in latest_rows
} }
for row in rows_out: for row in rows_out:
ticker_id = ticker_by_setup_id.get(row["id"]) ticker_id = ticker_by_setup_id.get(row["id"])
row["reentry_lockdown_remaining_sessions"] = ( row["reentry_gate_reset_required"] = (
reentry_lockdowns.get(ticker_id) if ticker_id is not None else None ticker_id in reentry_gate_locks if ticker_id is not None else False
) )
return rows_out return rows_out
+70 -97
View File
@@ -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 __future__ import annotations
from collections import defaultdict from collections.abc import Iterable
from datetime import date, datetime, timezone from datetime import datetime, timezone
from sqlalchemy import func, select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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.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, db: AsyncSession,
*, *,
as_of: date | None = None, closed_before: datetime | None = None,
sessions: int = REENTRY_LOCKDOWN_SESSIONS, ) -> dict[int, PaperTrade]:
) -> dict[int, int]: """Return the most recent initial-stop trade for each ticker."""
"""Return ``{ticker_id: remaining_sessions}`` for active lockdowns. stmt = (
select(PaperTrade)
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"),
)
.where( .where(
PaperTrade.status == "closed", PaperTrade.status == "closed",
PaperTrade.close_reason == "stop", PaperTrade.close_reason == "stop",
PaperTrade.closed_at.is_not(None), 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() if closed_before is not None:
for ticker_id, stopped_at in stop_result.all() stmt = stmt.where(PaperTrade.closed_at <= closed_before)
if stopped_at is not None and stopped_at.date() <= session_cutoff 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, db: AsyncSession,
*, *,
as_of: date | None = None, evaluated_ticker_ids: Iterable[int],
sessions: int = REENTRY_LOCKDOWN_SESSIONS, qualified_ticker_ids: Iterable[int],
observed_at: datetime | None = None,
) -> set[int]: ) -> set[int]:
"""Compatibility wrapper for callers that only need blocked ticker ids.""" """Persist gate-failure and later requalification observations.
return set(
await get_reentry_lockdowns( Only tickers whose scan completed successfully belong in
db, ``evaluated_ticker_ids``. This prevents a scanner exception from being
as_of=as_of, mistaken for a real gate exit. The caller owns the transaction; this helper
sessions=sessions, 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
@@ -373,8 +373,8 @@ export function BacktestPanel() {
<p className="text-[11px] text-gray-500"> <p className="text-[11px] text-gray-500">
Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '} Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '}
{fmtR(monitorRun.worst_trade_r)} · Avg P&amp;L per trade {fmtMoney(monitorRun.avg_trade_pnl)} {fmtR(monitorRun.worst_trade_r)} · Avg P&amp;L per trade {fmtMoney(monitorRun.avg_trade_pnl)}
{monitorRun.reentry_lockdown_sessions ? ( {monitorRun.reentry_policy === 'gate_reset' ? (
<> · Re-entry lockdown {monitorRun.reentry_lockdown_sessions} market sessions after initial stop</> <> · Re-entry after gate failure and fresh qualification</>
) : null} ) : null}
</p> </p>
@@ -67,14 +67,13 @@ function entryDrift(setup: TradeSetup, currentPrice?: number) {
} }
type NotActionableState = type NotActionableState =
| { kind: 'lockdown'; remainingSessions: number } | { kind: 'gate-reset' }
| { kind: 'invalidated' } | { kind: 'invalidated' }
| null; | null;
function notActionableState(setup: TradeSetup, currentPrice?: number) { function notActionableState(setup: TradeSetup, currentPrice?: number) {
const remainingSessions = setup.reentry_lockdown_remaining_sessions ?? 0; if (setup.reentry_gate_reset_required) {
if (remainingSessions > 0) { return { kind: 'gate-reset' } satisfies NotActionableState;
return { kind: 'lockdown', remainingSessions } satisfies NotActionableState;
} }
if (currentPrice == null) return null; if (currentPrice == null) return null;
if (entryDrift(setup, currentPrice)?.status !== 'invalidated') 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); const inactiveState = notActionableState(setup, currentPrice);
if (inactiveState?.kind === 'lockdown') { if (inactiveState?.kind === 'gate-reset') {
const remaining = inactiveState.remainingSessions;
return ( return (
<div data-direction={setup.direction} className="rounded-xl border border-amber-400/20 bg-amber-400/[0.04] p-4"> <div data-direction={setup.direction} className="rounded-xl border border-amber-400/20 bg-amber-400/[0.04] p-4">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<DirTag direction={setup.direction} /> <DirTag direction={setup.direction} />
<span className="num text-[10px] uppercase tracking-[0.16em] text-amber-300">post-stop lockdown</span> <span className="num text-[10px] uppercase tracking-[0.16em] text-amber-300">awaiting gate reset</span>
<span className="num ml-auto text-xs text-gray-500"> <span className="num ml-auto text-xs text-gray-500">re-entry paused</span>
{remaining} market session{remaining === 1 ? '' : 's'} remaining
</span>
</div> </div>
<p className="mt-2 text-[11.5px] leading-relaxed text-gray-400"> <p className="mt-2 text-[11.5px] leading-relaxed text-gray-400">
This setup remains visible for context but cannot be marked as taken. Once the lockdown expires, This setup remains visible for context but cannot be marked as taken. The ticker must first fail
the scanner recalculates the normal gate before it can become actionable again. the production gate; only a later fresh qualification can become actionable again.
</p> </p>
</div> </div>
); );
@@ -639,11 +635,11 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
<div className="min-w-0"> <div className="min-w-0">
{preferredInactive ? ( {preferredInactive ? (
<span className="text-sm font-semibold text-gray-400"> <span className="text-sm font-semibold text-gray-400">
{preferredInactive.kind === 'lockdown' ? ( {preferredInactive.kind === 'gate-reset' ? (
<> <>
Re-entry paused{' '} Re-entry paused{' '}
<span className="font-normal text-gray-500"> <span className="font-normal text-gray-500">
({preferredInactive.remainingSessions} market session{preferredInactive.remainingSessions === 1 ? '' : 's'} remaining after stop) (waiting for the gate to fail before a fresh qualification)
</span> </span>
</> </>
) : ( ) : (
+3 -4
View File
@@ -43,7 +43,7 @@ export function liveRiskReward(setup: TradeSetup, currentPrice: number): number
* app/services/qualification.py keep the two in sync. * app/services/qualification.py keep the two in sync.
*/ */
export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean { export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean {
if ((setup.reentry_lockdown_remaining_sessions ?? 0) > 0) return false; if (setup.reentry_gate_reset_required) return false;
if (setup.rr_ratio < config.min_rr) return false; if (setup.rr_ratio < config.min_rr) return false;
// Live R:R from current price — drops setups whose price has already run // Live R:R from current price — drops setups whose price has already run
// toward target (reward consumed) or through the stop. // toward target (reward consumed) or through the stop.
@@ -80,9 +80,8 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
* qualifiesSetup rule-for-rule (keep the order in sync). * qualifiesSetup rule-for-rule (keep the order in sync).
*/ */
export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): string | null { export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): string | null {
const lockdownRemaining = setup.reentry_lockdown_remaining_sessions ?? 0; if (setup.reentry_gate_reset_required) {
if (lockdownRemaining > 0) { return 'post-stop gate reset required';
return `post-stop lockdown · ${lockdownRemaining} session${lockdownRemaining === 1 ? '' : 's'} remaining`;
} }
if (setup.rr_ratio < config.min_rr) { if (setup.rr_ratio < config.min_rr) {
return `R:R ${setup.rr_ratio.toFixed(1)} below gate ${config.min_rr.toFixed(1)}`; return `R:R ${setup.rr_ratio.toFixed(1)} below gate ${config.min_rr.toFixed(1)}`;
+6 -6
View File
@@ -144,7 +144,7 @@ export interface TradeSetup {
momentum_percentile?: number | null; momentum_percentile?: number | null;
strategy_rank?: number | null; strategy_rank?: number | null;
volatility_percentile?: number | null; volatility_percentile?: number | null;
reentry_lockdown_remaining_sessions?: number | null; reentry_gate_reset_required?: boolean;
context_as_of?: TradeSetupContextAsOf | null; context_as_of?: TradeSetupContextAsOf | null;
recommendation_summary?: RecommendationSummary; recommendation_summary?: RecommendationSummary;
} }
@@ -356,10 +356,10 @@ export interface BacktestPortfolioMonitorRun extends BacktestPortfolioPolicy {
label: string; label: string;
description: string; description: string;
is_production: boolean; is_production: boolean;
comparison_arm?: 'live_no_lockdown' | 'live_lockdown_5' | null; comparison_arm?: 'live_immediate' | 'live_gate_reset' | null;
entry_variant: string; entry_variant: string;
exit_policy: string; exit_policy: string;
reentry_lockdown_sessions?: number; reentry_policy?: 'immediate' | 'gate_reset';
lookback: string; lookback: string;
lookback_label: string; lookback_label: string;
} }
@@ -371,8 +371,8 @@ export interface BacktestPortfolioMonitor {
label: string; label: string;
description: string; description: string;
is_production: boolean; is_production: boolean;
comparison_arm?: 'live_no_lockdown' | 'live_lockdown_5' | null; comparison_arm?: 'live_immediate' | 'live_gate_reset' | null;
reentry_lockdown_sessions?: number; reentry_policy?: 'immediate' | 'gate_reset';
}[]; }[];
lookbacks: { lookback: string; label: string }[]; lookbacks: { lookback: string; label: string }[];
runs: BacktestPortfolioMonitorRun[]; runs: BacktestPortfolioMonitorRun[];
@@ -415,7 +415,7 @@ export interface BacktestReport {
target_model?: 'production_gtl' | 'structural_sr'; target_model?: 'production_gtl' | 'structural_sr';
target_model_label?: string; target_model_label?: string;
is_production_target_model?: boolean; is_production_target_model?: boolean;
production_reentry_lockdown_sessions?: number; production_reentry_policy?: 'gate_reset';
}; };
overall_qualified: BacktestBucket; overall_qualified: BacktestBucket;
overall_all: BacktestBucket; overall_all: BacktestBucket;
+11 -10
View File
@@ -1,9 +1,9 @@
"""Run the four production cadence/lockdown arms on one offline snapshot. """Run the four production cadence/re-entry arms on one offline snapshot.
The command executes the complete backtest once weekly and once daily. Each The command executes the complete backtest once weekly and once daily. Each
backtest contains two otherwise identical live-policy portfolio arms: no backtest contains two otherwise identical live-policy portfolio arms: immediate
post-stop lockdown and the production five-session lockdown. It writes both post-stop re-entry and the production gate-reset rule. It writes both full
full reports plus one compact four-arm comparison report. reports plus one compact four-arm comparison report.
""" """
from __future__ import annotations from __future__ import annotations
@@ -77,7 +77,8 @@ def _print_arm(row: dict) -> None:
print( print(
f" {row['arm']}: Sharpe {row.get('sharpe')}, " f" {row['arm']}: Sharpe {row.get('sharpe')}, "
f"CAGR {row.get('cagr_pct')}%, DD {row.get('max_drawdown_pct')}%, " f"CAGR {row.get('cagr_pct')}%, DD {row.get('max_drawdown_pct')}%, "
f"trades {row.get('trades')}, skipped cooldown {row.get('skipped_cooldown', 0)}" f"trades {row.get('trades')}, post-stop re-entries "
f"{row.get('post_stop_reentries', 0)}"
) )
@@ -139,16 +140,16 @@ async def _main() -> None:
expected = { expected = {
"prod_live_setup_weekly", "prod_live_setup_weekly",
"prod_live_setup_daily", "prod_live_setup_daily",
"cooldown_5_weekly", "gate_reset_weekly",
"cooldown_5_daily", "gate_reset_daily",
} }
if {row.get("arm") for row in arms} != expected: if {row.get("arm") for row in arms} != expected:
raise RuntimeError("The generated cadence report does not contain all four arms") raise RuntimeError("The generated cadence report does not contain all four arms")
arm_order = { arm_order = {
"prod_live_setup_weekly": 0, "prod_live_setup_weekly": 0,
"prod_live_setup_daily": 1, "prod_live_setup_daily": 1,
"cooldown_5_weekly": 2, "gate_reset_weekly": 2,
"cooldown_5_daily": 3, "gate_reset_daily": 3,
} }
arms.sort(key=lambda row: arm_order[str(row["arm"])]) arms.sort(key=lambda row: arm_order[str(row["arm"])])
@@ -164,7 +165,7 @@ async def _main() -> None:
"note": ( "note": (
"All four arms use the same snapshot, activation settings, target model, " "All four arms use the same snapshot, activation settings, target model, "
"live Admin exit policy, fees, sizing, and portfolio constraints. Within " "live Admin exit policy, fees, sizing, and portfolio constraints. Within "
"each cadence pair, only the five-session post-stop lockdown differs." "each cadence pair, only the post-stop gate-reset rule differs."
), ),
} }
comparison_path = out_dir / f"{prefix}-comparison.json" comparison_path = out_dir / f"{prefix}-comparison.json"
+58 -20
View File
@@ -679,11 +679,51 @@ class TestSimulatePortfolio:
assert sim["trades"] == 1 assert sim["trades"] == 1
assert callback_dates == [self.ORD + 1] assert callback_dates == [self.ORD + 1]
def test_production_monitor_applies_live_reentry_lockdown(self, monkeypatch): def test_gate_reset_waits_for_failed_evaluation_then_requalification(self):
closes = [100.0] * 95
entry_ord = self.ORD + bt.MIN_LOOKBACK - 1
stop_ord = entry_ord + 1
reentry_ord = entry_ord + 3
closes[bt.MIN_LOOKBACK] = 94.0
closes[bt.MIN_LOOKBACK + 1] = 95.0
closes[bt.MIN_LOOKBACK + 2] = 96.0
prices = {"AAA": _sim_prices(self.ORD, closes)}
candidates = [
_sim_cand("AAA", entry_ord, entry=100.0, stop=95.0, target=120.0),
# Still qualified on the stop day: this must not unlock re-entry.
_sim_cand("AAA", stop_ord, entry=94.0, stop=89.0, target=110.0),
# No candidate on the intervening session means the daily gate
# failed. A fresh qualification on the next session may re-enter.
_sim_cand("AAA", reentry_ord, entry=96.0, stop=90.0, target=115.0),
]
gate_reset = bt._make_gate_reset_reentry_fn(
candidates,
prices,
cadence="daily",
)
sim = bt._simulate_portfolio(
candidates,
prices,
None,
"hold",
30,
post_stop_reentry_fn=gate_reset,
include_trades=True,
)
assert sim is not None
assert sim["post_stop_reentries"] == 1
assert sim["trade_details"][1]["entry_date"] == date.fromordinal(
reentry_ord
).isoformat()
assert sim["reentry_events"][0]["wait_sessions"] == 2
def test_production_monitor_applies_live_gate_reset(self, monkeypatch):
def fake_simulator(*_args, **kwargs): def fake_simulator(*_args, **kwargs):
return { return {
"trades": 0, "trades": 0,
"applied_reentry_lockdown": kwargs.get("reentry_cooldown_sessions", 0), "applied_gate_reset": kwargs.get("post_stop_reentry_fn") is not None,
} }
monkeypatch.setattr(bt, "_simulate_portfolio", fake_simulator) monkeypatch.setattr(bt, "_simulate_portfolio", fake_simulator)
@@ -694,37 +734,38 @@ class TestSimulatePortfolio:
production_rows = [ production_rows = [
row for row in monitor["runs"] if row["is_production"] row for row in monitor["runs"] if row["is_production"]
] ]
comparison_rows = [ immediate_rows = [
row for row in monitor["runs"] if not row["is_production"] row for row in monitor["runs"]
if row["comparison_arm"] == "live_immediate"
] ]
assert production_rows assert production_rows
assert all( assert all(
row["reentry_lockdown_sessions"] == bt.REENTRY_LOCKDOWN_SESSIONS row["reentry_policy"] == "gate_reset"
and row["applied_reentry_lockdown"] == bt.REENTRY_LOCKDOWN_SESSIONS and row["applied_gate_reset"] is True
for row in production_rows for row in production_rows
) )
assert comparison_rows assert immediate_rows
assert all( assert all(
row["reentry_lockdown_sessions"] == 0 row["reentry_policy"] == "immediate"
and row["applied_reentry_lockdown"] == 0 and row["applied_gate_reset"] is False
for row in comparison_rows for row in immediate_rows
) )
def test_production_cadence_comparison_names_exact_two_arms(self): def test_production_cadence_comparison_names_exact_two_arms(self):
monitor = { monitor = {
"runs": [ "runs": [
{ {
"comparison_arm": "live_no_lockdown", "comparison_arm": "live_immediate",
"lookback": "all", "lookback": "all",
"reentry_lockdown_sessions": 0, "reentry_policy": "immediate",
"trades": 10, "trades": 10,
"equity_curve": [{"date": "2026-01-01", "value": 1.0}], "equity_curve": [{"date": "2026-01-01", "value": 1.0}],
}, },
{ {
"comparison_arm": "live_lockdown_5", "comparison_arm": "live_gate_reset",
"lookback": "all", "lookback": "all",
"reentry_lockdown_sessions": 5, "reentry_policy": "gate_reset",
"trades": 8, "trades": 8,
"benchmark_curve": [{"date": "2026-01-01", "value": 1.0}], "benchmark_curve": [{"date": "2026-01-01", "value": 1.0}],
}, },
@@ -736,7 +777,7 @@ class TestSimulatePortfolio:
assert comparison is not None assert comparison is not None
assert [row["arm"] for row in comparison["arms"]] == [ assert [row["arm"] for row in comparison["arms"]] == [
"prod_live_setup_daily", "prod_live_setup_daily",
"cooldown_5_daily", "gate_reset_daily",
] ]
assert all("equity_curve" not in row for row in comparison["arms"]) assert all("equity_curve" not in row for row in comparison["arms"])
assert all("benchmark_curve" not in row for row in comparison["arms"]) assert all("benchmark_curve" not in row for row in comparison["arms"])
@@ -997,7 +1038,7 @@ def test_build_recommendation_prefers_production_monitor_headline():
}) })
assert rec["headline"] is not None assert rec["headline"] is not None
assert "3x ATR trailing exit" in rec["headline"] assert "3x ATR trailing exit" in rec["headline"]
assert "5-session re-entry lockdown" in rec["headline"] assert "after the gate fails" in rec["headline"]
assert any(item["topic"] == "production" for item in rec["items"]) assert any(item["topic"] == "production" for item in rec["items"])
@@ -1261,10 +1302,7 @@ async def test_run_backtest_smoke(session):
assert report["params"]["is_production_target_model"] is True assert report["params"]["is_production_target_model"] is True
assert report["params"]["entry_cadence"] == "weekly" assert report["params"]["entry_cadence"] == "weekly"
assert report["params"]["step_sessions"] == 5 assert report["params"]["step_sessions"] == 5
assert ( assert report["params"]["production_reentry_policy"] == "gate_reset"
report["params"]["production_reentry_lockdown_sessions"]
== bt.REENTRY_LOCKDOWN_SESSIONS
)
assert "net_avg_r" in report["overall_all"] assert "net_avg_r" in report["overall_all"]
# ablation baseline reproduces the qualified set exactly, and every row # ablation baseline reproduces the qualified set exactly, and every row
+15 -15
View File
@@ -48,22 +48,18 @@ async def test_create_and_list_open(session):
assert row["current_price"] == 110.0 # marked to the latest close assert row["current_price"] == 110.0 # marked to the latest close
async def test_create_trade_enforces_post_stop_lockdown_at_service_boundary(session): async def test_create_trade_enforces_post_stop_gate_reset_at_service_boundary(session):
blocked_id = await _seed(session, "LOCKQ", close=100.0) blocked_id = await _seed(session, "LOCKQ", close=100.0)
released_id = await _seed(session, "FREEQ", close=100.0) released_id = await _seed(session, "FREEQ", close=100.0)
today = date.today() today = date.today()
market_sessions = [
today - timedelta(days=8),
today - timedelta(days=7),
today - timedelta(days=6),
today - timedelta(days=3),
today - timedelta(days=2),
today - timedelta(days=1),
]
for market_date in market_sessions:
session.add(BenchmarkPrice(symbol="SPY", date=market_date, close=400.0))
def stopped_trade(ticker_id: int, closed_on: date) -> PaperTrade: def stopped_trade(ticker_id: int, *, gate_reset_complete: bool) -> PaperTrade:
closed_on = today - timedelta(days=10)
reset_at = datetime.combine(
closed_on + timedelta(days=1),
datetime.min.time(),
tzinfo=timezone.utc,
)
return PaperTrade( return PaperTrade(
user_id=1, user_id=1,
ticker_id=ticker_id, ticker_id=ticker_id,
@@ -81,17 +77,21 @@ async def test_create_trade_enforces_post_stop_lockdown_at_service_boundary(sess
closed_on, datetime.min.time(), tzinfo=timezone.utc closed_on, datetime.min.time(), tzinfo=timezone.utc
), ),
close_reason="stop", close_reason="stop",
reentry_gate_failed_at=reset_at if gate_reset_complete else None,
reentry_gate_requalified_at=(
reset_at + timedelta(days=1) if gate_reset_complete else None
),
) )
session.add_all( session.add_all(
[ [
stopped_trade(blocked_id, market_sessions[1]), stopped_trade(blocked_id, gate_reset_complete=False),
stopped_trade(released_id, market_sessions[0]), stopped_trade(released_id, gate_reset_complete=True),
] ]
) )
await session.commit() await session.commit()
with pytest.raises(ValidationError, match="1 market session remaining"): with pytest.raises(ValidationError, match="requires a post-stop gate reset"):
await svc.create_trade( await svc.create_trade(
session, session,
1, 1,
+20 -42
View File
@@ -20,7 +20,6 @@ from hypothesis import given, settings, HealthCheck, strategies as st
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.benchmark_price import BenchmarkPrice
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade from app.models.paper_trade import PaperTrade
from app.models.signal_context_snapshot import SignalContextSnapshot from app.models.signal_context_snapshot import SignalContextSnapshot
@@ -609,11 +608,10 @@ async def test_get_trade_setups_can_exclude_tickers_with_open_paper_trades(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_trade_setups_applies_five_session_initial_stop_lockdown( async def test_get_trade_setups_applies_initial_stop_gate_reset_lock(
db_session: AsyncSession, db_session: AsyncSession,
): ):
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
today = now.date()
if await db_session.get(User, 1) is None: if await db_session.get(User, 1) is None:
db_session.add( db_session.add(
User(id=1, username="u", password_hash="x", role="user", has_access=True) User(id=1, username="u", password_hash="x", role="user", has_access=True)
@@ -626,38 +624,6 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
db_session.add_all([blocked, released, trailing]) db_session.add_all([blocked, released, trailing])
await db_session.flush() await db_session.flush()
# Six SPY sessions D0..D5 form the canonical market calendar. A stop on
# D0 has five later sessions and is released; a stop on D1 has only four.
market_sessions = [
today - timedelta(days=8),
today - timedelta(days=7),
today - timedelta(days=6),
today - timedelta(days=3),
today - timedelta(days=2),
today - timedelta(days=1),
]
for market_date in market_sessions:
db_session.add(
BenchmarkPrice(
symbol="SPY",
date=market_date,
close=400.0,
)
)
# A bar from an unrelated/scanner-specific calendar must not release the
# ticker one session early. The old universe-wide DISTINCT query did.
db_session.add(
OHLCVRecord(
ticker_id=blocked.id,
date=today,
open=100.0,
high=101.0,
low=99.0,
close=100.0,
volume=1_000,
)
)
for ticker in (blocked, released, trailing): for ticker in (blocked, released, trailing):
db_session.add( db_session.add(
TradeSetup( TradeSetup(
@@ -672,7 +638,13 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
) )
) )
def closed_trade(ticker: Ticker, closed_on: date, reason: str) -> PaperTrade: def closed_trade(
ticker: Ticker,
reason: str,
*,
gate_reset_complete: bool = False,
) -> PaperTrade:
closed_on = now.date() - timedelta(days=10)
return PaperTrade( return PaperTrade(
user_id=1, user_id=1,
ticker_id=ticker.id, ticker_id=ticker.id,
@@ -690,13 +662,19 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
closed_on, datetime.min.time(), tzinfo=timezone.utc closed_on, datetime.min.time(), tzinfo=timezone.utc
), ),
close_reason=reason, close_reason=reason,
reentry_gate_failed_at=(
now - timedelta(days=9) if gate_reset_complete else None
),
reentry_gate_requalified_at=(
now - timedelta(days=8) if gate_reset_complete else None
),
) )
db_session.add_all( db_session.add_all(
[ [
closed_trade(blocked, market_sessions[1], "stop"), closed_trade(blocked, "stop"),
closed_trade(released, market_sessions[0], "stop"), closed_trade(released, "stop", gate_reset_complete=True),
closed_trade(trailing, market_sessions[-1], "trailing"), closed_trade(trailing, "trailing"),
] ]
) )
await db_session.flush() await db_session.flush()
@@ -710,7 +688,7 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
row["symbol"] row["symbol"]
for row in await get_trade_setups( for row in await get_trade_setups(
db_session, db_session,
exclude_reentry_lockdown_tickers=True, exclude_reentry_gate_locked_tickers=True,
) )
} }
assert "STOP4" not in available_symbols assert "STOP4" not in available_symbols
@@ -719,10 +697,10 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
annotated = await get_trade_setups( annotated = await get_trade_setups(
db_session, db_session,
symbol="STOP4", symbol="STOP4",
include_reentry_lockdown=True, include_reentry_gate_lock=True,
) )
assert len(annotated) == 1 assert len(annotated) == 1
assert annotated[0]["reentry_lockdown_remaining_sessions"] == 1 assert annotated[0]["reentry_gate_reset_required"] is True
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup: async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from app.models.paper_trade import PaperTrade
from app.models.ticker import Ticker
from app.models.user import User
from app.services.trade_policy import (
get_reentry_gate_locks,
observe_reentry_gate_transitions,
)
from tests.conftest import _test_session_factory # type: ignore
@pytest.fixture
async def session():
async with _test_session_factory() as db:
yield db
def _stopped_trade(
ticker_id: int,
*,
closed_at: datetime,
gate_failed_at: datetime | None = None,
gate_requalified_at: datetime | None = None,
) -> PaperTrade:
return PaperTrade(
user_id=1,
ticker_id=ticker_id,
direction="long",
entry_price=100.0,
shares=10.0,
stop_loss=95.0,
target=115.0,
status="closed",
opened_at=closed_at - timedelta(days=5),
close_price=95.0,
closed_at=closed_at,
close_reason="stop",
reentry_gate_failed_at=gate_failed_at,
reentry_gate_requalified_at=gate_requalified_at,
)
async def test_observation_releases_only_evaluated_unqualified_tickers(session):
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
tickers = [
Ticker(symbol=symbol)
for symbol in ("FAILQ", "PASSQ", "ERRORQ", "LATEQ")
]
session.add_all(tickers)
await session.flush()
stopped_at = datetime.now(timezone.utc) - timedelta(days=1)
trades = [
_stopped_trade(ticker.id, closed_at=stopped_at)
for ticker in tickers[:3]
]
observed_at = datetime.now(timezone.utc)
trades.append(
_stopped_trade(
tickers[3].id,
closed_at=observed_at + timedelta(seconds=1),
)
)
session.add_all(trades)
await session.commit()
updated = await observe_reentry_gate_transitions(
session,
evaluated_ticker_ids={tickers[0].id, tickers[1].id, tickers[3].id},
qualified_ticker_ids={tickers[1].id},
observed_at=observed_at,
)
assert updated == {tickers[0].id}
locks = await get_reentry_gate_locks(session)
assert set(locks) == {ticker.id for ticker in tickers}
assert trades[0].reentry_gate_failed_at == observed_at
assert trades[0].reentry_gate_requalified_at is None
assert trades[1].reentry_gate_failed_at is None
assert trades[2].reentry_gate_failed_at is None
assert trades[3].reentry_gate_failed_at is None
requalified_at = observed_at + timedelta(days=1)
updated = await observe_reentry_gate_transitions(
session,
evaluated_ticker_ids={tickers[0].id},
qualified_ticker_ids={tickers[0].id},
observed_at=requalified_at,
)
assert updated == {tickers[0].id}
assert trades[0].reentry_gate_requalified_at == requalified_at
assert set(await get_reentry_gate_locks(session)) == {
tickers[1].id,
tickers[2].id,
tickers[3].id,
}
async def test_latest_stop_starts_a_new_gate_reset_episode(session):
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
ticker = Ticker(symbol="TWOSTOP")
session.add(ticker)
await session.flush()
first_stop = datetime.now(timezone.utc) - timedelta(days=20)
session.add_all(
[
_stopped_trade(
ticker.id,
closed_at=first_stop,
gate_failed_at=first_stop + timedelta(days=1),
gate_requalified_at=first_stop + timedelta(days=2),
),
_stopped_trade(
ticker.id,
closed_at=first_stop + timedelta(days=10),
),
]
)
await session.commit()
assert ticker.id in await get_reentry_gate_locks(session)