Notebook 06: Multi-Event Arbitrage Backtest

This notebook takes the exact hedge-arbitrage strategy backtested in notebook 04 — buying opposite outcomes on Kalshi and Polymarket whenever their combined cost drops meaningfully below $1.00 — and points it at three more historical Fed-decision events instead of just January 2026: March, April, and June 2026.

How the multiple events are handled: rather than running three separate notebooks, every outcome bucket from all three events is folded into a single combined BUCKETS dict, keyed and labeled by event (e.g. fed_mar_2026__cut_small, displayed as "Mar 2026 – 25bps Decrease"), and run through one backtest pass. The engine itself doesn't distinguish "a bucket from event A" from "a bucket from event B" — it never has, even in notebook 04 — so this works without changing any of the actual strategy logic below.

Bucket discovery is also handled differently here than in notebook 04. With a single known event, notebook 04 hand-typed each bucket's Kalshi ticker and Polymarket slug directly. With three events, that's replaced by market_matching.match_outcomes() — an existing utility (previously unused by any notebook) that pulls each event's full sub-market list from both platforms and pairs them up by parsing direction/magnitude from each side's title text, explicitly reporting anything it can't confidently match rather than guessing. That matters here: Polymarket lumps all rate-hike outcomes into one contract for March and April (no matching 1:1 Kalshi pair, so the hike side is skipped entirely for those two events), while June splits hikes the same way Kalshi does, so June contributes the full five-outcome set. The result is 11 matched buckets across 3 events, not 12 — a real difference in market structure per event, not a bug, and something notebook 04's hand-typed January buckets never had to account for since only one event was ever in play.

Fees also aren't uniform across these events. Polymarket's feesEnabled flag is False for March's markets (fee = $0, same as January) but True for April's and June's, at the existing 10% rate. Each bucket below carries its own live-verified polymarket_fee_rate, applied per-trade — notebook 04 could get away with one flat constant because it only ever covered one event; this notebook can't.

Bankroll recycling across events: notebook 04's bankroll engine already tracks each position's own entry/exit timing independently, with zero awareness of which bucket it belongs to — so positions from March, April, and June naturally open and release capital on their own real calendar dates, all on one shared bankroll, with no changes needed to make that work correctly.

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 (e.g., do trades taken during elevated volatility perform differently?), 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 notebook 04, 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 notebook 04, 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. Unlike notebook 04's single event, Polymarket's fee rate is genuinely not uniform across these three events — March's markets have feesEnabled: False (fee = $0, same as January), while April's and June's have feesEnabled: True at the same 10% rate as before. 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, explicitly labeling why a signal didn't convert
  • Holding duration per closed position
  • ROI per position and in aggregate
  • Bankroll movement over time (a running equity curve)
  • Fee cost per position and in aggregate
  • PNL per position and in aggregate

Data Ingestion

