Notebook 13: NFL Futures Arbitrage Scan — AFC Champion, NFC Champion, Super Bowl (2027)

A count-only scan, not a backtest. Notebooks 04–12 asked would this strategy have made money? This one asks a narrower, earlier question about three long-dated NFL futures that both platforms list: how many times, per team, has the price gap between Kalshi and Polymarket exceeded $0.05?

Event Kalshi Polymarket
AFC Champion KXNFLAFCCHAMP-27 (16 teams) pro-football-2027-afc-champion
NFC Champion KXNFLNFCCHAMP-27 (16 teams) pro-football-2027-nfc-champion
Super Bowl Champion KXSB-27 (32 teams) pro-football-2027-champion-20260729185915366

These markets are still open. The window runs from each market's open (February 2026) to the moment this notebook was run, so the counts are a snapshot of history so far, not a closed season.

Polymarket lists an extra Other contract in each event. It has no Kalshi counterpart and is inactive, so it is skipped.

Method

Gap — the same definition as notebooks 04–12: Kalshi yes_ask minus Polymarket's price for the same team. A positive gap means Kalshi's Yes is the more expensive side, so the trade is buy Kalshi No and Polymarket Yes. A negative gap means Polymarket's Yes is more expensive, so the trade is buy Kalshi Yes and Polymarket No.

Arbitrage event — a continuous run where |gap| is more than $0.05, held for at least 2 consecutive minutes, matching the entry dwell of notebooks 04–12. One run counts as one event, however long it lasts. A gap that closes and reopens counts twice.

One change from earlier notebooks: a 1-minute grid. For open markets, Kalshi's candlestick endpoint only returns a candle for minutes with book or trade activity. A long-shot team can go hours without one. Earlier notebooks aligned on Kalshi's own rows, which would miss gaps that open while Kalshi's quote sits still and Polymarket moves. So both series are put on a common 1-minute grid. Kalshi's last known ask carries forward until the next candle; Polymarket's prices-history (fidelity=1) already reports every minute.

Not modelled here: fees, fill confirmation, order-book depth, and bankroll. Every count below is a gross price gap. Kalshi minutes with no ask on the book are dropped, since there is nothing to buy.

Setup

In [1]:
import sys
import time
import datetime
from pathlib import Path

import numpy as np
import pandas as pd
import requests
import matplotlib.pyplot as plt
from IPython.display import Markdown, display

REPO_ROOT = Path.cwd().parents[1]
sys.path.insert(0, str(REPO_ROOT))
from arb.src import polymarket_client

pd.set_option("display.width", 140)
pd.set_option("display.max_rows", 100)

RAW_DIR = Path.cwd().parent / "data" / "raw"
RAW_DIR.mkdir(parents=True, exist_ok=True)

KALSHI_BASE = "https://external-api.kalshi.com/trade-api/v2"
GAP_THRESHOLD = 0.05      # an event needs |gap| strictly greater than this
DWELL_MINUTES = 2         # ...held for at least this many consecutive minutes
RUN_TS = pd.Timestamp.now(tz="UTC").floor("min")
print(f"Run time (UTC): {RUN_TS}")
Run time (UTC): 2026-10-13 16:09:00+00:00
In [2]:
EVENTS = {
    "afc": {
        "label": "AFC Champion",
        "kalshi_series": "KXNFLAFCCHAMP",
        "kalshi_event": "KXNFLAFCCHAMP-27",
        "polymarket_slug": "pro-football-2027-afc-champion",
    },
    "nfc": {
        "label": "NFC Champion",
        "kalshi_series": "KXNFLNFCCHAMP",
        "kalshi_event": "KXNFLNFCCHAMP-27",
        "polymarket_slug": "pro-football-2027-nfc-champion",
    },
    "sb": {
        "label": "Super Bowl Champion",
        "kalshi_series": "KXSB",
        "kalshi_event": "KXSB-27",
        "polymarket_slug": "pro-football-2027-champion-20260729185915366",
    },
}

Matching Teams

Kalshi names each contract by city (yes_sub_title, e.g. New York J), and Polymarket by full team name (groupItemTitle, e.g. New York Jets). Every Kalshi title is a unique prefix of exactly one Polymarket name, so teams are matched on that prefix. Any team that fails to match is listed below, not silently dropped.

In [3]:
import json


def kalshi_event_markets(event_ticker):
    resp = requests.get(f"{KALSHI_BASE}/markets", params={"event_ticker": event_ticker, "limit": 200}, timeout=20)
    resp.raise_for_status()
    return resp.json()["markets"]


PAIRS = {}
unmatched = []
for ev_key, ev in EVENTS.items():
    kalshi_markets = kalshi_event_markets(ev["kalshi_event"])
    poly_markets = [m for m in polymarket_client.get_event_by_slug(ev["polymarket_slug"])["markets"]
                    if m["groupItemTitle"] != "Other"]
    used_poly = set()
    for km in kalshi_markets:
        hits = [pm for pm in poly_markets if pm["groupItemTitle"].startswith(km["yes_sub_title"])]
        if len(hits) != 1:
            unmatched.append({"event": ev["label"], "side": "Kalshi", "name": km["yes_sub_title"], "candidates": len(hits)})
            continue
        pm = hits[0]
        used_poly.add(pm["groupItemTitle"])
        PAIRS[f"{ev_key}__{km['ticker']}"] = {
            "event_key": ev_key,
            "event": ev["label"],
            "team": pm["groupItemTitle"],
            "kalshi_series": ev["kalshi_series"],
            "kalshi_ticker": km["ticker"],
            "kalshi_open": pd.to_datetime(km["open_time"], utc=True),
            "polymarket_token": json.loads(pm["clobTokenIds"])[0],
            "polymarket_open": pd.to_datetime(pm["startDate"], utc=True),
            "polymarket_fees_enabled": bool(pm.get("feesEnabled")),
        }
    for pm in poly_markets:
        if pm["groupItemTitle"] not in used_poly:
            unmatched.append({"event": ev["label"], "side": "Polymarket", "name": pm["groupItemTitle"], "candidates": 0})

match_table = pd.DataFrame(PAIRS.values())
display(Markdown(f"#### {len(PAIRS)} Matched Team Pairs"))
display(match_table.groupby("event", sort=False).size().rename("matched teams").to_frame())
display(match_table[["event", "team", "kalshi_ticker", "kalshi_open", "polymarket_open", "polymarket_fees_enabled"]])
display(Markdown("**Unmatched:** none." if not unmatched else "#### Unmatched"))
if unmatched:
    display(pd.DataFrame(unmatched))

64 Matched Team Pairs

