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, db,
symbol=symbol, symbol=symbol,
live_recommendation=True, live_recommendation=True,
exclude_reentry_lockdown_tickers=True, include_reentry_lockdown=True,
) )
data = [] data = []
for row in rows: for row in rows:
+1
View File
@@ -59,5 +59,6 @@ class TradeSetupResponse(BaseModel):
momentum_percentile: float | None = None momentum_percentile: float | None = None
strategy_rank: float | None = None strategy_rank: float | None = None
volatility_percentile: float | None = None volatility_percentile: float | None = None
reentry_lockdown_remaining_sessions: int | None = None
context_as_of: TradeSetupContextAsOfResponse | None = None context_as_of: TradeSetupContextAsOfResponse | None = None
recommendation_summary: RecommendationSummaryResponse | None = None recommendation_summary: RecommendationSummaryResponse | None = None
+10 -10
View File
@@ -1356,7 +1356,7 @@ def _simulate_portfolio(
max_positions: int = SIM_MAX_POSITIONS, max_positions: int = SIM_MAX_POSITIONS,
risk_per_trade: float = SIM_RISK_PER_TRADE, risk_per_trade: float = SIM_RISK_PER_TRADE,
atr_trail_multiplier: float = ATR_TRAIL_MULTIPLIER, atr_trail_multiplier: float = ATR_TRAIL_MULTIPLIER,
reentry_cooldown_days: int = 0, reentry_cooldown_sessions: int = 0,
initial_stop_refresh_fn: ( initial_stop_refresh_fn: (
Callable[[str, int, float, dict, Any], float | None] | None Callable[[str, int, float, dict, Any], float | None] | 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 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 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. 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 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 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 stop when the active initial stop is touched; the replacement is still
@@ -1541,7 +1541,7 @@ def _simulate_portfolio(
def _marked_equity() -> float: def _marked_equity() -> float:
return cash + sum(p["shares"] * p["last_close"] for p in positions.values()) 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): for calendar_index, o in enumerate(calendar):
# 1) exits on today's bars (stop intraday, target intraday, time at close) # 1) exits on today's bars (stop intraday, target intraday, time at close)
for sym in list(positions): for sym in list(positions):
@@ -1579,8 +1579,8 @@ def _simulate_portfolio(
if not survived_refresh: if not survived_refresh:
fill = min(pos["stop"], bar.open) fill = min(pos["stop"], bar.open)
closed_pos = _close_trade(sym, fill, reason) closed_pos = _close_trade(sym, fill, reason)
if reason == "stop" and cooldown_days: if reason == "stop" and cooldown_sessions:
cooldown_until_index[sym] = calendar_index + cooldown_days cooldown_until_index[sym] = calendar_index + cooldown_sessions
if reason == "stop" and post_stop_reentry_fn is not None: if reason == "stop" and post_stop_reentry_fn is not None:
post_stop_events += 1 post_stop_events += 1
post_stop_states[sym] = { post_stop_states[sym] = {
@@ -1833,8 +1833,8 @@ def _simulate_portfolio(
result["equity_curve"] = curve_payload result["equity_curve"] = curve_payload
if benchmark_payload is not None: if benchmark_payload is not None:
result["benchmark_curve"] = benchmark_payload result["benchmark_curve"] = benchmark_payload
if cooldown_days: if cooldown_sessions:
result["reentry_cooldown_days"] = cooldown_days result["reentry_cooldown_sessions"] = cooldown_sessions
result["skipped_cooldown"] = skipped_cooldown result["skipped_cooldown"] = skipped_cooldown
if initial_stop_refresh_fn is not None: if initial_stop_refresh_fn is not None:
result["stop_refresh_attempts"] = stop_refresh_attempts result["stop_refresh_attempts"] = stop_refresh_attempts
@@ -2403,7 +2403,7 @@ def _min_rr_sweep(
max_positions=int(entry_cfg["max_positions"]), max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]), risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier, atr_trail_multiplier=trail_multiplier,
reentry_cooldown_days=reentry_lockdown_sessions, reentry_cooldown_sessions=reentry_lockdown_sessions,
start_date=sweep_start, start_date=sweep_start,
) )
if sim is None: if sim is None:
@@ -2516,7 +2516,7 @@ def _holdout_evaluation(
max_positions=int(entry_cfg["max_positions"]), max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]), risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier, atr_trail_multiplier=trail_multiplier,
reentry_cooldown_days=reentry_lockdown_sessions, reentry_cooldown_sessions=reentry_lockdown_sessions,
start_date=start, start_date=start,
end_date=end, end_date=end,
include_curve=True, include_curve=True,
@@ -2592,7 +2592,7 @@ def _portfolio_monitor(
max_positions=int(entry_cfg["max_positions"]), max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]), risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier, atr_trail_multiplier=trail_multiplier,
reentry_cooldown_days=reentry_lockdown_sessions, reentry_cooldown_sessions=reentry_lockdown_sessions,
start_date=start, start_date=start,
include_curve=True, include_curve=True,
) )
+8
View File
@@ -20,6 +20,7 @@ from app.services.outcome_service import (
Bar, Bar,
evaluate_setup_against_bars, evaluate_setup_against_bars,
) )
from app.services.trade_policy import get_reentry_lockdowns
# Exit policy for OPEN paper trades (auto-close). Production defaults to the # Exit policy for OPEN paper trades (auto-close). Production defaults to the
# July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max # July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max
@@ -318,6 +319,13 @@ async def create_trade(
raise ValidationError("shares and entry_price must be positive") raise ValidationError("shares and entry_price must be positive")
ticker = await _get_ticker(db, symbol) ticker = await _get_ticker(db, symbol)
remaining_sessions = (await get_reentry_lockdowns(db)).get(ticker.id)
if 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( trade = PaperTrade(
user_id=user_id, user_id=user_id,
ticker_id=ticker.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.indicator_service import _extract_ohlcv, compute_atr
from app.services.price_service import query_ohlcv from app.services.price_service import query_ohlcv
from app.services.sr_service import detect_gate_target_ladder from app.services.sr_service import detect_gate_target_ladder
from app.services.trade_policy import get_reentry_lockdown_ticker_ids from app.services.trade_policy import get_reentry_lockdowns
from app.services.recommendation_service import ( from app.services.recommendation_service import (
_risk_level_from_conflicts, _risk_level_from_conflicts,
build_recommendation_snapshot, build_recommendation_snapshot,
@@ -773,6 +773,7 @@ async def get_trade_setups(
live_recommendation: bool = False, live_recommendation: bool = False,
exclude_open_trade_tickers: bool = False, exclude_open_trade_tickers: bool = False,
exclude_reentry_lockdown_tickers: bool = False, exclude_reentry_lockdown_tickers: bool = False,
include_reentry_lockdown: bool = False,
) -> list[dict]: ) -> list[dict]:
"""Get latest stored trade setups, optionally filtered. """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: if recommended_action is not None and not live_recommendation:
stmt = stmt.where(TradeSetup.recommended_action == recommended_action) stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
excluded_ticker_ids: set[int] = set() excluded_ticker_ids: set[int] = set()
reentry_lockdowns: dict[int, int] = {}
if exclude_open_trade_tickers: if exclude_open_trade_tickers:
open_trade_result = await db.execute( open_trade_result = await db.execute(
select(PaperTrade.ticker_id) select(PaperTrade.ticker_id)
@@ -806,8 +808,10 @@ async def get_trade_setups(
excluded_ticker_ids.update( excluded_ticker_ids.update(
ticker_id for ticker_id, in open_trade_result.all() ticker_id for ticker_id, in open_trade_result.all()
) )
if exclude_reentry_lockdown_tickers or include_reentry_lockdown:
reentry_lockdowns = await get_reentry_lockdowns(db)
if exclude_reentry_lockdown_tickers: 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: if excluded_ticker_ids:
stmt = stmt.where(~TradeSetup.ticker_id.in_(excluded_ticker_ids)) stmt = stmt.where(~TradeSetup.ticker_id.in_(excluded_ticker_ids))
@@ -862,6 +866,15 @@ async def get_trade_setups(
), ),
reverse=True, 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 return rows_out
+95 -43
View File
@@ -2,13 +2,16 @@
from __future__ import annotations 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 sqlalchemy.ext.asyncio import AsyncSession
from app.models.benchmark_price import BenchmarkPrice
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade from app.models.paper_trade import PaperTrade
from app.services.benchmark_service import BENCHMARK_SYMBOL
# A ticker stopped at its initial stop may qualify again immediately, but the # A ticker stopped at its initial stop may qualify again immediately, but the
# July 2026 event study showed that waiting five market sessions materially # 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 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( async def get_reentry_lockdown_ticker_ids(
db: AsyncSession, db: AsyncSession,
*, *,
as_of: date | None = None, as_of: date | None = None,
sessions: int = REENTRY_LOCKDOWN_SESSIONS, sessions: int = REENTRY_LOCKDOWN_SESSIONS,
) -> set[int]: ) -> set[int]:
"""Ticker ids still inside the post-initial-stop market-session lockdown. """Compatibility wrapper for callers that only need blocked ticker ids."""
return set(
The market calendar is derived from stored OHLCV dates, not calendar days. await get_reentry_lockdowns(
A stop on session D is released once five later stored sessions exist. Only db,
an initial-stop close (``close_reason == "stop"``) starts the lockdown; as_of=as_of,
trailing, target, time, and manual exits do not. sessions=sessions,
"""
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,
) )
.distinct()
) )
return {ticker_id for ticker_id, in result.all()}
@@ -66,14 +66,19 @@ function entryDrift(setup: TradeSetup, currentPrice?: number) {
return { pct, r, status }; return { pct, r, status };
} }
/** type NotActionableState =
* The only state with no tradeable setup left: price has gone through the stop. | { kind: 'lockdown'; remainingSessions: number }
* Returns null when there's no live price. | { kind: 'invalidated' }
*/ | null;
function notActionableState(setup: TradeSetup, currentPrice?: number) { 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 (currentPrice == null) return null;
if (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null; if (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null;
return { invalidated: true }; return { kind: 'invalidated' } satisfies NotActionableState;
} }
function riskClass(risk: TradeSetup['risk_level']) { 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 exitPlan = deriveExitPlan(setup, exitPolicy);
const honorsTarget = exitPlan?.honorsTarget ?? false; const honorsTarget = exitPlan?.honorsTarget ?? false;
// Only price through the stop leaves no tradeable setup.
const notActionable = notActionableState(setup, currentPrice) != null;
const createTrade = useCreatePaperTrade(); const createTrade = useCreatePaperTrade();
const [taking, setTaking] = useState(false); const [taking, setTaking] = useState(false);
const [takeShares, setTakeShares] = useState<number>(sizing?.shares ?? 0); 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(); const dir = setup.direction.toUpperCase();
return ( return (
<div data-direction={setup.direction} className="rounded-xl border border-white/[0.07] p-4"> <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"> <div className="min-w-0">
{preferredInactive ? ( {preferredInactive ? (
<span className="text-sm font-semibold text-gray-400"> <span className="text-sm font-semibold text-gray-400">
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> </span>
) : (() => { ) : (() => {
const reasoning = summary?.reasoning ?? ''; 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. * app/services/qualification.py — keep the two in sync.
*/ */
export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean { export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean {
if ((setup.reentry_lockdown_remaining_sessions ?? 0) > 0) return false;
if (setup.rr_ratio < config.min_rr) return false; if (setup.rr_ratio < config.min_rr) return false;
// Live R:R from current price — drops setups whose price has already run // Live R:R from current price — drops setups whose price has already run
// toward target (reward consumed) or through the stop. // toward target (reward consumed) or through the stop.
@@ -79,6 +80,10 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
* qualifiesSetup rule-for-rule (keep the order in sync). * qualifiesSetup rule-for-rule (keep the order in sync).
*/ */
export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): string | null { export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): string | null {
const lockdownRemaining = setup.reentry_lockdown_remaining_sessions ?? 0;
if (lockdownRemaining > 0) {
return `post-stop lockdown · ${lockdownRemaining} session${lockdownRemaining === 1 ? '' : 's'} remaining`;
}
if (setup.rr_ratio < config.min_rr) { if (setup.rr_ratio < config.min_rr) {
return `R:R ${setup.rr_ratio.toFixed(1)} below gate ${config.min_rr.toFixed(1)}`; return `R:R ${setup.rr_ratio.toFixed(1)} below gate ${config.min_rr.toFixed(1)}`;
} }
+1
View File
@@ -144,6 +144,7 @@ export interface TradeSetup {
momentum_percentile?: number | null; momentum_percentile?: number | null;
strategy_rank?: number | null; strategy_rank?: number | null;
volatility_percentile?: number | null; volatility_percentile?: number | null;
reentry_lockdown_remaining_sessions?: number | null;
context_as_of?: TradeSetupContextAsOf | null; context_as_of?: TradeSetupContextAsOf | null;
recommendation_summary?: RecommendationSummary; recommendation_summary?: RecommendationSummary;
} }
+2 -2
View File
@@ -330,7 +330,7 @@ async def _main() -> None:
benchmark_closes, benchmark_closes,
exit_policy, exit_policy,
hold_days, hold_days,
reentry_cooldown_days=5, reentry_cooldown_sessions=5,
**sim_kwargs, **sim_kwargs,
) )
cooldown_10 = bt._simulate_portfolio( cooldown_10 = bt._simulate_portfolio(
@@ -339,7 +339,7 @@ async def _main() -> None:
benchmark_closes, benchmark_closes,
exit_policy, exit_policy,
hold_days, hold_days,
reentry_cooldown_days=10, reentry_cooldown_sessions=10,
**sim_kwargs, **sim_kwargs,
) )
refresher = GateStopRefresher( refresher = GateStopRefresher(
+31 -3
View File
@@ -590,19 +590,47 @@ class TestSimulatePortfolio:
None, None,
"hold", "hold",
30, 30,
reentry_cooldown_days=5, reentry_cooldown_sessions=5,
) )
assert baseline is not None and baseline["trades"] == 2 assert baseline is not None and baseline["trades"] == 2
assert cooldown is not None and cooldown["trades"] == 1 assert cooldown is not None and cooldown["trades"] == 1
assert cooldown["skipped_cooldown"] == 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 test_production_monitor_applies_live_reentry_lockdown(self, monkeypatch):
def fake_simulator(*_args, **kwargs): def fake_simulator(*_args, **kwargs):
return { return {
"trades": 0, "trades": 0,
"applied_reentry_lockdown": kwargs.get("reentry_cooldown_days", 0), "applied_reentry_lockdown": kwargs.get("reentry_cooldown_sessions", 0),
} }
monkeypatch.setattr(bt, "_simulate_portfolio", fake_simulator) 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 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): async def test_close_uses_current_price(session):
await _seed(session, "AAA", close=112.0) await _seed(session, "AAA", close=112.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long", 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 import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.benchmark_price import BenchmarkPrice
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade from app.models.paper_trade import PaperTrade
from app.models.signal_context_snapshot import SignalContextSnapshot from app.models.signal_context_snapshot import SignalContextSnapshot
@@ -625,21 +626,37 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
db_session.add_all([blocked, released, trailing]) db_session.add_all([blocked, released, trailing])
await db_session.flush() await db_session.flush()
# Six synthetic stored market sessions D0..D5. A stop on D0 has five # Six SPY sessions D0..D5 form the canonical market calendar. A stop on
# later sessions and is released; a stop on D1 has only four and is not. # D0 has five later sessions and is released; a stop on D1 has only four.
market_sessions = [today - timedelta(days=offset) for offset in range(5, -1, -1)] 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: for market_date in market_sessions:
db_session.add( db_session.add(
OHLCVRecord( BenchmarkPrice(
ticker_id=blocked.id, symbol="SPY",
date=market_date, date=market_date,
open=100.0, close=400.0,
high=101.0,
low=99.0,
close=100.0,
volume=1_000,
) )
) )
# A bar from an unrelated/scanner-specific calendar must not release the
# ticker one session early. The old universe-wide DISTINCT query did.
db_session.add(
OHLCVRecord(
ticker_id=blocked.id,
date=today,
open=100.0,
high=101.0,
low=99.0,
close=100.0,
volume=1_000,
)
)
for ticker in (blocked, released, trailing): for ticker in (blocked, released, trailing):
db_session.add( db_session.add(
@@ -699,6 +716,14 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
assert "STOP4" not in available_symbols assert "STOP4" not in available_symbols
assert {"STOP5", "TRAILQ"}.issubset(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: async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
"""Stored setup frozen at scan time (conf 82, neutral) vs. current context """Stored setup frozen at scan time (conf 82, neutral) vs. current context