Files
winning-wallet-finder/live/portfolio.py
T
jaxperro 8958279ee8 cleanup: archive everything the live system no longer touches
- retired-infra/: Railway config, Mac launchd runner, GH-Actions cron
  (worker is Fly.io arn now; Actions cron last fired 2026-07-02)
- live-research/: the June selection experiments (strategy/followability/
  pnl_basket/pnl_focused/backtest_june/clean_test + outputs)
- us-venue/: the scrapped Polymarket-US listability probe (+ its
  env-gated ONLY_CONDS replay filter stays in portfolio.py — generally
  useful for subset replays)
- root sweeps hunt/huntwide/oos/copyback/watch.json -> archive/
- untracked logs/CSVs of dead experiments -> archive/local/ (gitignored)
- READMEs updated: Fly migration, geoblock gotcha, new file map

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:18:46 -04:00

420 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Precompute the $1,000 paper portfolio server-side, off the cache.
The dashboard's top page used to replay the followed wallets' trades client-side,
which (a) hammered the data-api/clob from the browser and (b) phantom-locked capital
because the data-api misses resolution dates for high-volume wallets. This computes
the same book here instead, sourced from cache.duckdb — which already stores each
resolved bet's entry price, size, win/loss AND resolution time (res_t), so capital
RECYCLES correctly (cash frees at the true resolution moment). Output -> portfolio.json,
which the dashboard reads in one request.
Model: a $1,000 account that mirrors each followed wallet's CONVICTION bets (top-20%
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 argparse
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
import trust
_SSL = ssl._create_unverified_context()
HERE = os.path.dirname(__file__)
BANK = 1000.0
GAMMA = "https://gamma-api.polymarket.com"
# ---- interchangeable-wallet replay: live/backtest.json ----------------------
# {days, stake_cap_usd, class_pct: {volume, whale}, wallets: [{wallet, name,
# class}]}. Rolling window: "what if I started following these wallets `days`
# ago." Class 'whale' replays EVERY trusted bet at class_pct.whale of equity;
# 'volume' (default) replays conviction bets only (top-20% by stake, threshold
# from PRE-window trusted bets so it can't peek) at class_pct.volume.
# Ad-hoc runs that leave the dashboard feed alone:
# python3 portfolio.py --wallets 0xabc,0xdef:whale --days 30 --out /tmp/t.json
_ap = argparse.ArgumentParser()
_ap.add_argument("--wallets", help="comma list of addresses; ':whale' suffix opts into whale class")
_ap.add_argument("--days", type=int, help="window length (default backtest.json's, else 30)")
_ap.add_argument("--out", help="output path (default $PORTFOLIO_OUT or portfolio.json)")
_ARGS, _ = _ap.parse_known_args()
try:
_BT = json.load(open(os.path.join(HERE, "backtest.json")))
except Exception:
_BT = {}
DAYS = _ARGS.days or int(_BT.get("days", 30))
START = time.time() - DAYS * 86400 # rolling: started following DAYS ago
CLASS_PCT = {"volume": 0.04, "whale": 0.12, **(_BT.get("class_pct") or {})}
BASE_PCT = CLASS_PCT.get("volume", 0.04) # sweep threshold stays on the base class
# ---- 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 >0 limits concurrent bets whose markets belong to the same real-world
# event (a game's markets settle together — one correlated bet, not N diversified
# ones); 0 = off, mirror every conviction trade.
# per-class equity fractions come from CLASS_PCT (backtest.json). No stake cap
# and no banked reserve: the natural ceiling is the FOLLOWED WALLET'S OWN BET —
# a copy is never larger than what the wallet actually staked (you can't
# out-conviction the signal, and it keeps fills inside the size the market
# actually absorbed).
STAKE_MIN = 5.0
EVENT_CAP = 0
DD_THRESHOLD, DD_FACTOR = 0.80, 0.5
# skip entries above this price. High-price favorites win pennies and lose
# whole stakes: the June sweep (caps 0.75-1.0) peaked at 0.95 — >95¢ bets added
# ~23 wins yet LOWERED final equity (slip+fee eat the ~1-3% payouts, and the
# locked capital compounds better elsewhere). Deep caps (<=0.85) cut real
# winners. Mirrored by the bot's follow.max_entry; env-overridable for sweeps.
MAX_ENTRY = float(os.environ.get("MAX_ENTRY", 0.95))
OUT = _ARGS.out or os.environ.get("PORTFOLIO_OUT", "portfolio.json")
# ---- realism model (matches the live copybot) -------------------------------
# Taker fee (Polymarket V2, since 2026-03-30): fee = shares·rate·p·(1p); for a
# $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 replayed wallets — from --wallets, else live/backtest.json (editable:
# add/remove/swap any address there; classes default to 'volume')
if _ARGS.wallets:
WALLETS = []
for _tok in _ARGS.wallets.split(","):
_addr, _, _cls = _tok.strip().partition(":")
WALLETS.append({"wallet": _addr, "name": _addr[:10], "class": _cls or "volume"})
else:
WALLETS = [{"wallet": w["wallet"], "name": w.get("name", w["wallet"][:10]),
"class": w.get("class", "volume")} for w in _BT.get("wallets", [])]
if not WALLETS:
raise SystemExit("no wallets to replay: create live/backtest.json or pass --wallets")
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
_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 + 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(
f"https://clob.polymarket.com/markets/{cond}", headers={"User-Agent": "Mozilla/5.0"}),
timeout=20, context=_SSL)
m = json.loads(r.read())
_MKT[cond] = {"title": m.get("question") or "", "slug": m.get("market_slug") or ""}
except Exception:
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)
_WALLET_THR = {} # wallet -> conviction threshold used this run (0 for whales)
def window_bets():
"""Every replayed wallet's TRUSTED resolved bets entered inside the window,
with entry time. Trusted rows only (trust.py) — outcomes observed post-
resolution, res_t=ts poison excluded — so any pasted-in wallet is scored
honestly, not on cache marks. Class rules: 'whale' replays EVERY bet;
'volume' only conviction bets, with the p80 threshold computed from
PRE-window trusted bets (falls back to full history for wallets with no
pre-window sample) so the threshold can't peek at the window it scores."""
out = []
now = time.time()
trust.ensure_cons(cache.query)
for w in WALLETS:
cache.get_bets(w["wallet"]) # ensure pulled/fresh (pulls brand-new wallets)
ent = cache.get_entries(w["wallet"]) # cond -> first buy ts
rows = trust.trusted_wallet_rows(cache.query, w["wallet"], now)
best = {} # one bet per market: largest-stake token
for cond, asset, won, p, res_t, size in rows:
if cond not in best or size > best[cond][3]:
best[cond] = (won, p, res_t, size)
if w.get("class") == "whale":
thr = 0.0
else:
pre = [size for won, p, res_t, size in best.values() if res_t < START]
thr = cache.conv_cutoff(pre if pre else
[size for *_, size in best.values()])
_WALLET_THR[w["wallet"]] = thr
for cond, (won, p, res_t, size) in best.items():
if size < thr or p > MAX_ENTRY:
continue
et = ent.get(cond)
if not et or et < START: # only in-window entries
continue
out.append({"wallet": w["wallet"], "name": w["name"], "cond": cond,
"cls": w.get("class", "volume"), "their": size,
"entry_t": et, "p": p, "won": won, "res_t": res_t or 0})
return out
def open_bets():
"""Currently-held conviction positions (live /positions pull, small) for the
'current bets' panel — the cache only has resolved bets."""
out = []
for w in WALLETS:
ent = cache.get_entries(w["wallet"])
ps = sm.get_json("/positions", {"user": w["wallet"], "limit": 500, "sizeThreshold": 0}) or []
if w.get("class") == "whale":
thr = 0.0 # whales: every open position counts
else:
thr = _WALLET_THR.get(w["wallet"])
if thr is None:
thr = cache.conv_cutoff((p.get("initialValue") or 0) for p in ps)
for p in ps:
cp = p.get("curPrice", 0) or 0
if cp <= 0.001 or cp >= 0.999: # resolved -> belongs to history, not open
continue
if (p.get("initialValue") or 0) < thr:
continue
if (p.get("avgPrice", 0) or 0) > MAX_ENTRY:
continue
et = ent.get(p.get("conditionId"))
if et is not None and et < START:
continue # position predates the window
# unknown entry time -> queue at the END of the replay (an open
# position is the newest thing in the book; entry_t=0 used to put
# it FIRST, draining the bankroll before any historical bet ran)
out.append({"wallet": w["wallet"], "name": w["name"], "cond": p.get("conditionId"),
"cls": w.get("class", "volume"), "their": p.get("initialValue") or 0,
"entry_t": et if et is not None else time.time(),
"p": max(0.001, min(0.999, p.get("avgPrice", 0) or 0)),
"cur": cp, "title": p.get("title") or "", "outcome": p.get("outcome") or "",
"end": p.get("endDate")})
return out
def main():
now = time.time()
resolved_pool = window_bets()
open_pool = open_bets()
# merge into one entry-ordered stream; one position per market (earliest entry wins)
by_mkt = {}
for b in resolved_pool:
b["kind"] = "res"
if b["cond"] not in by_mkt or b["entry_t"] < by_mkt[b["cond"]]["entry_t"]:
by_mkt[b["cond"]] = b
for b in open_pool:
if b["cond"] and (b["cond"] not in by_mkt or b["entry_t"] < by_mkt[b["cond"]]["entry_t"]):
b["kind"] = "open"; by_mkt[b["cond"]] = b
stream = sorted(by_mkt.values(), key=lambda b: b["entry_t"])
# ONLY_CONDS=<json path>: replay only these markets — {cond: bool} or
# us_listable.py's {cond: {"listed": bool, ...}}. Models "same signal, but
# I can only execute the subset" (e.g. bets also listed on Polymarket US);
# thresholds/sizing still come from the full signal, capital only chases
# the executable bets.
_only = os.environ.get("ONLY_CONDS")
if _only:
_allow = {c for c, v in json.load(open(_only)).items()
if (v.get("listed") if isinstance(v, dict) else v)}
_pre = len(stream)
stream = [b for b in stream if b["cond"] in _allow]
print(f"portfolio: ONLY_CONDS filter kept {len(stream)}/{_pre} bets", flush=True)
# 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
reserve = 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,
"won": 0, "lost": 0, "class": w.get("class", "volume"),
"invested": 0.0, "realized": 0.0} for w in WALLETS}
resolved, current, missed = [], [], []
def cur_stake(frac=BASE_PCT, their=None):
"""The wallet-class fraction of current equity, drawdown-braked, and
NEVER larger than the followed wallet's own stake: when the percentage
works out to more than they actually bet, mirror their exact amount."""
nonlocal hwm
eq = cash + sum(c for _, c, _, _ in held)
hwm = max(hwm, eq)
if eq < DD_THRESHOLD * hwm:
frac *= DD_FACTOR
stake = max(STAKE_MIN, frac * eq)
if their and stake > their:
stake = their
return stake
def free(upto):
nonlocal cash, realized
keep = []
for ft, cost, payoff, rec in held:
if ft and ft <= upto and rec["kind"] == "res":
cash += payoff; realized += payoff - cost; perW[rec["wallet"]]["realized"] += payoff - cost
perW[rec["wallet"]]["won" if rec["won"] else "lost"] += 1
rec["pnl"] = payoff - cost
resolved.append(rec)
else:
keep.append((ft, cost, payoff, rec))
held[:] = keep
for b in stream:
free(b["entry_t"])
stake = cur_stake(CLASS_PCT.get(b.get("cls"), BASE_PCT), b.get("their"))
b["stake"] = round(stake, 2)
b["event"] = event_key(market_meta(b["cond"])["slug"])
# correlation cap (off when EVENT_CAP=0): skip a bet when we already hold
# EVENT_CAP positions on the same real-world event (deliberate risk skip)
if EVENT_CAP and 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
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"]
else:
missed.append(b)
free(now)
# finalize open (still held with kind==open): mark to market
invested = 0.0
open_cost = 0.0
for ft, cost, payoff, rec in held:
if rec["kind"] == "open":
invested += rec["val"]; rec["pnl"] = rec["val"] - cost
open_cost += cost
perW[rec["wallet"]]["invested"] += rec["val"]
current.append(rec)
# enrich resolved + missed with titles, keep most-recent 60
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 — 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):
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
missed.sort(key=lambda m: m.get("res_t") or 0, reverse=True)
for m in missed[:60]:
m["title"] = market_meta(m["cond"])["title"]
m["pnl"] = hypo_pnl(m)
wins = sum(1 for r in resolved if r.get("won"))
# per-wallet conviction threshold (cache p80) so the dashboard can filter LIVE open
# positions the same way; 1e12 = "no sized bets" (nothing qualifies)
conv_thr = {}
for w in WALLETS:
t = _WALLET_THR.get(w["wallet"])
if t is None:
t = cache.conv_cutoff(b["size"] for b in cache.get_bets(w["wallet"]) if (b["size"] or 0) > 0)
conv_thr[w["wallet"]] = round(t) if t != float("inf") else 1e12
equity = cash + invested + reserve
out = {
"started": START, "updated": now, "days": DAYS,
"bank": BANK, "stake": round(cur_stake(), 2), # the NEXT bet's size (base class)
"stake_pct": BASE_PCT, "class_pct": CLASS_PCT,
"event_cap": EVENT_CAP,
"hwm": round(hwm, 2),
"dd_threshold": DD_THRESHOLD, "capped_count": capped,
"max_entry": MAX_ENTRY,
"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),
"reserve": round(reserve, 2), # banked profit, never bet
"realized": round(realized, 2), "pnl": round(equity - BANK, 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"],
"won": v["won"], "lost": v["lost"], "class": v.get("class", "volume"),
"invested": round(v["invested"], 2), "realized": round(v["realized"], 2),
"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": 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": 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": 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, OUT) if not os.path.isabs(OUT) else OUT, "w"),
separators=(",", ":"))
save_slug_cache()
print(f"portfolio[{DAYS}d rolling]: equity ${equity:,.0f} ({(equity-BANK)/BANK*100:+.0f}%) | banked ${reserve:,.0f} "
f"| realized ${realized:+,.0f} | fees ${fees_paid:,.0f} | next stake ${cur_stake():,.0f} "
f"| {len(resolved)} resolved ({wins}W/{len(resolved)-wins}L) | {len(current)} open "
f"| {len(missed)} missed ({capped} event-capped) | -> {os.path.basename(OUT)}", flush=True)
if __name__ == "__main__":
main()