Skip to content

Instantly share code, notes, and snippets.

@tulior
Created July 1, 2026 22:34
Show Gist options
  • Select an option

  • Save tulior/cbdb3f961595e45252d10d95bec5ad1a to your computer and use it in GitHub Desktop.

Select an option

Save tulior/cbdb3f961595e45252d10d95bec5ad1a to your computer and use it in GitHub Desktop.
Live football match prediction model with player priors and Dixon-Coles correction
#!/usr/bin/env python3
"""
football_live_match_model.py
Football Live Match Model
Estimate live or pre-match football probabilities from player-level priors,
current score, time remaining, Dixon-Coles low-score correction, optional
knockout/penalty logic, and optional market-odds comparison.
Generic live/pre-match football prediction model using:
1. Player-prior log-goal intensities
2. Time-scaled Poisson score simulation
3. Dixon-Coles low-score correction
4. Optional penalty shootout resolution for knockout matches
================================================================================
QUICK USE
================================================================================
Run with the built-in example:
python generic_live_football_model.py
Run with your own config:
python generic_live_football_model.py --config match_config.json
Create a starter config:
python generic_live_football_model.py --write-template match_config_template.json
================================================================================
WHAT VALUES YOU NEED AND WHERE TO PULL THEM FROM
================================================================================
This script is intentionally generic. It does not scrape websites by itself.
You provide the parameters from your data sources.
Required inputs:
1. MATCH STATE
Pull from:
- Official match centre
- FIFA / tournament match centre
- Sportradar / Opta / StatsPerform feed
- SofaScore / Flashscore / FotMob
- Your sportsbook live screen if necessary
Needed fields:
current_home_goals
current_away_goals
minutes_remaining
phase
Examples:
Pre-match:
score = 0-0
minutes_remaining = 90
duration_factor = 1.0
Live at 67':
score = 1-0
minutes_remaining = 23 plus stoppage estimate
duration_factor = remaining_minutes / 90
Extra-time halftime:
score = 2-2
minutes_remaining = 15
duration_factor = 15 / 90
2. ACTIVE PLAYERS / LINEUPS
Pull from:
- Official lineup release
- FIFA / federation lineup card
- Reuters / AP match report
- SofaScore / FotMob lineups
- Your live tracker after substitutions
You need one list for each team.
For live in-game / extra-time:
Use only players currently on the pitch.
Set weight = 1.0 for each active player.
The team weight sum should equal 11.0.
For pre-match:
Use expected-minutes weights.
Example:
GK and CBs: 1.00
Fullbacks: 0.85-1.00
Central mids: 0.70-0.95
Wingers: 0.60-0.85
Striker: 0.70-0.90
Bench attackers: 0.20-0.40
The total team weight should still equal 11.0.
Better: enforce positional-slot weights where each tactical slot sums to 1.0.
3. PLAYER PRIORS: gamma_attack and delta_defense
Pull or generate from:
- Your player-prior pipeline
- Event-level data such as Opta, Wyscout, StatsBomb, StatsPerform
- Rolling out-of-sample per-90 metrics
- Market-value / transfer-value cold-start model
- League-adjusted xG, xA, xT, OBV, VAEP, defensive RAPM
Interpretation:
gamma_attack is offensive contribution on the log-goal scale.
delta_defense is defensive suppression on the log-goal scale.
gamma = 0.10 means roughly:
exp(0.10) - 1 = +10.5% attacking-rate lift
delta = 0.10 means roughly:
1 - exp(-0.10) = 9.5% opponent scoring-rate reduction
Practical ranges:
Average player: gamma 0.00, delta 0.00
Good attacker: gamma 0.04 to 0.08
Elite attacker: gamma 0.10 to 0.18
Weak attacker: gamma -0.03 to -0.08
Good defender: delta 0.04 to 0.08
Elite defender / goalkeeper:delta 0.10 to 0.16
Defensive liability: delta -0.03 to -0.08
4. BASELINE mu
Best source:
- Fit on historical match/team xG using your training data.
Meaning:
exp(mu) is baseline team xG per 90 before player effects.
Common practical values:
exp(mu) = 1.15 to 1.35
mu = log(1.25) is a reasonable neutral default.
Market-assisted fallback:
If you do not have a trained base rate, derive total-goal expectation
from Over/Under lines, then choose mu so the model's total xG roughly
matches that expectation.
Be careful: if mu is calibrated directly to the market, your output is
no longer an independent pure-model prediction. It becomes a market-
anchored allocation model.
5. rho: Dixon-Coles low-score parameter
Best source:
- Fit from historical match results using your Dixon-Coles likelihood.
Typical range:
-0.15 to +0.05
A common default:
rho = -0.05
Interpretation:
Negative rho tends to increase low-score draw mass, especially useful
in cautious or late knockout states.
6. fatigue_multiplier / tempo_multiplier
Pull or infer from:
- Game state
- Phase
- Live xG / shot tempo
- Tactical context
- Red cards
- Need-to-chase state
Typical values:
Normal pre-match: 1.00
Late game, cautious: 0.80-0.95
Extra time, tired/cautious: 0.85-0.95
Team chasing aggressively: 1.05-1.25
Open chaotic game: 1.15-1.35
7. Penalty shootout edge
Pull or generate from:
- Goalkeeper penalty-saving data
- Penalty taker quality
- Market "to qualify" vs 90-min line
- Historical shootout model
- Simple 50/50 if uncertain
Typical values:
No edge: 0.50
Small edge: 0.53-0.56
Strong edge: 0.58-0.62
8. Market odds, optional
Pull from:
- Bet365
- Pinnacle
- Betfair Exchange
- DraftKings / FanDuel / Caesars
- Odds aggregators
Use for comparison only:
- 1X2 odds
- To qualify odds
- Over/Under goals
- Next goal / extra-time / penalties markets
================================================================================
MATH SUMMARY
================================================================================
For each team:
attack_team = sum_p weight_p * gamma_p
defense_team = sum_p weight_p * delta_p
Expected goals per 90:
log(lambda_home_90) = mu + home_adv + attack_home - defense_away
log(lambda_away_90) = mu + attack_away - defense_home
Time scaling:
lambda_home_remaining =
lambda_home_90 * duration_factor * fatigue_multiplier
lambda_away_remaining =
lambda_away_90 * duration_factor * fatigue_multiplier
Poisson score probability:
P(H=h, A=a)
= Pois(h | lambda_home_remaining)
*
Pois(a | lambda_away_remaining)
Dixon-Coles correction:
tau(0,0) = 1 - lambda_home * lambda_away * rho
tau(0,1) = 1 + lambda_home * rho
tau(1,0) = 1 + lambda_away * rho
tau(1,1) = 1 - rho
tau(x,y) = 1 otherwise
Corrected score matrix:
M[h,a] = Pois(h) * Pois(a) * tau(h,a)
Then normalize:
M <- M / sum(M)
For knockout matches:
P(home qualifies)
= P(home ahead after simulated period)
+ P(tied after simulated period) * home_penalty_win_prob
================================================================================
CONFIG FORMAT
================================================================================
A config JSON should look like this:
{
"match": {
"home_team": "Team A",
"away_team": "Team B",
"current_home_goals": 0,
"current_away_goals": 0,
"minutes_remaining": 90,
"duration_factor": 1.0,
"fatigue_multiplier": 1.0,
"mu": 0.22314355131420976,
"rho": -0.05,
"home_adv": 0.0,
"home_penalty_win_prob": 0.50,
"max_goals_remaining": 10,
"knockout": true
},
"home_players": [
{"name": "Home GK", "weight": 1.0, "gamma_attack": 0.00, "delta_defense": 0.12}
],
"away_players": [
{"name": "Away GK", "weight": 1.0, "gamma_attack": 0.00, "delta_defense": 0.10}
],
"market_odds": {
"home_90": 2.10,
"draw_90": 3.40,
"away_90": 3.50,
"home_qualify": 1.70,
"away_qualify": 2.20
}
}
market_odds is optional and only used for edge/EV comparison.
================================================================================
LIMITATIONS
================================================================================
This script is a modeling engine, not a data pipeline.
It does not:
- scrape lineups
- scrape odds
- estimate player priors from raw events
- fit mu/rho from historical data
- infer substitutions automatically
You should treat this as the final computation layer after your data has already
been cleaned and mapped into model-ready parameters.
"""
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from math import exp, lgamma, log
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple
import numpy as np
@dataclass(frozen=True)
class Player:
"""
A player contribution object.
weight:
Expected participation weight.
Live active XI:
Usually 1.0 for each player currently on the pitch.
Pre-match:
Usually expected_minutes / 90, with slot accounting.
gamma_attack:
Offensive player prior on log-goal scale.
delta_defense:
Defensive suppression prior on log-goal scale.
"""
name: str
weight: float
gamma_attack: float
delta_defense: float
@dataclass(frozen=True)
class MatchState:
"""
State and global model parameters.
"""
home_team: str
away_team: str
current_home_goals: int
current_away_goals: int
minutes_remaining: float
duration_factor: float
fatigue_multiplier: float
mu: float
rho: float
home_adv: float
home_penalty_win_prob: float
max_goals_remaining: int
knockout: bool = True
def poisson_pmf(lam: float, max_goals: int) -> np.ndarray:
"""
Vectorized Poisson PMF for k=0..max_goals.
Uses log-probabilities for numerical stability:
log P(X=k) = k log(lambda) - lambda - log(k!)
and:
log(k!) = lgamma(k+1)
"""
if lam < 0 or not np.isfinite(lam):
raise ValueError(f"lambda must be finite and non-negative, got {lam}")
goals = np.arange(max_goals + 1)
if lam == 0:
pmf = np.zeros(max_goals + 1, dtype=float)
pmf[0] = 1.0
return pmf
log_pmf = goals * log(lam) - lam - np.array([lgamma(int(g) + 1) for g in goals])
return np.exp(log_pmf)
def dixon_coles_score_matrix(
lambda_home: float,
lambda_away: float,
rho: float,
max_goals: int,
) -> np.ndarray:
"""
Build Dixon-Coles corrected score matrix.
Rows:
home goals in the modeled remaining period.
Columns:
away goals in the modeled remaining period.
Important:
lambda_home/lambda_away here are for the remaining period, not full match.
"""
home_pmf = poisson_pmf(lambda_home, max_goals)
away_pmf = poisson_pmf(lambda_away, max_goals)
matrix = np.outer(home_pmf, away_pmf)
tau = np.ones_like(matrix)
if max_goals >= 1:
tau[0, 0] = 1.0 - lambda_home * lambda_away * rho
tau[0, 1] = 1.0 + lambda_home * rho
tau[1, 0] = 1.0 + lambda_away * rho
tau[1, 1] = 1.0 - rho
# Prevent impossible log/normalization states if a bad rho is supplied.
tau = np.maximum(tau, 1e-10)
matrix *= tau
total = matrix.sum()
if not np.isfinite(total) or total <= 0:
raise FloatingPointError("Invalid score matrix normalization.")
return matrix / total
def aggregate_players(players: Iterable[Player]) -> Tuple[float, float, float]:
"""
Compute:
attack_sum = sum(weight * gamma_attack)
defense_sum = sum(weight * delta_defense)
weight_sum = sum(weight)
These are the direct inputs to the log-goal model.
"""
players = list(players)
attack_sum = sum(p.weight * p.gamma_attack for p in players)
defense_sum = sum(p.weight * p.delta_defense for p in players)
weight_sum = sum(p.weight for p in players)
return attack_sum, defense_sum, weight_sum
def validate_weight_sum(
players: List[Player],
team_name: str,
target: float = 11.0,
tolerance: float = 1e-6,
mode: str = "raise",
) -> List[Player]:
"""
Check or normalize team weight sum.
mode="raise":
Error if total weight does not equal target.
mode="normalize":
Rescale all weights proportionally to make the sum equal target.
Recommendation:
Use "raise" for serious live work. Normalize only for quick testing.
"""
total = sum(p.weight for p in players)
if abs(total - target) <= tolerance:
return players
if mode == "raise":
raise ValueError(
f"{team_name} weight sum is {total:.6f}, expected {target:.6f}. "
"Fix player weights or use mode='normalize'."
)
if mode == "normalize":
if total <= 0:
raise ValueError(f"{team_name} has non-positive total weight.")
scale = target / total
return [
Player(
name=p.name,
weight=p.weight * scale,
gamma_attack=p.gamma_attack,
delta_defense=p.delta_defense,
)
for p in players
]
raise ValueError("mode must be 'raise' or 'normalize'")
def model_probabilities(
state: MatchState,
home_players: List[Player],
away_players: List[Player],
*,
weight_mode: str = "raise",
) -> Dict[str, object]:
"""
Run the generic model.
Returns:
components
probabilities
fair_odds
final_score_probs
score_matrix
"""
home_players = validate_weight_sum(
home_players,
state.home_team,
target=11.0,
mode=weight_mode,
)
away_players = validate_weight_sum(
away_players,
state.away_team,
target=11.0,
mode=weight_mode,
)
home_attack, home_defense, home_weight = aggregate_players(home_players)
away_attack, away_defense, away_weight = aggregate_players(away_players)
log_lambda_home_90 = state.mu + state.home_adv + home_attack - away_defense
log_lambda_away_90 = state.mu + away_attack - home_defense
lambda_home_90 = exp(log_lambda_home_90)
lambda_away_90 = exp(log_lambda_away_90)
lambda_home_remaining = (
lambda_home_90
* state.duration_factor
* state.fatigue_multiplier
)
lambda_away_remaining = (
lambda_away_90
* state.duration_factor
* state.fatigue_multiplier
)
score_matrix = dixon_coles_score_matrix(
lambda_home=lambda_home_remaining,
lambda_away=lambda_away_remaining,
rho=state.rho,
max_goals=state.max_goals_remaining,
)
p_home_wins_period = 0.0
p_away_wins_period = 0.0
p_tied_after_period = 0.0
final_score_probs: List[Tuple[str, float]] = []
for h_add in range(score_matrix.shape[0]):
for a_add in range(score_matrix.shape[1]):
p = float(score_matrix[h_add, a_add])
final_home = state.current_home_goals + h_add
final_away = state.current_away_goals + a_add
if final_home > final_away:
p_home_wins_period += p
elif final_home < final_away:
p_away_wins_period += p
else:
p_tied_after_period += p
label = (
f"{final_home}-{final_away}, tied"
if final_home == final_away
else f"{final_home}-{final_away}"
)
final_score_probs.append((label, p))
if state.knockout:
p_home_qualifies = (
p_home_wins_period
+ p_tied_after_period * state.home_penalty_win_prob
)
p_away_qualifies = (
p_away_wins_period
+ p_tied_after_period * (1.0 - state.home_penalty_win_prob)
)
else:
p_home_qualifies = None
p_away_qualifies = None
final_score_probs.sort(key=lambda item: item[1], reverse=True)
probs = {
"home_ahead_after_period": p_home_wins_period,
"tied_after_period": p_tied_after_period,
"away_ahead_after_period": p_away_wins_period,
"home_qualifies": p_home_qualifies,
"away_qualifies": p_away_qualifies,
}
fair_odds = {
key: (1.0 / value if value and value > 0 else None)
for key, value in probs.items()
}
return {
"components": {
"home_attack": home_attack,
"home_defense": home_defense,
"home_weight_sum": home_weight,
"away_attack": away_attack,
"away_defense": away_defense,
"away_weight_sum": away_weight,
"log_lambda_home_90": log_lambda_home_90,
"log_lambda_away_90": log_lambda_away_90,
"lambda_home_90": lambda_home_90,
"lambda_away_90": lambda_away_90,
"lambda_home_remaining": lambda_home_remaining,
"lambda_away_remaining": lambda_away_remaining,
"total_lambda_remaining": lambda_home_remaining + lambda_away_remaining,
},
"probabilities": probs,
"fair_odds": fair_odds,
"final_score_probs": final_score_probs,
"score_matrix": score_matrix,
}
def devig_decimal_odds(odds: Dict[str, float]) -> Dict[str, float]:
"""
Convert decimal odds to no-vig probabilities.
Example:
odds = {"home": 2.0, "draw": 3.5, "away": 4.0}
Raw implied:
p_i_raw = 1 / odds_i
No-vig:
p_i = p_i_raw / sum(p_raw)
"""
raw = {k: 1.0 / v for k, v in odds.items() if v and v > 1.0}
total = sum(raw.values())
if total <= 0:
return {}
return {k: v / total for k, v in raw.items()}
def expected_value(probability: float, decimal_odds: float) -> float:
"""
Expected value per unit stake:
EV = p * odds - 1
Positive EV means the offered price is above your fair price.
"""
return probability * decimal_odds - 1.0
def compare_market(
result: Dict[str, object],
market_odds: Optional[Dict[str, float]],
) -> Dict[str, Dict[str, Optional[float]]]:
"""
Compare model fair probabilities to market odds.
Supported market_odds keys:
home_90
draw_90
away_90
home_qualify
away_qualify
In a live extra-time state, home_90/draw_90/away_90 may not be meaningful.
Use home_qualify/away_qualify or period-specific markets instead.
"""
if not market_odds:
return {}
probs = result["probabilities"]
comparison: Dict[str, Dict[str, Optional[float]]] = {}
mapping = {
"home_qualify": "home_qualifies",
"away_qualify": "away_qualifies",
}
for market_key, prob_key in mapping.items():
odds_value = market_odds.get(market_key)
prob = probs.get(prob_key)
if odds_value is None or prob is None:
continue
comparison[market_key] = {
"model_probability": prob,
"market_decimal_odds": odds_value,
"model_fair_odds": 1.0 / prob if prob > 0 else None,
"ev": expected_value(prob, odds_value),
}
return comparison
def load_config(path: Path) -> Tuple[MatchState, List[Player], List[Player], Optional[Dict[str, float]]]:
"""
Load model config from JSON.
"""
data = json.loads(path.read_text(encoding="utf-8"))
m = data["match"]
state = MatchState(
home_team=m["home_team"],
away_team=m["away_team"],
current_home_goals=int(m["current_home_goals"]),
current_away_goals=int(m["current_away_goals"]),
minutes_remaining=float(m["minutes_remaining"]),
duration_factor=float(m["duration_factor"]),
fatigue_multiplier=float(m["fatigue_multiplier"]),
mu=float(m["mu"]),
rho=float(m["rho"]),
home_adv=float(m.get("home_adv", 0.0)),
home_penalty_win_prob=float(m.get("home_penalty_win_prob", 0.5)),
max_goals_remaining=int(m.get("max_goals_remaining", 10)),
knockout=bool(m.get("knockout", True)),
)
home_players = [
Player(
name=p["name"],
weight=float(p["weight"]),
gamma_attack=float(p["gamma_attack"]),
delta_defense=float(p["delta_defense"]),
)
for p in data["home_players"]
]
away_players = [
Player(
name=p["name"],
weight=float(p["weight"]),
gamma_attack=float(p["gamma_attack"]),
delta_defense=float(p["delta_defense"]),
)
for p in data["away_players"]
]
market_odds = data.get("market_odds")
return state, home_players, away_players, market_odds
def template_config() -> Dict[str, object]:
"""
Generic starter config.
Replace names and coefficients with real values pulled from:
- lineups/subs: official lineup source / live tracker
- gamma/delta: player-prior pipeline
- mu/rho: fitted historical model
- market odds: sportsbook / exchange
"""
return {
"match": {
"home_team": "Home Team",
"away_team": "Away Team",
"current_home_goals": 0,
"current_away_goals": 0,
"minutes_remaining": 90,
"duration_factor": 1.0,
"fatigue_multiplier": 1.0,
"mu": log(1.25),
"rho": -0.05,
"home_adv": 0.0,
"home_penalty_win_prob": 0.50,
"max_goals_remaining": 10,
"knockout": True,
},
"home_players": [
{"name": "Home GK", "weight": 1.0, "gamma_attack": 0.00, "delta_defense": 0.12},
{"name": "Home RB", "weight": 1.0, "gamma_attack": 0.02, "delta_defense": 0.06},
{"name": "Home CB1", "weight": 1.0, "gamma_attack": 0.00, "delta_defense": 0.09},
{"name": "Home CB2", "weight": 1.0, "gamma_attack": 0.00, "delta_defense": 0.09},
{"name": "Home LB", "weight": 1.0, "gamma_attack": 0.02, "delta_defense": 0.06},
{"name": "Home DM", "weight": 1.0, "gamma_attack": 0.02, "delta_defense": 0.08},
{"name": "Home CM", "weight": 1.0, "gamma_attack": 0.05, "delta_defense": 0.04},
{"name": "Home AM", "weight": 1.0, "gamma_attack": 0.08, "delta_defense": 0.01},
{"name": "Home RW", "weight": 1.0, "gamma_attack": 0.08, "delta_defense": 0.00},
{"name": "Home ST", "weight": 1.0, "gamma_attack": 0.11, "delta_defense": 0.00},
{"name": "Home LW", "weight": 1.0, "gamma_attack": 0.08, "delta_defense": 0.00},
],
"away_players": [
{"name": "Away GK", "weight": 1.0, "gamma_attack": 0.00, "delta_defense": 0.10},
{"name": "Away RB", "weight": 1.0, "gamma_attack": 0.02, "delta_defense": 0.05},
{"name": "Away CB1", "weight": 1.0, "gamma_attack": 0.00, "delta_defense": 0.08},
{"name": "Away CB2", "weight": 1.0, "gamma_attack": 0.00, "delta_defense": 0.08},
{"name": "Away LB", "weight": 1.0, "gamma_attack": 0.02, "delta_defense": 0.05},
{"name": "Away DM", "weight": 1.0, "gamma_attack": 0.02, "delta_defense": 0.07},
{"name": "Away CM", "weight": 1.0, "gamma_attack": 0.04, "delta_defense": 0.04},
{"name": "Away AM", "weight": 1.0, "gamma_attack": 0.07, "delta_defense": 0.01},
{"name": "Away RW", "weight": 1.0, "gamma_attack": 0.07, "delta_defense": 0.00},
{"name": "Away ST", "weight": 1.0, "gamma_attack": 0.10, "delta_defense": 0.00},
{"name": "Away LW", "weight": 1.0, "gamma_attack": 0.07, "delta_defense": 0.00},
],
"market_odds": {
"home_qualify": 1.80,
"away_qualify": 2.10,
},
}
def print_result(
state: MatchState,
result: Dict[str, object],
market_comparison: Dict[str, Dict[str, Optional[float]]],
top_scores: int = 10,
) -> None:
"""
Human-readable console output.
"""
c = result["components"]
p = result["probabilities"]
o = result["fair_odds"]
def pct(x: Optional[float]) -> str:
if x is None:
return "n/a"
return f"{100.0 * x:.2f}%"
def odd(x: Optional[float]) -> str:
if x is None:
return "n/a"
return f"{x:.2f}"
print(f"{state.home_team} vs {state.away_team}")
print("=" * 60)
print(f"Current score: {state.current_home_goals}-{state.current_away_goals}")
print(f"Minutes remaining modeled: {state.minutes_remaining:.1f}")
print(f"Duration factor: {state.duration_factor:.3f}")
print(f"Fatigue/tempo multiplier: {state.fatigue_multiplier:.3f}")
print(f"mu: {state.mu:.4f} | exp(mu): {exp(state.mu):.3f}")
print(f"rho: {state.rho:.4f}")
print(f"Home adv: {state.home_adv:.4f}")
print()
print("Aggregates")
print("-" * 60)
print(f"{state.home_team} attack sum: {c['home_attack']:.3f}")
print(f"{state.home_team} defense sum: {c['home_defense']:.3f}")
print(f"{state.home_team} weight sum: {c['home_weight_sum']:.2f}")
print(f"{state.away_team} attack sum: {c['away_attack']:.3f}")
print(f"{state.away_team} defense sum: {c['away_defense']:.3f}")
print(f"{state.away_team} weight sum: {c['away_weight_sum']:.2f}")
print()
print("Expected goals")
print("-" * 60)
print(f"{state.home_team} lambda 90-equivalent: {c['lambda_home_90']:.3f}")
print(f"{state.away_team} lambda 90-equivalent: {c['lambda_away_90']:.3f}")
print(f"{state.home_team} lambda remaining: {c['lambda_home_remaining']:.3f}")
print(f"{state.away_team} lambda remaining: {c['lambda_away_remaining']:.3f}")
print(f"Total lambda remaining: {c['total_lambda_remaining']:.3f}")
print()
print("Outcome probabilities for modeled period")
print("-" * 60)
print(
f"{state.home_team} ahead after period: "
f"{pct(p['home_ahead_after_period'])} | fair odd {odd(o['home_ahead_after_period'])}"
)
print(
f"Tied after period: "
f"{pct(p['tied_after_period'])} | fair odd {odd(o['tied_after_period'])}"
)
print(
f"{state.away_team} ahead after period: "
f"{pct(p['away_ahead_after_period'])} | fair odd {odd(o['away_ahead_after_period'])}"
)
if state.knockout:
print()
print("Qualification probabilities")
print("-" * 60)
print(
f"{state.home_team} qualifies: "
f"{pct(p['home_qualifies'])} | fair odd {odd(o['home_qualifies'])}"
)
print(
f"{state.away_team} qualifies: "
f"{pct(p['away_qualifies'])} | fair odd {odd(o['away_qualifies'])}"
)
if market_comparison:
print()
print("Market comparison")
print("-" * 60)
for key, row in market_comparison.items():
print(
f"{key}: model {pct(row['model_probability'])}, "
f"market odd {row['market_decimal_odds']:.2f}, "
f"fair odd {odd(row['model_fair_odds'])}, "
f"EV {100.0 * row['ev']:.2f}%"
)
print()
print("Most likely final scores")
print("-" * 60)
for label, prob in result["final_score_probs"][:top_scores]:
print(f"{label}: {pct(prob)}")
def main() -> None:
parser = argparse.ArgumentParser(
description="Generic player-prior Dixon-Coles football model."
)
parser.add_argument(
"--config",
type=Path,
help="Path to JSON config file.",
)
parser.add_argument(
"--write-template",
type=Path,
help="Write a starter JSON template and exit.",
)
parser.add_argument(
"--weight-mode",
choices=["raise", "normalize"],
default="raise",
help="How to handle team weight sums not equal to 11.0.",
)
args = parser.parse_args()
if args.write_template:
args.write_template.write_text(
json.dumps(template_config(), indent=2),
encoding="utf-8",
)
print(f"Wrote template config to {args.write_template}")
return
if args.config:
state, home_players, away_players, market_odds = load_config(args.config)
else:
# Built-in example only. Replace with --config for real use.
cfg = template_config()
tmp_path = Path("_embedded_template_config.json")
tmp_path.write_text(json.dumps(cfg), encoding="utf-8")
state, home_players, away_players, market_odds = load_config(tmp_path)
tmp_path.unlink(missing_ok=True)
result = model_probabilities(
state,
home_players,
away_players,
weight_mode=args.weight_mode,
)
market_comparison = compare_market(result, market_odds)
print_result(state, result, market_comparison)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment