fix: harden post-stop reentry lockdown
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user