2026-06-18 11:16:20 -06:00
|
|
|
|
#!/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
|
2026-07-01 23:15:14 -06:00
|
|
|
|
# resolved only: the cache stores early-sold positions in unresolved markets
|
|
|
|
|
|
# with res_t in the future and won = curPrice at pull time (a mark, not an
|
|
|
|
|
|
# outcome) — they must not count toward z.
|
|
|
|
|
|
now_t = time.time()
|
|
|
|
|
|
bets = [b for b in bets if (b.get("res_t") or 0) <= now_t]
|
2026-06-18 11:16:20 -06:00
|
|
|
|
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()
|