matched teams
event
AFC Champion 16
NFC Champion 16
Super Bowl Champion 32
event team kalshi_ticker kalshi_open polymarket_open polymarket_fees_enabled
0 AFC Champion New York Jets KXNFLAFCCHAMP-27-NYJ 2026-02-07 15:00:00+00:00 2026-02-09 22:33:35.100445+00:00 True
1 AFC Champion New England Patriots KXNFLAFCCHAMP-27-NE 2026-02-07 15:00:00+00:00 2026-02-09 22:33:31.017504+00:00 True
2 AFC Champion Jacksonville Jaguars KXNFLAFCCHAMP-27-JAC 2026-02-07 15:00:00+00:00 2026-02-09 22:34:22.005584+00:00 True
3 AFC Champion Houston Texans KXNFLAFCCHAMP-27-HOU 2026-02-07 15:00:00+00:00 2026-02-09 22:34:08.624851+00:00 True
4 AFC Champion Las Vegas Raiders KXNFLAFCCHAMP-27-LV 2026-02-07 15:00:00+00:00 2026-02-09 22:34:46.284399+00:00 True
5 AFC Champion Pittsburgh Steelers KXNFLAFCCHAMP-27-PIT 2026-02-07 15:00:00+00:00 2026-02-09 22:34:05.586049+00:00 True
6 AFC Champion Baltimore Ravens KXNFLAFCCHAMP-27-BAL 2026-02-07 15:00:00+00:00 2026-02-09 22:33:43.184286+00:00 True
7 AFC Champion Buffalo Bills KXNFLAFCCHAMP-27-BUF 2026-02-07 15:00:00+00:00 2026-02-09 22:33:18.856360+00:00 True
8 AFC Champion Kansas City Chiefs KXNFLAFCCHAMP-27-KC 2026-02-07 15:00:00+00:00 2026-02-09 22:34:40.374442+00:00 True
9 AFC Champion Cincinnati Bengals KXNFLAFCCHAMP-27-CIN 2026-02-07 15:00:00+00:00 2026-02-09 22:33:51.244813+00:00 True
10 AFC Champion Los Angeles Chargers KXNFLAFCCHAMP-27-LAC 2026-02-07 15:00:00+00:00 2026-02-09 22:34:54.594329+00:00 True
11 AFC Champion Miami Dolphins KXNFLAFCCHAMP-27-MIA 2026-02-07 15:00:00+00:00 2026-02-09 22:33:24.889400+00:00 True
12 AFC Champion Tennessee Titans KXNFLAFCCHAMP-27-TEN 2026-02-07 15:00:00+00:00 2026-02-09 22:34:29.047459+00:00 True
13 AFC Champion Indianapolis Colts KXNFLAFCCHAMP-27-IND 2026-02-07 15:00:00+00:00 2026-02-09 22:34:15.954545+00:00 True
14 AFC Champion Denver Broncos KXNFLAFCCHAMP-27-DEN 2026-02-07 15:00:00+00:00 2026-02-09 22:34:34.111204+00:00 True
15 AFC Champion Cleveland Browns KXNFLAFCCHAMP-27-CLE 2026-02-07 15:00:00+00:00 2026-02-09 22:33:56.542715+00:00 True
16 NFC Champion Detroit Lions KXNFLNFCCHAMP-27-DET 2026-02-07 15:00:00+00:00 2026-02-09 22:30:50.052049+00:00 True
17 NFC Champion Green Bay Packers KXNFLNFCCHAMP-27-GB 2026-02-07 15:00:00+00:00 2026-02-09 22:30:56.186370+00:00 True
18 NFC Champion Dallas Cowboys KXNFLNFCCHAMP-27-DAL 2026-02-07 15:00:00+00:00 2026-02-09 22:30:17.735055+00:00 True
19 NFC Champion Washington Commanders KXNFLNFCCHAMP-27-WAS 2026-02-07 15:00:00+00:00 2026-02-09 22:30:36.957393+00:00 True
20 NFC Champion Seattle Seahawks KXNFLNFCCHAMP-27-SEA 2026-02-07 15:00:00+00:00 2026-02-09 22:31:53.470726+00:00 True
21 NFC Champion Minnesota Vikings KXNFLNFCCHAMP-27-MIN 2026-02-07 15:00:00+00:00 2026-02-09 22:31:03.412484+00:00 True
22 NFC Champion Los Angeles Rams KXNFLNFCCHAMP-27-LAR 2026-02-07 15:00:00+00:00 2026-02-09 22:31:40.404139+00:00 True
23 NFC Champion San Francisco 49ers KXNFLNFCCHAMP-27-SF 2026-02-07 15:00:00+00:00 2026-02-09 22:31:48.332972+00:00 True
24 NFC Champion Carolina Panthers KXNFLNFCCHAMP-27-CAR 2026-02-07 15:00:00+00:00 2026-02-09 22:31:13.404423+00:00 True
25 NFC Champion Chicago Bears KXNFLNFCCHAMP-27-CHI 2026-02-07 15:00:00+00:00 2026-02-09 22:30:43.821318+00:00 True
26 NFC Champion Arizona Cardinals KXNFLNFCCHAMP-27-ARI 2026-02-07 15:00:00+00:00 2026-02-09 22:31:35.351141+00:00 True
27 NFC Champion Atlanta Falcons KXNFLNFCCHAMP-27-ATL 2026-02-07 15:00:00+00:00 2026-02-09 22:31:09.371746+00:00 True
28 NFC Champion New Orleans Saints KXNFLNFCCHAMP-27-NO 2026-02-07 15:00:00+00:00 2026-02-09 22:31:21.269814+00:00 True
29 NFC Champion New York Giants KXNFLNFCCHAMP-27-NYG 2026-02-07 15:00:00+00:00 2026-02-09 22:30:23.740619+00:00 True
30 NFC Champion Tampa Bay Buccaneers KXNFLNFCCHAMP-27-TB 2026-02-07 15:00:00+00:00 2026-02-09 22:31:29.262614+00:00 True
31 NFC Champion Philadelphia Eagles KXNFLNFCCHAMP-27-PHI 2026-02-07 15:00:00+00:00 2026-02-09 22:30:29.858060+00:00 True
32 Super Bowl Champion New York Jets KXSB-27-NYJ 2026-02-08 20:00:00+00:00 2026-02-09 00:44:14.468000+00:00 True
33 Super Bowl Champion Philadelphia Eagles KXSB-27-PHI 2026-02-08 20:00:00+00:00 2026-02-09 00:44:18.396000+00:00 True
34 Super Bowl Champion New England Patriots KXSB-27-NE 2026-02-08 20:00:00+00:00 2026-02-09 00:43:57.733000+00:00 True
35 Super Bowl Champion Las Vegas Raiders KXSB-27-LV 2026-02-08 20:00:00+00:00 2026-02-09 00:43:26.218000+00:00 True
36 Super Bowl Champion Detroit Lions KXSB-27-DET 2026-02-08 20:00:00+00:00 2026-02-09 00:42:49.965000+00:00 True
37 Super Bowl Champion Green Bay Packers KXSB-27-GB 2026-02-08 20:00:00+00:00 2026-02-09 00:42:55.886000+00:00 True
38 Super Bowl Champion Atlanta Falcons KXSB-27-ATL 2026-02-08 20:00:00+00:00 2026-02-09 00:41:57.584000+00:00 True
39 Super Bowl Champion Minnesota Vikings KXSB-27-MIN 2026-02-08 20:00:00+00:00 2026-02-09 00:43:50.505000+00:00 True
40 Super Bowl Champion Los Angeles Rams KXSB-27-LAR 2026-02-08 20:00:00+00:00 2026-02-09 00:43:39.302000+00:00 True
41 Super Bowl Champion New York Giants KXSB-27-NYG 2026-02-08 20:00:00+00:00 2026-02-09 00:44:08.522000+00:00 True
42 Super Bowl Champion Indianapolis Colts KXSB-27-IND 2026-02-08 20:00:00+00:00 2026-02-09 00:43:09.174000+00:00 True
43 Super Bowl Champion Houston Texans KXSB-27-HOU 2026-02-08 20:00:00+00:00 2026-02-09 00:43:03.073000+00:00 True
44 Super Bowl Champion Kansas City Chiefs KXSB-27-KC 2026-02-08 20:00:00+00:00 2026-02-09 00:43:20.148000+00:00 True
45 Super Bowl Champion Denver Broncos KXSB-27-DEN 2026-02-08 20:00:00+00:00 2026-02-09 00:42:43.859000+00:00 True
46 Super Bowl Champion Cincinnati Bengals KXSB-27-CIN 2026-02-08 20:00:00+00:00 2026-02-09 00:42:25.752000+00:00 True
47 Super Bowl Champion Buffalo Bills KXSB-27-BUF 2026-02-08 20:00:00+00:00 2026-02-09 00:42:08.760000+00:00 True
48 Super Bowl Champion Chicago Bears KXSB-27-CHI 2026-02-08 20:00:00+00:00 2026-02-09 00:42:18.774000+00:00 True
49 Super Bowl Champion Dallas Cowboys KXSB-27-DAL 2026-02-08 20:00:00+00:00 2026-02-09 00:42:37.814000+00:00 True
50 Super Bowl Champion San Francisco 49ers KXSB-27-SF 2026-02-08 20:00:00+00:00 2026-02-09 00:44:32.389000+00:00 True
51 Super Bowl Champion Cleveland Browns KXSB-27-CLE 2026-02-08 20:00:00+00:00 2026-02-09 00:42:32.683000+00:00 True
52 Super Bowl Champion Baltimore Ravens KXSB-27-BAL 2026-02-08 20:00:00+00:00 2026-02-09 00:42:03.691000+00:00 True
53 Super Bowl Champion Seattle Seahawks KXSB-27-SEA 2026-02-08 20:00:00+00:00 2026-02-09 00:44:38.388000+00:00 True
54 Super Bowl Champion Jacksonville Jaguars KXSB-27-JAC 2026-02-08 20:00:00+00:00 2026-02-09 00:43:14.221000+00:00 True
55 Super Bowl Champion Miami Dolphins KXSB-27-MIA 2026-02-08 20:00:00+00:00 2026-02-09 00:43:44.555000+00:00 True
56 Super Bowl Champion Arizona Cardinals KXSB-27-ARI 2026-02-08 20:00:00+00:00 2026-02-09 00:41:51.439000+00:00 True
57 Super Bowl Champion Washington Commanders KXSB-27-WAS 2026-02-08 20:00:00+00:00 2026-02-09 00:44:56.669000+00:00 True
58 Super Bowl Champion New Orleans Saints KXSB-27-NO 2026-02-08 20:00:00+00:00 2026-02-09 00:44:02.606000+00:00 True
59 Super Bowl Champion Carolina Panthers KXSB-27-CAR 2026-02-08 20:00:00+00:00 2026-02-09 00:42:13.750000+00:00 True
60 Super Bowl Champion Pittsburgh Steelers KXSB-27-PIT 2026-02-08 20:00:00+00:00 2026-02-09 00:44:24.370000+00:00 True
61 Super Bowl Champion Tampa Bay Buccaneers KXSB-27-TB 2026-02-08 20:00:00+00:00 2026-02-09 00:44:44.485000+00:00 True
62 Super Bowl Champion Los Angeles Chargers KXSB-27-LAC 2026-02-08 20:00:00+00:00 2026-02-09 00:43:34.117000+00:00 True
63 Super Bowl Champion Tennessee Titans KXSB-27-TEN 2026-02-08 20:00:00+00:00 2026-02-09 00:44:50.528000+00:00 True

