Notebook 05: Hold-to-Close Strategy¶
This notebook is a direct comparison to notebook 04's profit-based-exit backtest, using the same four outcome buckets of the January 2026 Fed decision event, the same underlying price/trade data, and the same Kalshi-confirmation simulation. What's different is entry sizing and exit — both changed specifically to make a hold-to-close strategy behave sensibly rather than just being notebook 04 with the exit deleted.
The two strategies can't share an entry/sizing design unchanged: notebook 04's 20%-of-bankroll sizing relied on positions closing and freeing capital for reuse. A hold-to-close position never frees capital until the bucket actually resolves, so every entry across the whole ~4-month lifetime competes for the same, never-replenished pool — 20% sizing would exhaust the bankroll in 5 trades. This notebook uses a much smaller, flat-dollar entry size instead, and a stricter definition of what counts as a distinct "opportunity" so a single persistent mispricing doesn't get treated as dozens of separate entries.
Strategy Definition¶
Monitor: unchanged from notebook 04 — news events, volatility, and unusually large trades are logged alongside every trade for later analysis, not used as an entry filter.
Entry — what counts as a distinct opportunity: the gap must reach ≥ ENTRY_GAP_THRESHOLD ($0.05) for at least 1 tick to open a position. Once opened, that specific gap value is locked as "the opportunity" — no new entry fires against it even while the gap keeps satisfying the threshold, since it's still the same mispricing. The opportunity only resets once the gap has moved to a genuinely different value (higher, lower, or flipped sign) and held there for OPPORTUNITY_RESET_TICKS (2) ticks, at which point that new value is eligible for its own fresh entry if it still qualifies.
This is stricter than notebook 04's rule, which only cared whether the gap stayed ≥$0.05 at all (a continuous run from $0.05 to $0.08 and back was one episode there). Here, a gap that wobbles between qualifying values during a choppy stretch produces multiple distinct entries, not one — by design, since this version is sized to handle many more, much smaller trades.
Execution direction largely follows notebook 04: Kalshi first, sequential, never simultaneous, and a signal is normally confirmed only if Kalshi shows realized trade volume during the qualifying window.
Because each trade only commits roughly $1–2 total, a resting top-of-book quote is realistically fillable at that size even without a matching realized trade — so an unconfirmed signal is no longer automatically discarded here. **When Kalshi shows no realized volume**, the strategy still takes the trade, but conservatively, and only for one specific pairing: **buy Kalshi Yes + buy Polymarket No**, priced directly off Kalshi's real quoted `yes_ask` — a genuine, live, exchange-published top-of-book price that needs no derivation, unlike the No side (Kalshi has no separate No order book; No's true ask is `1 − yes_bid`, a value assembled from two quote fields rather than one directly-quoted price). Polymarket's side is priced the same way it always has been (`1 − price`); with only $1 committed and Polymarket's real depth at that size, that approximation is trusted as-is. The other direction — buy Kalshi No + buy Polymarket Yes — still requires a realized Kalshi trade to confirm; unconfirmed signals in that direction are still skipped entirely. Confirmed trades execute in whichever direction the signal actually calls for, using the correct price for each leg (1 − yes_bid for No, yes_ask for Yes) — the fallback restriction to one direction only applies when there's no realized trade to confirm the fill. Each executed trade is tagged with fill_basis (realized_trade vs quoted_price) so the two populations can be told apart in the results.
Sizing: a flat **TRADE_BUDGET_PER_LEG ($1.00)** committed to each leg — $1 toward Kalshi, $1 toward Polymarket, per trade — rather than a percentage of bankroll. Contract count is still matched equally across both legs (the smaller of what each leg's $1 affords), so one leg may end up spending a little under $1 to keep the hedge exact.
Concurrency: every position stays open until its bucket's actual resolution date — there is no recycling. Capital committed to the very first trade isn't available again until the bucket closes, months later. That means the bankroll can only ever decrease over the course of the backtest, all the way until final settlement, when every open position resolves at once.
Exit: hold to close, unconditionally. No early-exit logic of any kind — every position settles at the bucket's actual resolution, paying the guaranteed $1.00/contract (one side resolves to $1, the other to $0) with no exit fee. Because of this, every trade's outcome is fully determined at entry: PNL is just a function of the entry gap size and the entry fees, with zero exit-timing variance. That's a deliberate, useful contrast with notebook 04, where exit timing was itself part of what was being tested.
No stop loss is used, consistent with both earlier versions of this strategy.
Fees: identical to notebook 04 — Kalshi's confirmed formula and Polymarket's documented (not on-chain-contract) formula, both from src/fees.py. Since every trade holds to close, only entry fees are ever paid; there is no exit fee to compute.
Variables (for later sensitivity testing / heatmaps)¶
ENTRY_GAP_THRESHOLD = 0.05— minimum gross price gap to qualify as an opportunityENTRY_DWELL_TICKS = 1— minute-ticks the gap must sustain before entryOPPORTUNITY_RESET_TICKS = 2— minute-ticks a changed gap value must hold before the previous opportunity is considered closed and a new one can openTRADE_BUDGET_PER_LEG = 1.00— flat dollars committed to each leg (Kalshi and Polymarket) per tradeSTARTING_BANKROLL = 1000— dollarsKALSHI_TAKER_RATE = 0.07,POLYMARKET_TAKER_RATE = 0.10— fee assumptions, fromfees.pySTOP_LOSS = None— disabled, consistent with notebook 04
Metrics to Report¶
Same list as notebook 04, minus the two that no longer apply now that every trade holds to close by construction (exit type, and exit-timing cost — there's no early exit to time):
- Funnel counts, with a reason for each miss — signals detected → Kalshi leg confirmed → executed, labeling why a signal didn't convert. Expect "insufficient available bankroll" to be a far bigger category here than it was in notebook 04 (which never skipped a single trade for lack of capital), since nothing recycles until the very end.
- Holding duration per position — in this version, this is really just "time from entry to that bucket's resolution date," so it's expected to correlate almost entirely with when a trade entered, not with anything else.
- ROI per position and in aggregate
- Bankroll movement over time — expected to be a monotonically non-increasing curve until the very end, unlike notebook 04's up-and-down pattern
- Fee cost per position and in aggregate (entry-only, no exit fees)
- PNL per position and in aggregate
Data Ingestion¶
Same four outcome buckets, same full market lifetimes, same underlying Kalshi/Polymarket data as notebook 04 — so this reuses that notebook's cache directly (data/raw/nb04_*.parquet) rather than re-fetching anything. If that cache doesn't exist yet (e.g. running this notebook fresh, without having run notebook 04 first), it will be fetched and cached under the same filenames.
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):
# Shares notebook 04's cache -- same buckets, same date ranges, same data.
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¶
Same overall shape as notebook 04 (per-bucket detection, then a chronological bankroll pass, then funnel/summary metrics), but the entry step is genuinely new — a state machine implementing the locked-opportunity rule — and the bankroll pass is simpler, since nothing recycles until every bucket resolves at (essentially) the same date.
ENTRY_GAP_THRESHOLD = 0.05
ENTRY_DWELL_TICKS = 1
OPPORTUNITY_RESET_TICKS = 2
TRADE_BUDGET_PER_LEG = 1.00
STARTING_BANKROLL = 1000.0
KALSHI_TAKER_RATE = fees.KALSHI_TAKER_RATE
POLYMARKET_TAKER_RATE = fees.POLYMARKET_TAKER_RATE
STOP_LOSS = None
def kalshi_price_for_side(direction, kalshi_yes_ask, kalshi_yes_bid):
"""direction=+1 -> buying Kalshi No, priced off the true no_ask (1 - yes_bid) --
Kalshi has no separate No order book, so No's ask is mathematically the complement
of Yes's bid, not Yes's ask. direction=-1 -> buying Kalshi Yes, priced off the real
quoted yes_ask directly."""
return (1 - kalshi_yes_bid) 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)
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),
)
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", "yes_bid": "kalshi_yes_bid"}
)
p = poly_deduped.reset_index().rename(columns={"timestamp": "ts", "price": "polymarket_price"})
aligned = pd.merge_asof(
k[["ts", "kalshi_yes_ask", "kalshi_yes_bid"]].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_locked_opportunities(aligned, gap_threshold, reset_ticks):
"""
Walk the aligned gap series tick by tick. A gap reaching |gap| >= threshold opens
an opportunity, locked to that specific (rounded-to-the-cent) value. While locked,
further ticks that still satisfy the threshold do NOT open new entries -- it's the
same opportunity. The lock only releases once the gap has moved to a genuinely
different value (higher, lower, or flipped sign) and held there for `reset_ticks`
consecutive ticks, at which point that new value is freshly eligible for its own
entry if it still qualifies.
Returns a list of dicts: entry_ts, direction, opportunity_end_ts (the tick the
lock was released, or the series' last tick if it was still locked at bucket close
-- used as the Kalshi-confirmation window, mirroring notebook 04's use of the full
qualifying-episode span for that same check).
"""
gaps = aligned["gap"].round(2)
opportunities = []
locked = False
locked_value = None
locked_entry_ts = None
change_streak = 0
change_value = None
for ts, gap in gaps.items():
if not locked:
if abs(gap) >= gap_threshold:
locked = True
locked_value = gap
locked_entry_ts = ts
change_streak = 0
change_value = None
continue
if gap == locked_value:
change_streak = 0
change_value = None
continue
if change_value is not None and gap == change_value:
change_streak += 1
else:
change_value = gap
change_streak = 1
if change_streak >= reset_ticks:
opportunities.append({
"entry_ts": locked_entry_ts,
"direction": int(np.sign(locked_value)),
"opportunity_end_ts": ts,
})
locked = False
locked_value = None
change_streak = 0
change_value = None
if abs(gap) >= gap_threshold:
locked = True
locked_value = gap
locked_entry_ts = ts
if locked:
opportunities.append({
"entry_ts": locked_entry_ts,
"direction": int(np.sign(locked_value)),
"opportunity_end_ts": gaps.index[-1],
})
return opportunities
for key, b in BUCKETS.items():
b["opportunities"] = find_locked_opportunities(b["aligned"], ENTRY_GAP_THRESHOLD, OPPORTUNITY_RESET_TICKS)
print(f"{b['label']}: {len(b['opportunities']):,} distinct locked opportunities")
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():
for opp in b["opportunities"]:
opp["kalshi_confirmed"] = kalshi_confirmed(b["kalshi_trades"], opp["entry_ts"], opp["opportunity_end_ts"])
n_confirmed = sum(1 for opp in b["opportunities"] if opp["kalshi_confirmed"])
print(f"{b['label']}: {n_confirmed:,} / {len(b['opportunities']):,} opportunities had a Kalshi fill (confirmed)")
all_candidates = []
for key, b in BUCKETS.items():
for opp in b["opportunities"]:
direction = opp["direction"]
# Unconfirmed signals are still skipped UNLESS this is the one direction
# (buy Yes Kalshi + No Poly) where the Kalshi leg is priced directly off a
# real quoted price (yes_ask) rather than one assembled from two quote
# fields (no_ask = 1 - yes_bid) -- see strategy note above. The other
# direction still requires a realized Kalshi trade.
if not opp["kalshi_confirmed"] and direction != -1:
continue
entry_ts = opp["entry_ts"]
entry_kalshi_price = kalshi_price_for_side(
direction,
b["aligned"].loc[entry_ts, "kalshi_yes_ask"],
b["aligned"].loc[entry_ts, "kalshi_yes_bid"],
)
entry_poly_price = polymarket_price_for_side(direction, b["aligned"].loc[entry_ts, "polymarket_price"])
all_candidates.append({
"bucket": key,
"bucket_label": b["label"],
"direction": direction,
"entry_ts": entry_ts,
"entry_kalshi_price": entry_kalshi_price,
"entry_poly_price": entry_poly_price,
"bucket_close_ts": b["aligned"].index.max(),
"kalshi_confirmed": opp["kalshi_confirmed"],
"fill_basis": "realized_trade" if opp["kalshi_confirmed"] else "quoted_price",
})
all_candidates.sort(key=lambda c: c["entry_ts"])
n_fallback = sum(1 for c in all_candidates if c["fill_basis"] == "quoted_price")
print(
f"Total candidate trades across all buckets: {len(all_candidates):,} "
f"({len(all_candidates) - n_fallback:,} Kalshi-confirmed, {n_fallback:,} quote-fallback)"
)
available_bankroll = STARTING_BANKROLL
bankroll_curve = [(all_candidates[0]["entry_ts"] if all_candidates else pd.Timestamp.now(tz="UTC"), STARTING_BANKROLL)]
closed_trades = []
funnel_skipped = []
# Nothing recycles until a position's bucket actually resolves, and all four buckets
# resolve at essentially the same date -- so this is one pass through entries in
# chronological order (bankroll only ever decreases), then a single mass-release at
# the end, rather than notebook 04's continuous open/release loop.
for cand in all_candidates:
budget = TRADE_BUDGET_PER_LEG
contracts = int(min(budget / cand["entry_kalshi_price"], budget / cand["entry_poly_price"]))
if contracts < 1:
funnel_skipped.append({**cand, "skip_reason": "budget_too_small_for_1_contract"})
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
capital_committed = entry_cost + entry_fees
if capital_committed > available_bankroll:
funnel_skipped.append({**cand, "skip_reason": "insufficient_available_bankroll"})
continue
exit_proceeds = contracts * 1.00 # guaranteed hold-to-close payout, no exit fee
pnl = exit_proceeds - capital_committed
trade_record = {
**cand,
"contracts": contracts,
"entry_cost": entry_cost,
"entry_fees": entry_fees,
"exit_proceeds": exit_proceeds,
"exit_fees": 0.0,
"kalshi_fees": entry_kalshi_fee,
"poly_fees": entry_poly_fee,
"total_fees": entry_fees,
"pnl": pnl,
"roi": pnl / capital_committed,
"holding_minutes": (cand["bucket_close_ts"] - cand["entry_ts"]).total_seconds() / 60,
}
closed_trades.append(trade_record)
available_bankroll -= capital_committed
bankroll_curve.append((cand["entry_ts"], available_bankroll))
# Mass release: every executed position settles when its bucket resolves.
for t in sorted(closed_trades, key=lambda t: t["bucket_close_ts"]):
available_bankroll += t["exit_proceeds"]
bankroll_curve.append((t["bucket_close_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)
n_skipped_bankroll_check = sum(1 for s in funnel_skipped if s["skip_reason"] == "insufficient_available_bankroll")
print(f"Trades executed: {len(trades_df):,}")
print(f"Opportunities skipped for lack of available bankroll: {n_skipped_bankroll_check:,}")
print(f"Final bankroll: ${available_bankroll:,.2f} (started at ${STARTING_BANKROLL:,.2f})")
funnel_rows = []
for key, b in BUCKETS.items():
n_opps = len(b["opportunities"])
n_confirmed = sum(1 for opp in b["opportunities"] if opp["kalshi_confirmed"])
n_fallback_eligible = sum(
1 for opp in b["opportunities"] if not opp["kalshi_confirmed"] and opp["direction"] == -1
)
n_still_skipped = n_opps - n_confirmed - n_fallback_eligible
funnel_rows.append({
"Bucket": b["label"],
"Opportunities Detected": n_opps,
"Kalshi Confirmed": n_confirmed,
"Unconfirmed, Quote Fallback": n_fallback_eligible,
"Unconfirmed, Skipped": n_still_skipped,
})
funnel_table = pd.DataFrame(funnel_rows)
n_skipped_bankroll = sum(1 for s in funnel_skipped if s["skip_reason"] == "insufficient_available_bankroll")
n_skipped_budget = sum(1 for s in funnel_skipped if s["skip_reason"] == "budget_too_small_for_1_contract")
n_executed = len(trades_df)
n_executed_confirmed = (trades_df["fill_basis"] == "realized_trade").sum()
n_executed_fallback = (trades_df["fill_basis"] == "quoted_price").sum()
n_candidates = funnel_table["Kalshi Confirmed"].sum() + funnel_table["Unconfirmed, Quote Fallback"].sum()
display(Markdown("#### Funnel: Why an Opportunity Did or Did Not Become a Trade"))
display(funnel_table)
print(
f"\nOf {n_candidates:,} candidate opportunities across all buckets "
f"({funnel_table['Kalshi Confirmed'].sum():,} Kalshi-confirmed + "
f"{funnel_table['Unconfirmed, Quote Fallback'].sum():,} quote-fallback):\n"
f" Executed: {n_executed:,} ({n_executed_confirmed:,} realized-trade fills, {n_executed_fallback:,} quote fills)\n"
f" Skipped (insufficient available bankroll): {n_skipped_bankroll:,}\n"
f" Skipped ($1 budget too small for even 1 contract): {n_skipped_budget:,}\n"
f" Never candidates (unconfirmed, other direction): {funnel_table['Unconfirmed, Skipped'].sum():,}"
)
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
n_fallback_trades = (trades_df["fill_basis"] == "quoted_price").sum()
metrics_table = pd.DataFrame(
{
"Metric": [
"Total Trades", "Win Rate", "Total PNL", "Total Fees Paid",
"Starting Bankroll", "Final Bankroll", "Overall ROI", "Avg. Holding Duration",
"Quote-Fallback Trades (unconfirmed)",
],
"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",
f"{n_fallback_trades:,} ({n_fallback_trades / len(trades_df):.1%})",
],
}
)
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),
quote_fallback_trades=("fill_basis", lambda s: (s == "quoted_price").sum()),
).round(2)
display(by_bucket)
Simulation Findings¶
Essentially breakeven: −$0.39 net on the $1,000 starting bankroll (−0.04% ROI) across 187 executed trades, 62.6% win rate.
That's a long way from this notebook's original (buggy) +$4.51 / 100%-win-rate result, and also a much healthier number than the −$36.24 loss the wrong-direction fallback produced when it was tried first. Critically, this isn't a case of the strategy failing to find real mispricings — it found roughly $7.10 of genuine gross profit — fees cost $7.48, essentially the entire edge and then some, landing the strategy at a $0.39 net loss. See Fee Impact immediately below for the full breakdown.
Two things worth flagging up front:
- The direction restriction is doing real work. Only the fallback direction priced off a directly-quoted price (buy Yes Kalshi + No Poly) is included in the unconfirmed population here. The mirror-image direction (buy No Kalshi + Yes Poly, priced off a derived
1 − yes_bid) was tried first and lost badly — −3.6% ROI on 947 trades — and is excluded entirely from this run. Confirmed trades in that direction are still allowed, since a realized fill at least proves the price was genuinely tradeable at that moment. - This reads as a small-trade-size strategy running into a fee floor, not a strategy that can't find edge.
Fee Impact¶
Every bucket that traded found real, positive gross edge. Fees are what erased most of it:
display(Markdown("#### Gross Edge vs. Fees, by Bucket"))
with_gross = trades_df.assign(gross_pnl=trades_df["pnl"] + trades_df["total_fees"])
gross_by_bucket = with_gross.groupby("bucket_label").agg(
trades=("pnl", "count"),
gross_pnl=("gross_pnl", "sum"),
total_fees=("total_fees", "sum"),
net_pnl=("pnl", "sum"),
).round(2)
gross_by_bucket.loc["Total"] = [
with_gross["pnl"].count(),
with_gross["gross_pnl"].sum().round(2),
with_gross["total_fees"].sum().round(2),
with_gross["pnl"].sum().round(2),
]
display(gross_by_bucket)
Two of the three buckets that traded (25bps Decrease, No Change) had their entire gross edge wiped out by fees and landed net negative; only 50+bps Decrease — the bucket with the highest concentration of quote-fallback trades (28 of 31) — had enough edge per trade to clear its fee cost and land net positive.
The arithmetic behind why fees dominate here: TRADE_BUDGET_PER_LEG is $1.00 and `ENTRY_GAP_THRESHOLD` is $0.05 — a nominal 5% edge on a $1 trade. But the edge actually captured on a real fill is almost always smaller than the raw detected gap (spread, timing, and rounding all eat into it before a trade ever executes), while Kalshi's and Polymarket's fees are computed independently of how thin the trade's actual margin turned out to be. At this trade size, a few cents of fee is a large fraction of a few cents of edge — there's very little room for error, and this run shows it: genuine mispricing existed in every bucket that traded, and fees still came out ahead.
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 | Opportunities Detected | Kalshi Confirmed | Quote Fallback (Yes Kalshi/No Poly, unconfirmed) | Skipped (other direction, unconfirmed) |
|---|---|---|---|---|
| No Change | 961 | 53 | 47 | 861 |
| 25bps Decrease | 1,238 | 53 | 3 | 1,182 |
| 50+bps Decrease | 140 | 3 | 28 | 109 |
| 25bps+ Increase | 8 | 0 | 0 | 8 |
| Total | 2,347 | 109 | 78 | 2,160 |
- All 187 candidates (109 confirmed + 78 fallback) executed — 0 skipped for insufficient bankroll, 0 skipped for the $1 budget being too small for even 1 contract. Every opportunity that qualified as a candidate became a trade.
- The dominant "miss" reason by far is direction: 2,160 of 2,347 detected opportunities (92%) were in the excluded direction while unconfirmed — the strategy is deliberately leaving the large majority of raw signals on the table, because that direction's price can't be trusted without a real fill behind it.
- 25bps+ Increase produced zero trades of any kind — all 8 of its detected opportunities were in the excluded direction, and none were Kalshi-confirmed either. A real coverage gap for this bucket, not a bug.
Holding duration: average 1,813.3 hours (~75.6 days) overall — expected for hold-to-close, since duration is really just "time from entry to that bucket's resolution date." Longest average hold was 50+bps Decrease (2,721.4 hours, ~113 days); shortest was No Change (1,628.7 hours, ~67.9 days).
ROI: −0.04% overall (−$0.39 / $1,000). See the by-bucket gross-vs-net table in Fee Impact above for the split.
Bankroll movement: monotonically non-increasing from $1,000 as all 187 positions opened (no recycling), then a single mass release to $999.61 once all four buckets resolved. Unlike the earlier (wrong-direction) run, nothing was skipped for insufficient bankroll — peak capital committed came nowhere close to exhausting the $1,000 pool at this trade volume.
Fee cost: $7.48 total across 187 trades (~4¢/trade average) — small in absolute terms, but larger than the entire net result. Full breakdown in Fee Impact above.
PNL: −$0.39 net (+$7.10 gross, per Fee Impact above).
Other Lessons Learned¶
A real pricing bug, caught and fixed mid-build: the No-Kalshi leg was originally priced as 1 − yes_ask. Kalshi has no separate No order book — No's true ask is 1 − yes_bid, the complement of Yes's bid, not its ask. Since yes_bid ≤ yes_ask always, the old formula understated the true cost of buying No on every direction-+1 trade in this notebook, which is exactly why the original version of this backtest showed a suspicious 100% win rate.
Which direction to trust without a Kalshi fill turned out to matter enormously. Buy Yes Kalshi + No Poly, priced directly off a real quoted yes_ask, contributed positively even when unconfirmed (78 trades, part of the +$7.09 gross edge). Buy No Kalshi + Yes Poly, priced off the derived 1 − yes_bid, lost badly when applied the same way (919 trades, −3.6% ROI) — a large enough loss to be a real, replicable signal rather than noise. The lesson generalizes: a directly-quoted price is trustworthy enough to trade on without a realized fill; an assembled/derived one is not, at least not at this trade size and confirmation looseness.
Fees, not a lack of genuine mispricing, are the binding constraint at this trade size. All three buckets that traded found positive gross edge. The strategy's problem isn't detection — it's that TRADE_BUDGET_PER_LEG ($1) is small enough that Kalshi's and Polymarket's fees, which don't scale down with how thin the actual captured edge is, regularly cost more than the edge itself.