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 +0 -0
- footix/data_io/__init__.py +0 -0
- footix/data_io/base_scrapper.py +58 -0
- footix/data_io/data_reader.py +85 -0
- footix/data_io/footballdata.py +126 -0
- footix/data_io/understat.py +190 -0
- footix/data_io/utils_scrapper.py +65 -0
- footix/implied_odds/__init__.py +3 -0
- footix/implied_odds/implied.py +116 -0
- footix/metrics/__init__.py +3 -0
- footix/metrics/metrics_function.py +121 -0
- footix/models/__init__.py +0 -0
- footix/models/basic_poisson.py +99 -0
- footix/models/bayes_xg.py +427 -0
- footix/models/bayesian.py +175 -0
- footix/models/dixon_coles.py +133 -0
- footix/models/elo.py +119 -0
- footix/models/hybrid_model.py +222 -0
- footix/models/score_matrix.py +110 -0
- footix/models/team_elo.py +25 -0
- footix/models/utils.py +150 -0
- footix/strategy/__init__.py +0 -0
- footix/strategy/_utils.py +111 -0
- footix/strategy/bets.py +158 -0
- footix/strategy/kelly_strategies.py +472 -0
- footix/strategy/portfolio_management.py +236 -0
- footix/strategy/select_bets.py +201 -0
- footix/strategy/simple.py +11 -0
- footix/utils/__init__.py +0 -0
- footix/utils/decorators.py +28 -0
- footix/utils/typing.py +43 -0
- footix/utils/utils.py +100 -0
- pyfootix-0.1.0.dist-info/LICENSE +21 -0
- pyfootix-0.1.0.dist-info/METADATA +50 -0
- pyfootix-0.1.0.dist-info/RECORD +36 -0
- pyfootix-0.1.0.dist-info/WHEEL +4 -0
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,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
|