mirror of
https://github.com/jaxperro/winning-wallet-finder.git
synced 2026-07-27 15:57:47 +00:00
3d0bc7f001
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 <noreply@anthropic.com>
149 lines
5.8 KiB
Python
149 lines
5.8 KiB
Python
#!/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()
|