mirror of
https://github.com/cjudice-commits/prediction-market-arb.git
synced 2026-07-27 13:37:46 +00:00
05c0441053
- 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>
162 lines
4.9 KiB
Python
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
|