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
+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