Unmatched: none.

Data Ingestion

Kalshi 1-minute candlesticks come from the live series endpoint, /series/{series}/markets/{ticker}/candlesticks. The existing kalshi_client.get_candlesticks() reads /historical/..., which returns 404 for markets that haven't settled, and expects the older non-_dollars field names, so this notebook has its own small fetcher. Polymarket uses the existing polymarket_client.get_prices_history().

Everything is cached to data/raw/ under an nb13_ prefix, with the run time in the filename. Re-running on the same day reads from the cache. A later day fetches fresh data, since these markets keep trading.

In [4]:
KALSHI_MAX_CANDLES = 5000


def _kalshi_get(path, params):
    for attempt in range(6):
        resp = requests.get(f"{KALSHI_BASE}{path}", params=params, timeout=30)
        if resp.status_code == 429 or resp.status_code >= 500:
            time.sleep(1.5 * (attempt + 1))
            continue
        resp.raise_for_status()
        return resp.json()
    resp.raise_for_status()


def _dollars(side, field="close_dollars"):
    v = (side or {}).get(field)
    return float(v) if v is not None else None


def kalshi_live_candles(series, ticker, start_dt, end_dt):
    """1-minute candles for an open Kalshi market. Returns ts-indexed yes_ask / yes_bid / volume."""
    start_ts, end_ts = int(start_dt.timestamp()), int(end_dt.timestamp())
    rows, chunk_start = [], start_ts
    while chunk_start < end_ts:
        chunk_end = min(chunk_start + KALSHI_MAX_CANDLES * 60, end_ts)
        data = _kalshi_get(
            f"/series/{series}/markets/{ticker}/candlesticks",
            {"start_ts": chunk_start, "end_ts": chunk_end, "period_interval": 1},
        )
        rows.extend(data.get("candlesticks", []))
        chunk_start = chunk_end
    df = pd.DataFrame(
        {
            "ts": [pd.Timestamp(c["end_period_ts"], unit="s", tz="UTC") for c in rows],
            "yes_ask": [_dollars(c.get("yes_ask")) for c in rows],
            "yes_bid": [_dollars(c.get("yes_bid")) for c in rows],
            "volume": [float(c.get("volume_fp") or 0) for c in rows],
        }
    )
    if df.empty:
        return df.set_index("ts")
    return df.drop_duplicates("ts").set_index("ts").sort_index()


def load_or_fetch(name, fetch_fn):
    path = RAW_DIR / f"nb13_{RUN_TS:%Y%m%d}__{name}.parquet"
    if path.exists():
        return pd.read_parquet(path)
    df = fetch_fn()
    df.to_parquet(path)
    return df
In [5]:
from concurrent.futures import ThreadPoolExecutor

FETCH_WORKERS = 6  # ~8 months of 1-minute data is ~70 requests per Kalshi market; sequential takes over an hour

t0 = time.time()
with ThreadPoolExecutor(FETCH_WORKERS) as pool:
    results = pool.map(
        lambda kp: load_or_fetch(
            f"{kp[0]}__kalshi",
            lambda: kalshi_live_candles(kp[1]["kalshi_series"], kp[1]["kalshi_ticker"], kp[1]["kalshi_open"], RUN_TS),
        ),
        PAIRS.items(),
    )
    for p, df in zip(PAIRS.values(), results):
        p["kalshi"] = df
