diff --git a/HANDOFF.md b/HANDOFF.md index a9f52ce4..01da463c 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -24,8 +24,9 @@ Builder API Key (SDK gasless relay) the runtime bot deliberately lacks — LOW urgency, the platform auto-redeems winners itself. auto_redeem OFF. Open now: #1 Signal A, #2 tape research, #4 redeem builder-key, #13 Fri bench, #14 edge verdict, #16 surge momentum (forward window OPEN), #17 -oracle fair value (accumulating, nothing frozen), #18 empty-cond copies -are unsettleable (2026-07-20 CASH≠CHAIN post-mortem). +oracle fair value (accumulating, nothing frozen). #18 (empty-cond copies +unsettleable) closed same day: RTDS seed enrichment + falsy-cond repair +pass + 1h alarm. ## Operating boundary (user, 2026-07-13 — standing) **Full autonomy on the bots**; the real-money bot **stays ARMED**. Never @@ -42,7 +43,8 @@ push through. 6-wallet Set E rev 4, 4% of working equity/bet, alarm-free after the 07-20 reconcile (CASH≠CHAIN −$1.75: empty-cond Odyssey bet the venue auto-redeemed → hand-settled won +$0.68, $0.05 rounding folded; root - cause → #18). **wwf-copybot** (paper $1k): same set, FAK-parity fills. + cause FIXED same day, #18: RTDS metadata-less rows now enrich at seed, + repair_market_meta un-sticks cond-less books each settle pass). **wwf-copybot** (paper $1k): same set, FAK-parity fills. Both on the audit-hardened build (locks, chain-gated sweep, boot-id single-writer guard, TLS'd user-ws — HANDOFF_ARCHIVE rev 16). 2026-07-20: FAK no-match OPENs get one re-quote retry on both bots, PER-NICHE waits diff --git a/copybot.py b/copybot.py index 31e8597c..8f136dc3 100644 --- a/copybot.py +++ b/copybot.py @@ -778,6 +778,16 @@ class RtdsListener: "outcome": p.get("outcome"), "conditionId": p.get("conditionId"), "eventSlug": p.get("eventSlug") or p.get("slug"), "timestamp": int(ts) if ts else None} + # #18: some markets stream with empty title/outcome/conditionId (the + # 07-20 Odyssey copy) — a cond-less book entry is UNSETTLEABLE. Enrich + # from the token id (gamma, cached) before seeding; on a miss the copy + # still goes through and repair_market_meta backfills later. + if seed["asset"] and not (seed["conditionId"] and seed["title"]): + meta = _token_market(seed["asset"]) + if meta: + seed["title"] = seed["title"] or meta[0] + seed["outcome"] = seed["outcome"] or meta[1] + seed["conditionId"] = seed["conditionId"] or meta[2] try: # same funnel as the Alchemy push — self.bot.on_wallet_activity(w, seed_trade=seed) # locks internally except Exception as e: @@ -919,6 +929,10 @@ class Copybot: self.shadow_log = cfg.get("shadow_log", "rtds_shadow.jsonl") # persisted across restarts via the engine's state file self.conds = engine.state.setdefault("conds", {}) # token_id -> conditionId (open positions) + # #18 empty-cond repair: in-memory backoff/alarm bookkeeping (a reboot + # restarts the 1h clock — fine, the repair itself is what matters) + self._meta_fail = {} # token -> (first_fail_ts, last_attempt_ts) + self._meta_warned = set() # tokens already alarmed as unsettleable engine.state.setdefault("cash", cfg["bankroll_usd"]) # free cash (recycles on sell/resolution) engine.state.setdefault("lag", {"n": 0, "sum_s": 0.0, "sum_slip_pct": 0.0}) engine.state.setdefault("fees_paid", 0.0) @@ -2096,7 +2110,9 @@ class Copybot: self.engine.handle_trade(wallet, t) # sizes, gates, places (paper/live) self.engine.seen.update(extras) # component fills of the merged bet tok = t.get("asset") - if tok in self.engine.state["my_pos"] and tok not in self.conds: + # falsy-overwrite, not key-presence (#18): a seed that stored + # cond="" must not block the indexer's richer row from fixing it + if tok in self.engine.state["my_pos"] and not self.conds.get(tok): self.conds[tok] = t.get("conditionId") # remember for settling for f in self._drain_fills(): if f["token"] == tok: # the fill from this copy @@ -2371,6 +2387,51 @@ class Copybot: f"hypothetical ${m['pnl']:+.2f} (sold)") self.engine.persist() + META_RETRY_S = 300 # per-token backoff between failed lookups + META_WARN_S = 3600 # empty-cond position older than this alarms + + def repair_market_meta(self): + """#18: a copy seeded from a metadata-less RTDS row books with + title/outcome/cond == "" and settle_resolved's `if not cond` skip makes + it silently UNSETTLEABLE — the 07-20 Odyssey bet sat open while the + venue auto-redeemed it on-chain, alarming CASH≠CHAIN -$1.75 until + manual state surgery. Each settle pass: re-resolve empty conds from + the token id (gamma, cached), backfill the position/bet/missed + records, and alarm ONCE per token still unsettleable after an hour. + Caller holds self.lock (settle_resolved).""" + st = self.engine.state + now = time.time() + empties = [(t, p) for t, p in st["my_pos"].items() if not self.conds.get(t)] + for m in st.get("missed", []): # cond-less misses skip hypothetical + if m.get("status") == "open" and not m.get("cond") and m.get("token"): + empties.append((m["token"], m)) + for tok, rec in empties: + first, last = self._meta_fail.get(tok, (now, 0.0)) + if now - last >= self.META_RETRY_S: + meta = _token_market(tok) + if meta and meta[2]: + self._meta_fail.pop(tok, None) + self._meta_warned.discard(tok) + b = st.get("bets", {}).get(tok) + for d in (rec, b) if b is not None else (rec,): + d["title"] = d.get("title") or meta[0] + d["outcome"] = d.get("outcome") or meta[1] + if "cond" in d or d is rec: + d["cond"] = d.get("cond") or meta[2] + if tok in st["my_pos"]: + self.conds[tok] = meta[2] + log(f"meta repair (#18): …{tok[-8:]} -> " + f"{str(meta[0])[:40]} · {meta[1]}") + continue + self._meta_fail[tok] = (first, now) + if (tok in st["my_pos"] and now - first > self.META_WARN_S + and tok not in self._meta_warned): + self._meta_warned.add(tok) + self.engine.alert( + f"⚠ …{tok[-8:]} ({str(rec.get('title') or '?')[:36]}) has had " + f"no condition id for {(now - first) / 3600:.1f}h — " + f"UNSETTLEABLE until metadata resolves (#18)") + def settle_resolved(self): # audit 4: this ran on EVERY webhook POST ahead of the copy itself — # dozens of redundant CLOB fetches during clip bursts. 20s throttle; @@ -2383,6 +2444,7 @@ class Copybot: realized P&L. This is the resolution path the engine's sell-only mirror lacks — without it the $1k never recycles for held-to-resolution bets.""" with self.lock: + self.repair_market_meta() # #18: un-stick cond-less books first mp = self.engine.state["my_pos"] for token in list(mp): cond = self.conds.get(token) diff --git a/tests/test_meta_repair.py b/tests/test_meta_repair.py new file mode 100644 index 00000000..f19e5779 --- /dev/null +++ b/tests/test_meta_repair.py @@ -0,0 +1,149 @@ +"""Stub tests for repair_market_meta (#18): a copy seeded from a +metadata-less RTDS row books title/outcome/cond == "" and can never settle — +the 2026-07-20 Odyssey bet sat open while the venue auto-redeemed it, +alarming CASH≠CHAIN until manual surgery. Covers: backfill of my_pos + bets ++ missed records, the falsy-cond overwrite, lookup-failure backoff, the 1h +unsettleable alarm (once), and the end-to-end un-stick (repair -> same-pass +settle). Run: python3 tests/test_meta_repair.py — no network, no config. +""" +import os +import sys +import threading +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +import copybot # noqa: E402 + +TOK = "35358889366059131445607416132477521541386270328042995107702816540034073998102" +MISS_TOK = "111" +COND = "0xd2a97dd956e70c9c4abda30d2864588ece0c7213013eb3fa664b88509ff829b1" +TITLE = 'Will "The Odyssey" Opening Weekend Box Office be greater than 115m?' + + +class Ex: + live = False + + +class Eng: + def __init__(self, st): + self.state, self.ex, self.alerts = st, Ex(), [] + + def persist(self): + pass + + def alert(self, m, discord_text=None): + self.alerts.append(m) + + +def mkbot(st): + bot = copybot.Copybot.__new__(copybot.Copybot) + bot.engine = Eng(st) + bot.lock = threading.Lock() + bot.redeemer = None + bot.fee_rate = 0.03 + bot.conds = st.setdefault("conds", {}) + bot._meta_fail = {} + bot._meta_warned = set() + return bot + + +def fresh_state(): + return { + "cash": 10.0, "adjustments": [], + "my_pos": {TOK: {"shares": 1.694914, "cost": 1.0, "title": "", + "outcome": "", "event": None, "wallet": "0xw", + "cond": ""}}, + "bets": {TOK: {"token": TOK, "wallet": "0xw", "name": "Bikes", + "outcome": "", "title": "", "shares": 1.69, + "cost": 1.0, "fee": 0.0123, "opened": 1, + "status": "open", "exit_price": None, "pnl": None, + "settled": None}}, + "missed": [{"token": MISS_TOK, "cond": "", "status": "open", + "outcome": "", "price": 0.5, "stake": 2.0}], + "conds": {TOK: ""}, + } + + +calls = [] + + +def meta_ok(token): + calls.append(token) + return (TITLE, "Yes", COND) if token == TOK else ("Miss mkt", "No", "0xm") + + +def meta_fail(token): + calls.append(token) + return None + + +fails = [] + +# 1) repair backfills my_pos + bets + conds + missed, and logs no alarm +copybot._token_market = meta_ok +st = fresh_state() +bot = mkbot(st) +bot.repair_market_meta() +if st["conds"][TOK] != COND: + fails.append(f"conds not repaired: {st['conds'][TOK]!r}") +if st["my_pos"][TOK]["title"] != TITLE or st["my_pos"][TOK]["cond"] != COND: + fails.append(f"my_pos not backfilled: {st['my_pos'][TOK]}") +if st["bets"][TOK]["title"] != TITLE or st["bets"][TOK]["outcome"] != "Yes": + fails.append(f"bets not backfilled: {st['bets'][TOK]}") +if st["missed"][0]["cond"] != "0xm": + fails.append(f"missed not repaired: {st['missed'][0]}") +if MISS_TOK in st["conds"]: + fails.append("missed token leaked into conds (conds is open positions only)") +if bot.engine.alerts: + fails.append(f"unexpected alarm: {bot.engine.alerts}") + +# 2) lookup failure: tracked, backed off, no crash; second pass inside the +# retry window must NOT re-call the API +copybot._token_market = meta_fail +st = fresh_state() +bot = mkbot(st) +calls.clear() +bot.repair_market_meta() +n_first = len(calls) +bot.repair_market_meta() # immediate second pass -> backoff, no calls +if n_first != 2: # TOK + MISS_TOK + fails.append(f"expected 2 lookup calls, got {n_first}") +if len(calls) != n_first: + fails.append(f"backoff violated: {len(calls) - n_first} extra calls") +if st["conds"][TOK] != "": + fails.append("failed lookup should leave cond empty") + +# 3) 1h-old failure alarms ONCE (my_pos only, never for missed) +now = time.time() +bot._meta_fail[TOK] = (now - 3700, now) # old failure, recent attempt +bot._meta_fail[MISS_TOK] = (now - 3700, now) +bot.repair_market_meta() +bot.repair_market_meta() +alarms = [a for a in bot.engine.alerts if "UNSETTLEABLE" in a] +if len(alarms) != 1: + fails.append(f"expected exactly 1 unsettleable alarm, got {bot.engine.alerts}") + +# 4) recovery after failure: lookup succeeds once the market appears -> alarm +# bookkeeping cleared +copybot._token_market = meta_ok +bot._meta_fail[TOK] = (now - 3700, 0.0) # out of the retry window +bot.repair_market_meta() +if st["conds"][TOK] != COND or TOK in bot._meta_fail or TOK in bot._meta_warned: + fails.append("recovery did not clear fail/warned bookkeeping") + +# 5) end-to-end un-stick: repaired cond lets the SAME settle_resolved pass +# settle the bet (this is the exact Odyssey failure, inverted) +copybot._token_market = meta_ok +copybot.resolution_price = lambda token, cond, outcome=None: 1.0 +st = fresh_state() +bot = mkbot(st) +bot._settle_last = 0 +bot.settle_resolved() +b = st["bets"][TOK] +if b["status"] != "won" or b["pnl"] is None or TOK in st["my_pos"]: + fails.append(f"repair+settle did not un-stick the bet: {b}") +if abs(st["cash"] - 11.694914) > 1e-9: + fails.append(f"settle cash wrong: {st['cash']}") + +print("FAILURES:", fails or "none") +sys.exit(1 if fails else 0)