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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user