feat: add five-session post-stop reentry lockdown

This commit is contained in:
2026-07-17 13:21:06 +02:00
parent f714782fa4
commit 1e9f2dc4fb
14 changed files with 36919 additions and 14 deletions
+1
View File
@@ -282,6 +282,7 @@ async def _qualified_setups(db: AsyncSession) -> list[dict]:
db,
live_recommendation=True,
exclude_open_trade_tickers=True,
exclude_reentry_lockdown_tickers=True,
)
config = await get_activation_config(db)
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
+243 -10
View File
@@ -94,6 +94,7 @@ from app.services.scoring_service import (
compute_technical_from_arrays,
)
from app.services.sr_service import detect_gate_target_ladder, detect_sr_levels
from app.services.trade_policy import REENTRY_LOCKDOWN_SESSIONS
logger = logging.getLogger(__name__)
@@ -991,6 +992,68 @@ def _replay_and_signals(
)
def _replay_candidates_for_period(
symbol: str,
columns: tuple,
config: dict,
activation: dict,
benchmark_closes: dict[date, float] | None,
start_date: date,
) -> list[dict]:
"""Slim picklable replay used by local event studies.
Unlike the full report worker it skips factor-series construction and only
evaluates setup dates on or after ``start_date``.
"""
date_ords, opens, highs, lows, closes, volumes = columns
bars = [
SimpleNamespace(
date=date.fromordinal(o), open=op, high=hi, low=lo, close=cl, volume=vo
)
for o, op, hi, lo, cl, vo in zip(
date_ords, opens, highs, lows, closes, volumes
)
]
candidates: list[dict] = []
for i in range(MIN_LOOKBACK - 1, len(bars) - HORIZON, STEP_DAYS):
if bars[i].date < start_date:
continue
window = bars[: i + 1]
window_closes = [float(r.close) for r in window]
window_dates = [r.date for r in window]
residual_momentum = _residual_momentum_12_1(
window_dates,
window_closes,
len(window) - 1,
benchmark_closes,
)
vol_6m = _realized_vol_6m(window_closes, len(window) - 1)
iso = bars[i].date.isocalendar()
for setup in _window_setups(window, config, activation):
if setup["direction"] != "long":
continue
candidates.append({
"symbol": symbol,
"date": bars[i].date.isoformat(),
"iso_week": (iso[0], iso[1]),
"direction": "long",
"entry": setup["entry"],
"stop": setup["stop"],
"target": setup["target"],
"rr": setup["rr"],
"confidence": setup["confidence"],
"primary_prob": setup["primary_prob"],
"best_prob": setup["best_prob"],
"momentum": setup["momentum"],
"residual_momentum": residual_momentum,
"vol_6m": vol_6m,
"meets_core": setup["meets_core"],
"action": setup["action"],
"risk_level": setup["risk_level"],
})
return candidates
def _backtest_worker_count() -> int:
"""How many worker processes to replay tickers across. Capped to cpu_count-1
so a core stays free for the web server; 1 means sequential."""
@@ -1293,9 +1356,17 @@ 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,
initial_stop_refresh_fn: (
Callable[[str, int, float, dict, Any], float | None] | None
) = None,
post_stop_reentry_fn: (
Callable[[str, int, dict, Any], dict | None] | None
) = None,
start_date: date | None = None,
end_date: date | None = None,
include_curve: bool = False,
include_trades: bool = False,
) -> dict | None:
"""Replay the qualified setups as ONE capital-constrained book and report
portfolio economics from the daily equity curve (return, CAGR, drawdown,
@@ -1309,7 +1380,14 @@ 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.
Returns None when there is nothing to trade.
``reentry_cooldown_days`` 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
checked against the same bar. ``post_stop_reentry_fn`` turns an initial
stop-out into a stateful episode and is the only path by which that ticker
can re-enter until the callback emits a new candidate. Returns None when
there is nothing to trade.
"""
if qualified_fn is None:
def _default_qualified(c: dict) -> bool:
@@ -1362,6 +1440,14 @@ def _simulate_portfolio(
curve: list[tuple[int, float]] = []
trades: list[dict] = []
skipped_full = 0
skipped_cooldown = 0
cooldown_until_index: dict[str, int] = {}
stop_refresh_attempts = 0
stop_refreshes = 0
stop_refresh_same_bar_hits = 0
post_stop_states: dict[str, dict] = {}
post_stop_events = 0
reentry_events: list[dict] = []
technical_cache: dict[tuple[str, int], float | None] = {}
atr_cache: dict[tuple[str, int], float | None] = {}
@@ -1426,7 +1512,7 @@ def _simulate_portfolio(
atr_cache[key] = None
return atr_cache[key]
def _close_trade(sym: str, fill: float, reason: str) -> None:
def _close_trade(sym: str, fill: float, reason: str) -> dict:
nonlocal cash
pos = positions.pop(sym)
proceeds = pos["shares"] * fill
@@ -1434,16 +1520,29 @@ def _simulate_portfolio(
cash += proceeds - cost
risk = pos["entry"] - pos["initial_stop"]
trades.append({
"symbol": sym,
"entry_ord": pos["entry_ord"],
"exit_ord": o,
"entry": pos["entry"],
"initial_stop": pos["initial_stop"],
"active_stop": pos["stop"],
"fill": fill,
"pnl": proceeds - pos["shares"] * pos["entry"] - cost - pos["entry_cost"],
"r": (fill - pos["entry"]) / risk if risk > 0 else 0.0,
"hold": pos["bars_held"],
"reason": reason,
"stop_refreshes": pos["stop_refreshes"],
"is_reentry": pos["is_reentry"],
"reentry_wait_sessions": pos["reentry_wait_sessions"],
"transaction_cost": pos["entry_cost"] + cost,
})
return pos
def _marked_equity() -> float:
return cash + sum(p["shares"] * p["last_close"] for p in positions.values())
for o in calendar:
cooldown_days = max(0, int(reentry_cooldown_days))
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):
pos = positions[sym]
@@ -1460,8 +1559,42 @@ def _simulate_portfolio(
if pos["stop"] > pos["initial_stop"] + 1e-9
else "stop"
)
_close_trade(sym, min(pos["stop"], bar.open), reason)
continue
survived_refresh = False
if reason == "stop" and initial_stop_refresh_fn is not None:
stop_refresh_attempts += 1
refreshed_stop = initial_stop_refresh_fn(
sym, o, float(pos["stop"]), pos, bar
)
if (
refreshed_stop is not None
and 0 < float(refreshed_stop) < pos["stop"] - 1e-9
):
pos["stop"] = float(refreshed_stop)
pos["stop_refreshes"] += 1
stop_refreshes += 1
if bar.low > pos["stop"]:
survived_refresh = True
else:
stop_refresh_same_bar_hits += 1
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 post_stop_reentry_fn is not None:
post_stop_events += 1
post_stop_states[sym] = {
"stop_ord": o,
"stop_calendar_index": calendar_index,
"stop_day_high": float(bar.high),
"stop_day_low": float(bar.low),
"stop_day_close": float(bar.close),
"exit_fill": float(fill),
"previous_entry": float(closed_pos["entry"]),
"previous_stop": float(closed_pos["initial_stop"]),
"gate_went_unqualified": False,
}
continue
if exit_policy in ("target", "atr_trail3_target") and pos["target"] and bar.high >= pos["target"]:
_close_trade(sym, pos["target"], "target")
continue
@@ -1493,8 +1626,29 @@ def _simulate_portfolio(
# 2) entries at today's close, best momentum first
equity = _marked_equity()
fixed_todays = list(entries_by_ord.get(o, ()))
reentry_todays: list[dict] = []
if post_stop_reentry_fn is not None:
fixed_todays = [
candidate
for candidate in fixed_todays
if candidate["symbol"] not in post_stop_states
]
for sym, state in list(post_stop_states.items()):
bar = _bar(sym, o)
if bar is None:
continue
state["sessions_since_stop"] = (
calendar_index - state["stop_calendar_index"]
)
candidate = post_stop_reentry_fn(sym, o, state, bar)
if candidate is None:
continue
tagged = dict(candidate)
tagged["_post_stop_reentry"] = True
reentry_todays.append(tagged)
todays = sorted(
entries_by_ord.get(o, ()),
fixed_todays + reentry_todays,
key=lambda c: c.get(ranking_key) or 0.0,
reverse=True,
)
@@ -1502,6 +1656,9 @@ def _simulate_portfolio(
sym = c["symbol"]
if sym in positions:
continue
if calendar_index < cooldown_until_index.get(sym, -1):
skipped_cooldown += 1
continue
if len(positions) >= max_positions:
skipped_full += 1
continue
@@ -1518,9 +1675,23 @@ def _simulate_portfolio(
continue
entry_cost = shares * entry * COST_PER_SIDE
cash -= shares * entry + entry_cost
is_reentry = bool(c.get("_post_stop_reentry"))
reentry_wait_sessions: int | None = None
if is_reentry:
state = post_stop_states.pop(sym, None)
if state is not None:
reentry_wait_sessions = int(state["sessions_since_stop"])
reentry_events.append({
"symbol": sym,
"stop_ord": state["stop_ord"],
"reentry_ord": o,
"wait_sessions": reentry_wait_sessions,
"reason": c.get("_reentry_reason"),
})
positions[sym] = {
"shares": shares,
"entry": entry,
"entry_ord": o,
"initial_stop": stop,
"stop": stop,
"target": float(c["target"]) if c.get("target") else None,
@@ -1528,6 +1699,9 @@ def _simulate_portfolio(
"bars_held": 0,
"last_close": entry,
"highest_close": entry,
"stop_refreshes": 0,
"is_reentry": is_reentry,
"reentry_wait_sessions": reentry_wait_sessions,
}
equity = _marked_equity()
@@ -1659,6 +1833,42 @@ 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
result["skipped_cooldown"] = skipped_cooldown
if initial_stop_refresh_fn is not None:
result["stop_refresh_attempts"] = stop_refresh_attempts
result["stop_refreshes"] = stop_refreshes
result["stop_refresh_same_bar_hits"] = stop_refresh_same_bar_hits
if post_stop_reentry_fn is not None:
result["post_stop_events"] = post_stop_events
result["post_stop_reentries"] = len(reentry_events)
result["post_stop_states_open_at_end"] = len(post_stop_states)
result["reentry_events"] = [
{
**{
key: value
for key, value in event.items()
if key not in {"stop_ord", "reentry_ord"}
},
"stop_date": date.fromordinal(event["stop_ord"]).isoformat(),
"reentry_date": date.fromordinal(event["reentry_ord"]).isoformat(),
}
for event in reentry_events
]
if include_trades:
result["trade_details"] = [
{
**{
key: value
for key, value in trade.items()
if key not in {"entry_ord", "exit_ord"}
},
"entry_date": date.fromordinal(trade["entry_ord"]).isoformat(),
"exit_date": date.fromordinal(trade["exit_ord"]).isoformat(),
}
for trade in trades
]
return result
@@ -2019,13 +2229,15 @@ PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = (
},
{
"strategy": PRODUCTION_PORTFOLIO_STRATEGY,
"label": "Production: residual/high-vol 80/20 + 3x ATR trail",
"label": "Production: residual/high-vol 80/20 + 3x ATR trail + 5-session lockdown",
"description": (
"The live strategy: production activation gate and Admin exit policy "
"as currently configured, 80/20 residual/high-vol rank."
"as currently configured, 80/20 residual/high-vol rank, and a "
"five-session re-entry lockdown after an initial-stop exit."
),
"entry_variant": "residual80_highvol_blend80_20_fixed10",
"exit_policy": "atr_trail3",
"reentry_lockdown_sessions": REENTRY_LOCKDOWN_SESSIONS,
# The production row replays what the platform actually does right now:
# the live qualification flag (runtime Admin activation settings) and the
# live Admin exit policy, instead of the frozen research-variant gate.
@@ -2149,6 +2361,9 @@ def _min_rr_sweep(
exit_policy = str(strategy["exit_policy"])
row_hold_days = hold_days
trail_multiplier = ATR_TRAIL_MULTIPLIER
reentry_lockdown_sessions = int(
strategy.get("reentry_lockdown_sessions", 0)
)
if strategy.get("use_live_config") and live_exit_policy is not None:
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
@@ -2188,6 +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,
start_date=sweep_start,
)
if sim is None:
@@ -2212,6 +2428,7 @@ def _min_rr_sweep(
"live_qualified_setups": live_qualified,
"reproduces_production_gate": reproduces,
"exit_policy": exit_policy,
"reentry_lockdown_sessions": reentry_lockdown_sessions,
"entries_from": sweep_start.isoformat() if sweep_start else None,
"window": "out-of-sample (test)" if sweep_start else "full history (in-sample)",
"rows": rows,
@@ -2267,6 +2484,9 @@ def _holdout_evaluation(
exit_policy = str(strategy["exit_policy"])
row_hold_days = hold_days
trail_multiplier = ATR_TRAIL_MULTIPLIER
reentry_lockdown_sessions = int(
strategy.get("reentry_lockdown_sessions", 0)
)
if strategy.get("use_live_config") and live_exit_policy is not None:
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
@@ -2296,6 +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,
start_date=start,
end_date=end,
include_curve=True,
@@ -2307,6 +2528,7 @@ def _holdout_evaluation(
return {
"split_date": split.isoformat(),
"strategy": strategy["strategy"],
"reentry_lockdown_sessions": reentry_lockdown_sessions,
"rows": rows,
"note": (
"Train = entries before the split; test = entries on/after it. The two "
@@ -2339,6 +2561,9 @@ def _portfolio_monitor(
# policy. The overlay opts into this deliberately so only ordering
# changes relative to the production row.
use_live = bool(strategy.get("use_live_config"))
reentry_lockdown_sessions = int(
strategy.get("reentry_lockdown_sessions", 0)
)
exit_policy = str(strategy["exit_policy"])
row_hold_days = hold_days
trail_multiplier = ATR_TRAIL_MULTIPLIER
@@ -2367,6 +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,
start_date=start,
include_curve=True,
)
@@ -2381,6 +2607,7 @@ def _portfolio_monitor(
"ranking_key": ranking_key,
"exit_policy": exit_policy,
"live_exit_mode": live_exit_mode,
"reentry_lockdown_sessions": reentry_lockdown_sessions,
"lookback": lookback["lookback"],
"lookback_label": lookback["label"],
**sim,
@@ -2393,6 +2620,9 @@ def _portfolio_monitor(
"label": s["label"],
"description": s["description"],
"is_production": bool(s.get("is_production")),
"reentry_lockdown_sessions": int(
s.get("reentry_lockdown_sessions", 0)
),
}
for s in strategies
],
@@ -2405,7 +2635,8 @@ def _portfolio_monitor(
"Portfolio monitor runs supported named strategies across cached lookbacks. "
"The structural overlay appears only in its explicit research arm and changes "
"ordering, not production qualification. Local snapshot backtests remain the "
"research surface for broad variant sweeps."
"research surface for broad variant sweeps. The production row applies the "
"same five-session post-initial-stop re-entry lockdown as the live setup list."
),
}
@@ -2618,7 +2849,8 @@ def _build_recommendation(report: dict) -> dict:
if production_row is not None:
headline = (
"Production baseline: residual/high-vol 80/20 entry rank with a "
"3x ATR trailing exit and 30-trading-day max hold."
"3x ATR trailing exit, 30-trading-day max hold, and 5-session "
"re-entry lockdown after an initial stop."
)
if (
production_row.get("cagr_pct") is not None
@@ -2994,6 +3226,7 @@ async def run_backtest(
"target_model": target_model,
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
"is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL,
"production_reentry_lockdown_sessions": REENTRY_LOCKDOWN_SESSIONS,
},
"activation": activation,
"overall_qualified": _bucket_stats(qualified),
+10 -3
View File
@@ -29,6 +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.recommendation_service import (
_risk_level_from_conflicts,
build_recommendation_snapshot,
@@ -771,6 +772,7 @@ async def get_trade_setups(
symbol: str | None = None,
live_recommendation: bool = False,
exclude_open_trade_tickers: bool = False,
exclude_reentry_lockdown_tickers: bool = False,
) -> list[dict]:
"""Get latest stored trade setups, optionally filtered.
@@ -794,15 +796,20 @@ async def get_trade_setups(
stmt = stmt.where(TradeSetup.confidence_score >= min_confidence)
if recommended_action is not None and not live_recommendation:
stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
excluded_ticker_ids: set[int] = set()
if exclude_open_trade_tickers:
open_trade_result = await db.execute(
select(PaperTrade.ticker_id)
.where(PaperTrade.status == "open")
.distinct()
)
open_ticker_ids = {ticker_id for ticker_id, in open_trade_result.all()}
if open_ticker_ids:
stmt = stmt.where(~TradeSetup.ticker_id.in_(open_ticker_ids))
excluded_ticker_ids.update(
ticker_id for ticker_id, in open_trade_result.all()
)
if exclude_reentry_lockdown_tickers:
excluded_ticker_ids.update(await get_reentry_lockdown_ticker_ids(db))
if excluded_ticker_ids:
stmt = stmt.where(~TradeSetup.ticker_id.in_(excluded_ticker_ids))
stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
+68
View File
@@ -0,0 +1,68 @@
"""Shared live/backtest trading-policy constants and availability checks."""
from __future__ import annotations
from datetime import date, datetime, time, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
# A ticker stopped at its initial stop may qualify again immediately, but the
# July 2026 event study showed that waiting five market sessions materially
# improved the production book. The stop session is wait_session=0; the first
# permitted re-entry is wait_session=5, provided the normal gate still passes.
REENTRY_LOCKDOWN_SESSIONS = 5
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,
)
.distinct()
)
return {ticker_id for ticker_id, in result.all()}