Files
winning-wallet-finder/live/payouts.py
T
jaxperro 4941818d51 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>
2026-07-07 01:55:50 -04:00

207 lines
8.2 KiB
Python

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