print(f"Kalshi candles fetched for {len(PAIRS)} markets in {time.time() - t0:,.0f}s")
Kalshi candles fetched for 64 markets in 0s
In [6]:
t0 = time.time()
with ThreadPoolExecutor(FETCH_WORKERS) as pool:
    results = pool.map(
        lambda kp: load_or_fetch(
            f"{kp[0]}__polymarket",
            lambda: polymarket_client.get_prices_history(
                kp[1]["polymarket_token"], kp[1]["polymarket_open"].to_pydatetime(), RUN_TS.to_pydatetime(), fidelity=1
            ),
        ),
        PAIRS.items(),
    )
    for p, df in zip(PAIRS.values(), results):
        p["polymarket"] = df
print(f"Polymarket history fetched for {len(PAIRS)} markets in {time.time() - t0:,.0f}s")
Polymarket history fetched for 64 markets in 0s
In [7]:
display(Markdown("#### Data Ingestion Complete"))
display(
    pd.DataFrame(
        [
            {
                "Event": p["event"],
                "Team": p["team"],
                "Kalshi Candles": len(p["kalshi"]),
                "Kalshi First": p["kalshi"].index.min() if len(p["kalshi"]) else None,
                "Polymarket Ticks": len(p["polymarket"]),
                "Polymarket First": p["polymarket"].index.min() if len(p["polymarket"]) else None,
            }
            for p in PAIRS.values()
        ]
    )
)

Data Ingestion Complete

Event Team Kalshi Candles Kalshi First Polymarket Ticks Polymarket First
0 AFC Champion New York Jets 2361 2026-02-07 15:01:00+00:00 352875 2026-02-09 22:48:12+00:00
1 AFC Champion New England Patriots 23416 2026-02-07 15:01:00+00:00 352886 2026-02-09 22:45:12+00:00
2 AFC Champion Jacksonville Jaguars 20937 2026-02-07 15:01:00+00:00 352879 2026-02-09 22:49:17+00:00
3 AFC Champion Houston Texans 22403 2026-02-07 15:01:00+00:00 352875 2026-02-09 22:46:13+00:00
4 AFC Champion Las Vegas Raiders 15704 2026-02-07 15:01:00+00:00 352873 2026-02-09 22:49:18+00:00
5 AFC Champion Pittsburgh Steelers 9565 2026-02-07 15:01:00+00:00 352886 2026-02-09 22:48:11+00:00
6 AFC Champion Baltimore Ravens 30521 2026-02-07 15:01:00+00:00 352873 2026-02-09 22:48:19+00:00
7 AFC Champion Buffalo Bills 34177 2026-02-07 15:01:00+00:00 352883 2026-02-09 22:46:12+00:00
8 AFC Champion Kansas City Chiefs 31497 2026-02-07 15:01:00+00:00 352876 2026-02-09 22:46:17+00:00
9 AFC Champion Cincinnati Bengals 17874 2026-02-07 15:01:00+00:00 352880 2026-02-09 22:46:14+00:00
10 AFC Champion Los Angeles Chargers 23348 2026-02-07 15:01:00+00:00 352888 2026-02-09 22:46:17+00:00
11 AFC Champion Miami Dolphins 3456 2026-02-07 15:01:00+00:00 352877 2026-02-09 22:48:19+00:00
12 AFC Champion Tennessee Titans 7512 2026-02-07 15:01:00+00:00 352882 2026-02-09 22:49:18+00:00
13 AFC Champion Indianapolis Colts 18725 2026-02-07 15:01:00+00:00 352880 2026-02-09 22:49:12+00:00
14 AFC Champion Denver Broncos 29183 2026-02-07 15:01:00+00:00 352875 2026-02-09 22:49:17+00:00
15 AFC Champion Cleveland Browns 5229 2026-02-07 15:01:00+00:00 352880 2026-02-09 22:48:16+00:00
16 NFC Champion Detroit Lions 24499 2026-02-07 15:01:00+00:00 352880 2026-02-09 22:42:13+00:00
17 NFC Champion Green Bay Packers 27546 2026-02-07 15:01:00+00:00 352888 2026-02-09 22:41:16+00:00
18 NFC Champion Dallas Cowboys 34243 2026-02-07 15:01:00+00:00 352881 2026-02-09 22:42:13+00:00
19 NFC Champion Washington Commanders 10158 2026-02-07 15:01:00+00:00 352879 2026-02-09 22:45:20+00:00
20 NFC Champion Seattle Seahawks 36068 2026-02-07 15:01:00+00:00 352885 2026-02-09 22:42:18+00:00
21 NFC Champion Minnesota Vikings 27344 2026-02-07 15:01:00+00:00 352878 2026-02-09 22:45:18+00:00
22 NFC Champion Los Angeles Rams 33869 2026-02-07 15:01:00+00:00 352883 2026-02-09 22:46:17+00:00
23 NFC Champion San Francisco 49ers 24970 2026-02-07 15:01:00+00:00 352893 2026-02-09 22:41:18+00:00
24 NFC Champion Carolina Panthers 9804 2026-02-07 15:01:00+00:00 352875 2026-02-09 22:46:16+00:00
25 NFC Champion Chicago Bears 30926 2026-02-07 15:01:00+00:00 352878 2026-02-09 22:45:19+00:00
26 NFC Champion Arizona Cardinals 2203 2026-02-07 15:01:00+00:00 352888 2026-02-09 22:46:13+00:00
27 NFC Champion Atlanta Falcons 19219 2026-02-07 15:01:00+00:00 352877 2026-02-09 22:46:15+00:00
28 NFC Champion New Orleans Saints 12288 2026-02-07 15:01:00+00:00 352874 2026-02-09 22:46:18+00:00
29 NFC Champion New York Giants 14072 2026-02-07 15:01:00+00:00 352877 2026-02-09 22:45:20+00:00
30 NFC Champion Tampa Bay Buccaneers 13676 2026-02-07 15:01:00+00:00 352882 2026-02-09 22:42:17+00:00
31 NFC Champion Philadelphia Eagles 21152 2026-02-07 15:01:00+00:00 352887 2026-02-09 22:41:15+00:00
32 Super Bowl Champion New York Jets 4215 2026-02-08 21:19:00+00:00 354186 2026-02-09 00:59:15+00:00
33 Super Bowl Champion Philadelphia Eagles 36984 2026-02-08 20:20:00+00:00 354184 2026-02-09 00:59:17+00:00
34 Super Bowl Champion New England Patriots 30969 2026-02-08 20:16:00+00:00 354180 2026-02-09 00:58:17+00:00
35 Super Bowl Champion Las Vegas Raiders 12119 2026-02-08 21:18:00+00:00 354218 2026-02-09 00:58:15+00:00
36 Super Bowl Champion Detroit Lions 27296 2026-02-08 20:26:00+00:00 354190 2026-02-09 00:57:12+00:00
37 Super Bowl Champion Green Bay Packers 101285 2026-02-08 20:12:00+00:00 354186 2026-02-09 00:57:16+00:00
38 Super Bowl Champion Atlanta Falcons 29542 2026-02-08 20:29:00+00:00 354192 2026-02-09 00:56:14+00:00
39 Super Bowl Champion Minnesota Vikings 20022 2026-02-08 20:24:00+00:00 354194 2026-02-09 00:58:17+00:00
40 Super Bowl Champion Los Angeles Rams 57198 2026-02-08 20:24:00+00:00 354183 2026-02-09 00:58:13+00:00
41 Super Bowl Champion New York Giants 20915 2026-02-08 20:22:00+00:00 354189 2026-02-09 00:59:15+00:00
42 Super Bowl Champion Indianapolis Colts 31747 2026-02-08 20:25:00+00:00 354185 2026-02-09 00:58:16+00:00
43 Super Bowl Champion Houston Texans 32627 2026-02-08 20:25:00+00:00 354181 2026-02-09 00:57:12+00:00
44 Super Bowl Champion Kansas City Chiefs 48846 2026-02-08 20:24:00+00:00 354185 2026-02-09 00:58:15+00:00
45 Super Bowl Champion Denver Broncos 34333 2026-02-08 20:23:00+00:00 354185 2026-02-09 00:57:17+00:00
46 Super Bowl Champion Cincinnati Bengals 39022 2026-02-08 20:27:00+00:00 354184 2026-02-09 00:57:12+00:00
47 Super Bowl Champion Buffalo Bills 51609 2026-02-08 20:28:00+00:00 354192 2026-02-09 00:57:15+00:00
48 Super Bowl Champion Chicago Bears 29775 2026-02-08 20:27:00+00:00 354195 2026-02-09 00:57:14+00:00
49 Super Bowl Champion Dallas Cowboys 30266 2026-02-08 20:26:00+00:00 354187 2026-02-09 00:57:12+00:00
50 Super Bowl Champion San Francisco 49ers 40109 2026-02-08 20:20:00+00:00 354183 2026-02-09 00:59:16+00:00
51 Super Bowl Champion Cleveland Browns 6872 2026-02-08 21:18:00+00:00 354197 2026-02-09 00:57:13+00:00
52 Super Bowl Champion Baltimore Ravens 97986 2026-02-08 20:28:00+00:00 354193 2026-02-09 00:56:15+00:00
53 Super Bowl Champion Seattle Seahawks 49873 2026-02-08 20:21:00+00:00 354181 2026-02-09 00:59:14+00:00
54 Super Bowl Champion Jacksonville Jaguars 28972 2026-02-08 20:25:00+00:00 354183 2026-02-09 00:58:12+00:00
55 Super Bowl Champion Miami Dolphins 5282 2026-02-08 21:19:00+00:00 354186 2026-02-09 00:58:14+00:00
56 Super Bowl Champion Arizona Cardinals 3326 2026-02-08 21:17:00+00:00 354187 2026-02-09 00:56:17+00:00
57 Super Bowl Champion Washington Commanders 30505 2026-02-08 21:19:00+00:00 354182 2026-02-09 00:59:14+00:00
58 Super Bowl Champion New Orleans Saints 8190 2026-02-08 21:19:00+00:00 354180 2026-02-09 00:58:13+00:00
59 Super Bowl Champion Carolina Panthers 7257 2026-02-08 21:18:00+00:00 354184 2026-02-09 00:57:12+00:00
60 Super Bowl Champion Pittsburgh Steelers 33126 2026-02-08 20:21:00+00:00 354194 2026-02-09 00:59:15+00:00
61 Super Bowl Champion Tampa Bay Buccaneers 18655 2026-02-08 20:20:00+00:00 354184 2026-02-09 00:59:17+00:00
62 Super Bowl Champion Los Angeles Chargers 35732 2026-02-08 20:24:00+00:00 354195 2026-02-09 00:58:17+00:00
63 Super Bowl Champion Tennessee Titans 4028 2026-02-08 20:09:00+00:00 354218 2026-02-09 00:59:18+00:00

