pyfootix 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
footix/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,58 @@
1
+ import pathlib
2
+
3
+ import pandas as pd
4
+ import requests
5
+
6
+ from footix.data_io.utils_scrapper import MAPPING_COMPETITIONS
7
+
8
+
9
+ class Scraper:
10
+ base_url: str = ""
11
+ scraper_name: str | None = None
12
+
13
+ def __init__(self, path: str, mapping_teams: dict[str, str] | None) -> None:
14
+ self.mapping_teams = mapping_teams
15
+ self.headers = {
16
+ "User-Agent": (
17
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
18
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
19
+ "Chrome/124.0.0.0 Safari/537.36"
20
+ )
21
+ }
22
+ self.path = self.manage_path(path=path)
23
+
24
+ def _check_competitions(self, competition_name: str):
25
+ list_comp = self.competitions()
26
+ if competition_name not in list_comp:
27
+ raise ValueError(f"{competition_name} not available for scraper {self.scraper_name}")
28
+
29
+ @classmethod
30
+ def competitions(cls) -> list[str]:
31
+ if cls.scraper_name is None:
32
+ raise AttributeError("Scraper name undefined")
33
+ list_compet = []
34
+ for compet, info in MAPPING_COMPETITIONS.items():
35
+ if cls.scraper_name in info.keys():
36
+ list_compet.append(compet)
37
+ return list_compet
38
+
39
+ def replace_name_team(self, df: pd.DataFrame, columns: list[str]) -> pd.DataFrame:
40
+ tmp_df = df.copy()
41
+ if self.mapping_teams is not None:
42
+ for column in columns:
43
+ tmp_df[column] = tmp_df[column].replace(self.mapping_teams)
44
+ return tmp_df
45
+
46
+ def get(self, url: str) -> str:
47
+ return requests.get(url, headers=self.headers).content.decode("utf-8-sig")
48
+
49
+ @staticmethod
50
+ def manage_path(path: str) -> pathlib.Path:
51
+ tmp_pth = pathlib.Path(path)
52
+ if tmp_pth.is_file():
53
+ raise ValueError("Path should be a directory")
54
+ if tmp_pth.exists():
55
+ return tmp_pth
56
+ else:
57
+ tmp_pth.mkdir(parents=True, exist_ok=True)
58
+ return tmp_pth
@@ -0,0 +1,85 @@
1
+ import dataclasses
2
+ from typing import Iterator, Protocol
3
+
4
+ import pandas as pd
5
+
6
+
7
+ class DataProtocol(Protocol):
8
+ def __len__(self) -> int:
9
+ ...
10
+
11
+
12
+ @dataclasses.dataclass
13
+ class MatchupResult:
14
+ """A dataclass representing the result of a football match.
15
+
16
+ Attributes:
17
+ home_team (str): The name of the home team.
18
+ away_team (str): The name of the away team.
19
+ result (str): The final result of the match:
20
+ ('H' for Home Win, 'A' for Away Win, 'D' for Draw).
21
+ away_goals (float): The number of goals scored by the away team.
22
+ home_goals (float): The number of goals scored by the home team.
23
+
24
+ """
25
+
26
+ home_team: str
27
+ away_team: str
28
+ result: str
29
+ away_goals: float
30
+ home_goals: float
31
+
32
+ @staticmethod
33
+ def from_dict(dict_row: dict) -> "MatchupResult":
34
+ """Factory method to create a MatchupResult object from a dictionary row.
35
+
36
+ Parameters:
37
+ dict_row (dict): A dictionary containing the match results with keys:
38
+ - 'HomeTeam': The name of the home team.
39
+ - 'AwayTeam': The name of the away team.
40
+ - 'FTR': The final result
41
+ ('H' for Home Win, 'A' for Away Win, 'D' for Draw).
42
+ - 'FTAG': The number of goals scored by the away team.
43
+ - 'FTHG': The number of goals scored by the home team.
44
+ Returns:
45
+ MatchupResult: An instance of the MatchupResult class populated with data
46
+ from the dictionary row.
47
+
48
+ """
49
+ return MatchupResult(
50
+ home_team=dict_row["HomeTeam"],
51
+ away_team=dict_row["AwayTeam"],
52
+ result=dict_row["FTR"],
53
+ away_goals=dict_row["FTAG"],
54
+ home_goals=dict_row["FTHG"],
55
+ )
56
+
57
+
58
+ class EloDataReader(DataProtocol):
59
+ def __init__(self, df_data: pd.DataFrame):
60
+ self.df_data = self._process_df(df_data)
61
+ # Better performances for iteration over rows
62
+ self.data = self.df_data.to_dict(orient="index")
63
+
64
+ # TODO: Add a sanity check to verify if columns are presents in the df
65
+ def _process_df(self, df_data: pd.DataFrame) -> pd.DataFrame:
66
+ df = df_data.copy().reset_index(drop=True)
67
+ df = df[["Date", "HomeTeam", "AwayTeam", "FTHG", "FTAG", "FTR"]]
68
+ df["Date"] = pd.to_datetime(df["Date"], dayfirst=True)
69
+ df = df.sort_values(by="Date", ascending=True)
70
+ return df
71
+
72
+ def __len__(self) -> int:
73
+ return len(self.df_data)
74
+
75
+ def unique_teams(self) -> list[str]:
76
+ list_unique_team = list(
77
+ set(self.df_data["HomeTeam"].unique()).intersection(self.df_data["AwayTeam"].unique())
78
+ )
79
+ return sorted(list_unique_team)
80
+
81
+ def __iter__(self) -> Iterator[MatchupResult]:
82
+ return iter(self.__getitem__(idx) for idx in range(len(self)))
83
+
84
+ def __getitem__(self, idx: int) -> MatchupResult:
85
+ return MatchupResult.from_dict(self.data[idx])
@@ -0,0 +1,126 @@
1
+ import io
2
+
3
+ import pandas as pd
4
+
5
+ import footix.data_io.utils_scrapper as utils_scrapper
6
+ from footix.data_io.base_scrapper import Scraper
7
+
8
+
9
+ class ScrapFootballData(Scraper):
10
+ """Scraper for downloading and processing football match data from football-data.co.uk.
11
+
12
+ This class handles the retrieval, local storage, and preprocessing of football match data
13
+ for a given competition and season. It supports automatic downloading, file management,
14
+ column sanitization, and team name mapping.
15
+
16
+ Args:
17
+ competition (str): The competition code (e.g., 'E0' for Premier League).
18
+ season (str): The season string (e.g., '2020/2021', '2020-2021', or '2021').
19
+ path (str): Directory path to store the downloaded CSV files.
20
+ force_reload (bool, optional): If True, forces re-download of data even if file exists.
21
+ mapping_teams (dict[str, str] | None, optional): Optional mapping for team name
22
+ normalization.
23
+
24
+ Attributes:
25
+ base_url (str): Base URL for football-data.co.uk.
26
+ scraper_name (str): Name identifier for the scraper.
27
+ competition (str): Competition code.
28
+ season (str): Processed season string.
29
+ path (Path): Path object for data storage.
30
+ force_reload (bool): Whether to force data reload.
31
+ infered_url (str): Constructed URL for the CSV file.
32
+ df (pd.DataFrame): Loaded and processed match data.
33
+
34
+ Methods:
35
+ download(): Downloads and saves the competition data as a CSV file.
36
+ load() -> pd.DataFrame: Loads the data from file or downloads if not present.
37
+ sanitize_columns(): Converts DataFrame columns to snake_case.
38
+ get_fixtures() -> pd.DataFrame: Returns the processed match data.
39
+
40
+ """
41
+
42
+ base_url: str = "https://www.football-data.co.uk/mmz4281/"
43
+ scraper_name = "footballdata"
44
+
45
+ def __init__(
46
+ self,
47
+ competition: str,
48
+ season: str,
49
+ path: str,
50
+ force_reload: bool = False,
51
+ mapping_teams: dict[str, str] | None = None,
52
+ ) -> None:
53
+ super().__init__(path=path, mapping_teams=mapping_teams)
54
+ self._check_competitions(competition_name=competition)
55
+ self.competition = competition
56
+ slug = utils_scrapper.MAPPING_COMPETITIONS[self.competition]["footballdata"]["slug"]
57
+ self.season = _process_season(season)
58
+ self.path = self.manage_path(path)
59
+ self.force_reload = force_reload
60
+ self.infered_url = self.base_url + self.season + "/" + slug + ".csv"
61
+ self.df = self.load()
62
+ self.sanitize_columns()
63
+ self.df = utils_scrapper.add_mathc_id(self.df)
64
+
65
+ def download(self):
66
+ response = self.get(self.infered_url)
67
+ df = (
68
+ pd.read_csv(io.StringIO(response), encoding="utf-8")
69
+ .sort_index()
70
+ .pipe(self.replace_name_team, columns=["home_team", "away_team"])
71
+ )
72
+ df.to_csv(
73
+ self.path / (self.competition + "_" + self.season + ".csv"),
74
+ index=False,
75
+ encoding="utf-8",
76
+ )
77
+
78
+ def load(self) -> pd.DataFrame:
79
+ if self._check_if_file_exist() and not self.force_reload:
80
+ df = pd.read_csv(self.path / (self.competition + "_" + self.season + ".csv"))
81
+ else:
82
+ self.download()
83
+ df = pd.read_csv(self.path / (self.competition + "_" + self.season + ".csv"))
84
+ return df
85
+
86
+ def sanitize_columns(self):
87
+ self.df.columns = [utils_scrapper.to_snake_case(x) for x in self.df.columns]
88
+
89
+ def get_fixtures(self) -> pd.DataFrame:
90
+ return self.df
91
+
92
+ def _check_if_file_exist(self) -> bool:
93
+ name = self.competition + "_" + self.season + ".csv"
94
+ expected_file = self.path / name
95
+ if not expected_file.is_file():
96
+ return False
97
+ return True
98
+
99
+
100
+ def _process_season(season: str) -> str:
101
+ """Process a season string to extract a standardized format.
102
+
103
+ Args:
104
+ season (str): A string representing a football season in the format
105
+ 'YYYY/YYYY' or 'YYYY-YYYY'. For example: '2020/2021' or '2020-2021'
106
+
107
+ Raises:
108
+ ValueError: if the season string cannot be split into exactly two years.
109
+
110
+ Returns:
111
+ str: the formatted season string.
112
+
113
+ """
114
+ if len(season) == 4 and season.isdigit():
115
+ if int(season[-2:]) - int(season[:2]) != 1:
116
+ raise ValueError("Years must be consecutive")
117
+ return season
118
+ # Remove any whitespace and split on common separators
119
+ clean_season = season.replace(" ", "-").replace("/", "-").split("-")
120
+ # Extract last 2 digits from each year
121
+ year1 = clean_season[0][-2:]
122
+ year2 = clean_season[-1][-2:]
123
+ # Check if the years are consecutive
124
+ if int(year2) - int(year1) != 1:
125
+ raise ValueError("Years must be consecutive")
126
+ return year1 + year2
@@ -0,0 +1,190 @@
1
+ import json
2
+ import re
3
+ from datetime import datetime
4
+ from functools import lru_cache
5
+ from typing import Any
6
+ from urllib.parse import urljoin
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from lxml import html
11
+
12
+ import footix.data_io.utils_scrapper as utils_scrapper
13
+ from footix.data_io.base_scrapper import Scraper
14
+
15
+
16
+ class ShotDataNotFound(RuntimeError):
17
+ """Raised when the expected shotsData <script> block is not present."""
18
+
19
+
20
+ class FixtureDataNotFound(RuntimeError):
21
+ """Raised when the fixture data are not present."""
22
+
23
+
24
+ class ScrapUnderstat(Scraper):
25
+ """
26
+ Scraper for downloading and processing football match data from understat.com.
27
+ This class function is heavily inspired/copied from its counterpart from penalty blog:
28
+ https://github.com/martineastwood/penaltyblog
29
+
30
+ This class retrieves, parses, and processes football match data for a given competition
31
+ and season from Understat. It extracts fixture details, expected goals (xG), forecasts,
32
+ and normalizes team names. The data is returned as a processed pandas DataFrame.
33
+
34
+ Args:
35
+ competition (str): The competition code (e.g., 'EPL' for Premier League).
36
+ season (str): The season string (e.g., '2020/2021', '2020-2021', or '2021').
37
+ path (str): Directory path for any required file operations.
38
+ force_reload (bool, optional): If True, forces re-download or reprocessing of data.
39
+ mapping_teams (dict[str, str] | None, optional): Optional mapping for team name
40
+ normalization.
41
+
42
+ Attributes:
43
+ base_url (str): Base URL for understat.com.
44
+ scraper_name (str): Name identifier for the scraper.
45
+ season (str): Processed season string.
46
+ force_reload (bool): Whether to force data reload.
47
+ slug (str): Slug for the competition used in URL construction.
48
+
49
+ Methods:
50
+ sanitize_columns(df): Converts DataFrame columns to snake_case.
51
+ get_fixtures() -> pd.DataFrame: Downloads, parses, and returns processed match data.
52
+ _process_season(season: str) -> str: Processes the season string for URL usage.
53
+ """
54
+
55
+ base_url: str = "https://understat.com/"
56
+ scraper_name = "understat"
57
+
58
+ def __init__(
59
+ self,
60
+ competition: str,
61
+ season: str,
62
+ path: str,
63
+ force_reload: bool = False,
64
+ mapping_teams: dict[str, str] | None = None,
65
+ ):
66
+ self._check_competitions(competition_name=competition)
67
+ super().__init__(path=path, mapping_teams=mapping_teams)
68
+ self.season = self._process_season(season)
69
+ self.force_reload = force_reload
70
+ self.slug = utils_scrapper.MAPPING_COMPETITIONS[competition]["understat"]["slug"]
71
+ self.competition = competition
72
+
73
+ @staticmethod
74
+ def sanitize_columns(df: pd.DataFrame):
75
+ df.columns = [utils_scrapper.to_snake_case(x) for x in df.columns]
76
+
77
+ @lru_cache(maxsize=256)
78
+ def get_fixtures(self):
79
+ implied_url = urljoin(self.base_url, f"league/{self.slug}/{self.season}")
80
+ content = self.get(implied_url)
81
+ tree = html.fromstring(content)
82
+ events = None
83
+ for s in tree.cssselect("script"):
84
+ if "datesData" in s.text:
85
+ script = s.text
86
+ script = " ".join(script.split())
87
+ script = str(script.encode(), "unicode-escape")
88
+ script = re.match(r"var datesData = JSON\.parse\('(?P<json>.*?)'\)", script)
89
+ if script is not None:
90
+ script = script.group("json")
91
+ events = json.loads(script)
92
+ break
93
+
94
+ if events is None:
95
+ raise FixtureDataNotFound
96
+
97
+ fixtures = list()
98
+ for e in events:
99
+ if not e["isResult"]:
100
+ continue
101
+
102
+ tmp: dict[str, Any] = dict()
103
+ tmp["understat_id"] = str(e["id"])
104
+ tmp["datetime"] = e["datetime"]
105
+ tmp["home_team"] = e["h"]["title"]
106
+ tmp["away_team"] = e["a"]["title"]
107
+ tmp["fthg"] = int(e["goals"]["h"])
108
+ tmp["ftag"] = int(e["goals"]["a"])
109
+ tmp["fthxg"] = float(e["xG"]["h"])
110
+ tmp["ftaxg"] = float(e["xG"]["a"])
111
+ tmp["forecast_w"] = float(e["forecast"]["w"])
112
+ tmp["forecast_d"] = float(e["forecast"]["d"])
113
+ tmp["forecast_l"] = float(e["forecast"]["l"])
114
+ fixtures.append(tmp)
115
+
116
+ df = (
117
+ pd.DataFrame(fixtures)
118
+ .pipe(self.replace_name_team, columns=["home_team", "away_team"])
119
+ .sort_index()
120
+ )
121
+
122
+ def _get_date(date: str) -> str:
123
+ dt = datetime.strptime(date, "%Y-%m-%d %H:%M:%S")
124
+ return dt.strftime("%d/%m/%Y")
125
+
126
+ def get_ftr(row) -> str:
127
+ fthg = row["fthg"]
128
+ ftag = row["ftag"]
129
+ if fthg > ftag:
130
+ return "H"
131
+ if fthg == ftag:
132
+ return "D"
133
+ return "A"
134
+
135
+ self.sanitize_columns(df)
136
+ df["ftr"] = df.apply(get_ftr, axis=1)
137
+ df["date"] = df["datetime"].apply(_get_date)
138
+ df = utils_scrapper.add_mathc_id(df)
139
+ return df
140
+
141
+ def _process_season(self, season: str) -> str:
142
+ clean_season = season.replace(" ", "-").replace("/", "-").split("-")
143
+ return clean_season[0]
144
+
145
+ @lru_cache(maxsize=256)
146
+ def get_shots(self, understat_id: str) -> pd.DataFrame:
147
+ url = urljoin(self.base_url, f"match/{understat_id}")
148
+ content = self.get(url)
149
+ tree = html.fromstring(content)
150
+ events = None
151
+
152
+ for s in tree.cssselect("script"):
153
+ if "shotsData" in s.text:
154
+ script = s.text
155
+ script = " ".join(script.split())
156
+ script = str(script.encode(), "unicode-escape")
157
+ script = re.match(r"var shotsData = JSON\.parse\('(?P<json>.*?)'\)", script)
158
+ if script is not None:
159
+ script = script.group("json")
160
+ events = json.loads(script)
161
+ break
162
+
163
+ if events is None:
164
+ raise ShotDataNotFound
165
+
166
+ shots = list()
167
+ shots.extend(events["h"])
168
+ shots.extend(events["a"])
169
+
170
+ col_renames = {
171
+ "h_team": "home_team",
172
+ "a_team": "away_team",
173
+ "h_goals": "goals_home",
174
+ "a_goals": "goals_away",
175
+ "match_id": "understat_id",
176
+ }
177
+
178
+ df = (
179
+ pd.DataFrame(shots)
180
+ .rename(columns=col_renames)
181
+ .assign(season=self.season)
182
+ .assign(competition=self.competition)
183
+ .assign(date=lambda x: pd.to_datetime(x["date"]).dt.strftime("%d-%m-%Y"))
184
+ .pipe(self.replace_name_team, columns=["home_team", "away_team"])
185
+ .sort_index()
186
+ )
187
+ df["h_a"] = np.where(df["h_a"] == "h", df["home_team"], df["away_team"])
188
+ df = utils_scrapper.add_mathc_id(df)
189
+ self.sanitize_columns(df)
190
+ return df
@@ -0,0 +1,65 @@
1
+ # Mapping of the different competitions to their respective slugs
2
+ import re
3
+ from typing import Any
4
+
5
+ import pandas as pd
6
+
7
+ from footix.utils.decorators import verify_required_column
8
+
9
+ MAPPING_COMPETITIONS: dict[str, dict[str, Any]] = {
10
+ "FRA Ligue 1": {"footballdata": {"slug": "F1"}, "understat": {"slug": "Ligue_1"}},
11
+ "FRA Ligue 2": {"footballdata": {"slug": "F2"}},
12
+ "ENG Premier League": {"footballdata": {"slug": "E0"}, "understat": {"slug": "EPL"}},
13
+ "ENG Championship": {"footballdata": {"slug": "E1"}},
14
+ "DEU Bundesliga 1": {"footballdata": {"slug": "D1"}, "understat": {"slug": "Bundesliga"}},
15
+ "DEU Bundesliga 2": {"footballdata": {"slug": "D2"}},
16
+ "ITA Serie A": {"footballdata": {"slug": "I1"}, "understat": {"slug": "Serie_A"}},
17
+ "ITA Serie B": {"footballdata": {"slug": "I2"}},
18
+ "SPA La Liga": {"footballdata": {"slug": "SP1"}, "understat": {"slug": "La_Liga"}},
19
+ "SPA La Liga 2": {"footballdata": {"slug": "SP2"}},
20
+ }
21
+
22
+
23
+ def check_competition_exists(competition: str) -> bool:
24
+ """Check if the competition exists in the MAPPING_COMPETITIONS dictionary.
25
+
26
+ Args:
27
+ competition (str): The name of the competition to check.
28
+
29
+ Returns:
30
+ bool: True if the competition exists, False otherwise.
31
+
32
+ """
33
+ return competition in MAPPING_COMPETITIONS
34
+
35
+
36
+ def process_string(input_string):
37
+ lower_string = input_string.lower()
38
+ no_space_string = lower_string.replace(" ", "")
39
+ return no_space_string
40
+
41
+
42
+ def to_snake_case(name: str) -> str:
43
+ """
44
+ Convert the string name into a snake case string.
45
+ Shamelessly copied from:
46
+ https://stackoverflow.com/questions/1175208/
47
+ elegant-python-function-to-convert-camelcase-to-snake-case
48
+
49
+ Args:
50
+ name (str): the name to convert
51
+
52
+ Returns:
53
+ str: the name in snake case
54
+ """
55
+ name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
56
+ name = re.sub("__([A-Z])", r"_\1", name)
57
+ name = re.sub("([a-z0-9])([A-Z])", r"\1_\2", name)
58
+ return name.lower()
59
+
60
+
61
+ @verify_required_column(["home_team", "away_team", "date"])
62
+ def add_mathc_id(df: pd.DataFrame) -> pd.DataFrame:
63
+ tmp_df = df.copy()
64
+ tmp_df["match_id"] = tmp_df["home_team"] + " - " + tmp_df["away_team"] + " - " + tmp_df["date"]
65
+ return tmp_df
@@ -0,0 +1,3 @@
1
+ from .implied import multiplicative, power, shin
2
+
3
+ __all__ = ["multiplicative", "power", "shin"]
@@ -0,0 +1,116 @@
1
+ import operator
2
+ from typing import cast
3
+
4
+ import numpy as np
5
+ from scipy import optimize
6
+
7
+ from footix.utils.typing import ArrayLikeF
8
+
9
+ # Most of those functions are inspired by the awesome package penaltyblog
10
+ # https://github.com/martineastwood/penaltyblog/tree/master
11
+
12
+
13
+ def _assert_odds(odds: ArrayLikeF, axis: None | int = None) -> None:
14
+ if (not isinstance(odds, list)) and (not isinstance(odds, np.ndarray)):
15
+ raise TypeError("Odds must be a list or an numpy array.")
16
+ if isinstance(odds, list):
17
+ odds = np.array(odds)
18
+ if axis is not None:
19
+ if odds.shape[axis] != 3:
20
+ raise ValueError("It is a football package ! You must provide 3 odds.")
21
+ else:
22
+ if odds.shape[0] != 3:
23
+ raise ValueError("It is a football package ! You must provide 3 odds.")
24
+ if (odds < 1.0).any():
25
+ raise ValueError("All odds must be greater then 1.")
26
+
27
+
28
+ def multiplicative(odds: ArrayLikeF, axis: int = -1) -> tuple[np.ndarray, float | np.ndarray]:
29
+ """Multiplicative way to normalize the odds. Work for multidimensionnal array.
30
+
31
+ Args:
32
+ odds (list or np.array): list of odds
33
+ axis (int) : axis where compute the probabilities
34
+
35
+ """
36
+ _assert_odds(odds, axis=axis)
37
+ if isinstance(odds, list):
38
+ odds = np.array(odds)
39
+ if len(odds.shape) > 1:
40
+ normalization = np.sum(1.0 / odds, axis=axis, keepdims=True)
41
+ else:
42
+ normalization = np.sum(1.0 / odds, axis=axis)
43
+ margin = normalization - 1.0
44
+ return 1.0 / (normalization * odds), margin
45
+
46
+
47
+ def power(odds: ArrayLikeF) -> tuple[np.ndarray, float]:
48
+ """From penaltyblog package. The power method computes the implied probabilities by solving
49
+ for the power coefficient that normalizes the inverse of the odds to sum to 1.0.
50
+
51
+ Args:
52
+ odds : (list or np.array): list of odds
53
+
54
+ """
55
+ _assert_odds(odds)
56
+ if isinstance(odds, list):
57
+ odds = np.array(odds)
58
+ inv_odds = 1.0 / odds
59
+ margin = cast(float, np.sum(inv_odds) - 1.0)
60
+
61
+ def _fit(k: float, inv_odds: np.ndarray) -> float:
62
+ implied = operator.pow(inv_odds, k)
63
+ return 1 - np.sum(implied)
64
+
65
+ res = optimize.ridder(_fit, 0, 100, args=(inv_odds,))
66
+ normalized = operator.pow(inv_odds, res)
67
+ return normalized, margin
68
+
69
+
70
+ def shin(odds: ArrayLikeF) -> tuple[np.ndarray, float]:
71
+ """Computes the implied probabilities via Shin's method (1992, 1993).
72
+
73
+ Args:
74
+ odds (list or np.ndarray): An array of size 3 containing the odds for Home victory, draw,
75
+ or Away victory, respectively.
76
+
77
+ Returns:
78
+ tuple:
79
+ - np.ndarray: The implied probabilities for each outcome.
80
+ - float: The margin.
81
+
82
+ """
83
+ _assert_odds(odds)
84
+
85
+ if isinstance(odds, list):
86
+ odds = np.array(odds)
87
+
88
+ inv_odds = 1.0 / odds
89
+ margin = cast(float, np.sum(inv_odds) - 1.0)
90
+
91
+ def _fit(z_param: float, inv_odds: np.ndarray) -> float:
92
+ implied = _shin(z_param, inv_odds)
93
+ return 1.0 - np.sum(implied)
94
+
95
+ res = optimize.ridder(_fit, 0, 100, args=(inv_odds,))
96
+ normalized = _shin(res, inv_odds)
97
+ return normalized, margin
98
+
99
+
100
+ def _shin(z_param: float, inv_odds: np.ndarray) -> np.ndarray:
101
+ """Computes the implied probabilities using Shin's method.
102
+
103
+ Args:
104
+ z_param (float): The Shin adjustment parameter.
105
+ inv_odds (np.ndarray): An array of size 3 containing the inverse odds for Home victory,
106
+ draw, or Away victory, respectively.
107
+
108
+ Returns:
109
+ np.ndarray: The implied probabilities for each outcome.
110
+
111
+ """
112
+ normalized = np.sum(inv_odds)
113
+ implied = (
114
+ np.sqrt(z_param**2 + 4 * (1 - z_param) * inv_odds**2 / normalized) - z_param
115
+ ) / (2 - 2 * z_param)
116
+ return implied
@@ -0,0 +1,3 @@
1
+ from .metrics_function import incertity, rps, zscore
2
+
3
+ __all__ = ["incertity", "rps", "zscore"]