Coverage for src/pyrf/market.py: 100%

108 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-06 21:54 +0000

1"""Market classes for betting quotes and odds.""" 

2 

3import abc 

4from enum import Enum 

5 

6import attrs 

7import numpy as np 

8 

9 

10class Market(Enum): 

11 """Represents the different markets for betting quotes.""" 

12 

13 ASIAN_HCAP = "AsianHandicap" 

14 TOTAL_GOAL = "TotalGoals" 

15 MATCH_RSLT = "Match Result" 

16 

17 

18class BetState(Enum): 

19 """Represents the side of a betting quote.""" 

20 

21 OVER = "Over" 

22 UNDR = "Under" 

23 HOME = "Home" 

24 AWAY = "Away" 

25 PUSH = "Push" 

26 

27 

28VALID_SIDES = { 

29 Market.TOTAL_GOAL: (BetState.OVER, BetState.UNDR), 

30 Market.ASIAN_HCAP: (BetState.HOME, BetState.AWAY), 

31} 

32 

33 

34@attrs.define 

35class QuotedLine: 

36 """Represents a betting line value with quarter-point precision. 

37 

38 Betting lines are typically quoted in increments of 0.25 points and can be: 

39 - Unit lines: whole numbers (0, 1, 2, etc.) - push possible 

40 - Half lines: x.5 values (0.5, 1.5, etc.) - no push possible 

41 - Quarter lines: x.25, x.75 values - split between adjacent half-lines 

42 

43 Attributes: 

44 value: The line value, must be a multiple of 0.25. 

45 """ 

46 

47 value: float = attrs.field(init=True, repr=True) 

48 

49 def __attrs_post_init__(self) -> None: 

50 """Validate that the line value is a multiple of 0.25.""" 

51 if not np.isfinite(self.value): 

52 raise AssertionError(f"Line value must be finite, got {self.value}") 

53 if np.mod(self.value, 0.25) != 0: 

54 raise AssertionError(f"Line value must be a multiple of 0.25, got {self.value}") 

55 

56 def is_unit_line(self) -> bool: 

57 """Check if this is a unit line (whole number).""" 

58 return bool(np.mod(self.value, 1) == 0) 

59 

60 def is_half_line(self) -> bool: 

61 """Check if this is a half line (x.5).""" 

62 return bool(np.mod(self.value * 2, 2) == 1) 

63 

64 def is_quarter_line(self) -> bool: 

65 """Check if this is a quarter line (x.25 or x.75).""" 

66 return np.mod(self.value * 4, 4) in (1, 3) 

67 

68 def neighbours(self) -> tuple[float, float]: 

69 """Get the neighbours for quarter line splitting. 

70 

71 Quarter lines are split between two adjacent half-lines. 

72 For other lines, both neighbours are the same value. 

73 

74 Returns: 

75 Tuple of (lower_bound, upper_bound) for line calculation 

76 """ 

77 if self.is_quarter_line(): 

78 lower = self.value - 0.25 

79 upper = self.value + 0.25 

80 else: 

81 lower = upper = self.value 

82 return lower, upper 

83 

84 

85@attrs.define 

86class QuotedOdds(abc.ABC): 

87 """Abstract base class for betting odds. 

88 

89 This class serves as a base for different odds representations (e.g., DecimalOdds). 

90 Subclasses must implement the implied_probability method. 

91 

92 Attributes: 

93 value: The odds value, typically a float representing the odds ratio. 

94 """ 

95 

96 value: float = attrs.field(init=True, repr=True) 

97 

98 @abc.abstractmethod 

99 def implied_probability(self) -> float: 

100 """Calculate the implied probability from the odds. 

101 

102 Returns: 

103 The implied probability as a float. 

104 """ 

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

106 

107 

108@attrs.define 

109class DecimalOdds(QuotedOdds): 

110 """Decimal odds representation. 

111 

112 Attributes: 

113 value: The odds value, typically a float representing the odds ratio. 

114 """ 

115 

116 def implied_probability(self) -> float: 

117 """Calculate the implied probability from the decimal odds. 

118 

119 Returns: 

120 The implied probability as a float. 

121 """ 

122 return 1 / self.value 

123 

124 @classmethod 

125 def from_probability(cls, prob: float) -> DecimalOdds: 

126 """Create DecimalOdds from an implied probability. 

127 

128 Args: 

129 prob: The implied probability as a float. 

130 """ 

131 if not (0 < prob <= 1): 

132 raise ValueError(f"Invalid implied probability: {prob}") 

133 return cls(value=1 / prob) 

134 

135 

136@attrs.frozen 

137class QuoteLeg: 

138 """Represents a price quote for a betting outcome. 

139 

140 Attributes: 

141 market: The type of betting market. 

142 line: The betting line for the quote. 

143 side: Which side of the line (Goal Total, Home/Away). 

144 odds: The odds for the bet. 

145 """ 

146 

147 market: Market = attrs.field(init=True, repr=True) 

148 line: QuotedLine = attrs.field(init=True, repr=True) 

149 side: BetState = attrs.field(init=True, repr=True) 

150 odds: QuotedOdds = attrs.field(init=True, repr=True) 

151 

152 def __attrs_post_init__(self) -> None: 

153 """Validate side based on market type.""" 

154 if self.side not in VALID_SIDES.get(self.market, []): 

