From bc50ba913681147f06beb394b2b73c4ed5109bdb Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Fri, 17 Jul 2026 14:17:57 +0200 Subject: [PATCH] fix: harden post-stop reentry lockdown --- app/routers/trades.py | 2 +- app/schemas/trade_setup.py | 1 + app/services/backtest_service.py | 20 +-- app/services/paper_trade_service.py | 8 + app/services/rr_scanner_service.py | 17 ++- app/services/trade_policy.py | 138 ++++++++++++------ .../components/ticker/RecommendationPanel.tsx | 56 +++++-- frontend/src/lib/qualification.ts | 5 + frontend/src/lib/types.ts | 1 + scripts/run_gate_protected_stop_study.py | 4 +- tests/unit/test_backtest_service.py | 34 ++++- tests/unit/test_paper_trade_service.py | 68 +++++++++ tests/unit/test_rr_scanner_preservation.py | 45 ++++-- 13 files changed, 318 insertions(+), 81 deletions(-) diff --git a/app/routers/trades.py b/app/routers/trades.py index 7c6470e..66b35f8 100644 --- a/app/routers/trades.py +++ b/app/routers/trades.py @@ -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: diff --git a/app/schemas/trade_setup.py b/app/schemas/trade_setup.py index 975c5c8..06920e2 100644 --- a/app/schemas/trade_setup.py +++ b/app/schemas/trade_setup.py @@ -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 diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 485765a..071706d 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -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, ) diff --git a/app/services/paper_trade_service.py b/app/services/paper_trade_service.py index 23020ce..b170e30 100644 --- a/app/services/paper_trade_service.py +++ b/app/services/paper_trade_service.py @@ -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, diff --git a/app/services/rr_scanner_service.py b/app/services/rr_scanner_service.py index 78c9388..21becf0 100644 --- a/app/services/rr_scanner_service.py +++ b/app/services/rr_scanner_service.py @@ -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 diff --git a/app/services/trade_policy.py b/app/services/trade_policy.py index b29dd0d..d9773ca 100644 --- a/app/services/trade_policy.py +++ b/app/services/trade_policy.py @@ -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()} diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index 74140d9..2c1a8e9 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -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(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 ( +
+
+ + post-stop lockdown + + {remaining} market session{remaining === 1 ? '' : 's'} remaining + +
+

+ 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. +

+
+ ); + } + + if (inactiveState?.kind === 'invalidated') { const dir = setup.direction.toUpperCase(); return (
@@ -617,7 +639,21 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
{preferredInactive ? ( - No current setup (last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} — invalidated at the stop) + {preferredInactive.kind === 'lockdown' ? ( + <> + Re-entry paused{' '} + + ({preferredInactive.remainingSessions} market session{preferredInactive.remainingSessions === 1 ? '' : 's'} remaining after stop) + + + ) : ( + <> + No current setup{' '} + + (last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} — invalidated at the stop) + + + )} ) : (() => { const reasoning = summary?.reasoning ?? ''; diff --git a/frontend/src/lib/qualification.ts b/frontend/src/lib/qualification.ts index 14a6b44..83bdbf5 100644 --- a/frontend/src/lib/qualification.ts +++ b/frontend/src/lib/qualification.ts @@ -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)}`; } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 978bea9..7a771c6 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; } diff --git a/scripts/run_gate_protected_stop_study.py b/scripts/run_gate_protected_stop_study.py index fcf2197..d674589 100644 --- a/scripts/run_gate_protected_stop_study.py +++ b/scripts/run_gate_protected_stop_study.py @@ -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( diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index 5231327..1cf5337 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -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) diff --git a/tests/unit/test_paper_trade_service.py b/tests/unit/test_paper_trade_service.py index 7638365..21bad9c 100644 --- a/tests/unit/test_paper_trade_service.py +++ b/tests/unit/test_paper_trade_service.py @@ -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", diff --git a/tests/unit/test_rr_scanner_preservation.py b/tests/unit/test_rr_scanner_preservation.py index 3631553..b6c37a0 100644 --- a/tests/unit/test_rr_scanner_preservation.py +++ b/tests/unit/test_rr_scanner_preservation.py @@ -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