copybot: one-shot 10s re-quote retry for FAK no-match OPENs

'no orders found to match with FAK order' is the #1 miss class (13 of the
last 48h's misses) — the copy lands in the crater the sharp just swept,
before makers requote. Instead of recording the miss at the first
rejection, hand the OPEN to Copybot.fak_requote_retry: sleep fak_retry_s
(default 10, 0 disables) OUTSIDE the bot lock, then re-run the whole gated
buy path (fresh quote, price guard, depth gate) exactly once, mirroring
the webhook call site's fill drain so a retry fill books lag + bet rows.
A second rejection records the miss tagged 'twice (re-quote retry)'.
Paper-parity: the hook is installed in both modes, and PaperExecutor's
FAK model re-decides on the fresh book. tests/test_fak_retry.py covers
hook scheduling, no-double-count sizing (their_size=0 re-entry), ADD and
non-FAK bypasses, and the end-to-end retry thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jaxperro
2026-07-20 14:52:15 -04:00
parent 57b549abb0
commit 01687f4a44
5 changed files with 255 additions and 4 deletions
+47
View File
@@ -893,6 +893,9 @@ class Copybot:
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))
# FAK no-match re-quote retry wait (s); 0 disables (miss recorded at
# the first rejection, pre-2026-07-20 behavior)
self.fak_retry_s = float(cfg.get("fak_retry_s", 10))
def _backfill_lag_recent(self, window_s=86400):
"""[[ts, lag_s, slip], …] for fills in the last window_s, read from the
@@ -1147,6 +1150,48 @@ class Copybot:
log(f" ↳ untracked buy booked: {(pos.get('title') or '?')[:42]}"
f"{f['shares']:.1f}sh @ {f['price']:.3f}")
def fak_requote_retry(self, ctx):
"""One-shot re-quote retry for an OPEN whose FAK died unmatched
(2026-07-20: 13 of 48h's misses were 'no orders found to match' — the
copy arrives in the crater the sharp just swept, before makers
requote; the resolved ones were net-positive would-be P&L). Sleep
OUTSIDE the bot lock (a burst is exactly when these fire), then
re-run the whole gated buy path — fresh quote, price guard, depth
gate — exactly once (retry=True can't re-schedule). Mirrors the
webhook call site's fill drain so a retry fill books its lag + bet
rows instead of surfacing later as an untracked orphan; a second
rejection records the miss inside _handle_their_buy, tagged
'twice (re-quote retry)'. Installed on engine.on_fak_reject by
main() when cfg fak_retry_s > 0 (default 10)."""
def run():
time.sleep(self.fak_retry_s)
tok = ctx["token"]
try:
with self.lock:
self.engine._handle_their_buy(
ctx["wallet"], tok, 0.0, ctx["their_price"],
ctx["label"], ctx["title"], ctx["outcome"],
event=ctx["event"], cond=ctx["cond"],
their_ts=ctx["their_ts"], retry=True)
self.engine.persist()
if tok in self.engine.state["my_pos"] \
and tok not in self.conds and ctx["cond"]:
self.conds[tok] = ctx["cond"]
synth = {"timestamp": ctx["their_ts"],
"price": ctx["their_price"],
"outcome": ctx["outcome"], "title": ctx["title"]}
for f in self._drain_fills():
if f["token"] == tok:
self._record_lag(ctx["wallet"], synth, f)
else:
self._record_untracked_buy(f)
self.check_book()
except Exception as e:
log(f"re-quote retry error ({ctx['label'][:36]}): {e}")
log(f"FAK no-match — re-quote retry in {self.fak_retry_s:.0f}s: "
f"{ctx['label'][:48]}")
threading.Thread(target=run, daemon=True).start()
def _ledger_buy_tokens(self):
"""Tokens with a BUY line in the fills ledger — i.e. positions whose
cash demonstrably went through _drain_fills. Lines from before
@@ -2601,6 +2646,8 @@ def main():
if want_live else "")
filt = FollowFilter(cfg)
bot = Copybot(cfg, engine, filt, redeemer=redeemer)
if bot.fak_retry_s > 0: # FAK crater misses get one re-quote retry
engine.on_fak_reject = bot.fak_requote_retry
# single-writer invariant (audit 3.4): every boot stamps the state; a
# stale process that survived `machine stop` (gotcha 15c) sees a newer
# boot on origin at publish time and yields instead of overwriting it.