/** * compute-elo-ratings.ts * ====================== * Batch ELO rating computation for all finished football matches. * * Processes 109K+ matches in chronological order and computes: * - Overall ELO (general strength) * - Home ELO (home performance) * - Away ELO (away performance) * - Form ELO (recent 10-match weighted performance) * * Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/compute-elo-ratings.ts */ import { PrismaClient } from "@prisma/client"; // ───────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────── interface EloState { overallElo: number; homeElo: number; awayElo: number; formElo: number; matchesPlayed: number; recentResults: string[]; // Last 10 results: W/D/L } interface MatchRecord { id: string; homeTeamId: string | null; awayTeamId: string | null; scoreHome: number | null; scoreAway: number | null; mstUtc: bigint; } // ───────────────────────────────────────────────────────────── // ELO Algorithm // ───────────────────────────────────────────────────────────── const BASE_ELO = 1500.0; const K_FACTOR = 32; const HOME_ADVANTAGE = 50; // Home team gets +50 ELO advantage in expected score calc const FORM_DECAY = 0.9; // Recent matches weighted more heavily for form ELO const MAX_RECENT_RESULTS = 10; function getExpectedScore(ratingA: number, ratingB: number): number { return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400)); } function getAdaptiveK(matchesPlayed: number): number { // New teams get higher K for faster convergence if (matchesPlayed < 10) return K_FACTOR * 2; if (matchesPlayed < 30) return K_FACTOR * 1.5; return K_FACTOR; } function getActualScore( scoreHome: number, scoreAway: number, isHomeTeam: boolean, ): number { if (scoreHome > scoreAway) return isHomeTeam ? 1.0 : 0.0; if (scoreHome < scoreAway) return isHomeTeam ? 0.0 : 1.0; return 0.5; // Draw } function getResultChar( scoreHome: number, scoreAway: number, isHomeTeam: boolean, ): string { if (scoreHome > scoreAway) return isHomeTeam ? "W" : "L"; if (scoreHome < scoreAway) return isHomeTeam ? "L" : "W"; return "D"; } function calculateFormElo(recentResults: string[]): number { if (recentResults.length === 0) return BASE_ELO; let formScore = 0; let totalWeight = 0; for (let i = 0; i < recentResults.length; i++) { const weight = Math.pow(FORM_DECAY, i); // Most recent = highest weight const result = recentResults[i]; const score = result === "W" ? 3 : result === "D" ? 1 : 0; formScore += score * weight; totalWeight += 3 * weight; // Max possible per match } // Normalize to ELO-like scale (1200-1800 range) const normalizedForm = totalWeight > 0 ? formScore / totalWeight : 0.5; return 1200 + normalizedForm * 600; } // ───────────────────────────────────────────────────────────── // Main Computation // ───────────────────────────────────────────────────────────── async function computeEloRatings(): Promise { const prisma = new PrismaClient(); const startTime = Date.now(); try { console.log("🏟️ ELO Rating Computation — Starting..."); console.log("─".repeat(60)); // 1. Fetch all finished football matches in chronological order const matches: MatchRecord[] = await prisma.match.findMany({ where: { sport: "football", status: "FT", scoreHome: { not: null }, scoreAway: { not: null }, homeTeamId: { not: null }, awayTeamId: { not: null }, }, select: { id: true, homeTeamId: true, awayTeamId: true, scoreHome: true, scoreAway: true, mstUtc: true, }, orderBy: { mstUtc: "asc" }, }); console.log( `📊 Total matches to process: ${matches.length.toLocaleString()}`, ); // 2. Initialize ELO state map const eloMap = new Map(); function getOrCreateElo(teamId: string): EloState { let state = eloMap.get(teamId); if (!state) { state = { overallElo: BASE_ELO, homeElo: BASE_ELO, awayElo: BASE_ELO, formElo: BASE_ELO, matchesPlayed: 0, recentResults: [], }; eloMap.set(teamId, state); } return state; } // 3. Process each match chronologically let processed = 0; const logInterval = 10000; for (const match of matches) { const homeTeamId = match.homeTeamId!; const awayTeamId = match.awayTeamId!; const scoreHome = match.scoreHome!; const scoreAway = match.scoreAway!; const homeState = getOrCreateElo(homeTeamId); const awayState = getOrCreateElo(awayTeamId); // Actual scores const homeActual = getActualScore(scoreHome, scoreAway, true); const awayActual = getActualScore(scoreHome, scoreAway, false); // K-factors (adaptive) const homeK = getAdaptiveK(homeState.matchesPlayed); const awayK = getAdaptiveK(awayState.matchesPlayed); // --- Overall ELO --- const expectedHome = getExpectedScore( homeState.overallElo + HOME_ADVANTAGE, awayState.overallElo, ); const expectedAway = 1 - expectedHome; homeState.overallElo += homeK * (homeActual - expectedHome); awayState.overallElo += awayK * (awayActual - expectedAway); // --- Home/Away specific ELO --- const expectedHomeSpec = getExpectedScore( homeState.homeElo, awayState.awayElo, ); const expectedAwaySpec = 1 - expectedHomeSpec; homeState.homeElo += homeK * (homeActual - expectedHomeSpec); awayState.awayElo += awayK * (awayActual - expectedAwaySpec); // --- Recent results & Form ELO --- const homeResult = getResultChar(scoreHome, scoreAway, true); const awayResult = getResultChar(scoreHome, scoreAway, false); homeState.recentResults.unshift(homeResult); awayState.recentResults.unshift(awayResult); if (homeState.recentResults.length > MAX_RECENT_RESULTS) { homeState.recentResults.pop(); } if (awayState.recentResults.length > MAX_RECENT_RESULTS) { awayState.recentResults.pop(); } homeState.formElo = calculateFormElo(homeState.recentResults); awayState.formElo = calculateFormElo(awayState.recentResults); // --- Increment match count --- homeState.matchesPlayed++; awayState.matchesPlayed++; processed++; if (processed % logInterval === 0) { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log( ` ⏳ Processed ${processed.toLocaleString()} / ${matches.length.toLocaleString()} matches (${elapsed}s)`, ); } } console.log( `✅ ELO computation complete — ${eloMap.size.toLocaleString()} teams rated`, ); // 4. Bulk upsert to team_elo_ratings console.log("💾 Writing to team_elo_ratings..."); const BATCH_SIZE = 500; const teams = Array.from(eloMap.entries()); for (let i = 0; i < teams.length; i += BATCH_SIZE) { const batch = teams.slice(i, i + BATCH_SIZE); await prisma.$transaction( batch.map(([teamId, state]) => prisma.teamEloRating.upsert({ where: { teamId }, update: { overallElo: Math.round(state.overallElo * 10) / 10, homeElo: Math.round(state.homeElo * 10) / 10, awayElo: Math.round(state.awayElo * 10) / 10, formElo: Math.round(state.formElo * 10) / 10, matchesPlayed: state.matchesPlayed, recentForm: state.recentResults.join(""), }, create: { teamId, overallElo: Math.round(state.overallElo * 10) / 10, homeElo: Math.round(state.homeElo * 10) / 10, awayElo: Math.round(state.awayElo * 10) / 10, formElo: Math.round(state.formElo * 10) / 10, matchesPlayed: state.matchesPlayed, recentForm: state.recentResults.join(""), }, }), ), ); if ((i + BATCH_SIZE) % 2000 === 0 || i + BATCH_SIZE >= teams.length) { console.log( ` 💾 Saved ${Math.min(i + BATCH_SIZE, teams.length).toLocaleString()} / ${teams.length.toLocaleString()} teams`, ); } } // 5. Print summary stats const elapsedTotal = ((Date.now() - startTime) / 1000).toFixed(1); const eloValues = Array.from(eloMap.values()); const overallElos = eloValues .map((s) => s.overallElo) .sort((a, b) => b - a); console.log("─".repeat(60)); console.log("📊 ELO Rating Summary:"); console.log(` Teams rated: ${eloMap.size.toLocaleString()}`); console.log(` Matches used: ${processed.toLocaleString()}`); console.log(` Highest ELO: ${overallElos[0]?.toFixed(1) ?? "N/A"}`); console.log( ` Lowest ELO: ${overallElos[overallElos.length - 1]?.toFixed(1) ?? "N/A"}`, ); console.log( ` Median ELO: ${overallElos[Math.floor(overallElos.length / 2)]?.toFixed(1) ?? "N/A"}`, ); console.log(` Duration: ${elapsedTotal}s`); console.log("─".repeat(60)); // Top 20 teams const topTeams = await prisma.teamEloRating.findMany({ orderBy: { overallElo: "desc" }, take: 20, include: { team: { select: { name: true } } }, }); console.log("\n🏆 Top 20 Teams by ELO:"); topTeams.forEach((t, i) => { const form = t.recentForm.split("").join("-"); console.log( ` ${String(i + 1).padStart(2)}. ${t.team.name.padEnd(25)} Overall: ${t.overallElo.toFixed(1).padStart(7)} Home: ${t.homeElo.toFixed(1).padStart(7)} Away: ${t.awayElo.toFixed(1).padStart(7)} Form: ${form}`, ); }); console.log("\n✅ Done!"); } catch (error) { console.error("❌ ELO computation failed:", error); process.exit(1); } finally { await prisma.$disconnect(); } } // Run computeEloRatings().catch(console.error);