mirror of
https://github.com/cjudice-commits/prediction-market-arb.git
synced 2026-07-27 21:47:46 +00:00
bcc02cac49
Polymarket now publishes a HYPE monthly event
(what-price-will-hyperliquid-hit-in-{month}, no year suffix), so the
old strike-probe fallback was both unnecessary and mis-ranged -- its
strikes [24..52] predated HYPE roughly doubling to ~$72, so every
"above" market ($76-92) was invisible and all 8 above rows came back
NO POLY. Point HYPE at its event like every other asset and re-range
the probe fallback to the current band [52..92].
Regenerated June pairs.json: HYPE now 16/16 with a Poly leg (was 8/16),
total 110/126 (every non-ZEC row paired; ZEC stays Kalshi-only). Only
HYPE rows changed; the other 7 assets are byte-identical.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
265 lines
11 KiB
Python
265 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Regenerate data/pairs.json for a target settlement month from LIVE markets.
|
|
|
|
The monthly scanner is driven entirely by data/pairs.json (a curated list of
|
|
Kalshi tickers <-> Polymarket slugs). Those identifiers are month-specific, so
|
|
when May settles and June lists, the file must be rebuilt or the Monthly tab,
|
|
the SMS alerts, and the public dashboard all go stale.
|
|
|
|
This tool discovers both legs live and rebuilds the file:
|
|
|
|
* Kalshi -> the anchor. For each asset it walks the KX{ASSET}MAXMON /
|
|
MINMON series and keeps every market whose ticker carries the
|
|
target month's expiry code (e.g. 26JUN30). Strike = last ticker
|
|
segment / 100.
|
|
* Poly -> the coarser ladder. For each asset it reads that month's
|
|
multi-outcome event (forward-parsing each market's slug) and,
|
|
for assets with no event container (HYPE), probes known strikes.
|
|
* Join -> each Kalshi strike is matched to the NEAREST same-direction
|
|
Polymarket strike (replicating the curated file, which pairs a
|
|
fine Kalshi ladder against a coarse Poly one). Kalshi strikes
|
|
with no Poly market in that direction are emitted Poly-less and
|
|
surface as NO POLY in the scanner.
|
|
|
|
Usage:
|
|
python3 scripts/roll_month.py --month 2026-06 [--dry-run] [--force]
|
|
|
|
Safety: refuses to overwrite a populated pairs.json when discovery finds fewer
|
|
than --min-pairs markets (default 20) unless --force is given, so running it
|
|
before the new month lists can't wipe the working file. The previous file is
|
|
copied to pairs.json.bak on every write.
|
|
"""
|
|
import argparse
|
|
import calendar
|
|
import json
|
|
import re
|
|
import sys
|
|
import time
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
PAIRS = ROOT / "data" / "pairs.json"
|
|
KBASE = "https://api.elections.kalshi.com/trade-api/v2"
|
|
GBASE = "https://gamma-api.polymarket.com"
|
|
|
|
# asset -> Polymarket name + event-slug templates ({m}=month name, {y}=year).
|
|
# A `probe` strike list is a fallback used when the event container is missing
|
|
# (or hasn't listed yet) -- HYPE keeps one in case its event lags the others.
|
|
ASSETS = {
|
|
"BTC": dict(name="bitcoin", events=["what-price-will-bitcoin-hit-in-{m}-{y}",
|
|
"what-price-will-bitcoin-hit-in-{m}"]),
|
|
"ETH": dict(name="ethereum", events=["what-price-will-ethereum-hit-in-{m}-{y}",
|
|
"what-price-will-ethereum-hit-in-{m}"]),
|
|
"SOL": dict(name="solana", events=["what-price-will-solana-hit-in-{m}-{y}",
|
|
"what-price-will-solana-hit-in-{m}"]),
|
|
"XRP": dict(name="xrp", events=["what-price-will-xrp-hit-in-{m}-{y}",
|
|
"what-price-will-xrp-hit-in-{m}"]),
|
|
"DOGE": dict(name="dogecoin", events=["what-price-will-dogecoin-hit-in-{m}-{y}",
|
|
"what-price-will-dogecoin-hit-in-{m}"]),
|
|
"BNB": dict(name="bnb", events=["what-price-will-bnb-hit-in-{m}",
|
|
"what-price-will-bnb-hit-in-{m}-{y}"]),
|
|
"HYPE": dict(name="hyperliquid", events=["what-price-will-hyperliquid-hit-in-{m}",
|
|
"what-price-will-hyperliquid-hit-in-{m}-{y}"],
|
|
probe=[52, 56, 60, 64, 68, 72, 76, 80, 84, 88, 92]),
|
|
"ZEC": dict(name="zcash", events=["what-price-will-zcash-hit-in-{m}-{y}",
|
|
"what-price-will-zcash-hit-in-{m}"],
|
|
probe=[]),
|
|
}
|
|
|
|
|
|
def gj(url, tries=5):
|
|
"""GET JSON with exponential backoff. Kalshi/Poly rate-limit bursts, and a
|
|
silent failure mid-discovery would truncate the ladder, so retry hard."""
|
|
rq = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
|
delay = 0.5
|
|
for i in range(tries):
|
|
try:
|
|
with urllib.request.urlopen(rq, timeout=25) as r:
|
|
return json.loads(r.read().decode())
|
|
except Exception:
|
|
if i == tries - 1:
|
|
return None
|
|
time.sleep(delay)
|
|
delay = min(delay * 2, 8.0)
|
|
return None
|
|
|
|
|
|
def expiry_code(year, month):
|
|
"""2026-06 -> '26JUN30' (Kalshi monthly tickers end on the last day)."""
|
|
last = calendar.monthrange(year, month)[1]
|
|
return "%02d%s%02d" % (year % 100, calendar.month_abbr[month].upper(), last)
|
|
|
|
|
|
def parse_strike_slug(num, k):
|
|
"""'85','k' -> 85000 ; '1pt6','' -> 1.6 ; '100','' -> 100."""
|
|
val = float(num.replace("pt", "."))
|
|
return val * 1000.0 if k == "k" else val
|
|
|
|
|
|
# ----------------------------------------------------------------- Kalshi
|
|
def kalshi_ladder(asset, expcode):
|
|
"""{('above'|'below', strike): ticker} for the target month."""
|
|
out = {}
|
|
for suffix, direc in (("MAXMON", "above"), ("MINMON", "below")):
|
|
series = "KX%s%s" % (asset, suffix)
|
|
cursor = ""
|
|
for _ in range(12): # cursor-page guard
|
|
url = "%s/markets?limit=1000&series_ticker=%s" % (KBASE, series)
|
|
if cursor:
|
|
url += "&cursor=" + cursor
|
|
data = gj(url)
|
|
if not data:
|
|
break
|
|
for m in data.get("markets", []):
|
|
tk = m.get("ticker") or ""
|
|
if expcode not in tk:
|
|
continue
|
|
seg = tk.split("-")[-1]
|
|
if not seg.isdigit():
|
|
continue
|
|
out[(direc, int(seg) / 100.0)] = tk
|
|
cursor = data.get("cursor") or ""
|
|
if not cursor:
|
|
break
|
|
return out
|
|
|
|
|
|
# ------------------------------------------------------------- Polymarket
|
|
def poly_ladder(asset, cfg, month, year):
|
|
"""{('above'|'below', strike): slug} discovered for the target month."""
|
|
out = {}
|
|
name = cfg["name"]
|
|
# 1) event container (forward-parse every contained market slug)
|
|
for tmpl in cfg.get("events", []):
|
|
ev = gj("%s/events?slug=%s" % (GBASE,
|
|
tmpl.format(m=month, y=year)))
|
|
if not ev:
|
|
continue
|
|
for mk in ev[0].get("markets", []):
|
|
if mk.get("closed"):
|
|
continue
|
|
sl = mk.get("slug") or ""
|
|
m = re.search(r"-(dip-to|reach)-(\d+(?:pt\d+)?)(k?)-in-%s" % month, sl)
|
|
if not m:
|
|
continue
|
|
direc = "below" if m.group(1) == "dip-to" else "above"
|
|
out[(direc, parse_strike_slug(m.group(2), m.group(3)))] = sl
|
|
if out:
|
|
break # first template that works
|
|
# 2) direct probe for assets with no event (HYPE)
|
|
for strike in cfg.get("probe", []):
|
|
for direc, kw in (("above", "reach"), ("below", "dip-to")):
|
|
if (direc, float(strike)) in out:
|
|
continue
|
|
for variant in ("will-%s-%s-%s-in-%s" % (name, kw, strike, month),
|
|
"will-%s-%s-%s-in-%s-%s" % (name, kw, strike, month, year)):
|
|
a = gj("%s/markets?slug=%s" % (GBASE, urllib.parse.quote(variant)))
|
|
if a and not a[0].get("closed"):
|
|
out[(direc, float(strike))] = variant
|
|
break
|
|
return out
|
|
|
|
|
|
def nearest(poly_for_dir, strike):
|
|
"""(poly_strike, slug) closest to `strike`, or (None, None)."""
|
|
best = None
|
|
for ps, slug in poly_for_dir:
|
|
d = abs(ps - strike)
|
|
if best is None or d < best[0]:
|
|
best = (d, ps, slug)
|
|
return (best[1], best[2]) if best else (None, None)
|
|
|
|
|
|
# ------------------------------------------------------------------- main
|
|
def build(month_name, year):
|
|
rows, zero_kalshi = [], []
|
|
expcode = expiry_code(year, _MONTHNUM[month_name])
|
|
print("Target expiry code: %s Poly month: %s-%s" % (expcode, month_name, year))
|
|
for asset, cfg in ASSETS.items():
|
|
kl = kalshi_ladder(asset, expcode)
|
|
if not kl:
|
|
zero_kalshi.append(asset) # every tracked asset has a ladder;
|
|
# empty == a failed/throttled fetch
|
|
pl = poly_ladder(asset, cfg, month_name, year)
|
|
by_dir = {"above": [], "below": []}
|
|
for (d, ps), slug in pl.items():
|
|
by_dir[d].append((ps, slug))
|
|
n_pair = 0
|
|
for (direc, kstrike), tk in sorted(kl.items(),
|
|
key=lambda x: (x[0][0], -x[0][1])):
|
|
pstrike, slug = nearest(by_dir[direc], kstrike)
|
|
rows.append({
|
|
"asset": asset,
|
|
"kalshi_ticker": tk,
|
|
"kalshi_strike": kstrike,
|
|
"poly_slug": slug,
|
|
"poly_strike": pstrike,
|
|
"active": True,
|
|
})
|
|
n_pair += slug is not None
|
|
print(" %-5s kalshi=%-3d poly=%-3d -> %d rows (%d with Poly)"
|
|
% (asset, len(kl), len(pl), len(kl), n_pair))
|
|
return rows, zero_kalshi
|
|
|
|
|
|
_MONTHNUM = {calendar.month_name[i].lower(): i for i in range(1, 13)}
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--month", help="target month YYYY-MM (default: next month)")
|
|
ap.add_argument("--dry-run", action="store_true")
|
|
ap.add_argument("--force", action="store_true",
|
|
help="write even if fewer than --min-pairs discovered")
|
|
ap.add_argument("--min-pairs", type=int, default=20)
|
|
ap.add_argument("--out", default=str(PAIRS))
|
|
args = ap.parse_args()
|
|
|
|
if args.month:
|
|
y, m = (int(x) for x in args.month.split("-")[:2])
|
|
else:
|
|
t = date.today()
|
|
y, m = (t.year + 1, 1) if t.month == 12 else (t.year, t.month + 1)
|
|
month_name = calendar.month_name[m].lower()
|
|
|
|
rows, zero_kalshi = build(month_name, y)
|
|
with_poly = sum(1 for r in rows if r["poly_slug"])
|
|
print("\nDiscovered %d rows (%d with a Polymarket leg)." % (len(rows), with_poly))
|
|
|
|
if zero_kalshi and not args.force:
|
|
print("ABORT: Kalshi discovery returned nothing for %s — almost "
|
|
"certainly a throttled/failed fetch, not reality. Re-run in a "
|
|
"minute (or --force to write a partial file)."
|
|
% ", ".join(zero_kalshi), file=sys.stderr)
|
|
return 2
|
|
if rows and with_poly < 0.3 * len(rows):
|
|
print("WARNING: very few rows got a Polymarket leg (%d/%d). Polymarket "
|
|
"may have changed its %s event-slug pattern — check the per-asset "
|
|
"'poly=' counts above before relying on this file."
|
|
% (with_poly, len(rows), month_name), file=sys.stderr)
|
|
|
|
if len(rows) < args.min_pairs and not args.force:
|
|
print("ABORT: only %d rows (< --min-pairs %d). The %s markets may not "
|
|
"be listed yet. Re-run when they are, or pass --force."
|
|
% (len(rows), args.min_pairs, month_name), file=sys.stderr)
|
|
return 2
|
|
if args.dry_run:
|
|
print("--dry-run; not writing.")
|
|
return 0
|
|
|
|
out = Path(args.out)
|
|
if out.exists():
|
|
bak = out.with_suffix(".json.bak")
|
|
bak.write_text(out.read_text())
|
|
print("Backed up existing -> %s" % bak)
|
|
out.write_text(json.dumps(rows, indent=2) + "\n")
|
|
print("Wrote %s (%d rows)." % (out, len(rows)))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|