Kalshi ↔ Polymarket Arbitrage Feasibility¶
Goal: not a trading bot yet — just checking whether a fee-surviving price gap between matching Kalshi/Polymarket contracts exists and lasts long enough to be tradeable.
Process: this notebook is built once and rerun per batch of events (2 at a time). Change ACTIVE_BATCH in the Config cell below, Restart & Run All, and the results append to data/processed/kpi_ledger.csv. Batch 1 = Bank of Canada Apr + Jun 2026 (build/validate here first). Batch 2 = Fed Jan + Mar 2026 (same cells, zero code changes — proves the process generalizes).
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)
LOOKBACK_DAYS = 7 # how far before resolution to pull price history
1. Config — pick the active batch¶
Change this one line to move to the next group of events. Everything below loops over ACTIVE_BATCH only.
# --- CHANGE THIS LINE TO SWITCH BATCHES ---
ACTIVE_BATCH = events_config.BATCH_2 # BATCH_1 = BoC Apr+Jun 2026, BATCH_2 = Fed Jan+Mar 2026
# --------------------------------------------
print(f"Active batch: {ACTIVE_BATCH}")
for key in ACTIVE_BATCH:
print(f" {key}: {events_config.EVENTS[key]['label']}")
Active batch: ['fed_jan_2026', 'fed_mar_2026'] fed_jan_2026: Fed Decision - January 2026 fed_mar_2026: Fed Decision - March 2026
2. Market Discovery¶
Resolve each event's Kalshi series ticker and Polymarket slug into concrete per-outcome contracts, then match them by parsed direction/magnitude (e.g. "Cut 25bps" ↔ "25 bps decrease"). Kalshi always splits hike/cut into an exact-25bps contract and a >25bps contract; Polymarket does the same for cuts but sometimes lumps all hikes into one contract with no magnitude split — those get reported as skipped, not guessed at, since there's no single Kalshi contract they correspond to.
discovery = {} # event_key -> {"event": {...}, "matched": [...], "skipped": [...]}
for event_key in ACTIVE_BATCH:
event_cfg = events_config.EVENTS[event_key]
kalshi_markets = kalshi_client.get_historical_markets(event_cfg["kalshi_series_ticker"])
poly_event = polymarket_client.get_event_by_slug(event_cfg["polymarket_slug"])
if not kalshi_markets or poly_event is None:
raise RuntimeError(
f"[{event_key}] discovery failed: kalshi_markets={len(kalshi_markets)}, "
f"poly_event found={poly_event is not None}"
)
matched, skipped = market_matching.match_outcomes(kalshi_markets, poly_event["markets"])
discovery[event_key] = {"event": event_cfg, "matched": matched, "skipped": skipped}
# Confirmation table — check this by eye before trusting anything downstream.
rows = []
for event_key, d in discovery.items():
for m in d["matched"]:
rows.append(
{
"event": event_key,
"outcome": m["outcome_label"],
"kalshi_ticker": m["kalshi_ticker"],
"kalshi_sub_title": m["kalshi_sub_title"],
"polymarket_question": m["polymarket_question"],
}
)
display_df = pd.DataFrame(rows)
print(f"Matched {len(display_df)} outcome pairs across {len(ACTIVE_BATCH)} events:")
display(display_df)
print("\nSkipped (no 1:1 match found):")
for event_key, d in discovery.items():
for s in d["skipped"]:
print(f" [{event_key}] {s['side']}: {s['identifier']} -- {s['reason']}")
Matched 6 outcome pairs across 2 events:
| event | outcome | kalshi_ticker | kalshi_sub_title | polymarket_question | |
|---|---|---|---|---|---|
| 0 | fed_jan_2026 | cut_big | KXFEDDECISION-26JAN-C26 | Cut >25bps | Fed decreases interest rates by 50+ bps after ... |
| 1 | fed_jan_2026 | cut_small | KXFEDDECISION-26JAN-C25 | Cut 25bps | Fed decreases interest rates by 25 bps after J... |
| 2 | fed_jan_2026 | maintain_None | KXFEDDECISION-26JAN-H0 | Fed maintains rate | No change in Fed interest rates after January ... |
| 3 | fed_mar_2026 | cut_big | KXFEDDECISION-26MAR-C26 | Cut >25bps | Will the Fed decrease interest rates by 50+ bp... |
| 4 | fed_mar_2026 | cut_small | KXFEDDECISION-26MAR-C25 | Cut 25bps | Will the Fed decrease interest rates by 25 bps... |
| 5 | fed_mar_2026 | maintain_None | KXFEDDECISION-26MAR-H0 | Fed maintains rate | Will there be no change in Fed interest rates ... |
Skipped (no 1:1 match found):
[fed_jan_2026] polymarket: fed-increases-interest-rates-by-25-bps-after-january-2026-meeting -- lumps multiple magnitude buckets together — no single matching Kalshi contract
[fed_jan_2026] kalshi: KXFEDDECISION-26JAN-H26 -- no Polymarket contract found for parsed outcome ('hike', 'big')
[fed_jan_2026] kalshi: KXFEDDECISION-26JAN-H25 -- no Polymarket contract found for parsed outcome ('hike', 'small')
[fed_mar_2026] polymarket: will-the-fed-increase-interest-rates-by-25-bps-after-the-march-2026-meeting -- lumps multiple magnitude buckets together — no single matching Kalshi contract
[fed_mar_2026] kalshi: KXFEDDECISION-26MAR-H26 -- no Polymarket contract found for parsed outcome ('hike', 'big')
[fed_mar_2026] kalshi: KXFEDDECISION-26MAR-H25 -- no Polymarket contract found for parsed outcome ('hike', 'small')
3. Data Ingestion¶
Pull LOOKBACK_DAYS of history before each matched contract's Kalshi close_time. Kalshi candlesticks are capped at 5000 candles/request (handled by chunking in kalshi_client.get_candlesticks); Polymarket's /prices-history can silently return coarser-than-requested data for closed markets, so the actual granularity returned is measured and logged per contract — don't assume 1-minute resolution without checking the printed value below.
Raw responses are cached to data/raw/*.parquet keyed by ticker/token, so re-running this cell after the first time is instant and doesn't re-hit either API.
raw_data = {} # (event_key, outcome_label) -> {"kalshi": df, "polymarket": df, ...}
for event_key, d in discovery.items():
for m in d["matched"]:
outcome = m["outcome_label"]
cache_key = f"{event_key}__{outcome}"
kalshi_cache = DATA_RAW / f"{cache_key}__kalshi.parquet"
poly_cache = DATA_RAW / f"{cache_key}__polymarket.parquet"
close_time = pd.to_datetime(m["kalshi_market"]["close_time"], utc=True)
window_start = close_time - datetime.timedelta(days=LOOKBACK_DAYS)
if kalshi_cache.exists():
k_df = pd.read_parquet(kalshi_cache)
else:
k_df = kalshi_client.get_candlesticks(
m["kalshi_ticker"], window_start, close_time, period_interval=1
)
k_df.to_parquet(kalshi_cache)
if poly_cache.exists():
p_df = pd.read_parquet(poly_cache)
else:
p_df = polymarket_client.get_prices_history(
m["polymarket_token_id_yes"], window_start, close_time, fidelity=1
)
p_df.to_parquet(poly_cache)
poly_granularity_s = polymarket_client.actual_granularity_seconds(p_df)
raw_data[(event_key, outcome)] = {
"kalshi": k_df,
"polymarket": p_df,
"kalshi_ticker": m["kalshi_ticker"],
"poly_token": m["polymarket_token_id_yes"],
"kalshi_market": m["kalshi_market"],
"polymarket_market": m["polymarket_market"],
"poly_granularity_s": poly_granularity_s,
}
poly_note = "" if (poly_granularity_s or 0) <= 90 else " <-- COARSER than 1-min, see note above"
print(
f"[{event_key}/{outcome}] kalshi: {len(k_df)} rows "
f"(price_source={k_df['price_source'].value_counts().to_dict() if len(k_df) else {}}) | "
f"polymarket: {len(p_df)} rows, actual granularity ~{poly_granularity_s}s{poly_note}"
)
[fed_jan_2026/cut_big] kalshi: 3943 rows (price_source={'bid_ask_mid': 2916, 'trade': 1027}) | polymarket: 10079 rows, actual granularity ~60.0s
[fed_jan_2026/cut_small] kalshi: 6182 rows (price_source={'trade': 3409, 'bid_ask_mid': 2773}) | polymarket: 10079 rows, actual granularity ~60.0s
[fed_jan_2026/maintain_None] kalshi: 9374 rows (price_source={'bid_ask_mid': 7334, 'trade': 2040}) | polymarket: 10079 rows, actual granularity ~60.0s
[fed_mar_2026/cut_big] kalshi: 907 rows (price_source={'trade': 856, 'bid_ask_mid': 51}) | polymarket: 10071 rows, actual granularity ~60.0s
[fed_mar_2026/cut_small] kalshi: 8832 rows (price_source={'bid_ask_mid': 6435, 'trade': 2397}) | polymarket: 10071 rows, actual granularity ~60.0s
[fed_mar_2026/maintain_None] kalshi: 8900 rows (price_source={'bid_ask_mid': 7870, 'trade': 1030}) | polymarket: 10071 rows, actual granularity ~60.0s
4. Alignment¶
merge_asof each Polymarket tick series onto the Kalshi 1-minute grid (backward-looking, so we never align to a Polymarket price from after the Kalshi timestamp — that would be lookahead bias). A 5-minute tolerance caps how stale a matched Polymarket price is allowed to be; anything beyond that is dropped rather than falsely aligned.
ALIGN_TOLERANCE = pd.Timedelta("5min")
aligned_data = {} # (event_key, outcome_label) -> aligned DataFrame
for key, d in raw_data.items():
k_df, p_df = d["kalshi"], d["polymarket"]
if k_df.empty or p_df.empty:
print(f"{key}: skipping alignment, empty series (kalshi={len(k_df)}, poly={len(p_df)})")
continue
k_sorted = k_df.reset_index().rename(
columns={"timestamp": "ts", "price": "kalshi_price", "yes_bid": "kalshi_bid", "yes_ask": "kalshi_ask"}
)
p_sorted = p_df.reset_index().rename(columns={"timestamp": "ts", "price": "poly_price"})
merged = pd.merge_asof(
k_sorted[["ts", "kalshi_price", "kalshi_bid", "kalshi_ask", "price_source"]],
p_sorted[["ts", "poly_price"]],
on="ts",
tolerance=ALIGN_TOLERANCE,
direction="backward",
).dropna(subset=["kalshi_price", "poly_price"])
aligned_data[key] = merged
print(f"{key}: {len(merged)} aligned rows ({len(k_sorted)} kalshi rows -> {len(merged)} after alignment/dropna)")
('fed_jan_2026', 'cut_big'): 3943 aligned rows (3943 kalshi rows -> 3943 after alignment/dropna)
('fed_jan_2026', 'cut_small'): 6182 aligned rows (6182 kalshi rows -> 6182 after alignment/dropna)
('fed_jan_2026', 'maintain_None'): 9373 aligned rows (9374 kalshi rows -> 9373 after alignment/dropna)
('fed_mar_2026', 'cut_big'): 907 aligned rows (907 kalshi rows -> 907 after alignment/dropna)
('fed_mar_2026', 'cut_small'): 8831 aligned rows (8832 kalshi rows -> 8831 after alignment/dropna)
('fed_mar_2026', 'maintain_None'): 8899 aligned rows (8900 kalshi rows -> 8899 after alignment/dropna)
5. Fee Assumptions — check these before trusting any KPI below¶
Both platforms only charge a taker fee (maker/resting orders are free on both) — since an arb bot has to cross the spread on at least one leg to capture the edge, taker fees are the relevant cost here, not maker fees:
- Kalshi taker fee =
7¢ × C × (1 − C)per contract, whereCis the contract price in dollars. This is Kalshi's published formula, high confidence. - Polymarket taker fee = a flat % of notional, currently set to a placeholder below — Polymarket's real taker rate varies 0%–1.8% by market category and isn't independently confirmed for rate-decision markets specifically. Edit
POLY_TAKER_RATEbelow to your actual observed rate before trusting the Net Edge numbers.
# EDIT THESE if your actual account rates differ from the defaults in src/fees.py
POLY_TAKER_RATE = fees.POLYMARKET_TAKER_RATE # ASSUMPTION — VERIFY AGAINST YOUR ACTUAL ACCOUNT/CATEGORY RATE
print(f"Kalshi taker fee formula: {fees.KALSHI_TAKER_RATE} x C x (1-C) per contract")
print(f"Polymarket taker fee rate (in use this run): {POLY_TAKER_RATE:.2%} <-- placeholder, confirm before trusting Net Edge")
Kalshi taker fee formula: 0.07 x C x (1-C) per contract Polymarket taker fee rate (in use this run): 2.00% <-- placeholder, confirm before trusting Net Edge
6. Calculator — Gross Spread, Net Edge, Time-to-Convergence, Depth Proxy, Capital Lockup¶
Depth/slippage caveat: neither platform archives historical full order-book (L2) snapshots, so "how much size could I actually get filled" isn't retroactively knowable. As a proxy, this uses Kalshi's yes_bid/yes_ask candlestick width (Polymarket's history endpoint only returns a single last-traded price, no bid/ask) — a wide Kalshi bid/ask spread is a signal that size was thin, not a literal walked-book fill price. Treat the "net edge vs trade size" chart below as directional, not exact.
Capital lockup caveat: uses Kalshi's occurrence_datetime (when the event outcome was actually determined) as the shared T0 for both legs, then Kalshi's settlement_ts and Polymarket's closedTime as each platform's "funds available" proxy.
TRADE_SIZES = [50, 100, 250, 500, 1000, 2500] # dollars, for the depth-proxy KPI
BASE_SIZE = 100 # reference size the observed bid/ask width is assumed to cover cleanly
calc_data = {} # key -> aligned df with gross_spread/net_edge/profitable columns added
duration_stats = {} # key -> {mean/median/min/max minutes of contiguous profitable windows}
capital_lockup = {} # key -> {kalshi_minutes, polymarket_minutes}
for key, merged in aligned_data.items():
df = merged.copy()
df["gross_spread"] = (df["kalshi_price"] - df["poly_price"]).abs()
df["kalshi_fee"] = df["kalshi_price"].apply(fees.kalshi_taker_fee)
df["poly_fee"] = df["poly_price"].apply(lambda p: fees.polymarket_taker_fee(p, rate=POLY_TAKER_RATE))
df["net_edge"] = df["gross_spread"] - df["kalshi_fee"] - df["poly_fee"]
df["profitable"] = df["net_edge"] > 0
df["kalshi_bid_ask_width"] = (df["kalshi_ask"] - df["kalshi_bid"]).clip(lower=0)
# Depth/slippage proxy: penalize larger simulated sizes by how wide the
# observed Kalshi bid/ask already was at that moment (see caveat above).
for size in TRADE_SIZES:
penalty = df["kalshi_bid_ask_width"] * (size / BASE_SIZE)
df[f"net_edge_at_{size}"] = df["net_edge"] - penalty
calc_data[key] = df
# Time-to-convergence: contiguous runs of `profitable == True`.
run_id = (df["profitable"] != df["profitable"].shift()).cumsum()
profitable_runs = df[df["profitable"]].groupby(run_id)["ts"].agg(["min", "max", "count"])
if len(profitable_runs):
run_minutes = (profitable_runs["max"] - profitable_runs["min"]).dt.total_seconds() / 60
duration_stats[key] = {
"n_windows": len(run_minutes),
"mean_minutes": run_minutes.mean(),
"median_minutes": run_minutes.median(),
"max_minutes": run_minutes.max(),
}
else:
duration_stats[key] = {"n_windows": 0, "mean_minutes": None, "median_minutes": None, "max_minutes": None}
# Capital lockup: time from the real-world event outcome to each platform's settlement proxy.
# `occurrence_datetime` (exact announcement moment) isn't always populated by Kalshi —
# fall back to `close_time` (when the market stopped trading because the outcome was known).
k_market = raw_data[key]["kalshi_market"]
p_market = raw_data[key]["polymarket_market"]
t0_source = "occurrence_datetime" if k_market.get("occurrence_datetime") else "close_time (fallback)"
t0 = pd.to_datetime(k_market.get("occurrence_datetime") or k_market["close_time"], utc=True)
kalshi_settled = pd.to_datetime(k_market["settlement_ts"], utc=True)
poly_settled = pd.to_datetime(p_market["closedTime"], utc=True)
capital_lockup[key] = {
"event_t0": t0,
"event_t0_source": t0_source,
"kalshi_minutes": (kalshi_settled - t0).total_seconds() / 60,
"polymarket_minutes": (poly_settled - t0).total_seconds() / 60,
}
for key in calc_data:
ds, cl = duration_stats[key], capital_lockup[key]
print(
f"{key}: pct_profitable={calc_data[key]['profitable'].mean():.1%} | "
f"profitable windows={ds['n_windows']} (median {ds['median_minutes']}min) | "
f"lockup kalshi={cl['kalshi_minutes']:.0f}min poly={cl['polymarket_minutes']:.0f}min "
f"(t0 source: {cl['event_t0_source']})"
)
('fed_jan_2026', 'cut_big'): pct_profitable=0.0% | profitable windows=0 (median Nonemin) | lockup kalshi=13min poly=234min (t0 source: close_time (fallback))
('fed_jan_2026', 'cut_small'): pct_profitable=43.1% | profitable windows=929 (median 1.0min) | lockup kalshi=13min poly=235min (t0 source: close_time (fallback))
('fed_jan_2026', 'maintain_None'): pct_profitable=0.0% | profitable windows=0 (median Nonemin) | lockup kalshi=13min poly=233min (t0 source: close_time (fallback))
('fed_mar_2026', 'cut_big'): pct_profitable=0.0% | profitable windows=0 (median Nonemin) | lockup kalshi=13min poly=199min (t0 source: close_time (fallback))
('fed_mar_2026', 'cut_small'): pct_profitable=2.8% | profitable windows=7 (median 26.0min) | lockup kalshi=13min poly=199min (t0 source: close_time (fallback))
('fed_mar_2026', 'maintain_None'): pct_profitable=0.0% | profitable windows=0 (median Nonemin) | lockup kalshi=13min poly=188min (t0 source: close_time (fallback))
7. Visualization¶
### 7a. Price convergence — both legs, full lookback window, per outcome pair
n = len(calc_data)
fig, axes = plt.subplots(n, 1, figsize=(11, 3.2 * n), squeeze=False)
for ax, (key, df) in zip(axes[:, 0], calc_data.items()):
ax.plot(df["ts"], df["kalshi_price"], label="Kalshi", linewidth=1)
ax.plot(df["ts"], df["poly_price"], label="Polymarket", linewidth=1)
ax.set_title(f"{key[0]} / {key[1]}")
ax.set_ylabel("price ($)")
ax.legend(loc="lower left", fontsize=8)
plt.tight_layout()
plt.show()
### 7b. Net Edge distribution — is there a positive-profit zone at all?
all_net_edge = pd.concat(
[df.assign(event_outcome=f"{k[0]}/{k[1]}")[["net_edge", "event_outcome"]] for k, df in calc_data.items()],
ignore_index=True,
)
plt.figure(figsize=(10, 5))
sns.histplot(data=all_net_edge, x="net_edge", hue="event_outcome", bins=60, element="step", stat="density", common_norm=False)
plt.axvline(0, color="black", linestyle="--", linewidth=1, label="breakeven")
plt.title("Net Edge distribution (gross spread minus both platforms' taker fees)")
plt.xlabel("net edge ($ per $1 notional)")
plt.legend(fontsize=8)
plt.tight_layout()
plt.show()
print(f"Overall % of aligned minutes with positive net edge: {(all_net_edge['net_edge'] > 0).mean():.2%}")
Overall % of aligned minutes with positive net edge: 7.64%
### 7c. Net Edge vs. simulated trade size (depth/slippage proxy — see caveat in section 6)
size_rows = []
for key, df in calc_data.items():
for size in TRADE_SIZES:
size_rows.append(
{
"event_outcome": f"{key[0]}/{key[1]}",
"trade_size": size,
"mean_net_edge": df[f"net_edge_at_{size}"].mean(),
"pct_profitable": (df[f"net_edge_at_{size}"] > 0).mean(),
}
)
size_df = pd.DataFrame(size_rows)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 4.5))
sns.lineplot(data=size_df, x="trade_size", y="mean_net_edge", hue="event_outcome", marker="o", ax=ax1)
ax1.axhline(0, color="black", linestyle="--", linewidth=1)
ax1.set_title("Mean net edge vs. simulated trade size")
ax1.set_xlabel("trade size ($)")
sns.lineplot(data=size_df, x="trade_size", y="pct_profitable", hue="event_outcome", marker="o", ax=ax2, legend=False)
ax2.set_title("% of time profitable vs. simulated trade size")
ax2.set_xlabel("trade size ($)")
ax2.set_ylabel("% profitable")
plt.tight_layout()
plt.show()
### 7d. Time-to-convergence — how long do profitable windows last?
# Resolution caveat: this can only see windows >= the data's own granularity
# (~1 min for Kalshi, check the logged Polymarket granularity in section 3).
# If the numbers below look tiny/borderline, that's a sign the real decay
# happens faster than either historical API can measure, not that it's zero.
window_rows = []
for key, df in calc_data.items():
run_id = (df["profitable"] != df["profitable"].shift()).cumsum()
runs = df[df["profitable"]].groupby(run_id)["ts"].agg(["min", "max"])
minutes = (runs["max"] - runs["min"]).dt.total_seconds() / 60
for m in minutes:
window_rows.append({"event_outcome": f"{key[0]}/{key[1]}", "duration_minutes": m})
window_df = pd.DataFrame(window_rows)
if len(window_df):
plt.figure(figsize=(9, 4.5))
sns.histplot(data=window_df, x="duration_minutes", hue="event_outcome", bins=30, multiple="dodge")
plt.title("Duration of contiguous profitable (net edge > 0) windows")
plt.xlabel("minutes")
plt.tight_layout()
plt.show()
else:
print("No profitable windows found in this batch — nothing to plot.")
8. Summary KPI table — the go/no-go readout for this batch¶
One row per event (aggregated across its matched outcome pairs) so it lines up with the ledger. Per-outcome detail is in the plots above if a specific outcome needs a closer look.
summary_rows = []
for event_key in ACTIVE_BATCH:
event_keys_for_this_event = [k for k in calc_data if k[0] == event_key]
if not event_keys_for_this_event:
print(f"{event_key}: no aligned data available, skipping in summary")
continue
combined = pd.concat([calc_data[k] for k in event_keys_for_this_event], ignore_index=True)
pooled_durations = window_df[window_df["event_outcome"].str.startswith(f"{event_key}/")]["duration_minutes"] \
if len(window_df) else pd.Series(dtype=float)
kalshi_gran = np.median([
raw_data[k]["kalshi"].index.to_series().diff().dropna().dt.total_seconds().median()
for k in event_keys_for_this_event if len(raw_data[k]["kalshi"]) > 1
])
poly_gran = np.mean([raw_data[k]["poly_granularity_s"] for k in event_keys_for_this_event if raw_data[k]["poly_granularity_s"]])
summary_rows.append(
{
"event_key": event_key,
"event_label": events_config.EVENTS[event_key]["label"],
"run_timestamp": pd.Timestamp.now("UTC").isoformat(),
"n_outcome_pairs": len(event_keys_for_this_event),
"kalshi_granularity_actual_s": kalshi_gran,
"polymarket_granularity_actual_s": poly_gran,
"gross_spread_mean": combined["gross_spread"].mean(),
"gross_spread_max": combined["gross_spread"].max(),
"net_edge_mean": combined["net_edge"].mean(),
"net_edge_median": combined["net_edge"].median(),
"pct_time_profitable": combined["profitable"].mean(),
"mean_duration_profitable_min": pooled_durations.mean() if len(pooled_durations) else None,
"median_duration_profitable_min": pooled_durations.median() if len(pooled_durations) else None,
"capital_lockup_kalshi_min": np.mean([capital_lockup[k]["kalshi_minutes"] for k in event_keys_for_this_event]),
"capital_lockup_poly_min": np.mean([capital_lockup[k]["polymarket_minutes"] for k in event_keys_for_this_event]),
}
)
summary_df = pd.DataFrame(summary_rows)
display(summary_df)
| event_key | event_label | run_timestamp | n_outcome_pairs | kalshi_granularity_actual_s | polymarket_granularity_actual_s | gross_spread_mean | gross_spread_max | net_edge_mean | net_edge_median | pct_time_profitable | mean_duration_profitable_min | median_duration_profitable_min | capital_lockup_kalshi_min | capital_lockup_poly_min | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | fed_jan_2026 | Fed Decision - January 2026 | 2026-08-17T21:48:10.778913+00:00 | 3 | 60.0 | 60.0 | 0.007755 | 0.0215 | -0.011813 | -0.00673 | 0.136527 | 3.184069 | 1.0 | 12.841256 | 234.100000 |
| 1 | fed_mar_2026 | Fed Decision - March 2026 | 2026-08-17T21:48:10.787816+00:00 | 3 | 60.0 | 60.0 | 0.002973 | 0.0155 | -0.016563 | -0.00959 | 0.013414 | 34.714286 | 26.0 | 13.158029 | 195.494444 |
9. Ledger Update¶
Upserts this batch's rows into data/processed/kpi_ledger.csv by event_key — rerunning a batch (e.g. after adjusting POLY_TAKER_RATE) updates its row instead of duplicating it. Prints the full accumulated ledger, so results from earlier batches stay visible even though only one batch runs per session.
LEDGER_PATH = DATA_PROCESSED / "kpi_ledger.csv"
if LEDGER_PATH.exists():
ledger = pd.read_csv(LEDGER_PATH)
ledger = ledger[~ledger["event_key"].isin(summary_df["event_key"])] # drop stale rows for these events
ledger = pd.concat([ledger, summary_df], ignore_index=True)
else:
ledger = summary_df.copy()
ledger.to_csv(LEDGER_PATH, index=False)
print(f"Ledger updated at {LEDGER_PATH} ({len(summary_df)} row(s) upserted this run)")
display(ledger)
Ledger updated at C:\Users\mesim\OneDrive\Desktop\arb_bot\data\processed\kpi_ledger.csv (2 row(s) upserted this run)
| event_key | event_label | run_timestamp | n_outcome_pairs | kalshi_granularity_actual_s | polymarket_granularity_actual_s | gross_spread_mean | gross_spread_max | net_edge_mean | net_edge_median | pct_time_profitable | mean_duration_profitable_min | median_duration_profitable_min | capital_lockup_kalshi_min | capital_lockup_poly_min | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | boc_apr_2026 | Bank of Canada Decision - April 2026 | 2026-08-17T21:44:30.996740+00:00 | 3 | 180.0 | 60.0 | 0.008111 | 0.4480 | -0.010251 | -0.00653 | 0.084038 | 42.061728 | 13.0 | 57.323839 | 140.180889 |
| 1 | boc_jun_2026 | Bank of Canada Decision - June 2026 | 2026-08-17T21:44:31.159164+00:00 | 3 | 120.0 | 60.0 | 0.026830 | 0.1380 | 0.005896 | -0.00010 | 0.499123 | 36.121795 | 11.0 | 49.560442 | 144.513194 |
| 2 | fed_jan_2026 | Fed Decision - January 2026 | 2026-08-17T21:48:10.778913+00:00 | 3 | 60.0 | 60.0 | 0.007755 | 0.0215 | -0.011813 | -0.00673 | 0.136527 | 3.184069 | 1.0 | 12.841256 | 234.100000 |
| 3 | fed_mar_2026 | Fed Decision - March 2026 | 2026-08-17T21:48:10.787816+00:00 | 3 | 60.0 | 60.0 | 0.002973 | 0.0155 | -0.016563 | -0.00959 | 0.013414 | 34.714286 | 26.0 | 13.158029 | 195.494444 |