From 3d0bc7f001080ce0e723030e3f5bb12f07f6dfc6 Mon Sep 17 00:00:00 2001 From: jaxperro Date: Thu, 18 Jun 2026 11:16:20 -0600 Subject: [PATCH] Add live/ skilled-wallet scanner + cache; document clean OOS finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit live/: operationalizes the LBS/Yale "skilled ~3%" result against the live data-api. Enumerate recent liquid markets -> top traders -> candidate pool; cache every wallet's resolved bets once in DuckDB (~26k wallets / 12.5M bets, keyed by per-bet resolution time so any cutoff re-scores in seconds); 5-gate skill funnel (n>=15, z>0, BH-FDR, split-half OOS, MM/bot cap); dashboard + daily refresh. Key finding: copying the high-win-rate "favorite-rider" cohort looks +23.6% in-sample but loses -7.4% once selected on pre-June-1 data only (99% -> 68% win rate) — selection bias, reproducing the paper's "lucky winners revert" result on live data. Win rate != edge, again. wide/: bulk subgraph->DuckDB scanner (survivorship-bias-free over all wallets), but the public subgraph is frozen at Jan 2026 -> historical tool only. Large local data (*.duckdb, candidates.json, *_scored.json, history/) gitignored. README + FINDINGS updated with the current logic and the clean result. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 9 + FINDINGS.md | 30 + README.md | 34 ++ live/README.md | 83 +++ live/backtest_june.py | 150 +++++ live/cache.py | 81 +++ live/clean_test.sh | 13 + live/collect.py | 45 ++ live/daily.sh | 26 + live/dashboard.html | 121 ++++ live/dashboard.py | 173 ++++++ live/enumerate.py | 148 +++++ live/skill.py | 159 +++++ live/watch_skilled.json | 1286 +++++++++++++++++++++++++++++++++++++++ wide/README.md | 71 +++ wide/clob_tokens.py | 83 +++ wide/edge.sql | 47 ++ wide/ingest.py | 217 +++++++ wide/run_full.sh | 31 + wide/score.py | 145 +++++ wide/subgraph.py | 118 ++++ 21 files changed, 3070 insertions(+) create mode 100644 live/README.md create mode 100644 live/backtest_june.py create mode 100644 live/cache.py create mode 100755 live/clean_test.sh create mode 100644 live/collect.py create mode 100755 live/daily.sh create mode 100644 live/dashboard.html create mode 100644 live/dashboard.py create mode 100644 live/enumerate.py create mode 100644 live/skill.py create mode 100644 live/watch_skilled.json create mode 100644 wide/README.md create mode 100644 wide/clob_tokens.py create mode 100644 wide/edge.sql create mode 100644 wide/ingest.py create mode 100755 wide/run_full.sh create mode 100644 wide/score.py create mode 100644 wide/subgraph.py diff --git a/.gitignore b/.gitignore index fbeff8ca..0f9aa679 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,12 @@ watcher_state.json huntwide.csv oos.log hunt.log + +# live/ + wide/ — large local data + regenerable artifacts +*.duckdb +*.duckdb.wal +live/candidates.json +live/scored.json +live/*_scored.json +live/watch_prejune*.json +live/history/ diff --git a/FINDINGS.md b/FINDINGS.md index 969ac6ac..0dc1f282 100644 --- a/FINDINGS.md +++ b/FINDINGS.md @@ -116,6 +116,32 @@ real forward (out-of-sample) data on these wallets — observe before you size u - A durable trading edge has to come from *you* (a niche you know), with this tooling built around your judgment. +## The skilled-3% scan, and a clean out-of-sample loss (June 2026) + +External validation arrived: an LBS/Yale study (Gomez-Cram, Guo, Kung, Jensen, +Apr 2026; SSRN 5910522) over 1.72M accounts found only **~3.14%** of traders are +genuinely skilled — measured by randomizing each trader's bet *directions* 10k× +(a Monte-Carlo z-score) and requiring out-of-sample persistence. That is exactly +this project's z-score + `oos.py` method, independently confirmed. + +Built `live/` to operationalize it at scale: enumerate recent liquid markets → +cache every candidate's resolved bets locally (~26k wallets / 12.5M bets, so +re-scoring at any cutoff is seconds) → a 5-gate funnel (n≥15, z>0, BH-FDR, +split-half OOS, MM/bot cap). It surfaced 107 "validated" wallets. + +**The decisive test.** Copying the high-win-rate "favorite-rider" cohort, $1000, +no execution lag, June 1→now: +- selected *through* the test window (look-ahead): 99% win rate, **+23.6%**. +- selected on **pre-June-1 data only** (honest): 68% win rate, **−7.4%** + (−19% on the settled portion). + +The +23.6% was selection bias. Done cleanly, the favorites **lose** — a textbook +reproduction of the paper's "~60% of lucky winners become losers out-of-sample," +now on our own live data. *Lesson reinforced: high win rate is the most +misleading signal on the platform; favorite-riders are uncopyable.* The +underdog/`value` archetype (beats longshot prices) is the only one left worth +testing. + ## Repo layout - `insider.py` — the detector: z-score/p-value, timing/freshness/sizing signals, @@ -124,5 +150,9 @@ real forward (out-of-sample) data on these wallets — observe before you size u - `copyback.py` / `oos.py` — in-sample and out-of-sample copy-trade backtests. - `webhook_receiver.py` — push-based live trade watcher (Alchemy → Discord). - `smart_money.py` — data foundation + dashboard (true-win-rate scanner). +- `live/` — current scanner: cache-backed skilled-3% finder + watchlist + daily + refresh + dashboard. See `live/README.md`. +- `wide/` — bulk subgraph→DuckDB scanner (survivorship-bias-free, all wallets); + public subgraph frozen at Jan 2026, so historical-only. See `wide/README.md`. - `archive/` — the strategies that didn't work, kept for reference. See `archive/README.md`. diff --git a/README.md b/README.md index 99b0ff19..4541a8a8 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ Two refinements separate signal from noise: | `webhook_receiver.py` | Push-based live watcher: Alchemy on-chain webhook → enrich → Discord. | | `watch.json` | The tracked wallet set + edge weights (shared by the watcher and the tracker). | | paper tracker | Client-side `$1,000` running portfolio → [jaxperro.com/trading](https://jaxperro.com/trading) (page lives in the personal site repo). | +| **`live/`** | **The current scanner** — find & track the skilled ~3% from the live API at scale: enumerate → cache → 5-gate skill funnel → dashboard → daily refresh. Caches ~26k wallets / 12.5M bets locally so every re-score is seconds. ([live/README](live/README.md)) | +| `wide/` | Bulk subgraph→DuckDB scanner: survivorship-bias-free over all 1.76M wallets, but the public subgraph is **frozen at Jan 2026**, so it's a historical tool only. ([wide/README](wide/README.md)) | | `archive/` | The six strategies that didn't work, kept for reference ([details](archive/README.md)). | --- @@ -159,6 +161,38 @@ public API (CORS-open) — zero backend, zero added cost. --- +## The skilled-wallet scanner (`live/`) + +The newest pipeline operationalizes the [LBS/Yale finding](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=5910522) +that ~3% of accounts are genuinely skilled. It scans the **live** data-api at +scale and tracks the survivors forward. + +**The 5-gate funnel** — a wallet is "skilled" only if it clears all five: +`n ≥ 15 resolved bets` → `z > 0` (beats its entry prices) → `Benjamini–Hochberg +FDR @5%` → `split-half out-of-sample persists` → `not a market-maker/bot`. Win +rate is never a gate. + +**The cache makes it cheap.** Each wallet's full resolved-bet history is pulled +once into `cache.duckdb` (~26k wallets / 12.5M bets), keyed with per-bet +resolution times — so any cutoff (pre-June-1, full-window, archetypes) re-scores +in **seconds** instead of hours of API pulls. + +**The clean out-of-sample result (June 2026).** Copying the "favorite-rider" +skilled wallets, $1000, no execution lag: + +| Selection | Win rate | Forward P&L (June 1+) | +|-----------|----------|-----------------------| +| In-sample (peeks at test window) | 99% | **+23.6%** | +| **Clean (pre-June-1 data only)** | 68% | **−7.4%** (−19% on settled) | + +The +23.6% was pure selection bias. Selected honestly, the favorites **lose** — +exactly the paper's "~60% of lucky winners become losers out-of-sample." High +win rate ≠ edge, again. (The `value`/longshot archetype — wallets that beat +*underdog* prices — is the one worth testing next.) Full pipeline in +[`live/README.md`](live/README.md). + +--- + ## The honest verdict - **Detection works.** z-score + timing + funding-cluster reliably surfaces diff --git a/live/README.md b/live/README.md new file mode 100644 index 00000000..1ae9350e --- /dev/null +++ b/live/README.md @@ -0,0 +1,83 @@ +# live/ — find & track the genuinely-skilled ~3% + +Finds the small fraction of Polymarket wallets with a *real, repeatable* edge — +the ~3% the [LBS/Yale study](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=5910522) +identifies — from the **live data-api**, caches everything locally, and tracks +them forward. This is the going-forward system; the frozen-subgraph bulk approach +lives in `../wide/`. + +## Why this and not win rate + +Win rate is survivorship-biased and decoupled from edge (see `../FINDINGS.md`). +A wallet is "skilled" only if it **beats the prices it paid** and that edge +**persists out-of-sample**. We reproduced the research's own finding on live +June data: favorite-rider wallets that looked +23.6% in-sample lost **−7.4%** +once selected without look-ahead (see "The clean test" below). + +## The 5-gate funnel (`skill.py`) + +A wallet counts as skilled only if it clears all five: + +1. **n ≥ 15** resolved bets (assessability; the paper's skilled avg ~79). +2. **z = (wins − Σp)/√Σp(1−p)** clearly > 0 — wins above what entry odds implied. + This is the closed form of the paper's "randomize direction 10k×" benchmark. +3. **Benjamini–Hochberg FDR @ 5%** — at scale, thousands clear z>3 by chance. +4. **Split-half out-of-sample** — skill in the earlier half persists in the + recent half (`z_oos > 0`). The gate that separates the real 3% from the lucky. +5. **MM/bot cap** (`n ≤ 2500`) — a thousands-of-bets grinder isn't info-edge. + +Win rate is never a gate — only displayed. Wallets are tagged `value` (beats +underdog/longshot prices — the copyable alpha), `balanced`, or `favorite` +(high win% riding near-certain favorites — real but thin/uncopyable). + +## Pipeline + +| step | script | what | +|------|--------|------| +| enumerate | `enumerate.py [days]` | recent liquid markets (Gamma `end_date_min`) → top traders → candidate pool (`candidates.json`), accumulates across runs | +| cache | `cache.py` / `collect.py` | pull each wallet's resolved bets **once** into `cache.duckdb` (24s → 0.003s on re-read). Stores `res_t` per bet, so any date cutoff reads the same cache | +| score | `skill.py [N]` | the 5-gate funnel over cached candidates → `watch_skilled.json` (webhook-compatible) | +| dashboard | `dashboard.py` | self-contained `dashboard.html` — sortable, archetype-tagged, live recent-trade lookup | +| backtest | `backtest_june.py [arch]` | copy an archetype's June-1+ entries, $1000, no lag → P&L | +| clean test | `clean_test.sh` | **the honest test**: re-select on pre-June-1 data only, then backtest June-1+ forward | + +## The cache is the point + +`cache.duckdb` holds ~26k wallets / 12.5M+ bets, pulled once. Every score — +any archetype, any cutoff, the clean OOS test — now runs in **seconds** instead +of hours of API pulls. `MAX_AGE_DAYS=14`: the broad pool refreshes biweekly; the +watchlist is force-refreshed daily (`cache.invalidate`) for forward tracking. + +## The clean test (why the favorites are a mirage) + +`clean_test.sh` selects favorites using **only bets resolved before June 1**, +then copies their June-1+ entries: + +- **In-sample (contaminated):** 21 favorites, 99% win rate, **+23.6%**. +- **Clean (pre-June-1 selection):** 15 favorites, 68% win rate, **−7.4%** + (−19% on the settled portion). + +The +23.6% was selection bias. This matches the paper: ~60% of "lucky winners" +turn into losers out-of-sample. **Don't copy favorite-riders.** The `value` +archetype (beats underdog prices) is where real alpha may live — test it with +`backtest_june.py value`. + +## Daily (`daily.sh`) + +1. discover (enumerate last 14d) → 2. freshen cache (force-refresh watchlist + +top up new wallets) → 3. re-score (instant from cache) → 4. regenerate dashboard ++ snapshot to `history/`. Schedule via launchd/cron (Mac must be awake). + +## Usage + +```bash +pip install duckdb +python3 enumerate.py 180 # build candidate pool (last 6 months) +python3 collect.py # cache all candidates (one-time, slow; resumable) +python3 skill.py # -> watch_skilled.json (seconds, from cache) +python3 dashboard.py # -> dashboard.html +./clean_test.sh # the out-of-sample verdict +``` + +Local data (`*.duckdb`, `candidates.json`, `*_scored.json`, `history/`) is +gitignored — regenerate via the steps above. diff --git a/live/backtest_june.py b/live/backtest_june.py new file mode 100644 index 00000000..4b5ee404 --- /dev/null +++ b/live/backtest_june.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Forward copy-test: copy the FAVORITE-rider skilled wallets' new entries from +June 1 to now, $1000 bankroll, NO execution lag (we get their exact fill price). + +Method: collect every BUY these wallets made on/after June 1 (data-api), take the +first entry per market (basket consensus, one position per market), deploy $1000 +split equally across them, then settle each via the CLOB winner flag (resolved) +or mark to current price (still open). Reports realized + unrealized P&L. + + python3 backtest_june.py # favorites, from 2026-06-01 + python3 backtest_june.py value # test the value/longshot archetype instead +""" + +import json +import os +import ssl +import sys +import time +import urllib.request +from concurrent.futures import ThreadPoolExecutor + +HERE = os.path.dirname(__file__) +DATA = "https://data-api.polymarket.com" +CLOB = "https://clob.polymarket.com/markets" +CTX = ssl._create_unverified_context() +START = time.mktime(time.strptime("2026-06-01", "%Y-%m-%d")) +BANKROLL = 1000.0 +ARCH = sys.argv[1] if len(sys.argv) > 1 else "favorite" + + +def get(url): + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + return json.loads(urllib.request.urlopen(req, timeout=30, context=CTX).read()) + + +def trades_since(wallet): + """All BUY trades on/after START for one wallet.""" + out, off = [], 0 + for _ in range(8): + try: + page = get(f"{DATA}/activity?user={wallet}&type=TRADE&limit=500&offset={off}") + except Exception: + break + if not page: + break + for t in page: + if (t.get("timestamp") or 0) < START: + return out + if t.get("side") == "BUY" and t.get("conditionId"): + out.append(t) + off += 500 + if len(page) < 500: + break + return out + + +_mkt = {} +def market(cond): + if cond not in _mkt: + try: + _mkt[cond] = get(f"{CLOB}/{cond}") + except Exception: + _mkt[cond] = None + return _mkt[cond] + + +def settle(cond, outcome_idx, outcome_name): + """-> (status, value_per_share). status in won/lost/open/unknown.""" + m = market(cond) + if not m: + return "unknown", None + toks = m.get("tokens") or [] + tok = None + if outcome_idx is not None and outcome_idx < len(toks): + tok = toks[outcome_idx] + if tok is None: + for t in toks: + if (t.get("outcome") or "").lower() == (outcome_name or "").lower(): + tok = t; break + if tok is None: + return "unknown", None + if tok.get("winner") is True: + return "won", 1.0 + if tok.get("winner") is False: + return "lost", 0.0 + return "open", float(tok.get("price") or 0) # not resolved -> mark to price + + +def main(): + wl = json.load(open(os.path.join(HERE, os.environ.get("BT_WATCH", "watch_skilled.json")))) + wallets = [w for w in wl if (w["avg_entry"] >= 0.85 if ARCH == "favorite" + else w["avg_entry"] < 0.5 if ARCH == "value" + else True)] + print(f"{ARCH}: {len(wallets)} wallets · copying BUYs from " + f"{time.strftime('%Y-%m-%d', time.localtime(START))} to now, ${BANKROLL:.0f}, no lag\n", + flush=True) + + # gather every favorite's June+ buys, keep the FIRST entry per market + picks = {} # cond -> trade (earliest) + with ThreadPoolExecutor(max_workers=10) as ex: + for ts in ex.map(trades_since, [w["wallet"] for w in wallets]): + for t in ts: + c = t["conditionId"] + if c not in picks or t["timestamp"] < picks[c]["timestamp"]: + picks[c] = t + n = len(picks) + if not n: + print("no copied entries in the window."); return + stake = BANKROLL / n + print(f"{n} unique markets entered → ${stake:.2f} per position\n", flush=True) + + won = lost = openc = unk = 0 + realized = unreal_val = realized_cost = open_cost = 0.0 + rows = [] + with ThreadPoolExecutor(max_workers=10) as ex: + results = list(ex.map( + lambda kv: (kv[1], settle(kv[0], kv[1].get("outcomeIndex"), kv[1].get("outcome"))), + picks.items())) + for t, (status, vps) in results: + p = t.get("price") or 0.01 + shares = stake / max(p, 0.001) + title = (t.get("title") or "")[:46] + if status == "won": + won += 1; realized += shares * 1.0; realized_cost += stake + rows.append((shares - stake, status, p, title)) + elif status == "lost": + lost += 1; realized += 0.0; realized_cost += stake + rows.append((-stake, status, p, title)) + elif status == "open": + openc += 1; unreal_val += shares * vps; open_cost += stake + rows.append((shares * vps - stake, status, p, title)) + else: + unk += 1; unreal_val += stake; open_cost += stake # unknown -> hold at cost + + realized_pl = realized - realized_cost + equity = realized + unreal_val + 0.0 # all $1000 deployed + total_pl = equity - BANKROLL + print(f"resolved: {won}W / {lost}L · still open: {openc} · unknown: {unk}") + print(f"REALIZED P&L: {realized_pl:+,.2f} (on ${realized_cost:,.0f} settled)") + print(f"open positions marked to market: ${unreal_val:,.2f} (cost ${open_cost:,.0f})") + print(f"\nFINAL EQUITY: ${equity:,.2f} TOTAL P&L: {total_pl:+,.2f} " + f"({100*total_pl/BANKROLL:+.1f}% on ${BANKROLL:.0f})\n") + rows.sort() + print("worst / best copied bets:") + for pl, st, p, title in rows[:4] + rows[-4:]: + print(f" {pl:+8.2f} {st:>5} @{p:.2f} {title}") + + +if __name__ == "__main__": + main() diff --git a/live/cache.py b/live/cache.py new file mode 100644 index 00000000..b9d07c45 --- /dev/null +++ b/live/cache.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Local cache of per-wallet resolved bets, so we stop re-pulling the data-api. + +Each wallet's resolved bets (won, entry price p, conditionId, resolution time, +size) are stored once in cache.duckdb. Because we keep res_t per bet, ANY date +cutoff — pre-June-1, full window, future experiments — reads the same cached +rows and filters locally. A pull only happens for wallets not seen, or older +than MAX_AGE_DAYS. + +Thread-safe: API pulls (the slow part) run outside the lock; only the small +DuckDB reads/writes are serialized, so skill.py's worker pool still parallelizes +the network. +""" + +import os +import sys +import threading +import time + +import duckdb + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +import insider # noqa: E402 + +DB = os.path.join(os.path.dirname(__file__), "cache.duckdb") +WINDOW_DAYS = 180 +MAX_AGE_DAYS = 14 # broad pool re-pulls only every 2 weeks; watchlist is + # force-refreshed daily via invalidate() (see daily.sh) + +_lock = threading.Lock() +_con = duckdb.connect(DB) +_con.execute("""CREATE TABLE IF NOT EXISTS bets( + wallet TEXT, cond TEXT, won BOOLEAN, p DOUBLE, res_t BIGINT, size DOUBLE)""") +_con.execute("CREATE INDEX IF NOT EXISTS bets_w ON bets(wallet)") +_con.execute("CREATE TABLE IF NOT EXISTS pulled(wallet TEXT PRIMARY KEY, pulled_at BIGINT)") + + +def get_bets(wallet): + """Resolved bets for a wallet — from cache if fresh, else pull and store.""" + now = time.time() + with _lock: + r = _con.execute("SELECT pulled_at FROM pulled WHERE wallet=?", [wallet]).fetchone() + if r and now - r[0] < MAX_AGE_DAYS * 86400: + rows = _con.execute( + "SELECT won,p,cond,res_t,size FROM bets WHERE wallet=?", [wallet]).fetchall() + return [{"won": w, "p": p, "cond": c, "res_t": rt, "size": s} + for w, p, c, rt, s in rows] + # cache miss / stale -> pull (slow, outside the lock so workers stay parallel) + try: + bets = insider.resolved_bets(wallet, now - WINDOW_DAYS * 86400) + except Exception: + bets = [] + with _lock: + _con.execute("DELETE FROM bets WHERE wallet=?", [wallet]) + if bets: + _con.executemany( + "INSERT INTO bets(wallet,cond,won,p,res_t,size) VALUES (?,?,?,?,?,?)", + [(wallet, b["cond"], b["won"], b["p"], b.get("res_t"), b.get("size")) + for b in bets]) + _con.execute("INSERT OR REPLACE INTO pulled VALUES (?,?)", [wallet, int(now)]) + return bets + + +def invalidate(wallets): + """Force a re-pull of these wallets on next get_bets (for daily watchlist + forward-refresh).""" + with _lock: + for w in wallets: + _con.execute("DELETE FROM pulled WHERE wallet=?", [w]) + + +def stats(): + with _lock: + w = _con.execute("SELECT count(*) FROM pulled").fetchone()[0] + b = _con.execute("SELECT count(*) FROM bets").fetchone()[0] + return w, b + + +if __name__ == "__main__": + w, b = stats() + print(f"cache: {w:,} wallets, {b:,} bets in {DB}") diff --git a/live/clean_test.sh b/live/clean_test.sh new file mode 100755 index 00000000..e109dbc1 --- /dev/null +++ b/live/clean_test.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Clean out-of-sample test: +# 1) re-select skilled wallets using ONLY bets resolved before June 1 +# (selection cannot peek at the June 1+ test window) +# 2) copy the resulting FAVORITE-rider wallets' June 1+ entries, $1000, no lag +# This removes the selection contamination of the first backtest. +set -u +cd "$(dirname "$0")" +echo "[clean] $(date '+%F %T') re-scoring candidates on PRE-June-1 data only…" +SKILL_BEFORE=2026-06-01 SKILL_OUT=watch_prejune.json python3 skill.py 2000 +echo "[clean] $(date '+%F %T') backtest: copy pre-June-1 favorites' June1+ entries" +BT_WATCH=watch_prejune.json python3 backtest_june.py favorite +echo "[clean] $(date '+%F %T') done" diff --git a/live/collect.py b/live/collect.py new file mode 100644 index 00000000..b44a46bb --- /dev/null +++ b/live/collect.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Collect EVERY candidate wallet's resolved bets into the cache, up to present. + +One-time (per refresh window) comprehensive pull so the whole candidate pool is +local. Resumable: cache.get_bets skips wallets pulled within MAX_AGE_DAYS, so +killing and re-running continues where it left off. Most-active wallets first, +so a partial cache already covers the wallets most likely to be skilled. + + python3 collect.py +""" + +import json +import os +import sys +import time +from concurrent.futures import ThreadPoolExecutor, as_completed + +import cache + +HERE = os.path.dirname(__file__) +WORKERS = 16 + + +def main(): + cands = json.load(open(os.path.join(HERE, "candidates.json"))) + cands.sort(key=lambda c: c.get("markets_seen", 0), reverse=True) + wallets = [c["wallet"] for c in cands] + print(f"collecting {len(wallets):,} wallets up to present · {WORKERS} workers", flush=True) + done, t0 = 0, time.time() + with ThreadPoolExecutor(max_workers=WORKERS) as ex: + futs = [ex.submit(cache.get_bets, w) for w in wallets] + for _ in as_completed(futs): + done += 1 + if done % 200 == 0: + w, b = cache.stats() + rate = done / max(1e-9, time.time() - t0) + eta = (len(wallets) - done) / max(1e-9, rate) / 3600 + print(f" {done:,}/{len(wallets):,} · cache {w:,}w/{b:,}bets · " + f"{rate:.1f}/s · ETA {eta:.1f}h", flush=True) + w, b = cache.stats() + print(f"DONE {time.strftime('%F %T')} — cache: {w:,} wallets, {b:,} bets", flush=True) + + +if __name__ == "__main__": + main() diff --git a/live/daily.sh b/live/daily.sh new file mode 100755 index 00000000..bb2f1082 --- /dev/null +++ b/live/daily.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Daily skilled-wallet refresh, cache-backed: +# 1) discover — enumerate traders of markets resolved in the last ~14 days, +# merged into the accumulating candidate pool +# 2) freshen — force a re-pull of the current watchlist (forward tracking), +# then top up the cache (collect re-pulls only new + >14d-stale +# wallets, so it's cheap after the initial collection) +# 3) re-score — the 5-gate funnel, instant from cache -> watch_skilled.json +# 4) dashboard — regenerate + snapshot for auditable forward history +# +# Schedule with launchd/cron (Mac must be awake). Logs to daily.log. +set -u +cd "$(dirname "$0")" +echo "[daily] $(date '+%F %T') 1/4 discover (enumerate last 14d)" +python3 enumerate.py 14 +echo "[daily] $(date '+%F %T') 2/4 freshen cache (watchlist forced + new wallets)" +python3 -c "import json,os,cache +if os.path.exists('watch_skilled.json'): + cache.invalidate([w['wallet'] for w in json.load(open('watch_skilled.json'))])" 2>/dev/null || true +python3 collect.py +echo "[daily] $(date '+%F %T') 3/4 re-score (cache-backed, instant)" +python3 skill.py +echo "[daily] $(date '+%F %T') 4/4 dashboard" +python3 dashboard.py +mkdir -p history && cp watch_skilled.json "history/watch_$(date '+%Y%m%d').json" 2>/dev/null +echo "[daily] $(date '+%F %T') done -> watch_skilled.json + dashboard.html" diff --git a/live/dashboard.html b/live/dashboard.html new file mode 100644 index 00000000..80716a42 --- /dev/null +++ b/live/dashboard.html @@ -0,0 +1,121 @@ + + +Skilled Wallets — Polymarket +
+

