Update: sync local state to remote

This commit is contained in:
SII-leiyu
2026-04-06 14:41:05 +08:00
parent cc5c0c3578
commit 9b8b052a21
714 changed files with 29375 additions and 42455 deletions
+47 -4
View File
@@ -11,8 +11,40 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Gemini API
# LLM API (OpenAI-compatible proxy)
gemini_api_key: str = Field(default="", alias="GEMINI_API_KEY")
llm_base_url: str = Field(default="http://apicz.boyuerichdata.com/v1/", alias="LLM_BASE_URL")
# Internal trade data API
internal_api_url: str = Field(default="http://103.197.25.170:18088", alias="INTERNAL_API_URL")
internal_api_key: str = Field(default="", alias="INTERNAL_API_KEY")
# Twitter API (for social sentiment search)
twitter_api_key: str = Field(default="", alias="TWITTER_API_KEY")
# Tavily API (for web search, replaces Google Search)
tavily_api_key: str = Field(default="", alias="TAVILY_API_KEY")
# Serper API (web search fallback)
serper_api_key: str = Field(default="", alias="SERPER_API_KEY")
# FRED API (macroeconomic data)
fred_api_key: str = Field(default="", alias="FRED_API_KEY")
# Polygon.io API (stocks, forex, commodities)
polygon_api_key: str = Field(default="", alias="POLYGON_API_KEY")
# Congress.gov API (U.S. legislation)
congress_api_key: str = Field(default="", alias="CONGRESS_API_KEY")
# Etherscan API (on-chain data)
etherscan_api_key: str = Field(default="", alias="ETHERSCAN_API_KEY")
# Telegram API (crypto channel monitoring)
telegram_api_id: str = Field(default="", alias="TELEGRAM_API_ID")
telegram_api_hash: str = Field(default="", alias="TELEGRAM_API_HASH")
telegram_session_string: str = Field(default="", alias="TELEGRAM_SESSION_STRING")
telegram_channels: str = Field(default="", alias="TELEGRAM_CHANNELS")
# Polygon Wallet
polygon_wallet_private_key: str = Field(default="", alias="POLYGON_WALLET_PRIVATE_KEY")
@@ -20,22 +52,33 @@ class Settings(BaseSettings):
# MongoDB
mongodb_uri: str = Field(default="mongodb://localhost:27017/whale_watcher", alias="MONGODB_URI")
# SQLite database
db_path: str = Field(default="data/signals.db", alias="DB_PATH")
# 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")
fetch_interval_seconds: int = Field(default=15, 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 Settings
llm_model: str = Field(default="gemini-3-flash-preview", alias="LLM_MODEL")
llm_temperature: float = Field(default=0.0, alias="LLM_TEMPERATURE")
# Trade Execution
enable_trade_execution: bool = Field(default=False, alias="ENABLE_TRADE_EXECUTION")
# Email notification
email_smtp_server: str = Field(default="smtp.qq.com", alias="EMAIL_SMTP_SERVER")
email_smtp_port: int = Field(default=465, alias="EMAIL_SMTP_PORT")
email_sender: str = Field(default="", alias="EMAIL_SENDER")
email_password: str = Field(default="", alias="EMAIL_PASSWORD")
email_recipient: str = Field(default="1253608463@qq.com,lyk@sii.edu.cn,1286874010@qq.com,tianhao.alex.huang@gmail.com", alias="EMAIL_RECIPIENT")
email_enabled: bool = Field(default=False, alias="EMAIL_ENABLED")
# Logging
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
+213
View File
@@ -0,0 +1,213 @@
"""FastAPI dashboard for signal performance tracking."""
import logging
from typing import Optional
from fastapi import FastAPI, Query
from fastapi.responses import HTMLResponse
from src.config import get_settings
from src.db.database import SignalDatabase
from src.services.stats_engine import StatsEngine
logger = logging.getLogger(__name__)
app = FastAPI(title="Polymarket Whale Watcher - Signal Dashboard")
def _get_db() -> SignalDatabase:
settings = get_settings()
return SignalDatabase(settings.db_path)
@app.get("/api/stats")
def api_stats():
"""Overall signal performance statistics."""
db = _get_db()
return db.get_stats()
@app.get("/api/stats/tiers")
def api_stats_tiers():
"""Signal stats by information_asymmetry_score tier."""
db = _get_db()
return db.get_stats_by_tier()
@app.get("/api/signals")
def api_signals(
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""Paginated signal list (newest first)."""
db = _get_db()
signals = db.get_all_signals(limit=limit, offset=offset)
return [s.model_dump(mode="json") for s in signals]
@app.get("/api/signals/best-worst")
def api_best_worst(n: int = Query(5, ge=1, le=20)):
"""Best and worst signals by theoretical ROI."""
db = _get_db()
result = db.get_best_worst(n=n)
return {
"best": [s.model_dump(mode="json") for s in result["best"]],
"worst": [s.model_dump(mode="json") for s in result["worst"]],
}
@app.get("/", response_class=HTMLResponse)
def dashboard_page():
"""HTML dashboard page."""
return HTML_TEMPLATE
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Polymarket Whale Watcher - Signal Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f1117; color: #e0e0e0; padding: 20px; }
h1 { color: #fff; margin-bottom: 8px; font-size: 1.8em; }
h2 { color: #a0a8c0; margin: 24px 0 12px; font-size: 1.2em; }
.subtitle { color: #666; margin-bottom: 24px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; }
.stat-card { background: #1a1d28; border-radius: 8px; padding: 16px; text-align: center; }
.stat-value { font-size: 1.8em; font-weight: bold; color: #4fc3f7; }
.stat-value.green { color: #66bb6a; }
.stat-value.red { color: #ef5350; }
.stat-label { color: #888; font-size: 0.85em; margin-top: 4px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 24px; }
th { background: #1a1d28; color: #a0a8c0; text-align: left; padding: 10px 12px; font-weight: 600; font-size: 0.85em; }
td { padding: 10px 12px; border-bottom: 1px solid #222; font-size: 0.9em; }
tr:hover { background: #1a1d28; }
.correct { color: #66bb6a; }
.incorrect { color: #ef5350; }
.pending { color: #888; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8em; font-weight: bold; }
.badge-high { background: #ef535033; color: #ef5350; }
.badge-med { background: #ffb74d33; color: #ffb74d; }
.badge-low { background: #66bb6a33; color: #66bb6a; }
.tier-table th, .tier-table td { text-align: center; }
#loading { color: #666; text-align: center; padding: 40px; }
</style>
</head>
<body>
<h1>Polymarket Whale Watcher</h1>
<p class="subtitle">Signal Performance Dashboard</p>
<div id="loading">Loading...</div>
<div id="content" style="display:none">
<div class="stats-grid" id="stats-grid"></div>
<h2>Stats by Likelihood Tier</h2>
<table class="tier-table" id="tier-table">
<thead><tr><th>Tier</th><th>Total</th><th>Resolved</th><th>Correct</th><th>Win Rate</th><th>Avg ROI</th></tr></thead>
<tbody></tbody>
</table>
<h2>Best Signals</h2>
<table id="best-table">
<thead><tr><th>Market</th><th>Side</th><th>Price</th><th>Size</th><th>Likelihood</th><th>Outcome</th><th>ROI</th></tr></thead>
<tbody></tbody>
</table>
<h2>Worst Signals</h2>
<table id="worst-table">
<thead><tr><th>Market</th><th>Side</th><th>Price</th><th>Size</th><th>Likelihood</th><th>Outcome</th><th>ROI</th></tr></thead>
<tbody></tbody>
</table>
<h2>Recent Signals</h2>
<table id="signals-table">
<thead><tr><th>Detected</th><th>Market</th><th>Side</th><th>Price</th><th>Size</th><th>Likelihood</th><th>Result</th><th>ROI</th></tr></thead>
<tbody></tbody>
</table>
</div>
<script>
const fmt = (v, d=1) => v !== null && v !== undefined ? (v*100).toFixed(d)+'%' : 'N/A';
const fmtRoi = v => v !== null && v !== undefined ? (v >= 0 ? '+' : '') + (v*100).toFixed(1)+'%' : 'Pending';
const fmtUsd = v => '$' + Number(v).toLocaleString('en-US', {maximumFractionDigits: 0});
const likeBadge = v => {
if (v >= 0.8) return `<span class="badge badge-high">${fmt(v,0)}</span>`;
if (v >= 0.6) return `<span class="badge badge-med">${fmt(v,0)}</span>`;
return `<span class="badge badge-low">${fmt(v,0)}</span>`;
};
const resultClass = s => {
if (s.signal_correct === true) return 'correct';
if (s.signal_correct === false) return 'incorrect';
return 'pending';
};
const resultText = s => {
if (!s.market_resolved) return 'Pending';
return s.signal_correct ? 'Correct' : 'Incorrect';
};
function signalRow(s, showDate=true) {
const cols = [];
if (showDate) cols.push(`<td>${(s.detected_at||'').slice(0,16)}</td>`);
cols.push(`<td>${(s.market_question||'').slice(0,60)}</td>`);
cols.push(`<td>${s.trade_side} ${s.trade_outcome}</td>`);
cols.push(`<td>${Number(s.trade_price).toFixed(4)}</td>`);
cols.push(`<td>${fmtUsd(s.trade_size_usd)}</td>`);
cols.push(`<td>${likeBadge(s.information_asymmetry_score)}</td>`);
if (showDate) cols.push(`<td class="${resultClass(s)}">${resultText(s)}</td>`);
else cols.push(`<td>${s.resolved_outcome||'Pending'}</td>`);
cols.push(`<td class="${resultClass(s)}">${fmtRoi(s.theoretical_roi)}</td>`);
return '<tr>' + cols.join('') + '</tr>';
}
async function load() {
try {
const [statsRes, tiersRes, bwRes, sigRes] = await Promise.all([
fetch('/api/stats'), fetch('/api/stats/tiers'),
fetch('/api/signals/best-worst?n=5'), fetch('/api/signals?limit=100')
]);
const stats = await statsRes.json();
const tiers = await tiersRes.json();
const bw = await bwRes.json();
const signals = await sigRes.json();
// Stats cards
const grid = document.getElementById('stats-grid');
const cards = [
['Total Signals', stats.total_signals, ''],
['Resolved', stats.resolved, ''],
['Win Rate', fmt(stats.win_rate), stats.win_rate >= 0.5 ? 'green' : 'red'],
['Avg ROI', fmtRoi(stats.avg_roi), stats.avg_roi >= 0 ? 'green' : 'red'],
['Correct', stats.correct, 'green'],
['Total PnL', (stats.total_theoretical_pnl >= 0 ? '+' : '') + Number(stats.total_theoretical_pnl).toFixed(2) + 'x', stats.total_theoretical_pnl >= 0 ? 'green' : 'red'],
];
grid.innerHTML = cards.map(([label, value, cls]) =>
`<div class="stat-card"><div class="stat-value ${cls}">${value}</div><div class="stat-label">${label}</div></div>`
).join('');
// Tier table
const tierBody = document.querySelector('#tier-table tbody');
tierBody.innerHTML = tiers.map(t =>
`<tr><td>${t.tier}</td><td>${t.total}</td><td>${t.resolved}</td><td>${t.correct}</td><td>${t.resolved > 0 ? fmt(t.win_rate) : 'N/A'}</td><td>${t.resolved > 0 ? fmtRoi(t.avg_roi) : 'N/A'}</td></tr>`
).join('');
// Best/worst
document.querySelector('#best-table tbody').innerHTML = bw.best.map(s => signalRow(s, false)).join('');
document.querySelector('#worst-table tbody').innerHTML = bw.worst.map(s => signalRow(s, false)).join('');
// All signals
document.querySelector('#signals-table tbody').innerHTML = signals.map(s => signalRow(s)).join('');
document.getElementById('loading').style.display = 'none';
document.getElementById('content').style.display = 'block';
} catch(e) {
document.getElementById('loading').textContent = 'Error loading data: ' + e.message;
}
}
load();
</script>
</body>
</html>"""
+4
View File
@@ -0,0 +1,4 @@
"""Database module for signal storage and tracking."""
from src.db.database import SignalDatabase
__all__ = ["SignalDatabase"]
+396
View File
@@ -0,0 +1,396 @@
"""SQLite database for anomaly signal storage and resolution tracking."""
import json
import logging
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from src.models.anomaly_signal import AnomalySignal
from src.models.trade import TraderRanking, TraderHistory
logger = logging.getLogger(__name__)
class SignalDatabase:
"""SQLite-backed storage for anomaly signals with resolution tracking."""
def __init__(self, db_path: str = "data/signals.db"):
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _get_conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def _init_db(self):
with self._get_conn() as conn:
# Migrate: rename old column if it exists
try:
conn.execute(
"ALTER TABLE signals RENAME COLUMN insider_trading_likelihood TO information_asymmetry_score"
)
logger.info("Migrated column: insider_trading_likelihood -> information_asymmetry_score")
except Exception:
pass # Column already renamed or table doesn't exist yet
conn.execute("""
CREATE TABLE IF NOT EXISTS signals (
id TEXT,
market_id TEXT NOT NULL,
market_question TEXT NOT NULL,
market_slug TEXT,
condition_id TEXT,
transaction_hash TEXT UNIQUE NOT NULL,
trade_timestamp INTEGER NOT NULL,
trade_side TEXT NOT NULL,
trade_price REAL NOT NULL,
trade_size_usd REAL NOT NULL,
trade_outcome TEXT NOT NULL,
trader_wallet TEXT,
trader_ranking_json TEXT,
trader_history_json TEXT,
information_asymmetry_score REAL NOT NULL DEFAULT 0.0,
reasoning TEXT DEFAULT '',
insider_evidence TEXT DEFAULT '',
detected_at TEXT NOT NULL,
market_resolved INTEGER DEFAULT 0,
market_resolved_at TEXT,
resolved_outcome TEXT,
signal_correct INTEGER,
theoretical_roi REAL
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_signals_market_id
ON signals(market_id)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_signals_market_resolved
ON signals(market_resolved)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_signals_likelihood
ON signals(information_asymmetry_score DESC)
""")
def insert_signal(self, signal: AnomalySignal) -> bool:
"""Insert a signal, deduplicating by transaction_hash. Returns True if inserted."""
try:
with self._get_conn() as conn:
conn.execute("""
INSERT OR IGNORE INTO signals (
id, market_id, market_question, market_slug, condition_id,
transaction_hash, trade_timestamp, trade_side, trade_price,
trade_size_usd, trade_outcome, trader_wallet,
trader_ranking_json, trader_history_json,
information_asymmetry_score, reasoning, insider_evidence,
detected_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
signal.id,
signal.market_id,
signal.market_question,
signal.market_slug,
signal.condition_id,
signal.transaction_hash,
signal.trade_timestamp,
signal.trade_side,
signal.trade_price,
signal.trade_size_usd,
signal.trade_outcome,
signal.trader_wallet,
signal.trader_ranking.model_dump_json() if signal.trader_ranking else None,
signal.trader_history.model_dump_json() if signal.trader_history else None,
signal.information_asymmetry_score,
signal.reasoning,
signal.insider_evidence,
signal.detected_at.isoformat(),
))
return conn.total_changes > 0
except sqlite3.IntegrityError:
logger.debug(f"Signal already exists: {signal.transaction_hash}")
return False
except Exception as e:
logger.error(f"Failed to insert signal: {e}")
return False
def _row_to_signal(self, row: sqlite3.Row) -> AnomalySignal:
"""Convert a database row to an AnomalySignal."""
trader_ranking = None
if row["trader_ranking_json"]:
try:
trader_ranking = TraderRanking.model_validate_json(row["trader_ranking_json"])
except Exception:
pass
trader_history = None
if row["trader_history_json"]:
try:
trader_history = TraderHistory.model_validate_json(row["trader_history_json"])
except Exception:
pass
return AnomalySignal(
id=row["id"] or "",
market_id=row["market_id"],
market_question=row["market_question"],
market_slug=row["market_slug"],
condition_id=row["condition_id"],
transaction_hash=row["transaction_hash"],
trade_timestamp=row["trade_timestamp"],
trade_side=row["trade_side"],
trade_price=row["trade_price"],
trade_size_usd=row["trade_size_usd"],
trade_outcome=row["trade_outcome"],
trader_wallet=row["trader_wallet"],
trader_ranking=trader_ranking,
trader_history=trader_history,
information_asymmetry_score=row["information_asymmetry_score"],
reasoning=row["reasoning"] or "",
insider_evidence=row["insider_evidence"] or "",
detected_at=datetime.fromisoformat(row["detected_at"]),
market_resolved=bool(row["market_resolved"]),
market_resolved_at=(
datetime.fromisoformat(row["market_resolved_at"])
if row["market_resolved_at"] else None
),
resolved_outcome=row["resolved_outcome"],
signal_correct=bool(row["signal_correct"]) if row["signal_correct"] is not None else None,
theoretical_roi=row["theoretical_roi"],
)
def get_signals_for_market(
self,
market_id: str,
top_recent: int = 5,
top_likelihood: int = 5,
min_likelihood: float = 0.4,
) -> List[AnomalySignal]:
"""Get top recent + top likelihood signals for a market, deduplicated.
Only returns signals with likelihood >= min_likelihood (for LLM context)."""
with self._get_conn() as conn:
# Top recent (above threshold only)
recent_rows = conn.execute(
"SELECT * FROM signals WHERE market_id = ? AND information_asymmetry_score >= ? ORDER BY trade_timestamp DESC LIMIT ?",
(market_id, min_likelihood, top_recent),
).fetchall()
# Top likelihood (above threshold only)
likelihood_rows = conn.execute(
"SELECT * FROM signals WHERE market_id = ? AND information_asymmetry_score >= ? ORDER BY information_asymmetry_score DESC LIMIT ?",
(market_id, min_likelihood, top_likelihood),
).fetchall()
seen = set()
combined = []
for row in list(recent_rows) + list(likelihood_rows):
tx_hash = row["transaction_hash"]
if tx_hash not in seen:
seen.add(tx_hash)
combined.append(self._row_to_signal(row))
combined.sort(key=lambda s: s.trade_timestamp, reverse=True)
return combined
def get_unresolved_market_ids(self) -> List[str]:
"""Return distinct market_ids that have unresolved signals."""
with self._get_conn() as conn:
rows = conn.execute(
"SELECT DISTINCT market_id FROM signals WHERE market_resolved = 0"
).fetchall()
return [row["market_id"] for row in rows]
def mark_market_resolved(
self,
market_id: str,
resolved_outcome: str,
resolved_at: datetime,
) -> int:
"""
Mark all signals for a market as resolved and compute correctness/ROI.
Returns number of updated rows.
"""
with self._get_conn() as conn:
rows = conn.execute(
"SELECT transaction_hash, trade_outcome, trade_price FROM signals WHERE market_id = ? AND market_resolved = 0",
(market_id,),
).fetchall()
updated = 0
for row in rows:
correct = row["trade_outcome"] == resolved_outcome
if correct:
roi = (1.0 - row["trade_price"]) / row["trade_price"] if row["trade_price"] > 0 else 0.0
else:
roi = -1.0
conn.execute("""
UPDATE signals SET
market_resolved = 1,
market_resolved_at = ?,
resolved_outcome = ?,
signal_correct = ?,
theoretical_roi = ?
WHERE transaction_hash = ?
""", (
resolved_at.isoformat(),
resolved_outcome,
int(correct),
roi,
row["transaction_hash"],
))
updated += 1
return updated
def get_stats(self) -> dict:
"""Aggregate statistics: total, resolved, correct, win_rate, avg_roi."""
with self._get_conn() as conn:
row = conn.execute("""
SELECT
COUNT(*) as total,
SUM(CASE WHEN market_resolved = 1 THEN 1 ELSE 0 END) as resolved,
SUM(CASE WHEN signal_correct = 1 THEN 1 ELSE 0 END) as correct,
AVG(CASE WHEN market_resolved = 1 THEN theoretical_roi END) as avg_roi,
SUM(CASE WHEN market_resolved = 1 THEN theoretical_roi ELSE 0 END) as total_pnl
FROM signals
""").fetchone()
total = row["total"]
resolved = row["resolved"] or 0
correct = row["correct"] or 0
win_rate = correct / resolved if resolved > 0 else 0.0
return {
"total_signals": total,
"resolved": resolved,
"correct": correct,
"win_rate": win_rate,
"avg_roi": row["avg_roi"] or 0.0,
"total_theoretical_pnl": row["total_pnl"] or 0.0,
}
def get_stats_by_tier(self) -> List[dict]:
"""Stats grouped by information_asymmetry_score tiers."""
tiers = [
("0.4-0.6", 0.4, 0.6),
("0.6-0.8", 0.6, 0.8),
("0.8-1.0", 0.8, 1.01),
]
results = []
with self._get_conn() as conn:
for label, low, high in tiers:
row = conn.execute("""
SELECT
COUNT(*) as total,
SUM(CASE WHEN market_resolved = 1 THEN 1 ELSE 0 END) as resolved,
SUM(CASE WHEN signal_correct = 1 THEN 1 ELSE 0 END) as correct,
AVG(CASE WHEN market_resolved = 1 THEN theoretical_roi END) as avg_roi
FROM signals
WHERE information_asymmetry_score >= ? AND information_asymmetry_score < ?
""", (low, high)).fetchone()
resolved = row["resolved"] or 0
correct = row["correct"] or 0
results.append({
"tier": label,
"total": row["total"],
"resolved": resolved,
"correct": correct,
"win_rate": correct / resolved if resolved > 0 else 0.0,
"avg_roi": row["avg_roi"] or 0.0,
})
return results
def get_all_signals(self, limit: int = 50, offset: int = 0) -> List[AnomalySignal]:
"""Paginated query of all signals, newest first."""
with self._get_conn() as conn:
rows = conn.execute(
"SELECT * FROM signals ORDER BY detected_at DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
return [self._row_to_signal(row) for row in rows]
def get_all_market_ids(self) -> List[str]:
"""Get all distinct market IDs."""
with self._get_conn() as conn:
rows = conn.execute("SELECT DISTINCT market_id FROM signals").fetchall()
return [row["market_id"] for row in rows]
def get_signal_count(self, market_id: Optional[str] = None) -> int:
"""Count signals, optionally filtered by market_id."""
with self._get_conn() as conn:
if market_id:
row = conn.execute(
"SELECT COUNT(*) as cnt FROM signals WHERE market_id = ?", (market_id,)
).fetchone()
else:
row = conn.execute("SELECT COUNT(*) as cnt FROM signals").fetchone()
return row["cnt"]
def cleanup_old_signals(self, max_age_days: int = 30) -> int:
"""Remove signals older than max_age_days. Returns count removed."""
from datetime import timedelta
cutoff = (datetime.utcnow() - timedelta(days=max_age_days)).isoformat()
with self._get_conn() as conn:
cursor = conn.execute(
"DELETE FROM signals WHERE detected_at < ?", (cutoff,)
)
return cursor.rowcount
def get_recent_resolved(self, limit: int = 20) -> List[AnomalySignal]:
"""Get recently resolved signals."""
with self._get_conn() as conn:
rows = conn.execute(
"SELECT * FROM signals WHERE market_resolved = 1 ORDER BY market_resolved_at DESC LIMIT ?",
(limit,),
).fetchall()
return [self._row_to_signal(row) for row in rows]
def get_best_worst(self, n: int = 5) -> dict:
"""Get best and worst signals by ROI."""
with self._get_conn() as conn:
best_rows = conn.execute(
"SELECT * FROM signals WHERE market_resolved = 1 ORDER BY theoretical_roi DESC LIMIT ?",
(n,),
).fetchall()
worst_rows = conn.execute(
"SELECT * FROM signals WHERE market_resolved = 1 ORDER BY theoretical_roi ASC LIMIT ?",
(n,),
).fetchall()
return {
"best": [self._row_to_signal(r) for r in best_rows],
"worst": [self._row_to_signal(r) for r in worst_rows],
}
def migrate_from_json(self, json_dir: Path) -> int:
"""One-time migration from JSON files to SQLite. Returns count migrated."""
if not json_dir.exists():
logger.warning(f"JSON directory not found: {json_dir}")
return 0
count = 0
for json_file in json_dir.glob("*.json"):
try:
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
signals = data if isinstance(data, list) else data.get("signals", [])
for item in signals:
try:
signal = AnomalySignal.model_validate(item)
if self.insert_signal(signal):
count += 1
except Exception as e:
logger.warning(f"Failed to parse signal from {json_file.name}: {e}")
except Exception as e:
logger.error(f"Failed to load {json_file}: {e}")
logger.info(f"Migrated {count} signals from JSON to SQLite")
return count
+263 -7
View File
@@ -15,17 +15,25 @@ import asyncio
import os
import re
import signal
import smtplib
import sys
from datetime import datetime
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
import typer
from src.config import get_settings
from src.db.database import SignalDatabase
from src.services.market_fetcher import MarketFetcher
from src.services.trade_monitor import TradeMonitor
from src.services.price_monitor import PriceMonitor, VolatilityAlert
from src.services.llm_analyzer import LLMAnalyzer
from src.services.volatility_analyzer import VolatilityAnalyzer
from src.services.daily_briefing import DailyBriefingGenerator
from src.services.resolution_tracker import ResolutionTracker
from src.models.trade import WhaleTrade
from src.utils.logger import setup_logging, WhaleWatcherLogger
@@ -45,8 +53,28 @@ class WhaleWatcher:
self.trade_monitor = TradeMonitor(on_whale_detected=self.on_whale_detected)
self.llm_analyzer = LLMAnalyzer()
# Volatility analyzer for detecting "price leads news" signals
self.volatility_analyzer = VolatilityAnalyzer()
# Price monitor for ALL active markets (independent from trade monitor)
self.price_monitor = PriceMonitor(
window_seconds=3600, # 1 hour
threshold=0.20, # 20%
poll_interval=60, # Poll every 60 seconds
on_volatility_detected=self.on_volatility_detected,
)
# Database and resolution tracker
self.db = SignalDatabase(self.settings.db_path)
self.resolution_tracker = ResolutionTracker(self.db)
# Daily briefing generator
self.briefing_generator = DailyBriefingGenerator(self.settings.db_path)
self._running = False
self._refresh_interval = 300 # Refresh markets every 5 minutes
self._refresh_interval = 900 # Refresh markets every 15 minutes
self._resolution_check_interval = 1800 # Check resolutions every 30 minutes
self._last_briefing_date = None # Track last briefing date
# Ensure reports directory exists
self.REPORTS_DIR.mkdir(parents=True, exist_ok=True)
@@ -72,11 +100,15 @@ class WhaleWatcher:
Path to the saved file
"""
trade = whale_trade.trade
date_str = datetime.now().strftime("%Y%m%d")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
market_name = self._sanitize_filename(whale_trade.market_question)
day_dir = self.REPORTS_DIR / date_str
day_dir.mkdir(parents=True, exist_ok=True)
filename = f"{timestamp}_{trade.side}_{int(trade.usdc_size)}USD_{market_name}.md"
filepath = self.REPORTS_DIR / filename
filepath = day_dir / filename
with open(filepath, "w", encoding="utf-8") as f:
f.write(full_report)
@@ -95,7 +127,7 @@ class WhaleWatcher:
# Log detection
logger.whale_detected(
amount=trade.usdc_size,
side=trade.side,
side=f"BUY {trade.outcome}",
price=trade.price,
market=whale_trade.market_question,
)
@@ -116,6 +148,84 @@ class WhaleWatcher:
filepath = self._save_report(whale_trade, full_report)
logger.info(f"Report saved to: {filepath}")
# Real-time email alert for high information asymmetry (>= 60%)
ias = decision.recommendation.information_asymmetry_score
if ias >= 0.6:
self._send_alert_email(whale_trade, full_report, ias)
logger.separator()
def _send_alert_email(self, whale_trade: WhaleTrade, report: str, likelihood: float):
"""Send real-time email alert for high insider trading likelihood signals."""
settings = get_settings()
if not settings.email_enabled or not settings.email_sender or not settings.email_password:
return
alert_recipient = "1253608463@qq.com"
trade = whale_trade.trade
try:
subject = (
f"内幕交易警报 ({likelihood:.0%}) — "
f"BUY {trade.outcome} @ {trade.price:.4f} "
f"${trade.usdc_size:,.0f}{whale_trade.market_question[:50]}"
)
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = settings.email_sender
msg["To"] = alert_recipient
msg.attach(MIMEText(report, "plain", "utf-8"))
with smtplib.SMTP_SSL(settings.email_smtp_server, settings.email_smtp_port) as server:
server.login(settings.email_sender, settings.email_password)
server.sendmail(settings.email_sender, alert_recipient, msg.as_string())
logger.info(f"Insider alert email sent to {alert_recipient} (likelihood: {likelihood:.0%})")
except Exception as e:
logger.error(f"Failed to send alert email: {e}")
async def on_volatility_detected(self, alert: VolatilityAlert) -> None:
"""
Callback when price volatility is detected.
Analyzes the volatility to determine if it's a "price leads news" signal.
Args:
alert: The volatility alert
"""
logger.info(
f"Volatility detected: {alert.market_question[:50]}... "
f"{alert.direction} {abs(alert.price_change_percent):.1%}"
)
# Analyze with LLM to check if price leads news
logger.info("Analyzing volatility for leading signal detection...")
signal = await self.volatility_analyzer.analyze_volatility(alert)
if signal:
# Print the analysis report
report = self.volatility_analyzer.format_signal_report(signal)
print(report)
if signal.is_leading_signal:
logger.info(
f"LEADING SIGNAL recorded: {alert.market_question[:50]}... "
f"Time advantage: {signal.time_advantage_minutes} minutes"
)
else:
logger.info(
f"Signal analyzed: {signal.signal_type.value} "
f"(confidence: {signal.confidence:.1%})"
)
# Print stats
stats = self.volatility_analyzer.get_leading_signals_stats()
logger.info(
f"Dataset stats: {stats['total_signals']} total, "
f"{stats['leading_signals']} leading signals"
)
logger.separator()
async def refresh_markets(self) -> None:
@@ -126,9 +236,28 @@ class WhaleWatcher:
limit=self.settings.trending_markets_limit
)
# Additionally scan for specialized market categories
# that may not be in the top trending list
existing_ids = {tm.market.id for tm in trending_markets}
# 1. Token launch / crypto project markets
token_markets = self.market_fetcher.get_token_launch_markets()
token_added = 0
for tm in token_markets:
if tm.market.id not in existing_ids:
trending_markets.append(tm)
existing_ids.add(tm.market.id)
token_added += 1
if token_added:
logger.info(
f"Added {token_added} token launch markets "
f"(total: {len(trending_markets)})"
)
if trending_markets:
self.trade_monitor.set_monitored_markets(trending_markets)
logger.info(f"Now monitoring {len(trending_markets)} trending markets")
logger.info(f"Now monitoring {len(trending_markets)} markets")
else:
logger.error("Failed to fetch trending markets")
@@ -148,16 +277,25 @@ class WhaleWatcher:
max_price=self.settings.max_price,
)
# Start monitoring and market refresh tasks
# Start monitoring tasks:
# 1. Trade monitor - watches top markets for whale trades
# 2. Market refresh - refreshes the market list periodically
# 3. Daily briefing - generates daily summary at midnight
# 4. Resolution check - checks if markets with signals have resolved
# NOTE: Price volatility monitor is temporarily disabled
monitor_task = asyncio.create_task(self.trade_monitor.run())
# price_monitor_task = asyncio.create_task(self.price_monitor.run())
refresh_task = asyncio.create_task(self._refresh_loop())
briefing_task = asyncio.create_task(self._briefing_loop())
resolution_task = asyncio.create_task(self._resolution_check_loop())
try:
await asyncio.gather(monitor_task, refresh_task)
await asyncio.gather(monitor_task, refresh_task, briefing_task, resolution_task)
except asyncio.CancelledError:
logger.info("Shutting down...")
finally:
self.trade_monitor.stop()
self.price_monitor.stop()
await self.trade_monitor.close()
async def _refresh_loop(self) -> None:
@@ -167,10 +305,58 @@ class WhaleWatcher:
if self._running:
await self.refresh_markets()
def _briefing_already_sent(self, date: datetime) -> bool:
"""Check if briefing for a date was already generated (file exists)."""
from src.services.daily_briefing import BRIEFINGS_DIR
date_str = date.strftime("%Y-%m-%d")
return (BRIEFINGS_DIR / f"briefing_{date_str}.md").exists()
async def _briefing_loop(self) -> None:
"""Generate daily briefing for previous day at 10:00 local time."""
while self._running:
now = datetime.now()
today = now.date()
yesterday = now - timedelta(days=1)
# Generate at 10:00 local time, skip if already sent (survives restart)
if now.hour == 10 and now.minute >= 0:
if self._last_briefing_date != today and not self._briefing_already_sent(yesterday):
try:
filepath = self.briefing_generator.generate_briefing()
if filepath:
logger.info(f"Daily briefing generated: {filepath}")
self._last_briefing_date = today
except Exception as e:
logger.error(f"Error generating daily briefing: {e}")
else:
self._last_briefing_date = today
# Check every minute
await asyncio.sleep(60)
async def _resolution_check_loop(self) -> None:
"""Periodically check if markets with signals have resolved."""
# Initial delay to let the system start up
await asyncio.sleep(60)
while self._running:
try:
result = await self.resolution_tracker.check_all()
if result["resolved"] > 0:
logger.info(
f"Resolution check: {result['resolved']} markets resolved, "
f"{result['signals_updated']} signals updated"
)
except Exception as e:
logger.error(f"Error in resolution check: {e}")
await asyncio.sleep(self._resolution_check_interval)
def stop(self) -> None:
"""Stop the whale watcher."""
self._running = False
self.trade_monitor.stop()
self.price_monitor.stop()
# Global instance for signal handling
@@ -292,6 +478,76 @@ def test_analyze(
print(decision.analysis)
@app.command()
def briefing(
date: str = typer.Option(None, "--date", "-d", help="Date in YYYY-MM-DD format (defaults to yesterday)"),
today: bool = typer.Option(False, "--today", "-t", help="Generate briefing for today instead of yesterday"),
):
"""Generate daily briefing manually."""
setup_logging("INFO")
settings = get_settings()
generator = DailyBriefingGenerator(settings.db_path)
if today:
filepath = generator.generate_today_briefing()
elif date:
try:
target_date = datetime.strptime(date, "%Y-%m-%d")
filepath = generator.generate_briefing(target_date)
except ValueError:
print(f"Invalid date format: {date}. Use YYYY-MM-DD")
raise typer.Exit(1)
else:
filepath = generator.generate_briefing() # Yesterday by default
if filepath:
print(f"\nBriefing generated: {filepath}")
# Print the content
with open(filepath, 'r', encoding='utf-8') as f:
print("\n" + "=" * 80)
print(f.read())
else:
print("\nNo signals found for the specified date. No briefing generated.")
@app.command()
def migrate():
"""Migrate anomaly signals from JSON files to SQLite database."""
setup_logging("INFO")
settings = get_settings()
db = SignalDatabase(settings.db_path)
json_dir = Path(__file__).parent.parent / "anomaly_signals"
print(f"Migrating signals from {json_dir} to {settings.db_path}")
count = db.migrate_from_json(json_dir)
print(f"Migration complete: {count} signals migrated")
# Show stats
stats = db.get_stats()
print(f"\nDatabase stats:")
print(f" Total signals: {stats['total_signals']}")
print(f" Resolved: {stats['resolved']}")
@app.command()
def dashboard(
port: int = typer.Option(8000, "--port", "-p", help="Port to run the dashboard on"),
host: str = typer.Option("0.0.0.0", "--host", "-h", help="Host to bind to"),
):
"""Start the signal performance dashboard web server."""
setup_logging("INFO")
import uvicorn
from src.dashboard import app as dashboard_app
print(f"Starting dashboard at http://{host}:{port}")
uvicorn.run(dashboard_app, host=host, port=port)
def main():
"""Entry point."""
app()
+15 -3
View File
@@ -12,7 +12,7 @@ class AnomalySignal(BaseModel):
Represents a stored anomaly signal for a market.
This captures the raw trade and trader information for trades with medium
or higher insider trading likelihood. The insider_trading_likelihood is stored
or higher information asymmetry score. The information_asymmetry_score is stored
for sorting/filtering purposes, but NOT shown to LLM - the model will
re-analyze all signals (historical + current) together without bias.
"""
@@ -24,6 +24,7 @@ class AnomalySignal(BaseModel):
market_id: str
market_question: str
market_slug: Optional[str] = None
condition_id: Optional[str] = None
# Trade information
transaction_hash: str
@@ -38,12 +39,23 @@ class AnomalySignal(BaseModel):
trader_ranking: Optional[TraderRanking] = None
trader_history: Optional[TraderHistory] = None
# Insider trading likelihood (for sorting/filtering only, NOT shown to LLM)
insider_trading_likelihood: float = Field(default=0.0, ge=0.0, le=1.0)
# Information asymmetry score (for sorting/filtering only, NOT shown to LLM)
information_asymmetry_score: float = Field(default=0.0, ge=0.0, le=1.0)
# LLM analysis results
reasoning: str = ""
insider_evidence: str = ""
# Metadata
detected_at: datetime = Field(default_factory=datetime.utcnow)
# Resolution tracking
market_resolved: bool = False
market_resolved_at: Optional[datetime] = None
resolved_outcome: Optional[str] = None
signal_correct: Optional[bool] = None
theoretical_roi: Optional[float] = None
def to_context_string(self) -> str:
"""
Format this anomaly signal as a context string for LLM.
+3 -3
View File
@@ -33,10 +33,10 @@ class TradeRecommendation(BaseModel):
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
# Information asymmetry assessment fields
information_asymmetry_score: float = Field(default=0.0, ge=0.0, le=1.0) # 0-1 score
trader_credibility: TraderCredibility = TraderCredibility.UNKNOWN
insider_evidence: str = "" # Evidence supporting insider trading assessment
insider_evidence: str = "" # Evidence supporting information asymmetry assessment
class LLMDecision(BaseModel):
+124
View File
@@ -0,0 +1,124 @@
"""Data models for leading signal detection - price moves before news."""
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import List, Optional
class SignalType(str, Enum):
"""Type of price volatility signal."""
LEADING_SIGNAL = "LEADING_SIGNAL" # Price moved before news
NEWS_DRIVEN = "NEWS_DRIVEN" # Price reacted to news
SOCIAL_DRIVEN = "SOCIAL_DRIVEN" # Price driven by social media
SPECULATION = "SPECULATION" # No clear information source
@dataclass
class LeadingSignal:
"""
A case where price movement preceded public news.
This is used to build a dataset of "price leads news" events
for research purposes.
"""
# Basic info
id: str
market_id: str
market_question: str
# Price movement details
price_change_percent: float # e.g., 0.25 for 25%
direction: str # "UP" or "DOWN"
start_price: float
end_price: float
window_seconds: int
# Timing
detected_at: str # ISO format timestamp
volatility_detected_at: str # When price volatility was detected
# LLM analysis results
signal_type: SignalType
confidence: float # 0-1
is_leading_signal: bool
# News analysis
news_found: bool
earliest_news_time: Optional[str] = None # ISO format
key_news_headlines: List[str] = field(default_factory=list)
# Social media analysis
earliest_social_time: Optional[str] = None # ISO format
key_social_posts: List[str] = field(default_factory=list)
# Time advantage
time_advantage_minutes: int = 0 # How many minutes price led news
# Analysis
reasoning: str = ""
potential_information_source: str = ""
# Full LLM analysis text
full_analysis: str = ""
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"id": self.id,
"market_id": self.market_id,
"market_question": self.market_question,
"price_change_percent": self.price_change_percent,
"direction": self.direction,
"start_price": self.start_price,
"end_price": self.end_price,
"window_seconds": self.window_seconds,
"detected_at": self.detected_at,
"volatility_detected_at": self.volatility_detected_at,
"signal_type": self.signal_type.value if isinstance(self.signal_type, SignalType) else self.signal_type,
"confidence": self.confidence,
"is_leading_signal": self.is_leading_signal,
"news_found": self.news_found,
"earliest_news_time": self.earliest_news_time,
"key_news_headlines": self.key_news_headlines,
"earliest_social_time": self.earliest_social_time,
"key_social_posts": self.key_social_posts,
"time_advantage_minutes": self.time_advantage_minutes,
"reasoning": self.reasoning,
"potential_information_source": self.potential_information_source,
"full_analysis": self.full_analysis,
}
@classmethod
def from_dict(cls, data: dict) -> "LeadingSignal":
"""Create from dictionary."""
signal_type = data.get("signal_type", "SPECULATION")
if isinstance(signal_type, str):
try:
signal_type = SignalType(signal_type)
except ValueError:
signal_type = SignalType.SPECULATION
return cls(
id=data["id"],
market_id=data["market_id"],
market_question=data["market_question"],
price_change_percent=data["price_change_percent"],
direction=data["direction"],
start_price=data["start_price"],
end_price=data["end_price"],
window_seconds=data["window_seconds"],
detected_at=data["detected_at"],
volatility_detected_at=data["volatility_detected_at"],
signal_type=signal_type,
confidence=data.get("confidence", 0.0),
is_leading_signal=data.get("is_leading_signal", False),
news_found=data.get("news_found", False),
earliest_news_time=data.get("earliest_news_time"),
key_news_headlines=data.get("key_news_headlines", []),
earliest_social_time=data.get("earliest_social_time"),
key_social_posts=data.get("key_social_posts", []),
time_advantage_minutes=data.get("time_advantage_minutes", 0),
reasoning=data.get("reasoning", ""),
potential_information_source=data.get("potential_information_source", ""),
full_analysis=data.get("full_analysis", ""),
)
+91 -7
View File
@@ -57,6 +57,32 @@ class TraderHistory(BaseModel):
recent_trades: list[dict] = Field(default_factory=list) # Recent trade details
class EventPosition(BaseModel):
"""Whale's position in a related market under the same event."""
market_question: str
condition_id: str = ""
outcome: str = "" # "Yes" or "No"
size: float = 0.0 # token size held
avg_price: float = 0.0 # average entry price
current_price: float = 0.0 # current market price
current_value: float = 0.0 # current position value in USD
initial_value: float = 0.0 # cost basis
pnl: float = 0.0 # realized + unrealized PnL
side_summary: str = "" # human readable summary
class MarketTopTrader(BaseModel):
"""Top trader on a market (by net volume)."""
wallet: str
name: Optional[str] = None
rank: Optional[int] = None
pnl: Optional[float] = None
net_volume_usd: float = 0.0 # positive = net buyer of Yes, negative = net seller
trade_count: int = 0
class WhaleTrade(BaseModel):
"""Whale trade that meets detection criteria."""
@@ -75,6 +101,11 @@ class WhaleTrade(BaseModel):
trader_ranking: Optional[TraderRanking] = None
# Trader history info
trader_history: Optional[TraderHistory] = None
# Whale's positions across the same event
whale_event_positions: list[EventPosition] = Field(default_factory=list)
# Top traders on this market (bulls and bears)
market_top_buyers: list[MarketTopTrader] = Field(default_factory=list)
market_top_sellers: list[MarketTopTrader] = Field(default_factory=list)
@property
def is_whale_trade(self) -> bool:
@@ -86,6 +117,55 @@ class WhaleTrade(BaseModel):
"""Check if trade price is in valid range (0.2-0.8)."""
return 0.2 <= self.trade.price <= 0.8
def format_event_positions(self) -> str:
"""Format whale's event positions for LLM context."""
if self.whale_event_positions:
info = "### 该鲸鱼在同一事件下其他市场的持仓\n"
info += "(用于判断是否存在对冲或关联押注)\n\n"
for pos in self.whale_event_positions:
pnl_str = f"盈亏 ${pos.pnl:+,.0f}" if pos.pnl else ""
info += (
f"- **{pos.market_question[:60]}{'...' if len(pos.market_question) > 60 else ''}**\n"
f" {pos.side_summary} | "
f"当前价值 ${pos.current_value:,.0f} | 成本 ${pos.initial_value:,.0f} | "
f"{pnl_str}\n"
)
return info
return "### 该鲸鱼在同一事件下其他市场的持仓\n- 无其他关联持仓(单一市场事件或无跨市场交易)\n"
def format_top_traders(self) -> str:
"""Format market top holders for LLM context."""
info = "### 该市场 Top 5 多空双方持仓者\n"
info += "(反映市场主要参与者的立场和资质)\n"
if self.market_top_buyers:
info += "\n**看多方 (持有 Yes Token)**:\n"
for i, t in enumerate(self.market_top_buyers, 1):
rank_str = f"排名 #{t.rank}" if t.rank else "未上榜"
pnl_str = f"PnL ${t.pnl:,.0f}" if t.pnl is not None else ""
name_str = t.name or t.wallet[:10] + "..."
info += (
f" {i}. **{name_str}** ({rank_str}{', ' + pnl_str if pnl_str else ''}) "
f"— 持仓价值 ${t.net_volume_usd:,.0f}\n"
)
else:
info += "\n**看多方**: 无显著持仓\n"
if self.market_top_sellers:
info += "\n**看空方 (持有 No Token)**:\n"
for i, t in enumerate(self.market_top_sellers, 1):
rank_str = f"排名 #{t.rank}" if t.rank else "未上榜"
pnl_str = f"PnL ${t.pnl:,.0f}" if t.pnl is not None else ""
name_str = t.name or t.wallet[:10] + "..."
info += (
f" {i}. **{name_str}** ({rank_str}{', ' + pnl_str if pnl_str else ''}) "
f"— 持仓价值 ${t.net_volume_usd:,.0f}\n"
)
else:
info += "\n**看空方**: 无显著持仓\n"
return info
def to_llm_context(self) -> str:
"""Generate context string for LLM analysis."""
# Format trader ranking info
@@ -135,13 +215,16 @@ class WhaleTrade(BaseModel):
## 异常交易检测
### 交易信息
- 交易方向: BUY {self.trade.outcome} Token ({'看多,认为事件会发生' if self.trade.outcome == 'Yes' else '看空,认为事件不会发生'})
- 交易金额: ${self.trade.usdc_size:,.2f} USDC
- 交易方向: {self.trade.side}
- 交易价格: {self.trade.price:.4f}
- 交易结果: {self.trade.outcome}
- 买入价格: {self.trade.price:.4f}(赔率约 {1/self.trade.price:.1f}x
- 交易时间: {datetime.fromtimestamp(self.trade.timestamp).strftime('%Y-%m-%d %H:%M:%S')}
- 交易者钱包: {self.trade.proxy_wallet or 'Unknown'}
{trader_info}{history_info}
{self.format_event_positions()}
{self.format_top_traders()}
### 市场信息
- 市场问题: {self.market_question}
- 市场描述: {self.market_description or 'N/A'}
@@ -149,8 +232,9 @@ class WhaleTrade(BaseModel):
- 当前价格: {', '.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. **交易者排名和历史交易是判断内幕交易可信度的重要参考** - 高排名、大额交易频繁的交易者通常有更好的信息来源或分析能力
1. 这笔大额交易 (${self.trade.usdc_size:,.2f}) 的方向为 **BUY {self.trade.outcome} Token**{'表明交易者看多,认为事件会发生' if self.trade.outcome == 'Yes' else '表明交易者看空,认为事件不会发生'}
2. 买入价格 {self.trade.price:.4f},赔率约 {1/self.trade.price:.1f}x
3. **交易者排名和历史交易是判断内幕交易可信度的重要参考**
4. **注意分析该鲸鱼在同一事件下的其他持仓** — 如果持有反向仓位可能是对冲策略
5. **参考该市场 Top 多空持仓者的阵营** — 精英交易者集中在哪一方
"""
+222
View File
@@ -0,0 +1,222 @@
"""Prompts for price volatility analysis - detecting leading signals."""
from datetime import datetime, timezone
class VolatilityAnalyzerPrompts:
"""Prompts for LLM price volatility analysis."""
@staticmethod
def system_prompt() -> str:
"""Get the system prompt for volatility analysis."""
current_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
return f"""你是一位专业的预测市场分析师,专门研究"价格领先于新闻"的现象。
**当前真实时间**{current_utc}
**你的核心任务**:判断一次市场价格异常波动是否"领先于公开新闻"——即价格变动发生在相关新闻公开报道之前。
## 背景知识
在预测市场中,有时会出现这样的现象:
1. 市场价格突然大幅波动
2. 但此时主流新闻媒体尚未报道相关事件
3. 随后(几小时或几天后),相关新闻才公开
这种"价格领先于新闻"的现象可能说明:
- 有知情人士提前获知了信息并进行交易
- 市场参与者通过社交媒体、小道消息等渠道获取了信息
- 纯粹的市场投机或技术性波动
## 你的工作流程
### 第一步:分析提供的 Web 搜索结果
- 分析与市场主题相关的最新新闻
- 特别关注新闻的发布时间
- 判断是否有重大新闻可以解释这次价格波动
### 第二步:分析 Twitter 社交媒体数据
- 分析提供的 Twitter 搜索结果
- 查看是否有早期的社交媒体讨论
- 关注 KOL、内部人士的发言时间
### 第三步:判断价格波动的性质
根据搜索结果,将价格波动分为以下几类:
1. **LEADING_SIGNAL(领先信号)**:价格波动明显早于公开新闻
- 搜索不到能解释波动的已发布新闻
- 或者找到的新闻发布时间晚于价格波动
- 这是我们最关注的类型!
2. **NEWS_DRIVEN(新闻驱动)**:价格波动是对已发布新闻的反应
- 找到了明确的相关新闻
- 新闻发布时间早于或接近价格波动时间
3. **SOCIAL_DRIVEN(社交驱动)**:价格波动由社交媒体讨论引发
- Twitter 上有大量讨论,但主流媒体尚未报道
- 介于领先信号和新闻驱动之间
4. **SPECULATION(投机波动)**:无明显信息来源的波动
- 搜索不到相关新闻或讨论
- 可能是纯粹的市场投机
**重要原则**
- 务必仔细分析提供的 Web 搜索结果中的最新新闻
- 仔细分析 Twitter 搜索结果
- 特别关注新闻和讨论的时间戳
- 如果是 LEADING_SIGNAL,详细记录证据"""
@staticmethod
def analyze_volatility(
market_question: str,
price_change_percent: float,
direction: str,
start_price: float,
end_price: float,
window_seconds: int,
detected_at: str,
twitter_context: str = "",
web_search_context: str = "",
) -> str:
"""
Get the prompt for analyzing a price volatility event.
Args:
market_question: The market question
price_change_percent: Price change as decimal (e.g., 0.25 for 25%)
direction: "UP" or "DOWN"
start_price: Starting price
end_price: Ending price
window_seconds: Time window in seconds
detected_at: Detection timestamp
twitter_context: Twitter search results
web_search_context: Web search results from Tavily
Returns:
Complete prompt for LLM
"""
direction_cn = "上涨" if direction == "UP" else "下跌"
window_minutes = window_seconds // 60
web_search_section = ""
if web_search_context:
web_search_section = f"""
---
## Web 搜索结果(新闻与分析)
以下是与该市场相关的最新网络搜索结果,请仔细分析发布时间和内容:
{web_search_context}
---
"""
twitter_section = ""
if twitter_context:
twitter_section = f"""
---
## Twitter 社交媒体搜索结果
以下是与该市场相关的 Twitter 实时讨论,请仔细分析发布时间和内容:
{twitter_context}
---
"""
return f"""## 价格异常波动检测报告
### 波动详情
- **市场问题**: {market_question}
- **价格变动**: {direction_cn} {abs(price_change_percent):.1%}
- **起始价格**: {start_price:.2%}
- **结束价格**: {end_price:.2%}
- **时间窗口**: {window_minutes} 分钟内
- **检测时间**: {detected_at}
{web_search_section}{twitter_section}
---
# 价格波动验证任务
你检测到了一次显著的价格异常波动,请判断这是否是一个"领先于新闻"的信号。
---
## 第一步:Web 搜索结果分析(必须分析!)
**请仔细分析上文提供的 Web 搜索结果,重点关注:**
1. 与"{market_question}"相关的最新新闻(重点关注过去24小时)
2. 可能触发这次价格波动的事件或公告
3. 每条新闻的发布时间
**Web 搜索结果摘要**
(请在此列出搜索结果中的关键新闻,必须包含发布时间)
---
## 第二步:Twitter 社交媒体分析
**分析上文提供的 Twitter 搜索结果:**
1. 最早的相关讨论是什么时候?
2. 讨论的主要内容是什么?
3. 是否有 KOL 或内部人士发言?
4. 社交媒体讨论是否早于主流新闻报道?
**Twitter 分析摘要**
(请在此总结 Twitter 上的关键信息和时间线)
---
## 第三步:时间线对比分析
**关键问题**:价格波动发生在新闻公开之前还是之后?
- 价格波动检测时间: {detected_at}
- 找到的最早相关新闻发布时间: [请填写]
- 找到的最早社交媒体讨论时间: [请填写]
**时间线结论**
(价格波动是领先于新闻,还是滞后于新闻?)
---
## 第四步:最终判定
基于以上分析,给出你的判断,并用以下 JSON 格式输出:
```json
{{
"signal_type": "LEADING_SIGNAL/NEWS_DRIVEN/SOCIAL_DRIVEN/SPECULATION",
"confidence": 0.0-1.0之间的数字,
"is_leading_signal": true/false,
"news_found": true/false,
"earliest_news_time": "找到的最早相关新闻的发布时间,格式 YYYY-MM-DD HH:MM UTC,如无则为 null",
"earliest_social_time": "找到的最早社交媒体讨论时间,格式 YYYY-MM-DD HH:MM UTC,如无则为 null",
"time_advantage_minutes": 价格领先于新闻的分钟数(如果是领先信号),否则为 0,
"key_news_headlines": ["相关新闻标题1", "相关新闻标题2"],
"key_social_posts": ["关键社交媒体帖子摘要1", "关键社交媒体帖子摘要2"],
"reasoning": "简要说明你的判断依据",
"potential_information_source": "推测的信息来源(如:内部人士、社交媒体泄露、官方提前通知等)"
}}
```
**判断标准**
- **LEADING_SIGNAL**: 价格波动发生时,Web 搜索不到相关新闻,或新闻发布时间明显晚于价格波动(>=30分钟)
- **NEWS_DRIVEN**: 找到了明确相关的新闻,且新闻发布时间早于或接近价格波动时间
- **SOCIAL_DRIVEN**: Twitter 上有早期讨论,但主流媒体尚未报道
- **SPECULATION**: 既没有新闻也没有社交讨论,可能是纯投机
**特别注意**
- is_leading_signal 为 true 时,必须详细说明证据
- time_advantage_minutes 表示价格领先于新闻的时间优势
- 这个数据将用于构建"价格领先于新闻"的研究数据集
---
⚠️ 免责声明:本分析仅供研究参考,不构成投资建议。"""
+183 -187
View File
@@ -1,4 +1,5 @@
"""Prompts for whale trade analysis."""
from datetime import datetime, timezone
from typing import List
@@ -7,243 +8,246 @@ class WhaleAnalyzerPrompts:
@staticmethod
def system_prompt() -> str:
"""Get the system prompt for whale trade analysis."""
return """你是一位专业的预测市场分析师和内幕交易识别专家,专门分析 Polymarket 上的大额异常交易。
"""System prompt for whale trade analysis with tool-use."""
current_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
**你的核心任务**:验证一笔"疑似异常交易"是否真的是"内幕交易"(即交易者掌握了市场尚未反映的信息)
return f"""你是一位专业的预测市场分析师和内幕交易识别专家,专门分析 Polymarket 上的大额异常交易
## 你的工作流程
**当前真实时间**{current_utc}
### 第一步:接收疑似异常交易信号
你会收到一笔被系统标记为"疑似异常"的交易,包含:
- 交易金额($5,000+的大额交易)
- 交易方向(BUY/SELL)和价格
- 交易者的排行榜排名和历史盈亏
- **交易者历史交易记录**(近期交易总数、交易总额、大额交易次数、活跃市场等)
## 你的核心任务
### 第二步:获取市场信息和历史异常信号
你会同时收到该交易对应的市场信息:
- 市场问题(预测的事件)
- 市场描述
- 当前各结果的价格/概率
- **历史异常交易信号**(如有):该市场之前检测到的其他异常交易记录
验证一笔"疑似异常交易"是否真的是"内幕交易"(即交易者掌握了市场尚未反映的信息)。
### 第三步:使用 Google Search 验证(关键步骤!)
**你必须使用 Google 搜索来验证这笔交易是否基于真实信息:**
- 搜索与市场主题相关的最新新闻(过去24-72小时)
- 查找是否有尚未被市场完全反映的重要信息
- 验证交易者的判断是否有公开信息支持
- 寻找任何可能触发这笔交易的事件
## 你会收到的数据
### 第四步:综合判断并生成报告
结合所有信息(当前交易 + 历史信号 + 搜索结果),判断:
- 这笔交易是"真正的内幕交易"还是"普通大额交易"
- 给出内幕交易可能性评分(0-100%
- 提供跟单建议(BUY/SELL/HOLD
每次分析任务,你将收到以下结构化数据(在 user message 中):
## 内幕交易识别框架
1. **交易详情** — 触发告警的鲸鱼交易:金额、方向(BUY Yes 或 BUY No)、买入价格、时间、交易者钱包地址、异常评分
2. **交易解读** — 方向含义(看多/看空)、隐含概率
3. **交易者画像(Trader Profile JSON** — 包含交易者的原始数据:
- `ranking`:排名、PnL、总交易量、是否验证、用户名
- `behavior`:总交易次数、总交易量、平均交易金额、大额交易次数及占比、活跃市场
- `recent_trades`:近期交易记录
6. **该鲸鱼在同一事件下其他市场的持仓** — 用于判断是否存在对冲、关联押注或套利(数据来自 Polymarket 持仓 API,是实时真实持仓)
7. **该市场 Top 5 多空双方交易者** — 看多方和看空方各 Top 5 交易者的排名、PnL、净交易量(反映聪明钱共识方向)
8. **市场信息** — 市场问题、描述、可能结果、当前赔率
9. **历史异常信号**(如有) — 该市场过去检测到的异常交易信号,用于趋势对比
1. **交易者可信度(基于排名)**
- 前100名 = HIGH(历史盈利能力强,信号可信度高)
- 100-500名 = MEDIUM(有一定实力,需验证)
- 500名+ = LOW(信号参考价值较低)
- 未上榜 = UNKNOWN(新手或小额交易者)
**你需要综合以上所有数据进行分析,不要忽略任何一个维度。**
2. **交易者历史行为分析(重要!)**:
- **大额交易频率**:频繁进行大额交易的交易者更可能是专业玩家或内幕人士
- **交易总额**:高交易总额表明资金实力雄厚,信号更可信
- **活跃市场**:如果交易者在相关市场有多次交易,说明对该领域有深入研究
- **平均交易金额**:平均金额高说明是专业大户,不是偶然的一次性大单
- **近期大额交易明细**:查看其他大额交易的方向和结果,判断其判断力
## 可用工具
3. **信息验证**
- 搜索是否有支持该交易方向的最新新闻
- 判断市场是否已经反映了这些信息
- 评估信息的时效性和可靠性
你可以调用以下工具来获取实时信息(所有结果都是真实的实时数据)
4. **综合判断标准**
- 高排名 + 频繁大额交易 + 有最新未反映信息 + 历史信号方向一致 = 高度可疑内幕交易 (0.8+)
- 高排名 + 有历史记录 + 无明显信息 = 可能基于深度分析 (0.5-0.7)
- 低排名/未上榜 + 首次大额交易 + 无信息 + 无历史信号 = 普通投机交易 (<0.4)
- 未上榜但有大量历史交易记录 = 可能是隐藏的专业玩家,需要重点关注
- **search_web**: 搜索网络新闻和分析文章。适用于:验证事件、官方公告、监管新闻、财报、法院裁决、立法进度等。
- **search_twitter**: 搜索 Twitter/X 社交媒体。适用于:实时舆情、KOL 观点、加密社区反应、突发消息等。
- **search_telegram**: 搜索 Telegram 频道(吴说区块链、Whale Alert、Polymarket 官方及新闻频道等)。适用于:加密货币内幕消息、代币发行公告、鲸鱼链上转账提醒,以及 Polymarket 社区对各类市场(地缘政治、经济、政治等)的讨论和情报。
- **get_crypto_price**: 获取加密货币实时行情(价格、24h/7d/30d 涨跌幅、市值、成交量、ATH)。适用于:涉及加密货币价格目标的市场(如"BTC 是否会达到 $100k")。
- **get_crypto_market_overview**: 获取全球加密市场概览(总市值、BTC/ETH 占比、24h 变化)。适用于:判断整体加密市场情绪。
- **get_economic_data**: 获取 FRED 宏观经济数据。支持:fed_rate、cpi、unemployment、gdp、oil_price、wti、brent、gold、vix、sp500、yield_curve、jobless_claims 等。适用于:Fed 政策市场、通胀市场、就业数据、原油/商品价格、衰退指标。
- **get_stock_price**: 获取股票/ETF 实时行情快照(价格、涨跌幅、成交量)。支持:AAPL、TSLA、GS、SPY、QQQ、GLD、USO 等。适用于:涉及具体公司或行业的市场。
- **get_stock_news**: 获取股票/公司的最新新闻。适用于:公司事件(IPO、财报、诉讼、并购)、CEO 言论、监管行动。
- **get_bill_status**: 获取美国国会特定法案的状态(需要 congress 编号、法案类型和编号)。适用于:涉及具体立法的市场(如 TikTok 禁令、加密货币监管、移民法案)。
- **get_recent_legislation**: 获取最近更新的美国国会法案列表。适用于:了解当前立法动态、政治类市场。
- **get_protocol_tvl**: 获取 DeFi 协议 TVL(锁仓量)、TVL 变化(1h/24h/7d)、链分布。适用于:代币发行 FDV 市场、DeFi 协议基本面评估、项目健康度判断。
- **get_token_unlocks**: 获取代币解锁/归属时间表。适用于:评估代币供应动态、FDV 市场、预判解锁卖压。
- **get_protocol_revenue**: 获取 DeFi 协议费用和收入(24h/7d/30d/历史总计)。适用于:评估协议基本面、对比收入与 FDV 是否合理。
- **get_wallet_transfers**: 获取以太坊钱包的近期 ERC-20 代币转账(USDC/USDT/WETH/DAI)。适用于:检查鲸鱼是否刚收到大额 USDC 转入(为交易准备资金)、追踪钱包资金流向。
- **get_contract_info**: 查询以太坊地址是否为智能合约、合约名称、验证状态。适用于:验证项目是否已部署合约、判断代币发行市场的项目进度。
**重要原则**
- **务必使用 Google Search!** 不要仅依赖你的历史知识
- **重视交易者历史记录!** 这是判断交易者专业性的关键依据
- **如果有历史异常交易信号,务必结合这些信号进行对比分析!** 这能帮助你了解该市场的交易模式和趋势
- 关注过去24-72小时的最新动态
- 如果搜索不到支持信息,内幕交易可能性应该降低
- 信心不足时建议观望(HOLD"""
**工具使用原则**
- 根据市场类型和交易特征,自行判断需要调用哪些工具
- 可以调用一个、多个或零个工具
- 可以用不同的关键词多次调用同一工具
- 如果交易金额特别大或内幕嫌疑高,应更积极地搜索验证
**工具协作与交叉验证(重要)**
- 不同工具获取到的信息必须**交叉验证**,不要仅凭单一信息源下结论。例如:网页搜索发现某政策传闻,应再用 Twitter 搜索验证舆论反应,用经济数据佐证影响
- 在使用一个工具的过程中,如果发现了新的线索或关键词,**应立即调用其他工具追查**。例如:搜索新闻发现某官员辞职,应继续搜索该官员的名字获取更多细节,同时搜索 Twitter 看是否有未被报道的内部消息
- 多个工具的结果**互相矛盾**时,应明确标注分歧并降低信心,而非选择性采信
- 鼓励"搜索链"式调查:第一轮搜索→发现线索→针对性二轮搜索→深入三轮搜索,逐层深入而非浅尝辄止
## Polymarket 交易机制
交易数据为 taker 的真实买入行为(已过滤掉 SELL/平仓交易),**无任何归一化处理**:
- **BUY Yes** = 买入 Yes Token = **看多**(认为事件会发生)
- **BUY No** = 买入 No Token = **看空**(认为事件不会发生)
- **价格**为 taker 实际买入价格(0.0~1.0),越低说明赔率越高、不确定性越大
- 例如 BUY Yes @ 0.06 = 花 $0.06 买一份,若事件发生获得 $1(约17倍赔率)
- 例如 BUY No @ 0.30 = 花 $0.30 买一份,若事件不发生获得 $1(约3.3倍赔率)
- **交易金额**usdc_size)为 taker 的真实 USDC 花费
- 我们只关注买入价 ≤ 0.7 的交易(高价买入确定性太高,无信号价值)
## 分析框架
### 交易者可信度
交易者可信度(HIGH/MEDIUM/LOW/UNKNOWN)应综合所有可用的原始数据评定,不要仅依据单一指标。评定时请考虑:
- **排名**:排名越靠前(数字越小),交易者越可能是经验丰富的参与者。null 表示未上榜
- **PnL**:累计盈亏金额直接反映交易者的历史表现,高 PnL 比高排名更能说明实力
- **交易行为**:总交易次数、平均交易金额、大额交易占比等反映交易者的风格和经验
- **活跃市场**:近期参与的市场类型反映交易者的专长领域,与当前市场主题是否匹配
- **近期交易记录**:具体的买卖方向、金额和价格,帮助判断交易者的策略模式
### 内幕交易可信度判断标准(必须严格遵守)
**"内幕交易"的定义非常严格**:交易者必须掌握了市场尚未反映的、非公开的、具体的信息(如未公布的政策决定、未发布的数据、私下谈判结果等)。仅仅是"聪明的分析""经验丰富""排名高"都**不构成**内幕交易。
**评分校准基准(大多数交易应落在 0.2-0.5 之间)**:
- **0.8-1.0(极高)**: 仅当发现**明确的非公开信息证据**时才可给出。例如:交易时间精准在重大公告前数小时,且该公告完全不可预测;或交易者有已知的信息渠道(如政治内部人士身份)。**极少数交易应达到此级别。**
- **0.6-0.8(高)**: 高排名交易者 + 交易时机与即将发生的未定价事件高度吻合 + 搜索发现了市场尚未充分反映的具体信息。需要多个强证据同时满足。
- **0.4-0.6(中等)**: 高排名交易者的大额交易 + 有一定信息支撑但不确定是否为内幕。这是**大多数有一定可疑度的交易**应该落在的区间。
- **0.2-0.4(低)**: 有一些异常特征但缺乏信息支撑,或交易者排名一般。**大多数普通鲸鱼交易**应该在这个范围。
- **0.0-0.2(极低)**: 未上榜交易者的常规交易,无任何异常信号。
**常见的错误高估场景(必须避免)**:
- ❌ 仅因为交易者排名高就给 0.7+(高排名交易者每天做很多交易,绝大多数不是内幕交易)
- ❌ 仅因为交易金额大就给 0.6+(大额交易是鲸鱼的常规操作)
- ❌ 短期价格预测市场(如"Bitcoin Up or Down 5分钟")给高分(这类市场几乎不可能有内幕信息)
- ❌ 临近到期的市场、价格接近 0 或 1 的交易给高分(这通常是市场共识的正常体现)
- ❌ 搜索到的信息都是公开新闻时给高分(公开信息 ≠ 内幕信息)
- ❌ 大型地缘政治/宏观市场(如伊朗局势、总统弹劾等)轻易给高分 — 这类市场参与者众多、信息源复杂,鲸鱼交易大多反映公开分析而非内幕
**应该重点关注的高价值场景**
- ✅ **小众市场**(日交易量 < $500k)的大额交易 — 参与者少、信息差大、鲸鱼信号更有意义
- ✅ **新项目/代币发行**(FDV、TGE、公售)— 项目方和早期投资者可能有未公开信息
- ✅ **具体可验证事件**(某人是否会做某事、某公司是否会公布某决定)— 知情者范围小、信息明确
- ✅ **冷门市场突然出现高排名交易者大额交易** — 反常行为是最强信号
## 事件关联持仓分析
交易数据中会包含鲸鱼在同一事件(Event)下其他市场的持仓情况。你需要综合分析:
- **对冲识别**:如果鲸鱼在同一事件的不同市场持有反向仓位,可能是对冲策略而非单方向押注,应降低内幕可信度
- **关联押注**:如果鲸鱼在同一事件的多个市场持有同向仓位(如同时看多多个相关市场),这增强了信号强度
- **套利行为**:同一事件下价格不一致时,鲸鱼可能在做套利,这不是内幕交易信号
## 市场多空力量分析
交易数据中会包含该市场 Top 5 买方和卖方的排名与持仓。你需要分析:
- **聪明钱共识**:如果多个高排名、高盈利的交易者站在同一方,信号更强
- **对手方分析**:如果鲸鱼的对手方都是低排名交易者,信号更可靠;如果对手方也有高排名交易者,则需要更谨慎
- **市场集中度**:如果某一方的持仓高度集中在少数大户,市场可能更容易出现剧烈波动
## 重要原则
- 主动使用工具获取最新信息来验证交易
- 搜索不到支持信息时,内幕可能性应降低
- 信心不足时建议 HOLD
- 鲸鱼也可能犯错或有其他动机(对冲、试探等)
- 综合事件关联持仓和市场多空力量做出更全面的判断
- **时间判断**:不要猜测未知的事件时间(如比赛开始时间)。如果需要判断交易发生在事件之前还是之后,必须用工具搜索确认事件时间,而非凭空推测"""
@staticmethod
def analyze_whale_trade(trade_context: str, historical_context: str = "") -> str:
"""
Get the prompt for analyzing a whale trade.
Build the user prompt for analyzing a whale trade.
Args:
trade_context: Formatted trade context from AnomalyDetector
historical_context: Formatted historical reports context (optional)
historical_context: Historical anomaly signals (optional)
Returns:
Complete prompt for LLM
Complete user prompt
"""
history_section = ""
if historical_context:
history_section = f"""
---
{historical_context}
---
"""
return f"""{trade_context}
{history_section}
---
# 鲸鱼交易验证报告
# 鲸鱼交易验证任务
你收到了一笔**疑似异常交易信号**,请按照以下步骤验证这是否是"真正的内幕交易"
## 第 0 步:预筛选(必须首先完成)
在进行任何搜索和分析之前,先判断这笔信号是否值得生成完整报告。
**筛选标准:**
- **优先分析(低门槛)**: 小众市场、加密货币/代币发行相关(FDV、TGE、公售、协议治理)、具体可验证事件、冷门市场突然出现大额交易
- **门槛更高(需要信号特别强)**: 大型地缘政治市场(战争、制裁、外交)、宏观经济/Fed利率/选举等参与者众多的大市场
- **直接跳过**: 体育/赛事结果、价格已接近 0 或 1 的市场(≥0.95 或 ≤0.05
综合交易金额、交易者排名和画像、异常评分、市场类型判断。
**如果判定不值得分析,直接输出以下 JSON 并结束,不要进行后续步骤:**
```json
{{{{"action": "SKIP", "reason": "一句话理由"}}}}
```
**如果判定值得分析,继续以下步骤。**
---
## 第一步:Google 搜索验证(必须执行!)
## 请完成以下步骤:
**请立即使用 Google Search 搜索以下内容:**
### 1. 信息搜集
根据市场主题,使用可用工具搜索相关信息:
- 该市场主题的最新新闻和动态
- 社交媒体上的讨论和舆情
- 任何可能触发这笔交易的事件
1. 搜索该市场主题的最新新闻(过去24-72小时)
2. 搜索可能影响结果的关键人物/组织的最新动态
3. 搜索任何可能触发这笔交易的突发事件
### 2. 交易信号分析
- 交易者排名和历史盈亏表现
- 结构化画像(排名、PnL、交易行为数据、近期交易记录)
- 交易时机是否异常
**搜索结果摘要**
(请在此列出你搜索到的关键信息,包括来源和时间)
### 3. 事件关联持仓分析
- 该鲸鱼在同一事件的其他市场是否有持仓?
- 如果有反向持仓(如同时持有 Yes 和 No,或在相关市场对冲),可能是对冲/套利策略,应降低内幕可信度
- 如果同方向押注多个关联市场,则信号增强
---
### 4. 市场多空力量分析
- Top 5 看多方和看空方分别是谁?排名如何?
- 高排名、高盈利的交易者集中在哪一方?这代表聪明钱的共识
- 该鲸鱼的对手方资质如何?如果对手方也有高排名交易者,需更谨慎
## 第二步:交易信号分析
### 2.1 交易者排名评估
- 交易者排名意味着什么?(HIGH/MEDIUM/LOW/UNKNOWN
- 其历史盈亏(PnL)表现如何?
- 交易量规模如何?
### 2.2 交易者历史行为分析(重要!)
根据提供的交易者历史交易记录,分析:
- **交易活跃度**:近期交易总数和交易总额说明什么?
- **大额交易习惯**:该交易者是否经常进行大额交易?大额交易次数有多少?
- **平均交易规模**:平均交易金额是多少?本次交易与其平均水平相比如何?
- **活跃市场领域**:交易者主要在哪些市场活跃?是否与本次交易的市场相关?
- **近期大额交易表现**:查看其他大额交易的方向,判断其整体判断力
### 2.3 交易时机分析
- 这笔交易发生的时间点是否异常?
- **结合搜索结果**:是否有近期新闻可能触发了这笔交易?
- 交易者是否可能掌握了市场尚未反映的信息?
---
## 第三步:市场信息验证
### 3.1 当前市场状态
- 市场价格是否已经反映了最新信息?
- 交易价格与当前市场价格的关系如何?
### 3.2 信息差分析
- **关键问题**:搜索到的最新信息是否支持这笔交易的方向?
### 5. 信息差分析
- 搜索到的信息是否支持这笔交易的方向?
- 这些信息是否已被市场完全定价?
- 如存在信息差,幅度有多大?
- 如存在信息差,幅度有多大?
---
### 6. 历史信号对比(如有)
- 历史信号与当前信号的方向是否一致?
- 是否有高排名交易者参与?
- 交易金额和价格的趋势如何?
## 第四步:历史异常信号分析(如有历史信号)
### 7. 信息不对称评估
如果上文提供了历史异常交易信号,请将历史信号与当前信号一起进行分析
评估交易者相对于公开信息的信息优势。核心逻辑
- I_public = 你通过所有工具能获取到的公开信息集合
- I_trader = 交易者做出该交易决策所依据的信息集合
- 信息不对称 = I_trader - I_public
- 如果公开信息已能充分解释交易行为 → 分数低
- 如果公开信息无法解释交易行为(交易者可能有额外信息源、领域专长、数据速度优势)→ 分数高
### 4.1 交易方向对比
- 历史信号与当前信号的交易方向(BUY/SELL)是否一致?
- 如果方向一致,说明该市场持续有资金流入同一方向,内幕交易可能性提高
- 如果方向相反,需要分析原因(时间变化、新信息出现、不同交易者的判断)
### 4.2 交易者对比分析
- 对比各信号的交易者排名和历史记录
- 是否有高排名交易者(前100名)参与?
- 是否有"聪明钱"流入某一方向?
- 同一个钱包是否多次出现?
### 4.3 趋势演变分析
- 交易金额是否在增加?(信心增强的信号)
- 交易价格的变化趋势如何?
- 两笔或多笔交易之间的时间间隔有多长?
### 4.4 综合评估
- 结合所有信号的交易者特征和交易模式
- 结合 Google 搜索结果验证
- 给出对该市场异常交易活动的统一判断
---
## 第五步:内幕交易判定
### 5.1 内幕交易可能性评估
综合以上分析,判断这笔交易是:
- **真正的内幕交易**:交易者确实掌握了市场未反映的信息
- **深度分析交易**:交易者基于公开信息的深度分析
- **普通投机交易**:没有明显信息优势
### 5.2 关键证据
列出支持你判断的关键证据(来自搜索结果)
---
## 第六步:跟单风险提示
- 鲸鱼也可能犯错或有其他动机(对冲、试探等)
- 市场可能已经部分反映了该信息
- 搜索结果可能不完整
---
## 第七步:最终决策
基于以上分析,给出你的交易建议,并用以下JSON格式输出决策:
输出 JSON 格式评估:
```json
{{
"action": "BUY/SELL/HOLD",
"outcome": "你建议交易的结果选项",
"confidence": 0.0-1.0之间的数字,
"insider_trading_likelihood": 0.0-1.0之间的数字(内幕交易可能性评估),
"information_asymmetry_score": 0.0-1.0,
"trader_credibility": "HIGH/MEDIUM/LOW/UNKNOWN",
"suggested_price": 建议的交易价格,
"suggested_size_percent": 0.0-1.0之间的数字(建议使用资金的比例),
"reasoning": "简要说明你的推理过程",
"insider_evidence": "支持内幕交易判断的关键证据"
"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格式
---
**免责声明**:本报告仅供参考,不构成投资建议。预测市场具有高风险,请用户基于自身判断谨慎决策。"""
- information_asymmetry_score 必须严格校准:大多数交易应在 0.2-0.5,只有发现明确的信息优势证据时才给 0.7+
- 信息优势包括但不限于:领域专长、数据源速度差、非公开渠道、精准的时机把握
- 仅凭交易者排名高或交易金额大,information_asymmetry_score 不应超过 0.5
- 确保输出有效 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
"""
"""Superforecaster-style analysis prompt."""
outcomes_str = ", ".join(outcomes)
return f"""作为一名超级预测者,请对以下预测市场进行分析:
@@ -295,15 +299,7 @@ class WhaleAnalyzerPrompts:
@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
"""
"""Quick decision prompt for time-sensitive situations."""
return f"""快速分析以下鲸鱼交易并给出建议:
{trade_summary}
+2
View File
@@ -3,10 +3,12 @@ from .market_fetcher import MarketFetcher
from .trade_monitor import TradeMonitor
from .anomaly_detector import AnomalyDetector
from .llm_analyzer import LLMAnalyzer
from .twitter_search import TwitterSearchService
__all__ = [
"MarketFetcher",
"TradeMonitor",
"AnomalyDetector",
"LLMAnalyzer",
"TwitterSearchService",
]
+255 -154
View File
@@ -1,125 +1,263 @@
"""Anomaly detection service - filters and validates whale trades."""
"""Anomaly detection service - multi-dimensional scoring for whale trades."""
import logging
from typing import List, Optional
import time
from collections import defaultdict, deque
from typing import Dict, List, Optional, Tuple
from datetime import datetime, timedelta
from src.config import get_settings
from src.models.trade import WhaleTrade, TradeActivity
from src.models.trade import WhaleTrade, TradeActivity, TraderHistory
from src.models.market import Market
from src.services.trader_profiler import TraderProfiler
logger = logging.getLogger(__name__)
class AnomalyDetector:
"""
Detects anomalous (whale) trades based on configurable criteria.
Multi-dimensional anomaly detection for whale trades.
Criteria:
- Trade size >= MIN_TRADE_SIZE_USD (default: $10,000)
- Trade price between MIN_PRICE and MAX_PRICE (default: 0.2-0.8)
Scoring dimensions:
1. Size relative to market (trade vs market 24h volume)
2. Price uncertainty (closer to 0.5 = more uncertain = more interesting)
3. Time-of-day (off-peak hours = more suspicious)
4. Trader deviation (trade size vs trader's historical average)
5. Cluster signal (multiple same-direction trades in short window)
"""
# --- Time-of-day weights (US Eastern Time) ---
# Polymarket is US-dominated, so we use ET to judge trading hour anomaly.
# Higher weight = more unusual trading hour = more suspicious.
_ET_HOUR_WEIGHTS = {
# ET 0-5 (midnight to 5am) — very unusual, most suspicious
0: 0.6, 1: 0.7, 2: 0.8, 3: 0.9, 4: 0.8, 5: 0.6,
# ET 6-8 (early morning) — some early traders
6: 0.4, 7: 0.3, 8: 0.2,
# ET 9-17 (US business hours) — peak activity, least suspicious
9: 0.1, 10: 0.0, 11: 0.0, 12: 0.0, 13: 0.0,
14: 0.0, 15: 0.0, 16: 0.0, 17: 0.1,
# ET 18-20 (evening) — moderate
18: 0.2, 19: 0.2, 20: 0.3,
# ET 21-23 (late night) — unusual
21: 0.4, 22: 0.5, 23: 0.5,
}
# UTC offset for US Eastern: -5 (EST) or -4 (EDT).
# Use -4 as default (EDT covers ~Mar-Nov, most of the year).
_ET_UTC_OFFSET = -4
# Cluster detection: track recent trades per market
# Key: market_id, Value: deque of (timestamp, side, usdc_size)
_CLUSTER_WINDOW_SECONDS = 300 # 5 minutes
_CLUSTER_MIN_COUNT = 3 # minimum trades for cluster signal
def __init__(self):
self.settings = get_settings()
self.trader_profiler = TraderProfiler()
# Recent trades for cluster detection: market_id -> deque
self._recent_trades: Dict[str, deque] = defaultdict(
lambda: deque(maxlen=50)
)
def is_anomalous_trade(self, activity: TradeActivity) -> bool:
# ================================================================
# Core scoring
# ================================================================
def get_anomaly_score(
self,
activity: TradeActivity,
market: Optional[Market] = None,
trader_history: Optional[TraderHistory] = None,
market_id: str = "",
) -> Tuple[float, dict]:
"""
Check if a trade is anomalous based on size and price.
Args:
activity: The trade activity to check
Calculate multi-dimensional anomaly score.
Returns:
True if the trade is anomalous
(total_score, breakdown_dict) where breakdown has per-dimension scores.
"""
# Check trade size
if activity.usdc_size < self.settings.min_trade_size_usd:
return False
breakdown = {}
# 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
# --- 1. Size score (absolute) ---
# $5k=0.1, $20k=0.25, $50k=0.4, $100k+=0.5
raw_size = min(0.5, 0.1 + (activity.usdc_size - 5000) / 250000)
breakdown["size_abs"] = max(0.0, raw_size)
return True
# --- 2. Size relative to market volume ---
if market and market.volume_24hr > 0:
# What fraction of 24h volume is this single trade?
ratio = activity.usdc_size / market.volume_24hr
# ratio 0.001=noise, 0.01=notable, 0.05=significant, 0.1+=massive
rel_score = min(0.3, ratio * 6.0) # 0.05 ratio -> 0.3
breakdown["size_relative"] = rel_score
else:
breakdown["size_relative"] = 0.15 # unknown market volume, use neutral
def get_anomaly_score(self, activity: TradeActivity) -> float:
# --- 3. Price uncertainty ---
# Price is taker's buy price (no normalization).
# Lower price = more uncertain/risky bet = more interesting.
# 0.5 -> 0.2, 0.3/0.7 -> 0.1, 0.1/0.9 -> 0.0
dist = abs(activity.price - 0.5)
if dist <= 0.3:
price_score = 0.2 * (1 - dist / 0.3)
else:
price_score = 0.0
breakdown["price_uncertainty"] = price_score
# --- 4. Time-of-day ---
utc_hour = datetime.utcfromtimestamp(activity.timestamp).hour
et_hour = (utc_hour + self._ET_UTC_OFFSET) % 24
breakdown["time_of_day"] = self._ET_HOUR_WEIGHTS.get(et_hour, 0.1) * 0.15
# --- 5. Trader deviation ---
if trader_history and trader_history.avg_trade_size > 0:
# How many X of their average is this trade?
multiple = activity.usdc_size / trader_history.avg_trade_size
# 1x=normal, 2x=notable, 5x=very unusual, 10x+=extreme
if multiple >= 5:
deviation_score = 0.15
elif multiple >= 2:
deviation_score = 0.05 + (multiple - 2) / 3 * 0.10
else:
deviation_score = 0.0
breakdown["trader_deviation"] = deviation_score
else:
# Unknown trader history — slightly suspicious
breakdown["trader_deviation"] = 0.05
# --- 6. Cluster signal ---
cluster_score = self._get_cluster_score(activity, market_id=market_id)
breakdown["cluster"] = cluster_score
# --- 7. Niche market bonus ---
# Small/niche markets have higher information asymmetry value.
# Large political/macro markets (volume > $5M/day) are noisy;
# small markets (< $500k/day) are where insider signals matter most.
if market and market.volume_24hr > 0:
vol = market.volume_24hr
if vol < 100_000:
niche_score = 0.15 # very niche
elif vol < 500_000:
niche_score = 0.10
elif vol < 2_000_000:
niche_score = 0.05
else:
niche_score = 0.0 # large/macro market, no bonus
breakdown["niche_market"] = niche_score
else:
breakdown["niche_market"] = 0.05
# --- Total ---
total = sum(breakdown.values())
total = min(1.0, max(0.0, total))
return total, breakdown
def record_trade(self, activity: TradeActivity, market_id: str):
"""Record a trade for cluster detection. Call for every trade, not just whales."""
self._recent_trades[market_id].append((
activity.timestamp,
activity.side,
activity.usdc_size,
))
def _get_cluster_score(self, activity: TradeActivity, market_id: str = "") -> float:
"""
Calculate an anomaly score for a trade.
Check if there are multiple same-direction trades in a short window.
Higher score = more interesting anomaly.
Args:
activity: The trade activity to score
Returns:
Anomaly score between 0 and 1
A cluster of BUY or SELL in the same market in 5 minutes suggests
coordinated or informed trading.
"""
if not self.is_anomalous_trade(activity):
# Use the same market_id key as record_trade()
key = market_id or activity.condition_id
recent = self._recent_trades.get(key)
if not recent:
return 0.0
score = 0.0
now = activity.timestamp
cutoff = now - self._CLUSTER_WINDOW_SECONDS
# 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
# Count same-direction trades in window
same_dir_count = 0
same_dir_volume = 0.0
for ts, side, size in recent:
if ts >= cutoff and side == activity.side:
same_dir_count += 1
same_dir_volume += size
# 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)
if same_dir_count >= self._CLUSTER_MIN_COUNT:
# 3 trades = 0.05, 5+ = 0.10, volume also matters
count_score = min(0.10, 0.02 * same_dir_count)
vol_bonus = min(0.05, same_dir_volume / 500000)
return count_score + vol_bonus
return min(1.0, score)
return 0.0
# ================================================================
# Pre-filter (before LLM)
# ================================================================
def should_analyze(
self,
activity: TradeActivity,
market: Optional[Market] = None,
trader_history: Optional[TraderHistory] = None,
market_id: str = "",
min_score: float = 0.40,
) -> Tuple[bool, float, dict]:
"""
Decide whether a whale trade warrants LLM analysis.
Returns:
(should_analyze, score, breakdown)
"""
score, breakdown = self.get_anomaly_score(
activity, market, trader_history, market_id=market_id,
)
return score >= min_score, score, breakdown
# ================================================================
# Legacy compatibility
# ================================================================
def is_anomalous_trade(self, activity: TradeActivity) -> bool:
"""Check if a trade is anomalous based on size and price."""
if activity.usdc_size < self.settings.min_trade_size_usd:
return False
if not (self.settings.min_price <= activity.price <= self.settings.max_price):
return False
return True
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
"""
"""Filter whale trades by anomaly score."""
filtered = []
for trade in trades:
score = self.get_anomaly_score(trade.trade)
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
# ================================================================
# LLM context formatting
# ================================================================
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
"""
"""Analyze the context of a whale trade for LLM input."""
trade = whale_trade.trade
# Determine trade direction interpretation
if trade.side == "BUY":
direction_meaning = f"The trader is betting FOR '{trade.outcome}' occurring"
# Direction interpretation (only BUY trades, no normalization)
if trade.outcome == "Yes":
direction_meaning = f"交易者买入 Yes Token @ {trade.price:.4f},看多(认为事件会发生)"
else:
direction_meaning = f"The trader is betting AGAINST '{trade.outcome}' occurring"
direction_meaning = f"交易者买入 No Token @ {trade.price:.4f},看空(认为事件不会发生)"
# Calculate implied probability from price
implied_prob = trade.price if trade.side == "BUY" else (1 - trade.price)
# Buy price directly reflects taker's conviction — lower price = higher odds bet
implied_prob = trade.price
# Assess market state from outcome prices
# Market state
market_state = "uncertain"
if whale_trade.market_outcome_prices:
max_price = max(whale_trade.market_outcome_prices)
@@ -128,12 +266,8 @@ class AnomalyDetector:
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"
# Multi-dimensional anomaly score
score, breakdown = self.get_anomaly_score(trade)
return {
"trade_size_usd": trade.usdc_size,
@@ -143,98 +277,61 @@ class AnomalyDetector:
"direction_meaning": direction_meaning,
"implied_probability": implied_prob,
"market_state": market_state,
"conviction_level": conviction,
"anomaly_score": self.get_anomaly_score(trade),
"anomaly_score": score,
"anomaly_breakdown": breakdown,
"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
"""
"""Format whale trade data for LLM analysis."""
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"])
):
for outcome, price in 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 = """
### 交易者排名信息
- 该交易者不在盈利排行榜上(可能是新用户或小额交易者)
"""
# Trader profile
trader_profile = self.trader_profiler.generate_profile(
wallet_address=trade.proxy_wallet or "Unknown",
ranking=whale_trade.trader_ranking,
history=whale_trade.trader_history,
)
trader_profile_str = self.trader_profiler.format_profile_for_llm(trader_profile)
# 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 = """
### 交易者历史交易记录
- 无法获取该交易者的历史交易记录
"""
# Anomaly breakdown string
bd = context["anomaly_breakdown"]
breakdown_str = (
f" 绝对金额: {bd.get('size_abs', 0):.2f} | "
f"相对市场: {bd.get('size_relative', 0):.2f} | "
f"价格不确定性: {bd.get('price_uncertainty', 0):.2f} | "
f"交易时间: {bd.get('time_of_day', 0):.2f} | "
f"交易者偏离: {bd.get('trader_deviation', 0):.2f} | "
f"聚集信号: {bd.get('cluster', 0):.2f}"
)
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')}
- **交易方向**: BUY {context['trade_outcome']} Token ({'看多' if context['trade_outcome'] == 'Yes' else '看空'})
- **买入价格**: {context['trade_price']:.4f}(赔率约 {1/context['trade_price']:.1f}x
- **交易时间**: {datetime.fromtimestamp(trade.timestamp).strftime('%Y-%m-%d %H:%M:%S UTC')}
- **交易者钱包**: {trade.proxy_wallet or 'Unknown'}
- **异常评分**: {context['anomaly_score']:.2f}/1.00
### 异常评分
- **综合评分**: {context['anomaly_score']:.2f}/1.00
- **评分分解**:
{breakdown_str}
### 交易解读
- **方向含义**: {context['direction_meaning']}
- **隐含概率**: 交易者认为结果发生的概率约为 {context['implied_probability']:.2%}
- **信心程度**: {context['conviction_level']}
{ranking_str}{history_str}
{trader_profile_str}
### 市场信息
- **市场问题**: {context['market_question']}
- **市场描述**: {whale_trade.market_description or 'N/A'}
@@ -242,12 +339,16 @@ class AnomalyDetector:
- **当前赔率**:
{prices_str}
### 分析要点
1. 这是一笔 ${context['trade_size_usd']:,.2f} 的大额交易,表明交易者有{context['conviction_level']}的信心
2. 交易价格 {context['trade_price']:.4f} 说明市场尚未形成明确共识
3. {context['direction_meaning']}
4. **请重点分析交易者的排名和历史交易记录,判断其专业性和可信度**
5. 这可能暗示交易者掌握了某些市场尚未充分反映的信息
{whale_trade.format_event_positions()}
请分析这笔交易并给出你的交易建议。
{whale_trade.format_top_traders()}
### 分析要点
1. 这是一笔 ${context['trade_size_usd']:,.2f} 的大额交易,方向为 **BUY {context['trade_outcome']} Token**
2. {context['direction_meaning']}
3. **重点分析上方的 Trader Profile JSON,综合排名、PnL、交易行为和近期交易记录判断交易者可信度**
4. **注意分析该鲸鱼在同一事件下的其他持仓** — 如果持有反向仓位可能是对冲策略
5. **参考该市场 Top 多空持仓者的阵营** — 高排名交易者集中在哪一方
请分析这笔交易的内幕交易可能性。
"""
+26 -194
View File
@@ -1,11 +1,8 @@
"""Anomaly history service - stores and retrieves historical anomaly signals by market."""
import json
"""Anomaly history service - stores and retrieves historical anomaly signals via SQLite."""
import logging
import os
import re
from pathlib import Path
from typing import List, Optional
from src.db.database import SignalDatabase
from src.models.anomaly_signal import AnomalySignal
logger = logging.getLogger(__name__)
@@ -15,109 +12,41 @@ class AnomalyHistoryService:
"""
Service for storing and retrieving historical anomaly signals.
Anomaly signals are stored in JSON files, organized by market.
Only trades with medium or higher insider trading likelihood (>= 0.4) are stored.
Backend: SQLite via SignalDatabase.
All analyzed signals are stored for tracking accuracy.
Only signals with likelihood >= 0.4 are used as historical context for LLM.
"""
# Minimum insider trading likelihood to store a signal
MIN_INSIDER_LIKELIHOOD = 0.4
# Minimum insider trading likelihood to use as historical context for LLM
MIN_CONTEXT_LIKELIHOOD = 0.4
def __init__(self, storage_dir: Optional[Path] = None):
def __init__(self, db_path: str = "data/signals.db"):
"""
Initialize the anomaly history service.
Args:
storage_dir: Path to the storage directory. Defaults to project's anomaly_signals dir.
db_path: Path to the SQLite database file.
"""
if storage_dir is None:
self.storage_dir = Path(__file__).parent.parent.parent / "anomaly_signals"
else:
self.storage_dir = storage_dir
# Ensure storage directory exists
self.storage_dir.mkdir(parents=True, exist_ok=True)
def _sanitize_market_id(self, market_id: str) -> str:
"""
Sanitize market ID for use in filename.
Args:
market_id: The market ID
Returns:
Sanitized market ID safe for filenames
"""
# Keep only alphanumeric characters and hyphens
return re.sub(r'[^\w\-]', '_', market_id)
def _get_market_filepath(self, market_id: str) -> Path:
"""
Get the filepath for a market's anomaly signals.
Args:
market_id: The market ID
Returns:
Path to the market's anomaly signals file
"""
sanitized_id = self._sanitize_market_id(market_id)
return self.storage_dir / f"{sanitized_id}.json"
self.db = SignalDatabase(db_path)
def should_store_signal(self, insider_likelihood: float) -> bool:
"""
Check if a signal should be stored based on insider trading likelihood.
Args:
insider_likelihood: The insider trading likelihood score (0-1)
Returns:
True if the signal should be stored, False otherwise
"""
return insider_likelihood >= self.MIN_INSIDER_LIKELIHOOD
"""All analyzed signals should be stored for accuracy tracking."""
return True
def store_signal(self, signal: AnomalySignal) -> bool:
"""
Store an anomaly signal for a market.
Store an anomaly signal for accuracy tracking.
Args:
signal: The anomaly signal to store
Returns:
True if stored successfully, False otherwise
All analyzed signals are stored regardless of likelihood.
"""
if not self.should_store_signal(signal.insider_trading_likelihood):
logger.debug(
f"Signal not stored: insider likelihood {signal.insider_trading_likelihood:.2f} "
f"below threshold {self.MIN_INSIDER_LIKELIHOOD}"
)
return False
filepath = self._get_market_filepath(signal.market_id)
# Load existing signals
existing_signals = self._load_signals_from_file(filepath)
# Check for duplicate (same transaction hash)
for existing in existing_signals:
if existing.transaction_hash == signal.transaction_hash:
logger.debug(f"Signal already exists for transaction: {signal.transaction_hash}")
return False
# Add new signal
existing_signals.append(signal)
# Save back to file
try:
self._save_signals_to_file(filepath, existing_signals)
stored = self.db.insert_signal(signal)
if stored:
logger.info(
f"Stored anomaly signal for market {signal.market_id}: "
f"Stored signal for market {signal.market_id}: "
f"${signal.trade_size_usd:,.2f} {signal.trade_side} "
f"(insider likelihood: {signal.insider_trading_likelihood:.0%})"
f"(IAS: {signal.information_asymmetry_score:.0%})"
)
return True
except Exception as e:
logger.error(f"Failed to store anomaly signal: {e}")
return False
return stored
def get_signals_for_market(
self,
@@ -139,33 +68,7 @@ class AnomalyHistoryService:
Returns:
List of AnomalySignal objects (deduplicated, sorted by trade timestamp newest first)
"""
filepath = self._get_market_filepath(market_id)
signals = self._load_signals_from_file(filepath)
if not signals:
return []
# Get top N most recent signals (by trade timestamp)
signals_by_time = sorted(signals, key=lambda s: s.trade_timestamp, reverse=True)
recent_signals = signals_by_time[:top_recent]
# Get top N highest insider trading likelihood signals
signals_by_likelihood = sorted(signals, key=lambda s: s.insider_trading_likelihood, reverse=True)
high_likelihood_signals = signals_by_likelihood[:top_likelihood]
# Deduplicate by transaction_hash
seen_hashes = set()
combined_signals = []
for signal in recent_signals + high_likelihood_signals:
if signal.transaction_hash not in seen_hashes:
seen_hashes.add(signal.transaction_hash)
combined_signals.append(signal)
# Sort final result by trade timestamp (newest first)
combined_signals.sort(key=lambda s: s.trade_timestamp, reverse=True)
return combined_signals
return self.db.get_signals_for_market(market_id, top_recent, top_likelihood)
def format_historical_signals_context(
self,
@@ -210,53 +113,6 @@ class AnomalyHistoryService:
"""
return context
def _load_signals_from_file(self, filepath: Path) -> List[AnomalySignal]:
"""
Load anomaly signals from a JSON file.
Args:
filepath: Path to the JSON file
Returns:
List of AnomalySignal objects
"""
if not filepath.exists():
return []
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
signals = []
for item in data:
try:
signal = AnomalySignal.model_validate(item)
signals.append(signal)
except Exception as e:
logger.warning(f"Failed to parse anomaly signal: {e}")
continue
return signals
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON file {filepath}: {e}")
return []
except Exception as e:
logger.error(f"Failed to load signals from {filepath}: {e}")
return []
def _save_signals_to_file(self, filepath: Path, signals: List[AnomalySignal]) -> None:
"""
Save anomaly signals to a JSON file.
Args:
filepath: Path to the JSON file
signals: List of AnomalySignal objects to save
"""
data = [signal.model_dump(mode='json') for signal in signals]
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
def get_all_market_ids(self) -> List[str]:
"""
Get all market IDs that have stored anomaly signals.
@@ -264,25 +120,19 @@ class AnomalyHistoryService:
Returns:
List of market IDs
"""
market_ids = []
for filepath in self.storage_dir.glob("*.json"):
market_id = filepath.stem
market_ids.append(market_id)
return market_ids
return self.db.get_all_market_ids()
def get_signal_count(self, market_id: str) -> int:
def get_signal_count(self, market_id: Optional[str] = None) -> int:
"""
Get the number of stored signals for a market.
Get the number of stored signals.
Args:
market_id: The market ID
market_id: Optional market ID to filter by
Returns:
Number of stored signals
"""
filepath = self._get_market_filepath(market_id)
signals = self._load_signals_from_file(filepath)
return len(signals)
return self.db.get_signal_count(market_id)
def cleanup_old_signals(self, max_age_days: int = 30) -> int:
"""
@@ -294,22 +144,4 @@ class AnomalyHistoryService:
Returns:
Number of signals removed
"""
from datetime import datetime, timedelta, timezone
cutoff_time = datetime.now(timezone.utc) - timedelta(days=max_age_days)
total_removed = 0
for filepath in self.storage_dir.glob("*.json"):
signals = self._load_signals_from_file(filepath)
original_count = len(signals)
# Filter out old signals
signals = [s for s in signals if s.detected_at >= cutoff_time]
removed_count = original_count - len(signals)
if removed_count > 0:
self._save_signals_to_file(filepath, signals)
total_removed += removed_count
logger.info(f"Removed {removed_count} old signals from {filepath.stem}")
return total_removed
return self.db.cleanup_old_signals(max_age_days)
+168
View File
@@ -0,0 +1,168 @@
"""CoinGecko API service for cryptocurrency market data."""
import logging
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
COINGECKO_API = "https://api.coingecko.com/api/v3"
# Common coin ID mapping (Polymarket markets often use ticker symbols)
TICKER_TO_ID = {
"BTC": "bitcoin",
"ETH": "ethereum",
"SOL": "solana",
"XRP": "ripple",
"DOGE": "dogecoin",
"ADA": "cardano",
"AVAX": "avalanche-2",
"DOT": "polkadot",
"MATIC": "matic-network",
"LINK": "chainlink",
"UNI": "uniswap",
"SHIB": "shiba-inu",
"LTC": "litecoin",
"BNB": "binancecoin",
"NEAR": "near",
"ARB": "arbitrum",
"OP": "optimism",
"APT": "aptos",
"SUI": "sui",
"PEPE": "pepe",
}
def _resolve_coin_id(query: str) -> str:
"""Resolve a ticker or name to a CoinGecko coin ID."""
q = query.strip().upper()
if q in TICKER_TO_ID:
return TICKER_TO_ID[q]
# Try lowercase as-is (CoinGecko IDs are lowercase)
return query.strip().lower()
class CoinGeckoService:
"""
CoinGecko API client for crypto market data.
Free tier: 30 calls/min, no API key required.
"""
def __init__(self):
self._client = httpx.Client(timeout=15.0)
def get_price(self, coin: str) -> str:
"""
Get current price, 24h change, market cap, and volume for a cryptocurrency.
Args:
coin: Ticker symbol (BTC, ETH, SOL) or CoinGecko ID (bitcoin, ethereum)
Returns:
Formatted price report string.
"""
coin_id = _resolve_coin_id(coin)
try:
resp = self._client.get(
f"{COINGECKO_API}/coins/{coin_id}",
params={
"localization": "false",
"tickers": "false",
"community_data": "false",
"developer_data": "false",
"sparkline": "false",
},
)
if resp.status_code == 404:
return f"Coin '{coin}' (id: {coin_id}) not found on CoinGecko."
resp.raise_for_status()
data = resp.json()
market = data.get("market_data", {})
name = data.get("name", coin_id)
symbol = data.get("symbol", "").upper()
price = market.get("current_price", {}).get("usd")
change_24h = market.get("price_change_percentage_24h")
change_7d = market.get("price_change_percentage_7d")
change_30d = market.get("price_change_percentage_30d")
high_24h = market.get("high_24h", {}).get("usd")
low_24h = market.get("low_24h", {}).get("usd")
market_cap = market.get("market_cap", {}).get("usd")
volume_24h = market.get("total_volume", {}).get("usd")
ath = market.get("ath", {}).get("usd")
ath_change = market.get("ath_change_percentage", {}).get("usd")
lines = [
f"--- {name} ({symbol}) Market Data ---",
f"Price: ${price:,.2f}" if price else "Price: N/A",
]
if high_24h and low_24h:
lines.append(f"24h Range: ${low_24h:,.2f} - ${high_24h:,.2f}")
if change_24h is not None:
lines.append(f"24h Change: {change_24h:+.2f}%")
if change_7d is not None:
lines.append(f"7d Change: {change_7d:+.2f}%")
if change_30d is not None:
lines.append(f"30d Change: {change_30d:+.2f}%")
if market_cap:
lines.append(f"Market Cap: ${market_cap:,.0f}")
if volume_24h:
lines.append(f"24h Volume: ${volume_24h:,.0f}")
if ath and ath_change is not None:
lines.append(f"ATH: ${ath:,.2f} ({ath_change:+.1f}% from ATH)")
lines.append("---")
return "\n".join(lines)
except httpx.HTTPError as e:
msg = f"CoinGecko API error for '{coin}': {e}"
logger.error(msg)
return msg
except Exception as e:
msg = f"CoinGecko query failed for '{coin}': {e}"
logger.error(msg)
return msg
def get_market_overview(self) -> str:
"""
Get global crypto market overview: total market cap, BTC dominance, etc.
Returns:
Formatted global market overview string.
"""
try:
resp = self._client.get(f"{COINGECKO_API}/global")
resp.raise_for_status()
data = resp.json().get("data", {})
total_cap = data.get("total_market_cap", {}).get("usd", 0)
total_vol = data.get("total_volume", {}).get("usd", 0)
btc_dom = data.get("market_cap_percentage", {}).get("btc", 0)
eth_dom = data.get("market_cap_percentage", {}).get("eth", 0)
change_24h = data.get("market_cap_change_percentage_24h_usd", 0)
active_coins = data.get("active_cryptocurrencies", 0)
lines = [
"--- Global Crypto Market Overview ---",
f"Total Market Cap: ${total_cap:,.0f}",
f"24h Change: {change_24h:+.2f}%",
f"24h Volume: ${total_vol:,.0f}",
f"BTC Dominance: {btc_dom:.1f}%",
f"ETH Dominance: {eth_dom:.1f}%",
f"Active Coins: {active_coins:,}",
"---",
]
return "\n".join(lines)
except Exception as e:
msg = f"CoinGecko global query failed: {e}"
logger.error(msg)
return msg
+204
View File
@@ -0,0 +1,204 @@
"""Congress.gov API service for U.S. legislative data."""
import logging
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
CONGRESS_API = "https://api.congress.gov/v3"
class CongressService:
"""
Congress.gov API client for U.S. legislative data.
Covers: bills, votes, members, committees, nominations.
"""
def __init__(self, api_key: str):
self.api_key = api_key
self._client = httpx.Client(timeout=15.0)
def is_available(self) -> bool:
return bool(self.api_key and self.api_key.strip())
def _get(self, path: str, params: Optional[dict] = None) -> dict:
"""Make authenticated GET request."""
params = params or {}
params["api_key"] = self.api_key
params["format"] = "json"
resp = self._client.get(f"{CONGRESS_API}{path}", params=params)
resp.raise_for_status()
return resp.json()
def search_bills(self, query: str, limit: int = 5) -> str:
"""
Search for bills by keyword.
Args:
query: Search keywords (e.g. 'TikTok ban', 'crypto regulation', 'immigration')
limit: Number of results (1-10)
Returns:
Formatted report of matching bills with status.
"""
limit = max(1, min(limit, 10))
try:
data = self._get("/bill", params={
"limit": limit,
"sort": "updateDate+desc",
})
bills = data.get("bills", [])
if not bills:
return f"No bills found on Congress.gov."
# Filter by query keyword in title (API doesn't support text search directly)
# So we fetch recent bills and note to user
lines = [f"--- Congress.gov: Recent Bills ---"]
lines.append(f"(Showing {len(bills)} most recently updated bills)")
lines.append("")
for i, bill in enumerate(bills, 1):
bill_type = bill.get("type", "")
number = bill.get("number", "")
title = bill.get("title", "No title")
congress = bill.get("congress", "")
update_date = bill.get("updateDate", "")[:10]
latest_action = bill.get("latestAction", {})
action_text = latest_action.get("text", "")
action_date = latest_action.get("actionDate", "")
bill_id = f"{bill_type} {number}" if bill_type and number else "N/A"
lines.append(f"{i}. **{bill_id}** (Congress {congress})")
lines.append(f" Title: {title[:150]}")
lines.append(f" Updated: {update_date}")
if action_text:
lines.append(f" Latest Action ({action_date}): {action_text[:150]}")
lines.append("")
lines.append("---")
return "\n".join(lines)
except Exception as e:
msg = f"Congress.gov search failed: {e}"
logger.error(msg)
return msg
def get_bill_status(self, congress: int, bill_type: str, bill_number: int) -> str:
"""
Get detailed status of a specific bill.
Args:
congress: Congress number (e.g. 119 for current)
bill_type: Bill type (hr, s, hjres, sjres)
bill_number: Bill number
Returns:
Formatted bill status report.
"""
bt = bill_type.strip().lower()
try:
data = self._get(f"/bill/{congress}/{bt}/{bill_number}")
bill = data.get("bill", {})
if not bill:
return f"Bill {bt.upper()} {bill_number} (Congress {congress}) not found."
title = bill.get("title", "No title")
introduced = bill.get("introducedDate", "N/A")
sponsors = bill.get("sponsors", [])
sponsor_str = ", ".join(
f"{s.get('firstName', '')} {s.get('lastName', '')} ({s.get('party', '')}-{s.get('state', '')})"
for s in sponsors[:3]
) if sponsors else "N/A"
latest_action = bill.get("latestAction", {})
action_text = latest_action.get("text", "N/A")
action_date = latest_action.get("actionDate", "")
policy_area = bill.get("policyArea", {}).get("name", "N/A")
committees_count = bill.get("committees", {}).get("count", 0)
cosponsors_count = bill.get("cosponsors", {}).get("count", 0)
actions_count = bill.get("actions", {}).get("count", 0)
# Determine bill progress
constitutional = bill.get("constitutionalAuthorityStatementText", "")
lines = [
f"--- Bill Status: {bt.upper()} {bill_number} (Congress {congress}) ---",
f"Title: {title}",
f"Introduced: {introduced}",
f"Sponsor: {sponsor_str}",
f"Cosponsors: {cosponsors_count}",
f"Policy Area: {policy_area}",
f"Committees Referred: {committees_count}",
f"Total Actions: {actions_count}",
f"",
f"Latest Action ({action_date}): {action_text}",
f"---",
]
return "\n".join(lines)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return f"Bill {bt.upper()} {bill_number} (Congress {congress}) not found."
return f"Congress.gov API error: HTTP {e.response.status_code}"
except Exception as e:
msg = f"Congress.gov bill query failed: {e}"
logger.error(msg)
return msg
def get_recent_votes(self, chamber: str = "senate", limit: int = 5) -> str:
"""
Get recent roll call votes.
Args:
chamber: 'senate' or 'house'
limit: Number of votes (1-10)
Returns:
Formatted report of recent votes.
"""
chamber = chamber.strip().lower()
if chamber not in ("senate", "house"):
chamber = "senate"
limit = max(1, min(limit, 10))
try:
# Get current congress number (119th as of 2025-2026)
congress = 119
data = self._get(f"/bill", params={
"limit": limit,
"sort": "updateDate+desc",
})
# Use the nominations endpoint for Senate votes
# or fall back to recent bill actions
lines = [f"--- Recent Congressional Activity ({chamber.title()}) ---"]
bills = data.get("bills", [])
for i, bill in enumerate(bills[:limit], 1):
bill_type = bill.get("type", "")
number = bill.get("number", "")
title = bill.get("title", "")[:100]
latest = bill.get("latestAction", {})
action = latest.get("text", "")[:120]
date = latest.get("actionDate", "")
lines.append(f"{i}. {bill_type} {number}: {title}")
lines.append(f" {date}: {action}")
lines.append("")
lines.append("---")
return "\n".join(lines)
except Exception as e:
msg = f"Congress.gov votes query failed: {e}"
logger.error(msg)
return msg
+390
View File
@@ -0,0 +1,390 @@
"""Daily briefing service - generates daily summary of high-value signals."""
import logging
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Dict, Optional
from src.config import get_settings
from src.db.database import SignalDatabase
from src.services.stats_engine import StatsEngine
logger = logging.getLogger(__name__)
# Directories
VOLATILITY_DIR = Path(__file__).parent.parent.parent / "price_volatility"
BRIEFINGS_DIR = Path(__file__).parent.parent.parent / "daily_briefings"
class DailyBriefingGenerator:
"""
Generates daily briefings summarizing high-value signals.
Includes:
- Insider trading signals with likelihood >= 60%
- Price volatility alerts
- Historical signal performance stats
"""
# Minimum information asymmetry score to include in briefing
MIN_IAS = 0.6 # 60%
# Maximum signals to include when falling back to top-N
FALLBACK_TOP_N = 5
def __init__(self, db_path: str = "data/signals.db"):
"""Initialize the briefing generator."""
BRIEFINGS_DIR.mkdir(parents=True, exist_ok=True)
self.db = SignalDatabase(db_path)
self.stats_engine = StatsEngine(self.db)
def _get_date_range(self, date: datetime) -> tuple:
"""
Get start and end timestamps for a given date.
Args:
date: The date to get range for
Returns:
Tuple of (start_timestamp, end_timestamp)
"""
start = datetime(date.year, date.month, date.day, 0, 0, 0)
end = start + timedelta(days=1)
return int(start.timestamp()), int(end.timestamp())
def _load_insider_signals(self, date: datetime) -> tuple:
"""
Load insider trading signals for a specific date from the database.
First tries to find signals with likelihood >= 60%.
If none found, falls back to the top 5 by likelihood.
Args:
date: The date to load signals for
Returns:
Tuple of (signals list as dicts, is_fallback bool)
"""
date_str = date.strftime("%Y-%m-%d")
# Query all signals detected on this date
all_signals = self.db.get_all_signals(limit=500, offset=0)
day_signals = []
for signal in all_signals:
if signal.detected_at.strftime("%Y-%m-%d") == date_str:
day_signals.append(signal)
if not day_signals:
return [], False
# Sort by likelihood descending
day_signals.sort(key=lambda s: s.information_asymmetry_score, reverse=True)
# Convert to dicts for backward compat with _format_briefing
def signal_to_dict(s):
return {
"market_id": s.market_id,
"market_question": s.market_question,
"transaction_hash": s.transaction_hash,
"trade_size_usd": s.trade_size_usd,
"trade_price": s.trade_price,
"trade_outcome": s.trade_outcome,
"information_asymmetry_score": s.information_asymmetry_score,
"reasoning": s.reasoning,
"insider_evidence": s.insider_evidence,
"detected_at": s.detected_at.isoformat(),
}
# Filter high-likelihood signals
high_likelihood = [
signal_to_dict(s) for s in day_signals
if s.information_asymmetry_score >= self.MIN_IAS
]
if high_likelihood:
return high_likelihood, False
# Fallback: top N signals by likelihood
return [signal_to_dict(s) for s in day_signals[:self.FALLBACK_TOP_N]], True
def _load_volatility_alerts(self, date: datetime) -> List[Dict]:
"""
Load price volatility alerts for a specific date.
Args:
date: The date to load alerts for
Returns:
List of volatility alerts
"""
import json
alerts_file = VOLATILITY_DIR / "volatility_alerts.json"
date_str = date.strftime("%Y-%m-%d")
if not alerts_file.exists():
return []
try:
with open(alerts_file, 'r', encoding='utf-8') as f:
all_alerts = json.load(f)
# Filter alerts for the target date
day_alerts = [
alert for alert in all_alerts
if alert.get("detected_at", "").startswith(date_str)
]
# Sort by price change magnitude descending
day_alerts.sort(
key=lambda x: abs(x.get("price_change_percent", 0)),
reverse=True
)
return day_alerts
except Exception as e:
logger.error(f"Error loading volatility alerts: {e}")
return []
def _format_briefing(
self,
date: datetime,
insider_signals: List[Dict],
volatility_alerts: List[Dict],
is_fallback: bool = False,
) -> str:
"""
Format the daily briefing as markdown.
Args:
date: The date of the briefing
insider_signals: List of insider signals
volatility_alerts: List of price volatility alerts
is_fallback: True if signals are fallback (none >= 60%)
Returns:
Formatted markdown briefing
"""
date_str = date.strftime("%Y-%m-%d")
lines = [
f"# 每日信号简报 - {date_str}",
"",
f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"",
]
# Summary stats
if is_fallback:
summary_line = f"- 今日无可信度 ≥ 60% 的内幕信号,以下为可信度最高的 **{len(insider_signals)}** 条"
else:
summary_line = f"- 高可信度内幕交易信号: **{len(insider_signals)}** 个 (可信度 ≥ 60%)"
lines.extend([
"## 今日概览",
"",
summary_line,
f"- 异常价格波动: **{len(volatility_alerts)}** 次",
"",
])
# Insider trading signals section
if is_fallback:
section_title = "## 今日可信度最高的异常交易"
else:
section_title = "## 高可信度内幕交易信号"
lines.extend([
"---",
"",
section_title,
"",
])
if insider_signals:
for i, signal in enumerate(insider_signals, 1):
likelihood = signal.get("information_asymmetry_score", 0)
market_question = signal.get("market_question", "Unknown")
trade_size = signal.get("trade_size_usd", 0)
trade_price = signal.get("trade_price", 0)
trade_outcome = signal.get("trade_outcome", "Yes")
reasoning = signal.get("reasoning", "")
insider_evidence = signal.get("insider_evidence", "")
detected_at = signal.get("detected_at", "")
# Odds calculation
odds_str = f"{1/trade_price:.1f}x" if trade_price > 0 else "N/A"
lines.extend([
f"### {i}. {market_question[:80]}{'...' if len(market_question) > 80 else ''}",
"",
f"| 指标 | 值 |",
f"|------|-----|",
f"| 信息不对称 | **{likelihood:.0%}** |",
f"| 交易方向 | BUY {trade_outcome} Token ({'看多' if trade_outcome == 'Yes' else '看空'}) |",
f"| 买入价格 | {trade_price:.4f}(赔率 {odds_str} |",
f"| 花费金额 | **${trade_size:,.0f}** USDC |",
f"| 检测时间 | {detected_at} |",
"",
])
if reasoning:
lines.extend([
f"**分析过程**: {reasoning}",
"",
])
if insider_evidence:
lines.extend([
f"**内幕证据**: {insider_evidence}",
"",
])
lines.append("")
else:
lines.extend([
"*今日无异常交易信号*",
"",
])
# Volatility alerts section
lines.extend([
"---",
"",
"## 异常价格波动",
"",
])
if volatility_alerts:
lines.extend([
"| 市场 | 方向 | 波动幅度 | 起始价格 | 结束价格 | 检测时间 |",
"|------|------|----------|----------|----------|----------|",
])
for alert in volatility_alerts:
market_question = alert.get("market_question", "Unknown")
# Truncate long market questions
if len(market_question) > 40:
market_question = market_question[:37] + "..."
direction = "下跌" if alert.get("direction") == "DOWN" else "上涨"
price_change = abs(alert.get("price_change_percent", 0))
start_price = alert.get("start_price", 0)
end_price = alert.get("end_price", 0)
detected_at = alert.get("detected_at", "")[:16] # Trim to minute
lines.append(
f"| {market_question} | {direction} | {price_change:.1%} | "
f"{start_price:.2%} | {end_price:.2%} | {detected_at} |"
)
lines.append("")
else:
lines.extend([
"*今日无异常价格波动*",
"",
])
# Signal performance stats section
stats_summary = self.stats_engine.format_stats_summary()
if stats_summary:
lines.extend([
"---",
"",
stats_summary,
])
# Footer
lines.extend([
"---",
"",
"*此简报由 Polymarket Whale Watcher 自动生成*",
])
return "\n".join(lines)
def generate_briefing(self, date: Optional[datetime] = None) -> Optional[str]:
"""
Generate daily briefing for a specific date.
Args:
date: The date to generate briefing for (defaults to yesterday)
Returns:
Path to the saved briefing file, or None if no signals
"""
if date is None:
# Default to yesterday
date = datetime.now() - timedelta(days=1)
date_str = date.strftime("%Y-%m-%d")
logger.info(f"Generating daily briefing for {date_str}")
# Load signals
insider_signals, is_fallback = self._load_insider_signals(date)
volatility_alerts = self._load_volatility_alerts(date)
# Check if there's anything to report
if not insider_signals and not volatility_alerts:
logger.info(f"No signals for {date_str}, skipping briefing")
return None
# Generate briefing
briefing_content = self._format_briefing(date, insider_signals, volatility_alerts, is_fallback)
# Save to file
filename = f"briefing_{date_str}.md"
filepath = BRIEFINGS_DIR / filename
with open(filepath, 'w', encoding='utf-8') as f:
f.write(briefing_content)
logger.info(
f"Daily briefing saved to {filepath} "
f"({len(insider_signals)} insider signals, {len(volatility_alerts)} volatility alerts)"
)
# Send email notification
self._send_email(date_str, briefing_content)
return str(filepath)
def _send_email(self, date_str: str, content: str) -> None:
"""Send briefing via email if configured."""
settings = get_settings()
if not settings.email_enabled:
return
if not settings.email_sender or not settings.email_password:
logger.warning("Email enabled but sender/password not configured, skipping")
return
try:
recipients = [r.strip() for r in settings.email_recipient.split(",") if r.strip()]
msg = MIMEMultipart("alternative")
msg["Subject"] = f"Polymarket 鲸鱼日报 - {date_str}"
msg["From"] = settings.email_sender
msg["To"] = ", ".join(recipients)
# Markdown content as plain text
text_part = MIMEText(content, "plain", "utf-8")
msg.attach(text_part)
with smtplib.SMTP_SSL(settings.email_smtp_server, settings.email_smtp_port) as server:
server.login(settings.email_sender, settings.email_password)
server.sendmail(settings.email_sender, recipients, msg.as_string())
logger.info(f"Daily briefing emailed to {', '.join(recipients)}")
except Exception as e:
logger.error(f"Failed to send briefing email: {e}")
def generate_today_briefing(self) -> Optional[str]:
"""
Generate briefing for today (useful for testing or end-of-day summary).
Returns:
Path to the saved briefing file, or None if no signals
"""
return self.generate_briefing(datetime.now())
+62
View File
@@ -0,0 +1,62 @@
"""DuckDuckGo web search service — free, no API key required."""
import logging
logger = logging.getLogger(__name__)
class DDGSearchService:
"""Web search service using duckduckgo-search (no API key needed)."""
def __init__(self):
self._available: bool | None = None
def is_available(self) -> bool:
if self._available is None:
try:
from duckduckgo_search import DDGS # noqa: F401
self._available = True
except ImportError:
logger.warning(
"duckduckgo-search not installed. "
"Install with: pip install duckduckgo-search"
)
self._available = False
return self._available
def search(self, query: str, max_results: int = 5) -> str:
if not self.is_available():
return "Web search unavailable: duckduckgo-search package not installed."
try:
from duckduckgo_search import DDGS
with DDGS() as ddgs:
results = list(ddgs.text(query, max_results=max_results))
if not results:
return f"No web search results found for '{query}'."
report = [f"--- Web Search Results for '{query}' ---"]
for idx, item in enumerate(results, 1):
title = item.get("title", "No title")
url = item.get("href", "")
body = item.get("body", "")[:300]
if len(item.get("body", "")) > 300:
body += "..."
report.append(f"{idx}. **{title}**")
report.append(f" Source: {url}")
report.append(f" {body}")
report.append("")
report.append("-------------------------------------------")
return "\n".join(report)
except Exception as e:
logger.error(f"DuckDuckGo search failed: {e}")
return f"Web search failed: {str(e)}"
def search_for_market(self, market_question: str, max_results: int = 5) -> str:
query = market_question[:200]
result = self.search(query, max_results=max_results)
if "No web search results" not in result and "Error" not in result and "unavailable" not in result:
return "## 🔍 Web Search Results (News & Analysis)\n" + result
return f"No relevant web results found for: {market_question[:50]}..."
+339
View File
@@ -0,0 +1,339 @@
"""DeFiLlama API service for DeFi protocol data (TVL, revenue, token unlocks)."""
import logging
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
DEFILLAMA_API = "https://api.llama.fi"
def _fmt_usd(value) -> str:
"""Format a dollar value with appropriate suffix."""
if value is None:
return "N/A"
if isinstance(value, list):
return "N/A"
try:
value = float(value)
except (TypeError, ValueError):
return "N/A"
abs_val = abs(value)
if abs_val >= 1_000_000_000:
return f"${value / 1_000_000_000:,.2f}B"
if abs_val >= 1_000_000:
return f"${value / 1_000_000:,.2f}M"
if abs_val >= 1_000:
return f"${value / 1_000:,.2f}K"
return f"${value:,.2f}"
class DefiLlamaService:
"""
DeFiLlama API client for DeFi protocol analytics.
Free API, no key required. Rate limits are generous.
"""
def __init__(self):
self._client = httpx.Client(timeout=20.0)
self._protocols_cache: Optional[list] = None
@staticmethod
def is_available() -> bool:
"""Always available - no API key needed."""
return True
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _fetch_protocols_list(self) -> list:
"""Fetch and cache the full protocols list for slug lookups."""
if self._protocols_cache is not None:
return self._protocols_cache
try:
resp = self._client.get(f"{DEFILLAMA_API}/protocols")
resp.raise_for_status()
self._protocols_cache = resp.json()
return self._protocols_cache
except Exception as e:
logger.error(f"Failed to fetch DeFiLlama protocols list: {e}")
return []
def _resolve_slug(self, query: str) -> Optional[str]:
"""
Fuzzy-match a user query to a DeFiLlama protocol slug.
Tries exact slug match, then name match, then substring match.
"""
q = query.strip().lower()
protocols = self._fetch_protocols_list()
# 1) Exact slug match
for p in protocols:
if p.get("slug", "").lower() == q:
return p["slug"]
# 2) Exact name match (case-insensitive)
for p in protocols:
if p.get("name", "").lower() == q:
return p["slug"]
# 3) Substring match on slug or name prefer shortest match (most specific)
candidates = []
for p in protocols:
slug = p.get("slug", "").lower()
name = p.get("name", "").lower()
if q in slug or q in name:
candidates.append(p)
if candidates:
# Sort by TVL descending so the most prominent protocol wins ties
candidates.sort(key=lambda p: p.get("tvl") or 0, reverse=True)
return candidates[0]["slug"]
return None
# ------------------------------------------------------------------
# Public methods all return formatted strings
# ------------------------------------------------------------------
def get_protocol_tvl(self, protocol: str) -> str:
"""
Get protocol TVL, TVL changes, and chain breakdown.
Args:
protocol: Protocol name or slug (e.g., "aave", "Lido", "uniswap").
Returns:
Formatted TVL report string.
"""
slug = self._resolve_slug(protocol)
if slug is None:
return f"Protocol '{protocol}' not found on DeFiLlama."
try:
resp = self._client.get(f"{DEFILLAMA_API}/protocol/{slug}")
if resp.status_code == 404:
return f"Protocol '{protocol}' (slug: {slug}) not found on DeFiLlama."
resp.raise_for_status()
data = resp.json()
name = data.get("name", slug)
symbol = data.get("symbol", "")
category = data.get("category", "N/A")
# tvl field is a historical list; get current TVL from last entry or currentChainTvls
tvl_data = data.get("tvl")
if isinstance(tvl_data, list) and tvl_data:
tvl = tvl_data[-1].get("totalLiquidityUSD", 0)
elif isinstance(tvl_data, (int, float)):
tvl = tvl_data
else:
tvl = None
chain_tvls = data.get("chainTvls", {})
# TVL changes
change_1h = data.get("change_1h")
change_1d = data.get("change_1d")
change_7d = data.get("change_7d")
lines = [
f"--- {name} ({symbol}) TVL Report ---",
f"Category: {category}",
f"Total TVL: {_fmt_usd(tvl)}",
]
if change_1h is not None:
lines.append(f"1h Change: {change_1h:+.2f}%")
if change_1d is not None:
lines.append(f"24h Change: {change_1d:+.2f}%")
if change_7d is not None:
lines.append(f"7d Change: {change_7d:+.2f}%")
# Chain breakdown show top chains by TVL
if chain_tvls:
# chainTvls has sub-objects; the latest TVL per chain is the last entry
chain_summary = {}
for chain_name, chain_data in chain_tvls.items():
# Skip aggregated keys like "staking", "borrowed", "pool2"
if "-" in chain_name or chain_name in ("staking", "borrowed", "pool2", "vesting"):
continue
if isinstance(chain_data, dict):
tvl_history = chain_data.get("tvl", [])
if tvl_history:
chain_summary[chain_name] = tvl_history[-1].get("totalLiquidityUSD", 0)
elif isinstance(chain_data, (int, float)):
chain_summary[chain_name] = chain_data
if chain_summary:
sorted_chains = sorted(chain_summary.items(), key=lambda x: x[1], reverse=True)
lines.append("Chain Breakdown:")
for chain_name, chain_tvl in sorted_chains[:10]:
lines.append(f" {chain_name}: {_fmt_usd(chain_tvl)}")
lines.append("---")
return "\n".join(lines)
except httpx.HTTPError as e:
msg = f"DeFiLlama API error for '{protocol}': {e}"
logger.error(msg)
return msg
except Exception as e:
msg = f"DeFiLlama TVL query failed for '{protocol}': {e}"
logger.error(msg)
return msg
def get_token_unlocks(self, protocol: str) -> str:
"""
Get token unlock/vesting schedule for a protocol.
Args:
protocol: Protocol name or slug.
Returns:
Formatted token unlock schedule string.
"""
slug = self._resolve_slug(protocol)
if slug is None:
return f"Protocol '{protocol}' not found on DeFiLlama."
try:
resp = self._client.get(f"{DEFILLAMA_API}/api/emission/{slug}")
if resp.status_code == 404:
return f"No token unlock data for '{protocol}' on DeFiLlama."
resp.raise_for_status()
data = resp.json()
name = data.get("name", slug)
token_price = data.get("tokenPrice", {})
categories = data.get("categories", {})
events = data.get("events", [])
lines = [f"--- {name} Token Unlock Schedule ---"]
# Token price info
if isinstance(token_price, dict):
price = token_price.get("price")
symbol = token_price.get("symbol", "")
if price:
lines.append(f"Token: {symbol.upper()} @ ${price:,.4f}")
# Emission categories
if categories:
lines.append("Allocation Categories:")
for cat_name, cat_data in categories.items():
if isinstance(cat_data, dict):
pct = cat_data.get("percentage")
if pct is not None:
lines.append(f" {cat_name}: {pct:.1f}%")
else:
lines.append(f" {cat_name}")
# Upcoming events
if events:
lines.append("Upcoming Unlock Events:")
shown = 0
for event in events[:10]:
desc = event.get("description", "Unlock")
date = event.get("date", "TBD")
amount = event.get("noOfTokens")
if amount:
lines.append(f" {date}: {desc} ({amount:,.0f} tokens)")
else:
lines.append(f" {date}: {desc}")
shown += 1
if len(events) > 10:
lines.append(f" ... and {len(events) - 10} more events")
if len(lines) == 1:
lines.append("No detailed unlock data available.")
lines.append("---")
return "\n".join(lines)
except httpx.HTTPError as e:
msg = f"DeFiLlama API error for '{protocol}' unlocks: {e}"
logger.error(msg)
return msg
except Exception as e:
msg = f"DeFiLlama unlock query failed for '{protocol}': {e}"
logger.error(msg)
return msg
def get_protocol_revenue(self, protocol: str) -> str:
"""
Get protocol fees and revenue data.
Args:
protocol: Protocol name or slug.
Returns:
Formatted fees/revenue report string.
"""
slug = self._resolve_slug(protocol)
if slug is None:
return f"Protocol '{protocol}' not found on DeFiLlama."
try:
resp = self._client.get(f"{DEFILLAMA_API}/summary/fees/{slug}")
if resp.status_code == 404:
return f"No fee/revenue data for '{protocol}' on DeFiLlama."
resp.raise_for_status()
data = resp.json()
name = data.get("name", slug)
category = data.get("category", "N/A")
total_24h = data.get("total24h")
total_7d = data.get("total7d")
total_30d = data.get("total30d")
total_all_time = data.get("totalAllTime")
revenue_24h = data.get("revenue24h")
revenue_7d = data.get("revenue7d")
revenue_30d = data.get("revenue30d")
lines = [
f"--- {name} Fees & Revenue ---",
f"Category: {category}",
]
# Fees
lines.append("Fees:")
if total_24h is not None:
lines.append(f" 24h Fees: {_fmt_usd(total_24h)}")
if total_7d is not None:
lines.append(f" 7d Fees: {_fmt_usd(total_7d)}")
if total_30d is not None:
lines.append(f" 30d Fees: {_fmt_usd(total_30d)}")
if total_all_time is not None:
lines.append(f" All-Time Fees: {_fmt_usd(total_all_time)}")
# Revenue (protocol revenue, subset of fees)
has_revenue = any(v is not None for v in [revenue_24h, revenue_7d, revenue_30d])
if has_revenue:
lines.append("Revenue (protocol share):")
if revenue_24h is not None:
lines.append(f" 24h Revenue: {_fmt_usd(revenue_24h)}")
if revenue_7d is not None:
lines.append(f" 7d Revenue: {_fmt_usd(revenue_7d)}")
if revenue_30d is not None:
lines.append(f" 30d Revenue: {_fmt_usd(revenue_30d)}")
# Chain breakdown if available
chain_data = data.get("totalDataChartBreakdown")
if not chain_data and data.get("chains"):
lines.append(f"Available on chains: {', '.join(data['chains'][:15])}")
lines.append("---")
return "\n".join(lines)
except httpx.HTTPError as e:
msg = f"DeFiLlama API error for '{protocol}' revenue: {e}"
logger.error(msg)
return msg
except Exception as e:
msg = f"DeFiLlama revenue query failed for '{protocol}': {e}"
logger.error(msg)
return msg
+299
View File
@@ -0,0 +1,299 @@
"""Etherscan API service for Ethereum on-chain data (wallet balances, token transfers, contracts)."""
import logging
from datetime import datetime, timezone
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
ETHERSCAN_API = "https://api.etherscan.io/api"
# Well-known ERC-20 token contracts on Ethereum mainnet
TOKEN_CONTRACTS = {
"USDC": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"USDT": "0xdac17f958d2ee523a2206206994597c13d831ec7",
"WETH": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"DAI": "0x6b175474e89094c44da98b954eedeac495271d0f",
}
# Decimals per token (used for converting raw amounts)
TOKEN_DECIMALS = {
"USDC": 6,
"USDT": 6,
"WETH": 18,
"DAI": 18,
}
def _format_amount(raw_value: str, decimals: int) -> float:
"""Convert a raw token amount string to a human-readable float."""
try:
return int(raw_value) / (10 ** decimals)
except (ValueError, TypeError):
return 0.0
def _short_address(address: str) -> str:
"""Shorten an Ethereum address for display."""
if len(address) >= 10:
return f"{address[:6]}...{address[-4:]}"
return address
def _ts_to_str(timestamp: str) -> str:
"""Convert a unix timestamp string to a readable UTC datetime."""
try:
dt = datetime.fromtimestamp(int(timestamp), tz=timezone.utc)
return dt.strftime("%Y-%m-%d %H:%M UTC")
except (ValueError, TypeError):
return timestamp
class EtherscanService:
"""
Etherscan API client for Ethereum on-chain data.
Note: Free tier is limited to 5 calls/sec. Add delays between rapid
successive calls if needed.
"""
def __init__(self, api_key: str = ""):
self.api_key = api_key
self._client = httpx.Client(timeout=20.0)
def is_available(self) -> bool:
return bool(self.api_key and self.api_key.strip())
def _get(self, params: dict) -> dict:
"""Make authenticated GET request to Etherscan API."""
params["apikey"] = self.api_key
resp = self._client.get(ETHERSCAN_API, params=params)
resp.raise_for_status()
return resp.json()
# ------------------------------------------------------------------
# 1. Token transfers
# ------------------------------------------------------------------
def get_wallet_token_transfers(self, address: str, token: str = "USDC") -> str:
"""
Get recent ERC-20 token transfers for a wallet.
Args:
address: Ethereum wallet address.
token: Token symbol to filter on (USDC, USDT, etc.).
Pass "ALL" to show all ERC-20 transfers.
Returns:
Formatted transfer report string for LLM consumption.
"""
addr = address.strip().lower()
token_upper = token.strip().upper()
try:
params = {
"module": "account",
"action": "tokentx",
"address": addr,
"sort": "desc",
"page": "1",
"offset": "20",
}
data = self._get(params)
if data.get("status") != "1" or not data.get("result"):
message = data.get("message", "No transfers found")
return f"No ERC-20 token transfers found for {_short_address(addr)}: {message}"
transfers = data["result"]
# Filter by token if not "ALL"
if token_upper != "ALL":
contract = TOKEN_CONTRACTS.get(token_upper, "").lower()
if contract:
transfers = [
tx for tx in transfers
if tx.get("contractAddress", "").lower() == contract
]
else:
# Try matching by symbol in the response
transfers = [
tx for tx in transfers
if tx.get("tokenSymbol", "").upper() == token_upper
]
if not transfers:
return f"No {token_upper} transfers found for {_short_address(addr)} in the last 20 token transactions."
lines = [f"--- Token Transfers for {_short_address(addr)} ({token_upper}) ---"]
for tx in transfers:
tx_from = tx.get("from", "").lower()
tx_to = tx.get("to", "").lower()
symbol = tx.get("tokenSymbol", "???")
decimals = int(tx.get("tokenDecimal", TOKEN_DECIMALS.get(symbol.upper(), 18)))
raw_value = tx.get("value", "0")
amount = _format_amount(raw_value, decimals)
ts = _ts_to_str(tx.get("timeStamp", ""))
tx_hash = tx.get("hash", "")
# Determine direction
if tx_from == addr:
direction = "OUT"
counterparty = _short_address(tx_to)
elif tx_to == addr:
direction = "IN"
counterparty = _short_address(tx_from)
else:
direction = "???"
counterparty = f"{_short_address(tx_from)} -> {_short_address(tx_to)}"
# Flag large transfers
large_flag = ""
if symbol.upper() in ("USDC", "USDT", "DAI") and amount > 10_000:
large_flag = " [LARGE]"
elif symbol.upper() == "WETH" and amount > 5:
large_flag = " [LARGE]"
lines.append(
f" {direction} {amount:,.2f} {symbol}{large_flag} | "
f"{'to' if direction == 'OUT' else 'from'}: {counterparty} | {ts}"
)
lines.append("---")
return "\n".join(lines)
except httpx.HTTPError as e:
msg = f"Etherscan API error fetching token transfers for '{address}': {e}"
logger.error(msg)
return msg
except Exception as e:
msg = f"Etherscan token transfer query failed for '{address}': {e}"
logger.error(msg)
return msg
# ------------------------------------------------------------------
# 2. Contract info
# ------------------------------------------------------------------
def get_contract_info(self, address: str) -> str:
"""
Check if an address is a smart contract and retrieve basic contract metadata.
Args:
address: Ethereum address to inspect.
Returns:
Formatted contract info string for LLM consumption.
"""
addr = address.strip()
try:
# First check if ABI is available (verified contract)
abi_data = self._get({
"module": "contract",
"action": "getabi",
"address": addr,
})
is_verified = abi_data.get("status") == "1"
# Get source code info (includes contract name, compiler, etc.)
source_data = self._get({
"module": "contract",
"action": "getsourcecode",
"address": addr,
})
results = source_data.get("result", [])
lines = [f"--- Contract Info for {_short_address(addr)} ---"]
if not results or (isinstance(results, list) and len(results) == 0):
lines.append("No contract data returned. Address may be an EOA (externally owned account).")
lines.append("---")
return "\n".join(lines)
info = results[0] if isinstance(results, list) else results
contract_name = info.get("ContractName", "")
compiler = info.get("CompilerVersion", "")
optimization = info.get("OptimizationUsed", "")
proxy = info.get("Proxy", "0")
implementation = info.get("Implementation", "")
if not contract_name:
lines.append("This address does not appear to be a verified contract.")
lines.append("It may be an EOA (regular wallet) or an unverified contract.")
else:
lines.append(f"Contract Name: {contract_name}")
lines.append(f"Verified: {'Yes' if is_verified else 'No'}")
if compiler:
lines.append(f"Compiler: {compiler}")
if optimization:
lines.append(f"Optimization: {'Yes' if optimization == '1' else 'No'}")
if proxy == "1":
lines.append(f"Proxy Contract: Yes")
if implementation:
lines.append(f"Implementation: {implementation}")
lines.append("---")
return "\n".join(lines)
except httpx.HTTPError as e:
msg = f"Etherscan API error fetching contract info for '{address}': {e}"
logger.error(msg)
return msg
except Exception as e:
msg = f"Etherscan contract query failed for '{address}': {e}"
logger.error(msg)
return msg
# ------------------------------------------------------------------
# 3. ETH balance
# ------------------------------------------------------------------
def get_wallet_eth_balance(self, address: str) -> str:
"""
Get ETH balance for a wallet address.
Args:
address: Ethereum wallet address.
Returns:
Formatted ETH balance string for LLM consumption.
"""
addr = address.strip()
try:
data = self._get({
"module": "account",
"action": "balance",
"address": addr,
"tag": "latest",
})
if data.get("status") != "1":
message = data.get("message", "Unknown error")
return f"Could not fetch ETH balance for {_short_address(addr)}: {message}"
raw_balance = data.get("result", "0")
eth_balance = _format_amount(raw_balance, 18)
lines = [
f"--- ETH Balance for {_short_address(addr)} ---",
f"Balance: {eth_balance:,.6f} ETH",
"---",
]
return "\n".join(lines)
except httpx.HTTPError as e:
msg = f"Etherscan API error fetching ETH balance for '{address}': {e}"
logger.error(msg)
return msg
except Exception as e:
msg = f"Etherscan balance query failed for '{address}': {e}"
logger.error(msg)
return msg
+179
View File
@@ -0,0 +1,179 @@
"""FRED (Federal Reserve Economic Data) API service for macroeconomic indicators."""
import logging
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
FRED_API = "https://api.stlouisfed.org/fred"
# Common series IDs for prediction market analysis
SERIES_MAP = {
# Interest rates
"fed_funds_rate": "FEDFUNDS",
"fed_rate": "FEDFUNDS",
"interest_rate": "FEDFUNDS",
"10y_treasury": "DGS10",
"2y_treasury": "DGS2",
"30y_mortgage": "MORTGAGE30US",
# Inflation
"cpi": "CPIAUCSL",
"core_cpi": "CPILFESL",
"pce": "PCEPI",
"core_pce": "PCEPILFE",
"inflation": "CPIAUCSL",
# Employment
"unemployment": "UNRATE",
"unemployment_rate": "UNRATE",
"nonfarm_payrolls": "PAYEMS",
"jobs": "PAYEMS",
"initial_claims": "ICSA",
"jobless_claims": "ICSA",
# GDP
"gdp": "GDP",
"real_gdp": "GDPC1",
"gdp_growth": "A191RL1Q225SBEA",
# Markets / Financial conditions
"sp500": "SP500",
"vix": "VIXCLS",
"yield_curve": "T10Y2Y",
"financial_stress": "STLFSI2",
# Dollar
"dollar_index": "DTWEXBGS",
"usd": "DTWEXBGS",
# Oil / Commodities
"oil_price": "DCOILWTICO",
"wti": "DCOILWTICO",
"crude_oil": "DCOILWTICO",
"brent": "DCOILBRENTEU",
"gas_price": "GASREGW",
"gold": "GOLDAMGBD228NLBM",
}
def _resolve_series_id(query: str) -> str:
"""Resolve a common name to a FRED series ID."""
q = query.strip().lower().replace(" ", "_")
if q in SERIES_MAP:
return SERIES_MAP[q]
# If already looks like a FRED series ID (uppercase), use as-is
return query.strip().upper()
class FREDService:
"""
FRED API client for macroeconomic data.
Free, unlimited usage with API key.
"""
def __init__(self, api_key: str):
self.api_key = api_key
self._client = httpx.Client(timeout=15.0)
def is_available(self) -> bool:
return bool(self.api_key and self.api_key.strip())
def get_series(self, query: str, num_observations: int = 10) -> str:
"""
Get recent observations for an economic data series.
Args:
query: Common name (e.g. 'fed_rate', 'cpi', 'unemployment', 'oil_price')
or a FRED series ID (e.g. 'FEDFUNDS', 'UNRATE')
num_observations: Number of recent data points to return
Returns:
Formatted report with series info and recent values.
"""
series_id = _resolve_series_id(query)
try:
# Get series metadata
meta_resp = self._client.get(
f"{FRED_API}/series",
params={
"series_id": series_id,
"api_key": self.api_key,
"file_type": "json",
},
)
if meta_resp.status_code == 400:
return (
f"Series '{query}' (id: {series_id}) not found on FRED. "
f"Common names: fed_rate, cpi, unemployment, gdp, oil_price, "
f"vix, yield_curve, gold, sp500, jobless_claims"
)
meta_resp.raise_for_status()
meta = meta_resp.json().get("seriess", [{}])[0]
title = meta.get("title", series_id)
frequency = meta.get("frequency", "")
units = meta.get("units", "")
last_updated = meta.get("last_updated", "")
# Get recent observations
obs_resp = self._client.get(
f"{FRED_API}/series/observations",
params={
"series_id": series_id,
"api_key": self.api_key,
"file_type": "json",
"sort_order": "desc",
"limit": num_observations,
},
)
obs_resp.raise_for_status()
observations = obs_resp.json().get("observations", [])
lines = [
f"--- FRED: {title} ({series_id}) ---",
f"Units: {units}",
f"Frequency: {frequency}",
f"Last Updated: {last_updated}",
"",
"Recent Data:",
]
for obs in reversed(observations):
date = obs.get("date", "")
value = obs.get("value", ".")
if value == ".":
lines.append(f" {date}: N/A")
else:
try:
v = float(value)
lines.append(f" {date}: {v:,.2f}")
except ValueError:
lines.append(f" {date}: {value}")
# Add trend info if enough data
valid_vals = []
for obs in observations:
v = obs.get("value", ".")
if v != ".":
try:
valid_vals.append(float(v))
except ValueError:
pass
if len(valid_vals) >= 2:
latest = valid_vals[0]
prev = valid_vals[1]
change = latest - prev
pct = (change / abs(prev) * 100) if prev != 0 else 0
lines.append(f"\nLatest vs Previous: {change:+.2f} ({pct:+.2f}%)")
lines.append("---")
return "\n".join(lines)
except httpx.HTTPError as e:
msg = f"FRED API error for '{query}' ({series_id}): {e}"
logger.error(msg)
return msg
except Exception as e:
msg = f"FRED query failed for '{query}': {e}"
logger.error(msg)
return msg
+204 -144
View File
@@ -1,13 +1,22 @@
"""LLM analyzer service - analyzes whale trades using AI."""
"""
LLM analyzer service - analyzes whale trades using AI with tool-use.
Architecture:
1. Build context (trade info + historical signals)
2. Send to LLM with tool schemas (search_twitter, search_web, etc.)
3. LLM decides which tools to call (if any)
4. Execute tool calls, return results to LLM
5. LLM produces final analysis + JSON decision
The LLM controls which information sources to query based on the market type.
"""
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 openai import OpenAI
from src.config import get_settings
from src.models.trade import WhaleTrade
@@ -15,61 +24,85 @@ from src.models.decision import LLMDecision, TradeRecommendation, TradeAction, T
from src.models.anomaly_signal import AnomalySignal
from src.services.anomaly_detector import AnomalyDetector
from src.services.anomaly_history import AnomalyHistoryService
from src.services.tools import ToolRegistry
from src.prompts.whale_analyzer import WhaleAnalyzerPrompts
logger = logging.getLogger(__name__)
# Maximum tool-use rounds to prevent infinite loops
# 14 tools available; LLM can call multiple per round but may need
# several rounds for chain-of-investigation (search → discover → verify)
MAX_TOOL_ROUNDS = 5
class LLMAnalyzer:
"""
Analyzes whale trades using LLM (Google Gemini models).
Combines trade context with superforecaster methodology to generate
comprehensive analysis reports with trading recommendations.
"""
"""Analyzes whale trades using LLM with function-calling tools."""
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.client = OpenAI(
base_url=self.settings.llm_base_url,
api_key=self.settings.gemini_api_key,
)
self.anomaly_detector = AnomalyDetector()
self.prompts = WhaleAnalyzerPrompts()
self.anomaly_history = AnomalyHistoryService()
self.anomaly_history = AnomalyHistoryService(self.settings.db_path)
# Tool registry — LLM decides which tools to call
self.tool_registry = ToolRegistry(
twitter_api_key=self.settings.twitter_api_key,
tavily_api_key=self.settings.tavily_api_key,
fred_api_key=self.settings.fred_api_key,
polygon_api_key=self.settings.polygon_api_key,
congress_api_key=self.settings.congress_api_key,
etherscan_api_key=self.settings.etherscan_api_key,
serper_api_key=self.settings.serper_api_key,
telegram_api_id=self.settings.telegram_api_id,
telegram_api_hash=self.settings.telegram_api_hash,
telegram_session_string=self.settings.telegram_session_string,
telegram_channels=self.settings.telegram_channels,
)
# Track the number of historical signals used in the last analysis
self._last_historical_signal_count = 0
@property
def last_historical_signal_count(self) -> int:
"""Get the number of historical anomaly signals used in the last analysis."""
return self._last_historical_signal_count
# ================================================================
# Response parsing
# ================================================================
# Fields that identify the final recommendation JSON (vs intermediate tool-call JSONs)
_RECOMMENDATION_FIELDS = {"information_asymmetry_score", "confidence", "trader_credibility"}
def _extract_json_from_response(self, response: str) -> Optional[dict]:
"""Extract the final recommendation JSON from LLM response text.
When the response contains multiple JSON code blocks (e.g. an
intermediate ANALYZE decision followed by the real assessment),
prefer the block that contains recommendation-specific fields.
Falls back to the last parseable block.
"""
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:
candidates: list[dict] = []
for match in re.findall(r"```(?:json)?\s*([\s\S]*?)```", response):
try:
return json.loads(match.strip())
candidates.append(json.loads(match.strip()))
except json.JSONDecodeError:
continue
# Try to find raw JSON
if candidates:
# Prefer the block that looks like a final recommendation
for c in reversed(candidates):
if c.keys() & self._RECOMMENDATION_FIELDS:
return c
# No block has recommendation fields — return the last one
return candidates[-1]
# Try raw JSON
try:
# Find JSON-like content
start = response.find("{")
end = response.rfind("}") + 1
if start >= 0 and end > start:
@@ -80,35 +113,22 @@ class LLMAnalyzer:
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
"""
"""Parse JSON into TradeRecommendation."""
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))
confidence = max(0.0, min(1.0, float(json_data.get("confidence", 0.0))))
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))
suggested_size = max(0.0, min(1.0, float(json_data.get("suggested_size_percent", 0.1))))
# 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))
insider_likelihood = max(0.0, min(1.0, float(json_data.get("information_asymmetry_score", 0.0))))
credibility_str = json_data.get("trader_credibility", "UNKNOWN").upper()
try:
@@ -116,8 +136,6 @@ class LLMAnalyzer:
except ValueError:
trader_credibility = TraderCredibility.UNKNOWN
insider_evidence = str(json_data.get("insider_evidence", ""))
return TradeRecommendation(
action=action,
outcome=str(json_data.get("outcome", "")),
@@ -125,41 +143,36 @@ class LLMAnalyzer:
suggested_price=suggested_price,
suggested_size_percent=suggested_size,
reasoning=str(json_data.get("reasoning", "")),
insider_trading_likelihood=insider_likelihood,
information_asymmetry_score=insider_likelihood,
trader_credibility=trader_credibility,
insider_evidence=insider_evidence,
insider_evidence=str(json_data.get("insider_evidence", "")),
)
# ================================================================
# Anomaly signal storage
# ================================================================
def _store_anomaly_signal_if_qualified(
self,
whale_trade: WhaleTrade,
decision: LLMDecision,
) -> None:
"""
Store an anomaly signal if the insider trading likelihood meets threshold.
Only signals with insider_trading_likelihood >= 0.4 are stored.
Args:
whale_trade: The whale trade
decision: The LLM decision
"""
"""Store anomaly signal if information asymmetry score qualifies."""
rec = decision.recommendation
if not self.anomaly_history.should_store_signal(rec.insider_trading_likelihood):
if not self.anomaly_history.should_store_signal(rec.information_asymmetry_score):
logger.debug(
f"Signal not stored: insider likelihood {rec.insider_trading_likelihood:.2f} "
f"Signal not stored: IAS {rec.information_asymmetry_score:.2f} "
f"below threshold"
)
return
# Create anomaly signal from whale trade
# Store insider_trading_likelihood for sorting, but it won't be shown to LLM
signal = AnomalySignal(
id=whale_trade.id,
market_id=whale_trade.market_id,
market_question=whale_trade.market_question,
market_slug=whale_trade.trade.slug,
condition_id=whale_trade.trade.condition_id,
transaction_hash=whale_trade.trade.transaction_hash,
trade_timestamp=whale_trade.trade.timestamp,
trade_side=whale_trade.trade.side,
@@ -169,69 +182,144 @@ class LLMAnalyzer:
trader_wallet=whale_trade.trade.proxy_wallet,
trader_ranking=whale_trade.trader_ranking,
trader_history=whale_trade.trader_history,
insider_trading_likelihood=rec.insider_trading_likelihood,
information_asymmetry_score=rec.information_asymmetry_score,
reasoning=rec.reasoning,
insider_evidence=rec.insider_evidence,
detected_at=whale_trade.detected_at,
)
# Store the signal
stored = self.anomaly_history.store_signal(signal)
if stored:
logger.info(
f"Stored anomaly signal: {whale_trade.market_question[:50]}... "
f"insider_likelihood={rec.insider_trading_likelihood:.0%}"
f"IAS={rec.information_asymmetry_score:.0%}"
)
# ================================================================
# Tool-use loop
# ================================================================
def _execute_tool_calls(self, tool_calls) -> list[dict]:
"""Execute tool calls from the LLM and return message dicts."""
results = []
for tc in tool_calls:
fn_name = tc.function.name
try:
fn_args = json.loads(tc.function.arguments)
except json.JSONDecodeError:
fn_args = {}
logger.info(f"LLM requested tool: {fn_name}({fn_args})")
output = self.tool_registry.call(fn_name, **fn_args)
results.append({
"role": "tool",
"tool_call_id": tc.id,
"content": output,
})
return results
async def analyze_whale_trade(self, whale_trade: WhaleTrade) -> LLMDecision:
"""
Analyze a whale trade using LLM.
Analyze a whale trade using LLM with tool-use.
Args:
whale_trade: The whale trade to analyze
Returns:
LLMDecision with analysis and recommendation
Flow:
0. Pre-screening: lightweight check if signal is worth full analysis
1. Build initial context (trade + historical signals)
2. Send to LLM with available tool schemas
3. If LLM requests tools execute return results repeat (up to MAX_TOOL_ROUNDS)
4. Parse final text response for JSON decision
"""
# Format trade context for LLM
# Build context
trade_context = self.anomaly_detector.format_for_llm(whale_trade)
# Find and format historical anomaly signals for the same market
# Get top 5 most recent + top 5 highest insider likelihood, deduplicated
historical_context = ""
historical_signals = self.anomaly_history.get_signals_for_market(
whale_trade.market_id,
top_recent=5,
top_likelihood=5,
whale_trade.market_id, top_recent=5, top_likelihood=5,
)
self._last_historical_signal_count = len(historical_signals)
if historical_signals:
historical_context = self.anomaly_history.format_historical_signals_context(historical_signals)
logger.info(f"Found {len(historical_signals)} historical anomaly signals for market: {whale_trade.market_question}")
logger.info(f"Found {len(historical_signals)} historical anomaly signals for market")
# Build prompt (Gemini uses single prompt with system instruction)
# Build initial messages
system_prompt = self.prompts.system_prompt()
user_prompt = self.prompts.analyze_whale_trade(trade_context, historical_context)
full_prompt = f"{system_prompt}\n\n---\n\n{user_prompt}"
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
# Tool schemas (empty list if no tools available)
tool_schemas = self.tool_registry.openai_tool_schemas()
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 = ""
analysis_text = response.text
logger.debug(f"LLM response: {analysis_text[:500]}...")
# Tool-use loop
for round_idx in range(MAX_TOOL_ROUNDS + 1):
# Call LLM
call_kwargs = {
"model": self.settings.llm_model,
"messages": messages,
}
if tool_schemas and round_idx < MAX_TOOL_ROUNDS:
call_kwargs["tools"] = tool_schemas
call_kwargs["tool_choice"] = "auto"
# Extract JSON from response
response = self.client.chat.completions.create(**call_kwargs)
msg = response.choices[0].message
# If LLM wants to call tools
if msg.tool_calls:
logger.info(
f"Round {round_idx + 1}: LLM requested "
f"{len(msg.tool_calls)} tool call(s)"
)
# Append assistant message with tool calls
messages.append(msg.model_dump())
# Execute tools and append results
tool_results = self._execute_tool_calls(msg.tool_calls)
messages.extend(tool_results)
continue # Next round — LLM processes tool results
# No tool calls — final response
analysis_text = msg.content or ""
logger.info(
f"Analysis complete after {round_idx + 1} round(s) "
f"({len(analysis_text)} chars)"
)
break
# Parse JSON decision from final response
json_data = self._extract_json_from_response(analysis_text)
if json_data:
# Check if LLM decided to skip (pre-screening in prompt)
if json_data.get("action") == "SKIP":
reason = json_data.get("reason", "not in scope")
logger.info(
f"⏭️ Pre-screening SKIP: {reason} "
f"(market: {whale_trade.market_question[:40]}...)"
)
return LLMDecision(
whale_trade_id=whale_trade.id,
market_id=whale_trade.market_id,
analysis=f"Pre-screening: {reason}",
recommendation=TradeRecommendation(
action=TradeAction.HOLD,
outcome="",
confidence=0.0,
reasoning=f"Signal filtered: {reason}",
),
)
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,
@@ -247,14 +335,11 @@ class LLMAnalyzer:
recommendation=recommendation,
)
# Store anomaly signal if insider trading likelihood >= 0.4
self._store_anomaly_signal_if_qualified(whale_trade, decision)
return decision
except Exception as e:
logger.error(f"Error calling LLM: {e}")
# Return a safe default decision
logger.error(f"Error in LLM analysis: {e}")
return LLMDecision(
whale_trade_id=whale_trade.id,
market_id=whale_trade.market_id,
@@ -267,27 +352,20 @@ class LLMAnalyzer:
),
)
# ================================================================
# Report formatting (unchanged)
# ================================================================
def format_full_report(
self,
whale_trade: WhaleTrade,
decision: LLMDecision,
historical_signal_count: int = 0,
) -> str:
"""
Format a complete analysis report with trade info, analysis, and decision.
Args:
whale_trade: The whale trade
decision: The LLM decision
historical_signal_count: Number of historical anomaly signals used in analysis
Returns:
Formatted report string
"""
"""Format a complete analysis report."""
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([
@@ -295,32 +373,29 @@ class LLMAnalyzer:
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%})"
ias = rec.information_asymmetry_score
if ias >= 0.7:
insider_indicator = f"🔴 高信息不对称 ({ias:.0%})"
elif ias >= 0.4:
insider_indicator = f"🟡 中等信息不对称 ({ias:.0%})"
else:
insider_indicator = f"🟢 普通交易 ({insider_likelihood:.0%})"
insider_indicator = f"🟢 低信息不对称 ({ias:.0%})"
# Trader credibility indicator
rank_num = whale_trade.trader_ranking.rank if whale_trade.trader_ranking and whale_trade.trader_ranking.rank else None
credibility_indicators = {
TraderCredibility.HIGH: "🏆 高可信度 (前100名)",
TraderCredibility.MEDIUM: "⭐ 中等可信度 (100-500名)",
TraderCredibility.LOW: "📉 低可信度 (500名+)",
TraderCredibility.HIGH: f"🏆 高可信度 (#{rank_num})" if rank_num else "🏆 高可信度",
TraderCredibility.MEDIUM: f"⭐ 中等可信度 (#{rank_num})" if rank_num else "⭐ 中等可信度",
TraderCredibility.LOW: f"📉 低可信度 (#{rank_num})" if rank_num else "📉 低可信度",
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
@@ -328,7 +403,6 @@ class LLMAnalyzer:
pnl_str = f"${tr.pnl:,.2f}" if tr.pnl else "N/A"
trader_ranking_str = f"| **交易者排名** | {rank_str} (PnL: {pnl_str}) |"
# Historical signals info
historical_info = ""
if historical_signal_count > 0:
historical_info = f"\n**参考历史异常信号**: {historical_signal_count} 笔 (已综合分析)"
@@ -346,9 +420,8 @@ class LLMAnalyzer:
|------|------|
| **市场** | {whale_trade.market_question} |
| **交易金额** | ${trade.usdc_size:,.2f} USDC |
| **交易方向** | {trade.side} |
| **交易方向** | BUY {trade.outcome} Token ({'看多' if trade.outcome == 'Yes' else '看空'}) |
| **交易价格** | {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}
@@ -358,33 +431,20 @@ class LLMAnalyzer:
{decision.analysis}
{'='*70}
## 🔍 内幕交易评估
## 🔍 信息不对称评估
{'='*70}
| 项目 | 评估 |
|------|------|
| **内幕交易可能性** | {insider_indicator} |
| **信息不对称程度** | {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}
**推理过程**: {rec.reasoning}
{'='*70}
免责声明本报告由AI生成仅供参考不构成投资建议
预测市场具有高风险请基于自身判断谨慎决策
{'='*70}
"""
return report
+201 -29
View File
@@ -14,25 +14,40 @@ logger = logging.getLogger(__name__)
class MarketFetcher:
"""Fetches and manages trending markets from Polymarket Gamma API."""
# Sports-related keywords to filter out (case-insensitive)
# Short-term price prediction markets to filter out (no insider trading value)
# Matches patterns like "Bitcoin Up or Down - March 27, 2:00AM-2:15AM ET"
SHORT_TERM_PRICE_KEYWORDS = [
"up or down", # "Bitcoin Up or Down - March 27, 2:00AM"
"higher or lower", # price higher or lower
"above or below", # close above or below
"opens up or down", # "S&P 500 Opens Up or Down"
"green or red", # daily candle color
]
# Temperature/weather markets (no insider trading value)
WEATHER_KEYWORDS = [
"highest temperature",
"lowest temperature",
"temperature in",
"weather",
"rainfall",
"°f on",
"°c on",
]
# Sports-related keywords to filter out
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",
@@ -51,28 +66,35 @@ class MarketFetcher:
if hasattr(self, "_client"):
self._client.close()
def _is_sports_market(self, market_data: dict) -> bool:
def _should_filter_market(self, market_data: dict) -> str:
"""
Check if a market is sports-related.
Args:
market_data: Raw market data from API
Check if a market should be filtered out.
Returns:
True if the market is sports-related
Filter reason string if should be filtered, empty string if OK.
"""
# 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}"
text = f"{question} {description} {slug}"
for keyword in self.SPORTS_KEYWORDS:
if keyword in text_to_check:
return True
if keyword in text:
return "sports"
return False
for keyword in self.SHORT_TERM_PRICE_KEYWORDS:
if keyword in text:
return "short_term_price"
for keyword in self.WEATHER_KEYWORDS:
if keyword in text:
return "weather"
return ""
def _is_sports_market(self, market_data: dict) -> bool:
"""Legacy compatibility."""
return bool(self._should_filter_market(market_data))
def _parse_market(self, data: dict) -> Optional[Market]:
"""Parse raw market data into Market model."""
@@ -156,11 +178,11 @@ class MarketFetcher:
if not data:
break # No more markets
sports_count = 0
filtered_counts = {"sports": 0, "short_term_price": 0, "weather": 0}
for market_data in data:
# Skip sports markets
if self._is_sports_market(market_data):
sports_count += 1
reason = self._should_filter_market(market_data)
if reason:
filtered_counts[reason] = filtered_counts.get(reason, 0) + 1
continue
market = self._parse_market(market_data)
@@ -177,11 +199,19 @@ class MarketFetcher:
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)}"
)
total_filtered = sum(filtered_counts.values())
if total_filtered:
parts = [f"{k}={v}" for k, v in filtered_counts.items() if v > 0]
logger.debug(
f"Batch {iteration}: fetched {len(data)}, "
f"filtered {total_filtered} ({', '.join(parts)}), "
f"kept: {len(trending_markets)}"
)
else:
logger.debug(
f"Batch {iteration}: fetched {len(data)}, "
f"kept: {len(trending_markets)}"
)
if len(data) < batch_size:
break # No more markets available
@@ -189,7 +219,8 @@ class MarketFetcher:
offset += batch_size
logger.info(
f"Fetched {len(trending_markets)} trending markets (sports markets filtered out)"
f"Fetched {len(trending_markets)} trending markets "
f"(filtered: sports, short-term price, weather)"
)
return trending_markets
@@ -200,6 +231,147 @@ class MarketFetcher:
logger.error(f"Error fetching trending markets: {e}")
return trending_markets
# Keywords that identify token launch / crypto project markets
TOKEN_LAUNCH_KEYWORDS = [
"fdv", "market cap (fdv)", "launch a token", "tge",
"listing", "airdrop", "public sale",
]
def _is_token_launch_market(self, market_data: dict) -> bool:
"""Check if a market is related to token launches / crypto projects."""
question = (market_data.get("question") or "").lower()
return any(kw in question for kw in self.TOKEN_LAUNCH_KEYWORDS)
def get_token_launch_markets(self, max_scan: int = 2000) -> List[TrendingMarket]:
"""
Scan active markets for token launch / crypto project markets
that may not be in the top trending list.
Returns:
List of TrendingMarket objects for token launch markets.
"""
token_markets = []
offset = 0
batch_size = 100
seen_ids = set()
try:
while offset < max_scan:
params = {
"active": True, "closed": False, "archived": False,
"limit": batch_size, "offset": offset,
"order": "volume24hr", "ascending": False,
"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:
if not self._is_token_launch_market(market_data):
continue
market = self._parse_market(market_data)
if market and market.id not in seen_ids:
seen_ids.add(market.id)
tm = TrendingMarket(
market=market,
volume_24hr=market.volume_24hr,
liquidity=market.liquidity,
)
if tm.is_valid_for_monitoring:
token_markets.append(tm)
if len(data) < batch_size:
break
offset += batch_size
logger.info(f"Found {len(token_markets)} token launch markets")
return token_markets
except Exception as e:
logger.error(f"Error fetching token launch markets: {e}")
return token_markets
def get_niche_markets(
self,
limit: int = 50,
min_volume_24hr: float = 5_000,
max_volume_24hr: float = 500_000,
offset_start: int = 200,
max_scan: int = 1500,
) -> List[TrendingMarket]:
"""
Fetch niche markets (lower volume) that may have higher information
asymmetry value. Scans markets ranked beyond the top trending list.
Args:
limit: Max number of niche markets to return
min_volume_24hr: Minimum 24h volume (filter out dead markets)
max_volume_24hr: Maximum 24h volume (filter out large/macro markets)
offset_start: Start scanning from this rank
max_scan: Stop scanning after this offset
Returns:
List of TrendingMarket objects for niche markets.
"""
niche_markets = []
offset = offset_start
batch_size = 100
try:
while offset < max_scan and len(niche_markets) < limit:
params = {
"active": True, "closed": False, "archived": False,
"limit": batch_size, "offset": offset,
"order": "volume24hr", "ascending": False,
"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:
vol = float(market_data.get("volume24hr", 0) or 0)
# Volume filter: not too small (dead), not too large (macro)
if vol < min_volume_24hr or vol > max_volume_24hr:
continue
# Apply standard filters (sports, weather, short-term price)
if self._should_filter_market(market_data):
continue
market = self._parse_market(market_data)
if market:
tm = TrendingMarket(
market=market,
volume_24hr=market.volume_24hr,
liquidity=market.liquidity,
)
if tm.is_valid_for_monitoring:
niche_markets.append(tm)
if len(niche_markets) >= limit:
break
if len(data) < batch_size:
break
offset += batch_size
logger.info(
f"Found {len(niche_markets)} niche markets "
f"(volume ${min_volume_24hr:,.0f}-${max_volume_24hr:,.0f})"
)
return niche_markets
except Exception as e:
logger.error(f"Error fetching niche markets: {e}")
return niche_markets
def get_market_by_id(self, market_id: str) -> Optional[Market]:
"""
Fetch a single market by ID.
+156
View File
@@ -0,0 +1,156 @@
"""Polygon.io API service for stocks, forex, and commodities market data."""
import logging
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
POLYGON_API = "https://api.polygon.io"
class PolygonService:
"""
Polygon.io API client for financial market data.
Covers: stocks, options, forex, crypto, indices, commodities futures.
"""
def __init__(self, api_key: str):
self.api_key = api_key
self._client = httpx.Client(timeout=15.0)
def is_available(self) -> bool:
return bool(self.api_key and self.api_key.strip())
def _get(self, path: str, params: Optional[dict] = None) -> dict:
"""Make authenticated GET request."""
params = params or {}
params["apiKey"] = self.api_key
resp = self._client.get(f"{POLYGON_API}{path}", params=params)
resp.raise_for_status()
return resp.json()
def get_ticker_snapshot(self, ticker: str) -> str:
"""
Get previous day close + recent daily bars for a ticker.
Args:
ticker: Ticker symbol (AAPL, TSLA, GS, SPY, QQQ, GLD, USO)
Returns:
Formatted price and market data report.
"""
t = ticker.strip().upper()
try:
# Previous close (free tier)
prev_data = self._get(f"/v2/aggs/ticker/{t}/prev")
results = prev_data.get("results", [])
if not results:
return f"No data found for '{ticker}' on Polygon.io."
bar = results[0]
close = bar.get("c", 0)
open_p = bar.get("o", 0)
high = bar.get("h", 0)
low = bar.get("l", 0)
volume = bar.get("v", 0)
vwap = bar.get("vw", 0)
change = close - open_p if open_p else 0
change_pct = (change / open_p * 100) if open_p else 0
lines = [
f"--- {t} Last Trading Day (Polygon.io) ---",
f"Close: ${close:,.2f}",
f"Open: ${open_p:,.2f}",
f"High: ${high:,.2f}",
f"Low: ${low:,.2f}",
f"Change: {change:+.2f} ({change_pct:+.2f}%)",
]
if vwap:
lines.append(f"VWAP: ${vwap:,.2f}")
if volume:
lines.append(f"Volume: {volume:,.0f}")
# Also try to get 5-day bars for trend
try:
from datetime import date, timedelta
end = date.today()
start = end - timedelta(days=10)
range_data = self._get(
f"/v2/aggs/ticker/{t}/range/1/day/{start.isoformat()}/{end.isoformat()}",
params={"adjusted": "true", "sort": "asc", "limit": 10},
)
bars = range_data.get("results", [])
if len(bars) >= 2:
first_close = bars[0].get("c", 0)
last_close = bars[-1].get("c", 0)
if first_close:
week_change = ((last_close - first_close) / first_close) * 100
lines.append(f"~{len(bars)}-day Change: {week_change:+.2f}%")
except Exception:
pass # trend data is optional
lines.append("---")
return "\n".join(lines)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return f"Ticker '{ticker}' not found on Polygon.io."
return f"Polygon API error for '{ticker}': HTTP {e.response.status_code}"
except Exception as e:
msg = f"Polygon query failed for '{ticker}': {e}"
logger.error(msg)
return msg
def get_market_news(self, ticker: str, limit: int = 5) -> str:
"""
Get recent news articles for a ticker.
Args:
ticker: Stock/crypto ticker (e.g. AAPL, TSLA, GS)
limit: Number of articles (1-10)
Returns:
Formatted news report.
"""
t = ticker.strip().upper()
limit = max(1, min(limit, 10))
try:
data = self._get("/v2/reference/news", params={
"ticker": t,
"limit": limit,
"order": "desc",
"sort": "published_utc",
})
results = data.get("results", [])
if not results:
return f"No recent news found for '{ticker}'."
lines = [f"--- {t} Recent News (Polygon.io) ---"]
for i, article in enumerate(results, 1):
title = article.get("title", "No title")
published = article.get("published_utc", "")[:19]
source = article.get("publisher", {}).get("name", "Unknown")
desc = article.get("description", "")[:200]
if len(article.get("description", "")) > 200:
desc += "..."
lines.append(f"{i}. **{title}**")
lines.append(f" Source: {source} | {published}")
if desc:
lines.append(f" {desc}")
lines.append("")
lines.append("---")
return "\n".join(lines)
except Exception as e:
msg = f"Polygon news query failed for '{ticker}': {e}"
logger.error(msg)
return msg
+426
View File
@@ -0,0 +1,426 @@
"""Price monitoring service - monitors ALL active market prices for volatility."""
import asyncio
import json
import logging
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Awaitable, Callable, Dict, List, Optional
import httpx
logger = logging.getLogger(__name__)
# Gamma API for fetching market prices
GAMMA_API_URL = "https://gamma-api.polymarket.com/markets"
# Storage directory for price volatility alerts
VOLATILITY_DIR = Path(__file__).parent.parent.parent / "price_volatility"
@dataclass
class PricePoint:
"""A single price observation."""
timestamp: int
yes_price: float
@dataclass
class VolatilityAlert:
"""A price volatility alert."""
market_id: str
market_question: str
start_timestamp: int
end_timestamp: int
start_price: float
end_price: float
price_change: float
price_change_percent: float
direction: str # "UP" or "DOWN"
window_seconds: int
detected_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
def to_dict(self) -> dict:
return {
"market_id": self.market_id,
"market_question": self.market_question,
"start_timestamp": self.start_timestamp,
"end_timestamp": self.end_timestamp,
"start_price": self.start_price,
"end_price": self.end_price,
"price_change": self.price_change,
"price_change_percent": self.price_change_percent,
"direction": self.direction,
"window_seconds": self.window_seconds,
"detected_at": self.detected_at,
}
class PriceMonitor:
"""
Monitors ALL active market prices for short-term volatility.
Tracks Yes prices for all active markets and alerts when
price changes exceed threshold within the time window.
"""
# Default configuration
DEFAULT_WINDOW_SECONDS = 300 # 5 minutes
DEFAULT_THRESHOLD = 0.10 # 10%
DEFAULT_MAX_HISTORY_SECONDS = 3600 # Keep 1 hour of history
DEFAULT_POLL_INTERVAL = 30 # Poll all markets every 30 seconds
def __init__(
self,
window_seconds: int = DEFAULT_WINDOW_SECONDS,
threshold: float = DEFAULT_THRESHOLD,
max_history_seconds: int = DEFAULT_MAX_HISTORY_SECONDS,
poll_interval: int = DEFAULT_POLL_INTERVAL,
on_volatility_detected: Optional[Callable[["VolatilityAlert"], Awaitable[None]]] = None,
):
"""
Initialize the price monitor.
Args:
window_seconds: Time window for volatility detection (default 5 minutes)
threshold: Price change threshold to trigger alert (default 10%)
max_history_seconds: How long to keep price history (default 1 hour)
poll_interval: Interval for polling all markets (default 30 seconds)
on_volatility_detected: Async callback when volatility is detected
"""
self.window_seconds = window_seconds
self.threshold = threshold
self.max_history_seconds = max_history_seconds
self.poll_interval = poll_interval
# Callback for volatility detection
self._on_volatility_detected = on_volatility_detected
# Price history per market: market_id -> deque of PricePoints
self._price_history: Dict[str, deque] = {}
# Market info cache: market_id -> question
self._market_info: Dict[str, str] = {}
# Track recent alerts to avoid duplicates (market_id -> last_alert_timestamp)
self._recent_alerts: Dict[str, int] = {}
# Minimum interval between alerts for same market (seconds)
self._alert_cooldown = 3600 # 1 hour (match window_seconds)
# HTTP client for API calls
self._client: Optional[httpx.AsyncClient] = None
# Control flag
self._running = False
# Ensure storage directory exists
VOLATILITY_DIR.mkdir(parents=True, exist_ok=True)
def record_price(self, market_id: str, market_question: str, yes_price: float) -> Optional[VolatilityAlert]:
"""
Record a price observation and check for volatility.
Args:
market_id: The market ID
market_question: The market question text
yes_price: Current Yes price (0-1)
Returns:
VolatilityAlert if threshold exceeded, None otherwise
"""
now = int(datetime.utcnow().timestamp())
# Initialize history for new markets
if market_id not in self._price_history:
self._price_history[market_id] = deque()
history = self._price_history[market_id]
# Add new price point
history.append(PricePoint(timestamp=now, yes_price=yes_price))
# Clean up old entries
cutoff = now - self.max_history_seconds
while history and history[0].timestamp < cutoff:
history.popleft()
# Check for volatility
alert = self._check_volatility(market_id, market_question, now)
if alert:
# Check cooldown
last_alert = self._recent_alerts.get(market_id, 0)
if now - last_alert < self._alert_cooldown:
logger.debug(f"Alert suppressed for {market_id} (cooldown)")
return None
# Record alert
self._recent_alerts[market_id] = now
self._store_alert(alert)
# Log warning
logger.warning(
f"🚨 PRICE VOLATILITY: {market_question[:50]}... "
f"{alert.direction} {abs(alert.price_change_percent):.1%} "
f"({alert.start_price:.2%}{alert.end_price:.2%}) "
f"in {alert.window_seconds // 60}min"
)
return alert
return None
def _check_volatility(
self, market_id: str, market_question: str, current_time: int
) -> Optional[VolatilityAlert]:
"""
Check if price volatility exceeds threshold within the time window.
Args:
market_id: The market ID
market_question: The market question text
current_time: Current timestamp
Returns:
VolatilityAlert if threshold exceeded, None otherwise
"""
history = self._price_history.get(market_id)
if not history or len(history) < 2:
return None
current_price = history[-1].yes_price
window_start = current_time - self.window_seconds
# Find the oldest price within the window
oldest_in_window = None
for point in history:
if point.timestamp >= window_start:
oldest_in_window = point
break
if oldest_in_window is None:
return None
# Calculate price change
price_change = current_price - oldest_in_window.yes_price
price_change_abs = abs(price_change)
if price_change_abs < self.threshold:
return None
# Create alert
return VolatilityAlert(
market_id=market_id,
market_question=market_question,
start_timestamp=oldest_in_window.timestamp,
end_timestamp=current_time,
start_price=oldest_in_window.yes_price,
end_price=current_price,
price_change=price_change,
price_change_percent=price_change,
direction="UP" if price_change > 0 else "DOWN",
window_seconds=current_time - oldest_in_window.timestamp,
)
def _store_alert(self, alert: VolatilityAlert) -> None:
"""
Store a volatility alert to file.
Args:
alert: The alert to store
"""
alerts_file = VOLATILITY_DIR / "volatility_alerts.json"
# Load existing alerts
existing_alerts = []
if alerts_file.exists():
try:
with open(alerts_file, 'r', encoding='utf-8') as f:
existing_alerts = json.load(f)
except Exception as e:
logger.error(f"Failed to load existing alerts: {e}")
# Add new alert
existing_alerts.append(alert.to_dict())
# Save back
try:
with open(alerts_file, 'w', encoding='utf-8') as f:
json.dump(existing_alerts, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"Failed to store volatility alert: {e}")
def get_price_history(self, market_id: str) -> List[dict]:
"""
Get price history for a market.
Args:
market_id: The market ID
Returns:
List of price points as dicts
"""
history = self._price_history.get(market_id, deque())
return [{"timestamp": p.timestamp, "yes_price": p.yes_price} for p in history]
def get_all_alerts(self) -> List[dict]:
"""
Get all stored volatility alerts.
Returns:
List of alerts as dicts
"""
alerts_file = VOLATILITY_DIR / "volatility_alerts.json"
if not alerts_file.exists():
return []
try:
with open(alerts_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"Failed to load alerts: {e}")
return []
def clear_history(self, market_id: Optional[str] = None) -> None:
"""
Clear price history.
Args:
market_id: If provided, clear only this market's history. Otherwise clear all.
"""
if market_id:
if market_id in self._price_history:
del self._price_history[market_id]
logger.info(f"Cleared price history for {market_id}")
else:
self._price_history.clear()
logger.info("Cleared all price history")
async def _fetch_all_active_markets(self) -> List[dict]:
"""
Fetch all active markets from Gamma API.
Returns:
List of market data dicts with id, question, and outcomePrices
"""
all_markets = []
offset = 0
batch_size = 100
try:
while True:
params = {
"active": True,
"closed": False,
"archived": False,
"limit": batch_size,
"offset": offset,
"enableOrderBook": True,
}
response = await self._client.get(GAMMA_API_URL, params=params)
response.raise_for_status()
data = response.json()
if not data:
break
for market in data:
market_id = str(market.get("id", ""))
question = market.get("question", "")
outcome_prices = market.get("outcomePrices", [])
if isinstance(outcome_prices, str):
outcome_prices = json.loads(outcome_prices)
if market_id and outcome_prices:
all_markets.append({
"id": market_id,
"question": question,
"yes_price": float(outcome_prices[0]) if outcome_prices else None,
})
# Cache market info
self._market_info[market_id] = question
if len(data) < batch_size:
break
offset += batch_size
except Exception as e:
logger.error(f"Error fetching active markets: {e}")
return all_markets
async def _poll_all_prices(self) -> List[VolatilityAlert]:
"""
Poll prices for all active markets and check for volatility.
Returns:
List of volatility alerts triggered
"""
alerts = []
markets = await self._fetch_all_active_markets()
logger.debug(f"Polling prices for {len(markets)} active markets")
for market in markets:
market_id = market["id"]
question = market["question"]
yes_price = market.get("yes_price")
if yes_price is not None:
alert = self.record_price(market_id, question, yes_price)
if alert:
alerts.append(alert)
return alerts
async def run(self) -> None:
"""
Start the price monitoring loop.
Continuously polls all active markets at the configured interval.
"""
self._running = True
self._client = httpx.AsyncClient(timeout=60.0)
logger.info(
f"Starting price monitor (interval: {self.poll_interval}s, "
f"window: {self.window_seconds}s, threshold: {self.threshold:.0%})"
)
try:
while self._running:
try:
alerts = await self._poll_all_prices()
if alerts:
logger.info(f"Detected {len(alerts)} volatility alerts")
# Call callback for each alert
if self._on_volatility_detected:
for alert in alerts:
try:
await self._on_volatility_detected(alert)
except Exception as e:
logger.error(f"Error in volatility callback: {e}")
except Exception as e:
logger.error(f"Error in price monitoring loop: {e}")
await asyncio.sleep(self.poll_interval)
finally:
await self._client.aclose()
self._client = None
def stop(self) -> None:
"""Stop the price monitoring loop."""
self._running = False
logger.info("Price monitor stopping...")
def get_monitored_market_count(self) -> int:
"""Get the number of markets currently being monitored."""
return len(self._price_history)
+115
View File
@@ -0,0 +1,115 @@
"""Resolution tracker - checks if markets with signals have resolved and updates correctness."""
import asyncio
import logging
from datetime import datetime
from typing import Optional
from src.db.database import SignalDatabase
from src.services.market_fetcher import MarketFetcher
logger = logging.getLogger(__name__)
class ResolutionTracker:
"""Tracks market resolutions and computes signal correctness."""
def __init__(self, db: SignalDatabase):
self.db = db
self.market_fetcher = MarketFetcher()
def _determine_resolved_outcome(self, market) -> Optional[str]:
"""
Determine the resolved outcome from a market.
A market is considered resolved if closed==True and one outcome price >= 0.99.
Returns:
The winning outcome string (e.g. "Yes" or "No"), or None if not resolved.
"""
if not market.closed:
return None
if not market.outcomes or not market.outcome_prices:
return None
for outcome, price in zip(market.outcomes, market.outcome_prices):
if price >= 0.99:
return outcome
return None
def _is_past_end_date(self, market) -> bool:
"""Check if a market's end_date has passed."""
if not market.end_date:
return True # No end date, always check
try:
end_dt = datetime.fromisoformat(market.end_date.replace("Z", "+00:00"))
return datetime.utcnow().replace(tzinfo=end_dt.tzinfo) >= end_dt
except (ValueError, TypeError):
return True
async def check_all(self) -> dict:
"""
Check all unresolved markets for resolution.
Returns:
Summary dict with counts.
"""
unresolved_ids = self.db.get_unresolved_market_ids()
if not unresolved_ids:
logger.debug("No unresolved markets to check")
return {"checked": 0, "resolved": 0, "signals_updated": 0}
logger.info(f"Checking {len(unresolved_ids)} unresolved markets for resolution")
checked = 0
resolved = 0
signals_updated = 0
for market_id in unresolved_ids:
try:
market = self.market_fetcher.get_market_by_id(market_id)
if not market:
logger.debug(f"Market {market_id} not found on API")
checked += 1
await asyncio.sleep(0.5)
continue
# Optimization: skip markets whose end_date hasn't passed yet
if not self._is_past_end_date(market):
checked += 1
await asyncio.sleep(0.5)
continue
outcome = self._determine_resolved_outcome(market)
if outcome:
updated = self.db.mark_market_resolved(
market_id=market_id,
resolved_outcome=outcome,
resolved_at=datetime.utcnow(),
)
resolved += 1
signals_updated += updated
logger.info(
f"Market resolved: {market.question[:50]}... "
f"outcome={outcome}, {updated} signals updated"
)
checked += 1
await asyncio.sleep(0.5) # Rate limiting
except Exception as e:
logger.error(f"Error checking market {market_id}: {e}")
checked += 1
await asyncio.sleep(0.5)
result = {
"checked": checked,
"resolved": resolved,
"signals_updated": signals_updated,
}
logger.info(
f"Resolution check complete: {checked} checked, "
f"{resolved} resolved, {signals_updated} signals updated"
)
return result
+95
View File
@@ -0,0 +1,95 @@
"""Serper.dev web search service."""
import logging
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
SERPER_SEARCH_URL = "https://google.serper.dev/search"
class SerperSearchService:
"""Web search service using Serper.dev API (Google Search results)."""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or ""
if not self.api_key:
logger.debug("SERPER_API_KEY not set. Serper search will be disabled.")
def is_available(self) -> bool:
return bool(self.api_key and self.api_key.strip())
def search(self, query: str, max_results: int = 5) -> str:
if not self.is_available():
return "Web search unavailable: SERPER_API_KEY not configured."
headers = {
"X-API-KEY": self.api_key,
"Content-Type": "application/json",
}
payload = {"q": query, "num": max_results}
try:
with httpx.Client(timeout=20) as client:
response = client.post(SERPER_SEARCH_URL, json=payload, headers=headers)
if response.status_code != 200:
logger.error(f"Serper API error: {response.status_code} - {response.text[:200]}")
return f"Web search API Error: {response.status_code}"
data = response.json()
# Format results
report = [f"--- Web Search Results for '{query}' ---"]
# Knowledge graph answer
kg = data.get("knowledgeGraph")
if kg:
title = kg.get("title", "")
desc = kg.get("description", "")
if title and desc:
report.append(f"**Summary**: {title}{desc}\n")
# Answer box
answer_box = data.get("answerBox")
if answer_box:
answer = answer_box.get("answer") or answer_box.get("snippet", "")
if answer:
report.append(f"**Summary**: {answer}\n")
# Organic results
organic = data.get("organic", [])
if not organic:
return f"No web search results found for '{query}'."
for idx, item in enumerate(organic[:max_results], 1):
title = item.get("title", "No title")
url = item.get("link", "")
snippet = item.get("snippet", "")
report.append(f"{idx}. **{title}**")
report.append(f" Source: {url}")
report.append(f" {snippet}")
report.append("")
report.append("-------------------------------------------")
return "\n".join(report)
except Exception as e:
logger.error(f"Serper search failed: {e}")
return f"Web search failed: {str(e)}"
def search_for_market(self, market_question: str, max_results: int = 5) -> str:
if not self.is_available():
return "Web search unavailable: SERPER_API_KEY not configured."
query = market_question[:200]
result = self.search(query, max_results=max_results)
# Propagate errors so the unified WebSearchService can fall back
if "API Error" in result or "search failed" in result:
return result
if "No web search results" not in result and "Error" not in result:
return "## 🔍 Web Search Results (News & Analysis)\n" + result
return f"No relevant web results found for: {market_question[:50]}..."
+90
View File
@@ -0,0 +1,90 @@
"""Stats engine - computes signal performance statistics from the database."""
import logging
from typing import List
from src.db.database import SignalDatabase
from src.models.anomaly_signal import AnomalySignal
logger = logging.getLogger(__name__)
class StatsEngine:
"""Computes signal performance statistics."""
def __init__(self, db: SignalDatabase):
self.db = db
def get_overview(self) -> dict:
"""
Get overall signal performance stats.
Returns:
Dict with total_signals, resolved, correct, win_rate, avg_roi, total_theoretical_pnl.
"""
return self.db.get_stats()
def get_stats_by_likelihood_tier(self) -> List[dict]:
"""
Get stats broken down by information_asymmetry_score tiers.
Tiers: 0.4-0.6, 0.6-0.8, 0.8-1.0
Returns:
List of tier stat dicts.
"""
return self.db.get_stats_by_tier()
def get_recent_resolved(self, limit: int = 20) -> List[AnomalySignal]:
"""Get recently resolved signals."""
return self.db.get_recent_resolved(limit)
def get_best_worst(self, n: int = 5) -> dict:
"""Get best and worst signals by ROI."""
return self.db.get_best_worst(n)
def format_stats_summary(self) -> str:
"""
Format a human-readable stats summary for briefings.
Returns:
Markdown-formatted stats string.
"""
stats = self.get_overview()
tier_stats = self.get_stats_by_likelihood_tier()
if stats["resolved"] == 0:
return ""
lines = [
"## 信号历史战绩",
"",
f"| 指标 | 值 |",
f"|------|-----|",
f"| 总信号数 | {stats['total_signals']} |",
f"| 已验证 | {stats['resolved']} |",
f"| 正确 | {stats['correct']} |",
f"| 胜率 | **{stats['win_rate']:.1%}** |",
f"| 平均ROI | **{stats['avg_roi']:+.1%}** |",
f"| 理论总PnL | **{stats['total_theoretical_pnl']:+.2f}x** |",
"",
]
# Tier breakdown
has_resolved_tiers = any(t["resolved"] > 0 for t in tier_stats)
if has_resolved_tiers:
lines.extend([
"### 按信号可信度分层",
"",
"| 可信度区间 | 信号数 | 已验证 | 胜率 | 平均ROI |",
"|-----------|-------|-------|------|---------|",
])
for t in tier_stats:
if t["total"] > 0:
wr = f"{t['win_rate']:.0%}" if t["resolved"] > 0 else "N/A"
roi = f"{t['avg_roi']:+.1%}" if t["resolved"] > 0 else "N/A"
lines.append(
f"| {t['tier']} | {t['total']} | {t['resolved']} | {wr} | {roi} |"
)
lines.append("")
return "\n".join(lines)
+140
View File
@@ -0,0 +1,140 @@
"""Tavily web search service - replaces Google Search for whale trade verification."""
import logging
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
TAVILY_SEARCH_URL = "https://api.tavily.com/search"
def _format_search_results(query: str, results: list[dict]) -> str:
"""Format Tavily search results as a readable report."""
report = [f"--- Web Search Results for '{query}' ---"]
for idx, item in enumerate(results, 1):
title = item.get("title", "No title")
url = item.get("url", "")
content = item.get("content", "")[:300]
if len(item.get("content", "")) > 300:
content += "..."
report.append(f"{idx}. **{title}**")
report.append(f" Source: {url}")
report.append(f" {content}")
report.append("")
report.append("-------------------------------------------")
return "\n".join(report)
class TavilySearchService:
"""
Web search service using Tavily API for whale trade verification.
Replaces Google Search grounding with explicit Tavily web search,
passing results as context to the LLM.
"""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or ""
if not self.api_key:
logger.warning("TAVILY_API_KEY not set. Web search will be disabled.")
def is_available(self) -> bool:
"""Check if Tavily search is available (API key is set)."""
return bool(self.api_key and self.api_key.strip())
def search(
self,
query: str,
max_results: int = 5,
search_depth: str = "basic",
) -> str:
"""
Search the web using Tavily API.
Args:
query: Search query
max_results: Number of results to return (1-10)
search_depth: "basic" for fast search, "advanced" for deeper search
Returns:
Formatted search results report.
"""
if not self.is_available():
return "Web search unavailable: TAVILY_API_KEY not configured."
max_results = max(1, min(max_results, 10))
payload = {
"api_key": self.api_key,
"query": query,
"search_depth": search_depth,
"max_results": max_results,
"include_answer": True,
}
try:
with httpx.Client(timeout=20) as client:
response = client.post(TAVILY_SEARCH_URL, json=payload)
if response.status_code != 200:
logger.error(f"Tavily API error: {response.status_code} - {response.text[:200]}")
return f"Web search API Error: {response.status_code}"
data = response.json()
results = data.get("results", [])
if not results:
return f"No web search results found for '{query}'."
report_parts = []
# Include AI-generated answer summary if available
answer = data.get("answer")
if answer:
report_parts.append(f"**Summary**: {answer}\n")
report_parts.append(_format_search_results(query, results))
return "\n".join(report_parts)
except Exception as e:
logger.error(f"Tavily search failed: {e}")
return f"Web search failed: {str(e)}"
def search_for_market(
self,
market_question: str,
max_results: int = 5,
) -> str:
"""
Search the web for information relevant to a prediction market.
Performs a deeper search for market-relevant news.
Args:
market_question: The market question to search for
max_results: Number of results per query
Returns:
Combined search results.
"""
if not self.is_available():
return "Web search unavailable: TAVILY_API_KEY not configured."
results = []
# Search with the market question directly
query = market_question[:200]
main_result = self.search(query, max_results=max_results, search_depth="advanced")
# Propagate errors so the unified WebSearchService can fall back
if "API Error" in main_result or "search failed" in main_result:
return main_result
if "No web search results" not in main_result and "Error" not in main_result:
results.append("## 🔍 Web Search Results (News & Analysis)\n" + main_result)
if not results:
return f"No relevant web results found for: {market_question[:50]}..."
return "\n\n".join(results)
+182
View File
@@ -0,0 +1,182 @@
"""Telegram search service for crypto and geopolitical channel monitoring."""
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Optional, List
try:
import nest_asyncio
nest_asyncio.apply()
except ImportError:
pass
logger = logging.getLogger(__name__)
# Default public channels: crypto + geopolitics/politics
# Verified 2026-03-31: all channels return text messages with meaningful content
DEFAULT_CHANNELS = [
# Crypto / macro
"CryptoVIPSignalTA", # crypto signals + macro news
"whale_alert_io", # on-chain whale transfers
"WatcherGuru", # crypto/macro breaking news
# Geopolitics & politics
"DDGeopolitics", # geopolitical analysis (views ~20k)
"disclosetv", # US politics, breaking news (views ~50-70k)
"realDonaldTrump", # Trump's own posts
"intelslava", # Russia/Ukraine, geopolitics (views ~50k)
"TheScrollOfBenjamin", # Middle East geopolitics
"WarMonitors", # conflict breaking news (views ~14k, active)
]
class TelegramSearchService:
"""
Searches crypto-relevant public Telegram channels for messages.
Uses Telethon (MTProto API) to search public channels by keyword.
Requires a one-time auth to generate a session string.
"""
def __init__(
self,
api_id: str = "",
api_hash: str = "",
session_string: str = "",
channels: Optional[List[str]] = None,
):
self.api_id = api_id
self.api_hash = api_hash
self.session_string = session_string
self.channels = channels or DEFAULT_CHANNELS
self._client = None
def is_available(self) -> bool:
"""Check if Telegram search is available."""
if not (self.api_id and self.api_hash and self.session_string):
return False
try:
import telethon # noqa: F401
return True
except ImportError:
logger.warning("telethon not installed. Telegram search disabled.")
return False
def _get_client(self):
"""Get or create the Telethon client."""
if self._client is None:
from telethon import TelegramClient
from telethon.sessions import StringSession
self._client = TelegramClient(
StringSession(self.session_string),
int(self.api_id),
self.api_hash,
)
return self._client
async def _search_channel(
self,
channel: str,
query: str,
limit: int = 5,
) -> List[dict]:
"""Search a single channel for messages matching a query."""
from telethon.errors import (
FloodWaitError,
ChannelPrivateError,
UsernameNotOccupiedError,
UsernameInvalidError,
)
results = []
try:
client = self._get_client()
async for message in client.iter_messages(
channel,
search=query,
limit=limit,
):
if not message.text:
continue
results.append({
"channel": channel,
"text": message.text,
"date": message.date.strftime("%Y-%m-%d %H:%M UTC") if message.date else "",
"views": message.views or 0,
"forwards": message.forwards or 0,
})
except FloodWaitError as e:
logger.warning(f"Telegram flood wait: {e.seconds}s for channel {channel}")
except (ChannelPrivateError, UsernameNotOccupiedError, UsernameInvalidError):
logger.debug(f"Channel {channel} not accessible, skipping")
except Exception as e:
logger.error(f"Error searching Telegram channel {channel}: {e}")
return results
async def _search_all_channels(self, query: str, limit_per_channel: int = 5) -> List[dict]:
"""Search all configured channels."""
client = self._get_client()
async with client:
all_results = []
for channel in self.channels:
results = await self._search_channel(channel, query, limit_per_channel)
all_results.extend(results)
# Sort by views descending
all_results.sort(key=lambda x: x["views"], reverse=True)
return all_results
def _format_report(self, query: str, messages: List[dict]) -> str:
"""Format search results as a report string."""
if not messages:
return f"No Telegram messages found for '{query}'."
total_views = sum(m["views"] for m in messages)
lines = [
f"--- Telegram Search Results for '{query}' ---",
f"Total Views in Sample: {total_views:,}",
f"Channels Searched: {', '.join(self.channels)}",
"Messages:",
]
for idx, msg in enumerate(messages):
text_preview = msg["text"][:200]
if len(msg["text"]) > 200:
text_preview += "..."
lines.append(
f'{idx + 1}. [{msg["channel"]}] ({msg["date"]}, '
f'👁 {msg["views"]:,}): "{text_preview}"'
)
lines.append("-------------------------------------------")
return "\n".join(lines)
def search_for_market(self, query: str, limit: int = 10) -> str:
"""
Search Telegram channels for messages relevant to a market.
Args:
query: Search query
limit: Max total messages to return
Returns:
Formatted report string
"""
if not self.is_available():
return "Telegram search unavailable: credentials not configured or telethon not installed."
try:
# Calculate per-channel limit
limit_per_channel = max(3, limit // len(self.channels))
# Run async search (nest_asyncio allows nested run_until_complete)
loop = asyncio.get_event_loop()
messages = loop.run_until_complete(
self._search_all_channels(query, limit_per_channel)
)
# Trim to total limit
messages = messages[:limit]
return self._format_report(query, messages)
except Exception as e:
logger.error(f"Telegram search failed: {e}")
return f"Telegram search failed: {str(e)}"
+467
View File
@@ -0,0 +1,467 @@
"""
Tool registry for LLM function calling.
Each tool is a callable that the LLM can invoke on demand.
Tools are registered with their OpenAI-compatible function schema
and an executor function that performs the actual work.
"""
import json
import logging
from dataclasses import dataclass
from typing import Callable, Dict, List
from src.services.twitter_search import TwitterSearchService
from src.services.web_search import WebSearchService
from src.services.coingecko import CoinGeckoService
from src.services.fred import FREDService
from src.services.polygon import PolygonService
from src.services.congress import CongressService
from src.services.defillama import DefiLlamaService
from src.services.etherscan import EtherscanService
from src.services.telegram_search import TelegramSearchService
logger = logging.getLogger(__name__)
@dataclass
class Tool:
"""A tool available to the LLM."""
name: str
description: str
parameters: dict # JSON Schema for parameters
execute: Callable[..., str] # (kwargs) -> result string
class ToolRegistry:
"""
Registry of tools available for LLM function calling.
Usage:
registry = ToolRegistry(twitter_api_key="...", tavily_api_key="...")
schemas = registry.openai_tool_schemas() # pass to LLM
result = registry.call("search_twitter", query="Bitcoin") # execute
"""
def __init__(
self,
twitter_api_key: str,
tavily_api_key: str,
fred_api_key: str = "",
polygon_api_key: str = "",
congress_api_key: str = "",
etherscan_api_key: str = "",
serper_api_key: str = "",
telegram_api_id: str = "",
telegram_api_hash: str = "",
telegram_session_string: str = "",
telegram_channels: str = "",
):
self._tools: Dict[str, Tool] = {}
# -- Twitter search --
twitter = TwitterSearchService(api_key=twitter_api_key)
if twitter.is_available():
self._register(Tool(
name="search_twitter",
description=(
"Search Twitter/X for real-time social sentiment, KOL opinions, "
"and breaking news about a topic. Returns top and latest tweets "
"with engagement metrics. Best for: real-time sentiment, crypto "
"community reactions, political commentary, breaking news that "
"hasn't hit mainstream media yet."
),
parameters={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (e.g. 'Bitcoin ETF', 'Trump indictment')",
},
},
"required": ["query"],
},
execute=lambda query: twitter.search_for_market(query, limit=10),
))
# -- Telegram search (crypto channels) --
telegram = TelegramSearchService(
api_id=telegram_api_id,
api_hash=telegram_api_hash,
session_string=telegram_session_string,
channels=telegram_channels.split(",") if telegram_channels.strip() else None,
)
if telegram.is_available():
self._register(Tool(
name="search_telegram",
description=(
"Search Telegram channels for recent messages about a topic. "
"Covers crypto (Whale Alert, WatcherGuru, CryptoVIPSignalTA) and "
"politics/geopolitics (Disclose.tv, DDGeopolitics, Intel Slava, "
"PoliticsForAll, Trump, TheScrollOfBenjamin). "
"Returns messages with view counts. "
"Best for: US politics, Trump news, approval ratings, elections, "
"geopolitics, military conflicts, Russia/Ukraine, Middle East, "
"macro economics, crypto news, whale transfers, and breaking news."
),
parameters={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (e.g. 'MegaETH launch', 'Solana outage')",
},
},
"required": ["query"],
},
execute=lambda query: telegram.search_for_market(query, limit=10),
))
# -- Web search (Tavily -> Serper -> DuckDuckGo fallback) --
web_search = WebSearchService(
tavily_api_key=tavily_api_key,
serper_api_key=serper_api_key,
)
if web_search.is_available():
self._register(Tool(
name="search_web",
description=(
"Search the web for recent news articles, analysis, and factual "
"information about a topic. Returns article summaries with sources. "
"Best for: verifying events, finding official announcements, "
"regulatory news, earnings reports, court rulings, legislation status."
),
parameters={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query for news and analysis",
},
},
"required": ["query"],
},
execute=lambda query: web_search.search_for_market(query, max_results=5),
))
# -- CoinGecko crypto data --
coingecko = CoinGeckoService()
self._register(Tool(
name="get_crypto_price",
description=(
"Get real-time cryptocurrency price, 24h/7d/30d change, market cap, "
"volume, and ATH data. Accepts ticker symbols (BTC, ETH, SOL) or "
"CoinGecko IDs. Best for: any market involving crypto price targets "
"(e.g. 'Will BTC hit $100k'), crypto market conditions."
),
parameters={
"type": "object",
"properties": {
"coin": {
"type": "string",
"description": "Ticker symbol (BTC, ETH, SOL) or CoinGecko coin ID",
},
},
"required": ["coin"],
},
execute=lambda coin: coingecko.get_price(coin),
))
self._register(Tool(
name="get_crypto_market_overview",
description=(
"Get global crypto market overview: total market cap, 24h change, "
"BTC/ETH dominance, trading volume. Best for: understanding overall "
"crypto market sentiment and conditions."
),
parameters={
"type": "object",
"properties": {},
},
execute=lambda: coingecko.get_market_overview(),
))
# -- FRED macroeconomic data --
fred = FREDService(api_key=fred_api_key)
if fred.is_available():
self._register(Tool(
name="get_economic_data",
description=(
"Get macroeconomic data from FRED (Federal Reserve). Supports "
"common names: fed_rate, cpi, inflation, unemployment, gdp, "
"oil_price, wti, brent, gold, vix, sp500, yield_curve, "
"jobless_claims, 10y_treasury, 2y_treasury, dollar_index. "
"Also accepts any FRED series ID (e.g. FEDFUNDS, UNRATE). "
"Returns recent data points with trend. Best for: Fed policy "
"markets, inflation bets, employment data, oil/commodity prices, "
"recession indicators."
),
parameters={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": (
"Common name (fed_rate, cpi, unemployment, oil_price, "
"vix, gold, sp500) or FRED series ID"
),
},
},
"required": ["query"],
},
execute=lambda query: fred.get_series(query),
))
# -- Polygon.io financial data --
polygon = PolygonService(api_key=polygon_api_key)
if polygon.is_available():
self._register(Tool(
name="get_stock_price",
description=(
"Get real-time stock/ETF price snapshot from Polygon.io. "
"Includes price, daily change, volume, day range. "
"Examples: AAPL, TSLA, GS, META, SPY, QQQ, GLD, USO. "
"Best for: markets involving specific company events "
"(IPOs, earnings, lawsuits), sector ETFs, gold/oil ETFs."
),
parameters={
"type": "object",
"properties": {
"ticker": {
"type": "string",
"description": "Ticker symbol (e.g. AAPL, TSLA, GS, SPY, GLD)",
},
},
"required": ["ticker"],
},
execute=lambda ticker: polygon.get_ticker_snapshot(ticker),
))
self._register(Tool(
name="get_stock_news",
description=(
"Get recent news articles for a stock/company from Polygon.io. "
"Returns headlines, sources, and summaries. "
"Best for: company-specific events, earnings surprises, "
"M&A rumors, regulatory actions, CEO statements."
),
parameters={
"type": "object",
"properties": {
"ticker": {
"type": "string",
"description": "Stock ticker (e.g. AAPL, TSLA, GS)",
},
},
"required": ["ticker"],
},
execute=lambda ticker: polygon.get_market_news(ticker),
))
# -- Congress.gov legislative data --
congress_svc = CongressService(api_key=congress_api_key)
if congress_svc.is_available():
self._register(Tool(
name="get_bill_status",
description=(
"Get status of a specific U.S. Congressional bill. "
"Requires congress number (e.g. 119), bill type (hr, s, hjres, sjres), "
"and bill number. Returns sponsor, cosponsors, latest action, "
"committee referrals. Best for: markets about specific legislation "
"(TikTok ban, crypto regulation, immigration reform, tax bills)."
),
parameters={
"type": "object",
"properties": {
"congress": {
"type": "integer",
"description": "Congress number (119 for 2025-2026)",
},
"bill_type": {
"type": "string",
"description": "Bill type: hr (House), s (Senate), hjres, sjres",
},
"bill_number": {
"type": "integer",
"description": "Bill number",
},
},
"required": ["congress", "bill_type", "bill_number"],
},
execute=lambda congress, bill_type, bill_number: congress_svc.get_bill_status(
congress, bill_type, bill_number
),
))
self._register(Tool(
name="get_recent_legislation",
description=(
"Get recently updated U.S. Congressional bills. "
"Returns latest bills with their current status and actions. "
"Best for: understanding current legislative activity, "
"political markets about government actions, policy changes."
),
parameters={
"type": "object",
"properties": {},
},
execute=lambda: congress_svc.search_bills("", limit=5),
))
# -- DeFiLlama (DeFi protocol data, no API key needed) --
defillama = DefiLlamaService()
self._register(Tool(
name="get_protocol_tvl",
description=(
"Get DeFi protocol TVL (Total Value Locked), TVL changes (1h/24h/7d), "
"and chain breakdown from DeFiLlama. Accepts protocol name or slug "
"(e.g. 'aave', 'uniswap', 'lido', 'eigenlayer'). "
"Best for: token launch FDV markets, DeFi protocol health, "
"evaluating project fundamentals before/after token launch."
),
parameters={
"type": "object",
"properties": {
"protocol": {
"type": "string",
"description": "Protocol name or slug (e.g. 'aave', 'uniswap', 'megaeth')",
},
},
"required": ["protocol"],
},
execute=lambda protocol: defillama.get_protocol_tvl(protocol),
))
self._register(Tool(
name="get_token_unlocks",
description=(
"Get token unlock/vesting schedule for a DeFi protocol from DeFiLlama. "
"Shows allocation categories and upcoming unlock events. "
"Best for: understanding token supply dynamics, evaluating FDV markets, "
"predicting sell pressure from upcoming unlocks."
),
parameters={
"type": "object",
"properties": {
"protocol": {
"type": "string",
"description": "Protocol name or slug (e.g. 'arbitrum', 'optimism', 'eigenlayer')",
},
},
"required": ["protocol"],
},
execute=lambda protocol: defillama.get_token_unlocks(protocol),
))
self._register(Tool(
name="get_protocol_revenue",
description=(
"Get DeFi protocol fees and revenue (24h/7d/30d/all-time) from DeFiLlama. "
"Best for: evaluating protocol fundamentals, comparing revenue vs FDV, "
"assessing if a token launch valuation is justified."
),
parameters={
"type": "object",
"properties": {
"protocol": {
"type": "string",
"description": "Protocol name or slug (e.g. 'aave', 'uniswap', 'gmx')",
},
},
"required": ["protocol"],
},
execute=lambda protocol: defillama.get_protocol_revenue(protocol),
))
# -- Etherscan (on-chain data) --
etherscan = EtherscanService(api_key=etherscan_api_key)
if etherscan.is_available():
self._register(Tool(
name="get_wallet_transfers",
description=(
"Get recent ERC-20 token transfers (USDC, USDT, WETH, DAI) for an "
"Ethereum wallet address from Etherscan. Shows direction (IN/OUT), "
"amount, counterparty, and flags large transfers (>$10k). "
"Best for: checking if a Polymarket whale recently received large "
"USDC deposits (funding for trades), tracking wallet activity."
),
parameters={
"type": "object",
"properties": {
"address": {
"type": "string",
"description": "Ethereum wallet address (0x...)",
},
"token": {
"type": "string",
"description": "Token to track: USDC, USDT, WETH, or DAI (default: USDC)",
},
},
"required": ["address"],
},
execute=lambda address, token="USDC": etherscan.get_wallet_token_transfers(address, token),
))
self._register(Tool(
name="get_contract_info",
description=(
"Check if an Ethereum address is a smart contract, when it was created, "
"its name and verification status from Etherscan. "
"Best for: verifying if a crypto project has deployed contracts, "
"checking contract activity for token launch markets."
),
parameters={
"type": "object",
"properties": {
"address": {
"type": "string",
"description": "Ethereum contract address (0x...)",
},
},
"required": ["address"],
},
execute=lambda address: etherscan.get_contract_info(address),
))
def _register(self, tool: Tool):
self._tools[tool.name] = tool
logger.info(f"Registered tool: {tool.name}")
@property
def available_tools(self) -> List[str]:
return list(self._tools.keys())
def openai_tool_schemas(self) -> List[dict]:
"""Return tool schemas in OpenAI function-calling format."""
return [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
},
}
for tool in self._tools.values()
]
def call(self, name: str, **kwargs) -> str:
"""
Execute a tool by name.
Returns the tool's string result, or an error message if the tool
is not found or execution fails.
"""
tool = self._tools.get(name)
if not tool:
msg = f"Tool '{name}' not found. Available: {self.available_tools}"
logger.error(msg)
return msg
try:
result = tool.execute(**kwargs)
logger.info(f"Tool {name} executed successfully ({len(result)} chars)")
return result
except Exception as e:
msg = f"Tool '{name}' failed: {e}"
logger.error(msg)
return msg
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
"""Trader profiler service - generates structured trader profiles for LLM consumption."""
import json
import logging
from typing import Optional
from src.models.trade import TraderRanking, TraderHistory
logger = logging.getLogger(__name__)
class TraderProfiler:
"""
Generates structured trader profiles for LLM consumption.
Only organizes raw data into a clean JSON structure.
All interpretation and judgment is left to the LLM.
"""
def generate_profile(
self,
wallet_address: str,
ranking: Optional[TraderRanking],
history: Optional[TraderHistory],
) -> dict:
"""
Generate a structured trader profile from raw data.
Returns:
dict with raw trader data for LLM consumption
"""
# Ranking - raw numbers only
ranking_data = {
"rank": ranking.rank if ranking else None,
"pnl": ranking.pnl if ranking else None,
"total_volume": ranking.volume if ranking else None,
"verified": ranking.verified if ranking else False,
"username": ranking.user_name if ranking else None,
}
# Trading behavior - raw numbers only
large_trade_ratio = 0.0
if history and history.total_trades > 0:
large_trade_ratio = history.large_trades_count / history.total_trades
behavior_data = {
"total_trades": history.total_trades if history else 0,
"total_volume": history.total_volume if history else 0.0,
"avg_trade_size": history.avg_trade_size if history else 0.0,
"large_trades_count": history.large_trades_count if history else 0,
"large_trade_ratio": round(large_trade_ratio, 3),
"active_markets": history.recent_markets[:5] if history and history.recent_markets else [],
}
# Recent trades - raw data
recent_trades = []
if history and history.recent_trades:
for t in history.recent_trades[:10]:
recent_trades.append({
"side": t.get("side", ""),
"size_usd": t.get("usdc_size", 0),
"price": t.get("price", 0),
"market": t.get("title", "")[:50],
})
return {
"ranking": ranking_data,
"behavior": behavior_data,
"recent_trades": recent_trades,
}
def format_profile_for_llm(self, profile: dict) -> str:
"""Format the profile dict as JSON for LLM input."""
profile_json = json.dumps(profile, ensure_ascii=False, indent=2)
return f"""
### Trader Profile
```json
{profile_json}
```
"""
+220
View File
@@ -0,0 +1,220 @@
"""Twitter search service for whale trade verification."""
import os
import logging
from typing import Optional, Literal
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
logger = logging.getLogger(__name__)
# ---------- Retry-enabled HTTP GET ----------
_shared_session: Optional[requests.Session] = None
def robust_get(url: str, **kwargs) -> requests.Response:
"""GET request with automatic retry (3 retries, exponential backoff)."""
global _shared_session
if _shared_session is None:
_shared_session = requests.Session()
retry = Retry(
total=3,
backoff_factor=0.5,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=["GET"],
raise_on_status=False,
)
adapter = HTTPAdapter(max_retries=retry)
_shared_session.mount("http://", adapter)
_shared_session.mount("https://", adapter)
kwargs.setdefault("timeout", 15)
return _shared_session.get(url, **kwargs)
# Twitter module constants and helpers (extracted to avoid langchain @tool decorator issues)
TWITTER_API_KEY = os.getenv("TWITTER_API_KEY", "NONE")
BASE_URL = "https://api.twitterapi.io/twitter"
SEARCH_ENDPOINT = f"{BASE_URL}/tweet/advanced_search"
USER_TWEETS_ENDPOINT = f"{BASE_URL}/user/last_tweets"
def _parse_tweet_text(tweet_data: dict) -> Optional[dict]:
"""Parse and format a single tweet."""
try:
# API returns author (not user), with userName (not username)
author = tweet_data.get("author") or tweet_data.get("user") or {}
user = author.get("userName") or author.get("username") or "unknown"
text = tweet_data.get("text", "")
likes = tweet_data.get("likeCount") or tweet_data.get("favorite_count") or 0
retweets = tweet_data.get("retweetCount") or tweet_data.get("retweet_count") or 0
created_at = tweet_data.get("createdAt") or tweet_data.get("created_at") or ""
engagement = int(likes) + int(retweets)
return {
"user": user,
"text": text,
"engagement": engagement,
"time": created_at,
}
except Exception:
return None
def _format_tweets_report(title: str, parsed_tweets: list[dict], total_engagement: int) -> str:
"""Format tweets list as a report."""
report = [f"--- {title} ---"]
report.append(f"Total Engagement in Sample: {total_engagement} (Likes+RTs)")
report.append("Top Discussions:")
for idx, item in enumerate(parsed_tweets):
text_preview = item["text"][:200]
if len(item["text"]) > 200:
text_preview += "..."
report.append(f'{idx + 1}. @{item["user"]} (🔥{item["engagement"]}): "{text_preview}"')
report.append("-------------------------------------------")
return "\n".join(report)
class TwitterSearchService:
"""
Twitter search service for whale trade verification.
Uses Twitter API for social sentiment search.
"""
def __init__(self, api_key: Optional[str] = None):
"""
Initialize Twitter search service.
Args:
api_key: Twitter API key. If not provided, reads from TWITTER_API_KEY env var.
"""
self.api_key = api_key or os.getenv("TWITTER_API_KEY", "")
if not self.api_key:
logger.warning("TWITTER_API_KEY not set. Twitter search will be disabled.")
def _get_headers(self) -> dict[str, str]:
"""Get request headers with API key."""
return {"X-API-Key": self.api_key}
def is_available(self) -> bool:
"""Check if Twitter search is available (API key is set)."""
return bool(self.api_key and self.api_key.strip().upper() != "NONE")
def search_tweets(
self,
query: str,
search_mode: Literal["top", "latest"] = "top",
limit: int = 10,
) -> str:
"""
Search Twitter for tweets matching a query.
Args:
query: Search query (e.g., "Trump", "Bitcoin", "Fed rate")
search_mode: "top" for most relevant, "latest" for most recent
limit: Number of tweets to return (1-20)
Returns:
Formatted report of tweets with engagement metrics.
"""
if not self.is_available():
return "Twitter search unavailable: TWITTER_API_KEY not configured."
limit = max(1, min(limit, 20))
query_type = "Top" if search_mode == "top" else "Latest"
params = {
"query": query,
"queryType": query_type,
"limit": limit,
}
try:
response = robust_get(
SEARCH_ENDPOINT,
params=params,
headers=self._get_headers(),
timeout=15,
)
if response.status_code != 200:
logger.error(f"Twitter API error: {response.status_code} - {response.text[:200]}")
return f"Twitter API Error: {response.status_code}"
data = response.json()
tweets_raw = data.get("tweets", [])
if not tweets_raw:
return f"No recent tweets found for '{query}'."
# Parse tweets
parsed_tweets = []
total_engagement = 0
for t in tweets_raw:
p = _parse_tweet_text(t)
if p:
parsed_tweets.append(p)
total_engagement += p["engagement"]
mode_label = "Hot" if search_mode == "top" else "Latest"
return _format_tweets_report(
f"Twitter Search Results for '{query}' [{mode_label}]",
parsed_tweets,
total_engagement,
)
except Exception as e:
logger.error(f"Twitter search failed: {e}")
return f"Twitter search failed: {str(e)}"
def search_for_market(
self,
market_question: str,
limit: int = 10,
) -> str:
"""
Search Twitter for information relevant to a prediction market.
Combines both TOP (importance/engagement) and LATEST (timeliness) results
to balance relevance and recency.
Args:
market_question: The market question to search for
limit: Number of tweets per search mode (will search both top and latest)
Returns:
Combined search results from both search modes.
"""
if not self.is_available():
return "Twitter search unavailable: TWITTER_API_KEY not configured."
results = []
query = market_question[:100] # Limit query length for API
# 1. Search TOP tweets - high engagement, represents importance
top_result = self.search_tweets(query, search_mode="top", limit=limit)
if "No recent tweets" not in top_result and "Error" not in top_result:
results.append("## 🔥 热门推文(高互动/重要性)\n" + top_result)
# 2. Search LATEST tweets - real-time info, represents timeliness
latest_result = self.search_tweets(query, search_mode="latest", limit=limit)
if "No recent tweets" not in latest_result and "Error" not in latest_result:
results.append("## ⚡ 最新推文(实时/时效性)\n" + latest_result)
if not results:
return f"No relevant tweets found for: {market_question[:50]}..."
return "\n\n".join(results)
# Singleton instance
_twitter_service: Optional[TwitterSearchService] = None
def get_twitter_service() -> TwitterSearchService:
"""Get the singleton Twitter search service instance."""
global _twitter_service
if _twitter_service is None:
_twitter_service = TwitterSearchService()
return _twitter_service
+372
View File
@@ -0,0 +1,372 @@
"""Volatility analyzer service - analyzes price volatility using AI to detect leading signals."""
import json
import logging
import re
from datetime import datetime
from pathlib import Path
from typing import Optional
from openai import OpenAI
from src.config import get_settings
from src.models.leading_signal import LeadingSignal, SignalType
from src.services.price_monitor import VolatilityAlert
from src.services.twitter_search import TwitterSearchService
from src.prompts.volatility_analyzer import VolatilityAnalyzerPrompts
logger = logging.getLogger(__name__)
# Directory for storing leading signals dataset
LEADING_SIGNALS_DIR = Path(__file__).parent.parent.parent / "leading_signals"
class VolatilityAnalyzer:
"""
Analyzes price volatility events using LLM to detect "price leads news" signals.
Uses Tavily web search and Twitter to verify whether a price movement
preceded public news, building a dataset of leading signals.
"""
def __init__(self):
self.settings = get_settings()
# Configure OpenAI-compatible API client
self.client = OpenAI(
base_url=self.settings.llm_base_url,
api_key=self.settings.gemini_api_key,
)
self.prompts = VolatilityAnalyzerPrompts()
self.twitter_search = TwitterSearchService(api_key=self.settings.twitter_api_key)
from src.services.web_search import WebSearchService
self.web_search = WebSearchService(
tavily_api_key=self.settings.tavily_api_key,
serper_api_key=self.settings.serper_api_key,
)
# Ensure storage directory exists
LEADING_SIGNALS_DIR.mkdir(parents=True, exist_ok=True)
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:
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_signal_type(self, type_str: str) -> SignalType:
"""Parse signal type string to enum."""
try:
return SignalType(type_str.upper())
except ValueError:
return SignalType.SPECULATION
def _store_leading_signal(self, signal: LeadingSignal) -> str:
"""
Store a leading signal to the dataset.
Args:
signal: The leading signal to store
Returns:
Path to the stored file
"""
# Create filename with timestamp and market info
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
market_slug = re.sub(r'[^\w\s-]', '', signal.market_question)[:40]
market_slug = re.sub(r'\s+', '_', market_slug)
filename = f"{timestamp}_{signal.signal_type.value}_{market_slug}.json"
filepath = LEADING_SIGNALS_DIR / filename
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(signal.to_dict(), f, ensure_ascii=False, indent=2)
return str(filepath)
def _store_all_signals_index(self, signal: LeadingSignal) -> None:
"""
Append signal to the master index file for easy querying.
Args:
signal: The signal to append
"""
index_file = LEADING_SIGNALS_DIR / "signals_index.jsonl"
with open(index_file, 'a', encoding='utf-8') as f:
f.write(json.dumps(signal.to_dict(), ensure_ascii=False) + "\n")
async def analyze_volatility(self, alert: VolatilityAlert) -> Optional[LeadingSignal]:
"""
Analyze a price volatility event to determine if it's a leading signal.
Args:
alert: The volatility alert to analyze
Returns:
LeadingSignal if analysis successful, None otherwise
"""
logger.info(
f"Analyzing volatility: {alert.market_question[:50]}... "
f"{alert.direction} {abs(alert.price_change_percent):.1%}"
)
# Search web (Tavily) for news verification
web_search_context = ""
if self.web_search.is_available():
logger.info(f"Searching web for: {alert.market_question[:50]}...")
web_result = self.web_search.search_for_market(
market_question=alert.market_question,
max_results=5,
)
if web_result and "unavailable" not in web_result.lower():
web_search_context = web_result
logger.info("Web search (Tavily) completed")
# Search Twitter for social sentiment
twitter_context = ""
if self.twitter_search.is_available():
logger.info(f"Searching Twitter for: {alert.market_question[:50]}...")
twitter_result = self.twitter_search.search_for_market(
market_question=alert.market_question,
limit=10,
)
if twitter_result and "unavailable" not in twitter_result.lower():
twitter_context = twitter_result
logger.info("Twitter search completed")
# Build prompts
system_prompt = self.prompts.system_prompt()
user_prompt = self.prompts.analyze_volatility(
market_question=alert.market_question,
price_change_percent=alert.price_change_percent,
direction=alert.direction,
start_price=alert.start_price,
end_price=alert.end_price,
window_seconds=alert.window_seconds,
detected_at=alert.detected_at,
twitter_context=twitter_context,
web_search_context=web_search_context,
)
try:
# Call LLM API
response = self.client.chat.completions.create(
model=self.settings.llm_model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
)
analysis_text = response.choices[0].message.content
logger.debug(f"LLM response: {analysis_text[:500]}...")
# Extract JSON from response
json_data = self._extract_json_from_response(analysis_text)
if not json_data:
logger.warning("Could not parse LLM response as JSON")
return None
# Create LeadingSignal from response
signal_id = f"vol_{alert.market_id}_{int(datetime.now().timestamp())}"
signal = LeadingSignal(
id=signal_id,
market_id=alert.market_id,
market_question=alert.market_question,
price_change_percent=alert.price_change_percent,
direction=alert.direction,
start_price=alert.start_price,
end_price=alert.end_price,
window_seconds=alert.window_seconds,
detected_at=datetime.utcnow().isoformat(),
volatility_detected_at=alert.detected_at,
signal_type=self._parse_signal_type(json_data.get("signal_type", "SPECULATION")),
confidence=float(json_data.get("confidence", 0.0)),
is_leading_signal=bool(json_data.get("is_leading_signal", False)),
news_found=bool(json_data.get("news_found", False)),
earliest_news_time=json_data.get("earliest_news_time"),
key_news_headlines=json_data.get("key_news_headlines", []),
earliest_social_time=json_data.get("earliest_social_time"),
key_social_posts=json_data.get("key_social_posts", []),
time_advantage_minutes=int(json_data.get("time_advantage_minutes", 0)),
reasoning=str(json_data.get("reasoning", "")),
potential_information_source=str(json_data.get("potential_information_source", "")),
full_analysis=analysis_text,
)
# Store the signal
filepath = self._store_leading_signal(signal)
self._store_all_signals_index(signal)
# Log result
if signal.is_leading_signal:
logger.warning(
f"🚨 LEADING SIGNAL DETECTED: {alert.market_question[:50]}... "
f"Time advantage: {signal.time_advantage_minutes} minutes"
)
else:
logger.info(
f"Volatility analyzed: {signal.signal_type.value} "
f"(confidence: {signal.confidence:.1%})"
)
logger.info(f"Signal stored: {filepath}")
return signal
except Exception as e:
logger.error(f"Error analyzing volatility: {e}")
return None
def format_signal_report(self, signal: LeadingSignal) -> str:
"""
Format a leading signal as a readable report.
Args:
signal: The signal to format
Returns:
Formatted report string
"""
direction_cn = "上涨" if signal.direction == "UP" else "下跌"
signal_type_cn = {
SignalType.LEADING_SIGNAL: "🚨 领先信号(价格早于新闻)",
SignalType.NEWS_DRIVEN: "📰 新闻驱动",
SignalType.SOCIAL_DRIVEN: "🐦 社交驱动",
SignalType.SPECULATION: "💭 投机波动",
}
news_headlines = "\n".join([f" - {h}" for h in signal.key_news_headlines]) or ""
social_posts = "\n".join([f" - {p}" for p in signal.key_social_posts]) or ""
report = f"""
{'='*70}
# 📊 价格波动分析报告
{'='*70}
**分析时间**: {signal.detected_at}
## 波动详情
| 项目 | 详情 |
|------|------|
| **市场** | {signal.market_question} |
| **价格变动** | {direction_cn} {abs(signal.price_change_percent):.1%} |
| **起始价格** | {signal.start_price:.2%} |
| **结束价格** | {signal.end_price:.2%} |
| **时间窗口** | {signal.window_seconds // 60} 分钟 |
{'='*70}
## 🔍 分析结果
{'='*70}
| 项目 | 结果 |
|------|------|
| **信号类型** | {signal_type_cn.get(signal.signal_type, '未知')} |
| **置信度** | {signal.confidence:.1%} |
| **是否领先信号** | {'✅ 是' if signal.is_leading_signal else '❌ 否'} |
| **时间优势** | {signal.time_advantage_minutes} 分钟 |
**最早新闻时间**: {signal.earliest_news_time or 'N/A'}
**最早社交时间**: {signal.earliest_social_time or 'N/A'}
## 关键新闻
{news_headlines}
## 关键社交帖子
{social_posts}
## 分析理由
{signal.reasoning}
## 推测信息来源
{signal.potential_information_source or '未知'}
{'='*70}
{signal.full_analysis}
{'='*70}
"""
return report
def get_leading_signals_stats(self) -> dict:
"""
Get statistics about collected leading signals.
Returns:
Dictionary with stats
"""
index_file = LEADING_SIGNALS_DIR / "signals_index.jsonl"
if not index_file.exists():
return {
"total_signals": 0,
"leading_signals": 0,
"news_driven": 0,
"social_driven": 0,
"speculation": 0,
}
stats = {
"total_signals": 0,
"leading_signals": 0,
"news_driven": 0,
"social_driven": 0,
"speculation": 0,
"avg_time_advantage_minutes": 0,
}
time_advantages = []
try:
with open(index_file, 'r', encoding='utf-8') as f:
for line in f:
if line.strip():
data = json.loads(line)
stats["total_signals"] += 1
signal_type = data.get("signal_type", "SPECULATION")
if signal_type == "LEADING_SIGNAL":
stats["leading_signals"] += 1
time_advantages.append(data.get("time_advantage_minutes", 0))
elif signal_type == "NEWS_DRIVEN":
stats["news_driven"] += 1
elif signal_type == "SOCIAL_DRIVEN":
stats["social_driven"] += 1
else:
stats["speculation"] += 1
if time_advantages:
stats["avg_time_advantage_minutes"] = sum(time_advantages) / len(time_advantages)
except Exception as e:
logger.error(f"Error reading signals index: {e}")
return stats
+79
View File
@@ -0,0 +1,79 @@
"""Unified web search with fallback: Tavily -> Serper -> DuckDuckGo."""
import logging
from typing import Optional
from src.services.tavily_search import TavilySearchService
from src.services.serper_search import SerperSearchService
from src.services.ddg_search import DDGSearchService
logger = logging.getLogger(__name__)
# Responses that indicate a search engine failed (not just "no results")
_FAILURE_KEYWORDS = ("API Error", "search failed", "unavailable", "exceeds your plan")
def _is_failure(result: str) -> bool:
return any(kw in result for kw in _FAILURE_KEYWORDS)
class WebSearchService:
"""
Unified web search with automatic fallback.
Priority: Tavily (best quality) -> Serper -> DuckDuckGo (free).
Falls back to the next engine when the current one errors.
"""
def __init__(
self,
tavily_api_key: str = "",
serper_api_key: str = "",
):
self._engines = []
tavily = TavilySearchService(api_key=tavily_api_key)
if tavily.is_available():
self._engines.append(("Tavily", tavily))
serper = SerperSearchService(api_key=serper_api_key)
if serper.is_available():
self._engines.append(("Serper", serper))
ddg = DDGSearchService()
if ddg.is_available():
self._engines.append(("DuckDuckGo", ddg))
if self._engines:
names = [name for name, _ in self._engines]
logger.info(f"Web search engines: {' -> '.join(names)}")
else:
logger.warning("No web search engine available.")
def is_available(self) -> bool:
return len(self._engines) > 0
def search(self, query: str, max_results: int = 5) -> str:
if not self._engines:
return "Web search unavailable: no search engine configured."
for name, engine in self._engines:
result = engine.search(query, max_results=max_results)
if _is_failure(result):
logger.warning(f"{name} search failed, trying next engine...")
continue
return result
return f"All web search engines failed for: '{query}'"
def search_for_market(self, market_question: str, max_results: int = 5) -> str:
if not self._engines:
return "Web search unavailable: no search engine configured."
for name, engine in self._engines:
result = engine.search_for_market(market_question, max_results=max_results)
if _is_failure(result):
logger.warning(f"{name} market search failed, trying next engine...")
continue
return result
return f"No relevant web results found for: {market_question[:50]}..."