Gap Series and Arbitrage Events

In [8]:
def aligned_grid(kalshi, poly):
    """Both series on one 1-minute grid, from the later of the two first observations to the earlier last one."""
    k = kalshi["yes_ask"].dropna()
    k = k[k > 0]  # no ask on the book: nothing to buy
    pm = poly["price"].astype(float)
    pm = pm[~pm.index.duplicated(keep="last")]
    if k.empty or pm.empty:
        return pd.DataFrame(columns=["kalshi_yes_ask", "polymarket_price", "gap"])
    k.index = k.index.floor("min")
    pm.index = pm.index.floor("min")
    k = k[~k.index.duplicated(keep="last")]
    pm = pm[~pm.index.duplicated(keep="last")]
    start = max(k.index.min(), pm.index.min())
    end = min(RUN_TS, max(k.index.max(), pm.index.max()))
    grid = pd.date_range(start, end, freq="1min", tz="UTC")
    df = pd.DataFrame(
        {
            "kalshi_yes_ask": k.reindex(grid, method="ffill"),
            # Polymarket reports every minute; a short forward-fill only bridges isolated missing minutes.
            "polymarket_price": pm.reindex(grid, method="ffill", limit=5),
        }
    ).dropna()
    df["gap"] = (df["kalshi_yes_ask"] - df["polymarket_price"]).round(4)
    return df


def find_arb_events(aligned, threshold, dwell_minutes):
    """Run-length encode |gap| > threshold over consecutive grid minutes; keep runs of at least dwell_minutes."""
    if aligned.empty:
        return pd.DataFrame(columns=["start", "end", "minutes", "direction", "max_abs_gap"])
    hot = aligned["gap"].abs() > threshold + 1e-9
    # A new run starts on a change in hot-ness, a sign flip, or a hole in the grid.
    sign = np.sign(aligned["gap"])
    hole = aligned.index.to_series().diff() != pd.Timedelta("1min")
    run_id = ((hot != hot.shift()) | (sign != sign.shift()) | hole).cumsum()
    runs = aligned[hot].assign(run=run_id[hot]).groupby("run").agg(
        start=("gap", lambda s: s.index[0]),
        end=("gap", lambda s: s.index[-1]),
        minutes=("gap", "size"),
        direction=("gap", lambda s: int(np.sign(s.iloc[0]))),
        max_abs_gap=("gap", lambda s: s.abs().max()),
    )
    return runs[runs["minutes"] >= dwell_minutes].reset_index(drop=True)


for key, p in PAIRS.items():
    p["aligned"] = aligned_grid(p["kalshi"], p["polymarket"])
    p["arb_events_all"] = find_arb_events(p["aligned"], GAP_THRESHOLD, DWELL_MINUTES)

print(f"Aligned minutes across all {len(PAIRS)} pairs: {sum(len(p['aligned']) for p in PAIRS.values()):,}")
print(f"Arbitrage events, raw (|gap| > ${GAP_THRESHOLD:.2f}, >= {DWELL_MINUTES} min): {sum(len(p['arb_events_all']) for p in PAIRS.values()):,}")
Aligned minutes across all 64 pairs: 22,652,242
Arbitrage events, raw (|gap| > $0.05, >= 2 min): 1,444

Data Quality: Two Windows That Aren't Real Arbitrage

The raw count is dominated by two stretches where Polymarket's price-history value is not a tradeable price.

1. Polymarket's launch window. Polymarket listed all three events on 2026-02-09, two days after Kalshi. For roughly the next two weeks, many of its books were close to empty. The recorded price sat near the middle of an empty book (often 0.40–0.50) for teams Kalshi priced at 1–5¢. That produces multi-day "events" with 40–49¢ gaps. Nobody could have bought Polymarket No at 50–60¢ on a 2% team, since there was no liquidity there.

