Notebook 10: CPI / Annual Inflation Backtest

This notebook takes the same hedge-arbitrage strategy backtested in notebooks 04, 06, 07, 08, and 09 and points it at a structurally different event type: CPI / Annual Inflation, across five 2026 releases — January, February, March, April, and May. Same engine, same variables, same entry/exit rules — only the events and the outcome-matching logic change.

A genuinely different market shape, not a wording variant. Every prior notebook backtested rate decisions, where both platforms split outcomes by direction and magnitude (hike/cut/no-change). CPI has no such vocabulary — both platforms instead offer a grid of exact-point contracts ("Exactly 3.4%" on Kalshi, "3.4%" on Polymarket) plus, on Polymarket's side only, two catch-all tail contracts at the extremes. market_matching.match_outcomes() returned zero matches across all six months on the first attempt (it's built entirely around hike/cut/no-change parsing) — a new market_matching.match_outcomes_point_value() was added: it matches on exact numeric-value equality and reports Polymarket's tail buckets as skipped rather than matched, consistent with this project's established policy that tails are out of scope (there is no single Kalshi point contract a tail corresponds to 1:1).

Verified against real resolved data before building, not just rules text. All six months of 2026 (January–June) were checked against each platform's actual resolved winner. Three months landed on Polymarket's exact-point contracts and both platforms agreed to the decimal (February 2.4%, April 3.8%, May 4.2%); the other three landed in Polymarket's tail buckets but were fully consistent with what that tail claimed (January 2.4% ≤ 2.8%, March 3.3% ≥ 2.8%, June 3.5% ≤ 3.6%). Zero contradictions anywhere — unlike GDP, which was rejected earlier in this project for a genuine boundary-value disagreement between platforms.

Multi-event handling follows the established pattern: all matched buckets across the five months are folded into one combined BUCKETS dict, keyed and labeled by event, and run through a single backtest pass with one shared bankroll. Bucket count varies by month with the width of Polymarket's covered range: 34 matched buckets across 5 months (January 3, February 5, March 7, April 9, May 10) — January's and March's Polymarket ranges were set narrower than the eventual actual print, so fewer of their points had a Kalshi counterpart at all (a market-design/liquidity quirk on Polymarket's side, not a matching bug).

June 2026 is excluded from this batch. Its 9 matched buckets all return a genuine 404 Not Found on Kalshi's candlestick endpoint — the same too-recently-resolved archival gap that excluded Bank of England's July decision from notebook 07 (June's event closed 2026-07-14, about seven weeks before this was checked). Not fixable by retry; it can be revisited once Kalshi's archive catches up.

Fee rate is not uniform across these five months: January, February, and March have feesEnabled: False (fee = $0) on Polymarket's side; April and May have feesEnabled: True at the standard 10% rate — handled per-bucket exactly as in notebooks 06–09.

Strategy Definition

Monitor: the backtest logs, but does not gate on, three contextual signals already investigated in notebook 03 — scheduled/unscheduled news events, realized price volatility, and unusually large single trades. These are recorded alongside every trade for later analysis, not used as an entry filter in this version.

Entry: a position opens when the gross price gap between Kalshi and Polymarket for the same outcome reaches ≥ $0.05, sustained for at least 2 consecutive 1-minute ticks — the finest granularity the historical price data actually supports.

Execution is sequential, never simultaneous, and always Kalshi first. Direction is set by the sign of the gap (Kalshi yes_ask minus Polymarket price):

  • If Kalshi's Yes is the more expensive side (gap positive): buy Kalshi No, wait for confirmation, then buy Polymarket Yes.
  • If Polymarket's Yes is the more expensive side (gap negative): buy Kalshi Yes, wait for confirmation, then buy Polymarket No.

Since this is a backtest rather than a live order book, "confirmation" is simulated from realized historical volume: if Kalshi shows zero realized trade volume during the qualifying entry window, the Kalshi leg is treated as not confirmed and the trade is skipped entirely — the Polymarket leg is never taken without a confirmed Kalshi fill first. Identical logic to notebooks 04/06/07/08/09, unchanged.

Sizing: 20% of available bankroll per trade, split into a matched equal contract count on both legs — not an equal dollar split. Contract count is set by the smaller of what each leg's 10%-of-bankroll sub-budget affords.

Concurrency: multiple positions can be open simultaneously, bankroll capacity permitting — including positions from different events. A new signal is skipped for lack of capital only if the bankroll currently deployed in open positions leaves less than the required 20% available. When a position closes, its capital (net of PNL) returns to the available bankroll immediately, freeing it for the next opportunity, regardless of which event that next opportunity belongs to.

Exit: once a position is open, its value is checked on every tick against a simple, guaranteed benchmark: holding to close pays exactly $1.00/contract (one side always resolves to $1, the other to $0), and costs no exit fee at all. The position exits early only if selling both legs right now would net more than that guaranteed payout, after fees, by at least PROFIT_EXIT_BUFFER — otherwise it holds. Identical profit-based exit rule as notebooks 04/06/07/08/09, unchanged.

No stop loss is used in this version.

Fees: Kalshi's confirmed formula and Polymarket's documented (not on-chain-contract) formula, both as currently implemented in src/fees.py. Polymarket's fee rate is not uniform across these five months — January's, February's, and March's markets have feesEnabled: False (fee = $0), while April's and May's have feesEnabled: True at the standard 10% rate. Each bucket carries its own live-verified polymarket_fee_rate, applied per-trade rather than as one global constant.

Variables (for later sensitivity testing / heatmaps)

Every threshold below is a named constant, chosen so each can be swept independently in a future pass:

  • ENTRY_GAP_THRESHOLD = 0.05 — minimum gross price gap to open a position
  • ENTRY_DWELL_MINUTES = 2 — minutes the gap must sustain before entry
  • PROFIT_EXIT_BUFFER = 0.00 — how much better (per contract, after fees) selling now must be than the guaranteed $1.00 hold-to-close payout before the position exits early
  • POSITION_SIZE_PCT = 0.20 — share of available bankroll risked per trade
  • STARTING_BANKROLL = 1000 — dollars
  • KALSHI_TAKER_RATE = 0.07 — fee assumption, from fees.py, uniform across all buckets
  • POLYMARKET_TAKER_RATE = 0.10 — Polymarket's rate where fees are enabled; applied per-bucket (0% where that bucket's feesEnabled is False), not as one flat constant — see Fees above
  • STOP_LOSS = None — disabled in this version

Metrics to Report

  • Funnel counts, with a reason for each miss — signals detected → Kalshi leg confirmed → both legs confirmed → position closed
  • Holding duration per closed position
  • ROI per position and in aggregate
  • Bankroll movement over time (a running equity curve)
  • Fee cost per position and in aggregate
  • PNL per position and in aggregate

Data Ingestion

This backtest needs, for each of the 34 matched outcome buckets across 5 CPI releases (January, February, March, April, May 2026):

  • Kalshi 1-minute candlesticks (price series)
  • Kalshi individual trade records (for the confirmation-simulation logic)
  • Polymarket price history (prices-history, fidelity=1)
  • Polymarket individual trade records

Everything is cached to data/raw/ under an nb10_ prefix — distinct from every other notebook's cache, since these are entirely new buckets and date ranges. Re-running this notebook won't re-hit the APIs unless the cache is deleted.

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

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from matplotlib.lines import Line2D
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 events_config
import fees

sns.set_theme(style="whitegrid")
pd.set_option("display.width", 140)

RAW_DIR = Path.cwd().parent / "data" / "raw"
RAW_DIR.mkdir(parents=True, exist_ok=True)
In [2]:
ACTIVE_EVENT_KEYS = events_config.BATCH_7

EVENT_SHORT_LABELS = {
    "cpi_jan_2026": "Jan 2026",
    "cpi_feb_2026": "Feb 2026",
    "cpi_mar_2026": "Mar 2026",
    "cpi_apr_2026": "Apr 2026",
    "cpi_may_2026": "May 2026",
}
In [3]:
def get_kalshi_markets_robust(event_ticker):
    """get_historical_markets() has shown genuine transient flakiness for this
    project (empty results that succeed on a retry) -- retry a couple of times
    before falling back to the live (non-historical) /markets endpoint, which
    returns the same shape for a still-visible event."""
    for _ in range(3):
        markets = kalshi_client.get_historical_markets(event_ticker)
        if markets:
            return markets
    import requests

    resp = requests.get(
        "https://api.elections.kalshi.com/trade-api/v2/markets",
        params={"event_ticker": event_ticker, "limit": 200},
        timeout=15,
    )
    resp.raise_for_status()
    return resp.json().get("markets", [])


BUCKETS = {}
all_skipped = []

for event_key in ACTIVE_EVENT_KEYS:
    ev = events_config.EVENTS[event_key]
    kalshi_markets = get_kalshi_markets_robust(ev["kalshi_series_ticker"])
    polymarket_event = polymarket_client.get_event_by_slug(ev["polymarket_slug"])
    poly_markets = polymarket_event["markets"] if polymarket_event else []

    matched, skipped = market_matching.match_outcomes_point_value(kalshi_markets, poly_markets)
    for s in skipped:
        all_skipped.append({"event": ev["label"], **s})

    for m in matched:
        outcome_label = m["outcome_label"]
        bucket_key = f"{event_key}__{outcome_label}"
        km = m["kalshi_market"]
        pm = m["polymarket_market"]
        BUCKETS[bucket_key] = {
            "event_key": event_key,
            "label": f"{EVENT_SHORT_LABELS[event_key]} \u2013 {m['kalshi_sub_title']}",
            "kalshi_ticker": m["kalshi_ticker"],
            "polymarket_slug": pm.get("slug"),
            "kalshi_open": pd.to_datetime(km["open_time"], utc=True),
            "kalshi_close": pd.to_datetime(km["close_time"], utc=True),
            "polymarket_token": m["polymarket_token_id_yes"],
            "polymarket_condition_id": pm["conditionId"],
            "polymarket_open": pd.to_datetime(pm["startDate"], utc=True),
            "polymarket_close": pd.to_datetime(pm["closedTime"], utc=True),
            "polymarket_fee_rate": fees.POLYMARKET_TAKER_RATE if pm.get("feesEnabled") else 0.0,
        }

