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
@@ -0,0 +1,584 @@
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../../database/prisma.service";
// ─────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────
export interface FrequencySignal {
market: string;
pick: string;
homeSignal: number;
awaySignal: number;
combinedSignal: number;
homeMatchCount: number;
awayMatchCount: number;
leagueBonus: number;
confidence: number;
}
export interface MatchCandidate {
matchId: string;
homeTeamId: string;
awayTeamId: string;
homeTeamName: string;
awayTeamName: string;
leagueId: string;
leagueName: string;
homeOdds: number;
awayOdds: number;
drawOdds: number;
signals: FrequencySignal[];
bestSignal: FrequencySignal | null;
matchTime: number;
}
interface TeamFrequencyRow {
team_id: string;
venue: "home" | "away";
odds_band: string;
total_matches: number;
ou15_rate: number;
ou25_rate: number;
ou35_rate: number;
btts_rate: number;
win_rate: number;
avg_goals: number;
}
interface LeagueProfileRow {
league_id: string;
league_name: string;
total_matches: number;
ou25_rate: number;
btts_rate: number;
avg_goals: number;
}
interface UpcomingMatchRow {
match_id: string;
home_team_id: string;
away_team_id: string;
home_team_name: string;
away_team_name: string;
league_id: string;
league_name: string;
mst_utc: bigint;
ms1_odds: number | null;
ms2_odds: number | null;
msx_odds: number | null;
ou25_over_odds: number | null;
ou25_under_odds: number | null;
btts_yes_odds: number | null;
btts_no_odds: number | null;
ou15_over_odds: number | null;
ou35_over_odds: number | null;
}
// ─────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────
const MIN_MATCHES = 3;
const GOLCU_LEAGUES = new Set([
// Strategy generator'dan türetilen yüksek golcü ligler
// Lig isimleri veritabanındaki gibi
]);
const DEFANSIF_LEAGUES = new Set([
// Düşük golcü ligler
]);
// ─────────────────────────────────────────────────────────────
// Service
// ─────────────────────────────────────────────────────────────
@Injectable()
export class FrequencyEngineService {
private readonly logger = new Logger(FrequencyEngineService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Belirli bir takımın ev/deplasman + oran bandı koşullu frekanslarını döndürür.
*/
async getTeamFrequency(
teamId: string,
venue: "home" | "away",
oddsBand: string,
): Promise<TeamFrequencyRow | null> {
const venueColumn =
venue === "home" ? "m.home_team_id" : "m.away_team_id";
const oddsSelection = venue === "home" ? "'1'" : "'2'";
const bandRange = this.parseBandRange(oddsBand);
if (!bandRange) {
return null;
}
const rows = await this.prisma.$queryRawUnsafe<TeamFrequencyRow[]>(
`
WITH team_matches AS (
SELECT
m.id AS match_id,
m.score_home,
m.score_away,
(m.score_home + m.score_away) AS total_goals,
CAST(os.odd_value AS DECIMAL) AS team_odds
FROM matches m
JOIN odd_categories oc ON oc.match_id = m.id AND oc.name = 'Maç Sonucu'
JOIN odd_selections os ON os.odd_category_db_id = oc.db_id AND os.name = ${oddsSelection}
WHERE m.status = 'FT'
AND m.score_home IS NOT NULL
AND ${venueColumn} = $1
AND CAST(os.odd_value AS DECIMAL) >= $2
AND CAST(os.odd_value AS DECIMAL) < $3
)
SELECT
$1::text AS team_id,
$4::text AS venue,
$5::text AS odds_band,
COUNT(*)::int AS total_matches,
COALESCE(AVG(CASE WHEN total_goals > 1 THEN 1.0 ELSE 0.0 END), 0)::float AS ou15_rate,
COALESCE(AVG(CASE WHEN total_goals > 2 THEN 1.0 ELSE 0.0 END), 0)::float AS ou25_rate,
COALESCE(AVG(CASE WHEN total_goals > 3 THEN 1.0 ELSE 0.0 END), 0)::float AS ou35_rate,
COALESCE(AVG(CASE WHEN score_home > 0 AND score_away > 0 THEN 1.0 ELSE 0.0 END), 0)::float AS btts_rate,
COALESCE(AVG(CASE WHEN ${venue === "home" ? "score_home > score_away" : "score_away > score_home"} THEN 1.0 ELSE 0.0 END), 0)::float AS win_rate,
COALESCE(AVG(total_goals), 0)::float AS avg_goals
FROM team_matches
`,
teamId,
bandRange.min,
bandRange.max,
venue,
oddsBand,
);
if (!rows.length || rows[0].total_matches < MIN_MATCHES) {
return null;
}
return rows[0];
}
/**
* İki takımın oran bandı geçmişlerini çapraz kontrol eder.
* Tüm marketler için kombine sinyal üretir.
*/
async getMatchFrequencySignals(
homeTeamId: string,
awayTeamId: string,
homeOdds: number,
awayOdds: number,
leagueId?: string,
): Promise<FrequencySignal[]> {
const homeBand = this.getOddsBand(homeOdds);
const awayBand = this.getOddsBand(awayOdds);
const [homeFreq, awayFreq, leagueProfile] = await Promise.all([
this.getTeamFrequency(homeTeamId, "home", homeBand),
this.getTeamFrequency(awayTeamId, "away", awayBand),
leagueId ? this.getLeagueProfile(leagueId) : null,
]);
if (!homeFreq || !awayFreq) {
return [];
}
const leagueBonus = this.calculateLeagueBonus(leagueProfile);
const signals: FrequencySignal[] = [];
// OU 1.5 OVER
const ou15Combined = (homeFreq.ou15_rate + awayFreq.ou15_rate) / 2;
if (ou15Combined >= 0.80) {
signals.push({
market: "OU1.5_OVER",
pick: "1.5 UST",
homeSignal: homeFreq.ou15_rate,
awaySignal: awayFreq.ou15_rate,
combinedSignal: ou15Combined,
homeMatchCount: homeFreq.total_matches,
awayMatchCount: awayFreq.total_matches,
leagueBonus,
confidence: this.calculateConfidence(
ou15Combined,
homeFreq.total_matches,
awayFreq.total_matches,
leagueBonus,
),
});
}
// OU 2.5 OVER
const ou25Combined = (homeFreq.ou25_rate + awayFreq.ou25_rate) / 2;
if (ou25Combined >= 0.60) {
signals.push({
market: "OU2.5_OVER",
pick: "2.5 UST",
homeSignal: homeFreq.ou25_rate,
awaySignal: awayFreq.ou25_rate,
combinedSignal: ou25Combined,
homeMatchCount: homeFreq.total_matches,
awayMatchCount: awayFreq.total_matches,
leagueBonus,
confidence: this.calculateConfidence(
ou25Combined,
homeFreq.total_matches,
awayFreq.total_matches,
leagueBonus,
),
});
}
// OU 3.5 OVER
const ou35Combined = (homeFreq.ou35_rate + awayFreq.ou35_rate) / 2;
if (ou35Combined >= 0.50) {
signals.push({
market: "OU3.5_OVER",
pick: "3.5 UST",
homeSignal: homeFreq.ou35_rate,
awaySignal: awayFreq.ou35_rate,
combinedSignal: ou35Combined,
homeMatchCount: homeFreq.total_matches,
awayMatchCount: awayFreq.total_matches,
leagueBonus,
confidence: this.calculateConfidence(
ou35Combined,
homeFreq.total_matches,
awayFreq.total_matches,
leagueBonus,
),
});
}
// BTTS YES
const bttsCombined = (homeFreq.btts_rate + awayFreq.btts_rate) / 2;
if (bttsCombined >= 0.60) {
signals.push({
market: "BTTS_YES",
pick: "KG VAR",
homeSignal: homeFreq.btts_rate,
awaySignal: awayFreq.btts_rate,
combinedSignal: bttsCombined,
homeMatchCount: homeFreq.total_matches,
awayMatchCount: awayFreq.total_matches,
leagueBonus,
confidence: this.calculateConfidence(
bttsCombined,
homeFreq.total_matches,
awayFreq.total_matches,
leagueBonus,
),
});
}
// OU 2.5 UNDER (düşük gol beklentisi)
const ou25UnderCombined =
(1 - homeFreq.ou25_rate + (1 - awayFreq.ou25_rate)) / 2;
if (ou25UnderCombined >= 0.65) {
signals.push({
market: "OU2.5_UNDER",
pick: "2.5 ALT",
homeSignal: 1 - homeFreq.ou25_rate,
awaySignal: 1 - awayFreq.ou25_rate,
combinedSignal: ou25UnderCombined,
homeMatchCount: homeFreq.total_matches,
awayMatchCount: awayFreq.total_matches,
leagueBonus: -leagueBonus, // golcü lig bonusu ters çevrilir
confidence: this.calculateConfidence(
ou25UnderCombined,
homeFreq.total_matches,
awayFreq.total_matches,
-leagueBonus,
),
});
}
// MS HOME WIN (ev sahibi kazanma)
const hwCombined = (homeFreq.win_rate + awayFreq.win_rate) / 2;
// awayFreq.win_rate aslında deplasman takımının KAYBETme oranı
// (away takımı o bandda maçları kazanma değil, kaybetme olarak bak)
if (hwCombined >= 0.70 && homeOdds > 1.10 && homeOdds < 3.50) {
signals.push({
market: "MS_HOME",
pick: "MS 1",
homeSignal: homeFreq.win_rate,
awaySignal: awayFreq.win_rate,
combinedSignal: hwCombined,
homeMatchCount: homeFreq.total_matches,
awayMatchCount: awayFreq.total_matches,
leagueBonus: 0,
confidence: this.calculateConfidence(
hwCombined,
homeFreq.total_matches,
awayFreq.total_matches,
0,
),
});
}
// Güvene göre sırala (en güçlü sinyal önce)
signals.sort((a, b) => b.confidence - a.confidence);
return signals;
}
/**
* Yaklaşan maçları oranlarıyla birlikte getirir.
* LiveMatch tablosundan JSON odds parse eder.
*/
async getUpcomingMatchesWithOdds(
matchIds?: string[],
limit: number = 50,
): Promise<UpcomingMatchRow[]> {
const nowMs = Date.now();
if (matchIds && matchIds.length > 0) {
// Belirli maçlar istendi
return this.prisma.$queryRawUnsafe<UpcomingMatchRow[]>(
`
SELECT
lm.id AS match_id,
lm.home_team_id,
lm.away_team_id,
COALESCE(ht.name, 'Unknown') AS home_team_name,
COALESCE(at.name, 'Unknown') AS away_team_name,
COALESCE(lm.league_id, '') AS league_id,
COALESCE(l.name, 'Unknown') AS league_name,
lm.mst_utc,
(lm.odds->'Maç Sonucu'->>'1')::decimal AS ms1_odds,
(lm.odds->'Maç Sonucu'->>'2')::decimal AS ms2_odds,
(lm.odds->'Maç Sonucu'->>'0')::decimal AS msx_odds,
(lm.odds->'2,5 Alt/Üst'->>'Üst')::decimal AS ou25_over_odds,
(lm.odds->'2,5 Alt/Üst'->>'Alt')::decimal AS ou25_under_odds,
(lm.odds->'Karşılıklı Gol'->>'Var')::decimal AS btts_yes_odds,
(lm.odds->'Karşılıklı Gol'->>'Yok')::decimal AS btts_no_odds,
(lm.odds->'1,5 Alt/Üst'->>'Üst')::decimal AS ou15_over_odds,
(lm.odds->'3,5 Alt/Üst'->>'Üst')::decimal AS ou35_over_odds
FROM live_matches lm
LEFT JOIN teams ht ON lm.home_team_id = ht.id
LEFT JOIN teams at ON lm.away_team_id = at.id
LEFT JOIN leagues l ON lm.league_id = l.id
WHERE lm.id = ANY($1)
AND lm.odds IS NOT NULL
AND lm.odds != 'null'::jsonb
ORDER BY lm.mst_utc ASC
`,
matchIds,
);
}
// Otomatik: yaklaşan tüm maçlar
return this.prisma.$queryRawUnsafe<UpcomingMatchRow[]>(
`
SELECT
lm.id AS match_id,
lm.home_team_id,
lm.away_team_id,
COALESCE(ht.name, 'Unknown') AS home_team_name,
COALESCE(at.name, 'Unknown') AS away_team_name,
COALESCE(lm.league_id, '') AS league_id,
COALESCE(l.name, 'Unknown') AS league_name,
lm.mst_utc,
(lm.odds->'Maç Sonucu'->>'1')::decimal AS ms1_odds,
(lm.odds->'Maç Sonucu'->>'2')::decimal AS ms2_odds,
(lm.odds->'Maç Sonucu'->>'0')::decimal AS msx_odds,
(lm.odds->'2,5 Alt/Üst'->>'Üst')::decimal AS ou25_over_odds,
(lm.odds->'2,5 Alt/Üst'->>'Alt')::decimal AS ou25_under_odds,
(lm.odds->'Karşılıklı Gol'->>'Var')::decimal AS btts_yes_odds,
(lm.odds->'Karşılıklı Gol'->>'Yok')::decimal AS btts_no_odds,
(lm.odds->'1,5 Alt/Üst'->>'Üst')::decimal AS ou15_over_odds,
(lm.odds->'3,5 Alt/Üst'->>'Üst')::decimal AS ou35_over_odds
FROM live_matches lm
LEFT JOIN teams ht ON lm.home_team_id = ht.id
LEFT JOIN teams at ON lm.away_team_id = at.id
LEFT JOIN leagues l ON lm.league_id = l.id
WHERE lm.mst_utc >= $1
AND lm.sport = 'football'
AND lm.odds IS NOT NULL
AND lm.odds != 'null'::jsonb
AND (lm.status IS NULL OR lm.status NOT IN ('FT', 'AET', 'PEN', 'ABD', 'CANC', 'PST', 'SUSP', 'INT', 'AWD', 'WO'))
AND (lm.state IS NULL OR lm.state NOT IN ('after', 'postponed', 'cancelled', 'abandoned'))
ORDER BY lm.mst_utc ASC
LIMIT $2
`,
BigInt(nowMs),
limit,
);
}
/**
* Lig bazlı gol profili.
*/
async getLeagueProfile(
leagueId: string,
): Promise<LeagueProfileRow | null> {
const rows = await this.prisma.$queryRawUnsafe<LeagueProfileRow[]>(
`
SELECT
m.league_id,
l.name AS league_name,
COUNT(*)::int AS total_matches,
AVG(CASE WHEN (m.score_home + m.score_away) > 2 THEN 1.0 ELSE 0.0 END)::float AS ou25_rate,
AVG(CASE WHEN m.score_home > 0 AND m.score_away > 0 THEN 1.0 ELSE 0.0 END)::float AS btts_rate,
AVG(m.score_home + m.score_away)::float AS avg_goals
FROM matches m
JOIN leagues l ON m.league_id = l.id
WHERE m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.league_id = $1
GROUP BY m.league_id, l.name
HAVING COUNT(*) >= 20
`,
leagueId,
);
return rows.length > 0 ? rows[0] : null;
}
/**
* Bir upcoming match row'unu MatchCandidate'e dönüştürür
* ve frekans sinyallerini hesaplar.
*/
async buildMatchCandidate(
row: UpcomingMatchRow,
): Promise<MatchCandidate | null> {
const homeOdds = row.ms1_odds ? Number(row.ms1_odds) : 0;
const awayOdds = row.ms2_odds ? Number(row.ms2_odds) : 0;
const drawOdds = row.msx_odds ? Number(row.msx_odds) : 0;
if (homeOdds <= 0 || awayOdds <= 0) {
return null;
}
const signals = await this.getMatchFrequencySignals(
row.home_team_id,
row.away_team_id,
homeOdds,
awayOdds,
row.league_id || undefined,
);
if (signals.length === 0) {
return null;
}
return {
matchId: row.match_id,
homeTeamId: row.home_team_id,
awayTeamId: row.away_team_id,
homeTeamName: row.home_team_name,
awayTeamName: row.away_team_name,
leagueId: row.league_id,
leagueName: row.league_name,
homeOdds,
awayOdds,
drawOdds,
signals,
bestSignal: signals[0] || null,
matchTime: Number(row.mst_utc),
};
}
/**
* Bir market pick'ine karşılık gelen odds'u UpcomingMatchRow'dan çeker.
*/
getMarketOdds(row: UpcomingMatchRow, market: string): number {
switch (market) {
case "OU1.5_OVER":
return row.ou15_over_odds ? Number(row.ou15_over_odds) : 0;
case "OU2.5_OVER":
return row.ou25_over_odds ? Number(row.ou25_over_odds) : 0;
case "OU2.5_UNDER":
return row.ou25_under_odds ? Number(row.ou25_under_odds) : 0;
case "OU3.5_OVER":
return row.ou35_over_odds ? Number(row.ou35_over_odds) : 0;
case "BTTS_YES":
return row.btts_yes_odds ? Number(row.btts_yes_odds) : 0;
case "MS_HOME":
return row.ms1_odds ? Number(row.ms1_odds) : 0;
default:
return 0;
}
}
// ─────────────────────────────────────────────────────────────
// Private Helpers
// ─────────────────────────────────────────────────────────────
/**
* Oran bandı fonksiyonu — strategy_generator.py ile aynı mantık.
*/
getOddsBand(odds: number): string {
if (odds < 1.3) return "1.00-1.30";
if (odds < 1.5) return "1.30-1.50";
if (odds < 1.8) return "1.50-1.80";
if (odds < 2.2) return "1.80-2.20";
if (odds < 2.8) return "2.20-2.80";
if (odds < 4.0) return "2.80-4.00";
if (odds < 6.0) return "4.00-6.00";
return "6.00+";
}
private parseBandRange(
band: string,
): { min: number; max: number } | null {
const map: Record<string, { min: number; max: number }> = {
"1.00-1.30": { min: 1.0, max: 1.3 },
"1.30-1.50": { min: 1.3, max: 1.5 },
"1.50-1.80": { min: 1.5, max: 1.8 },
"1.80-2.20": { min: 1.8, max: 2.2 },
"2.20-2.80": { min: 2.2, max: 2.8 },
"2.80-4.00": { min: 2.8, max: 4.0 },
"4.00-6.00": { min: 4.0, max: 6.0 },
"6.00+": { min: 6.0, max: 999.0 },
};
return map[band] || null;
}
private calculateLeagueBonus(
profile: LeagueProfileRow | null,
): number {
if (!profile || profile.total_matches < 20) {
return 0;
}
// OU2.5 > %60 ise golcü lig bonusu
if (profile.ou25_rate > 0.6) {
return Math.min((profile.ou25_rate - 0.5) * 0.2, 0.05);
}
// OU2.5 < %40 ise defansif lig bonusu (negatif)
if (profile.ou25_rate < 0.4) {
return Math.max((profile.ou25_rate - 0.5) * 0.2, -0.05);
}
return 0;
}
private calculateConfidence(
combinedSignal: number,
homeN: number,
awayN: number,
leagueBonus: number,
): number {
// Base confidence: kombine sinyal * 100
let confidence = combinedSignal * 100;
// Sample size bonus: daha fazla veri = daha güvenilir
const minN = Math.min(homeN, awayN);
if (minN >= 20) {
confidence += 5;
} else if (minN >= 10) {
confidence += 2;
} else if (minN < 5) {
confidence -= 5;
}
// Liga bonusu
confidence += leagueBonus * 100;
return Math.max(0, Math.min(100, parseFloat(confidence.toFixed(1))));
}
}
@@ -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.