500 lines
15 KiB
TypeScript
Executable File
500 lines
15 KiB
TypeScript
Executable File
import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
|
||
import { GeminiService } from "../../gemini/gemini.service";
|
||
import {
|
||
AiEngineClient,
|
||
AiEngineRequestError,
|
||
} from "../../../common/utils/ai-engine-client";
|
||
import {
|
||
FrequencyEngineService,
|
||
type MatchCandidate,
|
||
type FrequencySignal,
|
||
} from "./frequency-engine.service";
|
||
|
||
export type PredictionRiskLevel = "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
|
||
export type PredictionDataQuality = "HIGH" | "MEDIUM" | "LOW";
|
||
export type BetGrade = "A" | "B" | "C" | "PASS";
|
||
|
||
export interface PredictionPickRow {
|
||
market: string;
|
||
pick: string;
|
||
probability: number;
|
||
confidence: number;
|
||
odds: number;
|
||
raw_confidence: number;
|
||
calibrated_confidence: number;
|
||
min_required_confidence: number;
|
||
edge: number;
|
||
play_score: number;
|
||
playable: boolean;
|
||
bet_grade: BetGrade;
|
||
stake_units: number;
|
||
decision_reasons: string[];
|
||
}
|
||
|
||
export interface PredictionBetSummaryRow {
|
||
market: string;
|
||
pick: string;
|
||
raw_confidence: number;
|
||
calibrated_confidence: number;
|
||
bet_grade: BetGrade;
|
||
playable: boolean;
|
||
stake_units: number;
|
||
play_score: number;
|
||
reasons: string[];
|
||
}
|
||
|
||
export interface SingleMatchPredictionPackage {
|
||
model_version: string;
|
||
match_info: {
|
||
match_id: string;
|
||
match_name: string;
|
||
home_team: string;
|
||
away_team: string;
|
||
league: string;
|
||
match_date_ms: number;
|
||
};
|
||
data_quality: {
|
||
label: PredictionDataQuality;
|
||
score: number;
|
||
flags: string[];
|
||
home_lineup_count: number;
|
||
away_lineup_count: number;
|
||
};
|
||
risk: {
|
||
level: PredictionRiskLevel;
|
||
score: number;
|
||
is_surprise_risk: boolean;
|
||
surprise_type: string | null;
|
||
warnings: string[];
|
||
};
|
||
engine_breakdown: {
|
||
team: number;
|
||
player: number;
|
||
odds: number;
|
||
referee: number;
|
||
};
|
||
main_pick: PredictionPickRow | null;
|
||
value_pick: PredictionPickRow | null;
|
||
bet_advice: {
|
||
playable: boolean;
|
||
suggested_stake_units: number;
|
||
reason: string;
|
||
};
|
||
bet_summary: PredictionBetSummaryRow[];
|
||
supporting_picks: PredictionPickRow[];
|
||
aggressive_pick: {
|
||
market: string;
|
||
pick: string;
|
||
probability: number;
|
||
confidence: number;
|
||
odds: number | null;
|
||
} | null;
|
||
scenario_top5: Array<{
|
||
score: string;
|
||
prob: number;
|
||
[key: string]: unknown;
|
||
}>;
|
||
score_prediction: {
|
||
ft: string;
|
||
ht: string;
|
||
xg_home: number;
|
||
xg_away: number;
|
||
xg_total: number;
|
||
};
|
||
market_board: Record<string, unknown>;
|
||
reasoning_factors: string[];
|
||
ai_commentary?: string | null;
|
||
}
|
||
|
||
export interface SmartCouponResult {
|
||
strategy: string;
|
||
generated_at: string;
|
||
match_count: number;
|
||
bets: Array<{
|
||
match_id: string;
|
||
match_name: string;
|
||
market: string;
|
||
pick: string;
|
||
probability: number;
|
||
confidence: number;
|
||
odds: number;
|
||
risk_level: PredictionRiskLevel;
|
||
data_quality: PredictionDataQuality;
|
||
}>;
|
||
total_odds: number;
|
||
expected_win_rate: number;
|
||
rejected_matches: Array<{
|
||
match_id: string;
|
||
reason: string;
|
||
threshold?: number;
|
||
}>;
|
||
}
|
||
|
||
@Injectable()
|
||
export class SmartCouponService {
|
||
private readonly logger = new Logger(SmartCouponService.name);
|
||
private readonly aiEngineUrl: string;
|
||
private readonly aiEngineClient: AiEngineClient;
|
||
|
||
constructor(
|
||
private readonly geminiService: GeminiService,
|
||
private readonly frequencyEngine: FrequencyEngineService,
|
||
) {
|
||
this.aiEngineUrl = process.env.AI_ENGINE_URL || "http://ai-engine:8000";
|
||
this.aiEngineClient = new AiEngineClient({
|
||
baseUrl: this.aiEngineUrl,
|
||
logger: this.logger,
|
||
serviceName: SmartCouponService.name,
|
||
timeoutMs: 60000,
|
||
maxRetries: 2,
|
||
retryDelayMs: 750,
|
||
});
|
||
}
|
||
|
||
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
|
||
let prediction: SingleMatchPredictionPackage;
|
||
try {
|
||
const response = await this.aiEngineClient.post<SingleMatchPredictionPackage>(
|
||
`/v20plus/analyze/${matchId}`,
|
||
);
|
||
prediction = response.data;
|
||
} catch (error: unknown) {
|
||
if (error instanceof AiEngineRequestError) {
|
||
const detail =
|
||
typeof error.detail === "string" ? error.detail : error.message;
|
||
throw new HttpException(
|
||
`AI analyze failed: ${detail}`,
|
||
error.status || HttpStatus.SERVICE_UNAVAILABLE,
|
||
);
|
||
}
|
||
throw new HttpException(
|
||
"AI analyze failed",
|
||
HttpStatus.SERVICE_UNAVAILABLE,
|
||
);
|
||
}
|
||
|
||
// Generate AI commentary (non-blocking — fail-safe)
|
||
prediction.ai_commentary = await this.generateMatchCommentary(prediction);
|
||
return prediction;
|
||
}
|
||
|
||
private async generateMatchCommentary(
|
||
prediction: SingleMatchPredictionPackage,
|
||
): Promise<string | null> {
|
||
if (!this.geminiService.isAvailable()) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const result = await this.geminiService.generateText(
|
||
JSON.stringify(prediction, null, 2),
|
||
{
|
||
model: "gemini-2.0-flash",
|
||
temperature: 0.7,
|
||
maxTokens: 600,
|
||
systemPrompt: MATCH_COMMENTARY_SYSTEM_PROMPT,
|
||
},
|
||
);
|
||
return result.text || null;
|
||
} catch (error) {
|
||
this.logger.warn("AI commentary generation failed, skipping", error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async generateDailyBankoCoupon(
|
||
matchIds: string[],
|
||
): Promise<SmartCouponResult | null> {
|
||
if (matchIds.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return this.getSmartCoupon(matchIds, "SAFE", {
|
||
maxMatches: 2,
|
||
minConfidence: 78,
|
||
});
|
||
}
|
||
|
||
async getSmartCoupon(
|
||
matchIds: string[],
|
||
strategy:
|
||
| "SAFE"
|
||
| "BALANCED"
|
||
| "AGGRESSIVE"
|
||
| "VALUE"
|
||
| "MIRACLE" = "BALANCED",
|
||
options: { maxMatches?: number; minConfidence?: number } = {},
|
||
): Promise<SmartCouponResult> {
|
||
try {
|
||
const response = await this.aiEngineClient.post<SmartCouponResult>(
|
||
"/v20plus/coupon",
|
||
{
|
||
match_ids: matchIds,
|
||
strategy,
|
||
max_matches: options.maxMatches,
|
||
min_confidence: options.minConfidence,
|
||
},
|
||
);
|
||
return response.data;
|
||
} catch (error: unknown) {
|
||
this.logger.error("Failed to generate smart coupon", error);
|
||
if (error instanceof AiEngineRequestError) {
|
||
const detail =
|
||
typeof error.detail === "string" ? error.detail : error.message;
|
||
throw new HttpException(
|
||
`Coupon generation failed: ${detail}`,
|
||
error.status || HttpStatus.SERVICE_UNAVAILABLE,
|
||
);
|
||
}
|
||
throw new HttpException(
|
||
"Coupon generation failed",
|
||
HttpStatus.SERVICE_UNAVAILABLE,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// FREQUENCY-BASED COUPON ENGINE
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
async generateFrequencyBasedCoupon(options: {
|
||
matchIds?: string[];
|
||
maxMatches?: number;
|
||
minSignal?: number;
|
||
markets?: string[];
|
||
}): Promise<FrequencyCouponResult> {
|
||
const maxMatches = options.maxMatches ?? 3;
|
||
const minSignal = options.minSignal ?? 0.70;
|
||
const allowedMarkets = options.markets?.map((m) => m.toUpperCase()) || null;
|
||
|
||
this.logger.log(
|
||
`[FrequencyCoupon] Starting — max=${maxMatches}, minSignal=${minSignal}`,
|
||
);
|
||
|
||
// 1. Yaklaşan maçları oranlarıyla getir
|
||
const upcomingRows = await this.frequencyEngine.getUpcomingMatchesWithOdds(
|
||
options.matchIds,
|
||
80,
|
||
);
|
||
|
||
this.logger.log(
|
||
`[FrequencyCoupon] Found ${upcomingRows.length} upcoming matches with odds`,
|
||
);
|
||
|
||
if (upcomingRows.length === 0) {
|
||
return {
|
||
strategy: "FREQUENCY",
|
||
generated_at: new Date().toISOString(),
|
||
bets: [],
|
||
total_odds: 0,
|
||
expected_hit_rate: 0,
|
||
expected_value: 0,
|
||
ev_positive: false,
|
||
reasoning: ["Bültende uygun maç bulunamadı."],
|
||
rejected_matches: [],
|
||
};
|
||
}
|
||
|
||
// 2. Her maç için frekans sinyallerini hesapla (paralel)
|
||
const candidatePromises = upcomingRows.map((row) =>
|
||
this.frequencyEngine.buildMatchCandidate(row).then((candidate) => ({
|
||
candidate,
|
||
row,
|
||
})),
|
||
);
|
||
const candidateResults = await Promise.all(candidatePromises);
|
||
|
||
// 3. Sinyali olan adayları filtrele
|
||
const allCandidates: Array<{
|
||
candidate: MatchCandidate;
|
||
row: (typeof upcomingRows)[0];
|
||
}> = [];
|
||
const rejected: FrequencyCouponResult["rejected_matches"] = [];
|
||
|
||
for (const { candidate, row } of candidateResults) {
|
||
if (!candidate) {
|
||
rejected.push({
|
||
match_id: row.match_id,
|
||
match_name: `${row.home_team_name} vs ${row.away_team_name}`,
|
||
reason: `Yetersiz geçmiş veri (min ${3} maç gerekli)`,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
// Market filtresi uygula
|
||
let filteredSignals = candidate.signals;
|
||
if (allowedMarkets) {
|
||
filteredSignals = filteredSignals.filter((s) =>
|
||
allowedMarkets.some((m) => s.market.includes(m)),
|
||
);
|
||
}
|
||
|
||
// Min signal filtresi
|
||
filteredSignals = filteredSignals.filter(
|
||
(s) => s.combinedSignal >= minSignal,
|
||
);
|
||
|
||
if (filteredSignals.length === 0) {
|
||
rejected.push({
|
||
match_id: row.match_id,
|
||
match_name: `${row.home_team_name} vs ${row.away_team_name}`,
|
||
reason: `Kombinasyon sinyali ${(minSignal * 100).toFixed(0)}% eşiğinin altında`,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
// En güçlü sinyali seç
|
||
candidate.signals = filteredSignals;
|
||
candidate.bestSignal = filteredSignals[0];
|
||
allCandidates.push({ candidate, row });
|
||
}
|
||
|
||
this.logger.log(
|
||
`[FrequencyCoupon] ${allCandidates.length} candidates passed filters, ${rejected.length} rejected`,
|
||
);
|
||
|
||
// 4. En güçlü sinyale göre sırala
|
||
allCandidates.sort(
|
||
(a, b) =>
|
||
(b.candidate.bestSignal?.confidence ?? 0) -
|
||
(a.candidate.bestSignal?.confidence ?? 0),
|
||
);
|
||
|
||
// 5. Çeşitlilik: aynı ligden max 2 maç
|
||
const selected: typeof allCandidates = [];
|
||
const leagueCount = new Map<string, number>();
|
||
|
||
for (const entry of allCandidates) {
|
||
if (selected.length >= maxMatches) break;
|
||
|
||
const lid = entry.candidate.leagueId;
|
||
const currentCount = leagueCount.get(lid) || 0;
|
||
if (currentCount >= 2) {
|
||
rejected.push({
|
||
match_id: entry.candidate.matchId,
|
||
match_name: `${entry.candidate.homeTeamName} vs ${entry.candidate.awayTeamName}`,
|
||
reason: `Aynı ligden zaten 2 maç seçildi (${entry.candidate.leagueName})`,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
selected.push(entry);
|
||
leagueCount.set(lid, currentCount + 1);
|
||
}
|
||
|
||
// 6. Sonucu oluştur
|
||
const bets: FrequencyCouponResult["bets"] = [];
|
||
let totalOdds = 1;
|
||
let combinedHitRate = 1;
|
||
const reasoning: string[] = [];
|
||
|
||
for (const { candidate, row } of selected) {
|
||
const signal = candidate.bestSignal!;
|
||
const betOdds = this.frequencyEngine.getMarketOdds(row, signal.market);
|
||
|
||
if (betOdds <= 0) continue;
|
||
|
||
const homeBand = this.frequencyEngine.getOddsBand(candidate.homeOdds);
|
||
const awayBand = this.frequencyEngine.getOddsBand(candidate.awayOdds);
|
||
|
||
// Lig profili belirle
|
||
let leagueProfile = "NORMAL";
|
||
if (signal.leagueBonus > 0.02) leagueProfile = "GOLCU";
|
||
else if (signal.leagueBonus < -0.02) leagueProfile = "DEFANSIF";
|
||
|
||
bets.push({
|
||
match_id: candidate.matchId,
|
||
match_name: `${candidate.homeTeamName} vs ${candidate.awayTeamName}`,
|
||
league: candidate.leagueName,
|
||
market: signal.market,
|
||
pick: signal.pick,
|
||
home_signal: parseFloat(signal.homeSignal.toFixed(3)),
|
||
away_signal: parseFloat(signal.awaySignal.toFixed(3)),
|
||
combined_signal: parseFloat(signal.combinedSignal.toFixed(3)),
|
||
league_profile: leagueProfile,
|
||
historical_hit_rate: parseFloat(signal.combinedSignal.toFixed(3)),
|
||
odds: betOdds,
|
||
home_odds_band: homeBand,
|
||
away_odds_band: awayBand,
|
||
home_match_count: signal.homeMatchCount,
|
||
away_match_count: signal.awayMatchCount,
|
||
});
|
||
|
||
totalOdds *= betOdds;
|
||
combinedHitRate *= signal.combinedSignal;
|
||
|
||
reasoning.push(
|
||
`${candidate.homeTeamName} vs ${candidate.awayTeamName}: ` +
|
||
`${signal.pick} — Ev(${homeBand}): ${(signal.homeSignal * 100).toFixed(0)}% (${signal.homeMatchCount} maç), ` +
|
||
`Dep(${awayBand}): ${(signal.awaySignal * 100).toFixed(0)}% (${signal.awayMatchCount} maç)`,
|
||
);
|
||
}
|
||
|
||
totalOdds = parseFloat(totalOdds.toFixed(2));
|
||
const expectedValue = parseFloat((combinedHitRate * totalOdds).toFixed(3));
|
||
|
||
return {
|
||
strategy: "FREQUENCY",
|
||
generated_at: new Date().toISOString(),
|
||
bets,
|
||
total_odds: totalOdds,
|
||
expected_hit_rate: parseFloat(combinedHitRate.toFixed(4)),
|
||
expected_value: expectedValue,
|
||
ev_positive: expectedValue > 1.0,
|
||
reasoning,
|
||
rejected_matches: rejected,
|
||
};
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Frequency Coupon Result Interface
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
export interface FrequencyCouponResult {
|
||
strategy: "FREQUENCY";
|
||
generated_at: string;
|
||
bets: Array<{
|
||
match_id: string;
|
||
match_name: string;
|
||
league: string;
|
||
market: string;
|
||
pick: string;
|
||
home_signal: number;
|
||
away_signal: number;
|
||
combined_signal: number;
|
||
league_profile: string;
|
||
historical_hit_rate: number;
|
||
odds: number;
|
||
home_odds_band: string;
|
||
away_odds_band: string;
|
||
home_match_count: number;
|
||
away_match_count: number;
|
||
}>;
|
||
total_odds: number;
|
||
expected_hit_rate: number;
|
||
expected_value: number;
|
||
ev_positive: boolean;
|
||
reasoning: string[];
|
||
rejected_matches: Array<{
|
||
match_id: string;
|
||
match_name: string;
|
||
reason: string;
|
||
}>;
|
||
}
|
||
|
||
const MATCH_COMMENTARY_SYSTEM_PROMPT = `Sen uzman bir futbol bahis analistisin. Sana verilen model çıktısını analiz edip kısa, net ve aksiyon odaklı Türkçe bir yorum yaz.
|
||
|
||
Kurallar:
|
||
- Max 3-4 kısa paragraf, gereksiz uzatma
|
||
- Playable olan marketleri ve nedenlerini açıkla
|
||
- Edge pozitif olan marketleri vurgula (bahisçiden daha iyi biliyoruz)
|
||
- Tüm edge'ler negatifse "trap maç" olarak uyar
|
||
- xG ve skor senaryolarına göre strateji öner
|
||
- Bahis grade'lerini açıkla: A = güvenli, B = iyi, PASS = oynama
|
||
- Data quality ve risk seviyesini yorumla (kadro onaylı mı, probable XI mi)
|
||
- "Ben olsam..." formatında kişisel tavsiye ver
|
||
- Emoji kullan: ⚽ ✅ ⚠️ 🎯 ❌ 💰
|
||
- Markdown formatı KULLANMA, düz metin yaz
|
||
- Bahis terminolojisi kullan: edge, value, implied odds, xG`;
|