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
+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,
}