1471 lines
66 KiB
Python
1471 lines
66 KiB
Python
"""Market Board Mixin — market rows, decoration, surprise profile, data quality.
|
||
|
||
Auto-extracted mixin module — split from services/single_match_orchestrator.py.
|
||
All methods here are composed into SingleMatchOrchestrator via inheritance.
|
||
`self` attributes (self.dsn, self.enrichment, self.v25_predictor, etc.) are
|
||
initialised in the main __init__.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import time
|
||
import math
|
||
import os
|
||
import pickle
|
||
from collections import defaultdict
|
||
from typing import Any, Dict, List, Optional, Set, Tuple, overload
|
||
|
||
import pandas as pd
|
||
import numpy as np
|
||
|
||
import psycopg2
|
||
from psycopg2.extras import RealDictCursor
|
||
|
||
from data.db import get_clean_dsn
|
||
from schemas.prediction import FullMatchPrediction
|
||
from schemas.match_data import MatchData
|
||
from models.v25_ensemble import V25Predictor, get_v25_predictor
|
||
try:
|
||
from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge
|
||
except ImportError:
|
||
class V27Predictor: # type: ignore[no-redef]
|
||
def __init__(self): self.models = {}
|
||
def load_models(self): return False
|
||
def predict_all(self, features): return {}
|
||
def compute_divergence(*args, **kwargs):
|
||
return {}
|
||
def compute_value_edge(*args, **kwargs):
|
||
return {}
|
||
from features.odds_band_analyzer import OddsBandAnalyzer
|
||
try:
|
||
from models.basketball_v25 import (
|
||
BasketballMatchPrediction,
|
||
get_basketball_v25_predictor,
|
||
)
|
||
except ImportError:
|
||
BasketballMatchPrediction = Any # type: ignore[misc]
|
||
def get_basketball_v25_predictor() -> Any:
|
||
raise ImportError("Basketball predictor is not available")
|
||
from core.engines.player_predictor import PlayerPrediction, get_player_predictor
|
||
from services.feature_enrichment import FeatureEnrichmentService
|
||
from services.betting_brain import BettingBrain
|
||
from services.v26_shadow_engine import V26ShadowEngine, get_v26_shadow_engine
|
||
from services.match_commentary import generate_match_commentary
|
||
from utils.top_leagues import load_top_league_ids
|
||
from utils.league_reliability import load_league_reliability
|
||
from config.config_loader import build_threshold_dict, get_threshold_default
|
||
from models.calibration import get_calibrator
|
||
|
||
|
||
class MarketBoardMixin:
|
||
def _build_prediction_package(
|
||
self,
|
||
data: MatchData,
|
||
prediction: FullMatchPrediction,
|
||
v25_signal: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, Any]:
|
||
quality = self._compute_data_quality(data)
|
||
|
||
raw_market_rows = self._build_market_rows(data, prediction, v25_signal)
|
||
raw_market_rows = self._apply_market_consistency(
|
||
raw_market_rows,
|
||
data,
|
||
prediction,
|
||
)
|
||
market_rows = [
|
||
self._decorate_market_row(data, prediction, quality, row)
|
||
for row in raw_market_rows
|
||
]
|
||
market_rows.sort(
|
||
key=lambda row: (
|
||
1 if row.get("playable") else 0,
|
||
float(row.get("play_score", 0.0)),
|
||
),
|
||
reverse=True,
|
||
)
|
||
|
||
playable_rows = [row for row in market_rows if row.get("playable")]
|
||
|
||
MIN_ODDS = 1.30
|
||
playable_with_odds = [
|
||
row for row in playable_rows
|
||
if float(row.get("odds", 0.0)) >= MIN_ODDS
|
||
]
|
||
|
||
if playable_with_odds:
|
||
playable_with_odds.sort(
|
||
key=lambda r: (
|
||
float(r.get("ev_edge", 0.0)),
|
||
float(r.get("play_score", 0.0)),
|
||
),
|
||
reverse=True,
|
||
)
|
||
main_pick = playable_with_odds[0]
|
||
main_pick["is_guaranteed"] = False
|
||
main_pick["pick_reason"] = "positive_ev_after_odds_band_gate"
|
||
else:
|
||
fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
|
||
fallback_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
|
||
main_pick = fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None)
|
||
if main_pick:
|
||
main_pick["is_guaranteed"] = False
|
||
main_pick["playable"] = False
|
||
main_pick["stake_units"] = 0.0
|
||
main_pick["bet_grade"] = "PASS"
|
||
main_pick["pick_reason"] = "no_playable_value_after_odds_band_gate"
|
||
|
||
aggressive_pick = None
|
||
htft_probs = prediction.ht_ft_probs or {}
|
||
aggressive_candidates = [
|
||
("1/2", float(htft_probs.get("1/2", 0.0))),
|
||
("2/1", float(htft_probs.get("2/1", 0.0))),
|
||
("X/1", float(htft_probs.get("X/1", 0.0))),
|
||
("X/2", float(htft_probs.get("X/2", 0.0))),
|
||
]
|
||
aggressive_candidates.sort(key=lambda item: item[1], reverse=True)
|
||
if (
|
||
aggressive_candidates
|
||
and aggressive_candidates[0][1] > 0.03
|
||
and self._market_has_real_pick_odds("HTFT", aggressive_candidates[0][0], data.odds_data or {})
|
||
):
|
||
aggressive_pick = {
|
||
"market": "HT/FT",
|
||
"pick": aggressive_candidates[0][0],
|
||
"probability": round(aggressive_candidates[0][1], 4),
|
||
"confidence": round(aggressive_candidates[0][1] * 100, 1),
|
||
"odds": None,
|
||
}
|
||
|
||
value_pick = None
|
||
# Esnek/Değerli (Value) Pick: Yüksek oran (>= 1.60) ve fena olmayan güven (>= %40)
|
||
value_candidates = [
|
||
row for row in playable_rows
|
||
if float(row.get("odds", 0.0)) >= 1.60
|
||
# V34: Lowered min calibrated_confidence for value candidates from 40.0 to 25.0
|
||
# to allow high-odds value bets (which naturally have lower probabilities).
|
||
and float(row.get("calibrated_confidence", 0.0)) >= 25.0
|
||
]
|
||
if value_candidates:
|
||
# Score them by (ev_edge) to reward actual mathematical value
|
||
value_candidates.sort(key=lambda r: float(r.get("ev_edge", 0.0)), reverse=True)
|
||
for v_cand in value_candidates:
|
||
if not main_pick or (v_cand["market"] != main_pick["market"] or v_cand["pick"] != main_pick["pick"]):
|
||
value_pick = v_cand
|
||
break
|
||
|
||
supporting: List[Dict[str, Any]] = []
|
||
for row in market_rows:
|
||
if main_pick and row["market"] == main_pick["market"] and row["pick"] == main_pick["pick"]:
|
||
continue
|
||
supporting.append(row)
|
||
supporting = supporting[:6]
|
||
bet_summary = [self._to_bet_summary_item(row) for row in market_rows]
|
||
|
||
reasons = self._build_reasoning_factors(data, prediction, quality)
|
||
|
||
market_board = {
|
||
"MS": {
|
||
"pick": prediction.ms_pick,
|
||
"confidence": round(float(prediction.ms_confidence), 1),
|
||
"probs": {
|
||
"1": round(float(prediction.ms_home_prob), 4),
|
||
"X": round(float(prediction.ms_draw_prob), 4),
|
||
"2": round(float(prediction.ms_away_prob), 4),
|
||
},
|
||
},
|
||
"DC": {
|
||
"pick": prediction.dc_pick,
|
||
"confidence": round(float(prediction.dc_confidence), 1),
|
||
"probs": {
|
||
"1X": round(float(prediction.dc_1x_prob), 4),
|
||
"X2": round(float(prediction.dc_x2_prob), 4),
|
||
"12": round(float(prediction.dc_12_prob), 4),
|
||
},
|
||
},
|
||
"OU15": {
|
||
"pick": prediction.ou15_pick,
|
||
"confidence": round(float(prediction.ou15_confidence), 1),
|
||
"probs": {
|
||
"over": round(float(prediction.over_15_prob), 4),
|
||
"under": round(float(prediction.under_15_prob), 4),
|
||
},
|
||
},
|
||
"OU25": {
|
||
"pick": prediction.ou25_pick,
|
||
"confidence": round(float(prediction.ou25_confidence), 1),
|
||
"probs": {
|
||
"over": round(float(prediction.over_25_prob), 4),
|
||
"under": round(float(prediction.under_25_prob), 4),
|
||
},
|
||
},
|
||
"OU35": {
|
||
"pick": prediction.ou35_pick,
|
||
"confidence": round(float(prediction.ou35_confidence), 1),
|
||
"probs": {
|
||
"over": round(float(prediction.over_35_prob), 4),
|
||
"under": round(float(prediction.under_35_prob), 4),
|
||
},
|
||
},
|
||
"BTTS": {
|
||
"pick": prediction.btts_pick,
|
||
"confidence": round(float(prediction.btts_confidence), 1),
|
||
"probs": {
|
||
"yes": round(float(prediction.btts_yes_prob), 4),
|
||
"no": round(float(prediction.btts_no_prob), 4),
|
||
},
|
||
},
|
||
"HT": {
|
||
"pick": prediction.ht_pick,
|
||
"confidence": round(float(prediction.ht_confidence), 1),
|
||
"probs": {
|
||
"1": round(float(prediction.ht_home_prob), 4),
|
||
"X": round(float(prediction.ht_draw_prob), 4),
|
||
"2": round(float(prediction.ht_away_prob), 4),
|
||
},
|
||
},
|
||
"HTFT": {
|
||
"probs": {k: round(float(v), 4) for k, v in htft_probs.items()},
|
||
},
|
||
"OE": {
|
||
"pick": prediction.odd_even_pick,
|
||
"probs": {
|
||
"odd": round(float(prediction.odd_prob), 4),
|
||
"even": round(float(prediction.even_prob), 4),
|
||
},
|
||
},
|
||
"HT_OU05": {
|
||
"pick": prediction.ht_ou_pick,
|
||
"confidence": round(float(max(prediction.ht_over_05_prob, prediction.ht_under_05_prob) * 100), 1),
|
||
"probs": {
|
||
"over": round(float(prediction.ht_over_05_prob), 4),
|
||
"under": round(float(prediction.ht_under_05_prob), 4),
|
||
},
|
||
},
|
||
"HT_OU15": {
|
||
"pick": prediction.ht_ou15_pick,
|
||
"confidence": round(float(max(prediction.ht_over_15_prob, prediction.ht_under_15_prob) * 100), 1),
|
||
"probs": {
|
||
"over": round(float(prediction.ht_over_15_prob), 4),
|
||
"under": round(float(prediction.ht_under_15_prob), 4),
|
||
},
|
||
},
|
||
"CARDS": {
|
||
"pick": prediction.card_pick,
|
||
"confidence": round(float(prediction.cards_confidence), 1),
|
||
"total": round(float(prediction.total_cards_pred), 1),
|
||
"probs": {
|
||
"over": round(float(prediction.cards_over_prob), 4),
|
||
"under": round(float(prediction.cards_under_prob), 4),
|
||
},
|
||
},
|
||
"HCAP": {
|
||
"pick": prediction.handicap_pick,
|
||
"confidence": round(float(prediction.handicap_confidence), 1),
|
||
"probs": {
|
||
"1": round(float(prediction.handicap_home_prob), 4),
|
||
"X": round(float(prediction.handicap_draw_prob), 4),
|
||
"2": round(float(prediction.handicap_away_prob), 4),
|
||
},
|
||
},
|
||
}
|
||
if v25_signal:
|
||
market_board = self._merge_v25_market_board(market_board, v25_signal)
|
||
|
||
available_markets = {str(row.get("market") or "") for row in market_rows}
|
||
market_board = {
|
||
market: payload
|
||
for market, payload in market_board.items()
|
||
if market in available_markets
|
||
}
|
||
|
||
# Determine simulation mode for the response
|
||
_resp_status = str(data.status or "").upper()
|
||
_resp_state = str(data.state or "").upper()
|
||
is_simulation = _resp_status in {"FT", "FINISHED"} or _resp_state in {"POSTGAME", "POST_GAME"}
|
||
|
||
return {
|
||
"model_version": "v28-pro-max",
|
||
"simulation_mode": "pre_match" if is_simulation else None,
|
||
"match_info": {
|
||
"match_id": data.match_id,
|
||
"match_name": f"{data.home_team_name} vs {data.away_team_name}",
|
||
"home_team": data.home_team_name,
|
||
"away_team": data.away_team_name,
|
||
"league": data.league_name,
|
||
"match_date_ms": data.match_date_ms,
|
||
"sport": data.sport,
|
||
# Live snapshot — match_commentary uses this to detect upset-in-progress
|
||
"status": data.status,
|
||
"state": data.state,
|
||
"is_live": self._is_live_match(data),
|
||
"current_score_home": data.current_score_home,
|
||
"current_score_away": data.current_score_away,
|
||
},
|
||
"prediction_freshness": {
|
||
"generated_at_ms": int(time.time() * 1000),
|
||
"is_pre_match_snapshot": True,
|
||
# Stale when the match is already underway — UI should warn the user.
|
||
"is_stale_for_live": self._is_live_match(data),
|
||
},
|
||
"data_quality": quality,
|
||
"risk": {
|
||
"level": prediction.risk_level,
|
||
"score": round(float(prediction.risk_score), 1),
|
||
"is_surprise_risk": bool(prediction.is_surprise_risk),
|
||
"surprise_type": prediction.surprise_type,
|
||
"surprise_score": round(float(getattr(prediction, "surprise_score", 0.0) or 0.0), 1),
|
||
"surprise_comment": str(getattr(prediction, "surprise_comment", "") or ""),
|
||
"surprise_reasons": list(getattr(prediction, "surprise_reasons", []) or []),
|
||
"surprise_breakdown": list(getattr(prediction, "surprise_breakdown", []) or []),
|
||
"warnings": prediction.risk_warnings,
|
||
},
|
||
"engine_breakdown": self._build_engine_breakdown(prediction),
|
||
"main_pick": main_pick,
|
||
"value_pick": value_pick,
|
||
"bet_advice": {
|
||
"playable": bool(main_pick and main_pick.get("playable")),
|
||
"suggested_stake_units": float(main_pick.get("stake_units", 0.0)) if (main_pick and main_pick.get("playable")) else 0.0,
|
||
"reason": "playable_pick_found" if (main_pick and main_pick.get("playable")) else "no_bet_conditions_met",
|
||
},
|
||
"bet_summary": bet_summary,
|
||
"supporting_picks": supporting,
|
||
"aggressive_pick": aggressive_pick,
|
||
"scenario_top5": prediction.ft_scores_top5,
|
||
"score_prediction": {
|
||
"ft": prediction.predicted_ft_score,
|
||
"ht": prediction.predicted_ht_score,
|
||
"xg_home": round(float(prediction.home_xg), 2),
|
||
"xg_away": round(float(prediction.away_xg), 2),
|
||
"xg_total": round(float(prediction.total_xg), 2),
|
||
},
|
||
"market_board": market_board,
|
||
"others": {
|
||
"handicap": prediction.handicap_pick,
|
||
"cards": {
|
||
"total": round(float(prediction.total_cards_pred), 1),
|
||
"pick": prediction.card_pick,
|
||
},
|
||
},
|
||
"v25_signal": {
|
||
"available": v25_signal is not None,
|
||
"markets": v25_signal if v25_signal else None,
|
||
"value_bets": v25_signal.get('value_bets', []) if v25_signal else [],
|
||
"ensemble_weights": {"v25": 1.0},
|
||
},
|
||
"reasoning_factors": reasons,
|
||
}
|
||
|
||
def _real_market_odds(self, odds_data: Dict[str, Any], key: str) -> float:
|
||
"""
|
||
Return the odds value for a given key, but 1.0 if it's a known default or missing.
|
||
|
||
The prediction engine needs default odds (2.65/3.20) as ML features,
|
||
but market rows must NOT use them for EV edge / Kelly calculations.
|
||
Returning 1.0 acts as a neutral multiplier, avoiding zero-out errors.
|
||
"""
|
||
val = float(odds_data.get(key, 1.0))
|
||
if val <= 1.01:
|
||
return 1.0
|
||
_DEFAULTS: Dict[str, float] = {
|
||
"ms_h": self.DEFAULT_MS_H,
|
||
"ms_d": self.DEFAULT_MS_D,
|
||
"ms_a": self.DEFAULT_MS_A,
|
||
"ml_h": 1.90,
|
||
"ml_a": 1.90,
|
||
"ht_h": 2.4,
|
||
"ht_d": 1.9,
|
||
"ht_a": 3.1,
|
||
"ht_ou05_o": 1.9,
|
||
"ht_ou05_u": 1.9,
|
||
"ht_ou15_o": 2.4,
|
||
"ht_ou15_u": 1.5,
|
||
"ou15_o": 1.4,
|
||
"ou15_u": 2.6,
|
||
"ou25_o": 1.9,
|
||
"ou25_u": 1.9,
|
||
"ou35_o": 2.7,
|
||
"ou35_u": 1.4,
|
||
"btts_y": 1.9,
|
||
"btts_n": 1.9,
|
||
"dc_1x": 1.2,
|
||
"dc_x2": 1.4,
|
||
"dc_12": 1.2,
|
||
"oe_odd": 1.9,
|
||
"oe_even": 1.9,
|
||
"cards_o": 1.9,
|
||
"cards_u": 1.9,
|
||
"tot_o": 1.90,
|
||
"tot_u": 1.90,
|
||
}
|
||
if key in _DEFAULTS and abs(val - _DEFAULTS[key]) < 1e-6:
|
||
return 1.0
|
||
return val
|
||
|
||
@staticmethod
|
||
def _v25_pick_to_market_pick(market: str, pick: str) -> str:
|
||
if market == "BTTS":
|
||
return "KG Var" if pick == "Yes" else "KG Yok" if pick == "No" else pick
|
||
if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}:
|
||
return "Üst" if pick == "Over" else "Alt" if pick == "Under" else pick
|
||
if market == "OE":
|
||
return "Tek" if pick == "Odd" else "Çift" if pick == "Even" else pick
|
||
return pick
|
||
|
||
def _v25_market_odds(self, odds: Dict[str, Any], market: str, pick: str) -> float:
|
||
normalized_pick = str(pick or "").strip()
|
||
if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}:
|
||
normalized_pick = "Over" if ("Üst" in normalized_pick or "Over" in normalized_pick) else "Under"
|
||
elif market == "BTTS":
|
||
normalized_pick = "Yes" if normalized_pick in {"KG Var", "Var", "Yes"} else "No"
|
||
elif market == "OE":
|
||
normalized_pick = "Odd" if normalized_pick in {"Tek", "Odd"} else "Even"
|
||
elif market == "DC":
|
||
normalized_pick = normalized_pick.replace("-", "").upper()
|
||
elif market == "HCAP" and normalized_pick.startswith("Handikap"):
|
||
if " 1" in normalized_pick:
|
||
normalized_pick = "1"
|
||
elif " X" in normalized_pick:
|
||
normalized_pick = "X"
|
||
elif " 2" in normalized_pick:
|
||
normalized_pick = "2"
|
||
|
||
key_map = {
|
||
"MS": {"1": "ms_h", "X": "ms_d", "2": "ms_a"},
|
||
"DC": {"1X": "dc_1x", "X2": "dc_x2", "12": "dc_12"},
|
||
"OU15": {"Over": "ou15_o", "Under": "ou15_u"},
|
||
"OU25": {"Over": "ou25_o", "Under": "ou25_u"},
|
||
"OU35": {"Over": "ou35_o", "Under": "ou35_u"},
|
||
"BTTS": {"Yes": "btts_y", "No": "btts_n"},
|
||
"HT": {"1": "ht_h", "X": "ht_d", "2": "ht_a"},
|
||
"HT_OU05": {"Over": "ht_ou05_o", "Under": "ht_ou05_u"},
|
||
"HT_OU15": {"Over": "ht_ou15_o", "Under": "ht_ou15_u"},
|
||
"OE": {"Odd": "oe_odd", "Even": "oe_even"},
|
||
"CARDS": {"Over": "cards_o", "Under": "cards_u"},
|
||
"HCAP": {"1": "hcap_h", "X": "hcap_d", "2": "hcap_a"},
|
||
}
|
||
if market == "HTFT":
|
||
return round(float(odds.get(f"htft_{normalized_pick.replace('/', '').lower()}", 1.0)), 2)
|
||
odds_key = key_map.get(market, {}).get(normalized_pick)
|
||
if not odds_key:
|
||
return 1.0
|
||
return round(self._real_market_odds(odds, odds_key), 2)
|
||
|
||
def _market_has_real_pick_odds(self, market: str, pick: str, odds: Dict[str, Any]) -> bool:
|
||
if market not in self.ODDS_REQUIRED_MARKETS:
|
||
return True
|
||
return self._v25_market_odds(odds, market, pick) > 1.01
|
||
|
||
def _odds_band_verdict(
|
||
self,
|
||
data: MatchData,
|
||
market: str,
|
||
pick: str,
|
||
implied_prob: float,
|
||
) -> Dict[str, Any]:
|
||
features = getattr(data, "odds_band_features", {}) or {}
|
||
market_key = str(market or "").upper()
|
||
if not isinstance(features, dict) or implied_prob <= 0.0:
|
||
return {
|
||
"required": market_key in self.odds_band_min_sample,
|
||
"available": False,
|
||
"band_prob": 0.0,
|
||
"band_sample": 0.0,
|
||
"band_edge": 0.0,
|
||
"aligned": False,
|
||
"reason": "odds_band_unavailable",
|
||
}
|
||
|
||
pick_key = self._normalize_pick_token(pick)
|
||
band_prob = 0.0
|
||
sample = 0.0
|
||
|
||
if market_key == "MS":
|
||
if pick_key == "1":
|
||
band_prob = float(features.get("home_band_ms_win_rate", 0.0) or 0.0)
|
||
sample = float(features.get("home_band_ms_sample", 0.0) or 0.0)
|
||
elif pick_key == "2":
|
||
band_prob = float(features.get("away_band_ms_win_rate", 0.0) or 0.0)
|
||
sample = float(features.get("away_band_ms_sample", 0.0) or 0.0)
|
||
elif pick_key in {"X", "0"}:
|
||
home_draw = float(features.get("home_band_ms_draw_rate", 0.0) or 0.0)
|
||
away_draw = float(features.get("away_band_ms_draw_rate", 0.0) or 0.0)
|
||
band_prob = (home_draw + away_draw) / 2.0 if home_draw and away_draw else max(home_draw, away_draw)
|
||
sample = max(
|
||
float(features.get("home_band_ms_sample", 0.0) or 0.0),
|
||
float(features.get("away_band_ms_sample", 0.0) or 0.0),
|
||
)
|
||
elif market_key == "DC":
|
||
dc_key = pick_key.replace("-", "").lower()
|
||
band_prob = float(features.get(f"band_dc_{dc_key}_rate", 0.0) or 0.0)
|
||
sample = float(features.get(f"band_dc_{dc_key}_sample", 0.0) or 0.0)
|
||
elif market_key in {"OU15", "OU25", "OU35"}:
|
||
suffix = {"OU15": "ou15", "OU25": "ou25", "OU35": "ou35"}[market_key]
|
||
rate_key = "over_rate" if self._pick_is_over(pick_key) else "under_rate"
|
||
band_prob = float(features.get(f"band_{suffix}_{rate_key}", 0.0) or 0.0)
|
||
sample = float(features.get(f"band_{suffix}_sample", 0.0) or 0.0)
|
||
elif market_key == "BTTS":
|
||
is_yes = "VAR" in pick_key or "YES" in pick_key or pick_key == "Y"
|
||
band_prob = float(features.get(f"band_btts_{'yes' if is_yes else 'no'}_rate", 0.0) or 0.0)
|
||
sample = float(features.get("band_btts_sample", 0.0) or 0.0)
|
||
elif market_key == "HT":
|
||
if pick_key == "1":
|
||
band_prob = float(features.get("home_band_ht_win_rate", 0.0) or 0.0)
|
||
sample = float(features.get("home_band_ht_sample", 0.0) or 0.0)
|
||
elif pick_key == "2":
|
||
band_prob = float(features.get("away_band_ht_win_rate", 0.0) or 0.0)
|
||
sample = float(features.get("away_band_ht_sample", 0.0) or 0.0)
|
||
elif pick_key in {"X", "0"}:
|
||
home_draw = float(features.get("home_band_ht_draw_rate", 0.0) or 0.0)
|
||
away_draw = float(features.get("away_band_ht_draw_rate", 0.0) or 0.0)
|
||
band_prob = (home_draw + away_draw) / 2.0 if home_draw and away_draw else max(home_draw, away_draw)
|
||
sample = max(
|
||
float(features.get("home_band_ht_sample", 0.0) or 0.0),
|
||
float(features.get("away_band_ht_sample", 0.0) or 0.0),
|
||
)
|
||
elif market_key in {"HT_OU05", "HT_OU15"}:
|
||
suffix = "ht_ou05" if market_key == "HT_OU05" else "ht_ou15"
|
||
rate_key = "over_rate" if self._pick_is_over(pick_key) else "under_rate"
|
||
band_prob = float(features.get(f"band_{suffix}_{rate_key}", 0.0) or 0.0)
|
||
sample = float(features.get(f"band_{suffix}_sample", 0.0) or 0.0)
|
||
|
||
band_edge = band_prob - implied_prob if band_prob > 0.0 else 0.0
|
||
required_sample = float(self.odds_band_min_sample.get(market_key, 0.0))
|
||
required_edge = float(self.odds_band_min_edge.get(market_key, 0.0))
|
||
available = band_prob > 0.0 and sample >= required_sample
|
||
aligned = available and band_edge >= required_edge
|
||
|
||
reason = "odds_band_confirms_value"
|
||
if required_sample > 0.0 and sample < required_sample:
|
||
reason = "odds_band_sample_too_low"
|
||
elif band_prob <= 0.0:
|
||
reason = "odds_band_missing_probability"
|
||
elif band_edge < required_edge:
|
||
reason = f"odds_band_no_value_{band_edge:+.3f}"
|
||
|
||
return {
|
||
"required": market_key in self.odds_band_min_sample,
|
||
"available": available,
|
||
"band_prob": band_prob,
|
||
"band_sample": sample,
|
||
"band_edge": band_edge,
|
||
"aligned": aligned,
|
||
"reason": reason,
|
||
}
|
||
|
||
@staticmethod
|
||
def _normalize_pick_token(pick: str) -> str:
|
||
return (
|
||
str(pick or "")
|
||
.strip()
|
||
.upper()
|
||
.replace("İ", "I")
|
||
.replace("Ü", "U")
|
||
.replace("Ş", "S")
|
||
.replace("Ğ", "G")
|
||
.replace("Ö", "O")
|
||
.replace("Ç", "C")
|
||
)
|
||
|
||
@staticmethod
|
||
def _pick_is_over(pick_key: str) -> bool:
|
||
return "UST" in pick_key or "OVER" in pick_key
|
||
|
||
@staticmethod
|
||
def _goal_line_for_market(market: str) -> Optional[float]:
|
||
return {
|
||
"OU15": 1.5,
|
||
"OU25": 2.5,
|
||
"OU35": 3.5,
|
||
"HT_OU05": 0.5,
|
||
"HT_OU15": 1.5,
|
||
"CARDS": 4.5,
|
||
}.get(market)
|
||
|
||
def _is_live_match(self, data: MatchData) -> bool:
|
||
status = str(data.status or "").upper()
|
||
if status in {"NS", "FT", "POSTPONED", "CANC", "ABD"}:
|
||
return False
|
||
return data.current_score_home is not None and data.current_score_away is not None
|
||
|
||
def _apply_market_consistency(
|
||
self,
|
||
rows: List[Dict[str, Any]],
|
||
data: MatchData,
|
||
prediction: FullMatchPrediction,
|
||
) -> List[Dict[str, Any]]:
|
||
if not rows:
|
||
return rows
|
||
|
||
is_live = self._is_live_match(data)
|
||
current_goals = (
|
||
int(data.current_score_home or 0) + int(data.current_score_away or 0)
|
||
if is_live
|
||
else 0
|
||
)
|
||
both_scored = (
|
||
bool(data.current_score_home and data.current_score_home > 0)
|
||
and bool(data.current_score_away and data.current_score_away > 0)
|
||
)
|
||
predicted_total = float(getattr(prediction, "total_xg", 0.0) or 0.0)
|
||
over25_prob = float(getattr(prediction, "over_25_prob", 0.0) or 0.0)
|
||
over35_prob = float(getattr(prediction, "over_35_prob", 0.0) or 0.0)
|
||
btts_yes_prob = float(getattr(prediction, "btts_yes_prob", 0.0) or 0.0)
|
||
home_xg = float(getattr(prediction, "home_xg", 0.0) or 0.0)
|
||
away_xg = float(getattr(prediction, "away_xg", 0.0) or 0.0)
|
||
xg_gap = abs(home_xg - away_xg)
|
||
ht_under05_prob = float(getattr(prediction, "ht_under_05_prob", 0.0) or 0.0)
|
||
ht_over05_prob = float(getattr(prediction, "ht_over_05_prob", 0.0) or 0.0)
|
||
ht_home_prob = float(getattr(prediction, "ht_home_prob", 0.0) or 0.0)
|
||
ht_draw_prob = float(getattr(prediction, "ht_draw_prob", 0.0) or 0.0)
|
||
ht_away_prob = float(getattr(prediction, "ht_away_prob", 0.0) or 0.0)
|
||
htft_probs = getattr(prediction, "ht_ft_probs", {}) or {}
|
||
first_half_goal_from_htft = float(
|
||
sum(
|
||
float(prob or 0.0)
|
||
for outcome, prob in htft_probs.items()
|
||
if str(outcome).startswith(("1/", "2/"))
|
||
)
|
||
)
|
||
|
||
adjusted: List[Dict[str, Any]] = []
|
||
for row in rows:
|
||
market = str(row.get("market") or "")
|
||
pick = str(row.get("pick") or "")
|
||
probability = float(row.get("probability") or 0.0)
|
||
confidence = float(row.get("confidence") or (probability * 100.0))
|
||
reasons = list(row.get("consistency_reasons") or [])
|
||
impossible = False
|
||
|
||
if is_live:
|
||
if market == "BTTS" and pick == "KG Yok" and both_scored:
|
||
impossible = True
|
||
reasons.append("live_state_impossible_market")
|
||
line = self._goal_line_for_market(market)
|
||
if line is not None and "Alt" in pick and current_goals > line:
|
||
impossible = True
|
||
reasons.append("live_score_exceeds_under_line")
|
||
|
||
if impossible:
|
||
continue
|
||
|
||
penalty = 0.0
|
||
line = self._goal_line_for_market(market)
|
||
if line is not None:
|
||
if "Alt" in pick and predicted_total > (line + 0.35):
|
||
penalty += min(32.0, (predicted_total - line) * 18.0)
|
||
reasons.append("score_model_conflicts_with_under_pick")
|
||
if "Üst" in pick and predicted_total < (line - 0.35):
|
||
penalty += min(24.0, (line - predicted_total) * 16.0)
|
||
reasons.append("score_model_conflicts_with_over_pick")
|
||
|
||
if market == "OU35" and "Alt" in pick:
|
||
if over25_prob >= 0.78:
|
||
penalty += 14.0
|
||
reasons.append("market_stack_conflict_over25")
|
||
if btts_yes_prob >= 0.74:
|
||
penalty += 10.0
|
||
reasons.append("market_stack_conflict_btts")
|
||
if is_live and current_goals >= 3:
|
||
penalty += 24.0
|
||
reasons.append("live_total_goals_close_to_line")
|
||
|
||
if market == "BTTS" and pick == "KG Yok" and predicted_total >= 2.8:
|
||
penalty += 16.0
|
||
reasons.append("score_model_conflicts_with_btts_no")
|
||
|
||
if market == "MS":
|
||
if pick == "X" and xg_gap >= 0.95:
|
||
penalty += 18.0
|
||
reasons.append("score_model_conflicts_with_draw_pick")
|
||
if pick == "1" and (away_xg - home_xg) >= 0.85:
|
||
penalty += 20.0
|
||
reasons.append("score_model_conflicts_with_home_pick")
|
||
if pick == "2" and (home_xg - away_xg) >= 0.85:
|
||
penalty += 20.0
|
||
reasons.append("score_model_conflicts_with_away_pick")
|
||
|
||
if market == "HT_OU05":
|
||
if "Alt" in pick:
|
||
if max(ht_home_prob, ht_away_prob) >= 0.42:
|
||
penalty += 22.0
|
||
reasons.append("first_half_result_conflicts_with_goalless_half")
|
||
if first_half_goal_from_htft >= 0.45:
|
||
penalty += 20.0
|
||
reasons.append("first_half_htft_conflicts_with_goalless_half")
|
||
if "Üst" in pick and ht_draw_prob >= 0.56 and ht_under05_prob >= 0.54:
|
||
penalty += 14.0
|
||
reasons.append("first_half_draw_conflicts_with_goal_pick")
|
||
|
||
if market == "HT" and pick in {"1", "2"} and ht_under05_prob >= 0.56:
|
||
penalty += 28.0
|
||
reasons.append("first_half_goalless_conflicts_with_result_pick")
|
||
|
||
if market == "HTFT":
|
||
htft_first_half = pick.split("/")[0] if "/" in pick else ""
|
||
if htft_first_half in {"1", "2"} and ht_under05_prob >= 0.56:
|
||
penalty += 34.0
|
||
reasons.append("first_half_goalless_conflicts_with_htft_pick")
|
||
if htft_first_half == "X" and ht_over05_prob >= 0.68:
|
||
penalty += 16.0
|
||
reasons.append("first_half_goal_pressure_conflicts_with_htft_draw")
|
||
|
||
if penalty > 0:
|
||
probability *= max(0.35, 1.0 - (penalty / 100.0))
|
||
confidence = max(1.0, confidence - penalty)
|
||
|
||
next_row = dict(row)
|
||
next_row["probability"] = round(probability, 4)
|
||
next_row["confidence"] = round(confidence, 1)
|
||
if reasons:
|
||
next_row["consistency_reasons"] = reasons
|
||
adjusted.append(next_row)
|
||
|
||
return adjusted
|
||
|
||
def _build_surprise_profile(
|
||
self,
|
||
data: MatchData,
|
||
prediction: FullMatchPrediction,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Produces an explainable surprise profile.
|
||
|
||
Each factor pushes the base score and contributes:
|
||
- a human-readable Turkish reason
|
||
- a `breakdown` entry with code, points, label
|
||
"""
|
||
BASE_SCORE = 22.0
|
||
breakdown: List[Dict[str, Any]] = []
|
||
reasons: List[str] = []
|
||
score = BASE_SCORE
|
||
|
||
def add(code: str, points: float, label: str) -> None:
|
||
nonlocal score
|
||
score += points
|
||
reasons.append(label)
|
||
breakdown.append({"code": code, "points": round(points, 1), "label": label})
|
||
|
||
ms_home = float(getattr(prediction, "ms_home_prob", 0.0) or 0.0)
|
||
ms_draw = float(getattr(prediction, "ms_draw_prob", 0.0) or 0.0)
|
||
ms_away = float(getattr(prediction, "ms_away_prob", 0.0) or 0.0)
|
||
top_prob = max(ms_home, ms_draw, ms_away)
|
||
second_prob = sorted([ms_home, ms_draw, ms_away], reverse=True)[1]
|
||
parity_gap = top_prob - second_prob
|
||
total_xg = float(getattr(prediction, "total_xg", 0.0) or 0.0)
|
||
btts_yes = float(getattr(prediction, "btts_yes_prob", 0.0) or 0.0)
|
||
over35 = float(getattr(prediction, "over_35_prob", 0.0) or 0.0)
|
||
|
||
if parity_gap <= 0.08:
|
||
add("balanced_match_risk", 18.0, "Takımlar birbirine çok yakın — sonuç kırılabilir")
|
||
if ms_draw >= 0.30:
|
||
add("draw_probability_elevated", 14.0, f"Beraberlik olasılığı yüksek (%{ms_draw*100:.0f})")
|
||
if total_xg >= 3.25:
|
||
add("high_total_goal_volatility", 10.0, f"Toplam gol beklentisi yüksek (xG {total_xg:.1f}) — açık skor riski")
|
||
if btts_yes >= 0.68:
|
||
add("mutual_goal_pressure", 8.0, f"Karşılıklı gol baskısı (%{btts_yes*100:.0f})")
|
||
if over35 >= 0.52:
|
||
add("late_goal_swing_risk", 8.0, "Geç gol/skor değişimi riski")
|
||
|
||
# Odds-based traps (favorite odds trap from UpsetEngineV2 logic)
|
||
ms_h_odd = self._safe_float((data.odds_data or {}).get("ms_h"), 0.0)
|
||
ms_a_odd = self._safe_float((data.odds_data or {}).get("ms_a"), 0.0)
|
||
ms_d_odd = self._safe_float((data.odds_data or {}).get("ms_d"), 0.0)
|
||
favorite_side = None
|
||
favorite_odd = 0.0
|
||
if ms_h_odd > 1.01 and ms_a_odd > 1.01:
|
||
if ms_h_odd <= ms_a_odd:
|
||
favorite_side, favorite_odd = "home", ms_h_odd
|
||
else:
|
||
favorite_side, favorite_odd = "away", ms_a_odd
|
||
|
||
# Favorite odds trap (1.40-1.60 historically %33+ surprise rate)
|
||
if 1.40 <= favorite_odd < 1.60:
|
||
add(
|
||
"favorite_odds_trap",
|
||
12.0,
|
||
f"Favori oranı tuzak aralığında ({favorite_odd:.2f}) — tarihsel sürpriz oranı %30+",
|
||
)
|
||
elif 1.20 <= favorite_odd < 1.30:
|
||
add(
|
||
"low_odds_trap_suspicion",
|
||
6.0,
|
||
f"Favori oranı çok düşük ({favorite_odd:.2f}) — piyasa aşırı güveniyor olabilir",
|
||
)
|
||
|
||
# Bookmaker margin
|
||
if ms_h_odd > 1.01 and ms_a_odd > 1.01 and ms_d_odd > 1.01:
|
||
margin = (1 / ms_h_odd + 1 / ms_d_odd + 1 / ms_a_odd) - 1
|
||
if margin > 0.20:
|
||
add(
|
||
"bookmaker_margin_high",
|
||
10.0,
|
||
f"Bookmaker marjı çok yüksek (%{margin*100:.1f}) — bahisçi risk görüyor",
|
||
)
|
||
elif margin > 0.18:
|
||
add(
|
||
"bookmaker_margin_elevated",
|
||
6.0,
|
||
f"Bookmaker marjı yüksek (%{margin*100:.1f})",
|
||
)
|
||
|
||
# Away favorite carries inherent extra risk
|
||
if favorite_side == "away" and favorite_odd > 0:
|
||
add(
|
||
"away_favorite_extra_risk",
|
||
6.0,
|
||
"Deplasman favorisi — atmosfer ve seyahat ek risk yaratır",
|
||
)
|
||
|
||
if data.lineup_source == "probable_xi":
|
||
add("lineup_probable_not_confirmed", 8.0, "Kadrolar tahmini — kesinleşmemiş")
|
||
if data.lineup_source == "none":
|
||
add("lineup_unavailable", 12.0, "Kadro bilgisi yok — analiz güvenilirliği düştü")
|
||
if not data.referee_name:
|
||
add("missing_referee", 6.0, "Hakem atanmamış — disiplin/avantaj sinyali eksik")
|
||
|
||
if self._is_live_match(data):
|
||
current_goals = int(data.current_score_home or 0) + int(data.current_score_away or 0)
|
||
if current_goals >= 3:
|
||
add("live_match_open_state", 18.0, f"Maç şu an açık skorlu ({current_goals} gol) — pre-match tahminler riskli")
|
||
elif current_goals >= 2:
|
||
add("live_match_active_state", 10.0, f"Maç canlı ve hareketli ({current_goals} gol)")
|
||
|
||
# Live underdog leading (pre-match favorite is losing)
|
||
cur_home = int(data.current_score_home or 0)
|
||
cur_away = int(data.current_score_away or 0)
|
||
if favorite_side == "home" and cur_away > cur_home:
|
||
add(
|
||
"live_underdog_leading",
|
||
20.0,
|
||
"Canlı: deplasman önde, pre-match ev sahibi favorisiydi — sürpriz GERÇEKLEŞİYOR",
|
||
)
|
||
elif favorite_side == "away" and cur_home > cur_away:
|
||
add(
|
||
"live_underdog_leading",
|
||
20.0,
|
||
"Canlı: ev sahibi önde, pre-match deplasman favorisiydi — sürpriz GERÇEKLEŞİYOR",
|
||
)
|
||
|
||
score = max(0.0, min(100.0, score))
|
||
if score >= 75:
|
||
comment = "Bu maçta sürpriz ve kırılma riski yüksek. Ana tahminler yatabilir; tekli yerine daha temkinli yaklaşım gerekir."
|
||
elif score >= 55:
|
||
comment = "Bu maçta belirgin sürpriz sinyalleri var. Tahminler yön verse de kupon kararında temkinli olunmalı."
|
||
elif score >= 40:
|
||
comment = "Maçta orta seviyede belirsizlik var. Tahminler yorum için faydalı ama güven payı sınırlı."
|
||
else:
|
||
comment = "Sürpriz riski düşük görünüyor. Tahminler normal güven bandında okunabilir."
|
||
|
||
# Deduplicate reasons by text while preserving order
|
||
deduped_reasons = list(dict.fromkeys(reasons))[:8]
|
||
# Same dedup logic for breakdown (by code)
|
||
seen_codes: Set[str] = set()
|
||
deduped_breakdown: List[Dict[str, Any]] = []
|
||
for entry in breakdown:
|
||
if entry["code"] in seen_codes:
|
||
continue
|
||
seen_codes.add(entry["code"])
|
||
deduped_breakdown.append(entry)
|
||
|
||
return {
|
||
"score": round(score, 1),
|
||
"comment": comment,
|
||
"reasons": deduped_reasons,
|
||
"breakdown": deduped_breakdown[:10],
|
||
"base_score": BASE_SCORE,
|
||
}
|
||
|
||
@staticmethod
|
||
def _normalize_v25_probs(market: str, probs: Dict[str, Any]) -> Dict[str, float]:
|
||
out: Dict[str, float] = {}
|
||
for key, value in (probs or {}).items():
|
||
if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}:
|
||
norm_key = "over" if key == "Over" else "under" if key == "Under" else str(key).lower()
|
||
elif market == "BTTS":
|
||
norm_key = "yes" if key == "Yes" else "no" if key == "No" else str(key).lower()
|
||
elif market == "OE":
|
||
norm_key = "odd" if key == "Odd" else "even" if key == "Even" else str(key).lower()
|
||
else:
|
||
norm_key = str(key)
|
||
out[norm_key] = round(float(value), 4)
|
||
return out
|
||
|
||
def _merge_v25_market_rows(
|
||
self,
|
||
rows: List[Dict[str, Any]],
|
||
odds: Dict[str, Any],
|
||
v25_signal: Optional[Dict[str, Any]],
|
||
) -> List[Dict[str, Any]]:
|
||
if not v25_signal:
|
||
return rows
|
||
|
||
by_market = {row.get("market"): dict(row) for row in rows}
|
||
for market, payload in v25_signal.items():
|
||
if market == "value_bets" or not isinstance(payload, dict):
|
||
continue
|
||
pick = str(payload.get("pick") or "")
|
||
if not self._market_has_real_pick_odds(market, pick, odds):
|
||
continue
|
||
probability = float(payload.get("probability") or 0.0)
|
||
by_market[market] = {
|
||
"market": market,
|
||
"pick": self._v25_pick_to_market_pick(market, pick),
|
||
"probability": round(probability, 4),
|
||
"confidence": round(float(payload.get("confidence") or probability * 100.0), 1),
|
||
"odds": self._v25_market_odds(odds, market, pick),
|
||
}
|
||
|
||
preferred_order = [
|
||
"MS", "DC", "OU15", "OU25", "OU35", "BTTS",
|
||
"HT", "HT_OU05", "HT_OU15", "HTFT", "OE", "CARDS", "HCAP",
|
||
]
|
||
return [by_market[key] for key in preferred_order if key in by_market]
|
||
|
||
def _merge_v25_market_board(
|
||
self,
|
||
market_board: Dict[str, Any],
|
||
v25_signal: Optional[Dict[str, Any]],
|
||
) -> Dict[str, Any]:
|
||
if not v25_signal:
|
||
return market_board
|
||
|
||
merged = dict(market_board)
|
||
for market, payload in v25_signal.items():
|
||
if market == "value_bets" or not isinstance(payload, dict):
|
||
continue
|
||
merged[market] = {
|
||
"pick": self._v25_pick_to_market_pick(market, str(payload.get("pick") or "")),
|
||
"confidence": round(float(payload.get("confidence") or 0.0), 1),
|
||
"probs": self._normalize_v25_probs(market, payload.get("probs") or {}),
|
||
}
|
||
return merged
|
||
|
||
def _build_market_rows(
|
||
self,
|
||
data: MatchData,
|
||
pred: FullMatchPrediction,
|
||
v25_signal: Optional[Dict[str, Any]] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
odds = data.odds_data
|
||
|
||
rows = [
|
||
{
|
||
"market": "MS",
|
||
"pick": pred.ms_pick,
|
||
"probability": round(
|
||
float(max(pred.ms_home_prob, pred.ms_draw_prob, pred.ms_away_prob)),
|
||
4,
|
||
),
|
||
"confidence": round(float(pred.ms_confidence), 1),
|
||
"odds": round(self._real_market_odds(odds, {"1": "ms_h", "X": "ms_d", "2": "ms_a"}.get(pred.ms_pick, "ms_h")), 2),
|
||
},
|
||
{
|
||
"market": "DC",
|
||
"pick": pred.dc_pick,
|
||
"probability": round(
|
||
float(max(pred.dc_1x_prob, pred.dc_x2_prob, pred.dc_12_prob)),
|
||
4,
|
||
),
|
||
"confidence": round(float(pred.dc_confidence), 1),
|
||
"odds": round(float(odds.get(f"dc_{pred.dc_pick.lower()}", 1.0)), 2),
|
||
},
|
||
{
|
||
"market": "OU15",
|
||
"pick": pred.ou15_pick,
|
||
"probability": round(float(pred.over_15_prob if "Üst" in pred.ou15_pick or "Over" in pred.ou15_pick else pred.under_15_prob), 4),
|
||
"confidence": round(float(pred.ou15_confidence), 1),
|
||
"odds": round(float(odds.get("ou15_o" if "Üst" in pred.ou15_pick or "Over" in pred.ou15_pick else "ou15_u", 1.0)), 2),
|
||
},
|
||
{
|
||
"market": "OU25",
|
||
"pick": pred.ou25_pick,
|
||
"probability": round(float(pred.over_25_prob if "Üst" in pred.ou25_pick or "Over" in pred.ou25_pick else pred.under_25_prob), 4),
|
||
"confidence": round(float(pred.ou25_confidence), 1),
|
||
"odds": round(float(odds.get("ou25_o" if "Üst" in pred.ou25_pick or "Over" in pred.ou25_pick else "ou25_u", 1.0)), 2),
|
||
},
|
||
{
|
||
"market": "OU35",
|
||
"pick": pred.ou35_pick,
|
||
"probability": round(float(pred.over_35_prob if "Üst" in pred.ou35_pick or "Over" in pred.ou35_pick else pred.under_35_prob), 4),
|
||
"confidence": round(float(pred.ou35_confidence), 1),
|
||
"odds": round(float(odds.get("ou35_o" if "Üst" in pred.ou35_pick or "Over" in pred.ou35_pick else "ou35_u", 1.0)), 2),
|
||
},
|
||
{
|
||
"market": "BTTS",
|
||
"pick": pred.btts_pick,
|
||
"probability": round(float(pred.btts_yes_prob if "Var" in pred.btts_pick or "Yes" in pred.btts_pick else pred.btts_no_prob), 4),
|
||
"confidence": round(float(pred.btts_confidence), 1),
|
||
"odds": round(float(odds.get("btts_y" if "Var" in pred.btts_pick or "Yes" in pred.btts_pick else "btts_n", 1.0)), 2),
|
||
},
|
||
{
|
||
"market": "HT",
|
||
"pick": pred.ht_pick,
|
||
"probability": round(float(max(pred.ht_home_prob, pred.ht_draw_prob, pred.ht_away_prob)), 4),
|
||
"confidence": round(float(pred.ht_confidence), 1),
|
||
"odds": round(float(odds.get({"1": "ht_h", "X": "ht_d", "2": "ht_a"}.get(pred.ht_pick, "ht_h"), 1.0)), 2),
|
||
},
|
||
{
|
||
"market": "HT_OU05",
|
||
"pick": pred.ht_ou_pick,
|
||
"probability": round(float(pred.ht_over_05_prob if "Üst" in pred.ht_ou_pick or "Over" in pred.ht_ou_pick else pred.ht_under_05_prob), 4),
|
||
"confidence": round(float(max(pred.ht_over_05_prob, pred.ht_under_05_prob) * 100), 1),
|
||
"odds": round(float(odds.get("ht_ou05_o" if "Üst" in pred.ht_ou_pick or "Over" in pred.ht_ou_pick else "ht_ou05_u", 1.0)), 2),
|
||
},
|
||
{
|
||
"market": "HT_OU15",
|
||
"pick": pred.ht_ou15_pick,
|
||
"probability": round(float(pred.ht_over_15_prob if "Üst" in pred.ht_ou15_pick or "Over" in pred.ht_ou15_pick else pred.ht_under_15_prob), 4),
|
||
"confidence": round(float(max(pred.ht_over_15_prob, pred.ht_under_15_prob) * 100), 1),
|
||
"odds": round(float(odds.get("ht_ou15_o" if "Üst" in pred.ht_ou15_pick or "Over" in pred.ht_ou15_pick else "ht_ou15_u", 1.0)), 2),
|
||
},
|
||
{
|
||
"market": "OE",
|
||
"pick": pred.odd_even_pick,
|
||
"probability": round(float(pred.odd_prob if "Tek" in pred.odd_even_pick else pred.even_prob), 4),
|
||
"confidence": round(float(max(pred.odd_prob, pred.even_prob) * 100), 1),
|
||
"odds": round(float(odds.get("oe_odd" if "Tek" in pred.odd_even_pick else "oe_even", 1.0)), 2),
|
||
},
|
||
{
|
||
"market": "CARDS",
|
||
"pick": pred.card_pick,
|
||
"probability": round(float(pred.cards_over_prob if "Üst" in pred.card_pick or "Over" in pred.card_pick else pred.cards_under_prob), 4),
|
||
"confidence": round(float(pred.cards_confidence), 1),
|
||
"odds": round(float(odds.get("cards_o" if "Üst" in pred.card_pick or "Over" in pred.card_pick else "cards_u", 1.0)), 2),
|
||
},
|
||
{
|
||
"market": "HCAP",
|
||
"pick": pred.handicap_pick,
|
||
"probability": round(float(
|
||
pred.handicap_home_prob if pred.handicap_pick == "1"
|
||
else pred.handicap_draw_prob if pred.handicap_pick == "X"
|
||
else pred.handicap_away_prob
|
||
), 4),
|
||
"confidence": round(float(pred.handicap_confidence), 1),
|
||
"odds": round(float(
|
||
odds.get(
|
||
{"1": "hcap_h", "X": "hcap_d", "2": "hcap_a"}.get(pred.handicap_pick, "hcap_h"),
|
||
1.0,
|
||
)
|
||
), 2),
|
||
},
|
||
]
|
||
|
||
# HT/FT Market - 9 possible outcomes
|
||
htft_probs = pred.ht_ft_probs or {}
|
||
if htft_probs:
|
||
# Find the highest probability HT/FT outcome
|
||
htft_labels = ("1/1", "1/X", "1/2", "X/1", "X/X", "X/2", "2/1", "2/X", "2/2")
|
||
best_htft = max(htft_labels, key=lambda x: float(htft_probs.get(x, 0.0)))
|
||
best_htft_prob = float(htft_probs.get(best_htft, 0.0))
|
||
|
||
# Map HT/FT labels to odds keys
|
||
htft_odds_key = f"htft_{best_htft.replace('/', '').lower()}" # e.g., htft_11, htft_1x, htft_12
|
||
htft_odds = float(odds.get(htft_odds_key, 1.0))
|
||
|
||
rows.append({
|
||
"market": "HTFT",
|
||
"pick": best_htft,
|
||
"probability": round(best_htft_prob, 4),
|
||
"confidence": round(best_htft_prob * 100, 1),
|
||
"odds": round(htft_odds, 2),
|
||
})
|
||
|
||
rows = [
|
||
row for row in rows
|
||
if self._market_has_real_pick_odds(
|
||
str(row.get("market") or ""),
|
||
str(row.get("pick") or ""),
|
||
odds,
|
||
)
|
||
]
|
||
|
||
return self._merge_v25_market_rows(rows, odds, v25_signal)
|
||
|
||
def _decorate_market_row(
|
||
self,
|
||
data: MatchData,
|
||
prediction: FullMatchPrediction,
|
||
quality: Dict[str, Any],
|
||
row: Dict[str, Any],
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Decorate a raw market row with playability, grading, and staking.
|
||
|
||
V20+Quant hybrid:
|
||
- All existing V20+ safety gates preserved (lineup, risk, quality, conf)
|
||
- Edge: EV formula → (prob × odds) - 1.0 (not simple prob - implied)
|
||
- Staking: Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll)
|
||
- Grading: Edge-based → A(>10%), B(>5%), C(>2%), PASS
|
||
"""
|
||
market = str(row.get("market") or "")
|
||
raw_conf = float(row.get("confidence") or 0.0)
|
||
prob = float(row.get("probability") or 0.0)
|
||
odd = float(row.get("odds") or 0.0)
|
||
pick_str = str(row.get("pick") or "")
|
||
|
||
# Trained isotonic calibrator (preferred) — falls back to multiplier if not trained.
|
||
# IMPORTANT: trainer was fed (raw_confidence/100, actual). Orchestrator must feed
|
||
# the same shape — using `prob` (which may differ from raw_conf/100 due to upstream
|
||
# confidence boosting) would give the calibrator an out-of-distribution input.
|
||
calibrator = get_calibrator()
|
||
cal_key = self._calibrator_key(market, pick_str)
|
||
if cal_key and cal_key in calibrator.calibrators:
|
||
cal_input = max(0.001, min(0.999, raw_conf / 100.0))
|
||
cal_prob = calibrator.calibrate(cal_key, cal_input, odds_val=odd if odd > 1.0 else None)
|
||
calibrated_conf = max(1.0, min(99.0, cal_prob * 100.0))
|
||
else:
|
||
multiplier = self.market_calibration.get(market, 0.85)
|
||
calibrated_conf = max(1.0, min(99.0, raw_conf * multiplier))
|
||
min_conf = self.market_min_conf.get(market, 55.0)
|
||
|
||
implied_prob = (1.0 / odd) if odd > 1.0 else 0.0
|
||
band_verdict = self._odds_band_verdict(data, market, pick_str, implied_prob)
|
||
|
||
# ── V31: League-specific odds reliability ──────────────────────
|
||
# Higher reliability → trust odds-based edge more in play_score
|
||
# Lower reliability → lean more on model confidence, less on edge
|
||
odds_rel = self.league_reliability.get(
|
||
str(data.league_id or ""), 0.35 # default for unknown leagues
|
||
)
|
||
# Edge weight: reliable league → edge matters more (up to 120%)
|
||
# unreliable league → edge matters less (down to 60%)
|
||
edge_multiplier = 0.60 + (odds_rel * 0.60) # range: 0.60 – 1.20
|
||
|
||
risk_level = str(prediction.risk_level or "MEDIUM").upper()
|
||
risk_penalty = {"LOW": 0.0, "MEDIUM": 3.0, "HIGH": 8.0, "EXTREME": 12.0}.get(
|
||
risk_level,
|
||
5.0,
|
||
)
|
||
quality_label = str(quality.get("label") or "MEDIUM").upper()
|
||
quality_penalty = {"HIGH": 0.0, "MEDIUM": 3.0, "LOW": 7.0}.get(
|
||
quality_label,
|
||
5.0,
|
||
)
|
||
# V33: Removed probability deflation. Deflating probability breaks normalization
|
||
# (probs no longer sum to 1) and mathematically guarantees negative EV edge.
|
||
# Data quality and confidence penalties are already applied to play_score.
|
||
model_calibrated_prob = prob
|
||
band_prob = float(band_verdict.get("band_prob", 0.0) or 0.0)
|
||
if bool(band_verdict.get("available")):
|
||
calibrated_probability = (
|
||
(model_calibrated_prob * 0.45)
|
||
+ (band_prob * 0.35)
|
||
+ (implied_prob * 0.20)
|
||
)
|
||
elif implied_prob > 0.0:
|
||
calibrated_probability = (model_calibrated_prob * 0.65) + (implied_prob * 0.35)
|
||
else:
|
||
calibrated_probability = model_calibrated_prob
|
||
calibrated_probability = max(0.0, min(0.99, calibrated_probability))
|
||
model_edge = model_calibrated_prob - implied_prob if implied_prob > 0 else 0.0
|
||
ev_edge = (calibrated_probability * odd) - 1.0 if odd > 1.0 else 0.0
|
||
simple_edge = calibrated_probability - implied_prob if implied_prob > 0 else 0.0
|
||
|
||
home_n = len(data.home_lineup or [])
|
||
away_n = len(data.away_lineup or [])
|
||
lineup_missing = home_n < 9 or away_n < 9
|
||
lineup_sensitive = market in ("MS", "BTTS", "HT", "HTFT")
|
||
lineup_penalty = 5.0 if lineup_missing and lineup_sensitive else 0.0
|
||
if data.lineup_source == "probable_xi" and lineup_sensitive:
|
||
lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0)))
|
||
lineup_penalty += max(1.0, (1.0 - lineup_conf) * 5.0)
|
||
|
||
# ── V20+ Safety gates (PRESERVED) ─────────────────────────────
|
||
min_play_score = self.market_min_play_score.get(market, 68.0)
|
||
min_edge = self.market_min_edge.get(market, 0.02)
|
||
reasons: List[str] = []
|
||
playable = True
|
||
|
||
# V34: Broadened value_sniper bypass — odds-aware model rarely shows 3% EV edge
|
||
# Allow high-confidence predictions OR modest positive EV to bypass secondary gates
|
||
is_value_sniper = ev_edge >= 0.008 or calibrated_conf >= 55.0
|
||
|
||
if calibrated_conf < min_conf:
|
||
if not is_value_sniper:
|
||
playable = False
|
||
reasons.append("below_calibrated_conf_threshold")
|
||
if market in self.ODDS_REQUIRED_MARKETS and odd <= 1.01:
|
||
playable = False
|
||
reasons.append("market_odds_missing")
|
||
if risk_level in ("HIGH", "EXTREME") and quality_label == "LOW":
|
||
playable = False
|
||
reasons.append("high_risk_low_data_quality")
|
||
if lineup_missing and lineup_sensitive:
|
||
# V32: Don't hard-block, apply heavy penalty instead
|
||
# This allows high-confidence predictions to still surface
|
||
lineup_penalty += 8.0
|
||
reasons.append("lineup_insufficient_for_market")
|
||
if data.lineup_source == "probable_xi" and lineup_sensitive:
|
||
# V32: Penalty instead of hard block
|
||
# Most pre-match predictions use probable_xi — blocking kills all output
|
||
lineup_penalty += 6.0
|
||
reasons.append("lineup_probable_xi_penalty")
|
||
# V34: Added confidence bonus — high raw model probability gets a boost
|
||
# This prevents over-penalization when edge is near-zero but model is confident
|
||
raw_top_prob = float(row.get("probability", 0.0))
|
||
confidence_bonus = 0.0
|
||
if raw_top_prob >= 0.65:
|
||
confidence_bonus = 15.0
|
||
elif raw_top_prob >= 0.55:
|
||
confidence_bonus = 10.0
|
||
elif raw_top_prob >= 0.45:
|
||
confidence_bonus = 5.0
|
||
base_score = calibrated_conf + (simple_edge * 100.0 * edge_multiplier) + confidence_bonus
|
||
play_score = max(
|
||
0.0,
|
||
min(100.0, base_score - risk_penalty - quality_penalty - lineup_penalty),
|
||
)
|
||
# V34: odds_band gate — only hard-block when band data is AVAILABLE and aligned=False
|
||
# When band data is sparse (available=False), skip alignment check entirely
|
||
band_available = bool(band_verdict.get("available", False))
|
||
if band_available and bool(band_verdict.get("required")) and not bool(band_verdict.get("aligned")):
|
||
if not is_value_sniper:
|
||
playable = False
|
||
reasons.append(str(band_verdict.get("reason") or "odds_band_not_aligned"))
|
||
elif not band_available and bool(band_verdict.get("required")):
|
||
# Sparse data — log but don't block
|
||
reasons.append("odds_band_data_sparse_skipped")
|
||
# V34: REMOVED model_not_above_market gate entirely
|
||
# V25 model is odds-informed BY DESIGN → model output ≈ market-implied probability
|
||
# Requiring model > market is mathematically impossible with this architecture
|
||
# The negative_model_edge gate below still catches truly anti-value picks
|
||
# V34: negative edge threshold relaxed — odds-aware model's edge is naturally near zero
|
||
# Reliable league: -0.08, unreliable: up to -0.15
|
||
# Only blocks truly anti-value picks (model significantly below market)
|
||
neg_edge_threshold = -0.08 - (1.0 - odds_rel) * 0.07
|
||
if odd > 1.0 and simple_edge < neg_edge_threshold:
|
||
if not is_value_sniper:
|
||
playable = False
|
||
reasons.append(f"negative_model_edge_{simple_edge:+.3f}")
|
||
# V34: Added value_sniper bypass — was missing before, causing hard blocks
|
||
if odd > 1.0 and ev_edge < min_edge:
|
||
if not is_value_sniper:
|
||
playable = False
|
||
reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}")
|
||
if play_score < min_play_score:
|
||
if not is_value_sniper:
|
||
playable = False
|
||
reasons.append("insufficient_play_score")
|
||
|
||
if not reasons:
|
||
reasons.append("market_passed_all_gates")
|
||
consistency_reasons = [
|
||
str(reason)
|
||
for reason in row.get("consistency_reasons", [])
|
||
if reason
|
||
]
|
||
if consistency_reasons:
|
||
reasons.extend(consistency_reasons)
|
||
reasons = list(dict.fromkeys(reasons))
|
||
|
||
# ── V2 Quant: Edge-based grading (replaces play_score bands) ──
|
||
if not playable:
|
||
grade = "PASS"
|
||
stake_units = 0.0
|
||
elif ev_edge > 0.10:
|
||
grade = "A"
|
||
# V2 Quant: Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll)
|
||
stake_units = self._kelly_stake(calibrated_probability, odd)
|
||
reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_A")
|
||
elif ev_edge > 0.05:
|
||
grade = "B"
|
||
stake_units = self._kelly_stake(calibrated_probability, odd)
|
||
reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_B")
|
||
elif ev_edge > 0.02:
|
||
grade = "C"
|
||
stake_units = self._kelly_stake(calibrated_probability, odd)
|
||
reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_C")
|
||
else:
|
||
# Passes all V20+ gates but no mathematical edge over bookie
|
||
grade = "C"
|
||
stake_units = 0.25 # minimum stake (conservative)
|
||
reasons.append("no_ev_edge_minimum_stake")
|
||
|
||
out = dict(row)
|
||
out.update(
|
||
{
|
||
"raw_confidence": round(raw_conf, 1),
|
||
"calibrated_confidence": round(calibrated_conf, 1),
|
||
"min_required_confidence": round(min_conf, 1),
|
||
"min_required_play_score": round(min_play_score, 1),
|
||
"min_required_edge": round(min_edge, 4),
|
||
"edge": round(ev_edge, 4),
|
||
"model_probability": round(prob, 4),
|
||
"model_edge": round(model_edge, 4),
|
||
"calibrated_probability": round(calibrated_probability, 4),
|
||
"implied_prob": round(implied_prob, 4),
|
||
"ev_edge": round(ev_edge, 4),
|
||
"is_value_sniper": is_value_sniper,
|
||
"odds_band_probability": round(float(band_verdict.get("band_prob", 0.0) or 0.0), 4),
|
||
"odds_band_sample": round(float(band_verdict.get("band_sample", 0.0) or 0.0), 1),
|
||
"odds_band_edge": round(float(band_verdict.get("band_edge", 0.0) or 0.0), 4),
|
||
"odds_band_aligned": bool(band_verdict.get("aligned")),
|
||
"odds_reliability": round(odds_rel, 4),
|
||
"play_score": round(play_score, 1),
|
||
"playable": playable,
|
||
"bet_grade": grade,
|
||
"stake_units": stake_units,
|
||
"decision_reasons": reasons[:5],
|
||
},
|
||
)
|
||
return out
|
||
|
||
@staticmethod
|
||
def _kelly_stake(true_prob: float, decimal_odds: float) -> float:
|
||
"""
|
||
Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll).
|
||
|
||
Full Kelly: f* = ((b × p) - q) / b
|
||
where b = odds - 1, p = true_prob, q = 1 - p
|
||
|
||
Quarter-Kelly reduces variance and ruin risk on noisy sports data.
|
||
Returns stake in units, capped at 3.0.
|
||
"""
|
||
if decimal_odds <= 1.0 or true_prob <= 0.0 or true_prob >= 1.0:
|
||
return 0.25 # minimum fallback
|
||
|
||
b = decimal_odds - 1.0
|
||
p = true_prob
|
||
q = 1.0 - p
|
||
f_star = ((b * p) - q) / b
|
||
|
||
if f_star <= 0.0:
|
||
return 0.25 # minimum fallback
|
||
|
||
kelly_fraction = 0.25 # quarter-Kelly
|
||
bankroll_units = 10.0
|
||
stake = f_star * kelly_fraction * bankroll_units
|
||
stake = min(stake, 3.0) # cap
|
||
return round(max(0.25, stake), 1)
|
||
|
||
@staticmethod
|
||
def _to_bet_summary_item(row: Dict[str, Any]) -> Dict[str, Any]:
|
||
return {
|
||
"market": row.get("market"),
|
||
"pick": row.get("pick"),
|
||
"raw_confidence": row.get("raw_confidence", row.get("confidence")),
|
||
"calibrated_confidence": row.get("calibrated_confidence", row.get("confidence")),
|
||
"bet_grade": row.get("bet_grade", "PASS"),
|
||
"playable": bool(row.get("playable")),
|
||
"stake_units": float(row.get("stake_units", 0.0)),
|
||
"play_score": row.get("play_score", 0.0),
|
||
"ev_edge": row.get("ev_edge", row.get("edge", 0.0)),
|
||
"is_value_sniper": bool(row.get("is_value_sniper")),
|
||
"model_probability": row.get("model_probability", row.get("probability", 0.0)),
|
||
"model_edge": row.get("model_edge", 0.0),
|
||
"calibrated_probability": row.get("calibrated_probability", row.get("probability", 0.0)),
|
||
"implied_prob": row.get("implied_prob", 0.0),
|
||
"odds_band_probability": row.get("odds_band_probability", 0.0),
|
||
"odds_band_sample": row.get("odds_band_sample", 0.0),
|
||
"odds_band_edge": row.get("odds_band_edge", 0.0),
|
||
"odds_band_aligned": bool(row.get("odds_band_aligned")),
|
||
"odds_reliability": row.get("odds_reliability", 0.35),
|
||
"odds": row.get("odds", 0.0),
|
||
"reasons": row.get("decision_reasons", []),
|
||
}
|
||
|
||
def _compute_data_quality(self, data: MatchData) -> Dict[str, Any]:
|
||
if str(data.sport or "football").lower() == "basketball":
|
||
return self._compute_basketball_data_quality(data)
|
||
|
||
flags: List[str] = []
|
||
|
||
ms_keys = ("ms_h", "ms_d", "ms_a")
|
||
has_ms = all(k in data.odds_data for k in ms_keys)
|
||
has_market_depth = any(k not in ms_keys for k in data.odds_data.keys())
|
||
is_default_ms = (
|
||
abs(float(data.odds_data.get("ms_h", 0.0)) - self.DEFAULT_MS_H) < 1e-6 and
|
||
abs(float(data.odds_data.get("ms_d", 0.0)) - self.DEFAULT_MS_D) < 1e-6 and
|
||
abs(float(data.odds_data.get("ms_a", 0.0)) - self.DEFAULT_MS_A) < 1e-6
|
||
)
|
||
has_real_ms = has_ms and (has_market_depth or (not is_default_ms))
|
||
odds_score = 1.0 if has_real_ms else (0.6 if has_ms else 0.4)
|
||
if odds_score < 1.0:
|
||
flags.append("missing_full_ms_odds")
|
||
|
||
home_n = len(data.home_lineup or [])
|
||
away_n = len(data.away_lineup or [])
|
||
lineup_score = min(home_n, away_n) / 11.0 if min(home_n, away_n) > 0 else 0.0
|
||
if data.lineup_source == "probable_xi":
|
||
lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0)))
|
||
lineup_score *= max(0.45, min(0.88, lineup_conf))
|
||
flags.append("lineup_probable_not_confirmed")
|
||
if lineup_conf < 0.65:
|
||
flags.append("lineup_projection_low_confidence")
|
||
elif data.lineup_source == "none":
|
||
flags.append("lineup_unavailable")
|
||
if lineup_score < 0.7:
|
||
flags.append("lineup_incomplete")
|
||
|
||
ref_score = 1.0 if data.referee_name else 0.6
|
||
if not data.referee_name:
|
||
flags.append("missing_referee")
|
||
if data.source_table == "live_matches":
|
||
flags.append("live_match_pre_match_features")
|
||
feature_source = str(getattr(data, "feature_source", "") or "")
|
||
if feature_source == "live_prematch_enrichment":
|
||
flags.append("ai_features_inferred_from_history")
|
||
|
||
total_score = (odds_score * 0.45) + (lineup_score * 0.45) + (ref_score * 0.10)
|
||
|
||
# When statistical features are inferred (not from pre-computed table),
|
||
# ELO/form/H2H data reliability is unknown — cap quality at MEDIUM.
|
||
if feature_source == "live_prematch_enrichment":
|
||
total_score = min(total_score, 0.74)
|
||
|
||
if total_score >= 0.8:
|
||
label = "HIGH"
|
||
elif total_score >= 0.55:
|
||
label = "MEDIUM"
|
||
else:
|
||
label = "LOW"
|
||
if label == "HIGH" and (
|
||
data.lineup_source == "probable_xi" or not data.referee_name
|
||
or feature_source == "live_prematch_enrichment"
|
||
):
|
||
label = "MEDIUM"
|
||
|
||
return {
|
||
"label": label,
|
||
"score": round(total_score, 3),
|
||
"home_lineup_count": home_n,
|
||
"away_lineup_count": away_n,
|
||
"lineup_source": data.lineup_source,
|
||
"lineup_confidence": round(float(getattr(data, "lineup_confidence", 0.0) or 0.0), 3),
|
||
"feature_source": feature_source or "unknown",
|
||
"flags": flags,
|
||
}
|
||
|
||
def _build_reasoning_factors(
|
||
self,
|
||
data: MatchData,
|
||
prediction: FullMatchPrediction,
|
||
quality: Dict[str, Any],
|
||
) -> List[str]:
|
||
factors: List[str] = []
|
||
|
||
if prediction.odds_confidence >= prediction.team_confidence:
|
||
factors.append("market_signal_dominant")
|
||
else:
|
||
factors.append("team_form_signal_dominant")
|
||
|
||
if prediction.player_confidence >= 60:
|
||
factors.append("lineup_signal_strong")
|
||
elif not data.home_lineup or not data.away_lineup:
|
||
factors.append("lineup_signal_weak")
|
||
if data.lineup_source == "probable_xi":
|
||
factors.append("lineup_probable_xi_used")
|
||
|
||
if prediction.is_surprise_risk:
|
||
factors.append("upset_risk_detected")
|
||
|
||
if quality["label"] == "LOW":
|
||
factors.append("limited_data_confidence")
|
||
|
||
if prediction.risk_warnings:
|
||
factors.extend([f"risk:{w}" for w in prediction.risk_warnings[:2]])
|
||
|
||
return factors
|