Coverage for src/pyrf/payoffs.py: 100%
69 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-06 21:54 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-06 21:54 +0000
1"""Payoff calculation classes for different betting markets."""
3import abc
4from typing import Any
6import attrs
7import pandas as pd
9from .core import GoalGrid
10from .market import VALID_SIDES, BetState, DecimalOdds, Market, Quote, QuotedLine, QuoteLeg
11from .models import BivariateDistribution
14@attrs.define
15class PayoffSurface(abc.ABC):
16 """Abstract base class for betting payoff surfaces.
18 A payoff surface calculates betting outcomes across all possible goal combinations
19 for a given line. Subclasses implement specific bet types (Asian handicap, over/under).
21 Attributes:
22 line: The betting line (automatically converted to QuotedLine).
23 """
25 line: QuotedLine = attrs.field(converter=QuotedLine)
27 @property
28 @abc.abstractmethod
29 def market(self) -> Market:
30 """The market type for this payoff surface.
32 Returns:
33 The market type as a Market enum (e.g., Market.TOTAL_GOAL, Market.ASIAN_HCAP)
34 """
35 raise NotImplementedError("Subclasses must implement the _market property.") # pragma: no cover
37 @staticmethod
38 def _core_grid_payoff(df: pd.DataFrame) -> pd.DataFrame:
39 """Convert a difference DataFrame to payoff values.
41 Args:
42 df: DataFrame with numeric differences
44 Returns:
45 DataFrame with payoff values: +1 (win), 0 (push), -1 (loss)
46 """
47 dg = df.copy()
48 dg[df < 0] = -1
49 dg[df == 0] = 0
50 dg[df > 0] = +1
51 return dg
53 @abc.abstractmethod
54 def grid_payoff(self, grid: GoalGrid | None = None) -> pd.DataFrame:
55 """Calculate the payoff surface for this bet type.
57 Returns:
58 DataFrame with payoff values for all goal combinations
59 """
60 raise NotImplementedError("Subclasses must implement the payoff method.") # pragma: no cover
62 def grid_payoff_prob(self, model: BivariateDistribution, grid: GoalGrid | None = None) -> pd.DataFrame:
63 """Convenience method to calculate probability-weighted payoff.
65 Args:
66 model: BivariateDistribution representing the probability distribution
67 grid: Optional GoalGrid for calculations (defaults to GoalGrid.default())
69 Returns:
70 DataFrame with expected (model probability weighted) payoff values for
71 all goal combinations
72 """
73 grid = grid if grid is not None else GoalGrid.default()
74 return model.pmf(data=grid)
76 def grid_payoff_ev(self, model: BivariateDistribution, grid: GoalGrid | None = None) -> pd.DataFrame:
77 """Calculate the expected payoff given a probability distribution.
79 Args:
80 model: BivariateDistribution representing the probability distribution
81 grid: Optional GoalGrid for calculations (defaults to GoalGrid.default())
83 Returns:
84 DataFrame with expected (model probability weighted) payoff values for
85 all goal combinations
86 """
87 grid = grid if grid is not None else GoalGrid.default()
88 return self.grid_payoff(grid=grid) * self.grid_payoff_prob(model=model, grid=grid)
90 def implied_prob(self, model: BivariateDistribution, grid: GoalGrid | None = None) -> dict[BetState, Any]:
91 """Calculate model-implied probabilities of winning on each side of the bet.
93 Args:
94 model: BivariateDistribution representing the probability distribution
95 grid: Optional GoalGrid for calculations (defaults to GoalGrid.default())
97 Returns:
98 Tuple with probabilities of (under, over, push)
99 """
100 df = self.grid_payoff_ev(model=model, grid=grid)
101 ev_over = +df[df > 0].sum().sum()
102 ev_undr = -df[df < 0].sum().sum()
103 mass = df.abs().sum().sum()
104 key_a, key_b = VALID_SIDES[self.market]
105 return {key_a: ev_over / mass, key_b: ev_undr / mass}
107 def implied_odds(self, model: BivariateDistribution, grid: GoalGrid | None = None) -> Quote:
108 """Calculate model-implied probabilities of winning on each side of the bet.
110 Args:
111 model: BivariateDistribution representing the probability distribution
112 grid: Optional GoalGrid for calculations (defaults to GoalGrid.default())
114 Returns:
115 Tuple with probabilities of (under, over, push)
116 """
117 probs = self.implied_prob(model=model, grid=grid)
118 key_a, key_b = VALID_SIDES[self.market]
119 side_a = QuoteLeg(
120 market=self.market, line=self.line, side=key_a, odds=DecimalOdds.from_probability(prob=probs[key_a])
121 )
122 side_b = QuoteLeg(
123 market=self.market, line=self.line, side=key_b, odds=DecimalOdds.from_probability(prob=probs[key_b])
124 )
125 return Quote(side_a=side_a, side_b=side_b)
128@attrs.define
129class AsianHandicap(PayoffSurface):
130 """Asian handicap betting surface.
132 Asian handicap gives one team a goal advantage/disadvantage. The line represents
133 the handicap applied to the home team:
134 - Positive line: home team gets goals added
135 - Negative line: home team gets goals subtracted
137 Payoff calculation: (home_goals - away_goals) + line
138 - Win: final result > 0
139 - Push: final result = 0 (unit lines only)
140 - Loss: final result < 0
141 """
143 @property
144 def market(self) -> Market:
145 """The market type for this payoff surface.
147 Returns:
148 The market type as a Market enum (Market.ASIAN_HCAP)
149 """
150 return Market.ASIAN_HCAP
152 def grid_payoff(self, grid: GoalGrid | None = None) -> pd.DataFrame:
153 """Calculate Asian handicap payoffs.
155 Returns:
156 DataFrame with payoff values based on goal difference minus handicap line
157 """
158 grid = grid if grid is not None else GoalGrid.default()
159 lower, upper = self.line.neighbours()
160 df = grid.home - grid.away
161 df_lower = self._core_grid_payoff(df + lower)
162 if lower == upper:
163 return df_lower
164 else: # quarter lines require blending
165 df_upper = self._core_grid_payoff(df + upper)
166 return 0.5 * (df_lower + df_upper)
169@attrs.define
170class TotalGoals(PayoffSurface):
171 """Goal Total (totals) betting surface.
173 Goal Total betting is on the total number of goals scored by both teams.
174 The line represents the threshold for total goals:
175 - Over: bet wins if total goals > line
176 - Under: bet wins if total goals < line
177 - Push: total goals = line (unit lines only)
179 Payoff calculation: (home_goals + away_goals) - line
180 - Win: final result > 0 (Over wins)
181 - Push: final result = 0 (unit lines only)
182 - Loss: final result < 0 (Under wins)
183 """
185 @property
186 def market(self) -> Market:
187 """The market type for this payoff surface.
189 Returns:
190 The market type as a Market enum (Market.TOTAL_GOAL)
191 """
192 return Market.TOTAL_GOAL
194 def grid_payoff(self, grid: GoalGrid | None = None) -> pd.DataFrame:
195 """Calculate Goal Total payoffs.
197 Returns:
198 DataFrame with payoff values based on total goals minus line
199 """
200 grid = grid if grid is not None else GoalGrid.default()
201 lower, upper = self.line.neighbours()
202 df = grid.home + grid.away
203 df_lower = self._core_grid_payoff(df - lower)
204 if lower == upper:
205 return df_lower
206 else: # quarter lines require blending
207 df_upper = self._core_grid_payoff(df - upper)
208 return 0.5 * (df_lower + df_upper)