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
+15 -7
View File
@@ -165,6 +165,11 @@ class BettingBrain:
score -= 18.0
issues.append("base_model_not_playable")
is_value_sniper = bool(row.get("is_value_sniper"))
if is_value_sniper:
score += 35.0
positives.append("value_sniper_override")
score += max(0.0, min(20.0, calibrated_conf * 0.22))
score += max(-8.0, min(16.0, ev_edge * 45.0))
score += max(0.0, min(14.0, play_score * 0.12))
@@ -178,13 +183,13 @@ class BettingBrain:
if odds < self.MIN_ODDS:
vetoes.append("odds_below_minimum")
if calibrated_conf < 38.0:
if calibrated_conf < 38.0 and not is_value_sniper:
vetoes.append("calibrated_confidence_too_low")
if play_score < 50.0:
if play_score < 50.0 and not is_value_sniper:
vetoes.append("play_score_too_low")
if divergence is not None:
if divergence >= self.HARD_DIVERGENCE:
if divergence >= self.HARD_DIVERGENCE and not is_value_sniper:
score -= 42.0
vetoes.append("v25_v27_hard_disagreement")
elif divergence >= self.SOFT_DIVERGENCE:
@@ -211,7 +216,7 @@ class BettingBrain:
else:
score -= 16.0
issues.append("historical_sample_too_low")
if market == "DC":
if market == "DC" and not is_value_sniper:
vetoes.append("dc_without_historical_sample")
elif market in {"MS", "DC", "OU25"}:
score -= 10.0
@@ -227,20 +232,21 @@ class BettingBrain:
and model_prob >= self.EXTREME_MODEL_PROB
and model_gap >= self.EXTREME_GAP
and not triple_is_value
and not is_value_sniper
):
score -= 24.0
vetoes.append("extreme_probability_without_evidence")
if market in {"HT", "HTFT", "OE"} and score < 86.0:
if market in {"HT", "HTFT", "OE"} and score < 86.0 and not is_value_sniper:
vetoes.append("volatile_market_requires_exceptional_evidence")
score = max(0.0, min(100.0, score))
action = "BET"
if vetoes:
action = "REJECT"
elif score < self.MIN_WATCH_SCORE:
elif score < self.MIN_WATCH_SCORE and not is_value_sniper:
action = "REJECT"
elif score < self.MIN_BET_SCORE:
elif score < self.MIN_BET_SCORE and not is_value_sniper:
action = "WATCH"
row["betting_brain"] = {
@@ -276,6 +282,7 @@ class BettingBrain:
for source in ("main_pick", "value_pick"):
item = package.get(source)
if isinstance(item, dict) and item.get("market"):
# print(f"DEBUG: {source} is_value_sniper: {item.get('is_value_sniper')}")
rows[self._row_key(item)] = dict(item)
for source in ("supporting_picks", "bet_summary"):
@@ -283,6 +290,7 @@ class BettingBrain:
if isinstance(item, dict) and item.get("market"):
key = self._row_key(item)
rows[key] = self._merge_row(rows.get(key), item)
return list(rows.values())
@staticmethod
+151 -24
View File
@@ -14,11 +14,40 @@ is missing or queries fail.
from __future__ import annotations
import unicodedata
from typing import Any, Dict, Optional, Tuple
from psycopg2.extras import RealDictCursor
# ─── Turkish Name Normalization ──────────────────────────────────
_TR_CHAR_MAP = str.maketrans(
'çÇğĞıİöÖşŞüÜâÂîÎûÛ',
'cCgGiIoOsSuUaAiIuU',
)
def _normalize_name(name: str) -> str:
"""
Normalize a Turkish referee name for fuzzy matching.
Strips accents, lowercases, removes extra whitespace, and maps
Turkish-specific characters to their ASCII equivalents.
"""
if not name:
return ''
# 1. Turkish-specific character mapping
normalized = name.translate(_TR_CHAR_MAP)
# 2. Unicode NFKD decomposition → strip combining marks
normalized = unicodedata.normalize('NFKD', normalized)
normalized = ''.join(
c for c in normalized if not unicodedata.combining(c)
)
# 3. Lowercase + collapse whitespace
return ' '.join(normalized.lower().split())
class FeatureEnrichmentService:
"""Stateless service — all state comes from DB via cursor."""
@@ -380,34 +409,20 @@ class FeatureEnrichmentService:
"""
Referee tendencies: home win bias, avg goals, card rates.
Matches referee by name in match_officials (role_id=1 = Orta Hakem).
Uses Turkish-aware fuzzy matching as a fallback when exact name
lookup returns zero results.
"""
if not referee_name:
return dict(self._DEFAULT_REFEREE)
try:
# Get match IDs officiated by this referee
cur.execute(
"""
SELECT
m.home_team_id,
m.score_home,
m.score_away,
m.id AS match_id
FROM match_officials mo
JOIN matches m ON m.id = mo.match_id
WHERE mo.name = %s
AND mo.role_id = 1
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.mst_utc < %s
ORDER BY m.mst_utc DESC
LIMIT %s
""",
(referee_name, before_date_ms, limit),
rows = self._query_referee_matches(cur, referee_name, before_date_ms, limit)
# Fuzzy fallback: if exact match fails, try normalized name search
if not rows:
rows = self._fuzzy_referee_lookup(
cur, referee_name, before_date_ms, limit,
)
rows = cur.fetchall()
except Exception:
return dict(self._DEFAULT_REFEREE)
if not rows:
return dict(self._DEFAULT_REFEREE)
@@ -459,6 +474,118 @@ class FeatureEnrichmentService:
'experience': total,
}
def _query_referee_matches(
self,
cur: RealDictCursor,
referee_name: str,
before_date_ms: int,
limit: int,
) -> list:
"""Exact-match referee lookup in match_officials."""
try:
cur.execute(
"""
SELECT
m.home_team_id,
m.score_home,
m.score_away,
m.id AS match_id
FROM match_officials mo
JOIN matches m ON m.id = mo.match_id
WHERE mo.name = %s
AND mo.role_id = 1
AND m.status = 'FT'
AND m.score_home IS NOT NULL
AND m.score_away IS NOT NULL
AND m.mst_utc < %s
ORDER BY m.mst_utc DESC
LIMIT %s
""",
(referee_name, before_date_ms, limit),
)
return cur.fetchall()
except Exception:
return []
def _fuzzy_referee_lookup(
self,
cur: RealDictCursor,
referee_name: str,
before_date_ms: int,
limit: int,
) -> list:
"""
Fuzzy referee lookup using Turkish name normalization.
Strategy: fetch recent distinct referee names from match_officials,
normalize both the query name and each candidate, and pick the
best match. This handles common mismatches like:
- 'Hüseyin Göçek' vs 'Huseyin Gocek'
- 'Ali Palabıyık' vs 'Ali Palabiyik'
- Extra/missing middle initials
"""
normalized_query = _normalize_name(referee_name)
if not normalized_query:
return []
try:
# Fetch candidate referee names (distinct, recent, role=1)
cur.execute(
"""
SELECT DISTINCT mo.name
FROM match_officials mo
JOIN matches m ON m.id = mo.match_id
WHERE mo.role_id = 1
AND m.status = 'FT'
AND m.mst_utc < %s
ORDER BY mo.name
LIMIT 2000
""",
(before_date_ms,),
)
candidates = cur.fetchall()
except Exception:
return []
if not candidates:
return []
# Find best match by normalized name comparison
best_match: Optional[str] = None
best_score = 0.0
for cand_row in candidates:
cand_name = cand_row.get('name', '')
if not cand_name:
continue
normalized_cand = _normalize_name(cand_name)
# Exact normalized match
if normalized_cand == normalized_query:
best_match = cand_name
best_score = 1.0
break
# Substring containment (handles "First Last" vs "First M. Last")
if (
normalized_query in normalized_cand
or normalized_cand in normalized_query
):
containment_score = min(
len(normalized_query), len(normalized_cand)
) / max(len(normalized_query), len(normalized_cand))
if containment_score > best_score and containment_score > 0.6:
best_match = cand_name
best_score = containment_score
if not best_match:
return []
# Re-query with the resolved name
return self._query_referee_matches(
cur, best_match, before_date_ms, limit,
)
# ─── 5. League Averages ─────────────────────────────────────────
def compute_league_averages(
+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,
}