This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* ===================================================
|
||||
* BACKTEST ACCURACY — V30 Prediction System
|
||||
* ===================================================
|
||||
* Tests historical predictions against actual outcomes.
|
||||
* Uses the running AI Engine's /v20plus/analyze/{match_id}
|
||||
* endpoint which extracts features from DB internally.
|
||||
*
|
||||
* Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/backtest-accuracy.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import axios from 'axios';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Configuration
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
const AI_ENGINE_URL = process.env.AI_ENGINE_URL || 'http://127.0.0.1:3005';
|
||||
const CONCURRENT_REQUESTS = 5;
|
||||
const MAX_MATCHES = 1000;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Types
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
interface TestMatch {
|
||||
id: string;
|
||||
scoreHome: number;
|
||||
scoreAway: number;
|
||||
htScoreHome: number | null;
|
||||
htScoreAway: number | null;
|
||||
}
|
||||
|
||||
interface BacktestResult {
|
||||
matchId: string;
|
||||
actual: { ms: string; ou25: string; btts: string; htft: string };
|
||||
predicted: { ms: string; ou25: string; btts: string };
|
||||
probabilities: {
|
||||
home: number;
|
||||
draw: number;
|
||||
away: number;
|
||||
over: number;
|
||||
under: number;
|
||||
bttsYes: number;
|
||||
bttsNo: number;
|
||||
};
|
||||
mainPickCorrect: boolean;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function determineActualOutcome(
|
||||
scoreHome: number,
|
||||
scoreAway: number,
|
||||
htScoreHome: number | null,
|
||||
htScoreAway: number | null,
|
||||
): { ms: string; ou25: string; btts: string; htft: string } {
|
||||
const ms = scoreHome > scoreAway ? '1' : scoreHome < scoreAway ? '2' : 'X';
|
||||
const ou25 = scoreHome + scoreAway > 2.5 ? 'Over' : 'Under';
|
||||
const btts = scoreHome > 0 && scoreAway > 0 ? 'Yes' : 'No';
|
||||
|
||||
let htft = 'unknown';
|
||||
if (htScoreHome !== null && htScoreAway !== null) {
|
||||
const htResult =
|
||||
htScoreHome > htScoreAway ? '1' : htScoreHome < htScoreAway ? '2' : 'X';
|
||||
htft = `${htResult}/${ms}`;
|
||||
}
|
||||
|
||||
return { ms, ou25, btts, htft };
|
||||
}
|
||||
|
||||
function extractPrediction(response: unknown): {
|
||||
ms: string;
|
||||
ou25: string;
|
||||
btts: string;
|
||||
probs: BacktestResult['probabilities'];
|
||||
mainPick: string;
|
||||
mainMarket: string;
|
||||
} {
|
||||
const data = response as Record<string, unknown>;
|
||||
const predictions = data?.predictions as Record<string, unknown> | undefined;
|
||||
|
||||
const mainPickObj = data?.main_pick as Record<string, unknown> | undefined;
|
||||
const mainPick =
|
||||
typeof mainPickObj?.pick === 'string' ? mainPickObj.pick : '';
|
||||
const mainMarket =
|
||||
typeof mainPickObj?.market === 'string' ? mainPickObj.market : '';
|
||||
|
||||
// Extract MS from probabilities or main pick
|
||||
const msProbs = (predictions?.ms || data?.ms || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const homeProb =
|
||||
typeof msProbs['1'] === 'number'
|
||||
? msProbs['1']
|
||||
: typeof msProbs.home_prob === 'number'
|
||||
? msProbs.home_prob
|
||||
: 0;
|
||||
const drawProb =
|
||||
typeof msProbs['X'] === 'number'
|
||||
? msProbs['X']
|
||||
: typeof msProbs.draw_prob === 'number'
|
||||
? msProbs.draw_prob
|
||||
: 0;
|
||||
const awayProb =
|
||||
typeof msProbs['2'] === 'number'
|
||||
? msProbs['2']
|
||||
: typeof msProbs.away_prob === 'number'
|
||||
? msProbs.away_prob
|
||||
: 0;
|
||||
|
||||
let ms = '1';
|
||||
if (drawProb > homeProb && drawProb > awayProb) ms = 'X';
|
||||
else if (awayProb > homeProb) ms = '2';
|
||||
|
||||
// Extract OU25
|
||||
const ou25Probs = (predictions?.ou25 || data?.ou25 || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const overProb =
|
||||
typeof ou25Probs.Over === 'number'
|
||||
? ou25Probs.Over
|
||||
: typeof ou25Probs.over_prob === 'number'
|
||||
? ou25Probs.over_prob
|
||||
: 0;
|
||||
const underProb =
|
||||
typeof ou25Probs.Under === 'number'
|
||||
? ou25Probs.Under
|
||||
: typeof ou25Probs.under_prob === 'number'
|
||||
? ou25Probs.under_prob
|
||||
: 0;
|
||||
const ou25 = overProb > underProb ? 'Over' : 'Under';
|
||||
|
||||
// Extract BTTS
|
||||
const bttsProbs = (predictions?.btts || data?.btts || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const bttsYes =
|
||||
typeof bttsProbs.Yes === 'number'
|
||||
? bttsProbs.Yes
|
||||
: typeof bttsProbs.yes_prob === 'number'
|
||||
? bttsProbs.yes_prob
|
||||
: 0;
|
||||
const bttsNo =
|
||||
typeof bttsProbs.No === 'number'
|
||||
? bttsProbs.No
|
||||
: typeof bttsProbs.no_prob === 'number'
|
||||
? bttsProbs.no_prob
|
||||
: 0;
|
||||
const btts = bttsYes > bttsNo ? 'Yes' : 'No';
|
||||
|
||||
return {
|
||||
ms,
|
||||
ou25,
|
||||
btts,
|
||||
probs: {
|
||||
home: homeProb,
|
||||
draw: drawProb,
|
||||
away: awayProb,
|
||||
over: overProb,
|
||||
under: underProb,
|
||||
bttsYes,
|
||||
bttsNo,
|
||||
},
|
||||
mainPick,
|
||||
mainMarket,
|
||||
};
|
||||
}
|
||||
|
||||
async function processBatch(batch: TestMatch[]): Promise<BacktestResult[]> {
|
||||
const results: BacktestResult[] = [];
|
||||
|
||||
const promises = batch.map(async (match) => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${AI_ENGINE_URL}/v20plus/analyze/${match.id}`,
|
||||
{},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
|
||||
const actual = determineActualOutcome(
|
||||
match.scoreHome,
|
||||
match.scoreAway,
|
||||
match.htScoreHome,
|
||||
match.htScoreAway,
|
||||
);
|
||||
|
||||
const pred = extractPrediction(response.data);
|
||||
|
||||
// Check main pick
|
||||
let mainPickCorrect = false;
|
||||
if (pred.mainMarket === 'MS') {
|
||||
mainPickCorrect = pred.mainPick === actual.ms;
|
||||
} else if (pred.mainMarket === 'OU25') {
|
||||
mainPickCorrect = pred.mainPick === actual.ou25;
|
||||
} else if (pred.mainMarket === 'BTTS') {
|
||||
mainPickCorrect = pred.mainPick === actual.btts;
|
||||
}
|
||||
|
||||
results.push({
|
||||
matchId: match.id,
|
||||
actual,
|
||||
predicted: { ms: pred.ms, ou25: pred.ou25, btts: pred.btts },
|
||||
probabilities: pred.probs,
|
||||
mainPickCorrect,
|
||||
});
|
||||
} catch {
|
||||
// Skip failed matches silently
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Main Backtest
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
async function runBacktest(): Promise<void> {
|
||||
console.log('🎯 BACKTEST ACCURACY — V30 Betting Engine');
|
||||
console.log('════════════════════════════════════════════════════════');
|
||||
|
||||
// 1. Health check
|
||||
try {
|
||||
const health = await axios.get(`${AI_ENGINE_URL}/health`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
console.log(`✅ AI Engine: ${JSON.stringify(health.data)}`);
|
||||
} catch {
|
||||
console.error('❌ AI Engine not reachable at', AI_ENGINE_URL);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. Load finished matches with features
|
||||
console.log('\n📥 Loading test matches...');
|
||||
const matches = await prisma.$queryRaw<TestMatch[]>`
|
||||
SELECT m.id, m.score_home AS "scoreHome", m.score_away AS "scoreAway",
|
||||
m.ht_score_home AS "htScoreHome", m.ht_score_away AS "htScoreAway"
|
||||
FROM matches m
|
||||
JOIN match_ai_features maf ON maf.match_id = m.id
|
||||
WHERE m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
AND m.sport = 'football'
|
||||
AND maf.home_elo != 1500
|
||||
AND maf.implied_home != 0.33
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT ${MAX_MATCHES}
|
||||
`;
|
||||
console.log(` 📊 Test matches: ${matches.length}`);
|
||||
|
||||
// 3. Run predictions in batches
|
||||
console.log('\n🤖 Running predictions...');
|
||||
const allResults: BacktestResult[] = [];
|
||||
let processed = 0;
|
||||
|
||||
for (let i = 0; i < matches.length; i += CONCURRENT_REQUESTS) {
|
||||
const batch = matches.slice(i, i + CONCURRENT_REQUESTS);
|
||||
const batchResults = await processBatch(batch);
|
||||
allResults.push(...batchResults);
|
||||
processed += batch.length;
|
||||
|
||||
if (processed % 50 === 0 || processed === matches.length) {
|
||||
const currentMsAcc =
|
||||
allResults.length > 0
|
||||
? (
|
||||
(allResults.filter((r) => r.predicted.ms === r.actual.ms).length /
|
||||
allResults.length) *
|
||||
100
|
||||
).toFixed(1)
|
||||
: '0';
|
||||
console.log(
|
||||
` 📊 ${processed}/${matches.length} — Success: ${allResults.length} — MS Acc: ${currentMsAcc}%`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Calculate metrics
|
||||
const total = allResults.length;
|
||||
if (total === 0) {
|
||||
console.error('❌ No results to analyze');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const msCorrect = allResults.filter(
|
||||
(r) => r.predicted.ms === r.actual.ms,
|
||||
).length;
|
||||
const ou25Correct = allResults.filter(
|
||||
(r) => r.predicted.ou25 === r.actual.ou25,
|
||||
).length;
|
||||
const bttsCorrect = allResults.filter(
|
||||
(r) => r.predicted.btts === r.actual.btts,
|
||||
).length;
|
||||
const mainPickCorrect = allResults.filter((r) => r.mainPickCorrect).length;
|
||||
|
||||
// Actual distribution
|
||||
const actHome = allResults.filter((r) => r.actual.ms === '1').length;
|
||||
const actDraw = allResults.filter((r) => r.actual.ms === 'X').length;
|
||||
const actAway = allResults.filter((r) => r.actual.ms === '2').length;
|
||||
|
||||
// Predicted distribution
|
||||
const predHome = allResults.filter((r) => r.predicted.ms === '1').length;
|
||||
const predDraw = allResults.filter((r) => r.predicted.ms === 'X').length;
|
||||
const predAway = allResults.filter((r) => r.predicted.ms === '2').length;
|
||||
|
||||
// Confidence calibration (based on max probability)
|
||||
const buckets: Record<string, { correct: number; total: number }> = {
|
||||
'33-40%': { correct: 0, total: 0 },
|
||||
'40-50%': { correct: 0, total: 0 },
|
||||
'50-60%': { correct: 0, total: 0 },
|
||||
'60-70%': { correct: 0, total: 0 },
|
||||
'70%+': { correct: 0, total: 0 },
|
||||
};
|
||||
|
||||
for (const r of allResults) {
|
||||
const maxProb = Math.max(
|
||||
r.probabilities.home,
|
||||
r.probabilities.draw,
|
||||
r.probabilities.away,
|
||||
);
|
||||
const key =
|
||||
maxProb >= 0.7
|
||||
? '70%+'
|
||||
: maxProb >= 0.6
|
||||
? '60-70%'
|
||||
: maxProb >= 0.5
|
||||
? '50-60%'
|
||||
: maxProb >= 0.4
|
||||
? '40-50%'
|
||||
: '33-40%';
|
||||
buckets[key].total++;
|
||||
if (r.predicted.ms === r.actual.ms) buckets[key].correct++;
|
||||
}
|
||||
|
||||
// 5. Print Report
|
||||
console.log('\n════════════════════════════════════════════════════════');
|
||||
console.log('📊 BACKTEST ACCURACY REPORT');
|
||||
console.log('════════════════════════════════════════════════════════');
|
||||
console.log(` Total Matches Analyzed: ${total}`);
|
||||
console.log('');
|
||||
console.log(' 🎯 Market Accuracy:');
|
||||
console.log(
|
||||
` ⚽ Match Result (MS): ${((msCorrect / total) * 100).toFixed(2)}% (${msCorrect}/${total})`,
|
||||
);
|
||||
console.log(
|
||||
` 📈 Over/Under 2.5: ${((ou25Correct / total) * 100).toFixed(2)}% (${ou25Correct}/${total})`,
|
||||
);
|
||||
console.log(
|
||||
` 🤝 Both Teams Score: ${((bttsCorrect / total) * 100).toFixed(2)}% (${bttsCorrect}/${total})`,
|
||||
);
|
||||
console.log(
|
||||
` 🏆 Main Pick Success: ${((mainPickCorrect / total) * 100).toFixed(2)}% (${mainPickCorrect}/${total})`,
|
||||
);
|
||||
|
||||
console.log('\n 📊 MS Distribution:');
|
||||
console.log(
|
||||
` Actual: 1: ${actHome} (${((actHome / total) * 100).toFixed(1)}%) | X: ${actDraw} (${((actDraw / total) * 100).toFixed(1)}%) | 2: ${actAway} (${((actAway / total) * 100).toFixed(1)}%)`,
|
||||
);
|
||||
console.log(
|
||||
` Predicted: 1: ${predHome} (${((predHome / total) * 100).toFixed(1)}%) | X: ${predDraw} (${((predDraw / total) * 100).toFixed(1)}%) | 2: ${predAway} (${((predAway / total) * 100).toFixed(1)}%)`,
|
||||
);
|
||||
|
||||
console.log('\n 📊 Confidence Calibration:');
|
||||
for (const [range, bucket] of Object.entries(buckets)) {
|
||||
if (bucket.total === 0) continue;
|
||||
const acc = (bucket.correct / bucket.total) * 100;
|
||||
const bar = '█'.repeat(Math.round(acc / 3));
|
||||
console.log(
|
||||
` ${range.padEnd(8)} : ${acc.toFixed(1)}% acc (n=${bucket.total}) ${bar}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Per-market deep dive
|
||||
console.log('\n 📊 OU25 Breakdown:');
|
||||
const actOver = allResults.filter((r) => r.actual.ou25 === 'Over').length;
|
||||
const actUnder = total - actOver;
|
||||
const predOver = allResults.filter((r) => r.predicted.ou25 === 'Over').length;
|
||||
const predUnder = total - predOver;
|
||||
console.log(
|
||||
` Actual: Over: ${actOver} (${((actOver / total) * 100).toFixed(1)}%) | Under: ${actUnder} (${((actUnder / total) * 100).toFixed(1)}%)`,
|
||||
);
|
||||
console.log(
|
||||
` Predicted: Over: ${predOver} (${((predOver / total) * 100).toFixed(1)}%) | Under: ${predUnder} (${((predUnder / total) * 100).toFixed(1)}%)`,
|
||||
);
|
||||
|
||||
console.log('\n 📊 BTTS Breakdown:');
|
||||
const actBttsYes = allResults.filter((r) => r.actual.btts === 'Yes').length;
|
||||
const actBttsNo = total - actBttsYes;
|
||||
const predBttsYes = allResults.filter(
|
||||
(r) => r.predicted.btts === 'Yes',
|
||||
).length;
|
||||
const predBttsNo = total - predBttsYes;
|
||||
console.log(
|
||||
` Actual: Yes: ${actBttsYes} (${((actBttsYes / total) * 100).toFixed(1)}%) | No: ${actBttsNo} (${((actBttsNo / total) * 100).toFixed(1)}%)`,
|
||||
);
|
||||
console.log(
|
||||
` Predicted: Yes: ${predBttsYes} (${((predBttsYes / total) * 100).toFixed(1)}%) | No: ${predBttsNo} (${((predBttsNo / total) * 100).toFixed(1)}%)`,
|
||||
);
|
||||
|
||||
console.log('════════════════════════════════════════════════════════');
|
||||
console.log('✅ Backtest complete!');
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
runBacktest().catch((err: unknown) => {
|
||||
console.error('❌ Backtest failed:', err);
|
||||
void prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* ===================================================
|
||||
* BATCH PREDICTION PIPELINE — V30
|
||||
* ===================================================
|
||||
* Processes all upcoming matches (NS) and generates
|
||||
* predictions by calling the AI Engine.
|
||||
* Saves results to the predictions table.
|
||||
*
|
||||
* Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/batch-predict.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import axios from 'axios';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const AI_ENGINE_URL = process.env.AI_ENGINE_URL || 'http://127.0.0.1:3005';
|
||||
const BATCH_SIZE = 5;
|
||||
const MAX_MATCHES_TO_PROCESS = 1000; // Limit for local testing/batch capacity
|
||||
|
||||
async function runBatchPrediction() {
|
||||
console.log('🗓 BATCH PREDICTION PIPELINE STARTING');
|
||||
console.log('════════════════════════════════════════════════════════');
|
||||
|
||||
// 1. Health check
|
||||
try {
|
||||
const health = await axios.get(`${AI_ENGINE_URL}/health`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
console.log(`✅ AI Engine Health: ${JSON.stringify(health.data)}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
console.error('❌ AI Engine not reachable at', AI_ENGINE_URL);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. Load upcoming matches (Not Started)
|
||||
const upcomingMatches = await prisma.match.findMany({
|
||||
where: {
|
||||
status: 'NS',
|
||||
mstUtc: {
|
||||
gte: Math.floor(Date.now() / 1000), // Future matches
|
||||
},
|
||||
sport: 'football',
|
||||
},
|
||||
orderBy: { mstUtc: 'asc' },
|
||||
take: MAX_MATCHES_TO_PROCESS,
|
||||
select: {
|
||||
id: true,
|
||||
homeTeam: { select: { name: true } },
|
||||
awayTeam: { select: { name: true } },
|
||||
mstUtc: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`\n📥 Found ${upcomingMatches.length} upcoming matches to process.`,
|
||||
);
|
||||
|
||||
// 3. Process matches in batches
|
||||
let processedCount = 0;
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < upcomingMatches.length; i += BATCH_SIZE) {
|
||||
const batch = upcomingMatches.slice(i, i + BATCH_SIZE);
|
||||
|
||||
console.log(
|
||||
`\n⏳ Processing batch ${Math.floor(i / BATCH_SIZE) + 1} (${batch.length} matches)...`,
|
||||
);
|
||||
|
||||
const promises = batch.map(async (match) => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${AI_ENGINE_URL}/v20plus/analyze/${match.id}`,
|
||||
{},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
if (!data || !data.predictions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use the entire response payload so frontend gets the expected MatchPredictionDto schema
|
||||
const modelOutput = data;
|
||||
|
||||
// Cache result in predictions table
|
||||
await prisma.prediction.upsert({
|
||||
where: { matchId: match.id },
|
||||
create: {
|
||||
matchId: match.id,
|
||||
predictionJson: modelOutput,
|
||||
},
|
||||
update: {
|
||||
predictionJson: modelOutput,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
` ✅ Cached prediction for: ${match.homeTeam?.name} vs ${match.awayTeam?.name} (${match.mstUtc})`,
|
||||
);
|
||||
return true;
|
||||
} catch (e: unknown) {
|
||||
const err = e as Error;
|
||||
console.error(
|
||||
` ❌ Failed for match ${match.id}:`,
|
||||
err?.message || 'Unknown error',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
successCount += results.filter(Boolean).length;
|
||||
processedCount += batch.length;
|
||||
}
|
||||
|
||||
console.log('\n════════════════════════════════════════════════════════');
|
||||
console.log(`🎉 BATCH PROCESS COMPLETE`);
|
||||
console.log(` Total Processed: ${processedCount}`);
|
||||
console.log(` Successfully Updated/Created: ${successCount}`);
|
||||
console.log(` Failed: ${processedCount - successCount}`);
|
||||
console.log('════════════════════════════════════════════════════════');
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
runBatchPrediction().catch((e: unknown) => {
|
||||
const err = e as Error;
|
||||
console.error(err);
|
||||
void prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('🔍 Checking for potential duplicate matches...');
|
||||
|
||||
// Group by unique match characteristics
|
||||
// Since we can't easily do GROUP BY with HAVING count > 1 in Prisma standard API without raw query,
|
||||
// we'll use a raw query for efficiency.
|
||||
|
||||
const duplicates = await prisma.$queryRaw<
|
||||
{
|
||||
home_team_id: string;
|
||||
away_team_id: string;
|
||||
mst_utc: bigint;
|
||||
count: bigint;
|
||||
ids: string[];
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
home_team_id,
|
||||
away_team_id,
|
||||
mst_utc,
|
||||
COUNT(*) as count,
|
||||
array_agg(id) as ids
|
||||
FROM matches
|
||||
WHERE home_team_id IS NOT NULL
|
||||
AND away_team_id IS NOT NULL
|
||||
GROUP BY home_team_id, away_team_id, mst_utc
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY count DESC
|
||||
LIMIT 50;
|
||||
`;
|
||||
|
||||
if (duplicates.length === 0) {
|
||||
console.log(
|
||||
'✅ No duplicate matches found based on (HomeTeam + AwayTeam + Date).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`⚠️ Found ${duplicates.length} sets of duplicate matches:\n`);
|
||||
|
||||
for (const group of duplicates) {
|
||||
const homeTeam = await prisma.team.findUnique({
|
||||
where: { id: group.home_team_id },
|
||||
select: { name: true },
|
||||
});
|
||||
const awayTeam = await prisma.team.findUnique({
|
||||
where: { id: group.away_team_id },
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
const date = new Date(Number(group.mst_utc)).toISOString();
|
||||
console.log(
|
||||
`📅 ${date} | ${homeTeam?.name} vs ${awayTeam?.name} (Count: ${group.count})`,
|
||||
);
|
||||
console.log(` IDs: ${group.ids.join(', ')}`);
|
||||
|
||||
// Check details of the duplicates to see if one is complete and one is not
|
||||
for (const id of group.ids) {
|
||||
const match = await prisma.match.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
oddCategories: { select: { dbId: true } },
|
||||
footballTeamStats: { select: { id: true } },
|
||||
basketballPlayerStats: { select: { id: true } },
|
||||
playerEvents: { select: { id: true } },
|
||||
officials: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (match) {
|
||||
const counts = [
|
||||
match.oddCategories.length > 0 ? 'Odds' : '',
|
||||
match.footballTeamStats.length > 0 ? 'Stats' : '',
|
||||
match.playerEvents.length > 0 ? 'Events' : '',
|
||||
match.officials.length > 0 ? 'Officials' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
console.log(
|
||||
` - [${id}] Status: ${match.status} | Score: ${match.scoreHome}-${match.scoreAway} | Data: ${counts || 'None'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log('---------------------------------------------------');
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => console.error(e))
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Live Matches Cleanup Script
|
||||
*
|
||||
* Bitmiş maçları live_matches tablosundan siler.
|
||||
* Kullanım: npx ts-node -r tsconfig-paths/register src/scripts/cleanup-live-matches.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const FINISHED_STATUSES = ['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'];
|
||||
const FINISHED_STATES = ['Finished', 'post', 'FT', 'postGame'];
|
||||
const LIVE_STATUSES = [
|
||||
'LIVE',
|
||||
'1H',
|
||||
'2H',
|
||||
'HT',
|
||||
'1Q',
|
||||
'2Q',
|
||||
'3Q',
|
||||
'4Q',
|
||||
'Playing',
|
||||
'Half Time',
|
||||
];
|
||||
const LIVE_STATES = ['live', 'firsthalf', 'secondhalf'];
|
||||
|
||||
async function cleanupLiveMatches() {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
try {
|
||||
console.log('🧹 Live matches temizliği başlıyor...');
|
||||
|
||||
const now = Date.now();
|
||||
const finishedGraceMs = 6 * 60 * 60 * 1000;
|
||||
const staleGraceMs = 24 * 60 * 60 * 1000;
|
||||
const finishedBefore = BigInt(now - finishedGraceMs);
|
||||
const staleBefore = BigInt(now - staleGraceMs);
|
||||
|
||||
const totalBefore = await prisma.liveMatch.count();
|
||||
const outdatedCount = await prisma.liveMatch.count({
|
||||
where: {
|
||||
mstUtc: { lt: BigInt(now) },
|
||||
},
|
||||
});
|
||||
const finishedPastCount = await prisma.liveMatch.count({
|
||||
where: {
|
||||
mstUtc: { lt: finishedBefore },
|
||||
OR: [
|
||||
{ status: { in: FINISHED_STATUSES } },
|
||||
{ state: { in: FINISHED_STATES } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
console.log('📊 Mevcut durum:');
|
||||
console.log(` Toplam live_matches: ${totalBefore}`);
|
||||
console.log(` Geçmiş zamanlı kayıt: ${outdatedCount}`);
|
||||
console.log(
|
||||
` Bitmiş ve grace süresini aşmış kayıt: ${finishedPastCount}`,
|
||||
);
|
||||
|
||||
const deleted = await prisma.liveMatch.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
mstUtc: { lt: finishedBefore },
|
||||
OR: [
|
||||
{ status: { in: FINISHED_STATUSES } },
|
||||
{ state: { in: FINISHED_STATES } },
|
||||
],
|
||||
},
|
||||
{
|
||||
mstUtc: { lt: staleBefore },
|
||||
NOT: {
|
||||
OR: [
|
||||
{ status: { in: LIVE_STATUSES } },
|
||||
{ state: { in: LIVE_STATES } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const totalAfter = await prisma.liveMatch.count();
|
||||
|
||||
console.log('\n✅ Temizlik tamamlandı!');
|
||||
console.log(` Silinen maç: ${deleted.count}`);
|
||||
console.log(` Kalan maç: ${totalAfter}`);
|
||||
|
||||
const states = await prisma.$queryRaw`
|
||||
SELECT state, COUNT(*)::int as count
|
||||
FROM live_matches
|
||||
GROUP BY state
|
||||
`;
|
||||
|
||||
console.log('\n📋 Kalan maçların durumları:');
|
||||
(states as any).forEach((s: any) => {
|
||||
console.log(` ${s.state || 'null'}: ${s.count}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Hata:', error);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void cleanupLiveMatches();
|
||||
@@ -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);
|
||||
@@ -0,0 +1,636 @@
|
||||
import 'reflect-metadata';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from '../app.module';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type SwaggerPaths = Record<string, Record<string, JsonRecord>>;
|
||||
type SwaggerSchemas = Record<string, JsonRecord>;
|
||||
|
||||
interface PostmanResponse {
|
||||
name: string;
|
||||
originalRequest: JsonRecord;
|
||||
status: string;
|
||||
code: number;
|
||||
_postman_previewlanguage: 'json';
|
||||
header: Array<{ key: string; value: string }>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface PostmanItem {
|
||||
name: string;
|
||||
item?: PostmanItem[];
|
||||
request?: JsonRecord;
|
||||
response?: PostmanResponse[];
|
||||
}
|
||||
|
||||
interface AiEndpointDefinition {
|
||||
name: string;
|
||||
method: 'GET' | 'POST';
|
||||
path: string;
|
||||
description: string;
|
||||
query?: Array<{ key: string; value: string; description: string }>;
|
||||
body?: JsonRecord;
|
||||
response: JsonRecord;
|
||||
}
|
||||
|
||||
function refName(ref: string | undefined): string | null {
|
||||
if (!ref) {
|
||||
return null;
|
||||
}
|
||||
const parts = ref.split('/');
|
||||
return parts[parts.length - 1] ?? null;
|
||||
}
|
||||
|
||||
function resolveSchema(
|
||||
schema: unknown,
|
||||
schemas: SwaggerSchemas,
|
||||
): JsonRecord | null {
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const schemaObject = schema as JsonRecord;
|
||||
const schemaRef = typeof schemaObject.$ref === 'string' ? schemaObject.$ref : null;
|
||||
if (schemaRef) {
|
||||
const name = refName(schemaRef);
|
||||
return name ? (schemas[name] ?? null) : null;
|
||||
}
|
||||
|
||||
return schemaObject;
|
||||
}
|
||||
|
||||
function examplePrimitive(schema: JsonRecord): unknown {
|
||||
if (schema.example !== undefined) {
|
||||
return schema.example;
|
||||
}
|
||||
if (schema.default !== undefined) {
|
||||
return schema.default;
|
||||
}
|
||||
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
||||
return schema.enum[0];
|
||||
}
|
||||
|
||||
const type = typeof schema.type === 'string' ? schema.type : 'string';
|
||||
const format = typeof schema.format === 'string' ? schema.format : '';
|
||||
|
||||
if (type === 'string') {
|
||||
if (format === 'email') {
|
||||
return 'user@example.com';
|
||||
}
|
||||
if (format === 'date-time') {
|
||||
return '2026-04-14T00:00:00.000Z';
|
||||
}
|
||||
if (format === 'date') {
|
||||
return '2026-04-14';
|
||||
}
|
||||
if (format === 'uuid') {
|
||||
return '11111111-1111-1111-1111-111111111111';
|
||||
}
|
||||
return 'string';
|
||||
}
|
||||
if (type === 'integer' || type === 'number') {
|
||||
return 1;
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
return true;
|
||||
}
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function buildExampleFromSchema(
|
||||
schema: unknown,
|
||||
schemas: SwaggerSchemas,
|
||||
visited: Set<string> = new Set<string>(),
|
||||
): unknown {
|
||||
const resolved = resolveSchema(schema, schemas);
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const schemaRef = typeof resolved.$ref === 'string' ? resolved.$ref : null;
|
||||
if (schemaRef) {
|
||||
const name = refName(schemaRef);
|
||||
if (!name || visited.has(name)) {
|
||||
return null;
|
||||
}
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(name);
|
||||
return buildExampleFromSchema(schemas[name], schemas, nextVisited);
|
||||
}
|
||||
|
||||
if (Array.isArray(resolved.allOf) && resolved.allOf.length > 0) {
|
||||
return resolved.allOf.reduce<JsonRecord>((accumulator, part) => {
|
||||
const partial = buildExampleFromSchema(part, schemas, visited);
|
||||
if (partial && typeof partial === 'object' && !Array.isArray(partial)) {
|
||||
return { ...accumulator, ...(partial as JsonRecord) };
|
||||
}
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
if (Array.isArray(resolved.oneOf) && resolved.oneOf.length > 0) {
|
||||
return buildExampleFromSchema(resolved.oneOf[0], schemas, visited);
|
||||
}
|
||||
|
||||
if (Array.isArray(resolved.anyOf) && resolved.anyOf.length > 0) {
|
||||
return buildExampleFromSchema(resolved.anyOf[0], schemas, visited);
|
||||
}
|
||||
|
||||
const type = typeof resolved.type === 'string' ? resolved.type : 'object';
|
||||
if (type === 'array') {
|
||||
return [buildExampleFromSchema(resolved.items, schemas, visited)];
|
||||
}
|
||||
|
||||
if (type === 'object' || resolved.properties) {
|
||||
const properties = (resolved.properties ?? {}) as JsonRecord;
|
||||
const output: JsonRecord = {};
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
output[key] = buildExampleFromSchema(value, schemas, visited);
|
||||
}
|
||||
if (Object.keys(output).length > 0) {
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
return examplePrimitive(resolved);
|
||||
}
|
||||
|
||||
function swaggerSchemaFromContent(content: unknown): unknown {
|
||||
if (!content || typeof content !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const contentObject = content as JsonRecord;
|
||||
const jsonContent = contentObject['application/json'];
|
||||
if (jsonContent && typeof jsonContent === 'object') {
|
||||
return (jsonContent as JsonRecord).schema ?? null;
|
||||
}
|
||||
const firstContent = Object.values(contentObject)[0];
|
||||
if (firstContent && typeof firstContent === 'object') {
|
||||
return (firstContent as JsonRecord).schema ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toPostmanPath(pathname: string): string {
|
||||
return pathname.replace(/\{([^}]+)\}/g, '{{$1}}');
|
||||
}
|
||||
|
||||
function buildRequestBody(
|
||||
operation: JsonRecord,
|
||||
schemas: SwaggerSchemas,
|
||||
): string | null {
|
||||
const requestBody = operation.requestBody as JsonRecord | undefined;
|
||||
const schema = swaggerSchemaFromContent(requestBody?.content);
|
||||
if (!schema) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const example = buildExampleFromSchema(schema, schemas);
|
||||
return JSON.stringify(example ?? {}, null, 2);
|
||||
}
|
||||
|
||||
function buildResponses(
|
||||
operation: JsonRecord,
|
||||
method: string,
|
||||
rawPath: string,
|
||||
baseUrlVariable: string,
|
||||
schemas: SwaggerSchemas,
|
||||
body: string | null,
|
||||
): PostmanResponse[] {
|
||||
const responses = (operation.responses ?? {}) as JsonRecord;
|
||||
const entries = Object.entries(responses);
|
||||
|
||||
return entries.map(([statusCode, responseObject]) => {
|
||||
const responseRecord = responseObject as JsonRecord;
|
||||
const schema = swaggerSchemaFromContent(responseRecord.content);
|
||||
const example = buildExampleFromSchema(schema, schemas);
|
||||
const numericStatus = Number(statusCode);
|
||||
|
||||
return {
|
||||
name: `${method.toUpperCase()} ${rawPath} - ${statusCode}`,
|
||||
originalRequest: {
|
||||
method: method.toUpperCase(),
|
||||
header: [{ key: 'Content-Type', value: 'application/json' }],
|
||||
body: body
|
||||
? {
|
||||
mode: 'raw',
|
||||
raw: body,
|
||||
}
|
||||
: undefined,
|
||||
url: {
|
||||
raw: `{{${baseUrlVariable}}}${toPostmanPath(rawPath)}`,
|
||||
host: [`{{${baseUrlVariable}}}`],
|
||||
path: rawPath.split('/').filter(Boolean),
|
||||
},
|
||||
},
|
||||
status: typeof responseRecord.description === 'string'
|
||||
? responseRecord.description
|
||||
: `HTTP ${statusCode}`,
|
||||
code: Number.isFinite(numericStatus) ? numericStatus : 200,
|
||||
_postman_previewlanguage: 'json',
|
||||
header: [{ key: 'Content-Type', value: 'application/json' }],
|
||||
body: JSON.stringify(example ?? {}, null, 2),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildQueryParams(operation: JsonRecord): Array<JsonRecord> {
|
||||
const parameters = Array.isArray(operation.parameters)
|
||||
? (operation.parameters as JsonRecord[])
|
||||
: [];
|
||||
|
||||
return parameters
|
||||
.filter((parameter) => parameter.in === 'query')
|
||||
.map((parameter) => ({
|
||||
key: String(parameter.name ?? ''),
|
||||
value:
|
||||
parameter.schema && typeof parameter.schema === 'object'
|
||||
? String(((parameter.schema as JsonRecord).default ?? ''))
|
||||
: '',
|
||||
description: String(parameter.description ?? ''),
|
||||
disabled: parameter.required === true ? false : true,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildHeaders(operation: JsonRecord): Array<JsonRecord> {
|
||||
const headers: Array<JsonRecord> = [
|
||||
{
|
||||
key: 'Content-Type',
|
||||
value: 'application/json',
|
||||
},
|
||||
];
|
||||
|
||||
const security = Array.isArray(operation.security)
|
||||
? (operation.security as JsonRecord[])
|
||||
: [];
|
||||
if (security.length > 0) {
|
||||
headers.push({
|
||||
key: 'Authorization',
|
||||
value: 'Bearer {{accessToken}}',
|
||||
});
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function createRequestItem(
|
||||
name: string,
|
||||
method: string,
|
||||
rawPath: string,
|
||||
baseUrlVariable: string,
|
||||
operation: JsonRecord,
|
||||
schemas: SwaggerSchemas,
|
||||
): PostmanItem {
|
||||
const body = buildRequestBody(operation, schemas);
|
||||
const query = buildQueryParams(operation);
|
||||
const headers = buildHeaders(operation);
|
||||
|
||||
const request: JsonRecord = {
|
||||
method: method.toUpperCase(),
|
||||
header: headers,
|
||||
description:
|
||||
typeof operation.description === 'string'
|
||||
? operation.description
|
||||
: (typeof operation.summary === 'string' ? operation.summary : ''),
|
||||
url: {
|
||||
raw: `{{${baseUrlVariable}}}${toPostmanPath(rawPath)}`,
|
||||
host: [`{{${baseUrlVariable}}}`],
|
||||
path: rawPath.split('/').filter(Boolean),
|
||||
query,
|
||||
},
|
||||
};
|
||||
|
||||
if (body) {
|
||||
request.body = {
|
||||
mode: 'raw',
|
||||
raw: body,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
request,
|
||||
response: buildResponses(
|
||||
operation,
|
||||
method,
|
||||
rawPath,
|
||||
baseUrlVariable,
|
||||
schemas,
|
||||
body,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildNestFolders(document: JsonRecord): PostmanItem[] {
|
||||
const paths = (document.paths ?? {}) as SwaggerPaths;
|
||||
const schemas = ((document.components ?? {}) as JsonRecord).schemas as
|
||||
| SwaggerSchemas
|
||||
| undefined;
|
||||
const safeSchemas = schemas ?? {};
|
||||
|
||||
const folders = new Map<string, PostmanItem[]>();
|
||||
|
||||
for (const [rawPath, pathItem] of Object.entries(paths)) {
|
||||
for (const [method, operationObject] of Object.entries(pathItem)) {
|
||||
if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const operation = operationObject as JsonRecord;
|
||||
const tags = Array.isArray(operation.tags) ? operation.tags : [];
|
||||
const folderName =
|
||||
typeof tags[0] === 'string' && tags[0].trim().length > 0
|
||||
? tags[0]
|
||||
: 'Misc';
|
||||
const requestName =
|
||||
typeof operation.summary === 'string' && operation.summary.trim().length > 0
|
||||
? operation.summary
|
||||
: `${method.toUpperCase()} ${rawPath}`;
|
||||
|
||||
const item = createRequestItem(
|
||||
requestName,
|
||||
method,
|
||||
rawPath,
|
||||
'beBaseUrl',
|
||||
operation,
|
||||
safeSchemas,
|
||||
);
|
||||
|
||||
const existing = folders.get(folderName) ?? [];
|
||||
existing.push(item);
|
||||
folders.set(folderName, existing);
|
||||
}
|
||||
}
|
||||
|
||||
return [...folders.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([folderName, items]) => ({
|
||||
name: folderName,
|
||||
item: items.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}));
|
||||
}
|
||||
|
||||
function createAiRequest(
|
||||
endpoint: AiEndpointDefinition,
|
||||
folderName: string,
|
||||
): PostmanItem {
|
||||
const url: JsonRecord = {
|
||||
raw: `{{aiBaseUrl}}${endpoint.path}`,
|
||||
host: ['{{aiBaseUrl}}'],
|
||||
path: endpoint.path.split('/').filter(Boolean),
|
||||
};
|
||||
|
||||
if (endpoint.query && endpoint.query.length > 0) {
|
||||
url.query = endpoint.query.map((queryItem) => ({
|
||||
key: queryItem.key,
|
||||
value: queryItem.value,
|
||||
description: queryItem.description,
|
||||
}));
|
||||
}
|
||||
|
||||
const request: JsonRecord = {
|
||||
method: endpoint.method,
|
||||
header: [{ key: 'Content-Type', value: 'application/json' }],
|
||||
description: endpoint.description,
|
||||
url,
|
||||
};
|
||||
|
||||
if (endpoint.body) {
|
||||
request.body = {
|
||||
mode: 'raw',
|
||||
raw: JSON.stringify(endpoint.body, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: endpoint.name,
|
||||
request,
|
||||
response: [
|
||||
{
|
||||
name: `${endpoint.method} ${endpoint.path}`,
|
||||
originalRequest: request,
|
||||
status: 'OK',
|
||||
code: 200,
|
||||
_postman_previewlanguage: 'json',
|
||||
header: [{ key: 'Content-Type', value: 'application/json' }],
|
||||
body: JSON.stringify(endpoint.response, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildAiFolder(): PostmanItem {
|
||||
const v20Endpoints: AiEndpointDefinition[] = [
|
||||
{
|
||||
name: 'Root',
|
||||
method: 'GET',
|
||||
path: '/',
|
||||
description: 'AI engine root status endpoint',
|
||||
response: {
|
||||
status: 'Suggest-Bet AI Engine v20+',
|
||||
engine: 'V20 Plus Single Match Orchestrator',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Health',
|
||||
method: 'GET',
|
||||
path: '/health',
|
||||
description: 'AI engine health endpoint',
|
||||
response: { status: 'healthy', engine: 'v20plus', ready: true },
|
||||
},
|
||||
{
|
||||
name: 'Analyze Match',
|
||||
method: 'POST',
|
||||
path: '/v20plus/analyze/{{match_id}}',
|
||||
description: 'Full V20+ single match analysis',
|
||||
response: {
|
||||
model_version: 'v30.0',
|
||||
match_info: { match_id: '{{match_id}}' },
|
||||
main_pick: { market: 'OU25', pick: '2.5 Üst' },
|
||||
market_board: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Analyze HTMS',
|
||||
method: 'GET',
|
||||
path: '/v20plus/analyze-htms/{{match_id}}',
|
||||
description: 'Half-time result analysis endpoint',
|
||||
response: { match_id: '{{match_id}}', market: 'HT' },
|
||||
},
|
||||
{
|
||||
name: 'Analyze HTFT',
|
||||
method: 'GET',
|
||||
path: '/v20plus/analyze-htft/{{match_id}}',
|
||||
description: 'Half-time/full-time analysis endpoint',
|
||||
query: [
|
||||
{
|
||||
key: 'timeout_sec',
|
||||
value: '30',
|
||||
description: 'Timeout between 3 and 120 seconds',
|
||||
},
|
||||
],
|
||||
response: {
|
||||
engine: 'v20plus.1',
|
||||
match_info: { match_id: '{{match_id}}' },
|
||||
ht_ft_probs: { '1/1': 0.25, 'X/X': 0.18 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Generate Coupon',
|
||||
method: 'POST',
|
||||
path: '/v20plus/coupon',
|
||||
description: 'Generate V20+ coupon from selected matches',
|
||||
body: {
|
||||
match_ids: ['match-1', 'match-2'],
|
||||
strategy: 'BALANCED',
|
||||
max_matches: 4,
|
||||
min_confidence: 55,
|
||||
},
|
||||
response: {
|
||||
success: true,
|
||||
data: {
|
||||
strategy: 'BALANCED',
|
||||
bets: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Daily Banker',
|
||||
method: 'GET',
|
||||
path: '/v20plus/daily-banker',
|
||||
description: 'Get daily banker picks',
|
||||
query: [
|
||||
{
|
||||
key: 'count',
|
||||
value: '3',
|
||||
description: 'Number of banker picks',
|
||||
},
|
||||
],
|
||||
response: { count: 3, bankers: [] },
|
||||
},
|
||||
{
|
||||
name: 'Reversal Watchlist',
|
||||
method: 'GET',
|
||||
path: '/v20plus/reversal-watchlist',
|
||||
description: 'Reversal watchlist candidates',
|
||||
query: [
|
||||
{ key: 'count', value: '20', description: 'Result size' },
|
||||
{ key: 'horizon_hours', value: '72', description: 'Future horizon' },
|
||||
{ key: 'min_score', value: '45', description: 'Minimum score' },
|
||||
{
|
||||
key: 'top_leagues_only',
|
||||
value: 'false',
|
||||
description: 'Filter to top leagues',
|
||||
},
|
||||
],
|
||||
response: { count: 0, items: [] },
|
||||
},
|
||||
];
|
||||
|
||||
const v2Endpoints: AiEndpointDefinition[] = [
|
||||
{
|
||||
name: 'V2 Health',
|
||||
method: 'GET',
|
||||
path: '/v2/health',
|
||||
description: 'V2 betting engine health',
|
||||
response: {
|
||||
status: 'healthy',
|
||||
engine: 'v2.betting_engine',
|
||||
models_loaded: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'V2 Analyze Match',
|
||||
method: 'POST',
|
||||
path: '/v2/analyze/{{match_id}}',
|
||||
description: 'V2 leakage-free match analysis',
|
||||
response: {
|
||||
model_version: 'v2.betting_engine',
|
||||
match_info: { match_id: '{{match_id}}' },
|
||||
main_pick: { market: 'MS', pick: '1' },
|
||||
market_board: {
|
||||
MS: { pick: '1', confidence: 58.4 },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
name: 'AI Engine',
|
||||
item: [
|
||||
{
|
||||
name: 'V20+',
|
||||
item: v20Endpoints.map((endpoint) => createAiRequest(endpoint, 'V20+')),
|
||||
},
|
||||
{
|
||||
name: 'V2',
|
||||
item: v2Endpoints.map((endpoint) => createAiRequest(endpoint, 'V2')),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const outputDir = path.join(projectRoot, 'mds');
|
||||
const outputFile = path.join(
|
||||
outputDir,
|
||||
'suggest-bet-platform.postman_collection.json',
|
||||
);
|
||||
|
||||
process.env.REDIS_ENABLED = 'true';
|
||||
|
||||
const app = await NestFactory.create(AppModule, { logger: false });
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('Suggest Bet Backend API')
|
||||
.setDescription('Postman collection export source')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(
|
||||
app,
|
||||
swaggerConfig,
|
||||
) as unknown as JsonRecord;
|
||||
|
||||
const collection: JsonRecord = {
|
||||
info: {
|
||||
name: 'Suggest-Bet Platform API',
|
||||
description:
|
||||
'Auto-generated Postman collection for Nest backend and AI engine endpoints.',
|
||||
schema:
|
||||
'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
|
||||
},
|
||||
variable: [
|
||||
{ key: 'beBaseUrl', value: 'http://localhost:3005' },
|
||||
{ key: 'aiBaseUrl', value: 'http://localhost:8000' },
|
||||
{ key: 'accessToken', value: '' },
|
||||
{ key: 'match_id', value: 'sample-match-id' },
|
||||
],
|
||||
auth: {
|
||||
type: 'bearer',
|
||||
bearer: [{ key: 'token', value: '{{accessToken}}', type: 'string' }],
|
||||
},
|
||||
item: [
|
||||
{
|
||||
name: 'Nest API',
|
||||
item: buildNestFolders(document),
|
||||
},
|
||||
buildAiFolder(),
|
||||
],
|
||||
};
|
||||
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
writeFileSync(outputFile, JSON.stringify(collection, null, 2), 'utf8');
|
||||
|
||||
await app.close();
|
||||
|
||||
console.log(`✅ Postman collection exported: ${outputFile}`);
|
||||
}
|
||||
|
||||
void run();
|
||||
+687
@@ -0,0 +1,687 @@
|
||||
import 'reflect-metadata';
|
||||
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import ts from 'typescript';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from '../app.module';
|
||||
|
||||
type HttpMethod =
|
||||
| 'get'
|
||||
| 'post'
|
||||
| 'put'
|
||||
| 'patch'
|
||||
| 'delete'
|
||||
| 'options'
|
||||
| 'head'
|
||||
| 'all';
|
||||
|
||||
interface TsDecoratorMeta {
|
||||
name: string;
|
||||
firstArg?: string;
|
||||
}
|
||||
|
||||
interface TsParameterMeta {
|
||||
name: string;
|
||||
type: string | null;
|
||||
decorators: TsDecoratorMeta[];
|
||||
}
|
||||
|
||||
interface TsMethodMeta {
|
||||
operationId: string;
|
||||
controller: string;
|
||||
controllerRoute: string;
|
||||
methodName: string;
|
||||
httpMethod: HttpMethod;
|
||||
routePath: string;
|
||||
returnType: string | null;
|
||||
hasPublicDecorator: boolean;
|
||||
methodDecorators: string[];
|
||||
params: TsParameterMeta[];
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
const HTTP_DECORATOR_TO_METHOD: Record<string, HttpMethod> = {
|
||||
Get: 'get',
|
||||
Post: 'post',
|
||||
Put: 'put',
|
||||
Patch: 'patch',
|
||||
Delete: 'delete',
|
||||
Options: 'options',
|
||||
Head: 'head',
|
||||
All: 'all',
|
||||
};
|
||||
|
||||
function getDecorators(node: ts.Node): readonly ts.Decorator[] {
|
||||
return ts.canHaveDecorators(node) ? (ts.getDecorators(node) ?? []) : [];
|
||||
}
|
||||
|
||||
function parseDecorator(
|
||||
decorator: ts.Decorator,
|
||||
sourceFile: ts.SourceFile,
|
||||
): TsDecoratorMeta | null {
|
||||
const expression = decorator.expression;
|
||||
|
||||
if (ts.isIdentifier(expression)) {
|
||||
return { name: expression.text };
|
||||
}
|
||||
|
||||
if (ts.isCallExpression(expression)) {
|
||||
const called = expression.expression;
|
||||
if (!ts.isIdentifier(called)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstArg = expression.arguments[0];
|
||||
let firstArgText: string | undefined;
|
||||
if (firstArg) {
|
||||
if (
|
||||
ts.isStringLiteral(firstArg) ||
|
||||
ts.isNoSubstitutionTemplateLiteral(firstArg)
|
||||
) {
|
||||
firstArgText = firstArg.text;
|
||||
} else {
|
||||
firstArgText = firstArg.getText(sourceFile);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: called.text,
|
||||
firstArg: firstArgText,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectControllerFiles(dirPath: string): string[] {
|
||||
const files: string[] = [];
|
||||
const entries = readdirSync(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...collectControllerFiles(absolutePath));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.endsWith('.controller.ts')) {
|
||||
files.push(absolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function normalizeRoutePart(value: string | undefined): string {
|
||||
if (!value || value === "''" || value === '""') {
|
||||
return '';
|
||||
}
|
||||
return value.trim().replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
function buildSwaggerPath(
|
||||
globalPrefix: string,
|
||||
controllerRoute: string,
|
||||
routePath: string,
|
||||
): string {
|
||||
const parts = [
|
||||
normalizeRoutePart(globalPrefix),
|
||||
normalizeRoutePart(controllerRoute),
|
||||
normalizeRoutePart(routePath),
|
||||
].filter(Boolean);
|
||||
|
||||
return `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
function collectTsEndpointMetadata(
|
||||
projectRoot: string,
|
||||
): Map<string, TsMethodMeta> {
|
||||
const modulesDir = path.join(projectRoot, 'src', 'modules');
|
||||
const controllerFiles = collectControllerFiles(modulesDir);
|
||||
const metadataByOperationId = new Map<string, TsMethodMeta>();
|
||||
|
||||
for (const filePath of controllerFiles) {
|
||||
const sourceText = readFileSync(filePath, 'utf8');
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
|
||||
ts.forEachChild(sourceFile, (node) => {
|
||||
if (!ts.isClassDeclaration(node) || !node.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const className = node.name.text;
|
||||
const classDecorators = getDecorators(node)
|
||||
.map((decorator) => parseDecorator(decorator, sourceFile))
|
||||
.filter((decorator): decorator is TsDecoratorMeta =>
|
||||
Boolean(decorator),
|
||||
);
|
||||
|
||||
const controllerDecorator = classDecorators.find(
|
||||
(decorator) => decorator.name === 'Controller',
|
||||
);
|
||||
if (!controllerDecorator) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controllerRoute = normalizeRoutePart(controllerDecorator.firstArg);
|
||||
|
||||
for (const member of node.members) {
|
||||
if (!ts.isMethodDeclaration(member) || !member.name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const methodDecorators = getDecorators(member)
|
||||
.map((decorator) => parseDecorator(decorator, sourceFile))
|
||||
.filter((decorator): decorator is TsDecoratorMeta =>
|
||||
Boolean(decorator),
|
||||
);
|
||||
|
||||
const httpDecorator = methodDecorators.find(
|
||||
(decorator) => decorator.name in HTTP_DECORATOR_TO_METHOD,
|
||||
);
|
||||
if (!httpDecorator) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const methodName = member.name.getText(sourceFile);
|
||||
const httpMethod = HTTP_DECORATOR_TO_METHOD[httpDecorator.name];
|
||||
const routePath = normalizeRoutePart(httpDecorator.firstArg);
|
||||
const returnType = member.type
|
||||
? member.type.getText(sourceFile).trim()
|
||||
: null;
|
||||
|
||||
const params: TsParameterMeta[] = member.parameters.map((param) => {
|
||||
const paramDecorators = getDecorators(param)
|
||||
.map((decorator) => parseDecorator(decorator, sourceFile))
|
||||
.filter((decorator): decorator is TsDecoratorMeta =>
|
||||
Boolean(decorator),
|
||||
);
|
||||
|
||||
return {
|
||||
name: param.name.getText(sourceFile),
|
||||
type: param.type ? param.type.getText(sourceFile).trim() : null,
|
||||
decorators: paramDecorators,
|
||||
};
|
||||
});
|
||||
|
||||
const operationId = `${className}_${methodName}`;
|
||||
metadataByOperationId.set(operationId, {
|
||||
operationId,
|
||||
controller: className,
|
||||
controllerRoute,
|
||||
methodName,
|
||||
httpMethod,
|
||||
routePath,
|
||||
returnType,
|
||||
hasPublicDecorator: methodDecorators.some(
|
||||
(decorator) => decorator.name === 'Public',
|
||||
),
|
||||
methodDecorators: methodDecorators.map((decorator) => decorator.name),
|
||||
params,
|
||||
filePath,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return metadataByOperationId;
|
||||
}
|
||||
|
||||
function refName(ref?: string): string | null {
|
||||
if (!ref || typeof ref !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const parts = ref.split('/');
|
||||
return parts[parts.length - 1] ?? null;
|
||||
}
|
||||
|
||||
function collectSchemaRefs(
|
||||
value: unknown,
|
||||
refs = new Set<string>(),
|
||||
): Set<string> {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return refs;
|
||||
}
|
||||
|
||||
const recordValue = value as Record<string, unknown>;
|
||||
const maybeRef = recordValue.$ref;
|
||||
if (typeof maybeRef === 'string') {
|
||||
const name = refName(maybeRef);
|
||||
if (name) {
|
||||
refs.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const nested of Object.values(recordValue)) {
|
||||
collectSchemaRefs(nested, refs);
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function schemaTypeSummary(schema: unknown): string {
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const schemaObj = schema as Record<string, unknown>;
|
||||
if (typeof schemaObj.$ref === 'string') {
|
||||
return refName(schemaObj.$ref) ?? 'unknown';
|
||||
}
|
||||
|
||||
const type = typeof schemaObj.type === 'string' ? schemaObj.type : 'object';
|
||||
if (type === 'array') {
|
||||
return `array<${schemaTypeSummary(schemaObj.items)}>`;
|
||||
}
|
||||
|
||||
if (Array.isArray(schemaObj.enum) && schemaObj.enum.length > 0) {
|
||||
return `${type}(${schemaObj.enum.join(' | ')})`;
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
function normalizeParameters(parameters: unknown[] = []) {
|
||||
const parsed = parameters
|
||||
.map((parameter) => parameter as Record<string, unknown>)
|
||||
.filter(Boolean)
|
||||
.map((parameter) => {
|
||||
const schema = (parameter.schema ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
name: typeof parameter.name === 'string' ? parameter.name : '',
|
||||
in: typeof parameter.in === 'string' ? parameter.in : '',
|
||||
required: Boolean(parameter.required),
|
||||
description:
|
||||
typeof parameter.description === 'string'
|
||||
? parameter.description
|
||||
: null,
|
||||
type: schemaTypeSummary(schema),
|
||||
enum: Array.isArray(schema.enum) ? schema.enum : [],
|
||||
default: schema.default ?? null,
|
||||
format: typeof schema.format === 'string' ? schema.format : null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
path: parsed.filter((item) => item.in === 'path'),
|
||||
query: parsed.filter((item) => item.in === 'query'),
|
||||
header: parsed.filter((item) => item.in === 'header'),
|
||||
cookie: parsed.filter((item) => item.in === 'cookie'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRequestBody(requestBody: unknown) {
|
||||
if (!requestBody || typeof requestBody !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestBodyObj = requestBody as Record<string, unknown>;
|
||||
if (typeof requestBodyObj.$ref === 'string') {
|
||||
return {
|
||||
required: false,
|
||||
contentTypes: [],
|
||||
schemaTypes: [],
|
||||
schemaRefs: [refName(requestBodyObj.$ref)].filter(Boolean),
|
||||
raw: requestBodyObj,
|
||||
};
|
||||
}
|
||||
|
||||
const content = (requestBodyObj.content ?? {}) as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const contentTypes = Object.keys(content);
|
||||
const schemaTypes: string[] = [];
|
||||
const refs = new Set<string>();
|
||||
|
||||
for (const mediaType of Object.values(content)) {
|
||||
const schema = mediaType.schema;
|
||||
schemaTypes.push(schemaTypeSummary(schema));
|
||||
collectSchemaRefs(schema, refs);
|
||||
}
|
||||
|
||||
return {
|
||||
required: Boolean(requestBodyObj.required),
|
||||
contentTypes,
|
||||
schemaTypes,
|
||||
schemaRefs: [...refs].sort(),
|
||||
raw: requestBodyObj,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeResponses(responses: Record<string, unknown>) {
|
||||
return Object.entries(responses)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.map(([statusCode, response]) => {
|
||||
const responseObj = response as Record<string, unknown>;
|
||||
const content = (responseObj.content ?? {}) as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const contentTypes = Object.keys(content);
|
||||
const refs = new Set<string>();
|
||||
const schemaTypes: string[] = [];
|
||||
|
||||
for (const mediaType of Object.values(content)) {
|
||||
const schema = mediaType.schema;
|
||||
schemaTypes.push(schemaTypeSummary(schema));
|
||||
collectSchemaRefs(schema, refs);
|
||||
}
|
||||
|
||||
return {
|
||||
status: Number(statusCode),
|
||||
description:
|
||||
typeof responseObj.description === 'string'
|
||||
? responseObj.description
|
||||
: '',
|
||||
contentTypes,
|
||||
schemaTypes,
|
||||
schemaRefs: [...refs].sort(),
|
||||
hasSchema: contentTypes.length > 0,
|
||||
raw: responseObj,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const projectRoot = process.cwd();
|
||||
const outputDir = path.join(projectRoot, 'mds');
|
||||
const outputFile = path.join(
|
||||
outputDir,
|
||||
'backend_endpoints_swagger_summary.json',
|
||||
);
|
||||
|
||||
// Predictions module is conditionally loaded with REDIS_ENABLED in AppModule.
|
||||
// Force-enable here to include all backend endpoints in one Swagger export.
|
||||
process.env.REDIS_ENABLED = 'true';
|
||||
|
||||
const tsMetadata = collectTsEndpointMetadata(projectRoot);
|
||||
|
||||
const app = await NestFactory.create(AppModule, { logger: false });
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('Suggest Bet Backend API')
|
||||
.setDescription('Auto-generated endpoint summary from Swagger document')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
const paths = document.paths ?? {};
|
||||
|
||||
const endpoints: Array<Record<string, unknown>> = [];
|
||||
const seenOperationIds = new Set<string>();
|
||||
const globalPrefix = 'api';
|
||||
|
||||
const sortedPaths = Object.keys(paths).sort((a, b) => a.localeCompare(b));
|
||||
for (const endpointPath of sortedPaths) {
|
||||
const pathItem = paths[endpointPath] as Record<string, unknown>;
|
||||
|
||||
const methods = Object.keys(pathItem)
|
||||
.filter((method) =>
|
||||
['get', 'post', 'put', 'patch', 'delete', 'options', 'head'].includes(
|
||||
method,
|
||||
),
|
||||
)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
for (const method of methods) {
|
||||
const operation = pathItem[method] as Record<string, unknown>;
|
||||
const operationId =
|
||||
typeof operation.operationId === 'string' ? operation.operationId : '';
|
||||
|
||||
if (operationId) {
|
||||
seenOperationIds.add(operationId);
|
||||
}
|
||||
|
||||
const tsMeta = operationId ? tsMetadata.get(operationId) : undefined;
|
||||
const tags = Array.isArray(operation.tags)
|
||||
? operation.tags.map((tag) => String(tag))
|
||||
: [];
|
||||
|
||||
const parameters = normalizeParameters(
|
||||
Array.isArray(operation.parameters) ? operation.parameters : [],
|
||||
);
|
||||
const requestBody = normalizeRequestBody(operation.requestBody);
|
||||
const responses = normalizeResponses(
|
||||
(operation.responses ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
const security = Array.isArray(operation.security)
|
||||
? operation.security
|
||||
: [];
|
||||
const securitySchemes = security.flatMap((rule) =>
|
||||
Object.keys((rule ?? {}) as Record<string, unknown>),
|
||||
);
|
||||
|
||||
const tsBodyParams =
|
||||
tsMeta?.params
|
||||
.filter((param) =>
|
||||
param.decorators.some((decorator) => decorator.name === 'Body'),
|
||||
)
|
||||
.map((param) => ({
|
||||
name: param.name,
|
||||
type: param.type,
|
||||
bodyKey:
|
||||
param.decorators.find((decorator) => decorator.name === 'Body')
|
||||
?.firstArg ?? null,
|
||||
})) ?? [];
|
||||
|
||||
endpoints.push({
|
||||
inSwagger: true,
|
||||
operationId,
|
||||
method: method.toUpperCase(),
|
||||
path: endpointPath,
|
||||
tag: tags[0] ?? null,
|
||||
tags,
|
||||
summary:
|
||||
typeof operation.summary === 'string' ? operation.summary : null,
|
||||
description:
|
||||
typeof operation.description === 'string'
|
||||
? operation.description
|
||||
: null,
|
||||
auth: {
|
||||
swaggerSecurityRequired: security.length > 0,
|
||||
swaggerSecuritySchemes: [...new Set(securitySchemes)].sort(),
|
||||
hasPublicDecorator: tsMeta?.hasPublicDecorator ?? false,
|
||||
},
|
||||
request: {
|
||||
parameters,
|
||||
body: requestBody,
|
||||
tsBodyParams,
|
||||
},
|
||||
response: {
|
||||
tsReturnType: tsMeta?.returnType ?? null,
|
||||
statuses: responses,
|
||||
},
|
||||
source: tsMeta
|
||||
? {
|
||||
controller: tsMeta.controller,
|
||||
methodName: tsMeta.methodName,
|
||||
filePath: path.relative(projectRoot, tsMeta.filePath),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add controller methods that are not present in Swagger document.
|
||||
for (const [operationId, tsMeta] of tsMetadata.entries()) {
|
||||
if (seenOperationIds.has(operationId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
endpoints.push({
|
||||
inSwagger: false,
|
||||
operationId,
|
||||
method: tsMeta.httpMethod.toUpperCase(),
|
||||
path: buildSwaggerPath(
|
||||
globalPrefix,
|
||||
tsMeta.controllerRoute,
|
||||
tsMeta.routePath,
|
||||
),
|
||||
tag: tsMeta.controller.replace(/Controller$/, ''),
|
||||
tags: [tsMeta.controller.replace(/Controller$/, '')],
|
||||
summary: null,
|
||||
description: 'Not present in generated Swagger document',
|
||||
auth: {
|
||||
swaggerSecurityRequired: null,
|
||||
swaggerSecuritySchemes: [],
|
||||
hasPublicDecorator: tsMeta.hasPublicDecorator,
|
||||
},
|
||||
request: {
|
||||
parameters: {
|
||||
path: [],
|
||||
query: [],
|
||||
header: [],
|
||||
cookie: [],
|
||||
},
|
||||
body: null,
|
||||
tsBodyParams: tsMeta.params
|
||||
.filter((param) =>
|
||||
param.decorators.some((decorator) => decorator.name === 'Body'),
|
||||
)
|
||||
.map((param) => ({
|
||||
name: param.name,
|
||||
type: param.type,
|
||||
bodyKey:
|
||||
param.decorators.find((decorator) => decorator.name === 'Body')
|
||||
?.firstArg ?? null,
|
||||
})),
|
||||
},
|
||||
response: {
|
||||
tsReturnType: tsMeta.returnType,
|
||||
statuses: [],
|
||||
},
|
||||
source: {
|
||||
controller: tsMeta.controller,
|
||||
methodName: tsMeta.methodName,
|
||||
filePath: path.relative(projectRoot, tsMeta.filePath),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
endpoints.sort((a, b) => {
|
||||
const pathA = typeof a.path === 'string' ? a.path : '';
|
||||
const pathB = typeof b.path === 'string' ? b.path : '';
|
||||
if (pathA !== pathB) {
|
||||
return pathA.localeCompare(pathB);
|
||||
}
|
||||
return (typeof a.method === 'string' ? a.method : '').localeCompare(
|
||||
typeof b.method === 'string' ? b.method : '',
|
||||
);
|
||||
});
|
||||
|
||||
const tagStats = new Map<string, number>();
|
||||
for (const endpoint of endpoints) {
|
||||
const tag = typeof endpoint.tag === 'string' ? endpoint.tag : 'Unknown';
|
||||
tagStats.set(tag, (tagStats.get(tag) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const referencedSchemas = new Set<string>();
|
||||
for (const endpoint of endpoints) {
|
||||
const requestBody = (endpoint.request as Record<string, unknown>)
|
||||
.body as Record<string, unknown> | null;
|
||||
if (requestBody && Array.isArray(requestBody.schemaRefs)) {
|
||||
for (const schemaName of requestBody.schemaRefs) {
|
||||
if (typeof schemaName === 'string') {
|
||||
referencedSchemas.add(schemaName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const statuses = (endpoint.response as Record<string, unknown>)
|
||||
.statuses as Array<Record<string, unknown>>;
|
||||
for (const status of statuses ?? []) {
|
||||
if (!Array.isArray(status.schemaRefs)) {
|
||||
continue;
|
||||
}
|
||||
for (const schemaName of status.schemaRefs) {
|
||||
if (typeof schemaName === 'string') {
|
||||
referencedSchemas.add(schemaName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allSchemas = (document.components?.schemas ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const schemaSnapshots: Record<string, unknown> = {};
|
||||
for (const schemaName of [...referencedSchemas].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
)) {
|
||||
if (allSchemas[schemaName]) {
|
||||
schemaSnapshots[schemaName] = allSchemas[schemaName];
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedBy: 'src/scripts/export-swagger-endpoints-summary.ts',
|
||||
project: 'Suggest-Bet-BE',
|
||||
swagger: {
|
||||
docsPath: '/api/docs',
|
||||
globalPrefix: '/api',
|
||||
endpointCountInSwagger: endpoints.filter((item) => item.inSwagger).length,
|
||||
endpointCountTotal: endpoints.length,
|
||||
warnings: [
|
||||
'Swagger output reflects loaded modules for current environment.',
|
||||
'This export forces REDIS_ENABLED=true to include conditional Prediction endpoints.',
|
||||
],
|
||||
},
|
||||
stats: {
|
||||
byTag: [...tagStats.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([tag, count]) => ({ tag, count })),
|
||||
endpointsWithoutSummary: endpoints
|
||||
.filter((endpoint) => !endpoint.summary)
|
||||
.map((endpoint) => ({
|
||||
operationId: endpoint.operationId,
|
||||
method: endpoint.method,
|
||||
path: endpoint.path,
|
||||
})),
|
||||
endpointsWithoutResponseSchema: endpoints
|
||||
.filter((endpoint) =>
|
||||
(
|
||||
(endpoint.response as Record<string, unknown>).statuses as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
).some((status) => status.hasSchema === false),
|
||||
)
|
||||
.map((endpoint) => ({
|
||||
operationId: endpoint.operationId,
|
||||
method: endpoint.method,
|
||||
path: endpoint.path,
|
||||
})),
|
||||
},
|
||||
endpoints,
|
||||
referencedSchemas: schemaSnapshots,
|
||||
};
|
||||
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
writeFileSync(outputFile, JSON.stringify(summary, null, 2), 'utf8');
|
||||
|
||||
await app.close();
|
||||
|
||||
console.log(`✅ Swagger endpoint summary exported: ${outputFile}`);
|
||||
|
||||
console.log(
|
||||
` Endpoints in swagger: ${summary.swagger.endpointCountInSwagger}, total (with TS scan): ${summary.swagger.endpointCountTotal}`,
|
||||
);
|
||||
}
|
||||
|
||||
void run().catch((error: unknown) => {
|
||||
console.error('❌ Failed to export Swagger endpoint summary');
|
||||
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -0,0 +1,5 @@
|
||||
process.env.PORT = process.env.PORT || '3005';
|
||||
process.env.AI_ENGINE_URL = process.env.AI_ENGINE_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('./run-full-stack');
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../app.module';
|
||||
import { FeederService } from '../modules/feeder/feeder.service';
|
||||
|
||||
async function bootstrap() {
|
||||
console.log('🏀 Bootstrapping Basketball Feeder...');
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['log', 'error', 'warn', 'debug', 'verbose'],
|
||||
});
|
||||
|
||||
const feederService = app.get(FeederService);
|
||||
|
||||
// Run ONLY for basketball
|
||||
// Adjust start date if needed, otherwise uses default
|
||||
await feederService.runHistoricalScan(['basketball']);
|
||||
|
||||
console.log('✅ Basketball Feeder finished.');
|
||||
await app.close();
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
console.error('❌ Basketball Feeder failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Run Targeted Historical Feeder Script
|
||||
* Fetches matches only for leagues in top_leagues.json for the last ~2.5 seasons.
|
||||
*/
|
||||
|
||||
process.env.FEEDER_MODE = 'historical';
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../app.module';
|
||||
import { FeederService } from '../modules/feeder/feeder.service';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('FeederFilteredScript');
|
||||
logger.log('🚀 Starting Targeted Historical Feeder...');
|
||||
|
||||
// Read top_leagues.json
|
||||
const leaguesPath = path.join(process.cwd(), 'top_leagues.json');
|
||||
let targetLeagues: string[] = [];
|
||||
try {
|
||||
const data = fs.readFileSync(leaguesPath, 'utf8');
|
||||
targetLeagues = JSON.parse(data);
|
||||
// Deduplicate
|
||||
targetLeagues = [...new Set(targetLeagues)];
|
||||
logger.log(
|
||||
`✅ Loaded ${targetLeagues.length} unique target leagues from top_leagues.json`,
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
logger.error(`❌ Failed to load top_leagues.json: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['log', 'error', 'warn'],
|
||||
});
|
||||
|
||||
try {
|
||||
const feederService = app.get(FeederService);
|
||||
// Start from 2023-07-01 to cover 2023-2024, 2024-2025, and current 2025-2026 seasons
|
||||
const START_DATE = '2023-07-01';
|
||||
logger.log(`📅 Date Range: ${START_DATE} -> Today`);
|
||||
|
||||
await feederService.runHistoricalScan(
|
||||
['football'],
|
||||
START_DATE,
|
||||
targetLeagues,
|
||||
);
|
||||
logger.log('✅ Targeted Feeder completed successfully!');
|
||||
} catch (error: any) {
|
||||
logger.error(`❌ Feeder failed: ${error.message}`);
|
||||
logger.error(error.stack);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Run Previous-Day Completed Match Sync
|
||||
* Usage: npm run feeder:historical
|
||||
*/
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { FeederService } from '../modules/feeder/feeder.service';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
async function bootstrap() {
|
||||
process.env.FEEDER_MODE = 'historical';
|
||||
|
||||
const logger = new Logger('FeederScript');
|
||||
|
||||
logger.log('🚀 Starting previous-day completed match sync...');
|
||||
|
||||
// Load AppModule after FEEDER_MODE is set so cron imports can be disabled.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { AppModule } = require('../app.module');
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['log', 'error', 'warn'],
|
||||
});
|
||||
|
||||
try {
|
||||
const feederService = app.get(FeederService);
|
||||
await feederService.runPreviousDayCompletedMatchesScan();
|
||||
logger.log('✅ Previous-day completed match sync completed successfully!');
|
||||
} catch (error: any) {
|
||||
logger.error(`❌ Feeder failed: ${error.message}`);
|
||||
logger.error(error.stack);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,362 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { access } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
interface ManagedProcess {
|
||||
readonly name: string;
|
||||
readonly child: ChildProcess;
|
||||
}
|
||||
|
||||
const ROOT_DIR = process.cwd();
|
||||
const AI_ENGINE_DIR = path.join(ROOT_DIR, 'ai-engine');
|
||||
loadEnvFile(path.join(ROOT_DIR, '.env'));
|
||||
const DEFAULT_AI_URL = process.env.AI_ENGINE_URL ?? 'http://127.0.0.1:8000';
|
||||
const DEFAULT_API_PORT = Number(process.env.PORT ?? '3005');
|
||||
const AI_ENGINE_PORT = resolveAiPort(DEFAULT_AI_URL);
|
||||
const AI_START_TIMEOUT_MS = 120_000;
|
||||
const NEST_START_TIMEOUT_MS = 90_000;
|
||||
const HEALTH_POLL_INTERVAL_MS = 1_000;
|
||||
|
||||
let nestProcess: ManagedProcess | null = null;
|
||||
let aiProcess: ManagedProcess | null = null;
|
||||
let shuttingDown = false;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
ensureWindowsOrUnixShellAwareness();
|
||||
|
||||
const aiHealthUrl = `${DEFAULT_AI_URL}/health`;
|
||||
const aiHost = resolveHost(DEFAULT_AI_URL);
|
||||
const aiAlreadyHealthy = await isHealthy(aiHealthUrl);
|
||||
|
||||
if (aiAlreadyHealthy) {
|
||||
log(`AI engine already running at ${aiHealthUrl}`);
|
||||
} else {
|
||||
const aiPortBusy = await isPortInUse(aiHost, AI_ENGINE_PORT);
|
||||
if (aiPortBusy) {
|
||||
throw new Error(
|
||||
`AI engine port ${AI_ENGINE_PORT} is already in use but ${aiHealthUrl} is not healthy`,
|
||||
);
|
||||
}
|
||||
|
||||
const pythonCommand = await resolvePythonCommand();
|
||||
aiProcess = {
|
||||
name: 'ai-engine',
|
||||
child: spawn(
|
||||
pythonCommand.command,
|
||||
[
|
||||
...pythonCommand.args,
|
||||
'-m',
|
||||
'uvicorn',
|
||||
'main:app',
|
||||
'--host',
|
||||
'0.0.0.0',
|
||||
'--port',
|
||||
String(AI_ENGINE_PORT),
|
||||
...resolveAiExtraArgs(),
|
||||
],
|
||||
{
|
||||
cwd: AI_ENGINE_DIR,
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PORT: String(AI_ENGINE_PORT),
|
||||
},
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
attachExitHandlers(aiProcess);
|
||||
|
||||
log(`Waiting for AI engine health at ${aiHealthUrl}`);
|
||||
await waitForHealth(aiHealthUrl, AI_START_TIMEOUT_MS);
|
||||
log('AI engine is ready');
|
||||
}
|
||||
|
||||
const nestHealthUrl = `http://127.0.0.1:${DEFAULT_API_PORT}/api/health/live`;
|
||||
const nestAlreadyHealthy = await isHealthy(nestHealthUrl);
|
||||
|
||||
if (nestAlreadyHealthy) {
|
||||
log(`NestJS already running at ${nestHealthUrl}`);
|
||||
} else {
|
||||
const nestPortBusy = await isPortInUse('127.0.0.1', DEFAULT_API_PORT);
|
||||
if (nestPortBusy) {
|
||||
throw new Error(
|
||||
`NestJS port ${DEFAULT_API_PORT} is already in use but ${nestHealthUrl} is not healthy`,
|
||||
);
|
||||
}
|
||||
|
||||
nestProcess = {
|
||||
name: 'nest',
|
||||
child: spawnNestProcess(),
|
||||
};
|
||||
|
||||
attachExitHandlers(nestProcess);
|
||||
|
||||
log(`Waiting for NestJS health at ${nestHealthUrl}`);
|
||||
await waitForHealth(nestHealthUrl, NEST_START_TIMEOUT_MS);
|
||||
log('NestJS is ready');
|
||||
}
|
||||
|
||||
log('Full stack is running');
|
||||
}
|
||||
|
||||
function ensureWindowsOrUnixShellAwareness(): void {
|
||||
process.on('SIGINT', () => {
|
||||
void shutdown('SIGINT');
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
void shutdown('SIGTERM');
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error: Error) => {
|
||||
console.error('[full:run] Uncaught exception:', error);
|
||||
void shutdown('uncaughtException', 1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason: unknown) => {
|
||||
console.error('[full:run] Unhandled rejection:', reason);
|
||||
void shutdown('unhandledRejection', 1);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveNestStartScript(): string {
|
||||
return process.env.FULL_RUN_NEST_SCRIPT ?? 'start:dev';
|
||||
}
|
||||
|
||||
function resolveAiExtraArgs(): string[] {
|
||||
return process.env.FULL_RUN_AI_RELOAD === 'true' ? ['--reload'] : [];
|
||||
}
|
||||
|
||||
function spawnNestProcess(): ChildProcess {
|
||||
const nestScript = resolveNestStartScript();
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return spawn('cmd.exe', ['/d', '/s', '/c', `npm run ${nestScript}`], {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
return spawn('npm', ['run', nestScript], {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolvePythonCommand(): Promise<{
|
||||
command: string;
|
||||
args: string[];
|
||||
}> {
|
||||
const explicitPython = process.env.AI_ENGINE_PYTHON?.trim();
|
||||
if (explicitPython) {
|
||||
return { command: explicitPython, args: [] };
|
||||
}
|
||||
|
||||
const localVenvPython =
|
||||
process.platform === 'win32'
|
||||
? path.join(AI_ENGINE_DIR, 'venv', 'Scripts', 'python.exe')
|
||||
: path.join(AI_ENGINE_DIR, 'venv', 'bin', 'python');
|
||||
|
||||
if (await pathExists(localVenvPython)) {
|
||||
return { command: localVenvPython, args: [] };
|
||||
}
|
||||
|
||||
return { command: process.platform === 'win32' ? 'python' : 'python3', args: [] };
|
||||
}
|
||||
|
||||
function resolveAiPort(aiUrl: string): number {
|
||||
const parsedUrl = new URL(aiUrl);
|
||||
|
||||
if (parsedUrl.port) {
|
||||
return Number(parsedUrl.port);
|
||||
}
|
||||
|
||||
return parsedUrl.protocol === 'https:' ? 443 : 80;
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHost(url: string): string {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.hostname;
|
||||
}
|
||||
|
||||
function attachExitHandlers(managedProcess: ManagedProcess): void {
|
||||
managedProcess.child.on('exit', (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail =
|
||||
signal !== null
|
||||
? `signal=${signal}`
|
||||
: `code=${code ?? 'unknown'}`;
|
||||
|
||||
console.error(`[full:run] ${managedProcess.name} exited unexpectedly (${detail})`);
|
||||
void shutdown(`${managedProcess.name}-exit`, code ?? 1);
|
||||
});
|
||||
|
||||
managedProcess.child.on('error', (error: Error) => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`[full:run] Failed to start ${managedProcess.name}:`, error);
|
||||
void shutdown(`${managedProcess.name}-error`, 1);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHealth(url: string, timeoutMs: number): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Service is still booting.
|
||||
}
|
||||
|
||||
await delay(HEALTH_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error(`Health check timed out: ${url}`);
|
||||
}
|
||||
|
||||
async function isHealthy(url: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isPortInUse(host: string, port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host, port });
|
||||
|
||||
socket.once('connect', () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.once('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function shutdown(reason: string, exitCode = 0): Promise<void> {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
shuttingDown = true;
|
||||
log(`Shutting down stack (${reason})`);
|
||||
|
||||
await stopProcess(nestProcess);
|
||||
await stopProcess(aiProcess);
|
||||
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
async function stopProcess(managedProcess: ManagedProcess | null): Promise<void> {
|
||||
if (!managedProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { child, name } = managedProcess;
|
||||
if (child.killed || child.exitCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill('SIGTERM');
|
||||
const stopped = await waitForProcessExit(child, 10_000);
|
||||
if (!stopped) {
|
||||
console.warn(`[full:run] ${name} did not stop gracefully, forcing termination`);
|
||||
child.kill('SIGKILL');
|
||||
await waitForProcessExit(child, 5_000);
|
||||
}
|
||||
}
|
||||
|
||||
function waitForProcessExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
|
||||
const onExit = () => {
|
||||
cleanup();
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
child.off('exit', onExit);
|
||||
};
|
||||
|
||||
child.once('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
function log(message: string): void {
|
||||
console.log(`[full:run] ${message}`);
|
||||
}
|
||||
|
||||
function loadEnvFile(envPath: string): void {
|
||||
try {
|
||||
const content = readFileSync(envPath, 'utf8');
|
||||
const lines = content.split(/\r?\n/u);
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const separatorIndex = trimmed.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, separatorIndex).trim();
|
||||
const rawValue = trimmed.slice(separatorIndex + 1).trim();
|
||||
const normalizedValue = rawValue.replace(/^['"]|['"]$/gu, '');
|
||||
|
||||
if (!process.env[key]) {
|
||||
process.env[key] = normalizedValue;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// .env is optional for this script.
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error: Error) => {
|
||||
console.error('[full:run] Startup failed:', error);
|
||||
void shutdown('startup-failed', 1);
|
||||
});
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../app.module';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { DataFetcherTask } from '../tasks/data-fetcher.task';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('LiveFeederShell');
|
||||
console.log('🚀 Starting Manual Live Feeder Update (Console)...');
|
||||
logger.log('🚀 Starting Manual Live Feeder Update...');
|
||||
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['log', 'error', 'warn'],
|
||||
});
|
||||
|
||||
try {
|
||||
const dataFetcherTask = app.get(DataFetcherTask);
|
||||
|
||||
// 1. Fetch Soccer Matches (4-day full sync: today + 3 days ahead)
|
||||
logger.log('⚽ Fetching soccer live matches (4-day window)...');
|
||||
await dataFetcherTask.fetchLiveMatchesFull();
|
||||
|
||||
// 2. Fetch Basketball Matches
|
||||
logger.log('🏀 Fetching basketball live matches...');
|
||||
await dataFetcherTask.fetchBasketballMatches();
|
||||
|
||||
// 3. Fetch Odds for all live matches
|
||||
logger.log('📊 Fetching odds for all live matches...');
|
||||
await dataFetcherTask.fetchOddsForPreMatches();
|
||||
|
||||
// 4. Fetch Lineups & Sidelined (NEW)
|
||||
logger.log('👕 Fetching lineups & sidelined for active matches...');
|
||||
await dataFetcherTask.updateLineupsAndSidelined();
|
||||
|
||||
logger.log('✅ Live Feeder update completed successfully!');
|
||||
} catch (error: any) {
|
||||
logger.error(`❌ Live Feeder failed: ${error.message}`);
|
||||
console.error(error.stack);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
Reference in New Issue
Block a user