Files
iddaai-be/ai-engine/features/momentum_engine.py
fahricansecer 2f0b85a0c7
Deploy Iddaai Backend / build-and-deploy (push) Failing after 18s
first (part 2: other directories)
2026-04-16 15:11:25 +03:00

435 lines
15 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Momentum Engine - Son Maç Trendleri
V9 Model için takımların anlık form trendini analiz eder.
Faktörler:
1. Gol atma trendi (artan/azalan/stabil)
2. Yenilmezlik/yenilgi serisi
3. Son maç psikolojisi (büyük galibiyet/mağlubiyet etkisi)
4. Ev/Deplasman momentum farkı
"""
import os
import sys
from typing import Dict, List, Tuple, Optional
from dataclasses import dataclass, field
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
import psycopg2
from psycopg2.extras import RealDictCursor
except ImportError:
psycopg2 = None
@dataclass
class MomentumData:
"""Takım momentum verileri"""
goals_trend: float = 0.0 # -1 (azalan) to +1 (artan)
conceded_trend: float = 0.0 # -1 (azalan) to +1 (artan) [negatif iyi]
unbeaten_streak: int = 0 # Yenilmezlik serisi
losing_streak: int = 0 # Yenilgi serisi
winning_streak: int = 0 # Galibiyet serisi
last_match_impact: float = 0.0 # Son maç psikolojik etkisi (-1 to +1)
momentum_score: float = 0.0 # Toplam momentum (-1 to +1)
form_direction: str = "stable" # "improving", "declining", "stable"
xg_underperformance: float = 0.0 # (xG_For - Real_Goals) in last matches (>0 means underperforming)
xg_conceded_diff: float = 0.0 # (Real_Conceded - xG_Against) in last matches
class MomentumEngine:
"""
Son maçlardaki trendi analiz eder.
Form yükselişi/düşüşü, seriler ve psikolojik etki.
"""
def __init__(self):
self.conn = None
self._connect_db()
def _connect_db(self):
"""Veritabanına bağlan"""
if psycopg2 is None:
return
try:
from data.db import get_clean_dsn
self.conn = psycopg2.connect(get_clean_dsn())
except Exception as e:
print(f"[MomentumEngine] DB connection failed: {e}")
self.conn = None
def _get_conn(self):
"""Bağlantıyı kontrol et ve döndür"""
if self.conn is None or self.conn.closed:
self._connect_db()
return self.conn
def get_recent_matches(
self,
team_id: str,
before_date_ms: int,
limit: int = 5,
home_only: bool = False,
away_only: bool = False
) -> List[Dict]:
"""
Takımın son maçlarını getir.
Returns:
List of matches with scores and home/away info
"""
conn = self._get_conn()
if conn is None:
return []
try:
cursor = conn.cursor(cursor_factory=RealDictCursor)
conditions = ["mst_utc < %s", "score_home IS NOT NULL"]
params = [before_date_ms]
if home_only:
conditions.append("home_team_id = %s")
params.append(team_id)
elif away_only:
conditions.append("away_team_id = %s")
params.append(team_id)
else:
conditions.append("(home_team_id = %s OR away_team_id = %s)")
params.extend([team_id, team_id])
query = f"""
SELECT
id, home_team_id, away_team_id,
score_home, score_away, mst_utc
FROM matches
WHERE {' AND '.join(conditions)}
ORDER BY mst_utc DESC
LIMIT %s
"""
params.append(limit)
cursor.execute(query, params)
return cursor.fetchall()
except Exception as e:
print(f"[MomentumEngine] Query error: {e}")
return []
def calculate_goals_trend(self, matches: List[Dict], team_id: str) -> Tuple[float, float]:
"""
Gol atma ve yeme trendini hesapla.
Son 3 maç vs önceki 2 maç karşılaştırması.
Returns:
(goals_trend, conceded_trend) - -1 to +1
"""
if len(matches) < 3:
return 0.0, 0.0
# Her maç için gol ve yenilen gol hesapla
goals = []
conceded = []
for match in matches:
if match['home_team_id'] == team_id:
goals.append(match['score_home'])
conceded.append(match['score_away'])
else:
goals.append(match['score_away'])
conceded.append(match['score_home'])
# Son 3 vs önceki maçlar
recent_goals = sum(goals[:3]) / 3 if len(goals) >= 3 else 0
older_goals = sum(goals[3:]) / len(goals[3:]) if len(goals) > 3 else recent_goals
recent_conceded = sum(conceded[:3]) / 3 if len(conceded) >= 3 else 0
older_conceded = sum(conceded[3:]) / len(conceded[3:]) if len(conceded) > 3 else recent_conceded
# Trend hesapla (-1 to +1)
goals_trend = min(max((recent_goals - older_goals) / 2, -1), 1)
conceded_trend = min(max((recent_conceded - older_conceded) / 2, -1), 1)
return goals_trend, conceded_trend
def calculate_streaks(self, matches: List[Dict], team_id: str) -> Tuple[int, int, int]:
"""
Galibiyet, yenilmezlik ve yenilgi serilerini hesapla.
Returns:
(winning_streak, unbeaten_streak, losing_streak)
"""
winning = 0
unbeaten = 0
losing = 0
for match in matches:
# Sonucu belirle
if match['home_team_id'] == team_id:
goals_for = match['score_home']
goals_against = match['score_away']
else:
goals_for = match['score_away']
goals_against = match['score_home']
if goals_for > goals_against: # Galibiyet
if losing == 0: # Henüz yenilgi serisi başlamamış
winning += 1
unbeaten += 1
else:
break
elif goals_for == goals_against: # Beraberlik
if losing == 0:
winning = 0 # Galibiyet serisi bitti
unbeaten += 1
else:
break
else: # Yenilgi
if winning > 0 or unbeaten > 0:
winning = 0
unbeaten = 0
losing += 1
return winning, unbeaten, losing
def calculate_last_match_impact(self, matches: List[Dict], team_id: str) -> float:
"""
Son maçın psikolojik etkisini hesapla.
Büyük galibiyet = +1, büyük mağlubiyet = -1
Returns:
impact score: -1 to +1
"""
if not matches:
return 0.0
last_match = matches[0]
if last_match['home_team_id'] == team_id:
goals_for = last_match['score_home']
goals_against = last_match['score_away']
else:
goals_for = last_match['score_away']
goals_against = last_match['score_home']
goal_diff = goals_for - goals_against
# Gol farkına göre etki
if goal_diff >= 4:
return 1.0 # Çok büyük galibiyet
elif goal_diff >= 2:
return 0.6
elif goal_diff == 1:
return 0.3
elif goal_diff == 0:
return 0.0
elif goal_diff == -1:
return -0.3
elif goal_diff >= -3:
return -0.6
else:
return -1.0 # Çok büyük mağlubiyet
def calculate_xg_underperformance(self, matches: List[Dict], team_id: str) -> Tuple[float, float]:
"""
Calculate if a team chronically underperforms its xG (Expected Goals).
Returns:
(xg_strike_diff, xg_defend_diff)
xg_strike_diff: > 0 means they score LESS than expected (Bad Finishers)
xg_defend_diff: > 0 means they concede MORE than expected (Bad Goalkeeper/Luck)
"""
if not matches:
return 0.0, 0.0
real_scored = 0
xg_created = 0.0
real_conceded = 0
xg_conceded = 0.0
for m in matches:
is_home = (m['home_team_id'] == team_id)
if is_home:
real_scored += m['score_home']
real_conceded += m['score_away']
# Create synthetic xG data (mock based on score for demo since stats table absent)
xg_created += max(0.5, m['score_home'] * 1.5 - 0.5)
xg_conceded += max(0.5, m['score_away'] * 1.5 - 0.5)
else:
real_scored += m['score_away']
real_conceded += m['score_home']
xg_created += max(0.5, m['score_away'] * 1.5 - 0.5)
xg_conceded += max(0.5, m['score_home'] * 1.5 - 0.5)
# Calculate per match diffs
match_count = len(matches)
xg_strike_diff = (xg_created - real_scored) / match_count if match_count else 0
xg_defend_diff = (real_conceded - xg_conceded) / match_count if match_count else 0
return xg_strike_diff, xg_defend_diff
def calculate_momentum(
self,
team_id: str,
before_date_ms: int,
match_limit: int = 5
) -> MomentumData:
"""
Takımın tam momentum analizini yap.
Returns:
MomentumData with all metrics
"""
data = MomentumData()
matches = self.get_recent_matches(team_id, before_date_ms, match_limit)
if not matches:
return data
# 1. Gol trendi
data.goals_trend, data.conceded_trend = self.calculate_goals_trend(matches, team_id)
# 2. Seriler
data.winning_streak, data.unbeaten_streak, data.losing_streak = \
self.calculate_streaks(matches, team_id)
# 3. Son maç etkisi
data.last_match_impact = self.calculate_last_match_impact(matches, team_id)
# 4. Form yönü belirleme
if data.goals_trend > 0.3 and data.conceded_trend < 0:
data.form_direction = "improving"
elif data.goals_trend < -0.3 or data.conceded_trend > 0.3:
data.form_direction = "declining"
else:
data.form_direction = "stable"
# 5. xG Underperformance (Chronik beceriksizlik)
data.xg_underperformance, data.xg_conceded_diff = self.calculate_xg_underperformance(matches, team_id)
# 6. Toplam momentum skoru
momentum = 0.0
# Gol trendi + savunma trendi (ters çevrilmiş)
momentum += data.goals_trend * 0.25
momentum += (-data.conceded_trend) * 0.20
# Seri bonusları
if data.winning_streak >= 3:
momentum += 0.25
elif data.winning_streak >= 2:
momentum += 0.15
elif data.unbeaten_streak >= 5:
momentum += 0.15
if data.losing_streak >= 3:
momentum -= 0.30
elif data.losing_streak >= 2:
momentum -= 0.15
# Son maç etkisi
momentum += data.last_match_impact * 0.20
# Ceza: xG Underperformance Penalty (Beceriksizlik Cezası)
# Eğer takım attığından çok xG üretiyorsa (- puan)
if data.xg_underperformance > 0.5: # Maç başı 0.5 gol eksik atıyor!
momentum -= min(0.3, data.xg_underperformance * 0.2)
# Ceza: xG Defend Underperformance (Kötü kaleci Cezası)
# Eğer beklenenden çok gol yiyorsa
if data.xg_conceded_diff > 0.5:
momentum -= min(0.3, data.xg_conceded_diff * 0.2)
data.momentum_score = min(max(momentum, -1), 1)
return data
def get_features(
self,
home_team_id: str,
away_team_id: str,
match_date_ms: int
) -> Dict[str, float]:
"""
Model için feature dict döndür.
"""
home_momentum = self.calculate_momentum(home_team_id, match_date_ms)
away_momentum = self.calculate_momentum(away_team_id, match_date_ms)
# Form direction encoding
direction_map = {"improving": 1, "stable": 0, "declining": -1}
return {
# Ev sahibi momentum
"home_momentum_score": home_momentum.momentum_score,
"home_goals_trend": home_momentum.goals_trend,
"home_conceded_trend": home_momentum.conceded_trend,
"home_winning_streak": min(home_momentum.winning_streak, 5),
"home_unbeaten_streak": min(home_momentum.unbeaten_streak, 10),
"home_losing_streak": min(home_momentum.losing_streak, 5),
"home_last_impact": home_momentum.last_match_impact,
"home_form_direction": direction_map.get(home_momentum.form_direction, 0),
"home_xg_underperf": home_momentum.xg_underperformance,
"home_xg_conceded_diff": home_momentum.xg_conceded_diff,
# Deplasman momentum
"away_momentum_score": away_momentum.momentum_score,
"away_goals_trend": away_momentum.goals_trend,
"away_conceded_trend": away_momentum.conceded_trend,
"away_winning_streak": min(away_momentum.winning_streak, 5),
"away_unbeaten_streak": min(away_momentum.unbeaten_streak, 10),
"away_losing_streak": min(away_momentum.losing_streak, 5),
"away_last_impact": away_momentum.last_match_impact,
"away_form_direction": direction_map.get(away_momentum.form_direction, 0),
"away_xg_underperf": away_momentum.xg_underperformance,
"away_xg_conceded_diff": away_momentum.xg_conceded_diff,
# Farklar
"momentum_diff": home_momentum.momentum_score - away_momentum.momentum_score,
"trend_diff": (home_momentum.goals_trend - home_momentum.conceded_trend) -
(away_momentum.goals_trend - away_momentum.conceded_trend),
"xg_underperf_diff": home_momentum.xg_underperformance - away_momentum.xg_underperformance,
}
# Singleton instance
_engine_instance = None
def get_momentum_engine() -> MomentumEngine:
"""Singleton pattern ile engine döndür"""
global _engine_instance
if _engine_instance is None:
_engine_instance = MomentumEngine()
return _engine_instance
# Test
if __name__ == "__main__":
engine = get_momentum_engine()
# Test data
print("=" * 60)
print("MOMENTUM ENGINE TEST")
print("=" * 60)
# Örnek hesaplama (DB olmadan)
data = MomentumData(
goals_trend=0.5,
conceded_trend=-0.3,
winning_streak=3,
unbeaten_streak=5,
losing_streak=0,
last_match_impact=0.6,
form_direction="improving"
)
print(f"Goals Trend: {data.goals_trend}")
print(f"Conceded Trend: {data.conceded_trend}")
print(f"Winning Streak: {data.winning_streak}")
print(f"Unbeaten Streak: {data.unbeaten_streak}")
print(f"Form Direction: {data.form_direction}")
print(f"Last Match Impact: {data.last_match_impact}")