Files
winning-wallet-finder_github/live/portfolio.py
T

753 lines
38 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). Exits MIRROR the signal, like the live bot (2026-07-07): when the wallet
fully closed a position pre-resolution, the replay sells there too — close time from
/closed-positions, exit price reconstructed as avgPrice + realizedPnl/totalBought,
exit taker fee + slippage haircut paid, status SOLD. Bets they held to resolution
settle at the chain-truth payout (1/0/0.5 — refunds are scratches, not losses).
Complete in-window round trips on still-unresolved markets are replayed as
entry+exit (the old hold-to-resolution model missed them entirely). 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 payouts
import smart_money as sm
import trust
_SSL = ssl._create_unverified_context()
HERE = os.path.dirname(__file__)
# BANK is set after the arg/backtest.json parse below
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("--follow-only", action="store_true",
help="replay only the paper bot's follow set "
"(backtest.json entries ∩ copybot.paper.json wallets)")
_ap.add_argument("--days", type=int, help="window length (default backtest.json's, else 30)")
_ap.add_argument("--bank", type=float, help="starting bankroll (default backtest.json's, else 1000)")
_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))
BANK = float(_ARGS.bank or _BT.get("bank", 1000.0))
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 _ARGS.follow_only:
# the paper bot's live follow set — names/classes stay from backtest.json
try:
_FSET = {w["wallet"].lower() for w in
json.load(open(os.path.join(HERE, "copybot.paper.json")))["wallets"]}
WALLETS = [w for w in WALLETS if w["wallet"].lower() in _FSET]
except Exception as _e:
raise SystemExit(f"--follow-only: cannot read copybot.paper.json ({_e})")
if not WALLETS:
raise SystemExit("no wallets to replay: create live/backtest.json or pass --wallets")
# the live bot's pinned conviction floors (sync_floors -> copybot.paper.json).
# When a replayed wallet is one the bot follows, gate on the SAME floor the bot
# enforces — a recomputed p80 can drift a few % from the pin and then the two
# books take different bets (alignment audit 2026-07-08). Candidate sets passed
# via --wallets have no pin and fall back to the p80 recompute.
try:
_PINNED = {w["wallet"].lower(): w["floor"]
for w in json.load(open(os.path.join(HERE, "copybot.paper.json")))["wallets"]
if w.get("floor")}
except Exception:
_PINNED = {}
# ---- execution mode: mirrors the PAPER BOT's follow flags (2026-07-23) ------
# The backtest replays the same execution the paper test runs (#20/#21):
# entry_mode "maker": fill at the sharp's own price, no fee, no slip (the
# deployed bot rests at their price, 60s TTL; T3/T11 measured 90% touch
# with the never-filled tail being winners — so 100%-fill-at-their-price
# is the DECLARED optimistic bound; wallet-selection bench, not an EV
# certification — verdicts stay with the bots' own #20/#21 windows)
# exit_mode "hold": mirrored sells ignored; every position rides to chain
# truth (sold-but-unresolved positions carry as open, marked at the
# signal's exit print). Flip copybot.paper.json back -> backtest follows.
try:
_FOLLOW = json.load(open(os.path.join(HERE, "copybot.paper.json")))["follow"]
except Exception:
_FOLLOW = {}
ENTRY_MODE = os.environ.get("BT_ENTRY_MODE", _FOLLOW.get("entry_mode", "taker"))
EXIT_MODE = os.environ.get("BT_EXIT_MODE", _FOLLOW.get("exit_mode", "mirror"))
# band-guarded mirroring (#21 successor): sells below this price are NOT
# mirrored — held to truth like hold mode (sell-band study: every band
# <90c lost by mirroring; >=90c saved). 0.0 = mirror everything.
MIRROR_SELL_MIN_P = float(os.environ.get(
"BT_MIRROR_SELL_MIN_P", _FOLLOW.get("mirror_sell_min_p", 0.0)))
def _mirrors(exit_p):
"""Would the bot mirror a sell at this price under the current flags?"""
return (EXIT_MODE != "hold"
and (exit_p or 0) >= MIRROR_SELL_MIN_P)
def entry_model(p, stake):
"""(effective entry price, entry fee, total cash cost) of a $stake copy.
taker: price worsened by the lag-slippage haircut, taker fee on top.
maker: the sharp's own price, feeless (see ENTRY_MODE note above)."""
if ENTRY_MODE == "maker":
return p, 0.0, stake
p_eff = min(0.999, p * (1 + SLIP))
fee = stake * FEE_RATE * (1 - p_eff)
return p_eff, fee, stake + fee
def _sold_pre_resolution(cx, won, res_t):
"""Did the wallet SELL this position (a mirrorable exit the live bot would
copy) rather than redeem it at resolution? The timestamp test only works
when res_t is a real resolution moment — for in-play markets res_t is the
market's endDate metadata (game-DAY midnight), which pre-dates the wallet's
own entry, so the exit can never test earlier and every in-play sell used
to book as held-to-resolution (Kruto's Jul-7 Brewers sells, found
2026-07-08). Price is the fallback truth: a genuine sell prints a mid
price; a redeem (or post-resolution dump) prints ≈ the payout. When the two
disagree we book the wallet's actual exit print — that is what happened on
Polymarket, and it also self-corrects a poisoned won flag. A 50/50 refund
redeemed at 0.5 books as sold-at-0.5 (same money as refund, label S not R)."""
if res_t and cx["ts"] < res_t - 300:
return True
xp = cx.get("exit_p")
if xp is None:
return False
# a redeem reconstructs at EXACTLY the payout (payoff×shares/shares); the
# 0.02 band only absorbs avg-price rounding artifacts (0.8999998…), so a
# 0.96 exit on a won market correctly reads as a pre-resolution sell
payout = 1.0 if won else 0.0
return abs(xp - payout) > 0.02
_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][4]:
best[cond] = (asset, won, p, res_t, size)
if w.get("class") == "whale":
thr = 0.0
elif w["wallet"].lower() in _PINNED:
thr = _PINNED[w["wallet"].lower()]
else:
pre = [size for asset, 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
# the wallet's fully-closed positions: close time + reconstructed exit
# price. The live bot MIRRORS exits, so the replay must too — a bet the
# signal sold pre-resolution exits the book right there (status SOLD),
# not at resolution (the old hold-to-resolution ceiling).
closed = closed_positions(w["wallet"])
for cond, (asset, 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
b = {"wallet": w["wallet"], "name": w["name"], "cond": cond,
"asset": asset, "cls": w.get("class", "volume"),
"their": size, "entry_t": et, "p": p, "won": won,
"res_t": res_t or 0}
cx = closed.get(asset)
if cx and _sold_pre_resolution(cx, won, res_t):
b["exit_t"], b["exit_p"] = cx["ts"], cx["exit_p"]
out.append(b)
# complete round trips the cache can't see: entered AND fully exited
# in-window on a market that never resolved (or hasn't yet) — the live
# bot would have copied both legs, so the replay does too
for asset, cx in closed.items():
cond = cx["cond"]
if (cond in best or not cond or cx["ts"] < START
or (cx["iv"] or 0) < thr or cx["p"] > MAX_ENTRY):
continue
et = ent.get(cond)
if not et or et < START:
continue
wp = payouts.truth(cond)
if wp is not None and abs((cx["exit_p"] if cx["exit_p"] is not None
else wp) - wp) <= 0.02:
# resolved on-chain AND their close prints ≈ the payout: the
# close was the redeem, not a sell. The trusted cache row
# usually books this bet — but when res_t is bogus forward
# metadata (in-play/tennis endDate: Kruto's Chidekh total sat
# "unresolved until Jul 14" on a Jul-5 match) the row never
# qualifies and the bet used to vanish here. Book it as
# held-to-resolution at chain truth (2026-07-08).
best[cond] = None
out.append({"wallet": w["wallet"], "name": w["name"], "cond": cond,
"asset": asset, "cls": w.get("class", "volume"),
"their": cx["iv"], "entry_t": et, "p": cx["p"],
"won": wp > 0.5, "wp": wp, "res_t": cx["ts"],
"title": cx.get("title") or ""})
continue
# exit print ≠ payout (or market genuinely unresolved): a real
# pre-resolution sell — mirror it, like the live bot
best[cond] = None # one per market, same as everywhere
out.append({"wallet": w["wallet"], "name": w["name"], "cond": cond,
"asset": asset, "cls": w.get("class", "volume"),
"their": cx["iv"], "entry_t": et, "p": cx["p"],
"won": None, "res_t": 0,
"exit_t": cx["ts"], "exit_p": cx["exit_p"],
"title": cx.get("title") or ""})
# abandoned losers/winners: conviction bets entered in-window that the
# signal held to a decided outcome and never redeemed (curPrice 0/1 in
# /positions). Not trusted rows, not in /closed-positions -> the replay
# missed them and read optimistic. Settle at the decided outcome (a loser
# pays 0 = full loss), same fold-in as the sharps table.
for asset, r in resolved_unredeemed(w["wallet"]).items():
cond = r["cond"]
if cond in best or not cond:
continue # already a trusted row / round trip
if (r["iv"] or 0) < thr or r["p"] > MAX_ENTRY:
continue
et = ent.get(cond)
if not et or et < START:
continue
best[cond] = None
out.append({"wallet": w["wallet"], "name": w["name"], "cond": cond,
"asset": asset, "cls": w.get("class", "volume"),
"their": r["iv"], "entry_t": et, "p": r["p"],
"won": r["curp"] >= 0.5, "res_t": r["res_t"]})
# chain-truth payouts for the replayed markets: refunds pay 0.5/share, and
# a cache `won` mark can be wrong on operator-resolved markets — the
# replay must settle at what a redeem actually pays (see payouts.py)
payouts.ensure({b["cond"] for b in out})
for b in out:
b["wp"] = payouts.truth(b["cond"], b.get("asset"))
return out
def closed_positions(wallet):
"""The wallet's fully-closed positions — cache.closed_exits, the
incremental cached layer (validate_timing uses the same one, so the
backtest and the sharps stats mirror exits identically)."""
return cache.closed_exits(wallet)
def _end_ts(s):
if not s:
return 0
s = s.replace("Z", "")
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
try:
return int(time.mktime(time.strptime(s, fmt)))
except ValueError:
continue
return 0
def resolved_unredeemed(wallet):
"""Conviction bets the signal LOST (or won) and never redeemed — they sit in
/positions at curPrice pinned 0/1, are NOT in /closed-positions, and for
operator-resolved markets are NOT trusted rows either, so the replay never
saw them. Mostly ABANDONED LOSERS. Folding them in is the same anti-
survivorship correction the sharps table uses (_open_split) — without it the
backtest silently drops these losses and reads optimistic.
{asset: {cond, iv, p, curp, res_t}}."""
out = {}
for off in range(0, 100000, 50):
pg = sm.get_json("/positions", {"user": wallet, "limit": 50, "offset": off,
"sizeThreshold": 0})
if not pg:
break
for pos in pg:
cp = pos.get("curPrice", 0) or 0
asset = pos.get("asset")
if not asset or 0.001 < cp < 0.999: # genuinely open -> not decided
continue
out[asset] = {"cond": pos.get("conditionId"),
"iv": pos.get("initialValue") or 0,
"p": max(0.001, min(0.999, pos.get("avgPrice") or 0)),
"curp": cp,
"res_t": _end_ts(pos.get("endDate")) or int(time.time())}
if len(pg) < 50:
break
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, "ref": 0, "sold": 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
if rec.get("sold"):
# mirrored exit: neither won nor lost — its truth is the price
perW[rec["wallet"]]["sold"] = perW[rec["wallet"]].get("sold", 0) + 1
rec["won"] = None
else:
wp = rec.get("wp")
won = rec["won"] if wp is None else wp > 0.5
# refunds are scratches, not losses — count them apart
perW[rec["wallet"]]["won" if won else "ref" if wp == 0.5 else "lost"] += 1
rec["won"] = won # truth-adjusted for the feed
if wp == 0.5:
rec["refund"] = True
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":
if b.get("exit_t") and _mirrors(b.get("exit_p")):
# the signal SOLD pre-resolution -> mirror the exit, like the
# live bot: their exit price with the slippage haircut against
# us, minus the taker fee (sells pay it; redeems don't)
xp = max(0.001, b["exit_p"] * (1 - SLIP))
fee_out = shares * FEE_RATE * xp * (1 - xp)
fees_paid += fee_out
b["sold"] = True
held.append((b["exit_t"], cost, shares * xp - fee_out, b))
elif b.get("wp") is None and b.get("won") is None:
# hold mode, signal sold, market NOT resolved: no truth to
# grade against yet -> carry as an open position marked at
# the signal's exit print (best known price; conservative:
# capital stays locked until real resolution)
b["kind"] = "open"
b["cur"] = b.get("exit_p") or b["p"]
held.append((None, cost, 0.0, b))
b["val"] = shares * b["cur"]
else:
# held to resolution: chain-truth payout (1/0/0.5) when
# known, else the cache mark; redeem is fee-free
wp = b.get("wp")
if wp is None:
wp = 1.0 if b["won"] else 0.0
held.append((b["res_t"] or now, cost, shares * wp, 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("exit_t") or r.get("res_t") or 0, reverse=True)
for r in resolved[:250]:
m = market_meta(r["cond"])
if m["title"]: # round-trip recs already carry a title
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)
shares = stake / p_eff
if m.get("exit_t") and _mirrors(m.get("exit_p")): # would have mirrored
xp = max(0.001, m["exit_p"] * (1 - SLIP))
return shares * xp - shares * FEE_RATE * xp * (1 - xp) - cost
if m.get("wp") is not None or m.get("won") is not None:
wp = m.get("wp")
if wp is None:
wp = 1.0 if m["won"] else 0.0
return shares * wp - cost
# no truth yet (open, or hold-mode sold-unresolved): mark to price
mark = m.get("cur") or m.get("exit_p") or p_eff
return shares * mark - cost
def _truth_won(m):
"""Chain/cache-truth won for a missed rec, None when undecided."""
if m.get("wp") is not None:
return m["wp"] > 0.5
return m.get("won")
def _missed_won(m):
if m.get("exit_t") and _mirrors(m.get("exit_p")):
return None # mirrored exit: truth is the price
return _truth_won(m)
def _missed_status(m):
if m.get("exit_t") and _mirrors(m.get("exit_p")):
return "sold"
w = _truth_won(m)
if w is None:
return None
if m.get("wp") == 0.5:
return "refund"
return "won" if w else "lost"
missed.sort(key=lambda m: m.get("res_t") or 0, reverse=True)
for m in missed: # full enrichment: the companion file
m["title"] = market_meta(m["cond"])["title"] # (disk-cached)
m["pnl"] = hypo_pnl(m)
wins = sum(1 for r in resolved if r.get("won"))
refunds = sum(1 for r in resolved if r.get("refund"))
solds = sum(1 for r in resolved if r.get("sold"))
# 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 - refunds - solds,
"refunds": refunds, "sold": solds,
"open_count": len(current), "missed_count": len(missed),
"wallets": [{"name": v["name"], "wallet": v["wallet"], "bets": v["bets"],
"won": v["won"], "lost": v["lost"], "ref": v.get("ref", 0),
"sold": v.get("sold", 0), "class": v.get("class", "volume"),
"invested": round(v["invested"], 2), "realized": round(v["realized"], 2),
"conv_thr": conv_thr.get(v["wallet"], 1e12),
# per-wallet missed totals over the FULL missed list —
# the feed's missed[] is a 60-row window, so the cards
# must get lifetime numbers server-side (2026-07-22,
# same lesson as the copybot dash's wallet_pnl)
"missed_n": sum(1 for m in missed if m["name"] == v["name"]),
"missed_pnl": round(sum(hypo_pnl(m) for m in missed
if m["name"] == v["name"]), 2)}
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"])],
# status mirrors the live bot's vocabulary: won / lost / refund (50/50
# scratch — pays $0.50/share, so "not a win" can still be P&L-positive
# below 50¢ entries) / sold (the signal exited pre-resolution and the
# replay mirrored it, exit fee + slip paid — same as the live bot).
"resolved": [{"title": r.get("title", ""), "name": r["name"], "won": r["won"],
"status": ("sold" if r.get("sold")
else "refund" if r.get("refund")
else "won" if r["won"] else "lost"),
"stake": r.get("stake"), "pnl": round(r["pnl"], 2),
"date": r.get("exit_t") or r.get("res_t")}
for r in resolved[:250]],
"missed": [{"title": m.get("title", ""), "name": m["name"],
"won": _missed_won(m),
"status": _missed_status(m),
"stake": m.get("stake"), "capped": bool(m.get("capped")),
"pnl": round(m["pnl"], 2), "date": m.get("exit_t") or m.get("res_t")}
for m in missed[:60]],
"missed_pnl": round(sum(hypo_pnl(m) for m in missed), 2),
"entry_mode": ENTRY_MODE, "exit_mode": EXIT_MODE,
"mirror_sell_min_p": MIRROR_SELL_MIN_P,
"follow_only": bool(_ARGS.follow_only),
}
json.dump(out, open(os.path.join(HERE, OUT) if not os.path.isabs(OUT) else OUT, "w"),
separators=(",", ":"))
# companion: the COMPLETE missed list (the feed's missed[] is a 60-row
# window; wallet cards sum the full list server-side — this file lets
# the dashboard's wallet modal show every row behind those totals)
mfull = [{"title": m.get("title", ""), "name": m["name"],
"won": _missed_won(m), "status": _missed_status(m),
"stake": m.get("stake"), "capped": bool(m.get("capped")),
"p": m.get("p"), "pnl": round(m["pnl"], 2),
"date": m.get("exit_t") or m.get("res_t")} for m in missed]
mf = (OUT[:-5] if OUT.endswith(".json") else OUT) + "_missed.json"
json.dump(mfull, open(os.path.join(HERE, mf) if not os.path.isabs(mf)
else mf, "w"), separators=(",", ":"))
# companion 2: EVERY replayed bet with join keys (cond/asset) — the
# three-book reconciliation study (#24) joins these against both bots'
# fills/misses to decompose backtest-vs-reality per named gap bucket
bfull = []
_mids = {id(m) for m in missed}
for b in stream:
st_ = ("missed" if id(b) in _mids else
"open" if b.get("kind") == "open" or b.get("cur") is not None
else "sold" if b.get("sold") else "resolved")
bfull.append({"cond": b.get("cond"), "asset": b.get("asset"),
"name": b["name"], "p": b.get("p"),
"their": b.get("their"), "stake": b.get("stake"),
"entry_t": int(b.get("entry_t") or 0), "status": st_,
"capped": bool(b.get("capped")),
"wp": b.get("wp"),
"pnl": round(b["pnl"], 2) if b.get("pnl") is not None
else None})
bf = (OUT[:-5] if OUT.endswith(".json") else OUT) + "_bets.json"
json.dump(bfull, open(os.path.join(HERE, bf) if not os.path.isabs(bf)
else bf, "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-refunds-solds}L/{refunds}R/{solds}S) | {len(current)} open "
f"| {len(missed)} missed ({capped} event-capped) | -> {os.path.basename(OUT)}", flush=True)
if __name__ == "__main__":
main()