Volume & Liquidity Exploration

In Prediction Market Arbitrage Feasibility Study, we identified real arbitrage opportunities between Kalshi and Polymarket for the Fed Rate Change in January 2026 event, across the November 1–30, 2025 window. That document broke those opportunities down by outcome bucket, then raised an open question: average trading volume during those divergent windows was very thin — 3, 2, and 0 contracts per minute across the three matched buckets — thin enough to raise a real doubt about whether trades placed during those windows would actually get filled.

This notebook picks up that question directly. Next, we need to confirm there is enough liquidity in these markets to know that arbitrage trades are actually possible — not just that a price gap existed on paper.

Setting Notebook Environment

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

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from IPython.display import Markdown, HTML, display

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)

ARBITRAGE_THRESHOLD = 0.05

Data Ingestion — "Fed Maintains Rate" / "No Change"

Re-ingesting the full-lifetime, 1-minute history for the same running-example pair used throughout the previous notebook, so this notebook is self-contained rather than depending on that one still being open.

In [2]:
KALSHI_TICKER = "KXFEDDECISION-26JAN-H0"
POLYMARKET_SLUG = "no-change-in-fed-interest-rates-after-january-2026-meeting"

kalshi_markets = kalshi_client.get_historical_markets("KXFEDDECISION-26JAN")
kalshi_market_meta = next(m for m in kalshi_markets if m["ticker"] == KALSHI_TICKER)
kalshi_open = pd.to_datetime(kalshi_market_meta["open_time"], utc=True)
kalshi_close = pd.to_datetime(kalshi_market_meta["close_time"], utc=True)
kalshi_history = kalshi_client.get_candlesticks(KALSHI_TICKER, kalshi_open, kalshi_close, period_interval=1)

polymarket_event = polymarket_client.get_event_by_slug("fed-decision-in-january")
polymarket_market = next(m for m in polymarket_event["markets"] if m["slug"] == POLYMARKET_SLUG)
polymarket_token = market_matching._polymarket_yes_token(polymarket_market)
polymarket_start = pd.to_datetime(polymarket_market["startDate"], utc=True)
polymarket_close = pd.to_datetime(polymarket_market["closedTime"], utc=True)
polymarket_history = polymarket_client.get_prices_history(
    polymarket_token, polymarket_start, polymarket_close, fidelity=1
)

print(f"Kalshi: {len(kalshi_history):,} rows | Polymarket: {len(polymarket_history):,} rows")
Kalshi: 74,906 rows | Polymarket: 191,489 rows

Weekly Count of Arbitrage-Qualifying Minutes

Carried over from the previous notebook, as the starting point for the liquidity questions this notebook is meant to answer.

In [3]:
kalshi_full = kalshi_history.reset_index().rename(columns={"timestamp": "ts", "yes_ask": "kalshi_yes_ask"})
polymarket_full = polymarket_history.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)

No description has been provided for this image
Total qualifying minutes: 6,688 out of 74,872 aligned minutes (8.93%)

Trade-Level Data

Everything pulled above — Kalshi's candlesticks and Polymarket's prices-history — is aggregated price data, sampled at 1-minute ticks, same as notebook 02. Neither tells us how many individual trades happened or how large each one was: Kalshi's per-minute volume field is just a sum, and Polymarket's price series doesn't carry volume at all. To actually judge liquidity, we need to go a level deeper, to the raw trade records themselves.

Both platforms expose this — Kalshi via /historical/trades (not yet used anywhere in this project; kalshi_client.get_trades() was just added to reach it), Polymarket via /trades (already used briefly in notebook 02, for a single day's volume chart). Importing both for the same November window lets us see what's actually behind the thin-looking average volume numbers from the baseline table: whether it's a few large trades, many small ones, or genuinely almost nothing.

In [4]:
NOV_START = datetime.datetime(2025, 11, 1, tzinfo=datetime.timezone.utc)
NOV_END = datetime.datetime(2025, 12, 1, tzinfo=datetime.timezone.utc)

kalshi_trades = kalshi_client.get_trades(KALSHI_TICKER, NOV_START, NOV_END)
polymarket_trades = polymarket_client.get_trades(polymarket_market["conditionId"], NOV_START, NOV_END)

print(f"Kalshi: {len(kalshi_trades):,} trades, {kalshi_trades['count'].sum():,.0f} contracts total")
print(f"Polymarket: {len(polymarket_trades):,} trades, {polymarket_trades['size'].sum():,.0f} contracts total")
Kalshi: 158 trades, 26,234 contracts total
Polymarket: 1,624 trades, 318,200 contracts total

Daily Volume — November 2025

A general first look at daily trading volume on both platforms, using the trade-level data pulled above rather than mixing Kalshi's native candlestick volume with a Polymarket-derived figure. Scoped to November 1–30 only. Kalshi and Polymarket are plotted as separate panels with independent y-axes, since Polymarket trades roughly an order of magnitude more volume than Kalshi — a shared axis would flatten Kalshi's bars to nearly nothing.

This is deliberately just the volume picture for now — joining it against which days actually had qualifying arbitrage divergence, and digging into trade-size/frequency differences between the two platforms, come next.

In [5]:
nov_days = pd.date_range(NOV_START, periods=30, freq="1D", tz="UTC")

kalshi_daily_volume = kalshi_trades["count"].resample("1D").sum().reindex(nov_days, fill_value=0)
polymarket_daily_volume = polymarket_trades["size"].resample("1D").sum().reindex(nov_days, fill_value=0)

fig, (ax_k, ax_p) = plt.subplots(2, 1, figsize=(14, 8), sharex=True)

k_bars = ax_k.bar(kalshi_daily_volume.index, kalshi_daily_volume.values, color="red", width=0.8)
ax_k.bar_label(k_bars, fmt="{:,.0f}", fontsize=7, rotation=90, padding=2)
ax_k.set_ylabel("Kalshi contracts")
ax_k.set_title('Daily Trading Volume — Kalshi "Fed Maintains Rate" vs. Polymarket "No change", November 2025')

p_bars = ax_p.bar(polymarket_daily_volume.index, polymarket_daily_volume.values, color="blue", width=0.8)
ax_p.bar_label(p_bars, fmt="{:,.0f}", fontsize=7, rotation=90, padding=2)
ax_p.set_ylabel("Polymarket contracts")
ax_p.set_xlabel("date")
ax_p.tick_params(axis="x", rotation=45)

plt.tight_layout()
plt.show()

print(
    f"Kalshi Nov total: {kalshi_daily_volume.sum():,.0f} contracts across "
    f"{(kalshi_daily_volume > 0).sum()} of {len(nov_days)} days"
)
print(
    f"Polymarket Nov total: {polymarket_daily_volume.sum():,.0f} contracts across "
    f"{(polymarket_daily_volume > 0).sum()} of {len(nov_days)} days"
)
No description has been provided for this image
Kalshi Nov total: 26,234 contracts across 21 of 30 days
Polymarket Nov total: 318,200 contracts across 30 of 30 days

Flag: Kalshi had zero trades on 9 of November's 30 days (Nov 2, 4, 5, 6, 8, 10, 11, 16, and 30) — nearly a third of the month with no realized trading activity at all. Polymarket, by contrast, traded on all 30.

Does that mean it's unlikely we could have traded — and had our arbitrage bets actually filled — on those days? Checked directly rather than assumed:

  • On all nine dead-trade days, Kalshi still had a live, quoted yes_bid/yes_ask for part of the day — ranging from 82 to 515 minutes out of 1,440 possible (roughly 6% to 36% of the day).
  • So these weren't empty order books; a market maker was still posting prices, nobody just happened to cross the spread and trade.
  • That's a meaningfully different situation than "there was nothing to trade against" — it's plausible a trade could have occurred on any of these days if someone (us, in this scenario) had been willing to take the quoted price.
  • That's not the same as saying it's fine, though: quote coverage on these days was still partial, not continuous — even the "active" days only had a live quote for a minority of the day's minutes, meaning there were still long stretches with no price at all to trade against.
  • And as flagged earlier in this project, we still don't know the size available at those quotes — Kalshi's historical data doesn't retain size-at-price, only whether a price existed.

So the honest read is: a dead trade day doesn't rule out fillability, but it doesn't confirm it either. It shifts the question from "was there a market" (yes, partially) to "was there enough size in that market, often enough, to actually get our trade done" — which is still open.

Aligning Arbitrage Frequency with Volume

The weekly arbitrage-frequency chart near the top of this notebook and the daily volume charts above don't share a granularity or a time range — one is weekly across the full Oct–Jan lifetime, the other is daily across just November. To line them up, the qualifying-minute count is recomputed here at daily granularity for November only, using the same arb_aligned data from that first chart.

Two views follow: a third panel added to the volume dashboard, so divergence and volume can be read off the same date column at a glance; then a scatter plot of daily volume against daily divergence-minutes, which is the more direct test of whether the two actually move together, rather than just sitting near each other on a shared timeline.

In [6]:
arb_nov = arb_aligned[(arb_aligned["ts"] >= NOV_START) & (arb_aligned["ts"] < NOV_END)]
daily_arb_count = arb_nov.set_index("ts")["is_arbitrage"].resample("1D").sum().reindex(nov_days, fill_value=0)

display(Markdown("#### Daily Divergence vs. Volume — November 2025"))

fig, (ax_arb, ax_k, ax_p) = plt.subplots(3, 1, figsize=(14, 11), sharex=True)

arb_bars = ax_arb.bar(daily_arb_count.index, daily_arb_count.values, color="darkgreen", width=0.8)
ax_arb.bar_label(arb_bars, fmt="{:,.0f}", fontsize=7, rotation=90, padding=2)
ax_arb.set_ylabel("qualifying minutes")
ax_arb.set_title('Daily Arbitrage-Qualifying Minutes vs. Trading Volume, November 2025')

k_bars = ax_k.bar(kalshi_daily_volume.index, kalshi_daily_volume.values, color="red", width=0.8)
ax_k.bar_label(k_bars, fmt="{:,.0f}", fontsize=7, rotation=90, padding=2)
ax_k.set_ylabel("Kalshi contracts")

p_bars = ax_p.bar(polymarket_daily_volume.index, polymarket_daily_volume.values, color="blue", width=0.8)
ax_p.bar_label(p_bars, fmt="{:,.0f}", fontsize=7, rotation=90, padding=2)
ax_p.set_ylabel("Polymarket contracts")
ax_p.set_xlabel("date")
ax_p.tick_params(axis="x", rotation=45)

plt.tight_layout()
plt.show()

Daily Divergence vs. Volume — November 2025

No description has been provided for this image
In [7]:
corr_kalshi = daily_arb_count.corr(kalshi_daily_volume)
corr_poly = daily_arb_count.corr(polymarket_daily_volume)

display(Markdown("#### Daily Volume vs. Daily Arbitrage-Qualifying Minutes"))

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5))

ax1.scatter(daily_arb_count.values, kalshi_daily_volume.values, color="red")
k_fit = np.polyfit(daily_arb_count.values, kalshi_daily_volume.values, 1)
x_line = np.linspace(daily_arb_count.min(), daily_arb_count.max(), 2)
ax1.plot(x_line, np.polyval(k_fit, x_line), color="black", linestyle="--", linewidth=1)
ax1.set_xlabel("qualifying minutes (arbitrage)")
ax1.set_ylabel("Kalshi contracts")
ax1.set_title(f"Kalshi — r = {corr_kalshi:.2f}")

ax2.scatter(daily_arb_count.values, polymarket_daily_volume.values, color="blue")
p_fit = np.polyfit(daily_arb_count.values, polymarket_daily_volume.values, 1)
ax2.plot(x_line, np.polyval(p_fit, x_line), color="black", linestyle="--", linewidth=1)
ax2.set_xlabel("qualifying minutes (arbitrage)")
ax2.set_ylabel("Polymarket contracts")
ax2.set_title(f"Polymarket — r = {corr_poly:.2f}")

plt.tight_layout()
plt.show()

print(f"Correlation (daily divergence-minutes vs. daily volume): Kalshi r={corr_kalshi:.3f}, Polymarket r={corr_poly:.3f}")

Daily Volume vs. Daily Arbitrage-Qualifying Minutes

No description has been provided for this image
Correlation (daily divergence-minutes vs. daily volume): Kalshi r=0.334, Polymarket r=0.243

How Many Contracts Could a Trade Have Actually Bought?

Question: this notebook opened by flagging that average volume during divergent windows looked thin — 3, 2, and 0 contracts per minute, across the three matched buckets in notebook 02. An average like that can hide a lot: does that thin rate at least add up to something tradeable over the life of a typical qualifying episode, or does it stay thin the whole way through?

For each of the 64 qualifying episodes this November, the actual Kalshi and Polymarket trades that happened within that specific episode's start-to-end window are summed — not the whole day, just the minutes the gap was actually open. The number that matters for a hedge is whichever leg traded less, since a hedge can't be sized past its thinner leg.

How to read it: each bar buckets episodes by how many contracts were actually tradeable — the smaller of the two legs' realized volume — during that specific episode.

What it shows: the honest answer is usually zero. Most qualifying episodes had no realized trades at all on at least one leg — almost always Kalshi — during the entire window the gap was open. That doesn't necessarily mean no order could have been filled (the "dead trading days" check earlier in this notebook showed a quoted bid/ask often persisted even without trades), but it does mean nobody actually traded there in practice. Only a minority of episodes had any realized volume on both legs at once.

In [8]:
LIQ_MIN_DURATION_MINUTES = 5

liq_run_id = (arb_nov["is_arbitrage"] != arb_nov["is_arbitrage"].shift()).cumsum()
liq_episodes = arb_nov[arb_nov["is_arbitrage"]].groupby(liq_run_id)["ts"].agg(["min", "max"])
liq_episodes["duration_minutes"] = (liq_episodes["max"] - liq_episodes["min"]).dt.total_seconds() / 60
liq_qualifying = liq_episodes[liq_episodes["duration_minutes"] >= LIQ_MIN_DURATION_MINUTES].copy()

kalshi_ep_contracts, poly_ep_contracts = [], []
for _, row in liq_qualifying.iterrows():
    k_slice = kalshi_trades.loc[row["min"]:row["max"]]
    p_slice = polymarket_trades.loc[row["min"]:row["max"]]
    kalshi_ep_contracts.append(k_slice["count"].sum() if len(k_slice) else 0.0)
    poly_ep_contracts.append(p_slice["size"].sum() if len(p_slice) else 0.0)

liq_qualifying["kalshi_contracts"] = kalshi_ep_contracts
liq_qualifying["poly_contracts"] = poly_ep_contracts
liq_qualifying["tradeable_contracts"] = liq_qualifying[["kalshi_contracts", "poly_contracts"]].min(axis=1)

LIQ_BIN_EDGES = [-0.5, 0.5, 10, 50, 200, 1000, 5000]
LIQ_BIN_LABELS = ["0\n(no fill)", "1-10", "11-50", "51-200", "201-1,000", "1,000+"]
liq_qualifying["bucket"] = pd.cut(liq_qualifying["tradeable_contracts"], bins=LIQ_BIN_EDGES, labels=LIQ_BIN_LABELS)
bucket_counts = liq_qualifying["bucket"].value_counts().reindex(LIQ_BIN_LABELS, fill_value=0)

display(Markdown(f"#### Contracts Actually Tradeable Per Qualifying Episode \u2014 November 2025"))

fig, ax = plt.subplots(figsize=(12, 5))
colors = ["crimson"] + ["steelblue"] * (len(LIQ_BIN_LABELS) - 1)
bars = ax.bar(bucket_counts.index, bucket_counts.values, color=colors)
ax.bar_label(bars, fmt="{:,.0f}", fontsize=9, padding=2)
ax.set_xlabel("contracts tradeable during the episode (smaller of the two legs' realized volume)")
ax.set_ylabel("count of episodes")
ax.set_title(
    f"How Many Contracts a Hedge Could Actually Have Bought \u2014 {len(liq_qualifying)} qualifying episodes, November 2025"
)
plt.tight_layout()
plt.show()

n_zero = (liq_qualifying["tradeable_contracts"] == 0).sum()
nonzero_median = liq_qualifying.loc[liq_qualifying["tradeable_contracts"] > 0, "tradeable_contracts"].median()
print(
    f"Episodes with zero realized volume on at least one leg (no hedge possible at all): "
    f"{n_zero:,} / {len(liq_qualifying):,} ({n_zero / len(liq_qualifying):.1%})\n"
    f"Median tradeable size across ALL qualifying episodes: {liq_qualifying['tradeable_contracts'].median():.0f} contracts\n"
    f"Median tradeable size, episodes with any fill at all: {nonzero_median:.0f} contracts "
    f"({len(liq_qualifying) - n_zero:,} episodes)"
)

Contracts Actually Tradeable Per Qualifying Episode — November 2025

No description has been provided for this image
Episodes with zero realized volume on at least one leg (no hedge possible at all): 54 / 64 (84.4%)
Median tradeable size across ALL qualifying episodes: 0 contracts
Median tradeable size, episodes with any fill at all: 77 contracts (10 episodes)

Was There At Least a Quote to Trade Against?

The zero-fill result above raises two follow-up questions — the same distinction already drawn in the dead-trading-days check earlier in this notebook:

  1. Was a live, two-sided Kalshi quote (yes_bid and yes_ask both present) actually up during these 54 zero-fill episodes — or was there genuinely nothing to trade against?
  2. How much size sat behind that quote, if it existed — could a real order have actually gotten filled, and for how many contracts?

Question 2 can't be answered with data available to this project: Kalshi's historical API returns bid/ask prices, never size-at-price — already confirmed as a hard limitation back in notebook 02's schema comparison. That's not something more digging resolves; it would take a live order-book capture going forward, or a data source neither platform's public API offers. Question 1, though, is answerable directly from data already in hand.

How to read it: for each of the 54 zero-fill episodes, the chart below shows what share of that episode's minutes had a live two-sided Kalshi quote — the same yes_bid/yes_ask-presence check used for the earlier dead-trading-days flag, just scoped to these specific windows instead of full days.

What it shows: not one of the 54 episodes was completely unquoted — every single one had a live quote for at least part of its window. About a third were quoted the entire time, and the median episode had a live quote for roughly two-thirds of its duration.

In [9]:
zero_fill_episodes = liq_qualifying[liq_qualifying["tradeable_contracts"] == 0].copy()

kalshi_history_dedup = kalshi_history[~kalshi_history.index.duplicated(keep="first")].sort_index()

pct_quoted_list = []
for _, row in zero_fill_episodes.iterrows():
    minute_range = pd.date_range(row["min"], row["max"], freq="1min", tz="UTC")
    sl = kalshi_history_dedup.reindex(minute_range)
    quoted = sl[["yes_bid", "yes_ask"]].notna().all(axis=1)
    pct_quoted_list.append(quoted.mean() * 100)

zero_fill_episodes["pct_quoted"] = pct_quoted_list

display(Markdown(f"#### Live Quote Coverage During the {len(zero_fill_episodes)} Zero-Fill Episodes"))

fig, ax = plt.subplots(figsize=(12, 5))
ax.hist(zero_fill_episodes["pct_quoted"], bins=np.arange(0, 110, 10), color="steelblue", edgecolor="white")
ax.set_xlabel("% of the episode's minutes with a live two-sided Kalshi quote")
ax.set_ylabel("count of zero-fill episodes")
ax.set_title(
    f"Quote Coverage During Zero-Fill Episodes \u2014 {len(zero_fill_episodes)} episodes, November 2025"
)
plt.tight_layout()
plt.show()

n_never = (zero_fill_episodes["pct_quoted"] == 0).sum()
n_full = (zero_fill_episodes["pct_quoted"] == 100).sum()
n_partial = len(zero_fill_episodes) - n_never - n_full

print(
    f"Never quoted (0% \u2014 genuinely nothing to trade against): {n_never} / {len(zero_fill_episodes)}\n"
    f"Fully quoted (100% \u2014 a live quote the entire window): {n_full} / {len(zero_fill_episodes)}\n"
    f"Partially quoted (some but not all of the window): {n_partial} / {len(zero_fill_episodes)}\n"
    f"Median quote coverage: {zero_fill_episodes['pct_quoted'].median():.0f}%\n"
    f"Mean quote coverage: {zero_fill_episodes['pct_quoted'].mean():.0f}%"
)

Live Quote Coverage During the 54 Zero-Fill Episodes

No description has been provided for this image
Never quoted (0% — genuinely nothing to trade against): 0 / 54
Fully quoted (100% — a live quote the entire window): 17 / 54
Partially quoted (some but not all of the window): 37 / 54
Median quote coverage: 68%
Mean quote coverage: 60%

So, back to the original question — does zero realized fills mean these 54 episodes wouldn't have been filled? No, and the data leans the other way: every one of them had some live quote to trade against, and a third had one the entire time. What actually happened (nobody traded) and what was possible (a market was often sitting there, untouched) are two different things, and this confirms it directly rather than just leaving it as a caveat.

What's still genuinely unknown is size — a quote being live doesn't say whether it could have absorbed 1 contract or 500. That question stays open; it's a real limit of the historical data available, not a gap in this analysis.

Quick summary: the correlation numbers (r=0.33 Kalshi, r=0.24 Polymarket) say the relationship is real but weak across the month as a whole — most days don't show volume and divergence moving together in any tight way. But looking at the daily table directly, rather than just the correlation coefficient, there's a specific stretch where all three charts genuinely line up.

The days that actually align: November 19–21 and 24–25. Defining "aligned" as a day where qualifying minutes, Kalshi volume, and Polymarket volume are all simultaneously above their own November median, five days qualify — and three of them (19, 20, 21) run back to back:

Date Qualifying minutes Kalshi contracts Polymarket contracts
Nov 19 265 2,643 27,831
Nov 20 619 359 25,188
Nov 21 704 5,605 21,932
Nov 24 189 9,822 29,643
Nov 25 170 1,102 19,114

On arbitrage opportunity counts specifically: those 5 days alone account for 1,947 of the month's 4,493 total qualifying minutes — 43.3% of all of November's arbitrage activity, concentrated in one-sixth of its days. November 21 is the single standout: 704 qualifying minutes in one day (nearly 16% of the entire month by itself), and simultaneously the month's highest Kalshi volume day (5,605 contracts, its monthly peak). That's not a coincidence worth ignoring — this is the same date range flagged independently, much earlier in this project, as the single most volatile stretch in Polymarket's entire ~4-month price history for this contract (Nov 21 alone saw the largest one-day price swing in the whole series, $0.25 → $0.515). Two separate analyses, done at different points and for different reasons, are pointing at the same few days.

The other side of it: this doesn't mean volume and divergence always travel together. Nov 17–18 had some of the highest qualifying-minute counts of the month (348 and 339) with almost no Kalshi volume behind them (64 and 1 contracts) — a reminder that plenty of divergence still shows up on days where Kalshi barely traded at all, consistent with the dead-day flag raised earlier. The aligned days are the clearest, most defensible window for judging whether this arbitrage was actually tradeable — not the whole month.

Arbitrage Identifier

This section is about finding circumstantial evidence that other traders may already be running a similar cross-platform arbitrage strategy — not proof, since none of this can be confirmed from public trade data alone, but signals that would help validate whether the strategy this project has been evaluating is one other market participants are already acting on.

Potential evidence to look for:

  • Trade-size precision. A bot sizing a trade automatically (e.g. "spend exactly $X at the current price") tends to produce oddly precise, many-decimal contract counts, unlike the round numbers a person manually entering a trade would use. We've already spotted a mix of both in the raw Polymarket data pulled earlier in this project.
  • Cross-platform timing correlation. Trades landing within seconds to a couple of minutes of each other on Kalshi and Polymarket — especially clustered right when the price gap opens — would suggest someone executing both legs of the same arbitrage, rather than two platforms trading independently.
  • Timing relative to divergence onset. Trades clustering right at the start of a qualifying episode, rather than spread randomly across its duration, would suggest reactive, opportunity-seeking execution — someone monitoring the spread and acting quickly once it opens.
  • Taker-side concentration. A spike in taker (liquidity-removing) trades specifically during divergence windows, rather than passive resting orders, is consistent with someone urgently crossing the spread to capture a mispricing rather than routine trading that happens to overlap.

Trade Timing, Size & Direction

One combined view of all four signals above, scoped to November 19–25 — the aligned window identified earlier, rather than the full month, since shading every divergence episode across all of November would leave the background solid and unreadable.

Kalshi and Polymarket trades sit in two lanes on a shared time axis, with divergence-qualifying minutes shaded green behind both. Each point encodes: position (does a Kalshi trade line up in time with a Polymarket trade, and does either land right at the start of a shaded band), size (bubble size = trade size), color (green = buy-side taker, orange = sell-side taker), and shape (circle = round trade size, diamond = precise/non-round size).

In [10]:
from matplotlib.lines import Line2D

ALIGN_START = datetime.datetime(2025, 11, 19, tzinfo=datetime.timezone.utc)
ALIGN_END = datetime.datetime(2025, 11, 26, tzinfo=datetime.timezone.utc)

# Divergence episodes within this window, reusing arb_aligned from the first chart in this notebook.
window_aligned = arb_aligned[(arb_aligned["ts"] >= ALIGN_START) & (arb_aligned["ts"] < ALIGN_END)].copy()
run_id = (window_aligned["is_arbitrage"] != window_aligned["is_arbitrage"].shift()).cumsum()
window_episodes = window_aligned[window_aligned["is_arbitrage"]].groupby(run_id)["ts"].agg(start="min", end="max")

kalshi_window_trades = kalshi_trades.loc[ALIGN_START:ALIGN_END].copy()
polymarket_window_trades = polymarket_trades.loc[ALIGN_START:ALIGN_END].copy()


def is_round_size(x):
    return np.isclose(x, np.round(x), atol=0.01)


kalshi_window_trades["is_round"] = is_round_size(kalshi_window_trades["count"])
polymarket_window_trades["is_round"] = is_round_size(polymarket_window_trades["size"])

kalshi_window_trades["buy_side_taker"] = kalshi_window_trades["taker_book_side"] == "ask"
polymarket_window_trades["buy_side_taker"] = polymarket_window_trades["side"] == "BUY"

BUY_COLOR = "seagreen"
SELL_COLOR = "darkorange"


def plot_lane(ax, trades, size_col, y):
    for round_flag, marker in [(True, "o"), (False, "D")]:
        subset = trades[trades["is_round"] == round_flag]
        if subset.empty:
            continue
        colors = np.where(subset["buy_side_taker"], BUY_COLOR, SELL_COLOR)
        sizes = 20 + 8 * np.sqrt(subset[size_col])
        ax.scatter(
            subset.index, [y] * len(subset), s=sizes, c=colors, marker=marker,
            alpha=0.75, edgecolors="black", linewidths=0.5, zorder=2,
        )


display(Markdown("#### Trade Timing, Size & Direction — November 19–25, 2025"))

fig, ax = plt.subplots(figsize=(16, 6))

for start, end in zip(window_episodes["start"], window_episodes["end"]):
    ax.axvspan(start, end, color="lightgreen", alpha=0.3, zorder=0)

plot_lane(ax, kalshi_window_trades, "count", 1)
plot_lane(ax, polymarket_window_trades, "size", 0)

ax.set_yticks([0, 1])
ax.set_yticklabels(["Polymarket", "Kalshi"])
ax.set_ylim(-0.5, 1.5)
ax.set_xlabel("date")
ax.set_title(
    "Trade Timing, Size, and Direction — Nov 19–25, 2025\n"
    "shaded = arbitrage-qualifying minutes | bubble size = trade size | "
    "color = taker direction | shape = round (circle) vs. precise (diamond) size"
)

legend_elements = [
    Line2D([0], [0], marker="o", color="w", markerfacecolor=BUY_COLOR, markersize=10, label="Buy-side taker"),
    Line2D([0], [0], marker="o", color="w", markerfacecolor=SELL_COLOR, markersize=10, label="Sell-side taker"),
    Line2D([0], [0], marker="o", color="w", markerfacecolor="gray", markersize=10, label="Round trade size"),
    Line2D([0], [0], marker="D", color="w", markerfacecolor="gray", markersize=8, label="Precise (non-round) size"),
]
ax.legend(handles=legend_elements, loc="upper left", bbox_to_anchor=(1.01, 1))

plt.tight_layout()
plt.show()

print(f"Divergence episodes shaded: {len(window_episodes)}")
print(
    f"Kalshi trades in window: {len(kalshi_window_trades)} "
    f"({kalshi_window_trades['is_round'].mean():.0%} round-sized)"
)
print(
    f"Polymarket trades in window: {len(polymarket_window_trades)} "
    f"({polymarket_window_trades['is_round'].mean():.0%} round-sized)"
)

Trade Timing, Size & Direction — November 19–25, 2025

No description has been provided for this image
Divergence episodes shaded: 128
Kalshi trades in window: 97 (100% round-sized)
Polymarket trades in window: 824 (38% round-sized)

Trade-Size Matching, Checked Against Chance

Redone with a fixed 5-minute time window rather than "somewhere in the 7-day window," to avoid the earlier version's real flaw: with hundreds of Polymarket trades to check against, taking whichever one happened to be closest in time would have quietly cherry-picked the best-looking candidate out of several, making the match look tighter than it really was.

The test: for each of the 97 Kalshi trades, is there a Polymarket trade of the same size (within 1 contract) and within 5 minutes of it? 19 of 97 (19.6%) pass both conditions. To know whether that's meaningful, Polymarket's trade timestamps were shuffled at random 1,000 times — keeping the same trade sizes and the same set of trade times, just breaking the real link between which size happened at which moment — and the same size-and-time-window test was rerun each time. On average, random shuffling produced only 5.95 matches (6.1%), with 90% of simulations landing between 3 and 10 matches. The real result of 19 is nearly 6 standard deviations above that baseline, and none of the 1,000 random simulations reached 19 matches on their own.

What this does and doesn't show:

  • This is now a real, statistically strong signal — not just "more than chance" but far outside the range chance produced in 1,000 tries. Same-sized trades landing on both platforms within 5 minutes of each other happens far too often to be coincidence.
  • It is not a count of traders, for the same reason as before: 19 is a count of matched trade pairs, not matched people. A single active participant placing many paired trades over the week produces this same pattern.
  • It still isn't direct proof that any specific pair executed together as one arbitrage trade — same size and same 5-minute window is strong circumstantial evidence, not a transaction record linking the two. But this version closes the main gap in the earlier one: timing, not just size, is now part of what's been tested, and tested against a proper randomized baseline rather than an approximate one.

Compared to the earlier, looser check (89.7% matched somewhere in the week): requiring both size and tight timing cuts the raw count down a lot (97 → 19), but what's left is far more trustworthy — a smaller number backed by an actual significance test, rather than a bigger number that turned out to be mostly just Polymarket trading a lot.

Estimated Profit: The Dominant Wallet

Section 8's "Arbitrage Identifier" analysis flagged one wallet — 0xde0463ea7f611b065e8ab06bbfbddad75e6dfa37 ("mwenya" / "Honest-Similarity") — behind 12 of the 19 statistically-significant size-and-timing matches found in the Nov 19-25 window, out of 47 total trades that wallet placed. This section estimates what that activity was actually worth, using the same fee formulas established in Section 1.

On fees: yes, both platforms charge a taker fee for buying and for selling — the fee applies whenever an order crosses the spread and takes liquidity, on either side of the trade, not just on entry. A maker order (a resting limit order that waits for someone else to trade into it) is free on both platforms; only taking is charged. That matters here because this wallet's 47 trades in the window were 100% buys, with zero sells of any kind — so only one taker fee was ever paid per leg (on entry), never a second one on exit. Had they sold before resolution by taking the bid, a second fee would apply and would have cut further into an already thin margin.

Of the 12 matched trades, only 10 are structured as an actual hedge — Kalshi and Polymarket on opposite sides of the same real-world question, priced so that Kalshi price + Polymarket price < $1.00, which is the arbitrage condition from Section 1. The other 2 are both on the same side, which isn't arbitrage at all — covered separately below.

The 10 Hedged Trades

