gg
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user