This backtest needs, for each of the 11 matched outcome buckets across 3 Fed-decision events (March, April, June 2026 — see Strategy Definition above for why bucket count differs from notebook 04's single-event, four-bucket setup):

  • 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

Each bucket's data spans its own full market lifetime. Everything is cached to data/raw/ under an nb06_ prefix as it's fetched — distinct from notebook 04/05's nb04_ 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_3

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 = {
    "fed_mar_2026": "Mar 2026",
    "fed_apr_2026": "Apr 2026",
    "fed_jun_2026": "Jun 2026",
}
In [3]:
BUCKETS = {}
all_skipped = []

for event_key in ACTIVE_EVENT_KEYS:
    ev = events_config.EVENTS[event_key]
    kalshi_markets = kalshi_client.get_historical_markets(ev["kalshi_series_ticker"])
    polymarket_event = polymarket_client.get_event_by_slug(ev["polymarket_slug"])
    poly_markets = polymarket_event["markets"] if polymarket_event else []

    matched, skipped = market_matching.match_outcomes(kalshi_markets, poly_markets)
    for s in skipped:
        all_skipped.append({"event": ev["label"], **s})

    for m in matched:
        outcome_label = m["outcome_label"]
        bucket_key = f"{event_key}__{outcome_label}"
        km = m["kalshi_market"]
        pm = m["polymarket_market"]
        BUCKETS[bucket_key] = {
            "event_key": event_key,
            "label": f"{EVENT_SHORT_LABELS[event_key]} – {OUTCOME_LABELS.get(outcome_label, outcome_label)}",
            "kalshi_ticker": m["kalshi_ticker"],
            "polymarket_slug": pm.get("slug"),
            "kalshi_open": pd.to_datetime(km["open_time"], utc=True),
            "kalshi_close": pd.to_datetime(km["close_time"], utc=True),
            "polymarket_token": m["polymarket_token_id_yes"],
            "polymarket_condition_id": pm["conditionId"],
            "polymarket_open": pd.to_datetime(pm["startDate"], utc=True),
            "polymarket_close": pd.to_datetime(pm["closedTime"], utc=True),
            "polymarket_fee_rate": fees.POLYMARKET_TAKER_RATE if pm.get("feesEnabled") else 0.0,
        }

confirm_table = pd.DataFrame(
    [
        {
            "Bucket": b["label"],
            "Kalshi Ticker": b["kalshi_ticker"],
            "Kalshi Window": f"{b['kalshi_open'].date()} to {b['kalshi_close'].date()}",
            "Polymarket Slug": b["polymarket_slug"],
            "Polymarket Window": f"{b['polymarket_open'].date()} to {b['polymarket_close'].date()}",
            "Poly Fee Rate": f"{b['polymarket_fee_rate']:.0%}",
        }
        for b in BUCKETS.values()
    ]
)
display(Markdown(f"#### Resolved Market Metadata, {len(BUCKETS)} Matched Buckets Across {len(ACTIVE_EVENT_KEYS)} Events"))
display(confirm_table)

if all_skipped:
    skipped_table = pd.DataFrame(all_skipped)[["event", "side", "identifier", "reason"]]
    display(Markdown(f"#### Outcomes Skipped by Auto-Matching ({len(all_skipped)})"))
    display(skipped_table)
else:
    display(Markdown("No outcomes were skipped by auto-matching."))

Resolved Market Metadata, 11 Matched Buckets Across 3 Events

Bucket Kalshi Ticker Kalshi Window Polymarket Slug Polymarket Window Poly Fee Rate
0 Mar 2026 – 50+bps Decrease KXFEDDECISION-26MAR-C26 2025-09-29 to 2026-03-18 will-the-fed-decrease-interest-rates-by-50-bps... 2025-10-29 to 2026-03-18 0%
1 Mar 2026 – 25bps Decrease KXFEDDECISION-26MAR-C25 2025-09-29 to 2026-03-18 will-the-fed-decrease-interest-rates-by-25-bps... 2025-10-29 to 2026-03-18 0%
2 Mar 2026 – No Change KXFEDDECISION-26MAR-H0 2025-09-29 to 2026-03-18 will-there-be-no-change-in-fed-interest-rates-... 2025-10-29 to 2026-03-18 0%
3 Apr 2026 – 50+bps Decrease KXFEDDECISION-26APR-C26 2025-09-29 to 2026-04-29 will-the-fed-decrease-interest-rates-by-50-bps... 2025-11-13 to 2026-04-29 10%
4 Apr 2026 – 25bps Decrease KXFEDDECISION-26APR-C25 2025-09-29 to 2026-04-29 will-the-fed-decrease-interest-rates-by-25-bps... 2025-11-13 to 2026-04-29 10%
5 Apr 2026 – No Change KXFEDDECISION-26APR-H0 2025-09-29 to 2026-04-29 will-there-be-no-change-in-fed-interest-rates-... 2025-11-13 to 2026-04-29 10%
6 Jun 2026 – 50+bps Decrease KXFEDDECISION-26JUN-C26 2025-09-29 to 2026-06-17 will-the-fed-decrease-interest-rates-by-50-bps... 2025-12-10 to 2026-06-17 10%
7 Jun 2026 – 25bps Decrease KXFEDDECISION-26JUN-C25 2025-09-29 to 2026-06-17 will-the-fed-decrease-interest-rates-by-25-bps... 2025-12-10 to 2026-06-17 10%
8 Jun 2026 – No Change KXFEDDECISION-26JUN-H0 2025-09-29 to 2026-06-17 will-there-be-no-change-in-fed-interest-rates-... 2025-12-10 to 2026-06-17 10%
9 Jun 2026 – 25bps Increase KXFEDDECISION-26JUN-H25 2025-09-29 to 2026-06-17 will-the-fed-increase-interest-rates-by-25-bps... 2025-12-10 to 2026-06-17 10%
10 Jun 2026 – 50+bps Increase KXFEDDECISION-26JUN-H26 2025-09-29 to 2026-06-17 will-the-fed-increase-interest-rates-by-50-bps... 2025-12-10 to 2026-06-17 10%

Outcomes Skipped by Auto-Matching (6)

event side identifier reason
0 Fed Decision - March 2026 polymarket will-the-fed-increase-interest-rates-by-25-bps... lumps multiple magnitude buckets together — no...
1 Fed Decision - March 2026 kalshi KXFEDDECISION-26MAR-H26 no Polymarket contract found for parsed outcom...
2 Fed Decision - March 2026 kalshi KXFEDDECISION-26MAR-H25 no Polymarket contract found for parsed outcom...
3 Fed Decision - April 2026 polymarket will-the-fed-increase-interest-rates-by-25-bps... lumps multiple magnitude buckets together — no...
4 Fed Decision - April 2026 kalshi KXFEDDECISION-26APR-H26 no Polymarket contract found for parsed outcom...
5 Fed Decision - April 2026 kalshi KXFEDDECISION-26APR-H25 no Polymarket contract found for parsed outcom...
In [4]:
def load_or_fetch(cache_name, fetch_fn):
    cache_path = RAW_DIR / f"nb06_{cache_name}.parquet"
    if cache_path.exists():
        return pd.read_parquet(cache_path)
    df = fetch_fn()
    df.to_parquet(cache_path)
    return df
In [5]:
for key, b in BUCKETS.items():
    b["kalshi_history"] = load_or_fetch(
        f"{key}__kalshi_history",
        lambda b=b: kalshi_client.get_candlesticks(
            b["kalshi_ticker"], b["kalshi_open"], b["kalshi_close"], period_interval=1
        ),
    )
    print(f"{b['label']}: Kalshi history {len(b['kalshi_history']):,} rows")
Mar 2026 – 50+bps Decrease: Kalshi history 21,370 rows
Mar 2026 – 25bps Decrease: Kalshi history 53,692 rows
Mar 2026 – No Change: Kalshi history 56,629 rows
Apr 2026 – 50+bps Decrease: Kalshi history 38,700 rows
Apr 2026 – 25bps Decrease: Kalshi history 68,950 rows
Apr 2026 – No Change: Kalshi history 76,462 rows
Jun 2026 – 50+bps Decrease: Kalshi history 36,611 rows
Jun 2026 – 25bps Decrease: Kalshi history 67,419 rows
Jun 2026 – No Change: Kalshi history 68,213 rows
Jun 2026 – 25bps Increase: Kalshi history 28,661 rows
Jun 2026 – 50+bps Increase: Kalshi history 7,393 rows
In [6]:
for key, b in BUCKETS.items():
    b["polymarket_history"] = load_or_fetch(
        f"{key}__polymarket_history",
        lambda b=b: polymarket_client.get_prices_history(
            b["polymarket_token"], b["polymarket_open"], b["polymarket_close"], fidelity=1
        ),
    )
    print(f"{b['label']}: Polymarket history {len(b['polymarket_history']):,} rows")
Mar 2026 – 50+bps Decrease: Polymarket history 201,608 rows
Mar 2026 – 25bps Decrease: Polymarket history 201,608 rows
Mar 2026 – No Change: Polymarket history 201,597 rows
Apr 2026 – 50+bps Decrease: Polymarket history 241,357 rows
Apr 2026 – 25bps Decrease: Polymarket history 241,350 rows
Apr 2026 – No Change: Polymarket history 241,350 rows
Jun 2026 – 50+bps Decrease: Polymarket history 271,519 rows
Jun 2026 – 25bps Decrease: Polymarket history 271,527 rows
Jun 2026 – No Change: Polymarket history 271,519 rows
Jun 2026 – 25bps Increase: Polymarket history 271,532 rows
Jun 2026 – 50+bps Increase: Polymarket history 271,532 rows
In [7]:
for key, b in BUCKETS.items():
    b["kalshi_trades"] = load_or_fetch(
        f"{key}__kalshi_trades",
        lambda b=b: kalshi_client.get_trades(b["kalshi_ticker"], b["kalshi_open"], b["kalshi_close"]),
    )
    print(f"{b['label']}: Kalshi trades {len(b['kalshi_trades']):,} rows")
Mar 2026 – 50+bps Decrease: Kalshi trades 3,508 rows
Mar 2026 – 25bps Decrease: Kalshi trades 18,463 rows
Mar 2026 – No Change: Kalshi trades 11,146 rows
Apr 2026 – 50+bps Decrease: Kalshi trades 2,126 rows
Apr 2026 – 25bps Decrease: Kalshi trades 9,052 rows
Apr 2026 – No Change: Kalshi trades 7,501 rows
Jun 2026 – 50+bps Decrease: Kalshi trades 3,902 rows
Jun 2026 – 25bps Decrease: Kalshi trades 14,310 rows
Jun 2026 – No Change: Kalshi trades 12,950 rows
Jun 2026 – 25bps Increase: Kalshi trades 5,464 rows
Jun 2026 – 50+bps Increase: Kalshi trades 1,093 rows
In [8]:
for key, b in BUCKETS.items():
    b["polymarket_trades"] = load_or_fetch(
        f"{key}__polymarket_trades",
        lambda b=b: polymarket_client.get_trades(
            b["polymarket_condition_id"], b["polymarket_open"], b["polymarket_close"]
        ),
    )
    print(f"{b['label']}: Polymarket trades {len(b['polymarket_trades']):,} rows")
Mar 2026 – 50+bps Decrease: Polymarket trades 83,334 rows
Mar 2026 – 25bps Decrease: Polymarket trades 62,308 rows
Mar 2026 – No Change: Polymarket trades 77,477 rows
Apr 2026 – 50+bps Decrease: Polymarket trades 54,668 rows
Apr 2026 – 25bps Decrease: Polymarket trades 40,508 rows
Apr 2026 – No Change: Polymarket trades 74,720 rows
Jun 2026 – 50+bps Decrease: Polymarket trades 62,422 rows
Jun 2026 – 25bps Decrease: Polymarket trades 43,891 rows
Jun 2026 – No Change: Polymarket trades 68,900 rows
Jun 2026 – 25bps Increase: Polymarket trades 44,217 rows
Jun 2026 – 50+bps Increase: Polymarket trades 28,538 rows
In [9]:
display(Markdown("#### Data Ingestion Complete"))

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

Data Ingestion Complete

Bucket Kalshi Candles Kalshi Trades Polymarket Ticks Polymarket Trades
0 Mar 2026 – 50+bps Decrease 21370 3508 201608 83334
1 Mar 2026 – 25bps Decrease 53692 18463 201608 62308
2 Mar 2026 – No Change 56629 11146 201597 77477
3 Apr 2026 – 50+bps Decrease 38700 2126 241357 54668
4 Apr 2026 – 25bps Decrease 68950 9052 241350 40508
5 Apr 2026 – No Change 76462 7501 241350 74720
6 Jun 2026 – 50+bps Decrease 36611 3902 271519 62422
7 Jun 2026 – 25bps Decrease 67419 14310 271527 43891
8 Jun 2026 – No Change 68213 12950 271519 68900
9 Jun 2026 – 25bps Increase 28661 5464 271532 44217
10 Jun 2026 – 50+bps Increase 7393 1093 271532 28538

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 notebook 04, unchanged except for reading each bucket's own polymarket_fee_rate instead of one global constant. The engine runs in three passes:

  1. Per-bucket entry/exit detection — find every qualifying entry episode and simulate its Kalshi-confirmation and exit (profit-based: exit early only if it beats the guaranteed hold-to-close payout), independent of bankroll.
  2. Chronological bankroll pass — merge every bucket's candidate trades, across all 3 events, into one timeline, apply the 20%-of-available-bankroll sizing and concurrency rules, and size/settle each trade in the order it actually would have happened — including releasing capital as each event resolves on its own date, not all at once.
  3. Funnel and summary metrics.
In [10]:
ENTRY_GAP_THRESHOLD = 0.05
ENTRY_DWELL_MINUTES = 2
PROFIT_EXIT_BUFFER = 0.00
POSITION_SIZE_PCT = 0.20
STARTING_BANKROLL = 1000.0
KALSHI_TAKER_RATE = fees.KALSHI_TAKER_RATE
STOP_LOSS = None


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


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


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

print("Aligned price series built for all buckets:")
for key, b in BUCKETS.items():
    print(f"  {b['label']}: {len(b['aligned']):,} aligned ticks")
Aligned price series built for all buckets:
  Mar 2026 – 50+bps Decrease: 19,519 aligned ticks
  Mar 2026 – 25bps Decrease: 51,831 aligned ticks
  Mar 2026 – No Change: 54,765 aligned ticks
  Apr 2026 – 50+bps Decrease: 36,232 aligned ticks
  Apr 2026 – 25bps Decrease: 66,458 aligned ticks
  Apr 2026 – No Change: 73,949 aligned ticks
  Jun 2026 – 50+bps Decrease: 33,164 aligned ticks
  Jun 2026 – 25bps Decrease: 62,888 aligned ticks
  Jun 2026 – No Change: 64,725 aligned ticks
  Jun 2026 – 25bps Increase: 25,208 aligned ticks
  Jun 2026 – 50+bps Increase: 3,994 aligned ticks
In [12]:
def find_entry_episodes(aligned, gap_threshold, dwell_minutes):
    """Run-length encode |gap| >= threshold, keep runs lasting >= dwell_minutes, tag each with a direction."""
    qualifies = aligned["gap"].abs() >= gap_threshold
    run_id = (qualifies != qualifies.shift()).cumsum()
    episodes = aligned[qualifies].groupby(run_id).agg(
        start=("gap", lambda s: s.index[0]),
        end=("gap", lambda s: s.index[-1]),
        entry_gap=("gap", "first"),
    )
    episodes["duration_minutes"] = (episodes["end"] - episodes["start"]).dt.total_seconds() / 60
    episodes = episodes[episodes["duration_minutes"] >= dwell_minutes - 1].copy()
    episodes["direction"] = np.sign(episodes["entry_gap"]).astype(int)
    episodes = episodes[episodes["direction"] != 0]
    return episodes.reset_index(drop=True)


for key, b in BUCKETS.items():
    b["entry_episodes"] = find_entry_episodes(b["aligned"], ENTRY_GAP_THRESHOLD, ENTRY_DWELL_MINUTES)
    print(f"{b['label']}: {len(b['entry_episodes']):,} candidate entry episodes")
Mar 2026 – 50+bps Decrease: 51 candidate entry episodes
Mar 2026 – 25bps Decrease: 124 candidate entry episodes
Mar 2026 – No Change: 171 candidate entry episodes
Apr 2026 – 50+bps Decrease: 299 candidate entry episodes
Apr 2026 – 25bps Decrease: 423 candidate entry episodes
Apr 2026 – No Change: 349 candidate entry episodes
Jun 2026 – 50+bps Decrease: 226 candidate entry episodes
Jun 2026 – 25bps Decrease: 645 candidate entry episodes
Jun 2026 – No Change: 314 candidate entry episodes
Jun 2026 – 25bps Increase: 69 candidate entry episodes
Jun 2026 – 50+bps Increase: 7 candidate entry episodes
In [13]:
def kalshi_confirmed(kalshi_trades, start_ts, end_ts):
    """Simulate order confirmation from realized historical volume (see strategy note above)."""
    window = kalshi_trades.loc[start_ts:end_ts]
    return len(window) > 0


for key, b in BUCKETS.items():
    confirmed_flags = [
        kalshi_confirmed(b["kalshi_trades"], row["start"], row["end"])
        for _, row in b["entry_episodes"].iterrows()
    ]
    b["entry_episodes"]["kalshi_confirmed"] = confirmed_flags
    n_confirmed = sum(confirmed_flags)
    print(f"{b['label']}: {n_confirmed:,} / {len(confirmed_flags):,} episodes had a Kalshi fill (confirmed)")
Mar 2026 – 50+bps Decrease: 1 / 51 episodes had a Kalshi fill (confirmed)
Mar 2026 – 25bps Decrease: 20 / 124 episodes had a Kalshi fill (confirmed)
Mar 2026 – No Change: 29 / 171 episodes had a Kalshi fill (confirmed)
Apr 2026 – 50+bps Decrease: 11 / 299 episodes had a Kalshi fill (confirmed)

Apr 2026 – 25bps Decrease: 71 / 423 episodes had a Kalshi fill (confirmed)
Apr 2026 – No Change: 26 / 349 episodes had a Kalshi fill (confirmed)
Jun 2026 – 50+bps Decrease: 61 / 226 episodes had a Kalshi fill (confirmed)
Jun 2026 – 25bps Decrease: 218 / 645 episodes had a Kalshi fill (confirmed)
Jun 2026 – No Change: 74 / 314 episodes had a Kalshi fill (confirmed)
Jun 2026 – 25bps Increase: 4 / 69 episodes had a Kalshi fill (confirmed)
Jun 2026 – 50+bps Increase: 0 / 7 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)

        # Per-contract fee, independent of final position size (both platforms'
        # taker fees scale linearly with contracts, so contracts=1 is sufficient
        # for this comparison).
        kalshi_fee, poly_fee = taker_fee_pair(current_kalshi_price, current_poly_price, 1, polymarket_fee_rate)
        early_exit_net = (current_kalshi_price + current_poly_price) - (kalshi_fee + poly_fee)

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

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


