feat: require gate reset before post-stop reentry
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user