diff --git a/fly.surgebot.toml b/fly.surgebot.toml new file mode 100644 index 00000000..b4ec8ef1 --- /dev/null +++ b/fly.surgebot.toml @@ -0,0 +1,20 @@ +# wwf-surgebot — Study A surge-momentum PAPER harness (research silo). +# No keys, no orders, no repo clone: code baked in image (recorder pattern). +# Believing its numbers is gated on the #16 forward verdict; see the +# sprint-plan issue. State on the volume; the Mac nightly pulls + grades. +app = "wwf-surgebot" +primary_region = "arn" + +[build] + dockerfile = "research/surgebot.Dockerfile" + +[[mounts]] + source = "surge" + destination = "/data" + +[[restart]] + policy = "always" + +[[vm]] + size = "shared-cpu-1x" + memory = "256mb" diff --git a/research/grade_surge.py b/research/grade_surge.py new file mode 100644 index 00000000..553fd0ed --- /dev/null +++ b/research/grade_surge.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Nightly chain-truth grading of the surge paper harness (wwf-surgebot). + +Pulls /data/surge_state.json off the box (read-only — never writes back), +re-grades every settled entry with CTF payout vectors (payouts.truth; the +harness's provisional CLOB winner flags lie on operator-resolved markets, +[[polymarket-resolution-truth]]), and appends one row per settle to +research/surge_paper_ledger.jsonl (keyed asset+ts, idempotent). Also emits +the running paper-book summary the #16 sprint decision reads.""" +import json +import os +import shutil +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +LEDGER = os.path.join(HERE, "surge_paper_ledger.jsonl") +TMP = os.path.join(HERE, ".surge_state.pull.json") +FLYCTL = shutil.which("flyctl") or "/opt/homebrew/bin/flyctl" + + +def main(): + r = subprocess.run([FLYCTL, "ssh", "sftp", "get", "/data/surge_state.json", + TMP, "-a", "wwf-surgebot"], capture_output=True, + timeout=300, stdin=subprocess.DEVNULL) + if not os.path.exists(TMP) or os.path.getsize(TMP) == 0: + print("[grade_surge] box unreachable or no state yet — skip") + return 0 + st = json.load(open(TMP)) + os.remove(TMP) + sys.path.insert(0, os.path.join(HERE, "..", "live")) + import payouts + have = set() + try: + for ln in open(LEDGER): + try: + d = json.loads(ln) + have.add((d["asset"], d["ts"])) + except Exception: + pass + except FileNotFoundError: + pass + settled = st.get("settled", []) + new = [s for s in settled if (s["asset"], s["ts"]) not in have] + if new: + payouts.ensure(sorted({s["cond"] for s in new if s.get("cond")})) + graded = flips = 0 + with open(LEDGER, "a") as fh: + for s in new: + t = payouts.truth(s.get("cond"), s.get("asset")) if s.get("cond") else None + pay = s["payout"] if t is None else t + pnl = round(s["shares"] * pay - s["cost"] - s["fee"], 2) + if t is not None and abs(t - s["payout"]) > 1e-9: + flips += 1 + fh.write(json.dumps({**s, "chain_payout": pay, "chain_pnl": pnl, + "flip": t is not None + and abs(t - s["payout"]) > 1e-9}) + "\n") + graded += 1 + total = wins = 0 + pnl_sum = 0.0 + try: + for ln in open(LEDGER): + d = json.loads(ln) + total += 1 + wins += d["chain_payout"] == 1.0 + pnl_sum += d["chain_pnl"] + except FileNotFoundError: + pass + c = st.get("counters", {}) + print(f"[grade_surge] +{graded} settles ({flips} provisional flips) · " + f"book: {total} settled {wins}W · chain P&L ${pnl_sum:+.2f} · " + f"cash ${st.get('cash', 0):.2f} · open {len(st.get('open', {}))} · " + f"lifetime trig {c.get('triggers')} fill {c.get('fills')} " + f"crater {c.get('craters')}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/research/informed_set.py b/research/informed_set.py new file mode 100644 index 00000000..6fc97748 --- /dev/null +++ b/research/informed_set.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Publish the frozen-method informed set for the surge paper harness. + +Writes params/informed_set.json — the top-150 wallets by the FROZEN Study-A +scoring (study_flow.informed_set, params/study_flow.json), as-of now. The +wwf-surgebot worker fetches this from raw.githubusercontent at boot and +every 6h; nightly.sh regenerates + commits it daily so the harness trades +the same signal the forward ledger scores. Identity showed NO lift over +random controls (#16) — the set is kept anyway because the FROZEN signal +definition uses it, and the verdict must validate exactly what was frozen. +""" +import json +import os +import time + +import tape +import study_flow as sf + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.join(HERE, "params", "informed_set.json") + + +def main(): + db = tape.connect() + now = db.execute("SELECT max(ts) FROM trades").fetchone()[0] + P = json.load(open(os.path.join(HERE, "params", "study_flow.json"))) + fz = P["frozen"] + wallets = sf.informed_set(db, now, fz["top_n"]) + json.dump({"generated_at": int(time.time()), "as_of_tape_ts": int(now), + "method": "study_flow.informed_set FROZEN " + f"(top_n={fz['top_n']}, z>={sf.SET_MIN_Z}, " + f"n>={sf.SET_MIN_BETS})", + "wallets": wallets}, + open(OUT, "w"), indent=1) + print(f"[informed_set] {len(wallets)} wallets -> {OUT}") + + +if __name__ == "__main__": + main() diff --git a/research/nightly.sh b/research/nightly.sh index 1f0b0e57..44391e29 100755 --- a/research/nightly.sh +++ b/research/nightly.sh @@ -51,9 +51,12 @@ while true; do done "$PY" forward.py >> forward.log 2>&1 +"$PY" informed_set.py >> forward.log 2>&1 # surge harness reads this daily +"$PY" grade_surge.py >> forward.log 2>&1 || true # paper book -> chain truth cd .. -git add research/forward_ledger.jsonl +git add research/forward_ledger.jsonl research/params/informed_set.json \ + research/surge_paper_ledger.jsonl 2>/dev/null if ! git diff --cached --quiet; then git commit -q -m "research: forward ledger $(date -u +%F) [skip ci]" git pull --rebase --autostash -q && git push -q diff --git a/research/params/informed_set.json b/research/params/informed_set.json new file mode 100644 index 00000000..b67d2c17 --- /dev/null +++ b/research/params/informed_set.json @@ -0,0 +1,157 @@ +{ + "generated_at": 1784661151, + "as_of_tape_ts": 1784660384, + "method": "study_flow.informed_set FROZEN (top_n=150, z>=2.5, n>=6)", + "wallets": [ + "0xda770326bd091d26f816917aa83a6baff668683f", + "0x893575c7d99542163c6b6e8a0fe5af0b6d217daa", + "0xa9f5e5c9c761a0c7d692ba479028f778a5dd937f", + "0xc7d02944a76b9f83b199e9090ecc92c82d241f8a", + "0xc4fe817476491890086330a639ac417d51bf601f", + "0x204f72f35326db932158cba6adff0b9a1da95e14", + "0x945a49252f772a10c6ddd1d1e1e24ee20438a48c", + "0xc29198ad764bd6adaf7bb971a3757a689ece5d74", + "0xfe787d2da716d60e8acff57fb87eb13cd4d10319", + "0xdbdd45150249e229eb4ca8aa48a30dca21faa5de", + "0x2005d16a84ceefa912d4e380cd32e7ff827875ea", + "0xdb83e85ffd22faa4009273034770f96ffc5b1e50", + "0x200ffb575b936d297fcf39076ddf2b476ae07a96", + "0x076daa87c4fe1a85402a9b6b8e0a866224388d4c", + "0xe91171f655be1568e4c63f29663c0028649e3d4e", + "0x2f26606ccc45a934838a97d44a30c32012ae0d8f", + "0xd9c9651bfd445b2cddf750662ee79a755f216686", + "0x03bf6d028f9bbc44a8b90d4b195ecbfe54c324b9", + "0xf1f2b3c6c5bea42a6664fe3a8395addc1ab28c8d", + "0x34045be5c902268d74219ebfe7caa347cfae10d2", + "0x904ed7c7820a434ea9b11e7333ed69190d850bec", + "0x0d2d845a6ff64e31e04a70afce8a573940767ff5", + "0x67af214764bd6eda61fadb25cdcbea6d13d761ee", + "0xbe0c69b37d57a0e2f171b606adab285e56fec0cd", + "0x0ec7f0eac6c47934ab9b41e02bd8f15a5f4293d1", + "0xa509ae942ac8d8bc3f8c5df30cb6ae2c9b13ae46", + "0x592997c2d8119d82d71f21119957c6434d244684", + "0xa951006f1ce68498c1aef9b013880459e6e08a2f", + "0x489d3df3601288940f89d56b5642b0951129f6e3", + "0xa6db8383b756f1e58c504ff4179e19427dd73155", + "0x2e3c40fa47b27c676ddd573064162f57d51508ba", + "0x47138dc1eef25f1ea91f3b2fda0e0f455c634d21", + "0x48363a6cd08b9602e9f2e0523c4f9a89d4c4859b", + "0x505f23d257d23e4a1ec37ff9bca7c88f3bf0645e", + "0xebff466d6070548b13a432d7583e9fa5bce870e5", + "0xa3c2ec158f222f3f4f5f4f57b113d761a4d3df3d", + "0x0c99d9f172fc4c25fd2aa92f9fc272da5f020c88", + "0xd13c1e677498544774850a8d34b210887ec798a7", + "0x2da325618fff9b2ffe64a49253fbcae8c7b9f258", + "0x84ad9c5c547a82ec9a08547b94bd922446e5bfb7", + "0x8d0c8253703b8febbe53f45d182671c5385dc90a", + "0x65228958f3c7e09f09ce2dab06813d6051333a5a", + "0xadfb6cba33cebca02eab6111ace1e3924b9cc2ef", + "0xae4160ad50090eb38c6792e4e00caa512ff88311", + "0x98db8cca55c32b24cfb414b5b43d273f4e1fdd17", + "0x2a69660046d7acc4ab204d7cc5ba78b0776cd2f7", + "0x521070c99db06e54af5e8e4a91d6858decdfbd53", + "0x60ec17443af511a21945f01430e61c803465f7b0", + "0xf504d6bcf88d485d21f65398b9deb04d92180111", + "0x67011de1721bd8af42522c8eacbb4c52d518d7d2", + "0x5d634050ad89f172afb340437ed3170eaa2c9075", + "0x6d3c5bd13984b2de47c3a88ddc455309aab3d294", + "0x0cf18b4294b05509c44a6a0172eff7e97b20d7fe", + "0x77de6e0a24dc57a4345c987eb061d46eb6e5e67a", + "0x83182e6abe7101f2084eef8d570ebdfc66dc34a6", + "0x14e72e19ea2f6e2be41504dd3268184ebe5fa32c", + "0x47c9014f76660cd2efe17c1f5ea342fd12cd2038", + "0x9ab303a355bf22a29e485d98fb88d140abb43044", + "0x4a5147d7af926edc3054c2e4cfe9becd36c14ef6", + "0x237f21a15396d2e56c7ca5f74cfbc6af9dade76e", + "0x9d94f602535e518ee1cb6aade0ca9569f1b1017d", + "0x717990979ff84a32d47205a7eede94317aab7d79", + "0xc75b0390b025086df22363d6c22602506c3b3ea7", + "0x52d93dcf81444f793dda7fedc370f7a35101fb1a", + "0x162147bf9d4588675de814fce9ce36d3b2c0f040", + "0x273d0a3b3e0652465ee8baf8b30c1acad8befb63", + "0xa032d2358fa08860ba30ba8e8d828b7738202090", + "0x7d0caf83488431bb760f204d52bdf5246e2fa3b7", + "0x2126c12a2eb40b10acd1d1d0bda4cea42d58aeeb", + "0xbb8ef6381e4a35b4124584cfce1c73137e2508c1", + "0xeea3f08e8a36081233e4c4b4142fd61a7b56a6a2", + "0x6b23c73159b753c96d3c1ed94413f113300c04a3", + "0x37c1ff27d21b08d1cae9f38453b895eae2a78de1", + "0xfb0c8b014cc976a606e924b5fc4a93b31fd98c72", + "0x0346afae2603313d2bbee96b628536c8cbe352a5", + "0xb6d2d81518cda8f9025697f942b2533ad4efd67b", + "0x9e20c1016cfdf518c84aa398d64bf9b2277a5cf9", + "0x747fda2528435ab2719fdc36d07b4248af61f2d7", + "0x1cae27a4deb610229d264199b655f2b1a4d5d01c", + "0x5268527977f700f9bf9b6d5cd843859e4e70135d", + "0xf1ee4842fa0c7283632185e42577027452d4519c", + "0xd5a32eba30f8baa08c3f12880ff6525f926d5b70", + "0xe83abacce6bf35b11b8de4c890ea4bf19d218355", + "0x32b484581fc5606de9c1e43af4636b6be9bc8b21", + "0x7a5d94f83ff0195387eaf9f4463433d03eda54db", + "0x14f3cee5d890a61fe427407f340d190eab7a0796", + "0xbfd770b7ff07fb2ece41b0e7c593db65a84cf0bf", + "0x0df758f3a438f88d9b98da42ab76cd585a101ec1", + "0x7d0e41915dd94259bbe9dae12d33b1c26d304f01", + "0x1ba2b1137313b248b03a9be40093b23d7e96d212", + "0xc846f5368ab49a854d2280b7ecff92669824c413", + "0x7d9a514f9da9e8aa7fa37306943c7d1720d805e6", + "0x94d22bf045f9a1d1cb37a40a4a8c86fd8917cfbd", + "0x224f7ef690d952cf551a471846d5afb4892e514a", + "0xff34c2cc46e7624310f3549ea355540d57a4694b", + "0x7547479d43f4b52d892a6f2254050eea50edd158", + "0x80d8dddcfdc075eb70f6d2d774a27bfb255ab703", + "0x5a218c7ad04135830a45c41aaed7294df7809318", + "0x4f29e103339919c4baaea2a60195cf1c8bb27a7e", + "0x1df231bd33dae91463e1682927e8096b82c289a0", + "0xc1fd8001916292efc9343e2d555bb67e9edb10cc", + "0x77bcf759a5bbbf604a52d839d5f68ead4c78b91b", + "0x2e0bf01211c2bc155b930ffeb06b67b97dd11435", + "0x664a25f165a0150ce687410470f3352f5783fc5f", + "0x5b8990db8c1304d56cf8466e443b5e50a47f23fb", + "0x4462e46cf0d31466693058893f4913e2411fbfdf", + "0x1a9eb0213ca31d612381841bd6159071aab1a24b", + "0x2c475c09b18fa388f3c60cacef6e7221b00b47f5", + "0xbac4ab50a9059b89131cf7bad38bb12c031586db", + "0x64d4c540211c48ce4cb7b853ffcb0df2b438f056", + "0x26c283df72be6f746b6acd37b7b06ff89da60cdf", + "0x56cc7a163d7a56134a394b76abaa47c7bdcad589", + "0x1a1e0ac0b3ecc0b5bb133af94a26d5236557f992", + "0xb91cdd2b03d05f67db8ed560fb56cca7f668ed66", + "0x083d6a36c1b5e0178aa18d6d19814c73cf7d10a1", + "0xde9f7f4e77a1595623ceb58e469f776257ccd43c", + "0x83d9a38fbf2265d1a092499a964412dd44e810d9", + "0xc2bec11a79567ea92f7c5dfa6587eadaede6af12", + "0xeda9da7fe9a6992128f5008ddbd7156cef74f624", + "0x4126330ba61224a3b265921fcdea1adc50d6e68b", + "0xa3b48b1e1e3c44c2c8181092206f65f29bce88df", + "0x99f0d31fdced5b3a0e5ee2867730a6644a6c9495", + "0x466658e9a04e4586fb0ea46abf0a2fd8902f3a55", + "0x2c46ffe86ee500875db3c679e52f6e64854cdac9", + "0x2d6ac4f70307102ac46e9e6ded67f3838ddf8add", + "0x3e43bfa5be2efe93a621bc17edc934095b100306", + "0x58a84177426c8be517141558baaac252153ba3c7", + "0x125a733601a947640cd928d4fd1bd5ddb841df71", + "0x99d4291db7710e216a8b8f95323385442cc47c0d", + "0xb8fc63b6b017d941510cd32e275058c40171b543", + "0x325f0e5b5afc66ff54102d850f67f0c621304511", + "0x26d61145a64b1b5036c82a147dfeb7d56fa0cacc", + "0xd37ab1a5eeb25696c568e20e94af72b8d3422358", + "0x7ddeb72e6b8726b146d085c20a90bb64158030b1", + "0x91ac00e15e85a986f4d3d035e3d9b1b4ac435ff3", + "0x999a0341c09fb5512feba1cbfb84c1d278dd9a25", + "0xc62fc154efd70e3a7991726c4eef6118566c1781", + "0x98883c610e4400dd998f81cf41658d51ff06e6fe", + "0x1a59d3f300b446d78a42941ab1b71dd61273a57e", + "0x93078b5dd1c34d0f288d2f51b23f1347520d9da3", + "0x8845c34e8ba60527de59575bdc4b5ab70922360e", + "0xd03450276d4aa4069ce11bac4cfb1683a724eece", + "0xa1a77ea9382bb8c3610f3303b66e093f644aace4", + "0xf4c70c115ce5d717073bea78e02995df6635b8e1", + "0x6485f47d0344c03eb4340f985159f6eb2dcba265", + "0xfe8a28be83ba295a9384f72f240d2cfa96bc23ce", + "0xc3a7bca628ccd2fd4bfc72f86c1f257e96e43172", + "0x2529c68db36ce8aa64c64affe620267744a3548a", + "0x4daaedfa9503e4f6981e1d53b1931dc932639f01", + "0x6469297e9e08ed8fb630a3fa4699fdd3899fd7ca" + ] +} \ No newline at end of file diff --git a/research/surgebot.Dockerfile b/research/surgebot.Dockerfile new file mode 100644 index 00000000..35d794fe --- /dev/null +++ b/research/surgebot.Dockerfile @@ -0,0 +1,6 @@ +# wwf-surgebot — one dep, no git, no clone (a paper harness must not depend +# on GitHub being up to boot; informed set fetch degrades gracefully). +FROM python:3.12-slim +RUN pip install --no-cache-dir websocket-client +COPY research/surgebot.py /surgebot.py +CMD ["python3", "-u", "/surgebot.py"] diff --git a/research/surgebot.py b/research/surgebot.py new file mode 100644 index 00000000..c963b390 --- /dev/null +++ b/research/surgebot.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""surgebot — Study A's surge-momentum signal as a real-time PAPER harness. + +PAPER ONLY. No keys, no orders, no bot imports — this file is self-contained +(recorder-style: baked into the wwf-surgebot image, must not depend on the +repo to boot). It exists to answer the two questions the tape sim cannot: +does the signal compute in real time on the live stream, and what does the +paper book do at the $100/5%-stake deployment spec (#16 sprint plan). Its +entries are graded nightly against chain truth on the Mac (grade_surge.py); +believing ANY of it is gated on the pre-registered #16 forward verdict. + +FROZEN signal (params/study_flow.json, 2026-07-20 — do not tune here): + informed set top-150 (fetched from the repo, regenerated nightly) + trigger net informed flow >= $300 in 60s, one market + band entry price 0.10-0.90 · niches sports+esports + cooldown 900s per token +Deployment spec (2026-07-21 sizing discussion): + bank $100 paper · stake 5% of equity, set once per UTC day, $1 floor + cash-gated all-or-nothing · max 2 open positions per real-world event +Paper fill = live CLOB best ask inside p_ref*1.05, else crater (the same +FAK model the copybot's paper mode uses); venue fee 3% * shares * min(p,1-p). +Settles provisionally from the CLOB closed/winner flags; the nightly +re-grades with CTF payout vectors (operator-resolved markets lie here). +""" +import json +import os +import re +import ssl +import threading +import time +import urllib.request + +import websocket + +WS_URL = "wss://ws-live-data.polymarket.com" +CLOB = "https://clob.polymarket.com" +SET_URL = ("https://raw.githubusercontent.com/jaxperro/winning-wallet-finder/" + "main/research/params/informed_set.json") +STATE = os.environ.get("SURGE_STATE", "/data/surge_state.json") + +BANK = 100.0 +STAKE_PCT = 0.05 +EVENT_CAP = 2 +FLOW_USD = 300.0 +WINDOW_S = 60 +BAND = (0.10, 0.90) +COOLDOWN_S = 900 +FEE_RATE = 0.03 +SLIP_CAP = 0.05 +NICHES = {"sports", "esports"} +NICHE_PATTERNS = [ + ("esports", ["lol:", "dota", "cs2", "csgo", "valorant", "esports", + "bilibili", "map ", "game 1", "game 2", "game 3"]), + ("tennis", ["tennis", "atp", "wta", "wimbledon", "set winner"]), + ("sports", [" vs. ", " vs ", " @ ", "mlb", "nba", "nhl", "ufc", + "world cup", "f1", "grand prix", "fifa"]), +] +SSL_CTX = ssl._create_unverified_context() + + +def log(m): + print(f"{time.strftime('%H:%M:%S')} {m}", flush=True) + + +def niche(title): + t = (title or "").lower() + for label, pats in NICHE_PATTERNS: + if any(p in t for p in pats): + return label + return "other" + + +def event_key(slug): + m = re.match(r"(.*?\d{4}-\d{2}-\d{2})", slug or "") + return m.group(1) if m else (slug or None) + + +def get_json(url, timeout=8): + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=timeout, context=SSL_CTX) as r: + return json.loads(r.read().decode()) + + +class Surge: + def __init__(self): + self.state = {"cash": BANK, "day": "", "day_stake": 5.0, + "open": {}, "settled": [], "skips": [], + "counters": {"triggers": 0, "fills": 0, "craters": 0, + "cash_skip": 0, "event_skip": 0}} + if os.path.exists(STATE): + try: + self.state = json.load(open(STATE)) + log(f"resumed: cash ${self.state['cash']:.2f} · " + f"{len(self.state['open'])} open") + except Exception as e: + log(f"⚠ state load failed ({e}) — fresh book") + self.informed = set() + self.set_meta = {} + self.win = {} # asset -> [(ts, ±usd)] + self.last_trig = {} + self.lock = threading.Lock() + self._roll_day(force=True) + + # ── config / sizing ──────────────────────────────────────────────────── + def load_set(self): + try: + d = get_json(SET_URL, timeout=15) + self.informed = {w.lower() for w in d["wallets"]} + self.set_meta = {"generated_at": d.get("generated_at"), + "n": len(self.informed)} + age_h = (time.time() - (d.get("generated_at") or 0)) / 3600 + log(f"informed set: {len(self.informed)} wallets " + f"({age_h:.0f}h old)" + (" ⚠ STALE >48h" if age_h > 48 else "")) + except Exception as e: + log(f"⚠ informed set fetch failed ({e}) — " + f"keeping {len(self.informed)} cached") + + def equity(self): + return self.state["cash"] + sum(p["cost"] for p in + self.state["open"].values()) + + def _roll_day(self, force=False): + day = time.strftime("%Y-%m-%d", time.gmtime()) + if force or day != self.state["day"]: + self.state["day"] = day + self.state["day_stake"] = max(1.0, round(STAKE_PCT * self.equity(), 2)) + log(f"day {day}: stake ${self.state['day_stake']:.2f} " + f"(5% of ${self.equity():.2f} equity)") + + def persist(self): + tmp = STATE + ".tmp" + json.dump(self.state, open(tmp, "w")) + os.replace(tmp, STATE) + + # ── signal ───────────────────────────────────────────────────────────── + def on_trade(self, p): + w = (p.get("proxyWallet") or "").lower() + if w not in self.informed: + return + title = p.get("title") or "" + if niche(title) not in NICHES: + return + asset = p.get("asset") + px = float(p.get("price") or 0) + usd = px * float(p.get("size") or 0) + if p.get("side") == "SELL": + usd = -usd + now = time.time() + with self.lock: + buf = self.win.setdefault(asset, []) + buf.append((now, usd)) + while buf and buf[0][0] < now - WINDOW_S: + buf.pop(0) + flow = sum(u for _, u in buf) + if flow < FLOW_USD or not (BAND[0] <= px <= BAND[1]): + return + if now - self.last_trig.get(asset, 0) < COOLDOWN_S: + return + self.last_trig[asset] = now + self.state["counters"]["triggers"] += 1 + self.execute(p, asset, px, flow, title) + + # ── paper execution (called under lock) ──────────────────────────────── + def execute(self, p, asset, p_ref, flow, title): + self._roll_day() + c = self.state["counters"] + ev = event_key(p.get("eventSlug") or p.get("slug") or "") + if asset in self.state["open"]: + return + n_ev = sum(1 for o in self.state["open"].values() if o["event"] == ev) + if ev and n_ev >= EVENT_CAP: + c["event_skip"] += 1 + log(f"skip (event cap) {title[:40]}") + return + stake = self.state["day_stake"] + if self.state["cash"] < stake: + c["cash_skip"] += 1 + log(f"skip (no cash: ${self.state['cash']:.2f}) {title[:40]}") + return + try: + book = get_json(f"{CLOB}/book?token_id={asset}") + asks = [float(a["price"]) for a in (book.get("asks") or [])] + ba = min(asks) if asks else None + except Exception as e: + log(f"skip (book fetch failed: {e}) {title[:40]}") + return + cap = min(p_ref * (1 + SLIP_CAP), 0.99) + if ba is None or ba > cap: + c["craters"] += 1 + self.state["skips"].append({"ts": int(time.time()), "asset": asset, + "p_ref": p_ref, "ba": ba, "flow": flow, + "title": title[:60], "why": "crater"}) + del self.state["skips"][:-500] + log(f"CRATER {title[:40]} ref {p_ref:.2f} ask " + f"{('%.2f' % ba) if ba else '—'}") + self.persist() + return + shares = stake / ba + fee = FEE_RATE * shares * min(ba, 1 - ba) + self.state["cash"] -= stake + fee + self.state["open"][asset] = { + "ts": int(time.time()), "cond": p.get("conditionId"), + "event": ev, "title": title[:60], + "outcome": p.get("outcome"), "p_ref": p_ref, "price": ba, + "shares": round(shares, 4), "cost": stake, "fee": round(fee, 4), + "flow": round(flow)} + c["fills"] += 1 + log(f"FILL {p.get('outcome')} · {title[:40]} @ {ba:.3f} " + f"(${stake:.2f}, flow ${flow:.0f}) · cash ${self.state['cash']:.2f}") + self.persist() + + # ── provisional settles (nightly re-grades with chain truth) ─────────── + def settle_pass(self): + for asset, pos in list(self.state["open"].items()): + try: + m = get_json(f"{CLOB}/markets/{pos['cond']}") + except Exception: + continue + if not m.get("closed"): + continue + pay = None + for t in m.get("tokens") or []: + if str(t.get("token_id")) == str(asset): + pay = 1.0 if t.get("winner") else 0.0 + if pay is None: + continue + self.state["cash"] += pos["shares"] * pay + self.state["settled"].append({**pos, "asset": asset, "payout": pay, + "provisional": True, + "settled_ts": int(time.time()), + "pnl": round(pos["shares"] * pay + - pos["cost"] - pos["fee"], 2)}) + del self.state["open"][asset] + log(f"SETTLE {'WON' if pay else 'lost'} {pos['title'][:38]} " + f"{'+' if pay else ''}{pos['shares'] * pay - pos['cost'] - pos['fee']:.2f} " + f"· cash ${self.state['cash']:.2f}") + self.persist() + + +def run_conn(tag, bot): + backoff = 2 + while True: + state = {"fresh": time.time()} + + def on_open(ws): + ws.send(json.dumps({"action": "subscribe", "subscriptions": + [{"topic": "activity", "type": "trades", + "filters": ""}]})) + state["fresh"] = time.time() + log(f"ws[{tag}]: connected") + + def ping(): + while ws.keep_running: + time.sleep(5) + try: + ws.send('{"action":"ping"}') + except Exception: + break + if time.time() - state["fresh"] > 20: + try: + ws.close() + except Exception: + pass + break + threading.Thread(target=ping, daemon=True).start() + + def on_message(ws, raw): + state["fresh"] = time.time() + try: + m = json.loads(raw) + except Exception: + return + if m.get("topic") == "activity" and m.get("type") == "trades": + try: + bot.on_trade(m.get("payload") or {}) + except Exception as e: + log(f"⚠ on_trade error: {e}") + + try: + app = websocket.WebSocketApp(WS_URL, on_open=on_open, + on_message=on_message) + app.run_forever(sslopt={"cert_reqs": ssl.CERT_NONE}) + except Exception as e: + log(f"ws[{tag}]: {str(e)[:60]}") + time.sleep(backoff + (1 if tag == "b" else 0)) + backoff = min(backoff * 2, 30) + if time.time() - state["fresh"] < 60: + backoff = 2 + + +def main(): + bot = Surge() + bot.load_set() + for tag in ("a", "b"): + threading.Thread(target=run_conn, args=(tag, bot), daemon=True).start() + last_set = time.time() + n = 0 + while True: + time.sleep(60) + n += 1 + with bot.lock: + bot._roll_day() + if n % 2 == 0: + with bot.lock: + bot.settle_pass() + if time.time() - last_set > 6 * 3600: + bot.load_set() + last_set = time.time() + c = bot.state["counters"] + log(f"paper ${bot.equity():.2f} (cash ${bot.state['cash']:.2f}) · " + f"open {len(bot.state['open'])} · trig {c['triggers']} " + f"fill {c['fills']} crater {c['craters']} " + f"skip {c['cash_skip']}c/{c['event_skip']}e") + + +if __name__ == "__main__": + main()