Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d854020322 | |||
| 131dfed3b4 | |||
| 96e471882a | |||
| c100bbfdfb | |||
| b9c0d8304f | |||
| c68a6cc32d | |||
| 0c4d2bebea | |||
| 4111d1ee00 | |||
| 0003d228e5 | |||
| 656bd6469c | |||
| 87f51a9c32 | |||
| 6f60e2fb6d | |||
| b0314fee57 | |||
| f91dbb6b6d | |||
| ccae51c466 | |||
| 4acc7916dd | |||
| 4981277eef | |||
| d5e04593f1 | |||
| a0bec521b6 | |||
| 89bf80e3d8 |
@@ -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=
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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 @@
|
||||
"""Tests for alerter module."""
|
||||
"""Tests for the alerter module."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 @@
|
||||
"""Tests for storage module."""
|
||||
"""Tests for the storage layer."""
|
||||
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user