155 raise ValueError(f"Invalid side '{self.side.value}' for market '{self.market.value}'") 

156 

157 

158@attrs.frozen 

159class Quote: 

160 """Represents a pair of complementary price quotes. 

161 

162 This class is used to group together two price quotes that are 

163 mutually exclusive (e.g., Home Win and Away Win). 

164 

165 Attributes: 

166 side_a: The first price quote. 

167 side_b: The second price quote. 

168 """ 

169 

170 side_a: QuoteLeg = attrs.field(init=True, repr=True) 

171 side_b: QuoteLeg = attrs.field(init=True, repr=True) 

172 

173 def __attrs_post_init__(self) -> None: 

174 """Validate that the two sides are complementary.""" 

175 if self.side_a.market != self.side_b.market: 

176 raise ValueError("Both quotes must be from the same market.") 

177 if self.side_a.line != self.side_b.line: 

178 raise ValueError("Both quotes must have the same line value.") 

179 if {self.side_a.side, self.side_b.side} not in map(set, VALID_SIDES.values()): 

180 raise ValueError("Quotes must be complementary.") 

181 

182 @classmethod 

183 def build( 

184 cls, 

185 market: Market, 

186 line: QuotedLine, 

187 side_pair: tuple[BetState, BetState], 

188 odds_pair: tuple[QuotedOdds, QuotedOdds], 

189 ) -> Quote: 

190 """Build a Quote from market, line, sides, and odds. 

191 

192 Args: 

193 market: The type of betting market 

194 line: The betting line for the quote 

195 side_pair: A tuple of two BetState values representing the sides 

196 odds_pair: A tuple of two QuotedOdds values representing the odds 

197 Returns: 

198 A Quote instance with the specified parameters. 

199 """ 

200 side_a = QuoteLeg(market=market, line=line, side=side_pair[0], odds=odds_pair[0]) 

201 side_b = QuoteLeg(market=market, line=line, side=side_pair[1], odds=odds_pair[1]) 

202 return cls(side_a=side_a, side_b=side_b) 

203 

204 @property 

205 def market(self) -> Market: 

206 """Get the market type of the quote pair.""" 

207 return self.side_a.market 

208 

209 @property 

210 def line(self) -> QuotedLine: 

211 """Get the line value of the quote pair.""" 

212 return self.side_a.line 

213 

214 def implied_margin(self) -> float: 

215 """Calculate the bookmaker's margin for the quote pair. 

216 

217 Returns: 

218 The juice as a float. 

219 """ 

220 prob_a = self.side_a.odds.implied_probability() 

221 prob_b = self.side_b.odds.implied_probability() 

222 margin = (prob_a + prob_b) - 1 

223 if np.abs(margin) < 1e-14: 

224 margin = 0.0 

225 return margin 

226 

227 def margin_strip(self) -> Quote: 

228 """Return a new Quote with de-margined odds. 

229 

230 Uses the basic proportional method to remove the bookmaker's margin. 

231 

232 Returns: 

233 A new Quote with de-margined odds. 

234 """ 

235 return self.margin_set(target_margin=0.0) 

236 

237 def margin_set(self, target_margin: float) -> Quote: 

238 """Return a new Quote with specified margin. 

239 

240 Uses the basic proportional method to set the bookmaker's margin. 

241 

242 Args: 

243 target_margin: The desired margin as a float (e.g., 0.05 for 5%) 

244 

245 Returns: 

246 A new Quote with the specified margin. 

247 """ 

248 prob_a = self.side_a.odds.implied_probability() 

249 prob_b = self.side_b.odds.implied_probability() 

250 total_prob = prob_a + prob_b 

251 adjusted_total_prob = 1 + target_margin 

252 new_prob_a = (prob_a / total_prob) * adjusted_total_prob 

253 new_prob_b = (prob_b / total_prob) * adjusted_total_prob 

254 new_odds_a = DecimalOdds.from_probability(new_prob_a) 

255 new_odds_b = DecimalOdds.from_probability(new_prob_b) 

256 new_side_a = attrs.evolve(self.side_a, odds=new_odds_a) 

257 new_side_b = attrs.evolve(self.side_b, odds=new_odds_b) 

258 return Quote(side_a=new_side_a, side_b=new_side_b) 

259 

260 def __repr__(self) -> str: 

261 """Return string representation of Quote.""" 

262 margin_pct = self.implied_margin() * 100 

263 if self.market == Market.TOTAL_GOAL: 

264 return ( 

265 f"Quote(market={self.market.value}, line={self.line.value:+.2f}, " 

266 f"[{self.side_a.side.value} @ {self.side_a.odds.value:.3f}, " 

267 f"{self.side_b.side.value} @ {self.side_b.odds.value:.3f}], " 

268 f"margin={margin_pct:.1f}%)" 

269 ) 

270 elif self.market == Market.ASIAN_HCAP: 

271 return ( 

272 f"Quote(market={self.market.value}, " 

273 f"[{self.side_a.side.value}({self.line.value:+.2f}) @ {self.side_a.odds.value:.3f}, " 

274 f"{self.side_b.side.value}({-self.line.value:+.2f}) @ {self.side_b.odds.value:.3f}], " 

275 f"margin={margin_pct:.1f}%)" 

276 ) 

277 else: 

278 raise NotImplementedError(f"Quote representation not implemented for market {self.market}")