confirm_table = pd.DataFrame(
    [
        {
            "Bucket": b["label"],
            "Kalshi Ticker": b["kalshi_ticker"],
            "Kalshi Window": f"{b['kalshi_open'].date()} to {b['kalshi_close'].date()}",
            "Polymarket Slug": b["polymarket_slug"],
            "Polymarket Window": f"{b['polymarket_open'].date()} to {b['polymarket_close'].date()}",
            "Poly Fee Rate": f"{b['polymarket_fee_rate']:.0%}",
        }
        for b in BUCKETS.values()
    ]
)
display(Markdown(f"#### Resolved Market Metadata, {len(BUCKETS)} Matched Buckets Across {len(ACTIVE_EVENT_KEYS)} Events"))
display(confirm_table)

if all_skipped:
    skipped_table = pd.DataFrame(all_skipped)[["event", "side", "identifier", "reason"]]
    display(Markdown(f"#### Outcomes Skipped by Auto-Matching ({len(all_skipped)})"))
    display(skipped_table)
else:
    display(Markdown("No outcomes were skipped by auto-matching."))

Resolved Market Metadata, 34 Matched Buckets Across 5 Events

Bucket Kalshi Ticker Kalshi Window Polymarket Slug Polymarket Window Poly Fee Rate
0 Jan 2026 – Exactly 2.9% KXECONSTATCPIYOY-26JAN-T2.9 2025-12-15 to 2026-02-11 will-annual-inflation-increase-by-2pt9-in-january 2025-12-19 to 2026-02-13 0%
1 Jan 2026 – Exactly 3.0% KXECONSTATCPIYOY-26JAN-T3.0 2025-12-15 to 2026-02-11 will-annual-inflation-increase-by-3pt0-in-january 2025-12-19 to 2026-02-13 0%
2 Jan 2026 – Exactly 3.1% KXECONSTATCPIYOY-26JAN-T3.1 2025-12-15 to 2026-02-11 will-annual-inflation-increase-by-3pt1-in-january 2025-12-19 to 2026-02-13 0%
3 Feb 2026 – Exactly 2.2% KXECONSTATCPIYOY-26FEB-T2.2 2025-12-15 to 2026-03-11 will-annual-inflation-increase-by-2pt2-in-febr... 2026-02-13 to 2026-03-11 0%
4 Feb 2026 – Exactly 2.3% KXECONSTATCPIYOY-26FEB-T2.3 2025-12-15 to 2026-03-11 will-annual-inflation-increase-by-2pt3-in-febr... 2026-02-13 to 2026-03-11 0%
5 Feb 2026 – Exactly 2.4% KXECONSTATCPIYOY-26FEB-T2.4 2025-12-15 to 2026-03-11 will-annual-inflation-increase-by-2pt4-in-febr... 2026-02-13 to 2026-03-11 0%
6 Feb 2026 – Exactly 2.5% KXECONSTATCPIYOY-26FEB-T2.5 2025-12-15 to 2026-03-11 will-annual-inflation-increase-by-2pt5-in-febr... 2026-02-13 to 2026-03-11 0%
7 Feb 2026 – Exactly 2.6% KXECONSTATCPIYOY-26FEB-T2.6 2025-12-15 to 2026-03-11 will-annual-inflation-increase-by-2pt6-in-febr... 2026-02-13 to 2026-03-11 0%
8 Mar 2026 – Exactly 2.1% KXECONSTATCPIYOY-26MAR-T2.1 2025-12-15 to 2026-04-10 will-annual-inflation-increase-by-2pt1-in-march 2026-02-13 to 2026-04-10 0%
9 Mar 2026 – Exactly 2.2% KXECONSTATCPIYOY-26MAR-T2.2 2025-12-15 to 2026-04-10 will-annual-inflation-increase-by-2pt2-in-march 2026-02-13 to 2026-04-10 0%
10 Mar 2026 – Exactly 2.3% KXECONSTATCPIYOY-26MAR-T2.3 2025-12-15 to 2026-04-10 will-annual-inflation-increase-by-2pt3-in-march 2026-02-13 to 2026-04-10 0%
11 Mar 2026 – Exactly 2.4% KXECONSTATCPIYOY-26MAR-T2.4 2025-12-15 to 2026-04-10 will-annual-inflation-increase-by-2pt4-in-march 2026-02-13 to 2026-04-10 0%
12 Mar 2026 – Exactly 2.5% KXECONSTATCPIYOY-26MAR-T2.5 2025-12-15 to 2026-04-10 will-annual-inflation-increase-by-2pt5-in-marc... 2026-02-13 to 2026-04-10 0%
13 Mar 2026 – Exactly 2.6% KXECONSTATCPIYOY-26MAR-T2.6 2025-12-15 to 2026-04-10 will-annual-inflation-increase-by-2pt6-in-marc... 2026-02-13 to 2026-04-10 0%
14 Mar 2026 – Exactly 2.7% KXECONSTATCPIYOY-26MAR-T2.7 2025-12-15 to 2026-04-10 will-annual-inflation-increase-by-2pt7-in-marc... 2026-02-13 to 2026-04-10 0%
15 Apr 2026 – Exactly 3.2% KXECONSTATCPIYOY-26APR-T3.2 2025-12-15 to 2026-05-12 will-annual-inflation-increase-by-3pt2-in-april 2026-04-10 to 2026-05-12 10%
16 Apr 2026 – Exactly 3.3% KXECONSTATCPIYOY-26APR-T3.3 2025-12-15 to 2026-05-12 will-annual-inflation-increase-by-3pt3-in-april 2026-04-10 to 2026-05-12 10%
17 Apr 2026 – Exactly 3.4% KXECONSTATCPIYOY-26APR-T3.4 2025-12-15 to 2026-05-12 will-annual-inflation-increase-by-3pt4-in-april 2026-04-10 to 2026-05-12 10%
18 Apr 2026 – Exactly 3.5% KXECONSTATCPIYOY-26APR-T3.5 2025-12-15 to 2026-05-12 will-annual-inflation-increase-by-3pt5-in-april 2026-04-10 to 2026-05-12 10%
19 Apr 2026 – Exactly 3.6% KXECONSTATCPIYOY-26APR-T3.6 2026-03-18 to 2026-05-12 will-annual-inflation-increase-by-3pt6-in-april 2026-04-10 to 2026-05-12 10%
20 Apr 2026 – Exactly 3.7% KXECONSTATCPIYOY-26APR-T3.7 2026-03-18 to 2026-05-12 will-annual-inflation-increase-by-3pt7-in-april 2026-04-10 to 2026-05-12 10%
21 Apr 2026 – Exactly 3.8% KXECONSTATCPIYOY-26APR-T3.8 2026-03-18 to 2026-05-12 will-annual-inflation-increase-by-3pt8-in-april 2026-04-10 to 2026-05-12 10%
22 Apr 2026 – Exactly 3.9% KXECONSTATCPIYOY-26APR-T3.9 2026-03-18 to 2026-05-12 will-annual-inflation-increase-by-3pt9-in-april 2026-04-10 to 2026-05-12 10%
23 Apr 2026 – Exactly 4.0% KXECONSTATCPIYOY-26APR-T4.0 2026-03-18 to 2026-05-12 will-annual-inflation-increase-by-4pt0-in-april 2026-04-10 to 2026-05-12 10%
24 May 2026 – Exactly 3.4% KXECONSTATCPIYOY-26MAY-T3.4 2025-12-15 to 2026-06-10 will-annual-inflation-be-3pt4-in-may-715-782 2026-05-12 to 2026-06-10 10%
25 May 2026 – Exactly 3.5% KXECONSTATCPIYOY-26MAY-T3.5 2025-12-15 to 2026-06-10 will-annual-inflation-be-3pt5-in-may-178-525 2026-05-12 to 2026-06-10 10%
26 May 2026 – Exactly 3.6% KXECONSTATCPIYOY-26MAY-T3.6 2026-03-18 to 2026-06-10 will-annual-inflation-be-3pt6-in-may-986-389 2026-05-12 to 2026-06-10 10%
27 May 2026 – Exactly 3.7% KXECONSTATCPIYOY-26MAY-T3.7 2026-03-18 to 2026-06-10 will-annual-inflation-be-3pt7-in-may-842-613 2026-05-12 to 2026-06-10 10%
28 May 2026 – Exactly 3.8% KXECONSTATCPIYOY-26MAY-T3.8 2026-03-18 to 2026-06-10 will-annual-inflation-be-3pt8-in-may-315-277 2026-05-12 to 2026-06-10 10%
29 May 2026 – Exactly 3.9% KXECONSTATCPIYOY-26MAY-T3.9 2026-03-18 to 2026-06-10 will-annual-inflation-be-3pt9-in-may-733-934 2026-05-12 to 2026-06-10 10%
30 May 2026 – Exactly 4.0% KXECONSTATCPIYOY-26MAY-T4.0 2026-03-18 to 2026-06-10 will-annual-inflation-be-4pt0-in-may 2026-05-12 to 2026-06-10 10%
31 May 2026 – Exactly 4.1% KXECONSTATCPIYOY-26MAY-T4.1 2026-04-02 to 2026-06-10 will-annual-inflation-be-4pt1-in-may 2026-05-12 to 2026-06-10 10%
32 May 2026 – Exactly 4.2% KXECONSTATCPIYOY-26MAY-T4.2 2026-04-02 to 2026-06-10 will-annual-inflation-be-4pt2-in-may 2026-05-12 to 2026-06-10 10%
33 May 2026 – Exactly 4.3% KXECONSTATCPIYOY-26MAY-T4.3 2026-04-02 to 2026-06-10 will-annual-inflation-be-4pt3-in-may 2026-05-12 to 2026-06-10 10%

