|
"""2026 World Cup: two views of how title-winner odds narrowed as the field shrank. |
|
|
|
- plot_bump: teams ranked among the still-alive field; a line ends when its team is |
|
eliminated (odds -> 0). Survivors colored, eliminated faint grey. |
|
- plot_stack: win probability (normalized to 100%) stacked; eliminated bands pinch |
|
out over time and the survivors' bands swell. |
|
Data: Polymarket world-cup-winner (~$3.7B volume). |
|
""" |
|
import datetime |
|
import pandas as pd |
|
import matplotlib.pyplot as plt |
|
import matplotlib.dates as mdates |
|
from matplotlib.offsetbox import OffsetImage, AnnotationBbox |
|
|
|
RAW = "data/wc_raw.csv" |
|
START = "2026-06-10" |
|
ELIM = 0.2 |
|
|
|
# tournament stages (start, end, label) — chart dividers/labels appear as each begins |
|
STAGES = [ |
|
("2026-06-11", "2026-06-28", "GROUP STAGE"), |
|
("2026-06-28", "2026-07-04", "ROUND OF 32"), |
|
("2026-07-04", "2026-07-09", "ROUND OF 16"), |
|
("2026-07-09", "2026-07-14", "QUARTER-FINALS"), |
|
("2026-07-14", "2026-07-18", "SEMI-FINALS"), |
|
("2026-07-18", "2026-07-21", "FINAL"), |
|
] |
|
STAGE_DISPLAY = {"GROUP STAGE": "Group stage", "ROUND OF 32": "Round of 32", |
|
"ROUND OF 16": "Round of 16", "QUARTER-FINALS": "Quarter-finals", |
|
"SEMI-FINALS": "Semi-finals", "FINAL": "Final"} |
|
|
|
|
|
def current_stage(as_of=None): |
|
"""Display label of the stage in progress on `as_of` (YYYY-MM-DD str); default = today UTC.""" |
|
import datetime as _dt |
|
day = as_of or _dt.datetime.utcnow().strftime("%Y-%m-%d") |
|
label = "GROUP STAGE" |
|
for s0, _s1, lab in STAGES: |
|
if s0 <= day: |
|
label = lab |
|
return STAGE_DISPLAY.get(label, label.title()) |
|
|
|
|
|
def load(): |
|
df = pd.read_csv(RAW) |
|
df = df[~df.team.str.startswith("Team ")] |
|
df["ts"] = pd.to_datetime(df["timestamp"], unit="s", utc=True) |
|
w = df.pivot_table(index="ts", columns="team", values="price") |
|
raw_now = w.ffill().iloc[-1] * 100 # true latest odds per team |
|
w = w.resample("6h").last().ffill() |
|
w = w[w.index >= pd.Timestamp(START, tz="UTC")] |
|
sm = (w * 100).rolling(4, center=True, min_periods=1).median() |
|
sm.iloc[-1] = raw_now.reindex(sm.columns) # current point = latest tick, not smoothed |
|
return sm |
|
|
|
|
|
def load_hourly(): |
|
"""Full hourly resolution (no rank/6h resample) for the odds-over-time line chart.""" |
|
df = pd.read_csv(RAW) |
|
df = df[~df.team.str.startswith("Team ")] |
|
df["ts"] = pd.to_datetime(df["timestamp"], unit="s", utc=True) |
|
w = df.pivot_table(index="ts", columns="team", values="price") |
|
w = w.resample("1h").last().ffill() |
|
w = w[w.index >= pd.Timestamp(START, tz="UTC")] |
|
return w * 100 |
|
|
|
|
|
def flag(team, zoom): |
|
return OffsetImage(plt.imread(f"data/flags/{team}.png"), zoom=zoom) |
|
|
|
|
|
def survivors_and_exits(sm): |
|
"""Use REAL knockout dates (data/wc_elims.csv), not odds. A team's line runs |
|
flat until the actual date it was eliminated, even if its odds sat at the floor.""" |
|
el = pd.read_csv("data/wc_elims.csv") |
|
last = sm.index[-1] |
|
exit_t, peak, alive = {}, {}, [] |
|
for _, r in el.iterrows(): |
|
t = r["team"] |
|
if t not in sm.columns: |
|
continue |
|
peak[t] = sm[t].max() |
|
if r["status"] == "alive": |
|
exit_t[t] = last |
|
alive.append(t) |
|
else: |
|
exit_t[t] = min(pd.Timestamp(r["date"], tz="UTC"), last) |
|
return exit_t, peak, alive |
|
|
|
|
|
def plot_bump(sm, out="data/wc_bump.png", deltas=None, dark=False, squeeze=False, figsize=(18, 16)): |
|
exit_t, peak, alive = survivors_and_exits(sm) |
|
alive_mask = pd.DataFrame({t: (sm.index <= exit_t[t]) for t in sm.columns}, index=sm.index) |
|
rank = sm.where(alive_mask).rank(axis=1, ascending=False, method="first") |
|
nmax = int(rank.max().max()) |
|
order = sm[alive].iloc[-1].sort_values(ascending=False).index |
|
A_n = len(alive) |
|
SQ = 0.42 # eliminated-rank compression factor when squeeze=True |
|
npos = nmax # bottom rank's plotted position |
|
if squeeze: # compress ranks below the alive teams (kills dead space) |
|
_m = rank > A_n |
|
rank = rank.where(~_m, A_n + (rank - A_n) * SQ) |
|
npos = A_n + (nmax - A_n) * SQ |
|
|
|
BG = "#0e1013" if dark else "white" |
|
FG = "#e9ebee" if dark else "#141414" |
|
MUTE = "#9aa1aa" if dark else "#666" |
|
FAINT_LINE = "#41454d" if dark else "#cccccc" # eliminated team lines |
|
FAINT_TXT = "#6a7079" if dark else "#bbb" # pre-tournament %, secondary labels |
|
GRID = "#2a2e35" if dark else "#dddddd" |
|
|
|
fig, ax = plt.subplots(figsize=figsize) # tall so survivor flags don't collide |
|
fig.patch.set_facecolor(BG) |
|
ax.set_facecolor(BG) |
|
colors = plt.cm.tab20.colors + plt.cm.tab20b.colors |
|
cmap = {t: colors[i % len(colors)] for i, t in enumerate(order)} |
|
|
|
FZ = 12 # flags always on top of every line |
|
# tournament stage dividers + labels — added automatically as each stage begins |
|
last = sm.index[-1] |
|
for s0s, s1s, label in STAGES: |
|
s0 = pd.Timestamp(s0s, tz="UTC") |
|
if s0 > last: # stage hasn't started yet |
|
continue |
|
s1 = pd.Timestamp(s1s, tz="UTC") |
|
if label != "GROUP STAGE": # dotted divider at each knockout stage's start |
|
ax.axvline(s0, color="#777", ls=(0, (3, 3)), lw=1.3, alpha=0.8, zorder=1) |
|
mid = s0 + (min(s1, last) - s0) / 2 # centre the label in the stage's visible span |
|
active = s1 > last # the stage currently in progress |
|
ax.text(mid, -1.6, label, ha="center", va="center", fontsize=13, fontweight="bold", |
|
color=(FG if active else "#bbb"), zorder=1) |
|
|
|
# all 48 teams: line runs to the real knockout date; flag at start (left) & exit |
|
for t in sm.columns: |
|
if t in alive: |
|
continue |
|
r = rank[t].dropna() |
|
if r.empty: |
|
continue |
|
ax.plot(r.index, r.values, color=FAINT_LINE, lw=0.9, alpha=0.7, zorder=2, |
|
solid_capstyle="round") |
|
# pre-tournament odds on the left for every team, even eliminated ones |
|
ax.annotate(f"{sm[t].iloc[0]:.2f}%", (r.index[0], r.values[0]), xytext=(-30, 0), |
|
textcoords="offset points", va="center", ha="right", fontsize=7.5, color=FAINT_TXT) |
|
ax.add_artist(AnnotationBbox(flag(t, 0.09), (r.index[0], r.values[0]), frameon=False, |
|
box_alignment=(1, 0.5), zorder=FZ, xybox=(-5, 0), boxcoords="offset points")) |
|
ax.add_artist(AnnotationBbox(flag(t, 0.11), (r.index[-1], r.values[-1]), frameon=False, |
|
box_alignment=(0, 0.5), zorder=FZ, xybox=(6, 0), boxcoords="offset points")) |
|
# bold cross over the flag at the knockout point = eliminated / done |
|
ax.annotate("✕", (r.index[-1], r.values[-1]), xytext=(15, 0), |
|
textcoords="offset points", ha="center", va="center", fontsize=22, |
|
fontweight="bold", color="#e01b3d", alpha=0.72, zorder=FZ + 1) |
|
for t in order: |
|
r = rank[t].dropna() |
|
ax.plot(r.index, r.values, color=cmap[t], lw=3.0, alpha=0.97, zorder=5, |
|
solid_capstyle="round") |
|
ax.annotate(f"{sm[t].iloc[0]:.2f}%", (r.index[0], r.values[0]), xytext=(-32, 0), |
|
textcoords="offset points", va="center", ha="right", |
|
fontsize=9, fontweight="bold", color=cmap[t]) |
|
ax.add_artist(AnnotationBbox(flag(t, 0.12), (r.index[0], r.values[0]), frameon=False, |
|
box_alignment=(1, 0.5), zorder=FZ, xybox=(-6, 0), boxcoords="offset points")) |
|
xe, ye = r.index[-1], r.values[-1] |
|
# current rank number, then flag, then odds — so viewers can rank-compare fast |
|
ax.annotate(f"{int(round(ye))}", (xe, ye), xytext=(6, 0), textcoords="offset points", |
|
va="center", ha="left", fontsize=10.5, fontweight="bold", color=cmap[t]) |
|
ax.add_artist(AnnotationBbox(flag(t, 0.15), (xe, ye), frameon=False, |
|
box_alignment=(0, 0.5), zorder=FZ, xybox=(28, 0), boxcoords="offset points")) |
|
ax.annotate(f"{sm[t].iloc[-1]:.2f}%", (xe, ye), xytext=(60, 0), |
|
textcoords="offset points", va="center", ha="left", |
|
fontsize=10, fontweight="bold", color=cmap[t]) |
|
if deltas and t in deltas: |
|
_d1h, _d24 = deltas[t] |
|
else: |
|
_pre = sm[t][sm.index <= sm.index[-1] - pd.Timedelta(hours=24)] |
|
_d1h, _d24 = None, sm[t].iloc[-1] - (_pre.iloc[-1] if len(_pre) else sm[t].iloc[0]) |
|
for _v, _off in ((_d1h, 118), (_d24, 170)): |
|
if _v is None: |
|
continue |
|
_dcol = MUTE if abs(_v) < 0.005 else ("#0a7a0a" if _v > 0 else "#c0392b") |
|
ax.annotate(("+" if _v >= 0 else "−") + f"{abs(_v):.2f}", (xe, ye), xytext=(_off, 0), |
|
textcoords="offset points", va="center", ha="left", fontsize=8.5, |
|
fontweight="bold", color=_dcol) |
|
|
|
# column headers on the right (aligned with the odds / 1h / 24h values) |
|
_hx = sm.index[-1] |
|
for _lbl, _off in (("#", 6), ("odds", 60), ("1h", 118), ("24h", 170)): |
|
ax.annotate(_lbl, (_hx, 0.15), xytext=(_off, 0), textcoords="offset points", |
|
ha="left", va="center", fontsize=8, color=MUTE, fontweight="bold") |
|
|
|
ax.set_ylim(npos + 0.7, -3.0) # headroom above rank 1 for the stage labels |
|
if squeeze: # every alive rank, then every 5th of the compressed tail |
|
_ticks = list(range(1, A_n + 1)) + [r for r in range(A_n + 1, nmax + 1) if r % 5 == 0] |
|
ax.set_yticks([r if r <= A_n else A_n + (r - A_n) * SQ for r in _ticks]) |
|
ax.set_yticklabels([str(r) for r in _ticks]) |
|
else: |
|
ax.set_yticks(range(1, nmax + 1)) # every rank, so all teams' positions are readable |
|
ax.set_ylabel("Rank among teams still alive", color=FG) |
|
ax.set_xlim(sm.index[0] - pd.Timedelta(days=1.9), sm.index[-1] + pd.Timedelta(days=4.7)) |
|
ax.xaxis.set_major_locator(mdates.DayLocator(interval=1)) # every single day |
|
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d")) |
|
ax.tick_params(colors=FG) |
|
ax.tick_params(axis="y", labelsize=8) # smaller so all rank labels fit |
|
ax.tick_params(axis="x", labelsize=8, rotation=45) # keep daily labels legible |
|
for lbl in ax.get_xticklabels(): |
|
lbl.set_ha("right") |
|
ax.grid(axis="y", alpha=.18, color=GRID) # subtle — there are now a line per rank |
|
for s in ("top", "right"): |
|
ax.spines[s].set_visible(False) |
|
for s in ("left", "bottom"): |
|
ax.spines[s].set_color(GRID) |
|
# (headline lives on the website now) — short two-line caption |
|
ax.text(0, 1.020, f"Win-the-Cup odds among the {len(alive)} teams still alive; grey = eliminated.", |
|
transform=ax.transAxes, fontsize=13, color=FG, fontweight="bold") |
|
ax.text(0, 1.005, "Left of flag = pre-tournament odds · right = current odds, 1h & 24h change · source: ~$3.7B betting volume", |
|
transform=ax.transAxes, fontsize=11, color=MUTE) |
|
stamp = datetime.datetime.utcnow().strftime("%b %d, %Y · %H:%M UTC") |
|
fig.text(0.995, 0.004, f"As of {stamp} · cupcharts.com", ha="right", va="bottom", |
|
fontsize=10, color=MUTE) |
|
fig.tight_layout() |
|
fig.savefig(out, dpi=150) |
|
plt.close(fig) # free the figure so the 1-min loop doesn't leak memory |
|
print(f"wrote {out} (alive: {len(alive)})") |
|
|
|
|
|
def plot_odds(out="data/wc_odds.png", dark=False): |
|
"""Absolute win-the-Cup odds (%) over time for every still-alive team, linear y from 0 |
|
(so sub-1% teams sit flat at the bottom instead of looking weird on a log axis), |
|
hourly detail with daily gridlines, flag + current % at each line's right end.""" |
|
# rolling-median smooth kills hourly jitter; clip so near-floor teams sit flat at 0.1% |
|
raw_h = load_hourly() |
|
hourly = raw_h.rolling(7, center=True, min_periods=1).median().clip(lower=0.1) |
|
hourly.iloc[-1] = raw_h.iloc[-1].clip(lower=0.1) # current point = latest tick, not smoothed |
|
exit_t, peak, alive = survivors_and_exits(load()) # alive set from wc_elims.csv |
|
alive = [t for t in alive if t in hourly.columns] |
|
now = hourly.iloc[-1] |
|
order = list(now[alive].sort_values(ascending=False).index) |
|
|
|
BG = "#0e1013" if dark else "white" |
|
FG = "#e9ebee" if dark else "#141414" |
|
MUTE = "#9aa1aa" if dark else "#666" |
|
GRID = "#2a2e35" if dark else "#dddddd" |
|
MINOR = "#20242b" if dark else "#eeeeee" |
|
|
|
fig, ax = plt.subplots(figsize=(18, 11)) |
|
fig.patch.set_facecolor(BG) |
|
ax.set_facecolor(BG) |
|
colors = plt.cm.tab20.colors + plt.cm.tab20b.colors |
|
cmap = {t: colors[i % len(colors)] for i, t in enumerate(order)} |
|
|
|
last_i = hourly.index[-1] |
|
for s0s, _s1s, label in STAGES: # knockout-stage dividers as they begin |
|
if label == "GROUP STAGE": |
|
continue |
|
s0 = pd.Timestamp(s0s, tz="UTC") |
|
if s0 <= last_i: |
|
ax.axvline(s0, color="#777", ls=(0, (3, 3)), lw=1.2, alpha=0.7, zorder=1) |
|
|
|
# every team's line, with flag + current % right at the line's end |
|
# (log axis spreads the field naturally, so no leader-line dragging needed) |
|
for t in order: |
|
s = hourly[t].dropna() |
|
ax.plot(s.index, s.values, color=cmap[t], lw=2.1, alpha=0.95, |
|
zorder=4, solid_capstyle="round") |
|
xe, ye = s.index[-1], s.values[-1] |
|
ax.add_artist(AnnotationBbox(flag(t, 0.12), (xe, ye), frameon=False, |
|
box_alignment=(0, 0.5), zorder=8, xybox=(7, 0), boxcoords="offset points")) |
|
ax.annotate(f"{ye:.2f}%", (xe, ye), xytext=(34, 0), textcoords="offset points", |
|
va="center", ha="left", fontsize=9, fontweight="bold", color=cmap[t]) |
|
|
|
ax.set_yscale("log") |
|
yt = [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 40] |
|
ax.set_yticks(yt) |
|
ax.set_yticklabels([f"{v:g}%" for v in yt]) |
|
ax.set_ylim(0.1, max(60, float(now[alive].max()) * 1.6)) |
|
ax.set_ylabel("Win-the-Cup odds (log scale)", color=FG) |
|
|
|
ax.set_xlim(hourly.index[0], hourly.index[-1] + pd.Timedelta(days=2.4)) |
|
ax.xaxis.set_major_locator(mdates.DayLocator(interval=1)) # daily labels |
|
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d")) |
|
ax.xaxis.set_minor_locator(mdates.HourLocator(byhour=(0, 6, 12, 18))) # hourly detail |
|
ax.tick_params(colors=FG) |
|
ax.tick_params(axis="x", labelsize=8, rotation=45) |
|
for lbl in ax.get_xticklabels(): |
|
lbl.set_ha("right") |
|
ax.grid(which="major", axis="both", alpha=.5, color=GRID) |
|
ax.grid(which="minor", axis="x", alpha=.3, color=MINOR) |
|
for sp in ("top", "right"): |
|
ax.spines[sp].set_visible(False) |
|
for sp in ("left", "bottom"): |
|
ax.spines[sp].set_color(GRID) |
|
|
|
ax.text(0, 1.020, f"Title odds over time — {len(alive)} teams still alive", |
|
transform=ax.transAxes, fontsize=14, color=FG, fontweight="bold") |
|
ax.text(0, 1.005, "Each remaining team's win-the-Cup odds from before kickoff to now · hourly, daily gridlines · source: ~$3.7B betting volume", |
|
transform=ax.transAxes, fontsize=11, color=MUTE) |
|
stamp = datetime.datetime.utcnow().strftime("%b %d, %Y · %H:%M UTC") |
|
fig.text(0.995, 0.004, f"As of {stamp} · cupcharts.com", ha="right", va="bottom", |
|
fontsize=10, color=MUTE) |
|
fig.tight_layout() |
|
fig.savefig(out, dpi=150) |
|
plt.close(fig) # free the figure so the 1-min loop doesn't leak memory |
|
print(f"wrote {out} (alive: {len(alive)})") |
|
|
|
|
|
HOME_COL, AWAY_COL = "#2a7de1", "#e0603a" |
|
|
|
|
|
def plot_match(home, away, hist, out, dark=False, goals=None, notables=None, |
|
kickoff=None, halftime=None, result=None): |
|
"""Small expandable chart: 2-way 'to win the match' odds over time (draw removed, |
|
the two teams rescaled to 100%). hist = {'home': [(t,pct)...], 'away': [...]}. |
|
goals -> dotted line + "Scorer min'" per goal; notables -> missed/saved penalties; |
|
kickoff/halftime -> phase lines; result ('home'/'away') snaps to a clean 100%/0% finish.""" |
|
BG = "#0e1013" if dark else "white" |
|
FG = "#e9ebee" if dark else "#141414" |
|
MUTE = "#9aa1aa" if dark else "#666" |
|
GRID = "#2a2e35" if dark else "#e6e6e6" |
|
|
|
# align home/away by the union of timestamps (a low-liquidity token can have sparse |
|
# history, so forward-fill each rather than requiring exact-timestamp matches) |
|
hd = dict(hist.get("home") or []) |
|
ad = dict(hist.get("away") or []) |
|
if not hd or not ad: |
|
return |
|
pts, lh, la = [], None, None |
|
for t in sorted(set(hd) | set(ad)): |
|
lh = hd.get(t, lh) |
|
la = ad.get(t, la) |
|
if lh is not None and la is not None and (lh + la) > 0: |
|
pts.append((t, lh, la)) |
|
if not pts: |
|
return |
|
fig, ax = plt.subplots(figsize=(8.4, 3.4)) |
|
fig.patch.set_facecolor(BG) |
|
ax.set_facecolor(BG) |
|
xs = [pd.Timestamp(t, unit="s", tz="UTC") for t, _, _ in pts] |
|
# when the game goes level (extra time / penalties) the 90-min market collapses to a draw, |
|
# so home & away both near 0 -> the 2-way ratio is noise. Hold the last meaningful split |
|
# through that period rather than crashing to a fake 50/50; the result snap ends it cleanly. |
|
home_ys, away_ys, lh, la = [], [], 50.0, 50.0 |
|
for _, hp, ap in pts: |
|
if hp + ap >= 5: |
|
lh, la = hp / (hp + ap) * 100, ap / (hp + ap) * 100 |
|
home_ys.append(lh) |
|
away_ys.append(la) |
|
if result == "home": # settled — snap off Polymarket's residual stink bid |
|
home_ys[-1], away_ys[-1] = 100.0, 0.0 |
|
elif result == "away": |
|
home_ys[-1], away_ys[-1] = 0.0, 100.0 |
|
lo, hi = xs[0], xs[-1] |
|
|
|
# phase lines: kickoff + halftime (neutral, behind everything) |
|
for pts_ts, plabel in ((kickoff, "kickoff"), (halftime, "half")): |
|
if not pts_ts: |
|
continue |
|
px = pd.Timestamp(pts_ts, unit="s", tz="UTC") |
|
if not (lo <= px <= hi): |
|
continue |
|
ax.axvline(px, color=MUTE, ls=(0, (1, 3)), lw=1.0, alpha=.65, zorder=1) |
|
ax.annotate(plabel, xy=(px, 2), ha="center", va="bottom", fontsize=6.5, |
|
color=MUTE, alpha=.9, zorder=1) |
|
|
|
series = {} |
|
for label, side, col, ys in ((home, "home", HOME_COL, home_ys), |
|
(away, "away", AWAY_COL, away_ys)): |
|
ax.plot(xs, ys, color=col, lw=2.4, solid_capstyle="round") |
|
ax.annotate(f"{label} {ys[-1]:.1f}%", (xs[-1], ys[-1]), xytext=(6, 0), |
|
textcoords="offset points", va="center", ha="left", |
|
fontsize=9, fontweight="bold", color=col) |
|
series[side] = (label, col, ys) |
|
|
|
# peak markers: only mark a high-water mark a team FELL from (peak clearly above its final |
|
# value) — this skips the winner's trivial ~100% lock-in at the whistle, but keeps the |
|
# dramatic near-misses (Egypt hit 96% then lost). A team at 100% that then LOST still shows. |
|
for side, (label, col, ys) in series.items(): |
|
pk = max(range(len(ys)), key=lambda i: ys[i]) |
|
if pk > 0 and ys[pk] >= 55 and (ys[pk] - ys[-1]) >= 12: |
|
ax.plot(xs[pk], ys[pk], "o", color=col, ms=5, zorder=7) |
|
ax.annotate(f"{label.split()[-1]} peak {ys[pk]:.0f}%", (xs[pk], ys[pk]), |
|
xytext=(0, 9), textcoords="offset points", ha="center", va="bottom", |
|
fontsize=7, fontweight="bold", color=col, zorder=7) |
|
|
|
# event markers: goals (solid dotted line) + missed/saved penalties (looser dash), each |
|
# labelled "Scorer min'" and coloured by the team, staggered so close events don't collide |
|
lo, hi = xs[0], xs[-1] |
|
|
|
def _surname(s): |
|
return (s or "").split()[-1] if s else "" |
|
|
|
marks = [] |
|
for gl in (goals or []): |
|
who = _surname(gl.get("scorer")) |
|
lbl = f"{who} {gl.get('minute','')}".strip() |
|
if gl.get("pen"): |
|
lbl += " (pen)" |
|
if gl.get("own"): |
|
lbl += " OG" |
|
marks.append({"ts": gl.get("ts"), "side": gl.get("side"), "label": lbl, "pen": False}) |
|
for nt in (notables or []): |
|
who = _surname(nt.get("scorer")) |
|
k = "saved" if nt.get("kind") == "pen_saved" else "miss" if nt.get("kind") == "pen_missed" else "pen" |
|
marks.append({"ts": nt.get("ts"), "side": nt.get("side"), |
|
"label": f"{who} pen {k}".strip(), "pen": True}) |
|
marks = [m for m in marks if m.get("ts") is not None] |
|
gi = 0 |
|
for m in sorted(marks, key=lambda x: x["ts"]): |
|
gx = pd.Timestamp(m["ts"], unit="s", tz="UTC") |
|
if not (lo <= gx <= hi): |
|
continue |
|
col = HOME_COL if m.get("side") == "home" else AWAY_COL |
|
dash = (0, (1, 2)) if m["pen"] else (0, (2, 2)) # penalties get a lighter dash |
|
ax.axvline(gx, color=col, ls=dash, lw=1.1, alpha=.6 if m["pen"] else .8, zorder=2) |
|
yb = (99, 90, 81)[gi % 3] # 3-level stagger |
|
ax.annotate(m["label"], xy=(gx, yb), ha="center", va="top", |
|
fontsize=6.6, fontweight="bold", color=col, zorder=6, |
|
bbox=dict(boxstyle="round,pad=0.2", fc=BG, ec=col, |
|
lw=0.7, ls="--" if m["pen"] else "-", alpha=0.95)) |
|
gi += 1 |
|
|
|
ax.set_ylabel("to-win %", color=MUTE, fontsize=9) |
|
ax.set_ylim(0, 100) |
|
ax.margins(x=0.02) |
|
ax.set_xlim(xs[0], xs[-1] + (xs[-1] - xs[0]) * 0.22) |
|
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d %H:%M")) |
|
ax.tick_params(colors=FG, labelsize=8) |
|
ax.grid(alpha=.4, color=GRID) |
|
for sp in ("top", "right"): |
|
ax.spines[sp].set_visible(False) |
|
for sp in ("left", "bottom"): |
|
ax.spines[sp].set_color(GRID) |
|
ax.set_title(f"{home} vs {away} — to win the match", color=FG, |
|
fontsize=11, fontweight="bold", loc="left") |
|
fig.tight_layout() |
|
fig.savefig(out, dpi=130) |
|
plt.close(fig) |
|
|
|
|
|
def plot_cup_trend(hourly, home_col, away_col, home_label, away_label, out, dark=False): |
|
"""Fallback expand chart (when Polymarket has no match line): the two teams' |
|
win-the-Cup odds over time, from the title-odds history we already have. Log y.""" |
|
BG = "#0e1013" if dark else "white" |
|
FG = "#e9ebee" if dark else "#141414" |
|
MUTE = "#9aa1aa" if dark else "#666" |
|
GRID = "#2a2e35" if dark else "#e6e6e6" |
|
sm = hourly.rolling(7, center=True, min_periods=1).median().clip(lower=0.1) |
|
win = sm.index >= sm.index[-1] - pd.Timedelta(days=7) |
|
|
|
fig, ax = plt.subplots(figsize=(8.4, 3.4)) |
|
fig.patch.set_facecolor(BG) |
|
ax.set_facecolor(BG) |
|
for col, label, c in ((home_col, home_label, "#2a7de1"), (away_col, away_label, "#e0603a")): |
|
s = sm.loc[win, col].dropna() |
|
ax.plot(s.index, s.values, color=c, lw=2.2, solid_capstyle="round") |
|
ax.annotate(f"{label} {s.iloc[-1]:.2f}%", (s.index[-1], s.iloc[-1]), xytext=(6, 0), |
|
textcoords="offset points", va="center", ha="left", |
|
fontsize=9, fontweight="bold", color=c) |
|
ax.set_yscale("log") |
|
ax.set_ylabel("win-cup %", color=MUTE, fontsize=9) |
|
ax.margins(x=0.02) |
|
xr = sm.index[win] |
|
ax.set_xlim(xr[0], xr[-1] + (xr[-1] - xr[0]) * 0.24) |
|
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d")) |
|
ax.tick_params(colors=FG, labelsize=8) |
|
ax.grid(alpha=.4, color=GRID) |
|
for sp in ("top", "right"): |
|
ax.spines[sp].set_visible(False) |
|
for sp in ("left", "bottom"): |
|
ax.spines[sp].set_color(GRID) |
|
ax.set_title(f"{home_label} vs {away_label} — win-the-Cup odds over time", color=FG, |
|
fontsize=11, fontweight="bold", loc="left") |
|
fig.tight_layout() |
|
fig.savefig(out, dpi=130) |
|
plt.close(fig) |
|
|
|
|
|
def plot_stack(sm, out="data/wc_stack.png"): |
|
exit_t, peak, alive = survivors_and_exits(sm) |
|
norm = sm.div(sm.sum(axis=1), axis=0) * 100 # each row sums to 100% |
|
order = norm.iloc[-1].sort_values(ascending=False).index # favorites first (bottom) |
|
colors = plt.cm.tab20.colors + plt.cm.tab20b.colors |
|
cmap = {} |
|
ci = 0 |
|
for t in order: |
|
if t in alive: |
|
cmap[t] = colors[ci % len(colors)]; ci += 1 |
|
else: |
|
cmap[t] = "#dddddd" |
|
|
|
fig, ax = plt.subplots(figsize=(17, 12)) |
|
ax.stackplot(norm.index, [norm[t].values for t in order], |
|
colors=[cmap[t] for t in order], edgecolor="white", linewidth=0.15) |
|
|
|
# label survivor bands at the right edge (flag + %) |
|
cum = 0.0 |
|
finals = norm.iloc[-1] |
|
for t in order: |
|
v = finals[t] |
|
if t in alive and v > 0.15: |
|
yc = cum + v / 2 |
|
ax.add_artist(AnnotationBbox(flag(t, 0.13), (norm.index[-1], yc), frameon=False, |
|
box_alignment=(0, 0.5), zorder=6, xybox=(6, 0), boxcoords="offset points")) |
|
ax.annotate(f"{sm[t].iloc[-1]:.2f}%", (norm.index[-1], yc), xytext=(34, 0), |
|
textcoords="offset points", va="center", ha="left", |
|
fontsize=9.5, fontweight="bold", color="#222") |
|
cum += v |
|
|
|
ax.set_ylim(0, 100) |
|
ax.set_ylabel("Share of win-the-Cup probability (%)") |
|
ax.set_xlim(norm.index[0], norm.index[-1] + pd.Timedelta(days=2.4)) |
|
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d")) |
|
ax.xaxis.set_major_locator(mdates.WeekdayLocator(interval=1)) |
|
for s in ("top", "right"): |
|
ax.spines[s].set_visible(False) |
|
ax.set_title("Winner-Take-All: How 2026 World Cup Title Odds Consolidated", |
|
fontsize=20, fontweight="bold", loc="left", pad=34) |
|
ax.text(0, 1.015, f"Each team's share of win-the-Cup probability; grey = eliminated (bands pinch out). " |
|
f"{len(alive)} of 48 remain. Polymarket, ~$3.7B volume.", |
|
transform=ax.transAxes, fontsize=12, color="#555") |
|
fig.tight_layout() |
|
fig.savefig(out, dpi=150) |
|
plt.close(fig) |
|
print(f"wrote {out}") |
|
|
|
|
|
if __name__ == "__main__": |
|
sm = load() |
|
plot_bump(sm) |
|
plot_stack(sm) |