Source code for footix.strategy.select_bets

from dataclasses import dataclass, field
from typing import NamedTuple, Optional, Sequence

import numpy as np

from footix.strategy.bets import Bet, OddsInput
from footix.utils.typing import SampleProbaResult


[docs] class Thresholds(NamedTuple): edge_floor: float prob_edge: Optional[float] = None
[docs] class OddsRange(NamedTuple): """Represents an odds range and its corresponding edge and probability thresholds. Attributes: min_odds: Minimum odds value (inclusive) max_odds: Maximum odds value (inclusive) edge: Required edge floor for this odds range prob_edge: Required probability of positive edge (optional) """ min_odds: float max_odds: float edge: float prob_edge: Optional[float] = None
[docs] @dataclass(slots=True) class EdgeFloorConfig: ranges: Sequence[OddsRange] = field(default_factory=list) default_edge_floor: float = 0.0 default_prob_edge: Optional[float] = None
[docs] def get_thresholds(self, odds: float) -> Thresholds: """Return edge-floor thresholds for the given odds.""" # Find the first matching range, if any match = next( (r for r in self.ranges if r.min_odds <= odds <= r.max_odds), None, ) if match is not None: return Thresholds( edge_floor=match.edge, prob_edge=match.prob_edge or self.default_prob_edge, ) # No range matched – fall back to defaults return Thresholds(self.default_edge_floor, self.default_prob_edge)
[docs] def simple_select_bets( odds_input: list[OddsInput], probas: np.ndarray, edge_floor: float | EdgeFloorConfig = 0.0, single_bet_per_game: bool = True, outcomes: Sequence[str] = ("H", "D", "A"), ) -> list[Bet]: n_matches = len(odds_input) if probas.shape != (n_matches, 3): raise ValueError(f"probas must have shape ({n_matches}, 3), got {probas.shape}") if isinstance(edge_floor, float): edge_config = EdgeFloorConfig(default_edge_floor=edge_floor) elif isinstance(edge_floor, EdgeFloorConfig): edge_config = edge_floor else: raise TypeError("Edge floor should be either a float or an EdgeFloorConfig instance") selections: list[Bet] = [] for idx, odd in enumerate(odds_input): odds_arr = np.asarray(odd.odds) # Compute expected edge for each outcome: edge = p*(odds-1) + (p-1) edges = probas[idx] * (odds_arr - 1) + (probas[idx] - 1) if single_bet_per_game: best_idx = int(np.argmax(edges)) required_edges, _ = edge_config.get_thresholds(odds_arr[best_idx]) if edges[best_idx] > required_edges: selections.append( _build_bet( odd, outcomes=outcomes, pick=best_idx, prob=probas[idx, best_idx], ) ) else: # Add every outcome with positive edge for pick in np.where(edges > edge_floor)[0]: selections.append( _build_bet( odd, outcomes=outcomes, pick=int(pick), prob=probas[idx, pick], ) ) return selections
def _build_bet( odd_input: OddsInput, outcomes: Sequence[str], pick: int, prob: float, ) -> Bet: """Construct a Bet object from the row data and computed metrics. Args: row (pd.Series): One row from the odds DataFrame. outcomes (list[str]): List of outcome labels, e.g. ["H","D","A"]. odds_arr (np.ndarray): Array of odds for the three outcomes. pick (int): Index of the chosen outcome (0,1,2). edge (float): Expected edge for the chosen outcome. prob (float): Predicted probability for the chosen outcome. Returns: Bet: Initialized with match_id, market, odds, edge_mean, and prob_mean. """ return Bet( match_id=odd_input.match_id, market=outcomes[pick], odds=odd_input.odds[pick], prob_mean=prob, )
[docs] def select_matches_posterior( odds_input: list[OddsInput], lambda_samples: dict[str, SampleProbaResult], *, config: EdgeFloorConfig | None = None, edge_floor: float = 0.1, prob_edge_threshold: float = 0.55, single_bet_per_game: bool = True, ) -> list[Bet]: """Select bets based on posterior probabilities computed from the Skellam distribution. For each match, posterior probabilities for the home-win, draw, and away-win outcomes are computed. The expected edge is calculated for each outcome. Bets are only selected if the mean edge exceeds the specified edge_floor and the probability of a positive edge is above the prob_edge_threshold. If single_bet_per_game is True, only the bet with the highest mean edge is kept per match. Args: odds_input (list[OddsInput]): List of odds input objects. lambda_samples (dict[str, tuple[np.ndarray, np.ndarray]]): Dictionary mapping match_id to lambda samples (home and away) used for posterior probability computation. edge_floor (float, optional): Minimum required mean edge to consider a bet. Defaults to 0.1. prob_edge_threshold (float, optional): Minimum probability of positive edge to consider a bet. Defaults to 0.55. single_bet_per_game (bool, optional): If True, only the best bet per match is selected. Defaults to True. Returns: list[Bet]: A sorted list of selected Bet objects, ordered by descending edge_mean. """ if config is None: config = EdgeFloorConfig( default_edge_floor=edge_floor, default_prob_edge=prob_edge_threshold ) selected: list[Bet] = [] for odd in odds_input: p_home, p_draw, p_away = lambda_samples[odd.match_id] candidate_bets = [] for market, p_samples in zip(("H", "D", "A"), (p_home, p_draw, p_away)): o = odd.odd_dict[market] edge_samples = p_samples * (o - 1.0) - (1.0 - p_samples) mu_edge = edge_samples.mean() std_edge = edge_samples.std(ddof=1) prob_pos = (edge_samples > 0).mean() p_mean = p_samples.mean() edge_thresholds = config.get_thresholds(odds=o) if mu_edge > edge_thresholds.edge_floor and prob_pos > edge_thresholds.prob_edge: candidate_bets.append( Bet( match_id=odd.match_id, market=market, odds=o, edge_std=std_edge, prob_edge_pos=prob_pos, prob_mean=p_mean, ) ) if candidate_bets: if single_bet_per_game: best_bet = max(candidate_bets, key=lambda b: b.edge_mean) selected.append(best_bet) else: selected.extend(candidate_bets) return sorted(selected, key=lambda b: b.edge_mean, reverse=True)
[docs] def select_bets_by_probability( odds_input: list[OddsInput], probas: np.ndarray, prob_floor: float = 0.55, single_bet_per_game: bool = True, ) -> list[Bet]: """Select bets based on the highest predicted probabilities. For each match, outcomes with predicted probability greater than or equal to prob_floor are considered. If single_bet_per_game is True, only the outcome with the highest probability (if it meets the threshold) is selected per match. Otherwise, every outcome meeting the threshold is selected. Args: odds_input (list[OddsInput]): List of odds input objects. probas (np.ndarray): Array of shape (n_matches, 3) containing predicted probabilities. prob_floor (float, optional): Minimum acceptable probability for a bet. Defaults to 0.55. single_bet_per_game (bool, optional): If True, only the most probable bet per match is selected. Defaults to True. Returns: list[Bet]: A list of Bet objects selected based on the highest probability. """ outcomes = ["H", "D", "A"] n_matches = len(odds_input) if probas.shape != (n_matches, 3): raise ValueError(f"probas must have shape ({n_matches}, 3), got {probas.shape}") selections: list[Bet] = [] for idx, odd in enumerate(odds_input): p = probas[idx] if single_bet_per_game: best_idx = int(np.argmax(p)) if p[best_idx] >= prob_floor: selections.append( _build_bet(odd, outcomes=outcomes, pick=best_idx, prob=p[best_idx]) ) else: # Add every outcome with predicted probability above or equal to the threshold. for pick in np.where(p >= prob_floor)[0]: selections.append(_build_bet(odd, outcomes=outcomes, pick=int(pick), prob=p[pick])) return selections