Notebook 07: Bank of England Decision Backtest

This notebook takes the same hedge-arbitrage strategy backtested in notebooks 04 and 06 and points it at Bank of England rate decisions instead of Fed or Bank of Canada ones: March, April, and June 2026. Same engine, same variables, same entry/exit rules — only the events under test change.

A fourth event, July 2026, was identified and structurally verified (5/5 outcomes matched cleanly) but is excluded from this run — its candlestick history isn't available yet (confirmed via a live 404 on every ticker in the event), the same too-recently-resolved data gap that excluded Fed's July decision from notebook 06.

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

Bucket count varies by event, and it's not a bug. Polymarket lumps all rate-hike outcomes into one contract for March and April (no 1:1 Kalshi counterpart, so the hike side is skipped for those two), while June splits hikes the same way Kalshi does, contributing the full five-outcome set. That gives 11 matched buckets across 3 events (3 + 3 + 5), not 15.

This event type also forced two real fixes to src/market_matching.py, discovered while verifying these events — Bank of England's wording differs from Fed/BoC in ways the outcome parser didn't originally handle:

  • "Hike more than 25bps" (BoE) vs. "Hike **50+**bps"/">25bps" (Fed/BoC) — without recognizing "more than," this parsed to the same key as the 1-25bps bucket, silently discarding the 50+bps ticker with no error.
  • "Hike 1-25 basis points" (used only in March's listing) vs. "Hike 1-25bps" (every other month) — the unit regex only recognized "bps," so March's spelled-out wording fell through to an unrecognized magnitude and silently matched Polymarket's lumped "any increase" contract under a shared fallback key — a wrong pairing that would have looked like a valid match if it hadn't been checked by hand.

Both are now fixed with unit-agnostic matching (bps, bp, and basis points all treated the same). Fee rate is also not uniform across these events — March has feesEnabled: False (fee = $0), April and June have feesEnabled: True at the standard 10% rate — handled per-bucket exactly as in notebook 06.

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, 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, 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 four events — March's markets have feesEnabled: False (fee = $0), while April's, June's, and July'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 11 matched outcome buckets across 3 Bank of England decisions (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 nb07_ 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_4

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 = {
    "boe_mar_2026": "Mar 2026",
    "boe_apr_2026": "Apr 2026",
    "boe_jun_2026": "Jun 2026",
    "boe_jul_2026": "Jul 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, 11 Matched Buckets Across 3 Events

Bucket Kalshi Ticker Kalshi Window Polymarket Slug Polymarket Window Poly Fee Rate
0 Mar 2026 – 50+bps Decrease KXCBDECISIONENGLAND-26MAR19-C25P 2026-02-06 to 2026-03-19 bank-of-england-decreases-interest-rates-by-50... 2025-12-18 to 2026-03-19 0%
1 Mar 2026 – 25bps Decrease KXCBDECISIONENGLAND-26MAR19-C25 2026-02-06 to 2026-03-19 bank-of-england-decreases-interest-rates-by-25... 2025-12-18 to 2026-03-19 0%
2 Mar 2026 – No Change KXCBDECISIONENGLAND-26MAR19-HOLD 2026-02-06 to 2026-03-19 no-change-in-bank-of-englands-interest-rates-a... 2025-12-18 to 2026-03-19 0%
3 Apr 2026 – 50+bps Decrease KXCBDECISIONENGLAND-26APR30-C25P 2026-03-21 to 2026-04-30 bank-of-england-decreases-interest-rates-by-50... 2026-02-06 to 2026-04-30 10%
4 Apr 2026 – 25bps Decrease KXCBDECISIONENGLAND-26APR30-C25 2026-03-21 to 2026-04-30 bank-of-england-decreases-interest-rates-by-25... 2026-02-06 to 2026-04-30 10%
5 Apr 2026 – No Change KXCBDECISIONENGLAND-26APR30-HOLD 2026-03-21 to 2026-04-30 no-change-in-bank-of-englands-interest-rates-a... 2026-02-06 to 2026-04-30 10%
6 Jun 2026 – 50+bps Decrease KXCBDECISIONENGLAND-26JUN18-C25P 2026-04-30 to 2026-06-18 bank-of-england-decreases-interest-rates-by-50... 2026-03-24 to 2026-06-18 10%
7 Jun 2026 – 25bps Decrease KXCBDECISIONENGLAND-26JUN18-C25 2026-04-30 to 2026-06-18 bank-of-england-decreases-interest-rates-by-25... 2026-03-24 to 2026-06-18 10%
8 Jun 2026 – No Change KXCBDECISIONENGLAND-26JUN18-HOLD 2026-04-30 to 2026-06-18 no-change-in-bank-of-englands-interest-rates-a... 2026-03-24 to 2026-06-18 10%
9 Jun 2026 – 25bps Increase KXCBDECISIONENGLAND-26JUN18-H25 2026-04-30 to 2026-06-18 bank-of-england-increases-interest-rates-after... 2026-03-24 to 2026-06-18 10%
10 Jun 2026 – 50+bps Increase KXCBDECISIONENGLAND-26JUN18-H25P 2026-04-30 to 2026-06-18 bank-of-england-increases-interest-rates-by-50... 2026-03-24 to 2026-06-18 10%

Outcomes Skipped by Auto-Matching (6)

event side identifier reason
0 Bank of England Decision - March 2026 polymarket bank-of-england-increases-interest-rates-after... no Kalshi contract found for parsed outcome ('...
1 Bank of England Decision - March 2026 kalshi KXCBDECISIONENGLAND-26MAR19-H25P no Polymarket contract found for parsed outcom...
2 Bank of England Decision - March 2026 kalshi KXCBDECISIONENGLAND-26MAR19-H25 no Polymarket contract found for parsed outcom...
3 Bank of England Decision - April 2026 polymarket bank-of-england-increases-interest-rates-after... no Kalshi contract found for parsed outcome ('...
4 Bank of England Decision - April 2026 kalshi KXCBDECISIONENGLAND-26APR30-H25P no Polymarket contract found for parsed outcom...
5 Bank of England Decision - April 2026 kalshi KXCBDECISIONENGLAND-26APR30-H25 no Polymarket contract found for parsed outcom...
In [4]:
def load_or_fetch(cache_name, fetch_fn):
    cache_path = RAW_DIR / f"nb07_{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")
Mar 2026 – 50+bps Decrease: Kalshi history 470 rows
Mar 2026 – 25bps Decrease: Kalshi history 5,110 rows
Mar 2026 – No Change: Kalshi history 5,499 rows
Apr 2026 – 50+bps Decrease: Kalshi history 85 rows
Apr 2026 – 25bps Decrease: Kalshi history 106 rows
Apr 2026 – No Change: Kalshi history 6,427 rows
Jun 2026 – 50+bps Decrease: Kalshi history 528 rows
Jun 2026 – 25bps Decrease: Kalshi history 350 rows
Jun 2026 – No Change: Kalshi history 7,393 rows
Jun 2026 – 25bps Increase: Kalshi history 8,044 rows
Jun 2026 – 50+bps Increase: Kalshi history 575 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")
Mar 2026 – 50+bps Decrease: Polymarket history 130,614 rows
Mar 2026 – 25bps Decrease: Polymarket history 130,614 rows
Mar 2026 – No Change: Polymarket history 130,614 rows
Apr 2026 – 50+bps Decrease: Polymarket history 118,718 rows
Apr 2026 – 25bps Decrease: Polymarket history 118,718 rows
Apr 2026 – No Change: Polymarket history 118,717 rows
Jun 2026 – 50+bps Decrease: Polymarket history 122,741 rows
Jun 2026 – 25bps Decrease: Polymarket history 122,741 rows
Jun 2026 – No Change: Polymarket history 122,767 rows
Jun 2026 – 25bps Increase: Polymarket history 122,741 rows
Jun 2026 – 50+bps Increase: Polymarket history 122,741 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")
Mar 2026 – 50+bps Decrease: Kalshi trades 62 rows
Mar 2026 – 25bps Decrease: Kalshi trades 335 rows
Mar 2026 – No Change: Kalshi trades 543 rows
Apr 2026 – 50+bps Decrease: Kalshi trades 0 rows
Apr 2026 – 25bps Decrease: Kalshi trades 6 rows
Apr 2026 – No Change: Kalshi trades 206 rows
Jun 2026 – 50+bps Decrease: Kalshi trades 71 rows
Jun 2026 – 25bps Decrease: Kalshi trades 62 rows
Jun 2026 – No Change: Kalshi trades 327 rows
Jun 2026 – 25bps Increase: Kalshi trades 265 rows
Jun 2026 – 50+bps Increase: Kalshi trades 67 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")
Mar 2026 – 50+bps Decrease: Polymarket trades 986 rows
Mar 2026 – 25bps Decrease: Polymarket trades 1,863 rows
Mar 2026 – No Change: Polymarket trades 1,840 rows
Apr 2026 – 50+bps Decrease: Polymarket trades 1,082 rows
Apr 2026 – 25bps Decrease: Polymarket trades 1,420 rows
Apr 2026 – No Change: Polymarket trades 1,971 rows
Jun 2026 – 50+bps Decrease: Polymarket trades 525 rows
Jun 2026 – 25bps Decrease: Polymarket trades 616 rows
Jun 2026 – No Change: Polymarket trades 952 rows
Jun 2026 – 25bps Increase: Polymarket trades 740 rows
Jun 2026 – 50+bps Increase: Polymarket trades 617 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 Mar 2026 – 50+bps Decrease 470 62 130614 986
1 Mar 2026 – 25bps Decrease 5110 335 130614 1863
2 Mar 2026 – No Change 5499 543 130614 1840
3 Apr 2026 – 50+bps Decrease 85 0 118718 1082
4 Apr 2026 – 25bps Decrease 106 6 118718 1420
5 Apr 2026 – No Change 6427 206 118717 1971
6 Jun 2026 – 50+bps Decrease 528 71 122741 525
7 Jun 2026 – 25bps Decrease 350 62 122741 616
8 Jun 2026 – No Change 7393 327 122767 952
9 Jun 2026 – 25bps Increase 8044 265 122741 740
10 Jun 2026 – 50+bps Increase 575 67 122741 617

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, 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 3 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:
  Mar 2026 – 50+bps Decrease: 470 aligned ticks
  Mar 2026 – 25bps Decrease: 5,101 aligned ticks
  Mar 2026 – No Change: 5,498 aligned ticks
  Apr 2026 – 50+bps Decrease: 85 aligned ticks
  Apr 2026 – 25bps Decrease: 106 aligned ticks
  Apr 2026 – No Change: 6,415 aligned ticks
  Jun 2026 – 50+bps Decrease: 527 aligned ticks
  Jun 2026 – 25bps Decrease: 349 aligned ticks
  Jun 2026 – No Change: 7,384 aligned ticks
  Jun 2026 – 25bps Increase: 8,036 aligned ticks
  Jun 2026 – 50+bps Increase: 570 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"),
    )
    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")
Mar 2026 – 50+bps Decrease: 4 candidate entry episodes
Mar 2026 – 25bps Decrease: 83 candidate entry episodes
Mar 2026 – No Change: 77 candidate entry episodes
Apr 2026 – 50+bps Decrease: 1 candidate entry episodes
Apr 2026 – 25bps Decrease: 1 candidate entry episodes
Apr 2026 – No Change: 50 candidate entry episodes
Jun 2026 – 50+bps Decrease: 1 candidate entry episodes
Jun 2026 – 25bps Decrease: 2 candidate entry episodes
Jun 2026 – No Change: 86 candidate entry episodes
Jun 2026 – 25bps Increase: 112 candidate entry episodes
Jun 2026 – 50+bps Increase: 8 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)")
Mar 2026 – 50+bps Decrease: 2 / 4 episodes had a Kalshi fill (confirmed)
Mar 2026 – 25bps Decrease: 17 / 83 episodes had a Kalshi fill (confirmed)
Mar 2026 – No Change: 23 / 77 episodes had a Kalshi fill (confirmed)
Apr 2026 – 50+bps Decrease: 0 / 1 episodes had a Kalshi fill (confirmed)
Apr 2026 – 25bps Decrease: 1 / 1 episodes had a Kalshi fill (confirmed)
Apr 2026 – No Change: 7 / 50 episodes had a Kalshi fill (confirmed)
Jun 2026 – 50+bps Decrease: 1 / 1 episodes had a Kalshi fill (confirmed)
Jun 2026 – 25bps Decrease: 1 / 2 episodes had a Kalshi fill (confirmed)
Jun 2026 – No Change: 13 / 86 episodes had a Kalshi fill (confirmed)
Jun 2026 – 25bps Increase: 32 / 112 episodes had a Kalshi fill (confirmed)
Jun 2026 – 50+bps Increase: 4 / 8 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")
Mar 2026 – 50+bps Decrease: 0 profit-target exits, 2 held to close
Mar 2026 – 25bps Decrease: 17 profit-target exits, 0 held to close
Mar 2026 – No Change: 23 profit-target exits, 0 held to close
Apr 2026 – 50+bps Decrease: 0 profit-target exits, 0 held to close
Apr 2026 – 25bps Decrease: 0 profit-target exits, 0 held to close
Apr 2026 – No Change: 7 profit-target exits, 0 held to close
Jun 2026 – 50+bps Decrease: 0 profit-target exits, 1 held to close
Jun 2026 – 25bps Decrease: 0 profit-target exits, 1 held to close
Jun 2026 – No Change: 13 profit-target exits, 0 held to close
Jun 2026 – 25bps Increase: 16 profit-target exits, 15 held to close
Jun 2026 – 50+bps Increase: 0 profit-target exits, 4 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," and not previously hit by
    # any Fed/BoC bucket, so notebooks 04/06 never needed this guard.
    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: 99
(Confirmed but excluded for insufficient forward data before bucket close: 2)
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: 99
Trades skipped for lack of available bankroll: 0
Final bankroll: $1,617.44 (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 425
1 Kalshi Confirmed 101
2 Confirmation Rate 23.8%
3 Total Trades 99
4 Win Rate 92.9%
5 Total PNL $617.44
6 Total Fees Paid $347.57
7 Starting Bankroll $1,000.00
8 Final Bankroll $1,617.44
9 Overall ROI 61.7%
10 Avg. Holding Duration 105.5 hours

By Event

trades win_rate total_pnl total_fees avg_holding_hours
event_key
boe_apr_2026 7 0.86 79.31 33.04 34.70
boe_jun_2026 50 0.88 207.23 228.97 134.99
boe_mar_2026 42 1.00 330.90 85.56 82.12

By Bucket

trades win_rate total_pnl kalshi_fees poly_fees total_fees avg_holding_hours
bucket_label
Apr 2026 – No Change 7 0.86 79.31 11.23 21.81 33.04 34.70
Jun 2026 – 25bps Decrease 1 1.00 7.40 0.54 0.02 0.56 1167.15
Jun 2026 – 25bps Increase 31 0.90 127.70 58.36 73.57 131.93 53.34
Jun 2026 – 50+bps Decrease 1 1.00 7.55 0.60 0.08 0.68 1167.45
Jun 2026 – 50+bps Increase 4 1.00 20.71 1.54 0.09 1.63 496.45
Jun 2026 – No Change 13 0.77 43.87 36.83 57.34 94.17 59.66
Mar 2026 – 25bps Decrease 17 1.00 111.17 32.04 0.00 32.04 75.60
Mar 2026 – 50+bps Decrease 2 1.00 8.14 0.63 0.00 0.63 637.67
Mar 2026 – No Change 23 1.00 211.59 52.89 0.00 52.89 38.64

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 three events in play, capital recycles across 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 — Bank of England 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,617.44

No description has been provided for this image
Peak bankroll: $1,617.44 | Trough bankroll: $208.82 | Max drawdown from peak: $1,408.62

Summary

Strongest result of any backtest in this project so far: +61.7% ROI ($617.44 net on the $1,000 starting bankroll), 99 trades, 92.9% win rate. All three events landed net positive individually — March 100% win rate ($330.90), June 88% ($207.23), April 86% ($79.31) — not just in aggregate.

Funnel: 425 signals detected across the 11 buckets, 101 Kalshi-confirmed (23.8% confirmation rate — notably higher than every Fed/BoC event checked so far, which ran 6.6%–19.2%), 99 executed. The 2-trade gap between confirmed and executed isn't a funnel miss in the usual sense — it's the new edge case this notebook surfaced: a confirmed signal firing with fewer than 2 aligned ticks left before its bucket's own close, which simulate_exit() can't evaluate. Zero trades were skipped for insufficient bankroll.

Fees ate a smaller share of the edge than in the Fed backtest. Gross PNL was $965.01 ($617.44 net + $347.57 fees) — fees consumed 36.0% of the gross edge here, versus 45.8% in notebook 06's Fed backtest. March's zero Polymarket fees (`feesEnabled: False`, same pattern as Fed's January and March) is part of that — its 42 trades paid Kalshi fees only ($85.56 total) and still delivered the single best per-event result ($330.90 on the fewest trades of the three events).

Bankroll swung meaningfully but nowhere near the near-wipeout seen in notebook 06. Trough was $208.82 (peak $1,617.44, max drawdown $1,408.62) — a real, substantial commitment of capital, but the strategy never got within a few dollars of zero the way the Fed multi-event backtest's $3.87 trough did.

Two real bugs were found and fixed to make this backtest trustworthy at all, both in src/market_matching.py, both specific to Bank of England's wording conventions rather than anything Fed/BoC used:

  • "Hike more than 25bps" collided with the 1-25bps bucket's key, silently discarding the 50+bps ticker with no error — fixed by recognizing "more than" as equivalent to 50+/>25.
  • March's listing spelled out "basis points" instead of "bps," which fell through to an unrecognized magnitude and silently matched Polymarket's lumped "any increase" contract — a wrong pairing that looked valid until checked by hand. Fixed with unit-agnostic matching (bps, bp, basis points all treated the same).

A fourth event, July 2026, was structurally verified (5/5 outcomes matched cleanly, same as June) but excluded from this run — its Kalshi candlestick history isn't available yet, the same too-recently-resolved data gap that excluded Fed's July from notebook 06.