Notebook 04: Arbitrage Strategy Backtest¶
This notebook backtests the cross-platform hedge-arbitrage strategy identified across notebooks 01–03 — buying opposite outcomes on Kalshi and Polymarket whenever their combined cost drops meaningfully below $1.00 — against real historical price and trade data.
Unlike the exploratory work in notebooks 02–03, which focused on a single outcome bucket ("No Change") over a single month (November 2025), this backtest covers the full lifecycle of the January 2026 Fed decision event, across all four outcome buckets, using the same threshold-based framework already validated throughout this project.
This notebook only defines the strategy and gathers the data it needs. The actual simulation/backtest engine is built in a later pass.
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 (the originally-discussed 20-second window isn't verifiable from 1-minute candles).
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. This directly reuses the liquidity-check methodology from notebook 03, where 84.4% of qualifying episodes had zero realized Kalshi volume, so a meaningful share of signals are expected to fail at this step by design, not by bug.
Sizing: 20% of available bankroll per trade, split into a matched equal contract count on both legs — not an equal dollar split. A true hedge requires the same number of contracts on both sides; matching dollars instead would leave part of the position directionally unhedged. 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. 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.
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.
This replaces an earlier version of this strategy that exited on a gap-reversal signal instead of on actual profitability. That version was tested directly: 33 of 34 trades it took showed a real, favorable price move (gross proceeds exceeded entry cost), but 27 of those 33 still lost money once fees were included — the gap-reversal trigger had no way to know whether an early exit was actually worth paying a second round of fees for. Tying the exit decision directly to "does this beat the guaranteed alternative" fixes that by construction: an early exit is now only ever taken when it's the better of the two options.
No stop loss is used in this version. (Future work: a version without the Kalshi-confirmation delay — assuming instant, automatic fills on both legs — combined with a time-based exit rule and an actual stop loss, to compare against this profit-based, no-stop-loss version.)
Fees: Kalshi's confirmed formula and Polymarket's documented (not on-chain-contract) formula, both as currently implemented in src/fees.py — not the 0% actually realized on the January "No Change" market historically, since that was specific to one market and isn't assumed to generalize.
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,POLYMARKET_TAKER_RATE = 0.10— fee assumptions, fromfees.pySTOP_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 (Kalshi never confirmed, insufficient available bankroll, etc.), not just a single success/attempt ratio
- 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
- Exit-timing cost — trough value vs. actual (lag-confirmed) exit value, per position that exited on a reversal rather than holding to close
Data Ingestion¶
This backtest needs, for all four outcome buckets of the January 2026 Fed decision event, across each bucket's full market lifetime (not just November):
- 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
This is a much larger pull than anything earlier in this project — 4 buckets × 2 platforms × 2 data types, over the full ~4-month lifetime instead of one month — so each series is cached to data/raw/ as it's fetched. 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 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)
BUCKETS = {
"no_change": {
"label": "No Change",
"kalshi_ticker": "KXFEDDECISION-26JAN-H0",
"polymarket_slug": "no-change-in-fed-interest-rates-after-january-2026-meeting",
},
"cut_25bps": {
"label": "25bps Decrease",
"kalshi_ticker": "KXFEDDECISION-26JAN-C25",
"polymarket_slug": "fed-decreases-interest-rates-by-25-bps-after-january-2026-meeting",
},
"cut_50plus_bps": {
"label": "50+bps Decrease",
"kalshi_ticker": "KXFEDDECISION-26JAN-C26",
"polymarket_slug": "fed-decreases-interest-rates-by-50-bps-after-january-2026-meeting",
},
"increase_25plus_bps": {
"label": "25bps+ Increase",
"kalshi_ticker": "KXFEDDECISION-26JAN-H25",
"polymarket_slug": "fed-increases-interest-rates-by-25-bps-after-january-2026-meeting",
},
}
POLYMARKET_EVENT_SLUG = "fed-decision-in-january"
kalshi_markets_meta = kalshi_client.get_historical_markets("KXFEDDECISION-26JAN")
polymarket_event = polymarket_client.get_event_by_slug(POLYMARKET_EVENT_SLUG)
for key, b in BUCKETS.items():
k_meta = next(m for m in kalshi_markets_meta if m["ticker"] == b["kalshi_ticker"])
b["kalshi_open"] = pd.to_datetime(k_meta["open_time"], utc=True)
b["kalshi_close"] = pd.to_datetime(k_meta["close_time"], utc=True)
p_market = next(m for m in polymarket_event["markets"] if m["slug"] == b["polymarket_slug"])
b["polymarket_token"] = market_matching._polymarket_yes_token(p_market)
b["polymarket_condition_id"] = p_market["conditionId"]
b["polymarket_open"] = pd.to_datetime(p_market["startDate"], utc=True)
b["polymarket_close"] = pd.to_datetime(p_market["closedTime"], utc=True)
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()}",
}
for b in BUCKETS.values()
]
)
display(Markdown("#### Resolved Market Metadata, All Four Buckets"))
display(confirm_table)
def load_or_fetch(cache_name, fetch_fn):
cache_path = RAW_DIR / f"nb04_{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. 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 all four buckets' candidate trades 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.
- 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
POLYMARKET_TAKER_RATE = fees.POLYMARKET_TAKER_RATE
STOP_LOSS = None
def taker_fee_pair(kalshi_price, poly_price, contracts):
"""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_TAKER_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):
"""
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)
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["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"],
"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"],
}
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)
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)
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 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 $100.62 on the $1,000 starting bankroll — a +10.1% ROI — across the same 34 executed trades as before. Win rate jumped to 97.1% (33 of 34 profitable), up from 17.6% under the original gap-reversal exit.
This is the direct result of switching the exit rule from "gap has reversed" to "selling now beats the guaranteed hold-to-close payout" (see the Exit section above). The earlier version found real, favorable price moves on 33 of 34 trades but still lost money on 27 of them because the exit trigger had no way to know whether paying a second round of fees was actually worth it. Tying the exit decision directly to profitability fixes that by construction.
A couple of things worth carrying forward:
- The confirmation-rate caveat still applies — these are the same 34 Kalshi-confirmed trades out of 517 detected signals (6.6%) as before; only the exit logic changed, not the entry/confirmation funnel.
- Average holding duration roughly doubled, from 98.7 to 256.0 hours (~10.7 days). Waiting for genuine profitability instead of a shallow gap-reversal means positions sit open longer on average — a real tradeoff against capital velocity, worth weighing against the return improvement.
- The one loss (50+bps Decrease, -$4.38) is the same single-trade outlier flagged before — its entry already carried a negative gross edge from the start, so no exit rule can fix it. Still too small a sample (n=1) to read much into.
Fee Impact¶
Total fees paid dropped from $291.77 to **$221.88** — not because trades stopped paying entry+exit fees (all 34 still took an early exit rather than literally holding to close), but because exiting later, once genuinely profitable, tends to land at prices closer to the extremes (near $0 or $1), where both platforms' fee formulas — proportional to price × (1-price) — are naturally smaller than near the 50¢ midpoint.
Combined with the higher win rate, the by-bucket picture flipped from "fees erase a real edge" to "edge survives fees comfortably":
- 25bps Decrease — $112.91 in fees, but a **+$61.27 net gain** (100% win rate on 17 trades).
- No Change — $104.58 in fees, a **+$43.74 net gain** (100% win rate on 16 trades).
- 50+bps Decrease — still the lone loser, -$4.38 on its single trade, consistent with the negative gross edge already flagged at entry.
Fees are still a real cost — $221.88 against $322.50 of gross gain is nowhere near free — but they no longer dominate the outcome the way they did under the gap-reversal exit.
Visualizing the Trades¶
Four stacked panels, one per bucket, each with its Polymarket price line and a daily Kalshi + Polymarket volume chart directly below it. 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. Known FOMC-related dates are marked as vertical guides. In place of interactive tooltips (this notebook stays static-image based like the rest of the project), a set of summary-stats tables follows below the chart — one per bucket, side by side — with the aggregate numbers behind that bucket’s dumbbells.
Volume is plotted on a log scale — daily volume spans several orders of magnitude across the full lifetime (a small trickle in October vs. a surge of over a million contracts/day right before the January 28 resolution), and a linear scale crushes the entire trading-relevant window into an invisible flat line.
NEWS_EVENTS = [
("Sept 17\nFOMC", pd.Timestamp("2025-09-17 19:00:00", tz="UTC")),
("Oct 29\nFOMC", pd.Timestamp("2025-10-29 19:00:00", tz="UTC")),
("Nov 19\nminutes", pd.Timestamp("2025-11-19 19:00:00", tz="UTC")),
("Nov 21\nWilliams", pd.Timestamp("2025-11-21 13:30:00", tz="UTC")),
("Dec 10\nFOMC", pd.Timestamp("2025-12-10 19:00:00", tz="UTC")),
("Jan 28\nFOMC", pd.Timestamp("2026-01-28 19:00:00", tz="UTC")),
]
WIN_COLOR = "seagreen"
LOSS_COLOR = "crimson"
fig = plt.figure(figsize=(18, 22))
gs = fig.add_gridspec(8, 1, height_ratios=[2, 1] * 4, hspace=0.35)
axes_price, axes_vol = [], []
for row in range(4):
ax_p = fig.add_subplot(gs[row * 2], sharex=axes_price[0] if axes_price else None)
ax_v = fig.add_subplot(gs[row * 2 + 1], sharex=axes_price[0] if axes_price else None)
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)
for label_txt, ts in NEWS_EVENTS:
ax_p.axvline(ts, 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 price ($)")
ax_p.set_title(f"{b['label']} \u2014 {len(bucket_trades)} trade(s)", fontsize=11, loc="left")
ax_p.set_ylim(-0.05, 1.05)
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 scale)")
ax_v.set_yscale("symlog")
if i == 0:
ax_v.legend(loc="upper right", fontsize=8)
axes_vol[-1].set_xlabel("date")
axes_price[0].xaxis.set_major_formatter(mdates.DateFormatter("%b %d"))
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="News event"),
]
fig.legend(handles=legend_elements, loc="upper center", ncol=5, bbox_to_anchor=(0.5, 0.995), fontsize=9)
fig.suptitle("Backtest Trades by Bucket \u2014 Price, Entry/Exit Dumbbells, and Daily Volume", fontsize=14, y=1.01)
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 — it's flat between events and jumps at each one, which is the accurate picture of a strategy with a handful of discrete trades rather than continuous exposure.
display(Markdown(f"#### Bankroll Over Time \u2014 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,
)
for label_txt, ts in NEWS_EVENTS:
ax.axvline(ts, color="gray", linestyle=":", linewidth=0.8, alpha=0.5, zorder=0)
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 \u2014 Profit-Based Exit Strategy", 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}"
)
Entry Timing: How Far Before Resolution Do Trades Actually Happen?¶
A follow-up question raised while comparing bankroll allocation strategies in notebook 06: 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 34 executed trades in this backtest actually entered.
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 Bucket"))
by_bucket_timing = trades_df.groupby("bucket_label")["days_before_close"].agg(["count", "min", "median", "max"]).round(1)
display(by_bucket_timing)
Summary¶
Walking back through the "Metrics to Report" list from the top of this notebook, with the final numbers behind each one, followed by everything else worth carrying forward that wasn't on that original list.
Metrics to Report — Final Results¶
Funnel counts, with reason for each miss:
| Bucket | Signals Detected | Kalshi Confirmed | Kalshi Not Confirmed |
|---|---|---|---|
| No Change | 169 | 16 | 153 |
| 25bps Decrease | 300 | 17 | 283 |
| 50+bps Decrease | 45 | 1 | 44 |
| 25bps+ Increase | 3 | 0 | 3 |
| Total | 517 | 34 (6.6%) | 483 (93.4%) |
- Every one of the 34 confirmed signals had enough available bankroll and was executed — 0 trades were skipped for lack of capital.
- The dominant failure reason by far is "Kalshi never showed realized volume during the entry window," not bankroll or any other constraint.
Holding duration (per bucket, since duration varied enormously by bucket):
| Bucket | Avg. Hold (hours) | Avg. Hold (days) |
|---|---|---|
| No Change | 174.1 | 7.3 |
| 25bps Decrease | 348.2 | 14.5 |
| 50+bps Decrease | 0.0 | 0.0 |
ROI, per position and in aggregate:
| Bucket | Total ROI | Avg. ROI per Trade |
|---|---|---|
| No Change | 3.4% | 3.5% |
| 25bps Decrease | 4.1% | 4.2% |
| 50+bps Decrease | -3.7% | -3.7% |
| Overall (34 trades) | 10.1% |
- Total ROI (aggregate PNL ÷ aggregate capital) and average ROI per trade are close but not identical — the difference is which trades get more weight (dollars vs. count).
Bankroll movement: starting $1,000 → final $1,100.62, but not smoothly — dropped to a trough of $108.63** (89% of capital deployed at once) in early December before a cluster of closes released it all at once. Max drawdown from peak: **$991.99. See the Rolling Bankroll chart above for the full shape.
Fee cost, per position and in aggregate:
| Bucket | Kalshi Fees | Poly Fees | Total Fees |
|---|---|---|---|
| No Change | $43.71 | $60.87 | $104.58 |
| 25bps Decrease | $46.70 | $66.21 | $112.91 |
| 50+bps Decrease | $1.40 | $2.98 | $4.38 |
| Total | $221.88 |
PNL, per position and in aggregate:
| Bucket | Avg. PNL | Total PNL |
|---|---|---|
| No Change | $2.73 | $43.74 |
| 25bps Decrease | $3.60 | $61.27 |
| 50+bps Decrease | -$4.38 | -$4.38 |
| Total (34 trades) | $100.62 |
Exit-timing cost (trough vs. confirmed-exit value): this metric doesn't apply anymore. It was defined for the original gap-reversal exit, which tracked a moving trough and paid a cost for only being able to confirm a reversal one tick after the fact. The profit-based exit that replaced it doesn't have a "trough" concept — it compares against a fixed benchmark (the guaranteed $1.00 hold-to-close payout) instead of a moving target, so there's no equivalent lag cost to report. Worth stating plainly rather than quietly dropping the row.
Other Lessons Learned¶
Bugs caught before or during the build:
- The original strategy write-up had the entry direction backwards (said "if Kalshi is the cheaper side, buy Kalshi No" — worked through the real numbers and it's actually the more expensive side that pairs with buying No). Caught by hand-checking a real trade before writing any simulation code, not after.
- Kalshi's historical candlestick data has duplicate timestamps (a recurring issue across this whole project, likely from chunked-fetch boundary overlaps) — broke the episode-detection
groupbywith a "cannot reindex on an axis with duplicate labels" error until deduped. - Discovered a real, previously-unknown limit in Polymarket's
/tradesendpoint: it hard-caps at an offset of 10,000 per query, regardless of date filtering — silently invisible until this notebook needed full-lifetime data instead of the single-month windows every earlier notebook used. Fixed with recursive date-range bisection inpolymarket_client.get_trades(), which now benefits any future work that pulls trade data over long windows.
What actually moved the result:
- Switching the exit rule from gap-reversal to profit-based was the single biggest lever tested — net result flipped from -5.4% ROI to +10.1% ROI on the same 34 trades. The old rule had already captured a real, favorable price move on 33 of 34 trades; it just had no way to know whether a second round of fees was worth paying to lock it in.
- The fee reduction wasn't just from fewer round trips — total fees dropped too ($291.77 → $221.88), because profit-triggered exits tend to land at more extreme prices, where both platforms' fee formulas (proportional to
price × (1-price)) are naturally smaller than near the 50¢ midpoint.
What the aggregate numbers hid, and only the charts revealed:
- The rolling bankroll chart showed ~89% of capital tied up simultaneously for roughly six weeks in the middle of the backtest — a real liquidity/concentration risk invisible in the final +10.1% ROI figure alone.
- The volume panels revealed a genuine, extreme surge right before resolution (one bucket alone saw 34% of its entire lifetime's volume in the final two days) — large enough to force a log-scale y-axis, since a linear one crushed the entire actual trading window into an invisible flat line.
- Those same volume panels also showed Kalshi liquidity was sparse through most of the window and only became consistently active starting mid-December — a visual confirmation of the 6.6% Kalshi-confirmation rate, and a hint that this exact strategy might perform differently if run closer to a decision date than to the start of a contract's life.
Platform mechanics worth remembering for future backtest variants:
- Kalshi offers native stop-loss/take-profit orders and pays variable-rate interest (~3-4% APY) on both cash and open positions; Polymarket has neither natively — no interest on idle balances, and conditional order types require a third-party layer on top of its plain limit/market order book.
- Even where Polymarket does offer yield, it's a narrow, curated list of political/geopolitical markets funded from its own treasury — not confirmed to apply to Fed rate-decision contracts at all.
Conclusion: How Does This Compare to Just Holding an Index Fund?¶
Using real closing values for the same start/end dates as this backtest's full data window: the S&P 500 closed at 6,631.96 on September 17, 2025 and 6,978.03 on January 28, 2026 — a price return of +5.2% over that span (likely ~5.5-5.7% with dividends reinvested, which a bare price index doesn't capture but a real index fund would).
This backtest's +10.1% ROI is meaningfully higher — roughly double the index's return over the same calendar period. Worth being precise about what that does and doesn't establish, though:
- The index return assumes a lump sum invested continuously for the full 4.4 months. This strategy's capital exposure looked nothing like that — the Rolling Bankroll chart above shows it swinging from fully idle to ~89% deployed at different points, so it isn't a clean "same money, same duration" comparison.
- 34 trades on one contract's lifecycle is a small sample to generalize a "beats the market" claim from — this is one backtest, not a track record.
- This is a raw dollar-return comparison, with no risk-adjustment (e.g. Sharpe-style). The two strategies carry very different kinds of risk — market beta for the index fund vs. this strategy's own exposure to fees, liquidity, and platform counterparty risk — that a bigger raw return number doesn't account for.
So: greater, by a real and non-trivial margin, but a result to sit alongside the rest of this notebook's caveats rather than a clean verdict that this strategy beats the market.
Future Backtesting Candidates¶
Additional Fed-decision events, not yet pulled or backtested, that this notebook's pipeline could be pointed at next (same general structure as the January 2026 event already run — multiple outcome buckets, matching markets on both platforms):
| Event | Polymarket | Kalshi |
|---|---|---|
| Fed Decision — March 2026 | fed-decision-in-march-885 | KXFEDDECISION-26MAR |
| Fed Decision — April 2026 | fed-decision-in-april | KXFEDDECISION-26APR |
| Fed Decision — June 2026 | fed-decision-in-june-825 | KXFEDDECISION-26JUN |
| Fed Decision — July 2026 | fed-decision-in-july-181 | KXFEDDECISION-26JUL |
These are event-level identifiers only — none of the per-bucket market tickers/token IDs have been resolved yet, the same way KXFEDDECISION-26JAN-H0 etc. were resolved for January. Before backtesting any of these:
- Confirm the actual outcome-bucket set for each event — it may not be the same four buckets (No Change / 25bps Decrease / 50+bps Decrease / 25bps+ Increase) used for January.
- Re-check Polymarket's live
/fee-ratefor whatever category these fall under, rather than assuming the currentPOLYMARKET_TAKER_RATEstill applies. - The dumbbell/volume visualization below is currently hardcoded to exactly 4 buckets (
add_gridspec(8, 1, ...),range(4)) — would need adjusting if any of these events have a different bucket count.