fix: harden post-stop reentry lockdown

This commit is contained in:
2026-07-17 14:17:57 +02:00
parent 1e9f2dc4fb
commit bc50ba9136
13 changed files with 318 additions and 81 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ async def get_ticker_trade_setups(
db,
symbol=symbol,
live_recommendation=True,
exclude_reentry_lockdown_tickers=True,
include_reentry_lockdown=True,
)
data = []
for row in rows:
+1
View File
@@ -59,5 +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
context_as_of: TradeSetupContextAsOfResponse | None = None
recommendation_summary: RecommendationSummaryResponse | None = None
+10 -10
View File
@@ -1356,7 +1356,7 @@ def _simulate_portfolio(
max_positions: int = SIM_MAX_POSITIONS,
risk_per_trade: float = SIM_RISK_PER_TRADE,
atr_trail_multiplier: float = ATR_TRAIL_MULTIPLIER,
reentry_cooldown_days: int = 0,
reentry_cooldown_sessions: int = 0,
initial_stop_refresh_fn: (
Callable[[str, int, float, dict, Any], float | None] | None
) = None,
@@ -1380,7 +1380,7 @@ def _simulate_portfolio(
runs the ATR trail *and* the S/R take-profit together — the trade ends at
whichever comes first. Stops fill at the worse of stop or open (gaps
modeled); positions still open at the end are closed at their last mark.
``reentry_cooldown_days`` blocks a ticker for that many market sessions
``reentry_cooldown_sessions`` blocks a ticker for that many market sessions
after an initial-stop loss. Profitable trailing-stop exits do not trigger
it. ``initial_stop_refresh_fn`` may supply a lower, point-in-time valid long
stop when the active initial stop is touched; the replacement is still
@@ -1541,7 +1541,7 @@ def _simulate_portfolio(
def _marked_equity() -> float:
return cash + sum(p["shares"] * p["last_close"] for p in positions.values())
cooldown_days = max(0, int(reentry_cooldown_days))
cooldown_sessions = max(0, int(reentry_cooldown_sessions))
for calendar_index, o in enumerate(calendar):
# 1) exits on today's bars (stop intraday, target intraday, time at close)
for sym in list(positions):
@@ -1579,8 +1579,8 @@ def _simulate_portfolio(
if not survived_refresh:
fill = min(pos["stop"], bar.open)
closed_pos = _close_trade(sym, fill, reason)
if reason == "stop" and cooldown_days:
cooldown_until_index[sym] = calendar_index + cooldown_days
if reason == "stop" and cooldown_sessions:
cooldown_until_index[sym] = calendar_index + cooldown_sessions
if reason == "stop" and post_stop_reentry_fn is not None:
post_stop_events += 1
post_stop_states[sym] = {
@@ -1833,8 +1833,8 @@ def _simulate_portfolio(
result["equity_curve"] = curve_payload
if benchmark_payload is not None:
result["benchmark_curve"] = benchmark_payload
if cooldown_days:
result["reentry_cooldown_days"] = cooldown_days
if cooldown_sessions:
result["reentry_cooldown_sessions"] = cooldown_sessions
result["skipped_cooldown"] = skipped_cooldown
if initial_stop_refresh_fn is not None:
result["stop_refresh_attempts"] = stop_refresh_attempts
@@ -2403,7 +2403,7 @@ 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_days=reentry_lockdown_sessions,
reentry_cooldown_sessions=reentry_lockdown_sessions,
start_date=sweep_start,
)
if sim is None:
@@ -2516,7 +2516,7 @@ def _holdout_evaluation(
max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier,
reentry_cooldown_days=reentry_lockdown_sessions,
reentry_cooldown_sessions=reentry_lockdown_sessions,
start_date=start,
end_date=end,
include_curve=True,
@@ -2592,7 +2592,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_days=reentry_lockdown_sessions,
reentry_cooldown_sessions=reentry_lockdown_sessions,
start_date=start,
include_curve=True,
)
+8
View File
@@ -20,6 +20,7 @@ from app.services.outcome_service import (
Bar,
evaluate_setup_against_bars,
)
from app.services.trade_policy import get_reentry_lockdowns
# 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
@@ -318,6 +319,13 @@ 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"
raise ValidationError(
f"{ticker.symbol} is in a post-stop re-entry lockdown: "
f"{remaining_sessions} market {suffix} remaining"
)
trade = PaperTrade(
user_id=user_id,
ticker_id=ticker.id,
+15 -2
View File
@@ -29,7 +29,7 @@ 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.sr_service import detect_gate_target_ladder
from app.services.trade_policy import get_reentry_lockdown_ticker_ids
from app.services.trade_policy import get_reentry_lockdowns
from app.services.recommendation_service import (
_risk_level_from_conflicts,
build_recommendation_snapshot,
@@ -773,6 +773,7 @@ async def get_trade_setups(
live_recommendation: bool = False,
exclude_open_trade_tickers: bool = False,
exclude_reentry_lockdown_tickers: bool = False,
include_reentry_lockdown: bool = False,
) -> list[dict]:
"""Get latest stored trade setups, optionally filtered.
@@ -797,6 +798,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] = {}
if exclude_open_trade_tickers:
open_trade_result = await db.execute(
select(PaperTrade.ticker_id)
@@ -806,8 +808,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(await get_reentry_lockdown_ticker_ids(db))
excluded_ticker_ids.update(reentry_lockdowns)
if excluded_ticker_ids:
stmt = stmt.where(~TradeSetup.ticker_id.in_(excluded_ticker_ids))
@@ -862,6 +866,15 @@ async def get_trade_setups(
),
reverse=True,
)
if include_reentry_lockdown:
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
)
return rows_out
+95 -43
View File
@@ -2,13 +2,16 @@
from __future__ import annotations
from datetime import date, datetime, time, timezone
from collections import defaultdict
from datetime import date, datetime, timezone
from sqlalchemy import select
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.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
@@ -17,52 +20,101 @@ from app.models.paper_trade import PaperTrade
REENTRY_LOCKDOWN_SESSIONS = 5
async def get_reentry_lockdowns(
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"),
)
.where(
PaperTrade.status == "closed",
PaperTrade.close_reason == "stop",
PaperTrade.closed_at.is_not(None),
)
.group_by(PaperTrade.ticker_id)
)
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 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(
db: AsyncSession,
*,
as_of: date | None = None,
sessions: int = REENTRY_LOCKDOWN_SESSIONS,
) -> set[int]:
"""Ticker ids still inside the post-initial-stop market-session lockdown.
The market calendar is derived from stored OHLCV dates, not calendar days.
A stop on session D is released once five later stored sessions exist. Only
an initial-stop close (``close_reason == "stop"``) starts the lockdown;
trailing, target, time, and manual exits do not.
"""
sessions = max(0, int(sessions))
if sessions == 0:
return set()
session_cutoff = as_of or datetime.now(timezone.utc).date()
session_result = await db.execute(
select(OHLCVRecord.date)
.where(OHLCVRecord.date <= session_cutoff)
.distinct()
.order_by(OHLCVRecord.date.desc())
.limit(sessions)
)
recent_sessions = [row[0] for row in session_result.all()]
if not recent_sessions:
return set()
# Stops on or after the oldest of the latest N sessions have fewer than N
# later completed sessions. Once that oldest session rolls forward, the
# corresponding stop automatically leaves the result set.
lockdown_threshold = min(recent_sessions)
threshold_start = datetime.combine(
lockdown_threshold,
time.min,
tzinfo=timezone.utc,
)
result = await db.execute(
select(PaperTrade.ticker_id)
.where(
PaperTrade.status == "closed",
PaperTrade.close_reason == "stop",
PaperTrade.closed_at.is_not(None),
PaperTrade.closed_at >= threshold_start,
"""Compatibility wrapper for callers that only need blocked ticker ids."""
return set(
await get_reentry_lockdowns(
db,
as_of=as_of,
sessions=sessions,
)
.distinct()
)
return {ticker_id for ticker_id, in result.all()}
@@ -66,14 +66,19 @@ function entryDrift(setup: TradeSetup, currentPrice?: number) {
return { pct, r, status };
}
/**
* The only state with no tradeable setup left: price has gone through the stop.
* Returns null when there's no live price.
*/
type NotActionableState =
| { kind: 'lockdown'; remainingSessions: number }
| { 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 (currentPrice == null) return null;
if (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null;
return { invalidated: true };
return { kind: 'invalidated' } satisfies NotActionableState;
}
function riskClass(risk: TradeSetup['risk_level']) {
@@ -218,9 +223,6 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
const exitPlan = deriveExitPlan(setup, exitPolicy);
const honorsTarget = exitPlan?.honorsTarget ?? false;
// Only price through the stop leaves no tradeable setup.
const notActionable = notActionableState(setup, currentPrice) != null;
const createTrade = useCreatePaperTrade();
const [taking, setTaking] = useState(false);
const [takeShares, setTakeShares] = useState<number>(sizing?.shares ?? 0);
@@ -268,7 +270,27 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
);
};
if (notActionable) {
const inactiveState = notActionableState(setup, currentPrice);
if (inactiveState?.kind === 'lockdown') {
const remaining = inactiveState.remainingSessions;
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>
</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.
</p>
</div>
);
}
if (inactiveState?.kind === 'invalidated') {
const dir = setup.direction.toUpperCase();
return (
<div data-direction={setup.direction} className="rounded-xl border border-white/[0.07] p-4">
@@ -617,7 +639,21 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
<div className="min-w-0">
{preferredInactive ? (
<span className="text-sm font-semibold text-gray-400">
No current setup <span className="font-normal text-gray-500">(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} invalidated at the stop)</span>
{preferredInactive.kind === 'lockdown' ? (
<>
Re-entry paused{' '}
<span className="font-normal text-gray-500">
({preferredInactive.remainingSessions} market session{preferredInactive.remainingSessions === 1 ? '' : 's'} remaining after stop)
</span>
</>
) : (
<>
No current setup{' '}
<span className="font-normal text-gray-500">
(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} invalidated at the stop)
</span>
</>
)}
</span>
) : (() => {
const reasoning = summary?.reasoning ?? '';
+5
View File
@@ -43,6 +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.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.
@@ -79,6 +80,10 @@ 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.rr_ratio < config.min_rr) {
return `R:R ${setup.rr_ratio.toFixed(1)} below gate ${config.min_rr.toFixed(1)}`;
}
+1
View File
@@ -144,6 +144,7 @@ export interface TradeSetup {
momentum_percentile?: number | null;
strategy_rank?: number | null;
volatility_percentile?: number | null;
reentry_lockdown_remaining_sessions?: number | null;
context_as_of?: TradeSetupContextAsOf | null;
recommendation_summary?: RecommendationSummary;
}
+2 -2
View File
@@ -330,7 +330,7 @@ async def _main() -> None:
benchmark_closes,
exit_policy,
hold_days,
reentry_cooldown_days=5,
reentry_cooldown_sessions=5,
**sim_kwargs,
)
cooldown_10 = bt._simulate_portfolio(
@@ -339,7 +339,7 @@ async def _main() -> None:
benchmark_closes,
exit_policy,
hold_days,
reentry_cooldown_days=10,
reentry_cooldown_sessions=10,
**sim_kwargs,
)
refresher = GateStopRefresher(
+31 -3
View File
@@ -590,19 +590,47 @@ class TestSimulatePortfolio:
None,
"hold",
30,
reentry_cooldown_days=5,
reentry_cooldown_sessions=5,
)
assert baseline is not None and baseline["trades"] == 2
assert cooldown is not None and cooldown["trades"] == 1
assert cooldown["skipped_cooldown"] == 1
assert cooldown["reentry_cooldown_days"] == 5
assert cooldown["reentry_cooldown_sessions"] == 5
def test_initial_stop_cooldown_unlocks_exactly_after_session_five(self):
closes = [100.0, 94.0, 96.0, 96.0, 96.0, 96.0, 97.0, 98.0]
prices = {"AAA": _sim_prices(self.ORD, closes)}
candidates = [
_sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=120.0),
# Four completed sessions since the stop: still locked.
_sim_cand("AAA", self.ORD + 5, entry=96.0, stop=90.0, target=115.0),
# Five completed sessions since the stop: first permitted re-entry.
_sim_cand("AAA", self.ORD + 6, entry=97.0, stop=90.0, target=118.0),
]
sim = bt._simulate_portfolio(
candidates,
prices,
None,
"hold",
30,
reentry_cooldown_sessions=5,
include_trades=True,
)
assert sim is not None
assert sim["trades"] == 2
assert sim["skipped_cooldown"] == 1
assert sim["trade_details"][1]["entry_date"] == date.fromordinal(
self.ORD + 6
).isoformat()
def test_production_monitor_applies_live_reentry_lockdown(self, monkeypatch):
def fake_simulator(*_args, **kwargs):
return {
"trades": 0,
"applied_reentry_lockdown": kwargs.get("reentry_cooldown_days", 0),
"applied_reentry_lockdown": kwargs.get("reentry_cooldown_sessions", 0),
}
monkeypatch.setattr(bt, "_simulate_portfolio", fake_simulator)
+68
View File
@@ -48,6 +48,74 @@ 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):
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:
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=datetime.combine(
closed_on - timedelta(days=1), datetime.min.time(), tzinfo=timezone.utc
),
close_price=95.0,
closed_at=datetime.combine(
closed_on, datetime.min.time(), tzinfo=timezone.utc
),
close_reason="stop",
)
session.add_all(
[
stopped_trade(blocked_id, market_sessions[1]),
stopped_trade(released_id, market_sessions[0]),
]
)
await session.commit()
with pytest.raises(ValidationError, match="1 market session remaining"):
await svc.create_trade(
session,
1,
symbol="LOCKQ",
direction="long",
entry_price=100.0,
shares=10.0,
stop_loss=95.0,
target=115.0,
)
trade = await svc.create_trade(
session,
1,
symbol="FREEQ",
direction="long",
entry_price=100.0,
shares=10.0,
stop_loss=95.0,
target=115.0,
)
assert trade.ticker_id == released_id
async def test_close_uses_current_price(session):
await _seed(session, "AAA", close=112.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
+35 -10
View File
@@ -20,6 +20,7 @@ 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
@@ -625,21 +626,37 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
db_session.add_all([blocked, released, trailing])
await db_session.flush()
# Six synthetic stored market sessions D0..D5. A stop on D0 has five
# later sessions and is released; a stop on D1 has only four and is not.
market_sessions = [today - timedelta(days=offset) for offset in range(5, -1, -1)]
# 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(
OHLCVRecord(
ticker_id=blocked.id,
BenchmarkPrice(
symbol="SPY",
date=market_date,
open=100.0,
high=101.0,
low=99.0,
close=100.0,
volume=1_000,
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(
@@ -699,6 +716,14 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
assert "STOP4" not in available_symbols
assert {"STOP5", "TRAILQ"}.issubset(available_symbols)
annotated = await get_trade_setups(
db_session,
symbol="STOP4",
include_reentry_lockdown=True,
)
assert len(annotated) == 1
assert annotated[0]["reentry_lockdown_remaining_sessions"] == 1
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
"""Stored setup frozen at scan time (conf 82, neutral) vs. current context