diff --git a/copybot.py b/copybot.py index da5b7aaa..1d3dd875 100644 --- a/copybot.py +++ b/copybot.py @@ -62,7 +62,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from copytrade import ( # the execution engine (sizing, gates, executors) CopyTrader, PaperExecutor, LiveExecutor, DEFAULT_CONFIG, - load_json, save_json, new_state, recent_trades, confirm_live, + load_json, save_json, new_state, recent_trades, confirm_live, clob_price, ) import smart_money as sm # noqa: E402 from smart_money import SSL_CTX # noqa: E402 @@ -1640,6 +1640,68 @@ class Copybot: st["exit_retries"] = keep self.engine.persist() + DUST_MIN_USD = 0.10 # below this a sweep sell isn't worth the fee + DUST_WALLET = "0x455e252e45Ee46d6C4cc1c8fAdD3899d68f245a1" # deposit wallet + + def sweep_dust(self, cycle): + """Exit-dust reclaimer (2026-07-17): proportional mirror-exits leave + sub-share remainders on chain after the book closes the bet (found by + the platform-vs-book reconcile: 4 residues worth ~$1.90). Shortly + after boot and ~daily, market-sell any LIVE-market wallet holding the + book does NOT track and credit the proceeds as a documented + ADJUSTMENT — recovery of already-settled bets, never new realized P&L + (the ledger invariant needs the cash move in exactly one term). + Never touches my_pos / pending / retry tokens; a failed sell (dust + books FAK no-match, sub-$1 rejects) just waits for the next pass.""" + if not getattr(self.engine.ex, "live", False): + return + if cycle != 5 and cycle % 1440 != 0: + return + pos = sm.get_json("/positions", {"user": self.DUST_WALLET, + "sizeThreshold": 0}) or [] + st = self.engine.state + skip = (set(st["my_pos"]) + | {p.get("token") for p in st.get("pending_orders", [])} + | {r.get("token") for r in st.get("exit_retries", [])}) + recovered, sold, tried = 0.0, [], 0 + for p in pos: + tok, val = p.get("asset"), p.get("currentValue") or 0 + cur, shares = p.get("curPrice") or 0, p.get("size") or 0 + if (not tok or tok in skip or val < self.DUST_MIN_USD + or p.get("redeemable") or not 0.01 <= cur <= 0.99 + or shares <= 0): + continue + if tried >= 10: + break + tried += 1 + quote = clob_price(tok, "sell") + if not quote: + continue + with self.lock: + before = st["cash"] + r = self.engine.ex.sell(tok, shares, quote, + {"title": p.get("title") or "dust"}) + self._drain_fills() + delta = st["cash"] - before + if r.get("ok") and delta > 0: + recovered += delta + sold.append(f"{shares:.2f}sh @ {r['price']:.3f} " + f"{str(p.get('title'))[:36]}") + log(f"DUST SWEEP +${delta:.2f} · {sold[-1]}") + else: + log(f"dust sweep skip ({str(r.get('resp'))[:50]}) · " + f"{str(p.get('title'))[:40]}") + if recovered > 0: + st.setdefault("adjustments", []).append({ + "ts": int(time.time()), "amount": round(recovered, 6), + "note": f"dust sweep: {len(sold)} residual holdings sold " + "(recovery of closed bets, not P&L)"}) + self.engine.persist() + self.engine.alert( + f"dust sweep recovered ${recovered:.2f} ({len(sold)} holdings)", + discord_text=(f"🧹 **DUST SWEEP** [LIVE] +${recovered:.2f}\n" + + "\n".join(sold[:8]))) + _chain_bal = (0.0, None) # (checked_at, usdc) — cached; poll ≤1/min def chain_cash_gap(self): @@ -2534,6 +2596,7 @@ def main(): bot.resolve_pendings() # adopt/expire in-play held orders bot.retry_stuck_exits() # LIVE_ROLLOUT 1.6 bot.settle_resolved() + bot.sweep_dust(cycle) # reclaim untracked exit residue (live) if cycle % 5 == 0: bot.reconcile_exits() bot.reconcile_entries() diff --git a/tests/test_dust_sweep.py b/tests/test_dust_sweep.py new file mode 100644 index 00000000..17a51104 --- /dev/null +++ b/tests/test_dust_sweep.py @@ -0,0 +1,75 @@ +"""Stub test for Copybot.sweep_dust (2026-07-17): sells only untracked +live-market residue, books an adjustment (not P&L), skips my_pos/pending/ +floor/redeemable. Run: python3 tests/test_dust_sweep.py +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +import copybot # noqa: E402 + + +class Ex: + live = True + + def __init__(self): + self.fills, self.sold = [], [] + + def sell(self, tok, shares, price, meta): + self.sold.append(tok) + self.fills.append({"side": "SELL", "token": tok, "shares": shares, + "price": price}) + return {"ok": True, "filled_shares": shares, "price": price} + + +class Eng: + def __init__(self, st): + self.state, self.ex, self.alerts = st, Ex(), [] + + def persist(self): + pass + + def alert(self, m, discord_text=None): + self.alerts.append(m) + + +st = {"my_pos": {"HELD": {}}, "pending_orders": [{"token": "PEND"}], + "exit_retries": [], "cash": 10.0, "adjustments": [], "bets": {}} +bot = copybot.Copybot.__new__(copybot.Copybot) +bot.engine = Eng(st) +bot.fee_rate = 0.03 +import threading +bot.lock = threading.Lock() + +POS = [ + {"asset": "HELD", "currentValue": 5.0, "curPrice": 0.5, "size": 10}, # book-open + {"asset": "PEND", "currentValue": 5.0, "curPrice": 0.5, "size": 10}, # pending + {"asset": "TINY", "currentValue": 0.05, "curPrice": 0.5, "size": 0.1}, # under floor + {"asset": "DEAD", "currentValue": 0.0, "curPrice": 0.0, "size": 2, + "redeemable": True}, # worthless + {"asset": "DUST", "currentValue": 0.37, "curPrice": 0.37, "size": 1.0, + "title": "Team Yandex dust"}, # sweep me +] +copybot.sm.get_json = lambda path, params=None: POS +copybot.clob_price = lambda tok, side: 0.37 + +bot.sweep_dust(cycle=5) +fails = [] +if bot.engine.ex.sold != ["DUST"]: + fails.append(f"sold wrong set: {bot.engine.ex.sold}") +fee = copybot.taker_fee(1.0, 0.37, 0.03) +want_cash = 10.0 + 0.37 - fee +if abs(st["cash"] - want_cash) > 1e-9: + fails.append(f"cash {st['cash']} != {want_cash}") +if not (st["adjustments"] and abs(st["adjustments"][0]["amount"] - (0.37 - fee)) < 1e-9): + fails.append(f"adjustment wrong: {st['adjustments']}") +# realized invariant: cash delta fully covered by the adjustment +if not bot.engine.alerts: + fails.append("no alert sent") +# off-schedule cycles do nothing +bot.sweep_dust(cycle=6) +if len(bot.engine.ex.sold) != 1: + fails.append("swept on an off-schedule cycle") + +print("FAILURES:", fails or "none") +sys.exit(1 if fails else 0)