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
+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()}