Files
Kalshi_Polymarket/scripts/roll_month.py
T
Casey Judice bcc02cac49 Fix HYPE Polymarket discovery: read its event container
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>
2026-06-01 17:21:18 -04:00

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())