"""Daily exit-scan job for the Kevin bridge. Walks open Kevin trades (Trade.strategy_id == KEVIN_STRATEGY_UUID, status FILLED, no offsetting SELL) and publishes EXIT TradeSignals for any whose holding period has elapsed. """ from __future__ import annotations import logging from datetime import datetime, timedelta, timezone from typing import Any, Callable from sqlalchemy import and_, select from shared.constants.kevin import KEVIN_STRATEGY_UUID from shared.models.meet_kevin_trading import KevinSignalBridgeState from shared.models.trading import Trade, TradeStatus from shared.schemas.trading import SignalDirection, TradeSignal logger = logging.getLogger(__name__) class ExitScanner: def __init__( self, session_factory: Callable[..., Any], publisher: Any, config: Any, ) -> None: self.session_factory = session_factory self.publisher = publisher self.config = config async def scan_and_emit_exits(self) -> int: """Returns the number of EXIT signals emitted.""" now = datetime.now(timezone.utc) emitted = 0 async with self.session_factory() as session: # Find open Kevin trades (FILLED, no closing trade yet on same ticker) open_trades = ( ( await session.execute( select(Trade).where( and_( Trade.strategy_id == KEVIN_STRATEGY_UUID, Trade.status == TradeStatus.FILLED, ) ) ) ) .scalars() .all() ) for trade in open_trades: # Find the source audit row to learn the original holding_days target async with self.session_factory() as session: audit = ( ( await session.execute( select(KevinSignalBridgeState).where( KevinSignalBridgeState.trade_id == trade.id ) ) ) .scalars() .one_or_none() ) if audit is None: continue hold_days = self._holding_days(audit) target_exit_at = audit.decided_at + timedelta(days=hold_days) if now < target_exit_at: continue signal = TradeSignal( ticker=trade.ticker, direction=SignalDirection.EXIT, strength=1.0, strategy_id=KEVIN_STRATEGY_UUID, strategy_sources=[f"kevin:exit_scan:hold_expired:{hold_days}d"], ) try: await self.publisher.publish(signal) emitted += 1 except Exception: logger.exception("exit-scan publish failed for trade %s", trade.id) return emitted def _holding_days(self, audit: KevinSignalBridgeState) -> int: """Best-effort holding days from notes; fallback to config default.""" notes = audit.notes or "" # Try to find ' hold=Nd' in the audit notes for token in notes.split(): if token.startswith("hold=") and token.endswith("d"): try: return int(token.removeprefix("hold=").removesuffix("d")) except ValueError: pass default_map = getattr(self.config, "kevin_hold_days", {}) return int(default_map.get("unspecified", 10))