Notebook 14: NFL Futures Arbitrage Backtest — AFC Champion, NFC Champion, Super Bowl (2027)

Notebook 13 counted how often Kalshi and Polymarket disagreed by more than $0.05 on the 2027 AFC, NFC and Super Bowl futures: 653 events after cleaning. This notebook asks whether trading those gaps would have made money. It applies the hedge-arbitrage strategy from notebooks 04–12 (buy the cheap side on one platform and the opposite side on the other, then exit early or hold to resolution), with fees, fill confirmation, and one shared bankroll.

One thing makes this backtest unlike 04–12: none of these markets has resolved. The conference championships settle around 2027-02-08 and the Super Bowl around 2027-02-14 (Kalshi's expected_expiration_time). A hedged position held to close is guaranteed its $1.00/contract payout, but that money isn't released until then. So the results separate:

  • Realized PnL: positions that exited early, with cash already back in the bankroll.
  • Locked-in PnL: positions held to close. Their profit is fixed by the hedge, but the capital is tied up until February 2027.

The data and the two excluded windows are the same as notebook 13: Polymarket's first 14 days after listing (empty books) and the 2026-04-28 12:45–18:30 UTC Polymarket glitch. No entry or exit is allowed inside either window.

Strategy Definition

Signal — the executable gap. For each team, on a common 1-minute grid:

  • Kalshi rich (direction +1): Kalshi's Yes bid is above Polymarket's price. Trade: buy Kalshi No at 1 − yes_bid and buy Polymarket Yes. Gap = yes_bid − Polymarket price.
  • Poly rich (direction −1): Polymarket's price is above Kalshi's Yes ask. Trade: buy Kalshi Yes at yes_ask and buy Polymarket No at 1 − Polymarket price. Gap = Polymarket price − yes_ask.

Either way, the gap is how far below $1.00 the pair of contracts costs before fees and slippage. A signal is a run where the gap is **more than $0.05**.

Entry — sequential, Kalshi first, confirmed by real Kalshi volume. The gap must hold for 2 consecutive minutes. From then on, the position opens at the first minute in the run where Kalshi printed trade volume (the simulated Kalshi fill). Polymarket is never bought without that confirmation. If the run ends without any Kalshi volume, the signal is skipped as unconfirmed. One entry per run.

Exit — the same profit rule as notebooks 04–12. On every later clean minute, check what selling both legs right now would return after exit fees and slippage. Kalshi No sells at 1 − yes_ask and Kalshi Yes sells at yes_bid. If that beats the guaranteed $1.00/contract hold-to-close payout by more than PROFIT_EXIT_BUFFER, exit. Otherwise hold to resolution. No stop loss.

Sizing and concurrency — unchanged from 04–12. Each trade uses 20% of available bankroll, split into an equal contract count on both legs. Multiple positions can be open at once, across teams and events. A signal is skipped if available cash can't buy one contract pair. Capital returns to the bankroll when a position exits early, or at resolution if held.

Fees.

  • Kalshi: the confirmed taker formula in src/fees.py: ceil(0.07 × C × (1 − C) × contracts) cents per order.
  • Polymarket, base case: every one of these 67 markets carries feeType: sports_fees_v2 and feeSchedule: {rate: 0.03, exponent: 1, takerOnly: true}. That is modelled as rate × min(p, 1 − p) per share, the same reading of feeSchedule used by the weather side of this site (routes/calculators/fees.py). It is not an officially documented formula.
  • Polymarket, conservative case: the legacy /fee-rate endpoint still reports base_fee: 1000. The sensitivity table also runs src/fees.py's 10% × p × (1 − p) formula.

Slippage on Polymarket. Polymarket's price history records its displayed price, not the bid and ask. Each Polymarket fill is charged POLY_SLIPPAGE against it (buys pay more, sells receive less). The base case is $0.005, half of a 1¢ spread. Kalshi needs no such assumption, since its candlesticks carry the real bid and ask.

What's different from notebooks 04–12, and why:

Notebooks 04–12 This notebook
Entry time Last minute of the qualifying run, confirmed by Kalshi volume anywhere in the run First minute after the 2-minute dwell that has Kalshi volume. Avoids using knowledge of when the run ends
Kalshi prices yes_ask for both sides Buy Yes at ask, buy No at 1 − bid, sell Yes at bid, sell No at 1 − ask
Polymarket price Displayed price, no spread Displayed price ± POLY_SLIPPAGE
Hold to close Released at resolution, inside the backtest window Resolution is after the backtest window, so held capital stays locked

Variables

  • ENTRY_GAP_THRESHOLD = 0.05: a signal needs the executable gap strictly above this
  • ENTRY_DWELL_MINUTES = 2
  • PROFIT_EXIT_BUFFER = 0.00
  • POSITION_SIZE_PCT = 0.20
  • STARTING_BANKROLL = 1000
  • POLY_SLIPPAGE = 0.005
  • POLY_FEE_MODEL = "schedule" (3% × min(p, 1 − p)), alternative "legacy" (10% × p × (1 − p))
  • LAUNCH_EXCLUSION_DAYS = 14; glitch window 2026-04-28 12:45–18:30 UTC
  • STOP_LOSS = None

Setup and Data

In [1]:
import sys
import json
import time
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor

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 fees, polymarket_client

pd.set_option("display.width", 160)
pd.set_option("display.max_rows", 120)

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"
RUN_TS = pd.Timestamp.now(tz="UTC").floor("min")

ENTRY_GAP_THRESHOLD = 0.05
ENTRY_DWELL_MINUTES = 2
PROFIT_EXIT_BUFFER = 0.00
POSITION_SIZE_PCT = 0.20
STARTING_BANKROLL = 1000.0
POLY_SLIPPAGE = 0.005
POLY_FEE_MODEL = "schedule"
STOP_LOSS = None

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

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"},
}
print(f"Run time (UTC): {RUN_TS}")
Run time (UTC): 2026-10-13 16:35:00+00:00

