Documents the persist-all-assessments feature shipped in 8a0e8c9 and adds two regression tests covering: (1) sub-threshold assessments still hit the DB, and (2) DB write failures do not block alert dispatch.
187 lines
6.3 KiB
Python
187 lines
6.3 KiB
Python
"""Tests for RiskAssessment persistence inside Pipeline._score_and_alert.
|
|
|
|
Verifies:
|
|
1. Every signal-bearing assessment is written to risk_assessments, even
|
|
when ``should_alert`` is False (i.e. below the alert threshold).
|
|
2. A DB failure during persistence never blocks alert dispatching.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from decimal import Decimal
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
from polymarket_insider_tracker.config import Settings
|
|
from polymarket_insider_tracker.detector.models import RiskAssessment
|
|
from polymarket_insider_tracker.detector.scorer import SignalBundle
|
|
from polymarket_insider_tracker.ingestor.models import TradeEvent
|
|
from polymarket_insider_tracker.pipeline import Pipeline
|
|
from polymarket_insider_tracker.storage.database import DatabaseManager
|
|
from polymarket_insider_tracker.storage.models import Base, RiskAssessmentModel
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_settings():
|
|
"""Settings stub with the attributes Pipeline reaches for at runtime."""
|
|
detector = MagicMock()
|
|
detector.persist_assessments = True
|
|
detector.alert_threshold = 0.8
|
|
|
|
settings = MagicMock(spec=Settings)
|
|
settings.detector = detector
|
|
settings.dry_run = False
|
|
return settings
|
|
|
|
|
|
@pytest.fixture
|
|
async def async_engine():
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
yield engine
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
async def db_manager(async_engine):
|
|
manager = DatabaseManager.__new__(DatabaseManager)
|
|
manager.database_url = "sqlite+aiosqlite:///:memory:"
|
|
manager.async_mode = True
|
|
manager._pool_size = 5
|
|
manager._max_overflow = 10
|
|
manager._echo = False
|
|
manager._sync_engine = None
|
|
manager._async_engine = async_engine
|
|
manager._sync_session_factory = None
|
|
manager._async_session_factory = async_sessionmaker(
|
|
bind=async_engine, expire_on_commit=False
|
|
)
|
|
return manager
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_trade() -> TradeEvent:
|
|
return TradeEvent(
|
|
trade_id="0x" + "a" * 64,
|
|
wallet_address="0x" + "b" * 40,
|
|
market_id="0x" + "c" * 64,
|
|
asset_id="asset_xyz",
|
|
side="BUY",
|
|
price=Decimal("0.42"),
|
|
size=Decimal("1000"),
|
|
timestamp=datetime.now(UTC),
|
|
outcome="Yes",
|
|
outcome_index=0,
|
|
event_title="Test Event",
|
|
market_slug="test-market",
|
|
)
|
|
|
|
|
|
def _make_assessment(trade: TradeEvent, *, should_alert: bool, score: float) -> RiskAssessment:
|
|
return RiskAssessment(
|
|
trade_event=trade,
|
|
wallet_address=trade.wallet_address,
|
|
market_id=trade.market_id,
|
|
fresh_wallet_signal=None,
|
|
size_anomaly_signal=None,
|
|
signals_triggered=1,
|
|
weighted_score=score,
|
|
should_alert=should_alert,
|
|
)
|
|
|
|
|
|
def _build_pipeline(
|
|
mock_settings,
|
|
*,
|
|
db_manager=None,
|
|
assessment: RiskAssessment,
|
|
dispatcher: MagicMock | None = None,
|
|
) -> Pipeline:
|
|
"""Construct a Pipeline with the minimum collaborators wired in."""
|
|
pipeline = Pipeline(mock_settings)
|
|
pipeline._db_manager = db_manager
|
|
|
|
pipeline._risk_scorer = MagicMock()
|
|
pipeline._risk_scorer.assess = AsyncMock(return_value=assessment)
|
|
|
|
pipeline._alert_formatter = MagicMock()
|
|
pipeline._alert_formatter.format = MagicMock(return_value=MagicMock())
|
|
|
|
if dispatcher is None:
|
|
dispatcher = MagicMock()
|
|
dispatcher.dispatch = AsyncMock(
|
|
return_value=MagicMock(all_succeeded=True, success_count=1, failure_count=0)
|
|
)
|
|
pipeline._alert_dispatcher = dispatcher
|
|
pipeline._dry_run = False
|
|
return pipeline
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPersistAssessment:
|
|
@pytest.mark.asyncio
|
|
async def test_below_threshold_assessment_is_persisted(
|
|
self, mock_settings, db_manager, sample_trade, async_engine
|
|
):
|
|
"""Assessments with should_alert=False must still hit the DB; no dispatch."""
|
|
assessment = _make_assessment(sample_trade, should_alert=False, score=0.45)
|
|
pipeline = _build_pipeline(
|
|
mock_settings, db_manager=db_manager, assessment=assessment
|
|
)
|
|
|
|
await pipeline._score_and_alert(SignalBundle(trade_event=sample_trade))
|
|
|
|
# Row landed in risk_assessments
|
|
async with async_sessionmaker(bind=async_engine, expire_on_commit=False)() as session:
|
|
rows = (await session.execute(select(RiskAssessmentModel))).scalars().all()
|
|
assert len(rows) == 1
|
|
row = rows[0]
|
|
assert row.assessment_id == assessment.assessment_id
|
|
assert row.should_alert is False
|
|
assert float(row.weighted_score) == pytest.approx(0.45, abs=1e-3)
|
|
assert row.wallet_address == sample_trade.wallet_address.lower()
|
|
|
|
# No alert dispatched for sub-threshold assessments
|
|
pipeline._alert_dispatcher.dispatch.assert_not_called()
|
|
assert pipeline.stats.alerts_sent == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persistence_failure_does_not_block_dispatch(
|
|
self, mock_settings, sample_trade
|
|
):
|
|
"""If repo.insert blows up, the alert pipeline still ships the alert."""
|
|
assessment = _make_assessment(sample_trade, should_alert=True, score=0.92)
|
|
|
|
# db_manager whose get_async_session raises -> _persist_assessment swallows it
|
|
broken_db = MagicMock()
|
|
broken_db.get_async_session = MagicMock(
|
|
side_effect=RuntimeError("DB connection failed")
|
|
)
|
|
|
|
pipeline = _build_pipeline(
|
|
mock_settings, db_manager=broken_db, assessment=assessment
|
|
)
|
|
|
|
await pipeline._score_and_alert(SignalBundle(trade_event=sample_trade))
|
|
|
|
# DB write was attempted and failed silently
|
|
broken_db.get_async_session.assert_called_once()
|
|
|
|
# Dispatcher still ran and the stats counter incremented
|
|
pipeline._alert_dispatcher.dispatch.assert_awaited_once()
|
|
assert pipeline.stats.alerts_sent == 1
|