copybot: close the orphan/cash-debit seam (HANDOFF proper fix, Option A)

Every drained buy fill is now recorded: the tok-match keeps _record_lag
(real lag/slippage), leftovers get a bet record synthesized from the fill
(_record_untracked_buy). check_book() asserts the book invariant after
every trade and at boot: my_pos token => bet record + conds entry + cash
debited. Records/conds self-correct; the cash leg heals only at boot and
only when ledger_drift matches one un-vouched position's cost+fee exactly
— anything else stays a visible ⚠ drift. my_pos entries now carry cond
(engine) and fills-ledger BUY lines carry token, so the book can always
self-attribute. reconcile_exits' drain no longer discards leftover buys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jaxperro
2026-07-07 22:49:47 -04:00
parent fcfaaea431
commit 167a0b9c92
2 changed files with 180 additions and 16 deletions
+6 -1
View File
@@ -483,11 +483,16 @@ class CopyTrader:
if is_add:
mine["shares"] += res["filled_shares"]
mine["cost"] += spent
if cond:
mine.setdefault("cond", cond)
else:
# wallet/cond ride on the position so the book can always self-repair:
# wallet for the feed's my_pos->bets safety net, cond so check_book can
# rebuild the conds map (an orphan without it can never settle)
self.state["my_pos"][token] = {
"shares": res["filled_shares"], "cost": spent,
"title": title, "outcome": outcome, "event": event,
"wallet": wallet} # attribution for the feed's my_pos->bets safety net
"wallet": wallet, "cond": cond}
tag = "[PAPER]" if not self.ex.live else "[LIVE]"
self.alert(
f"{kind} {label}{tag} buy {res['filled_shares']:.1f} "
+174 -15
View File
@@ -467,7 +467,7 @@ class Copybot:
my_p = fill["price"]
slip_pct = (my_p - their_p) / their_p if their_p else 0.0
rec = {
"ts": round(now, 1), "wallet": wallet,
"ts": round(now, 1), "wallet": wallet, "token": str(fill["token"]),
"name": self.names.get(wallet.lower(), wallet[:10]),
"outcome": t.get("outcome"), "title": (t.get("title") or "")[:80],
"detect_lag_s": round(detect_s, 1) if detect_s is not None else None,
@@ -523,6 +523,166 @@ class Copybot:
log(f" ↳ lag {('%.0fs' % detect_s) if detect_s is not None else '?'} · "
f"their {their_p:.3f} → mine {my_p:.3f} ({slip_pct:+.1%} slippage)")
def _synth_bet(self, tok, pos):
"""Bet record for an open my_pos position that has none (their_price
None marks it synthesized — lag/slippage unknowable, the source trade
is gone). Fee is ESTIMATED with the same taker_fee formula _drain_fills
charges, so for a position whose cash WAS debited the ledger closes to
0 (same inputs, same result) — while a never-debited orphan shows
drift = cost+fee exactly, which is what check_book's heal keys on."""
bets = self.engine.state.setdefault("bets", {})
b = bets.get(tok) or {}
sh = pos.get("shares") or 0
cost = pos.get("cost") or 0
fee = b.get("fee") or (taker_fee(sh, cost / sh, self.fee_rate) if sh else 0)
bets[tok] = {
"token": tok, "wallet": pos.get("wallet", ""),
"name": self.names.get((pos.get("wallet") or "").lower())
or b.get("name") or "?",
"outcome": pos.get("outcome"), "title": (pos.get("title") or "")[:90],
"their_price": None,
"my_price": round(cost / sh, 4) if sh else None,
"slippage_pct": None,
"shares": round(sh, 2), "cost": round(cost, 2),
"fee": round(fee, 4),
"opened": b.get("opened") or int(time.time()), "status": "open",
"exit_price": None, "pnl": None, "settled": None,
}
return bets[tok]
def _record_untracked_buy(self, f):
"""A drained BUY fill no _record_lag call claimed — the handler that
placed it died between handle_trade and _drain_fills (webhook catches
the exception, the fill sits in ex.fills), so a LATER drain booked its
cash under some other trade's iteration. Before 2026-07-08 that fill
vanished: position in my_pos, cash debited, but no bet record and no
conds entry — the invisible-orphan seam behind the $+36.35 drift.
Book it now: audit line, bet record (from the fill + my_pos
attribution), conds from the position."""
tok = f["token"]
pos = self.engine.state["my_pos"].get(tok, {})
if pos.get("cond") and tok not in self.conds:
self.conds[tok] = pos["cond"]
try:
with open(os.path.join(self.here, self.fill_log), "a") as fh:
fh.write(json.dumps({
"ts": round(time.time(), 1), "side": "BUY", "untracked": True,
"token": str(tok), "shares": round(f["shares"], 4),
"price": round(f["price"], 4), "fee": f.get("fee", 0),
"mode": "live" if self.engine.ex.live else "paper",
}) + "\n")
except Exception:
pass
bets = self.engine.state.setdefault("bets", {})
prev = bets.get(tok)
if prev and prev.get("status") == "open":
# ADD to an open record: accumulate unrounded, like _record_lag
sh = prev["shares"] + f["shares"]
cost = prev["cost"] + f["shares"] * f["price"]
prev.update(shares=sh, cost=cost,
my_price=(cost / sh) if sh else prev["my_price"],
fee=(prev.get("fee") or 0) + f.get("fee", 0))
else:
bets[tok] = {
"token": tok, "wallet": pos.get("wallet", ""),
"name": self.names.get((pos.get("wallet") or "").lower(), "?"),
"outcome": pos.get("outcome"), "title": (pos.get("title") or "")[:90],
"their_price": None,
"my_price": round(f["price"], 4),
"slippage_pct": None,
"shares": round(f["shares"], 2),
"cost": round(f["shares"] * f["price"], 2),
"fee": f.get("fee", 0),
"opened": int(time.time()), "status": "open",
"exit_price": None, "pnl": None, "settled": None,
}
log(f" ↳ untracked buy booked: {(pos.get('title') or '?')[:42]}"
f"{f['shares']:.1f}sh @ {f['price']:.3f}")
def _ledger_buy_tokens(self):
"""Tokens with a BUY line in the fills ledger — i.e. positions whose
cash demonstrably went through _drain_fills. Lines from before
2026-07-08 carry no 'token' (they can't vouch for anyone) and no
'side' (they are all buys — sells only started logging 2026-07-06)."""
toks = set()
try:
with open(os.path.join(self.here, self.fill_log)) as fh:
for line in fh:
try:
r = json.loads(line)
except Exception:
continue
if r.get("side", "BUY") == "BUY" and r.get("token"):
toks.add(str(r["token"]))
except FileNotFoundError:
pass
return toks
def check_book(self, heal_cash=False):
"""The book invariant (HANDOFF proper fix, Option A), asserted after
every trade and at boot: every my_pos token has a bet record, a conds
entry, and its cost debited from cash. Records and conds self-correct
here. The cash leg only heals at boot (heal_cash=True) and only when
ledger_drift matches ONE candidate's cost+fee — candidates are
positions with no drained-fill evidence (no ledger BUY line, record
synthesized) — so a drift that matches nothing stays loudly visible
instead of being papered over. Callers hold self.lock (webhook path)
or run before threads start (boot)."""
st = self.engine.state
mp = st["my_pos"]
bets = st.setdefault("bets", {})
fixed = False
for tok, pos in list(mp.items()):
b = bets.get(tok)
if not b or b.get("status") != "open":
self._synth_bet(tok, pos)
log(f"⚠ BOOK: synthesized missing bet record — "
f"{(pos.get('title') or '?')[:42]}")
fixed = True
if tok not in self.conds:
if pos.get("cond"):
self.conds[tok] = pos["cond"]
log(f"⚠ BOOK: backfilled conds from my_pos — "
f"{(pos.get('title') or '?')[:42]}")
fixed = True
else:
log(f"⚠ BOOK: no conditionId for "
f"{(pos.get('title') or '?')[:42]} — can't settle it "
f"until a reconcile pass learns the market")
drift = self.ledger_drift()
if heal_cash and abs(drift) > 0.01:
vouched = self._ledger_buy_tokens()
for tok in list(mp):
b = bets.get(tok) or {}
# a real _record_lag record (their_price set) or a ledger BUY
# line proves the fill was drained — cash side is fine
if str(tok) in vouched or b.get("their_price") is not None:
continue
gap = (b.get("cost") or mp[tok].get("cost") or 0) + (b.get("fee") or 0)
if gap > 0 and abs(drift - gap) <= max(0.10, 0.01 * gap):
st["cash"] -= gap
st["fees_paid"] = st.get("fees_paid", 0.0) + (b.get("fee") or 0)
try: # the late debit IS the missed drain
with open(os.path.join(self.here, self.fill_log), "a") as fh:
fh.write(json.dumps({
"ts": round(time.time(), 1), "side": "BUY",
"healed": True, "token": str(tok),
"shares": round(mp[tok].get("shares", 0), 4),
"price": b.get("my_price"), "fee": b.get("fee", 0),
"mode": "live" if self.engine.ex.live else "paper",
}) + "\n")
except Exception:
pass
log(f"⚠ BOOK HEALED: debited ${gap:.2f} never-drained cost — "
f"{(b.get('title') or '?')[:42]} (drift was ${drift:+.2f})")
drift = self.ledger_drift()
fixed = True
if fixed:
self.engine.persist()
if abs(drift) > 0.01:
log(f"⚠ LEDGER DRIFT ${drift:+.2f} — check_book could not attribute it")
return drift
def write_feed(self):
"""Publish the bot's live book to live/copybot_live.json — the feed the
top of jaxperro.com/trading reads. Reconciles any open bet no longer held
@@ -539,19 +699,7 @@ class Copybot:
for tok, p in mp.items():
b = bets.get(tok)
if not b or b.get("status") != "open":
bets[tok] = {
"token": tok, "wallet": p.get("wallet", ""),
"name": self.names.get((p.get("wallet") or "").lower())
or (b or {}).get("name") or "?",
"outcome": p.get("outcome"), "title": (p.get("title") or "")[:90],
"their_price": None,
"my_price": round(p["cost"] / p["shares"], 4) if p.get("shares") else None,
"slippage_pct": None,
"shares": round(p["shares"], 2), "cost": round(p["cost"], 2),
"fee": (b or {}).get("fee", 0),
"opened": (b or {}).get("opened") or int(time.time()), "status": "open",
"exit_price": None, "pnl": None, "settled": None,
}
self._synth_bet(tok, p)
for tok, b in bets.items():
if b["status"] == "open" and tok not in mp:
b["status"] = "closed"
@@ -800,6 +948,12 @@ class Copybot:
for f in self._drain_fills():
if f["token"] == tok: # the fill from this copy
self._record_lag(wallet, t, f)
else:
# a leftover fill from a handler that died mid-trade —
# its cash was just debited above; record it or it
# becomes an invisible orphan (the 2026-07-08 drift)
self._record_untracked_buy(f)
self.check_book()
def reconcile_exits(self):
"""Exits the signal made while we weren't listening. RECENT_TRADE_WINDOW_S
@@ -859,7 +1013,8 @@ class Copybot:
# their_prev<=0 -> frac 1.0: sell everything we hold
self.engine._handle_their_sell(
token, 0, 0, f"{pos.get('outcome','?')} · {pos.get('title','?')[:42]}")
self._drain_fills() # book the sell's cash + sold-leg
for f in self._drain_fills(): # book the sell's cash + sold-leg
self._record_untracked_buy(f) # (returned buys = leftovers)
# LIVE_ROLLOUT 1.6 — a FAK sell on a thin book can fill 0 and
# the position silently rides to resolution. This pass re-fires
# every backstop poll; count the attempts and raise the alarm
@@ -1244,6 +1399,10 @@ def main():
f"${cfg['risk']['daily_spend_cap_usd']:.0f}/day, "
f"${cfg['risk']['max_total_exposure_usd']:.0f} exposure")
bot.seed()
# boot invariant pass: rebuild any missing bet/conds records and — only
# here, where no trade is in flight — heal a never-debited orphan's cash
# if the drift matches it exactly (HANDOFF proper fix, Option A)
bot.check_book(heal_cash=True)
# one-shot pipeline test: no server, just push a wallet's latest trade through
if args.test_wallet: