July live test: follow Kruto/shisan/fortuneking/LSB1; fee+lag backtest; 24/7 host runner

- portfolio.py: follow set -> the fee-aware copy-positive sharps (Kruto2027,
  shisan888, fortuneking) + LSB1; entries pay the 0.03 sports taker fee and a
  +0.5%/~90s lag haircut (entry_model); backfilled from June 1; feed exposes
  fee_rate/slip/lag_est_s/fees_paid and unreal uses true cost basis.
- copybot.paper.json: same four wallets, p80 conviction floors from the cache
  (Kruto $123.08, shisan $704.33, fortuneking $970, LSB1 $231). Fresh $1k state
  committed separately by the bot - clean July book.
- host/start.sh + nixpacks: turnkey 24/7 worker for Railway/Fly/VPS - clones
  with GITHUB_TOKEN, resumes the committed state, polls 60s, publishes
  state+feed+fills back through publish_feed. Replaces the Mac poller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jaxperro
2026-07-02 09:51:35 -04:00
parent 1630d94aba
commit f18162058d
4 changed files with 120 additions and 44 deletions
Executable
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# 24/7 copybot runner for an always-on host (Railway worker / Fly.io / any $5 VPS).
#
# Why a fresh clone: cloud build images usually ship the source WITHOUT .git, but
# the bot persists its book by committing state + feed + fills back to GitHub
# (copybot.publish_feed). Cloning at boot gives it a real repo, and the last
# committed copybot_state.json — so the $1k book survives restarts and redeploys
# with no volume attached.
#
# Required env:
# GITHUB_TOKEN fine-grained PAT with Contents: read+write on
# jaxperro/winning-wallet-finder (the push credential)
# Optional env:
# DISCORD_WEBHOOK ping on every placement
# POLL_SECONDS poll cadence (default 60 — keep well under the bot's
# 600s stale window so no trade is skipped)
#
# Railway setup (one time):
# 1. New service -> Deploy from GitHub repo -> jaxperro/winning-wallet-finder
# 2. Settings -> Deploy -> Custom Start Command: bash host/start.sh
# 3. Variables -> add GITHUB_TOKEN (and DISCORD_WEBHOOK if wanted)
# 4. No public networking needed (poll mode makes outbound calls only)
# Then STOP the Mac poller so the book has one writer:
# launchctl unload ~/Library/LaunchAgents/com.jaxperro.copybot.plist
set -euo pipefail
: "${GITHUB_TOKEN:?set GITHUB_TOKEN (PAT with repo contents read+write)}"
REPO_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/jaxperro/winning-wallet-finder.git"
DIR="${COPYBOT_DIR:-/tmp/wwf}"
rm -rf "$DIR"
git clone --depth 20 "$REPO_URL" "$DIR"
cd "$DIR"
git config user.name "copybot[bot]"
git config user.email "copybot@users.noreply.github.com"
# paper config is committed (no secrets); state resumes from the last commit
exec python3 copybot.py \
--config live/copybot.paper.json \
--state copybot_state.json \
--poll "${POLL_SECONDS:-60}"
+12 -12
View File
@@ -4,24 +4,24 @@
"bankroll_pct": 0.05,
"price_guard_pct": 0.05,
"watchlist": [
"0xa1a77ea9382bb8c3610f3303b66e093f644aace4",
"0x6d1a94f4bdd53114ec483925d025367db68697fb",
"0xe8ca3f758c93f44f3ec210542ab78afb7c0bcccb",
"0xf3488e52ac2d7f0628b04481db5a5b0446f0e543",
"0x86c878cde72660ec52f5e6f0f0438b76de8fc867",
"0x41558102a796ba971c7567cad41c307e59f8fa41"
],
"watch": [
{
"wallet": "0xa1a77ea9382bb8c3610f3303b66e093f644aace4",
"name": "raid3r"
},
{
"wallet": "0x6d1a94f4bdd53114ec483925d025367db68697fb",
"name": "0x6d1A94f4"
},
{
"wallet": "0xe8ca3f758c93f44f3ec210542ab78afb7c0bcccb",
"name": "Kruto2027"
},
{
"wallet": "0xf3488e52ac2d7f0628b04481db5a5b0446f0e543",
"name": "shisan888"
},
{
"wallet": "0x86c878cde72660ec52f5e6f0f0438b76de8fc867",
"name": "fortuneking"
},
{
"wallet": "0x41558102a796ba971c7567cad41c307e59f8fa41",
"name": "LSB1"
@@ -31,9 +31,9 @@
"buy_only": true,
"min_their_usd": 25.0,
"per_wallet_min_usd": {
"0xa1a77ea9382bb8c3610f3303b66e093f644aace4": 33.98,
"0x6d1a94f4bdd53114ec483925d025367db68697fb": 3.0,
"0xe8ca3f758c93f44f3ec210542ab78afb7c0bcccb": 123.08,
"0xf3488e52ac2d7f0628b04481db5a5b0446f0e543": 704.33,
"0x86c878cde72660ec52f5e6f0f0438b76de8fc867": 970.0,
"0x41558102a796ba971c7567cad41c307e59f8fa41": 231.0
},
"min_entry": 0.0,
+60 -30
View File
@@ -11,8 +11,11 @@ which the dashboard reads in one request.
Model: a $1,000 account that mirrors each followed wallet's CONVICTION bets (top-20%
stake) at a flat $50, held to resolution (the cache has no sell events, which is the
right model for the hold-to-resolution wallets we follow). One position per market
(first wallet to enter wins the slot); when capital is fully deployed a bet is MISSED.
right model for the hold-to-resolution wallets we follow). Entries pay the Polymarket
taker fee and a lag-slippage price haircut (see FEE_RATE / SLIP / LAG_EST_S) so the
book models what a real copier nets, not the idealized zero-cost mirror. One position
per market (first wallet to enter wins the slot); when capital is fully deployed a bet
is MISSED.
Resolved history + realized P&L come from the cache; currently-open bets come from a
small live /positions pull so the page can still show what's in flight.
"""
@@ -30,17 +33,37 @@ _SSL = ssl._create_unverified_context()
HERE = os.path.dirname(__file__)
BANK = 1000.0
STAKE = 50.0
START = time.mktime(time.strptime("2026-06-23", "%Y-%m-%d")) # forward test: started following 2026-06-23
START = time.mktime(time.strptime("2026-06-01", "%Y-%m-%d")) # backfilled: replay from June 1
GAMMA = "https://gamma-api.polymarket.com"
# ---- realism model (matches the live copybot) -------------------------------
# Taker fee (Polymarket V2, since 2026-03-30): fee = shares·rate·p·(1p); for a
# flat-$STAKE buy that's STAKE·rate·(1p). Sports 0.03 — the follow set's
# category. Redeeming at resolution is fee-free, so only entries pay here
# (hold-to-resolution model, no mirrored exits).
FEE_RATE = 0.03
# Copy lag: we enter LAG_EST_S after the wallet does, at a slightly worse price.
# SLIP is the entry-price penalty estimate: the live bot measured +0.35% at ~5min
# lag; a 60s poller should see less — 0.5% is a conservative flat haircut.
LAG_EST_S = 90
SLIP = 0.005
# the followed wallets — single source of truth (dashboard renders names from the feed)
WALLETS = [
{"name": "raid3r", "wallet": "0xa1a77ea9382bb8c3610f3303b66e093f644aace4"},
{"name": "0x6d1A94f4", "wallet": "0x6d1a94f4bdd53114ec483925d025367db68697fb"},
{"name": "Kruto2027", "wallet": "0xe8ca3f758c93f44f3ec210542ab78afb7c0bcccb"},
{"name": "LSB1", "wallet": "0x41558102a796ba971c7567cad41c307e59f8fa41"},
{"name": "Kruto2027", "wallet": "0xe8ca3f758c93f44f3ec210542ab78afb7c0bcccb"},
{"name": "shisan888", "wallet": "0xf3488e52ac2d7f0628b04481db5a5b0446f0e543"},
{"name": "fortuneking", "wallet": "0x86c878cde72660ec52f5e6f0f0438b76de8fc867"},
{"name": "LSB1", "wallet": "0x41558102a796ba971c7567cad41c307e59f8fa41"},
]
def entry_model(p):
"""(effective entry price, entry fee, total cash cost) of a flat-$STAKE copy:
price worsened by the lag-slippage haircut, taker fee on top of the stake."""
p_eff = min(0.999, p * (1 + SLIP))
fee = STAKE * FEE_RATE * (1 - p_eff)
return p_eff, fee, STAKE + fee
_MKT = {}
def market_meta(cond):
"""Market title for display, from the CLOB market endpoint (gamma's condition_ids
@@ -124,7 +147,8 @@ def main():
cash = BANK
realized = 0.0
held = [] # (free_t, stake, payoff) payoff paid at free_t
fees_paid = 0.0
held = [] # (free_t, cost, payoff) cost = stake + entry fee; payoff paid at free_t
perW = {w["wallet"]: {"name": w["name"], "wallet": w["wallet"], "bets": 0,
"invested": 0.0, "realized": 0.0} for w in WALLETS}
resolved, current, missed = [], [], []
@@ -132,34 +156,37 @@ def main():
def free(upto):
nonlocal cash, realized
keep = []
for ft, stake, payoff, rec in held:
for ft, cost, payoff, rec in held:
if ft and ft <= upto and rec["kind"] == "res":
cash += payoff; realized += payoff - stake; perW[rec["wallet"]]["realized"] += payoff - stake
rec["pnl"] = payoff - stake
cash += payoff; realized += payoff - cost; perW[rec["wallet"]]["realized"] += payoff - cost
rec["pnl"] = payoff - cost
resolved.append(rec)
else:
keep.append((ft, stake, payoff, rec))
keep.append((ft, cost, payoff, rec))
held[:] = keep
for b in stream:
free(b["entry_t"])
if cash >= STAKE:
cash -= STAKE; perW[b["wallet"]]["bets"] += 1
shares = STAKE / b["p"]
p_eff, fee, cost = entry_model(b["p"])
if cash >= cost:
cash -= cost; fees_paid += fee; perW[b["wallet"]]["bets"] += 1
shares = STAKE / p_eff # lag-adjusted entry price
if b["kind"] == "res":
payoff = shares * (1.0 if b["won"] else 0.0)
held.append((b["res_t"] or now, STAKE, payoff, b))
payoff = shares * (1.0 if b["won"] else 0.0) # redeem is fee-free
held.append((b["res_t"] or now, cost, payoff, b))
else: # currently open -> mark to market, no free yet
held.append((None, STAKE, 0.0, b))
held.append((None, cost, 0.0, b))
b["val"] = shares * b["cur"]; b["stake"] = STAKE
else:
missed.append(b)
free(now)
# finalize open (still held with kind==open): mark to market
invested = 0.0
for ft, stake, payoff, rec in held:
open_cost = 0.0
for ft, cost, payoff, rec in held:
if rec["kind"] == "open":
invested += rec["val"]; rec["pnl"] = rec["val"] - stake
invested += rec["val"]; rec["pnl"] = rec["val"] - cost
open_cost += cost
perW[rec["wallet"]]["invested"] += rec["val"]
current.append(rec)
@@ -167,15 +194,16 @@ def main():
resolved.sort(key=lambda r: r.get("res_t") or 0, reverse=True)
for r in resolved[:60]:
m = market_meta(r["cond"]); r["title"] = m["title"]
# hypothetical P&L had we been able to afford it: resolved bets at their
# outcome, still-open bets marked to the current price. Missed bets can be
# kind=="open" (no "won"/"res_t" keys) — indexing m["won"] here used to
# KeyError and kill the whole portfolio step the first time capital ran out
# while a followed wallet had a live position.
# hypothetical P&L had we been able to afford it — same fee + lag model as the
# placed bets: resolved bets at their outcome, still-open bets marked to the
# current price. Missed bets can be kind=="open" (no "won"/"res_t" keys) —
# indexing m["won"] here used to KeyError and kill the whole portfolio step
# the first time capital ran out while a followed wallet had a live position.
def hypo_pnl(m):
p_eff, fee, cost = entry_model(m["p"])
if "won" in m:
return STAKE * ((1.0 / m["p"]) - 1) if m["won"] else -STAKE
return STAKE * (m.get("cur", m["p"]) / m["p"] - 1)
return (STAKE / p_eff) - cost if m["won"] else -cost
return STAKE * (m.get("cur", p_eff) / p_eff) - cost
missed.sort(key=lambda m: m.get("res_t") or 0, reverse=True)
for m in missed[:60]:
@@ -192,9 +220,11 @@ def main():
out = {
"started": START, "updated": now,
"bank": BANK, "stake": STAKE,
"fee_rate": FEE_RATE, "slip": SLIP, "lag_est_s": LAG_EST_S,
"fees_paid": round(fees_paid, 2),
"equity": round(equity, 2), "liquid": round(cash, 2), "invested": round(invested, 2),
"realized": round(realized, 2), "pnl": round(equity - BANK, 2),
"unreal": round(invested - STAKE * len(current), 2),
"unreal": round(invested - open_cost, 2),
"resolved_count": len(resolved), "wins": wins, "losses": len(resolved) - wins,
"open_count": len(current), "missed_count": len(missed),
"wallets": [{"name": v["name"], "wallet": v["wallet"], "bets": v["bets"],
@@ -214,8 +244,8 @@ def main():
}
json.dump(out, open(os.path.join(HERE, "portfolio.json"), "w"), separators=(",", ":"))
print(f"portfolio: equity ${equity:,.0f} ({(equity-BANK)/BANK*100:+.0f}%) | realized ${realized:+,.0f} "
f"| {len(resolved)} resolved ({wins}W/{len(resolved)-wins}L) | {len(current)} open "
f"| {len(missed)} missed | -> portfolio.json", flush=True)
f"| fees ${fees_paid:,.0f} | {len(resolved)} resolved ({wins}W/{len(resolved)-wins}L) "
f"| {len(current)} open | {len(missed)} missed | -> portfolio.json", flush=True)
if __name__ == "__main__":
+7 -2
View File
@@ -1,6 +1,11 @@
# Explicit build config for Railway/Nixpacks (pure-stdlib Python service)
# Explicit build config for Railway/Nixpacks (pure-stdlib Python service).
# Default start = the Discord webhook receiver. For the 24/7 copybot worker,
# deploy a second service from this repo and override the start command with
# bash host/start.sh
# (see host/start.sh for the full setup). git is needed at runtime — the bot
# persists its book by committing state + feed back to GitHub.
[phases.setup]
nixPkgs = ["python311"]
nixPkgs = ["python311", "git"]
[start]
cmd = "python3 webhook_receiver.py"