diff --git a/src/polymarket_insider_tracker/detector/size_anomaly.py b/src/polymarket_insider_tracker/detector/size_anomaly.py index b348dee..151e58e 100644 --- a/src/polymarket_insider_tracker/detector/size_anomaly.py +++ b/src/polymarket_insider_tracker/detector/size_anomaly.py @@ -17,6 +17,9 @@ logger = logging.getLogger(__name__) DEFAULT_VOLUME_THRESHOLD = 0.02 # 2% of daily volume DEFAULT_BOOK_THRESHOLD = 0.05 # 5% of order book depth DEFAULT_NICHE_VOLUME_THRESHOLD = Decimal("50000") # $50k daily volume +# Niche-only path requires real trade size; below this, suppress the base +# 0.2 confidence so we don't flood alerts with low-value niche trades. +DEFAULT_NICHE_MIN_TRADE_SIZE = Decimal("500") # USDC notional # Niche market categories - markets in these categories with low specificity # are more likely to have insider information value @@ -59,6 +62,7 @@ class SizeAnomalyDetector: volume_threshold: float = DEFAULT_VOLUME_THRESHOLD, book_threshold: float = DEFAULT_BOOK_THRESHOLD, niche_volume_threshold: Decimal = DEFAULT_NICHE_VOLUME_THRESHOLD, + niche_min_trade_size: Decimal = DEFAULT_NICHE_MIN_TRADE_SIZE, ) -> None: """Initialize the size anomaly detector. @@ -67,11 +71,15 @@ class SizeAnomalyDetector: volume_threshold: Threshold for volume impact (default 0.02 = 2%). book_threshold: Threshold for book impact (default 0.05 = 5%). niche_volume_threshold: Volume below which market is niche ($50k). + niche_min_trade_size: Minimum trade notional ($) to allow a + niche-only signal. Below this, niche-only path is suppressed + to avoid flooding alerts with low-value trades. """ self._metadata_sync = metadata_sync self._volume_threshold = volume_threshold self._book_threshold = book_threshold self._niche_volume_threshold = niche_volume_threshold + self._niche_min_trade_size = niche_min_trade_size async def analyze( self, @@ -129,6 +137,18 @@ class SizeAnomalyDetector: exceeds_volume = volume_impact > self._volume_threshold exceeds_book = book_impact > self._book_threshold + # Niche-only signals require a minimum trade size; otherwise we'd + # flood alerts with every tiny trade in any niche-prone category. + niche_only = is_niche and not exceeds_volume and not exceeds_book + if niche_only and trade_size < self._niche_min_trade_size: + logger.debug( + "Trade %s niche-only but size %s < min %s, skipping", + trade.trade_id, + trade_size, + self._niche_min_trade_size, + ) + return None + if not exceeds_volume and not exceeds_book and not is_niche: logger.debug( "Trade %s does not exceed thresholds: volume=%.4f, book=%.4f", diff --git a/tests/detector/test_size_anomaly.py b/tests/detector/test_size_anomaly.py index f214c01..963c917 100644 --- a/tests/detector/test_size_anomaly.py +++ b/tests/detector/test_size_anomaly.py @@ -588,6 +588,93 @@ class TestAnalyzeMethod: assert signal.is_niche_market is True assert signal.confidence == 0.2 # niche_base + @pytest.mark.asyncio + async def test_analyze_niche_only_below_min_trade_size_skipped( + self, + mock_metadata_sync: AsyncMock, + sample_metadata: MarketMetadata, + ) -> None: + """Niche-only trades below the min trade size are suppressed.""" + mock_metadata_sync.get_market.return_value = sample_metadata + detector = SizeAnomalyDetector(mock_metadata_sync) + + tiny_trade = TradeEvent( + market_id="market_abc123", + trade_id="tx_tiny", + wallet_address="0xabc", + side="BUY", + outcome="Yes", + outcome_index=0, + price=Decimal("0.5"), + size=Decimal("100"), # $50 notional, below default $500 floor + timestamp=datetime.now(UTC), + asset_id="token_123", + event_title="Niche tiny trade", + ) + + signal = await detector.analyze(tiny_trade) + assert signal is None + + @pytest.mark.asyncio + async def test_analyze_niche_only_at_min_trade_size_emits( + self, + mock_metadata_sync: AsyncMock, + sample_metadata: MarketMetadata, + ) -> None: + """Niche-only trades at or above the min trade size still emit.""" + mock_metadata_sync.get_market.return_value = sample_metadata + detector = SizeAnomalyDetector(mock_metadata_sync) + + ok_trade = TradeEvent( + market_id="market_abc123", + trade_id="tx_ok", + wallet_address="0xabc", + side="BUY", + outcome="Yes", + outcome_index=0, + price=Decimal("0.5"), + size=Decimal("2000"), # $1000 notional, above default $500 floor + timestamp=datetime.now(UTC), + asset_id="token_123", + event_title="Niche ok trade", + ) + + signal = await detector.analyze(ok_trade) + assert signal is not None + assert signal.is_niche_market is True + assert signal.confidence == 0.2 + + @pytest.mark.asyncio + async def test_niche_min_trade_size_does_not_block_real_anomalies( + self, + mock_metadata_sync: AsyncMock, + sample_metadata: MarketMetadata, + ) -> None: + """A trade that exceeds volume/book thresholds is never blocked by the niche guard.""" + mock_metadata_sync.get_market.return_value = sample_metadata + detector = SizeAnomalyDetector(mock_metadata_sync) + + small_but_high_impact_trade = TradeEvent( + market_id="market_abc123", + trade_id="tx_small_impact", + wallet_address="0xabc", + side="BUY", + outcome="Yes", + outcome_index=0, + price=Decimal("0.5"), + size=Decimal("200"), # $100 notional, below niche floor + timestamp=datetime.now(UTC), + asset_id="token_123", + event_title="Small but high-impact", + ) + + # Provide small daily_volume so volume_impact exceeds 2% threshold + signal = await detector.analyze( + small_but_high_impact_trade, daily_volume=Decimal("1000") + ) + assert signal is not None + assert signal.volume_impact > 0.02 + @pytest.mark.asyncio async def test_analyze_no_anomaly( self,