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