commit 05c0441053bbd80e80834eff59dd7ebebd4374f3 Author: Casey Judice Date: Wed May 20 19:48:50 2026 -0400 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 diff --git a/.github/workflows/alerts.yml b/.github/workflows/alerts.yml new file mode 100644 index 000000000..cfaf53982 --- /dev/null +++ b/.github/workflows/alerts.yml @@ -0,0 +1,48 @@ +name: arb-alerts + +# Runs the monthly-arb scanner every ~5 min on GitHub's runners and texts +# you (via your Apps Script SMS webhook) when a NEW arb opportunity appears. +# State (which arbs were already-alerted) lives in alerts_state.json and is +# committed back by the workflow itself so cron runs remember each other. + +on: + schedule: + - cron: "*/5 * * * *" # 5-min cadence (GH min; expect ~5-15m lag) + workflow_dispatch: {} # manual "Run workflow" button in the UI + +permissions: + contents: write # needed to commit alerts_state.json back + +concurrency: + group: arb-alerts + cancel-in-progress: false # let an in-flight run finish before the next + +jobs: + scan: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Run alert poller + env: + SMS_WEBHOOK: ${{ secrets.SMS_WEBHOOK }} + SMS_PHONE: ${{ secrets.SMS_PHONE }} + SMS_CARRIER: ${{ secrets.SMS_CARRIER }} + run: python3 scripts/gha_alerts.py + + - name: Persist state if changed + run: | + if [ -z "$(git status --porcelain alerts_state.json)" ]; then + echo "no state change" + exit 0 + fi + git config user.email "actions@users.noreply.github.com" + git config user.name "arb-alerts" + git add alerts_state.json + git commit -m "alerts: update state [skip ci]" + git push diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..0528d9faa --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Secrets β€” never commit. Provide your own data/secrets.json locally. +data/secrets.json + +# Python +__pycache__/ +*.pyc + +# Local preview staging +.arb-preview/ + +# Local Claude Code config (launch.json, settings.local.json, worktrees, etc.) +.claude/ + +# macOS +.DS_Store diff --git a/arb/__init__.py b/arb/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/arb/alerts.py b/arb/alerts.py new file mode 100644 index 000000000..b7f410e73 --- /dev/null +++ b/arb/alerts.py @@ -0,0 +1,120 @@ +"""Background arb poller that texts on newly-detected ARBs. + +Polls `scan.run_scan` on an interval, tracks the set of currently-ARB pair +ids in memory, and sends an SMS via the configured Apps Script webhook only +when a pair id newly appears. On first cycle we "silently prime" β€” record +what's currently ARB without firing β€” so restarting the server does NOT +produce a text storm. A pair that disappears (e.g. price moved) leaves the +set and will re-fire if it returns later. + +Config (all required, otherwise the poller no-ops): + data/secrets.json -> sms_webhook, sms_phone, sms_carrier +""" +import json +import sys +import threading +import time +import urllib.request + +from . import positions, scan + +POLL_INTERVAL = 60 # seconds between scans +START_DELAY = 5 # let the HTTP server warm up before the first scan +SMS_GAP = 0.5 # space out multiple texts in a single cycle + +_state = {"primed": False, "active": set()} + + +def _log(msg): + sys.stderr.write("[alerts] %s\n" % msg) + sys.stderr.flush() + + +def _send(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=15) as r: + ok = r.status == 200 + if not ok: + _log("SMS webhook returned %s" % r.status) + return ok + except Exception as e: + _log("SMS send failed: %s: %s" % (type(e).__name__, e)) + return False + + +def _format(row): + """SMS-friendly, ASCII, ~80 chars (one segment).""" + asset = row.get("asset") or "?" + ks = row.get("kalshi_strike") + side = (row.get("best_side") or "").replace("YES", "Y").replace("NO", "N") + net = (row.get("net_return") or 0) * 100 + ann = (row.get("annualized") or 0) * 100 + ct = int(row.get("max_contracts") or 0) + gain = row.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(row): + return "%s|%s" % (row.get("kalshi_ticker") or "", + row.get("poly_slug") or "") + + +def _cycle(): + secrets = positions.load_secrets() + webhook = secrets.get("sms_webhook") or "" + phone = secrets.get("sms_phone") or "" + carrier = secrets.get("sms_carrier") or "" + if not (webhook and phone and carrier): + return # SMS not configured -> no-op + + try: + payload = scan.run_scan(force=False) + except Exception as e: + _log("scan failed: %s" % e) + return + + settings = payload.get("settings", {}) + min_ret = settings.get("min_net_return", 0) + + current = {} + for r in payload.get("rows", []): + if r.get("status") == "ARB" and (r.get("net_return") or 0) >= min_ret: + current[_pair_id(r)] = r + + if not _state["primed"]: + _state["active"] = set(current) + _state["primed"] = True + _log("primed: %d currently-active arb(s), watching for new ones" + % len(current)) + return + + new = set(current) - _state["active"] + for pid in new: + msg = _format(current[pid]) + if _send(msg, webhook, phone, carrier): + _log("sent: %s" % msg) + time.sleep(SMS_GAP) + _state["active"] = set(current) + + +def _loop(): + time.sleep(START_DELAY) + while True: + try: + _cycle() + except Exception as e: + _log("cycle crashed: %s: %s" % (type(e).__name__, e)) + time.sleep(POLL_INTERVAL) + + +def start_poller(): + """Spawn the daemon thread. Safe to call once at server startup.""" + t = threading.Thread(target=_loop, daemon=True, name="arb-sms-poller") + t.start() + _log("poller started (interval=%ds)" % POLL_INTERVAL) diff --git a/arb/calc.py b/arb/calc.py new file mode 100644 index 000000000..577430232 --- /dev/null +++ b/arb/calc.py @@ -0,0 +1,210 @@ +"""Arb math, ported from the workbook formulas. + +Authoritative source: the *Arb Positions* sheet (it holds real Excel formulas; +the *Arb Scanner* sheet only holds script-computed values). + + Direction C = IF(SEARCH("MINMON",ticker), "Below", "Above") + Basis % AC = IF("Above",(kStrike-pStrike)/kStrike,(pStrike-kStrike)/pStrike) + Kalshi fee AI = (rate*size*price)*(1-price) -> per-contract: rate*p*(1-p) + Poly fee AJ = size*rate*price*(1-price) -> per-contract: rate*p*(1-p) + Per leg win -> (1 - price) - fee (L-AI in the sheet) + lose -> (- price) - fee (-K-AI in the sheet) + Min Gain AA = MIN(W+X, Y+Z) <- the true guaranteed P&L + In Between AD = X+Y <- P&L in the strike-gap region + % Return AG = pnl / outlay + Annualized AH = AG * (365 / (close - open)) + +A clean arb pays $1 from exactly one leg only when BOTH contracts ask the same +question (same strike + direction). When strikes differ there is a price band +between the two strikes where the position can double-WIN (free money) or +double-LOSE (basis risk). The sheet surfaces this via AA and AD; we evaluate +all resolution regions and key off the guaranteed worst case, NOT (1 - cost). +""" +from datetime import date + + +def direction(ticker): + return "Below" if "MINMON" in (ticker or "").upper() else "Above" + + +def basis_pct(kalshi_strike, poly_strike, direc): + if not kalshi_strike or not poly_strike: + return None + if direc == "Above": + return (kalshi_strike - poly_strike) / kalshi_strike + return (poly_strike - kalshi_strike) / poly_strike + + +def _days_to(expiry_iso, today=None): + if not expiry_iso: + return None + try: + y, m, d = (int(x) for x in expiry_iso.split("-")[:3]) + delta = (date(y, m, d) - (today or date.today())).days + return delta if delta > 0 else None + except (ValueError, TypeError): + return None + + +def _stmt_true(price, strike, is_above): + return price >= strike if is_above else price <= strike + + +def _leg(price, fee, won): + """Per-contract P&L for one leg. Mirrors L-AI / -K-AI in Arb Positions.""" + return ((1.0 - price) - fee) if won else ((-price) - fee) + + +def _scenarios(ks, ps, is_above): + """Representative prices covering every resolution region. + + Returns list of (kalshi_stmt_true, poly_stmt_true). The mid region only + exists when strikes differ; that region is the strike-gap / basis zone. + """ + lo = min(ks, ps) * 0.5 + hi = max(ks, ps) * 1.5 + 1.0 + pts = [lo, hi] + if ks != ps: + pts.append((ks + ps) / 2.0) + return [(_stmt_true(p, ks, is_above), _stmt_true(p, ps, is_above)) + for p in pts] + + +def evaluate(pair, kq, pq, settings, today=None): + """Build one scanner row from a pair + its Kalshi/Poly quotes.""" + tkr = pair["kalshi_ticker"] + direc = direction(tkr) + ks = pair.get("kalshi_strike") + ps = pair.get("poly_strike") + bpct = basis_pct(ks, ps, direc) + + row = { + "asset": pair.get("asset"), + "kalshi_ticker": tkr, + "kalshi_strike": ks, + "poly_slug": pair.get("poly_slug"), + "poly_strike": ps, + "direction": direc, + "basis_pct": bpct, + "basis_favorable": None, + "best_side": None, + "kalshi_price": None, "kalshi_size": None, + "poly_price": None, "poly_size": None, + "combined_cost": None, "kalshi_fee": None, "poly_fee": None, + "total_fee": None, + "worst_pnl": None, # guaranteed P&L / contract (sheet "Min Gain") + "best_pnl": None, # best-case P&L / contract (sheet "Max Gain") + "mid_pnl": None, # strike-gap P&L (sheet "In Between") + "net_return": None, # worst_pnl / combined_cost + "annualized": None, + "max_contracts": None, "total_gain": None, + "poly_volume": (pq or {}).get("volume"), + "days_to_expiry": None, + "status": None, + # Display metadata straight from the venues' APIs. + "kalshi_title": (kq or {}).get("title"), + "kalshi_rules": (kq or {}).get("rules"), + "kalshi_yes_label": (kq or {}).get("yes_label"), + "kalshi_no_label": (kq or {}).get("no_label"), + "poly_question": (pq or {}).get("question"), + "poly_description": (pq or {}).get("description"), + "image": (pq or {}).get("image") or (pq or {}).get("icon"), + } + + if not pair.get("poly_slug"): + row["status"] = "NO PAIR" + return row + if not kq or not pq or not ks or not ps: + row["status"] = "NO DATA" + return row + + kfee_rate = settings["kalshi_fee_rate"] + pfee_rate = settings["poly_fee_rate"] + is_above = (direc == "Above") + scen = _scenarios(ks, ps, is_above) + + # Kalshi top-of-book size is only fetched for candidates; until then fall + # back to open interest as a liquidity proxy for the size gate. + k_oi = kq.get("open_interest") + k_ysz = kq.get("yes_ask_size") if kq.get("yes_ask_size") is not None else k_oi + k_nsz = kq.get("no_ask_size") if kq.get("no_ask_size") is not None else k_oi + + # Candidate hedged pairings: hold opposite sides across the two venues. + cands = [] + if kq.get("yes_ask") and pq.get("no_ask"): + cands.append(("YES+NO", "YES", kq["yes_ask"], k_ysz, + "NO", pq["no_ask"], pq.get("no_ask_size"))) + if kq.get("no_ask") and pq.get("yes_ask"): + cands.append(("NO+YES", "NO", kq["no_ask"], k_nsz, + "YES", pq["yes_ask"], pq.get("yes_ask_size"))) + if not cands: + row["status"] = "NO DATA" + return row + + best = None # (worst_pnl, ...) + for label, kside, kp, ksz, pside, pp, psz in cands: + kfee = kfee_rate * kp * (1 - kp) + pfee = pfee_rate * pp * (1 - pp) + k_yes = (kside == "YES") + p_yes = (pside == "YES") + pnls = [] + for kt, pt in scen: + kp_l = _leg(kp, kfee, kt == k_yes) + pp_l = _leg(pp, pfee, pt == p_yes) + pnls.append(kp_l + pp_l) + worst = min(pnls) + bestc = max(pnls) + mid = pnls[2] if len(pnls) > 2 else None + cand = (worst, bestc, mid, label, kside, kp, ksz, pside, pp, psz, + kfee, pfee) + if best is None or worst > best[0]: + best = cand + + (worst, bestc, mid, label, kside, kp, ksz, pside, pp, psz, + kfee, pfee) = best + cost = kp + pp + total_fee = kfee + pfee + net_return = worst / cost if cost else None + + expiry = kq.get("expiry") or pq.get("end_date") + days = _days_to(expiry, today) + annualized = (net_return * (365.0 / days) + if (net_return is not None and days) else None) + + sizes = [s for s in (ksz, psz) if s is not None] + max_contracts = min(sizes) if sizes else None + total_gain = worst * max_contracts if max_contracts is not None else None + + # Favorable basis == strikes match, or the gap region is not a double-loss. + fav = (ks == ps) or (mid is not None and mid >= -1e-9) + + row.update({ + "basis_favorable": fav, + "best_side": label, + "kalshi_price": kp, "kalshi_size": ksz, + "poly_price": pp, "poly_size": psz, + "combined_cost": cost, "kalshi_fee": kfee, "poly_fee": pfee, + "total_fee": total_fee, + "worst_pnl": worst, "best_pnl": bestc, "mid_pnl": mid, + "net_return": net_return, "annualized": annualized, + "max_contracts": max_contracts, "total_gain": total_gain, + "days_to_expiry": days, + }) + + # Status precedence mirrors the workbook: DATA > BASIS > RETURN > SIZE. + min_ret = settings["min_net_return"] + min_vol = settings["min_poly_volume"] + min_ct = settings["min_contracts"] + has_edge = net_return is not None and net_return >= min_ret + size_ok = (max_contracts is None or max_contracts >= min_ct) \ + and (row["poly_volume"] or 0) >= min_vol + + if not fav and not has_edge: + row["status"] = "BAD BASIS" # strike-gap double-loss kills it + elif not has_edge: + row["status"] = "NO ARB" # no guaranteed edge + elif not size_ok: + row["status"] = "LOW SIZE" # real edge, but untradeable size/volume + else: + row["status"] = "ARB" + return row diff --git a/arb/hourly.py b/arb/hourly.py new file mode 100644 index 000000000..596efb662 --- /dev/null +++ b/arb/hourly.py @@ -0,0 +1,205 @@ +"""Hourly scanner: Kalshi hourly strike ladder vs Polymarket "Up or Down". + +Pairing model +------------- +Polymarket "{ASSET} Up or Down - {date} {H}{am/pm} ET" resolves Up if the +Binance 1h candle [H:00 -> H+1:00] closes >= it opens. So its *implied strike* +is the Binance candle OPEN at H:00, and it settles at H+1:00 ET. + +Kalshi `KX{SYM}D-{YYMMMDD}{HH}-T{strike}` resolves Yes if the asset is >= +strike at HH:00 ET (CF Benchmarks). We pick the Kalshi market that settles at +the Polymarket window's CLOSE hour, with the strike nearest the Binance open. +Then Kalshi-Yes ~= Polymarket-Up, and the existing worst-case math handles the +(discrete strike) vs (exact open) gap as basis. + +This is NOT a locked arb: the two venues settle on different price feeds +(CF Benchmarks vs Binance). `refs` surfaces that divergence live per asset. +""" +import time +from datetime import datetime, timezone, timedelta + +from .net import get_json, FetchError +from . import poly, refs +from .calc import evaluate + +KALSHI = "https://api.elections.kalshi.com/trade-api/v2" + +# asset -> (kalshi hourly series, polymarket slug name) +MARKETS = { + "BTC": ("KXBTCD", "bitcoin"), + "ETH": ("KXETHD", "ethereum"), + "SOL": ("KXSOLD", "solana"), + "XRP": ("KXXRPD", "xrp"), + "DOGE": ("KXDOGED", "dogecoin"), + "BNB": ("KXBNBD", "bnb"), +} +_MON = ["january", "february", "march", "april", "may", "june", "july", + "august", "september", "october", "november", "december"] + +try: + from zoneinfo import ZoneInfo + _ET = ZoneInfo("America/New_York") +except Exception: # no tzdata -> EDT (valid Mar-Nov) + _ET = timezone(timedelta(hours=-4)) + + +def _et(ms): + return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).astimezone(_ET) + + +def _poly_slug(name, dt_et): + h = dt_et.hour + ampm = "am" if h < 12 else "pm" + h12 = h % 12 or 12 + return "%s-up-or-down-%s-%d-%d-%d%s-et" % ( + name, _MON[dt_et.month - 1], dt_et.day, dt_et.year, h12, ampm) + + +def _strike_from_ticker(t): + """KX..-T89799.99 -> 89799.99 ; range/below buckets -> None.""" + i = t.rfind("-T") + if i == -1: + return None + try: + return float(t[i + 2:]) + except ValueError: + return None + + +def _kalshi_ladder(series): + d = get_json("%s/markets?series_ticker=%s&status=open&limit=1000" + % (KALSHI, series)) + return d.get("markets", []) + + +def _pick(markets, close_iso, target): + """Among markets settling at close_iso, the '... or above' market whose + strike is nearest `target`.""" + best = None + for m in markets: + if (m.get("close_time") or "")[:16] != close_iso[:16]: + continue + if "or above" not in (m.get("yes_sub_title") or "").lower(): + continue + k = _strike_from_ticker(m.get("ticker") or "") + if k is None: + continue + d = abs(k - target) + if best is None or d < best[0]: + best = (d, k, m) + return best # (dist, strike, market) | None + + +def _kq(m, strike): + def f(v): + try: + x = float(v) + return x if x > 0 else None + except (TypeError, ValueError): + return None + return { + "ticker": m.get("ticker"), + "yes_ask": f(m.get("yes_ask_dollars")), + "no_ask": f(m.get("no_ask_dollars")), + "yes_bid": f(m.get("yes_bid_dollars")), + "no_bid": f(m.get("no_bid_dollars")), + "yes_ask_size": None, "no_ask_size": None, + "open_interest": f(m.get("open_interest_fp")) or 0.0, + "status": m.get("status"), + "expiry": None, # intraday; handled via minutes field + "title": m.get("title"), + "yes_label": m.get("yes_sub_title"), + "no_label": m.get("no_sub_title"), + "rules": (m.get("rules_primary") or "").strip()[:360], + } + + +def run(settings, assets=None): + assets = assets or list(MARKETS) + rf = refs.fetch_refs(assets) + + # Build the current-hour Polymarket slug per asset, fetch them batched. + slug_of, want = {}, [] + for a in assets: + r = rf.get(a) + if not r: + continue + start_et = _et(r["hour_open_ms"]) + slug = _poly_slug(MARKETS[a][1], start_et) + slug_of[a] = (slug, r, start_et) + want.append(slug) + pq_all = poly.fetch_quotes(want) if want else {} + + now_ms = time.time() * 1000 + rows = [] + for a in assets: + meta = slug_of.get(a) + r = rf.get(a) + base = { + "asset": a, + "implied_strike": r["hour_open"] if r else None, + "binance_spot": r["binance_spot"] if r else None, + "cf_spot": r["cf_spot"] if r else None, + "divergence": r["divergence"] if r else None, + } + if not meta: + rows.append({**_empty_row(a), **base, "status": "NO DATA"}) + continue + slug, ref, start_et = meta + close_dt = start_et + timedelta(hours=1) + close_iso = (start_et.astimezone(timezone.utc) + + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%S") + + series = MARKETS[a][0] + try: + ladder = _kalshi_ladder(series) + except FetchError: + ladder = [] + pick = _pick(ladder, close_iso, ref["hour_open"]) + pq = pq_all.get(slug) + + if not pick or not pq: + rows.append({ + **_empty_row(a), **base, + "poly_slug": slug, "poly_question": (pq or {}).get("question"), + "window_start": start_et.strftime("%H:%M ET"), + "window_close": close_dt.strftime("%H:%M ET"), + "minutes_to_resolve": max(0, round( + (ref["hour_open_ms"] + 3600_000 - now_ms) / 60000)), + "status": "NO DATA" if not pq else "NO KALSHI", + }) + continue + + _, kstrike, kmkt = pick + pair = { + "asset": a, "kalshi_ticker": kmkt.get("ticker"), + "kalshi_strike": kstrike, "poly_slug": slug, + "poly_strike": ref["hour_open"], "active": True, + } + row = evaluate(pair, _kq(kmkt, kstrike), pq, settings) + row.update(base) + row["window_start"] = start_et.strftime("%H:%M ET") + row["window_close"] = close_dt.strftime("%H:%M ET") + row["minutes_to_resolve"] = max(0, round( + (ref["hour_open_ms"] + 3600_000 - now_ms) / 60000)) + rows.append(row) + return rows + + +def _empty_row(a): + return { + "asset": a, "kalshi_ticker": None, "kalshi_strike": None, + "poly_slug": None, "poly_strike": None, "direction": "Above", + "basis_pct": None, "basis_favorable": None, "best_side": None, + "kalshi_price": None, "kalshi_size": None, "poly_price": None, + "poly_size": None, "combined_cost": None, "kalshi_fee": None, + "poly_fee": None, "total_fee": None, "worst_pnl": None, + "best_pnl": None, "mid_pnl": None, "net_return": None, + "annualized": None, "max_contracts": None, "total_gain": None, + "poly_volume": None, "days_to_expiry": None, + "kalshi_title": None, "kalshi_rules": None, "kalshi_yes_label": None, + "kalshi_no_label": None, "poly_question": None, + "poly_description": None, "image": None, + "window_start": None, "window_close": None, + "minutes_to_resolve": None, + } diff --git a/arb/kalshi.py b/arb/kalshi.py new file mode 100644 index 000000000..6bdfc16ab --- /dev/null +++ b/arb/kalshi.py @@ -0,0 +1,118 @@ +"""Kalshi public trade API client (no auth required for market data). + +Kalshi rate-limits aggressively, so we do NOT fetch per ticker. Instead: + + fetch_quotes() -> ONE batched GET /markets?tickers=a,b,c... (chunked + + cursor-paged) for prices/status/expiry of every ticker. + fetch_sizes() -> /markets/{t}/orderbook, called only for the small set of + basis-favorable candidates, to get true executable + top-of-book size. + +Kalshi binary markets: buying YES is matched against resting NO bids, so the +size available at the YES ask == size of the best NO bid (and vice versa). +""" +import re +from .net import get_json, parallel, FetchError + +BASE = "https://api.elections.kalshi.com/trade-api/v2" +_CHUNK = 80 + +_MONTHS = { + "JAN": 1, "FEB": 2, "MAR": 3, "APR": 4, "MAY": 5, "JUN": 6, + "JUL": 7, "AUG": 8, "SEP": 9, "OCT": 10, "NOV": 11, "DEC": 12, +} + + +def _f(v): + try: + x = float(v) + return x if x > 0 else None + except (TypeError, ValueError): + return None + + +def parse_expiry(ticker): + """KX...-26MAY31-7000 -> '2026-05-31', else None.""" + m = re.search(r"-(\d{2})([A-Z]{3})(\d{2})-", ticker) + if not m or m.group(2) not in _MONTHS: + return None + return "20%s-%02d-%02d" % (m.group(1), _MONTHS[m.group(2)], int(m.group(3))) + + +def _clip(s, n): + s = (s or "").strip() + return s if len(s) <= n else s[: n - 1].rstrip() + "…" + + +def _quote(mkt): + t = mkt.get("ticker") + return { + "ticker": t, + "yes_bid": _f(mkt.get("yes_bid_dollars")), + "yes_ask": _f(mkt.get("yes_ask_dollars")), + "no_bid": _f(mkt.get("no_bid_dollars")), + "no_ask": _f(mkt.get("no_ask_dollars")), + "yes_ask_size": None, + "no_ask_size": None, + "open_interest": _f(mkt.get("open_interest_fp")) or 0.0, + "status": mkt.get("status"), + "expiry": (mkt.get("close_time") or "")[:10] or parse_expiry(t or ""), + "title": mkt.get("title"), + "yes_label": mkt.get("yes_sub_title"), + "no_label": mkt.get("no_sub_title"), + "rules": _clip(mkt.get("rules_primary"), 360), + } + + +def fetch_quotes(tickers): + """tickers: iterable. Returns {ticker: quote|None} via batched calls.""" + uniq = sorted({t for t in tickers if t}) + out = {t: None for t in uniq} + for i in range(0, len(uniq), _CHUNK): + chunk = uniq[i:i + _CHUNK] + cursor = "" + for _ in range(10): # cursor-page guard + url = "%s/markets?limit=1000&tickers=%s" % (BASE, ",".join(chunk)) + if cursor: + url += "&cursor=" + cursor + try: + data = get_json(url) + except FetchError: + break + for m in data.get("markets", []): + if m.get("ticker") in out: + out[m["ticker"]] = _quote(m) + cursor = data.get("cursor") or "" + if not cursor: + break + return out + + +def _best_bid_size(ladder): + """ladder = [[price, size], ...] resting bids -> size at the top bid.""" + best = None + for row in ladder or []: + try: + p, s = float(row[0]), float(row[1]) + except (TypeError, ValueError, IndexError): + continue + if best is None or p > best[0]: + best = (p, s) + return best[1] if best else None + + +def _one_ob(ticker): + ob = get_json("%s/markets/%s/orderbook" % (BASE, ticker)).get( + "orderbook_fp", {}) + return { + "yes_ask_size": _best_bid_size(ob.get("no_dollars")), + "no_ask_size": _best_bid_size(ob.get("yes_dollars")), + } + + +def fetch_sizes(tickers): + """Top-of-book executable size for a SMALL candidate set. {ticker: {..}}.""" + uniq = sorted({t for t in tickers if t}) + res = parallel(_one_ob, uniq, workers=6) + return {t: (None if isinstance(v, FetchError) else v) + for t, v in res.items()} diff --git a/arb/net.py b/arb/net.py new file mode 100644 index 000000000..54531b894 --- /dev/null +++ b/arb/net.py @@ -0,0 +1,65 @@ +"""Tiny stdlib HTTP JSON helpers + a bounded thread pool for fan-out fetches.""" +import json +import time +import urllib.request +import urllib.error +from concurrent.futures import ThreadPoolExecutor + +_HEADERS = { + "User-Agent": "Mozilla/5.0 (prediction-market-arb)", + "Accept": "application/json", +} + + +class FetchError(Exception): + pass + + +def get_json(url, timeout=20, retries=2): + last = None + for attempt in range(retries + 1): + req = urllib.request.Request(url, headers=_HEADERS) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + last = FetchError("HTTP %s for %s" % (e.code, url)) + if e.code not in (429, 500, 502, 503, 504): + raise last + except Exception as e: # URLError, timeout, JSON, etc. + last = FetchError("%s for %s" % (type(e).__name__, url)) + if attempt < retries: + time.sleep(0.4 * (attempt + 1)) # linear backoff + raise last + + +def post_json(url, payload, timeout=25): + data = json.dumps(payload).encode() + headers = dict(_HEADERS) + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + raise FetchError("HTTP %s for %s" % (e.code, url)) + except Exception as e: + raise FetchError("%s for %s" % (type(e).__name__, url)) + + +def parallel(fn, items, workers=16): + """Map fn over items concurrently; returns {item: result_or_FetchError}.""" + out = {} + if not items: + return out + with ThreadPoolExecutor(max_workers=min(workers, len(items))) as ex: + futs = {ex.submit(fn, it): it for it in items} + for fut in futs: + it = futs[fut] + try: + out[it] = fut.result() + except FetchError as e: + out[it] = e + except Exception as e: # never let one bad fetch kill the scan + out[it] = FetchError("%s: %s" % (type(e).__name__, e)) + return out diff --git a/arb/poly.py b/arb/poly.py new file mode 100644 index 000000000..4719b1721 --- /dev/null +++ b/arb/poly.py @@ -0,0 +1,161 @@ +"""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 diff --git a/arb/positions.py b/arb/positions.py new file mode 100644 index 000000000..9c062e44d --- /dev/null +++ b/arb/positions.py @@ -0,0 +1,506 @@ +"""Live positions from Polymarket (public, by wallet) and Kalshi (signed). + +Polymarket is on-chain: the public Data API returns holdings + P&L given +just the proxy-wallet address β€” no secret. + +Kalshi requires an authed call. We sign exactly per Kalshi's spec +(RSA-PSS / SHA-256 over "{ts}{METHOD}{path}") but shell out to the system +`openssl` so no crypto dependency and the private key never leaves disk. +Read-only β€” no order endpoints are ever called. + +Credentials come from data/secrets.json (git-ignored), supplied by the user. +Missing/!configured venues degrade gracefully with setup guidance. +""" +import base64 +import json +import os +import re +import subprocess +import time +import urllib.request +import urllib.error + +from .net import get_json, parallel, FetchError + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SECRETS = os.path.join(ROOT, "data", "secrets.json") +PAIRS = os.path.join(ROOT, "data", "pairs.json") + +POLY_DATA = "https://data-api.polymarket.com" +CLOB_MKT = "https://clob.polymarket.com/markets/" +KALSHI = "https://api.elections.kalshi.com" +KALSHI_POS_PATH = "/trade-api/v2/portfolio/positions" + +_ASSETS = ("BTC", "ETH", "SOL", "XRP", "DOGE", "BNB", "HYPE", "ZEC", "LTC", + "ADA", "AVAX", "LINK", "SHIB", "XLM", "SUI", "TRX") + + +def _asset_from_ticker(t): + """KXXRPMAXMON-XRP-26MAY31-180 -> XRP ; KXHYPED-26MAY0117-T31 -> HYPE.""" + if not t: + return None + parts = t.split("-") + if len(parts) > 1 and parts[1].isalpha() and 2 <= len(parts[1]) <= 5: + return parts[1].upper() + head = parts[0].upper() + for a in _ASSETS: + if a in head: + return a + return None + + +def load_secrets(): + try: + with open(SECRETS) as f: + s = json.load(f) + return s if isinstance(s, dict) else {} + except (OSError, ValueError): + return {} + + +# ---------------------------------------------------------------- Polymarket +def _poly(wallet): + base = "%s/positions?user=%s&sizeThreshold=0.1&limit=500&sortBy=CURRENT&sortDirection=DESC" % ( + POLY_DATA, wallet) + rows = get_json(base) + raw = [p for p in (rows or []) if abs(float(p.get("size") or 0)) >= 1e-9] + + # Value each holding at the current CLOB best BID (executable exit price), + # not the data-API curPrice (last/mid). Fall back to the API fields only + # if a token has no live bid (illiquid / resolved). + from . import poly + bids = {} + try: + bids = poly.fetch_token_bids([p.get("asset") for p in raw]) + except Exception: + bids = {} + + out = [] + for p in raw: + size = float(p.get("size") or 0) + cost = p.get("initialValue") + bid = bids.get(str(p.get("asset"))) + if bid is not None: + cur_price = bid + value = size * bid + pnl = (value - cost) if cost is not None else None + pnl_pct = (pnl / cost) if (pnl is not None and cost) else None + else: # no live bid -> API fallback + cur_price = p.get("curPrice") + value = p.get("currentValue") + pnl = p.get("cashPnl") + pnl_pct = (p.get("percentPnl") / 100.0 + if p.get("percentPnl") is not None else None) + out.append({ + "venue": "Polymarket", + "market": p.get("title"), + "ref": p.get("slug"), + "side": p.get("outcome"), + "size": size, + "avg_price": p.get("avgPrice"), + "cur_price": cur_price, + "cost": cost, + "value": value, + "pnl": pnl, + "pnl_pct": pnl_pct, + "realized_pnl": p.get("realizedPnl"), + "icon": p.get("icon"), + "end_date": (p.get("endDate") or "")[:10] or None, + "redeemable": bool(p.get("redeemable")), + "asset_id": p.get("asset"), + "condition_id": p.get("conditionId"), + }) + # Portfolio value coherent with bid-based marks; API /value as fallback. + total = sum((p["value"] or 0) for p in out) if out else None + if total is None: + try: + v = get_json("%s/value?user=%s" % (POLY_DATA, wallet)) + if isinstance(v, list) and v: + total = v[0].get("value") + except FetchError: + pass + return {"configured": True, "positions": out, "total_value": total} + + +def _clob_resolution(cid): + """CLOB market by conditionId -> (closed, winning_outcome|None).""" + m = get_json(CLOB_MKT + cid) + if not isinstance(m, dict) or not m.get("closed"): + return (False, None) + win = None + for tk in m.get("tokens") or []: + if tk.get("winner"): + win = tk.get("outcome") + return (True, win) + + +def _poly_history(wallet, open_cids): + """Reconstruct realized P&L for resolved markets from the public activity + ledger. Trade cash is exact; the redemption payout is derived as + (net contracts held on CLOB's winning outcome) x $1.""" + acts = [] + for off in range(0, 4000, 500): # paginate, bounded + try: + page = get_json("%s/activity?user=%s&limit=500&offset=%d" + % (POLY_DATA, wallet, off)) + except FetchError: + break + if not page: + break + acts.extend(page) + if len(page) < 500: + break + + mk = {} + for a in acts: + if a.get("type") != "TRADE": + continue + cid = a.get("conditionId") + if not cid or cid in open_cids: + continue + g = mk.setdefault(cid, { + "title": a.get("title"), "slug": a.get("slug"), + "icon": a.get("icon"), "net": {}, "buy": 0.0, "sell": 0.0, + "ts": a.get("timestamp")}) + oc = a.get("outcome") + sz = float(a.get("size") or 0) + usd = float(a.get("usdcSize") or 0) + if a.get("side") == "BUY": + g["net"][oc] = g["net"].get(oc, 0.0) + sz + g["buy"] += usd + else: + g["net"][oc] = g["net"].get(oc, 0.0) - sz + g["sell"] += usd + g["ts"] = max(g["ts"] or 0, a.get("timestamp") or 0) + + res = parallel(_clob_resolution, list(mk), workers=12) + rows = [] + for cid, g in mk.items(): + r = res.get(cid) + if isinstance(r, FetchError) or not r or not r[0]: + continue # unresolved -> not history + winner = r[1] + held = max(0.0, g["net"].get(winner, 0.0)) if winner else 0.0 + payout = held # winning shares pay $1 + trade_cash = g["sell"] - g["buy"] + realized = trade_cash + payout + side = max(g["net"].items(), key=lambda kv: abs(kv[1]))[0] \ + if g["net"] else None + import datetime + sd = (datetime.datetime.utcfromtimestamp(g["ts"]).date().isoformat() + if g.get("ts") else None) + rows.append({ + "venue": "Polymarket", + "market": g["title"], + "ref": g["slug"], + "result": (winner or "β€”"), + "side": side, + "size": abs(g["net"].get(side, 0.0)) if side else 0.0, + "cost": g["buy"], + "payout": payout + max(0.0, g["sell"]), + "realized": realized, + "settled_date": sd, + "icon": g["icon"], + "derived": True, + }) + rows.sort(key=lambda x: x["settled_date"] or "", reverse=True) + return rows + + +# -------------------------------------------------------------------- Kalshi +def _sign(message, key_path): + """RSA-PSS / SHA-256, salt = digest length β€” Kalshi's scheme, via openssl.""" + p = subprocess.run( + ["openssl", "dgst", "-sha256", "-sign", key_path, + "-sigopt", "rsa_padding_mode:pss", + "-sigopt", "rsa_pss_saltlen:-1"], + input=message.encode(), capture_output=True) + if p.returncode != 0: + raise FetchError("openssl sign failed: %s" + % p.stderr.decode()[:160].strip()) + return base64.b64encode(p.stdout).decode() + + +def _kalshi(key_id, key_path): + if not key_id or not key_path: + return {"configured": False, "reason": "no_credentials"} + if not os.path.isfile(os.path.expanduser(key_path)): + return {"configured": False, "reason": "key_file_not_found"} + key_path = os.path.expanduser(key_path) + + def signed_get(path): + ts = str(int(time.time() * 1000)) + sig = _sign(ts + "GET" + path.split("?")[0], key_path) + req = urllib.request.Request(KALSHI + path, headers={ + "KALSHI-ACCESS-KEY": key_id, + "KALSHI-ACCESS-TIMESTAMP": ts, + "KALSHI-ACCESS-SIGNATURE": sig, + "Accept": "application/json", + "User-Agent": "prediction-market-arb", + }) + with urllib.request.urlopen(req, timeout=20) as r: + return json.loads(r.read().decode()) + + try: + data = signed_get(KALSHI_POS_PATH + "?limit=500&count_filter=position") + except urllib.error.HTTPError as e: + return {"configured": True, "error": "Kalshi HTTP %s β€” check key ID / " + "private key / permissions." % e.code, "positions": []} + except Exception as e: + return {"configured": True, + "error": "%s" % type(e).__name__, "positions": []} + + mp = data.get("market_positions") or [] + tickers = [m.get("ticker") for m in mp if m.get("ticker")] + marks = {} + if tickers: + try: + from . import kalshi + marks = kalshi.fetch_quotes(tickers) + except Exception: + marks = {} + + def num(m, base): + """Kalshi returns "{base}_dollars" as a string; fall back to int cents.""" + d = m.get(base + "_dollars") + if d is not None: + try: + return float(d) + except (TypeError, ValueError): + pass + v = m.get(base) + return (v / 100.0) if isinstance(v, (int, float)) else 0.0 + + out = [] + for m in mp: + pos = 0.0 + for f in ("position_fp", "position"): # fp = fractional position + try: + pos = float(m.get(f)) + break + except (TypeError, ValueError): + continue + if abs(pos) < 1e-9: + continue + side = "Yes" if pos > 0 else "No" # negative == net No + size = abs(pos) + q = marks.get(m.get("ticker")) or {} + mark = q.get("yes_bid") if pos > 0 else q.get("no_bid") + # Cost basis of the CURRENTLY held position = market_exposure. + # total_traded is lifetime traded volume (inflated by any round-trips) + # and must NOT be used as cost β€” it shows phantom losses. + cost = num(m, "market_exposure") or num(m, "total_traded") + value = (size * mark) if mark is not None else cost + out.append({ + "venue": "Kalshi", + "market": q.get("title") or m.get("ticker"), + "ref": m.get("ticker"), + "side": side, + "size": size, + "avg_price": (cost / size) if size else None, + "cur_price": mark, + "cost": cost or None, + "value": value, + "pnl": (value - cost) if cost else None, + "pnl_pct": ((value - cost) / cost) if cost else None, + "realized_pnl": num(m, "realized_pnl"), + "fees": num(m, "fees_paid"), + "icon": None, + "asset": _asset_from_ticker(m.get("ticker")), + "end_date": q.get("expiry") or None, + "redeemable": q.get("status") in ("settled", "finalized"), + "asset_id": m.get("ticker"), + }) + + total_value = None + try: + pv = signed_get("/trade-api/v2/portfolio/balance").get( + "portfolio_value") + if isinstance(pv, (int, float)): + total_value = pv / 100.0 # cents -> dollars + except Exception: + pass + if total_value is None: + total_value = sum(p["value"] or 0 for p in out) + + # Settled (expired) markets β€” exact realized P&L straight from Kalshi. + settled, cursor = [], "" + for _ in range(15): # cursor-page guard + try: + sd = signed_get("/trade-api/v2/portfolio/settlements?limit=200" + + ("&cursor=" + cursor if cursor else "")) + except Exception: + break + for s in sd.get("settlements", []): + yc = float(s.get("yes_total_cost_dollars") or 0) + nc = float(s.get("no_total_cost_dollars") or 0) + fee = float(s.get("fee_cost") or 0) + yct = float(s.get("yes_count_fp") or 0) + nct = float(s.get("no_count_fp") or 0) + result = (s.get("market_result") or "").lower() + # Kalshi's `revenue`/`value` are 0 when both sides were held to + # expiry β€” NOT the payout. Settled contracts pay $1 each on the + # winning side, so derive payout from market_result + counts. + if result == "yes": + payout = yct + elif result == "no": + payout = nct + else: # void/refund -> fall back + payout = (s.get("revenue") or 0) / 100.0 + cost = yc + nc + fee + side = "Yes" if yct >= nct else "No" + settled.append({ + "venue": "Kalshi", + "market": s.get("ticker"), + "ref": s.get("ticker"), + "result": (result.upper() or "β€”"), + "side": side, + "size": max(yct, nct), + "cost": cost, + "payout": payout, + "realized": payout - cost, + "settled_date": (s.get("settled_time") or "")[:10] or None, + "asset": _asset_from_ticker(s.get("ticker")), + "derived": False, + }) + cursor = sd.get("cursor") or "" + if not cursor: + break + + return {"configured": True, "positions": out, + "total_value": total_value, "settlements": settled} + + +# ------------------------------------------------------------- paired view +def _load_pairs(): + try: + with open(PAIRS) as f: + return json.load(f) + except (OSError, ValueError): + return [] + + +def _paired(poly_pos, kalshi_pos): + pairs = _load_pairs() + by_slug, by_tkr = {}, {} + for pr in pairs: + if pr.get("poly_slug"): + by_slug.setdefault(pr["poly_slug"], pr) + if pr.get("kalshi_ticker"): + by_tkr[pr["kalshi_ticker"]] = pr + + groups = {} + + def key(pr): + return "%s|%s|%s" % (pr.get("asset"), pr.get("kalshi_ticker"), + pr.get("poly_slug")) + + for p in poly_pos: + pr = by_slug.get(p["ref"]) + if pr: + groups.setdefault(key(pr), {"pair": pr, "poly": [], "kalshi": []}) + groups[key(pr)]["poly"].append(p) + for p in kalshi_pos: + pr = by_tkr.get(p["ref"]) + if pr: + groups.setdefault(key(pr), {"pair": pr, "poly": [], "kalshi": []}) + groups[key(pr)]["kalshi"].append(p) + + rows = [] + for g in groups.values(): + legs = g["poly"] + g["kalshi"] + cost = sum((x["cost"] or 0) for x in legs) + value = sum((x["value"] or 0) for x in legs) + pnl = sum((x["pnl"] or 0) for x in legs if x["pnl"] is not None) + rows.append({ + "asset": g["pair"].get("asset"), + "kalshi_ticker": g["pair"].get("kalshi_ticker"), + "poly_slug": g["pair"].get("poly_slug"), + "kalshi_strike": g["pair"].get("kalshi_strike"), + "poly_strike": g["pair"].get("poly_strike"), + "legs": legs, + "cost": cost, + "value": value, + "pnl": pnl, + "pnl_pct": (pnl / cost if cost else None), + "complete": bool(g["poly"] and g["kalshi"]), + }) + rows.sort(key=lambda r: r["pnl"], reverse=True) + return rows + + +def run_positions(): + s = load_secrets() + wallet = (s.get("polymarket_wallet") or "").strip() + poly = ({"configured": False, "reason": "no_wallet"} + if not wallet or wallet.startswith("0xYOUR") + else _safe(_poly, wallet)) + kalshi = _safe(_kalshi, s.get("kalshi_key_id"), + s.get("kalshi_private_key_path")) + + pp = poly.get("positions", []) if poly.get("configured") else [] + kp = kalshi.get("positions", []) if kalshi.get("configured") else [] + paired = _paired(pp, kp) + + # History (expired/settled): Kalshi exact; Polymarket reconstructed. + k_hist = kalshi.get("settlements", []) if kalshi.get("configured") else [] + p_hist = [] + if poly.get("configured"): + open_cids = {p.get("condition_id") for p in pp if p.get("condition_id")} + p_hist = _safe(_poly_history, wallet, open_cids) + if isinstance(p_hist, dict): # _safe wrapped an error + p_hist = [] + + def total_pnl(lst): + return sum((p.get("pnl") or 0) for p in lst) + + def total_cost(lst): + return sum((p.get("cost") or 0) for p in lst) + + def total_realized(lst): + return sum((p.get("realized") or 0) for p in lst) + + poly_pnl = total_pnl(pp) if poly.get("configured") else None + kalshi_pnl = total_pnl(kp) if kalshi.get("configured") else None + grand_pnl = (None if poly_pnl is None and kalshi_pnl is None + else (poly_pnl or 0) + (kalshi_pnl or 0)) + all_cost = total_cost(pp) + total_cost(kp) + total_return = ((grand_pnl / all_cost) + if (grand_pnl is not None and all_cost) else None) + + kalshi_realized = total_realized(k_hist) if kalshi.get("configured") else None + poly_realized = total_realized(p_hist) if poly.get("configured") else None + grand_realized = (None if kalshi_realized is None and poly_realized is None + else (kalshi_realized or 0) + (poly_realized or 0)) + + return { + "polymarket": poly, + "kalshi": kalshi, + "paired": paired, + "history": {"kalshi": k_hist, "polymarket": p_hist}, + "totals": { + "poly_value": poly.get("total_value"), + "kalshi_value": kalshi.get("total_value"), + "poly_pnl": poly_pnl, + "kalshi_pnl": kalshi_pnl, + "total_pnl": grand_pnl, + "total_return": total_return, + "kalshi_realized": kalshi_realized, + "poly_realized": poly_realized, + "total_realized": grand_realized, + "open_positions": len(pp) + len(kp), + "settled_count": len(k_hist) + len(p_hist), + "paired_count": sum(1 for r in paired if r["complete"]), + }, + "generated_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + } + + +def _safe(fn, *a): + try: + return fn(*a) + except FetchError as e: + return {"configured": True, "error": str(e), "positions": []} + except Exception as e: + return {"configured": True, + "error": "%s: %s" % (type(e).__name__, e), "positions": []} diff --git a/arb/refs.py b/arb/refs.py new file mode 100644 index 000000000..39d238fc5 --- /dev/null +++ b/arb/refs.py @@ -0,0 +1,62 @@ +"""Reference-price client for the hourly scanner. + +Polymarket "Up or Down" resolves on the Binance {SYM}/USDT 1-hour candle +(Up if close >= open). So the *implied strike* for the hour == the Binance +candle OPEN. Kalshi hourly settles on CF Benchmarks (BRTI etc.), whose index +is built from Coinbase/Kraken/Bitstamp/LMAX. The Binance-vs-Coinbase spot gap +is therefore a live proxy for the unhedgeable settlement-feed basis risk. + + data-api.binance.vision -> Binance public mirror (api.binance.com is 451 + geo-blocked from here). Gives 1h-candle open + + spot for the implied strike & Binance side. + api.coinbase.com -> CF-Benchmarks-side spot proxy. +""" +from .net import get_json, parallel, FetchError + +BINANCE = "https://data-api.binance.vision/api/v3" +COINBASE = "https://api.coinbase.com/v2/prices/%s-USD/spot" + +# asset -> (binance symbol, coinbase code or None if not listed there) +ASSETS = { + "BTC": ("BTCUSDT", "BTC"), + "ETH": ("ETHUSDT", "ETH"), + "SOL": ("SOLUSDT", "SOL"), + "XRP": ("XRPUSDT", "XRP"), + "DOGE": ("DOGEUSDT", "DOGE"), + "BNB": ("BNBUSDT", None), # not on Coinbase -> no CF-side proxy +} + + +def _one(asset): + bsym, cb = ASSETS[asset] + k = get_json("%s/klines?symbol=%s&interval=1h&limit=1" % (BINANCE, bsym)) + row = k[0] + open_px = float(row[1]) + open_ms = int(row[0]) + binance_spot = float(get_json( + "%s/ticker/price?symbol=%s" % (BINANCE, bsym))["price"]) + cf_spot = None + if cb: + try: + cf_spot = float(get_json(COINBASE % cb)["data"]["amount"]) + except (FetchError, KeyError, ValueError, TypeError): + cf_spot = None + div = None + if cf_spot: + div = (binance_spot - cf_spot) / cf_spot + return { + "asset": asset, + "hour_open": open_px, # implied Polymarket strike for the hour + "hour_open_ms": open_ms, # UTC ms of the candle open (window start) + "binance_spot": binance_spot, + "cf_spot": cf_spot, + "divergence": div, # (binance - coinbase) / coinbase + } + + +def fetch_refs(assets): + """assets: iterable of symbols. Returns {asset: ref|None}.""" + want = [a for a in assets if a in ASSETS] + res = parallel(_one, want, workers=8) + return {a: (None if isinstance(v, FetchError) else v) + for a, v in res.items()} diff --git a/arb/scan.py b/arb/scan.py new file mode 100644 index 000000000..782ce9558 --- /dev/null +++ b/arb/scan.py @@ -0,0 +1,148 @@ +"""Load seed data, fan out the live API calls, evaluate every active pair.""" +import json +import os +import time +import threading +from concurrent.futures import ThreadPoolExecutor + +from . import kalshi, poly +from .calc import evaluate + +_DATA = os.path.join(os.path.dirname(__file__), "..", "data") +_LOCK = threading.Lock() +_CACHE = {"ts": 0.0, "payload": None} +_HCACHE = {"ts": 0.0, "payload": None} +_HLOCK = threading.Lock() +_PCACHE = {"ts": 0.0, "payload": None} +_PLOCK = threading.Lock() +_POS_TTL = 12 +_CACHE_TTL = 4 # seconds; just enough to dedupe rapid refreshes + + +def _path(name): + return os.path.abspath(os.path.join(_DATA, name)) + + +def load_settings(): + with open(_path("settings.json")) as f: + return json.load(f) + + +def load_pairs(): + with open(_path("pairs.json")) as f: + return json.load(f) + + +def run_scan(force=False): + """Returns {rows, summary, generated_at, settings}. Cached for _CACHE_TTL.""" + with _LOCK: + now = time.time() + if (not force) and _CACHE["payload"] and (now - _CACHE["ts"] < _CACHE_TTL): + return _CACHE["payload"] + + settings = load_settings() + pairs = load_pairs() + active = [p for p in pairs if p.get("active")] + + tickers = [p["kalshi_ticker"] for p in active if p.get("kalshi_ticker")] + slugs = [p["poly_slug"] for p in active if p.get("poly_slug")] + + t0 = time.time() + # Hit both venues at the same time instead of one after the other. + with ThreadPoolExecutor(max_workers=2) as ex: + fk = ex.submit(kalshi.fetch_quotes, tickers) + fp = ex.submit(poly.fetch_quotes, slugs) + kquotes = fk.result() + pquotes = fp.result() + + def _eval(): + return [ + evaluate(p, kquotes.get(p["kalshi_ticker"]), + pquotes.get(p.get("poly_slug")), settings) + for p in active + ] + + rows = _eval() + + # Phase 2: for basis-favorable, near/above-breakeven rows, replace the + # open-interest proxy with true Kalshi top-of-book size, then re-eval. + cand_tickers = [ + r["kalshi_ticker"] for r in rows + if r.get("basis_favorable") + and r.get("worst_pnl") is not None + and r.get("combined_cost") + and r["worst_pnl"] > -0.05 * r["combined_cost"] + ] + if cand_tickers: + for tk, sz in kalshi.fetch_sizes(cand_tickers).items(): + if sz and kquotes.get(tk): + kquotes[tk]["yes_ask_size"] = sz["yes_ask_size"] + kquotes[tk]["no_ask_size"] = sz["no_ask_size"] + rows = _eval() + + fetch_ms = int((time.time() - t0) * 1000) + + summary = {"total": len(rows), "fetch_ms": fetch_ms} + for st in ("ARB", "NO ARB", "BAD BASIS", "LOW SIZE", + "NO DATA", "NO PAIR"): + summary[st] = sum(1 for r in rows if r["status"] == st) + + payload = { + "rows": rows, + "summary": summary, + "settings": settings, + "generated_at": time.strftime("%Y-%m-%d %H:%M:%S", + time.localtime()), + } + _CACHE["ts"] = now + _CACHE["payload"] = payload + return payload + + +def run_hourly(force=False): + """Kalshi hourly ladder vs Polymarket Up/Down. Cached for _CACHE_TTL.""" + from . import hourly + with _HLOCK: + now = time.time() + if (not force) and _HCACHE["payload"] and \ + (now - _HCACHE["ts"] < _CACHE_TTL): + return _HCACHE["payload"] + + settings = load_settings() + t0 = time.time() + rows = hourly.run(settings) + fetch_ms = int((time.time() - t0) * 1000) + + summary = {"total": len(rows), "fetch_ms": fetch_ms} + for st in ("ARB", "NO ARB", "BAD BASIS", "LOW SIZE", + "NO DATA", "NO KALSHI"): + summary[st] = sum(1 for r in rows if r["status"] == st) + live = [r for r in rows if r.get("divergence") is not None] + summary["max_divergence"] = ( + max(abs(r["divergence"]) for r in live) if live else None) + + payload = { + "rows": rows, + "summary": summary, + "settings": settings, + "mode": "hourly", + "generated_at": time.strftime("%Y-%m-%d %H:%M:%S", + time.localtime()), + } + _HCACHE["ts"] = now + _HCACHE["payload"] = payload + return payload + + +def run_positions(force=False): + """Live Polymarket + Kalshi positions. Cached for _POS_TTL.""" + from . import positions + with _PLOCK: + now = time.time() + if (not force) and _PCACHE["payload"] and \ + (now - _PCACHE["ts"] < _POS_TTL): + return _PCACHE["payload"] + payload = positions.run_positions() + _PCACHE["ts"] = now + _PCACHE["payload"] = payload + return payload diff --git a/data/pairs.json b/data/pairs.json new file mode 100644 index 000000000..f58025702 --- /dev/null +++ b/data/pairs.json @@ -0,0 +1,946 @@ +[ + { + "active": true, + "asset": "HYPE", + "kalshi_ticker": "KXHYPEMAXMON-HYPE-26MAY31-6250", + "kalshi_strike": 62.5, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "HYPE", + "kalshi_ticker": "KXHYPEMAXMON-HYPE-26MAY31-6000", + "kalshi_strike": 60.0, + "poly_slug": "will-hyperliquid-reach-52-in-may", + "poly_strike": 52.0 + }, + { + "active": true, + "asset": "HYPE", + "kalshi_ticker": "KXHYPEMAXMON-HYPE-26MAY31-5750", + "kalshi_strike": 57.5, + "poly_slug": "will-hyperliquid-reach-52-in-may", + "poly_strike": 52.0 + }, + { + "active": true, + "asset": "HYPE", + "kalshi_ticker": "KXHYPEMAXMON-HYPE-26MAY31-5500", + "kalshi_strike": 55.0, + "poly_slug": "will-hyperliquid-reach-52-in-may", + "poly_strike": 52.0 + }, + { + "active": true, + "asset": "HYPE", + "kalshi_ticker": "KXHYPEMAXMON-HYPE-26MAY31-5250", + "kalshi_strike": 52.5, + "poly_slug": "will-hyperliquid-reach-52-in-may", + "poly_strike": 52.0 + }, + { + "active": true, + "asset": "HYPE", + "kalshi_ticker": "KXHYPEMAXMON-HYPE-26MAY31-5000", + "kalshi_strike": 50.0, + "poly_slug": "will-hyperliquid-reach-52-in-may", + "poly_strike": 52.0 + }, + { + "active": true, + "asset": "HYPE", + "kalshi_ticker": "KXHYPEMAXMON-HYPE-26MAY31-4750", + "kalshi_strike": 47.5, + "poly_slug": "will-hyperliquid-reach-48-in-may", + "poly_strike": 48.0 + }, + { + "active": true, + "asset": "HYPE", + "kalshi_ticker": "KXHYPEMAXMON-HYPE-26MAY31-4500", + "kalshi_strike": 45.0, + "poly_slug": "will-hyperliquid-reach-44-in-may", + "poly_strike": 44.0 + }, + { + "active": true, + "asset": "HYPE", + "kalshi_ticker": "KXHYPEMINMON-HYPE-26MAY31-4000", + "kalshi_strike": 40.0, + "poly_slug": "will-hyperliquid-dip-to-38-in-may", + "poly_strike": 38.0 + }, + { + "active": true, + "asset": "HYPE", + "kalshi_ticker": "KXHYPEMINMON-HYPE-26MAY31-3750", + "kalshi_strike": 37.5, + "poly_slug": "will-hyperliquid-dip-to-38-in-may", + "poly_strike": 38.0 + }, + { + "active": true, + "asset": "HYPE", + "kalshi_ticker": "KXHYPEMINMON-HYPE-26MAY31-3500", + "kalshi_strike": 35.0, + "poly_slug": "will-hyperliquid-dip-to-38-in-may", + "poly_strike": 38.0 + }, + { + "active": true, + "asset": "HYPE", + "kalshi_ticker": "KXHYPEMINMON-HYPE-26MAY31-3250", + "kalshi_strike": 32.5, + "poly_slug": "will-hyperliquid-dip-to-32-in-may", + "poly_strike": 32.0 + }, + { + "active": true, + "asset": "HYPE", + "kalshi_ticker": "KXHYPEMINMON-HYPE-26MAY31-3000", + "kalshi_strike": 30.0, + "poly_slug": "will-hyperliquid-dip-to-32-in-may", + "poly_strike": 32.0 + }, + { + "active": true, + "asset": "HYPE", + "kalshi_ticker": "KXHYPEMINMON-HYPE-26MAY31-2750", + "kalshi_strike": 27.5, + "poly_slug": "will-hyperliquid-dip-to-28-in-may", + "poly_strike": 28.0 + }, + { + "active": true, + "asset": "HYPE", + "kalshi_ticker": "KXHYPEMINMON-HYPE-26MAY31-2500", + "kalshi_strike": 25.0, + "poly_slug": "will-hyperliquid-dip-to-24-in-may", + "poly_strike": 24.0 + }, + { + "active": true, + "asset": "HYPE", + "kalshi_ticker": "KXHYPEMINMON-HYPE-26MAY31-2250", + "kalshi_strike": 22.5, + "poly_slug": "will-hyperliquid-dip-to-24-in-may", + "poly_strike": 24.0 + }, + { + "active": true, + "asset": "BTC", + "kalshi_ticker": "KXBTCMAXMON-BTC-26MAY31-9750000", + "kalshi_strike": 97500.0, + "poly_slug": "will-bitcoin-reach-95k-in-may-2026", + "poly_strike": 95000.0 + }, + { + "active": true, + "asset": "BTC", + "kalshi_ticker": "KXBTCMAXMON-BTC-26MAY31-9500000", + "kalshi_strike": 95000.0, + "poly_slug": "will-bitcoin-reach-95k-in-may-2026", + "poly_strike": 95000.0 + }, + { + "active": true, + "asset": "BTC", + "kalshi_ticker": "KXBTCMAXMON-BTC-26MAY31-9250000", + "kalshi_strike": 92500.0, + "poly_slug": "will-bitcoin-reach-95k-in-may-2026", + "poly_strike": 95000.0 + }, + { + "active": true, + "asset": "BTC", + "kalshi_ticker": "KXBTCMAXMON-BTC-26MAY31-9000000", + "kalshi_strike": 90000.0, + "poly_slug": "will-bitcoin-reach-90k-in-may-2026", + "poly_strike": 90000.0 + }, + { + "active": true, + "asset": "BTC", + "kalshi_ticker": "KXBTCMAXMON-BTC-26MAY31-8750000", + "kalshi_strike": 87500.0, + "poly_slug": "will-bitcoin-reach-85k-in-may-2026", + "poly_strike": 85000.0 + }, + { + "active": true, + "asset": "BTC", + "kalshi_ticker": "KXBTCMAXMON-BTC-26MAY31-8500000", + "kalshi_strike": 85000.0, + "poly_slug": "will-bitcoin-reach-85k-in-may-2026", + "poly_strike": 85000.0 + }, + { + "active": true, + "asset": "BTC", + "kalshi_ticker": "KXBTCMAXMON-BTC-26MAY31-8250000", + "kalshi_strike": 82500.0, + "poly_slug": "will-bitcoin-reach-85k-in-may-2026", + "poly_strike": 85000.0 + }, + { + "active": true, + "asset": "BTC", + "kalshi_ticker": "KXBTCMINMON-BTC-26MAY31-7500000", + "kalshi_strike": 75000.0, + "poly_slug": "will-bitcoin-dip-to-75k-in-may-2026", + "poly_strike": 75000.0 + }, + { + "active": true, + "asset": "BTC", + "kalshi_ticker": "KXBTCMINMON-BTC-26MAY31-7250000", + "kalshi_strike": 72500.0, + "poly_slug": "will-bitcoin-dip-to-70k-in-may-2026", + "poly_strike": 70000.0 + }, + { + "active": true, + "asset": "BTC", + "kalshi_ticker": "KXBTCMINMON-BTC-26MAY31-7000000", + "kalshi_strike": 70000.0, + "poly_slug": "will-bitcoin-dip-to-70k-in-may-2026", + "poly_strike": 70000.0 + }, + { + "active": true, + "asset": "BTC", + "kalshi_ticker": "KXBTCMINMON-BTC-26MAY31-6750000", + "kalshi_strike": 67500.0, + "poly_slug": "will-bitcoin-dip-to-65k-in-may-2026", + "poly_strike": 65000.0 + }, + { + "active": true, + "asset": "BTC", + "kalshi_ticker": "KXBTCMINMON-BTC-26MAY31-6500000", + "kalshi_strike": 65000.0, + "poly_slug": "will-bitcoin-dip-to-65k-in-may-2026", + "poly_strike": 65000.0 + }, + { + "active": true, + "asset": "BTC", + "kalshi_ticker": "KXBTCMINMON-BTC-26MAY31-6250000", + "kalshi_strike": 62500.0, + "poly_slug": "will-bitcoin-dip-to-60k-in-may-2026", + "poly_strike": 60000.0 + }, + { + "active": true, + "asset": "BTC", + "kalshi_ticker": "KXBTCMINMON-BTC-26MAY31-6000000", + "kalshi_strike": 60000.0, + "poly_slug": "will-bitcoin-dip-to-60k-in-may-2026", + "poly_strike": 60000.0 + }, + { + "active": true, + "asset": "BTC", + "kalshi_ticker": "KXBTCMINMON-BTC-26MAY31-5750000", + "kalshi_strike": 57500.0, + "poly_slug": "will-bitcoin-dip-to-55k-in-may-2026", + "poly_strike": 55000.0 + }, + { + "active": true, + "asset": "ETH", + "kalshi_ticker": "KXETHMAXMON-ETH-26MAY31-425000", + "kalshi_strike": 4250.0, + "poly_slug": "will-ethereum-reach-4000-in-may-2026", + "poly_strike": 4000.0 + }, + { + "active": true, + "asset": "ETH", + "kalshi_ticker": "KXETHMAXMON-ETH-26MAY31-400000", + "kalshi_strike": 4000.0, + "poly_slug": "will-ethereum-reach-4000-in-may-2026", + "poly_strike": 4000.0 + }, + { + "active": true, + "asset": "ETH", + "kalshi_ticker": "KXETHMAXMON-ETH-26MAY31-375000", + "kalshi_strike": 3750.0, + "poly_slug": "will-ethereum-reach-3800-in-may-2026", + "poly_strike": 3800.0 + }, + { + "active": true, + "asset": "ETH", + "kalshi_ticker": "KXETHMAXMON-ETH-26MAY31-350000", + "kalshi_strike": 3500.0, + "poly_slug": "will-ethereum-reach-3400-in-may-2026", + "poly_strike": 3400.0 + }, + { + "active": true, + "asset": "ETH", + "kalshi_ticker": "KXETHMAXMON-ETH-26MAY31-325000", + "kalshi_strike": 3250.0, + "poly_slug": "will-ethereum-reach-3200-in-may-2026", + "poly_strike": 3200.0 + }, + { + "active": true, + "asset": "ETH", + "kalshi_ticker": "KXETHMAXMON-ETH-26MAY31-300000", + "kalshi_strike": 3000.0, + "poly_slug": "will-ethereum-reach-3000-in-may-2026", + "poly_strike": 3000.0 + }, + { + "active": true, + "asset": "ETH", + "kalshi_ticker": "KXETHMAXMON-ETH-26MAY31-275000", + "kalshi_strike": 2750.0, + "poly_slug": "will-ethereum-reach-2800-in-may-2026", + "poly_strike": 2800.0 + }, + { + "active": true, + "asset": "ETH", + "kalshi_ticker": "KXETHMAXMON-ETH-26MAY31-250000", + "kalshi_strike": 2500.0, + "poly_slug": "will-ethereum-reach-2600-in-may-2026", + "poly_strike": 2600.0 + }, + { + "active": true, + "asset": "ETH", + "kalshi_ticker": "KXETHMINMON-ETH-26MAY31-75000", + "kalshi_strike": 750.0, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "ETH", + "kalshi_ticker": "KXETHMINMON-ETH-26MAY31-50000", + "kalshi_strike": 500.0, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "ETH", + "kalshi_ticker": "KXETHMINMON-ETH-26MAY31-25000", + "kalshi_strike": 250.0, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "ETH", + "kalshi_ticker": "KXETHMINMON-ETH-26MAY31-200000", + "kalshi_strike": 2000.0, + "poly_slug": "will-ethereum-dip-to-2000-in-may-2026", + "poly_strike": 2000.0 + }, + { + "active": true, + "asset": "ETH", + "kalshi_ticker": "KXETHMINMON-ETH-26MAY31-175000", + "kalshi_strike": 1750.0, + "poly_slug": "will-ethereum-dip-to-2000-in-may-2026", + "poly_strike": 2000.0 + }, + { + "active": true, + "asset": "ETH", + "kalshi_ticker": "KXETHMINMON-ETH-26MAY31-150000", + "kalshi_strike": 1500.0, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "ETH", + "kalshi_ticker": "KXETHMINMON-ETH-26MAY31-125000", + "kalshi_strike": 1250.0, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "ETH", + "kalshi_ticker": "KXETHMINMON-ETH-26MAY31-100000", + "kalshi_strike": 1000.0, + "poly_slug": "will-ethereum-dip-to-1000-in-may-2026", + "poly_strike": 1000.0 + }, + { + "active": true, + "asset": "SOL", + "kalshi_ticker": "KXSOLMAXMON-SOL-26MAY31-9500", + "kalshi_strike": 95.0, + "poly_slug": "will-solana-reach-100-in-may-2026", + "poly_strike": 100.0 + }, + { + "active": true, + "asset": "SOL", + "kalshi_ticker": "KXSOLMAXMON-SOL-26MAY31-9000", + "kalshi_strike": 90.0, + "poly_slug": "will-solana-reach-90-in-may-2026", + "poly_strike": 90.0 + }, + { + "active": true, + "asset": "SOL", + "kalshi_ticker": "KXSOLMAXMON-SOL-26MAY31-12500", + "kalshi_strike": 125.0, + "poly_slug": "will-solana-reach-130-in-may-2026", + "poly_strike": 130.0 + }, + { + "active": true, + "asset": "SOL", + "kalshi_ticker": "KXSOLMAXMON-SOL-26MAY31-12000", + "kalshi_strike": 120.0, + "poly_slug": "will-solana-reach-120-in-may-2026", + "poly_strike": 120.0 + }, + { + "active": true, + "asset": "SOL", + "kalshi_ticker": "KXSOLMAXMON-SOL-26MAY31-11500", + "kalshi_strike": 115.0, + "poly_slug": "will-solana-reach-120-in-may-2026", + "poly_strike": 120.0 + }, + { + "active": true, + "asset": "SOL", + "kalshi_ticker": "KXSOLMAXMON-SOL-26MAY31-11000", + "kalshi_strike": 110.0, + "poly_slug": "will-solana-reach-110-in-may-2026", + "poly_strike": 110.0 + }, + { + "active": true, + "asset": "SOL", + "kalshi_ticker": "KXSOLMAXMON-SOL-26MAY31-10500", + "kalshi_strike": 105.0, + "poly_slug": "will-solana-reach-100-in-may-2026", + "poly_strike": 100.0 + }, + { + "active": true, + "asset": "SOL", + "kalshi_ticker": "KXSOLMAXMON-SOL-26MAY31-10000", + "kalshi_strike": 100.0, + "poly_slug": "will-solana-reach-100-in-may-2026", + "poly_strike": 100.0 + }, + { + "active": true, + "asset": "SOL", + "kalshi_ticker": "KXSOLMINMON-SOL-26MAY31-8000", + "kalshi_strike": 80.0, + "poly_slug": "will-solana-dip-to-70-in-may-2026", + "poly_strike": 70.0 + }, + { + "active": true, + "asset": "SOL", + "kalshi_ticker": "KXSOLMINMON-SOL-26MAY31-7500", + "kalshi_strike": 75.0, + "poly_slug": "will-solana-dip-to-70-in-may-2026", + "poly_strike": 70.0 + }, + { + "active": true, + "asset": "SOL", + "kalshi_ticker": "KXSOLMINMON-SOL-26MAY31-7000", + "kalshi_strike": 70.0, + "poly_slug": "will-solana-dip-to-70-in-may-2026", + "poly_strike": 70.0 + }, + { + "active": true, + "asset": "SOL", + "kalshi_ticker": "KXSOLMINMON-SOL-26MAY31-6500", + "kalshi_strike": 65.0, + "poly_slug": "will-solana-dip-to-60-in-may-2026", + "poly_strike": 60.0 + }, + { + "active": true, + "asset": "SOL", + "kalshi_ticker": "KXSOLMINMON-SOL-26MAY31-6000", + "kalshi_strike": 60.0, + "poly_slug": "will-solana-dip-to-60-in-may-2026", + "poly_strike": 60.0 + }, + { + "active": true, + "asset": "SOL", + "kalshi_ticker": "KXSOLMINMON-SOL-26MAY31-5500", + "kalshi_strike": 55.0, + "poly_slug": "will-solana-dip-to-50-in-may-2026", + "poly_strike": 50.0 + }, + { + "active": true, + "asset": "SOL", + "kalshi_ticker": "KXSOLMINMON-SOL-26MAY31-5000", + "kalshi_strike": 50.0, + "poly_slug": "will-solana-dip-to-50-in-may-2026", + "poly_strike": 50.0 + }, + { + "active": true, + "asset": "SOL", + "kalshi_ticker": "KXSOLMINMON-SOL-26MAY31-4500", + "kalshi_strike": 45.0, + "poly_slug": "will-solana-dip-to-50-in-may-2026", + "poly_strike": 50.0 + }, + { + "active": true, + "asset": "XRP", + "kalshi_ticker": "KXXRPMAXMON-XRP-26MAY31-210", + "kalshi_strike": 2.1, + "poly_slug": "will-xrp-reach-2-in-may-2026", + "poly_strike": 2.0 + }, + { + "active": true, + "asset": "XRP", + "kalshi_ticker": "KXXRPMAXMON-XRP-26MAY31-200", + "kalshi_strike": 2.0, + "poly_slug": "will-xrp-reach-2-in-may-2026", + "poly_strike": 2.0 + }, + { + "active": true, + "asset": "XRP", + "kalshi_ticker": "KXXRPMAXMON-XRP-26MAY31-190", + "kalshi_strike": 1.9, + "poly_slug": "will-xrp-reach-1pt8-in-may-2026", + "poly_strike": 1.8 + }, + { + "active": true, + "asset": "XRP", + "kalshi_ticker": "KXXRPMAXMON-XRP-26MAY31-180", + "kalshi_strike": 1.8, + "poly_slug": "will-xrp-reach-1pt8-in-may-2026", + "poly_strike": 1.8 + }, + { + "active": true, + "asset": "XRP", + "kalshi_ticker": "KXXRPMAXMON-XRP-26MAY31-170", + "kalshi_strike": 1.7, + "poly_slug": "will-xrp-reach-1pt6-in-may-2026", + "poly_strike": 1.6 + }, + { + "active": true, + "asset": "XRP", + "kalshi_ticker": "KXXRPMAXMON-XRP-26MAY31-160", + "kalshi_strike": 1.6, + "poly_slug": "will-xrp-reach-1pt6-in-may-2026", + "poly_strike": 1.6 + }, + { + "active": true, + "asset": "XRP", + "kalshi_ticker": "KXXRPMAXMON-XRP-26MAY31-150", + "kalshi_strike": 1.5, + "poly_slug": "will-xrp-reach-1pt6-in-may-2026", + "poly_strike": 1.6 + }, + { + "active": true, + "asset": "XRP", + "kalshi_ticker": "KXXRPMINMON-XRP-26MAY31-130", + "kalshi_strike": 1.3, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "XRP", + "kalshi_ticker": "KXXRPMINMON-XRP-26MAY31-120", + "kalshi_strike": 1.2, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "XRP", + "kalshi_ticker": "KXXRPMINMON-XRP-26MAY31-110", + "kalshi_strike": 1.1, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "XRP", + "kalshi_ticker": "KXXRPMINMON-XRP-26MAY31-100", + "kalshi_strike": 1.0, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "XRP", + "kalshi_ticker": "KXXRPMINMON-XRP-26MAY31-090", + "kalshi_strike": 0.9, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "XRP", + "kalshi_ticker": "KXXRPMINMON-XRP-26MAY31-080", + "kalshi_strike": 0.8, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "XRP", + "kalshi_ticker": "KXXRPMINMON-XRP-26MAY31-070", + "kalshi_strike": 0.7, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "XRP", + "kalshi_ticker": "KXXRPMINMON-XRP-26MAY31-060", + "kalshi_strike": 0.6, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "ZEC", + "kalshi_ticker": "KXZECMAXMON-ZEC-26MAY31-43000", + "kalshi_strike": 430.0, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "ZEC", + "kalshi_ticker": "KXZECMINMON-ZEC-26MAY31-35000", + "kalshi_strike": 350.0, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "ZEC", + "kalshi_ticker": "KXZECMINMON-ZEC-26MAY31-34000", + "kalshi_strike": 340.0, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "ZEC", + "kalshi_ticker": "KXZECMINMON-ZEC-26MAY31-33000", + "kalshi_strike": 330.0, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "ZEC", + "kalshi_ticker": "KXZECMINMON-ZEC-26MAY31-32000", + "kalshi_strike": 320.0, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "ZEC", + "kalshi_ticker": "KXZECMINMON-ZEC-26MAY31-31000", + "kalshi_strike": 310.0, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "ZEC", + "kalshi_ticker": "KXZECMINMON-ZEC-26MAY31-30000", + "kalshi_strike": 300.0, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "ZEC", + "kalshi_ticker": "KXZECMINMON-ZEC-26MAY31-29000", + "kalshi_strike": 290.0, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "ZEC", + "kalshi_ticker": "KXZECMINMON-ZEC-26MAY31-28000", + "kalshi_strike": 280.0, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "DOGE", + "kalshi_ticker": "KXDOGEMAXMON-DOGE-26MAY31-018", + "kalshi_strike": 0.18, + "poly_slug": "will-dogecoin-reach-0pt2-in-may-2026", + "poly_strike": 0.2 + }, + { + "active": true, + "asset": "DOGE", + "kalshi_ticker": "KXDOGEMAXMON-DOGE-26MAY31-017", + "kalshi_strike": 0.17, + "poly_slug": "will-dogecoin-reach-0pt15-in-may-2026", + "poly_strike": 0.15 + }, + { + "active": true, + "asset": "DOGE", + "kalshi_ticker": "KXDOGEMAXMON-DOGE-26MAY31-016", + "kalshi_strike": 0.16, + "poly_slug": "will-dogecoin-reach-0pt15-in-may-2026", + "poly_strike": 0.15 + }, + { + "active": true, + "asset": "DOGE", + "kalshi_ticker": "KXDOGEMAXMON-DOGE-26MAY31-015", + "kalshi_strike": 0.15, + "poly_slug": "will-dogecoin-reach-0pt15-in-may-2026", + "poly_strike": 0.15 + }, + { + "active": true, + "asset": "DOGE", + "kalshi_ticker": "KXDOGEMAXMON-DOGE-26MAY31-014", + "kalshi_strike": 0.14, + "poly_slug": "will-dogecoin-reach-0pt15-in-may-2026", + "poly_strike": 0.15 + }, + { + "active": true, + "asset": "DOGE", + "kalshi_ticker": "KXDOGEMAXMON-DOGE-26MAY31-013", + "kalshi_strike": 0.13, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "DOGE", + "kalshi_ticker": "KXDOGEMAXMON-DOGE-26MAY31-012", + "kalshi_strike": 0.12, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "DOGE", + "kalshi_ticker": "KXDOGEMINMON-DOGE-26MAY31-010", + "kalshi_strike": 0.1, + "poly_slug": "will-dogecoin-dip-to-0pt1-in-may-2026", + "poly_strike": 0.1 + }, + { + "active": true, + "asset": "DOGE", + "kalshi_ticker": "KXDOGEMINMON-DOGE-26MAY31-009", + "kalshi_strike": 0.09, + "poly_slug": "will-dogecoin-dip-to-0pt1-in-may-2026", + "poly_strike": 0.1 + }, + { + "active": true, + "asset": "DOGE", + "kalshi_ticker": "KXDOGEMINMON-DOGE-26MAY31-008", + "kalshi_strike": 0.08, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "DOGE", + "kalshi_ticker": "KXDOGEMINMON-DOGE-26MAY31-007", + "kalshi_strike": 0.07, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "DOGE", + "kalshi_ticker": "KXDOGEMINMON-DOGE-26MAY31-006", + "kalshi_strike": 0.06, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "DOGE", + "kalshi_ticker": "KXDOGEMINMON-DOGE-26MAY31-005", + "kalshi_strike": 0.05, + "poly_slug": "will-dogecoin-dip-to-0pt05-in-may-2026", + "poly_strike": 0.05 + }, + { + "active": true, + "asset": "DOGE", + "kalshi_ticker": "KXDOGEMINMON-DOGE-26MAY31-004", + "kalshi_strike": 0.04, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "DOGE", + "kalshi_ticker": "KXDOGEMINMON-DOGE-26MAY31-003", + "kalshi_strike": 0.03, + "poly_slug": null, + "poly_strike": null + }, + { + "active": true, + "asset": "BNB", + "kalshi_ticker": "KXBNBMAXMON-BNB-26MAY31-72000", + "kalshi_strike": 720.0, + "poly_slug": "will-bnb-reach-700-in-may", + "poly_strike": 700.0 + }, + { + "active": true, + "asset": "BNB", + "kalshi_ticker": "KXBNBMAXMON-BNB-26MAY31-71000", + "kalshi_strike": 710.0, + "poly_slug": "will-bnb-reach-700-in-may", + "poly_strike": 700.0 + }, + { + "active": true, + "asset": "BNB", + "kalshi_ticker": "KXBNBMAXMON-BNB-26MAY31-70000", + "kalshi_strike": 700.0, + "poly_slug": "will-bnb-reach-700-in-may", + "poly_strike": 700.0 + }, + { + "active": true, + "asset": "BNB", + "kalshi_ticker": "KXBNBMAXMON-BNB-26MAY31-69000", + "kalshi_strike": 690.0, + "poly_slug": "will-bnb-reach-700-in-may", + "poly_strike": 700.0 + }, + { + "active": true, + "asset": "BNB", + "kalshi_ticker": "KXBNBMAXMON-BNB-26MAY31-68000", + "kalshi_strike": 680.0, + "poly_slug": "will-bnb-reach-700-in-may", + "poly_strike": 700.0 + }, + { + "active": true, + "asset": "BNB", + "kalshi_ticker": "KXBNBMAXMON-BNB-26MAY31-67000", + "kalshi_strike": 670.0, + "poly_slug": "will-bnb-reach-700-in-may", + "poly_strike": 700.0 + }, + { + "active": true, + "asset": "BNB", + "kalshi_ticker": "KXBNBMAXMON-BNB-26MAY31-66000", + "kalshi_strike": 660.0, + "poly_slug": "will-bnb-reach-700-in-may", + "poly_strike": 700.0 + }, + { + "active": true, + "asset": "BNB", + "kalshi_ticker": "KXBNBMAXMON-BNB-26MAY31-65000", + "kalshi_strike": 650.0, + "poly_slug": "will-bnb-reach-700-in-may", + "poly_strike": 700.0 + }, + { + "active": true, + "asset": "BNB", + "kalshi_ticker": "KXBNBMINMON-BNB-26MAY31-60000", + "kalshi_strike": 600.0, + "poly_slug": "will-bnb-dip-to-600-in-may", + "poly_strike": 600.0 + }, + { + "active": true, + "asset": "BNB", + "kalshi_ticker": "KXBNBMINMON-BNB-26MAY31-59000", + "kalshi_strike": 590.0, + "poly_slug": "will-bnb-dip-to-600-in-may", + "poly_strike": 600.0 + }, + { + "active": true, + "asset": "BNB", + "kalshi_ticker": "KXBNBMINMON-BNB-26MAY31-58000", + "kalshi_strike": 580.0, + "poly_slug": "will-bnb-dip-to-600-in-may", + "poly_strike": 600.0 + }, + { + "active": true, + "asset": "BNB", + "kalshi_ticker": "KXBNBMINMON-BNB-26MAY31-57000", + "kalshi_strike": 570.0, + "poly_slug": "will-bnb-dip-to-600-in-may", + "poly_strike": 600.0 + }, + { + "active": true, + "asset": "BNB", + "kalshi_ticker": "KXBNBMINMON-BNB-26MAY31-56000", + "kalshi_strike": 560.0, + "poly_slug": "will-bnb-dip-to-600-in-may", + "poly_strike": 600.0 + }, + { + "active": true, + "asset": "BNB", + "kalshi_ticker": "KXBNBMINMON-BNB-26MAY31-55000", + "kalshi_strike": 550.0, + "poly_slug": "will-bnb-dip-to-500-in-may", + "poly_strike": 500.0 + }, + { + "active": true, + "asset": "BNB", + "kalshi_ticker": "KXBNBMINMON-BNB-26MAY31-54000", + "kalshi_strike": 540.0, + "poly_slug": "will-bnb-dip-to-500-in-may", + "poly_strike": 500.0 + }, + { + "active": true, + "asset": "BNB", + "kalshi_ticker": "KXBNBMINMON-BNB-26MAY31-53000", + "kalshi_strike": 530.0, + "poly_slug": "will-bnb-dip-to-500-in-may", + "poly_strike": 500.0 + } +] \ No newline at end of file diff --git a/data/secrets.example.json b/data/secrets.example.json new file mode 100644 index 000000000..f1db51a55 --- /dev/null +++ b/data/secrets.example.json @@ -0,0 +1,14 @@ +{ + "_comment": "Copy this file to data/secrets.json and fill in your own values. data/secrets.json is git-ignored. Never share these values.", + + "polymarket_wallet": "0xYOUR_POLYMARKET_PROXY_WALLET_ADDRESS", + + "_kalshi_comment": "Kalshi -> Settings -> API Keys. Create a key, save the private key file locally, put its absolute path + the Key ID here. Read-only is fine.", + "kalshi_key_id": "", + "kalshi_private_key_path": "", + + "_sms_comment": "Optional SMS alerts. Deploy a tiny Apps Script web app whose doPost reads {message,to,carrier} and calls MailApp.sendEmail(to+'@'+carrier, '', message). Paste its /exec URL below, then your phone (10 digits) and the carrier gateway domain (e.g. vtext.com, tmomail.net, txt.att.net).", + "sms_webhook": "", + "sms_phone": "", + "sms_carrier": "" +} diff --git a/data/settings.json b/data/settings.json new file mode 100644 index 000000000..b97955bae --- /dev/null +++ b/data/settings.json @@ -0,0 +1,7 @@ +{ + "kalshi_fee_rate": 0.07, + "poly_fee_rate": 0.072, + "min_net_return": 0.002, + "min_poly_volume": 100.0, + "min_contracts": 1.0 +} \ No newline at end of file diff --git a/scripts/gha_alerts.py b/scripts/gha_alerts.py new file mode 100644 index 000000000..0a41253e1 --- /dev/null +++ b/scripts/gha_alerts.py @@ -0,0 +1,113 @@ +#!/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 "") + + +def main(): + 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 β€” exiting (set SMS_WEBHOOK/SMS_PHONE/SMS_CARRIER)") + return + + payload = scan.run_scan(force=True) + 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() diff --git a/server.py b/server.py new file mode 100644 index 000000000..652bbf848 --- /dev/null +++ b/server.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Prediction-market arb scanner β€” pure-stdlib HTTP server. + + python3 server.py [port] (default 8787) + +Routes: + GET / served web UI + GET /api/scan live Kalshi + Polymarket scan (JSON) + GET /api/settings current thresholds + POST /api/settings update thresholds (writes data/settings.json) +""" +import json +import os +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse, parse_qs + +from arb import scan + +ROOT = os.path.dirname(os.path.abspath(__file__)) +WEB = os.path.join(ROOT, "web") + +_STATIC = { + "/": ("index.html", "text/html; charset=utf-8"), + "/index.html": ("index.html", "text/html; charset=utf-8"), + "/app.js": ("app.js", "application/javascript; charset=utf-8"), + "/style.css": ("style.css", "text/css; charset=utf-8"), +} + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def _send(self, code, body, ctype="application/json; charset=utf-8"): + if isinstance(body, (dict, list)): + body = json.dumps(body).encode() + elif isinstance(body, str): + body = body.encode() + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _static(self, path): + name, ctype = _STATIC[path] + try: + with open(os.path.join(WEB, name), "rb") as f: + self._send(200, f.read(), ctype) + except OSError: + self._send(404, {"error": "not found"}) + + def do_GET(self): + u = urlparse(self.path) + if u.path in _STATIC: + return self._static(u.path) + if u.path in ("/api/scan", "/api/scan/hourly"): + force = parse_qs(u.query).get("force", ["0"])[0] == "1" + runner = (scan.run_hourly if u.path.endswith("hourly") + else scan.run_scan) + try: + return self._send(200, runner(force=force)) + except Exception as e: + return self._send(500, {"error": "%s: %s" + % (type(e).__name__, e)}) + if u.path == "/api/positions": + force = parse_qs(u.query).get("force", ["0"])[0] == "1" + try: + return self._send(200, scan.run_positions(force=force)) + except Exception as e: + return self._send(500, {"error": "%s: %s" + % (type(e).__name__, e)}) + if u.path == "/api/settings": + return self._send(200, scan.load_settings()) + self._send(404, {"error": "not found"}) + + def do_POST(self): + if urlparse(self.path).path != "/api/settings": + return self._send(404, {"error": "not found"}) + try: + n = int(self.headers.get("Content-Length") or 0) + incoming = json.loads(self.rfile.read(n) or b"{}") + cur = scan.load_settings() + allowed = ("kalshi_fee_rate", "poly_fee_rate", "min_net_return", + "min_poly_volume", "min_contracts") + for k in allowed: + if k in incoming: + cur[k] = float(incoming[k]) + with open(os.path.join(ROOT, "data", "settings.json"), "w") as f: + json.dump(cur, f, indent=2) + scan._CACHE["payload"] = None # force fresh recompute next scan + self._send(200, cur) + except Exception as e: + self._send(400, {"error": "%s: %s" % (type(e).__name__, e)}) + + def log_message(self, fmt, *args): + sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args)) + + +def main(): + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8787 + srv = ThreadingHTTPServer(("127.0.0.1", port), Handler) + print("Arb scanner running -> http://127.0.0.1:%d" % port) + print("Press Ctrl+C to stop.") + from arb import alerts + alerts.start_poller() + try: + srv.serve_forever() + except KeyboardInterrupt: + print("\nStopping.") + srv.shutdown() + + +if __name__ == "__main__": + main() diff --git a/web/app.js b/web/app.js new file mode 100644 index 000000000..cabefa31b --- /dev/null +++ b/web/app.js @@ -0,0 +1,633 @@ +"use strict"; + +const ASSET = { + BTC: "#f7931a", ETH: "#7b87ff", SOL: "#19fb9b", XRP: "#3fb6e8", + BNB: "#f3ba2f", DOGE: "#c2a633", HYPE: "#22d3a6", ZEC: "#f4b728", +}; +const ac = (a) => ASSET[a] || "#7c8cff"; + +const COLS_MONTHLY = [ + { k: "_mkt", t: "Market", align: "l", sort: "kalshi_ticker" }, + { k: "best_side", t: "Side", align: "l" }, + { k: "_px", t: "K / P px", align: "l", sort: "combined_cost" }, + { k: "combined_cost", t: "Comb $", f: "px" }, + { k: "worst_pnl", t: "Min $/ct", f: "s4" }, + { k: "net_return", t: "Net Ret", f: "pctBig" }, + { k: "annualized", t: "Annual", f: "pct" }, + { k: "max_contracts", t: "Max Ct", f: "sz" }, + { k: "total_gain", t: "Tot $", f: "s2" }, + { k: "poly_volume", t: "P Vol", f: "money" }, + { k: "days_to_expiry",t: "Days", f: "int" }, + { k: "status", t: "Status", align: "l", f: "status" }, +]; +const COLS_HOURLY = [ + { k: "_mkt", t: "Market", align: "l", sort: "asset" }, + { k: "_window", t: "Window", align: "l", sort: "minutes_to_resolve" }, + { k: "kalshi_strike", t: "K Strike", f: "strike" }, + { k: "implied_strike",t: "Binance open", f: "strike" }, + { k: "best_side", t: "Side", align: "l" }, + { k: "_px", t: "K / P px", align: "l", sort: "combined_cost" }, + { k: "combined_cost", t: "Comb $", f: "px" }, + { k: "worst_pnl", t: "Min $/ct", f: "s4" }, + { k: "net_return", t: "Net Ret", f: "pctBig" }, + { k: "divergence", t: "Feed Ξ”", f: "div" }, + { k: "poly_volume", t: "P Vol", f: "money" }, + { k: "status", t: "Status", align: "l", f: "status" }, +]; + +let MODE = "monthly", POS_VIEW = "venue"; +const COLS = () => (MODE === "hourly" ? COLS_HOURLY : COLS_MONTHLY); +const endpoint = () => MODE === "positions" ? "/api/positions" + : MODE === "hourly" ? "/api/scan/hourly" : "/api/scan"; + +let RAW = [], sortKey = "net_return", sortAsc = false, openKey = null; +const $ = (id) => document.getElementById(id); +const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); +const rid = (r) => (r.asset || "") + "|" + (r.kalshi_ticker || "") + + "|" + (r.poly_slug || ""); + +function fmt(v, kind) { + if (v === null || v === undefined || v === "") + return 'β€”'; + const sign = (n, body, big) => { + const c = n > 0 ? "pos" : n < 0 ? "neg" : "dimv"; + return `${body}`; + }; + switch (kind) { + case "int": return String(Math.round(v)); + case "sz": return (+v).toLocaleString(undefined, { maximumFractionDigits: 0 }); + case "px": return `${(+v).toFixed(3)}`; + case "money": return "$" + (+v).toLocaleString(undefined, { notation: v >= 1e6 ? "compact" : "standard", maximumFractionDigits: 1 }); + case "pct": return sign(v, (v * 100).toFixed(1) + "%"); + case "pctBig":return sign(v, (v > 0 ? "+" : "") + (v * 100).toFixed(2) + "%", true); + case "s4": return sign(v, (v > 0 ? "+" : "") + (+v).toFixed(4)); + case "s2": return sign(v, (v > 0 ? "+" : "") + "$" + (+v).toFixed(2)); + case "strike":return `${(+v).toLocaleString(undefined, { maximumFractionDigits: v < 10 ? 4 : 0 })}`; + case "div": { const p = (v * 100), hot = Math.abs(p) >= 0.3; + return `${p >= 0 ? "+" : ""}${p.toFixed(3)}%`; } + case "status":{ const s = String(v).replace(/\s+/g, ""); + return `${v}`; } + default: return esc(v); + } +} + +function thumb(r, sz) { + const s = sz || 34; + if (r.image) + return ``; + return `
${esc((r.asset || "?").slice(0, 4))}
`; +} + +function cellMarket(r) { + const title = r.kalshi_title || r.poly_question || + `${r.asset} ${r.direction} ${r.kalshi_strike ?? ""}`; + const strikes = `K $${r.kalshi_strike ?? "β€”"} Β· P $${r.poly_strike ?? "β€”"}`; + return `
${thumb(r)} +
+
+ ${esc(r.asset || "?")} + ${esc(title)} +
+
${esc(r.direction || "")}  ${esc(strikes)}
+
`; +} + +function cellPx(r) { + const k = r.kalshi_price, p = r.poly_price, c = r.combined_cost; + if (k == null || p == null) return 'β€”'; + const pct = Math.min(100, (c / 1) * 100); + const col = c < 1 ? "var(--good)" : "var(--bad)"; + return `
+
K ${k.toFixed(3)}P ${p.toFixed(3)}
+
+
cost vs $1${c.toFixed(3)}
+
`; +} + +function cellWindow(r) { + if (!r.window_start) return 'β€”'; + const m = r.minutes_to_resolve; + const mc = m != null && m <= 10 ? "neg" : m != null && m <= 25 ? "pos" : "dimv"; + return `
+
${esc(r.window_start)} β†’ ${esc(r.window_close)}
+
${m != null ? "resolves in " + m + "m" : ""}
+
`; +} + +function renderHead() { + $("head").innerHTML = COLS().map((c) => { + const sk = c.sort || c.k; + let cls = c.align === "l" ? "l" : ""; + if (sk === sortKey) cls += sortAsc ? " asc" : " sorted"; + return `${c.t}`; + }).join(""); + document.querySelectorAll("#head th").forEach((th) => { + th.onclick = () => { + const k = th.dataset.sk; + if (k === sortKey) sortAsc = !sortAsc; else { sortKey = k; sortAsc = false; } + renderHead(); renderBody(); + }; + }); +} + +function activeStatus() { + const b = document.querySelector("#statusSeg button.on"); + return b ? b.dataset.s : ""; +} +function activeAssets() { + return [...document.querySelectorAll("#assetChips .ac.on")].map((e) => e.dataset.a); +} + +function filtered() { + const q = $("search").value.trim().toLowerCase(); + const s = activeStatus(), as = activeAssets(), fav = $("favOnly").checked; + return RAW.filter((r) => { + if (s && r.status !== s) return false; + if (as.length && !as.includes(r.asset)) return false; + if (fav && !r.basis_favorable) return false; + if (q) { + const h = `${r.asset} ${r.kalshi_ticker} ${r.poly_slug || ""} ${r.kalshi_title || ""} ${r.poly_question || ""}`.toLowerCase(); + if (!h.includes(q)) return false; + } + return true; + }); +} + +function sortRows(rows) { + return rows.sort((x, y) => { + let a = x[sortKey], b = y[sortKey]; + a = a == null ? -Infinity : a; b = b == null ? -Infinity : b; + if (typeof a === "string" || typeof b === "string") + return sortAsc ? String(a).localeCompare(b) : String(b).localeCompare(a); + return sortAsc ? a - b : b - a; + }); +} + +function drawer(r) { + const kv = (k, v) => `
${k}
${v}
`; + const money = (n, d = 4) => n == null ? "β€”" + : `${n > 0 ? "+" : ""}${(+n).toFixed(d)}`; + const kCard = `
+

