Notebook 12: Unemployment Rate — Full-Ladder Static-Arb Search¶
Summary¶
Example event (January 2026): Polymarket https://polymarket.com/event/january-unemployment-rate-333 | Kalshi https://kalshi.com/markets/kxu3/unemployment/kxu3-26jan
Notebook 11 found that Strategy 4 (Kalshi Above(V−0.1) Yes + Polymarket No(V)) has a genuine worst-case floor of $1 -- it can never pay $0, only $1 or $2 -- and that a 20% take-profit exit strictly improved it (Strategy 4b: 35.1% return on capital invested vs. 25.4% for the naive "any profit" exit, on essentially identical capital).
Strategy 4 always paired Polymarket's point V with the Kalshi threshold immediately below it, Above(V−0.1). This notebook asks the obvious next question: does the floor property hold for every Kalshi threshold below V, not just the nearest one -- and if so, does searching the whole ladder find better opportunities than the adjacent pair alone?
Worked and brute-force verified the general case before writing any backtest code: for Kalshi Above(X) Yes + Polymarket No(V), the worst-case-$1 floor holds for any X < V, not just X = V−0.1. The payoff has three regions instead of two once X isn't adjacent to V:
| Region | Kalshi Above(X) Yes |
Polymarket No(V) |
Combined |
|---|---|---|---|
| actual ≤ X | loses | wins | $1 |
| X < actual < V | wins | wins | $2 |
| actual = V | wins | loses | $1 |
| actual > V | wins | wins | $2 |
Checked all 780 valid (X, V) combinations on a 0.1 grid from 2.0% to 6.0% -- every single one has a worst case of exactly $1, never $0. Moving X further below V actually widens the $2-paying region (the middle band), which is favorable for upside -- but a further-below threshold is also more likely to already be "in the money" (closer to certain), so its price should be higher, making the combined entry cost worse, not better. Whether that tradeoff ever nets positive for a non-adjacent pair, versus Strategy 4's adjacent-only search, is the empirical question this notebook answers with real data rather than assuming either way.
Scope: same 5 months as notebook 11 (January–May 2026; June/July excluded for the candlestick archival gap). Reuses notebook 11's already-ingested data (nb11_ cache) rather than re-fetching -- the underlying market data is identical, only the search logic differs. Tails remain out of scope, same as every strategy tested so far.
Conditions¶
- Entry: for every Polymarket point
V, scan every Kalshi thresholdX < Von that month's ladder (not just the adjacent one) and build a separate tradeable bucket for each valid pair.gap = 1.00 − (kalshi_X_yes_ask + polymarket_no_V_price); enter whengap >= ENTRY_GAP_THRESHOLD, sustainedENTRY_DWELL_MINUTES-- identical mechanism to Strategy 4, just applied across a much larger bucket set. Each(X, V)pair is treated as an independent bucket, same as every prior notebook treats each outcome bucket independently -- worth flagging that buckets sharing the sameValso share exposure to the same Polymarket contract, so they aren't fully independent bets even though the backtest engine treats them that way. - Confirmation: single Kalshi leg (the chosen
X), real trade volume in the window. - Sizing: same
POSITION_SIZE_PCT = 0.05, split two ways, same DRIP schedule reused again. - Exit: uses the 20% take-profit rule directly, not the naive "any profit above floor" rule -- notebook 11 already established it's strictly better on identical capital, so there's no reason to re-test the worse baseline here. Falls back to holding to close (realized $1 or $2, looked up from each month's actual resolved value) if the 20% target is never reached.
- Fees: one Kalshi taker fee + one Polymarket fee, same as Strategy 4 -- not doubled.
Data Ingestion (reusing notebook 11's cache)¶
import sys
import re
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
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 events_config
import fees
sns.set_theme(style="whitegrid")
pd.set_option("display.width", 140)
RAW_DIR = Path.cwd().parent / "data" / "raw"
def load_nb11_cache(cache_name):
return pd.read_parquet(RAW_DIR / f"nb11_{cache_name}.parquet")
def get_kalshi_markets_robust(event_ticker):
for _ in range(3):
markets = kalshi_client.get_historical_markets(event_ticker)
if markets:
return markets
import requests
resp = requests.get(
"https://api.elections.kalshi.com/trade-api/v2/markets",
params={"event_ticker": event_ticker, "limit": 200},
timeout=15,
)
resp.raise_for_status()
return resp.json().get("markets", [])
def polymarket_yes_token(m):
import json as _json
outcomes = m.get("outcomes", [])
token_ids = m.get("clobTokenIds", [])
if isinstance(outcomes, str):
outcomes = _json.loads(outcomes)
if isinstance(token_ids, str):
token_ids = _json.loads(token_ids)
for outcome, token_id in zip(outcomes, token_ids):
if outcome.strip().lower() == "yes":
return token_id
return None
def parse_poly_rung(question):
text = question.lower()
if "\u2264" in question or "or less" in text or "less than or equal" in text:
m = re.search(r"([\d.]+)\s*%", question)
return (float(m.group(1)) if m else None, True)
if "\u2265" in question or "or more" in text or "greater than or equal" in text:
m = re.search(r"([\d.]+)\s*%", question)
return (float(m.group(1)) if m else None, True)
m = re.search(r"([\d.]+)\s*%", question)
return (float(m.group(1)) if m else None, False)
ACTIVE_EVENT_KEYS = events_config.BATCH_8
EVENT_SHORT_LABELS = {
"unemployment_jan_2026": "Jan 2026",
"unemployment_feb_2026": "Feb 2026",
"unemployment_mar_2026": "Mar 2026",
"unemployment_apr_2026": "Apr 2026",
"unemployment_may_2026": "May 2026",
}
EVENTS = {}
for event_key in ACTIVE_EVENT_KEYS:
ev = events_config.EVENTS[event_key]
kalshi_markets = get_kalshi_markets_robust(ev["kalshi_series_ticker"])
polymarket_event = polymarket_client.get_event_by_slug(ev["polymarket_slug"])
poly_markets = polymarket_event["markets"] if polymarket_event else []
kalshi_rungs = {}
for m in sorted(kalshi_markets, key=lambda m: m.get("floor_strike", 0)):
kalshi_rungs[m["ticker"]] = {
"floor_strike": m.get("floor_strike"),
"result": m.get("result"),
"open_time": pd.to_datetime(m["open_time"], utc=True),
"close_time": pd.to_datetime(m["close_time"], utc=True),
}
# The actual resolved value, precisely, from the crossover of the full
# Kalshi ladder (highest "Above X" that resolved YES + 0.1) -- same method
# used to data-verify this whole event category before notebook 10 was built.
yes_floors = [r["floor_strike"] for r in kalshi_rungs.values() if r["result"] == "yes"]
actual_value = round(max(yes_floors) + 0.1, 1) if yes_floors else None
poly_rungs = {}
for m in poly_markets:
token_id = polymarket_yes_token(m)
if token_id is None:
continue
value, is_tail = parse_poly_rung(m.get("question", ""))
if value is None or is_tail:
continue
poly_rungs[m["slug"]] = {
"question": m.get("question"), "V": value, "token_id": token_id, "condition_id": m.get("conditionId"),
"polymarket_fee_rate": fees.POLYMARKET_TAKER_RATE if m.get("feesEnabled") else 0.0,
"open_time": pd.to_datetime(m["startDate"], utc=True), "close_time": pd.to_datetime(m["closedTime"], utc=True),
}
EVENTS[event_key] = {
"label": EVENT_SHORT_LABELS[event_key], "kalshi_rungs": kalshi_rungs,
"poly_rungs": poly_rungs, "actual_value": actual_value,
}
for event_key, ev in EVENTS.items():
print(f"{ev['label']}: actual resolved value = {ev['actual_value']}%, {len(ev['kalshi_rungs'])} Kalshi rungs, {len(ev['poly_rungs'])} Polymarket points")
for event_key, ev in EVENTS.items():
for ticker, r in ev["kalshi_rungs"].items():
r["kalshi_history"] = load_nb11_cache(f"{event_key}__{ticker}__kalshi_history")
r["kalshi_trades"] = load_nb11_cache(f"{event_key}__{ticker}__kalshi_trades")
for slug, r in ev["poly_rungs"].items():
r["polymarket_history"] = load_nb11_cache(f"{event_key}__{slug}__polymarket_history")
r["polymarket_trades"] = load_nb11_cache(f"{event_key}__{slug}__polymarket_trades")
display(Markdown("#### Data Ingestion Complete (reused from notebook 11, no new API calls for price/trade history)"))
Building Every Valid (X, V) Bucket¶
SEARCH_BUCKETS = {}
skipped_search = []
for event_key, ev in EVENTS.items():
kalshi_floors = sorted(r["floor_strike"] for r in ev["kalshi_rungs"].values())
kalshi_by_floor = {r["floor_strike"]: (ticker, r) for ticker, r in ev["kalshi_rungs"].items()}
for slug, prung in ev["poly_rungs"].items():
V = prung["V"]
valid_X = [X for X in kalshi_floors if X < V]
if not valid_X:
skipped_search.append({"event": ev["label"], "slug": slug, "V": V, "reason": "no Kalshi rung below V"})
continue
for X in valid_X:
low_ticker, low_rung = kalshi_by_floor[X]
bucket_key = f"{event_key}__X{X:g}_V{V:g}"
open_ts = max(low_rung["open_time"], prung["open_time"])
close_ts = min(low_rung["close_time"], prung["close_time"])
actual = ev["actual_value"]
settlement_payout = 1.00 if (actual is None or actual <= X or actual == V) else 2.00
is_adjacent = round(V - X, 1) == 0.1
SEARCH_BUCKETS[bucket_key] = {
"event_key": event_key, "label": f"{ev['label']} \u2013 X={X:g}% V={V:g}%{' (adjacent)' if is_adjacent else ''}",
"X": X, "V": V, "is_adjacent": is_adjacent,
"kalshi_low_ticker": low_ticker, "kalshi_low_history": low_rung["kalshi_history"], "kalshi_low_trades": low_rung["kalshi_trades"],
"polymarket_history": prung["polymarket_history"], "polymarket_trades": prung["polymarket_trades"],
"polymarket_fee_rate": prung["polymarket_fee_rate"], "settlement_payout": settlement_payout,
"open_ts": open_ts, "close_ts": close_ts,
}
display(Markdown(f"#### Search Buckets Built: {len(SEARCH_BUCKETS)} (vs. Strategy 4's adjacent-only {sum(1 for b in SEARCH_BUCKETS.values() if b['is_adjacent'])})"))
search_bucket_summary = pd.DataFrame(
[{"Event": ev["label"], "Poly Points": len(ev["poly_rungs"]), "Kalshi Rungs": len(ev["kalshi_rungs"]),
"Buckets Built": sum(1 for b in SEARCH_BUCKETS.values() if b["event_key"] == k)}
for k, ev in EVENTS.items()]
)
display(search_bucket_summary)
if skipped_search:
display(Markdown(f"#### Skipped ({len(skipped_search)})"))
display(pd.DataFrame(skipped_search))
Alignment, Entry, Confirmation, 20%-Take-Profit Exit¶
ENTRY_GAP_THRESHOLD = 0.05
ENTRY_DWELL_MINUTES = 2
POSITION_SIZE_PCT = 0.05
STARTING_BANKROLL = 1000.0
TAKE_PROFIT_PCT = 0.20
DRIP_TIGHT_DAYS = 50
DRIP_LOOSE_DAYS = 75
DRIP_INITIAL_FRACTION = 0.60
DRIP_MID_FRACTION = 0.80
def taker_fee_pair(kalshi_price, poly_price, contracts, polymarket_fee_rate):
return (
fees.kalshi_taker_fee(kalshi_price, contracts),
fees.polymarket_taker_fee(poly_price, contracts, rate=polymarket_fee_rate),
)
for key, b in SEARCH_BUCKETS.items():
low_d = b["kalshi_low_history"][~b["kalshi_low_history"].index.duplicated(keep="first")]
poly_d = b["polymarket_history"][~b["polymarket_history"].index.duplicated(keep="first")]
low = low_d.reset_index().rename(columns={"timestamp": "ts", "yes_ask": "low_yes_ask"})[["ts", "low_yes_ask"]].sort_values("ts")
poly = poly_d.reset_index().rename(columns={"timestamp": "ts", "price": "polymarket_price"})[["ts", "polymarket_price"]].sort_values("ts")
aligned = pd.merge_asof(low, poly, on="ts", tolerance=pd.Timedelta("5min"), direction="backward").dropna()
aligned = aligned[(aligned["ts"] >= b["open_ts"]) & (aligned["ts"] <= b["close_ts"])]
aligned["polymarket_no_price"] = 1 - aligned["polymarket_price"]
aligned["gap"] = 1.00 - (aligned["low_yes_ask"] + aligned["polymarket_no_price"])
b["aligned"] = aligned.set_index("ts").sort_index()
print(f"Aligned price series built for all {len(SEARCH_BUCKETS)} search buckets.")
def find_ladder_episodes(aligned, gap_threshold, dwell_minutes):
qualifies = aligned["gap"] >= gap_threshold
run_id = (qualifies != qualifies.shift()).cumsum()
episodes = aligned[qualifies].groupby(run_id).agg(
start=("gap", lambda s: s.index[0]), end=("gap", lambda s: s.index[-1]), entry_gap=("gap", "first"),
)
if len(episodes) == 0:
episodes["duration_minutes"] = pd.Series(dtype="float64")
return episodes.reset_index(drop=True)
episodes["duration_minutes"] = (episodes["end"] - episodes["start"]).dt.total_seconds() / 60
episodes = episodes[episodes["duration_minutes"] >= dwell_minutes - 1].copy()
return episodes.reset_index(drop=True)
def leg_has_volume(trades, start_ts, end_ts):
return len(trades.loc[start_ts:end_ts]) > 0
total_signals_search = 0
total_confirmed_search = 0
for key, b in SEARCH_BUCKETS.items():
b["entry_episodes"] = find_ladder_episodes(b["aligned"], ENTRY_GAP_THRESHOLD, ENTRY_DWELL_MINUTES)
confirmed_flags = [leg_has_volume(b["kalshi_low_trades"], row["start"], row["end"]) for _, row in b["entry_episodes"].iterrows()]
b["entry_episodes"]["kalshi_confirmed"] = confirmed_flags
total_signals_search += len(b["entry_episodes"])
total_confirmed_search += sum(confirmed_flags)
print(f"Total signals across all buckets: {total_signals_search:,}")
print(f"Total confirmed (Kalshi leg had volume): {total_confirmed_search:,}")
def simulate_ladder_exit_pct(aligned, entry_ts, entry_cost, take_profit_pct, bucket_close_ts, polymarket_fee_rate):
forward = aligned.loc[entry_ts:bucket_close_ts]
if len(forward) < 2:
return None
target_value = entry_cost * (1 + take_profit_pct)
for i in range(len(forward)):
ts = forward.index[i]
low_yes_ask = forward["low_yes_ask"].iloc[i]
poly_no_price = forward["polymarket_no_price"].iloc[i]
low_fee, _ = taker_fee_pair(low_yes_ask, 0, 1, 0)
_, poly_fee = taker_fee_pair(0, poly_no_price, 1, polymarket_fee_rate)
current_value_net = (low_yes_ask + poly_no_price) - (low_fee + poly_fee)
if current_value_net >= target_value:
return {"exit_type": "profit_target", "exit_ts": ts, "exit_low_yes_ask": low_yes_ask, "exit_poly_no_price": poly_no_price}
return {"exit_type": "held_to_close", "exit_ts": None, "exit_low_yes_ask": None, "exit_poly_no_price": None}
all_search_candidates = []
n_skipped_no_forward_data_search = 0
for key, b in SEARCH_BUCKETS.items():
bucket_close_ts = b["aligned"].index.max() if len(b["aligned"]) else None
for _, row in b["entry_episodes"].iterrows():
if not row["kalshi_confirmed"] or bucket_close_ts is None:
continue
entry_ts = row["end"]
entry_low_price = b["aligned"].loc[entry_ts, "low_yes_ask"]
entry_poly_no_price = b["aligned"].loc[entry_ts, "polymarket_no_price"]
entry_cost = entry_low_price + entry_poly_no_price
exit_result = simulate_ladder_exit_pct(b["aligned"], entry_ts, entry_cost, TAKE_PROFIT_PCT, bucket_close_ts, b["polymarket_fee_rate"])
if exit_result is None:
n_skipped_no_forward_data_search += 1
continue
trade = {
"bucket": key, "bucket_label": b["label"], "event_key": b["event_key"], "X": b["X"], "V": b["V"], "is_adjacent": b["is_adjacent"],
"entry_ts": entry_ts, "entry_low_price": entry_low_price, "entry_poly_no_price": entry_poly_no_price,
"entry_gap": row["entry_gap"], "exit_type": exit_result["exit_type"],
"polymarket_fee_rate": b["polymarket_fee_rate"], "settlement_payout": b["settlement_payout"],
}
if exit_result["exit_type"] == "profit_target":
trade["exit_ts"] = exit_result["exit_ts"]
trade["exit_low_price"] = exit_result["exit_low_yes_ask"]
trade["exit_poly_no_price"] = exit_result["exit_poly_no_price"]
else:
trade["exit_ts"] = b["aligned"].index.max()
trade["exit_low_price"] = None
trade["exit_poly_no_price"] = None
all_search_candidates.append(trade)
all_search_candidates.sort(key=lambda t: t["entry_ts"])
print(f"Total confirmed candidate trades: {len(all_search_candidates):,}")
if n_skipped_no_forward_data_search:
print(f"(Confirmed but excluded for insufficient forward data before bucket close: {n_skipped_no_forward_data_search:,})")
n_adjacent_trades = sum(1 for t in all_search_candidates if t["is_adjacent"])
n_nonadjacent_trades = len(all_search_candidates) - n_adjacent_trades
print(f"Of which adjacent (X=V-0.1, same as Strategy 4): {n_adjacent_trades:,}")
print(f"Of which non-adjacent (X < V-0.1, new to this notebook): {n_nonadjacent_trades:,}")
DRIP-Aware Bankroll Pass¶
SEARCH_CLOSE_DATES = sorted(set(b["close_ts"] for b in SEARCH_BUCKETS.values()))
def search_drip_ceiling(ts, starting_bankroll):
future_closes = [d for d in SEARCH_CLOSE_DATES if d > ts]
if not future_closes:
return starting_bankroll
days = (min(future_closes) - ts).total_seconds() / 86400
if days > DRIP_LOOSE_DAYS:
return DRIP_INITIAL_FRACTION * starting_bankroll
elif days > DRIP_TIGHT_DAYS:
return DRIP_MID_FRACTION * starting_bankroll
return starting_bankroll
search_available_bankroll = STARTING_BANKROLL
search_bankroll_curve = [(all_search_candidates[0]["entry_ts"] if all_search_candidates else pd.Timestamp.now(tz="UTC"), STARTING_BANKROLL)]
search_open_positions = []
search_closed_trades = []
n_skipped_drip_search = 0
n_skipped_bankroll_search = 0
for cand in all_search_candidates:
still_open = []
for pos in search_open_positions:
if pos["exit_ts"] <= cand["entry_ts"]:
search_available_bankroll += pos["capital_committed"] + pos["pnl"]
search_bankroll_curve.append((pos["exit_ts"], search_available_bankroll))
else:
still_open.append(pos)
search_open_positions = still_open
ceiling = search_drip_ceiling(cand["entry_ts"], STARTING_BANKROLL)
usable = min(search_available_bankroll, ceiling)
budget = usable * POSITION_SIZE_PCT
sub_budget = budget / 2
contracts = int(min(sub_budget / cand["entry_low_price"], sub_budget / cand["entry_poly_no_price"]))
if contracts < 1:
if usable < search_available_bankroll:
n_skipped_drip_search += 1
else:
n_skipped_bankroll_search += 1
continue
entry_cost = contracts * (cand["entry_low_price"] + cand["entry_poly_no_price"])
entry_low_fee, _ = taker_fee_pair(cand["entry_low_price"], 0, contracts, 0)
_, entry_poly_fee = taker_fee_pair(0, cand["entry_poly_no_price"], contracts, cand["polymarket_fee_rate"])
entry_fees = entry_low_fee + entry_poly_fee
if cand["exit_type"] == "profit_target":
exit_proceeds = contracts * (cand["exit_low_price"] + cand["exit_poly_no_price"])
exit_low_fee, _ = taker_fee_pair(cand["exit_low_price"], 0, contracts, 0)
_, exit_poly_fee = taker_fee_pair(0, cand["exit_poly_no_price"], contracts, cand["polymarket_fee_rate"])
exit_fees = exit_low_fee + exit_poly_fee
else:
exit_proceeds = contracts * cand["settlement_payout"]
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, "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,
}
search_closed_trades.append(trade_record)
search_available_bankroll -= capital_committed
search_bankroll_curve.append((cand["entry_ts"], search_available_bankroll))
search_open_positions.append({"exit_ts": cand["exit_ts"], "capital_committed": capital_committed, "pnl": pnl})
for pos in sorted(search_open_positions, key=lambda p: p["exit_ts"]):
search_available_bankroll += pos["capital_committed"] + pos["pnl"]
search_bankroll_curve.append((pos["exit_ts"], search_available_bankroll))
search_trades_df = pd.DataFrame(search_closed_trades)
search_bankroll_df = pd.DataFrame(search_bankroll_curve, columns=["ts", "bankroll"]).sort_values("ts").reset_index(drop=True)
print(f"Trades executed: {len(search_trades_df):,}")
print(f"Trades skipped (DRIP ceiling): {n_skipped_drip_search:,}")
print(f"Trades skipped (insufficient available bankroll): {n_skipped_bankroll_search:,}")
print(f"Final bankroll: ${search_available_bankroll:,.2f} (started at ${STARTING_BANKROLL:,.2f})")
PNL Table¶
display(Markdown("#### Aggregate Backtest Metrics -- Full-Ladder Search"))
if len(search_trades_df):
total_pnl_search = search_trades_df["pnl"].sum()
total_fees_search = search_trades_df["total_fees"].sum()
win_rate_search = (search_trades_df["pnl"] > 0).mean()
capital_invested_search = search_trades_df["entry_cost"].sum()
return_on_capital_search = total_pnl_search / capital_invested_search if capital_invested_search else float("nan")
avg_holding_hours_search = search_trades_df["holding_minutes"].mean() / 60
else:
total_pnl_search = total_fees_search = win_rate_search = capital_invested_search = return_on_capital_search = avg_holding_hours_search = 0.0
metrics_table_search = pd.DataFrame(
{
"Metric": [
"Signals Detected", "Kalshi Confirmed", "Total Trades", "Win Rate", "Total PNL", "Total Fees Paid",
"Capital Invested", "Return on Capital Invested", "Final Bankroll", "Overall ROI (vs. starting bankroll)", "Avg. Holding Duration",
],
"Value": [
f"{total_signals_search:,}", f"{total_confirmed_search:,}", f"{len(search_trades_df):,}", f"{win_rate_search:.1%}",
f"${total_pnl_search:,.2f}", f"${total_fees_search:,.2f}", f"${capital_invested_search:,.2f}", f"{return_on_capital_search:.1%}",
f"${search_available_bankroll:,.2f}", f"{(search_available_bankroll - STARTING_BANKROLL) / STARTING_BANKROLL:.1%}",
f"{avg_holding_hours_search:,.1f} hours",
],
}
)
display(metrics_table_search)
if len(search_trades_df):
display(Markdown("#### Adjacent (Strategy 4) vs. Non-Adjacent (New) -- Does Searching Wider Help?"))
adjacency_table = search_trades_df.groupby("is_adjacent").agg(
trades=("pnl", "count"), win_rate=("pnl", lambda s: (s > 0).mean()),
total_pnl=("pnl", "sum"), total_fees=("total_fees", "sum"), capital_invested=("entry_cost", "sum"),
).round(2)
adjacency_table["return_on_capital"] = (adjacency_table["total_pnl"] / adjacency_table["capital_invested"]).round(4)
adjacency_table.index = adjacency_table.index.map({True: "Adjacent (X=V-0.1)", False: "Non-adjacent (X<V-0.1)"})
display(adjacency_table)
display(Markdown("#### By Event"))
by_event_search = search_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_search)
display(Markdown("#### By Bucket (top 15 by PNL)"))
by_bucket_search = search_trades_df.groupby("bucket_label").agg(
trades=("pnl", "count"), win_rate=("pnl", lambda s: (s > 0).mean()),
total_pnl=("pnl", "sum"), total_fees=("total_fees", "sum"),
).round(2).sort_values("total_pnl", ascending=False).head(15)
display(by_bucket_search)
else:
display(Markdown("**No trades executed.**"))
Visualization¶
if len(search_trades_df):
fig, axes = plt.subplots(2, 1, figsize=(16, 11), gridspec_kw={"height_ratios": [1.4, 1]})
ax = axes[0]
ax.plot(search_bankroll_df["ts"], search_bankroll_df["bankroll"], color="mediumpurple", 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(search_bankroll_df["ts"], search_bankroll_df["bankroll"], STARTING_BANKROLL,
where=(search_bankroll_df["bankroll"] >= STARTING_BANKROLL), color="seagreen", alpha=0.15, step="post", zorder=0)
ax.fill_between(search_bankroll_df["ts"], search_bankroll_df["bankroll"], STARTING_BANKROLL,
where=(search_bankroll_df["bankroll"] < STARTING_BANKROLL), color="crimson", alpha=0.15, step="post", zorder=0)
event_resolution_dates_search = {}
for key, b in SEARCH_BUCKETS.items():
event_resolution_dates_search.setdefault(b["event_key"], b["close_ts"])
for event_key, ts in event_resolution_dates_search.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 = search_bankroll_df["ts"].iloc[-1]
final_value = search_bankroll_df["bankroll"].iloc[-1]
ax.scatter([final_ts], [final_value], color="mediumpurple", 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 Full-Ladder Static-Arb Search", fontsize=12)
ax.legend(loc="upper left", fontsize=9)
ax2 = axes[1]
adj_plot = search_trades_df.groupby("is_adjacent").agg(pnl=("pnl", "sum"), trades=("pnl", "count")).reindex([True, False])
labels = ["Adjacent\n(Strategy 4)", "Non-adjacent\n(new)"]
colors = ["steelblue", "mediumpurple"]
bars = ax2.bar(labels, adj_plot["pnl"], color=colors)
for bar, n in zip(bars, adj_plot["trades"]):
ax2.annotate(f"{int(n) if pd.notna(n) else 0} trades", xy=(bar.get_x() + bar.get_width() / 2, bar.get_height()),
xytext=(0, 4), textcoords="offset points", ha="center", fontsize=9)
ax2.axhline(0, color="black", linewidth=0.8)
ax2.set_ylabel("Total PNL ($)")
ax2.set_title("PNL: Adjacent vs. Non-Adjacent Pairs", fontsize=12)
plt.tight_layout()
plt.show()
peak_search = search_bankroll_df["bankroll"].max()
trough_search = search_bankroll_df["bankroll"].min()
print(f"Peak bankroll: ${peak_search:,.2f} | Trough bankroll: ${trough_search:,.2f} | Max drawdown from peak: ${peak_search - trough_search:,.2f}")
else:
print("No trades executed -- nothing to plot.")
Summary¶
Note on the exit rule already used above: this run already uses the 20% take-profit exit, reused directly from notebook 11's finding that it beats the naive "any profit above floor" rule -- not the baseline rule Strategy 4 originally tested. The comparison section below builds the missing baseline (naive exit) version of the full-ladder search specifically to check whether the same improvement holds once the bucket set expands beyond the adjacent pair, rather than assuming it carries over unchanged.
20 trades (17 adjacent + 3 new non-adjacent), 100% win rate, $218.93 PNL, $218.93 / $633.56 = 34.6% return on capital invested, 21.9% overall ROI.
| Metric | Value |
|---|---|
| Signals Detected | 69 |
| Kalshi Confirmed | 20 |
| Total Trades | 20 |
| Win Rate | 100% |
| Total PNL | $218.93 |
| Total Fees | $18.61 |
| Capital Invested | $633.56 |
| Return on Capital Invested | 34.6% |
| Overall ROI (vs. starting bankroll) | 21.9% |
| Avg. Holding Duration | 257.7 hours |
| Peak / Trough Bankroll | $1,218.93 / $834.84 |
| Max Drawdown | $384.09 |
Does searching the wider ladder actually help, or just find worse copies of the same trade? Real answer, not assumed:
| Trades | Total PNL | Return on Capital | |
|---|---|---|---|
| Adjacent (Strategy 4's pairing) | 17 | $187.10 | 34.87% |
| Non-adjacent (new to this search) | 3 | $31.83 | 32.82% |
Both. The 3 non-adjacent trades are genuinely profitable (100% win rate held) and added $31.83 that a Strategy-4-only search would have missed entirely -- but at a slightly lower capital efficiency (32.8% vs 34.9%), exactly matching the cost-structure reasoning laid out in the Summary above the code: a Kalshi threshold further below V is more likely already priced near certain, so it costs more to buy, eating into the margin. The wider search doesn't uncover a hidden goldmine -- it finds smaller, slightly-less-efficient versions of the same real opportunity, which is itself a useful confirmation that the theory and the data agree.
By event: January remained the largest contributor ($147.02 on 9 trades), May second ($58.32 on 5 trades, the same $1.41 price-overshoot pattern documented in notebook 11 likely repeating here given the long 576.5-hour average hold). February and April were modest ($4.14 and $9.43). March produced zero executed trades again -- the third strategy variant in a row (Strategy 4, 4b, and now this) to find nothing tradeable in March despite 21 buckets built, which points to something structural about that month's price behavior rather than a quirk of any one exit rule.
Worth remembering: all of this rests on 20 trades across 5 months. The 100% win rate held again, consistent with the mathematically guaranteed floor, but the sample is still small enough that "consistent" shouldn't yet be read as "statistically settled."
Comparison: Full-Ladder Search with Baseline Exit vs. 20% Take-Profit¶
Builds the missing baseline variant -- same buckets, same entries, same confirmation as above, but exits the instant selling covers any amount above the guaranteed $1 floor (Strategy 4's original rule), instead of holding for a 20% gain. Reuses SEARCH_BUCKETS and each bucket's already-computed entry_episodes -- only the exit simulation and the candidate/bankroll pass are rebuilt.
def simulate_ladder_exit_floor(aligned, entry_ts, bucket_close_ts, polymarket_fee_rate):
forward = aligned.loc[entry_ts:bucket_close_ts]
if len(forward) < 2:
return None
FLOOR_VALUE = 1.00
for i in range(len(forward)):
ts = forward.index[i]
low_yes_ask = forward["low_yes_ask"].iloc[i]
poly_no_price = forward["polymarket_no_price"].iloc[i]
low_fee, _ = taker_fee_pair(low_yes_ask, 0, 1, 0)
_, poly_fee = taker_fee_pair(0, poly_no_price, 1, polymarket_fee_rate)
current_value_net = (low_yes_ask + poly_no_price) - (low_fee + poly_fee)
if current_value_net > FLOOR_VALUE:
return {"exit_type": "profit_target", "exit_ts": ts, "exit_low_yes_ask": low_yes_ask, "exit_poly_no_price": poly_no_price}
return {"exit_type": "held_to_close", "exit_ts": None, "exit_low_yes_ask": None, "exit_poly_no_price": None}
all_search_baseline_candidates = []
n_skipped_no_forward_data_baseline = 0
for key, b in SEARCH_BUCKETS.items():
bucket_close_ts = b["aligned"].index.max() if len(b["aligned"]) else None
for _, row in b["entry_episodes"].iterrows():
if not row["kalshi_confirmed"] or bucket_close_ts is None:
continue
entry_ts = row["end"]
entry_low_price = b["aligned"].loc[entry_ts, "low_yes_ask"]
entry_poly_no_price = b["aligned"].loc[entry_ts, "polymarket_no_price"]
exit_result = simulate_ladder_exit_floor(b["aligned"], entry_ts, bucket_close_ts, b["polymarket_fee_rate"])
if exit_result is None:
n_skipped_no_forward_data_baseline += 1
continue
trade = {
"bucket": key, "bucket_label": b["label"], "event_key": b["event_key"], "X": b["X"], "V": b["V"], "is_adjacent": b["is_adjacent"],
"entry_ts": entry_ts, "entry_low_price": entry_low_price, "entry_poly_no_price": entry_poly_no_price,
"entry_gap": row["entry_gap"], "exit_type": exit_result["exit_type"],
"polymarket_fee_rate": b["polymarket_fee_rate"], "settlement_payout": b["settlement_payout"],
}
if exit_result["exit_type"] == "profit_target":
trade["exit_ts"] = exit_result["exit_ts"]
trade["exit_low_price"] = exit_result["exit_low_yes_ask"]
trade["exit_poly_no_price"] = exit_result["exit_poly_no_price"]
else:
trade["exit_ts"] = b["aligned"].index.max()
trade["exit_low_price"] = None
trade["exit_poly_no_price"] = None
all_search_baseline_candidates.append(trade)
all_search_baseline_candidates.sort(key=lambda t: t["entry_ts"])
print(f"Total confirmed candidate trades (baseline exit): {len(all_search_baseline_candidates):,}")
if n_skipped_no_forward_data_baseline:
print(f"(Confirmed but excluded for insufficient forward data before bucket close: {n_skipped_no_forward_data_baseline:,})")
search_baseline_available_bankroll = STARTING_BANKROLL
search_baseline_bankroll_curve = [(all_search_baseline_candidates[0]["entry_ts"] if all_search_baseline_candidates else pd.Timestamp.now(tz="UTC"), STARTING_BANKROLL)]
search_baseline_open_positions = []
search_baseline_closed_trades = []
n_skipped_drip_baseline = 0
n_skipped_bankroll_baseline = 0
for cand in all_search_baseline_candidates:
still_open = []
for pos in search_baseline_open_positions:
if pos["exit_ts"] <= cand["entry_ts"]:
search_baseline_available_bankroll += pos["capital_committed"] + pos["pnl"]
search_baseline_bankroll_curve.append((pos["exit_ts"], search_baseline_available_bankroll))
else:
still_open.append(pos)
search_baseline_open_positions = still_open
ceiling = search_drip_ceiling(cand["entry_ts"], STARTING_BANKROLL)
usable = min(search_baseline_available_bankroll, ceiling)
budget = usable * POSITION_SIZE_PCT
sub_budget = budget / 2
contracts = int(min(sub_budget / cand["entry_low_price"], sub_budget / cand["entry_poly_no_price"]))
if contracts < 1:
if usable < search_baseline_available_bankroll:
n_skipped_drip_baseline += 1
else:
n_skipped_bankroll_baseline += 1
continue
entry_cost = contracts * (cand["entry_low_price"] + cand["entry_poly_no_price"])
entry_low_fee, _ = taker_fee_pair(cand["entry_low_price"], 0, contracts, 0)
_, entry_poly_fee = taker_fee_pair(0, cand["entry_poly_no_price"], contracts, cand["polymarket_fee_rate"])
entry_fees = entry_low_fee + entry_poly_fee
if cand["exit_type"] == "profit_target":
exit_proceeds = contracts * (cand["exit_low_price"] + cand["exit_poly_no_price"])
exit_low_fee, _ = taker_fee_pair(cand["exit_low_price"], 0, contracts, 0)
_, exit_poly_fee = taker_fee_pair(0, cand["exit_poly_no_price"], contracts, cand["polymarket_fee_rate"])
exit_fees = exit_low_fee + exit_poly_fee
else:
exit_proceeds = contracts * cand["settlement_payout"]
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, "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,
}
search_baseline_closed_trades.append(trade_record)
search_baseline_available_bankroll -= capital_committed
search_baseline_bankroll_curve.append((cand["entry_ts"], search_baseline_available_bankroll))
search_baseline_open_positions.append({"exit_ts": cand["exit_ts"], "capital_committed": capital_committed, "pnl": pnl})
for pos in sorted(search_baseline_open_positions, key=lambda p: p["exit_ts"]):
search_baseline_available_bankroll += pos["capital_committed"] + pos["pnl"]
search_baseline_bankroll_curve.append((pos["exit_ts"], search_baseline_available_bankroll))
search_baseline_trades_df = pd.DataFrame(search_baseline_closed_trades)
search_baseline_bankroll_df = pd.DataFrame(search_baseline_bankroll_curve, columns=["ts", "bankroll"]).sort_values("ts").reset_index(drop=True)
print(f"Trades executed (baseline exit): {len(search_baseline_trades_df):,}")
print(f"Final bankroll: ${search_baseline_available_bankroll:,.2f} (started at ${STARTING_BANKROLL:,.2f})")
Third Variant: Held Till Close (No Early Exit at All)¶
Same entries, same confirmation, same buckets -- but never exits early regardless of price. Every position rides to the actual event resolution and collects whatever the real settlement pays ($1.00 or $2.00 per contract, looked up from each bucket's already-known actual outcome). This is the natural third point of comparison: baseline exits at the first cent of profit, 20% TP exits at a real target, and this variant never exits early at all.
all_search_htc_candidates = []
for key, b in SEARCH_BUCKETS.items():
bucket_close_ts = b["aligned"].index.max() if len(b["aligned"]) else None
for _, row in b["entry_episodes"].iterrows():
if not row["kalshi_confirmed"] or bucket_close_ts is None:
continue
entry_ts = row["end"]
forward = b["aligned"].loc[entry_ts:bucket_close_ts]
if len(forward) < 2:
continue
entry_low_price = b["aligned"].loc[entry_ts, "low_yes_ask"]
entry_poly_no_price = b["aligned"].loc[entry_ts, "polymarket_no_price"]
trade = {
"bucket": key, "bucket_label": b["label"], "event_key": b["event_key"], "X": b["X"], "V": b["V"], "is_adjacent": b["is_adjacent"],
"entry_ts": entry_ts, "entry_low_price": entry_low_price, "entry_poly_no_price": entry_poly_no_price,
"entry_gap": row["entry_gap"], "exit_type": "held_to_close",
"polymarket_fee_rate": b["polymarket_fee_rate"], "settlement_payout": b["settlement_payout"],
"exit_ts": bucket_close_ts, "exit_low_price": None, "exit_poly_no_price": None,
}
all_search_htc_candidates.append(trade)
all_search_htc_candidates.sort(key=lambda t: t["entry_ts"])
print(f"Total confirmed candidate trades (held till close): {len(all_search_htc_candidates):,}")
search_htc_available_bankroll = STARTING_BANKROLL
search_htc_bankroll_curve = [(all_search_htc_candidates[0]["entry_ts"] if all_search_htc_candidates else pd.Timestamp.now(tz="UTC"), STARTING_BANKROLL)]
search_htc_open_positions = []
search_htc_closed_trades = []
for cand in all_search_htc_candidates:
still_open = []
for pos in search_htc_open_positions:
if pos["exit_ts"] <= cand["entry_ts"]:
search_htc_available_bankroll += pos["capital_committed"] + pos["pnl"]
search_htc_bankroll_curve.append((pos["exit_ts"], search_htc_available_bankroll))
else:
still_open.append(pos)
search_htc_open_positions = still_open
ceiling = search_drip_ceiling(cand["entry_ts"], STARTING_BANKROLL)
usable = min(search_htc_available_bankroll, ceiling)
budget = usable * POSITION_SIZE_PCT
sub_budget = budget / 2
contracts = int(min(sub_budget / cand["entry_low_price"], sub_budget / cand["entry_poly_no_price"]))
if contracts < 1:
continue
entry_cost = contracts * (cand["entry_low_price"] + cand["entry_poly_no_price"])
entry_low_fee, _ = taker_fee_pair(cand["entry_low_price"], 0, contracts, 0)
_, entry_poly_fee = taker_fee_pair(0, cand["entry_poly_no_price"], contracts, cand["polymarket_fee_rate"])
entry_fees = entry_low_fee + entry_poly_fee
exit_proceeds = contracts * cand["settlement_payout"]
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, "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,
}
search_htc_closed_trades.append(trade_record)
search_htc_available_bankroll -= capital_committed
search_htc_bankroll_curve.append((cand["entry_ts"], search_htc_available_bankroll))
search_htc_open_positions.append({"exit_ts": cand["exit_ts"], "capital_committed": capital_committed, "pnl": pnl})
for pos in sorted(search_htc_open_positions, key=lambda p: p["exit_ts"]):
search_htc_available_bankroll += pos["capital_committed"] + pos["pnl"]
search_htc_bankroll_curve.append((pos["exit_ts"], search_htc_available_bankroll))
search_htc_trades_df = pd.DataFrame(search_htc_closed_trades)
search_htc_bankroll_df = pd.DataFrame(search_htc_bankroll_curve, columns=["ts", "bankroll"]).sort_values("ts").reset_index(drop=True)
print(f"Trades executed (held till close): {len(search_htc_trades_df):,}")
print(f"Final bankroll: ${search_htc_available_bankroll:,.2f} (started at ${STARTING_BANKROLL:,.2f})")
display(Markdown("#### Comparison: Full-Ladder Search, Baseline Exit vs. 20% Take-Profit vs. Held Till Close"))
def exit_price_series(df):
"""Per-contract value actually received at exit -- the sale price for
trades that exited early, the realized settlement payout for trades that
held to close."""
if not len(df):
return pd.Series(dtype="float64")
is_profit_target = df["exit_type"] == "profit_target"
sale_price = df["exit_low_price"].fillna(0) + df["exit_poly_no_price"].fillna(0)
return sale_price.where(is_profit_target, df["settlement_payout"])
capital_invested_fl_baseline = search_baseline_trades_df["entry_cost"].sum() if len(search_baseline_trades_df) else 0.0
return_on_capital_fl_baseline = search_baseline_trades_df["pnl"].sum() / capital_invested_fl_baseline if capital_invested_fl_baseline else float("nan")
avg_exit_price_baseline = exit_price_series(search_baseline_trades_df).mean() if len(search_baseline_trades_df) else float("nan")
avg_exit_price_tp = exit_price_series(search_trades_df).mean() if len(search_trades_df) else float("nan")
capital_invested_htc = search_htc_trades_df["entry_cost"].sum() if len(search_htc_trades_df) else 0.0
return_on_capital_htc = search_htc_trades_df["pnl"].sum() / capital_invested_htc if capital_invested_htc else float("nan")
avg_exit_price_htc = exit_price_series(search_htc_trades_df).mean() if len(search_htc_trades_df) else float("nan")
avg_holding_hours_htc = search_htc_trades_df["holding_minutes"].mean() / 60 if len(search_htc_trades_df) else float("nan")
fl_compare_table = pd.DataFrame(
{
"Metric": [
"Total Trades", "Win Rate", "Investment", "Total PNL", "Total Fees Paid",
"Average Exit Price", "Return on Investment", "Final Bankroll", "Overall ROI (vs. starting bankroll)", "Avg. Holding Duration",
],
"Baseline Exit": [
f"{len(search_baseline_trades_df):,}", f"{(search_baseline_trades_df['pnl'] > 0).mean():.1%}" if len(search_baseline_trades_df) else "n/a",
f"${capital_invested_fl_baseline:,.2f}",
f"${search_baseline_trades_df['pnl'].sum():,.2f}", f"${search_baseline_trades_df['total_fees'].sum():,.2f}",
f"${avg_exit_price_baseline:,.4f}", f"{return_on_capital_fl_baseline:.1%}",
f"${search_baseline_available_bankroll:,.2f}",
f"{(search_baseline_available_bankroll - STARTING_BANKROLL) / STARTING_BANKROLL:.1%}",
f"{search_baseline_trades_df['holding_minutes'].mean() / 60:,.1f} hours" if len(search_baseline_trades_df) else "n/a",
],
"20% Take-Profit": [
f"{len(search_trades_df):,}", f"{win_rate_search:.1%}", f"${capital_invested_search:,.2f}",
f"${total_pnl_search:,.2f}", f"${total_fees_search:,.2f}",
f"${avg_exit_price_tp:,.4f}", f"{return_on_capital_search:.1%}",
f"${search_available_bankroll:,.2f}", f"{(search_available_bankroll - STARTING_BANKROLL) / STARTING_BANKROLL:.1%}",
f"{avg_holding_hours_search:,.1f} hours",
],
"Held Till Close": [
f"{len(search_htc_trades_df):,}", f"{(search_htc_trades_df['pnl'] > 0).mean():.1%}" if len(search_htc_trades_df) else "n/a",
f"${capital_invested_htc:,.2f}",
f"${search_htc_trades_df['pnl'].sum():,.2f}" if len(search_htc_trades_df) else "$0.00",
f"${search_htc_trades_df['total_fees'].sum():,.2f}" if len(search_htc_trades_df) else "$0.00",
f"${avg_exit_price_htc:,.4f}", f"{return_on_capital_htc:.1%}",
f"${search_htc_available_bankroll:,.2f}",
f"{(search_htc_available_bankroll - STARTING_BANKROLL) / STARTING_BANKROLL:.1%}",
f"{avg_holding_hours_htc:,.1f} hours",
],
}
)
display(fl_compare_table)
Comparison Summary¶
The 20% take-profit improvement generalizes beyond the adjacent-pair case -- confirmed, not assumed. Same 20 trades, same 100% win rate, essentially identical investment, but the take-profit exit produced meaningfully more profit:
| Metric | Baseline Exit | 20% Take-Profit |
|---|---|---|
| Total Trades | 20 | 20 |
| Win Rate | 100% | 100% |
| Investment | $633.36 | $633.56 |
| Total PNL | $142.85 | $218.93 |
| Total Fees | $20.03 | $18.61 |
| Return on Investment | 22.6% | 34.6% |
| Overall ROI (vs. starting bankroll) | 14.3% | 21.9% |
| Avg. Holding Duration | 13.6 hours | 257.7 hours |
Investment came out within $0.20 of each other between the two runs, so the PNL difference is a real, capital-normalized improvement, not an artifact of one variant deploying more money -- the same check applied to Strategy 4 vs. 4b in notebook 11.
The mechanism is the same one documented there too: the baseline rule sells into the first cent of profit above the guaranteed floor, missing most of a temporary price overshoot that shows up before settlement (the combined price spiked to $1.41 on several May trades before settling back to $1.00). The 20% bar holds out for a real target, capturing a much bigger share of that overshoot when it happens, at the cost of capital sitting tied up roughly 19x longer per trade on average. Fees came in slightly lower with the take-profit rule too, for the same reason -- more trades hold to close, which carries no exit fee, instead of paying to exit at the first sign of any profit.
Why ROI differs so much when trades, win rate, and investment all look identical -- verified per-trade, not assumed. The $1.42 fee difference in the table above cannot explain a $76.08 PNL gap on its own. Pulled the actual exit price for all 20 trades under both rules to find the real driver:
- 7 of 20 trades: identical exit price for both rules -- the market price jumped straight past both thresholds within the same 1-minute candle, so there was no early premium sitting there for baseline to grab ahead of the take-profit rule.
- 5 of 20 trades: baseline actually did marginally better -- all cases where the 20% target was never reached, so the take-profit rule just held to the flat $1.00 floor, giving up a few cents versus baseline's early sale.
- 8 of 20 trades: the take-profit rule won decisively -- most strikingly four May trades where baseline sold around $1.01-$1.045 for gains of just $0.065-$0.15, while the combined price later spiked to **$1.41** before settling back to $1.00, and the take-profit rule captured most of that swing instead (gains of $0.465-$0.515 on the same four trades).
Summed per-contract across all 20 trades: baseline captured about $4.22 of gain, the take-profit rule about $6.55 -- a ~55% gap, tracking almost exactly with the real $142.85 -> $218.93 PNL difference (~53%). That gap is the mechanism; fees are a rounding error by comparison.
When does the baseline rule actually exit? The instant the combined sellable value (Kalshi leg's ask price + Polymarket No's price, net of fees) first ticks above $1.00 -- no buffer, no target, just the first cent of profit available. Across this 20-trade set that happened anywhere from **1 minute to about 46 hours** after entry -- and notably, baseline never once had to hold all the way to bucket close in this data; it always found a profitable exit tick somewhere along the way. The 20% take-profit rule, by contrast, held 5 of the 20 trades all the way to close (settling for the flat $1.00) because the price never rose 20% above entry -- the direct cost of demanding a bigger target.
A third variant makes the mechanism impossible to miss: Held Till Close is the worst of the three, not the safest.
| Metric | Baseline Exit | 20% Take-Profit | Held Till Close |
|---|---|---|---|
| Investment | $633.36 | $633.56 | $591.83 |
| Total PNL | $142.85 | $218.93 | $44.42 |
| Average Exit Price | $1.1317 | $1.2437 | $1.0000 |
| Return on Investment | 22.6% | 34.6% | 7.5% |
| Avg. Holding Duration | 13.6 hours | 257.7 hours | 356.6 hours |
Every one of these 20 trades settles at exactly $1.00 in reality (none landed in the $2 region) -- so an average exit price of exactly $1.0000 for Held Till Close means it captured *zero* of the temporary price overshoot documented above. It just collected the flat floor every single time. Both other rules partially or substantially captured that overshoot instead (average exit price $1.13 and $1.24) -- that overshoot capture is the entire source of profit beyond the base floor return, not a bonus on top of it. Holding to close isn't the cautious, patient choice here; it's actively the worst one, because all the real profit in this dataset comes from selling into a temporary mispricing before settlement, not from the settlement itself.
Held Till Close also deployed noticeably less capital ($591.83 vs. $633+) -- holding every position for an average of 14.9 days ties up capital far longer, leaving less available to size later trades. A second, compounding cost of never exiting early, on top of missing the overshoot entirely.