diff --git a/live/follow_since.json b/live/follow_since.json new file mode 100644 index 00000000..e4a890e6 --- /dev/null +++ b/live/follow_since.json @@ -0,0 +1,18 @@ +{ + "0xe8ca3f758c93f44f3ec210542ab78afb7c0bcccb": 1783307453, + "0x86c878cde72660ec52f5e6f0f0438b76de8fc867": 1783307453, + "0x41558102a796ba971c7567cad41c307e59f8fa41": 1783307453, + "0xd96750bf8d941a8186e592b0ae6e096da66aa266": 1783307453, + "0xfc81760d44a21acc9fd4b749a5bf9a9b2eeae072": 1783307453, + "0xf5fe759cece500f58a431ef8dacea321f6e3e23d": 1783307453, + "0x73afc8160c17830c0c7281a7bf570c871455b880": 1783307453, + "0x4bfb41d5b570defd03c39a9a4d8de6bd8b8982e": 1783308783, + "0x72e1597864456eda62878413cf3e60c332e4a45d": 1783308985, + "0x0d42cc5ff0526fbb8f0bb9eafa9ebba3f23df124": 1783476565, + "0xbadaf319415c17f28824a43ae0cd912b9d84d874": 1783476565, + "0x215adbb63b47d0ca92f849fe2c2dc1adb0f6254c": 1783476565, + "0xd7d36345c4aab150e59577e360696b01d01d698b": 1783527507, + "0x921433c93558b9a4ba807ec824d02aad7ea2ddbf": 1783527507, + "0xa1d57d329227c75b12b09f927fb3d6d6ef8f1343": 1783527507, + "0x40ce68f1564f3c751b12d88a393d8cc0651dbf90": 1784823384 +} \ No newline at end of file diff --git a/live/follow_since.py b/live/follow_since.py new file mode 100644 index 00000000..eabc0b52 --- /dev/null +++ b/live/follow_since.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""#24 v1 — per-wallet follow-start dates from the follow config's own git +history (the truth of when each wallet actually entered the book). +Writes live/follow_since.json {wallet_lower: epoch}; wallets present in +the FIRST recorded config carry that first-commit date (left-censored, +same convention as sharp_halflife). Run by daily.sh before the portfolio +replays so --follow-only never replays a wallet before we followed it +(the JuiceFarm class: 8 'unseen' bets that were simply pre-follow).""" +import json +import os +import subprocess +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) + + +def main(): + log = subprocess.run( + ["git", "log", "--reverse", "--format=%at %H", + "--", "live/copybot.paper.json"], + cwd=ROOT, capture_output=True, text=True).stdout.split() + pairs = list(zip(log[0::2], log[1::2])) + since = {} + for ts, sha in pairs: + try: + blob = subprocess.run( + ["git", "show", f"{sha}:live/copybot.paper.json"], + cwd=ROOT, capture_output=True, text=True).stdout + cfg = json.loads(blob) + except Exception: + continue + for w in cfg.get("wallets", []): + a = (w.get("wallet") or "").lower() + if a and a not in since: + since[a] = int(ts) + # wallets in the working tree but not yet committed: follow = now + try: + cfg = json.load(open(os.path.join(HERE, "copybot.paper.json"))) + for w in cfg.get("wallets", []): + a = (w.get("wallet") or "").lower() + if a and a not in since: + since[a] = int(time.time()) + except Exception: + pass + out = os.path.join(HERE, "follow_since.json") + json.dump(since, open(out, "w"), indent=1) + print(f"[follow_since] {len(since)} wallets -> follow_since.json") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/live/portfolio.py b/live/portfolio.py index 820c967a..f9a78bb0 100644 --- a/live/portfolio.py +++ b/live/portfolio.py @@ -127,6 +127,16 @@ if _ARGS.follow_only: 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})") + # #24 v1: never replay a wallet before we actually followed it (the + # JuiceFarm class — pre-follow bets are not copyable reality). Dates + # from the follow config's own git history (live/follow_since.py). + try: + FOLLOW_SINCE = {k.lower(): int(v) for k, v in json.load( + open(os.path.join(HERE, "follow_since.json"))).items()} + except Exception: + FOLLOW_SINCE = {} +else: + FOLLOW_SINCE = {} if not WALLETS: raise SystemExit("no wallets to replay: create live/backtest.json or pass --wallets") @@ -460,6 +470,13 @@ def main(): 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"]) + if FOLLOW_SINCE: + _pre = len(stream) + stream = [b for b in stream if b["entry_t"] >= + FOLLOW_SINCE.get(b["wallet"].lower(), 0)] + if _pre != len(stream): + print(f"portfolio: follow-since dropped {_pre - len(stream)} " + f"pre-follow bets", flush=True) # ONLY_CONDS=: replay only these markets — {cond: bool} or # us_listable.py's {cond: {"listed": bool, ...}}. Models "same signal, but @@ -707,6 +724,7 @@ def main(): "entry_mode": ENTRY_MODE, "exit_mode": EXIT_MODE, "mirror_sell_min_p": MIRROR_SELL_MIN_P, "follow_only": bool(_ARGS.follow_only), + "follow_since_applied": bool(FOLLOW_SINCE), } json.dump(out, open(os.path.join(HERE, OUT) if not os.path.isabs(OUT) else OUT, "w"), separators=(",", ":")) diff --git a/research/copy_reconcile.py b/research/copy_reconcile.py index 268cf611..ab8e6b0d 100644 --- a/research/copy_reconcile.py +++ b/research/copy_reconcile.py @@ -23,6 +23,7 @@ honest. Companion input: live/portfolio_follow_bets.json (portfolio.py import json import os import sys +import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import tape # noqa: E402 @@ -77,13 +78,34 @@ def bot_views(): return v +def rejects(): + """{book: {token,...}} — the reject ledgers (live since 2026-07-24) + split UNSEEN into REJECTED (screen: the bot saw and filtered) vs + truly unseen (universe).""" + out = {"paper": set(), "live": set()} + for book, f in (("paper", "copybot_rejects.jsonl"), + ("live", "copybot_rejects.live.jsonl")): + for r in load_jsonl(f): + if r.get("token"): + out[book].add(str(r["token"])) + return out + + def main(): bets = json.load(open(os.path.join(ROOT, "live", "portfolio_follow_bets.json"))) + try: + fs = {k.lower(): int(v) for k, v in json.load(open(os.path.join( + ROOT, "live", "follow_since.json"))).items()} + nm2w = {w["name"]: w["wallet"].lower() for w in json.load(open( + os.path.join(ROOT, "live", "copybot.paper.json")))["wallets"]} + except Exception: + fs, nm2w = {}, {} bets = [b for b in bets if b.get("entry_t", 0) >= PARITY_T0 - and b.get("asset") and b.get("p")] - print(f"backtest bets in the parity era with join keys: {len(bets)}", - flush=True) + and b.get("asset") and b.get("p") + and b.get("entry_t", 0) >= fs.get(nm2w.get(b["name"], ""), 0)] + print(f"backtest bets in the parity era with join keys " + f"(follow-since applied): {len(bets)}", flush=True) db = tape.connect() tape.build_resolved(db) pays = fwd.payouts_for(db, [b["asset"] for b in bets]) @@ -100,9 +122,11 @@ def main(): print(f"chain-graded (refunds/pending excluded): {len(graded)}\n", flush=True) + rej = rejects() + row = {"date": time.strftime("%Y-%m-%d"), "n": len(graded)} for book in ("paper", "live"): f, mset = v[book]["fills"], v[book]["missed"] - buckets = {"FILLED": [], "MISSED": [], "UNSEEN": []} + buckets = {"FILLED": [], "MISSED": [], "REJECTED": [], "UNSEEN": []} pg = 0.0 for b in graded: a = b["asset"] @@ -114,17 +138,23 @@ def main(): pg += b["_ev_bt"] - ev_real elif a in mset: buckets["MISSED"].append(b) + elif a in rej[book]: + buckets["REJECTED"].append(b) # screen gap, now visible else: - buckets["UNSEEN"].append(b) + buckets["UNSEEN"].append(b) # universe gap tot_bt = sum(b["_ev_bt"] for b in graded) + row[f"{book}_bt_ev"] = round(tot_bt) print(f"== {book.upper()} (n={len(graded)} signals · backtest " f"$100-EV {tot_bt:+,.0f}) ==") - for k in ("FILLED", "MISSED", "UNSEEN"): + for k in ("FILLED", "MISSED", "REJECTED", "UNSEEN"): bs = buckets[k] ev = sum(b["_ev_bt"] for b in bs) share = 100 * ev / tot_bt if tot_bt else 0 - print(f" {k:>7}: n={len(bs):>4} · backtest EV in bucket " + row[f"{book}_{k.lower()}_n"] = len(bs) + row[f"{book}_{k.lower()}_ev"] = round(ev) + print(f" {k:>8}: n={len(bs):>4} · backtest EV in bucket " f"{ev:+9.0f} ({share:+5.1f}% of story)") + row[f"{book}_fill_gap"] = round(pg) print(f" fill-price gap on FILLED (their_p feeless -> my_p+fee): " f"{pg:+,.0f}") un = buckets["UNSEEN"] @@ -135,8 +165,60 @@ def main(): top = sorted(byw.items(), key=lambda kv: -kv[1])[:4] print(f" unseen by wallet: {top}") print(flush=True) - json.dump({"n": len(graded)}, open(os.path.join( - HERE, "copy_reconcile.json"), "w")) + # live-bankroll replica residual: the model at the live book's capital + # vs the live book itself (window-aligned numbers recorded raw) + try: + rep = json.load(open(os.path.join(ROOT, "live", + "portfolio_live_replica.json"))) + lv = json.load(open(os.path.join(ROOT, "live", + "copybot_live_real_full.json"))) + row["replica_pnl"] = round(rep.get("pnl") or 0, 2) + row["live_realized"] = round(lv.get("pnl_realized") + or lv.get("realized") or 0, 2) + print(f"REPLICA: model-at-live-bank 30d P&L {row['replica_pnl']:+.2f}" + f" vs live book realized {row['live_realized']:+.2f} " + f"(residual {row['replica_pnl'] - row['live_realized']:+.2f})", + flush=True) + except Exception: + pass + json.dump(row, open(os.path.join(HERE, "copy_reconcile.json"), "w")) + + if "--csv" in sys.argv: + import csv as _csv + path = os.path.join(ROOT, "history", "reconcile.csv") + os.makedirs(os.path.dirname(path), exist_ok=True) + rows = [] + try: + rows = list(_csv.DictReader(open(path))) + except FileNotFoundError: + pass + rows = [r for r in rows if r.get("date") != row["date"]] + # drift alarm: any bucket-EV share moved >15pp vs the trailing mean + if len(rows) >= 5: + import statistics as _st + for book in ("paper", "live"): + for k in ("filled", "missed", "rejected", "unseen"): + key = f"{book}_{k}_ev" + tot_k = f"{book}_bt_ev" + hist = [float(r[key]) / max(1, abs(float(r[tot_k]))) + for r in rows[-7:] if r.get(key) and r.get(tot_k)] + if not hist: + continue + cur = row[key] / max(1, abs(row[tot_k])) + if abs(cur - _st.mean(hist)) > 0.15: + print(f"⚠ RECONCILE DRIFT {book}.{k}: share " + f"{cur:+.0%} vs 7d mean {_st.mean(hist):+.0%}", + flush=True) + rows.append({k: str(vv) for k, vv in row.items()}) + cols = sorted({c for r in rows for c in r}, key=lambda c: + (c != "date", c)) + with open(path, "w", newline="") as fh: + w = _csv.DictWriter(fh, fieldnames=cols) + w.writeheader() + for r in rows: + w.writerow(r) + print(f"[reconcile.csv] {len(rows)} rows -> history/reconcile.csv", + flush=True) if __name__ == "__main__":