Files
jaxperro 34d02956d8 Archive dead-end strategies; add FINDINGS.md write-up
Moved the 8 tested-and-failed strategy tools into archive/ (copytrade, backtest,
edge_research, lookback, table_77, lp_screener, lp_paper, xarb) with an
archive/README explaining each. Root now holds the keepers: insider.py (made
self-sufficient — dropped the copytrade load_json dependency) and smart_money.py
(data foundation). New FINDINGS.md is the honest scorecard: six systematic
public-data edges all efficient/illusory, the win-rate survivorship-bias
finding, and the one real signal (z-score improbability + funding clustering).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:09:56 -04:00

218 lines
7.9 KiB
Python

#!/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()