#!/usr/bin/env python3 """bharath.ai Data Lab: IPL Elo ratings and IPL 2027 title projections from Cricsheet ball-by-ball data. This is the code behind the numbers on bharath.ai/cricket-lab (Elo table, title and playoff projections, toss and chase figures, venue effects, all-time leaders) and behind data/cricket_analysis.json. It is deterministic: the simulation is seeded (random.seed(20260730)), so the same input gives the same output. To rerun: download the IPL JSON archive from https://cricsheet.org/downloads/ (ipl_json.zip), unzip it into data-lab/ipl_data/, and run python3 data-lab/ipl_elo_title_model.py . Output: data-lab/cricket_analysis.json. The published figures used the 1,243 IPL matches from 2008 to the 2026 final. """ import json, os, glob, math, random, collections, datetime DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'ipl_data') OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cricket_analysis.json') # Franchise renames -> canonical current names CANON = { 'Delhi Daredevils': 'Delhi Capitals', 'Kings XI Punjab': 'Punjab Kings', 'Royal Challengers Bangalore': 'Royal Challengers Bengaluru', 'Rising Pune Supergiant': 'Rising Pune Supergiants', } DEFUNCT = {'Deccan Chargers', 'Pune Warriors', 'Kochi Tuskers Kerala', 'Gujarat Lions', 'Rising Pune Supergiants'} def canon(t): return CANON.get(t, t) matches = [] for f in glob.glob(os.path.join(DATA, '*.json')): with open(f) as fh: j = json.load(fh) info = j.get('info', {}) if info.get('match_type') != 'T20': continue date = info['dates'][0] season = str(info.get('season', date[:4])) teams = [canon(t) for t in info.get('teams', [])] outcome = info.get('outcome', {}) winner = canon(outcome.get('winner', '')) if 'winner' in outcome else None toss = info.get('toss', {}) toss_winner = canon(toss.get('winner', '')) toss_decision = toss.get('decision', '') venue = info.get('venue', '') city = info.get('city', '') # innings totals + batter/bowler aggregates inn_totals = [] batter_runs = collections.Counter(); batter_balls = collections.Counter() bowler_wkts = collections.Counter(); bowler_runs = collections.Counter(); bowler_balls = collections.Counter() for inn in j.get('innings', []): total = 0 for over in inn.get('overs', []): for d in over.get('deliveries', []): runs = d['runs']['total']; total += runs b = d['batter']; bw = d['bowler'] batter_runs[b] += d['runs']['batter'] if 'wides' not in d.get('extras', {}): batter_balls[b] += 1 bowler_runs[bw] += d['runs']['batter'] + d['runs'].get('extras', 0) - d.get('extras', {}).get('byes', 0) - d.get('extras', {}).get('legbyes', 0) bowler_balls[bw] += 0 if ('wides' in d.get('extras', {}) or 'noballs' in d.get('extras', {})) else 1 for w in d.get('wickets', []): if w.get('kind') not in ('run out', 'retired hurt', 'retired out', 'obstructing the field'): bowler_wkts[bw] += 1 inn_totals.append({'team': canon(inn.get('team', '')), 'runs': total}) matches.append({ 'date': date, 'season': season, 'teams': teams, 'winner': winner, 'toss_winner': toss_winner, 'toss_decision': toss_decision, 'venue': venue, 'city': city, 'innings': inn_totals, 'batter_runs': dict(batter_runs), 'batter_balls': dict(batter_balls), 'bowler_wkts': dict(bowler_wkts), 'bowler_runs': dict(bowler_runs), 'bowler_balls': dict(bowler_balls), }) matches.sort(key=lambda m: m['date']) n_matches = len(matches) seasons = sorted({m['season'] for m in matches}) latest_season = seasons[-1] # ---- Elo ratings through history ---- K = 24; elo = collections.defaultdict(lambda: 1500.0) elo_history = collections.defaultdict(list) for m in matches: if not m['winner'] or len(m['teams']) != 2: continue a, b = m['teams'] ea = 1 / (1 + 10 ** ((elo[b] - elo[a]) / 400)) sa = 1.0 if m['winner'] == a else 0.0 elo[a] += K * (sa - ea); elo[b] += K * ((1 - sa) - (1 - ea)) elo_history[a].append((m['date'], round(elo[a], 1))) elo_history[b].append((m['date'], round(elo[b], 1))) active = {t: r for t, r in elo.items() if t not in DEFUNCT} elo_table = sorted(active.items(), key=lambda x: -x[1]) # ---- Team records (all-time + last-2-seasons form) ---- rec = collections.defaultdict(lambda: {'p': 0, 'w': 0}) recent = collections.defaultdict(lambda: {'p': 0, 'w': 0}) last2 = set(seasons[-2:]) for m in matches: if not m['winner']: continue for t in m['teams']: rec[t]['p'] += 1; rec[t]['w'] += 1 if m['winner'] == t else 0 if m['season'] in last2: recent[t]['p'] += 1; recent[t]['w'] += 1 if m['winner'] == t else 0 # ---- Toss impact ---- toss_won_match = sum(1 for m in matches if m['winner'] and m['toss_winner'] == m['winner']) toss_decided = sum(1 for m in matches if m['winner']) bat_first_wins = 0; chase_wins = 0 for m in matches: if not m['winner'] or len(m['innings']) < 2: continue first = m['innings'][0]['team'] if m['winner'] == first: bat_first_wins += 1 else: chase_wins += 1 # ---- Venue effects (min 20 matches) ---- venues = collections.defaultdict(lambda: {'m': 0, 'first_bat_wins': 0, 'tot_first': 0}) for m in matches: if not m['winner'] or len(m['innings']) < 2: continue v = m['venue'] venues[v]['m'] += 1 venues[v]['tot_first'] += m['innings'][0]['runs'] if m['winner'] == m['innings'][0]['team']: venues[v]['first_bat_wins'] += 1 venue_table = [ {'venue': v, 'matches': d['m'], 'avg_first_innings': round(d['tot_first'] / d['m'], 1), 'bat_first_win_pct': round(100 * d['first_bat_wins'] / d['m'], 1)} for v, d in venues.items() if d['m'] >= 20 ] venue_table.sort(key=lambda x: -x['matches']) # ---- Career leaders (across dataset) ---- runs_all = collections.Counter(); balls_all = collections.Counter() wkts_all = collections.Counter(); bruns_all = collections.Counter(); bballs_all = collections.Counter() for m in matches: for k, v in m['batter_runs'].items(): runs_all[k] += v for k, v in m['batter_balls'].items(): balls_all[k] += v for k, v in m['bowler_wkts'].items(): wkts_all[k] += v for k, v in m['bowler_runs'].items(): bruns_all[k] += v for k, v in m['bowler_balls'].items(): bballs_all[k] += v top_run = [{'player': p, 'runs': r, 'strike_rate': round(100 * r / balls_all[p], 1)} for p, r in runs_all.most_common(10)] top_wkt = [{'player': p, 'wickets': w, 'economy': round(6 * bruns_all[p] / max(bballs_all[p], 1), 2)} for p, w in wkts_all.most_common(10)] # ---- Latest-season summary ---- ls = [m for m in matches if m['season'] == latest_season] ls_rec = collections.defaultdict(lambda: {'p': 0, 'w': 0}) for m in ls: if not m['winner']: continue for t in m['teams']: ls_rec[t]['p'] += 1; ls_rec[t]['w'] += 1 if m['winner'] == t else 0 latest_standings = sorted( [{'team': t, 'played': d['p'], 'wins': d['w'], 'win_pct': round(100 * d['w'] / d['p'], 1)} for t, d in ls_rec.items()], key=lambda x: -x['win_pct']) # ---- Monte Carlo next-season title probabilities from final Elo ---- random.seed(20260730) teams_next = [t for t, _ in elo_table][:10] SIMS = 20000 titles = collections.Counter(); playoffs = collections.Counter() def pwin(ra, rb): return 1 / (1 + 10 ** ((rb - ra) / 400)) for _ in range(SIMS): pts = collections.Counter() for i, a in enumerate(teams_next): for b in teams_next[i+1:]: for _g in range(2): if random.random() < pwin(active[a], active[b]): pts[a] += 2 else: pts[b] += 2 order = sorted(teams_next, key=lambda t: (pts[t], random.random()), reverse=True) top4 = order[:4] for t in top4: playoffs[t] += 1 # playoffs: Q1 1v2, Elim 3v4, Q2 loser1 v winnerElim, Final w1 = top4[0] if random.random() < pwin(active[top4[0]], active[top4[1]]) else top4[1] l1 = top4[1] if w1 == top4[0] else top4[0] we = top4[2] if random.random() < pwin(active[top4[2]], active[top4[3]]) else top4[3] w2 = l1 if random.random() < pwin(active[l1], active[we]) else we champ = w1 if random.random() < pwin(active[w1], active[w2]) else w2 titles[champ] += 1 title_probabilities = sorted( [{'team': t, 'title_pct': round(100 * titles[t] / SIMS, 1), 'playoff_pct': round(100 * playoffs[t] / SIMS, 1)} for t in teams_next], key=lambda x: -x['title_pct']) out = { 'meta': { 'source': 'Cricsheet ball-by-ball IPL data (cricsheet.org), analyzed locally', 'matches_analyzed': n_matches, 'seasons_covered': f'{seasons[0]}-{latest_season}', 'generated': datetime.date.today().isoformat(), 'method': 'Elo ratings (K=24) computed match-by-match across all seasons; title probabilities via 20,000-run Monte Carlo simulation of a 10-team double round-robin + IPL playoff format seeded with current Elo; all other figures direct aggregation of ball-by-ball data', }, 'elo_ratings': [{'team': t, 'elo': round(r, 1)} for t, r in elo_table], 'all_time_records': sorted( [{'team': t, 'played': d['p'], 'wins': d['w'], 'win_pct': round(100 * d['w'] / d['p'], 1)} for t, d in rec.items() if t not in DEFUNCT], key=lambda x: -x['win_pct']), 'recent_form_last2_seasons': sorted( [{'team': t, 'played': d['p'], 'wins': d['w'], 'win_pct': round(100 * d['w'] / d['p'], 1)} for t, d in recent.items() if d['p'] > 0 and t not in DEFUNCT], key=lambda x: -x['win_pct']), 'toss_analysis': { 'toss_winner_wins_match_pct': round(100 * toss_won_match / toss_decided, 1), 'bat_first_win_pct': round(100 * bat_first_wins / (bat_first_wins + chase_wins), 1), 'chase_win_pct': round(100 * chase_wins / (bat_first_wins + chase_wins), 1), 'matches_with_result': toss_decided, }, 'venue_effects': venue_table[:12], 'career_leaders': {'most_runs': top_run, 'most_wickets': top_wkt}, 'latest_season': {'season': latest_season, 'standings': latest_standings}, 'next_season_title_probabilities': title_probabilities, } with open(OUT, 'w') as f: json.dump(out, f, indent=1) print(f"Analyzed {n_matches} matches, seasons {seasons[0]}-{latest_season}") print("Top 5 Elo:", [(t, round(r)) for t, r in elo_table[:5]]) print("Title probabilities, top 3:", title_probabilities[:3]) print("Toss->win:", out['toss_analysis'])