1645 lines
52 KiB
TypeScript
Executable File
1645 lines
52 KiB
TypeScript
Executable File
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,
|
||
};
|
||
}
|
||
}
|