Files
Casey Judice 05c0441053 Initial commit: prediction-market arb scanner + GHA alerts
- Monthly + hourly Kalshi/Polymarket arb scanner (stdlib-only Python).
- Live positions tab w/ realized P&L history.
- GitHub Actions cron workflow texts SMS via Apps Script webhook on
  newly-detected arbs. State persisted in alerts_state.json.

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 19:48:50 -04:00

162 lines
4.9 KiB
Python

"""Polymarket client.
- Gamma /markets?slug=a&slug=b... (BATCHED, ~25 slugs/call) -> token ids,
volume, end date, open/closed flags, image, description.
- CLOB POST /books (1 batched call for ALL tokens) ->
executable best ask price + size for the YES and NO books separately.
"""
import json
from .net import get_json, post_json, FetchError
_SLUG_CHUNK = 25
GAMMA = "https://gamma-api.polymarket.com/markets"
CLOB_BOOKS = "https://clob.polymarket.com/books"
def _loads(s, default):
if isinstance(s, (list, dict)):
return s
try:
return json.loads(s)
except (TypeError, ValueError):
return default
def _parse_meta(m):
slug = m.get("slug")
outcomes = [str(o).strip().lower() for o in _loads(m.get("outcomes"), [])]
tokens = _loads(m.get("clobTokenIds"), [])
yes_id = no_id = None
for i, oc in enumerate(outcomes):
if i >= len(tokens):
break
if oc == "yes":
yes_id = str(tokens[i])
elif oc == "no":
no_id = str(tokens[i])
if yes_id is None and len(tokens) >= 1:
yes_id = str(tokens[0])
if no_id is None and len(tokens) >= 2:
no_id = str(tokens[1])
desc = (m.get("description") or "").strip()
if len(desc) > 420:
desc = desc[:419].rstrip() + "…"
return {
"slug": slug,
"question": m.get("question"),
"description": desc,
"image": m.get("image") or m.get("icon"),
"icon": m.get("icon") or m.get("image"),
"yes_id": yes_id,
"no_id": no_id,
"volume": float(m.get("volumeNum") or 0) or 0.0,
"end_date": (m.get("endDate") or "")[:10] or None,
"closed": bool(m.get("closed")),
"active": bool(m.get("active")),
}
def _best_ask(book):
"""Lowest-price ask level -> (price, size). Order-agnostic."""
best = None
for lvl in (book or {}).get("asks") or []:
try:
p = float(lvl["price"])
s = float(lvl["size"])
except (TypeError, ValueError, KeyError):
continue
if best is None or p < best[0]:
best = (p, s)
return best
def _best_bid(book):
"""Highest-price bid level -> price (what you could sell into now)."""
best = None
for lvl in (book or {}).get("bids") or []:
try:
p = float(lvl["price"])
except (TypeError, ValueError, KeyError):
continue
if best is None or p > best:
best = p
return best
def fetch_token_bids(token_ids):
"""Batched CLOB books -> {token_id: best_bid_price}. For valuing held
positions at the executable exit price (not last/mid)."""
ids = [str(t) for t in dict.fromkeys(token_ids) if t]
out = {}
for i in range(0, len(ids), 100):
chunk = ids[i:i + 100]
try:
resp = post_json(CLOB_BOOKS, [{"token_id": t} for t in chunk])
except FetchError:
continue
for b in resp or []:
out[str(b.get("asset_id"))] = _best_bid(b)
return out
def _fetch_metas(uniq):
"""Batched Gamma fetch. Returns {slug: parsed_meta} (missing slugs absent)."""
metas = {}
for i in range(0, len(uniq), _SLUG_CHUNK):
chunk = uniq[i:i + _SLUG_CHUNK]
url = "%s?limit=%d&%s" % (GAMMA, len(chunk) + 5,
"&".join("slug=%s" % s for s in chunk))
try:
for m in get_json(url) or []:
if m.get("slug"):
metas[m["slug"]] = _parse_meta(m)
except FetchError:
continue
return metas
def fetch_quotes(slugs):
"""slugs: iterable. Returns {slug: quote|None} with executable YES/NO asks."""
uniq = sorted({s for s in slugs if s})
metas = _fetch_metas(uniq)
token_ids = []
for v in metas.values():
for tid in (v["yes_id"], v["no_id"]):
if tid:
token_ids.append(tid)
books = {}
if token_ids:
try:
resp = post_json(CLOB_BOOKS, [{"token_id": t} for t in token_ids])
for b in resp or []:
books[str(b.get("asset_id"))] = b
except FetchError:
books = {}
out = {}
for slug in uniq:
v = metas.get(slug)
if v is None:
out[slug] = None
continue
ya = _best_ask(books.get(v["yes_id"] or ""))
na = _best_ask(books.get(v["no_id"] or ""))
out[slug] = {
"slug": slug,
"question": v["question"],
"description": v["description"],
"image": v["image"],
"icon": v["icon"],
"yes_ask": ya[0] if ya else None,
"yes_ask_size": ya[1] if ya else None,
"no_ask": na[0] if na else None,
"no_ask_size": na[1] if na else None,
"volume": v["volume"],
"end_date": v["end_date"],
"closed": v["closed"],
}
return out