mirror of
https://github.com/cjudice-commits/prediction-market-arb.git
synced 2026-07-27 13:37:46 +00:00
b7ccd14b05
The IBKR Client Portal Gateway has an aggressive ~5min inactivity timeout that drops the session if no API calls are made (causing 401s on the next Daily-tab refresh). A daemon thread alongside the SMS poller now calls ibkr.auth_status + tickle every minute as long as the server is running. No-op when Gateway isn't up or not authed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
139 lines
4.4 KiB
Python
139 lines
4.4 KiB
Python
"""Background arb poller that texts on newly-detected ARBs.
|
|
|
|
Polls `scan.run_scan` on an interval, tracks the set of currently-ARB pair
|
|
ids in memory, and sends an SMS via the configured Apps Script webhook only
|
|
when a pair id newly appears. On first cycle we "silently prime" — record
|
|
what's currently ARB without firing — so restarting the server does NOT
|
|
produce a text storm. A pair that disappears (e.g. price moved) leaves the
|
|
set and will re-fire if it returns later.
|
|
|
|
Config (all required, otherwise the poller no-ops):
|
|
data/secrets.json -> sms_webhook, sms_phone, sms_carrier
|
|
"""
|
|
import json
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.request
|
|
|
|
from . import positions, scan
|
|
|
|
POLL_INTERVAL = 60 # seconds between scans
|
|
START_DELAY = 5 # let the HTTP server warm up before the first scan
|
|
SMS_GAP = 0.5 # space out multiple texts in a single cycle
|
|
|
|
_state = {"primed": False, "active": set()}
|
|
|
|
|
|
def _log(msg):
|
|
sys.stderr.write("[alerts] %s\n" % msg)
|
|
sys.stderr.flush()
|
|
|
|
|
|
def _send(message, webhook, phone, carrier):
|
|
body = json.dumps({"message": message, "to": phone,
|
|
"carrier": carrier}).encode()
|
|
req = urllib.request.Request(
|
|
webhook, data=body,
|
|
headers={"Content-Type": "application/json"}, method="POST")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=15) as r:
|
|
ok = r.status == 200
|
|
if not ok:
|
|
_log("SMS webhook returned %s" % r.status)
|
|
return ok
|
|
except Exception as e:
|
|
_log("SMS send failed: %s: %s" % (type(e).__name__, e))
|
|
return False
|
|
|
|
|
|
def _format(row):
|
|
"""SMS-friendly, ASCII, ~80 chars (one segment)."""
|
|
asset = row.get("asset") or "?"
|
|
ks = row.get("kalshi_strike")
|
|
side = (row.get("best_side") or "").replace("YES", "Y").replace("NO", "N")
|
|
net = (row.get("net_return") or 0) * 100
|
|
ann = (row.get("annualized") or 0) * 100
|
|
ct = int(row.get("max_contracts") or 0)
|
|
gain = row.get("total_gain") or 0
|
|
return "ARB %s %s %s net %+.2f%% ann %+.0f%% %dct $%.2f" % (
|
|
asset, ks, side, net, ann, ct, gain)
|
|
|
|
|
|
def _pair_id(row):
|
|
return "%s|%s" % (row.get("kalshi_ticker") or "",
|
|
row.get("poly_slug") or "")
|
|
|
|
|
|
def _cycle():
|
|
secrets = positions.load_secrets()
|
|
webhook = secrets.get("sms_webhook") or ""
|
|
phone = secrets.get("sms_phone") or ""
|
|
carrier = secrets.get("sms_carrier") or ""
|
|
if not (webhook and phone and carrier):
|
|
return # SMS not configured -> no-op
|
|
|
|
try:
|
|
payload = scan.run_scan(force=False)
|
|
except Exception as e:
|
|
_log("scan failed: %s" % e)
|
|
return
|
|
|
|
settings = payload.get("settings", {})
|
|
min_ret = settings.get("min_net_return", 0)
|
|
|
|
current = {}
|
|
for r in payload.get("rows", []):
|
|
if r.get("status") == "ARB" and (r.get("net_return") or 0) >= min_ret:
|
|
current[_pair_id(r)] = r
|
|
|
|
if not _state["primed"]:
|
|
_state["active"] = set(current)
|
|
_state["primed"] = True
|
|
_log("primed: %d currently-active arb(s), watching for new ones"
|
|
% len(current))
|
|
return
|
|
|
|
new = set(current) - _state["active"]
|
|
for pid in new:
|
|
msg = _format(current[pid])
|
|
if _send(msg, webhook, phone, carrier):
|
|
_log("sent: %s" % msg)
|
|
time.sleep(SMS_GAP)
|
|
_state["active"] = set(current)
|
|
|
|
|
|
def _loop():
|
|
time.sleep(START_DELAY)
|
|
while True:
|
|
try:
|
|
_cycle()
|
|
except Exception as e:
|
|
_log("cycle crashed: %s: %s" % (type(e).__name__, e))
|
|
time.sleep(POLL_INTERVAL)
|
|
|
|
|
|
def _ibkr_keepalive():
|
|
"""Tickles the IBKR Client Portal Gateway every minute so its short
|
|
inactivity timeout (~5min) doesn't drop the session out from under us.
|
|
Silent no-op when the Gateway isn't running / not authed."""
|
|
time.sleep(START_DELAY)
|
|
while True:
|
|
try:
|
|
from . import ibkr
|
|
if ibkr.auth_status().get("authenticated"):
|
|
ibkr.tickle()
|
|
except Exception:
|
|
pass
|
|
time.sleep(60)
|
|
|
|
|
|
def start_poller():
|
|
"""Spawn the daemon thread(s). Safe to call once at server startup."""
|
|
threading.Thread(target=_loop, daemon=True,
|
|
name="arb-sms-poller").start()
|
|
threading.Thread(target=_ibkr_keepalive, daemon=True,
|
|
name="ibkr-keepalive").start()
|
|
_log("poller started (interval=%ds); ibkr keepalive enabled" %
|
|
POLL_INTERVAL)
|