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 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, 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.
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_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",
}
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."))
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
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 notebook 04, 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 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.
- Funnel and summary metrics.
ENTRY_GAP_THRESHOLD = 0.05
ENTRY_DWELL_MINUTES = 2
PROFIT_EXIT_BUFFER = 0.00
POSITION_SIZE_PCT = 0.20
STARTING_BANKROLL = 1000.0
KALSHI_TAKER_RATE = fees.KALSHI_TAKER_RATE
STOP_LOSS = None
def taker_fee_pair(kalshi_price, poly_price, contracts, polymarket_fee_rate):
"""Returns (kalshi_fee, poly_fee) separately, not just their sum."""
return (
fees.kalshi_taker_fee(kalshi_price, contracts),
fees.polymarket_taker_fee(poly_price, contracts, rate=polymarket_fee_rate),
)
def kalshi_price_for_side(direction, kalshi_yes_ask):
"""direction=+1 -> buying Kalshi No (Kalshi is the expensive side); direction=-1 -> buying Kalshi Yes."""
return (1 - kalshi_yes_ask) if direction == 1 else kalshi_yes_ask
def polymarket_price_for_side(direction, polymarket_price):
"""direction=+1 -> buying Polymarket Yes; direction=-1 -> buying Polymarket No."""
return polymarket_price if direction == 1 else (1 - polymarket_price)
for key, b in BUCKETS.items():
kalshi_deduped = b["kalshi_history"][~b["kalshi_history"].index.duplicated(keep="first")]
poly_deduped = b["polymarket_history"][~b["polymarket_history"].index.duplicated(keep="first")]
k = kalshi_deduped.reset_index().rename(columns={"timestamp": "ts", "yes_ask": "kalshi_yes_ask"})
p = poly_deduped.reset_index().rename(columns={"timestamp": "ts", "price": "polymarket_price"})
aligned = pd.merge_asof(
k[["ts", "kalshi_yes_ask"]].sort_values("ts"),
p[["ts", "polymarket_price"]].sort_values("ts"),
on="ts",
tolerance=pd.Timedelta("5min"),
direction="backward",
).dropna()
aligned["gap"] = aligned["kalshi_yes_ask"] - aligned["polymarket_price"]
b["aligned"] = aligned.set_index("ts").sort_index()
print("Aligned price series built for all buckets:")
for key, b in BUCKETS.items():
print(f" {b['label']}: {len(b['aligned']):,} aligned ticks")
def find_entry_episodes(aligned, gap_threshold, dwell_minutes):
"""Run-length encode |gap| >= threshold, keep runs lasting >= dwell_minutes, tag each with a direction."""
qualifies = aligned["gap"].abs() >= gap_threshold
run_id = (qualifies != qualifies.shift()).cumsum()
episodes = aligned[qualifies].groupby(run_id).agg(
start=("gap", lambda s: s.index[0]),
end=("gap", lambda s: s.index[-1]),
entry_gap=("gap", "first"),
)
episodes["duration_minutes"] = (episodes["end"] - episodes["start"]).dt.total_seconds() / 60
episodes = episodes[episodes["duration_minutes"] >= dwell_minutes - 1].copy()
episodes["direction"] = np.sign(episodes["entry_gap"]).astype(int)
episodes = episodes[episodes["direction"] != 0]
return episodes.reset_index(drop=True)
for key, b in BUCKETS.items():
b["entry_episodes"] = find_entry_episodes(b["aligned"], ENTRY_GAP_THRESHOLD, ENTRY_DWELL_MINUTES)
print(f"{b['label']}: {len(b['entry_episodes']):,} candidate entry episodes")
def kalshi_confirmed(kalshi_trades, start_ts, end_ts):
"""Simulate order confirmation from realized historical volume (see strategy note above)."""
window = kalshi_trades.loc[start_ts:end_ts]
return len(window) > 0
for key, b in BUCKETS.items():
confirmed_flags = [
kalshi_confirmed(b["kalshi_trades"], row["start"], row["end"])
for _, row in b["entry_episodes"].iterrows()
]
b["entry_episodes"]["kalshi_confirmed"] = confirmed_flags
n_confirmed = sum(confirmed_flags)
print(f"{b['label']}: {n_confirmed:,} / {len(confirmed_flags):,} episodes had a Kalshi fill (confirmed)")
def simulate_exit(aligned, entry_ts, direction, profit_exit_buffer, bucket_close_ts, polymarket_fee_rate):
"""
Forward-scan from entry_ts. Exit as soon as selling both legs right now would
net more, per contract after fees, than the guaranteed $1.00 hold-to-close
payout (which pays no exit fee) -- by at least profit_exit_buffer. Otherwise
hold to close.
"""
forward = aligned.loc[entry_ts:bucket_close_ts]
if len(forward) < 2:
return None
HOLD_TO_CLOSE_VALUE = 1.00 # guaranteed payout per contract, no exit fee
for i in range(len(forward)):
ts = forward.index[i]
kalshi_yes_ask = forward["kalshi_yes_ask"].iloc[i]
polymarket_price = forward["polymarket_price"].iloc[i]
current_kalshi_price = kalshi_price_for_side(direction, kalshi_yes_ask)
current_poly_price = polymarket_price_for_side(direction, polymarket_price)
# 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")
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):,}")
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})")
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:,}"
)
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)
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.
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()
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>"
))
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.
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 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.
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}")
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()
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")
)
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.
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)
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.
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}")
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.