dynamic sizing: 4% of equity per bet, drawdown brake, per-event correlation cap

- engine (copytrade): stake_usd() = bankroll_pct x current equity (cash + open
  cost basis) - Kelly-style compounding both directions - halved while equity
  sits below 80% of its high-water mark; new risk.max_per_event (default 2)
  blocks stacking correlated markets on one real-world event (dated-slug prefix
  grouping; LSB1 once put 6 conviction bets on a single match).
- copybot: feed/summary report the dynamic stake, stake_pct, event_cap, hwm.
- portfolio backtest mirrors the exact same rule (PCT 4%, clamp $5-$150,
  EVENT_CAP 2, brake 80%/half), with per-bet stakes in every table row and a
  persistent CLOB slug cache for event grouping. June backfill: +426% vs +168%
  flat - compounding amplifies the in-sample month; July live is the test.
  Misses fell 62 -> 19 cash-missed (+26 deliberate event-cap skips): smaller
  early stakes capture more signals.
- configs: bankroll_pct 0.04, max_trade/max_position 150 (runaway guards),
  max_per_event 2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jaxperro
2026-07-02 10:54:07 -04:00
parent 76c83a58da
commit 716daeccc1
6 changed files with 163 additions and 41 deletions
+1
View File
@@ -38,3 +38,4 @@ live/scored.json
live/*_scored.json
live/watch_prejune*.json
live/history/
live/slug_cache.json
+52 -6
View File
@@ -30,6 +30,7 @@ Usage
import argparse
import json
import os
import re
import sys
import time
import urllib.error
@@ -49,8 +50,10 @@ DEFAULT_CONFIG = {
"poll_seconds": 12, # how often to check each wallet
"discord_webhook": "", # paste a Discord webhook URL to get pings
"watchlist": [], # ["0xwallet1", "0xwallet2", ...]
"bankroll_usd": 1000.0, # your stake pool
"bankroll_pct": 0.02, # 2% of bankroll per new entry
"bankroll_usd": 1000.0, # starting stake pool
"bankroll_pct": 0.02, # fraction of CURRENT equity per new entry
# (compounds up and down; falls back to a flat
# fraction of bankroll_usd when cash isn't tracked)
"price_guard_pct": 0.05, # skip if price moved >5% from their fill
"risk": {
"max_trade_usd": 50.0, # hard ceiling on any single copy
@@ -58,6 +61,8 @@ DEFAULT_CONFIG = {
"daily_spend_cap_usd": 250.0,
"max_total_exposure_usd": 500.0,
"max_open_positions": 20,
"max_per_event": 2, # max concurrent positions on one real-world
# event (a game's markets are one correlated bet)
"min_price": 0.05, # don't open longshots/near-certainties
"max_price": 0.95,
"min_order_usd": 5.0, # Polymarket min order size
@@ -155,6 +160,16 @@ def recent_trades(wallet, limit=100):
{"user": wallet, "type": "TRADE", "limit": limit}) or []
def event_key(t):
"""Correlation-group id for a trade: the real-world event its market belongs
to. Polymarket sub-splits one game across several eventSlugs
(`…-2026-07-01-more-markets`, `…-2026-07-01-second-half-result`), so dated
slugs collapse to their `…-YYYY-MM-DD` prefix; undated slugs stand as-is."""
ev = t.get("eventSlug") or t.get("slug") or ""
m = re.match(r"(.*?\d{4}-\d{2}-\d{2})", ev)
return m.group(1) if m else (ev or None)
# ── execution ────────────────────────────────────────────────────────────────
class PaperExecutor:
@@ -243,6 +258,26 @@ class CopyTrader:
def open_exposure(self):
return sum(p["cost"] for p in self.state["my_pos"].values())
# ---- dynamic sizing: fraction of CURRENT equity, with a drawdown brake ----
DD_THRESHOLD = 0.80 # below 80% of the high-water mark…
DD_FACTOR = 0.5 # …bet half size until equity recovers
def stake_usd(self):
"""Next bet size = bankroll_pct × current equity (cash + open cost basis),
so stakes compound with the book in both directions; halved while in a
>20% drawdown from the high-water mark. Falls back to the flat static
stake when cash isn't tracked (legacy poll CLI)."""
cash = self.state.get("cash")
if cash is None:
return self.cfg["bankroll_usd"] * self.cfg["bankroll_pct"]
eq = cash + self.open_exposure()
hwm = max(self.state.get("hwm", 0.0), eq)
self.state["hwm"] = hwm
frac = self.cfg["bankroll_pct"]
if eq < self.DD_THRESHOLD * hwm:
frac *= self.DD_FACTOR
return frac * eq
def persist(self):
self.state["seen_tx"] = list(self.seen)[-5000:]
save_json(self.state_path, self.state)
@@ -295,7 +330,7 @@ class CopyTrader:
if side == "BUY":
self._handle_their_buy(wallet, token, their_size, their_price,
label, title, outcome)
label, title, outcome, event=event_key(t))
their_book[token] = their_prev + their_size
elif side == "SELL":
self._handle_their_sell(token, their_size, their_prev, label)
@@ -317,7 +352,7 @@ class CopyTrader:
return drift <= self.cfg["price_guard_pct"]
def _handle_their_buy(self, wallet, token, their_size, their_price,
label, title, outcome):
label, title, outcome, event=None):
mine = self.state["my_pos"].get(token)
is_add = mine is not None
# don't backfill: never open a position they already held when we
@@ -326,6 +361,17 @@ class CopyTrader:
if not is_add and token in self.state["seed_tokens"].get(wallet, []):
self.log(f"BUY {label} — skip (held before we started, no backfill)")
return
# correlation cap: a game's markets settle together — N bets on one event
# are one big bet, not N diversified ones (LSB1 once stacked 6 markets on
# a single match). Cap concurrent positions per real-world event.
cap = self.risk.get("max_per_event")
if not is_add and event and cap:
held = sum(1 for p in self.state["my_pos"].values()
if p.get("event") == event)
if held >= cap:
self.log(f"BUY {label} — skip (already {held} positions on this "
f"event, cap {cap})")
return
price = self._live_price(token, "buy")
if price is None:
@@ -343,7 +389,7 @@ class CopyTrader:
want_usd = want_shares * price
kind = "ADD "
else:
want_usd = self.cfg["bankroll_usd"] * self.cfg["bankroll_pct"]
want_usd = self.stake_usd() # fraction of current equity (compounds)
kind = "OPEN"
pos_cost = mine["cost"] if is_add else 0.0
@@ -364,7 +410,7 @@ class CopyTrader:
else:
self.state["my_pos"][token] = {
"shares": res["filled_shares"], "cost": spent,
"title": title, "outcome": outcome}
"title": title, "outcome": outcome, "event": event}
tag = "[PAPER]" if not self.ex.live else "[LIVE]"
self.alert(
f"{kind} {label}{tag} buy {res['filled_shares']:.1f} "
+5 -2
View File
@@ -377,7 +377,10 @@ class Copybot:
lag = st.get("lag", {})
feed = {
"mode": "live" if self.engine.ex.live else "paper",
"bankroll": bank, "stake": round(bank * self.cfg["bankroll_pct"], 2),
"bankroll": bank, "stake": round(self.engine.stake_usd(), 2),
"stake_pct": self.cfg["bankroll_pct"],
"event_cap": self.engine.risk.get("max_per_event"),
"hwm": round(st.get("hwm", 0.0), 2),
"cash": round(cash, 2), "deployed": round(exp, 2),
"realized": round(cash + exp - bank, 2), "open_count": len(mp),
"fees_paid": round(st.get("fees_paid", 0.0), 2),
@@ -468,7 +471,7 @@ class Copybot:
def summary(self, cycle):
bank = self.cfg["bankroll_usd"]
stake = bank * self.cfg["bankroll_pct"]
stake = self.engine.stake_usd() # dynamic: pct of current equity
exp = self.engine.open_exposure()
cash = self.engine.state.get("cash", bank)
realized = cash + exp - bank # see _drain_fills / settle_resolved
+5 -4
View File
@@ -1,7 +1,7 @@
{
"mode": "paper",
"bankroll_usd": 1000.0,
"bankroll_pct": 0.05,
"bankroll_pct": 0.04,
"price_guard_pct": 0.05,
"watchlist": [
"0xe8ca3f758c93f44f3ec210542ab78afb7c0bcccb",
@@ -40,13 +40,14 @@
"max_entry": 1.0
},
"risk": {
"max_trade_usd": 50.0,
"max_position_usd": 50.0,
"max_trade_usd": 150.0,
"max_position_usd": 150.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
"min_order_usd": 5.0,
"max_per_event": 2
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+99 -28
View File
@@ -10,20 +10,24 @@ RECYCLES correctly (cash frees at the true resolution moment). Output -> portfol
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). 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.
stake), held to resolution (the cache has no sell events, which is the right model for
the hold-to-resolution wallets we follow). Sizing is DYNAMIC — each bet stakes PCT of
current equity (Kelly-style compounding), halved in a >20% drawdown, capped at
EVENT_CAP concurrent bets per real-world event — and entries pay the Polymarket taker
fee plus a lag-slippage price haircut (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.
"""
import json
import os
import re
import ssl
import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor
import cache
import smart_money as sm
@@ -32,14 +36,25 @@ _SSL = ssl._create_unverified_context()
HERE = os.path.dirname(__file__)
BANK = 1000.0
STAKE = 50.0
START = time.mktime(time.strptime("2026-06-01", "%Y-%m-%d")) # backfilled: replay from June 1
GAMMA = "https://gamma-api.polymarket.com"
# ---- dynamic sizing (mirrors the live copybot) ------------------------------
# Each new bet stakes PCT of CURRENT equity (cash + open cost basis) so the book
# compounds in both directions; the stake is halved while equity sits below
# DD_THRESHOLD of its high-water mark, and clamped to [STAKE_MIN, STAKE_CAP].
# EVENT_CAP limits concurrent bets whose markets belong to the same real-world
# event — a game's markets settle together, so N bets on one match are one
# correlated bet, not N diversified ones.
PCT = 0.04
STAKE_MIN, STAKE_CAP = 5.0, 150.0
EVENT_CAP = 2
DD_THRESHOLD, DD_FACTOR = 0.80, 0.5
# ---- 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
# $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.
@@ -57,17 +72,26 @@ WALLETS = [
]
def entry_model(p):
"""(effective entry price, entry fee, total cash cost) of a flat-$STAKE copy:
def entry_model(p, stake):
"""(effective entry price, entry fee, total cash cost) of a $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
fee = stake * FEE_RATE * (1 - p_eff)
return p_eff, fee, stake + fee
_MKT = {}
_SLUG_CACHE = os.path.join(HERE, "slug_cache.json")
try:
_MKT.update(json.load(open(_SLUG_CACHE)))
except Exception:
pass
def market_meta(cond):
"""Market title for display, from the CLOB market endpoint (gamma's condition_ids
filter returns nothing for resolved markets) — cached."""
"""Market title + slug from the CLOB market endpoint (gamma's condition_ids
filter returns nothing for resolved markets) — cached in-process AND on disk
(slug_cache.json), since the event cap needs a slug for every replayed market,
not just the top-60 displayed."""
if cond not in _MKT:
try:
r = urllib.request.urlopen(urllib.request.Request(
@@ -76,10 +100,26 @@ def market_meta(cond):
m = json.loads(r.read())
_MKT[cond] = {"title": m.get("question") or "", "slug": m.get("market_slug") or ""}
except Exception:
_MKT[cond] = {"title": "", "slug": ""}
return {"title": "", "slug": ""} # transient failure — don't cache
return _MKT[cond]
def save_slug_cache():
try:
json.dump({c: v for c, v in _MKT.items() if v.get("slug")},
open(_SLUG_CACHE, "w"))
except Exception:
pass
def event_key(slug):
"""Correlation-group id: Polymarket sub-splits one game across slugs
(`…-2026-07-01-more-markets`, `…-2026-07-01-second-half-result`), so dated
slugs collapse to their `…-YYYY-MM-DD` prefix; undated slugs stand as-is."""
m = re.match(r"(.*?\d{4}-\d{2}-\d{2})", slug or "")
return m.group(1) if m else (slug or None)
def conviction_bets():
"""Every followed wallet's resolved conviction bets from the cache, with entry time."""
out = []
@@ -145,14 +185,29 @@ def main():
b["kind"] = "open"; by_mkt[b["cond"]] = b
stream = sorted(by_mkt.values(), key=lambda b: b["entry_t"])
# prefetch every replayed market's slug (threaded; disk-cached) so the
# event-correlation cap can group markets by real-world event
with ThreadPoolExecutor(max_workers=8) as ex:
list(ex.map(market_meta, {b["cond"] for b in stream}))
cash = BANK
realized = 0.0
fees_paid = 0.0
hwm = BANK
capped = 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 = [], [], []
def cur_stake():
"""PCT of current equity, drawdown-braked, clamped — the copybot's rule."""
nonlocal hwm
eq = cash + sum(c for _, c, _, _ in held)
hwm = max(hwm, eq)
frac = PCT * (DD_FACTOR if eq < DD_THRESHOLD * hwm else 1.0)
return max(STAKE_MIN, min(STAKE_CAP, frac * eq))
def free(upto):
nonlocal cash, realized
keep = []
@@ -167,16 +222,26 @@ def main():
for b in stream:
free(b["entry_t"])
p_eff, fee, cost = entry_model(b["p"])
stake = cur_stake()
b["stake"] = round(stake, 2)
b["event"] = event_key(market_meta(b["cond"])["slug"])
# correlation cap: skip a bet when we already hold EVENT_CAP positions on
# the same real-world event (deliberate risk skip, tallied separately)
if b["event"] and sum(1 for _, _, _, r in held
if r.get("event") == b["event"]) >= EVENT_CAP:
b["capped"] = True; capped += 1
missed.append(b)
continue
p_eff, fee, cost = entry_model(b["p"], stake)
if cash >= cost:
cash -= cost; fees_paid += fee; perW[b["wallet"]]["bets"] += 1
shares = STAKE / p_eff # lag-adjusted entry price
shares = stake / p_eff # lag-adjusted entry price
if b["kind"] == "res":
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, cost, 0.0, b))
b["val"] = shares * b["cur"]; b["stake"] = STAKE
b["val"] = shares * b["cur"]
else:
missed.append(b)
free(now)
@@ -200,10 +265,11 @@ def main():
# 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"])
stake = m.get("stake") or STAKE_MIN
p_eff, fee, cost = entry_model(m["p"], stake)
if "won" in m:
return (STAKE / p_eff) - cost if m["won"] else -cost
return STAKE * (m.get("cur", p_eff) / p_eff) - cost
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]:
@@ -219,7 +285,9 @@ def main():
equity = cash + invested
out = {
"started": START, "updated": now,
"bank": BANK, "stake": STAKE,
"bank": BANK, "stake": round(cur_stake(), 2), # the NEXT bet's size
"stake_pct": PCT, "event_cap": EVENT_CAP, "hwm": round(hwm, 2),
"dd_threshold": DD_THRESHOLD, "capped_count": capped,
"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),
@@ -232,20 +300,23 @@ def main():
"conv_thr": conv_thr.get(v["wallet"], 1e12)}
for v in perW.values()],
"current": [{"title": c.get("title", ""), "name": c["name"], "outcome": c.get("outcome", ""),
"stake": STAKE, "val": round(c["val"], 2), "pnl": round(c["pnl"], 2),
"stake": c.get("stake"), "val": round(c["val"], 2), "pnl": round(c["pnl"], 2),
"end": c.get("end")} for c in sorted(current, key=lambda c: c["entry_t"])],
"resolved": [{"title": r.get("title", ""), "name": r["name"], "won": r["won"],
"stake": STAKE, "pnl": round(r["pnl"], 2), "date": r.get("res_t")}
"stake": r.get("stake"), "pnl": round(r["pnl"], 2), "date": r.get("res_t")}
for r in resolved[:60]],
"missed": [{"title": m.get("title", ""), "name": m["name"], "won": m.get("won"),
"stake": STAKE, "pnl": round(m["pnl"], 2), "date": m.get("res_t")}
"stake": m.get("stake"), "capped": bool(m.get("capped")),
"pnl": round(m["pnl"], 2), "date": m.get("res_t")}
for m in missed[:60]],
"missed_pnl": round(sum(hypo_pnl(m) for m in missed), 2),
}
json.dump(out, open(os.path.join(HERE, "portfolio.json"), "w"), separators=(",", ":"))
save_slug_cache()
print(f"portfolio: equity ${equity:,.0f} ({(equity-BANK)/BANK*100:+.0f}%) | realized ${realized:+,.0f} "
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)
f"| fees ${fees_paid:,.0f} | next stake ${cur_stake():,.0f} | {len(resolved)} resolved "
f"({wins}W/{len(resolved)-wins}L) | {len(current)} open | {len(missed)} missed "
f"({capped} event-capped) | -> portfolio.json", flush=True)
if __name__ == "__main__":