Date (UTC) Kalshi Side Kalshi Price Poly Side Poly Price Price Sum Arb Edge/Contract Contracts Spent Gross Fees Net ROI
Nov 19, 17:45 No $0.47 Yes $0.48 $0.95 $0.05 76 $72.20 $3.80 $2.06 $1.74 2.4%
Nov 21, 11:42 No $0.59 Yes $0.35 $0.94 $0.06 62 $58.28 $3.72 $1.48 $2.24 3.8%
Nov 21, 12:01 No $0.59 Yes $0.36 $0.95 $0.05 29 $27.55 $1.45 $0.71 $0.74 2.7%
Nov 21, 12:01 No $0.59 Yes $0.26 $0.85 $0.15 5 $4.25 $0.75 $0.12 $0.63 14.9%
Nov 25, 02:19 Yes $0.59 No $0.39 $0.98 $0.02 30 $29.40 $0.60 $0.74 -$0.14 -0.5%
Nov 25, 02:23 Yes $0.59 No $0.39 $0.98 $0.02 31 $30.38 $0.62 $0.77 -$0.15 -0.5%
Nov 25, 07:16 Yes $0.61 No $0.36 $0.97 $0.03 18 $17.46 $0.54 $0.43 $0.11 0.6%
Nov 25, 07:26 Yes $0.61 No $0.36 $0.97 $0.03 112 $108.64 $3.36 $2.68 $0.68 0.6%
Nov 25, 07:47 Yes $0.61 No $0.36 $0.97 $0.03 22 $21.34 $0.66 $0.53 $0.13 0.6%
Nov 25, 22:20 No $0.40 Yes $0.58 $0.98 $0.02 20 $19.60 $0.40 $0.57 -$0.17 -0.9%
Total 405 $389.10 $15.90 $10.09 $5.81 1.5%

Fees consumed 63% of gross profit, and 3 of the 10 trades were net losses once fees are included — every one of them had an arb edge of just $0.02 per contract before fees, well inside Kalshi and Polymarket's combined cost to trade. The trades that actually worked were the ones with a wider edge going in (5c-15c per contract); the ones priced closer to the $1.00 breakeven line didn't survive contact with real trading costs. Given Polymarket's fee rate is still an unverified placeholder in this project, the true net result could be worse than the $5.81 shown here — it can't be meaningfully better, since Kalshi's fee formula is the one figure in this table we're confident is accurate.

Large Contract Purchases, Not Hedged

The other 2 matches don't fit the hedge pattern at all — Kalshi and Polymarket both bought Yes, not opposite sides:

Date (UTC) Kalshi Side Kalshi Price Poly Side Poly Price Contracts Spent Gross (if won) Fees Net (if won) ROI (if won)
Nov 19, 17:32 Yes $0.60 Yes $0.557 999 $1,155.84 $842.16 $27.92 $814.24 70.4%
Nov 24, 13:45 Yes $0.58 Yes $0.529 100 $110.95 $89.05 $2.77 $86.28 77.8%

Capital deployed on these two alone: $1,266.79** — more than 3x the capital across all 10 real hedge trades combined. Since the actual outcome was "No Change," both legs of both trades won, implying **$900.52 net and a 70-78% return if these two Polymarket trades genuinely belong to the same actor and intent as their Kalshi counterparts.

That's a much bigger number than the $5.81 from confirmed arbitrage — but it deserves real skepticism, for the exact reason it looks different: these two don't have the complementary structure that makes the other ten credible as linked trades in the first place. A large independent trader betting big on "No Change" on both platforms, with no connection to each other beyond similar size and coincidental timing (both pairs still landed within about 5 seconds of each other, same as the hedged trades), is a live alternative explanation we can't rule out. The $5.81 is the number we actually believe; the $900.52 is a flagged, lower-confidence figure — not something to add to the confirmed total.

Two things worth sitting with:

  • It's odd this wallet never sold. Across all 47 of their trades in the window, every single one was a buy — zero sells. They didn't take the "place limit sells to unlock capital" step described back in the Arbitrage Strategy section; every position was held straight through to resolution, meaning their capital sat locked up for the full duration on every trade, not just the hedged ones — worth noting given Capital Lockup is one of this project's own named KPIs.
  • No other wallet shows this pattern. Of 196 unique wallets active in this market during the window, the remaining 7 matched trades are spread across 6 different wallets, none appearing more than twice. This kind of repeated, seconds-apart, complementary cross-platform pairing is unique to this one account in what we observed.

Fee Mechanics: What These Trades Actually Cost

The "Estimated Profit" tables above used a placeholder Polymarket fee rate — a flat 2% of notional, applied because there was no confirmed rate to work from at the time. This section replaces that guess with real findings: Kalshi's fee formula (confirmed, unchanged throughout this project), and a live investigation into how Polymarket actually charges fees, using the dominant wallet's own market as the test case.

Kalshi: Confirmed

Kalshi's taker fee is public and stable:

fee = ceil(0.07 × price × (1 - price) × contracts × 100) / 100 dollars

7% is the fee rate constant, applied per contract and rounded up to the nearest cent. Maker (resting limit) orders pay 25% of that; only takers — orders that cross the spread — pay a fee at all, and they pay it on both entry and exit if they take on both sides. This formula is used unchanged everywhere in this notebook.

Polymarket: Not a Flat Rate

The original 2% assumption turned out to be structurally wrong, not just numerically approximate. Polymarket doesn't charge one platform-wide rate — every market carries its own feesEnabled flag and feeType in its Gamma API metadata, and the actual numeric rate is set per-market, retrievable live from /fee-rate?token_id=....

Checking the specific Fed-decision markets this wallet traded in November 2025 turned up an important result: feesEnabled: False. Polymarket charged this wallet nothing — their real fee on every one of those trades was $0, not 2%, not any placeholder. This wasn't a fluke or a stale field either: the Bank of Canada rate-decision market examined earlier in this project has feesEnabled: True, confirming fees genuinely do vary market-to-market rather than January's metadata being wrong.

Because that market has already resolved with fees off, there's no live rate left to query for it. As a forward-looking placeholder, we instead checked the currently-active September 2026 Fed-decision market — fed-decision-in-september-762 on Polymarket, KXFEDDECISION-26SEP on Kalshi — confirmed feesEnabled: True, feeType: "economics_fees", and queried its live rate: base_fee: 1000, i.e. 1000 bps = 10%.

Two Formulas, One Real Ambiguity

Polymarket's own documentation and its on-chain contract code don't agree on what that 10% rate actually means in dollars:

  • Docs formula: fee = contracts × rate × price × (1 - price) — structurally identical to Kalshi's, peaking at the 50c mark and falling off toward the extremes. This is also what Polymarket's official py-clob-client SDK computes when it auto-fills fees on order signing.
  • Contract formula: fee = rate_bps × min(price, 1 - price) × contracts / (price × 10000) — a different curve that can diverge meaningfully from the docs formula away from 50c.

This isn't a case of one version being outdated — it's a live, acknowledged inconsistency in Polymarket's own ecosystem (py-clob-client#326), and the same kind of mismatch shows up elsewhere in their published fee schedule. We're adopting the docs formula as the going-forward placeholder in this project: it's what the official SDK actually implements, it mirrors Kalshi's own formula shape (which keeps the two platforms directly comparable), and it's the version Polymarket publishes as authoritative even though the contract code doesn't fully agree with it.

src/fees.py now implements polymarket_taker_fee(price, contracts) = contracts × 0.10 × price × (1 - price), replacing the flat 2% placeholder used earlier in this notebook.

Revisiting the Wallet's Profit Under All Three Rates

Scenario Hedged Net (10 trades) Hedged ROI Directional Net (if won) Directional ROI Combined Net
Actual (Jan 2026 market, fees off) $9.01 2.3% $912.76 72.1% $921.77
Old placeholder (flat 2%) $5.81 1.5% $900.57 71.1% $906.38
New placeholder (10% docs formula) -$0.51 -0.1% $885.62 69.9% $885.11

The real number is the best one: because this specific market had fees switched off, the 10 hedged trades actually netted **$9.01** (2.3% ROI) — better than the $5.81 estimated earlier with too-high a fee guess. But that's a feature of this particular market, not something to rely on going forward.

Swap in a fee-enabled market at something close to the rate that's actually live right now (the 10% docs formula), and the picture flips: the 10 hedged trades go net negative overall, and 7 of the 10 individual trades turn into losses — up from 3 under the old 2% guess. Every trade priced with less than roughly a 10c edge doesn't survive contact with a fee-enabled market at this rate. Only the two large directional trades stay comfortably profitable across all three scenarios; their edge against the $1.00 payout is wide enough to absorb almost any plausible fee rate.

