@@ -21,6 +21,10 @@ import {
|
||||
} from "./dto";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { FeederService } from "../feeder/feeder.service";
|
||||
import {
|
||||
isMatchCompleted,
|
||||
isMatchLive,
|
||||
} from "../../common/utils/match-status.util";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
@@ -49,7 +53,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
private queueEvents: QueueEvents | null = null;
|
||||
private readonly aiEngineUrl: string;
|
||||
private readonly aiEngineClient: AiEngineClient;
|
||||
private readonly topLeagueIds = new Set<string>();
|
||||
private readonly qualifiedLeagueIds = new Set<string>();
|
||||
private readonly reasonTranslations: Record<string, string> = {
|
||||
confidence_below_threshold: "Güven eşiğin altında",
|
||||
confidence_interval_too_wide: "Güven aralığı çok geniş",
|
||||
@@ -137,7 +141,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
maxRetries: 2,
|
||||
retryDelayMs: 750,
|
||||
});
|
||||
this.topLeagueIds = this.loadTopLeagueIds();
|
||||
this.qualifiedLeagueIds = this.loadQualifiedLeagueIds();
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
@@ -155,6 +159,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private predictionMemCache = new Map<
|
||||
string,
|
||||
{ timestamp: number; payload: MatchPredictionDto }
|
||||
>();
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.queueEvents) {
|
||||
await this.queueEvents.close();
|
||||
@@ -177,8 +186,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
return {
|
||||
status: response.data?.status || "healthy",
|
||||
modelLoaded: response.data?.model_loaded ?? true,
|
||||
predictionServiceReady:
|
||||
response.data?.prediction_service_ready ?? true,
|
||||
predictionServiceReady: response.data?.prediction_service_ready ?? true,
|
||||
aiEngineReachable: true,
|
||||
circuitState: circuit.state,
|
||||
consecutiveFailures: circuit.consecutiveFailures,
|
||||
@@ -330,33 +338,38 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
match_date_ms: Number(p.match.mstUtc) * 1000,
|
||||
league: p.match.league?.name || "",
|
||||
league_id: p.match.leagueId,
|
||||
is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ""),
|
||||
is_top_league: this.qualifiedLeagueIds.has(p.match.leagueId ?? ""),
|
||||
},
|
||||
} as unknown as MatchPredictionDto;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private loadTopLeagueIds(): Set<string> {
|
||||
private loadQualifiedLeagueIds(): Set<string> {
|
||||
try {
|
||||
const topLeaguesPath = path.join(process.cwd(), "top_leagues.json");
|
||||
if (!fs.existsSync(topLeaguesPath)) {
|
||||
const filePath = path.join(process.cwd(), "qualified_leagues.json");
|
||||
if (!fs.existsSync(filePath)) {
|
||||
this.logger.warn(
|
||||
"qualified_leagues.json not found — all leagues allowed",
|
||||
);
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8"));
|
||||
const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
if (!Array.isArray(raw)) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return new Set(
|
||||
const ids = new Set(
|
||||
raw
|
||||
.map((value) => String(value).trim())
|
||||
.filter((value) => value.length > 0),
|
||||
);
|
||||
this.logger.log(`Loaded ${ids.size} qualified league IDs`);
|
||||
return ids;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Failed to load top_leagues.json: ${message}`);
|
||||
this.logger.warn(`Failed to load qualified_leagues.json: ${message}`);
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
@@ -370,7 +383,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
if (match) {
|
||||
return {
|
||||
leagueId: match.leagueId ?? null,
|
||||
isTopLeague: this.topLeagueIds.has(match.leagueId ?? ""),
|
||||
isTopLeague: this.qualifiedLeagueIds.has(match.leagueId ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -381,7 +394,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
return {
|
||||
leagueId: liveMatch?.leagueId ?? null,
|
||||
isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ""),
|
||||
isTopLeague: this.qualifiedLeagueIds.has(liveMatch?.leagueId ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -731,20 +744,20 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.reasonTranslations[normalized];
|
||||
}
|
||||
|
||||
const evMatch = normalized.match(/^ev_edge_([+\-][\d.]+%)_grade_(\w)$/);
|
||||
const evMatch = normalized.match(/^ev_edge_([-+][\d.]+%)_grade_(\w)$/);
|
||||
if (evMatch) {
|
||||
return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`;
|
||||
}
|
||||
|
||||
const negativeEdgeMatch = normalized.match(
|
||||
/^negative_model_edge_([+\-]?[\d.]+)$/,
|
||||
/^negative_model_edge_([-+]?[\d.]+)$/,
|
||||
);
|
||||
if (negativeEdgeMatch) {
|
||||
return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
|
||||
}
|
||||
|
||||
const edgeThresholdMatch = normalized.match(
|
||||
/^below_market_edge_threshold_([+\-]?[\d.]+)$/,
|
||||
/^below_market_edge_threshold_([-+]?[\d.]+)$/,
|
||||
);
|
||||
if (edgeThresholdMatch) {
|
||||
return `Piyasa avantaj eşiğinin altında (${edgeThresholdMatch[1]})`;
|
||||
@@ -1071,10 +1084,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
// Direct HTTP mode
|
||||
try {
|
||||
const response = await this.aiEngineClient.post(
|
||||
"/smart-coupon",
|
||||
{ match_ids: matchIds, strategy, ...options },
|
||||
);
|
||||
const response = await this.aiEngineClient.post("/smart-coupon", {
|
||||
match_ids: matchIds,
|
||||
strategy,
|
||||
...options,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
@@ -1130,8 +1144,26 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
async cachePrediction(matchId: string, prediction: MatchPredictionDto) {
|
||||
this.predictionMemCache.set(matchId, {
|
||||
timestamp: Date.now(),
|
||||
payload: prediction,
|
||||
});
|
||||
if (this.predictionMemCache.size > 500) {
|
||||
const firstKey = this.predictionMemCache.keys().next().value;
|
||||
if (firstKey) this.predictionMemCache.delete(firstKey);
|
||||
}
|
||||
|
||||
const payload = prediction as unknown as Prisma.InputJsonObject;
|
||||
try {
|
||||
const existsInMatch = await this.prisma.match.findUnique({
|
||||
where: { id: matchId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!existsInMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.prediction.upsert({
|
||||
where: { matchId },
|
||||
update: {
|
||||
@@ -1151,6 +1183,16 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
async getCachedPrediction(
|
||||
matchId: string,
|
||||
): Promise<MatchPredictionDto | null> {
|
||||
const memCached = this.predictionMemCache.get(matchId);
|
||||
if (memCached) {
|
||||
if (Date.now() - memCached.timestamp < 10 * 60 * 1000) {
|
||||
// 10 mins TTL
|
||||
return memCached.payload;
|
||||
} else {
|
||||
this.predictionMemCache.delete(matchId);
|
||||
}
|
||||
}
|
||||
|
||||
const prediction = await this.prisma.prediction.findUnique({
|
||||
where: { matchId },
|
||||
});
|
||||
@@ -1216,32 +1258,38 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async ensurePredictionDataReady(matchId: string): Promise<void> {
|
||||
const [liveMatch, persistedMatch, oddCategoryCount] = await Promise.all([
|
||||
this.prisma.liveMatch.findUnique({
|
||||
where: { id: matchId },
|
||||
select: {
|
||||
id: true,
|
||||
odds: true,
|
||||
state: true,
|
||||
status: true,
|
||||
scoreHome: true,
|
||||
scoreAway: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.match.findUnique({
|
||||
where: { id: matchId },
|
||||
select: {
|
||||
id: true,
|
||||
state: true,
|
||||
status: true,
|
||||
scoreHome: true,
|
||||
scoreAway: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.oddCategory.count({
|
||||
where: { matchId },
|
||||
}),
|
||||
]);
|
||||
const [liveMatch, persistedMatch, oddCategoryCount, lineupCount] =
|
||||
await Promise.all([
|
||||
this.prisma.liveMatch.findUnique({
|
||||
where: { id: matchId },
|
||||
select: {
|
||||
id: true,
|
||||
odds: true,
|
||||
state: true,
|
||||
status: true,
|
||||
scoreHome: true,
|
||||
scoreAway: true,
|
||||
leagueId: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.match.findUnique({
|
||||
where: { id: matchId },
|
||||
select: {
|
||||
id: true,
|
||||
state: true,
|
||||
status: true,
|
||||
scoreHome: true,
|
||||
scoreAway: true,
|
||||
leagueId: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.oddCategory.count({
|
||||
where: { matchId },
|
||||
}),
|
||||
this.prisma.matchPlayerParticipation.count({
|
||||
where: { matchId },
|
||||
}),
|
||||
]);
|
||||
|
||||
const hasLiveOdds =
|
||||
!!liveMatch?.odds &&
|
||||
@@ -1257,27 +1305,68 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
// League qualification gate: reject predictions for leagues without
|
||||
// sufficient historical training data (odds + lineups + stats)
|
||||
const leagueId = liveMatch?.leagueId || persistedMatch?.leagueId;
|
||||
if (
|
||||
this.qualifiedLeagueIds.size > 0 &&
|
||||
(!leagueId || !this.qualifiedLeagueIds.has(leagueId))
|
||||
) {
|
||||
throw new HttpException(
|
||||
`Bu lig için yeterli geçmiş veri bulunmuyor. Tahmin yapılamaz.`,
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
}
|
||||
|
||||
const state = liveMatch?.state || persistedMatch?.state;
|
||||
const status = liveMatch?.status || persistedMatch?.status;
|
||||
const scoreHome = liveMatch?.scoreHome ?? persistedMatch?.scoreHome;
|
||||
const scoreAway = liveMatch?.scoreAway ?? persistedMatch?.scoreAway;
|
||||
const hasScores =
|
||||
scoreHome !== null &&
|
||||
scoreHome !== undefined &&
|
||||
scoreAway !== null &&
|
||||
scoreAway !== undefined;
|
||||
|
||||
const isFinished =
|
||||
hasScores ||
|
||||
state === "MS" ||
|
||||
state === "postGame" ||
|
||||
["Finished", "Played", "FT", "AET", "PEN", "Ended"].includes(
|
||||
status as string,
|
||||
);
|
||||
const isFinished = isMatchCompleted({
|
||||
state: state ?? null,
|
||||
status: status ?? null,
|
||||
scoreHome,
|
||||
scoreAway,
|
||||
});
|
||||
|
||||
const isLive = isMatchLive({
|
||||
state: state ?? null,
|
||||
status: status ?? null,
|
||||
});
|
||||
|
||||
const hasOdds = hasLiveOdds || oddCategoryCount > 0;
|
||||
|
||||
if (hasOdds || isFinished) {
|
||||
if (hasOdds || isFinished || isLive) {
|
||||
// ── Lineup guard: fetch lineups if missing before analysis ──
|
||||
// A proper football lineup has at least 11 starting players (22 total
|
||||
// with subs). If we have fewer than 11 participation records, the
|
||||
// lineup data is likely missing — attempt to fetch it from source.
|
||||
if (lineupCount < 11) {
|
||||
this.logger.log(
|
||||
`[${matchId}] ⚠️ Lineups missing (${lineupCount} players in DB). Fetching from source before analysis...`,
|
||||
);
|
||||
try {
|
||||
const refreshResult = await this.feederService.refreshMatch(
|
||||
matchId,
|
||||
"lineups",
|
||||
);
|
||||
if (refreshResult.success) {
|
||||
this.logger.log(
|
||||
`[${matchId}] ✅ Lineups fetched successfully before analysis`,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`[${matchId}] ⚠️ Lineup fetch returned failure — proceeding with existing data. Error: ${refreshResult.error ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`[${matchId}] ⚠️ Lineup fetch exception — proceeding with existing data. ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1315,7 +1404,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Prediction run audit skipped for ${matchId}: ${message}`);
|
||||
this.logger.warn(
|
||||
`Prediction run audit skipped for ${matchId}: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1397,8 +1488,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
: null,
|
||||
bet_advice: {
|
||||
playable: payload.bet_advice?.playable ?? false,
|
||||
suggested_stake_units:
|
||||
payload.bet_advice?.suggested_stake_units ?? 0,
|
||||
suggested_stake_units: payload.bet_advice?.suggested_stake_units ?? 0,
|
||||
reason: payload.bet_advice?.reason ?? null,
|
||||
},
|
||||
top_summary: topSummary,
|
||||
|
||||
Reference in New Issue
Block a user