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 positionENTRY_DWELL_MINUTES = 2— minutes the gap must sustain before entryPROFIT_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 earlyPOSITION_SIZE_PCT = 0.20— share of available bankroll risked per tradeSTARTING_BANKROLL = 1000— dollarsKALSHI_TAKER_RATE = 0.07— fee assumption, fromfees.py, uniform across all bucketsPOLYMARKET_TAKER_RATE = 0.10— Polymarket's rate where fees are enabled; applied per-bucket (0% where that bucket'sfeesEnabledisFalse), not as one flat constant — see Fees aboveSTOP_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.
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)
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",
}
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."))
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
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")
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")
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")
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")
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))
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:
- 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.
- 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.
- Summary metrics, including funnel frequency.
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),
)
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")
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")
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)")
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")
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:,})")
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})")
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)
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.
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}"
)
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.