Compare commits

...

33 Commits

Author SHA1 Message Date
pselamy b1b88a3dd0 fix: lint/format fixes for eth_getLogs chunking
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-14 19:14:16 +00:00
schrodinger01 8ebcd6b4c6 fix(profiler): 0x-prefix the Transfer event topic for strict RPC providers
`HexBytes.hex()` returns a bare hex string with no `0x` prefix. publicnode
tolerates that, but drpc — which we use as the failover RPC — rejects it
outright with `invalid argument 0: hex string without 0x prefix`, and every
single eth_getLogs chunk in the funding trace fails. Once the primary is
flipped to unhealthy by any other call, the entire funding subsystem
silently produces zero rows in funding_transfers.

Switch to a precomputed `TRANSFER_EVENT_TOPIC` constant that always carries
the `0x` prefix, and add a regression test that asserts the topic shape
sent to eth_getLogs.
2026-06-14 19:13:14 +00:00
schrodinger01 ff1e23a0d3 fix(profiler): cap default lookback at 80k blocks + early-break on pruned
Field-test of the chunking fix on a public Polygon RPC (publicnode)
revealed a second wall behind the first: after a chunk request lands
outside the provider's archive horizon, every subsequent chunk fails
with the same error:

  {'code': -32701, 'message': 'History has been pruned for this block.
   To remove restrictions, order a dedicated full node here: ...'}

publicnode empirically retains roughly the most recent 100_000 blocks
(~55 hours) of log history. Surveying other public free-tier RPCs:

  drpc.org      — archive, but rejects ranges >= ~1_000 blocks
  llamarpc      — empty responses on archive ranges
  ankr          — now requires API key
  blockpi/onfin — block-range limits 50–500
  1rpc.io/matic — limited to 50 blocks

Two changes to make funding traces actually return data on a public
RPC instead of swallowing 140 pruned-history warnings per wallet:

1. Lower DEFAULT_MAX_LOOKBACK_BLOCKS from 1_300_000 to 80_000. Fresh
   wallets — the population this signal exists to flag — are by
   definition new, so a ~44 hour window covers their entire funding
   history. Older wallets lose archive coverage on free RPCs but
   they're not what the fresh-wallet signal scores on anyway.

2. Detect pruned-history errors by message substring and short-circuit
   the chunk walk. Walking further back is futile once we're past the
   cutoff; bailing early avoids burning RPC quota on chunks that are
   guaranteed to fail.

Both knobs remain constructor parameters — deployments behind a paid
archive node can dial DEFAULT_MAX_LOOKBACK_BLOCKS back up.

Two new tests:
- test_get_transfer_logs_breaks_on_pruned_history: pruned error on
  chunk #2 must keep chunk #3 from ever being issued
- test_get_transfer_logs_default_lookback_fits_pruned_horizon:
  regression guard pinning the default at <= 100_000 so a future
  refactor doesn't silently re-introduce the unusable default
2026-06-14 19:13:14 +00:00
schrodinger01 82f3e8eb6a fix(profiler): chunk eth_getLogs into <=10k-block windows
Public Polygon RPC providers (publicnode, ankr, llamarpc) cap eth_getLogs
at 10_000 blocks per request. The funding tracer was calling get_logs
with from_block=0 / to_block="latest", so every funding chain trace
failed in production with:

    {'code': -32701, 'message': 'exceed maximum block range: 10000'}

Resolve the symbolic range to concrete bounds (default lookback ~30 days
of Polygon blocks) and walk the window in 9_000-block chunks, oldest
first, stopping early once `limit` matches are gathered. Walking
oldest-first preserves the "first transfer" semantics the funding tracer
already relies on.

Includes 4 new tests:
- chunks_large_ranges: regression guard that no single window exceeds
  the cap
- stops_when_limit_hit_mid_walk: short-circuits once enough hits
- skips_failing_chunk: a flaky window doesn't tank the whole trace
- resolves_latest_via_block_number: from_block=0 + to_block="latest"
  resolves to the last max_lookback_blocks

The 3 pre-existing _get_transfer_logs tests now pass explicit numeric
ranges so they don't go through the latest-resolution path; coverage of
that path is moved to the new dedicated test.
2026-06-14 19:13:14 +00:00
Patrick Selamy b962bdaee2 fix(ingestor): align WebSocket subscribe + routing with live API (#105)
* fix(ingestor): align WebSocket subscribe + routing with live API

The Polymarket ws-live-data WebSocket requires `action: "subscribe"` in
the subscribe envelope. Without it the server accepts the connection but
never delivers trade events, causing the tracker to silently produce
zero alerts.

Additionally, incoming frames are shaped `{connection_id, payload:{...}}`
-- they do NOT echo the `topic`/`type` keys we sent. The previous routing
check matched nothing and every real trade was silently dropped.

Changes:
- Add `action: "subscribe"` to subscription message
- Route incoming messages by payload shape (transactionHash + proxyWallet)
- Add ratchet tests for payload routing edge cases
- Rewrite README as agent-first with <2min quickstart
- Add skill draft (docs/skill-tracking-prediction-market-flow.md)

Closes #89

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(lint): remove unused imports in test_pipeline_persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: apply ruff formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-14 14:24:54 -04:00
axel-claw ff678468e3 fix: load nested env settings and ignore blank webhooks (#91) 2026-04-11 16:20:15 -04:00
Patrick Selamy 1f4fc607a7 feat: wire WalletRepository and FundingRepository into live pipeline (#90)
The live pipeline was receiving trade data and populating Redis caches but
never persisting wallet profiles or funding transfers to Postgres. This
wires the repositories into the _on_trade flow: when a fresh wallet signal
is detected, the wallet profile is upserted to wallet_profiles and the
funding chain is traced and inserted into funding_transfers. Existing
Redis caching behavior is preserved.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 09:21:37 -04:00
Patrick Selamy 232680a760 chore: smoke test fixes and code quality improvements (#88)
* fix: replace deprecated datetime.utcnow() and websockets.legacy APIs

- Replace datetime.utcnow() with datetime.now(UTC) in Orderbook model
- Use websockets.asyncio.client.connect instead of legacy websockets.connect
- Import ConnectionClosed from websockets.exceptions directly
- Update test mocks to patch the new import paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve ruff lint errors in alembic migration

- Replace typing.Union with X | Y syntax (UP007)
- Import Sequence from collections.abc instead of typing (UP035)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove deprecated version key from docker-compose.yml

The top-level 'version' key is obsolete in modern Docker Compose
and produces a warning.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: fix README to match actual project structure and tooling

- Replace pip commands with uv equivalents
- Fix run command from 'python -m src.main' to 'python -m polymarket_insider_tracker'
- Update project structure tree to reflect actual src/polymarket_insider_tracker/ layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:03:10 -04:00
Patrick Selamy 7c494c38a6 fix: replace (str, Enum) with StrEnum to resolve ruff UP042 (#87)
StrEnum (Python 3.11+) is the modern replacement for the (str, Enum)
pattern. Updates SyncState and PipelineState enums.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:17:51 -04:00
Patrick Selamy 17504a5eaa fix: replace (str, Enum) with StrEnum to resolve ruff UP042 (#86)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:16:23 -04:00
Patrick Selamy cadc593d62 fix: replace (str, Enum) with StrEnum to resolve ruff UP042 (#85)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:14:36 -04:00
Patrick Selamy 0320a96f8a fix: replace (str, Enum) with StrEnum (ruff UP042) (#84)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:12:59 -04:00
Patrick Selamy 753d6cf1de fix: replace (str, Enum) with StrEnum (ruff UP042) (#83)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:11:19 -04:00
Patrick Selamy f3801987fe fix: resolve .env.example variable interpolation and alembic env var mismatch (#62) (#82)
.env files don't support shell variable expansion, so DATABASE_URL and
REDIS_URL contained literal ${...} strings. Also alembic/env.py read
SQLALCHEMY_DATABASE_URL instead of DATABASE_URL.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:05:00 -04:00
Patrick Selamy 5cbcaccd82 fix: resolve alembic migration failures from .env and env var mismatch (#81)
- Replace shell variable interpolation in .env.example with literal
  values since .env files don't support variable expansion
- Change alembic/env.py to read DATABASE_URL (matching app convention)
  instead of SQLALCHEMY_DATABASE_URL

Closes #62

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:03:58 -04:00
Patrick Selamy 288fbc75cf fix: resolve broken alembic migrations (#62) (#80)
- Replace shell variable interpolation in .env.example with literal values
- Change alembic/env.py to read DATABASE_URL instead of SQLALCHEMY_DATABASE_URL

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:02:53 -04:00
Patrick Selamy 37ea0c6540 fix: resolve broken alembic migrations (#62) (#79)
- Replace shell variable interpolation in .env.example with literal values
- Change alembic/env.py to read DATABASE_URL instead of SQLALCHEMY_DATABASE_URL

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:02:02 -04:00
Patrick Selamy 654f4466f0 fix: resolve broken alembic migrations (#62) (#78)
- Replace shell variable interpolation in .env.example with literal
  values since .env files don't expand variables
- Change alembic/env.py to read DATABASE_URL instead of
  SQLALCHEMY_DATABASE_URL to match the app's env var name

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:00:56 -04:00
Patrick Selamy 8628e3aadb fix: use literal values in .env.example and correct env var name in alembic (#77)
.env files don't support shell variable interpolation, so DATABASE_URL
and REDIS_URL had literal ${VAR} strings instead of actual values.
Also alembic/env.py read SQLALCHEMY_DATABASE_URL instead of DATABASE_URL.

Closes #62

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:59:46 -04:00
Patrick Selamy d808819990 fix: resolve alembic migration setup bugs (#62) (#76)
- Replace shell variable interpolation in .env.example with literal
  default values (.env files don't expand variables)
- Change alembic/env.py to read DATABASE_URL instead of
  SQLALCHEMY_DATABASE_URL to match the app's convention

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:58:34 -04:00
Patrick Selamy 20b8ad992c fix: resolve alembic migration setup bugs (#62) (#75)
- Replace shell variable interpolation in .env.example with literal
  default values since .env files don't expand variables
- Change alembic/env.py to read DATABASE_URL instead of
  SQLALCHEMY_DATABASE_URL to match the app's env var name

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:57:33 -04:00
Patrick Selamy bd320f1a8e fix: resolve alembic migration setup bugs (#62) (#74)
- Replace shell variable interpolation in .env.example with literal
  values (.env files don't expand variables)
- Change alembic/env.py to read DATABASE_URL instead of
  SQLALCHEMY_DATABASE_URL to match app configuration

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:56:30 -04:00
Patrick Selamy c65ed2225f fix: resolve alembic migration setup bugs (#62) (#73)
- Replace shell variable interpolation in .env.example with literal
  values (.env files don't expand variables)
- Change alembic/env.py to read DATABASE_URL instead of
  SQLALCHEMY_DATABASE_URL to match app convention

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:55:24 -04:00
Patrick Selamy 06ff48117c fix: use literal values in .env.example and correct env var name in alembic (#72)
.env files don't support shell variable interpolation, so DATABASE_URL
and REDIS_URL had literal ${VAR} strings instead of actual values.
Also alembic/env.py was reading SQLALCHEMY_DATABASE_URL instead of
DATABASE_URL, which is what the app and .env use.

Closes #62

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:54:15 -04:00
Patrick Selamy 34393e7ee3 fix: resolve alembic migration failures from env config bugs (#62) (#71)
- Replace shell variable interpolation in .env.example with literal
  values (.env files don't expand ${VAR} syntax)
- Change alembic/env.py to read DATABASE_URL instead of
  SQLALCHEMY_DATABASE_URL to match the app's env var name

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:53:02 -04:00
Patrick Selamy cf66aac9e3 fix: resolve alembic migration failures from env config bugs (#62) (#70)
- Replace shell variable interpolation in .env.example with literal
  values (.env files don't expand ${VAR} syntax)
- Change alembic/env.py to read DATABASE_URL instead of
  SQLALCHEMY_DATABASE_URL to match the app's env var name

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:52:16 -04:00
Patrick Selamy 13d5945b01 fix: resolve alembic migration failures from env config bugs (#62) (#69)
- Replace shell variable interpolation in .env.example with literal values
- Fix alembic/env.py to read DATABASE_URL instead of SQLALCHEMY_DATABASE_URL

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:51:13 -04:00
Patrick Selamy 7352143004 fix: resolve two bugs preventing alembic migrations (#62) (#68)
1. Replace shell variable interpolation in .env.example with literal
   values, since .env files don't expand variables.
2. Change alembic/env.py to read DATABASE_URL instead of
   SQLALCHEMY_DATABASE_URL to match app convention.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:49:57 -04:00
Patrick Selamy e05ac2ca64 fix: resolve two bugs preventing alembic migrations (#62) (#67)
1. Replace shell variable interpolation in .env.example with literal
   values, since .env files don't expand variables.
2. Change alembic/env.py to read DATABASE_URL instead of
   SQLALCHEMY_DATABASE_URL to match app convention.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:49:18 -04:00
Patrick Selamy 5d533c8c79 fix: resolve two bugs preventing alembic migrations (#62) (#66)
1. Replace shell variable interpolation in .env.example with literal
   values, since .env files don't expand variables.
2. Change alembic/env.py to read DATABASE_URL instead of
   SQLALCHEMY_DATABASE_URL to match app convention.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:48:25 -04:00
Patrick Selamy e67f2d6d8f fix: resolve alembic migration setup bugs (#62) (#65)
- Replace shell variable interpolation in .env.example with literal values
- Change alembic/env.py to read DATABASE_URL instead of SQLALCHEMY_DATABASE_URL

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:47:23 -04:00
Patrick Selamy 387a10132b fix: resolve alembic migration setup bugs (#62) (#64)
- Replace shell variable interpolation in .env.example with literal
  default values (.env files don't expand variables)
- Change alembic/env.py to read DATABASE_URL instead of
  SQLALCHEMY_DATABASE_URL to match the app's env var name

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:46:25 -04:00
Patrick Selamy b044bf25be fix: resolve broken alembic migrations (#62) (#63)
- Replace shell variable interpolation in .env.example with literal
  values, since .env files don't expand shell variables
- Fix alembic/env.py to read DATABASE_URL instead of SQLALCHEMY_DATABASE_URL
  to match the app's environment variable convention

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:45:21 -04:00
15 changed files with 1150 additions and 327 deletions
+2 -2
View File
@@ -6,12 +6,12 @@ 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}
DATABASE_URL=postgresql://tracker:dev_password@localhost:5432/polymarket_tracker
# Redis Configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT}
REDIS_URL=redis://localhost:6379
# Optional: Development tool ports
ADMINER_PORT=8080
+118 -254
View File
@@ -2,80 +2,103 @@
**Detect informed money before the market moves.**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![CI](https://github.com/pselamy/polymarket-insider-tracker/actions/workflows/ci.yml/badge.svg)](https://github.com/pselamy/polymarket-insider-tracker/actions/workflows/ci.yml)
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
Real-time detection of suspicious trading patterns on Polymarket: fresh wallets, unusual sizing, niche-market activity, and funding chain analysis. Streams trades via WebSocket, profiles wallets on-chain (Polygon), scores risk with ML + heuristics, and dispatches alerts to Discord/Telegram.
---
## The Opportunity
## Quick Start (< 2 minutes)
On January 3, 2026, a trader spotted a significant political event on Polymarket **before it happened**. How? Not by predicting the future, but by tracking suspicious trading behavior.
### 1. Install
> "You don't need to predict the future, you need to track suspicious behavior."
> — [@DidiTrading](https://x.com/DidiTrading)
An insider wallet turned **$35,000 into $442,000** (12.6x return) by entering a position hours before a major market move. The tool that detected this activity flagged five separate alerts before the event occurred.
**This repository builds that tool.**
---
## What This Does
The Polymarket Insider Tracker monitors prediction market trading activity in real-time and identifies patterns that suggest informed trading:
| Signal | What It Detects | Why It Matters |
|--------|-----------------|----------------|
| **Fresh Wallets** | Brand new wallets making large trades | Insiders create new wallets to hide their identity |
| **Unusual Sizing** | Trades that are disproportionately large for the market | Informed traders bet bigger when they have edge |
| **Niche Markets** | Activity in low-volume, specific-outcome markets | Easier to have inside information on obscure events |
| **Funding Chains** | Where wallet funds originated from | Links seemingly separate wallets to the same entity |
When suspicious activity is detected, you receive an instant alert with actionable intelligence.
---
## How It Works
```
┌─────────────────┐ ┌──────────────────┐ ┌────────────────────┐
│ Polymarket API │────>│ Wallet Profiler │────>│ Anomaly Detector │
│ (Real-time) │ │ (Blockchain) │ │ (ML + Heuristics) │
└─────────────────┘ └──────────────────┘ └────────────────────┘
┌────────────────────────────┘
v
┌─────────────────────┐
│ Alert Dispatcher │───> Discord / Telegram / Email
│ "Fresh wallet │
│ buying YES @7.5¢ │
│ on niche market" │
└─────────────────────┘
```bash
# Requires: Python 3.11+, Docker
git clone https://github.com/pselamy/polymarket-insider-tracker.git
cd polymarket-insider-tracker
uv sync --all-extras # or: pip install -e ".[dev]"
```
### Detection Algorithms
### 2. Start infrastructure
1. **Fresh Wallet Detection**
- Checks wallet transaction history on Polygon
- Flags wallets with fewer than 5 lifetime transactions making trades over $1,000
- Traces funding source to identify if connected to known entities
```bash
docker compose up -d # PostgreSQL 15 + Redis 7
docker compose ps # wait for healthy
```
2. **Liquidity Impact Analysis**
- Calculates trade size relative to market depth
- Flags trades consuming more than 2% of visible order book
- Weights by market category (niche markets score higher)
### 3. Configure
3. **Sniper Cluster Detection**
- Uses DBSCAN clustering to find wallets that consistently enter markets within minutes of creation
- Identifies coordinated behavior patterns
```bash
cp .env.example .env
# Edit .env — only DATABASE_URL and REDIS_URL are required for local dev
# (defaults in .env.example work with docker compose)
```
4. **Event Correlation**
- Cross-references trading activity with news feeds
- Detects positions opened 1-4 hours before related news breaks
### 4. Run migrations + start
```bash
uv run alembic upgrade head
uv run python -m polymarket_insider_tracker
```
You should see live trades within seconds:
```
INFO Connection state: disconnected -> connecting
INFO Connected to wss://ws-live-data.polymarket.com and subscribed to trades
DEBUG Trade: BUY 450 @ 1.00 on fifwc-ger-kor-2026-06-14-ger
DEBUG Trade: SELL 5 @ 0.86 on chi1-cd1-cdl-2026-06-14-draw
```
### CLI Options
```bash
python -m polymarket_insider_tracker --help
--version Show version
--config-check Validate configuration and exit
--log-level DEBUG Override log level
--dry-run Run pipeline without sending alerts
--health-port 8080 Override health check port
```
---
## Sample Alert
## Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `DATABASE_URL` | Yes | — | PostgreSQL connection string |
| `REDIS_URL` | No | `redis://localhost:6379` | Redis connection string |
| `POLYGON_RPC_URL` | No | `https://polygon-rpc.com` | Polygon RPC (public default works) |
| `POLYGON_FALLBACK_RPC_URL` | No | — | Fallback RPC endpoint |
| `POLYMARKET_WS_URL` | No | `wss://ws-live-data.polymarket.com` | WebSocket endpoint |
| `POLYMARKET_API_KEY` | No | — | Optional API key for higher rate limits |
| `DISCORD_WEBHOOK_URL` | No | — | Discord alerts |
| `TELEGRAM_BOT_TOKEN` | No | — | Telegram alerts (needs `TELEGRAM_CHAT_ID` too) |
| `TELEGRAM_CHAT_ID` | No | — | Telegram chat for alerts |
| `LOG_LEVEL` | No | `INFO` | Logging level |
| `DRY_RUN` | No | `false` | Skip sending alerts |
| `HEALTH_PORT` | No | `8080` | Health check HTTP port |
No API keys are needed for basic operation — the Polymarket WebSocket and CLOB REST APIs are public.
---
## What It Detects
| Signal | Detection Method | Threshold |
|--------|-----------------|-----------|
| **Fresh Wallets** | Wallet age < 48h, nonce <= 5, making trades > $1k | Confidence 0.5-0.9 |
| **Size Anomalies** | Trade size > 2% of 24h volume or > 5% of order book | Weighted by niche factor |
| **Niche Markets** | Low-volume markets (< $50k daily) with specific outcomes | 1.5x risk multiplier |
| **Funding Chains** | Trace wallet funding to known entities (exchanges, etc.) | On-chain lineage |
| **Sniper Clusters** | DBSCAN clustering of wallets entering within minutes | Coordinated behavior |
Risk scoring combines signals with configurable weights (default threshold: 0.6). Multi-signal bonuses: 2 signals +20%, 3+ signals +30%.
### Sample Alert
```
SUSPICIOUS ACTIVITY DETECTED
@@ -99,207 +122,62 @@ Confidence: HIGH (3/4 signals triggered)
---
## Quick Start
## Architecture
### Prerequisites
```
Polymarket WebSocket ──> Ingestor ──> Profiler ──> Detector ──> Alerter
(wss://ws-live-data) (trades) (on-chain) (scoring) (Discord/TG)
|
Polygon RPC
```
- Python 3.11+
- Docker and Docker Compose
- Polygon RPC endpoint (Alchemy, QuickNode, or self-hosted)
- Polymarket API key (free at [docs.polymarket.com](https://docs.polymarket.com))
### Components
### Installation
| Module | Purpose |
|--------|---------|
| `ingestor/` | WebSocket trade stream + CLOB REST client with rate limiting |
| `profiler/` | Polygon wallet analysis, entity identification, funding chain tracing |
| `detector/` | Fresh wallet, size anomaly, sniper cluster detection, composite risk scorer |
| `alerter/` | Multi-channel dispatch (Discord webhooks, Telegram bot) with dedup |
| `storage/` | SQLAlchemy ORM + Alembic migrations (PostgreSQL) |
| `pipeline.py` | Orchestrator wiring all components together |
| `shutdown.py` | Graceful SIGTERM/SIGINT handling with cleanup callbacks |
---
## Development
```bash
# Clone the repository
git clone https://github.com/pselamy/polymarket-insider-tracker.git
cd polymarket-insider-tracker
# Copy environment template
cp .env.example .env
# Edit .env with your API keys
# Start infrastructure (PostgreSQL, Redis)
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
uv run pytest # run tests
uv run ruff check src/ tests/ # lint
uv run ruff format src/ tests/ # format
uv run mypy src/ # type check (strict mode)
```
### 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
# .env file
POLYGON_RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY
POLYMARKET_API_KEY=your_polymarket_api_key
# Alert destinations (optional)
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
TELEGRAM_BOT_TOKEN=your_bot_token
TELEGRAM_CHAT_ID=your_chat_id
# Detection thresholds
MIN_TRADE_SIZE_USDC=1000
FRESH_WALLET_MAX_NONCE=5
LIQUIDITY_IMPACT_THRESHOLD=0.02
```
| Adminer | 8080 | Database admin UI (optional, `--profile tools`) |
| RedisInsight | 5540 | Redis admin UI (optional, `--profile tools`) |
---
## Project Structure
## Troubleshooting
```
polymarket-insider-tracker/
├── src/
│ ├── ingestor/ # Real-time market data ingestion
│ │ ├── clob_client.py # Polymarket CLOB API wrapper
│ │ └── websocket.py # WebSocket event handler
│ ├── profiler/ # Wallet analysis
│ │ ├── analyzer.py # Core wallet profiling logic
│ │ ├── chain.py # Polygon blockchain client
│ │ └── funding.py # Funding chain tracer
│ ├── detector/ # Anomaly detection engines
│ │ ├── fresh_wallet.py
│ │ ├── size_anomaly.py
│ │ ├── sniper.py # DBSCAN clustering
│ │ └── scorer.py # Composite risk scoring
│ ├── alerter/ # Notification dispatch
│ │ ├── formatter.py # Alert message formatting
│ │ └── dispatcher.py # Multi-channel delivery
│ └── storage/ # Persistence layer
│ ├── models.py # SQLAlchemy models
│ └── repos.py # Repository pattern
├── tests/ # Test suite
├── scripts/
│ └── backtest.py # Historical analysis
├── docker-compose.yml
├── pyproject.toml
└── README.md
```
**No trades received / silent connection**
The WebSocket subscription requires `action: "subscribe"` in the envelope. If you're on an older version, update — this was fixed in the WebSocket protocol alignment (see #89).
---
**Connection timeout / DNS errors**
Verify `wss://ws-live-data.polymarket.com` is reachable from your network. Some corporate firewalls block WebSocket connections.
## Roadmap
**Database migration errors**
Ensure PostgreSQL is running (`docker compose ps`) and `DATABASE_URL` matches your docker-compose config. Run `uv run alembic upgrade head` after any schema changes.
### Phase 1: Core Detection (Current)
- [x] Project structure and documentation
- [ ] Polymarket CLOB API integration
- [ ] Fresh wallet detection
- [ ] Size anomaly detection
- [ ] Basic alerting (Discord/Telegram)
### Phase 2: Advanced Intelligence
- [ ] Funding chain analysis
- [ ] Sniper cluster detection (DBSCAN)
- [ ] Market categorization (niche vs mainstream)
- [ ] Historical backtesting framework
### Phase 3: Production Hardening
- [ ] High-availability deployment
- [ ] Rate limit management
- [ ] False positive feedback loop
- [ ] Web dashboard
---
## Why This Matters
Prediction markets are becoming a critical source of real-time probability estimates for world events. As they grow, so does the incentive for informed actors to exploit information asymmetry.
This tool democratizes access to the same detection capabilities that sophisticated traders use. Whether you are:
- **A trader** looking for alpha signals
- **A researcher** studying market microstructure
- **A platform operator** monitoring for manipulation
...this tracker provides visibility into the hidden flows that move markets.
---
## Technical Background
### Polymarket Architecture
Polymarket is a prediction market platform built on Polygon (Ethereum L2). Key characteristics:
- **CLOB (Central Limit Order Book)**: Centralized matching engine for speed
- **On-chain Settlement**: Final trades settle on Polygon blockchain
- **USDC Collateral**: All positions denominated in USDC stablecoin
- **Binary Outcomes**: Shares priced between $0.00 and $1.00
### Data Sources
| Source | Purpose | Latency |
|--------|---------|---------|
| Polymarket CLOB API | Real-time trades, orderbook | Milliseconds |
| Polygon RPC | Wallet history, nonce, funding | 1-2 seconds |
| Market Metadata API | Market categorization | On-demand |
### Detection Challenges
1. **Sybil Resistance**: Insiders use fresh wallets per trade
2. **Rate Limits**: Polygon RPC calls require caching strategy
3. **Market Classification**: NLP needed to categorize market niches
4. **Timing**: CLOB data leads on-chain by seconds
---
## Contributing
Contributions are welcome! Please read our Contributing Guide before submitting PRs.
### Development Setup
```bash
# Install dev dependencies
pip install -e ".[dev]"
# Run tests
pytest
# Run linting
ruff check src/
# Run type checking
mypy src/
```
**Rate limiting on Polygon RPC**
The default public RPC (`https://polygon-rpc.com`) has low rate limits. For production use, set `POLYGON_RPC_URL` to a dedicated provider (Alchemy, QuickNode, etc.).
---
@@ -312,20 +190,6 @@ This software is provided for **educational and research purposes only**.
- Insider trading is illegal in regulated markets; this tool is for transparency and research
- Users are responsible for compliance with applicable laws
---
## License
MIT License - see [LICENSE](LICENSE) for details.
---
## Acknowledgments
- Inspired by [@DidiTrading](https://x.com/DidiTrading) and [@spacexbt](https://x.com/spacexbt)
- Built on the open Polymarket API ecosystem
- Community contributions welcome
---
**Questions?** Open an issue or start a discussion.
+1 -1
View File
@@ -19,7 +19,7 @@ if config.config_file_name is not None:
target_metadata = Base.metadata
# Get database URL from environment variable or config
database_url = os.environ.get("SQLALCHEMY_DATABASE_URL")
database_url = os.environ.get("DATABASE_URL")
if database_url:
config.set_main_option("sqlalchemy.url", database_url)
@@ -5,16 +5,16 @@ Revises:
Create Date: 2026-01-04 00:00:00.000000+00:00
"""
from typing import Sequence, Union
from collections.abc import Sequence
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
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
-2
View File
@@ -1,5 +1,3 @@
version: "3.8"
services:
postgres:
image: postgres:15
@@ -0,0 +1,113 @@
# Skill: tracking-prediction-market-flow
Use when analyzing prediction market activity for informed-flow signals, insider
trading patterns, or suspicious wallet behavior on Polymarket.
## What This Tool Does
polymarket-insider-tracker streams real-time trades from Polymarket's WebSocket
feed, profiles trader wallets on the Polygon blockchain, and scores each trade
for informed-flow risk using multiple detection signals:
- **Fresh wallet detection**: New wallets (age < 48h, nonce <= 5) making large
trades (> $1k). Insiders create disposable wallets per trade.
- **Size anomaly detection**: Trades consuming > 2% of 24h volume or > 5% of
visible order book depth. Informed traders bet bigger when they have edge.
- **Niche market scoring**: Low-volume markets (< $50k daily) get a 1.5x risk
multiplier. Easier to have inside information on obscure events.
- **Funding chain analysis**: Traces wallet funding sources on-chain to link
seemingly separate wallets to the same entity or exchange.
- **Sniper cluster detection**: DBSCAN clustering identifies wallets that
consistently enter markets within minutes of creation.
Composite risk scoring combines signals with configurable weights (default
alert threshold: 0.6). Multi-signal bonuses: 2 signals +20%, 3+ signals +30%.
## Installation
```bash
git clone https://github.com/pselamy/polymarket-insider-tracker.git
cd polymarket-insider-tracker
uv sync --all-extras
docker compose up -d # PostgreSQL + Redis
cp .env.example .env # defaults work for local dev
uv run alembic upgrade head
```
No API keys required for basic operation (Polymarket APIs are public).
## Usage
```bash
# Start the tracker (streams trades, profiles wallets, scores risk, alerts)
uv run python -m polymarket_insider_tracker
# Dry run (no alerts sent)
uv run python -m polymarket_insider_tracker --dry-run
# Debug mode (see every trade)
uv run python -m polymarket_insider_tracker --log-level DEBUG
# Validate config without starting
uv run python -m polymarket_insider_tracker --config-check
```
## Interpreting Signals
### Risk Assessment Output
Each flagged trade produces a risk assessment with:
- **Confidence score** (0.0-1.0): Composite of weighted signals
- **Signal breakdown**: Which detectors fired and their individual confidence
- **Wallet profile**: Age, nonce, transaction count, funding source
- **Market context**: Volume, category, order book depth
### Signal Interpretation Guide
| Score Range | Interpretation | Action |
|-------------|---------------|--------|
| 0.6-0.7 | Moderate: single strong signal or two weak ones | Monitor, note the market |
| 0.7-0.85 | High: multiple signals converging | Investigate the market and wallet |
| 0.85-1.0 | Critical: fresh wallet + large size + niche market | High-confidence informed flow |
### What This Is NOT
- Not a trading signal generator. Informed flow != actionable alpha without
further analysis (hypothesis -> leakage-aware backtest -> capital).
- Not real-time enough for front-running. The tool detects patterns for
research and monitoring, not millisecond-level execution.
- Detection of informed flow does not prove insider trading. Many legitimate
reasons exist for the patterns this tool flags.
## Rate Limits and Etiquette
- **Polymarket WebSocket**: No explicit rate limit; one persistent connection.
Do not open multiple connections unnecessarily.
- **Polymarket CLOB REST**: Built-in rate limiter at 10 req/s with retry
backoff on 429/5xx. Respect this for metadata/orderbook queries.
- **Polygon RPC**: Public endpoints (polygon-rpc.com) have low limits. For
sustained use, configure a dedicated RPC provider via `POLYGON_RPC_URL`.
Built-in token-bucket rate limiter at 25 req/s with Redis caching (5min TTL).
## Known Pitfalls
1. **WebSocket subscription format**: Must include `action: "subscribe"` in the
envelope. Without it, the server accepts the connection but delivers zero
trade events (silent failure). Fixed in the current version.
2. **Message routing**: Live-data WebSocket pushes `{connection_id, payload:
{...trade fields}}`, not `{topic, type, payload}`. Route by checking for
`transactionHash` + `proxyWallet` keys in `payload`.
3. **Public RPC rate limits**: Default Polygon RPC will throttle under load.
Use a dedicated provider for production.
4. **Database required**: PostgreSQL + Redis must be running. Use
`docker compose up -d` for local dev.
## Cross-References
- **Repository**: https://github.com/pselamy/polymarket-insider-tracker
- **Issues**: https://github.com/pselamy/polymarket-insider-tracker/issues
- **Agent skill landing** (follow-on): selamy-labs/agent-skills
+25 -8
View File
@@ -18,7 +18,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseSettings(BaseSettings):
"""Database connection settings."""
model_config = SettingsConfigDict(env_prefix="")
model_config = SettingsConfigDict(
env_prefix="", env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
url: str = Field(
alias="DATABASE_URL",
@@ -37,7 +39,9 @@ class DatabaseSettings(BaseSettings):
class RedisSettings(BaseSettings):
"""Redis connection settings."""
model_config = SettingsConfigDict(env_prefix="")
model_config = SettingsConfigDict(
env_prefix="", env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
url: str = Field(
default="redis://localhost:6379",
@@ -57,7 +61,9 @@ class RedisSettings(BaseSettings):
class PolygonSettings(BaseSettings):
"""Polygon blockchain RPC settings."""
model_config = SettingsConfigDict(env_prefix="POLYGON_")
model_config = SettingsConfigDict(
env_prefix="POLYGON_", env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
rpc_url: str = Field(
default="https://polygon-rpc.com",
@@ -84,7 +90,9 @@ class PolygonSettings(BaseSettings):
class PolymarketSettings(BaseSettings):
"""Polymarket API settings."""
model_config = SettingsConfigDict(env_prefix="POLYMARKET_")
model_config = SettingsConfigDict(
env_prefix="POLYMARKET_", env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
ws_url: str = Field(
default="wss://ws-subscriptions-clob.polymarket.com/ws/market",
@@ -109,7 +117,9 @@ class PolymarketSettings(BaseSettings):
class DiscordSettings(BaseSettings):
"""Discord notification settings."""
model_config = SettingsConfigDict(env_prefix="DISCORD_")
model_config = SettingsConfigDict(
env_prefix="DISCORD_", env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
webhook_url: SecretStr | None = Field(
default=None,
@@ -120,13 +130,15 @@ class DiscordSettings(BaseSettings):
@property
def enabled(self) -> bool:
"""Check if Discord notifications are enabled."""
return self.webhook_url is not None
return self.webhook_url is not None and bool(self.webhook_url.get_secret_value().strip())
class TelegramSettings(BaseSettings):
"""Telegram notification settings."""
model_config = SettingsConfigDict(env_prefix="TELEGRAM_")
model_config = SettingsConfigDict(
env_prefix="TELEGRAM_", env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
bot_token: SecretStr | None = Field(
default=None,
@@ -142,7 +154,12 @@ class TelegramSettings(BaseSettings):
@property
def enabled(self) -> bool:
"""Check if Telegram notifications are enabled."""
return self.bot_token is not None and self.chat_id is not None
return (
self.bot_token is not None
and bool(self.bot_token.get_secret_value().strip())
and self.chat_id is not None
and bool(self.chat_id.strip())
)
class Settings(BaseSettings):
@@ -11,7 +11,7 @@ import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import Enum
from enum import StrEnum
from redis.asyncio import Redis
@@ -27,7 +27,7 @@ DEFAULT_CACHE_TTL_SECONDS = 600 # 10 minutes
DEFAULT_REDIS_KEY_PREFIX = "polymarket:market:"
class SyncState(str, Enum):
class SyncState(StrEnum):
"""State of the metadata synchronizer."""
STOPPED = "stopped"
@@ -86,7 +86,7 @@ class Orderbook:
bids: tuple[OrderbookLevel, ...]
asks: tuple[OrderbookLevel, ...]
tick_size: Decimal
timestamp: datetime = field(default_factory=datetime.utcnow)
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
@classmethod
def from_clob_orderbook(cls, orderbook: Any) -> "Orderbook":
@@ -9,8 +9,9 @@ from dataclasses import dataclass
from enum import Enum
from typing import Any
import websockets
from websockets.asyncio.client import ClientConnection
from websockets.asyncio.client import connect as ws_connect
from websockets.exceptions import ConnectionClosed
from polymarket_insider_tracker.ingestor.models import TradeEvent
@@ -149,14 +150,14 @@ class TradeStreamHandler:
elif self._market_filter:
subscription["filters"] = json.dumps({"market_slug": self._market_filter})
return {"subscriptions": [subscription]}
return {"action": "subscribe", "subscriptions": [subscription]}
async def _connect(self) -> ClientConnection:
"""Establish WebSocket connection."""
await self._set_state(ConnectionState.CONNECTING)
try:
ws = await websockets.connect(
ws = await ws_connect(
self._host,
ping_interval=self._ping_interval,
ping_timeout=self._ping_interval * 2,
@@ -182,12 +183,13 @@ class TradeStreamHandler:
try:
data = json.loads(message)
# Check if this is a trade message
topic = data.get("topic")
msg_type = data.get("type")
if topic == "activity" and msg_type == "trades":
payload = data.get("payload", {})
# ws-live-data pushes {connection_id, payload:{...trade fields}}
payload = data.get("payload")
if (
isinstance(payload, dict)
and "transactionHash" in payload
and "proxyWallet" in payload
):
trade = TradeEvent.from_websocket_message(payload)
self._stats.trades_received += 1
@@ -207,8 +209,7 @@ class TradeStreamHandler:
logger.error("Error in trade callback: %s", e)
else:
# Log other message types for debugging
logger.debug("Received message: topic=%s type=%s", topic, msg_type)
logger.debug("Received non-trade message: %s", str(data)[:120])
except json.JSONDecodeError as e:
logger.warning("Invalid JSON message: %s", e)
@@ -227,7 +228,7 @@ class TradeStreamHandler:
else:
logger.debug("Received binary message (%d bytes)", len(message))
except websockets.ConnectionClosed as e:
except ConnectionClosed as e:
logger.warning("Connection closed: %s", e)
raise
except Exception as e:
@@ -284,7 +285,7 @@ class TradeStreamHandler:
while self._running:
try:
await self._listen(self._ws)
except (websockets.ConnectionClosed, Exception) as e:
except (ConnectionClosed, Exception) as e:
if not self._running:
break
+75 -2
View File
@@ -11,7 +11,7 @@ import contextlib
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import Enum
from enum import StrEnum
from typing import TYPE_CHECKING
from redis.asyncio import Redis
@@ -29,7 +29,14 @@ from polymarket_insider_tracker.ingestor.metadata_sync import MarketMetadataSync
from polymarket_insider_tracker.ingestor.websocket import TradeStreamHandler
from polymarket_insider_tracker.profiler.analyzer import WalletAnalyzer
from polymarket_insider_tracker.profiler.chain import PolygonClient
from polymarket_insider_tracker.profiler.funding import FundingTracer
from polymarket_insider_tracker.storage.database import DatabaseManager
from polymarket_insider_tracker.storage.repos import (
FundingRepository,
FundingTransferDTO,
WalletProfileDTO,
WalletRepository,
)
if TYPE_CHECKING:
from typing import Any
@@ -43,7 +50,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
class PipelineState(str, Enum):
class PipelineState(StrEnum):
"""Pipeline lifecycle states."""
STOPPED = "stopped"
@@ -120,6 +127,7 @@ class Pipeline:
self._alert_formatter: AlertFormatter | None = None
self._alert_dispatcher: AlertDispatcher | None = None
self._trade_stream: TradeStreamHandler | None = None
self._funding_tracer: FundingTracer | None = None
# Synchronization
self._stop_event: asyncio.Event | None = None
@@ -233,6 +241,10 @@ class Pipeline:
redis=self._redis,
)
# Initialize Funding Tracer
logger.debug("Initializing funding tracer...")
self._funding_tracer = FundingTracer(self._polygon_client)
# Initialize Detectors
logger.debug("Initializing detectors...")
self._fresh_wallet_detector = FreshWalletDetector(self._wallet_analyzer)
@@ -365,6 +377,10 @@ class Pipeline:
self._detect_size_anomaly(trade),
)
# Persist wallet profile and funding data when a fresh wallet is detected
if fresh_signal is not None:
await self._persist_wallet_and_funding(fresh_signal)
# Bundle signals
bundle = SignalBundle(
trade_event=trade,
@@ -382,6 +398,63 @@ class Pipeline:
self._stats.errors += 1
self._stats.last_error = str(e)
async def _persist_wallet_and_funding(self, signal: FreshWalletSignal) -> None:
"""Persist wallet profile and funding transfers to Postgres.
Called when a fresh wallet signal is detected. Upserts the wallet
profile and traces/inserts any funding transfers found on-chain.
Args:
signal: The fresh wallet signal containing the wallet profile.
"""
if not self._db_manager:
return
profile = signal.wallet_profile
address = profile.address
try:
async with self._db_manager.get_async_session() as session:
# Persist wallet profile
wallet_repo = WalletRepository(session)
dto = WalletProfileDTO(
address=address,
nonce=profile.nonce,
first_seen_at=profile.first_seen,
is_fresh=profile.is_fresh,
matic_balance=profile.matic_balance,
usdc_balance=profile.usdc_balance,
analyzed_at=profile.analyzed_at,
)
await wallet_repo.upsert(dto)
# Trace and persist funding transfers
if self._funding_tracer:
chain = await self._funding_tracer.trace(address)
if chain.chain:
funding_repo = FundingRepository(session)
funding_dtos = [
FundingTransferDTO(
from_address=t.from_address,
to_address=t.to_address,
amount=t.amount,
token=t.token,
tx_hash=t.tx_hash,
block_number=t.block_number,
timestamp=t.timestamp,
)
for t in chain.chain
]
await funding_repo.insert_many(funding_dtos)
logger.debug(
"Persisted wallet profile and %d funding transfers for %s",
len(chain.chain) if self._funding_tracer and chain.chain else 0,
address[:10] + "...",
)
except Exception as e:
logger.warning("Failed to persist wallet/funding data for %s: %s", address, e)
async def _detect_fresh_wallet(self, trade: TradeEvent) -> FreshWalletSignal | None:
"""Run fresh wallet detection."""
if not self._fresh_wallet_detector:
@@ -26,8 +26,46 @@ logger = logging.getLogger(__name__)
USDC_BRIDGED = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
USDC_NATIVE = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359"
# ERC20 Transfer event signature
# ERC20 Transfer event signature. ``HexBytes.hex()`` returns a *bare* hex
# string without the ``0x`` prefix; publicnode tolerates that, but stricter
# providers (e.g. drpc — which we use as the fallback) reject it with
# ``invalid argument 0: hex string without 0x prefix``. Always pass the
# 0x-prefixed form to ``eth_getLogs``.
TRANSFER_EVENT_SIGNATURE = AsyncWeb3.keccak(text="Transfer(address,address,uint256)")
TRANSFER_EVENT_TOPIC = "0x" + TRANSFER_EVENT_SIGNATURE.hex().removeprefix("0x")
# eth_getLogs block-range chunking. Most public Polygon RPCs (publicnode, ankr,
# llamarpc) cap the range at 10_000 blocks per call; pick a window slightly
# under the cap so off-by-one differences between providers don't trip us up.
DEFAULT_CHUNK_SIZE_BLOCKS = 9_000
# Polygon block time is ~2.0s. publicnode (the most common free RPC) prunes
# log history aggressively — empirically only ~100k blocks (~55 hours) are
# served before requests start returning "History has been pruned". We default
# to 80k blocks (~44 hours), which is more than enough for fresh-wallet
# funding traces (those wallets are by definition new) and fits comfortably
# inside what most public providers retain.
DEFAULT_MAX_LOOKBACK_BLOCKS = 80_000
# Substrings that, when present in an RPC error, indicate the chunk we just
# asked for is outside the provider's archive horizon. Walking further back
# is futile, so we stop the trace early instead of hammering every chunk.
_PRUNED_HISTORY_MARKERS: tuple[str, ...] = (
"history has been pruned",
"missing trie node",
"older than",
)
def _is_pruned_history_error(err: BaseException) -> bool:
"""Return True if the RPC error indicates pruned history.
Public Polygon nodes only retain a recent slice of log history. When we
walk back through that slice in chunks and hit the cutoff, every further
chunk will fail with the same message — so we stop early instead of
burning quota on guaranteed failures.
"""
text = str(err).lower()
return any(marker in text for marker in _PRUNED_HISTORY_MARKERS)
class FundingTracer:
@@ -50,6 +88,8 @@ class FundingTracer:
*,
max_hops: int = 3,
usdc_addresses: list[str] | None = None,
chunk_size_blocks: int = DEFAULT_CHUNK_SIZE_BLOCKS,
max_lookback_blocks: int = DEFAULT_MAX_LOOKBACK_BLOCKS,
) -> None:
"""Initialize the funding tracer.
@@ -58,6 +98,11 @@ class FundingTracer:
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.
chunk_size_blocks: Block window size per eth_getLogs call. Public
Polygon RPCs cap at 10_000 blocks; default leaves a safety margin.
max_lookback_blocks: How far back to scan when caller passes
``from_block=0``. Default ~44 hours at 2s block time, which
fits inside the pruned-history horizon of most public RPCs.
"""
self.polygon_client = polygon_client
self.entity_registry = entity_registry or EntityRegistry()
@@ -65,6 +110,8 @@ class FundingTracer:
self._usdc_addresses = [
addr.lower() for addr in (usdc_addresses or [USDC_BRIDGED, USDC_NATIVE])
]
self._chunk_size_blocks = chunk_size_blocks
self._max_lookback_blocks = max_lookback_blocks
async def trace(
self,
@@ -209,46 +256,144 @@ class FundingTracer:
) -> list[dict[str, Any]]:
"""Get ERC20 Transfer event logs.
Public Polygon RPCs (publicnode, ankr, llamarpc) cap ``eth_getLogs`` at
10_000 blocks per call. To work around this we resolve the requested
range into a concrete block window (defaulting to the last
``max_lookback_blocks`` when caller passes ``from_block=0``) and walk
the window in chunks of ``chunk_size_blocks``, oldest-first, stopping
once ``limit`` matches are collected. Walking oldest-first preserves
the "first transfer" semantics expected by the funding chain tracer.
If a chunk comes back with a "history has been pruned" style error
the rest of the walk is short-circuited — every subsequent chunk
would hit the same archive cutoff and there's no point burning quota
on guaranteed failures.
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.
from_block: Starting block number (0 means
``latest - max_lookback_blocks``).
to_block: Ending block number ("latest" resolves to current head).
Returns:
List of log dictionaries.
List of log dictionaries, oldest first, capped at ``limit``.
"""
# Pad address to 32 bytes for topic filter
padded_to = "0x" + to_address.lower().replace("0x", "").zfill(64)
topics = [
TRANSFER_EVENT_TOPIC, # Transfer event (must be 0x-prefixed for drpc)
None, # from (any)
padded_to, # to (target address)
]
contract_address = AsyncWeb3.to_checksum_address(token_address)
start_block, end_block = await self._resolve_block_range(from_block, to_block)
if start_block > end_block:
return []
results: list[dict[str, Any]] = []
chunk_size = max(1, self._chunk_size_blocks)
chunk_start = start_block
while chunk_start <= end_block:
chunk_end = min(chunk_start + chunk_size - 1, end_block)
try:
chunk_logs = await self._fetch_logs_chunk(
contract_address=contract_address,
topics=topics,
from_block=chunk_start,
to_block=chunk_end,
)
except Exception as e:
if _is_pruned_history_error(e):
# The provider has dropped this slice of history. Walking
# further back will hit the same wall on every chunk;
# stop now and return what we already have.
logger.info(
"eth_getLogs chunk %d-%d outside archive horizon for %s; stopping trace",
chunk_start,
chunk_end,
to_address,
)
break
logger.warning(
"eth_getLogs chunk %d-%d failed for %s: %s",
chunk_start,
chunk_end,
to_address,
e,
)
# Skip this window and keep walking — partial data is better
# than aborting the whole trace on a single flaky chunk.
chunk_start = chunk_end + 1
continue
for log in chunk_logs:
results.append(dict(log))
if len(results) >= limit:
return results
chunk_start = chunk_end + 1
return results
async def _resolve_block_range(
self,
from_block: int | str,
to_block: int | str,
) -> tuple[int, int]:
"""Resolve symbolic block params to concrete numeric bounds.
``from_block=0`` (the historical default) is rewritten to
``latest - max_lookback_blocks`` so we don't try to scan all of Polygon.
"""
w3 = self._select_w3()
if isinstance(to_block, str):
await self.polygon_client._rate_limiter.acquire()
head = int(await w3.eth.block_number)
end = head
else:
end = int(to_block)
if isinstance(from_block, str):
# Treat any symbolic from-block (e.g. "earliest") as "go back
# max_lookback_blocks from end"; that's what callers actually want.
start = max(0, end - self._max_lookback_blocks)
elif from_block == 0:
start = max(0, end - self._max_lookback_blocks)
else:
start = int(from_block)
return start, end
async def _fetch_logs_chunk(
self,
contract_address: str,
topics: list[Any],
from_block: int,
to_block: int,
) -> list[Any]:
"""Issue a single bounded ``eth_getLogs`` call."""
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
w3 = self._select_w3()
# Note: web3 typing is overly restrictive for block params
logs = await w3.eth.get_logs(
return 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)
],
"address": contract_address,
"topics": topics,
"fromBlock": from_block, # type: ignore[typeddict-item]
"toBlock": to_block, # type: ignore[typeddict-item]
}
)
# Convert to list of dicts and limit
result = [dict(log) for log in logs[:limit]]
return result
def _select_w3(self) -> AsyncWeb3:
"""Pick primary or fallback web3 instance based on health."""
if self.polygon_client._primary_healthy:
return self.polygon_client._w3
return self.polygon_client._w3_fallback or self.polygon_client._w3
async def _log_to_funding_transfer(
self,
+49 -14
View File
@@ -84,7 +84,10 @@ class TestTradeStreamHandler:
"""Test building subscription message without filters."""
msg = handler._build_subscription_message()
assert msg == {"subscriptions": [{"topic": "activity", "type": "trades"}]}
assert msg == {
"action": "subscribe",
"subscriptions": [{"topic": "activity", "type": "trades"}],
}
def test_build_subscription_message_with_event_filter(self, on_trade_mock: AsyncMock) -> None:
"""Test building subscription message with event filter."""
@@ -110,11 +113,10 @@ class TestTradeStreamHandler:
async def test_handle_message_trade(
self, handler: TradeStreamHandler, on_trade_mock: AsyncMock
) -> None:
"""Test handling a valid trade message."""
"""Test handling a valid trade message (payload-based routing)."""
message = json.dumps(
{
"topic": "activity",
"type": "trades",
"connection_id": "abc123",
"payload": {
"conditionId": "0xmarket",
"transactionHash": "0xtx",
@@ -142,11 +144,10 @@ class TestTradeStreamHandler:
async def test_handle_message_non_trade(
self, handler: TradeStreamHandler, on_trade_mock: AsyncMock
) -> None:
"""Test handling a non-trade message."""
"""Test handling a non-trade message (no transactionHash/proxyWallet)."""
message = json.dumps(
{
"topic": "comments",
"type": "comment_created",
"connection_id": "abc123",
"payload": {"body": "Hello"},
}
)
@@ -156,6 +157,35 @@ class TestTradeStreamHandler:
on_trade_mock.assert_not_called()
assert handler.stats.trades_received == 0
@pytest.mark.asyncio
async def test_handle_message_payload_missing_proxy_wallet(
self, handler: TradeStreamHandler, on_trade_mock: AsyncMock
) -> None:
"""Ratchet: payload with transactionHash but no proxyWallet is not a trade."""
message = json.dumps(
{
"connection_id": "abc",
"payload": {"transactionHash": "0xtx", "other": "field"},
}
)
await handler._handle_message(message)
on_trade_mock.assert_not_called()
assert handler.stats.trades_received == 0
@pytest.mark.asyncio
async def test_handle_message_no_payload_key(
self, handler: TradeStreamHandler, on_trade_mock: AsyncMock
) -> None:
"""Ratchet: message without payload key is ignored."""
message = json.dumps({"connection_id": "abc", "status": "ok"})
await handler._handle_message(message)
on_trade_mock.assert_not_called()
assert handler.stats.trades_received == 0
@pytest.mark.asyncio
async def test_handle_message_invalid_json(
self, handler: TradeStreamHandler, on_trade_mock: AsyncMock
@@ -175,8 +205,7 @@ class TestTradeStreamHandler:
message = json.dumps(
{
"topic": "activity",
"type": "trades",
"connection_id": "abc",
"payload": {
"conditionId": "0x",
"transactionHash": "0x",
@@ -231,14 +260,18 @@ class TestTradeStreamHandler:
mock_ws = AsyncMock()
mock_ws.send = AsyncMock()
with patch("websockets.connect", AsyncMock(return_value=mock_ws)):
with patch(
"polymarket_insider_tracker.ingestor.websocket.ws_connect",
AsyncMock(return_value=mock_ws),
):
ws = await handler._connect()
assert ws is mock_ws
mock_ws.send.assert_called_once()
# Verify subscription message
# Verify subscription message includes action: subscribe
sent_msg = json.loads(mock_ws.send.call_args[0][0])
assert sent_msg["action"] == "subscribe"
assert "subscriptions" in sent_msg
assert sent_msg["subscriptions"][0]["topic"] == "activity"
assert sent_msg["subscriptions"][0]["type"] == "trades"
@@ -289,8 +322,7 @@ class TestTradeStreamHandlerIntegration:
trade_message = json.dumps(
{
"topic": "activity",
"type": "trades",
"connection_id": "test-conn",
"payload": {
"conditionId": "0xtest",
"transactionHash": "0xtx",
@@ -333,7 +365,10 @@ class TestTradeStreamHandlerIntegration:
mock_ws = MockWebSocket(handler, trade_message)
with patch("websockets.connect", AsyncMock(return_value=mock_ws)):
with patch(
"polymarket_insider_tracker.ingestor.websocket.ws_connect",
AsyncMock(return_value=mock_ws),
):
# Run with timeout to prevent hanging
try:
await asyncio.wait_for(handler.start(), timeout=1.0)
+244 -1
View File
@@ -327,6 +327,10 @@ class TestGetTransferLogs:
await funding_tracer._get_transfer_logs(
to_address=TEST_WALLET,
token_address=USDC_BRIDGED,
# Explicit numeric range so we stay inside one chunk and skip
# the "latest" → block_number resolution path.
from_block=1,
to_block=8_000,
)
mock_w3.eth.get_logs.assert_called_once()
@@ -334,10 +338,15 @@ class TestGetTransferLogs:
# Verify topics structure
assert len(call_args["topics"]) == 3
assert call_args["topics"][0] == TRANSFER_EVENT_SIGNATURE.hex()
# The Transfer event topic must be 0x-prefixed; drpc rejects bare hex.
assert call_args["topics"][0] == "0x" + TRANSFER_EVENT_SIGNATURE.hex().removeprefix("0x")
assert call_args["topics"][0].startswith("0x")
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", ""))
# And the chunk bounds match what we asked for.
assert call_args["fromBlock"] == 1
assert call_args["toBlock"] == 8_000
@pytest.mark.asyncio
async def test_get_transfer_logs_respects_limit(
@@ -355,6 +364,8 @@ class TestGetTransferLogs:
to_address=TEST_WALLET,
token_address=USDC_BRIDGED,
limit=3,
from_block=1,
to_block=8_000,
)
assert len(result) == 3
@@ -374,10 +385,242 @@ class TestGetTransferLogs:
await funding_tracer._get_transfer_logs(
to_address=TEST_WALLET,
token_address=USDC_BRIDGED,
from_block=1,
to_block=8_000,
)
mock_fallback.eth.get_logs.assert_called_once()
@pytest.mark.asyncio
async def test_get_transfer_logs_chunks_large_ranges(
self,
funding_tracer: FundingTracer,
mock_polygon_client: MagicMock,
) -> None:
"""Ranges wider than chunk_size are split into multiple eth_getLogs calls.
This is the regression guard for the publicnode 10_000-block cap that
was rejecting every funding trace before chunking landed.
"""
mock_w3 = MagicMock()
mock_w3.eth.get_logs = AsyncMock(return_value=[])
mock_polygon_client._w3 = mock_w3
# 25_000 blocks at 9_000-per-chunk → 3 calls (9000 + 9000 + 7001).
await funding_tracer._get_transfer_logs(
to_address=TEST_WALLET,
token_address=USDC_BRIDGED,
from_block=1_000_000,
to_block=1_025_000,
)
assert mock_w3.eth.get_logs.call_count == 3
windows = [call[0][0] for call in mock_w3.eth.get_logs.call_args_list]
assert windows[0]["fromBlock"] == 1_000_000
assert windows[0]["toBlock"] == 1_008_999
assert windows[1]["fromBlock"] == 1_009_000
assert windows[1]["toBlock"] == 1_017_999
assert windows[2]["fromBlock"] == 1_018_000
assert windows[2]["toBlock"] == 1_025_000
# No window exceeds the chunk size — that's what RPC providers reject.
for win in windows:
assert win["toBlock"] - win["fromBlock"] + 1 <= 9_000
@pytest.mark.asyncio
async def test_get_transfer_logs_stops_when_limit_hit_mid_walk(
self,
funding_tracer: FundingTracer,
mock_polygon_client: MagicMock,
) -> None:
"""Walking should stop as soon as ``limit`` matches are gathered."""
mock_w3 = MagicMock()
# First chunk yields 5 logs, more than the limit, so subsequent chunks
# must not be queried.
mock_w3.eth.get_logs = AsyncMock(return_value=[MagicMock() for _ in range(5)])
mock_polygon_client._w3 = mock_w3
result = await funding_tracer._get_transfer_logs(
to_address=TEST_WALLET,
token_address=USDC_BRIDGED,
limit=2,
from_block=1_000_000,
to_block=1_025_000,
)
assert len(result) == 2
mock_w3.eth.get_logs.assert_called_once()
@pytest.mark.asyncio
async def test_get_transfer_logs_skips_failing_chunk(
self,
funding_tracer: FundingTracer,
mock_polygon_client: MagicMock,
) -> None:
"""A flaky chunk must not abort the whole trace — we move on."""
mock_w3 = MagicMock()
good_log = MagicMock()
responses: list[Any] = [
RuntimeError("RPC hiccup"),
[good_log],
]
async def fake_get_logs(_params: dict[str, Any]) -> list[Any]:
outcome = responses.pop(0)
if isinstance(outcome, BaseException):
raise outcome
return outcome
mock_w3.eth.get_logs = AsyncMock(side_effect=fake_get_logs)
mock_polygon_client._w3 = mock_w3
result = await funding_tracer._get_transfer_logs(
to_address=TEST_WALLET,
token_address=USDC_BRIDGED,
from_block=1_000_000,
to_block=1_018_000, # forces 3 chunks; we exercise chunks 1+2
)
# The error chunk is skipped; the second chunk contributes one log.
assert result == [dict(good_log)]
assert mock_w3.eth.get_logs.call_count >= 2
@pytest.mark.asyncio
async def test_get_transfer_logs_resolves_latest_via_block_number(
self,
funding_tracer: FundingTracer,
mock_polygon_client: MagicMock,
) -> None:
"""``to_block='latest'`` should resolve via ``eth.block_number``.
And ``from_block=0`` should not become a full-history scan — it must
be clamped to ``latest - max_lookback_blocks``.
"""
async def _block_number_coro() -> int:
return 5_000
mock_eth = MagicMock()
mock_eth.get_logs = AsyncMock(return_value=[])
# Property-style awaitable: web3.py exposes block_number as a property
# returning a coroutine, so each access must yield a fresh awaitable.
type(mock_eth).block_number = property( # type: ignore[misc]
lambda _self: _block_number_coro()
)
mock_w3 = MagicMock()
mock_w3.eth = mock_eth
mock_polygon_client._w3 = mock_w3
await funding_tracer._get_transfer_logs(
to_address=TEST_WALLET,
token_address=USDC_BRIDGED,
)
# block_number=5000 < chunk_size, so it's one chunk that bottoms at 0.
mock_eth.get_logs.assert_called_once()
call_args = mock_eth.get_logs.call_args[0][0]
assert call_args["fromBlock"] == 0
assert call_args["toBlock"] == 5_000
@pytest.mark.asyncio
async def test_get_transfer_logs_breaks_on_pruned_history(
self,
funding_tracer: FundingTracer,
mock_polygon_client: MagicMock,
) -> None:
"""A pruned-history error must short-circuit the whole walk.
Public Polygon RPCs prune log history. Once we walk past the cutoff,
every subsequent chunk will raise the same error — keep walking and
we just burn quota on guaranteed failures. The first such error must
end the walk and return whatever we already collected.
"""
mock_w3 = MagicMock()
good_log = MagicMock()
responses: list[Any] = [
[good_log],
RuntimeError(
"{'code': -32701, 'message': 'History has been pruned for "
"this block. To remove restrictions, order a dedicated full "
"node here: https://www.allnodes.com/pol/host'}"
),
# If the early-break logic is missing, this third chunk would
# also be requested. The test asserts it isn't.
[MagicMock()],
]
async def fake_get_logs(_params: dict[str, Any]) -> list[Any]:
outcome = responses.pop(0)
if isinstance(outcome, BaseException):
raise outcome
return outcome
mock_w3.eth.get_logs = AsyncMock(side_effect=fake_get_logs)
mock_polygon_client._w3 = mock_w3
# 3 chunks total. The pruned error fires on chunk #2; chunk #3 must
# never be issued.
result = await funding_tracer._get_transfer_logs(
to_address=TEST_WALLET,
token_address=USDC_BRIDGED,
from_block=1_000_000,
to_block=1_027_000,
)
assert result == [dict(good_log)]
assert mock_w3.eth.get_logs.call_count == 2
@pytest.mark.asyncio
async def test_get_transfer_logs_default_lookback_fits_pruned_horizon(
self,
) -> None:
"""Default ``max_lookback_blocks`` must stay inside what public RPCs serve.
publicnode prunes after ~100k blocks. If we default to 1.3M, every
funding trace blows through the archive horizon and produces nothing
but pruned-history warnings. Pin the default at <= 100k as a
regression guard.
"""
from polymarket_insider_tracker.profiler.funding import (
DEFAULT_MAX_LOOKBACK_BLOCKS,
)
assert DEFAULT_MAX_LOOKBACK_BLOCKS <= 100_000
@pytest.mark.asyncio
async def test_get_transfer_logs_topic_is_0x_prefixed(
self,
funding_tracer: FundingTracer,
mock_polygon_client: MagicMock,
) -> None:
"""The Transfer event topic passed to ``eth_getLogs`` must begin with ``0x``.
``HexBytes.hex()`` returns a bare hex string. publicnode tolerates
that, but stricter providers like drpc (our fallback) reject it with
``invalid argument 0: hex string without 0x prefix`` and every chunk
in the trace fails. This guards against regressing back to the
bare-hex form.
"""
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,
from_block=1,
to_block=8_000,
)
topics = mock_w3.eth.get_logs.call_args[0][0]["topics"]
assert topics[0].startswith("0x")
# And the topic also has to be 32 bytes (64 hex chars) as required by
# the JSON-RPC spec.
assert len(topics[0]) == 2 + 64
# The padded `to` topic was already 0x-prefixed; double-check that
# didn't regress either.
assert topics[2].startswith("0x")
class TestLogToFundingTransfer:
"""Tests for _log_to_funding_transfer method."""
+334
View File
@@ -0,0 +1,334 @@
"""Tests verifying wallet and funding data persistence in the pipeline.
These tests confirm that running the live pipeline writes rows into
wallet_profiles and funding_transfers tables when fresh wallets are detected.
"""
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from polymarket_insider_tracker.config import Settings
from polymarket_insider_tracker.detector.models import FreshWalletSignal
from polymarket_insider_tracker.ingestor.models import TradeEvent
from polymarket_insider_tracker.pipeline import Pipeline
from polymarket_insider_tracker.profiler.models import FundingChain, FundingTransfer, WalletProfile
from polymarket_insider_tracker.storage.database import DatabaseManager
from polymarket_insider_tracker.storage.models import Base, FundingTransferModel, WalletProfileModel
@pytest.fixture
def mock_settings():
"""Create mock settings for testing."""
redis = MagicMock()
redis.url = "redis://localhost:6379"
database = MagicMock()
database.url = "sqlite+aiosqlite:///:memory:"
polygon = MagicMock()
polygon.rpc_url = "https://polygon-rpc.com"
polygon.fallback_rpc_url = None
polymarket = MagicMock()
polymarket.ws_url = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
polymarket.api_key = None
discord = MagicMock()
discord.enabled = False
discord.webhook_url = None
telegram = MagicMock()
telegram.enabled = False
telegram.bot_token = None
telegram.chat_id = None
settings = MagicMock(spec=Settings)
settings.redis = redis
settings.database = database
settings.polygon = polygon
settings.polymarket = polymarket
settings.discord = discord
settings.telegram = telegram
settings.dry_run = True
return settings
@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 db_manager(async_engine):
"""Create a DatabaseManager backed by the in-memory SQLite engine."""
manager = DatabaseManager.__new__(DatabaseManager)
manager.database_url = "sqlite+aiosqlite:///:memory:"
manager.async_mode = True
manager._pool_size = 5
manager._max_overflow = 10
manager._echo = False
manager._sync_engine = None
manager._async_engine = async_engine
manager._sync_session_factory = None
manager._async_session_factory = async_sessionmaker(bind=async_engine, expire_on_commit=False)
return manager
@pytest.fixture
def sample_trade():
"""Create a sample trade event."""
return TradeEvent(
trade_id="0x" + "a" * 64,
wallet_address="0x" + "b" * 40,
market_id="0x" + "c" * 64,
asset_id="asset_123",
side="BUY",
price=Decimal("0.65"),
size=Decimal("5000"),
timestamp=datetime.now(UTC),
outcome="Yes",
outcome_index=0,
event_title="Test Market",
market_slug="test-market",
)
@pytest.fixture
def sample_profile():
"""Create a sample fresh wallet profile."""
return WalletProfile(
address="0x" + "b" * 40,
nonce=2,
first_seen=datetime(2026, 3, 31, 12, 0, 0, tzinfo=UTC),
age_hours=1.5,
is_fresh=True,
total_tx_count=2,
matic_balance=Decimal("1000000000000000000"),
usdc_balance=Decimal("5000000000"),
fresh_threshold=5,
)
@pytest.fixture
def sample_funding_chain():
"""Create a sample funding chain with one transfer."""
return FundingChain(
target_address="0x" + "b" * 40,
chain=[
FundingTransfer(
from_address="0x" + "d" * 40,
to_address="0x" + "b" * 40,
amount=Decimal("5000000000"),
token="USDC",
tx_hash="0x" + "e" * 64,
block_number=12345678,
timestamp=datetime(2026, 3, 31, 11, 0, 0, tzinfo=UTC),
),
],
origin_address="0x" + "d" * 40,
origin_type="cex_binance",
hop_count=1,
)
class TestPipelinePersistence:
"""Tests that the pipeline persists wallet and funding data to Postgres."""
@pytest.mark.asyncio
async def test_on_trade_persists_wallet_profile(
self, mock_settings, db_manager, sample_trade, sample_profile, async_engine
):
"""When a fresh wallet signal fires, the wallet profile is written to wallet_profiles."""
pipeline = Pipeline(mock_settings)
pipeline._db_manager = db_manager
fresh_signal = FreshWalletSignal(
trade_event=sample_trade,
wallet_profile=sample_profile,
confidence=0.8,
factors={"base": 0.5, "brand_new": 0.2},
)
pipeline._fresh_wallet_detector = MagicMock()
pipeline._fresh_wallet_detector.analyze = AsyncMock(return_value=fresh_signal)
pipeline._size_anomaly_detector = MagicMock()
pipeline._size_anomaly_detector.analyze = AsyncMock(return_value=None)
pipeline._funding_tracer = MagicMock()
pipeline._funding_tracer.trace = AsyncMock(
return_value=FundingChain(target_address=sample_profile.address)
)
pipeline._risk_scorer = MagicMock()
pipeline._risk_scorer.assess = AsyncMock(
return_value=MagicMock(should_alert=False, weighted_score=0.3)
)
pipeline._alert_formatter = MagicMock()
pipeline._alert_dispatcher = MagicMock()
await pipeline._on_trade(sample_trade)
# Verify wallet_profiles has a row
async with async_sessionmaker(bind=async_engine, expire_on_commit=False)() as session:
result = await session.execute(select(WalletProfileModel))
rows = result.scalars().all()
assert len(rows) == 1
assert rows[0].address == sample_profile.address.lower()
assert rows[0].nonce == sample_profile.nonce
assert rows[0].is_fresh is True
@pytest.mark.asyncio
async def test_on_trade_persists_funding_transfers(
self,
mock_settings,
db_manager,
sample_trade,
sample_profile,
sample_funding_chain,
async_engine,
):
"""When a fresh wallet signal fires, funding transfers are written to funding_transfers."""
pipeline = Pipeline(mock_settings)
pipeline._db_manager = db_manager
fresh_signal = FreshWalletSignal(
trade_event=sample_trade,
wallet_profile=sample_profile,
confidence=0.8,
factors={"base": 0.5, "brand_new": 0.2},
)
pipeline._fresh_wallet_detector = MagicMock()
pipeline._fresh_wallet_detector.analyze = AsyncMock(return_value=fresh_signal)
pipeline._size_anomaly_detector = MagicMock()
pipeline._size_anomaly_detector.analyze = AsyncMock(return_value=None)
pipeline._funding_tracer = MagicMock()
pipeline._funding_tracer.trace = AsyncMock(return_value=sample_funding_chain)
pipeline._risk_scorer = MagicMock()
pipeline._risk_scorer.assess = AsyncMock(
return_value=MagicMock(should_alert=False, weighted_score=0.3)
)
pipeline._alert_formatter = MagicMock()
pipeline._alert_dispatcher = MagicMock()
await pipeline._on_trade(sample_trade)
# Verify funding_transfers has a row
async with async_sessionmaker(bind=async_engine, expire_on_commit=False)() as session:
result = await session.execute(select(FundingTransferModel))
rows = result.scalars().all()
assert len(rows) == 1
assert rows[0].to_address == ("0x" + "b" * 40).lower()
assert rows[0].from_address == ("0x" + "d" * 40).lower()
assert rows[0].token == "USDC"
assert rows[0].tx_hash == ("0x" + "e" * 64).lower()
@pytest.mark.asyncio
async def test_no_persistence_without_fresh_signal(
self, mock_settings, db_manager, sample_trade, async_engine
):
"""No rows written when fresh wallet signal is None (wallet not fresh)."""
pipeline = Pipeline(mock_settings)
pipeline._db_manager = db_manager
pipeline._fresh_wallet_detector = MagicMock()
pipeline._fresh_wallet_detector.analyze = AsyncMock(return_value=None)
pipeline._size_anomaly_detector = MagicMock()
pipeline._size_anomaly_detector.analyze = AsyncMock(return_value=None)
await pipeline._on_trade(sample_trade)
async with async_sessionmaker(bind=async_engine, expire_on_commit=False)() as session:
wallets = (await session.execute(select(WalletProfileModel))).scalars().all()
transfers = (await session.execute(select(FundingTransferModel))).scalars().all()
assert len(wallets) == 0
assert len(transfers) == 0
@pytest.mark.asyncio
async def test_persistence_failure_does_not_break_pipeline(
self, mock_settings, sample_trade, sample_profile
):
"""Persistence errors are caught and don't crash trade processing."""
pipeline = Pipeline(mock_settings)
# Use a broken db_manager that raises on get_async_session
broken_db = MagicMock()
broken_db.get_async_session = MagicMock(side_effect=Exception("DB connection failed"))
pipeline._db_manager = broken_db
fresh_signal = FreshWalletSignal(
trade_event=sample_trade,
wallet_profile=sample_profile,
confidence=0.8,
factors={"base": 0.5},
)
pipeline._fresh_wallet_detector = MagicMock()
pipeline._fresh_wallet_detector.analyze = AsyncMock(return_value=fresh_signal)
pipeline._size_anomaly_detector = MagicMock()
pipeline._size_anomaly_detector.analyze = AsyncMock(return_value=None)
pipeline._funding_tracer = MagicMock()
pipeline._risk_scorer = MagicMock()
pipeline._risk_scorer.assess = AsyncMock(
return_value=MagicMock(should_alert=False, weighted_score=0.3)
)
pipeline._alert_formatter = MagicMock()
pipeline._alert_dispatcher = MagicMock()
# Should not raise
await pipeline._on_trade(sample_trade)
assert pipeline.stats.trades_processed == 1
@pytest.mark.asyncio
async def test_duplicate_funding_transfers_are_skipped(
self,
mock_settings,
db_manager,
sample_trade,
sample_profile,
sample_funding_chain,
async_engine,
):
"""Processing the same trade twice should not duplicate funding transfer rows."""
pipeline = Pipeline(mock_settings)
pipeline._db_manager = db_manager
fresh_signal = FreshWalletSignal(
trade_event=sample_trade,
wallet_profile=sample_profile,
confidence=0.8,
factors={"base": 0.5},
)
pipeline._fresh_wallet_detector = MagicMock()
pipeline._fresh_wallet_detector.analyze = AsyncMock(return_value=fresh_signal)
pipeline._size_anomaly_detector = MagicMock()
pipeline._size_anomaly_detector.analyze = AsyncMock(return_value=None)
pipeline._funding_tracer = MagicMock()
pipeline._funding_tracer.trace = AsyncMock(return_value=sample_funding_chain)
pipeline._risk_scorer = MagicMock()
pipeline._risk_scorer.assess = AsyncMock(
return_value=MagicMock(should_alert=False, weighted_score=0.3)
)
pipeline._alert_formatter = MagicMock()
pipeline._alert_dispatcher = MagicMock()
# Process same trade twice
await pipeline._on_trade(sample_trade)
await pipeline._on_trade(sample_trade)
# Should still have only 1 funding transfer (duplicate skipped)
async with async_sessionmaker(bind=async_engine, expire_on_commit=False)() as session:
result = await session.execute(select(FundingTransferModel))
rows = result.scalars().all()
assert len(rows) == 1