319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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<string, EloState>();
|
|
|
|
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);
|