This commit is contained in:
2026-04-22 02:17:02 +03:00
parent 2ccd6831eb
commit df428ed1e8
19 changed files with 6436 additions and 9 deletions
@@ -4,6 +4,11 @@ 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";
@@ -131,7 +136,10 @@ export class SmartCouponService {
private readonly aiEngineUrl: string;
private readonly aiEngineClient: AiEngineClient;
constructor(private readonly geminiService: GeminiService) {
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,
@@ -244,6 +252,235 @@ export class SmartCouponService {
);
}
}
// ─────────────────────────────────────────────────────────────
// 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.