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