overnight batch: H3 cursor fetch, Bearer clone-guard, SDK preflight, 5c drift alarm

- 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 <noreply@anthropic.com>
This commit is contained in:
jaxperro
2026-07-12 22:00:41 -04:00
parent d6eb7ab2e9
commit 476c1059c8
4 changed files with 125 additions and 109 deletions
+27 -3
View File
@@ -987,7 +987,7 @@ class Copybot:
fixed = True fixed = True
if fixed: if fixed:
self.engine.persist() 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") log(f"⚠ LEDGER DRIFT ${drift:+.2f} — check_book could not attribute it")
return drift return drift
@@ -1426,7 +1426,7 @@ class Copybot:
f"slip {lag['sum_slip_pct']/lag['n']:+.1%}") f"slip {lag['sum_slip_pct']/lag['n']:+.1%}")
bankstr = f" · banked ${reserve:,.0f}" if reserve else "" bankstr = f" · banked ${reserve:,.0f}" if reserve else ""
drift = self.ledger_drift() 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() gap = self.chain_cash_gap()
if gap is not None and abs(gap) > 1.0: # ≥$1: beyond fee/rounding float if gap is not None and abs(gap) > 1.0: # ≥$1: beyond fee/rounding float
driftstr += f" · ⚠ CASH≠CHAIN ${gap:+.2f}" driftstr += f" · ⚠ CASH≠CHAIN ${gap:+.2f}"
@@ -1440,11 +1440,35 @@ class Copybot:
+ (f" · CAN'T OPEN (free < ${stake:,.0f} stake — bets missed)" + (f" · CAN'T OPEN (free < ${stake:,.0f} stake — bets missed)"
if cash < stake else "")) 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): def on_wallet_activity(self, wallet, ignore_stale=False):
"""A watched wallet just transacted — pull its latest trades and route any """A watched wallet just transacted — pull its latest trades and route any
new, recent one through the filter and (if it passes) the engine.""" new, recent one through the filter and (if it passes) the engine."""
name = self.names.get(wallet.lower(), wallet[:10] + "") 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 # 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 = # 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 # 3×$204 same-second rows, every clip sub-floor while the backtest's
+6 -3
View File
@@ -167,10 +167,13 @@ def their_positions(wallet):
return pos return pos
def recent_trades(wallet, limit=100): def recent_trades(wallet, limit=100, offset=0):
"""Newest-first TRADE activity for a wallet.""" """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", return get_json("/activity",
{"user": wallet, "type": "TRADE", "limit": limit}) or [] {"user": wallet, "type": "TRADE", "limit": limit,
"offset": offset}) or []
def event_key(t): def event_key(t):
+9 -2
View File
@@ -69,12 +69,19 @@ cd "$DIR"
# API is unreachable — never blocks the boot forever). # API is unreachable — never blocks the boot forever).
for try in 1 2 3 4; do for try in 1 2 3 4; do
LOCAL_SHA=$(git rev-parse HEAD) LOCAL_SHA=$(git rev-parse HEAD)
# public repo — unauthenticated works and avoids fine-grained-PAT header # Bearer auth first (fine-grained PATs reject the legacy "token" scheme —
# quirks (the token'd call 401'd on the box, 2026-07-11 06:04 boot) # 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 \ 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" \ -H "Accept: application/vnd.github.sha" \
https://api.github.com/repos/jaxperro/winning-wallet-finder/commits/main \ https://api.github.com/repos/jaxperro/winning-wallet-finder/commits/main \
|| true) || 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 if [ -z "$REMOTE_SHA" ]; then
echo "[clone-guard] GitHub API unreachable — proceeding with $LOCAL_SHA" echo "[clone-guard] GitHub API unreachable — proceeding with $LOCAL_SHA"
break break
+83 -101
View File
@@ -1,28 +1,31 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Read-only preflight for live trading — verifies credentials, balance, and """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 # uses config.live.json
python3 preflight_live.py --config config.live.example.json # Fly path
Checks, in order: Checks, in order:
1. config.live.json parses; private key + funder present 1. config parses; private key present (env LIVE_PRIVATE_KEY wins)
2. CLOB auth: derive L2 API creds from the key (proves the key signs) 2. unified-SDK auth: SecureClient.create resolves the Deposit Wallet
3. USDC balance + allowance on the funder (proves deposits landed and the 3. pUSD collateral via get_balance_allowance the balance the exchange
proxy can spend — the balance the bot will actually trade with) will actually let the bot trade with (raw USDC reads 0 — gotcha 16)
4. live order book fetch for one of the followed wallets' recent markets 4. live order book fetch for a followed wallet's recent market
(proves market-data access end to end) 5. RTDS trade stream: connect + subscribe + first message (T0 detection)
5. Polygon RPC + EOA POL gas balance (needed by auto-redeem) 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. Exit code 0 = every check passed; anything else prints what to fix.
""" """
import json import json
import sys import os
import time
import urllib.request
import ssl import ssl
import sys
import urllib.request
CLOB = "https://clob.polymarket.com"
_SSL = ssl._create_unverified_context() _SSL = ssl._create_unverified_context()
OK, BAD = "", "" OK, BAD = "", ""
failures = [] failures = []
@@ -34,113 +37,92 @@ def check(name, fn):
print(f"{OK} {name}" + (f"{msg}" if msg else "")) print(f"{OK} {name}" + (f"{msg}" if msg else ""))
except Exception as e: except Exception as e:
failures.append(name) 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(): 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" path = "config.live.json"
if "--config" in sys.argv: if "--config" in sys.argv:
path = sys.argv[sys.argv.index("--config") + 1] path = sys.argv[sys.argv.index("--config") + 1]
cfg = json.load(open(path)) cfg = json.load(open(path))
live = cfg.setdefault("live", {}) live = cfg.setdefault("live", {})
for env, key in (("LIVE_PRIVATE_KEY", "private_key"), if (os.environ.get("LIVE_PRIVATE_KEY") or "").strip():
("LIVE_FUNDER_ADDRESS", "funder_address"), live["private_key"] = os.environ["LIVE_PRIVATE_KEY"].strip()
("LIVE_SIGNATURE_TYPE", "signature_type"), pk = live.get("private_key")
("ALCHEMY_RPC_URL", "rpc_url")): if not pk:
if (os.environ.get(env) or "").strip(): sys.exit(f"{BAD} no live.private_key in {path} and no LIVE_PRIVATE_KEY env")
live[key] = os.environ[env].strip() # tolerate pasted newlines print(f"{OK} config parses — key present, pct {cfg.get('bankroll_pct')}, "
if "watchlist" not in cfg and cfg.get("wallets"): f"band [{cfg['risk'].get('min_price')},{cfg['risk'].get('max_price')}]")
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)")
from py_clob_client.client import ClobClient from polymarket import SecureClient
from py_clob_client.clob_types import BalanceAllowanceParams, AssetType client = SecureClient.create(private_key=pk)
client = ClobClient(host=CLOB, key=pk, chain_id=137,
signature_type=int(live.get("signature_type") or 1), funder=funder)
def auth(): def auth():
creds = client.create_or_derive_api_creds() return f"deposit wallet {client.wallet}"
client.set_api_creds(creds) check("unified-SDK auth (SecureClient.create)", auth)
return f"L2 api key {creds.api_key[:8]}… derived (signer {client.get_address()[:10]}…)"
check("CLOB auth (key signs, creds derive)", auth)
def balance(): def collateral():
r = client.get_balance_allowance( b = client.get_balance_allowance(asset_type="COLLATERAL")
BalanceAllowanceParams(asset_type=AssetType.COLLATERAL)) usd = b.balance / 1e6
bal = int(r.get("balance", 0)) / 1e6 if usd <= 0:
if bal <= 0: raise RuntimeError("exchange-view collateral is $0 — bankroll "
raise RuntimeError("USDC balance is $0 — deposit test funds to the funder address first") "not wrapped to pUSD? (gotcha 16)")
return f"${bal:,.2f} USDC spendable on {funder[:10]}" return f"${usd:,.2f} pUSD, {len(b.allowances)} spender allowances"
check("USDC balance on funder", balance) check("exchange-view collateral (pUSD)", collateral)
def book(): def book():
# a sharp's LATEST trade is often an in-play market that has already for w in [x["wallet"] for x in cfg.get("wallets", [])][:3]:
# closed (404 = "no orderbook", which itself proves connectivity) — for t in get(f"https://data-api.polymarket.com/activity?user={w}"
# walk recent trades until one still has a live book f"&type=TRADE&limit=5"):
last = None tok = t.get("asset")
for w in cfg["watchlist"]: if not tok:
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
continue continue
bid = ob.bids[-1].price if ob.bids else "" ob = get(f"https://clob.polymarket.com/book?token_id={tok}")
ask = ob.asks[-1].price if ob.asks else "" bids, asks = ob.get("bids") or [], ob.get("asks") or []
return f"{(t.get('title') or '')[:40]} bid {bid} / ask {ask}" return (f"{len(bids)} bids / {len(asks)} asks on "
# every followed wallet is fully in-play right now — prove book access f"{(t.get('title') or '?')[:40]}")
# against any active market instead raise RuntimeError("no recent market found across the follow set")
req = urllib.request.Request( check("order-book access (followed wallet's market)", book)
"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)
def gas(): def rtds():
if live.get("auto_redeem") is False: import threading
return "auto_redeem OFF — manual UI redeems; no POL needed (CASH≠CHAIN will nag after wins until redeemed)" import websocket
from web3 import Web3 got = []
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)
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: if failures:
sys.exit(f"NOT ready: fix {len(failures)} item(s) above.") sys.exit(f"\n{len(failures)} check(s) failed: {', '.join(failures)}")
print("ALL CHECKS PASSED — ready for the supervised live test:") print("\nall checks passed — safe to arm")
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)')
if __name__ == "__main__": if __name__ == "__main__":