From 34b39fa1ba3e5f202b1bc5c923bc96df9e3f394d Mon Sep 17 00:00:00 2001 From: jaxperro Date: Sat, 13 Jun 2026 00:22:10 -0400 Subject: [PATCH] Add edge-research tooling and document the full research log Adds edge_research.py (scan ~2000 wallets for reliable/copyable weekly edge), lookback.py (long-window half-split out-of-sample read), and table_77.py (aggregate a wallet set to CSV). README now leads with a research log capturing the key findings: win-rate survivorship bias, win-rate != EV, flat-size copying is -EV, the reliable edge is rare and skews to young accounts, and ROI is inversely related to bet size. Generated data files are gitignored. Co-Authored-By: Claude Fable 5 --- .gitignore | 5 + README.md | 80 +++++++++++++++- edge_research.py | 239 +++++++++++++++++++++++++++++++++++++++++++++++ lookback.py | 119 +++++++++++++++++++++++ table_77.py | 87 +++++++++++++++++ 5 files changed, 527 insertions(+), 3 deletions(-) create mode 100644 edge_research.py create mode 100644 lookback.py create mode 100644 table_77.py diff --git a/.gitignore b/.gitignore index 7e34287f..03f9814c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,8 @@ config.json copytrade_state.json *.log *.tmp + +# generated research data (regenerable via edge_research.py / table_77.py) +edge_metrics.jsonl +edge_profitable.json +copyable_77.csv diff --git a/README.md b/README.md index 54544265..fd63d927 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,24 @@ # Polymarket Smart Money -Finds Polymarket wallets that **win more than 75% of their resolved bets** and -**bet multiple times per week** — the "smart money" worth watching. +Tools to find Polymarket wallets worth following, copy-trade them (paper or +live), and backtest the strategy. Zero dependencies — Python 3 stdlib only +(except live trading, which needs `py-clob-client`). -Zero dependencies. One file, Python 3 stdlib only. +> **Start here — read [What we learned](#what-we-learned-research-log).** The +> project began as "find wallets winning >75% of their bets." That metric turned +> out to be an artifact, and the research below changed what we actually measure. +> Don't fund anything before reading it. + +## Tools + +| File | What it does | +|------|--------------| +| `smart_money.py` | Dashboard + scanner. Ranks leaderboard wallets by **true** win rate. | +| `edge_research.py` | Scans up to ~2000 wallets for a reliable, copyable weekly edge (consistency metrics + copyability). | +| `lookback.py` | Deep-dive a short list over a long window, split into halves for out-of-sample reads. | +| `table_77.py` | Aggregate a filtered wallet set into one CSV (ROI, total staked, consistency). | +| `copytrade.py` | Copy-trade engine — mirror a watchlist (paper by default, live gated). | +| `backtest.py` | Replay a watchlist over a recent window and mark outcomes. | ## Run the dashboard @@ -126,3 +141,62 @@ and runtime state never get committed. - Very high-volume / market-maker wallets (thousands of fills) can't be cleanly backtested via the public API — too many fills, no historical position snapshot. + +## What we learned (research log) + +The honest story of what the data showed, in order. Each finding killed an +assumption the previous step relied on. + +**1. Win rate was an illusion (survivorship bias).** +Polymarket only redeems *winning* shares; losing shares are worth $0 and sit +unredeemed in `/positions` at `curPrice 0` forever, never entering +`/closed-positions`. Measuring win rate over `/closed-positions` alone counts +almost only winners. Examples: NiNo999 read 90.6%, true rate **48.3%**; Boggs +read 73.4%, true **50.3%**. Fixed by unioning both endpoints over a window. +**Takeaway: a high reported win rate is a red flag for a bias bug, not a sharp.** + +**2. With the honest metric, nobody wins 75%.** +Across 25 top wallets, true win rates clustered at a **median 49%** (coin flip), +max ~60%. Zero passed a 75% bar. Win rate is the wrong thing to rank on. + +**3. Win rate ≠ profit; raw PnL ≠ reliability.** +`surfandturf` won 54.8% and made millions; `Latina` (leaderboard #1 all-time) +won 43% and was **−$3.8M over 90 days**. And wallets with big total PnL often +got there on one or two outlier weeks (38% green weeks) — a lottery, not an +edge. The signal that finds reliable money is **weekly consistency**: % of weeks +green, profit factor, weekly Sharpe — measured per week, with enough weeks. + +**4. Flat-size copy-trading is −EV.** +A 7-day backtest of four "top" wallets returned **−48%**. At ~50% entry hit +rates, mirroring entries at flat size just pays the spread on coin flips. A +profitable wallet's edge lives in *sizing and entry prices*, which copying +entries does not reproduce. (The backtest also exposed a missing per-position +cap — proportional adds could balloon one market to the whole exposure limit.) + +**5. A reliable edge looks real but rare — and skews young.** +Scanning 1,500 wallets over 120 days: 1,017 had history, 199 passed a +consistency screen, **77 were copyable** (hold-to-resolution ≥70%). But ~7.5% +of wallets passing by chance is exactly what randomness produces over 1,017 +coin-flippers — so some of the 77 are luck. Worse, a 240-day lookback showed +the "best" wallets are **young accounts** (surfandturf's oldest bet: 72 days; +joblessfinalboss: 79). New accounts that get hot rise to the leaderboard and +pass the screen; the ones that flamed out are delisted. **The most impressive +short-term performers are the least trustworthy.** + +**6. ROI and size are inversely related.** +Among the 77 copyable wallets, the highest-ROI ones bet small (dnte: 57% ROI on +$109K), while the biggest bettors scalp thin edges (elmcap2: $114M staked, +**0.4%** ROI). `surfandturf` was the lone anomaly — big *and* high-ROI ($27.9M +staked at 16%) — which makes it either the best find or the biggest variance +story. At 72 days old, we can't yet tell. + +### Where this leaves the strategy + +- **Rank on risk-adjusted consistency** (% green weeks × profit factor × + Sharpe), never win rate or raw PnL. +- **Require account longevity** — distrust anything under ~4–6 months. +- **Validate out-of-sample** (walk-forward: select on an early window, measure a + later one) before trusting any wallet list. This is the decisive open step. +- **Copying entries ≠ copying edge.** A working strategy likely needs to model + sizing/pricing, or pivot to a consensus signal (bet where many vetted wallets + agree) rather than blind mirroring. diff --git a/edge_research.py b/edge_research.py new file mode 100644 index 00000000..49c1e8df --- /dev/null +++ b/edge_research.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Scan many wallets for a RELIABLE, COPYABLE weekly edge. + +Two passes: + 1. metrics — for every candidate, bucket resolved-bet PnL by week over the + window and compute consistency (% green weeks, profit factor, Sharpe, ROI). + Results stream to a JSONL file so a long run is crash-safe. + 2. copyability — for the wallets that look profitable, pull /activity and + measure how much they hold to resolution (mirrorable) vs trade around + (not mirrorable by copying entries). + + python3 edge_research.py --pool 1500 --days 120 + +Outputs: edge_metrics.jsonl (raw, all wallets) + edge_profitable.json (filtered + copyability, ranked) +""" + +import argparse +import json +import os +import statistics +import sys +import time +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor, as_completed + +import smart_money as sm + +WEEK = 7 * 86400 +MAX_PAGES = 40 # per endpoint, bounds runtime on hyperactive wallets + + +def _parse_end(end): + if not end: + return 0 + end = end.replace("Z", "") + for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"): + try: + return time.mktime(time.strptime(end, fmt)) + except ValueError: + continue + return 0 + + +def candidates(pool): + seen = {} + for window in ("7d", "30d", "all"): + offset = 0 + while offset < pool and offset < 2000: + page = sm.get_json("/v1/leaderboard", + {"window": window, "limit": 50, "offset": offset}) + if not page: + break + for u in page: + w = u.get("proxyWallet") + if w and w not in seen: + seen[w] = {"wallet": w, + "username": u.get("userName") or w[:10] + "...", + "lb_pnl": u.get("pnl", 0)} + offset += 50 + if len(page) < 50: + break + if len(seen) >= pool: + break + return list(seen.values())[:pool] + + +def resolved_with_stake(wallet, cutoff): + now = time.time() + out = [] + off = 0 + while off < MAX_PAGES * 50: + page = sm.get_json("/closed-positions", + {"user": wallet, "limit": 50, "offset": off, + "sortBy": "TIMESTAMP", "sortDirection": "DESC"}) + if not page: + break + for p in page: + if p.get("timestamp", 0) >= cutoff: + out.append({"ts": p["timestamp"], "pnl": p.get("realizedPnl", 0), + "stake": p.get("avgPrice", 0) * p.get("totalBought", 0)}) + off += 50 + if len(page) < 50 or page[-1].get("timestamp", 0) < cutoff: + break + off = 0 + while off < MAX_PAGES * 50: + page = sm.get_json("/positions", + {"user": wallet, "limit": 50, "offset": off, + "sizeThreshold": 0.0}) + if not page: + break + for p in page: + end = _parse_end(p.get("endDate")) + if cutoff <= end < now: + out.append({"ts": end, "pnl": p.get("cashPnl", 0), + "stake": p.get("initialValue", 0)}) + off += 50 + if len(page) < 50: + break + return out + + +def metrics(cand, cutoff): + bets = resolved_with_stake(cand["wallet"], cutoff) + if len(bets) < 20: + return None + by_week = defaultdict(lambda: [0.0, 0.0]) + for b in bets: + wk = int(b["ts"] // WEEK) + by_week[wk][0] += b["pnl"] + by_week[wk][1] += b["stake"] + weeks = sorted(by_week) + wpnl = [by_week[w][0] for w in weeks] + wroi = [by_week[w][0] / by_week[w][1] if by_week[w][1] else 0 for w in weeks] + total_pnl = sum(wpnl) + total_stake = sum(by_week[w][1] for w in weeks) + gw = sum(p for p in wpnl if p > 0) + gl = abs(sum(p for p in wpnl if p < 0)) + mean_roi = statistics.mean(wroi) + std_roi = statistics.pstdev(wroi) if len(wroi) > 1 else 0 + return { + "wallet": cand["wallet"], "username": cand["username"], + "lb_pnl": round(cand["lb_pnl"]), + "n_weeks": len(weeks), "n_bets": len(bets), + "pct_weeks_pos": round(sum(1 for p in wpnl if p > 0) / len(weeks) * 100), + "mean_weekly_roi": round(mean_roi * 100, 1), + "weekly_sharpe": round(mean_roi / std_roi, 2) if std_roi else 0, + "profit_factor": round(gw / gl, 2) if gl else 999, + "total_pnl": round(total_pnl), + "total_roi": round(total_pnl / total_stake * 100, 1) if total_stake else 0, + } + + +def copyability(wallet): + trades, off = [], 0 + while off < 2000: # cap fills for speed + p = sm.get_json("/activity", + {"user": wallet, "type": "TRADE", "limit": 500, "offset": off}) + if not p: + break + trades += p + off += 500 + if len(p) < 500: + break + by_mkt = defaultdict(lambda: {"buy_usd": 0.0, "sell_usd": 0.0, "sold": False}) + for t in trades: + m = by_mkt[t.get("conditionId")] + if t.get("side") == "BUY": + m["buy_usd"] += t.get("usdcSize", 0) + else: + m["sell_usd"] += t.get("usdcSize", 0) + m["sold"] = True + n = len(by_mkt) or 1 + hold = sum(1 for m in by_mkt.values() if not m["sold"]) + return {"markets": len(by_mkt), "hold_pct": round(hold / n * 100), + "fills": len(trades)} + + +def run(pool, days, workers): + cutoff = time.time() - days * 86400 + out_path, prof_path = "edge_metrics.jsonl", "edge_profitable.json" + print(f"[{time.strftime('%H:%M:%S')}] pulling up to {pool} candidates...", flush=True) + cands = candidates(pool) + print(f"[{time.strftime('%H:%M:%S')}] {len(cands)} candidates · " + f"window {days}d · analyzing (workers={workers})", flush=True) + + done = kept = 0 + with open(out_path, "w") as fout, ThreadPoolExecutor(max_workers=workers) as ex: + futs = {ex.submit(metrics, c, cutoff): c for c in cands} + for f in as_completed(futs): + done += 1 + try: + r = f.result() + except Exception: + r = None + if r: + kept += 1 + fout.write(json.dumps(r) + "\n") + fout.flush() + if done % 50 == 0 or done == len(cands): + print(f"[{time.strftime('%H:%M:%S')}] {done}/{len(cands)} analyzed " + f"· {kept} with enough history", flush=True) + + rows = [json.loads(l) for l in open(out_path)] + # "looks profitable" screen + prof = [r for r in rows if r["n_weeks"] >= max(4, days // 7 * 0.4) + and r["n_bets"] >= 30 and r["total_pnl"] > 0 and r["total_roi"] > 0 + and r["pct_weeks_pos"] >= 60 and r["profit_factor"] >= 1.3] + print(f"\n[{time.strftime('%H:%M:%S')}] {len(prof)} wallets pass the profitable " + f"screen · checking copyability...", flush=True) + + with ThreadPoolExecutor(max_workers=workers) as ex: + futs = {ex.submit(copyability, r["wallet"]): r for r in prof} + for f in as_completed(futs): + r = futs[f] + try: + r["copy"] = f.result() + except Exception: + r["copy"] = {"markets": 0, "hold_pct": 0, "fills": 0} + + for r in prof: + r["copyable"] = r["copy"]["hold_pct"] >= 70 + # composite: reward consistency, profit factor, and ROI + r["score"] = round(r["pct_weeks_pos"] / 100 * r["profit_factor"] + * (1 + r["total_roi"] / 100), 2) + prof.sort(key=lambda r: (r["copyable"], r["score"]), reverse=True) + json.dump(prof, open(prof_path, "w"), indent=2) + + print(f"\n{'='*94}") + print(f" PROFITABLE & COPYABLE wallets (window {days}d, pool {len(cands)})") + print(f"{'='*94}") + h = (f"{'Trader':<20}{'wks':>4}{'bets':>6}{'%wk+':>6}{'PF':>6}" + f"{'Sharpe':>7}{'totROI':>8}{'hold%':>7}{'copy':>6}{'90d PnL':>13}") + print(h) + print("-" * len(h)) + for r in prof: + print(f"{r['username'][:20]:<20}{r['n_weeks']:>4}{r['n_bets']:>6}" + f"{r['pct_weeks_pos']:>5}%{r['profit_factor']:>6.2f}" + f"{r['weekly_sharpe']:>7.2f}{r['total_roi']:>7}%" + f"{r['copy']['hold_pct']:>6}%{'yes' if r['copyable'] else 'no':>6}" + f"{'$'+format(r['total_pnl'], ','):>13}") + print("-" * len(h)) + cop = sum(1 for r in prof if r["copyable"]) + print(f"{len(prof)} profitable · {cop} of them copyable (hold-to-resolution ≥70%)") + print(f"Full detail: {prof_path}\n") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--pool", type=int, default=1500) + ap.add_argument("--days", type=int, default=120) + ap.add_argument("--workers", type=int, default=12) + args = ap.parse_args() + run(args.pool, args.days, args.workers) + + +if __name__ == "__main__": + main() diff --git a/lookback.py b/lookback.py new file mode 100644 index 00000000..9cf9db5d --- /dev/null +++ b/lookback.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""240-day lookback on a short list of wallets, split into halves. + +We selected these wallets on their last 120 days. The *older* half (240->120 +days ago) is data that played no part in selection — so consistency there is +backward out-of-sample evidence the edge is real, not a lucky recent stretch. +""" + +import statistics +import sys +import time +from collections import defaultdict + +import smart_money as sm + +WEEK = 7 * 86400 +PAGES = 160 # generous for this focused 5-wallet run + + +def parse_end(end): + if not end: + return 0 + end = end.replace("Z", "") + for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"): + try: + return time.mktime(time.strptime(end, fmt)) + except ValueError: + continue + return 0 + + +def resolved(wallet, cutoff): + now = time.time() + out = [] + off = 0 + while off < PAGES * 50: + page = sm.get_json("/closed-positions", + {"user": wallet, "limit": 50, "offset": off, + "sortBy": "TIMESTAMP", "sortDirection": "DESC"}) + if not page: + break + for p in page: + if p.get("timestamp", 0) >= cutoff: + out.append({"ts": p["timestamp"], "pnl": p.get("realizedPnl", 0), + "stake": p.get("avgPrice", 0) * p.get("totalBought", 0)}) + off += 50 + if len(page) < 50 or page[-1].get("timestamp", 0) < cutoff: + break + off = 0 + while off < PAGES * 50: + page = sm.get_json("/positions", + {"user": wallet, "limit": 50, "offset": off, + "sizeThreshold": 0.0}) + if not page: + break + for p in page: + end = parse_end(p.get("endDate")) + if cutoff <= end < now: + out.append({"ts": end, "pnl": p.get("cashPnl", 0), + "stake": p.get("initialValue", 0)}) + off += 50 + if len(page) < 50: + break + return out + + +def stats(bets): + if not bets: + return None + by_week = defaultdict(lambda: [0.0, 0.0]) + for b in bets: + wk = int(b["ts"] // WEEK) + by_week[wk][0] += b["pnl"] + by_week[wk][1] += b["stake"] + weeks = sorted(by_week) + wpnl = [by_week[w][0] for w in weeks] + wroi = [by_week[w][0] / by_week[w][1] if by_week[w][1] else 0 for w in weeks] + tot_pnl = sum(wpnl) + tot_stake = sum(by_week[w][1] for w in weeks) + gw = sum(p for p in wpnl if p > 0) + gl = abs(sum(p for p in wpnl if p < 0)) + mean = statistics.mean(wroi) + std = statistics.pstdev(wroi) if len(wroi) > 1 else 0 + return { + "weeks": len(weeks), "bets": len(bets), + "green": round(sum(1 for p in wpnl if p > 0) / len(weeks) * 100), + "pf": round(gw / gl, 2) if gl else 999, + "sharpe": round(mean / std, 2) if std else 0, + "roi": round(tot_pnl / tot_stake * 100, 1) if tot_stake else 0, + "pnl": round(tot_pnl), + } + + +def line(label, s): + if not s: + print(f" {label:<8} (no resolved bets in this period)") + return + print(f" {label:<8} {s['weeks']:>2}wk {s['bets']:>5}bets " + f"{s['green']:>3}%grn PF {s['pf']:>6} Sharpe {s['sharpe']:>5} " + f"ROI {s['roi']:>6}% ${s['pnl']:>12,}") + + +def main(wallets): + now = time.time() + mid = now - 120 * 86400 + for name, w in wallets: + bets = resolved(w, now - 240 * 86400) + older = [b for b in bets if b["ts"] < mid] # 240->120d (not used to select) + recent = [b for b in bets if b["ts"] >= mid] # 120->0d (selection window) + print(f"\n{name} ({w[:16]}…)") + line("240d all", stats(bets)) + line("older½", stats(older)) # out-of-sample + line("recent½", stats(recent)) # in-sample + + +if __name__ == "__main__": + # name, wallet — passed as alternating argv or hardcoded by caller + pairs = [(sys.argv[i], sys.argv[i + 1]) for i in range(1, len(sys.argv), 2)] + main(pairs) diff --git a/table_77.py b/table_77.py new file mode 100644 index 00000000..4c3ef822 --- /dev/null +++ b/table_77.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Aggregate the 77 copyable wallets into one table: total staked, PnL, ROI, +consistency. Prints sorted by ROI and writes copyable_77.csv.""" + +import csv +import json +import statistics +import time +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor, as_completed + +import smart_money as sm +from lookback import resolved # reuse the 120d+ resolved-bet puller + +WEEK = 7 * 86400 + + +def compute(r): + cutoff = time.time() - 120 * 86400 + bets = resolved(r["wallet"], cutoff) + if not bets: + return None + by_week = defaultdict(lambda: [0.0, 0.0]) + for b in bets: + wk = int(b["ts"] // WEEK) + by_week[wk][0] += b["pnl"] + by_week[wk][1] += b["stake"] + weeks = sorted(by_week) + wpnl = [by_week[w][0] for w in weeks] + wroi = [by_week[w][0] / by_week[w][1] if by_week[w][1] else 0 for w in weeks] + total_bet = sum(b["stake"] for b in bets) + total_pnl = sum(b["pnl"] for b in bets) + gw = sum(p for p in wpnl if p > 0) + gl = abs(sum(p for p in wpnl if p < 0)) + mean = statistics.mean(wroi) + std = statistics.pstdev(wroi) if len(wroi) > 1 else 0 + oldest_days = round((time.time() - min(b["ts"] for b in bets)) / 86400) + return { + "username": r["username"], "wallet": r["wallet"], + "weeks": len(weeks), "bets": len(bets), + "total_bet": round(total_bet), "total_pnl": round(total_pnl), + "roi_pct": round(total_pnl / total_bet * 100, 1) if total_bet else 0, + "pct_weeks_green": round(sum(1 for p in wpnl if p > 0) / len(weeks) * 100), + "profit_factor": round(gw / gl, 2) if gl else 999, + "weekly_sharpe": round(mean / std, 2) if std else 0, + "hold_pct": r["copy"]["hold_pct"], + "history_days": oldest_days, + "avg_bet": round(total_bet / len(bets)) if bets else 0, + } + + +def main(): + cop = [r for r in json.load(open("edge_profitable.json")) if r.get("copyable")] + out = [] + with ThreadPoolExecutor(max_workers=12) as ex: + futs = {ex.submit(compute, r): r for r in cop} + for f in as_completed(futs): + r = f.result() + if r: + out.append(r) + out.sort(key=lambda r: r["roi_pct"], reverse=True) + + cols = ["username", "roi_pct", "total_bet", "total_pnl", "avg_bet", + "pct_weeks_green", "profit_factor", "weekly_sharpe", "weeks", + "bets", "hold_pct", "history_days", "wallet"] + with open("copyable_77.csv", "w", newline="") as f: + w = csv.DictWriter(f, fieldnames=cols) + w.writeheader() + w.writerows(out) + + print(f"{'#':>3} {'Trader':<20}{'ROI%':>7}{'TotalBet':>13}{'TotalPnL':>13}" + f"{'AvgBet':>9}{'%grn':>6}{'PF':>6}{'Shrp':>6}{'wks':>4}{'hist_d':>7}") + print("-" * 100) + for i, r in enumerate(out, 1): + print(f"{i:>3} {r['username'][:20]:<20}{r['roi_pct']:>6}%" + f"{'$'+format(r['total_bet'], ','):>13}{'$'+format(r['total_pnl'], ','):>13}" + f"{'$'+format(r['avg_bet'], ','):>9}{r['pct_weeks_green']:>5}%" + f"{r['profit_factor']:>6.1f}{r['weekly_sharpe']:>6.2f}{r['weeks']:>4}" + f"{r['history_days']:>7}") + print("-" * 100) + print(f"{len(out)} copyable wallets · saved to copyable_77.csv") + print(f" total staked across all: ${sum(r['total_bet'] for r in out):,}") + print(f" median history: {statistics.median([r['history_days'] for r in out])} days") + + +if __name__ == "__main__": + main()