fix(detector): suppress niche-only signals under a min trade size (#100)

The size anomaly detector currently emits a base 0.2 confidence signal
whenever a market is "niche" (low volume OR niche-prone category with
unknown volume) — even for $5 trades. In production this floods the
alert pipeline with nothing-burgers, because the niche-prone category
set covers `science / tech / finance / other`, which matches a huge
chunk of Polymarket's long tail.

This adds a `DEFAULT_NICHE_MIN_TRADE_SIZE = $500` floor that applies
ONLY to the niche-only path: if a trade exceeds the volume or book
thresholds, the guard does not block it. So real anomalies still come
through, but tiny niche trades get filtered out before reaching the
risk scorer.

The threshold is configurable via `niche_min_trade_size` in
`SizeAnomalyDetector.__init__`.

Tests added:
- niche-only below floor → suppressed
- niche-only at/above floor → still emits 0.2 base
- small trade with high volume_impact → guard does not block

Co-authored-by: schrodinger01 <schrodinger01@users.noreply.github.com>
This commit is contained in:
schrodinger01
2026-06-18 03:48:01 +08:00
committed by GitHub
parent 5f5a2bffd6
commit 0ceae95e92
2 changed files with 107 additions and 0 deletions
@@ -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",
+87
View File
@@ -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,