Update to tiered market coverage (700+ markets), refactor anomaly detection and trade monitoring
- Expand market coverage from 50 trending to 700+ active markets with three volume tiers - Refactor anomaly detector with improved scoring logic - Simplify trade monitor architecture - Add tiered market fetching in market_fetcher - Update prompts, settings, and etherscan service - Remove requirements.txt (using other dependency management) - Update README to reflect new capabilities
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
[](LICENSE)
|
||||
[](https://docs.polymarket.com/)
|
||||
|
||||
Real-time monitoring | Multi-dimensional anomaly detection | LLM investigation with 14 autonomous tools | Signal accuracy tracking
|
||||
Real-time monitoring | Tiered market coverage | LLM investigation with 14 autonomous tools | Signal accuracy tracking
|
||||
|
||||
[Quick Start](#-quick-start) | [How It Works](#-how-it-works) | [Features](#-features) | [Configuration](#-configuration) | [Dashboard](#-dashboard)
|
||||
|
||||
@@ -18,11 +18,11 @@ Real-time monitoring | Multi-dimensional anomaly detection | LLM investigation w
|
||||
|
||||
## What It Does
|
||||
|
||||
Whale Watcher continuously monitors 50+ trending Polymarket markets, detects large trades with anomalous patterns, and uses an LLM agent with 14 autonomous research tools to investigate whether the trader may possess an information advantage. It tracks signal accuracy over time, achieving **63.5% win rate** with **+28.3% average ROI** on high-confidence signals.
|
||||
Whale Watcher continuously monitors **700+ active Polymarket markets** across three volume tiers, detects large trades with anomalous patterns, and uses an LLM agent with **14 autonomous research tools** to investigate whether the trader may possess an information advantage. It tracks signal accuracy over time, generates daily briefings, and sends real-time email alerts for high-confidence signals.
|
||||
|
||||
```
|
||||
Trending Markets → Whale Detection → Anomaly Scoring → LLM Investigation → Signal Tracking
|
||||
(50+) ($1k-$100k+) (5 dimensions) (14 tools, 5 rounds) (win rate, ROI)
|
||||
Tiered Markets → Whale Detection → Anomaly Scoring → LLM Investigation → Signal Tracking
|
||||
(700+) ($5k-$100k+) (5 dimensions) (14 tools, 3 rounds) (win rate, ROI)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -35,35 +35,36 @@ Trending Markets → Whale Detection → Anomaly Scoring → LLM Investigation
|
||||
```
|
||||
$ python -m src.main run
|
||||
|
||||
╭──────────────────────────────────────────────────────────╮
|
||||
│ 🐋 Polymarket Whale Watcher │
|
||||
│ │
|
||||
│ Markets Monitored: 50 │
|
||||
│ Polling Interval: 15s │
|
||||
│ Min Trade Size: $1,000 │
|
||||
│ Price Range: 0 - 0.7 │
|
||||
╰──────────────────────────────────────────────────────────╯
|
||||
============================================================
|
||||
WHALE WATCHER STARTED
|
||||
============================================================
|
||||
Monitoring: 765 markets
|
||||
Interval: 10 seconds
|
||||
Min Trade Size: $10,000 USD
|
||||
Price Range: 0.1 - 0.9
|
||||
============================================================
|
||||
|
||||
[14:22:51] 🐋 WHALE DETECTED on "Will MegaETH launch a token by June 30, 2026?"
|
||||
BUY Yes @ 0.4200 | $92,336 USDC | Wallet: 0x7a3b...f91e
|
||||
Anomaly Score: 0.78/1.00
|
||||
Tiered monitoring: Tier1=8 (>500K), Tier2=198 (>10K), Tier3=559 (>1K)
|
||||
|
||||
[14:22:53] 🤖 LLM Analysis started (model: gemini-3-flash-preview)
|
||||
→ Tool call: search_web("MegaETH token launch date 2026")
|
||||
→ Tool call: search_twitter("MegaETH $METH token TGE")
|
||||
→ Tool call: get_protocol_tvl("megaeth")
|
||||
→ Tool call: get_contract_info("0x4f9b...2a1c")
|
||||
→ Tool call: search_telegram("MegaETH launch")
|
||||
[22:05:14] WHALE TRADE DETECTED!
|
||||
Amount: $5,000.00 USDC
|
||||
Side: BUY Yes
|
||||
Price: 0.7962
|
||||
Market: Over $20M committed to the Printr public sale?
|
||||
|
||||
[14:23:07] ✅ Analysis complete
|
||||
Information Asymmetry Score: 0.72 (HIGH)
|
||||
Recommendation: BUY Yes | Confidence: 0.75
|
||||
Report saved: reports/20260415/...
|
||||
Generating analysis report...
|
||||
Round 1: LLM requested 3 tool call(s)
|
||||
→ search_web("Printr public sale Sonar raise commitments")
|
||||
→ search_twitter("Printr PRINT token sale commitments")
|
||||
→ search_telegram("Printr public sale Sonar raise")
|
||||
Round 2: LLM requested 2 tool call(s)
|
||||
→ search_web("Printr PRINT token sale total raised April 28")
|
||||
→ search_twitter("Printr sale oversubscribed April 28")
|
||||
|
||||
[15:00:00] 📊 Resolution check: 3 markets resolved
|
||||
→ "EdgeX FDV above 400M" resolved YES — Signal CORRECT (ROI: +142%)
|
||||
→ "Will Trump talk to Rutte" resolved NO — Signal INCORRECT
|
||||
→ "Over 9M committed to P2P" resolved YES — Signal CORRECT (ROI: +67%)
|
||||
Analysis complete after 3 round(s)
|
||||
Information Asymmetry Score: 0.22 (LOW)
|
||||
Trader Credibility: LOW (#2650076, PnL: -$207K)
|
||||
Report saved: reports/20260428/...
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -75,14 +76,11 @@ Each whale trade generates a detailed markdown report:
|
||||
|
||||
- **Trade details** — amount, direction, price, trader wallet
|
||||
- **Trader profile** — leaderboard rank, PnL, history, recent trades
|
||||
- **Event positions** — whale's positions across related markets (hedge detection)
|
||||
- **Market top holders** — Top 5 buyers/sellers with rankings
|
||||
- **LLM investigation** — autonomous tool calls (web, Twitter, Telegram, on-chain, DeFi)
|
||||
- **Information asymmetry assessment** — score, evidence, reasoning
|
||||
|
||||
Example finding:
|
||||
> *"New ERC-20 contract deployed by MegaETH deployer wallet 6 hours before trade — not yet publicly announced. KOL tweets about insider knowledge preceded the trade by ~3 hours."*
|
||||
>
|
||||
> **Information Asymmetry Score: 0.72** | Trader Credibility: HIGH
|
||||
|
||||
See full example: [docs/examples/sample_report.md](docs/examples/sample_report.md)
|
||||
|
||||
</details>
|
||||
@@ -90,17 +88,12 @@ See full example: [docs/examples/sample_report.md](docs/examples/sample_report.m
|
||||
<details>
|
||||
<summary><b>Daily Briefing</b> — Automated intelligence summary</summary>
|
||||
|
||||
Daily briefings include:
|
||||
- High-confidence signals with analysis
|
||||
Daily briefings are generated at 10:00 AM local time and emailed automatically. They include:
|
||||
- High-confidence signals (IAS >= 60%) with full analysis
|
||||
- Fallback: top 5 signals by score if none reach 60%
|
||||
- Price volatility alerts
|
||||
- Historical signal performance (win rate, ROI by confidence tier)
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Win Rate | **63.5%** |
|
||||
| Avg ROI | **+28.3%** |
|
||||
| Signals with IAS >= 60% | 3 today |
|
||||
|
||||
See full example: [docs/examples/sample_briefing.md](docs/examples/sample_briefing.md)
|
||||
|
||||
</details>
|
||||
@@ -130,7 +123,7 @@ Then add your API key and start:
|
||||
echo "GEMINI_API_KEY=your_key_here" >> .env
|
||||
|
||||
# Activate the environment and run
|
||||
source venv/bin/activate
|
||||
source .venv/bin/activate
|
||||
python -m src.main run
|
||||
```
|
||||
|
||||
@@ -143,33 +136,20 @@ docker build -t whale-watcher .
|
||||
docker run --env-file .env -v ./data:/app/data -v ./reports:/app/reports whale-watcher
|
||||
```
|
||||
|
||||
### Make Commands
|
||||
|
||||
```bash
|
||||
make setup # One-click setup
|
||||
make run # Start monitoring
|
||||
make run-debug # Start with debug logging
|
||||
make dashboard # Start web dashboard
|
||||
make markets # View trending markets
|
||||
make briefing # Generate today's briefing
|
||||
make help # Show all commands
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Real-Time Monitoring** | Parallel per-market polling of 50+ trending markets |
|
||||
| **Anomaly Detection** | 5-dimensional scoring: size, price uncertainty, time-of-day, trader deviation, cluster signals |
|
||||
| **Trader Profiling** | Leaderboard ranking, PnL, trading history, recent behavior |
|
||||
| **LLM Analysis** | 14 autonomous tools across 5 rounds of investigation |
|
||||
| **Signal Tracking** | Automatic market resolution checking, win rate stats by confidence tier |
|
||||
| **Daily Briefing** | Automated 10:00 AM summary with high-confidence signals |
|
||||
| **Tiered Market Monitoring** | 700+ markets across 3 tiers: Tier1 (>$500K, 15s), Tier2 (>$10K, 60s), Tier3 (>$1K, 300s) |
|
||||
| **Anomaly Detection** | 5-factor scoring: premium ratio, signal cleanliness, depth ratio, cluster signals, base confidence |
|
||||
| **Trader Profiling** | Leaderboard ranking, PnL, trading history, event positions, market top holders |
|
||||
| **LLM Analysis** | 14 autonomous tools across up to 3 rounds of investigation |
|
||||
| **Signal Tracking** | Automatic market resolution checking every 30 min, win rate stats by confidence tier |
|
||||
| **Daily Briefing** | Automated 10:00 AM summary with high-confidence signals, emailed to configured recipients |
|
||||
| **Email Alerts** | Real-time notifications for high information-asymmetry signals (>= 60%) |
|
||||
| **Web Dashboard** | FastAPI-based signal performance dashboard |
|
||||
| **Official API** | Works with Polymarket's public API — no private API access needed |
|
||||
|
||||
### 14 LLM Research Tools
|
||||
|
||||
@@ -178,11 +158,10 @@ The LLM agent autonomously selects and uses these tools during investigation:
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
| **Social Sentiment** | `search_twitter`, `search_telegram`, `search_web` |
|
||||
| **Crypto Data** | `get_crypto_price`, `get_defi_metrics`, `get_token_unlocks` |
|
||||
| **Financial Data** | `get_stock_price`, `get_economic_indicators` |
|
||||
| **On-Chain** | `get_wallet_transactions`, `get_contract_info` |
|
||||
| **Legislation** | `search_congress` |
|
||||
| **Market Data** | `get_market_history`, `get_trader_positions` |
|
||||
| **Crypto Data** | `get_crypto_price`, `get_crypto_market_overview`, `get_protocol_tvl`, `get_token_unlocks`, `get_protocol_revenue` |
|
||||
| **Financial Data** | `get_stock_price`, `get_stock_news`, `get_economic_data` |
|
||||
| **On-Chain** | `get_wallet_transfers`, `get_contract_info` |
|
||||
| **Legislation** | `get_bill_status`, `get_recent_legislation` |
|
||||
|
||||
---
|
||||
|
||||
@@ -191,12 +170,12 @@ The LLM agent autonomously selects and uses these tools during investigation:
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Polymarket API] --> B[Market Fetcher]
|
||||
B --> C[50+ Trending Markets]
|
||||
B --> C[700+ Tiered Markets]
|
||||
C --> D[Trade Monitor]
|
||||
D --> E{Whale Trade?}
|
||||
E -->|No| D
|
||||
E -->|Yes| F[Anomaly Detector]
|
||||
F --> G{Score > Threshold?}
|
||||
F --> G{Score >= 0.65?}
|
||||
G -->|No| D
|
||||
G -->|Yes| H[LLM Analyzer]
|
||||
H --> I[14 Research Tools]
|
||||
@@ -207,20 +186,28 @@ flowchart LR
|
||||
|
||||
### Pipeline
|
||||
|
||||
1. **Market Selection** — Fetches top trending markets by 24h volume from Polymarket, filters out sports/weather/short-term price markets, refreshes every 15 minutes
|
||||
1. **Market Selection** — Fetches all active markets from Polymarket Gamma API, classifies into 3 tiers by 24h volume, adds token launch markets. Refreshes every 15 minutes.
|
||||
|
||||
2. **Trade Monitoring** — Runs parallel async tasks per market, polls for new trades incrementally, deduplicates by transaction hash
|
||||
2. **Trade Monitoring** — Runs parallel async tasks per market (one task per market), polls official Polymarket data-api for new taker BUY trades, deduplicates by transaction hash. Connection pool tuned for 700+ concurrent markets.
|
||||
|
||||
3. **Anomaly Detection** — Multi-dimensional scoring on 5 axes:
|
||||
- **Size** — Trade size relative to market 24h volume
|
||||
- **Price uncertainty** — Closer to 0.5 = more interesting
|
||||
- **Time-of-day** — ET hour-based suspicion weights
|
||||
- **Trader deviation** — Trade size vs trader's historical average
|
||||
- **Cluster signal** — Same-direction trades within 5-minute window
|
||||
3. **Whale Pre-filter** — Multi-layer filter chain:
|
||||
- Price range: 0.10 - 0.90
|
||||
- Minimum size: $5,000 hard floor
|
||||
- Dynamic threshold: $10K base, scaled by market volume (floor $5K, cap $100K)
|
||||
- Conviction check: must pay above market mid price
|
||||
- Resolution window: 6 hours to 90 days
|
||||
|
||||
4. **LLM Investigation** — Builds rich context (trade + trader profile + market data), LLM autonomously uses tools to investigate (up to 5 rounds), produces structured recommendation with information asymmetry score
|
||||
4. **Anomaly Scoring** — 5-factor model (max 1.0):
|
||||
- Base confidence: 0.50
|
||||
- Premium-to-threshold ratio: +0.20
|
||||
- Signal cleanliness (conviction): +0.10
|
||||
- Depth ratio (size vs liquidity): +0.10
|
||||
- Cluster tier (repeated same-direction): +0.10
|
||||
- Threshold: >= 0.65 to trigger LLM analysis
|
||||
|
||||
5. **Signal Tracking** — Resolution tracker checks every 30 minutes for resolved markets, validates signal correctness, computes theoretical ROI, aggregates win rates by confidence tier
|
||||
5. **LLM Investigation** — Builds rich context (trade + trader profile + event positions + market top holders + historical signals), LLM autonomously uses tools for up to 3 rounds, produces structured assessment with information asymmetry score (0-1).
|
||||
|
||||
6. **Signal Tracking** — Resolution tracker checks every 30 minutes for resolved markets, validates signal correctness, computes theoretical ROI. Daily briefings generated at 10:00 AM and emailed.
|
||||
|
||||
---
|
||||
|
||||
@@ -232,35 +219,57 @@ Copy `.env.example` to `.env` and configure:
|
||||
|
||||
| Variable | Description | Get It |
|
||||
|----------|-------------|--------|
|
||||
| `GEMINI_API_KEY` | Gemini API key for LLM analysis | [Google AI Studio](https://aistudio.google.com/apikey) |
|
||||
|
||||
### Trade Data Source
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TRADE_API_MODE` | `official` | `official` = Polymarket public API (no auth needed), `internal` = private API |
|
||||
|
||||
The bot works out of the box with Polymarket's official public API. No private API access required.
|
||||
| `GEMINI_API_KEY` | LLM API key for analysis | [Google AI Studio](https://aistudio.google.com/apikey) |
|
||||
|
||||
### Optional (enhances analysis)
|
||||
|
||||
| Variable | Description | Get It |
|
||||
|----------|-------------|--------|
|
||||
| `TAVILY_API_KEY` | Web search (primary) | [tavily.com](https://tavily.com) |
|
||||
| `TWITTER_API_KEY` | Twitter sentiment search | [Twitter Developer](https://developer.twitter.com) |
|
||||
| `SERPER_API_KEY` | Web search (fallback) | [serper.dev](https://serper.dev) |
|
||||
| `TWITTER_API_KEY` | Twitter sentiment search | [twitterapi.io](https://twitterapi.io) |
|
||||
| `POLYGON_API_KEY` | Stock/ETF/forex data | [polygon.io](https://polygon.io) |
|
||||
| `FRED_API_KEY` | Economic indicators | [FRED](https://fred.stlouisfed.org/docs/api/api_key.html) |
|
||||
| `ETHERSCAN_API_KEY` | On-chain wallet analysis | [etherscan.io](https://etherscan.io/apis) |
|
||||
| `ETHERSCAN_API_KEY` | On-chain wallet analysis (Polygon V2) | [etherscan.io](https://etherscan.io/apis) |
|
||||
| `CONGRESS_API_KEY` | US legislation data | [congress.gov](https://api.congress.gov/) |
|
||||
| `TELEGRAM_API_ID` / `TELEGRAM_API_HASH` | Telegram channel monitoring | [my.telegram.org](https://my.telegram.org) |
|
||||
|
||||
### LLM Settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LLM_MODEL` | `gemini-3-flash-preview` | Model name (any OpenAI-compatible) |
|
||||
| `LLM_BASE_URL` | Google AI endpoint | OpenAI-compatible API base URL |
|
||||
| `LLM_TEMPERATURE` | `0` | LLM temperature |
|
||||
|
||||
### Whale Detection Tuning
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MIN_TRADE_SIZE_USD` | `1000` | Minimum trade size to consider |
|
||||
| `MIN_PRICE` / `MAX_PRICE` | `0` / `0.7` | Price range filter |
|
||||
| `FETCH_INTERVAL_SECONDS` | `15` | Polling interval per market |
|
||||
| `TRENDING_MARKETS_LIMIT` | `50` | Number of markets to monitor |
|
||||
| `MIN_TRADE_SIZE_USD` | `10000` | Minimum trade size to consider |
|
||||
| `MIN_PRICE` / `MAX_PRICE` | `0.10` / `0.90` | Price range filter |
|
||||
| `FETCH_INTERVAL_SECONDS` | `10` | Default polling interval |
|
||||
|
||||
### Tiered Market Monitoring
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `FULL_MARKET_SCAN` | `true` | Enable tiered monitoring (all active markets) |
|
||||
| `TIER1_VOLUME_MIN` | `500000` | Tier 1 volume threshold |
|
||||
| `TIER2_VOLUME_MIN` | `10000` | Tier 2 volume threshold |
|
||||
| `TIER3_VOLUME_MIN` | `1000` | Tier 3 volume threshold |
|
||||
| `TIER1_POLL_INTERVAL` | `15` | Tier 1 polling interval (seconds) |
|
||||
| `TIER2_POLL_INTERVAL` | `60` | Tier 2 polling interval (seconds) |
|
||||
| `TIER3_POLL_INTERVAL` | `300` | Tier 3 polling interval (seconds) |
|
||||
|
||||
### Email Alerts
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `EMAIL_ENABLED` | `false` | Enable email notifications |
|
||||
| `EMAIL_SENDER` | — | Sender email address |
|
||||
| `EMAIL_PASSWORD` | — | Sender email password (app password) |
|
||||
| `EMAIL_RECIPIENT` | — | Comma-separated recipient emails |
|
||||
|
||||
---
|
||||
|
||||
@@ -270,6 +279,8 @@ The bot works out of the box with Polymarket's official public API. No private A
|
||||
# Core
|
||||
python -m src.main run [--debug] # Start monitoring
|
||||
python -m src.main check-markets --limit 20 # View trending markets
|
||||
|
||||
# Analysis
|
||||
python -m src.main test-analyze <market_id> # Test LLM on a specific market
|
||||
|
||||
# Reports
|
||||
@@ -312,20 +323,28 @@ src/
|
||||
│ ├── trade.py # TradeActivity, WhaleTrade, TraderRanking
|
||||
│ ├── decision.py # TradeRecommendation, LLMDecision
|
||||
│ └── anomaly_signal.py # AnomalySignal (stored signal)
|
||||
├── services/ # Business logic (23 modules)
|
||||
│ ├── market_fetcher.py # Polymarket Gamma API
|
||||
│ ├── trade_monitor.py # Per-market parallel monitoring (official + internal API)
|
||||
│ ├── anomaly_detector.py # Multi-dimensional anomaly scoring
|
||||
│ ├── llm_analyzer.py # LLM with tool-use (14 tools, 5 rounds)
|
||||
├── services/ # Business logic
|
||||
│ ├── market_fetcher.py # Polymarket Gamma API (tiered market selection)
|
||||
│ ├── trade_monitor.py # Per-market parallel monitoring (official API)
|
||||
│ ├── anomaly_detector.py # 5-factor anomaly scoring
|
||||
│ ├── llm_analyzer.py # LLM with tool-use (14 tools, 3 rounds)
|
||||
│ ├── tools.py # Tool registry
|
||||
│ ├── resolution_tracker.py # Market resolution checking
|
||||
│ ├── stats_engine.py # Performance statistics
|
||||
│ ├── daily_briefing.py # Daily summary generation
|
||||
│ └── [data services] # Twitter, Telegram, CoinGecko, DeFiLlama,
|
||||
│ # FRED, Polygon, Etherscan, Congress, web search
|
||||
│ ├── daily_briefing.py # Daily summary generation + email
|
||||
│ ├── twitter_search.py # Twitter API search
|
||||
│ ├── telegram_search.py # Telegram channel monitoring
|
||||
│ ├── web_search.py # Tavily/Serper/DuckDuckGo search
|
||||
│ ├── coingecko.py # Crypto prices and market data
|
||||
│ ├── defillama.py # DeFi TVL, revenue, token unlocks
|
||||
│ ├── fred.py # FRED macroeconomic data
|
||||
│ ├── polygon.py # Stock/ETF prices and news
|
||||
│ ├── etherscan.py # On-chain data (Polygon, Etherscan V2)
|
||||
│ └── congress.py # US legislation data
|
||||
├── prompts/ # LLM system prompts
|
||||
│ ├── whale_analyzer.py # Whale trade analysis prompt
|
||||
│ └── volatility_analyzer.py # Price volatility analysis prompt
|
||||
├── db/database.py # SQLite signal storage
|
||||
├── prompts/ # LLM system prompts & tool schemas
|
||||
├── dashboard.py # FastAPI web dashboard
|
||||
└── main.py # CLI entry point (Typer)
|
||||
```
|
||||
|
||||
@@ -334,59 +353,54 @@ src/
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Polymarket Gamma API │
|
||||
│ (trending markets, prices) │
|
||||
└──────────────┬──────────────┘
|
||||
┌──────────────────────────────┐
|
||||
│ Polymarket Gamma API │
|
||||
│ (all active markets) │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
┌──────────────▼──────────────┐
|
||||
│ Market Fetcher │
|
||||
│ (filter sports/weather/etc) │
|
||||
└──────────────┬──────────────┘
|
||||
┌──────────────▼───────────────┐
|
||||
│ Market Fetcher │
|
||||
│ Tier1: >$500K (15s poll) │
|
||||
│ Tier2: >$10K (60s poll) │
|
||||
│ Tier3: >$1K (300s poll) │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
┌────────────────────▼────────────────────┐
|
||||
│ Trade Monitor (async) │
|
||||
│ 50+ parallel market polling tasks │
|
||||
│ Official API ←→ Internal API (switch) │
|
||||
│ Trade Monitor (async, 700+) │
|
||||
│ Official Polymarket data-api │
|
||||
│ Per-market parallel tasks │
|
||||
│ Pool: 50 connections, 120s timeout │
|
||||
└────────────────────┬────────────────────┘
|
||||
│
|
||||
┌────────────────────▼────────────────────┐
|
||||
│ Anomaly Detector │
|
||||
│ 5-axis scoring: size, price, time, │
|
||||
│ trader deviation, cluster signals │
|
||||
│ Pre-filter + Scoring │
|
||||
│ $5K+ size, 0.10-0.90 price, conviction │
|
||||
│ 5-factor anomaly score >= 0.65 │
|
||||
└────────────────────┬────────────────────┘
|
||||
│
|
||||
┌────────────────────▼────────────────────┐
|
||||
│ LLM Analyzer (Gemini) │
|
||||
│ 14 tools × 5 rounds of investigation │
|
||||
│ LLM Analyzer (14 tools) │
|
||||
│ Up to 3 rounds of investigation │
|
||||
│ max_tokens: 4096 │
|
||||
├─────────┬────────┬────────┬─────────────┤
|
||||
│ Twitter │ Web │ DeFi │ On-Chain │
|
||||
│Telegram │ Search │ Crypto │ Congress │
|
||||
│Telegram │ Search │ Crypto │ Legislation│
|
||||
└─────────┴───┬────┴────────┴─────────────┘
|
||||
│
|
||||
┌─────────────▼──────────────────────────┐
|
||||
│ Signal Storage (SQLite) │
|
||||
│ → Resolution Tracker (every 30min) │
|
||||
│ → Stats Engine (win rate, ROI) │
|
||||
│ → Dashboard (FastAPI) │
|
||||
│ → Daily Briefing (10:00 AM + email) │
|
||||
│ → Email Alerts (IAS >= 60%) │
|
||||
│ → Dashboard (FastAPI) │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Safety
|
||||
|
||||
- Trade execution **disabled by default** (`ENABLE_TRADE_EXECUTION=false`)
|
||||
- Position size capped at 20% of balance if enabled
|
||||
- Minimum 60% confidence threshold for execution
|
||||
- Price range filter avoids obvious outcomes
|
||||
- All decisions logged for audit trail
|
||||
- Rate limiting on all external APIs
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This system is for research and educational purposes. Prediction market trading involves significant risk. Never trade with funds you cannot afford to lose. Always verify recommendations independently.
|
||||
This system is for research and educational purposes. Prediction market trading involves significant risk. The information asymmetry scores and analyses are AI-generated estimates, not financial advice. Always verify independently.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,22 +0,0 @@
|
||||
# Core dependencies
|
||||
httpx>=0.27.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# AI/LLM (OpenAI-compatible API)
|
||||
openai>=1.0.0
|
||||
|
||||
# Web search fallback
|
||||
duckduckgo-search>=7.0.0
|
||||
|
||||
# CLI and formatting
|
||||
rich>=13.0.0
|
||||
typer>=0.9.0
|
||||
|
||||
# Telegram channel monitoring
|
||||
telethon>=1.36.0
|
||||
|
||||
# Dashboard
|
||||
fastapi>=0.100.0
|
||||
uvicorn>=0.20.0
|
||||
+9
-11
@@ -15,13 +15,6 @@ class Settings(BaseSettings):
|
||||
gemini_api_key: str = Field(default="", alias="GEMINI_API_KEY")
|
||||
llm_base_url: str = Field(default="http://apicz.boyuerichdata.com/v1/", alias="LLM_BASE_URL")
|
||||
|
||||
# Trade data API mode: "internal" (private API) or "official" (Polymarket data-api)
|
||||
trade_api_mode: str = Field(default="official", alias="TRADE_API_MODE")
|
||||
|
||||
# Internal trade data API (only used when TRADE_API_MODE=internal)
|
||||
internal_api_url: str = Field(default="http://103.197.25.170:18088", alias="INTERNAL_API_URL")
|
||||
internal_api_key: str = Field(default="", alias="INTERNAL_API_KEY")
|
||||
|
||||
# Twitter API (for social sentiment search)
|
||||
twitter_api_key: str = Field(default="", alias="TWITTER_API_KEY")
|
||||
|
||||
@@ -49,8 +42,6 @@ class Settings(BaseSettings):
|
||||
telegram_session_string: str = Field(default="", alias="TELEGRAM_SESSION_STRING")
|
||||
telegram_channels: str = Field(default="", alias="TELEGRAM_CHANNELS")
|
||||
|
||||
# Polygon Wallet
|
||||
polygon_wallet_private_key: str = Field(default="", alias="POLYGON_WALLET_PRIVATE_KEY")
|
||||
|
||||
# MongoDB
|
||||
mongodb_uri: str = Field(default="mongodb://localhost:27017/whale_watcher", alias="MONGODB_URI")
|
||||
@@ -67,12 +58,19 @@ class Settings(BaseSettings):
|
||||
fetch_interval_seconds: int = Field(default=15, alias="FETCH_INTERVAL_SECONDS")
|
||||
trending_markets_limit: int = Field(default=50, alias="TRENDING_MARKETS_LIMIT")
|
||||
|
||||
# Tiered market monitoring (full-coverage mode)
|
||||
full_market_scan: bool = Field(default=True, alias="FULL_MARKET_SCAN")
|
||||
tier1_volume_min: float = Field(default=500_000, alias="TIER1_VOLUME_MIN")
|
||||
tier2_volume_min: float = Field(default=10_000, alias="TIER2_VOLUME_MIN")
|
||||
tier3_volume_min: float = Field(default=1_000, alias="TIER3_VOLUME_MIN")
|
||||
tier1_poll_interval: int = Field(default=15, alias="TIER1_POLL_INTERVAL")
|
||||
tier2_poll_interval: int = Field(default=60, alias="TIER2_POLL_INTERVAL")
|
||||
tier3_poll_interval: int = Field(default=300, alias="TIER3_POLL_INTERVAL")
|
||||
|
||||
# LLM Settings
|
||||
llm_model: str = Field(default="gemini-3-flash-preview", alias="LLM_MODEL")
|
||||
llm_temperature: float = Field(default=0.0, alias="LLM_TEMPERATURE")
|
||||
|
||||
# Trade Execution
|
||||
enable_trade_execution: bool = Field(default=False, alias="ENABLE_TRADE_EXECUTION")
|
||||
|
||||
# Email notification
|
||||
email_smtp_server: str = Field(default="smtp.qq.com", alias="EMAIL_SMTP_SERVER")
|
||||
|
||||
+44
-3
@@ -230,17 +230,58 @@ class WhaleWatcher:
|
||||
|
||||
async def refresh_markets(self) -> None:
|
||||
"""Fetch and update the list of monitored markets."""
|
||||
if self.settings.full_market_scan:
|
||||
await self._refresh_markets_tiered()
|
||||
else:
|
||||
await self._refresh_markets_legacy()
|
||||
|
||||
async def _refresh_markets_tiered(self) -> None:
|
||||
"""Full-coverage tiered monitoring (mirrors options flow passive approach)."""
|
||||
logger.info("Fetching ALL active markets for tiered monitoring...")
|
||||
|
||||
tiers = self.market_fetcher.get_tiered_markets()
|
||||
|
||||
# Also merge token launch markets into appropriate tiers
|
||||
existing_ids = set()
|
||||
for tier_markets in tiers.values():
|
||||
for tm in tier_markets:
|
||||
existing_ids.add(tm.market.id)
|
||||
|
||||
token_markets = self.market_fetcher.get_token_launch_markets()
|
||||
token_added = 0
|
||||
for tm in token_markets:
|
||||
if tm.market.id not in existing_ids:
|
||||
# Assign to tier based on volume
|
||||
vol = tm.volume_24hr
|
||||
if vol >= self.settings.tier1_volume_min:
|
||||
tiers["tier1"].append(tm)
|
||||
elif vol >= self.settings.tier2_volume_min:
|
||||
tiers["tier2"].append(tm)
|
||||
else:
|
||||
tiers["tier3"].append(tm)
|
||||
existing_ids.add(tm.market.id)
|
||||
token_added += 1
|
||||
|
||||
if token_added:
|
||||
logger.info(f"Added {token_added} token launch markets to tiers")
|
||||
|
||||
total = sum(len(v) for v in tiers.values())
|
||||
if total > 0:
|
||||
self.trade_monitor.set_tiered_markets(tiers)
|
||||
logger.info(f"Tiered monitoring active: {total} markets total")
|
||||
else:
|
||||
logger.error("Failed to fetch any markets")
|
||||
|
||||
async def _refresh_markets_legacy(self) -> None:
|
||||
"""Original Top-N trending markets mode."""
|
||||
logger.info("Fetching trending markets...")
|
||||
|
||||
trending_markets = self.market_fetcher.get_trending_markets(
|
||||
limit=self.settings.trending_markets_limit
|
||||
)
|
||||
|
||||
# Additionally scan for specialized market categories
|
||||
# that may not be in the top trending list
|
||||
existing_ids = {tm.market.id for tm in trending_markets}
|
||||
|
||||
# 1. Token launch / crypto project markets
|
||||
token_markets = self.market_fetcher.get_token_launch_markets()
|
||||
token_added = 0
|
||||
for tm in token_markets:
|
||||
|
||||
@@ -59,14 +59,14 @@ You can call the following tools to obtain real-time information (all results ar
|
||||
**Tool usage principles**:
|
||||
- Based on market type and trade characteristics, decide which tools to call
|
||||
- You may call one, multiple, or zero tools
|
||||
- You may call the same tool multiple times with different keywords
|
||||
- If trade size is very large or information asymmetry suspicion is high, search more aggressively
|
||||
- **You have a maximum of 3 tool-call rounds. Budget wisely: use Round 1 for broad search (web + twitter + telegram in parallel), Round 2 for targeted follow-up if needed, then produce your final analysis. Do NOT use all rounds just searching — reserve capacity for your final answer.**
|
||||
- Do NOT call the same tool (e.g. search_web) more than 3 times total across all rounds
|
||||
- If the first search already covers the topic well, stop searching and analyze
|
||||
|
||||
**Tool collaboration and cross-verification (important)**:
|
||||
- Information from different tools must be **cross-verified** — do not draw conclusions from a single source. Example: if web search finds a policy rumor, verify with Twitter for public reaction and corroborate with economic data
|
||||
- If you discover new leads or keywords while using one tool, **immediately call other tools to follow up**. Example: if news search reveals an official's resignation, search for that person's name for more details and check Twitter for unreported information
|
||||
- When multiple tools return **contradictory results**, explicitly note the discrepancy and lower confidence — do not cherry-pick
|
||||
- Encourage "search chain" investigation: first-round search → discover leads → targeted second-round → deep third-round, progressing layer by layer rather than skimming the surface
|
||||
- Information from different tools should be **cross-verified** — but 2-3 sources are sufficient, do not over-search
|
||||
- When multiple tools return **contradictory results**, explicitly note the discrepancy and lower confidence
|
||||
- Prefer breadth (web + twitter + domain-specific tool) over depth (web × 8 with slightly different keywords)
|
||||
|
||||
## Polymarket Trading Mechanics
|
||||
|
||||
|
||||
+175
-138
@@ -1,9 +1,21 @@
|
||||
"""Anomaly detection service - multi-dimensional scoring for whale trades."""
|
||||
"""
|
||||
Anomaly detection service — confidence scoring for whale trades.
|
||||
|
||||
Mirrors the options flow confidence scoring from llm-trading-agent:
|
||||
Base confidence: 0.50
|
||||
+ Premium-to-threshold ratio: +0.20 (sqrt-scaled by market liquidity)
|
||||
+ Signal cleanliness (ask ratio): +0.10
|
||||
+ Volume/OI equivalent (depth ratio): +0.10
|
||||
+ Alert rule / cluster tier: +0.10
|
||||
|
||||
Total max = 1.0. Pre-filter threshold = 0.60 (matches options flow pipeline).
|
||||
"""
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from src.config import get_settings
|
||||
from src.models.trade import WhaleTrade, TradeActivity, TraderHistory
|
||||
@@ -15,51 +27,29 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class AnomalyDetector:
|
||||
"""
|
||||
Multi-dimensional anomaly detection for whale trades.
|
||||
Confidence scoring for whale trades, mirroring options flow pipeline.
|
||||
|
||||
Scoring dimensions:
|
||||
1. Size relative to market (trade vs market 24h volume)
|
||||
2. Price uncertainty (closer to 0.5 = more uncertain = more interesting)
|
||||
3. Time-of-day (off-peak hours = more suspicious)
|
||||
4. Trader deviation (trade size vs trader's historical average)
|
||||
5. Cluster signal (multiple same-direction trades in short window)
|
||||
5-factor scoring (same structure as OptionsFlowSignalProvider._calculate_confidence):
|
||||
1. Base confidence: 0.50
|
||||
2. Premium-to-threshold ratio: +0.20 (trade_size vs dynamic threshold)
|
||||
3. Signal cleanliness: +0.10 (conviction / price displacement)
|
||||
4. Depth ratio: +0.10 (trade_size vs market liquidity, like Volume/OI)
|
||||
5. Cluster tier: +0.10 (repeated same-direction trades, like alert_rule)
|
||||
"""
|
||||
|
||||
# --- Time-of-day weights (US Eastern Time) ---
|
||||
# Polymarket is US-dominated, so we use ET to judge trading hour anomaly.
|
||||
# Higher weight = more unusual trading hour = more suspicious.
|
||||
_ET_HOUR_WEIGHTS = {
|
||||
# ET 0-5 (midnight to 5am) — very unusual, most suspicious
|
||||
0: 0.6, 1: 0.7, 2: 0.8, 3: 0.9, 4: 0.8, 5: 0.6,
|
||||
# ET 6-8 (early morning) — some early traders
|
||||
6: 0.4, 7: 0.3, 8: 0.2,
|
||||
# ET 9-17 (US business hours) — peak activity, least suspicious
|
||||
9: 0.1, 10: 0.0, 11: 0.0, 12: 0.0, 13: 0.0,
|
||||
14: 0.0, 15: 0.0, 16: 0.0, 17: 0.1,
|
||||
# ET 18-20 (evening) — moderate
|
||||
18: 0.2, 19: 0.2, 20: 0.3,
|
||||
# ET 21-23 (late night) — unusual
|
||||
21: 0.4, 22: 0.5, 23: 0.5,
|
||||
}
|
||||
# UTC offset for US Eastern: -5 (EST) or -4 (EDT).
|
||||
# Use -4 as default (EDT covers ~Mar-Nov, most of the year).
|
||||
_ET_UTC_OFFSET = -4
|
||||
|
||||
# Cluster detection: track recent trades per market
|
||||
# Key: market_id, Value: deque of (timestamp, side, usdc_size)
|
||||
# Cluster detection
|
||||
_CLUSTER_WINDOW_SECONDS = 300 # 5 minutes
|
||||
_CLUSTER_MIN_COUNT = 3 # minimum trades for cluster signal
|
||||
_CLUSTER_MIN_COUNT = 3
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self.trader_profiler = TraderProfiler()
|
||||
# Recent trades for cluster detection: market_id -> deque
|
||||
self._recent_trades: Dict[str, deque] = defaultdict(
|
||||
lambda: deque(maxlen=50)
|
||||
)
|
||||
|
||||
# ================================================================
|
||||
# Core scoring
|
||||
# Core scoring — mirrors OptionsFlowSignalProvider._calculate_confidence
|
||||
# ================================================================
|
||||
|
||||
def get_anomaly_score(
|
||||
@@ -70,81 +60,38 @@ class AnomalyDetector:
|
||||
market_id: str = "",
|
||||
) -> Tuple[float, dict]:
|
||||
"""
|
||||
Calculate multi-dimensional anomaly score.
|
||||
Calculate confidence score using options-flow-style 5-factor model.
|
||||
|
||||
Returns:
|
||||
(total_score, breakdown_dict) where breakdown has per-dimension scores.
|
||||
(total_score, breakdown_dict) — total in [0.0, 1.0].
|
||||
"""
|
||||
breakdown = {}
|
||||
|
||||
# --- 1. Size score (absolute) ---
|
||||
# $5k=0.1, $20k=0.25, $50k=0.4, $100k+=0.5
|
||||
raw_size = min(0.5, 0.1 + (activity.usdc_size - 5000) / 250000)
|
||||
breakdown["size_abs"] = max(0.0, raw_size)
|
||||
# --- 1. Base confidence: 0.50 ---
|
||||
breakdown["base"] = 0.50
|
||||
|
||||
# --- 2. Size relative to market volume ---
|
||||
if market and market.volume_24hr > 0:
|
||||
# What fraction of 24h volume is this single trade?
|
||||
ratio = activity.usdc_size / market.volume_24hr
|
||||
# ratio 0.001=noise, 0.01=notable, 0.05=significant, 0.1+=massive
|
||||
rel_score = min(0.3, ratio * 6.0) # 0.05 ratio -> 0.3
|
||||
breakdown["size_relative"] = rel_score
|
||||
else:
|
||||
breakdown["size_relative"] = 0.15 # unknown market volume, use neutral
|
||||
# --- 2. Premium-to-threshold ratio: +0.20 max ---
|
||||
# Mirrors _premium_ratio_bonus: min(0.20, (sqrt(ratio) - 1) * 0.20)
|
||||
# where ratio = trade_size / dynamic_threshold
|
||||
# dynamic_threshold = base_size * sqrt(market_volume / baseline_volume)
|
||||
breakdown["premium_ratio"] = self._premium_ratio_bonus(activity, market)
|
||||
|
||||
# --- 3. Price uncertainty ---
|
||||
# Price is taker's buy price (no normalization).
|
||||
# Lower price = more uncertain/risky bet = more interesting.
|
||||
# 0.5 -> 0.2, 0.3/0.7 -> 0.1, 0.1/0.9 -> 0.0
|
||||
dist = abs(activity.price - 0.5)
|
||||
if dist <= 0.3:
|
||||
price_score = 0.2 * (1 - dist / 0.3)
|
||||
else:
|
||||
price_score = 0.0
|
||||
breakdown["price_uncertainty"] = price_score
|
||||
# --- 3. Signal cleanliness (conviction): +0.10 max ---
|
||||
# Mirrors _ask_ratio_bonus: measures taker aggressiveness.
|
||||
# In options: ASK-side ratio > 0.8 = full bonus.
|
||||
# In Polymarket: price displacement from market mid = conviction.
|
||||
breakdown["signal_clean"] = self._signal_cleanliness_bonus(activity, market)
|
||||
|
||||
# --- 4. Time-of-day ---
|
||||
utc_hour = datetime.utcfromtimestamp(activity.timestamp).hour
|
||||
et_hour = (utc_hour + self._ET_UTC_OFFSET) % 24
|
||||
breakdown["time_of_day"] = self._ET_HOUR_WEIGHTS.get(et_hour, 0.1) * 0.15
|
||||
# --- 4. Depth ratio (like Volume/OI): +0.10 max ---
|
||||
# Mirrors _volume_oi_bonus: trade_size / market_liquidity.
|
||||
# High ratio = new significant positioning.
|
||||
breakdown["depth_ratio"] = self._depth_ratio_bonus(activity, market)
|
||||
|
||||
# --- 5. Trader deviation ---
|
||||
if trader_history and trader_history.avg_trade_size > 0:
|
||||
# How many X of their average is this trade?
|
||||
multiple = activity.usdc_size / trader_history.avg_trade_size
|
||||
# 1x=normal, 2x=notable, 5x=very unusual, 10x+=extreme
|
||||
if multiple >= 5:
|
||||
deviation_score = 0.15
|
||||
elif multiple >= 2:
|
||||
deviation_score = 0.05 + (multiple - 2) / 3 * 0.10
|
||||
else:
|
||||
deviation_score = 0.0
|
||||
breakdown["trader_deviation"] = deviation_score
|
||||
else:
|
||||
# Unknown trader history — slightly suspicious
|
||||
breakdown["trader_deviation"] = 0.05
|
||||
|
||||
# --- 6. Cluster signal ---
|
||||
cluster_score = self._get_cluster_score(activity, market_id=market_id)
|
||||
breakdown["cluster"] = cluster_score
|
||||
|
||||
# --- 7. Niche market bonus ---
|
||||
# Small/niche markets have higher information asymmetry value.
|
||||
# Large political/macro markets (volume > $5M/day) are noisy;
|
||||
# small markets (< $500k/day) are where insider signals matter most.
|
||||
if market and market.volume_24hr > 0:
|
||||
vol = market.volume_24hr
|
||||
if vol < 100_000:
|
||||
niche_score = 0.15 # very niche
|
||||
elif vol < 500_000:
|
||||
niche_score = 0.10
|
||||
elif vol < 2_000_000:
|
||||
niche_score = 0.05
|
||||
else:
|
||||
niche_score = 0.0 # large/macro market, no bonus
|
||||
breakdown["niche_market"] = niche_score
|
||||
else:
|
||||
breakdown["niche_market"] = 0.05
|
||||
# --- 5. Cluster tier (like alert_rule): +0.10 max ---
|
||||
# Mirrors _alert_rule_bonus: repeated same-direction activity.
|
||||
# In options: RepeatedHitsAscendingFill = 0.10, RepeatedHits = 0.07.
|
||||
# In Polymarket: multiple same-direction trades in 5-min window.
|
||||
breakdown["cluster_tier"] = self._cluster_tier_bonus(activity, market_id)
|
||||
|
||||
# --- Total ---
|
||||
total = sum(breakdown.values())
|
||||
@@ -152,22 +99,113 @@ class AnomalyDetector:
|
||||
|
||||
return total, breakdown
|
||||
|
||||
def record_trade(self, activity: TradeActivity, market_id: str):
|
||||
"""Record a trade for cluster detection. Call for every trade, not just whales."""
|
||||
self._recent_trades[market_id].append((
|
||||
activity.timestamp,
|
||||
activity.side,
|
||||
activity.usdc_size,
|
||||
))
|
||||
|
||||
def _get_cluster_score(self, activity: TradeActivity, market_id: str = "") -> float:
|
||||
@staticmethod
|
||||
def _premium_ratio_bonus(activity: TradeActivity, market: Optional[Market]) -> float:
|
||||
"""
|
||||
Check if there are multiple same-direction trades in a short window.
|
||||
Trade size vs dynamic threshold, sqrt-scaled.
|
||||
|
||||
A cluster of BUY or SELL in the same market in 5 minutes suggests
|
||||
coordinated or informed trading.
|
||||
Mirrors OptionsFlowSignalProvider._premium_ratio_bonus:
|
||||
threshold = 100K * sqrt(market_cap / 40B)
|
||||
bonus = min(0.20, (sqrt(premium / threshold) - 1) * 0.20)
|
||||
|
||||
Polymarket mapping:
|
||||
threshold = base_size * sqrt(market_volume / baseline_volume)
|
||||
base_size = $5,000, baseline_volume = $1,000,000
|
||||
"""
|
||||
max_bonus = 0.20
|
||||
trade_size = activity.usdc_size
|
||||
|
||||
if trade_size <= 0:
|
||||
return 0.0
|
||||
|
||||
# Dynamic threshold based on market volume (like market-cap scaling)
|
||||
base_size = 10_000.0
|
||||
baseline_volume = 1_000_000.0
|
||||
|
||||
if market and market.volume > 0:
|
||||
threshold = base_size * math.sqrt(market.volume / baseline_volume)
|
||||
threshold = max(5_000.0, threshold) # floor $5K
|
||||
else:
|
||||
threshold = base_size
|
||||
|
||||
ratio = trade_size / threshold
|
||||
if ratio <= 1.0:
|
||||
return 0.0
|
||||
|
||||
bonus = (math.sqrt(ratio) - 1) * max_bonus
|
||||
return min(max_bonus, max(0.0, bonus))
|
||||
|
||||
@staticmethod
|
||||
def _signal_cleanliness_bonus(activity: TradeActivity, market: Optional[Market]) -> float:
|
||||
"""
|
||||
Taker conviction / price displacement from market mid.
|
||||
|
||||
Mirrors _ask_ratio_bonus logic:
|
||||
ASK ratio > 0.8 → 0.10 (full), > 0.6 → 0.05 (half)
|
||||
|
||||
Polymarket mapping:
|
||||
A buyer paying significantly above market mid = aggressive taker (like ASK-side).
|
||||
Displacement > 5% → 0.10, > 2% → 0.05.
|
||||
"""
|
||||
if not market or not market.outcome_prices:
|
||||
return 0.0
|
||||
|
||||
# Get market mid price for the outcome the trader bought
|
||||
if activity.outcome == "Yes":
|
||||
market_mid = market.outcome_prices[0]
|
||||
elif len(market.outcome_prices) > 1:
|
||||
market_mid = market.outcome_prices[1]
|
||||
else:
|
||||
market_mid = 1.0 - market.outcome_prices[0]
|
||||
|
||||
displacement = activity.price - market_mid
|
||||
|
||||
if displacement > 0.05:
|
||||
return 0.10 # strong conviction (like ASK ratio > 0.8)
|
||||
if displacement > 0.02:
|
||||
return 0.05 # moderate conviction (like ASK ratio > 0.6)
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _depth_ratio_bonus(activity: TradeActivity, market: Optional[Market]) -> float:
|
||||
"""
|
||||
Trade size vs market liquidity (like Volume/OI).
|
||||
|
||||
Mirrors _volume_oi_bonus:
|
||||
V/OI > 3.0 → 0.10, > 1.5 → 0.07, > 1.0 → 0.03
|
||||
|
||||
Polymarket mapping:
|
||||
depth_ratio = trade_size / market_liquidity
|
||||
> 0.10 → 0.10, > 0.05 → 0.07, > 0.02 → 0.03
|
||||
"""
|
||||
if not market or not market.liquidity or market.liquidity <= 0:
|
||||
return 0.0
|
||||
|
||||
ratio = activity.usdc_size / market.liquidity
|
||||
|
||||
if ratio > 0.10:
|
||||
return 0.10
|
||||
if ratio > 0.05:
|
||||
return 0.07
|
||||
if ratio > 0.02:
|
||||
return 0.03
|
||||
return 0.0
|
||||
|
||||
def _cluster_tier_bonus(self, activity: TradeActivity, market_id: str = "") -> float:
|
||||
"""
|
||||
Cluster of same-direction trades in short window (like alert_rule tiers).
|
||||
|
||||
Mirrors _alert_rule_bonus tier structure:
|
||||
RepeatedHitsAscendingFill → 0.10
|
||||
RepeatedHits → 0.07
|
||||
SweepsFollowedByFloor → 0.05
|
||||
Single sweep → 0.03
|
||||
|
||||
Polymarket mapping:
|
||||
5+ same-direction trades in 5min → 0.10
|
||||
3-4 trades → 0.07
|
||||
2 trades with large volume → 0.03
|
||||
"""
|
||||
# Use the same market_id key as record_trade()
|
||||
key = market_id or activity.condition_id
|
||||
recent = self._recent_trades.get(key)
|
||||
if not recent:
|
||||
@@ -176,7 +214,6 @@ class AnomalyDetector:
|
||||
now = activity.timestamp
|
||||
cutoff = now - self._CLUSTER_WINDOW_SECONDS
|
||||
|
||||
# Count same-direction trades in window
|
||||
same_dir_count = 0
|
||||
same_dir_volume = 0.0
|
||||
for ts, side, size in recent:
|
||||
@@ -184,14 +221,22 @@ class AnomalyDetector:
|
||||
same_dir_count += 1
|
||||
same_dir_volume += size
|
||||
|
||||
if same_dir_count >= 5:
|
||||
return 0.10
|
||||
if same_dir_count >= self._CLUSTER_MIN_COUNT:
|
||||
# 3 trades = 0.05, 5+ = 0.10, volume also matters
|
||||
count_score = min(0.10, 0.02 * same_dir_count)
|
||||
vol_bonus = min(0.05, same_dir_volume / 500000)
|
||||
return count_score + vol_bonus
|
||||
|
||||
return 0.07
|
||||
if same_dir_count >= 2 and same_dir_volume > 20_000:
|
||||
return 0.03
|
||||
return 0.0
|
||||
|
||||
def record_trade(self, activity: TradeActivity, market_id: str):
|
||||
"""Record a trade for cluster detection. Call for every trade, not just whales."""
|
||||
self._recent_trades[market_id].append((
|
||||
activity.timestamp,
|
||||
activity.side,
|
||||
activity.usdc_size,
|
||||
))
|
||||
|
||||
# ================================================================
|
||||
# Pre-filter (before LLM)
|
||||
# ================================================================
|
||||
@@ -202,13 +247,13 @@ class AnomalyDetector:
|
||||
market: Optional[Market] = None,
|
||||
trader_history: Optional[TraderHistory] = None,
|
||||
market_id: str = "",
|
||||
min_score: float = 0.40,
|
||||
min_score: float = 0.65,
|
||||
) -> Tuple[bool, float, dict]:
|
||||
"""
|
||||
Decide whether a whale trade warrants LLM analysis.
|
||||
|
||||
Returns:
|
||||
(should_analyze, score, breakdown)
|
||||
Threshold 0.65: requires at least base (0.50) + one strong factor
|
||||
to trigger LLM analysis.
|
||||
"""
|
||||
score, breakdown = self.get_anomaly_score(
|
||||
activity, market, trader_history, market_id=market_id,
|
||||
@@ -230,9 +275,9 @@ class AnomalyDetector:
|
||||
def filter_whale_trades(
|
||||
self,
|
||||
trades: List[WhaleTrade],
|
||||
min_score: float = 0.5,
|
||||
min_score: float = 0.65,
|
||||
) -> List[WhaleTrade]:
|
||||
"""Filter whale trades by anomaly score."""
|
||||
"""Filter whale trades by confidence score."""
|
||||
filtered = []
|
||||
for trade in trades:
|
||||
score, _ = self.get_anomaly_score(trade.trade)
|
||||
@@ -248,16 +293,13 @@ class AnomalyDetector:
|
||||
"""Analyze the context of a whale trade for LLM input."""
|
||||
trade = whale_trade.trade
|
||||
|
||||
# Direction interpretation (only BUY trades, no normalization)
|
||||
if trade.outcome == "Yes":
|
||||
direction_meaning = f"Trader bought Yes Token @ {trade.price:.4f} — Bullish (believes event will occur)"
|
||||
else:
|
||||
direction_meaning = f"Trader bought No Token @ {trade.price:.4f} — Bearish (believes event will NOT occur)"
|
||||
|
||||
# Buy price directly reflects taker's conviction — lower price = higher odds bet
|
||||
implied_prob = trade.price
|
||||
|
||||
# Market state
|
||||
market_state = "uncertain"
|
||||
if whale_trade.market_outcome_prices:
|
||||
max_price = max(whale_trade.market_outcome_prices)
|
||||
@@ -266,7 +308,6 @@ class AnomalyDetector:
|
||||
elif max_price < 0.6:
|
||||
market_state = "highly uncertain"
|
||||
|
||||
# Multi-dimensional anomaly score
|
||||
score, breakdown = self.get_anomaly_score(trade)
|
||||
|
||||
return {
|
||||
@@ -289,12 +330,10 @@ class AnomalyDetector:
|
||||
context = self.analyze_trade_context(whale_trade)
|
||||
trade = whale_trade.trade
|
||||
|
||||
# Build outcome prices string
|
||||
prices_str = ""
|
||||
for outcome, price in zip(context["market_outcomes"], context["current_prices"]):
|
||||
prices_str += f" - {outcome}: {price:.2%}\n"
|
||||
|
||||
# Trader profile
|
||||
trader_profile = self.trader_profiler.generate_profile(
|
||||
wallet_address=trade.proxy_wallet or "Unknown",
|
||||
ranking=whale_trade.trader_ranking,
|
||||
@@ -302,15 +341,13 @@ class AnomalyDetector:
|
||||
)
|
||||
trader_profile_str = self.trader_profiler.format_profile_for_llm(trader_profile)
|
||||
|
||||
# Anomaly breakdown string
|
||||
bd = context["anomaly_breakdown"]
|
||||
breakdown_str = (
|
||||
f" Absolute size: {bd.get('size_abs', 0):.2f} | "
|
||||
f"Relative to market: {bd.get('size_relative', 0):.2f} | "
|
||||
f"Price uncertainty: {bd.get('price_uncertainty', 0):.2f} | "
|
||||
f"Time of day: {bd.get('time_of_day', 0):.2f} | "
|
||||
f"Trader deviation: {bd.get('trader_deviation', 0):.2f} | "
|
||||
f"Cluster signal: {bd.get('cluster', 0):.2f}"
|
||||
f" Base: {bd.get('base', 0):.2f} | "
|
||||
f"Premium ratio: {bd.get('premium_ratio', 0):.2f} | "
|
||||
f"Signal clean: {bd.get('signal_clean', 0):.2f} | "
|
||||
f"Depth ratio: {bd.get('depth_ratio', 0):.2f} | "
|
||||
f"Cluster tier: {bd.get('cluster_tier', 0):.2f}"
|
||||
)
|
||||
|
||||
return f"""
|
||||
@@ -323,7 +360,7 @@ class AnomalyDetector:
|
||||
- **Trade time**: {datetime.fromtimestamp(trade.timestamp).strftime('%Y-%m-%d %H:%M:%S UTC')}
|
||||
- **Trader wallet**: {trade.proxy_wallet or 'Unknown'}
|
||||
|
||||
### Anomaly Score
|
||||
### Confidence Score
|
||||
- **Overall score**: {context['anomaly_score']:.2f}/1.00
|
||||
- **Score breakdown**:
|
||||
{breakdown_str}
|
||||
|
||||
+36
-17
@@ -7,19 +7,25 @@ import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ETHERSCAN_API = "https://api.etherscan.io/api"
|
||||
ETHERSCAN_API_V2 = "https://api.etherscan.io/v2/api"
|
||||
|
||||
# Well-known ERC-20 token contracts on Ethereum mainnet
|
||||
# Default chain: Polygon (137) where Polymarket operates.
|
||||
# Ethereum mainnet = 1, can be overridden per-call.
|
||||
DEFAULT_CHAIN_ID = 137
|
||||
|
||||
# Well-known ERC-20 token contracts on Polygon
|
||||
TOKEN_CONTRACTS = {
|
||||
"USDC": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
|
||||
"USDT": "0xdac17f958d2ee523a2206206994597c13d831ec7",
|
||||
"WETH": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
|
||||
"DAI": "0x6b175474e89094c44da98b954eedeac495271d0f",
|
||||
"USDC": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", # USDC native on Polygon
|
||||
"USDC.e": "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", # USDC.e (bridged) on Polygon
|
||||
"USDT": "0xc2132d05d31c914a87c6611c10748aeb04b58e8f",
|
||||
"WETH": "0x7ceb23fd6bc0add59e62ac25578270cff1b9f619",
|
||||
"DAI": "0x8f3cf7ad23cd3cadbd9735aff958023239c6a063",
|
||||
}
|
||||
|
||||
# Decimals per token (used for converting raw amounts)
|
||||
TOKEN_DECIMALS = {
|
||||
"USDC": 6,
|
||||
"USDC.e": 6,
|
||||
"USDT": 6,
|
||||
"WETH": 18,
|
||||
"DAI": 18,
|
||||
@@ -65,10 +71,11 @@ class EtherscanService:
|
||||
def is_available(self) -> bool:
|
||||
return bool(self.api_key and self.api_key.strip())
|
||||
|
||||
def _get(self, params: dict) -> dict:
|
||||
"""Make authenticated GET request to Etherscan API."""
|
||||
def _get(self, params: dict, chain_id: int = DEFAULT_CHAIN_ID) -> dict:
|
||||
"""Make authenticated GET request to Etherscan V2 API."""
|
||||
params["apikey"] = self.api_key
|
||||
resp = self._client.get(ETHERSCAN_API, params=params)
|
||||
params["chainid"] = chain_id
|
||||
resp = self._client.get(ETHERSCAN_API_V2, params=params)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
@@ -111,18 +118,30 @@ class EtherscanService:
|
||||
|
||||
# Filter by token if not "ALL"
|
||||
if token_upper != "ALL":
|
||||
contract = TOKEN_CONTRACTS.get(token_upper, "").lower()
|
||||
if contract:
|
||||
# For USDC, match both native and bridged (USDC.e) contracts
|
||||
if token_upper == "USDC":
|
||||
allowed = {
|
||||
TOKEN_CONTRACTS.get("USDC", "").lower(),
|
||||
TOKEN_CONTRACTS.get("USDC.e", "").lower(),
|
||||
}
|
||||
allowed.discard("")
|
||||
transfers = [
|
||||
tx for tx in transfers
|
||||
if tx.get("contractAddress", "").lower() == contract
|
||||
if tx.get("contractAddress", "").lower() in allowed
|
||||
]
|
||||
else:
|
||||
# Try matching by symbol in the response
|
||||
transfers = [
|
||||
tx for tx in transfers
|
||||
if tx.get("tokenSymbol", "").upper() == token_upper
|
||||
]
|
||||
contract = TOKEN_CONTRACTS.get(token_upper, "").lower()
|
||||
if contract:
|
||||
transfers = [
|
||||
tx for tx in transfers
|
||||
if tx.get("contractAddress", "").lower() == contract
|
||||
]
|
||||
else:
|
||||
# Try matching by symbol in the response
|
||||
transfers = [
|
||||
tx for tx in transfers
|
||||
if tx.get("tokenSymbol", "").upper() == token_upper
|
||||
]
|
||||
|
||||
if not transfers:
|
||||
return f"No {token_upper} transfers found for {_short_address(addr)} in the last 20 token transactions."
|
||||
|
||||
@@ -32,7 +32,7 @@ logger = logging.getLogger(__name__)
|
||||
# Maximum tool-use rounds to prevent infinite loops
|
||||
# 14 tools available; LLM can call multiple per round but may need
|
||||
# several rounds for chain-of-investigation (search → discover → verify)
|
||||
MAX_TOOL_ROUNDS = 5
|
||||
MAX_TOOL_ROUNDS = 3
|
||||
|
||||
|
||||
class LLMAnalyzer:
|
||||
@@ -259,12 +259,25 @@ class LLMAnalyzer:
|
||||
|
||||
# Tool-use loop
|
||||
for round_idx in range(MAX_TOOL_ROUNDS + 1):
|
||||
# Last round: no tools, force final answer
|
||||
is_last_round = (round_idx == MAX_TOOL_ROUNDS)
|
||||
if is_last_round:
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": (
|
||||
"You have used all available tool rounds. "
|
||||
"Based on all information gathered, provide your final "
|
||||
"analysis and output the JSON assessment now."
|
||||
),
|
||||
})
|
||||
|
||||
# Call LLM
|
||||
call_kwargs = {
|
||||
"model": self.settings.llm_model,
|
||||
"messages": messages,
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
if tool_schemas and round_idx < MAX_TOOL_ROUNDS:
|
||||
if tool_schemas and not is_last_round:
|
||||
call_kwargs["tools"] = tool_schemas
|
||||
call_kwargs["tool_choice"] = "auto"
|
||||
|
||||
@@ -289,10 +302,13 @@ class LLMAnalyzer:
|
||||
|
||||
# No tool calls — final response
|
||||
analysis_text = msg.content or ""
|
||||
finish_reason = response.choices[0].finish_reason
|
||||
logger.info(
|
||||
f"Analysis complete after {round_idx + 1} round(s) "
|
||||
f"({len(analysis_text)} chars)"
|
||||
f"({len(analysis_text)} chars, finish={finish_reason})"
|
||||
)
|
||||
if len(analysis_text) < 200:
|
||||
logger.warning(f"Suspiciously short response: {analysis_text[:200]}")
|
||||
break
|
||||
|
||||
# Parse JSON decision from final response
|
||||
|
||||
@@ -372,6 +372,59 @@ class MarketFetcher:
|
||||
logger.error(f"Error fetching niche markets: {e}")
|
||||
return niche_markets
|
||||
|
||||
def get_tiered_markets(self) -> dict[str, list[TrendingMarket]]:
|
||||
"""
|
||||
Fetch ALL active markets and classify into 3 tiers by 24h volume.
|
||||
|
||||
Returns dict with keys "tier1", "tier2", "tier3", each a list of TrendingMarket.
|
||||
Mirrors options flow's "passive receive all signals" approach:
|
||||
scan everything, filter later.
|
||||
"""
|
||||
settings = self.settings
|
||||
all_markets = self.get_all_current_markets()
|
||||
|
||||
tiers: dict[str, list[TrendingMarket]] = {"tier1": [], "tier2": [], "tier3": []}
|
||||
|
||||
for market in all_markets:
|
||||
# Apply standard filters
|
||||
raw = {
|
||||
"question": market.question,
|
||||
"description": market.description,
|
||||
"slug": market.slug,
|
||||
}
|
||||
if self._should_filter_market(raw):
|
||||
continue
|
||||
|
||||
if not market.clob_token_ids or not market.active or market.closed:
|
||||
continue
|
||||
|
||||
vol = market.volume_24hr
|
||||
|
||||
tm = TrendingMarket(
|
||||
market=market,
|
||||
volume_24hr=vol,
|
||||
liquidity=market.liquidity,
|
||||
)
|
||||
|
||||
if vol >= settings.tier1_volume_min:
|
||||
tiers["tier1"].append(tm)
|
||||
elif vol >= settings.tier2_volume_min:
|
||||
tiers["tier2"].append(tm)
|
||||
elif vol >= settings.tier3_volume_min:
|
||||
tiers["tier3"].append(tm)
|
||||
# vol < tier3_volume_min → dead market, skip
|
||||
|
||||
# Sort each tier by volume descending
|
||||
for tier in tiers.values():
|
||||
tier.sort(key=lambda t: t.volume_24hr, reverse=True)
|
||||
|
||||
logger.info(
|
||||
f"Tiered markets: Tier1={len(tiers['tier1'])} (>{settings.tier1_volume_min/1000:.0f}K), "
|
||||
f"Tier2={len(tiers['tier2'])} (>{settings.tier2_volume_min/1000:.0f}K), "
|
||||
f"Tier3={len(tiers['tier3'])} (>{settings.tier3_volume_min/1000:.0f}K)"
|
||||
)
|
||||
return tiers
|
||||
|
||||
def get_market_by_id(self, market_id: str) -> Optional[Market]:
|
||||
"""
|
||||
Fetch a single market by ID.
|
||||
|
||||
@@ -167,11 +167,20 @@ class TelegramSearchService:
|
||||
# Calculate per-channel limit
|
||||
limit_per_channel = max(3, limit // len(self.channels))
|
||||
|
||||
# Run async search (nest_asyncio allows nested run_until_complete)
|
||||
loop = asyncio.get_event_loop()
|
||||
messages = loop.run_until_complete(
|
||||
self._search_all_channels(query, limit_per_channel)
|
||||
)
|
||||
# Run async search — handle both sync and async calling contexts
|
||||
coro = self._search_all_channels(query, limit_per_channel)
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# Already inside an async event loop — use a new thread
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
messages = pool.submit(
|
||||
asyncio.run, coro
|
||||
).result(timeout=30)
|
||||
except RuntimeError:
|
||||
# No running loop — safe to use run_until_complete
|
||||
loop = asyncio.get_event_loop()
|
||||
messages = loop.run_until_complete(coro)
|
||||
|
||||
# Trim to total limit
|
||||
messages = messages[:limit]
|
||||
|
||||
+118
-213
@@ -2,7 +2,7 @@
|
||||
Trade monitoring service - per-market parallel architecture.
|
||||
|
||||
Each market runs its own independent async task that:
|
||||
1. Polls the internal API for new trades (incremental via start_ts)
|
||||
1. Polls the official Polymarket data-api for new trades
|
||||
2. Detects whale trades
|
||||
3. Fetches trader ranking + history in parallel
|
||||
4. Fires the whale callback (LLM report generation) without blocking other markets
|
||||
@@ -12,6 +12,7 @@ Modeled after paper_trading/paper_trading.py's _market_loop pattern.
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import time as _time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -32,7 +33,7 @@ logger = logging.getLogger(__name__)
|
||||
# Gamma API for fetching latest market prices
|
||||
GAMMA_API_URL = "https://gamma-api.polymarket.com/markets"
|
||||
|
||||
# Internal API for trade data (more stable than official data-api)
|
||||
# Official Polymarket data-api for trade data
|
||||
# URL and key loaded from settings (.env)
|
||||
|
||||
# File to persist processed transaction hashes
|
||||
@@ -52,32 +53,27 @@ class TradeMonitor:
|
||||
):
|
||||
self.settings = get_settings()
|
||||
|
||||
# Official API (for trader ranking/history queries only)
|
||||
# Official Polymarket data-api
|
||||
self.data_api_url = "https://data-api.polymarket.com"
|
||||
self.trades_endpoint = f"{self.data_api_url}/trades"
|
||||
self.leaderboard_endpoint = f"{self.data_api_url}/v1/leaderboard"
|
||||
self._client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
# Internal API client for trade data
|
||||
self._internal_api_url = self.settings.internal_api_url
|
||||
self._internal_client = httpx.AsyncClient(
|
||||
timeout=30.0,
|
||||
headers={
|
||||
"X-API-Key": self.settings.internal_api_key,
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
},
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(30.0, pool=120.0),
|
||||
limits=httpx.Limits(
|
||||
max_connections=50,
|
||||
max_keepalive_connections=20,
|
||||
keepalive_expiry=30,
|
||||
),
|
||||
)
|
||||
|
||||
# Per-market last-fetch timestamps for incremental polling
|
||||
self._market_last_ts: Dict[str, int] = {}
|
||||
|
||||
# Global rate limiter for internal API (matches paper_trading: 5 QPS max)
|
||||
# NOTE: Lock created lazily in run() to avoid "attached to different loop" error
|
||||
# Rate limiter: Lock + Semaphore created lazily in run() to avoid "attached to different loop" error
|
||||
self._api_lock: Optional[asyncio.Lock] = None
|
||||
self._api_sem: Optional[asyncio.Semaphore] = None # concurrency limiter
|
||||
self._api_last_request: float = 0.0
|
||||
self._api_global_interval: float = 1.0 # min 1s between requests = 1 QPS
|
||||
self._api_global_interval: float = 0.2 # min 0.2s between requests = 5 QPS
|
||||
|
||||
# Cache for trader rankings to avoid repeated API calls
|
||||
self._trader_ranking_cache: Dict[str, TraderRanking] = {}
|
||||
@@ -143,7 +139,6 @@ class TradeMonitor:
|
||||
"""Cleanup resources."""
|
||||
self._save_processed_txns()
|
||||
await self._client.aclose()
|
||||
await self._internal_client.aclose()
|
||||
|
||||
# ================================================================
|
||||
# Market list management
|
||||
@@ -157,28 +152,46 @@ class TradeMonitor:
|
||||
self._monitored_markets[tm.market.id] = tm.market
|
||||
logger.info(f"Now monitoring {len(self._monitored_markets)} markets")
|
||||
|
||||
def set_tiered_markets(self, tiers: dict[str, list]) -> None:
|
||||
"""
|
||||
Set markets with per-tier poll intervals.
|
||||
|
||||
Stores poll_interval per market_id in _market_poll_intervals dict.
|
||||
"""
|
||||
self._monitored_markets = {}
|
||||
self._market_poll_intervals: dict[str, int] = {}
|
||||
|
||||
tier_intervals = {
|
||||
"tier1": self.settings.tier1_poll_interval,
|
||||
"tier2": self.settings.tier2_poll_interval,
|
||||
"tier3": self.settings.tier3_poll_interval,
|
||||
}
|
||||
|
||||
for tier_name, markets in tiers.items():
|
||||
interval = tier_intervals.get(tier_name, self.settings.fetch_interval_seconds)
|
||||
for tm in markets:
|
||||
if tm.market.id:
|
||||
self._monitored_markets[tm.market.id] = tm.market
|
||||
self._market_poll_intervals[tm.market.id] = interval
|
||||
|
||||
tier_counts = {k: len(v) for k, v in tiers.items()}
|
||||
logger.info(
|
||||
f"Tiered monitoring: {tier_counts} "
|
||||
f"(intervals: {tier_intervals}s), total={len(self._monitored_markets)}"
|
||||
)
|
||||
|
||||
# ================================================================
|
||||
# Trade fetching: dispatches to internal or official API
|
||||
# Trade fetching
|
||||
# ================================================================
|
||||
|
||||
_MAX_RETRIES = 3
|
||||
_RETRY_BACKOFF = [1, 2, 4] # seconds between retries
|
||||
|
||||
async def fetch_market_trades(self, market_id: str) -> List[TradeActivity]:
|
||||
"""
|
||||
Fetch recent trades for a market. Dispatches to internal or official API
|
||||
based on TRADE_API_MODE setting.
|
||||
"""
|
||||
if self.settings.trade_api_mode == "internal":
|
||||
return await self._fetch_trades_internal(market_id)
|
||||
else:
|
||||
return await self._fetch_trades_official(market_id)
|
||||
_MAX_RETRIES = 4
|
||||
_RETRY_BACKOFF = [2, 5, 10, 20] # seconds between retries (with jitter)
|
||||
|
||||
# ================================================================
|
||||
# Official Polymarket data-api: fetch trades
|
||||
# ================================================================
|
||||
|
||||
async def _fetch_trades_official(self, market_id: str) -> List[TradeActivity]:
|
||||
async def fetch_market_trades(self, market_id: str) -> List[TradeActivity]:
|
||||
"""
|
||||
Fetch recent trades using the official Polymarket data-api /trades endpoint.
|
||||
|
||||
@@ -200,13 +213,9 @@ class TradeMonitor:
|
||||
|
||||
params: Dict[str, object] = {
|
||||
"market": condition_id,
|
||||
"limit": 50 if last_ts is None else 500,
|
||||
"limit": 50,
|
||||
}
|
||||
|
||||
# Incremental polling: only fetch trades after last seen timestamp
|
||||
if last_ts is not None:
|
||||
params["after"] = last_ts + 1
|
||||
|
||||
sem = self._api_sem or asyncio.Semaphore(20)
|
||||
last_err: Optional[Exception] = None
|
||||
async with sem:
|
||||
@@ -238,11 +247,11 @@ class TradeMonitor:
|
||||
except httpx.HTTPError as e:
|
||||
last_err = e
|
||||
if attempt < self._MAX_RETRIES - 1:
|
||||
delay = self._RETRY_BACKOFF[attempt]
|
||||
delay = self._RETRY_BACKOFF[attempt] + random.uniform(0, 2)
|
||||
logger.debug(
|
||||
f"Official API retry for {market_id} "
|
||||
f"(attempt {attempt + 1}/{self._MAX_RETRIES}): "
|
||||
f"{type(e).__name__}, retrying in {delay}s"
|
||||
f"{type(e).__name__}, retrying in {delay:.1f}s"
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
@@ -324,153 +333,6 @@ class TradeMonitor:
|
||||
logger.warning(f"Error fetching official trades for {market_id}: {type(e).__name__}: {e}")
|
||||
return []
|
||||
|
||||
# ================================================================
|
||||
# Internal API: fetch trades
|
||||
# ================================================================
|
||||
|
||||
async def _fetch_trades_internal(self, market_id: str) -> List[TradeActivity]:
|
||||
"""
|
||||
Fetch recent taker trades for a market using the internal /flows API.
|
||||
|
||||
/flows returns one record per taker per transaction (already aggregated
|
||||
across maker fills), with accurate usd_amount and real execution price.
|
||||
Uses incremental polling via start_ts.
|
||||
Retries up to _MAX_RETRIES times on connection/timeout errors.
|
||||
"""
|
||||
try:
|
||||
last_ts = self._market_last_ts.get(market_id)
|
||||
|
||||
params: Dict[str, object] = {
|
||||
"market_id": market_id,
|
||||
"role": "taker",
|
||||
# First poll: only fetch recent 50 trades to record txn hashes
|
||||
# Subsequent polls: incremental via start_ts, small data
|
||||
"limit": 50 if last_ts is None else 500,
|
||||
"desc": True,
|
||||
}
|
||||
|
||||
if last_ts is not None:
|
||||
params["start_ts"] = last_ts + 1
|
||||
|
||||
# Semaphore limits concurrent requests; Lock enforces per-request interval
|
||||
sem = self._api_sem or asyncio.Semaphore(20)
|
||||
last_err: Optional[Exception] = None
|
||||
async with sem:
|
||||
for attempt in range(self._MAX_RETRIES):
|
||||
try:
|
||||
# Global rate limit
|
||||
async with self._api_lock:
|
||||
now = _time.monotonic()
|
||||
wait = self._api_global_interval - (now - self._api_last_request)
|
||||
if wait > 0:
|
||||
await asyncio.sleep(wait)
|
||||
self._api_last_request = _time.monotonic()
|
||||
|
||||
response = await self._internal_client.get(
|
||||
f"{self._internal_api_url}/flows", params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
break # success
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code in (502, 503, 504) and attempt < self._MAX_RETRIES - 1:
|
||||
delay = self._RETRY_BACKOFF[attempt]
|
||||
logger.debug(
|
||||
f"Internal API {e.response.status_code} for {market_id} "
|
||||
f"(attempt {attempt + 1}/{self._MAX_RETRIES}), "
|
||||
f"retrying in {delay}s"
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
raise # don't retry other HTTP errors
|
||||
except httpx.HTTPError as e:
|
||||
last_err = e
|
||||
if attempt < self._MAX_RETRIES - 1:
|
||||
delay = self._RETRY_BACKOFF[attempt]
|
||||
logger.debug(
|
||||
f"Internal API retry for {market_id} "
|
||||
f"(attempt {attempt + 1}/{self._MAX_RETRIES}): "
|
||||
f"{type(e).__name__}, retrying in {delay}s"
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Internal API connection error for {market_id} "
|
||||
f"(attempt {attempt + 1}/{self._MAX_RETRIES}, giving up): "
|
||||
f"{type(e).__name__}: {e}"
|
||||
)
|
||||
return []
|
||||
else:
|
||||
# All retries exhausted (shouldn't reach here, but just in case)
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
if not data:
|
||||
return []
|
||||
|
||||
activities = []
|
||||
max_ts = last_ts or 0
|
||||
|
||||
for item in data:
|
||||
try:
|
||||
raw_direction = item.get("direction", "")
|
||||
|
||||
# Only track BUY trades (new positions).
|
||||
# SELL may just be exiting a position, not a directional signal.
|
||||
if raw_direction != "BUY":
|
||||
continue
|
||||
|
||||
token_amount = float(item.get("token_amount", 0) or 0)
|
||||
raw_price = float(item.get("price", 0) or 0)
|
||||
usdc_size = float(item.get("usd_amount", 0) or 0)
|
||||
|
||||
# No normalization — keep real price and outcome:
|
||||
# - nonusdc_side=token1: BUY Yes token at raw_price
|
||||
# - nonusdc_side=token2: BUY No token at raw_price
|
||||
nonusdc_side = item.get("nonusdc_side", "token1")
|
||||
outcome = "Yes" if nonusdc_side == "token1" else "No"
|
||||
|
||||
ts = int(item.get("timestamp", 0) or 0)
|
||||
|
||||
if ts > max_ts:
|
||||
max_ts = ts
|
||||
|
||||
activity = TradeActivity(
|
||||
transaction_hash=f"{item.get('transaction_hash', '')}-{item.get('log_index', '')}",
|
||||
timestamp=ts,
|
||||
condition_id=item.get("condition_id", market_id),
|
||||
asset=item.get("condition_id", ""),
|
||||
side="BUY",
|
||||
size=token_amount,
|
||||
usdc_size=usdc_size,
|
||||
price=raw_price,
|
||||
outcome=outcome,
|
||||
outcome_index=0 if outcome == "Yes" else 1,
|
||||
title="",
|
||||
slug=None,
|
||||
event_slug=None,
|
||||
proxy_wallet=item.get("address"),
|
||||
name=None,
|
||||
)
|
||||
activities.append(activity)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse /flows trade: {e}")
|
||||
continue
|
||||
|
||||
if max_ts > 0:
|
||||
self._market_last_ts[market_id] = max_ts
|
||||
|
||||
return activities
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning(
|
||||
f"Flows API HTTP {e.response.status_code} for {market_id}: "
|
||||
f"{e.response.text[:200]}"
|
||||
)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.warning(f"Error fetching flows for {market_id}: {type(e).__name__}: {e}")
|
||||
return []
|
||||
|
||||
# ================================================================
|
||||
# Official API: trader info (ranking + history)
|
||||
# ================================================================
|
||||
@@ -761,37 +623,78 @@ class TradeMonitor:
|
||||
|
||||
def _is_whale_trade(self, activity: TradeActivity, market: Optional[Market] = None) -> bool:
|
||||
"""
|
||||
Check if a trade qualifies as a whale trade.
|
||||
Multi-layer pre-filter mirroring options flow SignalFilter._check_signal.
|
||||
|
||||
Uses a dynamic size threshold based on market volume:
|
||||
- Large markets (24h vol > $1M): standard threshold (MIN_TRADE_SIZE_USD)
|
||||
- Small markets (24h vol < $100k): lowered to $1,000
|
||||
- In between: linearly interpolated
|
||||
Filter chain (early rejection, same order as options flow):
|
||||
1. Price range — like moneyness filter (OTM/ITM range)
|
||||
2. Direction — BUY only (like enabled direction_filters)
|
||||
3. Resolution window — like DTE filter (3-60 days sweet spot)
|
||||
4. Size — like premium filter ($250K+ minimum)
|
||||
5. Dynamic size — like dynamic_premium (base × √(vol / baseline))
|
||||
6. Signal strength — like ask_ratio filter (conviction check)
|
||||
"""
|
||||
# Price filter: only BUY trades remain, price is the taker's buy price.
|
||||
# Low price = cheap bet with high upside, high price = expensive/certain.
|
||||
# Filter to [MIN_PRICE, MAX_PRICE] range (e.g. 0-0.7).
|
||||
import math
|
||||
from datetime import datetime as _dt
|
||||
|
||||
# --- 1. Price range (like moneyness: OTM 0-20%) ---
|
||||
# Price 0.2-0.8 = uncertain outcome = tradeable
|
||||
# Price < 0.2 or > 0.8 = near-consensus = no edge
|
||||
if not (self.settings.min_price <= activity.price <= self.settings.max_price):
|
||||
return False
|
||||
|
||||
# Dynamic threshold based on market total volume:
|
||||
# - Tiny markets ($10k-$100k vol): $1,000 (niche, info asymmetry high)
|
||||
# - Medium markets ($100k-$5M vol): $5,000 (standard)
|
||||
# - Large markets ($5M+ vol): $10,000 (macro, noise high)
|
||||
if market and market.volume > 0:
|
||||
vol = market.volume # total volume, not 24hr
|
||||
if vol <= 10_000:
|
||||
threshold = 500
|
||||
elif vol <= 100_000:
|
||||
threshold = 1_000
|
||||
elif vol <= 5_000_000:
|
||||
threshold = 5_000
|
||||
else:
|
||||
threshold = 10_000
|
||||
else:
|
||||
threshold = 5_000
|
||||
# --- 2. Direction: BUY only (like direction_filters.enabled) ---
|
||||
# Already enforced upstream (only BUY trades reach here)
|
||||
|
||||
return activity.usdc_size >= threshold
|
||||
# --- 3. Resolution window (like DTE min=3, max=60) ---
|
||||
# Markets resolving < 6 hours = price already settled (like DTE < 3)
|
||||
# Markets resolving > 90 days = too far out, edge diluted (like DTE > 60)
|
||||
if market and market.end_date:
|
||||
try:
|
||||
end_dt = _dt.fromisoformat(market.end_date.replace("Z", "+00:00"))
|
||||
now_dt = _dt.utcnow().replace(tzinfo=end_dt.tzinfo) if end_dt.tzinfo else _dt.utcnow()
|
||||
hours_to_resolution = max(0, (end_dt - now_dt).total_seconds() / 3600)
|
||||
if hours_to_resolution < 6:
|
||||
return False # too close, like DTE < 3
|
||||
if hours_to_resolution > 90 * 24:
|
||||
return False # too far, like DTE > 60
|
||||
except (ValueError, TypeError):
|
||||
pass # unknown end date, don't reject
|
||||
|
||||
# --- 4. Size (like premium min=$250K) ---
|
||||
# Base minimum: $5,000 (Polymarket scale vs options $250K)
|
||||
if activity.usdc_size < 5_000:
|
||||
return False
|
||||
|
||||
# --- 5. Dynamic size (like dynamic_premium = base × √(mcap / baseline)) ---
|
||||
# Larger markets require proportionally larger trades to be meaningful
|
||||
base_size = 10_000.0
|
||||
baseline_volume = 1_000_000.0
|
||||
|
||||
if market and market.volume > 0:
|
||||
threshold = base_size * math.sqrt(market.volume / baseline_volume)
|
||||
threshold = max(5_000.0, min(threshold, 100_000.0)) # floor $5K, cap $100K
|
||||
else:
|
||||
threshold = base_size
|
||||
|
||||
if activity.usdc_size < threshold:
|
||||
return False
|
||||
|
||||
# --- 6. Signal strength (like ask_ratio > 70%) ---
|
||||
# In Polymarket: buyer paying above market mid = conviction
|
||||
# Reject trades at or below market mid (no conviction, possibly hedging)
|
||||
if market and market.outcome_prices:
|
||||
if activity.outcome == "Yes":
|
||||
market_mid = market.outcome_prices[0]
|
||||
elif len(market.outcome_prices) > 1:
|
||||
market_mid = market.outcome_prices[1]
|
||||
else:
|
||||
market_mid = 1.0 - market.outcome_prices[0]
|
||||
|
||||
# Must pay above market mid (no discount buys = no conviction)
|
||||
if activity.price < market_mid + 0.01:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _handle_whale(self, activity: TradeActivity, market_id: str, market: Market):
|
||||
"""
|
||||
@@ -885,7 +788,9 @@ class TradeMonitor:
|
||||
if not market:
|
||||
return
|
||||
|
||||
poll_interval = self.settings.fetch_interval_seconds
|
||||
# Per-market interval (from tiered monitoring) or global default
|
||||
poll_intervals = getattr(self, '_market_poll_intervals', {})
|
||||
poll_interval = poll_intervals.get(market_id, self.settings.fetch_interval_seconds)
|
||||
# If we already have a last_ts for this market, it means the loop was
|
||||
# restarted (e.g. after a market list refresh) — skip the silent
|
||||
# first-poll window to avoid missing trades.
|
||||
@@ -955,7 +860,7 @@ class TradeMonitor:
|
||||
|
||||
# Create lock/semaphore inside event loop (avoids "attached to different loop" error)
|
||||
self._api_lock = asyncio.Lock()
|
||||
self._api_sem = asyncio.Semaphore(5) # max 5 concurrent API requests
|
||||
self._api_sem = asyncio.Semaphore(10) # max 10 concurrent API requests
|
||||
|
||||
logger.info(
|
||||
f"Starting parallel trade monitor "
|
||||
|
||||
Reference in New Issue
Block a user