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

1"""Payoff calculation classes for different betting markets.""" 

2 

3import abc 

4from typing import Any 

5 

6import attrs 

7import pandas as pd 

8 

9from .core import GoalGrid 

10from .market import VALID_SIDES, BetState, DecimalOdds, Market, Quote, QuotedLine, QuoteLeg 

11from .models import BivariateDistribution 

12 

13 

14@attrs.define 

15class PayoffSurface(abc.ABC): 

16 """Abstract base class for betting payoff surfaces. 

17 

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). 

20 

21 Attributes: 

22 line: The betting line (automatically converted to QuotedLine). 

23 """ 

24 

25 line: QuotedLine = attrs.field(converter=QuotedLine) 

26 

27 @property 

28 @abc.abstractmethod 

29 def market(self) -> Market: 

30 """The market type for this payoff surface. 

31 

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 

36 

37 @staticmethod 

38 def _core_grid_payoff(df: pd.DataFrame) -> pd.DataFrame: 

39 """Convert a difference DataFrame to payoff values. 

40 

41 Args: 

42 df: DataFrame with numeric differences 

43 

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 

52 

53 @abc.abstractmethod 

54 def grid_payoff(self, grid: GoalGrid | None = None) -> pd.DataFrame: 

55 """Calculate the payoff surface for this bet type. 

56 

57 Returns: 

58 DataFrame with payoff values for all goal combinations 

59 """ 

60 raise NotImplementedError("Subclasses must implement the payoff method.") # pragma: no cover 

61 

62 def grid_payoff_prob(self, model: BivariateDistribution, grid: GoalGrid | None = None) -> pd.DataFrame: 

63 """Convenience method to calculate probability-weighted payoff. 

64 

65 Args: 

66 model: BivariateDistribution representing the probability distribution 

67 grid: Optional GoalGrid for calculations (defaults to GoalGrid.default()) 

68 

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) 

75 

76 def grid_payoff_ev(self, model: BivariateDistribution, grid: GoalGrid | None = None) -> pd.DataFrame: 

77 """Calculate the expected payoff given a probability distribution. 

78 

79 Args: 

80 model: BivariateDistribution representing the probability distribution 

81 grid: Optional GoalGrid for calculations (defaults to GoalGrid.default()) 

82 

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) 

89 

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. 

92 

93 Args: 

94 model: BivariateDistribution representing the probability distribution 

95 grid: Optional GoalGrid for calculations (defaults to GoalGrid.default()) 

96 

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} 

106 

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. 

109 

110 Args: 

111 model: BivariateDistribution representing the probability distribution 

112 grid: Optional GoalGrid for calculations (defaults to GoalGrid.default()) 

113 

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) 

126 

127 

128@attrs.define 

129class AsianHandicap(PayoffSurface): 

130 """Asian handicap betting surface. 

131 

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 

136 

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 """ 

142 

143 @property 

144 def market(self) -> Market: 

145 """The market type for this payoff surface. 

146 

147 Returns: 

148 The market type as a Market enum (Market.ASIAN_HCAP) 

149 """ 

150 return Market.ASIAN_HCAP 

151 

152 def grid_payoff(self, grid: GoalGrid | None = None) -> pd.DataFrame: 

153 """Calculate Asian handicap payoffs. 

154 

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) 

167 

168 

169@attrs.define 

170class TotalGoals(PayoffSurface): 

171 """Goal Total (totals) betting surface. 

172 

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) 

178 

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 """ 

184 

185 @property 

186 def market(self) -> Market: 

187 """The market type for this payoff surface. 

188 

189 Returns: 

190 The market type as a Market enum (Market.TOTAL_GOAL) 

191 """ 

192 return Market.TOTAL_GOAL 

193 

194 def grid_payoff(self, grid: GoalGrid | None = None) -> pd.DataFrame: 

195 """Calculate Goal Total payoffs. 

196 

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)