|
"""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") |