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
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-06 21:54 +0000
1"""Market classes for betting quotes and odds."""
3import abc
4from enum import Enum
6import attrs
7import numpy as np
10class Market(Enum):
11 """Represents the different markets for betting quotes."""
13 ASIAN_HCAP = "AsianHandicap"
14 TOTAL_GOAL = "TotalGoals"
15 MATCH_RSLT = "Match Result"
18class BetState(Enum):
19 """Represents the side of a betting quote."""
21 OVER = "Over"
22 UNDR = "Under"
23 HOME = "Home"
24 AWAY = "Away"
25 PUSH = "Push"
28VALID_SIDES = {
29 Market.TOTAL_GOAL: (BetState.OVER, BetState.UNDR),
30 Market.ASIAN_HCAP: (BetState.HOME, BetState.AWAY),
31}
34@attrs.define
35class QuotedLine:
36 """Represents a betting line value with quarter-point precision.
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
43 Attributes:
44 value: The line value, must be a multiple of 0.25.
45 """
47 value: float = attrs.field(init=True, repr=True)
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}")
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)
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)
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)
68 def neighbours(self) -> tuple[float, float]:
69 """Get the neighbours for quarter line splitting.
71 Quarter lines are split between two adjacent half-lines.
72 For other lines, both neighbours are the same value.
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
85@attrs.define
86class QuotedOdds(abc.ABC):
87 """Abstract base class for betting odds.
89 This class serves as a base for different odds representations (e.g., DecimalOdds).
90 Subclasses must implement the implied_probability method.
92 Attributes:
93 value: The odds value, typically a float representing the odds ratio.
94 """
96 value: float = attrs.field(init=True, repr=True)
98 @abc.abstractmethod
99 def implied_probability(self) -> float:
100 """Calculate the implied probability from the odds.
102 Returns:
103 The implied probability as a float.
104 """
105 raise NotImplementedError("Subclasses must implement the implied_probability method.") # pragma: no cover
108@attrs.define
109class DecimalOdds(QuotedOdds):
110 """Decimal odds representation.
112 Attributes:
113 value: The odds value, typically a float representing the odds ratio.
114 """
116 def implied_probability(self) -> float:
117 """Calculate the implied probability from the decimal odds.
119 Returns:
120 The implied probability as a float.
121 """
122 return 1 / self.value
124 @classmethod
125 def from_probability(cls, prob: float) -> DecimalOdds:
126 """Create DecimalOdds from an implied probability.
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)
136@attrs.frozen
137class QuoteLeg:
138 """Represents a price quote for a betting outcome.
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 """
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)
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}'")
158@attrs.frozen
159class Quote:
160 """Represents a pair of complementary price quotes.
162 This class is used to group together two price quotes that are
163 mutually exclusive (e.g., Home Win and Away Win).
165 Attributes:
166 side_a: The first price quote.
167 side_b: The second price quote.
168 """
170 side_a: QuoteLeg = attrs.field(init=True, repr=True)
171 side_b: QuoteLeg = attrs.field(init=True, repr=True)
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.")
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.
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)
204 @property
205 def market(self) -> Market:
206 """Get the market type of the quote pair."""
207 return self.side_a.market
209 @property
210 def line(self) -> QuotedLine:
211 """Get the line value of the quote pair."""
212 return self.side_a.line
214 def implied_margin(self) -> float:
215 """Calculate the bookmaker's margin for the quote pair.
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
227 def margin_strip(self) -> Quote:
228 """Return a new Quote with de-margined odds.
230 Uses the basic proportional method to remove the bookmaker's margin.
232 Returns:
233 A new Quote with de-margined odds.
234 """
235 return self.margin_set(target_margin=0.0)
237 def margin_set(self, target_margin: float) -> Quote:
238 """Return a new Quote with specified margin.
240 Uses the basic proportional method to set the bookmaker's margin.
242 Args:
243 target_margin: The desired margin as a float (e.g., 0.05 for 5%)
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)
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}")