Notebook 09: ECB Decision Backtest

This notebook takes the same hedge-arbitrage strategy backtested in notebooks 04, 06, 07, and 08 and points it at five European Central Bank rate decisions: December 2025, February, March, April, and June 2026. Same engine, same variables, same entry/exit rules — only the events under test change.

Multi-event handling follows notebooks 06/07/08's pattern: all matched outcome buckets across the five meetings are folded into one combined BUCKETS dict, keyed and labeled by event (e.g. ecb_dec_2025__cut_small), and run through a single backtest pass with one shared bankroll.

Bucket count varies by event, same pattern as Bank of England. Polymarket lumps all rate-hike outcomes into one contract for December, February, March, and April (no 1:1 Kalshi counterpart, so the hike side is skipped for those four), while June splits hikes the same way Kalshi does, contributing the full five-outcome set. That gives 17 matched buckets across 5 events (3 + 3 + 3 + 3 + 5).

No new parser bugs this time — the two fixes made for Bank of England already cover ECB's wording. Kalshi's ECB labels use the exact same variants that prompted those fixes: December's and February's listings spell out "Cut 1-25 basis points" (not "bps"), and every month uses "Cut more than 25bp(s)" for the big-magnitude bucket. Both were verified live against market_matching.py before building this notebook and matched correctly on the first try — a good sign the fix generalizes rather than being BoE-specific.

Candlestick history is available for all five events, including June (the most recently resolved) — no repeat of the archival-lag gap that excluded Fed's and BoE's July decisions. Fee rate is not uniform across these five events, same pattern as before: December, February, and March have feesEnabled: False (fee = $0), April and June have feesEnabled: True at the standard 10% rate. Handled per-bucket exactly as in notebooks 06/07/08.

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, 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, 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 events — December's, February's, and March's markets have feesEnabled: False (fee = $0), while April's and June'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 17 matched outcome buckets across 5 ECB decisions (December 2025, February, March, April, June 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 nb09_ 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_6

OUTCOME_LABELS = {
    "maintain_None": "No Change",
    "cut_small": "25bps Decrease",
    "cut_big": "50+bps Decrease",
    "hike_small": "25bps Increase",
    "hike_big": "50+bps Increase",
}

