All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Pipeline #46 surfaced two pre-existing CI bugs once fakeredis was installed and tests could collect: 1. test_models.py:389 asserted "DISCOVERED" in status_col.type.enums, but the model defines KevinVideoStatus with values_callable so .enums returns the lowercase string values, not member names. Asserting "discovered" instead. 2. Four test files use the db_session fixture which requires a real Postgres on localhost:5432. CI has no Postgres, so 10 tests failed with Connect call failed (errno 111). These genuinely need a DB — mirroring tests/integration/* which already use @pytest.mark.integration. Adding module-level pytestmark = pytest.mark.integration to: - tests/shared/models/test_meet_kevin_trading.py - tests/services/kevin_signal_bridge/test_aggregator.py - tests/services/kevin_signal_bridge/test_audit.py - tests/services/kevin_signal_bridge/test_exit_scanner.py CI runs with -m "not integration" so they're now deselected. Local pytest still picks them up by default (no marker filter).
156 lines
4.4 KiB
Python
156 lines
4.4 KiB
Python
"""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
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
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
|
|
|
|
|
|
def _factory(session: AsyncSession):
|
|
class _StaticSessionFactory:
|
|
async def __aenter__(self):
|
|
return session
|
|
|
|
async def __aexit__(self, *args):
|
|
pass
|
|
|
|
def factory():
|
|
return _StaticSessionFactory()
|
|
|
|
return factory
|
|
|
|
|
|
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
|
|
)
|
|
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
|
|
)
|
|
emitted = await scanner.scan_and_emit_exits()
|
|
assert emitted == 0
|
|
publisher.publish.assert_not_called()
|