Outcomes Skipped by Auto-Matching (73)

event side identifier reason
0 CPI / Annual Inflation - January 2026 polymarket will-annual-inflation-increase-by-2pt8-in-janu... tail bucket (catch-all range) -- out of scope,...
1 CPI / Annual Inflation - January 2026 polymarket will-annual-inflation-increase-by-3pt2-in-january tail bucket (catch-all range) -- out of scope,...
2 CPI / Annual Inflation - January 2026 kalshi KXECONSTATCPIYOY-26JAN-T3.5 no Polymarket point contract found for value 3.5%
3 CPI / Annual Inflation - January 2026 kalshi KXECONSTATCPIYOY-26JAN-T3.4 no Polymarket point contract found for value 3.4%
4 CPI / Annual Inflation - January 2026 kalshi KXECONSTATCPIYOY-26JAN-T3.3 no Polymarket point contract found for value 3.3%
... ... ... ... ...
68 CPI / Annual Inflation - May 2026 kalshi KXECONSTATCPIYOY-26MAY-T2.4 no Polymarket point contract found for value 2.4%
69 CPI / Annual Inflation - May 2026 kalshi KXECONSTATCPIYOY-26MAY-T2.3 no Polymarket point contract found for value 2.3%
70 CPI / Annual Inflation - May 2026 kalshi KXECONSTATCPIYOY-26MAY-T2.2 no Polymarket point contract found for value 2.2%
71 CPI / Annual Inflation - May 2026 kalshi KXECONSTATCPIYOY-26MAY-T2.1 no Polymarket point contract found for value 2.1%
72 CPI / Annual Inflation - May 2026 kalshi KXECONSTATCPIYOY-26MAY-T2.0 no Polymarket point contract found for value 2.0%

73 rows × 4 columns

In [4]:
def load_or_fetch(cache_name, fetch_fn):
    cache_path = RAW_DIR / f"nb10_{cache_name}.parquet"
    if cache_path.exists():
        return pd.read_parquet(cache_path)
    df = fetch_fn()
    df.to_parquet(cache_path)
    return df
In [5]:
for key, b in BUCKETS.items():
    b["kalshi_history"] = load_or_fetch(
        f"{key}__kalshi_history",
        lambda b=b: kalshi_client.get_candlesticks(
            b["kalshi_ticker"], b["kalshi_open"], b["kalshi_close"], period_interval=1
        ),
    )
    print(f"{b['label']}: Kalshi history {len(b['kalshi_history']):,} rows")
Jan 2026 – Exactly 2.9%: Kalshi history 1,219 rows
Jan 2026 – Exactly 3.0%: Kalshi history 1,234 rows
Jan 2026 – Exactly 3.1%: Kalshi history 1,493 rows
Feb 2026 – Exactly 2.2%: Kalshi history 9,749 rows
Feb 2026 – Exactly 2.3%: Kalshi history 6,295 rows
Feb 2026 – Exactly 2.4%: Kalshi history 6,414 rows
Feb 2026 – Exactly 2.5%: Kalshi history 5,324 rows
Feb 2026 – Exactly 2.6%: Kalshi history 6,277 rows
Mar 2026 – Exactly 2.1%: Kalshi history 1,944 rows
Mar 2026 – Exactly 2.2%: Kalshi history 30,910 rows
Mar 2026 – Exactly 2.3%: Kalshi history 25,602 rows
Mar 2026 – Exactly 2.4%: Kalshi history 2,417 rows
Mar 2026 – Exactly 2.5%: Kalshi history 7,202 rows
Mar 2026 – Exactly 2.6%: Kalshi history 15,243 rows
Mar 2026 – Exactly 2.7%: Kalshi history 3,703 rows
Apr 2026 – Exactly 3.2%: Kalshi history 16,388 rows
Apr 2026 – Exactly 3.3%: Kalshi history 10,960 rows
Apr 2026 – Exactly 3.4%: Kalshi history 12,381 rows
Apr 2026 – Exactly 3.5%: Kalshi history 14,132 rows
Apr 2026 – Exactly 3.6%: Kalshi history 9,720 rows
Apr 2026 – Exactly 3.7%: Kalshi history 9,302 rows
Apr 2026 – Exactly 3.8%: Kalshi history 10,148 rows
Apr 2026 – Exactly 3.9%: Kalshi history 12,622 rows
Apr 2026 – Exactly 4.0%: Kalshi history 13,272 rows
May 2026 – Exactly 3.4%: Kalshi history 6,003 rows
May 2026 – Exactly 3.5%: Kalshi history 7,649 rows
May 2026 – Exactly 3.6%: Kalshi history 5,184 rows
May 2026 – Exactly 3.7%: Kalshi history 4,531 rows
May 2026 – Exactly 3.8%: Kalshi history 4,415 rows
May 2026 – Exactly 3.9%: Kalshi history 4,539 rows
May 2026 – Exactly 4.0%: Kalshi history 5,218 rows
May 2026 – Exactly 4.1%: Kalshi history 10,107 rows
May 2026 – Exactly 4.2%: Kalshi history 9,836 rows
May 2026 – Exactly 4.3%: Kalshi history 11,152 rows
In [6]:
for key, b in BUCKETS.items():
    b["polymarket_history"] = load_or_fetch(
        f"{key}__polymarket_history",
        lambda b=b: polymarket_client.get_prices_history(
            b["polymarket_token"], b["polymarket_open"], b["polymarket_close"], fidelity=1
        ),
    )
    print(f"{b['label']}: Polymarket history {len(b['polymarket_history']):,} rows")
Jan 2026 – Exactly 2.9%: Polymarket history 80,240 rows
Jan 2026 – Exactly 3.0%: Polymarket history 80,241 rows
Jan 2026 – Exactly 3.1%: Polymarket history 80,242 rows
Feb 2026 – Exactly 2.2%: Polymarket history 37,004 rows
Feb 2026 – Exactly 2.3%: Polymarket history 37,003 rows
Feb 2026 – Exactly 2.4%: Polymarket history 37,003 rows
Feb 2026 – Exactly 2.5%: Polymarket history 37,002 rows
Feb 2026 – Exactly 2.6%: Polymarket history 37,005 rows
Mar 2026 – Exactly 2.1%: Polymarket history 80,102 rows
Mar 2026 – Exactly 2.2%: Polymarket history 80,102 rows
Mar 2026 – Exactly 2.3%: Polymarket history 80,099 rows
Mar 2026 – Exactly 2.4%: Polymarket history 80,102 rows
Mar 2026 – Exactly 2.5%: Polymarket history 80,103 rows
Mar 2026 – Exactly 2.6%: Polymarket history 80,102 rows
Mar 2026 – Exactly 2.7%: Polymarket history 80,101 rows
Apr 2026 – Exactly 3.2%: Polymarket history 45,501 rows
Apr 2026 – Exactly 3.3%: Polymarket history 45,501 rows
Apr 2026 – Exactly 3.4%: Polymarket history 45,501 rows
Apr 2026 – Exactly 3.5%: Polymarket history 45,500 rows
Apr 2026 – Exactly 3.6%: Polymarket history 45,501 rows
Apr 2026 – Exactly 3.7%: Polymarket history 45,504 rows
Apr 2026 – Exactly 3.8%: Polymarket history 45,505 rows
Apr 2026 – Exactly 3.9%: Polymarket history 45,505 rows
Apr 2026 – Exactly 4.0%: Polymarket history 45,505 rows
May 2026 – Exactly 3.4%: Polymarket history 41,284 rows
May 2026 – Exactly 3.5%: Polymarket history 41,283 rows
May 2026 – Exactly 3.6%: Polymarket history 41,283 rows
May 2026 – Exactly 3.7%: Polymarket history 41,284 rows
May 2026 – Exactly 3.8%: Polymarket history 41,284 rows
May 2026 – Exactly 3.9%: Polymarket history 41,283 rows
May 2026 – Exactly 4.0%: Polymarket history 41,283 rows
May 2026 – Exactly 4.1%: Polymarket history 41,283 rows
May 2026 – Exactly 4.2%: Polymarket history 41,283 rows
May 2026 – Exactly 4.3%: Polymarket history 41,283 rows
In [7]:
for key, b in BUCKETS.items():
    b["kalshi_trades"] = load_or_fetch(
        f"{key}__kalshi_trades",
        lambda b=b: kalshi_client.get_trades(b["kalshi_ticker"], b["kalshi_open"], b["kalshi_close"]),
    )
    print(f"{b['label']}: Kalshi trades {len(b['kalshi_trades']):,} rows")
