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(); private readonly reasonTranslations: Record = { 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 { 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)?.mode === "string" ? String((response.data as Record).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 { 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( `/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 { return this.getPredictionById(matchDetails.matchId); } async testPrediction(matchId: string): Promise { 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 { 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; const matchInfo = (out?.match_info || {}) as Record; 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 { 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(); } const raw = JSON.parse(fs.readFileSync(filePath, "utf8")); if (!Array.isArray(raw)) { 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 qualified_leagues.json: ${message}`); return new Set(); } } 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 { 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; 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 => 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, 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, matchContext: MatchContext, marketBoard: Record, ): 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, matchContext: MatchContext, marketBoard: Record, ): 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, 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; 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 = { 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 = { 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 = { 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 = { 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, market: string, ): Record { const entry = this.asRecord(marketBoard[market]); const probs = entry.probs; return probs && typeof probs === "object" ? (probs as Record) : {}; } private lookupProbability( probabilities: Record, 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 = { Ü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 { return value && typeof value === "object" ? (value as Record) : {}; } 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 { 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; const valueBetsList = out.value_bets as | Record[] | 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 { 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 { 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 { 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; 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 { 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).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 { 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 { 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).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 { 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> { 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 { 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, }; } }