Skip to content

Instantly share code, notes, and snippets.

@scfrisby
Last active March 3, 2026 13:27
Show Gist options
  • Select an option

  • Save scfrisby/61e9dfd7f3bfe0edf639c9bb371bec80 to your computer and use it in GitHub Desktop.

Select an option

Save scfrisby/61e9dfd7f3bfe0edf639c9bb371bec80 to your computer and use it in GitHub Desktop.
Championship simulation 2025-2026
# “””
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