Add live/ skilled-wallet scanner + cache; document clean OOS finding
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>
This commit is contained in:
@@ -24,3 +24,12 @@ watcher_state.json
|
|||||||
huntwide.csv
|
huntwide.csv
|
||||||
oos.log
|
oos.log
|
||||||
hunt.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/
|
||||||
|
|||||||
+30
@@ -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
|
- A durable trading edge has to come from *you* (a niche you know), with this
|
||||||
tooling built around your judgment.
|
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
|
## Repo layout
|
||||||
|
|
||||||
- `insider.py` — the detector: z-score/p-value, timing/freshness/sizing signals,
|
- `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.
|
- `copyback.py` / `oos.py` — in-sample and out-of-sample copy-trade backtests.
|
||||||
- `webhook_receiver.py` — push-based live trade watcher (Alchemy → Discord).
|
- `webhook_receiver.py` — push-based live trade watcher (Alchemy → Discord).
|
||||||
- `smart_money.py` — data foundation + dashboard (true-win-rate scanner).
|
- `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/` — the strategies that didn't work, kept for reference. See
|
||||||
`archive/README.md`.
|
`archive/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. |
|
| `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). |
|
| `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). |
|
| 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)). |
|
| `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
|
## The honest verdict
|
||||||
|
|
||||||
- **Detection works.** z-score + timing + funding-cluster reliably surfaces
|
- **Detection works.** z-score + timing + funding-cluster reliably surfaces
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -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}")
|
||||||
Executable
+13
@@ -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"
|
||||||
@@ -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()
|
||||||
Executable
+26
@@ -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"
|
||||||
File diff suppressed because one or more lines are too long
@@ -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"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Skilled Wallets — Polymarket</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0b0e14;--card:#141925;--line:#222b3a;--dim:#8a97ad;--fg:#e8edf5;
|
||||||
|
--green:#37d67a;--red:#ff5c6c;--amber:#ffcc55;--blue:#5b9dff;--violet:#b18cff}
|
||||||
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);
|
||||||
|
font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
|
||||||
|
.wrap{max-width:1180px;margin:0 auto;padding:22px 16px 60px}
|
||||||
|
h1{font-size:26px;margin:0 0 2px}.sub{color:var(--dim);margin:0 0 18px}
|
||||||
|
.cards{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:18px}
|
||||||
|
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;
|
||||||
|
padding:12px 16px;min-width:120px}.card .k{color:var(--dim);font-size:12px}
|
||||||
|
.card .v{font-size:22px;font-weight:700;margin-top:2px}
|
||||||
|
.controls{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:12px;align-items:center}
|
||||||
|
input,select{background:var(--card);border:1px solid var(--line);color:var(--fg);
|
||||||
|
border-radius:9px;padding:8px 11px;font-size:14px}
|
||||||
|
input{flex:1;min-width:180px}
|
||||||
|
.pill{padding:3px 9px;border-radius:999px;font-size:11px;font-weight:600;white-space:nowrap}
|
||||||
|
.value{background:rgba(55,214,122,.15);color:var(--green)}
|
||||||
|
.favorite{background:rgba(255,204,85,.15);color:var(--amber)}
|
||||||
|
.balanced{background:rgba(91,157,255,.15);color:var(--blue)}
|
||||||
|
table{width:100%;border-collapse:collapse;background:var(--card);
|
||||||
|
border:1px solid var(--line);border-radius:12px;overflow:hidden}
|
||||||
|
th,td{padding:10px 12px;text-align:right;border-bottom:1px solid var(--line);white-space:nowrap}
|
||||||
|
th:first-child,td:first-child,th:nth-child(2),td:nth-child(2){text-align:left}
|
||||||
|
th{color:var(--dim);font-size:12px;cursor:pointer;user-select:none;position:sticky;top:0;background:var(--card)}
|
||||||
|
th:hover{color:var(--fg)}tr.row:hover{background:#1a2030;cursor:pointer}
|
||||||
|
td.name{font-weight:600}a{color:var(--blue);text-decoration:none}a:hover{text-decoration:underline}
|
||||||
|
.z{font-weight:700}.mono{font-family:ui-monospace,Menlo,monospace;color:var(--dim);font-size:12px}
|
||||||
|
.det{background:#10141d;color:var(--dim);font-size:13px}
|
||||||
|
.det td{text-align:left;white-space:normal}
|
||||||
|
.bar{display:inline-block;height:7px;border-radius:4px;background:var(--violet);vertical-align:middle}
|
||||||
|
.note{color:var(--dim);font-size:12px;margin-top:14px;line-height:1.6}
|
||||||
|
.trade{display:flex;gap:8px;align-items:center;padding:3px 0;border-bottom:1px solid var(--line)}
|
||||||
|
.b{color:var(--green)}.s{color:var(--red)}
|
||||||
|
</style></head><body><div class="wrap">
|
||||||
|
<h1>Skilled Wallets <span style="color:var(--dim);font-weight:400;font-size:18px">· Polymarket</span></h1>
|
||||||
|
<p class="sub">{{COUNT}} wallets that beat their own entry prices and held up out-of-sample · generated {{GEN}}</p>
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card"><div class="k">Validated wallets</div><div class="v">{{COUNT}}</div></div>
|
||||||
|
<div class="card"><div class="k">Median z</div><div class="v">{{MEDZ}}</div></div>
|
||||||
|
<div class="card"><div class="k">Median OOS z</div><div class="v" style="color:var(--green)">{{MEDOOS}}</div></div>
|
||||||
|
<div class="card"><div class="k">Value / longshot</div><div class="v" style="color:var(--green)">{{NVAL}}</div></div>
|
||||||
|
<div class="card"><div class="k">Favorite-rider</div><div class="v" style="color:var(--amber)">{{NFAV}}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<input id="q" placeholder="search name or address…">
|
||||||
|
<select id="arch"><option value="">All archetypes</option>
|
||||||
|
<option value="value">Value / longshot</option>
|
||||||
|
<option value="balanced">Balanced</option>
|
||||||
|
<option value="favorite">Favorite-rider</option></select>
|
||||||
|
<select id="sort">
|
||||||
|
<option value="z">Sort: z (skill)</option>
|
||||||
|
<option value="z_oos">Sort: out-of-sample z</option>
|
||||||
|
<option value="band_0204">Sort: alpha-zone %</option>
|
||||||
|
<option value="n">Sort: # bets</option>
|
||||||
|
<option value="win_rate">Sort: win rate</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<table id="tbl"><thead><tr>
|
||||||
|
<th data-k="rank">#</th><th data-k="name">Wallet</th>
|
||||||
|
<th data-k="z">z</th><th data-k="z_oos">OOS z</th>
|
||||||
|
<th data-k="n">bets</th><th data-k="win_rate">win%</th>
|
||||||
|
<th data-k="avg_entry">avg entry</th><th data-k="band_0204">alpha 0.2–0.4</th>
|
||||||
|
<th data-k="arch">type</th>
|
||||||
|
</tr></thead><tbody id="body"></tbody></table>
|
||||||
|
<p class="note"><b>How to read it.</b> <b>z</b> = how far the wallet beat the prices it paid (skill; >3 is strong). <b>OOS z</b> = whether that skill held on held-out bets — the gate that separates real edge from luck. <b>Alpha 0.2–0.4</b> = share of bets in the underdog value zone where research finds real edge concentrates. <span class="value" style="padding:1px 7px">Value</span> wallets win by beating longshot/mid prices (the copyable alpha); <span class="favorite" style="padding:1px 7px">Favorite-rider</span> 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.</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const DATA=/*DATA*/;
|
||||||
|
const fmtW=w=>w.length>14?w.slice(0,6)+'…'+w.slice(-4):w;
|
||||||
|
let sortK='z',asc=false,open=null;
|
||||||
|
const body=document.getElementById('body');
|
||||||
|
function render(){
|
||||||
|
const q=document.getElementById('q').value.toLowerCase();
|
||||||
|
const af=document.getElementById('arch').value;
|
||||||
|
let rows=DATA.filter(r=>(!af||r.arch===af)&&
|
||||||
|
(!q||(r.name||'').toLowerCase().includes(q)||r.wallet.toLowerCase().includes(q)));
|
||||||
|
rows.sort((a,b)=>{const x=a[sortK],y=b[sortK];return (asc?1:-1)*((x>y)-(x<y));});
|
||||||
|
body.innerHTML=rows.map(r=>{
|
||||||
|
const zc=r.z>=10?'var(--violet)':r.z>=6?'var(--green)':'var(--fg)';
|
||||||
|
const oc=r.z_oos>=5?'var(--green)':r.z_oos>=3?'var(--amber)':'var(--red)';
|
||||||
|
const bw=Math.round(r.band_0204*46);
|
||||||
|
return `<tr class="row" data-w="${r.wallet}">
|
||||||
|
<td>${r.rank}</td>
|
||||||
|
<td class="name">${r.name||fmtW(r.wallet)}<div class="mono">${fmtW(r.wallet)}</div></td>
|
||||||
|
<td class="z" style="color:${zc}">${r.z.toFixed(1)}</td>
|
||||||
|
<td class="z" style="color:${oc}">${r.z_oos==null?'—':r.z_oos.toFixed(1)}</td>
|
||||||
|
<td>${r.wins}/${r.n}</td>
|
||||||
|
<td>${r.win_rate.toFixed(0)}%</td>
|
||||||
|
<td>${r.avg_entry.toFixed(2)}</td>
|
||||||
|
<td><span class="bar" style="width:${bw}px"></span> ${(r.band_0204*100).toFixed(0)}%</td>
|
||||||
|
<td><span class="pill ${r.arch}">${r.arch}</span></td></tr>`;
|
||||||
|
}).join('')||'<tr><td colspan="9" style="text-align:center;color:var(--dim);padding:24px">no matches</td></tr>';
|
||||||
|
}
|
||||||
|
body.addEventListener('click',async e=>{
|
||||||
|
const tr=e.target.closest('tr.row'); if(!tr)return;
|
||||||
|
const w=tr.dataset.w;
|
||||||
|
const nx=tr.nextElementSibling;
|
||||||
|
if(nx&&nx.classList.contains('det')){nx.remove();return;}
|
||||||
|
document.querySelectorAll('tr.det').forEach(x=>x.remove());
|
||||||
|
const d=document.createElement('tr');d.className='det';
|
||||||
|
d.innerHTML=`<td colspan="9">loading recent trades… · <a href="https://polymarket.com/profile/${w}" target="_blank">open profile ↗</a></td>`;
|
||||||
|
tr.after(d);
|
||||||
|
try{
|
||||||
|
const r=await fetch(`https://data-api.polymarket.com/activity?user=${w}&type=TRADE&limit=8`);
|
||||||
|
const ts=await r.json();
|
||||||
|
const rows=(ts||[]).map(t=>`<div class="trade"><span class="${t.side==='BUY'?'b':'s'}">${t.side}</span>
|
||||||
|
<span>${(t.outcome||'')} · ${(t.title||'').slice(0,52)}</span>
|
||||||
|
<span style="margin-left:auto;color:var(--dim)">${((t.price||0)*100).toFixed(0)}¢</span></div>`).join('');
|
||||||
|
d.firstChild.innerHTML=`<a href="https://polymarket.com/profile/${w}" target="_blank">open profile ↗</a><div style="margin-top:6px">${rows||'no recent trades'}</div>`;
|
||||||
|
}catch(err){d.firstChild.innerHTML=`<a href="https://polymarket.com/profile/${w}" target="_blank">open profile ↗</a> · live feed unavailable`;}
|
||||||
|
});
|
||||||
|
document.querySelectorAll('th').forEach(th=>th.onclick=()=>{
|
||||||
|
const k=th.dataset.k; if(k===sortK)asc=!asc;else{sortK=k;asc=(k==='rank'||k==='avg_entry');} render();});
|
||||||
|
document.getElementById('q').oninput=render;
|
||||||
|
document.getElementById('arch').onchange=render;
|
||||||
|
document.getElementById('sort').onchange=e=>{sortK=e.target.value;asc=false;render();};
|
||||||
|
render();
|
||||||
|
</script></body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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()
|
||||||
+159
@@ -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()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
```
|
||||||
@@ -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()
|
||||||
@@ -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;
|
||||||
+217
@@ -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:])
|
||||||
Executable
+31
@@ -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
|
||||||
+145
@@ -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()
|
||||||
@@ -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:<last>}`,
|
||||||
|
`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")
|
||||||
Reference in New Issue
Block a user