Notebook 11: Unemployment Rate — EV Exploration¶
This notebook is not a backtest of a fixed strategy the way notebooks 04–10 are — it's an exploration, because the Unemployment Rate event breaks the assumption every prior notebook depends on: that each platform splits an event into mutually-exclusive outcome buckets which map 1:1 onto each other.
Example event (January 2026): Polymarket https://polymarket.com/event/january-unemployment-rate-333 | Kalshi https://kalshi.com/markets/kxu3/unemployment/kxu3-26jan
The misalignment¶
Kalshi structures Unemployment as a ladder of independent, cumulative "Above X%" contracts (strike_type: greater), one per 0.1% threshold, roughly 10–14 per month. These are not mutually exclusive at the contract level — if the actual rate comes in at 4.3%, then "Above 4.0%," "Above 4.1%," and "Above 4.2%" all resolve YES simultaneously, while "Above 4.3%" and everything above it resolves NO. Multiple winners at once, by design.
Polymarket structures the same event as mutually-exclusive exact-point contracts ("Will the rate be exactly 4.3%?") plus two catch-all tails, on the same 0.1% grid — exactly one of these wins, the same shape as every rate-decision bucket and CPI point this project has traded so far.
Live-verified (see docs/future_backtest_events.md): Polymarket's Exactly V contract is correlated with, but not equivalent to, Kalshi's Above (V − 0.1) contract — they agree at the boundary but diverge everywhere else, since Kalshi's contract also wins for every value above V, not just V itself. Worked payoff tables across several candidate pairings (2-leg Yes/Yes, Yes/No, and combinations skipping the "No" side entirely) all showed the same thing: none of the simple 2-leg pairings produce a guaranteed-constant payout. Combined payouts vary — $0 in some scenarios, $1 in others, $2 in others — depending on where the actual print lands relative to the pair chosen. Only a full 3-leg construction (both of Kalshi's boundary contracts — Above(V−0.1) Yes *and* Above(V) No — against the Polymarket leg) reproduces the always-exactly-$1 guarantee every other notebook's hold-to-close logic relies on, and that construction doubles the Kalshi-side fee drag relative to everything built so far.
What this notebook is actually for¶
We are not looking for arbitrage. Every notebook through 10 searched for a risk-free, guaranteed-payout hedge — that's what "arbitrage" means in this project so far, and the analysis above shows the cheap, low-fee versions of that don't exist for this event type. Building the expensive, guaranteed version (3 legs, double Kalshi fees) may or may not clear its costs; that's an open question, not an assumption.
Instead, we're looking for unfair EV — combinations of Kalshi and Polymarket legs, matched or not, whose combined expected payout exceeds their combined cost by enough to survive fees, even without a worst-case guarantee. That's a different kind of search than every prior notebook ran: instead of monitoring a price gap between two complementary instruments, it means comparing a combination's cost against its full payout distribution across every possible actual value, and figuring out empirically — with real resolved data and visualizations of how each side's price actually moved leading up to resolution — what a viable strategy shape even looks like here before committing to a formal backtest engine.
Scope¶
Five months of 2026 are usable: January, February, March, April, May. Two other months were checked and dropped:
- June and July — structurally fine, but every single Kalshi rung in both events returns a
404 Not Foundonget_candlesticks()(confirmed live, ticker by ticker, not a chunking bug) — the same too-recently-resolved archival gap that excluded Bank of England's and the Fed's July decisions, and CPI's June, from earlier notebooks. Both events closed within the last several weeks; can be revisited once Kalshi's archive catches up.
May was nearly missed entirely: its Polymarket event (may-unemployment-rate-168) has series: None, unlike every other month, so it isn't linked into Polymarket's recurring series listing — enumerating that series to discover events silently skips it. Found only by trying the slug directly; fully data-verified against Kalshi afterward (both platforms agree the May print was 4.3%, consistent with every other month checked).
Data ingestion below pulls each of the 5 usable months' full ladder on both platforms, not a matched subset — every Kalshi "Above X%" contract and every Polymarket point/tail contract — since the whole point here is to see the full misaligned structure, not a pre-filtered pairing.
Data Ingestion¶
For each of the 5 months, this pulls:
- Every Kalshi "Above X%" market's 1-minute candlesticks and individual trades (full threshold ladder, ~10–14 contracts/month)
- Every Polymarket point/tail market's price history (
prices-history, fidelity=1) and individual trades (~6–9 contracts/month)
Cached to data/raw/ under an nb11_ prefix. 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 events_config
import fees
sns.set_theme(style="whitegrid")
pd.set_option("display.width", 140)
RAW_DIR = Path.cwd().parent / "data" / "raw"
RAW_DIR.mkdir(parents=True, exist_ok=True)
ACTIVE_EVENT_KEYS = events_config.BATCH_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",
"unemployment_jun_2026": "Jun 2026",
"unemployment_jul_2026": "Jul 2026",
}
def get_kalshi_markets_robust(event_ticker):
"""get_historical_markets() has shown genuine transient flakiness for this
project (empty results that succeed on a retry) -- retry a couple of times
before falling back to the live (non-historical) /markets endpoint, which
returns the same shape for a still-visible event."""
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(sub_market):
import json as _json
outcomes = sub_market.get("outcomes", [])
token_ids = sub_market.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
# Full ladder on both platforms -- every Kalshi threshold contract and every
# Polymarket point/tail contract for each month, not a matched-pairs subset.
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"),
"yes_sub_title": m.get("yes_sub_title"),
"result": m.get("result"),
"open_time": pd.to_datetime(m["open_time"], utc=True),
"close_time": pd.to_datetime(m["close_time"], utc=True),
}
poly_rungs = {}
for m in poly_markets:
token_id = polymarket_yes_token(m)
if token_id is None:
continue
poly_rungs[m["slug"]] = {
"question": m.get("question"),
"token_id": token_id,
"condition_id": m.get("conditionId"),
"fees_enabled": bool(m.get("feesEnabled")),
"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,
}
ladder_summary = pd.DataFrame(
[
{
"Event": EVENTS[k]["label"],
"Kalshi Rungs": len(EVENTS[k]["kalshi_rungs"]),
"Polymarket Rungs": len(EVENTS[k]["poly_rungs"]),
}
for k in ACTIVE_EVENT_KEYS
]
)
display(Markdown(f"#### Full Ladder Discovery, {len(ACTIVE_EVENT_KEYS)} Months"))
display(ladder_summary)
def load_or_fetch(cache_name, fetch_fn):
cache_path = RAW_DIR / f"nb11_{cache_name}.parquet"
if cache_path.exists():
return pd.read_parquet(cache_path)
df = fetch_fn()
df.to_parquet(cache_path)
return df
for event_key, ev in EVENTS.items():
n_done = 0
for ticker, rung in ev["kalshi_rungs"].items():
rung["kalshi_history"] = load_or_fetch(
f"{event_key}__{ticker}__kalshi_history",
lambda ticker=ticker, rung=rung: kalshi_client.get_candlesticks(
ticker, rung["open_time"], rung["close_time"], period_interval=1
),
)
n_done += 1
print(f"{ev['label']}: Kalshi history fetched for {n_done} rungs")
for event_key, ev in EVENTS.items():
n_done = 0
for ticker, rung in ev["kalshi_rungs"].items():
rung["kalshi_trades"] = load_or_fetch(
f"{event_key}__{ticker}__kalshi_trades",
lambda ticker=ticker, rung=rung: kalshi_client.get_trades(ticker, rung["open_time"], rung["close_time"]),
)
n_done += 1
print(f"{ev['label']}: Kalshi trades fetched for {n_done} rungs")
for event_key, ev in EVENTS.items():
n_done = 0
for slug, rung in ev["poly_rungs"].items():
rung["polymarket_history"] = load_or_fetch(
f"{event_key}__{slug}__polymarket_history",
lambda slug=slug, rung=rung: polymarket_client.get_prices_history(
rung["token_id"], rung["open_time"], rung["close_time"], fidelity=1
),
)
n_done += 1
print(f"{ev['label']}: Polymarket history fetched for {n_done} rungs")
for event_key, ev in EVENTS.items():
n_done = 0
for slug, rung in ev["poly_rungs"].items():
rung["polymarket_trades"] = load_or_fetch(
f"{event_key}__{slug}__polymarket_trades",
lambda slug=slug, rung=rung: polymarket_client.get_trades(
rung["condition_id"], rung["open_time"], rung["close_time"]
),
)
n_done += 1
print(f"{ev['label']}: Polymarket trades fetched for {n_done} rungs")
display(Markdown("#### Data Ingestion Complete"))
summary_rows = []
for event_key, ev in EVENTS.items():
kalshi_candles = sum(len(r["kalshi_history"]) for r in ev["kalshi_rungs"].values())
kalshi_trades = sum(len(r["kalshi_trades"]) for r in ev["kalshi_rungs"].values())
poly_ticks = sum(len(r["polymarket_history"]) for r in ev["poly_rungs"].values())
poly_trades = sum(len(r["polymarket_trades"]) for r in ev["poly_rungs"].values())
summary_rows.append({
"Event": ev["label"],
"Kalshi Rungs": len(ev["kalshi_rungs"]),
"Kalshi Candles (total)": kalshi_candles,
"Kalshi Trades (total)": kalshi_trades,
"Polymarket Rungs": len(ev["poly_rungs"]),
"Polymarket Ticks (total)": poly_ticks,
"Polymarket Trades (total)": poly_trades,
})
display(pd.DataFrame(summary_rows))
Candidate Strategies to Test¶
Six shapes came out of the payoff-table work in chat, ranging from a real (but expensive) guarantee down to a pure EV-only bet:
- Full 3-leg guaranteed hedge — Kalshi Above(V−0.1) Yes + Kalshi Above(V) No + the Polymarket leg. The only construction with a guaranteed-constant $1 payout; doubles Kalshi-side fees versus every prior notebook.
- 2-leg "Kalshi = Poly − 1" — Poly-Yes(V) + Kalshi-Yes(Above V−0.1). Wins in most scenarios; loses both legs only if actual lands ≤ V−0.1.
- 2-leg Poly-Yes(V) + Kalshi-Yes(Above V) — loses both legs only if actual < V, wins otherwise.
- 2-leg "ladder" — Poly-No(V) + Kalshi-Yes(Above V−0.1) — never paid $0 in the cases checked in chat (only $1 or $2), worth testing whether that floor holds generally — closest thing to a cheap near-guarantee found so far.
- Full-ladder static-arb search — scan all rungs on both platforms for any combination (not fixed to one V) whose worst-case payout beats its cost.
- EV-based directional sizing — no guarantee required; size positions wherever expected payout beats cost using the resolved-value data, accept real variance ("unfair EV," not arbitrage).
Strategy 1: Full 3-Leg Hedge¶
The only shape from the list above with a guaranteed-constant payout: for a target value V, buy YES on Kalshi's Above(V−0.1), buy NO on Kalshi's Above(V), and take the offsetting Polymarket leg. One side of the combined position always resolves to exactly $1, the same invariant every notebook through 10 relies on for hold-to-close — but this is the first time it's built from three legs instead of two, which means two separate Kalshi fills to confirm (each with its own fee) before the Polymarket leg, not one.
Open question this section needs to answer with the ingested data: does the combined edge actually clear the doubled Kalshi fee drag, or does the extra leg eat the whole advantage this event type otherwise has over the mismatched 2-leg alternatives?
Finalized conditions:
- Entry: the two Kalshi legs behave like one synthetic Yes/No market.
kalshi_synthetic_yes_cost = ask(Above(V−0.1), Yes) + ask(Above(V), No);gap = kalshi_synthetic_yes_cost − polymarket_price. SameENTRY_GAP_THRESHOLD/ENTRY_DWELL_MINUTESmechanism as every prior notebook. - Confirmation: both Kalshi legs must independently show real trade volume in the entry window — one leg alone is a naked bet on a single threshold, not the synthetic position.
- Sizing:
POSITION_SIZE_PCT = 0.05(reduced from the 0.20 used in every prior notebook, given the added leg risk), split three ways, matched equal contract count across all three legs. - Capital release: same DRIP schedule tested in notebook 06 — 60% of bankroll available immediately, +20% within 75 days of the nearest upcoming bucket's close, the remaining 20% within 50 days. Notebook 06 found this barely bound because real signals cluster much closer to close than 50–75 days out — worth re-checking whether that holds here too.
- Exit: hold-to-close still guarantees exactly $1 total across all three legs (that's the point of using the complementary pair); profit-exit checks whether selling all three legs now beats $1 after fees by
PROFIT_EXIT_BUFFER. - Fees: Kalshi's taker fee applies twice — once per leg — plus Polymarket's per-bucket rate once.
- Scope: every middle point on Polymarket's grid, across all 5 ingested months. Tails are not played — they only need one Kalshi leg (a structurally different, simpler trade) and are out of scope for this section entirely.
Variables¶
ENTRY_GAP_THRESHOLD = 0.05
ENTRY_DWELL_MINUTES = 2
PROFIT_EXIT_BUFFER = 0.00
POSITION_SIZE_PCT = 0.05
STARTING_BANKROLL = 1000.0
KALSHI_TAKER_RATE = fees.KALSHI_TAKER_RATE
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),
)
Building the Synthetic 3-Leg Buckets¶
import re
def parse_poly_rung(question):
"""Returns (value, is_tail) for a Polymarket unemployment question, or
(None, None) if it can't be parsed. Tails use <=/>= (or spelled-out "or
less"/"or more") wording; middle points state a single value."""
text = question.lower()
if "≤" 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 "≥" 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)
SYN_BUCKETS = {}
skipped_syn = []
for event_key, ev in EVENTS.items():
kalshi_by_floor = {r["floor_strike"]: (ticker, r) for ticker, r in ev["kalshi_rungs"].items()}
for slug, prung in ev["poly_rungs"].items():
value, is_tail = parse_poly_rung(prung["question"])
if value is None:
skipped_syn.append({"event": ev["label"], "slug": slug, "reason": "could not parse a value"})
continue
if is_tail:
continue # tails not played in this section
low_floor = round(value - 0.1, 1)
high_floor = round(value, 1)
if low_floor not in kalshi_by_floor or high_floor not in kalshi_by_floor:
skipped_syn.append({
"event": ev["label"], "slug": slug,
"reason": f"missing Kalshi rung(s) for {low_floor}/{high_floor}",
})
continue
low_ticker, low_rung = kalshi_by_floor[low_floor]
high_ticker, high_rung = kalshi_by_floor[high_floor]
bucket_key = f"{event_key}__{value:g}pct"
open_ts = max(low_rung["open_time"], high_rung["open_time"], prung["open_time"])
close_ts = min(low_rung["close_time"], high_rung["close_time"], prung["close_time"])
SYN_BUCKETS[bucket_key] = {
"event_key": event_key,
"label": f"{ev['label']} \u2013 Exactly {value:g}%",
"V": value,
"kalshi_low_ticker": low_ticker,
"kalshi_high_ticker": high_ticker,
"kalshi_low_history": low_rung["kalshi_history"],
"kalshi_low_trades": low_rung["kalshi_trades"],
"kalshi_high_history": high_rung["kalshi_history"],
"kalshi_high_trades": high_rung["kalshi_trades"],
"polymarket_history": prung["polymarket_history"],
"polymarket_trades": prung["polymarket_trades"],
"polymarket_fee_rate": prung["polymarket_fee_rate"],
"open_ts": open_ts,
"close_ts": close_ts,
}
display(Markdown(f"#### Synthetic 3-Leg Buckets Built: {len(SYN_BUCKETS)}"))
bucket_table = pd.DataFrame(
[
{"Bucket": b["label"], "Kalshi Low": b["kalshi_low_ticker"], "Kalshi High": b["kalshi_high_ticker"],
"Window": f"{b['open_ts'].date()} to {b['close_ts'].date()}", "Poly Fee": f"{b['polymarket_fee_rate']:.0%}"}
for b in SYN_BUCKETS.values()
]
)
display(bucket_table)
if skipped_syn:
display(Markdown(f"#### Skipped ({len(skipped_syn)})"))
display(pd.DataFrame(skipped_syn))
Alignment, Entry Episodes, Dual Confirmation, Exit Simulation¶
Bug found and fixed during first execution: the gap formula initially used low_yes_ask + (1 - high_yes_ask) — the real dollar cost to construct the 2-leg Kalshi spread — compared directly against polymarket_price. That cost sits near 1 + P(exactly V), not P(exactly V) itself, because a long-low-Yes/long-high-No spread's raw payout already bakes in an always-$1 baseline before you add the third (Polymarket) leg that turns it into a clean 3-way exhaustive partition (below V / above V / exactly V). Comparing a ~1.0-centered number directly against Polymarket's small probability-scale price made |gap| look enormous almost everywhere, collapsing every bucket into one giant always-qualifying entry episode that ran to the very last tick with no room left to simulate an exit. Fixed by using `low_yes_ask - high_yes_ask` for signal detection only (this correctly isolates the P(exactly V)-equivalent value, comparable to Polymarket's price) — the actual trade construction and the guaranteed-$1 settlement math were never wrong, only the entry-timing signal was.
def kalshi_price_for_side(direction, kalshi_yes_ask):
"""direction=+1 -> buying this leg's No side; direction=-1 -> buying its Yes side."""
return (1 - kalshi_yes_ask) if direction == 1 else kalshi_yes_ask
def polymarket_price_for_side(direction, polymarket_price):
return polymarket_price if direction == 1 else (1 - polymarket_price)
for key, b in SYN_BUCKETS.items():
low_d = b["kalshi_low_history"][~b["kalshi_low_history"].index.duplicated(keep="first")]
high_d = b["kalshi_high_history"][~b["kalshi_high_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")
high = high_d.reset_index().rename(columns={"timestamp": "ts", "yes_ask": "high_yes_ask"})[["ts", "high_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, high, on="ts", tolerance=pd.Timedelta("5min"), direction="backward")
aligned = pd.merge_asof(aligned, poly, on="ts", tolerance=pd.Timedelta("5min"), direction="backward")
aligned = aligned.dropna()
aligned = aligned[(aligned["ts"] >= b["open_ts"]) & (aligned["ts"] <= b["close_ts"])]
kalshi_synthetic_yes_cost = aligned["low_yes_ask"] - aligned["high_yes_ask"] # probability-equivalent, comparable to polymarket_price -- NOT the raw 2-leg dollar cost (which is 1 + this value; see markdown note above)
aligned["gap"] = kalshi_synthetic_yes_cost - aligned["polymarket_price"]
b["aligned"] = aligned.set_index("ts").sort_index()
print("Aligned 3-leg price series built for all buckets:")
for key, b in SYN_BUCKETS.items():
print(f" {b['label']}: {len(b['aligned']):,} aligned ticks")
def find_entry_episodes(aligned, gap_threshold, dwell_minutes):
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"),
)
if len(episodes) == 0:
episodes["duration_minutes"] = pd.Series(dtype="float64")
episodes["direction"] = pd.Series(dtype="int64")
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()
episodes["direction"] = np.sign(episodes["entry_gap"]).astype(int)
episodes = episodes[episodes["direction"] != 0]
return episodes.reset_index(drop=True)
for key, b in SYN_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 leg_has_volume(trades, start_ts, end_ts):
return len(trades.loc[start_ts:end_ts]) > 0
for key, b in SYN_BUCKETS.items():
confirmed_flags = []
for _, row in b["entry_episodes"].iterrows():
low_ok = leg_has_volume(b["kalshi_low_trades"], row["start"], row["end"])
high_ok = leg_has_volume(b["kalshi_high_trades"], row["start"], row["end"])
confirmed_flags.append(low_ok and high_ok) # dual-leg confirmation -- both must show volume
b["entry_episodes"]["kalshi_confirmed"] = confirmed_flags
n_confirmed = sum(confirmed_flags)
print(f"{b['label']}: {n_confirmed:,} / {len(confirmed_flags):,} episodes had both Kalshi legs fill (confirmed)")
def simulate_exit(aligned, entry_ts, direction, profit_exit_buffer, bucket_close_ts, polymarket_fee_rate):
forward = aligned.loc[entry_ts:bucket_close_ts]
if len(forward) < 2:
return None
HOLD_TO_CLOSE_VALUE = 1.00
for i in range(len(forward)):
ts = forward.index[i]
low_yes_ask = forward["low_yes_ask"].iloc[i]
high_yes_ask = forward["high_yes_ask"].iloc[i]
polymarket_price = forward["polymarket_price"].iloc[i]
low_price = kalshi_price_for_side(direction, low_yes_ask)
high_price = kalshi_price_for_side(-direction, high_yes_ask)
poly_price = polymarket_price_for_side(direction, polymarket_price)
low_fee, _ = taker_fee_pair(low_price, 0, 1, 0)
high_fee, _ = taker_fee_pair(high_price, 0, 1, 0)
_, poly_fee = taker_fee_pair(0, poly_price, 1, polymarket_fee_rate)
early_exit_net = (low_price + high_price + poly_price) - (low_fee + high_fee + poly_fee)
if early_exit_net > HOLD_TO_CLOSE_VALUE + profit_exit_buffer:
return {
"exit_type": "profit_target", "exit_ts": ts,
"exit_low_yes_ask": low_yes_ask, "exit_high_yes_ask": high_yes_ask,
"exit_polymarket_price": polymarket_price,
}
return {"exit_type": "held_to_close", "exit_ts": None, "exit_low_yes_ask": None,
"exit_high_yes_ask": None, "exit_polymarket_price": None}
for key, b in SYN_BUCKETS.items():
bucket_close_ts = b["aligned"].index.max() if len(b["aligned"]) else None
exit_results = []
for _, row in b["entry_episodes"].iterrows():
if not row["kalshi_confirmed"] or bucket_close_ts is None:
exit_results.append(None)
continue
exit_results.append(
simulate_exit(b["aligned"], row["end"], row["direction"], PROFIT_EXIT_BUFFER, bucket_close_ts, b["polymarket_fee_rate"])
)
b["entry_episodes"]["exit_result"] = exit_results
n_profit = sum(1 for r in exit_results if r and r["exit_type"] == "profit_target")
n_held = sum(1 for r in exit_results if r and r["exit_type"] == "held_to_close")
print(f"{b['label']}: {n_profit:,} profit-target exits, {n_held:,} held to close")
Candidate Trades and DRIP-Aware Bankroll Pass¶
def build_candidate_trade(bucket_key, b, row):
direction = row["direction"]
entry_ts = row["end"]
entry_low_price = kalshi_price_for_side(direction, b["aligned"].loc[entry_ts, "low_yes_ask"])
entry_high_price = kalshi_price_for_side(-direction, b["aligned"].loc[entry_ts, "high_yes_ask"])
entry_poly_price = polymarket_price_for_side(direction, b["aligned"].loc[entry_ts, "polymarket_price"])
exit_result = row["exit_result"]
trade = {
"bucket": bucket_key, "bucket_label": b["label"], "event_key": b["event_key"],
"direction": direction, "entry_ts": entry_ts,
"entry_low_price": entry_low_price, "entry_high_price": entry_high_price, "entry_poly_price": entry_poly_price,
"entry_gap": row["entry_gap"], "exit_type": exit_result["exit_type"],
"polymarket_fee_rate": b["polymarket_fee_rate"],
}
if exit_result["exit_type"] == "profit_target":
trade["exit_ts"] = exit_result["exit_ts"]
trade["exit_low_price"] = kalshi_price_for_side(direction, exit_result["exit_low_yes_ask"])
trade["exit_high_price"] = kalshi_price_for_side(-direction, exit_result["exit_high_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_low_price"] = None
trade["exit_high_price"] = None
trade["exit_poly_price"] = None
return trade
all_candidates = []
n_skipped_no_forward_data = 0
for key, b in SYN_BUCKETS.items():
episodes = b["entry_episodes"]
confirmed_rows = episodes[episodes["kalshi_confirmed"] & episodes["exit_result"].notna()]
n_skipped_no_forward_data += int(episodes["kalshi_confirmed"].sum()) - len(confirmed_rows)
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):,}")
if n_skipped_no_forward_data:
print(f"(Confirmed but excluded for insufficient forward data before bucket close: {n_skipped_no_forward_data:,})")
BUCKET_CLOSE_DATES = sorted(set(b["close_ts"] for b in SYN_BUCKETS.values()))
def drip_ceiling(ts, starting_bankroll):
future_closes = [d for d in BUCKET_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
available_bankroll = STARTING_BANKROLL
bankroll_curve = [(all_candidates[0]["entry_ts"] if all_candidates else pd.Timestamp.now(tz="UTC"), STARTING_BANKROLL)]
open_positions = []
closed_trades = []
n_skipped_drip = 0
n_skipped_bankroll = 0
for cand in all_candidates:
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
ceiling = drip_ceiling(cand["entry_ts"], STARTING_BANKROLL)
usable = min(available_bankroll, ceiling)
budget = usable * POSITION_SIZE_PCT
sub_budget = budget / 3
contracts = int(min(
sub_budget / cand["entry_low_price"], sub_budget / cand["entry_high_price"], sub_budget / cand["entry_poly_price"]
))
if contracts < 1:
if usable < available_bankroll:
n_skipped_drip += 1
else:
n_skipped_bankroll += 1
continue
entry_cost = contracts * (cand["entry_low_price"] + cand["entry_high_price"] + cand["entry_poly_price"])
entry_low_fee, _ = taker_fee_pair(cand["entry_low_price"], 0, contracts, 0)
entry_high_fee, _ = taker_fee_pair(cand["entry_high_price"], 0, contracts, 0)
_, entry_poly_fee = taker_fee_pair(0, cand["entry_poly_price"], contracts, cand["polymarket_fee_rate"])
entry_fees = entry_low_fee + entry_high_fee + entry_poly_fee
if cand["exit_type"] == "profit_target":
exit_proceeds = contracts * (cand["exit_low_price"] + cand["exit_high_price"] + cand["exit_poly_price"])
exit_low_fee, _ = taker_fee_pair(cand["exit_low_price"], 0, contracts, 0)
exit_high_fee, _ = taker_fee_pair(cand["exit_high_price"], 0, contracts, 0)
_, exit_poly_fee = taker_fee_pair(0, cand["exit_poly_price"], contracts, cand["polymarket_fee_rate"])
exit_fees = exit_low_fee + exit_high_fee + exit_poly_fee
else:
exit_proceeds = contracts * 1.00
exit_fees = 0.0
pnl = (exit_proceeds - exit_fees) - (entry_cost + entry_fees)
capital_committed = entry_cost + entry_fees
trade_record = {
**cand, "contracts": contracts, "entry_cost": entry_cost, "entry_fees": entry_fees,
"exit_proceeds": exit_proceeds, "exit_fees": exit_fees,
"kalshi_fees": entry_low_fee + entry_high_fee + (exit_low_fee + exit_high_fee if cand["exit_type"] == "profit_target" else 0),
"poly_fees": entry_poly_fee + (exit_poly_fee if cand["exit_type"] == "profit_target" else 0),
"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})
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 (DRIP ceiling): {n_skipped_drip:,}")
print(f"Trades skipped (insufficient available bankroll): {n_skipped_bankroll:,}")
print(f"Final bankroll: ${available_bankroll:,.2f} (started at ${STARTING_BANKROLL:,.2f})")
Aggregate Backtest Metrics¶
display(Markdown("#### Aggregate Backtest Metrics"))
total_signals = sum(len(b["entry_episodes"]) for b in SYN_BUCKETS.values())
total_confirmed = sum(int(b["entry_episodes"]["kalshi_confirmed"].sum()) for b in SYN_BUCKETS.values())
confirmation_rate = total_confirmed / total_signals if total_signals else float("nan")
if len(trades_df):
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
else:
total_pnl = total_fees = win_rate = avg_holding_hours = 0.0
metrics_table = pd.DataFrame(
{
"Metric": [
"Signals Detected", "Kalshi Confirmed (both legs)", "Confirmation Rate", "Total Trades",
"Win Rate", "Total PNL", "Total Fees Paid", "Starting Bankroll", "Final Bankroll",
"Overall ROI", "Avg. Holding Duration",
],
"Value": [
f"{total_signals:,}", f"{total_confirmed:,}", f"{confirmation_rate:.1%}", 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)
if len(trades_df):
display(Markdown("#### By Event"))
by_event = trades_df.groupby("event_key").agg(
trades=("pnl", "count"), win_rate=("pnl", lambda s: (s > 0).mean()),
total_pnl=("pnl", "sum"), total_fees=("total_fees", "sum"),
avg_holding_hours=("holding_minutes", lambda s: s.mean() / 60),
).round(2)
display(by_event)
display(Markdown("#### By Bucket"))
by_bucket = trades_df.groupby("bucket_label").agg(
trades=("pnl", "count"), win_rate=("pnl", lambda s: (s > 0).mean()),
total_pnl=("pnl", "sum"), kalshi_fees=("kalshi_fees", "sum"), poly_fees=("poly_fees", "sum"),
total_fees=("total_fees", "sum"), avg_holding_hours=("holding_minutes", lambda s: s.mean() / 60),
).round(2)
display(by_bucket)
else:
display(Markdown("**No trades executed.**"))
Rolling Bankroll¶
if len(trades_df):
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)
event_resolution_dates = {}
for key, b in SYN_BUCKETS.items():
event_resolution_dates.setdefault(b["event_key"], b["close_ts"])
for event_key, ts in event_resolution_dates.items():
ax.axvline(ts, color="gray", linestyle=":", linewidth=0.8, alpha=0.5, zorder=0)
ax.annotate(EVENT_SHORT_LABELS[event_key], xy=(ts, ax.get_ylim()[1]), xytext=(3, -12),
textcoords="offset points", fontsize=8, color="dimgray", rotation=90, va="top")
final_ts = bankroll_df["ts"].iloc[-1]
final_value = bankroll_df["bankroll"].iloc[-1]
ax.scatter([final_ts], [final_value], color="steelblue", s=40, zorder=4, edgecolors="black", linewidths=0.6)
ax.annotate(f"${final_value:,.2f}", xy=(final_ts, final_value), xytext=(8, 8), textcoords="offset points", fontsize=9, fontweight="bold")
ax.set_xlabel("date")
ax.set_ylabel("bankroll ($)")
ax.set_title("Rolling Bankroll \u2014 Unemployment Full 3-Leg Hedge", 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} | Max drawdown from peak: ${peak - trough:,.2f}")
else:
print("No trades executed -- nothing to plot.")
Summary¶
+1.6% ROI ($16.05 net on the $1,000 starting bankroll), 325 trades, 37.5% win rate -- barely breakeven, and fees are why. Gross PNL was $401.75 ($16.05 net + $385.70 in fees) -- fees consumed 96% of gross edge, dramatically worse than any 2-leg notebook in this project (23-36% in notebooks 06-10). Doubling the Kalshi-side fee, exactly the risk flagged during strategy planning, very nearly erased this category's entire advantage.
| Event | Trades | Win Rate | PNL | Fees |
|---|---|---|---|---|
| Jan 2026 | 95 | 15% | -$60.26 | $106.33 |
| Feb 2026 | 60 | 33% | $22.81 | $55.95 |
| Mar 2026 | 84 | 54% | $69.13 | $89.22 |
| Apr 2026 | 58 | 62% | $0.19 | $78.28 |
| May 2026 | 28 | 25% | -$15.82 | $55.92 |
| Total | 325 | 37.5% | $16.05 | $385.70 |
January and May landed net negative; February, March, and April net positive but thin -- April in particular nearly broke even exactly at zero ($0.19 on 58 trades).
Why the win rate is only 37.5%, not near-100% like every guaranteed-payout notebook before it: the raw $1 settlement guarantee is real and was re-verified here -- this construction is a genuine 3-way exhaustive partition (below V / above V / exactly V), so exactly one of the three legs always pays $1. But a guaranteed payout isn't a guaranteed profit: a trade loses whenever entry cost + fees exceeds that $1, even though it still collects the full dollar at settlement. The entry gap does bound the raw edge correctly -- worked the algebra and confirmed `cost = 1 - gap`, the same relationship every 2-leg notebook relies on -- but two Kalshi fees stacked together run close to $0.04/contract at the worst-case price (~$0.50), leaving very little of a flat $0.05 entry threshold once fees are paid twice on the Kalshi side instead of once.
Funnel: 948 signals detected, 329 Kalshi-confirmed under the dual-leg requirement (34.7% confirmation rate) -- lower than every 2-leg notebook's confirmation rate, as expected from needing two independent fills instead of one, but not as crushing as it could have been. Nearly every confirmed signal executed (325 of 329) -- the small gap is the same "confirmed but insufficient forward data before close" edge case seen since notebook 07.
DRIP schedule made no difference -- 0 trades skipped by the capital-release ceiling, same finding as notebook 06: real signals cluster much closer to each bucket's close than the 50-75 day thresholds this schedule gates on.
Bankroll swung far more than the flat final ROI suggests: peak $1,031.87, trough $256.41, a $775.47 max drawdown -- a rocky path to a nearly-flat destination, not a smooth accumulation.
A harmless RuntimeWarning: divide by zero appeared in the bankroll pass, same as notebook 09 -- one leg's entry price hit exactly $0.00, and min() correctly falls back to the finite legs, so sizing was unaffected.
Bottom line: the full 3-leg hedge works exactly as designed -- a real, verified guaranteed settlement, no leg-mismatch risk -- but its economics are marginal at the tested $0.05 entry threshold and 0.05 position size, because the fee structure here is fundamentally harsher than every 2-leg category this project has tested. A fee-aware entry threshold (requiring the gap to clear estimated round-trip **fees**, not just a flat $0.05) is the natural next thing to test before concluding this strategy shape isn't viable.
Strategy 4: Ladder — Kalshi Above(V−0.1) Yes + Polymarket No(V)¶
Worked the general payoff table (not just the one V=4.3 example from chat) and it's a genuinely different shape than Strategy 1:
| Actual value | Kalshi Above(V−0.1) Yes | Polymarket No(V) | Combined |
|---|---|---|---|
| actual ≤ V | loses | wins | $1 |
| actual > V | wins | wins | $2 |
The worst case is always $1, never $0 — unlike every 2-leg pairing worked through in chat, this one can't wipe out both legs. That means: if entry cost < $1.00, the trade is profitable no matter what the actual value turns out to be — a real floor, not a probabilistic edge. And it only needs two legs (one Kalshi fee, not two), so it doesn't carry Strategy 1's doubled fee drag.
Conditions:
- Entry:
gap = 1.00 − (kalshi_low_yes_ask + polymarket_no_price). Enter (always the same combo — there's no symmetric "other direction" here, since the complementary combo has a $0 worst case, verified separately) whengap >= ENTRY_GAP_THRESHOLD, sustainedENTRY_DWELL_MINUTES. - Confirmation: single Kalshi leg, real trade volume in the window — back to the original one-leg confirmation, since this only trades one Kalshi contract.
- Sizing: same
POSITION_SIZE_PCT = 0.05, split two ways (not three) — one Kalshi fee, not two. - Exit: hold-to-close pays the realized settlement value — $1 if the actual print landed at or below V, $2 if above V (looked up from Kalshi's own resolved result on the Above(V) contract, not traded, just used to determine the true payout). Early exit only if selling now beats the guaranteed floor ($1) by `PROFIT_EXIT_BUFFER` — conservative, doesn't try to predict whether the $2 upside will land.
- Fees: one Kalshi taker fee + one Polymarket fee, same as every notebook through 10 — not doubled like Strategy 1.
- DRIP: same schedule reused again.
LADDER_BUCKETS = {}
skipped_ladder = []
for event_key, ev in EVENTS.items():
kalshi_by_floor = {r["floor_strike"]: (ticker, r) for ticker, r in ev["kalshi_rungs"].items()}
for slug, prung in ev["poly_rungs"].items():
value, is_tail = parse_poly_rung(prung["question"])
if value is None or is_tail:
continue # tails not played, same as Strategy 1
low_floor = round(value - 0.1, 1)
high_floor = round(value, 1)
if low_floor not in kalshi_by_floor or high_floor not in kalshi_by_floor:
skipped_ladder.append({"event": ev["label"], "slug": slug, "reason": f"missing Kalshi rung(s) for {low_floor}/{high_floor}"})
continue
low_ticker, low_rung = kalshi_by_floor[low_floor]
high_ticker, high_rung = kalshi_by_floor[high_floor] # not traded -- only used to look up the true settlement payout
if high_rung["result"] not in ("yes", "no"):
skipped_ladder.append({"event": ev["label"], "slug": slug, "reason": f"Above({high_floor}) not yet resolved"})
continue
bucket_key = f"{event_key}__{value:g}pct"
open_ts = max(low_rung["open_time"], prung["open_time"])
close_ts = min(low_rung["close_time"], prung["close_time"])
LADDER_BUCKETS[bucket_key] = {
"event_key": event_key,
"label": f"{ev['label']} \u2013 Ladder {value:g}%",
"V": value,
"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": 2.00 if high_rung["result"] == "yes" else 1.00,
"open_ts": open_ts,
"close_ts": close_ts,
}
display(Markdown(f"#### Strategy 4 Buckets Built: {len(LADDER_BUCKETS)}"))
ladder_bucket_table = pd.DataFrame(
[
{"Bucket": b["label"], "Kalshi Leg": b["kalshi_low_ticker"], "Settlement Payout": f"${b['settlement_payout']:.2f}",
"Window": f"{b['open_ts'].date()} to {b['close_ts'].date()}", "Poly Fee": f"{b['polymarket_fee_rate']:.0%}"}
for b in LADDER_BUCKETS.values()
]
)
display(ladder_bucket_table)
if skipped_ladder:
display(Markdown(f"#### Skipped ({len(skipped_ladder)})"))
display(pd.DataFrame(skipped_ladder))
for key, b in LADDER_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("Aligned 2-leg price series built for all Strategy 4 buckets:")
for key, b in LADDER_BUCKETS.items():
print(f" {b['label']}: {len(b['aligned']):,} aligned ticks")
def find_ladder_episodes(aligned, gap_threshold, dwell_minutes):
"""Single-sided -- no direction sign needed, this strategy only ever
trades the one combo (Kalshi low Yes + Polymarket No), never its
complement (which has a $0 worst case)."""
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)
for key, b in LADDER_BUCKETS.items():
b["entry_episodes"] = find_ladder_episodes(b["aligned"], ENTRY_GAP_THRESHOLD, ENTRY_DWELL_MINUTES)
print(f"{b['label']}: {len(b['entry_episodes']):,} candidate entry episodes")
for key, b in LADDER_BUCKETS.items():
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
n_confirmed = sum(confirmed_flags)
print(f"{b['label']}: {n_confirmed:,} / {len(confirmed_flags):,} episodes had a Kalshi fill (confirmed)")
def simulate_ladder_exit(aligned, entry_ts, profit_exit_buffer, bucket_close_ts, polymarket_fee_rate):
"""Compares selling now against the guaranteed FLOOR ($1), not the
realized settlement value (which isn't known until close) -- conservative,
doesn't try to predict whether the $2 upside will land."""
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)
early_exit_net = (low_yes_ask + poly_no_price) - (low_fee + poly_fee)
if early_exit_net > FLOOR_VALUE + profit_exit_buffer:
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}
for key, b in LADDER_BUCKETS.items():
bucket_close_ts = b["aligned"].index.max() if len(b["aligned"]) else None
exit_results = []
for _, row in b["entry_episodes"].iterrows():
if not row["kalshi_confirmed"] or bucket_close_ts is None:
exit_results.append(None)
continue
exit_results.append(simulate_ladder_exit(b["aligned"], row["end"], PROFIT_EXIT_BUFFER, bucket_close_ts, b["polymarket_fee_rate"]))
b["entry_episodes"]["exit_result"] = exit_results
n_profit = sum(1 for r in exit_results if r and r["exit_type"] == "profit_target")
n_held = sum(1 for r in exit_results if r and r["exit_type"] == "held_to_close")
print(f"{b['label']}: {n_profit:,} profit-target exits, {n_held:,} held to close")
def build_ladder_candidate(bucket_key, b, row):
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 = row["exit_result"]
trade = {
"bucket": bucket_key, "bucket_label": b["label"], "event_key": b["event_key"],
"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
return trade
all_ladder_candidates = []
n_skipped_no_forward_data_ladder = 0
for key, b in LADDER_BUCKETS.items():
episodes = b["entry_episodes"]
confirmed_rows = episodes[episodes["kalshi_confirmed"] & episodes["exit_result"].notna()]
n_skipped_no_forward_data_ladder += int(episodes["kalshi_confirmed"].sum()) - len(confirmed_rows)
for _, row in confirmed_rows.iterrows():
all_ladder_candidates.append(build_ladder_candidate(key, b, row))
all_ladder_candidates.sort(key=lambda t: t["entry_ts"])
print(f"Total confirmed candidate trades: {len(all_ladder_candidates):,}")
if n_skipped_no_forward_data_ladder:
print(f"(Confirmed but excluded for insufficient forward data before bucket close: {n_skipped_no_forward_data_ladder:,})")
LADDER_CLOSE_DATES = sorted(set(b["close_ts"] for b in LADDER_BUCKETS.values()))
def ladder_drip_ceiling(ts, starting_bankroll):
future_closes = [d for d in LADDER_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
ladder_available_bankroll = STARTING_BANKROLL
ladder_bankroll_curve = [(all_ladder_candidates[0]["entry_ts"] if all_ladder_candidates else pd.Timestamp.now(tz="UTC"), STARTING_BANKROLL)]
ladder_open_positions = []
ladder_closed_trades = []
n_skipped_drip_ladder = 0
n_skipped_bankroll_ladder = 0
for cand in all_ladder_candidates:
still_open = []
for pos in ladder_open_positions:
if pos["exit_ts"] <= cand["entry_ts"]:
ladder_available_bankroll += pos["capital_committed"] + pos["pnl"]
ladder_bankroll_curve.append((pos["exit_ts"], ladder_available_bankroll))
else:
still_open.append(pos)
ladder_open_positions = still_open
ceiling = ladder_drip_ceiling(cand["entry_ts"], STARTING_BANKROLL)
usable = min(ladder_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 < ladder_available_bankroll:
n_skipped_drip_ladder += 1
else:
n_skipped_bankroll_ladder += 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:
# Held to close -- realized payout depends on where the actual print
# landed (looked up when the bucket was built), not a flat $1.
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,
"kalshi_fees": entry_low_fee + (exit_low_fee if cand["exit_type"] == "profit_target" else 0),
"poly_fees": entry_poly_fee + (exit_poly_fee if cand["exit_type"] == "profit_target" else 0),
"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,
}
ladder_closed_trades.append(trade_record)
ladder_available_bankroll -= capital_committed
ladder_bankroll_curve.append((cand["entry_ts"], ladder_available_bankroll))
ladder_open_positions.append({"exit_ts": cand["exit_ts"], "capital_committed": capital_committed, "pnl": pnl})
for pos in sorted(ladder_open_positions, key=lambda p: p["exit_ts"]):
ladder_available_bankroll += pos["capital_committed"] + pos["pnl"]
ladder_bankroll_curve.append((pos["exit_ts"], ladder_available_bankroll))
ladder_trades_df = pd.DataFrame(ladder_closed_trades)
ladder_bankroll_df = pd.DataFrame(ladder_bankroll_curve, columns=["ts", "bankroll"]).sort_values("ts").reset_index(drop=True)
print(f"Trades executed: {len(ladder_trades_df):,}")
print(f"Trades skipped (DRIP ceiling): {n_skipped_drip_ladder:,}")
print(f"Trades skipped (insufficient available bankroll): {n_skipped_bankroll_ladder:,}")
print(f"Final bankroll: ${ladder_available_bankroll:,.2f} (started at ${STARTING_BANKROLL:,.2f})")
Strategy 4: PNL Table¶
display(Markdown("#### Aggregate Backtest Metrics -- Strategy 4"))
total_signals_ladder = sum(len(b["entry_episodes"]) for b in LADDER_BUCKETS.values())
total_confirmed_ladder = sum(int(b["entry_episodes"]["kalshi_confirmed"].sum()) for b in LADDER_BUCKETS.values())
confirmation_rate_ladder = total_confirmed_ladder / total_signals_ladder if total_signals_ladder else float("nan")
if len(ladder_trades_df):
total_pnl_ladder = ladder_trades_df["pnl"].sum()
total_fees_ladder = ladder_trades_df["total_fees"].sum()
win_rate_ladder = (ladder_trades_df["pnl"] > 0).mean()
avg_holding_hours_ladder = ladder_trades_df["holding_minutes"].mean() / 60
else:
total_pnl_ladder = total_fees_ladder = win_rate_ladder = avg_holding_hours_ladder = 0.0
metrics_table_ladder = pd.DataFrame(
{
"Metric": [
"Signals Detected", "Kalshi Confirmed", "Confirmation Rate", "Total Trades", "Win Rate",
"Total PNL", "Total Fees Paid", "Starting Bankroll", "Final Bankroll", "Overall ROI", "Avg. Holding Duration",
],
"Value": [
f"{total_signals_ladder:,}", f"{total_confirmed_ladder:,}", f"{confirmation_rate_ladder:.1%}",
f"{len(ladder_trades_df):,}", f"{win_rate_ladder:.1%}", f"${total_pnl_ladder:,.2f}", f"${total_fees_ladder:,.2f}",
f"${STARTING_BANKROLL:,.2f}", f"${ladder_available_bankroll:,.2f}",
f"{(ladder_available_bankroll - STARTING_BANKROLL) / STARTING_BANKROLL:.1%}", f"{avg_holding_hours_ladder:,.1f} hours",
],
}
)
display(metrics_table_ladder)
if len(ladder_trades_df):
display(Markdown("#### By Event"))
by_event_ladder = ladder_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_ladder)
display(Markdown("#### By Bucket"))
by_bucket_ladder = ladder_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_ladder)
else:
display(Markdown("**No trades executed.**"))
Strategy 4: Visualization¶
if len(ladder_trades_df):
fig, axes = plt.subplots(2, 1, figsize=(16, 10), gridspec_kw={"height_ratios": [1.4, 1]})
ax = axes[0]
ax.plot(ladder_bankroll_df["ts"], ladder_bankroll_df["bankroll"], color="darkorange", 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(ladder_bankroll_df["ts"], ladder_bankroll_df["bankroll"], STARTING_BANKROLL,
where=(ladder_bankroll_df["bankroll"] >= STARTING_BANKROLL), color="seagreen", alpha=0.15, step="post", zorder=0)
ax.fill_between(ladder_bankroll_df["ts"], ladder_bankroll_df["bankroll"], STARTING_BANKROLL,
where=(ladder_bankroll_df["bankroll"] < STARTING_BANKROLL), color="crimson", alpha=0.15, step="post", zorder=0)
event_resolution_dates_ladder = {}
for key, b in LADDER_BUCKETS.items():
event_resolution_dates_ladder.setdefault(b["event_key"], b["close_ts"])
for event_key, ts in event_resolution_dates_ladder.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 = ladder_bankroll_df["ts"].iloc[-1]
final_value = ladder_bankroll_df["bankroll"].iloc[-1]
ax.scatter([final_ts], [final_value], color="darkorange", 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 Strategy 4: Ladder", fontsize=12)
ax.legend(loc="upper left", fontsize=9)
ax2 = axes[1]
by_event_plot = ladder_trades_df.groupby("event_key").agg(pnl=("pnl", "sum"), fees=("total_fees", "sum")).reindex(EVENT_SHORT_LABELS.keys()).dropna()
x = np.arange(len(by_event_plot))
width = 0.35
ax2.bar(x - width / 2, by_event_plot["pnl"], width, label="PNL", color="seagreen")
ax2.bar(x + width / 2, by_event_plot["fees"], width, label="Fees", color="crimson")
ax2.axhline(0, color="black", linewidth=0.8)
ax2.set_xticks(x)
ax2.set_xticklabels([EVENT_SHORT_LABELS[k] for k in by_event_plot.index])
ax2.set_ylabel("$")
ax2.set_title("PNL vs Fees by Event \u2014 Strategy 4", fontsize=12)
ax2.legend(loc="upper left", fontsize=9)
plt.tight_layout()
plt.show()
peak_ladder = ladder_bankroll_df["bankroll"].max()
trough_ladder = ladder_bankroll_df["bankroll"].min()
print(f"Peak bankroll: ${peak_ladder:,.2f} | Trough bankroll: ${trough_ladder:,.2f} | Max drawdown from peak: ${peak_ladder - trough_ladder:,.2f}")
else:
print("No trades executed -- nothing to plot.")
Strategy 4b: Ladder with a 20% Take-Profit¶
Same buckets, same entries, same confirmation as Strategy 4 — the only change is the exit rule. Baseline Strategy 4 exits the instant selling covers any amount above the guaranteed $1 floor (`PROFIT_EXIT_BUFFER = 0.00`); this variant instead holds for a **20% return on entry cost** before taking profit early, falling back to holding to close (realized $1 or $2, whichever the actual print landed on) if that target is never reached.
Worth noting before running it: since entry only fires when cost <= 0.95 (the $0.05 gap threshold), any trade entered at `cost <= $0.833` already has the 20% target satisfied by the guaranteed floor alone ($1.00 / $0.833 ≈ 1.20) — those trades would want to take profit essentially immediately rather than wait. Trades entered closer to the $0.95 boundary need the market to actually move before hitting 20%, and may end up holding to close instead — a real behavioral difference from the baseline's take-any-profit rule, not just a stricter version of the same thing.
TAKE_PROFIT_PCT = 0.20
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_ladder_tp_candidates = []
n_skipped_no_forward_data_tp = 0
for key, b in LADDER_BUCKETS.items():
bucket_close_ts = b["aligned"].index.max() if len(b["aligned"]) else None
episodes = b["entry_episodes"]
for _, row in 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_tp += 1
continue
trade = {
"bucket": key, "bucket_label": b["label"], "event_key": b["event_key"],
"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_ladder_tp_candidates.append(trade)
all_ladder_tp_candidates.sort(key=lambda t: t["entry_ts"])
print(f"Total confirmed candidate trades: {len(all_ladder_tp_candidates):,}")
if n_skipped_no_forward_data_tp:
print(f"(Confirmed but excluded for insufficient forward data before bucket close: {n_skipped_no_forward_data_tp:,})")
ladder_tp_available_bankroll = STARTING_BANKROLL
ladder_tp_bankroll_curve = [(all_ladder_tp_candidates[0]["entry_ts"] if all_ladder_tp_candidates else pd.Timestamp.now(tz="UTC"), STARTING_BANKROLL)]
ladder_tp_open_positions = []
ladder_tp_closed_trades = []
n_skipped_drip_tp = 0
n_skipped_bankroll_tp = 0
for cand in all_ladder_tp_candidates:
still_open = []
for pos in ladder_tp_open_positions:
if pos["exit_ts"] <= cand["entry_ts"]:
ladder_tp_available_bankroll += pos["capital_committed"] + pos["pnl"]
ladder_tp_bankroll_curve.append((pos["exit_ts"], ladder_tp_available_bankroll))
else:
still_open.append(pos)
ladder_tp_open_positions = still_open
ceiling = ladder_drip_ceiling(cand["entry_ts"], STARTING_BANKROLL)
usable = min(ladder_tp_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 < ladder_tp_available_bankroll:
n_skipped_drip_tp += 1
else:
n_skipped_bankroll_tp += 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,
"kalshi_fees": entry_low_fee + (exit_low_fee if cand["exit_type"] == "profit_target" else 0),
"poly_fees": entry_poly_fee + (exit_poly_fee if cand["exit_type"] == "profit_target" else 0),
"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,
}
ladder_tp_closed_trades.append(trade_record)
ladder_tp_available_bankroll -= capital_committed
ladder_tp_bankroll_curve.append((cand["entry_ts"], ladder_tp_available_bankroll))
ladder_tp_open_positions.append({"exit_ts": cand["exit_ts"], "capital_committed": capital_committed, "pnl": pnl})
for pos in sorted(ladder_tp_open_positions, key=lambda p: p["exit_ts"]):
ladder_tp_available_bankroll += pos["capital_committed"] + pos["pnl"]
ladder_tp_bankroll_curve.append((pos["exit_ts"], ladder_tp_available_bankroll))
ladder_tp_trades_df = pd.DataFrame(ladder_tp_closed_trades)
ladder_tp_bankroll_df = pd.DataFrame(ladder_tp_bankroll_curve, columns=["ts", "bankroll"]).sort_values("ts").reset_index(drop=True)
print(f"Trades executed: {len(ladder_tp_trades_df):,}")
print(f"Trades skipped (DRIP ceiling): {n_skipped_drip_tp:,}")
print(f"Trades skipped (insufficient available bankroll): {n_skipped_bankroll_tp:,}")
print(f"Final bankroll: ${ladder_tp_available_bankroll:,.2f} (started at ${STARTING_BANKROLL:,.2f})")
if len(ladder_tp_trades_df):
n_immediate = (ladder_tp_trades_df["holding_minutes"] < 5).sum()
print(f"Exited essentially immediately (<5 min): {n_immediate:,} / {len(ladder_tp_trades_df):,}")
Third Variant: Held Till Close (No Early Exit at All)¶
Same entries, same confirmation, same buckets as Strategy 4 -- 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).
all_ladder_htc_candidates = []
for key, b in LADDER_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"],
"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_ladder_htc_candidates.append(trade)
all_ladder_htc_candidates.sort(key=lambda t: t["entry_ts"])
print(f"Total confirmed candidate trades (held till close): {len(all_ladder_htc_candidates):,}")
ladder_htc_available_bankroll = STARTING_BANKROLL
ladder_htc_bankroll_curve = [(all_ladder_htc_candidates[0]["entry_ts"] if all_ladder_htc_candidates else pd.Timestamp.now(tz="UTC"), STARTING_BANKROLL)]
ladder_htc_open_positions = []
ladder_htc_closed_trades = []
for cand in all_ladder_htc_candidates:
still_open = []
for pos in ladder_htc_open_positions:
if pos["exit_ts"] <= cand["entry_ts"]:
ladder_htc_available_bankroll += pos["capital_committed"] + pos["pnl"]
ladder_htc_bankroll_curve.append((pos["exit_ts"], ladder_htc_available_bankroll))
else:
still_open.append(pos)
ladder_htc_open_positions = still_open
ceiling = ladder_drip_ceiling(cand["entry_ts"], STARTING_BANKROLL)
usable = min(ladder_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,
}
ladder_htc_closed_trades.append(trade_record)
ladder_htc_available_bankroll -= capital_committed
ladder_htc_bankroll_curve.append((cand["entry_ts"], ladder_htc_available_bankroll))
ladder_htc_open_positions.append({"exit_ts": cand["exit_ts"], "capital_committed": capital_committed, "pnl": pnl})
for pos in sorted(ladder_htc_open_positions, key=lambda p: p["exit_ts"]):
ladder_htc_available_bankroll += pos["capital_committed"] + pos["pnl"]
ladder_htc_bankroll_curve.append((pos["exit_ts"], ladder_htc_available_bankroll))
ladder_htc_trades_df = pd.DataFrame(ladder_htc_closed_trades)
ladder_htc_bankroll_df = pd.DataFrame(ladder_htc_bankroll_curve, columns=["ts", "bankroll"]).sort_values("ts").reset_index(drop=True)
print(f"Trades executed (held till close): {len(ladder_htc_trades_df):,}")
print(f"Final bankroll: ${ladder_htc_available_bankroll:,.2f} (started at ${STARTING_BANKROLL:,.2f})")
display(Markdown("#### Aggregate Backtest Metrics -- Strategy 4b (20% Take-Profit)"))
if len(ladder_tp_trades_df):
total_pnl_tp = ladder_tp_trades_df["pnl"].sum()
total_fees_tp = ladder_tp_trades_df["total_fees"].sum()
win_rate_tp = (ladder_tp_trades_df["pnl"] > 0).mean()
avg_holding_hours_tp = ladder_tp_trades_df["holding_minutes"].mean() / 60
else:
total_pnl_tp = total_fees_tp = win_rate_tp = avg_holding_hours_tp = 0.0
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_baseline = ladder_trades_df["entry_cost"].sum() if len(ladder_trades_df) else 0.0
capital_invested_tp = ladder_tp_trades_df["entry_cost"].sum() if len(ladder_tp_trades_df) else 0.0
return_on_capital_baseline = ladder_trades_df["pnl"].sum() / capital_invested_baseline if capital_invested_baseline else float("nan")
return_on_capital_tp = total_pnl_tp / capital_invested_tp if capital_invested_tp else float("nan")
avg_exit_price_baseline = exit_price_series(ladder_trades_df).mean() if len(ladder_trades_df) else float("nan")
avg_exit_price_tp = exit_price_series(ladder_tp_trades_df).mean() if len(ladder_tp_trades_df) else float("nan")
capital_invested_htc = ladder_htc_trades_df["entry_cost"].sum() if len(ladder_htc_trades_df) else 0.0
return_on_capital_htc = ladder_htc_trades_df["pnl"].sum() / capital_invested_htc if capital_invested_htc else float("nan")
avg_exit_price_htc = exit_price_series(ladder_htc_trades_df).mean() if len(ladder_htc_trades_df) else float("nan")
avg_holding_hours_htc = ladder_htc_trades_df["holding_minutes"].mean() / 60 if len(ladder_htc_trades_df) else float("nan")
compare_table = pd.DataFrame(
{
"Metric": [
"Total Trades", "Win Rate", "Capital Invested (sum of entry cost)", "Total PNL", "Total Fees Paid",
"Average Exit Price", "Return on Capital Invested", "Final Bankroll", "Overall ROI (vs. starting bankroll)", "Avg. Holding Duration",
],
"Strategy 4 (baseline)": [
f"{len(ladder_trades_df):,}", f"{(ladder_trades_df['pnl'] > 0).mean():.1%}", f"${capital_invested_baseline:,.2f}",
f"${ladder_trades_df['pnl'].sum():,.2f}", f"${ladder_trades_df['total_fees'].sum():,.2f}",
f"${avg_exit_price_baseline:,.4f}", f"{return_on_capital_baseline:.1%}",
f"${ladder_available_bankroll:,.2f}",
f"{(ladder_available_bankroll - STARTING_BANKROLL) / STARTING_BANKROLL:.1%}",
f"{ladder_trades_df['holding_minutes'].mean() / 60:,.1f} hours",
],
"Strategy 4b (20% TP)": [
f"{len(ladder_tp_trades_df):,}", f"{win_rate_tp:.1%}", f"${capital_invested_tp:,.2f}",
f"${total_pnl_tp:,.2f}", f"${total_fees_tp:,.2f}",
f"${avg_exit_price_tp:,.4f}", f"{return_on_capital_tp:.1%}",
f"${ladder_tp_available_bankroll:,.2f}", f"{(ladder_tp_available_bankroll - STARTING_BANKROLL) / STARTING_BANKROLL:.1%}",
f"{avg_holding_hours_tp:,.1f} hours",
],
"Held Till Close": [
f"{len(ladder_htc_trades_df):,}", f"{(ladder_htc_trades_df['pnl'] > 0).mean():.1%}" if len(ladder_htc_trades_df) else "n/a",
f"${capital_invested_htc:,.2f}",
f"${ladder_htc_trades_df['pnl'].sum():,.2f}" if len(ladder_htc_trades_df) else "$0.00",
f"${ladder_htc_trades_df['total_fees'].sum():,.2f}" if len(ladder_htc_trades_df) else "$0.00",
f"${avg_exit_price_htc:,.4f}", f"{return_on_capital_htc:.1%}",
f"${ladder_htc_available_bankroll:,.2f}",
f"{(ladder_htc_available_bankroll - STARTING_BANKROLL) / STARTING_BANKROLL:.1%}",
f"{avg_holding_hours_htc:,.1f} hours",
],
}
)
display(compare_table)
display(Markdown(
"*\"Overall ROI\" is measured against the fixed $1,000 starting bankroll, including capital that was never deployed. "
"\"Return on Capital Invested\" divides PNL by the actual dollar amount put to work (sum of entry costs across all trades) -- "
"the fairer comparison across variants of the same strategy. \"Average Exit Price\" is the per-contract value actually "
"received at exit -- the sale price for early exits, the realized settlement payout for trades held to close.*"
))
if len(ladder_tp_trades_df):
display(Markdown("#### By Event -- Strategy 4b"))
by_event_tp = ladder_tp_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_tp)
Summary: Strategy 4 vs. Strategy 4b¶
Adding a 20% take-profit target strictly improved Strategy 4 on essentially the same amount of capital deployed -- same 17 trades, same 100% win rate, nearly identical capital invested ($537.31 vs $538.14 -- less than $1 apart, since both variants trade the same entries), but more PNL and slightly lower fees, just by changing when to exit rather than what to trade.
| Metric | Strategy 4 (baseline) | Strategy 4b (20% TP) |
|---|---|---|
| Trades | 17 | 17 |
| Win Rate | 100% | 100% |
| Total PNL | $136.28 | $188.73 |
| Total Fees | $16.22 | $15.78 |
| Fees / Gross Edge | 10.6% | 7.7% |
| Capital Invested | $537.31 | $538.14 |
| Return on Capital Invested | 25.4% | 35.1% |
| Final Bankroll | $1,136.28 | $1,188.73 |
| Overall ROI (vs. starting bankroll) | 13.6% | 18.9% |
| Avg. Holding Duration | 15.3 hours | 262.3 hours |
Capital invested was checked deliberately, not assumed -- it's the natural thing to doubt when one variant shows a bigger PNL, since a strategy that simply deploys more money will show more profit without necessarily being better. Here it isn't: both variants put almost exactly the same dollar amount to work, so the jump from 25.4% to 35.1% return-on-capital is a real, capital-normalized improvement, not an artifact of sizing.
Why it helped -- checked against real per-trade prices, not assumed. The first instinct was that 4b must be capturing more $2 settlement outcomes that baseline's aggressive exit was cutting short. Pulled the actual entry/exit price for all 17 trades under both rules to check, and that instinct was wrong: **all 17 executed trades share the identical $1.00 settlement ceiling -- none of them landed in a $2-payout bucket.** The real mechanism is different, and only visible at the per-trade level:
- 7 of 17 trades: baseline and 4b sold at the identical price. The combined 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 4b -- no difference between the two rules in these cases.
- 3 of 17 trades (Feb ×2, Apr ×1): baseline actually did marginally better. 4b's 20% target was never reached, so it just held to the flat $1.00 floor, giving up a few cents versus baseline's early sale -- e.g. one Feb trade: baseline sold at $1.005 for a $0.060 gain, 4b held to $1.00 for $0.055.
- 6 of 17 trades: 4b won decisively. Most strikingly, all four May trades: baseline sold into the very first tick of profit (around $1.01-$1.045, gains of just $0.065-$0.15), while the combined price actually spiked to **$1.41** at some point before settling back to its true $1.00 value -- and 4b's patience captured most of that swing (gains of $0.465-$0.515 on the same four trades).
The takeaway: this synthetic 2-leg price genuinely overshoots its true settlement value before resolution -- most plausibly a temporary lag or mispricing between the two platforms right around when the underlying print becomes known, before both quotes catch up to reality. Baseline's "sell at the first cent of profit" rule captures almost none of that overshoot; a 20% bar captures most of it when it's large, at the small, occasional cost of settling for the flat floor instead of a tiny early profit when no real overshoot ever shows up. A handful of big wins (Jan, May) more than outweigh the pennies given up elsewhere (Feb, Apr) -- that's the entire source of the $52.45 PNL gap on essentially identical capital.
By event (Strategy 4b): May 2026 saw the largest swing of any bucket -- $52.75 versus baseline's $6.91, nearly 8x -- driven entirely by that $1.41 price spike across all four May trades, not by a different settlement outcome (May's bucket paid exactly $1.00 either way). January remained the largest single contributor in dollar terms ($123.40 on 8 trades). February and April were the two events where 4b's target went unreached on some trades, giving up a few cents to baseline rather than gaining anything. March remained at zero trades in both variants -- unaffected by the exit-rule change, since it never had an executable entry to begin with.
The real cost is capital efficiency, not risk: average holding duration jumped from 15.3 hours to 262.3 hours -- over 17x longer. Win rate didn't move (still 100% in both), so this isn't a riskier strategy, just a slower-turning one; capital sits tied up in open positions far longer before it's free to redeploy, which matters for a bankroll this notebook is also testing DRIP capital-release pacing against.