feat(ai-engine): value sniper thresholds and logic relaxed
This commit is contained in:
@@ -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 44 — 3-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 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: 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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user