copybot: exec-lag/slippage report a 24h rolling window, not lifetime avg

The heartbeat/dashboard 'avg lag' was a lifetime accumulator that mixed the
retired 60s poll era into push mode, so a genuinely ~3s bot read as 48s.
lag_recent now keeps the last 24h of (ts, lag, slip); summary + feed report
the 24h average (n = lifetime copies, n24 = fills in window). Backfilled from
copybot_fills.jsonl on boot so it's populated immediately. Verified: 24h view
12s vs 48s lifetime — reflects real push execution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jaxperro
2026-07-07 22:35:30 -04:00
parent 31c9931e57
commit 0c58ed8152
+54 -5
View File
@@ -371,8 +371,32 @@ class Copybot:
engine.state.setdefault("cash", cfg["bankroll_usd"]) # free cash (recycles on sell/resolution)
engine.state.setdefault("lag", {"n": 0, "sum_s": 0.0, "sum_slip_pct": 0.0})
engine.state.setdefault("fees_paid", 0.0)
# 24h rolling lag window — backfill from the fills ledger on first boot
# after the rolling-window change (older state has 'lag' but no 'lag_recent')
if "lag_recent" not in engine.state:
engine.state["lag_recent"] = self._backfill_lag_recent()
self.fee_rate = float(cfg.get("taker_fee_rate", TAKER_FEE_RATE))
def _backfill_lag_recent(self, window_s=86400):
"""[[ts, lag_s, slip], …] for fills in the last window_s, read from the
fills ledger — seeds the rolling avg so it's populated immediately."""
out = []
now = time.time()
try:
with open(os.path.join(self.here, self.fill_log)) as fh:
for line in fh:
try:
r = json.loads(line)
except Exception:
continue
ts, lag = r.get("ts"), r.get("detect_lag_s")
if ts and lag is not None and ts >= now - window_s:
out.append([round(ts, 1), round(lag, 1),
round(r.get("slippage_pct") or 0, 5)])
except FileNotFoundError:
pass
return out
def _drain_fills(self):
"""Apply cash flows from any fills the engine just made; return the BUY
fills so the caller can log lag/slippage against the source trade.
@@ -460,9 +484,16 @@ class Copybot:
pass
if detect_s is not None:
lag = self.engine.state["lag"]
lag["n"] += 1
lag["n"] += 1 # lifetime copy count (kept for the total)
lag["sum_s"] += detect_s
lag["sum_slip_pct"] += slip_pct
# rolling 24h window so the reported avg reflects CURRENT execution
# (push mode ~3s), not a lifetime average dragged up by the retired
# 60s poll era (which made a 3s bot read as 48s)
rec24 = self.engine.state.setdefault("lag_recent", [])
rec24.append([round(now, 1), round(detect_s, 1), round(slip_pct, 5)])
cutoff = now - 86400
self.engine.state["lag_recent"] = [r for r in rec24 if r[0] >= cutoff]
# record the placed bet for the live dashboard feed. AGGREGATE across
# fills: an ADD to an existing open position must accumulate shares/
# cost/fees, not overwrite the record with just the last fill — that
@@ -557,9 +588,12 @@ class Copybot:
"open_count": len(mp),
"fees_paid": round(st.get("fees_paid", 0.0), 2),
"fee_rate": self.fee_rate,
"lag": {"n": lag.get("n", 0),
"avg_s": round(lag["sum_s"] / lag["n"], 1) if lag.get("n") else None,
"avg_slip_pct": round(lag["sum_slip_pct"] / lag["n"], 4) if lag.get("n") else None},
# avg_s / avg_slip_pct are the 24h ROLLING view (current execution);
# n is the lifetime copy count, n24 the fills in the window
"lag": (lambda t: {"n": lag.get("n", 0), "n24": t[0],
"avg_s": round(t[1], 1) if t[1] is not None else None,
"avg_slip_pct": round(t[2], 4) if t[2] is not None else None,
"window_h": 24})(self.lag_24h()),
"wallets": [w.get("name", w["wallet"][:10]) for w in self.cfg.get("watch", [])],
"classes": {w.get("name", w["wallet"][:10]): self.engine.wallet_class(w["wallet"])
for w in self.cfg.get("watch", [])},
@@ -676,6 +710,17 @@ class Copybot:
return None
return None if bal is None else self.engine.state.get("cash", 0) - bal
def lag_24h(self):
"""(count, avg_lag_s, avg_slip_pct) over fills in the last 24h — the
CURRENT-execution view. The lifetime average buried push mode's ~3s
under the retired 60s poll era (a 3s bot read as 48s)."""
now = time.time()
rec = [r for r in self.engine.state.get("lag_recent", []) if r[0] >= now - 86400]
if not rec:
return 0, None, None
n = len(rec)
return n, sum(r[1] for r in rec) / n, sum(r[2] for r in rec) / n
def summary(self, cycle):
bank = self.cfg["bankroll_usd"]
stake = self.engine.stake_usd() # dynamic: pct of current equity
@@ -687,7 +732,11 @@ class Copybot:
n = len(self.engine.state["my_pos"])
lag = self.engine.state.get("lag", {})
lagstr = ""
if lag.get("n"):
n24, avg_s, avg_slip = self.lag_24h()
if n24:
lagstr = (f" · {lag.get('n', 0)} copies · 24h lag {avg_s:.0f}s "
f"slip {avg_slip:+.1%} ({n24})")
elif lag.get("n"):
lagstr = (f" · {lag['n']} copies avg lag {lag['sum_s']/lag['n']:.0f}s "
f"slip {lag['sum_slip_pct']/lag['n']:+.1%}")
bankstr = f" · banked ${reserve:,.0f}" if reserve else ""