Skip to content

Instantly share code, notes, and snippets.

@taarushv
Last active July 2, 2026 14:33
Show Gist options
  • Select an option

  • Save taarushv/0281b0dff7c3a5ca869df6bc1f7e644a to your computer and use it in GitHub Desktop.

Select an option

Save taarushv/0281b0dff7c3a5ca869df6bc1f7e644a to your computer and use it in GitHub Desktop.
2027 NBA title odds over the 2026 offseason (Polymarket + Kalshi)

2027 NBA Title Odds — Offseason Tracker

Tracks every NBA team's implied probability of winning the 2027 championship across the 2026 offseason, blending two prediction markets, and renders it as a chart annotated with the offseason's big trades and signings.

What it does

  1. Pulls the 2027 champion markets from Polymarket (nba-2027-champion, CLOB prices-history) and Kalshi (KXNBA-27, candlesticks). Both public, no API key needed for reads. ~$9M combined volume.
  2. Normalizes each book so all 30 teams sum to 100% at every timestamp (removes the book's vig), then blends the two, volume-weighted per team.
  3. Cleans thin-market tick noise with a rolling median, then charts it.
  4. Computes movers from a single baseline: the day before the Knicks clinched (Jun 12) — captures the Finals result plus all the offseason moves in one number.

Charts (this script produces all three)

  • data/odds_chart_bump.png — whole-league rank (bump) chart, all 30 teams
  • data/odds_chart_conf.png — same idea, split into West (top) / East (bottom) panels
  • data/odds_chart_lines.png — raw probability line chart (13 teams >= 2%)

Method notes (no manual fudging)

  • Prices are market-implied odds, not true win probabilities; longshots carry a premium.
  • Every transform is applied uniformly to all teams: vig-strip normalization, per-team volume-weighted blend, rolling-median denoise. No per-team adjustments, no dropped data points, no cherry-picked baseline.
  • Kalshi history starts Jun 14 (its market open date); before that the blend is Polymarket-only.
  • robustness.py recomputes the movers under Polymarket-only, Kalshi-only, equal-weight, and volume-weight, across smoothing windows. The top risers (Raptors, Sixers, Warriors) and top fallers (Spurs) hold under every variant.

Run

python3 -m venv .venv && .venv/bin/pip install requests pandas matplotlib pyyaml
.venv/bin/python fetch_polymarket.py
.venv/bin/python fetch_kalshi.py
.venv/bin/python analyze.py       # writes the three charts + data/movers.csv
.venv/bin/python robustness.py    # optional: method-robustness check

Files

  • fetch_polymarket.py, fetch_kalshi.py — data pulls
  • analyze.py — blend, movers, all three charts
  • robustness.py — method-robustness check
  • events.yaml — hand-curated news markers overlaid on the charts

Data as of July 1, 2026.

"""Fetch 2027 NBA Champion odds time-series from Polymarket.
Event: nba-2027-champion (36 team contracts). For each team's YES token we pull
hourly price history from the CLOB prices-history endpoint, then write a tidy CSV:
timestamp, team, price (price = implied P(win title), 0..1, raw/un-normalized)
"""
import json
import time
import sys
import requests
GAMMA = "https://gamma-api.polymarket.com"
CLOB = "https://clob.polymarket.com"
SLUG = "nba-2027-champion"
FIDELITY = 60 # minutes per candle (hourly)
OUT = "data/polymarket_raw.csv"
def get_event():
r = requests.get(f"{GAMMA}/events", params={"slug": SLUG}, timeout=30)
r.raise_for_status()
return r.json()[0]
def team_tokens(event):
"""Return list of (team, yes_token_id)."""
out = []
for m in event.get("markets", []):
team = m.get("groupItemTitle") or m.get("question")
toks = json.loads(m.get("clobTokenIds") or "[]")
if team and toks:
out.append((team, toks[0])) # index 0 = "Yes" outcome
return out
def history(token, start_ts):
r = requests.get(
f"{CLOB}/prices-history",
params={"market": token, "startTs": start_ts, "fidelity": FIDELITY},
timeout=30,
)
r.raise_for_status()
return r.json().get("history", [])
def main():
import os
os.makedirs("data", exist_ok=True)
# start ~offseason baseline; endpoint clamps to earliest available (~May 13)
start_ts = int(time.mktime(time.strptime("2026-05-01", "%Y-%m-%d")))
ev = get_event()
tokens = team_tokens(ev)
print(f"event: {ev.get('title')} teams: {len(tokens)}", file=sys.stderr)
# per-team traded volume (for volume-weighting the blend)
meta = ["team,volume"]
vol_by_team = {m.get("groupItemTitle") or m.get("question"):
(m.get("volumeNum") or float(m.get("volume") or 0))
for m in ev.get("markets", [])}
rows = ["timestamp,team,price"]
for i, (team, tok) in enumerate(tokens):
h = history(tok, start_ts)
for p in h:
rows.append(f"{p['t']},{team},{p['p']}")
meta.append(f"{team},{vol_by_team.get(team, 0)}")
print(f" [{i+1}/{len(tokens)}] {team:<26} {len(h)} pts", file=sys.stderr)
time.sleep(0.15) # be polite
with open(OUT, "w") as f:
f.write("\n".join(rows))
with open("data/polymarket_meta.csv", "w") as f:
f.write("\n".join(meta))
print(f"wrote {OUT} ({len(rows)-1} rows)", file=sys.stderr)
if __name__ == "__main__":
main()
"""Fetch 2027 NBA Champion odds time-series from Kalshi (public reads, no auth).
Event KXNBA-27, series KXNBA, 30 markets KXNBA-27-<CODE>. We pull hourly
candlesticks per team and use the bid/ask midpoint (falls back to last trade)
as the implied probability. Team names are mapped to Polymarket's full names so
the two sources align. Output tidy CSV: timestamp, team, price, volume, oi
"""
import time
import sys
import os
import requests
BASE = "https://api.elections.kalshi.com/trade-api/v2"
SERIES = "KXNBA"
EVENT = "KXNBA-27"
OUT = "data/kalshi_raw.csv"
CODE2NAME = {
"ATL": "Atlanta Hawks", "BKN": "Brooklyn Nets", "BOS": "Boston Celtics",
"CHA": "Charlotte Hornets", "CHI": "Chicago Bulls", "CLE": "Cleveland Cavaliers",
"DAL": "Dallas Mavericks", "DEN": "Denver Nuggets", "DET": "Detroit Pistons",
"GSW": "Golden State Warriors", "HOU": "Houston Rockets", "IND": "Indiana Pacers",
"LAC": "Los Angeles Clippers", "LAL": "Los Angeles Lakers", "MEM": "Memphis Grizzlies",
"MIA": "Miami Heat", "MIL": "Milwaukee Bucks", "MIN": "Minnesota Timberwolves",
"NOP": "New Orleans Pelicans", "NYK": "New York Knicks", "OKC": "Oklahoma City Thunder",
"ORL": "Orlando Magic", "PHI": "Philadelphia 76ers", "PHX": "Phoenix Suns",
"POR": "Portland Trail Blazers", "SAC": "Sacramento Kings", "SAS": "San Antonio Spurs",
"TOR": "Toronto Raptors", "UTA": "Utah Jazz", "WAS": "Washington Wizards",
}
def markets():
r = requests.get(f"{BASE}/markets", params={"event_ticker": EVENT, "limit": 100}, timeout=30)
r.raise_for_status()
return [m["ticker"] for m in r.json()["markets"]]
def _price(candle):
"""Implied prob (0..1). Prefer last-trade close, then period VWAP mean, then
carried previous. Only trust the bid/ask midpoint when the spread is tight
(<=15c) — early thin candles have $0.99 asks that poison a raw midpoint."""
pr = candle.get("price") or {}
for f in ("close_dollars", "mean_dollars", "previous_dollars"):
v = pr.get(f)
if v not in (None, "") and float(v) > 0:
return float(v)
def g(side):
v = (candle.get(side) or {}).get("close_dollars")
return float(v) if v not in (None, "") else None
bid, ask = g("yes_bid"), g("yes_ask")
if bid is not None and ask is not None and 0 < ask and (ask - bid) <= 0.15:
return (bid + ask) / 2
return None
def candles(ticker, start_ts, end_ts):
url = f"{BASE}/series/{SERIES}/markets/{ticker}/candlesticks"
r = requests.get(url, params={"start_ts": start_ts, "end_ts": end_ts, "period_interval": 60}, timeout=30)
r.raise_for_status()
return r.json().get("candlesticks", [])
def main():
os.makedirs("data", exist_ok=True)
start_ts = int(time.mktime(time.strptime("2026-06-01", "%Y-%m-%d")))
end_ts = int(time.time())
rows = ["timestamp,team,price,volume,oi"]
for i, tk in enumerate(markets()):
code = tk.replace(f"{EVENT}-", "")
name = CODE2NAME.get(code, code)
cs = candles(tk, start_ts, end_ts)
n = 0
for c in cs:
p = _price(c)
if p is None or p <= 0:
continue
vol = c.get("volume_fp") or 0
oi = c.get("open_interest_fp") or 0
rows.append(f"{c['end_period_ts']},{name},{p},{vol},{oi}")
n += 1
print(f" [{i+1}/30] {name:<26} {n} pts", file=sys.stderr)
time.sleep(0.12)
with open(OUT, "w") as f:
f.write("\n".join(rows))
print(f"wrote {OUT} ({len(rows)-1} rows)", file=sys.stderr)
if __name__ == "__main__":
main()
"""Blend Polymarket + Kalshi into one 2027 NBA title-odds series and chart it.
per source: pivot -> resample to 6h grid -> ffill -> normalize each timestamp to
sum=100% (strips each book's vig). blend: per-team VOLUME-WEIGHTED average of the
normalized sources (Kalshi is deeper so it carries more weight); before Kalshi
opened (~Jun 14) it's Polymarket only. then movers are computed off two baselines
(eve of Finals, and morning after) and all 30 teams are charted with logos.
"""
import os
import math
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from matplotlib.offsetbox import OffsetImage, AnnotationBbox
from matplotlib.ticker import FuncFormatter
import yaml
POLY = "data/polymarket_raw.csv"
KALSHI = "data/kalshi_raw.csv"
PLOT_START = "2026-06-12" # day before the Knicks clinched (Jun 13); pre-Finals is too noisy
BASE = "2026-06-12" # single baseline: still captures the Finals result + all offseason moves
FREQ = "4h"
PLACEHOLDER = {"Team A", "Team B", "Team C", "Team D", "Team E", "Other"}
CODE = {
"Atlanta Hawks": "ATL", "Brooklyn Nets": "BKN", "Boston Celtics": "BOS",
"Charlotte Hornets": "CHA", "Chicago Bulls": "CHI", "Cleveland Cavaliers": "CLE",
"Dallas Mavericks": "DAL", "Denver Nuggets": "DEN", "Detroit Pistons": "DET",
"Golden State Warriors": "GSW", "Houston Rockets": "HOU", "Indiana Pacers": "IND",
"Los Angeles Clippers": "LAC", "Los Angeles Lakers": "LAL", "Memphis Grizzlies": "MEM",
"Miami Heat": "MIA", "Milwaukee Bucks": "MIL", "Minnesota Timberwolves": "MIN",
"New Orleans Pelicans": "NOP", "New York Knicks": "NYK", "Oklahoma City Thunder": "OKC",
"Orlando Magic": "ORL", "Philadelphia 76ers": "PHI", "Phoenix Suns": "PHX",
"Portland Trail Blazers": "POR", "Sacramento Kings": "SAC", "San Antonio Spurs": "SAS",
"Toronto Raptors": "TOR", "Utah Jazz": "UTA", "Washington Wizards": "WAS",
}
COLOR = {
"ATL": "#E03A3E", "BKN": "#111111", "BOS": "#007A33", "CHA": "#1D1160",
"CHI": "#CE1141", "CLE": "#860038", "DAL": "#00538C", "DEN": "#0E2240",
"DET": "#1D42BA", "GSW": "#1D428A", "HOU": "#CE1141", "IND": "#FDBB30",
"LAC": "#C8102E", "LAL": "#552583", "MEM": "#5D76A9", "MIA": "#98002E",
"MIL": "#00471B", "MIN": "#236192", "NOP": "#85714D", "NYK": "#F58426",
"OKC": "#007AC1", "ORL": "#0077C0", "PHI": "#006BB6", "PHX": "#E56020",
"POR": "#E03A3E", "SAC": "#5A2D81", "SAS": "#8A8D8F", "TOR": "#CE1141",
"UTA": "#002B5C", "WAS": "#002B5C",
}
CONF_EAST = {"ATL", "BKN", "BOS", "CHA", "CHI", "CLE", "DET", "IND", "MIA",
"MIL", "NYK", "ORL", "PHI", "TOR", "WAS"}
def source_norm(path):
df = pd.read_csv(path)
df = df[~df["team"].isin(PLACEHOLDER)]
df["ts"] = pd.to_datetime(df["timestamp"], unit="s", utc=True)
wide = df.pivot_table(index="ts", columns="team", values="price")
wide = wide.resample(FREQ).last().ffill()
keep = wide.columns[wide.notna().sum() > 20]
wide = wide[keep]
return wide.div(wide.sum(axis=1), axis=0)
def weights():
"""Per-team traded volume for each source (used to weight the blend)."""
wp = pd.read_csv("data/polymarket_meta.csv").set_index("team")["volume"]
k = pd.read_csv(KALSHI)
wk = k.groupby("team")["volume"].sum()
return wp, wk
def blend():
poly, kal = source_norm(POLY), source_norm(KALSHI)
idx = poly.index.union(kal.index)
teams = poly.columns.union(kal.columns)
poly = poly.reindex(index=idx, columns=teams).ffill()
kal = kal.reindex(index=idx, columns=teams).ffill()
wp, wk = weights()
wp = wp.reindex(teams).fillna(0) + 1.0 # +1 so no team has zero weight
wk = wk.reindex(teams).fillna(0) + 1.0
both = kal.notna()
blended_both = (poly.mul(wp, axis=1) + kal.mul(wk, axis=1)).div(wp + wk, axis=1)
out = blended_both.where(both, poly) # Polymarket-only before Kalshi opened
out = out[out.index >= pd.Timestamp(PLOT_START, tz="UTC")]
return out.div(out.sum(axis=1), axis=0) # renormalize to 100%
def smooth(norm, window=5):
"""Rolling-median clean-up (kills single/double thin-market tick spikes),
re-normalized. Used for BOTH the chart and the movers so numbers match viz."""
s = norm.rolling(window, center=True, min_periods=1).median()
return s.div(s.sum(axis=1), axis=0)
def movers(clean, start):
w = clean[clean.index >= pd.Timestamp(start, tz="UTC")]
first, last = w.iloc[0], w.iloc[-1]
return pd.DataFrame({
"start_%": (first * 100).round(2),
"now_%": (last * 100).round(2),
"delta_pp": ((last - first) * 100).round(2),
}).sort_values("delta_pp", ascending=False)
def _logo(code, zoom=0.045):
return OffsetImage(plt.imread(f"data/logos/{code}.png"), zoom=zoom)
def _decollide(order, gap, iters=300):
"""Nudge overlapping label y-positions apart symmetrically, staying as close
to each line's true endpoint as possible (no big ladder-style displacement)."""
teams = list(order.index) # high -> low
y = {t: float(order[t]) for t in teams}
for _ in range(iters):
moved = False
for i in range(len(teams) - 1):
a, b = teams[i], teams[i + 1]
d = y[a] - y[b]
if d < gap:
push = (gap - d) / 2
y[a] += push
y[b] -= push
moved = True
if not moved:
break
return y
def plot_lines(clean, out="data/odds_chart_lines.png"):
disp = clean * 100
final = disp.iloc[-1]
# keep teams at >=2% now, or that fell from a real level (5%+ peak) to under 2%
keep = [t for t in final.index if final[t] >= 2.0 or disp[t].max() >= 5.0]
disp = disp[keep]
order = disp.iloc[-1].sort_values(ascending=False)
ymax = order.max() + 2
fig, ax = plt.subplots(figsize=(15, 11))
for team in order.index:
c = CODE[team]
lw = 2.8 if order[team] > 3 else 1.1
a = 0.95 if order[team] > 3 else 0.40
ax.plot(disp.index, disp[team], color=COLOR[c], lw=lw, alpha=a, zorder=2 if lw > 2 else 1)
# logos sit ON each line's actual endpoint; only a gentle symmetric nudge so
# overlapping logos separate a little (no connector lines, no ladder -> the
# vertical position stays honest to where the line really ends).
label_x = disp.index[-1] + pd.Timedelta(hours=14)
ys = _decollide(order, gap=ymax * 0.042)
for team in order.index:
c = CODE[team]
ax.add_artist(AnnotationBbox(_logo(c, 0.05), (label_x, ys[team]), frameon=False,
xycoords="data", box_alignment=(0.5, 0.5), zorder=5))
ax.text(label_x + pd.Timedelta(hours=13), ys[team], f"{order[team]:.1f}%",
va="center", ha="left", fontsize=9, fontweight="bold",
color=COLOR[c], zorder=6)
# news annotations: a boxed callout anchored to the affected team's own line
try:
events = yaml.safe_load(open("events.yaml"))
except FileNotFoundError:
events = []
name_by_code = {v: k for k, v in CODE.items()}
for e in events:
x = pd.Timestamp(str(e["date"]), tz="UTC")
team = name_by_code.get(e["teams"][0])
if team not in disp.columns or not (disp.index[0] <= x <= disp.index[-1]):
continue
c = CODE[team]
yi = disp[team].iloc[disp.index.get_indexer([x], method="nearest")[0]]
ax.axvline(x, color="#333", ls=(0, (2, 3)), lw=1.2, alpha=.85, zorder=1)
ax.scatter([x], [yi], s=34, color=COLOR[c], edgecolor="white", lw=1, zorder=7)
ax.annotate(e["label"], xy=(x, yi),
xytext=(x + pd.Timedelta(days=e.get("dx", -2)), yi + e.get("dy", 3)),
fontsize=9, ha="center", va="center", color="#111", zorder=8,
bbox=dict(boxstyle="round,pad=0.35", fc="white", ec=COLOR[c], lw=1.3, alpha=0.97),
arrowprops=dict(arrowstyle="-", color=COLOR[c], lw=1))
ax.set_title("How the Offseason Reshaped the 2027 NBA Title Race",
fontsize=19, fontweight="bold", loc="left", pad=26)
ax.text(0, 1.02, "Implied championship probability, blended from ~$9M of Polymarket + Kalshi volume",
transform=ax.transAxes, fontsize=12, color="#555")
ax.set_ylabel("Title probability (%)")
ax.set_ylim(0, ymax)
ax.set_xlim(disp.index[0], label_x + pd.Timedelta(days=2.5))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d"))
ax.xaxis.set_major_locator(mdates.WeekdayLocator(interval=1))
ax.grid(axis="y", alpha=.25)
for s in ("top", "right"):
ax.spines[s].set_visible(False)
fig.tight_layout()
fig.savefig(out, dpi=150)
print(f"wrote {out}")
def plot_bump(clean, out="data/odds_chart_bump.png", step_freq="12h", subtitle=None):
"""Rank (bump) chart: all 30 teams, evenly spaced by rank so every line ends
at its own row and the logo sits right on the line-end (no collisions)."""
prob = clean * 100
n = prob.shape[1]
# rank on a daily cadence and draw straight segments -> sharp, honest rank
# moves (a team visibly jumps the day its trade lands) instead of wavy noise
step = prob.resample(step_freq).mean().dropna(how="all")
disp = step.rank(axis=1, ascending=False, method="first")
raw = disp
start, final = step.iloc[0], step.iloc[-1]
fig, ax = plt.subplots(figsize=(16, 16.5))
x0, x1 = disp.index[0], disp.index[-1]
left_x = x0 - pd.Timedelta(hours=16)
right_x = x1 + pd.Timedelta(hours=16)
LOGO_ZOOM = 0.054
for team in prob.columns:
c = CODE[team]
move = abs(raw[team].iloc[0] - raw[team].iloc[-1])
ax.plot(disp[team].index, disp[team], color=COLOR[c],
lw=2.0, alpha=0.85,
zorder=4 if move >= 4 else 2, solid_capstyle="round")
ax.text(left_x - pd.Timedelta(hours=17), raw[team].iloc[0], f"{start[team]:.1f}%",
va="center", ha="right", fontsize=8.5, color="#888", zorder=6)
ax.text(left_x - pd.Timedelta(hours=42), raw[team].iloc[0], f"{int(raw[team].iloc[0])}.",
va="center", ha="right", fontsize=12, fontweight="bold", color="#555", zorder=6)
ax.add_artist(AnnotationBbox(_logo(c, LOGO_ZOOM), (left_x, raw[team].iloc[0]),
frameon=False, box_alignment=(0.5, 0.5), zorder=6))
ax.add_artist(AnnotationBbox(_logo(c, LOGO_ZOOM), (right_x, raw[team].iloc[-1]),
frameon=False, box_alignment=(0.5, 0.5), zorder=6))
ax.text(right_x + pd.Timedelta(hours=17), raw[team].iloc[-1], f"{final[team]:.1f}%",
va="center", ha="left", fontsize=8.5, fontweight="bold", color=COLOR[c], zorder=6)
try:
events = yaml.safe_load(open("events.yaml"))
except FileNotFoundError:
events = []
name_by_code = {v: k for k, v in CODE.items()}
for e in events:
x = pd.Timestamp(str(e["date"]), tz="UTC")
team = name_by_code.get(e["teams"][0])
if team not in disp.columns or not (x0 <= x <= x1):
continue
c = CODE[team]
yi = disp[team].iloc[disp.index.get_indexer([x], method="nearest")[0]]
ax.axvline(x, color="#333", ls=(0, (2, 3)), lw=1.2, alpha=.85, zorder=1)
ax.scatter([x], [yi], s=36, color=COLOR[c], edgecolor="white", lw=1, zorder=7)
ax.annotate(e["label"], xy=(x, yi),
xytext=(x + pd.Timedelta(days=e.get("dx", -2)), yi - e.get("dy", 1.2) * 2.2),
fontsize=9, ha="center", va="center", color="#111", zorder=8,
bbox=dict(boxstyle="round,pad=0.35", fc="white", ec=COLOR[c], lw=1.3, alpha=0.97),
arrowprops=dict(arrowstyle="-", color=COLOR[c], lw=1))
ax.set_title("How the Offseason Reshaped the 2027 NBA Title Race",
fontsize=19, fontweight="bold", loc="left", pad=40)
ax.text(0, 1.012, subtitle or "Each team ranked by 2027 title odds (1 = favorite). Left % = day before the Finals, right % = now. Blended from ~$9M of market volume.",
transform=ax.transAxes, fontsize=12, color="#555")
ax.set_ylabel("Title-odds rank (1 = favorite)")
ax.set_ylim(n + 0.7, 0.3) # inverted: rank 1 at top
ax.set_yticks([1, 5, 10, 15, 20, 25, 30])
ax.set_xlim(left_x - pd.Timedelta(days=2.5), right_x + pd.Timedelta(days=2.2))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d"))
ax.xaxis.set_major_locator(mdates.WeekdayLocator(interval=1))
ax.grid(axis="y", alpha=.2)
for s in ("top", "right"):
ax.spines[s].set_visible(False)
fig.tight_layout()
fig.savefig(out, dpi=150)
print(f"wrote {out}")
def _bump_panel(ax, step, teams, events, name_by_code, title):
n = len(teams)
sub = step[teams]
disp = sub.rank(axis=1, ascending=False, method="first")
start, final = sub.iloc[0], sub.iloc[-1]
x0, x1 = disp.index[0], disp.index[-1]
left_x = x0 - pd.Timedelta(hours=14)
right_x = x1 + pd.Timedelta(hours=14)
for team in teams:
c = CODE[team]
r = disp[team]
big = abs(r.iloc[0] - r.iloc[-1]) >= 3
ax.plot(r.index, r, color=COLOR[c], lw=2.4 if big else 1.8,
alpha=0.95 if big else 0.7, zorder=4 if big else 2, solid_capstyle="round")
ax.text(left_x - pd.Timedelta(hours=15), r.iloc[0], f"{start[team]:.1f}%",
va="center", ha="right", fontsize=8.5, color="#888")
ax.add_artist(AnnotationBbox(_logo(c, 0.05), (left_x, r.iloc[0]),
frameon=False, box_alignment=(0.5, 0.5), zorder=6))
ax.add_artist(AnnotationBbox(_logo(c, 0.05), (right_x, r.iloc[-1]),
frameon=False, box_alignment=(0.5, 0.5), zorder=6))
ax.text(right_x + pd.Timedelta(hours=15), r.iloc[-1], f"{final[team]:.1f}%",
va="center", ha="left", fontsize=8.5, fontweight="bold", color=COLOR[c])
for e in events:
team = name_by_code.get(e["teams"][0])
x = pd.Timestamp(str(e["date"]), tz="UTC")
if team not in teams or not (x0 <= x <= x1):
continue
c = CODE[team]
yi = disp[team].iloc[disp.index.get_indexer([x], method="nearest")[0]]
ax.axvline(x, color="#333", ls=(0, (2, 3)), lw=1.1, alpha=.8, zorder=1)
ax.scatter([x], [yi], s=32, color=COLOR[c], edgecolor="white", lw=1, zorder=7)
ax.annotate(e["label"], xy=(x, yi),
xytext=(x + pd.Timedelta(days=e.get("dx", -0.4)), yi + e.get("dy", 1.2)),
fontsize=8.5, ha="center", va="center", color="#111", zorder=8,
bbox=dict(boxstyle="round,pad=0.3", fc="white", ec=COLOR[c], lw=1.2, alpha=0.97),
arrowprops=dict(arrowstyle="-", color=COLOR[c], lw=1))
ax.set_ylim(n + 0.7, 0.3)
ax.set_yticks([1, 5, 10, 15])
ax.set_ylabel("Rank in conference")
ax.set_xlim(left_x - pd.Timedelta(days=1.5), right_x + pd.Timedelta(days=1.6))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d"))
ax.xaxis.set_major_locator(mdates.WeekdayLocator(interval=1))
ax.grid(axis="y", alpha=.2)
for s in ("top", "right"):
ax.spines[s].set_visible(False)
ax.set_title(title, fontsize=15, fontweight="bold", loc="left")
def _line_panel(ax, disp, teams, events, name_by_code, title, log=False):
disp = disp[teams]
order = disp.iloc[-1].sort_values(ascending=False)
hi_q = order.quantile(0.6)
lo = 0.3
hi = order.max() * (1.18 if log else 1.12) + (0 if log else 0.6)
for team in teams:
c = CODE[team]
big = order[team] >= hi_q
ax.plot(disp.index, disp[team].clip(lower=lo if log else None),
color=COLOR[c], lw=2.4 if big else 1.4,
alpha=0.95 if big else 0.6, zorder=4 if big else 2, solid_capstyle="round")
# logo sits right at the height where each line ends (no de-collision, no
# leader lines -> nothing looks like it jumped). overlap on ties is fine.
label_x = disp.index[-1] + pd.Timedelta(hours=8)
for team in order.index:
c = CODE[team]
ax.add_artist(AnnotationBbox(_logo(c, 0.058), (label_x, order[team]),
frameon=False, box_alignment=(0.5, 0.5), zorder=6))
ax.text(label_x + pd.Timedelta(hours=19), order[team], f"{order[team]:.1f}%",
va="center", ha="left", fontsize=16, fontweight="bold", color=COLOR[c])
for e in events:
team = name_by_code.get(e["teams"][0])
x = pd.Timestamp(str(e["date"]), tz="UTC")
if team not in teams or not (disp.index[0] <= x <= disp.index[-1]):
continue
c = CODE[team]
yi = disp[team].iloc[disp.index.get_indexer([x], method="nearest")[0]]
up = e.get("dy", 1) >= 0
ytext = yi * (1.35 if up else 1 / 1.35) if log else yi + (1 if up else -1) * hi * 0.08
ax.axvline(x, color="#333", ls=(0, (2, 3)), lw=1, alpha=.7, zorder=1)
ax.scatter([x], [max(yi, lo)], s=30, color=COLOR[c], edgecolor="white", lw=1, zorder=7)
ax.annotate(e["label"], xy=(x, max(yi, lo)),
xytext=(x + pd.Timedelta(days=e.get("dx", -0.3)), max(ytext, lo)),
fontsize=11, ha="center", va="center", color="#111", zorder=8,
bbox=dict(boxstyle="round,pad=0.35", fc="white", ec=COLOR[c], lw=1.3, alpha=0.97),
arrowprops=dict(arrowstyle="-", color=COLOR[c], lw=1))
if log:
ax.set_yscale("log")
ax.set_ylim(lo, hi)
ax.set_yticks([0.5, 1, 2, 5, 10, 20])
ax.yaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v:g}"))
else:
ax.set_ylim(0, hi)
ax.set_xlim(disp.index[0], label_x + pd.Timedelta(days=3.4))
ax.set_ylabel("Title probability (%)" + (" — log scale" if log else ""))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d"))
ax.xaxis.set_major_locator(mdates.WeekdayLocator(interval=1))
ax.grid(axis="y", alpha=.2)
for s in ("top", "right"):
ax.spines[s].set_visible(False)
ax.set_title(title, fontsize=15, fontweight="bold", loc="left")
def plot_lines_conf(clean, out="data/odds_chart_lines_conf.png"):
"""Two stacked line panels (West top, East bottom) of true title probability."""
disp = clean * 100
try:
events = yaml.safe_load(open("events.yaml"))
except FileNotFoundError:
events = []
name_by_code = {v: k for k, v in CODE.items()}
west = [t for t in disp.columns if CODE[t] not in CONF_EAST]
east = [t for t in disp.columns if CODE[t] in CONF_EAST]
log = out.endswith("_log.png")
fig, (axe, axw) = plt.subplots(2, 1, figsize=(15, 18))
_line_panel(axe, disp, east, events, name_by_code, "Eastern Conference", log=log)
_line_panel(axw, disp, west, events, name_by_code, "Western Conference", log=log)
fig.suptitle("How the Offseason Reshaped the 2027 NBA Title Race",
fontsize=18, fontweight="bold", x=0.02, ha="left")
fig.text(0.02, 0.955, "Each team's implied probability of winning the 2027 title, by conference. Blended from ~$9M of market volume.",
fontsize=11, color="#555")
fig.tight_layout(rect=[0, 0, 1, 0.95])
fig.savefig(out, dpi=150)
print(f"wrote {out}")
def plot_bump_conf(clean, out="data/odds_chart_conf.png"):
"""Two stacked bump panels (West on top, East on bottom), teams ranked within
their own conference by 2027 title odds."""
prob = clean * 100
step = prob.resample("12h").mean().dropna(how="all")
try:
events = yaml.safe_load(open("events.yaml"))
except FileNotFoundError:
events = []
name_by_code = {v: k for k, v in CODE.items()}
west = [t for t in prob.columns if CODE[t] not in CONF_EAST]
east = [t for t in prob.columns if CODE[t] in CONF_EAST]
fig, (axe, axw) = plt.subplots(2, 1, figsize=(15, 19))
_bump_panel(axe, step, east, events, name_by_code, "Eastern Conference")
_bump_panel(axw, step, west, events, name_by_code, "Western Conference")
fig.suptitle("How the Offseason Reshaped the 2027 NBA Title Race",
fontsize=18, fontweight="bold", x=0.02, ha="left")
fig.text(0.02, 0.955, "Teams ranked within their conference by 2027 title odds. Left % = day before the Finals, right % = now. Blended from ~$9M of market volume.",
fontsize=11, color="#555")
fig.tight_layout(rect=[0, 0, 1, 0.95])
fig.savefig(out, dpi=150)
print(f"wrote {out}")
if __name__ == "__main__":
os.makedirs("data", exist_ok=True)
clean = smooth(blend())
print(f"blended: {clean.shape[1]} teams x {clean.shape[0]} timepoints "
f"({clean.index[0]:%b %d} -> {clean.index[-1]:%b %d})")
tbl = movers(clean, BASE)
tbl.to_csv("data/movers.csv")
print(f"\n===== movers since the day before the Finals (baseline {BASE}) =====")
print("TOP 6 RISERS:\n" + tbl.head(6).to_string())
print("\nTOP 6 FALLERS:\n" + tbl.tail(6).sort_values("delta_pp").to_string())
plot_lines(clean)
plot_bump(clean)
clean3 = clean[clean.index >= clean.index[-1] - pd.Timedelta(days=3)]
plot_bump(clean3, out="data/odds_chart_bump_3d.png", step_freq="6h",
subtitle="Rank by 2027 title odds over the last 3 days (free agency). Left % = 3 days ago, right % = now.")
plot_bump_conf(clean)
plot_lines_conf(clean)
plot_lines_conf(clean, out="data/odds_chart_lines_conf_log.png")
"""Robustness check: do the headline movers survive reasonable method changes?
We recompute the post-Finals (Jun 14) movers under several defensible variants:
- Polymarket only
- Kalshi only
- equal-weight blend
- volume-weight blend (the one used in the post)
and under light vs heavier smoothing. If the top risers/fallers are the same set
across all of them, the result isn't an artifact of one tuning choice.
"""
import pandas as pd
import analyze as A
def norm_pair():
p = A.source_norm(A.POLY)
k = A.source_norm(A.KALSHI)
idx = p.index.union(k.index)
cols = p.columns.union(k.columns)
return (p.reindex(index=idx, columns=cols).ffill(),
k.reindex(index=idx, columns=cols).ffill())
def build(kind):
p, k = norm_pair()
both = k.notna()
if kind == "poly":
s = p
elif kind == "kalshi":
s = k.where(both, p) # fall back to poly before Kalshi opens
elif kind == "equal":
s = ((p + k) / 2).where(both, p)
else: # volume-weighted (post's method)
wp, wk = A.weights()
wp = wp.reindex(p.columns).fillna(0) + 1
wk = wk.reindex(k.columns).fillna(0) + 1
s = (p.mul(wp, axis=1) + k.mul(wk, axis=1)).div(wp + wk, axis=1).where(both, p)
s = s[s.index >= pd.Timestamp(A.PLOT_START, tz="UTC")]
return s.div(s.sum(axis=1), axis=0)
def top_movers(kind, window, base, n=5):
clean = A.smooth(build(kind), window)
m = A.movers(clean, base)
return list(m.head(n).index), list(m.tail(n).sort_values("delta_pp").index)
if __name__ == "__main__":
base = A.BASE_POST
print(f"Post-Finals top-5 movers under each variant (baseline {base}):\n")
variants = [("poly", 5), ("kalshi", 5), ("equal", 5), ("volwt", 5),
("volwt", 3), ("volwt", 7)]
up_sets, dn_sets = [], []
for kind, w in variants:
up, dn = top_movers(kind, w, base)
up_sets.append(set(up)); dn_sets.append(set(dn))
print(f"[{kind:6} win{w}] UP: {', '.join(A.CODE[t] for t in up)}")
print(f"{'':14} DN: {', '.join(A.CODE[t] for t in dn)}")
print("\nAlways a top-5 RISER:", ", ".join(A.CODE[t] for t in set.intersection(*up_sets)))
print("Always a top-5 FALLER:", ", ".join(A.CODE[t] for t in set.intersection(*dn_sets)))
# Hand-curated offseason news markers overlaid on the odds chart.
# Anchored to the FIRST team's line at that date; boxes sit tight to the line.
# dx = days offset, dy = rank offset within conference (negative = up).
# East panel:
- date: 2026-06-13
label: "Knicks win\nthe title"
teams: [NYK]
dx: 0.24
dy: -0.71
- date: 2026-06-22
label: "Giannis to\nHeat"
teams: [MIA]
dx: -0.18
dy: -0.71
- date: 2026-06-22
label: "Bucks trade\nGiannis"
teams: [MIL]
dx: -0.24
dy: 0.76
- date: 2026-06-30
label: "Kawhi to\nRaptors"
teams: [TOR]
dx: -0.29
dy: -0.71
- date: 2026-07-01
label: "Brown to\nSixers"
teams: [PHI]
dx: -0.5
dy: -0.76
- date: 2026-07-01
label: "Celtics\ntrade Brown"
teams: [BOS]
dx: -0.5
dy: 0.76
# West panel:
- date: 2026-06-30
label: "Warriors\nadd Horford"
teams: [GSW]
dx: -0.5
dy: 0.9
- date: 2026-07-01
label: "LeBron says\nhe's leaving LA"
teams: [LAL]
dx: -0.6
dy: -0.9
- date: 2026-06-27
label: "Ja Morant\nto Blazers"
teams: [POR]
dx: 0.5
dy: 0.9
- date: 2026-06-23
label: "Dybantsa\nNo. 1 pick"
teams: [WAS]
dx: 0.5
dy: 0.9
- date: 2026-06-13
label: "Spurs lose\nthe Finals"
teams: [SAS]
dx: 0.7
dy: -0.9
- date: 2026-06-24
label: "LaMelo to\nTimberwolves"
teams: [MIN]
dx: 0.24
dy: -0.71
- date: 2026-06-24
label: "Hornets trade\nLaMelo"
teams: [CHA]
dx: 0.24
dy: 0.76
- date: 2026-06-30
label: "Kawhi out\nof L.A."
teams: [LAC]
dx: -0.24
dy: 0.76
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment