578 lines
19 KiB
TypeScript
578 lines
19 KiB
TypeScript
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.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<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))));
|
||
}
|
||
}
|