Jan 2026 – Exactly 2.9%: Kalshi trades 1 rows
Jan 2026 – Exactly 3.0%: Kalshi trades 1 rows
Jan 2026 – Exactly 3.1%: Kalshi trades 7 rows
Feb 2026 – Exactly 2.2%: Kalshi trades 37 rows
Feb 2026 – Exactly 2.3%: Kalshi trades 187 rows
Feb 2026 – Exactly 2.4%: Kalshi trades 250 rows
Feb 2026 – Exactly 2.5%: Kalshi trades 331 rows
Feb 2026 – Exactly 2.6%: Kalshi trades 71 rows
Mar 2026 – Exactly 2.1%: Kalshi trades 2 rows
Mar 2026 – Exactly 2.2%: Kalshi trades 13 rows
Mar 2026 – Exactly 2.3%: Kalshi trades 15 rows
Mar 2026 – Exactly 2.4%: Kalshi trades 12 rows
Mar 2026 – Exactly 2.5%: Kalshi trades 16 rows
Mar 2026 – Exactly 2.6%: Kalshi trades 80 rows
Mar 2026 – Exactly 2.7%: Kalshi trades 34 rows
Apr 2026 – Exactly 3.2%: Kalshi trades 89 rows
Apr 2026 – Exactly 3.3%: Kalshi trades 289 rows
Apr 2026 – Exactly 3.4%: Kalshi trades 244 rows
Apr 2026 – Exactly 3.5%: Kalshi trades 662 rows
Apr 2026 – Exactly 3.6%: Kalshi trades 1,164 rows
Apr 2026 – Exactly 3.7%: Kalshi trades 1,213 rows
Apr 2026 – Exactly 3.8%: Kalshi trades 1,498 rows
Apr 2026 – Exactly 3.9%: Kalshi trades 561 rows
Apr 2026 – Exactly 4.0%: Kalshi trades 164 rows
May 2026 – Exactly 3.4%: Kalshi trades 258 rows
May 2026 – Exactly 3.5%: Kalshi trades 244 rows
May 2026 – Exactly 3.6%: Kalshi trades 107 rows
May 2026 – Exactly 3.7%: Kalshi trades 93 rows
May 2026 – Exactly 3.8%: Kalshi trades 172 rows
May 2026 – Exactly 3.9%: Kalshi trades 160 rows
May 2026 – Exactly 4.0%: Kalshi trades 261 rows
May 2026 – Exactly 4.1%: Kalshi trades 614 rows
May 2026 – Exactly 4.2%: Kalshi trades 619 rows
May 2026 – Exactly 4.3%: Kalshi trades 571 rows
In [8]:
for key, b in BUCKETS.items():
    b["polymarket_trades"] = load_or_fetch(
        f"{key}__polymarket_trades",
        lambda b=b: polymarket_client.get_trades(
            b["polymarket_condition_id"], b["polymarket_open"], b["polymarket_close"]
        ),
    )
    print(f"{b['label']}: Polymarket trades {len(b['polymarket_trades']):,} rows")
Jan 2026 – Exactly 2.9%: Polymarket trades 186 rows
Jan 2026 – Exactly 3.0%: Polymarket trades 152 rows
Jan 2026 – Exactly 3.1%: Polymarket trades 119 rows
Feb 2026 – Exactly 2.2%: Polymarket trades 1,314 rows
Feb 2026 – Exactly 2.3%: Polymarket trades 1,548 rows
Feb 2026 – Exactly 2.4%: Polymarket trades 1,658 rows
Feb 2026 – Exactly 2.5%: Polymarket trades 1,613 rows
Feb 2026 – Exactly 2.6%: Polymarket trades 1,281 rows
Mar 2026 – Exactly 2.1%: Polymarket trades 800 rows
Mar 2026 – Exactly 2.2%: Polymarket trades 867 rows
Mar 2026 – Exactly 2.3%: Polymarket trades 1,090 rows
Mar 2026 – Exactly 2.4%: Polymarket trades 1,018 rows
Mar 2026 – Exactly 2.5%: Polymarket trades 1,017 rows
Mar 2026 – Exactly 2.6%: Polymarket trades 1,090 rows
Mar 2026 – Exactly 2.7%: Polymarket trades 1,068 rows
Apr 2026 – Exactly 3.2%: Polymarket trades 394 rows
Apr 2026 – Exactly 3.3%: Polymarket trades 449 rows
Apr 2026 – Exactly 3.4%: Polymarket trades 459 rows
Apr 2026 – Exactly 3.5%: Polymarket trades 702 rows
Apr 2026 – Exactly 3.6%: Polymarket trades 912 rows
Apr 2026 – Exactly 3.7%: Polymarket trades 1,140 rows
Apr 2026 – Exactly 3.8%: Polymarket trades 1,298 rows
Apr 2026 – Exactly 3.9%: Polymarket trades 966 rows
Apr 2026 – Exactly 4.0%: Polymarket trades 757 rows
May 2026 – Exactly 3.4%: Polymarket trades 221 rows
May 2026 – Exactly 3.5%: Polymarket trades 463 rows
May 2026 – Exactly 3.6%: Polymarket trades 438 rows
May 2026 – Exactly 3.7%: Polymarket trades 423 rows
May 2026 – Exactly 3.8%: Polymarket trades 469 rows
May 2026 – Exactly 3.9%: Polymarket trades 458 rows
May 2026 – Exactly 4.0%: Polymarket trades 647 rows
May 2026 – Exactly 4.1%: Polymarket trades 781 rows
May 2026 – Exactly 4.2%: Polymarket trades 1,224 rows
May 2026 – Exactly 4.3%: Polymarket trades 1,386 rows
In [9]:
display(Markdown("#### Data Ingestion Complete"))

summary_rows = [
    {
        "Bucket": b["label"],
        "Kalshi Candles": len(b["kalshi_history"]),
        "Kalshi Trades": len(b["kalshi_trades"]),
        "Polymarket Ticks": len(b["polymarket_history"]),
        "Polymarket Trades": len(b["polymarket_trades"]),
    }
    for b in BUCKETS.values()
]
display(pd.DataFrame(summary_rows))

Data Ingestion Complete

Bucket Kalshi Candles Kalshi Trades Polymarket Ticks Polymarket Trades
0 Jan 2026 – Exactly 2.9% 1219 1 80240 186
1 Jan 2026 – Exactly 3.0% 1234 1 80241 152
2 Jan 2026 – Exactly 3.1% 1493 7 80242 119
3 Feb 2026 – Exactly 2.2% 9749 37 37004 1314
4 Feb 2026 – Exactly 2.3% 6295 187 37003 1548
5 Feb 2026 – Exactly 2.4% 6414 250 37003 1658
6 Feb 2026 – Exactly 2.5% 5324 331 37002 1613
7 Feb 2026 – Exactly 2.6% 6277 71 37005 1281
8 Mar 2026 – Exactly 2.1% 1944 2 80102 800
9 Mar 2026 – Exactly 2.2% 30910 13 80102 867
10 Mar 2026 – Exactly 2.3% 25602 15 80099 1090
11 Mar 2026 – Exactly 2.4% 2417 12 80102 1018
12 Mar 2026 – Exactly 2.5% 7202 16 80103 1017
13 Mar 2026 – Exactly 2.6% 15243 80 80102 1090
14 Mar 2026 – Exactly 2.7% 3703 34 80101 1068
15 Apr 2026 – Exactly 3.2% 16388 89 45501 394
16 Apr 2026 – Exactly 3.3% 10960 289 45501 449
17 Apr 2026 – Exactly 3.4% 12381 244 45501 459
18 Apr 2026 – Exactly 3.5% 14132 662 45500 702
19 Apr 2026 – Exactly 3.6% 9720 1164 45501 912
20 Apr 2026 – Exactly 3.7% 9302 1213 45504 1140
21 Apr 2026 – Exactly 3.8% 10148 1498 45505 1298
22 Apr 2026 – Exactly 3.9% 12622 561 45505 966
23 Apr 2026 – Exactly 4.0% 13272 164 45505 757
24 May 2026 – Exactly 3.4% 6003 258 41284 221
25 May 2026 – Exactly 3.5% 7649 244 41283 463
26 May 2026 – Exactly 3.6% 5184 107 41283 438
27 May 2026 – Exactly 3.7% 4531 93 41284 423
28 May 2026 – Exactly 3.8% 4415 172 41284 469
29 May 2026 – Exactly 3.9% 4539 160 41283 458
30 May 2026 – Exactly 4.0% 5218 261 41283 647
31 May 2026 – Exactly 4.1% 10107 614 41283 781
32 May 2026 – Exactly 4.2% 9836 619 41283 1224
33 May 2026 – Exactly 4.3% 11152 571 41283 1386

Backtest Engine

Everything below implements the strategy exactly as specified above, using the named variables from that section so any threshold can be changed and the whole backtest re-run — identical engine to notebooks 04/06/07/08/09, unchanged except for reading each bucket's own polymarket_fee_rate instead of one global constant. The engine runs in three passes:

  1. Per-bucket entry/exit detection — find every qualifying entry episode and simulate its Kalshi-confirmation and exit (profit-based: exit early only if it beats the guaranteed hold-to-close payout), independent of bankroll.
  2. Chronological bankroll pass — merge every bucket's candidate trades, across all 5 events, into one timeline, apply the 20%-of-available-bankroll sizing and concurrency rules, and size/settle each trade in the order it actually would have happened — including releasing capital as each event resolves on its own date, not all at once.
  3. Summary metrics, including funnel frequency.
