feat(ai-engine): value sniper thresholds and logic relaxed

This commit is contained in:
2026-05-06 17:44:45 +03:00
parent 5b5f83c8cf
commit 4f7090e2d9
13 changed files with 2040 additions and 382 deletions
+431 -166
View File
@@ -84,6 +84,7 @@ class MatchData:
current_score_home: Optional[int] = None
current_score_away: Optional[int] = None
lineup_confidence: float = 0.0
source_table: str = "matches"
class SingleMatchOrchestrator:
@@ -190,35 +191,35 @@ class SingleMatchOrchestrator:
}
# Min confidence: lowered to be achievable (max_reachable - 16 to -20)
self.market_min_conf: Dict[str, float] = {
"MS": 42.0, # was 443-way market, hard to get high conf
"DC": 52.0, # was 55 — double chance is easier
"OU15": 55.0, # was 58 — binary + usually high conf
"OU25": 48.0, # was 52 — core market, allow more through
"OU35": 48.0, # was 54 — lowered to let signals pass
"BTTS": 46.0, # was 50 — binary market
"HT": 40.0, # was 45 — was ❌ impossible, now achievable
"HT_OU05": 50.0, # was 54 — binary HT market
"HT_OU15": 42.0, # was 48 — was ❌ impossible, now achievable
"OE": 46.0, # was 50 — coin-flip market, lower bar
"CARDS": 42.0, # was 48 — was ❌ impossible, now achievable
"HCAP": 40.0, # was 46 — was ❌ impossible, now achievable
"HTFT": 28.0, # was 32 — was ❌ impossible, 9-way market
"MS": 20.0, # was 42drastically 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: moderate reduction to allow more C-grade bets
# Min play score: Significantly reduced to stop blocking value bets on underdogs
self.market_min_play_score: Dict[str, float] = {
"MS": 65.0, # was 72 — let more MS through for tracking
"DC": 58.0, # was 62 — DC is high accuracy
"OU15": 60.0, # was 64 — strong market per backtest
"OU25": 64.0, # was 70 — core market
"OU35": 68.0, # was 76 — riskier market
"BTTS": 64.0, # was 70 — allow more signals
"HT": 66.0, # was 74 — was never reachable anyway
"HT_OU05": 60.0, # was 64 — strong backtest market
"HT_OU15": 64.0, # was 72 — moderate
"OE": 60.0, # was 66 — low priority market
"CARDS": 66.0, # was 74 — niche market
"HCAP": 68.0, # was 76 — risky
"HTFT": 72.0, # was 82 — 9-way, very risky
"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
@@ -235,6 +236,28 @@ class SingleMatchOrchestrator:
"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,
}
def _get_v25_predictor(self) -> V25Predictor:
if self.v25_predictor is None:
@@ -362,6 +385,32 @@ class SingleMatchOrchestrator:
away_venue_elo = float(elo_row.get('away_away_elo') or away_elo)
home_form_elo_val = float(elo_row.get('home_form_elo') or home_elo)
away_form_elo_val = float(elo_row.get('away_form_elo') or away_elo)
else:
cur.execute(
"""
SELECT
team_id,
overall_elo,
home_elo,
away_elo,
form_elo
FROM team_elo_ratings
WHERE team_id IN (%s, %s)
""",
(data.home_team_id, data.away_team_id),
)
elo_rows = cur.fetchall()
by_team = {str(r.get("team_id")): r for r in elo_rows}
home_row = by_team.get(str(data.home_team_id))
away_row = by_team.get(str(data.away_team_id))
if home_row:
home_elo = float(home_row.get("overall_elo") or 1500.0)
home_venue_elo = float(home_row.get("home_elo") or home_elo)
home_form_elo_val = float(home_row.get("form_elo") or home_elo)
if away_row:
away_elo = float(away_row.get("overall_elo") or 1500.0)
away_venue_elo = float(away_row.get("away_elo") or away_elo)
away_form_elo_val = float(away_row.get("form_elo") or away_elo)
# Enrichment queries
home_stats = enr.compute_team_stats(cur, data.home_team_id, data.match_date_ms)
@@ -390,6 +439,8 @@ class SingleMatchOrchestrator:
before_ts=data.match_date_ms,
referee_name=data.referee_name,
)
setattr(data, "odds_band_features", odds_band_features)
setattr(data, "feature_source", "football_ai_features" if elo_row else "live_prematch_enrichment")
except Exception:
# Full fallback — use all defaults
home_stats = dict(enr._DEFAULT_TEAM_STATS)
@@ -409,6 +460,8 @@ class SingleMatchOrchestrator:
home_rest = 7.0
away_rest = 7.0
odds_band_features = {} # V28 fallback
setattr(data, "odds_band_features", odds_band_features)
setattr(data, "feature_source", "fallback_defaults")
odds_presence = {
'odds_ms_h_present': 1.0 if ms_h > 1.01 else 0.0,
@@ -1290,25 +1343,72 @@ class SingleMatchOrchestrator:
),
}
# BTTS triple value
btts_yes_odds = float((data.odds_data or {}).get("btts_y", 0))
# BTTS triple value — now with V27 BTTS model
btts_yes_odds = float((data.odds_data or {}).get('btts_y', 0))
btts_implied = (1.0 / btts_yes_odds) if btts_yes_odds > 1.0 else 0.50
btts_band_rate = odds_band_btts["yes_rate"]
btts_combined = btts_band_rate
btts_band_rate = odds_band_btts['yes_rate']
# V27 BTTS model prediction (if available)
v27_btts = v27_preds.get('btts')
v27_btts_yes = (v27_btts or {}).get('yes', 0) if v27_btts else 0
if v27_btts_yes > 0:
btts_combined = (v27_btts_yes + btts_band_rate) / 2.0
else:
btts_combined = btts_band_rate
btts_edge = btts_combined - btts_implied
btts_band_confirms = btts_band_rate > btts_implied
btts_v27_confirms = v27_btts_yes > btts_implied if v27_btts_yes > 0 else False
btts_conf_count = sum([btts_v27_confirms, btts_band_confirms])
triple_value["btts_yes"] = {
"band_rate": round(btts_band_rate, 4),
"implied_prob": round(btts_implied, 4),
"combined_prob": round(btts_combined, 4),
"edge": round(btts_edge, 4),
"band_sample": odds_band_btts["sample"],
"confirmations": 1 if btts_band_confirms else 0,
"is_value": (
# BTTS divergence (V25 vs V27)
v25_btts_probs = {
'no': 1.0 - prediction.btts_yes_prob,
'yes': prediction.btts_yes_prob,
}
btts_divergence = compute_divergence(v25_btts_probs, v27_btts) if v27_btts else {}
btts_odds = {
'yes': float((data.odds_data or {}).get('btts_y', 0)),
'no': float((data.odds_data or {}).get('btts_n', 0)),
}
btts_value_edge = compute_value_edge(
v25_btts_probs, v27_btts, btts_odds,
) if v27_btts else {}
# DC divergence (derived from V27 MS probs)
v27_dc = v27_preds.get('dc')
dc_divergence = {}
dc_value_edge = {}
if v27_dc:
v25_dc_probs = {
'1x': prediction.ms_home_prob + prediction.ms_draw_prob,
'x2': prediction.ms_draw_prob + prediction.ms_away_prob,
'12': prediction.ms_home_prob + prediction.ms_away_prob,
}
dc_divergence = compute_divergence(v25_dc_probs, v27_dc)
dc_odds = {
'1x': float((data.odds_data or {}).get('dc_1x', 0)),
'x2': float((data.odds_data or {}).get('dc_x2', 0)),
'12': float((data.odds_data or {}).get('dc_12', 0)),
}
dc_value_edge = compute_value_edge(v25_dc_probs, v27_dc, dc_odds)
triple_value['btts_yes'] = {
'v27_prob': round(v27_btts_yes, 4),
'band_rate': round(btts_band_rate, 4),
'implied_prob': round(btts_implied, 4),
'combined_prob': round(btts_combined, 4),
'edge': round(btts_edge, 4),
'band_sample': odds_band_btts['sample'],
'confirmations': btts_conf_count,
'is_value': (
btts_conf_count >= 2
and btts_edge > 0.05
and odds_band_btts['sample'] >= 8
) if v27_btts_yes > 0 else (
btts_band_confirms
and btts_edge > 0.05
and odds_band_btts["sample"] >= 8
and odds_band_btts['sample'] >= 8
),
}
@@ -1366,14 +1466,20 @@ class SingleMatchOrchestrator:
"predictions": {
"ms": v27_ms or {},
"ou25": v27_ou25 or {},
"btts": v27_btts or {},
"dc": v27_dc or {},
},
"divergence": {
"ms": ms_divergence,
"ou25": ou25_divergence,
"btts": btts_divergence,
"dc": dc_divergence,
},
"value_edge": {
"ms": ms_value,
"ou25": ou25_value,
"btts": btts_value_edge,
"dc": dc_value_edge,
},
"odds_band": {
"ms_home": odds_band_ms_home,
@@ -2670,6 +2776,13 @@ class SingleMatchOrchestrator:
# Hard gate: predictions with unknown teams are noisy and misleading.
return None
status, state, substate = self._normalize_match_status(
row.get("status"),
row.get("state"),
row.get("substate"),
row.get("score_home"),
row.get("score_away"),
)
odds_data = self._extract_odds(cur, row)
home_lineup, away_lineup, lineup_source, lineup_confidence = self._extract_lineups(cur, row)
sidelined = self._parse_json_dict(row.get("sidelined"))
@@ -2723,10 +2836,11 @@ class SingleMatchOrchestrator:
home_position=home_position,
away_position=away_position,
lineup_source=lineup_source,
status=str(row.get("status") or ""),
state=row.get("state"),
substate=row.get("substate"),
status=status,
state=state,
substate=substate,
lineup_confidence=lineup_confidence,
source_table=str(row.get("source_table") or "matches"),
current_score_home=(
int(row.get("score_home"))
if row.get("score_home") is not None
@@ -2760,7 +2874,8 @@ class SingleMatchOrchestrator:
lm.referee_name,
ht.name as home_team_name,
at.name as away_team_name,
l.name as league_name
l.name as league_name,
'live_matches'::text as source_table
FROM live_matches lm
LEFT JOIN teams ht ON ht.id = lm.home_team_id
LEFT JOIN teams at ON at.id = lm.away_team_id
@@ -2772,6 +2887,37 @@ class SingleMatchOrchestrator:
)
return cur.fetchone()
@staticmethod
def _normalize_match_status(
status: Any,
state: Any,
substate: Any,
score_home: Any,
score_away: Any,
) -> Tuple[str, Optional[str], Optional[str]]:
state_text = str(state or "").strip()
status_text = str(status or "").strip()
substate_text = str(substate or "").strip()
state_key = state_text.lower().replace("_", "").replace(" ", "")
status_key = status_text.lower().replace("_", "").replace(" ", "")
substate_key = substate_text.lower().replace("_", "").replace(" ", "")
live_tokens = {"live", "livegame", "firsthalf", "secondhalf", "halftime", "1h", "2h", "ht", "1q", "2q", "3q", "4q"}
finished_tokens = {"post", "postgame", "finished", "played", "ft", "ended", "aet", "pen", "penalties", "afterpenalties"}
pre_tokens = {"pre", "pregame", "scheduled", "ns", "notstarted", "timestamp"}
if state_key in live_tokens or status_key in live_tokens or substate_key in live_tokens:
return "LIVE", state_text or "live", substate_text or None
if state_key in finished_tokens or status_key in finished_tokens or substate_key in finished_tokens:
return "FT", state_text or "post", substate_text or None
if score_home is not None and score_away is not None and status_key not in pre_tokens:
return "FT", state_text or "post", substate_text or None
if state_key in pre_tokens or status_key in pre_tokens or substate_key in pre_tokens:
return "NS", state_text or "pre", substate_text or None
return status_text or "NS", state_text or None, substate_text or None
def _fetch_hist_match(self, cur: RealDictCursor, match_id: str) -> Optional[Dict[str, Any]]:
cur.execute(
"""
@@ -2793,7 +2939,8 @@ class SingleMatchOrchestrator:
ref.name as referee_name,
ht.name as home_team_name,
at.name as away_team_name,
l.name as league_name
l.name as league_name,
'matches'::text as source_table
FROM matches m
LEFT JOIN teams ht ON ht.id = m.home_team_id
LEFT JOIN teams at ON at.id = m.away_team_id
@@ -3668,66 +3815,33 @@ class SingleMatchOrchestrator:
playable_rows = [row for row in market_rows if row.get("playable")]
# GUARANTEED PICK LOGIC (V32 - Calibration-aware):
# Runtime replay insights:
# - Trust only markets that remain robust after pre-match replay.
# - Current strongest football markets: DC, OU15, HT_OU05.
#
# Priority 1: High-accuracy market (DC/OU15/HT_OU05/OU25) + Odds >= 1.30 + Conf >= 44%
# Priority 2: Any playable + Odds >= 1.30 + Conf >= 44%
# Priority 3: Playable + Odds >= 1.30
# Priority 4: Best non-playable (fallback)
MIN_ODDS = 1.30
MIN_CONFIDENCE = 44.0 # V32: lowered from 52 to match new calibration
# High-accuracy markets from backtest (prioritize these)
HIGH_ACCURACY_MARKETS = {"DC", "OU15", "HT_OU05"}
# Priority 1: High-accuracy markets with good odds and confidence
high_accuracy_picks = [
playable_with_odds = [
row for row in playable_rows
if row.get("market") in HIGH_ACCURACY_MARKETS
and float(row.get("odds", 0.0)) >= MIN_ODDS
and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
if float(row.get("odds", 0.0)) >= MIN_ODDS
]
if high_accuracy_picks:
# Sort by play_score, pick the best
high_accuracy_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = high_accuracy_picks[0]
main_pick["is_guaranteed"] = True
main_pick["pick_reason"] = "high_accuracy_market"
if playable_with_odds:
playable_with_odds.sort(
key=lambda r: (
float(r.get("ev_edge", 0.0)),
float(r.get("play_score", 0.0)),
),
reverse=True,
)
main_pick = playable_with_odds[0]
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "positive_ev_after_odds_band_gate"
else:
# Priority 2: Any playable with odds >= 1.30 and confidence >= 40%
guaranteed_picks = [
row for row in playable_rows
if float(row.get("odds", 0.0)) >= MIN_ODDS
and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
]
if guaranteed_picks:
guaranteed_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = guaranteed_picks[0]
main_pick["is_guaranteed"] = True
main_pick["pick_reason"] = "confidence_threshold_met"
else:
# Priority 3: Fallback - playable with odds >= 1.30
playable_with_odds = [
row for row in playable_rows
if float(row.get("odds", 0.0)) >= MIN_ODDS
]
if playable_with_odds:
playable_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = playable_with_odds[0]
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "odds_only_fallback"
else:
# Priority 4: Last resort - any playable or first market WITH ODDS > 0
fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
main_pick = playable_rows[0] if playable_rows else (fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None))
if main_pick:
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "last_resort"
fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
fallback_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None)
if main_pick:
main_pick["is_guaranteed"] = False
main_pick["playable"] = False
main_pick["stake_units"] = 0.0
main_pick["bet_grade"] = "PASS"
main_pick["pick_reason"] = "no_playable_value_after_odds_band_gate"
aggressive_pick = None
htft_probs = prediction.ht_ft_probs or {}
@@ -3756,11 +3870,13 @@ class SingleMatchOrchestrator:
value_candidates = [
row for row in playable_rows
if float(row.get("odds", 0.0)) >= 1.60
and float(row.get("calibrated_confidence", 0.0)) >= 40.0
# V34: Lowered min calibrated_confidence for value candidates from 40.0 to 25.0
# to allow high-odds value bets (which naturally have lower probabilities).
and float(row.get("calibrated_confidence", 0.0)) >= 25.0
]
if value_candidates:
# Score them by (play_score * odds) to reward higher odds
value_candidates.sort(key=lambda r: float(r.get("play_score", 0.0)) * float(r.get("odds", 1.0)), reverse=True)
# Score them by (ev_edge) to reward actual mathematical value
value_candidates.sort(key=lambda r: float(r.get("ev_edge", 0.0)), reverse=True)
for v_cand in value_candidates:
if not main_pick or (v_cand["market"] != main_pick["market"] or v_cand["pick"] != main_pick["pick"]):
value_pick = v_cand
@@ -3982,51 +4098,33 @@ class SingleMatchOrchestrator:
playable_rows = [row for row in market_rows if row.get("playable")]
# GUARANTEED PICK LOGIC (Optimized - same as football)
MIN_ODDS = 1.30
MIN_CONFIDENCE = 40.0
HIGH_ACCURACY_MARKETS = {"ML", "TOT", "SPREAD"}
high_accuracy_picks = [
playable_with_odds = [
row for row in playable_rows
if row.get("market_type") in HIGH_ACCURACY_MARKETS
and float(row.get("odds", 0.0)) >= MIN_ODDS
and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
if float(row.get("odds", 0.0)) >= MIN_ODDS
]
if high_accuracy_picks:
high_accuracy_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = high_accuracy_picks[0]
main_pick["is_guaranteed"] = True
main_pick["pick_reason"] = "high_accuracy_market"
if playable_with_odds:
playable_with_odds.sort(
key=lambda r: (
float(r.get("ev_edge", 0.0)),
float(r.get("play_score", 0.0)),
),
reverse=True,
)
main_pick = playable_with_odds[0]
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "positive_ev_pick"
else:
guaranteed_picks = [
row for row in playable_rows
if float(row.get("odds", 0.0)) >= MIN_ODDS
and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
]
if guaranteed_picks:
guaranteed_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = guaranteed_picks[0]
main_pick["is_guaranteed"] = True
main_pick["pick_reason"] = "confidence_threshold_met"
else:
playable_with_odds = [
row for row in playable_rows
if float(row.get("odds", 0.0)) >= MIN_ODDS
]
if playable_with_odds:
playable_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = playable_with_odds[0]
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "odds_only_fallback"
else:
fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
main_pick = playable_rows[0] if playable_rows else (fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None))
if main_pick:
main_pick["is_guaranteed"] = False
main_pick["pick_reason"] = "last_resort"
fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
fallback_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
main_pick = fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None)
if main_pick:
main_pick["is_guaranteed"] = False
main_pick["playable"] = False
main_pick["stake_units"] = 0.0
main_pick["bet_grade"] = "PASS"
main_pick["pick_reason"] = "no_playable_value_found"
supporting: List[Dict[str, Any]] = []
for row in market_rows:
@@ -4518,6 +4616,121 @@ class SingleMatchOrchestrator:
return True
return self._v25_market_odds(odds, market, pick) > 1.01
def _odds_band_verdict(
self,
data: MatchData,
market: str,
pick: str,
implied_prob: float,
) -> Dict[str, Any]:
features = getattr(data, "odds_band_features", {}) or {}
market_key = str(market or "").upper()
if not isinstance(features, dict) or implied_prob <= 0.0:
return {
"required": market_key in self.odds_band_min_sample,
"available": False,
"band_prob": 0.0,
"band_sample": 0.0,
"band_edge": 0.0,
"aligned": False,
"reason": "odds_band_unavailable",
}
pick_key = self._normalize_pick_token(pick)
band_prob = 0.0
sample = 0.0
if market_key == "MS":
if pick_key == "1":
band_prob = float(features.get("home_band_ms_win_rate", 0.0) or 0.0)
sample = float(features.get("home_band_ms_sample", 0.0) or 0.0)
elif pick_key == "2":
band_prob = float(features.get("away_band_ms_win_rate", 0.0) or 0.0)
sample = float(features.get("away_band_ms_sample", 0.0) or 0.0)
elif pick_key in {"X", "0"}:
home_draw = float(features.get("home_band_ms_draw_rate", 0.0) or 0.0)
away_draw = float(features.get("away_band_ms_draw_rate", 0.0) or 0.0)
band_prob = (home_draw + away_draw) / 2.0 if home_draw and away_draw else max(home_draw, away_draw)
sample = max(
float(features.get("home_band_ms_sample", 0.0) or 0.0),
float(features.get("away_band_ms_sample", 0.0) or 0.0),
)
elif market_key == "DC":
dc_key = pick_key.replace("-", "").lower()
band_prob = float(features.get(f"band_dc_{dc_key}_rate", 0.0) or 0.0)
sample = float(features.get(f"band_dc_{dc_key}_sample", 0.0) or 0.0)
elif market_key in {"OU15", "OU25", "OU35"}:
suffix = {"OU15": "ou15", "OU25": "ou25", "OU35": "ou35"}[market_key]
rate_key = "over_rate" if self._pick_is_over(pick_key) else "under_rate"
band_prob = float(features.get(f"band_{suffix}_{rate_key}", 0.0) or 0.0)
sample = float(features.get(f"band_{suffix}_sample", 0.0) or 0.0)
elif market_key == "BTTS":
is_yes = "VAR" in pick_key or "YES" in pick_key or pick_key == "Y"
band_prob = float(features.get(f"band_btts_{'yes' if is_yes else 'no'}_rate", 0.0) or 0.0)
sample = float(features.get("band_btts_sample", 0.0) or 0.0)
elif market_key == "HT":
if pick_key == "1":
band_prob = float(features.get("home_band_ht_win_rate", 0.0) or 0.0)
sample = float(features.get("home_band_ht_sample", 0.0) or 0.0)
elif pick_key == "2":
band_prob = float(features.get("away_band_ht_win_rate", 0.0) or 0.0)
sample = float(features.get("away_band_ht_sample", 0.0) or 0.0)
elif pick_key in {"X", "0"}:
home_draw = float(features.get("home_band_ht_draw_rate", 0.0) or 0.0)
away_draw = float(features.get("away_band_ht_draw_rate", 0.0) or 0.0)
band_prob = (home_draw + away_draw) / 2.0 if home_draw and away_draw else max(home_draw, away_draw)
sample = max(
float(features.get("home_band_ht_sample", 0.0) or 0.0),
float(features.get("away_band_ht_sample", 0.0) or 0.0),
)
elif market_key in {"HT_OU05", "HT_OU15"}:
suffix = "ht_ou05" if market_key == "HT_OU05" else "ht_ou15"
rate_key = "over_rate" if self._pick_is_over(pick_key) else "under_rate"
band_prob = float(features.get(f"band_{suffix}_{rate_key}", 0.0) or 0.0)
sample = float(features.get(f"band_{suffix}_sample", 0.0) or 0.0)
band_edge = band_prob - implied_prob if band_prob > 0.0 else 0.0
required_sample = float(self.odds_band_min_sample.get(market_key, 0.0))
required_edge = float(self.odds_band_min_edge.get(market_key, 0.0))
available = band_prob > 0.0 and sample >= required_sample
aligned = available and band_edge >= required_edge
reason = "odds_band_confirms_value"
if required_sample > 0.0 and sample < required_sample:
reason = "odds_band_sample_too_low"
elif band_prob <= 0.0:
reason = "odds_band_missing_probability"
elif band_edge < required_edge:
reason = f"odds_band_no_value_{band_edge:+.3f}"
return {
"required": market_key in self.odds_band_min_sample,
"available": available,
"band_prob": band_prob,
"band_sample": sample,
"band_edge": band_edge,
"aligned": aligned,
"reason": reason,
}
@staticmethod
def _normalize_pick_token(pick: str) -> str:
return (
str(pick or "")
.strip()
.upper()
.replace("İ", "I")
.replace("Ü", "U")
.replace("Ş", "S")
.replace("Ğ", "G")
.replace("Ö", "O")
.replace("Ç", "C")
)
@staticmethod
def _pick_is_over(pick_key: str) -> bool:
return "UST" in pick_key or "OVER" in pick_key
@staticmethod
def _goal_line_for_market(market: str) -> Optional[float]:
return {
@@ -4968,12 +5181,8 @@ class SingleMatchOrchestrator:
calibrated_conf = max(1.0, min(99.0, raw_conf * calibration))
min_conf = self.market_min_conf.get(market, 55.0)
# ── V2 Quant: EV Edge formula ──────────────────────────────────
# Old: edge = prob - (1/odd) ← simple probability difference
# New: edge = (prob × odd) - 1 ← Expected Value (what a quant uses)
implied_prob = (1.0 / odd) if odd > 1.0 else 0.0
ev_edge = (prob * odd) - 1.0 if odd > 1.0 else 0.0
simple_edge = prob - implied_prob if implied_prob > 0 else 0.0
band_verdict = self._odds_band_verdict(data, market, str(row.get("pick") or ""), implied_prob)
# ── V31: League-specific odds reliability ──────────────────────
# Higher reliability → trust odds-based edge more in play_score
@@ -4995,6 +5204,25 @@ class SingleMatchOrchestrator:
quality_label,
5.0,
)
# V33: Removed probability deflation. Deflating probability breaks normalization
# (probs no longer sum to 1) and mathematically guarantees negative EV edge.
# Data quality and confidence penalties are already applied to play_score.
model_calibrated_prob = prob
band_prob = float(band_verdict.get("band_prob", 0.0) or 0.0)
if bool(band_verdict.get("available")):
calibrated_probability = (
(model_calibrated_prob * 0.45)
+ (band_prob * 0.35)
+ (implied_prob * 0.20)
)
elif implied_prob > 0.0:
calibrated_probability = (model_calibrated_prob * 0.65) + (implied_prob * 0.35)
else:
calibrated_probability = model_calibrated_prob
calibrated_probability = max(0.0, min(0.99, calibrated_probability))
model_edge = model_calibrated_prob - implied_prob if implied_prob > 0 else 0.0
ev_edge = (calibrated_probability * odd) - 1.0 if odd > 1.0 else 0.0
simple_edge = calibrated_probability - implied_prob if implied_prob > 0 else 0.0
home_n = len(data.home_lineup or [])
away_n = len(data.away_lineup or [])
@@ -5005,22 +5233,18 @@ class SingleMatchOrchestrator:
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)
play_score = max(
0.0,
min(100.0, base_score - risk_penalty - quality_penalty - lineup_penalty),
)
# ── V20+ Safety gates (PRESERVED) ─────────────────────────────
min_play_score = self.market_min_play_score.get(market, 68.0)
min_edge = self.market_min_edge.get(market, 0.02)
reasons: List[str] = []
playable = True
is_value_sniper = ev_edge >= 0.03
if calibrated_conf < min_conf:
playable = False
reasons.append("below_calibrated_conf_threshold")
if not is_value_sniper:
playable = False
reasons.append("below_calibrated_conf_threshold")
if market in self.ODDS_REQUIRED_MARKETS and odd <= 1.01:
playable = False
reasons.append("market_odds_missing")
@@ -5037,18 +5261,33 @@ 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)
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")):
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
if odd > 1.0 and simple_edge < neg_edge_threshold:
playable = False
reasons.append(f"negative_model_edge_{simple_edge:+.3f}")
if not is_value_sniper:
playable = False
reasons.append(f"negative_model_edge_{simple_edge:+.3f}")
if odd > 1.0 and ev_edge < min_edge:
playable = False
reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}")
if play_score < min_play_score:
playable = False
reasons.append("insufficient_play_score")
if not is_value_sniper:
playable = False
reasons.append("insufficient_play_score")
if not reasons:
reasons.append("market_passed_all_gates")
@@ -5068,15 +5307,15 @@ class SingleMatchOrchestrator:
elif ev_edge > 0.10:
grade = "A"
# V2 Quant: Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll)
stake_units = self._kelly_stake(prob, odd)
stake_units = self._kelly_stake(calibrated_probability, odd)
reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_A")
elif ev_edge > 0.05:
grade = "B"
stake_units = self._kelly_stake(prob, odd)
stake_units = self._kelly_stake(calibrated_probability, odd)
reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_B")
elif ev_edge > 0.02:
grade = "C"
stake_units = self._kelly_stake(prob, odd)
stake_units = self._kelly_stake(calibrated_probability, odd)
reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_C")
else:
# Passes all V20+ gates but no mathematical edge over bookie
@@ -5093,8 +5332,16 @@ class SingleMatchOrchestrator:
"min_required_play_score": round(min_play_score, 1),
"min_required_edge": round(min_edge, 4),
"edge": round(ev_edge, 4),
"model_probability": round(prob, 4),
"model_edge": round(model_edge, 4),
"calibrated_probability": round(calibrated_probability, 4),
"implied_prob": round(implied_prob, 4),
"ev_edge": round(ev_edge, 4),
"is_value_sniper": is_value_sniper,
"odds_band_probability": round(float(band_verdict.get("band_prob", 0.0) or 0.0), 4),
"odds_band_sample": round(float(band_verdict.get("band_sample", 0.0) or 0.0), 1),
"odds_band_edge": round(float(band_verdict.get("band_edge", 0.0) or 0.0), 4),
"odds_band_aligned": bool(band_verdict.get("aligned")),
"odds_reliability": round(odds_rel, 4),
"play_score": round(play_score, 1),
"playable": playable,
@@ -5145,7 +5392,15 @@ class SingleMatchOrchestrator:
"stake_units": float(row.get("stake_units", 0.0)),
"play_score": row.get("play_score", 0.0),
"ev_edge": row.get("ev_edge", row.get("edge", 0.0)),
"is_value_sniper": bool(row.get("is_value_sniper")),
"model_probability": row.get("model_probability", row.get("probability", 0.0)),
"model_edge": row.get("model_edge", 0.0),
"calibrated_probability": row.get("calibrated_probability", row.get("probability", 0.0)),
"implied_prob": row.get("implied_prob", 0.0),
"odds_band_probability": row.get("odds_band_probability", 0.0),
"odds_band_sample": row.get("odds_band_sample", 0.0),
"odds_band_edge": row.get("odds_band_edge", 0.0),
"odds_band_aligned": bool(row.get("odds_band_aligned")),
"odds_reliability": row.get("odds_reliability", 0.35),
"odds": row.get("odds", 0.0),
"reasons": row.get("decision_reasons", []),
@@ -5187,6 +5442,11 @@ class SingleMatchOrchestrator:
ref_score = 1.0 if data.referee_name else 0.6
if not data.referee_name:
flags.append("missing_referee")
if data.source_table == "live_matches":
flags.append("live_match_pre_match_features")
feature_source = str(getattr(data, "feature_source", "") or "")
if feature_source == "live_prematch_enrichment":
flags.append("ai_features_inferred_from_history")
total_score = (odds_score * 0.45) + (lineup_score * 0.45) + (ref_score * 0.10)
@@ -5196,6 +5456,10 @@ class SingleMatchOrchestrator:
label = "MEDIUM"
else:
label = "LOW"
if label == "HIGH" and (
data.lineup_source == "probable_xi" or not data.referee_name
):
label = "MEDIUM"
return {
"label": label,
@@ -5204,6 +5468,7 @@ class SingleMatchOrchestrator:
"away_lineup_count": away_n,
"lineup_source": data.lineup_source,
"lineup_confidence": round(float(getattr(data, "lineup_confidence", 0.0) or 0.0), 3),
"feature_source": feature_source or "unknown",
"flags": flags,
}