diff --git a/copybot.py b/copybot.py index ec238d49..98a5d07f 100644 --- a/copybot.py +++ b/copybot.py @@ -348,9 +348,14 @@ class LedgerLiveExecutor: # them). BUY never bounds below the quoted cross; SELL never # bounds above the quoted bid. if side == "BUY": + # share-flooring can shave a gated $1.00 stake to $0.99, which + # the venue rejects ('invalid amount for a marketable BUY') — + # 7 of 13 live rejections in the first 3 days. Bump to the $1 + # minimum; worst case pays a cent over the gated stake. + amt = max(round(sz * price, 2), 1.0) r = self.client.place_market_order( token_id=token_id, side="BUY", - amount=round(sz * price, 2), + amount=amt, max_price=min(max(round(price * (1 + self._slip), 4), price), 0.99), order_type=self._otype) @@ -609,6 +614,117 @@ class RtdsListener: log(f"rtds handler error: {e}") +# ── own-fill push: the CLOB user channel ───────────────────────────────────── + +class UserFillsListener: + """LIVE only. Streams OUR order/trade lifecycle from the canonical CLOB + user channel and turns a matching event into an IMMEDIATE + resolve_pendings() pass — in-play holds adopt the second they match + instead of on the next 60s tick. Events only TRIGGER the resolver; + get_order stays the single arbiter (the 2026-07-12 anti-phantom-cash + invariant books nothing from ws payloads). Auth = the CLOB L2 creds the + SDK already derived (client.credentials). On any failure the listener + logs and stays down while the 60s poll carries — never below today.""" + + URL = "wss://ws-subscriptions-clob.polymarket.com/ws/user" + + def __init__(self, bot): + self.bot = bot + self.state = "off" + self.last_msg = 0.0 + + def start(self): + try: + import websocket # noqa: F401 + creds = self.bot.engine.ex.client.credentials + self._auth = {"apiKey": creds.key, "secret": creds.secret, + "passphrase": creds.passphrase} + except Exception as e: + log(f"user-ws: unavailable ({type(e).__name__}: {str(e)[:60]}) — " + "pending adoption stays on the 60s poll") + return False + import websocket + threading.Thread(target=self._run, args=(websocket,), + daemon=True, name="user-ws").start() + return True + + def _pending_ids(self): + return {p.get("order_id") + for p in self.bot.engine.state.get("pending_orders", [])} + + def _run(self, websocket): + backoff = 1 + + def on_open(ws): + # all-markets subscription: omit "markets" entirely (the SDK's + # own UserSpec does the same) — no per-market management needed + ws.send(json.dumps({"type": "user", "auth": self._auth})) + self.state = "up" + self.last_msg = time.time() + log("user-ws: connected — own-fill events live") + + def ping(): + while ws.keep_running: + time.sleep(10) + try: + ws.send("PING") + except Exception: + break + if time.time() - self.last_msg > 30: + log("user-ws: silent 30s — forcing reconnect") + try: + ws.close() + except Exception: + pass + break + threading.Thread(target=ping, daemon=True).start() + + def on_message(ws, raw): + self.last_msg = time.time() + if raw == "PONG": + return + try: + m = json.loads(raw) + except Exception: + return + events = m if isinstance(m, list) else [m] + hit = False + for ev in events: + if not isinstance(ev, dict): + continue + et = ev.get("event_type") + ids = set() + if et == "order" and ev.get("type") == "UPDATE" \ + and float(ev.get("size_matched") or 0) > 0: + ids.add(ev.get("id")) + elif et == "trade" and ev.get("status") in ( + "MATCHED", "MINED", "CONFIRMED"): + ids.add(ev.get("taker_order_id")) + for mo in ev.get("maker_orders") or []: + if isinstance(mo, dict): + ids.add(mo.get("order_id")) + if ids & self._pending_ids(): + hit = True + if hit: + log("user-ws: pending order matched — resolving now") + try: + self.bot.resolve_pendings() + except Exception as e: + log(f"user-ws resolver error: {e}") + + while True: + try: + app = websocket.WebSocketApp(self.URL, on_open=on_open, + on_message=on_message) + app.run_forever(sslopt={"cert_reqs": ssl.CERT_NONE}) + except Exception as e: + log(f"user-ws: listener error {str(e)[:80]}") + self.state = "down" + log(f"user-ws: down — reconnect in {backoff}s (60s poll carries)") + time.sleep(backoff) + backoff = min(backoff * 2, 120) + + # ── the push → filter → execute bridge ────────────────────────────────────── class Copybot: @@ -1197,6 +1313,20 @@ class Copybot: pend = st.get("pending_orders") or [] if not pend or not self.engine.ex.live: return + # user-ws events and the heartbeat both call this — one pass at a + # time; a concurrent caller just skips (the running pass adopts) + rl = getattr(self, "_resolve_lock", None) + if rl is None: + rl = self._resolve_lock = threading.Lock() + if not rl.acquire(blocking=False): + return + try: + self._resolve_pendings_locked(st) + finally: + rl.release() + + def _resolve_pendings_locked(self, st): + pend = st.get("pending_orders") or [] ex = self.engine.ex now = time.time() keep = [] @@ -2115,6 +2245,11 @@ def main(): bot.rtds.start() else: log("rtds: T0 listener disabled for this role (RTDS_DETECT=1 enables)") + if want_live: + # own-fill push (2026-07-13): in-play holds adopt the moment they + # match instead of on the next 60s resolver tick + bot.userws = UserFillsListener(bot) + bot.userws.start() # on-chain resolution RPC (payout vectors for operator-resolved markets): # env ALCHEMY_RPC_URL wins (the Fly worker has no config.json), else the diff --git a/copytrade.py b/copytrade.py index 07034e10..353677e9 100644 --- a/copytrade.py +++ b/copytrade.py @@ -63,7 +63,15 @@ DEFAULT_CONFIG = { "price_guard_abs": 0.05, # skip if price moved >5 POINTS above their # fill (absolute, 2026-07-10: 0.14→0.15 must # follow; relative % blocked 1-tick moves on - # cheap in-play books) + # cheap in-play books). VALIDATED 2026-07-13 + # by the missed-ledger counterfactuals: the + # 0.05 line sits at the EV knee (0.05-0.10 + # moves ≈ breakeven, >0.10 = −20% ROI). + "depth_gate": { # 2026-07-13, fitted on 131 gated fills — + "max_spread": 0.08, # wider = market mid-move (med |slip| ~14%) + "min_ask5c": 50.0, # dust books mispriced every observed fill + "max_frac_of_ask5c": 0.10, # stake ≤10% of 5c ask depth (impact <~2%) + }, "risk": { "max_trade_usd": 50.0, # hard ceiling on any single copy "max_position_usd": 40.0, # hard ceiling on total cost in one market @@ -145,6 +153,32 @@ def clob_price(token_id, side): return None +def book_depth(token_id): + """Top-of-book + $-depth within 5c of touch — the DEPTH GATE's input + (2026-07-13, fitted on 131 gated fills). Returns {bb, ba, spread, bid5c, + ask5c} or None on any failure (the gate then declines to bind — the + price guard and protected prices still bound the copy).""" + try: + url = f"{CLOB_API}/book?token_id={token_id}" + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=8, context=SSL_CTX) as r: + b = json.loads(r.read().decode()) + bids, asks = b.get("bids") or [], b.get("asks") or [] + bb = max((float(x["price"]) for x in bids), default=None) + ba = min((float(x["price"]) for x in asks), default=None) + + def depth(side, ref, sgn): + if ref is None: + return None + return round(sum(float(x["size"]) * float(x["price"]) for x in side + if sgn * (float(x["price"]) - ref) >= -0.05), 2) + return {"bb": bb, "ba": ba, + "spread": round(ba - bb, 4) if bb is not None and ba is not None else None, + "bid5c": depth(bids, bb, 1), "ask5c": depth(asks, ba, -1)} + except Exception: + return None + + def their_positions(wallet): """Current open positions -> {token_id: shares}, for exit-fraction math. Cap is generous: a whale can hold >500 open positions, and a position @@ -499,6 +533,39 @@ class CopyTrader: self.record_miss(wallet, token, cond, title, outcome, price, want_usd, reason) return + # DEPTH GATE (2026-07-13, fitted on 131 book-annotated fills): the + # book must absorb the stake. Fills into <$90 of 5c-depth paid + # +2.6–4.2%; >20% of visible depth made >+2% slippage 33–50% likely; + # spread>0.08 books ran median |slip| ~14% (market mid-move). Shrink + # to 10% of depth, skip dust/unreliable books. A failed book fetch + # declines to bind — guard + protected prices still bound the copy. + dg = self.cfg.get("depth_gate") + if dg: + bk = book_depth(token) + if bk and bk.get("ask5c") is not None: + d_reason = None + if (bk.get("spread") or 0) > dg["max_spread"]: + d_reason = (f"book unreliable (spread {bk['spread']:.2f} > " + f"{dg['max_spread']:.2f})") + elif bk["ask5c"] < dg["min_ask5c"]: + d_reason = (f"thin book (${bk['ask5c']:.0f} within 5c < " + f"${dg['min_ask5c']:.0f})") + else: + cap = dg["max_frac_of_ask5c"] * bk["ask5c"] + if cap < self.risk["min_order_usd"]: + d_reason = (f"depth cap ${cap:.2f} below min order " + f"(ask5c ${bk['ask5c']:.0f})") + elif cap < allowed: + self.log(f"{kind} {label} — depth gate shrinks " + f"${allowed:.2f} → ${cap:.2f} " + f"(10% of ${bk['ask5c']:.0f} ask depth)") + allowed = cap + if d_reason: + self.log(f"{kind} {label} — skip ({d_reason})") + if not is_add: + self.record_miss(wallet, token, cond, title, outcome, + price, allowed, d_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