items 2-4: depth gate + guard validated + clob_user own-fill push

DEPTH GATE (copytrade.book_depth + gate in _handle_their_buy, fitted on
131 book-annotated fills): pre-order, skip if spread>0.08 (market mid-move,
median |slip| ~14%) or ask5c<$50 (dust books mispriced every fill), else
cap the stake at 10% of 5c ask depth (keeps impact <~2%; >20% of depth made
>+2% slip 33-50% likely). Fail-open: a failed book fetch declines to bind.
This is what lets stakes scale — binds mainly on slow-market wallets like
0xbadaf319 (median 23% of depth at $40-100 stakes). config.depth_gate;
paper+live inherit DEFAULT_CONFIG.

GUARD: counterfactuals VALIDATED the 0.05 absolute guard — it sits at the
EV knee (0.05-0.10 moves ≈ breakeven, >0.10 = -20% ROI). No change, comment
records the evidence. (The +9-14% lag-drift tail in deep esports books is a
detection-lag problem the depth gate can't see; RTDS ~1s already addresses
it — protected max_price is the backstop.)

CLOB_USER: UserFillsListener streams our order/trade lifecycle from
ws-subscriptions-clob/ws/user (auth = client.credentials, the SDK's derived
L2 creds; all-markets, no per-market mgmt). A matching order id triggers an
IMMEDIATE resolve_pendings() — in-play holds adopt on match, not on the 60s
tick. Events only TRIGGER; get_order stays the arbiter (2026-07-12
anti-phantom-cash invariant intact). resolve_pendings now re-entrancy-locked
(ws + heartbeat race). 60s poll stays as the fallback; ws failure degrades
to today. Live role only.

Also: sub-$1 BUY amount bumped to the $1 venue minimum (share-flooring
shaved gated $1.00 stakes to $0.99 — 7 of 13 live rejections).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jaxperro
2026-07-12 23:18:54 -04:00
parent f96a0d58c5
commit e0679a40d9
2 changed files with 204 additions and 2 deletions
+136 -1
View File
@@ -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
+68 -1
View File
@@ -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.64.2%; >20% of visible depth made >+2% slippage 3350% 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