scoring: chain-truth resolutions overlay — refunds no longer count as wins

The cache's won (curPrice>=0.5 at pull) counts 50/50 refunds as wins for
BOTH sides — 521 of 2,128 chain-checked follow-set markets (24%) were
refunds, which is where the whales' 92-100% displayed win rates came from.

- live/payouts.py: resolutions table in cache.duckdb, filled from the CTF
  contract's payout vectors (batched+paced JSON-RPC, ~12 calls/s free tier;
  resolved rows immutable, unresolved recheck 6h, RPC failures never cached).
  truth(cond, asset) -> 1/0/0.5/None; refunds need no asset side.
- validate_timing: every displayed stat (conv/conv30/all-time/realized/copy
  replay) settles at truth; refunds count as neither W nor L, P&L is
  size*(wp-p)/p; new conv_ref/conv30_ref/all_ref feed fields.
- trust.conviction_record: optional truthfn — the selection gates
  (trust_wr/trust_roi) no longer select on refund inflation.
- portfolio.py: replay pays wp (refunds 0.5/share, was 1.0).
- conviction_scan: documented as the (refund-inflated) candidate layer;
  final selection re-judges against truth downstream.

Validation: 0x4bFb-whale conv 174-16 91.6% $1.61M -> 34-16 +140ref 68%
$214k, and truth-adjusted all-time P&L now sits within ~16% of lb-api's
PM P&L (was 7x apart); LSB1 (0 refunds) byte-identical, its P&L matches
PM P&L to 0.07%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jaxperro
2026-07-07 01:54:58 -04:00
parent 23c6c44057
commit 4941818d51
5 changed files with 334 additions and 44 deletions
+8
View File
@@ -22,6 +22,14 @@ Profile gates (on TRAIN conviction bets):
* median conviction stake >= MIN_MED_STAKE (dust wallets betting $2-$6 clips
aren't followable and their fills aren't reproducible)
Then validate forward and count how many keep the profile.
NB (2026-07-06): this scan reads the cache's `won` marks, which count 50/50
REFUNDS as wins for both sides (28% of resolved markets in the in-play niche)
— so it OVER-generates candidates. That's acceptable: this is the candidate
layer; final selection (validate_timing.py) re-judges every candidate against
chain-truth payouts (payouts.py) and rejects refund-inflated records. Chain-
checking all ~19M rows here would take days of RPC; the funnel does it only
for the few dozen wallets that survive to the copy replay.
"""
import math, os, time
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Chain-truth resolution overlay for cache.duckdb — the 2026-07-06 audit fix.
The cache's `won` is `curPrice >= 0.5` at pull time, and that lies two ways
(see the audit + [[polymarket-resolution-truth]] in project memory):
* 50/50 REFUNDS (walkovers/abandonments — 28% of the follow set's resolved
markets in the 30d sample!) leave curPrice at 0.5, so EVERY holder of
EITHER side gets won=True and (1-p)/p phantom profit. The whales' 92-100%
displayed win rates were largely this.
* operator-resolved in-play markets can leave a stale mark on the losing
side (one confirmed both-sides-won market), which resolved=TRUE certifies.
The fix keeps the cache AS PULLED and overlays the on-chain truth: the CTF
contract's payout vector per condition, plus the market's token order so a
row's `asset` maps to its payout. Resolved payouts are immutable — fetched
once, cached in a `resolutions` table forever. Unresolved conditions recheck
after RECHECK_S.
Usage:
import payouts
payouts.ensure(conds) # batch-backfill (chain + CLOB, cached)
wp = payouts.truth(cond, asset) # 1.0 / 0.0 / 0.5 / None (unknown)
Scoring rule for consumers: truth 1/0 -> real win/loss; 0.5 -> REFUND, count
as neither and P&L = size*(0.5-p)/p; None -> fall back to the cache's `won`
(legacy NULL-asset rows, unresolved markets, RPC gaps).
RPC: config.json `alchemy_key` (or ALCHEMY_RPC_URL env). Batched JSON-RPC
(BATCH per POST); the CLOB market fetch supplies token order. Selection runs
on the Mac, so this module is never needed by the Fly worker.
"""
import json
import os
import ssl
import threading
import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor
import cache
_SSL = ssl._create_unverified_context()
HERE = os.path.dirname(__file__)
CTF = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
SEL_DEN = "0xdd34de67" # payoutDenominator(bytes32)
SEL_NUM = "0x0504c814" # payoutNumerators(bytes32,uint256)
BATCH = 60 # JSON-RPC calls per POST
PACE_S = float(os.environ.get("PAYOUTS_PACE_S", 5.0)) # sleep between batches:
# free-tier Alchemy sustains ~12 eth_calls/s — an
# unpaced backfill just trades 429 retries for gaps
RECHECK_S = 6 * 3600 # re-ask the chain about unresolved conds after this
cache.query("""CREATE TABLE IF NOT EXISTS resolutions(
cond TEXT PRIMARY KEY, p0 DOUBLE, p1 DOUBLE,
token0 TEXT, token1 TEXT, checked_at BIGINT)""")
_mem = {} # cond -> row tuple, warm in-process copy
_lock = threading.Lock()
def _rpc_url():
url = os.environ.get("ALCHEMY_RPC_URL")
if url:
return url
try:
key = json.load(open(os.path.join(HERE, "..", "config.json")))["alchemy_key"]
return f"https://polygon-mainnet.g.alchemy.com/v2/{key}"
except Exception:
return None
def _rpc_batch(calls):
"""calls: [hex calldata] -> [int results or None], one batched POST."""
url = _rpc_url()
if not url:
return [None] * len(calls)
body = json.dumps([{"jsonrpc": "2.0", "id": i, "method": "eth_call",
"params": [{"to": CTF, "data": d}, "latest"]}
for i, d in enumerate(calls)]).encode()
# retry with backoff — Alchemy 429s a fast backfill after ~2 batches, and a
# failed batch must surface as None (skip-this-run), never "unresolved"
for attempt in range(4):
try:
req = urllib.request.Request(url, data=body,
headers={"Content-Type": "application/json"})
rs = json.loads(urllib.request.urlopen(req, timeout=30, context=_SSL).read())
if isinstance(rs, dict): # whole-batch error object
raise OSError(str(rs.get("error"))[:80])
by_id = {r.get("id"): r for r in rs}
out = []
for i in range(len(calls)):
v = by_id.get(i, {}).get("result")
try:
# "0x" (revert/empty) must not kill the whole batch — one
# bad item used to blank every cond in the chunk
out.append(int(v, 16) if v and v != "0x" else None)
except (TypeError, ValueError):
out.append(None)
return out
except Exception:
if attempt == 3:
return [None] * len(calls)
time.sleep(2 ** attempt)
def _clob_tokens(cond):
"""(token0, token1) in the CLOB market's outcome order — payout index order."""
try:
req = urllib.request.Request("https://clob.polymarket.com/markets/" + cond,
headers={"User-Agent": "Mozilla/5.0"})
m = json.loads(urllib.request.urlopen(req, timeout=15, context=_SSL).read())
toks = [str(t.get("token_id")) for t in (m.get("tokens") or [])[:2]]
return (toks + [None, None])[:2]
except Exception:
return None, None
def _load(conds):
with _lock:
missing = [c for c in conds if c and c not in _mem]
if missing:
rows = cache.query(
"SELECT cond, p0, p1, token0, token1, checked_at FROM resolutions "
"WHERE cond IN (SELECT UNNEST(?::VARCHAR[]))", [missing])
with _lock:
for r in rows:
_mem[r[0]] = r
def ensure(conds, workers=8):
"""Make sure every cond has a resolutions row (fetching chain + CLOB for the
unknown ones). Resolved rows are permanent; unresolved recheck after
RECHECK_S. Safe to call with thousands of conds — everything is cached."""
conds = [c for c in {c for c in conds if c}]
_load(conds)
now = int(time.time())
with _lock:
todo = [c for c in conds
if c not in _mem
or (_mem[c][1] is None and now - (_mem[c][5] or 0) > RECHECK_S)]
if not todo:
return
# chain payouts, batched: den + num0 per cond (binary: num1 = den - num0)
payout = {}
for i in range(0, len(todo), BATCH // 2):
chunk = todo[i:i + BATCH // 2]
calls = []
for c in chunk:
h = c[2:].rjust(64, "0")
calls += [SEL_DEN + h,
SEL_NUM + h + "0".rjust(64, "0")]
res = _rpc_batch(calls)
for j, c in enumerate(chunk):
den, n0 = res[2 * j], res[2 * j + 1]
if den is None:
payout[c] = "rpc-fail" # do NOT cache — retry next run
elif den and n0 is not None:
payout[c] = (n0 / den, (den - n0) / den)
else:
payout[c] = (None, None) # chain says: not resolved yet
if i + BATCH // 2 < len(todo):
time.sleep(PACE_S)
# token order for the RESOLVED ones (index -> asset mapping); cached forever
resolved = [c for c in todo
if isinstance(payout.get(c), tuple) and payout[c][0] is not None]
tokens = {}
with ThreadPoolExecutor(max_workers=workers) as ex:
for c, tk in zip(resolved, ex.map(_clob_tokens, resolved)):
tokens[c] = tk
with _lock:
for c in todo:
p = payout.get(c)
if p == "rpc-fail" or p is None:
continue # transient — leave uncached, retry next run
t0, t1 = tokens.get(c, (None, None))
row = (c, p[0], p[1], t0, t1, now)
_mem[c] = row
cache.query("INSERT OR REPLACE INTO resolutions VALUES (?,?,?,?,?,?)", list(row))
def truth(cond, asset=None):
"""Chain-truth payout of this position: 1.0 / 0.0 / 0.5 / None (unknown).
Refunds ([0.5,0.5]) need no asset; decided markets map asset -> index."""
_load([cond])
r = _mem.get(cond)
if not r or r[1] is None:
return None
p0, p1, t0, t1 = r[1], r[2], r[3], r[4]
if p0 == p1: # refund (or exotic even split)
return p0
if asset is not None:
if str(asset) == str(t0):
return p0
if str(asset) == str(t1):
return p1
return None # decided, but we can't map the side
def stats():
n = cache.query("SELECT count(*), count(p0) FROM resolutions")[0]
return {"rows": n[0], "resolved": n[1]}
if __name__ == "__main__":
print(stats())
+29 -9
View File
@@ -31,6 +31,7 @@ import urllib.request
from concurrent.futures import ThreadPoolExecutor
import cache
import payouts
import smart_money as sm
import trust
@@ -179,24 +180,31 @@ def window_bets():
rows = trust.trusted_wallet_rows(cache.query, w["wallet"], now)
best = {} # one bet per market: largest-stake token
for cond, asset, won, p, res_t, size in rows:
if cond not in best or size > best[cond][3]:
best[cond] = (won, p, res_t, size)
if cond not in best or size > best[cond][4]:
best[cond] = (asset, won, p, res_t, size)
if w.get("class") == "whale":
thr = 0.0
else:
pre = [size for won, p, res_t, size in best.values() if res_t < START]
pre = [size for asset, won, p, res_t, size in best.values() if res_t < START]
thr = cache.conv_cutoff(pre if pre else
[size for *_, size in best.values()])
_WALLET_THR[w["wallet"]] = thr
for cond, (won, p, res_t, size) in best.items():
for cond, (asset, won, p, res_t, size) in best.items():
if size < thr or p > MAX_ENTRY:
continue
et = ent.get(cond)
if not et or et < START: # only in-window entries
continue
out.append({"wallet": w["wallet"], "name": w["name"], "cond": cond,
"cls": w.get("class", "volume"), "their": size,
"entry_t": et, "p": p, "won": won, "res_t": res_t or 0})
"asset": asset, "cls": w.get("class", "volume"),
"their": size, "entry_t": et, "p": p, "won": won,
"res_t": res_t or 0})
# chain-truth payouts for the replayed markets: refunds pay 0.5/share, and
# a cache `won` mark can be wrong on operator-resolved markets — the
# replay must settle at what a redeem actually pays (see payouts.py)
payouts.ensure({b["cond"] for b in out})
for b in out:
b["wp"] = payouts.truth(b["cond"], b.get("asset"))
return out
@@ -301,7 +309,12 @@ def main():
for ft, cost, payoff, rec in held:
if ft and ft <= upto and rec["kind"] == "res":
cash += payoff; realized += payoff - cost; perW[rec["wallet"]]["realized"] += payoff - cost
perW[rec["wallet"]]["won" if rec["won"] else "lost"] += 1
wp = rec.get("wp")
won = rec["won"] if wp is None else wp > 0.5
perW[rec["wallet"]]["won" if won else "lost"] += 1
rec["won"] = won # truth-adjusted for the feed
if wp == 0.5:
rec["refund"] = True
rec["pnl"] = payoff - cost
resolved.append(rec)
else:
@@ -325,7 +338,11 @@ def main():
cash -= cost; fees_paid += fee; perW[b["wallet"]]["bets"] += 1
shares = stake / p_eff # lag-adjusted entry price
if b["kind"] == "res":
payoff = shares * (1.0 if b["won"] else 0.0) # redeem is fee-free
# chain-truth payout (1/0/0.5) when known, else the cache mark
wp = b.get("wp")
if wp is None:
wp = 1.0 if b["won"] else 0.0
payoff = shares * wp # redeem is fee-free
held.append((b["res_t"] or now, cost, payoff, b))
else: # currently open -> mark to market, no free yet
held.append((None, cost, 0.0, b))
@@ -356,7 +373,10 @@ def main():
stake = m.get("stake") or STAKE_MIN
p_eff, fee, cost = entry_model(m["p"], stake)
if "won" in m:
return (stake / p_eff) - cost if m["won"] else -cost
wp = m.get("wp")
if wp is None:
wp = 1.0 if m["won"] else 0.0
return (stake / p_eff) * wp - cost
return stake * (m.get("cur", p_eff) / p_eff) - cost
missed.sort(key=lambda m: m.get("res_t") or 0, reverse=True)
+28 -9
View File
@@ -103,17 +103,23 @@ def trusted_wallet_rows(runq, wallet, now=None):
[wallet, now, now])
def conviction_record(runq, wallet, days=90, pctile=0.80, now=None):
def conviction_record(runq, wallet, days=90, pctile=0.80, now=None, truthfn=None):
"""Trailing trusted CONVICTION record for the held-edge gate:
{n, wr, roi} over the wallet's top-(1-pctile) stake bets resolved in the
last `days`. The conviction cutoff is that wallet's stake p80 over its FULL
trusted history (matching cache.conv_cutoff semantics). roi is the flat-
stake hold-to-resolution copy ROI per bet, fee/slip-free (gates compare it
to 0, and fees are already charged in copy_pnl, the other selection leg)."""
to 0, and fees are already charged in copy_pnl, the other selection leg).
truthfn(cond, asset) -> 1/0/0.5/None (payouts.truth): chain-truth payouts.
50/50 REFUNDS (28% of the follow-set niche's resolved markets!) then count
as NEITHER won nor lost, with roi credited at (0.5-p)/p — the cache alone
marks every refund won=True for both sides, which is exactly the inflation
these gates must not select on. None -> the cache's won (old behavior)."""
now = int(now or time.time())
rows = trusted_wallet_rows(runq, wallet, now)
if not rows:
return dict(n=0, wr=0.0, roi=0.0)
return dict(n=0, wr=0.0, roi=0.0, refunds=0)
sizes = sorted(r[5] for r in rows)
k = (len(sizes) - 1) * pctile
f = int(k)
@@ -123,11 +129,24 @@ def conviction_record(runq, wallet, days=90, pctile=0.80, now=None):
best = {}
for cond, asset, won, p, res_t, size in rows:
if size >= thr and res_t >= cut:
if cond not in best or size > best[cond][2]:
best[cond] = (won, p, size)
if cond not in best or size > best[cond][3]:
best[cond] = (cond, asset, won, p, size)
conv = list(best.values())
if not conv:
return dict(n=0, wr=0.0, roi=0.0)
wins = sum(1 for won, _, _ in conv if won)
roi = sum(((1 - p) / p if won else -1.0) for won, p, _ in conv) / len(conv)
return dict(n=len(conv), wr=wins / len(conv), roi=roi)
return dict(n=0, wr=0.0, roi=0.0, refunds=0)
wins = losses = refunds = 0
roi_sum = 0.0
for cond, asset, won, p, size in conv:
wp = truthfn(cond, asset) if truthfn else None
if wp is None:
wp = 1.0 if won else 0.0
roi_sum += (wp - p) / p
if wp > 0.5:
wins += 1
elif wp < 0.5:
losses += 1
else:
refunds += 1
decided = wins + losses
return dict(n=decided, wr=(wins / decided) if decided else 0.0,
roi=roi_sum / len(conv), refunds=refunds)
+63 -26
View File
@@ -37,6 +37,7 @@ import urllib.request
from concurrent.futures import ThreadPoolExecutor
import cache
import payouts
import smart_money as sm
import trust
@@ -93,11 +94,25 @@ def _pm_profit(w):
return None
def _bet_pnl(b):
"""Resolved (outcome) P&L of one cache bet: a $size stake at avg price p pays
size/p if won, else $0 — so P&L = size·(1p)/p if won else size."""
def _wp(cond, asset, won):
"""Chain-truth payout for a bet (1/0/0.5), falling back to the cache's
`won` mark when the chain can't say (unresolved, legacy NULL-asset rows on
decided markets, RPC gaps). The fallback keeps old behavior; the truth
path kills the two cache lies: 50/50 refunds counted as wins for BOTH
sides (28% of the follow set's resolved markets!) and stale both-sides-won
marks on operator-resolved markets."""
wp = payouts.truth(cond, asset)
return (1.0 if won else 0.0) if wp is None else wp
def _bet_pnl(b, wp=None):
"""Resolved P&L of one cache bet at payout wp: a $size stake at avg price p
returns size·(wpp)/p — wp=1 win, 0 loss, 0.5 refund ($0.50/share, NOT
money-back: flat near coin-flip entries, ruinous for favorites)."""
p = max(0.001, min(0.999, b["p"] or 0))
return b["size"] * ((1 - p) / p if b["won"] else -1)
if wp is None:
wp = _wp(b.get("cond"), b.get("asset"), b["won"])
return b["size"] * (wp - p) / p
def display_stats(w):
@@ -121,35 +136,51 @@ def display_stats(w):
now = time.time()
bets = [b for b in cache.get_bets(w)
if (b["size"] or 0) > 0 and (b["res_t"] or 0) <= now]
# chain-truth payouts for everything this wallet's stats touch (cached
# in the resolutions table — incremental after the first backfill)
trows = trust.trusted_wallet_rows(cache.query, w)
payouts.ensure({b["cond"] for b in bets} | {r[0] for r in trows})
# ---- ALL-TIME stats over EVERY trusted bet (any size): the dashboard's
# "of every bet placed" columns. Trusted rows only, deduped one-per-market,
# so scalper-poisoned cache marks can't inflate them. ----
trows = trust.trusted_wallet_rows(cache.query, w)
# truth-adjusted: refunds (wp=0.5) count as neither won nor lost. ----
tbest = {}
for cond, asset, won, p, res_t, size in trows:
if cond not in tbest or size > tbest[cond][2]:
tbest[cond] = (won, p, size)
all_won = sum(1 for won, _, _ in tbest.values() if won)
all_lost = len(tbest) - all_won
all_pnl = sum(size * ((1 - p) / p if won else -1.0)
for won, p, size in tbest.values())
if cond not in tbest or size > tbest[cond][3]:
tbest[cond] = (cond, asset, won, p, size)
def tally(rows):
"""(won, lost, refunds, pnl) over (cond, asset, won, p, size) rows."""
w_ = l_ = r_ = 0
pnl = 0.0
for cond, asset, won, p, size in rows:
wp = _wp(cond, asset, won)
pc = max(0.001, min(0.999, p or 0))
pnl += size * (wp - pc) / pc
if wp > 0.5:
w_ += 1
elif wp < 0.5:
l_ += 1
else:
r_ += 1
return w_, l_, r_, pnl
all_won, all_lost, all_ref, all_pnl = tally(tbest.values())
thr = cache.conv_cutoff(b["size"] for b in bets)
conv = [b for b in bets if b["size"] >= thr]
won = sum(1 for b in conv if b["won"])
recent = sorted(bets, key=lambda b: b["res_t"] or 0, reverse=True)[:500]
cut30 = time.time() - 30 * 86400
conv30 = [b for b in conv if (b["res_t"] or 0) >= cut30]
won30 = sum(1 for b in conv30 if b["won"])
brow = lambda bs: [(b["cond"], b.get("asset"), b["won"], b["p"], b["size"]) for b in bs]
cw, cl, cr, cpnl = tally(brow(conv))
c3w, c3l, c3r, c3pnl = tally(brow(conv30))
out = {
"conv_win": round(100 * won / len(conv), 1) if conv else None,
"conv_won": won, "conv_lost": len(conv) - won,
"conv_pnl": round(sum(_bet_pnl(b) for b in conv)),
"conv30_win": round(100 * won30 / len(conv30), 1) if conv30 else None,
"conv30_won": won30, "conv30_lost": len(conv30) - won30,
"conv30_pnl": round(sum(_bet_pnl(b) for b in conv30)),
"realized_pnl": round(sum(_bet_pnl(b) for b in recent)),
"conv_win": round(100 * cw / (cw + cl), 1) if (cw + cl) else None,
"conv_won": cw, "conv_lost": cl, "conv_ref": cr,
"conv_pnl": round(cpnl),
"conv30_win": round(100 * c3w / (c3w + c3l), 1) if (c3w + c3l) else None,
"conv30_won": c3w, "conv30_lost": c3l, "conv30_ref": c3r,
"conv30_pnl": round(c3pnl),
"realized_pnl": round(tally(brow(recent))[3]),
"all_win": round(100 * all_won / (all_won + all_lost), 1) if (all_won + all_lost) else None,
"all_won": all_won, "all_lost": all_lost, "all_pnl": round(all_pnl),
"all_won": all_won, "all_lost": all_lost, "all_ref": all_ref, "all_pnl": round(all_pnl),
"pm_pnl": _pm_profit(w),
"avg_bet": round(sum(b["size"] for b in conv) / len(conv)) if conv else 0,
"copy_pnl": 0, "held_pnl": 0, "held_won": 0, "held_lost": 0, "sold": 0,
@@ -206,13 +237,18 @@ def display_stats(w):
scalp += sh * pr - STAKE - openp[c]["fee"] - fee_out
sold += 1; del openp[c]
for c, p in openp.items(): # settle held bets at resolution
wv = resmap.get(p["a"])
wv = payouts.truth(c, p["a"]) # chain first: refunds pay 0.5
if wv is None:
wv = resmap.get(p["a"])
if wv is None:
wv = _clob_winner(c, p["a"]) # clob fallback for out-of-pull markets
if wv is None:
continue # not resolved yet -> exclude
held += (p["sh"] if wv else 0) - STAKE - p["fee"] # redeem itself is fee-free
hw += wv; hl += 1 - wv
held += p["sh"] * wv - STAKE - p["fee"] # redeem itself is fee-free
if wv > 0.5:
hw += 1
elif wv < 0.5:
hl += 1
out.update(copy_pnl=round(scalp + held), held_pnl=round(held),
held_won=hw, held_lost=hl, sold=sold)
return out
@@ -281,8 +317,9 @@ def main():
# holders are judged on their real resolved sample (the replay's own held leg
# is mostly "unresolved" for them and only reported for display).
tr = trust.conviction_record(cache.query, c["wallet"], days=TRUST_DAYS,
pctile=cache.CONV_PCTILE)
pctile=cache.CONV_PCTILE, truthfn=payouts.truth)
c["trust_n"], c["trust_wr"], c["trust_roi"] = tr["n"], round(tr["wr"], 3), round(tr["roi"], 3)
c["trust_refunds"] = tr.get("refunds", 0)
# SELECT a copyable sharp: active, copy-positive (fee-aware replay), and a
# genuine hold-to-resolution edge — trailing trusted conviction record wins a
# clear majority with positive flat-stake ROI on a real sample, so the edge