From 01687f4a446cea4f3cae3e8a06a06482eaa0327d Mon Sep 17 00:00:00 2001 From: jaxperro Date: Mon, 20 Jul 2026 14:52:15 -0400 Subject: [PATCH] copybot: one-shot 10s re-quote retry for FAK no-match OPENs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '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 --- HANDOFF.md | 4 +- config.live.example.json | 1 + copybot.py | 47 +++++++++++ copytrade.py | 29 ++++++- tests/test_fak_retry.py | 178 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 tests/test_fak_retry.py diff --git a/HANDOFF.md b/HANDOFF.md index 88f31d51..37cb8797 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -40,7 +40,9 @@ push through. 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. 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 + crypto ticks, ~8M events/day, dual-socket ~99.9% capture, 25GB volume, NOTHING deleted until the Mac verifiably ingests it) → nightly → diff --git a/config.live.example.json b/config.live.example.json index 9e263223..b59104d4 100644 --- a/config.live.example.json +++ b/config.live.example.json @@ -4,6 +4,7 @@ "bankroll_usd": 66.42, "bankroll_pct": 0.04, "price_guard_abs": 0.05, + "fak_retry_s": 10, "taker_fee_rate": 0.03, "discord_webhook": "", "follow": { diff --git a/copybot.py b/copybot.py index ddf1ca92..f4259905 100644 --- a/copybot.py +++ b/copybot.py @@ -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. diff --git a/copytrade.py b/copytrade.py index 34ec4dcb..5dc6eedf 100644 --- a/copytrade.py +++ b/copytrade.py @@ -276,6 +276,10 @@ class CopyTrader: self.seen = _SeenTx((tx, i) for i, tx in enumerate(state["seen_tx"])) self.webhook = cfg.get("discord_webhook", "") 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 -- def log(self, msg): @@ -447,7 +451,10 @@ class CopyTrader: def _handle_their_buy(self, wallet, token, their_size, their_price, 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) is_add = mine is not None # 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]}…)") self.persist() 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 + tag = " twice (re-quote retry)" if retry else "" 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 spent = res["filled_shares"] * res["price"] self.state["spend"]["usd"] += spent diff --git a/tests/test_fak_retry.py b/tests/test_fak_retry.py new file mode 100644 index 00000000..5a4ad351 --- /dev/null +++ b/tests/test_fak_retry.py @@ -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)