Initial commit: prediction-market arb scanner + GHA alerts

- Monthly + hourly Kalshi/Polymarket arb scanner (stdlib-only Python).
- Live positions tab w/ realized P&L history.
- GitHub Actions cron workflow texts SMS via Apps Script webhook on
  newly-detected arbs. State persisted in alerts_state.json.

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Casey Judice
2026-05-20 19:48:50 -04:00
commit 05c0441053
20 changed files with 3967 additions and 0 deletions
+48
View File
@@ -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
+15
View File
@@ -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
View File
+120
View File
@@ -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)
+210
View File
@@ -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
+205
View File
@@ -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,
}
+118
View File
@@ -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()}
+65
View File
@@ -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
+161
View File
@@ -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
+506
View File
@@ -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": []}
+62
View File
@@ -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()}
+148
View File
@@ -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
+946
View File
@@ -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
}
]
+14
View File
@@ -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": ""
}
+7
View File
@@ -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
}
+113
View File
@@ -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()
+116
View File
@@ -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()
+633
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const rid = (r) => (r.asset || "") + "|" + (r.kalshi_ticker || "") +
"|" + (r.poly_slug || "");
function fmt(v, kind) {
if (v === null || v === undefined || v === "")
return '<span class="dimv">—</span>';
const sign = (n, body, big) => {
const c = n > 0 ? "pos" : n < 0 ? "neg" : "dimv";
return `<span class="num ${c}${big ? " big" : ""}">${body}</span>`;
};
switch (kind) {
case "int": return String(Math.round(v));
case "sz": return (+v).toLocaleString(undefined, { maximumFractionDigits: 0 });
case "px": return `<span class="mono">${(+v).toFixed(3)}</span>`;
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 `<span class="mono">${(+v).toLocaleString(undefined, { maximumFractionDigits: v < 10 ? 4 : 0 })}</span>`;
case "div": { const p = (v * 100), hot = Math.abs(p) >= 0.3;
return `<span class="mono ${hot ? "neg" : "dimv"}">${p >= 0 ? "+" : ""}${p.toFixed(3)}%</span>`; }
case "status":{ const s = String(v).replace(/\s+/g, "");
return `<span class="pill ${s}">${v}</span>`; }
default: return esc(v);
}
}
function thumb(r, sz) {
const s = sz || 34;
if (r.image)
return `<img class="thumb" style="width:${s}px;height:${s}px"
src="${esc(r.image)}" loading="lazy"
onerror="this.replaceWith(Object.assign(document.createElement('div'),
{className:'badge',style:'width:${s}px;height:${s}px;background:${ac(r.asset)}',
textContent:'${esc(r.asset || "?").slice(0,4)}'}))">`;
return `<div class="badge" style="width:${s}px;height:${s}px;background:${ac(r.asset)}">${esc((r.asset || "?").slice(0, 4))}</div>`;
}
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 `<div class="mkt">${thumb(r)}
<div class="info">
<div class="qa">
<span class="achip" style="background:${ac(r.asset)}22;color:${ac(r.asset)}">${esc(r.asset || "?")}</span>
<span class="q" title="${esc(title)}">${esc(title)}</span>
</div>
<div class="sub"><span class="dir">${esc(r.direction || "")}</span> &nbsp;${esc(strikes)}</div>
</div></div>`;
}
function cellPx(r) {
const k = r.kalshi_price, p = r.poly_price, c = r.combined_cost;
if (k == null || p == null) return '<span class="dimv">—</span>';
const pct = Math.min(100, (c / 1) * 100);
const col = c < 1 ? "var(--good)" : "var(--bad)";
return `<div class="pricebar">
<div class="lbl"><span>K ${k.toFixed(3)}</span><span>P ${p.toFixed(3)}</span></div>
<div class="track"><div class="fill" style="width:${pct}%;background:${col}"></div></div>
<div class="lbl"><span class="dimv">cost vs $1</span><span class="${c < 1 ? "pos" : "neg"}">${c.toFixed(3)}</span></div>
</div>`;
}
function cellWindow(r) {
if (!r.window_start) return '<span class="dimv">—</span>';
const m = r.minutes_to_resolve;
const mc = m != null && m <= 10 ? "neg" : m != null && m <= 25 ? "pos" : "dimv";
return `<div class="info">
<div class="q mono">${esc(r.window_start)}${esc(r.window_close)}</div>
<div class="sub ${mc}">${m != null ? "resolves in " + m + "m" : ""}</div>
</div>`;
}
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 `<th class="${cls}" data-sk="${sk}">${c.t}</th>`;
}).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) => `<div class="k">${k}</div><div class="v">${v}</div>`;
const money = (n, d = 4) => n == null ? "—"
: `<span class="${n > 0 ? "pos" : n < 0 ? "neg" : ""}">${n > 0 ? "+" : ""}${(+n).toFixed(d)}</span>`;
const kCard = `<div class="vcard">
<h4><span class="tag k">Kalshi</span> ${esc(r.kalshi_ticker)}</h4>
<div class="title">${esc(r.kalshi_title || "—")}</div>
<div class="legs">
<div class="leg"><div class="k">Yes ask</div><div class="v">${r.kalshi_price != null && r.best_side === "YES+NO" ? r.kalshi_price.toFixed(3) : "—"}</div></div>
<div class="leg"><div class="k">No ask</div><div class="v">${r.kalshi_price != null && r.best_side === "NO+YES" ? r.kalshi_price.toFixed(3) : "—"}</div></div>
<div class="leg"><div class="k">Open int</div><div class="v">${r.kalshi_size != null ? Math.round(r.kalshi_size).toLocaleString() : "—"}</div></div>
</div>
<div class="desc">${esc(r.kalshi_rules || "Resolution rules unavailable.")}</div>
</div>`;
const pImg = r.image ? `<img class="hero-img" src="${esc(r.image)}" onerror="this.remove()">` : "";
const pSideRaw = (r.best_side || "—").split("+")[1] || "—";
const pSide = MODE === "hourly"
? (pSideRaw === "NO" ? "Down" : pSideRaw === "YES" ? "Up" : pSideRaw)
: pSideRaw;
const pCard = `<div class="vcard">
<h4><span class="tag p">Polymarket</span> ${esc(r.poly_slug || "no paired market")}</h4>
${pImg}
<div class="title">${esc(r.poly_question || "—")}</div>
<div class="legs">
<div class="leg"><div class="k">Side held</div><div class="v">${esc(pSide)}</div></div>
<div class="leg"><div class="k">Poly px</div><div class="v">${r.poly_price != null ? r.poly_price.toFixed(3) : "—"}</div></div>
<div class="leg"><div class="k">Volume</div><div class="v">$${(r.poly_volume || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}</div></div>
</div>
<div class="desc">${esc(r.poly_description || "Market description unavailable.")}</div>
</div>`;
const hourly = MODE === "hourly";
const scen = `<div class="scen">
<div class="b"><div class="t">${hourly ? "Worst case" : "Min / guaranteed"}</div><div class="x">${money(r.worst_pnl)}</div></div>
<div class="b"><div class="t">Strike gap</div><div class="x">${r.mid_pnl == null ? '<span class="dimv">none</span>' : money(r.mid_pnl)}</div></div>
<div class="b"><div class="t">Best case</div><div class="x">${money(r.best_pnl)}</div></div>
</div>`;
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 = `<div class="vcard">
<h4>${hourly ? "Speculative breakdown" : "Arb breakdown"}</h4>
${scen}
${hourly ? '<div class="warn-line">Different settlement feeds — legs are not a locked hedge.</div>' : ""}
<div class="kv">
${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 ? ' <span class="pos">✓ favorable</span>' : ' <span class="neg">✗ risk</span>'))}
${kv("Max contracts", r.max_contracts != null ? Math.round(r.max_contracts).toLocaleString() : "—")}
${hRows}
</div></div>`;
return `<tr class="detail"><td colspan="${COLS().length}">
<div class="drawer">${kCard}${pCard}${bd}</div></td></tr>`;
}
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(`<tr class="r${isOpen ? " open" : ""}" data-id="${esc(id)}">` +
COLS().map((c) => {
const cls = c.align === "l" ? "l" : "";
if (c.k === "_mkt") return `<td class="${cls}">${cellMarket(r)}</td>`;
if (c.k === "_px") return `<td class="${cls}">${cellPx(r)}</td>`;
if (c.k === "_window") return `<td class="${cls}">${cellWindow(r)}</td>`;
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 `<td class="${cls}"><span class="mono">${esc(bs)}</span></td>`;
}
return `<td class="${cls}">${fmt(r[c.k], c.f)}</td>`;
}).join("") + "</tr>");
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]) =>
`<div class="stat ${cls}"><div class="n">${s[k] ?? 0}</div>
<div class="l">${l}</div></div>`).join("") +
`<div class="stat"><div class="n">${s.fetch_ms ?? "—"}<span style="font-size:13px;color:var(--faint)">ms</span></div>
<div class="l">Fetch time</div></div>`;
}
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) => `
<div class="hcard${hourly ? " spec" : ""}" data-id="${esc(rid(r))}">
<div class="glow"></div>
<div class="top">${thumb(r, 38)}
<div class="q">${esc(r.kalshi_title || r.poly_question || r.asset)}</div></div>
<div class="ret">+${(r.net_return * 100).toFixed(2)}%</div>
<div class="meta">
${hourly
? `<span><b>${r.minutes_to_resolve ?? "—"}m</b> left</span>
<span><b>${r.divergence != null ? (r.divergence * 100).toFixed(3) + "%" : "—"}</b> feed Δ</span>`
: `<span><b>${r.annualized ? (r.annualized * 100).toFixed(0) + "%" : "—"}</b> annual</span>
<span><b>$${(r.total_gain || 0).toFixed(2)}</b> locked</span>`}
<span><b>${Math.round(r.max_contracts || 0).toLocaleString()}</b> ct</span>
<span class="mono">${esc(r.best_side)}</span>
</div>
</div>`).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 '<span class="dimv">—</span>';
const c = n > 0 ? "pos" : n < 0 ? "neg" : "dimv";
const p = pct != null ? ` <span class="pp">(${(pct * 100).toFixed(1)}%)</span>` : "";
return `<span class="num ${c}">${n > 0 ? "+" : ""}${usd(n)}${p}</span>`;
}
function posThumb(p) {
if (p.icon) return `<img class="thumb" style="width:30px;height:30px"
src="${esc(p.icon)}" loading="lazy" onerror="this.remove()">`;
const a = p.asset ? p.asset.slice(0, 4) : "?";
return `<div class="badge" style="width:30px;height:30px;background:${ac(p.asset)}">${esc(a)}</div>`;
}
function venuePanel(name, tag, v) {
const setup = (body) => `<div class="vcard setup">
<h4><span class="tag ${tag}">${name}</span> not connected</h4>${body}</div>`;
if (!v || v.configured === false) {
if (name === "Polymarket")
return setup(`<p>Add your wallet to <code>data/secrets.json</code>:</p>
<pre>{ "polymarket_wallet": "0xYourProxyWallet" }</pre>
<p class="dimv">Public address only — no private key. Copy
<code>data/secrets.example.json</code> to start.</p>`);
return setup(`<p>Create a read-only API key in Kalshi → Settings → API Keys,
save the private-key file locally, then add to
<code>data/secrets.json</code>:</p>
<pre>{ "kalshi_key_id": "…",
"kalshi_private_key_path": "/abs/path/key.pem" }</pre>
<p class="dimv">Signed locally via openssl — the key never leaves your
machine. ${v && v.reason === "key_file_not_found"
? '<span class="neg">Key file not found at that path.</span>' : ""}</p>`);
}
if (v.error)
return `<div class="vcard"><h4><span class="tag ${tag}">${name}</span></h4>
<p class="neg">${esc(v.error)}</p></div>`;
const ps = v.positions || [];
const head = `<div class="vc-head">
<h4><span class="tag ${tag}">${name}</span> ${ps.length} open</h4>
<span class="vc-tot">${usd(v.total_value)}</span></div>`;
if (!ps.length)
return `<div class="vcard">${head}<p class="dimv">No open positions.</p></div>`;
const rows = ps.sort((a, b) => (b.value || 0) - (a.value || 0)).map((p) => `
<tr>
<td class="l"><div class="mkt">${posThumb(p)}<div class="info">
<div class="q" title="${esc(p.market)}">${esc(p.market)}</div>
<div class="sub mono">${esc(p.ref || "")}</div></div></div></td>
<td><span class="sidetag ${String(p.side).toLowerCase()}">${esc(p.side)}</span></td>
<td class="num">${(p.size || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}</td>
<td class="num mono">${p.avg_price != null ? (+p.avg_price).toFixed(3) : "—"}${p.cur_price != null ? (+p.cur_price).toFixed(3) : "—"}</td>
<td class="num">${usd(p.cost)}</td>
<td class="num">${usd(p.value)}</td>
<td class="num">${pnl(p.pnl, p.pnl_pct)}</td>
</tr>`).join("");
return `<div class="vcard">${head}
<table class="ptbl"><thead><tr>
<th class="l">Market</th><th>Side</th><th>Size</th>
<th>Avg → Cur</th><th>Cost</th><th>Value</th><th>P&amp;L</th>
</tr></thead><tbody>${rows}</tbody></table></div>`;
}
function pairedView(rows) {
if (!rows || !rows.length)
return `<div class="vcard"><h4>Paired exposure</h4>
<p class="dimv">No held positions match a known Kalshi↔Polymarket arb
pair (pairs come from the Monthly map). Positions still show under
<b>By venue</b>.</p></div>`;
const body = rows.map((r) => {
const legs = r.legs.map((l) => `<div class="leg2">
<span class="tag ${l.venue === "Kalshi" ? "k" : "p"}">${l.venue[0]}</span>
<span class="sidetag ${String(l.side).toLowerCase()}">${esc(l.side)}</span>
<span class="dimv mono">${esc(l.ref || "")}</span>
<span class="num">${(l.size || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
<span>${pnl(l.pnl)}</span></div>`).join("");
return `<tr>
<td class="l"><span class="achip" style="background:${ac(r.asset)}22;color:${ac(r.asset)}">${esc(r.asset || "?")}</span>
${r.complete ? '<span class="okpill">paired</span>' : '<span class="onepill">one leg</span>'}</td>
<td class="l">${legs}</td>
<td class="num">${usd(r.cost)}</td>
<td class="num">${usd(r.value)}</td>
<td class="num">${pnl(r.pnl, r.pnl_pct)}</td>
</tr>`;
}).join("");
return `<div class="vcard"><h4>Paired / netted exposure</h4>
<table class="ptbl"><thead><tr>
<th class="l">Pair</th><th class="l">Legs</th>
<th>Cost</th><th>Value</th><th>Net P&amp;L</th>
</tr></thead><tbody>${body}</tbody></table></div>`;
}
function historyCard(name, tag, rows, note) {
const head = `<div class="vc-head">
<h4><span class="tag ${tag}">${name}</span> ${rows.length} settled${note ? ` <span class="dimv">· ${note}</span>` : ""}</h4>
<span class="vc-tot">${pnl(rows.reduce((s, r) => s + (r.realized || 0), 0))}</span></div>`;
if (!rows.length)
return `<div class="vcard">${head}<p class="dimv">No settled markets found.</p></div>`;
const body = rows.slice().sort((a, b) =>
(b.settled_date || "").localeCompare(a.settled_date || "")).map((r) => `
<tr>
<td class="l"><div class="mkt">
${r.icon ? `<img class="thumb" style="width:26px;height:26px" src="${esc(r.icon)}" loading="lazy" onerror="this.remove()">` : ""}
<div class="info"><div class="q" title="${esc(r.market)}">${esc(r.market)}</div>
<div class="sub mono">${esc(r.settled_date || "")}</div></div></div></td>
<td><span class="sidetag ${String(r.result).toLowerCase()}">${esc(r.result)}</span></td>
<td><span class="sidetag ${String(r.side).toLowerCase()}">${esc(r.side || "—")}</span></td>
<td class="num">${(r.size || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}</td>
<td class="num">${usd(r.cost)}</td>
<td class="num">${usd(r.payout)}</td>
<td class="num">${pnl(r.realized)}</td>
</tr>`).join("");
return `<div class="vcard">${head}
<table class="ptbl"><thead><tr>
<th class="l">Market</th><th>Result</th><th>Side</th><th>Size</th>
<th>Cost</th><th>Payout</th><th>Realized</th>
</tr></thead><tbody>${body}</tbody></table></div>`;
}
function historyView(h) {
h = h || {};
return `<div class="vstack">
${historyCard("Kalshi", "k", h.kalshi || [], "exact (settlements)")}
${historyCard("Polymarket", "p", h.polymarket || [], "reconstructed · payout derived")}
</div>`;
}
function renderPositions(d) {
if (d) POS_DATA = d;
d = POS_DATA;
const t = d.totals || {};
const stat = (n, l, cls) =>
`<div class="stat ${cls || ""}"><div class="n">${n}</div><div class="l">${l}</div></div>`;
const pstat = (n, l, big) => {
if (n == null)
return `<div class="stat"><div class="n">—</div><div class="l">${l}</div></div>`;
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 `<div class="stat ${cls}${big ? " stat-lg" : ""}">
<div class="n">${v}</div><div class="l">${l}</div></div>`;
};
const strip = `<div class="summary">
${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 `<div class="stat stat-lg"><div class="n">—</div><div class="l">Total P&L</div></div>`;
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 ? "" :
`<div class="stat-sub ${r > 0 ? "pos" : r < 0 ? "neg" : ""}">${r > 0 ? "+" : ""}${(r * 100).toFixed(2)}% return</div>`;
return `<div class="stat stat-lg ${cls}"><div class="n">${v}</div>${sub}<div class="l">Total P&L</div></div>`;
})()}
${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")}</div>`;
const tab = (v, l) =>
`<button data-v="${v}" class="${POS_VIEW === v ? "on" : ""}">${l}</button>`;
const seg = `<div class="seg posseg">
${tab("venue", "By venue")}${tab("paired", "Paired")}${tab("history", "History")}
</div>`;
const content = POS_VIEW === "paired" ? pairedView(d.paired)
: POS_VIEW === "history" ? historyView(d.history)
: `<div class="vstack">
${venuePanel("Polymarket", "p", d.polymarket)}
${venuePanel("Kalshi", "k", d.kalshi)}</div>`;
$("positions").innerHTML = strip +
`<div class="postoolbar">${seg}
<span class="dimv pos-note">Read-only · credentials stay in local
data/secrets.json</span></div>` + 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) =>
`<span class="ac" data-a="${a}"><span class="blob" style="background:${ac(a)}"></span>${a}</span>`).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);
+102
View File
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Arb Scanner · Kalshi × Polymarket</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="aurora"></div>
<header>
<div class="brand">
<div class="logo"></div>
<div>
<h1>Arb&nbsp;Scanner</h1>
<span class="sub">Kalshi&nbsp;×&nbsp;Polymarket · crypto</span>
</div>
<div class="tabs" id="tabs">
<button data-m="monthly" class="on">Monthly&nbsp;arb</button>
<button data-m="hourly">Hourly&nbsp;<span class="spec">spec</span></button>
<button data-m="positions">Positions</button>
</div>
</div>
<div class="actions">
<div class="live"><span class="dot"></span><span id="meta">connecting…</span></div>
<label class="auto"><input type="checkbox" id="auto"><span>Auto&nbsp;8s</span></label>
<button id="settingsBtn" class="ghost">Settings</button>
<button id="refresh" class="primary"><span class="bspin"></span><span class="blabel">Refresh</span></button>
</div>
</header>
<div id="hbanner" class="hbanner" hidden>
<div class="hb-ico"></div>
<div class="hb-txt">
<b>Speculative — not a locked arb.</b> Kalshi settles on CF&nbsp;Benchmarks,
Polymarket on Binance. Different feeds &amp; the discrete-strike gap mean
legs can both lose. Live Binance↔CF divergence:
<span id="hbDiv" class="hb-div"></span>
</div>
</div>
<section id="summary" class="summary"></section>
<section id="hero" class="hero" hidden>
<div class="hero-head"><h2>Top opportunities</h2><span class="muted">positive guaranteed return, ranked</span></div>
<div id="heroCards" class="hero-cards"></div>
</section>
<section class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" width="15" height="15"><path d="M21 21l-4.3-4.3M11 18a7 7 0 1 1 0-14 7 7 0 0 1 0 14Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<input type="search" id="search" placeholder="Search asset, ticker, market…">
</div>
<div id="assetChips" class="chips"></div>
<div class="seg" id="statusSeg">
<button data-s="" class="on">All</button>
<button data-s="ARB">Arb</button>
<button data-s="NO ARB">No&nbsp;arb</button>
<button data-s="BAD BASIS">Bad&nbsp;basis</button>
<button data-s="LOW SIZE">Low&nbsp;size</button>
</div>
<label class="chk"><input type="checkbox" id="favOnly"><span>Favorable&nbsp;basis</span></label>
<span class="count" id="rowCount"></span>
</section>
<main>
<table id="grid">
<thead><tr id="head"></tr></thead>
<tbody id="body"></tbody>
</table>
<div id="empty" class="empty" hidden>
<div class="empty-art"></div>
<p>No markets match the current filters.</p>
</div>
</main>
<section id="positions" class="positions" hidden></section>
<div id="settingsModal" class="modal" hidden>
<div class="card">
<h2>Scanner settings</h2>
<p class="hint">Mirrors the workbook <em>Settings</em> sheet · persisted server-side.</p>
<label>Kalshi fee rate <input type="number" step="0.001" id="s_kfee"></label>
<label>Polymarket fee rate <input type="number" step="0.001" id="s_pfee"></label>
<label>Min net return <input type="number" step="0.0001" id="s_minret"></label>
<label>Min Poly volume ($) <input type="number" step="100" id="s_minvol"></label>
<label>Min contracts <input type="number" step="1" id="s_minct"></label>
<div class="row">
<button id="saveSettings" class="primary">Save &amp; rescan</button>
<button id="closeSettings" class="ghost">Cancel</button>
</div>
</div>
</div>
<div id="toast" class="toast" hidden></div>
<script src="app.js"></script>
</body>
</html>
+378
View File
@@ -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; } }