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 { 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( ` 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 { 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.8) { 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.6) { 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.5) { 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.6) { 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.7 && homeOdds > 1.1 && homeOdds < 3.5) { 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 { const nowMs = Date.now(); if (matchIds && matchIds.length > 0) { // Belirli maçlar istendi return this.prisma.$queryRawUnsafe( ` 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( ` 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 { const rows = await this.prisma.$queryRawUnsafe( ` 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 { 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 = { "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)))); } }