Team matching and data ingestion are identical to notebook 13: Kalshi city name as a prefix of Polymarket's team name, Kalshi 1-minute candles from the live series endpoint, and Polymarket prices-history at fidelity=1. This notebook reads notebook 13's cache (nb13_ prefix, same day) rather than downloading 8 months of data twice. On a different day, it fetches fresh data into that cache.

In [2]:
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 = {}
for ev_key, ev in EVENTS.items():
    poly_markets = [m for m in polymarket_client.get_event_by_slug(ev["polymarket_slug"])["markets"]
                    if m["groupItemTitle"] != "Other"]
    for km in kalshi_event_markets(ev["kalshi_event"]):
        hits = [pm for pm in poly_markets if pm["groupItemTitle"].startswith(km["yes_sub_title"])]
        assert len(hits) == 1, (ev_key, km["yes_sub_title"], len(hits))
        pm = hits[0]
        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),
            "resolution_ts": pd.to_datetime(km["expected_expiration_time"], utc=True),
            "polymarket_token": json.loads(pm["clobTokenIds"])[0],
            "polymarket_open": pd.to_datetime(pm["startDate"], utc=True),
            "poly_fee_schedule": pm.get("feeSchedule"),
            "poly_fee_type": pm.get("feeType"),
        }

meta = pd.DataFrame(PAIRS.values())
display(Markdown(f"#### {len(PAIRS)} matched team pairs"))
display(
    meta.groupby("event", sort=False).agg(
        teams=("team", "size"),
        expected_resolution=("resolution_ts", "first"),
        poly_fee_type=("poly_fee_type", "first"),
        poly_fee_schedule=("poly_fee_schedule", lambda s: str(s.iloc[0])),
    )
)
assert meta["poly_fee_schedule"].map(str).nunique() == 1, "fee schedule differs between markets"

64 matched team pairs

teams expected_resolution poly_fee_type poly_fee_schedule
event
AFC Champion 16 2027-02-08 15:00:00+00:00 sports_fees_v2 {'exponent': 1, 'rate': 0.03, 'takerOnly': Tru...
NFC Champion 16 2027-02-08 15:00:00+00:00 sports_fees_v2 {'exponent': 1, 'rate': 0.03, 'takerOnly': Tru...
Super Bowl Champion 32 2027-02-14 23:30:00+00:00 sports_fees_v2 {'exponent': 1, 'rate': 0.03, 'takerOnly': Tru...
In [3]:
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):
    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],
    })
    return df.set_index("ts") if df.empty else 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


