trading/tests/services/kevin_signal_bridge/test_exit_scanner.py

183 lines
5 KiB
Python
Raw Normal View History

"""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()