Initial commit: Polymarket Whale Watcher

- Add trade monitoring and whale detection system
- Add LLM-powered trade analysis
- Add market data fetching from Polymarket API
- Include example environment configuration
- Add automated report generation

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
SII-leiyu
2026-01-07 15:41:05 +08:00
co-authored by Claude
commit 0254e2c01a
204 changed files with 21173 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Polymarket Whale Watcher - AI-powered whale trade detection and analysis."""
+4
View File
@@ -0,0 +1,4 @@
"""Configuration module."""
from .settings import Settings, get_settings
__all__ = ["Settings", "get_settings"]
+52
View File
@@ -0,0 +1,52 @@
"""Application settings and configuration."""
import os
from functools import lru_cache
from typing import Optional
from dotenv import load_dotenv
from pydantic import Field
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Gemini API
gemini_api_key: str = Field(default="", alias="GEMINI_API_KEY")
# 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")
# Whale Detection Settings
min_trade_size_usd: float = Field(default=1000.0, alias="MIN_TRADE_SIZE_USD")
min_price: float = Field(default=0.2, alias="MIN_PRICE")
max_price: float = Field(default=0.8, alias="MAX_PRICE")
# Monitoring Settings
fetch_interval_seconds: int = Field(default=5, alias="FETCH_INTERVAL_SECONDS")
trending_markets_limit: int = Field(default=50, alias="TRENDING_MARKETS_LIMIT")
# LLM Settings (Gemini)
llm_model: str = Field(default="gemini-3-pro-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")
# Logging
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
extra = "ignore"
@lru_cache
def get_settings() -> Settings:
"""Get cached settings instance."""
load_dotenv()
return Settings()
+297
View File
@@ -0,0 +1,297 @@
"""
Polymarket Whale Watcher - Main Entry Point
This bot monitors trending Polymarket markets for large (whale) trades
and generates AI-powered analysis reports to assist user decision-making.
Flow:
1. Fetch trending markets (by 24hr volume, excluding sports)
2. Monitor these markets for trades
3. Detect anomalous trades ($1,000+, price 0.2-0.8)
4. Generate analysis reports using LLM
5. Output reports for user review (no automatic trading)
"""
import asyncio
import os
import re
import signal
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
import typer
from src.config import get_settings
from src.services.market_fetcher import MarketFetcher
from src.services.trade_monitor import TradeMonitor
from src.services.llm_analyzer import LLMAnalyzer
from src.models.trade import WhaleTrade
from src.utils.logger import setup_logging, WhaleWatcherLogger
app = typer.Typer(help="Polymarket Whale Watcher - AI-powered whale trade analysis")
logger = WhaleWatcherLogger()
class WhaleWatcher:
"""Main whale watcher application."""
# Reports directory
REPORTS_DIR = Path(__file__).parent.parent / "reports"
def __init__(self):
self.settings = get_settings()
self.market_fetcher = MarketFetcher()
self.trade_monitor = TradeMonitor(on_whale_detected=self.on_whale_detected)
self.llm_analyzer = LLMAnalyzer()
self._running = False
self._refresh_interval = 300 # Refresh markets every 5 minutes
# Ensure reports directory exists
self.REPORTS_DIR.mkdir(parents=True, exist_ok=True)
def _sanitize_filename(self, text: str, max_length: int = 50) -> str:
"""Sanitize text for use in filename."""
# Remove special characters, keep alphanumeric and spaces
sanitized = re.sub(r'[^\w\s-]', '', text)
# Replace spaces with underscores
sanitized = re.sub(r'\s+', '_', sanitized)
# Truncate if too long
return sanitized[:max_length]
def _save_report(self, whale_trade: WhaleTrade, full_report: str) -> str:
"""
Save report to a markdown file.
Args:
whale_trade: The whale trade
full_report: The formatted report
Returns:
Path to the saved file
"""
trade = whale_trade.trade
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
market_name = self._sanitize_filename(whale_trade.market_question)
filename = f"{timestamp}_{trade.side}_{int(trade.usdc_size)}USD_{market_name}.md"
filepath = self.REPORTS_DIR / filename
with open(filepath, "w", encoding="utf-8") as f:
f.write(full_report)
return str(filepath)
async def on_whale_detected(self, whale_trade: WhaleTrade) -> None:
"""
Callback when a whale trade is detected.
Args:
whale_trade: The detected whale trade
"""
trade = whale_trade.trade
# Log detection
logger.whale_detected(
amount=trade.usdc_size,
side=trade.side,
price=trade.price,
market=whale_trade.market_question,
)
# Analyze with LLM
logger.info("Generating analysis report...")
decision = await self.llm_analyzer.analyze_whale_trade(whale_trade)
# Print the full report (includes analysis + decision summary)
full_report = self.llm_analyzer.format_full_report(whale_trade, decision)
print(full_report)
# Save report to file
filepath = self._save_report(whale_trade, full_report)
logger.info(f"Report saved to: {filepath}")
logger.separator()
async def refresh_markets(self) -> None:
"""Fetch and update the list of monitored markets."""
logger.info("Fetching trending markets...")
trending_markets = self.market_fetcher.get_trending_markets(
limit=self.settings.trending_markets_limit
)
if trending_markets:
self.trade_monitor.set_monitored_markets(trending_markets)
logger.info(f"Now monitoring {len(trending_markets)} trending markets")
else:
logger.error("Failed to fetch trending markets")
async def run(self) -> None:
"""Run the main whale watcher loop."""
self._running = True
# Initial market fetch
await self.refresh_markets()
# Log startup
logger.monitoring_started(
market_count=len(self.trade_monitor._monitored_markets),
interval=self.settings.fetch_interval_seconds,
min_trade_size=self.settings.min_trade_size_usd,
min_price=self.settings.min_price,
max_price=self.settings.max_price,
)
# Start monitoring and market refresh tasks
monitor_task = asyncio.create_task(self.trade_monitor.run())
refresh_task = asyncio.create_task(self._refresh_loop())
try:
await asyncio.gather(monitor_task, refresh_task)
except asyncio.CancelledError:
logger.info("Shutting down...")
finally:
self.trade_monitor.stop()
await self.trade_monitor.close()
async def _refresh_loop(self) -> None:
"""Periodically refresh the market list."""
while self._running:
await asyncio.sleep(self._refresh_interval)
if self._running:
await self.refresh_markets()
def stop(self) -> None:
"""Stop the whale watcher."""
self._running = False
self.trade_monitor.stop()
# Global instance for signal handling
_watcher: Optional[WhaleWatcher] = None
def signal_handler(signum, frame):
"""Handle shutdown signals."""
logger.info("Received shutdown signal...")
if _watcher:
_watcher.stop()
sys.exit(0)
@app.command()
def run(
debug: bool = typer.Option(False, "--debug", "-d", help="Enable debug logging"),
):
"""Start the whale watcher bot."""
global _watcher
# Setup logging
setup_logging("DEBUG" if debug else "INFO")
# Setup signal handlers
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Create and run watcher
_watcher = WhaleWatcher()
try:
asyncio.run(_watcher.run())
except KeyboardInterrupt:
logger.info("Interrupted by user")
finally:
logger.info("Whale watcher stopped")
@app.command()
def check_markets(
limit: int = typer.Option(10, "--limit", "-l", help="Number of markets to show"),
):
"""Check current trending markets."""
setup_logging("INFO")
fetcher = MarketFetcher()
markets = fetcher.get_trending_markets(limit=limit)
print(f"\n{'='*80}")
print(f"Top {len(markets)} Trending Markets by 24hr Volume")
print(f"{'='*80}\n")
for tm in markets:
m = tm.market
prices = ", ".join(
[f"{o}: {p:.2%}" for o, p in zip(m.outcomes, m.outcome_prices)]
)
print(f"#{tm.rank} | Vol24h: ${tm.volume_24hr:,.0f}")
print(f" Question: {m.question}")
print(f" Prices: {prices}")
print(f" ID: {m.id}")
print()
@app.command()
def test_analyze(
market_id: str = typer.Argument(..., help="Market ID to test analysis on"),
):
"""Test LLM analysis on a specific market (simulates a whale trade)."""
setup_logging("INFO")
fetcher = MarketFetcher()
market = fetcher.get_market_by_id(market_id)
if not market:
print(f"Market {market_id} not found")
raise typer.Exit(1)
# Create a simulated whale trade
from src.models.trade import TradeActivity, WhaleTrade
import time
fake_activity = TradeActivity(
transaction_hash="test_" + str(int(time.time())),
timestamp=int(time.time()),
condition_id=market.condition_id or "",
asset=market.clob_token_ids[0] if market.clob_token_ids else "",
side="BUY",
size=50000.0,
usdc_size=25000.0, # Simulated $25k trade
price=0.45, # Simulated price
outcome=market.outcomes[0] if market.outcomes else "",
outcome_index=0,
title=market.question,
)
whale_trade = WhaleTrade(
id=f"test_{market_id}",
trade=fake_activity,
market_id=market.id,
market_question=market.question,
market_description=market.description,
market_outcomes=market.outcomes,
market_outcome_prices=market.outcome_prices,
)
print(f"\nSimulating whale trade analysis for:")
print(f" Market: {market.question}")
print(f" Trade: $25,000 BUY @ 0.45")
print(f"\nAnalyzing with LLM...\n")
analyzer = LLMAnalyzer()
decision = asyncio.run(analyzer.analyze_whale_trade(whale_trade))
print(analyzer.format_decision_report(decision))
print("\nFull Analysis:")
print("-" * 60)
print(decision.analysis)
def main():
"""Entry point."""
app()
if __name__ == "__main__":
main()
+13
View File
@@ -0,0 +1,13 @@
"""Data models module."""
from .market import Market, TrendingMarket
from .trade import WhaleTrade, TradeActivity
from .decision import LLMDecision, TradeRecommendation
__all__ = [
"Market",
"TrendingMarket",
"WhaleTrade",
"TradeActivity",
"LLMDecision",
"TradeRecommendation",
]
+59
View File
@@ -0,0 +1,59 @@
"""LLM decision models."""
from datetime import datetime
from typing import Optional
from enum import Enum
from pydantic import BaseModel, Field
class TradeAction(str, Enum):
"""Recommended trade action."""
BUY = "BUY"
SELL = "SELL"
HOLD = "HOLD" # Do not trade
class TraderCredibility(str, Enum):
"""Trader credibility level based on leaderboard ranking."""
HIGH = "HIGH" # Top 100
MEDIUM = "MEDIUM" # 100-500
LOW = "LOW" # 500+
UNKNOWN = "UNKNOWN" # Not on leaderboard
class TradeRecommendation(BaseModel):
"""Trade recommendation from LLM."""
action: TradeAction
outcome: str # Which outcome to trade
confidence: float = Field(ge=0.0, le=1.0) # 0-1 confidence score
suggested_price: Optional[float] = None
suggested_size_percent: float = Field(default=0.1, ge=0.0, le=1.0) # % of balance
reasoning: str = ""
# Insider trading assessment fields
insider_trading_likelihood: float = Field(default=0.0, ge=0.0, le=1.0) # 0-1 likelihood
trader_credibility: TraderCredibility = TraderCredibility.UNKNOWN
insider_evidence: str = "" # Evidence supporting insider trading assessment
class LLMDecision(BaseModel):
"""Complete LLM decision for a whale trade."""
whale_trade_id: str
market_id: str
analysis: str # Full LLM analysis text
recommendation: TradeRecommendation
created_at: datetime = Field(default_factory=datetime.utcnow)
executed: bool = False
execution_result: Optional[str] = None
@property
def should_trade(self) -> bool:
"""Check if we should execute this trade."""
return (
self.recommendation.action != TradeAction.HOLD
and self.recommendation.confidence >= 0.6
)
+44
View File
@@ -0,0 +1,44 @@
"""Market data models."""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field
class Market(BaseModel):
"""Polymarket market data model."""
id: str
question: str
condition_id: Optional[str] = None
slug: Optional[str] = None
description: Optional[str] = None
end_date: Optional[str] = None
outcomes: List[str] = Field(default_factory=list)
outcome_prices: List[float] = Field(default_factory=list)
clob_token_ids: List[str] = Field(default_factory=list)
volume: float = 0.0
volume_24hr: float = 0.0
liquidity: float = 0.0
active: bool = True
closed: bool = False
neg_risk: bool = False
class TrendingMarket(BaseModel):
"""Trending market with additional metrics."""
market: Market
volume_24hr: float = 0.0
liquidity: float = 0.0
rank: int = 0
fetched_at: datetime = Field(default_factory=datetime.utcnow)
@property
def is_valid_for_monitoring(self) -> bool:
"""Check if market is valid for whale monitoring."""
return (
self.market.active
and not self.market.closed
and len(self.market.clob_token_ids) > 0
)
+156
View File
@@ -0,0 +1,156 @@
"""Trade data models."""
from datetime import datetime
from typing import Optional
from enum import Enum
from pydantic import BaseModel, Field
class TradeSide(str, Enum):
"""Trade side enum."""
BUY = "BUY"
SELL = "SELL"
class TradeActivity(BaseModel):
"""Raw trade activity from Polymarket API."""
transaction_hash: str
timestamp: int
condition_id: str
asset: str
side: str
size: float # Token size
usdc_size: float # USD value
price: float
outcome: str
outcome_index: int
title: str
slug: Optional[str] = None
event_slug: Optional[str] = None
proxy_wallet: Optional[str] = None
name: Optional[str] = None
class TraderRanking(BaseModel):
"""Trader ranking information from leaderboard."""
rank: Optional[int] = None # Position on leaderboard (None if not ranked)
pnl: Optional[float] = None # Profit/Loss
volume: Optional[float] = None # Trading volume
user_name: Optional[str] = None # Display name
profile_image: Optional[str] = None # Avatar URL
verified: bool = False # Verified badge
time_period: str = "ALL" # Time period for ranking
class TraderHistory(BaseModel):
"""Trader's recent trading history summary."""
total_trades: int = 0 # Total number of recent trades
total_volume: float = 0.0 # Total trading volume in USDC
avg_trade_size: float = 0.0 # Average trade size
win_rate: Optional[float] = None # Win rate if calculable
recent_markets: list[str] = Field(default_factory=list) # Recent markets traded
large_trades_count: int = 0 # Number of trades >= $5000
recent_trades: list[dict] = Field(default_factory=list) # Recent trade details
class WhaleTrade(BaseModel):
"""Whale trade that meets detection criteria."""
id: str = Field(default_factory=lambda: "")
trade: TradeActivity
market_id: str
market_question: str
market_description: Optional[str] = None
market_outcomes: list[str] = Field(default_factory=list)
market_outcome_prices: list[float] = Field(default_factory=list)
detected_at: datetime = Field(default_factory=datetime.utcnow)
processed: bool = False
llm_analyzed: bool = False
# Trader ranking info
trader_ranking: Optional[TraderRanking] = None
# Trader history info
trader_history: Optional[TraderHistory] = None
@property
def is_whale_trade(self) -> bool:
"""Check if this qualifies as a whale trade."""
return self.trade.usdc_size >= 10000
@property
def is_valid_price_range(self) -> bool:
"""Check if trade price is in valid range (0.2-0.8)."""
return 0.2 <= self.trade.price <= 0.8
def to_llm_context(self) -> str:
"""Generate context string for LLM analysis."""
# Format trader ranking info
trader_info = ""
if self.trader_ranking:
rank_str = f"#{self.trader_ranking.rank}" if self.trader_ranking.rank else "未上榜"
pnl_str = f"${self.trader_ranking.pnl:,.2f}" if self.trader_ranking.pnl else "N/A"
vol_str = f"${self.trader_ranking.volume:,.2f}" if self.trader_ranking.volume else "N/A"
verified_str = "✅ 已认证" if self.trader_ranking.verified else "未认证"
trader_info = f"""
### 交易者排名信息 (盈利排行榜)
- **排名**: {rank_str} (时间范围: {self.trader_ranking.time_period})
- **累计盈亏 (PnL)**: {pnl_str}
- **交易量**: {vol_str}
- **用户名**: {self.trader_ranking.user_name or 'Anonymous'}
- **认证状态**: {verified_str}
"""
else:
trader_info = """
### 交易者排名信息
- 该交易者不在盈利排行榜上(可能是新用户或小额交易者)
"""
# Format trader history info
history_info = ""
if self.trader_history:
history_info = f"""
### 交易者历史交易记录
- **近期交易总数**: {self.trader_history.total_trades}
- **近期交易总额**: ${self.trader_history.total_volume:,.2f} USDC
- **平均交易金额**: ${self.trader_history.avg_trade_size:,.2f} USDC
- **大额交易次数** (≥$5000): {self.trader_history.large_trades_count}
- **活跃市场**: {', '.join(self.trader_history.recent_markets[:5]) if self.trader_history.recent_markets else 'N/A'}
"""
# Add recent large trades details
if self.trader_history.recent_trades:
history_info += "\n**近期大额交易明细**:\n"
for i, t in enumerate(self.trader_history.recent_trades[:5], 1):
history_info += f" {i}. {t.get('side', 'N/A')} ${t.get('usdc_size', 0):,.2f} @ {t.get('price', 0):.4f} - {t.get('title', 'N/A')[:40]}...\n"
else:
history_info = """
### 交易者历史交易记录
- 无法获取该交易者的历史交易记录
"""
return f"""
## 异常交易检测
### 交易信息
- 交易金额: ${self.trade.usdc_size:,.2f} USDC
- 交易方向: {self.trade.side}
- 交易价格: {self.trade.price:.4f}
- 交易结果: {self.trade.outcome}
- 交易时间: {datetime.fromtimestamp(self.trade.timestamp).strftime('%Y-%m-%d %H:%M:%S')}
- 交易者钱包: {self.trade.proxy_wallet or 'Unknown'}
{trader_info}{history_info}
### 市场信息
- 市场问题: {self.market_question}
- 市场描述: {self.market_description or 'N/A'}
- 可能结果: {', '.join(self.market_outcomes)}
- 当前价格: {', '.join([f'{o}: {p:.4f}' for o, p in zip(self.market_outcomes, self.market_outcome_prices)])}
### 分析要点
1. 这笔大额交易 (${self.trade.usdc_size:,.2f}) 表明交易者对 "{self.trade.outcome}" 结果有很强的信心
2. 交易价格 {self.trade.price:.4f} 说明市场尚未形成明确共识
3. 交易方向为 {self.trade.side},可能暗示内部信息或深度分析结论
4. **交易者排名和历史交易是判断内幕交易可信度的重要参考** - 高排名、大额交易频繁的交易者通常有更好的信息来源或分析能力
"""
+4
View File
@@ -0,0 +1,4 @@
"""Prompts module."""
from .whale_analyzer import WhaleAnalyzerPrompts
__all__ = ["WhaleAnalyzerPrompts"]
+281
View File
@@ -0,0 +1,281 @@
"""Prompts for whale trade analysis."""
from typing import List
class WhaleAnalyzerPrompts:
"""Prompts for LLM whale trade analysis."""
@staticmethod
def system_prompt() -> str:
"""Get the system prompt for whale trade analysis."""
return """你是一位专业的预测市场分析师和内幕交易识别专家,专门分析 Polymarket 上的大额异常交易。
**你的核心任务**:验证一笔"疑似异常交易"是否真的是"内幕交易"(即交易者掌握了市场尚未反映的信息)。
## 你的工作流程
### 第一步:接收疑似异常交易信号
你会收到一笔被系统标记为"疑似异常"的交易,包含:
- 交易金额($5,000+的大额交易)
- 交易方向(BUY/SELL)和价格
- 交易者的排行榜排名和历史盈亏
- **交易者历史交易记录**(近期交易总数、交易总额、大额交易次数、活跃市场等)
### 第二步:获取市场信息
你会同时收到该交易对应的市场信息:
- 市场问题(预测的事件)
- 市场描述
- 当前各结果的价格/概率
### 第三步:使用 Google Search 验证(关键步骤!)
**你必须使用 Google 搜索来验证这笔交易是否基于真实信息:**
- 搜索与市场主题相关的最新新闻(过去24-72小时)
- 查找是否有尚未被市场完全反映的重要信息
- 验证交易者的判断是否有公开信息支持
- 寻找任何可能触发这笔交易的事件
### 第四步:综合判断并生成报告
结合所有信息,判断:
- 这笔交易是"真正的内幕交易"还是"普通大额交易"
- 给出内幕交易可能性评分(0-100%
- 提供跟单建议(BUY/SELL/HOLD
## 内幕交易识别框架
1. **交易者可信度(基于排名)**:
- 前100名 = HIGH(历史盈利能力强,信号可信度高)
- 100-500名 = MEDIUM(有一定实力,需验证)
- 500名+ = LOW(信号参考价值较低)
- 未上榜 = UNKNOWN(新手或小额交易者)
2. **交易者历史行为分析(重要!)**:
- **大额交易频率**:频繁进行大额交易的交易者更可能是专业玩家或内幕人士
- **交易总额**:高交易总额表明资金实力雄厚,信号更可信
- **活跃市场**:如果交易者在相关市场有多次交易,说明对该领域有深入研究
- **平均交易金额**:平均金额高说明是专业大户,不是偶然的一次性大单
- **近期大额交易明细**:查看其他大额交易的方向和结果,判断其判断力
3. **信息验证**
- 搜索是否有支持该交易方向的最新新闻
- 判断市场是否已经反映了这些信息
- 评估信息的时效性和可靠性
4. **综合判断标准**
- 高排名 + 频繁大额交易 + 有最新未反映信息 = 高度可疑内幕交易 (0.8+)
- 高排名 + 有历史记录 + 无明显信息 = 可能基于深度分析 (0.5-0.7)
- 低排名/未上榜 + 首次大额交易 + 无信息 = 普通投机交易 (<0.4)
- 未上榜但有大量历史交易记录 = 可能是隐藏的专业玩家,需要重点关注
**重要原则**
- **务必使用 Google Search!** 不要仅依赖你的历史知识
- **重视交易者历史记录!** 这是判断交易者专业性的关键依据
- 关注过去24-72小时的最新动态
- 如果搜索不到支持信息,内幕交易可能性应该降低
- 信心不足时建议观望(HOLD"""
@staticmethod
def analyze_whale_trade(trade_context: str) -> str:
"""
Get the prompt for analyzing a whale trade.
Args:
trade_context: Formatted trade context from AnomalyDetector
Returns:
Complete prompt for LLM
"""
return f"""{trade_context}
---
# 鲸鱼交易验证报告
你收到了一笔**疑似异常交易信号**,请按照以下步骤验证这是否是"真正的内幕交易"
---
## 第一步:Google 搜索验证(必须执行!)
**请立即使用 Google Search 搜索以下内容:**
1. 搜索该市场主题的最新新闻(过去24-72小时)
2. 搜索可能影响结果的关键人物/组织的最新动态
3. 搜索任何可能触发这笔交易的突发事件
**搜索结果摘要**
(请在此列出你搜索到的关键信息,包括来源和时间)
---
## 第二步:交易信号分析
### 2.1 交易者排名评估
- 交易者排名意味着什么?(HIGH/MEDIUM/LOW/UNKNOWN
- 其历史盈亏(PnL)表现如何?
- 交易量规模如何?
### 2.2 交易者历史行为分析(重要!)
根据提供的交易者历史交易记录,分析:
- **交易活跃度**:近期交易总数和交易总额说明什么?
- **大额交易习惯**:该交易者是否经常进行大额交易?大额交易次数有多少?
- **平均交易规模**:平均交易金额是多少?本次交易与其平均水平相比如何?
- **活跃市场领域**:交易者主要在哪些市场活跃?是否与本次交易的市场相关?
- **近期大额交易表现**:查看其他大额交易的方向,判断其整体判断力
### 2.3 交易时机分析
- 这笔交易发生的时间点是否异常?
- **结合搜索结果**:是否有近期新闻可能触发了这笔交易?
- 交易者是否可能掌握了市场尚未反映的信息?
---
## 第三步:市场信息验证
### 3.1 当前市场状态
- 市场价格是否已经反映了最新信息?
- 交易价格与当前市场价格的关系如何?
### 3.2 信息差分析
- **关键问题**:搜索到的最新信息是否支持这笔交易的方向?
- 这些信息是否已被市场完全定价?
- 如果存在信息差,幅度有多大?
---
## 第四步:内幕交易判定
### 4.1 内幕交易可能性评估
综合以上分析,判断这笔交易是:
- **真正的内幕交易**:交易者确实掌握了市场未反映的信息
- **深度分析交易**:交易者基于公开信息的深度分析
- **普通投机交易**:没有明显信息优势
### 4.2 关键证据
列出支持你判断的关键证据(来自搜索结果)
---
## 第五步:跟单风险提示
- 鲸鱼也可能犯错或有其他动机(对冲、试探等)
- 市场可能已经部分反映了该信息
- 搜索结果可能不完整
---
## 第六步:最终决策
基于以上分析,给出你的交易建议,并用以下JSON格式输出决策:
```json
{{
"action": "BUY/SELL/HOLD",
"outcome": "你建议交易的结果选项",
"confidence": 0.0-1.0之间的数字,
"insider_trading_likelihood": 0.0-1.0之间的数字(内幕交易可能性评估),
"trader_credibility": "HIGH/MEDIUM/LOW/UNKNOWN",
"suggested_price": 建议的交易价格,
"suggested_size_percent": 0.0-1.0之间的数字(建议使用资金的比例),
"reasoning": "简要说明你的推理过程",
"insider_evidence": "支持内幕交易判断的关键证据"
}}
```
注意:
- action为HOLD时,outcome可以为空字符串
- confidence低于0.6时应该选择HOLD
- suggested_size_percent不应超过0.220%的资金)
- insider_trading_likelihood: 0.7+表示高度可疑内幕交易,0.4-0.7为中等可能,<0.4为普通大额交易
- trader_credibility基于排行榜排名:前100=HIGH100-500=MEDIUM500+=LOW,未上榜=UNKNOWN
- 请确保输出的是有效的JSON格式
---
**免责声明**:本报告仅供参考,不构成投资建议。预测市场具有高风险,请用户基于自身判断谨慎决策。"""
@staticmethod
def superforecaster_prompt(question: str, description: str, outcomes: List[str]) -> str:
"""
Get superforecaster-style analysis prompt.
Args:
question: The market question
description: Market description
outcomes: Possible outcomes
Returns:
Superforecaster prompt
"""
outcomes_str = ", ".join(outcomes)
return f"""作为一名超级预测者,请对以下预测市场进行分析:
**问题**: {question}
**描述**: {description}
**可能结果**: {outcomes_str}
请使用以下系统性方法进行预测:
### 1. 问题分解
- 将问题分解为更小、更易管理的部分
- 识别回答问题需要解决的关键组成部分
### 2. 信息收集
- 考虑相关的定量数据和定性见解
- 思考最新的相关新闻和专家分析
### 3. 基础概率
- 使用统计基线或历史平均值作为起点
- 将当前情况与类似的历史事件进行比较
### 4. 因素评估
- 列出可能影响结果的因素
- 评估每个因素的影响,考虑正面和负面因素
- 使用证据权衡这些因素
### 5. 概率思维
- 用概率而非确定性表达预测
- 为不同结果分配可能性
- 承认不确定性
请为每个结果提供概率估计,确保所有概率之和为100%。
输出格式:
```json
{{
"analysis": "你的详细分析",
"probabilities": {{
"结果1": 0.XX,
"结果2": 0.XX
}},
"confidence_level": "low/medium/high",
"key_factors": ["因素1", "因素2", "因素3"]
}}
```"""
@staticmethod
def quick_decision_prompt(trade_summary: str) -> str:
"""
Get a quick decision prompt for time-sensitive situations.
Args:
trade_summary: Brief trade summary
Returns:
Quick decision prompt
"""
return f"""快速分析以下鲸鱼交易并给出建议:
{trade_summary}
请直接输出JSON格式的决策:
```json
{{
"action": "BUY/SELL/HOLD",
"outcome": "交易的结果选项",
"confidence": 0.0-1.0,
"reasoning": "一句话理由"
}}
```"""
+12
View File
@@ -0,0 +1,12 @@
"""Services module."""
from .market_fetcher import MarketFetcher
from .trade_monitor import TradeMonitor
from .anomaly_detector import AnomalyDetector
from .llm_analyzer import LLMAnalyzer
__all__ = [
"MarketFetcher",
"TradeMonitor",
"AnomalyDetector",
"LLMAnalyzer",
]
+252
View File
@@ -0,0 +1,252 @@
"""Anomaly detection service - filters and validates whale trades."""
import logging
from typing import List, Optional
from datetime import datetime, timedelta
from src.config import get_settings
from src.models.trade import WhaleTrade, TradeActivity
logger = logging.getLogger(__name__)
class AnomalyDetector:
"""
Detects anomalous (whale) trades based on configurable criteria.
Criteria:
- Trade size >= MIN_TRADE_SIZE_USD (default: $10,000)
- Trade price between MIN_PRICE and MAX_PRICE (default: 0.2-0.8)
"""
def __init__(self):
self.settings = get_settings()
def is_anomalous_trade(self, activity: TradeActivity) -> bool:
"""
Check if a trade is anomalous based on size and price.
Args:
activity: The trade activity to check
Returns:
True if the trade is anomalous
"""
# Check trade size
if activity.usdc_size < self.settings.min_trade_size_usd:
return False
# Check price range (0.2-0.8 means not too certain either way)
if not (self.settings.min_price <= activity.price <= self.settings.max_price):
return False
return True
def get_anomaly_score(self, activity: TradeActivity) -> float:
"""
Calculate an anomaly score for a trade.
Higher score = more interesting anomaly.
Args:
activity: The trade activity to score
Returns:
Anomaly score between 0 and 1
"""
if not self.is_anomalous_trade(activity):
return 0.0
score = 0.0
# Size component (bigger trades = higher score)
# $10k = 0.3, $50k = 0.5, $100k+ = 0.6
size_score = min(0.6, 0.3 + (activity.usdc_size - 10000) / 200000)
score += size_score
# Price component (closer to 0.5 = more uncertain = higher score)
# Price at 0.5 = 0.4, price at 0.2 or 0.8 = 0.2
price_distance_from_50 = abs(activity.price - 0.5)
price_score = 0.4 * (1 - price_distance_from_50 / 0.3)
score += max(0, price_score)
return min(1.0, score)
def filter_whale_trades(
self,
trades: List[WhaleTrade],
min_score: float = 0.5,
) -> List[WhaleTrade]:
"""
Filter whale trades by anomaly score.
Args:
trades: List of whale trades to filter
min_score: Minimum anomaly score to include
Returns:
Filtered list of whale trades
"""
filtered = []
for trade in trades:
score = self.get_anomaly_score(trade.trade)
if score >= min_score:
filtered.append(trade)
logger.debug(
f"Trade passed filter: ${trade.trade.usdc_size:,.2f} "
f"@ {trade.trade.price:.4f} (score: {score:.2f})"
)
return filtered
def analyze_trade_context(self, whale_trade: WhaleTrade) -> dict:
"""
Analyze the context of a whale trade for LLM input.
Args:
whale_trade: The whale trade to analyze
Returns:
Dictionary with analysis context
"""
trade = whale_trade.trade
# Determine trade direction interpretation
if trade.side == "BUY":
direction_meaning = f"The trader is betting FOR '{trade.outcome}' occurring"
else:
direction_meaning = f"The trader is betting AGAINST '{trade.outcome}' occurring"
# Calculate implied probability from price
implied_prob = trade.price if trade.side == "BUY" else (1 - trade.price)
# Assess market state from outcome prices
market_state = "uncertain"
if whale_trade.market_outcome_prices:
max_price = max(whale_trade.market_outcome_prices)
if max_price > 0.7:
market_state = "leaning towards one outcome"
elif max_price < 0.6:
market_state = "highly uncertain"
# Calculate conviction level based on size
conviction = "moderate"
if trade.usdc_size >= 50000:
conviction = "very high"
elif trade.usdc_size >= 25000:
conviction = "high"
return {
"trade_size_usd": trade.usdc_size,
"trade_side": trade.side,
"trade_price": trade.price,
"trade_outcome": trade.outcome,
"direction_meaning": direction_meaning,
"implied_probability": implied_prob,
"market_state": market_state,
"conviction_level": conviction,
"anomaly_score": self.get_anomaly_score(trade),
"market_question": whale_trade.market_question,
"market_outcomes": whale_trade.market_outcomes,
"current_prices": whale_trade.market_outcome_prices,
}
def format_for_llm(self, whale_trade: WhaleTrade) -> str:
"""
Format whale trade data for LLM analysis.
Args:
whale_trade: The whale trade to format
Returns:
Formatted string for LLM input
"""
context = self.analyze_trade_context(whale_trade)
trade = whale_trade.trade
# Build outcome prices string
prices_str = ""
for i, (outcome, price) in enumerate(
zip(context["market_outcomes"], context["current_prices"])
):
prices_str += f" - {outcome}: {price:.2%}\n"
# Build trader ranking info
ranking_str = ""
if whale_trade.trader_ranking:
rank = whale_trade.trader_ranking
rank_display = f"#{rank.rank}" if rank.rank else "未上榜"
pnl_display = f"${rank.pnl:,.2f}" if rank.pnl else "N/A"
vol_display = f"${rank.volume:,.2f}" if rank.volume else "N/A"
verified_display = "✅ 已认证" if rank.verified else "未认证"
ranking_str = f"""
### 交易者排名信息(盈利排行榜)
- **排名**: {rank_display} (时间范围: {rank.time_period})
- **累计盈亏 (PnL)**: {pnl_display}
- **总交易量**: {vol_display}
- **用户名**: {rank.user_name or 'Anonymous'}
- **认证状态**: {verified_display}
"""
else:
ranking_str = """
### 交易者排名信息
- 该交易者不在盈利排行榜上(可能是新用户或小额交易者)
"""
# Build trader history info
history_str = ""
if whale_trade.trader_history:
hist = whale_trade.trader_history
history_str = f"""
### 交易者历史交易记录(重要!)
- **近期交易总数**: {hist.total_trades}
- **近期交易总额**: ${hist.total_volume:,.2f} USDC
- **平均交易金额**: ${hist.avg_trade_size:,.2f} USDC
- **大额交易次数** (≥$5000): {hist.large_trades_count}
- **活跃市场**: {', '.join(hist.recent_markets[:5]) if hist.recent_markets else 'N/A'}
"""
# Add recent large trades details
if hist.recent_trades:
history_str += "\n**近期大额交易明细**:\n"
for i, t in enumerate(hist.recent_trades[:5], 1):
title = t.get('title', 'N/A')
if len(title) > 40:
title = title[:40] + "..."
history_str += f" {i}. {t.get('side', 'N/A')} ${t.get('usdc_size', 0):,.2f} @ {t.get('price', 0):.4f} - {title}\n"
else:
history_str = """
### 交易者历史交易记录
- 无法获取该交易者的历史交易记录
"""
return f"""
## 大额交易异常检测报告
### 交易详情
- **交易金额**: ${context['trade_size_usd']:,.2f} USDC
- **交易方向**: {context['trade_side']}
- **交易价格**: {context['trade_price']:.4f} ({context['trade_price']:.2%})
- **交易结果**: {context['trade_outcome']}
- **交易时间**: {datetime.fromtimestamp(trade.timestamp).strftime('%Y-%m-%d %H:%M:%S')}
- **交易者钱包**: {trade.proxy_wallet or 'Unknown'}
- **异常评分**: {context['anomaly_score']:.2f}/1.00
### 交易解读
- **方向含义**: {context['direction_meaning']}
- **隐含概率**: 交易者认为结果发生的概率约为 {context['implied_probability']:.2%}
- **信心程度**: {context['conviction_level']}
{ranking_str}{history_str}
### 市场状态
- **市场问题**: {context['market_question']}
- **市场状态**: {context['market_state']}
- **当前赔率**:
{prices_str}
### 分析要点
1. 这是一笔 ${context['trade_size_usd']:,.2f} 的大额交易,表明交易者有{context['conviction_level']}的信心
2. 交易价格 {context['trade_price']:.4f} 说明市场尚未形成明确共识
3. {context['direction_meaning']}
4. **请重点分析交易者的排名和历史交易记录,判断其专业性和可信度**
5. 这可能暗示交易者掌握了某些市场尚未充分反映的信息
请分析这笔交易并给出你的交易建议。
"""
+299
View File
@@ -0,0 +1,299 @@
"""LLM analyzer service - analyzes whale trades using AI."""
import json
import logging
import os
import re
from datetime import datetime
from typing import Optional
from google import genai
from google.genai import types
from src.config import get_settings
from src.models.trade import WhaleTrade
from src.models.decision import LLMDecision, TradeRecommendation, TradeAction, TraderCredibility
from src.services.anomaly_detector import AnomalyDetector
from src.prompts.whale_analyzer import WhaleAnalyzerPrompts
logger = logging.getLogger(__name__)
class LLMAnalyzer:
"""
Analyzes whale trades using LLM (Google Gemini models).
Combines trade context with superforecaster methodology to generate
comprehensive analysis reports with trading recommendations.
"""
def __init__(self):
self.settings = get_settings()
# Configure Gemini API using new client SDK
os.environ["GOOGLE_API_KEY"] = self.settings.gemini_api_key
self.client = genai.Client()
self.anomaly_detector = AnomalyDetector()
self.prompts = WhaleAnalyzerPrompts()
def _extract_json_from_response(self, response: str) -> Optional[dict]:
"""
Extract JSON from LLM response.
Args:
response: The LLM response text
Returns:
Parsed JSON dict or None
"""
# Try to find JSON in code blocks
json_pattern = r"```(?:json)?\s*([\s\S]*?)```"
matches = re.findall(json_pattern, response)
for match in matches:
try:
return json.loads(match.strip())
except json.JSONDecodeError:
continue
# Try to find raw JSON
try:
# Find JSON-like content
start = response.find("{")
end = response.rfind("}") + 1
if start >= 0 and end > start:
return json.loads(response[start:end])
except json.JSONDecodeError:
pass
return None
def _parse_recommendation(self, json_data: dict) -> TradeRecommendation:
"""
Parse JSON data into TradeRecommendation.
Args:
json_data: Parsed JSON from LLM
Returns:
TradeRecommendation object
"""
action_str = json_data.get("action", "HOLD").upper()
try:
action = TradeAction(action_str)
except ValueError:
action = TradeAction.HOLD
confidence = float(json_data.get("confidence", 0.0))
# Clamp confidence to valid range
confidence = max(0.0, min(1.0, confidence))
suggested_price = json_data.get("suggested_price")
if suggested_price is not None:
suggested_price = float(suggested_price)
suggested_size = float(json_data.get("suggested_size_percent", 0.1))
suggested_size = max(0.0, min(1.0, suggested_size))
# Parse insider trading assessment fields
insider_likelihood = float(json_data.get("insider_trading_likelihood", 0.0))
insider_likelihood = max(0.0, min(1.0, insider_likelihood))
credibility_str = json_data.get("trader_credibility", "UNKNOWN").upper()
try:
trader_credibility = TraderCredibility(credibility_str)
except ValueError:
trader_credibility = TraderCredibility.UNKNOWN
insider_evidence = str(json_data.get("insider_evidence", ""))
return TradeRecommendation(
action=action,
outcome=str(json_data.get("outcome", "")),
confidence=confidence,
suggested_price=suggested_price,
suggested_size_percent=suggested_size,
reasoning=str(json_data.get("reasoning", "")),
insider_trading_likelihood=insider_likelihood,
trader_credibility=trader_credibility,
insider_evidence=insider_evidence,
)
async def analyze_whale_trade(self, whale_trade: WhaleTrade) -> LLMDecision:
"""
Analyze a whale trade using LLM.
Args:
whale_trade: The whale trade to analyze
Returns:
LLMDecision with analysis and recommendation
"""
# Format trade context for LLM
trade_context = self.anomaly_detector.format_for_llm(whale_trade)
# Build prompt (Gemini uses single prompt with system instruction)
system_prompt = self.prompts.system_prompt()
user_prompt = self.prompts.analyze_whale_trade(trade_context)
full_prompt = f"{system_prompt}\n\n---\n\n{user_prompt}"
try:
# Call Gemini API with Google Search tool enabled
response = self.client.models.generate_content(
model=self.settings.llm_model,
contents=full_prompt,
config=types.GenerateContentConfig(
tools=[types.Tool(google_search=types.GoogleSearch())],
),
)
analysis_text = response.text
logger.debug(f"LLM response: {analysis_text[:500]}...")
# Extract JSON from response
json_data = self._extract_json_from_response(analysis_text)
if json_data:
recommendation = self._parse_recommendation(json_data)
else:
# Default to HOLD if we can't parse the response
logger.warning("Could not parse LLM response as JSON, defaulting to HOLD")
recommendation = TradeRecommendation(
action=TradeAction.HOLD,
outcome="",
confidence=0.0,
reasoning="Failed to parse LLM response",
)
return LLMDecision(
whale_trade_id=whale_trade.id,
market_id=whale_trade.market_id,
analysis=analysis_text,
recommendation=recommendation,
)
except Exception as e:
logger.error(f"Error calling LLM: {e}")
# Return a safe default decision
return LLMDecision(
whale_trade_id=whale_trade.id,
market_id=whale_trade.market_id,
analysis=f"Error during analysis: {str(e)}",
recommendation=TradeRecommendation(
action=TradeAction.HOLD,
outcome="",
confidence=0.0,
reasoning=f"Analysis failed: {str(e)}",
),
)
def format_full_report(self, whale_trade: WhaleTrade, decision: LLMDecision) -> str:
"""
Format a complete analysis report with trade info, analysis, and decision.
Args:
whale_trade: The whale trade
decision: The LLM decision
Returns:
Formatted report string
"""
trade = whale_trade.trade
rec = decision.recommendation
# Format outcome prices
prices_str = ""
if whale_trade.market_outcomes and whale_trade.market_outcome_prices:
prices_str = " | ".join([
f"{o}: {p:.1%}"
for o, p in zip(whale_trade.market_outcomes, whale_trade.market_outcome_prices)
])
# Action emoji and color indicator
action_indicator = {
TradeAction.BUY: "🟢 BUY",
TradeAction.SELL: "🔴 SELL",
TradeAction.HOLD: "⚪ HOLD",
}
# Insider trading likelihood indicator
insider_likelihood = rec.insider_trading_likelihood
if insider_likelihood >= 0.7:
insider_indicator = f"🔴 高度可疑 ({insider_likelihood:.0%})"
elif insider_likelihood >= 0.4:
insider_indicator = f"🟡 中等可能 ({insider_likelihood:.0%})"
else:
insider_indicator = f"🟢 普通交易 ({insider_likelihood:.0%})"
# Trader credibility indicator
credibility_indicators = {
TraderCredibility.HIGH: "🏆 高可信度 (前100名)",
TraderCredibility.MEDIUM: "⭐ 中等可信度 (100-500名)",
TraderCredibility.LOW: "📉 低可信度 (500名+)",
TraderCredibility.UNKNOWN: "❓ 未知 (未上榜)",
}
credibility_str = credibility_indicators.get(rec.trader_credibility, "❓ 未知")
# Trader ranking info
trader_ranking_str = ""
if whale_trade.trader_ranking:
tr = whale_trade.trader_ranking
rank_str = f"#{tr.rank}" if tr.rank else "未上榜"
pnl_str = f"${tr.pnl:,.2f}" if tr.pnl else "N/A"
trader_ranking_str = f"| **交易者排名** | {rank_str} (PnL: {pnl_str}) |"
report = f"""
{'='*70}
# 🐋 鲸鱼交易分析报告
{'='*70}
**生成时间**: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} UTC
## 交易摘要
| 项目 | 详情 |
|------|------|
| **市场** | {whale_trade.market_question} |
| **交易金额** | ${trade.usdc_size:,.2f} USDC |
| **交易方向** | {trade.side} |
| **交易价格** | {trade.price:.4f} ({trade.price:.1%}) |
| **交易结果** | {trade.outcome} |
| **当前赔率** | {prices_str} |
| **交易时间** | {datetime.fromtimestamp(trade.timestamp).strftime('%Y-%m-%d %H:%M:%S') if trade.timestamp else 'N/A'} |
{trader_ranking_str}
{'='*70}
{decision.analysis}
{'='*70}
## 🔍 内幕交易评估
{'='*70}
| 项目 | 评估 |
|------|------|
| **内幕交易可能性** | {insider_indicator} |
| **交易者可信度** | {credibility_str} |
**关键证据**: {rec.insider_evidence or '无明确证据'}
{'='*70}
## 📊 决策摘要
{'='*70}
| 项目 | 建议 |
|------|------|
| **操作建议** | {action_indicator.get(rec.action, '⚪ HOLD')} |
| **目标结果** | {rec.outcome or 'N/A'} |
| **信心程度** | {rec.confidence:.1%} |
| **建议仓位** | {rec.suggested_size_percent:.1%} |
| **建议价格** | {f'{rec.suggested_price:.4f}' if rec.suggested_price else 'Market'} |
**决策理由**: {rec.reasoning}
{'='*70}
⚠️ 免责声明:本报告由AI生成,仅供参考,不构成投资建议。
预测市场具有高风险,请基于自身判断谨慎决策。
{'='*70}
"""
return report
+301
View File
@@ -0,0 +1,301 @@
"""Market fetching service - fetches trending markets from Polymarket."""
import json
import logging
from typing import List, Optional
import httpx
from src.config import get_settings
from src.models.market import Market, TrendingMarket
logger = logging.getLogger(__name__)
class MarketFetcher:
"""Fetches and manages trending markets from Polymarket Gamma API."""
# Sports-related keywords to filter out (case-insensitive)
SPORTS_KEYWORDS = [
# General sports terms
"nba", "nfl", "mlb", "nhl", "mls", "ufc", "wwe", "pga", "atp", "wta",
"fifa", "uefa", "epl", "premier league", "la liga", "serie a", "bundesliga",
"champions league", "world cup", "olympics", "olympic",
# Sports names
"basketball", "football", "soccer", "baseball", "hockey", "tennis",
"golf", "boxing", "mma", "wrestling", "cricket", "rugby", "f1", "formula 1",
"nascar", "racing", "motorsport",
# Team/game terms
"game", "match", "vs", "versus", "playoff", "playoffs", "finals",
"championship", "tournament", "season", "super bowl", "world series",
# Player/team actions
"score", "points", "goals", "touchdowns", "wins", "win against",
"beat", "defeat",
# Specific sports betting terms
"mvp", "rookie", "all-star", "draft", "trade",
# Common sports team cities/names patterns
"lakers", "celtics", "warriors", "bulls", "heat", "knicks",
"yankees", "dodgers", "red sox", "cubs", "mets",
"cowboys", "patriots", "chiefs", "eagles", "49ers",
"manchester", "barcelona", "real madrid", "liverpool", "chelsea",
]
def __init__(self):
self.settings = get_settings()
self.gamma_url = "https://gamma-api.polymarket.com"
self.markets_endpoint = f"{self.gamma_url}/markets"
self.events_endpoint = f"{self.gamma_url}/events"
self._client = httpx.Client(timeout=30.0)
def __del__(self):
"""Cleanup HTTP client."""
if hasattr(self, "_client"):
self._client.close()
def _is_sports_market(self, market_data: dict) -> bool:
"""
Check if a market is sports-related.
Args:
market_data: Raw market data from API
Returns:
True if the market is sports-related
"""
# Check question and description
question = (market_data.get("question") or "").lower()
description = (market_data.get("description") or "").lower()
slug = (market_data.get("slug") or "").lower()
text_to_check = f"{question} {description} {slug}"
for keyword in self.SPORTS_KEYWORDS:
if keyword in text_to_check:
return True
return False
def _parse_market(self, data: dict) -> Optional[Market]:
"""Parse raw market data into Market model."""
try:
# Parse outcome prices (comes as stringified list)
outcome_prices = data.get("outcomePrices", [])
if isinstance(outcome_prices, str):
outcome_prices = json.loads(outcome_prices)
outcome_prices = [float(p) for p in outcome_prices]
# Parse clob token IDs
clob_token_ids = data.get("clobTokenIds", [])
if isinstance(clob_token_ids, str):
clob_token_ids = json.loads(clob_token_ids)
# Parse outcomes
outcomes = data.get("outcomes", [])
if isinstance(outcomes, str):
outcomes = json.loads(outcomes)
return Market(
id=str(data.get("id", "")),
question=data.get("question", ""),
condition_id=data.get("conditionId"),
slug=data.get("slug"),
description=data.get("description"),
end_date=data.get("endDate"),
outcomes=outcomes,
outcome_prices=outcome_prices,
clob_token_ids=clob_token_ids,
volume=float(data.get("volume", 0) or 0),
volume_24hr=float(data.get("volume24hr", 0) or 0),
liquidity=float(data.get("liquidity", 0) or 0),
active=data.get("active", False),
closed=data.get("closed", False),
neg_risk=data.get("negRisk", False),
)
except Exception as e:
logger.warning(f"Failed to parse market {data.get('id')}: {e}")
return None
def get_trending_markets(self, limit: Optional[int] = None) -> List[TrendingMarket]:
"""
Fetch trending markets sorted by 24-hour volume.
Filters out sports-related markets since they lack fundamental analysis value.
Args:
limit: Maximum number of non-sports markets to return (defaults to settings)
Returns:
List of TrendingMarket objects (excluding sports markets)
"""
limit = limit or self.settings.trending_markets_limit
trending_markets = []
offset = 0
batch_size = 100 # Fetch more to account for sports filtering
max_iterations = 10 # Safety limit to prevent infinite loops
try:
iteration = 0
while len(trending_markets) < limit and iteration < max_iterations:
iteration += 1
# Fetch active markets sorted by volume
params = {
"active": True,
"closed": False,
"archived": False,
"limit": batch_size,
"offset": offset,
"order": "volume24hr",
"ascending": False,
"enableOrderBook": True, # Only markets with CLOB enabled
}
response = self._client.get(self.markets_endpoint, params=params)
response.raise_for_status()
data = response.json()
if not data:
break # No more markets
sports_count = 0
for market_data in data:
# Skip sports markets
if self._is_sports_market(market_data):
sports_count += 1
continue
market = self._parse_market(market_data)
if market:
trending_market = TrendingMarket(
market=market,
volume_24hr=market.volume_24hr,
liquidity=market.liquidity,
rank=len(trending_markets) + 1,
)
if trending_market.is_valid_for_monitoring:
trending_markets.append(trending_market)
if len(trending_markets) >= limit:
break
logger.debug(
f"Batch {iteration}: fetched {len(data)}, "
f"filtered {sports_count} sports markets, "
f"total non-sports: {len(trending_markets)}"
)
if len(data) < batch_size:
break # No more markets available
offset += batch_size
logger.info(
f"Fetched {len(trending_markets)} trending markets (sports markets filtered out)"
)
return trending_markets
except httpx.HTTPError as e:
logger.error(f"HTTP error fetching trending markets: {e}")
return trending_markets # Return what we have so far
except Exception as e:
logger.error(f"Error fetching trending markets: {e}")
return trending_markets
def get_market_by_id(self, market_id: str) -> Optional[Market]:
"""
Fetch a single market by ID.
Args:
market_id: The market ID
Returns:
Market object or None
"""
try:
url = f"{self.markets_endpoint}/{market_id}"
response = self._client.get(url)
response.raise_for_status()
data = response.json()
return self._parse_market(data)
except httpx.HTTPError as e:
logger.error(f"HTTP error fetching market {market_id}: {e}")
return None
except Exception as e:
logger.error(f"Error fetching market {market_id}: {e}")
return None
def get_market_by_condition_id(self, condition_id: str) -> Optional[Market]:
"""
Fetch a market by condition ID.
Args:
condition_id: The condition ID
Returns:
Market object or None
"""
try:
params = {"conditionId": condition_id}
response = self._client.get(self.markets_endpoint, params=params)
response.raise_for_status()
data = response.json()
if data and len(data) > 0:
return self._parse_market(data[0])
return None
except httpx.HTTPError as e:
logger.error(f"HTTP error fetching market by condition {condition_id}: {e}")
return None
except Exception as e:
logger.error(f"Error fetching market by condition {condition_id}: {e}")
return None
def get_all_current_markets(self, batch_size: int = 100) -> List[Market]:
"""
Fetch all current active markets (paginated).
Args:
batch_size: Number of markets per request
Returns:
List of all active Market objects
"""
all_markets = []
offset = 0
while True:
try:
params = {
"active": True,
"closed": False,
"archived": False,
"limit": batch_size,
"offset": offset,
"enableOrderBook": True,
}
response = self._client.get(self.markets_endpoint, params=params)
response.raise_for_status()
data = response.json()
if not data:
break
for market_data in data:
market = self._parse_market(market_data)
if market:
all_markets.append(market)
if len(data) < batch_size:
break
offset += batch_size
except Exception as e:
logger.error(f"Error fetching markets at offset {offset}: {e}")
break
logger.info(f"Fetched {len(all_markets)} total active markets")
return all_markets
+475
View File
@@ -0,0 +1,475 @@
"""Trade monitoring service - monitors markets for whale trades."""
import asyncio
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Set, Callable, Awaitable
import httpx
from src.config import get_settings
from src.models.market import Market, TrendingMarket
from src.models.trade import TradeActivity, WhaleTrade, TraderRanking, TraderHistory
logger = logging.getLogger(__name__)
# File to persist processed transaction hashes
PROCESSED_TXNS_FILE = Path(__file__).parent.parent.parent / "data" / "processed_transactions.json"
class TradeMonitor:
"""
Monitors Polymarket markets for large trades.
Similar to copy-trading-bot's tradeMonitor, but monitors markets instead of users.
"""
def __init__(
self,
on_whale_detected: Optional[Callable[[WhaleTrade], Awaitable[None]]] = None,
):
"""
Initialize trade monitor.
Args:
on_whale_detected: Async callback when whale trade is detected
"""
self.settings = get_settings()
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)
# Cache for trader rankings to avoid repeated API calls
self._trader_ranking_cache: Dict[str, TraderRanking] = {}
# Markets being monitored: condition_id -> Market
self._monitored_markets: Dict[str, Market] = {}
# Track processed transactions to avoid duplicates
self._processed_txns: Set[str] = set()
# Load previously processed transactions from file
self._load_processed_txns()
# Callback for whale detection
self._on_whale_detected = on_whale_detected
# Control flag
self._running = False
# Flag to track if initial scan is complete (ignore historical trades)
self._initial_scan_complete = False
def _load_processed_txns(self):
"""Load processed transaction hashes from JSON file."""
try:
if PROCESSED_TXNS_FILE.exists():
with open(PROCESSED_TXNS_FILE, "r") as f:
data = json.load(f)
self._processed_txns = set(data.get("transactions", []))
logger.info(f"Loaded {len(self._processed_txns)} processed transactions from file")
except Exception as e:
logger.warning(f"Failed to load processed transactions: {e}")
self._processed_txns = set()
def _save_processed_txns(self):
"""Save processed transaction hashes to JSON file."""
try:
# Ensure directory exists
PROCESSED_TXNS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(PROCESSED_TXNS_FILE, "w") as f:
json.dump({
"transactions": list(self._processed_txns),
"count": len(self._processed_txns),
"last_updated": datetime.now().isoformat()
}, f, indent=2)
logger.debug(f"Saved {len(self._processed_txns)} processed transactions to file")
except Exception as e:
logger.warning(f"Failed to save processed transactions: {e}")
async def close(self):
"""Cleanup resources."""
# Save processed transactions before closing
self._save_processed_txns()
await self._client.aclose()
def set_monitored_markets(self, markets: List[TrendingMarket]):
"""
Update the list of markets to monitor.
Args:
markets: List of trending markets to monitor
"""
self._monitored_markets = {}
for tm in markets:
if tm.market.condition_id:
self._monitored_markets[tm.market.condition_id] = tm.market
logger.info(f"Now monitoring {len(self._monitored_markets)} markets")
async def fetch_market_trades(self, condition_id: str) -> List[TradeActivity]:
"""
Fetch recent trades for a market using the /trades endpoint.
This endpoint allows querying by market without requiring a user address.
Args:
condition_id: The market condition ID
Returns:
List of trade activities
"""
try:
# Use /trades endpoint which supports market-based queries
# Docs: https://docs.polymarket.com/api-reference/core/get-trades-for-a-user-or-markets
params = {
"market": condition_id,
"limit": 500,
}
response = await self._client.get(self.trades_endpoint, params=params)
response.raise_for_status()
data = response.json()
activities = []
for item in data:
try:
# Calculate USDC size from price and size
size = float(item.get("size", 0) or 0)
price = float(item.get("price", 0) or 0)
usdc_size = float(item.get("usdcSize", 0) or 0)
# If usdcSize not provided, calculate it
if usdc_size == 0 and size > 0 and price > 0:
usdc_size = size * price
activity = TradeActivity(
transaction_hash=item.get("transactionHash", item.get("id", "")),
timestamp=item.get("timestamp", 0),
condition_id=item.get("conditionId", condition_id),
asset=item.get("asset", item.get("tokenId", "")),
side=item.get("side", ""),
size=size,
usdc_size=usdc_size,
price=price,
outcome=item.get("outcome", ""),
outcome_index=int(item.get("outcomeIndex", 0) or 0),
title=item.get("title", item.get("marketTitle", "")),
slug=item.get("slug", item.get("marketSlug")),
event_slug=item.get("eventSlug"),
proxy_wallet=item.get("proxyWallet", item.get("maker", item.get("taker"))),
name=item.get("name"),
)
activities.append(activity)
except Exception as e:
logger.debug(f"Failed to parse trade: {e}")
continue
return activities
except httpx.HTTPError as e:
logger.warning(f"HTTP error fetching trades for {condition_id}: {e}")
return []
except Exception as e:
logger.warning(f"Error fetching trades for {condition_id}: {e}")
return []
async def fetch_trader_ranking(self, wallet_address: str) -> Optional[TraderRanking]:
"""
Fetch trader ranking from the leaderboard API.
Args:
wallet_address: The trader's wallet address
Returns:
TraderRanking or None if not found/error
"""
if not wallet_address:
return None
# Check cache first
if wallet_address in self._trader_ranking_cache:
return self._trader_ranking_cache[wallet_address]
try:
# Query leaderboard for this specific user (ALL time period for overall ranking)
params = {
"user": wallet_address,
"timePeriod": "ALL",
"orderBy": "PNL",
}
response = await self._client.get(self.leaderboard_endpoint, params=params)
response.raise_for_status()
data = response.json()
if data and len(data) > 0:
user_data = data[0]
ranking = TraderRanking(
rank=user_data.get("rank"),
pnl=float(user_data.get("pnl", 0) or 0),
volume=float(user_data.get("vol", 0) or 0),
user_name=user_data.get("userName"),
profile_image=user_data.get("profileImage"),
verified=bool(user_data.get("verifiedBadge")),
time_period="ALL",
)
# Cache the result
self._trader_ranking_cache[wallet_address] = ranking
logger.debug(f"Fetched ranking for {wallet_address}: #{ranking.rank}")
return ranking
# User not on leaderboard
return None
except httpx.HTTPError as e:
logger.debug(f"HTTP error fetching ranking for {wallet_address}: {e}")
return None
except Exception as e:
logger.debug(f"Error fetching ranking for {wallet_address}: {e}")
return None
async def fetch_trader_history(self, wallet_address: str) -> Optional[TraderHistory]:
"""
Fetch trader's recent trading history.
Args:
wallet_address: The trader's wallet address
Returns:
TraderHistory or None if not found/error
"""
if not wallet_address:
return None
try:
# Fetch recent trades for this user
params = {
"user": wallet_address,
"limit": 100, # Get last 100 trades
}
response = await self._client.get(self.trades_endpoint, params=params)
response.raise_for_status()
data = response.json()
if not data:
return None
# Calculate statistics
total_trades = len(data)
total_volume = 0.0
large_trades_count = 0
recent_markets = set()
recent_trades = []
for trade in data:
usdc_size = float(trade.get("usdcSize", 0) or 0)
if usdc_size == 0:
size = float(trade.get("size", 0) or 0)
price = float(trade.get("price", 0) or 0)
usdc_size = size * price
total_volume += usdc_size
if usdc_size >= 5000:
large_trades_count += 1
recent_trades.append({
"side": trade.get("side", ""),
"usdc_size": usdc_size,
"price": float(trade.get("price", 0) or 0),
"title": trade.get("title", trade.get("marketTitle", "")),
"timestamp": trade.get("timestamp", 0),
})
title = trade.get("title", trade.get("marketTitle", ""))
if title:
recent_markets.add(title[:50])
avg_trade_size = total_volume / total_trades if total_trades > 0 else 0
# Sort recent trades by size (largest first)
recent_trades.sort(key=lambda x: x["usdc_size"], reverse=True)
history = TraderHistory(
total_trades=total_trades,
total_volume=total_volume,
avg_trade_size=avg_trade_size,
large_trades_count=large_trades_count,
recent_markets=list(recent_markets)[:10],
recent_trades=recent_trades[:10],
)
logger.debug(f"Fetched history for {wallet_address}: {total_trades} trades, ${total_volume:,.2f} volume")
return history
except httpx.HTTPError as e:
logger.debug(f"HTTP error fetching history for {wallet_address}: {e}")
return None
except Exception as e:
logger.debug(f"Error fetching history for {wallet_address}: {e}")
return None
def _is_whale_trade(self, activity: TradeActivity) -> bool:
"""
Check if a trade qualifies as a whale trade.
Args:
activity: The trade activity to check
Returns:
True if this is a whale trade
"""
return (
activity.usdc_size >= self.settings.min_trade_size_usd
and self.settings.min_price <= activity.price <= self.settings.max_price
)
async def _check_market(self, condition_id: str, market: Market) -> List[WhaleTrade]:
"""
Check a single market for whale trades.
Args:
condition_id: Market condition ID
market: Market object
Returns:
List of detected whale trades
"""
whale_trades = []
activities = await self.fetch_market_trades(condition_id)
for activity in activities:
# Skip if already processed
if activity.transaction_hash in self._processed_txns:
continue
# Mark as processed
self._processed_txns.add(activity.transaction_hash)
# Skip during initial scan (only record historical transactions)
if not self._initial_scan_complete:
continue
# Check if it's a whale trade
if self._is_whale_trade(activity):
# Fetch trader ranking and history concurrently
trader_ranking, trader_history = await asyncio.gather(
self.fetch_trader_ranking(activity.proxy_wallet),
self.fetch_trader_history(activity.proxy_wallet),
)
whale_trade = WhaleTrade(
id=f"{condition_id}_{activity.transaction_hash}",
trade=activity,
market_id=market.id,
market_question=market.question,
market_description=market.description,
market_outcomes=market.outcomes,
market_outcome_prices=market.outcome_prices,
trader_ranking=trader_ranking,
trader_history=trader_history,
)
whale_trades.append(whale_trade)
# Log with ranking info
rank_str = f"(排名 #{trader_ranking.rank})" if trader_ranking and trader_ranking.rank else "(未上榜)"
logger.info(
f"🐋 Whale trade detected! ${activity.usdc_size:,.2f} "
f"{activity.side} @ {activity.price:.4f} {rank_str} on '{market.question[:50]}...'"
)
return whale_trades
async def check_all_markets(self) -> List[WhaleTrade]:
"""
Check all monitored markets for whale trades.
Returns:
List of all detected whale trades
"""
all_whale_trades = []
# Check markets concurrently in batches
batch_size = 10
items = list(self._monitored_markets.items())
for i in range(0, len(items), batch_size):
batch = items[i : i + batch_size]
tasks = [
self._check_market(condition_id, market)
for condition_id, market in batch
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, Exception):
logger.error(f"Error checking market: {result}")
elif result:
all_whale_trades.extend(result)
return all_whale_trades
async def run(self):
"""
Start the monitoring loop.
Continuously monitors markets at the configured interval.
First scan records existing transactions without triggering alerts.
"""
self._running = True
logger.info(
f"Starting trade monitor (interval: {self.settings.fetch_interval_seconds}s)"
)
# Initial scan - record existing transactions without alerting
logger.info("Performing initial scan to record existing transactions...")
await self.check_all_markets()
self._initial_scan_complete = True
self._save_processed_txns() # Save after initial scan
logger.info(f"Initial scan complete. Recorded {len(self._processed_txns)} existing transactions. Now monitoring for NEW trades only.")
save_counter = 0
while self._running:
try:
whale_trades = await self.check_all_markets()
# Call callback for each whale trade
if self._on_whale_detected:
for whale_trade in whale_trades:
try:
await self._on_whale_detected(whale_trade)
except Exception as e:
logger.error(f"Error in whale callback: {e}")
# Save processed transactions periodically (every 12 cycles = ~1 minute)
save_counter += 1
if save_counter >= 12:
self._save_processed_txns()
save_counter = 0
except Exception as e:
logger.error(f"Error in monitoring loop: {e}")
# Wait for next interval
await asyncio.sleep(self.settings.fetch_interval_seconds)
def stop(self):
"""Stop the monitoring loop."""
self._running = False
logger.info("Trade monitor stopping...")
def clear_processed_transactions(self):
"""Clear the processed transactions cache."""
count = len(self._processed_txns)
self._processed_txns.clear()
logger.info(f"Cleared {count} processed transactions from cache")
+4
View File
@@ -0,0 +1,4 @@
"""Utilities module."""
from .logger import setup_logging, get_logger
__all__ = ["setup_logging", "get_logger"]
+120
View File
@@ -0,0 +1,120 @@
"""Logging utilities."""
import logging
import sys
from datetime import datetime
from typing import Optional
from rich.console import Console
from rich.logging import RichHandler
from src.config import get_settings
def setup_logging(level: Optional[str] = None) -> None:
"""
Set up application logging with rich formatting.
Args:
level: Log level (DEBUG, INFO, WARNING, ERROR). Defaults to settings.
"""
settings = get_settings()
log_level = level or settings.log_level
# Create rich console
console = Console()
# Configure root logger
logging.basicConfig(
level=log_level,
format="%(message)s",
datefmt="[%X]",
handlers=[
RichHandler(
console=console,
rich_tracebacks=True,
show_path=False,
)
],
)
# Reduce noise from third-party libraries
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("openai").setLevel(logging.WARNING)
logging.getLogger("web3").setLevel(logging.WARNING)
def get_logger(name: str) -> logging.Logger:
"""
Get a logger instance.
Args:
name: Logger name (usually __name__)
Returns:
Logger instance
"""
return logging.getLogger(name)
class WhaleWatcherLogger:
"""Custom logger for whale watcher with formatted output."""
def __init__(self):
self.console = Console()
self.logger = logging.getLogger("whale_watcher")
def whale_detected(
self,
amount: float,
side: str,
price: float,
market: str,
) -> None:
"""Log a whale trade detection."""
self.console.print(
f"\n[bold cyan]{'='*60}[/bold cyan]\n"
f"[bold yellow]🐋 WHALE TRADE DETECTED![/bold yellow]\n"
f"[bold cyan]{'='*60}[/bold cyan]\n"
f"[green]Amount:[/green] ${amount:,.2f} USDC\n"
f"[green]Side:[/green] {side}\n"
f"[green]Price:[/green] {price:.4f}\n"
f"[green]Market:[/green] {market}\n"
f"[green]Time:[/green] {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
f"[bold cyan]{'='*60}[/bold cyan]\n"
)
def report_generated(self, market: str) -> None:
"""Log that a report was generated."""
self.console.print(
f"\n[bold magenta]{'='*60}[/bold magenta]\n"
f"[bold magenta]📊 ANALYSIS REPORT GENERATED[/bold magenta]\n"
f"[bold magenta]{'='*60}[/bold magenta]\n"
f"[green]Market:[/green] {market[:50]}...\n"
f"[bold magenta]{'='*60}[/bold magenta]\n"
)
def monitoring_started(self, market_count: int, interval: int, min_trade_size: float = 1000, min_price: float = 0.2, max_price: float = 0.8) -> None:
"""Log monitoring start."""
self.console.print(
f"\n[bold green]{'='*60}[/bold green]\n"
f"[bold green]🚀 WHALE WATCHER STARTED[/bold green]\n"
f"[bold green]{'='*60}[/bold green]\n"
f"[green]Monitoring:[/green] {market_count} markets\n"
f"[green]Interval:[/green] {interval} seconds\n"
f"[green]Min Trade Size:[/green] ${min_trade_size:,.0f} USD\n"
f"[green]Price Range:[/green] {min_price} - {max_price}\n"
f"[bold green]{'='*60}[/bold green]\n"
)
def error(self, message: str) -> None:
"""Log an error."""
self.console.print(f"[bold red]❌ ERROR:[/bold red] {message}")
def info(self, message: str) -> None:
"""Log an info message."""
self.console.print(f"[blue]️[/blue] {message}")
def separator(self) -> None:
"""Print a separator line."""
self.console.print(f"[dim]{''*60}[/dim]")