2. A Polymarket-wide glitch on 2026-04-28, 12:45–18:30 UTC. At 12:46 UTC, up to 29 of these 64 markets, across all three events, jumped toward 0.50 in the same minute and drifted back over the next few hours. A simultaneous move across every team in three separate events is a platform event (a book wipe or price-feed fault), not independent mispricings.

The cells below show the evidence, then drop those minutes before counting. All results after this section use the cleaned series, and the per-team tables also show the raw count for reference.

In [9]:
LAUNCH_EXCLUSION_DAYS = 14
GLITCH_WINDOWS = [
    (pd.Timestamp("2026-04-28 12:45", tz="UTC"), pd.Timestamp("2026-04-28 18:30", tz="UTC")),
]

raw_events = pd.concat(
    [p["arb_events_all"].assign(Event=p["event"]) for p in PAIRS.values() if len(p["arb_events_all"])],
    ignore_index=True,
)
weekly_raw = (
    raw_events.assign(week=raw_events["start"].dt.tz_convert(None).dt.to_period("W").dt.start_time.dt.date)
    .groupby(["week", "Event"]).size().unstack(fill_value=0)
)
display(Markdown("#### Raw events by week (first 8 weeks) — the launch fortnight dominates"))
display(weekly_raw.head(8))

# Cross-market check: how many of the 64 Polymarket series moved > $0.10 off their prior-hour median, per 15 minutes, on 2026-04-28.
day_start, day_end = pd.Timestamp("2026-04-28 10:00", tz="UTC"), pd.Timestamp("2026-04-28 22:00", tz="UTC")
moved = {}
for key, p in PAIRS.items():
    px = p["polymarket"]["price"].astype(float)
    px = px[~px.index.duplicated(keep="last")]
    base = px[:day_start].iloc[-60:].median()
    moved[key] = (px[day_start:day_end] - base).abs().resample("15min").max() > 0.10
moved = pd.DataFrame(moved).fillna(False).sum(axis=1)
fig, ax = plt.subplots(figsize=(14, 3.5))
ax.bar(moved.index, moved.values, width=0.009, color="#c0392b")
ax.set_title("2026-04-28: Polymarket markets (of 64) more than $0.10 off their prior-hour price, per 15 minutes")
ax.set_ylabel("markets")
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
plt.show()

Raw events by week (first 8 weeks) — the launch fortnight dominates

Event AFC Champion NFC Champion Super Bowl Champion
week
2026-02-09 209 257 101
2026-02-16 78 104 0
2026-02-23 8 6 0
2026-03-02 70 25 0
2026-03-09 68 1 0
2026-03-16 29 48 0
2026-03-23 179 6 0
2026-03-30 1 0 0
No description has been provided for this image
In [10]:
def clean_mask(p):
    idx = p["aligned"].index
    keep = idx >= p["polymarket_open"] + pd.Timedelta(days=LAUNCH_EXCLUSION_DAYS)
    for start, end in GLITCH_WINDOWS:
        keep &= ~((idx >= start) & (idx <= end))
    return keep


for key, p in PAIRS.items():
    # Dropped minutes leave a hole in the grid, which find_arb_events treats as a run boundary.
    p["aligned_clean"] = p["aligned"][clean_mask(p)]
    p["arb_events"] = find_arb_events(p["aligned_clean"], GAP_THRESHOLD, DWELL_MINUTES)

n_raw = sum(len(p["arb_events_all"]) for p in PAIRS.values())
n_clean = sum(len(p["arb_events"]) for p in PAIRS.values())
display(Markdown(
    f"**Raw events: {n_raw:,} → cleaned: {n_clean:,}** "
    f"(launch window: first {LAUNCH_EXCLUSION_DAYS} days after each Polymarket listing; glitch window: 2026-04-28 12:45–18:30 UTC)."
))

Raw events: 1,444 → cleaned: 653 (launch window: first 14 days after each Polymarket listing; glitch window: 2026-04-28 12:45–18:30 UTC).

Results: Arbitrage Events per Team

Arb Events is the headline count, on the cleaned series. Raw is the same count before the two exclusions above. Kalshi Rich means Kalshi's Yes was the more expensive side (buy Kalshi No + Polymarket Yes); Poly Rich is the reverse. Minutes in Arb is how long those events lasted in total, and Max Gap is the widest gap seen. Median Gap is the typical |gap| over every aligned minute, as a sense of how far apart the two platforms usually sit.

In [11]:
def team_summary(p):
    ev = p["arb_events"]
    a = p["aligned_clean"]
    return {
        "Event": p["event"],
        "Team": p["team"],
        "Arb Events": len(ev),
        "Raw": len(p["arb_events_all"]),
        "Kalshi Rich": int((ev["direction"] == 1).sum()) if len(ev) else 0,
        "Poly Rich": int((ev["direction"] == -1).sum()) if len(ev) else 0,
        "Minutes in Arb": int(ev["minutes"].sum()) if len(ev) else 0,
        "Longest (min)": int(ev["minutes"].max()) if len(ev) else 0,
        "Max Gap": round(float(ev["max_abs_gap"].max()), 3) if len(ev) else np.nan,
        "Median Gap": round(float(a["gap"].abs().median()), 3) if len(a) else np.nan,
        "Aligned Days": round(len(a) / 1440, 1),
        "Last Kalshi Ask": float(a["kalshi_yes_ask"].iloc[-1]) if len(a) else np.nan,
        "Last Poly Price": float(a["polymarket_price"].iloc[-1]) if len(a) else np.nan,
    }


summary = pd.DataFrame([team_summary(p) for p in PAIRS.values()])

for ev_key, ev in EVENTS.items():
    t = (
        summary[summary["Event"] == ev["label"]]
        .drop(columns="Event")
        .sort_values(["Arb Events", "Minutes in Arb"], ascending=False)
        .reset_index(drop=True)
    )
    t.index = t.index + 1
    display(Markdown(f"### {ev['label']} — {t['Arb Events'].sum():,} events across {(t['Arb Events'] > 0).sum()} of {len(t)} teams"))
    display(t)

AFC Champion — 405 events across 15 of 16 teams

Team Arb Events Raw Kalshi Rich Poly Rich Minutes in Arb Longest (min) Max Gap Median Gap Aligned Days Last Kalshi Ask Last Poly Price
1 Indianapolis Colts 213 218 0 213 11201 2099 0.180 0.009 231.1 0.02 0.0130
2 Buffalo Bills 37 44 37 0 2571 745 0.065 0.020 231.1 0.21 0.2050
3 Houston Texans 25 36 0 25 1027 756 0.060 0.013 231.1 0.04 0.0355
4 Kansas City Chiefs 24 27 24 0 5512 3260 0.080 0.030 231.1 0.18 0.1750
5 Jacksonville Jaguars 19 24 10 9 685 203 0.120 0.015 231.1 0.14 0.1250
6 Los Angeles Chargers 15 119 12 3 259 81 0.075 0.020 231.1 0.01 0.0060
7 New York Jets 13 15 0 13 1438 699 0.080 0.005 231.1 0.01 0.0045
8 Tennessee Titans 11 14 0 11 2429 1683 0.081 0.007 231.1 0.01 0.0045
9 Pittsburgh Steelers 11 12 0 11 168 66 0.070 0.006 231.1 0.03 0.0220
10 Denver Broncos 10 55 3 7 1270 784 0.080 0.015 231.1 0.08 0.0700
11 Cincinnati Bengals 10 24 10 0 818 737 0.075 0.018 231.1 0.07 0.0680
12 Baltimore Ravens 10 54 2 8 548 152 0.065 0.005 231.1 0.17 0.1550
13 New England Patriots 4 11 4 0 434 333 0.065 0.015 231.1 0.08 0.0700
14 Las Vegas Raiders 2 49 0 2 486 400 0.063 0.005 231.1 0.02 0.0195
15 Cleveland Browns 1 4 0 1 3 3 0.051 0.005 231.1 0.03 0.0190
16 Miami Dolphins 0 1 0 0 0 0 NaN 0.006 231.1 0.01 0.0035

