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¶
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}")
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.
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))
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.
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
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")
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")
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()
]
)
)
Gap Series and Arbitrage Events¶
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()):,}")
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.
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()
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)."
))
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.
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)
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)
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()
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.
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)
)
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.