This commit is contained in:
2026-05-10 10:37:45 +03:00
parent 4f7090e2d9
commit c525b12dfd
32 changed files with 2374 additions and 209 deletions
+62 -112
View File
@@ -51,8 +51,10 @@ from core.engines.player_predictor import PlayerPrediction, get_player_predictor
from services.feature_enrichment import FeatureEnrichmentService
from services.betting_brain import BettingBrain
from services.v26_shadow_engine import V26ShadowEngine, get_v26_shadow_engine
from services.match_commentary import generate_match_commentary
from utils.top_leagues import load_top_league_ids
from utils.league_reliability import load_league_reliability
from config.config_loader import build_threshold_dict, get_threshold_default
@dataclass
@@ -165,99 +167,15 @@ class SingleMatchOrchestrator:
self.league_reliability = load_league_reliability()
self.enrichment = FeatureEnrichmentService()
self.odds_band_analyzer = OddsBandAnalyzer()
# ── V32 Calibration Rebalance ──────────────────────────────────
# RULE: max_reachable = 100 × calibration MUST be > min_conf + 8
# Previous values had 5 markets where this was IMPOSSIBLE:
# HT(0.42×100=42 < 45), HCAP(0.40×100=40 < 46), HTFT(0.28×100=28 < 32)
# HT_OU15(0.46×100=46 < 48), CARDS(0.45×100=45 < 48)
# These markets could NEVER become playable → all predictions were PASS.
#
# New calibration: conservative but mathematically achievable.
# Each market's calibration ensures high-confidence model outputs CAN pass.
self.market_calibration: Dict[str, float] = {
"MS": 0.62, # max=62 vs min=42 ✓ (was 0.48→max=48 vs 44 ⚠️)
"DC": 0.82, # max=82 vs min=52 ✓ (unchanged, already good)
"OU15": 0.84, # max=84 vs min=55 ✓ (unchanged, already good)
"OU25": 0.68, # max=68 vs min=48 ✓ (was 0.54→max=54 vs 52 ⚠️)
"OU35": 0.60, # max=60 vs min=48 ✓ (was 0.44→max=44 vs 54 ❌)
"BTTS": 0.65, # max=65 vs min=46 ✓ (was 0.50→max=50 vs 50 ⚠️)
"HT": 0.58, # max=58 vs min=40 ✓ (was 0.42→max=42 vs 45 ❌)
"HT_OU05": 0.68, # max=68 vs min=50 ✓ (unchanged)
"HT_OU15": 0.60, # max=60 vs min=42 ✓ (was 0.46→max=46 vs 48 ❌)
"OE": 0.62, # max=62 vs min=46 ✓ (was 0.58→max=58 vs 50 ok)
"CARDS": 0.58, # max=58 vs min=42 ✓ (was 0.45→max=45 vs 48 ❌)
"HCAP": 0.56, # max=56 vs min=40 ✓ (was 0.40→max=40 vs 46 ❌)
"HTFT": 0.45, # max=45 vs min=28 ✓ (was 0.28→max=28 vs 32 ❌)
}
# Min confidence: lowered to be achievable (max_reachable - 16 to -20)
self.market_min_conf: Dict[str, float] = {
"MS": 20.0, # was 42 — drastically lowered to allow underdog/draw value bets
"DC": 40.0, # was 52
"OU15": 45.0, # was 55
"OU25": 30.0, # was 48
"OU35": 20.0, # was 48
"BTTS": 30.0, # was 46
"HT": 20.0, # was 40
"HT_OU05": 35.0, # was 50
"HT_OU15": 25.0, # was 42
"OE": 35.0, # was 46
"CARDS": 30.0, # was 42
"HCAP": 25.0, # was 40
"HTFT": 10.0, # was 28
}
# Min play score: Significantly reduced to stop blocking value bets on underdogs
self.market_min_play_score: Dict[str, float] = {
"MS": 30.0, # was 65
"DC": 55.0, # was 58
"OU15": 55.0, # was 60
"OU25": 45.0, # was 64
"OU35": 35.0, # was 68
"BTTS": 45.0, # was 64
"HT": 30.0, # was 66
"HT_OU05": 45.0, # was 60
"HT_OU15": 35.0, # was 64
"OE": 35.0, # was 60
"CARDS": 40.0, # was 66
"HCAP": 35.0, # was 68
"HTFT": 20.0, # was 72
}
self.market_min_edge: Dict[str, float] = {
"MS": 0.02, # was 0.03 — slight relaxation
"DC": 0.01, # unchanged
"OU15": 0.01, # unchanged
"OU25": 0.02, # unchanged
"OU35": 0.03, # was 0.04
"BTTS": 0.02, # was 0.03
"HT": 0.03, # was 0.04
"HT_OU05": 0.01, # unchanged
"HT_OU15": 0.02, # was 0.03
"OE": 0.02, # unchanged
"CARDS": 0.02, # was 0.03
"HCAP": 0.03, # was 0.04
"HTFT": 0.05, # was 0.06
}
self.odds_band_min_sample: Dict[str, float] = {
"MS": 8.0,
"DC": 8.0,
"OU15": 8.0,
"OU25": 8.0,
"OU35": 8.0,
"BTTS": 8.0,
"HT": 8.0,
"HT_OU05": 8.0,
"HT_OU15": 8.0,
}
self.odds_band_min_edge: Dict[str, float] = {
"MS": 0.015,
"DC": 0.012,
"OU15": 0.012,
"OU25": 0.015,
"OU35": 0.018,
"BTTS": 0.015,
"HT": 0.018,
"HT_OU05": 0.012,
"HT_OU15": 0.015,
}
# ── Market Thresholds (loaded from config/market_thresholds.json) ──
# All values are centralized in a single JSON file for easy tuning
# without code changes. See config/market_thresholds.json for details.
self.market_calibration: Dict[str, float] = build_threshold_dict("calibration")
self.market_min_conf: Dict[str, float] = build_threshold_dict("min_conf")
self.market_min_play_score: Dict[str, float] = build_threshold_dict("min_play_score")
self.market_min_edge: Dict[str, float] = build_threshold_dict("min_edge")
self.odds_band_min_sample: Dict[str, float] = build_threshold_dict("odds_band_min_sample")
self.odds_band_min_edge: Dict[str, float] = build_threshold_dict("odds_band_min_edge")
def _get_v25_predictor(self) -> V25Predictor:
if self.v25_predictor is None:
@@ -720,7 +638,7 @@ class SingleMatchOrchestrator:
signal: Dict[str, Any] = {}
def _temperature_scale(probs_dict: Dict[str, float], temperature: float = 2.5) -> Dict[str, float]:
def _temperature_scale(probs_dict: Dict[str, float], temperature: float = 1.5) -> Dict[str, float]:
"""
Apply temperature scaling to soften overconfident model outputs.
@@ -729,19 +647,22 @@ class SingleMatchOrchestrator:
T=1.0 → no change, T>1 → softer probabilities.
Standard approach for post-hoc model calibration (Guo et al., 2017).
V34: Reduced from 2.5 to 1.5 — V25 model is already calibrated via
odds-aware training. Excessive flattening was destroying signal.
"""
import math
eps = 1e-7 # numerical stability
n = len(probs_dict)
# Determine appropriate temperature based on market type
# V34: Reduced temperature — odds-aware model is already calibrated
# Binary markets (2-class) tend to be more overconfident in LGB
if n <= 2:
T = max(temperature, 2.0)
T = max(temperature, 1.5) # was 2.0
elif n == 3:
T = max(temperature * 0.8, 1.5) # 3-way slightly less aggressive
T = max(temperature * 0.8, 1.2) # was 1.5 — 3-way slightly less aggressive
else:
T = max(temperature * 0.6, 1.3) # 9-way (HTFT) already spread
T = max(temperature * 0.6, 1.0) # was 1.3 — 9-way (HTFT) already spread
# Convert to log-odds and apply temperature
labels = list(probs_dict.keys())
@@ -767,8 +688,8 @@ class SingleMatchOrchestrator:
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)
# V34: Apply temperature scaling — reduced from 2.5 to 1.5
scaled_probs = _temperature_scale(probs_dict, temperature=1.5)
best_label = max(scaled_probs, key=scaled_probs.get)
best_prob = float(scaled_probs[best_label])
@@ -1532,6 +1453,13 @@ class SingleMatchOrchestrator:
base_package = self._apply_upper_brain_guards(base_package)
# ── Match Commentary: human-readable summary ──────────────
try:
base_package["match_commentary"] = generate_match_commentary(base_package)
except Exception as e:
print(f"[Commentary] ⚠ Generation failed (non-fatal): {e}")
base_package["match_commentary"] = None
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"
@@ -1545,6 +1473,7 @@ class SingleMatchOrchestrator:
)
if mode == "v26":
shadow_package["match_commentary"] = base_package.get("match_commentary")
return shadow_package
if mode == "dual":
merged = dict(base_package)
@@ -5239,7 +5168,9 @@ class SingleMatchOrchestrator:
reasons: List[str] = []
playable = True
is_value_sniper = ev_edge >= 0.03
# V34: Broadened value_sniper bypass — odds-aware model rarely shows 3% EV edge
# Allow high-confidence predictions OR modest positive EV to bypass secondary gates
is_value_sniper = ev_edge >= 0.008 or calibrated_conf >= 55.0
if calibrated_conf < min_conf:
if not is_value_sniper:
@@ -5261,29 +5192,48 @@ class SingleMatchOrchestrator:
# Most pre-match predictions use probable_xi — blocking kills all output
lineup_penalty += 6.0
reasons.append("lineup_probable_xi_penalty")
base_score = calibrated_conf + (simple_edge * 100.0 * edge_multiplier)
# V34: Added confidence bonus — high raw model probability gets a boost
# This prevents over-penalization when edge is near-zero but model is confident
raw_top_prob = float(row.get("probability", 0.0))
confidence_bonus = 0.0
if raw_top_prob >= 0.65:
confidence_bonus = 15.0
elif raw_top_prob >= 0.55:
confidence_bonus = 10.0
elif raw_top_prob >= 0.45:
confidence_bonus = 5.0
base_score = calibrated_conf + (simple_edge * 100.0 * edge_multiplier) + confidence_bonus
play_score = max(
0.0,
min(100.0, base_score - risk_penalty - quality_penalty - lineup_penalty),
)
if bool(band_verdict.get("required")) and not bool(band_verdict.get("aligned")):
# V34: odds_band gate — only hard-block when band data is AVAILABLE and aligned=False
# When band data is sparse (available=False), skip alignment check entirely
band_available = bool(band_verdict.get("available", False))
if band_available and bool(band_verdict.get("required")) and not bool(band_verdict.get("aligned")):
if not is_value_sniper:
playable = False
reasons.append(str(band_verdict.get("reason") or "odds_band_not_aligned"))
if bool(band_verdict.get("required")) and implied_prob > 0.0 and model_edge <= 0.0:
if not is_value_sniper:
playable = False
reasons.append(f"model_not_above_market_{model_edge:+.3f}")
# V31: negative edge threshold adapts to league reliability
# Reliable league: stricter (-0.03), unreliable: looser (-0.08)
neg_edge_threshold = -0.03 - (1.0 - odds_rel) * 0.05
elif not band_available and bool(band_verdict.get("required")):
# Sparse data — log but don't block
reasons.append("odds_band_data_sparse_skipped")
# V34: REMOVED model_not_above_market gate entirely
# V25 model is odds-informed BY DESIGN → model output ≈ market-implied probability
# Requiring model > market is mathematically impossible with this architecture
# The negative_model_edge gate below still catches truly anti-value picks
# V34: negative edge threshold relaxed — odds-aware model's edge is naturally near zero
# Reliable league: -0.08, unreliable: up to -0.15
# Only blocks truly anti-value picks (model significantly below market)
neg_edge_threshold = -0.08 - (1.0 - odds_rel) * 0.07
if odd > 1.0 and simple_edge < neg_edge_threshold:
if not is_value_sniper:
playable = False
reasons.append(f"negative_model_edge_{simple_edge:+.3f}")
# V34: Added value_sniper bypass — was missing before, causing hard blocks
if odd > 1.0 and ev_edge < min_edge:
playable = False
reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}")
if not is_value_sniper:
playable = False
reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}")
if play_score < min_play_score:
if not is_value_sniper:
playable = False