Files
iddaai-be/src/modules/predictions/predictions.service.ts
T
2026-05-10 10:37:45 +03:00

1645 lines
52 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Injectable,
Logger,
HttpException,
HttpStatus,
OnModuleDestroy,
OnModuleInit,
Optional,
} from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import { ConfigService } from "@nestjs/config";
import { QueueEvents } from "bullmq";
import { PredictionsQueue } from "./queues/predictions.queue";
import { PREDICTIONS_QUEUE } from "./queues/predictions.types";
import {
MatchPredictionDto,
PredictionHistoryResponseDto,
UpcomingPredictionsDto,
ValueBetDto,
AIHealthDto,
} 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 {
AiEngineClient,
AiEngineRequestError,
} from "../../common/utils/ai-engine-client";
type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW";
interface ConfidenceInterval {
lower: number;
upper: number;
width: number;
band: ConfidenceBand;
threshold_met: boolean;
}
interface MatchContext {
leagueId: string | null;
isTopLeague: boolean;
}
@Injectable()
export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PredictionsService.name);
private queueEvents: QueueEvents | null = null;
private readonly aiEngineUrl: string;
private readonly aiEngineClient: AiEngineClient;
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ş",
confidence_interval_too_wide_for_main_pick:
"Ana seçim için güven aralığı çok geniş",
confidence_band_low: "Güven bandı düşük",
playable_edge_found: "Model avantaj sinyali bulundu",
market_signal_dominant: "Piyasa sinyali baskın",
team_form_signal_dominant: "Takım formuna dayalı sinyaller çok baskın",
lineup_signal_strong: "İlk on bir sinyali güçlü",
lineup_signal_weak: "İlk on bir sinyali zayıf",
lineup_probable_xi_used: "Muhtemel ilk on bir kullanıldı",
lineup_probable_not_confirmed: "Muhtemel ilk on bir henüz doğrulanmadı",
lineup_unavailable: "İlk on bir bilgisi mevcut değil",
lineup_incomplete: "İlk on bir bilgisi eksik",
missing_referee: "Hakem verisi eksik",
draw_probability_elevated: "Beraberlik olasılığı yükselmiş görünüyor",
balanced_match_risk: "Maç dengeli, sürpriz riski yükseliyor",
draw_pressure: "Beraberlik baskısı yüksek",
upset_risk_detected: "Sürpriz riski tespit edildi",
limited_data_confidence: "Veri kısıtlı olduğu için güven sınırlı",
data_quality_issue: "Veri kalitesi sorunu var",
high_risk_low_data_quality: "Risk yüksek, veri kalitesi düşük",
insufficient_play_score: "Model sinyali yetersiz",
odds_band_confirms_value: "Tarihsel oran bandı değeri doğruluyor",
odds_band_sample_too_low: "Tarihsel oran bandı örneklemi yetersiz",
odds_band_missing_probability: "Tarihsel oran bandı olasılığı yok",
odds_band_unavailable: "Tarihsel oran bandı kullanılamıyor",
odds_band_not_aligned: "Model ve tarihsel oran bandı aynı yönde değil",
no_bet_conditions_met: "Bahis koşulları oluşmadı",
market_passed_all_gates: "Market tüm güvenlik kontrollerini geçti",
no_ev_edge_minimum_stake:
"Beklenen avantaj oluşmadı, minimum bahis önerildi",
player_form_signal_strong: "Oyuncu formu sinyali güçlü",
player_form_signal_limited: "Oyuncu formu sinyali sınırlı",
live_state_impossible_market:
"Canlı maç durumu bu marketi geçersiz kılıyor",
live_score_exceeds_under_line: "Mevcut skor bu alt seçeneğiyle çelişiyor",
score_model_conflicts_with_under_pick:
"Skor modeli alt seçeneğiyle çelişiyor",
score_model_conflicts_with_over_pick:
"Skor modeli üst seçeneğiyle çelişiyor",
market_stack_conflict_over25:
"Üst 2.5 sinyaliyle çeliştiği için zayıflatıldı",
market_stack_conflict_btts:
"Karşılıklı gol sinyaliyle çeliştiği için zayıflatıldı",
first_half_result_conflicts_with_goalless_half:
"İlk yarı sonucu beklentisi golsüz ilk yarıyla çelişiyor",
first_half_htft_conflicts_with_goalless_half:
"İlk yarı/maç sonu beklentisi golsüz ilk yarıyla çelişiyor",
first_half_draw_conflicts_with_goal_pick:
"İlk yarı beraberlik baskısı erken gol beklentisiyle çelişiyor",
first_half_goalless_conflicts_with_result_pick:
"Golsüz ilk yarı beklentisi ilk yarı sonuç seçimiyle çelişiyor",
first_half_goalless_conflicts_with_htft_pick:
"Golsüz ilk yarı beklentisi ilk yarı/maç sonu seçimiyle çelişiyor",
first_half_goal_pressure_conflicts_with_htft_draw:
"İlk yarı gol baskısı ilk yarı beraberlik kurgusuyla çelişiyor",
live_total_goals_close_to_line: "Canlı toplam gol çizgisine çok yakın",
score_model_conflicts_with_btts_no:
"Skor modeli KG Yok seçeneğiyle çelişiyor",
score_model_conflicts_with_draw_pick:
"Skor modeli beraberlik seçeneğiyle çelişiyor",
score_model_conflicts_with_home_pick:
"Skor modeli ev sahibi seçeneğiyle çelişiyor",
score_model_conflicts_with_away_pick:
"Skor modeli deplasman seçeneğiyle çelişiyor",
high_total_goal_volatility: "Toplam gol volatilitesi yüksek",
mutual_goal_pressure: "İki takımın da gol baskısı yüksek",
late_goal_swing_risk: "Geç gol kaynaklı kırılma riski var",
live_match_open_state: "Canlı maç açık oyuna dönmüş durumda",
live_match_active_state: "Canlı maç aktif ve dalgalı ilerliyor",
};
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
private readonly feederService: FeederService,
@Optional() private readonly predictionsQueue?: PredictionsQueue,
) {
this.aiEngineUrl = this.resolveAiEngineUrl();
this.aiEngineClient = new AiEngineClient({
baseUrl: this.aiEngineUrl,
logger: this.logger,
serviceName: PredictionsService.name,
timeoutMs: 60000,
maxRetries: 2,
retryDelayMs: 750,
});
this.qualifiedLeagueIds = this.loadQualifiedLeagueIds();
}
onModuleInit() {
if (this.predictionsQueue) {
this.queueEvents = new QueueEvents(PREDICTIONS_QUEUE, {
connection: {
host: this.configService.get("redis.host", "localhost"),
port: this.configService.get("redis.port", 6379),
password: this.configService.get("redis.password"),
},
});
this.logger.log("Queue mode enabled for predictions");
} else {
this.logger.log("Direct HTTP mode enabled for predictions (no Redis)");
}
}
private predictionMemCache = new Map<
string,
{ timestamp: number; payload: MatchPredictionDto }
>();
async onModuleDestroy() {
if (this.queueEvents) {
await this.queueEvents.close();
}
}
async checkHealth(): Promise<AIHealthDto> {
const circuit = this.aiEngineClient.getSnapshot();
try {
const response = await this.aiEngineClient.get<{
status?: string;
model_loaded?: boolean;
prediction_service_ready?: boolean;
}>("/health", {
timeout: 5000,
retryCount: 0,
});
return {
status: response.data?.status || "healthy",
modelLoaded: response.data?.model_loaded ?? true,
predictionServiceReady: response.data?.prediction_service_ready ?? true,
aiEngineReachable: true,
circuitState: circuit.state,
consecutiveFailures: circuit.consecutiveFailures,
endpoint: this.aiEngineUrl,
mode:
typeof (response.data as Record<string, unknown>)?.mode === "string"
? String((response.data as Record<string, unknown>).mode)
: this.configService.get("AI_ENGINE_MODE", "v28-pro-max"),
};
} catch (error: unknown) {
const requestError =
error instanceof AiEngineRequestError
? error
: new AiEngineRequestError("AI health check failed");
return {
status: requestError.isCircuitOpen ? "circuit_open" : "unhealthy",
modelLoaded: false,
predictionServiceReady: false,
aiEngineReachable: false,
circuitState: this.aiEngineClient.getSnapshot().state,
consecutiveFailures:
this.aiEngineClient.getSnapshot().consecutiveFailures,
endpoint: this.aiEngineUrl,
detail:
typeof requestError.detail === "string"
? requestError.detail
: requestError.message,
mode: this.configService.get("AI_ENGINE_MODE", "v28-pro-max"),
};
}
}
async getPredictionById(matchId: string): Promise<MatchPredictionDto | null> {
await this.ensurePredictionDataReady(matchId);
const matchContext = await this.getMatchContext(matchId);
// Queue mode (Redis enabled) - REMOVED per user request to always fetch from scratch
// Direct HTTP mode (no Redis)
try {
const response = await this.aiEngineClient.post<MatchPredictionDto>(
`/v20plus/analyze/${matchId}`,
{ simulate: true, is_simulation: true, pre_match_only: true },
);
const prediction = this.enrichPredictionResponse(
response.data,
matchContext,
);
await this.recordPredictionRun(matchId, response.data);
await this.cachePrediction(matchId, prediction);
return prediction;
} catch (e: unknown) {
const requestError =
e instanceof AiEngineRequestError
? e
: new AiEngineRequestError("AI Engine request failed");
const status = requestError.status;
const detail = requestError.detail || requestError.message;
// ── Cooldown fallback cascade: memCache → DB stored → DB cached → wait & retry ──
if (
status === HttpStatus.SERVICE_UNAVAILABLE &&
this.hasCooldown(detail)
) {
// 1) In-memory cache (10min TTL)
const memCached = this.predictionMemCache.get(matchId);
if (memCached && Date.now() - memCached.timestamp < 10 * 60 * 1000) {
this.logger.warn(
`AI Engine cooldown for ${matchId}; returning mem-cached prediction`,
);
return memCached.payload;
}
// 2) DB stored prediction (no TTL filter)
const storedPrediction = await this.getStoredPrediction(matchId);
if (storedPrediction) {
this.logger.warn(
`AI Engine cooldown for ${matchId}; returning stored prediction`,
);
return this.enrichPredictionResponse(storedPrediction, matchContext);
}
// 3) DB cached prediction (with model version check)
const cachedPrediction = await this.getCachedPrediction(matchId);
if (cachedPrediction) {
this.logger.warn(
`AI Engine cooldown for ${matchId}; returning cached prediction`,
);
return this.enrichPredictionResponse(cachedPrediction, matchContext);
}
// 4) No cached data at all — return null gracefully
this.logger.warn(
`AI Engine cooldown for ${matchId}; no cached data available — returning null gracefully`,
);
return null;
}
// ── Non-cooldown errors (e.g. AI Engine 500 for this match) ──
// Try DB fallback before giving up
const storedFallback = await this.getStoredPrediction(matchId);
if (storedFallback) {
this.logger.warn(
`AI Engine failed for ${matchId} (status=${status}); returning stored prediction as fallback`,
);
return this.enrichPredictionResponse(storedFallback, matchContext);
}
const cachedFallback = await this.getCachedPrediction(matchId);
if (cachedFallback) {
this.logger.warn(
`AI Engine failed for ${matchId} (status=${status}); returning cached prediction as fallback`,
);
return this.enrichPredictionResponse(cachedFallback, matchContext);
}
this.logger.error(
`Direct AI Engine call failed for ${matchId}: status=${status}, detail=${JSON.stringify(detail)}`,
);
// Forward AI Engine's actual error for client-meaningful statuses
if (status === 404) {
throw new HttpException(
`Match not found in AI Engine: ${matchId}`,
HttpStatus.NOT_FOUND,
);
}
if (status === 422) {
throw new HttpException(
`AI Engine: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`,
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
// For server errors (500, 503 etc.) return null instead of throwing
// This prevents the user from seeing raw 503 errors
this.logger.warn(
`AI Engine server error for ${matchId}; returning null gracefully instead of ${status}`,
);
return null;
}
}
async getPredictionWithData(matchDetails: {
matchId: string;
}): Promise<MatchPredictionDto | null> {
return this.getPredictionById(matchDetails.matchId);
}
async testPrediction(matchId: string): Promise<MatchPredictionDto | null> {
this.logger.log(`[TEST PREDICTION] Syncing match data for ${matchId}...`);
// refreshMatch triggers the feeder scraper to get all match info, odds, and lineups and write to DB
const refreshResult = await this.feederService.refreshMatch(matchId, "all");
if (!refreshResult.success) {
this.logger.warn(
`Failed to refresh match ${matchId} before test prediction. Proceeding with existing data anyway.`,
);
} else {
this.logger.log(
`Successfully refreshed match ${matchId}. Calling AI Engine...`,
);
}
return this.getPredictionById(matchId);
}
async getUpcomingPredictions(): Promise<UpcomingPredictionsDto> {
const upcoming = await this.prisma.prediction.findMany({
where: {
match: {
status: "NS",
mstUtc: { gte: Math.floor(Date.now() / 1000) },
},
},
include: {
match: {
include: { homeTeam: true, awayTeam: true, league: true },
},
},
orderBy: { match: { mstUtc: "asc" } },
take: 50,
});
return {
count: upcoming.length,
modelVersion: "v28-pro-max",
matches: upcoming.map((p) => {
const out = p.predictionJson as Record<string, unknown>;
const matchInfo = (out?.match_info || {}) as Record<string, unknown>;
return {
...out,
match_info: {
...matchInfo,
match_name: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`,
match_date_ms: Number(p.match.mstUtc) * 1000,
league: p.match.league?.name || "",
league_id: p.match.leagueId,
is_top_league: this.qualifiedLeagueIds.has(p.match.leagueId ?? ""),
},
} as unknown as MatchPredictionDto;
}),
};
}
private loadQualifiedLeagueIds(): Set<string> {
try {
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(filePath, "utf8"));
if (!Array.isArray(raw)) {
return new Set<string>();
}
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 qualified_leagues.json: ${message}`);
return new Set<string>();
}
}
private resolveAiEngineUrl(): string {
const configuredUrl = this.configService.get(
"AI_ENGINE_URL",
"http://localhost:8000",
);
const localEnvUrl = this.readLocalEnvValue("AI_ENGINE_URL");
if (
process.env.NODE_ENV !== "production" &&
localEnvUrl &&
localEnvUrl !== configuredUrl &&
this.isLocalhostUrl(configuredUrl) &&
this.isLocalhostUrl(localEnvUrl)
) {
this.logger.warn(
`AI_ENGINE_URL inherited from parent process (${configuredUrl}) differs from .env.local (${localEnvUrl}); using .env.local for local development`,
);
return localEnvUrl;
}
return configuredUrl;
}
private readLocalEnvValue(key: string): string | null {
const filePath = path.join(process.cwd(), ".env.local");
if (!fs.existsSync(filePath)) {
return null;
}
const line = fs
.readFileSync(filePath, "utf8")
.split(/\r?\n/u)
.find((entry) => entry.trim().startsWith(`${key}=`));
if (!line) {
return null;
}
return line
.slice(line.indexOf("=") + 1)
.trim()
.replace(/^['"]|['"]$/gu, "");
}
private isLocalhostUrl(value: string): boolean {
try {
const url = new URL(value);
return ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
} catch {
return false;
}
}
private async getMatchContext(matchId: string): Promise<MatchContext> {
const match = await this.prisma.match.findUnique({
where: { id: matchId },
select: { leagueId: true },
});
if (match) {
return {
leagueId: match.leagueId ?? null,
isTopLeague: this.qualifiedLeagueIds.has(match.leagueId ?? ""),
};
}
const liveMatch = await this.prisma.liveMatch.findUnique({
where: { id: matchId },
select: { leagueId: true },
});
return {
leagueId: liveMatch?.leagueId ?? null,
isTopLeague: this.qualifiedLeagueIds.has(liveMatch?.leagueId ?? ""),
};
}
private enrichPredictionResponse(
prediction: MatchPredictionDto,
matchContext: MatchContext,
): MatchPredictionDto {
const response = prediction as MatchPredictionDto & Record<string, unknown>;
const dataQuality = this.asRecord(response.data_quality);
const risk = this.asRecord(response.risk);
const marketBoard = this.asRecord(response.market_board);
const matchInfo = {
...this.asRecord(response.match_info),
league_id:
this.asRecord(response.match_info).league_id ?? matchContext.leagueId,
is_top_league:
this.asRecord(response.match_info).is_top_league ??
matchContext.isTopLeague,
};
const mainPick = this.enrichPick(
response.main_pick,
response,
matchContext,
marketBoard,
);
const valuePick = this.enrichPick(
response.value_pick,
response,
matchContext,
marketBoard,
);
const aggressivePick = this.enrichPick(
response.aggressive_pick,
response,
matchContext,
marketBoard,
);
const supportingPicks = Array.isArray(response.supporting_picks)
? response.supporting_picks
.map((pick) =>
this.enrichPick(pick, response, matchContext, marketBoard),
)
.filter((pick): pick is NonNullable<typeof pick> => pick !== null)
: [];
const betSummary = Array.isArray(response.bet_summary)
? response.bet_summary.map((item) =>
this.enrichSummary(item, response, matchContext, marketBoard),
)
: [];
const mainBand = this.asRecord(mainPick?.confidence_interval).band ?? "LOW";
const minConfidenceForPlay = this.getMinConfidenceForPlay(
this.asRecord(mainPick).market,
matchContext.isTopLeague,
);
const isMainPlayable =
Boolean(this.asRecord(mainPick).playable) &&
Boolean(this.asRecord(mainPick?.confidence_interval).threshold_met);
const mainSignalTier = this.classifySignalTier(
this.asRecord(mainPick),
this.asRecord(mainPick?.confidence_interval),
);
const reasoningFactors = Array.isArray(response.reasoning_factors)
? response.reasoning_factors.map((reason) =>
this.translateReason(String(reason)),
)
: [];
if (mainPick && !isMainPlayable) {
reasoningFactors.unshift(
this.translateReason("confidence_interval_too_wide_for_main_pick"),
);
}
const betAdvice = {
...this.asRecord(response.bet_advice),
playable: isMainPlayable,
confidence_band: mainBand,
min_confidence_for_play: minConfidenceForPlay,
signal_tier: mainSignalTier,
reason: this.translateReason(
isMainPlayable
? String(
this.asRecord(response.bet_advice).reason ||
"playable_edge_found",
)
: "confidence_below_threshold",
),
suggested_stake_units: isMainPlayable
? Number(this.asRecord(response.bet_advice).suggested_stake_units ?? 0)
: 0,
};
const enrichedMarketBoard = Object.fromEntries(
Object.entries(marketBoard).map(([market, entry]) => {
const record = this.asRecord(entry);
const pickName = String(record.pick ?? "");
if (!pickName || !record.probs || typeof record.probs !== "object") {
return [market, record];
}
const syntheticPick = {
market,
pick: pickName,
probability: this.lookupProbability(
record.probs as Record<string, unknown>,
pickName,
),
confidence: Number(record.confidence ?? 0),
calibrated_confidence: Number(record.confidence ?? 0),
raw_confidence: Number(record.confidence ?? 0),
min_required_confidence: 0,
odds: 0,
edge: 0,
ev_edge: 0,
implied_prob: 0,
play_score: 0,
playable: false,
bet_grade: "PASS",
stake_units: 0,
decision_reasons: [],
};
const enriched = this.enrichPick(
syntheticPick,
response,
matchContext,
marketBoard,
);
return [
market,
{
...record,
confidence_interval: this.asRecord(enriched?.confidence_interval),
confidence_band:
this.asRecord(enriched?.confidence_interval).band ?? "LOW",
},
];
}),
);
return {
...response,
match_info: matchInfo as MatchPredictionDto["match_info"],
data_quality: {
...dataQuality,
lineup_source: String(dataQuality.lineup_source ?? "none"),
} as MatchPredictionDto["data_quality"],
risk: {
...risk,
surprise_type: this.translateReason(String(risk.surprise_type ?? "")),
surprise_reasons: Array.isArray(risk.surprise_reasons)
? risk.surprise_reasons.map((reason) =>
this.translateReason(String(reason)),
)
: [],
warnings: Array.isArray(risk.warnings)
? risk.warnings.map((warning) =>
this.translateReason(String(warning)),
)
: [],
} as MatchPredictionDto["risk"],
main_pick: mainPick,
value_pick: valuePick,
aggressive_pick: aggressivePick,
supporting_picks: supportingPicks,
bet_summary: betSummary,
bet_advice: betAdvice as MatchPredictionDto["bet_advice"],
market_board: enrichedMarketBoard,
reasoning_factors: reasoningFactors,
model_version: "v28-pro-max",
};
}
private enrichPick(
pick: unknown,
prediction: Record<string, unknown>,
matchContext: MatchContext,
marketBoard: Record<string, unknown>,
): MatchPredictionDto["main_pick"] {
if (!pick || typeof pick !== "object") {
return null;
}
const record = this.asRecord(pick);
const market = String(record.market ?? "");
const pickName = String(record.pick ?? "");
const probs = this.resolveMarketProbabilities(marketBoard, market);
const probability =
this.asNumber(record.probability) ||
this.lookupProbability(probs, pickName);
const calibratedConfidence =
this.asNumber(record.calibrated_confidence) ||
this.asNumber(record.confidence);
const impliedProb =
this.asNumber(record.implied_prob) ||
this.impliedProbabilityFromOdds(this.asNumber(record.odds));
const evEdge = this.asNumber(record.ev_edge) || this.asNumber(record.edge);
const interval = this.estimateConfidenceInterval({
market,
probability,
calibratedConfidence,
impliedProb,
evEdge,
marketBoardProbs: probs,
dataQualityScore: this.normalizeScore(
this.asRecord(prediction.data_quality).score,
),
riskScore: this.normalizeScore(this.asRecord(prediction.risk).score),
lineupSource: String(
this.asRecord(prediction.data_quality).lineup_source ?? "none",
),
isTopLeague: matchContext.isTopLeague,
});
const nextReasons = Array.isArray(record.decision_reasons)
? [...record.decision_reasons]
: [];
if (!interval.threshold_met) {
nextReasons.push("confidence_interval_too_wide");
}
if (interval.band === "LOW") {
nextReasons.push("confidence_band_low");
}
const displayOdds = this.normalizeDisplayOdds(
this.asNumber(record.odds),
impliedProb,
);
return {
...(record as MatchPredictionDto["main_pick"]),
market,
pick: pickName,
probability,
confidence: calibratedConfidence || this.asNumber(record.confidence),
calibrated_confidence: calibratedConfidence,
raw_confidence:
this.asNumber(record.raw_confidence) ||
calibratedConfidence ||
this.asNumber(record.confidence),
min_required_confidence: this.asNumber(record.min_required_confidence),
odds: displayOdds,
edge: this.asNumber(record.edge),
play_score: this.asNumber(record.play_score),
bet_grade: String(record.bet_grade || "PASS") as "A" | "B" | "C" | "PASS",
implied_prob: impliedProb,
ev_edge: evEdge,
playable: Boolean(record.playable) && interval.threshold_met,
stake_units:
Boolean(record.playable) && interval.threshold_met
? this.asNumber(record.stake_units)
: 0,
decision_reasons: Array.from(new Set(nextReasons)).map((reason) =>
this.translateReason(String(reason)),
),
confidence_interval: interval,
signal_tier: this.classifySignalTier(record, interval),
is_guaranteed: false,
};
}
private enrichSummary(
item: unknown,
prediction: Record<string, unknown>,
matchContext: MatchContext,
marketBoard: Record<string, unknown>,
): MatchPredictionDto["bet_summary"][number] {
const record = this.asRecord(item);
const market = String(record.market ?? "");
const pickName = String(record.pick ?? "");
const probs = this.resolveMarketProbabilities(marketBoard, market);
const probability = this.lookupProbability(probs, pickName);
const calibratedConfidence =
this.asNumber(record.calibrated_confidence) ||
this.asNumber(record.raw_confidence);
const odds = this.asNumber(record.odds);
const impliedProb =
this.asNumber(record.implied_prob) ||
this.impliedProbabilityFromOdds(odds);
const evEdge = this.asNumber(record.ev_edge);
const interval = this.estimateConfidenceInterval({
market,
probability,
calibratedConfidence,
impliedProb,
evEdge,
marketBoardProbs: probs,
dataQualityScore: this.normalizeScore(
this.asRecord(prediction.data_quality).score,
),
riskScore: this.normalizeScore(this.asRecord(prediction.risk).score),
lineupSource: String(
this.asRecord(prediction.data_quality).lineup_source ?? "none",
),
isTopLeague: matchContext.isTopLeague,
});
return {
...(record as MatchPredictionDto["bet_summary"][number]),
odds: this.normalizeDisplayOdds(odds, impliedProb),
implied_prob: impliedProb,
ev_edge: evEdge,
playable: Boolean(record.playable) && interval.threshold_met,
stake_units:
Boolean(record.playable) && interval.threshold_met
? this.asNumber(record.stake_units)
: 0,
reasons: Array.isArray(record.reasons)
? record.reasons.map((reason) => this.translateReason(String(reason)))
: [],
confidence_interval: interval,
signal_tier: this.classifySignalTier(
{
...record,
odds,
implied_prob: impliedProb,
ev_edge: evEdge,
calibrated_confidence: calibratedConfidence,
},
interval,
),
};
}
private normalizeDisplayOdds(odds: number, impliedProb: number): number {
if (odds <= 1.01 && impliedProb <= 0) {
return 0;
}
return odds;
}
private translateReason(reason: string): string {
if (!reason) {
return "";
}
const normalized = reason.startsWith("risk:") ? reason.slice(5) : reason;
if (this.reasonTranslations[normalized]) {
return this.reasonTranslations[normalized];
}
const evMatch = normalized.match(/^ev_edge_([-+][\d.]+%)_grade_(\w)$/);
if (evMatch) {
return `Teorik avantaj sinyali: Not ${evMatch[2]}`;
}
const negativeEdgeMatch = normalized.match(
/^negative_model_edge_([-+]?[\d.]+)$/,
);
if (negativeEdgeMatch) {
return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
}
const bandNoValueMatch = normalized.match(
/^odds_band_no_value_([-+]?[\d.]+)$/,
);
if (bandNoValueMatch) {
return `Tarihsel oran bandı değeri doğrulamadı (${bandNoValueMatch[1]})`;
}
const edgeThresholdMatch = normalized.match(
/^below_market_edge_threshold_([-+]?[\d.]+)$/,
);
if (edgeThresholdMatch) {
return `Piyasa avantaj eşiğinin altında (${edgeThresholdMatch[1]})`;
}
return normalized;
}
private classifySignalTier(
record: Record<string, unknown>,
interval: {
band?: "HIGH" | "MEDIUM" | "LOW";
threshold_met?: boolean;
},
): "CORE" | "VALUE" | "LEAN" | "LONGSHOT" | "PASS" {
const playable =
Boolean(record.playable) && Boolean(interval.threshold_met);
const calibratedConfidence = this.asNumber(record.calibrated_confidence);
const odds = this.asNumber(record.odds);
const evEdge = this.asNumber(record.ev_edge) || this.asNumber(record.edge);
const playScore = this.asNumber(record.play_score);
const band = String(interval.band ?? "LOW").toUpperCase();
if (
playable &&
band === "HIGH" &&
calibratedConfidence >= 72 &&
evEdge >= 0.02 &&
playScore >= 68
) {
return "CORE";
}
if (calibratedConfidence >= 52 && odds >= 1.75 && evEdge >= 0.04) {
return playable ? "VALUE" : "LONGSHOT";
}
if (
calibratedConfidence >= 46 &&
(band === "HIGH" || band === "MEDIUM" || evEdge > 0)
) {
return "LEAN";
}
if (odds >= 2.2 && calibratedConfidence >= 38) {
return "LONGSHOT";
}
return "PASS";
}
private estimateConfidenceInterval(input: {
market: string;
probability: number;
calibratedConfidence: number;
impliedProb: number;
evEdge: number;
marketBoardProbs: Record<string, unknown>;
dataQualityScore: number;
riskScore: number;
lineupSource: string;
isTopLeague: boolean;
}): ConfidenceInterval {
const probability = this.clamp(input.probability, 0.01, 0.99);
const sortedProbs = Object.values(input.marketBoardProbs)
.map((value) => this.asNumber(value))
.filter((value) => value > 0)
.sort((a, b) => b - a);
const secondProb = sortedProbs[1] ?? 0;
const topProb = sortedProbs[0] ?? probability;
const margin = Math.max(0, topProb - secondProb);
const normalizedConfidence = this.normalizePercent(
input.calibratedConfidence,
);
const baseWidthByMarket: Record<string, number> = {
MS: 0.18,
OU25: 0.14,
BTTS: 0.14,
HT_OU15: 0.16,
CARDS: 0.2,
HCAP: 0.22,
};
const baseWidth = baseWidthByMarket[input.market] ?? 0.19;
const lineupPenalty =
input.lineupSource === "confirmed_live"
? -0.015
: input.lineupSource === "probable_xi"
? 0
: 0.02;
const width = this.clamp(
baseWidth -
margin * 0.22 -
normalizedConfidence * 0.05 +
(1 - input.dataQualityScore) * 0.09 +
input.riskScore * 0.08 -
(input.isTopLeague ? 0.012 : 0) +
lineupPenalty,
0.08,
0.34,
);
const lower = this.clamp(probability - width / 2, 0.01, 0.99);
const upper = this.clamp(probability + width / 2, 0.01, 0.99);
const minConfidence = this.getMinConfidenceForPlay(
input.market,
input.isTopLeague,
);
const thresholdMet =
input.calibratedConfidence >= minConfidence &&
width <= this.getMaxAllowedWidth(input.market) &&
input.dataQualityScore >= 0.58 &&
input.evEdge >= this.getMinEdge(input.market) &&
upper - input.impliedProb >= 0.03;
let band: ConfidenceBand = "LOW";
if (input.calibratedConfidence >= 69 && width <= 0.12 && margin >= 0.07) {
band = "HIGH";
} else if (
input.calibratedConfidence >= 58 &&
width <= 0.18 &&
margin >= 0.035
) {
band = "MEDIUM";
}
return {
lower: Number((lower * 100).toFixed(1)),
upper: Number((upper * 100).toFixed(1)),
width: Number((width * 100).toFixed(1)),
band,
threshold_met: thresholdMet,
};
}
private getMinConfidenceForPlay(
market: string,
isTopLeague: boolean,
): number {
const baseline: Record<string, number> = {
MS: 62,
OU25: 60,
BTTS: 60,
HT_OU15: 61,
CARDS: 64,
HCAP: 65,
};
const marketBaseline = baseline[market] ?? 62;
return isTopLeague ? marketBaseline : marketBaseline + 2;
}
private getMaxAllowedWidth(market: string): number {
const byMarket: Record<string, number> = {
MS: 16,
OU25: 14,
BTTS: 14,
HT_OU15: 15,
CARDS: 18,
HCAP: 18,
};
return byMarket[market] ?? 16;
}
private getMinEdge(market: string): number {
const byMarket: Record<string, number> = {
MS: 0.02,
OU25: 0.018,
BTTS: 0.018,
HT_OU15: 0.02,
CARDS: 0.025,
HCAP: 0.025,
};
return byMarket[market] ?? 0.02;
}
private resolveMarketProbabilities(
marketBoard: Record<string, unknown>,
market: string,
): Record<string, unknown> {
const entry = this.asRecord(marketBoard[market]);
const probs = entry.probs;
return probs && typeof probs === "object"
? (probs as Record<string, unknown>)
: {};
}
private lookupProbability(
probabilities: Record<string, unknown>,
pickName: string,
): number {
if (!pickName) {
return 0;
}
const normalizedPick = this.normalizePickKey(pickName);
for (const [key, value] of Object.entries(probabilities)) {
if (this.normalizePickKey(key) === normalizedPick) {
return this.asNumber(value);
}
}
return 0;
}
private normalizePickKey(value: string): string {
const normalized = value.trim().toUpperCase();
const aliases: Record<string, string> = {
ÜST: "OVER",
UST: "OVER",
OVER: "OVER",
ALT: "UNDER",
UNDER: "UNDER",
"KG VAR": "YES",
VAR: "YES",
YES: "YES",
"KG YOK": "NO",
YOK: "NO",
NO: "NO",
TEK: "ODD",
ODD: "ODD",
ÇİFT: "EVEN",
CIFT: "EVEN",
EVEN: "EVEN",
};
return aliases[normalized] ?? normalized;
}
private impliedProbabilityFromOdds(odds: number): number {
if (odds <= 1) {
return 0;
}
return Number((1 / odds).toFixed(4));
}
private normalizeScore(value: unknown): number {
const numeric = this.asNumber(value);
return numeric > 1
? this.clamp(numeric / 100, 0, 1)
: this.clamp(numeric, 0, 1);
}
private normalizePercent(value: number): number {
return value > 1 ? this.clamp(value / 100, 0, 1) : this.clamp(value, 0, 1);
}
private asRecord(value: unknown): Record<string, any> {
return value && typeof value === "object"
? (value as Record<string, any>)
: {};
}
private asNumber(value: unknown): number {
return typeof value === "number"
? value
: typeof value === "string"
? Number(value) || 0
: 0;
}
private clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
async getValueBets(): Promise<ValueBetDto[]> {
const predictions = await this.prisma.prediction.findMany({
where: { match: { status: "NS" } },
include: { match: { include: { homeTeam: true, awayTeam: true } } },
});
const valueBets: ValueBetDto[] = [];
for (const p of predictions) {
const out = p.predictionJson as Record<string, unknown>;
const valueBetsList = out.value_bets as
| Record<string, unknown>[]
| undefined;
if (Array.isArray(valueBetsList)) {
valueBetsList.forEach((vb) => {
valueBets.push({
matchId: p.matchId,
matchName: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`,
betType: (vb.market || vb.betType || "") as string,
prediction: (vb.pick || vb.prediction || "") as string,
confidence: typeof vb.confidence === "number" ? vb.confidence : 0,
odd: typeof vb.odd === "number" ? vb.odd : 0,
expectedValue:
typeof vb.edge === "number"
? vb.edge
: typeof vb.expectedValue === "number"
? vb.expectedValue
: 0,
});
});
}
}
return valueBets
.sort((a, b) => b.expectedValue - a.expectedValue)
.slice(0, 50);
}
async getSmartCoupon(
matchIds: string[],
strategy: string = "BALANCED",
options: { maxMatches?: number; minConfidence?: number } = {},
): Promise<any> {
await this.ensureSmartCouponDataReady(matchIds);
// Queue mode (Redis enabled)
if (this.predictionsQueue && this.queueEvents) {
try {
const job = await this.predictionsQueue.addSmartCouponJob({
matchIds,
strategy,
options,
});
return await job.waitUntilFinished(this.queueEvents, 60000);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Smart coupon queue failed: ${message}`);
this.throwAiError(message);
}
}
// Direct HTTP mode
try {
const response = await this.aiEngineClient.post("/smart-coupon", {
match_ids: matchIds,
strategy,
...options,
});
return response.data;
} catch (error: unknown) {
const message =
error instanceof AiEngineRequestError
? error.message
: error instanceof Error
? error.message
: String(error);
this.logger.error(`Direct smart coupon call failed: ${message}`);
this.throwAiError(message);
}
}
private throwAiError(message: string): never {
if (
message.includes("timed out") ||
message.includes("AI_ENGINE_TIMEOUT") ||
message.includes("AI_ENGINE_504")
) {
throw new HttpException(
"Prediction request timed out",
HttpStatus.GATEWAY_TIMEOUT,
);
}
if (message.includes("AI_ENGINE_502")) {
throw new HttpException(
"AI Engine upstream returned 502",
HttpStatus.BAD_GATEWAY,
);
}
if (message.includes("circuit breaker is open")) {
throw new HttpException(
"AI Engine is temporarily unavailable",
HttpStatus.SERVICE_UNAVAILABLE,
);
}
throw new HttpException(
"Failed to get prediction from AI Engine",
HttpStatus.SERVICE_UNAVAILABLE,
);
}
getPredictionHistory(): Promise<PredictionHistoryResponseDto> {
return Promise.resolve({
stats: {
totalPredictions: 0,
totalResolved: 0,
correctPredictions: 0,
accuracyRate: 0,
},
history: [],
});
}
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: {
predictionJson: payload,
updatedAt: new Date(),
},
create: {
matchId,
predictionJson: payload,
},
});
} catch (error) {
this.logger.warn(`Failed to cache prediction for ${matchId}`, error);
}
}
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 },
});
if (!prediction) {
return null;
}
const cacheAge = Date.now() - prediction.updatedAt.getTime();
if (cacheAge > 6 * 60 * 60 * 1000) {
return null;
}
const cached = prediction.predictionJson as Record<string, unknown>;
const modelVersion = cached["model_version"];
if (typeof modelVersion !== "string") {
return null;
}
if (!modelVersion.startsWith("v28-pro-max")) {
return null;
}
return prediction.predictionJson as unknown as MatchPredictionDto;
}
private async getStoredPrediction(
matchId: string,
): Promise<MatchPredictionDto | null> {
const prediction = await this.prisma.prediction.findUnique({
where: { matchId },
});
return prediction
? (prediction.predictionJson as unknown as MatchPredictionDto)
: null;
}
private hasCooldown(detail: unknown): boolean {
if (typeof detail === "string") {
return detail.includes("cooldownRemainingMs");
}
if (detail && typeof detail === "object") {
return "cooldownRemainingMs" in detail;
}
return false;
}
private extractCooldownMs(detail: unknown): number {
if (
detail &&
typeof detail === "object" &&
"cooldownRemainingMs" in detail
) {
return (
Number((detail as Record<string, unknown>).cooldownRemainingMs) || 0
);
}
if (typeof detail === "string") {
const match = detail.match(/cooldownRemainingMs[":\s]+(\d+)/);
return match ? Number(match[1]) : 0;
}
return 0;
}
private async ensureSmartCouponDataReady(matchIds: string[]): Promise<void> {
const uniqueMatchIds = [...new Set(matchIds.filter((id) => !!id))];
if (uniqueMatchIds.length === 0) {
throw new HttpException(
"No matchIds provided for smart coupon generation",
HttpStatus.BAD_REQUEST,
);
}
await Promise.all(
uniqueMatchIds.map((matchId) => this.ensurePredictionDataReady(matchId)),
);
}
private async ensurePredictionDataReady(matchId: string): Promise<void> {
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 &&
typeof liveMatch.odds === "object" &&
!Array.isArray(liveMatch.odds) &&
Object.keys(liveMatch.odds as Record<string, unknown>).length > 0;
const matchExists = !!liveMatch?.id || !!persistedMatch?.id;
if (!matchExists) {
throw new HttpException(
`Match not found: ${matchId}`,
HttpStatus.NOT_FOUND,
);
}
// 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 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 || 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;
}
throw new HttpException(
`Prediction prerequisites are missing for match ${matchId}: odds`,
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
private async recordPredictionRun(
matchId: string,
payload: MatchPredictionDto,
): Promise<void> {
try {
const oddsSnapshot = await this.getPredictionOddsSnapshot(matchId);
const payloadSummary = this.buildPredictionPayloadSummary(payload);
await this.prisma.$executeRawUnsafe(
`
INSERT INTO prediction_runs (
match_id,
engine_version,
decision_trace_id,
odds_snapshot,
payload_summary
)
VALUES ($1, $2, $3, $4::jsonb, $5::jsonb)
`,
matchId,
String(payload.model_version || "unknown"),
typeof payload.decision_trace_id === "string"
? payload.decision_trace_id
: null,
JSON.stringify(oddsSnapshot),
JSON.stringify(payloadSummary),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(
`Prediction run audit skipped for ${matchId}: ${message}`,
);
}
}
private async getPredictionOddsSnapshot(
matchId: string,
): Promise<Record<string, unknown>> {
const liveMatch = await this.prisma.liveMatch.findUnique({
where: { id: matchId },
select: {
odds: true,
oddsUpdatedAt: true,
state: true,
status: true,
scoreHome: true,
scoreAway: true,
},
});
if (liveMatch) {
return {
source: "live_match",
odds: liveMatch.odds ?? {},
odds_updated_at: liveMatch.oddsUpdatedAt?.toISOString() ?? null,
state: liveMatch.state ?? null,
status: liveMatch.status ?? null,
score_home: liveMatch.scoreHome ?? null,
score_away: liveMatch.scoreAway ?? null,
};
}
const oddCategoryCount = await this.prisma.oddCategory.count({
where: { matchId },
});
return {
source: "historical_match",
odd_category_count: oddCategoryCount,
};
}
private buildPredictionPayloadSummary(
payload: MatchPredictionDto,
): Record<string, unknown> {
const topSummary = Array.isArray(payload.bet_summary)
? payload.bet_summary.slice(0, 5).map((item) => ({
market: item.market,
pick: item.pick,
playable: item.playable,
bet_grade: item.bet_grade,
odds: item.odds,
model_edge: item.model_edge,
calibrated_probability: item.calibrated_probability,
calibrated_confidence: item.calibrated_confidence,
ev_edge: item.ev_edge ?? 0,
odds_band_probability: item.odds_band_probability,
odds_band_sample: item.odds_band_sample,
odds_band_edge: item.odds_band_edge,
odds_band_aligned: item.odds_band_aligned,
stake_units: item.stake_units,
}))
: [];
return {
model_version: payload.model_version,
calibration_version: payload.calibration_version ?? null,
shadow_engine_version: payload.shadow_engine_version ?? null,
decision_trace_id: payload.decision_trace_id ?? null,
main_pick: payload.main_pick
? {
market: payload.main_pick.market,
pick: payload.main_pick.pick,
playable: payload.main_pick.playable,
bet_grade: payload.main_pick.bet_grade,
odds: payload.main_pick.odds,
model_edge: payload.main_pick.model_edge,
calibrated_probability: payload.main_pick.calibrated_probability,
calibrated_confidence: payload.main_pick.calibrated_confidence,
ev_edge: payload.main_pick.ev_edge ?? 0,
odds_band_probability: payload.main_pick.odds_band_probability,
odds_band_sample: payload.main_pick.odds_band_sample,
odds_band_edge: payload.main_pick.odds_band_edge,
odds_band_aligned: payload.main_pick.odds_band_aligned,
stake_units: payload.main_pick.stake_units,
}
: null,
value_pick: payload.value_pick
? {
market: payload.value_pick.market,
pick: payload.value_pick.pick,
playable: payload.value_pick.playable,
bet_grade: payload.value_pick.bet_grade,
odds: payload.value_pick.odds,
model_edge: payload.value_pick.model_edge,
calibrated_confidence: payload.value_pick.calibrated_confidence,
ev_edge: payload.value_pick.ev_edge ?? 0,
}
: null,
bet_advice: {
playable: payload.bet_advice?.playable ?? false,
suggested_stake_units: payload.bet_advice?.suggested_stake_units ?? 0,
reason: payload.bet_advice?.reason ?? null,
},
top_summary: topSummary,
market_reliability: payload.market_reliability ?? {},
shadow_engine: payload.shadow_engine ?? null,
};
}
}