"""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, broker: Any, ) -> None: self.session_factory = session_factory self.publisher = publisher self.config = config self.broker = broker async def scan_and_emit_exits(self) -> int: """Returns the number of EXIT signals emitted.""" now = datetime.now(timezone.utc) emitted = 0 # Offsetting-SELL guard: only emit exits for tickers STILL held at the # broker, so we never re-emit for an already-closed position. With zero # open positions this set is empty → the scan is a safe no-op. positions = await self.broker.get_positions() held_tickers = {p.ticker for p in positions if p.qty != 0} if not held_tickers: return 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: # Skip tickers no longer held at the broker (already closed). if trade.ticker not in held_tickers: continue # 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))