diff --git a/copybot.py b/copybot.py index 518d31a8..0d4f70a5 100644 --- a/copybot.py +++ b/copybot.py @@ -238,6 +238,10 @@ FOLLOW_DEFAULT = { "per_wallet_min_usd": {}, # {wallet: usd} — overrides the global floor per wallet "min_entry": 0.0, # only copy entries with their fill price in this band "max_entry": 1.0, # (the archetype/copyability zone; 0.35–0.70 = value) + # #20/#21 dark flags (pre-registered; flip only at the Friday read): + "entry_mode": "taker", # "maker" = rest a GTC bid at their price + "exit_mode": "mirror", # "hold" = never mirror their sells + "maker_ttl_s": 60, # registry-side cancel for resting maker bids } RECENT_TRADE_WINDOW_S = 600 # webhook just told us a trade happened; ignore stale @@ -577,6 +581,66 @@ class LedgerLiveExecutor: "shares": r["filled_shares"], "price": r["price"]}) return r + def maker_buy(self, token_id, shares, price, meta): + """#20 dark path (live): rest a GTC limit BUY at the sharp's own + price; the pending registry adopts the fill or cancels at its TTL. + An instant cross books like a fill (taker at their price or better — + the same semantics the backtest counted); any instant PARTIAL cancels + its remainder so no order outlives this call untracked (the 07-10 + invariant). Venue-side GTD needs >=3min, so TTL is registry-side.""" + import math + sz = math.floor(shares * 100) / 100.0 + try: + bal0 = self._shares_held(token_id) + except Exception as e: + return {"ok": False, "filled_shares": 0.0, "price": price, + "resp": f"pre-check failed: {e}", "paper": False} + d = 2 + for cand in (2, 3, 4): + if abs(round(price, cand) - price) < 1e-9: + d = cand + break + else: + d = 4 + try: + r = self.client.place_limit_order( + token_id=token_id, side="BUY", size=sz, price=round(price, d)) + except Exception as e: # a timed-out post may still be resting + filled, px = self._settle_uncertain(token_id, "BUY", bal0, price) + if filled > 0: + self.fills.append({"side": "BUY", "token": token_id, + "shares": filled, "price": px, + "maker": True}) + return {"ok": True, "filled_shares": filled, "price": px, + "resp": f"filled despite client error: {e}", + "paper": False} + return {"ok": False, "filled_shares": 0.0, "price": price, + "resp": f"exception: {e}", "paper": False} + if not getattr(r, "ok", False): + return {"ok": False, "filled_shares": 0.0, "price": price, + "resp": f"{getattr(r, 'code', '?')}: " + f"{getattr(r, 'message', r)}", "paper": False} + making = float(r.making_amount or 0) + taking = float(r.taking_amount or 0) + filled = taking + if filled > 0: + px = making / filled if filled else price + if filled < sz - 0.01: # partial cross: kill the rest + try: + self.client.cancel_order(order_id=r.order_id) + except Exception: + pass + self.fills.append({"side": "BUY", "token": token_id, + "shares": filled, "price": px, "maker": True}) + return {"ok": True, "filled_shares": filled, "price": px, + "resp": {"order_id": r.order_id, "status": r.status}, + "paper": False} + return {"ok": False, "filled_shares": 0.0, "price": price, + "pending": {"order_id": r.order_id, "bal0": bal0, "sz": sz, + "mode": "maker"}, + "resp": {"order_id": r.order_id, "status": r.status, + "note": "maker bid resting"}, "paper": False} + def sell(self, token_id, shares, price, meta): r = self._order(token_id, shares, price, "SELL") if r["ok"]: @@ -599,6 +663,8 @@ class FollowFilter: def __init__(self, cfg): f = {**FOLLOW_DEFAULT, **cfg.get("follow", {})} self.buy_only = f["buy_only"] + self.entry_mode = f.get("entry_mode", "taker") + self.exit_mode = f.get("exit_mode", "mirror") self.min_their_usd = float(f["min_their_usd"]) self.per_wallet = {k.lower(): float(v) for k, v in f["per_wallet_min_usd"].items()} self.min_entry = float(f["min_entry"]) @@ -638,9 +704,12 @@ class FollowFilter: def describe(self): pw = f" · {len(self.per_wallet)} per-wallet floors" if self.per_wallet else "" wh = f" · {len(self.whales)} whales follow-all" if self.whales else "" + md = (f" · entry_mode {self.entry_mode} · exit_mode {self.exit_mode}" + if (self.entry_mode, self.exit_mode) != ("taker", "mirror") + else "") return (f"follow filter · {'BUY-only' if self.buy_only else 'BUY+SELL'} · " f"conviction ≥ ${self.min_their_usd:,.0f}{pw}{wh} · " - f"entry [{self.min_entry:.2f},{self.max_entry:.2f}]") + f"entry [{self.min_entry:.2f},{self.max_entry:.2f}]{md}") # ── T0: the real-time trade stream (RTDS) ─────────────────────────────────── @@ -742,6 +811,13 @@ class RtdsListener: if m.get("topic") != "activity" or m.get("type") != "trades": return p = m.get("payload") or {} + # #20 paper leg: every platform print passes here — fill any resting + # paper maker bid the print crosses. Cheap no-op unless bids exist. + if self.bot.engine.state.get("paper_maker"): + try: + self.bot.paper_maker_probe(p) + except Exception as e: + log(f"⚠ paper_maker_probe error: {e}") w = (p.get("proxyWallet") or "").lower() if w not in self.watched: return @@ -940,6 +1016,8 @@ class Copybot: self.feed_path = cfg.get("feed_path", FEED) self.fill_log = cfg.get("fill_log", FILL_LOG) self.shadow_log = cfg.get("shadow_log", "rtds_shadow.jsonl") + # #21: append-only ignored-exit ledger (derived per book) + self.ignored_log = self.fill_log.replace("fills", "ignored_exits") # 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 @@ -1097,6 +1175,102 @@ class Copybot: first-order model. Best-effort: never blocks or fails a copy.""" return book_depth(token) # dedupe 2026-07-19 (closes #6): was a line-for-line copy + def write_ignored_exit(self, rec): + """#21 hold-through instrumentation: one JSONL row per ignored + mirror-exit (the forward counterfactual's raw data) + a trimmed tail + in state for the feed.""" + rec["mode"] = "live" if self.engine.ex.live else "paper" + try: + with open(os.path.join(self.here, self.ignored_log), "a") as fh: + fh.write(json.dumps(rec) + "\n") + except Exception: + pass + tail = self.engine.state.setdefault("ignored_exits", []) + tail.append(rec) + del tail[:-200] + + # ── #20 paper-maker registry (paper books only; live uses the pending + # registry with the same TTL semantics) ───────────────────────────── + def paper_maker_probe(self, p): + """RTDS shows every platform print — fill any resting paper maker + bid the print crosses (price <= bid, fill AT our bid).""" + tok = str(p.get("asset") or "") + try: + px = float(p.get("price") or 0) + except (TypeError, ValueError): + return + if not tok or px <= 0: + return + with self.lock: + pend = self.engine.state.get("paper_maker") or [] + if not pend: + return + keep = [] + for e in pend: + if str(e["token"]) != tok or px > e["price"] + 1e-9: + keep.append(e) + continue + self._book_paper_maker_fill(e) + self.engine.state["paper_maker"] = keep + + def _book_paper_maker_fill(self, e): # under self.lock + st = self.engine.state + filled, px = e["sz"], e["price"] + spent = filled * px + st["spend"]["usd"] += spent + mine = st["my_pos"].get(e["token"]) + if e.get("is_add") and mine: + mine["shares"] += filled + mine["cost"] += spent + else: + st["my_pos"][e["token"]] = { + "shares": filled, "cost": spent, "title": e.get("title"), + "outcome": e.get("outcome"), "event": e.get("event"), + "wallet": e.get("wallet"), "cond": e.get("cond")} + self.engine.ex.fills.append({"side": "BUY", "token": e["token"], + "shares": filled, "price": px, + "maker": True}) + for f in self._drain_fills(): + synth = {"timestamp": e.get("their_ts"), + "price": e.get("their_price"), + "outcome": e.get("outcome"), "title": e.get("title")} + self._record_lag(e.get("wallet", ""), synth, f) + ms = st.setdefault("maker_stats", {"fills": 0, "misses": 0}) + ms["fills"] += 1 + held = time.time() - e["ts"] + self.engine.alert( + f"MAKER FILLED · {e.get('outcome')} · {str(e.get('title'))[:40]} — " + f"buy {filled:.2f} @ {px:.3f} (${spent:.2f}, rested {held:.0f}s)", + discord_text=(f"🟢 **OPEN (maker bid filled)** [PAPER]\n" + f"{e.get('outcome')} · {str(e.get('title'))[:60]}\n" + f"buy {filled:.2f} @ {px:.3f} = **${spent:.2f}** " + f"(rested {held:.0f}s)")) + self.engine.persist() + + def paper_maker_sweep(self): + """Expire resting paper bids at TTL into honest misses.""" + st = self.engine.state + if not st.get("paper_maker"): + return + now = time.time() + with self.lock: + keep = [] + for e in st.get("paper_maker") or []: + if now - e["ts"] <= e.get("ttl_s", 60): + keep.append(e) + continue + ms = st.setdefault("maker_stats", {"fills": 0, "misses": 0}) + ms["misses"] += 1 + if not e.get("is_add"): + self.engine.record_miss( + e.get("wallet", ""), e["token"], e.get("cond"), + e.get("title"), e.get("outcome"), e["price"], + e.get("stake", 0), + f"maker bid unfilled ({int(now - e['ts'])}s)") + log(f"MAKER expired unfilled: {e.get('outcome')} · " + f"{str(e.get('title'))[:40]}") + st["paper_maker"] = keep + def _record_lag(self, wallet, t, fill): """Gap 1 — log the detection lag and price slippage of a copy: their fill time/price vs ours. Appends to copybot_fills.jsonl and tracks running @@ -1118,6 +1292,7 @@ class Copybot: "fee": fill.get("fee", 0), "mode": "live" if self.engine.ex.live else "paper", "book": self._book_snapshot(fill["token"]), + **({"maker": True} if fill.get("maker") else {}), } try: with open(os.path.join(self.here, self.fill_log), "a") as fh: @@ -1582,6 +1757,7 @@ class Copybot: paths = [p for p in (self.feed_path, self.feed_path.replace(".json", "_full.json"), self.engine.state_path, self.fill_log, + self.ignored_log, self.shadow_log, self.bets_archive, self.missed_archive) if os.path.exists(os.path.join(repo, p))] @@ -1754,12 +1930,19 @@ class Copybot: except Exception: pass if filled <= 0.01: - log(f"pending expired unfilled: {p['outcome']} · {p['title'][:40]}") + is_maker = p.get("mode") == "maker" + why = "maker bid unfilled" if is_maker \ + else "in-play hold expired unfilled" + log(f"pending expired unfilled: {p['outcome']} · " + f"{p['title'][:40]}" + (" (maker)" if is_maker else "")) + if is_maker: + ms = st.setdefault("maker_stats", {"fills": 0, "misses": 0}) + ms["misses"] += 1 if p["side"] == "BUY" and not p.get("is_add"): self.engine.record_miss( p["wallet"], tok, p.get("cond"), p["title"], p["outcome"], p["price"], p.get("stake", 0), - f"in-play hold expired unfilled ({int(now - p['ts'])}s)") + f"{why} ({int(now - p['ts'])}s)") continue spent = filled * px if p["side"] == "BUY": @@ -2307,6 +2490,20 @@ class Copybot: continue pos["shares"] = min(pos["shares"], chain_sh) name = self.names.get(wallet.lower(), wallet[:10]) + if (self.engine.cfg.get("follow") or {}).get( + "exit_mode", "mirror") == "hold": + # #21: they exited while we weren't listening — log the + # ignored exit ONCE (the flag stops backstop-poll spam; + # we keep holding, so the trigger condition never clears) + if not pos.get("hold_thru_logged"): + pos["hold_thru_logged"] = int(time.time()) + log(f"reconcile: {name} exited " + f"{pos.get('title','?')[:42]} — HOLD-THROUGH " + f"(exit_mode=hold)") + self.engine._handle_their_sell( + token, 0, 0, f"{pos.get('outcome','?')} · " + f"{pos.get('title','?')[:42]}") + continue log(f"reconcile: {name} exited {pos.get('title','?')[:42]} while we " f"weren't listening — mirror-exiting {pos['shares']:.1f}sh now") # their_prev<=0 -> frac 1.0: sell everything we hold @@ -2869,6 +3066,7 @@ def main(): bot = Copybot(cfg, engine, filt, redeemer=redeemer) if bot.fak_retry_s > 0: # FAK crater misses get one re-quote retry engine.on_fak_reject = bot.fak_requote_retry + engine.on_ignored_exit = bot.write_ignored_exit # #21 instrumentation engine.on_miss_spool = bot.spool_missed # missed history survives the cap # single-writer invariant (audit 3.4): every boot stamps the state; a # stale process that survived `machine stop` (gotcha 15c) sees a newer @@ -3009,6 +3207,7 @@ def main(): cycle += 1 try: bot.resolve_pendings() # adopt/expire in-play held orders + bot.paper_maker_sweep() # #20: expire resting paper bids bot.retry_stuck_exits() # LIVE_ROLLOUT 1.6 bot.settle_resolved() bot.sweep_dust(cycle) # reclaim untracked exit residue (live) diff --git a/copytrade.py b/copytrade.py index 2e5f3055..a57a8228 100644 --- a/copytrade.py +++ b/copytrade.py @@ -261,6 +261,16 @@ class PaperExecutor: f"(paper model: {gone})", "paper": True} return {"ok": True, "filled_shares": shares, "price": price, "paper": True} + def maker_buy(self, token_id, shares, price, meta): + """#20 dark path (paper): a resting bid never fills at placement — + the host's paper-maker registry fills it from RTDS prints <= bid + within TTL, or expires it into an honest miss.""" + import math + sz = math.floor(shares * 100) / 100.0 + return {"ok": False, "filled_shares": 0.0, "price": price, + "pending": {"paper_maker": True, "sz": sz, "mode": "maker"}, + "paper": True} + def sell(self, token_id, shares, price, meta): return {"ok": True, "filled_shares": shares, "price": price, "paper": True} @@ -284,6 +294,10 @@ class CopyTrader: # trimmed off state["missed"] so the full-history feed keeps every # skip ever (2026-07-21). None = trimmed rows are simply dropped. self.on_miss_spool = None + # host-installed hook (#21 hold-through): receives each ignored + # mirror-exit so the sell counterfactual stays exactly computable + # forward. None = ignored exits are only console-logged. + self.on_ignored_exit = None # -- helpers -- def log(self, msg): @@ -602,22 +616,46 @@ class CopyTrader: 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, "book": bk}) + f = self.cfg.get("follow") or {} + maker = f.get("entry_mode", "taker") == "maker" + if maker: + # #20: rest at THEIR price (not the current ask) — same gated + # opportunity set as taker mode, only the execution differs + entry_px = their_price + shares = allowed / entry_px + res = self.ex.maker_buy(token, shares, entry_px, + {"title": title, "book": bk}) + else: + entry_px = price + shares = allowed / price + res = self.ex.buy(token, shares, price, + {"title": title, "book": bk}) if not res["ok"]: # in-play books ACCEPT orders with a delayed hold — the executor # reports those as pending (order id + pre-order balance) instead # of failed. Park the full copy context; the heartbeat resolver # adopts the fill when it lands or converts to a miss at TTL. + # (Maker bids park the same way with their own, shorter TTL.) if res.get("pending"): - self.state.setdefault("pending_orders", []).append({ + pend = { **res["pending"], "token": token, "side": "BUY", "wallet": wallet, "title": title, "outcome": outcome, "event": event, "cond": cond, "their_price": their_price, - "their_ts": their_ts, "price": price, "is_add": is_add, - "stake": allowed, "ts": time.time(), "ttl_s": 600}) - self.log(f"{kind} {label} — PENDING (in-play hold, " - f"order {str(res['pending'].get('order_id'))[:14]}…)") + "their_ts": their_ts, "price": entry_px, "is_add": is_add, + "stake": allowed, "ts": time.time(), + "ttl_s": (float(f.get("maker_ttl_s", 60)) + if res["pending"].get("mode") == "maker" + else 600), + "ba_at_signal": (bk or {}).get("ba")} + if res["pending"].get("paper_maker"): + self.state.setdefault("paper_maker", []).append(pend) + self.log(f"{kind} {label} — MAKER bid {entry_px:.3f} " + f"resting (paper, {pend['ttl_s']:.0f}s)") + else: + self.state.setdefault("pending_orders", []).append(pend) + self.log(f"{kind} {label} — PENDING (" + f"{'maker bid' if pend.get('mode') == 'maker' else 'in-play hold'}, " + f"order {str(res['pending'].get('order_id'))[:14]}…)") self.persist() return resp_s = str(res.get("resp")) @@ -668,6 +706,25 @@ class CopyTrader: mine = self.state["my_pos"].get(token) if not mine: return # we don't hold it + if (self.cfg.get("follow") or {}).get("exit_mode", "mirror") == "hold": + # #21 dark path: never mirror their exits — positions ride to + # resolution. Every ignored sell is logged so the mirror-exit + # counterfactual stays exactly computable forward (the + # pre-registered verdict's control). + frac = 1.0 if their_prev <= 0 else min(1.0, their_size / their_prev) + self.log(f"EXIT {label} — HOLD-THROUGH (exit_mode=hold, " + f"their frac {frac:.2f})") + if self.on_ignored_exit: + self.on_ignored_exit({ + "ts": round(time.time(), 1), "token": str(token), + "their_size": their_size, "their_prev": their_prev, + "frac": round(frac, 4), + "shares_held": round(mine.get("shares", 0), 4), + "cost_held": round(mine.get("cost", 0), 2), + "title": (mine.get("title") or "")[:80], + "outcome": mine.get("outcome"), + "wallet": mine.get("wallet", "")}) + return frac = 1.0 if their_prev <= 0 else min(1.0, their_size / their_prev) sell_shares = min(mine["shares"], mine["shares"] * frac) if sell_shares <= 0: