Files
signal-platform/app/services/trade_policy.py
T

94 lines
3.2 KiB
Python

"""Shared live trading-policy state and availability checks."""
from __future__ import annotations
from collections.abc import Iterable
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.paper_trade import PaperTrade
async def _latest_initial_stop_trades(
db: AsyncSession,
*,
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),
)
.order_by(
PaperTrade.ticker_id.asc(),
PaperTrade.closed_at.desc(),
PaperTrade.id.desc(),
)
)
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
}
async def observe_reentry_gate_transitions(
db: AsyncSession,
*,
evaluated_ticker_ids: Iterable[int],
qualified_ticker_ids: Iterable[int],
observed_at: datetime | None = None,
) -> set[int]:
"""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