mirror of
https://github.com/jaxperro/winning-wallet-finder.git
synced 2026-08-04 11:47:45 +00:00
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:
@@ -483,11 +483,16 @@ class CopyTrader:
|
|||||||
if is_add:
|
if is_add:
|
||||||
mine["shares"] += res["filled_shares"]
|
mine["shares"] += res["filled_shares"]
|
||||||
mine["cost"] += spent
|
mine["cost"] += spent
|
||||||
|
if cond:
|
||||||
|
mine.setdefault("cond", cond)
|
||||||
else:
|
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] = {
|
self.state["my_pos"][token] = {
|
||||||
"shares": res["filled_shares"], "cost": spent,
|
"shares": res["filled_shares"], "cost": spent,
|
||||||
"title": title, "outcome": outcome, "event": event,
|
"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]"
|
tag = "[PAPER]" if not self.ex.live else "[LIVE]"
|
||||||
self.alert(
|
self.alert(
|
||||||
f"{kind} {label} — {tag} buy {res['filled_shares']:.1f} "
|
f"{kind} {label} — {tag} buy {res['filled_shares']:.1f} "
|
||||||
|
|||||||
+174
-15
@@ -467,7 +467,7 @@ class Copybot:
|
|||||||
my_p = fill["price"]
|
my_p = fill["price"]
|
||||||
slip_pct = (my_p - their_p) / their_p if their_p else 0.0
|
slip_pct = (my_p - their_p) / their_p if their_p else 0.0
|
||||||
rec = {
|
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]),
|
"name": self.names.get(wallet.lower(), wallet[:10]),
|
||||||
"outcome": t.get("outcome"), "title": (t.get("title") or "")[:80],
|
"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,
|
"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 '?'} · "
|
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)")
|
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):
|
def write_feed(self):
|
||||||
"""Publish the bot's live book to live/copybot_live.json — the feed the
|
"""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
|
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():
|
for tok, p in mp.items():
|
||||||
b = bets.get(tok)
|
b = bets.get(tok)
|
||||||
if not b or b.get("status") != "open":
|
if not b or b.get("status") != "open":
|
||||||
bets[tok] = {
|
self._synth_bet(tok, p)
|
||||||
"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,
|
|
||||||
}
|
|
||||||
for tok, b in bets.items():
|
for tok, b in bets.items():
|
||||||
if b["status"] == "open" and tok not in mp:
|
if b["status"] == "open" and tok not in mp:
|
||||||
b["status"] = "closed"
|
b["status"] = "closed"
|
||||||
@@ -800,6 +948,12 @@ class Copybot:
|
|||||||
for f in self._drain_fills():
|
for f in self._drain_fills():
|
||||||
if f["token"] == tok: # the fill from this copy
|
if f["token"] == tok: # the fill from this copy
|
||||||
self._record_lag(wallet, t, f)
|
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):
|
def reconcile_exits(self):
|
||||||
"""Exits the signal made while we weren't listening. RECENT_TRADE_WINDOW_S
|
"""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
|
# their_prev<=0 -> frac 1.0: sell everything we hold
|
||||||
self.engine._handle_their_sell(
|
self.engine._handle_their_sell(
|
||||||
token, 0, 0, f"{pos.get('outcome','?')} · {pos.get('title','?')[:42]}")
|
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
|
# 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
|
# the position silently rides to resolution. This pass re-fires
|
||||||
# every backstop poll; count the attempts and raise the alarm
|
# 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']['daily_spend_cap_usd']:.0f}/day, "
|
||||||
f"${cfg['risk']['max_total_exposure_usd']:.0f} exposure")
|
f"${cfg['risk']['max_total_exposure_usd']:.0f} exposure")
|
||||||
bot.seed()
|
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
|
# one-shot pipeline test: no server, just push a wallet's latest trade through
|
||||||
if args.test_wallet:
|
if args.test_wallet:
|
||||||
|
|||||||
Reference in New Issue
Block a user