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_bidand 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_askand 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_v2andfeeSchedule: {rate: 0.03, exponent: 1, takerOnly: true}. That is modelled asrate × min(p, 1 − p)per share, the same reading offeeScheduleused 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 runssrc/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 thisENTRY_DWELL_MINUTES = 2PROFIT_EXIT_BUFFER = 0.00POSITION_SIZE_PCT = 0.20STARTING_BANKROLL = 1000POLY_SLIPPAGE = 0.005POLY_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 UTCSTOP_LOSS = None
Setup and Data¶
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}")
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.
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"
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")
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.
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()):,}")
Backtest Engine¶
The engine runs in three passes, like notebooks 04–12:
- 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.
- 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.
- 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.
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")
Results¶
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)
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))
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.
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))
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.
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}")
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()
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.
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)
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).