CRITICAL: fix their_prev crash that silently killed all new opens since 07-05

_handle_their_buy referenced their_prev in the fresh-OPEN branch but only
assigned it in the is_add branch — every new position crashed with
UnboundLocalError the moment the their-bet ceiling landed 2026-07-06. The
webhook/heartbeat try/except swallowed it as 'handler error', so the bot
logged FOLLOW then silently placed NOTHING (last real fill 07-05 23:17;
live Fly logs show FOLLOW ArbTrader ... -> heartbeat error: their_prev).
This is the primary cause of 'live bot missing bets'. Fix: hoist their_prev
above the if/else so both branches see the signal's prior position.

Also (the user's two asks):
- copybot on_wallet_activity: a QUALIFYING bet we were too slow to catch
  (webhook missed / bot down / fast market resolved before we polled) is now
  recorded as a missed bet 'too slow to follow (Nm late)' instead of silently
  dropped; below-floor dust stays console-only. Filter runs before the stale
  gate so we know if it would have qualified.
- sync_floors.py rewritten: PIN each paper-bot wallet's floor to the trusted
  cache p80 (identical to the backtest's conv_thr), written to
  copybot.paper.json, wired into daily.sh + committed. Kills the boot-time
  data-api floor drift (fortuneking $1,498 boot vs $892 backtest) that made
  the live bot filter out bets the backtest kept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jaxperro
2026-07-07 11:33:36 -04:00
parent c002c45e5b
commit 972b32e8f0
4 changed files with 91 additions and 43 deletions
+7 -1
View File
@@ -407,6 +407,13 @@ class CopyTrader:
label, title, outcome, event=None, cond=None):
mine = self.state["my_pos"].get(token)
is_add = mine is not None
# the signal's position in this token BEFORE this trade — the their-bet
# ceiling needs it in BOTH branches (their_prev + their_size = their
# total stake). Defining it only inside the is_add branch made every
# fresh OPEN crash with UnboundLocalError since the ceiling landed
# 2026-07-06 — swallowed as a webhook "handler error", so the bot
# silently placed NO new positions (last fill 2026-07-05, found 07-07).
their_prev = self.state["their_pos"].get(wallet, {}).get(token, 0)
# don't backfill: never open a position they already held when we
# started watching. (A position we built during the run is an ADD;
# a brand-new position they opened after start is a fresh OPEN.)
@@ -445,7 +452,6 @@ class CopyTrader:
# game to 2.15 stakes ($90 on a $42-stake book) when fortuneking
# doubled into his own bet; the backtest is one-market-one-stake,
# so the bot must be too.
their_prev = self.state["their_pos"].get(wallet, {}).get(token, 0)
frac = their_size / their_prev if their_prev > 0 else 0
want_shares = mine["shares"] * frac
room = self.stake_usd(wallet, their_prev + their_size) - mine["cost"]
+24 -3
View File
@@ -689,10 +689,31 @@ class Copybot:
tx = t.get("transactionHash")
if not tx or tx in self.engine.seen or tx in self.skipped:
continue
if not ignore_stale and time.time() - t.get("timestamp", 0) > RECENT_TRADE_WINDOW_S:
self.skipped.add(tx) # stale — the webhook is about a newer tx
continue
# filter FIRST (before the stale gate) so we know whether a trade we
# were too slow on WOULD have qualified — a qualifying miss is worth
# recording; a below-floor/out-of-band one is a deliberate skip.
follow, reason = self.filt.check(wallet, t)
stale = not ignore_stale and time.time() - t.get("timestamp", 0) > RECENT_TRADE_WINDOW_S
if follow and stale:
# a bet we'd have copied but didn't catch in time — webhook missed
# it, the bot was down, or the market resolved faster than we poll
# (5-min crypto, in-play). Log it as MISSED so it's visible on the
# dashboard instead of silently dropped; settle_resolved values it
# hypothetically like every other miss. record_miss dedups by token
# so reconcile_entries can't double-count the same position.
self.skipped.add(tx)
if t.get("side") == "BUY" and t.get("asset"):
late_m = (time.time() - (t.get("timestamp") or 0)) / 60.0
with self.lock:
self.engine.record_miss(
wallet, t["asset"], t.get("conditionId"),
t.get("title") or "", t.get("outcome") or "",
t.get("price") or 0, self.engine.stake_usd(wallet),
f"too slow to follow ({late_m:.0f}m late)")
self.engine.persist()
log(f"MISS {name}: {t.get('side')} {t.get('outcome','?')} "
f"@ {t.get('price',0):.3f} — too slow ({late_m:.0f}m late)")
continue
if not follow:
self.skipped.add(tx)
log(f"skip {name}: {t.get('side')} {t.get('outcome','?')} "
+8 -7
View File
@@ -9,10 +9,11 @@
# 4) sharps — conviction-profile scan + last-minute timing gate ->
# conviction_wallets.json + watch_sharps.json (the set the live
# trading dashboard reads via raw.githubusercontent)
# 5) floors — recompute the copy bot's per-wallet p80 conviction floors from
# the (now-fresh) cache into ../config.json, so copybot.py stays
# in parity with the dashboard's top-20%-by-stake gate. Local-only
# (config.json is gitignored); never touches the curated watchlist.
# 5) floors — pin the live paper bot's per-wallet p80 conviction floors
# (trusted-cache p80, same as the backtest) into
# copybot.paper.json, so the bot and the backtest gate on the
# SAME threshold and boots are deterministic (no per-boot
# data-api drift). Committed in the publish step.
# 6) dashboard — regenerate + snapshot for auditable forward history
# 7) publish — commit + push the refreshed outputs so the live dashboard
# (jaxperro.com/trading) picks up the new sharp list
@@ -49,8 +50,8 @@ python3 skill.py
echo "[daily] $(date '+%F %T') 4/7 sharps: conviction scan + last-minute timing gate"
python3 conviction_scan.py
python3 validate_timing.py
echo "[daily] $(date '+%F %T') 5/7 floors: recompute copy-bot p80 conviction floors -> ../config.json"
python3 sync_floors.py || echo "[daily] floor sync skipped (no config/watchlist)"
echo "[daily] $(date '+%F %T') 5/7 floors: pin copy-bot p80 conviction floors -> copybot.paper.json"
python3 sync_floors.py || echo "[daily] floor sync skipped"
echo "[daily] $(date '+%F %T') portfolio: cache-based \$1k book -> portfolio.json"
python3 portfolio.py || echo "[daily] portfolio skipped"
echo "[daily] $(date '+%F %T') 6/7 dashboard"
@@ -58,7 +59,7 @@ python3 dashboard.py
mkdir -p history && cp watch_skilled.json "history/watch_$(date '+%Y%m%d').json" 2>/dev/null
echo "[daily] $(date '+%F %T') 7/7 publish (commit + push refreshed outputs)"
PUBLISH="no changes"
git add watch_skilled.json watch_sharps.json conviction_wallets.json dashboard.html portfolio.json 2>/dev/null
git add watch_skilled.json watch_sharps.json conviction_wallets.json dashboard.html portfolio.json copybot.paper.json 2>/dev/null
if git diff --cached --quiet 2>/dev/null; then
echo "[daily] no output changes to publish"
elif git commit -q -m "live: daily refresh — skilled + sharp wallets [skip ci]"; then
+52 -32
View File
@@ -1,53 +1,73 @@
#!/usr/bin/env python3
"""Recompute the copy bot's per-wallet p80 conviction floors from the cache.
"""Pin the live paper bot's per-wallet conviction floors to the backtest's.
Keeps config.json's `follow.per_wallet_min_usd` in exact parity with the
dashboard's "top 20% by stake" gate as the watched wallets keep trading — using
the same cache.conv_cutoff() (p80) over each wallet's own bet sizes that the
pipeline and trading/index.html use. Only the *floors* are rewritten; the
curated watchlist itself is never touched.
PARITY FIX (2026-07-07): the bot used to derive floors AT BOOT from the
data-api's most recent ~500 positions (copybot.derive_floor). The backtest
(portfolio.py) uses the TRUSTED cache p80 over ~180 days. When a wallet's
recent bets ran bigger than its long-term norm the two diverged hard
fortuneking's boot floor hit $1,498 vs the backtest's $892, so the live bot
silently filtered out conviction bets the backtest counted (and the floor
drifted every restart). This writes the backtest's floor as a PINNED
`floor` on every wallet in copybot.paper.json, so both books gate on the
same threshold and boots are deterministic.
Reads the wallet set from config.json's "watchlist" (falls back to "watch"), so
if you re-curate the portfolio the floors follow automatically. config.json is
gitignored, so this stays local and is never committed.
Uses the exact backtest method: trusted_wallet_rows (trust.py) deduped to
one row per market (largest stake), then cache.conv_cutoff (p80). Wired into
daily.sh after portfolio.py (cache warm, floors match that run); the publish
step commits copybot.paper.json so the next bot restart picks them up.
python3 sync_floors.py # run standalone; also wired into daily.sh
python3 sync_floors.py # writes live/copybot.paper.json floors
"""
import json
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cache # noqa: E402 — local bet cache + conv_cutoff (p80)
import cache # noqa: E402
import trust # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
CFG = os.path.join(HERE, "..", "config.json")
PAPER = os.path.join(HERE, "copybot.paper.json")
def trusted_p80(wallet, now=None):
"""The wallet's conviction floor = p80 of its TRUSTED bet stakes, one row
per market (largest stake) identical to portfolio.py's conv_thr."""
now = int(now or time.time())
rows = trust.trusted_wallet_rows(cache.query, wallet, now)
best = {}
for cond, asset, won, p, res_t, size in rows:
if cond not in best or size > best[cond]:
best[cond] = size
return cache.conv_cutoff(best.values())
def main():
if not os.path.exists(CFG):
print("[floors] no ../config.json — nothing to do")
cfg = json.load(open(PAPER))
ws = cfg.get("wallets") or []
if not ws:
print("[floors] copybot.paper.json has no wallets — nothing to do")
return
cfg = json.load(open(CFG))
wallets = cfg.get("watchlist") or [w["wallet"] for w in cfg.get("watch", [])]
if not wallets:
print("[floors] config has no watchlist — nothing to do")
return
floors = {}
for w in wallets:
sizes = [b["size"] for b in cache.get_bets(w) if b.get("size")]
p80 = cache.conv_cutoff(sizes) # the dashboard's top-20% threshold
if p80 != float("inf"): # skip wallets with no sized bets
floors[w.lower()] = round(p80, 2)
cfg.setdefault("follow", {})["per_wallet_min_usd"] = floors
tmp = CFG + ".tmp"
trust.ensure_cons(cache.query)
now = int(time.time())
changed = []
for w in ws:
p80 = trusted_p80(w["wallet"], now)
if p80 == float("inf"):
continue # no trusted sized bets — leave as-is
new = round(p80, 2)
old = w.get("floor")
w["floor"] = new
if old is None or abs((old or 0) - new) > 0.5:
changed.append((w.get("name", w["wallet"][:8]), old, new))
tmp = PAPER + ".tmp"
json.dump(cfg, open(tmp, "w"), indent=2)
os.replace(tmp, CFG) # atomic write
print(f"[floors] {len(floors)} p80 conviction floors updated: " +
", ".join(f"{w[:8]}…=${v:g}" for w, v in floors.items()))
os.replace(tmp, PAPER)
print(f"[floors] pinned {len(ws)} trusted-p80 floors to copybot.paper.json"
+ (f"; changed: " + ", ".join(f"{n} {o}{v:g}" for n, o, v in changed)
if changed else " (no material change)"))
if __name__ == "__main__":