From 646139dfa300af0af7984e95d5656de5c1dc9b28 Mon Sep 17 00:00:00 2001 From: jaxperro Date: Wed, 15 Jul 2026 23:48:56 -0400 Subject: [PATCH] paper FAK parity: no ask inside the protected band -> honest miss, not a pretend fill Co-Authored-By: Claude Fable 5 --- HANDOFF.md | 23 ++++++++- copybot.py | 5 +- copytrade.py | 31 +++++++++++- tests/test_paper_fak.py | 104 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 tests/test_paper_fak.py diff --git a/HANDOFF.md b/HANDOFF.md index 85bddeb1..950dd28d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,4 +1,4 @@ -# Session handoff — 2026-07-14 (rev 12: chain-seed — fills decoded from the tx receipt) +# Session handoff — 2026-07-15 (rev 13: paper FAK parity; rev 12: chain-seed) ## Operating boundary set by the user (2026-07-13, "away for a while") **Full autonomy on BOTH bots**; real-money bot **stays ARMED**. Never touch @@ -7,6 +7,27 @@ the private key, never raise/loosen caps, never rotate the Discord webhook DISARM the live bot (`flyctl secrets unset LIVE_CONFIRM`) rather than push through it. +## Shipped since rev 12 (2026-07-15) +- **PAPER FAK PARITY**: PaperExecutor BUYs now model live FAK reality — if + the depth gate's book snapshot (threaded through meta["book"], no second + fetch) has no ask ≤ quote×1.05 (live.max_slippage_pct default), the paper + "fill" is a rejection and the engine records the same "order rejected: no + orders found to match" miss live logs. Rationale: post-chain-seed this is + live's #1 miss class (10/33) and paper pretended to fill them (it "filled" + the Claude Opus bet live's FAK couldn't) — biasing the live-vs-paper + per-signal ratio (the top-up number) OPTIMISTIC. Chain-seed verdict after + 41h: detection misses 23→3 live / 35→2 paper; the residual tail is + no-trigger-at-all. Resting-limit fix for live FAK no-match evaluated and + REJECTED for now: recent 10-row sample is −$0.97 would-be net + a resting + bid is adversely selected (fills when price falls through it, not when + we're right). Revisit only with paper-parity data showing real cost. + Fail-open on dead book fetch; SELLs stay optimistic (exits need the + retry/pending machinery to model honestly — out of scope). Live book + unaffected (meta ignored). Tests: tests/test_paper_fak.py (8 paths); + LedgerPaperExecutor no longer appends a fill row for a rejection. + Deploy: paper app restart only. Expect paper misses like "order rejected: + no orders found … (paper model: best ask 0.900 > cap 0.840)". + ## Shipped since rev 11 (2026-07-14) - **CHAIN SEED (T0b)**: the missed-bets review found the one remaining detection leak — RTDS doesn't emit every market (the "Credible public diff --git a/copybot.py b/copybot.py index 7093bba5..da5b7aaa 100644 --- a/copybot.py +++ b/copybot.py @@ -336,8 +336,9 @@ class LedgerPaperExecutor(PaperExecutor): def buy(self, token_id, shares, price, meta): r = super().buy(token_id, shares, price, meta) - self.fills.append({"side": "BUY", "token": token_id, - "shares": r["filled_shares"], "price": r["price"]}) + if r["ok"]: # a paper FAK no-match is not a fill + self.fills.append({"side": "BUY", "token": token_id, + "shares": r["filled_shares"], "price": r["price"]}) return r def sell(self, token_id, shares, price, meta): diff --git a/copytrade.py b/copytrade.py index 353677e9..33e8fac6 100644 --- a/copytrade.py +++ b/copytrade.py @@ -223,10 +223,36 @@ def event_key(t): # ── execution ──────────────────────────────────────────────────────────────── class PaperExecutor: - """Simulates fills at the current best price. Places nothing.""" + """Simulates fills at the current best price. Places nothing. + + BUYs model live FAK reality (2026-07-15): live sends fill-and-kill with a + protected cap of quote × (1 + max_slippage_pct); on a thin book with no + ask inside that band the order dies ('no orders found to match') — the #1 + live miss class once detection got fast (the copy arrives in the crater + the sharp just swept, before makers requote). Paper used to 'fill' those, + which biased the live-vs-paper per-signal ratio — the bankroll top-up + number — optimistic. Now the same book snapshot the depth gate fetched + (meta['book'], else fetched here) decides: no ask ≤ cap -> rejected, and + the engine records the same 'order rejected' miss live would. A failed + book fetch fails OPEN (fills, today's behavior) — a dead data source + shouldn't fabricate misses. SELLs stay optimistic-fill: exits are + proportional mirrors of a position we hold, and modeling exit failure + honestly needs the retry/pending machinery, not a one-line reject.""" live = False + max_slippage_pct = 0.05 # mirrors live.max_slippage_pct's default def buy(self, token_id, shares, price, meta): + bk = (meta or {}).get("book") + if bk is None: + bk = book_depth(token_id) + if bk is not None: + ba, cap = bk.get("ba"), price * (1 + self.max_slippage_pct) + if ba is None or ba > cap: + gone = ("no asks" if ba is None + else f"best ask {ba:.3f} > cap {cap:.3f}") + return {"ok": False, "filled_shares": 0.0, "price": price, + "resp": "no orders found to match with FAK order " + f"(paper model: {gone})", "paper": True} return {"ok": True, "filled_shares": shares, "price": price, "paper": True} def sell(self, token_id, shares, price, meta): @@ -540,6 +566,7 @@ class CopyTrader: # to 10% of depth, skip dust/unreliable books. A failed book fetch # declines to bind — guard + protected prices still bound the copy. dg = self.cfg.get("depth_gate") + bk = None # reused by the paper FAK model below if dg: bk = book_depth(token) if bk and bk.get("ask5c") is not None: @@ -576,7 +603,7 @@ class CopyTrader: "on this token)") return shares = allowed / price - res = self.ex.buy(token, shares, price, {"title": title}) + res = self.ex.buy(token, shares, price, {"title": title, "book": bk}) if not res["ok"]: # in-play books ACCEPT orders with a delayed hold — the executor # reports those as pending (order id + pre-order balance) instead diff --git a/tests/test_paper_fak.py b/tests/test_paper_fak.py new file mode 100644 index 00000000..8f58b3cf --- /dev/null +++ b/tests/test_paper_fak.py @@ -0,0 +1,104 @@ +"""Stub tests for the paper FAK no-match model (HANDOFF 2026-07-15): +PaperExecutor.buy rejects when no ask sits inside the live protected band, +reuses the depth gate's book snapshot, fails open on a dead book fetch, and +LedgerPaperExecutor records no fill row for a rejection. Run: +python3 tests/test_paper_fak.py — needs no network, no config. +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +import copytrade # noqa: E402 +import copybot # noqa: E402 + +fails = [] + + +def case(name): + def deco(fn): + bd0 = copytrade.book_depth + try: + fn() + print(f"PASS {name}") + except AssertionError as e: + print(f"FAIL {name}: {e}") + fails.append(name) + finally: + copytrade.book_depth = bd0 + return deco + + +def boom(token_id): + raise AssertionError("book_depth called — meta['book'] should be reused") + + +@case("ask inside the band -> fills, and the meta book is reused (no refetch)") +def t1(): + copytrade.book_depth = boom + ex = copytrade.PaperExecutor() + r = ex.buy("T", 10.0, 0.80, {"book": {"ba": 0.82, "ask5c": 200.0}}) + assert r["ok"] and r["filled_shares"] == 10.0 and r["price"] == 0.80 + + +@case("best ask above quote*(1+5%) -> FAK no-match rejection") +def t2(): + copytrade.book_depth = boom + ex = copytrade.PaperExecutor() + r = ex.buy("T", 10.0, 0.80, {"book": {"ba": 0.90, "ask5c": 200.0}}) + assert not r["ok"] and r["filled_shares"] == 0.0 + assert "no orders found to match" in r["resp"], r["resp"] + + +@case("no asks at all -> FAK no-match rejection") +def t3(): + copytrade.book_depth = boom + ex = copytrade.PaperExecutor() + r = ex.buy("T", 10.0, 0.80, {"book": {"ba": None, "ask5c": None}}) + assert not r["ok"] and "no asks" in r["resp"], r["resp"] + + +@case("boundary: ask exactly at the cap still fills") +def t4(): + copytrade.book_depth = boom + ex = copytrade.PaperExecutor() + r = ex.buy("T", 10.0, 0.80, {"book": {"ba": 0.84, "ask5c": 200.0}}) + assert r["ok"], r + + +@case("no meta book + dead book fetch -> fail OPEN (fills, today's behavior)") +def t5(): + copytrade.book_depth = lambda token_id: None + ex = copytrade.PaperExecutor() + r = ex.buy("T", 10.0, 0.80, {"title": "x", "book": None}) + assert r["ok"] and r["filled_shares"] == 10.0 + + +@case("no meta book -> fetches its own and applies the band") +def t6(): + copytrade.book_depth = lambda token_id: {"ba": 0.95, "ask5c": 200.0} + ex = copytrade.PaperExecutor() + r = ex.buy("T", 10.0, 0.80, {"title": "x"}) + assert not r["ok"] and "no orders found to match" in r["resp"] + + +@case("SELLs stay optimistic-fill (unchanged)") +def t7(): + copytrade.book_depth = boom + ex = copytrade.PaperExecutor() + r = ex.sell("T", 10.0, 0.80, {"book": {"ba": None}}) + assert r["ok"] and r["filled_shares"] == 10.0 + + +@case("LedgerPaperExecutor: rejection appends NO fill row; success appends") +def t8(): + copytrade.book_depth = boom + ex = copybot.LedgerPaperExecutor() + r = ex.buy("T", 10.0, 0.80, {"book": {"ba": 0.95, "ask5c": 200.0}}) + assert not r["ok"] and ex.fills == [], ex.fills + r = ex.buy("T", 10.0, 0.80, {"book": {"ba": 0.81, "ask5c": 200.0}}) + assert r["ok"] and len(ex.fills) == 1 and ex.fills[0]["shares"] == 10.0 + + +print() +print("FAILURES:", fails or "none") +sys.exit(1 if fails else 0)