NFC Champion — 242 events across 14 of 16 teams

Team Arb Events Raw Kalshi Rich Poly Rich Minutes in Arb Longest (min) Max Gap Median Gap Aligned Days Last Kalshi Ask Last Poly Price
1 Los Angeles Rams 68 87 5 63 19480 9147 0.290 0.010 231.1 0.17 0.1700
2 Carolina Panthers 31 36 0 31 20557 5111 0.099 0.007 231.1 0.03 0.0225
3 Seattle Seahawks 30 39 3 27 444 131 0.080 0.010 231.1 0.15 0.1450
4 Dallas Cowboys 26 76 18 8 15678 5712 0.154 0.024 231.1 0.06 0.0565
5 Green Bay Packers 22 24 22 0 4515 1158 0.070 0.015 231.1 0.04 0.0405
6 Minnesota Vikings 19 27 2 17 4664 3820 0.097 0.011 231.1 0.09 0.0745
7 Detroit Lions 16 17 15 1 5500 3795 0.110 0.015 231.1 0.08 0.0560
8 Atlanta Falcons 13 15 0 13 319 138 0.085 0.010 231.1 0.03 0.0190
9 Chicago Bears 6 20 6 0 1321 1198 0.080 0.025 231.1 0.08 0.0850
10 Philadelphia Eagles 4 8 1 3 1269 878 0.095 0.014 231.1 0.04 0.0475
11 New Orleans Saints 2 7 0 2 4870 2660 0.075 0.006 231.1 0.01 0.0170
12 Arizona Cardinals 2 6 0 2 2009 1597 0.060 0.004 231.1 0.01 0.0045
13 San Francisco 49ers 2 186 1 1 8 5 0.095 0.010 231.1 0.21 0.2050
14 New York Giants 1 9 0 1 6 6 0.055 0.009 231.1 0.02 0.0245
15 Washington Commanders 0 54 0 0 0 0 NaN 0.018 231.1 0.01 0.0155
16 Tampa Bay Buccaneers 0 9 0 0 0 0 NaN 0.007 231.1 0.01 0.0115

Super Bowl Champion — 6 events across 2 of 32 teams

Team Arb Events Raw Kalshi Rich Poly Rich Minutes in Arb Longest (min) Max Gap Median Gap Aligned Days Last Kalshi Ask Last Poly Price
1 Seattle Seahawks 5 12 0 5 304 170 0.055 0.015 232.0 0.09 0.0750
2 Chicago Bears 1 5 0 1 6 6 0.210 0.013 232.0 0.04 0.0350
3 New York Jets 0 2 0 0 0 0 NaN 0.002 232.0 0.01 0.0035
4 Philadelphia Eagles 0 3 0 0 0 0 NaN 0.011 232.0 0.02 0.0185
5 New England Patriots 0 3 0 0 0 0 NaN 0.009 232.0 0.04 0.0375
6 Las Vegas Raiders 0 5 0 0 0 0 NaN 0.003 232.0 0.01 0.0055
7 Detroit Lions 0 5 0 0 0 0 NaN 0.011 232.0 0.04 0.0305
8 Green Bay Packers 0 1 0 0 0 0 NaN 0.013 232.0 0.02 0.0185
9 Atlanta Falcons 0 3 0 0 0 0 NaN 0.009 232.0 0.02 0.0105
10 Minnesota Vikings 0 4 0 0 0 0 NaN 0.005 232.0 0.04 0.0315
11 Los Angeles Rams 0 10 0 0 0 0 NaN 0.015 232.0 0.11 0.1050
12 New York Giants 0 2 0 0 0 0 NaN 0.007 232.0 0.01 0.0055
13 Indianapolis Colts 0 5 0 0 0 0 NaN 0.007 232.0 0.01 0.0055
14 Houston Texans 0 3 0 0 0 0 NaN 0.006 232.0 0.02 0.0155
15 Kansas City Chiefs 0 4 0 0 0 0 NaN 0.011 232.0 0.10 0.0985
16 Denver Broncos 0 2 0 0 0 0 NaN 0.012 232.0 0.04 0.0295
17 Cincinnati Bengals 0 2 0 0 0 0 NaN 0.013 232.0 0.04 0.0385
18 Buffalo Bills 0 2 0 0 0 0 NaN 0.005 232.0 0.12 0.1150
19 Dallas Cowboys 0 5 0 0 0 0 NaN 0.007 232.0 0.03 0.0235
20 San Francisco 49ers 0 2 0 0 0 0 NaN 0.013 232.0 0.13 0.1145
21 Cleveland Browns 0 3 0 0 0 0 NaN 0.002 232.0 0.01 0.0045
22 Baltimore Ravens 0 2 0 0 0 0 NaN 0.005 232.0 0.09 0.0755
23 Jacksonville Jaguars 0 3 0 0 0 0 NaN 0.005 232.0 0.07 0.0710
24 Miami Dolphins 0 2 0 0 0 0 NaN 0.001 232.0 0.01 0.0035
25 Arizona Cardinals 0 2 0 0 0 0 NaN 0.003 232.0 0.01 0.0045
26 Washington Commanders 0 3 0 0 0 0 NaN 0.013 232.0 0.01 0.0045
27 New Orleans Saints 0 2 0 0 0 0 NaN 0.002 232.0 0.01 0.0045
28 Carolina Panthers 0 3 0 0 0 0 NaN 0.003 232.0 0.01 0.0085
29 Pittsburgh Steelers 0 3 0 0 0 0 NaN 0.007 232.0 0.01 0.0075
30 Tampa Bay Buccaneers 0 6 0 0 0 0 NaN 0.009 232.0 0.01 0.0045
31 Los Angeles Chargers 0 3 0 0 0 0 NaN 0.009 232.0 0.01 0.0035
32 Tennessee Titans 0 5 0 0 0 0 NaN 0.003 232.0 0.01 0.0035
In [12]:
display(Markdown("### Totals by Event"))
totals = summary.groupby("Event", sort=False).agg(
    teams=("Team", "size"),
    teams_with_arb=("Arb Events", lambda s: int((s > 0).sum())),
    arb_events=("Arb Events", "sum"),
    raw_events=("Raw", "sum"),
    kalshi_rich=("Kalshi Rich", "sum"),
    poly_rich=("Poly Rich", "sum"),
    minutes_in_arb=("Minutes in Arb", "sum"),
    max_gap=("Max Gap", "max"),
)
display(totals)

Totals by Event

