v28
Deploy Iddaai Backend / build-and-deploy (push) Successful in 3m21s

This commit is contained in:
2026-04-24 23:46:28 +03:00
parent 3875f2a512
commit 9027cc9900
17 changed files with 4315 additions and 122 deletions
+497
View File
@@ -0,0 +1,497 @@
"""
Deterministic betting judge for prediction packages.
The model layer estimates event probabilities. BettingBrain decides whether
those probabilities are trustworthy enough to risk money.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
class BettingBrain:
MIN_ODDS = 1.30
MIN_BET_SCORE = 72.0
MIN_WATCH_SCORE = 62.0
MIN_BAND_SAMPLE = 8
HARD_DIVERGENCE = 0.22
SOFT_DIVERGENCE = 0.14
EXTREME_MODEL_PROB = 0.85
EXTREME_GAP = 0.30
MARKET_PRIORS = {
"DC": 4.0,
"OU15": 3.0,
"OU25": 2.0,
"BTTS": 0.0,
"MS": -2.0,
"OU35": -2.0,
"HT": -6.0,
"HTFT": -12.0,
"CARDS": -5.0,
"OE": -8.0,
}
def judge(self, package: Dict[str, Any]) -> Dict[str, Any]:
v27_engine = package.get("v27_engine")
if not isinstance(v27_engine, dict):
return package
guarded = dict(package)
rows = self._collect_rows(guarded)
if not rows:
return guarded
judged_rows: Dict[str, Dict[str, Any]] = {}
decisions: List[Dict[str, Any]] = []
for row in rows:
key = self._row_key(row)
judged = self._judge_row(dict(row), guarded)
judged_rows[key] = judged
decisions.append(judged["betting_brain"])
approved = [
row for row in judged_rows.values()
if row.get("betting_brain", {}).get("action") == "BET"
]
watchlist = [
row for row in judged_rows.values()
if row.get("betting_brain", {}).get("action") == "WATCH"
]
approved.sort(key=self._candidate_sort_key, reverse=True)
watchlist.sort(key=self._candidate_sort_key, reverse=True)
original_main = guarded.get("main_pick") or {}
main_pick = None
decision = "NO_BET"
decision_reason = "No candidate passed the betting brain evidence gates."
if approved:
main_pick = dict(approved[0])
main_pick["is_guaranteed"] = bool(main_pick.get("betting_brain", {}).get("score", 0.0) >= 82.0)
main_pick["pick_reason"] = "betting_brain_approved"
decision = "BET"
decision_reason = main_pick.get("betting_brain", {}).get("summary", "Evidence is aligned.")
elif watchlist:
main_pick = dict(watchlist[0])
self._force_no_bet(main_pick, "betting_brain_watchlist")
decision = "WATCHLIST"
decision_reason = main_pick.get("betting_brain", {}).get("summary", "Interesting but not clean enough.")
elif original_main:
main_pick = dict(judged_rows.get(self._row_key(original_main), original_main))
self._force_no_bet(main_pick, "betting_brain_no_safe_pick")
main_key = self._row_key(main_pick) if main_pick else ""
supporting = [
dict(row)
for row in judged_rows.values()
if self._row_key(row) != main_key
]
supporting.sort(key=self._candidate_sort_key, reverse=True)
bet_summary = [
self._summary_item(row)
for row in sorted(judged_rows.values(), key=self._candidate_sort_key, reverse=True)
]
guarded["main_pick"] = main_pick
guarded["value_pick"] = self._pick_value_candidate(judged_rows, main_key)
guarded["supporting_picks"] = supporting[:6]
guarded["bet_summary"] = bet_summary
playable = decision == "BET" and bool(main_pick and main_pick.get("playable"))
advice = dict(guarded.get("bet_advice") or {})
advice["playable"] = playable
advice["suggested_stake_units"] = float(main_pick.get("stake_units", 0.0)) if playable else 0.0
advice["reason"] = "betting_brain_approved" if playable else "betting_brain_no_bet"
advice["decision"] = decision
advice["confidence_band"] = self._decision_band(main_pick)
guarded["bet_advice"] = advice
rejected = [d for d in decisions if d.get("action") == "REJECT"]
guarded["betting_brain"] = {
"version": "judge-v1",
"decision": decision,
"reason": decision_reason,
"main_pick_key": main_key or None,
"approved_count": len(approved),
"watchlist_count": len(watchlist),
"rejected_count": len(rejected),
"top_candidates": self._top_decisions(decisions),
"rules": {
"min_bet_score": self.MIN_BET_SCORE,
"min_watch_score": self.MIN_WATCH_SCORE,
"min_band_sample": self.MIN_BAND_SAMPLE,
"hard_divergence": self.HARD_DIVERGENCE,
"soft_divergence": self.SOFT_DIVERGENCE,
"extreme_model_probability": self.EXTREME_MODEL_PROB,
"extreme_model_market_gap": self.EXTREME_GAP,
},
}
guarded["upper_brain"] = guarded["betting_brain"]
guarded.setdefault("analysis_details", {})
guarded["analysis_details"]["betting_brain_applied"] = True
guarded["analysis_details"]["betting_brain_decision"] = decision
return guarded
def _judge_row(self, row: Dict[str, Any], package: Dict[str, Any]) -> Dict[str, Any]:
market = str(row.get("market") or "")
pick = str(row.get("pick") or "")
model_prob = self._market_probability(row, package)
odds = self._safe_float(row.get("odds"), 0.0) or 0.0
implied = (1.0 / odds) if odds > 1.0 else 0.0
model_gap = (model_prob - implied) if model_prob is not None and implied > 0 else None
calibrated_conf = self._safe_float(row.get("calibrated_confidence", row.get("confidence")), 0.0) or 0.0
play_score = self._safe_float(row.get("play_score"), 0.0) or 0.0
ev_edge = self._safe_float(row.get("ev_edge", row.get("edge")), 0.0) or 0.0
v27_prob = self._v27_probability(market, pick, package.get("v27_engine") or {})
divergence = abs(model_prob - v27_prob) if model_prob is not None and v27_prob is not None else None
triple_key = self._triple_key(market, pick)
triple = self._triple_value(package, triple_key)
band_sample = int(self._safe_float((triple or {}).get("band_sample"), 0.0) or 0.0)
triple_is_value = bool((triple or {}).get("is_value"))
consensus = str((package.get("v27_engine") or {}).get("consensus") or "").upper()
positives: List[str] = []
issues: List[str] = []
vetoes: List[str] = []
score = 0.0
if row.get("playable"):
score += 18.0
positives.append("base_model_playable")
else:
score -= 18.0
issues.append("base_model_not_playable")
score += max(0.0, min(20.0, calibrated_conf * 0.22))
score += max(-8.0, min(16.0, ev_edge * 45.0))
score += max(0.0, min(14.0, play_score * 0.12))
score += self.MARKET_PRIORS.get(market, -3.0)
data_quality = package.get("data_quality") or {}
quality_score = self._safe_float(data_quality.get("score"), 0.6) or 0.6
score += max(-8.0, min(6.0, (quality_score - 0.55) * 16.0))
risk = str((package.get("risk") or {}).get("level") or "MEDIUM").upper()
score += {"LOW": 5.0, "MEDIUM": 0.0, "HIGH": -12.0, "EXTREME": -22.0}.get(risk, -4.0)
if odds < self.MIN_ODDS:
vetoes.append("odds_below_minimum")
if calibrated_conf < 38.0:
vetoes.append("calibrated_confidence_too_low")
if play_score < 50.0:
vetoes.append("play_score_too_low")
if divergence is not None:
if divergence >= self.HARD_DIVERGENCE:
score -= 42.0
vetoes.append("v25_v27_hard_disagreement")
elif divergence >= self.SOFT_DIVERGENCE:
score -= 18.0
issues.append("v25_v27_soft_disagreement")
else:
score += 11.0
positives.append("v25_v27_aligned")
if isinstance(triple, dict):
if triple_is_value:
score += 18.0
positives.append("triple_value_confirmed")
elif market in {"DC", "MS", "OU25", "BTTS"}:
score -= 18.0
issues.append("triple_value_not_confirmed")
if band_sample >= 25:
score += 8.0
positives.append("strong_historical_sample")
elif band_sample >= self.MIN_BAND_SAMPLE:
score += 3.0
positives.append("usable_historical_sample")
else:
score -= 16.0
issues.append("historical_sample_too_low")
if market == "DC":
vetoes.append("dc_without_historical_sample")
elif market in {"MS", "DC", "OU25"}:
score -= 10.0
issues.append("missing_triple_value_evidence")
if consensus == "DISAGREE" and market in {"MS", "DC"}:
score -= 12.0
issues.append("engine_consensus_disagree")
if (
model_prob is not None
and model_gap is not None
and model_prob >= self.EXTREME_MODEL_PROB
and model_gap >= self.EXTREME_GAP
and not triple_is_value
):
score -= 24.0
vetoes.append("extreme_probability_without_evidence")
if market in {"HT", "HTFT", "OE"} and score < 86.0:
vetoes.append("volatile_market_requires_exceptional_evidence")
score = max(0.0, min(100.0, score))
action = "BET"
if vetoes:
action = "REJECT"
elif score < self.MIN_WATCH_SCORE:
action = "REJECT"
elif score < self.MIN_BET_SCORE:
action = "WATCH"
row["betting_brain"] = {
"action": action,
"score": round(score, 1),
"summary": self._summary(action, market, pick, positives, issues, vetoes),
"positives": positives[:5],
"issues": issues[:6],
"vetoes": vetoes[:6],
"model_prob": round(model_prob, 4) if model_prob is not None else None,
"implied_prob": round(implied, 4),
"model_market_gap": round(model_gap, 4) if model_gap is not None else None,
"v27_prob": round(v27_prob, 4) if v27_prob is not None else None,
"divergence": round(divergence, 4) if divergence is not None else None,
"triple_key": triple_key,
"triple_value": triple,
}
if action != "BET":
self._force_no_bet(row, f"betting_brain_{action.lower()}")
else:
row["is_guaranteed"] = bool(score >= 82.0)
row["pick_reason"] = "betting_brain_approved"
row["stake_units"] = self._brain_stake(row, score)
row["bet_grade"] = "A" if score >= 82.0 else "B"
row["playable"] = True
self._append_reason(row, f"betting_brain_{action.lower()}_{round(score)}")
return row
def _collect_rows(self, package: Dict[str, Any]) -> List[Dict[str, Any]]:
rows: Dict[str, Dict[str, Any]] = {}
for source in ("main_pick", "value_pick"):
item = package.get(source)
if isinstance(item, dict) and item.get("market"):
rows[self._row_key(item)] = dict(item)
for source in ("supporting_picks", "bet_summary"):
for item in package.get(source) or []:
if isinstance(item, dict) and item.get("market"):
key = self._row_key(item)
rows[key] = self._merge_row(rows.get(key), item)
return list(rows.values())
@staticmethod
def _merge_row(existing: Optional[Dict[str, Any]], incoming: Dict[str, Any]) -> Dict[str, Any]:
if existing is None:
return dict(incoming)
merged = dict(incoming)
merged.update({k: v for k, v in existing.items() if v is not None})
for key in ("decision_reasons", "reasons"):
reasons = list(existing.get(key) or []) + list(incoming.get(key) or [])
if reasons:
merged[key] = list(dict.fromkeys(reasons))
return merged
def _pick_value_candidate(self, rows: Dict[str, Dict[str, Any]], main_key: str) -> Optional[Dict[str, Any]]:
candidates = [
row for key, row in rows.items()
if key != main_key
and row.get("betting_brain", {}).get("action") in {"BET", "WATCH"}
and (self._safe_float(row.get("odds"), 0.0) or 0.0) >= 1.60
]
candidates.sort(key=self._candidate_sort_key, reverse=True)
return dict(candidates[0]) if candidates else None
def _summary_item(self, row: Dict[str, Any]) -> Dict[str, Any]:
reasons = list(row.get("decision_reasons") or row.get("reasons") or [])
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) or 0.0),
"play_score": row.get("play_score", 0.0),
"ev_edge": row.get("ev_edge", row.get("edge", 0.0)),
"implied_prob": row.get("implied_prob", 0.0),
"odds_reliability": row.get("odds_reliability", 0.35),
"odds": row.get("odds", 0.0),
"reasons": reasons[:6],
"betting_brain": row.get("betting_brain"),
}
@staticmethod
def _candidate_sort_key(row: Dict[str, Any]) -> Tuple[float, float, float]:
brain = row.get("betting_brain") or {}
action_boost = {"BET": 2.0, "WATCH": 1.0, "REJECT": 0.0}.get(str(brain.get("action")), 0.0)
return (
action_boost,
float(brain.get("score", 0.0) or 0.0),
float(row.get("play_score", 0.0) or 0.0),
)
@staticmethod
def _row_key(row: Optional[Dict[str, Any]]) -> str:
if not isinstance(row, dict):
return ""
return f"{row.get('market')}:{row.get('pick')}"
def _force_no_bet(self, row: Dict[str, Any], reason: str) -> None:
row["playable"] = False
row["stake_units"] = 0.0
row["bet_grade"] = "PASS"
row["is_guaranteed"] = False
row["pick_reason"] = reason
if row.get("signal_tier") == "CORE":
row["signal_tier"] = "PASS"
self._append_reason(row, reason)
@staticmethod
def _append_reason(row: Dict[str, Any], reason: str) -> None:
key = "decision_reasons" if "decision_reasons" in row else "reasons"
reasons = list(row.get(key) or [])
if reason not in reasons:
reasons.append(reason)
row[key] = reasons[:6]
def _brain_stake(self, row: Dict[str, Any], score: float) -> float:
existing = self._safe_float(row.get("stake_units"), 0.0) or 0.0
odds = self._safe_float(row.get("odds"), 0.0) or 0.0
if odds <= 1.0:
return 0.0
cap = 2.0 if score >= 82.0 else 1.2
if score < 78.0:
cap = 0.8
return round(max(0.25, min(existing if existing > 0 else cap, cap)), 1)
@staticmethod
def _decision_band(main_pick: Optional[Dict[str, Any]]) -> str:
if not main_pick:
return "LOW"
score = float((main_pick.get("betting_brain") or {}).get("score", 0.0) or 0.0)
if score >= 82.0:
return "HIGH"
if score >= 72.0:
return "MEDIUM"
return "LOW"
@staticmethod
def _top_decisions(decisions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
ordered = sorted(decisions, key=lambda d: float(d.get("score", 0.0) or 0.0), reverse=True)
return [
{
"action": item.get("action"),
"score": item.get("score"),
"summary": item.get("summary"),
"vetoes": item.get("vetoes", []),
"issues": item.get("issues", []),
}
for item in ordered[:5]
]
@staticmethod
def _summary(action: str, market: str, pick: str, positives: List[str], issues: List[str], vetoes: List[str]) -> str:
if action == "BET":
return f"{market} {pick} approved: evidence is aligned enough for a controlled stake."
if action == "WATCH":
return f"{market} {pick} is interesting but not clean enough for stake."
if vetoes:
return f"{market} {pick} rejected: {', '.join(vetoes[:3])}."
if issues:
return f"{market} {pick} rejected: {', '.join(issues[:3])}."
return f"{market} {pick} rejected by evidence score."
def _market_probability(self, row: Dict[str, Any], package: Dict[str, Any]) -> Optional[float]:
direct = self._safe_float(row.get("probability"))
if direct is not None:
return direct
board = package.get("market_board") or {}
payload = board.get(str(row.get("market") or "")) if isinstance(board, dict) else None
probs = payload.get("probs") if isinstance(payload, dict) else None
if not isinstance(probs, dict):
return None
key = self._prob_key(str(row.get("market") or ""), str(row.get("pick") or ""))
return self._safe_float(probs.get(key)) if key else None
def _v27_probability(self, market: str, pick: str, v27_engine: Dict[str, Any]) -> Optional[float]:
predictions = v27_engine.get("predictions") or {}
ms = predictions.get("ms") or {}
ou25 = predictions.get("ou25") or {}
if market == "MS":
return self._safe_float(ms.get({"1": "home", "X": "draw", "2": "away"}.get(pick, "")))
if market == "DC":
home = self._safe_float(ms.get("home"), 0.0) or 0.0
draw = self._safe_float(ms.get("draw"), 0.0) or 0.0
away = self._safe_float(ms.get("away"), 0.0) or 0.0
return {"1X": home + draw, "X2": draw + away, "12": home + away}.get(pick)
if market == "OU25":
key = self._prob_key(market, pick)
return self._safe_float(ou25.get(key)) if key else None
return None
def _triple_value(self, package: Dict[str, Any], key: Optional[str]) -> Optional[Dict[str, Any]]:
if not key:
return None
value = ((package.get("v27_engine") or {}).get("triple_value") or {}).get(key)
return value if isinstance(value, dict) else None
def _triple_key(self, market: str, pick: str) -> Optional[str]:
prob_key = self._prob_key(market, pick)
if market == "MS":
return {"1": "home", "2": "away"}.get(pick)
if market == "DC" and pick.upper() in {"1X", "X2", "12"}:
return f"dc_{pick.lower()}"
if market in {"OU15", "OU25", "OU35"} and prob_key == "over":
return f"{market.lower()}_over"
if market == "BTTS" and prob_key == "yes":
return "btts_yes"
if market == "HT":
return {"1": "ht_home", "2": "ht_away"}.get(pick)
if market in {"HT_OU05", "HT_OU15"} and prob_key == "over":
return f"{market.lower()}_over"
if market == "OE" and prob_key == "odd":
return "oe_odd"
if market == "CARDS" and prob_key == "over":
return "cards_over"
if market == "HTFT" and "/" in pick:
return f"htft_{pick.replace('/', '').lower()}"
return None
@staticmethod
def _prob_key(market: str, pick: str) -> Optional[str]:
norm = str(pick or "").strip().casefold()
if market in {"MS", "HT", "HCAP"}:
return pick if pick in {"1", "X", "2"} else None
if market == "DC":
return pick.upper() if pick.upper() in {"1X", "X2", "12"} else None
if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}:
if "over" in norm or "ust" in norm or "üst" in norm:
return "over"
if "under" in norm or "alt" in norm:
return "under"
if market == "BTTS":
if "yes" in norm or "var" in norm:
return "yes"
if "no" in norm or "yok" in norm:
return "no"
if market == "OE":
if "odd" in norm or "tek" in norm:
return "odd"
if "even" in norm or "cift" in norm or "çift" in norm:
return "even"
if market == "HTFT" and "/" in pick:
return pick
return None
@staticmethod
def _safe_float(value: Any, default: Optional[float] = None) -> Optional[float]:
try:
return float(value)
except (TypeError, ValueError):
return default
+715 -65
View File
@@ -30,12 +30,18 @@ from models.v20_ensemble import FullMatchPrediction
from models.v25_ensemble import V25Predictor, get_v25_predictor
from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge
from features.odds_band_analyzer import OddsBandAnalyzer
from models.basketball_v25 import (
BasketballMatchPrediction,
get_basketball_v25_predictor,
)
try:
from models.basketball_v25 import (
BasketballMatchPrediction,
get_basketball_v25_predictor,
)
except ImportError:
BasketballMatchPrediction = Any
def get_basketball_v25_predictor():
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 utils.top_leagues import load_top_league_ids
from utils.league_reliability import load_league_reliability
@@ -69,6 +75,7 @@ class MatchData:
substate: Optional[str] = None
current_score_home: Optional[int] = None
current_score_away: Optional[int] = None
lineup_confidence: float = 0.0
class SingleMatchOrchestrator:
@@ -144,7 +151,7 @@ class SingleMatchOrchestrator:
self.v26_shadow_engine: Optional[V26ShadowEngine] = None
self.basketball_predictor: Optional[Any] = None
self.dsn = get_clean_dsn()
self.engine_mode = str(os.getenv("AI_ENGINE_MODE", "v25")).strip().lower()
self.engine_mode = str(os.getenv("AI_ENGINE_MODE", "v28-pro-max")).strip().lower()
self.top_league_ids = load_top_league_ids()
self.league_reliability = load_league_reliability()
self.enrichment = FeatureEnrichmentService()
@@ -527,12 +534,18 @@ class SingleMatchOrchestrator:
}
def _get_squad_features(self, data: MatchData) -> Dict[str, float]:
"""Non-fatal squad analysis. Returns zero-defaults on failure."""
"""Non-fatal squad analysis. Returns neutral-average defaults on failure.
Design note (V32-fix): Previous 0.0 defaults caused the model to treat
missing lineups as 'both teams have zero quality', producing overly
conservative predictions (e.g. static 1.5 Under). Neutral averages let
the model fall back on stronger signals (odds, ELO, form, H2H).
"""
defaults = {
'home_squad_quality': 0.0, 'away_squad_quality': 0.0, 'squad_diff': 0.0,
'home_key_players': 0.0, 'away_key_players': 0.0,
'home_squad_quality': 0.50, 'away_squad_quality': 0.50, 'squad_diff': 0.0,
'home_key_players': 3.0, 'away_key_players': 3.0,
'home_missing_impact': 0.0, 'away_missing_impact': 0.0,
'home_goals_form': 0.0, 'away_goals_form': 0.0,
'home_goals_form': 1.3, 'away_goals_form': 1.3,
}
try:
engine = get_player_predictor()
@@ -559,27 +572,186 @@ class SingleMatchOrchestrator:
print(f"⚠️ Squad features failed: {e}")
return defaults
# ── V25 internal key → _build_v25_prediction key mapping ──
_V25_KEY_MAP = {
"ms": "MS",
"ou15": "OU15",
"ou25": "OU25",
"ou35": "OU35",
"btts": "BTTS",
"ht_result": "HT",
"ht_ou05": "HT_OU05",
"ht_ou15": "HT_OU15",
"htft": "HTFT",
"cards_ou45": "CARDS",
"handicap_ms": "HCAP",
"odd_even": "OE",
}
def _get_v25_signal(
self,
data: MatchData,
features: Optional[Dict[str, float]] = None,
) -> Dict[str, Any]:
"""
Get V25 ensemble predictions for all available markets.
Returns a dict keyed by UPPERCASE market name (MS, OU25, BTTS, etc.)
each with a 'probs' sub-dict that _prob_map can consume.
CRITICAL: Keys MUST be uppercase to match _build_v25_prediction lookups.
"""
v25 = self._get_v25_predictor()
feature_row = features or self._build_v25_features(data)
return v25.predict_market_bundle(
features=feature_row,
odds=self._sanitize_v25_odds(data.odds_data or {}),
)
signal: Dict[str, Any] = {}
def _temperature_scale(probs_dict: Dict[str, float], temperature: float = 2.5) -> Dict[str, float]:
"""
Apply temperature scaling to soften overconfident model outputs.
LightGBM often produces extreme probabilities (e.g., 0.999 / 0.001).
Temperature scaling converts to log-odds, divides by T, then re-normalizes.
T=1.0 → no change, T>1 → softer probabilities.
Standard approach for post-hoc model calibration (Guo et al., 2017).
"""
import math
eps = 1e-7 # numerical stability
n = len(probs_dict)
# Determine appropriate temperature based on market type
# Binary markets (2-class) tend to be more overconfident in LGB
if n <= 2:
T = max(temperature, 2.0)
elif n == 3:
T = max(temperature * 0.8, 1.5) # 3-way slightly less aggressive
else:
T = max(temperature * 0.6, 1.3) # 9-way (HTFT) already spread
# Convert to log-odds and apply temperature
labels = list(probs_dict.keys())
log_odds = []
for label in labels:
p = max(eps, min(1.0 - eps, float(probs_dict[label])))
log_odds.append(math.log(p) / T)
# Softmax re-normalization
max_lo = max(log_odds)
exp_vals = [math.exp(lo - max_lo) for lo in log_odds]
total = sum(exp_vals)
scaled = {}
for i, label in enumerate(labels):
scaled[label] = exp_vals[i] / total
return scaled
def _enrich_signal_entry(probs_dict: Dict[str, float]) -> Dict[str, Any]:
"""Add pick, probability, confidence to a signal entry from its probs.
Applies temperature scaling to convert overconfident LightGBM outputs
into realistic, calibrated probabilities.
"""
# Apply temperature scaling to soften extreme probabilities
scaled_probs = _temperature_scale(probs_dict, temperature=2.5)
best_label = max(scaled_probs, key=scaled_probs.get)
best_prob = float(scaled_probs[best_label])
return {
"probs": scaled_probs,
"raw_probs": probs_dict, # keep originals for debugging
"pick": best_label,
"probability": best_prob,
"confidence": round(best_prob * 100.0, 1),
}
# Core markets using dedicated methods
h, d, a = v25.predict_ms(feature_row)
signal["MS"] = _enrich_signal_entry({"1": h, "X": d, "2": a})
print(f" [V25-SIGNAL] MS → H={h:.4f} D={d:.4f} A={a:.4f}")
over25, under25 = v25.predict_ou25(feature_row)
signal["OU25"] = _enrich_signal_entry({"Over": over25, "Under": under25})
print(f" [V25-SIGNAL] OU25 → O={over25:.4f} U={under25:.4f}")
btts_y, btts_n = v25.predict_btts(feature_row)
signal["BTTS"] = _enrich_signal_entry({"Yes": btts_y, "No": btts_n})
print(f" [V25-SIGNAL] BTTS → Y={btts_y:.4f} N={btts_n:.4f}")
# Additional markets via generic predict_market
for model_key, label_map in [
("ou15", {"Over": 0, "Under": None}),
("ou35", {"Over": 0, "Under": None}),
("ht_result", {"1": 0, "X": 1, "2": 2}),
("ht_ou05", {"Over": 0, "Under": None}),
("ht_ou15", {"Over": 0, "Under": None}),
("htft", None),
("cards_ou45", {"Over": 0, "Under": None}),
("handicap_ms", {"1": 0, "X": 1, "2": 2}),
("odd_even", {"Odd": 0, "Even": None}),
]:
out_key = self._V25_KEY_MAP.get(model_key, model_key.upper())
if not v25.has_market(model_key):
continue
raw = v25.predict_market(model_key, feature_row)
if raw is None:
continue
if label_map is None:
# HTFT — 9 combinations
htft_labels = ["1/1", "1/X", "1/2", "X/1", "X/X", "X/2", "2/1", "2/X", "2/2"]
probs_dict = {}
for i, label in enumerate(htft_labels):
probs_dict[label] = float(raw[i]) if i < len(raw) else 0.0
signal[out_key] = _enrich_signal_entry(probs_dict)
elif len(label_map) == 2:
# Binary market
labels = list(label_map.keys())
p = float(raw[0]) if len(raw) >= 1 else None
if p is None:
print(f" [V25-SIGNAL] {out_key} → EMPTY raw output, skipped")
continue
signal[out_key] = _enrich_signal_entry({labels[0]: p, labels[1]: 1.0 - p})
elif len(label_map) == 3:
# 3-class market
labels = list(label_map.keys())
probs_dict = {}
for i, label in enumerate(labels):
if i >= len(raw):
print(f" [V25-SIGNAL] {out_key} → insufficient probabilities in raw output")
break
probs_dict[label] = float(raw[i])
else:
signal[out_key] = _enrich_signal_entry(probs_dict)
if out_key in signal:
print(f" [V25-SIGNAL] {out_key}{signal[out_key]['probs']}")
print(f" [V25-SIGNAL] Total markets with real predictions: {len(signal)}")
if not signal:
raise RuntimeError("V25 model produced ZERO market predictions — cannot continue")
return signal
@staticmethod
def _prob_map(signal: Optional[Dict[str, Any]], market: str, defaults: Dict[str, float]) -> Dict[str, float]:
"""Extract normalised probabilities from signal.
If the signal contains real model output for this market, use it.
If the market is missing from the signal, log a warning and return
the defaults as a LAST RESORT (so the pipeline doesn't crash).
The defaults are ONLY used for non-core / secondary markets that
may not have a trained model yet (e.g. CARDS, HCAP, OE).
"""
market_payload = signal.get(market, {}) if isinstance(signal, dict) else {}
probs = market_payload.get("probs", {}) if isinstance(market_payload, dict) else {}
if not isinstance(probs, dict) or not probs:
print(f" ⚠️ [PROB_MAP] Market '{market}' NOT found in V25 signal — model output missing")
return dict(defaults)
out = {key: float(probs.get(key, value)) for key, value in defaults.items()}
total = sum(out.values())
if total <= 0:
print(f" ⚠️ [PROB_MAP] Market '{market}' has zero total probability")
return dict(defaults)
return {key: value / total for key, value in out.items()}
@@ -730,7 +902,8 @@ class SingleMatchOrchestrator:
prediction.cards_confidence,
prediction.handicap_confidence,
)
lineup_penalty = 12.0 if data.lineup_source == "none" else 7.0 if data.lineup_source == "probable_xi" else 0.0
lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0)))
lineup_penalty = 12.0 if data.lineup_source == "none" else max(1.5, (1.0 - lineup_conf) * 8.0) if data.lineup_source == "probable_xi" else 0.0
referee_penalty = 6.0 if not data.referee_name else 0.0
parity_penalty = 8.0 if abs(ms_edge) < 0.08 else 0.0
prediction.risk_score = round(min(100.0, max(10.0, 100.0 - max_market_conf + lineup_penalty + referee_penalty + parity_penalty)), 1)
@@ -747,6 +920,8 @@ class SingleMatchOrchestrator:
prediction.risk_warnings = []
if data.lineup_source == "probable_xi":
prediction.risk_warnings.append("lineup_probable_not_confirmed")
if lineup_conf < 0.65:
prediction.risk_warnings.append("lineup_projection_low_confidence")
if data.lineup_source == "none":
prediction.risk_warnings.append("lineup_unavailable")
if not data.referee_name:
@@ -1142,7 +1317,9 @@ class SingleMatchOrchestrator:
if band_val.get("is_value"):
boost = min(8.0, boost + 3.0) # Triple confirmation extra boost
prediction.ms_confidence = min(95.0, prediction.ms_confidence + boost)
base_package["prediction"]["ms_confidence"] = prediction.ms_confidence
market_board = base_package.get("market_board")
if isinstance(market_board, dict) and isinstance(market_board.get("MS"), dict):
market_board["MS"]["confidence"] = round(float(prediction.ms_confidence), 1)
base_package["v27_engine"]["consensus"] = "AGREE"
else:
base_package["v27_engine"]["consensus"] = "DISAGREE"
@@ -1157,8 +1334,10 @@ class SingleMatchOrchestrator:
base_package.setdefault("analysis_details", {})
base_package["analysis_details"]["v27_loaded"] = False
mode = str(getattr(self, "engine_mode", "v25") or "v25").lower()
if mode not in {"v25", "v26", "dual"}:
base_package = self._apply_upper_brain_guards(base_package)
mode = str(getattr(self, "engine_mode", "v28-pro-max") or "v28-pro-max").lower()
if mode not in {"v25", "v26", "dual", "v28", "v28-pro-max"}:
mode = "v25"
quality = base_package.get("data_quality", self._compute_data_quality(data))
@@ -1185,6 +1364,304 @@ class SingleMatchOrchestrator:
return merged
return base_package
def _apply_upper_brain_guards(self, package: Dict[str, Any]) -> Dict[str, Any]:
return BettingBrain().judge(package)
v27_engine = package.get("v27_engine")
if not isinstance(v27_engine, dict) or not v27_engine.get("triple_value"):
return package
guarded = dict(package)
vetoed_keys = set()
guarded_keys = set()
def mark_guard(item: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(item, dict):
return item
out = dict(item)
assessment = self._upper_brain_assessment(out, guarded)
if not assessment.get("applies"):
return out
key = f"{out.get('market')}:{out.get('pick')}"
guarded_keys.add(key)
out["upper_brain"] = assessment
reason_key = "decision_reasons" if "decision_reasons" in out else "reasons"
reasons = list(out.get(reason_key) or [])
for reason in assessment.get("reason_codes", []):
if reason not in reasons:
reasons.append(reason)
out[reason_key] = reasons[:6]
if assessment.get("veto"):
vetoed_keys.add(key)
out["playable"] = False
out["stake_units"] = 0.0
out["bet_grade"] = "PASS"
out["is_guaranteed"] = False
out["pick_reason"] = "upper_brain_veto"
if "signal_tier" in out:
out["signal_tier"] = "PASS"
elif assessment.get("downgrade"):
out["is_guaranteed"] = False
if out.get("signal_tier") == "CORE":
out["signal_tier"] = "LEAN"
if out.get("pick_reason") == "high_accuracy_market":
out["pick_reason"] = "upper_brain_downgraded"
return out
main_pick = mark_guard(guarded.get("main_pick") or {})
value_pick = mark_guard(guarded.get("value_pick") or {}) if guarded.get("value_pick") else None
supporting = [
mark_guard(row)
for row in list(guarded.get("supporting_picks") or [])
if isinstance(row, dict)
]
bet_summary = [
mark_guard(row)
for row in list(guarded.get("bet_summary") or [])
if isinstance(row, dict)
]
main_safe = bool(main_pick and main_pick.get("playable") and not main_pick.get("upper_brain", {}).get("veto"))
if not main_safe:
candidates = [
row for row in supporting
if row.get("playable")
and not row.get("upper_brain", {}).get("veto")
and float(row.get("odds", 0.0) or 0.0) >= 1.30
]
candidates.sort(key=lambda row: float(row.get("play_score", 0.0) or 0.0), reverse=True)
if candidates:
main_pick = dict(candidates[0])
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "upper_brain_reselected"
reasons = list(main_pick.get("decision_reasons") or [])
if "upper_brain_reselected_after_veto" not in reasons:
reasons.append("upper_brain_reselected_after_veto")
main_pick["decision_reasons"] = reasons[:6]
elif main_pick:
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "upper_brain_no_safe_pick"
if main_pick:
supporting = [
row for row in supporting
if not (
row.get("market") == main_pick.get("market")
and row.get("pick") == main_pick.get("pick")
)
][:6]
guarded["main_pick"] = main_pick if main_pick else None
guarded["value_pick"] = value_pick
guarded["supporting_picks"] = supporting
guarded["bet_summary"] = bet_summary
playable = bool(main_pick and main_pick.get("playable") and not main_pick.get("upper_brain", {}).get("veto"))
advice = dict(guarded.get("bet_advice") or {})
advice["playable"] = playable
advice["suggested_stake_units"] = float(main_pick.get("stake_units", 0.0)) if playable else 0.0
if playable:
advice["reason"] = "playable_pick_found"
elif vetoed_keys:
advice["reason"] = "upper_brain_no_safe_pick"
else:
advice["reason"] = "no_bet_conditions_met"
guarded["bet_advice"] = advice
guarded["upper_brain"] = {
"applied": True,
"guarded_count": len(guarded_keys),
"vetoed_count": len(vetoed_keys),
"vetoed": sorted(vetoed_keys)[:8],
"rules": {
"min_band_sample": 8,
"max_v25_v27_divergence": 0.18,
"dc_requires_triple_value": True,
},
}
guarded.setdefault("analysis_details", {})
guarded["analysis_details"]["upper_brain_guards_applied"] = True
guarded["analysis_details"]["upper_brain_vetoed_count"] = len(vetoed_keys)
return guarded
def _upper_brain_assessment(
self,
item: Dict[str, Any],
package: Dict[str, Any],
) -> Dict[str, Any]:
market = str(item.get("market") or "")
pick = str(item.get("pick") or "")
if not market or not pick:
return {"applies": False}
v27_engine = package.get("v27_engine") or {}
triple_value = v27_engine.get("triple_value") or {}
model_prob = self._upper_brain_market_probability(item, package)
v27_prob = self._upper_brain_v27_probability(market, pick, v27_engine)
triple_key = self._upper_brain_triple_key(market, pick)
triple = triple_value.get(triple_key) if triple_key else None
veto = False
downgrade = False
reasons: List[str] = []
divergence = None
if model_prob is not None and v27_prob is not None:
divergence = abs(float(model_prob) - float(v27_prob))
if divergence >= 0.18:
veto = True
reasons.append("upper_brain_v25_v27_divergence")
elif divergence >= 0.12:
downgrade = True
reasons.append("upper_brain_v25_v27_warning")
if isinstance(triple, dict):
band_sample = int(float(triple.get("band_sample", 0) or 0))
is_value = bool(triple.get("is_value"))
if market == "DC":
if band_sample < 8:
veto = True
reasons.append("upper_brain_band_sample_too_low")
elif not is_value:
veto = True
reasons.append("upper_brain_triple_value_rejected")
elif market in {"MS", "OU25"} and band_sample > 0 and band_sample < 8:
downgrade = True
reasons.append("upper_brain_band_sample_thin")
elif market in {"OU15", "HT_OU05"} and band_sample < 8:
downgrade = True
reasons.append("upper_brain_band_sample_thin")
consensus = str(v27_engine.get("consensus") or "").upper()
if consensus == "DISAGREE" and market in {"MS", "DC"} and not veto:
downgrade = True
reasons.append("upper_brain_consensus_disagree")
applies = bool(reasons or triple is not None or v27_prob is not None)
return {
"applies": applies,
"veto": veto,
"downgrade": downgrade,
"reason_codes": reasons,
"model_prob": round(float(model_prob), 4) if model_prob is not None else None,
"v27_prob": round(float(v27_prob), 4) if v27_prob is not None else None,
"divergence": round(float(divergence), 4) if divergence is not None else None,
"triple_key": triple_key,
"triple_value": triple,
}
def _upper_brain_market_probability(
self,
item: Dict[str, Any],
package: Dict[str, Any],
) -> Optional[float]:
raw_prob = item.get("probability")
if raw_prob is not None:
try:
return float(raw_prob)
except (TypeError, ValueError):
pass
market = str(item.get("market") or "")
pick = str(item.get("pick") or "")
board = package.get("market_board") or {}
payload = board.get(market) if isinstance(board, dict) else None
probs = payload.get("probs") if isinstance(payload, dict) else None
if not isinstance(probs, dict):
return None
prob_key = self._upper_brain_prob_key(market, pick)
if prob_key is None:
return None
try:
return float(probs.get(prob_key))
except (TypeError, ValueError):
return None
def _upper_brain_v27_probability(
self,
market: str,
pick: str,
v27_engine: Dict[str, Any],
) -> Optional[float]:
predictions = v27_engine.get("predictions") or {}
ms = predictions.get("ms") or {}
ou25 = predictions.get("ou25") or {}
if market == "MS":
return self._safe_float(ms.get({"1": "home", "X": "draw", "2": "away"}.get(pick, "")))
if market == "DC":
if pick == "1X":
return self._safe_float(ms.get("home"), 0.0) + self._safe_float(ms.get("draw"), 0.0)
if pick == "X2":
return self._safe_float(ms.get("draw"), 0.0) + self._safe_float(ms.get("away"), 0.0)
if pick == "12":
return self._safe_float(ms.get("home"), 0.0) + self._safe_float(ms.get("away"), 0.0)
if market == "OU25":
prob_key = self._upper_brain_prob_key(market, pick)
return self._safe_float(ou25.get(prob_key)) if prob_key else None
return None
@staticmethod
def _upper_brain_prob_key(market: str, pick: str) -> Optional[str]:
pick_norm = str(pick or "").strip().casefold()
if market in {"MS", "HT", "HCAP"}:
return pick if pick in {"1", "X", "2"} else None
if market == "DC":
return pick.upper() if pick.upper() in {"1X", "X2", "12"} else None
if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}:
if "over" in pick_norm or "st" in pick_norm:
return "over"
if "under" in pick_norm or "alt" in pick_norm:
return "under"
if market == "BTTS":
if "yes" in pick_norm or "var" in pick_norm:
return "yes"
if "no" in pick_norm or "yok" in pick_norm:
return "no"
if market == "OE":
if "odd" in pick_norm or "tek" in pick_norm:
return "odd"
if "even" in pick_norm or "ift" in pick_norm:
return "even"
if market == "HTFT" and "/" in pick:
return pick
return None
def _upper_brain_triple_key(self, market: str, pick: str) -> Optional[str]:
prob_key = self._upper_brain_prob_key(market, pick)
if market == "MS":
return {"1": "home", "2": "away"}.get(pick)
if market == "DC":
return f"dc_{pick.lower()}" if pick.upper() in {"1X", "X2", "12"} else None
if market in {"OU15", "OU25", "OU35"} and prob_key == "over":
return f"{market.lower()}_over"
if market == "BTTS" and prob_key == "yes":
return "btts_yes"
if market == "HT":
return {"1": "ht_home", "2": "ht_away"}.get(pick)
if market in {"HT_OU05", "HT_OU15"} and prob_key == "over":
return f"{market.lower()}_over"
if market == "OE" and prob_key == "odd":
return "oe_odd"
if market == "CARDS" and prob_key == "over":
return "cards_over"
if market == "HTFT" and "/" in pick:
return f"htft_{pick.replace('/', '').lower()}"
return None
@staticmethod
def _safe_float(value: Any, default: Optional[float] = None) -> Optional[float]:
try:
return float(value)
except (TypeError, ValueError):
return default
def analyze_match_htms(self, match_id: str) -> Optional[Dict[str, Any]]:
"""
HT/MS focused response for upset-hunting workflows.
@@ -2104,7 +2581,7 @@ class SingleMatchOrchestrator:
return None
odds_data = self._extract_odds(cur, row)
home_lineup, away_lineup, lineup_source = self._extract_lineups(cur, row)
home_lineup, away_lineup, lineup_source, lineup_confidence = self._extract_lineups(cur, row)
sidelined = self._parse_json_dict(row.get("sidelined"))
match_date_ms = int(row.get("match_date_ms") or 0)
league_id = str(row.get("league_id")) if row.get("league_id") else None
@@ -2159,6 +2636,7 @@ class SingleMatchOrchestrator:
status=str(row.get("status") or ""),
state=row.get("state"),
substate=row.get("substate"),
lineup_confidence=lineup_confidence,
current_score_home=(
int(row.get("score_home"))
if row.get("score_home") is not None
@@ -2291,48 +2769,78 @@ class SingleMatchOrchestrator:
self,
cur: RealDictCursor,
row: Dict[str, Any],
) -> Tuple[Optional[List[str]], Optional[List[str]], str]:
) -> Tuple[Optional[List[str]], Optional[List[str]], str, float]:
live_lineups = row.get("lineups")
home, away = self._parse_lineups_json(live_lineups)
if (home and len(home) >= 9) and (away and len(away) >= 9):
return home, away, "confirmed_live"
# fallback 1: current match participation table
cur.execute(
"""
SELECT team_id, player_id
FROM match_player_participation
WHERE match_id = %s
AND is_starting = true
""",
(row["match_id"],),
status_upper = str(row.get("status") or "").upper()
state_upper = str(row.get("state") or "").upper()
substate_upper = str(row.get("substate") or "").upper()
can_trust_feed_lineups = (
status_upper in {"LIVE", "1H", "2H", "HT", "FT", "FINISHED"}
or state_upper in {"LIVE", "FIRSTHALF", "SECONDHALF", "POSTGAME", "POST_GAME"}
or substate_upper in {"LIVE", "FIRSTHALF", "SECONDHALF"}
)
home, away = self._parse_lineups_json(live_lineups) if can_trust_feed_lineups else (None, None)
if (home and len(home) >= 9) and (away and len(away) >= 9):
return home, away, "confirmed_live", 1.0
home_id = str(row["home_team_id"])
away_id = str(row["away_team_id"])
rows = cur.fetchall()
if rows:
home_players = [str(r["player_id"]) for r in rows if str(r["team_id"]) == home_id]
away_players = [str(r["player_id"]) for r in rows if str(r["team_id"]) == away_id]
if not home and home_players:
home = home_players
if not away and away_players:
away = away_players
if (home and len(home) >= 9) and (away and len(away) >= 9):
return home, away, "confirmed_participation"
# fallback 1: current match participation table.
# Trust this only for live/finished matches; pre-match rows can be stale feed snapshots.
if can_trust_feed_lineups:
cur.execute(
"""
SELECT team_id, player_id
FROM match_player_participation
WHERE match_id = %s
AND is_starting = true
""",
(row["match_id"],),
)
rows = cur.fetchall()
if rows:
home_players = [str(r["player_id"]) for r in rows if str(r["team_id"]) == home_id]
away_players = [str(r["player_id"]) for r in rows if str(r["team_id"]) == away_id]
if not home and home_players:
home = home_players
if not away and away_players:
away = away_players
if (home and len(home) >= 9) and (away and len(away) >= 9):
return home, away, "confirmed_participation", 0.98
# fallback 2: probable XI from historical starts before match date
before_date_ms = int(row.get("match_date_ms") or 0)
sidelined = self._parse_json_dict(row.get("sidelined")) or {}
home_excluded = self._sidelined_player_ids(sidelined.get("homeTeam"))
away_excluded = self._sidelined_player_ids(sidelined.get("awayTeam"))
used_probable = False
if not home:
home = self._build_probable_xi(cur, home_id, before_date_ms)
home_conf = 0.0
away_conf = 0.0
if not home or len(home) < 9:
home, home_conf = self._build_probable_xi(
cur,
home_id,
before_date_ms,
excluded_player_ids=home_excluded,
)
used_probable = used_probable or bool(home)
if not away:
away = self._build_probable_xi(cur, away_id, before_date_ms)
if not away or len(away) < 9:
away, away_conf = self._build_probable_xi(
cur,
away_id,
before_date_ms,
excluded_player_ids=away_excluded,
)
used_probable = used_probable or bool(away)
if used_probable:
return home, away, "probable_xi"
return home, away, "none"
inferred_conf = min(
home_conf if home else 0.0,
away_conf if away else 0.0,
)
return home, away, "probable_xi", inferred_conf
return home, away, "none", 0.0
def _calculate_team_form(
self,
@@ -2445,35 +2953,172 @@ class SingleMatchOrchestrator:
cur: RealDictCursor,
team_id: str,
before_date_ms: int,
max_days: int = 30,
) -> Optional[List[str]]:
match_limit: int = 5,
lookback_days: int = 370,
max_staleness_days: int = 120,
excluded_player_ids: Optional[Set[str]] = None,
) -> Tuple[Optional[List[str]], float]:
if not team_id:
return None
return None, 0.0
min_date_ms = max(0, before_date_ms - (lookback_days * 24 * 60 * 60 * 1000))
min_date_ms = max(0, before_date_ms - (max_days * 24 * 60 * 60 * 1000))
cur.execute(
"""
SELECT
mpp.player_id,
COUNT(*) AS starts,
MAX(m.mst_utc) AS last_start_ms
m.id AS match_id,
m.mst_utc,
m.home_team_id,
m.away_team_id
FROM match_player_participation mpp
JOIN matches m ON m.id = mpp.match_id
WHERE mpp.team_id = %s
AND mpp.is_starting = true
AND m.status = 'FT'
AND m.mst_utc < %s
AND m.mst_utc >= %s
GROUP BY mpp.player_id
ORDER BY starts DESC, last_start_ms DESC
LIMIT 11
AND NOT EXISTS (
SELECT 1
FROM match_player_participation later_mpp
JOIN matches later_m ON later_m.id = later_mpp.match_id
WHERE later_mpp.player_id = mpp.player_id
AND later_mpp.team_id <> %s
AND later_m.mst_utc > m.mst_utc
AND later_m.mst_utc < %s
AND (
later_m.status = 'FT'
OR later_m.state = 'postGame'
OR (later_m.score_home IS NOT NULL AND later_m.score_away IS NOT NULL)
)
)
AND m.id IN (
SELECT m2.id
FROM matches m2
JOIN match_player_participation recent_mpp
ON recent_mpp.match_id = m2.id
AND recent_mpp.team_id = %s
AND recent_mpp.is_starting = true
WHERE (m2.home_team_id = %s OR m2.away_team_id = %s)
AND (
m2.status = 'FT'
OR m2.state = 'postGame'
OR (m2.score_home IS NOT NULL AND m2.score_away IS NOT NULL)
)
AND m2.mst_utc < %s
AND m2.mst_utc >= %s
GROUP BY m2.id
HAVING COUNT(recent_mpp.*) >= 9
ORDER BY MAX(m2.mst_utc) DESC
LIMIT %s
)
ORDER BY m.mst_utc DESC
""",
(team_id, before_date_ms, min_date_ms),
(
team_id,
team_id,
before_date_ms,
team_id,
team_id,
team_id,
before_date_ms,
min_date_ms,
match_limit,
),
)
rows = cur.fetchall()
if not rows:
return None
return [str(r["player_id"]) for r in rows]
return None, 0.0
latest_mst = max(int(row.get("mst_utc") or 0) for row in rows)
age_days = (before_date_ms - latest_mst) / (24 * 60 * 60 * 1000)
stale_projection = age_days > max_staleness_days
excluded = {str(pid) for pid in (excluded_player_ids or set()) if pid}
match_order: Dict[str, int] = {}
for row in rows:
match_id = str(row["match_id"])
if match_id not in match_order:
match_order[match_id] = len(match_order)
player_scores: Dict[str, Dict[str, float]] = {}
for row in rows:
player_id = str(row["player_id"])
if player_id in excluded:
continue
idx = match_order.get(str(row["match_id"]), match_limit)
recency_weight = max(1.0, float(match_limit - idx))
score = recency_weight
if idx == 0:
score += 3.0
elif idx == 1:
score += 1.5
stats = player_scores.setdefault(
player_id,
{
"score": 0.0,
"starts": 0.0,
"last_seen_rank": float(idx),
},
)
stats["score"] += score
stats["starts"] += 1.0
stats["last_seen_rank"] = min(stats["last_seen_rank"], float(idx))
if not player_scores:
return None, 0.0
ranked = sorted(
player_scores.items(),
key=lambda item: (
item[1]["score"],
item[1]["starts"],
-item[1]["last_seen_rank"],
),
reverse=True,
)
lineup = [player_id for player_id, _ in ranked[:11]]
coverage = min(1.0, len(lineup) / 11.0)
available_matches = max(1, len(match_order))
history_score = min(1.0, available_matches / float(match_limit))
core_stability = 0.0
if ranked:
stable_core = sum(1 for _, stats in ranked[:11] if stats["starts"] >= 2.0)
core_stability = stable_core / 11.0
staleness_factor = max(
0.35,
min(1.0, float(max_staleness_days) / max(age_days, 1.0)),
)
confidence = (
(coverage * 0.45) + (history_score * 0.25) + (core_stability * 0.30)
) * staleness_factor
if excluded:
confidence *= 0.92
confidence_cap = 0.58 if stale_projection else 0.88
return lineup or None, round(max(0.0, min(confidence_cap, confidence)), 3)
@staticmethod
def _sidelined_player_ids(team_data: Any) -> Set[str]:
if not isinstance(team_data, dict):
return set()
players = team_data.get("players")
if not isinstance(players, list):
return set()
ids: Set[str] = set()
for player in players:
if not isinstance(player, dict):
continue
player_id = (
player.get("playerId")
or player.get("player_id")
or player.get("id")
or player.get("personId")
)
if player_id:
ids.add(str(player_id))
return ids
def _parse_odds_json(self, odds_json: Any) -> Dict[str, float]:
odds_json = self._parse_json_dict(odds_json)
@@ -4267,7 +4912,8 @@ class SingleMatchOrchestrator:
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_penalty += 4.0
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)
# V31: edge contribution weighted by league odds reliability
base_score = calibrated_conf + (simple_edge * 100.0 * edge_multiplier)
@@ -4438,8 +5084,11 @@ class SingleMatchOrchestrator:
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_score *= 0.55
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:
@@ -4464,6 +5113,7 @@ class SingleMatchOrchestrator:
"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),
"flags": flags,
}