copybot: compact follow config + auto floors + one-command deploy
config format matches backtest.json — one entry per wallet:
wallets: [{wallet, name, class: volume|whale, floor?}]
normalize_follow_config expands it into the legacy watchlist/watch/
floors/classes structures (old-format configs like config.live.json
pass through untouched). Volume wallets without an explicit floor get
an auto p80 derived from the data-api at boot (the worker has no cache),
logged and published in the feed. live/deploy_bot.sh = validate ->
preview -> commit -> push -> railway redeploy -> confirm boot.
Promotion workflow: prove a wallet in backtest.json, copy the same
entry into copybot.paper.json, run deploy_bot.sh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+67
@@ -55,6 +55,7 @@ from copytrade import ( # noqa: E402
|
||||
CopyTrader, PaperExecutor, LiveExecutor, DEFAULT_CONFIG,
|
||||
load_json, save_json, new_state, recent_trades, confirm_live,
|
||||
)
|
||||
import smart_money as sm # noqa: E402
|
||||
from smart_money import SSL_CTX # noqa: E402
|
||||
|
||||
CLOB_API = "https://clob.polymarket.com"
|
||||
@@ -441,6 +442,8 @@ class Copybot:
|
||||
"wallets": [w.get("name", w["wallet"][:10]) for w in self.cfg.get("watch", [])],
|
||||
"classes": {w.get("name", w["wallet"][:10]): self.engine.wallet_class(w["wallet"])
|
||||
for w in self.cfg.get("watch", [])},
|
||||
"floors": {self.names.get(a, a[:10]): v
|
||||
for a, v in self.filt.per_wallet.items()},
|
||||
"bets": sorted(bets.values(),
|
||||
key=lambda b: b.get("settled") or b.get("opened") or 0,
|
||||
reverse=True)[:100],
|
||||
@@ -703,6 +706,69 @@ def make_handler(bot, signing_key):
|
||||
|
||||
# ── config / cli ────────────────────────────────────────────────────────────
|
||||
|
||||
def _pctl80(vals):
|
||||
s = sorted(v for v in vals if v and v > 0)
|
||||
if not s:
|
||||
return None
|
||||
k = (len(s) - 1) * 0.8
|
||||
f = int(k)
|
||||
return s[f] if f + 1 >= len(s) else s[f] + (s[f + 1] - s[f]) * (k - f)
|
||||
|
||||
|
||||
def derive_floor(wallet):
|
||||
"""Cacheless p80 conviction floor: the 80th percentile of the wallet's
|
||||
recent position stakes from the data-api (last 500 closed + current open).
|
||||
The Railway worker has no cache.duckdb, so this is how an auto floor gets
|
||||
computed at boot; it refreshes on every restart."""
|
||||
sizes = []
|
||||
for p in (sm.get_json("/closed-positions", {"user": wallet, "limit": 500,
|
||||
"sortBy": "TIMESTAMP", "sortDirection": "DESC"}) or []) + (sm.get_json("/positions", {"user": wallet, "limit": 500,
|
||||
"sizeThreshold": 0}) or []):
|
||||
v = p.get("initialValue") or 0
|
||||
if v > 0:
|
||||
sizes.append(v)
|
||||
return _pctl80(sizes)
|
||||
|
||||
|
||||
def normalize_follow_config(cfg):
|
||||
"""Expand the compact follow format into the legacy structures the filter
|
||||
and engine read. One entry per wallet:
|
||||
|
||||
"wallets": [{"wallet": "0x…", "name": "…", "class": "volume"|"whale",
|
||||
"floor": 123.0 (optional)}]
|
||||
|
||||
-> watchlist / watch / follow.wallet_class / follow.per_wallet_min_usd.
|
||||
Old-format configs (no "wallets" key, e.g. config.live.json) pass through
|
||||
untouched. Floors: explicit "floor" wins; whales need none (follow-all);
|
||||
otherwise derived from the data-api at boot (see derive_floor)."""
|
||||
ws = cfg.get("wallets")
|
||||
if not ws:
|
||||
return cfg
|
||||
cfg["watchlist"] = [w["wallet"] for w in ws]
|
||||
cfg["watch"] = [{"wallet": w["wallet"], "name": w.get("name", w["wallet"][:10])}
|
||||
for w in ws]
|
||||
f = cfg.setdefault("follow", {})
|
||||
f["wallet_class"] = {w["wallet"].lower(): w.get("class", "volume") for w in ws}
|
||||
floors = {}
|
||||
for w in ws:
|
||||
addr = w["wallet"].lower()
|
||||
name = w.get("name", addr[:10])
|
||||
if w.get("class", "volume") == "whale":
|
||||
continue # follow-all: floors ignored
|
||||
if w.get("floor") is not None:
|
||||
floors[addr] = float(w["floor"])
|
||||
else:
|
||||
fl = derive_floor(w["wallet"])
|
||||
if fl is not None:
|
||||
floors[addr] = round(fl, 2)
|
||||
log(f"floor[{name}] auto p80 = ${fl:,.0f}")
|
||||
else:
|
||||
log(f"⚠ floor[{name}] underivable — falls back to the global "
|
||||
f"min_their_usd ${f.get('min_their_usd', 0):,.0f}")
|
||||
f["per_wallet_min_usd"] = floors
|
||||
return cfg
|
||||
|
||||
|
||||
def load_cfg(path):
|
||||
if not os.path.exists(path):
|
||||
sys.exit(f"No config at {path}.")
|
||||
@@ -710,6 +776,7 @@ def load_cfg(path):
|
||||
cfg["risk"] = {**DEFAULT_CONFIG["risk"], **cfg.get("risk", {})}
|
||||
cfg["live"] = {**DEFAULT_CONFIG["live"], **cfg.get("live", {})}
|
||||
cfg["follow"] = {**FOLLOW_DEFAULT, **cfg.get("follow", {})}
|
||||
normalize_follow_config(cfg)
|
||||
# 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"]]
|
||||
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# One-command deploy of the paper copybot's follow set.
|
||||
#
|
||||
# 1. edit live/copybot.paper.json — add/remove/reclass entries in "wallets":
|
||||
# {"wallet": "0x…", "name": "…", "class": "volume"|"whale", "floor": 123}
|
||||
# ("floor" optional: volume wallets without one get an auto p80 at boot;
|
||||
# whales ignore floors — they're followed on every trade)
|
||||
# 2. ./live/deploy_bot.sh
|
||||
#
|
||||
# Validates the config (a malformed JSON = crash-looping container), previews
|
||||
# the follow set, commits+pushes just the config, redeploys the Railway worker,
|
||||
# and waits for the fresh boot banner.
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
python3 - <<'PY'
|
||||
import json, sys
|
||||
c = json.load(open("copybot.paper.json"))
|
||||
ws = c.get("wallets") or []
|
||||
if not ws:
|
||||
sys.exit("copybot.paper.json has no wallets[] — nothing to follow")
|
||||
seen = set()
|
||||
for w in ws:
|
||||
a = w.get("wallet", "")
|
||||
assert a.startswith("0x") and len(a) == 42, f"bad address: {w}"
|
||||
assert a.lower() not in seen, f"duplicate wallet: {a}"
|
||||
seen.add(a.lower())
|
||||
assert w.get("class", "volume") in ("volume", "whale"), f"bad class: {w}"
|
||||
if w.get("floor") is not None:
|
||||
assert float(w["floor"]) >= 0, f"bad floor: {w}"
|
||||
pct = (c.get("follow") or {}).get("class_pct") or {}
|
||||
print(f"config OK · {len(ws)} wallets:")
|
||||
for w in ws:
|
||||
cls = w.get("class", "volume")
|
||||
fl = ("follow-all" if cls == "whale"
|
||||
else f"floor ${w['floor']:,.0f}" if w.get("floor") is not None
|
||||
else "floor auto (p80 at boot)")
|
||||
print(f" {w.get('name', w['wallet'][:10]):<18} {cls:<7} "
|
||||
f"{pct.get(cls, 0.04)*100:.0f}%/bet · {fl}")
|
||||
PY
|
||||
|
||||
git add copybot.paper.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "no config changes — redeploying anyway (picks up current main)"
|
||||
else
|
||||
git commit -m "copybot: follow-set update (deploy_bot.sh)"
|
||||
git pull --rebase --autostash -q
|
||||
git push -q
|
||||
echo "pushed."
|
||||
fi
|
||||
|
||||
railway redeploy --service copybot --yes
|
||||
echo "redeploy triggered — waiting for the new container…"
|
||||
for i in $(seq 1 15); do
|
||||
sleep 20
|
||||
tail5=$(railway logs --service copybot 2>/dev/null | tail -5)
|
||||
if echo "$tail5" | grep -qE "\[[123]\] "; then
|
||||
railway logs --service copybot 2>/dev/null | grep -E "watching|floor\[" | tail -12
|
||||
echo "$tail5" | tail -1
|
||||
echo "✅ bot rebooted with the new follow set"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
echo "⚠ no fresh boot banner within 5min — check: railway logs --service copybot"
|
||||
exit 1
|
||||
Reference in New Issue
Block a user