The practical read: this strategy, as executed by this wallet, leaned heavily on trading in a fee-off window. If Polymarket fees on this kind of market ever look like the 10% rate currently live on the September 2026 contract, most of this wallet's volume — trades priced within a few cents of the $1.00 breakeven line — stops being viable, and only the wider-edge opportunities remain worth taking. This is still a placeholder rate, not a confirmed one for the exact market this wallet traded (which no longer has an active rate to check, since it already resolved with fees off) — a live fee-enabled rate-decision market could land above or below it.

Time to Converge: How Long Until the Gap Actually Closes

The Fee Mechanics section above priced individual trades under a hold-to-resolution assumption. The strategy actually being explored here is different: close both legs once the mispricing itself corrects, and recycle that capital into the next opportunity. Under that plan, the binding constraint isn't a single trade's fee cost — it's how long capital sits locked up before an episode's gap actually closes.

"Closed" here means the gross spread genuinely converges back to near-zero (≤ $0.01) — not just dropping back below the $0.05 entry bar, since ducking just under that bar and reopening a few minutes later isn't a real exit. Episodes are allowed to bounce in and out of the ≥$0.05 zone before finally settling; what's measured below is the first touch of the ≤$0.01 band after each qualifying episode begins. This reuses the exact arb_aligned series built at the top of this notebook, which already spans the full contract lifetime through actual resolution — so any episode that never converges shows up honestly as "held to close," not silently dropped from the count.

Question: How Long Does a Typical Gap Take to Close?

Once a qualifying episode begins, how much time typically passes before it's actually gone (down to ≤$0.01)? The chart below is the direct answer.

How to read it: each bar counts qualifying episodes whose convergence time fell in that hour range. The x-axis is log-scaled because the values span three orders of magnitude — some episodes close in minutes, others take over a week — a linear axis would crush the fast cases into an unreadable sliver against the slow ones.

What it shows: there isn't one typical time. There's a cluster of fast episodes on the left and a second, heavier cluster stretching past 100 hours — which is exactly why relying on a single "average" time to plan around would be misleading.

In [11]:
NEAR_ZERO_THRESHOLD = 0.01
MIN_EVENT_DURATION_MINUTES = 5

run_id = (arb_aligned["is_arbitrage"] != arb_aligned["is_arbitrage"].shift()).cumsum()
conv_episodes = arb_aligned[arb_aligned["is_arbitrage"]].groupby(run_id)["ts"].agg(["min", "max"])
conv_episodes["duration_minutes"] = (conv_episodes["max"] - conv_episodes["min"]).dt.total_seconds() / 60
conv_qualifying = conv_episodes[conv_episodes["duration_minutes"] >= MIN_EVENT_DURATION_MINUTES].copy()

arb_indexed = arb_aligned.set_index("ts")

convergence_rows = []
for _, row in conv_qualifying.iterrows():
    ep_start, ep_end = row["min"], row["max"]
    forward = arb_indexed.loc[ep_end:]
    hit = forward[forward["gross_spread"] <= NEAR_ZERO_THRESHOLD]
    if len(hit):
        conv_ts = hit.index[0]
        convergence_rows.append(
            {
                "start": ep_start,
                "end": ep_end,
                "converged": True,
                "conv_ts": conv_ts,
                "minutes": (conv_ts - ep_start).total_seconds() / 60,
            }
        )
    else:
        convergence_rows.append(
            {"start": ep_start, "end": ep_end, "converged": False, "conv_ts": pd.NaT, "minutes": np.nan}
        )

convergence_df = pd.DataFrame(convergence_rows)
converged_only = convergence_df[convergence_df["converged"]]
n_qualifying = len(conv_qualifying)
n_converged = len(converged_only)
n_never = (~convergence_df["converged"]).sum()

hours = converged_only["minutes"] / 60
bins = [0, 0.25, 0.5, 1, 2, 4, 8, 16, 24, 48, 96, 192, 400]

fig, ax3 = plt.subplots(figsize=(14, 5))
ax3.hist(hours, bins=bins, color="steelblue", edgecolor="white")
ax3.set_xscale("log")
ax3.set_xlabel("time to converge (hours, log scale)")
ax3.set_ylabel("count of episodes")
ax3.set_title(
    f"Distribution of Time-to-Converge \u2014 {n_converged} converged episodes, full contract lifetime \u2014 "
    f"median {hours.median():.1f}h, mean {hours.mean():.1f}h",
    fontsize=10,
)
plt.tight_layout()
plt.show()
No description has been provided for this image

Question: By a Given Point in Time, How Much Capital Is Free to Redeploy?

The histogram shows where episodes cluster; this chart answers the more directly useful bankroll question — if capital is committed for X hours, what fraction of positions have converged and are free to redeploy by then?

How to read it: same log-scaled hours on the x-axis; the y-axis is the cumulative percentage of episodes converged by that point, so the curve only ever climbs. The three marked points (1 hour, 24 hours, 1 week) are the practical checkpoints most relevant to an actual recycling schedule.

What it shows: recycling on a strict 1-hour clock frees up only about 11% of positions; a full day gets just over half; a full week is needed to clear roughly 82%. The remaining ~18% take longer still — the same long tail visible in the histogram, now expressed as a share of capital rather than a count of episodes.

In [12]:
sorted_hours = np.sort(hours.values)
cdf_pct = np.arange(1, len(sorted_hours) + 1) / len(sorted_hours) * 100

fig, ax_cdf = plt.subplots(figsize=(14, 5))
ax_cdf.plot(sorted_hours, cdf_pct, color="steelblue", linewidth=1.8, drawstyle="steps-post")
ax_cdf.set_xscale("log")
ax_cdf.set_xlabel("time to converge (hours, log scale)")
ax_cdf.set_ylabel("% of episodes converged by then")
ax_cdf.set_title(
    f"Cumulative Distribution of Time-to-Converge \u2014 {n_converged} converged episodes, full contract lifetime",
    fontsize=10,
)

for label, h in [("1 hour", 1), ("24 hours", 24), ("1 week", 168)]:
    pct_at_h = (hours <= h).mean() * 100
    ax_cdf.axvline(h, color="darkorange", linestyle=":", linewidth=1)
    ax_cdf.axhline(pct_at_h, color="darkorange", linestyle=":", linewidth=1)
    ax_cdf.scatter([h], [pct_at_h], color="darkorange", zorder=5, s=40)
    ax_cdf.annotate(
        f"{label}\n{pct_at_h:.0f}%",
        xy=(h, pct_at_h),
        xytext=(6, -14),
        textcoords="offset points",
        fontsize=8,
        color="darkorange",
    )

ax_cdf.set_ylim(0, 105)
plt.tight_layout()
plt.show()
No description has been provided for this image

Question: What Are the Concrete Numbers Behind the Shape?

The two charts above show the shape of the distribution; these two tables pin down specific numbers from it for quick reference, side by side.

How to read them: the left table reports standard summary statistics on time-to-converge, in hours. The right table restates three of the same practical checkpoints from the CDF chart (1 hour / 24 hours / 1 week) as plain percentages, so they don't need to be read off a curve.

What they show: the gap between the mean (66.3h) and median (20.6h) is the numeric fingerprint of the right-skew visible in both charts above — half of all episodes converge in under a day, but a handful of very slow ones pull the average up to nearly three days.

In [13]:
conv_stats_table = pd.DataFrame(
    {
        "Statistic": ["Mean", "Median", "Min", "25th Percentile", "75th Percentile", "90th Percentile", "Max", "Std Dev"],
        "Time to Converge (hours)": [
            f"{hours.mean():.1f}",
            f"{hours.median():.1f}",
            f"{hours.min():.1f}",
            f"{hours.quantile(0.25):.1f}",
            f"{hours.quantile(0.75):.1f}",
            f"{hours.quantile(0.90):.1f}",
            f"{hours.max():.1f}",
            f"{hours.std():.1f}",
        ],
    }
)

styled_conv_stats_table = (
    conv_stats_table.style
    .hide(axis="index")
    .set_properties(**{"text-align": "center", "padding": "10px"})
    .set_table_styles(
        [
            {"selector": "th", "props": [("text-align", "center"), ("background-color", "#f0f0f0"), ("padding", "10px")]},
        ]
    )
)