for key, b in BUCKETS.items():
    bucket_close_ts = b["aligned"].index.max()
    exit_results = []
    for _, row in b["entry_episodes"].iterrows():
        if not row["kalshi_confirmed"]:
            exit_results.append(None)
            continue
        exit_results.append(
            simulate_exit(
                b["aligned"], row["end"], row["direction"],
                PROFIT_EXIT_BUFFER, bucket_close_ts, b["polymarket_fee_rate"],
            )
        )
    b["entry_episodes"]["exit_result"] = exit_results
    n_profit = sum(1 for r in exit_results if r and r["exit_type"] == "profit_target")
    n_held = sum(1 for r in exit_results if r and r["exit_type"] == "held_to_close")
    print(f"{b['label']}: {n_profit:,} profit-target exits, {n_held:,} held to close")
Mar 2026 – 50+bps Decrease: 1 profit-target exits, 0 held to close
Mar 2026 – 25bps Decrease: 20 profit-target exits, 0 held to close
Mar 2026 – No Change: 29 profit-target exits, 0 held to close
Apr 2026 – 50+bps Decrease: 5 profit-target exits, 6 held to close
Apr 2026 – 25bps Decrease: 39 profit-target exits, 32 held to close
Apr 2026 – No Change: 26 profit-target exits, 0 held to close
Jun 2026 – 50+bps Decrease: 61 profit-target exits, 0 held to close
Jun 2026 – 25bps Decrease: 199 profit-target exits, 19 held to close
Jun 2026 – No Change: 74 profit-target exits, 0 held to close
Jun 2026 – 25bps Increase: 4 profit-target exits, 0 held to close
Jun 2026 – 50+bps Increase: 0 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 = []
for key, b in BUCKETS.items():
    confirmed_rows = b["entry_episodes"][b["entry_episodes"]["kalshi_confirmed"]]
    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):,}")
