diff --git a/.gitignore b/.gitignore index d313373c..65e199e2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ copyable_77.csv lp_markets.csv follow_10.json lp_paper_state.json + +# cross-venue scanner output +xarb_hits.csv diff --git a/README.md b/README.md index 2a729783..439f9caa 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ live), and backtest the strategy. Zero dependencies — Python 3 stdlib only | `backtest.py` | Replay a watchlist over a recent window and mark outcomes. | | `lp_screener.py` | Rank reward-eligible markets by risk-adjusted LP yield (pool ÷ competition, penalized by volatility). | | `lp_paper.py` | Paper liquidity-provision loop — simulate quoting on the live book, track **net = rewards − adverse selection**. | +| `xarb.py` | Cross-venue scanner — match the same event on Polymarket vs Kalshi and flag price gaps. | ## Run the dashboard @@ -249,3 +250,32 @@ pessimistic on fill rate); rewards accrue by score-share of each pool. Net P&L, per-market breakdown, and Discord summaries let it run for days to see whether the edge survives mean-reversion. **Only if net stays clearly positive does a real, funded, hosted bot make sense.** + +## Cross-venue arbitrage: Polymarket ↔ Kalshi (`xarb.py`) + +The last relative-value lane: buy YES on one venue + NO on the other for < $1 +(net of fees) = locked profit. Kalshi's public API (`api.elections.kalshi.com`) +exposes ~65k markets; `xarb.py` pulls both venues, matches the same event +(token overlap + same resolution month + **exact numeric match** on +thresholds/scores/dates so we compare the same *contract*, not just the same +event), and computes both arb directions with Kalshi's `0.07·P·(1−P)` taker fee. + +**Verdict: efficient — no retail arb.** On liquid, cleanly-matched, identical +contracts the two venues agree to **~1¢**, and locking both sides costs **>$1 +after fees.** Worked example (live): *Brazil vs Morocco — Both Teams To Score* +priced PM 0.46/0.47 vs Kalshi 0.47/0.48; every arb direction nets **negative**. +The large "edges" the scanner surfaces are artifacts: false matches (same event, +different sub-question), illiquid wide-spread markets (exact-score, props), or +stale snapshot timing. Matches the documented reality that real gaps last +~seconds and are taken by bots watching 10k+ markets. + +### The bottom line across the whole project + +Six systematic, public-data edges tested — copy-trading, win-rate ranking, LP +reward farming, binary arb, multi-outcome logical arb, and cross-venue arb — +**all efficient or illusory.** Polymarket in 2026 does not hand a retail bot a +turnkey edge. Durable edge requires *speed/infra* (competing with pro arb bots), +*genuine private information* (a niche you know better than the market), or +*getting paid to provide a service* (liquidity, at modest adverse-selection- +dominated yields). The most valuable output here is knowing that before funding +any of it. diff --git a/xarb.py b/xarb.py new file mode 100644 index 00000000..d95a630c --- /dev/null +++ b/xarb.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Polymarket <-> Kalshi cross-venue arbitrage scanner. + +Pulls live prices from both venues, matches the same event across them, and +flags executable spreads: buy YES on one + NO on the other for < $1 (net of +fees) = locked profit regardless of resolution. + + python3 xarb.py # one-shot scan -> xarb_hits.csv + python3 xarb.py --min-vol 5000 # only liquid markets + +Matching is conservative (token overlap + same resolution month) to limit +false matches — a wrong match isn't an arb, it's two different bets +(resolution risk). Treat flagged hits as candidates to eyeball, not gospel. +""" + +import argparse +import csv +import json +import re +import ssl +import urllib.request +from collections import defaultdict + +ctx = ssl._create_unverified_context() +K = "https://api.elections.kalshi.com/trade-api/v2" +GAMMA = "https://gamma-api.polymarket.com" + +STOP = {"will", "the", "a", "an", "to", "of", "in", "by", "be", "win", "wins", + "winner", "2026", "2025", "at", "on", "for", "and", "vs", "game", "match", + "who", "what", "during", "this", "next", "before", "after", "his", "her"} + + +def get(url): + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=30, context=ctx) as r: + return json.loads(r.read().decode()) + + +def get_safe(url): + try: + return get(url) + except Exception: + return None + + +def fnum(v, d=0.0): + try: + return float(v) + except (TypeError, ValueError): + return d + + +def norm(s): + s = re.sub(r"[^a-z0-9 ]", " ", (s or "").lower()) + return {t for t in s.split() if t not in STOP and len(t) > 1} + + +def nums(s): + """Numeric tokens (thresholds, scores, dates) that must match exactly for + two markets to be the *same* contract, not just the same event.""" + return set(re.findall(r"\d+", (s or "").lower())) + + +def kalshi_fee(price): + """Kalshi taker fee per $1 contract ≈ 0.07 * P * (1-P).""" + return 0.07 * price * (1 - price) + + +# ── data pulls ────────────────────────────────────────────────────────────── + +def pull_kalshi(min_vol): + evs, cur = [], "" + for _ in range(60): + d = get(K + "/events?limit=200&status=open&with_nested_markets=true" + + (f"&cursor={cur}" if cur else "")) + evs += d.get("events", []) + cur = d.get("cursor") + if not cur: + break + out = [] + for e in evs: + for m in e.get("markets", []): + ya, na = fnum(m.get("yes_ask_dollars")), fnum(m.get("no_ask_dollars")) + if not (0 < ya < 1 and 0 < na < 1): + continue + vol = fnum(m.get("volume_24h_fp")) + if vol < min_vol: + continue + text = f"{e.get('title','')} {m.get('yes_sub_title','')}" + out.append({ + "venue": "kalshi", "ticker": m["ticker"], "text": text, + "tokens": norm(text), "end": (m.get("close_time") or "")[:7], + "yes_ask": ya, "no_ask": na, + "yes_bid": fnum(m.get("yes_bid_dollars")), + "no_bid": fnum(m.get("no_bid_dollars")), "vol": vol, + }) + return out + + +def pull_polymarket(min_vol): + out, offset = [], 0 + for _ in range(200): + g = get_safe(f"{GAMMA}/markets?limit=100&offset={offset}&active=true" + f"&closed=false&order=volumeNum&ascending=false") + if not g: + break + for m in g: + try: + outcomes = json.loads(m.get("outcomes", "[]")) + except Exception: + outcomes = [] + if [o.lower() for o in outcomes] != ["yes", "no"]: + continue + ask = fnum(m.get("bestAsk")) + bid = fnum(m.get("bestBid")) + if not (0 < ask < 1 and 0 < bid < 1): + continue + vol = fnum(m.get("volumeNum")) + if vol < min_vol: + continue + q = m.get("question", "") + out.append({ + "venue": "poly", "text": q, "tokens": norm(q), + "end": (m.get("endDateIso") or m.get("endDate") or "")[:7], + "yes_ask": ask, "no_ask": round(1 - bid, 4), # NO ask ≈ 1 - YES bid + "vol": vol, + }) + if len(g) < 100: + break + offset += 100 + return out + + +# ── matching + arb ──────────────────────────────────────────────────────── + +def match_and_scan(poly, kalshi, min_sim): + # inverted index: token -> kalshi markets containing it + idx = defaultdict(list) + for k in kalshi: + for t in k["tokens"]: + idx[t].append(k) + hits = [] + for p in poly: + if len(p["tokens"]) < 2: + continue + p_nums = nums(p["text"]) + cand = {id(k): k for t in p["tokens"] for k in idx.get(t, [])} + best, best_sim = None, 0 + for k in cand.values(): + if p["end"] and k["end"] and p["end"] != k["end"]: + continue # different resolution month → skip + if nums(k["text"]) != p_nums: + continue # different thresholds/scores/dates → not the same contract + inter = len(p["tokens"] & k["tokens"]) + sim = inter / len(p["tokens"] | k["tokens"]) + if sim > best_sim: + best, best_sim = k, sim + if not best or best_sim < min_sim: + continue + # two arb directions + # A: poly YES + kalshi NO + a_cost = p["yes_ask"] + best["no_ask"] + a_edge = 1 - a_cost - kalshi_fee(best["no_ask"]) + # B: kalshi YES + poly NO + b_cost = best["yes_ask"] + p["no_ask"] + b_edge = 1 - b_cost - kalshi_fee(best["yes_ask"]) + if a_edge >= b_edge: + edge, leg = a_edge, "poly YES + kalshi NO" + else: + edge, leg = b_edge, "kalshi YES + poly NO" + hits.append({ + "edge_c": round(edge * 100, 2), "sim": round(best_sim, 2), + "leg": leg, "poly": p["text"][:46], "kalshi": best["text"][:46], + "p_yes": p["yes_ask"], "p_no": p["no_ask"], + "k_yes": best["yes_ask"], "k_no": best["no_ask"], + "min_vol": round(min(p["vol"], best["vol"])), + }) + hits.sort(key=lambda h: h["edge_c"], reverse=True) + return hits + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--min-vol", type=float, default=2000) + ap.add_argument("--min-sim", type=float, default=0.5, help="token-overlap threshold") + ap.add_argument("--top", type=int, default=30) + args = ap.parse_args() + + print("pulling Kalshi...", flush=True) + kalshi = pull_kalshi(args.min_vol) + print(f" {len(kalshi)} liquid Kalshi markets", flush=True) + print("pulling Polymarket...", flush=True) + poly = pull_polymarket(args.min_vol) + print(f" {len(poly)} liquid Polymarket markets", flush=True) + + hits = match_and_scan(poly, kalshi, args.min_sim) + with open("xarb_hits.csv", "w", newline="") as f: + w = csv.DictWriter(f, fieldnames=list(hits[0].keys()) if hits else + ["edge_c", "sim", "leg", "poly", "kalshi"]) + w.writeheader() + w.writerows(hits) + + arbs = [h for h in hits if h["edge_c"] > 0] + print(f"\nmatched pairs: {len(hits)} · positive-edge (after fees): {len(arbs)}") + print(f"\n{'edge¢':>6}{'sim':>5}{'minVol':>9} match (poly ↔ kalshi)") + print("-" * 92) + for h in hits[:args.top]: + print(f"{h['edge_c']:>6.1f}{h['sim']:>5.2f}{h['min_vol']:>9} " + f"{h['poly'][:34]:34} ↔ {h['kalshi'][:30]}") + print("-" * 92) + print(f"saved {len(hits)} matched pairs → xarb_hits.csv (edge>0 = arb after fees)") + return arbs, hits + + +if __name__ == "__main__": + main()