implied.py
340de185
 """Tools for dealing with bookmaker's odds and implied probabilities"""
 
ec276364
 from fractions import Fraction
340de185
 from math import sqrt
ec276364
 from typing import Optional, Sequence
340de185
 
 import scipy.optimize
 import toolz
 
 
ec276364
 def overround(p: Sequence[float]) -> float:
     """Return the overround, given a sequence of implied probabilities."""
     return sum(p) - 1
 
 
 def from_american(odds: int) -> float:
     """Return implied probability, given American odds."""
     odds = round(odds)
     if abs(odds) < 100:
         raise ValueError(f"Invalid American odds {odds}")
     if odds > 0:
         return 100 / (100 + odds)
     else:
         odds = -odds
         return odds / (100 + odds)
 
 
 def to_american(p: float, ndigits: Optional[int] = None) -> int:
     """Return American odds, given an implied probability.
 
     Parameters
     ----------
     p
         Implied probability
     ndigits
         The number of digits to which to round the result. The default
         is to round to the nearest integer (American odds are not usually
         quoted to more decimal places), but this may be adjusted if
         more precision is needed (and the result will be a floating
         point number).
     """
     if p < 0.5:
         a = 100 / p - 100
     else:
         a = -(100 * p) / (1 - p)
     return round(a, ndigits)
 
 
 def from_euro(odds: float) -> float:
     """Return implied probability, given Euro/Decimal odds."""
     return 1 / odds
 
 
 def to_euro(p: float) -> float:
     """Return Euro/Decimal odds, given an implied probability."""
     return 1 / p
 
 
 def from_british(odds: Fraction) -> float:
     """Return implied probability, given British odds."""
     return float(1 / (odds + 1))
 
 
 def to_british(p: float, largest_denominator: int = 100) -> Fraction:
     """Return British odds, given an implied probability.
 
     Parameters
     ----------
     p
         Implied probability
     largest_denominator
         The largest denominator to use when representing the result.
         The smaller this number the more accuracy is lost.
     """
     return Fraction(1 / p - 1).limit_denominator(largest_denominator)
 
 
 from_decimal = from_euro
 to_decimal = to_euro
 
 
340de185
 # -- Adjusting implied probabilities to true probabilities
 
 _adjust_methods = {}
 
 
 @toolz.curry
 def add_method(name: str, f):
     _adjust_methods[name] = f
     return f
 
 
 def validate_odds(odds: Sequence[float]) -> None:
     if len(odds) < 2:
         raise ValueError(f"Expected at least 2 outcomes, but got {len(odds)}")
     if any(x >= 1 for x in odds):
         raise ValueError(f"Expected all outcomes to have an implied probability < 1")
     r = overround(odds)
     if r < 0:
         raise ValueError(f"Odds {odds} have a negative overround ({r})")
     elif r > 0.5:
         raise ValueError(f"Odds {odds} have excessive overround ({r})")
 
 
 def adjust_odds(odds: Sequence[float], method="power") -> list[float]:
ec276364
     """Return fair odds given bookmaker odds.
340de185
 
     Parameters
     ----------
     odds
         The odds of each outcome, given as implied probabilites.
 
     The implied probabilities associated with the odds quoted by a bookmaker
     sum to a number > 1. This excess is the "overround" of the odds. There
ec276364
     are a number of models for distributing the overround among the outcomes
     to recover the "fair odds" of each outcome.
340de185
 
     Notes
     -----
     All implemented methods are based on the descriptions in the paper by
     S. Clarke in the references section.
 
     References
     ----------
     S. Clarke, S. Kovalchik, M. Ingram
         Adjusting Bookmaker's odds to Allow for Overround.
     """
     try:
         f = _adjust_methods[method]
     except KeyError:
         raise ValueError(
             f"Unknown method '{method}', expected one of {list(_adjust_methods)}"
ec276364
         ) from None
340de185
     validate_odds(odds)
     return f(odds)
 
 
 @add_method("additive")
 def adjust_odds_additive(odds: Sequence[float]) -> list[float]:
     """Adjust bookmaker odds using the additive method.
 
     In this method the overround is assumed to be split equality between
     the different outcomes, regardless of their relative implied probabilities.
     """
     y = overround(odds) / len(odds)
     return [x - y for x in odds]
 
 
 @add_method("multiplicative")
 def adjust_odds_multiplicative(odds: Sequence[float]) -> list[float]:
     """Adjust bookmaker odds using the multiplicative method.
 
     In this method the overround is assumed to be split among the
     different outcomes in proportion to their implied probabilities.
     """
     s = sum(odds)
     r = overround(odds)
     return [x - (r * x / s) for x in odds]
 
 
 @add_method("power")
 def adjust_odds_power(odds: Sequence[float]) -> list[float]:
     """Adjust bookmaker odds using the power method.
 
     In this method the true probabilities are assumed to be
     related to the implied probabilities by a power law,
     i.e. p_i = π_i^k, for some fixed k. The k is then
     chosen such that the true probabilities sum to 1.
 
     Notes
     -----
     The 'k' parameter for the power method is computed by
     solving the nonlinear equation Σ_i π_i^k = 1 using
     a root-finding algorithm.
 
     References
     ----------
     S.R. Clarke
         Adjusting true odds to allow for vigorish
     """
     # This bracketing interval should be sufficient for reasonable overrounds
     # (not > 50%) and reasonable distribution of implied probability between
     # the different outcomes.
     try:
         k = scipy.optimize.root_scalar(
             lambda k: sum(x ** k for x in odds) - 1,
             bracket=[0.9, 3],
             xtol=1e-14,
         ).root
     except ValueError as e:
         if "different signs" in str(e):
             raise ValueError(f"Bracketing interval too small for odds {odds}") from None
 
     return [x ** k for x in odds]
 
 
 @add_method("shin")
 def adjust_odds_shin(odds):
     """Adjust bookmaker odds using the Shin method.
 
     In this method we assume a fraction z of sharp bettors
     and adjust the true probabilities accordingly.
 
     Notes
     -----
     The 'z' parameter for the Shin method is computed
     by solving a nonlinear equation using a root-finding algorithm.
 
     References
     ----------
     H.S. Shin
         Prices of State Contingent Claims with Insider Traders
         and the Favorite-Longshot Bias
     """
     s = sum(odds)
     n = len(odds)
 
     def residual(z):
         return (
             sum(sqrt(z ** 2 + 4 * (1 - z) * pi ** 2 / s) for pi in odds)
             - 2
             - (n - 2) * z
         )
 
     # 'z' is in the interval [0, 1). We set the upper limit for the
     # bracketing interval to just below 1 to exclude a (spurious) solution at z=1.
     try:
         z = scipy.optimize.root_scalar(residual, bracket=[0, 0.999], xtol=1e-14).root
     except ValueError:
         raise ValueError(f"Shin method failed to converge for odds {odds}") from None
 
     return [
         (sqrt(z ** 2 + 4 * (1 - z) * pi ** 2 / s) - z) / (2 * (1 - z)) for pi in odds
     ]