107 lines
3.4 KiB
JavaScript
107 lines
3.4 KiB
JavaScript
const SIMILARITY_WEIGHTS = {
|
|
functional_role_match: 0.20,
|
|
opponent_defensive_rating: 0.14,
|
|
pace: 0.15,
|
|
lineup_context: 0.12,
|
|
rest_days: 0.09,
|
|
travel_fatigue: 0.08,
|
|
game_importance: 0.07,
|
|
referee_tendency: 0.06,
|
|
score_state_context: 0.05,
|
|
role_variance_match: 0.04,
|
|
};
|
|
|
|
/**
|
|
* Calculate similarity score between two games based on weighted factors.
|
|
* @param {object} gameA - First game context object
|
|
* @param {object} gameB - Second game context object
|
|
* @param {object} weights - Weight configuration (defaults to SIMILARITY_WEIGHTS)
|
|
* @returns {number} Similarity score between 0 and 1
|
|
*/
|
|
function calculateSimilarityScore(gameA, gameB, weights = SIMILARITY_WEIGHTS) {
|
|
let totalScore = 0;
|
|
let totalWeight = 0;
|
|
|
|
for (const [factor, weight] of Object.entries(weights)) {
|
|
if (gameA[factor] !== undefined && gameB[factor] !== undefined) {
|
|
const maxVal = Math.max(Math.abs(gameA[factor]), Math.abs(gameB[factor]), 1);
|
|
const diff = Math.abs(gameA[factor] - gameB[factor]) / maxVal;
|
|
const similarity = Math.max(0, 1 - diff);
|
|
totalScore += similarity * weight;
|
|
totalWeight += weight;
|
|
}
|
|
}
|
|
|
|
if (totalWeight === 0) return 0;
|
|
return Math.min(1, Math.max(0, totalScore / totalWeight));
|
|
}
|
|
|
|
/**
|
|
* Find the most similar historical games to a target game.
|
|
* @param {object} targetGame - The game to match against
|
|
* @param {Array} historicalGames - Array of historical game objects
|
|
* @param {number} minInstances - Minimum matches required (default 15)
|
|
* @returns {object} { games: sorted matches, confidence: HIGH|LOW, usedSeasonAvg: boolean }
|
|
*/
|
|
function findSimilarGames(targetGame, historicalGames, minInstances = 15) {
|
|
if (!historicalGames || historicalGames.length === 0) {
|
|
return { games: [], confidence: 'LOW', usedSeasonAvg: true };
|
|
}
|
|
|
|
const scored = historicalGames.map(game => ({
|
|
...game,
|
|
similarityScore: calculateSimilarityScore(targetGame, game),
|
|
}));
|
|
|
|
scored.sort((a, b) => b.similarityScore - a.similarityScore);
|
|
|
|
if (scored.length < minInstances) {
|
|
return {
|
|
games: scored,
|
|
confidence: 'LOW',
|
|
usedSeasonAvg: true,
|
|
note: `Only ${scored.length} similar games found (min: ${minInstances}). Falling back to season averages.`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
games: scored.slice(0, Math.max(minInstances, Math.floor(scored.length * 0.3))),
|
|
confidence: 'HIGH',
|
|
usedSeasonAvg: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate posterior distribution from similar games.
|
|
* @param {Array} similarGames - Array of game objects with a stat value
|
|
* @returns {object} { mean, stddev, ci_low, ci_high, n }
|
|
*/
|
|
function getPosteriorDistribution(similarGames) {
|
|
if (!similarGames || similarGames.length === 0) {
|
|
return { mean: 0, stddev: 0, ci_low: 0, ci_high: 0, n: 0 };
|
|
}
|
|
|
|
const values = similarGames.map(g => g.statValue || 0);
|
|
const n = values.length;
|
|
const mean = values.reduce((sum, v) => sum + v, 0) / n;
|
|
const variance = values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / (n - 1 || 1);
|
|
const stddev = Math.sqrt(variance);
|
|
const zScore = 1.96; // 95% CI
|
|
const se = stddev / Math.sqrt(n);
|
|
|
|
return {
|
|
mean: Math.round(mean * 100) / 100,
|
|
stddev: Math.round(stddev * 100) / 100,
|
|
ci_low: Math.round((mean - zScore * se) * 100) / 100,
|
|
ci_high: Math.round((mean + zScore * se) * 100) / 100,
|
|
n,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
SIMILARITY_WEIGHTS,
|
|
calculateSimilarityScore,
|
|
findSimilarGames,
|
|
getPosteriorDistribution,
|
|
};
|