初步完成项目,可以监控给出入场信号
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from src.config.settings import Settings, get_settings
|
||||
|
||||
__all__ = ["Settings", "get_settings"]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Application settings for project B (copy-trader)."""
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Settings loaded from .env."""
|
||||
|
||||
# Capital & execution
|
||||
initial_capital_usd: float = Field(default=10000.0, alias="INITIAL_CAPITAL_USD")
|
||||
max_position_pct: float = Field(default=0.05, alias="MAX_POSITION_PCT")
|
||||
kelly_fraction: float = Field(default=0.5, alias="KELLY_FRACTION")
|
||||
|
||||
# Price guardrails
|
||||
min_price: float = Field(default=0.10, alias="MIN_PRICE")
|
||||
max_price: float = Field(default=0.90, alias="MAX_PRICE")
|
||||
|
||||
# Trading rules
|
||||
min_trade_size_usd: float = Field(default=500.0, alias="MIN_TRADE_SIZE_USD")
|
||||
execution_delay_seconds: float = Field(default=5.0, alias="EXECUTION_DELAY_SECONDS")
|
||||
enable_execution: bool = Field(default=False, alias="ENABLE_EXECUTION")
|
||||
|
||||
# Wallet pool
|
||||
wallet_pool_size: int = Field(default=100, alias="WALLET_POOL_SIZE")
|
||||
wallet_pnl_min_usd: float = Field(default=5000.0, alias="WALLET_PNL_MIN_USD")
|
||||
wallet_min_trades: int = Field(default=30, alias="WALLET_MIN_TRADES")
|
||||
wallet_min_categories: int = Field(default=3, alias="WALLET_MIN_CATEGORIES")
|
||||
wallet_refresh_hours: int = Field(default=24, alias="WALLET_REFRESH_HOURS")
|
||||
|
||||
# Bayesian credibility priors
|
||||
bayesian_prior_skill: float = Field(default=0.5, alias="BAYESIAN_PRIOR_SKILL")
|
||||
bayesian_decay_days: int = Field(default=14, alias="BAYESIAN_DECAY_DAYS")
|
||||
|
||||
# User stream polling (Phase 1)
|
||||
user_poll_interval_seconds: int = Field(default=30, alias="USER_POLL_INTERVAL_SECONDS")
|
||||
stream_warmup_seconds: int = Field(default=30, alias="STREAM_WARMUP_SECONDS")
|
||||
stream_max_trades_per_wallet: int = Field(default=20, alias="STREAM_MAX_TRADES_PER_WALLET")
|
||||
|
||||
# Aggregator (Phase 2)
|
||||
consensus_window_seconds: int = Field(default=600, alias="CONSENSUS_WINDOW_SECONDS")
|
||||
consensus_min_wallets: int = Field(default=2, alias="CONSENSUS_MIN_WALLETS")
|
||||
consensus_strength_threshold: float = Field(default=0.4, alias="CONSENSUS_STRENGTH_THRESHOLD")
|
||||
wallet_debounce_seconds: int = Field(default=600, alias="WALLET_DEBOUNCE_SECONDS")
|
||||
min_credibility: float = Field(default=0.3, alias="MIN_CREDIBILITY")
|
||||
|
||||
# Credibility update loop
|
||||
credibility_update_minutes: int = Field(default=60, alias="CREDIBILITY_UPDATE_MINUTES")
|
||||
|
||||
# Debug logging (extra verbose beyond LOG_LEVEL)
|
||||
debug_log_api_payloads: bool = Field(default=False, alias="DEBUG_LOG_API_PAYLOADS")
|
||||
|
||||
# Pool builder concurrency
|
||||
wallet_pool_concurrency: int = Field(default=15, alias="WALLET_POOL_CONCURRENCY")
|
||||
wallet_pool_request_timeout: float = Field(default=5.0, alias="WALLET_POOL_REQUEST_TIMEOUT")
|
||||
wallet_pool_progress_every: int = Field(default=50, alias="WALLET_POOL_PROGRESS_EVERY")
|
||||
wallet_pool_backoff_emails: int = Field(default=30, alias="WALLET_POOL_BACKOFF_EMA") # consecutive empty responses → sleep
|
||||
|
||||
# Telegram notifications
|
||||
telegram_enabled: bool = Field(default=False, alias="TELEGRAM_ENABLED")
|
||||
telegram_bot_token: str = Field(default="", alias="TELEGRAM_BOT_TOKEN")
|
||||
telegram_chat_id: str = Field(default="", alias="TELEGRAM_CHAT_ID")
|
||||
|
||||
# Polymarket CLOB credentials (for live execution)
|
||||
poly_api_key: str = Field(default="", alias="POLY_API_KEY")
|
||||
poly_api_secret: str = Field(default="", alias="POLY_API_SECRET")
|
||||
poly_api_passphrase: str = Field(default="", alias="POLY_API_PASSPHRASE")
|
||||
poly_wallet_private_key: str = Field(default="", alias="POLY_WALLET_PRIVATE_KEY")
|
||||
|
||||
# HTTP
|
||||
http_proxy: str = Field(default="", alias="HTTP_PROXY")
|
||||
db_path: str = Field(default="data/copytrader.db", alias="DB_PATH")
|
||||
|
||||
# Logging
|
||||
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
extra = "ignore"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
load_dotenv()
|
||||
return Settings()
|
||||
@@ -0,0 +1,161 @@
|
||||
"""FastAPI dashboard for the copy-trader.
|
||||
|
||||
Endpoints:
|
||||
/api/stats — overall counters
|
||||
/api/wallets — top-priority wallet pool
|
||||
/api/signals/recent — recent consensus signals
|
||||
/api/health — liveness check
|
||||
"""
|
||||
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 CopyTraderDatabase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
app = FastAPI(title="Polymarket Copy Trader Dashboard")
|
||||
|
||||
|
||||
def _get_db() -> CopyTraderDatabase:
|
||||
settings = get_settings()
|
||||
return CopyTraderDatabase(settings.db_path)
|
||||
|
||||
|
||||
@app.get("/api/stats")
|
||||
def api_stats():
|
||||
db = _get_db()
|
||||
return db.get_stats()
|
||||
|
||||
|
||||
@app.get("/api/wallets")
|
||||
def api_wallets(limit: int = Query(50, ge=1, le=200)):
|
||||
db = _get_db()
|
||||
wallets = db.get_all_wallet_targets()
|
||||
return wallets[:limit]
|
||||
|
||||
|
||||
@app.get("/api/signals/recent")
|
||||
def api_signals(limit: int = Query(20, ge=1, le=200)):
|
||||
db = _get_db()
|
||||
return db.get_recent_signals(limit=limit)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def api_health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
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 Copy Trader Dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, sans-serif; background: #0f1117; color: #e0e0e0; padding: 20px; }
|
||||
h1 { color: #fff; margin-bottom: 8px; }
|
||||
h2 { color: #a0a8c0; margin: 24px 0 12px; font-size: 1.2em; }
|
||||
.subtitle { color: #666; margin-bottom: 24px; }
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
||||
.card { background: #1a1d28; border-radius: 8px; padding: 16px; text-align: center; }
|
||||
.card .v { font-size: 1.8em; font-weight: bold; color: #4fc3f7; }
|
||||
.card .l { 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; }
|
||||
td { padding: 10px 12px; border-bottom: 1px solid #222; font-size: 0.9em; }
|
||||
tr:hover { background: #1a1d28; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8em; font-weight: bold; }
|
||||
.b-high { background: #66bb6a33; color: #66bb6a; }
|
||||
.b-med { background: #ffb74d33; color: #ffb74d; }
|
||||
.b-low { background: #88888833; color: #aaa; }
|
||||
.mono { font-family: monospace; font-size: 0.85em; color: #888; }
|
||||
#loading { color: #666; text-align: center; padding: 40px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Polymarket Copy Trader</h1>
|
||||
<p class="subtitle" id="update-info">Live signal & wallet dashboard · auto-refresh 30s</p>
|
||||
|
||||
<div id="loading">Loading...</div>
|
||||
<div id="content" style="display:none">
|
||||
|
||||
<div class="stats" id="stats"></div>
|
||||
|
||||
<h2>Top Wallets</h2>
|
||||
<table id="wallet-table">
|
||||
<thead><tr><th>Rank</th><th>Address</th><th>PnL Total</th><th>Trades</th><th>Categories</th><th>Health</th><th>Credibility</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<h2>Recent Signals</h2>
|
||||
<table id="signal-table">
|
||||
<thead><tr><th>Time</th><th>Market</th><th>Direction</th><th>Entry</th><th>Strength</th><th>Wallets</th><th>Kelly Size</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function load() {
|
||||
try {
|
||||
const [statsR, walletsR, signalsR] = await Promise.all([
|
||||
fetch('/api/stats').then(r => r.json()),
|
||||
fetch('/api/wallets').then(r => r.json()),
|
||||
fetch('/api/signals/recent?limit=20').then(r => r.json())
|
||||
]);
|
||||
|
||||
document.getElementById('stats').innerHTML = [
|
||||
['Wallets', statsR.wallet_count],
|
||||
['Total Signals', statsR.total_signals],
|
||||
['Executed', statsR.executed_signals]
|
||||
].map(([l,v]) => `<div class="card"><div class="v">${v}</div><div class="l">${l}</div></div>`).join('');
|
||||
|
||||
document.querySelector('#wallet-table tbody').innerHTML = walletsR.map((w,i) =>
|
||||
`<tr>
|
||||
<td>${i+1}</td>
|
||||
<td class="mono">${(w.address||'').slice(0,8)}...${(w.address||'').slice(-4)}</td>
|
||||
<td>$${Number(w.pnl_total_usd||0).toLocaleString('en-US',{maximumFractionDigits:0})}</td>
|
||||
<td>${w.trades_count}</td>
|
||||
<td>${w.categories_count}</td>
|
||||
<td>${(w.health_score||0).toFixed(1)}</td>
|
||||
<td>${(w.credibility||0).toFixed(3)}</td>
|
||||
</tr>`
|
||||
).join('');
|
||||
|
||||
document.querySelector('#signal-table tbody').innerHTML = signalsR.map(s => {
|
||||
const cls = s.aggregated_strength > 0.7 ? 'b-high' : s.aggregated_strength > 0.4 ? 'b-med' : 'b-low';
|
||||
return `<tr>
|
||||
<td>${(s.generated_at||'').slice(0,16)}</td>
|
||||
<td>${(s.market_question||'').slice(0,60)}</td>
|
||||
<td>${s.side} ${s.outcome}</td>
|
||||
<td>${Number(s.entry_price||0).toFixed(4)}</td>
|
||||
<td><span class="badge ${cls}">${(s.aggregated_strength*100).toFixed(0)}%</span></td>
|
||||
<td>${s.n_contributors||'?'}</td>
|
||||
<td>$${Number(s.suggested_size_usd||0).toFixed(0)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('content').style.display = 'block';
|
||||
document.getElementById('update-info').textContent =
|
||||
`Last refresh: ${new Date().toLocaleTimeString()} · auto-refresh 30s`;
|
||||
} catch(e) {
|
||||
document.getElementById('loading').textContent = 'Error: ' + e.message;
|
||||
}
|
||||
}
|
||||
load();
|
||||
setInterval(load, 30000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def dashboard_page():
|
||||
return HTML_TEMPLATE
|
||||
@@ -0,0 +1,229 @@
|
||||
"""SQLite storage for wallet pool, signals, and trade executions."""
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CopyTraderDatabase:
|
||||
"""SQLite storage for the copy-trader."""
|
||||
|
||||
def __init__(self, db_path: str = "data/copytrader.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:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS wallet_targets (
|
||||
address TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
pnl_30d_usd REAL DEFAULT 0,
|
||||
pnl_total_usd REAL DEFAULT 0,
|
||||
trades_count INTEGER DEFAULT 0,
|
||||
categories_count INTEGER DEFAULT 0,
|
||||
health_score REAL DEFAULT 0,
|
||||
credibility REAL DEFAULT 0.5,
|
||||
last_seen_at TEXT,
|
||||
added_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS copy_signals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
condition_id TEXT NOT NULL,
|
||||
market_question TEXT,
|
||||
side TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
entry_price REAL NOT NULL,
|
||||
aggregated_strength REAL NOT NULL,
|
||||
source_wallets_json TEXT NOT NULL,
|
||||
kelly_fraction REAL NOT NULL,
|
||||
suggested_size_usd REAL NOT NULL,
|
||||
generated_at TEXT NOT NULL,
|
||||
executed INTEGER DEFAULT 0,
|
||||
exit_price REAL,
|
||||
pnl_usd REAL,
|
||||
resolved_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_copy_signals_condition
|
||||
ON copy_signals(condition_id)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_copy_signals_generated_at
|
||||
ON copy_signals(generated_at DESC)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS trade_executions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
signal_id INTEGER,
|
||||
condition_id TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
size_usd REAL NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
order_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
executed_at TEXT NOT NULL,
|
||||
FOREIGN KEY(signal_id) REFERENCES copy_signals(id)
|
||||
)
|
||||
""")
|
||||
|
||||
# ----- Wallet target operations -----
|
||||
def upsert_wallet_target(self, wallet: dict) -> None:
|
||||
now = datetime.now().isoformat()
|
||||
with self._get_conn() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO wallet_targets
|
||||
(address, source, pnl_30d_usd, pnl_total_usd, trades_count,
|
||||
categories_count, health_score, credibility, last_seen_at,
|
||||
added_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(address) DO UPDATE SET
|
||||
pnl_30d_usd=excluded.pnl_30d_usd,
|
||||
pnl_total_usd=excluded.pnl_total_usd,
|
||||
trades_count=excluded.trades_count,
|
||||
categories_count=excluded.categories_count,
|
||||
health_score=excluded.health_score,
|
||||
credibility=excluded.credibility,
|
||||
last_seen_at=excluded.last_seen_at,
|
||||
updated_at=excluded.updated_at
|
||||
""", (
|
||||
wallet["address"], wallet["source"],
|
||||
wallet.get("pnl_30d_usd", 0),
|
||||
wallet.get("pnl_total_usd", 0),
|
||||
wallet.get("trades_count", 0),
|
||||
wallet.get("categories_count", 0),
|
||||
wallet.get("health_score", 0),
|
||||
wallet.get("credibility", 0.5),
|
||||
wallet.get("last_seen_at"),
|
||||
wallet.get("added_at", now),
|
||||
now,
|
||||
))
|
||||
|
||||
def get_all_wallet_targets(self) -> List[dict]:
|
||||
with self._get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM wallet_targets ORDER BY health_score DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_wallet_addresses(self) -> List[str]:
|
||||
with self._get_conn() as conn:
|
||||
rows = conn.execute("SELECT address FROM wallet_targets").fetchall()
|
||||
return [r["address"] for r in rows]
|
||||
|
||||
def get_wallet_target(self, address: str) -> Optional[dict]:
|
||||
with self._get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM wallet_targets WHERE address=?", (address,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def update_wallet_credibility(self, address: str, credibility: float) -> None:
|
||||
with self._get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE wallet_targets SET credibility=?, updated_at=? WHERE address=?",
|
||||
(credibility, datetime.now().isoformat(), address),
|
||||
)
|
||||
|
||||
def get_signals_count(self) -> int:
|
||||
with self._get_conn() as conn:
|
||||
return conn.execute("SELECT COUNT(*) FROM copy_signals").fetchone()[0]
|
||||
|
||||
def get_wallet_count(self) -> int:
|
||||
with self._get_conn() as conn:
|
||||
return conn.execute("SELECT COUNT(*) FROM wallet_targets").fetchone()[0]
|
||||
|
||||
def get_executed_count(self) -> int:
|
||||
with self._get_conn() as conn:
|
||||
return conn.execute(
|
||||
"SELECT COUNT(*) FROM copy_signals WHERE executed=1"
|
||||
).fetchone()[0]
|
||||
|
||||
# ----- Copy signal operations -----
|
||||
def insert_signal(self, signal: dict) -> int:
|
||||
with self._get_conn() as conn:
|
||||
cur = conn.execute("""
|
||||
INSERT INTO copy_signals
|
||||
(condition_id, market_question, side, outcome, entry_price,
|
||||
aggregated_strength, source_wallets_json, kelly_fraction,
|
||||
suggested_size_usd, generated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
signal["condition_id"],
|
||||
signal.get("market_question", ""),
|
||||
signal["side"],
|
||||
signal["outcome"],
|
||||
signal["entry_price"],
|
||||
signal["aggregated_strength"],
|
||||
json.dumps(signal["source_wallets"]),
|
||||
signal["kelly_fraction"],
|
||||
signal["suggested_size_usd"],
|
||||
datetime.now().isoformat(),
|
||||
))
|
||||
return cur.lastrowid
|
||||
|
||||
def mark_signal_executed(self, signal_id: int) -> None:
|
||||
with self._get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE copy_signals SET executed=1 WHERE id=?", (signal_id,)
|
||||
)
|
||||
|
||||
def get_recent_signals(self, limit: int = 50) -> List[dict]:
|
||||
with self._get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM copy_signals ORDER BY generated_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def record_execution(self, exec_row: dict) -> int:
|
||||
with self._get_conn() as conn:
|
||||
cur = conn.execute("""
|
||||
INSERT INTO trade_executions
|
||||
(signal_id, condition_id, side, outcome, size_usd, price,
|
||||
order_id, status, error, executed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
exec_row.get("signal_id"),
|
||||
exec_row["condition_id"],
|
||||
exec_row["side"],
|
||||
exec_row["outcome"],
|
||||
exec_row["size_usd"],
|
||||
exec_row["price"],
|
||||
exec_row.get("order_id"),
|
||||
exec_row["status"],
|
||||
exec_row.get("error"),
|
||||
datetime.now().isoformat(),
|
||||
))
|
||||
return cur.lastrowid
|
||||
|
||||
# ----- Stats -----
|
||||
def get_stats(self) -> dict:
|
||||
with self._get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM wallet_targets").fetchone()[0]
|
||||
sigs = conn.execute("SELECT COUNT(*) FROM copy_signals").fetchone()[0]
|
||||
executed = conn.execute(
|
||||
"SELECT COUNT(*) FROM copy_signals WHERE executed=1"
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"wallet_count": total,
|
||||
"total_signals": sigs,
|
||||
"executed_signals": executed,
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
"""Copy trader main entry point (Typer CLI).
|
||||
|
||||
Commands:
|
||||
- run : orchestrator (pool refresh + trade stream + bayesian + telegram)
|
||||
- pool : one-shot rebuild of wallet pool
|
||||
- stats : DB stats
|
||||
- dashboard : start FastAPI dashboard (port 8518)
|
||||
- test-stream : 1-minute test that polls top 3 wallets and prints new trades
|
||||
"""
|
||||
import asyncio
|
||||
import signal as sys_signal
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from src.config import get_settings
|
||||
from src.db.database import CopyTraderDatabase
|
||||
from src.services.aggregator import SignalAggregator
|
||||
from src.services.bayesian import BayesianUpdater
|
||||
from src.services.kelly import KellySizer
|
||||
from src.services.telegram import TelegramNotifier
|
||||
from src.services.user_stream import UserTradeStream
|
||||
from src.services.wallet_pool import WalletPoolBuilder
|
||||
from src.utils.logger import BotLogger, setup_logging
|
||||
|
||||
app = typer.Typer(help="Polymarket Copy Trader — follow top wallets via consensus")
|
||||
logger = BotLogger()
|
||||
|
||||
|
||||
class CopyTrader:
|
||||
"""Main orchestrator."""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self.db = CopyTraderDatabase(self.settings.db_path)
|
||||
self.sizer = KellySizer()
|
||||
self.pool_builder = WalletPoolBuilder(self.db)
|
||||
self.aggregator = SignalAggregator(self.db, sizer=self.sizer)
|
||||
self.bayes = BayesianUpdater(self.db, self.aggregator)
|
||||
self.stream = UserTradeStream(self.db)
|
||||
self.notifier = TelegramNotifier()
|
||||
self._running = False
|
||||
self._tasks = []
|
||||
|
||||
async def run(self) -> None:
|
||||
self._running = True
|
||||
await self._bootstrap()
|
||||
|
||||
stats = self.db.get_stats()
|
||||
logger.startup(
|
||||
wallet_count=stats["wallet_count"],
|
||||
capital=self.settings.initial_capital_usd,
|
||||
)
|
||||
logger.info(
|
||||
f"Settings: min_trade=${self.settings.min_trade_size_usd} "
|
||||
f"price=[{self.settings.min_price}, {self.settings.max_price}] "
|
||||
f"poll={self.settings.user_poll_interval_seconds}s "
|
||||
f"consensus_wallets={self.settings.consensus_min_wallets}"
|
||||
)
|
||||
|
||||
self._tasks = [
|
||||
asyncio.create_task(self._pool_refresh_loop(), name="pool-refresh"),
|
||||
asyncio.create_task(self._trade_stream_loop(), name="trade-stream"),
|
||||
asyncio.create_task(self._credibility_loop(), name="credibility"),
|
||||
asyncio.create_task(self._health_beat(), name="health"),
|
||||
]
|
||||
try:
|
||||
await asyncio.gather(*self._tasks)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Cancelled")
|
||||
|
||||
async def _bootstrap(self) -> None:
|
||||
logger.info("Bootstrapping...")
|
||||
await self.notifier.start()
|
||||
self.aggregator.load_credibilities()
|
||||
if self.aggregator.wallet_credibility == {}:
|
||||
logger.info("No wallets in pool — running initial pool build")
|
||||
try:
|
||||
pool = await self.pool_builder.build_pool_async()
|
||||
now = datetime.now().isoformat()
|
||||
for w in pool:
|
||||
w.setdefault("added_at", now)
|
||||
self.db.upsert_wallet_target(w)
|
||||
logger.info(f"Initial pool built: {len(pool)} wallets")
|
||||
except Exception as e:
|
||||
logger.error(f"Initial pool build failed: {e}")
|
||||
await self.notifier.send_error(f"Initial pool build failed: {e}")
|
||||
self.aggregator.load_credibilities()
|
||||
logger.info(f"Pool loaded: {len(self.aggregator.wallet_credibility)} wallets")
|
||||
|
||||
async def _pool_refresh_loop(self) -> None:
|
||||
seconds = self.settings.wallet_refresh_hours * 3600
|
||||
logger.info(f"[loop] pool refresh every {seconds}s, first refresh after {seconds}s")
|
||||
await asyncio.sleep(seconds)
|
||||
while self._running:
|
||||
try:
|
||||
pool = await self.pool_builder.build_pool_async()
|
||||
now = datetime.now().isoformat()
|
||||
for w in pool:
|
||||
w.setdefault("added_at", now)
|
||||
self.db.upsert_wallet_target(w)
|
||||
logger.info(f"[loop] pool refreshed: {len(pool)} wallets")
|
||||
self.aggregator.load_credibilities()
|
||||
await self.notifier.send_signal(
|
||||
market="Pool refreshed",
|
||||
side="INFO",
|
||||
strength=0.0,
|
||||
size_usd=0,
|
||||
n_wallets=len(pool),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[loop] pool refresh failed: {e}")
|
||||
await self.notifier.send_error(f"Pool refresh failed: {e}")
|
||||
await asyncio.sleep(seconds)
|
||||
|
||||
async def _trade_stream_loop(self) -> None:
|
||||
logger.info("[loop] trade stream starting")
|
||||
|
||||
async def on_trade(addr: str, trade: dict) -> None:
|
||||
signal = await self.aggregator.on_trade(addr, trade)
|
||||
if signal:
|
||||
sig_id = self.db.insert_signal(signal)
|
||||
logger.info(
|
||||
f"[signal] #{sig_id} stored: {signal['side']} ${signal['suggested_size_usd']:.0f} "
|
||||
f"on {signal['market_question'][:40]}"
|
||||
)
|
||||
await self.notifier.send_signal(
|
||||
market=signal["market_question"],
|
||||
side=f"{signal['side']} {signal['outcome']}",
|
||||
strength=signal["aggregated_strength"],
|
||||
size_usd=signal["suggested_size_usd"],
|
||||
n_wallets=signal["n_contributors"],
|
||||
)
|
||||
|
||||
await self.stream.run(on_trade)
|
||||
|
||||
async def _credibility_loop(self) -> None:
|
||||
seconds = self.settings.credibility_update_minutes * 60
|
||||
logger.info(f"[loop] credibility update every {seconds}s")
|
||||
while self._running:
|
||||
await asyncio.sleep(seconds)
|
||||
try:
|
||||
await self.bayes._update_once()
|
||||
except Exception as e:
|
||||
logger.error(f"[loop] credibility update failed: {e}")
|
||||
|
||||
async def _health_beat(self) -> None:
|
||||
"""Emit periodic stats for log visibility."""
|
||||
while self._running:
|
||||
await asyncio.sleep(600)
|
||||
stats = self.db.get_stats()
|
||||
n_acc = len(self.aggregator.accumulators)
|
||||
logger.info(
|
||||
f"[health] wallets={stats['wallet_count']} "
|
||||
f"signals={stats['total_signals']} "
|
||||
f"executed={stats['executed_signals']} "
|
||||
f"open_accumulators={n_acc}"
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
self.stream.stop()
|
||||
self.bayes.stop()
|
||||
for t in self._tasks:
|
||||
t.cancel()
|
||||
|
||||
|
||||
_watcher: Optional[CopyTrader] = None
|
||||
|
||||
|
||||
def signal_handler(signum, frame):
|
||||
if _watcher:
|
||||
_watcher.stop()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
@app.command()
|
||||
def run(
|
||||
debug: bool = typer.Option(False, "--debug", "-d", help="Verbose DEBUG logs"),
|
||||
):
|
||||
"""Start the copy-trader bot."""
|
||||
global _watcher
|
||||
setup_logging("DEBUG" if debug else "INFO")
|
||||
|
||||
sys_signal.signal(sys_signal.SIGINT, signal_handler)
|
||||
sys_signal.signal(sys_signal.SIGTERM, signal_handler)
|
||||
|
||||
_watcher = CopyTrader()
|
||||
try:
|
||||
asyncio.run(_watcher.run())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
logger.info("Copy trader stopped")
|
||||
|
||||
|
||||
@app.command()
|
||||
def pool():
|
||||
"""Rebuild the top-wallet pool now and exit."""
|
||||
setup_logging("INFO")
|
||||
settings = get_settings()
|
||||
db = CopyTraderDatabase(settings.db_path)
|
||||
builder = WalletPoolBuilder(db)
|
||||
count = builder.refresh()
|
||||
print(f"Pool refreshed: {count} wallets persisted to {settings.db_path}")
|
||||
|
||||
|
||||
@app.command()
|
||||
def stats():
|
||||
"""Show database stats."""
|
||||
setup_logging("INFO")
|
||||
settings = get_settings()
|
||||
db = CopyTraderDatabase(settings.db_path)
|
||||
s = db.get_stats()
|
||||
print(f"Wallet count: {s['wallet_count']}")
|
||||
print(f"Total signals: {s['total_signals']}")
|
||||
print(f"Executed: {s['executed_signals']}")
|
||||
|
||||
|
||||
@app.command()
|
||||
def test_stream(minutes: int = 2):
|
||||
"""One-shot test: poll top 3 wallets for N minutes, print new trades."""
|
||||
setup_logging("DEBUG")
|
||||
async def _run():
|
||||
settings = get_settings()
|
||||
db = CopyTraderDatabase(settings.db_path)
|
||||
sizer = KellySizer()
|
||||
agg = SignalAggregator(db, sizer=sizer)
|
||||
agg.load_credibilities()
|
||||
stream = UserTradeStream(db)
|
||||
addrs = db.get_wallet_addresses()[:3]
|
||||
print(f"Top 3 wallets: {addrs}")
|
||||
|
||||
async def cb(addr, t):
|
||||
print(
|
||||
f" [{datetime.now().strftime('%H:%M:%S')}] "
|
||||
f"{addr[:8]}... {t.get('side')} {t.get('outcome')} "
|
||||
f"${float(t.get('size', 0)) * float(t.get('price', 0)):.0f} "
|
||||
f"@ {float(t.get('price', 0)):.4f} "
|
||||
f"[cid={t.get('conditionId', '?')[:12]}]"
|
||||
)
|
||||
|
||||
original_run = stream.run
|
||||
async def patched_run(on_trade):
|
||||
self_ref = stream
|
||||
self_ref._running = True
|
||||
warmup_until = __import__('time').time() + settings.stream_warmup_seconds
|
||||
while self_ref._running:
|
||||
for addr in addrs:
|
||||
try:
|
||||
trades = await asyncio.to_thread(self_ref.data.get_activity, addr, settings.stream_max_trades_per_wallet)
|
||||
await self_ref._process_trades(addr, trades, on_trade, __import__('time').time() < warmup_until)
|
||||
except Exception as e:
|
||||
print(f"poll err: {e}")
|
||||
await asyncio.sleep(settings.user_poll_interval_seconds)
|
||||
stream.run = patched_run
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(stream.run(cb), timeout=minutes * 60)
|
||||
except asyncio.TimeoutError:
|
||||
print(f"Test stream ended after {minutes} min")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
@app.command()
|
||||
def dashboard(port: int = 8518, host: str = "0.0.0.0"):
|
||||
"""Start FastAPI dashboard."""
|
||||
import uvicorn
|
||||
from src.dashboard import app as dashboard_app
|
||||
|
||||
print(f"Dashboard starting at http://{host}:{port}")
|
||||
uvicorn.run(dashboard_app, host=host, port=port, log_level="info")
|
||||
|
||||
|
||||
def main():
|
||||
app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Data models for the copy-trader."""
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalletTarget:
|
||||
"""A top-wallet we are watching and may follow."""
|
||||
address: str
|
||||
source: str # "top_holders", "trade_history", "leaderboard"
|
||||
pnl_30d_usd: float
|
||||
pnl_total_usd: float
|
||||
trades_count: int
|
||||
categories_count: int
|
||||
health_score: float # 0-100
|
||||
credibility: float # Bayesian posterior (0-1)
|
||||
last_seen_at: Optional[str] = None
|
||||
added_at: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CopySignal:
|
||||
"""Aggregated signal to follow top wallets on a market."""
|
||||
condition_id: str
|
||||
market_question: str
|
||||
side: str # "BUY" or "SELL"
|
||||
outcome: str # "Yes" or "No"
|
||||
entry_price: float
|
||||
aggregated_strength: float # 0-1 weighted consensus
|
||||
source_wallets: list # list of (address, size_usd, weight)
|
||||
kelly_fraction: float
|
||||
suggested_size_usd: float
|
||||
generated_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Signal aggregator: combines trades from multiple wallets into consensus signals.
|
||||
|
||||
Flow:
|
||||
on_trade(wallet_addr, trade)
|
||||
│
|
||||
├─ filter: price guard, size guard, credibility guard, debounce
|
||||
│
|
||||
├─ update per-market rolling accumulator
|
||||
│
|
||||
├─ if consensus reached: emit CopySignal
|
||||
│
|
||||
└─ cleanup expired accumulators
|
||||
|
||||
Consensus rule:
|
||||
- N wallets (≥ CONSENSUS_MIN_WALLETS) agree on same direction within window
|
||||
- aggregated strength = mean credibility of agreeing wallets
|
||||
- emit only when (agreed_strength - opposed_strength) >= CONSENSUS_STRENGTH_THRESHOLD
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from src.config import get_settings
|
||||
from src.db.database import CopyTraderDatabase
|
||||
from src.services.data_api import DataAPIClient
|
||||
from src.services.kelly import KellySizer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketAccumulator:
|
||||
condition_id: str
|
||||
market_question: str = ""
|
||||
outcome: str = "Yes"
|
||||
buy_strength: float = 0.0
|
||||
sell_strength: float = 0.0
|
||||
contributors_buy: List[dict] = field(default_factory=list)
|
||||
contributors_sell: List[dict] = field(default_factory=list)
|
||||
last_price: float = 0.0
|
||||
last_trade_at: float = 0.0
|
||||
window_started_at: float = 0.0
|
||||
|
||||
|
||||
class SignalAggregator:
|
||||
"""Detects consensus signals from a stream of wallet trades."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: CopyTraderDatabase,
|
||||
sizer: Optional[KellySizer] = None,
|
||||
data: Optional[DataAPIClient] = None,
|
||||
):
|
||||
self.db = db
|
||||
self.settings = get_settings()
|
||||
self.sizer = sizer or KellySizer()
|
||||
self.data = data or DataAPIClient()
|
||||
self.accumulators: Dict[str, MarketAccumulator] = {}
|
||||
self.wallet_credibility: Dict[str, float] = {}
|
||||
self.wallet_debounce: Dict[str, float] = {}
|
||||
self._signals_emitted = 0
|
||||
|
||||
def load_credibilities(self) -> None:
|
||||
"""Bulk-load credibility from DB into memory."""
|
||||
for w in self.db.get_all_wallet_targets():
|
||||
self.wallet_credibility[w["address"]] = w.get("credibility", 0.5)
|
||||
logger.info(
|
||||
f"[agg] loaded {len(self.wallet_credibility)} wallet credibilities"
|
||||
)
|
||||
|
||||
def update_credibility(self, address: str, credibility: float) -> None:
|
||||
"""Apply credibility update from Bayesian updater."""
|
||||
self.wallet_credibility[address] = credibility
|
||||
self.db.update_wallet_credibility(address, credibility)
|
||||
|
||||
def _lazy_market_meta(self, cid: str, default_outcome: str) -> tuple:
|
||||
"""Synchronously fetch market question; degrade gracefully on failure."""
|
||||
try:
|
||||
meta = self.data.get_market(cid)
|
||||
if not meta:
|
||||
return "", default_outcome
|
||||
q = meta.get("question", "")
|
||||
outcomes = meta.get("outcomes", "")
|
||||
if isinstance(outcomes, str):
|
||||
try:
|
||||
outcomes = json.loads(outcomes)
|
||||
except Exception:
|
||||
outcomes = []
|
||||
if isinstance(outcomes, list) and outcomes:
|
||||
return q, outcomes[0]
|
||||
return q, default_outcome
|
||||
except Exception as e:
|
||||
logger.debug(f"[agg] market meta fetch failed for {cid[:10]}: {e}")
|
||||
return "", default_outcome
|
||||
|
||||
async def on_trade(self, wallet_addr: str, trade: dict) -> Optional[dict]:
|
||||
"""Process a new trade. Returns a CopySignal dict if consensus reached."""
|
||||
cid = trade.get("conditionId") or ""
|
||||
if not cid:
|
||||
logger.debug(f"[agg] skipped trade without conditionId: {trade}")
|
||||
return None
|
||||
|
||||
side = trade.get("side", "")
|
||||
outcome = trade.get("outcome", "Yes")
|
||||
try:
|
||||
price = float(trade.get("price", 0))
|
||||
size = float(trade.get("size", 0))
|
||||
except (TypeError, ValueError):
|
||||
logger.debug(f"[agg] skipped trade with bad numerics: {trade}")
|
||||
return None
|
||||
|
||||
# Compute USD size
|
||||
if size == 0 and "usdcSize" in trade:
|
||||
usdc = float(trade.get("usdcSize", 0))
|
||||
else:
|
||||
usdc = size * price
|
||||
|
||||
# Filters
|
||||
if usdc < self.settings.min_trade_size_usd:
|
||||
logger.debug(
|
||||
f"[agg] ${usdc:.0f} < min ${self.settings.min_trade_size_usd:.0f}, skipping"
|
||||
)
|
||||
return None
|
||||
if not (self.settings.min_price <= price <= self.settings.max_price):
|
||||
logger.debug(
|
||||
f"[agg] price {price:.4f} outside [{self.settings.min_price}, {self.settings.max_price}], skipping"
|
||||
)
|
||||
return None
|
||||
|
||||
cred = self.wallet_credibility.get(wallet_addr, 0.5)
|
||||
if cred < self.settings.min_credibility:
|
||||
logger.debug(
|
||||
f"[agg] wallet {wallet_addr[:10]} cred {cred:.2f} < min {self.settings.min_credibility}, skipping"
|
||||
)
|
||||
return None
|
||||
|
||||
# Debounce per (wallet, market)
|
||||
deb_key = f"{wallet_addr}:{cid}"
|
||||
last = self.wallet_debounce.get(deb_key, 0)
|
||||
if time.time() - last < self.settings.wallet_debounce_seconds:
|
||||
logger.debug(f"[agg] debounced {deb_key}")
|
||||
return None
|
||||
self.wallet_debounce[deb_key] = time.time()
|
||||
|
||||
acc = self.accumulators.get(cid)
|
||||
if acc is None:
|
||||
market_q, derived_outcome = self._lazy_market_meta(cid, outcome)
|
||||
acc = MarketAccumulator(
|
||||
condition_id=cid,
|
||||
market_question=market_q,
|
||||
outcome=derived_outcome,
|
||||
window_started_at=time.time(),
|
||||
last_trade_at=time.time(),
|
||||
)
|
||||
self.accumulators[cid] = acc
|
||||
logger.debug(f"[agg] new accumulator for {cid[:10]}: {market_q[:60]}")
|
||||
acc.last_price = price
|
||||
acc.last_trade_at = time.time()
|
||||
|
||||
contrib = {
|
||||
"address": wallet_addr,
|
||||
"credibility": cred,
|
||||
"size_usd": usdc,
|
||||
}
|
||||
if side == "BUY":
|
||||
acc.buy_strength += cred
|
||||
acc.contributors_buy.append(contrib)
|
||||
elif side == "SELL":
|
||||
acc.sell_strength += cred
|
||||
acc.contributors_sell.append(contrib)
|
||||
else:
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"[agg] [{cid[:10]}] {wallet_addr[:10]} {side} ${usdc:.0f} @ {price:.4f} | "
|
||||
f"buy={acc.buy_strength:.2f} ({len(acc.contributors_buy)}w) "
|
||||
f"sell={acc.sell_strength:.2f} ({len(acc.contributors_sell)}w) | "
|
||||
f"cred={cred:.2f}"
|
||||
)
|
||||
|
||||
self._cleanup_expired()
|
||||
|
||||
spread = acc.buy_strength - acc.sell_strength
|
||||
if (
|
||||
len(acc.contributors_buy) >= self.settings.consensus_min_wallets
|
||||
and spread >= self.settings.consensus_strength_threshold
|
||||
):
|
||||
return self._emit(cid, "BUY", acc.outcome, price, acc)
|
||||
elif (
|
||||
len(acc.contributors_sell) >= self.settings.consensus_min_wallets
|
||||
and -spread >= self.settings.consensus_strength_threshold
|
||||
):
|
||||
return self._emit(cid, "SELL", acc.outcome, price, acc)
|
||||
return None
|
||||
|
||||
def _emit(
|
||||
self,
|
||||
cid: str,
|
||||
side: str,
|
||||
outcome: str,
|
||||
price: float,
|
||||
acc: MarketAccumulator,
|
||||
) -> dict:
|
||||
contributors = acc.contributors_buy if side == "BUY" else acc.contributors_sell
|
||||
n = len(contributors)
|
||||
aggregated = sum(c["credibility"] for c in contributors) / max(1, n)
|
||||
total_size_usd = sum(c["size_usd"] for c in contributors)
|
||||
|
||||
kelly = self.sizer.fraction(aggregated, price, side)
|
||||
suggested = self.sizer.position_usd(
|
||||
kelly, self.settings.initial_capital_usd
|
||||
)
|
||||
|
||||
wallet_contribs = [
|
||||
[c["address"], c["size_usd"], c["credibility"]] for c in contributors
|
||||
]
|
||||
|
||||
signal = {
|
||||
"condition_id": cid,
|
||||
"market_question": acc.market_question,
|
||||
"side": side,
|
||||
"outcome": outcome,
|
||||
"entry_price": price,
|
||||
"aggregated_strength": aggregated,
|
||||
"source_wallets": wallet_contribs,
|
||||
"kelly_fraction": kelly,
|
||||
"suggested_size_usd": suggested,
|
||||
"total_signal_size_usd": total_size_usd,
|
||||
"n_contributors": n,
|
||||
}
|
||||
self._signals_emitted += 1
|
||||
logger.info(
|
||||
f"[agg] SIGNAL #{self._signals_emitted}: "
|
||||
f"{side} {outcome} on {acc.market_question[:60]} @ {price:.4f} | "
|
||||
f"strength={aggregated:.2f} wallets={n} "
|
||||
f"kelly={kelly:.3f} size=${suggested:.0f}"
|
||||
)
|
||||
self.accumulators.pop(cid, None)
|
||||
return signal
|
||||
|
||||
def _cleanup_expired(self) -> None:
|
||||
now = time.time()
|
||||
expired = [
|
||||
cid
|
||||
for cid, acc in self.accumulators.items()
|
||||
if now - acc.last_trade_at > self.settings.consensus_window_seconds
|
||||
]
|
||||
for cid in expired:
|
||||
acc = self.accumulators.pop(cid, None)
|
||||
if acc:
|
||||
logger.debug(
|
||||
f"[agg] window expired for {cid[:10]} (no consensus reached)"
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Bayesian credibility updater.
|
||||
|
||||
For each wallet, fetch recent closed-positions (realized PnL) and adjust
|
||||
credibility using a simple posterior update.
|
||||
|
||||
State:
|
||||
prior_skill: BAYESIAN_PRIOR_SKILL (default 0.5)
|
||||
posterior: updated based on realized PnL trend
|
||||
|
||||
Update rule (simple exponential smoothing):
|
||||
if realized_30d > 0: cred += step * (1 - cred)
|
||||
if realized_30d < 0: cred -= step * cred
|
||||
|
||||
Where step = 0.05 by default (slow update).
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict
|
||||
|
||||
from src.config import get_settings
|
||||
from src.db.database import CopyTraderDatabase
|
||||
from src.services.aggregator import SignalAggregator
|
||||
from src.services.data_api import DataAPIClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BayesianUpdater:
|
||||
"""Periodically refresh wallet credibility from realized PnL."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: CopyTraderDatabase,
|
||||
aggregator: SignalAggregator,
|
||||
data: DataAPIClient = None,
|
||||
):
|
||||
self.db = db
|
||||
self.aggregator = aggregator
|
||||
self.settings = get_settings()
|
||||
self.data = data or DataAPIClient()
|
||||
self._running = False
|
||||
|
||||
async def run(self) -> None:
|
||||
self._running = True
|
||||
interval = self.settings.credibility_update_minutes * 60
|
||||
logger.info(f"[bayes] starting, interval={interval}s")
|
||||
try:
|
||||
while self._running:
|
||||
await self._update_once()
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("[bayes] cancelled")
|
||||
|
||||
async def _update_once(self) -> None:
|
||||
addresses = self.db.get_wallet_addresses()
|
||||
logger.debug(f"[bayes] updating credibility for {len(addresses)} wallets")
|
||||
for addr in addresses:
|
||||
try:
|
||||
closed = await asyncio.to_thread(self.data.get_closed_positions, addr, 100)
|
||||
except Exception as e:
|
||||
logger.warning(f"[bayes] closed-positions fetch failed {addr[:10]}: {e}")
|
||||
continue
|
||||
realized = sum(float(p.get("realizedPnl") or 0) for p in closed)
|
||||
current = self.aggregator.wallet_credibility.get(addr, 0.5)
|
||||
|
||||
# Update
|
||||
step = 0.05
|
||||
if realized > 0:
|
||||
new = current + step * (1 - current)
|
||||
elif realized < 0:
|
||||
new = current - step * current
|
||||
else:
|
||||
new = current
|
||||
new = max(0.05, min(0.95, new))
|
||||
self.aggregator.update_credibility(addr, new)
|
||||
logger.debug(
|
||||
f"[bayes] {addr[:10]} realized=${realized:.0f} cred {current:.3f}→{new:.3f}"
|
||||
)
|
||||
logger.info(f"[bayes] credibility refresh complete")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Polymarket public API clients (Gamma + Data). All endpoints are public, no auth."""
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from src.utils.http import get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GAMMA_BASE = "https://gamma-api.polymarket.com"
|
||||
DATA_BASE = "https://data-api.polymarket.com"
|
||||
|
||||
|
||||
class DataAPIClient:
|
||||
"""Client for Polymarket Data API (positions, holders, activity)."""
|
||||
|
||||
def __init__(self):
|
||||
self._client = get_client(timeout=30.0)
|
||||
|
||||
def get_top_holders(
|
||||
self, condition_id: str, limit: int = 50, timeout: Optional[float] = None,
|
||||
) -> List[dict]:
|
||||
"""Fetch top holders by TOTAL_PNL for a market."""
|
||||
kwargs: Dict[str, Any] = {}
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
r = self._client.get(
|
||||
f"{DATA_BASE}/v1/market-positions",
|
||||
params={
|
||||
"market": condition_id,
|
||||
"status": "ALL",
|
||||
"sortBy": "TOTAL_PNL",
|
||||
"sortDirection": "DESC",
|
||||
"limit": limit,
|
||||
},
|
||||
**kwargs,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def get_positions(
|
||||
self, address: str, limit: int = 500, timeout: Optional[float] = None,
|
||||
) -> List[dict]:
|
||||
"""Fetch all positions for a wallet."""
|
||||
kwargs: Dict[str, Any] = {}
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
r = self._client.get(
|
||||
f"{DATA_BASE}/positions",
|
||||
params={"user": address, "limit": limit, "sortBy": "CASHPNL"},
|
||||
**kwargs,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def get_closed_positions(self, address: str, limit: int = 100) -> List[dict]:
|
||||
"""Closed positions (realized PnL)."""
|
||||
r = self._client.get(
|
||||
f"{DATA_BASE}/closed-positions",
|
||||
params={"user": address, "limit": limit, "sortBy": "REALIZEDPNL"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def get_activity(self, address: str, limit: int = 50) -> List[dict]:
|
||||
"""Fetch recent activity (TRADE, SPLIT, MERGE, etc.) for a wallet."""
|
||||
r = self._client.get(
|
||||
f"{DATA_BASE}/activity",
|
||||
params={
|
||||
"user": address,
|
||||
"type": "TRADE",
|
||||
"limit": min(limit, 500),
|
||||
"sortBy": "TIMESTAMP",
|
||||
"sortDirection": "DESC",
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def get_market(self, condition_id: str) -> Optional[dict]:
|
||||
"""Single market metadata from Gamma API."""
|
||||
try:
|
||||
r = self._client.get(
|
||||
f"{GAMMA_BASE}/markets/{condition_id}",
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class GammaAPIClient:
|
||||
"""Client for Gamma API (markets discovery, metadata)."""
|
||||
|
||||
def __init__(self):
|
||||
self._client = get_client(timeout=30.0)
|
||||
|
||||
def get_active_events_by_volume(self, limit: int = 200) -> List[dict]:
|
||||
"""List active events sorted by 24h volume."""
|
||||
r = self._client.get(
|
||||
f"{GAMMA_BASE}/events",
|
||||
params={
|
||||
"active": "true",
|
||||
"closed": "false",
|
||||
"archived": "false",
|
||||
"order": "volume24hr",
|
||||
"ascending": "false",
|
||||
"limit": min(limit, 500),
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def get_event_by_slug(self, slug: str) -> Optional[dict]:
|
||||
r = self._client.get(f"{GAMMA_BASE}/events", params={"slug": slug})
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
return data[0] if data else None
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Kelly position sizer with Favorite-Longshot bias correction.
|
||||
|
||||
Kelly formula (half-Kelly by default for safety):
|
||||
p = estimated win probability (from signal strength)
|
||||
b = payoff ratio: (1 - price)/price for BUY, price/(1-price) for SELL
|
||||
q = 1 - p
|
||||
f* = (b * p - q) / b
|
||||
use = f* * kelly_fraction (default 0.5 → half-Kelly)
|
||||
|
||||
Favorite-Longshot bias correction (research-driven):
|
||||
factor = (1 - 2 * |price - 0.5|)^beta
|
||||
Edge at extreme prices (<0.10 or >0.90) is reduced.
|
||||
"""
|
||||
import math
|
||||
|
||||
from src.config import get_settings
|
||||
|
||||
|
||||
class KellySizer:
|
||||
"""Position sizing per the research framework."""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
|
||||
def win_probability(self, strength: float) -> float:
|
||||
"""Map signal strength [0,1] → win probability.
|
||||
|
||||
strength=0.5 → p=0.55 (baseline)
|
||||
strength=1.0 → p=0.85 (strong consensus)
|
||||
strength=0.0 → p=0.50 (coin flip)
|
||||
"""
|
||||
strength = max(0.0, min(1.0, strength))
|
||||
return 0.50 + 0.35 * strength
|
||||
|
||||
def payoff_ratio(self, price: float, side: str) -> float:
|
||||
"""How much we win vs how much we risk."""
|
||||
p = max(0.01, min(0.99, price))
|
||||
if side == "BUY":
|
||||
return (1.0 - p) / p # win=(1-p), risk=p
|
||||
# SELL: assume we already hold the position at avg price p, hedge at current
|
||||
return p / (1.0 - p)
|
||||
|
||||
def favorite_longshot_correction(self, price: float, beta: float = 1.5) -> float:
|
||||
"""Smooth penalty for extreme prices. 1.0 at price=0.5, ~0 at extremes."""
|
||||
return (1.0 - 2.0 * abs(price - 0.5)) ** beta
|
||||
|
||||
def fraction(
|
||||
self,
|
||||
signal_strength: float,
|
||||
price: float,
|
||||
side: str,
|
||||
beta: float = 1.5,
|
||||
) -> float:
|
||||
"""Compute Kelly fraction (capped 0..1) for a single signal."""
|
||||
p = self.win_probability(signal_strength)
|
||||
q = 1.0 - p
|
||||
b = self.payoff_ratio(price, side)
|
||||
|
||||
f_star = max(0.0, (b * p - q) / b)
|
||||
f_star *= self.favorite_longshot_correction(price, beta)
|
||||
f_star *= self.settings.kelly_fraction
|
||||
|
||||
return min(f_star, self.settings.max_position_pct)
|
||||
|
||||
def position_usd(self, fraction: float, capital: float) -> float:
|
||||
"""Translate fraction → dollar size, capped by per-trade position limit."""
|
||||
size = fraction * capital
|
||||
max_size = capital * self.settings.max_position_pct
|
||||
return min(size, max_size)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Telegram bot for trade notifications."""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from src.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramNotifier:
|
||||
"""Sends trade alerts via Telegram bot."""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self._bot = None
|
||||
|
||||
async def start(self) -> None:
|
||||
if not self.settings.telegram_enabled:
|
||||
logger.info("Telegram notifications disabled (TELEGRAM_ENABLED=false)")
|
||||
return
|
||||
if not self.settings.telegram_bot_token or not self.settings.telegram_chat_id:
|
||||
logger.warning("Telegram credentials missing; notifier disabled")
|
||||
return
|
||||
try:
|
||||
from telegram import Bot
|
||||
self._bot = Bot(token=self.settings.telegram_bot_token)
|
||||
me = await self._bot.get_me()
|
||||
logger.info(f"Telegram bot started: @{me.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start Telegram bot: {e}")
|
||||
self._bot = None
|
||||
|
||||
async def send_signal(self, market: str, side: str, strength: float,
|
||||
size_usd: float, n_wallets: int) -> None:
|
||||
if not self._bot:
|
||||
return
|
||||
text = (
|
||||
f"🎯 *Copy Signal*\n"
|
||||
f"Market: {market[:80]}\n"
|
||||
f"Direction: {side}\n"
|
||||
f"Strength: {strength:.2f}\n"
|
||||
f"Suggested size: ${size_usd:,.0f}\n"
|
||||
f"Source wallets: {n_wallets}\n"
|
||||
)
|
||||
try:
|
||||
await self._bot.send_message(
|
||||
chat_id=self.settings.telegram_chat_id,
|
||||
text=text,
|
||||
parse_mode="Markdown",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram signal: {e}")
|
||||
|
||||
async def send_error(self, message: str) -> None:
|
||||
if not self._bot:
|
||||
return
|
||||
try:
|
||||
await self._bot.send_message(
|
||||
chat_id=self.settings.telegram_chat_id,
|
||||
text=f"⚠️ Error: {message[:500]}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,135 @@
|
||||
"""User trade stream (poll-based). Detects new trades from each top wallet.
|
||||
|
||||
Strategy:
|
||||
- For each wallet, poll /activity?type=TRADE every USER_POLL_INTERVAL_SECONDS
|
||||
- New trade = trade whose tx_hash is not in last_seen set
|
||||
- Skip during warmup (avoid historical-trade noise)
|
||||
- Yield trade via callback for aggregator
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Awaitable, Callable, Dict, List, Optional, Set
|
||||
|
||||
from src.config import get_settings
|
||||
from src.db.database import CopyTraderDatabase
|
||||
from src.services.data_api import DataAPIClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TradeCallback = Callable[[str, dict], Awaitable[None]]
|
||||
|
||||
|
||||
class UserTradeStream:
|
||||
"""Polls each tracked wallet and emits new trades."""
|
||||
|
||||
def __init__(self, db: CopyTraderDatabase, data: Optional[DataAPIClient] = None):
|
||||
self.db = db
|
||||
self.settings = get_settings()
|
||||
self.data = data or DataAPIClient()
|
||||
self._last_seen: Dict[str, Optional[str]] = {}
|
||||
self._running = False
|
||||
self._poll_count = 0
|
||||
self._trades_emitted = 0
|
||||
|
||||
async def run(self, on_trade: TradeCallback) -> None:
|
||||
"""Continuously poll wallets and call on_trade(wallet_addr, trade)."""
|
||||
self._running = True
|
||||
warmup_until = time.time() + self.settings.stream_warmup_seconds
|
||||
logger.info(
|
||||
f"[stream] starting — poll_interval={self.settings.user_poll_interval_seconds}s, "
|
||||
f"warmup={self.settings.stream_warmup_seconds}s"
|
||||
)
|
||||
try:
|
||||
while self._running:
|
||||
self._poll_count += 1
|
||||
addresses = self.db.get_wallet_addresses()
|
||||
in_warmup = time.time() < warmup_until
|
||||
if in_warmup and self._poll_count == 1:
|
||||
logger.info(f"[stream] warming up, will skip baseline trades")
|
||||
|
||||
# Parallel wallet polling (semaphore limits concurrency)
|
||||
sem = asyncio.Semaphore(self.settings.wallet_pool_concurrency)
|
||||
poll_sem = self.settings.wallet_pool_concurrency
|
||||
poll_timeout = self.settings.wallet_pool_request_timeout
|
||||
|
||||
async def poll_one(addr: str) -> None:
|
||||
async with sem:
|
||||
try:
|
||||
trades = await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
self.data.get_activity, addr,
|
||||
self.settings.stream_max_trades_per_wallet,
|
||||
),
|
||||
timeout=poll_timeout,
|
||||
)
|
||||
await self._process_trades(addr, trades, on_trade, in_warmup)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug(f"[stream] poll timeout for {addr[:10]}")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[stream] poll failed for {addr[:10]}: {e}",
|
||||
exc_info=self.settings.log_level == "DEBUG",
|
||||
)
|
||||
|
||||
await asyncio.gather(*[poll_one(addr) for addr in addresses])
|
||||
|
||||
if self._poll_count % 10 == 0:
|
||||
logger.info(
|
||||
f"[stream] poll #{self._poll_count} complete "
|
||||
f"({len(addresses)} wallets, {self._trades_emitted} total trades emitted)"
|
||||
)
|
||||
await asyncio.sleep(self.settings.user_poll_interval_seconds)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("[stream] cancelled, shutting down")
|
||||
|
||||
async def _process_trades(
|
||||
self,
|
||||
address: str,
|
||||
trades: List[dict],
|
||||
on_trade: TradeCallback,
|
||||
in_warmup: bool,
|
||||
) -> None:
|
||||
last_hash = self._last_seen.get(address)
|
||||
new_trades: List[dict] = []
|
||||
for trade in trades:
|
||||
tx_hash = self._trade_hash(trade)
|
||||
if tx_hash is None:
|
||||
continue
|
||||
if tx_hash == last_hash:
|
||||
break
|
||||
new_trades.append(trade)
|
||||
|
||||
if trades:
|
||||
first_hash = self._trade_hash(trades[0])
|
||||
if first_hash is not None:
|
||||
self._last_seen[address] = first_hash
|
||||
|
||||
if in_warmup:
|
||||
logger.info(
|
||||
f"[stream] warmup: skipped {len(new_trades)} baseline trades for {address[:10]}"
|
||||
)
|
||||
return
|
||||
|
||||
for trade in reversed(new_trades): # process oldest-first
|
||||
if self.settings.debug_log_api_payloads:
|
||||
logger.info(f"[stream] NEW TRADE from {address[:10]}: {trade}")
|
||||
try:
|
||||
await on_trade(address, trade)
|
||||
self._trades_emitted += 1
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[stream] on_trade callback failed for {address[:10]}: {e}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _trade_hash(trade: dict) -> Optional[str]:
|
||||
"""Stable unique key per Polymarket trade record."""
|
||||
for k in ("transactionHash", "tx_hash", "id", "tradeId"):
|
||||
v = trade.get(k)
|
||||
if v:
|
||||
return str(v)
|
||||
return None
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Top-wallet pool builder. Identifies and maintains the pool of wallets we follow.
|
||||
|
||||
Strategy:
|
||||
1. Fetch top events by 24h volume (1 API call)
|
||||
2. For each market (parallel, semaphore-limited), fetch top holders
|
||||
3. Deduplicate wallet addresses across all markets
|
||||
4. For each candidate (parallel), fetch /positions to compute PnL/trades/categories
|
||||
5. Apply health score; keep top N by score; persist to DB
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Set
|
||||
|
||||
from src.config import get_settings
|
||||
from src.db.database import CopyTraderDatabase
|
||||
from src.services.data_api import DataAPIClient, GammaAPIClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def compute_health_score(pnl_30d: float, total_pnl: float,
|
||||
trades: int, categories: int) -> float:
|
||||
"""0-100 score combining PnL magnitude, trade count, category diversity."""
|
||||
if trades < 10 or pnl_30d <= 0:
|
||||
return 0.0
|
||||
|
||||
pnl_score = min(pnl_30d / 10000.0, 1.0) * 50
|
||||
trade_score = min(trades / 100.0, 1.0) * 25
|
||||
diversity_score = min(categories / 5.0, 1.0) * 25
|
||||
|
||||
return pnl_score + trade_score + diversity_score
|
||||
|
||||
|
||||
class WalletPoolBuilder:
|
||||
"""Builds and refreshes the target wallet pool."""
|
||||
|
||||
def __init__(self, db: CopyTraderDatabase):
|
||||
self.db = db
|
||||
self.settings = get_settings()
|
||||
self.gamma = GammaAPIClient()
|
||||
self.data = DataAPIClient()
|
||||
|
||||
async def build_pool_async(self, max_markets: int = 200) -> List[dict]:
|
||||
"""Async build with parallelism + per-call timeout.
|
||||
|
||||
Returns list of wallet dicts (already filtered by min_pnl/trades/categories).
|
||||
"""
|
||||
settings = self.settings
|
||||
concurrency = settings.wallet_pool_concurrency
|
||||
timeout = settings.wallet_pool_request_timeout
|
||||
progress_every = settings.wallet_pool_progress_every
|
||||
|
||||
logger.info(
|
||||
f"[pool] Building top wallet pool from up to {max_markets} markets "
|
||||
f"(concurrency={concurrency}, request_timeout={timeout}s)"
|
||||
)
|
||||
|
||||
# Phase 1: get top events (single call)
|
||||
try:
|
||||
events = await asyncio.to_thread(
|
||||
self.gamma.get_active_events_by_volume, max_markets
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[pool] Gamma API failed: {e}")
|
||||
return []
|
||||
|
||||
condition_ids: Set[str] = set()
|
||||
for ev in events:
|
||||
for m in (ev.get("markets") or []):
|
||||
cid = m.get("conditionId")
|
||||
if cid and not m.get("closed"):
|
||||
condition_ids.add(cid)
|
||||
logger.info(f"[pool] Found {len(condition_ids)} candidate markets")
|
||||
|
||||
# Phase 2: parallel holder fetch
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
empty_streak = 0
|
||||
|
||||
async def fetch_holders(cid: str) -> List[dict]:
|
||||
nonlocal empty_streak
|
||||
async with semaphore:
|
||||
if empty_streak >= settings.wallet_pool_backoff_emails:
|
||||
logger.info(f"[pool] {empty_streak} consecutive empty responses, sleeping 15s")
|
||||
await asyncio.sleep(15)
|
||||
empty_streak = 0
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
self.data.get_top_holders, cid, 30, timeout
|
||||
)
|
||||
if isinstance(result, list) and len(result) == 0:
|
||||
empty_streak += 1
|
||||
else:
|
||||
empty_streak = 0
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.debug(f"[pool] holders failed for {cid[:10]}: {e}")
|
||||
empty_streak += 1
|
||||
return []
|
||||
|
||||
market_tasks = [fetch_holders(cid) for cid in condition_ids]
|
||||
candidates: Dict[str, int] = {}
|
||||
completed = 0
|
||||
total = len(market_tasks)
|
||||
|
||||
for coro in asyncio.as_completed(market_tasks):
|
||||
holders = await coro
|
||||
completed += 1
|
||||
# Response shape: [{"token": "...", "positions": [...]}]
|
||||
for token_wrapper in holders:
|
||||
for pos in (token_wrapper.get("positions") or []):
|
||||
addr = pos.get("proxyWallet")
|
||||
if addr:
|
||||
candidates[addr] = candidates.get(addr, 0) + 1
|
||||
if completed % progress_every == 0 or completed == total:
|
||||
logger.info(
|
||||
f"[pool] holders: scanned {completed}/{total} markets "
|
||||
f"({len(candidates)} unique wallets so far)"
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
logger.warning("[pool] No candidate wallets found")
|
||||
return []
|
||||
|
||||
logger.info(f"[pool] Found {len(candidates)} candidate wallets")
|
||||
|
||||
# Phase 3: parallel position profile fetch
|
||||
async def fetch_wallet(addr: str) -> dict:
|
||||
async with semaphore:
|
||||
try:
|
||||
positions = await asyncio.to_thread(
|
||||
self.data.get_positions, addr, 500, timeout * 2
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[pool] positions failed for {addr[:10]}: {e}")
|
||||
positions = []
|
||||
|
||||
cash_pnl = sum(float(p.get("cashPnl") or 0) for p in positions)
|
||||
realized_pnl = sum(
|
||||
float(p.get("realizedPnl") or 0) for p in positions
|
||||
)
|
||||
total_pnl = cash_pnl + realized_pnl
|
||||
trades = len(positions)
|
||||
categories = len({
|
||||
p.get("eventSlug") or p.get("slug")
|
||||
for p in positions
|
||||
if p.get("eventSlug") or p.get("slug")
|
||||
})
|
||||
|
||||
score = compute_health_score(
|
||||
pnl_30d=total_pnl,
|
||||
total_pnl=total_pnl,
|
||||
trades=trades,
|
||||
categories=categories,
|
||||
)
|
||||
return {
|
||||
"address": addr,
|
||||
"source": "top_holders",
|
||||
"pnl_30d_usd": total_pnl,
|
||||
"pnl_total_usd": total_pnl,
|
||||
"trades_count": trades,
|
||||
"categories_count": categories,
|
||||
"health_score": score,
|
||||
"credibility": settings.bayesian_prior_skill,
|
||||
"last_seen_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
wallet_tasks = [fetch_wallet(addr) for addr in candidates]
|
||||
wallets: List[dict] = []
|
||||
completed = 0
|
||||
total = len(wallet_tasks)
|
||||
|
||||
for coro in asyncio.as_completed(wallet_tasks):
|
||||
wallet = await coro
|
||||
completed += 1
|
||||
if (
|
||||
wallet["pnl_total_usd"] >= settings.wallet_pnl_min_usd
|
||||
and wallet["trades_count"] >= settings.wallet_min_trades
|
||||
and wallet["categories_count"] >= settings.wallet_min_categories
|
||||
):
|
||||
wallets.append(wallet)
|
||||
if completed % progress_every == 0 or completed == total:
|
||||
logger.info(
|
||||
f"[pool] profiles: scanned {completed}/{total} wallets "
|
||||
f"({len(wallets)} passed filter so far)"
|
||||
)
|
||||
|
||||
wallets.sort(key=lambda w: w["health_score"], reverse=True)
|
||||
result = wallets[:settings.wallet_pool_size]
|
||||
logger.info(
|
||||
f"[pool] Selected top {len(result)} wallets (from {len(wallets)} candidates)"
|
||||
)
|
||||
return result
|
||||
|
||||
def build_pool(self, max_markets: int = 200) -> List[dict]:
|
||||
"""Sync wrapper for CLI usage."""
|
||||
return asyncio.run(self.build_pool_async(max_markets))
|
||||
|
||||
def refresh(self, max_markets: int = 200) -> int:
|
||||
"""Build pool and persist. Returns count. CLI/sync only — call
|
||||
build_pool_async() directly from async contexts."""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
logger.warning(
|
||||
"[pool] refresh() called from async context; "
|
||||
"use build_pool_async() instead"
|
||||
)
|
||||
return 0
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
pool = asyncio.run(self.build_pool_async(max_markets))
|
||||
now = datetime.now().isoformat()
|
||||
for w in pool:
|
||||
w.setdefault("added_at", now)
|
||||
self.db.upsert_wallet_target(w)
|
||||
return len(pool)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Shared HTTP client factory with proxy support."""
|
||||
import httpx
|
||||
from src.config.settings import get_settings
|
||||
|
||||
|
||||
def get_proxy_config() -> str | None:
|
||||
settings = get_settings()
|
||||
proxy = settings.http_proxy.strip()
|
||||
return proxy if proxy else None
|
||||
|
||||
|
||||
def get_client(**kwargs) -> httpx.Client:
|
||||
proxy = get_proxy_config()
|
||||
if proxy:
|
||||
kwargs.setdefault("proxy", proxy)
|
||||
kwargs.setdefault("timeout", 30.0)
|
||||
return httpx.Client(**kwargs)
|
||||
|
||||
|
||||
def get_async_client(**kwargs) -> httpx.AsyncClient:
|
||||
proxy = get_proxy_config()
|
||||
if proxy:
|
||||
kwargs.setdefault("proxy", proxy)
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Logging utilities."""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
|
||||
from src.config import get_settings
|
||||
|
||||
|
||||
def setup_logging(level: Optional[str] = None) -> None:
|
||||
settings = get_settings()
|
||||
log_level = level or settings.log_level
|
||||
|
||||
console = Console()
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format="%(message)s",
|
||||
datefmt="[%X]",
|
||||
handlers=[
|
||||
RichHandler(
|
||||
console=console,
|
||||
rich_tracebacks=True,
|
||||
show_path=False,
|
||||
)
|
||||
],
|
||||
)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
logging.getLogger("telegram").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
class BotLogger:
|
||||
"""Custom logger for the copy-trader with formatted output."""
|
||||
|
||||
def __init__(self):
|
||||
self.console = Console()
|
||||
self.logger = logging.getLogger("copy_trader")
|
||||
|
||||
def startup(self, wallet_count: int, capital: float) -> None:
|
||||
self.console.print(
|
||||
f"\n[bold green]{'='*60}[/bold green]\n"
|
||||
f"[bold green]📈 COPY TRADER STARTED[/bold green]\n"
|
||||
f"[bold green]{'='*60}[/bold green]\n"
|
||||
f"[green]Target Wallets:[/green] {wallet_count}\n"
|
||||
f"[green]Capital:[/green] ${capital:,.0f}\n"
|
||||
f"[bold green]{'='*60}[/bold green]\n"
|
||||
)
|
||||
|
||||
def signal(self, market: str, side: str, strength: float, n_wallets: int) -> None:
|
||||
self.console.print(
|
||||
f"\n[bold magenta]{'='*60}[/bold magenta]\n"
|
||||
f"[bold magenta]🎯 COPY SIGNAL[/bold magenta]\n"
|
||||
f"[bold magenta]{'='*60}[/bold magenta]\n"
|
||||
f"[green]Market:[/green] {market[:60]}\n"
|
||||
f"[green]Direction:[/green] {side}\n"
|
||||
f"[green]Strength:[/green] {strength:.3f}\n"
|
||||
f"[green]Source Wallets:[/green] {n_wallets}\n"
|
||||
f"[bold magenta]{'='*60}[/bold magenta]\n"
|
||||
)
|
||||
|
||||
def info(self, message: str) -> None:
|
||||
self.console.print(f"[blue]ℹ️[/blue] {message}")
|
||||
|
||||
def error(self, message: str) -> None:
|
||||
self.console.print(f"[bold red]❌ ERROR:[/bold red] {message}")
|
||||
|
||||
def separator(self) -> None:
|
||||
self.console.print(f"[dim]{'─'*60}[/dim]")
|
||||
Reference in New Issue
Block a user