teams teams_with_arb arb_events raw_events kalshi_rich poly_rich minutes_in_arb max_gap
Event
AFC Champion 16 15 405 707 102 303 28849 0.18
NFC Champion 16 14 242 620 73 169 80640 0.29
Super Bowl Champion 32 2 6 117 0 6 310 0.21
In [13]:
fig, axes = plt.subplots(1, 3, figsize=(18, 9), gridspec_kw={"width_ratios": [1, 1, 1.4]})
for ax, (ev_key, ev) in zip(axes, EVENTS.items()):
    t = summary[summary["Event"] == ev["label"]].sort_values("Arb Events")
    ax.barh(t["Team"], t["Kalshi Rich"], color="#3b6fb6", label="Kalshi rich (buy K-No + P-Yes)")
    ax.barh(t["Team"], t["Poly Rich"], left=t["Kalshi Rich"], color="#e08a2c", label="Poly rich (buy K-Yes + P-No)")
    for y, (n, team) in enumerate(zip(t["Arb Events"], t["Team"])):
        ax.text(n, y, f" {n}", va="center", fontsize=8)
    ax.set_title(ev["label"], fontsize=12)
    ax.set_xlabel(f"arbitrage events (|gap| > ${GAP_THRESHOLD:.2f}, >= {DWELL_MINUTES} min)")
    ax.tick_params(axis="y", labelsize=8)
    ax.grid(axis="x", alpha=0.3)
axes[0].legend(loc="lower right", fontsize=8)
fig.suptitle("Kalshi vs Polymarket arbitrage events per team, market open to " + f"{RUN_TS:%Y-%m-%d}", fontsize=13)
plt.tight_layout()
plt.show()
No description has been provided for this image

When the Events Happened

Arbitrage events per week across all three events. A cluster around a single date usually points to news (a trade, an injury, a result) that one platform repriced before the other.

In [14]:
all_events = pd.concat(
    [p["arb_events"].assign(Event=p["event"], Team=p["team"]) for p in PAIRS.values() if len(p["arb_events"])],
    ignore_index=True,
)
if all_events.empty:
    print("No arbitrage events to plot.")
else:
    weekly = (
        all_events.assign(week=all_events["start"].dt.tz_convert(None).dt.to_period("W").dt.start_time)
        .groupby(["week", "Event"]).size().unstack(fill_value=0)
        .reindex(columns=[ev["label"] for ev in EVENTS.values()], fill_value=0)
    )
    weekly = weekly.reindex(pd.date_range(weekly.index.min(), weekly.index.max(), freq="7D"), fill_value=0)
    fig, ax = plt.subplots(figsize=(16, 5))
    weekly.plot(kind="bar", stacked=True, ax=ax, color=["#3b6fb6", "#2a9d8f", "#e08a2c"], width=0.85)
    ax.set_xticklabels([d.strftime("%b %d") for d in weekly.index], rotation=60, fontsize=8)
    ax.set_xlabel("week starting")
    ax.set_ylabel("arbitrage events")
    ax.set_title("Arbitrage events per week")
    ax.grid(axis="y", alpha=0.3)
    plt.tight_layout()
    plt.show()

    display(Markdown("#### Ten Widest Gaps"))
    display(
        all_events.sort_values("max_abs_gap", ascending=False)
        .head(10)[["Event", "Team", "start", "end", "minutes", "direction", "max_abs_gap"]]
        .reset_index(drop=True)
    )
No description has been provided for this image

Ten Widest Gaps

Event Team start end minutes direction max_abs_gap
0 NFC Champion Los Angeles Rams 2026-06-05 13:32:00+00:00 2026-06-05 13:56:00+00:00 25 -1 0.290
1 NFC Champion Los Angeles Rams 2026-06-05 12:49:00+00:00 2026-06-05 13:17:00+00:00 29 -1 0.280
2 Super Bowl Champion Chicago Bears 2026-09-27 00:14:00+00:00 2026-09-27 00:19:00+00:00 6 -1 0.210
3 NFC Champion Los Angeles Rams 2026-06-05 14:14:00+00:00 2026-06-05 14:17:00+00:00 4 -1 0.200
4 AFC Champion Indianapolis Colts 2026-03-01 09:04:00+00:00 2026-03-01 12:38:00+00:00 215 -1 0.180
5 AFC Champion Indianapolis Colts 2026-02-28 18:32:00+00:00 2026-02-28 19:15:00+00:00 44 -1 0.160
6 AFC Champion Indianapolis Colts 2026-02-26 08:07:00+00:00 2026-02-27 00:36:00+00:00 990 -1 0.160
7 AFC Champion Indianapolis Colts 2026-02-27 06:15:00+00:00 2026-02-27 06:59:00+00:00 45 -1 0.155
8 NFC Champion Los Angeles Rams 2026-06-06 02:22:00+00:00 2026-06-06 02:35:00+00:00 14 -1 0.155
9 NFC Champion Dallas Cowboys 2026-09-27 17:24:00+00:00 2026-09-27 20:31:00+00:00 188 1 0.154

Summary

Figures as of the 2026-10-13 run. These markets are still open, so the counts grow on any re-run.

Yes, there is arbitrage in the history of all three pairings, but it is concentrated in the conference markets. After dropping Polymarket's launch fortnight and the 2026-04-28 glitch, there were 653 arbitrage events where the gross gap exceeded $0.05 for at least 2 minutes (1,444 raw).

Event Events (clean) Raw Teams with ≥ 1 event Widest clean gap
AFC Champion 405 707 15 of 16 $0.18
NFC Champion 242 620 14 of 16 $0.29
Super Bowl Champion 6 117 2 of 32 $0.21

The Super Bowl market is effectively arbitrage-free. Of 117 raw events, 111 sit inside the two excluded windows. What remains is 5 short Seahawks events at about $0.05 and one 6-minute Bears spike. With 32 teams, it is the deepest and most closely watched of the three, and the two platforms track each other to within about a cent (median |gap| of $0.001–0.015 per team).

The conference markets disagree persistently, mostly in one direction. Most events are Poly Rich: Polymarket's price for the team sits above Kalshi's ask, so the trade is buy Kalshi Yes + Polymarket No. Two kinds of event stand out:

  • Many short flickers: Indianapolis (AFC) alone accounts for 213 events. Its gap hovered right around $0.05 for weeks (max $0.18, typically $0.05–0.06), so the count is inflated by a gap that keeps crossing the threshold and dropping back.
  • A few very long runs: the LA Rams (NFC, 68 events, 19,480 minutes in arb, longest about 6 days), Carolina (20,557 minutes), and Dallas (15,678 minutes, mostly Kalshi Rich) held gaps for days at a time.

Timing: most clean events fall in March, then they thin out to a handful a week from April through the start of the season, with small bursts in early July and late September (see the weekly chart).

Most of these gaps are small, so treat them as an upper bound. Most clean events peak at $0.05–0.07. Kalshi's taker fee alone is up to about $0.0175/contract at these prices, Polymarket charges where feesEnabled is true, and Polymarket's history value is its displayed price, not an executable ask. On a thin long-shot book, the real No ask can be several cents worse. The multi-day conference gaps are the ones worth a live order-book check before building a strategy around them. A backtest in the style of notebooks 04–12, with fees and Kalshi fill confirmation, would be the natural next step.