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).
184 lines
5.5 KiB
Python
184 lines
5.5 KiB
Python
"""Tests for the MentionAggregator — multi-mention window + conviction boost."""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
from services.kevin_signal_bridge.aggregator import MentionAggregator
|
|
from shared.models.meet_kevin import (
|
|
KevinAnalysis,
|
|
KevinChannel,
|
|
KevinMarketOutlook,
|
|
KevinStockMention,
|
|
KevinTickerAction,
|
|
KevinTimeHorizon,
|
|
KevinVideo,
|
|
KevinVideoStatus,
|
|
)
|
|
|
|
|
|
async def _seed_channel_video(
|
|
session: AsyncSession, suffix: str = "1"
|
|
) -> tuple[int, int]:
|
|
channel = KevinChannel(youtube_channel_id=f"UC{suffix}", title="t")
|
|
session.add(channel)
|
|
await session.flush()
|
|
video = KevinVideo(
|
|
channel_id=channel.id,
|
|
youtube_video_id=f"v{suffix}",
|
|
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()
|
|
return video.id, analysis.id
|
|
|
|
|
|
def _factory(session: AsyncSession):
|
|
"""Return a session_factory that returns the same session."""
|
|
|
|
class _StaticSessionFactory:
|
|
async def __aenter__(self):
|
|
return session
|
|
|
|
async def __aexit__(self, *args):
|
|
pass
|
|
|
|
def factory():
|
|
return _StaticSessionFactory()
|
|
|
|
return factory
|
|
|
|
|
|
async def _insert_mention(
|
|
session: AsyncSession,
|
|
video_id: int,
|
|
analysis_id: int,
|
|
symbol: str,
|
|
conviction: str,
|
|
when: datetime,
|
|
action: KevinTickerAction = KevinTickerAction.BUY,
|
|
) -> KevinStockMention:
|
|
m = KevinStockMention(
|
|
video_id=video_id,
|
|
analysis_id=analysis_id,
|
|
symbol=symbol,
|
|
action=action,
|
|
conviction=Decimal(conviction),
|
|
time_horizon=KevinTimeHorizon.WEEKS,
|
|
rationale_quote="x",
|
|
)
|
|
session.add(m)
|
|
await session.flush()
|
|
# Override created_at
|
|
m.created_at = when
|
|
session.add(m)
|
|
await session.flush()
|
|
return m
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aggregator_returns_all_unseen_mentions(db_session: AsyncSession):
|
|
video_id, analysis_id = await _seed_channel_video(db_session)
|
|
now = datetime.now(timezone.utc)
|
|
m1 = await _insert_mention(
|
|
db_session, video_id, analysis_id, "NVDA", "0.7", now - timedelta(hours=10)
|
|
)
|
|
m2 = await _insert_mention(
|
|
db_session, video_id, analysis_id, "NVDA", "0.7", now - timedelta(hours=5)
|
|
)
|
|
m3 = await _insert_mention(
|
|
db_session, video_id, analysis_id, "INTC", "0.7", now - timedelta(hours=2)
|
|
)
|
|
|
|
agg = MentionAggregator(session_factory=_factory(db_session))
|
|
pending = await agg.fetch_pending(since_id=0)
|
|
ids = {p.id for p in pending}
|
|
assert ids == {m1.id, m2.id, m3.id}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aggregator_applies_conviction_boost(db_session: AsyncSession):
|
|
video_id, analysis_id = await _seed_channel_video(db_session, "boost")
|
|
now = datetime.now(timezone.utc)
|
|
await _insert_mention(
|
|
db_session, video_id, analysis_id, "NVDA", "0.7", now - timedelta(hours=10)
|
|
)
|
|
m2 = await _insert_mention(
|
|
db_session, video_id, analysis_id, "NVDA", "0.7", now - timedelta(hours=5)
|
|
)
|
|
|
|
agg = MentionAggregator(
|
|
session_factory=_factory(db_session),
|
|
window_hours=48,
|
|
boost_per_repeat=Decimal("0.05"),
|
|
)
|
|
pending = await agg.fetch_pending(since_id=0)
|
|
# The second NVDA mention should have effective_conviction = 0.7 + 0.05 = 0.75
|
|
by_id = {p.id: p for p in pending}
|
|
assert by_id[m2.id].effective_conviction == Decimal("0.75")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aggregator_caps_boost_at_max(db_session: AsyncSession):
|
|
video_id, analysis_id = await _seed_channel_video(db_session, "cap")
|
|
now = datetime.now(timezone.utc)
|
|
# 6 mentions -> 5 extras -> boost 5*0.05 = 0.25 -> capped at 0.20
|
|
last_m = None
|
|
for i in range(6):
|
|
last_m = await _insert_mention(
|
|
db_session,
|
|
video_id,
|
|
analysis_id,
|
|
"NVDA",
|
|
"0.7",
|
|
now - timedelta(hours=20 - i),
|
|
)
|
|
|
|
agg = MentionAggregator(
|
|
session_factory=_factory(db_session),
|
|
window_hours=48,
|
|
boost_per_repeat=Decimal("0.05"),
|
|
max_boost=Decimal("0.20"),
|
|
)
|
|
pending = await agg.fetch_pending(since_id=0)
|
|
by_id = {p.id: p for p in pending}
|
|
assert last_m is not None
|
|
# capped at 0.7 + 0.20 = 0.90
|
|
assert by_id[last_m.id].effective_conviction == Decimal("0.90")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aggregator_excludes_already_processed(db_session: AsyncSession):
|
|
video_id, analysis_id = await _seed_channel_video(db_session, "excl")
|
|
now = datetime.now(timezone.utc)
|
|
m1 = await _insert_mention(
|
|
db_session, video_id, analysis_id, "NVDA", "0.7", now - timedelta(hours=10)
|
|
)
|
|
m2 = await _insert_mention(
|
|
db_session, video_id, analysis_id, "INTC", "0.7", now - timedelta(hours=5)
|
|
)
|
|
|
|
agg = MentionAggregator(session_factory=_factory(db_session))
|
|
pending = await agg.fetch_pending(since_id=m1.id)
|
|
ids = {p.id for p in pending}
|
|
assert m1.id not in ids
|
|
assert m2.id in ids
|