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:
@@ -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",
|
||||
]
|
||||
@@ -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. 这可能暗示交易者掌握了某些市场尚未充分反映的信息
|
||||
|
||||
请分析这笔交易并给出你的交易建议。
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user