This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user