Notebook 09: ECB Decision Backtest¶
This notebook takes the same hedge-arbitrage strategy backtested in notebooks 04, 06, 07, and 08 and points it at five European Central Bank rate decisions: December 2025, February, March, April, and June 2026. Same engine, same variables, same entry/exit rules — only the events under test change.
Multi-event handling follows notebooks 06/07/08's pattern: all matched outcome buckets across the five meetings are folded into one combined BUCKETS dict, keyed and labeled by event (e.g. ecb_dec_2025__cut_small), and run through a single backtest pass with one shared bankroll.
Bucket count varies by event, same pattern as Bank of England. Polymarket lumps all rate-hike outcomes into one contract for December, February, March, and April (no 1:1 Kalshi counterpart, so the hike side is skipped for those four), while June splits hikes the same way Kalshi does, contributing the full five-outcome set. That gives 17 matched buckets across 5 events (3 + 3 + 3 + 3 + 5).
No new parser bugs this time — the two fixes made for Bank of England already cover ECB's wording. Kalshi's ECB labels use the exact same variants that prompted those fixes: December's and February's listings spell out "Cut 1-25 basis points" (not "bps"), and every month uses "Cut more than 25bp(s)" for the big-magnitude bucket. Both were verified live against market_matching.py before building this notebook and matched correctly on the first try — a good sign the fix generalizes rather than being BoE-specific.
Candlestick history is available for all five events, including June (the most recently resolved) — no repeat of the archival-lag gap that excluded Fed's and BoE's July decisions. Fee rate is not uniform across these five events, same pattern as before: December, February, and March have feesEnabled: False (fee = $0), April and June have feesEnabled: True at the standard 10% rate. Handled per-bucket exactly as in notebooks 06/07/08.
Strategy Definition¶
Monitor: the backtest logs, but does not gate on, three contextual signals already investigated in notebook 03 — scheduled/unscheduled news events, realized price volatility, and unusually large single trades. These are recorded alongside every trade for later analysis, not used as an entry filter in this version.
Entry: a position opens when the gross price gap between Kalshi and Polymarket for the same outcome reaches ≥ $0.05, sustained for at least 2 consecutive 1-minute ticks — the finest granularity the historical price data actually supports.
Execution is sequential, never simultaneous, and always Kalshi first. Direction is set by the sign of the gap (Kalshi yes_ask minus Polymarket price):
- If Kalshi's Yes is the more expensive side (gap positive): buy Kalshi No, wait for confirmation, then buy Polymarket Yes.
- If Polymarket's Yes is the more expensive side (gap negative): buy Kalshi Yes, wait for confirmation, then buy Polymarket No.
Since this is a backtest rather than a live order book, "confirmation" is simulated from realized historical volume: if Kalshi shows zero realized trade volume during the qualifying entry window, the Kalshi leg is treated as not confirmed and the trade is skipped entirely — the Polymarket leg is never taken without a confirmed Kalshi fill first. Identical logic to notebooks 04/06/07/08, unchanged.
Sizing: 20% of available bankroll per trade, split into a matched equal contract count on both legs — not an equal dollar split. Contract count is set by the smaller of what each leg's 10%-of-bankroll sub-budget affords.
Concurrency: multiple positions can be open simultaneously, bankroll capacity permitting — including positions from different events. A new signal is skipped for lack of capital only if the bankroll currently deployed in open positions leaves less than the required 20% available. When a position closes, its capital (net of PNL) returns to the available bankroll immediately, freeing it for the next opportunity, regardless of which event that next opportunity belongs to.
Exit: once a position is open, its value is checked on every tick against a simple, guaranteed benchmark: holding to close pays exactly $1.00/contract (one side always resolves to $1, the other to $0), and costs no exit fee at all. The position exits early only if selling both legs right now would net more than that guaranteed payout, after fees, by at least PROFIT_EXIT_BUFFER — otherwise it holds. Identical profit-based exit rule as notebooks 04/06/07/08, unchanged.
No stop loss is used in this version.
Fees: Kalshi's confirmed formula and Polymarket's documented (not on-chain-contract) formula, both as currently implemented in src/fees.py. Polymarket's fee rate is not uniform across these five events — December's, February's, and March's markets have feesEnabled: False (fee = $0), while April's and June's have feesEnabled: True at the standard 10% rate. Each bucket carries its own live-verified polymarket_fee_rate, applied per-trade rather than as one global constant.
Variables (for later sensitivity testing / heatmaps)¶
Every threshold below is a named constant, chosen so each can be swept independently in a future pass:
ENTRY_GAP_THRESHOLD = 0.05— minimum gross price gap to open a 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 17 matched outcome buckets across 5 ECB decisions (December 2025, February, March, April, June 2026):
- Kalshi 1-minute candlesticks (price series)
- Kalshi individual trade records (for the confirmation-simulation logic)
- Polymarket price history (
prices-history, fidelity=1) - Polymarket individual trade records
Everything is cached to data/raw/ under an nb09_ prefix — distinct from every other notebook's cache, since these are entirely new buckets and date ranges. Re-running this notebook won't re-hit the APIs unless the cache is deleted.
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_6
OUTCOME_LABELS = {
"maintain_None": "No Change",
"cut_small": "25bps Decrease",
"cut_big": "50+bps Decrease",
"hike_small": "25bps Increase",
"hike_big": "50+bps Increase",
}
EVENT_SHORT_LABELS = {
"ecb_dec_2025": "Dec 2025",
"ecb_feb_2026": "Feb 2026",
"ecb_mar_2026": "Mar 2026",
"ecb_apr_2026": "Apr 2026",
"ecb_jun_2026": "Jun 2026",
}
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"nb09_{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/08, 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 5 events, into one timeline, apply the 20%-of-available-bankroll sizing and concurrency rules, and size/settle each trade in the order it actually would have happened — including releasing capital as each event resolves on its own date, not all at once.
- 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"),
)
if len(episodes) == 0:
# A bucket whose gap never once crossed the threshold produces a
# zero-row groupby result -- pandas doesn't reliably preserve the
# start/end columns' datetime dtype in that case (inconsistent even
# across buckets in the same run), so .dt below can raise on some
# empty buckets but not others. Short-circuit before that happens
# rather than depend on dtype inference over zero rows.
episodes["duration_minutes"] = pd.Series(dtype="float64")
episodes["direction"] = pd.Series(dtype="int64")
return episodes.reset_index(drop=True)
episodes["duration_minutes"] = (episodes["end"] - episodes["start"]).dt.total_seconds() / 60
episodes = episodes[episodes["duration_minutes"] >= dwell_minutes - 1].copy()
episodes["direction"] = np.sign(episodes["entry_gap"]).astype(int)
episodes = episodes[episodes["direction"] != 0]
return episodes.reset_index(drop=True)
for key, b in BUCKETS.items():
b["entry_episodes"] = find_entry_episodes(b["aligned"], ENTRY_GAP_THRESHOLD, ENTRY_DWELL_MINUTES)
print(f"{b['label']}: {len(b['entry_episodes']):,} candidate entry episodes")
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 five events in play, capital recycles across December, February, March, April, and June positions on one shared bankroll, released as each position's own bucket resolves rather than waiting for every event to finish.
display(Markdown(f"#### Bankroll Over Time — Starting ${STARTING_BANKROLL:,.0f}, Ending ${available_bankroll:,.2f}"))
fig, ax = plt.subplots(figsize=(16, 6))
ax.plot(bankroll_df["ts"], bankroll_df["bankroll"], color="steelblue", linewidth=1.6, drawstyle="steps-post", zorder=3)
ax.axhline(STARTING_BANKROLL, color="gray", linestyle="--", linewidth=1, zorder=1, label=f"Starting bankroll (${STARTING_BANKROLL:,.0f})")
ax.fill_between(
bankroll_df["ts"], bankroll_df["bankroll"], STARTING_BANKROLL,
where=(bankroll_df["bankroll"] >= STARTING_BANKROLL), color="seagreen", alpha=0.15, step="post", zorder=0,
)
ax.fill_between(
bankroll_df["ts"], bankroll_df["bankroll"], STARTING_BANKROLL,
where=(bankroll_df["bankroll"] < STARTING_BANKROLL), color="crimson", alpha=0.15, step="post", zorder=0,
)
event_resolution_dates = {}
for key, b in BUCKETS.items():
event_resolution_dates.setdefault(b["event_key"], b["kalshi_close"])
for event_key, ts in event_resolution_dates.items():
ax.axvline(ts, color="gray", linestyle=":", linewidth=0.8, alpha=0.5, zorder=0)
ax.annotate(
EVENT_SHORT_LABELS[event_key], xy=(ts, ax.get_ylim()[1]), xytext=(3, -12),
textcoords="offset points", fontsize=8, color="dimgray", rotation=90, va="top",
)
final_ts = bankroll_df["ts"].iloc[-1]
final_value = bankroll_df["bankroll"].iloc[-1]
ax.scatter([final_ts], [final_value], color="steelblue", s=40, zorder=4, edgecolors="black", linewidths=0.6)
ax.annotate(
f"${final_value:,.2f}",
xy=(final_ts, final_value), xytext=(8, 8), textcoords="offset points", fontsize=9, fontweight="bold",
)
ax.set_xlabel("date")
ax.set_ylabel("bankroll ($)")
ax.set_title("Rolling Bankroll — ECB Decision Backtest", fontsize=12)
ax.legend(loc="upper left", fontsize=9)
plt.tight_layout()
plt.show()
peak = bankroll_df["bankroll"].max()
trough = bankroll_df["bankroll"].min()
print(
f"Peak bankroll: ${peak:,.2f} | Trough bankroll: ${trough:,.2f} | "
f"Max drawdown from peak: ${peak - trough:,.2f}"
)
Summary¶
Second-strongest result of any backtest in this project: +92.5% ROI ($924.57 net on the $1,000 starting bankroll), 77 trades, 97.4% win rate. All five events landed net positive individually — June was the largest contributor ($478.70 on 32 trades), followed by April ($179.54), February ($166.63), March ($94.78), and December ($4.93, on a single trade — the smallest sample of the five, this event's window was the shortest of all events tested across notebooks 06–09).
Funnel: 796 signals detected across the 17 buckets, 77 Kalshi-confirmed (9.7% confirmation rate — close to notebook 08's Bank of Canada rate of 9.2%, well below Bank of England's unusually high 23.8%). Every confirmed signal executed — 0 skipped for insufficient bankroll, and the "confirmed but insufficient forward data" edge case from notebook 07 wasn't hit here either.
Fees consumed a middling share of gross edge, between the other two central-bank notebooks: gross PNL was $1,336.39 ($924.57 net + $411.82 fees) — fees took 30.8% of the edge, versus 23.2% for Bank of Canada (notebook 08) and 36.0% for Bank of England (notebook 07). December, February, and March all had `feesEnabled: False` on Polymarket's side (fee = $0) — February's 7 trades in particular delivered $166.63 of PNL against just $3.74 in fees, the best fee-to-edge ratio of any event bucket across this notebook.
Bankroll swung real capital — trough $245.06 against a peak (and final value) of $1,924.57, a max drawdown of $1,679.52 — comfortably above notebook 06's Fed near-wipeout, in the same range as notebooks 07 and 08's healthier profiles.
Two implementation notes, both handled without changing the strategy itself:
- A genuinely new pandas edge case surfaced in
find_entry_episodes(): a bucket whose gap never once crossed the entry threshold produces a zero-rowgroupby().agg()result, and pandas doesn't reliably preserve thestart/endcolumns' datetime dtype across every such empty case —.dt.total_seconds()raisedAttributeErroron some empty buckets but not others in the same run. Fixed by short-circuiting on an empty result rather than depending on dtype inference over zero rows. - A harmless
RuntimeWarning: divide by zeroappeared in the bankroll pass — one leg's entry price hit exactly $0.00 (plausible at an extreme, near-certain outcome), and numpy's float division returnsinfrather than raising.min(inf, other_leg)still correctly falls back to the finite constraint, so contract sizing was unaffected — confirmed by sane, internally consistent totals throughout.
No new market_matching.py bugs — verified before building, using the exact same wording variants ("more than 25bp(s)," spelled-out "basis points") that prompted the two fixes made for Bank of England. Both generalized cleanly to ECB's identical phrasing on the first try.