Files
Kalshi_Polymarket/scripts/gha_alerts.py
T
Casey Judice 5e6889ec7b Static dashboard via GitHub Pages (docs/) + GHA snapshot writes
Adds a phone-friendly public dashboard served from docs/ via GitHub Pages:
- docs/index.html, app.js, style.css: monthly + hourly tabs only, no
  positions/settings (positions stay on the local Mac UI). Reads
  ./data/scan.json and ./data/hourly.json instead of /api endpoints.
- gha_alerts.py also writes both snapshots to docs/data/ each cron tick.
- Workflow now commits docs/data/ alongside alerts_state.json.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:13:47 -04:00

135 lines
4.5 KiB
Python

#!/usr/bin/env python3
"""One alert cycle, designed for GitHub Actions cron.
- Runs `scan.run_scan` (public Kalshi + Polymarket APIs; no account creds
needed for monthly arb discovery).
- Reads/writes `alerts_state.json` at the repo root so successive cron runs
remember which arbs were already alerted on. The workflow commits this
file back to the repo (concurrency-guarded).
- On the very first run (no state file yet) we *silently prime* — record
the currently-active arb set without firing — so you don't get a text
storm the moment you enable the workflow.
- SMS goes via your existing Apps Script webhook (`SMS_WEBHOOK`), which
email-relays to your carrier gateway (`SMS_PHONE@SMS_CARRIER`).
- No secrets live on disk in the repo; all SMS values come from GitHub
Actions Secrets via env vars.
"""
import json
import os
import sys
import time
import urllib.request
import urllib.error
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from arb import scan # noqa: E402 (after sys.path)
STATE_FILE = ROOT / "alerts_state.json"
def load_state():
try:
return set(json.loads(STATE_FILE.read_text()).get("active", []))
except (OSError, ValueError):
return set()
def save_state(active):
STATE_FILE.write_text(json.dumps({
"active": sorted(active),
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}, indent=2) + "\n")
def send_sms(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=20) as r:
return r.status == 200
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as e:
print("send failed: %s: %s" % (type(e).__name__, e))
return False
def fmt(r):
asset = r.get("asset") or "?"
ks = r.get("kalshi_strike")
side = (r.get("best_side") or "").replace("YES", "Y").replace("NO", "N")
net = (r.get("net_return") or 0) * 100
ann = (r.get("annualized") or 0) * 100
ct = int(r.get("max_contracts") or 0)
gain = r.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(r):
return "%s|%s" % (r.get("kalshi_ticker") or "",
r.get("poly_slug") or "")
DOCS_DATA = ROOT / "docs" / "data"
def write_snapshot(name, payload):
DOCS_DATA.mkdir(parents=True, exist_ok=True)
path = DOCS_DATA / (name + ".json")
path.write_text(json.dumps(payload, separators=(",", ":")) + "\n")
print("snapshot: docs/data/%s.json (%d rows)" %
(name, len(payload.get("rows", []) or [])))
def main():
# 1) Monthly scan -> snapshot + alert source.
payload = scan.run_scan(force=True)
write_snapshot("scan", payload)
# 2) Hourly snapshot for the public dashboard. Failure here doesn't
# block the monthly alert path.
try:
write_snapshot("hourly", scan.run_hourly(force=True))
except Exception as e:
print("hourly snapshot failed: %s: %s" % (type(e).__name__, e))
# 3) Monthly alerts (SMS). No-op if SMS env vars aren't configured.
webhook = os.environ.get("SMS_WEBHOOK", "").strip()
phone = os.environ.get("SMS_PHONE", "").strip()
carrier = os.environ.get("SMS_CARRIER", "").strip()
if not (webhook and phone and carrier):
print("SMS env vars missing — snapshots written, no texts sent")
return
settings = payload.get("settings", {})
min_ret = settings.get("min_net_return", 0)
current = {pair_id(r): r for r in payload.get("rows", [])
if r.get("status") == "ARB"
and (r.get("net_return") or 0) >= min_ret}
if not STATE_FILE.exists():
save_state(set(current))
print("primed (first run): %d active arb(s) recorded, no texts sent"
% len(current))
return
prev = load_state()
new = sorted(set(current) - prev)
for pid in new:
msg = fmt(current[pid])
if send_sms(msg, webhook, phone, carrier):
print("sent: %s" % msg)
time.sleep(0.5)
save_state(set(current))
print("cycle: %d active, %d new (sent), %d unchanged"
% (len(current), len(new), len(current) - len(new)))
if __name__ == "__main__":
main()