Files
winning-wallet-finder/live/skill.py
T

165 lines
6.9 KiB
Python
Raw Normal View History

#!/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
# 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]
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()