FIX: pending-adoption oversell — bounded arbiter + per-token serialization

2026-07-12 phantom-cash incident (+$7.86 book-only, alarms caught it):
overlapping pendings on one token each read the same balance drop via
max(matched, diff) — one real 1.48-share sell booked 3×. No real money
moved wrongly (the CLOB rejects oversells); the book just self-inflated.

Fixes: (1) order's own size caps every adoption fill; (2) the balance
diff is consulted ONLY when the exchange no longer answers for the order
(a definitive killed/0 is final); (3) SELL adoptions cap at book
holdings; (4) ONE outstanding pending per token — engine buy/sell and
exit-retries refuse while the resolver owns a token. Regression suite:
the incident scenario books exactly the real fill; phantoms zero.

Surgery (machine stopped): cash → chain $11.206, the two poisoned bets
corrected from real chain fills (residual adjustment −$0.08), drift 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jaxperro
2026-07-12 21:38:35 -04:00
parent 308dbf0b4e
commit 8951556e30
3 changed files with 9099 additions and 9059 deletions
+38 -14
View File
@@ -381,7 +381,8 @@ class LedgerLiveExecutor:
deadline_s=8, cancel=False)
if filled <= 0:
return {"ok": False, "filled_shares": 0.0, "price": price,
"pending": {"order_id": r.order_id, "bal0": bal0},
"pending": {"order_id": r.order_id, "bal0": bal0,
"sz": sz},
"resp": {"order_id": r.order_id, "status": r.status,
"note": "in-play hold — pending resolver"},
"paper": False}
@@ -1194,10 +1195,12 @@ class Copybot:
for p in pend:
tok = p["token"]
px, status, matched = p["price"], "gone", 0.0
order_view = False # did the exchange answer for THIS order?
try:
o = ex.client.get_order(order_id=p["order_id"])
matched = float(o.size_matched or 0)
status = o.status
order_view = True
if matched > 0:
px = float(o.price or px)
except Exception:
@@ -1211,13 +1214,26 @@ class Copybot:
ex.client.cancel_order(order_id=p["order_id"])
except Exception:
pass
filled = matched
try: # balance diff is the arbiter
bal1 = ex._shares_held(tok)
diff = (bal1 - p["bal0"]) if p["side"] == "BUY" else (p["bal0"] - bal1)
filled = max(filled, diff)
except Exception:
pass
# THE 2026-07-12 LESSON (+$7.86 phantom cash): with several
# pendings on ONE token, each balance-diff window sees the SAME
# balance move, so max(matched, diff) booked one real fill many
# times (a 1.56-share position "sold" 4.52 shares). The order's
# own matched size is authoritative; the diff is a FALLBACK for
# orders gone from the open view, and everything caps at the
# order's own size.
sz_cap = p.get("sz") or p.get("shares_req") or float("inf")
filled = min(matched, sz_cap)
if filled <= 0 and not order_view:
# the exchange no longer knows the order — ONLY then is the
# balance diff worth consulting (a definitive killed/0 answer
# above means unfilled, full stop)
try:
bal1 = ex._shares_held(tok)
diff = (bal1 - p["bal0"]) if p["side"] == "BUY" \
else (p["bal0"] - bal1)
filled = min(max(diff, 0.0), sz_cap)
except Exception:
pass
if filled <= 0.01:
log(f"pending expired unfilled: {p['outcome']} · {p['title'][:40]}")
if p["side"] == "BUY" and not p.get("is_add"):
@@ -1258,12 +1274,16 @@ class Copybot:
f"(held {int(now - p['ts'])}s)"))
else: # SELL adoption: reduce the position
mine = st["my_pos"].get(tok)
if mine and mine.get("shares"):
frac = min(1.0, filled / mine["shares"])
mine["cost"] *= (1 - frac)
mine["shares"] -= filled
if mine["shares"] <= 0.01:
st["my_pos"].pop(tok, None)
if not mine or not mine.get("shares"):
log(f"pending sell adoption: no position left for "
f"{p['title'][:36]} — dropping (nothing to book)")
continue
filled = min(filled, mine["shares"]) # book can't sell more
frac = min(1.0, filled / mine["shares"])
mine["cost"] *= (1 - frac)
mine["shares"] -= filled
if mine["shares"] <= 0.01:
st["my_pos"].pop(tok, None)
ex.fills.append({"side": "SELL", "token": tok,
"shares": filled, "price": px})
self._drain_fills()
@@ -1294,6 +1314,10 @@ class Copybot:
mine = st["my_pos"].get(tok)
if not mine or mine.get("shares", 0) <= 0.01:
continue # settled/sold meanwhile — done
if any(po.get("token") == tok
for po in st.get("pending_orders", [])):
keep.append(r) # resolver owns this token right now
continue
shares = min(r["shares"], mine["shares"])
price = self.engine._live_price(tok, "sell")
if price is None: