From 4925e449c9342364de3f9badd199ef97ec3bf40f Mon Sep 17 00:00:00 2001 From: Corey Schaf Date: Mon, 10 Jun 2024 21:36:33 -0400 Subject: [PATCH] Feature: Model Pred Endpoints (#2) * Feature: Model Pred Endpoints Adds model.rankings() : Player Rankings Adds model.pre_tournament_pred : Pre Tournament Model Predictions * Feature - Model Prediction Endpoints Adds model.pre_tournament_pred_archive, model.player_skill_decompositions, model.player_skill_ratings, model.detailed_appraoch_skill, model.fantasy_projection. Change: Changes how the query_parameters are passed to the HTTP client and helpers, this is no longer a big ugly string, but a dict. Adds helpers in http.py to build final query parameter * version bump to 0.3.0 --- data_golf/api/general.py | 18 +++-- data_golf/api/model.py | 142 ++++++++++++++++++++++++++++++++++++++ data_golf/client.py | 2 + data_golf/http.py | 30 +++++--- pyproject.toml | 2 +- tests/api/test_general.py | 42 +++++------ tests/api/test_model.py | 86 +++++++++++++++++++++++ 7 files changed, 279 insertions(+), 43 deletions(-) create mode 100644 data_golf/api/model.py create mode 100644 tests/api/test_model.py diff --git a/data_golf/api/general.py b/data_golf/api/general.py index 20195f2..13a8c7a 100644 --- a/data_golf/api/general.py +++ b/data_golf/api/general.py @@ -12,21 +12,25 @@ def player_list(self, format: str = "json") -> List[dict]: """ return self.client.get(resource="/get-player-list", format=format) - def tour_schedule(self, tour: str = "all", format: str = "json") -> dict: + def tour_schedule(self, tour: str = "all", f_format: str = "json") -> dict: """ - + Current season schedule for PGA Tour, European Tour, Korn Ferry Tour, and LIV. :param tour: str optional defaults to 'all', the tour you want the schedule for. values: all, pga, euro, kft, alt, liv - :param format: + :param f_format: :return: """ - return self.client.get(resource=f"/get-schedule?tour={tour}", format=format) + return self.client.get( + resource="/get-schedule", params={"tour": tour}, format=f_format + ) - def field_updates(self, tour: str = None, format: str = "json") -> List[dict]: + def field_updates(self, tour: str = "pga", f_format: str = "json") -> List[dict]: """ Up-to-the-minute field updates on WDs, Monday Qualifiers, tee times, and fantasy salaries for PGA Tour, European Tour, and Korn Ferry Tour events. Includes data golf IDs and tour-specific IDs for each player in the field. :return: """ - q = f"?tour={tour}" if tour else "" - return self.client.get(resource=f"/field-updates{q}", format=format) + + return self.client.get( + resource="/field-updates", params={"tour": tour}, format=f_format + ) diff --git a/data_golf/api/model.py b/data_golf/api/model.py new file mode 100644 index 0000000..806c611 --- /dev/null +++ b/data_golf/api/model.py @@ -0,0 +1,142 @@ +class Model: + def __init__(self, client): + self.client = client + self._path = "/preds" + + def rankings(self, f_format: str = "json") -> dict: + """ + Returns top 500 players according to DG model predictions. + :return: dict + """ + return self.client.get( + resource=f"{self._path}/get-dg-rankings", format=f_format + ) + + def pre_tournament_pred( + self, + tour: str = "pga", + add_position: str = None, + dead_heat: bool = True, + odds_format: str = "percent", + f_format: str = "json", + ) -> dict: + """ + + :param tour: pga, euro, kft, opp, alt + :param add_position: 1, 2, 3 (csv separated values) + :param dead_heat: bool - Adjust odds for dead-heat rules. + :param odds_format: percent (default), american, decimal, fraction + :param f_format: json (default) + :return: + """ + query_p = {"tour": tour} + if add_position: + query_p["add_position"] = add_position + + query_p["dead_heat"] = "yes" if dead_heat else "no" + query_p["odds_format"] = odds_format + + return self.client.get( + resource=f"{self._path}/pre-tournament?", params=query_p, format=f_format + ) + + def pre_tournament_pred_archive( + self, + event_id: str = None, + year: str = None, + odd_format: str = "percent", + f_format="json", + ) -> dict: + """ + Returns pre-tournament predictions for a specific event or year. + :param event_id: The event id for the tournament. + :param year: The year for the tournament. + :param odd_format: percent (default), american, decimal, fraction + :param f_format: json (default) + :return: dict + """ + + query_p = {} + + if event_id: + query_p["event_id"] = event_id + if year: + query_p["year"] = year + query_p["odds_format"] = odd_format + query_p["file_format"] = f_format + + return self.client.get( + resource=f"{self._path}/pre-tournament-archive", + params=query_p, + format=f_format, + ) + + def player_skill_decompositions( + self, tour: str = "pga", f_format: str = "json" + ) -> dict: + """ + Returns player skill decompositions for a specific tour. + :param tour: pga, euro, kft, opp, alt + :param f_format: json (default) + :return: dict + """ + query_p = {"tour": tour} + return self.client.get( + resource=f"{self._path}/player-decompositions", + params=query_p, + format=f_format, + ) + + def player_skill_ratings( + self, display: str = "value", f_format: str = "json" + ) -> dict: + """ + Returns our estimate and rank for each skill for all players with sufficient Shotlink measured rounds (at least 30 rounds in the last year or 50 in the last 2 years). + :param display: value, rank + :param f_format: json (default) + :return: dict + """ + query_p = {"display": display} + return self.client.get( + resource=f"{self._path}/skill-ratings", + params=query_p, + format=f_format, + ) + + def detailed_approach_skill( + self, period: str = "l24", f_format: str = "json" + ) -> dict: + """ + Returns detailed player-level approach performance stats (strokes-gained per shot, proximity, GIR, good shot rate, poor shot avoidance rate) across various yardage/lie buckets. + :param period: l24 (last 24 months) (default), l12 (last 12 months), ytd (year to date) + :param f_format: json (default) + :return: dict + """ + query_p = {"period": period} + return self.client.get( + resource=f"{self._path}/approach-skill", + params=query_p, + format=f_format, + ) + + def fantasy_projection( + self, + tour: str = "pga", + slate: str = "main", + site: str = "draftkings", + f_format: str = "json", + ) -> dict: + """ + Returns our fantasy projections for a specific tour and slate. + :param tour: pga (default), euro, opp (opposite field PGA TOUR event), alt + :param slate: main (default), showdown, showdown_late, weekend, captain + :param site: draftkings (default), fanduel, yahoo + :param f_format: json (default) + :return: dict + """ + query_p = {"tour": tour, "slate": slate, "site": site} + return self.client.get( + resource=f"{self._path}/fantasy-projection-defaults", + params=query_p, + format=f_format, + ) diff --git a/data_golf/client.py b/data_golf/client.py index e3223e3..d8ba7de 100644 --- a/data_golf/client.py +++ b/data_golf/client.py @@ -1,3 +1,4 @@ +from data_golf.api.model import Model from data_golf.config import DGConfig from data_golf.http import HttpClient from data_golf.api.general import General @@ -24,6 +25,7 @@ def __init__( # Endpoints self.general = General(self._http_client) + self.model = Model(self._http_client) def _validate_api_key(self, api_key: str) -> None: """ diff --git a/data_golf/http.py b/data_golf/http.py index 0d9200d..4a80570 100644 --- a/data_golf/http.py +++ b/data_golf/http.py @@ -1,3 +1,5 @@ +from typing import Tuple + from data_golf.request_helpers import RequestHelpers import httpx @@ -10,26 +12,29 @@ def __init__(self, config) -> None: if self._config.verbose: logging.basicConfig(level=logging.INFO) - def _build_url(self, resource: str, format: str): + def _build_request( + self, resource: str, query_params: dict, format: str + ) -> Tuple[str, dict]: """ Private method to build the URL for the Data Golf API. :param resource: :param format: :return: """ - params = [f"key={self._config.api_key}", f"file_format={format}"] - url = "" + query_params["key"] = self._config.api_key + query_params["file_format"] = format + + url = f"{self._config.base_url}{resource}?" - if len(resource.split("?")) > 1: - url = f"{self._config.base_url}{resource}&{'&'.join(params)}" - else: - url = f"{self._config.base_url}{resource}?{'&'.join(params)}" - return url + return url, query_params @RequestHelpers.prepare_request - def get(self, resource: str, format: str = "json", **kwargs) -> httpx.request: + def get( + self, resource: str, params: dict = None, format: str = "json", **kwargs + ) -> httpx.request: """ Private method to make a get request to the Data Golf API. This wraps the lib httpx functionality. + :param params: :param format: :param resource: :return: @@ -37,8 +42,13 @@ def get(self, resource: str, format: str = "json", **kwargs) -> httpx.request: with httpx.Client( verify=self._config.ssl_verify, timeout=self._config.timeout ) as client: + url, q = self._build_request( + resource=resource, query_params=params if params else {}, format=format + ) r: httpx.request = client.get( - url=self._build_url(resource, format), **kwargs + url=url, + params=q, + **kwargs, ) if self._config.verbose: diff --git a/pyproject.toml b/pyproject.toml index b9c4a2b..1fd8c1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "data_golf" -version = "0.2.1" +version = "0.3.0" description = "API Wrapper for Data golf endpoints" authors = ["Corey Schaf "] readme = "README.md" diff --git a/tests/api/test_general.py b/tests/api/test_general.py index fe83ec8..527f81d 100644 --- a/tests/api/test_general.py +++ b/tests/api/test_general.py @@ -14,11 +14,8 @@ def test_request_gets_json_header(d_m, dg_client): def test_api_key_appends_to_request(d_m, dg_client): dg_client.general.player_list() d_m.assert_called_once() - assert ( - d_m.call_args[1]["url"] - == "https://feeds.datagolf.com/get-player-list?key=test_key&file_format=json" - ) - assert "key=test_key" in d_m.call_args[1]["url"] + assert "https://feeds.datagolf.com/get-player-list?" in d_m.call_args[1]["url"] + assert d_m.call_args[1]["params"]["key"] == "test_key" def test_request_will_err_on_csv_format(dg_client): @@ -30,47 +27,42 @@ def test_request_will_err_on_csv_format(dg_client): def test_player_list(d_m, dg_client): dg_client.general.player_list() d_m.assert_called_once() - assert ( - d_m.call_args[1]["url"] - == "https://feeds.datagolf.com/get-player-list?key=test_key&file_format=json" - ) + assert "https://feeds.datagolf.com/get-player-list?" in d_m.call_args[1]["url"] + assert d_m.call_args[1]["params"]["key"] == "test_key" @mock.patch("httpx.Client.get") def test_tour_schedule(d_m, dg_client): dg_client.general.tour_schedule() d_m.assert_called_once() - assert ( - d_m.call_args[1]["url"] - == "https://feeds.datagolf.com/get-schedule?tour=all&key=test_key&file_format=json" - ) + assert "https://feeds.datagolf.com/get-schedule?" in d_m.call_args[1]["url"] + + assert d_m.call_args[1]["params"]["tour"] == "all" + assert d_m.call_args[1]["params"]["key"] == "test_key" @mock.patch("httpx.Client.get") def test_tour_schedule_for_tour(d_m, dg_client): dg_client.general.tour_schedule(tour="kft") d_m.assert_called_once() - assert ( - d_m.call_args[1]["url"] - == "https://feeds.datagolf.com/get-schedule?tour=kft&key=test_key&file_format=json" - ) + assert "https://feeds.datagolf.com/get-schedule?" in d_m.call_args[1]["url"] + assert d_m.call_args[1]["params"]["tour"] == "kft" + assert d_m.call_args[1]["params"]["key"] == "test_key" @mock.patch("httpx.Client.get") def test_field_updates(d_m, dg_client): dg_client.general.field_updates() d_m.assert_called_once() - assert ( - d_m.call_args[1]["url"] - == "https://feeds.datagolf.com/field-updates?key=test_key&file_format=json" - ) + assert "https://feeds.datagolf.com/field-updates?" in d_m.call_args[1]["url"] + assert d_m.call_args[1]["params"]["key"] == "test_key" @mock.patch("httpx.Client.get") def test_field_updates_with_tour_euro(d_m, dg_client): dg_client.general.field_updates(tour="euro") d_m.assert_called_once() - assert ( - d_m.call_args[1]["url"] - == "https://feeds.datagolf.com/field-updates?tour=euro&key=test_key&file_format=json" - ) + assert "https://feeds.datagolf.com/field-updates?" in d_m.call_args[1]["url"] + + assert d_m.call_args[1]["params"]["key"] == "test_key" + assert d_m.call_args[1]["params"]["tour"] == "euro" diff --git a/tests/api/test_model.py b/tests/api/test_model.py new file mode 100644 index 0000000..6748aa4 --- /dev/null +++ b/tests/api/test_model.py @@ -0,0 +1,86 @@ +from unittest import mock + + +@mock.patch("httpx.Client.get") +def test_rankings(d_m, dg_client): + dg_client.model.rankings() + d_m.assert_called_once() + assert ( + "https://feeds.datagolf.com/preds/get-dg-rankings?" in d_m.call_args[1]["url"] + ) + + assert d_m.call_args[1]["params"]["key"] == "test_key" + + +@mock.patch("httpx.Client.get") +def test_pre_tournament_pred(d_m, dg_client): + dg_client.model.pre_tournament_pred() + d_m.assert_called_once() + assert "https://feeds.datagolf.com/preds/pre-tournament?" in d_m.call_args[1]["url"] + + assert d_m.call_args[1]["params"]["key"] == "test_key" + assert d_m.call_args[1]["params"]["tour"] == "pga" + assert d_m.call_args[1]["params"]["odds_format"] == "percent" + assert d_m.call_args[1]["params"]["dead_heat"] == "yes" + + +@mock.patch("httpx.Client.get") +def test_pre_tournament_with_params(d_m, dg_client): + dg_client.model.pre_tournament_pred( + tour="euro", add_position="1,2,3", dead_heat=False, odds_format="american" + ) + d_m.assert_called_once() + assert "https://feeds.datagolf.com/preds/pre-tournament?" in d_m.call_args[1]["url"] + + assert d_m.call_args[1]["params"]["key"] == "test_key" + assert d_m.call_args[1]["params"]["tour"] == "euro" + assert d_m.call_args[1]["params"]["add_position"] == "1,2,3" + assert d_m.call_args[1]["params"]["dead_heat"] == "no" + assert d_m.call_args[1]["params"]["odds_format"] == "american" + + +@mock.patch("httpx.Client.get") +def test_pre_tournament_pred_archive(d_m, dg_client): + dg_client.model.pre_tournament_pred_archive(event_id="100") + d_m.assert_called_once() + assert ( + "https://feeds.datagolf.com/preds/pre-tournament-archive?" + in d_m.call_args[1]["url"] + ) + + assert d_m.call_args[1]["params"]["key"] == "test_key" + assert d_m.call_args[1]["params"]["odds_format"] == "percent" + assert d_m.call_args[1]["params"]["event_id"] == "100" + + +@mock.patch("httpx.Client.get") +def test_player_skill_decomp(d_m, dg_client): + dg_client.model.player_skill_decompositions(tour="alt") + d_m.assert_called_once() + assert ( + "https://feeds.datagolf.com/preds/player-decompositions?" + in d_m.call_args[1]["url"] + ) + + assert d_m.call_args[1]["params"]["key"] == "test_key" + assert d_m.call_args[1]["params"]["tour"] == "alt" + + +@mock.patch("httpx.Client.get") +def test_player_skill_ratings(d_m, dg_client): + dg_client.model.player_skill_ratings() + d_m.assert_called_once() + assert "https://feeds.datagolf.com/preds/skill-ratings?" in d_m.call_args[1]["url"] + + assert d_m.call_args[1]["params"]["key"] == "test_key" + assert d_m.call_args[1]["params"]["display"] == "value" + + +@mock.patch("httpx.Client.get") +def test_detailed_approach_skill(d_m, dg_client): + dg_client.model.detailed_approach_skill() + d_m.assert_called_once() + assert "https://feeds.datagolf.com/preds/approach-skill?" in d_m.call_args[1]["url"] + + assert d_m.call_args[1]["params"]["key"] == "test_key" + assert d_m.call_args[1]["params"]["period"] == "l24"