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)
# 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
)
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)]
+141 -41
View File
@@ -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),
+3 -6
View File
@@ -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,
+59 -17
View File
@@ -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
+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 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
@@ -373,8 +373,8 @@ export function BacktestPanel() {
<p className="text-[11px] text-gray-500">
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)}
{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}
</p>
@@ -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 (
<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">
<DirTag direction={setup.direction} />
<span className="num text-[10px] uppercase tracking-[0.16em] text-amber-300">post-stop lockdown</span>
<span className="num ml-auto text-xs text-gray-500">
{remaining} market session{remaining === 1 ? '' : 's'} remaining
</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">re-entry paused</span>
</div>
<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,
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.
</p>
</div>
);
@@ -639,11 +635,11 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
<div className="min-w-0">
{preferredInactive ? (
<span className="text-sm font-semibold text-gray-400">
{preferredInactive.kind === 'lockdown' ? (
{preferredInactive.kind === 'gate-reset' ? (
<>
Re-entry paused{' '}
<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>
</>
) : (
+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.
*/
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;
// Live R:R from current price — drops setups whose price has already run
// 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).
*/
export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): string | null {
const lockdownRemaining = setup.reentry_lockdown_remaining_sessions ?? 0;
if (lockdownRemaining > 0) {
return `post-stop lockdown · ${lockdownRemaining} session${lockdownRemaining === 1 ? '' : 's'} remaining`;
if (setup.reentry_gate_reset_required) {
return 'post-stop gate reset required';
}
if (setup.rr_ratio < config.min_rr) {
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;
strategy_rank?: number | null;
volatility_percentile?: number | null;
reentry_lockdown_remaining_sessions?: number | null;
reentry_gate_reset_required?: boolean;
context_as_of?: TradeSetupContextAsOf | null;
recommendation_summary?: RecommendationSummary;
}
@@ -356,10 +356,10 @@ export interface BacktestPortfolioMonitorRun extends BacktestPortfolioPolicy {
label: string;
description: string;
is_production: boolean;
comparison_arm?: 'live_no_lockdown' | 'live_lockdown_5' | null;
comparison_arm?: 'live_immediate' | 'live_gate_reset' | null;
entry_variant: string;
exit_policy: string;
reentry_lockdown_sessions?: number;
reentry_policy?: 'immediate' | 'gate_reset';
lookback: string;
lookback_label: string;
}
@@ -371,8 +371,8 @@ export interface BacktestPortfolioMonitor {
label: string;
description: string;
is_production: boolean;
comparison_arm?: 'live_no_lockdown' | 'live_lockdown_5' | null;
reentry_lockdown_sessions?: number;
comparison_arm?: 'live_immediate' | 'live_gate_reset' | null;
reentry_policy?: 'immediate' | 'gate_reset';
}[];
lookbacks: { lookback: string; label: string }[];
runs: BacktestPortfolioMonitorRun[];
@@ -415,7 +415,7 @@ export interface BacktestReport {
target_model?: 'production_gtl' | 'structural_sr';
target_model_label?: string;
is_production_target_model?: boolean;
production_reentry_lockdown_sessions?: number;
production_reentry_policy?: 'gate_reset';
};
overall_qualified: 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
backtest contains two otherwise identical live-policy portfolio arms: no
post-stop lockdown and the production five-session lockdown. It writes both
full reports plus one compact four-arm comparison report.
backtest contains two otherwise identical live-policy portfolio arms: immediate
post-stop re-entry and the production gate-reset rule. It writes both full
reports plus one compact four-arm comparison report.
"""
from __future__ import annotations
@@ -77,7 +77,8 @@ def _print_arm(row: dict) -> None:
print(
f" {row['arm']}: Sharpe {row.get('sharpe')}, "
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 = {
"prod_live_setup_weekly",
"prod_live_setup_daily",
"cooldown_5_weekly",
"cooldown_5_daily",
"gate_reset_weekly",
"gate_reset_daily",
}
if {row.get("arm") for row in arms} != expected:
raise RuntimeError("The generated cadence report does not contain all four arms")
arm_order = {
"prod_live_setup_weekly": 0,
"prod_live_setup_daily": 1,
"cooldown_5_weekly": 2,
"cooldown_5_daily": 3,
"gate_reset_weekly": 2,
"gate_reset_daily": 3,
}
arms.sort(key=lambda row: arm_order[str(row["arm"])])
@@ -164,7 +165,7 @@ async def _main() -> None:
"note": (
"All four arms use the same snapshot, activation settings, target model, "
"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"
+58 -20
View File
@@ -679,11 +679,51 @@ class TestSimulatePortfolio:
assert sim["trades"] == 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):
return {
"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)
@@ -694,37 +734,38 @@ class TestSimulatePortfolio:
production_rows = [
row for row in monitor["runs"] if row["is_production"]
]
comparison_rows = [
row for row in monitor["runs"] if not row["is_production"]
immediate_rows = [
row for row in monitor["runs"]
if row["comparison_arm"] == "live_immediate"
]
assert production_rows
assert all(
row["reentry_lockdown_sessions"] == bt.REENTRY_LOCKDOWN_SESSIONS
and row["applied_reentry_lockdown"] == bt.REENTRY_LOCKDOWN_SESSIONS
row["reentry_policy"] == "gate_reset"
and row["applied_gate_reset"] is True
for row in production_rows
)
assert comparison_rows
assert immediate_rows
assert all(
row["reentry_lockdown_sessions"] == 0
and row["applied_reentry_lockdown"] == 0
for row in comparison_rows
row["reentry_policy"] == "immediate"
and row["applied_gate_reset"] is False
for row in immediate_rows
)
def test_production_cadence_comparison_names_exact_two_arms(self):
monitor = {
"runs": [
{
"comparison_arm": "live_no_lockdown",
"comparison_arm": "live_immediate",
"lookback": "all",
"reentry_lockdown_sessions": 0,
"reentry_policy": "immediate",
"trades": 10,
"equity_curve": [{"date": "2026-01-01", "value": 1.0}],
},
{
"comparison_arm": "live_lockdown_5",
"comparison_arm": "live_gate_reset",
"lookback": "all",
"reentry_lockdown_sessions": 5,
"reentry_policy": "gate_reset",
"trades": 8,
"benchmark_curve": [{"date": "2026-01-01", "value": 1.0}],
},
@@ -736,7 +777,7 @@ class TestSimulatePortfolio:
assert comparison is not None
assert [row["arm"] for row in comparison["arms"]] == [
"prod_live_setup_daily",
"cooldown_5_daily",
"gate_reset_daily",
]
assert all("equity_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 "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"])
@@ -1261,10 +1302,7 @@ async def test_run_backtest_smoke(session):
assert report["params"]["is_production_target_model"] is True
assert report["params"]["entry_cadence"] == "weekly"
assert report["params"]["step_sessions"] == 5
assert (
report["params"]["production_reentry_lockdown_sessions"]
== bt.REENTRY_LOCKDOWN_SESSIONS
)
assert report["params"]["production_reentry_policy"] == "gate_reset"
assert "net_avg_r" in report["overall_all"]
# 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
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)
released_id = await _seed(session, "FREEQ", close=100.0)
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(
user_id=1,
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
),
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(
[
stopped_trade(blocked_id, market_sessions[1]),
stopped_trade(released_id, market_sessions[0]),
stopped_trade(blocked_id, gate_reset_complete=False),
stopped_trade(released_id, gate_reset_complete=True),
]
)
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(
session,
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.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.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
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,
):
now = datetime.now(timezone.utc)
today = now.date()
if await db_session.get(User, 1) is None:
db_session.add(
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])
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):
db_session.add(
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(
user_id=1,
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
),
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(
[
closed_trade(blocked, market_sessions[1], "stop"),
closed_trade(released, market_sessions[0], "stop"),
closed_trade(trailing, market_sessions[-1], "trailing"),
closed_trade(blocked, "stop"),
closed_trade(released, "stop", gate_reset_complete=True),
closed_trade(trailing, "trailing"),
]
)
await db_session.flush()
@@ -710,7 +688,7 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
row["symbol"]
for row in await get_trade_setups(
db_session,
exclude_reentry_lockdown_tickers=True,
exclude_reentry_gate_locked_tickers=True,
)
}
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(
db_session,
symbol="STOP4",
include_reentry_lockdown=True,
include_reentry_gate_lock=True,
)
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:
+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)