From 71194b219f6165e843bf03d34b1bacfcdc235847 Mon Sep 17 00:00:00 2001 From: jaxperro Date: Thu, 23 Jul 2026 18:14:07 -0400 Subject: [PATCH] T12 + T15 scripts (runs in flight; verdict headers follow with results) Co-Authored-By: Claude Fable 5 --- research/maker_unwind.py | 169 +++++++++++++++++++++++++++++++++++ research/sharp_halflife.py | 174 +++++++++++++++++++++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 research/maker_unwind.py create mode 100644 research/sharp_halflife.py diff --git a/research/maker_unwind.py b/research/maker_unwind.py new file mode 100644 index 00000000..0de3fbb6 --- /dev/null +++ b/research/maker_unwind.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""T12 EXPLORATORY (2026-07-23) — maker-sharp UNWIND leans: the sell side +of T6/Study C. When a screened maker-sharp builds a directional inventory +(>= $150 one-sided) and then takes back >= half of it, is the unwind an +exit signal (the market should be faded / a Study-C hold should exit) or +bankroll ops like taker-sharp exits (#21: exits are noise)? + +Walk-forward, no self-selection: per tape day D the maker-sharp set is +screened on tape < D only (maker_lean.screen_asof — same discipline as +T6). During D, per (wallet, asset): running net/gross from orders_matched +MAKER fills; an unwind fires ONCE when peak |net|*px >= $150 (a T6-grade +lean existed) AND |net| has dropped to <= 50% of peak with the same sign. + +Scored at the unwind print, chain-true (payouts_for — scorer law): + STAY $100 on the ORIGINAL lean side from the unwind print — what a + no-exit-rule Study C book experiences from this moment on. + STAY >= 0 => unwinds are noise, hold through (mirrors #21). + FADE $100 against the original lean — is the unwind actively + informative in reverse? +Comparator for STAY is literally 0 (exiting at the unwind print). + +FROZEN v0 params (declared before the run, not tuned after): + build |net|*px >= $150 AND |net|/gross >= 0.6 at peak (T6 trigger) + unwind |net| <= 0.5 * peak|net|, same sign, first per (w,a,day) + price lean-side last print in [0.05, 0.95] at unwind +Kill bar for the idea: STAY EV/unwind >= 0 at n >= 100 (exit rule adds +nothing); informative if STAY <= -$3/unwind at n >= 100.""" +import json +import os +import sys +import time + +sys.path.insert(0, "/Users/jaxmakielski/polymarket-smart-money/research") +import tape # noqa: E402 +import forward as fwd # noqa: E402 +import maker_lean as ml # noqa: E402 + +LEAN_USD = 150.0 # FROZEN — must equal maker_lean.py +NET_GROSS = 0.6 # FROZEN — must equal maker_lean.py +UNWIND_FRAC = 0.5 +BAND = (0.05, 0.95) + + +def day_unwinds(db, lo, hi, sharps): + rows = db.execute(""" + SELECT lower(json_extract_string(payload,'$.proxyWallet')) w, + json_extract_string(payload,'$.asset') a, + json_extract_string(payload,'$.side') s, + cast(json_extract(payload,'$.price') AS DOUBLE) p, + cast(json_extract(payload,'$.size') AS DOUBLE) z, ts + FROM aux WHERE type='orders_matched' AND ts >= ? AND ts < ? + ORDER BY ts""", [lo, hi]).fetchall() + book, fired, out = {}, set(), [] + for w, a, s_, p, z, ts in rows: + if w not in sharps or (w, a) in fired: + continue + st = book.setdefault((w, a), [0.0, 0.0, 0.0, False]) + # [net, gross, peak_net_abs_at_qualifying_lean, lean_armed] + st[0] += z if s_ == "BUY" else -z + st[1] += z + net, gross = st[0], st[1] + if gross < 1e-9: + continue + # arm (or re-peak) the lean state at each new extreme + if abs(net) > st[2]: + px_row = db.execute("""SELECT price FROM trades WHERE asset=? + AND ts<=? ORDER BY ts DESC LIMIT 1""", [a, ts]).fetchone() + if px_row is not None: + px_now = float(px_row[0]) + if (abs(net) * px_now >= LEAN_USD + and abs(net) / gross >= NET_GROSS): + st[2] = abs(net) + st[3] = net > 0 # sign of the armed lean + # unwind: armed lean and net back to <= half the peak, same sign + if st[2] > 0 and (net > 0) == st[3] \ + and abs(net) <= UNWIND_FRAC * st[2]: + px_row = db.execute("""SELECT price FROM trades WHERE asset=? + AND ts<=? ORDER BY ts DESC LIMIT 1""", [a, ts]).fetchone() + if px_row is None: + continue + px = float(px_row[0]) + lean_px = px if st[3] else 1 - px + if not (BAND[0] <= lean_px <= BAND[1]): + continue + fired.add((w, a)) + out.append({"w": w, "a": a, "ts": ts, + "side": 1 if st[3] else -1, + "peak_usd": st[2] * px, "px": px, + "lean_px": lean_px}) + return out + + +def main(): + dump = os.path.join(os.path.dirname(os.path.abspath(__file__)), + ".maker_unwind_triggers.json") + db = tape.connect() + if "--grade-only" in sys.argv: + # walk already done; grade the dumped triggers (lets a re-grade + # queue behind another process's cache.duckdb write lock) + triggers = json.load(open(dump)) + print(f"grade-only: {len(triggers)} dumped triggers", flush=True) + else: + t_lo, t_hi = db.execute( + "SELECT min(ts), max(ts) FROM aux WHERE type='orders_matched'" + ).fetchone() + day0 = int(t_lo // 86400 + 2) + days = [d * 86400 for d in range(day0, int(t_hi // 86400) + 1)] + print(f"walk-forward days: {len(days)}", flush=True) + triggers = [] + for lo in days: + hi = min(lo + 86400, t_hi) + sharps = ml.screen_asof(db, lo) + d_str = time.strftime("%m-%d", time.gmtime(lo)) + if not sharps: + print(f"{d_str}: 0 screened wallets", flush=True) + continue + found = day_unwinds(db, lo, hi, sharps) + for t in found: + t["day"] = d_str + triggers.extend(found) + print(f"{d_str}: {len(sharps)} screened · {len(found)} unwinds", + flush=True) + print(f"total unwinds: {len(triggers)}", flush=True) + json.dump(triggers, open(dump, "w")) + pays = fwd.payouts_for(db, [t["a"] for t in triggers]) + graded = [(t, pays.get(t["a"])) for t in triggers] + graded = [(t, p) for t, p in graded if p is not None and p != 0.5] + + def report(tag, rs): + if not rs: + print(f"{tag}: 0 graded") + return + n = len(rs) + stay = fade = 0.0 + holds = 0 + for t, p in rs: + lean_pay = p if t["side"] > 0 else 1 - p + sh = 100.0 / t["lean_px"] + stay += sh * (lean_pay - t["lean_px"]) + shf = 100.0 / (1 - t["lean_px"]) + fade += shf * ((1 - lean_pay) - (1 - t["lean_px"])) + holds += lean_pay == 1.0 + print(f"{tag}: n={n} · lean-side hit {holds/n:.2f} · avg lean px " + f"{sum(t['lean_px'] for t, _ in rs)/n:.2f} · " + f"STAY EV/unwind {stay/n:+.2f} · FADE EV/unwind {fade/n:+.2f}") + + print(f"chain-graded: {len(graded)}/{len(triggers)}") + report("ALL", graded) + for lo_, hi_, tag in [(150, 500, "$150-500"), (500, 2000, "$500-2k"), + (2000, 1e9, "$2k+")]: + report(f"peak {tag}", + [(t, p) for t, p in graded if lo_ <= t["peak_usd"] < hi_]) + for d in sorted({t["day"] for t, _ in graded}): + report(f"day {d}", [(t, p) for t, p in graded if t["day"] == d]) + # event concentration (the #22 fade-arm lesson): top-asset share + by_a = {} + for t, p in graded: + lean_pay = p if t["side"] > 0 else 1 - p + sh = 100.0 / t["lean_px"] + by_a[t["a"]] = by_a.get(t["a"], 0.0) + sh * (lean_pay - t["lean_px"]) + if by_a: + tot = sum(by_a.values()) + top = sorted(by_a.items(), key=lambda kv: -abs(kv[1]))[:5] + print(f"STAY concentration: total {tot:+.0f} · top-5 assets " + f"{[round(v) for _, v in top]}") + + +if __name__ == "__main__": + main() diff --git a/research/sharp_halflife.py b/research/sharp_halflife.py new file mode 100644 index 00000000..8b3c4e5e --- /dev/null +++ b/research/sharp_halflife.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""T15 EXPLORATORY (2026-07-23) — SHARP HALF-LIFE: how long does a wallet +stay sharp after we detect it? The live follow set has principled entry +criteria and no exit criteria; this measures whether detection-day edge +decays with age-in-set and how fast wallets churn out of the published +screen. + +Detection dates are REAL as-of history, not reconstruction: the git +commit series of live/watch_sharps.json (published daily by the pipeline +since 2026-06-18) — a wallet's detection date is the first commit that +contains it. Wallets already present in the FIRST commit are +left-censored (true detection unknown) and excluded from the age curves. + +Performance is the wallet's OWN bets from the cache (live/cache.duckdb +bets, read-only snapshot taken first so the payouts writer never +contends), graded to CHAIN TRUTH per bet via payouts.ensure/truth — +the cache's won/res_t columns are never trusted (the res_t=ts poison). +EV is per $100 at the wallet's own entry price, feeless: this measures +SIGNAL decay, not our execution (T3/T11 own execution). + +Outputs: EV/bet by age-in-set bucket (0-2d / 3-6d / 7-13d / 14d+), +pooled AND per-wallet-day mean-of-means (concentration guard), plus the +survival curve (fraction of detected wallets still published at age k). +NOT pre-registered — informs a rotation policy for the follow set.""" +import collections +import json +import os +import subprocess +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +sys.path.insert(0, os.path.join(ROOT, "live")) +BAND = (0.05, 0.95) +BUCKETS = [(0, 3, "age 0-2d"), (3, 7, "age 3-6d"), + (7, 14, "age 7-13d"), (14, 10**6, "age 14d+")] + + +def set_history(): + """[(date_str, {wallets})] oldest-first from git history.""" + log = subprocess.run( + ["git", "log", "--reverse", "--format=%ad %H", "--date=short", + "--", "live/watch_sharps.json"], + cwd=ROOT, capture_output=True, text=True).stdout.split() + pairs = list(zip(log[0::2], log[1::2])) + out = [] + for date, sha in pairs: + try: + blob = subprocess.run( + ["git", "show", f"{sha}:live/watch_sharps.json"], + cwd=ROOT, capture_output=True, text=True).stdout + rows = json.loads(blob) + ws = {r["wallet"].lower() for r in rows if r.get("wallet")} + if ws: + out.append((date, ws)) + except Exception: + continue + # one snapshot per date (last commit of the day wins) + byday = {} + for date, ws in out: + byday[date] = ws + return sorted(byday.items()) + + +def main(): + hist = set_history() + print(f"set snapshots: {len(hist)} days " + f"({hist[0][0]} .. {hist[-1][0]})", flush=True) + first_day, censored = {}, set() + for i, (date, ws) in enumerate(hist): + for w in ws: + if w not in first_day: + first_day[w] = date + if i == 0: + censored.add(w) + pool = set(first_day) - censored + print(f"wallets ever published: {len(first_day)} · " + f"left-censored (first snapshot): {len(censored)} · " + f"age-eligible: {len(pool)}", flush=True) + + day_n = {d: time.mktime(time.strptime(d, "%Y-%m-%d")) // 86400 + for d, _ in hist} + det_n = {w: day_n[first_day[w]] for w in pool} + + # survival: still-published at age k (right-censored by last snapshot) + last_n = day_n[hist[-1][0]] + print("\nSURVIVAL (still in the published set at age k):", flush=True) + for k in (1, 3, 7, 14, 21, 28): + elig = [w for w in pool if det_n[w] + k <= last_n] + if not elig: + continue + alive = 0 + for w in elig: + # snapshot on-or-after detection+k (carry-forward between days) + snap = None + for d, ws in hist: + if day_n[d] <= det_n[w] + k: + snap = ws + else: + break + alive += snap is not None and w in snap + print(f" age {k:>2}d: {alive}/{len(elig)} = " + f"{alive/len(elig):.0%}", flush=True) + + # bets snapshot (read-only, close before payouts writes the same file) + import duckdb + con = duckdb.connect(os.path.join(ROOT, "live", "cache.duckdb"), + read_only=True) + wl = ",".join(f"'{w}'" for w in pool) + bets = con.execute(f""" + SELECT lower(wallet) w, cond, asset, + any_value(p::DOUBLE) p, min(ts) ts + FROM bets + WHERE lower(wallet) IN ({wl}) AND p BETWEEN {BAND[0]} AND {BAND[1]} + AND resolved + GROUP BY lower(wallet), cond, asset""").fetchall() + con.close() + print(f"\nresolved cache bets for age-eligible wallets: {len(bets)}", + flush=True) + + import payouts + conds = sorted({b[1] for b in bets if b[1]}) + print(f"ensuring {len(conds)} conds against chain…", flush=True) + payouts.ensure(conds) + + per_bucket = {tag: [] for _, _, tag in BUCKETS} + per_wd = {tag: collections.defaultdict(list) for _, _, tag in BUCKETS} + graded = skipped = 0 + for w, cond, asset, p, ts in bets: + if ts // 86400 < det_n[w]: + continue # pre-detection bet + pay = payouts.truth(cond, asset) + if pay is None or pay == 0.5: + skipped += 1 + continue + graded += 1 + age = int(ts // 86400 - det_n[w]) + ev = (100.0 / p) * (pay - p) + for lo, hi, tag in BUCKETS: + if lo <= age < hi: + per_bucket[tag].append(ev) + per_wd[tag][(w, int(ts // 86400))].append(ev) + break + print(f"chain-graded post-detection bets: {graded} " + f"(ungraded/refund skipped: {skipped})\n", flush=True) + + print("EV BY AGE-IN-SET (per $100 at the wallet's own entry, feeless):", + flush=True) + for _, _, tag in BUCKETS: + evs = per_bucket[tag] + if not evs: + print(f" {tag:>10}: n=0") + continue + pooled = sum(evs) / len(evs) + wd = [sum(v) / len(v) for v in per_wd[tag].values()] + mom = sum(wd) / len(wd) + nw = len({k[0] for k in per_wd[tag]}) + print(f" {tag:>10}: n={len(evs):>5} · pooled EV/bet {pooled:+6.2f}" + f" · wallet-day mean {mom:+6.2f} · {nw} wallets", flush=True) + # concentration guard: top-5 wallet share of |pnl| overall + by_w = collections.defaultdict(float) + for _, _, tag in BUCKETS: + for (w, _), v in per_wd[tag].items(): + by_w[w] += sum(v) + if by_w: + tot = sum(by_w.values()) + top = sorted(by_w.items(), key=lambda kv: -abs(kv[1]))[:5] + print(f"\nconcentration: total {tot:+.0f} · top-5 wallets " + f"{[(w[:8], round(v)) for w, v in top]}", flush=True) + + +if __name__ == "__main__": + main()