Total confirmed candidate trades across all buckets: 515
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:
    # Release any positions that closed at or before this candidate's entry.
    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: 236
Trades skipped for lack of available bankroll: 279
Final bankroll: $1,376.45 (started at $1,000.00)
In [17]:
funnel_rows = []
for key, b in BUCKETS.items():
    n_episodes = len(b["entry_episodes"])
    n_confirmed = int(b["entry_episodes"]["kalshi_confirmed"].sum())
    n_not_confirmed = n_episodes - n_confirmed
    funnel_rows.append({
        "Bucket": b["label"],
        "Signals Detected": n_episodes,
        "Kalshi Confirmed": n_confirmed,
        "Kalshi Not Confirmed (no realized volume)": n_not_confirmed,
    })

funnel_table = pd.DataFrame(funnel_rows)
n_skipped_bankroll = len(funnel_skipped)
n_executed = len(trades_df)

display(Markdown("#### Funnel: Why a Signal Did or Didn't Become a Trade"))
display(funnel_table)
print(
    f"\nOf {funnel_table['Kalshi Confirmed'].sum():,} Kalshi-confirmed signals across all buckets:\n"
    f"  Executed (bankroll available): {n_executed:,}\n"
    f"  Skipped (insufficient available bankroll): {n_skipped_bankroll:,}"
)

Funnel: Why a Signal Did or Didn't Become a Trade

Bucket Signals Detected Kalshi Confirmed Kalshi Not Confirmed (no realized volume)
0 Mar 2026 – 50+bps Decrease 51 1 50
1 Mar 2026 – 25bps Decrease 124 20 104
2 Mar 2026 – No Change 171 29 142
3 Apr 2026 – 50+bps Decrease 299 11 288
4 Apr 2026 – 25bps Decrease 423 71 352
5 Apr 2026 – No Change 349 26 323
6 Jun 2026 – 50+bps Decrease 226 61 165
7 Jun 2026 – 25bps Decrease 645 218 427
8 Jun 2026 – No Change 314 74 240
9 Jun 2026 – 25bps Increase 69 4 65
10 Jun 2026 – 50+bps Increase 7 0 7
Of 515 Kalshi-confirmed signals across all buckets:
  Executed (bankroll available): 236
  Skipped (insufficient available bankroll): 279
