#!/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("--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·(1−p); for a # $stake buy that's stake·rate·(1−p). 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") # 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 = {} 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 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=: 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"): # 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)) 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"): # would have mirrored their exit xp = max(0.001, m["exit_p"] * (1 - SLIP)) return shares * xp - shares * FEE_RATE * xp * (1 - xp) - cost if "won" in m: wp = m.get("wp") if wp is None: wp = 1.0 if m["won"] else 0.0 return shares * wp - 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")) 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)} 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": (None if "won" not in m or m.get("exit_t") else (m["won"] if m.get("wp") is None else m["wp"] > 0.5)), "status": ("sold" if m.get("exit_t") else None if "won" not in m else "refund" if m.get("wp") == 0.5 else "won" if (m["won"] if m.get("wp") is None else m["wp"] > 0.5) else "lost"), "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), } 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-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()