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
+3 -1
View File
@@ -40,7 +40,9 @@ push through.
6-wallet Set E rev 4, 4% of working equity/bet, alarm-free after the 6-wallet Set E rev 4, 4% of working equity/bet, alarm-free after the
07-19 reconcile. **wwf-copybot** (paper $1k): same set, FAK-parity fills. 07-19 reconcile. **wwf-copybot** (paper $1k): same set, FAK-parity fills.
Both on the audit-hardened build (locks, chain-gated sweep, boot-id Both on the audit-hardened build (locks, chain-gated sweep, boot-id
single-writer guard, TLS'd user-ws — HANDOFF_ARCHIVE rev 16). single-writer guard, TLS'd user-ws — HANDOFF_ARCHIVE rev 16). 2026-07-20:
FAK no-match OPENs get one 10s re-quote retry on both bots (`fak_retry_s`,
paper-parity; misses from a second rejection tag "twice (re-quote retry)").
- **wwf-recorder**: the FULL firehose (trades + order matches + comments - **wwf-recorder**: the FULL firehose (trades + order matches + comments
+ crypto ticks, ~8M events/day, dual-socket ~99.9% capture, 25GB volume, + crypto ticks, ~8M events/day, dual-socket ~99.9% capture, 25GB volume,
NOTHING deleted until the Mac verifiably ingests it) → nightly → NOTHING deleted until the Mac verifiably ingests it) → nightly →
+1
View File
@@ -4,6 +4,7 @@
"bankroll_usd": 66.42, "bankroll_usd": 66.42,
"bankroll_pct": 0.04, "bankroll_pct": 0.04,
"price_guard_abs": 0.05, "price_guard_abs": 0.05,
"fak_retry_s": 10,
"taker_fee_rate": 0.03, "taker_fee_rate": 0.03,
"discord_webhook": "", "discord_webhook": "",
"follow": { "follow": {
+47
View File
@@ -893,6 +893,9 @@ class Copybot:
if "lag_recent" not in engine.state: if "lag_recent" not in engine.state:
engine.state["lag_recent"] = self._backfill_lag_recent() engine.state["lag_recent"] = self._backfill_lag_recent()
self.fee_rate = float(cfg.get("taker_fee_rate", TAKER_FEE_RATE)) 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): def _backfill_lag_recent(self, window_s=86400):
"""[[ts, lag_s, slip], …] for fills in the last window_s, read from the """[[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]}" log(f" ↳ untracked buy booked: {(pos.get('title') or '?')[:42]}"
f"{f['shares']:.1f}sh @ {f['price']:.3f}") 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): def _ledger_buy_tokens(self):
"""Tokens with a BUY line in the fills ledger — i.e. positions whose """Tokens with a BUY line in the fills ledger — i.e. positions whose
cash demonstrably went through _drain_fills. Lines from before cash demonstrably went through _drain_fills. Lines from before
@@ -2601,6 +2646,8 @@ def main():
if want_live else "") if want_live else "")
filt = FollowFilter(cfg) filt = FollowFilter(cfg)
bot = Copybot(cfg, engine, filt, redeemer=redeemer) 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 # single-writer invariant (audit 3.4): every boot stamps the state; a
# stale process that survived `machine stop` (gotcha 15c) sees a newer # stale process that survived `machine stop` (gotcha 15c) sees a newer
# boot on origin at publish time and yields instead of overwriting it. # boot on origin at publish time and yields instead of overwriting it.
+26 -3
View File
@@ -276,6 +276,10 @@ class CopyTrader:
self.seen = _SeenTx((tx, i) for i, tx in enumerate(state["seen_tx"])) self.seen = _SeenTx((tx, i) for i, tx in enumerate(state["seen_tx"]))
self.webhook = cfg.get("discord_webhook", "") self.webhook = cfg.get("discord_webhook", "")
self._discord_warned = False self._discord_warned = False
# host-installed hook (copybot.Copybot.fak_requote_retry): called with
# the copy context when an OPEN's FAK dies unmatched, instead of
# recording the miss immediately. None = old behavior (miss at once).
self.on_fak_reject = None
# -- helpers -- # -- helpers --
def log(self, msg): def log(self, msg):
@@ -447,7 +451,10 @@ class CopyTrader:
def _handle_their_buy(self, wallet, token, their_size, their_price, def _handle_their_buy(self, wallet, token, their_size, their_price,
label, title, outcome, event=None, cond=None, label, title, outcome, event=None, cond=None,
their_ts=None): their_ts=None, retry=False):
# retry=True: re-entry from the host's FAK re-quote retry. The caller
# passes their_size=0 — their_pos already counts the copied trade, so
# passing the size again would double-count their stake in the ceiling.
mine = self.state["my_pos"].get(token) mine = self.state["my_pos"].get(token)
is_add = mine is not None is_add = mine is not None
# the signal's position in this token BEFORE this trade — the their-bet # the signal's position in this token BEFORE this trade — the their-bet
@@ -593,10 +600,26 @@ class CopyTrader:
f"order {str(res['pending'].get('order_id'))[:14]}…)") f"order {str(res['pending'].get('order_id'))[:14]}…)")
self.persist() self.persist()
return return
self.log(f"{kind} {label} — ORDER FAILED: {res.get('resp')}") resp_s = str(res.get("resp"))
self.log(f"{kind} {label} — ORDER FAILED: {resp_s}")
# FAK no-match = the copy landed in the crater the sharp just
# swept (see PaperExecutor) — makers usually restore the ask side
# within seconds. Hand a first-attempt OPEN to the host's one-shot
# re-quote retry instead of recording the miss now; the retry
# re-runs this whole gated path (fresh quote, price guard, depth
# gate) exactly once, and its failure lands in the branch below.
if (not retry and not is_add and self.on_fak_reject is not None
and "no orders found to match" in resp_s):
self.on_fak_reject({
"wallet": wallet, "token": token,
"their_price": their_price, "label": label,
"title": title, "outcome": outcome,
"event": event, "cond": cond, "their_ts": their_ts})
return
if not is_add: # a rejected OPEN is a missed bet if not is_add: # a rejected OPEN is a missed bet
tag = " twice (re-quote retry)" if retry else ""
self.record_miss(wallet, token, cond, title, outcome, price, self.record_miss(wallet, token, cond, title, outcome, price,
allowed, f"order rejected: {str(res.get('resp'))[:60]}") allowed, f"order rejected{tag}: {resp_s[:60]}")
return return
spent = res["filled_shares"] * res["price"] spent = res["filled_shares"] * res["price"]
self.state["spend"]["usd"] += spent self.state["spend"]["usd"] += spent
+178
View File
@@ -0,0 +1,178 @@
"""Tests for the FAK no-match re-quote retry (2026-07-20): a first-attempt
OPEN whose FAK dies unmatched is handed to Copybot.fak_requote_retry instead
of recording a miss; the retry re-enters _handle_their_buy(retry=True) with
their_size=0 once, and only a SECOND rejection records the miss (tagged).
Run: python3 tests/test_fak_retry.py needs no network, no config.
"""
import os
import sys
import tempfile
import time
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
import copybot # noqa: E402
import copytrade # noqa: E402
_TMP = tempfile.mkdtemp(prefix="copybot_test_")
FAK_RESP = "exception: no orders found to match with FAK order. FAK orders..."
WALLET = "0xabc"
TOK = "T1"
fails = []
def case(name):
def deco(fn):
cp0, bd0 = copytrade.clob_price, copybot.book_depth
try:
fn()
print(f"PASS {name}")
except AssertionError as e:
print(f"FAIL {name}: {e}")
fails.append(name)
finally:
copytrade.clob_price, copybot.book_depth = cp0, bd0
return deco
class ScriptedEx:
"""Returns the scripted buy responses in order; repeats the last one."""
live = False
def __init__(self, *resps):
self.resps = list(resps)
self.fills = []
self.calls = 0
def buy(self, token_id, shares, price, meta):
self.calls += 1
r = self.resps.pop(0) if len(self.resps) > 1 else self.resps[0]
if r == "fak":
return {"ok": False, "filled_shares": 0.0, "price": price,
"resp": FAK_RESP, "paper": True}
if r == "other":
return {"ok": False, "filled_shares": 0.0, "price": price,
"resp": "some other rejection", "paper": True}
self.fills.append({"side": "BUY", "token": token_id,
"shares": shares, "price": price})
return {"ok": True, "filled_shares": shares, "price": price,
"paper": True}
def mkengine(*resps):
cfg = {"bankroll_usd": 100.0, "bankroll_pct": 0.04,
"watch": [{"wallet": WALLET, "name": "TestSharp"}],
"feed_path": os.path.join(_TMP, "feed.json"),
"fill_log": os.path.join(_TMP, "fills.jsonl"),
"risk": {"min_price": 0.02, "max_price": 0.99,
"max_open_positions": 50, "min_order_usd": 1.0,
"max_trade_usd": 100.0, "daily_spend_cap_usd": 1000.0,
"max_total_exposure_usd": 1000.0}}
state = copytrade.new_state()
state["cash"] = 100.0
eng = copytrade.CopyTrader(cfg, state, ScriptedEx(*resps),
os.path.join(_TMP, "state.json"))
copytrade.clob_price = lambda t, s: 0.50
return eng
def their_buy(eng, retry=False, their_size=100.0):
eng.state["their_pos"].setdefault(WALLET, {})
if retry: # host passes 0 — their_pos already counts it
eng.state["their_pos"][WALLET][TOK] = their_size
their_size = 0.0
eng._handle_their_buy(WALLET, TOK, their_size, 0.50, "Yes · Test market",
"Test market", "Yes", event="ev-1", cond="0xc0nd",
their_ts=123, retry=retry)
@case("first FAK reject on an OPEN -> hook fires with the copy ctx, NO miss")
def t1():
eng = mkengine("fak")
calls = []
eng.on_fak_reject = calls.append
their_buy(eng)
assert len(calls) == 1, f"hook calls: {len(calls)}"
ctx = calls[0]
assert ctx["token"] == TOK and ctx["wallet"] == WALLET
assert ctx["their_price"] == 0.50 and ctx["cond"] == "0xc0nd"
assert not eng.state.get("missed"), eng.state.get("missed")
assert TOK not in eng.state["my_pos"]
@case("retry=True rejection -> miss recorded 'twice (re-quote retry)', no re-schedule")
def t2():
eng = mkengine("fak")
calls = []
eng.on_fak_reject = calls.append
their_buy(eng, retry=True)
assert not calls, "retry must never re-schedule"
m = eng.state["missed"]
assert len(m) == 1 and "twice (re-quote retry)" in m[0]["reason"], m
@case("no hook installed -> miss recorded at once (pre-change behavior)")
def t3():
eng = mkengine("fak")
their_buy(eng)
m = eng.state["missed"]
assert len(m) == 1 and m[0]["reason"].startswith("order rejected: "), m
@case("non-FAK rejection -> no hook, miss recorded at once")
def t4():
eng = mkengine("other")
calls = []
eng.on_fak_reject = calls.append
their_buy(eng)
assert not calls
assert len(eng.state["missed"]) == 1
@case("FAK reject on an ADD -> no hook, no miss (adds were never miss-tracked)")
def t5():
eng = mkengine("fak")
calls = []
eng.on_fak_reject = calls.append
eng.state["my_pos"][TOK] = {"shares": 2.0, "cost": 1.0,
"title": "Test market", "outcome": "Yes"}
eng.state["their_pos"][WALLET] = {TOK: 100.0}
eng._handle_their_buy(WALLET, TOK, 100.0, 0.50, "Yes · Test market",
"Test market", "Yes")
assert not calls and not eng.state.get("missed")
@case("retry fill sizes off their FULL stake (their_size=0, no double count)")
def t6():
eng = mkengine("ok")
their_buy(eng, retry=True, their_size=5.0) # their whole bet: 5 shares
pos = eng.state["my_pos"].get(TOK)
assert pos, "retry fill should open the position"
# stake = min(4% × $100 equity, their 5) = $4, not capped by a doubled 10
assert abs(pos["cost"] - 4.0) < 1e-6, pos
@case("end-to-end: Copybot retry thread re-buys, books bet + lag, no miss")
def t7():
eng = mkengine("fak", "ok") # first buy dies, retry fills
bot = copybot.Copybot(eng.cfg, eng, filt=None)
bot.here = ""
bot.check_book = lambda: None # network-touching, not under test
copybot.book_depth = lambda t: None # _record_lag's book snapshot
bot.fak_retry_s = 0.05
eng.on_fak_reject = bot.fak_requote_retry
their_buy(eng) # reject -> schedules the retry
assert TOK not in eng.state["my_pos"]
time.sleep(1.0) # let the daemon thread run
pos = eng.state["my_pos"].get(TOK)
assert pos and pos["shares"] > 0, "retry did not fill"
assert eng.ex.calls == 2, f"buy attempts: {eng.ex.calls}"
b = eng.state["bets"].get(TOK)
assert b and b["status"] == "open" and b["their_price"] == 0.50, b
assert not eng.ex.fills, "fill left undrained"
assert not eng.state.get("missed"), eng.state["missed"]
print()
print("FAILURES:", fails or "none")
sys.exit(1 if fails else 0)