In [10]:
ENTRY_GAP_THRESHOLD = 0.05
ENTRY_DWELL_MINUTES = 2
PROFIT_EXIT_BUFFER = 0.00
POSITION_SIZE_PCT = 0.20
STARTING_BANKROLL = 1000.0
KALSHI_TAKER_RATE = fees.KALSHI_TAKER_RATE
STOP_LOSS = None


def taker_fee_pair(kalshi_price, poly_price, contracts, polymarket_fee_rate):
    """Returns (kalshi_fee, poly_fee) separately, not just their sum."""
    return (
        fees.kalshi_taker_fee(kalshi_price, contracts),
        fees.polymarket_taker_fee(poly_price, contracts, rate=polymarket_fee_rate),
    )
In [11]:
def kalshi_price_for_side(direction, kalshi_yes_ask):
    """direction=+1 -> buying Kalshi No (Kalshi is the expensive side); direction=-1 -> buying Kalshi Yes."""
    return (1 - kalshi_yes_ask) if direction == 1 else kalshi_yes_ask


def polymarket_price_for_side(direction, polymarket_price):
    """direction=+1 -> buying Polymarket Yes; direction=-1 -> buying Polymarket No."""
    return polymarket_price if direction == 1 else (1 - polymarket_price)


for key, b in BUCKETS.items():
    kalshi_deduped = b["kalshi_history"][~b["kalshi_history"].index.duplicated(keep="first")]
    poly_deduped = b["polymarket_history"][~b["polymarket_history"].index.duplicated(keep="first")]
    k = kalshi_deduped.reset_index().rename(columns={"timestamp": "ts", "yes_ask": "kalshi_yes_ask"})
    p = poly_deduped.reset_index().rename(columns={"timestamp": "ts", "price": "polymarket_price"})
    aligned = pd.merge_asof(
        k[["ts", "kalshi_yes_ask"]].sort_values("ts"),
        p[["ts", "polymarket_price"]].sort_values("ts"),
        on="ts",
        tolerance=pd.Timedelta("5min"),
        direction="backward",
    ).dropna()
    aligned["gap"] = aligned["kalshi_yes_ask"] - aligned["polymarket_price"]
    b["aligned"] = aligned.set_index("ts").sort_index()

print("Aligned price series built for all buckets:")
for key, b in BUCKETS.items():
    print(f"  {b['label']}: {len(b['aligned']):,} aligned ticks")
Aligned price series built for all buckets:
  Jan 2026 – Exactly 2.9%: 1,150 aligned ticks
  Jan 2026 – Exactly 3.0%: 1,165 aligned ticks
  Jan 2026 – Exactly 3.1%: 1,424 aligned ticks
  Feb 2026 – Exactly 2.2%: 8,861 aligned ticks
  Feb 2026 – Exactly 2.3%: 5,393 aligned ticks
  Feb 2026 – Exactly 2.4%: 5,524 aligned ticks
  Feb 2026 – Exactly 2.5%: 4,385 aligned ticks
  Feb 2026 – Exactly 2.6%: 5,403 aligned ticks
  Mar 2026 – Exactly 2.1%: 1,078 aligned ticks
  Mar 2026 – Exactly 2.2%: 30,021 aligned ticks
  Mar 2026 – Exactly 2.3%: 24,712 aligned ticks
  Mar 2026 – Exactly 2.4%: 1,549 aligned ticks
  Mar 2026 – Exactly 2.5%: 6,333 aligned ticks
  Mar 2026 – Exactly 2.6%: 14,373 aligned ticks
  Mar 2026 – Exactly 2.7%: 2,835 aligned ticks
  Apr 2026 – Exactly 3.2%: 13,994 aligned ticks
  Apr 2026 – Exactly 3.3%: 8,416 aligned ticks
  Apr 2026 – Exactly 3.4%: 10,461 aligned ticks
  Apr 2026 – Exactly 3.5%: 11,515 aligned ticks
  Apr 2026 – Exactly 3.6%: 9,328 aligned ticks
  Apr 2026 – Exactly 3.7%: 9,108 aligned ticks
  Apr 2026 – Exactly 3.8%: 9,970 aligned ticks
  Apr 2026 – Exactly 3.9%: 12,414 aligned ticks
  Apr 2026 – Exactly 4.0%: 12,812 aligned ticks
  May 2026 – Exactly 3.4%: 400 aligned ticks
  May 2026 – Exactly 3.5%: 838 aligned ticks
  May 2026 – Exactly 3.6%: 617 aligned ticks
  May 2026 – Exactly 3.7%: 796 aligned ticks
  May 2026 – Exactly 3.8%: 986 aligned ticks
  May 2026 – Exactly 3.9%: 1,395 aligned ticks
  May 2026 – Exactly 4.0%: 1,572 aligned ticks
  May 2026 – Exactly 4.1%: 6,459 aligned ticks
  May 2026 – Exactly 4.2%: 6,683 aligned ticks
  May 2026 – Exactly 4.3%: 8,114 aligned ticks
In [12]:
def find_entry_episodes(aligned, gap_threshold, dwell_minutes):
    """Run-length encode |gap| >= threshold, keep runs lasting >= dwell_minutes, tag each with a direction."""
    qualifies = aligned["gap"].abs() >= gap_threshold
    run_id = (qualifies != qualifies.shift()).cumsum()
    episodes = aligned[qualifies].groupby(run_id).agg(
        start=("gap", lambda s: s.index[0]),
        end=("gap", lambda s: s.index[-1]),
        entry_gap=("gap", "first"),
    )
    if len(episodes) == 0:
        # A bucket whose gap never once crossed the threshold produces a
        # zero-row groupby result -- pandas doesn't reliably preserve the
        # start/end columns' datetime dtype in that case (inconsistent even
        # across buckets in the same run), so .dt below can raise on some
        # empty buckets but not others. Short-circuit before that happens
        # rather than depend on dtype inference over zero rows.
        episodes["duration_minutes"] = pd.Series(dtype="float64")
        episodes["direction"] = pd.Series(dtype="int64")
        return episodes.reset_index(drop=True)
    episodes["duration_minutes"] = (episodes["end"] - episodes["start"]).dt.total_seconds() / 60
    episodes = episodes[episodes["duration_minutes"] >= dwell_minutes - 1].copy()
    episodes["direction"] = np.sign(episodes["entry_gap"]).astype(int)
    episodes = episodes[episodes["direction"] != 0]
    return episodes.reset_index(drop=True)


for key, b in BUCKETS.items():
    b["entry_episodes"] = find_entry_episodes(b["aligned"], ENTRY_GAP_THRESHOLD, ENTRY_DWELL_MINUTES)
    print(f"{b['label']}: {len(b['entry_episodes']):,} candidate entry episodes")
Jan 2026 – Exactly 2.9%: 1 candidate entry episodes
Jan 2026 – Exactly 3.0%: 1 candidate entry episodes
Jan 2026 – Exactly 3.1%: 3 candidate entry episodes
Feb 2026 – Exactly 2.2%: 63 candidate entry episodes
Feb 2026 – Exactly 2.3%: 25 candidate entry episodes
Feb 2026 – Exactly 2.4%: 12 candidate entry episodes
Feb 2026 – Exactly 2.5%: 48 candidate entry episodes
Feb 2026 – Exactly 2.6%: 12 candidate entry episodes
Mar 2026 – Exactly 2.1%: 9 candidate entry episodes
Mar 2026 – Exactly 2.2%: 13 candidate entry episodes
Mar 2026 – Exactly 2.3%: 11 candidate entry episodes
Mar 2026 – Exactly 2.4%: 19 candidate entry episodes
Mar 2026 – Exactly 2.5%: 11 candidate entry episodes
Mar 2026 – Exactly 2.6%: 12 candidate entry episodes
Mar 2026 – Exactly 2.7%: 20 candidate entry episodes
Apr 2026 – Exactly 3.2%: 1 candidate entry episodes
Apr 2026 – Exactly 3.3%: 51 candidate entry episodes
Apr 2026 – Exactly 3.4%: 22 candidate entry episodes
Apr 2026 – Exactly 3.5%: 76 candidate entry episodes
Apr 2026 – Exactly 3.6%: 24 candidate entry episodes
Apr 2026 – Exactly 3.7%: 33 candidate entry episodes
Apr 2026 – Exactly 3.8%: 24 candidate entry episodes
Apr 2026 – Exactly 3.9%: 15 candidate entry episodes
Apr 2026 – Exactly 4.0%: 8 candidate entry episodes
May 2026 – Exactly 3.4%: 0 candidate entry episodes
May 2026 – Exactly 3.5%: 1 candidate entry episodes
May 2026 – Exactly 3.6%: 2 candidate entry episodes
May 2026 – Exactly 3.7%: 1 candidate entry episodes
May 2026 – Exactly 3.8%: 11 candidate entry episodes
May 2026 – Exactly 3.9%: 10 candidate entry episodes
May 2026 – Exactly 4.0%: 2 candidate entry episodes
May 2026 – Exactly 4.1%: 6 candidate entry episodes
May 2026 – Exactly 4.2%: 36 candidate entry episodes
May 2026 – Exactly 4.3%: 61 candidate entry episodes
In [13]:
def kalshi_confirmed(kalshi_trades, start_ts, end_ts):
    """Simulate order confirmation from realized historical volume (see strategy note above)."""
    window = kalshi_trades.loc[start_ts:end_ts]
    return len(window) > 0