In [18]:
display(Markdown("#### Aggregate Backtest Metrics"))

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": [
            "Total Trades",
            "Win Rate",
            "Total PNL",
            "Total Fees Paid",
            "Starting Bankroll",
            "Final Bankroll",
            "Overall ROI",
            "Avg. Holding Duration",
        ],
        "Value": [
            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 Total Trades 236
1 Win Rate 99.2%
2 Total PNL $376.45
3 Total Fees Paid $317.80
4 Starting Bankroll $1,000.00
5 Final Bankroll $1,376.45
6 Overall ROI 37.6%
7 Avg. Holding Duration 786.4 hours

By Event

trades win_rate total_pnl total_fees avg_holding_hours
event_key
fed_apr_2026 73 0.97 114.86 149.93 759.92
fed_jun_2026 116 1.00 139.06 109.04 1036.33
fed_mar_2026 47 1.00 122.53 58.83 210.56

By Bucket

trades win_rate total_pnl kalshi_fees poly_fees total_fees avg_holding_hours
bucket_label
Apr 2026 – 25bps Decrease 43 0.98 47.77 31.28 41.93 73.21 754.54
Apr 2026 – 50+bps Decrease 11 0.91 27.60 6.56 9.43 15.99 1367.47
Apr 2026 – No Change 19 1.00 39.49 25.25 35.49 60.74 420.36
Jun 2026 – 25bps Decrease 69 1.00 85.96 31.69 41.89 73.58 1212.41
Jun 2026 – 25bps Increase 2 1.00 7.35 0.86 1.08 1.94 27.67
Jun 2026 – 50+bps Decrease 22 1.00 14.88 3.37 3.54 6.91 1497.82
Jun 2026 – No Change 23 1.00 30.88 10.86 15.74 26.60 154.38
Mar 2026 – 25bps Decrease 18 1.00 43.53 15.84 0.00 15.84 183.55
Mar 2026 – 50+bps Decrease 1 1.00 7.51 1.04 0.00 1.04 572.17
Mar 2026 – No Change 28 1.00 71.49 41.95 0.00 41.95 215.01

Simulation Findings

Net gain of $376.45 on the $1,000 starting bankroll — a +37.6% ROI — across 236 executed trades spanning all three events, 99.2% win rate (234 of 236 profitable).

This is a meaningfully stronger result than notebook 04's single-event +10.1% ROI, and it comes from the exact same strategy — same entry threshold, same profit-based exit, same 20%-of-bankroll sizing — simply given three separate opportunities to compound instead of one. Of the 515 Kalshi-confirmed signals across all 11 buckets, 236 (45.8%) actually executed; the remaining 279 were skipped for insufficient available bankroll, not because the signal itself was bad — see Fee Impact and the funnel breakdown below.

A few things worth flagging:

  • June contributed the most trades (116) and the strongest single-event PNL ($139.06) — it's also the only event where Polymarket didn't lump the hike outcomes together, so it's the only one of the three where all 5 outcome buckets (not 3) could even generate signals.
  • **The bankroll swing was severe: trough of $3.87** (0.4% of starting capital) before recovering to a $1,376.45 final balance — a near-total-wipeout moment mid-backtest, invisible in the headline +37.6% ROI figure. See the Rolling Bankroll chart below.
  • 2 of 236 trades lost money (99.2%, not 100%, win rate) — too small a sample to read much into beyond noting they exist.

Fee Impact

Total fees paid: $317.80** against **$694.25 of gross gain (before fees) — fees consumed 45.8% of the gross edge, leaving $376.45 net. Unlike notebook 05's hold-to-close strategy (where fees erased essentially the entire edge), this profit-based-exit strategy generates enough gross edge per trade to comfortably survive fees, even applied across three events with genuinely different fee structures:

  • March — $58.83 in fees (Kalshi-only; Polymarket's `feesEnabled: False` here means $0 Polymarket fees on every March trade), against a +$122.53 net gain (100% win rate, 47 trades).
  • April — $149.93 in fees (Polymarket's 10% rate applies here), a **+$114.86 net gain** (97% win rate, 73 trades) — the highest fee bill of the three, and correspondingly the smallest net gain relative to trade count.
  • June — $109.04 in fees, the strongest net result at **+$139.06** (100% win rate, 116 trades) — the largest trade count more than compensates for Polymarket's fee being active here too.

The per-bucket fee-rate threading (see Strategy Definition → Fees) is directly visible in the numbers: every March bucket shows $0.00 in Polymarket fees in the By Bucket table above, while every April/June bucket shows a real Polymarket fee line — exactly the behavior the live-verified feesEnabled check was built to produce.

Visualizing the Trades

One stacked price+volume panel-pair per bucket (11 total), grouped by event so each event's own panels share an x-axis sensibly rather than compressing three different multi-month windows onto one shared timeline. Entry/exit pairs are drawn as dumbbells directly on the price line — a circle at entry, a diamond at exit, connected by a line, colored green for a winning trade and red for a loser. Each panel's own event-resolution date is marked as a single vertical guide (not a shared cross-event news calendar like notebook 04 used — with three separate events spanning roughly nine months combined, a hand-curated shared news list doesn't transfer, and each bucket's own resolution date is the one date that unambiguously matters for that panel).

Volume is plotted on a log scale, same reasoning as notebook 04 — daily volume spans several orders of magnitude across a bucket's full lifetime.

In [19]:
WIN_COLOR = "seagreen"
LOSS_COLOR = "crimson"

n_buckets = len(BUCKETS)
fig = plt.figure(figsize=(18, 3.2 * n_buckets))
gs = fig.add_gridspec(n_buckets * 2, 1, height_ratios=[2, 1] * n_buckets, hspace=0.45)

axes_price, axes_vol = [], []
event_first_axis = {}
for i, (key, b) in enumerate(BUCKETS.items()):
    share_with = event_first_axis.get(b["event_key"])
    ax_p = fig.add_subplot(gs[i * 2], sharex=share_with)
    ax_v = fig.add_subplot(gs[i * 2 + 1], sharex=share_with)
    event_first_axis.setdefault(b["event_key"], ax_p)
    axes_price.append(ax_p)
    axes_vol.append(ax_v)

for i, (key, b) in enumerate(BUCKETS.items()):
    ax_p, ax_v = axes_price[i], axes_vol[i]
    a = b["aligned"]

    ax_p.plot(a.index, a["polymarket_price"], color="steelblue", linewidth=0.6, alpha=0.8, zorder=1)
    ax_p.axvline(b["kalshi_close"], color="gray", linestyle=":", linewidth=0.9, alpha=0.6, zorder=0)

    bucket_trades = trades_df[trades_df["bucket"] == key]
    for _, t in bucket_trades.iterrows():
        entry_price = a["polymarket_price"].asof(t["entry_ts"])
        exit_price = a["polymarket_price"].asof(t["exit_ts"])
        color = WIN_COLOR if t["pnl"] > 0 else LOSS_COLOR
        ax_p.plot([t["entry_ts"], t["exit_ts"]], [entry_price, exit_price], color=color, linewidth=1.8, alpha=0.85, zorder=3)
        ax_p.scatter([t["entry_ts"]], [entry_price], color=color, marker="o", s=22, zorder=4, edgecolors="black", linewidths=0.4)
        ax_p.scatter([t["exit_ts"]], [exit_price], color=color, marker="D", s=22, zorder=4, edgecolors="black", linewidths=0.4)

    ax_p.set_ylabel("Polymarket\nprice ($)", fontsize=8)
    ax_p.set_title(f"{b['label']} — {len(bucket_trades)} trade(s)", fontsize=10, loc="left")
    ax_p.set_ylim(-0.05, 1.05)
    ax_p.xaxis.set_major_formatter(mdates.DateFormatter("%b %d"))

    kt = b["kalshi_trades"]["count"].resample("1D").sum() if len(b["kalshi_trades"]) else pd.Series(dtype=float)
    pt = b["polymarket_trades"]["size"].resample("1D").sum() if len(b["polymarket_trades"]) else pd.Series(dtype=float)
    full_days = pd.date_range(a.index.min().floor("D"), a.index.max().ceil("D"), freq="1D", tz="UTC")
    kt = kt.reindex(full_days, fill_value=0)
    pt = pt.reindex(full_days, fill_value=0)

    ax_v.bar(full_days, kt.values, color="red", width=1, label="Kalshi", alpha=0.85)
    ax_v.bar(full_days, pt.values, bottom=kt.values, color="blue", width=1, label="Polymarket", alpha=0.85)
    ax_v.set_ylabel("contracts/day\n(log)", fontsize=8)
    ax_v.set_yscale("symlog")
    if i == 0:
        ax_v.legend(loc="upper right", fontsize=8)

axes_vol[-1].set_xlabel("date")

legend_elements = [
    Line2D([0], [0], marker="o", color="w", markerfacecolor=WIN_COLOR, markeredgecolor="black", markersize=8, label="Entry (win)"),
    Line2D([0], [0], marker="D", color="w", markerfacecolor=WIN_COLOR, markeredgecolor="black", markersize=8, label="Exit (win)"),
    Line2D([0], [0], marker="o", color="w", markerfacecolor=LOSS_COLOR, markeredgecolor="black", markersize=8, label="Entry (loss)"),
    Line2D([0], [0], marker="D", color="w", markerfacecolor=LOSS_COLOR, markeredgecolor="black", markersize=8, label="Exit (loss)"),
    Line2D([0], [0], color="gray", linestyle=":", label="Event resolution date"),
]
fig.legend(handles=legend_elements, loc="upper center", ncol=5, bbox_to_anchor=(0.5, 0.998), fontsize=9)
fig.suptitle("Backtest Trades by Bucket — Price, Entry/Exit Dumbbells, and Daily Volume", fontsize=14, y=1.003)

plt.show()
No description has been provided for this image
In [20]:
display(Markdown("#### Aggregate Backtest Metrics, by Bucket"))

STATS_ROWS = [
    "Number of Trades",
    "Number of Successful Trades",
    "Total ROI",
    "Average ROI per Trade",
    "Length of Hold (avg, hours)",
    "Length of Hold (avg, days)",
    "Average PNL",
    "Total PNL",
]

bucket_stat_tables = {}
for key, b in BUCKETS.items():
    bucket_trades = trades_df[trades_df["bucket"] == key]
    n_trades = len(bucket_trades)

    if n_trades == 0:
        values = ["0", "0", "N/A", "N/A", "N/A", "N/A", "N/A", "N/A"]
    else:
        n_success = int((bucket_trades["pnl"] > 0).sum())
        capital_committed = bucket_trades["entry_cost"] + bucket_trades["entry_fees"]
        total_pnl = bucket_trades["pnl"].sum()
        total_roi = total_pnl / capital_committed.sum() if capital_committed.sum() else np.nan
        avg_roi = bucket_trades["roi"].mean()
        avg_hold_hours = bucket_trades["holding_minutes"].mean() / 60
        avg_pnl = bucket_trades["pnl"].mean()
        values = [
            f"{n_trades:,}",
            f"{n_success:,}",
            f"{total_roi:.1%}",
            f"{avg_roi:.1%}",
            f"{avg_hold_hours:,.1f} hours",
            f"{avg_hold_hours / 24:,.1f} days",
            f"${avg_pnl:,.2f}",
            f"${total_pnl:,.2f}",
        ]

    bucket_stat_tables[key] = pd.DataFrame({"Metric": STATS_ROWS, "Value": values})

styled_tables_html = []
for key, b in BUCKETS.items():
    styled = (
        bucket_stat_tables[key].style
        .hide(axis="index")
        .set_caption(b["label"])
        .set_properties(**{"text-align": "center", "padding": "6px"})
        .set_table_styles([
            {"selector": "th", "props": [("text-align", "center"), ("background-color", "#f0f0f0"), ("padding", "8px")]},
            {"selector": "caption", "props": [("font-weight", "700"), ("font-size", "1.05em"), ("padding-bottom", "6px")]},
        ])
    )
    styled_tables_html.append(f"<div>{styled.to_html()}</div>")

display(HTML(
    '<div style="display:flex; gap:40px; align-items:flex-start; flex-wrap:wrap;">'
    + "".join(styled_tables_html)
    + "</div>"
))

Aggregate Backtest Metrics, by Bucket

Mar 2026 – 50+bps Decrease
Metric Value
Number of Trades 1
Number of Successful Trades 1
Total ROI 7.0%
Average ROI per Trade 7.0%
Length of Hold (avg, hours) 572.2 hours
Length of Hold (avg, days) 23.8 days
Average PNL $7.51
Total PNL $7.51
Mar 2026 – 25bps Decrease
Metric Value
Number of Trades 18
Number of Successful Trades 18
Total ROI 8.0%
Average ROI per Trade 7.8%
Length of Hold (avg, hours) 183.6 hours
Length of Hold (avg, days) 7.6 days
Average PNL $2.42
Total PNL $43.53
Mar 2026 – No Change
Metric Value
Number of Trades 28
Number of Successful Trades 28
Total ROI 5.2%
Average ROI per Trade 5.4%
Length of Hold (avg, hours) 215.0 hours
Length of Hold (avg, days) 9.0 days
Average PNL $2.55
Total PNL $71.49
Apr 2026 – 50+bps Decrease
Metric Value
Number of Trades 11
Number of Successful Trades 10
Total ROI 6.2%
Average ROI per Trade 9.0%
Length of Hold (avg, hours) 1,367.5 hours
Length of Hold (avg, days) 57.0 days
Average PNL $2.51
Total PNL $27.60
Apr 2026 – 25bps Decrease
Metric Value
Number of Trades 43
Number of Successful Trades 42
Total ROI 3.8%
Average ROI per Trade 3.4%
Length of Hold (avg, hours) 754.5 hours
Length of Hold (avg, days) 31.4 days
Average PNL $1.11
Total PNL $47.77
Apr 2026 – No Change
Metric Value
Number of Trades 19
Number of Successful Trades 19
Total ROI 4.8%
Average ROI per Trade 4.8%
Length of Hold (avg, hours) 420.4 hours
Length of Hold (avg, days) 17.5 days
Average PNL $2.08
Total PNL $39.49
Jun 2026 – 50+bps Decrease
Metric Value
Number of Trades 22
Number of Successful Trades 22
Total ROI 4.9%
Average ROI per Trade 5.5%
Length of Hold (avg, hours) 1,497.8 hours
Length of Hold (avg, days) 62.4 days
Average PNL $0.68
Total PNL $14.88
Jun 2026 – 25bps Decrease
Metric Value
Number of Trades 69
Number of Successful Trades 69
Total ROI 4.5%
Average ROI per Trade 3.8%
Length of Hold (avg, hours) 1,212.4 hours
Length of Hold (avg, days) 50.5 days
Average PNL $1.25
Total PNL $85.96
Jun 2026 – No Change
Metric Value
Number of Trades 23
Number of Successful Trades 23
Total ROI 10.2%
Average ROI per Trade 4.5%
Length of Hold (avg, hours) 154.4 hours
Length of Hold (avg, days) 6.4 days
Average PNL $1.34
Total PNL $30.88
Jun 2026 – 25bps Increase
Metric Value
Number of Trades 2
Number of Successful Trades 2
Total ROI 7.3%
Average ROI per Trade 7.7%
Length of Hold (avg, hours) 27.7 hours
Length of Hold (avg, days) 1.2 days
Average PNL $3.67
Total PNL $7.35
Jun 2026 – 50+bps Increase
Metric Value
Number of Trades 0
Number of Successful Trades 0
Total ROI N/A
Average ROI per Trade N/A
Length of Hold (avg, hours) N/A
Length of Hold (avg, days) N/A
Average PNL N/A
Total PNL N/A

Rolling Bankroll

Bankroll only changes at two moments: when a position opens (capital is deployed) and when it closes (capital plus PNL returns). The line below is a step function, not a smooth curve, for exactly that reason. With three events in play instead of one, this is also where the multi-event handling described in the intro becomes visible directly: capital recycles across March, April, and June positions on one shared bankroll, released as each position's own bucket resolves rather than waiting for every event to finish.

In [21]:
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 — Multi-Event Arbitrage Backtest", fontsize=12)
ax.legend(loc="upper left", fontsize=9)
plt.tight_layout()
plt.show()

peak = bankroll_df["bankroll"].max()
trough = bankroll_df["bankroll"].min()
print(
    f"Peak bankroll: ${peak:,.2f} | Trough bankroll: ${trough:,.2f} | "
    f"Max drawdown from peak: ${peak - trough:,.2f}"
)

Bankroll Over Time — Starting $1,000, Ending $1,376.45

No description has been provided for this image
Peak bankroll: $1,376.45 | Trough bankroll: $3.87 | Max drawdown from peak: $1,372.58

Bankroll Allocation: Shared Pool vs. Per-Event Pools

The backtest above pools all three events into one $1,000 bankroll, competing chronologically for capital. As a check on how much that shared-pool structure actually drove the result above (including the $3.87 trough), this section reruns the exact same 515 confirmed candidate trades — same entries, same exits, same fees, same sizing rule — through an alternative allocation: three independent $1,000 pools, one per event, with no capital ever crossing between them. Same strategy, same signals; only the capital-allocation rule changes.

In [22]:
def run_bankroll_scenario(candidates, starting_bankroll):
    """Same allocation logic as the Backtest Engine's bt_bankroll cell above, factored out
    so it can be re-run per-event as well as on the full shared pool."""
    if not candidates:
        return pd.DataFrame(), pd.DataFrame(columns=["ts", "bankroll"]), starting_bankroll

    available = starting_bankroll
    curve = [(candidates[0]["entry_ts"], starting_bankroll)]
    open_positions = []
    closed = []

    for cand in candidates:
        still_open = []
        for pos in open_positions:
            if pos["exit_ts"] <= cand["entry_ts"]:
                available += pos["capital_committed"] + pos["pnl"]
                curve.append((pos["exit_ts"], available))
            else:
                still_open.append(pos)
        open_positions = still_open

        budget = available * 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:
            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_fees = 0.0

        pnl = (exit_proceeds - exit_fees) - (entry_cost + entry_fees)
        capital_committed = entry_cost + entry_fees
        closed.append({
            **cand, "contracts": contracts, "capital_committed": capital_committed,
            "total_fees": entry_fees + exit_fees, "pnl": pnl,
        })
        available -= capital_committed
        curve.append((cand["entry_ts"], available))
        open_positions.append({"exit_ts": cand["exit_ts"], "capital_committed": capital_committed, "pnl": pnl})

    for pos in sorted(open_positions, key=lambda p: p["exit_ts"]):
        available += pos["capital_committed"] + pos["pnl"]
        curve.append((pos["exit_ts"], available))

    scenario_trades_df = pd.DataFrame(closed)
    scenario_bankroll_df = pd.DataFrame(curve, columns=["ts", "bankroll"]).sort_values("ts").reset_index(drop=True)
    return scenario_trades_df, scenario_bankroll_df, available


per_event_results = {}
for event_key in ACTIVE_EVENT_KEYS:
    event_candidates = [c for c in all_candidates if c["event_key"] == event_key]
    t_df, b_df, final_bal = run_bankroll_scenario(event_candidates, STARTING_BANKROLL)
    per_event_results[event_key] = {"trades_df": t_df, "bankroll_df": b_df, "final_bankroll": final_bal}
    print(f"{EVENT_SHORT_LABELS[event_key]}: {len(t_df)} trades, final ${final_bal:,.2f}, trough ${b_df['bankroll'].min():,.2f}")
Mar 2026: 50 trades, final $1,279.00, trough $50.41
Apr 2026: 108 trades, final $1,177.28, trough $12.63
Jun 2026: 102 trades, final $1,249.97, trough $3.86
In [23]:
EVENT_COLORS = {"fed_mar_2026": "steelblue", "fed_apr_2026": "darkorange", "fed_jun_2026": "seagreen"}

fig, ax = plt.subplots(figsize=(16, 6))

ax.plot(
    bankroll_df["ts"], bankroll_df["bankroll"], color="black", linewidth=2.0,
    drawstyle="steps-post", zorder=4, label="Shared pool ($1,000 total)",
)
for event_key in ACTIVE_EVENT_KEYS:
    b_df = per_event_results[event_key]["bankroll_df"]
    ax.plot(
        b_df["ts"], b_df["bankroll"], color=EVENT_COLORS[event_key], linewidth=1.3, alpha=0.85,
        drawstyle="steps-post", zorder=3, label=f"{EVENT_SHORT_LABELS[event_key]} pool (own $1,000)",
    )

ax.axhline(STARTING_BANKROLL, color="gray", linestyle="--", linewidth=1, zorder=1, label="Starting bankroll ($1,000)")
ax.set_xlabel("date")
ax.set_ylabel("bankroll ($)")
ax.set_title("Shared Pool vs. Independent Per-Event Pools — Same Trades, Different Capital Allocation", fontsize=12)
ax.legend(loc="upper left", fontsize=9)
plt.tight_layout()
plt.show()
No description has been provided for this image
In [24]:
display(Markdown("#### Final PNL Comparison: Shared Pool vs. Per-Event Pools"))

comparison_rows = [{
    "Scenario": "Shared pool (actual backtest above)",
    "Starting Capital": STARTING_BANKROLL,
    "Trades": len(trades_df),
    "Total PNL": trades_df["pnl"].sum(),
    "Total Fees": trades_df["total_fees"].sum(),
    "Final Capital": available_bankroll,
    "Trough": bankroll_df["bankroll"].min(),
    "ROI": (available_bankroll - STARTING_BANKROLL) / STARTING_BANKROLL,
}]

total_pe_pnl = total_pe_fees = total_pe_trades = total_pe_final = 0
for event_key in ACTIVE_EVENT_KEYS:
    r = per_event_results[event_key]
    t_df = r["trades_df"]
    pnl = t_df["pnl"].sum() if len(t_df) else 0.0
    fee = t_df["total_fees"].sum() if len(t_df) else 0.0
    comparison_rows.append({
        "Scenario": f"{EVENT_SHORT_LABELS[event_key]} pool (own $1,000)",
        "Starting Capital": STARTING_BANKROLL,
        "Trades": len(t_df),
        "Total PNL": pnl,
        "Total Fees": fee,
        "Final Capital": r["final_bankroll"],
        "Trough": r["bankroll_df"]["bankroll"].min(),
        "ROI": (r["final_bankroll"] - STARTING_BANKROLL) / STARTING_BANKROLL,
    })
    total_pe_pnl += pnl
    total_pe_fees += fee
    total_pe_trades += len(t_df)
    total_pe_final += r["final_bankroll"]

comparison_rows.append({
    "Scenario": "Per-event pools, combined (3 × $1,000)",
    "Starting Capital": STARTING_BANKROLL * 3,
    "Trades": total_pe_trades,
    "Total PNL": total_pe_pnl,
    "Total Fees": total_pe_fees,
    "Final Capital": total_pe_final,
    "Trough": np.nan,
    "ROI": (total_pe_final - STARTING_BANKROLL * 3) / (STARTING_BANKROLL * 3),
})

comparison_table = pd.DataFrame(comparison_rows)
display(
    comparison_table.style.format({
        "Starting Capital": "${:,.2f}",
        "Total PNL": "${:,.2f}",
        "Total Fees": "${:,.2f}",
        "Final Capital": "${:,.2f}",
        "Trough": lambda x: "N/A" if pd.isna(x) else f"${x:,.2f}",
        "ROI": "{:.1%}",
    }).hide(axis="index")
)

Final PNL Comparison: Shared Pool vs. Per-Event Pools

Scenario Starting Capital Trades Total PNL Total Fees Final Capital Trough ROI
Shared pool (actual backtest above) $1,000.00 236 $376.45 $317.80 $1,376.45 $3.87 37.6%
Mar 2026 pool (own $1,000) $1,000.00 50 $279.00 $117.39 $1,279.00 $50.41 27.9%
Apr 2026 pool (own $1,000) $1,000.00 108 $177.28 $220.98 $1,177.28 $12.63 17.7%
Jun 2026 pool (own $1,000) $1,000.00 102 $249.97 $180.87 $1,249.97 $3.86 25.0%
Per-event pools, combined (3 × $1,000) $3,000.00 260 $706.24 $519.24 $3,706.24 N/A 23.5%

Entry Timing: How Far Before Resolution Do Trades Actually Happen?

Motivated by the bankroll comparison above: if arbitrage opportunities are concentrated later in a bucket's life rather than spread evenly across it, capital sitting idle early on isn't actually costing much — it wouldn't have found a signal yet regardless. This looks at how many days before each bucket's own resolution date the 236 executed trades in the shared-pool backtest actually entered, as a first check on whether a staggered/progressive capital-release schedule (instead of the full $1,000 available from day one) would leave much on the table.

In [25]:
trades_df["bucket_close"] = trades_df["bucket"].map(lambda k: BUCKETS[k]["kalshi_close"])
trades_df["days_before_close"] = (trades_df["bucket_close"] - trades_df["entry_ts"]).dt.total_seconds() / 86400

display(Markdown("#### Entry Timing Relative to Bucket Close (all executed trades)"))
display(trades_df["days_before_close"].describe().round(1).to_frame(name="Days Before Close"))

display(Markdown("#### Trades Captured, by Minimum Runway Before Close"))
runway_rows = []
for n_days in [7, 14, 21, 30, 45, 60, 90]:
    subset = trades_df[trades_df["days_before_close"] <= n_days]
    runway_rows.append({
        "Runway (days before close)": f"≤ {n_days}",
        "Trades Captured": len(subset),
        "% of All Trades": len(subset) / len(trades_df),
        "PNL (if isolated)": subset["pnl"].sum(),
    })
runway_table = pd.DataFrame(runway_rows)
display(
    runway_table.style
    .format({"% of All Trades": "{:.1%}", "PNL (if isolated)": "${:,.2f}"})
    .hide(axis="index")
)

display(Markdown("#### By Event"))
by_event_timing = trades_df.groupby("event_key")["days_before_close"].agg(["count", "min", "median", "max"]).round(1)
display(by_event_timing)

Entry Timing Relative to Bucket Close (all executed trades)

Days Before Close
count 236.0
mean 100.7
std 31.0
min 35.2
25% 70.6
50% 104.4
75% 124.2
max 188.2

Trades Captured, by Minimum Runway Before Close

Runway (days before close) Trades Captured % of All Trades PNL (if isolated)
≤ 7 0 0.0% $0.00
≤ 14 0 0.0% $0.00
≤ 21 0 0.0% $0.00
≤ 30 0 0.0% $0.00
≤ 45 4 1.7% $1.86
≤ 60 21 8.9% $30.79
≤ 90 91 38.6% $140.86

By Event

count min median max
event_key
fed_apr_2026 73 67.0 90.9 167.2
fed_jun_2026 116 50.1 120.8 188.2
fed_mar_2026 47 35.2 67.9 138.6

Capital-Release ("Drip") Schedule: Does Timing-Based Pacing Help?

Motivated by the timing table above: since arbitrage signals cluster in the back half of each bucket's life, would releasing capital progressively — rather than making the full $1,000 available from day one — actually change anything? Tested schedule: 60% of the bankroll available immediately, +20% more once within 75 days of the nearest upcoming event's close, and the remaining 20% once within 50 days of that close.

In [26]:
EVENT_CLOSE_DATES = sorted(set(b["kalshi_close"] for b in BUCKETS.values()))


def drip_ceiling(ts, starting_bankroll):
    """60% of bankroll available initially; +20% within 75 days of the nearest
    upcoming event's close; the remaining 20% within 50 days."""
    future_closes = [d for d in EVENT_CLOSE_DATES if d > ts]
    if not future_closes:
        return starting_bankroll
    days = (min(future_closes) - ts).total_seconds() / 86400
    if days > 75:
        return 0.60 * starting_bankroll
    elif days > 50:
        return 0.80 * starting_bankroll
    return starting_bankroll


def run_bankroll_with_drip(candidates, starting_bankroll):
    available = starting_bankroll
    open_positions = []
    closed = []
    n_skipped_by_drip = 0

    for cand in candidates:
        still_open = []
        for pos in open_positions:
            if pos["exit_ts"] <= cand["entry_ts"]:
                available += pos["capital_committed"] + pos["pnl"]
            else:
                still_open.append(pos)
        open_positions = still_open

        ceiling = drip_ceiling(cand["entry_ts"], starting_bankroll)
        usable = min(available, ceiling)

        budget = usable * 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:
            if usable < available:
                n_skipped_by_drip += 1
            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_fees = 0.0

        pnl = (exit_proceeds - exit_fees) - (entry_cost + entry_fees)
        capital_committed = entry_cost + entry_fees
        closed.append({**cand, "contracts": contracts, "capital_committed": capital_committed,
                        "total_fees": entry_fees + exit_fees, "pnl": pnl})
        available -= capital_committed
        open_positions.append({"exit_ts": cand["exit_ts"], "capital_committed": capital_committed, "pnl": pnl})

    for pos in sorted(open_positions, key=lambda p: p["exit_ts"]):
        available += pos["capital_committed"] + pos["pnl"]

    return pd.DataFrame(closed), available, n_skipped_by_drip


drip_trades_df, drip_final_bankroll, n_skipped_by_drip = run_bankroll_with_drip(all_candidates, STARTING_BANKROLL)

display(Markdown("#### Baseline vs. Drip Schedule"))
compare_rows = [
    {
        "Scenario": "Baseline (full $1,000 available immediately)",
        "Trades": len(trades_df),
        "Total PNL": trades_df["pnl"].sum(),
        "Total Fees": trades_df["total_fees"].sum(),
        "Final Bankroll": available_bankroll,
        "ROI": (available_bankroll - STARTING_BANKROLL) / STARTING_BANKROLL,
    },
    {
        "Scenario": "Drip (60% / +20% at 75d / +20% at 50d before nearest close)",
        "Trades": len(drip_trades_df),
        "Total PNL": drip_trades_df["pnl"].sum(),
        "Total Fees": drip_trades_df["total_fees"].sum(),
        "Final Bankroll": drip_final_bankroll,
        "ROI": (drip_final_bankroll - STARTING_BANKROLL) / STARTING_BANKROLL,
    },
]
compare_table = pd.DataFrame(compare_rows)
display(
    compare_table.style.format({
        "Total PNL": "${:,.2f}", "Total Fees": "${:,.2f}", "Final Bankroll": "${:,.2f}", "ROI": "{:.1%}",
    }).hide(axis="index")
)
print(f"Trades specifically constrained by the drip ceiling (vs. ordinary insufficient-bankroll skips): {n_skipped_by_drip}")

Baseline vs. Drip Schedule

Scenario Trades Total PNL Total Fees Final Bankroll ROI
Baseline (full $1,000 available immediately) 236 $376.45 $317.80 $1,376.45 37.6%
Drip (60% / +20% at 75d / +20% at 50d before nearest close) 235 $376.60 $305.98 $1,376.60 37.7%
Trades specifically constrained by the drip ceiling (vs. ordinary insufficient-bankroll skips): 0

The drip schedule makes essentially no difference. Trade count, PNL, fees, and ROI all land within a rounding error of the baseline, and the drip ceiling constrains at or near zero trades directly.

The reason traces back to the timing table above: the earliest any trade fires at all, across all 11 buckets, is 35.2 days before that bucket's own close — comfortably inside the point where this schedule already reaches 100% (50 days before the nearest event's close). Kalshi's own confirmed-volume requirement is already a tighter, earlier-binding bottleneck on when trades can start than any capital-release schedule built around thresholds this loose. A drip schedule with a real effect would need to be pulled in much tighter — something closer to the 30–60 day range, where the actual entry-timing distribution has most of its mass — rather than 50–75 days, which mostly describes a period before signals exist at all.

Summary

Walking back through the "Metrics to Report" list from the top of this notebook, with the final numbers behind each one.

Metrics to Report — Final Results

Funnel counts, with reason for each miss:

Bucket Signals Detected Kalshi Confirmed Kalshi Not Confirmed
Mar 2026 – No Change 171 29 142
Mar 2026 – 25bps Decrease 124 20 104
Mar 2026 – 50+bps Decrease 51 1 50
Apr 2026 – No Change 349 26 323
Apr 2026 – 25bps Decrease 423 71 352
Apr 2026 – 50+bps Decrease 299 11 288
Jun 2026 – No Change 314 74 240
Jun 2026 – 25bps Decrease 645 218 427
Jun 2026 – 50+bps Decrease 226 61 165
Jun 2026 – 25bps Increase 69 4 65
Jun 2026 – 50+bps Increase 7 0 7
Total 2,678 515 (19.2%) 2,163 (80.8%)
  • Of the 515 Kalshi-confirmed signals, 236 (45.8%) executed; 279 (54.2%) were skipped for insufficient available bankroll — a much larger share than notebook 04 ever saw (which never skipped a single trade for lack of capital). Three concurrent events competing for one $1,000 bankroll, with 20%-of-bankroll sizing per trade, genuinely runs into capacity limits a single event never hit.
  • The Kalshi-confirmation rate (19.2%) is meaningfully higher than notebook 04's January-only rate (6.6%) — worth noting as a real cross-event difference, though this backtest doesn't establish why.

Holding duration: average 786.4 hours (~32.8 days) overall. Longest average hold was June's 50+bps Decrease bucket (1,497.8 hours, ~62 days); shortest was June's 25bps Increase (27.7 hours) — see the By Bucket table above for the full breakdown.

ROI: +37.6% overall. By event: March 100% win rate/47 trades, April 97%/73 trades, June 100%/116 trades — see Fee Impact above for the PNL split.

Bankroll movement: started at $1,000, swung as low as **$3.87** at its trough (a near-total-wipeout point), and finished at $1,376.45. Max drawdown from peak: $1,372.58. See the Rolling Bankroll chart above for the full shape — three separate events opening and releasing capital on their own calendars, all against one shared bankroll.

Fee cost: $317.80 total across 236 trades (~$1.35/trade average) — see Fee Impact above for the full per-event breakdown.

PNL: $376.45 net ($694.25 gross, per Fee Impact above).

Other Lessons Learned

The strategy generalizes — this isn't a one-event fluke. All three events landed net positive individually (March +$122.53, April +$114.86, June +$139.06), not just in aggregate. That's a meaningfully stronger claim than notebook 04 could make on its own with a single event.

Auto-discovering buckets via market_matching.match_outcomes() instead of hand-typing them worked cleanly, and caught something notebook 04's hand-typed January buckets didn't. Polymarket lumps all rate-hike outcomes into one contract for March and April, with no 1:1 Kalshi counterpart — match_outcomes() correctly reported those as skipped rather than forcing a mismatched pairing. Notebook 04's January buckets, by contrast, hand-matched Kalshi's narrower 25bps-only hike contract against Polymarket's broader "25bps-or-more" contract — a looser pairing than this notebook's automated matching would have allowed. Worth being aware of if January's results are ever compared directly against these three.

Fees genuinely aren't uniform across events, and it mattered. March's Polymarket fee rate is $0 (matching January's), while April's and June's are the same 10% used throughout this project. Threading a per-bucket fee rate through the engine (rather than one global constant) was a small code change but a real correctness fix — treating March's trades as if they paid the same fees as April/June would have understated March's net PNL by exactly its $0 Polymarket-fee advantage.

Concurrency across events is where this backtest genuinely diverges from notebook 04. With three events' worth of signals competing for one $1,000 bankroll, 54% of Kalshi-confirmed signals were skipped for insufficient capital — a constraint notebook 04 never hit with only one event in play. The trough-of-$3.87 bankroll swing is the sharpest expression of that: capital was, at one point, almost entirely committed to open positions simultaneously.

Conclusion: How Does This Compare to Just Holding an Index Fund?

Using real closing values spanning this backtest's full combined data window: the S&P 500 closed at 6,688.46 on September 29, 2025 and 7,420.10 on June 17, 2026 — a price return of +10.9% over that roughly 8.5-month span (likely somewhat higher with dividends reinvested, which a bare price index doesn't capture but a real index fund would).

This backtest's +37.6% ROI is substantially higher — more than 3x the index's return over the same calendar period — a wider margin than notebook 04's roughly 2x outperformance on the January-only backtest. The same caveats notebook 04 raised apply here, more so given the added complexity:

  • The index return assumes a lump sum invested continuously for the full window. This strategy's capital exposure looked nothing like that — it swung from a $3.87 trough (essentially fully deployed) to a $1,376.45 peak, so it isn't a clean "same money, same duration" comparison.
  • 236 trades across three events is a larger sample than notebook 04's 34, but still not a track record — three FOMC-cycle events sharing broadly similar market structure isn't the same as testing across genuinely different market regimes.
  • This is a raw dollar-return comparison, with no risk-adjustment. The near-wipeout trough is exactly the kind of risk a raw return number hides — a real investor living through a 99.6% peak-to-trough drawdown, even one that fully recovered, experienced something an index fund investor over the same window did not.

So: greater, by a wide and non-trivial margin — wider than the single-event backtest showed — but the trough-of-$3.87 finding is the reason not to read that gap as "the strategy is safer than the index," just that it returned more, with a very different risk profile along the way.