Prediction Market Arbitrage Feasibility Study¶
1. INTRODUCTION¶
Document Goal¶
Overall Goal: This study evaluates whether profitable arbitrage opportunities exist between matching event contracts listed on different prediction market platforms — and, if so, whether they appear often enough and last long enough to justify building a trading strategy around them.
What is arbitrage? Prediction markets let people trade contracts on the outcome of a real-world event — for example, whether the Federal Reserve will cut interest rates at its next meeting. When that same event is listed on more than one platform, each platform's traders can price the outcome slightly differently at any given moment. Arbitrage is the practice of profiting from that mismatch: buying the complementary side of the same event on each platform (e.g. "Yes" on one, "No" on the other) whenever the combined cost is less than the guaranteed $1.00 payout:
$$P_{\text{Poly}} + P_{\text{Kalshi}} < \$1.00$$
Since exactly one side always pays out $1.00 per contract at resolution, paying less than $1.00 combined locks in the difference as profit — regardless of how the event ultimately resolves. The risk is not in guessing the outcome; it's in whether the mismatch is real and large enough to survive the cost of trading, and whether it can be acted on before it closes. Trading fees on both platforms will be built directly into this calculation later in the notebook, since the gross mismatch above is only a starting signal — the fee-adjusted mismatch is what actually determines whether an opportunity is tradeable.
Arbitrage Strategy¶
At a high level, a live trading strategy built on this signal would operate as follows:
- Ingest live data from both platforms via real-time feeds, capturing prices as they update.
- Match event contracts across platforms, pairing each Kalshi market to the Polymarket market covering the same real-world outcome.
- Track quote prices for both the "Buy Yes" and "Buy No" side of each matched contract, on both platforms.
- Continuously calculate the combined cost of the complementary pair, monitoring how far the market sits from the $1.00 arbitrage threshold at all times.
- Execute automatically — the moment the combined, fee-adjusted cost drops below the threshold, purchase both legs simultaneously to lock in the position.
- Place limit sell orders on both legs at a price that captures the locked-in profit, freeing up capital immediately if the market moves back in the position's favor before the event resolves.
- Hold to resolution as a fallback — if the limit sells don't fill, hold both positions to close; the payout structure guarantees the profit at settlement regardless of the outcome.
Purpose of this Document¶
This document outlines the process for identifying potential arbitrage opportunities in historical data, and establishes the specific, measurable criteria a future trading strategy would need to meet before it's worth pursuing:
- Gross Spread — the raw price mismatch between matching contracts, before any costs are applied
- Net Edge — the spread that remains after trading fees and execution costs are subtracted
- Time-to-Convergence — how quickly a mismatch typically closes once it opens, and whether it lasts long enough to act on
- Timing to Close — how much time remains before a contract closes and resolves, since this shapes both how many opportunities are realistically catchable and how long capital would stay locked up
- Market Volume — the level of trading activity in each contract, used as a proxy for how much size can realistically be traded
- Order Book Depth & Slippage — how much capital can be deployed before the achievable price diverges from the quoted price
- Capital Lockup & Resolution Latency — how long funds remain tied up between trade execution and final settlement
Procedure¶
This notebook is organized around six stages, each building on the last:
- Environment Setup — establish the tools and infrastructure needed to pull data and run these calculations.
- Data Ingestion — pull historical data from both markets and examine each platform's unique data characteristics.
- Fee Integration — layer each platform's fee assumptions into the arbitrage calculation, turning the gross mismatch into a fee-adjusted, realistic figure.
- KPI Calculation — compute the feasibility metrics defined above across the historical data.
- Activity Simulation — simulate how the arbitrage strategy described above would have performed against that historical data.
- Visualization — chart the results to assess overall viability at a glance.
Setting Notebook Environment¶
Before any data can be pulled or analyzed, the notebook needs to load the software tools it depends on and set up where results will be saved along the way. This step doesn't produce any analysis on its own — it's the one-time setup everything that follows relies on.
import sys
import json
import datetime
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
sys.path.insert(0, str(Path.cwd().parent / "src"))
import kalshi_client
import polymarket_client
import market_matching
import fees
import events_config
sns.set_theme(style="whitegrid")
pd.set_option("display.width", 140)
DATA_RAW = Path.cwd().parent / "data" / "raw"
DATA_PROCESSED = Path.cwd().parent / "data" / "processed"
DATA_RAW.mkdir(parents=True, exist_ok=True)
DATA_PROCESSED.mkdir(parents=True, exist_ok=True)
Example Markets to Evaluate¶
To ground this study, we'll use one real historical pairing as the running example throughout the notebook:
- Polymarket: Fed decision in January
- Kalshi: Fed Meeting – January 2026
Both markets have already resolved. This is a backtest — using data from an event that has already played out to see whether an arbitrage strategy would actually have worked, before risking any capital on one that hasn't.
Why this pairing is a good fit:
Same structure, same resolver. Both platforms break the Fed's decision into the same set of discrete outcomes (e.g. "Maintains Rate," "Hike 25bps"), and both resolve off the identical source: the official FOMC statement. Because there's no gap in how "success" is defined between the two platforms, a price mismatch reflects a genuine market disagreement — not a difference in contract terms.
A high-information, high-liquidity event. The January meeting typically follows the Fed's December "dot plot" release, making it one of the most closely watched decisions of the year. That tends to mean deeper order books and less of the thin-liquidity problem that smaller economic contracts — like Bank of Canada decisions — can suffer from.
But also a highly efficient one. That same attention cuts the other way: institutional traders price this event so efficiently that the "No Change" contract can sit at $0.98+ for days or weeks beforehand. That keeps any arbitrage window narrow, which is exactly the kind of edge case this study needs to measure.
2. MARKET DISCOVERY¶
This section pulls a snapshot of historical data from both Kalshi and Polymarket to see what each platform actually returns, and to identify which parts of that data are useful for the KPIs defined earlier in this document.
Kalshi Snapshot Ingest¶
This step ingests a 14-day snapshot of historical data from Kalshi's Fed Meeting – January 2026: Maintains Rate contract (KXFEDDECISION-26JAN-H0), covering January 1–14, 2026, sampled at 1-hour ticks. That data is then visualized, followed by a table listing what was ingested and how relevant each piece of it is to the KPIs defined earlier in this document.
The charts below use only yes_ask, rather than the full price range, to keep the visualization simple. yes_ask is Kalshi's Quick Order — or "Quick Buy" — price: the price to instantly buy and guarantee a contract purchase, rather than waiting on a limit order to fill.
We will then go on to ingest the entire Kalshi dataset for this event — every outcome contract, over its full trading history — not just this 14-day sample.
kalshi_full_event_markets = kalshi_client.get_historical_markets("KXFEDDECISION-26JAN")
kalshi_full_event_data = {}
for m in kalshi_full_event_markets:
ticker = m["ticker"]
market_open = pd.to_datetime(m["open_time"], utc=True)
market_close = pd.to_datetime(m["close_time"], utc=True)
kalshi_full_event_data[ticker] = kalshi_client.get_candlesticks(
ticker, market_open, market_close, period_interval=1
)
from IPython.display import Markdown, display
KALSHI_TICKER = "KXFEDDECISION-26JAN-H0" # single sub-contract: "Fed Maintains Rate" — not the whole event
kalshi_window_start = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
kalshi_window_end = datetime.datetime(2026, 1, 14, tzinfo=datetime.timezone.utc)
kalshi_snapshot = kalshi_client.get_candlesticks(
KALSHI_TICKER, kalshi_window_start, kalshi_window_end, period_interval=60
)
print(f"{len(kalshi_snapshot)} rows, {kalshi_snapshot.index.min()} to {kalshi_snapshot.index.max()}")
# --- 24-hour sample (Jan 1), drawn from the same series as the 14-day panel below ---
sample = kalshi_snapshot.iloc[:24]
display(Markdown("#### Kalshi Price & Volume Charts"))
fig = plt.figure(figsize=(11, 10))
gs = fig.add_gridspec(3, 1, height_ratios=[1.3, 2, 1], hspace=0.5)
ax_14d = fig.add_subplot(gs[0])
ax_price = fig.add_subplot(gs[1])
ax_vol = fig.add_subplot(gs[2], sharex=ax_price)
# --- Top: 14-day yes_ask (Quick Order / "Quick Buy") price ---
ax_14d.plot(kalshi_snapshot.index, kalshi_snapshot["yes_ask"], color="darkorange", linewidth=1.2)
ax_14d.tick_params(axis="x", rotation=30)
ax_14d.set_title(
f'{KALSHI_TICKER} — "Fed Maintains Rate" — 14-Day Quick Order (yes_ask) Price\n'
f"Hourly ticks, {kalshi_window_start.date()} to {kalshi_window_end.date()}",
fontsize=11,
)
ax_14d.set_ylabel("yes_ask ($)")
# --- Middle: 24-hour yes_ask price, same series zoomed into Jan 1 ---
ax_price.plot(range(len(sample)), sample["yes_ask"], color="darkorange", linewidth=1.4, marker="o", markersize=3)
ax_price.set_title(
f'{KALSHI_TICKER} — "Fed Maintains Rate" contract only, one of five outcomes for this event\n'
f"24-Hour Quick Order (yes_ask) Price, hourly ticks, {sample.index[0].date()} 00:00–23:00 UTC",
fontsize=11,
)
ax_price.set_ylabel("yes_ask ($)")
# --- Bottom: 24-hour volume, aligned with the price panel's time axis ---
vol_bars = ax_vol.bar(range(len(sample)), sample["volume"], color="darkorange")
ax_vol.bar_label(vol_bars, fmt="{:,.0f}", fontsize=6, rotation=90, padding=2)
ax_vol.set_ylabel("volume")
ax_vol.set_xticks(range(len(sample)))
ax_vol.set_xticklabels([ts.strftime("%H:%M") for ts in sample.index], rotation=45, ha="right")
plt.tight_layout()
plt.show()
# --- Schema table: what's in kalshi_snapshot today, what Kalshi's raw ---
# --- candlestick schema also offers but we don't pull, and what isn't ---
# --- available from this endpoint at all. ---
schema_table = pd.DataFrame(
[
{"Field": "timestamp", "Brief Description": "End time of each hourly bar; the index everything else joins on.", "% Relevant to KPIs": 100, "Currently Ingested?": "Yes"},
{"Field": "price", "Brief Description": "Trade price at bar close, falling back to the bid/ask midpoint if no trade occurred that hour. The core value behind Gross Spread and Net Edge.", "% Relevant to KPIs": 100, "Currently Ingested?": "Yes"},
{"Field": "price_source", "Brief Description": "Flags whether price came from an actual trade or a bid/ask-midpoint fallback — a confidence signal on that bar's price.", "% Relevant to KPIs": 60, "Currently Ingested?": "Yes"},
{"Field": "yes_bid", "Brief Description": "Best available price to sell \"Yes\" at bar close — one side of the quoted spread.", "% Relevant to KPIs": 90, "Currently Ingested?": "Yes"},
{"Field": "yes_ask", "Brief Description": "The Quick Order (\"Buy Now\") price — the best available price to instantly buy \"Yes\" at bar close by taking liquidity rather than waiting on a limit order. Paired with yes_bid, this is our Order Book Depth & Slippage proxy.", "% Relevant to KPIs": 90, "Currently Ingested?": "Yes"},
{"Field": "volume", "Brief Description": "Number of contracts traded during the bar — directly the Market Volume KPI.", "% Relevant to KPIs": 100, "Currently Ingested?": "Yes"},
{"Field": "open_interest", "Brief Description": "Total contracts currently outstanding (not yet settled) — a liquidity signal, but not one of our named KPIs.", "% Relevant to KPIs": 25, "Currently Ingested?": "Yes"},
{"Field": "no_bid / no_ask", "Brief Description": "Best prices to sell/buy \"No\" instead of \"Yes.\" Not returned by the candlesticks endpoint (confirmed) — but always derivable, since Yes and No are complementary sides of the same contract: no_bid = $1 − yes_ask, no_ask = $1 − yes_bid. Only worth pulling directly if we ever trade the No side explicitly.", "% Relevant to KPIs": 15, "Currently Ingested?": "No — derivable, not a separate field"},
{"Field": "yes_bid_size_fp / yes_ask_size_fp", "Brief Description": "The number of contracts actually available at the best bid/ask — not just the price. This is the real gap in our current Depth & Slippage proxy, which only uses price width; size tells us how much could actually be filled before the price moves. Only available as a live/current-snapshot value on the metadata endpoint, not as a historical time series.", "% Relevant to KPIs": 70, "Currently Ingested?": "No — metadata endpoint, snapshot only, no history"},
{"Field": "price_open / high / low", "Brief Description": "Trade price range within the bar. Available via kalshi_client.get_candlesticks_raw() (used earlier to inspect the schema) but not pulled here, now that the chart uses yes_ask alone rather than a candlestick. Would sharpen Time-to-Convergence at finer granularity.", "% Relevant to KPIs": 40, "Currently Ingested?": "No — available, not pulled"},
{"Field": "price_mean", "Brief Description": "Kalshi-computed average trade price for the bar — an alternate \"typical price\" to close.", "% Relevant to KPIs": 20, "Currently Ingested?": "No — available, not pulled"},
{"Field": "price_previous", "Brief Description": "Last known trade price carried over from the prior bar when this bar had none — a continuity/convenience field.", "% Relevant to KPIs": 10, "Currently Ingested?": "No — available, not pulled"},
{"Field": "yes_bid / yes_ask open / high / low", "Brief Description": "Intrabar quote range — shows how much the spread (and the Quick Order price) moved within the hour, not just at bar-close.", "% Relevant to KPIs": 40, "Currently Ingested?": "No — available, not pulled"},
{"Field": "Number of trades / per-trade size / taker side", "Brief Description": "Not returned by the candlesticks endpoint. Confirmed available (public, no auth) from Kalshi's separate /historical/trades endpoint: individual trade_id, contract count, yes/no price, and taker side per trade — the real source for trade count and trade-size distribution, not just aggregate volume.", "% Relevant to KPIs": 60, "Currently Ingested?": "No — different endpoint"},
{"Field": "is_block_trade", "Brief Description": "Flags trades that were privately negotiated as a block rather than filled against the visible order book. Matters if we ever lean on trade volume as a depth signal, since block trades bypass normal book depth entirely.", "% Relevant to KPIs": 35, "Currently Ingested?": "No — different endpoint (/historical/trades)"},
]
)
pd.set_option("display.max_colwidth", None)
schema_table_styled = (
schema_table.style
.hide(axis="index")
.set_properties(**{"text-align": "left", "white-space": "normal", "vertical-align": "top", "padding": "10px"})
.set_properties(subset=["Brief Description"], **{"min-width": "480px"})
.set_properties(subset=["Field"], **{"min-width": "220px", "font-weight": "600"})
.set_table_styles(
[
{"selector": "table", "props": [("width", "100%"), ("table-layout", "fixed")]},
{"selector": "th", "props": [("text-align", "left"), ("background-color", "#f0f0f0"), ("padding", "10px")]},
]
)
)
display(Markdown("#### Kalshi Data Schema — Field Reference"))
display(schema_table_styled)
313 rows, 2026-01-01 00:00:00+00:00 to 2026-01-14 00:00:00+00:00
Kalshi Price & Volume Charts¶
C:\Users\mesim\AppData\Local\Temp\ipykernel_36668\3155026977.py:50: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect. plt.tight_layout()
Kalshi Data Schema — Field Reference¶
| Field | Brief Description | % Relevant to KPIs | Currently Ingested? |
|---|---|---|---|
| timestamp | End time of each hourly bar; the index everything else joins on. | 100 | Yes |
| price | Trade price at bar close, falling back to the bid/ask midpoint if no trade occurred that hour. The core value behind Gross Spread and Net Edge. | 100 | Yes |
| price_source | Flags whether price came from an actual trade or a bid/ask-midpoint fallback — a confidence signal on that bar's price. | 60 | Yes |
| yes_bid | Best available price to sell "Yes" at bar close — one side of the quoted spread. | 90 | Yes |
| yes_ask | The Quick Order ("Buy Now") price — the best available price to instantly buy "Yes" at bar close by taking liquidity rather than waiting on a limit order. Paired with yes_bid, this is our Order Book Depth & Slippage proxy. | 90 | Yes |
| volume | Number of contracts traded during the bar — directly the Market Volume KPI. | 100 | Yes |
| open_interest | Total contracts currently outstanding (not yet settled) — a liquidity signal, but not one of our named KPIs. | 25 | Yes |
| no_bid / no_ask | Best prices to sell/buy "No" instead of "Yes." Not returned by the candlesticks endpoint (confirmed) — but always derivable, since Yes and No are complementary sides of the same contract: no_bid = $1 − yes_ask, no_ask = $1 − yes_bid. Only worth pulling directly if we ever trade the No side explicitly. | 15 | No — derivable, not a separate field |
| yes_bid_size_fp / yes_ask_size_fp | The number of contracts actually available at the best bid/ask — not just the price. This is the real gap in our current Depth & Slippage proxy, which only uses price width; size tells us how much could actually be filled before the price moves. Only available as a live/current-snapshot value on the metadata endpoint, not as a historical time series. | 70 | No — metadata endpoint, snapshot only, no history |
| price_open / high / low | Trade price range within the bar. Available via kalshi_client.get_candlesticks_raw() (used earlier to inspect the schema) but not pulled here, now that the chart uses yes_ask alone rather than a candlestick. Would sharpen Time-to-Convergence at finer granularity. | 40 | No — available, not pulled |
| price_mean | Kalshi-computed average trade price for the bar — an alternate "typical price" to close. | 20 | No — available, not pulled |
| price_previous | Last known trade price carried over from the prior bar when this bar had none — a continuity/convenience field. | 10 | No — available, not pulled |
| yes_bid / yes_ask open / high / low | Intrabar quote range — shows how much the spread (and the Quick Order price) moved within the hour, not just at bar-close. | 40 | No — available, not pulled |
| Number of trades / per-trade size / taker side | Not returned by the candlesticks endpoint. Confirmed available (public, no auth) from Kalshi's separate /historical/trades endpoint: individual trade_id, contract count, yes/no price, and taker side per trade — the real source for trade count and trade-size distribution, not just aggregate volume. | 60 | No — different endpoint |
| is_block_trade | Flags trades that were privately negotiated as a block rather than filled against the visible order book. Matters if we ever lean on trade volume as a depth signal, since block trades bypass normal book depth entirely. | 35 | No — different endpoint (/historical/trades) |
Rules¶
kalshi_markets_meta = kalshi_client.get_historical_markets("KXFEDDECISION-26JAN")
kalshi_h0_meta = next(m for m in kalshi_markets_meta if m["ticker"] == KALSHI_TICKER)
display(Markdown(f"**Primary rule:** {kalshi_h0_meta['rules_primary']}"))
display(Markdown(f"**Secondary rule:** {kalshi_h0_meta['rules_secondary']}"))
Primary rule: If the Federal Reserve does a Hike of 0bps on January 28, 2026, then the market resolves to Yes.
Secondary rule: This market is mutually exclusive. Therefore, if the Federal Reserve hikes by 50bps, the 50bps market will resolve to Yes and the 25bps market will resolve to No. Only one bucket, at maximum, can resolve to Yes. Note 4/28/25: For the markets beginning after the May meeting, if a scheduled FOMC meeting is canceled and does not occur on its scheduled date, then the strike for "Fed maintains rate" will resolve to Yes and all others will resolve to No.
Contract Data¶
This data, held in Kalshi's database, describes the lifecycle of the event — when it opened, when and how it closed, and when it settled — rather than its price. The related fields are:
open_time— when the market began tradingclose_time— when the market stopped trading, once the outcome was effectively knownexpiration_time/expected_expiration_time/latest_expiration_time— the scheduled and actual expiration windowcan_close_early/early_close_condition— whether, and why, the market could resolve before its scheduled closeoccurrence_datetime— when the real-world event itself actually happenedstatus— the market's current lifecycle state (e.g. active, closed, finalized)result— the resolved outcome (yes/no)settlement_timer_seconds— Kalshi's stated delay before settling a resolved marketsettlement_ts— when funds actually settledcreated_time/updated_time— record creation and last-update bookkeeping timestamps
Kalshi Summary¶
Available tick granularity: Kalshi's historical candlestick endpoint supports exactly three options — 1 minute, 1 hour, and 1 day. There is no 1-second, 3-second, or 30-second tick available; sub-minute resolution isn't offered for historical data at all, only for live/current quotes. That caps how finely Time-to-Convergence can ever be measured from this endpoint, regardless of which of the three we choose to pull.
Key data worth carrying forward: Of everything cataloged in the schema table, these are the fields scoring 60% relevance or higher — the ones that should move forward into the real ingestion pipeline, because each either feeds a KPI directly or changes how much we can trust the numbers that do:
priceandtimestamp(100%) — the two values every KPI calculation is actually built on.volume(100%) — directly the Market Volume KPI; there's no substitute for it.yes_bidandyes_ask(90% each) — the quoted spread. Together they feed Gross Spread and Net Edge (via price) and are our current Order Book Depth & Slippage proxy (via their width).yes_bid_size_fp/yes_ask_size_fp(70%) — the actual contract size behind the best bid/ask, not just its price. This is the field that would turn our Depth & Slippage proxy into a real depth measurement, if it's ever exposed historically rather than as a live snapshot only.price_source(60%) — flags whether a bar's price came from an actual trade or a bid/ask-midpoint fallback. Without it, we'd be trusting synthetic-looking prices with no way to tell which ones are real.- Trade-level fields from
/historical/trades(60%) — trade count, per-trade size, and taker side. The only source of a genuine trade count rather than aggregate volume, and the basis for a more defensible read on both Market Volume and Depth.
Polymarket Snapshot Ingest¶
This step ingests a 14-day snapshot of historical data from Polymarket's Fed decision in January market, covering January 1–14, 2026, sampled at 1-hour ticks. That data is then visualized, followed by a table listing what was ingested and how relevant each piece of it is to the KPIs defined earlier in this document.
polymarket_event = polymarket_client.get_event_by_slug("fed-decision-in-january")
polymarket_full_event_data = {}
for market in polymarket_event["markets"]:
token_id = market_matching._polymarket_yes_token(market)
market_start = pd.to_datetime(market["startDate"], utc=True)
market_end = pd.to_datetime(market["closedTime"], utc=True)
polymarket_full_event_data[market["slug"]] = polymarket_client.get_prices_history(
token_id, market_start, market_end, fidelity=1
)
polymarket_market = next(m for m in polymarket_event["markets"] if "No change" in m["question"])
polymarket_window_start = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
polymarket_window_end = datetime.datetime(2026, 1, 14, tzinfo=datetime.timezone.utc)
# Sliced from polymarket_full_event_data (fidelity=1) rather than a separate
# fetch, then resampled to hourly to match the stated 1-hour-tick sample.
polymarket_snapshot = (
polymarket_full_event_data[polymarket_market["slug"]]
.loc[polymarket_window_start:polymarket_window_end]
.resample("1h")
.last()
.dropna()
)
print(f"{len(polymarket_snapshot)} rows, {polymarket_snapshot.index.min()} to {polymarket_snapshot.index.max()}")
print(f"actual granularity: {polymarket_client.actual_granularity_seconds(polymarket_snapshot):.0f}s")
# --- 14-day price history. No OHLC or volume exist for this endpoint, so ---
# --- unlike the Kalshi candlestick chart, this is a plain price line. ---
display(Markdown("#### 14-Day Price History"))
fig, ax = plt.subplots(figsize=(11, 4.5))
ax.plot(polymarket_snapshot.index, polymarket_snapshot["price"], color="steelblue", linewidth=1.2)
ax.set_title(
'Polymarket — "No change in Fed interest rates after January 2026 meeting?" (Yes)\n'
f"Hourly ticks, {polymarket_window_start.date()} to {polymarket_window_end.date()}",
fontsize=11,
)
ax.set_ylabel("price ($)")
plt.tight_layout()
plt.show()
# --- 24-hour sample, January 1st: price (hourly, from the full ingest) ---
# --- and volume (hourly-summed trade size, from the separate trades ---
# --- endpoint — prices-history itself has no volume field at all). ---
polymarket_24h_start = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
polymarket_24h_end = datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc)
polymarket_24h_index = pd.date_range(polymarket_24h_start, periods=24, freq="1h", tz="UTC")
polymarket_24h_price = (
polymarket_full_event_data[polymarket_market["slug"]]
.loc[polymarket_24h_start:polymarket_24h_end]
.resample("1h")
.last()
.reindex(polymarket_24h_index)
)
polymarket_24h_trades = polymarket_client.get_trades(
polymarket_market["conditionId"], polymarket_24h_start, polymarket_24h_end
)
polymarket_24h_volume = (
polymarket_24h_trades["size"].resample("1h").sum().reindex(polymarket_24h_index, fill_value=0)
)
display(Markdown("#### 24-Hour Sample — January 1, 2026"))
fig = plt.figure(figsize=(16, 6.5))
gs = fig.add_gridspec(2, 1, height_ratios=[2, 1], hspace=0.15)
ax_24h_price = fig.add_subplot(gs[0])
ax_24h_vol = fig.add_subplot(gs[1], sharex=ax_24h_price)
ax_24h_price.plot(range(24), polymarket_24h_price["price"], color="steelblue", marker="o", markersize=3, linewidth=1.2)
ax_24h_price.set_title(
'Polymarket — "No change in Fed interest rates after January 2026 meeting?" (Yes)\n'
"Hourly ticks, January 1, 2026",
fontsize=11,
)
ax_24h_price.set_ylabel("price ($)")
vol_bars = ax_24h_vol.bar(range(24), polymarket_24h_volume, color="steelblue")
ax_24h_vol.bar_label(vol_bars, fmt="{:,.0f}", fontsize=6, rotation=90, padding=2)
ax_24h_vol.set_ylabel("volume (contracts)")
ax_24h_vol.set_xticks(range(24))
ax_24h_vol.set_xticklabels([ts.strftime("%H:%M") for ts in polymarket_24h_index], rotation=45, ha="right")
plt.tight_layout()
plt.show()
# --- Schema table: what prices-history actually returns, and what it ---
# --- doesn't — for direct comparison against the Kalshi schema table. ---
polymarket_schema_table = pd.DataFrame(
[
{"Field": "timestamp (t)", "Brief Description": "Time of each price point. Points land at irregular sub-minute offsets even with fidelity=60 requested (e.g. 00:00:20, 01:00:16) rather than a clean hourly grid — worth noting before merge_asof-aligning against Kalshi's clean hourly bars.", "% Relevant to KPIs": 100, "Currently Ingested?": "Yes", "Also in Kalshi?": "Y"},
{"Field": "price (p)", "Brief Description": "The single price value at that timestamp. Unlike Kalshi, this endpoint doesn't distinguish trade price from quote price — it's just \"the price.\" The core value behind Gross Spread and Net Edge on the Polymarket side.", "% Relevant to KPIs": 100, "Currently Ingested?": "Yes", "Also in Kalshi?": "Y"},
{"Field": "bid / ask", "Brief Description": "Not returned by prices-history. The Gamma metadata endpoint does carry a live bestBid/bestAsk snapshot (and a computed spread field), but only as a current value, not a historical time series — same limitation as Kalshi's yes_bid_size_fp.", "% Relevant to KPIs": 90, "Currently Ingested?": "No — live snapshot only, no history", "Also in Kalshi?": "Y"},
{"Field": "volume", "Brief Description": "Not returned by prices-history. Aggregated ourselves above from individual trade sizes via the /trades endpoint; Gamma metadata also has lifetime and rolling-window totals (volume, volume1wk, volume1mo, volume1yr) but no per-bar time series.", "% Relevant to KPIs": 100, "Currently Ingested?": "Partial — derived from /trades, not a native field", "Also in Kalshi?": "Y"},
{"Field": "open_interest", "Brief Description": "Not offered anywhere in Polymarket's public API — no equivalent field exists on the Gamma market metadata or any other endpoint checked.", "% Relevant to KPIs": 25, "Currently Ingested?": "No — not offered by Polymarket at all", "Also in Kalshi?": "Y"},
{"Field": "price_mean (bar aggregate)", "Brief Description": "Not offered. Polymarket has no candle/bar aggregation endpoint at all — prices-history returns raw point-in-time ticks only, never an averaged price over an interval the way Kalshi's price.mean does.", "% Relevant to KPIs": 20, "Currently Ingested?": "No — not offered by Polymarket at all", "Also in Kalshi?": "Y"},
{"Field": "number of trades / per-trade size", "Brief Description": "Confirmed available: the public, unauthenticated data-api.polymarket.com/trades endpoint returns individual trade records (price, size, timestamp, BUY/SELL side, wallet, on-chain transactionHash) — used directly above to build the volume bar chart.", "% Relevant to KPIs": 60, "Currently Ingested?": "Yes — via get_trades()", "Also in Kalshi?": "Y"},
{"Field": "is_block_trade", "Brief Description": "Not present in the documented /trades response schema. No confirmed Polymarket equivalent to Kalshi's block-trade flag.", "% Relevant to KPIs": 35, "Currently Ingested?": "No — not offered (unconfirmed to exist)", "Also in Kalshi?": "Y"},
]
)
polymarket_schema_table_styled = (
polymarket_schema_table.style
.hide(axis="index")
.set_properties(**{"text-align": "left", "white-space": "normal", "vertical-align": "top", "padding": "10px"})
.set_properties(subset=["Brief Description"], **{"min-width": "480px"})
.set_properties(subset=["Field"], **{"min-width": "220px", "font-weight": "600"})
.set_table_styles(
[
{"selector": "table", "props": [("width", "100%"), ("table-layout", "fixed")]},
{"selector": "th", "props": [("text-align", "left"), ("background-color", "#f0f0f0"), ("padding", "10px")]},
]
)
)
display(Markdown("#### Polymarket Data Schema — Field Reference"))
display(polymarket_schema_table_styled)
312 rows, 2026-01-01 00:00:00+00:00 to 2026-01-13 23:00:00+00:00 actual granularity: 3600s
14-Day Price History¶
24-Hour Sample — January 1, 2026¶
C:\Users\mesim\AppData\Local\Temp\ipykernel_36668\1382377688.py:76: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect. plt.tight_layout()
Polymarket Data Schema — Field Reference¶
| Field | Brief Description | % Relevant to KPIs | Currently Ingested? | Also in Kalshi? |
|---|---|---|---|---|
| timestamp (t) | Time of each price point. Points land at irregular sub-minute offsets even with fidelity=60 requested (e.g. 00:00:20, 01:00:16) rather than a clean hourly grid — worth noting before merge_asof-aligning against Kalshi's clean hourly bars. | 100 | Yes | Y |
| price (p) | The single price value at that timestamp. Unlike Kalshi, this endpoint doesn't distinguish trade price from quote price — it's just "the price." The core value behind Gross Spread and Net Edge on the Polymarket side. | 100 | Yes | Y |
| bid / ask | Not returned by prices-history. The Gamma metadata endpoint does carry a live bestBid/bestAsk snapshot (and a computed spread field), but only as a current value, not a historical time series — same limitation as Kalshi's yes_bid_size_fp. | 90 | No — live snapshot only, no history | Y |
| volume | Not returned by prices-history. Aggregated ourselves above from individual trade sizes via the /trades endpoint; Gamma metadata also has lifetime and rolling-window totals (volume, volume1wk, volume1mo, volume1yr) but no per-bar time series. | 100 | Partial — derived from /trades, not a native field | Y |
| open_interest | Not offered anywhere in Polymarket's public API — no equivalent field exists on the Gamma market metadata or any other endpoint checked. | 25 | No — not offered by Polymarket at all | Y |
| price_mean (bar aggregate) | Not offered. Polymarket has no candle/bar aggregation endpoint at all — prices-history returns raw point-in-time ticks only, never an averaged price over an interval the way Kalshi's price.mean does. | 20 | No — not offered by Polymarket at all | Y |
| number of trades / per-trade size | Confirmed available: the public, unauthenticated data-api.polymarket.com/trades endpoint returns individual trade records (price, size, timestamp, BUY/SELL side, wallet, on-chain transactionHash) — used directly above to build the volume bar chart. | 60 | Yes — via get_trades() | Y |
| is_block_trade | Not present in the documented /trades response schema. No confirmed Polymarket equivalent to Kalshi's block-trade flag. | 35 | No — not offered (unconfirmed to exist) | Y |
Rules¶
Polymarket's data does not split rules into a primary/secondary pair the way Kalshi does. There's no rules_primary/rules_secondary field at all. Instead, the full resolution criteria live in a single description field on the market object — and that field is already part of the metadata we pulled during discovery (polymarket_market), so no separate scrape of the market page is needed; the text below comes directly from that existing ingest, not a fresh pull from the Polymarket event page.
display(Markdown(f"**Description / resolution criteria:** {polymarket_market['description']}"))
Description / resolution criteria: The FED interest rates are defined in this market by the upper bound of the target federal funds range. The decisions on the target federal fund range are made by the Federal Open Market Committee (FOMC) meetings.
This market will resolve to the amount of basis points the upper bound of the target federal funds rate is changed by versus the level it was prior to the Federal Reserve's January 2026 meeting.
If the target federal funds rate is changed to a level not expressed in the displayed options, the change will be rounded up to the nearest 25 and will resolve to the relevant bracket. (e.g. if there's a cut/increase of 12.5 bps it will be considered to be 25 bps)
The resolution source for this market is the FOMC’s statement after its meeting scheduled for January 27 - 28, 2026 according to the official calendar: https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm.
The level and change of the target federal funds rate is also published at the official website of the Federal Reserve at https://www.federalreserve.gov/monetarypolicy/openmarket.htm.
This market may resolve as soon as the FOMC’s statement for their January meeting with relevant data is issued. If no statement is released by the end date of the next scheduled meeting, this market will resolve to the "No change" bracket.
Contract Data¶
Like Kalshi's, this data describes the lifecycle of the event — but Polymarket's version is structured differently: several candidate "start" timestamps instead of one clean open_time, and no explicit settlement-delay timer, since resolution runs through Polymarket's UMA oracle process rather than a fixed internal clock the way Kalshi's settlement_timer_seconds is. The related fields, and where they are or aren't included in what we've already ingested:
createdAt/startDate/acceptingOrdersTimestamp— three different "start" timestamps: when the market record was created, its officially dated start, and when order acceptance actually began. Kalshi collapses this to oneopen_time.endDate— the scheduled/target close date (Kalshi'sexpiration_timeanalog).closedTime— when the market actually closed (Kalshi'sclose_timeanalog).umaEndDate— the timestamp Polymarket's UMA resolution process anchors to; matchedclosedTimeexactly for this contract.updatedAt— last time the record was updated — the closest available proxy for a settlement timestamp, though there's no dedicatedsettlement_ts-equivalent field the way Kalshi has one.active/closed/archived/ready/funded/acceptingOrders— several boolean lifecycle flags, where Kalshi uses a singlestatusstring.umaResolutionStatus— the state of the UMA oracle's resolution process (e.g. "resolved"), not the outcome itself.outcomePrices— where the actual resolved outcome is read from (e.g.["1", "0"]means "Yes" resolved true), rather than a dedicatedresultfield like Kalshi's.
All of these are already present in the market metadata pulled during discovery (polymarket_market) — nothing here requires a new fetch.
Polymarket Flat Price¶
The 14-day and 24-hour Polymarket price charts move noticeably less than Kalshi's — Jan 1 hourly price standard deviation was 0.0048 on Polymarket vs. 0.0128 on Kalshi, about 2.7x tighter. This was checked against a resampling artifact directly: pulling the raw, un-resampled tick data for the same day gives almost the same range (std 0.0047, $0.875–$0.895) as the hourly-resampled chart — so this isn't .resample("1h").last() smoothing away real intra-hour swings; the price genuinely didn't move much that day. That said, the general caveat still stands for future ingests: .last() always risks reflecting a stale, minutes-old tick rather than a fresh read whenever trading is thin in a given hour, so it's worth re-checking whenever the sampled window or contract changes.
The one visible move that day, looked into directly: the tick series sat flat at $0.875 through 6–8am, then jumped $0.875 → $0.880 → $0.885 within a single minute at 08:07–08:08 UTC. Pulling the actual trades (not just the price series) around that window shows real Yes-side buying already accumulating well before the visible jump — small buys from 07:31 onward, a 7,666-contract buy at 07:44, then 2,000- and 2,700-contract buys at 08:04 and 08:06. The more important finding: those trades were already executing at $0.88 starting at 07:31 UTC — 36 minutes before `prices-history` showed anything other than $0.875. The historical price endpoint doesn't track trades in real time; it lagged actual executions by over half an hour in this instance, which matters anywhere this data feeds a "what was the price at time T" calculation — it may not reflect what was actually tradeable at that moment. (Separately: pulling trades by market/conditionId returns both the Yes and No token's trades mixed together — the ~$0.11–0.13 rows in that raw pull are No-side trades, complementary to the ~$0.88 Yes trades, not a separate price level. Worth filtering by asset if trade-level data gets used more seriously later.)
Is this a recurring daily pattern? No. Checked across the full ~4-month history: of 12,171 total price-change events, the distribution by UTC hour is essentially flat — 386 to 606 changes per hour — and hour 8 actually has the fewest of any hour. There's no evidence Polymarket re-evaluates around 7–8am on a regular basis; Jan 1's timing looks coincidental.
When does volatility actually pick up? Not approaching Jan 1 or later — the opposite. The most volatile stretch in the contract's life was October 9 – November 21, well before this sample window, peaking on November 21 (single-day range $0.25 → $0.515, the largest swing in the whole series). From late December into January, volatility actually shrinks as the market converges toward near-certainty (climbing $0.85 → $0.99+ with small daily std) even as the number of price updates per day rises sharply (350+/day by mid-January vs. often under 50/day in the fall) — more frequent trading, smaller moves, as conviction builds ahead of the actual January 27–28 meeting.
Polymarket Summary¶
Available tick granularity, vs. Kalshi: Kalshi restricts you to exactly three fixed options — 1 minute, 1 hour, or 1 day — nothing in between and nothing finer. Polymarket's fidelity parameter, by contrast, accepts any integer number of minutes and honors it precisely: testing fidelity values of 1, 5, 15, 30, 60, and 360 on the same window each returned points spaced almost exactly that many minutes apart (e.g. fidelity=5 → points ~300 seconds apart, fidelity=60 → ~3600 seconds apart). In practice the finest achievable resolution still floors out at 1 minute — passing fidelity=0 or fidelity=0.5 silently returned the same 1-point-per-minute data as fidelity=1, so it doesn't go sub-minute — but Polymarket lets you dial in any coarser interval in between (5, 15, 45 minutes, etc.), where Kalshi jumps straight from 1 minute to 1 hour with no options in that range at all.
What actually matches Kalshi, and what can move forward: since this whole process depends on comparing the same thing across both platforms, only fields available on both sides in a genuinely comparable, historical, time-series form are usable going forward:
timestampandprice— a real historical time series on both platforms. This is the core pair the entire Gross Spread / Net Edge comparison is built on.- Volume — available on both, but not symmetrically: Kalshi returns it natively per candle; Polymarket has no such field and had to be derived by summing individual trades from a separate endpoint. Still usable, but sourced differently on each side and worth keeping distinct in code.
- Trade-level data (count, size) — both platforms expose this via a separate trades endpoint (Kalshi's
/historical/trades, Polymarket's/trades), so it's a genuine candidate to move forward, though only the Polymarket side has actually been wired into this notebook so far. - Everything else does not move forward as-is. Bid/ask exists on both platforms conceptually, but Polymarket's is a live snapshot only, not a historical series the way Kalshi's
yes_bid/yes_askis — so it can't be used for historical backtesting. Open interest, price_mean, and is_block_trade have no confirmed Polymarket equivalent at all.
On the flat-looking price: the 14-day and 24-hour Polymarket price charts move noticeably less than Kalshi's — Jan 1 hourly price standard deviation was 0.0048 on Polymarket vs. 0.0128 on Kalshi, about 2.7x tighter. This was checked against a resampling artifact directly: pulling the raw, un-resampled tick data for the same day gives almost the same range (std 0.0047, $0.875–$0.895) as the hourly-resampled chart — so this isn't .resample("1h").last() smoothing away real intra-hour swings; the price genuinely didn't move much that day. That said, the general caveat still stands for future ingests: .last() always risks reflecting a stale, minutes-old tick rather than a fresh read whenever trading is thin in a given hour, so it's worth re-checking whenever the sampled window or contract changes.
On volume: Polymarket had substantially more trading activity than Kalshi on the same day — 392,633 total contracts traded on Jan 1 vs. Kalshi's 143,898, roughly 2.7x more. That's a meaningful asymmetry for a Market Volume KPI meant to gauge how executable an arbitrage opportunity really is: liquidity isn't evenly split between the two legs of the trade.
3. Initial Arbitrage Alignment¶
This section runs an initial, high-level review of potential arbitrage between the two platforms, using the data already ingested above. Later sections will go into more detail — matching every outcome, computing fee-adjusted spreads, and simulating actual trades. For now, the goal is just to see, at a glance, whether the two markets look aligned enough that further analysis is even worth doing.
Market Price Overlay¶
This initial pass only looks at the Kalshi "Maintains Rate" and Polymarket "No change" contracts — the pair used throughout this notebook so far. Other outcome contracts are available on both platforms (see Market Discovery above), but a full cross-outcome comparison isn't the goal here; this is just an initial review.
It's also worth noting that both platforms' data ingested above is sampled at 1-minute ticks. That still leaves room for price variation and consolidation happening within any given minute — a 1-minute tick only gives a broad representation of the price at that moment, not a full record of everything that happened inside it, as we already saw when investigating the Polymarket flat-price question, where real trades were executing minutes before the tick series reflected them.
The chart below overlays 14 days of price history from both platforms directly on top of each other, to get a first visual read on how closely they track.
overlay_start = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
overlay_end = datetime.datetime(2026, 1, 14, tzinfo=datetime.timezone.utc)
kalshi_overlay = kalshi_full_event_data[KALSHI_TICKER].loc[overlay_start:overlay_end]
polymarket_overlay = polymarket_full_event_data[polymarket_market["slug"]].loc[overlay_start:overlay_end]
display(Markdown("#### 14-Day Price History — Kalshi vs. Polymarket, Overlaid"))
fig, ax = plt.subplots(figsize=(16, 5))
ax.plot(kalshi_overlay.index, kalshi_overlay["yes_ask"], color="red", linewidth=0.8, label="Kalshi (yes_ask / Quick Buy)")
ax.plot(polymarket_overlay.index, polymarket_overlay["price"], color="blue", linewidth=0.8, label="Polymarket")
ax.set_xlabel("date")
ax.set_ylabel("price ($)")
ax.set_title('"Fed Maintains Rate" / "No change" — 1-minute ticks, Jan 1–14, 2026')
ax.legend()
plt.tight_layout()
plt.show()
14-Day Price History — Kalshi vs. Polymarket, Overlaid¶
display(Markdown("#### Frequency of Price Change per Day — Polymarket vs. Kalshi"))
kalshi_full_price = kalshi_full_event_data[KALSHI_TICKER]["price"]
kalshi_daily_changes = (kalshi_full_price != kalshi_full_price.shift(1)).resample("1D").sum()
polymarket_full_price = polymarket_full_event_data[polymarket_market["slug"]]["price"]
polymarket_daily_changes = (polymarket_full_price != polymarket_full_price.shift(1)).resample("1D").sum()
fig, ax = plt.subplots(figsize=(16, 5))
ax.bar(polymarket_daily_changes.index, polymarket_daily_changes.values, color="blue", alpha=0.5, width=1, label="Polymarket")
ax.bar(kalshi_daily_changes.index, kalshi_daily_changes.values, color="red", alpha=0.5, width=1, label="Kalshi")
ax.set_xlabel("date")
ax.set_ylabel("frequency of price change (count/day)")
ax.set_title('"Fed Maintains Rate" / "No change" — daily count of price-change ticks, full contract lifetime')
ax.legend()
plt.tight_layout()
plt.show()
Frequency of Price Change per Day — Polymarket vs. Kalshi¶
Note: both platforms separately offer genuine individual trade records, not just aggregated price history — Kalshi via /historical/trades, Polymarket via /trades (already used above for the Jan 1 volume chart). Neither has been fully pulled and compared side-by-side here. That trade-level data could be useful as a validation step for arbitrage detection in a future notebook — confirming a modeled opportunity was actually fillable at the time, not just an artifact of two price series that don't quite line up.
4. Conclusion¶
Based on this initial, high-level review, there is no meaningful gap between Kalshi and Polymarket for the contract examined here. The 14-day overlay of Kalshi's Quick Buy (yes_ask) price against Polymarket's price, at 1-minute ticks, shows the two tracking closely together over January 1–14 — nothing that reads as a sustained, visually obvious mismatch worth chasing on sight.
That said, this is a first pass on a single outcome contract ("Fed Maintains Rate" / "No change"), using raw price alone — not fee-adjusted, not cross-checked against the other four outcome contracts for this event, and not fine enough to rule out short-lived mismatches a broad 1-minute-tick overlay could simply miss. Per the goal set out at the start of this section, that's exactly what this pass was for: deciding whether deeper analysis is warranted. On this contract, at this resolution, it doesn't look like it is — but that's a conclusion about this one pairing, not a verdict on the strategy as a whole.
There's also a specific reason to treat this particular window cautiously as a test of divergence: the Frequency of Price Change per Day chart shows Kalshi updating its price on 269–709 occasions every single day of Jan 1–14 (6,479 total), while Polymarket sat nearly silent at 0–18 changes/day for the first eight days before jumping to 122–365/day from Jan 9 onward (1,677 total) — this despite Polymarket trading roughly 6.7x more volume than Kalshi across the same window. One market was volatile; the other, for over half this window, essentially wasn't. (Part of Kalshi's count is a known artifact — 39% of its changes are the bid/ask-midpoint fallback flipping rather than genuine trades — but the majority, 61%, is real trade-driven movement, so this doesn't reverse the pattern.) Meaningful price divergence generally requires both sides to be moving somewhat independently; when one side is largely frozen, an apparent "gap" mostly just reflects the other side's own movement, not a genuine two-sided mismatch. That makes the earlier days of this window a weaker test of real divergence than the later days, where both platforms were actually active together.
5. Complete Event Contract Arbitrage Events¶
This section counts the number of possible arbitrage opportunities between Polymarket and Kalshi from October through January, using the full history already ingested for both platforms.
We'll measure the number of times an arbitrage opportunity actually occurred. Arbitrage is defined here as a price difference of $0.05 or more between the two platforms — that threshold is used to factor in potential fees and potential slippage, rather than counting every trivial, sub-cent mismatch as a real opportunity.
A note on how this is counted: time is not consolidated into individual arbitrage events here. A single opportunity that persists for, say, 20 straight minutes counts as 20, not 1 — what's being measured is the raw number of whole minutes where the gap cleared the threshold, not the number of distinct episodes.
ARBITRAGE_THRESHOLD = 0.05
kalshi_full = kalshi_full_event_data[KALSHI_TICKER].reset_index().rename(
columns={"timestamp": "ts", "yes_ask": "kalshi_yes_ask"}
)
polymarket_full = polymarket_full_event_data[polymarket_market["slug"]].reset_index().rename(
columns={"timestamp": "ts", "price": "polymarket_price"}
)
arb_aligned = pd.merge_asof(
kalshi_full[["ts", "kalshi_yes_ask"]].sort_values("ts"),
polymarket_full[["ts", "polymarket_price"]].sort_values("ts"),
on="ts",
tolerance=pd.Timedelta("5min"),
direction="backward",
).dropna()
arb_aligned["gross_spread"] = (arb_aligned["kalshi_yes_ask"] - arb_aligned["polymarket_price"]).abs()
arb_aligned["is_arbitrage"] = arb_aligned["gross_spread"] >= ARBITRAGE_THRESHOLD
weekly_arb_count = arb_aligned.set_index("ts")["is_arbitrage"].resample("1W").sum()
display(Markdown(f"#### Weekly Count of Arbitrage-Qualifying Minutes (Gross Spread ≥ ${ARBITRAGE_THRESHOLD:.2f})"))
fig, ax = plt.subplots(figsize=(14, 5))
weekly_bars = ax.bar(weekly_arb_count.index, weekly_arb_count.values, color="darkgreen", width=5)
ax.bar_label(weekly_bars, fmt="{:,.0f}", fontsize=8, padding=2)
ax.set_xlabel("week")
ax.set_ylabel("count of qualifying 1-minute ticks")
ax.set_title(
f'"Fed Maintains Rate" / "No change" — weekly count of minutes with '
f"|Kalshi yes_ask − Polymarket price| ≥ ${ARBITRAGE_THRESHOLD:.2f}"
)
plt.tight_layout()
plt.show()
print(
f"Total qualifying minutes: {arb_aligned['is_arbitrage'].sum():,} out of {len(arb_aligned):,} "
f"aligned minutes ({arb_aligned['is_arbitrage'].mean():.2%})"
)
Weekly Count of Arbitrage-Qualifying Minutes (Gross Spread ≥ $0.05)¶
Total qualifying minutes: 6,688 out of 74,872 aligned minutes (8.93%)
MIN_EVENT_DURATION_MINUTES = 5
# Group consecutive qualifying minutes into distinct episodes, then keep only
# the ones that persisted for at least MIN_EVENT_DURATION_MINUTES.
run_id = (arb_aligned["is_arbitrage"] != arb_aligned["is_arbitrage"].shift()).cumsum()
episodes = arb_aligned[arb_aligned["is_arbitrage"]].groupby(run_id)["ts"].agg(["min", "max"])
episodes["duration_minutes"] = (episodes["max"] - episodes["min"]).dt.total_seconds() / 60
qualifying_events = episodes[episodes["duration_minutes"] >= MIN_EVENT_DURATION_MINUTES]
weekly_event_count = pd.Series(1, index=qualifying_events["min"]).resample("1W").sum()
display(Markdown(
f"#### Weekly Count of Distinct Arbitrage Events (Gross Spread ≥ ${ARBITRAGE_THRESHOLD:.2f}, "
f"lasting ≥ {MIN_EVENT_DURATION_MINUTES} minutes)"
))
fig, ax = plt.subplots(figsize=(14, 5))
event_bars = ax.bar(weekly_event_count.index, weekly_event_count.values, color="seagreen", width=5)
ax.bar_label(event_bars, fmt="{:,.0f}", fontsize=8, padding=2)
ax.set_xlabel("week")
ax.set_ylabel("count of distinct events")
ax.set_title(
f'"Fed Maintains Rate" / "No change" — weekly count of arbitrage episodes lasting '
f"{MIN_EVENT_DURATION_MINUTES}+ minutes"
)
plt.tight_layout()
plt.show()
print(
f"Total distinct arbitrage episodes: {len(episodes):,} | "
f"lasting {MIN_EVENT_DURATION_MINUTES}+ minutes: {len(qualifying_events):,} "
f"({len(qualifying_events) / len(episodes):.1%} of all episodes)"
)
Weekly Count of Distinct Arbitrage Events (Gross Spread ≥ $0.05, lasting ≥ 5 minutes)¶
Total distinct arbitrage episodes: 336 | lasting 5+ minutes: 80 (23.8% of all episodes)
Summary of the arbitrage table:
- Arbitrage was available on 41.8% of days (51 of the 122 days with aligned data between both platforms) — measured as whether a given day had any qualifying minute at all, not how much of that day it lasted.
- Opportunities were not evenly spread across the window — they cluster earlier, not later. Qualifying days range from 121 days before close down to a most recent qualifying day just 41 days before close. That means there is no day in the final ~6 weeks leading into the actual Fed decision where the gap ever cleared $0.05 — the opportunity essentially disappears as the event approaches, consistent with both platforms converging toward certainty near resolution.
- Grouped into distinct episodes rather than raw minutes, there were 336 separate arbitrage events over the full window — 80 of them (23.8%) lasted 5 minutes or longer, the cutoff used in the chart above. So this isn't only single-minute flickers; roughly a quarter of the time an opportunity opened, it stuck around long enough to plausibly act on.
A note on timescale: everything measured here — both the raw-minute count and the 5-minute-plus events — is built on 1-minute tick data, because that's the finest resolution either platform's historical API offers (see the Kalshi and Polymarket Summary sections above). Traditional arbitrage bots operate in milliseconds, so there may be shorter-lived opportunities hiding in this data that a 1-minute view simply can't resolve — this analysis can't rule those out, only speak to what's visible at this resolution. But that cuts both ways: the fact that gaps of $0.05+ show up and persist at the 1-minute timescale — some for 5, 10, even more minutes at a stretch — is itself a proof of concept. That's slow enough for a person to plausibly catch and execute manually, and comfortably enough time for a custom automated bot to act, without needing millisecond-grade infrastructure to capture at least this layer of the opportunity.
Main conclusion: the arbitrage is there. On a raw basis, a $0.05+ mismatch showed up on more than 4 in 10 days over this contract's life, forming hundreds of distinct events — that's not nothing. But it isn't evenly available, isn't guaranteed on any given day, and dries up entirely as the event nears close. Turning "it's there sometimes" into an actual strategy takes more digging: which days it shows up on, what's happening within a qualifying day (open-and-close-fast vs. sustained), and whether the pattern found here — front-loaded, gone by the final stretch — holds for other outcome contracts too.
6. Additional Calculations¶
This section narrows in on November 1–30 only, to get a concrete sense of what actually trading this arbitrage would have looked like day to day — not just whether a gap existed, but how big it typically was, how long it lasted, and how much volume was around when it opened. Net Edge (the fee-adjusted version of these numbers) is covered in the next section; everything here is on the gross, pre-fee price gap.
The baseline statistics calculated for this window are:
- Average Price Divergence Per Event
- Average Price Divergence Per Day
- Median Price Divergence Per Event
- Average Length in Minutes of Divergence
- Longest Single Divergence Duration
- Largest Price Divergence Per Day
- Smallest Price Divergence Per Day
- Average Volume Per Divergent Event
- Average Buy Now Price Per Divergent Event
The table below reports each baseline statistic for the four outcome-contract buckets identified during Market Discovery. Three of these — No Change, 25bps Decrease, and 50+bps Decrease — are the clean, 1:1 matched pairs used throughout this notebook. The fourth, 25bps+ Increase, is not a clean match: Polymarket lumps every rate-hike magnitude into a single contract, while Kalshi splits hikes into a 25bps contract and a separate >25bps contract. The numbers in that column compare Kalshi's 25bps-hike contract against Polymarket's broader, lumped increase contract — not an apples-to-apples comparison, included with that caveat rather than left out.
A note on method: "Per Event" groups consecutive qualifying minutes into a single episode, same as the charts above. "Per Day" figures are computed by first finding each day's own value, then averaging across days — so "Largest Price Divergence Per Day" is the average of each day's own peak, not the single largest tick seen all month. Volume uses Kalshi's native per-minute volume only, since Polymarket's prices-history endpoint has no volume field of its own (see the Polymarket Data Schema table above).
NOV_START = datetime.datetime(2025, 11, 1, tzinfo=datetime.timezone.utc)
NOV_END = datetime.datetime(2025, 12, 1, tzinfo=datetime.timezone.utc)
BUCKETS = {
"No Change": ("KXFEDDECISION-26JAN-H0", "no-change-in-fed-interest-rates-after-january-2026-meeting"),
"25bps Decrease": ("KXFEDDECISION-26JAN-C25", "fed-decreases-interest-rates-by-25-bps-after-january-2026-meeting"),
"50+bps Decrease": ("KXFEDDECISION-26JAN-C26", "fed-decreases-interest-rates-by-50-bps-after-january-2026-meeting"),
"25bps+ Increase (imperfect match)": ("KXFEDDECISION-26JAN-H25", "fed-increases-interest-rates-by-25-bps-after-january-2026-meeting"),
}
STAT_ROWS = [
"Average Price Divergence Per Event",
"Average Price Divergence Per Day",
"Median Price Divergence Per Event",
"Average Length in Minutes of Divergence",
"Longest Single Divergence Duration (min)",
"Largest Price Divergence Per Day",
"Smallest Price Divergence Per Day",
"Average Volume Per Divergent Event",
"Average Buy Now Price Per Divergent Event",
]
def compute_bucket_stats(kalshi_ticker, polymarket_slug, window_start, window_end, threshold=ARBITRAGE_THRESHOLD):
k = kalshi_full_event_data[kalshi_ticker].reset_index().rename(
columns={"timestamp": "ts", "yes_ask": "kalshi_yes_ask", "volume": "kalshi_volume"}
)
p = polymarket_full_event_data[polymarket_slug].reset_index().rename(
columns={"timestamp": "ts", "price": "polymarket_price"}
)
aligned = pd.merge_asof(
k[["ts", "kalshi_yes_ask", "kalshi_volume"]].sort_values("ts"),
p[["ts", "polymarket_price"]].sort_values("ts"),
on="ts",
tolerance=pd.Timedelta("5min"),
direction="backward",
).dropna(subset=["kalshi_yes_ask", "polymarket_price"])
aligned = aligned[(aligned["ts"] >= window_start) & (aligned["ts"] < window_end)]
aligned["gross_spread"] = (aligned["kalshi_yes_ask"] - aligned["polymarket_price"]).abs()
aligned["is_arb"] = aligned["gross_spread"] >= threshold
if aligned.empty or not aligned["is_arb"].any():
return {stat: None for stat in STAT_ROWS}
arb_rows = aligned[aligned["is_arb"]]
run_id = (aligned["is_arb"] != aligned["is_arb"].shift()).cumsum()
episodes = arb_rows.groupby(run_id).agg(
start=("ts", "min"),
end=("ts", "max"),
mean_spread=("gross_spread", "mean"),
mean_volume=("kalshi_volume", "mean"),
mean_price=("kalshi_yes_ask", "mean"),
)
episodes["duration_minutes"] = (episodes["end"] - episodes["start"]).dt.total_seconds() / 60 + 1
daily = arb_rows.set_index("ts")["gross_spread"].resample("1D")
daily_mean, daily_max, daily_min = daily.mean().dropna(), daily.max().dropna(), daily.min().dropna()
return {
"Average Price Divergence Per Event": episodes["mean_spread"].mean(),
"Average Price Divergence Per Day": daily_mean.mean(),
"Median Price Divergence Per Event": episodes["mean_spread"].median(),
"Average Length in Minutes of Divergence": episodes["duration_minutes"].mean(),
"Longest Single Divergence Duration (min)": episodes["duration_minutes"].max(),
"Largest Price Divergence Per Day": daily_max.mean(),
"Smallest Price Divergence Per Day": daily_min.mean(),
"Average Volume Per Divergent Event": episodes["mean_volume"].mean(),
"Average Buy Now Price Per Divergent Event": episodes["mean_price"].mean(),
}
results = {
bucket_name: compute_bucket_stats(k_ticker, p_slug, NOV_START, NOV_END)
for bucket_name, (k_ticker, p_slug) in BUCKETS.items()
}
stats_table = pd.DataFrame(results).loc[STAT_ROWS]
price_rows = [
"Average Price Divergence Per Event",
"Average Price Divergence Per Day",
"Median Price Divergence Per Event",
"Largest Price Divergence Per Day",
"Smallest Price Divergence Per Day",
"Average Buy Now Price Per Divergent Event",
]
duration_rows = ["Average Length in Minutes of Divergence", "Longest Single Divergence Duration (min)"]
volume_rows = ["Average Volume Per Divergent Event"]
styled_stats_table = (
stats_table.style
.format("${:.4f}", subset=pd.IndexSlice[price_rows, :], na_rep="N/A")
.format("{:.1f} min", subset=pd.IndexSlice[duration_rows, :], na_rep="N/A")
.format("{:,.0f}", subset=pd.IndexSlice[volume_rows, :], na_rep="N/A")
.set_properties(**{"text-align": "center", "padding": "10px"})
.set_table_styles(
[
{"selector": "table", "props": [("width", "100%")]},
{"selector": "th", "props": [("text-align", "center"), ("background-color", "#f0f0f0"), ("padding", "10px")]},
{"selector": "th.row_heading", "props": [("text-align", "left")]},
]
)
)
display(Markdown("#### November Baseline Arbitrage Statistics, by Outcome Bucket"))
display(styled_stats_table)
November Baseline Arbitrage Statistics, by Outcome Bucket¶
| No Change | 25bps Decrease | 50+bps Decrease | 25bps+ Increase (imperfect match) | |
|---|---|---|---|---|
| Average Price Divergence Per Event | $0.0577 | $0.0644 | $0.0542 | N/A |
| Average Price Divergence Per Day | $0.0699 | $0.0863 | $0.0572 | N/A |
| Median Price Divergence Per Event | $0.0550 | $0.0575 | $0.0530 | N/A |
| Average Length in Minutes of Divergence | 65.9 min | 100.5 min | 136.0 min | N/A |
| Longest Single Divergence Duration (min) | 3426.0 min | 1939.0 min | 2830.0 min | N/A |
| Largest Price Divergence Per Day | $0.0989 | $0.1145 | $0.0646 | N/A |
| Smallest Price Divergence Per Day | $0.0535 | $0.0636 | $0.0524 | N/A |
| Average Volume Per Divergent Event | 3 | 2 | 0 | N/A |
| Average Buy Now Price Per Divergent Event | $0.5932 | $0.4010 | $0.0928 | N/A |
A caveat on the duration numbers ("Average Length in Minutes of Divergence" and "Longest Single Divergence Duration"): these are measured from the first qualifying tick to the last qualifying tick within a run of consecutive data rows — not from a continuous clock. Kalshi's candlestick data only includes a row when there was actual bid/ask/trade activity that minute (roughly 31% of November's calendar minutes have a Kalshi row at all), so a stretch with no Kalshi activity doesn't get marked as "not diverging" — it's simply absent. If the gap is still above $0.05 whenever the next real data point shows up, that whole stretch reads as one continuous episode, even though there's no confirmation the gap stayed above threshold throughout the silent period. The No Change bucket's longest episode (Nov 7–10, ~57 hours) is a clear example: only 265 actual qualifying data points span that window. Treat the duration numbers as an upper bound on persistence, not a guarantee of continuous divergence.
Summary of the November baseline table:
Arbitrage isn't confined to the front-runner outcome. All three cleanly-matched buckets — No Change, 25bps Decrease, and 50+bps Decrease — had real, qualifying divergence episodes in November (289, 149, and 22 respectively). The mismatch didn't only happen on the outcome everyone agreed was most likely; it showed up across the board. (The Increase bucket had none — but between its near-zero real-world probability this far from the meeting and its imperfect Kalshi/Polymarket match, that's inconclusive rather than a genuine "no gap" finding.)
In relative terms, the gap gets bigger the cheaper the contract. Comparing average divergence to average Buy Now price: No Change sits at 9.7% relative divergence ($0.058 on a $0.59 contract), 25bps Decrease at 16.1% ($0.064 on $0.40), and 50+bps Decrease at 58.4% ($0.054 on just $0.09) — a clean, monotonic climb as price drops. Worth being precise about what this does and doesn't mean: in raw dollar terms, 25bps Decrease actually has the largest average and single-day divergence of the three, not the cheapest bucket. "Larger in cheaper buckets" is true as a percentage of the price being traded, not as an absolute cent figure.
Cheaper buckets diverge less often, but longer, when they do. 50+bps Decrease had the fewest episodes (22, vs. 289 and 149) but the longest average duration (136.0 minutes, vs. 65.9 and 100.5) — consistent with a thinner, less efficient market that's slower to correct once a gap opens, even though gaps open less frequently there.
On the volume issue: this is where the picture gets more cautious. Average realized Kalshi volume during divergent minutes drops right alongside price — 3.0 contracts (No Change) → 1.6 (25bps Decrease) → 0.0 (50+bps Decrease, precisely — not just rounding). The bucket with by far the largest relative opportunity is also the one with essentially no confirmed trading activity during the windows when that opportunity existed. As discussed above, near-zero realized volume doesn't prove there was no liquidity to trade against — Kalshi's historical data can't distinguish "no one traded" from "no one was there" — but it does mean there's no positive evidence this specific opportunity was fillable, and thin markets are exactly where a wide-looking percentage gap can be pricing noise (bid/ask-midpoint fallback ticks) rather than a real, tradeable mismatch. The most attractive-looking number in this table is also the least trustworthy one.
7. Volume Exploration¶
The November baseline table above showed average Kalshi volume of just 3, 2, and 0 contracts per divergent-episode minute across the No Change, 25bps Decrease, and 50+bps Decrease buckets. Numbers that low raise a real liquidity concern: if there genuinely isn't much trading happening while a price gap is open, our own trades might not actually get filled when we try to act on it — the arbitrage could look real on paper without being executable in practice.
We need to explore whether that's actually true. Is 3, 2, and 0 average trades per minute a real problem, or a misleading read on a market that's more liquid than raw volume alone suggests?
Some ways to dig into that question:
- Pull Kalshi's
/historical/tradesfor these specific divergence windows, to see individual trade sizes rather than an aggregated per-minute sum — tells us whether "3 contracts" was one trade or several tiny ones. - Cross-check against the
price_sourceflag for those same minutes — if most divergent-window minutes arebid_ask_midrather thantrade, that confirms genuine inactivity rather than a coincidence of small trades. - Pull Polymarket's volume for the same windows via
get_trades()— an arbitrage trade needs both legs to fill, so Kalshi's volume alone isn't the whole picture. - Look at the distribution of volume per event, not just the average — a handful of thick events and many empty ones tells a very different story than a consistent trickle.
- Compare divergence-window volume against this contract's baseline (non-divergent) volume — if normal volume is also this low, it's just how thin the contract trades generally, not something specific to divergence moments.
- Check the yes_bid/yes_ask spread width during divergent minutes as a secondary depth proxy — a wider spread during divergence would reinforce a thin-market read.
- Recognize the hard limit: none of this fully confirms fillability, since neither platform's historical API exposes real order-book depth. Backtesting can narrow the question; genuinely confirming it needs live order-book monitoring or a small live pilot.
We'll continue this discussion in Volume & Liquidity Exploration.