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:
+3
-1
@@ -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 →
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user