Files
winning-wallet-finder_github/live/skill.py
T
jaxperro 08f87c589e paper-run realism: taker fees, resolved-only scoring, bounded collect, local runner
- copybot: model Polymarket taker fees (V2, since 2026-03-30: shares*rate*p*(1-p),
  sports 0.03) on every paper/live fill; track fees_paid in state + feed; settle
  P&L nets the entry fee. publish_feed now commits state + fills ledger with the
  feed (autostash rebase) so the local poller is the sole state writer.
- validate_timing: copy_pnl/held_pnl are fee-aware -> sharp selection now requires
  clearing real copy costs; conv stats + lead profile use resolved bets only.
- conviction_scan/skill: exclude res_t>now rows (early-sold positions in
  unresolved markets scored at curPrice - a mark, not an outcome; was ~5% of the
  June test window at a 72% pseudo-win rate).
- portfolio: skip unresolved rows (stake used to vanish from equity); missed bets
  of kind=open no longer KeyError - mark-to-market hypothetical P&L.
- collect: cap stale refreshes at STALE_CAP=2500/run (bulk-aged pool turned the
  daily refresh into a ~40h pull holding the DuckDB lock).
- Actions cron disabled: GitHub ran */5 every ~1.5-2.5h and the 10-min stale
  window skipped the rest -> 1 of ~104 qualifying buys copied. launchd --poll 60
  is now the runner; workflow stays as manual backstop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:15:14 -06:00

165 lines
6.9 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")
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()