Skilled Wallets · Polymarket

+

107 wallets that beat their own entry prices and held up out-of-sample · generated 2026-06-16 07:37

+
+
Validated wallets
107
+
Median z
4.0
+
Median OOS z
3.0
+
Value / longshot
69
+
Favorite-rider
21
+
+
+ + + +
+ + + + + + +
#WalletzOOS zbetswin%avg entryalpha 0.2–0.4type
+

How to read it. z = how far the wallet beat the prices it paid (skill; >3 is strong). OOS z = whether that skill held on held-out bets — the gate that separates real edge from luck. Alpha 0.2–0.4 = share of bets in the underdog value zone where research finds real edge concentrates. Value wallets win by beating longshot/mid prices (the copyable alpha); Favorite-rider wallets post high win rates buying near-certain favorites (real but thin, hard to copy). Click any row for live recent trades. Not financial advice — split-half OOS, forward-track before sizing.

+
+ \ No newline at end of file diff --git a/live/dashboard.py b/live/dashboard.py new file mode 100644 index 00000000..9b6dc2e6 --- /dev/null +++ b/live/dashboard.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Generate a self-contained dashboard.html from watch_skilled.json. + +Embeds the skilled-wallet snapshot (z, out-of-sample z, record, archetype, …) +into a sortable/filterable dark dashboard with Polymarket profile links and +on-demand live recent-trade lookup (data-api, client-side). Re-runnable — wire +into daily.sh so the dashboard refreshes with the watchlist. + + python3 dashboard.py # -> dashboard.html +""" + +import json +import os +import time + +HERE = os.path.dirname(__file__) + + +def archetype(r): + if r["avg_entry"] < 0.5: + return "value" # longshot / underdog value — the true alpha zone + if r["avg_entry"] >= 0.85: + return "favorite" # rides near-certain favorites (thin, less copyable) + return "balanced" + + +def main(): + w = json.load(open(os.path.join(HERE, "watch_skilled.json"))) + for i, r in enumerate(w, 1): + r["rank"] = i + r["arch"] = archetype(r) + r["wins"] = int(round(r["win_rate"] * r["n"] / 100)) + gen = time.strftime("%Y-%m-%d %H:%M") + med_z = sorted(x["z"] for x in w)[len(w) // 2] + med_oos = sorted(x["z_oos"] for x in w if x["z_oos"] is not None)[len(w) // 2] + n_val = sum(1 for x in w if x["arch"] == "value") + n_fav = sum(1 for x in w if x["arch"] == "favorite") + data = json.dumps(w) + + html = HTML.replace("/*DATA*/", data).replace("{{GEN}}", gen) \ + .replace("{{COUNT}}", str(len(w))).replace("{{MEDZ}}", f"{med_z:.1f}") \ + .replace("{{MEDOOS}}", f"{med_oos:.1f}").replace("{{NVAL}}", str(n_val)) \ + .replace("{{NFAV}}", str(n_fav)) + out = os.path.join(HERE, "dashboard.html") + open(out, "w").write(html) + print(f"wrote {out} ({len(w)} wallets)") + + +HTML = r""" + +Skilled Wallets — Polymarket +
+

