1830 lines
83 KiB
Python
1830 lines
83 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, get_final_recalibrator
|
||
from models.market_anchor import devig, apply_home_correction
|
||
|
||
# ── V30: Post-calibration trust factors ─────────────────────────────
|
||
# Controls how much to trust isotonic calibrator vs raw model output.
|
||
# trust=1.0 → use calibrator fully; trust=0.0 → bypass, use raw model.
|
||
# Derived from calibrator_metrics.json analysis (mean_predicted vs mean_actual):
|
||
# MS calibrators: gap < 0.5% → excellent, full trust
|
||
# BTTS: gap = +14.4% → calibrator broken, bypass
|
||
# OU25: gap = +5.3% → over-inflates, mostly bypass
|
||
# OU35: gap = +3.6% → moderate inflation, dampen
|
||
# OU15: gap = +1.5% → slight, mostly trust
|
||
# HT: mixed → moderate trust
|
||
# DC/HT_FT: < 30 samples → unreliable, bypass
|
||
POST_CAL_TRUST: Dict[str, float] = {
|
||
"ms_home": 1.0,
|
||
"ms_draw": 1.0,
|
||
"ms_away": 1.0,
|
||
"btts": 0.0,
|
||
"ou25": 0.15,
|
||
"ou35": 0.30,
|
||
"ou15": 0.70,
|
||
"ht_home": 0.50,
|
||
"ht_draw": 0.30,
|
||
"ht_away": 0.50,
|
||
"dc": 0.0,
|
||
"ht_ft": 0.0,
|
||
}
|
||
|
||
|
||
class MarketBoardMixin:
|
||
def _league_confidence_for(self, league_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
||
"""Return the backtest-derived confidence record for a league, or None.
|
||
|
||
Shape: {"label": high|medium|low, "bet_roi": float, "bet_n": int,
|
||
"hit": float}. None → league absent or too few bets ('unknown') → FE
|
||
shows no badge. Never raises (missing artifact = graceful None)."""
|
||
if not league_id:
|
||
return None
|
||
lookup = getattr(self, "league_confidence", None) or {}
|
||
info = lookup.get(str(league_id))
|
||
if not isinstance(info, dict):
|
||
return None
|
||
label = info.get("label")
|
||
if label in (None, "unknown"):
|
||
return None
|
||
return {
|
||
"label": label,
|
||
"bet_roi": info.get("bet_roi"),
|
||
"bet_n": info.get("bet_n"),
|
||
"hit": info.get("hit"),
|
||
}
|
||
|
||
def _is_national_match(self, league_id: Optional[str]) -> bool:
|
||
"""True if this league is an A-milli (senior men's) national competition."""
|
||
if not league_id:
|
||
return False
|
||
natl = getattr(self, "national_leagues", None) or set()
|
||
return str(league_id) in natl
|
||
|
||
def _competition_type_for(
|
||
self, league_id: Optional[str], league_name: Optional[str]
|
||
) -> Optional[str]:
|
||
"""For national matches, classify HAZIRLIK/ELEME/TURNUVA from the league
|
||
name. None for non-national leagues (clubs don't use this)."""
|
||
if not self._is_national_match(league_id):
|
||
return None
|
||
try:
|
||
from utils.national_leagues import classify_competition
|
||
return classify_competition(league_name or "")
|
||
except Exception:
|
||
return None
|
||
|
||
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
|
||
}
|
||
|
||
# V35: anchor the DISPLAYED per-market probabilities to the de-vigged
|
||
# market price (+ proven home-favourite correction). The model's own
|
||
# numbers were measured ~25-30% mis-calibrated; the de-vigged market is
|
||
# ~1.5% (out-of-sample). This only rewrites what the user sees.
|
||
market_board = self._apply_market_anchor(market_board, data)
|
||
|
||
# V35b: make the DISPLAYED confidence/edge fields on every pick object
|
||
# consistent with the calibrated board (Güven Skoru, Güven Aralığı,
|
||
# Model%/Teorik-avantaj), then drop a "value pick" that has no real edge
|
||
# once priced honestly — no fabricated value bets.
|
||
self._apply_anchor_to_picks(
|
||
market_board, main_pick, value_pick, aggressive_pick, supporting, bet_summary,
|
||
)
|
||
if value_pick is not None and float(value_pick.get("ev_edge", 0.0) or 0.0) <= 0.0:
|
||
value_pick = None
|
||
|
||
# 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,
|
||
"league_id": data.league_id,
|
||
# Backtest-derived per-league confidence (ROI + sample size).
|
||
# None when the league has too little data to judge → FE shows no badge.
|
||
"league_confidence": self._league_confidence_for(data.league_id),
|
||
# National-team match flags (drive betting_brain's national gate).
|
||
"is_national": self._is_national_match(data.league_id),
|
||
"competition_type": self._competition_type_for(data.league_id, 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
|
||
|
||
# ── V35 market-anchored calibration ────────────────────────────────
|
||
# Maps a board pick label -> the probs key it refers to, so the displayed
|
||
# confidence can be set to the EXISTING pick's now-calibrated probability.
|
||
_ANCHOR_PICK_KEY: Dict[str, Dict[str, str]] = {
|
||
"MS": {"1": "1", "X": "X", "0": "X", "2": "2"},
|
||
"HT": {"1": "1", "X": "X", "0": "X", "2": "2"},
|
||
"DC": {"1X": "1X", "X2": "X2", "12": "12",
|
||
"1-X": "1X", "X-2": "X2", "1-2": "12"},
|
||
"OU15": {"Üst": "over", "Alt": "under", "Over": "over", "Under": "under"},
|
||
"OU25": {"Üst": "over", "Alt": "under", "Over": "over", "Under": "under"},
|
||
"OU35": {"Üst": "over", "Alt": "under", "Over": "over", "Under": "under"},
|
||
"HT_OU05": {"Üst": "over", "Alt": "under", "Over": "over", "Under": "under"},
|
||
"HT_OU15": {"Üst": "over", "Alt": "under", "Over": "over", "Under": "under"},
|
||
"BTTS": {"KG Var": "yes", "KG Yok": "no", "Var": "yes", "Yok": "no",
|
||
"Yes": "yes", "No": "no"},
|
||
"OE": {"Tek": "odd", "Çift": "even", "Odd": "odd", "Even": "even"},
|
||
}
|
||
|
||
def _set_board(
|
||
self,
|
||
market_board: Dict[str, Any],
|
||
market: str,
|
||
probs: Dict[str, float],
|
||
) -> None:
|
||
"""Overwrite one board entry's probs with calibrated values and refresh
|
||
its confidence to the EXISTING pick's now-calibrated probability.
|
||
|
||
We recalibrate the NUMBERS, not the pick selection — showing the engine's
|
||
pick alongside its honest probability. Falls back to the most-likely
|
||
outcome only when the pick can't be mapped."""
|
||
entry = market_board.get(market)
|
||
if not isinstance(entry, dict):
|
||
return
|
||
rounded = {k: round(float(v), 4) for k, v in probs.items()}
|
||
if not rounded:
|
||
return
|
||
entry["probs"] = rounded
|
||
pick = str(entry.get("pick") or "")
|
||
key = self._ANCHOR_PICK_KEY.get(market, {}).get(pick)
|
||
if key is None or key not in rounded:
|
||
key = max(rounded, key=rounded.get)
|
||
entry["confidence"] = round(rounded[key] * 100.0, 1)
|
||
entry["calibration_source"] = "market_anchor_v35"
|
||
|
||
def _apply_market_anchor(
|
||
self,
|
||
market_board: Dict[str, Any],
|
||
data: MatchData,
|
||
) -> Dict[str, Any]:
|
||
"""Anchor DISPLAYED per-market probabilities to the de-vigged market
|
||
price (+ proven home-favourite correction for MS, and DC derived from
|
||
it for internal consistency).
|
||
|
||
Only markets with REAL odds are rewritten — `devig` returns None for any
|
||
missing/placeholder leg, so no-odds markets are left untouched (and are
|
||
already dropped upstream per the product rule: never show fabricated
|
||
numbers for a match without odds). Toggle off with env MARKET_ANCHOR_CAL=0.
|
||
"""
|
||
if os.environ.get("MARKET_ANCHOR_CAL", "1") == "0":
|
||
return market_board
|
||
if not isinstance(market_board, dict) or not market_board:
|
||
return market_board
|
||
odds = getattr(data, "odds_data", None) or {}
|
||
|
||
def real(key: str) -> Optional[float]:
|
||
val = self._real_market_odds(odds, key)
|
||
return val if val > 1.01 else None
|
||
|
||
# MS (3-way) + home-favourite correction; DC derived from the same vector
|
||
ms = devig([real("ms_h"), real("ms_d"), real("ms_a")])
|
||
if ms is not None:
|
||
p1, px, p2 = apply_home_correction(*ms)
|
||
if "MS" in market_board:
|
||
self._set_board(market_board, "MS", {"1": p1, "X": px, "2": p2})
|
||
if "DC" in market_board:
|
||
self._set_board(
|
||
market_board, "DC",
|
||
{"1X": p1 + px, "X2": px + p2, "12": p1 + p2},
|
||
)
|
||
|
||
# HT (3-way)
|
||
ht = devig([real("ht_h"), real("ht_d"), real("ht_a")])
|
||
if ht is not None and "HT" in market_board:
|
||
self._set_board(market_board, "HT", {"1": ht[0], "X": ht[1], "2": ht[2]})
|
||
|
||
# 2-way markets
|
||
for mk, ko, ku, lo, lu in (
|
||
("OU15", "ou15_o", "ou15_u", "over", "under"),
|
||
("OU25", "ou25_o", "ou25_u", "over", "under"),
|
||
("OU35", "ou35_o", "ou35_u", "over", "under"),
|
||
("BTTS", "btts_y", "btts_n", "yes", "no"),
|
||
("OE", "oe_odd", "oe_even", "odd", "even"),
|
||
("HT_OU05", "ht_ou05_o", "ht_ou05_u", "over", "under"),
|
||
("HT_OU15", "ht_ou15_o", "ht_ou15_u", "over", "under"),
|
||
):
|
||
if mk not in market_board:
|
||
continue
|
||
pair = devig([real(ko), real(ku)])
|
||
if pair is not None:
|
||
self._set_board(market_board, mk, {lo: pair[0], lu: pair[1]})
|
||
|
||
return market_board
|
||
|
||
def _anchored_prob_for(
|
||
self,
|
||
market_board: Dict[str, Any],
|
||
market: str,
|
||
pick: Any,
|
||
) -> Optional[float]:
|
||
"""Look up a pick's calibrated probability from the anchored board.
|
||
|
||
Returns None unless the market was actually anchored (real odds) and the
|
||
pick maps to a known outcome — so no-odds picks are never touched."""
|
||
entry = market_board.get(str(market or ""))
|
||
if not isinstance(entry, dict):
|
||
return None
|
||
if entry.get("calibration_source") != "market_anchor_v35":
|
||
return None
|
||
probs = entry.get("probs") or {}
|
||
key = self._ANCHOR_PICK_KEY.get(str(market or ""), {}).get(str(pick or ""))
|
||
if key is None or key not in probs:
|
||
return None
|
||
try:
|
||
return float(probs[key])
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
def _recalibrate_pick_display(
|
||
self,
|
||
obj: Optional[Dict[str, Any]],
|
||
market_board: Dict[str, Any],
|
||
) -> None:
|
||
"""Rewrite ONE pick object's displayed confidence/edge fields so they are
|
||
consistent with the calibrated (de-vigged market) probability.
|
||
|
||
Fixes Güven Skoru (`calibrated_confidence`/`unified_score`), Güven Aralığı
|
||
(`confidence_interval` recentred on the calibrated confidence), and the
|
||
value card's Model%/Teorik-avantaj (`model_probability`/`ev_edge`/`edge`,
|
||
recomputed honestly against the real price → the vig shows as it truly is,
|
||
no fabricated positive edge). Selection/gates/stake are left untouched."""
|
||
if not isinstance(obj, dict):
|
||
return
|
||
p = self._anchored_prob_for(market_board, obj.get("market"), obj.get("pick"))
|
||
if p is None:
|
||
return
|
||
try:
|
||
odds = float(obj.get("odds") or 0.0)
|
||
except (TypeError, ValueError):
|
||
odds = 0.0
|
||
implied = (1.0 / odds) if odds > 1.0 else 0.0
|
||
conf = round(p * 100.0, 1)
|
||
ev = round(p * odds - 1.0, 4) if odds > 1.0 else 0.0
|
||
obj["calibrated_probability"] = round(p, 4)
|
||
obj["model_probability"] = round(p, 4)
|
||
obj["calibrated_confidence"] = conf
|
||
obj["unified_score"] = conf
|
||
obj["implied_prob"] = round(implied, 4)
|
||
obj["model_edge"] = round(p - implied, 4) if implied > 0.0 else 0.0
|
||
obj["ev_edge"] = ev
|
||
obj["edge"] = ev
|
||
# Recentre the confidence interval on the calibrated confidence, keeping a
|
||
# sensible width (preserve the engine's width when present).
|
||
width = 16.0
|
||
ci = obj.get("confidence_interval")
|
||
if isinstance(ci, dict) and ci.get("lower") is not None and ci.get("upper") is not None:
|
||
try:
|
||
width = max(6.0, float(ci["upper"]) - float(ci["lower"]))
|
||
except (TypeError, ValueError):
|
||
width = 16.0
|
||
half = width / 2.0
|
||
lower = round(max(0.0, conf - half), 1)
|
||
upper = round(min(100.0, conf + half), 1)
|
||
band = "HIGH" if conf >= 60.0 else "MEDIUM" if conf >= 42.0 else "LOW"
|
||
obj["confidence_interval"] = {
|
||
"band": band,
|
||
"lower": lower,
|
||
"upper": upper,
|
||
"width": round(upper - lower, 1),
|
||
"threshold_met": conf >= 50.0,
|
||
}
|
||
obj["confidence_band"] = band
|
||
obj["calibration_source"] = "market_anchor_v35"
|
||
|
||
def _apply_anchor_to_picks(
|
||
self,
|
||
market_board: Dict[str, Any],
|
||
main_pick: Optional[Dict[str, Any]],
|
||
value_pick: Optional[Dict[str, Any]],
|
||
aggressive_pick: Optional[Dict[str, Any]],
|
||
supporting: Optional[List[Dict[str, Any]]],
|
||
bet_summary: Optional[List[Dict[str, Any]]],
|
||
) -> None:
|
||
"""Make every DISPLAYED pick object consistent with the anchored board.
|
||
Toggle off with env MARKET_ANCHOR_CAL=0."""
|
||
if os.environ.get("MARKET_ANCHOR_CAL", "1") == "0":
|
||
return
|
||
for obj in (main_pick, value_pick, aggressive_pick):
|
||
self._recalibrate_pick_display(obj, market_board)
|
||
for obj in list(supporting or []):
|
||
self._recalibrate_pick_display(obj, market_board)
|
||
for obj in list(bet_summary or []):
|
||
self._recalibrate_pick_display(obj, market_board)
|
||
|
||
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)
|
||
# V30: Trust-based blending — some calibrators inflate probabilities.
|
||
# Blend isotonic output with raw model based on calibrator accuracy.
|
||
trust = POST_CAL_TRUST.get(cal_key, 0.5)
|
||
cal_prob = trust * cal_prob + (1.0 - trust) * cal_input
|
||
calibrated_conf = max(1.0, min(99.0, cal_prob * 100.0))
|
||
else:
|
||
# V31b: Fallback for markets WITHOUT isotonic calibrator.
|
||
# Old approach used aggressive multipliers (0.58-0.85) causing
|
||
# massive deflation: HT_OU15 -40.5%, HT_OU05 -25.2%, OE -18.3%.
|
||
# New approach: mild damping (0.92) acknowledges slight model
|
||
# overconfidence without destroying probability signal.
|
||
# The tier system (V31b) is the real profitability gatekeeper.
|
||
calibrated_conf = max(1.0, min(99.0, raw_conf * 0.92))
|
||
|
||
# ── FINAL-OUTPUT RECALIBRATION (V31e) ──────────────────────────
|
||
# Last-step per-market map: "system says X% -> reality is Y%". ONLY
|
||
# badly-miscalibrated markets carry a map (fit-ECE >= 5: OU15, OU35,
|
||
# HT_OU05, HT_OU15). MS and every already-good market pass through
|
||
# UNCHANGED -> guaranteed no regression. Out-of-sample proven (e.g.
|
||
# HT_OU15 ECE 29.2->0.8) and identity-safe for MS (1.1->1.3).
|
||
# This adjusts ONLY the displayed confidence so users see honest
|
||
# probabilities; all analysis below (probabilities, edges, vetoes,
|
||
# tiers, bands) is preserved, and the pre-recal value is kept for audit.
|
||
pre_recal_conf = calibrated_conf
|
||
calibrated_conf = get_final_recalibrator().recalibrate_conf(market, calibrated_conf)
|
||
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
|
||
|
||
# V29b: Permissive upstream — let betting_brain's tiered system do the real filtering.
|
||
# Old threshold (ev>=0.008 OR conf>=55) let everything through AND bypassed brain vetoes.
|
||
# New approach: let most picks through market_board, but brain's MARKET_ODDS_TIERS
|
||
# + hard vetoes (neg EV, muted, low reliability) handle the intelligent filtering.
|
||
is_value_sniper = calibrated_conf >= 45.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")
|
||
|
||
# ── V30: Birleşik Güven Skoru (BGS) ────────────────────────────
|
||
# A single, honest metric for users: quality-adjusted win probability.
|
||
# Combines calibrated probability with data quality signals.
|
||
# Correlation analysis: model_gap r=-0.12, trap negative, reliability weak positive.
|
||
bgs = calibrated_conf # POST_CAL_TRUST corrected base
|
||
model_gap = prob - implied_prob if implied_prob > 0 else 0.0
|
||
# Penalty when model overestimates vs market (r=-0.12 correlation)
|
||
if model_gap > 0.05:
|
||
bgs -= 8.0
|
||
elif model_gap > 0.0:
|
||
bgs -= 3.0
|
||
# Trap market detection: implied prob significantly above historical band rate
|
||
is_trap_signal = False
|
||
if band_available and band_prob > 0 and implied_prob > 0:
|
||
is_trap_signal = (implied_prob - band_prob) > 0.10
|
||
if is_trap_signal:
|
||
bgs -= 7.0
|
||
# League reliability adjustment (±2)
|
||
bgs += (odds_rel - 0.50) * 4.0
|
||
# Band alignment
|
||
if band_available:
|
||
if bool(band_verdict.get("aligned")):
|
||
bgs += 2.0
|
||
else:
|
||
bgs -= 3.0
|
||
# BGS label for frontend
|
||
bgs = max(1.0, min(99.0, bgs))
|
||
if bgs >= 70:
|
||
bgs_label = "very_reliable"
|
||
elif bgs >= 55:
|
||
bgs_label = "reliable"
|
||
elif bgs >= 40:
|
||
bgs_label = "moderate"
|
||
else:
|
||
bgs_label = "low"
|
||
|
||
out = dict(row)
|
||
out.update(
|
||
{
|
||
"raw_confidence": round(raw_conf, 1),
|
||
"calibrated_confidence": round(calibrated_conf, 1),
|
||
"calibrated_confidence_pre_recal": round(pre_recal_conf, 1),
|
||
"unified_score": round(bgs, 1),
|
||
"unified_score_label": bgs_label,
|
||
"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")),
|
||
"unified_score": row.get("unified_score", row.get("calibrated_confidence", 0.0)),
|
||
"unified_score_label": row.get("unified_score_label", "moderate"),
|
||
"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
|