#!/usr/bin/env python3 """Backtest the copy-trade strategy over a recent window. Replays each watched wallet's real trades through the same copy logic the live bot uses — % -of-bankroll sizing, no-backfill, proportional adds/exits, risk caps — but fills at the wallet's actual historical trade price. Outcomes are marked from how each market resolved (curPrice 1/0 from closed-positions) or, for still-open positions, the current market price. python3 backtest.py # last 7 days, config.json watchlist python3 backtest.py --days 7 This is an approximation. Notably the price guard is a near no-op in backtest (we fill at their price, with no 12s real-time lag), so results are slightly optimistic. Wallets whose history doesn't reach before the window are flagged. """ import argparse import json import time from collections import defaultdict import smart_money as sm import sys, os; sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) # copytrade lives at the repo root since 2026-07-08 from copytrade import clob_price, DEFAULT_CONFIG, load_json LOOKBACK_DAYS = 21 # how far before the window we try to read, for seed MAX_TRADES = 4000 # pagination cap per wallet def fetch_trades(wallet, since_ts): """Newest-first TRADE activity back to ~since_ts (capped).""" out, off = [], 0 while off < MAX_TRADES: page = sm.get_json("/activity", {"user": wallet, "type": "TRADE", "limit": 500, "offset": off}) if not page: break out += page off += 500 if len(page) < 500 or page[-1].get("timestamp", 0) < since_ts: break return out def mark_map(wallets): """asset(token) -> current/resolved price (curPrice). Merges each wallet's open /positions (curPrice = live price, or 0/1 if it resolved but isn't redeemed yet) and /closed-positions (resolved 1/0). This is what lets us mark a position we still hold at its true value rather than falling back to entry price. """ res = {} for w in wallets: for endpoint in ("/positions", "/closed-positions"): off = 0 while off < 1000: params = {"user": w, "limit": 50, "offset": off} if endpoint == "/closed-positions": params.update(sortBy="TIMESTAMP", sortDirection="DESC") else: params["sizeThreshold"] = 0.0 page = sm.get_json(endpoint, params) if not page: break for p in page: if p.get("asset") is not None: # closed-positions wins ties (definitively resolved) if endpoint == "/closed-positions" or p["asset"] not in res: res[p["asset"]] = p.get("curPrice", 0) off += 50 if len(page) < 50: break return res def backtest(cfg, days): wallets = cfg["watchlist"] now = time.time() window_start = now - days * 86400 lookback_start = window_start - LOOKBACK_DAYS * 86400 stake = cfg["bankroll_usd"] * cfg["bankroll_pct"] risk = cfg["risk"] print(f"Backtesting {len(wallets)} wallets over the last {days} days " f"· ${stake:.0f}/entry · caps: ${risk['max_trade_usd']:.0f}/trade, " f"${risk['daily_spend_cap_usd']:.0f}/day, " f"${risk['max_total_exposure_usd']:.0f} exposure\n") # gather every wallet's trades + per-wallet data reach all_trades, reach = [], {} for w in wallets: ts = fetch_trades(w, lookback_start) for t in ts: t["_wallet"] = w all_trades += ts oldest = min((t["timestamp"] for t in ts), default=now) reach[w] = (now - oldest) / 86400 all_trades.sort(key=lambda t: t["timestamp"]) res = mark_map(wallets) # replay state their_pos = defaultdict(float) # (wallet, token) -> shares seed_tokens = set() # (wallet, token) held before window my = {} # token -> {shares, cost, title, outcome, wallet} daily_spend = defaultdict(float) # 'YYYY-MM-DD' -> usd deployed = 0.0 realized = 0.0 n_open = n_add = n_exit = n_skip_guard = n_skip_cap = n_skip_backfill = 0 price_cache = {} def cur_price(token, side): key = (token, side) if key not in price_cache: price_cache[key] = clob_price(token, side) return price_cache[key] def exposure(): return sum(p["cost"] for p in my.values()) for t in all_trades: w, token = t["_wallet"], t.get("asset") side, size, price = t.get("side"), t.get("size", 0), t.get("price", 0) key = (w, token) prev = their_pos[key] # pre-window trades only build their position (establish the seed) if t["timestamp"] < window_start: seed_tokens.add(key) their_pos[key] = prev + size if side == "BUY" else max(0.0, prev - size) continue label = f"{t.get('outcome','?')} · {t.get('title','?')[:44]}" if side == "BUY": mine = my.get(token) if mine is None and key in seed_tokens: n_skip_backfill += 1 elif mine is None: # fresh OPEN if not (risk["min_price"] <= price <= risk["max_price"]): n_skip_guard += 1 else: day = time.strftime("%Y-%m-%d", time.gmtime(t["timestamp"])) cap = min(stake, risk["max_trade_usd"], risk.get("max_position_usd", float("inf")), risk["daily_spend_cap_usd"] - daily_spend[day], risk["max_total_exposure_usd"] - exposure()) if cap < risk["min_order_usd"] or len(my) >= risk["max_open_positions"]: n_skip_cap += 1 else: sh = cap / price my[token] = {"shares": sh, "cost": cap, "title": t.get("title", "?"), "outcome": t.get("outcome", "?"), "wallet": w} deployed += cap daily_spend[day] += cap n_open += 1 else: # proportional ADD frac = size / prev if prev > 0 else 0 add_sh = mine["shares"] * frac add_usd = add_sh * price day = time.strftime("%Y-%m-%d", time.gmtime(t["timestamp"])) cap = min(add_usd, risk["max_trade_usd"], risk.get("max_position_usd", float("inf")) - mine["cost"], risk["daily_spend_cap_usd"] - daily_spend[day], risk["max_total_exposure_usd"] - exposure()) if cap >= risk["min_order_usd"]: sh = cap / price mine["shares"] += sh mine["cost"] += cap deployed += cap daily_spend[day] += cap n_add += 1 their_pos[key] = prev + size elif side == "SELL": mine = my.get(token) if mine and mine["shares"] > 0: frac = 1.0 if prev <= 0 else min(1.0, size / prev) sell_sh = min(mine["shares"], mine["shares"] * frac) if sell_sh > 0: sold_frac = sell_sh / mine["shares"] cost_out = mine["cost"] * sold_frac proceeds = sell_sh * price realized += proceeds - cost_out mine["shares"] -= sell_sh mine["cost"] -= cost_out n_exit += 1 if mine["shares"] <= 0.01: del my[token] their_pos[key] = max(0.0, prev - size) # mark remaining open positions to resolution or current price unrealized = 0.0 open_rows = [] for token, p in my.items(): mark = res.get(token) if mark is None: mark = cur_price(token, "sell") if mark is None: mark = p["cost"] / p["shares"] # last resort: flat # curPrice at the extremes means the market has resolved if mark <= 0.02: status = "LOST" elif mark >= 0.98: status = "WON" else: status = "open" val = p["shares"] * mark pnl = val - p["cost"] unrealized += pnl open_rows.append((p, mark, pnl, status)) total_pnl = realized + unrealized print(f"{'─'*74}") print(" Per-wallet data reach (how far history extended before today):") for w in wallets: flag = "" if reach[w] >= days + 3 else " ⚠ short history — low confidence" print(f" {w[:12]}… {reach[w]:5.1f} days{flag}") print(f"{'─'*74}") print(f" Copies it would have made:") print(f" {n_open} fresh entries · {n_add} adds · {n_exit} exits/trims") print(f" skipped: {n_skip_backfill} held-before-start, " f"{n_skip_guard} price/range, {n_skip_cap} risk-cap") print(f"{'─'*74}") print(f" Total deployed (bought): ${deployed:>12,.2f}") print(f" Realized P&L (closed legs): ${realized:>+12,.2f}") print(f" Unrealized P&L (still held): ${unrealized:>+12,.2f}") print(f" ── Net P&L: ${total_pnl:>+12,.2f}" f" ({(total_pnl/deployed*100) if deployed else 0:+.1f}% on deployed)") print(f"{'─'*74}") if open_rows: won = sum(1 for _, _, _, s in open_rows if s == "WON") lost = sum(1 for _, _, _, s in open_rows if s == "LOST") opn = sum(1 for _, _, _, s in open_rows if s == "open") print(f" Positions still on the book at window end: {len(open_rows)} " f"({won} won, {lost} lost, {opn} open & marked-to-market)") for p, mark, pnl, status in sorted(open_rows, key=lambda x: x[2]): print(f" {status:>5} {pnl:>+9,.2f} {p['outcome']} · {p['title'][:40]}") print() def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--config", default="config.json") ap.add_argument("--days", type=int, default=7) args = ap.parse_args() cfg = {**DEFAULT_CONFIG, **load_json(args.config, {})} cfg["risk"] = {**DEFAULT_CONFIG["risk"], **cfg.get("risk", {})} backtest(cfg, args.days) if __name__ == "__main__": main()