Source code for penaltyblog.betting.value_bets

"""
Value Bet Identification

Functions for identifying value bets by comparing bookmaker odds to estimated true probabilities.
"""

from dataclasses import dataclass
from typing import List, Optional, Union

import numpy as np
from numpy.typing import NDArray


[docs] @dataclass class ValueBetResult: """Result of value bet analysis for a single bet.""" bookmaker_odds: float estimated_probability: float implied_probability: float expected_value: float expected_return_percentage: float is_value_bet: bool edge: float # Kelly recommendation based on value recommended_stake_kelly: float recommended_stake_fraction: float # Risk metrics win_probability: float lose_probability: float potential_profit: float potential_loss: float # Metadata margin_over_fair_odds: float overround_contribution: float
[docs] @dataclass class MultipleValueBetResult: """Result of value bet analysis for multiple bets.""" individual_results: List[ValueBetResult] bookmaker_odds: List[float] estimated_probabilities: List[float] # Portfolio metrics total_value_bets: int average_edge: float total_expected_value: float portfolio_overround: float # Kelly recommendations for portfolio kelly_stakes: List[float] total_kelly_stake: float portfolio_expected_return: float # Summary statistics best_value_bet_index: int best_edge: float worst_edge: float
[docs] @dataclass class ArbitrageResult: """Result of arbitrage opportunity analysis across multiple bookmakers.""" has_arbitrage: bool total_implied_probability: float guaranteed_return: float arbitrage_margin: float # Best odds information best_odds: List[float] best_bookmakers: List[int] # Stake allocation stake_percentages: List[float] # Metadata outcome_labels: List[str] num_bookmakers: int num_outcomes: int
def _calculate_implied_probability(decimal_odds: float) -> float: """Calculate implied probability from decimal odds.""" if decimal_odds <= 1.0: raise ValueError("Decimal odds must be greater than 1.0") return 1.0 / decimal_odds def _calculate_expected_value(decimal_odds: float, estimated_prob: float) -> float: """Calculate expected value of a bet.""" return (estimated_prob * (decimal_odds - 1)) - (1 - estimated_prob) def _calculate_kelly_stake(decimal_odds: float, estimated_prob: float) -> float: """Calculate optimal Kelly stake for a value bet.""" edge = _calculate_expected_value(decimal_odds, estimated_prob) if edge <= 0: return 0.0 return edge / (decimal_odds - 1)
[docs] def identify_value_bet( bookmaker_odds: Union[float, List[float], NDArray], estimated_probability: Union[float, List[float], NDArray], kelly_fraction: float = 1.0, min_edge_threshold: float = 0.0, ) -> Union[ValueBetResult, MultipleValueBetResult]: """ Identify value bets by comparing bookmaker odds to estimated true probabilities. A value bet occurs when your estimated probability of an outcome is higher than the bookmaker's implied probability, creating positive expected value. Parameters ---------- bookmaker_odds : float | list[float] | np.ndarray Bookmaker odds in decimal format (e.g., 2.0 for even money) estimated_probability : float | list[float] | np.ndarray Your estimated true probability of the outcome (0-1) kelly_fraction : float, default=1.0 Fraction of optimal Kelly stake to recommend (e.g., 0.5 for half Kelly) min_edge_threshold : float, default=0.0 Minimum edge required to consider a bet as having value Returns ------- ValueBetResult | MultipleValueBetResult Comprehensive analysis of value betting opportunities Examples -------- >>> # Single value bet analysis >>> result = identify_value_bet(2.5, 0.50) # 50% chance, 2.5 odds >>> print(f"Expected value: {result.expected_value:.3f}") >>> print(f"Kelly stake: {result.recommended_stake_kelly:.2%}") >>> # Multiple bet analysis >>> odds = [2.0, 3.0, 1.8] >>> probs = [0.6, 0.4, 0.5] >>> results = identify_value_bet(odds, probs) >>> print(f"Found {results.total_value_bets} value bets") Raises ------ ValueError If odds <= 1.0, probabilities outside [0,1], or mismatched array lengths """ # Convert inputs to numpy arrays for consistent handling odds_array = np.asarray(bookmaker_odds) prob_array = np.asarray(estimated_probability) # Input validation if np.any(odds_array <= 1.0): raise ValueError("All bookmaker odds must be greater than 1.0") if np.any(prob_array < 0) or np.any(prob_array > 1): raise ValueError("All estimated probabilities must be between 0 and 1") # Handle scalar vs array inputs is_scalar = odds_array.ndim == 0 and prob_array.ndim == 0 if is_scalar: odds_array = odds_array.flatten() prob_array = prob_array.flatten() # Check array lengths match if odds_array.shape != prob_array.shape: raise ValueError( "bookmaker_odds and estimated_probability must have same length" ) # Flatten arrays for consistent processing odds_flat = odds_array.flatten() prob_flat = prob_array.flatten() individual_results = [] for i, (odds, prob) in enumerate(zip(odds_flat, prob_flat)): implied_prob = _calculate_implied_probability(odds) expected_value = _calculate_expected_value(odds, prob) expected_return_pct = expected_value * 100 edge = prob - implied_prob is_value = edge > min_edge_threshold # Kelly calculations kelly_stake = _calculate_kelly_stake(odds, prob) recommended_stake = kelly_stake * kelly_fraction # Risk metrics potential_profit = odds - 1 # Profit per unit staked if win potential_loss = 1.0 # Loss per unit staked if lose # Additional metrics fair_odds = 1.0 / prob if prob > 0 else float("inf") margin_over_fair = ( (odds - fair_odds) / fair_odds if fair_odds != float("inf") else 0 ) result = ValueBetResult( bookmaker_odds=float(odds), estimated_probability=float(prob), implied_probability=float(implied_prob), expected_value=float(expected_value), expected_return_percentage=float(expected_return_pct), is_value_bet=bool(is_value), edge=float(edge), recommended_stake_kelly=float(kelly_stake), recommended_stake_fraction=float(recommended_stake), win_probability=float(prob), lose_probability=float(1 - prob), potential_profit=float(potential_profit), potential_loss=float(potential_loss), margin_over_fair_odds=float(margin_over_fair), overround_contribution=float(implied_prob), ) individual_results.append(result) if is_scalar: return individual_results[0] # Portfolio-level analysis total_value_bets = sum(1 for r in individual_results if r.is_value_bet) # Calculate average edge only for value bets value_bet_edges = [r.edge for r in individual_results if r.is_value_bet] avg_edge = np.mean(value_bet_edges) if value_bet_edges else 0.0 total_ev = sum(r.expected_value for r in individual_results) # Portfolio overround (sum of implied probabilities) portfolio_overround = sum(r.implied_probability for r in individual_results) # Kelly stakes for the portfolio kelly_stakes = [r.recommended_stake_fraction for r in individual_results] total_kelly_stake = sum(kelly_stakes) # Portfolio expected return (weighted by stakes) if total_kelly_stake > 0: portfolio_expected_return = ( sum( r.recommended_stake_fraction * r.expected_return_percentage for r in individual_results ) / total_kelly_stake ) else: portfolio_expected_return = 0.0 # Find best and worst edges all_edges = [r.edge for r in individual_results] best_edge = max(all_edges) if all_edges else 0.0 worst_edge = min(all_edges) if all_edges else 0.0 best_value_bet_index = int(np.argmax(all_edges)) if all_edges else 0 return MultipleValueBetResult( individual_results=individual_results, bookmaker_odds=odds_flat.tolist(), estimated_probabilities=prob_flat.tolist(), total_value_bets=total_value_bets, average_edge=float(avg_edge), total_expected_value=float(total_ev), portfolio_overround=float(portfolio_overround), kelly_stakes=kelly_stakes, total_kelly_stake=float(total_kelly_stake), portfolio_expected_return=float(portfolio_expected_return), best_value_bet_index=best_value_bet_index, best_edge=float(best_edge), worst_edge=float(worst_edge), )
[docs] def calculate_bet_value(bookmaker_odds: float, estimated_probability: float) -> float: """ Calculate the expected value of a bet as a simple utility function. Parameters ---------- bookmaker_odds : float Decimal odds from bookmaker estimated_probability : float Your estimated probability (0-1) Returns ------- float Expected value per unit staked Examples -------- >>> value = calculate_bet_value(2.0, 0.6) # 60% chance at 2.0 odds >>> print(f"Expected value: {value:.3f}") # 0.200 """ if bookmaker_odds <= 1.0: raise ValueError("Bookmaker odds must be greater than 1.0") if not (0 <= estimated_probability <= 1): raise ValueError("Estimated probability must be between 0 and 1") return _calculate_expected_value(bookmaker_odds, estimated_probability)
[docs] def find_arbitrage_opportunities( bookmaker_odds_list: List[List[float]], outcome_labels: Optional[List[str]] = None ) -> ArbitrageResult: """ Find arbitrage opportunities across multiple bookmakers for the same event. An arbitrage opportunity exists when you can bet on all outcomes across different bookmakers and guarantee a profit regardless of the result. Parameters ---------- bookmaker_odds_list : List[List[float]] List of odds from each bookmaker. Each inner list contains odds for all outcomes. Example: [[2.1, 1.9], [2.0, 2.0]] for two bookmakers with two outcomes each. outcome_labels : List[str], optional Labels for each outcome (e.g., ["Home", "Away"]) Returns ------- ArbitrageResult Structured result containing arbitrage analysis: - has_arbitrage: bool indicating if arbitrage exists - total_implied_probability: float (< 1.0 indicates arbitrage) - best_odds: list of best odds for each outcome - best_bookmakers: list of bookmaker indices offering best odds - stake_percentages: recommended stake allocation - guaranteed_return: guaranteed profit percentage Examples -------- >>> # Two bookmakers, two outcomes >>> odds = [[2.1, 1.85], [1.95, 2.0]] >>> arb = find_arbitrage_opportunities(odds, ["Home", "Away"]) >>> if arb.has_arbitrage: ... print(f"Guaranteed return: {arb.guaranteed_return:.2%}") """ if not bookmaker_odds_list: return ArbitrageResult( has_arbitrage=False, total_implied_probability=0.0, guaranteed_return=0.0, arbitrage_margin=0.0, best_odds=[], best_bookmakers=[], stake_percentages=[], outcome_labels=outcome_labels or [], num_bookmakers=0, num_outcomes=0, ) # Validate all bookmakers have same number of outcomes n_outcomes = len(bookmaker_odds_list[0]) if not all(len(odds) == n_outcomes for odds in bookmaker_odds_list): raise ValueError( "All bookmakers must have odds for the same number of outcomes" ) # Validate all odds are > 1.0 for i, bookmaker_odds in enumerate(bookmaker_odds_list): if any(odds <= 1.0 for odds in bookmaker_odds): raise ValueError(f"All odds must be > 1.0 (bookmaker {i} has invalid odds)") # Find best odds for each outcome best_odds = [] best_bookmakers = [] for outcome_idx in range(n_outcomes): outcome_odds = [bookmaker[outcome_idx] for bookmaker in bookmaker_odds_list] best_odd = max(outcome_odds) best_bookmaker = outcome_odds.index(best_odd) best_odds.append(best_odd) best_bookmakers.append(best_bookmaker) # Calculate total implied probability using best odds implied_probs = [1.0 / odds for odds in best_odds] total_implied_prob = sum(implied_probs) # Check if arbitrage exists has_arbitrage = total_implied_prob < 1.0 if has_arbitrage: # Calculate optimal stake allocation stake_percentages = [prob / total_implied_prob for prob in implied_probs] guaranteed_return = (1.0 / total_implied_prob) - 1.0 else: stake_percentages = [0.0] * n_outcomes guaranteed_return = 0.0 # Create outcome labels if not provided if outcome_labels is None: outcome_labels = [f"Outcome_{i+1}" for i in range(n_outcomes)] # Calculate arbitrage margin arbitrage_margin = 1.0 - total_implied_prob if has_arbitrage else 0.0 return ArbitrageResult( has_arbitrage=has_arbitrage, total_implied_probability=total_implied_prob, guaranteed_return=guaranteed_return, arbitrage_margin=arbitrage_margin, best_odds=best_odds, best_bookmakers=best_bookmakers, stake_percentages=stake_percentages, outcome_labels=outcome_labels, num_bookmakers=len(bookmaker_odds_list), num_outcomes=n_outcomes, )