Notebook 08: Bank of Canada Decision Backtest

This notebook takes the same hedge-arbitrage strategy backtested in notebooks 04, 06, and 07 and points it at four Bank of Canada rate decisions: January, 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's pattern: all matched outcome buckets across the four meetings are folded into one combined BUCKETS dict, keyed and labeled by event (e.g. boc_jan_2026__cut_small), and run through a single backtest pass with one shared bankroll.

Bucket count is uniform this time, unlike Fed or BoE. Polymarket lumps the entire rate-hike side into one contract with no magnitude specified, every single month — not just some months like Fed, and not resolved by June the way BoE was. Kalshi still splits hikes into two buckets (25bps / 50+bps), so there's no 1:1 counterpart and the hike side is skipped for all four events. That gives a clean 12 matched buckets across 4 events (3 + 3 + 3 + 3).

No new bugs this time. Bank of Canada's Kalshi wording ("Cut >25bps", "Cut 25bps", "Maintains rate") already matches the patterns market_matching.py was built for — verified directly against live data for all four months before starting, unlike Bank of England, which needed two real fixes to the outcome parser. Candlestick history was also confirmed available for all four 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 four events, same pattern as before: January 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.

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, 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, 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 — January'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 12 matched outcome buckets across 4 Bank of Canada decisions (January, 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 nb08_ 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_5

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 = {
    "boc_jan_2026": "Jan 2026",
    "boc_mar_2026": "Mar 2026",
    "boc_apr_2026": "Apr 2026",
    "boc_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, 12 Matched Buckets Across 4 Events

Bucket Kalshi Ticker Kalshi Window Polymarket Slug Polymarket Window Poly Fee Rate
0 Jan 2026 – 50+bps Decrease KXCBDECISIONCANADA-26JAN-C26 2025-10-13 to 2026-01-28 will-the-bank-of-canada-announce-a-50-bps-decr... 2025-11-07 to 2026-01-28 0%
1 Jan 2026 – 25bps Decrease KXCBDECISIONCANADA-26JAN-C25 2025-10-13 to 2026-01-28 will-the-bank-of-canada-announce-a-25-bps-decr... 2025-11-07 to 2026-01-28 0%
2 Jan 2026 – No Change KXCBDECISIONCANADA-26JAN-H0 2025-10-13 to 2026-01-28 will-the-bank-of-canada-announce-no-change-at-... 2025-11-07 to 2026-01-28 0%
3 Mar 2026 – 50+bps Decrease KXCBDECISIONCANADA-26MAR-C26 2025-10-13 to 2026-03-18 will-the-bank-of-canada-announce-a-50-bps-decr... 2025-12-23 to 2026-03-18 0%
4 Mar 2026 – 25bps Decrease KXCBDECISIONCANADA-26MAR-C25 2025-10-13 to 2026-03-18 will-the-bank-of-canada-announce-a-25-bps-decr... 2025-12-23 to 2026-03-18 0%
5 Mar 2026 – No Change KXCBDECISIONCANADA-26MAR-H0 2025-10-13 to 2026-03-18 will-the-bank-of-canada-announce-no-change-at-... 2025-12-23 to 2026-03-18 0%
6 Apr 2026 – 50+bps Decrease KXCBDECISIONCANADA-26APR-C26 2025-10-13 to 2026-04-29 will-the-bank-of-canada-announce-a-50-bps-decr... 2026-01-31 to 2026-04-29 10%
7 Apr 2026 – 25bps Decrease KXCBDECISIONCANADA-26APR-C25 2025-10-13 to 2026-04-29 will-the-bank-of-canada-announce-a-25-bps-decr... 2026-01-31 to 2026-04-29 10%
8 Apr 2026 – No Change KXCBDECISIONCANADA-26APR-H0 2025-10-13 to 2026-04-29 will-the-bank-of-canada-announce-no-change-at-... 2026-01-31 to 2026-04-29 10%
9 Jun 2026 – 50+bps Decrease KXCBDECISIONCANADA-26JUN-C26 2025-10-13 to 2026-06-10 will-the-bank-of-canada-announce-a-50-bps-decr... 2026-03-24 to 2026-06-10 10%
10 Jun 2026 – 25bps Decrease KXCBDECISIONCANADA-26JUN-C25 2025-10-13 to 2026-06-10 will-the-bank-of-canada-announce-a-25-bps-decr... 2026-03-24 to 2026-06-10 10%
11 Jun 2026 – No Change KXCBDECISIONCANADA-26JUN-H0 2025-10-13 to 2026-06-10 will-the-bank-of-canada-announce-no-change-at-... 2026-03-24 to 2026-06-10 10%

Outcomes Skipped by Auto-Matching (12)

event side identifier reason
0 Bank of Canada Decision - January 2026 polymarket will-the-bank-of-canada-announce-an-increase-a... no Kalshi contract found for parsed outcome ('...
1 Bank of Canada Decision - January 2026 kalshi KXCBDECISIONCANADA-26JAN-H26 no Polymarket contract found for parsed outcom...
2 Bank of Canada Decision - January 2026 kalshi KXCBDECISIONCANADA-26JAN-H25 no Polymarket contract found for parsed outcom...
3 Bank of Canada Decision - March 2026 polymarket will-the-bank-of-canada-announce-an-increase-a... no Kalshi contract found for parsed outcome ('...
4 Bank of Canada Decision - March 2026 kalshi KXCBDECISIONCANADA-26MAR-H26 no Polymarket contract found for parsed outcom...
5 Bank of Canada Decision - March 2026 kalshi KXCBDECISIONCANADA-26MAR-H25 no Polymarket contract found for parsed outcom...
6 Bank of Canada Decision - April 2026 polymarket will-the-bank-of-canada-announce-an-increase-a... no Kalshi contract found for parsed outcome ('...
7 Bank of Canada Decision - April 2026 kalshi KXCBDECISIONCANADA-26APR-H26 no Polymarket contract found for parsed outcom...
8 Bank of Canada Decision - April 2026 kalshi KXCBDECISIONCANADA-26APR-H25 no Polymarket contract found for parsed outcom...
9 Bank of Canada Decision - June 2026 polymarket will-the-bank-of-canada-announce-an-increase-a... no Kalshi contract found for parsed outcome ('...
10 Bank of Canada Decision - June 2026 kalshi KXCBDECISIONCANADA-26JUN-H26 no Polymarket contract found for parsed outcom...
11 Bank of Canada Decision - June 2026 kalshi KXCBDECISIONCANADA-26JUN-H25 no Polymarket contract found for parsed outcom...
In [4]:
def load_or_fetch(cache_name, fetch_fn):
    cache_path = RAW_DIR / f"nb08_{cache_name}.parquet"
    if cache_path.exists():
        return pd.read_parquet(cache_path)
    df = fetch_fn()
    df.to_parquet(cache_path)
    return df
In [5]:
for key, b in BUCKETS.items():
    b["kalshi_history"] = load_or_fetch(
        f"{key}__kalshi_history",
        lambda b=b: kalshi_client.get_candlesticks(
            b["kalshi_ticker"], b["kalshi_open"], b["kalshi_close"], period_interval=1
        ),
    )
    print(f"{b['label']}: Kalshi history {len(b['kalshi_history']):,} rows")
Jan 2026 – 50+bps Decrease: Kalshi history 13,380 rows
Jan 2026 – 25bps Decrease: Kalshi history 24,279 rows
Jan 2026 – No Change: Kalshi history 19,568 rows
Mar 2026 – 50+bps Decrease: Kalshi history 8,261 rows
Mar 2026 – 25bps Decrease: Kalshi history 18,255 rows
Mar 2026 – No Change: Kalshi history 18,708 rows
Apr 2026 – 50+bps Decrease: Kalshi history 9,743 rows
Apr 2026 – 25bps Decrease: Kalshi history 12,258 rows
Apr 2026 – No Change: Kalshi history 20,882 rows
Jun 2026 – 50+bps Decrease: Kalshi history 5,701 rows
Jun 2026 – 25bps Decrease: Kalshi history 12,111 rows
Jun 2026 – No Change: Kalshi history 18,434 rows
In [6]:
for key, b in BUCKETS.items():
    b["polymarket_history"] = load_or_fetch(
        f"{key}__polymarket_history",
        lambda b=b: polymarket_client.get_prices_history(
            b["polymarket_token"], b["polymarket_open"], b["polymarket_close"], fidelity=1
        ),
    )
    print(f"{b['label']}: Polymarket history {len(b['polymarket_history']):,} rows")
Jan 2026 – 50+bps Decrease: Polymarket history 118,139 rows
Jan 2026 – 25bps Decrease: Polymarket history 118,118 rows
Jan 2026 – No Change: Polymarket history 118,119 rows
Mar 2026 – 50+bps Decrease: Polymarket history 121,986 rows
Mar 2026 – 25bps Decrease: Polymarket history 121,986 rows
Mar 2026 – No Change: Polymarket history 121,984 rows
Apr 2026 – 50+bps Decrease: Polymarket history 127,464 rows
Apr 2026 – 25bps Decrease: Polymarket history 127,464 rows
Apr 2026 – No Change: Polymarket history 127,464 rows
Jun 2026 – 50+bps Decrease: Polymarket history 111,407 rows
Jun 2026 – 25bps Decrease: Polymarket history 111,395 rows
Jun 2026 – No Change: Polymarket history 111,390 rows
In [7]:
for key, b in BUCKETS.items():
    b["kalshi_trades"] = load_or_fetch(
        f"{key}__kalshi_trades",
        lambda b=b: kalshi_client.get_trades(b["kalshi_ticker"], b["kalshi_open"], b["kalshi_close"]),
    )
    print(f"{b['label']}: Kalshi trades {len(b['kalshi_trades']):,} rows")
Jan 2026 – 50+bps Decrease: Kalshi trades 89 rows
Jan 2026 – 25bps Decrease: Kalshi trades 248 rows
Jan 2026 – No Change: Kalshi trades 300 rows
Mar 2026 – 50+bps Decrease: Kalshi trades 78 rows
Mar 2026 – 25bps Decrease: Kalshi trades 291 rows
Mar 2026 – No Change: Kalshi trades 511 rows
Apr 2026 – 50+bps Decrease: Kalshi trades 108 rows
Apr 2026 – 25bps Decrease: Kalshi trades 140 rows
Apr 2026 – No Change: Kalshi trades 593 rows
Jun 2026 – 50+bps Decrease: Kalshi trades 14 rows
Jun 2026 – 25bps Decrease: Kalshi trades 73 rows
Jun 2026 – No Change: Kalshi trades 568 rows
In [8]:
for key, b in BUCKETS.items():
    b["polymarket_trades"] = load_or_fetch(
        f"{key}__polymarket_trades",
        lambda b=b: polymarket_client.get_trades(
            b["polymarket_condition_id"], b["polymarket_open"], b["polymarket_close"]
        ),
    )
    print(f"{b['label']}: Polymarket trades {len(b['polymarket_trades']):,} rows")
Jan 2026 – 50+bps Decrease: Polymarket trades 377 rows
Jan 2026 – 25bps Decrease: Polymarket trades 428 rows
Jan 2026 – No Change: Polymarket trades 665 rows
Mar 2026 – 50+bps Decrease: Polymarket trades 744 rows
Mar 2026 – 25bps Decrease: Polymarket trades 953 rows
Mar 2026 – No Change: Polymarket trades 1,367 rows
Apr 2026 – 50+bps Decrease: Polymarket trades 574 rows
Apr 2026 – 25bps Decrease: Polymarket trades 539 rows
Apr 2026 – No Change: Polymarket trades 1,019 rows
Jun 2026 – 50+bps Decrease: Polymarket trades 198 rows
Jun 2026 – 25bps Decrease: Polymarket trades 227 rows
Jun 2026 – No Change: Polymarket trades 668 rows
In [9]:
display(Markdown("#### Data Ingestion Complete"))

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

Data Ingestion Complete

Bucket Kalshi Candles Kalshi Trades Polymarket Ticks Polymarket Trades
0 Jan 2026 – 50+bps Decrease 13380 89 118139 377
1 Jan 2026 – 25bps Decrease 24279 248 118118 428
2 Jan 2026 – No Change 19568 300 118119 665
3 Mar 2026 – 50+bps Decrease 8261 78 121986 744
4 Mar 2026 – 25bps Decrease 18255 291 121986 953
5 Mar 2026 – No Change 18708 511 121984 1367
6 Apr 2026 – 50+bps Decrease 9743 108 127464 574
7 Apr 2026 – 25bps Decrease 12258 140 127464 539
8 Apr 2026 – No Change 20882 593 127464 1019
9 Jun 2026 – 50+bps Decrease 5701 14 111407 198
10 Jun 2026 – 25bps Decrease 12111 73 111395 227
11 Jun 2026 – No Change 18434 568 111390 668

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, 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 4 events, into one timeline, apply the 20%-of-available-bankroll sizing and concurrency rules, and size/settle each trade in the order it actually would have happened — including releasing capital as each event resolves on its own date, not all at once.
  3. Summary metrics, including funnel frequency.
In [10]:
ENTRY_GAP_THRESHOLD = 0.05
ENTRY_DWELL_MINUTES = 2
PROFIT_EXIT_BUFFER = 0.00
POSITION_SIZE_PCT = 0.20
STARTING_BANKROLL = 1000.0
KALSHI_TAKER_RATE = fees.KALSHI_TAKER_RATE
STOP_LOSS = None


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


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


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

print("Aligned price series built for all buckets:")
for key, b in BUCKETS.items():
    print(f"  {b['label']}: {len(b['aligned']):,} aligned ticks")
Aligned price series built for all buckets:
  Jan 2026 – 50+bps Decrease: 12,049 aligned ticks
  Jan 2026 – 25bps Decrease: 22,940 aligned ticks
  Jan 2026 – No Change: 18,229 aligned ticks
  Mar 2026 – 50+bps Decrease: 5,414 aligned ticks
  Mar 2026 – 25bps Decrease: 15,399 aligned ticks
  Mar 2026 – No Change: 15,176 aligned ticks
  Apr 2026 – 50+bps Decrease: 5,527 aligned ticks
  Apr 2026 – 25bps Decrease: 8,195 aligned ticks
  Apr 2026 – No Change: 16,793 aligned ticks
  Jun 2026 – 50+bps Decrease: 1,275 aligned ticks
  Jun 2026 – 25bps Decrease: 7,641 aligned ticks
  Jun 2026 – No Change: 13,488 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")
Jan 2026 – 50+bps Decrease: 6 candidate entry episodes
Jan 2026 – 25bps Decrease: 148 candidate entry episodes
Jan 2026 – No Change: 138 candidate entry episodes
Mar 2026 – 50+bps Decrease: 9 candidate entry episodes
Mar 2026 – 25bps Decrease: 93 candidate entry episodes
Mar 2026 – No Change: 77 candidate entry episodes
Apr 2026 – 50+bps Decrease: 22 candidate entry episodes
Apr 2026 – 25bps Decrease: 31 candidate entry episodes
Apr 2026 – No Change: 127 candidate entry episodes
Jun 2026 – 50+bps Decrease: 2 candidate entry episodes
Jun 2026 – 25bps Decrease: 50 candidate entry episodes
Jun 2026 – No Change: 68 candidate entry episodes
In [13]:
def kalshi_confirmed(kalshi_trades, start_ts, end_ts):
    """Simulate order confirmation from realized historical volume (see strategy note above)."""
    window = kalshi_trades.loc[start_ts:end_ts]
    return len(window) > 0


for key, b in BUCKETS.items():
    confirmed_flags = [
        kalshi_confirmed(b["kalshi_trades"], row["start"], row["end"])
        for _, row in b["entry_episodes"].iterrows()
    ]
    b["entry_episodes"]["kalshi_confirmed"] = confirmed_flags
    n_confirmed = sum(confirmed_flags)
    print(f"{b['label']}: {n_confirmed:,} / {len(confirmed_flags):,} episodes had a Kalshi fill (confirmed)")
Jan 2026 – 50+bps Decrease: 0 / 6 episodes had a Kalshi fill (confirmed)
Jan 2026 – 25bps Decrease: 20 / 148 episodes had a Kalshi fill (confirmed)
Jan 2026 – No Change: 9 / 138 episodes had a Kalshi fill (confirmed)
Mar 2026 – 50+bps Decrease: 0 / 9 episodes had a Kalshi fill (confirmed)
Mar 2026 – 25bps Decrease: 7 / 93 episodes had a Kalshi fill (confirmed)
Mar 2026 – No Change: 3 / 77 episodes had a Kalshi fill (confirmed)
Apr 2026 – 50+bps Decrease: 1 / 22 episodes had a Kalshi fill (confirmed)
Apr 2026 – 25bps Decrease: 5 / 31 episodes had a Kalshi fill (confirmed)
Apr 2026 – No Change: 11 / 127 episodes had a Kalshi fill (confirmed)
Jun 2026 – 50+bps Decrease: 0 / 2 episodes had a Kalshi fill (confirmed)
Jun 2026 – 25bps Decrease: 2 / 50 episodes had a Kalshi fill (confirmed)
Jun 2026 – No Change: 13 / 68 episodes had a Kalshi fill (confirmed)
In [14]:
def simulate_exit(aligned, entry_ts, direction, profit_exit_buffer, bucket_close_ts, polymarket_fee_rate):
    """
    Forward-scan from entry_ts. Exit as soon as selling both legs right now would
    net more, per contract after fees, than the guaranteed $1.00 hold-to-close
    payout (which pays no exit fee) -- by at least profit_exit_buffer. Otherwise
    hold to close.
    """
    forward = aligned.loc[entry_ts:bucket_close_ts]
    if len(forward) < 2:
        return None

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

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

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

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

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

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


for key, b in BUCKETS.items():
    bucket_close_ts = b["aligned"].index.max()
    exit_results = []
    for _, row in b["entry_episodes"].iterrows():
        if not row["kalshi_confirmed"]:
            exit_results.append(None)
            continue
        exit_results.append(
            simulate_exit(
                b["aligned"], row["end"], row["direction"],
                PROFIT_EXIT_BUFFER, bucket_close_ts, b["polymarket_fee_rate"],
            )
        )
    b["entry_episodes"]["exit_result"] = exit_results
    n_profit = sum(1 for r in exit_results if r and r["exit_type"] == "profit_target")
    n_held = sum(1 for r in exit_results if r and r["exit_type"] == "held_to_close")
    print(f"{b['label']}: {n_profit:,} profit-target exits, {n_held:,} held to close")
Jan 2026 – 50+bps Decrease: 0 profit-target exits, 0 held to close
Jan 2026 – 25bps Decrease: 20 profit-target exits, 0 held to close
Jan 2026 – No Change: 9 profit-target exits, 0 held to close
Mar 2026 – 50+bps Decrease: 0 profit-target exits, 0 held to close
Mar 2026 – 25bps Decrease: 3 profit-target exits, 4 held to close
Mar 2026 – No Change: 3 profit-target exits, 0 held to close
Apr 2026 – 50+bps Decrease: 0 profit-target exits, 1 held to close
Apr 2026 – 25bps Decrease: 5 profit-target exits, 0 held to close
Apr 2026 – No Change: 11 profit-target exits, 0 held to close
Jun 2026 – 50+bps Decrease: 0 profit-target exits, 0 held to close
Jun 2026 – 25bps Decrease: 2 profit-target exits, 0 held to close
Jun 2026 – No Change: 13 profit-target exits, 0 held to close
In [15]:
def build_candidate_trade(bucket_key, b, row):
    """Assemble the price/fee economics of one candidate trade, per contract (size TBD in the bankroll pass)."""
    direction = row["direction"]
    entry_ts = row["end"]
    entry_kalshi_price = kalshi_price_for_side(direction, b["aligned"].loc[entry_ts, "kalshi_yes_ask"])
    entry_poly_price = polymarket_price_for_side(direction, b["aligned"].loc[entry_ts, "polymarket_price"])

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

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

    return trade


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

all_candidates.sort(key=lambda t: t["entry_ts"])
print(f"Total confirmed candidate trades across all buckets: {len(all_candidates):,}")
if n_skipped_no_forward_data:
    print(f"(Confirmed but excluded for insufficient forward data before bucket close: {n_skipped_no_forward_data:,})")
Total confirmed candidate trades across all buckets: 71
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: 71
Trades skipped for lack of available bankroll: 0
Final bankroll: $2,212.40 (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 771
1 Kalshi Confirmed 71
2 Confirmation Rate 9.2%
3 Total Trades 71
4 Win Rate 91.5%
5 Total PNL $1,212.40
6 Total Fees Paid $365.82
7 Starting Bankroll $1,000.00
8 Final Bankroll $2,212.40
9 Overall ROI 121.2%
10 Avg. Holding Duration 183.2 hours

By Event

trades win_rate total_pnl total_fees avg_holding_hours
event_key
boc_apr_2026 17 0.82 288.07 156.23 165.37
boc_jan_2026 29 0.97 614.99 34.45 201.36
boc_jun_2026 15 0.87 229.58 161.38 41.17
boc_mar_2026 10 1.00 79.75 13.76 373.94

By Bucket

trades win_rate total_pnl kalshi_fees poly_fees total_fees avg_holding_hours
bucket_label
Apr 2026 – 25bps Decrease 5 0.80 104.10 17.66 24.11 41.77 326.53
Apr 2026 – 50+bps Decrease 1 1.00 26.82 1.64 0.04 1.68 391.92
Apr 2026 – No Change 11 0.82 157.15 45.47 67.31 112.78 71.52
Jan 2026 – 25bps Decrease 20 0.95 469.99 24.01 0.00 24.01 245.45
Jan 2026 – No Change 9 1.00 145.01 10.44 0.00 10.44 103.40
Jun 2026 – 25bps Decrease 2 0.50 17.10 6.76 7.31 14.07 70.18
Jun 2026 – No Change 13 0.92 212.48 62.74 84.56 147.30 36.70
Mar 2026 – 25bps Decrease 7 1.00 42.66 6.52 0.00 6.52 400.77
Mar 2026 – No Change 3 1.00 37.09 7.24 0.00 7.24 311.34

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 four events in play, capital recycles across January, 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 Canada 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 $2,212.40

No description has been provided for this image
Peak bankroll: $2,212.40 | Trough bankroll: $183.99 | Max drawdown from peak: $2,028.41

Summary

Strongest result of any backtest in this project: +121.2% ROI ($1,212.40 net on the $1,000 starting bankroll), 71 trades, 91.5% win rate. All four events landed net positive individually — January $614.99 (97% win rate, the standout single event), April $288.07 (82%), June $229.58 (87%), March $79.75 (100%, but only 10 trades — the smallest sample of the four).

Funnel: 771 signals detected across the 12 buckets, 71 Kalshi-confirmed (9.2% confirmation rate — closer to the Fed range of 6.6%–19.2% than to Bank of England's unusually high 23.8%). Every confirmed signal executed — 0 skipped for insufficient bankroll, and unlike notebook 07, the "confirmed but insufficient forward data before bucket close" edge case wasn't hit at all this time (a rare condition, not a recurring one).

Fees consumed the smallest share of gross edge of any notebook yet. Gross PNL was $1,578.22 ($1,212.40 net + $365.82 fees) — fees took 23.2% of the edge, versus 36.0% in notebook 07 (Bank of England) and 45.8% in notebook 06 (Fed). January and March both had `feesEnabled: False` on Polymarket's side (fee = $0, same pattern seen in Fed's January and March, and BoE's March) — January's 20 trades in the "25bps Decrease" bucket alone contributed $469.99 of the total PNL at just $24.01 in fees.

Bankroll swung real capital but never approached the kind of near-wipeout seen in notebook 06's Fed backtest. Trough was $183.99 against a peak of $2,212.40 (which also happens to be the final value — the strategy was still compounding upward when the backtest window ended).

No new bugs or wording variants were found for Bank of Canada — its Kalshi labels ("Cut >25bps", "Cut 25bps", "Maintains rate") already matched the patterns market_matching.py was built for, verified directly against live data for all four months before this notebook was built. Bucket count is also the most uniform seen so far: Polymarket lumps the entire hike side into one contract with no magnitude specified, in every single month (not just some, like Fed, and not resolved for later months, like BoE's June) — so every event contributes exactly 3 buckets, 12 total across the 4 events, with no lumping variance to account for.