This commit is contained in:
2026-04-16 17:21:48 +03:00
parent c8fa4c442d
commit c8e7e4e927
116 changed files with 3720 additions and 4197 deletions
+188 -182
View File
@@ -6,26 +6,26 @@ import {
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';
} 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 axios, { AxiosError } from 'axios';
import { Prisma } from '@prisma/client';
import { FeederService } from '../feeder/feeder.service';
import * as fs from 'node:fs';
import * as path from 'node:path';
} from "./dto";
import axios, { AxiosError } from "axios";
import { Prisma } from "@prisma/client";
import { FeederService } from "../feeder/feeder.service";
import * as fs from "node:fs";
import * as path from "node:path";
type ConfidenceBand = 'HIGH' | 'MEDIUM' | 'LOW';
type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW";
interface ConfidenceInterval {
lower: number;
@@ -47,73 +47,72 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private readonly aiEngineUrl: string;
private readonly topLeagueIds = 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_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: 'Oynanabilir avantaj 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: 'Oynanabilirlik puanı yetersiz',
no_bet_conditions_met: 'Bahis koşulları oluşmadı',
market_passed_all_gates: 'Market tüm güvenlik kontrollerini geçti',
"Ana seçim için güven aralığı çok geniş",
confidence_band_low: "Güven bandı düşük",
playable_edge_found: "Oynanabilir avantaj 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: "Oynanabilirlik puanı yetersiz",
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',
"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',
"Skor modeli alt seçeneğiyle çelişiyor",
score_model_conflicts_with_over_pick:
'Skor modeli üst seçeneğiyle çelişiyor',
"Skor modeli üst seçeneğiyle çelişiyor",
market_stack_conflict_over25:
'Üst 2.5 sinyaliyle çeliştiği için zayıflatıldı',
"Ü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ı',
"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',
"İ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',
"İ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',
"İ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',
"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',
"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',
"İ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',
"Skor modeli KG Yok seçeneğiyle çelişiyor",
score_model_conflicts_with_draw_pick:
'Skor modeli beraberlik seçeneğiyle çelişiyor',
"Skor modeli beraberlik seçeneğiyle çelişiyor",
score_model_conflicts_with_home_pick:
'Skor modeli ev sahibi seçeneğiyle çelişiyor',
"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',
"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(
@@ -123,8 +122,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
@Optional() private readonly predictionsQueue?: PredictionsQueue,
) {
this.aiEngineUrl = this.configService.get(
'AI_ENGINE_URL',
'http://localhost:8000',
"AI_ENGINE_URL",
"http://localhost:8000",
);
this.topLeagueIds = this.loadTopLeagueIds();
}
@@ -133,14 +132,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
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'),
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');
this.logger.log("Queue mode enabled for predictions");
} else {
this.logger.log('Direct HTTP mode enabled for predictions (no Redis)');
this.logger.log("Direct HTTP mode enabled for predictions (no Redis)");
}
}
@@ -152,7 +151,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
checkHealth(): Promise<AIHealthDto> {
return Promise.resolve({
status: 'healthy',
status: "healthy",
modelLoaded: true,
predictionServiceReady: true,
});
@@ -212,12 +211,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
if (status === 422) {
throw new HttpException(
`AI Engine: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`,
`AI Engine: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`,
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
throw new HttpException(
`AI Engine error: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`,
`AI Engine error: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`,
status || HttpStatus.SERVICE_UNAVAILABLE,
);
}
@@ -232,7 +231,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
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');
const refreshResult = await this.feederService.refreshMatch(matchId, "all");
if (!refreshResult.success) {
this.logger.warn(
@@ -251,7 +250,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const upcoming = await this.prisma.prediction.findMany({
where: {
match: {
status: 'NS',
status: "NS",
mstUtc: { gte: Math.floor(Date.now() / 1000) },
},
},
@@ -260,13 +259,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
include: { homeTeam: true, awayTeam: true, league: true },
},
},
orderBy: { match: { mstUtc: 'asc' } },
orderBy: { match: { mstUtc: "asc" } },
take: 50,
});
return {
count: upcoming.length,
modelVersion: 'v25-v30-ensemble',
modelVersion: "v25-v30-ensemble",
matches: upcoming.map((p) => {
const out = p.predictionJson as Record<string, unknown>;
const matchInfo = (out?.match_info || {}) as Record<string, unknown>;
@@ -276,9 +275,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
...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: p.match.league?.name || "",
league_id: p.match.leagueId,
is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ''),
is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ""),
},
} as unknown as MatchPredictionDto;
}),
@@ -287,12 +286,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private loadTopLeagueIds(): Set<string> {
try {
const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json');
const topLeaguesPath = path.join(process.cwd(), "top_leagues.json");
if (!fs.existsSync(topLeaguesPath)) {
return new Set<string>();
}
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf8'));
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8"));
if (!Array.isArray(raw)) {
return new Set<string>();
}
@@ -318,7 +317,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
if (match) {
return {
leagueId: match.leagueId ?? null,
isTopLeague: this.topLeagueIds.has(match.leagueId ?? ''),
isTopLeague: this.topLeagueIds.has(match.leagueId ?? ""),
};
}
@@ -329,7 +328,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return {
leagueId: liveMatch?.leagueId ?? null,
isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ''),
isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ""),
};
}
@@ -346,7 +345,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
league_id:
this.asRecord(response.match_info).league_id ?? matchContext.leagueId,
is_top_league:
this.asRecord(response.match_info).is_top_league ?? matchContext.isTopLeague,
this.asRecord(response.match_info).is_top_league ??
matchContext.isTopLeague,
};
const mainPick = this.enrichPick(
@@ -369,9 +369,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
);
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)
? 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)
@@ -380,8 +382,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
)
: [];
const mainBand =
this.asRecord(mainPick?.confidence_interval).band ?? 'LOW';
const mainBand = this.asRecord(mainPick?.confidence_interval).band ?? "LOW";
const minConfidenceForPlay = this.getMinConfidenceForPlay(
this.asRecord(mainPick).market,
matchContext.isTopLeague,
@@ -402,7 +403,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
if (mainPick && !isMainPlayable) {
reasoningFactors.unshift(
this.translateReason('confidence_interval_too_wide_for_main_pick'),
this.translateReason("confidence_interval_too_wide_for_main_pick"),
);
}
@@ -416,9 +417,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
isMainPlayable
? String(
this.asRecord(response.bet_advice).reason ||
'playable_edge_found',
"playable_edge_found",
)
: 'confidence_below_threshold',
: "confidence_below_threshold",
),
suggested_stake_units: isMainPlayable
? Number(this.asRecord(response.bet_advice).suggested_stake_units ?? 0)
@@ -428,15 +429,18 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
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') {
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),
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),
@@ -447,7 +451,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
implied_prob: 0,
play_score: 0,
playable: false,
bet_grade: 'PASS',
bet_grade: "PASS",
stake_units: 0,
decision_reasons: [],
};
@@ -464,7 +468,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
{
...record,
confidence_interval: this.asRecord(enriched?.confidence_interval),
confidence_band: this.asRecord(enriched?.confidence_interval).band ?? 'LOW',
confidence_band:
this.asRecord(enriched?.confidence_interval).band ?? "LOW",
},
];
}),
@@ -472,14 +477,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return {
...response,
match_info: matchInfo as MatchPredictionDto['match_info'],
match_info: matchInfo as MatchPredictionDto["match_info"],
data_quality: {
...dataQuality,
lineup_source: String(dataQuality.lineup_source ?? 'none'),
} as MatchPredictionDto['data_quality'],
lineup_source: String(dataQuality.lineup_source ?? "none"),
} as MatchPredictionDto["data_quality"],
risk: {
...risk,
surprise_type: this.translateReason(String(risk.surprise_type ?? '')),
surprise_type: this.translateReason(String(risk.surprise_type ?? "")),
surprise_reasons: Array.isArray(risk.surprise_reasons)
? risk.surprise_reasons.map((reason) =>
this.translateReason(String(reason)),
@@ -490,13 +495,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
this.translateReason(String(warning)),
)
: [],
} as MatchPredictionDto['risk'],
} 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'],
bet_advice: betAdvice as MatchPredictionDto["bet_advice"],
market_board: enrichedMarketBoard,
reasoning_factors: reasoningFactors,
};
@@ -507,14 +512,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
prediction: Record<string, unknown>,
matchContext: MatchContext,
marketBoard: Record<string, unknown>,
): MatchPredictionDto['main_pick'] {
if (!pick || typeof pick !== 'object') {
): 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 market = String(record.market ?? "");
const pickName = String(record.pick ?? "");
const probs = this.resolveMarketProbabilities(marketBoard, market);
const probability =
this.asNumber(record.probability) ||
@@ -538,7 +543,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
),
riskScore: this.normalizeScore(this.asRecord(prediction.risk).score),
lineupSource: String(
this.asRecord(prediction.data_quality).lineup_source ?? 'none',
this.asRecord(prediction.data_quality).lineup_source ?? "none",
),
isTopLeague: matchContext.isTopLeague,
});
@@ -547,10 +552,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
? [...record.decision_reasons]
: [];
if (!interval.threshold_met) {
nextReasons.push('confidence_interval_too_wide');
nextReasons.push("confidence_interval_too_wide");
}
if (interval.band === 'LOW') {
nextReasons.push('confidence_band_low');
if (interval.band === "LOW") {
nextReasons.push("confidence_band_low");
}
const displayOdds = this.normalizeDisplayOdds(
@@ -559,7 +564,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
);
return {
...(record as MatchPredictionDto['main_pick']),
...(record as MatchPredictionDto["main_pick"]),
market,
pick: pickName,
probability,
@@ -573,7 +578,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
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',
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,
@@ -594,10 +599,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
prediction: Record<string, unknown>,
matchContext: MatchContext,
marketBoard: Record<string, unknown>,
): MatchPredictionDto['bet_summary'][number] {
): MatchPredictionDto["bet_summary"][number] {
const record = this.asRecord(item);
const market = String(record.market ?? '');
const pickName = String(record.pick ?? '');
const market = String(record.market ?? "");
const pickName = String(record.pick ?? "");
const probs = this.resolveMarketProbabilities(marketBoard, market);
const probability = this.lookupProbability(probs, pickName);
const calibratedConfidence =
@@ -621,13 +626,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
),
riskScore: this.normalizeScore(this.asRecord(prediction.risk).score),
lineupSource: String(
this.asRecord(prediction.data_quality).lineup_source ?? 'none',
this.asRecord(prediction.data_quality).lineup_source ?? "none",
),
isTopLeague: matchContext.isTopLeague,
});
return {
...(record as MatchPredictionDto['bet_summary'][number]),
...(record as MatchPredictionDto["bet_summary"][number]),
odds: this.normalizeDisplayOdds(odds, impliedProb),
implied_prob: impliedProb,
ev_edge: evEdge,
@@ -658,12 +663,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private translateReason(reason: string): string {
if (!reason) {
return '';
return "";
}
const normalized = reason.startsWith('risk:')
? reason.slice(5)
: reason;
const normalized = reason.startsWith("risk:") ? reason.slice(5) : reason;
if (this.reasonTranslations[normalized]) {
return this.reasonTranslations[normalized];
@@ -674,7 +677,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`;
}
const negativeEdgeMatch = normalized.match(/^negative_model_edge_([+\-]?[\d.]+)$/);
const negativeEdgeMatch = normalized.match(
/^negative_model_edge_([+\-]?[\d.]+)$/,
);
if (negativeEdgeMatch) {
return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
}
@@ -692,47 +697,44 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private classifySignalTier(
record: Record<string, unknown>,
interval: {
band?: 'HIGH' | 'MEDIUM' | 'LOW';
band?: "HIGH" | "MEDIUM" | "LOW";
threshold_met?: boolean;
},
): 'CORE' | 'VALUE' | 'LEAN' | 'LONGSHOT' | 'PASS' {
const playable = Boolean(record.playable) && Boolean(interval.threshold_met);
): "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();
const band = String(interval.band ?? "LOW").toUpperCase();
if (
playable &&
band === 'HIGH' &&
band === "HIGH" &&
calibratedConfidence >= 72 &&
evEdge >= 0.02 &&
playScore >= 68
) {
return 'CORE';
return "CORE";
}
if (
calibratedConfidence >= 52 &&
odds >= 1.75 &&
evEdge >= 0.04
) {
return playable ? 'VALUE' : 'LONGSHOT';
if (calibratedConfidence >= 52 && odds >= 1.75 && evEdge >= 0.04) {
return playable ? "VALUE" : "LONGSHOT";
}
if (
calibratedConfidence >= 46 &&
(band === 'HIGH' || band === 'MEDIUM' || evEdge > 0)
(band === "HIGH" || band === "MEDIUM" || evEdge > 0)
) {
return 'LEAN';
return "LEAN";
}
if (odds >= 2.2 && calibratedConfidence >= 38) {
return 'LONGSHOT';
return "LONGSHOT";
}
return 'PASS';
return "PASS";
}
private estimateConfidenceInterval(input: {
@@ -755,7 +757,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const secondProb = sortedProbs[1] ?? 0;
const topProb = sortedProbs[0] ?? probability;
const margin = Math.max(0, topProb - secondProb);
const normalizedConfidence = this.normalizePercent(input.calibratedConfidence);
const normalizedConfidence = this.normalizePercent(
input.calibratedConfidence,
);
const baseWidthByMarket: Record<string, number> = {
MS: 0.18,
@@ -767,19 +771,19 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
};
const baseWidth = baseWidthByMarket[input.market] ?? 0.19;
const lineupPenalty =
input.lineupSource === 'confirmed_live'
input.lineupSource === "confirmed_live"
? -0.015
: input.lineupSource === 'probable_xi'
: 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,
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,
);
@@ -795,17 +799,17 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
width <= this.getMaxAllowedWidth(input.market) &&
input.dataQualityScore >= 0.58 &&
input.evEdge >= this.getMinEdge(input.market) &&
(upper - input.impliedProb) >= 0.03;
upper - input.impliedProb >= 0.03;
let band: ConfidenceBand = 'LOW';
let band: ConfidenceBand = "LOW";
if (input.calibratedConfidence >= 69 && width <= 0.12 && margin >= 0.07) {
band = 'HIGH';
band = "HIGH";
} else if (
input.calibratedConfidence >= 58 &&
width <= 0.18 &&
margin >= 0.035
) {
band = 'MEDIUM';
band = "MEDIUM";
}
return {
@@ -864,7 +868,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
): Record<string, unknown> {
const entry = this.asRecord(marketBoard[market]);
const probs = entry.probs;
return probs && typeof probs === 'object'
return probs && typeof probs === "object"
? (probs as Record<string, unknown>)
: {};
}
@@ -895,7 +899,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private normalizeScore(value: unknown): number {
const numeric = this.asNumber(value);
return numeric > 1 ? this.clamp(numeric / 100, 0, 1) : this.clamp(numeric, 0, 1);
return numeric > 1
? this.clamp(numeric / 100, 0, 1)
: this.clamp(numeric, 0, 1);
}
private normalizePercent(value: number): number {
@@ -903,15 +909,15 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
private asRecord(value: unknown): Record<string, any> {
return value && typeof value === 'object'
return value && typeof value === "object"
? (value as Record<string, any>)
: {};
}
private asNumber(value: unknown): number {
return typeof value === 'number'
return typeof value === "number"
? value
: typeof value === 'string'
: typeof value === "string"
? Number(value) || 0
: 0;
}
@@ -922,7 +928,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
async getValueBets(): Promise<ValueBetDto[]> {
const predictions = await this.prisma.prediction.findMany({
where: { match: { status: 'NS' } },
where: { match: { status: "NS" } },
include: { match: { include: { homeTeam: true, awayTeam: true } } },
});
@@ -937,14 +943,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
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,
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'
typeof vb.edge === "number"
? vb.edge
: typeof vb.expectedValue === 'number'
: typeof vb.expectedValue === "number"
? vb.expectedValue
: 0,
});
@@ -959,7 +965,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
async getSmartCoupon(
matchIds: string[],
strategy: string = 'BALANCED',
strategy: string = "BALANCED",
options: { maxMatches?: number; minConfidence?: number } = {},
): Promise<any> {
await this.ensureSmartCouponDataReady(matchIds);
@@ -997,23 +1003,23 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private throwAiError(message: string): never {
if (
message.includes('timed out') ||
message.includes('AI_ENGINE_TIMEOUT') ||
message.includes('AI_ENGINE_504')
message.includes("timed out") ||
message.includes("AI_ENGINE_TIMEOUT") ||
message.includes("AI_ENGINE_504")
) {
throw new HttpException(
'Prediction request timed out',
"Prediction request timed out",
HttpStatus.GATEWAY_TIMEOUT,
);
}
if (message.includes('AI_ENGINE_502')) {
if (message.includes("AI_ENGINE_502")) {
throw new HttpException(
'AI Engine upstream returned 502',
"AI Engine upstream returned 502",
HttpStatus.BAD_GATEWAY,
);
}
throw new HttpException(
'Failed to get prediction from AI Engine',
"Failed to get prediction from AI Engine",
HttpStatus.SERVICE_UNAVAILABLE,
);
}
@@ -1066,12 +1072,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
const cached = prediction.predictionJson as Record<string, unknown>;
const modelVersion = cached['model_version'];
if (typeof modelVersion !== 'string') {
const modelVersion = cached["model_version"];
if (typeof modelVersion !== "string") {
return null;
}
if (!modelVersion.startsWith('v25')) {
if (!modelVersion.startsWith("v25")) {
return null;
}
@@ -1082,7 +1088,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const uniqueMatchIds = [...new Set(matchIds.filter((id) => !!id))];
if (uniqueMatchIds.length === 0) {
throw new HttpException(
'No matchIds provided for smart coupon generation',
"No matchIds provided for smart coupon generation",
HttpStatus.BAD_REQUEST,
);
}
@@ -1122,7 +1128,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const hasLiveOdds =
!!liveMatch?.odds &&
typeof liveMatch.odds === 'object' &&
typeof liveMatch.odds === "object" &&
!Array.isArray(liveMatch.odds) &&
Object.keys(liveMatch.odds as Record<string, unknown>).length > 0;
const matchExists = !!liveMatch?.id || !!persistedMatch?.id;
@@ -1146,9 +1152,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const isFinished =
hasScores ||
state === 'MS' ||
state === 'postGame' ||
['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'].includes(
state === "MS" ||
state === "postGame" ||
["Finished", "Played", "FT", "AET", "PEN", "Ended"].includes(
status as string,
);