Last active
March 3, 2026 13:27
-
-
Save scfrisby/61e9dfd7f3bfe0edf639c9bb371bec80 to your computer and use it in GitHub Desktop.
Championship simulation 2025-2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # “”” | |
| EFL Championship 2025/26 — Monte Carlo Simulation | |
| Standalone script. No external dependencies beyond the Python standard library. | |
| Produces: | |
| 1. League standings simulation (promotion / relegation probabilities) | |
| 1. Coventry City season goals projection | |
| Data source : thefishy.co.uk (updated 28 Feb 2026, GW35) | |
| Table state : GW37 — after Birmingham 1-3 Middlesbrough (3 Mar 2026) | |
| ## Methodology | |
| Match outcome probabilities are derived from a strength function: | |
| strength(team, venue) = | |
| 0.40 * venue PPG (home or away PPG this season) | |
| + 0.60 * last-6 form (pts from last 6 games / 6, i.e. 0-3 scale) | |
| + 0.10 * blended xG differential bonus | |
| Blended xG differential = (0.5*xGF/g + 0.5*GF/g) - (0.5*xGA/g + 0.5*GA/g) | |
| This anchors the model in underlying quality while respecting real results. | |
| Draw tendency: 40% match-specific draw rate + 60% league-average draw rate. | |
| A Beta(2,5) decay factor per simulation run (max 50%) slightly compresses | |
| win/loss probabilities and expands draws, reflecting end-of-season variance. | |
| Goals projection: Poisson model blending Coventry attack vs opponent defence, | |
| with home/away venue multipliers. | |
| ## Usage | |
| ``` | |
| python championship_sim.py # 100k sims (default) | |
| python championship_sim.py --sims 50000 # custom count | |
| python championship_sim.py --goals-only # goals projection only | |
| python championship_sim.py --table-only # standings only | |
| python championship_sim.py --no-colour # plain text | |
| python championship_sim.py --seed 123 # custom seed | |
| ``` | |
| “”” | |
| import random | |
| import math | |
| import argparse | |
| import sys | |
| from collections import defaultdict | |
| # ============================================================================= | |
| # SECTION 1 — RAW DATA (update each gameweek) | |
| # ============================================================================= | |
| # Source: thefishy.co.uk, 28 Feb 2026 (GW35) | |
| # Tuple: (P, GF, GA, xGF_pg, xGA_pg, home_ppg, away_ppg, draw_pct, last6_pts) | |
| # P games played | |
| # GF/GA season goals for/against | |
| # xGF_pg expected goals scored per game (Opta, via thefishy) | |
| # xGA_pg expected goals conceded per game | |
| # home_ppg points per home game this season | |
| # away_ppg points per away game this season | |
| # draw_pct fraction of games drawn (0-1) | |
| # last6_pts points from last 6 league games (max 18) | |
| RAW_DATA = { | |
| “Coventry”: (35, 72, 38, 1.74, 1.04, 2.47, 1.61, 0.23, 13), | |
| “Middlesbrough”: (34, 51, 34, 1.22, 0.88, 2.06, 1.65, 0.26, 10), | |
| “Millwall”: (35, 47, 40, 1.26, 1.10, 1.83, 1.71, 0.23, 13), | |
| “Ipswich”: (33, 59, 34, 1.55, 0.92, 2.24, 1.38, 0.27, 10), | |
| “Hull”: (34, 56, 48, 1.28, 1.64, 1.67, 1.88, 0.18, 10), | |
| “Wrexham”: (35, 54, 45, 1.25, 1.21, 1.67, 1.59, 0.34, 16), | |
| “Southampton”: (35, 57, 46, 1.54, 1.16, 1.76, 1.28, 0.31, 16), | |
| “Derby”: (35, 52, 46, 1.08, 1.19, 1.33, 1.59, 0.26, 10), | |
| “Watford”: (35, 45, 41, 1.21, 1.00, 1.78, 1.12, 0.34, 8), | |
| “Bristol City”: (35, 48, 44, 1.20, 1.24, 1.39, 1.47, 0.23, 8), | |
| “Birmingham”: (34, 45, 43, 1.41, 1.01, 1.94, 1.00, 0.29, 13), | |
| “Preston”: (35, 41, 40, 1.03, 1.34, 1.50, 1.29, 0.37, 7), | |
| “Sheffield Utd”: (35, 50, 48, 1.44, 1.16, 1.53, 1.22, 0.09, 13), | |
| “Stoke”: (35, 39, 34, 1.00, 1.23, 1.47, 1.22, 0.23, 7), | |
| “QPR”: (35, 46, 54, 1.17, 1.11, 1.53, 1.17, 0.23, 8), | |
| “Swansea”: (35, 40, 43, 0.99, 1.13, 1.78, 0.82, 0.20, 13), | |
| “Norwich”: (35, 47, 44, 1.24, 1.36, 1.12, 1.44, 0.17, 13), | |
| “Charlton”: (35, 33, 44, 0.96, 1.32, 1.47, 0.89, 0.31, 7), | |
| “Portsmouth”: (34, 34, 44, 1.09, 1.08, 1.29, 1.00, 0.26, 6), | |
| “Blackburn”: (35, 33, 46, 1.18, 1.08, 0.94, 1.24, 0.23, 13), | |
| “West Brom”: (35, 34, 52, 1.15, 0.98, 1.41, 0.61, 0.23, 4), | |
| “Leicester”: (35, 47, 56, 1.03, 1.45, 1.29, 1.00, 0.29, 2), | |
| “Oxford”: (35, 31, 47, 0.96, 1.27, 1.06, 0.78, 0.31, 6), | |
| “Sheffield Wed”: (35, 21, 71, 0.76, 1.78, 0.22, 0.41, 0.23, 0), | |
| } | |
| # ============================================================================= | |
| # SECTION 2 — CURRENT TABLE (update each gameweek) | |
| # ============================================================================= | |
| # After GW37: Birmingham 1-3 Middlesbrough (3 Mar 2026) | |
| TABLE = { | |
| “Coventry”: {“pts”: 71, “gd”: 34, “played”: 35}, | |
| “Middlesbrough”: {“pts”: 66, “gd”: 19, “played”: 35}, | |
| “Millwall”: {“pts”: 62, “gd”: 7, “played”: 35}, | |
| “Ipswich”: {“pts”: 60, “gd”: 25, “played”: 33}, | |
| “Hull”: {“pts”: 60, “gd”: 8, “played”: 34}, | |
| “Wrexham”: {“pts”: 57, “gd”: 9, “played”: 35}, | |
| “Southampton”: {“pts”: 53, “gd”: 11, “played”: 35}, | |
| “Derby”: {“pts”: 51, “gd”: 6, “played”: 35}, | |
| “Watford”: {“pts”: 51, “gd”: 4, “played”: 35}, | |
| “Bristol City”: {“pts”: 50, “gd”: 4, “played”: 35}, | |
| “Birmingham”: {“pts”: 49, “gd”: 0, “played”: 35}, | |
| “Preston”: {“pts”: 49, “gd”: 1, “played”: 35}, | |
| “Sheffield Utd”: {“pts”: 48, “gd”: 2, “played”: 35}, | |
| “Stoke”: {“pts”: 47, “gd”: 5, “played”: 35}, | |
| “QPR”: {“pts”: 47, “gd”: -8, “played”: 35}, | |
| “Swansea”: {“pts”: 46, “gd”: -3, “played”: 35}, | |
| “Norwich”: {“pts”: 45, “gd”: 3, “played”: 35}, | |
| “Charlton”: {“pts”: 41, “gd”:-11, “played”: 35}, | |
| “Portsmouth”: {“pts”: 39, “gd”:-10, “played”: 34}, | |
| “Blackburn”: {“pts”: 38, “gd”:-13, “played”: 35}, | |
| “West Brom”: {“pts”: 35, “gd”:-18, “played”: 35}, | |
| “Leicester”: {“pts”: 34, “gd”: -9, “played”: 35}, | |
| “Oxford”: {“pts”: 32, “gd”:-16, “played”: 35}, | |
| “Sheffield Wed”: {“pts”: -7, “gd”:-50, “played”: 35}, | |
| } | |
| # ============================================================================= | |
| # SECTION 3 — REMAINING FIXTURES (update each gameweek) | |
| # ============================================================================= | |
| # Format: (home_team, away_team). Duplicates removed automatically. | |
| FIXTURES_RAW = [ | |
| # Coventry confirmed fixtures (from official schedule) | |
| (“Bristol City”, “Coventry”), (“Coventry”, “Preston”), | |
| (“Coventry”, “Southampton”), (“Swansea”, “Coventry”), | |
| (“Coventry”, “Derby”), (“Hull”, “Coventry”), | |
| (“Coventry”, “Sheffield Wed”),(“Blackburn”, “Coventry”), | |
| (“Coventry”, “Portsmouth”), (“Coventry”, “Wrexham”), | |
| (“Watford”, “Coventry”), | |
| # Middlesbrough | |
| (“QPR”, “Middlesbrough”), (“Middlesbrough”, “Oxford”), | |
| (“Middlesbrough”,“Stoke”), (“Middlesbrough”, “Norwich”), | |
| (“Sheffield Wed”,“Middlesbrough”), (“Ipswich”, “Middlesbrough”), | |
| (“Middlesbrough”,“Millwall”), (“Middlesbrough”, “Watford”), | |
| (“Middlesbrough”,“Derby”), (“Middlesbrough”, “West Brom”), | |
| (“Middlesbrough”,“Hull”), | |
| # Millwall | |
| (“Millwall”, “Portsmouth”), (“Hull”, “Millwall”), | |
| (“Preston”, “Millwall”), (“West Brom”, “Millwall”), | |
| (“Millwall”, “Derby”), (“Millwall”, “Sheffield Utd”), | |
| (“Millwall”, “Charlton”), (“Millwall”, “QPR”), | |
| (“Oxford”, “Millwall”), (“Ipswich”, “Millwall”), | |
| (“Millwall”, “Swansea”), | |
| # Ipswich | |
| (“Ipswich”, “Hull”), (“Wrexham”, “Ipswich”), | |
| (“Stoke”, “Ipswich”), (“Ipswich”, “Leicester”), | |
| (“Preston”, “Ipswich”), (“Southampton”, “Ipswich”), | |
| (“Sheffield Utd”,“Ipswich”), (“Ipswich”, “Oxford”), | |
| (“Ipswich”, “Derby”), (“Ipswich”, “Middlesbrough”), | |
| (“Ipswich”, “Millwall”), | |
| # Hull | |
| (“Wrexham”, “Hull”), (“Hull”, “Bristol City”), | |
| (“Birmingham”, “Hull”), (“Charlton”, “Hull”), | |
| (“Oxford”, “Hull”), (“Hull”, “Southampton”), | |
| (“Norwich”, “Hull”), (“Hull”, “QPR”), | |
| # Wrexham | |
| (“Southampton”, “Wrexham”), (“Wrexham”, “West Brom”), | |
| (“Wrexham”, “QPR”), (“Preston”, “Wrexham”), | |
| (“Wrexham”, “Sheffield Wed”),(“Wrexham”, “Leicester”), | |
| (“Blackburn”, “Wrexham”), | |
| # Birmingham | |
| (“West Brom”, “Birmingham”), (“Birmingham”, “QPR”), | |
| (“Birmingham”, “Watford”), (“Birmingham”, “Swansea”), | |
| (“Birmingham”, “Preston”), (“Birmingham”, “Sheffield Utd”), | |
| (“Portsmouth”, “Birmingham”), (“Derby”, “Birmingham”), | |
| (“Bristol City”, “Birmingham”), | |
| # Derby | |
| (“Derby”, “Sheffield Wed”),(“Derby”, “Norwich”), | |
| (“Oxford”, “Derby”), (“Derby”, “Swansea”), | |
| # Watford | |
| (“Sheffield Wed”,“Watford”), (“QPR”, “Watford”), | |
| (“Stoke”, “Watford”), (“Norwich”, “Watford”), | |
| (“Watford”, “Blackburn”), (“Charlton”, “Watford”), | |
| (“Watford”, “Preston”), | |
| # Preston | |
| (“Southampton”, “Preston”), (“Norwich”, “Preston”), | |
| (“Preston”, “Oxford”), (“Preston”, “Sheffield Utd”), | |
| (“QPR”, “Preston”), (“Portsmouth”, “Preston”), | |
| # Charlton | |
| (“Southampton”, “Charlton”), (“Charlton”, “QPR”), | |
| (“Charlton”, “Portsmouth”), (“Charlton”, “Norwich”), | |
| (“Blackburn”, “Charlton”), | |
| # Portsmouth / Norwich | |
| (“Portsmouth”, “Norwich”), (“Leicester”, “Norwich”), | |
| (“Sheffield Wed”,“Norwich”), | |
| # Relegation cluster | |
| (“Leicester”, “Portsmouth”), (“Portsmouth”, “West Brom”), | |
| (“Portsmouth”, “Sheffield Wed”),(“Oxford”, “Portsmouth”), | |
| (“Oxford”, “Blackburn”), (“Leicester”, “Blackburn”), | |
| (“Blackburn”, “Sheffield Wed”),(“Oxford”, “West Brom”), | |
| (“West Brom”, “Sheffield Wed”),(“Leicester”, “West Brom”), | |
| (“Leicester”, “Sheffield Wed”),(“QPR”, “Leicester”), | |
| (“Leicester”, “Oxford”), (“Sheffield Wed”,“Oxford”), | |
| # Sheffield Utd | |
| (“Bristol City”, “Sheffield Utd”),(“Bristol City”,“West Brom”), | |
| (“Bristol City”, “QPR”), (“Sheffield Utd”,“Sheffield Wed”), | |
| (“Sheffield Utd”,“West Brom”), (“Norwich”, “Sheffield Utd”), | |
| (“Sheffield Utd”,“Portsmouth”), (“Sheffield Utd”,“Swansea”), | |
| (“Sheffield Utd”,“Blackburn”), | |
| # Swansea | |
| (“Charlton”, “Swansea”), (“Sheffield Wed”,“Swansea”), | |
| (“Swansea”, “Leicester”), | |
| # Stoke | |
| (“QPR”, “Stoke”), (“Stoke”, “Leicester”), | |
| (“West Brom”, “Stoke”), (“Stoke”, “Blackburn”), | |
| (“Stoke”, “Portsmouth”), (“Stoke”, “Norwich”), | |
| (“Stoke”, “Charlton”), | |
| # Bristol City / Southampton | |
| (“Norwich”, “Bristol City”),(“QPR”, “Sheffield Utd”), | |
| (“Southampton”, “Oxford”), (“Southampton”, “Bristol City”), | |
| (“West Brom”, “Southampton”), (“Blackburn”, “Southampton”), | |
| (“Sheffield Wed”,“Southampton”), (“Hull”, “Southampton”), | |
| (“Watford”, “Southampton”), (“QPR”, “Southampton”), | |
| (“Leicester”, “Southampton”), | |
| ] | |
| # Coventry remaining fixtures for the goals model | |
| # Format: (venue, opponent, date_label) | |
| COV_FIXTURES = [ | |
| (“away”, “Bristol City”, “Sat 7 Mar”), | |
| (“home”, “Preston”, “Wed 11 Mar”), | |
| (“home”, “Southampton”, “Sat 14 Mar”), | |
| (“away”, “Swansea”, “Sat 21 Mar”), | |
| (“home”, “Derby”, “Fri 3 Apr”), | |
| (“away”, “Hull”, “Mon 6 Apr”), | |
| (“home”, “Sheffield Wed”, “Sat 11 Apr”), | |
| (“away”, “Blackburn”, “Sat 18 Apr”), | |
| (“home”, “Portsmouth”, “Tue 21 Apr”), | |
| (“home”, “Wrexham”, “Sat 25 Apr”), | |
| (“away”, “Watford”, “Sat 2 May”), | |
| ] | |
| COV_GOALS_SO_FAR = 72 # season goals to date — update each gameweek | |
| COV_GAMES_PLAYED = 35 # games played to date — update each gameweek | |
| # ============================================================================= | |
| # SECTION 4 — MODEL PARAMETERS | |
| # ============================================================================= | |
| HOME_ATT_BOOST = 1.10 # home attack multiplier | |
| HOME_DEF_BOOST = 0.90 # home defence multiplier (opponent defends worse away) | |
| DECAY_ALPHA = 2 # Beta(alpha, beta) shape — late-season regression | |
| DECAY_BETA = 5 | |
| DECAY_MAX = 0.50 # hard cap on per-simulation decay factor | |
| # ============================================================================= | |
| # SECTION 5 — RATINGS ENGINE | |
| # ============================================================================= | |
| def build_ratings(data): | |
| “”“Derive blended attack/defence ratings and lookup tables from RAW_DATA.””” | |
| ``` | |
| def blended_att(t): | |
| p, gf, xgf_pg = data[t][0], data[t][1], data[t][3] | |
| return 0.50 * xgf_pg + 0.50 * (gf / p) | |
| def blended_def(t): | |
| p, ga, xga_pg = data[t][0], data[t][2], data[t][4] | |
| return 0.50 * xga_pg + 0.50 * (ga / p) | |
| league_avg_att = sum(blended_att(t) for t in data) / len(data) | |
| return { | |
| "xg_diff": {t: blended_att(t) - league_avg_att for t in data}, | |
| "home_ppg": {t: data[t][5] for t in data}, | |
| "away_ppg": {t: data[t][6] for t in data}, | |
| "draw_pct": {t: data[t][7] for t in data}, | |
| "form6": {t: data[t][8] for t in data}, | |
| "blend_att": {t: blended_att(t) for t in data}, | |
| "blend_def": {t: blended_def(t) for t in data}, | |
| } | |
| ``` | |
| def team_strength(team, venue, R): | |
| “”” | |
| Composite strength score. | |
| 0.40 * venue_PPG + 0.60 * last6_form_rate + 0.10 * xGD_bonus | |
| “”” | |
| sp = R[“home_ppg”][team] if venue == “home” else R[“away_ppg”][team] | |
| form = R[“form6”][team] / 6.0 | |
| return 0.40 * sp + 0.60 * form + R[“xg_diff”][team] * 0.10 | |
| def fixture_probs(home, away, R, decay=0.0): | |
| “”” | |
| Return (p_home_win, p_draw, p_away_win) for a fixture. | |
| Applies Beta-distributed decay to compress extreme probabilities. | |
| “”” | |
| league_avg_draw = sum(R[“draw_pct”].values()) / len(R[“draw_pct”]) | |
| hs = team_strength(home, “home”, R) | |
| as_ = team_strength(away, “away”, R) | |
| ``` | |
| draw = (0.40 * ((R["draw_pct"][home] + R["draw_pct"][away]) / 2) | |
| + 0.60 * league_avg_draw) | |
| r = hs / (hs + as_) if (hs + as_) > 0 else 0.5 | |
| nd = 1 - draw | |
| hw = min(0.72, max(0.10, r * nd * 1.05)) | |
| aw = min(0.65, max(0.08, (1 - r) * nd)) | |
| if decay > 0: | |
| hw = max(0.10, hw * (1 - decay * 0.25)) | |
| aw = max(0.08, aw * (1 - decay * 0.25)) | |
| draw = min(0.42, draw + decay * 0.06) | |
| tot = hw + draw + aw | |
| return hw / tot, draw / tot, aw / tot | |
| ``` | |
| # ============================================================================= | |
| # SECTION 6 — POISSON SAMPLER | |
| # ============================================================================= | |
| def poisson_sample(lam): | |
| “”“Sample from Poisson(lam). Knuth algorithm — no scipy required.””” | |
| L = math.exp(-max(lam, 1e-9)) | |
| k = 0 | |
| p = 1.0 | |
| while True: | |
| p *= random.random() | |
| if p <= L: | |
| return k | |
| k += 1 | |
| def fixture_xg(venue, opp, R): | |
| “”“Expected goals for Coventry in a single remaining fixture.””” | |
| att = R[“blend_att”][“Coventry”] | |
| dfe = R[“blend_def”][opp] | |
| if venue == “home”: | |
| return max(0.3, (att * HOME_ATT_BOOST + dfe * HOME_DEF_BOOST) / 2) | |
| else: | |
| return max(0.3, (att / HOME_ATT_BOOST + dfe / HOME_DEF_BOOST) / 2) | |
| # ============================================================================= | |
| # SECTION 7 — SIMULATION RUNNERS | |
| # ============================================================================= | |
| def run_league_simulation(table, fixtures_raw, R, n_sims, seed=42): | |
| “”“Monte Carlo simulation of all remaining league fixtures.””” | |
| random.seed(seed) | |
| ``` | |
| # Deduplicate fixtures and filter to known teams | |
| seen, clean = set(), [] | |
| for h, a in fixtures_raw: | |
| if h not in table or a not in table: | |
| continue | |
| key = tuple(sorted([h, a])) | |
| if key not in seen: | |
| seen.add(key); clean.append((h, a)) | |
| auto_p = defaultdict(int) | |
| champ_w = defaultdict(int) | |
| top6_c = defaultdict(int) | |
| relg_c = defaultdict(int) | |
| avg_pts = defaultdict(float) | |
| pos_dist = {t: defaultdict(int) for t in table} | |
| for _ in range(n_sims): | |
| decay = min(DECAY_MAX, random.betavariate(DECAY_ALPHA, DECAY_BETA)) | |
| pts = {t: d["pts"] for t, d in table.items()} | |
| gd = {t: d["gd"] for t, d in table.items()} | |
| for home, away in clean: | |
| hp, dp, ap = fixture_probs(home, away, R, decay) | |
| r = random.random() | |
| if r < hp: | |
| pts[home] += 3 | |
| gd[home] += random.randint(1, 3) | |
| gd[away] -= random.randint(1, 3) | |
| elif r < hp + dp: | |
| pts[home] += 1 | |
| pts[away] += 1 | |
| else: | |
| pts[away] += 3 | |
| gd[home] -= random.randint(1, 3) | |
| gd[away] += random.randint(1, 3) | |
| order = sorted(pts, key=lambda t: (pts[t], gd[t]), reverse=True) | |
| for pos, t in enumerate(order, 1): | |
| avg_pts[t] += pts[t] | |
| pos_dist[t][pos] += 1 | |
| if pos <= 2: auto_p[t] += 1 | |
| if pos <= 6: top6_c[t] += 1 | |
| if pos == 1: champ_w[t] += 1 | |
| if pos >= 22: relg_c[t] += 1 | |
| return { | |
| "auto": auto_p, "title": champ_w, "top6": top6_c, | |
| "relg": relg_c, "avg_pts": avg_pts, | |
| "pos_dist": pos_dist, "n_sims": n_sims, | |
| } | |
| ``` | |
| def run_goals_simulation(cov_fixtures, R, goals_so_far, n_sims, seed=42): | |
| “”“Poisson simulation of Coventry goals for the rest of the season.””” | |
| random.seed(seed) | |
| totals = [] | |
| for _ in range(n_sims): | |
| season = goals_so_far | |
| for venue, opp, _ in cov_fixtures: | |
| season += poisson_sample(fixture_xg(venue, opp, R)) | |
| totals.append(season) | |
| totals.sort() | |
| return totals | |
| # ============================================================================= | |
| # SECTION 8 — OUTPUT | |
| # ============================================================================= | |
| ANSI = { | |
| “reset”: “\033[0m”, “bold”: “\033[1m”, “dim”: “\033[2m”, | |
| “green”: “\033[92m”, “yellow”: “\033[93m”, | |
| “red”: “\033[91m”, | |
| } | |
| def col(text, key, use_colour): | |
| return (ANSI[key] + text + ANSI[“reset”]) if use_colour else text | |
| def pct(v): | |
| return f”{v:.1f}%” if v >= 0.1 else “—” | |
| def print_league_table(table, results, R, use_colour=True): | |
| N = results[“n_sims”] | |
| auto_p = results[“auto”] | |
| champ_w = results[“title”] | |
| top6_c = results[“top6”] | |
| relg_c = results[“relg”] | |
| avg_pts = results[“avg_pts”] | |
| pos_dist = results[“pos_dist”] | |
| ``` | |
| print() | |
| print(col("=" * 84, "bold", use_colour)) | |
| print(col(" EFL CHAMPIONSHIP 2025/26 — MONTE CARLO SIMULATION", "bold", use_colour)) | |
| print(col( | |
| f" {N:,} sims | GW37 table | thefishy.co.uk (28 Feb 2026)", | |
| "dim", use_colour | |
| )) | |
| print(col("=" * 84, "bold", use_colour)) | |
| print(f"\n {'#':<3} {'Team':<18} {'Pts':>4} {'PL':>3} {'Proj':>5} " | |
| f"{'1st':>6} {'2nd':>6} {'PO 3-6':>7} {'Mid':>7} {'Relg':>7}") | |
| print(f" {'─' * 80}") | |
| display = sorted(table, key=lambda t: (table[t]["pts"], table[t]["gd"]), reverse=True) | |
| zones = { | |
| 2: ("── AUTOMATIC PROMOTION ──", "green"), | |
| 6: ("── PLAYOFFS ──", "yellow"), | |
| 21: ("── RELEGATION ──", "red"), | |
| } | |
| for i, t in enumerate(display, 1): | |
| d = table[t]; pd = pos_dist[t] | |
| p1 = champ_w[t] / N * 100 | |
| p2 = (auto_p[t] - champ_w[t]) / N * 100 | |
| po = sum(pd[j] for j in range(3, 7)) / N * 100 | |
| mid = sum(pd[j] for j in range(7, 22)) / N * 100 | |
| rl = relg_c[t] / N * 100 | |
| proj = round(avg_pts[t] / N) | |
| zone_key = "green" if i <= 2 else "yellow" if i <= 6 else "red" if i >= 22 else "reset" | |
| row = (f" {i:<3} {t:<18} {d['pts']:>4} {d['played']:>3} {proj:>5} " | |
| f"{pct(p1):>6} {pct(p2):>6} {pct(po):>7} {pct(mid):>7} {pct(rl):>7}") | |
| print(col(row, zone_key, use_colour)) | |
| if i in zones: | |
| label, zone_c = zones[i] | |
| print(f" {col('─' * 80, zone_c, use_colour)}") | |
| print(f" {col(label, zone_c, use_colour)}") | |
| print(f" {col('─' * 80, zone_c, use_colour)}") | |
| print() | |
| print(col(" Methodology:", "bold", use_colour)) | |
| print(" strength = 0.40*venue_PPG + 0.60*last6_form + 0.10*blended_xGD") | |
| print(" blended_xGD uses 50% Opta xG + 50% actual goals (for and against)") | |
| print(" Decay: Beta(2,5) per sim, capped at 50%") | |
| ``` | |
| def print_goals_projection(cov_fixtures, totals, R, | |
| goals_so_far, games_played, use_colour=True): | |
| N = len(totals) | |
| mean = sum(totals) / N | |
| median = totals[N // 2] | |
| std = (sum((g - mean) ** 2 for g in totals) / N) ** 0.5 | |
| p10 = totals[int(N * 0.10)] | |
| p90 = totals[int(N * 0.90)] | |
| ``` | |
| all_xg = [fixture_xg(v, o, R) for v, o, _ in cov_fixtures] | |
| total_xg = sum(all_xg) | |
| best_xg = max(all_xg) | |
| worst_xg = min(all_xg) | |
| print() | |
| print(col("=" * 64, "bold", use_colour)) | |
| print(col(" COVENTRY CITY — PROJECTED SEASON GOALS 2025/26", "bold", use_colour)) | |
| print(col(f" {N:,} Poisson simulations | blended xG model", "dim", use_colour)) | |
| print(col("=" * 64, "bold", use_colour)) | |
| print(f"\n Current: {goals_so_far} goals in {games_played} games " | |
| f"({goals_so_far / games_played:.2f}/game)") | |
| print(f" Remaining: {len(cov_fixtures)} fixtures | " | |
| f"total xG: {total_xg:.1f} ({total_xg / len(cov_fixtures):.2f}/game)") | |
| print(f"\n {'Date':<14} {'V':>2} {'Opponent':<14} {'xG':>6}") | |
| print(f" {'─' * 44}") | |
| for (venue, opp, date), xg in zip(cov_fixtures, all_xg): | |
| loc = "H" if venue == "home" else "A" | |
| note = " <- best" if xg == best_xg else (" <- toughest" if xg == worst_xg else "") | |
| ck = "green" if xg == best_xg else ("red" if xg == worst_xg else "reset") | |
| row = f" {date:<14} ({loc}) {opp:<14} {xg:>6.2f}{note}" | |
| print(col(row, ck, use_colour)) | |
| print(f"\n {'─' * 44}") | |
| print(col(f" Projected total: {median} goals (median)", "bold", use_colour)) | |
| print(f" Mean: {mean:.1f} Std: +/-{std:.1f} P10-P90: {p10}-{p90}") | |
| print(f"\n Bracket probabilities:") | |
| for lo in [80, 85, 90, 95, 100]: | |
| hi = lo + 4 | |
| cnt = sum(1 for g in totals if lo <= g <= hi) | |
| pct_val = cnt / N * 100 | |
| bar = chr(9608) * int(pct_val / 100 * 40) | |
| ck = "green" if lo == 90 else "reset" | |
| print(col(f" {lo}-{hi}: {pct_val:5.1f}% {bar}", ck, use_colour)) | |
| print(f"\n Landmark probabilities:") | |
| for tgt in [85, 90, 95, 100]: | |
| p_val = sum(1 for g in totals if g >= tgt) / N * 100 | |
| ck = "green" if tgt == 90 else "reset" | |
| print(col(f" {tgt}+: {p_val:.1f}%", ck, use_colour)) | |
| ``` | |
| # ============================================================================= | |
| # SECTION 9 — ENTRY POINT | |
| # ============================================================================= | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description=“EFL Championship 2025/26 Monte Carlo simulation” | |
| ) | |
| parser.add_argument(”–sims”, type=int, default=100_000, | |
| help=“League sim count (goals uses 2x). Default: 100000”) | |
| parser.add_argument(”–goals-only”, action=“store_true”, | |
| help=“Run goals projection only”) | |
| parser.add_argument(”–table-only”, action=“store_true”, | |
| help=“Run league table simulation only”) | |
| parser.add_argument(”–no-colour”, action=“store_true”, | |
| help=“Disable ANSI colour output”) | |
| parser.add_argument(”–seed”, type=int, default=42, | |
| help=“Random seed. Default: 42”) | |
| args = parser.parse_args() | |
| ``` | |
| use_colour = not args.no_colour and sys.stdout.isatty() | |
| print(f"\nBuilding ratings from {len(RAW_DATA)} teams...") | |
| R = build_ratings(RAW_DATA) | |
| if not args.goals_only: | |
| print(f"Running league simulation ({args.sims:,} runs)...") | |
| results = run_league_simulation( | |
| TABLE, FIXTURES_RAW, R, args.sims, seed=args.seed | |
| ) | |
| print_league_table(TABLE, results, R, use_colour=use_colour) | |
| if not args.table_only: | |
| goal_sims = args.sims * 2 | |
| print(f"Running goals simulation ({goal_sims:,} runs)...") | |
| totals = run_goals_simulation( | |
| COV_FIXTURES, R, COV_GOALS_SO_FAR, goal_sims, seed=args.seed | |
| ) | |
| print_goals_projection( | |
| COV_FIXTURES, totals, R, | |
| COV_GOALS_SO_FAR, COV_GAMES_PLAYED, | |
| use_colour=use_colour | |
| ) | |
| print() | |
| ``` | |
| if **name** == “**main**”: | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment