Skip to content

Instantly share code, notes, and snippets.

@taarushv
Last active July 8, 2026 12:18
Show Gist options
  • Select an option

  • Save taarushv/1708b22d611e17c50551760efc8a5f91 to your computer and use it in GitHub Desktop.

Select an option

Save taarushv/1708b22d611e17c50551760efc8a5f91 to your computer and use it in GitHub Desktop.

Methodology and data pipeline behind cupcharts.com: live win-the-Cup odds for all 48 teams and per-match win-probability charts, refreshed every minute from betting markets.

What it shows

  • Title odds — each team's implied chance to win the World Cup and how it moves as the field narrows (rank/bump chart + odds-over-time).
  • Match odds — for each knockout tie, a 2-way "to advance" split over time, annotated with goals (scorer + minute), penalties, kickoff/halftime, and the settled result.

Data sources

  • Polymarket — the world-cup-winner event for title odds, and per-match 3-way markets (fifwc-<home>-<away>-<date>) for win/draw/loss. Live prices come from the CLOB order-book midpoint, with a Gamma fallback.
  • ESPN scoreboard + summary APIs — live scores, and per-goal keyEvents carrying real wall-clock timestamps (so goal/penalty markers land exactly on the odds timeline), plus kickoff/halftime and the final result.

How the odds are computed

  1. De-vig — raw prices are normalized so each market's outcomes sum to 100%, giving vig-free implied probabilities.
  2. Smooth — a rolling-median pass kills thin-market tick spikes; the last point is pinned to the latest real tick so "now" isn't smoothing-lagged.
  3. Match 2-way split — the 3-way is collapsed to "to advance" by dropping the draw and rescaling the two teams to 100%. When a tie goes to extra time / penalties the draw takes ~all the probability, so the 2-way is held at its last meaningful value (no bogus 50/50) and snapped to the actual winner once decided.
  4. Knockout exits — an eliminated team's live title odds are forced to 0 (ignores residual stink-bids on resolved markets).

Files

file role
fetch_wc.py Polymarket title odds → wc_raw.csv
fetch_results.py ESPN scores + goals / penalties / phases
fetch_match.py Polymarket per-match odds + price history
update.py one update cycle: fetch → rankings → charts
analyze_wc.py rank/bump chart, odds-over-time, per-match win-prob chart
poster.py the shareable infographic
wc_raw.csv raw title-odds time series (the underlying data)

Notes

Odds are market-implied, not true probabilities. Not affiliated with FIFA, Polymarket, or ESPN. Derived from public markets for informational/illustrative use.