for key, b in BUCKETS.items():
    confirmed_flags = [
        kalshi_confirmed(b["kalshi_trades"], row["start"], row["end"])
        for _, row in b["entry_episodes"].iterrows()
    ]
    b["entry_episodes"]["kalshi_confirmed"] = confirmed_flags
    n_confirmed = sum(confirmed_flags)
    print(f"{b['label']}: {n_confirmed:,} / {len(confirmed_flags):,} episodes had a Kalshi fill (confirmed)")
Jan 2026 – Exactly 2.9%: 1 / 1 episodes had a Kalshi fill (confirmed)
Jan 2026 – Exactly 3.0%: 1 / 1 episodes had a Kalshi fill (confirmed)
Jan 2026 – Exactly 3.1%: 2 / 3 episodes had a Kalshi fill (confirmed)
Feb 2026 – Exactly 2.2%: 3 / 63 episodes had a Kalshi fill (confirmed)
Feb 2026 – Exactly 2.3%: 5 / 25 episodes had a Kalshi fill (confirmed)
Feb 2026 – Exactly 2.4%: 5 / 12 episodes had a Kalshi fill (confirmed)
Feb 2026 – Exactly 2.5%: 10 / 48 episodes had a Kalshi fill (confirmed)
Feb 2026 – Exactly 2.6%: 1 / 12 episodes had a Kalshi fill (confirmed)
Mar 2026 – Exactly 2.1%: 0 / 9 episodes had a Kalshi fill (confirmed)
Mar 2026 – Exactly 2.2%: 2 / 13 episodes had a Kalshi fill (confirmed)
Mar 2026 – Exactly 2.3%: 1 / 11 episodes had a Kalshi fill (confirmed)
Mar 2026 – Exactly 2.4%: 1 / 19 episodes had a Kalshi fill (confirmed)
Mar 2026 – Exactly 2.5%: 2 / 11 episodes had a Kalshi fill (confirmed)
Mar 2026 – Exactly 2.6%: 2 / 12 episodes had a Kalshi fill (confirmed)
Mar 2026 – Exactly 2.7%: 5 / 20 episodes had a Kalshi fill (confirmed)
Apr 2026 – Exactly 3.2%: 0 / 1 episodes had a Kalshi fill (confirmed)
Apr 2026 – Exactly 3.3%: 9 / 51 episodes had a Kalshi fill (confirmed)
Apr 2026 – Exactly 3.4%: 5 / 22 episodes had a Kalshi fill (confirmed)
Apr 2026 – Exactly 3.5%: 16 / 76 episodes had a Kalshi fill (confirmed)
Apr 2026 – Exactly 3.6%: 8 / 24 episodes had a Kalshi fill (confirmed)
Apr 2026 – Exactly 3.7%: 10 / 33 episodes had a Kalshi fill (confirmed)
Apr 2026 – Exactly 3.8%: 9 / 24 episodes had a Kalshi fill (confirmed)
Apr 2026 – Exactly 3.9%: 5 / 15 episodes had a Kalshi fill (confirmed)
Apr 2026 – Exactly 4.0%: 3 / 8 episodes had a Kalshi fill (confirmed)
May 2026 – Exactly 3.4%: 0 / 0 episodes had a Kalshi fill (confirmed)
May 2026 – Exactly 3.5%: 0 / 1 episodes had a Kalshi fill (confirmed)
May 2026 – Exactly 3.6%: 0 / 2 episodes had a Kalshi fill (confirmed)
May 2026 – Exactly 3.7%: 0 / 1 episodes had a Kalshi fill (confirmed)
May 2026 – Exactly 3.8%: 2 / 11 episodes had a Kalshi fill (confirmed)
May 2026 – Exactly 3.9%: 1 / 10 episodes had a Kalshi fill (confirmed)
May 2026 – Exactly 4.0%: 1 / 2 episodes had a Kalshi fill (confirmed)
May 2026 – Exactly 4.1%: 4 / 6 episodes had a Kalshi fill (confirmed)
May 2026 – Exactly 4.2%: 12 / 36 episodes had a Kalshi fill (confirmed)
May 2026 – Exactly 4.3%: 13 / 61 episodes had a Kalshi fill (confirmed)
In [14]:
def simulate_exit(aligned, entry_ts, direction, profit_exit_buffer, bucket_close_ts, polymarket_fee_rate):
    """
    Forward-scan from entry_ts. Exit as soon as selling both legs right now would
    net more, per contract after fees, than the guaranteed $1.00 hold-to-close
    payout (which pays no exit fee) -- by at least profit_exit_buffer. Otherwise
    hold to close.
    """
    forward = aligned.loc[entry_ts:bucket_close_ts]
    if len(forward) < 2:
        return None

    HOLD_TO_CLOSE_VALUE = 1.00  # guaranteed payout per contract, no exit fee

    for i in range(len(forward)):
        ts = forward.index[i]
        kalshi_yes_ask = forward["kalshi_yes_ask"].iloc[i]
        polymarket_price = forward["polymarket_price"].iloc[i]

        current_kalshi_price = kalshi_price_for_side(direction, kalshi_yes_ask)
        current_poly_price = polymarket_price_for_side(direction, polymarket_price)

        kalshi_fee, poly_fee = taker_fee_pair(current_kalshi_price, current_poly_price, 1, polymarket_fee_rate)
        early_exit_net = (current_kalshi_price + current_poly_price) - (kalshi_fee + poly_fee)

        if early_exit_net > HOLD_TO_CLOSE_VALUE + profit_exit_buffer:
            return {
                "exit_type": "profit_target",
                "exit_ts": ts,
                "exit_kalshi_yes_ask": kalshi_yes_ask,
                "exit_polymarket_price": polymarket_price,
            }

    return {
        "exit_type": "held_to_close",
        "exit_ts": None,
        "exit_kalshi_yes_ask": None,
        "exit_polymarket_price": None,
    }


for key, b in BUCKETS.items():
    bucket_close_ts = b["aligned"].index.max()
    exit_results = []
    for _, row in b["entry_episodes"].iterrows():
        if not row["kalshi_confirmed"]:
            exit_results.append(None)
            continue
        exit_results.append(
            simulate_exit(
                b["aligned"], row["end"], row["direction"],
                PROFIT_EXIT_BUFFER, bucket_close_ts, b["polymarket_fee_rate"],
            )
        )
    b["entry_episodes"]["exit_result"] = exit_results
    n_profit = sum(1 for r in exit_results if r and r["exit_type"] == "profit_target")
    n_held = sum(1 for r in exit_results if r and r["exit_type"] == "held_to_close")
    print(f"{b['label']}: {n_profit:,} profit-target exits, {n_held:,} held to close")
Jan 2026 – Exactly 2.9%: 0 profit-target exits, 0 held to close
Jan 2026 – Exactly 3.0%: 0 profit-target exits, 0 held to close
Jan 2026 – Exactly 3.1%: 0 profit-target exits, 1 held to close
Feb 2026 – Exactly 2.2%: 3 profit-target exits, 0 held to close
Feb 2026 – Exactly 2.3%: 4 profit-target exits, 1 held to close
Feb 2026 – Exactly 2.4%: 3 profit-target exits, 1 held to close
Feb 2026 – Exactly 2.5%: 10 profit-target exits, 0 held to close
Feb 2026 – Exactly 2.6%: 1 profit-target exits, 0 held to close
Mar 2026 – Exactly 2.1%: 0 profit-target exits, 0 held to close
Mar 2026 – Exactly 2.2%: 2 profit-target exits, 0 held to close
Mar 2026 – Exactly 2.3%: 1 profit-target exits, 0 held to close
Mar 2026 – Exactly 2.4%: 0 profit-target exits, 1 held to close
Mar 2026 – Exactly 2.5%: 1 profit-target exits, 1 held to close
Mar 2026 – Exactly 2.6%: 1 profit-target exits, 1 held to close
Mar 2026 – Exactly 2.7%: 5 profit-target exits, 0 held to close
Apr 2026 – Exactly 3.2%: 0 profit-target exits, 0 held to close
Apr 2026 – Exactly 3.3%: 9 profit-target exits, 0 held to close
Apr 2026 – Exactly 3.4%: 5 profit-target exits, 0 held to close
Apr 2026 – Exactly 3.5%: 9 profit-target exits, 7 held to close
Apr 2026 – Exactly 3.6%: 5 profit-target exits, 3 held to close
Apr 2026 – Exactly 3.7%: 5 profit-target exits, 5 held to close
Apr 2026 – Exactly 3.8%: 8 profit-target exits, 0 held to close
Apr 2026 – Exactly 3.9%: 5 profit-target exits, 0 held to close
Apr 2026 – Exactly 4.0%: 3 profit-target exits, 0 held to close
May 2026 – Exactly 3.4%: 0 profit-target exits, 0 held to close
May 2026 – Exactly 3.5%: 0 profit-target exits, 0 held to close
May 2026 – Exactly 3.6%: 0 profit-target exits, 0 held to close
May 2026 – Exactly 3.7%: 0 profit-target exits, 0 held to close
May 2026 – Exactly 3.8%: 1 profit-target exits, 1 held to close
May 2026 – Exactly 3.9%: 1 profit-target exits, 0 held to close
May 2026 – Exactly 4.0%: 1 profit-target exits, 0 held to close
May 2026 – Exactly 4.1%: 2 profit-target exits, 2 held to close
May 2026 – Exactly 4.2%: 7 profit-target exits, 4 held to close
May 2026 – Exactly 4.3%: 12 profit-target exits, 0 held to close
In [15]:
def build_candidate_trade(bucket_key, b, row):
    """Assemble the price/fee economics of one candidate trade, per contract (size TBD in the bankroll pass)."""
    direction = row["direction"]
    entry_ts = row["end"]
    entry_kalshi_price = kalshi_price_for_side(direction, b["aligned"].loc[entry_ts, "kalshi_yes_ask"])
    entry_poly_price = polymarket_price_for_side(direction, b["aligned"].loc[entry_ts, "polymarket_price"])

    exit_result = row["exit_result"]
    trade = {
        "bucket": bucket_key,
        "bucket_label": b["label"],
        "event_key": b["event_key"],
        "direction": direction,
        "entry_ts": entry_ts,
        "entry_kalshi_price": entry_kalshi_price,
        "entry_poly_price": entry_poly_price,
        "entry_gap": row["entry_gap"],
        "exit_type": exit_result["exit_type"],
        "polymarket_fee_rate": b["polymarket_fee_rate"],
    }

    if exit_result["exit_type"] == "profit_target":
        trade["exit_ts"] = exit_result["exit_ts"]
        trade["exit_kalshi_price"] = kalshi_price_for_side(direction, exit_result["exit_kalshi_yes_ask"])
        trade["exit_poly_price"] = polymarket_price_for_side(direction, exit_result["exit_polymarket_price"])
    else:
        trade["exit_ts"] = b["aligned"].index.max()
        trade["exit_kalshi_price"] = None
        trade["exit_poly_price"] = None

    return trade


