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
+14
View File
@@ -496,6 +496,15 @@ class CopyTrader:
self.record_miss(wallet, token, cond, title, outcome, price,
want_usd, reason)
return
# ONE outstanding in-play hold per token: a second order while a
# pending rests re-buys the same signal and poisons the resolver's
# balance-diff window (2026-07-12: overlapping pendings booked one
# real fill several times → +$7.86 phantom cash).
if any(po.get("token") == token
for po in self.state.get("pending_orders", [])):
self.log(f"{kind} {label} — skip (in-play hold already pending "
"on this token)")
return
shares = allowed / price
res = self.ex.buy(token, shares, price, {"title": title})
if not res["ok"]:
@@ -553,6 +562,11 @@ class CopyTrader:
price = self._live_price(token, "sell")
if price is None:
return
if any(po.get("token") == token
for po in self.state.get("pending_orders", [])):
self.log(f"EXIT {label} — skip (in-play hold already pending on "
"this token; resolver owns it)")
return
res = self.ex.sell(token, sell_shares, price, {})
if not res["ok"]:
if res.get("pending"): # in-play hold — resolver adopts