Compare commits

...

20 Commits

Author SHA1 Message Date
Patrick Selamy d854020322 feat: add Docker Compose development stack (#24)
- Add docker-compose.yml with PostgreSQL 15 and Redis 7
- Add health checks for both services
- Add optional dev tools (Adminer, RedisInsight) via 'tools' profile
- Create .env.example with all configuration variables
- Update README.md with Docker setup documentation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:03:08 -05:00
Patrick Selamy 131dfed3b4 Merge pull request #45 from pselamy/fix/16
feat: implement SniperDetector with DBSCAN clustering (#16)
2026-01-04 17:00:18 -05:00
Patrick Selamy 96e471882a feat: implement SniperDetector with DBSCAN clustering (#16)
Add sniper cluster detection system that identifies wallets exhibiting
coordinated "sniper" behavior - consistently entering markets within
minutes of their creation.

Key features:
- SniperDetector class using DBSCAN clustering algorithm
- Tracks wallet entries across markets with timing analysis
- Feature vector: market hash, entry delta, log position size
- Identifies clusters of wallets with similar timing patterns
- Generates SniperClusterSignal for detected cluster members
- Configurable entry threshold (default 5 minutes), cluster size, DBSCAN params
- Confidence scoring based on cluster size, entry speed, market overlap

Also adds:
- SniperClusterSignal model to detector/models.py
- scikit-learn and numpy dependencies for ML clustering
- 27 comprehensive tests covering clustering logic and edge cases

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:59:53 -05:00
Patrick Selamy c100bbfdfb Merge pull request #44 from pselamy/fix/10
feat: implement FundingTracer for wallet funding source analysis (#10)
2026-01-04 16:52:24 -05:00
Patrick Selamy b9c0d8304f feat: implement FundingTracer for wallet funding source analysis (#10)
Add FundingTracer class that traces USDC transfers backwards from a target
wallet to identify funding sources. Key features:

- Traces funding chain up to configurable max hops (default 3)
- Identifies terminal entities (CEX hot wallets, bridges) using EntityRegistry
- Parses ERC20 Transfer event logs from Polygon blockchain
- Calculates suspiciousness scores based on funding patterns
- Supports batch tracing multiple addresses concurrently

Also adds FundingTransfer and FundingChain dataclasses to models.py
for representing funding chain data.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:51:57 -05:00
Patrick Selamy c68a6cc32d Merge pull request #43 from pselamy/fix/12
feat: add known entity registry for CEX and bridge detection (#12)
2026-01-04 16:43:29 -05:00
Patrick Selamy 0c4d2bebea feat: add known entity registry for CEX and bridge detection (#12)
- Add EntityType enum for classifying blockchain entities
- Add entity_data.py with Polygon CEX hot wallets, bridges, DEX contracts
- Add EntityRegistry class with classify/is_terminal methods
- Support custom entity additions and overrides
- Include 49 comprehensive unit tests

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:43:03 -05:00
Patrick Selamy 4111d1ee00 Merge pull request #42 from pselamy/fix/11
feat: add wallet profile database schema and repositories (#11)
2026-01-04 16:38:10 -05:00
Patrick Selamy 0003d228e5 feat: add wallet profile database schema and repositories (#11)
- Add SQLAlchemy models for wallet profiles, funding transfers, and relationships
- Add WalletRepository, FundingRepository, RelationshipRepository with async support
- Add DatabaseManager for connection and session management
- Add Alembic migration for initial schema
- Include comprehensive test suite with 21 tests using in-memory SQLite

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:37:40 -05:00
Patrick Selamy 656bd6469c Merge pull request #41 from pselamy/fix/22
feat: add alert history storage and deduplication (#22)
2026-01-04 16:30:02 -05:00
Patrick Selamy 87f51a9c32 feat: add alert history storage and deduplication (#22)
- Add AlertHistory class for Redis-based alert tracking
- Add AlertRecord dataclass for serialization/deserialization
- Implement deduplication by wallet/market/hour combination
- Add time-based indexes for efficient querying
- Support user feedback tracking on alerts
- Include cleanup of old alerts beyond retention period

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:29:19 -05:00
Patrick Selamy 6f60e2fb6d Merge pull request #40 from pselamy/fix/21
feat: add Discord and Telegram webhook dispatcher
2026-01-04 16:22:27 -05:00
Patrick Selamy b0314fee57 feat: add Discord and Telegram webhook dispatcher (#21)
Implements AlertDispatcher with multi-channel delivery support:
- DiscordChannel using webhook URL with embed formatting
- TelegramChannel using Bot API with MarkdownV2
- Rate limiting per channel (configurable)
- Circuit breaker pattern for failing channels
- Async delivery with retry and exponential backoff
- 23 comprehensive tests

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:22:01 -05:00
Patrick Selamy f91dbb6b6d Merge pull request #39 from pselamy/fix/20
feat: add alert message formatter with multi-channel support
2026-01-04 16:16:23 -05:00
Patrick Selamy ccae51c466 feat: add alert message formatter with multi-channel support (#20)
Implements AlertFormatter class that transforms RiskAssessment objects
into formatted messages for Discord (embeds), Telegram (markdown), and
plain text channels. Includes FormattedAlert dataclass, helper functions
for address truncation and risk level display, and comprehensive tests.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:15:49 -05:00
Patrick Selamy 4acc7916dd Merge pull request #38 from pselamy/fix/18
feat(detector): add composite risk scorer for signal aggregation (#18)
2026-01-04 16:07:16 -05:00
Patrick Selamy 4981277eef feat(detector): add composite risk scorer for signal aggregation (#18)
Implement RiskScorer that combines signals from multiple detectors into
a unified risk assessment with weighted scoring and deduplication.

Features:
- SignalBundle for collecting signals for a single trade
- RiskAssessment dataclass with complete scoring metadata
- Configurable weights for each signal type
- Multi-signal bonus (1.2x for 2 signals, 1.3x for 3+)
- Redis-based deduplication (1 hour window by default)
- Alert threshold configuration (default: 0.6)
- Batch assessment for processing multiple trades
- A/B testing support via dynamic weight updates

Default weights:
- fresh_wallet: 0.40
- size_anomaly: 0.35
- niche_market: 0.25

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:06:47 -05:00
Patrick Selamy d5e04593f1 Merge pull request #37 from pselamy/fix/15
feat(detector): add position size anomaly detection (#15)
2026-01-04 15:59:14 -05:00
Patrick Selamy a0bec521b6 feat(detector): add position size anomaly detection (#15)
Implement SizeAnomalyDetector for identifying trades with unusually
large position sizes relative to market liquidity.

Features:
- Volume impact analysis (trade size / 24h volume)
- Order book impact analysis (trade size / book depth)
- Niche market detection using category heuristics
- Confidence scoring with configurable thresholds
- Batch analysis for processing multiple trades

The detector gracefully handles missing volume/book data by falling
back to category-based heuristics for identifying niche markets
where large trades are more significant.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 15:58:27 -05:00
Patrick Selamy 89bf80e3d8 Merge pull request #36 from pselamy/fix/14
feat: implement fresh wallet detection algorithm (#14)
2026-01-04 15:51:03 -05:00
42 changed files with 10487 additions and 6 deletions
+30
View File
@@ -0,0 +1,30 @@
# PostgreSQL Configuration
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=polymarket_tracker
POSTGRES_USER=tracker
POSTGRES_PASSWORD=dev_password
# Constructed database URL (for application use)
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
# Redis Configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT}
# Optional: Development tool ports
ADMINER_PORT=8080
REDIS_INSIGHT_PORT=5540
# Polygon RPC Configuration
POLYGON_RPC_URL=https://polygon-rpc.com
POLYGON_FALLBACK_RPC_URL=https://polygon-bor.publicnode.com
# Polymarket API Configuration
POLYMARKET_WS_URL=wss://ws-subscriptions-clob.polymarket.com/ws/market
# Alert Webhooks (optional)
DISCORD_WEBHOOK_URL=
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
+35 -1
View File
@@ -120,15 +120,49 @@ cp .env.example .env
# Edit .env with your API keys
# Start infrastructure (PostgreSQL, Redis)
docker-compose up -d
docker compose up -d
# Wait for services to be healthy
docker compose ps
# Install Python dependencies
pip install -e .
# Run database migrations
alembic upgrade head
# Run the tracker
python -m src.main
```
### Docker Services
The development stack includes:
| Service | Port | Description |
|---------|------|-------------|
| PostgreSQL 15 | 5432 | Primary database |
| Redis 7 | 6379 | Caching and pub/sub |
| Adminer | 8080 | Database admin UI (optional) |
| RedisInsight | 5540 | Redis admin UI (optional) |
```bash
# Start core services only
docker compose up -d
# Start with development tools (Adminer, RedisInsight)
docker compose --profile tools up -d
# View logs
docker compose logs -f
# Stop all services
docker compose down
# Stop and remove volumes (reset data)
docker compose down -v
```
### Configuration
```bash
+79
View File
@@ -0,0 +1,79 @@
# Alembic Configuration File
[alembic]
# Path to migration scripts
script_location = alembic
# Template used to generate migration files
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(slug)s
# Prepend timestamp to migration file names
prepend_date = True
# Timezone to use when rendering the date within the migration file
# as well as the filename. Use UTC for consistency.
timezone = UTC
# Max length of characters to apply to the "slug" field
truncate_slug_length = 40
# Set to 'true' to run the environment during the 'revision' command
revision_environment = false
# Set to 'true' to allow .pyc and .pyo files without a source .py file
sourceless = false
# Version location specification
version_locations = %(here)s/alembic/versions
# Version path separator
version_path_separator = os
# Database URL - override with SQLALCHEMY_DATABASE_URL environment variable
sqlalchemy.url = postgresql://localhost/polymarket_tracker
[post_write_hooks]
# Post write hooks define scripts to run after generating new revision files
# Format using "black" - only if available
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -q
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+71
View File
@@ -0,0 +1,71 @@
"""Alembic migration environment configuration."""
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from polymarket_insider_tracker.storage.models import Base
# this is the Alembic Config object
config = context.config
# Interpret the config file for Python logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Target metadata for 'autogenerate' support
target_metadata = Base.metadata
# Get database URL from environment variable or config
database_url = os.environ.get("SQLALCHEMY_DATABASE_URL")
if database_url:
config.set_main_option("sqlalchemy.url", database_url)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL and not an Engine,
though an Engine is acceptable here as well. By skipping the Engine
creation we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine and associate a
connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,87 @@
"""Initial schema for wallet profiles and funding transfers.
Revision ID: 001_initial
Revises:
Create Date: 2026-01-04 00:00:00.000000+00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Wallet profiles table
op.create_table(
"wallet_profiles",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("address", sa.String(42), nullable=False),
sa.Column("nonce", sa.Integer(), nullable=False),
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("is_fresh", sa.Boolean(), nullable=False),
sa.Column("matic_balance", sa.Numeric(30, 0), nullable=True),
sa.Column("usdc_balance", sa.Numeric(20, 6), nullable=True),
sa.Column("analyzed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("address"),
)
op.create_index("idx_wallet_profiles_address", "wallet_profiles", ["address"])
# Funding transfers table
op.create_table(
"funding_transfers",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("from_address", sa.String(42), nullable=False),
sa.Column("to_address", sa.String(42), nullable=False),
sa.Column("amount", sa.Numeric(30, 6), nullable=False),
sa.Column("token", sa.String(10), nullable=False),
sa.Column("tx_hash", sa.String(66), nullable=False),
sa.Column("block_number", sa.Integer(), nullable=False),
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tx_hash"),
)
op.create_index("idx_funding_transfers_to", "funding_transfers", ["to_address"])
op.create_index("idx_funding_transfers_from", "funding_transfers", ["from_address"])
op.create_index("idx_funding_transfers_block", "funding_transfers", ["block_number"])
# Wallet relationships table
op.create_table(
"wallet_relationships",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("wallet_a", sa.String(42), nullable=False),
sa.Column("wallet_b", sa.String(42), nullable=False),
sa.Column("relationship_type", sa.String(20), nullable=False),
sa.Column("confidence", sa.Numeric(3, 2), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"wallet_a", "wallet_b", "relationship_type", name="uq_wallet_relationship"
),
)
op.create_index("idx_wallet_relationships_a", "wallet_relationships", ["wallet_a"])
op.create_index("idx_wallet_relationships_b", "wallet_relationships", ["wallet_b"])
def downgrade() -> None:
op.drop_index("idx_wallet_relationships_b", table_name="wallet_relationships")
op.drop_index("idx_wallet_relationships_a", table_name="wallet_relationships")
op.drop_table("wallet_relationships")
op.drop_index("idx_funding_transfers_block", table_name="funding_transfers")
op.drop_index("idx_funding_transfers_from", table_name="funding_transfers")
op.drop_index("idx_funding_transfers_to", table_name="funding_transfers")
op.drop_table("funding_transfers")
op.drop_index("idx_wallet_profiles_address", table_name="wallet_profiles")
op.drop_table("wallet_profiles")
+72
View File
@@ -0,0 +1,72 @@
version: "3.8"
services:
postgres:
image: postgres:15
container_name: polymarket-postgres
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${POSTGRES_DB:-polymarket_tracker}
POSTGRES_USER: ${POSTGRES_USER:-tracker}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev_password}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tracker} -d ${POSTGRES_DB:-polymarket_tracker}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
restart: unless-stopped
redis:
image: redis:7
container_name: polymarket-redis
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
restart: unless-stopped
# Optional development tools - use with: docker compose --profile tools up
adminer:
image: adminer:latest
container_name: polymarket-adminer
ports:
- "${ADMINER_PORT:-8080}:8080"
depends_on:
postgres:
condition: service_healthy
profiles:
- tools
restart: unless-stopped
redis-insight:
image: redis/redisinsight:latest
container_name: polymarket-redis-insight
ports:
- "${REDIS_INSIGHT_PORT:-5540}:5540"
volumes:
- redis_insight_data:/data
depends_on:
redis:
condition: service_healthy
profiles:
- tools
restart: unless-stopped
volumes:
postgres_data:
driver: local
redis_data:
driver: local
redis_insight_data:
driver: local
+2
View File
@@ -16,6 +16,8 @@ dependencies = [
"websockets>=12.0",
"prometheus-client>=0.19.0",
"aiohttp>=3.9.0",
"scikit-learn>=1.3.0",
"numpy>=1.24.0",
]
[project.optional-dependencies]
@@ -1 +1,26 @@
"""Alerting layer - Real-time notification delivery."""
from polymarket_insider_tracker.alerter.channels.discord import DiscordChannel
from polymarket_insider_tracker.alerter.channels.telegram import TelegramChannel
from polymarket_insider_tracker.alerter.dispatcher import (
AlertChannel,
AlertDispatcher,
CircuitBreakerState,
DispatchResult,
)
from polymarket_insider_tracker.alerter.formatter import AlertFormatter
from polymarket_insider_tracker.alerter.history import AlertHistory, AlertRecord
from polymarket_insider_tracker.alerter.models import FormattedAlert
__all__ = [
"AlertChannel",
"AlertDispatcher",
"AlertFormatter",
"AlertHistory",
"AlertRecord",
"CircuitBreakerState",
"DiscordChannel",
"DispatchResult",
"FormattedAlert",
"TelegramChannel",
]
@@ -0,0 +1,9 @@
"""Alert channel implementations for various platforms."""
from polymarket_insider_tracker.alerter.channels.discord import DiscordChannel
from polymarket_insider_tracker.alerter.channels.telegram import TelegramChannel
__all__ = [
"DiscordChannel",
"TelegramChannel",
]
@@ -0,0 +1,118 @@
"""Discord webhook channel implementation."""
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING
import httpx
if TYPE_CHECKING:
from polymarket_insider_tracker.alerter.models import FormattedAlert
logger = logging.getLogger(__name__)
class DiscordChannel:
"""Discord webhook channel for sending alerts.
Sends formatted alerts to Discord via webhook URL with rate limiting
and retry support.
"""
def __init__(
self,
webhook_url: str,
*,
rate_limit_per_minute: int = 30,
max_retries: int = 3,
retry_delay: float = 1.0,
timeout: float = 10.0,
) -> None:
"""Initialize Discord channel.
Args:
webhook_url: Discord webhook URL.
rate_limit_per_minute: Maximum messages per minute (Discord limit is 30).
max_retries: Maximum retry attempts on failure.
retry_delay: Base delay between retries (exponential backoff).
timeout: HTTP request timeout in seconds.
"""
self.webhook_url = webhook_url
self.rate_limit_per_minute = rate_limit_per_minute
self.max_retries = max_retries
self.retry_delay = retry_delay
self.timeout = timeout
self.name = "discord"
# Rate limiting state
self._request_times: list[float] = []
self._lock = asyncio.Lock()
async def _wait_for_rate_limit(self) -> None:
"""Wait if rate limit is exceeded."""
async with self._lock:
now = asyncio.get_event_loop().time()
# Remove requests older than 1 minute
self._request_times = [t for t in self._request_times if now - t < 60]
if len(self._request_times) >= self.rate_limit_per_minute:
# Wait until the oldest request expires
wait_time = 60 - (now - self._request_times[0])
if wait_time > 0:
logger.debug(f"Discord rate limit hit, waiting {wait_time:.2f}s")
await asyncio.sleep(wait_time)
self._request_times.append(now)
async def send(self, alert: FormattedAlert) -> bool:
"""Send alert to Discord webhook.
Args:
alert: Formatted alert with discord_embed.
Returns:
True if delivery succeeded, False otherwise.
"""
await self._wait_for_rate_limit()
payload = {
"embeds": [alert.discord_embed],
}
for attempt in range(self.max_retries):
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
self.webhook_url,
json=payload,
)
if response.status_code == 204:
logger.info("Discord alert delivered successfully")
return True
if response.status_code == 429:
# Rate limited by Discord
retry_after = response.json().get("retry_after", 1.0)
logger.warning(f"Discord rate limited, retry after {retry_after}s")
await asyncio.sleep(retry_after)
continue
logger.error(
f"Discord webhook failed: {response.status_code} {response.text}"
)
except httpx.TimeoutException:
logger.warning(f"Discord webhook timeout (attempt {attempt + 1})")
except httpx.HTTPError as e:
logger.error(f"Discord webhook error: {e}")
# Exponential backoff
if attempt < self.max_retries - 1:
delay = self.retry_delay * (2**attempt)
await asyncio.sleep(delay)
logger.error("Discord delivery failed after all retries")
return False
@@ -0,0 +1,131 @@
"""Telegram Bot API channel implementation."""
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING
import httpx
if TYPE_CHECKING:
from polymarket_insider_tracker.alerter.models import FormattedAlert
logger = logging.getLogger(__name__)
TELEGRAM_API_BASE = "https://api.telegram.org/bot{token}/sendMessage"
class TelegramChannel:
"""Telegram Bot API channel for sending alerts.
Sends formatted alerts to Telegram via Bot API with rate limiting
and retry support.
"""
def __init__(
self,
bot_token: str,
chat_id: str,
*,
rate_limit_per_minute: int = 20,
max_retries: int = 3,
retry_delay: float = 1.0,
timeout: float = 10.0,
) -> None:
"""Initialize Telegram channel.
Args:
bot_token: Telegram bot token.
chat_id: Target chat/channel ID.
rate_limit_per_minute: Maximum messages per minute.
max_retries: Maximum retry attempts on failure.
retry_delay: Base delay between retries (exponential backoff).
timeout: HTTP request timeout in seconds.
"""
self.bot_token = bot_token
self.chat_id = chat_id
self.rate_limit_per_minute = rate_limit_per_minute
self.max_retries = max_retries
self.retry_delay = retry_delay
self.timeout = timeout
self.name = "telegram"
self._api_url = TELEGRAM_API_BASE.format(token=bot_token)
# Rate limiting state
self._request_times: list[float] = []
self._lock = asyncio.Lock()
async def _wait_for_rate_limit(self) -> None:
"""Wait if rate limit is exceeded."""
async with self._lock:
now = asyncio.get_event_loop().time()
# Remove requests older than 1 minute
self._request_times = [t for t in self._request_times if now - t < 60]
if len(self._request_times) >= self.rate_limit_per_minute:
# Wait until the oldest request expires
wait_time = 60 - (now - self._request_times[0])
if wait_time > 0:
logger.debug(f"Telegram rate limit hit, waiting {wait_time:.2f}s")
await asyncio.sleep(wait_time)
self._request_times.append(now)
async def send(self, alert: FormattedAlert) -> bool:
"""Send alert to Telegram channel.
Args:
alert: Formatted alert with telegram_markdown.
Returns:
True if delivery succeeded, False otherwise.
"""
await self._wait_for_rate_limit()
payload = {
"chat_id": self.chat_id,
"text": alert.telegram_markdown,
"parse_mode": "MarkdownV2",
"disable_web_page_preview": False,
}
for attempt in range(self.max_retries):
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
self._api_url,
json=payload,
)
result = response.json()
if result.get("ok"):
logger.info("Telegram alert delivered successfully")
return True
error_code = result.get("error_code", 0)
description = result.get("description", "Unknown error")
if error_code == 429:
# Rate limited
retry_after = result.get("parameters", {}).get("retry_after", 1)
logger.warning(f"Telegram rate limited, retry after {retry_after}s")
await asyncio.sleep(retry_after)
continue
logger.error(f"Telegram API error: {error_code} - {description}")
except httpx.TimeoutException:
logger.warning(f"Telegram API timeout (attempt {attempt + 1})")
except httpx.HTTPError as e:
logger.error(f"Telegram API error: {e}")
# Exponential backoff
if attempt < self.max_retries - 1:
delay = self.retry_delay * (2**attempt)
await asyncio.sleep(delay)
logger.error("Telegram delivery failed after all retries")
return False
@@ -0,0 +1,239 @@
"""Alert dispatcher for multi-channel delivery."""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING:
from polymarket_insider_tracker.alerter.models import FormattedAlert
logger = logging.getLogger(__name__)
class AlertChannel(Protocol):
"""Protocol for alert delivery channels."""
name: str
async def send(self, alert: FormattedAlert) -> bool:
"""Send alert to channel. Returns True on success."""
...
@dataclass
class CircuitBreakerState:
"""State for circuit breaker pattern.
Tracks failures and manages open/closed state for a channel.
"""
failure_count: int = 0
last_failure_time: datetime | None = None
is_open: bool = False
half_open_attempts: int = 0
@dataclass
class DispatchResult:
"""Result of dispatching an alert to all channels."""
success_count: int
failure_count: int
channel_results: dict[str, bool] = field(default_factory=dict)
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
@property
def all_succeeded(self) -> bool:
"""Return True if all channels succeeded."""
return self.failure_count == 0 and self.success_count > 0
class AlertDispatcher:
"""Dispatcher for sending alerts to multiple channels.
Manages concurrent delivery to all configured channels with
circuit breaker protection for failing channels.
"""
def __init__(
self,
channels: list[AlertChannel],
*,
failure_threshold: int = 5,
recovery_timeout_seconds: int = 60,
half_open_max_attempts: int = 3,
) -> None:
"""Initialize the dispatcher.
Args:
channels: List of alert channels to dispatch to.
failure_threshold: Number of consecutive failures before opening circuit.
recovery_timeout_seconds: Time to wait before half-opening circuit.
half_open_max_attempts: Number of test attempts in half-open state.
"""
self.channels = channels
self.failure_threshold = failure_threshold
self.recovery_timeout_seconds = recovery_timeout_seconds
self.half_open_max_attempts = half_open_max_attempts
# Circuit breaker state per channel
self._circuit_state: dict[str, CircuitBreakerState] = {
ch.name: CircuitBreakerState() for ch in channels
}
def _should_attempt(self, channel_name: str) -> bool:
"""Check if we should attempt delivery to this channel."""
state = self._circuit_state[channel_name]
if not state.is_open:
return True
# Check if we should try half-open
if state.last_failure_time:
elapsed = (datetime.now(UTC) - state.last_failure_time).total_seconds()
if (
elapsed >= self.recovery_timeout_seconds
and state.half_open_attempts < self.half_open_max_attempts
):
# Allow half-open attempt
logger.info(
f"Circuit half-open for {channel_name}, "
f"attempt {state.half_open_attempts + 1}"
)
return True
return False
def _record_success(self, channel_name: str) -> None:
"""Record a successful delivery."""
state = self._circuit_state[channel_name]
state.failure_count = 0
state.is_open = False
state.half_open_attempts = 0
state.last_failure_time = None
logger.debug(f"Circuit closed for {channel_name}")
def _record_failure(self, channel_name: str) -> None:
"""Record a failed delivery."""
state = self._circuit_state[channel_name]
state.failure_count += 1
state.last_failure_time = datetime.now(UTC)
if state.is_open:
# Failed during half-open, increment attempts
state.half_open_attempts += 1
elif state.failure_count >= self.failure_threshold:
# Open the circuit
state.is_open = True
logger.warning(
f"Circuit opened for {channel_name} after "
f"{state.failure_count} failures"
)
async def _send_to_channel(
self, channel: AlertChannel, alert: FormattedAlert
) -> tuple[str, bool]:
"""Send alert to a single channel with circuit breaker."""
channel_name = channel.name
if not self._should_attempt(channel_name):
logger.debug(f"Skipping {channel_name} - circuit open")
return (channel_name, False)
try:
success = await channel.send(alert)
if success:
self._record_success(channel_name)
else:
self._record_failure(channel_name)
return (channel_name, success)
except Exception as e:
logger.error(f"Error sending to {channel_name}: {e}")
self._record_failure(channel_name)
return (channel_name, False)
async def dispatch(self, alert: FormattedAlert) -> DispatchResult:
"""Dispatch alert to all channels concurrently.
Args:
alert: Formatted alert to send.
Returns:
DispatchResult with per-channel status.
"""
if not self.channels:
logger.warning("No channels configured for dispatch")
return DispatchResult(success_count=0, failure_count=0)
# Send to all channels concurrently
tasks = [self._send_to_channel(ch, alert) for ch in self.channels]
results = await asyncio.gather(*tasks)
# Aggregate results
channel_results = dict(results)
success_count = sum(1 for success in channel_results.values() if success)
failure_count = len(channel_results) - success_count
result = DispatchResult(
success_count=success_count,
failure_count=failure_count,
channel_results=channel_results,
)
logger.info(
f"Dispatch complete: {success_count}/{len(channel_results)} succeeded"
)
return result
async def dispatch_batch(
self, alerts: list[FormattedAlert]
) -> list[DispatchResult]:
"""Dispatch multiple alerts sequentially.
Args:
alerts: List of formatted alerts to send.
Returns:
List of DispatchResult for each alert.
"""
results = []
for alert in alerts:
result = await self.dispatch(alert)
results.append(result)
return results
def get_circuit_status(self) -> dict[str, dict[str, object]]:
"""Get current circuit breaker status for all channels."""
return {
name: {
"is_open": state.is_open,
"failure_count": state.failure_count,
"half_open_attempts": state.half_open_attempts,
"last_failure": (
state.last_failure_time.isoformat()
if state.last_failure_time
else None
),
}
for name, state in self._circuit_state.items()
}
def reset_circuit(self, channel_name: str) -> bool:
"""Manually reset circuit breaker for a channel.
Args:
channel_name: Name of channel to reset.
Returns:
True if channel was found and reset.
"""
if channel_name in self._circuit_state:
self._circuit_state[channel_name] = CircuitBreakerState()
logger.info(f"Circuit reset for {channel_name}")
return True
return False
@@ -0,0 +1,378 @@
"""Alert message formatter for multi-channel delivery.
This module transforms RiskAssessment objects into human-readable,
actionable alert messages optimized for Discord, Telegram, and plain text.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Literal
from polymarket_insider_tracker.alerter.models import FormattedAlert
from polymarket_insider_tracker.detector.models import RiskAssessment
# Polymarket URLs
POLYMARKET_MARKET_URL = "https://polymarket.com/event/{slug}"
POLYGONSCAN_ADDRESS_URL = "https://polygonscan.com/address/{address}"
# Discord embed colors (decimal values)
COLOR_HIGH_RISK = 15158332 # Red (#E74C3C)
COLOR_MEDIUM_RISK = 15105570 # Orange (#E67E22)
COLOR_LOW_RISK = 16776960 # Yellow (#FFFF00)
# Risk level thresholds
HIGH_RISK_THRESHOLD = 0.7
MEDIUM_RISK_THRESHOLD = 0.5
def truncate_address(address: str, chars: int = 4) -> str:
"""Truncate an Ethereum address to 0x1234...5678 format."""
if len(address) < chars * 2 + 4:
return address
return f"{address[:chars+2]}...{address[-chars:]}"
def format_usdc(amount: Decimal) -> str:
"""Format a USDC amount with commas and 2 decimal places."""
return f"${amount:,.2f}"
def get_risk_level(score: float) -> str:
"""Get human-readable risk level from score."""
if score >= HIGH_RISK_THRESHOLD:
return "HIGH"
if score >= MEDIUM_RISK_THRESHOLD:
return "MEDIUM"
return "LOW"
def get_risk_color(score: float) -> int:
"""Get Discord embed color based on risk score."""
if score >= HIGH_RISK_THRESHOLD:
return COLOR_HIGH_RISK
if score >= MEDIUM_RISK_THRESHOLD:
return COLOR_MEDIUM_RISK
return COLOR_LOW_RISK
def get_triggered_signals(assessment: RiskAssessment) -> list[str]:
"""Get list of triggered signal names."""
signals = []
if assessment.fresh_wallet_signal:
signals.append("Fresh Wallet")
if assessment.size_anomaly_signal:
signals.append("Large Position")
if assessment.size_anomaly_signal.is_niche_market:
signals.append("Niche Market")
return signals
class AlertFormatter:
"""Formats RiskAssessments into multi-channel alert messages.
Supports two verbosity levels:
- compact: Essential info only (wallet, score, market)
- detailed: Full context (all signals, links, trade details)
"""
def __init__(
self,
verbosity: Literal["compact", "detailed"] = "detailed",
) -> None:
"""Initialize the formatter.
Args:
verbosity: Level of detail in formatted messages.
"""
self.verbosity = verbosity
def format(self, assessment: RiskAssessment) -> FormattedAlert:
"""Format a risk assessment into a multi-channel alert.
Args:
assessment: The risk assessment to format.
Returns:
FormattedAlert with all channel formats.
"""
# Build common data
wallet_short = truncate_address(assessment.wallet_address)
risk_level = get_risk_level(assessment.weighted_score)
signals = get_triggered_signals(assessment)
# Build links
links = self._build_links(assessment)
# Build title
title = f"🚨 Suspicious Activity Detected - {risk_level} Risk"
# Build body based on verbosity
body = self._build_body(assessment, wallet_short, risk_level, signals)
# Build channel-specific formats
discord_embed = self._build_discord_embed(
assessment, wallet_short, risk_level, signals, links
)
telegram_md = self._build_telegram_markdown(
assessment, wallet_short, risk_level, signals, links
)
plain_text = self._build_plain_text(
assessment, wallet_short, risk_level, signals, links
)
return FormattedAlert(
title=title,
body=body,
discord_embed=discord_embed,
telegram_markdown=telegram_md,
plain_text=plain_text,
links=links,
)
def _build_links(self, assessment: RiskAssessment) -> dict[str, str]:
"""Build dictionary of relevant links."""
trade = assessment.trade_event
links = {
"wallet": POLYGONSCAN_ADDRESS_URL.format(address=assessment.wallet_address),
}
# Add market link if we have the slug
if trade.market_slug:
links["market"] = POLYMARKET_MARKET_URL.format(slug=trade.market_slug)
return links
def _build_body(
self,
assessment: RiskAssessment,
wallet_short: str,
risk_level: str,
signals: list[str],
) -> str:
"""Build the main body text."""
trade = assessment.trade_event
if self.verbosity == "compact":
return (
f"Wallet {wallet_short} made a {trade.side} trade "
f"({format_usdc(trade.notional_value)}) with risk score "
f"{assessment.weighted_score:.2f} ({risk_level})"
)
# Detailed body
lines = [
f"Wallet: {wallet_short}",
f"Risk Score: {assessment.weighted_score:.2f} ({risk_level})",
f"Trade: {trade.side} {trade.outcome} @ ${trade.price:.3f}",
f"Size: {format_usdc(trade.notional_value)}",
]
if signals:
lines.append(f"Signals: {', '.join(signals)}")
if trade.event_title:
lines.append(f"Market: {trade.event_title}")
return "\n".join(lines)
def _build_discord_embed(
self,
assessment: RiskAssessment,
wallet_short: str,
risk_level: str,
signals: list[str],
links: dict[str, str],
) -> dict[str, object]:
"""Build Discord-optimized embed format."""
trade = assessment.trade_event
color = get_risk_color(assessment.weighted_score)
# Get wallet age if available
wallet_age_str = ""
if assessment.fresh_wallet_signal:
age_hours = assessment.fresh_wallet_signal.wallet_profile.age_hours
if age_hours < 1:
wallet_age_str = f" (Age: {int(age_hours * 60)}m)"
else:
wallet_age_str = f" (Age: {age_hours:.0f}h)"
fields: list[dict[str, object]] = [
{
"name": "Wallet",
"value": f"`{wallet_short}`{wallet_age_str}",
"inline": True,
},
{
"name": "Risk Score",
"value": f"{assessment.weighted_score:.2f} ({risk_level})",
"inline": True,
},
]
# Market field
market_title = trade.event_title or trade.market_slug or "Unknown Market"
market_value = market_title
if "market" in links:
market_value = f"[{market_title}]({links['market']})"
fields.append({"name": "Market", "value": market_value, "inline": False})
# Trade details
trade_detail = (
f"{trade.side} {trade.outcome} @ ${trade.price:.3f} | "
f"{format_usdc(trade.notional_value)}"
)
fields.append({"name": "Trade", "value": trade_detail, "inline": False})
# Signals (if any)
if signals:
fields.append({
"name": "Signals",
"value": ", ".join(signals),
"inline": False,
})
# Add detailed info for detailed verbosity
if self.verbosity == "detailed":
# Add confidence breakdown
confidences = []
if assessment.fresh_wallet_signal:
conf = assessment.fresh_wallet_signal.confidence
confidences.append(f"Fresh Wallet: {conf:.0%}")
if assessment.size_anomaly_signal:
conf = assessment.size_anomaly_signal.confidence
confidences.append(f"Size Anomaly: {conf:.0%}")
if confidences:
fields.append({
"name": "Confidence",
"value": " | ".join(confidences),
"inline": False,
})
embed: dict[str, object] = {
"title": "🚨 Suspicious Activity Detected",
"color": color,
"fields": fields,
"footer": {"text": "Polymarket Insider Tracker"},
}
# Add wallet link as URL if available
if "wallet" in links:
embed["url"] = links["wallet"]
return embed
def _build_telegram_markdown(
self,
assessment: RiskAssessment,
wallet_short: str,
risk_level: str,
signals: list[str],
links: dict[str, str],
) -> str:
"""Build Telegram-optimized markdown format."""
trade = assessment.trade_event
lines = ["🚨 *Suspicious Activity Detected*", ""]
# Wallet with link
wallet_line = f"*Wallet:* `{wallet_short}`"
if assessment.fresh_wallet_signal:
age_hours = assessment.fresh_wallet_signal.wallet_profile.age_hours
if age_hours < 1:
wallet_line += f" \\(Age: {int(age_hours * 60)}m\\)"
else:
wallet_line += f" \\(Age: {age_hours:.0f}h\\)"
lines.append(wallet_line)
# Risk score
lines.append(f"*Risk Score:* {assessment.weighted_score:.2f} \\({risk_level}\\)")
# Market
market_title = trade.event_title or trade.market_slug or "Unknown Market"
# Escape special Telegram markdown characters
market_title_escaped = self._escape_telegram_markdown(market_title)
if "market" in links:
lines.append(f"*Market:* [{market_title_escaped}]({links['market']})")
else:
lines.append(f"*Market:* {market_title_escaped}")
# Trade details
usdc_value = format_usdc(trade.notional_value).replace("$", "\\$")
lines.append(
f"*Trade:* {trade.side} {trade.outcome} @ \\${trade.price:.3f} \\| {usdc_value}"
)
# Signals
if signals:
lines.append(f"*Signals:* {', '.join(signals)}")
# Links
lines.append("")
if "wallet" in links:
lines.append(f"[View Wallet]({links['wallet']})")
if "market" in links:
lines.append(f"[View Market]({links['market']})")
return "\n".join(lines)
def _escape_telegram_markdown(self, text: str) -> str:
"""Escape special Telegram MarkdownV2 characters."""
special_chars = ["_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"]
for char in special_chars:
text = text.replace(char, f"\\{char}")
return text
def _build_plain_text(
self,
assessment: RiskAssessment,
wallet_short: str,
risk_level: str,
signals: list[str],
links: dict[str, str],
) -> str:
"""Build plain text format for generic channels."""
trade = assessment.trade_event
lines = [
"SUSPICIOUS ACTIVITY DETECTED",
"=" * 30,
"",
]
# Wallet info
wallet_line = f"Wallet: {wallet_short}"
if assessment.fresh_wallet_signal:
age_hours = assessment.fresh_wallet_signal.wallet_profile.age_hours
if age_hours < 1:
wallet_line += f" (Age: {int(age_hours * 60)}m)"
else:
wallet_line += f" (Age: {age_hours:.0f}h)"
lines.append(wallet_line)
# Risk
lines.append(f"Risk Score: {assessment.weighted_score:.2f} ({risk_level})")
# Market
market_title = trade.event_title or trade.market_slug or "Unknown Market"
lines.append(f"Market: {market_title}")
# Trade
lines.append(
f"Trade: {trade.side} {trade.outcome} @ ${trade.price:.3f} | "
f"{format_usdc(trade.notional_value)}"
)
# Signals
if signals:
lines.append(f"Signals: {', '.join(signals)}")
# Links
lines.append("")
if "wallet" in links:
lines.append(f"Wallet: {links['wallet']}")
if "market" in links:
lines.append(f"Market: {links['market']}")
return "\n".join(lines)
@@ -0,0 +1,398 @@
"""Alert history tracking and deduplication.
This module provides alert history management with deduplication
to prevent spam and enable analytics on alert patterns.
"""
from __future__ import annotations
import json
import logging
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from polymarket_insider_tracker.detector.models import RiskAssessment
logger = logging.getLogger(__name__)
@dataclass
class AlertRecord:
"""Record of a sent alert.
Attributes:
alert_id: Unique identifier for this alert.
wallet_address: Trader's wallet address.
market_id: Market condition ID.
risk_score: Final weighted risk score.
signals_triggered: List of signal names that triggered.
channels_attempted: List of channels we tried to send to.
channels_succeeded: List of channels that succeeded.
dedup_key: Key used for deduplication.
feedback_useful: User feedback on alert usefulness.
created_at: When the alert was sent.
"""
alert_id: str
wallet_address: str
market_id: str
risk_score: float
signals_triggered: list[str]
channels_attempted: list[str]
channels_succeeded: list[str]
dedup_key: str
feedback_useful: bool | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
def to_dict(self) -> dict[str, Any]:
"""Serialize to dictionary for storage."""
return {
"alert_id": self.alert_id,
"wallet_address": self.wallet_address,
"market_id": self.market_id,
"risk_score": self.risk_score,
"signals_triggered": self.signals_triggered,
"channels_attempted": self.channels_attempted,
"channels_succeeded": self.channels_succeeded,
"dedup_key": self.dedup_key,
"feedback_useful": self.feedback_useful,
"created_at": self.created_at.isoformat(),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> AlertRecord:
"""Deserialize from dictionary."""
created_at = data.get("created_at")
if isinstance(created_at, str):
created_at = datetime.fromisoformat(created_at)
elif created_at is None:
created_at = datetime.now(UTC)
return cls(
alert_id=data["alert_id"],
wallet_address=data["wallet_address"],
market_id=data["market_id"],
risk_score=float(data["risk_score"]),
signals_triggered=data.get("signals_triggered", []),
channels_attempted=data.get("channels_attempted", []),
channels_succeeded=data.get("channels_succeeded", []),
dedup_key=data["dedup_key"],
feedback_useful=data.get("feedback_useful"),
created_at=created_at,
)
def _generate_dedup_key(wallet_address: str, market_id: str, hour: datetime) -> str:
"""Generate deduplication key for wallet/market/hour combination."""
hour_str = hour.strftime("%Y%m%d%H")
return f"{wallet_address}:{market_id}:{hour_str}"
def _get_signals_from_assessment(assessment: RiskAssessment) -> list[str]:
"""Extract triggered signal names from assessment."""
signals = []
if assessment.fresh_wallet_signal:
signals.append("fresh_wallet")
if assessment.size_anomaly_signal:
signals.append("size_anomaly")
if assessment.size_anomaly_signal.is_niche_market:
signals.append("niche_market")
return signals
class AlertHistory:
"""Tracks alert history and provides deduplication.
Uses Redis for storage with configurable dedup window.
"""
# Redis key prefixes
KEY_PREFIX_DEDUP = "alert:dedup:"
KEY_PREFIX_ALERT = "alert:record:"
KEY_PREFIX_FEEDBACK = "alert:feedback:"
KEY_INDEX_TIME = "alert:index:time"
KEY_INDEX_WALLET = "alert:index:wallet:"
KEY_INDEX_MARKET = "alert:index:market:"
def __init__(
self,
redis: Any,
*,
dedup_window_hours: int = 1,
retention_days: int = 30,
) -> None:
"""Initialize alert history.
Args:
redis: Redis client (async).
dedup_window_hours: Hours to deduplicate alerts for same wallet/market.
retention_days: Days to retain alert history.
"""
self.redis = redis
self.dedup_window_hours = dedup_window_hours
self.retention_days = retention_days
self._dedup_ttl = dedup_window_hours * 3600
self._retention_ttl = retention_days * 86400
def _get_dedup_key(self, assessment: RiskAssessment) -> str:
"""Get deduplication key for an assessment."""
now = datetime.now(UTC)
return _generate_dedup_key(
assessment.wallet_address,
assessment.market_id,
now,
)
async def should_send(self, assessment: RiskAssessment) -> bool:
"""Check if alert should be sent (not a duplicate).
Args:
assessment: Risk assessment to check.
Returns:
True if alert should be sent, False if duplicate.
"""
dedup_key = self._get_dedup_key(assessment)
redis_key = f"{self.KEY_PREFIX_DEDUP}{dedup_key}"
# Check if key exists
exists = await self.redis.exists(redis_key)
if exists:
logger.debug(f"Duplicate alert for {dedup_key}")
return False
return True
async def record_sent(
self,
assessment: RiskAssessment,
channels_attempted: list[str],
channels_succeeded: dict[str, bool],
) -> str:
"""Record that an alert was sent.
Args:
assessment: The risk assessment that was alerted.
channels_attempted: List of channels we tried to send to.
channels_succeeded: Dict of channel name -> success status.
Returns:
The alert_id for this record.
"""
alert_id = str(uuid.uuid4())
dedup_key = self._get_dedup_key(assessment)
now = datetime.now(UTC)
# Create record
record = AlertRecord(
alert_id=alert_id,
wallet_address=assessment.wallet_address,
market_id=assessment.market_id,
risk_score=assessment.weighted_score,
signals_triggered=_get_signals_from_assessment(assessment),
channels_attempted=channels_attempted,
channels_succeeded=[ch for ch, success in channels_succeeded.items() if success],
dedup_key=dedup_key,
created_at=now,
)
# Store in Redis with pipeline
async with self.redis.pipeline() as pipe:
# Store dedup key with TTL
dedup_redis_key = f"{self.KEY_PREFIX_DEDUP}{dedup_key}"
pipe.set(dedup_redis_key, "1", ex=self._dedup_ttl)
# Store alert record
alert_redis_key = f"{self.KEY_PREFIX_ALERT}{alert_id}"
pipe.set(
alert_redis_key,
json.dumps(record.to_dict()),
ex=self._retention_ttl,
)
# Add to time index (sorted set with timestamp as score)
timestamp_score = now.timestamp()
pipe.zadd(self.KEY_INDEX_TIME, {alert_id: timestamp_score})
# Add to wallet index
wallet_index_key = f"{self.KEY_INDEX_WALLET}{assessment.wallet_address}"
pipe.zadd(wallet_index_key, {alert_id: timestamp_score})
pipe.expire(wallet_index_key, self._retention_ttl)
# Add to market index
market_index_key = f"{self.KEY_INDEX_MARKET}{assessment.market_id}"
pipe.zadd(market_index_key, {alert_id: timestamp_score})
pipe.expire(market_index_key, self._retention_ttl)
await pipe.execute()
logger.info(f"Recorded alert {alert_id} for {assessment.wallet_address}")
return alert_id
async def record_feedback(self, alert_id: str, useful: bool) -> bool:
"""Record user feedback on alert usefulness.
Args:
alert_id: The alert to provide feedback on.
useful: Whether the alert was useful.
Returns:
True if feedback was recorded, False if alert not found.
"""
alert_redis_key = f"{self.KEY_PREFIX_ALERT}{alert_id}"
# Get existing record
data = await self.redis.get(alert_redis_key)
if not data:
logger.warning(f"Alert {alert_id} not found for feedback")
return False
# Update record
record_dict = json.loads(data)
record_dict["feedback_useful"] = useful
# Get remaining TTL
ttl = await self.redis.ttl(alert_redis_key)
if ttl < 0:
ttl = self._retention_ttl
# Store updated record
await self.redis.set(alert_redis_key, json.dumps(record_dict), ex=ttl)
logger.info(f"Recorded feedback for alert {alert_id}: useful={useful}")
return True
async def get_alert(self, alert_id: str) -> AlertRecord | None:
"""Get a specific alert record.
Args:
alert_id: The alert ID to retrieve.
Returns:
AlertRecord if found, None otherwise.
"""
alert_redis_key = f"{self.KEY_PREFIX_ALERT}{alert_id}"
data = await self.redis.get(alert_redis_key)
if not data:
return None
return AlertRecord.from_dict(json.loads(data))
async def get_alerts(
self,
start: datetime,
end: datetime,
wallet: str | None = None,
market: str | None = None,
limit: int = 100,
) -> list[AlertRecord]:
"""Query alert history.
Args:
start: Start of time range.
end: End of time range.
wallet: Optional wallet address filter.
market: Optional market ID filter.
limit: Maximum number of results.
Returns:
List of matching AlertRecord objects.
"""
start_score = start.timestamp()
end_score = end.timestamp()
# Determine which index to use
if wallet:
index_key = f"{self.KEY_INDEX_WALLET}{wallet}"
elif market:
index_key = f"{self.KEY_INDEX_MARKET}{market}"
else:
index_key = self.KEY_INDEX_TIME
# Get alert IDs from index
alert_ids = await self.redis.zrangebyscore(
index_key,
start_score,
end_score,
start=0,
num=limit,
)
if not alert_ids:
return []
# Fetch all records
records = []
for alert_id in alert_ids:
if isinstance(alert_id, bytes):
alert_id = alert_id.decode()
record = await self.get_alert(alert_id)
if record:
# Apply additional filters if needed
if wallet and record.wallet_address != wallet:
continue
if market and record.market_id != market:
continue
records.append(record)
return records
async def get_recent_count(
self,
hours: int = 24,
wallet: str | None = None,
) -> int:
"""Get count of alerts in recent hours.
Args:
hours: Number of hours to look back.
wallet: Optional wallet address filter.
Returns:
Number of alerts in time period.
"""
end = datetime.now(UTC)
start = end - timedelta(hours=hours)
index_key = (
f"{self.KEY_INDEX_WALLET}{wallet}" if wallet else self.KEY_INDEX_TIME
)
count = await self.redis.zcount(
index_key,
start.timestamp(),
end.timestamp(),
)
return count
async def cleanup_old_alerts(self) -> int:
"""Remove alerts older than retention period.
Returns:
Number of alerts removed.
"""
cutoff = datetime.now(UTC) - timedelta(days=self.retention_days)
cutoff_score = cutoff.timestamp()
# Get old alert IDs
old_ids = await self.redis.zrangebyscore(
self.KEY_INDEX_TIME,
"-inf",
cutoff_score,
)
if not old_ids:
return 0
# Remove from time index
removed = await self.redis.zremrangebyscore(
self.KEY_INDEX_TIME,
"-inf",
cutoff_score,
)
# Note: Individual alert records will expire via TTL
# Wallet/market indexes will also expire via TTL
logger.info(f"Cleaned up {removed} old alert references")
return removed
@@ -0,0 +1,26 @@
"""Data models for the alerter module."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class FormattedAlert:
"""A formatted alert message ready for delivery across multiple channels.
Attributes:
title: Short alert title/headline.
body: Main alert body text.
discord_embed: Discord-optimized embed dictionary.
telegram_markdown: Telegram-formatted markdown string.
plain_text: Plain text fallback for other channels.
links: Dictionary of relevant links (e.g., market, wallet explorer).
"""
title: str
body: str
discord_embed: dict[str, object]
telegram_markdown: str
plain_text: str
links: dict[str, str] = field(default_factory=dict)
@@ -1,6 +1,24 @@
"""Anomaly detection layer - Suspicious activity identification."""
from polymarket_insider_tracker.detector.fresh_wallet import FreshWalletDetector
from polymarket_insider_tracker.detector.models import FreshWalletSignal
from polymarket_insider_tracker.detector.models import (
FreshWalletSignal,
RiskAssessment,
SizeAnomalySignal,
SniperClusterSignal,
)
from polymarket_insider_tracker.detector.scorer import RiskScorer, SignalBundle
from polymarket_insider_tracker.detector.size_anomaly import SizeAnomalyDetector
from polymarket_insider_tracker.detector.sniper import SniperDetector
__all__ = ["FreshWalletDetector", "FreshWalletSignal"]
__all__ = [
"FreshWalletDetector",
"FreshWalletSignal",
"RiskAssessment",
"RiskScorer",
"SignalBundle",
"SizeAnomalyDetector",
"SizeAnomalySignal",
"SniperClusterSignal",
"SniperDetector",
]
@@ -1,10 +1,13 @@
"""Data models for the detector module."""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime
from decimal import Decimal
from polymarket_insider_tracker.ingestor.models import TradeEvent
from polymarket_insider_tracker.ingestor.models import MarketMetadata, TradeEvent
from polymarket_insider_tracker.profiler.models import WalletProfile
@@ -71,3 +74,205 @@ class FreshWalletSignal:
"factors": self.factors,
"timestamp": self.timestamp.isoformat(),
}
@dataclass(frozen=True)
class SizeAnomalySignal:
"""Signal emitted when a trade has unusually large position size.
This signal is generated when a trade's size significantly impacts
the market volume or order book depth, indicating potential informed
trading activity.
Attributes:
trade_event: The original trade event that triggered this signal.
market_metadata: Metadata about the market being traded.
volume_impact: Trade size as fraction of 24h volume (0.0 if unknown).
book_impact: Trade size as fraction of order book depth (0.0 if unknown).
is_niche_market: Whether the market is considered niche/low-volume.
confidence: Overall confidence score (0.0 to 1.0).
factors: Individual factor scores contributing to confidence.
timestamp: When this signal was generated.
"""
trade_event: TradeEvent
market_metadata: MarketMetadata
volume_impact: float
book_impact: float
is_niche_market: bool
confidence: float
factors: dict[str, float]
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
@property
def wallet_address(self) -> str:
"""Return the wallet address from the trade event."""
return self.trade_event.wallet_address
@property
def market_id(self) -> str:
"""Return the market ID from the trade event."""
return self.trade_event.market_id
@property
def trade_size_usdc(self) -> Decimal:
"""Return the trade size in USDC (notional value)."""
return self.trade_event.notional_value
@property
def is_high_confidence(self) -> bool:
"""Return True if confidence exceeds 0.7."""
return self.confidence >= 0.7
@property
def is_very_high_confidence(self) -> bool:
"""Return True if confidence exceeds 0.85."""
return self.confidence >= 0.85
def to_dict(self) -> dict[str, object]:
"""Serialize to dictionary for Redis stream publishing."""
return {
"wallet_address": self.wallet_address,
"market_id": self.market_id,
"trade_id": self.trade_event.trade_id,
"trade_size": str(self.trade_size_usdc),
"trade_side": self.trade_event.side,
"trade_price": str(self.trade_event.price),
"market_category": self.market_metadata.category,
"volume_impact": self.volume_impact,
"book_impact": self.book_impact,
"is_niche_market": self.is_niche_market,
"confidence": self.confidence,
"factors": self.factors,
"timestamp": self.timestamp.isoformat(),
}
@dataclass(frozen=True)
class SniperClusterSignal:
"""Signal emitted when a wallet is identified as part of a sniper cluster.
Sniper clusters are groups of wallets that consistently enter markets
within minutes of their creation, suggesting coordinated insider activity.
Attributes:
wallet_address: The wallet identified as a sniper.
cluster_id: Unique identifier for this cluster.
cluster_size: Number of wallets in the cluster.
avg_entry_delta_seconds: Average time (seconds) from market creation to entry.
markets_in_common: Number of markets where cluster members overlap.
confidence: Confidence score (0.0 to 1.0) based on clustering strength.
timestamp: When this signal was generated.
"""
wallet_address: str
cluster_id: str
cluster_size: int
avg_entry_delta_seconds: float
markets_in_common: int
confidence: float
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
@property
def is_high_confidence(self) -> bool:
"""Return True if confidence exceeds 0.7."""
return self.confidence >= 0.7
@property
def is_very_high_confidence(self) -> bool:
"""Return True if confidence exceeds 0.85."""
return self.confidence >= 0.85
def to_dict(self) -> dict[str, object]:
"""Serialize to dictionary for Redis stream publishing."""
return {
"wallet_address": self.wallet_address,
"cluster_id": self.cluster_id,
"cluster_size": self.cluster_size,
"avg_entry_delta_seconds": self.avg_entry_delta_seconds,
"markets_in_common": self.markets_in_common,
"confidence": self.confidence,
"timestamp": self.timestamp.isoformat(),
}
@dataclass(frozen=True)
class RiskAssessment:
"""Combined risk assessment aggregating all signal types.
This represents the final scoring output that determines whether
a trade should trigger an alert, combining signals from multiple
detectors with configurable weights.
Attributes:
trade_event: The original trade event being assessed.
wallet_address: The trader's wallet address.
market_id: The market condition ID.
fresh_wallet_signal: Signal from fresh wallet detector, if triggered.
size_anomaly_signal: Signal from size anomaly detector, if triggered.
signals_triggered: Count of how many signal types fired.
weighted_score: Final weighted combination of all signals (0.0 to 1.0).
should_alert: Whether this assessment meets alert threshold.
assessment_id: Unique identifier for this assessment.
timestamp: When this assessment was generated.
"""
trade_event: TradeEvent
wallet_address: str
market_id: str
# Individual signals (None if not triggered)
fresh_wallet_signal: FreshWalletSignal | None
size_anomaly_signal: SizeAnomalySignal | None
# Combined scoring
signals_triggered: int
weighted_score: float
should_alert: bool
# Metadata
assessment_id: str = field(default_factory=lambda: str(uuid.uuid4()))
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
@property
def is_high_risk(self) -> bool:
"""Return True if weighted score exceeds 0.7."""
return self.weighted_score >= 0.7
@property
def is_very_high_risk(self) -> bool:
"""Return True if weighted score exceeds 0.85."""
return self.weighted_score >= 0.85
@property
def trade_size_usdc(self) -> Decimal:
"""Return the trade size in USDC (notional value)."""
return self.trade_event.notional_value
def to_dict(self) -> dict[str, object]:
"""Serialize to dictionary for Redis stream publishing."""
return {
"assessment_id": self.assessment_id,
"wallet_address": self.wallet_address,
"market_id": self.market_id,
"trade_id": self.trade_event.trade_id,
"trade_size": str(self.trade_size_usdc),
"trade_side": self.trade_event.side,
"trade_price": str(self.trade_event.price),
"signals_triggered": self.signals_triggered,
"weighted_score": self.weighted_score,
"should_alert": self.should_alert,
"has_fresh_wallet_signal": self.fresh_wallet_signal is not None,
"has_size_anomaly_signal": self.size_anomaly_signal is not None,
"fresh_wallet_confidence": (
self.fresh_wallet_signal.confidence
if self.fresh_wallet_signal
else None
),
"size_anomaly_confidence": (
self.size_anomaly_signal.confidence
if self.size_anomaly_signal
else None
),
"timestamp": self.timestamp.isoformat(),
}
@@ -0,0 +1,309 @@
"""Composite risk scorer combining all detector signals.
This module provides the RiskScorer class that aggregates signals from
multiple detectors into a unified risk assessment with weighted scoring.
"""
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from redis.asyncio import Redis
from polymarket_insider_tracker.detector.models import (
FreshWalletSignal,
RiskAssessment,
SizeAnomalySignal,
)
from polymarket_insider_tracker.ingestor.models import TradeEvent
logger = logging.getLogger(__name__)
# Default configuration
DEFAULT_ALERT_THRESHOLD = 0.6
DEFAULT_DEDUP_WINDOW_SECONDS = 3600 # 1 hour
DEFAULT_REDIS_KEY_PREFIX = "polymarket:dedup:"
# Default weights for each signal type
DEFAULT_WEIGHTS = {
"fresh_wallet": 0.40,
"size_anomaly": 0.35,
"niche_market": 0.25,
}
# Multi-signal bonuses
MULTI_SIGNAL_BONUS_2 = 1.2 # 20% bonus for 2 signals
MULTI_SIGNAL_BONUS_3 = 1.3 # 30% bonus for 3+ signals
@dataclass
class SignalBundle:
"""Bundle of signals for a single trade.
Collects all available signals for a trade event to pass to the scorer.
"""
trade_event: TradeEvent
fresh_wallet_signal: FreshWalletSignal | None = None
size_anomaly_signal: SizeAnomalySignal | None = None
@property
def wallet_address(self) -> str:
"""Return the wallet address from the trade event."""
return self.trade_event.wallet_address
@property
def market_id(self) -> str:
"""Return the market ID from the trade event."""
return self.trade_event.market_id
class RiskScorer:
"""Composite risk scorer combining signals into unified assessments.
This scorer:
- Aggregates signals from multiple detectors for the same trade
- Applies configurable weights based on signal type
- Calculates multi-signal bonuses for correlated signals
- Enforces deduplication to prevent alert spam
- Produces RiskAssessment objects for downstream alerting
Scoring Formula:
weighted_score = sum(signal.confidence * weight[type] for signal in signals)
# Multi-signal bonus
if signals >= 2: weighted_score *= 1.2
if signals >= 3: weighted_score *= 1.3
# Cap at 1.0
final_score = min(weighted_score, 1.0)
should_alert = final_score >= alert_threshold AND not deduplicated
Example:
```python
redis = Redis.from_url("redis://localhost:6379")
scorer = RiskScorer(redis)
bundle = SignalBundle(
trade_event=trade,
fresh_wallet_signal=fresh_signal,
size_anomaly_signal=size_signal,
)
assessment = await scorer.assess(bundle)
if assessment.should_alert:
await send_alert(assessment)
```
"""
def __init__(
self,
redis: Redis,
*,
weights: dict[str, float] | None = None,
alert_threshold: float = DEFAULT_ALERT_THRESHOLD,
dedup_window_seconds: int = DEFAULT_DEDUP_WINDOW_SECONDS,
key_prefix: str = DEFAULT_REDIS_KEY_PREFIX,
) -> None:
"""Initialize the risk scorer.
Args:
redis: Redis async client for deduplication.
weights: Custom weights for signal types. Defaults to DEFAULT_WEIGHTS.
alert_threshold: Minimum score to trigger alert (default 0.6).
dedup_window_seconds: Window for deduplication (default 3600 = 1 hour).
key_prefix: Redis key prefix for dedup keys.
"""
self._redis = redis
self._weights = weights or DEFAULT_WEIGHTS.copy()
self._alert_threshold = alert_threshold
self._dedup_window = dedup_window_seconds
self._key_prefix = key_prefix
async def assess(self, bundle: SignalBundle) -> RiskAssessment:
"""Assess a trade's risk based on all available signals.
This method:
1. Counts triggered signals
2. Calculates weighted score with bonuses
3. Checks deduplication
4. Creates RiskAssessment
Args:
bundle: SignalBundle containing trade and all signals.
Returns:
RiskAssessment with final scoring and alert decision.
"""
# Calculate weighted score
weighted_score, signals_triggered = self.calculate_weighted_score(bundle)
# Determine if should alert (before dedup check)
meets_threshold = weighted_score >= self._alert_threshold
# Check deduplication
is_duplicate = False
if meets_threshold:
is_duplicate = await self._check_and_set_dedup(
bundle.wallet_address,
bundle.market_id,
)
should_alert = meets_threshold and not is_duplicate
# Log assessment
if should_alert:
logger.info(
"Risk assessment triggered alert: wallet=%s, market=%s, "
"score=%.2f, signals=%d",
bundle.wallet_address[:10] + "...",
bundle.market_id[:10] + "...",
weighted_score,
signals_triggered,
)
elif is_duplicate:
logger.debug(
"Risk assessment deduplicated: wallet=%s, market=%s",
bundle.wallet_address[:10] + "...",
bundle.market_id[:10] + "...",
)
return RiskAssessment(
trade_event=bundle.trade_event,
wallet_address=bundle.wallet_address,
market_id=bundle.market_id,
fresh_wallet_signal=bundle.fresh_wallet_signal,
size_anomaly_signal=bundle.size_anomaly_signal,
signals_triggered=signals_triggered,
weighted_score=weighted_score,
should_alert=should_alert,
)
def calculate_weighted_score(
self, bundle: SignalBundle
) -> tuple[float, int]:
"""Calculate weighted score from all signals.
Applies per-signal weights and multi-signal bonuses.
Args:
bundle: SignalBundle with all available signals.
Returns:
Tuple of (weighted_score, signals_triggered_count).
"""
score = 0.0
signals_triggered = 0
# Fresh wallet signal
if bundle.fresh_wallet_signal is not None:
weight = self._weights.get("fresh_wallet", 0.0)
score += bundle.fresh_wallet_signal.confidence * weight
signals_triggered += 1
# Size anomaly signal
if bundle.size_anomaly_signal is not None:
weight = self._weights.get("size_anomaly", 0.0)
score += bundle.size_anomaly_signal.confidence * weight
signals_triggered += 1
# Additional niche market weight
if bundle.size_anomaly_signal.is_niche_market:
niche_weight = self._weights.get("niche_market", 0.0)
score += bundle.size_anomaly_signal.confidence * niche_weight
# Apply multi-signal bonus
if signals_triggered >= 3:
score *= MULTI_SIGNAL_BONUS_3
elif signals_triggered >= 2:
score *= MULTI_SIGNAL_BONUS_2
# Cap at 1.0
score = min(score, 1.0)
return score, signals_triggered
async def _check_and_set_dedup(
self,
wallet_address: str,
market_id: str,
) -> bool:
"""Check if this wallet/market combo was recently alerted.
If not a duplicate, sets the dedup key with TTL.
Args:
wallet_address: The trader's wallet address.
market_id: The market condition ID.
Returns:
True if this is a duplicate (already alerted), False otherwise.
"""
key = f"{self._key_prefix}{wallet_address}:{market_id}"
# Try to set with NX (only if not exists)
was_set = await self._redis.set(
key,
datetime.now(UTC).isoformat(),
nx=True,
ex=self._dedup_window,
)
# If was_set is None/False, key already existed = duplicate
return not was_set
async def clear_dedup(
self,
wallet_address: str,
market_id: str,
) -> bool:
"""Clear dedup key for a wallet/market combo.
Useful for testing or manual override.
Args:
wallet_address: The trader's wallet address.
market_id: The market condition ID.
Returns:
True if key was deleted, False if it didn't exist.
"""
key = f"{self._key_prefix}{wallet_address}:{market_id}"
deleted = await self._redis.delete(key)
return deleted > 0
async def assess_batch(
self, bundles: list[SignalBundle]
) -> list[RiskAssessment]:
"""Assess multiple trade bundles.
Args:
bundles: List of SignalBundles to assess.
Returns:
List of RiskAssessments.
"""
import asyncio
tasks = [self.assess(bundle) for bundle in bundles]
return await asyncio.gather(*tasks)
def get_weights(self) -> dict[str, float]:
"""Get current signal weights.
Returns:
Copy of the weights dictionary.
"""
return self._weights.copy()
def set_weights(self, weights: dict[str, float]) -> None:
"""Update signal weights.
Useful for A/B testing different weight configurations.
Args:
weights: New weights dictionary.
"""
self._weights = weights.copy()
logger.info("Updated risk scorer weights: %s", self._weights)
@@ -0,0 +1,354 @@
"""Position size anomaly detection algorithm.
This module provides the SizeAnomalyDetector class that identifies trades
with unusually large position sizes relative to market liquidity.
"""
import logging
from decimal import Decimal
from polymarket_insider_tracker.detector.models import SizeAnomalySignal
from polymarket_insider_tracker.ingestor.metadata_sync import MarketMetadataSync
from polymarket_insider_tracker.ingestor.models import MarketMetadata, TradeEvent
logger = logging.getLogger(__name__)
# Default configuration
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 market categories - markets in these categories with low specificity
# are more likely to have insider information value
NICHE_PRONE_CATEGORIES = frozenset({"science", "tech", "finance", "other"})
class SizeAnomalyDetector:
"""Detector for unusually large trade sizes.
This detector analyzes trade events for size anomalies by comparing
the trade size against market liquidity metrics:
- Volume impact: trade size / 24h volume
- Book impact: trade size / order book depth
When volume data is unavailable, the detector uses category-based
heuristics to identify niche markets where large trades are more
significant.
Confidence scoring:
- Volume impact > threshold: base score from impact ratio
- Book impact > threshold: additional score from impact ratio
- Niche market multiplier: 1.5x for low-volume markets
Example:
```python
sync = MarketMetadataSync(redis, clob_client)
detector = SizeAnomalyDetector(sync)
# Analyze a trade
signal = await detector.analyze(trade_event)
if signal is not None:
print(f"Size anomaly detected! Confidence: {signal.confidence}")
```
"""
def __init__(
self,
metadata_sync: MarketMetadataSync,
*,
volume_threshold: float = DEFAULT_VOLUME_THRESHOLD,
book_threshold: float = DEFAULT_BOOK_THRESHOLD,
niche_volume_threshold: Decimal = DEFAULT_NICHE_VOLUME_THRESHOLD,
) -> None:
"""Initialize the size anomaly detector.
Args:
metadata_sync: MarketMetadataSync for fetching market metadata.
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).
"""
self._metadata_sync = metadata_sync
self._volume_threshold = volume_threshold
self._book_threshold = book_threshold
self._niche_volume_threshold = niche_volume_threshold
async def analyze(
self,
trade: TradeEvent,
*,
daily_volume: Decimal | None = None,
book_depth: Decimal | None = None,
) -> SizeAnomalySignal | None:
"""Analyze a trade event for size anomalies.
This method:
1. Fetches market metadata
2. Calculates volume and book impact (if data available)
3. Determines if market is niche
4. Calculates confidence score
Args:
trade: TradeEvent to analyze.
daily_volume: Optional 24h volume in USDC. If provided, enables
volume impact calculation.
book_depth: Optional order book depth in USDC. If provided,
enables book impact calculation.
Returns:
SizeAnomalySignal if the trade triggers anomaly detection,
None otherwise.
"""
# Get market metadata
try:
metadata = await self._metadata_sync.get_market(trade.market_id)
if metadata is None:
logger.warning(
"No metadata found for market %s, creating minimal metadata",
trade.market_id,
)
metadata = self._create_minimal_metadata(trade)
except Exception as e:
logger.warning(
"Failed to get metadata for market %s: %s",
trade.market_id,
e,
)
metadata = self._create_minimal_metadata(trade)
trade_size = trade.notional_value
# Calculate impacts
volume_impact = self._calculate_volume_impact(trade_size, daily_volume)
book_impact = self._calculate_book_impact(trade_size, book_depth)
# Determine if niche market
is_niche = self._is_niche_market(metadata, daily_volume)
# Check if any threshold exceeded
exceeds_volume = volume_impact > self._volume_threshold
exceeds_book = book_impact > self._book_threshold
if not exceeds_volume and not exceeds_book and not is_niche:
logger.debug(
"Trade %s does not exceed thresholds: volume=%.4f, book=%.4f",
trade.trade_id,
volume_impact,
book_impact,
)
return None
# Calculate confidence score
confidence, factors = self.calculate_confidence(
volume_impact=volume_impact,
book_impact=book_impact,
is_niche=is_niche,
)
# Only emit signal if confidence is meaningful
if confidence < 0.1:
return None
logger.info(
"Size anomaly signal: market=%s, size=%s, volume_impact=%.4f, "
"book_impact=%.4f, niche=%s, confidence=%.2f",
trade.market_id[:10] + "...",
trade_size,
volume_impact,
book_impact,
is_niche,
confidence,
)
return SizeAnomalySignal(
trade_event=trade,
market_metadata=metadata,
volume_impact=volume_impact,
book_impact=book_impact,
is_niche_market=is_niche,
confidence=confidence,
factors=factors,
)
def _create_minimal_metadata(self, trade: TradeEvent) -> MarketMetadata:
"""Create minimal metadata from trade event."""
from polymarket_insider_tracker.ingestor.models import Token
return MarketMetadata(
condition_id=trade.market_id,
question=trade.event_title or "Unknown Market",
description="",
tokens=(
Token(
token_id=trade.asset_id,
outcome=trade.outcome,
price=trade.price,
),
),
category="other",
)
def _calculate_volume_impact(
self,
trade_size: Decimal,
daily_volume: Decimal | None,
) -> float:
"""Calculate trade size as fraction of daily volume.
Args:
trade_size: Trade notional value in USDC.
daily_volume: 24h trading volume in USDC.
Returns:
Volume impact ratio, or 0.0 if volume unknown.
"""
if daily_volume is None or daily_volume <= 0:
return 0.0
return float(trade_size / daily_volume)
def _calculate_book_impact(
self,
trade_size: Decimal,
book_depth: Decimal | None,
) -> float:
"""Calculate trade size as fraction of order book depth.
Args:
trade_size: Trade notional value in USDC.
book_depth: Visible order book depth in USDC.
Returns:
Book impact ratio, or 0.0 if depth unknown.
"""
if book_depth is None or book_depth <= 0:
return 0.0
return float(trade_size / book_depth)
def _is_niche_market(
self,
metadata: MarketMetadata,
daily_volume: Decimal | None,
) -> bool:
"""Determine if market is considered niche.
A market is niche if:
- Volume is below threshold ($50k), OR
- Category is prone to insider info AND volume is unknown
Args:
metadata: Market metadata with category.
daily_volume: Optional 24h volume.
Returns:
True if market is considered niche.
"""
# If volume known and below threshold, it's niche
if daily_volume is not None and daily_volume < self._niche_volume_threshold:
return True
# If volume unknown, use category heuristics
return daily_volume is None and metadata.category in NICHE_PRONE_CATEGORIES
def calculate_confidence(
self,
*,
volume_impact: float,
book_impact: float,
is_niche: bool,
) -> tuple[float, dict[str, float]]:
"""Calculate confidence score based on impact metrics.
Confidence scoring:
- Volume impact: min(impact/threshold, 3) / 3 * 0.5
- Book impact: min(impact/threshold, 3) / 3 * 0.3
- Niche multiplier: 1.5x final score
Final confidence clamped to [0.0, 1.0].
Args:
volume_impact: Trade size / daily volume ratio.
book_impact: Trade size / book depth ratio.
is_niche: Whether market is niche.
Returns:
Tuple of (confidence_score, factors_dict).
"""
factors: dict[str, float] = {}
confidence = 0.0
# Volume impact component
if volume_impact > self._volume_threshold:
ratio = min(volume_impact / self._volume_threshold, 3.0)
volume_score = ratio / 3.0 * 0.5
factors["volume_impact"] = volume_score
confidence += volume_score
# Book impact component
if book_impact > self._book_threshold:
ratio = min(book_impact / self._book_threshold, 3.0)
book_score = ratio / 3.0 * 0.3
factors["book_impact"] = book_score
confidence += book_score
# Niche market multiplier
if is_niche and confidence > 0:
factors["niche_multiplier"] = 1.5
confidence *= 1.5
# If niche but no other signals, give small base confidence
if is_niche and confidence == 0:
factors["niche_base"] = 0.2
confidence = 0.2
# Clamp to valid range
confidence = max(0.0, min(1.0, confidence))
return confidence, factors
async def analyze_batch(
self,
trades: list[TradeEvent],
*,
volume_data: dict[str, Decimal] | None = None,
book_data: dict[str, Decimal] | None = None,
) -> list[SizeAnomalySignal]:
"""Analyze multiple trades for size anomalies.
Processes trades in parallel for efficiency.
Args:
trades: List of trades to analyze.
volume_data: Optional dict mapping market_id to 24h volume.
book_data: Optional dict mapping market_id to book depth.
Returns:
List of SizeAnomalySignal for trades with anomalies.
"""
import asyncio
volume_data = volume_data or {}
book_data = book_data or {}
tasks = [
self.analyze(
trade,
daily_volume=volume_data.get(trade.market_id),
book_depth=book_data.get(trade.market_id),
)
for trade in trades
]
results = await asyncio.gather(*tasks, return_exceptions=True)
signals: list[SizeAnomalySignal] = []
for trade, result in zip(trades, results, strict=True):
if isinstance(result, BaseException):
logger.warning(
"Failed to analyze trade %s: %s",
trade.trade_id,
result,
)
continue
if result is not None:
signals.append(result)
return signals
@@ -0,0 +1,468 @@
"""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=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=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")
@@ -9,7 +9,18 @@ from polymarket_insider_tracker.profiler.chain import (
RateLimitError,
RPCError,
)
from polymarket_insider_tracker.profiler.entities import (
EntityRegistry,
)
from polymarket_insider_tracker.profiler.entity_data import (
EntityType,
)
from polymarket_insider_tracker.profiler.funding import (
FundingTracer,
)
from polymarket_insider_tracker.profiler.models import (
FundingChain,
FundingTransfer,
Transaction,
WalletInfo,
WalletProfile,
@@ -18,6 +29,13 @@ from polymarket_insider_tracker.profiler.models import (
__all__ = [
# Analyzer
"WalletAnalyzer",
# Entity Registry
"EntityRegistry",
"EntityType",
# Funding Tracer
"FundingChain",
"FundingTracer",
"FundingTransfer",
# Polygon Client
"PolygonClient",
"PolygonClientError",
@@ -0,0 +1,279 @@
"""Known entity registry for blockchain address classification.
This module provides the EntityRegistry class for classifying blockchain
addresses as known entities (CEX hot wallets, bridges, DEX contracts, etc.)
to support funding chain analysis and suspiciousness scoring.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from polymarket_insider_tracker.profiler.entity_data import (
EntityType,
get_all_known_entities,
)
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
class EntityRegistry:
"""Registry of known blockchain entities for address classification.
The registry contains mappings from blockchain addresses to known entity
types (CEX, bridges, DEX, etc.). This is used to:
- Terminate funding chain traces at known entities
- Classify funding sources for suspiciousness scoring
- Identify retail vs sophisticated wallet patterns
Attributes:
_entities: Internal mapping of address to entity type.
"""
# Entity types that should terminate funding chain traces
TERMINAL_ENTITY_TYPES = frozenset(
[
EntityType.CEX_BINANCE,
EntityType.CEX_COINBASE,
EntityType.CEX_KRAKEN,
EntityType.CEX_OKX,
EntityType.CEX_KUCOIN,
EntityType.CEX_BYBIT,
EntityType.CEX_CRYPTO_COM,
EntityType.CEX_OTHER,
EntityType.BRIDGE_POLYGON,
EntityType.BRIDGE_MULTICHAIN,
EntityType.BRIDGE_STARGATE,
EntityType.BRIDGE_HOP,
EntityType.BRIDGE_OTHER,
]
)
# Entity types that indicate CEX origin
CEX_ENTITY_TYPES = frozenset(
[
EntityType.CEX_BINANCE,
EntityType.CEX_COINBASE,
EntityType.CEX_KRAKEN,
EntityType.CEX_OKX,
EntityType.CEX_KUCOIN,
EntityType.CEX_BYBIT,
EntityType.CEX_CRYPTO_COM,
EntityType.CEX_OTHER,
]
)
# Entity types that indicate bridge origin
BRIDGE_ENTITY_TYPES = frozenset(
[
EntityType.BRIDGE_POLYGON,
EntityType.BRIDGE_MULTICHAIN,
EntityType.BRIDGE_STARGATE,
EntityType.BRIDGE_HOP,
EntityType.BRIDGE_OTHER,
]
)
# Entity types for DEX contracts
DEX_ENTITY_TYPES = frozenset(
[
EntityType.DEX_UNISWAP,
EntityType.DEX_SUSHISWAP,
EntityType.DEX_QUICKSWAP,
EntityType.DEX_1INCH,
EntityType.DEX_OTHER,
]
)
def __init__(
self,
custom_entities: dict[str, EntityType] | None = None,
*,
include_defaults: bool = True,
) -> None:
"""Initialize the entity registry.
Args:
custom_entities: Additional custom entity mappings to include.
include_defaults: Whether to include default known entities.
"""
self._entities: dict[str, EntityType] = {}
if include_defaults:
self._entities.update(get_all_known_entities())
if custom_entities:
# Add custom entities (normalized to lowercase)
for address, entity_type in custom_entities.items():
self._entities[address.lower()] = entity_type
logger.info(f"EntityRegistry initialized with {len(self._entities)} known entities")
def classify(self, address: str) -> EntityType:
"""Classify an address by its entity type.
Args:
address: The blockchain address to classify.
Returns:
The EntityType for the address, or UNKNOWN if not in registry.
"""
return self._entities.get(address.lower(), EntityType.UNKNOWN)
def is_known_entity(self, address: str) -> bool:
"""Check if an address is a known entity.
Args:
address: The blockchain address to check.
Returns:
True if the address is in the registry, False otherwise.
"""
return address.lower() in self._entities
def is_cex(self, address: str) -> bool:
"""Check if an address is a known CEX hot wallet.
Args:
address: The blockchain address to check.
Returns:
True if the address is a CEX hot wallet.
"""
return self.classify(address) in self.CEX_ENTITY_TYPES
def is_bridge(self, address: str) -> bool:
"""Check if an address is a known bridge contract.
Args:
address: The blockchain address to check.
Returns:
True if the address is a bridge contract.
"""
return self.classify(address) in self.BRIDGE_ENTITY_TYPES
def is_dex(self, address: str) -> bool:
"""Check if an address is a known DEX contract.
Args:
address: The blockchain address to check.
Returns:
True if the address is a DEX contract.
"""
return self.classify(address) in self.DEX_ENTITY_TYPES
def is_terminal(self, address: str) -> bool:
"""Check if an address should terminate a funding chain trace.
Terminal entities are those where tracing further back provides
diminishing returns (CEX, bridges). These indicate the practical
origin of funds from the perspective of on-chain analysis.
Args:
address: The blockchain address to check.
Returns:
True if the address should terminate a funding trace.
"""
return self.classify(address) in self.TERMINAL_ENTITY_TYPES
def is_contract(self, address: str) -> bool:
"""Check if an address is a known smart contract.
This includes DEX routers, token contracts, and DeFi protocols.
Args:
address: The blockchain address to check.
Returns:
True if the address is a known smart contract.
"""
entity_type = self.classify(address)
contract_types = (
self.DEX_ENTITY_TYPES
| {
EntityType.TOKEN_USDC,
EntityType.TOKEN_USDT,
EntityType.TOKEN_WETH,
EntityType.TOKEN_WMATIC,
EntityType.DEFI_AAVE,
EntityType.DEFI_COMPOUND,
EntityType.DEFI_OTHER,
EntityType.CONTRACT,
}
)
return entity_type in contract_types
def get_entity_category(self, address: str) -> str:
"""Get a human-readable category for an address.
Args:
address: The blockchain address to categorize.
Returns:
Category string: "cex", "bridge", "dex", "token", "defi", "contract", or "unknown".
"""
entity_type = self.classify(address)
if entity_type in self.CEX_ENTITY_TYPES:
return "cex"
if entity_type in self.BRIDGE_ENTITY_TYPES:
return "bridge"
if entity_type in self.DEX_ENTITY_TYPES:
return "dex"
if entity_type in {
EntityType.TOKEN_USDC,
EntityType.TOKEN_USDT,
EntityType.TOKEN_WETH,
EntityType.TOKEN_WMATIC,
}:
return "token"
if entity_type in {
EntityType.DEFI_AAVE,
EntityType.DEFI_COMPOUND,
EntityType.DEFI_OTHER,
}:
return "defi"
if entity_type == EntityType.CONTRACT:
return "contract"
return "unknown"
def add_entity(self, address: str, entity_type: EntityType) -> None:
"""Add or update an entity in the registry.
Args:
address: The blockchain address.
entity_type: The entity type to assign.
"""
self._entities[address.lower()] = entity_type
logger.debug(f"Added entity: {address} -> {entity_type.value}")
def remove_entity(self, address: str) -> bool:
"""Remove an entity from the registry.
Args:
address: The blockchain address to remove.
Returns:
True if the entity was removed, False if not found.
"""
normalized = address.lower()
if normalized in self._entities:
del self._entities[normalized]
return True
return False
def __len__(self) -> int:
"""Return the number of entities in the registry."""
return len(self._entities)
def __contains__(self, address: str) -> bool:
"""Check if an address is in the registry."""
return self.is_known_entity(address)
@@ -0,0 +1,154 @@
"""Known blockchain entity address mappings.
This module contains address-to-entity mappings for known blockchain
entities on Polygon including CEX hot wallets, bridges, and DEX contracts.
Sources:
- Etherscan labels
- Arkham Intelligence
- Official protocol documentation
"""
from __future__ import annotations
from enum import Enum
class EntityType(Enum):
"""Classification of known blockchain entities."""
# Centralized Exchanges
CEX_BINANCE = "cex_binance"
CEX_COINBASE = "cex_coinbase"
CEX_KRAKEN = "cex_kraken"
CEX_OKX = "cex_okx"
CEX_KUCOIN = "cex_kucoin"
CEX_BYBIT = "cex_bybit"
CEX_CRYPTO_COM = "cex_crypto_com"
CEX_OTHER = "cex_other"
# Bridges
BRIDGE_POLYGON = "bridge_polygon"
BRIDGE_MULTICHAIN = "bridge_multichain"
BRIDGE_STARGATE = "bridge_stargate"
BRIDGE_HOP = "bridge_hop"
BRIDGE_OTHER = "bridge_other"
# Decentralized Exchanges
DEX_UNISWAP = "dex_uniswap"
DEX_SUSHISWAP = "dex_sushiswap"
DEX_QUICKSWAP = "dex_quickswap"
DEX_1INCH = "dex_1inch"
DEX_OTHER = "dex_other"
# Token Contracts
TOKEN_USDC = "token_usdc"
TOKEN_USDT = "token_usdt"
TOKEN_WETH = "token_weth"
TOKEN_WMATIC = "token_wmatic"
# Lending/DeFi
DEFI_AAVE = "defi_aave"
DEFI_COMPOUND = "defi_compound"
DEFI_OTHER = "defi_other"
# Other
CONTRACT = "contract"
UNKNOWN = "unknown"
# CEX hot wallet addresses on Polygon
# Sources: Etherscan labels, Arkham Intelligence, public disclosures
CEX_ADDRESSES: dict[str, EntityType] = {
# Binance
"0x28c6c06298d514db089934071355e5743bf21d60": EntityType.CEX_BINANCE,
"0x21a31ee1afc51d94c2efccaa2092ad1028285549": EntityType.CEX_BINANCE,
"0xf89d7b9c864f589bbf53a82105107622b35eaa40": EntityType.CEX_BINANCE,
"0xdfd5293d8e347dfe59e90efd55b2956a1343963d": EntityType.CEX_BINANCE,
# Coinbase
"0x503828976d22510aad0339f595f37cc4e4645c80": EntityType.CEX_COINBASE,
"0x71660c4005ba85c37ccec55d0c4493e66fe775d3": EntityType.CEX_COINBASE,
"0xa9d1e08c7793af67e9d92fe308d5697fb81d3e43": EntityType.CEX_COINBASE,
# Kraken
"0x2910543af39aba0cd09dbb2d50200b3e800a63d2": EntityType.CEX_KRAKEN,
"0x0a869d79a7052c7f1b55a8ebabbea3420f0d1e13": EntityType.CEX_KRAKEN,
# OKX
"0x5041ed759dd4afc3a72b8192c143f72f4724081a": EntityType.CEX_OKX,
"0x6cc5f688a315f3dc28a7781717a9a798a59fda7b": EntityType.CEX_OKX,
# KuCoin
"0xf16e9b0d03470827a95cdfd0cb8a8a3b46969b91": EntityType.CEX_KUCOIN,
"0xd6216fc19db775df9774a6e33526131da7d19a2c": EntityType.CEX_KUCOIN,
# Bybit
"0xf89e6d82be28f5cc97a9e6a94a16a17e5be73e78": EntityType.CEX_BYBIT,
# Crypto.com
"0x6262998ced04146fa42253a5c0af90ca02dfd2a3": EntityType.CEX_CRYPTO_COM,
"0x46340b20830761efd32832a74d7169b29feb9758": EntityType.CEX_CRYPTO_COM,
}
# Bridge contract addresses on Polygon
BRIDGE_ADDRESSES: dict[str, EntityType] = {
# Polygon PoS Bridge (RootChain / Plasma Bridge related)
"0xa0c68c638235ee32657e8f720a23cec1bfc77c77": EntityType.BRIDGE_POLYGON,
"0x401f6c983ea34274ec46f84d70b31c151321188b": EntityType.BRIDGE_POLYGON,
# Multichain (formerly AnySwap)
"0x4f3aff3a747fcade12598081e80c6605a8be192f": EntityType.BRIDGE_MULTICHAIN,
# Stargate
"0x45a01e4e04f14f7a4a6880d0cbaf2c3c1acfbed4": EntityType.BRIDGE_STARGATE,
# Hop Protocol
"0x76b22b8c1079a44f1211b0e72c5d26c5e3b3c3c9": EntityType.BRIDGE_HOP,
}
# DEX router addresses on Polygon
DEX_ADDRESSES: dict[str, EntityType] = {
# Uniswap V3
"0xe592427a0aece92de3edee1f18e0157c05861564": EntityType.DEX_UNISWAP,
"0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45": EntityType.DEX_UNISWAP, # SwapRouter02
# SushiSwap
"0x1b02da8cb0d097eb8d57a175b88c7d8b47997506": EntityType.DEX_SUSHISWAP,
# QuickSwap
"0xa5e0829caced8ffdd4de3c43696c57f7d7a678ff": EntityType.DEX_QUICKSWAP,
# 1inch
"0x1111111254eeb25477b68fb85ed929f73a960582": EntityType.DEX_1INCH,
}
# Token contract addresses on Polygon
TOKEN_ADDRESSES: dict[str, EntityType] = {
# USDC (Bridged)
"0x2791bca1f2de4661ed88a30c99a7a9449aa84174": EntityType.TOKEN_USDC,
# USDC (Native)
"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359": EntityType.TOKEN_USDC,
# USDT
"0xc2132d05d31c914a87c6611c10748aeb04b58e8f": EntityType.TOKEN_USDT,
# WETH
"0x7ceb23fd6bc0add59e62ac25578270cff1b9f619": EntityType.TOKEN_WETH,
# WMATIC
"0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270": EntityType.TOKEN_WMATIC,
}
# DeFi protocol addresses on Polygon
DEFI_ADDRESSES: dict[str, EntityType] = {
# Aave V3
"0x794a61358d6845594f94dc1db02a252b5b4814ad": EntityType.DEFI_AAVE, # Pool
"0x8145edddf43f50276641b55bd3ad95944510021e": EntityType.DEFI_AAVE, # PoolAddressesProvider
}
def get_all_known_entities() -> dict[str, EntityType]:
"""Get all known entity addresses combined.
Returns:
Dictionary mapping lowercase addresses to their entity types.
"""
all_entities: dict[str, EntityType] = {}
for entities in [
CEX_ADDRESSES,
BRIDGE_ADDRESSES,
DEX_ADDRESSES,
TOKEN_ADDRESSES,
DEFI_ADDRESSES,
]:
for address, entity_type in entities.items():
all_entities[address.lower()] = entity_type
return all_entities
@@ -0,0 +1,358 @@
"""Funding chain tracer for wallet analysis.
This module provides the FundingTracer class for tracing the funding chain
of wallets to identify where their USDC/MATIC originated from.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from decimal import Decimal
from typing import TYPE_CHECKING, Any
from web3 import AsyncWeb3
from polymarket_insider_tracker.profiler.entities import EntityRegistry
from polymarket_insider_tracker.profiler.models import FundingChain, FundingTransfer
if TYPE_CHECKING:
from polymarket_insider_tracker.profiler.chain import PolygonClient
logger = logging.getLogger(__name__)
# USDC contract addresses on Polygon
USDC_BRIDGED = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
USDC_NATIVE = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359"
# ERC20 Transfer event signature
TRANSFER_EVENT_SIGNATURE = AsyncWeb3.keccak(text="Transfer(address,address,uint256)")
class FundingTracer:
"""Traces funding chains to identify wallet funding sources.
The tracer follows USDC transfers backwards from a target wallet
to find where the funds originated, stopping at known entities
(CEX hot wallets, bridges) or reaching the maximum hop count.
Attributes:
polygon_client: Client for Polygon blockchain queries.
entity_registry: Registry of known blockchain entities.
max_hops: Maximum number of hops to trace (default 3).
"""
def __init__(
self,
polygon_client: PolygonClient,
entity_registry: EntityRegistry | None = None,
*,
max_hops: int = 3,
usdc_addresses: list[str] | None = None,
) -> None:
"""Initialize the funding tracer.
Args:
polygon_client: Polygon blockchain client for queries.
entity_registry: Registry for entity classification. Creates default if None.
max_hops: Maximum hops to trace back (default 3).
usdc_addresses: USDC contract addresses to track. Uses defaults if None.
"""
self.polygon_client = polygon_client
self.entity_registry = entity_registry or EntityRegistry()
self.max_hops = max_hops
self._usdc_addresses = [
addr.lower() for addr in (usdc_addresses or [USDC_BRIDGED, USDC_NATIVE])
]
async def trace(
self,
address: str,
max_hops: int | None = None,
) -> FundingChain:
"""Trace the funding chain for a wallet.
Follows the first USDC transfer into the wallet, then recursively
traces the source wallet until reaching a known entity or max hops.
Args:
address: Target wallet address to trace.
max_hops: Override default max_hops for this trace.
Returns:
FundingChain with the complete trace result.
"""
effective_max_hops = max_hops if max_hops is not None else self.max_hops
normalized_address = address.lower()
chain: list[FundingTransfer] = []
current_address = normalized_address
origin_address = normalized_address
origin_type = "unknown"
for hop in range(effective_max_hops):
# Check if current address is a known entity
if self.entity_registry.is_terminal(current_address):
origin_address = current_address
origin_type = self.entity_registry.classify(current_address).value
logger.debug(
"Trace terminated at known entity: %s (%s)",
current_address,
origin_type,
)
break
# Get first USDC transfer into this address
transfer = await self.get_first_usdc_transfer(current_address)
if transfer is None:
logger.debug(
"No USDC transfer found for %s at hop %d",
current_address,
hop,
)
origin_address = current_address
break
chain.append(transfer)
origin_address = transfer.from_address
current_address = transfer.from_address
# Check if the source is a known entity
if self.entity_registry.is_terminal(origin_address):
origin_type = self.entity_registry.classify(origin_address).value
logger.debug(
"Trace found terminal entity: %s (%s)",
origin_address,
origin_type,
)
break
return FundingChain(
target_address=normalized_address,
chain=chain,
origin_address=origin_address,
origin_type=origin_type,
hop_count=len(chain),
traced_at=datetime.now(UTC),
)
async def get_first_usdc_transfer(
self,
address: str,
) -> FundingTransfer | None:
"""Get the first USDC transfer into a wallet.
Queries the blockchain for ERC20 Transfer events to the target
address for known USDC contracts.
Args:
address: Target wallet address.
Returns:
First FundingTransfer if found, None otherwise.
"""
normalized = address.lower()
# Query transfers for each USDC contract
for usdc_address in self._usdc_addresses:
transfer = await self._get_first_token_transfer(
to_address=normalized,
token_address=usdc_address,
)
if transfer is not None:
return transfer
return None
async def _get_first_token_transfer(
self,
to_address: str,
token_address: str,
) -> FundingTransfer | None:
"""Get the first ERC20 transfer to an address for a specific token.
Args:
to_address: Recipient wallet address.
token_address: ERC20 token contract address.
Returns:
First FundingTransfer if found, None otherwise.
"""
try:
logs = await self._get_transfer_logs(
to_address=to_address,
token_address=token_address,
limit=1,
)
except Exception as e:
logger.warning(
"Failed to get transfer logs for %s: %s",
to_address,
e,
)
return None
if not logs:
return None
log = logs[0]
return await self._log_to_funding_transfer(log, token_address)
async def _get_transfer_logs(
self,
to_address: str,
token_address: str,
limit: int = 10,
from_block: int | str = 0,
to_block: int | str = "latest",
) -> list[dict[str, Any]]:
"""Get ERC20 Transfer event logs.
Args:
to_address: Filter by recipient address.
token_address: ERC20 token contract address.
limit: Maximum logs to return.
from_block: Starting block number.
to_block: Ending block number.
Returns:
List of log dictionaries.
"""
# Pad address to 32 bytes for topic filter
padded_to = "0x" + to_address.lower().replace("0x", "").zfill(64)
await self.polygon_client._rate_limiter.acquire()
# Use the web3 instance from polygon client
w3 = (
self.polygon_client._w3
if self.polygon_client._primary_healthy
else (self.polygon_client._w3_fallback or self.polygon_client._w3)
)
# Get logs with Transfer event filtering by recipient
logs = await w3.eth.get_logs(
{
"address": AsyncWeb3.to_checksum_address(token_address),
"topics": [
TRANSFER_EVENT_SIGNATURE.hex(), # Transfer event
None, # from (any)
padded_to, # to (target address)
],
"fromBlock": from_block,
"toBlock": to_block,
}
)
# Convert to list of dicts and limit
result = [dict(log) for log in logs[:limit]]
return result
async def _log_to_funding_transfer(
self,
log: dict[str, Any],
token_address: str,
) -> FundingTransfer:
"""Convert a log entry to a FundingTransfer.
Args:
log: Log dictionary from get_logs.
token_address: Token contract address.
Returns:
FundingTransfer object.
"""
# Extract addresses from topics (padded to 32 bytes)
from_address = "0x" + log["topics"][1].hex()[-40:]
to_address = "0x" + log["topics"][2].hex()[-40:]
# Extract amount from data
amount = int(log["data"].hex(), 16)
# Get block timestamp
block_number = log["blockNumber"]
try:
block = await self.polygon_client.get_block(block_number)
timestamp = datetime.fromtimestamp(block["timestamp"], tz=UTC)
except Exception:
timestamp = datetime.now(UTC)
# Determine token symbol
token = "USDC" if token_address.lower() in self._usdc_addresses else "OTHER"
return FundingTransfer(
from_address=from_address.lower(),
to_address=to_address.lower(),
amount=Decimal(amount),
token=token,
tx_hash=log["transactionHash"].hex(),
block_number=block_number,
timestamp=timestamp,
)
async def get_funding_chains_batch(
self,
addresses: list[str],
max_hops: int | None = None,
) -> dict[str, FundingChain]:
"""Trace funding chains for multiple addresses concurrently.
Args:
addresses: List of wallet addresses to trace.
max_hops: Override default max_hops for all traces.
Returns:
Dictionary mapping address to FundingChain.
"""
tasks = [self.trace(addr, max_hops=max_hops) for addr in addresses]
results = await asyncio.gather(*tasks, return_exceptions=True)
chains: dict[str, FundingChain] = {}
for addr, result in zip(addresses, results, strict=True):
if isinstance(result, Exception):
logger.warning("Failed to trace %s: %s", addr, result)
chains[addr.lower()] = FundingChain(
target_address=addr.lower(),
origin_type="error",
)
else:
chains[addr.lower()] = result
return chains
def get_suspiciousness_score(self, chain: FundingChain) -> float:
"""Calculate a suspiciousness score based on funding chain.
Higher scores indicate more suspicious funding patterns:
- CEX origin: Lower suspicion (0.0-0.2)
- Bridge origin: Low suspicion (0.2-0.4)
- Unknown origin with few hops: High suspicion (0.8-1.0)
- Unknown origin with many hops: Medium suspicion (0.5-0.8)
Args:
chain: Funding chain to score.
Returns:
Suspiciousness score from 0.0 to 1.0.
"""
if chain.is_cex_origin:
# CEX origin is least suspicious
return 0.1
if chain.is_bridge_origin:
# Bridge origin is slightly more suspicious
return 0.3
# Unknown origin
if chain.hop_count == 0:
# No transfers found - very suspicious (possible contract or new wallet)
return 1.0
if chain.hop_count >= self.max_hops:
# Max hops reached without finding known entity
# More hops = more obfuscation = more suspicious
return 0.7
# Some hops but didn't reach max - moderately suspicious
return 0.5 + (0.3 * (1 - chain.hop_count / self.max_hops))
@@ -131,3 +131,87 @@ class WalletProfile:
# Weighted average: nonce is slightly more important
return 0.6 * nonce_score + 0.4 * age_score
@dataclass(frozen=True)
class FundingTransfer:
"""Represents an ERC20 token transfer for funding chain analysis.
Attributes:
from_address: Source wallet address.
to_address: Destination wallet address.
amount: Transfer amount in token decimals.
token: Token symbol (e.g., "USDC", "MATIC").
tx_hash: Transaction hash.
block_number: Block number of the transaction.
timestamp: Timestamp of the transaction.
"""
from_address: str
to_address: str
amount: Decimal
token: str
tx_hash: str
block_number: int
timestamp: datetime
@property
def amount_formatted(self) -> Decimal:
"""Return amount in human-readable format.
Assumes 6 decimals for USDC/USDT, 18 for others.
"""
if self.token in ("USDC", "USDT"):
return self.amount / Decimal("1000000")
return self.amount / Decimal("1000000000000000000")
@dataclass
class FundingChain:
"""Result of funding chain analysis.
Represents the path of funds from origin to target wallet,
tracing back through intermediate wallets.
Attributes:
target_address: The wallet being analyzed.
chain: Ordered list of transfers from target back to origin.
origin_address: The ultimate source of funds.
origin_type: Classification of the origin (cex, bridge, unknown, contract).
hop_count: Number of hops from target to origin.
traced_at: When the trace was performed.
"""
target_address: str
chain: list[FundingTransfer] = field(default_factory=list)
origin_address: str = ""
origin_type: str = "unknown"
hop_count: int = 0
traced_at: datetime = field(default_factory=lambda: datetime.now(UTC))
@property
def is_cex_origin(self) -> bool:
"""Return True if funds originated from a CEX."""
return self.origin_type.startswith("cex")
@property
def is_bridge_origin(self) -> bool:
"""Return True if funds came through a bridge."""
return self.origin_type.startswith("bridge")
@property
def is_unknown_origin(self) -> bool:
"""Return True if origin could not be determined."""
return self.origin_type == "unknown"
@property
def total_amount(self) -> Decimal:
"""Return total amount transferred in the chain."""
if not self.chain:
return Decimal("0")
return self.chain[0].amount
@property
def funding_depth(self) -> int:
"""Return the funding depth (hops from known entity)."""
return self.hop_count
@@ -1 +1,45 @@
"""Storage layer - Database schemas and repositories."""
from polymarket_insider_tracker.storage.database import (
DatabaseManager,
create_async_db_engine,
create_async_session_factory,
create_sync_engine,
create_sync_session_factory,
init_async_db,
init_db,
)
from polymarket_insider_tracker.storage.models import (
Base,
FundingTransferModel,
WalletProfileModel,
WalletRelationshipModel,
)
from polymarket_insider_tracker.storage.repos import (
FundingRepository,
FundingTransferDTO,
RelationshipRepository,
WalletProfileDTO,
WalletRelationshipDTO,
WalletRepository,
)
__all__ = [
"Base",
"DatabaseManager",
"FundingRepository",
"FundingTransferDTO",
"FundingTransferModel",
"RelationshipRepository",
"WalletProfileDTO",
"WalletProfileModel",
"WalletRelationshipDTO",
"WalletRelationshipModel",
"WalletRepository",
"create_async_db_engine",
"create_async_session_factory",
"create_sync_engine",
"create_sync_session_factory",
"init_async_db",
"init_db",
]
@@ -0,0 +1,209 @@
"""Database connection and session management.
This module provides the database engine, session factory, and
async session support for the storage layer.
"""
from __future__ import annotations
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import Session, sessionmaker
from polymarket_insider_tracker.storage.models import Base
if TYPE_CHECKING:
from sqlalchemy import Engine
from sqlalchemy.ext.asyncio import AsyncEngine
logger = logging.getLogger(__name__)
def create_sync_engine(database_url: str, **kwargs: Any) -> Engine:
"""Create a synchronous SQLAlchemy engine.
Args:
database_url: Database connection URL (e.g., postgresql://...).
**kwargs: Additional engine options.
Returns:
SQLAlchemy Engine instance.
"""
return create_engine(database_url, **kwargs)
def create_async_db_engine(database_url: str, **kwargs: Any) -> AsyncEngine:
"""Create an asynchronous SQLAlchemy engine.
Args:
database_url: Database connection URL (e.g., postgresql+asyncpg://...).
**kwargs: Additional engine options.
Returns:
SQLAlchemy AsyncEngine instance.
"""
return create_async_engine(database_url, **kwargs)
def create_sync_session_factory(engine: Engine) -> sessionmaker[Session]:
"""Create a synchronous session factory.
Args:
engine: SQLAlchemy Engine instance.
Returns:
Session factory.
"""
return sessionmaker(bind=engine, expire_on_commit=False)
def create_async_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
"""Create an asynchronous session factory.
Args:
engine: SQLAlchemy AsyncEngine instance.
Returns:
Async session factory.
"""
return async_sessionmaker(bind=engine, expire_on_commit=False)
def init_db(engine: Engine) -> None:
"""Initialize the database schema.
Creates all tables defined in the models.
Args:
engine: SQLAlchemy Engine instance.
"""
Base.metadata.create_all(engine)
logger.info("Database schema initialized")
async def init_async_db(engine: AsyncEngine) -> None:
"""Initialize the database schema asynchronously.
Creates all tables defined in the models.
Args:
engine: SQLAlchemy AsyncEngine instance.
"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database schema initialized (async)")
class DatabaseManager:
"""Manages database connections and sessions.
Provides a unified interface for both sync and async database operations.
"""
def __init__(
self,
database_url: str,
*,
async_mode: bool = True,
pool_size: int = 5,
max_overflow: int = 10,
echo: bool = False,
) -> None:
"""Initialize database manager.
Args:
database_url: Database connection URL.
async_mode: Use async engine/sessions if True.
pool_size: Connection pool size.
max_overflow: Maximum overflow connections.
echo: Echo SQL statements for debugging.
"""
self.database_url = database_url
self.async_mode = async_mode
self._pool_size = pool_size
self._max_overflow = max_overflow
self._echo = echo
self._sync_engine: Engine | None = None
self._async_engine: AsyncEngine | None = None
self._sync_session_factory: sessionmaker[Session] | None = None
self._async_session_factory: async_sessionmaker[AsyncSession] | None = None
def _get_sync_engine(self) -> Engine:
"""Get or create the synchronous engine."""
if self._sync_engine is None:
self._sync_engine = create_sync_engine(
self.database_url,
pool_size=self._pool_size,
max_overflow=self._max_overflow,
echo=self._echo,
)
return self._sync_engine
def _get_async_engine(self) -> AsyncEngine:
"""Get or create the asynchronous engine."""
if self._async_engine is None:
self._async_engine = create_async_db_engine(
self.database_url,
pool_size=self._pool_size,
max_overflow=self._max_overflow,
echo=self._echo,
)
return self._async_engine
def get_sync_session(self) -> Session:
"""Get a new synchronous session.
Returns:
SQLAlchemy Session instance.
"""
if self._sync_session_factory is None:
self._sync_session_factory = create_sync_session_factory(self._get_sync_engine())
return self._sync_session_factory()
@asynccontextmanager
async def get_async_session(self) -> AsyncGenerator[AsyncSession, None]:
"""Get an asynchronous session as a context manager.
Yields:
SQLAlchemy AsyncSession instance.
"""
if self._async_session_factory is None:
self._async_session_factory = create_async_session_factory(self._get_async_engine())
session = self._async_session_factory()
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
def init_schema(self) -> None:
"""Initialize database schema synchronously."""
init_db(self._get_sync_engine())
async def init_schema_async(self) -> None:
"""Initialize database schema asynchronously."""
await init_async_db(self._get_async_engine())
def dispose(self) -> None:
"""Dispose of all database connections."""
if self._sync_engine is not None:
self._sync_engine.dispose()
self._sync_engine = None
logger.info("Database connections disposed")
async def dispose_async(self) -> None:
"""Dispose of all async database connections."""
if self._async_engine is not None:
await self._async_engine.dispose()
self._async_engine = None
logger.info("Async database connections disposed")
@@ -0,0 +1,115 @@
"""SQLAlchemy models for persistent storage.
This module defines the database schema for storing wallet profiles,
funding transfers, and wallet relationships.
"""
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import (
Boolean,
DateTime,
Index,
Integer,
Numeric,
String,
UniqueConstraint,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
if TYPE_CHECKING:
pass
class Base(DeclarativeBase):
"""Base class for all SQLAlchemy models."""
pass
class WalletProfileModel(Base):
"""SQLAlchemy model for wallet profiles.
Stores analyzed wallet information including age, transaction count,
balances, and freshness classification.
"""
__tablename__ = "wallet_profiles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
address: Mapped[str] = mapped_column(String(42), unique=True, nullable=False)
nonce: Mapped[int] = mapped_column(Integer, nullable=False)
first_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
is_fresh: Mapped[bool] = mapped_column(Boolean, nullable=False)
matic_balance: Mapped[Decimal | None] = mapped_column(Numeric(30, 0), nullable=True)
usdc_balance: Mapped[Decimal | None] = mapped_column(Numeric(20, 6), nullable=True)
analyzed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
__table_args__ = (Index("idx_wallet_profiles_address", "address"),)
class FundingTransferModel(Base):
"""SQLAlchemy model for funding transfers.
Stores ERC20 transfer events to track wallet funding sources.
"""
__tablename__ = "funding_transfers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
from_address: Mapped[str] = mapped_column(String(42), nullable=False)
to_address: Mapped[str] = mapped_column(String(42), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(30, 6), nullable=False)
token: Mapped[str] = mapped_column(String(10), nullable=False)
tx_hash: Mapped[str] = mapped_column(String(66), unique=True, nullable=False)
block_number: Mapped[int] = mapped_column(Integer, nullable=False)
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
)
__table_args__ = (
Index("idx_funding_transfers_to", "to_address"),
Index("idx_funding_transfers_from", "from_address"),
Index("idx_funding_transfers_block", "block_number"),
)
class WalletRelationshipModel(Base):
"""SQLAlchemy model for wallet relationships.
Stores graph edges between wallets representing funding relationships
or entity linkages.
"""
__tablename__ = "wallet_relationships"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
wallet_a: Mapped[str] = mapped_column(String(42), nullable=False)
wallet_b: Mapped[str] = mapped_column(String(42), nullable=False)
relationship_type: Mapped[str] = mapped_column(String(20), nullable=False)
confidence: Mapped[Decimal] = mapped_column(Numeric(3, 2), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
)
__table_args__ = (
UniqueConstraint("wallet_a", "wallet_b", "relationship_type", name="uq_wallet_relationship"),
Index("idx_wallet_relationships_a", "wallet_a"),
Index("idx_wallet_relationships_b", "wallet_b"),
)
@@ -0,0 +1,515 @@
"""Repository pattern implementations for data access.
This module provides clean data access abstractions for wallet profiles,
funding transfers, and wallet relationships.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import delete, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from polymarket_insider_tracker.storage.models import (
FundingTransferModel,
WalletProfileModel,
WalletRelationshipModel,
)
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
@dataclass
class WalletProfileDTO:
"""Data transfer object for wallet profiles."""
address: str
nonce: int
first_seen_at: datetime | None
is_fresh: bool
matic_balance: Decimal | None
usdc_balance: Decimal | None
analyzed_at: datetime
created_at: datetime | None = None
updated_at: datetime | None = None
@classmethod
def from_model(cls, model: WalletProfileModel) -> WalletProfileDTO:
"""Create DTO from SQLAlchemy model."""
return cls(
address=model.address,
nonce=model.nonce,
first_seen_at=model.first_seen_at,
is_fresh=model.is_fresh,
matic_balance=model.matic_balance,
usdc_balance=model.usdc_balance,
analyzed_at=model.analyzed_at,
created_at=model.created_at,
updated_at=model.updated_at,
)
@dataclass
class FundingTransferDTO:
"""Data transfer object for funding transfers."""
from_address: str
to_address: str
amount: Decimal
token: str
tx_hash: str
block_number: int
timestamp: datetime
created_at: datetime | None = None
@classmethod
def from_model(cls, model: FundingTransferModel) -> FundingTransferDTO:
"""Create DTO from SQLAlchemy model."""
return cls(
from_address=model.from_address,
to_address=model.to_address,
amount=model.amount,
token=model.token,
tx_hash=model.tx_hash,
block_number=model.block_number,
timestamp=model.timestamp,
created_at=model.created_at,
)
@dataclass
class WalletRelationshipDTO:
"""Data transfer object for wallet relationships."""
wallet_a: str
wallet_b: str
relationship_type: str
confidence: Decimal
created_at: datetime | None = None
@classmethod
def from_model(cls, model: WalletRelationshipModel) -> WalletRelationshipDTO:
"""Create DTO from SQLAlchemy model."""
return cls(
wallet_a=model.wallet_a,
wallet_b=model.wallet_b,
relationship_type=model.relationship_type,
confidence=model.confidence,
created_at=model.created_at,
)
class WalletRepository:
"""Repository for wallet profile data access.
Provides CRUD operations for wallet profiles with async support.
"""
def __init__(self, session: AsyncSession) -> None:
"""Initialize repository with database session.
Args:
session: SQLAlchemy async session.
"""
self.session = session
async def get_by_address(self, address: str) -> WalletProfileDTO | None:
"""Get wallet profile by address.
Args:
address: Wallet address (lowercase).
Returns:
WalletProfileDTO if found, None otherwise.
"""
result = await self.session.execute(
select(WalletProfileModel).where(WalletProfileModel.address == address.lower())
)
model = result.scalar_one_or_none()
return WalletProfileDTO.from_model(model) if model else None
async def get_many(self, addresses: list[str]) -> list[WalletProfileDTO]:
"""Get multiple wallet profiles by addresses.
Args:
addresses: List of wallet addresses.
Returns:
List of WalletProfileDTOs for found addresses.
"""
normalized = [addr.lower() for addr in addresses]
result = await self.session.execute(
select(WalletProfileModel).where(WalletProfileModel.address.in_(normalized))
)
return [WalletProfileDTO.from_model(m) for m in result.scalars().all()]
async def get_fresh_wallets(self, limit: int = 100) -> list[WalletProfileDTO]:
"""Get recent fresh wallets.
Args:
limit: Maximum number of results.
Returns:
List of WalletProfileDTOs marked as fresh.
"""
result = await self.session.execute(
select(WalletProfileModel)
.where(WalletProfileModel.is_fresh.is_(True))
.order_by(WalletProfileModel.analyzed_at.desc())
.limit(limit)
)
return [WalletProfileDTO.from_model(m) for m in result.scalars().all()]
async def upsert(self, dto: WalletProfileDTO) -> WalletProfileDTO:
"""Insert or update wallet profile.
Args:
dto: Wallet profile data.
Returns:
Updated WalletProfileDTO.
"""
now = datetime.now(UTC)
values = {
"address": dto.address.lower(),
"nonce": dto.nonce,
"first_seen_at": dto.first_seen_at,
"is_fresh": dto.is_fresh,
"matic_balance": dto.matic_balance,
"usdc_balance": dto.usdc_balance,
"analyzed_at": dto.analyzed_at,
"updated_at": now,
}
# Try PostgreSQL upsert first, fall back to SQLite for testing
try:
stmt = pg_insert(WalletProfileModel).values(**values, created_at=now)
stmt = stmt.on_conflict_do_update(
index_elements=["address"],
set_={
"nonce": stmt.excluded.nonce,
"first_seen_at": stmt.excluded.first_seen_at,
"is_fresh": stmt.excluded.is_fresh,
"matic_balance": stmt.excluded.matic_balance,
"usdc_balance": stmt.excluded.usdc_balance,
"analyzed_at": stmt.excluded.analyzed_at,
"updated_at": stmt.excluded.updated_at,
},
)
await self.session.execute(stmt)
except Exception:
# Fall back to SQLite upsert for testing
stmt = sqlite_insert(WalletProfileModel).values(**values, created_at=now)
stmt = stmt.on_conflict_do_update(
index_elements=["address"],
set_={
"nonce": stmt.excluded.nonce,
"first_seen_at": stmt.excluded.first_seen_at,
"is_fresh": stmt.excluded.is_fresh,
"matic_balance": stmt.excluded.matic_balance,
"usdc_balance": stmt.excluded.usdc_balance,
"analyzed_at": stmt.excluded.analyzed_at,
"updated_at": stmt.excluded.updated_at,
},
)
await self.session.execute(stmt)
await self.session.flush()
return dto
async def delete(self, address: str) -> bool:
"""Delete wallet profile by address.
Args:
address: Wallet address.
Returns:
True if deleted, False if not found.
"""
result = await self.session.execute(
delete(WalletProfileModel).where(WalletProfileModel.address == address.lower())
)
return result.rowcount > 0
async def mark_stale(self, address: str) -> bool:
"""Mark a wallet profile as stale (soft delete).
Sets analyzed_at to a very old date to trigger re-analysis.
Args:
address: Wallet address.
Returns:
True if updated, False if not found.
"""
stale_time = datetime(2000, 1, 1, tzinfo=UTC)
result = await self.session.execute(
update(WalletProfileModel)
.where(WalletProfileModel.address == address.lower())
.values(analyzed_at=stale_time, updated_at=datetime.now(UTC))
)
return result.rowcount > 0
class FundingRepository:
"""Repository for funding transfer data access.
Provides CRUD operations for funding transfers with async support.
"""
def __init__(self, session: AsyncSession) -> None:
"""Initialize repository with database session.
Args:
session: SQLAlchemy async session.
"""
self.session = session
async def get_transfers_to(
self, address: str, limit: int = 100
) -> list[FundingTransferDTO]:
"""Get transfers to a wallet address.
Args:
address: Destination wallet address.
limit: Maximum number of results.
Returns:
List of FundingTransferDTOs ordered by timestamp.
"""
result = await self.session.execute(
select(FundingTransferModel)
.where(FundingTransferModel.to_address == address.lower())
.order_by(FundingTransferModel.timestamp.asc())
.limit(limit)
)
return [FundingTransferDTO.from_model(m) for m in result.scalars().all()]
async def get_transfers_from(
self, address: str, limit: int = 100
) -> list[FundingTransferDTO]:
"""Get transfers from a wallet address.
Args:
address: Source wallet address.
limit: Maximum number of results.
Returns:
List of FundingTransferDTOs ordered by timestamp.
"""
result = await self.session.execute(
select(FundingTransferModel)
.where(FundingTransferModel.from_address == address.lower())
.order_by(FundingTransferModel.timestamp.asc())
.limit(limit)
)
return [FundingTransferDTO.from_model(m) for m in result.scalars().all()]
async def get_first_transfer_to(self, address: str) -> FundingTransferDTO | None:
"""Get the first transfer to a wallet.
Args:
address: Wallet address.
Returns:
First FundingTransferDTO if found, None otherwise.
"""
result = await self.session.execute(
select(FundingTransferModel)
.where(FundingTransferModel.to_address == address.lower())
.order_by(FundingTransferModel.timestamp.asc())
.limit(1)
)
model = result.scalar_one_or_none()
return FundingTransferDTO.from_model(model) if model else None
async def get_by_tx_hash(self, tx_hash: str) -> FundingTransferDTO | None:
"""Get transfer by transaction hash.
Args:
tx_hash: Transaction hash.
Returns:
FundingTransferDTO if found, None otherwise.
"""
result = await self.session.execute(
select(FundingTransferModel).where(FundingTransferModel.tx_hash == tx_hash.lower())
)
model = result.scalar_one_or_none()
return FundingTransferDTO.from_model(model) if model else None
async def insert(self, dto: FundingTransferDTO) -> FundingTransferDTO:
"""Insert a new funding transfer.
Args:
dto: Funding transfer data.
Returns:
Inserted FundingTransferDTO.
Raises:
IntegrityError if tx_hash already exists.
"""
model = FundingTransferModel(
from_address=dto.from_address.lower(),
to_address=dto.to_address.lower(),
amount=dto.amount,
token=dto.token,
tx_hash=dto.tx_hash.lower(),
block_number=dto.block_number,
timestamp=dto.timestamp,
)
self.session.add(model)
await self.session.flush()
return dto
async def insert_many(self, dtos: list[FundingTransferDTO]) -> int:
"""Insert multiple funding transfers.
Skips duplicates silently.
Args:
dtos: List of funding transfer data.
Returns:
Number of transfers inserted.
"""
inserted = 0
for dto in dtos:
try:
await self.insert(dto)
inserted += 1
except Exception as e:
# Skip duplicates
if "UNIQUE constraint" in str(e) or "duplicate key" in str(e).lower():
continue
raise
return inserted
class RelationshipRepository:
"""Repository for wallet relationship data access.
Provides CRUD operations for wallet relationships with async support.
"""
def __init__(self, session: AsyncSession) -> None:
"""Initialize repository with database session.
Args:
session: SQLAlchemy async session.
"""
self.session = session
async def get_relationships(
self, wallet: str, relationship_type: str | None = None
) -> list[WalletRelationshipDTO]:
"""Get relationships for a wallet.
Args:
wallet: Wallet address.
relationship_type: Optional filter by type.
Returns:
List of WalletRelationshipDTOs.
"""
stmt = select(WalletRelationshipModel).where(
(WalletRelationshipModel.wallet_a == wallet.lower())
| (WalletRelationshipModel.wallet_b == wallet.lower())
)
if relationship_type:
stmt = stmt.where(WalletRelationshipModel.relationship_type == relationship_type)
result = await self.session.execute(stmt)
return [WalletRelationshipDTO.from_model(m) for m in result.scalars().all()]
async def get_related_wallets(
self, wallet: str, relationship_type: str | None = None
) -> list[str]:
"""Get addresses of related wallets.
Args:
wallet: Wallet address.
relationship_type: Optional filter by type.
Returns:
List of related wallet addresses.
"""
relationships = await self.get_relationships(wallet, relationship_type)
related = set()
normalized = wallet.lower()
for rel in relationships:
if rel.wallet_a == normalized:
related.add(rel.wallet_b)
else:
related.add(rel.wallet_a)
return list(related)
async def upsert(self, dto: WalletRelationshipDTO) -> WalletRelationshipDTO:
"""Insert or update wallet relationship.
Args:
dto: Wallet relationship data.
Returns:
Updated WalletRelationshipDTO.
"""
now = datetime.now(UTC)
values = {
"wallet_a": dto.wallet_a.lower(),
"wallet_b": dto.wallet_b.lower(),
"relationship_type": dto.relationship_type,
"confidence": dto.confidence,
"created_at": now,
}
# Try PostgreSQL upsert first, fall back to SQLite for testing
try:
stmt = pg_insert(WalletRelationshipModel).values(**values)
stmt = stmt.on_conflict_do_update(
constraint="uq_wallet_relationship",
set_={"confidence": stmt.excluded.confidence},
)
await self.session.execute(stmt)
except Exception:
# Fall back to SQLite upsert for testing
stmt = sqlite_insert(WalletRelationshipModel).values(**values)
stmt = stmt.on_conflict_do_update(
index_elements=["wallet_a", "wallet_b", "relationship_type"],
set_={"confidence": stmt.excluded.confidence},
)
await self.session.execute(stmt)
await self.session.flush()
return dto
async def delete(
self, wallet_a: str, wallet_b: str, relationship_type: str
) -> bool:
"""Delete a specific relationship.
Args:
wallet_a: First wallet address.
wallet_b: Second wallet address.
relationship_type: Type of relationship.
Returns:
True if deleted, False if not found.
"""
result = await self.session.execute(
delete(WalletRelationshipModel).where(
WalletRelationshipModel.wallet_a == wallet_a.lower(),
WalletRelationshipModel.wallet_b == wallet_b.lower(),
WalletRelationshipModel.relationship_type == relationship_type,
)
)
return result.rowcount > 0
+1 -1
View File
@@ -1 +1 @@
"""Tests for alerter module."""
"""Tests for the alerter module."""
+506
View File
@@ -0,0 +1,506 @@
"""Tests for alert dispatcher and channels."""
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from polymarket_insider_tracker.alerter.channels.discord import DiscordChannel
from polymarket_insider_tracker.alerter.channels.telegram import TelegramChannel
from polymarket_insider_tracker.alerter.dispatcher import (
AlertDispatcher,
CircuitBreakerState,
DispatchResult,
)
from polymarket_insider_tracker.alerter.models import FormattedAlert
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def sample_alert() -> FormattedAlert:
"""Create a sample formatted alert."""
return FormattedAlert(
title="Test Alert",
body="Test body",
discord_embed={
"title": "Test",
"color": 15158332,
"fields": [],
},
telegram_markdown="*Test Alert*\nTest body",
plain_text="TEST ALERT\nTest body",
links={"market": "https://polymarket.com/test"},
)
@pytest.fixture
def mock_discord_channel() -> MagicMock:
"""Create a mock Discord channel."""
channel = MagicMock()
channel.name = "discord"
channel.send = AsyncMock(return_value=True)
return channel
@pytest.fixture
def mock_telegram_channel() -> MagicMock:
"""Create a mock Telegram channel."""
channel = MagicMock()
channel.name = "telegram"
channel.send = AsyncMock(return_value=True)
return channel
# ============================================================================
# DiscordChannel Tests
# ============================================================================
class TestDiscordChannel:
"""Tests for Discord channel."""
def test_init(self) -> None:
"""Test channel initialization."""
channel = DiscordChannel(
webhook_url="https://discord.com/api/webhooks/123/abc",
rate_limit_per_minute=30,
)
assert channel.webhook_url == "https://discord.com/api/webhooks/123/abc"
assert channel.rate_limit_per_minute == 30
assert channel.name == "discord"
@pytest.mark.asyncio
async def test_send_success(self, sample_alert: FormattedAlert) -> None:
"""Test successful Discord message send."""
channel = DiscordChannel(
webhook_url="https://discord.com/api/webhooks/123/abc"
)
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 204
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await channel.send(sample_alert)
assert result is True
mock_client.post.assert_called_once()
@pytest.mark.asyncio
async def test_send_rate_limited(self, sample_alert: FormattedAlert) -> None:
"""Test Discord rate limit handling."""
channel = DiscordChannel(
webhook_url="https://discord.com/api/webhooks/123/abc",
max_retries=2,
retry_delay=0.01,
)
with patch("httpx.AsyncClient") as mock_client_class:
mock_response_429 = MagicMock()
mock_response_429.status_code = 429
mock_response_429.json.return_value = {"retry_after": 0.01}
mock_response_success = MagicMock()
mock_response_success.status_code = 204
mock_client = AsyncMock()
mock_client.post.side_effect = [mock_response_429, mock_response_success]
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await channel.send(sample_alert)
assert result is True
assert mock_client.post.call_count == 2
@pytest.mark.asyncio
async def test_send_failure(self, sample_alert: FormattedAlert) -> None:
"""Test Discord send failure after retries."""
channel = DiscordChannel(
webhook_url="https://discord.com/api/webhooks/123/abc",
max_retries=2,
retry_delay=0.01,
)
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = "Internal Server Error"
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await channel.send(sample_alert)
assert result is False
# ============================================================================
# TelegramChannel Tests
# ============================================================================
class TestTelegramChannel:
"""Tests for Telegram channel."""
def test_init(self) -> None:
"""Test channel initialization."""
channel = TelegramChannel(
bot_token="123456:ABC-DEF",
chat_id="-1001234567890",
rate_limit_per_minute=20,
)
assert channel.bot_token == "123456:ABC-DEF"
assert channel.chat_id == "-1001234567890"
assert channel.name == "telegram"
@pytest.mark.asyncio
async def test_send_success(self, sample_alert: FormattedAlert) -> None:
"""Test successful Telegram message send."""
channel = TelegramChannel(
bot_token="123456:ABC-DEF",
chat_id="-1001234567890",
)
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.json.return_value = {"ok": True}
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await channel.send(sample_alert)
assert result is True
@pytest.mark.asyncio
async def test_send_rate_limited(self, sample_alert: FormattedAlert) -> None:
"""Test Telegram rate limit handling."""
channel = TelegramChannel(
bot_token="123456:ABC-DEF",
chat_id="-1001234567890",
max_retries=2,
retry_delay=0.01,
)
with patch("httpx.AsyncClient") as mock_client_class:
mock_response_429 = MagicMock()
mock_response_429.json.return_value = {
"ok": False,
"error_code": 429,
"parameters": {"retry_after": 0.01},
}
mock_response_success = MagicMock()
mock_response_success.json.return_value = {"ok": True}
mock_client = AsyncMock()
mock_client.post.side_effect = [mock_response_429, mock_response_success]
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await channel.send(sample_alert)
assert result is True
@pytest.mark.asyncio
async def test_send_failure(self, sample_alert: FormattedAlert) -> None:
"""Test Telegram send failure."""
channel = TelegramChannel(
bot_token="123456:ABC-DEF",
chat_id="-1001234567890",
max_retries=2,
retry_delay=0.01,
)
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.json.return_value = {
"ok": False,
"error_code": 400,
"description": "Bad Request",
}
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await channel.send(sample_alert)
assert result is False
# ============================================================================
# CircuitBreakerState Tests
# ============================================================================
class TestCircuitBreakerState:
"""Tests for circuit breaker state."""
def test_default_state(self) -> None:
"""Test default circuit breaker state."""
state = CircuitBreakerState()
assert state.failure_count == 0
assert state.is_open is False
assert state.half_open_attempts == 0
assert state.last_failure_time is None
# ============================================================================
# DispatchResult Tests
# ============================================================================
class TestDispatchResult:
"""Tests for dispatch result."""
def test_all_succeeded(self) -> None:
"""Test all_succeeded property."""
result = DispatchResult(
success_count=2,
failure_count=0,
channel_results={"discord": True, "telegram": True},
)
assert result.all_succeeded is True
def test_partial_success(self) -> None:
"""Test partial success."""
result = DispatchResult(
success_count=1,
failure_count=1,
channel_results={"discord": True, "telegram": False},
)
assert result.all_succeeded is False
def test_empty_channels(self) -> None:
"""Test with no channels."""
result = DispatchResult(success_count=0, failure_count=0)
assert result.all_succeeded is False
# ============================================================================
# AlertDispatcher Tests
# ============================================================================
class TestAlertDispatcher:
"""Tests for alert dispatcher."""
def test_init(
self,
mock_discord_channel: MagicMock,
mock_telegram_channel: MagicMock,
) -> None:
"""Test dispatcher initialization."""
dispatcher = AlertDispatcher(
channels=[mock_discord_channel, mock_telegram_channel]
)
assert len(dispatcher.channels) == 2
assert "discord" in dispatcher._circuit_state
assert "telegram" in dispatcher._circuit_state
@pytest.mark.asyncio
async def test_dispatch_all_success(
self,
sample_alert: FormattedAlert,
mock_discord_channel: MagicMock,
mock_telegram_channel: MagicMock,
) -> None:
"""Test successful dispatch to all channels."""
dispatcher = AlertDispatcher(
channels=[mock_discord_channel, mock_telegram_channel]
)
result = await dispatcher.dispatch(sample_alert)
assert result.success_count == 2
assert result.failure_count == 0
assert result.all_succeeded is True
@pytest.mark.asyncio
async def test_dispatch_partial_failure(
self,
sample_alert: FormattedAlert,
mock_discord_channel: MagicMock,
mock_telegram_channel: MagicMock,
) -> None:
"""Test dispatch with one channel failing."""
mock_telegram_channel.send.return_value = False
dispatcher = AlertDispatcher(
channels=[mock_discord_channel, mock_telegram_channel]
)
result = await dispatcher.dispatch(sample_alert)
assert result.success_count == 1
assert result.failure_count == 1
assert result.channel_results["discord"] is True
assert result.channel_results["telegram"] is False
@pytest.mark.asyncio
async def test_dispatch_no_channels(
self, sample_alert: FormattedAlert
) -> None:
"""Test dispatch with no channels configured."""
dispatcher = AlertDispatcher(channels=[])
result = await dispatcher.dispatch(sample_alert)
assert result.success_count == 0
assert result.failure_count == 0
@pytest.mark.asyncio
async def test_circuit_opens_after_failures(
self,
sample_alert: FormattedAlert,
mock_discord_channel: MagicMock,
) -> None:
"""Test circuit breaker opens after threshold failures."""
mock_discord_channel.send.return_value = False
dispatcher = AlertDispatcher(
channels=[mock_discord_channel],
failure_threshold=3,
)
# First 3 failures
for _ in range(3):
await dispatcher.dispatch(sample_alert)
# Circuit should be open now
assert dispatcher._circuit_state["discord"].is_open is True
assert dispatcher._circuit_state["discord"].failure_count == 3
@pytest.mark.asyncio
async def test_circuit_skips_when_open(
self,
sample_alert: FormattedAlert,
mock_discord_channel: MagicMock,
) -> None:
"""Test that open circuit skips delivery."""
dispatcher = AlertDispatcher(
channels=[mock_discord_channel],
failure_threshold=3,
recovery_timeout_seconds=3600, # Long timeout
)
# Manually open the circuit
dispatcher._circuit_state["discord"].is_open = True
dispatcher._circuit_state["discord"].last_failure_time = datetime.now(UTC)
result = await dispatcher.dispatch(sample_alert)
assert result.channel_results["discord"] is False
# send() should not be called
mock_discord_channel.send.assert_not_called()
@pytest.mark.asyncio
async def test_circuit_closes_on_success(
self,
sample_alert: FormattedAlert,
mock_discord_channel: MagicMock,
) -> None:
"""Test circuit closes on successful delivery."""
mock_discord_channel.send.return_value = False
dispatcher = AlertDispatcher(
channels=[mock_discord_channel],
failure_threshold=2,
)
# Cause failures to open circuit
await dispatcher.dispatch(sample_alert)
await dispatcher.dispatch(sample_alert)
assert dispatcher._circuit_state["discord"].is_open is True
# Now succeed
mock_discord_channel.send.return_value = True
# Force half-open by resetting last_failure to past
dispatcher._circuit_state["discord"].last_failure_time = datetime(
2020, 1, 1, tzinfo=UTC
)
result = await dispatcher.dispatch(sample_alert)
assert result.channel_results["discord"] is True
assert dispatcher._circuit_state["discord"].is_open is False
@pytest.mark.asyncio
async def test_dispatch_batch(
self,
sample_alert: FormattedAlert,
mock_discord_channel: MagicMock,
) -> None:
"""Test batch dispatch."""
dispatcher = AlertDispatcher(channels=[mock_discord_channel])
alerts = [sample_alert, sample_alert, sample_alert]
results = await dispatcher.dispatch_batch(alerts)
assert len(results) == 3
assert all(r.success_count == 1 for r in results)
def test_get_circuit_status(
self,
mock_discord_channel: MagicMock,
mock_telegram_channel: MagicMock,
) -> None:
"""Test getting circuit status."""
dispatcher = AlertDispatcher(
channels=[mock_discord_channel, mock_telegram_channel]
)
status = dispatcher.get_circuit_status()
assert "discord" in status
assert "telegram" in status
assert status["discord"]["is_open"] is False
assert status["discord"]["failure_count"] == 0
def test_reset_circuit(
self,
mock_discord_channel: MagicMock,
) -> None:
"""Test manual circuit reset."""
dispatcher = AlertDispatcher(channels=[mock_discord_channel])
# Set up failure state
dispatcher._circuit_state["discord"].failure_count = 5
dispatcher._circuit_state["discord"].is_open = True
# Reset
result = dispatcher.reset_circuit("discord")
assert result is True
assert dispatcher._circuit_state["discord"].failure_count == 0
assert dispatcher._circuit_state["discord"].is_open is False
def test_reset_circuit_unknown_channel(
self,
mock_discord_channel: MagicMock,
) -> None:
"""Test reset with unknown channel name."""
dispatcher = AlertDispatcher(channels=[mock_discord_channel])
result = dispatcher.reset_circuit("unknown")
assert result is False
+621
View File
@@ -0,0 +1,621 @@
"""Tests for alert message formatter."""
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from polymarket_insider_tracker.alerter.formatter import (
COLOR_HIGH_RISK,
COLOR_LOW_RISK,
COLOR_MEDIUM_RISK,
AlertFormatter,
format_usdc,
get_risk_color,
get_risk_level,
get_triggered_signals,
truncate_address,
)
from polymarket_insider_tracker.alerter.models import FormattedAlert
from polymarket_insider_tracker.detector.models import (
FreshWalletSignal,
RiskAssessment,
SizeAnomalySignal,
)
from polymarket_insider_tracker.ingestor.models import MarketMetadata, Token, TradeEvent
from polymarket_insider_tracker.profiler.models import WalletProfile
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def sample_trade() -> TradeEvent:
"""Create a sample trade event."""
return TradeEvent(
market_id="market_abc123",
trade_id="tx_001",
wallet_address="0x1234567890abcdef1234567890abcdef12345678",
side="BUY",
outcome="Yes",
outcome_index=0,
price=Decimal("0.075"),
size=Decimal("200000"),
timestamp=datetime.now(UTC),
asset_id="token_123",
market_slug="will-x-happen",
event_title="Will X happen by Y?",
)
@pytest.fixture
def sample_wallet_profile() -> WalletProfile:
"""Create a sample wallet profile."""
return WalletProfile(
address="0x1234567890abcdef1234567890abcdef12345678",
nonce=2,
first_seen=datetime.now(UTC),
age_hours=2.0,
is_fresh=True,
total_tx_count=2,
matic_balance=Decimal("1000000000000000000"),
usdc_balance=Decimal("1000000"),
)
@pytest.fixture
def sample_metadata() -> MarketMetadata:
"""Create sample market metadata."""
return MarketMetadata(
condition_id="market_abc123",
question="Will X happen by Y?",
description="Test market description",
tokens=(Token(token_id="token_123", outcome="Yes", price=Decimal("0.075")),),
category="other",
)
@pytest.fixture
def fresh_wallet_signal(
sample_trade: TradeEvent, sample_wallet_profile: WalletProfile
) -> FreshWalletSignal:
"""Create a sample fresh wallet signal."""
return FreshWalletSignal(
trade_event=sample_trade,
wallet_profile=sample_wallet_profile,
confidence=0.8,
factors={"base": 0.5, "brand_new_bonus": 0.2, "large_trade_bonus": 0.1},
)
@pytest.fixture
def size_anomaly_signal(
sample_trade: TradeEvent, sample_metadata: MarketMetadata
) -> SizeAnomalySignal:
"""Create a sample size anomaly signal."""
return SizeAnomalySignal(
trade_event=sample_trade,
market_metadata=sample_metadata,
volume_impact=0.10,
book_impact=0.15,
is_niche_market=True,
confidence=0.7,
factors={"volume_impact": 0.4, "book_impact": 0.3},
)
@pytest.fixture
def high_risk_assessment(
sample_trade: TradeEvent,
fresh_wallet_signal: FreshWalletSignal,
size_anomaly_signal: SizeAnomalySignal,
) -> RiskAssessment:
"""Create a high-risk assessment with multiple signals."""
return RiskAssessment(
trade_event=sample_trade,
wallet_address=sample_trade.wallet_address,
market_id=sample_trade.market_id,
fresh_wallet_signal=fresh_wallet_signal,
size_anomaly_signal=size_anomaly_signal,
signals_triggered=2,
weighted_score=0.82,
should_alert=True,
)
@pytest.fixture
def medium_risk_assessment(
sample_trade: TradeEvent,
fresh_wallet_signal: FreshWalletSignal,
) -> RiskAssessment:
"""Create a medium-risk assessment with one signal."""
return RiskAssessment(
trade_event=sample_trade,
wallet_address=sample_trade.wallet_address,
market_id=sample_trade.market_id,
fresh_wallet_signal=fresh_wallet_signal,
size_anomaly_signal=None,
signals_triggered=1,
weighted_score=0.55,
should_alert=True,
)
@pytest.fixture
def low_risk_assessment(sample_trade: TradeEvent) -> RiskAssessment:
"""Create a low-risk assessment with no signals."""
return RiskAssessment(
trade_event=sample_trade,
wallet_address=sample_trade.wallet_address,
market_id=sample_trade.market_id,
fresh_wallet_signal=None,
size_anomaly_signal=None,
signals_triggered=0,
weighted_score=0.25,
should_alert=False,
)
# ============================================================================
# Helper Function Tests
# ============================================================================
class TestTruncateAddress:
"""Tests for truncate_address helper."""
def test_truncate_standard_address(self) -> None:
"""Test truncating a standard Ethereum address."""
address = "0x1234567890abcdef1234567890abcdef12345678"
result = truncate_address(address)
assert result == "0x1234...5678"
def test_truncate_with_custom_length(self) -> None:
"""Test truncating with custom character count."""
address = "0x1234567890abcdef1234567890abcdef12345678"
result = truncate_address(address, chars=6)
assert result == "0x123456...345678"
def test_short_address_not_truncated(self) -> None:
"""Test that short addresses are not truncated."""
address = "0x1234"
result = truncate_address(address)
assert result == "0x1234"
class TestFormatUsdc:
"""Tests for format_usdc helper."""
def test_format_whole_dollars(self) -> None:
"""Test formatting whole dollar amounts."""
result = format_usdc(Decimal("15000"))
assert result == "$15,000.00"
def test_format_with_cents(self) -> None:
"""Test formatting with decimal places."""
result = format_usdc(Decimal("1234.56"))
assert result == "$1,234.56"
def test_format_large_amount(self) -> None:
"""Test formatting large amounts."""
result = format_usdc(Decimal("1000000"))
assert result == "$1,000,000.00"
class TestGetRiskLevel:
"""Tests for get_risk_level helper."""
def test_high_risk(self) -> None:
"""Test high risk threshold."""
assert get_risk_level(0.85) == "HIGH"
assert get_risk_level(0.70) == "HIGH"
def test_medium_risk(self) -> None:
"""Test medium risk threshold."""
assert get_risk_level(0.65) == "MEDIUM"
assert get_risk_level(0.50) == "MEDIUM"
def test_low_risk(self) -> None:
"""Test low risk threshold."""
assert get_risk_level(0.40) == "LOW"
assert get_risk_level(0.10) == "LOW"
class TestGetRiskColor:
"""Tests for get_risk_color helper."""
def test_high_risk_color(self) -> None:
"""Test high risk returns red color."""
assert get_risk_color(0.85) == COLOR_HIGH_RISK
assert get_risk_color(0.70) == COLOR_HIGH_RISK
def test_medium_risk_color(self) -> None:
"""Test medium risk returns orange color."""
assert get_risk_color(0.65) == COLOR_MEDIUM_RISK
assert get_risk_color(0.50) == COLOR_MEDIUM_RISK
def test_low_risk_color(self) -> None:
"""Test low risk returns yellow color."""
assert get_risk_color(0.40) == COLOR_LOW_RISK
class TestGetTriggeredSignals:
"""Tests for get_triggered_signals helper."""
def test_no_signals(self, low_risk_assessment: RiskAssessment) -> None:
"""Test assessment with no signals."""
signals = get_triggered_signals(low_risk_assessment)
assert signals == []
def test_fresh_wallet_only(self, medium_risk_assessment: RiskAssessment) -> None:
"""Test assessment with only fresh wallet signal."""
signals = get_triggered_signals(medium_risk_assessment)
assert "Fresh Wallet" in signals
assert "Large Position" not in signals
def test_both_signals(self, high_risk_assessment: RiskAssessment) -> None:
"""Test assessment with both signals."""
signals = get_triggered_signals(high_risk_assessment)
assert "Fresh Wallet" in signals
assert "Large Position" in signals
assert "Niche Market" in signals # From size anomaly with is_niche_market=True
# ============================================================================
# AlertFormatter Tests
# ============================================================================
class TestAlertFormatterInit:
"""Tests for AlertFormatter initialization."""
def test_default_verbosity(self) -> None:
"""Test default verbosity is detailed."""
formatter = AlertFormatter()
assert formatter.verbosity == "detailed"
def test_compact_verbosity(self) -> None:
"""Test setting compact verbosity."""
formatter = AlertFormatter(verbosity="compact")
assert formatter.verbosity == "compact"
class TestAlertFormatterFormat:
"""Tests for AlertFormatter.format method."""
def test_format_returns_formatted_alert(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that format returns a FormattedAlert."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
assert isinstance(result, FormattedAlert)
def test_format_includes_all_fields(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that all fields are populated."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
assert result.title != ""
assert result.body != ""
assert result.discord_embed != {}
assert result.telegram_markdown != ""
assert result.plain_text != ""
assert result.links != {}
def test_format_title_includes_risk_level(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that title includes risk level."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
assert "HIGH" in result.title
def test_format_includes_wallet_link(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that wallet explorer link is included."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
assert "wallet" in result.links
assert "polygonscan.com" in result.links["wallet"]
def test_format_includes_market_link(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that market link is included when slug available."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
assert "market" in result.links
assert "polymarket.com" in result.links["market"]
class TestDiscordEmbed:
"""Tests for Discord embed format."""
def test_embed_has_required_fields(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that embed has required Discord fields."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
embed = result.discord_embed
assert "title" in embed
assert "color" in embed
assert "fields" in embed
assert "footer" in embed
def test_embed_color_reflects_risk(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that embed color matches risk level."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
assert result.discord_embed["color"] == COLOR_HIGH_RISK
def test_embed_includes_wallet_field(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that embed includes wallet field."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
fields = result.discord_embed["fields"]
wallet_field = next((f for f in fields if f["name"] == "Wallet"), None)
assert wallet_field is not None
assert "0x1234" in wallet_field["value"]
def test_embed_includes_wallet_age(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that wallet age is shown when fresh wallet signal present."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
fields = result.discord_embed["fields"]
wallet_field = next((f for f in fields if f["name"] == "Wallet"), None)
assert "Age:" in wallet_field["value"]
def test_embed_includes_trade_details(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that trade details are in embed."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
fields = result.discord_embed["fields"]
trade_field = next((f for f in fields if f["name"] == "Trade"), None)
assert trade_field is not None
assert "BUY" in trade_field["value"]
assert "Yes" in trade_field["value"]
def test_embed_includes_signals_field(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that signals are listed in embed."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
fields = result.discord_embed["fields"]
signals_field = next((f for f in fields if f["name"] == "Signals"), None)
assert signals_field is not None
assert "Fresh Wallet" in signals_field["value"]
def test_detailed_embed_includes_confidence(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that detailed mode includes confidence breakdown."""
formatter = AlertFormatter(verbosity="detailed")
result = formatter.format(high_risk_assessment)
fields = result.discord_embed["fields"]
conf_field = next((f for f in fields if f["name"] == "Confidence"), None)
assert conf_field is not None
class TestTelegramMarkdown:
"""Tests for Telegram markdown format."""
def test_telegram_includes_header(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that Telegram message has header."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
assert "*Suspicious Activity Detected*" in result.telegram_markdown
def test_telegram_includes_wallet(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that Telegram message includes wallet."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
assert "`0x1234...5678`" in result.telegram_markdown
def test_telegram_includes_risk_score(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that Telegram message includes risk score."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
assert "0.82" in result.telegram_markdown
assert "HIGH" in result.telegram_markdown
def test_telegram_includes_links(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that Telegram message includes links."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
assert "[View Wallet]" in result.telegram_markdown
assert "[View Market]" in result.telegram_markdown
class TestPlainText:
"""Tests for plain text format."""
def test_plain_text_header(self, high_risk_assessment: RiskAssessment) -> None:
"""Test plain text has header."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
assert "SUSPICIOUS ACTIVITY DETECTED" in result.plain_text
def test_plain_text_wallet(self, high_risk_assessment: RiskAssessment) -> None:
"""Test plain text includes wallet."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
assert "Wallet:" in result.plain_text
assert "0x1234...5678" in result.plain_text
def test_plain_text_trade(self, high_risk_assessment: RiskAssessment) -> None:
"""Test plain text includes trade details."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
assert "Trade:" in result.plain_text
assert "BUY" in result.plain_text
def test_plain_text_signals(self, high_risk_assessment: RiskAssessment) -> None:
"""Test plain text includes signals."""
formatter = AlertFormatter()
result = formatter.format(high_risk_assessment)
assert "Signals:" in result.plain_text
assert "Fresh Wallet" in result.plain_text
class TestCompactVerbosity:
"""Tests for compact verbosity mode."""
def test_compact_body_is_shorter(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that compact mode produces shorter body."""
detailed_formatter = AlertFormatter(verbosity="detailed")
compact_formatter = AlertFormatter(verbosity="compact")
detailed_result = detailed_formatter.format(high_risk_assessment)
compact_result = compact_formatter.format(high_risk_assessment)
assert len(compact_result.body) < len(detailed_result.body)
def test_compact_body_includes_essential_info(
self, high_risk_assessment: RiskAssessment
) -> None:
"""Test that compact mode still has essential info."""
formatter = AlertFormatter(verbosity="compact")
result = formatter.format(high_risk_assessment)
assert "0x1234...5678" in result.body
assert "0.82" in result.body
assert "HIGH" in result.body
class TestEdgeCases:
"""Tests for edge cases and special scenarios."""
def test_no_market_slug(self, sample_trade: TradeEvent) -> None:
"""Test formatting when market slug is empty."""
trade = TradeEvent(
market_id=sample_trade.market_id,
trade_id=sample_trade.trade_id,
wallet_address=sample_trade.wallet_address,
side=sample_trade.side,
outcome=sample_trade.outcome,
outcome_index=sample_trade.outcome_index,
price=sample_trade.price,
size=sample_trade.size,
timestamp=sample_trade.timestamp,
asset_id=sample_trade.asset_id,
market_slug="", # Empty slug
event_title="", # Empty title
)
assessment = RiskAssessment(
trade_event=trade,
wallet_address=trade.wallet_address,
market_id=trade.market_id,
fresh_wallet_signal=None,
size_anomaly_signal=None,
signals_triggered=0,
weighted_score=0.5,
should_alert=False,
)
formatter = AlertFormatter()
result = formatter.format(assessment)
# Should not have market link
assert "market" not in result.links
# Should have fallback text
assert "Unknown Market" in result.plain_text
def test_very_short_wallet_age(
self,
sample_trade: TradeEvent,
sample_wallet_profile: WalletProfile,
) -> None:
"""Test formatting with wallet age less than 1 hour."""
profile = WalletProfile(
address=sample_wallet_profile.address,
nonce=sample_wallet_profile.nonce,
first_seen=datetime.now(UTC),
age_hours=0.5, # 30 minutes
is_fresh=True,
total_tx_count=1,
matic_balance=sample_wallet_profile.matic_balance,
usdc_balance=sample_wallet_profile.usdc_balance,
)
signal = FreshWalletSignal(
trade_event=sample_trade,
wallet_profile=profile,
confidence=0.9,
factors={},
)
assessment = RiskAssessment(
trade_event=sample_trade,
wallet_address=sample_trade.wallet_address,
market_id=sample_trade.market_id,
fresh_wallet_signal=signal,
size_anomaly_signal=None,
signals_triggered=1,
weighted_score=0.75,
should_alert=True,
)
formatter = AlertFormatter()
result = formatter.format(assessment)
# Should show age in minutes
assert "30m" in result.plain_text or "Age: 30m" in result.plain_text
def test_size_anomaly_without_niche(
self,
sample_trade: TradeEvent,
sample_metadata: MarketMetadata,
) -> None:
"""Test size anomaly signal without niche market flag."""
signal = SizeAnomalySignal(
trade_event=sample_trade,
market_metadata=sample_metadata,
volume_impact=0.10,
book_impact=0.15,
is_niche_market=False, # Not niche
confidence=0.7,
factors={},
)
assessment = RiskAssessment(
trade_event=sample_trade,
wallet_address=sample_trade.wallet_address,
market_id=sample_trade.market_id,
fresh_wallet_signal=None,
size_anomaly_signal=signal,
signals_triggered=1,
weighted_score=0.6,
should_alert=True,
)
signals = get_triggered_signals(assessment)
assert "Large Position" in signals
assert "Niche Market" not in signals
+534
View File
@@ -0,0 +1,534 @@
"""Tests for alert history and deduplication."""
import json
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from unittest.mock import AsyncMock, MagicMock
import pytest
from polymarket_insider_tracker.alerter.history import (
AlertHistory,
AlertRecord,
_generate_dedup_key,
_get_signals_from_assessment,
)
from polymarket_insider_tracker.detector.models import (
FreshWalletSignal,
RiskAssessment,
SizeAnomalySignal,
)
from polymarket_insider_tracker.ingestor.models import MarketMetadata, Token, TradeEvent
from polymarket_insider_tracker.profiler.models import WalletProfile
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def sample_trade() -> TradeEvent:
"""Create a sample trade event."""
return TradeEvent(
market_id="market_abc123",
trade_id="tx_001",
wallet_address="0x1234567890abcdef1234567890abcdef12345678",
side="BUY",
outcome="Yes",
outcome_index=0,
price=Decimal("0.65"),
size=Decimal("10000"),
timestamp=datetime.now(UTC),
asset_id="token_123",
event_title="Test Market",
)
@pytest.fixture
def sample_wallet_profile() -> WalletProfile:
"""Create a sample wallet profile."""
return WalletProfile(
address="0x1234567890abcdef1234567890abcdef12345678",
nonce=2,
first_seen=datetime.now(UTC),
age_hours=1.0,
is_fresh=True,
total_tx_count=2,
matic_balance=Decimal("1000000000000000000"),
usdc_balance=Decimal("1000000"),
)
@pytest.fixture
def sample_metadata() -> MarketMetadata:
"""Create sample market metadata."""
return MarketMetadata(
condition_id="market_abc123",
question="Test market?",
description="Test",
tokens=(Token(token_id="token_123", outcome="Yes", price=Decimal("0.65")),),
category="other",
)
@pytest.fixture
def fresh_wallet_signal(
sample_trade: TradeEvent, sample_wallet_profile: WalletProfile
) -> FreshWalletSignal:
"""Create a sample fresh wallet signal."""
return FreshWalletSignal(
trade_event=sample_trade,
wallet_profile=sample_wallet_profile,
confidence=0.8,
factors={},
)
@pytest.fixture
def size_anomaly_signal(
sample_trade: TradeEvent, sample_metadata: MarketMetadata
) -> SizeAnomalySignal:
"""Create a sample size anomaly signal."""
return SizeAnomalySignal(
trade_event=sample_trade,
market_metadata=sample_metadata,
volume_impact=0.10,
book_impact=0.15,
is_niche_market=True,
confidence=0.7,
factors={},
)
@pytest.fixture
def high_risk_assessment(
sample_trade: TradeEvent,
fresh_wallet_signal: FreshWalletSignal,
size_anomaly_signal: SizeAnomalySignal,
) -> RiskAssessment:
"""Create a high-risk assessment."""
return RiskAssessment(
trade_event=sample_trade,
wallet_address=sample_trade.wallet_address,
market_id=sample_trade.market_id,
fresh_wallet_signal=fresh_wallet_signal,
size_anomaly_signal=size_anomaly_signal,
signals_triggered=2,
weighted_score=0.82,
should_alert=True,
)
@pytest.fixture
def mock_redis() -> MagicMock:
"""Create a mock Redis client."""
redis = MagicMock()
# Make async methods return AsyncMock
redis.exists = AsyncMock(return_value=0) # Key doesn't exist (not duplicate)
redis.get = AsyncMock(return_value=None)
redis.set = AsyncMock(return_value=True)
redis.ttl = AsyncMock(return_value=3600)
redis.zadd = AsyncMock(return_value=1)
redis.expire = AsyncMock(return_value=True)
redis.zrangebyscore = AsyncMock(return_value=[])
redis.zcount = AsyncMock(return_value=0)
redis.zremrangebyscore = AsyncMock(return_value=0)
# Mock pipeline - async context manager
pipeline = MagicMock()
pipeline.__aenter__ = AsyncMock(return_value=pipeline)
pipeline.__aexit__ = AsyncMock(return_value=None)
pipeline.set.return_value = pipeline
pipeline.zadd.return_value = pipeline
pipeline.expire.return_value = pipeline
pipeline.execute = AsyncMock(return_value=[True, True, True, True, True, True])
redis.pipeline.return_value = pipeline
return redis
# ============================================================================
# AlertRecord Tests
# ============================================================================
class TestAlertRecord:
"""Tests for AlertRecord dataclass."""
def test_to_dict(self) -> None:
"""Test serialization to dict."""
now = datetime.now(UTC)
record = AlertRecord(
alert_id="test-123",
wallet_address="0x1234",
market_id="market_abc",
risk_score=0.75,
signals_triggered=["fresh_wallet"],
channels_attempted=["discord", "telegram"],
channels_succeeded=["discord"],
dedup_key="0x1234:market_abc:2026010416",
feedback_useful=True,
created_at=now,
)
data = record.to_dict()
assert data["alert_id"] == "test-123"
assert data["risk_score"] == 0.75
assert data["feedback_useful"] is True
assert data["created_at"] == now.isoformat()
def test_from_dict(self) -> None:
"""Test deserialization from dict."""
data = {
"alert_id": "test-456",
"wallet_address": "0x5678",
"market_id": "market_xyz",
"risk_score": 0.82,
"signals_triggered": ["size_anomaly"],
"channels_attempted": ["discord"],
"channels_succeeded": ["discord"],
"dedup_key": "0x5678:market_xyz:2026010416",
"feedback_useful": None,
"created_at": "2026-01-04T16:00:00+00:00",
}
record = AlertRecord.from_dict(data)
assert record.alert_id == "test-456"
assert record.risk_score == 0.82
assert record.feedback_useful is None
def test_from_dict_missing_optional(self) -> None:
"""Test deserialization with missing optional fields."""
data = {
"alert_id": "test-789",
"wallet_address": "0x9999",
"market_id": "market_aaa",
"risk_score": "0.5", # Test string conversion
"dedup_key": "key",
}
record = AlertRecord.from_dict(data)
assert record.signals_triggered == []
assert record.channels_attempted == []
assert record.feedback_useful is None
# ============================================================================
# Helper Function Tests
# ============================================================================
class TestGenerateDedupKey:
"""Tests for dedup key generation."""
def test_basic_key(self) -> None:
"""Test basic dedup key generation."""
hour = datetime(2026, 1, 4, 16, 30, 0, tzinfo=UTC)
key = _generate_dedup_key("0x1234", "market_abc", hour)
assert key == "0x1234:market_abc:2026010416"
def test_different_hours(self) -> None:
"""Test that different hours produce different keys."""
hour1 = datetime(2026, 1, 4, 16, 0, 0, tzinfo=UTC)
hour2 = datetime(2026, 1, 4, 17, 0, 0, tzinfo=UTC)
key1 = _generate_dedup_key("0x1234", "market_abc", hour1)
key2 = _generate_dedup_key("0x1234", "market_abc", hour2)
assert key1 != key2
class TestGetSignalsFromAssessment:
"""Tests for signal extraction."""
def test_no_signals(self, sample_trade: TradeEvent) -> None:
"""Test extraction with no signals."""
assessment = RiskAssessment(
trade_event=sample_trade,
wallet_address=sample_trade.wallet_address,
market_id=sample_trade.market_id,
fresh_wallet_signal=None,
size_anomaly_signal=None,
signals_triggered=0,
weighted_score=0.0,
should_alert=False,
)
signals = _get_signals_from_assessment(assessment)
assert signals == []
def test_fresh_wallet_only(
self,
sample_trade: TradeEvent,
fresh_wallet_signal: FreshWalletSignal,
) -> None:
"""Test extraction with fresh wallet signal."""
assessment = RiskAssessment(
trade_event=sample_trade,
wallet_address=sample_trade.wallet_address,
market_id=sample_trade.market_id,
fresh_wallet_signal=fresh_wallet_signal,
size_anomaly_signal=None,
signals_triggered=1,
weighted_score=0.5,
should_alert=True,
)
signals = _get_signals_from_assessment(assessment)
assert "fresh_wallet" in signals
assert "size_anomaly" not in signals
def test_all_signals(self, high_risk_assessment: RiskAssessment) -> None:
"""Test extraction with all signals."""
signals = _get_signals_from_assessment(high_risk_assessment)
assert "fresh_wallet" in signals
assert "size_anomaly" in signals
assert "niche_market" in signals
# ============================================================================
# AlertHistory Tests
# ============================================================================
class TestAlertHistoryInit:
"""Tests for AlertHistory initialization."""
def test_default_settings(self, mock_redis: AsyncMock) -> None:
"""Test default configuration."""
history = AlertHistory(mock_redis)
assert history.dedup_window_hours == 1
assert history.retention_days == 30
assert history._dedup_ttl == 3600
assert history._retention_ttl == 30 * 86400
def test_custom_settings(self, mock_redis: AsyncMock) -> None:
"""Test custom configuration."""
history = AlertHistory(
mock_redis,
dedup_window_hours=2,
retention_days=7,
)
assert history.dedup_window_hours == 2
assert history._dedup_ttl == 7200
class TestShouldSend:
"""Tests for should_send method."""
@pytest.mark.asyncio
async def test_not_duplicate(
self,
mock_redis: AsyncMock,
high_risk_assessment: RiskAssessment,
) -> None:
"""Test that non-duplicate returns True."""
mock_redis.exists.return_value = 0
history = AlertHistory(mock_redis)
result = await history.should_send(high_risk_assessment)
assert result is True
mock_redis.exists.assert_called_once()
@pytest.mark.asyncio
async def test_is_duplicate(
self,
mock_redis: AsyncMock,
high_risk_assessment: RiskAssessment,
) -> None:
"""Test that duplicate returns False."""
mock_redis.exists.return_value = 1
history = AlertHistory(mock_redis)
result = await history.should_send(high_risk_assessment)
assert result is False
class TestRecordSent:
"""Tests for record_sent method."""
@pytest.mark.asyncio
async def test_record_success(
self,
mock_redis: AsyncMock,
high_risk_assessment: RiskAssessment,
) -> None:
"""Test recording a sent alert."""
history = AlertHistory(mock_redis)
alert_id = await history.record_sent(
high_risk_assessment,
channels_attempted=["discord", "telegram"],
channels_succeeded={"discord": True, "telegram": False},
)
assert alert_id is not None
assert len(alert_id) == 36 # UUID length
# Verify pipeline was used
mock_redis.pipeline.assert_called_once()
class TestRecordFeedback:
"""Tests for record_feedback method."""
@pytest.mark.asyncio
async def test_feedback_success(self, mock_redis: AsyncMock) -> None:
"""Test recording feedback for existing alert."""
existing_record = {
"alert_id": "test-123",
"wallet_address": "0x1234",
"market_id": "market_abc",
"risk_score": 0.75,
"signals_triggered": [],
"channels_attempted": [],
"channels_succeeded": [],
"dedup_key": "key",
"feedback_useful": None,
}
mock_redis.get.return_value = json.dumps(existing_record)
mock_redis.ttl.return_value = 3600
history = AlertHistory(mock_redis)
result = await history.record_feedback("test-123", useful=True)
assert result is True
mock_redis.set.assert_called()
@pytest.mark.asyncio
async def test_feedback_not_found(self, mock_redis: AsyncMock) -> None:
"""Test feedback for non-existent alert."""
mock_redis.get.return_value = None
history = AlertHistory(mock_redis)
result = await history.record_feedback("nonexistent", useful=True)
assert result is False
class TestGetAlert:
"""Tests for get_alert method."""
@pytest.mark.asyncio
async def test_get_existing(self, mock_redis: AsyncMock) -> None:
"""Test getting existing alert."""
existing_record = {
"alert_id": "test-123",
"wallet_address": "0x1234",
"market_id": "market_abc",
"risk_score": 0.75,
"signals_triggered": ["fresh_wallet"],
"channels_attempted": ["discord"],
"channels_succeeded": ["discord"],
"dedup_key": "key",
"created_at": "2026-01-04T16:00:00+00:00",
}
mock_redis.get.return_value = json.dumps(existing_record)
history = AlertHistory(mock_redis)
record = await history.get_alert("test-123")
assert record is not None
assert record.alert_id == "test-123"
assert record.risk_score == 0.75
@pytest.mark.asyncio
async def test_get_nonexistent(self, mock_redis: AsyncMock) -> None:
"""Test getting non-existent alert."""
mock_redis.get.return_value = None
history = AlertHistory(mock_redis)
record = await history.get_alert("nonexistent")
assert record is None
class TestGetAlerts:
"""Tests for get_alerts query method."""
@pytest.mark.asyncio
async def test_empty_results(self, mock_redis: AsyncMock) -> None:
"""Test query with no results."""
mock_redis.zrangebyscore.return_value = []
history = AlertHistory(mock_redis)
results = await history.get_alerts(
start=datetime.now(UTC) - timedelta(hours=24),
end=datetime.now(UTC),
)
assert results == []
@pytest.mark.asyncio
async def test_with_wallet_filter(self, mock_redis: AsyncMock) -> None:
"""Test query with wallet filter uses correct index."""
mock_redis.zrangebyscore.return_value = []
history = AlertHistory(mock_redis)
await history.get_alerts(
start=datetime.now(UTC) - timedelta(hours=24),
end=datetime.now(UTC),
wallet="0x1234",
)
# Verify correct index was used
call_args = mock_redis.zrangebyscore.call_args
assert "wallet:0x1234" in call_args[0][0]
class TestGetRecentCount:
"""Tests for get_recent_count method."""
@pytest.mark.asyncio
async def test_count_all(self, mock_redis: AsyncMock) -> None:
"""Test counting all recent alerts."""
mock_redis.zcount.return_value = 42
history = AlertHistory(mock_redis)
count = await history.get_recent_count(hours=24)
assert count == 42
@pytest.mark.asyncio
async def test_count_by_wallet(self, mock_redis: AsyncMock) -> None:
"""Test counting alerts for specific wallet."""
mock_redis.zcount.return_value = 5
history = AlertHistory(mock_redis)
count = await history.get_recent_count(hours=24, wallet="0x1234")
assert count == 5
class TestCleanupOldAlerts:
"""Tests for cleanup_old_alerts method."""
@pytest.mark.asyncio
async def test_cleanup_empty(self, mock_redis: AsyncMock) -> None:
"""Test cleanup with no old alerts."""
mock_redis.zrangebyscore.return_value = []
history = AlertHistory(mock_redis)
removed = await history.cleanup_old_alerts()
assert removed == 0
@pytest.mark.asyncio
async def test_cleanup_removes_old(self, mock_redis: AsyncMock) -> None:
"""Test cleanup removes old alerts."""
mock_redis.zrangebyscore.return_value = [b"alert-1", b"alert-2"]
mock_redis.zremrangebyscore.return_value = 2
history = AlertHistory(mock_redis)
removed = await history.cleanup_old_alerts()
assert removed == 2
mock_redis.zremrangebyscore.assert_called_once()
+690
View File
@@ -0,0 +1,690 @@
"""Tests for composite risk scorer."""
from datetime import UTC, datetime
from decimal import Decimal
from unittest.mock import AsyncMock
import pytest
from polymarket_insider_tracker.detector.models import (
FreshWalletSignal,
RiskAssessment,
SizeAnomalySignal,
)
from polymarket_insider_tracker.detector.scorer import (
DEFAULT_ALERT_THRESHOLD,
DEFAULT_WEIGHTS,
MULTI_SIGNAL_BONUS_2,
RiskScorer,
SignalBundle,
)
from polymarket_insider_tracker.ingestor.models import MarketMetadata, Token, TradeEvent
from polymarket_insider_tracker.profiler.models import WalletProfile
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def mock_redis() -> AsyncMock:
"""Create a mock Redis client."""
mock = AsyncMock()
# Default: key doesn't exist (not a duplicate)
mock.set.return_value = True
mock.delete.return_value = 1
return mock
@pytest.fixture
def sample_trade() -> TradeEvent:
"""Create a sample trade event."""
return TradeEvent(
market_id="market_abc123",
trade_id="tx_001",
wallet_address="0x1234567890abcdef1234567890abcdef12345678",
side="BUY",
outcome="Yes",
outcome_index=0,
price=Decimal("0.65"),
size=Decimal("10000"),
timestamp=datetime.now(UTC),
asset_id="token_123",
event_title="Test Market",
)
@pytest.fixture
def sample_wallet_profile() -> WalletProfile:
"""Create a sample wallet profile."""
return WalletProfile(
address="0x1234567890abcdef1234567890abcdef12345678",
nonce=2,
first_seen=datetime.now(UTC),
age_hours=1.0,
is_fresh=True,
total_tx_count=2,
matic_balance=Decimal("1000000000000000000"), # 1 MATIC
usdc_balance=Decimal("1000000"), # 1 USDC
)
@pytest.fixture
def sample_metadata() -> MarketMetadata:
"""Create sample market metadata."""
return MarketMetadata(
condition_id="market_abc123",
question="Will it rain tomorrow?",
description="Weather prediction market",
tokens=(
Token(token_id="token_123", outcome="Yes", price=Decimal("0.65")),
),
category="science",
)
@pytest.fixture
def fresh_wallet_signal(
sample_trade: TradeEvent, sample_wallet_profile: WalletProfile
) -> FreshWalletSignal:
"""Create a sample fresh wallet signal."""
return FreshWalletSignal(
trade_event=sample_trade,
wallet_profile=sample_wallet_profile,
confidence=0.8,
factors={"base": 0.5, "brand_new_bonus": 0.2, "large_trade_bonus": 0.1},
)
@pytest.fixture
def size_anomaly_signal(
sample_trade: TradeEvent, sample_metadata: MarketMetadata
) -> SizeAnomalySignal:
"""Create a sample size anomaly signal."""
return SizeAnomalySignal(
trade_event=sample_trade,
market_metadata=sample_metadata,
volume_impact=0.10,
book_impact=0.15,
is_niche_market=True,
confidence=0.7,
factors={"volume_impact": 0.4, "book_impact": 0.3},
)
# ============================================================================
# SignalBundle Tests
# ============================================================================
class TestSignalBundle:
"""Tests for the SignalBundle dataclass."""
def test_bundle_with_no_signals(self, sample_trade: TradeEvent) -> None:
"""Test bundle with only trade, no signals."""
bundle = SignalBundle(trade_event=sample_trade)
assert bundle.trade_event == sample_trade
assert bundle.fresh_wallet_signal is None
assert bundle.size_anomaly_signal is None
assert bundle.wallet_address == sample_trade.wallet_address
assert bundle.market_id == sample_trade.market_id
def test_bundle_with_fresh_wallet_signal(
self,
sample_trade: TradeEvent,
fresh_wallet_signal: FreshWalletSignal,
) -> None:
"""Test bundle with fresh wallet signal."""
bundle = SignalBundle(
trade_event=sample_trade,
fresh_wallet_signal=fresh_wallet_signal,
)
assert bundle.fresh_wallet_signal == fresh_wallet_signal
assert bundle.size_anomaly_signal is None
def test_bundle_with_all_signals(
self,
sample_trade: TradeEvent,
fresh_wallet_signal: FreshWalletSignal,
size_anomaly_signal: SizeAnomalySignal,
) -> None:
"""Test bundle with all signal types."""
bundle = SignalBundle(
trade_event=sample_trade,
fresh_wallet_signal=fresh_wallet_signal,
size_anomaly_signal=size_anomaly_signal,
)
assert bundle.fresh_wallet_signal == fresh_wallet_signal
assert bundle.size_anomaly_signal == size_anomaly_signal
# ============================================================================
# RiskAssessment Tests
# ============================================================================
class TestRiskAssessment:
"""Tests for the RiskAssessment dataclass."""
def test_assessment_creation(self, sample_trade: TradeEvent) -> None:
"""Test basic assessment creation."""
assessment = RiskAssessment(
trade_event=sample_trade,
wallet_address=sample_trade.wallet_address,
market_id=sample_trade.market_id,
fresh_wallet_signal=None,
size_anomaly_signal=None,
signals_triggered=0,
weighted_score=0.0,
should_alert=False,
)
assert assessment.trade_event == sample_trade
assert assessment.signals_triggered == 0
assert assessment.weighted_score == 0.0
assert assessment.should_alert is False
assert assessment.assessment_id is not None
assert assessment.timestamp is not None
def test_is_high_risk(self, sample_trade: TradeEvent) -> None:
"""Test is_high_risk property."""
high_risk = RiskAssessment(
trade_event=sample_trade,
wallet_address=sample_trade.wallet_address,
market_id=sample_trade.market_id,
fresh_wallet_signal=None,
size_anomaly_signal=None,
signals_triggered=1,
weighted_score=0.70,
should_alert=True,
)
low_risk = RiskAssessment(
trade_event=sample_trade,
wallet_address=sample_trade.wallet_address,
market_id=sample_trade.market_id,
fresh_wallet_signal=None,
size_anomaly_signal=None,
signals_triggered=1,
weighted_score=0.69,
should_alert=True,
)
assert high_risk.is_high_risk is True
assert low_risk.is_high_risk is False
def test_is_very_high_risk(self, sample_trade: TradeEvent) -> None:
"""Test is_very_high_risk property."""
very_high = RiskAssessment(
trade_event=sample_trade,
wallet_address=sample_trade.wallet_address,
market_id=sample_trade.market_id,
fresh_wallet_signal=None,
size_anomaly_signal=None,
signals_triggered=2,
weighted_score=0.85,
should_alert=True,
)
high = RiskAssessment(
trade_event=sample_trade,
wallet_address=sample_trade.wallet_address,
market_id=sample_trade.market_id,
fresh_wallet_signal=None,
size_anomaly_signal=None,
signals_triggered=2,
weighted_score=0.84,
should_alert=True,
)
assert very_high.is_very_high_risk is True
assert high.is_very_high_risk is False
def test_to_dict(
self,
sample_trade: TradeEvent,
fresh_wallet_signal: FreshWalletSignal,
) -> None:
"""Test to_dict serialization."""
assessment = RiskAssessment(
trade_event=sample_trade,
wallet_address=sample_trade.wallet_address,
market_id=sample_trade.market_id,
fresh_wallet_signal=fresh_wallet_signal,
size_anomaly_signal=None,
signals_triggered=1,
weighted_score=0.65,
should_alert=True,
)
result = assessment.to_dict()
assert result["wallet_address"] == sample_trade.wallet_address
assert result["market_id"] == sample_trade.market_id
assert result["signals_triggered"] == 1
assert result["weighted_score"] == 0.65
assert result["should_alert"] is True
assert result["has_fresh_wallet_signal"] is True
assert result["has_size_anomaly_signal"] is False
assert result["fresh_wallet_confidence"] == 0.8
assert result["size_anomaly_confidence"] is None
# ============================================================================
# RiskScorer Initialization Tests
# ============================================================================
class TestRiskScorerInit:
"""Tests for RiskScorer initialization."""
def test_default_initialization(self, mock_redis: AsyncMock) -> None:
"""Test scorer initializes with default values."""
scorer = RiskScorer(mock_redis)
assert scorer._alert_threshold == DEFAULT_ALERT_THRESHOLD
assert scorer._weights == DEFAULT_WEIGHTS
assert scorer._dedup_window == 3600
def test_custom_configuration(self, mock_redis: AsyncMock) -> None:
"""Test scorer with custom configuration."""
custom_weights = {"fresh_wallet": 0.5, "size_anomaly": 0.5}
scorer = RiskScorer(
mock_redis,
weights=custom_weights,
alert_threshold=0.7,
dedup_window_seconds=1800,
)
assert scorer._alert_threshold == 0.7
assert scorer._weights == custom_weights
assert scorer._dedup_window == 1800
# ============================================================================
# Weighted Score Calculation Tests
# ============================================================================
class TestWeightedScoreCalculation:
"""Tests for weighted score calculation."""
def test_no_signals_zero_score(
self, mock_redis: AsyncMock, sample_trade: TradeEvent
) -> None:
"""Test score is zero when no signals present."""
scorer = RiskScorer(mock_redis)
bundle = SignalBundle(trade_event=sample_trade)
score, count = scorer.calculate_weighted_score(bundle)
assert score == 0.0
assert count == 0
def test_fresh_wallet_only(
self,
mock_redis: AsyncMock,
sample_trade: TradeEvent,
fresh_wallet_signal: FreshWalletSignal,
) -> None:
"""Test score with only fresh wallet signal."""
scorer = RiskScorer(mock_redis)
bundle = SignalBundle(
trade_event=sample_trade,
fresh_wallet_signal=fresh_wallet_signal,
)
score, count = scorer.calculate_weighted_score(bundle)
# 0.8 confidence * 0.4 weight = 0.32
expected = 0.8 * DEFAULT_WEIGHTS["fresh_wallet"]
assert score == pytest.approx(expected)
assert count == 1
def test_size_anomaly_only(
self,
mock_redis: AsyncMock,
sample_trade: TradeEvent,
size_anomaly_signal: SizeAnomalySignal,
) -> None:
"""Test score with only size anomaly signal."""
scorer = RiskScorer(mock_redis)
bundle = SignalBundle(
trade_event=sample_trade,
size_anomaly_signal=size_anomaly_signal,
)
score, count = scorer.calculate_weighted_score(bundle)
# 0.7 confidence * 0.35 weight + 0.7 * 0.25 niche weight = 0.42
expected = (
0.7 * DEFAULT_WEIGHTS["size_anomaly"]
+ 0.7 * DEFAULT_WEIGHTS["niche_market"]
)
assert score == pytest.approx(expected)
assert count == 1
def test_size_anomaly_non_niche(
self,
mock_redis: AsyncMock,
sample_trade: TradeEvent,
sample_metadata: MarketMetadata,
) -> None:
"""Test size anomaly without niche bonus."""
signal = SizeAnomalySignal(
trade_event=sample_trade,
market_metadata=sample_metadata,
volume_impact=0.10,
book_impact=0.15,
is_niche_market=False,
confidence=0.7,
factors={},
)
scorer = RiskScorer(mock_redis)
bundle = SignalBundle(
trade_event=sample_trade,
size_anomaly_signal=signal,
)
score, count = scorer.calculate_weighted_score(bundle)
# 0.7 * 0.35 = 0.245 (no niche bonus)
expected = 0.7 * DEFAULT_WEIGHTS["size_anomaly"]
assert score == pytest.approx(expected)
def test_multi_signal_bonus_two_signals(
self,
mock_redis: AsyncMock,
sample_trade: TradeEvent,
fresh_wallet_signal: FreshWalletSignal,
size_anomaly_signal: SizeAnomalySignal,
) -> None:
"""Test 20% bonus for two signals."""
scorer = RiskScorer(mock_redis)
bundle = SignalBundle(
trade_event=sample_trade,
fresh_wallet_signal=fresh_wallet_signal,
size_anomaly_signal=size_anomaly_signal,
)
score, count = scorer.calculate_weighted_score(bundle)
# Calculate base score
base = (
0.8 * DEFAULT_WEIGHTS["fresh_wallet"]
+ 0.7 * DEFAULT_WEIGHTS["size_anomaly"]
+ 0.7 * DEFAULT_WEIGHTS["niche_market"]
)
expected = base * MULTI_SIGNAL_BONUS_2
assert score == pytest.approx(expected)
assert count == 2
def test_score_capped_at_one(
self,
mock_redis: AsyncMock,
sample_trade: TradeEvent,
sample_wallet_profile: WalletProfile,
sample_metadata: MarketMetadata,
) -> None:
"""Test score is capped at 1.0."""
# Create high confidence signals
fresh_signal = FreshWalletSignal(
trade_event=sample_trade,
wallet_profile=sample_wallet_profile,
confidence=1.0,
factors={},
)
size_signal = SizeAnomalySignal(
trade_event=sample_trade,
market_metadata=sample_metadata,
volume_impact=0.10,
book_impact=0.15,
is_niche_market=True,
confidence=1.0,
factors={},
)
scorer = RiskScorer(mock_redis)
bundle = SignalBundle(
trade_event=sample_trade,
fresh_wallet_signal=fresh_signal,
size_anomaly_signal=size_signal,
)
score, count = scorer.calculate_weighted_score(bundle)
assert score == 1.0 # Capped
assert count == 2
# ============================================================================
# Assess Method Tests
# ============================================================================
class TestAssessMethod:
"""Tests for the assess method."""
@pytest.mark.asyncio
async def test_assess_triggers_alert(
self,
mock_redis: AsyncMock,
sample_trade: TradeEvent,
fresh_wallet_signal: FreshWalletSignal,
size_anomaly_signal: SizeAnomalySignal,
) -> None:
"""Test assess triggers alert for high-risk trades."""
scorer = RiskScorer(mock_redis)
bundle = SignalBundle(
trade_event=sample_trade,
fresh_wallet_signal=fresh_wallet_signal,
size_anomaly_signal=size_anomaly_signal,
)
assessment = await scorer.assess(bundle)
assert assessment.should_alert is True
assert assessment.signals_triggered == 2
assert assessment.weighted_score >= DEFAULT_ALERT_THRESHOLD
@pytest.mark.asyncio
async def test_assess_no_alert_below_threshold(
self, mock_redis: AsyncMock, sample_trade: TradeEvent
) -> None:
"""Test assess does not alert for low-risk trades."""
scorer = RiskScorer(mock_redis)
bundle = SignalBundle(trade_event=sample_trade)
assessment = await scorer.assess(bundle)
assert assessment.should_alert is False
assert assessment.signals_triggered == 0
assert assessment.weighted_score == 0.0
@pytest.mark.asyncio
async def test_assess_deduplication(
self,
mock_redis: AsyncMock,
sample_trade: TradeEvent,
fresh_wallet_signal: FreshWalletSignal,
size_anomaly_signal: SizeAnomalySignal,
) -> None:
"""Test assess deduplicates repeated alerts."""
# First call: key doesn't exist (returns True)
# Second call: key exists (returns False/None)
mock_redis.set.side_effect = [True, False]
scorer = RiskScorer(mock_redis)
bundle = SignalBundle(
trade_event=sample_trade,
fresh_wallet_signal=fresh_wallet_signal,
size_anomaly_signal=size_anomaly_signal,
)
# First assessment should alert
assessment1 = await scorer.assess(bundle)
# Second assessment should be deduplicated
assessment2 = await scorer.assess(bundle)
assert assessment1.should_alert is True
assert assessment2.should_alert is False
@pytest.mark.asyncio
async def test_assess_preserves_signals(
self,
mock_redis: AsyncMock,
sample_trade: TradeEvent,
fresh_wallet_signal: FreshWalletSignal,
) -> None:
"""Test assess preserves original signals in assessment."""
scorer = RiskScorer(mock_redis)
bundle = SignalBundle(
trade_event=sample_trade,
fresh_wallet_signal=fresh_wallet_signal,
)
assessment = await scorer.assess(bundle)
assert assessment.fresh_wallet_signal == fresh_wallet_signal
assert assessment.size_anomaly_signal is None
# ============================================================================
# Deduplication Tests
# ============================================================================
class TestDeduplication:
"""Tests for deduplication functionality."""
@pytest.mark.asyncio
async def test_check_and_set_dedup_new_key(
self, mock_redis: AsyncMock
) -> None:
"""Test dedup returns False for new key."""
mock_redis.set.return_value = True
scorer = RiskScorer(mock_redis)
is_dup = await scorer._check_and_set_dedup("0xwallet", "market123")
assert is_dup is False
mock_redis.set.assert_called_once()
@pytest.mark.asyncio
async def test_check_and_set_dedup_existing_key(
self, mock_redis: AsyncMock
) -> None:
"""Test dedup returns True for existing key."""
mock_redis.set.return_value = False # Key exists, NX failed
scorer = RiskScorer(mock_redis)
is_dup = await scorer._check_and_set_dedup("0xwallet", "market123")
assert is_dup is True
@pytest.mark.asyncio
async def test_clear_dedup(self, mock_redis: AsyncMock) -> None:
"""Test clearing dedup key."""
mock_redis.delete.return_value = 1
scorer = RiskScorer(mock_redis)
cleared = await scorer.clear_dedup("0xwallet", "market123")
assert cleared is True
mock_redis.delete.assert_called_once()
# ============================================================================
# Batch Analysis Tests
# ============================================================================
class TestBatchAnalysis:
"""Tests for batch assessment."""
@pytest.mark.asyncio
async def test_assess_batch(
self,
mock_redis: AsyncMock,
sample_wallet_profile: WalletProfile,
) -> None:
"""Test batch assessment returns assessments for all bundles."""
scorer = RiskScorer(mock_redis)
bundles = []
for i in range(3):
trade = TradeEvent(
market_id=f"market_{i}",
trade_id=f"tx_{i}",
wallet_address=f"0xwallet{i}",
side="BUY",
outcome="Yes",
outcome_index=0,
price=Decimal("0.50"),
size=Decimal("10000"),
timestamp=datetime.now(UTC),
asset_id="token_123",
)
signal = FreshWalletSignal(
trade_event=trade,
wallet_profile=sample_wallet_profile,
confidence=0.8,
factors={},
)
bundles.append(
SignalBundle(trade_event=trade, fresh_wallet_signal=signal)
)
assessments = await scorer.assess_batch(bundles)
assert len(assessments) == 3
assert all(isinstance(a, RiskAssessment) for a in assessments)
@pytest.mark.asyncio
async def test_assess_batch_empty(self, mock_redis: AsyncMock) -> None:
"""Test batch assessment with empty list."""
scorer = RiskScorer(mock_redis)
assessments = await scorer.assess_batch([])
assert assessments == []
# ============================================================================
# Weight Management Tests
# ============================================================================
class TestWeightManagement:
"""Tests for weight get/set functionality."""
def test_get_weights(self, mock_redis: AsyncMock) -> None:
"""Test getting weights returns a copy."""
scorer = RiskScorer(mock_redis)
weights = scorer.get_weights()
assert weights == DEFAULT_WEIGHTS
# Verify it's a copy, not the original
weights["fresh_wallet"] = 999
assert scorer._weights["fresh_wallet"] != 999
def test_set_weights(self, mock_redis: AsyncMock) -> None:
"""Test setting new weights."""
scorer = RiskScorer(mock_redis)
new_weights = {"fresh_wallet": 0.5, "size_anomaly": 0.5}
scorer.set_weights(new_weights)
assert scorer._weights == new_weights
def test_set_weights_makes_copy(self, mock_redis: AsyncMock) -> None:
"""Test set_weights makes a copy of the input."""
scorer = RiskScorer(mock_redis)
new_weights = {"fresh_wallet": 0.5, "size_anomaly": 0.5}
scorer.set_weights(new_weights)
new_weights["fresh_wallet"] = 999
assert scorer._weights["fresh_wallet"] == 0.5
+829
View File
@@ -0,0 +1,829 @@
"""Tests for position size anomaly detection."""
from datetime import UTC, datetime
from decimal import Decimal
from unittest.mock import AsyncMock
import pytest
from polymarket_insider_tracker.detector.models import SizeAnomalySignal
from polymarket_insider_tracker.detector.size_anomaly import (
DEFAULT_BOOK_THRESHOLD,
DEFAULT_NICHE_VOLUME_THRESHOLD,
DEFAULT_VOLUME_THRESHOLD,
NICHE_PRONE_CATEGORIES,
SizeAnomalyDetector,
)
from polymarket_insider_tracker.ingestor.metadata_sync import MarketMetadataSync
from polymarket_insider_tracker.ingestor.models import MarketMetadata, Token, TradeEvent
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def mock_metadata_sync() -> AsyncMock:
"""Create a mock MarketMetadataSync."""
return AsyncMock(spec=MarketMetadataSync)
@pytest.fixture
def sample_token() -> Token:
"""Create a sample token."""
return Token(
token_id="token_123",
outcome="Yes",
price=Decimal("0.65"),
)
@pytest.fixture
def sample_metadata(sample_token: Token) -> MarketMetadata:
"""Create sample market metadata."""
return MarketMetadata(
condition_id="market_abc123",
question="Will it rain tomorrow?",
description="Weather prediction market",
tokens=(sample_token,),
category="science",
)
@pytest.fixture
def sample_trade() -> TradeEvent:
"""Create a sample trade event."""
return TradeEvent(
market_id="market_abc123",
trade_id="tx_001",
wallet_address="0x1234567890abcdef",
side="BUY",
outcome="Yes",
outcome_index=0,
price=Decimal("0.65"),
size=Decimal("10000"), # $6,500 notional
timestamp=datetime.now(UTC),
asset_id="token_123",
event_title="Weather Market",
)
@pytest.fixture
def large_trade() -> TradeEvent:
"""Create a large trade event."""
return TradeEvent(
market_id="market_abc123",
trade_id="tx_002",
wallet_address="0xlargewallet",
side="BUY",
outcome="Yes",
outcome_index=0,
price=Decimal("0.50"),
size=Decimal("100000"), # $50,000 notional
timestamp=datetime.now(UTC),
asset_id="token_123",
event_title="Big Market",
)
# ============================================================================
# SizeAnomalySignal Tests
# ============================================================================
class TestSizeAnomalySignal:
"""Tests for the SizeAnomalySignal dataclass."""
def test_signal_creation(
self, sample_trade: TradeEvent, sample_metadata: MarketMetadata
) -> None:
"""Test basic signal creation."""
signal = SizeAnomalySignal(
trade_event=sample_trade,
market_metadata=sample_metadata,
volume_impact=0.05,
book_impact=0.10,
is_niche_market=True,
confidence=0.75,
factors={"volume_impact": 0.4, "niche_multiplier": 1.5},
)
assert signal.trade_event == sample_trade
assert signal.market_metadata == sample_metadata
assert signal.volume_impact == 0.05
assert signal.book_impact == 0.10
assert signal.is_niche_market is True
assert signal.confidence == 0.75
assert "volume_impact" in signal.factors
def test_wallet_address_property(
self, sample_trade: TradeEvent, sample_metadata: MarketMetadata
) -> None:
"""Test wallet_address property."""
signal = SizeAnomalySignal(
trade_event=sample_trade,
market_metadata=sample_metadata,
volume_impact=0.05,
book_impact=0.10,
is_niche_market=False,
confidence=0.5,
factors={},
)
assert signal.wallet_address == sample_trade.wallet_address
def test_market_id_property(
self, sample_trade: TradeEvent, sample_metadata: MarketMetadata
) -> None:
"""Test market_id property."""
signal = SizeAnomalySignal(
trade_event=sample_trade,
market_metadata=sample_metadata,
volume_impact=0.05,
book_impact=0.10,
is_niche_market=False,
confidence=0.5,
factors={},
)
assert signal.market_id == sample_trade.market_id
def test_trade_size_usdc_property(
self, sample_trade: TradeEvent, sample_metadata: MarketMetadata
) -> None:
"""Test trade_size_usdc property returns notional value."""
signal = SizeAnomalySignal(
trade_event=sample_trade,
market_metadata=sample_metadata,
volume_impact=0.05,
book_impact=0.10,
is_niche_market=False,
confidence=0.5,
factors={},
)
# notional = price * size = 0.65 * 10000 = 6500
assert signal.trade_size_usdc == Decimal("6500.00")
def test_is_high_confidence(
self, sample_trade: TradeEvent, sample_metadata: MarketMetadata
) -> None:
"""Test is_high_confidence threshold."""
high_signal = SizeAnomalySignal(
trade_event=sample_trade,
market_metadata=sample_metadata,
volume_impact=0.05,
book_impact=0.10,
is_niche_market=False,
confidence=0.70,
factors={},
)
low_signal = SizeAnomalySignal(
trade_event=sample_trade,
market_metadata=sample_metadata,
volume_impact=0.05,
book_impact=0.10,
is_niche_market=False,
confidence=0.69,
factors={},
)
assert high_signal.is_high_confidence is True
assert low_signal.is_high_confidence is False
def test_is_very_high_confidence(
self, sample_trade: TradeEvent, sample_metadata: MarketMetadata
) -> None:
"""Test is_very_high_confidence threshold."""
very_high = SizeAnomalySignal(
trade_event=sample_trade,
market_metadata=sample_metadata,
volume_impact=0.05,
book_impact=0.10,
is_niche_market=False,
confidence=0.85,
factors={},
)
high = SizeAnomalySignal(
trade_event=sample_trade,
market_metadata=sample_metadata,
volume_impact=0.05,
book_impact=0.10,
is_niche_market=False,
confidence=0.84,
factors={},
)
assert very_high.is_very_high_confidence is True
assert high.is_very_high_confidence is False
def test_to_dict_serialization(
self, sample_trade: TradeEvent, sample_metadata: MarketMetadata
) -> None:
"""Test to_dict produces valid serialization."""
signal = SizeAnomalySignal(
trade_event=sample_trade,
market_metadata=sample_metadata,
volume_impact=0.05,
book_impact=0.10,
is_niche_market=True,
confidence=0.75,
factors={"volume_impact": 0.5},
)
result = signal.to_dict()
assert result["wallet_address"] == sample_trade.wallet_address
assert result["market_id"] == sample_trade.market_id
assert result["trade_id"] == sample_trade.trade_id
assert result["trade_size"] == "6500.00"
assert result["trade_side"] == "BUY"
assert result["market_category"] == "science"
assert result["volume_impact"] == 0.05
assert result["book_impact"] == 0.10
assert result["is_niche_market"] is True
assert result["confidence"] == 0.75
assert result["factors"] == {"volume_impact": 0.5}
assert "timestamp" in result
# ============================================================================
# SizeAnomalyDetector Initialization Tests
# ============================================================================
class TestSizeAnomalyDetectorInit:
"""Tests for SizeAnomalyDetector initialization."""
def test_default_initialization(self, mock_metadata_sync: AsyncMock) -> None:
"""Test detector initializes with default values."""
detector = SizeAnomalyDetector(mock_metadata_sync)
assert detector._volume_threshold == DEFAULT_VOLUME_THRESHOLD
assert detector._book_threshold == DEFAULT_BOOK_THRESHOLD
assert detector._niche_volume_threshold == DEFAULT_NICHE_VOLUME_THRESHOLD
def test_custom_thresholds(self, mock_metadata_sync: AsyncMock) -> None:
"""Test detector with custom thresholds."""
detector = SizeAnomalyDetector(
mock_metadata_sync,
volume_threshold=0.05,
book_threshold=0.10,
niche_volume_threshold=Decimal("100000"),
)
assert detector._volume_threshold == 0.05
assert detector._book_threshold == 0.10
assert detector._niche_volume_threshold == Decimal("100000")
# ============================================================================
# Volume Impact Tests
# ============================================================================
class TestVolumeImpactCalculation:
"""Tests for volume impact calculation."""
def test_volume_impact_calculation(self, mock_metadata_sync: AsyncMock) -> None:
"""Test correct volume impact calculation."""
detector = SizeAnomalyDetector(mock_metadata_sync)
# Trade size $1000, daily volume $50000 = 2% impact
impact = detector._calculate_volume_impact(
Decimal("1000"), Decimal("50000")
)
assert impact == pytest.approx(0.02)
def test_volume_impact_none_volume(self, mock_metadata_sync: AsyncMock) -> None:
"""Test volume impact returns 0 when volume is None."""
detector = SizeAnomalyDetector(mock_metadata_sync)
impact = detector._calculate_volume_impact(Decimal("1000"), None)
assert impact == 0.0
def test_volume_impact_zero_volume(self, mock_metadata_sync: AsyncMock) -> None:
"""Test volume impact returns 0 when volume is zero."""
detector = SizeAnomalyDetector(mock_metadata_sync)
impact = detector._calculate_volume_impact(Decimal("1000"), Decimal("0"))
assert impact == 0.0
def test_volume_impact_negative_volume(
self, mock_metadata_sync: AsyncMock
) -> None:
"""Test volume impact returns 0 when volume is negative."""
detector = SizeAnomalyDetector(mock_metadata_sync)
impact = detector._calculate_volume_impact(Decimal("1000"), Decimal("-1000"))
assert impact == 0.0
# ============================================================================
# Book Impact Tests
# ============================================================================
class TestBookImpactCalculation:
"""Tests for order book impact calculation."""
def test_book_impact_calculation(self, mock_metadata_sync: AsyncMock) -> None:
"""Test correct book impact calculation."""
detector = SizeAnomalyDetector(mock_metadata_sync)
# Trade size $5000, book depth $50000 = 10% impact
impact = detector._calculate_book_impact(Decimal("5000"), Decimal("50000"))
assert impact == pytest.approx(0.10)
def test_book_impact_none_depth(self, mock_metadata_sync: AsyncMock) -> None:
"""Test book impact returns 0 when depth is None."""
detector = SizeAnomalyDetector(mock_metadata_sync)
impact = detector._calculate_book_impact(Decimal("5000"), None)
assert impact == 0.0
def test_book_impact_zero_depth(self, mock_metadata_sync: AsyncMock) -> None:
"""Test book impact returns 0 when depth is zero."""
detector = SizeAnomalyDetector(mock_metadata_sync)
impact = detector._calculate_book_impact(Decimal("5000"), Decimal("0"))
assert impact == 0.0
# ============================================================================
# Niche Market Detection Tests
# ============================================================================
class TestNicheMarketDetection:
"""Tests for niche market detection."""
def test_niche_market_low_volume(
self, mock_metadata_sync: AsyncMock, sample_metadata: MarketMetadata
) -> None:
"""Test market is niche when volume below threshold."""
detector = SizeAnomalyDetector(mock_metadata_sync)
# Volume $40k < $50k threshold
is_niche = detector._is_niche_market(sample_metadata, Decimal("40000"))
assert is_niche is True
def test_not_niche_high_volume(
self, mock_metadata_sync: AsyncMock, sample_metadata: MarketMetadata
) -> None:
"""Test market is not niche when volume above threshold."""
detector = SizeAnomalyDetector(mock_metadata_sync)
# Volume $100k > $50k threshold
is_niche = detector._is_niche_market(sample_metadata, Decimal("100000"))
assert is_niche is False
def test_niche_market_unknown_volume_niche_category(
self, mock_metadata_sync: AsyncMock, sample_token: Token
) -> None:
"""Test market is niche when volume unknown and category is niche-prone."""
detector = SizeAnomalyDetector(mock_metadata_sync)
for category in NICHE_PRONE_CATEGORIES:
metadata = MarketMetadata(
condition_id="test",
question="Test",
description="",
tokens=(sample_token,),
category=category,
)
is_niche = detector._is_niche_market(metadata, None)
assert is_niche is True, f"Category {category} should be niche"
def test_not_niche_unknown_volume_mainstream_category(
self, mock_metadata_sync: AsyncMock, sample_token: Token
) -> None:
"""Test market is not niche when volume unknown but category is mainstream."""
detector = SizeAnomalyDetector(mock_metadata_sync)
mainstream_categories = ["politics", "sports", "crypto", "entertainment"]
for category in mainstream_categories:
metadata = MarketMetadata(
condition_id="test",
question="Test",
description="",
tokens=(sample_token,),
category=category,
)
is_niche = detector._is_niche_market(metadata, None)
assert is_niche is False, f"Category {category} should not be niche"
# ============================================================================
# Confidence Scoring Tests
# ============================================================================
class TestConfidenceScoring:
"""Tests for confidence score calculation."""
def test_confidence_volume_impact_only(
self, mock_metadata_sync: AsyncMock
) -> None:
"""Test confidence with only volume impact."""
detector = SizeAnomalyDetector(mock_metadata_sync)
# Volume impact 3x threshold = max score 0.5
confidence, factors = detector.calculate_confidence(
volume_impact=0.06, # 3x the 0.02 threshold
book_impact=0.0,
is_niche=False,
)
assert confidence == pytest.approx(0.5)
assert "volume_impact" in factors
assert factors["volume_impact"] == pytest.approx(0.5)
def test_confidence_book_impact_only(self, mock_metadata_sync: AsyncMock) -> None:
"""Test confidence with only book impact."""
detector = SizeAnomalyDetector(mock_metadata_sync)
# Book impact 3x threshold = max score 0.3
confidence, factors = detector.calculate_confidence(
volume_impact=0.0,
book_impact=0.15, # 3x the 0.05 threshold
is_niche=False,
)
assert confidence == pytest.approx(0.3)
assert "book_impact" in factors
assert factors["book_impact"] == pytest.approx(0.3)
def test_confidence_combined_impacts(self, mock_metadata_sync: AsyncMock) -> None:
"""Test confidence with both volume and book impact."""
detector = SizeAnomalyDetector(mock_metadata_sync)
# Both at 3x threshold = 0.5 + 0.3 = 0.8
confidence, factors = detector.calculate_confidence(
volume_impact=0.06,
book_impact=0.15,
is_niche=False,
)
assert confidence == pytest.approx(0.8)
assert "volume_impact" in factors
assert "book_impact" in factors
def test_confidence_niche_multiplier(self, mock_metadata_sync: AsyncMock) -> None:
"""Test niche multiplier increases confidence."""
detector = SizeAnomalyDetector(mock_metadata_sync)
# Volume impact 2x threshold = 0.33, with 1.5x niche = 0.5
confidence, factors = detector.calculate_confidence(
volume_impact=0.04, # 2x threshold
book_impact=0.0,
is_niche=True,
)
assert confidence == pytest.approx(0.5, rel=0.01)
assert "niche_multiplier" in factors
assert factors["niche_multiplier"] == 1.5
def test_confidence_niche_only_base(self, mock_metadata_sync: AsyncMock) -> None:
"""Test niche market with no other signals gives base confidence."""
detector = SizeAnomalyDetector(mock_metadata_sync)
# No threshold exceeded, but is niche
confidence, factors = detector.calculate_confidence(
volume_impact=0.01, # Below 0.02 threshold
book_impact=0.01, # Below 0.05 threshold
is_niche=True,
)
assert confidence == 0.2
assert "niche_base" in factors
assert factors["niche_base"] == 0.2
def test_confidence_clamped_to_max(self, mock_metadata_sync: AsyncMock) -> None:
"""Test confidence is clamped to 1.0."""
detector = SizeAnomalyDetector(mock_metadata_sync)
# High impacts with niche multiplier would exceed 1.0
confidence, factors = detector.calculate_confidence(
volume_impact=0.10, # 5x threshold (capped at 3x)
book_impact=0.20, # 4x threshold (capped at 3x)
is_niche=True, # 1.5x multiplier
)
assert confidence == 1.0
def test_confidence_zero_no_signals(self, mock_metadata_sync: AsyncMock) -> None:
"""Test confidence is zero with no signals."""
detector = SizeAnomalyDetector(mock_metadata_sync)
confidence, factors = detector.calculate_confidence(
volume_impact=0.01, # Below threshold
book_impact=0.01, # Below threshold
is_niche=False,
)
assert confidence == 0.0
assert len(factors) == 0
# ============================================================================
# Analyze Method Tests
# ============================================================================
class TestAnalyzeMethod:
"""Tests for the analyze method."""
@pytest.mark.asyncio
async def test_analyze_high_volume_impact(
self,
mock_metadata_sync: AsyncMock,
sample_trade: TradeEvent,
sample_metadata: MarketMetadata,
) -> None:
"""Test analyze detects high volume impact trade."""
mock_metadata_sync.get_market.return_value = sample_metadata
detector = SizeAnomalyDetector(mock_metadata_sync)
# Trade notional = 6500, volume = 65000, impact = 10% > 2% threshold
signal = await detector.analyze(
sample_trade,
daily_volume=Decimal("65000"),
)
assert signal is not None
assert signal.volume_impact == pytest.approx(0.10)
assert signal.confidence > 0.1
@pytest.mark.asyncio
async def test_analyze_high_book_impact(
self,
mock_metadata_sync: AsyncMock,
sample_trade: TradeEvent,
sample_metadata: MarketMetadata,
) -> None:
"""Test analyze detects high book impact trade."""
mock_metadata_sync.get_market.return_value = sample_metadata
detector = SizeAnomalyDetector(mock_metadata_sync)
# Trade notional = 6500, book depth = 32500, impact = 20% > 5% threshold
signal = await detector.analyze(
sample_trade,
book_depth=Decimal("32500"),
)
assert signal is not None
assert signal.book_impact == pytest.approx(0.20)
assert signal.confidence > 0.1
@pytest.mark.asyncio
async def test_analyze_niche_market(
self,
mock_metadata_sync: AsyncMock,
sample_trade: TradeEvent,
sample_metadata: MarketMetadata,
) -> None:
"""Test analyze detects niche market trade."""
mock_metadata_sync.get_market.return_value = sample_metadata
detector = SizeAnomalyDetector(mock_metadata_sync)
# Low volume market (science category with volume unknown)
signal = await detector.analyze(sample_trade)
assert signal is not None
assert signal.is_niche_market is True
assert signal.confidence == 0.2 # niche_base
@pytest.mark.asyncio
async def test_analyze_no_anomaly(
self,
mock_metadata_sync: AsyncMock,
sample_token: Token,
) -> None:
"""Test analyze returns None for normal trade."""
# Politics category is not niche
metadata = MarketMetadata(
condition_id="market_politics",
question="Will Biden win?",
description="",
tokens=(sample_token,),
category="politics",
)
mock_metadata_sync.get_market.return_value = metadata
trade = TradeEvent(
market_id="market_politics",
trade_id="tx_normal",
wallet_address="0xnormal",
side="BUY",
outcome="Yes",
outcome_index=0,
price=Decimal("0.50"),
size=Decimal("100"), # Small trade = $50 notional
timestamp=datetime.now(UTC),
asset_id="token_pol",
)
detector = SizeAnomalyDetector(mock_metadata_sync)
# High volume, large book depth = low impact
signal = await detector.analyze(
trade,
daily_volume=Decimal("1000000"),
book_depth=Decimal("500000"),
)
assert signal is None
@pytest.mark.asyncio
async def test_analyze_creates_minimal_metadata_on_missing(
self,
mock_metadata_sync: AsyncMock,
sample_trade: TradeEvent,
) -> None:
"""Test analyze creates minimal metadata when market not found."""
mock_metadata_sync.get_market.return_value = None
detector = SizeAnomalyDetector(mock_metadata_sync)
# Should still work with minimal metadata (category="other" which is niche)
signal = await detector.analyze(sample_trade)
assert signal is not None
assert signal.market_metadata.condition_id == sample_trade.market_id
assert signal.market_metadata.category == "other"
@pytest.mark.asyncio
async def test_analyze_handles_metadata_exception(
self,
mock_metadata_sync: AsyncMock,
sample_trade: TradeEvent,
) -> None:
"""Test analyze handles exception when fetching metadata."""
mock_metadata_sync.get_market.side_effect = Exception("Redis error")
detector = SizeAnomalyDetector(mock_metadata_sync)
# Should still work with minimal metadata
signal = await detector.analyze(sample_trade)
assert signal is not None
assert signal.market_metadata.category == "other"
@pytest.mark.asyncio
async def test_analyze_low_confidence_filtered(
self,
mock_metadata_sync: AsyncMock,
sample_token: Token,
) -> None:
"""Test analyze returns None when confidence is below 0.1."""
# Use a mainstream category with below-threshold impacts
metadata = MarketMetadata(
condition_id="market_sports",
question="Super Bowl winner?",
description="",
tokens=(sample_token,),
category="sports",
)
mock_metadata_sync.get_market.return_value = metadata
trade = TradeEvent(
market_id="market_sports",
trade_id="tx_small",
wallet_address="0xsmall",
side="BUY",
outcome="Yes",
outcome_index=0,
price=Decimal("0.50"),
size=Decimal("10"), # Tiny trade
timestamp=datetime.now(UTC),
asset_id="token_sports",
)
detector = SizeAnomalyDetector(mock_metadata_sync)
# High volume but below threshold impacts
signal = await detector.analyze(
trade,
daily_volume=Decimal("10000000"), # $10M volume
book_depth=Decimal("1000000"), # $1M depth
)
assert signal is None
# ============================================================================
# Batch Analysis Tests
# ============================================================================
class TestBatchAnalysis:
"""Tests for batch analysis."""
@pytest.mark.asyncio
async def test_analyze_batch_returns_signals(
self,
mock_metadata_sync: AsyncMock,
sample_metadata: MarketMetadata,
) -> None:
"""Test batch analysis returns signals for anomalous trades."""
mock_metadata_sync.get_market.return_value = sample_metadata
trades = [
TradeEvent(
market_id="market_abc123",
trade_id=f"tx_{i}",
wallet_address=f"0xwallet{i}",
side="BUY",
outcome="Yes",
outcome_index=0,
price=Decimal("0.50"),
size=Decimal("10000"), # Large trade
timestamp=datetime.now(UTC),
asset_id="token_123",
)
for i in range(3)
]
detector = SizeAnomalyDetector(mock_metadata_sync)
signals = await detector.analyze_batch(trades)
# All trades are in niche category with unknown volume
assert len(signals) == 3
@pytest.mark.asyncio
async def test_analyze_batch_with_volume_data(
self,
mock_metadata_sync: AsyncMock,
sample_metadata: MarketMetadata,
) -> None:
"""Test batch analysis uses provided volume data."""
mock_metadata_sync.get_market.return_value = sample_metadata
trades = [
TradeEvent(
market_id="market_abc123",
trade_id="tx_1",
wallet_address="0xwallet1",
side="BUY",
outcome="Yes",
outcome_index=0,
price=Decimal("0.50"),
size=Decimal("10000"), # $5000 notional
timestamp=datetime.now(UTC),
asset_id="token_123",
)
]
detector = SizeAnomalyDetector(mock_metadata_sync)
# $5000 trade / $50000 volume = 10% impact
signals = await detector.analyze_batch(
trades,
volume_data={"market_abc123": Decimal("50000")},
)
assert len(signals) == 1
assert signals[0].volume_impact == pytest.approx(0.10)
@pytest.mark.asyncio
async def test_analyze_batch_handles_errors(
self,
mock_metadata_sync: AsyncMock,
) -> None:
"""Test batch analysis handles individual trade errors."""
# First call succeeds, second fails
mock_metadata_sync.get_market.side_effect = [
Exception("Error"),
None,
]
trades = [
TradeEvent(
market_id=f"market_{i}",
trade_id=f"tx_{i}",
wallet_address=f"0xwallet{i}",
side="BUY",
outcome="Yes",
outcome_index=0,
price=Decimal("0.50"),
size=Decimal("10000"),
timestamp=datetime.now(UTC),
asset_id="token_123",
)
for i in range(2)
]
detector = SizeAnomalyDetector(mock_metadata_sync)
signals = await detector.analyze_batch(trades)
# Both should still produce signals (with minimal metadata fallback)
assert len(signals) == 2
@pytest.mark.asyncio
async def test_analyze_batch_empty_list(
self, mock_metadata_sync: AsyncMock
) -> None:
"""Test batch analysis with empty list."""
detector = SizeAnomalyDetector(mock_metadata_sync)
signals = await detector.analyze_batch([])
assert signals == []
+599
View File
@@ -0,0 +1,599 @@
"""Tests for the SniperDetector module."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from unittest.mock import MagicMock
from polymarket_insider_tracker.detector.models import SniperClusterSignal
from polymarket_insider_tracker.detector.sniper import (
ClusterInfo,
MarketEntry,
SniperDetector,
)
def create_mock_trade(
wallet_address: str,
market_id: str,
timestamp: datetime,
notional_value: Decimal = Decimal("1000"),
) -> MagicMock:
"""Create a mock TradeEvent for testing."""
trade = MagicMock()
trade.wallet_address = wallet_address
trade.market_id = market_id
trade.timestamp = timestamp
trade.notional_value = notional_value
return trade
class TestSniperDetectorInit:
"""Tests for SniperDetector initialization."""
def test_default_parameters(self) -> None:
"""Test initialization with default parameters."""
detector = SniperDetector()
assert detector.entry_threshold_seconds == 300
assert detector.min_cluster_size == 3
assert detector.eps == 0.5
assert detector.min_samples == 2
assert detector.min_entries_per_wallet == 2
def test_custom_parameters(self) -> None:
"""Test initialization with custom parameters."""
detector = SniperDetector(
entry_threshold_seconds=600,
min_cluster_size=5,
eps=0.3,
min_samples=3,
min_entries_per_wallet=4,
)
assert detector.entry_threshold_seconds == 600
assert detector.min_cluster_size == 5
assert detector.eps == 0.3
assert detector.min_samples == 3
assert detector.min_entries_per_wallet == 4
def test_empty_initial_state(self) -> None:
"""Test that detector starts with empty state."""
detector = SniperDetector()
assert detector.get_entry_count() == 0
assert detector.get_wallet_count() == 0
assert detector.get_cluster_count() == 0
class TestRecordEntry:
"""Tests for record_entry method."""
def test_records_entry_within_threshold(self) -> None:
"""Test that entries within threshold are recorded."""
detector = SniperDetector(entry_threshold_seconds=300)
market_created = datetime.now(UTC)
trade_time = market_created + timedelta(seconds=60)
trade = create_mock_trade(
wallet_address="0x1111111111111111111111111111111111111111",
market_id="market_001",
timestamp=trade_time,
)
detector.record_entry(trade, market_created)
assert detector.get_entry_count() == 1
assert detector.get_wallet_count() == 1
def test_ignores_entry_after_threshold(self) -> None:
"""Test that entries after threshold are ignored."""
detector = SniperDetector(entry_threshold_seconds=300)
market_created = datetime.now(UTC)
trade_time = market_created + timedelta(seconds=400) # 400s > 300s threshold
trade = create_mock_trade(
wallet_address="0x1111111111111111111111111111111111111111",
market_id="market_001",
timestamp=trade_time,
)
detector.record_entry(trade, market_created)
assert detector.get_entry_count() == 0
def test_ignores_entry_before_market_creation(self) -> None:
"""Test that entries before market creation are ignored."""
detector = SniperDetector()
market_created = datetime.now(UTC)
trade_time = market_created - timedelta(seconds=60) # Before creation
trade = create_mock_trade(
wallet_address="0x1111111111111111111111111111111111111111",
market_id="market_001",
timestamp=trade_time,
)
detector.record_entry(trade, market_created)
assert detector.get_entry_count() == 0
def test_tracks_multiple_wallets(self) -> None:
"""Test tracking entries from multiple wallets."""
detector = SniperDetector()
market_created = datetime.now(UTC)
for i in range(5):
trade = create_mock_trade(
wallet_address=f"0x{i:040x}",
market_id="market_001",
timestamp=market_created + timedelta(seconds=30 * i),
)
detector.record_entry(trade, market_created)
assert detector.get_entry_count() == 5
assert detector.get_wallet_count() == 5
def test_tracks_wallet_across_markets(self) -> None:
"""Test tracking one wallet across multiple markets."""
detector = SniperDetector()
wallet = "0x1111111111111111111111111111111111111111"
for i in range(3):
market_created = datetime.now(UTC)
trade = create_mock_trade(
wallet_address=wallet,
market_id=f"market_{i:03d}",
timestamp=market_created + timedelta(seconds=60),
)
detector.record_entry(trade, market_created)
assert detector.get_entry_count() == 3
assert detector.get_wallet_count() == 1
class TestRunClustering:
"""Tests for run_clustering method."""
def test_returns_empty_with_insufficient_wallets(self) -> None:
"""Test that clustering returns empty with too few wallets."""
detector = SniperDetector(min_cluster_size=3)
# Add entries for only 2 wallets
for i in range(2):
market_created = datetime.now(UTC)
for j in range(3): # Multiple entries per wallet
trade = create_mock_trade(
wallet_address=f"0x{i:040x}",
market_id=f"market_{j:03d}",
timestamp=market_created + timedelta(seconds=30),
)
detector.record_entry(trade, market_created)
signals = detector.run_clustering()
assert signals == []
def test_returns_empty_with_insufficient_entries_per_wallet(self) -> None:
"""Test that wallets with few entries are excluded."""
detector = SniperDetector(
min_cluster_size=2,
min_entries_per_wallet=3,
)
# Add only 2 entries per wallet
for i in range(5):
for j in range(2): # Only 2 entries
market_created = datetime.now(UTC)
trade = create_mock_trade(
wallet_address=f"0x{i:040x}",
market_id=f"market_{j:03d}",
timestamp=market_created + timedelta(seconds=30),
)
detector.record_entry(trade, market_created)
signals = detector.run_clustering()
assert signals == []
def test_detects_sniper_cluster(self) -> None:
"""Test detection of a cluster of snipers with similar patterns."""
detector = SniperDetector(
min_cluster_size=3,
min_entries_per_wallet=2,
eps=1.0, # Larger eps for easier clustering
min_samples=2,
)
# Create 5 wallets that all enter markets within 30 seconds
wallets = [f"0x{i:040x}" for i in range(5)]
markets = ["market_001", "market_002", "market_003"]
for market in markets:
market_created = datetime.now(UTC)
for wallet in wallets:
trade = create_mock_trade(
wallet_address=wallet,
market_id=market,
timestamp=market_created + timedelta(seconds=30),
notional_value=Decimal("1000"),
)
detector.record_entry(trade, market_created)
signals = detector.run_clustering()
# Should detect at least one cluster
assert len(signals) > 0
assert all(isinstance(s, SniperClusterSignal) for s in signals)
def test_does_not_duplicate_signals(self) -> None:
"""Test that same wallet isn't signaled twice."""
detector = SniperDetector(
min_cluster_size=3,
min_entries_per_wallet=2,
eps=1.0,
min_samples=2,
)
# Create cluster
wallets = [f"0x{i:040x}" for i in range(5)]
markets = ["market_001", "market_002"]
for market in markets:
market_created = datetime.now(UTC)
for wallet in wallets:
trade = create_mock_trade(
wallet_address=wallet,
market_id=market,
timestamp=market_created + timedelta(seconds=30),
)
detector.record_entry(trade, market_created)
# Run clustering twice
signals1 = detector.run_clustering()
signals2 = detector.run_clustering()
# Second run should return empty (no new signals)
assert len(signals2) == 0
# All wallets from signals1 should be unique
signaled_wallets = [s.wallet_address for s in signals1]
assert len(signaled_wallets) == len(set(signaled_wallets))
class TestIsSniper:
"""Tests for is_sniper method."""
def test_returns_false_for_unknown_wallet(self) -> None:
"""Test that unknown wallets return False."""
detector = SniperDetector()
assert detector.is_sniper("0x1111111111111111111111111111111111111111") is False
def test_returns_true_for_cluster_member(self) -> None:
"""Test that cluster members return True."""
detector = SniperDetector(
min_cluster_size=3,
min_entries_per_wallet=2,
eps=1.0,
min_samples=2,
)
wallets = [f"0x{i:040x}" for i in range(5)]
for market in ["market_001", "market_002"]:
market_created = datetime.now(UTC)
for wallet in wallets:
trade = create_mock_trade(
wallet_address=wallet,
market_id=market,
timestamp=market_created + timedelta(seconds=30),
)
detector.record_entry(trade, market_created)
signals = detector.run_clustering()
# All signaled wallets should be snipers
for signal in signals:
assert detector.is_sniper(signal.wallet_address) is True
class TestGetClusterForWallet:
"""Tests for get_cluster_for_wallet method."""
def test_returns_none_for_unknown_wallet(self) -> None:
"""Test that unknown wallets return None."""
detector = SniperDetector()
result = detector.get_cluster_for_wallet("0x1111111111111111111111111111111111111111")
assert result is None
def test_returns_cluster_info_for_member(self) -> None:
"""Test that cluster members get ClusterInfo."""
detector = SniperDetector(
min_cluster_size=3,
min_entries_per_wallet=2,
eps=1.0,
min_samples=2,
)
wallets = [f"0x{i:040x}" for i in range(5)]
for market in ["market_001", "market_002"]:
market_created = datetime.now(UTC)
for wallet in wallets:
trade = create_mock_trade(
wallet_address=wallet,
market_id=market,
timestamp=market_created + timedelta(seconds=30),
)
detector.record_entry(trade, market_created)
signals = detector.run_clustering()
if signals:
wallet = signals[0].wallet_address
cluster = detector.get_cluster_for_wallet(wallet)
assert cluster is not None
assert isinstance(cluster, ClusterInfo)
assert wallet in cluster.wallet_addresses
class TestClearEntries:
"""Tests for clear_entries method."""
def test_clears_all_entries(self) -> None:
"""Test that clear removes all entries."""
detector = SniperDetector()
market_created = datetime.now(UTC)
for i in range(5):
trade = create_mock_trade(
wallet_address=f"0x{i:040x}",
market_id="market_001",
timestamp=market_created + timedelta(seconds=30),
)
detector.record_entry(trade, market_created)
assert detector.get_entry_count() == 5
detector.clear_entries()
assert detector.get_entry_count() == 0
assert detector.get_wallet_count() == 0
class TestMarketEntryDataclass:
"""Tests for MarketEntry dataclass."""
def test_creation(self) -> None:
"""Test MarketEntry creation."""
entry = MarketEntry(
wallet_address="0x1111",
market_id="market_001",
entry_delta_seconds=30.5,
position_size=Decimal("1000"),
timestamp=datetime.now(UTC),
)
assert entry.wallet_address == "0x1111"
assert entry.market_id == "market_001"
assert entry.entry_delta_seconds == 30.5
assert entry.position_size == Decimal("1000")
class TestClusterInfoDataclass:
"""Tests for ClusterInfo dataclass."""
def test_creation(self) -> None:
"""Test ClusterInfo creation."""
wallets = {"0x1111", "0x2222", "0x3333"}
cluster = ClusterInfo(
cluster_id="cluster_001",
wallet_addresses=wallets,
avg_entry_delta=45.0,
markets_in_common=3,
)
assert cluster.cluster_id == "cluster_001"
assert cluster.wallet_addresses == wallets
assert cluster.avg_entry_delta == 45.0
assert cluster.markets_in_common == 3
assert cluster.created_at is not None
class TestSniperClusterSignalModel:
"""Tests for SniperClusterSignal dataclass."""
def test_creation(self) -> None:
"""Test SniperClusterSignal creation."""
signal = SniperClusterSignal(
wallet_address="0x1111",
cluster_id="cluster_001",
cluster_size=5,
avg_entry_delta_seconds=30.0,
markets_in_common=3,
confidence=0.85,
)
assert signal.wallet_address == "0x1111"
assert signal.cluster_id == "cluster_001"
assert signal.cluster_size == 5
assert signal.avg_entry_delta_seconds == 30.0
assert signal.markets_in_common == 3
assert signal.confidence == 0.85
def test_is_high_confidence(self) -> None:
"""Test is_high_confidence property."""
high = SniperClusterSignal(
wallet_address="0x1111",
cluster_id="c1",
cluster_size=5,
avg_entry_delta_seconds=30.0,
markets_in_common=3,
confidence=0.75,
)
low = SniperClusterSignal(
wallet_address="0x2222",
cluster_id="c2",
cluster_size=5,
avg_entry_delta_seconds=30.0,
markets_in_common=3,
confidence=0.65,
)
assert high.is_high_confidence is True
assert low.is_high_confidence is False
def test_is_very_high_confidence(self) -> None:
"""Test is_very_high_confidence property."""
very_high = SniperClusterSignal(
wallet_address="0x1111",
cluster_id="c1",
cluster_size=5,
avg_entry_delta_seconds=30.0,
markets_in_common=3,
confidence=0.90,
)
high = SniperClusterSignal(
wallet_address="0x2222",
cluster_id="c2",
cluster_size=5,
avg_entry_delta_seconds=30.0,
markets_in_common=3,
confidence=0.80,
)
assert very_high.is_very_high_confidence is True
assert high.is_very_high_confidence is False
def test_to_dict(self) -> None:
"""Test to_dict serialization."""
signal = SniperClusterSignal(
wallet_address="0x1111",
cluster_id="cluster_001",
cluster_size=5,
avg_entry_delta_seconds=30.0,
markets_in_common=3,
confidence=0.85,
)
result = signal.to_dict()
assert result["wallet_address"] == "0x1111"
assert result["cluster_id"] == "cluster_001"
assert result["cluster_size"] == 5
assert result["avg_entry_delta_seconds"] == 30.0
assert result["markets_in_common"] == 3
assert result["confidence"] == 0.85
assert "timestamp" in result
class TestConfidenceCalculation:
"""Tests for confidence calculation logic."""
def test_higher_confidence_with_larger_cluster(self) -> None:
"""Test that larger clusters get higher confidence."""
detector = SniperDetector(
min_cluster_size=2,
min_entries_per_wallet=2,
eps=1.0,
min_samples=2,
)
# Create a cluster
stats = {
"avg_delta": 30.0,
"markets_in_common": 3,
}
small_cluster = {"0x1", "0x2", "0x3"}
large_cluster = {"0x1", "0x2", "0x3", "0x4", "0x5", "0x6", "0x7", "0x8"}
small_conf = detector._calculate_confidence(small_cluster, stats)
large_conf = detector._calculate_confidence(large_cluster, stats)
assert large_conf > small_conf
def test_higher_confidence_with_faster_entries(self) -> None:
"""Test that faster entries get higher confidence."""
detector = SniperDetector()
cluster = {"0x1", "0x2", "0x3"}
fast_stats = {"avg_delta": 10.0, "markets_in_common": 3}
slow_stats = {"avg_delta": 200.0, "markets_in_common": 3}
fast_conf = detector._calculate_confidence(cluster, fast_stats)
slow_conf = detector._calculate_confidence(cluster, slow_stats)
assert fast_conf > slow_conf
def test_higher_confidence_with_more_overlap(self) -> None:
"""Test that more market overlap gets higher confidence."""
detector = SniperDetector()
cluster = {"0x1", "0x2", "0x3"}
high_overlap = {"avg_delta": 30.0, "markets_in_common": 5}
low_overlap = {"avg_delta": 30.0, "markets_in_common": 1}
high_conf = detector._calculate_confidence(cluster, high_overlap)
low_conf = detector._calculate_confidence(cluster, low_overlap)
assert high_conf > low_conf
class TestIntegration:
"""Integration tests for the sniper detection workflow."""
def test_end_to_end_sniper_detection(self) -> None:
"""Test complete workflow from entry recording to signal generation."""
detector = SniperDetector(
entry_threshold_seconds=300,
min_cluster_size=3,
min_entries_per_wallet=2,
eps=1.0,
min_samples=2,
)
# Simulate 5 snipers hitting 3 markets in rapid succession
sniper_wallets = [f"0x{'a' * 38}{i:02d}" for i in range(5)]
markets = ["market_001", "market_002", "market_003"]
for market in markets:
market_created = datetime.now(UTC)
for i, wallet in enumerate(sniper_wallets):
# Each sniper enters within 10-60 seconds
trade = create_mock_trade(
wallet_address=wallet,
market_id=market,
timestamp=market_created + timedelta(seconds=10 + i * 10),
notional_value=Decimal("5000"),
)
detector.record_entry(trade, market_created)
# Also add some normal traders (enter after threshold)
for market in markets:
market_created = datetime.now(UTC)
for i in range(3):
trade = create_mock_trade(
wallet_address=f"0x{'b' * 38}{i:02d}",
market_id=market,
timestamp=market_created + timedelta(seconds=400), # After threshold
)
detector.record_entry(trade, market_created)
# Run clustering
signals = detector.run_clustering()
# Should detect the sniper cluster
assert len(signals) > 0
# All signals should be from sniper wallets
for signal in signals:
assert signal.wallet_address in [w.lower() for w in sniper_wallets]
assert signal.cluster_size >= 3
assert signal.confidence > 0
# Verify snipers are marked
for wallet in sniper_wallets:
# May or may not be in cluster depending on clustering
if detector.is_sniper(wallet.lower()):
cluster = detector.get_cluster_for_wallet(wallet)
assert cluster is not None
+369
View File
@@ -0,0 +1,369 @@
"""Tests for known entity registry."""
import pytest
from polymarket_insider_tracker.profiler.entities import EntityRegistry
from polymarket_insider_tracker.profiler.entity_data import (
BRIDGE_ADDRESSES,
CEX_ADDRESSES,
DEFI_ADDRESSES,
DEX_ADDRESSES,
TOKEN_ADDRESSES,
EntityType,
get_all_known_entities,
)
# ============================================================================
# EntityType Tests
# ============================================================================
class TestEntityType:
"""Tests for EntityType enum."""
def test_cex_types_exist(self) -> None:
"""Test that CEX entity types are defined."""
assert EntityType.CEX_BINANCE.value == "cex_binance"
assert EntityType.CEX_COINBASE.value == "cex_coinbase"
assert EntityType.CEX_OTHER.value == "cex_other"
def test_bridge_types_exist(self) -> None:
"""Test that bridge entity types are defined."""
assert EntityType.BRIDGE_POLYGON.value == "bridge_polygon"
assert EntityType.BRIDGE_MULTICHAIN.value == "bridge_multichain"
def test_dex_types_exist(self) -> None:
"""Test that DEX entity types are defined."""
assert EntityType.DEX_UNISWAP.value == "dex_uniswap"
assert EntityType.DEX_SUSHISWAP.value == "dex_sushiswap"
def test_token_types_exist(self) -> None:
"""Test that token entity types are defined."""
assert EntityType.TOKEN_USDC.value == "token_usdc"
assert EntityType.TOKEN_WETH.value == "token_weth"
def test_unknown_type(self) -> None:
"""Test unknown entity type."""
assert EntityType.UNKNOWN.value == "unknown"
# ============================================================================
# Entity Data Tests
# ============================================================================
class TestEntityData:
"""Tests for entity data mappings."""
def test_cex_addresses_populated(self) -> None:
"""Test that CEX addresses are populated."""
assert len(CEX_ADDRESSES) > 0
# Check Binance address is present
binance_found = any(
entity == EntityType.CEX_BINANCE for entity in CEX_ADDRESSES.values()
)
assert binance_found
def test_bridge_addresses_populated(self) -> None:
"""Test that bridge addresses are populated."""
assert len(BRIDGE_ADDRESSES) > 0
def test_dex_addresses_populated(self) -> None:
"""Test that DEX addresses are populated."""
assert len(DEX_ADDRESSES) > 0
# Check Uniswap is present
uniswap_found = any(
entity == EntityType.DEX_UNISWAP for entity in DEX_ADDRESSES.values()
)
assert uniswap_found
def test_token_addresses_include_usdc(self) -> None:
"""Test that USDC address is in token addresses."""
usdc_address = "0x2791bca1f2de4661ed88a30c99a7a9449aa84174"
assert usdc_address in TOKEN_ADDRESSES
assert TOKEN_ADDRESSES[usdc_address] == EntityType.TOKEN_USDC
def test_get_all_known_entities(self) -> None:
"""Test combining all entity mappings."""
all_entities = get_all_known_entities()
total_expected = (
len(CEX_ADDRESSES)
+ len(BRIDGE_ADDRESSES)
+ len(DEX_ADDRESSES)
+ len(TOKEN_ADDRESSES)
+ len(DEFI_ADDRESSES)
)
assert len(all_entities) == total_expected
def test_addresses_are_lowercase(self) -> None:
"""Test that all addresses in get_all_known_entities are lowercase."""
all_entities = get_all_known_entities()
for address in all_entities:
assert address == address.lower()
# ============================================================================
# EntityRegistry Tests
# ============================================================================
class TestEntityRegistryInit:
"""Tests for EntityRegistry initialization."""
def test_default_initialization(self) -> None:
"""Test registry initializes with default entities."""
registry = EntityRegistry()
assert len(registry) > 0
def test_without_defaults(self) -> None:
"""Test registry without default entities."""
registry = EntityRegistry(include_defaults=False)
assert len(registry) == 0
def test_with_custom_entities(self) -> None:
"""Test registry with custom entities."""
custom = {"0x1234": EntityType.CEX_OTHER}
registry = EntityRegistry(custom_entities=custom, include_defaults=False)
assert len(registry) == 1
assert registry.classify("0x1234") == EntityType.CEX_OTHER
def test_custom_entities_override_defaults(self) -> None:
"""Test that custom entities can override defaults."""
# USDC address is in defaults as TOKEN_USDC
usdc_address = "0x2791bca1f2de4661ed88a30c99a7a9449aa84174"
custom = {usdc_address: EntityType.CONTRACT}
registry = EntityRegistry(custom_entities=custom)
assert registry.classify(usdc_address) == EntityType.CONTRACT
class TestEntityRegistryClassify:
"""Tests for EntityRegistry.classify method."""
@pytest.fixture
def registry(self) -> EntityRegistry:
"""Create a registry for testing."""
return EntityRegistry()
def test_classify_known_cex(self, registry: EntityRegistry) -> None:
"""Test classifying a known CEX address."""
# Binance address
binance = "0x28c6c06298d514db089934071355e5743bf21d60"
assert registry.classify(binance) == EntityType.CEX_BINANCE
def test_classify_case_insensitive(self, registry: EntityRegistry) -> None:
"""Test that classification is case-insensitive."""
binance_lower = "0x28c6c06298d514db089934071355e5743bf21d60"
binance_mixed = "0x28C6c06298D514db089934071355E5743bf21d60"
assert registry.classify(binance_lower) == registry.classify(binance_mixed)
def test_classify_unknown(self, registry: EntityRegistry) -> None:
"""Test classifying an unknown address."""
unknown = "0x0000000000000000000000000000000000000000"
assert registry.classify(unknown) == EntityType.UNKNOWN
def test_classify_usdc(self, registry: EntityRegistry) -> None:
"""Test classifying USDC token contract."""
usdc = "0x2791bca1f2de4661ed88a30c99a7a9449aa84174"
assert registry.classify(usdc) == EntityType.TOKEN_USDC
class TestEntityRegistryChecks:
"""Tests for EntityRegistry type check methods."""
@pytest.fixture
def registry(self) -> EntityRegistry:
"""Create a registry for testing."""
return EntityRegistry()
def test_is_known_entity_true(self, registry: EntityRegistry) -> None:
"""Test is_known_entity returns True for known addresses."""
binance = "0x28c6c06298d514db089934071355e5743bf21d60"
assert registry.is_known_entity(binance) is True
def test_is_known_entity_false(self, registry: EntityRegistry) -> None:
"""Test is_known_entity returns False for unknown addresses."""
unknown = "0x0000000000000000000000000000000000000000"
assert registry.is_known_entity(unknown) is False
def test_is_cex_true(self, registry: EntityRegistry) -> None:
"""Test is_cex returns True for CEX addresses."""
binance = "0x28c6c06298d514db089934071355e5743bf21d60"
assert registry.is_cex(binance) is True
def test_is_cex_false(self, registry: EntityRegistry) -> None:
"""Test is_cex returns False for non-CEX addresses."""
usdc = "0x2791bca1f2de4661ed88a30c99a7a9449aa84174"
assert registry.is_cex(usdc) is False
def test_is_bridge_true(self, registry: EntityRegistry) -> None:
"""Test is_bridge returns True for bridge addresses."""
polygon_bridge = "0xa0c68c638235ee32657e8f720a23cec1bfc77c77"
assert registry.is_bridge(polygon_bridge) is True
def test_is_bridge_false(self, registry: EntityRegistry) -> None:
"""Test is_bridge returns False for non-bridge addresses."""
binance = "0x28c6c06298d514db089934071355e5743bf21d60"
assert registry.is_bridge(binance) is False
def test_is_dex_true(self, registry: EntityRegistry) -> None:
"""Test is_dex returns True for DEX addresses."""
uniswap = "0xe592427a0aece92de3edee1f18e0157c05861564"
assert registry.is_dex(uniswap) is True
def test_is_dex_false(self, registry: EntityRegistry) -> None:
"""Test is_dex returns False for non-DEX addresses."""
binance = "0x28c6c06298d514db089934071355e5743bf21d60"
assert registry.is_dex(binance) is False
class TestEntityRegistryTerminal:
"""Tests for EntityRegistry.is_terminal method."""
@pytest.fixture
def registry(self) -> EntityRegistry:
"""Create a registry for testing."""
return EntityRegistry()
def test_cex_is_terminal(self, registry: EntityRegistry) -> None:
"""Test that CEX addresses are terminal."""
binance = "0x28c6c06298d514db089934071355e5743bf21d60"
assert registry.is_terminal(binance) is True
def test_bridge_is_terminal(self, registry: EntityRegistry) -> None:
"""Test that bridge addresses are terminal."""
polygon_bridge = "0xa0c68c638235ee32657e8f720a23cec1bfc77c77"
assert registry.is_terminal(polygon_bridge) is True
def test_dex_is_not_terminal(self, registry: EntityRegistry) -> None:
"""Test that DEX addresses are not terminal."""
uniswap = "0xe592427a0aece92de3edee1f18e0157c05861564"
assert registry.is_terminal(uniswap) is False
def test_token_is_not_terminal(self, registry: EntityRegistry) -> None:
"""Test that token addresses are not terminal."""
usdc = "0x2791bca1f2de4661ed88a30c99a7a9449aa84174"
assert registry.is_terminal(usdc) is False
def test_unknown_is_not_terminal(self, registry: EntityRegistry) -> None:
"""Test that unknown addresses are not terminal."""
unknown = "0x0000000000000000000000000000000000000000"
assert registry.is_terminal(unknown) is False
class TestEntityRegistryCategory:
"""Tests for EntityRegistry.get_entity_category method."""
@pytest.fixture
def registry(self) -> EntityRegistry:
"""Create a registry for testing."""
return EntityRegistry()
def test_category_cex(self, registry: EntityRegistry) -> None:
"""Test CEX category."""
binance = "0x28c6c06298d514db089934071355e5743bf21d60"
assert registry.get_entity_category(binance) == "cex"
def test_category_bridge(self, registry: EntityRegistry) -> None:
"""Test bridge category."""
polygon_bridge = "0xa0c68c638235ee32657e8f720a23cec1bfc77c77"
assert registry.get_entity_category(polygon_bridge) == "bridge"
def test_category_dex(self, registry: EntityRegistry) -> None:
"""Test DEX category."""
uniswap = "0xe592427a0aece92de3edee1f18e0157c05861564"
assert registry.get_entity_category(uniswap) == "dex"
def test_category_token(self, registry: EntityRegistry) -> None:
"""Test token category."""
usdc = "0x2791bca1f2de4661ed88a30c99a7a9449aa84174"
assert registry.get_entity_category(usdc) == "token"
def test_category_defi(self, registry: EntityRegistry) -> None:
"""Test DeFi category."""
aave = "0x794a61358d6845594f94dc1db02a252b5b4814ad"
assert registry.get_entity_category(aave) == "defi"
def test_category_unknown(self, registry: EntityRegistry) -> None:
"""Test unknown category."""
unknown = "0x0000000000000000000000000000000000000000"
assert registry.get_entity_category(unknown) == "unknown"
class TestEntityRegistryMutations:
"""Tests for EntityRegistry mutation methods."""
def test_add_entity(self) -> None:
"""Test adding an entity."""
registry = EntityRegistry(include_defaults=False)
registry.add_entity("0x1234", EntityType.CEX_OTHER)
assert registry.classify("0x1234") == EntityType.CEX_OTHER
def test_add_entity_normalizes_address(self) -> None:
"""Test that add_entity normalizes addresses to lowercase."""
registry = EntityRegistry(include_defaults=False)
registry.add_entity("0xABCD", EntityType.CEX_OTHER)
assert registry.classify("0xabcd") == EntityType.CEX_OTHER
def test_remove_entity(self) -> None:
"""Test removing an entity."""
registry = EntityRegistry(include_defaults=False)
registry.add_entity("0x1234", EntityType.CEX_OTHER)
assert registry.remove_entity("0x1234") is True
assert registry.classify("0x1234") == EntityType.UNKNOWN
def test_remove_nonexistent(self) -> None:
"""Test removing a non-existent entity."""
registry = EntityRegistry(include_defaults=False)
assert registry.remove_entity("0x1234") is False
class TestEntityRegistryDunder:
"""Tests for EntityRegistry dunder methods."""
def test_len(self) -> None:
"""Test __len__ returns count of entities."""
registry = EntityRegistry(include_defaults=False)
registry.add_entity("0x1234", EntityType.CEX_OTHER)
registry.add_entity("0x5678", EntityType.DEX_OTHER)
assert len(registry) == 2
def test_contains(self) -> None:
"""Test __contains__ for membership testing."""
registry = EntityRegistry()
binance = "0x28c6c06298d514db089934071355e5743bf21d60"
assert binance in registry
assert "0x0000000000000000000000000000000000000000" not in registry
class TestEntityRegistryContract:
"""Tests for EntityRegistry.is_contract method."""
@pytest.fixture
def registry(self) -> EntityRegistry:
"""Create a registry for testing."""
return EntityRegistry()
def test_dex_is_contract(self, registry: EntityRegistry) -> None:
"""Test that DEX addresses are contracts."""
uniswap = "0xe592427a0aece92de3edee1f18e0157c05861564"
assert registry.is_contract(uniswap) is True
def test_token_is_contract(self, registry: EntityRegistry) -> None:
"""Test that token addresses are contracts."""
usdc = "0x2791bca1f2de4661ed88a30c99a7a9449aa84174"
assert registry.is_contract(usdc) is True
def test_defi_is_contract(self, registry: EntityRegistry) -> None:
"""Test that DeFi protocol addresses are contracts."""
aave = "0x794a61358d6845594f94dc1db02a252b5b4814ad"
assert registry.is_contract(aave) is True
def test_cex_is_not_contract(self, registry: EntityRegistry) -> None:
"""Test that CEX addresses are not contracts."""
binance = "0x28c6c06298d514db089934071355e5743bf21d60"
assert registry.is_contract(binance) is False
def test_unknown_is_not_contract(self, registry: EntityRegistry) -> None:
"""Test that unknown addresses are not contracts."""
unknown = "0x0000000000000000000000000000000000000000"
assert registry.is_contract(unknown) is False
+760
View File
@@ -0,0 +1,760 @@
"""Tests for the FundingTracer module."""
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from polymarket_insider_tracker.profiler.entities import EntityRegistry
from polymarket_insider_tracker.profiler.entity_data import EntityType
from polymarket_insider_tracker.profiler.funding import (
TRANSFER_EVENT_SIGNATURE,
USDC_BRIDGED,
USDC_NATIVE,
FundingTracer,
)
from polymarket_insider_tracker.profiler.models import FundingChain, FundingTransfer
# Test addresses
TEST_WALLET = "0x1234567890abcdef1234567890abcdef12345678"
TEST_SOURCE = "0xabcdef1234567890abcdef1234567890abcdef12"
BINANCE_HOT_WALLET = "0x28c6c06298d514db089934071355e5743bf21d60"
@pytest.fixture
def mock_polygon_client() -> MagicMock:
"""Create a mock PolygonClient."""
client = MagicMock()
client._rate_limiter = MagicMock()
client._rate_limiter.acquire = AsyncMock()
client._primary_healthy = True
client._w3 = MagicMock()
client._w3_fallback = None
client.get_block = AsyncMock(return_value={"timestamp": 1704067200})
return client
@pytest.fixture
def entity_registry() -> EntityRegistry:
"""Create an EntityRegistry with default entities."""
return EntityRegistry()
@pytest.fixture
def funding_tracer(
mock_polygon_client: MagicMock,
entity_registry: EntityRegistry,
) -> FundingTracer:
"""Create a FundingTracer with mocked dependencies."""
return FundingTracer(
polygon_client=mock_polygon_client,
entity_registry=entity_registry,
max_hops=3,
)
class TestFundingTracerInit:
"""Tests for FundingTracer initialization."""
def test_init_with_defaults(self, mock_polygon_client: MagicMock) -> None:
"""Test initialization with default parameters."""
tracer = FundingTracer(mock_polygon_client)
assert tracer.polygon_client is mock_polygon_client
assert tracer.max_hops == 3
assert USDC_BRIDGED.lower() in tracer._usdc_addresses
assert USDC_NATIVE.lower() in tracer._usdc_addresses
def test_init_with_custom_max_hops(self, mock_polygon_client: MagicMock) -> None:
"""Test initialization with custom max_hops."""
tracer = FundingTracer(mock_polygon_client, max_hops=5)
assert tracer.max_hops == 5
def test_init_with_custom_usdc_addresses(
self, mock_polygon_client: MagicMock
) -> None:
"""Test initialization with custom USDC addresses."""
custom_addresses = ["0x1111111111111111111111111111111111111111"]
tracer = FundingTracer(
mock_polygon_client, usdc_addresses=custom_addresses
)
assert tracer._usdc_addresses == [custom_addresses[0].lower()]
def test_init_with_custom_entity_registry(
self, mock_polygon_client: MagicMock
) -> None:
"""Test initialization with custom entity registry."""
registry = EntityRegistry()
tracer = FundingTracer(mock_polygon_client, entity_registry=registry)
assert tracer.entity_registry is registry
def test_init_creates_default_entity_registry(
self, mock_polygon_client: MagicMock
) -> None:
"""Test initialization creates default EntityRegistry if None."""
tracer = FundingTracer(mock_polygon_client, entity_registry=None)
assert isinstance(tracer.entity_registry, EntityRegistry)
class TestFundingTracerTrace:
"""Tests for the trace method."""
@pytest.mark.asyncio
async def test_trace_terminates_at_known_cex(
self,
funding_tracer: FundingTracer,
) -> None:
"""Test trace terminates when starting at a CEX address."""
result = await funding_tracer.trace(BINANCE_HOT_WALLET)
assert result.target_address == BINANCE_HOT_WALLET.lower()
assert result.origin_address == BINANCE_HOT_WALLET.lower()
assert result.origin_type == EntityType.CEX_BINANCE.value
assert result.hop_count == 0
assert len(result.chain) == 0
@pytest.mark.asyncio
async def test_trace_no_transfers_found(
self,
funding_tracer: FundingTracer,
) -> None:
"""Test trace when no USDC transfers are found."""
funding_tracer._get_transfer_logs = AsyncMock(return_value=[])
result = await funding_tracer.trace(TEST_WALLET)
assert result.target_address == TEST_WALLET.lower()
assert result.origin_address == TEST_WALLET.lower()
assert result.origin_type == "unknown"
assert result.hop_count == 0
@pytest.mark.asyncio
async def test_trace_finds_cex_origin(
self,
funding_tracer: FundingTracer,
) -> None:
"""Test trace finds CEX as funding origin."""
# Mock a transfer from Binance to test wallet
mock_log = _create_mock_log(
from_address=BINANCE_HOT_WALLET,
to_address=TEST_WALLET,
amount=1000000, # 1 USDC
tx_hash="0x" + "ab" * 32,
block_number=50000000,
)
funding_tracer._get_transfer_logs = AsyncMock(return_value=[mock_log])
result = await funding_tracer.trace(TEST_WALLET)
assert result.target_address == TEST_WALLET.lower()
assert result.origin_address == BINANCE_HOT_WALLET.lower()
assert result.origin_type == EntityType.CEX_BINANCE.value
assert result.hop_count == 1
assert len(result.chain) == 1
@pytest.mark.asyncio
async def test_trace_multiple_hops(
self,
funding_tracer: FundingTracer,
) -> None:
"""Test trace follows multiple hops."""
intermediate_wallet = "0x" + "11" * 20
# First call: TEST_WALLET received from intermediate
# Second call: intermediate received from Binance
mock_logs = [
_create_mock_log(
from_address=intermediate_wallet,
to_address=TEST_WALLET,
amount=1000000,
tx_hash="0x" + "aa" * 32,
block_number=50000001,
),
_create_mock_log(
from_address=BINANCE_HOT_WALLET,
to_address=intermediate_wallet,
amount=1000000,
tx_hash="0x" + "bb" * 32,
block_number=50000000,
),
]
call_count = 0
async def mock_get_logs(
*_args: Any, **_kwargs: Any
) -> list[dict[str, Any]]:
nonlocal call_count
result = [mock_logs[call_count]] if call_count < len(mock_logs) else []
call_count += 1
return result
funding_tracer._get_transfer_logs = mock_get_logs
result = await funding_tracer.trace(TEST_WALLET)
assert result.hop_count == 2
assert result.origin_address == BINANCE_HOT_WALLET.lower()
assert result.origin_type == EntityType.CEX_BINANCE.value
@pytest.mark.asyncio
async def test_trace_respects_max_hops(
self,
funding_tracer: FundingTracer,
) -> None:
"""Test trace stops at max_hops."""
# Create a chain of unknown wallets
wallets = [f"0x{i:040x}" for i in range(10)]
call_count = 0
async def mock_get_logs(
*_args: Any, **_kwargs: Any
) -> list[dict[str, Any]]:
nonlocal call_count
if call_count < len(wallets) - 1:
log = _create_mock_log(
from_address=wallets[call_count + 1],
to_address=wallets[call_count],
amount=1000000,
tx_hash=f"0x{call_count:064x}",
block_number=50000000 + call_count,
)
call_count += 1
return [log]
return []
funding_tracer._get_transfer_logs = mock_get_logs
result = await funding_tracer.trace(wallets[0], max_hops=3)
assert result.hop_count == 3
assert result.origin_type == "unknown"
@pytest.mark.asyncio
async def test_trace_override_max_hops(
self,
funding_tracer: FundingTracer,
) -> None:
"""Test trace can override default max_hops."""
funding_tracer._get_transfer_logs = AsyncMock(return_value=[])
# Override to 1 hop
await funding_tracer.trace(TEST_WALLET, max_hops=1)
# Verify only 1 iteration (no hops since no transfers found)
# The trace should have been called once for the target wallet
class TestGetFirstUsdcTransfer:
"""Tests for get_first_usdc_transfer method."""
@pytest.mark.asyncio
async def test_get_first_usdc_transfer_bridged(
self,
funding_tracer: FundingTracer,
) -> None:
"""Test getting first USDC transfer from bridged contract."""
mock_log = _create_mock_log(
from_address=TEST_SOURCE,
to_address=TEST_WALLET,
amount=5000000,
tx_hash="0x" + "cc" * 32,
block_number=50000000,
)
funding_tracer._get_transfer_logs = AsyncMock(return_value=[mock_log])
result = await funding_tracer.get_first_usdc_transfer(TEST_WALLET)
assert result is not None
assert result.from_address == TEST_SOURCE.lower()
assert result.to_address == TEST_WALLET.lower()
assert result.amount == Decimal(5000000)
assert result.token == "USDC"
@pytest.mark.asyncio
async def test_get_first_usdc_transfer_native(
self,
funding_tracer: FundingTracer,
) -> None:
"""Test fallback to native USDC contract."""
call_count = 0
async def mock_get_logs(
*_args: Any, **_kwargs: Any
) -> list[dict[str, Any]]:
nonlocal call_count
call_count += 1
if call_count == 1: # First call (bridged) returns nothing
return []
# Second call (native) returns a transfer
return [
_create_mock_log(
from_address=TEST_SOURCE,
to_address=TEST_WALLET,
amount=1000000,
tx_hash="0x" + "dd" * 32,
block_number=50000000,
)
]
funding_tracer._get_transfer_logs = mock_get_logs
result = await funding_tracer.get_first_usdc_transfer(TEST_WALLET)
assert result is not None
assert call_count == 2
@pytest.mark.asyncio
async def test_get_first_usdc_transfer_none_found(
self,
funding_tracer: FundingTracer,
) -> None:
"""Test returns None when no USDC transfers found."""
funding_tracer._get_transfer_logs = AsyncMock(return_value=[])
result = await funding_tracer.get_first_usdc_transfer(TEST_WALLET)
assert result is None
class TestGetTransferLogs:
"""Tests for _get_transfer_logs method."""
@pytest.mark.asyncio
async def test_get_transfer_logs_formats_topics_correctly(
self,
funding_tracer: FundingTracer,
mock_polygon_client: MagicMock,
) -> None:
"""Test that transfer logs query is formatted correctly."""
mock_w3 = MagicMock()
mock_w3.eth.get_logs = AsyncMock(return_value=[])
mock_polygon_client._w3 = mock_w3
await funding_tracer._get_transfer_logs(
to_address=TEST_WALLET,
token_address=USDC_BRIDGED,
)
mock_w3.eth.get_logs.assert_called_once()
call_args = mock_w3.eth.get_logs.call_args[0][0]
# Verify topics structure
assert len(call_args["topics"]) == 3
assert call_args["topics"][0] == TRANSFER_EVENT_SIGNATURE.hex()
assert call_args["topics"][1] is None # from (any)
# to address should be padded to 32 bytes
assert call_args["topics"][2].endswith(TEST_WALLET.lower().replace("0x", ""))
@pytest.mark.asyncio
async def test_get_transfer_logs_respects_limit(
self,
funding_tracer: FundingTracer,
mock_polygon_client: MagicMock,
) -> None:
"""Test that limit parameter works correctly."""
mock_logs = [MagicMock() for _ in range(10)]
mock_w3 = MagicMock()
mock_w3.eth.get_logs = AsyncMock(return_value=mock_logs)
mock_polygon_client._w3 = mock_w3
result = await funding_tracer._get_transfer_logs(
to_address=TEST_WALLET,
token_address=USDC_BRIDGED,
limit=3,
)
assert len(result) == 3
@pytest.mark.asyncio
async def test_get_transfer_logs_uses_fallback_when_primary_unhealthy(
self,
funding_tracer: FundingTracer,
mock_polygon_client: MagicMock,
) -> None:
"""Test fallback RPC is used when primary is unhealthy."""
mock_polygon_client._primary_healthy = False
mock_fallback = MagicMock()
mock_fallback.eth.get_logs = AsyncMock(return_value=[])
mock_polygon_client._w3_fallback = mock_fallback
await funding_tracer._get_transfer_logs(
to_address=TEST_WALLET,
token_address=USDC_BRIDGED,
)
mock_fallback.eth.get_logs.assert_called_once()
class TestLogToFundingTransfer:
"""Tests for _log_to_funding_transfer method."""
@pytest.mark.asyncio
async def test_log_to_funding_transfer_parses_correctly(
self,
funding_tracer: FundingTracer,
) -> None:
"""Test correct parsing of log to FundingTransfer."""
mock_log = _create_mock_log(
from_address=TEST_SOURCE,
to_address=TEST_WALLET,
amount=1500000,
tx_hash="0x" + "ee" * 32,
block_number=50000000,
)
result = await funding_tracer._log_to_funding_transfer(
mock_log, USDC_BRIDGED
)
assert result.from_address == TEST_SOURCE.lower()
assert result.to_address == TEST_WALLET.lower()
assert result.amount == Decimal(1500000)
assert result.token == "USDC"
assert result.tx_hash == "ee" * 32
assert result.block_number == 50000000
@pytest.mark.asyncio
async def test_log_to_funding_transfer_handles_block_error(
self,
funding_tracer: FundingTracer,
mock_polygon_client: MagicMock,
) -> None:
"""Test graceful handling when block fetch fails."""
mock_polygon_client.get_block = AsyncMock(side_effect=Exception("Block error"))
mock_log = _create_mock_log(
from_address=TEST_SOURCE,
to_address=TEST_WALLET,
amount=1000000,
tx_hash="0x" + "ff" * 32,
block_number=50000000,
)
result = await funding_tracer._log_to_funding_transfer(
mock_log, USDC_BRIDGED
)
# Should still return a valid transfer with current timestamp
assert result.from_address == TEST_SOURCE.lower()
assert result.timestamp is not None
class TestGetFundingChainsBatch:
"""Tests for get_funding_chains_batch method."""
@pytest.mark.asyncio
async def test_batch_traces_multiple_addresses(
self,
funding_tracer: FundingTracer,
) -> None:
"""Test batch tracing multiple addresses."""
addresses = [f"0x{i:040x}" for i in range(3)]
# Mock trace to return simple chains
async def mock_trace(
addr: str, *, max_hops: int | None = None # noqa: ARG001
) -> FundingChain:
return FundingChain(
target_address=addr.lower(),
origin_type="unknown",
)
funding_tracer.trace = mock_trace
results = await funding_tracer.get_funding_chains_batch(addresses)
assert len(results) == 3
for addr in addresses:
assert addr.lower() in results
@pytest.mark.asyncio
async def test_batch_handles_exceptions(
self,
funding_tracer: FundingTracer,
) -> None:
"""Test batch handles exceptions gracefully."""
addresses = ["0x" + "11" * 20, "0x" + "22" * 20]
call_count = 0
async def mock_trace(
addr: str, *, max_hops: int | None = None # noqa: ARG001
) -> FundingChain:
nonlocal call_count
call_count += 1
if call_count == 1:
raise ValueError("Test error")
return FundingChain(
target_address=addr.lower(),
origin_type="cex_binance",
)
funding_tracer.trace = mock_trace
results = await funding_tracer.get_funding_chains_batch(addresses)
assert len(results) == 2
# First address should have error origin type
assert results[addresses[0].lower()].origin_type == "error"
# Second address should succeed
assert results[addresses[1].lower()].origin_type == "cex_binance"
@pytest.mark.asyncio
async def test_batch_empty_list(
self,
funding_tracer: FundingTracer,
) -> None:
"""Test batch with empty address list."""
results = await funding_tracer.get_funding_chains_batch([])
assert results == {}
@pytest.mark.asyncio
async def test_batch_respects_max_hops_override(
self,
funding_tracer: FundingTracer,
) -> None:
"""Test batch passes max_hops to individual traces."""
addresses = ["0x" + "11" * 20]
captured_max_hops: list[int | None] = []
async def mock_trace(
addr: str, max_hops: int | None = None
) -> FundingChain:
captured_max_hops.append(max_hops)
return FundingChain(target_address=addr.lower())
funding_tracer.trace = mock_trace
await funding_tracer.get_funding_chains_batch(addresses, max_hops=5)
assert captured_max_hops == [5]
class TestGetSuspiciousnessScore:
"""Tests for get_suspiciousness_score method."""
def test_cex_origin_low_score(self, funding_tracer: FundingTracer) -> None:
"""Test CEX origin results in low suspiciousness."""
chain = FundingChain(
target_address=TEST_WALLET,
origin_type="cex_binance",
hop_count=1,
)
score = funding_tracer.get_suspiciousness_score(chain)
assert score == 0.1
def test_bridge_origin_low_score(self, funding_tracer: FundingTracer) -> None:
"""Test bridge origin results in low-medium suspiciousness."""
chain = FundingChain(
target_address=TEST_WALLET,
origin_type="bridge_polygon",
hop_count=1,
)
score = funding_tracer.get_suspiciousness_score(chain)
assert score == 0.3
def test_unknown_no_transfers_high_score(
self, funding_tracer: FundingTracer
) -> None:
"""Test unknown origin with no transfers is most suspicious."""
chain = FundingChain(
target_address=TEST_WALLET,
origin_type="unknown",
hop_count=0,
)
score = funding_tracer.get_suspiciousness_score(chain)
assert score == 1.0
def test_unknown_max_hops_high_score(
self, funding_tracer: FundingTracer
) -> None:
"""Test unknown origin at max hops is suspicious."""
chain = FundingChain(
target_address=TEST_WALLET,
origin_type="unknown",
hop_count=3, # Same as max_hops
)
score = funding_tracer.get_suspiciousness_score(chain)
assert score == 0.7
def test_unknown_partial_hops_medium_score(
self, funding_tracer: FundingTracer
) -> None:
"""Test unknown origin with partial hops is moderately suspicious."""
chain = FundingChain(
target_address=TEST_WALLET,
origin_type="unknown",
hop_count=1,
)
score = funding_tracer.get_suspiciousness_score(chain)
# 0.5 + (0.3 * (1 - 1/3)) = 0.5 + 0.2 = 0.7
assert 0.5 < score < 0.8
class TestFundingTransferModel:
"""Tests for FundingTransfer dataclass."""
def test_amount_formatted_usdc(self) -> None:
"""Test formatted amount for USDC (6 decimals)."""
transfer = FundingTransfer(
from_address=TEST_SOURCE,
to_address=TEST_WALLET,
amount=Decimal("1500000"), # 1.5 USDC
token="USDC",
tx_hash="0x" + "aa" * 32,
block_number=50000000,
timestamp=datetime.now(UTC),
)
assert transfer.amount_formatted == Decimal("1.5")
def test_amount_formatted_other(self) -> None:
"""Test formatted amount for other tokens (18 decimals)."""
transfer = FundingTransfer(
from_address=TEST_SOURCE,
to_address=TEST_WALLET,
amount=Decimal("1500000000000000000"), # 1.5 MATIC
token="MATIC",
tx_hash="0x" + "aa" * 32,
block_number=50000000,
timestamp=datetime.now(UTC),
)
assert transfer.amount_formatted == Decimal("1.5")
class TestFundingChainModel:
"""Tests for FundingChain dataclass."""
def test_is_cex_origin(self) -> None:
"""Test is_cex_origin property."""
chain = FundingChain(
target_address=TEST_WALLET,
origin_type="cex_binance",
)
assert chain.is_cex_origin is True
chain2 = FundingChain(
target_address=TEST_WALLET,
origin_type="bridge_polygon",
)
assert chain2.is_cex_origin is False
def test_is_bridge_origin(self) -> None:
"""Test is_bridge_origin property."""
chain = FundingChain(
target_address=TEST_WALLET,
origin_type="bridge_polygon",
)
assert chain.is_bridge_origin is True
chain2 = FundingChain(
target_address=TEST_WALLET,
origin_type="cex_coinbase",
)
assert chain2.is_bridge_origin is False
def test_is_unknown_origin(self) -> None:
"""Test is_unknown_origin property."""
chain = FundingChain(
target_address=TEST_WALLET,
origin_type="unknown",
)
assert chain.is_unknown_origin is True
def test_total_amount_empty_chain(self) -> None:
"""Test total_amount with empty chain."""
chain = FundingChain(target_address=TEST_WALLET)
assert chain.total_amount == Decimal("0")
def test_total_amount_with_transfers(self) -> None:
"""Test total_amount returns first transfer amount."""
transfer = FundingTransfer(
from_address=TEST_SOURCE,
to_address=TEST_WALLET,
amount=Decimal("5000000"),
token="USDC",
tx_hash="0x" + "aa" * 32,
block_number=50000000,
timestamp=datetime.now(UTC),
)
chain = FundingChain(
target_address=TEST_WALLET,
chain=[transfer],
)
assert chain.total_amount == Decimal("5000000")
def test_funding_depth(self) -> None:
"""Test funding_depth property."""
chain = FundingChain(
target_address=TEST_WALLET,
hop_count=3,
)
assert chain.funding_depth == 3
class TestConstants:
"""Tests for module constants."""
def test_usdc_bridged_address(self) -> None:
"""Test USDC bridged contract address."""
assert USDC_BRIDGED == "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
def test_usdc_native_address(self) -> None:
"""Test USDC native contract address."""
assert USDC_NATIVE == "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359"
def test_transfer_event_signature(self) -> None:
"""Test Transfer event signature is correct keccak hash."""
# Transfer(address,address,uint256) hash
assert TRANSFER_EVENT_SIGNATURE is not None
assert len(TRANSFER_EVENT_SIGNATURE) == 32
# Helper functions
def _create_mock_log(
from_address: str,
to_address: str,
amount: int,
tx_hash: str,
block_number: int,
) -> dict[str, Any]:
"""Create a mock log entry for testing."""
# Pad addresses to 32 bytes (topics format)
from_padded = bytes.fromhex(from_address.replace("0x", "").zfill(64))
to_padded = bytes.fromhex(to_address.replace("0x", "").zfill(64))
# Amount as 32-byte hex data
amount_hex = bytes.fromhex(f"{amount:064x}")
return {
"topics": [
TRANSFER_EVENT_SIGNATURE,
from_padded,
to_padded,
],
"data": amount_hex,
"transactionHash": bytes.fromhex(tx_hash.replace("0x", "")),
"blockNumber": block_number,
}
+1 -1
View File
@@ -1 +1 @@
"""Tests for storage module."""
"""Tests for the storage layer."""
+496
View File
@@ -0,0 +1,496 @@
"""Tests for storage repositories."""
from datetime import UTC, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from polymarket_insider_tracker.storage.models import Base
from polymarket_insider_tracker.storage.repos import (
FundingRepository,
FundingTransferDTO,
RelationshipRepository,
WalletProfileDTO,
WalletRelationshipDTO,
WalletRepository,
)
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
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 async_session(async_engine) -> AsyncSession:
"""Create an async session for testing."""
session_factory = async_sessionmaker(bind=async_engine, expire_on_commit=False)
async with session_factory() as session:
yield session
@pytest.fixture
def sample_wallet_dto() -> WalletProfileDTO:
"""Create a sample wallet profile DTO."""
return WalletProfileDTO(
address="0x1234567890abcdef1234567890abcdef12345678",
nonce=5,
first_seen_at=datetime.now(UTC) - timedelta(hours=24),
is_fresh=True,
matic_balance=Decimal("1000000000000000000"),
usdc_balance=Decimal("1000.00"),
analyzed_at=datetime.now(UTC),
)
@pytest.fixture
def sample_transfer_dto() -> FundingTransferDTO:
"""Create a sample funding transfer DTO."""
return FundingTransferDTO(
from_address="0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
to_address="0x1234567890abcdef1234567890abcdef12345678",
amount=Decimal("5000.00"),
token="USDC",
tx_hash="0x" + "a" * 64,
block_number=12345678,
timestamp=datetime.now(UTC),
)
@pytest.fixture
def sample_relationship_dto() -> WalletRelationshipDTO:
"""Create a sample wallet relationship DTO."""
return WalletRelationshipDTO(
wallet_a="0x1234567890abcdef1234567890abcdef12345678",
wallet_b="0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
relationship_type="funded_by",
confidence=Decimal("0.95"),
)
# ============================================================================
# WalletRepository Tests
# ============================================================================
class TestWalletRepository:
"""Tests for WalletRepository."""
@pytest.mark.asyncio
async def test_get_by_address_not_found(self, async_session: AsyncSession) -> None:
"""Test getting a non-existent wallet returns None."""
repo = WalletRepository(async_session)
result = await repo.get_by_address("0xnonexistent")
assert result is None
@pytest.mark.asyncio
async def test_upsert_creates_new(
self, async_session: AsyncSession, sample_wallet_dto: WalletProfileDTO
) -> None:
"""Test upserting a new wallet profile."""
repo = WalletRepository(async_session)
await repo.upsert(sample_wallet_dto)
await async_session.commit()
result = await repo.get_by_address(sample_wallet_dto.address)
assert result is not None
assert result.address == sample_wallet_dto.address.lower()
assert result.nonce == sample_wallet_dto.nonce
assert result.is_fresh == sample_wallet_dto.is_fresh
@pytest.mark.asyncio
async def test_upsert_updates_existing(
self, async_session: AsyncSession, sample_wallet_dto: WalletProfileDTO
) -> None:
"""Test upserting updates existing profile."""
repo = WalletRepository(async_session)
await repo.upsert(sample_wallet_dto)
await async_session.commit()
# Update the DTO
updated_dto = WalletProfileDTO(
address=sample_wallet_dto.address,
nonce=10,
first_seen_at=sample_wallet_dto.first_seen_at,
is_fresh=False,
matic_balance=sample_wallet_dto.matic_balance,
usdc_balance=Decimal("2000.00"),
analyzed_at=datetime.now(UTC),
)
await repo.upsert(updated_dto)
await async_session.commit()
result = await repo.get_by_address(sample_wallet_dto.address)
assert result is not None
assert result.nonce == 10
assert result.is_fresh is False
assert result.usdc_balance == Decimal("2000.00")
@pytest.mark.asyncio
async def test_get_many(
self, async_session: AsyncSession, sample_wallet_dto: WalletProfileDTO
) -> None:
"""Test getting multiple wallets."""
repo = WalletRepository(async_session)
# Insert two wallets
dto2 = WalletProfileDTO(
address="0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
nonce=3,
first_seen_at=datetime.now(UTC),
is_fresh=True,
matic_balance=None,
usdc_balance=None,
analyzed_at=datetime.now(UTC),
)
await repo.upsert(sample_wallet_dto)
await repo.upsert(dto2)
await async_session.commit()
results = await repo.get_many([sample_wallet_dto.address, dto2.address])
assert len(results) == 2
@pytest.mark.asyncio
async def test_get_fresh_wallets(
self, async_session: AsyncSession, sample_wallet_dto: WalletProfileDTO
) -> None:
"""Test getting fresh wallets."""
repo = WalletRepository(async_session)
# Insert fresh and non-fresh wallets
non_fresh = WalletProfileDTO(
address="0xcccccccccccccccccccccccccccccccccccccccc",
nonce=100,
first_seen_at=datetime.now(UTC) - timedelta(days=30),
is_fresh=False,
matic_balance=None,
usdc_balance=None,
analyzed_at=datetime.now(UTC),
)
await repo.upsert(sample_wallet_dto)
await repo.upsert(non_fresh)
await async_session.commit()
results = await repo.get_fresh_wallets()
assert len(results) == 1
assert results[0].is_fresh is True
@pytest.mark.asyncio
async def test_delete(
self, async_session: AsyncSession, sample_wallet_dto: WalletProfileDTO
) -> None:
"""Test deleting a wallet profile."""
repo = WalletRepository(async_session)
await repo.upsert(sample_wallet_dto)
await async_session.commit()
deleted = await repo.delete(sample_wallet_dto.address)
await async_session.commit()
assert deleted is True
result = await repo.get_by_address(sample_wallet_dto.address)
assert result is None
@pytest.mark.asyncio
async def test_delete_not_found(self, async_session: AsyncSession) -> None:
"""Test deleting non-existent wallet returns False."""
repo = WalletRepository(async_session)
deleted = await repo.delete("0xnonexistent")
assert deleted is False
@pytest.mark.asyncio
async def test_mark_stale(
self, async_session: AsyncSession, sample_wallet_dto: WalletProfileDTO
) -> None:
"""Test marking a wallet as stale."""
repo = WalletRepository(async_session)
await repo.upsert(sample_wallet_dto)
await async_session.commit()
marked = await repo.mark_stale(sample_wallet_dto.address)
await async_session.commit()
assert marked is True
result = await repo.get_by_address(sample_wallet_dto.address)
assert result is not None
assert result.analyzed_at.year == 2000
# ============================================================================
# FundingRepository Tests
# ============================================================================
class TestFundingRepository:
"""Tests for FundingRepository."""
@pytest.mark.asyncio
async def test_insert(
self, async_session: AsyncSession, sample_transfer_dto: FundingTransferDTO
) -> None:
"""Test inserting a funding transfer."""
repo = FundingRepository(async_session)
await repo.insert(sample_transfer_dto)
await async_session.commit()
result = await repo.get_by_tx_hash(sample_transfer_dto.tx_hash)
assert result is not None
assert result.amount == sample_transfer_dto.amount
@pytest.mark.asyncio
async def test_get_transfers_to(
self, async_session: AsyncSession, sample_transfer_dto: FundingTransferDTO
) -> None:
"""Test getting transfers to an address."""
repo = FundingRepository(async_session)
await repo.insert(sample_transfer_dto)
await async_session.commit()
results = await repo.get_transfers_to(sample_transfer_dto.to_address)
assert len(results) == 1
assert results[0].from_address == sample_transfer_dto.from_address.lower()
@pytest.mark.asyncio
async def test_get_transfers_from(
self, async_session: AsyncSession, sample_transfer_dto: FundingTransferDTO
) -> None:
"""Test getting transfers from an address."""
repo = FundingRepository(async_session)
await repo.insert(sample_transfer_dto)
await async_session.commit()
results = await repo.get_transfers_from(sample_transfer_dto.from_address)
assert len(results) == 1
assert results[0].to_address == sample_transfer_dto.to_address.lower()
@pytest.mark.asyncio
async def test_get_first_transfer_to(
self, async_session: AsyncSession, sample_transfer_dto: FundingTransferDTO
) -> None:
"""Test getting first transfer to an address."""
repo = FundingRepository(async_session)
# Insert multiple transfers with different timestamps
earlier = FundingTransferDTO(
from_address="0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
to_address=sample_transfer_dto.to_address,
amount=Decimal("100.00"),
token="USDC",
tx_hash="0x" + "b" * 64,
block_number=12345670,
timestamp=datetime.now(UTC) - timedelta(hours=2),
)
await repo.insert(earlier)
await repo.insert(sample_transfer_dto)
await async_session.commit()
result = await repo.get_first_transfer_to(sample_transfer_dto.to_address)
assert result is not None
assert result.tx_hash == earlier.tx_hash.lower()
@pytest.mark.asyncio
async def test_insert_many(self, async_session: AsyncSession) -> None:
"""Test inserting multiple transfers."""
repo = FundingRepository(async_session)
transfers = [
FundingTransferDTO(
from_address=f"0x{'a' * 40}",
to_address=f"0x{'b' * 40}",
amount=Decimal(f"{i * 100}.00"),
token="USDC",
tx_hash=f"0x{str(i) * 64}"[:66],
block_number=12345678 + i,
timestamp=datetime.now(UTC),
)
for i in range(1, 4)
]
count = await repo.insert_many(transfers)
await async_session.commit()
assert count == 3
# ============================================================================
# RelationshipRepository Tests
# ============================================================================
class TestRelationshipRepository:
"""Tests for RelationshipRepository."""
@pytest.mark.asyncio
async def test_upsert(
self, async_session: AsyncSession, sample_relationship_dto: WalletRelationshipDTO
) -> None:
"""Test upserting a relationship."""
repo = RelationshipRepository(async_session)
await repo.upsert(sample_relationship_dto)
await async_session.commit()
results = await repo.get_relationships(sample_relationship_dto.wallet_a)
assert len(results) == 1
assert results[0].confidence == sample_relationship_dto.confidence
@pytest.mark.asyncio
async def test_get_relationships_filter_type(
self, async_session: AsyncSession, sample_relationship_dto: WalletRelationshipDTO
) -> None:
"""Test getting relationships with type filter."""
repo = RelationshipRepository(async_session)
await repo.upsert(sample_relationship_dto)
same_entity = WalletRelationshipDTO(
wallet_a=sample_relationship_dto.wallet_a,
wallet_b="0xdddddddddddddddddddddddddddddddddddddddd",
relationship_type="same_entity",
confidence=Decimal("0.80"),
)
await repo.upsert(same_entity)
await async_session.commit()
funded_results = await repo.get_relationships(
sample_relationship_dto.wallet_a, relationship_type="funded_by"
)
assert len(funded_results) == 1
assert funded_results[0].relationship_type == "funded_by"
@pytest.mark.asyncio
async def test_get_related_wallets(
self, async_session: AsyncSession, sample_relationship_dto: WalletRelationshipDTO
) -> None:
"""Test getting related wallet addresses."""
repo = RelationshipRepository(async_session)
await repo.upsert(sample_relationship_dto)
await async_session.commit()
related = await repo.get_related_wallets(sample_relationship_dto.wallet_a)
assert len(related) == 1
assert sample_relationship_dto.wallet_b.lower() in related
@pytest.mark.asyncio
async def test_delete_relationship(
self, async_session: AsyncSession, sample_relationship_dto: WalletRelationshipDTO
) -> None:
"""Test deleting a relationship."""
repo = RelationshipRepository(async_session)
await repo.upsert(sample_relationship_dto)
await async_session.commit()
deleted = await repo.delete(
sample_relationship_dto.wallet_a,
sample_relationship_dto.wallet_b,
sample_relationship_dto.relationship_type,
)
await async_session.commit()
assert deleted is True
results = await repo.get_relationships(sample_relationship_dto.wallet_a)
assert len(results) == 0
@pytest.mark.asyncio
async def test_upsert_updates_confidence(
self, async_session: AsyncSession, sample_relationship_dto: WalletRelationshipDTO
) -> None:
"""Test that upserting updates the confidence."""
repo = RelationshipRepository(async_session)
await repo.upsert(sample_relationship_dto)
await async_session.commit()
updated = WalletRelationshipDTO(
wallet_a=sample_relationship_dto.wallet_a,
wallet_b=sample_relationship_dto.wallet_b,
relationship_type=sample_relationship_dto.relationship_type,
confidence=Decimal("0.99"),
)
await repo.upsert(updated)
await async_session.commit()
results = await repo.get_relationships(sample_relationship_dto.wallet_a)
assert len(results) == 1
assert results[0].confidence == Decimal("0.99")
# ============================================================================
# DTO Tests
# ============================================================================
class TestDTOs:
"""Tests for Data Transfer Objects."""
def test_wallet_profile_dto_from_model(self) -> None:
"""Test WalletProfileDTO.from_model works correctly."""
from polymarket_insider_tracker.storage.models import WalletProfileModel
now = datetime.now(UTC)
model = WalletProfileModel(
id=1,
address="0x1234",
nonce=5,
first_seen_at=now,
is_fresh=True,
matic_balance=Decimal("100"),
usdc_balance=Decimal("50.00"),
analyzed_at=now,
created_at=now,
updated_at=now,
)
dto = WalletProfileDTO.from_model(model)
assert dto.address == "0x1234"
assert dto.nonce == 5
assert dto.is_fresh is True
def test_funding_transfer_dto_from_model(self) -> None:
"""Test FundingTransferDTO.from_model works correctly."""
from polymarket_insider_tracker.storage.models import FundingTransferModel
now = datetime.now(UTC)
model = FundingTransferModel(
id=1,
from_address="0xaaa",
to_address="0xbbb",
amount=Decimal("100.00"),
token="USDC",
tx_hash="0x123",
block_number=12345,
timestamp=now,
created_at=now,
)
dto = FundingTransferDTO.from_model(model)
assert dto.from_address == "0xaaa"
assert dto.amount == Decimal("100.00")
def test_wallet_relationship_dto_from_model(self) -> None:
"""Test WalletRelationshipDTO.from_model works correctly."""
from polymarket_insider_tracker.storage.models import WalletRelationshipModel
now = datetime.now(UTC)
model = WalletRelationshipModel(
id=1,
wallet_a="0xaaa",
wallet_b="0xbbb",
relationship_type="funded_by",
confidence=Decimal("0.95"),
created_at=now,
)
dto = WalletRelationshipDTO.from_model(model)
assert dto.wallet_a == "0xaaa"
assert dto.relationship_type == "funded_by"
assert dto.confidence == Decimal("0.95")
Generated
+222
View File
@@ -1053,6 +1053,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "joblib"
version = "1.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
]
[[package]]
name = "librt"
version = "0.7.7"
@@ -1376,6 +1385,85 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
name = "numpy"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/7a/6a3d14e205d292b738db449d0de649b373a59edb0d0b4493821d0a3e8718/numpy-2.4.0.tar.gz", hash = "sha256:6e504f7b16118198f138ef31ba24d985b124c2c469fe8467007cf30fd992f934", size = 20685720, upload-time = "2025-12-20T16:18:19.023Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/7e/7bae7cbcc2f8132271967aa03e03954fc1e48aa1f3bf32b29ca95fbef352/numpy-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:316b2f2584682318539f0bcaca5a496ce9ca78c88066579ebd11fd06f8e4741e", size = 16940166, upload-time = "2025-12-20T16:15:43.434Z" },
{ url = "https://files.pythonhosted.org/packages/0f/27/6c13f5b46776d6246ec884ac5817452672156a506d08a1f2abb39961930a/numpy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2718c1de8504121714234b6f8241d0019450353276c88b9453c9c3d92e101db", size = 12641781, upload-time = "2025-12-20T16:15:45.701Z" },
{ url = "https://files.pythonhosted.org/packages/14/1c/83b4998d4860d15283241d9e5215f28b40ac31f497c04b12fa7f428ff370/numpy-2.4.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:21555da4ec4a0c942520ead42c3b0dc9477441e085c42b0fbdd6a084869a6f6b", size = 5470247, upload-time = "2025-12-20T16:15:47.943Z" },
{ url = "https://files.pythonhosted.org/packages/54/08/cbce72c835d937795571b0464b52069f869c9e78b0c076d416c5269d2718/numpy-2.4.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:413aa561266a4be2d06cd2b9665e89d9f54c543f418773076a76adcf2af08bc7", size = 6799807, upload-time = "2025-12-20T16:15:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/ff/be/2e647961cd8c980591d75cdcd9e8f647d69fbe05e2a25613dc0a2ea5fb1a/numpy-2.4.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0feafc9e03128074689183031181fac0897ff169692d8492066e949041096548", size = 14701992, upload-time = "2025-12-20T16:15:51.615Z" },
{ url = "https://files.pythonhosted.org/packages/a2/fb/e1652fb8b6fd91ce6ed429143fe2e01ce714711e03e5b762615e7b36172c/numpy-2.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8fdfed3deaf1928fb7667d96e0567cdf58c2b370ea2ee7e586aa383ec2cb346", size = 16646871, upload-time = "2025-12-20T16:15:54.129Z" },
{ url = "https://files.pythonhosted.org/packages/62/23/d841207e63c4322842f7cd042ae981cffe715c73376dcad8235fb31debf1/numpy-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e06a922a469cae9a57100864caf4f8a97a1026513793969f8ba5b63137a35d25", size = 16487190, upload-time = "2025-12-20T16:15:56.147Z" },
{ url = "https://files.pythonhosted.org/packages/bc/a0/6a842c8421ebfdec0a230e65f61e0dabda6edbef443d999d79b87c273965/numpy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:927ccf5cd17c48f801f4ed43a7e5673a2724bd2171460be3e3894e6e332ef83a", size = 18580762, upload-time = "2025-12-20T16:15:58.524Z" },
{ url = "https://files.pythonhosted.org/packages/0a/d1/c79e0046641186f2134dde05e6181825b911f8bdcef31b19ddd16e232847/numpy-2.4.0-cp311-cp311-win32.whl", hash = "sha256:882567b7ae57c1b1a0250208cc21a7976d8cbcc49d5a322e607e6f09c9e0bd53", size = 6233359, upload-time = "2025-12-20T16:16:00.938Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f0/74965001d231f28184d6305b8cdc1b6fcd4bf23033f6cb039cfe76c9fca7/numpy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:8b986403023c8f3bf8f487c2e6186afda156174d31c175f747d8934dfddf3479", size = 12601132, upload-time = "2025-12-20T16:16:02.484Z" },
{ url = "https://files.pythonhosted.org/packages/65/32/55408d0f46dfebce38017f5bd931affa7256ad6beac1a92a012e1fbc67a7/numpy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:3f3096405acc48887458bbf9f6814d43785ac7ba2a57ea6442b581dedbc60ce6", size = 10573977, upload-time = "2025-12-20T16:16:04.77Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ff/f6400ffec95de41c74b8e73df32e3fff1830633193a7b1e409be7fb1bb8c/numpy-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a8b6bb8369abefb8bd1801b054ad50e02b3275c8614dc6e5b0373c305291037", size = 16653117, upload-time = "2025-12-20T16:16:06.709Z" },
{ url = "https://files.pythonhosted.org/packages/fd/28/6c23e97450035072e8d830a3c411bf1abd1f42c611ff9d29e3d8f55c6252/numpy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e284ca13d5a8367e43734148622caf0b261b275673823593e3e3634a6490f83", size = 12369711, upload-time = "2025-12-20T16:16:08.758Z" },
{ url = "https://files.pythonhosted.org/packages/bc/af/acbef97b630ab1bb45e6a7d01d1452e4251aa88ce680ac36e56c272120ec/numpy-2.4.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:49ff32b09f5aa0cd30a20c2b39db3e669c845589f2b7fc910365210887e39344", size = 5198355, upload-time = "2025-12-20T16:16:10.902Z" },
{ url = "https://files.pythonhosted.org/packages/c1/c8/4e0d436b66b826f2e53330adaa6311f5cac9871a5b5c31ad773b27f25a74/numpy-2.4.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:36cbfb13c152b1c7c184ddac43765db8ad672567e7bafff2cc755a09917ed2e6", size = 6545298, upload-time = "2025-12-20T16:16:12.607Z" },
{ url = "https://files.pythonhosted.org/packages/ef/27/e1f5d144ab54eac34875e79037011d511ac57b21b220063310cb96c80fbc/numpy-2.4.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35ddc8f4914466e6fc954c76527aa91aa763682a4f6d73249ef20b418fe6effb", size = 14398387, upload-time = "2025-12-20T16:16:14.257Z" },
{ url = "https://files.pythonhosted.org/packages/67/64/4cb909dd5ab09a9a5d086eff9586e69e827b88a5585517386879474f4cf7/numpy-2.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc578891de1db95b2a35001b695451767b580bb45753717498213c5ff3c41d63", size = 16363091, upload-time = "2025-12-20T16:16:17.32Z" },
{ url = "https://files.pythonhosted.org/packages/9d/9c/8efe24577523ec6809261859737cf117b0eb6fdb655abdfdc81b2e468ce4/numpy-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98e81648e0b36e325ab67e46b5400a7a6d4a22b8a7c8e8bbfe20e7db7906bf95", size = 16176394, upload-time = "2025-12-20T16:16:19.524Z" },
{ url = "https://files.pythonhosted.org/packages/61/f0/1687441ece7b47a62e45a1f82015352c240765c707928edd8aef875d5951/numpy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d57b5046c120561ba8fa8e4030fbb8b822f3063910fa901ffadf16e2b7128ad6", size = 18287378, upload-time = "2025-12-20T16:16:22.866Z" },
{ url = "https://files.pythonhosted.org/packages/d3/6f/f868765d44e6fc466467ed810ba9d8d6db1add7d4a748abfa2a4c99a3194/numpy-2.4.0-cp312-cp312-win32.whl", hash = "sha256:92190db305a6f48734d3982f2c60fa30d6b5ee9bff10f2887b930d7b40119f4c", size = 5955432, upload-time = "2025-12-20T16:16:25.06Z" },
{ url = "https://files.pythonhosted.org/packages/d4/b5/94c1e79fcbab38d1ca15e13777477b2914dd2d559b410f96949d6637b085/numpy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:680060061adb2d74ce352628cb798cfdec399068aa7f07ba9fb818b2b3305f98", size = 12306201, upload-time = "2025-12-20T16:16:26.979Z" },
{ url = "https://files.pythonhosted.org/packages/70/09/c39dadf0b13bb0768cd29d6a3aaff1fb7c6905ac40e9aaeca26b1c086e06/numpy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:39699233bc72dd482da1415dcb06076e32f60eddc796a796c5fb6c5efce94667", size = 10308234, upload-time = "2025-12-20T16:16:29.417Z" },
{ url = "https://files.pythonhosted.org/packages/a7/0d/853fd96372eda07c824d24adf02e8bc92bb3731b43a9b2a39161c3667cc4/numpy-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a152d86a3ae00ba5f47b3acf3b827509fd0b6cb7d3259665e63dafbad22a75ea", size = 16649088, upload-time = "2025-12-20T16:16:31.421Z" },
{ url = "https://files.pythonhosted.org/packages/e3/37/cc636f1f2a9f585434e20a3e6e63422f70bfe4f7f6698e941db52ea1ac9a/numpy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39b19251dec4de8ff8496cd0806cbe27bf0684f765abb1f4809554de93785f2d", size = 12364065, upload-time = "2025-12-20T16:16:33.491Z" },
{ url = "https://files.pythonhosted.org/packages/ed/69/0b78f37ca3690969beee54103ce5f6021709134e8020767e93ba691a72f1/numpy-2.4.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:009bd0ea12d3c784b6639a8457537016ce5172109e585338e11334f6a7bb88ee", size = 5192640, upload-time = "2025-12-20T16:16:35.636Z" },
{ url = "https://files.pythonhosted.org/packages/1d/2a/08569f8252abf590294dbb09a430543ec8f8cc710383abfb3e75cc73aeda/numpy-2.4.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5fe44e277225fd3dff6882d86d3d447205d43532c3627313d17e754fb3905a0e", size = 6541556, upload-time = "2025-12-20T16:16:37.276Z" },
{ url = "https://files.pythonhosted.org/packages/93/e9/a949885a4e177493d61519377952186b6cbfdf1d6002764c664ba28349b5/numpy-2.4.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f935c4493eda9069851058fa0d9e39dbf6286be690066509305e52912714dbb2", size = 14396562, upload-time = "2025-12-20T16:16:38.953Z" },
{ url = "https://files.pythonhosted.org/packages/99/98/9d4ad53b0e9ef901c2ef1d550d2136f5ac42d3fd2988390a6def32e23e48/numpy-2.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cfa5f29a695cb7438965e6c3e8d06e0416060cf0d709c1b1c1653a939bf5c2a", size = 16351719, upload-time = "2025-12-20T16:16:41.503Z" },
{ url = "https://files.pythonhosted.org/packages/28/de/5f3711a38341d6e8dd619f6353251a0cdd07f3d6d101a8fd46f4ef87f895/numpy-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba0cb30acd3ef11c94dc27fbfba68940652492bc107075e7ffe23057f9425681", size = 16176053, upload-time = "2025-12-20T16:16:44.552Z" },
{ url = "https://files.pythonhosted.org/packages/2a/5b/2a3753dc43916501b4183532e7ace862e13211042bceafa253afb5c71272/numpy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60e8c196cd82cbbd4f130b5290007e13e6de3eca79f0d4d38014769d96a7c475", size = 18277859, upload-time = "2025-12-20T16:16:47.174Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c5/a18bcdd07a941db3076ef489d036ab16d2bfc2eae0cf27e5a26e29189434/numpy-2.4.0-cp313-cp313-win32.whl", hash = "sha256:5f48cb3e88fbc294dc90e215d86fbaf1c852c63dbdb6c3a3e63f45c4b57f7344", size = 5953849, upload-time = "2025-12-20T16:16:49.554Z" },
{ url = "https://files.pythonhosted.org/packages/4f/f1/719010ff8061da6e8a26e1980cf090412d4f5f8060b31f0c45d77dd67a01/numpy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:a899699294f28f7be8992853c0c60741f16ff199205e2e6cdca155762cbaa59d", size = 12302840, upload-time = "2025-12-20T16:16:51.227Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5a/b3d259083ed8b4d335270c76966cb6cf14a5d1b69e1a608994ac57a659e6/numpy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9198f447e1dc5647d07c9a6bbe2063cc0132728cc7175b39dbc796da5b54920d", size = 10308509, upload-time = "2025-12-20T16:16:53.313Z" },
{ url = "https://files.pythonhosted.org/packages/31/01/95edcffd1bb6c0633df4e808130545c4f07383ab629ac7e316fb44fff677/numpy-2.4.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74623f2ab5cc3f7c886add4f735d1031a1d2be4a4ae63c0546cfd74e7a31ddf6", size = 12491815, upload-time = "2025-12-20T16:16:55.496Z" },
{ url = "https://files.pythonhosted.org/packages/59/ea/5644b8baa92cc1c7163b4b4458c8679852733fa74ca49c942cfa82ded4e0/numpy-2.4.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:0804a8e4ab070d1d35496e65ffd3cf8114c136a2b81f61dfab0de4b218aacfd5", size = 5320321, upload-time = "2025-12-20T16:16:57.468Z" },
{ url = "https://files.pythonhosted.org/packages/26/4e/e10938106d70bc21319bd6a86ae726da37edc802ce35a3a71ecdf1fdfe7f/numpy-2.4.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:02a2038eb27f9443a8b266a66911e926566b5a6ffd1a689b588f7f35b81e7dc3", size = 6641635, upload-time = "2025-12-20T16:16:59.379Z" },
{ url = "https://files.pythonhosted.org/packages/b3/8d/a8828e3eaf5c0b4ab116924df82f24ce3416fa38d0674d8f708ddc6c8aac/numpy-2.4.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1889b3a3f47a7b5bee16bc25a2145bd7cb91897f815ce3499db64c7458b6d91d", size = 14456053, upload-time = "2025-12-20T16:17:01.768Z" },
{ url = "https://files.pythonhosted.org/packages/68/a1/17d97609d87d4520aa5ae2dcfb32305654550ac6a35effb946d303e594ce/numpy-2.4.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85eef4cb5625c47ee6425c58a3502555e10f45ee973da878ac8248ad58c136f3", size = 16401702, upload-time = "2025-12-20T16:17:04.235Z" },
{ url = "https://files.pythonhosted.org/packages/18/32/0f13c1b2d22bea1118356b8b963195446f3af124ed7a5adfa8fdecb1b6ca/numpy-2.4.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6dc8b7e2f4eb184b37655195f421836cfae6f58197b67e3ffc501f1333d993fa", size = 16242493, upload-time = "2025-12-20T16:17:06.856Z" },
{ url = "https://files.pythonhosted.org/packages/ae/23/48f21e3d309fbc137c068a1475358cbd3a901b3987dcfc97a029ab3068e2/numpy-2.4.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:44aba2f0cafd287871a495fb3163408b0bd25bbce135c6f621534a07f4f7875c", size = 18324222, upload-time = "2025-12-20T16:17:09.392Z" },
{ url = "https://files.pythonhosted.org/packages/ac/52/41f3d71296a3dcaa4f456aaa3c6fc8e745b43d0552b6bde56571bb4b4a0f/numpy-2.4.0-cp313-cp313t-win32.whl", hash = "sha256:20c115517513831860c573996e395707aa9fb691eb179200125c250e895fcd93", size = 6076216, upload-time = "2025-12-20T16:17:11.437Z" },
{ url = "https://files.pythonhosted.org/packages/35/ff/46fbfe60ab0710d2a2b16995f708750307d30eccbb4c38371ea9e986866e/numpy-2.4.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b48e35f4ab6f6a7597c46e301126ceba4c44cd3280e3750f85db48b082624fa4", size = 12444263, upload-time = "2025-12-20T16:17:13.182Z" },
{ url = "https://files.pythonhosted.org/packages/a3/e3/9189ab319c01d2ed556c932ccf55064c5d75bb5850d1df7a482ce0badead/numpy-2.4.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4d1cfce39e511069b11e67cd0bd78ceff31443b7c9e5c04db73c7a19f572967c", size = 10378265, upload-time = "2025-12-20T16:17:15.211Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ed/52eac27de39d5e5a6c9aadabe672bc06f55e24a3d9010cd1183948055d76/numpy-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c95eb6db2884917d86cde0b4d4cf31adf485c8ec36bf8696dd66fa70de96f36b", size = 16647476, upload-time = "2025-12-20T16:17:17.671Z" },
{ url = "https://files.pythonhosted.org/packages/77/c0/990ce1b7fcd4e09aeaa574e2a0a839589e4b08b2ca68070f1acb1fea6736/numpy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:65167da969cd1ec3a1df31cb221ca3a19a8aaa25370ecb17d428415e93c1935e", size = 12374563, upload-time = "2025-12-20T16:17:20.216Z" },
{ url = "https://files.pythonhosted.org/packages/37/7c/8c5e389c6ae8f5fd2277a988600d79e9625db3fff011a2d87ac80b881a4c/numpy-2.4.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3de19cfecd1465d0dcf8a5b5ea8b3155b42ed0b639dba4b71e323d74f2a3be5e", size = 5203107, upload-time = "2025-12-20T16:17:22.47Z" },
{ url = "https://files.pythonhosted.org/packages/e6/94/ca5b3bd6a8a70a5eec9a0b8dd7f980c1eff4b8a54970a9a7fef248ef564f/numpy-2.4.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6c05483c3136ac4c91b4e81903cb53a8707d316f488124d0398499a4f8e8ef51", size = 6538067, upload-time = "2025-12-20T16:17:24.001Z" },
{ url = "https://files.pythonhosted.org/packages/79/43/993eb7bb5be6761dde2b3a3a594d689cec83398e3f58f4758010f3b85727/numpy-2.4.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36667db4d6c1cea79c8930ab72fadfb4060feb4bfe724141cd4bd064d2e5f8ce", size = 14411926, upload-time = "2025-12-20T16:17:25.822Z" },
{ url = "https://files.pythonhosted.org/packages/03/75/d4c43b61de473912496317a854dac54f1efec3eeb158438da6884b70bb90/numpy-2.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a818668b674047fd88c4cddada7ab8f1c298812783e8328e956b78dc4807f9f", size = 16354295, upload-time = "2025-12-20T16:17:28.308Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0a/b54615b47ee8736a6461a4bb6749128dd3435c5a759d5663f11f0e9af4ac/numpy-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ee32359fb7543b7b7bd0b2f46294db27e29e7bbdf70541e81b190836cd83ded", size = 16190242, upload-time = "2025-12-20T16:17:30.993Z" },
{ url = "https://files.pythonhosted.org/packages/98/ce/ea207769aacad6246525ec6c6bbd66a2bf56c72443dc10e2f90feed29290/numpy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e493962256a38f58283de033d8af176c5c91c084ea30f15834f7545451c42059", size = 18280875, upload-time = "2025-12-20T16:17:33.327Z" },
{ url = "https://files.pythonhosted.org/packages/17/ef/ec409437aa962ea372ed601c519a2b141701683ff028f894b7466f0ab42b/numpy-2.4.0-cp314-cp314-win32.whl", hash = "sha256:6bbaebf0d11567fa8926215ae731e1d58e6ec28a8a25235b8a47405d301332db", size = 6002530, upload-time = "2025-12-20T16:17:35.729Z" },
{ url = "https://files.pythonhosted.org/packages/5f/4a/5cb94c787a3ed1ac65e1271b968686521169a7b3ec0b6544bb3ca32960b0/numpy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d857f55e7fdf7c38ab96c4558c95b97d1c685be6b05c249f5fdafcbd6f9899e", size = 12435890, upload-time = "2025-12-20T16:17:37.599Z" },
{ url = "https://files.pythonhosted.org/packages/48/a0/04b89db963af9de1104975e2544f30de89adbf75b9e75f7dd2599be12c79/numpy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:bb50ce5fb202a26fd5404620e7ef820ad1ab3558b444cb0b55beb7ef66cd2d63", size = 10591892, upload-time = "2025-12-20T16:17:39.649Z" },
{ url = "https://files.pythonhosted.org/packages/53/e5/d74b5ccf6712c06c7a545025a6a71bfa03bdc7e0568b405b0d655232fd92/numpy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:355354388cba60f2132df297e2d53053d4063f79077b67b481d21276d61fc4df", size = 12494312, upload-time = "2025-12-20T16:17:41.714Z" },
{ url = "https://files.pythonhosted.org/packages/c2/08/3ca9cc2ddf54dfee7ae9a6479c071092a228c68aef08252aa08dac2af002/numpy-2.4.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:1d8f9fde5f6dc1b6fc34df8162f3b3079365468703fee7f31d4e0cc8c63baed9", size = 5322862, upload-time = "2025-12-20T16:17:44.145Z" },
{ url = "https://files.pythonhosted.org/packages/87/74/0bb63a68394c0c1e52670cfff2e309afa41edbe11b3327d9af29e4383f34/numpy-2.4.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e0434aa22c821f44eeb4c650b81c7fbdd8c0122c6c4b5a576a76d5a35625ecd9", size = 6644986, upload-time = "2025-12-20T16:17:46.203Z" },
{ url = "https://files.pythonhosted.org/packages/06/8f/9264d9bdbcf8236af2823623fe2f3981d740fc3461e2787e231d97c38c28/numpy-2.4.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40483b2f2d3ba7aad426443767ff5632ec3156ef09742b96913787d13c336471", size = 14457958, upload-time = "2025-12-20T16:17:48.017Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d9/f9a69ae564bbc7236a35aa883319364ef5fd41f72aa320cc1cbe66148fe2/numpy-2.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6a7664ddd9746e20b7325351fe1a8408d0a2bf9c63b5e898290ddc8f09544", size = 16398394, upload-time = "2025-12-20T16:17:50.409Z" },
{ url = "https://files.pythonhosted.org/packages/34/c7/39241501408dde7f885d241a98caba5421061a2c6d2b2197ac5e3aa842d8/numpy-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ecb0019d44f4cdb50b676c5d0cb4b1eae8e15d1ed3d3e6639f986fc92b2ec52c", size = 16241044, upload-time = "2025-12-20T16:17:52.661Z" },
{ url = "https://files.pythonhosted.org/packages/7c/95/cae7effd90e065a95e59fe710eeee05d7328ed169776dfdd9f789e032125/numpy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d0ffd9e2e4441c96a9c91ec1783285d80bf835b677853fc2770a89d50c1e48ac", size = 18321772, upload-time = "2025-12-20T16:17:54.947Z" },
{ url = "https://files.pythonhosted.org/packages/96/df/3c6c279accd2bfb968a76298e5b276310bd55d243df4fa8ac5816d79347d/numpy-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:77f0d13fa87036d7553bf81f0e1fe3ce68d14c9976c9851744e4d3e91127e95f", size = 6148320, upload-time = "2025-12-20T16:17:57.249Z" },
{ url = "https://files.pythonhosted.org/packages/92/8d/f23033cce252e7a75cae853d17f582e86534c46404dea1c8ee094a9d6d84/numpy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b1f5b45829ac1848893f0ddf5cb326110604d6df96cdc255b0bf9edd154104d4", size = 12623460, upload-time = "2025-12-20T16:17:58.963Z" },
{ url = "https://files.pythonhosted.org/packages/a4/4f/1f8475907d1a7c4ef9020edf7f39ea2422ec896849245f00688e4b268a71/numpy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:23a3e9d1a6f360267e8fbb38ba5db355a6a7e9be71d7fce7ab3125e88bb646c8", size = 10661799, upload-time = "2025-12-20T16:18:01.078Z" },
{ url = "https://files.pythonhosted.org/packages/4b/ef/088e7c7342f300aaf3ee5f2c821c4b9996a1bef2aaf6a49cc8ab4883758e/numpy-2.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b54c83f1c0c0f1d748dca0af516062b8829d53d1f0c402be24b4257a9c48ada6", size = 16819003, upload-time = "2025-12-20T16:18:03.41Z" },
{ url = "https://files.pythonhosted.org/packages/ff/ce/a53017b5443b4b84517182d463fc7bcc2adb4faa8b20813f8e5f5aeb5faa/numpy-2.4.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:aabb081ca0ec5d39591fc33018cd4b3f96e1a2dd6756282029986d00a785fba4", size = 12567105, upload-time = "2025-12-20T16:18:05.594Z" },
{ url = "https://files.pythonhosted.org/packages/77/58/5ff91b161f2ec650c88a626c3905d938c89aaadabd0431e6d9c1330c83e2/numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:8eafe7c36c8430b7794edeab3087dec7bf31d634d92f2af9949434b9d1964cba", size = 5395590, upload-time = "2025-12-20T16:18:08.031Z" },
{ url = "https://files.pythonhosted.org/packages/1d/4e/f1a084106df8c2df8132fc437e56987308e0524836aa7733721c8429d4fe/numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2f585f52b2baf07ff3356158d9268ea095e221371f1074fadea2f42544d58b4d", size = 6709947, upload-time = "2025-12-20T16:18:09.836Z" },
{ url = "https://files.pythonhosted.org/packages/63/09/3d8aeb809c0332c3f642da812ac2e3d74fc9252b3021f8c30c82e99e3f3d/numpy-2.4.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ed06d0fe9cae27d8fb5f400c63ccee72370599c75e683a6358dd3a4fb50aaf", size = 14535119, upload-time = "2025-12-20T16:18:12.105Z" },
{ url = "https://files.pythonhosted.org/packages/fd/7f/68f0fc43a2cbdc6bb239160c754d87c922f60fbaa0fa3cd3d312b8a7f5ee/numpy-2.4.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:57c540ed8fb1f05cb997c6761cd56db72395b0d6985e90571ff660452ade4f98", size = 16475815, upload-time = "2025-12-20T16:18:14.433Z" },
{ url = "https://files.pythonhosted.org/packages/11/73/edeacba3167b1ca66d51b1a5a14697c2c40098b5ffa01811c67b1785a5ab/numpy-2.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a39fb973a726e63223287adc6dafe444ce75af952d711e400f3bf2b36ef55a7b", size = 12489376, upload-time = "2025-12-20T16:18:16.524Z" },
]
[[package]]
name = "packaging"
version = "25.0"
@@ -1446,11 +1534,13 @@ dependencies = [
{ name = "aiohttp" },
{ name = "alembic" },
{ name = "httpx" },
{ name = "numpy" },
{ name = "prometheus-client" },
{ name = "py-clob-client" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "redis" },
{ name = "scikit-learn" },
{ name = "sqlalchemy" },
{ name = "web3" },
{ name = "websockets" },
@@ -1472,6 +1562,7 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.13.0" },
{ name = "httpx", specifier = ">=0.25.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.7.0" },
{ name = "numpy", specifier = ">=1.24.0" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0.0" },
{ name = "prometheus-client", specifier = ">=0.19.0" },
{ name = "py-clob-client", specifier = ">=0.1.0" },
@@ -1482,6 +1573,7 @@ requires-dist = [
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "redis", specifier = ">=5.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
{ name = "scikit-learn", specifier = ">=1.3.0" },
{ name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "web3", specifier = ">=6.0.0" },
{ name = "websockets", specifier = ">=12.0" },
@@ -2101,6 +2193,127 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
]
[[package]]
name = "scikit-learn"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "joblib" },
{ name = "numpy" },
{ name = "scipy" },
{ name = "threadpoolctl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" },
{ url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" },
{ url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" },
{ url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" },
{ url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" },
{ url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" },
{ url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" },
{ url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" },
{ url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" },
{ url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" },
{ url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" },
{ url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" },
{ url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" },
{ url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" },
{ url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" },
{ url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" },
{ url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" },
{ url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" },
{ url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" },
{ url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" },
{ url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" },
{ url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" },
{ url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" },
{ url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" },
{ url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" },
{ url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" },
{ url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" },
{ url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" },
{ url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" },
{ url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" },
{ url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" },
{ url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" },
{ url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" },
{ url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" },
{ url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" },
{ url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" },
]
[[package]]
name = "scipy"
version = "1.16.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0a/ca/d8ace4f98322d01abcd52d381134344bf7b431eba7ed8b42bdea5a3c2ac9/scipy-1.16.3.tar.gz", hash = "sha256:01e87659402762f43bd2fee13370553a17ada367d42e7487800bf2916535aecb", size = 30597883, upload-time = "2025-10-28T17:38:54.068Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/5f/6f37d7439de1455ce9c5a556b8d1db0979f03a796c030bafdf08d35b7bf9/scipy-1.16.3-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:40be6cf99e68b6c4321e9f8782e7d5ff8265af28ef2cd56e9c9b2638fa08ad97", size = 36630881, upload-time = "2025-10-28T17:31:47.104Z" },
{ url = "https://files.pythonhosted.org/packages/7c/89/d70e9f628749b7e4db2aa4cd89735502ff3f08f7b9b27d2e799485987cd9/scipy-1.16.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:8be1ca9170fcb6223cc7c27f4305d680ded114a1567c0bd2bfcbf947d1b17511", size = 28941012, upload-time = "2025-10-28T17:31:53.411Z" },
{ url = "https://files.pythonhosted.org/packages/a8/a8/0e7a9a6872a923505dbdf6bb93451edcac120363131c19013044a1e7cb0c/scipy-1.16.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bea0a62734d20d67608660f69dcda23e7f90fb4ca20974ab80b6ed40df87a005", size = 20931935, upload-time = "2025-10-28T17:31:57.361Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c7/020fb72bd79ad798e4dbe53938543ecb96b3a9ac3fe274b7189e23e27353/scipy-1.16.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:2a207a6ce9c24f1951241f4693ede2d393f59c07abc159b2cb2be980820e01fb", size = 23534466, upload-time = "2025-10-28T17:32:01.875Z" },
{ url = "https://files.pythonhosted.org/packages/be/a0/668c4609ce6dbf2f948e167836ccaf897f95fb63fa231c87da7558a374cd/scipy-1.16.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:532fb5ad6a87e9e9cd9c959b106b73145a03f04c7d57ea3e6f6bb60b86ab0876", size = 33593618, upload-time = "2025-10-28T17:32:06.902Z" },
{ url = "https://files.pythonhosted.org/packages/ca/6e/8942461cf2636cdae083e3eb72622a7fbbfa5cf559c7d13ab250a5dbdc01/scipy-1.16.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0151a0749efeaaab78711c78422d413c583b8cdd2011a3c1d6c794938ee9fdb2", size = 35899798, upload-time = "2025-10-28T17:32:12.665Z" },
{ url = "https://files.pythonhosted.org/packages/79/e8/d0f33590364cdbd67f28ce79368b373889faa4ee959588beddf6daef9abe/scipy-1.16.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7180967113560cca57418a7bc719e30366b47959dd845a93206fbed693c867e", size = 36226154, upload-time = "2025-10-28T17:32:17.961Z" },
{ url = "https://files.pythonhosted.org/packages/39/c1/1903de608c0c924a1749c590064e65810f8046e437aba6be365abc4f7557/scipy-1.16.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:deb3841c925eeddb6afc1e4e4a45e418d19ec7b87c5df177695224078e8ec733", size = 38878540, upload-time = "2025-10-28T17:32:23.907Z" },
{ url = "https://files.pythonhosted.org/packages/f1/d0/22ec7036ba0b0a35bccb7f25ab407382ed34af0b111475eb301c16f8a2e5/scipy-1.16.3-cp311-cp311-win_amd64.whl", hash = "sha256:53c3844d527213631e886621df5695d35e4f6a75f620dca412bcd292f6b87d78", size = 38722107, upload-time = "2025-10-28T17:32:29.921Z" },
{ url = "https://files.pythonhosted.org/packages/7b/60/8a00e5a524bb3bf8898db1650d350f50e6cffb9d7a491c561dc9826c7515/scipy-1.16.3-cp311-cp311-win_arm64.whl", hash = "sha256:9452781bd879b14b6f055b26643703551320aa8d79ae064a71df55c00286a184", size = 25506272, upload-time = "2025-10-28T17:32:34.577Z" },
{ url = "https://files.pythonhosted.org/packages/40/41/5bf55c3f386b1643812f3a5674edf74b26184378ef0f3e7c7a09a7e2ca7f/scipy-1.16.3-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81fc5827606858cf71446a5e98715ba0e11f0dbc83d71c7409d05486592a45d6", size = 36659043, upload-time = "2025-10-28T17:32:40.285Z" },
{ url = "https://files.pythonhosted.org/packages/1e/0f/65582071948cfc45d43e9870bf7ca5f0e0684e165d7c9ef4e50d783073eb/scipy-1.16.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:c97176013d404c7346bf57874eaac5187d969293bf40497140b0a2b2b7482e07", size = 28898986, upload-time = "2025-10-28T17:32:45.325Z" },
{ url = "https://files.pythonhosted.org/packages/96/5e/36bf3f0ac298187d1ceadde9051177d6a4fe4d507e8f59067dc9dd39e650/scipy-1.16.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2b71d93c8a9936046866acebc915e2af2e292b883ed6e2cbe5c34beb094b82d9", size = 20889814, upload-time = "2025-10-28T17:32:49.277Z" },
{ url = "https://files.pythonhosted.org/packages/80/35/178d9d0c35394d5d5211bbff7ac4f2986c5488b59506fef9e1de13ea28d3/scipy-1.16.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3d4a07a8e785d80289dfe66b7c27d8634a773020742ec7187b85ccc4b0e7b686", size = 23565795, upload-time = "2025-10-28T17:32:53.337Z" },
{ url = "https://files.pythonhosted.org/packages/fa/46/d1146ff536d034d02f83c8afc3c4bab2eddb634624d6529a8512f3afc9da/scipy-1.16.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0553371015692a898e1aa858fed67a3576c34edefa6b7ebdb4e9dde49ce5c203", size = 33349476, upload-time = "2025-10-28T17:32:58.353Z" },
{ url = "https://files.pythonhosted.org/packages/79/2e/415119c9ab3e62249e18c2b082c07aff907a273741b3f8160414b0e9193c/scipy-1.16.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72d1717fd3b5e6ec747327ce9bda32d5463f472c9dce9f54499e81fbd50245a1", size = 35676692, upload-time = "2025-10-28T17:33:03.88Z" },
{ url = "https://files.pythonhosted.org/packages/27/82/df26e44da78bf8d2aeaf7566082260cfa15955a5a6e96e6a29935b64132f/scipy-1.16.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fb2472e72e24d1530debe6ae078db70fb1605350c88a3d14bc401d6306dbffe", size = 36019345, upload-time = "2025-10-28T17:33:09.773Z" },
{ url = "https://files.pythonhosted.org/packages/82/31/006cbb4b648ba379a95c87262c2855cd0d09453e500937f78b30f02fa1cd/scipy-1.16.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5192722cffe15f9329a3948c4b1db789fbb1f05c97899187dcf009b283aea70", size = 38678975, upload-time = "2025-10-28T17:33:15.809Z" },
{ url = "https://files.pythonhosted.org/packages/c2/7f/acbd28c97e990b421af7d6d6cd416358c9c293fc958b8529e0bd5d2a2a19/scipy-1.16.3-cp312-cp312-win_amd64.whl", hash = "sha256:56edc65510d1331dae01ef9b658d428e33ed48b4f77b1d51caf479a0253f96dc", size = 38555926, upload-time = "2025-10-28T17:33:21.388Z" },
{ url = "https://files.pythonhosted.org/packages/ce/69/c5c7807fd007dad4f48e0a5f2153038dc96e8725d3345b9ee31b2b7bed46/scipy-1.16.3-cp312-cp312-win_arm64.whl", hash = "sha256:a8a26c78ef223d3e30920ef759e25625a0ecdd0d60e5a8818b7513c3e5384cf2", size = 25463014, upload-time = "2025-10-28T17:33:25.975Z" },
{ url = "https://files.pythonhosted.org/packages/72/f1/57e8327ab1508272029e27eeef34f2302ffc156b69e7e233e906c2a5c379/scipy-1.16.3-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:d2ec56337675e61b312179a1ad124f5f570c00f920cc75e1000025451b88241c", size = 36617856, upload-time = "2025-10-28T17:33:31.375Z" },
{ url = "https://files.pythonhosted.org/packages/44/13/7e63cfba8a7452eb756306aa2fd9b37a29a323b672b964b4fdeded9a3f21/scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:16b8bc35a4cc24db80a0ec836a9286d0e31b2503cb2fd7ff7fb0e0374a97081d", size = 28874306, upload-time = "2025-10-28T17:33:36.516Z" },
{ url = "https://files.pythonhosted.org/packages/15/65/3a9400efd0228a176e6ec3454b1fa998fbbb5a8defa1672c3f65706987db/scipy-1.16.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:5803c5fadd29de0cf27fa08ccbfe7a9e5d741bf63e4ab1085437266f12460ff9", size = 20865371, upload-time = "2025-10-28T17:33:42.094Z" },
{ url = "https://files.pythonhosted.org/packages/33/d7/eda09adf009a9fb81827194d4dd02d2e4bc752cef16737cc4ef065234031/scipy-1.16.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:b81c27fc41954319a943d43b20e07c40bdcd3ff7cf013f4fb86286faefe546c4", size = 23524877, upload-time = "2025-10-28T17:33:48.483Z" },
{ url = "https://files.pythonhosted.org/packages/7d/6b/3f911e1ebc364cb81320223a3422aab7d26c9c7973109a9cd0f27c64c6c0/scipy-1.16.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0c3b4dd3d9b08dbce0f3440032c52e9e2ab9f96ade2d3943313dfe51a7056959", size = 33342103, upload-time = "2025-10-28T17:33:56.495Z" },
{ url = "https://files.pythonhosted.org/packages/21/f6/4bfb5695d8941e5c570a04d9fcd0d36bce7511b7d78e6e75c8f9791f82d0/scipy-1.16.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7dc1360c06535ea6116a2220f760ae572db9f661aba2d88074fe30ec2aa1ff88", size = 35697297, upload-time = "2025-10-28T17:34:04.722Z" },
{ url = "https://files.pythonhosted.org/packages/04/e1/6496dadbc80d8d896ff72511ecfe2316b50313bfc3ebf07a3f580f08bd8c/scipy-1.16.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:663b8d66a8748051c3ee9c96465fb417509315b99c71550fda2591d7dd634234", size = 36021756, upload-time = "2025-10-28T17:34:13.482Z" },
{ url = "https://files.pythonhosted.org/packages/fe/bd/a8c7799e0136b987bda3e1b23d155bcb31aec68a4a472554df5f0937eef7/scipy-1.16.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eab43fae33a0c39006a88096cd7b4f4ef545ea0447d250d5ac18202d40b6611d", size = 38696566, upload-time = "2025-10-28T17:34:22.384Z" },
{ url = "https://files.pythonhosted.org/packages/cd/01/1204382461fcbfeb05b6161b594f4007e78b6eba9b375382f79153172b4d/scipy-1.16.3-cp313-cp313-win_amd64.whl", hash = "sha256:062246acacbe9f8210de8e751b16fc37458213f124bef161a5a02c7a39284304", size = 38529877, upload-time = "2025-10-28T17:35:51.076Z" },
{ url = "https://files.pythonhosted.org/packages/7f/14/9d9fbcaa1260a94f4bb5b64ba9213ceb5d03cd88841fe9fd1ffd47a45b73/scipy-1.16.3-cp313-cp313-win_arm64.whl", hash = "sha256:50a3dbf286dbc7d84f176f9a1574c705f277cb6565069f88f60db9eafdbe3ee2", size = 25455366, upload-time = "2025-10-28T17:35:59.014Z" },
{ url = "https://files.pythonhosted.org/packages/e2/a3/9ec205bd49f42d45d77f1730dbad9ccf146244c1647605cf834b3a8c4f36/scipy-1.16.3-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:fb4b29f4cf8cc5a8d628bc8d8e26d12d7278cd1f219f22698a378c3d67db5e4b", size = 37027931, upload-time = "2025-10-28T17:34:31.451Z" },
{ url = "https://files.pythonhosted.org/packages/25/06/ca9fd1f3a4589cbd825b1447e5db3a8ebb969c1eaf22c8579bd286f51b6d/scipy-1.16.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:8d09d72dc92742988b0e7750bddb8060b0c7079606c0d24a8cc8e9c9c11f9079", size = 29400081, upload-time = "2025-10-28T17:34:39.087Z" },
{ url = "https://files.pythonhosted.org/packages/6a/56/933e68210d92657d93fb0e381683bc0e53a965048d7358ff5fbf9e6a1b17/scipy-1.16.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:03192a35e661470197556de24e7cb1330d84b35b94ead65c46ad6f16f6b28f2a", size = 21391244, upload-time = "2025-10-28T17:34:45.234Z" },
{ url = "https://files.pythonhosted.org/packages/a8/7e/779845db03dc1418e215726329674b40576879b91814568757ff0014ad65/scipy-1.16.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:57d01cb6f85e34f0946b33caa66e892aae072b64b034183f3d87c4025802a119", size = 23929753, upload-time = "2025-10-28T17:34:51.793Z" },
{ url = "https://files.pythonhosted.org/packages/4c/4b/f756cf8161d5365dcdef9e5f460ab226c068211030a175d2fc7f3f41ca64/scipy-1.16.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:96491a6a54e995f00a28a3c3badfff58fd093bf26cd5fb34a2188c8c756a3a2c", size = 33496912, upload-time = "2025-10-28T17:34:59.8Z" },
{ url = "https://files.pythonhosted.org/packages/09/b5/222b1e49a58668f23839ca1542a6322bb095ab8d6590d4f71723869a6c2c/scipy-1.16.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cd13e354df9938598af2be05822c323e97132d5e6306b83a3b4ee6724c6e522e", size = 35802371, upload-time = "2025-10-28T17:35:08.173Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8d/5964ef68bb31829bde27611f8c9deeac13764589fe74a75390242b64ca44/scipy-1.16.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63d3cdacb8a824a295191a723ee5e4ea7768ca5ca5f2838532d9f2e2b3ce2135", size = 36190477, upload-time = "2025-10-28T17:35:16.7Z" },
{ url = "https://files.pythonhosted.org/packages/ab/f2/b31d75cb9b5fa4dd39a0a931ee9b33e7f6f36f23be5ef560bf72e0f92f32/scipy-1.16.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e7efa2681ea410b10dde31a52b18b0154d66f2485328830e45fdf183af5aefc6", size = 38796678, upload-time = "2025-10-28T17:35:26.354Z" },
{ url = "https://files.pythonhosted.org/packages/b4/1e/b3723d8ff64ab548c38d87055483714fefe6ee20e0189b62352b5e015bb1/scipy-1.16.3-cp313-cp313t-win_amd64.whl", hash = "sha256:2d1ae2cf0c350e7705168ff2429962a89ad90c2d49d1dd300686d8b2a5af22fc", size = 38640178, upload-time = "2025-10-28T17:35:35.304Z" },
{ url = "https://files.pythonhosted.org/packages/8e/f3/d854ff38789aca9b0cc23008d607ced9de4f7ab14fa1ca4329f86b3758ca/scipy-1.16.3-cp313-cp313t-win_arm64.whl", hash = "sha256:0c623a54f7b79dd88ef56da19bc2873afec9673a48f3b85b18e4d402bdd29a5a", size = 25803246, upload-time = "2025-10-28T17:35:42.155Z" },
{ url = "https://files.pythonhosted.org/packages/99/f6/99b10fd70f2d864c1e29a28bbcaa0c6340f9d8518396542d9ea3b4aaae15/scipy-1.16.3-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:875555ce62743e1d54f06cdf22c1e0bc47b91130ac40fe5d783b6dfa114beeb6", size = 36606469, upload-time = "2025-10-28T17:36:08.741Z" },
{ url = "https://files.pythonhosted.org/packages/4d/74/043b54f2319f48ea940dd025779fa28ee360e6b95acb7cd188fad4391c6b/scipy-1.16.3-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bb61878c18a470021fb515a843dc7a76961a8daceaaaa8bad1332f1bf4b54657", size = 28872043, upload-time = "2025-10-28T17:36:16.599Z" },
{ url = "https://files.pythonhosted.org/packages/4d/e1/24b7e50cc1c4ee6ffbcb1f27fe9f4c8b40e7911675f6d2d20955f41c6348/scipy-1.16.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2622206f5559784fa5c4b53a950c3c7c1cf3e84ca1b9c4b6c03f062f289ca26", size = 20862952, upload-time = "2025-10-28T17:36:22.966Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3a/3e8c01a4d742b730df368e063787c6808597ccb38636ed821d10b39ca51b/scipy-1.16.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7f68154688c515cdb541a31ef8eb66d8cd1050605be9dcd74199cbd22ac739bc", size = 23508512, upload-time = "2025-10-28T17:36:29.731Z" },
{ url = "https://files.pythonhosted.org/packages/1f/60/c45a12b98ad591536bfe5330cb3cfe1850d7570259303563b1721564d458/scipy-1.16.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3c820ddb80029fe9f43d61b81d8b488d3ef8ca010d15122b152db77dc94c22", size = 33413639, upload-time = "2025-10-28T17:36:37.982Z" },
{ url = "https://files.pythonhosted.org/packages/71/bc/35957d88645476307e4839712642896689df442f3e53b0fa016ecf8a3357/scipy-1.16.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d3837938ae715fc0fe3c39c0202de3a8853aff22ca66781ddc2ade7554b7e2cc", size = 35704729, upload-time = "2025-10-28T17:36:46.547Z" },
{ url = "https://files.pythonhosted.org/packages/3b/15/89105e659041b1ca11c386e9995aefacd513a78493656e57789f9d9eab61/scipy-1.16.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aadd23f98f9cb069b3bd64ddc900c4d277778242e961751f77a8cb5c4b946fb0", size = 36086251, upload-time = "2025-10-28T17:36:55.161Z" },
{ url = "https://files.pythonhosted.org/packages/1a/87/c0ea673ac9c6cc50b3da2196d860273bc7389aa69b64efa8493bdd25b093/scipy-1.16.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b7c5f1bda1354d6a19bc6af73a649f8285ca63ac6b52e64e658a5a11d4d69800", size = 38716681, upload-time = "2025-10-28T17:37:04.1Z" },
{ url = "https://files.pythonhosted.org/packages/91/06/837893227b043fb9b0d13e4bd7586982d8136cb249ffb3492930dab905b8/scipy-1.16.3-cp314-cp314-win_amd64.whl", hash = "sha256:e5d42a9472e7579e473879a1990327830493a7047506d58d73fc429b84c1d49d", size = 39358423, upload-time = "2025-10-28T17:38:20.005Z" },
{ url = "https://files.pythonhosted.org/packages/95/03/28bce0355e4d34a7c034727505a02d19548549e190bedd13a721e35380b7/scipy-1.16.3-cp314-cp314-win_arm64.whl", hash = "sha256:6020470b9d00245926f2d5bb93b119ca0340f0d564eb6fbaad843eaebf9d690f", size = 26135027, upload-time = "2025-10-28T17:38:24.966Z" },
{ url = "https://files.pythonhosted.org/packages/b2/6f/69f1e2b682efe9de8fe9f91040f0cd32f13cfccba690512ba4c582b0bc29/scipy-1.16.3-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:e1d27cbcb4602680a49d787d90664fa4974063ac9d4134813332a8c53dbe667c", size = 37028379, upload-time = "2025-10-28T17:37:14.061Z" },
{ url = "https://files.pythonhosted.org/packages/7c/2d/e826f31624a5ebbab1cd93d30fd74349914753076ed0593e1d56a98c4fb4/scipy-1.16.3-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:9b9c9c07b6d56a35777a1b4cc8966118fb16cfd8daf6743867d17d36cfad2d40", size = 29400052, upload-time = "2025-10-28T17:37:21.709Z" },
{ url = "https://files.pythonhosted.org/packages/69/27/d24feb80155f41fd1f156bf144e7e049b4e2b9dd06261a242905e3bc7a03/scipy-1.16.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:3a4c460301fb2cffb7f88528f30b3127742cff583603aa7dc964a52c463b385d", size = 21391183, upload-time = "2025-10-28T17:37:29.559Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d3/1b229e433074c5738a24277eca520a2319aac7465eea7310ea6ae0e98ae2/scipy-1.16.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f667a4542cc8917af1db06366d3f78a5c8e83badd56409f94d1eac8d8d9133fa", size = 23930174, upload-time = "2025-10-28T17:37:36.306Z" },
{ url = "https://files.pythonhosted.org/packages/16/9d/d9e148b0ec680c0f042581a2be79a28a7ab66c0c4946697f9e7553ead337/scipy-1.16.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f379b54b77a597aa7ee5e697df0d66903e41b9c85a6dd7946159e356319158e8", size = 33497852, upload-time = "2025-10-28T17:37:42.228Z" },
{ url = "https://files.pythonhosted.org/packages/2f/22/4e5f7561e4f98b7bea63cf3fd7934bff1e3182e9f1626b089a679914d5c8/scipy-1.16.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4aff59800a3b7f786b70bfd6ab551001cb553244988d7d6b8299cb1ea653b353", size = 35798595, upload-time = "2025-10-28T17:37:48.102Z" },
{ url = "https://files.pythonhosted.org/packages/83/42/6644d714c179429fc7196857866f219fef25238319b650bb32dde7bf7a48/scipy-1.16.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:da7763f55885045036fabcebd80144b757d3db06ab0861415d1c3b7c69042146", size = 36186269, upload-time = "2025-10-28T17:37:53.72Z" },
{ url = "https://files.pythonhosted.org/packages/ac/70/64b4d7ca92f9cf2e6fc6aaa2eecf80bb9b6b985043a9583f32f8177ea122/scipy-1.16.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa6eea95283b2b8079b821dc11f50a17d0571c92b43e2b5b12764dc5f9b285d", size = 38802779, upload-time = "2025-10-28T17:37:59.393Z" },
{ url = "https://files.pythonhosted.org/packages/61/82/8d0e39f62764cce5ffd5284131e109f07cf8955aef9ab8ed4e3aa5e30539/scipy-1.16.3-cp314-cp314t-win_amd64.whl", hash = "sha256:d9f48cafc7ce94cf9b15c6bffdc443a81a27bf7075cf2dcd5c8b40f85d10c4e7", size = 39471128, upload-time = "2025-10-28T17:38:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/64/47/a494741db7280eae6dc033510c319e34d42dd41b7ac0c7ead39354d1a2b5/scipy-1.16.3-cp314-cp314t-win_arm64.whl", hash = "sha256:21d9d6b197227a12dcbf9633320a4e34c6b0e51c57268df255a0942983bac562", size = 26464127, upload-time = "2025-10-28T17:38:11.34Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.45"
@@ -2143,6 +2356,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" },
]
[[package]]
name = "threadpoolctl"
version = "3.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" },
]
[[package]]
name = "tomli"
version = "2.3.0"