* ARB_BOT
  • About
    • Thesis
    • FAQ
  • Research
    • Notebook 01 — KPI Feasibility
    • Notebook 02 — Arbitrage Feasibility Study
    • Notebook 03 — Volume Exploration
    • Notebook 04 — Arbitrage Strategy Backtest
    • Notebook 05 — Hold-to-Close Strategy
    • Notebook 06 — Multi-Event Arbitrage Backtest
    • Notebook 07 — Bank of England Decision Backtest
    • Notebook 08 — Bank of Canada Decision Backtest
    • Notebook 09 — ECB Decision Backtest
    • Notebook 10 — CPI / Annual Inflation Backtest
    • Notebook 11 — Unemployment Rate EV Exploration
    • Notebook 12 — Unemployment Full-Ladder Search
RESEARCH_STATUS // FEASIBILITY

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).

In [1]:
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.

In [2]:
# --- 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.

In [3]:
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.

In [4]:
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.

In [5]:
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, where C is 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_RATE below to your actual observed rate before trusting the Net Edge numbers.
In [6]:
# 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.

In [7]:
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¶

In [8]:
### 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()
No description has been provided for this image
In [9]:
### 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%}")
No description has been provided for this image
Overall % of aligned minutes with positive net edge: 7.64%
In [10]:
### 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()
No description has been provided for this image
In [11]:
### 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.")
No description has been provided for this image

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.

In [12]:
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.

In [13]:
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