Kalshi ${esc(r.kalshi_ticker)}

+
${esc(r.kalshi_title || "β€”")}
+
+
Yes ask
${r.kalshi_price != null && r.best_side === "YES+NO" ? r.kalshi_price.toFixed(3) : "β€”"}
+
No ask
${r.kalshi_price != null && r.best_side === "NO+YES" ? r.kalshi_price.toFixed(3) : "β€”"}
+
Open int
${r.kalshi_size != null ? Math.round(r.kalshi_size).toLocaleString() : "β€”"}
+
+
${esc(r.kalshi_rules || "Resolution rules unavailable.")}
+
`; + const pImg = r.image ? `` : ""; + const pSideRaw = (r.best_side || "β€”").split("+")[1] || "β€”"; + const pSide = MODE === "hourly" + ? (pSideRaw === "NO" ? "Down" : pSideRaw === "YES" ? "Up" : pSideRaw) + : pSideRaw; + const pCard = `
+

Polymarket ${esc(r.poly_slug || "no paired market")}

+ ${pImg} +
${esc(r.poly_question || "β€”")}
+
+
Side held
${esc(pSide)}
+
Poly px
${r.poly_price != null ? r.poly_price.toFixed(3) : "β€”"}
+
Volume
$${(r.poly_volume || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}
+
+
${esc(r.poly_description || "Market description unavailable.")}
+
`; + const hourly = MODE === "hourly"; + const scen = `
+
${hourly ? "Worst case" : "Min / guaranteed"}
${money(r.worst_pnl)}
+
Strike gap
${r.mid_pnl == null ? 'none' : money(r.mid_pnl)}
+
Best case
${money(r.best_pnl)}
+
`; + const hRows = hourly ? ` + ${kv("Window", esc((r.window_start || "β€”") + " β†’ " + (r.window_close || "β€”")))} + ${kv("Resolves in", r.minutes_to_resolve != null ? r.minutes_to_resolve + " min" : "β€”")} + ${kv("Implied strike (Binance open)", r.implied_strike != null ? r.implied_strike.toLocaleString() : "β€”")} + ${kv("Binance spot / CF spot", `${r.binance_spot != null ? r.binance_spot.toLocaleString() : "β€”"} / ${r.cf_spot != null ? r.cf_spot.toLocaleString() : "n/a"}`)} + ${kv("Feed divergence", fmt(r.divergence, "div"))}` : ` + ${kv("Annualized", fmt(r.annualized, "pct"))} + ${kv("Total guaranteed $", money(r.total_gain, 2))} + ${kv("Days to expiry", r.days_to_expiry ?? "β€”")}`; + const bd = `
+

${hourly ? "Speculative breakdown" : "Arb breakdown"}

+ ${scen} + ${hourly ? '
Different settlement feeds β€” legs are not a locked hedge.
' : ""} +
+ ${kv("Best side", esc(r.best_side || "β€”"))} + ${kv("Combined cost", r.combined_cost != null ? "$" + r.combined_cost.toFixed(4) : "β€”")} + ${kv("Total fee / ct", r.total_fee != null ? "$" + r.total_fee.toFixed(4) : "β€”")} + ${kv("Net return", fmt(r.net_return, "pctBig"))} + ${kv("Basis %", fmt(r.basis_pct, "pct") + (r.basis_favorable ? ' βœ“ favorable' : ' βœ— risk'))} + ${kv("Max contracts", r.max_contracts != null ? Math.round(r.max_contracts).toLocaleString() : "β€”")} + ${hRows} +
`; + return ` +
${kCard}${pCard}${bd}
`; +} + +function renderBody() { + const rows = sortRows(filtered()); + $("rowCount").textContent = `${rows.length} of ${RAW.length} markets`; + $("empty").hidden = rows.length > 0; + const html = []; + for (const r of rows) { + const id = rid(r), isOpen = id === openKey; + html.push(`` + + COLS().map((c) => { + const cls = c.align === "l" ? "l" : ""; + if (c.k === "_mkt") return `${cellMarket(r)}`; + if (c.k === "_px") return `${cellPx(r)}`; + if (c.k === "_window") return `${cellWindow(r)}`; + if (c.k === "best_side") { + let bs = r.best_side || "β€”"; + if (MODE === "hourly" && bs !== "β€”") + bs = bs === "YES+NO" ? "K-Yes Β· P-Down" : "K-No Β· P-Up"; + return `${esc(bs)}`; + } + return `${fmt(r[c.k], c.f)}`; + }).join("") + ""); + if (isOpen) html.push(drawer(r)); + } + $("body").innerHTML = html.join(""); + document.querySelectorAll("#body tr.r").forEach((tr) => { + tr.onclick = () => { + openKey = openKey === tr.dataset.id ? null : tr.dataset.id; + renderBody(); + }; + }); +} + +function renderSummary(s) { + const cards = MODE === "hourly" ? [ + ["ARB", "Edges", "s-arb"], ["NO ARB", "No edge", ""], + ["BAD BASIS", "Bad basis", "s-bad"], ["NO DATA", "No data", ""], + ["total", "Assets", ""], + ] : [ + ["ARB", "Live arbs", "s-arb"], ["NO ARB", "No edge", ""], + ["BAD BASIS", "Bad basis", "s-bad"], ["LOW SIZE", "Low size", "s-warn"], + ["NO DATA", "No data", ""], ["total", "Scanned", ""], + ]; + $("summary").innerHTML = cards.map(([k, l, cls]) => + `
${s[k] ?? 0}
+
${l}
`).join("") + + `
${s.fetch_ms ?? "β€”"}ms
+
Fetch time
`; +} + +function renderHero(rows) { + const hourly = MODE === "hourly"; + const top = rows.filter((r) => r.status === "ARB" && r.net_return > 0) + .sort((a, b) => b.net_return - a.net_return).slice(0, 4); + const h = $("hero"); + h.querySelector("h2").textContent = + hourly ? "Top dislocations" : "Top opportunities"; + h.querySelector(".muted").textContent = hourly + ? "combined cost < $1 β€” speculative, basis risk applies" + : "positive guaranteed return, ranked"; + if (!top.length) { h.hidden = true; return; } + h.hidden = false; + $("heroCards").innerHTML = top.map((r) => ` +
+
+
${thumb(r, 38)} +
${esc(r.kalshi_title || r.poly_question || r.asset)}
+
+${(r.net_return * 100).toFixed(2)}%
+
+ ${hourly + ? `${r.minutes_to_resolve ?? "β€”"}m left + ${r.divergence != null ? (r.divergence * 100).toFixed(3) + "%" : "β€”"} feed Ξ”` + : `${r.annualized ? (r.annualized * 100).toFixed(0) + "%" : "β€”"} annual + $${(r.total_gain || 0).toFixed(2)} locked`} + ${Math.round(r.max_contracts || 0).toLocaleString()} ct + ${esc(r.best_side)} +
+
`).join(""); + document.querySelectorAll(".hcard").forEach((c) => c.onclick = () => { + openKey = c.dataset.id; + document.querySelector("#statusSeg button.on")?.classList.remove("on"); + document.querySelector('#statusSeg button[data-s=""]').classList.add("on"); + renderBody(); + document.querySelector(`#body tr.r[data-id="${CSS.escape(c.dataset.id)}"]`) + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + }); +} + +let POS_DATA = null; + +const usd = (n, d = 2) => n == null ? "β€”" + : "$" + (+n).toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d }); +function pnl(n, pct) { + if (n == null) return 'β€”'; + const c = n > 0 ? "pos" : n < 0 ? "neg" : "dimv"; + const p = pct != null ? ` (${(pct * 100).toFixed(1)}%)` : ""; + return `${n > 0 ? "+" : ""}${usd(n)}${p}`; +} + +function posThumb(p) { + if (p.icon) return ``; + const a = p.asset ? p.asset.slice(0, 4) : "?"; + return `
${esc(a)}
`; +} + +function venuePanel(name, tag, v) { + const setup = (body) => `
+

${name} not connected

${body}
`; + if (!v || v.configured === false) { + if (name === "Polymarket") + return setup(`

Add your wallet to data/secrets.json:

+
{ "polymarket_wallet": "0xYourProxyWallet" }
+

Public address only β€” no private key. Copy + data/secrets.example.json to start.

`); + return setup(`

Create a read-only API key in Kalshi β†’ Settings β†’ API Keys, + save the private-key file locally, then add to + data/secrets.json:

+
{ "kalshi_key_id": "…",
+  "kalshi_private_key_path": "/abs/path/key.pem" }
+

Signed locally via openssl β€” the key never leaves your + machine. ${v && v.reason === "key_file_not_found" + ? 'Key file not found at that path.' : ""}

`); + } + if (v.error) + return `

${name}

+

${esc(v.error)}

`; + const ps = v.positions || []; + const head = `
+

${name} ${ps.length} open

+ ${usd(v.total_value)}
`; + if (!ps.length) + return `
${head}

No open positions.

`; + const rows = ps.sort((a, b) => (b.value || 0) - (a.value || 0)).map((p) => ` + +
${posThumb(p)}
+
${esc(p.market)}
+
${esc(p.ref || "")}
+ ${esc(p.side)} + ${(p.size || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })} + ${p.avg_price != null ? (+p.avg_price).toFixed(3) : "β€”"} β†’ ${p.cur_price != null ? (+p.cur_price).toFixed(3) : "β€”"} + ${usd(p.cost)} + ${usd(p.value)} + ${pnl(p.pnl, p.pnl_pct)} + `).join(""); + return `
${head} + + + + ${rows}
MarketSideSizeAvg β†’ CurCostValueP&L
`; +} + +function pairedView(rows) { + if (!rows || !rows.length) + return `

Paired exposure

+

No held positions match a known Kalshi↔Polymarket arb + pair (pairs come from the Monthly map). Positions still show under + By venue.

`; + const body = rows.map((r) => { + const legs = r.legs.map((l) => `
+ ${l.venue[0]} + ${esc(l.side)} + ${esc(l.ref || "")} + ${(l.size || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })} + ${pnl(l.pnl)}
`).join(""); + return ` + ${esc(r.asset || "?")} + ${r.complete ? 'paired' : 'one leg'} + ${legs} + ${usd(r.cost)} + ${usd(r.value)} + ${pnl(r.pnl, r.pnl_pct)} + `; + }).join(""); + return `

