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))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user