This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
"""
|
||||
Value Detection Engine
|
||||
======================
|
||||
The Smart Way to Beat the Bookmakers
|
||||
|
||||
This engine doesn't just predict winners - it finds VALUE.
|
||||
The key insight: We don't need to predict the winner, we need to find
|
||||
where the bookmaker made a mistake in their odds.
|
||||
|
||||
Core Philosophy:
|
||||
- High Margin = High Uncertainty = Potential Value
|
||||
- Model Probability > Implied Probability = Value Bet
|
||||
- The goal is NOT to predict correctly, but to find +EV bets
|
||||
|
||||
Author: AI Engine V21
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValueBet:
|
||||
"""Represents a value bet opportunity"""
|
||||
outcome: str # "1", "X", "2"
|
||||
model_probability: float # Our model's probability (0-1)
|
||||
implied_probability: float # Bookmaker's implied probability (0-1)
|
||||
odds: float # Bookmaker's odds
|
||||
edge: float # model_prob - implied_prob (as percentage)
|
||||
expected_value: float # EV = (prob * odds) - 1
|
||||
kelly_fraction: float # Optimal bet size
|
||||
confidence: str # "HIGH", "MEDIUM", "LOW"
|
||||
reasons: List[str] # Why this is value
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"outcome": self.outcome,
|
||||
"model_prob": round(self.model_probability * 100, 1),
|
||||
"implied_prob": round(self.implied_probability * 100, 1),
|
||||
"odds": self.odds,
|
||||
"edge": round(self.edge * 100, 1),
|
||||
"ev": round(self.expected_value * 100, 1),
|
||||
"kelly": round(self.kelly_fraction * 100, 1),
|
||||
"confidence": self.confidence,
|
||||
"reasons": self.reasons
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarginAnalysis:
|
||||
"""Analysis of bookmaker margin"""
|
||||
raw_margin: float # Sum of raw implied probabilities - 1
|
||||
true_margin: float # Adjusted for favorite-longshot bias
|
||||
favorite_outcome: str
|
||||
favorite_odds: float
|
||||
uncertainty_level: str # "LOW", "MEDIUM", "HIGH", "EXTREME"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"raw_margin": round(self.raw_margin * 100, 1),
|
||||
"true_margin": round(self.true_margin * 100, 1),
|
||||
"favorite": self.favorite_outcome,
|
||||
"favorite_odds": self.favorite_odds,
|
||||
"uncertainty": self.uncertainty_level
|
||||
}
|
||||
|
||||
|
||||
class ValueDetectionEngine:
|
||||
"""
|
||||
The Smart Betting Engine
|
||||
|
||||
This engine finds value bets by comparing model probabilities
|
||||
with bookmaker implied probabilities.
|
||||
|
||||
Key Insights:
|
||||
1. Margin > 18% → Bookmaker is unsure, potential value on underdog
|
||||
2. Margin > 20% → Bookmaker sees high risk, BIG potential value
|
||||
3. Favorite odds 1.40-1.60 → Highest upset rate historically
|
||||
4. Away favorites have higher upset rate than home favorites
|
||||
"""
|
||||
|
||||
# Historical upset rates by favorite odds range
|
||||
UPSET_RATES = {
|
||||
(1.00, 1.25): 0.08, # 8% upset rate
|
||||
(1.25, 1.40): 0.18, # 18% upset rate
|
||||
(1.40, 1.60): 0.33, # 33% upset rate - DANGER ZONE
|
||||
(1.60, 1.80): 0.28, # 28% upset rate
|
||||
(1.80, 2.00): 0.35, # 35% upset rate
|
||||
(2.00, 2.50): 0.42, # 42% upset rate
|
||||
(2.50, 3.00): 0.45, # 45% upset rate
|
||||
(3.00, 5.00): 0.55, # 55% upset rate
|
||||
}
|
||||
|
||||
# Margin thresholds
|
||||
MARGIN_LOW = 0.06 # 6% - bookmaker very confident
|
||||
MARGIN_MEDIUM = 0.12 # 12% - normal margin
|
||||
MARGIN_HIGH = 0.18 # 18% - bookmaker unsure
|
||||
MARGIN_EXTREME = 0.22 # 22% - bookmaker very unsure
|
||||
|
||||
def __init__(self):
|
||||
self.historical_data = [] # For learning
|
||||
self.value_threshold = 0.03 # Minimum 3% edge to consider value
|
||||
|
||||
def calculate_margin(self, odds_1: float, odds_x: float, odds_2: float) -> MarginAnalysis:
|
||||
"""
|
||||
Calculate bookmaker margin and analyze uncertainty.
|
||||
|
||||
Higher margin = More uncertainty = More potential value
|
||||
"""
|
||||
if not all([odds_1 > 1, odds_x > 1, odds_2 > 1]):
|
||||
return MarginAnalysis(0, 0, "X", 0, "UNKNOWN")
|
||||
|
||||
# Raw implied probabilities
|
||||
imp_1 = 1 / odds_1
|
||||
imp_x = 1 / odds_x
|
||||
imp_2 = 1 / odds_2
|
||||
|
||||
raw_margin = imp_1 + imp_x + imp_2 - 1
|
||||
|
||||
# Determine favorite
|
||||
if odds_1 <= odds_x and odds_1 <= odds_2:
|
||||
favorite_outcome = "1"
|
||||
favorite_odds = odds_1
|
||||
elif odds_2 <= odds_1 and odds_2 <= odds_x:
|
||||
favorite_outcome = "2"
|
||||
favorite_odds = odds_2
|
||||
else:
|
||||
favorite_outcome = "X"
|
||||
favorite_odds = odds_x
|
||||
|
||||
# Adjust for favorite-longshot bias
|
||||
# Bookmakers typically overprice longshots
|
||||
true_margin = raw_margin * 0.85 # Simplified adjustment
|
||||
|
||||
# Determine uncertainty level
|
||||
if raw_margin < self.MARGIN_LOW:
|
||||
uncertainty = "LOW"
|
||||
elif raw_margin < self.MARGIN_MEDIUM:
|
||||
uncertainty = "MEDIUM"
|
||||
elif raw_margin < self.MARGIN_HIGH:
|
||||
uncertainty = "HIGH"
|
||||
else:
|
||||
uncertainty = "EXTREME"
|
||||
|
||||
return MarginAnalysis(
|
||||
raw_margin=raw_margin,
|
||||
true_margin=true_margin,
|
||||
favorite_outcome=favorite_outcome,
|
||||
favorite_odds=favorite_odds,
|
||||
uncertainty_level=uncertainty
|
||||
)
|
||||
|
||||
def get_historical_upset_rate(self, favorite_odds: float) -> float:
|
||||
"""Get historical upset rate for given favorite odds"""
|
||||
for (low, high), rate in self.UPSET_RATES.items():
|
||||
if low <= favorite_odds < high:
|
||||
return rate
|
||||
return 0.40 # Default for very high odds
|
||||
|
||||
def calculate_edge(
|
||||
self,
|
||||
model_prob: float,
|
||||
odds: float,
|
||||
margin: float
|
||||
) -> Tuple[float, float]:
|
||||
"""
|
||||
Calculate the edge (advantage) we have over the bookmaker.
|
||||
|
||||
Returns: (edge, expected_value)
|
||||
|
||||
Edge = Model Probability - True Implied Probability
|
||||
EV = (Probability * Odds) - 1
|
||||
"""
|
||||
if odds <= 1:
|
||||
return 0, -1
|
||||
|
||||
# Raw implied probability
|
||||
implied = 1 / odds
|
||||
|
||||
# Adjust for margin (proportional adjustment)
|
||||
# This gives us the "true" implied probability
|
||||
# Assuming bookmaker spreads margin proportionally
|
||||
true_implied = implied # Simplified - could be more sophisticated
|
||||
|
||||
edge = model_prob - true_implied
|
||||
ev = (model_prob * odds) - 1
|
||||
|
||||
return edge, ev
|
||||
|
||||
def calculate_kelly_fraction(
|
||||
self,
|
||||
probability: float,
|
||||
odds: float,
|
||||
half_kelly: bool = True
|
||||
) -> float:
|
||||
"""
|
||||
Calculate optimal bet size using Kelly Criterion.
|
||||
|
||||
Kelly = (p * b - 1) / (b - 1)
|
||||
where b = odds - 1
|
||||
|
||||
We use half Kelly for safety.
|
||||
"""
|
||||
if odds <= 1:
|
||||
return 0
|
||||
|
||||
b = odds - 1
|
||||
kelly = (probability * b - 1) / b
|
||||
|
||||
# Don't bet if negative
|
||||
if kelly < 0:
|
||||
return 0
|
||||
|
||||
# Use half Kelly for safety
|
||||
if half_kelly:
|
||||
kelly = kelly / 2
|
||||
|
||||
# Cap at 10% of bankroll
|
||||
return min(kelly, 0.10)
|
||||
|
||||
def find_value_bets(
|
||||
self,
|
||||
model_probs: Dict[str, float],
|
||||
odds: Dict[str, float],
|
||||
match_context: Optional[Dict] = None
|
||||
) -> List[ValueBet]:
|
||||
"""
|
||||
Find all value bets in a match.
|
||||
|
||||
This is the MAIN method - it finds where we have an edge.
|
||||
|
||||
Args:
|
||||
model_probs: {"1": 0.55, "X": 0.25, "2": 0.20}
|
||||
odds: {"1": 1.25, "X": 4.50, "2": 8.00}
|
||||
match_context: Additional context (form, h2h, etc.)
|
||||
|
||||
Returns:
|
||||
List of ValueBet objects, sorted by edge
|
||||
"""
|
||||
value_bets = []
|
||||
|
||||
# Calculate margin
|
||||
margin_analysis = self.calculate_margin(
|
||||
odds.get("1", 0),
|
||||
odds.get("X", 0),
|
||||
odds.get("2", 0)
|
||||
)
|
||||
|
||||
# Analyze each outcome
|
||||
for outcome in ["1", "X", "2"]:
|
||||
prob = model_probs.get(outcome, 0)
|
||||
odd = odds.get(outcome, 0)
|
||||
|
||||
if prob <= 0 or odd <= 1:
|
||||
continue
|
||||
|
||||
edge, ev = self.calculate_edge(prob, odd, margin_analysis.raw_margin)
|
||||
kelly = self.calculate_kelly_fraction(prob, odd)
|
||||
|
||||
# Determine if this is a value bet
|
||||
reasons = []
|
||||
|
||||
# 1. Basic edge
|
||||
if edge > self.value_threshold:
|
||||
reasons.append(f"Edge: +{round(edge*100, 1)}% over bookmaker")
|
||||
|
||||
# 2. High margin bonus
|
||||
if margin_analysis.raw_margin > self.MARGIN_HIGH:
|
||||
reasons.append(f"High margin ({round(margin_analysis.raw_margin*100, 1)}%) = uncertainty")
|
||||
|
||||
# Boost edge for underdogs in high margin matches
|
||||
if outcome != margin_analysis.favorite_outcome:
|
||||
edge += 0.02 # 2% bonus
|
||||
reasons.append("Underdog in high-margin match = bonus value")
|
||||
|
||||
# 3. Favorite odds trap
|
||||
fav_odds = margin_analysis.favorite_odds
|
||||
if margin_analysis.favorite_outcome != outcome:
|
||||
upset_rate = self.get_historical_upset_rate(fav_odds)
|
||||
if upset_rate > 0.25:
|
||||
reasons.append(f"Favorite odds {fav_odds} has {round(upset_rate*100)}% upset rate")
|
||||
|
||||
# Extra bonus for 1.40-1.60 range
|
||||
if 1.40 <= fav_odds <= 1.60:
|
||||
edge += 0.03
|
||||
reasons.append("DANGER ZONE: 1.40-1.60 odds = highest upset risk")
|
||||
|
||||
# 4. Away favorite risk
|
||||
if margin_analysis.favorite_outcome == "2" and outcome == "1":
|
||||
edge += 0.015
|
||||
reasons.append("Away favorite = extra home value")
|
||||
|
||||
# 5. EV positive
|
||||
if ev > 0:
|
||||
reasons.append(f"Positive EV: +{round(ev*100, 1)}%")
|
||||
|
||||
# Only add if we have reasons (value detected)
|
||||
if reasons and edge > 0:
|
||||
# Determine confidence
|
||||
if edge > 0.08 or (edge > 0.05 and kelly > 0.03):
|
||||
confidence = "HIGH"
|
||||
elif edge > 0.05:
|
||||
confidence = "MEDIUM"
|
||||
else:
|
||||
confidence = "LOW"
|
||||
|
||||
value_bets.append(ValueBet(
|
||||
outcome=outcome,
|
||||
model_probability=prob,
|
||||
implied_probability=1/odd,
|
||||
odds=odd,
|
||||
edge=edge,
|
||||
expected_value=ev,
|
||||
kelly_fraction=kelly,
|
||||
confidence=confidence,
|
||||
reasons=reasons
|
||||
))
|
||||
|
||||
# Sort by edge (highest first)
|
||||
value_bets.sort(key=lambda x: x.edge, reverse=True)
|
||||
|
||||
return value_bets
|
||||
|
||||
def predict_with_value(
|
||||
self,
|
||||
model_probs: Dict[str, float],
|
||||
odds: Dict[str, float],
|
||||
match_context: Optional[Dict] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Make a prediction based on VALUE, not just probability.
|
||||
|
||||
This is the smart way to bet:
|
||||
- If there's clear value on one outcome → Bet it
|
||||
- If there's no value → NO BET (don't force it)
|
||||
- If margin is extreme → Look for underdog value
|
||||
|
||||
Returns:
|
||||
{
|
||||
"best_value": ValueBet or None,
|
||||
"alternative_value": ValueBet or None,
|
||||
"margin_analysis": MarginAnalysis,
|
||||
"recommendation": str,
|
||||
"confidence": str
|
||||
}
|
||||
"""
|
||||
margin_analysis = self.calculate_margin(
|
||||
odds.get("1", 0),
|
||||
odds.get("X", 0),
|
||||
odds.get("2", 0)
|
||||
)
|
||||
|
||||
value_bets = self.find_value_bets(model_probs, odds, match_context)
|
||||
|
||||
result = {
|
||||
"margin_analysis": margin_analysis.to_dict(),
|
||||
"value_bets": [vb.to_dict() for vb in value_bets],
|
||||
"best_value": None,
|
||||
"alternative_value": None,
|
||||
"recommendation": "NO_BET",
|
||||
"confidence": "LOW",
|
||||
"reasoning": []
|
||||
}
|
||||
|
||||
if not value_bets:
|
||||
result["reasoning"].append("No value detected in any outcome")
|
||||
result["reasoning"].append("Bookmaker odds are efficient for this match")
|
||||
return result
|
||||
|
||||
# Get best value bet
|
||||
best = value_bets[0]
|
||||
result["best_value"] = best.to_dict()
|
||||
|
||||
if len(value_bets) > 1:
|
||||
result["alternative_value"] = value_bets[1].to_dict()
|
||||
|
||||
# Determine recommendation
|
||||
if best.confidence == "HIGH" and best.edge > 0.05:
|
||||
result["recommendation"] = f"BET_{best.outcome}"
|
||||
result["confidence"] = "HIGH"
|
||||
result["reasoning"] = best.reasons
|
||||
result["reasoning"].append(f"Strong value on {best.outcome} with {round(best.edge*100, 1)}% edge")
|
||||
|
||||
elif best.confidence == "MEDIUM" or best.edge > 0.03:
|
||||
result["recommendation"] = f"CONSIDER_{best.outcome}"
|
||||
result["confidence"] = "MEDIUM"
|
||||
result["reasoning"] = best.reasons
|
||||
result["reasoning"].append(f"Moderate value on {best.outcome}")
|
||||
|
||||
else:
|
||||
result["recommendation"] = "NO_BET"
|
||||
result["confidence"] = "LOW"
|
||||
result["reasoning"].append("Edge too small to justify bet")
|
||||
result["reasoning"].append(f"Best edge: {round(best.edge*100, 1)}% (need >3%)")
|
||||
|
||||
# Add margin context
|
||||
if margin_analysis.uncertainty_level == "EXTREME":
|
||||
result["reasoning"].append("⚠️ EXTREME margin - high volatility match")
|
||||
elif margin_analysis.uncertainty_level == "HIGH":
|
||||
result["reasoning"].append("⚠️ High margin - bookmaker sees risk")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_engine_instance = None
|
||||
|
||||
def get_value_detection_engine() -> ValueDetectionEngine:
|
||||
"""Get the singleton instance"""
|
||||
global _engine_instance
|
||||
if _engine_instance is None:
|
||||
_engine_instance = ValueDetectionEngine()
|
||||
return _engine_instance
|
||||
Reference in New Issue
Block a user