diff --git a/copybot.py b/copybot.py index 041a6973..a2e59491 100644 --- a/copybot.py +++ b/copybot.py @@ -1623,6 +1623,18 @@ class Copybot: keep.append(r) # resolver owns this token right now continue shares = min(r["shares"], mine["shares"]) + if getattr(self.engine.ex, "live", False): + try: # closes #7: never retry a ghost + chain_sh = self.engine.ex._shares_held(tok) + except Exception: + keep.append(r) + continue + if chain_sh < 0.01: + log(f"retry: ghost holding (chain 0) — dropping " + f"{r.get('label','?')[:40]}") + st["my_pos"].pop(tok, None) + continue + shares = min(shares, chain_sh) price = self.engine._live_price(tok, "sell") if price is None: res = {"ok": False, "resp": "no bid side"} @@ -2024,6 +2036,23 @@ class Copybot: pos = mp.get(token) if not pos: continue + # closes #7: the BOOK's share count can drift above the CHAIN's + # (venue rounding on partial exits — the BetBoom 0.12sh ghost + # looped "not enough balance" forever). Chain is the arbiter: + # clamp the exit to what actually exists; a ghost (<0.01sh on + # chain) is dropped outright, loudly, instead of retried. + if getattr(self.engine.ex, "live", False): + try: + chain_sh = self.engine.ex._shares_held(token) + except Exception: + continue # no chain truth -> no action this pass + if chain_sh < 0.01: + log(f"reconcile: ghost position (book " + f"{pos['shares']:.2f}sh, chain 0) — dropping " + f"{pos.get('title','?')[:40]}") + del mp[token] + continue + pos["shares"] = min(pos["shares"], chain_sh) name = self.names.get(wallet.lower(), wallet[:10]) log(f"reconcile: {name} exited {pos.get('title','?')[:42]} while we " f"weren't listening — mirror-exiting {pos['shares']:.1f}sh now") diff --git a/recorder/ingest.py b/recorder/ingest.py index 6d01abd5..c5c07f33 100644 --- a/recorder/ingest.py +++ b/recorder/ingest.py @@ -9,14 +9,32 @@ import gzip import json import os import shutil +import ssl import subprocess import time +import urllib.request HERE = os.path.dirname(os.path.abspath(__file__)) DB = os.path.join(HERE, "..", "live", "rtds.duckdb") APP, SEG = "wwf-recorder", "/data/segments" +def ping(msg): + """Best-effort Discord ping to the DAILY channel (user ask 2026-07-19: + the nightly pull announces start + finish). Never fatal.""" + try: + hook = json.load(open(os.path.join(HERE, "..", "config.json"))).get("daily_webhook") + if not hook: + return + req = urllib.request.Request(hook, data=json.dumps({"content": msg}).encode(), + headers={"Content-Type": "application/json", + "User-Agent": "Mozilla/5.0"}) + urllib.request.urlopen(req, timeout=10, + context=ssl._create_unverified_context()).read() + except Exception: + pass + + # launchd runs daily.sh with a minimal PATH (no /opt/homebrew/bin) — the # 2026-07-18 run died on FileNotFoundError: 'flyctl'. Resolve it explicitly. FLYCTL = shutil.which("flyctl") or "/opt/homebrew/bin/flyctl" @@ -42,6 +60,7 @@ def main(): have = {r[0] for r in con.execute("SELECT segment FROM ingested").fetchall()} segs = [s for s in box(f"ls {SEG}").split() if s.endswith(".gz") and s not in have] + ping(f"📼 tape ingest started — {len(segs)} segment(s) queued") total = 0 for s in sorted(segs): raw = box(f"base64 {SEG}/{s}") @@ -76,6 +95,8 @@ def main(): print(f"[ingest] {s}: {len(rows)} trades + {len(aux)} aux") n = con.execute("SELECT count(*) FROM trades").fetchone()[0] print(f"[ingest] +{total} rows · rtds.duckdb now {n:,} trades") + ping(f"📼 tape ingest done: +{total:,} rows across {len(segs)} segment(s) " + f"· rtds.duckdb now {n:,} trades") if __name__ == "__main__":