Files
iddaai-be/src/modules/coupons/services/smart-coupon.service.ts
T
2026-04-22 02:17:02 +03:00

500 lines
15 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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`;