Files
fahricansecer 9027cc9900
Deploy Iddaai Backend / build-and-deploy (push) Successful in 3m21s
v28
2026-04-24 23:46:28 +03:00

1225 lines
53 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.
"""
Odds-Band Historical Performance Analyzer (V28)
================================================
Uses historical betting odds as a lookup key to analyze how a team
actually performed when priced at similar odds ranges.
Instead of using odds as raw model features (V25 approach), this
module treats odds as a *historical reference* — answering the
question: "When this team was priced at ~1.55, what actually happened?"
This creates powerful calibration features that reveal systematic
market inefficiencies specific to each team.
All methods are fail-safe and return sensible defaults.
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Tuple
from psycopg2.extras import RealDictCursor
# ─── Band Range Configuration ──────────────────────────────────────
def get_band_range(odds: float) -> Tuple[float, float]:
"""
Calculate the odds band range for historical lookup.
Narrower bands for low odds (strong favorites — more data),
wider bands for high odds (underdogs — less data).
"""
if odds <= 0.0:
return (1.01, 1.50) # Invalid odds → default band
if odds <= 1.30:
margin = 0.05 # Very strong favorite → tight band
elif odds <= 2.00:
margin = 0.10 # Normal favorite
elif odds <= 3.50:
margin = 0.15 # Draw zone / slight underdog
else:
margin = 0.25 # Big underdog → wide band (less data)
return (max(1.01, odds - margin), odds + margin)
# ─── Minimum sample sizes ──────────────────────────────────────────
MIN_TEAM_SAMPLE = 8 # Min matches for team-level band stats
MIN_LEAGUE_SAMPLE = 15 # Min matches for league-level fallback
MAX_LOOKBACK = 50 # Max historical matches to consider
class OddsBandAnalyzer:
"""
Stateless service — receives a psycopg2 cursor and computes
odds-band historical features for any team + market combination.
"""
# Default features when no data is available
_DEFAULTS: Dict[str, float] = {
"band_ms_win_rate": 0.33,
"band_ms_draw_rate": 0.33,
"band_ms_loss_rate": 0.34,
"band_ms_sample": 0.0,
"band_ou25_over_rate": 0.50,
"band_ou25_sample": 0.0,
"band_ou15_over_rate": 0.65,
"band_ou15_sample": 0.0,
"band_ou35_over_rate": 0.35,
"band_ou35_sample": 0.0,
"band_btts_rate": 0.50,
"band_btts_sample": 0.0,
"band_ms_value_signal": 0.0,
"band_ou25_value_signal": 0.0,
"band_btts_value_signal": 0.0,
}
# ─── Public Interface ──────────────────────────────────────────
def compute_all(
self,
cur: RealDictCursor,
home_team_id: str,
away_team_id: str,
league_id: Optional[str],
odds: Dict[str, float],
before_ts: int,
referee_name: Optional[str] = None,
) -> Dict[str, float]:
"""
Compute odds-band features for both home and away teams.
Args:
cur: psycopg2 RealDictCursor
home_team_id: Home team ID
away_team_id: Away team ID
league_id: League ID for fallback queries
odds: Current match odds dict (ms_h, ms_d, ms_a, ou25_o, etc.)
before_ts: Match timestamp (ms) — only look at matches before this
referee_name: Optional referee name for card profiling
Returns:
Dict with odds-band features (home_ and away_ prefixed)
"""
result = {}
ms_home_odds = float(odds.get("ms_h", 0))
ms_away_odds = float(odds.get("ms_a", 0))
ou25_over_odds = float(odds.get("ou25_o", 0))
ou25_under_odds = float(odds.get("ou25_u", 0))
ou15_over_odds = float(odds.get("ou15_o", 0))
ou35_over_odds = float(odds.get("ou35_o", 0))
btts_yes_odds = float(odds.get("btts_y", 0))
# ── Home team band analysis ───────────────────────────────
home_ms = self._compute_ms_band(
cur, home_team_id, league_id, ms_home_odds,
is_home=True, before_ts=before_ts,
)
for key, val in home_ms.items():
result[f"home_{key}"] = val
# ── Away team band analysis ───────────────────────────────
away_ms = self._compute_ms_band(
cur, away_team_id, league_id, ms_away_odds,
is_home=False, before_ts=before_ts,
)
for key, val in away_ms.items():
result[f"away_{key}"] = val
# ── Match-level OU/BTTS band analysis ─────────────────────
ou25_band = self._compute_ou_band(
cur, home_team_id, away_team_id, league_id,
ou25_over_odds, line=2.5, before_ts=before_ts,
)
for key, val in ou25_band.items():
result[f"band_ou25_{key}"] = val
ou15_band = self._compute_ou_band(
cur, home_team_id, away_team_id, league_id,
ou15_over_odds, line=1.5, before_ts=before_ts,
)
for key, val in ou15_band.items():
result[f"band_ou15_{key}"] = val
ou35_band = self._compute_ou_band(
cur, home_team_id, away_team_id, league_id,
ou35_over_odds, line=3.5, before_ts=before_ts,
)
for key, val in ou35_band.items():
result[f"band_ou35_{key}"] = val
btts_band = self._compute_btts_band(
cur, home_team_id, away_team_id, league_id,
btts_yes_odds, before_ts=before_ts,
)
for key, val in btts_band.items():
result[f"band_btts_{key}"] = val
# ── DC (Çifte Şans) band ──────────────────────────────────
dc_1x_odds = float(odds.get("dc_1x", 0))
dc_x2_odds = float(odds.get("dc_x2", 0))
dc_12_odds = float(odds.get("dc_12", 0))
dc_band = self._compute_dc_band(
cur, home_team_id, away_team_id, league_id,
dc_1x_odds, dc_x2_odds, dc_12_odds, before_ts=before_ts,
)
for key, val in dc_band.items():
result[f"band_dc_{key}"] = val
# ── HT (İlk Yarı Sonucu) band ────────────────────────────
ht_h_odds = float(odds.get("ht_h", 0))
ht_a_odds = float(odds.get("ht_a", 0))
home_ht = self._compute_ht_band(
cur, home_team_id, league_id, ht_h_odds,
is_home=True, before_ts=before_ts,
)
for key, val in home_ht.items():
result[f"home_{key}"] = val
away_ht = self._compute_ht_band(
cur, away_team_id, league_id, ht_a_odds,
is_home=False, before_ts=before_ts,
)
for key, val in away_ht.items():
result[f"away_{key}"] = val
# ── HT OU 0.5 / 1.5 bands ────────────────────────────────
ht_ou05_odds = float(odds.get("ht_ou05_o", 0))
ht_ou05_band = self._compute_ou_band(
cur, home_team_id, away_team_id, league_id,
ht_ou05_odds, line=0.5, before_ts=before_ts,
half_time=True,
)
for key, val in ht_ou05_band.items():
result[f"band_ht_ou05_{key}"] = val
ht_ou15_odds = float(odds.get("ht_ou15_o", 0))
ht_ou15_band = self._compute_ou_band(
cur, home_team_id, away_team_id, league_id,
ht_ou15_odds, line=1.5, before_ts=before_ts,
half_time=True,
)
for key, val in ht_ou15_band.items():
result[f"band_ht_ou15_{key}"] = val
# ── OE (Tek/Çift) band ────────────────────────────────────
oe_odd_odds = float(odds.get("oe_odd", 0))
oe_band = self._compute_oe_band(
cur, home_team_id, away_team_id, league_id,
oe_odd_odds, before_ts=before_ts,
)
for key, val in oe_band.items():
result[f"band_oe_{key}"] = val
# ── Cards (Kart Alt/Üst) — Hakem + Takım profili ──────────
cards_o_odds = float(odds.get("cards_o", 0))
cards_band = self._compute_cards_band(
cur, home_team_id, away_team_id, league_id,
cards_o_odds, before_ts=before_ts,
referee_name=referee_name,
)
for key, val in cards_band.items():
result[f"band_cards_{key}"] = val
# ── HTFT (İY/MS) — 9 Kombinasyon ──────────────────────────
htft_band = self._compute_htft_band(
cur, home_team_id, away_team_id, league_id,
odds, before_ts=before_ts,
)
for key, val in htft_band.items():
result[key] = val # already prefixed with band_htft_
# ── Value signals ─────────────────────────────────────────
self._add_value_signal(result, "home_band_ms_value_signal",
result.get("home_band_ms_win_rate", 0.33), ms_home_odds)
self._add_value_signal(result, "away_band_ms_value_signal",
result.get("away_band_ms_win_rate", 0.33), ms_away_odds)
self._add_value_signal(result, "band_ou25_value_signal",
result.get("band_ou25_over_rate", 0.50), ou25_over_odds)
self._add_value_signal(result, "band_ou15_value_signal",
result.get("band_ou15_over_rate", 0.65), ou15_over_odds)
self._add_value_signal(result, "band_ou35_value_signal",
result.get("band_ou35_over_rate", 0.35), ou35_over_odds)
self._add_value_signal(result, "band_btts_value_signal",
result.get("band_btts_yes_rate", 0.50), btts_yes_odds)
self._add_value_signal(result, "band_dc_1x_value_signal",
result.get("band_dc_1x_rate", 0.60), dc_1x_odds)
self._add_value_signal(result, "band_dc_x2_value_signal",
result.get("band_dc_x2_rate", 0.60), dc_x2_odds)
self._add_value_signal(result, "band_dc_12_value_signal",
result.get("band_dc_12_rate", 0.67), dc_12_odds)
self._add_value_signal(result, "home_band_ht_value_signal",
result.get("home_band_ht_win_rate", 0.33), ht_h_odds)
self._add_value_signal(result, "away_band_ht_value_signal",
result.get("away_band_ht_win_rate", 0.33), ht_a_odds)
self._add_value_signal(result, "band_ht_ou05_value_signal",
result.get("band_ht_ou05_over_rate", 0.50), ht_ou05_odds)
self._add_value_signal(result, "band_ht_ou15_value_signal",
result.get("band_ht_ou15_over_rate", 0.35), ht_ou15_odds)
self._add_value_signal(result, "band_oe_value_signal",
result.get("band_oe_odd_rate", 0.50), oe_odd_odds)
# Cards value signal
self._add_value_signal(result, "band_cards_value_signal",
result.get("band_cards_combined_over_rate", 0.50), cards_o_odds)
# HTFT value signals (9 combinations)
for combo in ("11", "1x", "12", "x1", "xx", "x2", "21", "2x", "22"):
htft_key = f"htft_{combo}"
htft_odds_key = f"htft_{combo}"
htft_odds_val = float(odds.get(htft_odds_key, 0))
self._add_value_signal(result, f"band_htft_{combo}_value_signal",
result.get(f"band_htft_{combo}_rate", 0.11), htft_odds_val)
return result
# ─── Shared value signal helper ───────────────────────────────
@staticmethod
def _add_value_signal(
result: Dict[str, float], key: str,
actual_rate: float, odds_val: float,
) -> None:
if odds_val > 1.0:
result[key] = round(actual_rate - (1.0 / odds_val), 4)
else:
result[key] = 0.0
# ─── MS Band ──────────────────────────────────────────────────
def _compute_ms_band(
self,
cur: RealDictCursor,
team_id: str,
league_id: Optional[str],
team_odds: float,
is_home: bool,
before_ts: int,
) -> Dict[str, float]:
"""
Compute MS win/draw/loss rate for a team in a given odds band.
Looks up: "When this team had odds ~X, how often did they win?"
"""
defaults = {
"band_ms_win_rate": 0.33,
"band_ms_draw_rate": 0.33,
"band_ms_loss_rate": 0.34,
"band_ms_sample": 0.0,
"band_ms_avg_goals_scored": 1.3,
"band_ms_avg_goals_conceded": 1.1,
}
if team_odds <= 1.0:
return defaults
band_low, band_high = get_band_range(team_odds)
try:
# Query: find finished matches where this team's odds
# fell within the band, then calculate actual outcomes
cur.execute("""
WITH team_matches_in_band AS (
SELECT
m.id,
m.score_home,
m.score_away,
m.home_team_id,
m.away_team_id,
CASE
WHEN m.home_team_id = %(team_id)s THEN os_sel.odd_value::numeric
ELSE os_sel2.odd_value::numeric
END AS team_odds
FROM matches m
JOIN odd_categories oc
ON oc.match_id = m.id
AND oc.name IN ('Maç Sonucu', 'Mac Sonucu', 'Match Result', '1x2')
LEFT JOIN odd_selections os_sel
ON os_sel.odd_category_db_id = oc.db_id
AND os_sel.name = '1'
AND m.home_team_id = %(team_id)s
LEFT JOIN odd_selections os_sel2
ON os_sel2.odd_category_db_id = oc.db_id
AND os_sel2.name = '2'
AND m.away_team_id = %(team_id)s
WHERE (m.home_team_id = %(team_id)s OR m.away_team_id = %(team_id)s)
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)s
AND COALESCE(os_sel.odd_value::numeric, os_sel2.odd_value::numeric)
BETWEEN %(band_low)s AND %(band_high)s
ORDER BY m.mst_utc DESC
LIMIT %(max_lookback)s
)
SELECT
COUNT(*) AS sample_size,
COALESCE(AVG(CASE
WHEN (home_team_id = %(team_id)s AND score_home > score_away)
OR (away_team_id = %(team_id)s AND score_away > score_home)
THEN 1.0 ELSE 0.0 END), 0.33) AS win_rate,
COALESCE(AVG(CASE
WHEN score_home = score_away THEN 1.0
ELSE 0.0 END), 0.33) AS draw_rate,
COALESCE(AVG(CASE
WHEN (home_team_id = %(team_id)s AND score_home < score_away)
OR (away_team_id = %(team_id)s AND score_away < score_home)
THEN 1.0 ELSE 0.0 END), 0.34) AS loss_rate,
COALESCE(AVG(CASE
WHEN home_team_id = %(team_id)s THEN score_home
ELSE score_away END), 1.3) AS avg_goals_scored,
COALESCE(AVG(CASE
WHEN home_team_id = %(team_id)s THEN score_away
ELSE score_home END), 1.1) AS avg_goals_conceded
FROM team_matches_in_band
""", {
"team_id": team_id,
"before_ts": before_ts,
"band_low": band_low,
"band_high": band_high,
"max_lookback": MAX_LOOKBACK,
})
row = cur.fetchone()
if not row or int(row["sample_size"]) < MIN_TEAM_SAMPLE:
# Fallback to league-level if team sample is too small
return self._compute_ms_band_league_fallback(
cur, league_id, team_odds, before_ts, defaults
)
return {
"band_ms_win_rate": round(float(row["win_rate"]), 4),
"band_ms_draw_rate": round(float(row["draw_rate"]), 4),
"band_ms_loss_rate": round(float(row["loss_rate"]), 4),
"band_ms_sample": float(row["sample_size"]),
"band_ms_avg_goals_scored": round(float(row["avg_goals_scored"]), 2),
"band_ms_avg_goals_conceded": round(float(row["avg_goals_conceded"]), 2),
}
except Exception as e:
print(f"[OddsBand] ⚠ MS band query failed: {e}")
return defaults
def _compute_ms_band_league_fallback(
self,
cur: RealDictCursor,
league_id: Optional[str],
team_odds: float,
before_ts: int,
defaults: Dict[str, float],
) -> Dict[str, float]:
"""League-level fallback when team-specific sample is too small."""
if not league_id or team_odds <= 1.0:
return defaults
band_low, band_high = get_band_range(team_odds)
try:
cur.execute("""
WITH league_matches_in_band AS (
SELECT
m.id,
m.score_home,
m.score_away,
os_h.odd_value AS home_odds
FROM matches m
JOIN odd_categories oc
ON oc.match_id = m.id
AND oc.name IN ('Maç Sonucu', 'Mac Sonucu', 'Match Result', '1x2')
JOIN odd_selections os_h
ON os_h.odd_category_db_id = oc.db_id
AND os_h.name = '1'
WHERE m.league_id = %(league_id)s
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)s
AND os_h.odd_value::numeric BETWEEN %(band_low)s AND %(band_high)s
ORDER BY m.mst_utc DESC
LIMIT %(max_lookback)s
)
SELECT
COUNT(*) AS sample_size,
COALESCE(AVG(CASE WHEN score_home > score_away
THEN 1.0 ELSE 0.0 END), 0.33) AS home_win_rate,
COALESCE(AVG(CASE WHEN score_home = score_away
THEN 1.0 ELSE 0.0 END), 0.33) AS draw_rate,
COALESCE(AVG(score_home + score_away), 2.5) AS avg_total_goals
FROM league_matches_in_band
""", {
"league_id": league_id,
"before_ts": before_ts,
"band_low": band_low,
"band_high": band_high,
"max_lookback": MAX_LOOKBACK * 2, # Wider for league
})
row = cur.fetchone()
if not row or int(row["sample_size"]) < MIN_LEAGUE_SAMPLE:
return defaults
win_rate = float(row["home_win_rate"])
draw_rate = float(row["draw_rate"])
return {
"band_ms_win_rate": round(win_rate, 4),
"band_ms_draw_rate": round(draw_rate, 4),
"band_ms_loss_rate": round(1.0 - win_rate - draw_rate, 4),
"band_ms_sample": float(row["sample_size"]),
"band_ms_avg_goals_scored": round(float(row["avg_total_goals"]) / 2, 2),
"band_ms_avg_goals_conceded": round(float(row["avg_total_goals"]) / 2, 2),
}
except Exception as e:
print(f"[OddsBand] ⚠ League MS fallback failed: {e}")
return defaults
# ─── OU Band ──────────────────────────────────────────────────
def _compute_ou_band(
self,
cur: RealDictCursor,
home_team_id: str,
away_team_id: str,
league_id: Optional[str],
over_odds: float,
line: float,
before_ts: int,
half_time: bool = False,
) -> Dict[str, float]:
"""
Compute Over/Under rate for matches where teams had similar OU odds.
When half_time=True, uses IY category names and score_ht fields.
"""
defaults = {
"over_rate": 0.50,
"under_rate": 0.50,
"avg_total_goals": 2.5 if not half_time else 1.0,
"sample": 0.0,
}
if over_odds <= 1.0:
return defaults
band_low, band_high = get_band_range(over_odds)
line_str = str(line).replace(".", ",")
if half_time:
cat_names = [
f"1. Yarı {line_str} Alt/Üst",
f"1. Yari {line_str} Alt/Ust",
f"İlk Yarı {line_str} Alt/Üst",
f"Ilk Yari {line_str} Alt/Ust",
]
score_expr = "COALESCE(m.ht_score_home, 0) + COALESCE(m.ht_score_away, 0)"
else:
cat_names = [
f"{line_str} Alt/Üst",
f"{line_str} Alt/Ust",
f"Over/Under {line}",
]
score_expr = "m.score_home + m.score_away"
try:
query = f"""
WITH ou_matches AS (
SELECT
{score_expr} AS total_goals
FROM matches m
JOIN odd_categories oc
ON oc.match_id = m.id
AND oc.name = ANY(%(cat_names)s)
JOIN odd_selections os_over
ON os_over.odd_category_db_id = oc.db_id
AND os_over.name IN ('Üst', 'Ust', 'Over')
WHERE (m.home_team_id = %(home_id)s OR m.away_team_id = %(home_id)s
OR m.home_team_id = %(away_id)s OR m.away_team_id = %(away_id)s)
AND m.sport = 'football'
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.mst_utc < %(before_ts)s
AND os_over.odd_value::numeric BETWEEN %(band_low)s AND %(band_high)s
ORDER BY m.mst_utc DESC
LIMIT %(max_lookback)s
)
SELECT
COUNT(*) AS sample_size,
COALESCE(AVG(CASE WHEN total_goals > %(line)s
THEN 1.0 ELSE 0.0 END), 0.5) AS over_rate,
COALESCE(AVG(total_goals), %(default_avg)s) AS avg_total
FROM ou_matches
"""
cur.execute(query, {
"home_id": home_team_id,
"away_id": away_team_id,
"cat_names": cat_names,
"before_ts": before_ts,
"band_low": band_low,
"band_high": band_high,
"line": line,
"default_avg": 1.0 if half_time else 2.5,
"max_lookback": MAX_LOOKBACK,
})
row = cur.fetchone()
if not row or int(row["sample_size"]) < MIN_TEAM_SAMPLE:
return defaults
over_rate = float(row["over_rate"])
return {
"over_rate": round(over_rate, 4),
"under_rate": round(1.0 - over_rate, 4),
"avg_total_goals": round(float(row["avg_total"]), 2),
"sample": float(row["sample_size"]),
}
except Exception as e:
ht_label = "HT_" if half_time else ""
print(f"[OddsBand] ⚠ {ht_label}OU{line} band query failed: {e}")
return defaults
# ─── BTTS Band ────────────────────────────────────────────────
def _compute_btts_band(
self,
cur: RealDictCursor,
home_team_id: str,
away_team_id: str,
league_id: Optional[str],
btts_yes_odds: float,
before_ts: int,
) -> Dict[str, float]:
"""
Compute BTTS rate for matches where teams had similar BTTS odds.
"""
defaults = {
"yes_rate": 0.50,
"no_rate": 0.50,
"sample": 0.0,
}
if btts_yes_odds <= 1.0:
return defaults
band_low, band_high = get_band_range(btts_yes_odds)
try:
cur.execute("""
WITH btts_matches AS (
SELECT
m.score_home,
m.score_away
FROM matches m
JOIN odd_categories oc
ON oc.match_id = m.id
AND oc.name IN ('Karşılıklı Gol', 'Karsilikli Gol',
'Both Teams to Score', 'BTTS')
JOIN odd_selections os_yes
ON os_yes.odd_category_db_id = oc.db_id
AND os_yes.name IN ('Var', 'Yes')
WHERE (m.home_team_id = %(home_id)s OR m.away_team_id = %(home_id)s
OR m.home_team_id = %(away_id)s OR m.away_team_id = %(away_id)s)
AND m.sport = 'football'
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.mst_utc < %(before_ts)s
AND os_yes.odd_value::numeric BETWEEN %(band_low)s AND %(band_high)s
ORDER BY m.mst_utc DESC
LIMIT %(max_lookback)s
)
SELECT
COUNT(*) AS sample_size,
COALESCE(AVG(CASE WHEN score_home > 0 AND score_away > 0
THEN 1.0 ELSE 0.0 END), 0.5) AS btts_rate
FROM btts_matches
""", {
"home_id": home_team_id,
"away_id": away_team_id,
"before_ts": before_ts,
"band_low": band_low,
"band_high": band_high,
"max_lookback": MAX_LOOKBACK,
})
row = cur.fetchone()
if not row or int(row["sample_size"]) < MIN_TEAM_SAMPLE:
return defaults
yes_rate = float(row["btts_rate"])
return {
"yes_rate": round(yes_rate, 4),
"no_rate": round(1.0 - yes_rate, 4),
"sample": float(row["sample_size"]),
}
except Exception as e:
print(f"[OddsBand] ⚠ BTTS band query failed: {e}")
return defaults
# ─── DC (Çifte Şans) Band ─────────────────────────────────────
def _compute_dc_band(
self,
cur: RealDictCursor,
home_team_id: str,
away_team_id: str,
league_id: Optional[str],
dc_1x_odds: float,
dc_x2_odds: float,
dc_12_odds: float,
before_ts: int,
) -> Dict[str, float]:
"""Compute DC hit rates from matches with similar DC odds."""
defaults = {
"1x_rate": 0.60, "x2_rate": 0.60, "12_rate": 0.67,
"1x_sample": 0.0, "x2_sample": 0.0, "12_sample": 0.0,
}
result = {}
for sel_key, odds_val, label in [
("1x", dc_1x_odds, "1-X"),
("x2", dc_x2_odds, "X-2"),
("12", dc_12_odds, "1-2"),
]:
if odds_val <= 1.0:
result[f"{sel_key}_rate"] = defaults[f"{sel_key}_rate"]
result[f"{sel_key}_sample"] = 0.0
continue
band_low, band_high = get_band_range(odds_val)
try:
cur.execute("""
WITH dc_matches AS (
SELECT m.score_home, m.score_away
FROM matches m
JOIN odd_categories oc ON oc.match_id = m.id
AND oc.name IN ('Çifte Şans', 'Cifte Sans', 'Double Chance')
JOIN odd_selections os_sel ON os_sel.odd_category_db_id = oc.db_id
AND os_sel.name IN (%(label)s, %(label_compact)s)
WHERE (m.home_team_id = %(hid)s OR m.away_team_id = %(hid)s
OR m.home_team_id = %(aid)s OR m.away_team_id = %(aid)s)
AND m.sport = 'football' AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.mst_utc < %(before_ts)s
AND os_sel.odd_value::numeric BETWEEN %(bl)s AND %(bh)s
ORDER BY m.mst_utc DESC LIMIT %(ml)s
)
SELECT COUNT(*) AS ss,
COALESCE(AVG(CASE
WHEN %(sel_key)s = '1x' AND (score_home >= score_away) THEN 1.0
WHEN %(sel_key)s = 'x2' AND (score_away >= score_home) THEN 1.0
WHEN %(sel_key)s = '12' AND (score_home != score_away) THEN 1.0
ELSE 0.0 END), %(def_rate)s) AS hit_rate
FROM dc_matches
""", {
"hid": home_team_id, "aid": away_team_id,
"label": label, "label_compact": label.replace("-", ""),
"before_ts": before_ts, "bl": band_low, "bh": band_high,
"ml": MAX_LOOKBACK, "sel_key": sel_key,
"def_rate": defaults[f"{sel_key}_rate"],
})
row = cur.fetchone()
if row and int(row["ss"]) >= MIN_TEAM_SAMPLE:
result[f"{sel_key}_rate"] = round(float(row["hit_rate"]), 4)
result[f"{sel_key}_sample"] = float(row["ss"])
else:
result[f"{sel_key}_rate"] = defaults[f"{sel_key}_rate"]
result[f"{sel_key}_sample"] = 0.0
except Exception as e:
print(f"[OddsBand] ⚠ DC-{sel_key} band failed: {e}")
result[f"{sel_key}_rate"] = defaults[f"{sel_key}_rate"]
result[f"{sel_key}_sample"] = 0.0
return result
# ─── HT (İlk Yarı Sonucu) Band ───────────────────────────────
def _compute_ht_band(
self,
cur: RealDictCursor,
team_id: str,
league_id: Optional[str],
team_odds: float,
is_home: bool,
before_ts: int,
) -> Dict[str, float]:
"""Compute HT win/draw/loss rate for a team in a given HT odds band."""
defaults = {
"band_ht_win_rate": 0.33, "band_ht_draw_rate": 0.40,
"band_ht_loss_rate": 0.27, "band_ht_sample": 0.0,
}
if team_odds <= 1.0:
return defaults
band_low, band_high = get_band_range(team_odds)
try:
cur.execute("""
WITH ht_matches AS (
SELECT m.ht_score_home, m.ht_score_away,
m.home_team_id, m.away_team_id
FROM matches m
JOIN odd_categories oc ON oc.match_id = m.id
AND oc.name IN ('1. Yarı Sonucu', '1. Yari Sonucu',
'İlk Yarı Sonucu', 'Ilk Yari Sonucu',
'Half Time Result')
LEFT JOIN odd_selections os1 ON os1.odd_category_db_id = oc.db_id
AND os1.name = '1' AND m.home_team_id = %(tid)s
LEFT JOIN odd_selections os2 ON os2.odd_category_db_id = oc.db_id
AND os2.name = '2' AND m.away_team_id = %(tid)s
WHERE (m.home_team_id = %(tid)s OR m.away_team_id = %(tid)s)
AND m.sport = 'football' AND m.status = 'FT'
AND m.ht_score_home IS NOT NULL
AND m.mst_utc < %(before_ts)s
AND COALESCE(os1.odd_value::numeric, os2.odd_value::numeric)
BETWEEN %(bl)s AND %(bh)s
ORDER BY m.mst_utc DESC LIMIT %(ml)s
)
SELECT COUNT(*) AS ss,
COALESCE(AVG(CASE
WHEN (home_team_id = %(tid)s AND ht_score_home > ht_score_away)
OR (away_team_id = %(tid)s AND ht_score_away > ht_score_home)
THEN 1.0 ELSE 0.0 END), 0.33) AS win_rate,
COALESCE(AVG(CASE WHEN ht_score_home = ht_score_away
THEN 1.0 ELSE 0.0 END), 0.40) AS draw_rate
FROM ht_matches
""", {
"tid": team_id, "before_ts": before_ts,
"bl": band_low, "bh": band_high, "ml": MAX_LOOKBACK,
})
row = cur.fetchone()
if not row or int(row["ss"]) < MIN_TEAM_SAMPLE:
return defaults
w = float(row["win_rate"])
d = float(row["draw_rate"])
return {
"band_ht_win_rate": round(w, 4),
"band_ht_draw_rate": round(d, 4),
"band_ht_loss_rate": round(1.0 - w - d, 4),
"band_ht_sample": float(row["ss"]),
}
except Exception as e:
print(f"[OddsBand] ⚠ HT band query failed: {e}")
return defaults
# ─── OE (Tek/Çift) Band ──────────────────────────────────────
def _compute_oe_band(
self,
cur: RealDictCursor,
home_team_id: str,
away_team_id: str,
league_id: Optional[str],
oe_odd_odds: float,
before_ts: int,
) -> Dict[str, float]:
"""Compute Odd/Even rate from matches with similar OE odds."""
defaults = {"odd_rate": 0.50, "even_rate": 0.50, "sample": 0.0}
if oe_odd_odds <= 1.0:
return defaults
band_low, band_high = get_band_range(oe_odd_odds)
try:
cur.execute("""
WITH oe_matches AS (
SELECT m.score_home + m.score_away AS total
FROM matches m
JOIN odd_categories oc ON oc.match_id = m.id
AND oc.name IN ('Tek/Çift', 'Tek/Cift', 'Odd/Even')
JOIN odd_selections os_odd ON os_odd.odd_category_db_id = oc.db_id
AND os_odd.name IN ('Tek', 'Odd')
WHERE (m.home_team_id = %(hid)s OR m.away_team_id = %(hid)s
OR m.home_team_id = %(aid)s OR m.away_team_id = %(aid)s)
AND m.sport = 'football' AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.mst_utc < %(before_ts)s
AND os_odd.odd_value::numeric BETWEEN %(bl)s AND %(bh)s
ORDER BY m.mst_utc DESC LIMIT %(ml)s
)
SELECT COUNT(*) AS ss,
COALESCE(AVG(CASE WHEN total %% 2 = 1
THEN 1.0 ELSE 0.0 END), 0.5) AS odd_rate
FROM oe_matches
""", {
"hid": home_team_id, "aid": away_team_id,
"before_ts": before_ts, "bl": band_low, "bh": band_high,
"ml": MAX_LOOKBACK,
})
row = cur.fetchone()
if not row or int(row["ss"]) < MIN_TEAM_SAMPLE:
return defaults
odd_rate = float(row["odd_rate"])
return {
"odd_rate": round(odd_rate, 4),
"even_rate": round(1.0 - odd_rate, 4),
"sample": float(row["ss"]),
}
except Exception as e:
print(f"[OddsBand] ⚠ OE band query failed: {e}")
return defaults
# ─── Cards (Kart Alt/Üst) Band — 3-Layer Profiling ───────────
def _compute_cards_band(
self,
cur: RealDictCursor,
home_team_id: str,
away_team_id: str,
league_id: Optional[str],
cards_o_odds: float,
before_ts: int,
referee_name: Optional[str] = None,
) -> Dict[str, float]:
"""
3-layer card analysis:
1) Referee card profile (avg cards/match, over-line rate)
2) Team card profile (both teams' card averages)
3) Composite weighted average
Card data comes from match_player_events (event_type='card'),
NOT from score columns.
"""
# Detect the card line from odds (3.5, 4.5, 5.5 etc.)
card_line = self._detect_card_line(cards_o_odds)
defaults: Dict[str, float] = {
"referee_avg": 0.0,
"referee_over_rate": 0.50,
"referee_sample": 0.0,
"team_avg": 0.0,
"team_over_rate": 0.50,
"team_sample": 0.0,
"combined_over_rate": 0.50,
"sample": 0.0,
}
referee_avg = 0.0
referee_over_rate = 0.50
referee_sample = 0.0
team_avg = 0.0
team_over_rate = 0.50
team_sample = 0.0
# ── Layer 1: Referee card profile ──────────────────────────
if referee_name:
try:
cur.execute("""
WITH ref_matches AS (
SELECT m.id AS match_id
FROM matches m
JOIN match_officials mo ON mo.match_id = m.id
WHERE mo.name = %(ref)s
AND mo.role_id = 1
AND m.status = 'FT'
AND m.mst_utc < %(before_ts)s
ORDER BY m.mst_utc DESC
LIMIT 50
),
card_counts AS (
SELECT rm.match_id,
COUNT(*) AS total_cards
FROM ref_matches rm
JOIN match_player_events mpe ON mpe.match_id = rm.match_id
WHERE mpe.event_type = 'card'
GROUP BY rm.match_id
)
SELECT
COUNT(DISTINCT rm.match_id) AS ss,
COALESCE(AVG(COALESCE(cc.total_cards, 0)), 0) AS avg_cards,
COALESCE(AVG(CASE WHEN COALESCE(cc.total_cards, 0) > %(line)s
THEN 1.0 ELSE 0.0 END), 0.5) AS over_rate
FROM ref_matches rm
LEFT JOIN card_counts cc ON cc.match_id = rm.match_id
""", {
"ref": referee_name,
"before_ts": before_ts,
"line": card_line,
})
row = cur.fetchone()
if row and int(row["ss"]) >= 5:
referee_avg = float(row["avg_cards"])
referee_over_rate = float(row["over_rate"])
referee_sample = float(row["ss"])
except Exception as e:
print(f"[OddsBand] ⚠ Cards referee query failed: {e}")
# ── Layer 2: Team card profile ─────────────────────────────
try:
cur.execute("""
WITH team_matches AS (
SELECT m.id AS match_id
FROM matches m
WHERE (m.home_team_id = %(hid)s OR m.away_team_id = %(hid)s
OR m.home_team_id = %(aid)s OR m.away_team_id = %(aid)s)
AND m.sport = 'football'
AND m.status = 'FT'
AND m.mst_utc < %(before_ts)s
ORDER BY m.mst_utc DESC
LIMIT %(ml)s
),
card_counts AS (
SELECT tm.match_id,
COUNT(*) AS total_cards
FROM team_matches tm
JOIN match_player_events mpe ON mpe.match_id = tm.match_id
WHERE mpe.event_type = 'card'
GROUP BY tm.match_id
)
SELECT
COUNT(DISTINCT tm.match_id) AS ss,
COALESCE(AVG(COALESCE(cc.total_cards, 0)), 0) AS avg_cards,
COALESCE(AVG(CASE WHEN COALESCE(cc.total_cards, 0) > %(line)s
THEN 1.0 ELSE 0.0 END), 0.5) AS over_rate
FROM team_matches tm
LEFT JOIN card_counts cc ON cc.match_id = tm.match_id
""", {
"hid": home_team_id, "aid": away_team_id,
"before_ts": before_ts, "line": card_line,
"ml": MAX_LOOKBACK,
})
row = cur.fetchone()
if row and int(row["ss"]) >= MIN_TEAM_SAMPLE:
team_avg = float(row["avg_cards"])
team_over_rate = float(row["over_rate"])
team_sample = float(row["ss"])
except Exception as e:
print(f"[OddsBand] ⚠ Cards team query failed: {e}")
# ── Layer 3: Composite ─────────────────────────────────────
total_sample = referee_sample + team_sample
if total_sample > 0:
# Referee weight = 60% when available (referee insight is more predictive)
ref_weight = 0.6 if referee_sample >= 5 else 0.0
team_weight = 1.0 - ref_weight
combined = (referee_over_rate * ref_weight) + (team_over_rate * team_weight)
else:
combined = 0.50
return {
"referee_avg": round(referee_avg, 2),
"referee_over_rate": round(referee_over_rate, 4),
"referee_sample": referee_sample,
"team_avg": round(team_avg, 2),
"team_over_rate": round(team_over_rate, 4),
"team_sample": team_sample,
"combined_over_rate": round(combined, 4),
"sample": max(referee_sample, team_sample),
}
@staticmethod
def _detect_card_line(cards_o_odds: float) -> float:
"""
Detect the card line from over odds.
Low odds (< 1.60) → probably 3.5 line
Medium odds (1.60-2.20) → probably 4.5 line
High odds (> 2.20) → probably 5.5 line
"""
if cards_o_odds <= 0:
return 4.5 # Default assumption
if cards_o_odds < 1.60:
return 3.5
if cards_o_odds < 2.20:
return 4.5
return 5.5
# ─── HTFT (İY/MS) Band — 9 Combination Analysis ──────────────
_HTFT_COMBOS = ("11", "1x", "12", "x1", "xx", "x2", "21", "2x", "22")
def _compute_htft_band(
self,
cur: RealDictCursor,
home_team_id: str,
away_team_id: str,
league_id: Optional[str],
odds: Dict[str, float],
before_ts: int,
) -> Dict[str, float]:
"""
Compute HTFT (İlk Yarı / Maç Sonucu) hit rates for all 9 combinations.
For each combination, looks at historical matches where the HTFT odds
were in a similar band, and computes how often that specific HTFT
outcome actually occurred.
Uses ht_score_home, ht_score_away (half-time) and
score_home, score_away (full-time) from matches table.
"""
defaults: Dict[str, float] = {}
for combo in self._HTFT_COMBOS:
defaults[f"band_htft_{combo}_rate"] = 0.11 # ~1/9
defaults[f"band_htft_{combo}_sample"] = 0.0
# Check if any HTFT odds exist
has_htft_odds = any(
float(odds.get(f"htft_{combo}", 0)) > 1.0
for combo in self._HTFT_COMBOS
)
if not has_htft_odds:
return defaults
# Pick the most-traded HTFT combo (lowest odds = most likely) for band
# This gives us the best sample for the band lookup
try:
# Strategy: query all matches for both teams where HTFT odds exist,
# then compute hit rates for each combination
cur.execute("""
WITH htft_matches AS (
SELECT
m.id,
m.ht_score_home,
m.ht_score_away,
m.score_home,
m.score_away,
CASE
WHEN m.ht_score_home > m.ht_score_away THEN '1'
WHEN m.ht_score_home = m.ht_score_away THEN 'x'
ELSE '2'
END AS ht_result,
CASE
WHEN m.score_home > m.score_away THEN '1'
WHEN m.score_home = m.score_away THEN 'x'
ELSE '2'
END AS ft_result
FROM matches m
WHERE (m.home_team_id = %(hid)s OR m.away_team_id = %(hid)s
OR m.home_team_id = %(aid)s OR m.away_team_id = %(aid)s)
AND m.sport = 'football'
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.ht_score_home IS NOT NULL
AND m.mst_utc < %(before_ts)s
ORDER BY m.mst_utc DESC
LIMIT %(ml)s
)
SELECT
COUNT(*) AS total_matches,
COUNT(*) FILTER (WHERE ht_result || ft_result = '11') AS c_11,
COUNT(*) FILTER (WHERE ht_result || ft_result = '1x') AS c_1x,
COUNT(*) FILTER (WHERE ht_result || ft_result = '12') AS c_12,
COUNT(*) FILTER (WHERE ht_result || ft_result = 'x1') AS c_x1,
COUNT(*) FILTER (WHERE ht_result || ft_result = 'xx') AS c_xx,
COUNT(*) FILTER (WHERE ht_result || ft_result = 'x2') AS c_x2,
COUNT(*) FILTER (WHERE ht_result || ft_result = '21') AS c_21,
COUNT(*) FILTER (WHERE ht_result || ft_result = '2x') AS c_2x,
COUNT(*) FILTER (WHERE ht_result || ft_result = '22') AS c_22
FROM htft_matches
""", {
"hid": home_team_id, "aid": away_team_id,
"before_ts": before_ts,
"ml": MAX_LOOKBACK,
})
base_row = cur.fetchone()
base_total = int(base_row["total_matches"]) if base_row else 0
if base_total < MIN_TEAM_SAMPLE:
# Fallback to league level
if league_id:
cur.execute("""
WITH htft_lg AS (
SELECT
CASE
WHEN m.ht_score_home > m.ht_score_away THEN '1'
WHEN m.ht_score_home = m.ht_score_away THEN 'x'
ELSE '2'
END AS ht_result,
CASE
WHEN m.score_home > m.score_away THEN '1'
WHEN m.score_home = m.score_away THEN 'x'
ELSE '2'
END AS ft_result
FROM matches m
WHERE m.league_id = %(lid)s
AND m.sport = 'football'
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.ht_score_home IS NOT NULL
AND m.mst_utc < %(before_ts)s
ORDER BY m.mst_utc DESC
LIMIT 200
)
SELECT
COUNT(*) AS total_matches,
COUNT(*) FILTER (WHERE ht_result || ft_result = '11') AS c_11,
COUNT(*) FILTER (WHERE ht_result || ft_result = '1x') AS c_1x,
COUNT(*) FILTER (WHERE ht_result || ft_result = '12') AS c_12,
COUNT(*) FILTER (WHERE ht_result || ft_result = 'x1') AS c_x1,
COUNT(*) FILTER (WHERE ht_result || ft_result = 'xx') AS c_xx,
COUNT(*) FILTER (WHERE ht_result || ft_result = 'x2') AS c_x2,
COUNT(*) FILTER (WHERE ht_result || ft_result = '21') AS c_21,
COUNT(*) FILTER (WHERE ht_result || ft_result = '2x') AS c_2x,
COUNT(*) FILTER (WHERE ht_result || ft_result = '22') AS c_22
FROM htft_lg
""", {"lid": league_id, "before_ts": before_ts})
base_row = cur.fetchone()
base_total = int(base_row["total_matches"]) if base_row else 0
if base_total < MIN_TEAM_SAMPLE:
return defaults
result: Dict[str, float] = {}
for combo in self._HTFT_COMBOS:
count = int(base_row[f"c_{combo}"])
rate = count / base_total if base_total > 0 else 0.11
result[f"band_htft_{combo}_rate"] = round(rate, 4)
result[f"band_htft_{combo}_sample"] = float(base_total)
# ── Odds-band refinement: for combos with odds, check
# if similar-odds matches had different hit rates
for combo in self._HTFT_COMBOS:
combo_odds = float(odds.get(f"htft_{combo}", 0))
if combo_odds <= 1.0:
continue
band_low, band_high = get_band_range(combo_odds)
try:
cur.execute("""
WITH band_htft AS (
SELECT
CASE
WHEN m.ht_score_home > m.ht_score_away THEN '1'
WHEN m.ht_score_home = m.ht_score_away THEN 'x'
ELSE '2'
END || CASE
WHEN m.score_home > m.score_away THEN '1'
WHEN m.score_home = m.score_away THEN 'x'
ELSE '2'
END AS htft_outcome
FROM matches m
JOIN odd_categories oc ON oc.match_id = m.id
AND oc.name IN (
'İlk Yarı/Maç Sonucu',
'Ilk Yarı/Maç Sonucu',
'İlk Yarı/Mac Sonucu',
'Ilk Yari/Mac Sonucu',
'IY/MS'
)
JOIN odd_selections os ON os.odd_category_db_id = oc.db_id
AND os.odd_value::numeric BETWEEN %(bl)s AND %(bh)s
WHERE m.sport = 'football'
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.ht_score_home IS NOT NULL
AND m.mst_utc < %(before_ts)s
ORDER BY m.mst_utc DESC
LIMIT %(ml)s
)
SELECT
COUNT(*) AS ss,
COALESCE(AVG(CASE WHEN htft_outcome = %(target)s
THEN 1.0 ELSE 0.0 END), 0.0) AS hit_rate
FROM band_htft
""", {
"bl": band_low, "bh": band_high,
"before_ts": before_ts,
"ml": MAX_LOOKBACK,
"target": combo,
})
brow = cur.fetchone()
if brow and int(brow["ss"]) >= MIN_TEAM_SAMPLE:
# Blend base rate with band-specific rate (60/40 band preference)
base_rate = result[f"band_htft_{combo}_rate"]
band_rate = float(brow["hit_rate"])
blended = (band_rate * 0.6) + (base_rate * 0.4)
result[f"band_htft_{combo}_rate"] = round(blended, 4)
result[f"band_htft_{combo}_sample"] = float(brow["ss"])
except Exception:
pass # Keep base rate
return result
except Exception as e:
print(f"[OddsBand] ⚠ HTFT band query failed: {e}")
return defaults