CONVERGENCE_WINDOWS = [("1 hour", 1), ("24 hours", 24), ("1 week", 168)]
timing_table = pd.DataFrame(
    {
        "Held For At Least": [label for label, _ in CONVERGENCE_WINDOWS],
        "% of Episodes Converged By Then": [
            f"{(hours <= h).mean():.1%}" for _, h in CONVERGENCE_WINDOWS
        ],
    }
)

styled_timing_table = (
    timing_table.style
    .hide(axis="index")
    .set_properties(**{"text-align": "center", "padding": "10px"})
    .set_table_styles(
        [
            {"selector": "th", "props": [("text-align", "center"), ("background-color", "#f0f0f0"), ("padding", "10px")]},
        ]
    )
)

display(HTML(
    '<div style="display:flex; gap:60px; align-items:flex-start;">'
    f'<div>{styled_conv_stats_table.to_html()}</div>'
    f'<div>{styled_timing_table.to_html()}</div>'
    "</div>"
))

print(
    f"Qualifying episodes (gross spread \u2265 ${ARBITRAGE_THRESHOLD:.2f} for \u2265 {MIN_EVENT_DURATION_MINUTES} min): {n_qualifying:,}\n"
    f"Converged to \u2264 ${NEAR_ZERO_THRESHOLD:.2f} before contract close: {n_converged:,} ({n_converged / n_qualifying:.1%})\n"
    f"Never converged before contract close (held to resolution): {n_never:,} ({n_never / n_qualifying:.1%})"
)
Statistic Time to Converge (hours)
Mean 66.3
Median 20.6
Min 0.1
25th Percentile 4.7
75th Percentile 109.4
90th Percentile 198.9
Max 261.4
Std Dev 82.2
Held For At Least % of Episodes Converged By Then
1 hour 11.2%
24 hours 51.2%
1 week 82.5%
Qualifying episodes (gross spread ≥ $0.05 for ≥ 5 min): 80
Converged to ≤ $0.01 before contract close: 80 (100.0%)
Never converged before contract close (held to resolution): 0 (0.0%)

How Far Does It Reverse?

Question: when a gap does reverse instead of just settling to zero, how far does it actually go — a token cent past zero, or a genuine full flip to the other platform becoming the cheaper side?

The flip-frequency check earlier in this notebook showed how often the mispricing fully reverses direction, but not by how much once it does. This table takes it further: for each qualifying ≥$0.05 episode in November, the signed spread is tracked forward — from that episode's end until the next qualifying episode begins — and its lowest point past entry is recorded, oriented so a positive value means it simply settled back toward zero without reversing, and a negative value means the other platform took over as the cheaper side by that many cents.

How to read it: each column counts episodes that reversed at least that far, so a column's count is always inclusive of every column to its right — an episode that reached $-0.05$ necessarily also passed through $-0.03$ on the way there.

What it shows: most episodes never mount a real reversal — they just fade back toward zero. Only a small minority push all the way to a full flip, consistent with the 2 hard flips already found using the symmetric $\pm$$0.05 definition earlier in this notebook.

In [14]:
REVERSAL_ENTRY_THRESHOLD = 0.05
REVERSAL_MIN_DURATION_MIN = 5
REVERSAL_THRESHOLDS = [0.00, -0.01, -0.02, -0.03, -0.04, -0.05]
REVERSAL_LABELS = ["0.00", "-0.01", "-0.02", "-0.03", "-0.04", "-0.05+"]

nov_reversal = arb_aligned[(arb_aligned["ts"] >= NOV_START) & (arb_aligned["ts"] < NOV_END)].copy()
nov_reversal["signed_spread"] = nov_reversal["kalshi_yes_ask"] - nov_reversal["polymarket_price"]
nov_reversal["is_arb"] = nov_reversal["signed_spread"].abs() >= REVERSAL_ENTRY_THRESHOLD

run_id = (nov_reversal["is_arb"] != nov_reversal["is_arb"].shift()).cumsum()
reversal_episodes = nov_reversal[nov_reversal["is_arb"]].groupby(run_id)["ts"].agg(["min", "max"])
reversal_episodes["duration_minutes"] = (reversal_episodes["max"] - reversal_episodes["min"]).dt.total_seconds() / 60
reversal_qualifying = reversal_episodes[reversal_episodes["duration_minutes"] >= REVERSAL_MIN_DURATION_MIN].reset_index(drop=True)

nov_reversal_indexed = nov_reversal.set_index("ts")
last_nov_ts = nov_reversal["ts"].max()

troughs = []
for i, row in reversal_qualifying.iterrows():
    entry_sign = np.sign(nov_reversal_indexed.loc[row["min"], "signed_spread"])
    entry_sign = entry_sign if entry_sign != 0 else 1
    window_end = reversal_qualifying.loc[i + 1, "min"] if i + 1 < len(reversal_qualifying) else last_nov_ts
    forward = nov_reversal_indexed.loc[row["max"]:window_end]
    troughs.append((entry_sign * forward["signed_spread"]).min())

reversal_qualifying["trough"] = troughs

reversal_counts = [(reversal_qualifying["trough"] <= t).sum() for t in REVERSAL_THRESHOLDS]
reversal_table = pd.DataFrame(
    {
        "Reverses to at least ($)": REVERSAL_LABELS,
        "Count": reversal_counts,
        "% of Episodes": [f"{c / len(reversal_qualifying):.1%}" for c in reversal_counts],
    }
)

display(Markdown(f"#### Depth of Reversal \u2014 {len(reversal_qualifying)} Qualifying Episodes, November 2025"))

styled_reversal_table = (
    reversal_table.style
    .hide(axis="index")
    .set_properties(**{"text-align": "center", "padding": "10px"})
    .set_table_styles(
        [
            {"selector": "table", "props": [("width", "60%")]},
            {"selector": "th", "props": [("text-align", "center"), ("background-color", "#f0f0f0"), ("padding", "10px")]},
        ]
    )
)
display(styled_reversal_table)

Depth of Reversal — 64 Qualifying Episodes, November 2025

Reverses to at least ($) Count % of Episodes
0.00 10 15.6%
-0.01 8 12.5%
-0.02 5 7.8%
-0.03 5 7.8%
-0.04 4 6.2%
-0.05+ 2 3.1%

Key Takeaways — Time to Converge

  • There's no single "typical" convergence time. The distribution is sharply right-skewed: median 20.6 hours, but a long tail (episodes stretching to nearly 11 days) pulls the mean up to 66.3 hours. Planning around the mean alone would badly overstate how long most positions actually stay locked up.
  • Every qualifying episode eventually converged. Across the full contract lifetime through actual resolution, 0 of 80 episodes were still open when the contract closed — the "held to event close" outcome flagged as a real risk earlier in this notebook didn't materialize even once for this market.
  • Daily capital recycling only clears about half of positions. 51% of episodes converge within 24 hours — a bankroll strategy built around daily turnover will regularly be waiting on the other half.
  • A week clears most positions, not all of them. ~82% converge within a week, leaving a real ~18% minority that take longer — worth sizing capital reserves around, not just the median case.
  • Reversals past zero are the exception, not the rule. In the November sample, most episodes settle back toward zero without ever crossing it, and only about 3% mount a genuine flip of a full nickel or more. Waiting for a better exit price on the other side of zero is a low-probability bet, not something to rely on.

Does News Move These Markets — and Does It Matter for Timing a Trade?

Every price move examined so far in this notebook has been treated as a given — something to measure the shape and duration of, not something to explain. This section asks the question directly: what actually causes these moves, and does the kind of news (scheduled vs. unscheduled, and from whom) predict whether a real reaction follows?

Three real events land in a single four-day stretch (Nov 19–21, 2025) already flagged repeatedly elsewhere in this project as the most active window of the month, which makes them a natural, real-world test case rather than a contrived example:

  1. Oct FOMC minutes — released Nov 19, 2:00 PM ET (19:00 UTC). A scheduled Fed release; the exact date and time were public knowledge weeks in advance.
  2. Delayed September jobs report — released Nov 20, 8:30 AM ET (13:30 UTC), via BLS. A scheduled government release, but not from the Fed itself — and delayed months by the 2025 government shutdown.
  3. NY Fed President John Williams' remarks — delivered the morning of Nov 21 at a conference in Santiago, Chile. Unscheduled — no public calendar entry, no fixed time.
In [15]:
news_start = datetime.datetime(2025, 11, 19, tzinfo=datetime.timezone.utc)
news_end = datetime.datetime(2025, 11, 23, tzinfo=datetime.timezone.utc)

