cr
This commit is contained in:
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user