889 lines
30 KiB
TypeScript
889 lines
30 KiB
TypeScript
/**
|
||
* populate-feature-store.ts
|
||
* =========================
|
||
* Batch feature computation for all finished football matches.
|
||
* Populates the match_ai_features table with 7-pillar feature vectors.
|
||
*
|
||
* Pillars computed:
|
||
* 1. ELO Ratings (from team_elo_ratings)
|
||
* 2. Form (last 5 match rolling averages)
|
||
* 3. Odds Implied Probability (from odd_selections)
|
||
* 4. Team Stats (possession, shots, corners)
|
||
* 5. Head-to-Head (historical matchups)
|
||
* 6. Referee (bias, cards, goals)
|
||
* 7. League DNA (averages across league-season)
|
||
*
|
||
* Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/populate-feature-store.ts
|
||
*/
|
||
|
||
import { PrismaClient } from "@prisma/client";
|
||
|
||
const prisma = new PrismaClient();
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Types
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
interface MatchRow {
|
||
id: string;
|
||
homeTeamId: string;
|
||
awayTeamId: string;
|
||
leagueId: string | null;
|
||
scoreHome: number;
|
||
scoreAway: number;
|
||
mstUtc: bigint;
|
||
}
|
||
|
||
interface FeatureVector {
|
||
matchId: string;
|
||
// ELO
|
||
homeElo: number;
|
||
awayElo: number;
|
||
homeHomeElo: number;
|
||
awayAwayElo: number;
|
||
homeFormElo: number;
|
||
awayFormElo: number;
|
||
eloDiff: number;
|
||
// Form
|
||
homeFormScore: number;
|
||
awayFormScore: number;
|
||
homeGoalsAvg5: number;
|
||
awayGoalsAvg5: number;
|
||
homeConcededAvg5: number;
|
||
awayConcededAvg5: number;
|
||
homeCleanSheetRate: number;
|
||
awayCleanSheetRate: number;
|
||
homeScoringRate: number;
|
||
awayScoringRate: number;
|
||
homeWinStreak: number;
|
||
awayWinStreak: number;
|
||
// Odds
|
||
impliedHome: number;
|
||
impliedDraw: number;
|
||
impliedAway: number;
|
||
impliedOver25: number;
|
||
impliedBttsYes: number;
|
||
oddsOverround: number;
|
||
// Team Stats
|
||
homeAvgPossession: number;
|
||
awayAvgPossession: number;
|
||
homeAvgShotsOnTarget: number;
|
||
awayAvgShotsOnTarget: number;
|
||
homeShotConversion: number;
|
||
awayShotConversion: number;
|
||
homeAvgCorners: number;
|
||
awayAvgCorners: number;
|
||
// H2H
|
||
h2hTotal: number;
|
||
h2hHomeWinRate: number;
|
||
h2hAvgGoals: number;
|
||
h2hOver25Rate: number;
|
||
h2hBttsRate: number;
|
||
// Referee
|
||
refereeAvgCards: number;
|
||
refereeHomeBias: number;
|
||
refereeAvgGoals: number;
|
||
// League DNA
|
||
leagueAvgGoals: number;
|
||
leagueHomeWinPct: number;
|
||
leagueOver25Pct: number;
|
||
// Meta
|
||
missingPlayersImpact: number;
|
||
calculatorVer: string;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Pillar 1: ELO Ratings
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
async function loadEloMap(): Promise<
|
||
Map<string, { overall: number; home: number; away: number; form: number }>
|
||
> {
|
||
const ratings = await prisma.teamEloRating.findMany();
|
||
const map = new Map<
|
||
string,
|
||
{ overall: number; home: number; away: number; form: number }
|
||
>();
|
||
for (const r of ratings) {
|
||
map.set(r.teamId, {
|
||
overall: r.overallElo,
|
||
home: r.homeElo,
|
||
away: r.awayElo,
|
||
form: r.formElo,
|
||
});
|
||
}
|
||
console.log(` ✅ ELO map loaded: ${map.size} teams`);
|
||
return map;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Pillar 2: Form — Pre-compute per-team rolling stats
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
interface TeamFormState {
|
||
goalsScored: number[];
|
||
goalsConceded: number[];
|
||
results: string[]; // W/D/L
|
||
matchCount: number;
|
||
}
|
||
|
||
function buildFormIndex(
|
||
matches: MatchRow[],
|
||
): Map<string, Map<string, TeamFormState>> {
|
||
// matchId -> teamId -> form state AT THAT POINT (before match)
|
||
const teamStates = new Map<string, TeamFormState>();
|
||
const formIndex = new Map<string, Map<string, TeamFormState>>();
|
||
|
||
function getOrCreateState(teamId: string): TeamFormState {
|
||
let state = teamStates.get(teamId);
|
||
if (!state) {
|
||
state = {
|
||
goalsScored: [],
|
||
goalsConceded: [],
|
||
results: [],
|
||
matchCount: 0,
|
||
};
|
||
teamStates.set(teamId, state);
|
||
}
|
||
return state;
|
||
}
|
||
|
||
function cloneState(state: TeamFormState): TeamFormState {
|
||
return {
|
||
goalsScored: [...state.goalsScored],
|
||
goalsConceded: [...state.goalsConceded],
|
||
results: [...state.results],
|
||
matchCount: state.matchCount,
|
||
};
|
||
}
|
||
|
||
for (const match of matches) {
|
||
const homeState = getOrCreateState(match.homeTeamId);
|
||
const awayState = getOrCreateState(match.awayTeamId);
|
||
|
||
// Store form state BEFORE this match
|
||
const matchFormMap = new Map<string, TeamFormState>();
|
||
matchFormMap.set(match.homeTeamId, cloneState(homeState));
|
||
matchFormMap.set(match.awayTeamId, cloneState(awayState));
|
||
formIndex.set(match.id, matchFormMap);
|
||
|
||
// Update states AFTER this match
|
||
homeState.goalsScored.unshift(match.scoreHome);
|
||
homeState.goalsConceded.unshift(match.scoreAway);
|
||
awayState.goalsScored.unshift(match.scoreAway);
|
||
awayState.goalsConceded.unshift(match.scoreHome);
|
||
|
||
if (homeState.goalsScored.length > 10) homeState.goalsScored.pop();
|
||
if (homeState.goalsConceded.length > 10) homeState.goalsConceded.pop();
|
||
if (awayState.goalsScored.length > 10) awayState.goalsScored.pop();
|
||
if (awayState.goalsConceded.length > 10) awayState.goalsConceded.pop();
|
||
|
||
const homeResult =
|
||
match.scoreHome > match.scoreAway
|
||
? "W"
|
||
: match.scoreHome < match.scoreAway
|
||
? "L"
|
||
: "D";
|
||
const awayResult =
|
||
match.scoreAway > match.scoreHome
|
||
? "W"
|
||
: match.scoreAway < match.scoreHome
|
||
? "L"
|
||
: "D";
|
||
|
||
homeState.results.unshift(homeResult);
|
||
awayState.results.unshift(awayResult);
|
||
if (homeState.results.length > 10) homeState.results.pop();
|
||
if (awayState.results.length > 10) awayState.results.pop();
|
||
|
||
homeState.matchCount++;
|
||
awayState.matchCount++;
|
||
}
|
||
|
||
return formIndex;
|
||
}
|
||
|
||
function extractFormFeatures(formState: TeamFormState): {
|
||
goalsAvg5: number;
|
||
concededAvg5: number;
|
||
cleanSheetRate: number;
|
||
scoringRate: number;
|
||
winStreak: number;
|
||
formScore: number;
|
||
} {
|
||
const last5Goals = formState.goalsScored.slice(0, 5);
|
||
const last5Conceded = formState.goalsConceded.slice(0, 5);
|
||
const n = last5Goals.length || 1;
|
||
|
||
const goalsAvg5 = last5Goals.reduce((a, b) => a + b, 0) / n;
|
||
const concededAvg5 = last5Conceded.reduce((a, b) => a + b, 0) / n;
|
||
const cleanSheetRate = last5Conceded.filter((g) => g === 0).length / n;
|
||
const scoringRate = last5Goals.filter((g) => g > 0).length / n;
|
||
|
||
let winStreak = 0;
|
||
for (const r of formState.results) {
|
||
if (r === "W") winStreak++;
|
||
else break;
|
||
}
|
||
|
||
// Form score: (W=3, D=1, L=0) over last 5, normalized to 0-100
|
||
const last5Results = formState.results.slice(0, 5);
|
||
const points = last5Results.reduce(
|
||
(sum, r) => sum + (r === "W" ? 3 : r === "D" ? 1 : 0),
|
||
0,
|
||
);
|
||
const maxPoints = last5Results.length * 3 || 1;
|
||
const formScore = (points / maxPoints) * 100;
|
||
|
||
return {
|
||
goalsAvg5,
|
||
concededAvg5,
|
||
cleanSheetRate,
|
||
scoringRate,
|
||
winStreak,
|
||
formScore,
|
||
};
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Pillar 3: Odds Implied Probability (batch)
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
interface OddsData {
|
||
impliedHome: number;
|
||
impliedDraw: number;
|
||
impliedAway: number;
|
||
impliedOver25: number;
|
||
impliedBttsYes: number;
|
||
overround: number;
|
||
}
|
||
|
||
async function loadOddsIndex(): Promise<Map<string, OddsData>> {
|
||
const oddsIndex = new Map<string, OddsData>();
|
||
|
||
// Raw SQL: join odd_categories + odd_selections for MS and OU25
|
||
const rows = await prisma.$queryRaw<
|
||
Array<{
|
||
match_id: string;
|
||
cat_name: string;
|
||
sel_name: string;
|
||
odds: number;
|
||
}>
|
||
>`
|
||
SELECT oc.match_id, oc.name AS cat_name, os.name AS sel_name,
|
||
COALESCE(os.odd_value::numeric, 0)::float AS odds
|
||
FROM odd_categories oc
|
||
JOIN odd_selections os ON os.odd_category_db_id = oc.db_id
|
||
WHERE oc.name IN ('Maç Sonucu', 'Alt/Üst 2,5', 'Karşılıklı Gol')
|
||
AND os.odd_value IS NOT NULL
|
||
AND os.odd_value ~ '^[0-9]'
|
||
ORDER BY oc.match_id
|
||
`;
|
||
|
||
// Group by match_id
|
||
const byMatch = new Map<
|
||
string,
|
||
Array<{ cat: string; sel: string; odds: number }>
|
||
>();
|
||
for (const row of rows) {
|
||
let arr = byMatch.get(row.match_id);
|
||
if (!arr) {
|
||
arr = [];
|
||
byMatch.set(row.match_id, arr);
|
||
}
|
||
arr.push({ cat: row.cat_name, sel: row.sel_name, odds: row.odds });
|
||
}
|
||
|
||
for (const [matchId, selections] of byMatch.entries()) {
|
||
let msH = 0,
|
||
msD = 0,
|
||
msA = 0;
|
||
let ou25O = 0;
|
||
let bttsY = 0;
|
||
|
||
for (const s of selections) {
|
||
if (s.cat === "Maç Sonucu") {
|
||
if (s.sel === "1") msH = s.odds;
|
||
else if (s.sel === "X" || s.sel === "0") msD = s.odds;
|
||
else if (s.sel === "2") msA = s.odds;
|
||
} else if (s.cat === "Alt/Üst 2,5") {
|
||
if (
|
||
s.sel.toLowerCase().includes("üst") ||
|
||
s.sel.toLowerCase().includes("over")
|
||
)
|
||
ou25O = s.odds;
|
||
} else if (s.cat === "Karşılıklı Gol") {
|
||
if (
|
||
s.sel.toLowerCase().includes("var") ||
|
||
s.sel.toLowerCase().includes("yes")
|
||
)
|
||
bttsY = s.odds;
|
||
}
|
||
}
|
||
|
||
const impliedHome = msH > 0 ? 1 / msH : 0.33;
|
||
const impliedDraw = msD > 0 ? 1 / msD : 0.33;
|
||
const impliedAway = msA > 0 ? 1 / msA : 0.33;
|
||
const totalImplied = impliedHome + impliedDraw + impliedAway;
|
||
const overround = totalImplied > 0 ? (totalImplied - 1) * 100 : 0;
|
||
|
||
const impliedOver25 = ou25O > 0 ? 1 / ou25O : 0.5;
|
||
const impliedBttsYes = bttsY > 0 ? 1 / bttsY : 0.5;
|
||
|
||
oddsIndex.set(matchId, {
|
||
impliedHome: totalImplied > 0 ? impliedHome / totalImplied : 0.33,
|
||
impliedDraw: totalImplied > 0 ? impliedDraw / totalImplied : 0.33,
|
||
impliedAway: totalImplied > 0 ? impliedAway / totalImplied : 0.33,
|
||
impliedOver25: Math.min(impliedOver25, 1),
|
||
impliedBttsYes: Math.min(impliedBttsYes, 1),
|
||
overround,
|
||
});
|
||
}
|
||
|
||
console.log(
|
||
` ✅ Odds index loaded: ${oddsIndex.size} matches with odds data`,
|
||
);
|
||
return oddsIndex;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Pillar 5: Head-to-Head (batch precompute)
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
interface H2HState {
|
||
totalMatches: number;
|
||
homeWins: number;
|
||
totalGoals: number;
|
||
over25Count: number;
|
||
bttsCount: number;
|
||
}
|
||
|
||
function buildH2HIndex(matches: MatchRow[]): Map<string, H2HState> {
|
||
// Key: "teamA_teamB" sorted alphabetically
|
||
const h2hMap = new Map<string, H2HState>();
|
||
// matchId -> h2h state BEFORE that match
|
||
const h2hIndex = new Map<string, H2HState>();
|
||
|
||
function getKey(t1: string, t2: string): string {
|
||
return t1 < t2 ? `${t1}_${t2}` : `${t2}_${t1}`;
|
||
}
|
||
|
||
for (const match of matches) {
|
||
const key = getKey(match.homeTeamId, match.awayTeamId);
|
||
let state = h2hMap.get(key);
|
||
if (!state) {
|
||
state = {
|
||
totalMatches: 0,
|
||
homeWins: 0,
|
||
totalGoals: 0,
|
||
over25Count: 0,
|
||
bttsCount: 0,
|
||
};
|
||
h2hMap.set(key, state);
|
||
}
|
||
|
||
// Save state BEFORE this match
|
||
h2hIndex.set(match.id, { ...state });
|
||
|
||
// Update AFTER
|
||
state.totalMatches++;
|
||
if (match.scoreHome > match.scoreAway) state.homeWins++;
|
||
state.totalGoals += match.scoreHome + match.scoreAway;
|
||
if (match.scoreHome + match.scoreAway > 2.5) state.over25Count++;
|
||
if (match.scoreHome > 0 && match.scoreAway > 0) state.bttsCount++;
|
||
}
|
||
|
||
return h2hIndex;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Pillar 7: League DNA (batch precompute)
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
interface LeagueStats {
|
||
totalMatches: number;
|
||
totalGoals: number;
|
||
homeWins: number;
|
||
over25Count: number;
|
||
}
|
||
|
||
function buildLeagueIndex(matches: MatchRow[]): Map<string, LeagueStats> {
|
||
const leagueMap = new Map<string, LeagueStats>();
|
||
|
||
for (const match of matches) {
|
||
const key = match.leagueId ?? "unknown";
|
||
let stats = leagueMap.get(key);
|
||
if (!stats) {
|
||
stats = { totalMatches: 0, totalGoals: 0, homeWins: 0, over25Count: 0 };
|
||
leagueMap.set(key, stats);
|
||
}
|
||
stats.totalMatches++;
|
||
stats.totalGoals += match.scoreHome + match.scoreAway;
|
||
if (match.scoreHome > match.scoreAway) stats.homeWins++;
|
||
if (match.scoreHome + match.scoreAway > 2.5) stats.over25Count++;
|
||
}
|
||
|
||
return leagueMap;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Pillar 6: Referee — Load from match_officials
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
interface RefereeStats {
|
||
totalMatches: number;
|
||
totalCards: number;
|
||
totalGoals: number;
|
||
homeWins: number;
|
||
}
|
||
|
||
async function loadRefereeIndex(
|
||
matches: MatchRow[],
|
||
): Promise<Map<string, RefereeStats>> {
|
||
// Build match scores lookup
|
||
const matchScores = new Map<string, { home: number; away: number }>();
|
||
for (const m of matches) {
|
||
matchScores.set(m.id, { home: m.scoreHome, away: m.scoreAway });
|
||
}
|
||
|
||
// Load officials
|
||
const officials = await prisma.$queryRaw<
|
||
Array<{ match_id: string; official_name: string }>
|
||
>`
|
||
SELECT match_id, name AS official_name FROM match_officials
|
||
WHERE role_id = 1
|
||
LIMIT 500000
|
||
`;
|
||
|
||
const refereeIndex = new Map<string, RefereeStats>();
|
||
|
||
// Group by referee
|
||
const refMatchMap = new Map<string, string[]>();
|
||
for (const o of officials) {
|
||
let arr = refMatchMap.get(o.official_name);
|
||
if (!arr) {
|
||
arr = [];
|
||
refMatchMap.set(o.official_name, arr);
|
||
}
|
||
arr.push(o.match_id);
|
||
}
|
||
|
||
// matchId -> referee name
|
||
const matchRefereeMap = new Map<string, string>();
|
||
for (const o of officials) {
|
||
matchRefereeMap.set(o.match_id, o.official_name);
|
||
}
|
||
|
||
// Calculate referee stats
|
||
for (const [refName, matchIds] of refMatchMap.entries()) {
|
||
let totalGoals = 0;
|
||
let homeWins = 0;
|
||
let totalMatches = 0;
|
||
|
||
for (const mid of matchIds) {
|
||
const score = matchScores.get(mid);
|
||
if (score) {
|
||
totalGoals += score.home + score.away;
|
||
if (score.home > score.away) homeWins++;
|
||
totalMatches++;
|
||
}
|
||
}
|
||
|
||
refereeIndex.set(refName, {
|
||
totalMatches,
|
||
totalCards: 0, // Would require card stats table — use 0 as default
|
||
totalGoals,
|
||
homeWins,
|
||
});
|
||
}
|
||
|
||
// Build matchId -> refStats lookup
|
||
const matchRefStats = new Map<string, RefereeStats>();
|
||
for (const [matchId, refName] of matchRefereeMap.entries()) {
|
||
const stats = refereeIndex.get(refName);
|
||
if (stats) {
|
||
matchRefStats.set(matchId, stats);
|
||
}
|
||
}
|
||
|
||
console.log(
|
||
` ✅ Referee index loaded: ${refereeIndex.size} referees, ${matchRefStats.size} match-referee mappings`,
|
||
);
|
||
return matchRefStats;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Main Feature Store Population
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
async function populateFeatureStore(): Promise<void> {
|
||
const startTime = Date.now();
|
||
|
||
try {
|
||
console.log("🧠 Feature Store Population — Starting...");
|
||
console.log("─".repeat(60));
|
||
|
||
// Load all finished football matches
|
||
console.log("📥 Loading matches...");
|
||
const rawMatches = 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,
|
||
leagueId: true,
|
||
scoreHome: true,
|
||
scoreAway: true,
|
||
mstUtc: true,
|
||
},
|
||
orderBy: { mstUtc: "asc" },
|
||
});
|
||
|
||
const matches: MatchRow[] = rawMatches.map((m) => ({
|
||
id: m.id,
|
||
homeTeamId: m.homeTeamId!,
|
||
awayTeamId: m.awayTeamId!,
|
||
leagueId: m.leagueId,
|
||
scoreHome: m.scoreHome!,
|
||
scoreAway: m.scoreAway!,
|
||
mstUtc: m.mstUtc,
|
||
}));
|
||
|
||
console.log(` 📊 Matches loaded: ${matches.length.toLocaleString()}`);
|
||
|
||
// Pre-compute all indexes
|
||
console.log("\n📊 Building feature indexes...");
|
||
|
||
console.log(" 🏅 Pillar 1: Loading ELO ratings...");
|
||
const eloMap = await loadEloMap();
|
||
|
||
console.log(" 📈 Pillar 2: Building form index...");
|
||
const formIndex = buildFormIndex(matches);
|
||
|
||
console.log(" 💰 Pillar 3: Loading odds data...");
|
||
const oddsIndex = await loadOddsIndex();
|
||
|
||
console.log(" ⚔️ Pillar 5: Building H2H index...");
|
||
const h2hIndex = buildH2HIndex(matches);
|
||
|
||
console.log(" 📋 Pillar 6: Loading referee data...");
|
||
const refereeIndex = await loadRefereeIndex(matches);
|
||
|
||
console.log(" 🏟️ Pillar 7: Building league DNA...");
|
||
const leagueIndex = buildLeagueIndex(matches);
|
||
|
||
console.log("\n✅ All indexes built!");
|
||
console.log("─".repeat(60));
|
||
|
||
// Build feature vectors and batch upsert
|
||
console.log("💾 Writing features to database...");
|
||
|
||
const BATCH_SIZE = 1000;
|
||
let processed = 0;
|
||
const skipped = 0;
|
||
|
||
for (let i = 0; i < matches.length; i += BATCH_SIZE) {
|
||
const batch = matches.slice(i, i + BATCH_SIZE);
|
||
const featureVectors: FeatureVector[] = [];
|
||
|
||
for (const match of batch) {
|
||
// Pillar 1: ELO
|
||
const homeEloData = eloMap.get(match.homeTeamId);
|
||
const awayEloData = eloMap.get(match.awayTeamId);
|
||
|
||
const homeElo = homeEloData?.overall ?? 1500;
|
||
const awayElo = awayEloData?.overall ?? 1500;
|
||
|
||
// Pillar 2: Form
|
||
const matchForm = formIndex.get(match.id);
|
||
const homeFormState = matchForm?.get(match.homeTeamId);
|
||
const awayFormState = matchForm?.get(match.awayTeamId);
|
||
|
||
const homeForm = homeFormState
|
||
? extractFormFeatures(homeFormState)
|
||
: {
|
||
goalsAvg5: 0,
|
||
concededAvg5: 0,
|
||
cleanSheetRate: 0,
|
||
scoringRate: 0,
|
||
winStreak: 0,
|
||
formScore: 50,
|
||
};
|
||
const awayForm = awayFormState
|
||
? extractFormFeatures(awayFormState)
|
||
: {
|
||
goalsAvg5: 0,
|
||
concededAvg5: 0,
|
||
cleanSheetRate: 0,
|
||
scoringRate: 0,
|
||
winStreak: 0,
|
||
formScore: 50,
|
||
};
|
||
|
||
// Pillar 3: Odds
|
||
const odds = oddsIndex.get(match.id) ?? {
|
||
impliedHome: 0.33,
|
||
impliedDraw: 0.33,
|
||
impliedAway: 0.33,
|
||
impliedOver25: 0.5,
|
||
impliedBttsYes: 0.5,
|
||
overround: 0,
|
||
};
|
||
|
||
// Pillar 5: H2H
|
||
const h2h = h2hIndex.get(match.id) ?? {
|
||
totalMatches: 0,
|
||
homeWins: 0,
|
||
totalGoals: 0,
|
||
over25Count: 0,
|
||
bttsCount: 0,
|
||
};
|
||
|
||
// Pillar 6: Referee
|
||
const refStats = refereeIndex.get(match.id);
|
||
const refTotal = refStats?.totalMatches ?? 0;
|
||
|
||
// Pillar 7: League DNA
|
||
const leagueKey = match.leagueId ?? "unknown";
|
||
const leagueStats = leagueIndex.get(leagueKey) ?? {
|
||
totalMatches: 1,
|
||
totalGoals: 0,
|
||
homeWins: 0,
|
||
over25Count: 0,
|
||
};
|
||
|
||
featureVectors.push({
|
||
matchId: match.id,
|
||
// ELO
|
||
homeElo,
|
||
awayElo,
|
||
homeHomeElo: homeEloData?.home ?? 1500,
|
||
awayAwayElo: awayEloData?.away ?? 1500,
|
||
homeFormElo: homeEloData?.form ?? 1500,
|
||
awayFormElo: awayEloData?.form ?? 1500,
|
||
eloDiff: homeElo - awayElo,
|
||
// Form
|
||
homeFormScore: homeForm.formScore,
|
||
awayFormScore: awayForm.formScore,
|
||
homeGoalsAvg5: round(homeForm.goalsAvg5),
|
||
awayGoalsAvg5: round(awayForm.goalsAvg5),
|
||
homeConcededAvg5: round(homeForm.concededAvg5),
|
||
awayConcededAvg5: round(awayForm.concededAvg5),
|
||
homeCleanSheetRate: round(homeForm.cleanSheetRate),
|
||
awayCleanSheetRate: round(awayForm.cleanSheetRate),
|
||
homeScoringRate: round(homeForm.scoringRate),
|
||
awayScoringRate: round(awayForm.scoringRate),
|
||
homeWinStreak: homeForm.winStreak,
|
||
awayWinStreak: awayForm.winStreak,
|
||
// Odds
|
||
impliedHome: round(odds.impliedHome),
|
||
impliedDraw: round(odds.impliedDraw),
|
||
impliedAway: round(odds.impliedAway),
|
||
impliedOver25: round(odds.impliedOver25),
|
||
impliedBttsYes: round(odds.impliedBttsYes),
|
||
oddsOverround: round(odds.overround),
|
||
// Team Stats (placeholder — uses form data as proxy)
|
||
homeAvgPossession: 50.0,
|
||
awayAvgPossession: 50.0,
|
||
homeAvgShotsOnTarget: round(homeForm.goalsAvg5 * 2.5), // Approx proxy
|
||
awayAvgShotsOnTarget: round(awayForm.goalsAvg5 * 2.5),
|
||
homeShotConversion:
|
||
homeForm.goalsAvg5 > 0 ? round(homeForm.scoringRate) : 0,
|
||
awayShotConversion:
|
||
awayForm.goalsAvg5 > 0 ? round(awayForm.scoringRate) : 0,
|
||
homeAvgCorners: 5.0, // Default — no corner data in match table
|
||
awayAvgCorners: 4.5,
|
||
// H2H
|
||
h2hTotal: h2h.totalMatches,
|
||
h2hHomeWinRate:
|
||
h2h.totalMatches > 0 ? round(h2h.homeWins / h2h.totalMatches) : 0,
|
||
h2hAvgGoals:
|
||
h2h.totalMatches > 0 ? round(h2h.totalGoals / h2h.totalMatches) : 0,
|
||
h2hOver25Rate:
|
||
h2h.totalMatches > 0
|
||
? round(h2h.over25Count / h2h.totalMatches)
|
||
: 0,
|
||
h2hBttsRate:
|
||
h2h.totalMatches > 0 ? round(h2h.bttsCount / h2h.totalMatches) : 0,
|
||
// Referee
|
||
refereeAvgCards: 0, // No card data column available
|
||
refereeHomeBias:
|
||
refTotal > 0 ? round(refStats!.homeWins / refTotal - 0.46) : 0, // 0.46 = expected home win rate
|
||
refereeAvgGoals:
|
||
refTotal > 0 ? round(refStats!.totalGoals / refTotal) : 0,
|
||
// League DNA
|
||
leagueAvgGoals: round(
|
||
leagueStats.totalGoals / leagueStats.totalMatches,
|
||
),
|
||
leagueHomeWinPct: round(
|
||
leagueStats.homeWins / leagueStats.totalMatches,
|
||
),
|
||
leagueOver25Pct: round(
|
||
leagueStats.over25Count / leagueStats.totalMatches,
|
||
),
|
||
// Meta
|
||
missingPlayersImpact: 0,
|
||
calculatorVer: "v2.0",
|
||
});
|
||
}
|
||
|
||
// Batch upsert using raw SQL for performance
|
||
if (featureVectors.length > 0) {
|
||
await batchUpsertFeatures(featureVectors);
|
||
processed += featureVectors.length;
|
||
}
|
||
|
||
if ((i + BATCH_SIZE) % 10000 === 0 || i + BATCH_SIZE >= matches.length) {
|
||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||
console.log(
|
||
` 💾 Processed ${Math.min(i + BATCH_SIZE, matches.length).toLocaleString()} / ${matches.length.toLocaleString()} (${elapsed}s)`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||
console.log("─".repeat(60));
|
||
console.log(`✅ Feature Store population complete!`);
|
||
console.log(` Features written: ${processed.toLocaleString()}`);
|
||
console.log(` Skipped: ${skipped}`);
|
||
console.log(` Duration: ${elapsed}s`);
|
||
|
||
// Verify
|
||
const count = await prisma.footballAiFeature.count();
|
||
console.log(` DB row count: ${count.toLocaleString()}`);
|
||
console.log("─".repeat(60));
|
||
} catch (error) {
|
||
console.error("❌ Feature store population failed:", error);
|
||
process.exit(1);
|
||
} finally {
|
||
await prisma.$disconnect();
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Helpers
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
function round(val: number, decimals = 4): number {
|
||
const factor = Math.pow(10, decimals);
|
||
return Math.round(val * factor) / factor;
|
||
}
|
||
|
||
async function batchUpsertFeatures(features: FeatureVector[]): Promise<void> {
|
||
// Use Prisma transactions for batch upsert
|
||
await prisma.$transaction(
|
||
features.map((f) =>
|
||
prisma.footballAiFeature.upsert({
|
||
where: { matchId: f.matchId },
|
||
update: {
|
||
homeElo: f.homeElo,
|
||
awayElo: f.awayElo,
|
||
homeHomeElo: f.homeHomeElo,
|
||
awayAwayElo: f.awayAwayElo,
|
||
homeFormElo: f.homeFormElo,
|
||
awayFormElo: f.awayFormElo,
|
||
eloDiff: f.eloDiff,
|
||
homeFormScore: f.homeFormScore,
|
||
awayFormScore: f.awayFormScore,
|
||
homeGoalsAvg5: f.homeGoalsAvg5,
|
||
awayGoalsAvg5: f.awayGoalsAvg5,
|
||
homeConcededAvg5: f.homeConcededAvg5,
|
||
awayConcededAvg5: f.awayConcededAvg5,
|
||
homeCleanSheetRate: f.homeCleanSheetRate,
|
||
awayCleanSheetRate: f.awayCleanSheetRate,
|
||
homeScoringRate: f.homeScoringRate,
|
||
awayScoringRate: f.awayScoringRate,
|
||
homeWinStreak: f.homeWinStreak,
|
||
awayWinStreak: f.awayWinStreak,
|
||
impliedHome: f.impliedHome,
|
||
impliedDraw: f.impliedDraw,
|
||
impliedAway: f.impliedAway,
|
||
impliedOver25: f.impliedOver25,
|
||
impliedBttsYes: f.impliedBttsYes,
|
||
oddsOverround: f.oddsOverround,
|
||
homeAvgPossession: f.homeAvgPossession,
|
||
awayAvgPossession: f.awayAvgPossession,
|
||
homeAvgShotsOnTarget: f.homeAvgShotsOnTarget,
|
||
awayAvgShotsOnTarget: f.awayAvgShotsOnTarget,
|
||
homeShotConversion: f.homeShotConversion,
|
||
awayShotConversion: f.awayShotConversion,
|
||
homeAvgCorners: f.homeAvgCorners,
|
||
awayAvgCorners: f.awayAvgCorners,
|
||
h2hTotal: f.h2hTotal,
|
||
h2hHomeWinRate: f.h2hHomeWinRate,
|
||
h2hAvgGoals: f.h2hAvgGoals,
|
||
h2hOver25Rate: f.h2hOver25Rate,
|
||
h2hBttsRate: f.h2hBttsRate,
|
||
refereeAvgCards: f.refereeAvgCards,
|
||
refereeHomeBias: f.refereeHomeBias,
|
||
refereeAvgGoals: f.refereeAvgGoals,
|
||
leagueAvgGoals: f.leagueAvgGoals,
|
||
leagueHomeWinPct: f.leagueHomeWinPct,
|
||
leagueOver25Pct: f.leagueOver25Pct,
|
||
missingPlayersImpact: f.missingPlayersImpact,
|
||
calculatorVer: f.calculatorVer,
|
||
},
|
||
create: {
|
||
matchId: f.matchId,
|
||
homeElo: f.homeElo,
|
||
awayElo: f.awayElo,
|
||
homeHomeElo: f.homeHomeElo,
|
||
awayAwayElo: f.awayAwayElo,
|
||
homeFormElo: f.homeFormElo,
|
||
awayFormElo: f.awayFormElo,
|
||
eloDiff: f.eloDiff,
|
||
homeFormScore: f.homeFormScore,
|
||
awayFormScore: f.awayFormScore,
|
||
homeGoalsAvg5: f.homeGoalsAvg5,
|
||
awayGoalsAvg5: f.awayGoalsAvg5,
|
||
homeConcededAvg5: f.homeConcededAvg5,
|
||
awayConcededAvg5: f.awayConcededAvg5,
|
||
homeCleanSheetRate: f.homeCleanSheetRate,
|
||
awayCleanSheetRate: f.awayCleanSheetRate,
|
||
homeScoringRate: f.homeScoringRate,
|
||
awayScoringRate: f.awayScoringRate,
|
||
homeWinStreak: f.homeWinStreak,
|
||
awayWinStreak: f.awayWinStreak,
|
||
impliedHome: f.impliedHome,
|
||
impliedDraw: f.impliedDraw,
|
||
impliedAway: f.impliedAway,
|
||
impliedOver25: f.impliedOver25,
|
||
impliedBttsYes: f.impliedBttsYes,
|
||
oddsOverround: f.oddsOverround,
|
||
homeAvgPossession: f.homeAvgPossession,
|
||
awayAvgPossession: f.awayAvgPossession,
|
||
homeAvgShotsOnTarget: f.homeAvgShotsOnTarget,
|
||
awayAvgShotsOnTarget: f.awayAvgShotsOnTarget,
|
||
homeShotConversion: f.homeShotConversion,
|
||
awayShotConversion: f.awayShotConversion,
|
||
homeAvgCorners: f.homeAvgCorners,
|
||
awayAvgCorners: f.awayAvgCorners,
|
||
h2hTotal: f.h2hTotal,
|
||
h2hHomeWinRate: f.h2hHomeWinRate,
|
||
h2hAvgGoals: f.h2hAvgGoals,
|
||
h2hOver25Rate: f.h2hOver25Rate,
|
||
h2hBttsRate: f.h2hBttsRate,
|
||
refereeAvgCards: f.refereeAvgCards,
|
||
refereeHomeBias: f.refereeHomeBias,
|
||
refereeAvgGoals: f.refereeAvgGoals,
|
||
leagueAvgGoals: f.leagueAvgGoals,
|
||
leagueHomeWinPct: f.leagueHomeWinPct,
|
||
leagueOver25Pct: f.leagueOver25Pct,
|
||
missingPlayersImpact: f.missingPlayersImpact,
|
||
calculatorVer: f.calculatorVer,
|
||
},
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
|
||
// Run
|
||
populateFeatureStore().catch(console.error);
|