From e8fd5086768d1a766f8d51f2ece44aab0b590005 Mon Sep 17 00:00:00 2001 From: schrodinger01 Date: Sat, 23 May 2026 10:55:10 +0800 Subject: [PATCH] docs+test: add CHANGELOG and persistence regression tests 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. --- CHANGELOG.md | 29 +++++ tests/test_persist_assessment.py | 186 +++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 tests/test_persist_assessment.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..42e138e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to this project are documented in this file. +The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added +- **Risk-assessment persistence**: every signal-bearing trade now writes a row + to the new `risk_assessments` table, regardless of whether the assessment + meets the alert threshold. This is the ground-truth log future backtests will + read instead of grepping `alerts.log` / `journalctl`. + - Pipeline: `Pipeline._score_and_alert` calls `Pipeline._persist_assessment` + for every assessment; failures are caught and never block alert dispatch. + - Storage: new `RiskAssessmentModel`, `RiskAssessmentDTO`, and + `RiskAssessmentRepository` (alembic migration shipped previously). + - Config: `DETECTOR_PERSIST_ASSESSMENTS` env var (default `true`) controls + the write path so it can be disabled without code changes. + - Tests: `tests/test_persist_assessment.py` covers (a) sub-threshold rows are + persisted with `should_alert=False` and dispatch is skipped, and (b) DB + failures during persistence do not block dispatching. + +### Changed +- Alert threshold (`DETECTOR_ALERT_THRESHOLD`) is now fully env-driven; the + legacy hard-coded `0.6` default has been raised to `0.80` for production. + +### Notes +- Backtest scripts can now source data from `risk_assessments` directly. The + `alerts.log` parsing path remains for one release as a fallback. diff --git a/tests/test_persist_assessment.py b/tests/test_persist_assessment.py new file mode 100644 index 0000000..9fd25bc --- /dev/null +++ b/tests/test_persist_assessment.py @@ -0,0 +1,186 @@ +"""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