From 476c1059c81590474df11ac8e7553542f815cb8a Mon Sep 17 00:00:00 2001 From: jaxperro Date: Sun, 12 Jul 2026 22:00:41 -0400 Subject: [PATCH] overnight batch: H3 cursor fetch, Bearer clone-guard, SDK preflight, 5c drift alarm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - H3 (burst overflow): per-wallet trade cursor + paginated activity fetch (up to 5×100, walks back to the last processed timestamp) — a gkmg-scale clip burst can no longer scroll past a single newest-100 page. Cursor only advances after a fetch; 600s overlap re-serves boundary rows into the seen-tx dedupe. offset pagination verified against the data-api. - clone-guard: Bearer auth (fine-grained PATs reject the 'token' scheme) with unauthenticated fallback + 40-hex sanity on the answer — Fly's shared egress IPs rate-limit the anonymous path. - preflight_live.py rewritten for the unified SDK: deposit-wallet auth, pUSD collateral, order-book reach, RTDS stream delivery, geo verdict. - ledger-drift ALARM floor 1c → 5c (4dp fill rounding accumulates pennies across 60+ copies; the check stays for real bugs — it caught $15.45 and $7.24. The self-heal path keeps its own threshold.) Co-Authored-By: Claude Fable 5 --- copybot.py | 30 +++++++- copytrade.py | 9 ++- host/start.sh | 11 ++- preflight_live.py | 184 +++++++++++++++++++++------------------------- 4 files changed, 125 insertions(+), 109 deletions(-) diff --git a/copybot.py b/copybot.py index afbda67f..ec238d49 100644 --- a/copybot.py +++ b/copybot.py @@ -987,7 +987,7 @@ class Copybot: fixed = True if fixed: self.engine.persist() - if abs(drift) > 0.01: + if abs(drift) > 0.05: # pennies = 4dp fill rounding, not a bug log(f"⚠ LEDGER DRIFT ${drift:+.2f} — check_book could not attribute it") return drift @@ -1426,7 +1426,7 @@ class Copybot: f"slip {lag['sum_slip_pct']/lag['n']:+.1%}") bankstr = f" · banked ${reserve:,.0f}" if reserve else "" drift = self.ledger_drift() - driftstr = f" · ⚠ LEDGER DRIFT ${drift:+.2f}" if abs(drift) > 0.01 else "" + driftstr = f" · ⚠ LEDGER DRIFT ${drift:+.2f}" if abs(drift) > 0.05 else "" gap = self.chain_cash_gap() if gap is not None and abs(gap) > 1.0: # ≥$1: beyond fee/rounding float driftstr += f" · ⚠ CASH≠CHAIN ${gap:+.2f}" @@ -1440,11 +1440,35 @@ class Copybot: + (f" · CAN'T OPEN (free < ${stake:,.0f} stake — bets missed)" if cash < stake else "")) + def _fetch_since_cursor(self, wallet): + """H3 fix (2026-07-13): a burst trader can push unprocessed rows past + a single newest-100 fetch (gkmg: 30+ clips in 15 min). Paginate the + activity feed back to the last processed timestamp per wallet, capped + at 5 pages — the cursor makes every fetch lossless regardless of + burst size; the cap bounds a cold start. Cursor moves only forward + and only after a fetch, so a failed page just re-reads next time.""" + cur = self.engine.state.setdefault("trade_cursor", {}) + since = cur.get(wallet.lower(), 0) + rows, offset = [], 0 + for _ in range(5): + page = recent_trades(wallet, limit=100, offset=offset) + if not page: + break + rows.extend(page) + oldest = min((t.get("timestamp") or 0) for t in page) + if oldest <= since or len(page) < 100: + break + offset += 100 + if rows: + newest = max((t.get("timestamp") or 0) for t in rows) + cur[wallet.lower()] = max(since, newest) + return [t for t in rows if (t.get("timestamp") or 0) > since - 600] + def on_wallet_activity(self, wallet, ignore_stale=False): """A watched wallet just transacted — pull its latest trades and route any new, recent one through the filter and (if it passes) the engine.""" name = self.names.get(wallet.lower(), wallet[:10] + "…") - trades = recent_trades(wallet) + trades = self._fetch_since_cursor(wallet) # ONE conviction bet often arrives as several fills — a sweep through # the book or rapid clip entries (gkmg 2026-07-09: a $612 MOUZ entry = # 3×$204 same-second rows, every clip sub-floor while the backtest's diff --git a/copytrade.py b/copytrade.py index 91c810e3..07034e10 100644 --- a/copytrade.py +++ b/copytrade.py @@ -167,10 +167,13 @@ def their_positions(wallet): return pos -def recent_trades(wallet, limit=100): - """Newest-first TRADE activity for a wallet.""" +def recent_trades(wallet, limit=100, offset=0): + """Newest-first TRADE activity for a wallet. `offset` pages deeper — + the copybot's per-wallet cursor fetch (H3) walks back through bursts + that overflow a single page.""" return get_json("/activity", - {"user": wallet, "type": "TRADE", "limit": limit}) or [] + {"user": wallet, "type": "TRADE", "limit": limit, + "offset": offset}) or [] def event_key(t): diff --git a/host/start.sh b/host/start.sh index 40f50935..04de28e9 100755 --- a/host/start.sh +++ b/host/start.sh @@ -69,12 +69,19 @@ cd "$DIR" # API is unreachable — never blocks the boot forever). for try in 1 2 3 4; do LOCAL_SHA=$(git rev-parse HEAD) - # public repo — unauthenticated works and avoids fine-grained-PAT header - # quirks (the token'd call 401'd on the box, 2026-07-11 06:04 boot) + # Bearer auth first (fine-grained PATs reject the legacy "token" scheme — + # the 06:04 401), falling back to unauthenticated — which itself rate-limits + # on Fly's SHARED egress IPs (60/hr across tenants; bit at 01:49). REMOTE_SHA=$(curl -sf --max-time 10 \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github.sha" \ + https://api.github.com/repos/jaxperro/winning-wallet-finder/commits/main \ + || curl -sf --max-time 10 \ -H "Accept: application/vnd.github.sha" \ https://api.github.com/repos/jaxperro/winning-wallet-finder/commits/main \ || true) + # a sha is 40 hex chars — anything else (error JSON, rate-limit body) = unusable + echo "$REMOTE_SHA" | grep -qE '^[0-9a-f]{40}$' || REMOTE_SHA="" if [ -z "$REMOTE_SHA" ]; then echo "[clone-guard] GitHub API unreachable — proceeding with $LOCAL_SHA" break diff --git a/preflight_live.py b/preflight_live.py index 26379f8d..4a31bc76 100644 --- a/preflight_live.py +++ b/preflight_live.py @@ -1,28 +1,31 @@ #!/usr/bin/env python3 """Read-only preflight for live trading — verifies credentials, balance, and -market access WITHOUT placing any order. +market access on the UNIFIED SDK (polymarket-client) WITHOUT placing any +order. (Rewritten 2026-07-13: the original validated the archived +py-clob-client stack, whose orders the CLOB rejects — gotcha 16.) python3 preflight_live.py # uses config.live.json + python3 preflight_live.py --config config.live.example.json # Fly path Checks, in order: - 1. config.live.json parses; private key + funder present - 2. CLOB auth: derive L2 API creds from the key (proves the key signs) - 3. USDC balance + allowance on the funder (proves deposits landed and the - proxy can spend — the balance the bot will actually trade with) - 4. live order book fetch for one of the followed wallets' recent markets - (proves market-data access end to end) - 5. Polygon RPC + EOA POL gas balance (needed by auto-redeem) + 1. config parses; private key present (env LIVE_PRIVATE_KEY wins) + 2. unified-SDK auth: SecureClient.create resolves the Deposit Wallet + 3. pUSD collateral via get_balance_allowance — the balance the exchange + will actually let the bot trade with (raw USDC reads 0 — gotcha 16) + 4. live order book fetch for a followed wallet's recent market + 5. RTDS trade stream: connect + subscribe + first message (T0 detection) + 6. geo-gate verdict (informational on a blocked box; the bot enforces + fatally at boot) Exit code 0 = every check passed; anything else prints what to fix. """ import json -import sys -import time -import urllib.request +import os import ssl +import sys +import urllib.request -CLOB = "https://clob.polymarket.com" _SSL = ssl._create_unverified_context() OK, BAD = " ✓", " ✗" failures = [] @@ -34,113 +37,92 @@ def check(name, fn): print(f"{OK} {name}" + (f" — {msg}" if msg else "")) except Exception as e: failures.append(name) - print(f"{BAD} {name} — {e}") + print(f"{BAD} {name} — {type(e).__name__}: {e}") + + +def get(url): + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + return json.load(urllib.request.urlopen(req, timeout=15, context=_SSL)) def main(): - # config: --config PATH, else config.live.json. Secrets: env wins (the Fly - # worker path — LIVE_PRIVATE_KEY / LIVE_FUNDER_ADDRESS / LIVE_SIGNATURE_TYPE - # / ALCHEMY_RPC_URL — same 1.2 override chain as copybot; no key file needs - # to exist anywhere on the box). - import os path = "config.live.json" if "--config" in sys.argv: path = sys.argv[sys.argv.index("--config") + 1] cfg = json.load(open(path)) live = cfg.setdefault("live", {}) - for env, key in (("LIVE_PRIVATE_KEY", "private_key"), - ("LIVE_FUNDER_ADDRESS", "funder_address"), - ("LIVE_SIGNATURE_TYPE", "signature_type"), - ("ALCHEMY_RPC_URL", "rpc_url")): - if (os.environ.get(env) or "").strip(): - live[key] = os.environ[env].strip() # tolerate pasted newlines - if "watchlist" not in cfg and cfg.get("wallets"): - cfg["watchlist"] = [w["wallet"] for w in cfg["wallets"]] - pk, funder = live.get("private_key"), live.get("funder_address") - if not pk or not funder: - sys.exit("fill live.private_key and live.funder_address (config or env) first\n" - "(Polymarket profile -> the deposit/profile address is the funder;\n" - " email-login accounts export the key in Settings)") + if (os.environ.get("LIVE_PRIVATE_KEY") or "").strip(): + live["private_key"] = os.environ["LIVE_PRIVATE_KEY"].strip() + pk = live.get("private_key") + if not pk: + sys.exit(f"{BAD} no live.private_key in {path} and no LIVE_PRIVATE_KEY env") + print(f"{OK} config parses — key present, pct {cfg.get('bankroll_pct')}, " + f"band [{cfg['risk'].get('min_price')},{cfg['risk'].get('max_price')}]") - from py_clob_client.client import ClobClient - from py_clob_client.clob_types import BalanceAllowanceParams, AssetType - - client = ClobClient(host=CLOB, key=pk, chain_id=137, - signature_type=int(live.get("signature_type") or 1), funder=funder) + from polymarket import SecureClient + client = SecureClient.create(private_key=pk) def auth(): - creds = client.create_or_derive_api_creds() - client.set_api_creds(creds) - return f"L2 api key {creds.api_key[:8]}… derived (signer {client.get_address()[:10]}…)" - check("CLOB auth (key signs, creds derive)", auth) + return f"deposit wallet {client.wallet}" + check("unified-SDK auth (SecureClient.create)", auth) - def balance(): - r = client.get_balance_allowance( - BalanceAllowanceParams(asset_type=AssetType.COLLATERAL)) - bal = int(r.get("balance", 0)) / 1e6 - if bal <= 0: - raise RuntimeError("USDC balance is $0 — deposit test funds to the funder address first") - return f"${bal:,.2f} USDC spendable on {funder[:10]}…" - check("USDC balance on funder", balance) + def collateral(): + b = client.get_balance_allowance(asset_type="COLLATERAL") + usd = b.balance / 1e6 + if usd <= 0: + raise RuntimeError("exchange-view collateral is $0 — bankroll " + "not wrapped to pUSD? (gotcha 16)") + return f"${usd:,.2f} pUSD, {len(b.allowances)} spender allowances" + check("exchange-view collateral (pUSD)", collateral) def book(): - # a sharp's LATEST trade is often an in-play market that has already - # closed (404 = "no orderbook", which itself proves connectivity) — - # walk recent trades until one still has a live book - last = None - for w in cfg["watchlist"]: - req = urllib.request.Request( - "https://data-api.polymarket.com/activity?user=" + w - + "&type=TRADE&limit=10", - headers={"User-Agent": "Mozilla/5.0"}) - for t in json.loads(urllib.request.urlopen(req, timeout=15, context=_SSL).read()): - try: - ob = client.get_order_book(t["asset"]) - except Exception as e: - last = e + for w in [x["wallet"] for x in cfg.get("wallets", [])][:3]: + for t in get(f"https://data-api.polymarket.com/activity?user={w}" + f"&type=TRADE&limit=5"): + tok = t.get("asset") + if not tok: continue - bid = ob.bids[-1].price if ob.bids else "—" - ask = ob.asks[-1].price if ob.asks else "—" - return f"{(t.get('title') or '')[:40]}… bid {bid} / ask {ask}" - # every followed wallet is fully in-play right now — prove book access - # against any active market instead - req = urllib.request.Request( - "https://gamma-api.polymarket.com/markets?active=true&closed=false" - "&limit=1&order=volume24hr&ascending=false", - headers={"User-Agent": "Mozilla/5.0"}) - gm = json.loads(urllib.request.urlopen(req, timeout=15, context=_SSL).read())[0] - tok = json.loads(gm["clobTokenIds"])[0] - ob = client.get_order_book(tok) - bid = ob.bids[-1].price if ob.bids else "—" - ask = ob.asks[-1].price if ob.asks else "—" - return f"[fallback: {(gm.get('question') or '')[:36]}…] bid {bid} / ask {ask}" - check("order book fetch (market access)", book) + ob = get(f"https://clob.polymarket.com/book?token_id={tok}") + bids, asks = ob.get("bids") or [], ob.get("asks") or [] + return (f"{len(bids)} bids / {len(asks)} asks on " + f"{(t.get('title') or '?')[:40]}") + raise RuntimeError("no recent market found across the follow set") + check("order-book access (followed wallet's market)", book) - def gas(): - if live.get("auto_redeem") is False: - return "auto_redeem OFF — manual UI redeems; no POL needed (CASH≠CHAIN will nag after wins until redeemed)" - from web3 import Web3 - rpc = live.get("rpc_url") or ( - f"https://polygon-mainnet.g.alchemy.com/v2/{cfg.get('alchemy_key')}" - if cfg.get("alchemy_key") else None) - if not rpc: - raise RuntimeError("no live.rpc_url and no alchemy_key — auto-redeem needs a Polygon RPC") - w3 = Web3(Web3.HTTPProvider(rpc)) - acct = w3.eth.account.from_key(pk) - pol = w3.eth.get_balance(acct.address) / 1e18 - if pol < 0.05: - raise RuntimeError(f"EOA {acct.address[:10]}… holds {pol:.3f} POL — " - "send ~1 POL for redeem gas (or set live.auto_redeem false)") - return f"{pol:.2f} POL gas on EOA {acct.address[:10]}…" - check("Polygon RPC + redeem gas", gas) + def rtds(): + import threading + import websocket + got = [] - print() + def on_open(ws): + ws.send(json.dumps({"action": "subscribe", "subscriptions": [ + {"topic": "activity", "type": "trades", "filters": ""}]})) + + def on_message(ws, raw): + got.append(raw) + ws.close() + app = websocket.WebSocketApp("wss://ws-live-data.polymarket.com", + on_open=on_open, on_message=on_message) + t = threading.Thread(target=lambda: app.run_forever( + sslopt={"cert_reqs": ssl.CERT_NONE}), daemon=True) + t.start() + t.join(timeout=15) + if not got: + raise RuntimeError("no message within 15s") + return "stream delivers (T0 detection reachable)" + check("RTDS trade stream", rtds) + + def geo(): + r = get("https://polymarket.com/api/geoblock") + return ("TRADABLE from here" if not r.get("blocked") + else f"BLOCKED here ({r.get('country')}) — bot must run on " + "the Fly box (informational)") + check("geo-gate", geo) + + client.close() if failures: - sys.exit(f"NOT ready: fix {len(failures)} item(s) above.") - print("ALL CHECKS PASSED — ready for the supervised live test:") - print(" python3 copybot.py --config config.live.json " - "--state copybot_state.live.json --poll 60 --live") - print(' (it will ask you to type the confirmation phrase before anything is placed)') + sys.exit(f"\n{len(failures)} check(s) failed: {', '.join(failures)}") + print("\nall checks passed — safe to arm") if __name__ == "__main__":