copybot: reconcile_entries — downtime entries become missed bets, not silence

The backtest reads positions (state); the bot reads trades (events). A trade
during downtime was silently baselined away at the next boot — not copied,
not missed, invisible — while the backtest still showed it (2026-07-07:
9 ArbTrader positions opened into the Fly trial-expiry crash-loop appeared
only in portfolio.json's current list). Now every boot + backstop poll
records any unseeded, unheld, unrecorded position that clears the follow
filter as a missed bet (reason 'bot offline'), settled hypothetically like
every other miss. Never copied — the entry is stale by definition. Dust
below risk.min_order_usd is ignored; API failure skips (silence is never
data). Bets that open AND close entirely within downtime stay the
backtest's domain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jaxperro
2026-07-07 02:10:21 -04:00
parent 4941818d51
commit 327513d185
+52
View File
@@ -741,6 +741,53 @@ class Copybot:
self._drain_fills() # book the sell's cash + sold-leg
self.engine.persist()
def reconcile_entries(self):
"""Entries the signal made while we weren't listening — the mirror of
reconcile_exits. Every boot baselines history (no retro-copying), so a
trade during downtime vanishes: not copied, not even MISSED. The
backtest reads positions (state) while the bot reads trades (events),
so downtime opens showed up only in the backtest — that asymmetry is
how this hole was found (2026-07-07: 9 ArbTrader positions opened into
a dead listener during the Fly trial-expiry crash-loop). For each
followed wallet: any CURRENT position that (a) isn't seeded, held, in
bet history, or already recorded, and (b) clears the follow filter,
becomes a missed bet — reason 'bot offline'. Never copied: the entry
is stale by definition; record the truth and settle it hypothetically."""
with self.lock:
st = self.engine.state
bets = st.get("bets", {})
missed_toks = {m["token"] for m in st.get("missed", [])}
for w in self.cfg.get("watchlist", []):
ps = sm.get_json("/positions", {"user": w, "limit": 500,
"sizeThreshold": 0})
if ps is None:
continue # API failure — retry next pass
seeded = set(st.get("seed_tokens", {}).get(w, []))
name = self.names.get(w.lower(), w[:10])
for p in ps:
tok = p.get("asset")
if (not tok or tok in seeded or tok in st["my_pos"]
or tok in bets or tok in missed_toks):
continue
iv = p.get("initialValue") or 0
if iv < self.engine.risk.get("min_order_usd", 5.0):
continue # literal dust — not a signal
trade = {"side": "BUY", "usdcSize": iv,
"price": p.get("avgPrice", 0) or 0,
"outcome": p.get("outcome"), "title": p.get("title")}
ok, _ = self.filt.check(w, trade)
if not ok:
continue
want = self.engine.stake_usd(w, iv)
self.engine.record_miss(
w, tok, p.get("conditionId"), p.get("title") or "",
p.get("outcome") or "", trade["price"], want,
"bot offline (entered while down)")
missed_toks.add(tok)
log(f"reconcile: {name} entered {(p.get('title') or '?')[:42]} "
f"while we weren't listening — recorded as missed")
self.engine.persist()
def settle_resolved(self):
"""Free capital like the dashboard: when an open position's market has
resolved, settle it at the winner price (1/0), recycle the cash, and tally
@@ -1055,6 +1102,7 @@ def main():
return
bot.settle_resolved()
bot.reconcile_exits()
bot.reconcile_entries()
for w in cfg.get("watchlist", []):
bot.on_wallet_activity(w)
bot.summary(0)
@@ -1069,6 +1117,7 @@ def main():
if args.poll:
bot.baseline()
bot.reconcile_exits() # catch exits made while we were down
bot.reconcile_entries() # ...and entries: record them as missed
log(f"poll mode · every {args.poll}s · Ctrl-C to stop")
bot.write_feed() # publish an initial "online" snapshot
bot.publish_feed()
@@ -1078,6 +1127,7 @@ def main():
bot.settle_resolved() # recycle capital at resolution
if cycle % 5 == 0:
bot.reconcile_exits()
bot.reconcile_entries()
for w in cfg.get("watchlist", []):
bot.on_wallet_activity(w)
cycle += 1
@@ -1094,6 +1144,7 @@ def main():
port = int(os.environ.get("PORT", 8080))
bot.baseline()
bot.reconcile_exits() # catch exits made while we were down
bot.reconcile_entries() # ...and entries: record them as missed
# webhook mode is event-driven, but the book must not depend on the next
# push arriving: a heartbeat thread settles resolved positions, refreshes
@@ -1109,6 +1160,7 @@ def main():
bot.settle_resolved()
if cycle % 5 == 0:
bot.reconcile_exits()
bot.reconcile_entries()
for w in cfg.get("watchlist", []):
bot.on_wallet_activity(w)
bot.summary(cycle)