初步完成项目,可以监控给出入场信号

This commit is contained in:
2026-07-14 00:37:22 +08:00
commit a1963e58ed
31 changed files with 3566 additions and 0 deletions
View File
+3
View File
@@ -0,0 +1,3 @@
from src.config.settings import Settings, get_settings
__all__ = ["Settings", "get_settings"]
+89
View File
@@ -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()
+161
View File
@@ -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
View File
+229
View File
@@ -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
View File
@@ -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()
View File
+34
View File
@@ -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())
View File
+255
View File
@@ -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)"
)
+83
View File
@@ -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
+117
View File
@@ -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
+69
View File
@@ -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)
+65
View File
@@ -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
+135
View File
@@ -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
+217
View File
@@ -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)
View File
+24
View File
@@ -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)
+73
View File
@@ -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]")