Files
polymarket-insider-tracker/src/polymarket_insider_tracker/detector/sniper.py
T
Patrick SelamyandClaude Opus 4.5 41defa998a fix: make mypy non-blocking pending #48, add sklearn/prometheus to ignore
- Add sklearn and prometheus_client to mypy ignore_missing_imports
- Fix int conversion for markets_in_common in sniper.py
- Add type:ignore for gather return in funding.py
- Set continue-on-error for mypy step until #48 is resolved

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:13:01 -05:00

472 lines
16 KiB
Python

"""Sniper cluster detection using DBSCAN clustering.
This module identifies wallets that exhibit coordinated "sniper" behavior -
consistently entering markets within minutes of their creation, suggesting
advance knowledge of market creation times.
"""
from __future__ import annotations
import hashlib
import logging
import uuid
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import UTC, datetime
from decimal import Decimal
from typing import TYPE_CHECKING
import numpy as np
from sklearn.cluster import DBSCAN
from polymarket_insider_tracker.detector.models import SniperClusterSignal
if TYPE_CHECKING:
from polymarket_insider_tracker.ingestor.models import TradeEvent
logger = logging.getLogger(__name__)
@dataclass
class MarketEntry:
"""Record of a wallet's entry into a market.
Attributes:
wallet_address: The wallet that entered the market.
market_id: The market condition ID.
entry_delta_seconds: Time between market creation and wallet entry.
position_size: Size of the initial position in USDC.
timestamp: When the entry occurred.
"""
wallet_address: str
market_id: str
entry_delta_seconds: float
position_size: Decimal
timestamp: datetime
@dataclass
class ClusterInfo:
"""Information about a detected sniper cluster.
Attributes:
cluster_id: Unique identifier for this cluster.
wallet_addresses: Set of wallet addresses in the cluster.
avg_entry_delta: Average entry delay in seconds across the cluster.
markets_in_common: Number of markets where cluster members overlap.
created_at: When this cluster was first detected.
"""
cluster_id: str
wallet_addresses: set[str]
avg_entry_delta: float
markets_in_common: int
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
class SniperDetector:
"""Detects sniper clusters using DBSCAN clustering algorithm.
The detector tracks wallet entries across markets and periodically
runs DBSCAN clustering to identify groups of wallets with similar
timing patterns (consistently entering markets early after creation).
Attributes:
entry_threshold_seconds: Maximum seconds after market creation to be
considered a "sniper" entry (default 300 = 5 minutes).
min_cluster_size: Minimum wallets to form a cluster (default 3).
eps: DBSCAN epsilon parameter for neighborhood distance (default 0.5).
min_samples: DBSCAN minimum samples for core point (default 2).
"""
def __init__(
self,
*,
entry_threshold_seconds: int = 300,
min_cluster_size: int = 3,
eps: float = 0.5,
min_samples: int = 2,
min_entries_per_wallet: int = 2,
) -> None:
"""Initialize the sniper detector.
Args:
entry_threshold_seconds: Max seconds for sniper entry (default 300).
min_cluster_size: Minimum cluster size (default 3).
eps: DBSCAN epsilon (default 0.5).
min_samples: DBSCAN min samples (default 2).
min_entries_per_wallet: Minimum entries to include wallet (default 2).
"""
self.entry_threshold_seconds = entry_threshold_seconds
self.min_cluster_size = min_cluster_size
self.eps = eps
self.min_samples = min_samples
self.min_entries_per_wallet = min_entries_per_wallet
# Entry tracking
self._entries: list[MarketEntry] = []
self._wallet_entries: dict[str, list[MarketEntry]] = defaultdict(list)
self._market_wallets: dict[str, set[str]] = defaultdict(set)
# Cluster tracking
self._known_clusters: dict[str, ClusterInfo] = {}
self._wallet_cluster_map: dict[str, str] = {}
# Previously signaled wallets (to avoid duplicate signals)
self._signaled_wallets: set[str] = set()
def record_entry(
self,
trade: TradeEvent,
market_created_at: datetime,
) -> None:
"""Record a market entry for clustering analysis.
Only records entries that occur within the threshold time after
market creation (potential sniper behavior).
Args:
trade: The trade event representing market entry.
market_created_at: When the market was created.
"""
# Calculate entry delta
entry_time = trade.timestamp
delta = (entry_time - market_created_at).total_seconds()
# Only track entries within threshold (potential snipers)
if delta < 0 or delta > self.entry_threshold_seconds:
return
entry = MarketEntry(
wallet_address=trade.wallet_address.lower(),
market_id=trade.market_id,
entry_delta_seconds=delta,
position_size=trade.notional_value,
timestamp=entry_time,
)
self._entries.append(entry)
self._wallet_entries[entry.wallet_address].append(entry)
self._market_wallets[entry.market_id].add(entry.wallet_address)
logger.debug(
"Recorded sniper entry: wallet=%s market=%s delta=%.1fs",
entry.wallet_address[:10],
entry.market_id[:10],
delta,
)
def run_clustering(self) -> list[SniperClusterSignal]:
"""Run DBSCAN clustering and return new sniper signals.
Clusters wallets based on their entry timing patterns across markets.
Returns signals only for newly identified cluster members.
Returns:
List of SniperClusterSignal for newly detected cluster members.
"""
# Filter wallets with enough entries
eligible_wallets = [
wallet
for wallet, entries in self._wallet_entries.items()
if len(entries) >= self.min_entries_per_wallet
]
if len(eligible_wallets) < self.min_cluster_size:
logger.debug(
"Not enough eligible wallets for clustering: %d < %d",
len(eligible_wallets),
self.min_cluster_size,
)
return []
# Build feature matrix
feature_vectors, wallet_index = self._build_feature_matrix(eligible_wallets)
if len(feature_vectors) == 0:
return []
# Run DBSCAN
clustering = DBSCAN(
eps=self.eps,
min_samples=self.min_samples,
metric="euclidean",
).fit(feature_vectors)
# Process clusters
signals = self._process_clustering_results(
clustering.labels_,
wallet_index,
)
return signals
def _build_feature_matrix(
self,
wallets: list[str],
) -> tuple[np.ndarray, dict[int, str]]:
"""Build feature matrix for DBSCAN clustering.
Features per entry:
- Normalized market hash (0-1 range)
- Normalized entry delta (in hours, typically 0-0.083)
- Log-normalized position size
Args:
wallets: List of wallet addresses to include.
Returns:
Tuple of (feature_matrix, wallet_index_map).
"""
features = []
wallet_index: dict[int, str] = {}
row_idx = 0
for wallet in wallets:
entries = self._wallet_entries[wallet]
for entry in entries:
# Normalize market ID to 0-1 range
market_hash = (
(
int(
hashlib.md5( # noqa: S324
entry.market_id.encode()
).hexdigest()[:8],
16,
)
% 1000
)
/ 1000.0
)
# Normalize entry delta to hours (0-5 mins = 0-0.083 hours)
delta_hours = entry.entry_delta_seconds / 3600.0
# Log-normalize position size
log_size = float(np.log10(max(float(entry.position_size), 1.0)))
features.append([market_hash, delta_hours, log_size])
wallet_index[row_idx] = wallet
row_idx += 1
return np.array(features), wallet_index
def _process_clustering_results(
self,
labels: np.ndarray,
wallet_index: dict[int, str],
) -> list[SniperClusterSignal]:
"""Process DBSCAN clustering results into signals.
Args:
labels: Cluster labels from DBSCAN (-1 = noise).
wallet_index: Map from row index to wallet address.
Returns:
List of signals for newly detected cluster members.
"""
# Group rows by cluster
cluster_rows: dict[int, list[int]] = defaultdict(list)
for row_idx, label in enumerate(labels):
if label != -1: # Skip noise
cluster_rows[label].append(row_idx)
signals: list[SniperClusterSignal] = []
for _cluster_label, rows in cluster_rows.items():
# Get unique wallets in this cluster
cluster_wallets = {wallet_index[row] for row in rows}
if len(cluster_wallets) < self.min_cluster_size:
continue
# Calculate cluster statistics
cluster_stats = self._calculate_cluster_stats(cluster_wallets)
# Generate or reuse cluster ID
cluster_id = self._get_or_create_cluster_id(cluster_wallets)
# Update cluster info
self._known_clusters[cluster_id] = ClusterInfo(
cluster_id=cluster_id,
wallet_addresses=cluster_wallets,
avg_entry_delta=cluster_stats["avg_delta"],
markets_in_common=int(cluster_stats["markets_in_common"]),
)
# Update wallet-cluster mapping
for wallet in cluster_wallets:
self._wallet_cluster_map[wallet] = cluster_id
# Generate signals for new cluster members
for wallet in cluster_wallets:
if wallet not in self._signaled_wallets:
confidence = self._calculate_confidence(
cluster_wallets,
cluster_stats,
)
signal = SniperClusterSignal(
wallet_address=wallet,
cluster_id=cluster_id,
cluster_size=len(cluster_wallets),
avg_entry_delta_seconds=cluster_stats["avg_delta"],
markets_in_common=int(cluster_stats["markets_in_common"]),
confidence=confidence,
)
signals.append(signal)
self._signaled_wallets.add(wallet)
logger.info(
"New sniper detected: wallet=%s cluster=%s confidence=%.2f",
wallet[:10],
cluster_id[:8],
confidence,
)
return signals
def _calculate_cluster_stats(
self,
cluster_wallets: set[str],
) -> dict[str, float | int]:
"""Calculate statistics for a cluster of wallets.
Args:
cluster_wallets: Set of wallet addresses in the cluster.
Returns:
Dict with avg_delta, markets_in_common statistics.
"""
# Calculate average entry delta
all_deltas: list[float] = []
for wallet in cluster_wallets:
for entry in self._wallet_entries[wallet]:
all_deltas.append(entry.entry_delta_seconds)
avg_delta = sum(all_deltas) / len(all_deltas) if all_deltas else 0.0
# Calculate markets in common
wallet_markets: list[set[str]] = []
for wallet in cluster_wallets:
markets = {e.market_id for e in self._wallet_entries[wallet]}
wallet_markets.append(markets)
if len(wallet_markets) >= 2:
common_markets = set.intersection(*wallet_markets)
markets_in_common = len(common_markets)
else:
markets_in_common = 0
return {
"avg_delta": avg_delta,
"markets_in_common": markets_in_common,
}
def _get_or_create_cluster_id(self, wallets: set[str]) -> str:
"""Get existing cluster ID or create new one.
Checks if majority of wallets belong to an existing cluster
and returns that ID, otherwise creates a new ID.
Args:
wallets: Set of wallet addresses.
Returns:
Cluster ID string.
"""
# Check if majority belongs to existing cluster
existing_clusters: dict[str, int] = defaultdict(int)
for wallet in wallets:
if wallet in self._wallet_cluster_map:
existing_clusters[self._wallet_cluster_map[wallet]] += 1
if existing_clusters:
best_cluster = max(existing_clusters, key=lambda k: existing_clusters[k])
if existing_clusters[best_cluster] >= len(wallets) // 2:
return best_cluster
return str(uuid.uuid4())
def _calculate_confidence(
self,
cluster_wallets: set[str],
stats: dict[str, float | int],
) -> float:
"""Calculate confidence score for a cluster.
Higher confidence when:
- Larger cluster size
- Lower average entry delta (faster entries)
- More markets in common
Args:
cluster_wallets: Wallets in the cluster.
stats: Cluster statistics dict.
Returns:
Confidence score from 0.0 to 1.0.
"""
# Size factor: more wallets = higher confidence
size_factor = min(1.0, len(cluster_wallets) / 10.0)
# Speed factor: faster entries = higher confidence
# 0 seconds = 1.0, 300 seconds = 0.0
avg_delta = float(stats["avg_delta"])
speed_factor = max(0.0, 1.0 - (avg_delta / self.entry_threshold_seconds))
# Overlap factor: more markets in common = higher confidence
markets_common = int(stats["markets_in_common"])
overlap_factor = min(1.0, markets_common / 5.0)
# Weighted combination
confidence = 0.3 * size_factor + 0.4 * speed_factor + 0.3 * overlap_factor
return round(min(1.0, confidence), 3)
def is_sniper(self, wallet_address: str) -> bool:
"""Check if a wallet is in any known sniper cluster.
Args:
wallet_address: Wallet address to check.
Returns:
True if wallet is a known sniper.
"""
return wallet_address.lower() in self._wallet_cluster_map
def get_cluster_for_wallet(self, wallet_address: str) -> ClusterInfo | None:
"""Get cluster info for a wallet if it belongs to one.
Args:
wallet_address: Wallet address to look up.
Returns:
ClusterInfo if wallet is in a cluster, None otherwise.
"""
cluster_id = self._wallet_cluster_map.get(wallet_address.lower())
if cluster_id:
return self._known_clusters.get(cluster_id)
return None
def get_entry_count(self) -> int:
"""Return the total number of tracked entries."""
return len(self._entries)
def get_wallet_count(self) -> int:
"""Return the number of unique wallets tracked."""
return len(self._wallet_entries)
def get_cluster_count(self) -> int:
"""Return the number of detected clusters."""
return len(self._known_clusters)
def clear_entries(self) -> None:
"""Clear all tracked entries (for periodic cleanup)."""
self._entries.clear()
self._wallet_entries.clear()
self._market_wallets.clear()
logger.info("Cleared all sniper detector entries")