feat: require gate reset before post-stop reentry

This commit is contained in:
2026-07-17 19:30:40 +02:00
parent 1a6f82bf6d
commit 5155d00d9e
18 changed files with 577 additions and 278 deletions
+10
View File
@@ -36,3 +36,13 @@ class PaperTrade(Base):
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# How the trade was closed: "time" | "trailing" | "stop" | "target" | "manual".
close_reason: Mapped[str | None] = mapped_column(String(10), nullable=True)
# A trade stopped at its initial stop starts a re-entry gate-reset episode.
# The daily full-universe scanner records both state transitions: the first
# failed gate observation and a later fresh qualification. Re-entry remains
# non-actionable until both timestamps exist.
reentry_gate_failed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
reentry_gate_requalified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
+2 -2
View File
@@ -36,7 +36,7 @@ async def list_trade_setups(
recommended_action=recommended_action,
live_recommendation=True,
exclude_open_trade_tickers=True,
exclude_reentry_lockdown_tickers=True,
exclude_reentry_gate_locked_tickers=True,
)
data = []
@@ -99,7 +99,7 @@ async def get_ticker_trade_setups(
db,
symbol=symbol,
live_recommendation=True,
include_reentry_lockdown=True,
include_reentry_gate_lock=True,
)
data = []
for row in rows:
+1 -1
View File
@@ -59,6 +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
reentry_gate_reset_required: bool = False
context_as_of: TradeSetupContextAsOfResponse | None = None
recommendation_summary: RecommendationSummaryResponse | None = None
+1 -1
View File
@@ -282,7 +282,7 @@ async def _qualified_setups(db: AsyncSession) -> list[dict]:
db,
live_recommendation=True,
exclude_open_trade_tickers=True,
exclude_reentry_lockdown_tickers=True,
exclude_reentry_gate_locked_tickers=True,
)
config = await get_activation_config(db)
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
+141 -41
View File
@@ -94,7 +94,6 @@ 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__)
@@ -103,6 +102,7 @@ KEY_REPORT = "backtest_report"
WEEKLY_BACKTEST_CADENCE = "weekly"
DAILY_BACKTEST_CADENCE = "daily"
DEFAULT_BACKTEST_CADENCE = WEEKLY_BACKTEST_CADENCE
PRODUCTION_REENTRY_POLICY = "gate_reset"
BACKTEST_CADENCE_SESSIONS = {
WEEKLY_BACKTEST_CADENCE: 5,
DAILY_BACKTEST_CADENCE: 1,
@@ -1429,6 +1429,72 @@ LIVE_EXIT_MODE_TO_SIM = {
}
def _make_gate_reset_reentry_fn(
candidates: list[dict],
prices: dict[str, tuple],
*,
cadence: str,
qualified_fn: Callable[[dict], bool] | None = None,
ranking_key: str = PRODUCTION_PERCENTILE_KEY,
) -> Callable[[str, int, dict, Any], dict | None]:
"""Build the production post-stop gate-reset callback.
Missing candidates count as a gate failure only on dates on which that
ticker was actually evaluated at the selected replay cadence. This keeps a
weekly backtest from treating the four non-evaluation sessions between two
weekly observations as false gate exits.
"""
cadence = validate_backtest_cadence(cadence)
if qualified_fn is None:
def _default_qualified(candidate: dict) -> bool:
return bool(candidate.get("qualified"))
qualified_fn = _default_qualified
evaluation_ords: dict[str, set[int]] = {}
step_sessions = backtest_step_sessions(cadence)
for symbol, columns in prices.items():
ordinals = columns[0]
evaluation_ords[symbol] = {
int(ordinals[index])
for index in range(MIN_LOOKBACK - 1, len(ordinals) - HORIZON, step_sessions)
}
qualified_by_symbol_date: dict[tuple[str, int], dict] = {}
for candidate in candidates:
if candidate.get("direction") != "long" or not qualified_fn(candidate):
continue
key = (
str(candidate["symbol"]),
date.fromisoformat(str(candidate["date"])).toordinal(),
)
previous = qualified_by_symbol_date.get(key)
if previous is None or float(candidate.get(ranking_key) or 0.0) > float(
previous.get(ranking_key) or 0.0
):
qualified_by_symbol_date[key] = candidate
def _gate_reset(
symbol: str,
asof_ord: int,
state: dict,
_bar: Any,
) -> dict | None:
if asof_ord not in evaluation_ords.get(symbol, set()):
return None
candidate = qualified_by_symbol_date.get((symbol, asof_ord))
if candidate is None:
state["gate_went_unqualified"] = True
return None
if not state.get("gate_went_unqualified"):
return None
emitted = dict(candidate)
emitted["_reentry_reason"] = "gate_failed_then_requalified"
return emitted
return _gate_reset
def _simulate_portfolio(
candidates: list[dict],
prices: dict[str, tuple],
@@ -2325,35 +2391,35 @@ PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = (
"exit_policy": "hold",
},
{
"strategy": "production_live_no_lockdown",
"label": "Live setup + 3x ATR trail (no re-entry lockdown)",
"strategy": "production_live_immediate",
"label": "Live setup + 3x ATR trail (immediate re-entry)",
"description": (
"Exact live activation, ordering, and Admin exit policy, with only "
"the post-stop re-entry lockdown disabled as the comparison baseline."
"the post-stop gate reset disabled as the comparison baseline."
),
"entry_variant": "residual80_highvol_blend80_20_fixed10",
"exit_policy": "atr_trail3",
"reentry_lockdown_sessions": 0,
"reentry_policy": "immediate",
"use_live_config": True,
"comparison_arm": "live_no_lockdown",
"comparison_arm": "live_immediate",
},
{
"strategy": PRODUCTION_PORTFOLIO_STRATEGY,
"label": "Production: residual/high-vol 80/20 + 3x ATR trail + 5-session lockdown",
"label": "Production: residual/high-vol 80/20 + 3x ATR trail + gate reset",
"description": (
"The live strategy: production activation gate and Admin exit policy "
"as currently configured, 80/20 residual/high-vol rank, and a "
"five-session re-entry lockdown after an initial-stop exit."
"as currently configured, 80/20 residual/high-vol rank, and re-entry "
"only after the gate fails and later qualifies again."
),
"entry_variant": "residual80_highvol_blend80_20_fixed10",
"exit_policy": "atr_trail3",
"reentry_lockdown_sessions": REENTRY_LOCKDOWN_SESSIONS,
"reentry_policy": PRODUCTION_REENTRY_POLICY,
# 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.
"use_live_config": True,
"is_production": True,
"comparison_arm": "live_lockdown_5",
"comparison_arm": "live_gate_reset",
},
)
@@ -2456,6 +2522,7 @@ def _min_rr_sweep(
threshold: float,
hold_days: int,
live_exit_policy: dict | None = None,
cadence: str = DEFAULT_BACKTEST_CADENCE,
) -> dict:
"""Portfolio economics of the production book at each R:R floor.
@@ -2472,9 +2539,7 @@ 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)
)
reentry_policy = str(strategy.get("reentry_policy", "immediate"))
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"
@@ -2514,7 +2579,19 @@ 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_sessions=reentry_lockdown_sessions,
post_stop_reentry_fn=(
_make_gate_reset_reentry_fn(
candidates,
prices,
cadence=cadence,
qualified_fn=qualified_fn,
ranking_key=str(
entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]
),
)
if reentry_policy == "gate_reset"
else None
),
start_date=sweep_start,
)
if sim is None:
@@ -2539,7 +2616,7 @@ def _min_rr_sweep(
"live_qualified_setups": live_qualified,
"reproduces_production_gate": reproduces,
"exit_policy": exit_policy,
"reentry_lockdown_sessions": reentry_lockdown_sessions,
"reentry_policy": reentry_policy,
"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,
@@ -2573,6 +2650,7 @@ def _holdout_evaluation(
hold_days: int,
split: date,
live_exit_policy: dict | None = None,
cadence: str = DEFAULT_BACKTEST_CADENCE,
) -> dict:
"""The production strategy simulated on entries BEFORE the split (train) and
on entries ON/AFTER it (test), as separate books.
@@ -2595,9 +2673,7 @@ 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)
)
reentry_policy = str(strategy.get("reentry_policy", "immediate"))
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"
@@ -2610,6 +2686,20 @@ def _holdout_evaluation(
None if strategy.get("use_live_config")
else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config)
)
ranking_key = str(
entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]
)
post_stop_reentry_fn = (
_make_gate_reset_reentry_fn(
candidates,
prices,
cadence=cadence,
qualified_fn=qualified_fn,
ranking_key=ranking_key,
)
if reentry_policy == "gate_reset"
else None
)
rows: list[dict] = []
for window, start, end in (
@@ -2623,11 +2713,11 @@ def _holdout_evaluation(
exit_policy,
row_hold_days,
qualified_fn=qualified_fn,
ranking_key=str(entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]),
ranking_key=ranking_key,
max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier,
reentry_cooldown_sessions=reentry_lockdown_sessions,
post_stop_reentry_fn=post_stop_reentry_fn,
start_date=start,
end_date=end,
include_curve=True,
@@ -2639,7 +2729,7 @@ def _holdout_evaluation(
return {
"split_date": split.isoformat(),
"strategy": strategy["strategy"],
"reentry_lockdown_sessions": reentry_lockdown_sessions,
"reentry_policy": reentry_policy,
"rows": rows,
"note": (
"Train = entries before the split; test = entries on/after it. The two "
@@ -2655,6 +2745,7 @@ def _portfolio_monitor(
_spy_closes: dict[date, float] | None,
hold_days: int,
live_exit_policy: dict | None = None,
cadence: str = DEFAULT_BACKTEST_CADENCE,
) -> dict:
latest_ord = max((max(cols[0]) for cols in prices.values() if cols[0]), default=None)
rows: list[dict] = []
@@ -2672,9 +2763,7 @@ 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)
)
reentry_policy = str(strategy.get("reentry_policy", "immediate"))
exit_policy = str(strategy["exit_policy"])
row_hold_days = hold_days
trail_multiplier = ATR_TRAIL_MULTIPLIER
@@ -2690,6 +2779,17 @@ def _portfolio_monitor(
None if use_live
else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config)
)
post_stop_reentry_fn = (
_make_gate_reset_reentry_fn(
candidates,
prices,
cadence=cadence,
qualified_fn=qualified_fn,
ranking_key=ranking_key,
)
if reentry_policy == "gate_reset"
else None
)
for lookback in PORTFOLIO_MONITOR_LOOKBACKS:
start = _lookback_start(latest_ord, lookback["days"])
sim = _simulate_portfolio(
@@ -2703,7 +2803,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_sessions=reentry_lockdown_sessions,
post_stop_reentry_fn=post_stop_reentry_fn,
start_date=start,
include_curve=True,
)
@@ -2719,7 +2819,7 @@ def _portfolio_monitor(
"ranking_key": ranking_key,
"exit_policy": exit_policy,
"live_exit_mode": live_exit_mode,
"reentry_lockdown_sessions": reentry_lockdown_sessions,
"reentry_policy": reentry_policy,
"lookback": lookback["lookback"],
"lookback_label": lookback["label"],
**sim,
@@ -2733,9 +2833,7 @@ def _portfolio_monitor(
"description": s["description"],
"is_production": bool(s.get("is_production")),
"comparison_arm": s.get("comparison_arm"),
"reentry_lockdown_sessions": int(
s.get("reentry_lockdown_sessions", 0)
),
"reentry_policy": str(s.get("reentry_policy", "immediate")),
}
for s in strategies
],
@@ -2749,7 +2847,7 @@ def _portfolio_monitor(
"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. The production row applies the "
"same five-session post-initial-stop re-entry lockdown as the live setup list."
"same post-initial-stop gate-reset rule as the live setup list."
),
}
@@ -2758,7 +2856,7 @@ def _production_cadence_comparison(
monitor: dict | None,
cadence: str,
) -> dict | None:
"""Compact full-history live/no-lockdown vs live/5-session comparison."""
"""Compact full-history live/immediate vs live/gate-reset comparison."""
if not monitor:
return None
arms: list[dict] = []
@@ -2773,23 +2871,23 @@ def _production_cadence_comparison(
}
arm_name = (
"prod_live_setup"
if comparison_arm == "live_no_lockdown"
else "cooldown_5"
if comparison_arm == "live_immediate"
else "gate_reset"
)
compact["arm"] = f"{arm_name}_{cadence}"
compact["entry_cadence"] = cadence
arms.append(compact)
if not arms:
return None
arms.sort(key=lambda row: int(row.get("reentry_lockdown_sessions", 0)))
arms.sort(key=lambda row: row.get("reentry_policy") != "immediate")
return {
"entry_cadence": cadence,
"lookback": "all",
"arms": arms,
"note": (
"Both arms use the exact same live gate, ordering, Admin exit policy, "
"fees, and candidate cadence. Only the five-session post-stop "
"re-entry lockdown changes."
"fees, and candidate cadence. Only the post-stop gate-reset rule "
"changes."
),
}
@@ -3002,8 +3100,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, 30-trading-day max hold, and 5-session "
"re-entry lockdown after an initial stop."
"3x ATR trailing exit, 30-trading-day max hold, and re-entry only "
"after the gate fails and later qualifies again."
)
if (
production_row.get("cagr_pct") is not None
@@ -3357,17 +3455,19 @@ async def run_backtest(
portfolio_monitor_report = _portfolio_monitor(
candidates, price_columns, spy_closes, hold_horizon,
live_exit_policy=live_exit_policy,
cadence=cadence,
)
split = _holdout_split()
if split is not None:
holdout_report = _holdout_evaluation(
candidates, price_columns, spy_closes, hold_horizon, split,
live_exit_policy=live_exit_policy,
cadence=cadence,
)
if _min_rr_sweep_enabled():
min_rr_sweep_report = _min_rr_sweep(
candidates, price_columns, spy_closes, activation, current_min_pct,
hold_horizon, live_exit_policy=live_exit_policy,
hold_horizon, live_exit_policy=live_exit_policy, cadence=cadence,
)
except Exception:
logger.exception("Portfolio simulation failed")
@@ -3390,7 +3490,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,
"production_reentry_policy": PRODUCTION_REENTRY_POLICY,
},
"activation": activation,
"overall_qualified": _bucket_stats(qualified),
+3 -6
View File
@@ -20,7 +20,7 @@ from app.services.outcome_service import (
Bar,
evaluate_setup_against_bars,
)
from app.services.trade_policy import get_reentry_lockdowns
from app.services.trade_policy import get_reentry_gate_locks
# 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
@@ -319,12 +319,9 @@ 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"
if ticker.id in await get_reentry_gate_locks(db):
raise ValidationError(
f"{ticker.symbol} is in a post-stop re-entry lockdown: "
f"{remaining_sessions} market {suffix} remaining"
f"{ticker.symbol} requires a post-stop gate reset before re-entry"
)
trade = PaperTrade(
user_id=user_id,
+59 -17
View File
@@ -28,8 +28,12 @@ from app.models.ticker import Ticker
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.qualification import setup_qualifies
from app.services.sr_service import detect_gate_target_ladder
from app.services.trade_policy import get_reentry_lockdowns
from app.services.trade_policy import (
get_reentry_gate_locks,
observe_reentry_gate_transitions,
)
from app.services.recommendation_service import (
_risk_level_from_conflicts,
build_recommendation_snapshot,
@@ -700,12 +704,24 @@ async def scan_all_tickers(
``progress_callback(processed, total, current_symbol)`` is invoked as each
ticker is scanned so callers (e.g. the scheduler) can surface live progress.
"""
# Plain strings, not Ticker instances: the rollbacks below expire any ORM
# objects held across them, and touching an expired attribute afterwards
# Plain ids/strings, not Ticker instances: the rollbacks below expire any
# ORM objects held across them, and touching an expired attribute afterwards
# triggers sync lazy-loading, which raises on an AsyncSession.
result = await db.execute(select(Ticker.symbol).order_by(Ticker.symbol))
symbols = list(result.scalars().all())
total = len(symbols)
result = await db.execute(select(Ticker.id, Ticker.symbol).order_by(Ticker.symbol))
ticker_rows = [(int(ticker_id), symbol) for ticker_id, symbol in result.all()]
total = len(ticker_rows)
# Gate-reset observations must use the same runtime activation settings as
# the live setup list. If the config cannot be loaded, scan normally but do
# not mutate reset state from an evaluation whose rules are unknown.
activation: dict | None = None
try:
from app.services.admin_service import get_activation_config
activation = await get_activation_config(db)
except Exception:
await db.rollback()
logger.exception("Activation config load for re-entry gate reset failed")
# Rank the universe up front so each new setup carries both the residual
# activation gate percentile and the promoted production ordering score.
@@ -721,7 +737,10 @@ async def scan_all_tickers(
ranks = {}
all_setups: list[TradeSetup] = []
for index, symbol in enumerate(symbols):
evaluated_ticker_ids: set[int] = set()
qualified_ticker_ids: set[int] = set()
gate_observation_started_at = datetime.now(timezone.utc)
for index, (ticker_id, symbol) in enumerate(ticker_rows):
if progress_callback is not None:
progress_callback(index, total, symbol)
# Refresh scores first so the scheduled scan works off current data.
@@ -754,10 +773,33 @@ async def scan_all_tickers(
primary_min_rr=PRIMARY_TARGET_MIN_RR,
)
all_setups.extend(setups)
if activation is not None:
try:
if any(setup_qualifies(setup, activation) for setup in setups):
qualified_ticker_ids.add(ticker_id)
evaluated_ticker_ids.add(ticker_id)
except Exception:
logger.exception(
"Gate-reset qualification observation failed for %s", symbol
)
except Exception:
await db.rollback()
logger.exception("Error scanning ticker %s", symbol)
if activation is not None:
transitioned_ticker_ids = await observe_reentry_gate_transitions(
db,
evaluated_ticker_ids=evaluated_ticker_ids,
qualified_ticker_ids=qualified_ticker_ids,
observed_at=gate_observation_started_at,
)
await db.commit()
if transitioned_ticker_ids:
logger.info(
"Updated post-stop gate-reset state for %d ticker(s)",
len(transitioned_ticker_ids),
)
if progress_callback is not None and total:
progress_callback(total, total, "")
@@ -772,8 +814,8 @@ 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,
include_reentry_lockdown: bool = False,
exclude_reentry_gate_locked_tickers: bool = False,
include_reentry_gate_lock: bool = False,
) -> list[dict]:
"""Get latest stored trade setups, optionally filtered.
@@ -798,7 +840,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] = {}
reentry_gate_locks: dict[int, datetime] = {}
if exclude_open_trade_tickers:
open_trade_result = await db.execute(
select(PaperTrade.ticker_id)
@@ -808,10 +850,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(reentry_lockdowns)
if exclude_reentry_gate_locked_tickers or include_reentry_gate_lock:
reentry_gate_locks = await get_reentry_gate_locks(db)
if exclude_reentry_gate_locked_tickers:
excluded_ticker_ids.update(reentry_gate_locks)
if excluded_ticker_ids:
stmt = stmt.where(~TradeSetup.ticker_id.in_(excluded_ticker_ids))
@@ -866,14 +908,14 @@ async def get_trade_setups(
),
reverse=True,
)
if include_reentry_lockdown:
if include_reentry_gate_lock:
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
row["reentry_gate_reset_required"] = (
ticker_id in reentry_gate_locks if ticker_id is not None else False
)
return rows_out
+70 -97
View File
@@ -1,120 +1,93 @@
"""Shared live/backtest trading-policy constants and availability checks."""
"""Shared live trading-policy state and availability checks."""
from __future__ import annotations
from collections import defaultdict
from datetime import date, datetime, timezone
from collections.abc import Iterable
from datetime import datetime, timezone
from sqlalchemy import func, select
from sqlalchemy import 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
# 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_lockdowns(
async def _latest_initial_stop_trades(
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"),
)
closed_before: datetime | None = None,
) -> dict[int, PaperTrade]:
"""Return the most recent initial-stop trade for each ticker."""
stmt = (
select(PaperTrade)
.where(
PaperTrade.status == "closed",
PaperTrade.close_reason == "stop",
PaperTrade.closed_at.is_not(None),
)
.group_by(PaperTrade.ticker_id)
.order_by(
PaperTrade.ticker_id.asc(),
PaperTrade.closed_at.desc(),
PaperTrade.id.desc(),
)
)
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 closed_before is not None:
stmt = stmt.where(PaperTrade.closed_at <= closed_before)
result = await db.execute(stmt)
latest: dict[int, PaperTrade] = {}
for trade in result.scalars():
latest.setdefault(trade.ticker_id, trade)
return latest
async def get_reentry_gate_locks(db: AsyncSession) -> dict[int, datetime]:
"""Return tickers still waiting for a post-stop gate failure.
A later qualified setup is actionable only after the daily scanner has
observed an unqualified evaluation after the latest initial-stop exit and
then a fresh qualification. The returned timestamp is the stop time and is
useful for diagnostics; callers normally only need the keys.
"""
latest = await _latest_initial_stop_trades(db)
return {
ticker_id: trade.closed_at
for ticker_id, trade in latest.items()
if trade.reentry_gate_requalified_at is None and trade.closed_at is not None
}
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 observe_reentry_gate_transitions(
db: AsyncSession,
*,
as_of: date | None = None,
sessions: int = REENTRY_LOCKDOWN_SESSIONS,
evaluated_ticker_ids: Iterable[int],
qualified_ticker_ids: Iterable[int],
observed_at: datetime | None = None,
) -> set[int]:
"""Compatibility wrapper for callers that only need blocked ticker ids."""
return set(
await get_reentry_lockdowns(
db,
as_of=as_of,
sessions=sessions,
)
)
"""Persist gate-failure and later requalification observations.
Only tickers whose scan completed successfully belong in
``evaluated_ticker_ids``. This prevents a scanner exception from being
mistaken for a real gate exit. The caller owns the transaction; this helper
flushes so the new state is immediately visible in that transaction.
"""
evaluated = {int(ticker_id) for ticker_id in evaluated_ticker_ids}
if not evaluated:
return set()
qualified = {int(ticker_id) for ticker_id in qualified_ticker_ids}
timestamp = observed_at or datetime.now(timezone.utc)
latest = await _latest_initial_stop_trades(db, closed_before=timestamp)
updated: set[int] = set()
for ticker_id in evaluated:
trade = latest.get(ticker_id)
if trade is None or trade.reentry_gate_requalified_at is not None:
continue
if trade.reentry_gate_failed_at is None:
if ticker_id not in qualified:
trade.reentry_gate_failed_at = timestamp
updated.add(ticker_id)
elif ticker_id in qualified:
trade.reentry_gate_requalified_at = timestamp
updated.add(ticker_id)
if updated:
await db.flush()
return updated