Files
winning-wallet-finder_github/live/skill.py
T
jaxperro 6a27f79df6 diagnosability: gate first-failure tally + BH no-pass message says why
validate_timing: the holder gate went 37->34->14->0 across runs and the
log said only '0 copy-positive holders' — which condition bit took an
hour of hand queries to reconstruct. Now every run prints the
first-failure breakdown (today: inactive_30d 48, PASS 34, copy_pnl<=0
20, trust_roi<=0 7, trust_wr<55% 3).

skill.py: a BH threshold of 0.0 is NOT a broken bar — it is 'nobody
cleared FDR'. With 1,207 simultaneous tests the rank-1 bar is p<=4.1e-5
and the best wallet is 1.7e-4, so a z~3 record is no longer
distinguishable from luck. The message now says that with the numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 21:54:49 -04:00

177 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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")
# A zero threshold is NOT a broken bar — it is BH reporting that nothing
# cleared FDR. Say so, with the numbers that make it checkable (2026-08-02:
# "p<= 0.0e+00" read like a defect and cost an hour of forensics).
if thresh > 0:
print(f"\nscored {len(rows):,} wallets · BH@{int(FDR_Q*100)}% "
f"threshold p<= {thresh:.1e}")
else:
_ps = sorted(r["pval"] for r in rows) or [float("nan")]
print(f"\nscored {len(rows):,} wallets · BH@{int(FDR_Q*100)}%: "
f"NOBODY CLEARED FDR — best p={_ps[0]:.2e} vs the rank-1 bar "
f"p<={FDR_Q/max(1,len(rows)):.2e}. Not a bug: with "
f"{len(rows):,} simultaneous tests the bar tightens, so a "
f"z~3 wallet is no longer distinguishable from luck.")
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()