Paired / netted exposure

+ + + + ${body}
PairLegsCostValueNet P&L
`; +} + +function historyCard(name, tag, rows, note) { + const head = `
+

${name} ${rows.length} settled${note ? ` Β· ${note}` : ""}

+ ${pnl(rows.reduce((s, r) => s + (r.realized || 0), 0))}
`; + if (!rows.length) + return `
${head}

No settled markets found.

`; + const body = rows.slice().sort((a, b) => + (b.settled_date || "").localeCompare(a.settled_date || "")).map((r) => ` + +
+ ${r.icon ? `` : ""} +
${esc(r.market)}
+
${esc(r.settled_date || "")}
+ ${esc(r.result)} + ${esc(r.side || "β€”")} + ${(r.size || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })} + ${usd(r.cost)} + ${usd(r.payout)} + ${pnl(r.realized)} + `).join(""); + return `
${head} + + + + ${body}
MarketResultSideSizeCostPayoutRealized
`; +} + +function historyView(h) { + h = h || {}; + return `
+ ${historyCard("Kalshi", "k", h.kalshi || [], "exact (settlements)")} + ${historyCard("Polymarket", "p", h.polymarket || [], "reconstructed Β· payout derived")} +
`; +} + +function renderPositions(d) { + if (d) POS_DATA = d; + d = POS_DATA; + const t = d.totals || {}; + const stat = (n, l, cls) => + `
${n}
${l}
`; + const pstat = (n, l, big) => { + if (n == null) + return `
β€”
${l}
`; + const cls = n > 0 ? "s-arb" : n < 0 ? "s-bad" : ""; + const v = (n > 0 ? "+" : n < 0 ? "-" : "") + + "$" + Math.abs(n).toLocaleString(undefined, + { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + return `
+
${v}
${l}
`; + }; + const strip = `
+ ${stat(usd(t.poly_value), "Polymarket value")} + ${stat(t.kalshi_value == null ? "β€”" : usd(t.kalshi_value), "Kalshi value")} + ${pstat(t.kalshi_pnl, "Kalshi P&L")} + ${pstat(t.poly_pnl, "Polymarket P&L")} + ${(() => { + const n = t.total_pnl, r = t.total_return; + if (n == null) + return `
β€”
Total P&L
`; + const cls = n > 0 ? "s-arb" : n < 0 ? "s-bad" : ""; + const v = (n > 0 ? "+" : n < 0 ? "-" : "") + "$" + + Math.abs(n).toLocaleString(undefined, + { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + const sub = r == null ? "" : + `
${r > 0 ? "+" : ""}${(r * 100).toFixed(2)}% return
`; + return `
${v}
${sub}
Total P&L
`; + })()} + ${pstat(t.total_realized, "Realized P&L")} + ${stat(t.open_positions ?? 0, "Open positions")} + ${stat(t.settled_count ?? 0, "Settled")} + ${stat(t.paired_count ?? 0, "Complete pairs", "s-arb")}
`; + const tab = (v, l) => + ``; + const seg = `
+ ${tab("venue", "By venue")}${tab("paired", "Paired")}${tab("history", "History")} +
`; + const content = POS_VIEW === "paired" ? pairedView(d.paired) + : POS_VIEW === "history" ? historyView(d.history) + : `
+ ${venuePanel("Polymarket", "p", d.polymarket)} + ${venuePanel("Kalshi", "k", d.kalshi)}
`; + $("positions").innerHTML = strip + + `
${seg} + Read-only Β· credentials stay in local + data/secrets.json
` + content; + $("positions").querySelectorAll(".posseg button").forEach((b) => + b.onclick = () => { + POS_VIEW = b.dataset.v; renderPositions(); + }); +} + +function toast(msg, kind) { + const t = $("toast"); + t.textContent = msg; t.className = "toast" + (kind ? " " + kind : ""); + t.hidden = false; clearTimeout(toast._t); + toast._t = setTimeout(() => (t.hidden = true), kind === "err" ? 7000 : 2800); +} + +function applyChrome() { + const scanner = MODE !== "positions"; + $("summary").hidden = !scanner; + document.querySelector(".toolbar").hidden = !scanner; + document.querySelector("main").hidden = !scanner; + $("positions").hidden = scanner; + if (!scanner) { $("hero").hidden = true; $("hbanner").hidden = true; } +} + +async function load(force) { + const b = $("refresh"); + b.disabled = true; b.classList.add("loading"); + b.querySelector(".blabel").textContent = + MODE === "positions" ? "Loading…" : "Scanning…"; + applyChrome(); + try { + const res = await fetch(endpoint() + (force ? "?force=1" : "")); + const d = await res.json(); + if (!res.ok) throw new Error(d.error || res.status); + if (MODE === "positions") { + renderPositions(d); + $("meta").textContent = + `${d.generated_at} Β· ${d.totals.open_positions} open`; + } else { + RAW = d.rows; + renderSummary(d.summary); + buildAssetChips(); + renderHero(RAW); + renderBody(); + updateBanner(d.summary); + const unit = MODE === "hourly" ? "assets" : "pairs"; + $("meta").textContent = `${d.generated_at} Β· ${d.summary.total} ${unit}`; + } + } catch (e) { + toast((MODE === "positions" ? "Load" : "Scan") + " failed: " + e.message, "err"); + $("meta").textContent = "load failed"; + } finally { + b.disabled = false; b.classList.remove("loading"); + b.querySelector(".blabel").textContent = "Refresh"; + } +} + +function updateBanner(summary) { + const b = $("hbanner"); + if (MODE !== "hourly") { b.hidden = true; return; } + b.hidden = false; + const d = summary && summary.max_divergence; + const el = $("hbDiv"); + if (d == null) { el.textContent = "n/a"; el.classList.remove("hot"); return; } + const pct = (d * 100); + el.textContent = "max " + pct.toFixed(3) + "%"; + el.classList.toggle("hot", pct >= 0.3); +} + +function buildAssetChips() { + const el = $("assetChips"); + if (el.dataset.built) return; + const assets = [...new Set(RAW.map((r) => r.asset))].filter(Boolean).sort(); + el.innerHTML = assets.map((a) => + `${a}`).join(""); + el.dataset.built = "1"; + el.querySelectorAll(".ac").forEach((c) => c.onclick = () => { + c.classList.toggle("on"); renderBody(); + }); +} + +async function loadSettings() { + const s = await (await fetch("/api/settings")).json(); + s_kfee.value = s.kalshi_fee_rate; s_pfee.value = s.poly_fee_rate; + s_minret.value = s.min_net_return; s_minvol.value = s.min_poly_volume; + s_minct.value = s.min_contracts; +} + +function wire() { + document.querySelectorAll("#tabs button").forEach((b) => b.onclick = () => { + if (b.classList.contains("on")) return; + document.querySelector("#tabs button.on")?.classList.remove("on"); + b.classList.add("on"); + MODE = b.dataset.m; + openKey = null; + sortKey = "net_return"; sortAsc = false; + const ac = $("assetChips"); ac.dataset.built = ""; ac.innerHTML = ""; + document.querySelector("#statusSeg button.on")?.classList.remove("on"); + document.querySelector('#statusSeg button[data-s=""]').classList.add("on"); + $("search").value = ""; + renderHead(); + load(true); + }); + $("refresh").onclick = () => load(true); + $("search").addEventListener("input", renderBody); + $("favOnly").addEventListener("change", renderBody); + document.querySelectorAll("#statusSeg button").forEach((b) => b.onclick = () => { + document.querySelector("#statusSeg button.on")?.classList.remove("on"); + b.classList.add("on"); renderBody(); + }); + let timer = null; + $("auto").onchange = (e) => { + clearInterval(timer); + if (e.target.checked) { load(true); timer = setInterval(() => load(true), 8000); } + }; + $("settingsBtn").onclick = async () => { + await loadSettings(); $("settingsModal").hidden = false; + }; + $("closeSettings").onclick = () => ($("settingsModal").hidden = true); + $("saveSettings").onclick = async () => { + const body = { + kalshi_fee_rate: +s_kfee.value, poly_fee_rate: +s_pfee.value, + min_net_return: +s_minret.value, min_poly_volume: +s_minvol.value, + min_contracts: +s_minct.value, + }; + const r = await fetch("/api/settings", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (r.ok) { $("settingsModal").hidden = true; toast("Saved β€” rescanning", "ok"); load(true); } + else toast("Save failed", "err"); + }; +} + +renderHead(); +wire(); +load(false); diff --git a/web/index.html b/web/index.html new file mode 100644 index 000000000..4c9db27a9 --- /dev/null +++ b/web/index.html @@ -0,0 +1,102 @@ + + + + + +Arb Scanner Β· Kalshi Γ— Polymarket + + + + + + +
+ +
+
+ +
+

Arb Scanner

+ Kalshi Γ— Polymarket Β· crypto +
+
+ + + +
+
+
+
connecting…
+ + + +
+
+ + + +
+ + + +
+ +
+
+ + + + + +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 000000000..2191fb180 --- /dev/null +++ b/web/style.css @@ -0,0 +1,378 @@ +:root { + --bg: #0a0c14; --bg2: #0e1120; --panel: rgba(22,26,42,.72); + --panel-solid: #161a2a; --line: rgba(255,255,255,.07); + --line2: rgba(255,255,255,.12); + --txt: #eef1f8; --dim: #9aa3bd; --faint: #6b7290; + --accent: #6c8cff; --accent2: #b06cff; + --good: #2fe39b; --good-d: #0c2c22; --warn: #ffc24b; --warn-d: #2e2410; + --bad: #ff647c; --bad-d: #2c1420; --info: #4cc9f0; + --r: 14px; --shadow: 0 14px 40px -18px rgba(0,0,0,.7); +} +* { box-sizing: border-box; margin: 0; padding: 0; } +/* The HTML `hidden` attribute must always win, even over display:grid/flex. */ +[hidden] { display: none !important; } +html { scroll-behavior: smooth; } +body { + background: var(--bg); color: var(--txt); min-height: 100vh; + font: 14px/1.5 "Inter", -apple-system, BlinkMacSystemFont, sans-serif; + -webkit-font-smoothing: antialiased; padding-bottom: 80px; +} +.mono { font-family: "JetBrains Mono", ui-monospace, Menlo, monospace; } + +/* ambient gradient wash */ +.aurora { + position: fixed; inset: 0; z-index: -1; pointer-events: none; + background: + radial-gradient(60vw 50vh at 12% -5%, rgba(108,140,255,.20), transparent 60%), + radial-gradient(55vw 45vh at 95% 0%, rgba(176,108,255,.16), transparent 55%), + radial-gradient(50vw 50vh at 60% 110%, rgba(47,227,155,.10), transparent 60%), + var(--bg); +} + +/* ---------- header ---------- */ +header { + display: flex; justify-content: space-between; align-items: center; + padding: 18px 26px; position: sticky; top: 0; z-index: 20; + background: linear-gradient(180deg, rgba(10,12,20,.92), rgba(10,12,20,.6)); + backdrop-filter: blur(14px); border-bottom: 1px solid var(--line); +} +.brand { display: flex; align-items: center; gap: 14px; } +.logo { + width: 42px; height: 42px; display: grid; place-items: center; + font-size: 20px; border-radius: 12px; color: #fff; + background: linear-gradient(135deg, var(--accent), var(--accent2)); + box-shadow: 0 8px 24px -8px var(--accent); +} +h1 { font-size: 19px; font-weight: 800; letter-spacing: -.02em; } +.brand .sub { color: var(--dim); font-size: 12px; } +.tabs { display: flex; gap: 4px; margin-left: 18px; padding: 4px; + background: var(--panel); border: 1px solid var(--line2); border-radius: 12px; } +.tabs button { background: transparent; color: var(--dim); font-size: 13px; + padding: 7px 16px; border-radius: 9px; display: flex; align-items: center; gap: 6px; } +.tabs button.on { color: #fff; + background: linear-gradient(135deg, var(--accent), var(--accent2)); + box-shadow: 0 6px 18px -8px var(--accent); } +.tabs .spec { font-size: 9px; font-weight: 800; letter-spacing: .05em; + padding: 1px 5px; border-radius: 5px; background: var(--warn-d); + color: var(--warn); text-transform: uppercase; } +.tabs button.on .spec { background: rgba(255,255,255,.2); color: #fff; } + +.hbanner { display: flex; gap: 14px; align-items: center; margin: 18px 26px 0; + padding: 14px 18px; border-radius: var(--r); + background: linear-gradient(135deg, rgba(255,194,75,.12), var(--panel)); + border: 1px solid rgba(255,194,75,.3); } +.hb-ico { font-size: 22px; color: var(--warn); } +.hb-txt { font-size: 13px; color: var(--dim); line-height: 1.5; } +.hb-txt b { color: var(--warn); } +.hb-div { font-weight: 700; font-family: "JetBrains Mono", monospace; + color: var(--txt); padding: 1px 8px; border-radius: 6px; + background: rgba(255,255,255,.06); } +.hb-div.hot { color: var(--bad); background: var(--bad-d); } +.actions { display: flex; align-items: center; gap: 12px; } +.live { + display: flex; align-items: center; gap: 7px; color: var(--dim); + font-size: 12px; padding: 7px 12px; border: 1px solid var(--line); + border-radius: 999px; background: var(--panel); +} +.live .dot { + width: 8px; height: 8px; border-radius: 50%; background: var(--good); + box-shadow: 0 0 0 0 rgba(47,227,155,.6); animation: pulse 2s infinite; +} +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 rgba(47,227,155,.55); } + 70% { box-shadow: 0 0 0 7px rgba(47,227,155,0); } + 100% { box-shadow: 0 0 0 0 rgba(47,227,155,0); } +} +.auto { display: flex; align-items: center; gap: 7px; color: var(--dim); + font-size: 12px; user-select: none; cursor: pointer; } +.auto input { accent-color: var(--accent); } +button { + font: inherit; font-weight: 600; cursor: pointer; border: 0; + border-radius: 10px; padding: 9px 16px; transition: .16s; +} +.primary { + color: #fff; background: linear-gradient(135deg, var(--accent), var(--accent2)); + display: inline-flex; align-items: center; gap: 8px; + box-shadow: 0 8px 22px -10px var(--accent); +} +.primary:hover { transform: translateY(-1px); filter: brightness(1.08); } +.ghost { color: var(--txt); background: var(--panel); border: 1px solid var(--line2); } +.ghost:hover { border-color: var(--accent); color: #fff; } +button:disabled { opacity: .55; cursor: default; transform: none; } +.bspin { width: 13px; height: 13px; border-radius: 50%; display: none; + border: 2px solid rgba(255,255,255,.35); border-top-color: #fff; + animation: spin .7s linear infinite; } +.loading .bspin { display: inline-block; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* ---------- summary ---------- */ +.summary { + display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 14px; padding: 22px 26px 6px; +} +.stat { + position: relative; overflow: hidden; border-radius: var(--r); + border: 1px solid var(--line); background: var(--panel); + backdrop-filter: blur(8px); padding: 16px 18px; +} +.stat::before { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; + background: var(--accent); +} +.stat.s-arb::before { background: var(--good); } +.stat.s-bad::before { background: var(--bad); } +.stat.s-warn::before { background: var(--warn); } +.stat .n { font-size: 28px; font-weight: 800; letter-spacing: -.02em; } +.stat.s-arb .n { color: var(--good); } +.stat.s-bad .n { color: var(--bad); } +.stat .l { color: var(--dim); font-size: 11px; font-weight: 600; + text-transform: uppercase; letter-spacing: .07em; margin-top: 2px; } +.stat .spark { color: var(--faint); font-size: 11px; margin-top: 6px; } + +/* ---------- hero ---------- */ +.hero { padding: 18px 26px 0; } +.hero-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 12px; } +.hero-head h2 { font-size: 15px; font-weight: 700; } +.muted { color: var(--faint); font-size: 12px; } +.hero-cards { display: grid; gap: 14px; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } +.hcard { + position: relative; overflow: hidden; border-radius: var(--r); + padding: 16px; border: 1px solid var(--line2); + background: linear-gradient(150deg, rgba(47,227,155,.10), var(--panel) 55%); + box-shadow: var(--shadow); transition: .18s; cursor: pointer; +} +.hcard:hover { transform: translateY(-3px); border-color: var(--good); } +.hcard .glow { + position: absolute; right: -40px; top: -40px; width: 130px; height: 130px; + background: radial-gradient(circle, rgba(47,227,155,.4), transparent 70%); + filter: blur(6px); +} +.hcard .top { display: flex; align-items: center; gap: 11px; margin-bottom: 14px; } +.hcard .thumb { width: 38px; height: 38px; border-radius: 10px; } +.hcard .q { font-weight: 600; font-size: 13px; line-height: 1.35; } +.hcard .ret { font-size: 30px; font-weight: 800; color: var(--good); + letter-spacing: -.02em; } +.hcard .meta { display: flex; gap: 16px; margin-top: 8px; color: var(--dim); + font-size: 12px; } +.hcard .meta b { color: var(--txt); font-weight: 600; } +.hcard.spec { background: linear-gradient(150deg, rgba(255,194,75,.12), + var(--panel) 55%); } +.hcard.spec:hover { border-color: var(--warn); } +.hcard.spec .glow { background: radial-gradient(circle, + rgba(255,194,75,.34), transparent 70%); } +.hcard.spec .ret { color: var(--warn); } +.warn-line { font-size: 11.5px; color: var(--warn); background: var(--warn-d); + border-radius: 7px; padding: 7px 10px; margin: 10px 0; } + +/* ---------- toolbar ---------- */ +.toolbar { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + padding: 20px 26px 14px; +} +.search { position: relative; display: flex; align-items: center; } +.search svg { position: absolute; left: 12px; color: var(--faint); } +.search input { + background: var(--panel); border: 1px solid var(--line2); color: var(--txt); + border-radius: 999px; padding: 9px 16px 9px 34px; min-width: 250px; + font: inherit; outline: none; +} +.search input:focus { border-color: var(--accent); } +.chips { display: flex; gap: 7px; flex-wrap: wrap; } +.chips .ac { + display: flex; align-items: center; gap: 6px; padding: 6px 12px; + border-radius: 999px; border: 1px solid var(--line2); background: var(--panel); + color: var(--dim); font-size: 12px; font-weight: 600; cursor: pointer; + transition: .14s; +} +.chips .ac .blob { width: 8px; height: 8px; border-radius: 50%; } +.chips .ac.on { color: #fff; border-color: transparent; + background: rgba(255,255,255,.10); } +.seg { display: flex; background: var(--panel); border: 1px solid var(--line2); + border-radius: 999px; padding: 3px; } +.seg button { background: transparent; color: var(--dim); padding: 6px 13px; + border-radius: 999px; font-size: 12px; } +.seg button.on { color: #fff; + background: linear-gradient(135deg, var(--accent), var(--accent2)); } +.chk { display: flex; align-items: center; gap: 7px; color: var(--dim); + font-size: 12px; cursor: pointer; user-select: none; } +.chk input { accent-color: var(--accent); } +.toolbar .count { margin-left: auto; color: var(--faint); font-size: 12px; } + +/* ---------- table ---------- */ +main { padding: 0 26px; } +table { border-collapse: separate; border-spacing: 0; width: 100%; + font-variant-numeric: tabular-nums; } +thead th { + position: sticky; top: 79px; z-index: 5; background: var(--bg2); + color: var(--faint); font-size: 10.5px; font-weight: 700; + text-transform: uppercase; letter-spacing: .06em; text-align: right; + padding: 12px 12px; cursor: pointer; user-select: none; white-space: nowrap; + border-bottom: 1px solid var(--line2); +} +thead th:first-child, thead th.l { text-align: left; } +thead th:hover { color: var(--txt); } +thead th.sorted, thead th.asc { color: var(--accent); } +thead th.sorted::after { content: " ↓"; } +thead th.asc::after { content: " ↑"; } +tbody td { + padding: 11px 12px; text-align: right; white-space: nowrap; + border-bottom: 1px solid var(--line); } +tbody td.l { text-align: left; } +tbody tr.r { cursor: pointer; transition: background .12s; } +tbody tr.r:hover { background: rgba(255,255,255,.035); } +tbody tr.r.open { background: rgba(108,140,255,.07); } + +.mkt { display: flex; align-items: center; gap: 11px; max-width: 360px; } +.thumb, .badge { + width: 34px; height: 34px; border-radius: 9px; flex-shrink: 0; + object-fit: cover; background: var(--panel-solid); +} +.badge { display: grid; place-items: center; font-weight: 800; font-size: 12px; + color: #fff; letter-spacing: -.02em; } +.mkt .info { min-width: 0; } +.mkt .qa { display: flex; align-items: center; gap: 7px; } +.mkt .q { font-weight: 600; font-size: 13px; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; max-width: 270px; } +.mkt .sub { color: var(--faint); font-size: 11px; margin-top: 1px; } +.achip { font-size: 10px; font-weight: 800; padding: 2px 7px; border-radius: 6px; + letter-spacing: .02em; flex-shrink: 0; } +.dir { font-size: 10px; font-weight: 700; color: var(--dim); + border: 1px solid var(--line2); padding: 1px 6px; border-radius: 5px; } + +.pricebar { display: flex; flex-direction: column; gap: 3px; min-width: 110px; } +.pricebar .track { height: 6px; border-radius: 3px; background: rgba(255,255,255,.08); + position: relative; overflow: hidden; } +.pricebar .fill { position: absolute; inset: 0 auto 0 0; border-radius: 3px; + background: linear-gradient(90deg, var(--accent), var(--accent2)); } +.pricebar .lbl { display: flex; justify-content: space-between; + font-size: 10px; color: var(--faint); } + +.num { font-weight: 600; } +.pos { color: var(--good); } .neg { color: var(--bad); } +.big { font-size: 15px; font-weight: 800; letter-spacing: -.02em; } +.dimv { color: var(--faint); } + +.pill { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; + border-radius: 999px; font-size: 11px; font-weight: 700; } +.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; + background: currentColor; } +.pill.ARB { color: var(--good); background: var(--good-d); + box-shadow: 0 0 14px -3px rgba(47,227,155,.5); } +.pill.NOARB { color: var(--warn); background: var(--warn-d); } +.pill.BADBASIS { color: var(--bad); background: var(--bad-d); } +.pill.LOWSIZE { color: var(--info); background: rgba(76,201,240,.12); } +.pill.NODATA, .pill.NOPAIR { color: var(--faint); background: rgba(255,255,255,.05); } + +/* ---------- detail drawer ---------- */ +tr.detail td { padding: 0; border-bottom: 1px solid var(--line); } +.drawer { display: grid; grid-template-columns: 1fr 1fr 1.1fr; gap: 18px; + padding: 20px 22px; background: linear-gradient(180deg, + rgba(108,140,255,.06), transparent); animation: slide .22s ease; } +@keyframes slide { from { opacity: 0; transform: translateY(-6px); } } +.vcard { border: 1px solid var(--line); border-radius: 12px; padding: 16px; + background: var(--panel); } +.vcard h4 { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; + color: var(--dim); margin-bottom: 10px; display: flex; align-items: center; + gap: 8px; } +.vcard h4 .tag { font-size: 10px; padding: 2px 7px; border-radius: 5px; + color: #fff; font-weight: 700; } +.tag.k { background: #5b6cff; } .tag.p { background: #00c2a8; } +.vcard .title { font-weight: 600; font-size: 13px; line-height: 1.4; + margin-bottom: 10px; } +.vcard img.hero-img { width: 100%; max-height: 120px; object-fit: cover; + border-radius: 9px; margin-bottom: 10px; } +.vcard .desc { color: var(--dim); font-size: 12px; line-height: 1.55; + max-height: 130px; overflow: auto; } +.legs { display: flex; gap: 10px; margin-bottom: 12px; } +.leg { flex: 1; background: rgba(255,255,255,.04); border-radius: 9px; + padding: 9px 11px; } +.leg .k { color: var(--faint); font-size: 10px; text-transform: uppercase; + letter-spacing: .05em; } +.leg .v { font-weight: 700; font-size: 15px; margin-top: 2px; } +.kv { display: grid; grid-template-columns: 1fr auto; gap: 7px 14px; + font-size: 12px; } +.kv .k { color: var(--dim); } +.kv .v { font-weight: 600; text-align: right; } +.scen { display: flex; gap: 8px; margin: 12px 0 4px; } +.scen .b { flex: 1; text-align: center; border-radius: 9px; padding: 10px 6px; + background: rgba(255,255,255,.04); border: 1px solid var(--line); } +.scen .b .t { font-size: 10px; color: var(--faint); text-transform: uppercase; + letter-spacing: .05em; } +.scen .b .x { font-weight: 800; font-size: 15px; margin-top: 3px; } + +.empty { text-align: center; padding: 70px 20px; color: var(--faint); } +.empty-art { font-size: 40px; opacity: .4; margin-bottom: 10px; } + +/* ---------- modal / toast ---------- */ +.modal { position: fixed; inset: 0; z-index: 40; display: grid; + place-items: center; background: rgba(4,6,12,.7); backdrop-filter: blur(4px); } +.modal .card { width: 360px; background: var(--panel-solid); + border: 1px solid var(--line2); border-radius: 16px; padding: 24px; + box-shadow: var(--shadow); } +.modal h2 { font-size: 17px; } +.modal .hint { color: var(--dim); font-size: 12px; margin: 4px 0 16px; } +.modal label { display: flex; justify-content: space-between; align-items: center; + gap: 12px; margin: 10px 0; color: var(--dim); font-size: 13px; } +.modal input { width: 120px; background: var(--bg2); border: 1px solid var(--line2); + color: var(--txt); border-radius: 8px; padding: 7px 10px; font: inherit; } +.modal input:focus { outline: none; border-color: var(--accent); } +.modal .row { display: flex; gap: 10px; margin-top: 20px; } +.modal .row button { flex: 1; } +.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); + z-index: 50; background: var(--panel-solid); border: 1px solid var(--line2); + color: var(--txt); padding: 12px 20px; border-radius: 12px; + box-shadow: var(--shadow); font-size: 13px; font-weight: 500; + animation: slide .2s ease; } +.toast.err { border-color: var(--bad); color: var(--bad); } +.toast.ok { border-color: var(--good); } + +/* ---------- positions ---------- */ +.positions { padding: 22px 26px 60px; } +.stat.stat-lg { border-width: 1px 1px 1px 4px; + background: linear-gradient(135deg, rgba(108,140,255,.10), var(--panel)); } +.stat.stat-lg .n { font-size: 32px; } +.stat.stat-lg .l { color: var(--txt); } +.stat .stat-sub { font-size: 14px; font-weight: 700; margin-top: 2px; } +.postoolbar { display: flex; align-items: center; gap: 14px; margin: 16px 0; } +.posseg { display: flex; } +.pos-note { font-size: 12px; } +.vstack { display: flex; flex-direction: column; gap: 16px; } +.vcard .vc-head { display: flex; justify-content: space-between; + align-items: center; margin-bottom: 12px; } +.vc-tot { font-size: 18px; font-weight: 800; letter-spacing: -.02em; } +.vcard.setup { border-style: dashed; } +.vcard.setup p { color: var(--dim); font-size: 13px; margin: 8px 0; } +.vcard.setup pre { background: var(--bg2); border: 1px solid var(--line); + border-radius: 8px; padding: 12px; font: 12px/1.5 "JetBrains Mono", monospace; + color: var(--txt); overflow-x: auto; } +.vcard code { background: var(--bg2); padding: 1px 6px; border-radius: 5px; + font: 12px "JetBrains Mono", monospace; color: var(--accent); } +.ptbl { width: 100%; border-collapse: separate; border-spacing: 0; + font-variant-numeric: tabular-nums; } +.ptbl th { text-align: right; color: var(--faint); font-size: 10.5px; + font-weight: 700; text-transform: uppercase; letter-spacing: .05em; + padding: 8px 10px; border-bottom: 1px solid var(--line2); } +.ptbl th.l { text-align: left; } +.ptbl td { text-align: right; padding: 10px; border-bottom: 1px solid var(--line); + white-space: nowrap; } +.ptbl td.l { text-align: left; } +.ptbl tr:hover td { background: rgba(255,255,255,.03); } +.ptbl .mkt .q { max-width: 340px; } +.ptbl .pp { color: var(--faint); font-size: 11px; font-weight: 600; } +.sidetag { font-size: 10px; font-weight: 800; padding: 2px 8px; + border-radius: 6px; text-transform: uppercase; letter-spacing: .03em; } +.sidetag.yes, .sidetag.up { color: var(--good); background: var(--good-d); } +.sidetag.no, .sidetag.down { color: var(--bad); background: var(--bad-d); } +.okpill { font-size: 10px; font-weight: 700; color: var(--good); + background: var(--good-d); padding: 2px 8px; border-radius: 999px; + margin-left: 6px; } +.onepill { font-size: 10px; font-weight: 700; color: var(--warn); + background: var(--warn-d); padding: 2px 8px; border-radius: 999px; + margin-left: 6px; } +.leg2 { display: flex; align-items: center; gap: 8px; padding: 3px 0; + font-size: 12px; } +.leg2 .tag { width: 16px; height: 16px; display: grid; place-items: center; + font-size: 9px; padding: 0; } + +@media (max-width: 1100px) { .drawer { grid-template-columns: 1fr; } }