all_candidates = []
n_skipped_no_forward_data = 0
for key, b in BUCKETS.items():
    episodes = b["entry_episodes"]
    # A confirmed episode can still have exit_result=None: simulate_exit()
    # returns None when fewer than 2 aligned ticks remain between entry and
    # the bucket's close (an entry firing right at the edge of the data
    # window) -- distinct from "not confirmed" (first hit in notebook 07,
    # guarded here from the start).
    confirmed_rows = episodes[episodes["kalshi_confirmed"] & episodes["exit_result"].notna()]
    n_skipped_no_forward_data += int(episodes["kalshi_confirmed"].sum()) - len(confirmed_rows)
    for _, row in confirmed_rows.iterrows():
        all_candidates.append(build_candidate_trade(key, b, row))

all_candidates.sort(key=lambda t: t["entry_ts"])
print(f"Total confirmed candidate trades across all buckets: {len(all_candidates):,}")
if n_skipped_no_forward_data:
    print(f"(Confirmed but excluded for insufficient forward data before bucket close: {n_skipped_no_forward_data:,})")
Total confirmed candidate trades across all buckets: 132
(Confirmed but excluded for insufficient forward data before bucket close: 7)
In [16]:
available_bankroll = STARTING_BANKROLL
bankroll_curve = [(all_candidates[0]["entry_ts"] if all_candidates else pd.Timestamp.now(tz="UTC"), STARTING_BANKROLL)]
open_positions = []  # list of dicts with 'exit_ts' and 'capital_committed'
closed_trades = []
funnel_skipped = []

for cand in all_candidates:
    still_open = []
    for pos in open_positions:
        if pos["exit_ts"] <= cand["entry_ts"]:
            available_bankroll += pos["capital_committed"] + pos["pnl"]
            bankroll_curve.append((pos["exit_ts"], available_bankroll))
        else:
            still_open.append(pos)
    open_positions = still_open

    budget = available_bankroll * POSITION_SIZE_PCT
    sub_budget = budget / 2
    contracts = int(min(sub_budget / cand["entry_kalshi_price"], sub_budget / cand["entry_poly_price"]))

    if contracts < 1:
        funnel_skipped.append({**cand, "skip_reason": "insufficient_available_bankroll"})
        continue

    entry_cost = contracts * (cand["entry_kalshi_price"] + cand["entry_poly_price"])
    entry_kalshi_fee, entry_poly_fee = taker_fee_pair(
        cand["entry_kalshi_price"], cand["entry_poly_price"], contracts, cand["polymarket_fee_rate"]
    )
    entry_fees = entry_kalshi_fee + entry_poly_fee

    if cand["exit_type"] == "profit_target":
        exit_proceeds = contracts * (cand["exit_kalshi_price"] + cand["exit_poly_price"])
        exit_kalshi_fee, exit_poly_fee = taker_fee_pair(
            cand["exit_kalshi_price"], cand["exit_poly_price"], contracts, cand["polymarket_fee_rate"]
        )
        exit_fees = exit_kalshi_fee + exit_poly_fee
    else:
        exit_proceeds = contracts * 1.00
        exit_kalshi_fee = 0.0
        exit_poly_fee = 0.0
        exit_fees = 0.0

    pnl = (exit_proceeds - exit_fees) - (entry_cost + entry_fees)
    capital_committed = entry_cost + entry_fees

    trade_record = {
        **cand,
        "contracts": contracts,
        "entry_cost": entry_cost,
        "entry_fees": entry_fees,
        "exit_proceeds": exit_proceeds,
        "exit_fees": exit_fees,
        "kalshi_fees": entry_kalshi_fee + exit_kalshi_fee,
        "poly_fees": entry_poly_fee + exit_poly_fee,
        "total_fees": entry_fees + exit_fees,
        "pnl": pnl,
        "roi": pnl / capital_committed if capital_committed else np.nan,
        "holding_minutes": (cand["exit_ts"] - cand["entry_ts"]).total_seconds() / 60,
    }
    closed_trades.append(trade_record)

    available_bankroll -= capital_committed
    bankroll_curve.append((cand["entry_ts"], available_bankroll))
    open_positions.append({"exit_ts": cand["exit_ts"], "capital_committed": capital_committed, "pnl": pnl})

# Release whatever is still open at the very end of the backtest window.
for pos in sorted(open_positions, key=lambda p: p["exit_ts"]):
    available_bankroll += pos["capital_committed"] + pos["pnl"]
    bankroll_curve.append((pos["exit_ts"], available_bankroll))

trades_df = pd.DataFrame(closed_trades)
bankroll_df = pd.DataFrame(bankroll_curve, columns=["ts", "bankroll"]).sort_values("ts").reset_index(drop=True)

print(f"Trades executed: {len(trades_df):,}")
print(f"Trades skipped for lack of available bankroll: {len(funnel_skipped):,}")
print(f"Final bankroll: ${available_bankroll:,.2f} (started at ${STARTING_BANKROLL:,.2f})")
Trades executed: 132
Trades skipped for lack of available bankroll: 0
Final bankroll: $2,536.90 (started at $1,000.00)
In [17]:
display(Markdown("#### Aggregate Backtest Metrics"))

total_signals = sum(len(b["entry_episodes"]) for b in BUCKETS.values())
total_confirmed = sum(int(b["entry_episodes"]["kalshi_confirmed"].sum()) for b in BUCKETS.values())
confirmation_rate = total_confirmed / total_signals if total_signals else float("nan")

total_pnl = trades_df["pnl"].sum()
total_fees = trades_df["total_fees"].sum()
win_rate = (trades_df["pnl"] > 0).mean()
avg_holding_hours = trades_df["holding_minutes"].mean() / 60

metrics_table = pd.DataFrame(
    {
        "Metric": [
            "Signals Detected",
            "Kalshi Confirmed",
            "Confirmation Rate",
            "Total Trades",
            "Win Rate",
            "Total PNL",
            "Total Fees Paid",
            "Starting Bankroll",
            "Final Bankroll",
            "Overall ROI",
            "Avg. Holding Duration",
        ],
        "Value": [
            f"{total_signals:,}",
            f"{total_confirmed:,}",
            f"{confirmation_rate:.1%}",
            f"{len(trades_df):,}",
            f"{win_rate:.1%}",
            f"${total_pnl:,.2f}",
            f"${total_fees:,.2f}",
            f"${STARTING_BANKROLL:,.2f}",
            f"${available_bankroll:,.2f}",
            f"{(available_bankroll - STARTING_BANKROLL) / STARTING_BANKROLL:.1%}",
            f"{avg_holding_hours:,.1f} hours",
        ],
    }
)
display(metrics_table)

display(Markdown("#### By Event"))
by_event = trades_df.groupby("event_key").agg(
    trades=("pnl", "count"),
    win_rate=("pnl", lambda s: (s > 0).mean()),
    total_pnl=("pnl", "sum"),
    total_fees=("total_fees", "sum"),
    avg_holding_hours=("holding_minutes", lambda s: s.mean() / 60),
).round(2)
display(by_event)

display(Markdown("#### By Bucket"))
by_bucket = trades_df.groupby("bucket_label").agg(
    trades=("pnl", "count"),
    win_rate=("pnl", lambda s: (s > 0).mean()),
    total_pnl=("pnl", "sum"),
    kalshi_fees=("kalshi_fees", "sum"),
    poly_fees=("poly_fees", "sum"),
    total_fees=("total_fees", "sum"),
    avg_holding_hours=("holding_minutes", lambda s: s.mean() / 60),
).round(2)
display(by_bucket)

Aggregate Backtest Metrics