"""Polymarket per-match odds for upcoming World Cup games (3-way: home/draw/away).
Match markets live under the soccer tag with slugs like `fifwc-mex-eng-2026-07-05`
(one event, 3 markets: each team + Draw). We match them to ESPN fixtures by team name
so home/away orientation doesn't matter, and pull current win% + 24h change.
"""
import os
import json
import time
import re
import datetime
import unicodedata
import urllib.request
import urllib.parse
UA = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36"}
GAMMA = "https://gamma-api.polymarket.com"
CLOB = "https://clob.polymarket.com"
# True semantic name differences ESPN->Polymarket (accents/spelling are handled by _norm,
# so only genuinely different names need listing here).
_ALIAS = {"Cape Verde": "Cabo Verde", "Iran": "IR Iran", "Ivory Coast": "Côte d'Ivoire",
"USA": "United States"}
# base slug map is cached ~10 min (match markets don't appear often; full scan is ~17 calls)
_SLUG_CACHE = {"t": 0.0, "map": {}}
def _norm(s):
"""Accent/punctuation/case-insensitive key so Türkiye==Turkiye, Curaçao==Curacao, etc."""
s = unicodedata.normalize("NFKD", s or "")
return "".join(c for c in s if c.isascii() and c.isalnum()).lower()
def _team_key(name):
return _norm(_ALIAS.get(name, name))
def _get(url):
return json.load(urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=25))
def _arr(v):
if isinstance(v, str):
try:
return json.loads(v)
except Exception:
return []
return v or []
def _slug_map():
"""{frozenset(team names lower) -> base slug} for every open fifwc 3-way match market.
Full-scans the soccer tag (date filters hide base events), cached ~10 min. Team names
come from each market's groupItemTitle, so matching is name-based (orientation-free)."""
now = time.time()
if _SLUG_CACHE["map"] and now - _SLUG_CACHE["t"] < 600:
return _SLUG_CACHE["map"]
m = {}
for off in range(0, 3000, 100):
try:
d = _get(GAMMA + "/events?" + urllib.parse.urlencode(
{"tag_id": "100350", "closed": "false", "limit": 100, "offset": off}))
except Exception:
break
if not isinstance(d, list) or not d:
break
for e in d:
slug = e.get("slug", "")
mk = e.get("markets") or []
if re.fullmatch(r"fifwc-[a-z]{3}-[a-z]{3}-\d{4}-\d{2}-\d{2}", slug) and len(mk) == 3:
teams = [x.get("groupItemTitle", "") for x in mk
if not x.get("groupItemTitle", "").lower().startswith("draw")]
if len(teams) == 2:
m[frozenset(_norm(t) for t in teams)] = slug
if len(d) < 100:
break
if m:
_SLUG_CACHE["t"] = now
_SLUG_CACHE["map"] = m
return _SLUG_CACHE["map"]
def _event_by_slug(slug):
try:
r = _get(GAMMA + "/events?slug=" + slug)
return r[0] if isinstance(r, list) and r else None
except Exception:
return None
def _history(token, live=False):
"""CLOB price history [(t, pct)] for an outcome token; minute-level over the last 6h for
live games (captures in-play goal swings), hourly over 5 days otherwise. [] if none."""
fidelity = 1 if live else 60
span = 6 * 3600 if live else 5 * 24 * 3600
try:
h = _get(CLOB + "/prices-history?" + urllib.parse.urlencode(
{"market": token, "startTs": int(time.time()) - span, "fidelity": fidelity})).get("history", [])
return [(int(p["t"]), float(p["p"]) * 100) for p in h]
except Exception:
return []
def _history_range(token, start_ts, end_ts, fidelity=1):
"""CLOB price history [(t, pct)] for an explicit window — used to rebuild the in-play
line of a *finished* game (minute-level over the ~kickoff..final window)."""
try:
h = _get(CLOB + "/prices-history?" + urllib.parse.urlencode(
{"market": token, "startTs": int(start_ts), "endTs": int(end_ts),
"fidelity": fidelity})).get("history", [])
return [(int(p["t"]), float(p["p"]) * 100) for p in h]
except Exception:
return []
# ESPN uses FIFA 3-letter codes; Polymarket slugs use ISO-3166 codes. Map the ones that differ.
_PM_CODE = {"sui": "che", "por": "prt", "ger": "deu", "ned": "nld", "den": "dnk",
"cro": "hrv", "uru": "ury", "par": "pry", "rsa": "zaf", "chi": "chl",
"tog": "tgo", "cha": "tcd", "gua": "gtm", "hai": "hti", "hon": "hnd",
"phi": "phl", "bur": "bfa", "nig": "ner", "zam": "zmb"}
def _slug_candidates(ha, aa, date):
"""Every plausible Polymarket slug for a finished game: FIFA + ISO codes, both team
orders, and the kickoff date + day-before (Polymarket dates by US-local kickoff, so a
late-UTC game lands on the previous calendar day)."""
ch = [ha] + ([_PM_CODE[ha]] if ha in _PM_CODE else [])
ca = [aa] + ([_PM_CODE[aa]] if aa in _PM_CODE else [])
try:
d0 = datetime.date.fromisoformat(date)
dates = [date, (d0 - datetime.timedelta(days=1)).isoformat()]
except Exception:
dates = [date]
out, seen = [], set()
for d in dates:
for a in ch:
for b in ca:
for s in (f"fifwc-{a}-{b}-{d}", f"fifwc-{b}-{a}-{d}"):
if s not in seen:
seen.add(s)
out.append(s)
return out
def attach_finished_charts(games, since="2026-07-04"):
"""Recently-finished knockout games get an in-game odds history so the Recent-results
rows can expand to show how the line moved during the match. Resolved Polymarket markets
drop out of the tag scan but are still queryable by their exact slug.
Post-game odds are static, so once a chart PNG exists we just point at it — no re-fetch."""
posts = [g for g in games if g.get("state") == "post" and g.get("date", "") >= since]
cached = fetched = 0
for g in posts:
ha = (g.get("home_abbr") or "").lower()
aa = (g.get("away_abbr") or "").lower()
date = g.get("date")
if not (len(ha) == 3 and len(aa) == 3 and date):
continue
slugs = _slug_candidates(ha, aa, date)
done = next((s for s in slugs if os.path.exists("wc_match_" + s + ".png")), None)
if done: # already rendered — reuse, zero API calls
g["match_slug"] = done
g["match_chart"] = "wc_match_" + done + ".png"
cached += 1
continue
ev = slug = None
for s in slugs:
ev = _event_by_slug(s)
if ev:
slug = s
break
if not ev:
continue
ko = _kickoff_ts(g) or 0
start = (ko - 1800) if ko else int(time.time()) - 3 * 24 * 3600
end = (ko + 3 * 3600) if ko else int(time.time())
hk, ak = _team_key(g["home"]), _team_key(g["away"])
hist = {}
for m in ev.get("markets", []):
git = m.get("groupItemTitle", "")
side = "home" if _norm(git) == hk else "away" if _norm(git) == ak else None
if not side:
continue
toks = _arr(m.get("clobTokenIds"))
if not toks:
continue
ser = _history_range(toks[0], start, end, fidelity=1)
time.sleep(0.1)
if ser:
hist[side] = ser
if len(hist) >= 2:
g["match_slug"] = slug
g["_hist"] = hist
fetched += 1
if posts:
print(f"finished charts: {cached} cached + {fetched} fetched / {len(posts)} post games")
def _midpoint(token):
"""Real-time CLOB order-book midpoint (0-1) for an outcome token; None if unavailable.
gamma outcomePrices lags minutes during live play, so use this for in-play odds."""
try:
r = _get(CLOB + "/midpoint?token_id=" + token)
m = r.get("mid")
return float(m) if m is not None else None
except Exception:
return None
def _kickoff_ts(g):
try:
return int(datetime.datetime.fromisoformat(g["ts"].replace("Z", "+00:00")).timestamp())
except Exception:
return None
def attach_match_odds(games):
"""Attach Polymarket match-winner odds to upcoming AND live games:
g_home/g_away/g_draw (%), g_home_d/g_away_d (move: 24h for upcoming, since-kickoff
for live), odds_src='Polymarket', match_slug, and _hist (series) for the odds chart.
_hist is consumed by the chart renderer and stripped before results.json is written."""
targets = [g for g in games if g.get("upcoming") or g.get("state") == "in"]
if not targets:
return
try:
smap = _slug_map()
except Exception as e:
print("match odds: slug map failed:", e)
return
hit = 0
for g in targets:
hk, ak = _team_key(g["home"]), _team_key(g["away"])
slug = smap.get(frozenset([hk, ak]))
if not slug:
continue
e = _event_by_slug(slug) # fetch fresh for current prices
if not e:
continue
live = g.get("state") == "in"
ko = _kickoff_ts(g) if live else None
hist = {}
bases = {}
for m in e["markets"]:
git = m.get("groupItemTitle", "")
side = ("draw" if git.lower().startswith("draw")
else "home" if _norm(git) == hk
else "away" if _norm(git) == ak else None)
if not side:
continue
toks = _arr(m.get("clobTokenIds"))
token = toks[0] if toks else None
gp = _arr(m.get("outcomePrices"))
# current price: real-time CLOB midpoint for live games; gamma is fine pre-match
cur = _midpoint(token) if (live and token) else None
if cur is None and gp:
cur = float(gp[0])
if cur is None:
continue
g["g_" + side] = round(cur * 100, 2)
if token:
ser = _history(token, live=live)
time.sleep(0.1)
if ser:
hist[side] = ser
if side in ("home", "away"):
if live and ko: # move since kickoff
base = next((p for t, p in reversed(ser) if t <= ko), ser[0][1])
else: # 24h move
cut = ser[-1][0] - 24 * 3600
base = next((p for t, p in reversed(ser) if t <= cut), ser[0][1])
bases[side] = base
g["g_" + side + "_d"] = round(g["g_" + side] - base, 2)
# 2-way "who wins/advances" split — draw removed, the two teams rescaled to 100%.
# When the game is tied at 90' (extra time / penalties) the draw outcome takes ~all the
# probability, leaving home & away both near 0 — rescaling those is pure noise (a bogus
# 50/50). Skip below the threshold so the UI shows no line instead of a fake 50/50.
hc, ac = g.get("g_home"), g.get("g_away")
if hc is not None and ac is not None and (hc + ac) >= 12:
tot = hc + ac
g["g_home_adv"] = round(hc / tot * 100, 2)
g["g_away_adv"] = round(ac / tot * 100, 2)
bh, ba = bases.get("home"), bases.get("away")
if bh is not None and ba is not None and (bh + ba) > 0:
g["g_home_adv_d"] = round(g["g_home_adv"] - bh / (bh + ba) * 100, 2)
g["g_away_adv_d"] = round(g["g_away_adv"] - ba / (bh + ba) * 100, 2)
elif live: # tied at 90' — market is a draw, no shootout line exists
sn = (g.get("status_name") or "").upper()
stt = (g.get("status") or "").lower()
g["tie_break"] = ("penalty shootout" if "SHOOT" in sn or stt.startswith("pen")
else "extra time" if "EXTRA" in sn or "et" in stt
else "level after 90'")
if "g_home_adv" in g:
g["odds_src"] = "Polymarket"
g["match_slug"] = slug
if len(hist) >= 2:
g["_hist"] = hist
hit += 1
print(f"match odds: attached to {hit}/{len(targets)} games (upcoming+live)")
if __name__ == "__main__":
import fetch_results
import update
gs = fetch_results.fetch()
update._attach_game_odds(gs)
attach_match_odds(gs)
for g in gs:
if g.get("upcoming"):
print(f"{g['home']} {g.get('g_home')}% (d{g.get('g_home_d')}) | "
f"draw {g.get('g_draw')}% | {g['away']} {g.get('g_away')}% (d{g.get('g_away_d')})")
"""One update cycle: refresh Polymarket odds -> regenerate bump PNG + rankings.json.
Runs the same locally (in a loop) or in a GitHub Action (cron). fetch_wc pulls the
full history each time, so it self-backfills / is atomic — a fresh checkout just works.
"""
import json
import time
import datetime
import fetch_wc
import fetch_results
import fetch_match
import analyze_wc as A
# ESPN display name -> Polymarket team name (only the ones that differ)
_NAME_MAP = {"United States": "USA"}
def _attach_game_odds(games):
"""Attach each team's Polymarket win-cup odds to games:
- played (live/finished): odds before kickoff vs after (the match's swing)
- upcoming (pre) where BOTH teams are decided: current odds + 24h move
TBD/undetermined fixtures have no Polymarket team match, so they're skipped."""
considered = [g for g in games if g["state"] in ("in", "post", "pre")]
if not considered:
return
import pandas as pd
raw = pd.read_csv("data/wc_raw.csv")
raw = raw[~raw["team"].str.startswith("Team ")]
raw["ts"] = pd.to_datetime(raw["timestamp"], unit="s", utc=True)
last_ts = raw["ts"].max()
for g in considered:
kickoff = pd.Timestamp(g["ts"])
pre = g["state"] == "pre"
after_t = last_ts if g["state"] in ("in", "pre") else min(kickoff + pd.Timedelta(hours=3), last_ts)
before_t = (last_ts - pd.Timedelta(hours=24)) if pre else kickoff
sides = {}
for side in ("home", "away"):
team = _NAME_MAP.get(g[side], g[side])
s = raw[raw["team"] == team].sort_values("ts")
if s.empty:
continue
def val(t, s=s):
p = s[s["ts"] <= t]
return float(p["price"].iloc[-1]) * 100 if len(p) else float(s["price"].iloc[0]) * 100
sides[side] = (round(val(before_t), 2), round(val(after_t), 2))
if pre and len(sides) < 2:
continue # upcoming needs BOTH teams decided + tracked
for side, (before, after) in sides.items():
g[side + "_odds"] = after
g[side + "_before"] = before
g[side + "_delta"] = round(after - before, 2)
if pre:
g["upcoming"] = True
def _american_to_prob(a):
try:
a = float(str(a).replace("+", ""))
except (TypeError, ValueError):
return None
return 100.0 / (a + 100.0) if a > 0 else (-a) / (-a + 100.0)
def attach_espn_fallback(games):
"""For upcoming games with no Polymarket line, fall back to ESPN/DraftKings moneyline,
converted to vig-free implied win% (no history, so no 24h change)."""
n = 0
for g in games:
if not g.get("upcoming") or "g_home" in g:
continue
ml = g.get("espn_ml") or {}
ph, pa, pd_ = (_american_to_prob(ml.get(k)) for k in ("home", "away", "draw"))
if ph is None or pa is None:
continue
tot = ph + pa + (pd_ or 0)
if tot <= 0:
continue
g["g_home"] = round(ph / tot * 100, 2)
g["g_away"] = round(pa / tot * 100, 2)
if pd_ is not None:
g["g_draw"] = round(pd_ / tot * 100, 2)
two = ph + pa # 2-way "to win" split (draw excluded), for display
if two >= 0.12: # draw-dominant (tied at 90'/pens) -> skip bogus 50/50
g["g_home_adv"] = round(ph / two * 100, 2)
g["g_away_adv"] = round(pa / two * 100, 2)
g["odds_src"] = ml.get("provider") or "DraftKings"
n += 1
if n:
print(f"match odds: {n} upcoming games filled from ESPN fallback")
def _event_sides(g, items):
"""Tag each ESPN match event (goal or penalty) with home/away for chart colour coding,
preserving all its other fields."""
hn = (g.get("home") or "").strip().lower()
an = (g.get("away") or "").strip().lower()
out = []
for it in items:
tn = (it.get("team") or "").strip().lower()
side = ("home" if tn == hn else "away" if tn == an
else "home" if hn and (hn in tn or tn in hn)
else "away" if an and (an in tn or tn in an) else None)
d = dict(it)
d["side"] = side
out.append(d)
return out
def _render_match_charts(games):
"""Upcoming games with a Polymarket line get an expandable match-odds-over-time chart
(home/draw/away %). Live & finished games also get goal markers (dotted line + minute).
Strips the bulky _hist series so it never lands in results.json."""
for g in games:
hist = g.pop("_hist", None)
slug = g.get("match_slug")
if not (hist and slug):
continue
base = "wc_match_" + slug
goals, notables, kickoff, halftime = [], [], None, None
if g.get("espn_id"):
try:
ev = fetch_results.fetch_match_events(g["espn_id"])
goals = _event_sides(g, ev["goals"])
notables = _event_sides(g, ev.get("notables") or [])
kickoff, halftime = ev["kickoff"], ev["halftime"]
except Exception as e:
print("goals fetch error", slug, e)
result = None # snap the loser to 0% / winner to 100% once settled
if g.get("state") == "post":
if g.get("home_win"):
result = "home"
elif g.get("away_win"):
result = "away"
else:
try:
ih, ia = int(g.get("hs")), int(g.get("as"))
result = "home" if ih > ia else "away" if ia > ih else None
except Exception:
pass
try:
for out_png, dk in ((base + ".png", False), (base + "_dark.png", True)):
A.plot_match(g["home"], g["away"], hist, out=out_png, dark=dk, goals=goals,
notables=notables, kickoff=kickoff, halftime=halftime, result=result)
g["match_chart"] = base + ".png"
if g.get("state") == "in": # live goals -> feed the push goal-alert trigger
g["goals"] = goals
except Exception as e:
print("match chart error", slug, e)
def write_results(games):
"""Match results (scores/status/odds swing) -> results.json."""
for g in games: # safety: never serialize the raw history series
g.pop("_hist", None)
with open("results.json", "w", encoding="utf-8") as f:
json.dump({"updated_unix": int(time.time()), "games": games}, f, ensure_ascii=False)
live = sum(1 for g in games if g["state"] == "in")
print(f"results: {len(games)} games ({live} live)")
def _ko_round(date):
if date < "2026-07-04":
return "Round of 32"
if date < "2026-07-09":
return "Round of 16"
if date < "2026-07-14":
return "Quarter-final"
if date < "2026-07-18":
return "Semi-final"
return "Final"
def build_elims(games):
"""Regenerate data/wc_elims.csv live: fixed group-stage base + knockout losers
derived from finished ESPN games. Keeps the alive/eliminated funnel current."""
import csv
posts = [g for g in games if g["state"] == "post" and g["date"] >= "2026-06-28"]
if not posts: # no knockout results parsed -> keep last file
return
base = list(csv.DictReader(open("data/wc_group_base.csv")))
teams = [r["team"] for r in base]
tset = set(teams)
group_out = {r["team"] for r in base if r["group_status"] == "eliminated"}
elim = {t: (d, "Group stage") for t, d in
((r["team"], r["date"]) for r in base if r["group_status"] == "eliminated")}
for g in sorted(posts, key=lambda x: x["date"]):
if g["home_win"] and not g["away_win"]:
raw_loser = g["away"]
elif g["away_win"] and not g["home_win"]:
raw_loser = g["home"]
else:
continue
loser = _NAME_MAP.get(raw_loser, raw_loser)
if loser not in tset:
print("elim WARN: unmapped loser", raw_loser)
continue
if loser in group_out: # group-eliminated team can't lose a knockout (tz edge)
continue
elim[loser] = (g["date"], _ko_round(g["date"]))
g["out"] = raw_loser # this match knocked raw_loser out (for the results table)
# once knocked out, force their post-match win-cup odds to 0 (ignore Polymarket
# residual "stink bids" that never fully settle to zero)
_side = "home" if g["home"] == raw_loser else ("away" if g["away"] == raw_loser else None)
if _side and g.get(_side + "_before") is not None:
g[_side + "_odds"] = 0.0
g[_side + "_delta"] = round(0.0 - g[_side + "_before"], 2)
with open("data/wc_elims.csv", "w", newline="") as f:
w = csv.writer(f)
w.writerow(["team", "status", "date", "round"])
for t in teams:
if t in elim:
w.writerow([t, "eliminated", elim[t][0], elim[t][1]])
else:
w.writerow([t, "alive", "", ""])
print(f"elims: {len(teams) - len(elim)} alive, {len(elim)} out")
def main():
fetch_wc.main() # -> data/wc_raw.csv (full history + latest)
try:
games = fetch_results.fetch()
_attach_game_odds(games)
try:
fetch_match.attach_match_odds(games) # Polymarket 3-way match odds on upcoming games
attach_espn_fallback(games) # ESPN/DraftKings where Polymarket has no line
fetch_match.attach_finished_charts(games) # in-game odds history for finished games
_render_match_charts(games) # per-game expandable odds chart (Polymarket only)
except Exception as e:
print("match odds error:", e)
build_elims(games) # -> data/wc_elims.csv + marks g["out"] per game
write_results(games) # -> results.json (scores + odds swing + eliminations)
except Exception as e:
print("results/elims error:", e)
sm = A.load()
# 1h / 24h odds change per team, from the hourly Polymarket history
import pandas as pd
raw = pd.read_csv("data/wc_raw.csv")
raw = raw[~raw["team"].str.startswith("Team ")]
raw["ts"] = pd.to_datetime(raw["timestamp"], unit="s", utc=True)
_by = {tm: s.sort_values("ts") for tm, s in raw.groupby("team")}
def _chg(team, hours):
s = _by.get(team)
if s is None or s.empty:
return None
cur = float(s["price"].iloc[-1]) * 100
cut = s["ts"].iloc[-1] - pd.Timedelta(hours=hours)
p = s[s["ts"] <= cut]
base = float(p["price"].iloc[-1]) * 100 if len(p) else float(s["price"].iloc[0]) * 100
return round(cur - base, 2)
exit_t, peak, alive = A.survivors_and_exits(sm)
aliveset = set(alive)
now = sm.iloc[-1]
deltas = {t: (_chg(t, 1), _chg(t, 24)) for t in alive}
A.plot_bump(sm, out="wc_bump.png", deltas=deltas) # rank bump, light
A.plot_bump(sm, out="wc_bump_dark.png", deltas=deltas, dark=True) # rank bump, dark
A.plot_odds(out="wc_odds.png") # odds-over-time, light
A.plot_odds(out="wc_odds_dark.png", dark=True) # odds-over-time, dark
# baseline snapshots for the over/under toggle: kickoff + each knockout stage that's begun
now_ts = sm.index[-1]
baseline_defs = [("Kickoff", sm.index[0])]
for s0s, _s1s, lab in A.STAGES:
if lab == "GROUP STAGE":
continue
s0 = pd.Timestamp(s0s, tz="UTC")
if s0 <= now_ts:
baseline_defs.append((A.STAGE_DISPLAY[lab], s0))
r32_when = pd.Timestamp("2026-06-28", tz="UTC") # kept for the poster's over/under
rows, r = [], 1
for t in now.sort_values(ascending=False).index:
is_alive = t in aliveset
d1h, d24 = deltas.get(t, (None, None)) if is_alive else (None, None)
cur = float(now[t])
pre = round(float(sm[t].iloc[0]), 2) # odds at kickoff (start of series)
_r = sm[t][sm.index <= r32_when]
r32 = round(float(_r.iloc[-1]) if len(_r) else float(sm[t].iloc[0]), 2) # odds at R32 start
bases = {}
for blabel, bdate in baseline_defs: # odds at each baseline point
v = sm[t][sm.index <= bdate]
bases[blabel] = round(float(v.iloc[-1]) if len(v) else float(sm[t].iloc[0]), 2)
rows.append({"team": t, "odds": round(cur, 2),
"alive": is_alive, "rank": (r if is_alive else None),
"d1h": d1h, "d24h": d24,
"pre": pre, "dall": round(cur - pre, 2),
"r32": r32, "dr32": round(cur - r32, 2), "bases": bases})
if is_alive:
r += 1
# biggest 24h gainer / loser among teams still alive (% points)
movers = {}
cand = [(t, deltas[t][1]) for t in alive if deltas[t][1] is not None]
if cand:
gt, gd = max(cand, key=lambda x: x[1])
lt, ld = min(cand, key=lambda x: x[1])
movers = {"gainer": {"team": gt, "delta": gd, "odds": round(float(now[gt]), 2)},
"loser": {"team": lt, "delta": ld, "odds": round(float(now[lt]), 2)}}
meta = {
"updated_unix": int(time.time()),
"updated_iso": datetime.datetime.now().strftime("%b %d, %H:%M"),
"alive": len(alive),
"stage": A.current_stage(),
"movers": movers,
"rankings": rows,
}
with open("rankings.json", "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=1)
print(f"updated {meta['updated_iso']} — {len(alive)} of 48 alive")
if __name__ == "__main__":
main()
"""Fetch 2026 World Cup winner odds time-series from Polymarket.
Event: world-cup-winner (~$3.7B volume). For each team's YES token we pull hourly
price history, then write a tidy CSV: timestamp, team, price (implied P(win cup)).
A team is 'eliminated' once its price flatlines at ~0.
"""
import json
import time
import sys
import os
import requests
GAMMA = "https://gamma-api.polymarket.com"
CLOB = "https://clob.polymarket.com"
SLUG = "world-cup-winner"
FIDELITY = 60
OUT = "data/wc_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):
out = []
for m in event.get("markets", []):
team = m.get("groupItemTitle")
toks = json.loads(m.get("clobTokenIds") or "[]")
if team and toks:
out.append((team, toks[0]))
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():
os.makedirs("data", exist_ok=True)
start_ts = int(time.mktime(time.strptime("2026-06-01", "%Y-%m-%d"))) # tournament window
ev = get_event()
tokens = team_tokens(ev)
print(f"event: {ev.get('title')} teams: {len(tokens)}", file=sys.stderr)
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']}")
print(f" [{i+1}/{len(tokens)}] {team:<20} {len(h)} 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()
"""Recent 2026 World Cup match results from ESPN's free scoreboard API (no key).
Returns recent + live + upcoming games with scores/status, newest first.
Also drives elimination updates later; for now it feeds the site's results table.
"""
import datetime
import requests
ESPN = "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard"
SUMMARY = "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/summary"
def _event_ts(e):
wc = e.get("wallclock")
try:
return int(datetime.datetime.fromisoformat(wc.replace("Z", "+00:00")).timestamp())
except Exception:
return None
def _parse_match_events(events):
"""Pure parser (no network) over ESPN summary `keyEvents` -> goals/notables/phases.
Kept separate from the fetch so it can be unit-tested on synthetic events."""
goals, notables, kickoff, halftime = [], [], None, None
for e in events:
typ = ((e.get("type") or {}).get("text") or "")
tl = typ.lower()
if typ == "Kickoff" and kickoff is None:
kickoff = _event_ts(e)
elif typ == "Halftime" and halftime is None:
halftime = _event_ts(e)
parts = e.get("participants") or []
who = ((parts[0].get("athlete") or {}).get("displayName")) if parts else ""
common = {"ts": _event_ts(e),
"minute": (e.get("clock") or {}).get("displayValue", ""),
"team": (e.get("team") or {}).get("displayName", ""),
"scorer": who or ""}
if common["ts"] is None:
continue
if e.get("scoringPlay") and not e.get("shootout"):
goals.append({**common, "own": bool(e.get("ownGoal")), "pen": "penalt" in tl})
elif "penalt" in tl and not e.get("shootout"): # saved / missed penalty (no goal)
kind = "pen_saved" if "saved" in tl else "pen_missed" if "miss" in tl else "pen"
notables.append({**common, "kind": kind})
return {"goals": goals, "notables": notables, "kickoff": kickoff, "halftime": halftime}
def fetch_match_events(espn_id):
"""Goals + penalties + phase markers for one match from ESPN's summary feed (same call,
no extra requests). Each keyEvent has a real `wallclock` (ISO) that maps straight onto the
odds chart's time axis. Returns {goals, notables, kickoff, halftime}; goals carry a `pen`
flag for penalty goals, notables are missed/saved penalties (non-goals)."""
try:
r = requests.get(SUMMARY, params={"event": espn_id}, timeout=20)
events = r.json().get("keyEvents", [])
except Exception:
return {"goals": [], "notables": [], "kickoff": None, "halftime": None}
return _parse_match_events(events)
def fetch(days_back=30, days_fwd=8): # 30 back = whole tournament; 8 fwd = upcoming fixtures
today = datetime.datetime.utcnow().date()
start = (today - datetime.timedelta(days=days_back)).strftime("%Y%m%d")
end = (today + datetime.timedelta(days=days_fwd)).strftime("%Y%m%d")
# cache-bust: the big date-range response caches hard at ESPN's edge, so a server
# far from a fresh PoP can get a copy that's minutes stale (live scores lag). A unique
# _ param + no-cache headers force an origin-fresh response every cycle.
bust = int(datetime.datetime.utcnow().timestamp())
r = requests.get(ESPN, params={"dates": f"{start}-{end}", "limit": 300, "_": bust},
headers={"Cache-Control": "no-cache", "Pragma": "no-cache"}, timeout=30)
r.raise_for_status()
out = []
for e in r.json().get("events", []):
try:
c = e["competitions"][0]
comp = c["competitors"]
h = next(x for x in comp if x["homeAway"] == "home")
a = next(x for x in comp if x["homeAway"] == "away")
st = e["status"]["type"]
note = ""
if c.get("notes"):
note = c["notes"][0].get("headline", "")
# DraftKings moneyline (american) if present -> fallback when Polymarket has no line
espn_ml = None
od = (c.get("odds") or [None])[0]
if od:
ml = od.get("moneyline") or {}
def _am(side):
o = ml.get(side) or {}
v = (o.get("close") or o.get("open") or {}).get("odds")
return v
espn_ml = {"home": _am("home"), "away": _am("away"),
"draw": (od.get("drawOdds") or {}).get("moneyLine"),
"provider": (od.get("provider") or {}).get("name")}
out.append({
"espn_id": e.get("id"),
"date": e["date"][:10],
"ts": e["date"],
"home": h["team"].get("displayName"),
"home_abbr": h["team"].get("abbreviation"),
"home_logo": h["team"].get("logo"),
"hs": h.get("score"),
"away": a["team"].get("displayName"),
"away_abbr": a["team"].get("abbreviation"),
"away_logo": a["team"].get("logo"),
"as": a.get("score"),
"state": st.get("state"), # pre | in | post
"status": st.get("shortDetail"), # FT, AET, 11', Scheduled...
"status_name": st.get("name"), # STATUS_HALFTIME / STATUS_FULL_TIME / ...
"completed": st.get("completed", False),
"home_win": h.get("winner", False),
"away_win": a.get("winner", False),
"round": note,
"espn_ml": espn_ml,
})
except (KeyError, StopIteration, IndexError):
continue
out.sort(key=lambda x: x["ts"], reverse=True)
return out
if __name__ == "__main__":
for g in fetch()[:12]:
print(f"{g['date']} | {g['home']} {g['hs']}-{g['as']} {g['away']} | {g['status']} | {g['round']}")
"""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)
fastapi
uvicorn[standard]
requests
pandas
matplotlib
pyyaml
pywebpush
"""Sanity + historical-data tests for the WC live pipeline.
Run: python test_wc.py (prints PASS/FAIL, exits non-zero on any failure)
Covers: ESPN results feed shape, verification against known 2026 WC scores,
the Polymarket odds pipeline, and elimination-file consistency.
"""
import sys
import csv
import fetch_results
import analyze_wc as A
FAILS = []
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
def test_results_shape():
print("[results feed shape]")
games = fetch_results.fetch()
check(len(games) >= 10, f"fetched >=10 games (got {len(games)})")
fields = ("date", "home", "away", "state", "status")
check(all(all(k in g for k in fields) for g in games), "all games have required fields")
check(all(g["state"] in ("pre", "in", "post") for g in games), "all states valid")
done = [g for g in games if g["state"] == "post"]
check(all(str(g["hs"]).isdigit() and str(g["as"]).isdigit() for g in done),
"completed games have numeric scores")
check(all((g["home_win"] or g["away_win"] or g["hs"] == g["as"]) for g in done),
"completed games have a winner or are draws")
def test_historical_scores():
"""Verify the feed against known, already-played 2026 WC results (stable facts)."""
print("[historical verification]")
by = {(g["home"], g["away"]): g for g in fetch_results.fetch() if g["state"] == "post"}
known = [
("Brazil", "Japan", "2", "1"),
("Spain", "Austria", "3", "0"),
("Portugal", "Croatia", "2", "1"),
("United States", "Bosnia-Herzegovina", "2", "0"),
("England", "Congo DR", "2", "1"),
("Belgium", "Senegal", "3", "2"),
("Mexico", "Ecuador", "2", "0"),
]
for h, a, hs, asc in known:
g = by.get((h, a))
check(g is not None, f"found {h} vs {a}")
if g:
check(str(g["hs"]) == hs and str(g["as"]) == asc,
f"{h} {hs}-{asc} {a} (feed says {g['hs']}-{g['as']})")
def test_odds_pipeline():
print("[odds pipeline]")
sm = A.load()
check(sm.shape[1] >= 30, f"loaded >=30 teams (got {sm.shape[1]})")
exit_t, peak, alive = A.survivors_and_exits(sm)
check(len(alive) >= 1, f"has alive teams (got {len(alive)})")
last = sm.iloc[-1]
check(last.max() > last.min(), "odds are differentiated (not all equal)")
check("France" in sm.columns and last["France"] > 5, "France priced as a real contender")
check(last.idxmax() in alive, "the current favorite is still alive")
def test_elims_consistency():
print("[elimination file]")
rows = list(csv.DictReader(open("data/wc_elims.csv")))
check(len(rows) == 48, f"48 teams in wc_elims (got {len(rows)})")
check(all(r["status"] in ("alive", "eliminated") for r in rows), "statuses valid")
check(all(r["date"] for r in rows if r["status"] == "eliminated"),
"every eliminated team has a date")
def test_live_elims():
"""Eliminations derived live from ESPN results match reality."""
print("[live eliminations]")
import update
update.build_elims(fetch_results.fetch())
st = {r["team"]: r["status"] for r in csv.DictReader(open("data/wc_elims.csv"))}
for out in ("Germany", "Netherlands", "Japan", "Croatia", "Uruguay"): # known out
check(st.get(out) == "eliminated", f"{out} eliminated")
for live in ("France", "Spain", "Argentina"): # known alive
check(st.get(live) == "alive", f"{live} still alive")
if __name__ == "__main__":
for t in (test_results_shape, test_historical_scores, test_odds_pipeline,
test_elims_consistency, test_live_elims):
try:
t()
except Exception as e:
FAILS.append(f"{t.__name__} raised {e!r}")
print(f" ERROR {t.__name__}: {e!r}")
print("\n" + ("ALL PASS" if not FAILS else f"{len(FAILS)} FAILURE(S): " + "; ".join(FAILS)))
sys.exit(1 if FAILS else 0)
"""Compose a single shareable infographic of the site's current live data (for r/dataisbeautiful).
Pulls rankings.json + results.json + the rendered bump chart into one dark poster:
header + current favorite, the "Who's Left" chart (hero), live standings, upcoming games with
Polymarket match odds, and over/under performers. Run after an update cycle.
"""
import json
import datetime
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.offsetbox import OffsetImage, AnnotationBbox
BG = "#0e1013"; FG = "#e9ebee"; CARD = "#181b21"; BORDER = "#2a2e35"
MUTE = "#9aa1aa"; FAINT = "#6a7079"; UP = "#4cc85f"; DOWN = "#ff6b5e"; ACC = "#6ea8ff"
_SHORT = {"United States": "USA", "Bosnia-Herzegovina": "Bosnia", "South Africa": "S. Africa"}
def _disp(n):
return _SHORT.get(n, n)
def _round(date):
if date < "2026-07-04":
return "Round of 32"
if date < "2026-07-09":
return "Round of 16"
if date < "2026-07-14":
return "Quarter-final"
if date < "2026-07-18":
return "Semi-final"
return "Final"
def _flag(team, zoom):
try:
return OffsetImage(plt.imread(f"data/flags/{team}.png"), zoom=zoom)
except Exception:
return None
def _chg(v):
if v is None or abs(v) < 0.005:
return ("0.00", MUTE)
return (("+" if v > 0 else "−") + f"{abs(v):.2f}", UP if v > 0 else DOWN)
def _panel(ax, title):
ax.set_facecolor(CARD)
for s in ax.spines.values():
s.set_color(BORDER)
ax.set_xticks([]); ax.set_yticks([])
ax.text(0.03, 0.965, title, transform=ax.transAxes, color=FG, fontsize=15,
fontweight="bold", va="top")
def make_poster(out="cupcharts_poster.png"):
rk = json.load(open("rankings.json"))
rs = json.load(open("results.json"))
alive = [r for r in rk["rankings"] if r["alive"]]
fav = alive[0]
mv = rk.get("movers", {})
games = rs.get("games", [])
upcoming = sorted([g for g in games if g.get("upcoming") or g.get("state") == "in"],
key=lambda g: g["ts"])[:6]
stamp = rk.get("updated_iso", "")
fig = plt.figure(figsize=(16, 33))
fig.patch.set_facecolor(BG)
# ---- header ----
hd = fig.add_axes([0, 0.955, 1, 0.045]); hd.axis("off")
hd.text(0.035, 0.68, "2026 WORLD CUP — WHO'S LEFT", color=FG, fontsize=37,
fontweight="bold", va="center")
hd.text(0.035, 0.37, "& current title favorites, per betting markets", color=ACC,
fontsize=19, fontweight="bold", va="center")
hd.text(0.035, 0.10, f"Live win-the-Cup odds among the {len(alive)} teams still standing · "
f"~$3.7B prediction-market volume · as of {stamp} UTC · cupcharts.com",
color=MUTE, fontsize=13.5, va="center")
# ---- stat band: favorite + biggest 24h riser + biggest 24h faller ----
def _stat(x0, w, label, lcol, team, big):
ax = fig.add_axes([x0, 0.917, w, 0.032]); ax.set_facecolor(CARD)
for s in ax.spines.values():
s.set_color(BORDER)
ax.set_xticks([]); ax.set_yticks([])
ax.text(0.045, 0.76, label, transform=ax.transAxes, color=lcol, fontsize=11.5,
fontweight="bold", va="center")
f = _flag({"United States": "USA"}.get(team, team), 0.18)
if f:
ax.add_artist(AnnotationBbox(f, (0.095, 0.33), xycoords="axes fraction",
frameon=False, box_alignment=(0.5, 0.5)))
ax.text(0.18, 0.33, _disp(team), transform=ax.transAxes, color=FG, fontsize=16,
fontweight="bold", va="center")
ax.text(0.955, 0.33, big, transform=ax.transAxes, color=lcol, fontsize=17,
fontweight="bold", ha="right", va="center")
g = mv.get("gainer") or {}
l = mv.get("loser") or {}
_stat(0.035, 0.30, "CURRENT FAVORITE", ACC, fav["team"], f"{fav['odds']:.1f}%")
if g:
_stat(0.355, 0.30, "BIGGEST 24H RISER", UP, g["team"], f"+{g['delta']:.1f}")
if l:
_stat(0.675, 0.29, "BIGGEST 24H FALLER", DOWN, l["team"], f"{l['delta']:.1f}")
# ---- two charts stacked full width (bump on top, odds-over-time below) ----
for y0, h, img in ((0.525, 0.385, "wc_bump_dark.png"), (0.25, 0.265, "wc_odds_dark.png")):
ax = fig.add_axes([0.03, y0, 0.94, h]); ax.axis("off")
try:
ax.imshow(plt.imread(img))
except Exception:
ax.text(0.5, 0.5, "(chart)", color=MUTE, ha="center")
# ---- bottom: three compact panels ----
ax_s = fig.add_axes([0.035, 0.018, 0.30, 0.215]); _panel(ax_s, "LIVE STANDINGS")
ax_u = fig.add_axes([0.355, 0.018, 0.30, 0.215]); _panel(ax_u, "UPCOMING · to-win odds")
ax_o = fig.add_axes([0.675, 0.018, 0.29, 0.215]); _panel(ax_o, "OVER / UNDER (since Round of 32)")
# standings rows
n = min(len(alive), 14)
ax_s.text(0.62, 0.905, "odds", color=FAINT, fontsize=10, ha="right", transform=ax_s.transAxes)
ax_s.text(0.80, 0.905, "1h", color=FAINT, fontsize=10, ha="right", transform=ax_s.transAxes)
ax_s.text(0.98, 0.905, "24h", color=FAINT, fontsize=10, ha="right", transform=ax_s.transAxes)
for i, r in enumerate(alive[:n]):
y = 0.855 - i * (0.80 / n)
ax_s.text(0.04, y, str(r["rank"]), color=FAINT, fontsize=11, va="center", transform=ax_s.transAxes)
fl = _flag(r["team"], 0.11)
if fl:
ax_s.add_artist(AnnotationBbox(fl, (0.15, y), xycoords="axes fraction", frameon=False,
box_alignment=(0.5, 0.5)))
ax_s.text(0.22, y, r["team"][:14], color=FG, fontsize=11.5, va="center", transform=ax_s.transAxes)
ax_s.text(0.62, y, f"{r['odds']:.1f}%", color=FG, fontsize=11.5, fontweight="bold",
ha="right", va="center", transform=ax_s.transAxes)
for val, x in ((r.get("d1h"), 0.80), (r.get("d24h"), 0.98)):
txt, col = _chg(val)
ax_s.text(x, y, txt, color=col, fontsize=9.5, ha="right", va="center", transform=ax_s.transAxes)
# upcoming rows: teams on their own line, odds row below (home left · draw center · away right)
for i, g in enumerate(upcoming):
y = 0.85 - i * 0.145
live = g.get("state") == "in"
dt = datetime.datetime.fromisoformat(g["ts"].replace("Z", "+00:00"))
rnd = _round(g.get("date") or g["ts"][:10])
# two-line header: round/time on top, "win or go home" stakes below
if live:
ax_u.text(0.06, y + 0.064, "● LIVE " + (g.get("status") or ""), color=DOWN,
fontsize=9.5, fontweight="bold", va="center", transform=ax_u.transAxes)
else:
ax_u.text(0.06, y + 0.064, f"{rnd} · {dt.strftime('%a %b %d, %H:%M')}", color=ACC,
fontsize=9, fontweight="bold", va="center", transform=ax_u.transAxes)
ax_u.text(0.06, y + 0.034, ("knockout · " if live else "") + "win or go home — loser eliminated",
color=FAINT, fontsize=8, va="center", transform=ax_u.transAxes)
fh = _flag({"United States": "USA"}.get(g["home"], g["home"]), 0.10)
if fh:
ax_u.add_artist(AnnotationBbox(fh, (0.09, y), xycoords="axes fraction", frameon=False,
box_alignment=(0.5, 0.5)))
ax_u.text(0.14, y, _disp(g["home"]), color=FG, fontsize=10.5, va="center", transform=ax_u.transAxes)
fa = _flag({"United States": "USA"}.get(g["away"], g["away"]), 0.10)
if fa:
ax_u.add_artist(AnnotationBbox(fa, (0.955, y), xycoords="axes fraction", frameon=False,
box_alignment=(0.5, 0.5)))
ax_u.text(0.905, y, _disp(g["away"]), color=FG, fontsize=10.5, ha="right", va="center",
transform=ax_u.transAxes)
# 2-way "to win the match" odds (draw removed, rescaled to 100%)
hv, av = g.get("g_home_adv"), g.get("g_away_adv")
ax_u.text(0.06, y - 0.05, (f"{hv:.0f}%" if hv is not None else "—"), color=ACC, fontsize=11.5,
fontweight="bold", ha="left", va="center", transform=ax_u.transAxes)
ax_u.text(0.50, y - 0.05, "to win", color=FAINT, fontsize=8, ha="center",
va="center", transform=ax_u.transAxes)
ax_u.text(0.94, y - 0.05, (f"{av:.0f}%" if av is not None else "—"), color=ACC, fontsize=11.5,
fontweight="bold", ha="right", va="center", transform=ax_u.transAxes)
# over/under vs the Round of 32 baseline
au = sorted([r for r in alive if r.get("dr32") is not None], key=lambda r: -r["dr32"])
up5, dn5 = au[:5], au[-5:][::-1]
ax_o.text(0.04, 0.87, "▲ risen most", color=UP, fontsize=11, fontweight="bold",
transform=ax_o.transAxes)
for i, r in enumerate(up5):
y = 0.80 - i * 0.058
_ourow(ax_o, y, r)
ax_o.text(0.04, 0.44, "▼ fallen most", color=DOWN, fontsize=11, fontweight="bold",
transform=ax_o.transAxes)
for i, r in enumerate(dn5):
y = 0.37 - i * 0.058
_ourow(ax_o, y, r)
fig.text(0.5, 0.006, "cupcharts.com · odds = market-implied probabilities from ~$3.7B betting volume, "
"not true win probabilities", color=FAINT, fontsize=13, ha="center")
fig.savefig(out, dpi=300, facecolor=BG) # super high-res (4500x6300 px)
plt.close(fig)
print("wrote", out)
def _ourow(ax, y, r):
fl = _flag(r["team"], 0.10)
if fl:
ax.add_artist(AnnotationBbox(fl, (0.10, y), xycoords="axes fraction", frameon=False,
box_alignment=(0.5, 0.5)))
ax.text(0.17, y, _disp(r["team"])[:13], color=FG, fontsize=10.5, va="center", transform=ax.transAxes)
ax.text(0.74, y, f"{r['r32']:.1f}→{r['odds']:.1f}%", color=MUTE, fontsize=9, ha="right",
va="center", transform=ax.transAxes)
txt, col = _chg(r.get("dr32"))
ax.text(0.98, y, txt, color=col, fontsize=10, fontweight="bold", ha="right", va="center",
transform=ax.transAxes)
if __name__ == "__main__":
make_poster()
View raw

(Sorry about that, but we can’t show files that are this big right now.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment