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