Files
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

991 lines
33 KiB
Python
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.
"""
Feature Extractor - V2 Betting Engine
Pulls historical team stats, ELO, missing-player impact and live odds from
PostgreSQL and engineers a leakage-free feature vector for the ensemble model.
CRITICAL: Only pre-match data (matches before the target match) is used.
Post-match stats of the target match are NEVER included.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
ROLLING_WINDOW: int = 5
H2H_WINDOW: int = 10
MAX_REST_DAYS: float = 14.0
@dataclass
class MatchFeatures:
"""Structured feature vector ready for the ensemble model."""
match_id: str = ""
home_team_id: str = ""
away_team_id: str = ""
# ELO & AI features
home_elo: float = 1500.0
away_elo: float = 1500.0
elo_diff: float = 0.0
missing_players_impact: float = 0.0
home_form_score: float = 0.0
away_form_score: float = 0.0
h2h_home_win_rate: float = 0.5
h2h_sample_size: int = 0
home_rest_days: float = 7.0
away_rest_days: float = 7.0
rest_diff: float = 0.0
home_lineup_availability: float = 1.0
away_lineup_availability: float = 1.0
# Rolling averages - Home (last 5 matches)
home_avg_possession: float = 50.0
home_avg_shots_on_target: float = 4.0
home_avg_total_shots: float = 10.0
home_avg_goals_scored: float = 1.3
home_avg_goals_conceded: float = 1.1
# Rolling averages - Away (last 5 matches)
away_avg_possession: float = 50.0
away_avg_shots_on_target: float = 4.0
away_avg_total_shots: float = 10.0
away_avg_goals_scored: float = 1.3
away_avg_goals_conceded: float = 1.1
# Implied probabilities from bookmaker odds
implied_prob_home: float = 0.33
implied_prob_draw: float = 0.33
implied_prob_away: float = 0.33
implied_prob_over25: float = 0.50
implied_prob_under25: float = 0.50
implied_prob_btts_yes: float = 0.50
implied_prob_btts_no: float = 0.50
# Raw decimal odds (for Edge/Kelly calculations downstream)
odds_home: float = 2.50
odds_draw: float = 3.20
odds_away: float = 2.80
odds_over25: float = 1.90
odds_under25: float = 1.90
odds_btts_yes: float = 1.85
odds_btts_no: float = 1.95
# Data quality
data_quality_score: float = 0.5
data_quality_flags: list[str] = field(default_factory=list)
# Metadata
match_name: str = ""
home_team_name: str = ""
away_team_name: str = ""
league_id: str = ""
league_name: str = ""
referee_name: str = ""
match_date_ms: int = 0
league_avg_goals: float = 2.6
referee_avg_goals: float = 2.6
referee_home_bias: float = 0.0
home_squad_strength: float = 0.5
away_squad_strength: float = 0.5
home_key_players: float = 0.0
away_key_players: float = 0.0
def to_model_array(self) -> np.ndarray:
"""Return the 24-feature vector the ensemble expects."""
return np.array(
[
self.home_elo,
self.away_elo,
self.elo_diff,
self.missing_players_impact,
self.home_avg_possession,
self.home_avg_shots_on_target,
self.home_avg_total_shots,
self.home_avg_goals_scored,
self.home_avg_goals_conceded,
self.away_avg_possession,
self.away_avg_shots_on_target,
self.away_avg_total_shots,
self.away_avg_goals_scored,
self.away_avg_goals_conceded,
self.implied_prob_home,
self.implied_prob_draw,
self.implied_prob_away,
self.implied_prob_over25,
self.implied_prob_under25,
self.implied_prob_btts_yes,
self.implied_prob_btts_no,
self.odds_home,
self.odds_draw,
self.odds_away,
],
dtype=np.float64,
)
@staticmethod
def feature_names() -> list[str]:
return [
"home_elo", "away_elo", "elo_diff", "missing_players_impact",
"home_avg_possession", "home_avg_shots_on_target",
"home_avg_total_shots", "home_avg_goals_scored",
"home_avg_goals_conceded",
"away_avg_possession", "away_avg_shots_on_target",
"away_avg_total_shots", "away_avg_goals_scored",
"away_avg_goals_conceded",
"implied_prob_home", "implied_prob_draw", "implied_prob_away",
"implied_prob_over25", "implied_prob_under25",
"implied_prob_btts_yes", "implied_prob_btts_no",
"odds_home", "odds_draw", "odds_away",
]
async def extract_features(session: AsyncSession, match_id: str) -> MatchFeatures | None:
"""Master extraction pipeline."""
feats = MatchFeatures(match_id=match_id)
flags: list[str] = []
match_row = await _load_match_header(session, match_id)
if match_row is None:
logger.warning("Match %s not found in live_matches or matches.", match_id)
return None
feats.home_team_id = match_row["home_team_id"] or ""
feats.away_team_id = match_row["away_team_id"] or ""
feats.match_name = match_row.get("match_name", "") or ""
feats.match_date_ms = int(match_row.get("mst_utc", 0) or 0)
feats.home_team_name = match_row.get("home_name", "") or ""
feats.away_team_name = match_row.get("away_name", "") or ""
feats.league_id = match_row.get("league_id", "") or ""
feats.league_name = match_row.get("league_name", "") or ""
feats.referee_name = match_row.get("referee_name", "") or ""
if not feats.home_team_id or not feats.away_team_id:
logger.warning("Match %s missing team IDs.", match_id)
flags.append("missing_team_ids")
feats.data_quality_flags = flags
feats.data_quality_score = 0.1
return feats
ai_row = await _load_ai_features(session, match_id)
if ai_row:
feats.home_elo = float(ai_row["home_elo"] or 1500.0)
feats.away_elo = float(ai_row["away_elo"] or 1500.0)
feats.missing_players_impact = float(ai_row["missing_players_impact"] or 0.0)
feats.home_form_score = float(ai_row["home_form_score"] or 0.0)
feats.away_form_score = float(ai_row["away_form_score"] or 0.0)
if ai_row.get("h2h_home_win_rate") is not None:
feats.h2h_home_win_rate = float(ai_row["h2h_home_win_rate"])
feats.h2h_sample_size = int(ai_row.get("h2h_total") or 0)
else:
flags.append("missing_ai_features")
feats.elo_diff = feats.home_elo - feats.away_elo
home_rolling = await _rolling_team_stats(
session, feats.home_team_id, feats.match_date_ms,
)
away_rolling = await _rolling_team_stats(
session, feats.away_team_id, feats.match_date_ms,
)
if home_rolling is not None:
feats.home_avg_possession = home_rolling["avg_possession"]
feats.home_avg_shots_on_target = home_rolling["avg_shots_on_target"]
feats.home_avg_total_shots = home_rolling["avg_total_shots"]
feats.home_avg_goals_scored = home_rolling["avg_goals_scored"]
feats.home_avg_goals_conceded = home_rolling["avg_goals_conceded"]
else:
flags.append("missing_home_stats")
if away_rolling is not None:
feats.away_avg_possession = away_rolling["avg_possession"]
feats.away_avg_shots_on_target = away_rolling["avg_shots_on_target"]
feats.away_avg_total_shots = away_rolling["avg_total_shots"]
feats.away_avg_goals_scored = away_rolling["avg_goals_scored"]
feats.away_avg_goals_conceded = away_rolling["avg_goals_conceded"]
else:
flags.append("missing_away_stats")
if abs(feats.home_form_score) < 1e-6:
feats.home_form_score = round(
feats.home_avg_goals_scored - feats.home_avg_goals_conceded,
3,
)
if abs(feats.away_form_score) < 1e-6:
feats.away_form_score = round(
feats.away_avg_goals_scored - feats.away_avg_goals_conceded,
3,
)
home_rest_days = await _load_rest_days(
session, feats.home_team_id, feats.match_date_ms,
)
away_rest_days = await _load_rest_days(
session, feats.away_team_id, feats.match_date_ms,
)
if home_rest_days is not None:
feats.home_rest_days = home_rest_days
else:
flags.append("missing_home_rest")
if away_rest_days is not None:
feats.away_rest_days = away_rest_days
else:
flags.append("missing_away_rest")
feats.rest_diff = round(feats.home_rest_days - feats.away_rest_days, 3)
if feats.h2h_sample_size == 0:
h2h = await _load_h2h_stats(
session,
feats.home_team_id,
feats.away_team_id,
feats.match_date_ms,
)
if h2h is not None:
feats.h2h_home_win_rate = h2h["home_win_rate"]
feats.h2h_sample_size = h2h["sample_size"]
else:
flags.append("missing_h2h")
league_profile = await _load_league_profile(
session,
feats.league_id,
feats.match_date_ms,
)
if league_profile is not None:
feats.league_avg_goals = league_profile["avg_goals"]
else:
flags.append("missing_league_profile")
referee_profile = await _load_referee_profile(
session,
feats.referee_name,
feats.match_date_ms,
)
if referee_profile is not None:
feats.referee_avg_goals = referee_profile["avg_goals"]
feats.referee_home_bias = referee_profile["home_bias"]
else:
flags.append("missing_referee_profile")
home_squad = await _load_team_squad_profile(
session,
feats.home_team_id,
feats.match_date_ms,
)
away_squad = await _load_team_squad_profile(
session,
feats.away_team_id,
feats.match_date_ms,
)
if home_squad is not None:
feats.home_squad_strength = home_squad["squad_strength"]
feats.home_key_players = home_squad["key_players"]
else:
flags.append("missing_home_squad_profile")
if away_squad is not None:
feats.away_squad_strength = away_squad["squad_strength"]
feats.away_key_players = away_squad["key_players"]
else:
flags.append("missing_away_squad_profile")
lineup_info = _extract_lineup_context(match_row)
feats.home_lineup_availability = lineup_info["home_availability"]
feats.away_lineup_availability = lineup_info["away_availability"]
if lineup_info["has_real_lineup_data"]:
feats.missing_players_impact = max(
feats.missing_players_impact,
round(
(
(1.0 - feats.home_lineup_availability)
+ (1.0 - feats.away_lineup_availability)
) / 2.0,
4,
),
)
else:
flags.append("missing_lineup_context")
odds_ok = await _extract_odds(session, match_id, feats)
if not odds_ok:
flags.append("missing_odds")
quality = 1.0
penalty_map = {
"missing_team_ids": 0.5,
"missing_ai_features": 0.05,
"missing_home_stats": 0.15,
"missing_away_stats": 0.15,
"missing_home_rest": 0.05,
"missing_away_rest": 0.05,
"missing_h2h": 0.05,
"missing_league_profile": 0.04,
"missing_referee_profile": 0.04,
"missing_home_squad_profile": 0.06,
"missing_away_squad_profile": 0.06,
"missing_lineup_context": 0.05,
"missing_odds": 0.2,
}
for flag in flags:
quality -= penalty_map.get(flag, 0.05)
feats.data_quality_score = max(0.0, round(quality, 2))
feats.data_quality_flags = flags
return feats
async def _load_match_header(
session: AsyncSession, match_id: str,
) -> dict[str, Any] | None:
"""Try live_matches first, then matches table."""
table_queries = {
"live_matches": """
SELECT
m.id,
m.home_team_id,
m.away_team_id,
m.match_name,
m.mst_utc,
m.sport,
m.league_id,
m.referee_name,
m.lineups,
m.sidelined,
ht.name AS home_name,
at.name AS away_name,
l.name AS league_name
FROM live_matches m
LEFT JOIN teams ht ON ht.id = m.home_team_id
LEFT JOIN teams at ON at.id = m.away_team_id
LEFT JOIN leagues l ON l.id = m.league_id
WHERE m.id = :match_id
LIMIT 1
""",
"matches": """
SELECT
m.id,
m.home_team_id,
m.away_team_id,
m.match_name,
m.mst_utc,
m.sport,
m.league_id,
ref.name AS referee_name,
NULL AS lineups,
NULL AS sidelined,
ht.name AS home_name,
at.name AS away_name,
l.name AS league_name
FROM matches m
LEFT JOIN teams ht ON ht.id = m.home_team_id
LEFT JOIN teams at ON at.id = m.away_team_id
LEFT JOIN leagues l ON l.id = m.league_id
LEFT JOIN match_officials ref ON ref.match_id = m.id AND ref.role_id = 1
WHERE m.id = :match_id
LIMIT 1
""",
}
for table in ("live_matches", "matches"):
query = text(table_queries[table])
result = await session.execute(query, {"match_id": match_id})
row = result.mappings().first()
if row:
return dict(row)
return None
async def _load_ai_features(
session: AsyncSession, match_id: str,
) -> dict[str, Any] | None:
query = text("""
SELECT
home_elo,
away_elo,
missing_players_impact,
home_form_score,
away_form_score,
h2h_home_win_rate,
h2h_total
FROM football_ai_features
WHERE match_id = :match_id
LIMIT 1
""")
result = await session.execute(query, {"match_id": match_id})
row = result.mappings().first()
return dict(row) if row else None
async def _rolling_team_stats(
session: AsyncSession,
team_id: str,
before_mst_utc: int,
) -> dict[str, float] | None:
"""Calculate rolling averages from the team's last N finished matches."""
query = text("""
WITH recent AS (
SELECT
m.id AS match_id,
m.home_team_id,
m.away_team_id,
m.score_home,
m.score_away,
ts.possession_percentage,
ts.shots_on_target,
ts.total_shots
FROM matches m
JOIN football_team_stats ts ON ts.match_id = m.id AND ts.team_id = :team_id
WHERE (m.home_team_id = :team_id OR m.away_team_id = :team_id)
AND m.mst_utc < :before_ts
AND m.sport = 'football'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
ORDER BY m.mst_utc DESC
LIMIT :window
)
SELECT
COALESCE(AVG(possession_percentage), 50.0) AS avg_possession,
COALESCE(AVG(shots_on_target), 4.0) AS avg_shots_on_target,
COALESCE(AVG(total_shots), 10.0) AS avg_total_shots,
COALESCE(AVG(
CASE
WHEN home_team_id = :team_id THEN score_home
ELSE score_away
END
), 1.3) AS avg_goals_scored,
COALESCE(AVG(
CASE
WHEN home_team_id = :team_id THEN score_away
ELSE score_home
END
), 1.1) AS avg_goals_conceded,
COUNT(*) AS match_count
FROM recent
""")
result = await session.execute(
query,
{"team_id": team_id, "before_ts": before_mst_utc, "window": ROLLING_WINDOW},
)
row = result.mappings().first()
if row is None or int(row["match_count"]) == 0:
return None
return {
"avg_possession": round(float(row["avg_possession"]), 2),
"avg_shots_on_target": round(float(row["avg_shots_on_target"]), 2),
"avg_total_shots": round(float(row["avg_total_shots"]), 2),
"avg_goals_scored": round(float(row["avg_goals_scored"]), 2),
"avg_goals_conceded": round(float(row["avg_goals_conceded"]), 2),
}
async def _load_rest_days(
session: AsyncSession,
team_id: str,
before_mst_utc: int,
) -> float | None:
query = text("""
SELECT m.mst_utc
FROM matches m
WHERE (m.home_team_id = :team_id OR m.away_team_id = :team_id)
AND m.mst_utc < :before_ts
AND m.sport = 'football'
ORDER BY m.mst_utc DESC
LIMIT 1
""")
result = await session.execute(
query,
{"team_id": team_id, "before_ts": before_mst_utc},
)
last_match_ts = result.scalar_one_or_none()
if last_match_ts is None:
return None
rest_days = max(0.0, (float(before_mst_utc) - float(last_match_ts)) / 86400000.0)
return round(min(rest_days, MAX_REST_DAYS), 3)
async def _load_h2h_stats(
session: AsyncSession,
home_team_id: str,
away_team_id: str,
before_mst_utc: int,
) -> dict[str, float | int] | None:
query = text("""
SELECT
m.home_team_id,
m.away_team_id,
m.score_home,
m.score_away
FROM matches m
WHERE m.sport = 'football'
AND m.mst_utc < :before_ts
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND (
(m.home_team_id = :home_team_id AND m.away_team_id = :away_team_id)
OR
(m.home_team_id = :away_team_id AND m.away_team_id = :home_team_id)
)
ORDER BY m.mst_utc DESC
LIMIT :window
""")
result = await session.execute(
query,
{
"home_team_id": home_team_id,
"away_team_id": away_team_id,
"before_ts": before_mst_utc,
"window": H2H_WINDOW,
},
)
rows = result.mappings().all()
if not rows:
return None
home_wins = 0.0
draws = 0.0
sample_size = 0
for row in rows:
score_home = row["score_home"]
score_away = row["score_away"]
if score_home is None or score_away is None:
continue
sample_size += 1
row_home_team_id = row["home_team_id"]
row_away_team_id = row["away_team_id"]
current_home_score = float(score_home) if row_home_team_id == home_team_id else float(score_away)
current_away_score = float(score_away) if row_home_team_id == home_team_id else float(score_home)
if current_home_score > current_away_score:
home_wins += 1.0
elif current_home_score == current_away_score:
draws += 1.0
if sample_size == 0:
return None
# Count draws as a half-win signal instead of throwing them away.
home_win_rate = round((home_wins + draws * 0.5) / sample_size, 4)
return {
"home_win_rate": home_win_rate,
"sample_size": sample_size,
}
async def _load_league_profile(
session: AsyncSession,
league_id: str,
before_mst_utc: int,
) -> dict[str, float] | None:
if not league_id:
return None
query = text("""
SELECT
COALESCE(AVG(m.score_home + m.score_away), 2.6) AS avg_goals,
COUNT(*) AS match_count
FROM (
SELECT score_home, score_away
FROM matches
WHERE league_id = :league_id
AND sport = 'football'
AND status = 'FT'
AND score_home IS NOT NULL
AND score_away IS NOT NULL
AND mst_utc < :before_ts
ORDER BY mst_utc DESC
LIMIT 100
) m
""")
result = await session.execute(
query,
{"league_id": league_id, "before_ts": before_mst_utc},
)
row = result.mappings().first()
if row is None or int(row["match_count"] or 0) == 0:
return None
return {"avg_goals": round(float(row["avg_goals"]), 3)}
async def _load_referee_profile(
session: AsyncSession,
referee_name: str,
before_mst_utc: int,
) -> dict[str, float] | None:
if not referee_name:
return None
query = text("""
SELECT
COALESCE(AVG(CASE WHEN score_home > score_away THEN 1.0 ELSE 0.0 END), 0.46) - 0.46 AS home_bias,
COALESCE(AVG(score_home + score_away), 2.6) AS avg_goals,
COUNT(*) AS match_count
FROM (
SELECT m.score_home, m.score_away
FROM match_officials mo
JOIN matches m ON m.id = mo.match_id
WHERE mo.name = :referee_name
AND mo.role_id = 1
AND m.sport = 'football'
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.mst_utc < :before_ts
ORDER BY m.mst_utc DESC
LIMIT 30
) ref_matches
""")
result = await session.execute(
query,
{"referee_name": referee_name, "before_ts": before_mst_utc},
)
row = result.mappings().first()
if row is None or int(row["match_count"] or 0) == 0:
return None
return {
"home_bias": round(float(row["home_bias"]), 4),
"avg_goals": round(float(row["avg_goals"]), 3),
}
async def _load_team_squad_profile(
session: AsyncSession,
team_id: str,
before_mst_utc: int,
) -> dict[str, float] | None:
if not team_id:
return None
query = text("""
WITH recent_matches AS (
SELECT m.id, m.mst_utc
FROM matches m
WHERE (m.home_team_id = :team_id OR m.away_team_id = :team_id)
AND m.sport = 'football'
AND m.status = 'FT'
AND m.mst_utc < :before_ts
ORDER BY m.mst_utc DESC
LIMIT 8
),
player_base AS (
SELECT
mpp.player_id,
COUNT(*)::float AS appearances,
COUNT(*) FILTER (WHERE mpp.is_starting = true)::float AS starts
FROM match_player_participation mpp
JOIN recent_matches rm ON rm.id = mpp.match_id
WHERE mpp.team_id = :team_id
GROUP BY mpp.player_id
),
player_goals AS (
SELECT
mpe.player_id,
COUNT(*) FILTER (
WHERE mpe.event_type = 'goal'
AND COALESCE(mpe.event_subtype, '') NOT ILIKE '%penaltı kaçırma%'
)::float AS goals,
0.0::float AS assists
FROM match_player_events mpe
JOIN recent_matches rm ON rm.id = mpe.match_id
WHERE mpe.team_id = :team_id
GROUP BY mpe.player_id
UNION ALL
SELECT
mpe.assist_player_id AS player_id,
0.0::float AS goals,
COUNT(*) FILTER (
WHERE mpe.event_type = 'goal'
AND mpe.assist_player_id IS NOT NULL
)::float AS assists
FROM match_player_events mpe
JOIN recent_matches rm ON rm.id = mpe.match_id
WHERE mpe.team_id = :team_id
AND mpe.assist_player_id IS NOT NULL
GROUP BY mpe.assist_player_id
),
player_events AS (
SELECT
player_id,
SUM(goals) AS goals,
SUM(assists) AS assists
FROM player_goals
GROUP BY player_id
),
player_scores AS (
SELECT
pb.player_id,
(pb.starts * 1.5)
+ ((pb.appearances - pb.starts) * 0.5)
+ (COALESCE(pe.goals, 0.0) * 2.5)
+ (COALESCE(pe.assists, 0.0) * 1.5) AS score
FROM player_base pb
LEFT JOIN player_events pe ON pe.player_id = pb.player_id
)
SELECT
COALESCE(AVG(top_players.score), 0.0) AS avg_top_score,
COALESCE(COUNT(*) FILTER (WHERE top_players.score >= 6.0), 0) AS key_players,
COALESCE((SELECT COUNT(*) FROM recent_matches), 0) AS match_count
FROM (
SELECT score
FROM player_scores
ORDER BY score DESC
LIMIT 11
) top_players
""")
result = await session.execute(
query,
{"team_id": team_id, "before_ts": before_mst_utc},
)
row = result.mappings().first()
if row is None or int(row["match_count"] or 0) == 0:
return None
avg_top_score = float(row["avg_top_score"] or 0.0)
return {
"squad_strength": round(min(max(avg_top_score / 10.0, 0.0), 1.0), 4),
"key_players": float(row["key_players"] or 0),
}
def _safe_json(value: Any) -> dict[str, Any] | None:
if value is None:
return None
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except (TypeError, json.JSONDecodeError):
return None
return parsed if isinstance(parsed, dict) else None
return None
def _safe_list(value: Any) -> list[Any]:
if isinstance(value, list):
return value
return []
def _extract_lineup_context(match_row: dict[str, Any]) -> dict[str, float | bool]:
lineups = _safe_json(match_row.get("lineups"))
sidelined = _safe_json(match_row.get("sidelined"))
home_xi_count = 0
away_xi_count = 0
home_sidelined_count = 0
away_sidelined_count = 0
if lineups:
home_xi_count = len(_safe_list(lineups.get("home", {}).get("xi")))
away_xi_count = len(_safe_list(lineups.get("away", {}).get("xi")))
if sidelined:
home_team = sidelined.get("homeTeam", {})
away_team = sidelined.get("awayTeam", {})
home_sidelined_count = max(
int(home_team.get("totalSidelined") or 0),
len(_safe_list(home_team.get("players"))),
)
away_sidelined_count = max(
int(away_team.get("totalSidelined") or 0),
len(_safe_list(away_team.get("players"))),
)
has_real_lineup_data = any(
value > 0
for value in (
home_xi_count,
away_xi_count,
home_sidelined_count,
away_sidelined_count,
)
)
home_availability = _compute_availability(home_xi_count, home_sidelined_count)
away_availability = _compute_availability(away_xi_count, away_sidelined_count)
return {
"home_availability": home_availability,
"away_availability": away_availability,
"has_real_lineup_data": has_real_lineup_data,
}
def _compute_availability(xi_count: int, sidelined_count: int) -> float:
xi_ratio = min(max(xi_count / 11.0, 0.0), 1.0) if xi_count > 0 else 1.0
sidelined_penalty = min(max(sidelined_count / 11.0, 0.0), 1.0) * 0.35
return round(min(max(xi_ratio - sidelined_penalty, 0.0), 1.0), 4)
def _safe_odd(val: Any) -> float:
"""Parse an odds value that might be str, float, int, or None."""
if val is None:
return 0.0
try:
parsed = float(val)
return parsed if parsed > 1.0 else 0.0
except (ValueError, TypeError):
return 0.0
def _implied_prob(decimal_odd: float) -> float:
"""Convert decimal odds to implied probability, clamped [0, 1]."""
if decimal_odd <= 1.0:
return 0.0
return min(1.0, 1.0 / decimal_odd)
async def _extract_odds(
session: AsyncSession,
match_id: str,
feats: MatchFeatures,
) -> bool:
"""Extract odds from live JSON first, then relational tables."""
found = False
odds_json = await _load_live_odds_json(session, match_id)
if odds_json:
found = _parse_odds_json(odds_json, feats)
if not found:
found = await _load_relational_odds(session, match_id, feats)
if found:
feats.implied_prob_home = round(_implied_prob(feats.odds_home), 4)
feats.implied_prob_draw = round(_implied_prob(feats.odds_draw), 4)
feats.implied_prob_away = round(_implied_prob(feats.odds_away), 4)
feats.implied_prob_over25 = round(_implied_prob(feats.odds_over25), 4)
feats.implied_prob_under25 = round(_implied_prob(feats.odds_under25), 4)
feats.implied_prob_btts_yes = round(_implied_prob(feats.odds_btts_yes), 4)
feats.implied_prob_btts_no = round(_implied_prob(feats.odds_btts_no), 4)
return found
async def _load_live_odds_json(
session: AsyncSession, match_id: str,
) -> dict[str, Any] | None:
query = text("SELECT odds FROM live_matches WHERE id = :mid AND odds IS NOT NULL")
result = await session.execute(query, {"mid": match_id})
row = result.scalar_one_or_none()
if row is None:
return None
if isinstance(row, str):
try:
parsed = json.loads(row)
except (json.JSONDecodeError, TypeError):
return None
return parsed if isinstance(parsed, (dict, list)) else None
if isinstance(row, (dict, list)):
return row
return None
def _parse_odds_json(odds_blob: dict[str, Any] | list[Any], feats: MatchFeatures) -> bool:
"""Parse the Mackolik-style odds JSON structure."""
found_any = False
categories: list[dict[str, Any]] = []
if isinstance(odds_blob, list):
categories = [item for item in odds_blob if isinstance(item, dict)]
elif isinstance(odds_blob, dict):
raw_categories = odds_blob.get("categories", odds_blob.get("odds", []))
if isinstance(raw_categories, dict):
categories = [item for item in raw_categories.values() if isinstance(item, dict)]
elif isinstance(raw_categories, list):
categories = [item for item in raw_categories if isinstance(item, dict)]
for cat in categories:
cat_name = (cat.get("name") or cat.get("cn") or "").strip().lower()
selections = cat.get("selections") or cat.get("s") or []
if cat_name in ("mac sonucu", "match result", "1x2", "maç sonucu"):
sels = _selections_to_map(selections)
feats.odds_home = _safe_odd(sels.get("1")) or feats.odds_home
feats.odds_draw = _safe_odd(sels.get("x")) or feats.odds_draw
feats.odds_away = _safe_odd(sels.get("2")) or feats.odds_away
found_any = True
elif cat_name in ("2,5 alt/ust", "over/under 2.5", "2.5 alt/ust", "2,5 alt/üst", "2.5 alt/üst"):
sels = _selections_to_map(selections)
feats.odds_over25 = _safe_odd(sels.get("ust") or sels.get("over") or sels.get("üst")) or feats.odds_over25
feats.odds_under25 = _safe_odd(sels.get("alt") or sels.get("under")) or feats.odds_under25
found_any = True
elif cat_name in ("karsilikli gol", "both teams to score", "btts", "karşılıklı gol"):
sels = _selections_to_map(selections)
feats.odds_btts_yes = _safe_odd(sels.get("var") or sels.get("yes")) or feats.odds_btts_yes
feats.odds_btts_no = _safe_odd(sels.get("yok") or sels.get("no")) or feats.odds_btts_no
found_any = True
return found_any
def _selections_to_map(selections: list[Any] | dict[str, Any]) -> dict[str, Any]:
"""Normalize varied selection structures into {name_lower: odd_value}."""
result: dict[str, Any] = {}
if isinstance(selections, dict):
for key, value in selections.items():
result[str(key).strip().lower()] = value
elif isinstance(selections, list):
for sel in selections:
if isinstance(sel, dict):
name = (sel.get("name") or sel.get("n") or "").strip().lower()
value = sel.get("odd_value") or sel.get("ov") or sel.get("v")
if name:
result[name] = value
return result
async def _load_relational_odds(
session: AsyncSession, match_id: str, feats: MatchFeatures,
) -> bool:
"""Fallback: load odds from odd_categories + odd_selections."""
query = text("""
SELECT oc.name AS cat_name, os.name AS sel_name, os.odd_value
FROM odd_categories oc
JOIN odd_selections os ON os.odd_category_db_id = oc.db_id
WHERE oc.match_id = :match_id
AND oc.name IN ('Maç Sonucu', '2,5 Alt/Üst', 'Karşılıklı Gol')
""")
result = await session.execute(query, {"match_id": match_id})
rows = result.mappings().all()
if not rows:
return False
for row in rows:
cat = (row["cat_name"] or "").strip()
sel = (row["sel_name"] or "").strip().lower()
value = _safe_odd(row["odd_value"])
if value <= 1.0:
continue
if cat == "Maç Sonucu":
if sel == "1":
feats.odds_home = value
elif sel == "x":
feats.odds_draw = value
elif sel == "2":
feats.odds_away = value
elif cat == "2,5 Alt/Üst":
if sel in ("üst", "ust", "over"):
feats.odds_over25 = value
elif sel in ("alt", "under"):
feats.odds_under25 = value
elif cat == "Karşılıklı Gol":
if sel in ("var", "yes"):
feats.odds_btts_yes = value
elif sel in ("yok", "no"):
feats.odds_btts_no = value
return True