t0 = time.time()
with ThreadPoolExecutor(6) as pool:
    kalshi = list(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()))
    poly = list(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, k, pm in zip(PAIRS.values(), kalshi, poly):
    p["kalshi"], p["polymarket"] = k, pm
print(f"Loaded Kalshi + Polymarket history for {len(PAIRS)} pairs in {time.time() - t0:,.0f}s")
Loaded Kalshi + Polymarket history for 64 pairs in 0s

The clean 1-minute grid

As in notebook 13, Kalshi only returns a candle for minutes with activity, so its last known bid and ask carry forward to the next candle. Kalshi volume does not carry forward: a minute without a candle had no trades. Minutes inside the two excluded windows are removed, and the gaps they leave split runs and block exits.

In [4]:
def build_grid(p):
    k = p["kalshi"].copy()
    k.index = k.index.floor("min")
    k = k[~k.index.duplicated(keep="last")]
    pm = p["polymarket"]["price"].astype(float)
    pm.index = pm.index.floor("min")
    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({
        # A candle with no offer (ask None) or no bid stays NaN until the next candle -- nothing to trade against.
        "yes_ask": k["yes_ask"].reindex(grid, method="ffill"),
        "yes_bid": k["yes_bid"].reindex(grid, method="ffill"),
        "kalshi_volume": k["volume"].reindex(grid).fillna(0.0),
        "poly": pm.reindex(grid, method="ffill", limit=5),
    }).dropna(subset=["poly"])
    df.loc[df["yes_ask"] <= 0, "yes_ask"] = np.nan
    df.loc[df["yes_bid"] <= 0, "yes_bid"] = np.nan
    keep = df.index >= p["polymarket_open"] + pd.Timedelta(days=LAUNCH_EXCLUSION_DAYS)
    for start_w, end_w in GLITCH_WINDOWS:
        keep &= ~((df.index >= start_w) & (df.index <= end_w))
    return df[keep]


for p in PAIRS.values():
    p["grid"] = build_grid(p)
print(f"Clean aligned minutes: {sum(len(p['grid']) for p in PAIRS.values()):,}")
Clean aligned minutes: 21,341,250

Backtest Engine

The engine runs in three passes, like notebooks 04–12:

  1. Per pair, per direction: find every qualifying run, its confirmed entry minute, and its exit (first later minute where exiting beats holding, otherwise hold to resolution). This pass is vectorised with numpy, since each pair has about 330,000 minutes.
  2. Chronological bankroll pass: merge every candidate trade across all 64 pairs into one timeline, then apply the 20%-of-available sizing and release each position's capital when it closes.
  3. Metrics: funnel, PnL split into realized vs locked-in, fees, and holding time.

Everything is wrapped in one function, so the sensitivity table at the end can re-run it under different thresholds, fees and slippage.

In [5]:
def poly_fee(price, contracts, model):
    price = np.clip(price, 0.0, 1.0)
    if model == "schedule":
        return 0.03 * np.minimum(price, 1 - price) * contracts
    if model == "legacy":
        return fees.POLYMARKET_TAKER_RATE * price * (1 - price) * contracts
    raise ValueError(model)


def kalshi_fee_unrounded(price, contracts=1):
    # Per-contract screen for the vectorised exit rule; the bankroll pass uses fees.kalshi_taker_fee (rounded up per order).
    return fees.KALSHI_TAKER_RATE * price * (1 - price) * contracts


def leg_prices(g, direction, slip):
    """Per-minute buy and sell prices for both legs of a position in `direction`."""
    ask, bid, poly = g["yes_ask"].to_numpy(), g["yes_bid"].to_numpy(), g["poly"].to_numpy()
    if direction == 1:   # Kalshi rich: hold Kalshi No + Polymarket Yes
        return {
            "signal_gap": bid - poly,
            "k_buy": 1 - bid, "k_sell": 1 - ask,
            "p_buy": poly + slip, "p_sell": poly - slip,
        }
    else:                # Poly rich: hold Kalshi Yes + Polymarket No
        return {
            "signal_gap": poly - ask,
            "k_buy": ask, "k_sell": bid,
            "p_buy": (1 - poly) + slip, "p_sell": (1 - poly) - slip,
        }


def candidates_for_pair(key, p, threshold, slip, fee_model):
    g = p["grid"]
    if g.empty:
        return [], {"signals": 0, "confirmed": 0}
    ts = g.index
    contiguous = np.r_[False, (ts[1:] - ts[:-1]) == pd.Timedelta("1min")]
    volume = g["kalshi_volume"].to_numpy()
    out, n_signals, n_confirmed = [], 0, 0
    for direction in (1, -1):
        px = leg_prices(g, direction, slip)
        hot = np.nan_to_num(px["signal_gap"], nan=-1) > threshold + 1e-9
        run_start = hot & ~(np.r_[False, hot[:-1]] & contiguous)
        run_id = np.cumsum(run_start)
        idx = np.arange(len(g))
        start_idx = np.maximum.accumulate(np.where(run_start, idx, 0))
        pos_in_run = idx - start_idx
        long_enough = pd.Series(pos_in_run[hot]).groupby(run_id[hot]).max() >= ENTRY_DWELL_MINUTES - 1
        n_signals += int(long_enough.sum())
        can_enter = hot & (pos_in_run >= ENTRY_DWELL_MINUTES - 1) & (volume > 0)
        entries = pd.Series(idx[can_enter]).groupby(run_id[can_enter]).first().to_numpy()
        n_confirmed += len(entries)
        if not len(entries):
            continue
        exit_net = (
            px["k_sell"] + px["p_sell"]
            - kalshi_fee_unrounded(px["k_sell"]) - poly_fee(px["p_sell"], 1, fee_model)
        )
        exit_ok_idx = np.flatnonzero(np.nan_to_num(exit_net, nan=-1) > 1.0 + PROFIT_EXIT_BUFFER)
        for i in entries:
            j = np.searchsorted(exit_ok_idx, i + 1)
            early = j < len(exit_ok_idx)
            e = exit_ok_idx[j] if early else None
            out.append({
                "pair": key, "event": p["event"], "event_key": p["event_key"], "team": p["team"],
                "direction": direction,
                "entry_ts": ts[i],
                "entry_gap": px["signal_gap"][i],
                "k_entry": px["k_buy"][i], "p_entry": px["p_buy"][i],
                "exit_type": "early_exit" if early else "hold_to_resolution",
                "exit_ts": ts[e] if early else p["resolution_ts"],
                "k_exit": px["k_sell"][e] if early else None,
                "p_exit": px["p_sell"][e] if early else None,
            })
    return out, {"signals": n_signals, "confirmed": n_confirmed}


def bankroll_pass(cands, fee_model):
    cands = sorted(cands, key=lambda c: c["entry_ts"])
    cash, locked = STARTING_BANKROLL, 0.0
    open_pos, trades, skipped = [], [], 0
    curve = [(cands[0]["entry_ts"] if cands else RUN_TS, cash, 0.0)]

    def release_until(ts):
        nonlocal cash, locked, open_pos
        still = []
        for pos in sorted(open_pos, key=lambda q: q["exit_ts"]):
            if pos["exit_ts"] <= ts:
                cash += pos["capital"] + pos["pnl"]
                locked -= pos["capital"]
                curve.append((pos["exit_ts"], cash, locked))
            else:
                still.append(pos)
        open_pos = still

    for c in cands:
        release_until(c["entry_ts"])
        sub_budget = cash * POSITION_SIZE_PCT / 2
        contracts = int(min(sub_budget / c["k_entry"], sub_budget / c["p_entry"]))
        if contracts < 1:
            skipped += 1
            continue
        entry_cost = contracts * (c["k_entry"] + c["p_entry"])
        k_fee_in = fees.kalshi_taker_fee(c["k_entry"], contracts)
        p_fee_in = float(poly_fee(c["p_entry"], contracts, fee_model))
        if c["exit_type"] == "early_exit":
            proceeds = contracts * (c["k_exit"] + c["p_exit"])
            k_fee_out = fees.kalshi_taker_fee(c["k_exit"], contracts)
            p_fee_out = float(poly_fee(c["p_exit"], contracts, fee_model))
        else:
            proceeds, k_fee_out, p_fee_out = contracts * 1.0, 0.0, 0.0
        capital = entry_cost + k_fee_in + p_fee_in
        total_fees = k_fee_in + p_fee_in + k_fee_out + p_fee_out
        pnl = proceeds - k_fee_out - p_fee_out - capital
        trades.append({
            **c, "contracts": contracts, "capital": capital, "entry_cost": entry_cost,
            "kalshi_fees": k_fee_in + k_fee_out, "poly_fees": p_fee_in + p_fee_out, "total_fees": total_fees,
            "pnl": pnl, "roi": pnl / capital,
            "open_at_run_end": c["exit_ts"] > RUN_TS,
            "holding_days": (min(c["exit_ts"], RUN_TS) - c["entry_ts"]).total_seconds() / 86400,
        })
        cash -= capital
        locked += capital
        open_pos.append({"exit_ts": c["exit_ts"], "capital": capital, "pnl": pnl})
        curve.append((c["entry_ts"], cash, locked))

    release_until(RUN_TS)
    locked_capital = sum(q["capital"] for q in open_pos)
    locked_pnl = sum(q["pnl"] for q in open_pos)
    return pd.DataFrame(trades), skipped, cash, locked_capital, locked_pnl, pd.DataFrame(curve, columns=["ts", "cash", "locked"])


def run_backtest(threshold=ENTRY_GAP_THRESHOLD, slip=POLY_SLIPPAGE, fee_model=POLY_FEE_MODEL):
    cands, funnel = [], {"signals": 0, "confirmed": 0}
    for key, p in PAIRS.items():
        c, f = candidates_for_pair(key, p, threshold, slip, fee_model)
        cands += c
        funnel = {k: funnel[k] + f[k] for k in funnel}
    trades, skipped, cash, locked_capital, locked_pnl, curve = bankroll_pass(cands, fee_model)
    realized = trades.loc[~trades["open_at_run_end"], "pnl"].sum() if len(trades) else 0.0
    equity = cash + locked_capital + locked_pnl
    return {
        "trades": trades, "curve": curve, "funnel": funnel, "skipped": skipped, "cash": cash,
        "locked_capital": locked_capital, "locked_pnl": locked_pnl, "realized_pnl": realized,
        "equity": equity, "roi": equity / STARTING_BANKROLL - 1,
    }


t0 = time.time()
BASE = run_backtest()
trades_df = BASE["trades"]
print(f"Base case ran in {time.time() - t0:,.1f}s: {len(trades_df):,} trades")
Base case ran in 2.8s: 45 trades

Results

In [6]:
f = BASE["funnel"]
early = trades_df[trades_df["exit_type"] == "early_exit"]
held = trades_df[trades_df["exit_type"] == "hold_to_resolution"]

metrics = pd.DataFrame({
    "Metric": [
        "Signals (gap > threshold for >= 2 min)", "Kalshi-confirmed signals", "Confirmation rate",
        "Skipped: not enough available cash", "Trades executed",
        "  ...exited early (closed)", "  ...held to resolution (open until Feb 2027)",
        "Win rate (PnL > 0, held trades at guaranteed payout)",
        "Realized PnL (closed trades)", "Locked-in PnL (held trades, paid at resolution)", "Total PnL",
        "Fees paid (Kalshi + Polymarket)",
        "Starting bankroll", "Cash available at run end", "Capital locked until resolution",
        "Ending equity (cash + locked capital + locked-in PnL)", "Overall ROI",
        "Avg holding time, early exits",
    ],
    "Value": [
        f"{f['signals']:,}", f"{f['confirmed']:,}", f"{f['confirmed'] / f['signals']:.1%}" if f["signals"] else "n/a",
        f"{BASE['skipped']:,}", f"{len(trades_df):,}",
        f"{len(early):,}", f"{len(held):,}",
        f"{(trades_df['pnl'] > 0).mean():.1%}",
        f"${BASE['realized_pnl']:,.2f}", f"${BASE['locked_pnl']:,.2f}", f"${trades_df['pnl'].sum():,.2f}",
        f"${trades_df['total_fees'].sum():,.2f}",
        f"${STARTING_BANKROLL:,.2f}", f"${BASE['cash']:,.2f}", f"${BASE['locked_capital']:,.2f}",
        f"${BASE['equity']:,.2f}", f"{BASE['roi']:.1%}",
        f"{early['holding_days'].mean():.1f} days" if len(early) else "n/a",
    ],
})
display(Markdown("#### Aggregate Backtest Metrics (base case)"))
display(metrics)

Aggregate Backtest Metrics (base case)

Metric Value
0 Signals (gap > threshold for >= 2 min) 580
1 Kalshi-confirmed signals 45
2 Confirmation rate 7.8%
3 Skipped: not enough available cash 0
4 Trades executed 45
5 ...exited early (closed) 26
6 ...held to resolution (open until Feb 2027) 19
7 Win rate (PnL > 0, held trades at guaranteed p... 100.0%
8 Realized PnL (closed trades) $84.74
9 Locked-in PnL (held trades, paid at resolution) $37.43
10 Total PnL $122.16
11 Fees paid (Kalshi + Polymarket) $37.86
12 Starting bankroll $1,000.00
13 Cash available at run end $480.16
14 Capital locked until resolution $604.57
15 Ending equity (cash + locked capital + locked-... $1,122.16
16 Overall ROI 12.2%
17 Avg holding time, early exits 42.8 days
In [7]:
def group_table(by):
    return trades_df.groupby(by, sort=False).agg(
        trades=("pnl", "size"),
        early_exits=("exit_type", lambda s: int((s == "early_exit").sum())),
        held=("exit_type", lambda s: int((s == "hold_to_resolution").sum())),
        win_rate=("pnl", lambda s: round((s > 0).mean(), 3)),
        realized_pnl=("pnl", lambda s: round(s[trades_df.loc[s.index, "exit_type"] == "early_exit"].sum(), 2)),
        locked_pnl=("pnl", lambda s: round(s[trades_df.loc[s.index, "exit_type"] == "hold_to_resolution"].sum(), 2)),
        total_pnl=("pnl", lambda s: round(s.sum(), 2)),
        fees=("total_fees", lambda s: round(s.sum(), 2)),
        capital_deployed=("capital", lambda s: round(s.sum(), 2)),
    )


display(Markdown("#### By Event"))
display(group_table("event"))
display(Markdown("#### By Direction"))
display(group_table(trades_df["direction"].map({1: "Kalshi rich (K-No + P-Yes)", -1: "Poly rich (K-Yes + P-No)"})))
for ev in EVENTS.values():
    t = trades_df[trades_df["event"] == ev["label"]]
    display(Markdown(f"#### {ev['label']} — by team"))
    if t.empty:
        display(Markdown("No trades."))
        continue
    display(group_table(trades_df.loc[t.index, "team"]).sort_values("total_pnl", ascending=False))

By Event

trades early_exits held win_rate realized_pnl locked_pnl total_pnl fees capital_deployed
event
NFC Champion 29 19 10 1.0 56.71 16.42 73.13 28.28 1166.73
AFC Champion 13 6 7 1.0 17.81 19.74 37.55 8.56 605.28
Super Bowl Champion 3 1 2 1.0 10.22 1.26 11.48 1.02 71.56

By Direction

trades early_exits held win_rate realized_pnl locked_pnl total_pnl fees capital_deployed
direction
Poly rich (K-Yes + P-No) 41 22 19 1.0 76.80 37.43 114.22 33.98 1710.82
Kalshi rich (K-No + P-Yes) 4 4 0 1.0 7.94 0.00 7.94 3.88 132.74

AFC Champion — by team

trades early_exits held win_rate realized_pnl locked_pnl total_pnl fees capital_deployed
team
Indianapolis Colts 7 0 7 1.0 0.00 19.74 19.74 1.74 252.26
Baltimore Ravens 2 2 0 1.0 4.53 0.00 4.53 2.46 92.31
Jacksonville Jaguars 1 1 0 1.0 3.84 0.00 3.84 1.20 68.42
Las Vegas Raiders 1 1 0 1.0 3.41 0.00 3.41 0.42 62.97
Houston Texans 1 1 0 1.0 3.11 0.00 3.11 0.99 60.12
Kansas City Chiefs 1 1 0 1.0 2.93 0.00 2.93 1.75 69.20

NFC Champion — by team

trades early_exits held win_rate realized_pnl locked_pnl total_pnl fees capital_deployed
team
Los Angeles Rams 14 13 1 1.0 46.09 2.31 48.40 25.36 728.67
Carolina Panthers 8 0 8 1.0 0.00 9.70 9.70 0.74 187.30
Arizona Cardinals 1 0 1 1.0 0.00 4.41 4.41 0.24 88.59
New Orleans Saints 2 2 0 1.0 3.39 0.00 3.39 0.40 57.80
Minnesota Vikings 1 1 0 1.0 3.00 0.00 3.00 0.50 50.20
Dallas Cowboys 2 2 0 1.0 2.46 0.00 2.46 0.38 21.89
Philadelphia Eagles 1 1 0 1.0 1.76 0.00 1.76 0.67 32.28

Super Bowl Champion — by team

trades early_exits held win_rate realized_pnl locked_pnl total_pnl fees capital_deployed
team
Chicago Bears 1 1 0 1.0 10.22 0.00 10.22 0.73 41.82
Seattle Seahawks 2 0 2 1.0 0.00 1.26 1.26 0.29 29.74

Every Trade

k_entry / p_entry are the prices paid per contract on each leg, with Polymarket including slippage. k_exit / p_exit are blank for positions held to resolution.

In [8]:
all_trades = trades_df[[
    "event", "team", "direction", "entry_ts", "entry_gap", "k_entry", "p_entry", "contracts",
    "exit_type", "exit_ts", "k_exit", "p_exit", "capital", "total_fees", "pnl", "roi",
]].copy()
all_trades["direction"] = all_trades["direction"].map({1: "Kalshi rich", -1: "Poly rich"})
all_trades["entry_ts"] = all_trades["entry_ts"].dt.strftime("%Y-%m-%d %H:%M")
all_trades["exit_ts"] = all_trades["exit_ts"].dt.strftime("%Y-%m-%d %H:%M")
display(all_trades.round({"entry_gap": 4, "k_entry": 3, "p_entry": 4, "k_exit": 3, "p_exit": 4,
                          "capital": 2, "total_fees": 2, "pnl": 2, "roi": 3}).reset_index(drop=True))
event team direction entry_ts entry_gap k_entry p_entry contracts exit_type exit_ts k_exit p_exit capital total_fees pnl roi
0 NFC Champion Los Angeles Rams Poly rich 2026-02-23 22:43 0.0750 0.15 0.7800 128 early_exit 2026-03-07 18:55 0.15 0.8650 121.03 3.66 7.22 0.060
1 NFC Champion Arizona Cardinals Poly rich 2026-02-24 18:55 0.0550 0.01 0.9400 93 hold_to_resolution 2027-02-08 15:00 NaN NaN 88.59 0.24 4.41 0.050
2 NFC Champion Los Angeles Rams Poly rich 2026-02-24 23:04 0.0750 0.15 0.7800 101 early_exit 2026-03-07 18:55 0.15 0.8650 95.51 2.90 5.69 0.060
3 NFC Champion Los Angeles Rams Poly rich 2026-02-26 15:45 0.0650 0.15 0.7900 87 early_exit 2026-03-07 18:55 0.15 0.8650 83.11 2.46 4.06 0.049
4 AFC Champion Indianapolis Colts Poly rich 2026-02-26 19:11 0.1250 0.04 0.8400 72 hold_to_resolution 2027-02-08 15:00 NaN NaN 63.91 0.55 8.09 0.127
5 AFC Champion Houston Texans Poly rich 2026-03-03 22:21 0.0600 0.08 0.8650 63 early_exit 2026-03-08 19:11 0.07 0.9400 60.12 0.99 3.11 0.052
6 NFC Champion Minnesota Vikings Poly rich 2026-03-05 18:17 0.0625 0.03 0.9125 53 early_exit 2026-09-13 17:50 0.05 0.9585 50.20 0.50 3.00 0.060
7 AFC Champion Baltimore Ravens Poly rich 2026-03-07 14:07 0.0600 0.14 0.8050 54 early_exit 2026-04-23 18:55 0.13 0.8900 51.81 1.38 2.67 0.051
8 AFC Champion Indianapolis Colts Poly rich 2026-03-07 15:35 0.1035 0.04 0.8615 44 hold_to_resolution 2027-02-08 15:00 NaN NaN 39.97 0.30 4.03 0.101
9 AFC Champion Baltimore Ravens Poly rich 2026-03-07 16:22 0.0550 0.14 0.8100 42 early_exit 2026-04-23 18:55 0.13 0.8900 40.50 1.08 1.86 0.046
10 AFC Champion Kansas City Chiefs Kalshi rich 2026-03-08 02:32 0.0550 0.86 0.0900 72 early_exit 2026-03-09 18:33 0.86 0.1550 69.20 1.75 2.93 0.042
11 AFC Champion Jacksonville Jaguars Poly rich 2026-03-09 06:56 0.0650 0.09 0.8500 72 early_exit 2026-04-12 01:41 0.07 0.9400 68.42 1.20 3.84 0.056
12 AFC Champion Las Vegas Raiders Poly rich 2026-03-10 06:34 0.0545 0.02 0.9305 66 early_exit 2026-09-28 02:01 0.03 0.9785 62.97 0.42 3.41 0.054
13 NFC Champion Los Angeles Rams Poly rich 2026-03-10 17:32 0.0700 0.16 0.7750 71 early_exit 2026-05-11 01:18 0.15 0.8650 67.53 2.08 3.60 0.053
14 NFC Champion Carolina Panthers Poly rich 2026-03-16 21:19 0.0530 0.02 0.9320 52 hold_to_resolution 2027-02-08 15:00 NaN NaN 49.69 0.19 2.31 0.046
15 AFC Champion Indianapolis Colts Poly rich 2026-03-17 11:11 0.0730 0.04 0.8920 49 hold_to_resolution 2027-02-08 15:00 NaN NaN 45.97 0.30 3.03 0.066
16 NFC Champion Carolina Panthers Poly rich 2026-03-17 14:47 0.0605 0.02 0.9245 42 hold_to_resolution 2027-02-08 15:00 NaN NaN 39.82 0.16 2.18 0.055
17 AFC Champion Indianapolis Colts Poly rich 2026-03-18 02:02 0.0545 0.04 0.9105 38 hold_to_resolution 2027-02-08 15:00 NaN NaN 36.33 0.21 1.67 0.046
18 NFC Champion New Orleans Saints Poly rich 2026-03-18 13:35 0.0625 0.02 0.9225 34 early_exit 2026-09-22 22:31 0.03 0.9760 32.17 0.22 1.94 0.060
19 AFC Champion Indianapolis Colts Poly rich 2026-03-19 20:56 0.0535 0.04 0.9115 31 hold_to_resolution 2027-02-08 15:00 NaN NaN 29.67 0.17 1.33 0.045
20 NFC Champion New Orleans Saints Poly rich 2026-03-20 04:06 0.0595 0.02 0.9255 27 early_exit 2026-09-22 22:31 0.03 0.9760 25.63 0.18 1.45 0.057
21 NFC Champion Carolina Panthers Poly rich 2026-03-20 08:50 0.0535 0.02 0.9315 24 hold_to_resolution 2027-02-08 15:00 NaN NaN 22.93 0.09 1.07 0.047
22 AFC Champion Indianapolis Colts Poly rich 2026-03-20 12:05 0.0535 0.04 0.9115 22 hold_to_resolution 2027-02-08 15:00 NaN NaN 21.05 0.12 0.95 0.045
23 NFC Champion Carolina Panthers Poly rich 2026-03-20 14:30 0.0505 0.02 0.9345 19 hold_to_resolution 2027-02-08 15:00 NaN NaN 18.20 0.07 0.80 0.044
24 NFC Champion Carolina Panthers Poly rich 2026-03-22 13:36 0.0655 0.02 0.9195 18 hold_to_resolution 2027-02-08 15:00 NaN NaN 16.98 0.07 1.02 0.060
25 AFC Champion Indianapolis Colts Poly rich 2026-03-23 00:12 0.0505 0.04 0.9145 16 hold_to_resolution 2027-02-08 15:00 NaN NaN 15.36 0.09 0.64 0.041
26 NFC Champion Carolina Panthers Poly rich 2026-04-06 02:52 0.0645 0.02 0.9205 14 hold_to_resolution 2027-02-08 15:00 NaN NaN 13.22 0.05 0.78 0.059
27 NFC Champion Dallas Cowboys Kalshi rich 2026-04-10 15:45 0.0595 0.92 0.0255 13 early_exit 2026-04-27 22:55 0.92 0.1490 12.37 0.21 1.40 0.113
28 NFC Champion Carolina Panthers Poly rich 2026-04-11 02:17 0.0645 0.02 0.9205 11 hold_to_resolution 2027-02-08 15:00 NaN NaN 10.39 0.05 0.61 0.059
29 NFC Champion Dallas Cowboys Kalshi rich 2026-04-11 03:16 0.0595 0.92 0.0255 10 early_exit 2026-04-27 22:55 0.92 0.1490 9.52 0.17 1.06 0.112
30 NFC Champion Carolina Panthers Poly rich 2026-04-12 16:02 0.0645 0.02 0.9205 17 hold_to_resolution 2027-02-08 15:00 NaN NaN 16.06 0.07 0.94 0.059
31 Super Bowl Champion Seattle Seahawks Poly rich 2026-04-21 02:23 0.0550 0.08 0.8700 16 hold_to_resolution 2027-02-14 23:30 NaN NaN 15.35 0.15 0.65 0.042
32 Super Bowl Champion Seattle Seahawks Poly rich 2026-04-22 03:17 0.0550 0.08 0.8700 15 hold_to_resolution 2027-02-14 23:30 NaN NaN 14.39 0.14 0.61 0.042
33 NFC Champion Los Angeles Rams Poly rich 2026-05-02 22:28 0.1100 0.16 0.7350 32 early_exit 2026-05-11 01:18 0.15 0.8650 29.20 0.98 2.86 0.098
34 NFC Champion Los Angeles Rams Poly rich 2026-05-03 19:58 0.1000 0.16 0.7450 27 early_exit 2026-05-11 01:18 0.15 0.8650 24.90 0.83 2.14 0.086
35 NFC Champion Los Angeles Rams Poly rich 2026-05-22 19:40 0.0550 0.16 0.7900 39 early_exit 2026-06-02 03:34 0.24 0.7900 37.67 1.36 1.76 0.047
36 NFC Champion Los Angeles Rams Poly rich 2026-06-01 17:21 0.0600 0.21 0.7350 37 early_exit 2026-06-02 03:34 0.24 0.7900 35.69 1.44 1.71 0.048
37 NFC Champion Los Angeles Rams Poly rich 2026-06-01 17:28 0.0550 0.24 0.7100 33 early_exit 2026-06-02 03:34 0.24 0.7900 32.07 1.35 1.28 0.040
38 NFC Champion Los Angeles Rams Poly rich 2026-06-01 17:36 0.0550 0.25 0.7000 29 early_exit 2026-06-02 03:34 0.24 0.7900 28.20 1.21 1.11 0.039
39 NFC Champion Los Angeles Rams Poly rich 2026-06-02 14:03 0.0550 0.25 0.7000 45 early_exit 2026-06-07 07:51 0.24 0.7950 43.76 1.86 1.96 0.045
40 NFC Champion Los Angeles Rams Poly rich 2026-06-05 12:53 0.2150 0.25 0.5400 51 early_exit 2026-06-07 07:51 0.24 0.7950 41.66 2.35 10.15 0.244
41 NFC Champion Los Angeles Rams Kalshi rich 2026-06-07 16:20 0.0550 0.76 0.1900 43 early_exit 2026-06-29 13:56 0.75 0.3000 41.65 1.75 2.55 0.061
42 NFC Champion Philadelphia Eagles Poly rich 2026-06-24 23:19 0.0670 0.10 0.8380 34 early_exit 2026-07-08 03:38 0.09 0.9195 32.28 0.67 1.76 0.055
43 NFC Champion Los Angeles Rams Poly rich 2026-07-08 21:41 0.0750 0.25 0.6800 49 hold_to_resolution 2027-02-08 15:00 NaN NaN 46.69 1.12 2.31 0.049
44 Super Bowl Champion Chicago Bears Poly rich 2026-09-27 00:15 0.2100 0.03 0.7650 52 early_exit 2026-10-11 17:31 0.05 0.9555 41.82 0.73 10.22 0.244

Bankroll Over Time

The blue line is cash available to open new trades. The shaded area is capital sitting in open positions. Early exits return their capital (plus PnL) as soon as they close. Held positions don't return anything until resolution in February 2027, so every held trade permanently shrinks the cash available for the rest of the backtest.

In [9]:
curve = BASE["curve"].sort_values("ts", kind="stable")
fig, ax = plt.subplots(figsize=(16, 6))
ax.fill_between(curve["ts"], curve["cash"], curve["cash"] + curve["locked"], step="post", color="#e08a2c", alpha=0.35,
                label="capital in open positions")
ax.plot(curve["ts"], curve["cash"], drawstyle="steps-post", color="#3b6fb6", linewidth=1.6, label="cash available")
ax.axhline(STARTING_BANKROLL, color="gray", linestyle="--", linewidth=1, label=f"starting bankroll (${STARTING_BANKROLL:,.0f})")
ax.set_ylabel("dollars")
ax.set_title("Cash available vs capital tied up in open positions — base case")
ax.legend(loc="upper right", fontsize=9)
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()
print(f"Lowest available cash: ${curve['cash'].min():,.2f} | Capital still locked at run end: ${BASE['locked_capital']:,.2f}")
No description has been provided for this image
Lowest available cash: $89.79 | Capital still locked at run end: $604.57
In [10]:
fig, axes = plt.subplots(1, 2, figsize=(16, 5))
axes[0].hist(trades_df["entry_gap"], bins=np.arange(0.05, trades_df["entry_gap"].max() + 0.01, 0.005), color="#3b6fb6")
axes[0].set_title("Executable gap at entry")
axes[0].set_xlabel("gap ($ per contract pair, before fees and slippage)")
axes[0].set_ylabel("trades")
axes[1].hist(trades_df["roi"] * 100, bins=40, color="#2a9d8f")
axes[1].axvline(0, color="black", linewidth=0.8)
axes[1].set_title("ROI per trade (held trades at guaranteed payout)")
axes[1].set_xlabel("ROI (%)")
for ax in axes:
    ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()
No description has been provided for this image

Sensitivity

The same engine re-run across entry threshold, Polymarket slippage, and Polymarket fee model. Schedule is the base 3% × min(p, 1 − p). Legacy is src/fees.py's 10% × p × (1 − p), the stricter reading. The row with zero slippage and legacy fees is the closest to the cost model of notebooks 04–12.

In [11]:
rows = []
for fee_model in ("schedule", "legacy"):
    for slip in (0.0, 0.005, 0.01):
        for threshold in (0.05, 0.07, 0.10):
            r = run_backtest(threshold, slip, fee_model)
            t = r["trades"]
            rows.append({
                "fee model": fee_model, "poly slippage": slip, "threshold": threshold,
                "trades": len(t),
                "early exits": int((t["exit_type"] == "early_exit").sum()) if len(t) else 0,
                "win rate": round((t["pnl"] > 0).mean(), 3) if len(t) else np.nan,
                "realized PnL": round(r["realized_pnl"], 2),
                "locked-in PnL": round(r["locked_pnl"], 2),
                "capital locked": round(r["locked_capital"], 2),
                "ending equity": round(r["equity"], 2),
                "ROI": f"{r['roi']:.1%}",
            })
sensitivity = pd.DataFrame(rows)
display(sensitivity)
fee model poly slippage threshold trades early exits win rate realized PnL locked-in PnL capital locked ending equity ROI
0 schedule 0.000 0.05 45 44 1.0 216.50 4.86 88.14 1221.37 22.1%
1 schedule 0.000 0.07 18 18 1.0 161.35 0.00 0.00 1161.35 16.1%
2 schedule 0.000 0.10 5 5 1.0 109.30 0.00 0.00 1109.30 10.9%
3 schedule 0.005 0.05 45 26 1.0 84.74 37.43 604.57 1122.16 12.2%
4 schedule 0.005 0.07 18 10 1.0 77.63 40.36 542.64 1117.99 11.8%
5 schedule 0.005 0.10 5 4 1.0 78.19 13.39 105.61 1091.58 9.2%
6 schedule 0.010 0.05 45 23 1.0 57.51 45.07 680.93 1102.58 10.3%
7 schedule 0.010 0.07 18 9 1.0 58.67 49.97 594.03 1108.64 10.9%
8 schedule 0.010 0.10 5 3 1.0 50.56 36.01 203.99 1086.57 8.7%
9 legacy 0.000 0.05 45 41 1.0 128.72 8.40 184.60 1137.12 13.7%
10 legacy 0.000 0.07 18 17 1.0 120.64 3.75 90.25 1124.39 12.4%
11 legacy 0.000 0.10 5 5 1.0 98.45 0.00 0.00 1098.45 9.8%
12 legacy 0.005 0.05 45 26 1.0 68.89 33.15 595.85 1102.04 10.2%
13 legacy 0.005 0.07 18 10 1.0 72.89 36.01 544.99 1108.90 10.9%
14 legacy 0.005 0.10 5 4 1.0 70.80 12.36 106.64 1083.16 8.3%
15 legacy 0.010 0.05 45 22 1.0 46.77 40.21 678.79 1086.97 8.7%
16 legacy 0.010 0.07 18 9 1.0 52.36 44.81 595.19 1097.17 9.7%
17 legacy 0.010 0.10 5 3 1.0 44.92 33.51 205.49 1078.43 7.8%

Summary

Figures as of the 2026-10-13 run. These markets are still open, so results will change on a re-run.

The strategy makes money in every configuration tested, but slowly, and a lot of the profit is locked up until February 2027. Base case (3% fee schedule, $0.005 Polymarket slippage, $0.05 threshold): 45 trades, a 100% win rate, and +$122.16 total PnL on $1,000 (+12.2%). Of that, $84.74 is realized from 26 early exits, and $37.43 is locked-in profit on 19 positions held to resolution. Those 19 positions tie up $604.57, about 60% of the bankroll, until 2027-02-08 to 2027-02-14. The +12.2% is only fully collected then, about a year after the first trade on 2026-02-23.

A 100% win rate is built into the design, not a sign of edge. A hedged pair bought for less than $1.00 after fees can't lose if held, and the exit rule only fires when selling beats holding. The real risks are ones a price-history backtest can't see: whether Polymarket's displayed price was actually available to buy, order-book depth, and rule differences at resolution.

Kalshi confirmation is the bottleneck. 580 signals, but only 45 (7.8%) had any Kalshi trade volume while the gap was open. That's below the 9–24% seen in notebooks 07–10. Kalshi trades these long-dated futures in only a small fraction of minutes.

Nearly all trades are Poly rich (41 of 45): buy cheap Kalshi Yes on a team, buy Polymarket No on the same team. With Kalshi's real bid used for the other direction, only 4 Kalshi rich trades qualified. This also means notebook 13's Kalshi Rich counts were generous: that notebook measured the gap against Kalshi's ask, while buying Kalshi No really costs 1 − bid.

Where the money came from:

  • **NFC +$73.13 (29 trades):** led by the LA Rams, +$48.40 on 14 trades, almost all early exits.
  • AFC +$37.55 (13 trades): Indianapolis made 7 trades, all held to resolution.
  • Super Bowl +$11.48 (3 trades).
  • Spike trades: two single trades entered on one-minute Polymarket spikes, Chicago (Super Bowl, 2026-09-27, +$10.22) and the Rams (2026-06-05, +$10.15). Together they are about a sixth of total PnL, and their fills are the least trustworthy in the backtest.

Fees took $37.86, about 24% of the $160 gross edge.

Sensitivity: ROI ranges from +7.8% (stricter 10% fee model, $0.01 slippage, $0.10 threshold) to +22.1% (fee schedule, no slippage, $0.05 threshold). Slippage mostly changes *how* trades end rather than whether they win. With zero slippage, 44 of 45 trades exit early and recycle capital. At $0.005 or more with a $0.05–0.07 threshold, around half are held and $540–680 stays locked. Raising the threshold to $0.07 or $0.10 cuts trades to 18 or 5 and moves ROI by only a point or two either way.

Bottom line: there is a small, consistent edge in the conference markets, mainly cheap Kalshi long shots against pricier Polymarket ones. Capital lock-up limits it, and so does thin Kalshi liquidity: 45 trades in 8 months. Before trading it live, the next check is live order-book depth on the Polymarket No side for the teams that recur here (Rams, Colts, Panthers).