Files
iddaai-be/src/scripts/compute-elo-ratings.ts
T
fahricansecer 182f4aae16
Deploy Iddaai Backend / build-and-deploy (push) Successful in 33s
first (part 3: src directory)
2026-04-16 15:12:27 +03:00

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);