Skilled Wallets · Polymarket

+

{{COUNT}} wallets that beat their own entry prices and held up out-of-sample · generated {{GEN}}

+
+
Validated wallets
{{COUNT}}
+
Median z
{{MEDZ}}
+
Median OOS z
{{MEDOOS}}
+
Value / longshot
{{NVAL}}
+
Favorite-rider
{{NFAV}}
+
+
+ + + +
+ + + + + + +
#WalletzOOS zbetswin%avg entryalpha 0.2–0.4type
+

How to read it. z = how far the wallet beat the prices it paid (skill; >3 is strong). OOS z = whether that skill held on held-out bets — the gate that separates real edge from luck. Alpha 0.2–0.4 = share of bets in the underdog value zone where research finds real edge concentrates. Value wallets win by beating longshot/mid prices (the copyable alpha); Favorite-rider wallets post high win rates buying near-certain favorites (real but thin, hard to copy). Click any row for live recent trades. Not financial advice — split-half OOS, forward-track before sizing.

+
+""" + + +if __name__ == "__main__": + main() diff --git a/live/enumerate.py b/live/enumerate.py new file mode 100644 index 00000000..626eb08b --- /dev/null +++ b/live/enumerate.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Enumerate candidate wallets from markets resolved in the last N months. + +The bulk subgraph is frozen at Jan 2026, so recent data comes from the LIVE +data-api. We can't cheaply page every global trade back 6 months, but we don't +need to: skilled traders concentrate in liquid markets and recur across many of +them. So we source high-volume recently-resolved markets from Gamma, pull each +market's top traders by notional (insider.market_traders), dedup, and tally how +many markets each wallet shows up in (a prioritization signal for scoring). + +Output: candidates.json -> scored by skill.py. + + python3 enumerate.py # last 180 days, liquid markets + python3 enumerate.py 90 # last 90 days +""" + +import json +import os +import ssl +import sys +import time +import urllib.request +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +import insider # noqa: E402 + +GAMMA = "https://gamma-api.polymarket.com/markets" +CTX = ssl._create_unverified_context() +WINDOW_DAYS = int(sys.argv[1]) if len(sys.argv) > 1 else 180 +MAX_MARKETS = 1200 # enough high-volume recent markets to surface recurring sharps +MAX_SCAN = 25000 # stop scanning the volume ranking after this many markets +TOP_TRADERS = 30 # top traders by notional per market +MIN_VOLUME = 20000 # $ — focus on liquid markets where skilled traders play + + +def gamma(params): + q = "&".join(f"{k}={v}" for k, v in params.items()) + req = urllib.request.Request(f"{GAMMA}?{q}", headers={"User-Agent": "Mozilla/5.0"}) + return json.loads(urllib.request.urlopen(req, timeout=40, context=CTX).read()) + + +def _closed_ts(m): + """Parse Gamma's closedTime ('2026-05-12 06:44:09+00') -> epoch, else 0.""" + s = m.get("closedTime") + if not s: + return 0 + s = s.replace(" ", "T").split("+")[0].split(".")[0] + try: + return time.mktime(time.strptime(s, "%Y-%m-%dT%H:%M:%S")) + except ValueError: + return insider._parse_end(m.get("endDateIso")) # fallback to date + + +def _page(offset, end_min): + # end_date_min restricts to recently-resolved markets directly, so we don't + # scan the whole (mostly-old) volume ranking. Retry once on transient error. + for _ in range(2): + try: + return gamma({"limit": 100, "offset": offset, "closed": "true", + "end_date_min": end_min, "order": "volume", + "ascending": "false"}) + except Exception: + time.sleep(2) + return "ERR" + + +def recent_markets(): + """Markets that resolved within the window (via end_date_min), kept if liquid + (volumeNum >= MIN_VOLUME and closedTime in window). Gamma is slow (~5-11s/ + page) so we page CONCURRENTLY in waves and stop at end-of-data or MAX_SCAN.""" + cutoff = time.time() - WINDOW_DAYS * 86400 + end_min = time.strftime("%Y-%m-%dT00:00:00Z", time.gmtime(cutoff)) + out, scanned, base, WAVE = [], 0, 0, 4 # low concurrency — gamma throttles bursts + with ThreadPoolExecutor(max_workers=WAVE) as ex: + while len(out) < MAX_MARKETS and scanned < MAX_SCAN: + pages = list(ex.map(lambda o: _page(o, end_min), + [base + i * 100 for i in range(WAVE)])) + base += WAVE * 100 + ended = False + for page in pages: + if page == "ERR" or page is None: + continue # skip transient gaps, keep going + if len(page) < 100: + ended = True # genuine end of data + scanned += len(page) + for m in page: + try: + vol = float(m.get("volumeNum") or 0) + except (TypeError, ValueError): + vol = 0 + if vol >= MIN_VOLUME and _closed_ts(m) >= cutoff and m.get("conditionId"): + out.append((m["conditionId"], m.get("question", "?")[:60])) + print(f" scanned {scanned:,}… kept {len(out)}", flush=True) + if ended: + break + return out[:MAX_MARKETS] + + +def main(): + print(f"sourcing markets resolved in last {WINDOW_DAYS}d (>= ${MIN_VOLUME:,} vol)…", + flush=True) + mkts = recent_markets() + print(f" {len(mkts)} markets", flush=True) + + seen, name = defaultdict(int), {} + + def grab(cm): + try: + cands, _ = insider.market_traders(cm[0], top=TOP_TRADERS) + return cands + except Exception: + return [] + + done = 0 + with ThreadPoolExecutor(max_workers=12) as ex: + for cands in ex.map(grab, mkts): + for c in cands: + seen[c["wallet"]] += 1 + name.setdefault(c["wallet"], c["username"]) + done += 1 + if done % 500 == 0: + print(f" {done}/{len(mkts)} markets · {len(seen):,} wallets", flush=True) + + # merge with any existing candidates so daily re-runs ACCUMULATE the pool + path = os.path.join(os.path.dirname(__file__), "candidates.json") + merged = {} + if os.path.exists(path): + for c in json.load(open(path)): + merged[c["wallet"]] = c + new = 0 + for w, n in seen.items(): + if w in merged: + merged[w]["markets_seen"] = max(merged[w].get("markets_seen", 0), n) + merged[w].setdefault("username", name[w]) + else: + merged[w] = {"wallet": w, "username": name[w], "markets_seen": n} + new += 1 + rows = sorted(merged.values(), key=lambda c: c["markets_seen"], reverse=True) + json.dump(rows, open(path, "w")) + print(f"{len(rows):,} candidate wallets (+{new} new this run) -> candidates.json " + f"({sum(1 for r in rows if r['markets_seen'] >= 15):,} seen in >=15 markets)", + flush=True) + + +if __name__ == "__main__": + main() diff --git a/live/skill.py b/live/skill.py new file mode 100644 index 00000000..ab9814f6 --- /dev/null +++ b/live/skill.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Score candidate wallets for genuine skill — the ~3% the research identifies. + +The 5-gate funnel (a wallet must pass all to count as skilled): + 1. >= MIN_N resolved bets in the window (assessability) + 2. z = (wins - Σp)/√Σp(1-p) clearly > 0 (beats its entry prices) + 3. survives Benjamini-Hochberg FDR across the scan (not luck-of-the-draw) + 4. split-half: skill in the earlier half persists in (out-of-sample — the + the recent half (z_oos > 0) decisive gate) + 5. <= MAX_N bets (market-maker / bot proxy, since the data-api gives no + reliable trade count) and not pure favorite-riding + +This mirrors the LBS/Yale method (randomize direction 10k× ≈ the z benchmark; +split-events persistence) and uses an UNBIASED win rate (insider.resolved_bets +unions /positions + /closed-positions, so unredeemed losers are counted). + +Refinements used for ranking: odds-band 0.2-0.4 concentration (where alpha +concentrates per Hubble), entry timing / freshness available via insider. + + python3 skill.py # score top candidates, write watch_skilled.json + python3 skill.py 2500 # score the top 2500 candidates by activity +""" + +import json +import math +import os +import sys +import time +from concurrent.futures import ThreadPoolExecutor, as_completed + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +import insider # noqa: E402 +import cache # noqa: E402 — local per-wallet bet cache (avoids re-pulling) + +HERE = os.path.dirname(__file__) +WINDOW_DAYS = 180 +MIN_N = 15 # floor to say anything (paper's skilled avg ~79) +OOS_MIN_N = 24 # need >=12 bets/half for a meaningful split +MAX_N = 2500 # market-maker/bot proxy (no trade count available) +FDR_Q = 0.05 +SCORE_TOP = int(sys.argv[1]) if len(sys.argv) > 1 else 2000 # cap scoring cost +WORKERS = 10 +# clean out-of-sample re-selection: SKILL_BEFORE=YYYY-MM-DD scores ONLY bets +# resolved before that date, so selection can't peek at the test window. +BEFORE = (time.mktime(time.strptime(os.environ["SKILL_BEFORE"], "%Y-%m-%d")) + if os.environ.get("SKILL_BEFORE") else 0) +OUT = os.environ.get("SKILL_OUT", "watch_skilled.json") + + +def zstats(bets): + n = len(bets) + if not n: + return 0, 0, 0.0, 0.0 + wins = sum(1 for b in bets if b["won"]) + exp = sum(b["p"] for b in bets) + var = sum(b["p"] * (1 - b["p"]) for b in bets) or 1e-9 + return n, wins, exp, (wins - exp) / math.sqrt(var) + + +def score_wallet(c): + bets = cache.get_bets(c["wallet"]) # cached — pulls the data-api only once per wallet + if BEFORE: # clean OOS: only bets resolved before cutoff + bets = [b for b in bets if (b.get("res_t") or 0) < BEFORE] + n = len(bets) + if n < MIN_N or n > MAX_N: + return None + n, wins, exp, z = zstats(bets) + # split-half out-of-sample (chronological): does early skill persist forward? + bets.sort(key=lambda b: b.get("res_t") or 0) + if n >= OOS_MIN_N: + h = n // 2 + _, _, _, z_is = zstats(bets[:h]) + _, _, _, z_oos = zstats(bets[h:]) + else: + z_is = z_oos = None + band = sum(1 for b in bets if 0.2 <= b["p"] <= 0.4) / n + avg_p = sum(b["p"] for b in bets) / n + return { + "wallet": c["wallet"], "username": c.get("username") or c["wallet"][:10], + "n": n, "wins": wins, "win_rate": round(100 * wins / n, 1), + "exp_wins": round(exp, 1), "z": round(z, 2), "pval": insider.norm_sf(z), + "z_is": None if z_is is None else round(z_is, 2), + "z_oos": None if z_oos is None else round(z_oos, 2), + "avg_entry": round(avg_p, 2), "band_0204": round(band, 2), + "markets_seen": c.get("markets_seen", 0), + } + + +def bh_threshold(rows, q): + m = len(rows) + if not m: + return 0.0 + ps = sorted(r["pval"] for r in rows) + k = 0 + for i, p in enumerate(ps, 1): + if p <= q * i / m: + k = i + return ps[k - 1] if k else 0.0 + + +def main(): + cands = json.load(open(os.path.join(HERE, "candidates.json"))) + cands.sort(key=lambda c: c.get("markets_seen", 0), reverse=True) + cands = cands[:SCORE_TOP] + print(f"scoring {len(cands):,} candidates (window {WINDOW_DAYS}d, " + f"min_n {MIN_N}, max_n {MAX_N})…", flush=True) + + rows, done = [], 0 + with ThreadPoolExecutor(max_workers=WORKERS) as ex: + futs = {ex.submit(score_wallet, c): c for c in cands} + for f in as_completed(futs): + r = f.result() + if r: + rows.append(r) + done += 1 + if done % 200 == 0: + print(f" {done}/{len(cands)} scored · {len(rows)} with >= {MIN_N} bets", + flush=True) + + if not rows: + print("no wallets cleared the bet minimum.") + return + thresh = bh_threshold(rows, FDR_Q) + + # the skilled set: FDR-significant AND out-of-sample-positive + skilled = [r for r in rows + if thresh > 0 and r["pval"] <= thresh + and (r["z_oos"] is None or r["z_oos"] > 0)] + # tier: "validated" = enough bets for a real OOS split and it held + for r in skilled: + r["tier"] = ("validated" if (r["z_oos"] is not None and r["z_oos"] > 0) + else "candidate") + skilled.sort(key=lambda r: (r["tier"] == "validated", r["z"]), reverse=True) + + # z-weighted watchlist, compatible with webhook_receiver (wallet/name/weight) + tot = sum(r["z"] for r in skilled) or 1 + watch = [{"wallet": r["wallet"], "name": r["username"], + "weight": round(r["z"] / tot, 4), "tier": r["tier"], + "z": r["z"], "z_oos": r["z_oos"], "n": r["n"], + "win_rate": r["win_rate"], "avg_entry": r["avg_entry"], + "band_0204": r["band_0204"]} for r in skilled] + json.dump(watch, open(os.path.join(HERE, OUT), "w"), indent=2) + json.dump(rows, open(os.path.join(HERE, OUT.replace(".json", "_scored.json")), "w")) + + val = sum(1 for r in skilled if r["tier"] == "validated") + print(f"\nscored {len(rows):,} wallets · BH@{int(FDR_Q*100)}% threshold p<= {thresh:.1e}") + print(f"SKILLED: {len(skilled)} ({val} validated OOS, {len(skilled)-val} candidate) " + f"-> watch_skilled.json\n") + hdr = f"{'tier':>10}{'z':>6}{'z_oos':>6}{'rec':>12}{'win%':>6}{'avgP':>6}{'0.2-0.4':>8} wallet" + print(hdr); print("-" * len(hdr)) + for r in skilled[:40]: + oos = "n/a" if r["z_oos"] is None else f"{r['z_oos']:.1f}" + rec = f"{r['wins']}/{r['n']}" + print(f"{r['tier']:>10}{r['z']:>6.1f}{oos:>6}{rec:>12}" + f"{r['win_rate']:>5.0f}%{r['avg_entry']:>6.2f}{r['band_0204']:>8.2f} {r['username'][:18]}") + + +if __name__ == "__main__": + main() diff --git a/live/watch_skilled.json b/live/watch_skilled.json new file mode 100644 index 00000000..3aee3e25 --- /dev/null +++ b/live/watch_skilled.json @@ -0,0 +1,1286 @@ +[ + { + "wallet": "0x5f390e4b7d6f06d6756a6c92afdbf7b3176aa78c", + "name": "oVyg7f", + "weight": 0.0368, + "tier": "validated", + "z": 18.49, + "z_oos": 8.38, + "n": 2000, + "win_rate": 66.0, + "avg_entry": 0.57, + "band_0204": 0.02 + }, + { + "wallet": "0xf419556a9758214628edff29dce8f39a8aa13ce7", + "name": "2j289uUmAio7ap245975", + "weight": 0.0355, + "tier": "validated", + "z": 17.85, + "z_oos": 9.4, + "n": 2091, + "win_rate": 69.7, + "avg_entry": 0.51, + "band_0204": 0.13 + }, + { + "wallet": "0x2257901897e83d39e778fa86d568dc1cd9a2dca2", + "name": "iuii899889", + "weight": 0.0198, + "tier": "validated", + "z": 9.97, + "z_oos": 6.51, + "n": 1539, + "win_rate": 50.0, + "avg_entry": 0.38, + "band_0204": 0.25 + }, + { + "wallet": "0x9ace6559d0c1fe92c360503c6dbcd962e15f0f16", + "name": "Jach9394", + "weight": 0.0192, + "tier": "validated", + "z": 9.64, + "z_oos": 7.7, + "n": 2128, + "win_rate": 47.8, + "avg_entry": 0.38, + "band_0204": 0.46 + }, + { + "wallet": "0xda24f0caf5766fc36df8ac2f6ae3a567b71a9f1f", + "name": "Thewinwin", + "weight": 0.0183, + "tier": "validated", + "z": 9.18, + "z_oos": 6.84, + "n": 2001, + "win_rate": 41.0, + "avg_entry": 0.32, + "band_0204": 0.31 + }, + { + "wallet": "0x14d2bf01e3e7bac9b1799265db3c7d8a993dfa3e", + "name": "8998yueyye", + "weight": 0.0181, + "tier": "validated", + "z": 9.09, + "z_oos": 5.89, + "n": 1532, + "win_rate": 49.9, + "avg_entry": 0.39, + "band_0204": 0.25 + }, + { + "wallet": "0x97892305f3b5569c32d16f4ed70f840dd7e61ae7", + "name": "0x97892305f3b5569c32d16f4ed70F840dd7e61ae7-1770916318891", + "weight": 0.0177, + "tier": "validated", + "z": 8.9, + "z_oos": 6.92, + "n": 1806, + "win_rate": 44.5, + "avg_entry": 0.35, + "band_0204": 0.51 + }, + { + "wallet": "0xf820ceebb69255955e9d04367259c84e8e7f0485", + "name": "2iq", + "weight": 0.0173, + "tier": "validated", + "z": 8.68, + "z_oos": 13.09, + "n": 2000, + "win_rate": 16.8, + "avg_entry": 0.11, + "band_0204": 0.1 + }, + { + "wallet": "0x49818089c6e2755e0e300a48a8dc4747cf9cd2b4", + "name": "0x49818089\u2026", + "weight": 0.0165, + "tier": "validated", + "z": 8.3, + "z_oos": 4.81, + "n": 1334, + "win_rate": 61.6, + "avg_entry": 0.51, + "band_0204": 0.26 + }, + { + "wallet": "0x1b44945f8992fcea2b43034557e47d11827fd8de", + "name": "BrickThrower", + "weight": 0.0152, + "tier": "validated", + "z": 7.65, + "z_oos": 5.57, + "n": 2063, + "win_rate": 45.1, + "avg_entry": 0.38, + "band_0204": 0.23 + }, + { + "wallet": "0x2f633efb75256a2f2445110c8978684ab8936643", + "name": "AI.AGENT.2026", + "weight": 0.0144, + "tier": "validated", + "z": 7.24, + "z_oos": 4.26, + "n": 573, + "win_rate": 92.0, + "avg_entry": 0.82, + "band_0204": 0.05 + }, + { + "wallet": "0x753e554feb327f9d4f7bff8a6ebdced98fbd2046", + "name": "hfdffdhr", + "weight": 0.0135, + "tier": "validated", + "z": 6.78, + "z_oos": 4.21, + "n": 1396, + "win_rate": 49.2, + "avg_entry": 0.41, + "band_0204": 0.2 + }, + { + "wallet": "0xc684828f6b03487759ced2ebdd975f91f3532228", + "name": "oliman2", + "weight": 0.0126, + "tier": "validated", + "z": 6.31, + "z_oos": 3.63, + "n": 1933, + "win_rate": 52.0, + "avg_entry": 0.47, + "band_0204": 0.15 + }, + { + "wallet": "0xd82273662ba061a46f62a479de7303266f98000f", + "name": "1234zxcv07", + "weight": 0.0126, + "tier": "validated", + "z": 6.31, + "z_oos": 3.09, + "n": 1054, + "win_rate": 71.2, + "avg_entry": 0.64, + "band_0204": 0.12 + }, + { + "wallet": "0x751a2b86cab503496efd325c8344e10159349ea1", + "name": "Sharky6999", + "weight": 0.0121, + "tier": "validated", + "z": 6.07, + "z_oos": 3.13, + "n": 2002, + "win_rate": 96.3, + "avg_entry": 0.94, + "band_0204": 0.01 + }, + { + "wallet": "0xc850cf70d9da621ff0c219f625c9623f88b78c51", + "name": "0xc850cf70\u2026", + "weight": 0.0119, + "tier": "validated", + "z": 5.97, + "z_oos": 3.98, + "n": 2000, + "win_rate": 50.1, + "avg_entry": 0.44, + "band_0204": 0.32 + }, + { + "wallet": "0xba8c5fbcc5f58b0e4ae0c1413e0413f8c803e77d", + "name": "Rock.San", + "weight": 0.0116, + "tier": "validated", + "z": 5.81, + "z_oos": 2.63, + "n": 2001, + "win_rate": 95.8, + "avg_entry": 0.93, + "band_0204": 0.01 + }, + { + "wallet": "0x85c0272a4c6334e4cb1212b4e5cda66e68519231", + "name": "ferrusqwed", + "weight": 0.0111, + "tier": "validated", + "z": 5.57, + "z_oos": 3.46, + "n": 2229, + "win_rate": 48.7, + "avg_entry": 0.43, + "band_0204": 0.27 + }, + { + "wallet": "0xa75f077c6e2510b020967c6230bac2bf342300f0", + "name": "0xa75f077c\u2026", + "weight": 0.0111, + "tier": "validated", + "z": 5.56, + "z_oos": 4.03, + "n": 1772, + "win_rate": 49.4, + "avg_entry": 0.43, + "band_0204": 0.33 + }, + { + "wallet": "0x18954a8003ebbbdaa4d9190cd2505382a5b902fb", + "name": "Si-gambler", + "weight": 0.0109, + "tier": "validated", + "z": 5.48, + "z_oos": 4.07, + "n": 1651, + "win_rate": 40.4, + "avg_entry": 0.35, + "band_0204": 0.28 + }, + { + "wallet": "0xc78cdeb598f0bb8bae62a80e8134e842b3c44455", + "name": "kjjdhfgsg", + "weight": 0.0109, + "tier": "validated", + "z": 5.48, + "z_oos": 0.75, + "n": 1182, + "win_rate": 49.8, + "avg_entry": 0.42, + "band_0204": 0.23 + }, + { + "wallet": "0x30dc7f0cfa4c98590b1c77dd92c82570efa5eb4b", + "name": "napahp", + "weight": 0.0107, + "tier": "validated", + "z": 5.4, + "z_oos": 2.21, + "n": 2243, + "win_rate": 48.2, + "avg_entry": 0.43, + "band_0204": 0.35 + }, + { + "wallet": "0x8548f0fae7636fc17ca6bb2cbae39cbd4fbce4c1", + "name": "madoka123", + "weight": 0.0103, + "tier": "validated", + "z": 5.2, + "z_oos": 5.3, + "n": 2012, + "win_rate": 94.1, + "avg_entry": 0.92, + "band_0204": 0.01 + }, + { + "wallet": "0x8d650d146740463611db1f3974ae9b922953ce8f", + "name": "PWBR", + "weight": 0.0102, + "tier": "validated", + "z": 5.15, + "z_oos": 2.77, + "n": 2016, + "win_rate": 39.4, + "avg_entry": 0.35, + "band_0204": 0.11 + }, + { + "wallet": "0xb595d09ce5bbc4d39e3b3d04e80c402d2c8d5922", + "name": "0xB595d09Ce5bBc4d39E3b3D04E80C402d2C8D5922-1769777706105", + "weight": 0.0101, + "tier": "validated", + "z": 5.08, + "z_oos": 3.45, + "n": 2000, + "win_rate": 99.9, + "avg_entry": 0.99, + "band_0204": 0.0 + }, + { + "wallet": "0x858c18cd25e722892a60506a3e54c4ecbca84936", + "name": "52434624366255247", + "weight": 0.0101, + "tier": "validated", + "z": 5.06, + "z_oos": 4.15, + "n": 2000, + "win_rate": 98.6, + "avg_entry": 0.97, + "band_0204": 0.0 + }, + { + "wallet": "0x8a37fe3a49a36de9facd04bd09ee379211f7c99c", + "name": "ggmggn", + "weight": 0.01, + "tier": "validated", + "z": 5.01, + "z_oos": 4.76, + "n": 2000, + "win_rate": 89.2, + "avg_entry": 0.88, + "band_0204": 0.0 + }, + { + "wallet": "0x7ca56b03105371839e4a3d98f4c1059d10d784bc", + "name": "MyBettingFuture", + "weight": 0.01, + "tier": "validated", + "z": 5.0, + "z_oos": 3.59, + "n": 2019, + "win_rate": 98.7, + "avg_entry": 0.97, + "band_0204": 0.0 + }, + { + "wallet": "0xab8a8dafae74a018683748d070cb9e349cc2154e", + "name": "0xaB8A8dafAE74A018683748d070Cb9E349CC2154e-1771847292775", + "weight": 0.0099, + "tier": "validated", + "z": 4.99, + "z_oos": 2.13, + "n": 1943, + "win_rate": 38.4, + "avg_entry": 0.34, + "band_0204": 0.18 + }, + { + "wallet": "0xe52785eab686a52fa47b9396d3343e2908315471", + "name": "0xe52785eaB686a52FA47B9396D3343e2908315471-1775532473233", + "weight": 0.0099, + "tier": "validated", + "z": 4.97, + "z_oos": 2.98, + "n": 273, + "win_rate": 36.6, + "avg_entry": 0.25, + "band_0204": 0.15 + }, + { + "wallet": "0x472e77443a5a8693f6bd0dad933a61285bd7ae19", + "name": "ifailedmath", + "weight": 0.0099, + "tier": "validated", + "z": 4.97, + "z_oos": 6.42, + "n": 2032, + "win_rate": 53.3, + "avg_entry": 0.49, + "band_0204": 0.14 + }, + { + "wallet": "0xf3d54ccedcd08c9a203d28e332275e50770fdde9", + "name": "0xf3d54cce\u2026", + "weight": 0.0096, + "tier": "validated", + "z": 4.83, + "z_oos": 3.96, + "n": 1498, + "win_rate": 49.3, + "avg_entry": 0.43, + "band_0204": 0.32 + }, + { + "wallet": "0xcaee6125904f1ba1ee3f829328d0779f92d20900", + "name": "Dedis", + "weight": 0.0096, + "tier": "validated", + "z": 4.82, + "z_oos": 4.9, + "n": 859, + "win_rate": 31.3, + "avg_entry": 0.25, + "band_0204": 0.24 + }, + { + "wallet": "0x5f7832a6d8621345922490c74f4e525db20cbfca", + "name": "pupe", + "weight": 0.0095, + "tier": "validated", + "z": 4.79, + "z_oos": 2.95, + "n": 2000, + "win_rate": 41.5, + "avg_entry": 0.37, + "band_0204": 0.18 + }, + { + "wallet": "0x6c74116b34d77be18754a463df34a06f1b2f8a52", + "name": "SUNDAD", + "weight": 0.0095, + "tier": "validated", + "z": 4.78, + "z_oos": 1.6, + "n": 903, + "win_rate": 26.2, + "avg_entry": 0.2, + "band_0204": 0.38 + }, + { + "wallet": "0x9819e3e2e1992cb0d6cc1742df4f4d81af314951", + "name": "AfganNakrishe4", + "weight": 0.0095, + "tier": "validated", + "z": 4.75, + "z_oos": 4.61, + "n": 2010, + "win_rate": 43.7, + "avg_entry": 0.39, + "band_0204": 0.29 + }, + { + "wallet": "0x6ab434242be94f834de8d38ba2de0b4cbfdaa81b", + "name": "STAYCALM", + "weight": 0.0093, + "tier": "validated", + "z": 4.66, + "z_oos": 4.08, + "n": 2007, + "win_rate": 94.0, + "avg_entry": 0.92, + "band_0204": 0.01 + }, + { + "wallet": "0x558f2f82cf0394c28ce3c8e117ee747aeab51a56", + "name": "LesterDiamond", + "weight": 0.0093, + "tier": "validated", + "z": 4.66, + "z_oos": 5.48, + "n": 2168, + "win_rate": 59.5, + "avg_entry": 0.57, + "band_0204": 0.03 + }, + { + "wallet": "0xa16a1302ca05463f30faebeb5c045767fde233a1", + "name": "KimiTalibantonelli", + "weight": 0.0092, + "tier": "validated", + "z": 4.64, + "z_oos": 3.82, + "n": 2005, + "win_rate": 99.2, + "avg_entry": 0.98, + "band_0204": 0.0 + }, + { + "wallet": "0x7a5d94f83ff0195387eaf9f4463433d03eda54db", + "name": "mishipolis", + "weight": 0.0091, + "tier": "validated", + "z": 4.59, + "z_oos": 4.2, + "n": 2140, + "win_rate": 54.4, + "avg_entry": 0.5, + "band_0204": 0.2 + }, + { + "wallet": "0xdbdd45150249e229eb4ca8aa48a30dca21faa5de", + "name": "0xdbdd45150249e229eB4cA8aa48A30Dca21Faa5de-1757094771846", + "weight": 0.0091, + "tier": "validated", + "z": 4.57, + "z_oos": 3.4, + "n": 2040, + "win_rate": 52.2, + "avg_entry": 0.48, + "band_0204": 0.22 + }, + { + "wallet": "0x13a12ebadc50f6fa42c8f2595997d8adf329d9bc", + "name": "12398498123498189239843", + "weight": 0.0086, + "tier": "validated", + "z": 4.34, + "z_oos": 3.12, + "n": 2001, + "win_rate": 69.2, + "avg_entry": 0.65, + "band_0204": 0.12 + }, + { + "wallet": "0xdf6539d1fadb951a02a999d31a72e0cd7fd9c36d", + "name": "nndk", + "weight": 0.0086, + "tier": "validated", + "z": 4.33, + "z_oos": 2.83, + "n": 2073, + "win_rate": 48.5, + "avg_entry": 0.45, + "band_0204": 0.42 + }, + { + "wallet": "0x54e00a1ceb7f3b7c0bd240d1deeb0edb6b0eb02c", + "name": "0x54E00a1Ceb7f3b7c0bD240D1Deeb0edB6B0eb02C-1761391047632", + "weight": 0.0086, + "tier": "validated", + "z": 4.31, + "z_oos": 2.57, + "n": 2016, + "win_rate": 52.5, + "avg_entry": 0.49, + "band_0204": 0.37 + }, + { + "wallet": "0x82b59bbdd9ec1aaa995ee41d5d49b7de0a5f6f5f", + "name": "ldsam", + "weight": 0.0085, + "tier": "validated", + "z": 4.29, + "z_oos": 4.62, + "n": 2000, + "win_rate": 48.2, + "avg_entry": 0.44, + "band_0204": 0.29 + }, + { + "wallet": "0x773e8ca01ad9750283dbfb335d2c6670bc817502", + "name": "meow991", + "weight": 0.0085, + "tier": "validated", + "z": 4.27, + "z_oos": 7.1, + "n": 687, + "win_rate": 62.7, + "avg_entry": 0.56, + "band_0204": 0.17 + }, + { + "wallet": "0xee65685de42f8de9a03b4c53ee77d56a20d2cfc9", + "name": "0x50f7", + "weight": 0.0084, + "tier": "validated", + "z": 4.2, + "z_oos": 2.63, + "n": 2209, + "win_rate": 39.2, + "avg_entry": 0.37, + "band_0204": 0.01 + }, + { + "wallet": "0xbb9f224569285cb57f433bbe2e71f9b8a11b557b", + "name": "rocky42014", + "weight": 0.0084, + "tier": "validated", + "z": 4.2, + "z_oos": 2.87, + "n": 2023, + "win_rate": 43.2, + "avg_entry": 0.39, + "band_0204": 0.25 + }, + { + "wallet": "0xb36b3af57e480c750bcebed7be2d8afd24380cb1", + "name": "Didiaodiao", + "weight": 0.0083, + "tier": "validated", + "z": 4.17, + "z_oos": 3.13, + "n": 2000, + "win_rate": 48.1, + "avg_entry": 0.44, + "band_0204": 0.25 + }, + { + "wallet": "0xb2b4ec88c02de44d6ab61eb6c4141fe8170e512d", + "name": "0xb2b4ec88\u2026", + "weight": 0.0083, + "tier": "validated", + "z": 4.15, + "z_oos": 1.97, + "n": 2000, + "win_rate": 46.5, + "avg_entry": 0.42, + "band_0204": 0.28 + }, + { + "wallet": "0x8428101d6979dcc97de6c7474b0535ceb558b41f", + "name": "888SSSS", + "weight": 0.0082, + "tier": "validated", + "z": 4.14, + "z_oos": 1.8, + "n": 1825, + "win_rate": 53.8, + "avg_entry": 0.49, + "band_0204": 0.24 + }, + { + "wallet": "0x8cd9439c1205da4c9ba18559cdcfe2e12278190e", + "name": "kaslas", + "weight": 0.0082, + "tier": "validated", + "z": 4.13, + "z_oos": 3.14, + "n": 2038, + "win_rate": 50.6, + "avg_entry": 0.47, + "band_0204": 0.31 + }, + { + "wallet": "0x875e974594985283c999765461bf2e15b4dee6b5", + "name": "CorkisGeldbeutel", + "weight": 0.0081, + "tier": "validated", + "z": 4.05, + "z_oos": 3.32, + "n": 2001, + "win_rate": 97.2, + "avg_entry": 0.96, + "band_0204": 0.01 + }, + { + "wallet": "0xc095b5100653f0c34337c718f201dae96d762d65", + "name": "daddypow", + "weight": 0.008, + "tier": "validated", + "z": 4.0, + "z_oos": 2.37, + "n": 2000, + "win_rate": 98.8, + "avg_entry": 0.98, + "band_0204": 0.0 + }, + { + "wallet": "0x0cfe444ab50e01b14ee15ff5e677a1dd68cbd5c3", + "name": "0x0CFE444Ab50e01B14EE15ff5e677a1dd68cbD5c3-1778164468132", + "weight": 0.008, + "tier": "validated", + "z": 4.0, + "z_oos": 0.51, + "n": 902, + "win_rate": 44.8, + "avg_entry": 0.39, + "band_0204": 0.27 + }, + { + "wallet": "0x616d3f9819deb5450fb34315221f23d64f36bb9b", + "name": "JackOfAllTrades3130", + "weight": 0.008, + "tier": "validated", + "z": 4.0, + "z_oos": 3.01, + "n": 2007, + "win_rate": 99.2, + "avg_entry": 0.98, + "band_0204": 0.0 + }, + { + "wallet": "0x6a88c5ecd262e2c42e78bad8e6db7ab3c4e4b859", + "name": "pelik", + "weight": 0.0079, + "tier": "validated", + "z": 3.99, + "z_oos": 3.99, + "n": 2004, + "win_rate": 100.0, + "avg_entry": 1.0, + "band_0204": 0.0 + }, + { + "wallet": "0xb66dc120c79ec7282b48f938633c942567667ec1", + "name": "0xb66dc120c79ec7282b48f938633c94256", + "weight": 0.0079, + "tier": "validated", + "z": 3.97, + "z_oos": 2.39, + "n": 2317, + "win_rate": 43.4, + "avg_entry": 0.4, + "band_0204": 0.26 + }, + { + "wallet": "0x51928a6454475e6b0d883f5d6ba663670c83df62", + "name": "sfafdsaferw", + "weight": 0.0078, + "tier": "validated", + "z": 3.92, + "z_oos": 1.13, + "n": 2000, + "win_rate": 52.1, + "avg_entry": 0.49, + "band_0204": 0.18 + }, + { + "wallet": "0x08730443345e11c078c5b2597ce3c0f06af609e8", + "name": "Anne666", + "weight": 0.0078, + "tier": "validated", + "z": 3.9, + "z_oos": 2.78, + "n": 2016, + "win_rate": 0.4, + "avg_entry": 0.0, + "band_0204": 0.0 + }, + { + "wallet": "0x40a75505ba4e7deb9bf55e839e5a34dd6c9becb7", + "name": "Dreamwire", + "weight": 0.0078, + "tier": "validated", + "z": 3.9, + "z_oos": 2.9, + "n": 2008, + "win_rate": 81.3, + "avg_entry": 0.8, + "band_0204": 0.02 + }, + { + "wallet": "0x48270d81cfe598ae0379ca535a26dc41e3e9080d", + "name": "DiogoBNF566", + "weight": 0.0077, + "tier": "validated", + "z": 3.86, + "z_oos": 3.3, + "n": 2014, + "win_rate": 95.4, + "avg_entry": 0.94, + "band_0204": 0.01 + }, + { + "wallet": "0x1c7b8b9e0c9a5215d5b84553130029e5d1ae315d", + "name": "ksamlsa", + "weight": 0.0077, + "tier": "validated", + "z": 3.85, + "z_oos": 3.54, + "n": 2000, + "win_rate": 50.8, + "avg_entry": 0.47, + "band_0204": 0.3 + }, + { + "wallet": "0x17f0b79bae4c986b854f6be30f1fca8c484dea12", + "name": "bdmina", + "weight": 0.0076, + "tier": "validated", + "z": 3.81, + "z_oos": 2.86, + "n": 2003, + "win_rate": 44.2, + "avg_entry": 0.4, + "band_0204": 0.22 + }, + { + "wallet": "0x2ecb34e1cb95b3c8d9d3e2905668d3eee03a7645", + "name": "AlphaPengu1n", + "weight": 0.0075, + "tier": "validated", + "z": 3.78, + "z_oos": 3.57, + "n": 2170, + "win_rate": 45.6, + "avg_entry": 0.42, + "band_0204": 0.25 + }, + { + "wallet": "0xdeca32428d5cf4b1c4d7266a950e20791676ddd5", + "name": "durrrrrrrr", + "weight": 0.0072, + "tier": "validated", + "z": 3.64, + "z_oos": 2.45, + "n": 2048, + "win_rate": 31.6, + "avg_entry": 0.29, + "band_0204": 0.02 + }, + { + "wallet": "0x5a218c7ad04135830a45c41aaed7294df7809318", + "name": "balthazar", + "weight": 0.0072, + "tier": "validated", + "z": 3.62, + "z_oos": 3.98, + "n": 2384, + "win_rate": 49.0, + "avg_entry": 0.47, + "band_0204": 0.06 + }, + { + "wallet": "0xd92f13f9c20e5739aae98032e443b13d9d823d83", + "name": "follwmee", + "weight": 0.0072, + "tier": "validated", + "z": 3.6, + "z_oos": 1.88, + "n": 2040, + "win_rate": 48.8, + "avg_entry": 0.45, + "band_0204": 0.22 + }, + { + "wallet": "0xb4d250f58c26840e09723a83ce9c8149aa32ce99", + "name": "alpheclabs", + "weight": 0.0071, + "tier": "validated", + "z": 3.57, + "z_oos": 3.4, + "n": 2035, + "win_rate": 50.5, + "avg_entry": 0.47, + "band_0204": 0.24 + }, + { + "wallet": "0x51f9122c7ed2ceb749d8d63617153e98d3ba19a0", + "name": "kozelblack", + "weight": 0.007, + "tier": "validated", + "z": 3.52, + "z_oos": 3.34, + "n": 2018, + "win_rate": 98.3, + "avg_entry": 0.97, + "band_0204": 0.0 + }, + { + "wallet": "0x9219dd565d7521e95f273b7eea68dbb08d40027c", + "name": "aka-baka-tiki-taka", + "weight": 0.007, + "tier": "validated", + "z": 3.51, + "z_oos": 2.25, + "n": 2000, + "win_rate": 98.5, + "avg_entry": 0.97, + "band_0204": 0.0 + }, + { + "wallet": "0x1d99336f5c7dabec1b890cb2237f9b4aa139b04c", + "name": "traxeast", + "weight": 0.007, + "tier": "validated", + "z": 3.5, + "z_oos": 1.4, + "n": 2001, + "win_rate": 46.8, + "avg_entry": 0.43, + "band_0204": 0.19 + }, + { + "wallet": "0xa5427f80816486ad3e4c80aad9711f87b8b5172c", + "name": "0xA5427F80816486AD3E4c80AAd9711f87B8B5172C-1772701375450", + "weight": 0.0069, + "tier": "validated", + "z": 3.49, + "z_oos": 3.02, + "n": 2001, + "win_rate": 49.6, + "avg_entry": 0.46, + "band_0204": 0.37 + }, + { + "wallet": "0x31121402eafd4a157141db8f84b5da297ff63fcd", + "name": "yeagerist", + "weight": 0.0068, + "tier": "validated", + "z": 3.41, + "z_oos": 3.07, + "n": 2005, + "win_rate": 98.5, + "avg_entry": 0.98, + "band_0204": 0.0 + }, + { + "wallet": "0x7a3fb8c5ad2af4c621b2138d9579410343976712", + "name": "0x7a3FB8C5aD2aF4C621B2138D9579410343976712-1766367891961", + "weight": 0.0067, + "tier": "validated", + "z": 3.38, + "z_oos": 2.99, + "n": 2011, + "win_rate": 47.4, + "avg_entry": 0.44, + "band_0204": 0.29 + }, + { + "wallet": "0x6d3c9fe9cb9a7e006c99b13bdd0451ab5059c74a", + "name": "0x6D3C9fE9CB9A7e006C99b13bDd0451aB5059C74a-1775205794013", + "weight": 0.0066, + "tier": "validated", + "z": 3.34, + "z_oos": 0.16, + "n": 1292, + "win_rate": 30.0, + "avg_entry": 0.26, + "band_0204": 0.4 + }, + { + "wallet": "0x34f4c3118569f32ef02441326305bca0ff5816bd", + "name": "Walede-", + "weight": 0.0066, + "tier": "validated", + "z": 3.34, + "z_oos": 0.89, + "n": 2116, + "win_rate": 30.4, + "avg_entry": 0.27, + "band_0204": 0.47 + }, + { + "wallet": "0x50c0959535cf830f6b73c5366a02f2cce3aa1600", + "name": "LidoRzitoni", + "weight": 0.0066, + "tier": "validated", + "z": 3.34, + "z_oos": 1.56, + "n": 213, + "win_rate": 34.7, + "avg_entry": 0.26, + "band_0204": 0.31 + }, + { + "wallet": "0x93173b86dbe2f2dd9922c751806d306b48cf5a5f", + "name": "trader-93173b86", + "weight": 0.0066, + "tier": "validated", + "z": 3.33, + "z_oos": 1.94, + "n": 2007, + "win_rate": 49.3, + "avg_entry": 0.46, + "band_0204": 0.27 + }, + { + "wallet": "0xe75b11fa4fa01db5f11743e5f40df6c23b8e97eb", + "name": "sleepyzs", + "weight": 0.0066, + "tier": "validated", + "z": 3.3, + "z_oos": 1.46, + "n": 2001, + "win_rate": 99.0, + "avg_entry": 0.98, + "band_0204": 0.0 + }, + { + "wallet": "0x46b2f8a865a3572a435813e747e22cce0e835977", + "name": "quantssss", + "weight": 0.0065, + "tier": "validated", + "z": 3.29, + "z_oos": 2.37, + "n": 2007, + "win_rate": 49.2, + "avg_entry": 0.46, + "band_0204": 0.39 + }, + { + "wallet": "0x536be02af900fe046fa708c8059c04f737a2cee3", + "name": "fill-or-fok-me", + "weight": 0.0065, + "tier": "validated", + "z": 3.28, + "z_oos": 2.24, + "n": 2017, + "win_rate": 48.3, + "avg_entry": 0.45, + "band_0204": 0.34 + }, + { + "wallet": "0x2c697b1d8e5546aef19ed939522af03d90a3a9a2", + "name": "traxup", + "weight": 0.0065, + "tier": "validated", + "z": 3.27, + "z_oos": 1.39, + "n": 1720, + "win_rate": 49.7, + "avg_entry": 0.46, + "band_0204": 0.19 + }, + { + "wallet": "0x25d8c73a5ebeb6db934d8da68592ba3d4f1ca71f", + "name": "none910", + "weight": 0.0064, + "tier": "validated", + "z": 3.24, + "z_oos": 2.47, + "n": 1463, + "win_rate": 47.8, + "avg_entry": 0.44, + "band_0204": 0.1 + }, + { + "wallet": "0xca2d072efd5e4f02cd1a3539967ed1ab308539ed", + "name": "Originator", + "weight": 0.0064, + "tier": "validated", + "z": 3.2, + "z_oos": 2.12, + "n": 2000, + "win_rate": 51.3, + "avg_entry": 0.48, + "band_0204": 0.22 + }, + { + "wallet": "0xcea972195c2d011a5525df117abf0230e05157c6", + "name": "VelonLabs", + "weight": 0.0063, + "tier": "validated", + "z": 3.18, + "z_oos": 3.95, + "n": 1391, + "win_rate": 34.4, + "avg_entry": 0.31, + "band_0204": 0.17 + }, + { + "wallet": "0xb410255a64fd72acdb7e57b8f65999865a017736", + "name": "Lexma", + "weight": 0.0062, + "tier": "validated", + "z": 3.13, + "z_oos": 2.86, + "n": 2038, + "win_rate": 82.4, + "avg_entry": 0.81, + "band_0204": 0.02 + }, + { + "wallet": "0xe1d6b51521bd4365769199f392f9818661bd907c", + "name": "0xe1D6b51521Bd4365769199f392F9818661BD907", + "weight": 0.0062, + "tier": "validated", + "z": 3.11, + "z_oos": 2.8, + "n": 2119, + "win_rate": 48.0, + "avg_entry": 0.45, + "band_0204": 0.24 + }, + { + "wallet": "0xde0463ea7f611b065e8ab06bbfbddad75e6dfa37", + "name": "mwenya", + "weight": 0.0061, + "tier": "validated", + "z": 3.08, + "z_oos": 2.92, + "n": 2051, + "win_rate": 50.6, + "avg_entry": 0.48, + "band_0204": 0.16 + }, + { + "wallet": "0x323965e26fc0573234d6faa6819b6e886e986e1b", + "name": "maxev", + "weight": 0.006, + "tier": "validated", + "z": 3.04, + "z_oos": 1.77, + "n": 2006, + "win_rate": 99.2, + "avg_entry": 0.98, + "band_0204": 0.0 + }, + { + "wallet": "0xddb0629096a8a03f490b6572c06f2ee76465f95a", + "name": "IVY56", + "weight": 0.006, + "tier": "validated", + "z": 3.01, + "z_oos": 0.18, + "n": 2012, + "win_rate": 48.7, + "avg_entry": 0.47, + "band_0204": 0.06 + }, + { + "wallet": "0xfe37315964b9a43b48b4ece3882a08f226619d5c", + "name": "kitsune222", + "weight": 0.006, + "tier": "validated", + "z": 3.01, + "z_oos": 1.89, + "n": 635, + "win_rate": 50.6, + "avg_entry": 0.45, + "band_0204": 0.24 + }, + { + "wallet": "0xd189664c5308903476f9f079820431e4fd7d06f4", + "name": "rwo", + "weight": 0.006, + "tier": "validated", + "z": 2.99, + "z_oos": 1.75, + "n": 2000, + "win_rate": 53.6, + "avg_entry": 0.51, + "band_0204": 0.07 + }, + { + "wallet": "0x47138dc1eef25f1ea91f3b2fda0e0f455c634d21", + "name": "dsknadkasnkfjs", + "weight": 0.006, + "tier": "validated", + "z": 2.99, + "z_oos": 1.61, + "n": 2001, + "win_rate": 44.1, + "avg_entry": 0.41, + "band_0204": 0.26 + }, + { + "wallet": "0xf4f88a1e25ff5dfae68326eb62049d6a1b7c5bf1", + "name": "cosmicsteaks", + "weight": 0.0059, + "tier": "validated", + "z": 2.97, + "z_oos": 2.2, + "n": 2035, + "win_rate": 43.8, + "avg_entry": 0.41, + "band_0204": 0.32 + }, + { + "wallet": "0x86520db4175cf4e7f7f646cae22365f1f3b61ccb", + "name": "traxnorth", + "weight": 0.0058, + "tier": "validated", + "z": 2.92, + "z_oos": 1.0, + "n": 2001, + "win_rate": 42.7, + "avg_entry": 0.4, + "band_0204": 0.26 + }, + { + "wallet": "0xb7ab821f037a4c8deb9b23b8001a4be985d116d0", + "name": "funfacts", + "weight": 0.0058, + "tier": "validated", + "z": 2.92, + "z_oos": 3.07, + "n": 2015, + "win_rate": 63.7, + "avg_entry": 0.61, + "band_0204": 0.05 + }, + { + "wallet": "0x4caee221267f30efdc751bbca7fdb7ffef10c482", + "name": "Hinton07", + "weight": 0.0057, + "tier": "validated", + "z": 2.88, + "z_oos": 1.71, + "n": 2011, + "win_rate": 90.0, + "avg_entry": 0.88, + "band_0204": 0.0 + }, + { + "wallet": "0x1d949489f736378cbde40db18c093c5cff459100", + "name": "BullishBarb", + "weight": 0.0057, + "tier": "validated", + "z": 2.87, + "z_oos": 1.31, + "n": 2108, + "win_rate": 53.4, + "avg_entry": 0.51, + "band_0204": 0.25 + }, + { + "wallet": "0x43d7c53c20814021da901e89bc0a582ee7881c75", + "name": "STULAH", + "weight": 0.0057, + "tier": "validated", + "z": 2.85, + "z_oos": 3.3, + "n": 2004, + "win_rate": 44.3, + "avg_entry": 0.41, + "band_0204": 0.34 + }, + { + "wallet": "0x55154a54cd74db752540dcd663a30cdb5c00eb8b", + "name": "AK47ForLIFE", + "weight": 0.0057, + "tier": "validated", + "z": 2.84, + "z_oos": 2.64, + "n": 1405, + "win_rate": 41.5, + "avg_entry": 0.38, + "band_0204": 0.35 + }, + { + "wallet": "0x15ac9d4fb9c30fca4fec2fdecde3fdace721f3ba", + "name": "toireK", + "weight": 0.0056, + "tier": "validated", + "z": 2.82, + "z_oos": 3.4, + "n": 2006, + "win_rate": 96.7, + "avg_entry": 0.96, + "band_0204": 0.01 + }, + { + "wallet": "0x229a29c0e1a54d3177d899af3b36ceb0f67b1bf0", + "name": "HEROZ", + "weight": 0.0056, + "tier": "validated", + "z": 2.82, + "z_oos": 0.42, + "n": 1352, + "win_rate": 39.6, + "avg_entry": 0.37, + "band_0204": 0.18 + }, + { + "wallet": "0x4d4bdf24c73473e5b68e230bd843a667276a153c", + "name": "SisplacBII", + "weight": 0.0056, + "tier": "validated", + "z": 2.79, + "z_oos": 1.11, + "n": 2100, + "win_rate": 71.8, + "avg_entry": 0.69, + "band_0204": 0.02 + }, + { + "wallet": "0x689c37af05b28153d718757a5b3c1008c4226b1e", + "name": "amnesia12", + "weight": 0.0055, + "tier": "validated", + "z": 2.78, + "z_oos": 4.82, + "n": 1854, + "win_rate": 56.6, + "avg_entry": 0.54, + "band_0204": 0.14 + }, + { + "wallet": "0x255ab4ffc544bccbcdc2e5891fa239c5a3813435", + "name": "BaronOnThrone", + "weight": 0.0055, + "tier": "validated", + "z": 2.76, + "z_oos": 2.27, + "n": 2001, + "win_rate": 38.7, + "avg_entry": 0.36, + "band_0204": 0.4 + }, + { + "wallet": "0x290367f43d250062f470bb531fde2e3ab32e9203", + "name": "HarryRitter", + "weight": 0.0055, + "tier": "validated", + "z": 2.74, + "z_oos": 3.49, + "n": 1611, + "win_rate": 64.1, + "avg_entry": 0.61, + "band_0204": 0.12 + } +] \ No newline at end of file diff --git a/wide/README.md b/wide/README.md new file mode 100644 index 00000000..7f13c684 --- /dev/null +++ b/wide/README.md @@ -0,0 +1,71 @@ +# wide/ — bulk subgraph edge scanner + +Find wallets with a real edge across **all** of Polymarket (~1.76M traders, +268k conditions) by bulk-ingesting the on-chain subgraph into a local DuckDB +and ranking with SQL — instead of per-wallet API calls (which cap out at a few +hundred wallets and rate-limit). + +## Why a local DB, not the API + +The Goldsky orderbook subgraph **times out on any `orderBy`** over a +non-indexed field (`numTrades`, `scaledProfit`, …). You cannot ask it for "top +wallets." The only scalable pattern is: cursor-paginate every row by `id` (the +indexed key), land it locally, and rank in DuckDB. That constraint *is* the +architecture. + +## Why the win rate here is honest + +The data-api hides losers: Polymarket only redeems winning shares, so losing +positions sit unredeemed and never enter `/closed-positions`. Measuring win +rate there reads ~90% when the truth is ~50% (see ../FINDINGS.md). The subgraph +records a `marketPosition` for **every** buy regardless of redemption, so the +survivorship bias structurally does not exist in this data. + +## What "edge" means + +A high win rate is not edge — you can hit 90% by only buying 95¢ favorites. +Edge is **beating the prices you paid**: + +``` +p = valueBought / quantityBought (entry price, 0..1) +won = the outcome you held paid out +z = (wins − Σp) / sqrt(Σ p(1−p)) standard deviations above odds-implied +``` + +`z` high over enough bets, on a wallet that isn't a market-maker, is the real +signal (the same one ../insider.py computes, here over the entire market). + +## Pipeline + +| step | script | source | notes | +|------|--------|--------|-------| +| 1 | `ingest.py conditions` | subgraph | resolution + payoutNumerators → winning outcome | +| 2 | `gamma_tokens.py` | Gamma | token_id → (condition, outcome_index); subgraph's `outcomeIndex` is null | +| 3 | `ingest.py accounts` | subgraph | `numTrades` (market-maker filter), `creationTimestamp` (freshness) | +| 4 | `ingest.py market_positions` | subgraph | the heavy table: entry price + win/loss per bet | +| 5 | `score.py` | DuckDB | z, true win rate, profit + FDR + out-of-sample | + +All ingests are **resumable** (per-table `id` cursor in `_cursor`), so a long +run can be stopped and restarted. Tables join in `edge.sql`. + +## Guardrails (so a 1.76M-wallet scan doesn't just surface luck) + +- **min-n + market-maker cap.** A 300k-trade grinder posts huge z with no + information. Require ≥30 resolved bets and cap `numTrades`. +- **Benjamini–Hochberg FDR.** Scan 100k wallets and thousands clear z>3 by + chance (look-elsewhere effect). `score.py` reports how many survive 5% FDR. +- **Out-of-sample.** `score.py --cutoff YYYY-MM-DD` selects wallets on bets + resolved before the date, then measures the *same* wallets forward. Edge that + is real persists; edge that is curve-fit reverts to z≈0 — which is what every + strategy in ../FINDINGS.md did. **Do not size up on in-sample z.** + +## Usage + +```bash +pip install duckdb +python3 ingest.py conditions accounts # small tables +python3 gamma_tokens.py # token→outcome map +python3 ingest.py -p market_positions # heavy (~53M rows); parallel + resumable +python3 score.py --min-n 15 --max-trades 5000 --top 40 +python3 score.py --cutoff 2026-04-30 --min-n 15 # in-sample vs forward +``` diff --git a/wide/clob_tokens.py b/wide/clob_tokens.py new file mode 100644 index 00000000..24a776b6 --- /dev/null +++ b/wide/clob_tokens.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Build token -> (condition, outcome_index, winner) from the CLOB /markets feed. + +Gamma offset-paginates and 422s past ~10k markets; the subgraph's outcomeIndex +is null. CLOB /markets cursor-paginates the full market set (no offset cap), +1000 per page, and each token carries `winner` directly — so win/loss comes +straight from here instead of parsing payoutNumerators. + + python3 clob_tokens.py # page all markets, fill market_data +""" + +import json +import ssl +import time +import urllib.request + +import duckdb + +DB = "pmkt.duckdb" +CLOB = "https://clob.polymarket.com/markets" +_CTX = ssl._create_unverified_context() +END = "LTE=" # CLOB's end-of-pagination cursor (base64 of -1) + + +def get(cursor, retries=6): + url = CLOB + (f"?next_cursor={cursor}" if cursor else "") + delay = 1.0 + for attempt in range(retries): + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + return json.loads(urllib.request.urlopen(req, timeout=40, context=_CTX).read()) + except Exception: + if attempt == retries - 1: + raise + time.sleep(delay); delay = min(delay * 2, 20) + + +def main(): + con = duckdb.connect(DB) + con.execute("""CREATE TABLE IF NOT EXISTS market_data ( + token_id TEXT PRIMARY KEY, condition_id TEXT, + outcome_index INT, winner BOOLEAN);""") + con.execute("CREATE TABLE IF NOT EXISTS _cursor (table_name TEXT PRIMARY KEY, last_id TEXT);") + row = con.execute("SELECT last_id FROM _cursor WHERE table_name='market_data#clob'").fetchone() + cursor = row[0] if row else "" # resume from saved CLOB cursor + total, pages, t0 = 0, 0, time.time() + if cursor: + print(f"resuming token map from cursor {cursor}", flush=True) + while cursor != END: + d = get(cursor) + rows = [] + for m in d.get("data", []): + cond = m.get("condition_id") + toks = m.get("tokens") or [] + if not cond: + continue + for idx, t in enumerate(toks): # tokens ordered by outcome + tid = t.get("token_id") + if tid: + rows.append((str(tid), cond, idx, bool(t.get("winner")))) + pages += 1 + nxt = d.get("next_cursor") + if rows: + con.execute("BEGIN TRANSACTION") + con.executemany("INSERT OR IGNORE INTO market_data VALUES (?,?,?,?)", rows) + # checkpoint the NEXT cursor so a resume continues past this page + con.execute("INSERT OR REPLACE INTO _cursor VALUES ('market_data#clob', ?)", + [nxt or cursor]) + con.execute("COMMIT") + total += len(rows) + if not nxt or nxt == cursor: # safety: no progress + break + cursor = nxt + if pages % 25 == 0: + print(f" {pages} pages · {total:,} tokens ({total/max(1e-9,time.time()-t0):,.0f}/s)", flush=True) + cnt = con.execute("SELECT count(*) FROM market_data").fetchone()[0] + won = con.execute("SELECT count(*) FROM market_data WHERE winner").fetchone()[0] + print(f"done — {cnt:,} token rows ({won:,} winning) over {pages} pages", flush=True) + con.close() + + +if __name__ == "__main__": + main() diff --git a/wide/edge.sql b/wide/edge.sql new file mode 100644 index 00000000..797ea296 --- /dev/null +++ b/wide/edge.sql @@ -0,0 +1,47 @@ +-- Per-wallet edge over RESOLVED markets, computed entirely in DuckDB. +-- +-- The join chain: market_positions (a wallet's buy in one outcome token) +-- -> market_data (token -> condition + outcome_index) +-- -> conditions (resolution + payoutNumerators -> which outcome won). +-- +-- Why this beats the data-api: market_positions records a buy whether or not +-- the wallet redeemed, so losers are NOT hidden. The survivorship bias that +-- makes /closed-positions read 90% (truly 48%) does not exist here. +-- +-- entry price p = valueBought / quantityBought (USDC 6dp / shares 6dp -> 0..1) +-- won = payoutNumerators[outcome_index] != 0 +-- z = (wins - Σp) / sqrt(Σ p(1-p)) -- wins above what odds implied +-- +-- :cutoff_ts binds an out-of-sample boundary. Pass 0 to score everything. + +WITH bet AS ( + SELECT + mp.user_id, + c.resolution_ts, + LEAST(0.999, GREATEST(0.001, + mp.val_bought::DOUBLE / mp.qty_bought)) AS p, + CASE WHEN md.winner THEN 1 ELSE 0 END AS won + FROM market_positions mp + JOIN market_data md ON md.token_id = mp.token_id + JOIN conditions c ON c.id = md.condition_id + WHERE mp.qty_bought > 0 + AND c.resolution_ts > 0 +) +SELECT + b.user_id, + count(*) AS n, + sum(b.won) AS wins, + round(sum(b.p), 1) AS exp_wins, + round(100.0 * sum(b.won) / count(*), 1) AS win_rate, + round((sum(b.won) - sum(b.p)) + / sqrt(nullif(sum(b.p * (1 - b.p)), 0)), 2) AS z, + round(avg(b.p), 3) AS avg_entry, + a.scaled_profit AS profit, + a.scaled_volume AS volume, + a.creation_ts +FROM bet b +LEFT JOIN accounts a ON a.id = b.user_id +WHERE b.resolution_ts <= :cutoff_ts OR :cutoff_ts = 0 +GROUP BY b.user_id, a.scaled_profit, a.scaled_volume, a.creation_ts +HAVING count(*) >= :min_n +ORDER BY z DESC; diff --git a/wide/ingest.py b/wide/ingest.py new file mode 100644 index 00000000..9a4b0e8e --- /dev/null +++ b/wide/ingest.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Bulk-ingest the Polymarket subgraph into a local DuckDB (pmkt.duckdb). + +Each table is cursor-paginated by id and resumable: we checkpoint the last +id seen, and re-running continues where it left off (INSERT OR IGNORE makes +overlap harmless). All ranking/scoring happens later in SQL — see edge.sql. + + python3 ingest.py conditions market_data accounts # the small tables + python3 ingest.py market_positions # the heavy table + python3 ingest.py all +""" + +import queue +import sys +import threading +import time + +import duckdb + +import subgraph as sg + +DB = "pmkt.duckdb" +BATCH = 5000 + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS conditions ( + id TEXT PRIMARY KEY, resolution_ts BIGINT, + payout_num TEXT, payout_den BIGINT, slots INT); +CREATE TABLE IF NOT EXISTS market_data ( + token_id TEXT PRIMARY KEY, condition_id TEXT, outcome_index INT); +CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, num_trades BIGINT, creation_ts BIGINT, + scaled_profit DOUBLE, scaled_volume DOUBLE); +CREATE TABLE IF NOT EXISTS market_positions ( + id TEXT PRIMARY KEY, user_id TEXT, token_id TEXT, + qty_bought HUGEINT, val_bought HUGEINT, net_qty HUGEINT); +CREATE TABLE IF NOT EXISTS _cursor (table_name TEXT PRIMARY KEY, last_id TEXT); +""" + +# entity -> (graphql_fields, where_clause, row_mapper, target_table, columns) +def _i(x, d=0): + try: + return int(x) + except (TypeError, ValueError): + return d + +def _f(x, d=0.0): + try: + return float(x) + except (TypeError, ValueError): + return d + +SPECS = { + "conditions": dict( + entity="conditions", + fields="id resolutionTimestamp payoutNumerators payoutDenominator outcomeSlotCount", + where="", + table="conditions", + cols=("id", "resolution_ts", "payout_num", "payout_den", "slots"), + row=lambda c: (c["id"], _i(c.get("resolutionTimestamp")), + ",".join(c.get("payoutNumerators") or []), + _i(c.get("payoutDenominator")), _i(c.get("outcomeSlotCount"))), + ), + # NOTE: market_data (token -> outcome) comes from Gamma, not the subgraph — + # the subgraph's marketData.outcomeIndex is null. See gamma_tokens.py. + "accounts": dict( + entity="accounts", + fields="id numTrades creationTimestamp scaledProfit scaledCollateralVolume", + where="", + table="accounts", + cols=("id", "num_trades", "creation_ts", "scaled_profit", "scaled_volume"), + row=lambda a: (a["id"], _i(a.get("numTrades")), _i(a.get("creationTimestamp")), + _f(a.get("scaledProfit")), _f(a.get("scaledCollateralVolume"))), + ), + # marketPosition.id == user_address (0x + 40 hex = 42 chars) + token_id + # (decimal). We split it out instead of selecting the nested user/market + # objects, which the subgraph errors on when `market` is null. + "market_positions": dict( + entity="marketPositions", + fields="id quantityBought valueBought netQuantity", + where="", + table="market_positions", + cols=("id", "user_id", "token_id", "qty_bought", "val_bought", "net_qty"), + row=lambda p: (p["id"], p["id"][:42], p["id"][42:], + _i(p.get("quantityBought")), _i(p.get("valueBought")), + _i(p.get("netQuantity"))), + ), +} + + +def ingest(con, name, limit=0): + spec = SPECS[name] + last = con.execute("SELECT last_id FROM _cursor WHERE table_name=?", [name]).fetchone() + start = last[0] if last else "" + placeholders = ",".join("?" * len(spec["cols"])) + insert = (f"INSERT OR IGNORE INTO {spec['table']} " + f"({','.join(spec['cols'])}) VALUES ({placeholders})") + buf, total, t0, last_seen = [], 0, time.time(), start + + def flush(cursor_id): + nonlocal buf + if buf: + con.executemany(insert, buf) + buf = [] + con.execute("INSERT OR REPLACE INTO _cursor VALUES (?, ?)", [name, cursor_id]) + + print(f"[{name}] resuming from id={start[:14] or '(start)'}", flush=True) + for row in sg.paginate(spec["entity"], spec["fields"], where=spec["where"], start_id=start): + buf.append(spec["row"](row)) + total += 1 + last_seen = row["id"] + if len(buf) >= BATCH: + flush(last_seen) + rate = total / max(1e-9, time.time() - t0) + print(f"[{name}] {total:>9,} ({rate:,.0f}/s)", flush=True) + if limit and total >= limit: + break + flush(last_seen) # remaining buffer + advance cursor to the last id seen + cnt = con.execute(f"SELECT count(*) FROM {spec['table']}").fetchone()[0] + print(f"[{name}] done — {total:,} pulled this run, {cnt:,} rows total", flush=True) + + +def ingest_parallel(con, name, shards=16): + """Page `shards` id-ranges concurrently; workers fetch and enqueue, this + (single) thread writes to DuckDB and checkpoints each shard's cursor. + ~`shards`× the sequential throughput, and fully resumable per shard.""" + spec = SPECS[name] + bounds = sg.shard_bounds(shards) + placeholders = ",".join("?" * len(spec["cols"])) + insert = (f"INSERT OR IGNORE INTO {spec['table']} " + f"({','.join(spec['cols'])}) VALUES ({placeholders})") + starts = {} + for i in range(shards): + row = con.execute("SELECT last_id FROM _cursor WHERE table_name=?", + [f"{name}#{i:02d}"]).fetchone() + starts[i] = row[0] if row else "" + q = queue.Queue(maxsize=400) + DONE = object() + + def worker(i): + try: + for rows, last in sg.paginate_pages(spec["entity"], spec["fields"], + lo=bounds[i], hi=bounds[i + 1], + start_id=starts[i]): + q.put((i, [spec["row"](r) for r in rows], last)) + except Exception as e: + q.put((i, "ERR", str(e)[:120])) + q.put((i, DONE, None)) + + for i in range(shards): + threading.Thread(target=worker, args=(i,), daemon=True).start() + + # DuckDB fsyncs per commit, so committing each page caps us at the writer + # (~380/s) while fetch concurrency does ~4,600/s. Buffer many pages and + # commit in one transaction to amortize the fsync. + COMMIT_ROWS = 25000 + finished, total, t0 = 0, 0, time.time() + buf, shard_last = [], {} + + def flush(): + nonlocal buf + if not buf: + return + con.execute("BEGIN TRANSACTION") + con.executemany(insert, buf) + for sh, lid in shard_last.items(): + con.execute("INSERT OR REPLACE INTO _cursor VALUES (?, ?)", + [f"{name}#{sh:02d}", lid]) + con.execute("COMMIT") + buf = [] + + print(f"[{name}] {shards} parallel shards", flush=True) + while finished < shards: + i, payload, last = q.get() + if payload is DONE: + finished += 1 + continue + if payload == "ERR": + print(f"[{name}] shard {i:02d} error: {last}", flush=True) + continue + buf.extend(payload) + shard_last[i] = last + total += len(payload) + if len(buf) >= COMMIT_ROWS: + flush() + rate = total / max(1e-9, time.time() - t0) + print(f"[{name}] {total:>10,} ({rate:,.0f}/s, {finished}/{shards} shards done)", + flush=True) + flush() + cnt = con.execute(f"SELECT count(*) FROM {spec['table']}").fetchone()[0] + print(f"[{name}] done — {total:,} pulled this run, {cnt:,} rows total", flush=True) + + +def main(argv): + parallel = False + if argv and argv[0] in ("-p", "--parallel"): + parallel = True; argv = argv[1:] + limit = 0 + if argv and argv[-1].isdigit(): # optional trailing row-limit (per table) + limit = int(argv[-1]); argv = argv[:-1] + targets = argv or ["all"] + if targets == ["all"]: + targets = ["conditions", "accounts", "market_positions"] + con = duckdb.connect(DB) + con.execute(SCHEMA) + for t in targets: + if t not in SPECS: + print(f"unknown table: {t}", file=sys.stderr); continue + if parallel and not limit: + ingest_parallel(con, t) + else: + ingest(con, t, limit) + con.close() + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/wide/run_full.sh b/wide/run_full.sh new file mode 100755 index 00000000..3137f78e --- /dev/null +++ b/wide/run_full.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Massive ingest of the wallet data we still need, from the FAST subgraph +# (accounts + market_positions). The CLOB token map is already sufficient +# (1.27M tokens covering every resolved condition), so it's NOT re-run here — +# run `python3 clob_tokens.py` separately if you want to refresh it. +# +# Both steps are parallel (16 id-shards) and resumable (per-shard cursors), so +# killing and re-running continues from the last checkpoint. +# +# nohup ./run_full.sh > run_full.log 2>&1 < /dev/null & disown +# tail -f run_full.log +set -u +cd "$(dirname "$0")" + +until python3 -c "import duckdb;duckdb.connect('pmkt.duckdb',read_only=True).close()" 2>/dev/null; do + sleep 5 +done + +echo "[run] $(date '+%F %T') 1/2 accounts (parallel, resumable) …" +python3 ingest.py -p accounts + +echo "[run] $(date '+%F %T') 2/2 market_positions (parallel, resumable, the big one) …" +python3 ingest.py -p market_positions + +echo "[run] $(date '+%F %T') DONE" +python3 - <<'PY' +import duckdb +c = duckdb.connect("pmkt.duckdb", read_only=True) +for t in ("conditions", "market_data", "accounts", "market_positions"): + print(f" {t:18} {c.execute('select count(*) from '+t).fetchone()[0]:,}") +PY diff --git a/wide/score.py b/wide/score.py new file mode 100644 index 00000000..f819d086 --- /dev/null +++ b/wide/score.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Rank wallets by edge from the ingested DuckDB — with the guardrails that +stop a massive scan from just surfacing luck. + +Edge metric: z = (wins - Σp)/√Σp(1-p), wins above what entry odds implied. +A high win rate alone is meaningless (buy 95¢ favorites -> 90% wins, no edge), +and on this data win rate isn't even biased-high the way the data-api is. + +Guardrails: + * min_n resolved bets and a market-maker cap on num_trades (a 300k-trade + grinder posts huge z with no information — see FINDINGS.md / bjprolo). + * Benjamini-Hochberg FDR: scan 100k wallets and thousands clear z>3 by + chance. We report how many survive a 5% false-discovery rate. + * Out-of-sample: --cutoff scores wallets on bets resolved on/before a date, + then measures the SAME wallets forward. Edge that's real persists; edge + that's curve-fit (every strategy we tested) reverts to z~0 / 50%. + + python3 score.py --min-n 30 --max-trades 5000 --top 40 + python3 score.py --cutoff 2026-04-30 --min-n 30 # in-sample vs forward +""" + +import argparse +import math +import time + +import duckdb + +DB = "pmkt.duckdb" + + +def norm_sf(z): + """One-sided P(Z > z): the probability this z came from luck.""" + return 0.5 * math.erfc(z / math.sqrt(2)) if z is not None else 1.0 + + +def load_sql(cutoff_ts, min_n): + sql = open("edge.sql").read() + # these are ints we control (not user strings) -> safe to template + return sql.replace(":cutoff_ts", str(int(cutoff_ts))).replace(":min_n", str(int(min_n))) + + +def rank(con, cutoff_ts, min_n, max_n): + rows = con.execute(load_sql(cutoff_ts, min_n)).fetchall() + cols = [d[0] for d in con.description] + out = [] + for r in rows: + d = dict(zip(cols, r)) + # num_trades is null in this subgraph, so use the resolved-bet count as + # the market-maker proxy: a wallet with thousands of bets is grinding a + # systematic edge (bjprolo-style), not trading on information. + if max_n and d["n"] > max_n: + continue + d["pval"] = norm_sf(d["z"]) + out.append(d) + return out + + +def bh_fdr(rows, q=0.05): + """Benjamini-Hochberg: how many discoveries survive a q false-discovery rate.""" + m = len(rows) + if not m: + return 0, 1.0 + ps = sorted(r["pval"] for r in rows) + k = 0 + for i, p in enumerate(ps, 1): + if p <= q * i / m: + k = i + thresh = ps[k - 1] if k else 0.0 + return k, thresh + + +def to_ts(date_str): + if not date_str: + return 0 + return time.mktime(time.strptime(date_str, "%Y-%m-%d")) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--min-n", type=int, default=15, help="min resolved bets") + ap.add_argument("--max-n", type=int, default=3000, + help="exclude wallets with more resolved bets (market-maker proxy); 0=off") + ap.add_argument("--top", type=int, default=40) + ap.add_argument("--cutoff", help="YYYY-MM-DD: in-sample/out-of-sample split") + args = ap.parse_args() + + con = duckdb.connect(DB, read_only=True) + cutoff_ts = to_ts(args.cutoff) + rows = rank(con, cutoff_ts, args.min_n, args.max_n) + rows.sort(key=lambda r: (r["z"] is not None, r["z"]), reverse=True) + + k, thresh = bh_fdr(rows) + label = f"on/before {args.cutoff}" if args.cutoff else "all resolved bets" + print(f"\nscored {len(rows):,} wallets (min_n={args.min_n}, " + f"max_n={args.max_n or '∞'}) · {label}") + print(f"Benjamini-Hochberg @5% FDR: {k:,} wallets survive (p ≤ {thresh:.1e}) " + f"— the rest of the high-z tail is consistent with luck\n") + + hdr = f"{'z':>6}{'p(luck)':>10}{'rec':>13}{'win%':>7}{'avgP':>7}{'volume':>12}{'profit':>11} wallet" + print(hdr); print("-" * len(hdr)) + for r in rows[:args.top]: + rec = f"{r['wins']}/{r['n']}(E{r['exp_wins']:.0f})" + p = r["pval"] + ps = "<1e-12" if p <= 0 else (f"{p:.1e}" if p < 1e-3 else f"{p:.3f}") + star = " *" if p <= thresh and thresh > 0 else " " + print(f"{r['z']:>6.1f}{ps:>10}{rec:>13}{r['win_rate']:>6.1f}%{r['avg_entry']:>7.2f}" + f"{(r['volume'] or 0):>12,.0f}{(r['profit'] or 0):>11,.0f}{star}{r['user_id']}") + + if args.cutoff: + forward_oos(con, rows[:args.top], cutoff_ts, args.min_n) + + +def forward_oos(con, picks, cutoff_ts, min_n): + """For the in-sample top picks, measure their record AFTER the cutoff.""" + print(f"\n{'='*70}\nOUT-OF-SAMPLE: same wallets, only bets resolved AFTER cutoff") + print(f"{'='*70}") + ids = [r["user_id"] for r in picks] + if not ids: + return + # reuse the same join but flip the time filter and restrict to these wallets + sql = open("edge.sql").read() + sql = sql.replace("WHERE b.resolution_ts <= :cutoff_ts OR :cutoff_ts = 0", + f"WHERE b.resolution_ts > {int(cutoff_ts)} " + f"AND b.user_id IN ({','.join(repr(i) for i in ids)})") + sql = sql.replace("HAVING count(*) >= :min_n", "HAVING count(*) >= 1") + fwd = {r[0]: r for r in con.execute(sql).fetchall()} + cols = [d[0] for d in con.description] + zi, wi, ni, wri = cols.index("z"), cols.index("wins"), cols.index("n"), cols.index("win_rate") + print(f"{'in-sample z':>12}{'fwd z':>8}{'fwd rec':>12}{'fwd win%':>9} wallet") + for r in picks: + f = fwd.get(r["user_id"]) + if f: + fz = f"{f[zi]:.1f}" if f[zi] is not None else "n/a" + print(f"{r['z']:>12.1f}{fz:>8}{f'{f[wi]}/{f[ni]}':>12}{f[wri]:>8.1f}% {r['user_id']}") + else: + print(f"{r['z']:>12.1f}{'—':>8}{'(no fwd bets)':>12}{'—':>9} {r['user_id']}") + fz = [fwd[i][zi] for i in ids if i in fwd and fwd[i][zi] is not None] + if fz: + print(f"\nmedian forward z of in-sample winners: {sorted(fz)[len(fz)//2]:.2f} " + f"(near 0 = the in-sample edge did NOT persist)") + + +if __name__ == "__main__": + main() diff --git a/wide/subgraph.py b/wide/subgraph.py new file mode 100644 index 00000000..4e95b103 --- /dev/null +++ b/wide/subgraph.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Goldsky Polymarket orderbook subgraph client. + +The hosted subgraph times out on any `orderBy` over a non-indexed field +(numTrades, scaledProfit, ...), so we never sort server-side. Instead we +cursor-paginate by `id` (the indexed primary key) — `where:{id_gt:}`, +`first:1000` — which is stable and resumable. Ranking happens locally in +DuckDB after ingest. That constraint is exactly why the bulk-ingest design +is the only one that scales here. +""" + +import json +import ssl +import time +import urllib.request + +ENDPOINT = ("https://api.goldsky.com/api/public/" + "project_cl6mb8i9h0003e201j6li0diw/subgraphs/" + "polymarket-orderbook-resync/prod/gn") + +_CTX = ssl._create_unverified_context() +PAGE = 1000 + + +def query(gql, variables=None, retries=6): + """POST a GraphQL query, retrying on transient errors / timeouts.""" + body = json.dumps({"query": gql, "variables": variables or {}}).encode() + delay = 1.0 + for attempt in range(retries): + try: + req = urllib.request.Request( + ENDPOINT, data=body, + headers={"Content-Type": "application/json", + "User-Agent": "Mozilla/5.0"}) + r = json.loads(urllib.request.urlopen(req, timeout=60, context=_CTX).read()) + if "errors" in r: + # statement-timeout is transient under load; back off and retry + msg = r["errors"][0].get("message", "") + if "timeout" in msg.lower() or "timed out" in msg.lower(): + raise TimeoutError(msg) + raise RuntimeError(msg[:300]) + return r["data"] + except Exception as e: + if attempt == retries - 1: + raise + time.sleep(delay) + delay = min(delay * 2, 30) + return None + + +def paginate(entity, fields, where="", page=PAGE, start_id="", on_page=None): + """Yield every row of `entity`, cursor-paginating by id. + + `fields` is the GraphQL selection set (a string). `where` is extra filter + clauses (without braces), e.g. 'resolutionTimestamp_not: null'. Resume by + passing the last id seen as `start_id`. + """ + last = start_id + while True: + clauses = f'id_gt: "{last}"' + if where: + clauses += ", " + where + gql = (f'{{ {entity}(first: {page}, orderBy: id, orderDirection: asc, ' + f'where: {{ {clauses} }}) {{ {fields} }} }}') + rows = query(gql).get(entity, []) + if not rows: + return + for row in rows: + yield row + last = rows[-1]["id"] + if on_page: + on_page(len(rows), last) + if len(rows) < page: + return + + +def shard_bounds(n=16): + """Hex-prefix boundaries after '0x' that split the id space into n shards. + ids are lowercase hex; '0xg' sorts after every '0xf…' so it caps the last + shard. Returns n+1 bounds; shard i spans [bounds[i], bounds[i+1]).""" + d = "0123456789abcdef" + if n == 16: + return ["0x" + c for c in d] + ["0xg"] + if n == 256: + return ["0x" + a + b for a in d for b in d] + ["0xg"] + raise ValueError("n must be 16 or 256") + + +def paginate_pages(entity, fields, lo="", hi="", start_id="", page=PAGE): + """Yield (rows, last_id) per page for ids in (max(start_id,lo), hi). + Page-level so the caller can checkpoint each shard's cursor.""" + last = start_id or lo + while True: + clauses = f'id_gt: "{last}"' + if hi: + clauses += f', id_lt: "{hi}"' + gql = (f'{{ {entity}(first: {page}, orderBy: id, orderDirection: asc, ' + f'where: {{ {clauses} }}) {{ {fields} }} }}') + rows = query(gql).get(entity, []) + if not rows: + return + yield rows, rows[-1]["id"] + if len(rows) < page: + return + last = rows[-1]["id"] + + +if __name__ == "__main__": + # smoke test: count a few resolved conditions + n = 0 + for c in paginate("conditions", "id resolutionTimestamp payoutNumerators", + where="resolutionTimestamp_not: null"): + n += 1 + if n <= 2: + print(c) + if n >= 2500: + break + print(f"paged {n} resolved conditions ok")