news_kalshi = kalshi_history.loc[news_start:news_end]
news_poly = polymarket_history.loc[news_start:news_end]

NEWS_EVENTS = [
    ("Oct minutes\n(Nov 19, 2pm ET)", pd.Timestamp("2025-11-19 19:00:00", tz="UTC"), "darkorange"),
    ("Delayed Sept jobs report\n(Nov 20, 8:30am ET)", pd.Timestamp("2025-11-20 13:30:00", tz="UTC"), "gray"),
    ("Williams speech\n(Nov 21, ~8:30am ET)", pd.Timestamp("2025-11-21 13:30:00", tz="UTC"), "purple"),
]

display(Markdown("#### Price Action Around Three Different Kinds of News \u2014 Nov 19-22, 2025"))

fig, ax = plt.subplots(figsize=(16, 6))
ax.plot(news_kalshi.index, news_kalshi["yes_ask"], color="red", linewidth=0.9, label="Kalshi (yes_ask)")
ax.plot(news_poly.index, news_poly["price"], color="blue", linewidth=0.9, label="Polymarket")

for label, ts, color in NEWS_EVENTS:
    ax.axvline(ts, color=color, linestyle="--", linewidth=1.3)
    ax.text(ts, 0.64, label, fontsize=8, color=color, ha="center", va="bottom")

ax.set_ylim(0.28, 0.68)
ax.set_xlabel("date")
ax.set_ylabel("price ($)")
ax.set_title('"Fed Maintains Rate" / "No change" \u2014 three news events marked')
ax.legend(loc="lower left")
plt.tight_layout()
plt.show()

Price Action Around Three Different Kinds of News — Nov 19-22, 2025

No description has been provided for this image

What actually happened at each marker, measured precisely rather than eyeballed:

  • Oct minutes (scheduled, Fed's own release): Kalshi's yes_ask was $0.51 at 18:55 UTC, five minutes before release. By 19:04 UTC — four minutes *after* — it had spiked to $0.59, an 8-cent jump. By 19:16 UTC, twelve minutes later still, it had reversed back down to $0.50 — a genuine whipsaw, not a clean one-directional repricing. Net effect over the full 20-minute window: essentially flat, but with real intraday volatility along the way.
  • Delayed Sept jobs report (scheduled, but not from the Fed): no meaningful reaction at all. The largest 60-minute move anywhere in the full trading day was 3 cents on Kalshi and 7 cents on Polymarket — in line with normal background noise, not a news reaction.
  • Williams speech (unscheduled, Fed official, but not this market's Fed): the largest, cleanest move of the three — a 24.5-cent swing on Polymarket in under 20 minutes (13:22–13:41 UTC).

The honest takeaway: being on the Fed's own calendar doesn't guarantee a big move (the Oct minutes whipsawed but netted flat), and being scheduled at all doesn't guarantee one either (the jobs report barely registered). The single largest reaction of the three came from an unscheduled remark by an official who wasn't even discussing this specific meeting — which is exactly the finding that led to the Cross-Event-Contract Betting strategy idea (see strategies.md). For timing a trade, this means watching only this contract's own release calendar is not sufficient — the biggest risk (and opportunity) can come from an unplanned remark about a different, related meeting.

One open question this raises, not yet answered: is there a measurable relationship between arbitrage-qualifying activity and overall market volatility more broadly — do wider Kalshi/Polymarket gaps systematically coincide with higher realized volatility, or is that not actually true once tested directly? Worth a dedicated look in a future notebook rather than assumed from this one example.

Is There a Relationship Between Arbitrage and Volatility?

The News Timing section above found that a single official's remarks could move this market more than an entire government data release, and closed with an open question: does higher day-to-day price volatility — regardless of what specifically causes it — systematically coincide with more arbitrage-qualifying activity, the same way volume was already checked against it earlier in this notebook?

Volatility is measured two ways for each day in November: the standard deviation of 1-minute price changes (how choppy an average tick is) and the sum of absolute 1-minute price changes (total price distance traveled that day, regardless of direction). Both are compared against the daily arbitrage-qualifying-minute count (daily_arb_count) already computed earlier in this notebook.

In [16]:
arb_nov_indexed = arb_nov.set_index("ts")

kalshi_daily_vol = arb_nov_indexed["kalshi_yes_ask"].diff().abs().resample("1D").std().reindex(nov_days)
poly_daily_vol = arb_nov_indexed["polymarket_price"].diff().abs().resample("1D").std().reindex(nov_days)

kalshi_daily_travel = arb_nov_indexed["kalshi_yes_ask"].diff().abs().resample("1D").sum().reindex(nov_days, fill_value=0)
poly_daily_travel = arb_nov_indexed["polymarket_price"].diff().abs().resample("1D").sum().reindex(nov_days, fill_value=0)

corr_kalshi_travel = daily_arb_count.corr(kalshi_daily_travel)
corr_poly_travel = daily_arb_count.corr(poly_daily_travel)
corr_kalshi_vol = daily_arb_count.corr(kalshi_daily_vol)
corr_poly_vol = daily_arb_count.corr(poly_daily_vol)

display(Markdown("#### Daily Price \"Travel\" (Total Movement) vs. Daily Arbitrage-Qualifying Minutes"))

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5))

ax1.scatter(daily_arb_count.values, kalshi_daily_travel.values, color="red")
k_fit = np.polyfit(daily_arb_count.values, kalshi_daily_travel.values, 1)
x_line = np.linspace(daily_arb_count.min(), daily_arb_count.max(), 2)
ax1.plot(x_line, np.polyval(k_fit, x_line), color="black", linestyle="--", linewidth=1)
ax1.set_xlabel("qualifying minutes (arbitrage)")
ax1.set_ylabel("Kalshi total daily price movement ($)")
ax1.set_title(f"Kalshi \u2014 r = {corr_kalshi_travel:.2f}")

ax2.scatter(daily_arb_count.values, poly_daily_travel.values, color="blue")
p_fit = np.polyfit(daily_arb_count.values, poly_daily_travel.values, 1)
ax2.plot(x_line, np.polyval(p_fit, x_line), color="black", linestyle="--", linewidth=1)
ax2.set_xlabel("qualifying minutes (arbitrage)")
ax2.set_ylabel("Polymarket total daily price movement ($)")
ax2.set_title(f"Polymarket \u2014 r = {corr_poly_travel:.2f}")

plt.tight_layout()
plt.show()

print(
    f"Correlation, daily arb-qualifying minutes vs. total daily price movement: "
    f"Kalshi r={corr_kalshi_travel:.3f}, Polymarket r={corr_poly_travel:.3f}\n"
    f"Correlation, daily arb-qualifying minutes vs. std dev of 1-min price changes: "
    f"Kalshi r={corr_kalshi_vol:.3f}, Polymarket r={corr_poly_vol:.3f}"
)

Daily Price "Travel" (Total Movement) vs. Daily Arbitrage-Qualifying Minutes

No description has been provided for this image
Correlation, daily arb-qualifying minutes vs. total daily price movement: Kalshi r=0.633, Polymarket r=0.778
Correlation, daily arb-qualifying minutes vs. std dev of 1-min price changes: Kalshi r=0.346, Polymarket r=0.475

What this shows: volatility is a considerably stronger predictor of arbitrage activity than volume turned out to be. Using total daily price movement as the measure, the correlation with arb-qualifying minutes is r = 0.63 (Kalshi) and r = 0.78 (Polymarket) — clearly stronger than the r = 0.33 / r = 0.24 already found for volume earlier in this notebook. The simpler per-tick measure (standard deviation of 1-minute changes) shows a much weaker relationship (r = 0.35 / r = 0.47), which suggests it's specifically the cumulative amount of movement over a day — not how jumpy any single tick is — that tracks with arbitrage opportunity.

That makes intuitive sense: the two platforms don't update in perfect lockstep, so a day where prices genuinely travel a long way — whichever direction, however they get there — is a day where the two platforms are more likely to temporarily disagree along the way, which is exactly what an arbitrage-qualifying gap is.

Caveat, consistent with everything else in this notebook: this is still a single-contract, single-month sample. It's a correlation, not a demonstrated causal link, and the direction of causality — does volatility cause divergence, or does some third factor (like the news events in the section above) drive both independently — isn't established here.