"""Tests for ExitScanner — emits EXIT signal when holding period elapsed.""" from datetime import datetime, timedelta, timezone from decimal import Decimal from unittest.mock import AsyncMock, MagicMock import pytest from sqlalchemy.ext.asyncio import AsyncSession from services.kevin_signal_bridge.exit_scanner import ExitScanner from shared.constants.kevin import KEVIN_STRATEGY_UUID from shared.models.meet_kevin import ( KevinAnalysis, KevinChannel, KevinMarketOutlook, KevinStockMention, KevinTickerAction, KevinTimeHorizon, KevinVideo, KevinVideoStatus, ) from shared.models.meet_kevin_trading import BridgeStatus, KevinSignalBridgeState from shared.models.trading import Trade, TradeSide, TradeStatus from shared.schemas.trading import PositionInfo pytestmark = pytest.mark.integration def _factory(session: AsyncSession): class _StaticSessionFactory: async def __aenter__(self): return session async def __aexit__(self, *args): pass def factory(): return _StaticSessionFactory() return factory def _broker_holding(*tickers: str): """Mock broker reporting the given tickers as currently held.""" broker = AsyncMock() broker.get_positions = AsyncMock( return_value=[ PositionInfo( ticker=t, qty=10.0, avg_entry=100.0, current_price=110.0, unrealized_pnl=100.0, market_value=1100.0, ) for t in tickers ] ) return broker async def _seed_video_and_mention(session: AsyncSession) -> int: channel = KevinChannel(youtube_channel_id="UCex", title="t") session.add(channel) await session.flush() video = KevinVideo( channel_id=channel.id, youtube_video_id="vex", title="t", published_at=datetime.now(timezone.utc), status=KevinVideoStatus.ANALYZED, ) session.add(video) await session.flush() analysis = KevinAnalysis( video_id=video.id, model="m", prompt_version="v1", market_outlook_direction=KevinMarketOutlook.NEUTRAL, market_outlook_reasoning="x", summary="x", prompt_tokens=10, completion_tokens=10, cost_usd=Decimal("0.01"), ) session.add(analysis) await session.flush() mention = KevinStockMention( video_id=video.id, analysis_id=analysis.id, symbol="NVDA", action=KevinTickerAction.BUY, conviction=Decimal("0.7"), time_horizon=KevinTimeHorizon.WEEKS, rationale_quote="x", ) session.add(mention) await session.flush() return mention.id @pytest.mark.asyncio async def test_exit_scanner_emits_on_elapsed_hold(db_session: AsyncSession): mention_id = await _seed_video_and_mention(db_session) # Create a filled Kevin trade 30 days ago trade = Trade( ticker="NVDA", side=TradeSide.BUY, qty=10.0, price=100.0, strategy_id=KEVIN_STRATEGY_UUID, status=TradeStatus.FILLED, ) db_session.add(trade) await db_session.flush() # Audit row with decided_at 30 days ago + hold=10d in notes audit = KevinSignalBridgeState( mention_id=mention_id, bridge_status=BridgeStatus.EMITTED, trade_id=trade.id, notes="BUY conv=0.7 -> 2% target=$2000 hold=10d", decided_at=datetime.now(timezone.utc) - timedelta(days=30), ) db_session.add(audit) await db_session.flush() publisher = AsyncMock() publisher.publish.return_value = "1234-0" config = MagicMock(kevin_hold_days={"unspecified": 10}) scanner = ExitScanner( session_factory=_factory(db_session), publisher=publisher, config=config, broker=_broker_holding("NVDA"), ) emitted = await scanner.scan_and_emit_exits() assert emitted == 1 publisher.publish.assert_awaited_once() @pytest.mark.asyncio async def test_exit_scanner_does_not_emit_when_hold_not_elapsed( db_session: AsyncSession, ): mention_id = await _seed_video_and_mention(db_session) trade = Trade( ticker="NVDA", side=TradeSide.BUY, qty=10.0, price=100.0, strategy_id=KEVIN_STRATEGY_UUID, status=TradeStatus.FILLED, ) db_session.add(trade) await db_session.flush() # decided_at 2 days ago + hold=10d -> not elapsed audit = KevinSignalBridgeState( mention_id=mention_id, bridge_status=BridgeStatus.EMITTED, trade_id=trade.id, notes="BUY hold=10d", decided_at=datetime.now(timezone.utc) - timedelta(days=2), ) db_session.add(audit) await db_session.flush() publisher = AsyncMock() config = MagicMock(kevin_hold_days={"unspecified": 10}) scanner = ExitScanner( session_factory=_factory(db_session), publisher=publisher, config=config, broker=_broker_holding("NVDA"), ) emitted = await scanner.scan_and_emit_exits() assert emitted == 0 publisher.publish.assert_not_called()