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