fanduelapi 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.
fanduelapi/__init__.py ADDED
@@ -0,0 +1,202 @@
1
+ """Python client for FanDuel API (https://fanduelapi.com).
2
+
3
+ FanDuel API is an independent REST API for FanDuel Sportsbook data: player props, game lines,
4
+ alternates, futures, DFS salaries and the history of every line move. It is operated by
5
+ Stunning Couches LLC and is not affiliated with, endorsed by or sponsored by FanDuel.
6
+
7
+ No dependencies beyond the standard library.
8
+
9
+ from fanduelapi import FanDuelAPI
10
+ fd = FanDuelAPI() # reads FANDUEL_API_KEY
11
+ for p in fd.props(league="nba", stat="points", limit=20):
12
+ print(p["player"], p["line"], p["over_american"], p["under_american"])
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import time
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+ from typing import Any, Dict, Iterator, List, Optional
24
+
25
+ __all__ = ["FanDuelAPI", "FanDuelAPIError", "sample_props", "credit_costs", "__version__"]
26
+ __version__ = "0.1.0"
27
+
28
+ DEFAULT_BASE_URL = "https://fanduelapi.com"
29
+ USER_AGENT = f"fanduelapi-python/{__version__}"
30
+
31
+
32
+ class FanDuelAPIError(Exception):
33
+ """A non-2xx response. `status`, `code` and `body` carry the API's error object."""
34
+
35
+ def __init__(self, status: int, code: str, message: str, body: Optional[dict] = None):
36
+ super().__init__(f"{status} {code}: {message}")
37
+ self.status = status
38
+ self.code = code
39
+ self.message = message
40
+ self.body = body or {}
41
+
42
+ @property
43
+ def upgrade_url(self) -> Optional[str]:
44
+ return (self.body.get("error") or {}).get("upgrade_url")
45
+
46
+ @property
47
+ def topup_url(self) -> Optional[str]:
48
+ return (self.body.get("error") or {}).get("topup_url")
49
+
50
+
51
+ def _request(method: str, url: str, headers: Dict[str, str], body: Optional[dict], timeout: float):
52
+ data = json.dumps(body).encode() if body is not None else None
53
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
54
+ try:
55
+ with urllib.request.urlopen(req, timeout=timeout) as res:
56
+ raw = res.read().decode() or "{}"
57
+ return res.status, dict(res.headers), json.loads(raw)
58
+ except urllib.error.HTTPError as e:
59
+ raw = e.read().decode() or "{}"
60
+ try:
61
+ parsed = json.loads(raw)
62
+ except ValueError:
63
+ parsed = {"error": {"code": "HTTP_ERROR", "message": raw[:200]}}
64
+ return e.code, dict(e.headers), parsed
65
+
66
+
67
+ class FanDuelAPI:
68
+ """Client for the REST API at https://fanduelapi.com/api/v1.
69
+
70
+ Every method returns the response's `data` list (or object), except `request`, which returns
71
+ the whole body. On HTTP 429 the client waits for Retry-After and tries again, up to
72
+ `max_retries` times; a monthly quota stop (QUOTA_EXCEEDED) is raised at once, because waiting
73
+ does not help until the reset.
74
+ """
75
+
76
+ def __init__(self, api_key: Optional[str] = None, base_url: str = DEFAULT_BASE_URL, timeout: float = 30.0, max_retries: int = 3):
77
+ self.api_key = api_key if api_key is not None else os.environ.get("FANDUEL_API_KEY")
78
+ self.base_url = base_url.rstrip("/")
79
+ self.timeout = timeout
80
+ self.max_retries = max_retries
81
+ self.last_headers: Dict[str, str] = {}
82
+
83
+ # ---------------- transport ----------------
84
+ def request(self, method: str, path: str, params: Optional[Dict[str, Any]] = None, body: Optional[dict] = None) -> dict:
85
+ query = {k: v for k, v in (params or {}).items() if v is not None}
86
+ url = f"{self.base_url}{path}" + (f"?{urllib.parse.urlencode(query, doseq=True)}" if query else "")
87
+ headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
88
+ if body is not None:
89
+ headers["Content-Type"] = "application/json"
90
+ if self.api_key:
91
+ headers["Authorization"] = f"Bearer {self.api_key}"
92
+ attempt = 0
93
+ while True:
94
+ status, resp_headers, parsed = _request(method, url, headers, body, self.timeout)
95
+ self.last_headers = {k.lower(): v for k, v in resp_headers.items()}
96
+ if 200 <= status < 300:
97
+ return parsed
98
+ err = parsed.get("error") if isinstance(parsed, dict) else None
99
+ code = (err or {}).get("code", "HTTP_ERROR")
100
+ if status == 429 and code != "QUOTA_EXCEEDED" and attempt < self.max_retries:
101
+ attempt += 1
102
+ time.sleep(float(self.last_headers.get("retry-after", "5")))
103
+ continue
104
+ raise FanDuelAPIError(status, code, (err or {}).get("message", f"HTTP {status}"), parsed if isinstance(parsed, dict) else None)
105
+
106
+ def _get(self, path: str, **params: Any) -> Any:
107
+ return self.request("GET", f"/api/v1{path}", params).get("data")
108
+
109
+ # ---------------- data ----------------
110
+ def props(self, league: Optional[str] = None, stat: Optional[str] = None, player: Optional[str] = None, event_id: Optional[str] = None,
111
+ view: Optional[str] = None, updated_since: Optional[str] = None, limit: Optional[int] = None, offset: Optional[int] = None, **extra: Any) -> List[dict]:
112
+ """Current FanDuel player props. view="compact" returns up to 1,000 rows in one call, without the move summary."""
113
+ return self._get("/props", league=league, stat=stat, player=player, event_id=event_id, view=view, updated_since=updated_since, limit=limit, offset=offset, **extra)
114
+
115
+ def prop_history(self, prop_id: str) -> Any:
116
+ """Every recorded state of one prop: each line move, price move and status change."""
117
+ return self._get(f"/props/{urllib.parse.quote(prop_id, safe='')}/history")
118
+
119
+ def changes(self, since: Optional[str] = None, league: Optional[str] = None, limit: Optional[int] = None) -> dict:
120
+ """One page of the change feed. Returns the whole body: `data` and `meta.next_cursor`."""
121
+ return self.request("GET", "/api/v1/changes", {"since": since, "league": league, "limit": limit})
122
+
123
+ def follow_changes(self, since: Optional[str] = None, league: Optional[str] = None, poll_seconds: Optional[float] = None) -> Iterator[dict]:
124
+ """Yield every change forever, sleeping between empty pages for X-Next-Update-Seconds (or poll_seconds)."""
125
+ cursor = since
126
+ while True:
127
+ page = self.changes(since=cursor, league=league)
128
+ for change in page.get("data") or []:
129
+ yield change
130
+ cursor = (page.get("meta") or {}).get("next_cursor") or cursor
131
+ if not page.get("data"):
132
+ time.sleep(poll_seconds if poll_seconds is not None else float(self.last_headers.get("x-next-update-seconds", "30")))
133
+
134
+ def odds(self, league: Optional[str] = None, date: Optional[str] = None, market_type: Optional[str] = None, **extra: Any) -> Any:
135
+ """The odds board: every game of a league with its game lines."""
136
+ return self._get("/odds", league=league, date=date, market_type=market_type, **extra)
137
+
138
+ def events(self, league: Optional[str] = None, **extra: Any) -> List[dict]:
139
+ return self._get("/events", league=league, **extra)
140
+
141
+ def markets(self, **params: Any) -> List[dict]:
142
+ return self._get("/markets", **params)
143
+
144
+ def game_report(self, event_id: str) -> Any:
145
+ return self._get(f"/games/{urllib.parse.quote(event_id, safe='')}/report")
146
+
147
+ def futures(self, league: Optional[str] = None, **extra: Any) -> List[dict]:
148
+ return self._get("/futures", league=league, **extra)
149
+
150
+ def compare(self, **params: Any) -> List[dict]:
151
+ """FanDuel main line against its own alternate ladder, and against Novig, DraftKings and PrizePicks (view="books")."""
152
+ return self._get("/compare", **params)
153
+
154
+ def ev(self, **params: Any) -> List[dict]:
155
+ """FanDuel prices above the Novig fair price, with EV, edge and Kelly. min_edge is in percent."""
156
+ return self._get("/ev", **params)
157
+
158
+ def arbitrage(self, **params: Any) -> List[dict]:
159
+ return self._get("/arbitrage", **params)
160
+
161
+ def results(self, league: Optional[str] = None, stat: Optional[str] = None, player: Optional[str] = None, **extra: Any) -> List[dict]:
162
+ """Graded props: closing line, actual stat and result (paid plans)."""
163
+ return self._get("/results", league=league, stat=stat, player=player, **extra)
164
+
165
+ def hit_rates(self, player: str, stat: str, league: Optional[str] = None, games: Optional[int] = None) -> Any:
166
+ return self._get("/hit-rates", league=league, player=player, stat=stat, games=games)
167
+
168
+ def training_set(self, **params: Any) -> List[dict]:
169
+ return self._get("/training-set", **params)
170
+
171
+ def dfs_slates(self, sport: Optional[str] = None, **extra: Any) -> List[dict]:
172
+ return self._get("/dfs/slates", sport=sport, **extra)
173
+
174
+ def dfs_players(self, slate_id: str, **params: Any) -> List[dict]:
175
+ return self._get(f"/dfs/slates/{urllib.parse.quote(slate_id, safe='')}/players", **params)
176
+
177
+ def injuries(self, league: Optional[str] = None, **extra: Any) -> List[dict]:
178
+ return self._get("/injuries", league=league, **extra)
179
+
180
+ def coverage(self) -> Any:
181
+ return self._get("/coverage")
182
+
183
+ def credits_used(self) -> Dict[str, str]:
184
+ """X-Credits-* headers of the last response."""
185
+ return {k: v for k, v in self.last_headers.items() if k.startswith("x-credits")}
186
+
187
+
188
+ def credit_costs(path: Optional[str] = None, base_url: str = DEFAULT_BASE_URL) -> Any:
189
+ """The credit cost table, or with `path` the quote for that one call ({"path", "credits"}). No key needed."""
190
+ body = FanDuelAPI(api_key="", base_url=base_url).request("GET", "/api/v1/credit-costs", {"path": path})
191
+ return (body.get("meta") or {}).get("quote") if path else body.get("data")
192
+
193
+
194
+ def sample_props(sport: str = "nba", base_url: str = DEFAULT_BASE_URL, timeout: float = 30.0) -> List[dict]:
195
+ """Up to 10 real FanDuel props, delayed like the Free plan, from the keyless MCP tool get_sample_props. No key needed."""
196
+ body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "get_sample_props", "arguments": {"sport": sport}}}
197
+ headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", "User-Agent": USER_AGENT}
198
+ status, _, parsed = _request("POST", f"{base_url.rstrip('/')}/api/mcp", headers, body, timeout)
199
+ if status != 200 or "result" not in parsed:
200
+ raise FanDuelAPIError(status, "MCP_ERROR", json.dumps(parsed.get("error", parsed))[:200], parsed)
201
+ text = parsed["result"]["content"][0]["text"]
202
+ return json.loads(text).get("data", [])
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.5
2
+ Name: fanduelapi
3
+ Version: 0.1.0
4
+ Summary: Python client for FanDuel API: FanDuel Sportsbook player props, odds and line movement history as JSON.
5
+ Project-URL: Repository, https://github.com/fanduelapi/fanduelapi-python
6
+ Project-URL: Issues, https://github.com/fanduelapi/fanduelapi-python/issues
7
+ Project-URL: Homepage, https://fanduelapi.com
8
+ Project-URL: Documentation, https://fanduelapi.com/docs
9
+ Project-URL: Pricing, https://fanduelapi.com/pricing
10
+ Author-email: Stunning Couches LLC <support@fanduelapi.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: fanduel,line movement,odds,odds api,player props,sports betting,sportsbook
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+
22
+ # fanduelapi
23
+
24
+ Python client for [FanDuel API](https://fanduelapi.com): FanDuel Sportsbook player props, game lines, alternates, futures, DFS salaries and the history of every line move, as JSON. Standard library only, Python 3.8+.
25
+
26
+ FanDuel API is an independent data service operated by Stunning Couches LLC. It is not affiliated with, endorsed by or sponsored by FanDuel.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install fanduelapi
32
+ ```
33
+
34
+ ## Try it with no key
35
+
36
+ Two helpers need no account: a small delayed sample of real props, and the credit cost of any call.
37
+
38
+ ```python
39
+ import fanduelapi
40
+
41
+ for p in fanduelapi.sample_props("nba")[:5]:
42
+ print(f"{p['player']:<22} {p['stat']:<9} {p['line']:>5} over {p['over_american']:>5} under {p['under_american']:>5}")
43
+
44
+ print(fanduelapi.credit_costs("/api/v1/props?league=nba&limit=200"))
45
+ ```
46
+
47
+ Output on 15 November 2026 (the sample is delayed like the Free plan, so yours will differ):
48
+
49
+ ```
50
+ Aaron Nesmith assists 1.5 over 108 under -144
51
+ Aaron Nesmith pa 14.5 over -114 under -114
52
+ Aaron Nesmith points 13.5 over 102 under -136
53
+ Aaron Nesmith pr 16.5 over -118 under -112
54
+ Aaron Nesmith pra 18.5 over -104 under -128
55
+ {'path': '/api/v1/props?league=nba&limit=200', 'credits': 6}
56
+ ```
57
+
58
+ ## With a key
59
+
60
+ Get a free key (no card) at https://fanduelapi.com/sign-up and export it:
61
+
62
+ ```bash
63
+ export FANDUEL_API_KEY="fd_..."
64
+ ```
65
+
66
+ ```python
67
+ from fanduelapi import FanDuelAPI
68
+
69
+ fd = FanDuelAPI() # reads FANDUEL_API_KEY
70
+
71
+ # Every NBA points prop, with where it opened and how often it moved
72
+ for p in fd.props(league="nba", stat="points", limit=50):
73
+ m = p["move"]
74
+ print(p["player"], p["line"], p["over_american"], p["under_american"],
75
+ "opened", m["open"]["line"], "line moves", m["line_moves"])
76
+
77
+ # The whole board in one call (compact rows, 1 credit per 100 rows)
78
+ board = fd.props(league="nfl", view="compact", limit=1000)
79
+
80
+ # Every recorded move of one prop
81
+ history = fd.prop_history(board[0]["id"])
82
+
83
+ # Game lines for every game of a league
84
+ games = fd.odds(league="nhl")
85
+
86
+ # FanDuel prices above the exchange fair price (min_edge is in percent)
87
+ value = fd.ev(league="nba", min_edge=2)
88
+ ```
89
+
90
+ ### Follow line moves
91
+
92
+ `follow_changes` reads the change feed with a cursor and sleeps between empty pages for as long as the API's `X-Next-Update-Seconds` header says:
93
+
94
+ ```python
95
+ for c in fd.follow_changes(league="NBA"):
96
+ if c["kind"] == "line_move":
97
+ print(c["player"], c["stat"], c["previous"]["line"], "->", c["current"]["line"])
98
+ ```
99
+
100
+ ### Errors and limits
101
+
102
+ Non-2xx responses raise `FanDuelAPIError` with `status`, `code` and the API's error body. Short rate limits (429 with `Retry-After`) are retried automatically. A monthly stop (`QUOTA_EXCEEDED`) is raised at once with `topup_url` and `upgrade_url` for the person to click; the client never buys anything.
103
+
104
+ ```python
105
+ from fanduelapi import FanDuelAPIError
106
+ try:
107
+ fd.results(league="nba")
108
+ except FanDuelAPIError as e:
109
+ print(e.code, e.topup_url)
110
+ ```
111
+
112
+ `fd.credits_used()` returns the `X-Credits-*` headers of the last response.
113
+
114
+ ## Methods
115
+
116
+ | Method | Endpoint |
117
+ | --- | --- |
118
+ | `props(league, stat, player, event_id, view, updated_since, limit, offset)` | `GET /api/v1/props` |
119
+ | `prop_history(prop_id)` | `GET /api/v1/props/{id}/history` |
120
+ | `changes(since, league, limit)`, `follow_changes(...)` | `GET /api/v1/changes` |
121
+ | `odds(league, date, market_type)` | `GET /api/v1/odds` |
122
+ | `events`, `markets`, `futures`, `game_report(event_id)` | events, markets, futures, game report |
123
+ | `compare`, `ev`, `arbitrage` | alternates and other venues, +EV, arbitrage |
124
+ | `results`, `hit_rates(player, stat)`, `training_set` | graded results and model data (paid plans) |
125
+ | `dfs_slates`, `dfs_players(slate_id)`, `injuries`, `coverage` | DFS salaries, injuries, what is tracked |
126
+ | `fanduelapi.sample_props(sport)`, `fanduelapi.credit_costs(path)` | keyless |
127
+
128
+ Plans, credits and limits: https://fanduelapi.com/pricing and https://fanduelapi.com/credits. Full reference: https://fanduelapi.com/docs and https://fanduelapi.com/openapi.json.
129
+
130
+ ## Tests
131
+
132
+ ```bash
133
+ python -m unittest discover -s tests # offline, against a local fake server
134
+ FANDUEL_LIVE=1 python -m unittest discover -s tests # also hits the keyless live endpoints
135
+ ```
136
+
137
+ ## License
138
+
139
+ MIT
@@ -0,0 +1,5 @@
1
+ fanduelapi/__init__.py,sha256=g2WaXw9vxG7PWP1yyj7lU2Kvyd9I_CtCKGsQXAtxkS4,10503
2
+ fanduelapi-0.1.0.dist-info/METADATA,sha256=iEIxNK3q2zeVJqN_EiiDnrndjHmmY3xXRAxAil_4qVk,5308
3
+ fanduelapi-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
4
+ fanduelapi-0.1.0.dist-info/licenses/LICENSE,sha256=fPaBah2bETIgfvPKPxdsnSYOM8etWW_uvYwTiwARegk,1077
5
+ fanduelapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,16 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stunning Couches LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
7
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
8
+ persons to whom the Software is furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
11
+ Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
14
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
15
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.