From 2946ac9170439efbb7e01192699a93f20e4daea8 Mon Sep 17 00:00:00 2001 From: jaxperro Date: Thu, 25 Jun 2026 12:41:56 -0600 Subject: [PATCH] copybot: 24/7 paper copy-trade test + live dashboard feed Push-or-poll copy bot (FollowFilter conviction gate + copytrade engine), cash-gated $1k book, resolution settling, lag/slippage logging, FOK live orders, on-chain redeem. Runs free 24/7 via GitHub Actions (--poll-once cron) and publishes live/copybot_live.json for the trading dashboard. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/copybot.yml | 53 +++ .gitignore | 5 + archive/copytrade.py | 11 +- com.jaxperro.copybot.plist | 43 ++ copybot.py | 742 ++++++++++++++++++++++++++++++++++ live/copybot.paper.json | 52 +++ live/copybot_live.json | 22 + live/sync_floors.py | 54 +++ redeem.py | 111 +++++ run_copybot.sh | 14 + 10 files changed, 1106 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/copybot.yml create mode 100644 com.jaxperro.copybot.plist create mode 100644 copybot.py create mode 100644 live/copybot.paper.json create mode 100644 live/copybot_live.json create mode 100644 live/sync_floors.py create mode 100644 redeem.py create mode 100755 run_copybot.sh diff --git a/.github/workflows/copybot.yml b/.github/workflows/copybot.yml new file mode 100644 index 00000000..f9fe7723 --- /dev/null +++ b/.github/workflows/copybot.yml @@ -0,0 +1,53 @@ +name: copybot-paper + +# 24/7 paper copy-trade test, run for free on GitHub Actions (public repo). +# Every 5 minutes it does one poll pass — settle resolved bets, copy any new +# conviction trade from the 4 wallets at $50 (cash-gated $1k book) — and commits +# the updated feed (live/copybot_live.json) + state. The dashboard at the top of +# jaxperro.com/trading reads the feed. State persists in the repo, so the book +# carries forward run to run. No server, no secrets: the built-in GITHUB_TOKEN +# pushes the feed. (Paper only — for LIVE real-money trading use a dedicated host, +# not Actions.) + +on: + schedule: + - cron: "*/5 * * * *" # GitHub may delay/skip under load; fine for ≥3h leads + workflow_dispatch: {} # lets you run it manually from the Actions tab + +permissions: + contents: write # GITHUB_TOKEN pushes the feed + state + +concurrency: + group: copybot-paper # never overlap runs (state is committed) + cancel-in-progress: false + +jobs: + poll: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Poll once + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} # optional; pings on placements + run: | + python3 copybot.py \ + --config live/copybot.paper.json \ + --state copybot_state.json \ + --poll-once + + - name: Commit feed + state (only on change) + run: | + git config user.name "copybot[bot]" + git config user.email "copybot@users.noreply.github.com" + git add -f live/copybot_live.json copybot_state.json + if git diff --cached --quiet; then + echo "no change this run" + exit 0 + fi + git commit -m "copybot: paper poll [skip ci]" + git pull --rebase --autostash -q origin main || git rebase --abort || true + git push diff --git a/.gitignore b/.gitignore index 0f9aa679..12474f4f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,14 @@ __pycache__/ # never commit live credentials or runtime state config.json +config.json.bak +config.example.json copytrade_state.json +copybot_state.json +copybot_fills.jsonl *.log *.tmp +# NOTE: live/copybot_live.json IS tracked — it's the public dashboard feed # generated research data (regenerable via edge_research.py / table_77.py) edge_metrics.jsonl diff --git a/archive/copytrade.py b/archive/copytrade.py index 45c00e90..512529ad 100644 --- a/archive/copytrade.py +++ b/archive/copytrade.py @@ -255,6 +255,13 @@ class CopyTrader: if len(self.state["my_pos"]) >= r["max_open_positions"]: return 0.0, f"max open positions ({r['max_open_positions']}) reached" self.reset_daily_if_needed() + # free cash, when tracked (copybot maintains state["cash"], recycled on + # sell + resolution). All-or-nothing like the dashboard's `if(cash>=stake)`: + # a bet we can't fully fund from free cash is a MISS, not a partial fill. + cash = self.state.get("cash") + if cash is not None and cash < want_usd: + return 0.0, (f"capital fully deployed (free ${cash:.2f} < " + f"stake ${want_usd:.2f})") caps = [ want_usd, r["max_trade_usd"], @@ -262,10 +269,12 @@ class CopyTrader: r["daily_spend_cap_usd"] - self.state["spend"]["usd"], r["max_total_exposure_usd"] - self.open_exposure(), ] + if cash is not None: + caps.append(cash) # never deploy more than free cash allowed = min(caps) if allowed < r["min_order_usd"]: return 0.0, (f"capped to ${allowed:.2f} < min order " - f"${r['min_order_usd']:.2f} (daily/exposure caps)") + f"${r['min_order_usd']:.2f} (caps)") return allowed, None # -- process one of their trades -- diff --git a/com.jaxperro.copybot.plist b/com.jaxperro.copybot.plist new file mode 100644 index 00000000..f7604b64 --- /dev/null +++ b/com.jaxperro.copybot.plist @@ -0,0 +1,43 @@ + + + + + + Label + com.jaxperro.copybot + ProgramArguments + + /Users/jaxmakielski/polymarket-smart-money/run_copybot.sh + + WorkingDirectory + /Users/jaxmakielski/polymarket-smart-money + RunAtLoad + + KeepAlive + + StandardOutPath + /Users/jaxmakielski/polymarket-smart-money/copybot.launchd.log + StandardErrorPath + /Users/jaxmakielski/polymarket-smart-money/copybot.launchd.log + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + + + diff --git a/copybot.py b/copybot.py new file mode 100644 index 00000000..601613cd --- /dev/null +++ b/copybot.py @@ -0,0 +1,742 @@ +#!/usr/bin/env python3 +"""copybot.py — push-driven, live-capable Polymarket copy-trader. + +Marries the two halves you already built: + + * webhook_receiver.py's **push** trigger — Alchemy's Address-Activity webhook + POSTs here the instant a watched wallet transacts on Polygon. No polling. + * archive/copytrade.py's hardened **execution engine** — paper + live + (py-clob-client) executor, the full risk-block gates, price guard, + no-backfill seeding, and proportional entry/exit mirroring. + +Flow: + Alchemy POST /alchemy + → enrich the tx via the Polymarket data-API (market, side, price, size) + → FollowFilter — the "only the trades I actually want" gate + → CopyTrader.handle_trade — sizes + places under every risk cap + +The execution engine is unchanged; this file only swaps the *trigger* from a +poll loop to a push, and inserts the follow-filter in front of it. + +SAFETY — paper by default. Live trading needs ALL of: + 1. "mode": "live" in config.json, + 2. the --live flag, + 3. typing the confirmation phrase when prompted, + 4. py-clob-client installed + live creds in config "live". +The same hard caps (per-trade / daily / exposure / open positions / price band) +apply in both modes. This is real money in live mode. + +Endpoints (stdlib http server, binds $PORT or 8080): + POST /alchemy ← point the Alchemy webhook here + GET /health ← uptime check + +Usage: + python3 copybot.py # paper, listen for webhooks + python3 copybot.py --conviction-from-sharps live/watch_sharps.json + python3 copybot.py --test-wallet 0x.. # dry-run the pipeline on a wallet's + # latest trade, then exit (no server) + python3 copybot.py --live # live (needs mode:live + confirm) +""" + +import argparse +import hashlib +import hmac +import json +import os +import sys +import threading +import time +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +# reuse the proven execution engine as a library (kept in archive/) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "archive")) +from copytrade import ( # noqa: E402 + CopyTrader, PaperExecutor, LiveExecutor, DEFAULT_CONFIG, + load_json, save_json, new_state, recent_trades, post_discord, confirm_live, +) +from smart_money import SSL_CTX # noqa: E402 + +CLOB_API = "https://clob.polymarket.com" + +# follow-filter defaults — merged under cfg["follow"]; permissive so nothing is +# silently dropped until you opt in. The engine's risk caps bound everything +# regardless of these. +FOLLOW_DEFAULT = { + "buy_only": True, # SELLs only ever close a position we already hold + "min_their_usd": 0.0, # global conviction floor: ignore their bets below this + "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) +} + +RECENT_TRADE_WINDOW_S = 600 # webhook just told us a trade happened; ignore stale +FILL_LOG = "copybot_fills.jsonl" # append-only ledger of every copy fill + lag/slippage +FEED = os.path.join("live", "copybot_live.json") # published feed the trading dashboard reads +FEED_PUSH_MIN_S = 120 # min seconds between feed git-pushes (commit-on-change) + + +def log(m): + print(f"{time.strftime('%H:%M:%S')} {m}", flush=True) + + +# ── market resolution lookup (for settling held positions at resolution) ───── + +_MKT_CACHE = {} # cond -> market dict, cached only once resolved +_MKT_LOCK = threading.Lock() + + +def _market(cond): + with _MKT_LOCK: + if cond in _MKT_CACHE: + return _MKT_CACHE[cond] + try: + req = urllib.request.Request(f"{CLOB_API}/markets/{cond}", + headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=12, context=SSL_CTX) as r: + m = json.loads(r.read().decode()) or {} + except Exception: + return None + # only cache a resolved market (it won't change); re-check live ones each pass + if any(t.get("winner") in (True, False) for t in (m.get("tokens") or [])): + with _MKT_LOCK: + _MKT_CACHE[cond] = m + return m + + +def market_tokens(cond): + m = _market(cond) + return (m.get("tokens") or []) if m else None + + +def market_neg_risk(cond): + """True if the market settles through the Neg-Risk adapter (different redeem + path). Best-effort from the CLOB market metadata.""" + m = _market(cond) + return bool(m.get("neg_risk")) if m else False + + +def resolution_price(token_id, cond, outcome=None): + """Settled price of our held token: 1.0 if it won, 0.0 if it lost, None if the + market hasn't resolved yet. Matches by outcome first (as the dashboard does), + then by token_id.""" + toks = market_tokens(cond) + if not toks: + return None + + def winp(t): + w = t.get("winner") + return 1.0 if w is True else 0.0 if w is False else None + + if outcome is not None: + for t in toks: + if t.get("outcome") == outcome: + return winp(t) + for t in toks: + if str(t.get("token_id")) == str(token_id): + return winp(t) + return None + + +class LedgerPaperExecutor(PaperExecutor): + """Paper executor that records each fill (side/token/shares/price), so the run + can track free cash, realized P&L, and lag/slippage. Paper fills at the live + CLOB price the engine fetched — already capturing detection + price-drift lag, + unlike the dashboard's zero-lag assumption.""" + + def __init__(self): + self.fills = [] # [{side, token, shares, price}] since last drain + + def buy(self, token_id, shares, price, meta): + r = super().buy(token_id, shares, price, meta) + self.fills.append({"side": "BUY", "token": token_id, + "shares": r["filled_shares"], "price": r["price"]}) + return r + + def sell(self, token_id, shares, price, meta): + r = super().sell(token_id, shares, price, meta) + self.fills.append({"side": "SELL", "token": token_id, + "shares": r["filled_shares"], "price": r["price"]}) + return r + + +class LedgerLiveExecutor(LiveExecutor): + """Live executor with two production fixes over the base GTC executor: + + * **Marketable Fill-Or-Kill orders** (gap 3) — a copy either fills + immediately at a crossing price or is cleanly killed, never left resting + on the book half-filled. Order type is configurable (live.order_type: + FOK all-or-nothing, or FAK fill-what-you-can-then-kill). + * **Fill recording** — same ledger as paper, so cash/lag/slippage tracking + works live too. filled_shares comes from the match response when present. + """ + + def __init__(self, cfg): + super().__init__(cfg) + self.fills = [] + name = cfg.get("live", {}).get("order_type", "FOK").upper() + self._otype = getattr(self._OrderType, name, self._OrderType.FOK) + + def _order(self, token_id, shares, price, side): + args = self._OrderArgs(price=round(price, 3), size=round(shares, 2), + side=side, token_id=token_id) + signed = self.client.create_order(args) + resp = self.client.post_order(signed, self._otype) # marketable FOK/FAK + ok = bool(resp and resp.get("success", True)) + filled = shares if ok else 0.0 + for k in ("sizeMatched", "size_matched", "makingAmount"): # use real fill if reported + if resp and resp.get(k): + try: + filled = float(resp[k]); break + except (TypeError, ValueError): + pass + return {"ok": ok and filled > 0, "filled_shares": filled, "price": price, + "resp": resp, "paper": False} + + def buy(self, token_id, shares, price, meta): + r = self._order(token_id, shares, price, self._BUY) + if r["ok"]: + self.fills.append({"side": "BUY", "token": token_id, + "shares": r["filled_shares"], "price": r["price"]}) + return r + + def sell(self, token_id, shares, price, meta): + r = self._order(token_id, shares, price, self._SELL) + if r["ok"]: + self.fills.append({"side": "SELL", "token": token_id, + "shares": r["filled_shares"], "price": r["price"]}) + return r + + +# ── follow-filter — "just the ones I want to follow" ──────────────────────── + +class FollowFilter: + """Decides whether one of their trades is worth handing to the engine. + + This is the selection gate that sits in front of execution. A BUY must be in + your follow set, clear the conviction (stake-size) floor, and land in the + entry-price band. A SELL always passes — the engine then mirrors it only if + we actually hold the token. + """ + + def __init__(self, cfg): + f = {**FOLLOW_DEFAULT, **cfg.get("follow", {})} + self.buy_only = f["buy_only"] + 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"]) + self.max_entry = float(f["max_entry"]) + wl = cfg.get("watchlist") or [w["wallet"] for w in cfg.get("watch", [])] + self.wallets = {w.lower() for w in wl} + + def floor(self, wallet): + return self.per_wallet.get(wallet.lower(), self.min_their_usd) + + def check(self, wallet, t): + """-> (follow: bool, reason_if_skipped: str|None).""" + if wallet.lower() not in self.wallets: + return False, "wallet not in follow set" + side = t.get("side") + if side == "SELL": + return True, None # engine exits only if we hold + if side != "BUY": + return False, f"side {side}" + usd = t.get("usdcSize") or t.get("size", 0) * t.get("price", 0) + fl = self.floor(wallet) + if usd < fl: + return False, f"${usd:,.0f} < conviction floor ${fl:,.0f}" + p = t.get("price", 0) + if not (self.min_entry <= p <= self.max_entry): + return False, f"entry {p:.2f} outside [{self.min_entry:.2f},{self.max_entry:.2f}]" + return True, None + + def describe(self): + pw = f" · {len(self.per_wallet)} per-wallet floors" if self.per_wallet else "" + return (f"follow filter · {'BUY-only' if self.buy_only else 'BUY+SELL'} · " + f"conviction ≥ ${self.min_their_usd:,.0f}{pw} · " + f"entry [{self.min_entry:.2f},{self.max_entry:.2f}]") + + +# ── the push → filter → execute bridge ────────────────────────────────────── + +class Copybot: + def __init__(self, cfg, engine, filt, redeemer=None): + self.cfg = cfg + self.engine = engine + self.filt = filt + self.redeemer = redeemer # gap 2: on-chain redeem of resolved positions (live) + # webhook from env (so a scheduled runner can inject it as a secret) or config + self.webhook = os.environ.get("DISCORD_WEBHOOK") or cfg.get("discord_webhook", "") + self.names = {} + for w in cfg.get("watch", []): + self.names[w["wallet"].lower()] = w.get("name", w["wallet"][:10]) + self.skipped = set() # tx we've already evaluated-and-skipped (no re-log) + self.negrisk_warned = set() # conds we've already warned need manual redeem + self.lock = threading.Lock() # serialize engine/settle access (webhook is threaded) + self.here = os.path.dirname(os.path.abspath(__file__)) + # persisted across restarts via the engine's state file + self.conds = engine.state.setdefault("conds", {}) # token_id -> conditionId (open positions) + engine.state.setdefault("cash", cfg["bankroll_usd"]) # free cash (recycles on sell/resolution) + engine.state.setdefault("lag", {"n": 0, "sum_s": 0.0, "sum_slip_pct": 0.0}) + + def _drain_fills(self): + """Apply cash flows from any fills the engine just made; return the BUY + fills so the caller can log lag/slippage against the source trade.""" + ex = self.engine.ex + buys = [] + if hasattr(ex, "fills") and ex.fills: + for f in ex.fills: + sign = -1 if f["side"] == "BUY" else 1 + self.engine.state["cash"] += sign * f["shares"] * f["price"] + if f["side"] == "BUY": + buys.append(f) + ex.fills.clear() + return buys + + 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 + averages for the summary, so the live cost of lag is measurable.""" + now = time.time() + their_ts = t.get("timestamp", 0) or 0 + detect_s = (now - their_ts) if their_ts else None + their_p = t.get("price", 0) or 0 + my_p = fill["price"] + slip_pct = (my_p - their_p) / their_p if their_p else 0.0 + rec = { + "ts": round(now, 1), "wallet": wallet, + "name": self.names.get(wallet.lower(), wallet[:10]), + "outcome": t.get("outcome"), "title": (t.get("title") or "")[:80], + "detect_lag_s": round(detect_s, 1) if detect_s is not None else None, + "their_price": round(their_p, 4), "my_price": round(my_p, 4), + "slippage_pct": round(slip_pct, 4), + "shares": round(fill["shares"], 2), "cost": round(fill["shares"] * my_p, 2), + "mode": "live" if self.engine.ex.live else "paper", + } + try: + with open(os.path.join(self.here, FILL_LOG), "a") as fh: + fh.write(json.dumps(rec) + "\n") + except Exception: + pass + if detect_s is not None: + lag = self.engine.state["lag"] + lag["n"] += 1 + lag["sum_s"] += detect_s + lag["sum_slip_pct"] += slip_pct + # record the placed bet for the live dashboard feed + self.engine.state.setdefault("bets", {})[fill["token"]] = { + "token": fill["token"], "wallet": wallet, + "name": self.names.get(wallet.lower(), wallet[:10]), + "outcome": t.get("outcome"), "title": (t.get("title") or "")[:90], + "their_price": round(their_p, 4), "my_price": round(my_p, 4), + "slippage_pct": round(slip_pct, 4), + "shares": round(fill["shares"], 2), "cost": round(fill["shares"] * my_p, 2), + "opened": int(their_ts or now), "status": "open", + "exit_price": None, "pnl": None, "settled": None, + } + log(f" ↳ lag {('%.0fs' % detect_s) if detect_s is not None else '?'} · " + f"their {their_p:.3f} → mine {my_p:.3f} ({slip_pct:+.1%} slippage)") + + def write_feed(self): + """Publish the bot's live book to live/copybot_live.json — the feed the + top of jaxperro.com/trading reads. Reconciles any open bet no longer held + (mirror-sold) to 'closed'.""" + st = self.engine.state + bets = st.setdefault("bets", {}) + mp = st["my_pos"] + for tok, b in bets.items(): + if b["status"] == "open" and tok not in mp: + b["status"] = "closed" + b["settled"] = b["settled"] or int(time.time()) + bank = self.cfg["bankroll_usd"] + exp = self.engine.open_exposure() + cash = st.get("cash", bank) + lag = st.get("lag", {}) + feed = { + "mode": "live" if self.engine.ex.live else "paper", + "bankroll": bank, "stake": round(bank * self.cfg["bankroll_pct"], 2), + "cash": round(cash, 2), "deployed": round(exp, 2), + "realized": round(cash + exp - bank, 2), "open_count": len(mp), + "lag": {"n": lag.get("n", 0), + "avg_s": round(lag["sum_s"] / lag["n"], 1) if lag.get("n") else None, + "avg_slip_pct": round(lag["sum_slip_pct"] / lag["n"], 4) if lag.get("n") else None}, + "wallets": [w.get("name", w["wallet"][:10]) for w in self.cfg.get("watch", [])], + "bets": sorted(bets.values(), + key=lambda b: b.get("settled") or b.get("opened") or 0, + reverse=True)[:100], + } + # only (re)write — and so only commit — when the meaningful content changed, + # not on every poll. The "updated" stamp advances only on real change, so the + # scheduled runner doesn't spam a commit every 5 minutes. + sig = hashlib.md5(json.dumps(feed, sort_keys=True).encode()).hexdigest() + if sig == self.engine.state.get("feed_sig"): + return + self.engine.state["feed_sig"] = sig + feed["updated"] = int(time.time()) + path = os.path.join(self.here, FEED) + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".tmp" + json.dump(feed, open(tmp, "w"), indent=1) + os.replace(tmp, path) + + def publish_feed(self): + """Commit + push the feed to GitHub so the public dashboard can read it. + Throttled and commit-on-change, so pushes track betting activity, not the + poll rate. Best-effort — never crashes the run.""" + st = self.engine.state + now = time.time() + if now - st.get("feed_pushed_at", 0) < FEED_PUSH_MIN_S: + return + st["feed_pushed_at"] = now + try: + import subprocess + repo = self.here + c = subprocess.run(["git", "-C", repo, "commit", FEED, "-m", + "copybot: live paper feed [skip ci]"], + capture_output=True, text=True) + if c.returncode != 0: + return # nothing changed + subprocess.run(["git", "-C", repo, "pull", "--rebase", "-q", "origin", "main"], + capture_output=True) + p = subprocess.run(["git", "-C", repo, "push", "-q", "origin", "main"], + capture_output=True, text=True) + log("published live feed → dashboard" if p.returncode == 0 + else "feed push failed (will retry next change)") + except Exception as e: + log(f"feed publish error: {e}") + + def seed(self): + """Load each watched wallet's current positions so exits mirror correctly + and we never backfill a position they held before we started.""" + for wallet in self.cfg.get("watchlist", []): + self.engine.seed_wallet(wallet) + + def baseline(self): + """Mark every currently-visible trade as already seen, so a poll run only + copies trades that happen AFTER startup (the forward equivalent of the + dashboard's June-1 START — no retro-copying of history).""" + n = 0 + for wallet in self.cfg.get("watchlist", []): + for t in recent_trades(wallet): + tx = t.get("transactionHash") + if tx and tx not in self.engine.seen: + self.engine.seen.add(tx) + n += 1 + self.engine.persist() + log(f"baseline: {n} existing trades marked seen — copying only NEW trades from now") + + def summary(self, cycle): + bank = self.cfg["bankroll_usd"] + stake = bank * self.cfg["bankroll_pct"] + exp = self.engine.open_exposure() + cash = self.engine.state.get("cash", bank) + realized = cash + exp - bank # see _drain_fills / settle_resolved + n = len(self.engine.state["my_pos"]) + lag = self.engine.state.get("lag", {}) + lagstr = "" + if lag.get("n"): + lagstr = (f" · {lag['n']} copies avg lag {lag['sum_s']/lag['n']:.0f}s " + f"slip {lag['sum_slip_pct']/lag['n']:+.1%}") + log(f"[{cycle}] open {n} · deployed ${exp:,.0f} · free ${cash:,.0f}/${bank:,.0f} " + f"· realized ${realized:+,.2f}{lagstr}" + + (f" · CAN'T OPEN (free < ${stake:,.0f} stake — bets missed)" + if cash < stake else "")) + + 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) + # oldest-first so the engine's position math stays causal + for t in sorted(trades, key=lambda x: x.get("timestamp", 0)): + tx = t.get("transactionHash") + if not tx or tx in self.engine.seen or tx in self.skipped: + continue + if not ignore_stale and time.time() - t.get("timestamp", 0) > RECENT_TRADE_WINDOW_S: + self.skipped.add(tx) # stale — the webhook is about a newer tx + continue + follow, reason = self.filt.check(wallet, t) + if not follow: + self.skipped.add(tx) + log(f"skip {name}: {t.get('side')} {t.get('outcome','?')} " + f"@ {t.get('price',0):.3f} — {reason}") + continue + log(f"FOLLOW {name}: {t.get('side')} {t.get('outcome','?')} " + f"@ {t.get('price',0):.3f} (${t.get('usdcSize',0):,.0f})") + with self.lock: + self.engine.handle_trade(wallet, t) # sizes, gates, places (paper/live) + tok = t.get("asset") + if tok in self.engine.state["my_pos"] and tok not in self.conds: + self.conds[tok] = t.get("conditionId") # remember for settling + for f in self._drain_fills(): + if f["token"] == tok: # the fill from this copy + self._record_lag(wallet, t, f) + + def settle_resolved(self): + """Free capital like the dashboard: when an open position's market has + resolved, settle it at the winner price (1/0), recycle the cash, and tally + realized P&L. This is the resolution path the engine's sell-only mirror + lacks — without it the $1k never recycles for held-to-resolution bets.""" + with self.lock: + mp = self.engine.state["my_pos"] + for token in list(mp): + cond = self.conds.get(token) + if not cond: + continue + wp = resolution_price(token, cond, mp[token].get("outcome")) + if wp is None: + continue # not resolved yet + pos = mp[token] + # gap 2 — LIVE: redeem winning shares on-chain so the freed USDC is + # actually back in the wallet (paper just recycles a number). Losers + # are worth $0, no redeem. If the redeem fails, keep the position and + # retry next pass rather than free a slot we haven't cashed out. + if self.redeemer and wp >= 0.5: + neg = market_neg_risk(cond) + if neg and cond not in self.negrisk_warned: + self.negrisk_warned.add(cond) + self.engine.alert(f"⚠ {pos.get('title','?')[:42]} is a NEG-RISK " + f"market — auto-redeem unsupported; redeem " + f"manually in the Polymarket UI.") + if not neg: + ok, info = self.redeemer.try_redeem(cond) + if not ok: + log(f" ⚠ redeem failed ({info}) — keeping position, will retry") + continue + log(f" ↳ redeemed on-chain: {info}") + proceeds = pos["shares"] * wp + pnl = proceeds - pos["cost"] + self.engine.state["cash"] += proceeds # recycle freed capital + b = self.engine.state.get("bets", {}).get(token) + if b: + b.update(status=("won" if wp >= 0.5 else "lost"), + exit_price=wp, pnl=round(pnl, 2), settled=int(time.time())) + del mp[token] + self.conds.pop(token, None) + tag = "WON ✅" if wp >= 0.5 else "LOST ❌" + label = f"{pos.get('outcome','?')} · {pos.get('title','?')[:42]}" + self.engine.alert( + f"SETTLE {label} — {tag} {pos['shares']:.0f}sh -> " + f"${proceeds:.2f} (P&L ${pnl:+.2f})", + discord_text=(f"🏁 **SETTLE** {tag}\n{label}\n" + f"${pos['cost']:.2f} cost -> ${proceeds:.2f} " + f"= **${pnl:+.2f}**")) + self.engine.persist() + + +# ── Alchemy webhook plumbing ──────────────────────────────────────────────── + +def verify(raw, sig, signing_key): + if not signing_key: + return True # verification off if unconfigured + digest = hmac.new(signing_key.encode(), raw, hashlib.sha256).hexdigest() + return hmac.compare_digest(digest, sig or "") + + +def addresses_in_payload(payload, watched): + out = set() + for a in payload.get("event", {}).get("activity", []): + for k in ("fromAddress", "toAddress"): + v = a.get(k) + if v and v.lower() in watched: + out.add(v.lower()) + return out + + +def make_handler(bot, signing_key): + watched = {w.lower() for w in bot.cfg.get("watchlist", [])} + + class Handler(BaseHTTPRequestHandler): + def _send(self, code, body="ok"): + self.send_response(code) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(body.encode()) + + def do_GET(self): + self._send(200, "alive" if self.path == "/health" else "copybot") + + def do_POST(self): + raw = self.rfile.read(int(self.headers.get("Content-Length", 0))) + if not verify(raw, self.headers.get("x-alchemy-signature"), signing_key): + log("⚠ bad signature — rejected") + return self._send(401, "bad signature") + self._send(200) # ack fast; Alchemy retries non-2xx + try: + bot.settle_resolved() # recycle any newly-resolved positions + payload = json.loads(raw or b"{}") + for w in addresses_in_payload(payload, watched): + bot.on_wallet_activity(w) + bot.write_feed() # refresh + publish the dashboard feed + bot.publish_feed() + except Exception as e: + log(f"handler error: {e}") + + def log_message(self, *a): + pass + + return Handler + + +# ── config / cli ──────────────────────────────────────────────────────────── + +def load_cfg(path): + if not os.path.exists(path): + sys.exit(f"No config at {path}.") + cfg = {**DEFAULT_CONFIG, **load_json(path, {})} + cfg["risk"] = {**DEFAULT_CONFIG["risk"], **cfg.get("risk", {})} + cfg["live"] = {**DEFAULT_CONFIG["live"], **cfg.get("live", {})} + cfg["follow"] = {**FOLLOW_DEFAULT, **cfg.get("follow", {})} + # accept either "watchlist" (addresses) or "watch" ([{wallet,name}]); fill the gap + if not cfg.get("watchlist") and cfg.get("watch"): + cfg["watchlist"] = [w["wallet"] for w in cfg["watch"]] + if not cfg.get("watch") and cfg.get("watchlist"): + cfg["watch"] = [{"wallet": w, "name": w[:10]} for w in cfg["watchlist"]] + return cfg + + +def conviction_floors_from_sharps(path): + """Per-wallet conviction floor = that wallet's avg bet size, so only their + above-average ('conviction') bets get copied — the edge per the research.""" + rows = load_json(path, []) + return {r["wallet"].lower(): float(r["avg_bet"]) + for r in rows if r.get("avg_bet")} + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--config", default="config.json") + ap.add_argument("--state", default="copybot_state.json") + ap.add_argument("--live", action="store_true", + help="enable live trading (also needs mode:live in config)") + ap.add_argument("--conviction-from-sharps", metavar="PATH", + help="set each wallet's conviction floor to its avg_bet from " + "this sharps json (e.g. live/watch_sharps.json)") + ap.add_argument("--test-wallet", metavar="0x...", + help="dry-run: route this wallet's latest trade through the " + "pipeline once (paper), print the decision, then exit") + ap.add_argument("--poll", type=int, metavar="SECONDS", + help="run forward by polling every SECONDS (instead of waiting " + "for Alchemy webhooks) — lets a paper run go without " + "deploying the webhook. Use < 600 so no trade is missed.") + ap.add_argument("--poll-once", action="store_true", + help="run ONE poll pass (settle, copy new trades, write feed) " + "then exit — for a scheduled runner (GitHub Actions cron). " + "State persists across runs via --state.") + args = ap.parse_args() + + cfg = load_cfg(args.config) + if args.conviction_from_sharps: + floors = conviction_floors_from_sharps(args.conviction_from_sharps) + cfg["follow"]["per_wallet_min_usd"] = { + **cfg["follow"].get("per_wallet_min_usd", {}), **floors} + log(f"loaded {len(floors)} per-wallet conviction floors from " + f"{args.conviction_from_sharps}") + + want_live = args.live and cfg.get("mode") == "live" + if args.live and cfg.get("mode") != "live": + sys.exit('--live given but config "mode" is not "live". Refusing to trade.') + + state = load_json(args.state, new_state()) + redeemer = None + if want_live: + confirm_live(cfg) + executor = LedgerLiveExecutor(cfg) # FOK marketable orders + fill recording + if cfg.get("live", {}).get("auto_redeem", True): + try: + from redeem import Redeemer + redeemer = Redeemer(cfg) + log("on-chain auto-redeem ENABLED (resolved winners redeemed to USDC)") + except Exception as e: + log(f"⚠ auto-redeem unavailable ({e}) — resolved winners must be " + f"redeemed manually in the UI. (pip install web3, set live.private_key)") + else: + executor = LedgerPaperExecutor() # tracks cash flows for realized-P&L reporting + + engine = CopyTrader(cfg, state, executor, args.state) + filt = FollowFilter(cfg) + bot = Copybot(cfg, engine, filt, redeemer=redeemer) + + mode = "LIVE — REAL MONEY" if executor.live else "PAPER (no orders placed)" + log(f"copybot · mode: {mode}") + log(f"watching {len(cfg.get('watchlist', []))} wallets · {filt.describe()}") + log(f"bankroll ${cfg['bankroll_usd']:.0f} @ {cfg['bankroll_pct']:.1%}/entry · " + f"guard {cfg['price_guard_pct']:.0%} · " + f"caps: ${cfg['risk']['max_trade_usd']:.0f}/trade, " + f"${cfg['risk']['daily_spend_cap_usd']:.0f}/day, " + f"${cfg['risk']['max_total_exposure_usd']:.0f} exposure") + bot.seed() + + # one-shot pipeline test: no server, just push a wallet's latest trade through + if args.test_wallet: + log(f"--test-wallet: routing {args.test_wallet[:10]}…'s latest activity " + f"through filter + engine (paper)") + bot.on_wallet_activity(args.test_wallet, ignore_stale=True) + log("test done.") + return + + # single-pass mode for a scheduled runner (GitHub Actions cron). State persists + # across runs via --state, so the $1k book carries forward run to run. On the + # very first run there's no baseline → mark history seen and copy nothing, so we + # only copy trades that happen after the test starts. + if args.poll_once: + if not bot.engine.state.get("baselined"): + bot.baseline() + bot.engine.state["baselined"] = True + bot.write_feed() + bot.engine.persist() + log("first run — baselined history; published online feed, copied nothing") + return + bot.settle_resolved() + for w in cfg.get("watchlist", []): + bot.on_wallet_activity(w) + bot.summary(0) + bot.write_feed() + bot.engine.persist() + log("poll-once complete") + return + + # forward poll mode: run the same filter+engine pipeline by polling, so a paper + # run works today without the deployed Alchemy webhook. (Production push uses the + # webhook below; behaviour through the filter+engine is identical either way.) + if args.poll: + if bot.webhook: + post_discord(bot.webhook, + f"✅ **Copybot paper run started** ({mode}, poll {args.poll}s)\n" + f"{len(cfg.get('watchlist', []))} wallets · {filt.describe()}\n" + f"${cfg['bankroll_usd']:.0f} book · ${cfg['bankroll_usd']*cfg['bankroll_pct']:.0f}/bet") + bot.baseline() + log(f"poll mode · every {args.poll}s · Ctrl-C to stop") + bot.write_feed() # publish an initial "online" snapshot + bot.publish_feed() + cycle = 0 + try: + while True: + bot.settle_resolved() # recycle capital at resolution + for w in cfg.get("watchlist", []): + bot.on_wallet_activity(w) + cycle += 1 + bot.summary(cycle) + bot.write_feed() # refresh the dashboard feed each cycle + bot.publish_feed() # push to GitHub (throttled, on change) + time.sleep(args.poll) + except KeyboardInterrupt: + log("stopped.") + return + + signing_key = (os.environ.get("ALCHEMY_SIGNING_KEY") + or cfg.get("alchemy_signing_key", "")) + port = int(os.environ.get("PORT", 8080)) + if bot.webhook: + post_discord(bot.webhook, + f"✅ **Copybot online** ({mode})\n" + f"push-driven · {len(cfg.get('watchlist', []))} wallets · " + f"{filt.describe()}\nYou'll get a ping on every trade it places.") + log(f"listening on :{port} · POST /alchemy · " + f"signature-verify {'ON' if signing_key else 'OFF'}") + ThreadingHTTPServer(("0.0.0.0", port), make_handler(bot, signing_key)).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/live/copybot.paper.json b/live/copybot.paper.json new file mode 100644 index 00000000..cfadb6f2 --- /dev/null +++ b/live/copybot.paper.json @@ -0,0 +1,52 @@ +{ + "mode": "paper", + "bankroll_usd": 1000.0, + "bankroll_pct": 0.05, + "price_guard_pct": 0.05, + "watchlist": [ + "0xa1a77ea9382bb8c3610f3303b66e093f644aace4", + "0x6d1a94f4bdd53114ec483925d025367db68697fb", + "0xe8ca3f758c93f44f3ec210542ab78afb7c0bcccb", + "0x41558102a796ba971c7567cad41c307e59f8fa41" + ], + "watch": [ + { + "wallet": "0xa1a77ea9382bb8c3610f3303b66e093f644aace4", + "name": "raid3r" + }, + { + "wallet": "0x6d1a94f4bdd53114ec483925d025367db68697fb", + "name": "0x6d1A94f4" + }, + { + "wallet": "0xe8ca3f758c93f44f3ec210542ab78afb7c0bcccb", + "name": "Kruto2027" + }, + { + "wallet": "0x41558102a796ba971c7567cad41c307e59f8fa41", + "name": "LSB1" + } + ], + "follow": { + "buy_only": true, + "min_their_usd": 25.0, + "per_wallet_min_usd": { + "0xa1a77ea9382bb8c3610f3303b66e093f644aace4": 33.98, + "0x6d1a94f4bdd53114ec483925d025367db68697fb": 3.0, + "0xe8ca3f758c93f44f3ec210542ab78afb7c0bcccb": 123.08, + "0x41558102a796ba971c7567cad41c307e59f8fa41": 231.0 + }, + "min_entry": 0.0, + "max_entry": 1.0 + }, + "risk": { + "max_trade_usd": 50.0, + "max_position_usd": 50.0, + "daily_spend_cap_usd": 1000000.0, + "max_total_exposure_usd": 1000000.0, + "max_open_positions": 1000, + "min_price": 0.01, + "max_price": 0.99, + "min_order_usd": 5.0 + } +} \ No newline at end of file diff --git a/live/copybot_live.json b/live/copybot_live.json new file mode 100644 index 00000000..5ffc8914 --- /dev/null +++ b/live/copybot_live.json @@ -0,0 +1,22 @@ +{ + "mode": "paper", + "bankroll": 1000.0, + "stake": 50.0, + "cash": 1000.0, + "deployed": 0, + "realized": 0.0, + "open_count": 0, + "lag": { + "n": 0, + "avg_s": null, + "avg_slip_pct": null + }, + "wallets": [ + "raid3r", + "0x6d1A94f4", + "Kruto2027", + "LSB1" + ], + "bets": [], + "updated": 1782410619 +} \ No newline at end of file diff --git a/live/sync_floors.py b/live/sync_floors.py new file mode 100644 index 00000000..7a444509 --- /dev/null +++ b/live/sync_floors.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Recompute the copy bot's per-wallet p80 conviction floors from the cache. + +Keeps config.json's `follow.per_wallet_min_usd` in exact parity with the +dashboard's "top 20% by stake" gate as the watched wallets keep trading — using +the same cache.conv_cutoff() (p80) over each wallet's own bet sizes that the +pipeline and trading/index.html use. Only the *floors* are rewritten; the +curated watchlist itself is never touched. + +Reads the wallet set from config.json's "watchlist" (falls back to "watch"), so +if you re-curate the portfolio the floors follow automatically. config.json is +gitignored, so this stays local and is never committed. + + python3 sync_floors.py # run standalone; also wired into daily.sh +""" + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import cache # noqa: E402 — local bet cache + conv_cutoff (p80) + +HERE = os.path.dirname(os.path.abspath(__file__)) +CFG = os.path.join(HERE, "..", "config.json") + + +def main(): + if not os.path.exists(CFG): + print("[floors] no ../config.json — nothing to do") + return + cfg = json.load(open(CFG)) + wallets = cfg.get("watchlist") or [w["wallet"] for w in cfg.get("watch", [])] + if not wallets: + print("[floors] config has no watchlist — nothing to do") + return + + floors = {} + for w in wallets: + sizes = [b["size"] for b in cache.get_bets(w) if b.get("size")] + p80 = cache.conv_cutoff(sizes) # the dashboard's top-20% threshold + if p80 != float("inf"): # skip wallets with no sized bets + floors[w.lower()] = round(p80, 2) + + cfg.setdefault("follow", {})["per_wallet_min_usd"] = floors + tmp = CFG + ".tmp" + json.dump(cfg, open(tmp, "w"), indent=2) + os.replace(tmp, CFG) # atomic write + print(f"[floors] {len(floors)} p80 conviction floors updated: " + + ", ".join(f"{w[:8]}…=${v:g}" for w, v in floors.items())) + + +if __name__ == "__main__": + main() diff --git a/redeem.py b/redeem.py new file mode 100644 index 00000000..479a5fe1 --- /dev/null +++ b/redeem.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""On-chain redemption of resolved Polymarket positions (live mode, gap 2). + +After a market resolves, winning conditional-token shares are still ERC-1155 +tokens — you must REDEEM them through Gnosis CTF (ConditionalTokens) to turn them +back into USDC. The CLOB API doesn't do this; copybot calls this module so a +resolved winner's freed capital is actually back in the wallet (in paper mode the +recycle is just a number; live needs the real redemption). + +Covers standard binary markets via CTF.redeemPositions. NEG-RISK markets settle +through a different adapter and are NOT handled here — copybot warns and you redeem +those in the Polymarket UI. (Neg-risk auto-redeem is a clean follow-up.) + +Requires: pip install web3 and a Polygon RPC. RPC comes from config +live.rpc_url, else is built from your Alchemy key. Each redeem costs a little POL +(MATIC) in gas — fund the EOA with a few POL. + +UNTESTED against live until you run it on a small position — verify one redemption +manually before trusting the loop: + python3 redeem.py # redeem one resolved market, print tx hash +""" + +import json +import os +import sys + +# Polygon mainnet +CHAIN_ID = 137 +CTF_ADDRESS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" # Gnosis ConditionalTokens +USDC_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" # USDC.e (collateral) +ZERO32 = b"\x00" * 32 # parentCollectionId + +CTF_ABI = json.loads("""[ + {"constant": false, + "inputs": [ + {"name": "collateralToken", "type": "address"}, + {"name": "parentCollectionId", "type": "bytes32"}, + {"name": "conditionId", "type": "bytes32"}, + {"name": "indexSets", "type": "uint256[]"}], + "name": "redeemPositions", + "outputs": [], "stateMutability": "nonpayable", "type": "function"} +]""") + + +def _rpc(cfg): + url = cfg.get("live", {}).get("rpc_url") + if url: + return url + key = cfg.get("alchemy_key") + if key: + return f"https://polygon-mainnet.g.alchemy.com/v2/{key}" + raise RuntimeError("no Polygon RPC — set live.rpc_url or alchemy_key in config") + + +class Redeemer: + def __init__(self, cfg): + from web3 import Web3 # imported lazily (live-only dep) + self.Web3 = Web3 + pk = cfg.get("live", {}).get("private_key") + if not pk: + raise RuntimeError("live.private_key required to redeem") + self.w3 = Web3(Web3.HTTPProvider(_rpc(cfg))) + if not self.w3.is_connected(): + raise RuntimeError("Polygon RPC not reachable") + self.acct = self.w3.eth.account.from_key(pk) + self.address = self.acct.address + self.ctf = self.w3.eth.contract( + address=Web3.to_checksum_address(CTF_ADDRESS), abi=CTF_ABI) + self.usdc = Web3.to_checksum_address(USDC_ADDRESS) + + @staticmethod + def _cond_bytes(condition_id): + h = condition_id[2:] if condition_id.startswith("0x") else condition_id + return bytes.fromhex(h) + + def try_redeem(self, condition_id, index_sets=(1, 2)): + """Redeem all of this wallet's holdings in a resolved binary market. + Returns (ok, tx_hash_or_reason). index_sets [1,2] covers both outcome + slots — the winning one pays USDC, the losing one is a no-op.""" + try: + fn = self.ctf.functions.redeemPositions( + self.usdc, ZERO32, self._cond_bytes(condition_id), list(index_sets)) + tx = fn.build_transaction({ + "from": self.address, + "nonce": self.w3.eth.get_transaction_count(self.address), + "chainId": CHAIN_ID, + "gasPrice": int(self.w3.eth.gas_price * 1.25), + }) + signed = self.acct.sign_transaction(tx) + raw = getattr(signed, "raw_transaction", None) or signed.rawTransaction + h = self.w3.eth.send_raw_transaction(raw) + rcpt = self.w3.eth.wait_for_transaction_receipt(h, timeout=180) + hx = self.w3.to_hex(h) + return (True, hx) if rcpt.status == 1 else (False, f"reverted {hx}") + except Exception as e: + return False, str(e) + + +def main(): + if len(sys.argv) < 2: + sys.exit("usage: python3 redeem.py ") + cfg = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), + "config.json"))) + r = Redeemer(cfg) + print(f"redeeming {sys.argv[1]} from {r.address} …") + ok, info = r.try_redeem(sys.argv[1]) + print(("✅ " if ok else "❌ ") + info) + + +if __name__ == "__main__": + main() diff --git a/run_copybot.sh b/run_copybot.sh new file mode 100755 index 00000000..8078dc3b --- /dev/null +++ b/run_copybot.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Run the copy bot continuously for the 24/7 paper test. +# +# * caffeinate -i keeps the Mac from idle-sleeping while the bot runs (it will +# still stop if the lid is closed on battery, or the Mac is shut down / offline +# — for true machine-independent 24/7, deploy the webhook receiver to a cloud +# host instead; see the notes in copybot.py). +# * --poll 60 checks the four wallets every 60s (well under the 600s freshness +# window, so no trade is missed; their leads are ≥3h so this is plenty fast). +# +# Used by the launchd agent (com.jaxperro.copybot.plist) which restarts it on crash. +# Logs to copybot.log. +cd "$(dirname "$0")" +exec caffeinate -i python3 copybot.py --poll 60 >> copybot.log 2>&1