Update: sync local state to remote
This commit is contained in:
+47
-4
@@ -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")
|
||||
|
||||
|
||||
@@ -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>"""
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Database module for signal storage and tracking."""
|
||||
from src.db.database import SignalDatabase
|
||||
|
||||
__all__ = ["SignalDatabase"]
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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 多空持仓者的阵营** — 精英交易者集中在哪一方
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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.2(20%的资金)
|
||||
- insider_trading_likelihood: 0.7+表示高度可疑内幕交易,0.4-0.7为中等可能,<0.4为普通大额交易
|
||||
- trader_credibility基于排行榜排名:前100=HIGH,100-500=MEDIUM,500+=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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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]}..."
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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]}..."
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)}"
|
||||
@@ -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
|
||||
+596
-211
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
```
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]}..."
|
||||
Reference in New Issue
Block a user