Metric Value
0 Signals Detected 644
1 Kalshi Confirmed 139
2 Confirmation Rate 21.6%
3 Total Trades 132
4 Win Rate 93.2%
5 Total PNL $1,536.90
6 Total Fees Paid $877.10
7 Starting Bankroll $1,000.00
8 Final Bankroll $2,536.90
9 Overall ROI 153.7%
10 Avg. Holding Duration 94.0 hours

By Event

trades win_rate total_pnl total_fees avg_holding_hours
event_key
cpi_apr_2026 64 0.89 754.55 337.17 97.72
cpi_feb_2026 23 0.96 189.31 67.31 51.10
cpi_jan_2026 1 1.00 10.60 0.77 255.18
cpi_mar_2026 13 1.00 161.64 17.10 165.83
cpi_may_2026 31 0.97 420.80 454.75 82.62

By Bucket

trades win_rate total_pnl kalshi_fees poly_fees total_fees avg_holding_hours
bucket_label
Apr 2026 – Exactly 3.3% 9 0.78 113.05 14.10 22.00 36.10 2.36
Apr 2026 – Exactly 3.4% 5 1.00 193.38 10.34 19.10 29.44 77.67
Apr 2026 – Exactly 3.5% 16 0.88 192.56 30.85 40.04 70.89 174.19
Apr 2026 – Exactly 3.6% 8 1.00 40.59 25.61 36.96 62.57 62.35
Apr 2026 – Exactly 3.7% 10 0.70 43.11 27.34 39.99 67.33 169.96
Apr 2026 – Exactly 3.8% 8 1.00 39.79 16.85 25.46 42.31 56.44
Apr 2026 – Exactly 3.9% 5 1.00 106.23 5.99 15.00 20.99 70.38
Apr 2026 – Exactly 4.0% 3 1.00 25.85 2.69 4.84 7.53 18.61
Feb 2026 – Exactly 2.2% 3 1.00 19.12 2.12 0.00 2.12 107.87
Feb 2026 – Exactly 2.3% 5 1.00 31.08 7.48 0.00 7.48 57.92
Feb 2026 – Exactly 2.4% 4 1.00 40.40 18.06 0.00 18.06 28.89
Feb 2026 – Exactly 2.5% 10 0.90 95.04 39.02 0.00 39.02 23.52
Feb 2026 – Exactly 2.6% 1 1.00 3.68 0.63 0.00 0.63 211.45
Jan 2026 – Exactly 3.1% 1 1.00 10.60 0.77 0.00 0.77 255.18
Mar 2026 – Exactly 2.2% 2 1.00 38.10 3.63 0.00 3.63 15.02
Mar 2026 – Exactly 2.3% 1 1.00 20.23 1.53 0.00 1.53 39.17
Mar 2026 – Exactly 2.4% 1 1.00 5.76 0.71 0.00 0.71 997.75
Mar 2026 – Exactly 2.5% 2 1.00 64.41 4.92 0.00 4.92 422.47
Mar 2026 – Exactly 2.6% 2 1.00 11.42 0.55 0.00 0.55 9.64
Mar 2026 – Exactly 2.7% 5 1.00 21.73 5.76 0.00 5.76 44.91
May 2026 – Exactly 3.8% 2 1.00 26.18 2.26 3.27 5.53 277.99
May 2026 – Exactly 3.9% 1 1.00 39.84 2.54 6.27 8.81 10.83
May 2026 – Exactly 4.0% 1 1.00 8.30 2.50 4.05 6.55 0.22
May 2026 – Exactly 4.1% 4 1.00 31.06 6.15 8.68 14.83 50.26
May 2026 – Exactly 4.2% 11 0.91 118.70 75.17 107.04 182.21 49.99
May 2026 – Exactly 4.3% 12 1.00 196.71 97.41 139.42 236.83 103.59

Rolling Bankroll

Bankroll only changes at two moments: when a position opens (capital is deployed) and when it closes (capital plus PNL returns). The line below is a step function, not a smooth curve, for exactly that reason. With five months in play, capital recycles across January, February, March, April, and May positions on one shared bankroll, released as each position's own bucket resolves rather than waiting for every event to finish.

In [18]:
display(Markdown(f"#### Bankroll Over Time \u2014 Starting ${STARTING_BANKROLL:,.0f}, Ending ${available_bankroll:,.2f}"))

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

ax.plot(bankroll_df["ts"], bankroll_df["bankroll"], color="steelblue", linewidth=1.6, drawstyle="steps-post", zorder=3)
ax.axhline(STARTING_BANKROLL, color="gray", linestyle="--", linewidth=1, zorder=1, label=f"Starting bankroll (${STARTING_BANKROLL:,.0f})")

ax.fill_between(
    bankroll_df["ts"], bankroll_df["bankroll"], STARTING_BANKROLL,
    where=(bankroll_df["bankroll"] >= STARTING_BANKROLL), color="seagreen", alpha=0.15, step="post", zorder=0,
)
ax.fill_between(
    bankroll_df["ts"], bankroll_df["bankroll"], STARTING_BANKROLL,
    where=(bankroll_df["bankroll"] < STARTING_BANKROLL), color="crimson", alpha=0.15, step="post", zorder=0,
)

event_resolution_dates = {}
for key, b in BUCKETS.items():
    event_resolution_dates.setdefault(b["event_key"], b["kalshi_close"])
for event_key, ts in event_resolution_dates.items():
    ax.axvline(ts, color="gray", linestyle=":", linewidth=0.8, alpha=0.5, zorder=0)
    ax.annotate(
        EVENT_SHORT_LABELS[event_key], xy=(ts, ax.get_ylim()[1]), xytext=(3, -12),
        textcoords="offset points", fontsize=8, color="dimgray", rotation=90, va="top",
    )

final_ts = bankroll_df["ts"].iloc[-1]
final_value = bankroll_df["bankroll"].iloc[-1]
ax.scatter([final_ts], [final_value], color="steelblue", s=40, zorder=4, edgecolors="black", linewidths=0.6)
ax.annotate(
    f"${final_value:,.2f}",
    xy=(final_ts, final_value), xytext=(8, 8), textcoords="offset points", fontsize=9, fontweight="bold",
)

ax.set_xlabel("date")
ax.set_ylabel("bankroll ($)")
ax.set_title("Rolling Bankroll \u2014 CPI / Annual Inflation Backtest", fontsize=12)
ax.legend(loc="upper left", fontsize=9)
plt.tight_layout()
plt.show()

peak = bankroll_df["bankroll"].max()
trough = bankroll_df["bankroll"].min()
print(
    f"Peak bankroll: ${peak:,.2f} | Trough bankroll: ${trough:,.2f} | "
    f"Max drawdown from peak: ${peak - trough:,.2f}"
)

Bankroll Over Time — Starting $1,000, Ending $2,536.90

No description has been provided for this image
Peak bankroll: $2,536.90 | Trough bankroll: $237.67 | Max drawdown from peak: $2,299.23

Summary

Best result of the project: +153.7% ROI ($1,536.90 net on the $1,000 starting bankroll), 132 trades, 93.2% win rate. All five months landed net positive individually — April was the largest contributor ($754.55 on 64 trades), followed by May ($420.80 on 31 trades), February ($189.31 on 23 trades), March ($161.64 on 13 trades), and January ($10.60, on a single trade — the smallest sample of the five, a direct consequence of January's Polymarket range being the narrowest tested, with only 3 matched buckets).

Funnel: 644 signals detected across the 34 buckets, 139 Kalshi-confirmed (21.6% confirmation rate) — the second-highest of any notebook in this series, behind only Bank of England's 23.8% (notebook 07) and well above Bank of Canada's 9.2% and ECB's 9.7%. 7 confirmed signals were excluded for insufficient forward data before bucket close (the notebook 07 edge case, recurring here for the first time since); 0 skipped for insufficient bankroll.

Fees consumed the largest share of gross edge of any notebook in this series: gross PNL was $2,414.00 ($1,536.90 net + $877.10 fees) — fees took 36.3% of the edge, just above Bank of England's 36.0% (notebook 07) and well above Bank of Canada's 23.2% (notebook 08) and ECB's 30.8% (notebook 09). January, February, and March all had `feesEnabled: False` on Polymarket's side (fee = $0) — March's 13 trades delivered $161.64 of PNL against just $17.10 in fees, the best fee-to-edge ratio of any event bucket in this notebook. April's and May's 10%-fee buckets carried most of the drag, especially May's 4.2%/4.3% pair ($182.21 + $236.83 in fees against $118.70 + $196.71 in PNL).

Bankroll swung real capital — trough $237.67 against a peak (and final) value of $2,536.90, a max drawdown of $2,299.23 — the largest peak-to-final swing of any notebook in this series, driven by heavy compounding through April's and May's higher-confirmation-rate buckets.

No new bugs surfaced. The new market_matching.match_outcomes_point_value() matcher — built specifically for this notebook, since CPI's exact-point vocabulary ("Exactly X%") has no hike/cut/no-change language for the existing match_outcomes() to parse — matched all 34 point contracts correctly on the first live run, with Polymarket's tail buckets cleanly reported as skipped rather than mismatched. The find_entry_episodes() empty-groupby fix and the bt_candidates exit_result=None guard, both carried forward from earlier notebooks, were exercised again without incident.

June 2026 remains excluded — all 9 of its matched buckets return a genuine 404 Not Found on Kalshi's candlestick endpoint (the event closed 2026-07-14, not yet archived), the same gap that excluded Bank of England's July decision from notebook 07. It can be revisited once Kalshi's archive catches up.