mirror of
https://github.com/jaxperro/winning-wallet-finder.git
synced 2026-07-27 15:57:47 +00:00
08f87c589e
- 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>
165 lines
6.9 KiB
Python
165 lines
6.9 KiB
Python
#!/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()
|