This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 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);
|
||||
Reference in New Issue
Block a user