EVENT_SHORT_LABELS = {
    "ecb_dec_2025": "Dec 2025",
    "ecb_feb_2026": "Feb 2026",
    "ecb_mar_2026": "Mar 2026",
    "ecb_apr_2026": "Apr 2026",
    "ecb_jun_2026": "Jun 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(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]} – {OUTCOME_LABELS.get(outcome_label, outcome_label)}",
            "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, 17 Matched Buckets Across 5 Events

Bucket Kalshi Ticker Kalshi Window Polymarket Slug Polymarket Window Poly Fee Rate
0 Dec 2025 – 50+bps Decrease KXCBDECISIONEU-25DEC18-C25P 2025-11-08 to 2025-12-18 will-the-ecb-announce-a-50-bps-decrease-at-the... 2025-10-30 to 2025-12-18 0%
1 Dec 2025 – 25bps Decrease KXCBDECISIONEU-25DEC18-C25 2025-11-08 to 2025-12-18 will-the-ecb-announce-a-25-bps-decrease-at-the... 2025-10-30 to 2025-12-18 0%
2 Dec 2025 – No Change KXCBDECISIONEU-25DEC18-HOLD 2025-11-08 to 2025-12-18 will-the-ecb-announce-no-change-at-the-decembe... 2025-10-30 to 2025-12-18 0%
3 Feb 2026 – 50+bps Decrease KXCBDECISIONEU-26FEB05-C25P 2025-12-18 to 2026-02-05 will-the-ecb-announce-a-50-bps-decrease-at-the... 2025-11-12 to 2026-02-05 0%
4 Feb 2026 – 25bps Decrease KXCBDECISIONEU-26FEB05-C25 2025-12-18 to 2026-02-05 will-the-ecb-announce-a-25-bps-decrease-at-the... 2025-11-12 to 2026-02-05 0%
5 Feb 2026 – No Change KXCBDECISIONEU-26FEB05-HOLD 2025-12-18 to 2026-02-05 will-the-ecb-announce-no-change-at-the-februar... 2025-11-12 to 2026-02-05 0%
6 Mar 2026 – 50+bps Decrease KXCBDECISIONEU-26MAR19-C25P 2026-02-06 to 2026-03-19 will-the-ecb-announce-a-50-bps-decrease-at-the... 2025-12-18 to 2026-03-19 0%
7 Mar 2026 – 25bps Decrease KXCBDECISIONEU-26MAR19-C25 2026-02-06 to 2026-03-19 will-the-ecb-announce-a-25-bps-decrease-at-the... 2025-12-18 to 2026-03-19 0%
8 Mar 2026 – No Change KXCBDECISIONEU-26MAR19-HOLD 2026-02-06 to 2026-03-19 will-the-ecb-announce-no-change-at-the-march-2... 2025-12-18 to 2026-03-19 0%
9 Apr 2026 – 50+bps Decrease KXCBDECISIONEU-26APR30-C25P 2026-03-21 to 2026-04-30 will-the-ecb-announce-a-50-bps-decrease-at-the... 2026-02-06 to 2026-04-30 10%
10 Apr 2026 – 25bps Decrease KXCBDECISIONEU-26APR30-C25 2026-03-21 to 2026-04-30 will-the-ecb-announce-a-25-bps-decrease-at-the... 2026-02-06 to 2026-04-30 10%
11 Apr 2026 – No Change KXCBDECISIONEU-26APR30-HOLD 2026-03-21 to 2026-04-30 will-the-ecb-announce-no-change-at-the-april-2... 2026-02-06 to 2026-04-30 10%
12 Jun 2026 – 50+bps Decrease KXCBDECISIONEU-26JUN11-C25P 2026-04-30 to 2026-06-11 will-the-ecb-announce-a-50-bps-decrease-at-the... 2026-03-19 to 2026-06-11 10%
13 Jun 2026 – 25bps Decrease KXCBDECISIONEU-26JUN11-C25 2026-04-30 to 2026-06-11 will-the-ecb-announce-a-25-bps-decrease-at-the... 2026-03-19 to 2026-06-11 10%
14 Jun 2026 – No Change KXCBDECISIONEU-26JUN11-HOLD 2026-04-30 to 2026-06-11 will-the-ecb-announce-no-change-at-the-june-20... 2026-03-19 to 2026-06-11 10%
15 Jun 2026 – 25bps Increase KXCBDECISIONEU-26JUN11-H25 2026-04-30 to 2026-06-11 will-the-ecb-announce-an-increase-at-the-june-... 2026-03-19 to 2026-06-11 10%
16 Jun 2026 – 50+bps Increase KXCBDECISIONEU-26JUN11-H25P 2026-04-30 to 2026-06-11 will-the-ecb-announce-a-50-bps-increase-at-the... 2026-03-19 to 2026-06-11 10%

Outcomes Skipped by Auto-Matching (12)

event side identifier reason
0 ECB Decision - December 2025 polymarket will-the-ecb-announce-an-increase-at-the-decem... no Kalshi contract found for parsed outcome ('...
1 ECB Decision - December 2025 kalshi KXCBDECISIONEU-25DEC18-H25P no Polymarket contract found for parsed outcom...
2 ECB Decision - December 2025 kalshi KXCBDECISIONEU-25DEC18-H25 no Polymarket contract found for parsed outcom...
3 ECB Decision - February 2026 polymarket will-the-ecb-announce-an-increase-at-the-febru... no Kalshi contract found for parsed outcome ('...
4 ECB Decision - February 2026 kalshi KXCBDECISIONEU-26FEB05-H25P no Polymarket contract found for parsed outcom...
5 ECB Decision - February 2026 kalshi KXCBDECISIONEU-26FEB05-H25 no Polymarket contract found for parsed outcom...
6 ECB Decision - March 2026 polymarket will-the-ecb-announce-an-increase-at-the-march... no Kalshi contract found for parsed outcome ('...
7 ECB Decision - March 2026 kalshi KXCBDECISIONEU-26MAR19-H25P no Polymarket contract found for parsed outcom...
8 ECB Decision - March 2026 kalshi KXCBDECISIONEU-26MAR19-H25 no Polymarket contract found for parsed outcom...
9 ECB Decision - April 2026 polymarket will-the-ecb-announce-an-increase-at-the-april... no Kalshi contract found for parsed outcome ('...
10 ECB Decision - April 2026 kalshi KXCBDECISIONEU-26APR30-H25P no Polymarket contract found for parsed outcom...
11 ECB Decision - April 2026 kalshi KXCBDECISIONEU-26APR30-H25 no Polymarket contract found for parsed outcom...
In [4]:
def load_or_fetch(cache_name, fetch_fn):
    cache_path = RAW_DIR / f"nb09_{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")
Dec 2025 – 50+bps Decrease: Kalshi history 4,850 rows
Dec 2025 – 25bps Decrease: Kalshi history 6,700 rows
Dec 2025 – No Change: Kalshi history 5,157 rows
Feb 2026 – 50+bps Decrease: Kalshi history 8,295 rows
Feb 2026 – 25bps Decrease: Kalshi history 15,057 rows
Feb 2026 – No Change: Kalshi history 10,368 rows
Mar 2026 – 50+bps Decrease: Kalshi history 1,140 rows
Mar 2026 – 25bps Decrease: Kalshi history 1,472 rows
Mar 2026 – No Change: Kalshi history 5,324 rows
Apr 2026 – 50+bps Decrease: Kalshi history 1,249 rows
Apr 2026 – 25bps Decrease: Kalshi history 2,003 rows
Apr 2026 – No Change: Kalshi history 12,117 rows
Jun 2026 – 50+bps Decrease: Kalshi history 747 rows
Jun 2026 – 25bps Decrease: Kalshi history 853 rows
Jun 2026 – No Change: Kalshi history 13,609 rows
Jun 2026 – 25bps Increase: Kalshi history 14,187 rows
Jun 2026 – 50+bps Increase: Kalshi history 3,068 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")
Dec 2025 – 50+bps Decrease: Polymarket history 70,384 rows
Dec 2025 – 25bps Decrease: Polymarket history 70,385 rows
Dec 2025 – No Change: Polymarket history 70,390 rows
Feb 2026 – 50+bps Decrease: Polymarket history 122,070 rows
Feb 2026 – 25bps Decrease: Polymarket history 122,071 rows
Feb 2026 – No Change: Polymarket history 122,072 rows
Mar 2026 – 50+bps Decrease: Polymarket history 130,710 rows
Mar 2026 – 25bps Decrease: Polymarket history 130,708 rows
Mar 2026 – No Change: Polymarket history 130,704 rows
Apr 2026 – 50+bps Decrease: Polymarket history 118,843 rows
Apr 2026 – 25bps Decrease: Polymarket history 118,842 rows
Apr 2026 – No Change: Polymarket history 118,842 rows
Jun 2026 – 50+bps Decrease: Polymarket history 119,916 rows
Jun 2026 – 25bps Decrease: Polymarket history 119,916 rows
Jun 2026 – No Change: Polymarket history 119,916 rows
Jun 2026 – 25bps Increase: Polymarket history 119,943 rows
Jun 2026 – 50+bps Increase: Polymarket history 119,916 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")
Dec 2025 – 50+bps Decrease: Kalshi trades 29 rows
Dec 2025 – 25bps Decrease: Kalshi trades 151 rows
Dec 2025 – No Change: Kalshi trades 169 rows
Feb 2026 – 50+bps Decrease: Kalshi trades 137 rows
Feb 2026 – 25bps Decrease: Kalshi trades 300 rows
Feb 2026 – No Change: Kalshi trades 173 rows
Mar 2026 – 50+bps Decrease: Kalshi trades 162 rows
Mar 2026 – 25bps Decrease: Kalshi trades 93 rows
Mar 2026 – No Change: Kalshi trades 208 rows
Apr 2026 – 50+bps Decrease: Kalshi trades 26 rows
Apr 2026 – 25bps Decrease: Kalshi trades 48 rows
Apr 2026 – No Change: Kalshi trades 408 rows
Jun 2026 – 50+bps Decrease: Kalshi trades 56 rows
Jun 2026 – 25bps Decrease: Kalshi trades 129 rows
Jun 2026 – No Change: Kalshi trades 503 rows
Jun 2026 – 25bps Increase: Kalshi trades 508 rows
Jun 2026 – 50+bps Increase: Kalshi trades 97 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")
Dec 2025 – 50+bps Decrease: Polymarket trades 1,574 rows
Dec 2025 – 25bps Decrease: Polymarket trades 1,016 rows
Dec 2025 – No Change: Polymarket trades 2,204 rows
Feb 2026 – 50+bps Decrease: Polymarket trades 2,105 rows
Feb 2026 – 25bps Decrease: Polymarket trades 2,354 rows
Feb 2026 – No Change: Polymarket trades 2,961 rows
Mar 2026 – 50+bps Decrease: Polymarket trades 1,476 rows
Mar 2026 – 25bps Decrease: Polymarket trades 1,182 rows
Mar 2026 – No Change: Polymarket trades 1,967 rows
Apr 2026 – 50+bps Decrease: Polymarket trades 2,030 rows
Apr 2026 – 25bps Decrease: Polymarket trades 2,177 rows
Apr 2026 – No Change: Polymarket trades 4,175 rows
Jun 2026 – 50+bps Decrease: Polymarket trades 1,031 rows
Jun 2026 – 25bps Decrease: Polymarket trades 1,304 rows
Jun 2026 – No Change: Polymarket trades 1,948 rows
Jun 2026 – 25bps Increase: Polymarket trades 2,323 rows
Jun 2026 – 50+bps Increase: Polymarket trades 1,913 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 Dec 2025 – 50+bps Decrease 4850 29 70384 1574
1 Dec 2025 – 25bps Decrease 6700 151 70385 1016
2 Dec 2025 – No Change 5157 169 70390 2204
3 Feb 2026 – 50+bps Decrease 8295 137 122070 2105
4 Feb 2026 – 25bps Decrease 15057 300 122071 2354
5 Feb 2026 – No Change 10368 173 122072 2961
6 Mar 2026 – 50+bps Decrease 1140 162 130710 1476
7 Mar 2026 – 25bps Decrease 1472 93 130708 1182
8 Mar 2026 – No Change 5324 208 130704 1967
9 Apr 2026 – 50+bps Decrease 1249 26 118843 2030
10 Apr 2026 – 25bps Decrease 2003 48 118842 2177
11 Apr 2026 – No Change 12117 408 118842 4175
12 Jun 2026 – 50+bps Decrease 747 56 119916 1031
13 Jun 2026 – 25bps Decrease 853 129 119916 1304
14 Jun 2026 – No Change 13609 503 119916 1948
15 Jun 2026 – 25bps Increase 14187 508 119943 2323
16 Jun 2026 – 50+bps Increase 3068 97 119916 1913

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, 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:
  Dec 2025 – 50+bps Decrease: 4,849 aligned ticks
  Dec 2025 – 25bps Decrease: 6,698 aligned ticks
  Dec 2025 – No Change: 5,156 aligned ticks
  Feb 2026 – 50+bps Decrease: 8,291 aligned ticks
  Feb 2026 – 25bps Decrease: 15,053 aligned ticks
  Feb 2026 – No Change: 10,365 aligned ticks
  Mar 2026 – 50+bps Decrease: 1,140 aligned ticks
  Mar 2026 – 25bps Decrease: 1,472 aligned ticks
  Mar 2026 – No Change: 5,323 aligned ticks
  Apr 2026 – 50+bps Decrease: 1,249 aligned ticks
  Apr 2026 – 25bps Decrease: 2,003 aligned ticks
  Apr 2026 – No Change: 12,092 aligned ticks
  Jun 2026 – 50+bps Decrease: 746 aligned ticks
  Jun 2026 – 25bps Decrease: 852 aligned ticks
  Jun 2026 – No Change: 13,597 aligned ticks
  Jun 2026 – 25bps Increase: 14,161 aligned ticks
  Jun 2026 – 50+bps Increase: 3,065 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")
Dec 2025 – 50+bps Decrease: 0 candidate entry episodes
Dec 2025 – 25bps Decrease: 22 candidate entry episodes
Dec 2025 – No Change: 0 candidate entry episodes
Feb 2026 – 50+bps Decrease: 26 candidate entry episodes
Feb 2026 – 25bps Decrease: 47 candidate entry episodes
Feb 2026 – No Change: 11 candidate entry episodes
Mar 2026 – 50+bps Decrease: 11 candidate entry episodes
Mar 2026 – 25bps Decrease: 44 candidate entry episodes
Mar 2026 – No Change: 6 candidate entry episodes
Apr 2026 – 50+bps Decrease: 10 candidate entry episodes
Apr 2026 – 25bps Decrease: 4 candidate entry episodes
Apr 2026 – No Change: 173 candidate entry episodes
Jun 2026 – 50+bps Decrease: 1 candidate entry episodes
Jun 2026 – 25bps Decrease: 1 candidate entry episodes
Jun 2026 – No Change: 192 candidate entry episodes
Jun 2026 – 25bps Increase: 199 candidate entry episodes
Jun 2026 – 50+bps Increase: 49 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)")
Dec 2025 – 50+bps Decrease: 0 / 0 episodes had a Kalshi fill (confirmed)
Dec 2025 – 25bps Decrease: 1 / 22 episodes had a Kalshi fill (confirmed)
Dec 2025 – No Change: 0 / 0 episodes had a Kalshi fill (confirmed)
Feb 2026 – 50+bps Decrease: 5 / 26 episodes had a Kalshi fill (confirmed)
Feb 2026 – 25bps Decrease: 1 / 47 episodes had a Kalshi fill (confirmed)
Feb 2026 – No Change: 1 / 11 episodes had a Kalshi fill (confirmed)
Mar 2026 – 50+bps Decrease: 5 / 11 episodes had a Kalshi fill (confirmed)
Mar 2026 – 25bps Decrease: 5 / 44 episodes had a Kalshi fill (confirmed)
Mar 2026 – No Change: 1 / 6 episodes had a Kalshi fill (confirmed)
Apr 2026 – 50+bps Decrease: 1 / 10 episodes had a Kalshi fill (confirmed)
Apr 2026 – 25bps Decrease: 1 / 4 episodes had a Kalshi fill (confirmed)
Apr 2026 – No Change: 24 / 173 episodes had a Kalshi fill (confirmed)
Jun 2026 – 50+bps Decrease: 0 / 1 episodes had a Kalshi fill (confirmed)
Jun 2026 – 25bps Decrease: 0 / 1 episodes had a Kalshi fill (confirmed)
Jun 2026 – No Change: 18 / 192 episodes had a Kalshi fill (confirmed)
Jun 2026 – 25bps Increase: 13 / 199 episodes had a Kalshi fill (confirmed)
Jun 2026 – 50+bps Increase: 1 / 49 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")
Dec 2025 – 50+bps Decrease: 0 profit-target exits, 0 held to close

Dec 2025 – 25bps Decrease: 0 profit-target exits, 1 held to close
Dec 2025 – No Change: 0 profit-target exits, 0 held to close
Feb 2026 – 50+bps Decrease: 3 profit-target exits, 2 held to close
Feb 2026 – 25bps Decrease: 0 profit-target exits, 1 held to close
Feb 2026 – No Change: 1 profit-target exits, 0 held to close
Mar 2026 – 50+bps Decrease: 4 profit-target exits, 1 held to close
Mar 2026 – 25bps Decrease: 0 profit-target exits, 5 held to close
Mar 2026 – No Change: 1 profit-target exits, 0 held to close
Apr 2026 – 50+bps Decrease: 0 profit-target exits, 1 held to close
Apr 2026 – 25bps Decrease: 0 profit-target exits, 1 held to close
Apr 2026 – No Change: 24 profit-target exits, 0 held to close
Jun 2026 – 50+bps Decrease: 0 profit-target exits, 0 held to close
Jun 2026 – 25bps Decrease: 0 profit-target exits, 0 held to close
Jun 2026 – No Change: 11 profit-target exits, 7 held to close
Jun 2026 – 25bps Increase: 13 profit-target exits, 0 held to close
Jun 2026 – 50+bps Increase: 0 profit-target exits, 1 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: 77
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: 77
Trades skipped for lack of available bankroll: 0
Final bankroll: $1,924.57 (started at $1,000.00)
C:\Users\mesim\AppData\Local\Temp\ipykernel_8976\4151027514.py:19: RuntimeWarning: divide by zero encountered in scalar divide
  contracts = int(min(sub_budget / cand["entry_kalshi_price"], sub_budget / cand["entry_poly_price"]))
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 796
1 Kalshi Confirmed 77
2 Confirmation Rate 9.7%
3 Total Trades 77
4 Win Rate 97.4%
5 Total PNL $924.57
6 Total Fees Paid $411.82
7 Starting Bankroll $1,000.00
8 Final Bankroll $1,924.57
9 Overall ROI 92.5%
10 Avg. Holding Duration 194.3 hours

By Event

trades win_rate total_pnl total_fees avg_holding_hours
event_key
ecb_apr_2026 26 0.96 179.54 136.63 120.09
ecb_dec_2025 1 1.00 4.93 0.42 861.38
ecb_feb_2026 7 1.00 166.63 3.74 436.90
ecb_jun_2026 32 0.97 478.70 262.90 94.52
ecb_mar_2026 11 1.00 94.78 8.13 444.69

By Bucket

trades win_rate total_pnl kalshi_fees poly_fees total_fees avg_holding_hours
bucket_label
Apr 2026 – 25bps Decrease 1 1.00 4.61 0.34 0.01 0.35 662.45
Apr 2026 – 50+bps Decrease 1 1.00 5.38 0.40 0.01 0.41 734.55
Apr 2026 – No Change 24 0.96 169.55 52.13 83.74 135.87 71.89
Dec 2025 – 25bps Decrease 1 1.00 4.93 0.42 0.00 0.42 861.38
Feb 2026 – 25bps Decrease 1 1.00 4.22 0.41 0.00 0.41 471.42
Feb 2026 – 50+bps Decrease 5 1.00 155.44 3.12 0.00 3.12 433.90
Feb 2026 – No Change 1 1.00 6.97 0.21 0.00 0.21 417.38
Jun 2026 – 25bps Increase 13 1.00 163.31 52.19 78.96 131.15 21.96
Jun 2026 – 50+bps Increase 1 1.00 7.28 0.62 0.13 0.75 627.25
Jun 2026 – No Change 18 0.94 308.11 59.36 71.63 130.99 117.33
Mar 2026 – 25bps Decrease 5 1.00 28.98 3.30 0.00 3.30 731.04
Mar 2026 – 50+bps Decrease 5 1.00 60.78 4.42 0.00 4.42 234.57
Mar 2026 – No Change 1 1.00 5.03 0.41 0.00 0.41 63.55

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 events in play, capital recycles across December, February, March, April, and June 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 — 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 — ECB Decision 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 $1,924.57

No description has been provided for this image
Peak bankroll: $1,924.57 | Trough bankroll: $245.06 | Max drawdown from peak: $1,679.52

Summary

Second-strongest result of any backtest in this project: +92.5% ROI ($924.57 net on the $1,000 starting bankroll), 77 trades, 97.4% win rate. All five events landed net positive individually — June was the largest contributor ($478.70 on 32 trades), followed by April ($179.54), February ($166.63), March ($94.78), and December ($4.93, on a single trade — the smallest sample of the five, this event's window was the shortest of all events tested across notebooks 06–09).

Funnel: 796 signals detected across the 17 buckets, 77 Kalshi-confirmed (9.7% confirmation rate — close to notebook 08's Bank of Canada rate of 9.2%, well below Bank of England's unusually high 23.8%). Every confirmed signal executed — 0 skipped for insufficient bankroll, and the "confirmed but insufficient forward data" edge case from notebook 07 wasn't hit here either.

Fees consumed a middling share of gross edge, between the other two central-bank notebooks: gross PNL was $1,336.39 ($924.57 net + $411.82 fees) — fees took 30.8% of the edge, versus 23.2% for Bank of Canada (notebook 08) and 36.0% for Bank of England (notebook 07). December, February, and March all had `feesEnabled: False` on Polymarket's side (fee = $0) — February's 7 trades in particular delivered $166.63 of PNL against just $3.74 in fees, the best fee-to-edge ratio of any event bucket across this notebook.

Bankroll swung real capital — trough $245.06 against a peak (and final value) of $1,924.57, a max drawdown of $1,679.52 — comfortably above notebook 06's Fed near-wipeout, in the same range as notebooks 07 and 08's healthier profiles.

Two implementation notes, both handled without changing the strategy itself:

  • A genuinely new pandas edge case surfaced in find_entry_episodes(): a bucket whose gap never once crossed the entry threshold produces a zero-row groupby().agg() result, and pandas doesn't reliably preserve the start/end columns' datetime dtype across every such empty case — .dt.total_seconds() raised AttributeError on some empty buckets but not others in the same run. Fixed by short-circuiting on an empty result rather than depending on dtype inference over zero rows.
  • A harmless RuntimeWarning: divide by zero appeared in the bankroll pass — one leg's entry price hit exactly $0.00 (plausible at an extreme, near-certain outcome), and numpy's float division returns inf rather than raising. min(inf, other_leg) still correctly falls back to the finite constraint, so contract sizing was unaffected — confirmed by sane, internally consistent totals throughout.

No new market_matching.py bugs — verified before building, using the exact same wording variants ("more than 25bp(s)," spelled-out "basis points") that prompted the two fixes made for Bank of England. Both generalized cleanly to ECB's identical phrasing on the first try.