getodds 0.5.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.
- getodds/__init__.py +378 -0
- getodds/__main__.py +47 -0
- getodds-0.5.0.dist-info/METADATA +108 -0
- getodds-0.5.0.dist-info/RECORD +8 -0
- getodds-0.5.0.dist-info/WHEEL +5 -0
- getodds-0.5.0.dist-info/entry_points.txt +2 -0
- getodds-0.5.0.dist-info/licenses/LICENSE +21 -0
- getodds-0.5.0.dist-info/top_level.txt +1 -0
getodds/__init__.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Dependency-free GetOdds client. One bounded request; no implicit retries."""
|
|
2
|
+
import json
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from urllib.error import HTTPError, URLError
|
|
7
|
+
from urllib.parse import urlencode, quote
|
|
8
|
+
import re
|
|
9
|
+
from urllib.request import Request, build_opener, HTTPRedirectHandler
|
|
10
|
+
|
|
11
|
+
class GetOddsError(Exception):
|
|
12
|
+
def __init__(self, code, status=0, retry_after=None):
|
|
13
|
+
super().__init__(code)
|
|
14
|
+
self.code, self.status, self.retry_after = code, status, retry_after
|
|
15
|
+
|
|
16
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
17
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
def _text(value):
|
|
21
|
+
return isinstance(value, str) and bool(value)
|
|
22
|
+
|
|
23
|
+
def _date(value):
|
|
24
|
+
if not _text(value):
|
|
25
|
+
return False
|
|
26
|
+
try:
|
|
27
|
+
return datetime.fromisoformat(value.replace('Z', '+00:00')).tzinfo is not None
|
|
28
|
+
except ValueError:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
SOURCES = ('prizepicks', 'underdog', 'draftkings', 'fanduel')
|
|
32
|
+
|
|
33
|
+
def validate_markets(body, limit, source='prizepicks'):
|
|
34
|
+
valid = isinstance(body, dict) and _text(body.get('request_id'))
|
|
35
|
+
meta = body.get('meta') if valid else None
|
|
36
|
+
rows = body.get('data') if valid else None
|
|
37
|
+
valid = (valid and isinstance(meta, dict) and meta.get('source') == source
|
|
38
|
+
and meta.get('complete') is False and _date(meta.get('observed_at'))
|
|
39
|
+
and isinstance(rows, list) and len(rows) <= limit)
|
|
40
|
+
seen = set()
|
|
41
|
+
for row in rows if valid else []:
|
|
42
|
+
if not isinstance(row, dict):
|
|
43
|
+
valid = False
|
|
44
|
+
break
|
|
45
|
+
line = row.get('line')
|
|
46
|
+
valid = (valid and _text(row.get('id')) and row['id'] not in seen
|
|
47
|
+
and row.get('source') == source and _text(row.get('player'))
|
|
48
|
+
and _text(row.get('market')) and type(line) in (int, float)
|
|
49
|
+
and math.isfinite(line) and 'odds' in row and row['odds'] is None
|
|
50
|
+
and _date(row.get('observed_at'))
|
|
51
|
+
and all(k in row and (row[k] is None or _date(row[k])) for k in ('starts_at', 'source_updated_at'))
|
|
52
|
+
and 'league' in row and (row['league'] is None or _text(row['league'])))
|
|
53
|
+
if not valid:
|
|
54
|
+
break
|
|
55
|
+
seen.add(row['id'])
|
|
56
|
+
if not valid:
|
|
57
|
+
raise GetOddsError('INVALID_RESPONSE')
|
|
58
|
+
return body
|
|
59
|
+
|
|
60
|
+
RESOURCES = ('events', 'odds', 'history', 'sports', 'bookmakers', 'market-types',
|
|
61
|
+
'events/search', 'events/{id}', 'odds/multi', 'odds/updated', 'dropping-odds',
|
|
62
|
+
'leagues', 'historical/events', 'historical/events/search', 'odds/summary', 'odds/summary/multi')
|
|
63
|
+
LEAGUES = ('NFL','NBA','NHL','NCAAF','NCAAB','MLB','EPL','MLS')
|
|
64
|
+
CATEGORIES = ('basketball','american-football','ice-hockey','baseball','soccer')
|
|
65
|
+
FILTERS = {
|
|
66
|
+
'events':('sport','event_id'), 'odds':('sport','event_id','market','fresh_only'),
|
|
67
|
+
'history':('sport','event_id','market','offer_id'), 'events/search':('q','sport'),
|
|
68
|
+
'events/{id}':('id',), 'odds/multi':('event_ids','sport','market','fresh_only'),
|
|
69
|
+
'odds/updated':('since','sport','market','fresh_only'), 'dropping-odds':('sport','market','min_drop_pct'),
|
|
70
|
+
'leagues':('sport','upcoming_only'), 'historical/events':('league','from','to'),
|
|
71
|
+
'historical/events/search':('league','from','to','q'), 'odds/summary':('event_id','market'),
|
|
72
|
+
'odds/summary/multi':('event_ids','market'),
|
|
73
|
+
}
|
|
74
|
+
def _id(value):
|
|
75
|
+
return isinstance(value,str) and bool(re.fullmatch(r'[-A-Za-z0-9_:]{1,180}',value))
|
|
76
|
+
def _iso(value, utc_only=False):
|
|
77
|
+
if not isinstance(value,str) or not re.fullmatch(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})',value):
|
|
78
|
+
return None
|
|
79
|
+
if utc_only and not value.endswith('Z'): return None
|
|
80
|
+
try: return datetime.fromisoformat(value.replace('Z','+00:00'))
|
|
81
|
+
except ValueError: return None
|
|
82
|
+
|
|
83
|
+
def sportsbook_query(resource, options, now=None):
|
|
84
|
+
def bad(): raise GetOddsError('INVALID_QUERY')
|
|
85
|
+
if resource not in RESOURCES or not isinstance(options,dict): bad()
|
|
86
|
+
options=dict(options)
|
|
87
|
+
if 'from_' in options:
|
|
88
|
+
if not resource.startswith('historical/') or 'from' in options: bad()
|
|
89
|
+
options['from']=options.pop('from_')
|
|
90
|
+
now=now or datetime.now(timezone.utc)
|
|
91
|
+
single=resource=='events/{id}'
|
|
92
|
+
allowed={'source', *(() if single else ('limit','offset')), *FILTERS.get(resource,())}
|
|
93
|
+
limit, offset = options.get('limit',50), options.get('offset',0)
|
|
94
|
+
if (set(options)-allowed or options.get('source','draftkings')!='draftkings'
|
|
95
|
+
or type(limit) is not int or not 1<=limit<=200 or type(offset) is not int or not 0<=offset<=10000): bad()
|
|
96
|
+
if 'sport' in options and options['sport'] not in (CATEGORIES if resource=='leagues' else LEAGUES): bad()
|
|
97
|
+
if 'league' in options and options['league'] not in LEAGUES: bad()
|
|
98
|
+
if 'market' in options and options['market'] not in ('moneyline','spread','total'): bad()
|
|
99
|
+
for key in ('id','event_id','offer_id'):
|
|
100
|
+
if key in options and not _id(options[key]): bad()
|
|
101
|
+
for key in ('fresh_only','upcoming_only'):
|
|
102
|
+
if key in options and type(options[key]) is not bool: bad()
|
|
103
|
+
if resource=='history' and not options.get('event_id') and not options.get('offer_id'): raise GetOddsError('HISTORY_ID_REQUIRED')
|
|
104
|
+
if single and not _id(options.get('id')): bad()
|
|
105
|
+
if resource in ('events/search','historical/events/search'):
|
|
106
|
+
q=options.get('q')
|
|
107
|
+
if not isinstance(q,str) or not 2<=len(q.strip())<=100: bad()
|
|
108
|
+
options['q']=q.strip()
|
|
109
|
+
if resource in ('odds/multi','odds/summary/multi'):
|
|
110
|
+
ids=options.get('event_ids')
|
|
111
|
+
if isinstance(ids,str): ids=ids.split(',')
|
|
112
|
+
if not isinstance(ids,(list,tuple)) or not 1<=len(ids)<=20 or not all(_id(i) for i in ids) or len(set(ids))!=len(ids): bad()
|
|
113
|
+
options['event_ids']=','.join(ids)
|
|
114
|
+
if resource=='odds/summary' and not _id(options.get('event_id')): bad()
|
|
115
|
+
if resource=='odds/updated':
|
|
116
|
+
since=_iso(options.get('since'))
|
|
117
|
+
if since is None or not 0<=(now-since).total_seconds()<=7*86400: bad()
|
|
118
|
+
if 'min_drop_pct' in options:
|
|
119
|
+
drop=options['min_drop_pct']
|
|
120
|
+
if type(drop) not in (int,float) or not re.fullmatch(r'\d+(?:\.\d{1,3})?',str(drop)) or not 0.1<=drop<100: bad()
|
|
121
|
+
if resource.startswith('historical/'):
|
|
122
|
+
start,end=_iso(options.get('from'),True),_iso(options.get('to'),True)
|
|
123
|
+
if options.get('league') not in LEAGUES or start is None or end is None or not 0<(end-start).total_seconds()<=31*86400 or end>now: bad()
|
|
124
|
+
query={**options,'source':'draftkings'}
|
|
125
|
+
if single: query.pop('id');limit,offset=1,0
|
|
126
|
+
else: query.update(limit=limit,offset=offset)
|
|
127
|
+
for key in ('fresh_only','upcoming_only'):
|
|
128
|
+
if key in query: query[key]=str(query[key]).lower()
|
|
129
|
+
return urlencode(query),limit,offset
|
|
130
|
+
|
|
131
|
+
def validate_sportsbook(body, resource, limit, offset):
|
|
132
|
+
def fail(): raise GetOddsError('INVALID_RESPONSE')
|
|
133
|
+
def number(v): return type(v) in (int, float) and math.isfinite(v)
|
|
134
|
+
if not isinstance(body,dict) or not _text(body.get('request_id')): fail()
|
|
135
|
+
meta, rows, page = body.get('meta'), body.get('data'), body.get('pagination')
|
|
136
|
+
if (not isinstance(meta,dict) or meta.get('source') != 'draftkings'
|
|
137
|
+
or meta.get('transport') != 'stored_espn_draftkings' or meta.get('snapshot_consistent') is not False
|
|
138
|
+
or not _date(meta.get('checked_at')) or not isinstance(rows,list) or len(rows)>limit): fail()
|
|
139
|
+
if (not isinstance(page,dict) or type(page.get('limit')) is not int or page['limit']!=limit
|
|
140
|
+
or type(page.get('offset')) is not int or page['offset']!=offset
|
|
141
|
+
or type(page.get('has_more')) is not bool or 'next_offset' not in page): fail()
|
|
142
|
+
next_offset=page['next_offset']
|
|
143
|
+
if next_offset is not None and (not page['has_more'] or type(next_offset) is not int or next_offset!=offset+limit or next_offset>10000): fail()
|
|
144
|
+
original=resource
|
|
145
|
+
if resource in ('events/search','events/{id}','historical/events','historical/events/search'): resource='events'
|
|
146
|
+
if resource in ('odds/multi','odds/updated','dropping-odds'): resource='odds'
|
|
147
|
+
fields = {'events':('id','sport','home','away'), 'odds':('id','event_id','sport','market','period','side'),
|
|
148
|
+
'history':('seq','offer_id','event_id','sport','market','period','side'), 'market-types':('market','period')}.get(resource,() if resource.startswith('odds/summary') else ('id','name'))
|
|
149
|
+
for row in rows:
|
|
150
|
+
if not isinstance(row,dict) or not all(_text(row.get(k)) for k in fields): fail()
|
|
151
|
+
if resource in ('events','odds','history') and (not _date(row.get('observed_at')) or type(row.get('age_seconds')) is not int or row['age_seconds']<0 or type(row.get('stale')) is not bool): fail()
|
|
152
|
+
if resource in ('events','odds') and not _date(row.get('starts_at')): fail()
|
|
153
|
+
if resource in ('odds','history') and (not number(row.get('decimal')) or row['decimal']<=1 or type(row.get('american')) is not int or abs(row['american'])<100 or 'line' not in row or (row['line'] is not None and not number(row['line']))): fail()
|
|
154
|
+
if original=='events/{id}' and (len(rows)!=1 or page['has_more'] or page['next_offset'] is not None): fail()
|
|
155
|
+
if original.startswith('historical/') and (meta.get('event_state')!='unknown' or meta.get('finished_events_verified') is not False or meta.get('archive_complete') is not False or not _date(meta.get('from')) or not _date(meta.get('to'))): fail()
|
|
156
|
+
for row in rows:
|
|
157
|
+
if original=='leagues' and (row.get('sport') not in CATEGORIES or type(row.get('event_count')) is not int or row['event_count']<0 or type(row.get('upcoming_event_count')) is not int or not 0<=row['upcoming_event_count']<=row['event_count']): fail()
|
|
158
|
+
if original in ('odds/updated','dropping-odds') and not _date(row.get('changed_at')): fail()
|
|
159
|
+
if original=='dropping-odds' and (not _date(row.get('previous_observed_at')) or not number(row.get('previous_decimal')) or row['previous_decimal']<=1 or not number(row.get('drop_pct')) or not 0<row['drop_pct']<100): fail()
|
|
160
|
+
if original.startswith('odds/summary'): _validate_summaries(body,fail)
|
|
161
|
+
return body
|
|
162
|
+
|
|
163
|
+
def _validate_summaries(body,fail):
|
|
164
|
+
def number(v): return type(v) in (int,float) and math.isfinite(v)
|
|
165
|
+
def nullable(v): return v is None or number(v)
|
|
166
|
+
meta=body['meta']
|
|
167
|
+
if type(meta.get('independent_bookmakers')) is not int or meta['independent_bookmakers']!=1 or meta.get('cross_book_comparison') is not False or not _text(meta.get('normalization')): fail()
|
|
168
|
+
groups=set()
|
|
169
|
+
for row in body['data']:
|
|
170
|
+
if (not all(_text(row.get(k)) for k in ('event_id','league','market','period')) or row['market'] not in ('moneyline','spread','total')
|
|
171
|
+
or 'reference_line' not in row or not nullable(row['reference_line']) or type(row.get('bookmaker_count')) is not int or row['bookmaker_count']!=1
|
|
172
|
+
or type(row.get('expected_sides_present')) is not bool or type(row.get('capture_times_coherent')) is not bool
|
|
173
|
+
or 'implied_probability_sum' not in row or not nullable(row['implied_probability_sum']) or not isinstance(row.get('outcomes'),list) or not row['outcomes']): fail()
|
|
174
|
+
orientation={'spread':'home','total':'total'}.get(row['market'])
|
|
175
|
+
if 'line_orientation' not in row or row['line_orientation']!=orientation or (row['reference_line'] is None)!=(row['market']=='moneyline'): fail()
|
|
176
|
+
identity=(row['event_id'],row['market'],row['period'],row['reference_line'])
|
|
177
|
+
if identity in groups: fail()
|
|
178
|
+
groups.add(identity)
|
|
179
|
+
complete=row['expected_sides_present'] and row['capture_times_coherent']
|
|
180
|
+
if complete:
|
|
181
|
+
if not number(row['implied_probability_sum']) or row['implied_probability_sum']<=0: fail()
|
|
182
|
+
elif row['implied_probability_sum'] is not None: fail()
|
|
183
|
+
sides=set();implied=0;clocks=[]
|
|
184
|
+
for outcome in row['outcomes']:
|
|
185
|
+
if (not isinstance(outcome,dict) or not _text(outcome.get('side')) or outcome['side'] in sides or 'line' not in outcome or not nullable(outcome['line'])
|
|
186
|
+
or type(outcome.get('bookmaker_count')) is not int or outcome['bookmaker_count']!=1 or not isinstance(outcome.get('quotes'),list) or len(outcome['quotes'])!=1): fail()
|
|
187
|
+
sides.add(outcome['side']);quote=outcome['quotes'][0]
|
|
188
|
+
if (not isinstance(quote,dict) or not _text(quote.get('offer_id')) or quote.get('bookmaker')!='draftkings' or not number(quote.get('decimal')) or quote['decimal']<=1
|
|
189
|
+
or type(quote.get('american')) is not int or abs(quote['american'])<100 or not _date(quote.get('observed_at'))): fail()
|
|
190
|
+
if not all(number(outcome.get(k)) and outcome[k]==quote['decimal'] for k in ('best_decimal','average_decimal','median_decimal','worst_decimal')): fail()
|
|
191
|
+
implied+=1/quote['decimal'];clocks.append(datetime.fromisoformat(quote['observed_at'].replace('Z','+00:00')).timestamp())
|
|
192
|
+
line=None if row['market']=='moneyline' else -row['reference_line'] if row['market']=='spread' and outcome['side']=='away' else row['reference_line']
|
|
193
|
+
if outcome['line']!=line: fail()
|
|
194
|
+
if 'normalized_price_probability' not in outcome: fail()
|
|
195
|
+
probability=outcome['normalized_price_probability']
|
|
196
|
+
if complete:
|
|
197
|
+
if not number(probability) or not 0<=probability<=1 or abs(probability-(1/quote['decimal'])/row['implied_probability_sum'])>1e-9: fail()
|
|
198
|
+
elif probability is not None: fail()
|
|
199
|
+
if row['capture_times_coherent']!=(max(clocks)-min(clocks)<=60) or (complete and abs(implied-row['implied_probability_sum'])>1e-9): fail()
|
|
200
|
+
expected={'over','under'} if row['market']=='total' else {'home','away','draw'} if row['market']=='moneyline' and row['league'] in ('EPL','MLS') else {'home','away'}
|
|
201
|
+
if sides-expected or row['expected_sides_present']!=(sides==expected): fail()
|
|
202
|
+
|
|
203
|
+
READ_RESOURCES=['events/{id}/state','participants','participants/{id}','odds/replay','sportsbook/markets','outrights','outrights/odds','events/live']
|
|
204
|
+
def _seq(value): return isinstance(value,str) and re.fullmatch(r'(0|[1-9]\d{0,18})',value) is not None and int(value)<=9223372036854775807
|
|
205
|
+
def _native(value): return isinstance(value,str) and re.fullmatch(r'[A-Za-z0-9_.:-]{1,180}',value) is not None
|
|
206
|
+
def _event_identity(value): return isinstance(value,str) and re.fullmatch(r'espn-draftkings:espn:(NBA|NFL|NHL|NCAAF|NCAAB|MLB|EPL|MLS):\d{1,20}',value) is not None
|
|
207
|
+
def read_query(resource,options):
|
|
208
|
+
def bad(): raise GetOddsError('INVALID_QUERY')
|
|
209
|
+
if resource not in READ_RESOURCES or not isinstance(options,dict): bad()
|
|
210
|
+
fd=resource in READ_RESOURCES[4:];metadata=resource in READ_RESOURCES[:3];source='fanduel' if fd else 'draftkings'
|
|
211
|
+
fields=(['id'] if resource=='events/{id}/state' else ['event_id'] if resource=='participants' else ['event_id','id']) if metadata else ['after','through','limit','event_ids','sport','market'] if resource=='odds/replay' else ['limit','after','event_id','league']+(['sport'] if resource=='events/live' else ['market_id','family','status'])
|
|
212
|
+
if set(options)-set(['source']+fields) or options.get('source',source)!=source: bad()
|
|
213
|
+
query={**options,'source':source};path=resource
|
|
214
|
+
if metadata:
|
|
215
|
+
event=options.get('id') if resource=='events/{id}/state' else options.get('event_id')
|
|
216
|
+
if not _event_identity(event): bad()
|
|
217
|
+
if resource=='participants/{id}' and (not isinstance(options.get('id'),str) or not re.fullmatch(r'espn:(NBA|NFL|NHL|NCAAF|NCAAB|MLB|EPL|MLS):team:\d{1,20}',options['id']) or options['id'].split(':')[1]!=event.split(':')[2]): bad()
|
|
218
|
+
if '{id}' in resource: path=resource.replace('{id}',quote(options['id'],safe=''));del query['id']
|
|
219
|
+
limit=2 if resource=='participants' else 1
|
|
220
|
+
else:
|
|
221
|
+
limit=options.get('limit',50);query['limit']=limit
|
|
222
|
+
if type(limit) is not int or not 1<=limit<=(100 if fd else 200): bad()
|
|
223
|
+
if fd:
|
|
224
|
+
if any(key in options and not _native(options[key]) for key in fields if key!='limit'): bad()
|
|
225
|
+
if 'status' in options and options['status'] not in ('open','suspended','closed','removed'): bad()
|
|
226
|
+
else:
|
|
227
|
+
query['after']=options.get('after','0')
|
|
228
|
+
if not _seq(query['after']) or ('through'in options and (not _seq(options['through']) or int(query['after'])>int(options['through']))): bad()
|
|
229
|
+
if 'sport'in options and options['sport'] not in LEAGUES: bad()
|
|
230
|
+
if 'market'in options and options['market'] not in ('moneyline','spread','total'): bad()
|
|
231
|
+
if 'event_ids'in options:
|
|
232
|
+
ids=options['event_ids'] if isinstance(options['event_ids'],list) else options['event_ids'].split(',') if isinstance(options['event_ids'],str) else []
|
|
233
|
+
if not 1<=len(ids)<=20 or not all(_id(value) for value in ids) or len(set(ids))!=len(ids): bad()
|
|
234
|
+
query['event_ids']=','.join(ids)
|
|
235
|
+
return {'path':path,'query':query,'source':source,'limit':limit,'options':options}
|
|
236
|
+
def validate_read(body,resource,input):
|
|
237
|
+
def fail(): raise GetOddsError('INVALID_RESPONSE')
|
|
238
|
+
def number(value): return type(value) in (int,float) and math.isfinite(value)
|
|
239
|
+
if not isinstance(body,dict) or not _text(body.get('request_id')) or not isinstance(body.get('meta'),dict) or not isinstance(body.get('data'),list): fail()
|
|
240
|
+
meta=body['meta'];rows=body['data'];p=body.get('pagination');options=input['options']
|
|
241
|
+
if meta.get('source')!=input['source'] or len(rows)>input['limit'] or not all(isinstance(row,dict) for row in rows): fail()
|
|
242
|
+
if resource in READ_RESOURCES[:3]:
|
|
243
|
+
if meta.get('transport')!='live_espn_summary' or meta.get('metadata_provider')!='espn' or any(meta.get(key) is not False for key in ('canonical_cross_provider_identity','settlement_verified','receipt_time_is_source_freshness')) or not _date(meta.get('observed_at')): fail()
|
|
244
|
+
event=options['id'] if resource=='events/{id}/state' else options['event_id']
|
|
245
|
+
if len(rows)!=(2 if resource=='participants' else 1) or any(row.get('event_id')!=event for row in rows): fail()
|
|
246
|
+
if resource=='events/{id}/state':
|
|
247
|
+
row=rows[0]
|
|
248
|
+
if row.get('state') not in ('pre','in','post') or type(row.get('completed')) is not bool or (row['completed'] and row['state']!='post') or not _text(row.get('source_status')) or not _date(row.get('starts_at')): fail()
|
|
249
|
+
else:
|
|
250
|
+
for row in rows:
|
|
251
|
+
identity=row.get('id')
|
|
252
|
+
if not isinstance(identity,str) or not re.fullmatch(r'espn:(NBA|NFL|NHL|NCAAF|NCAAB|MLB|EPL|MLS):team:\d{1,20}',identity) or identity.split(':')[1]!=event.split(':')[2] or row.get('type')!='team' or not _text(row.get('name')) or row.get('side') not in ('home','away'): fail()
|
|
253
|
+
if len({row['id'] for row in rows})!=len(rows) or (resource=='participants' and len({row['side'] for row in rows})!=2) or (resource=='participants/{id}' and rows[0]['id']!=options['id']): fail()
|
|
254
|
+
else:
|
|
255
|
+
if not _date(meta.get('checked_at')) or not isinstance(p,dict) or type(p.get('limit')) is not int or p['limit']!=input['limit'] or type(p.get('has_more')) is not bool: fail()
|
|
256
|
+
if resource=='odds/replay':
|
|
257
|
+
if meta.get('transport')!='stored_espn_draftkings' or any(meta.get(key) is not False for key in ('source_capture_complete','deletion_events_available','snapshot_consistent')) or not all(_seq(p.get(key)) for key in ('after','through','next_after')): fail()
|
|
258
|
+
if p['after']!=input['query']['after'] or ('through'in options and p['through']!=options['through']) or type(p.get('replay_complete')) is not bool or p['replay_complete']==p['has_more'] or not int(p['after'])<=int(p['next_after'])<=int(p['through']): fail()
|
|
259
|
+
previous=int(p['after'])
|
|
260
|
+
for row in rows:
|
|
261
|
+
if not _seq(row.get('seq')) or not previous<int(row['seq'])<=int(p['through']) or not all(_text(row.get(key)) for key in ('offer_id','event_id')) or not _date(row.get('observed_at')) or not number(row.get('decimal')) or not number(row.get('american')): fail()
|
|
262
|
+
previous=int(row['seq'])
|
|
263
|
+
if (not rows or p['next_after']!=rows[-1]['seq']) if p['has_more'] else p['next_after']!=p['through']: fail()
|
|
264
|
+
else:
|
|
265
|
+
if meta.get('identity')!='source_native' or meta.get('capture_complete') is not False or p.get('snapshot_consistent') is not False or 'next_after' not in p: fail()
|
|
266
|
+
if (not rows or p['next_after']!=rows[-1].get('id')) if p['has_more'] else p['next_after'] is not None: fail()
|
|
267
|
+
if resource=='events/live' and (meta.get('completion_available') is not False or meta.get('freshness_window_minutes')!=15): fail()
|
|
268
|
+
previous=options.get('after','')
|
|
269
|
+
for row in rows:
|
|
270
|
+
if not _native(row.get('id')) or row['id']<=previous or not _text(row.get('sport')) or not _text(row.get('league')) or not _date(row.get('starts_at')): fail()
|
|
271
|
+
previous=row['id']
|
|
272
|
+
if resource=='events/live':
|
|
273
|
+
if meta.get('completion_available') is not False or meta.get('freshness_window_minutes')!=15 or row.get('observed_in_play') is not True or type(row.get('metadata_ambiguous')) is not bool or not _text(row.get('name')) or not _date(row.get('qualifying_market_observed_at')) or not _date(row.get('qualifying_market_changed_at')): fail()
|
|
274
|
+
else:
|
|
275
|
+
if row.get('is_future') is not (resource!='sportsbook/markets') or not _native(row.get('event_id')) or not _date(row.get('observed_at')) or not _date(row.get('changed_at')) or row.get('market_status') not in ('open','suspended','closed','removed'): fail()
|
|
276
|
+
if resource=='outrights':
|
|
277
|
+
if type(row.get('selection_count')) is not int or row['selection_count']<0 or 'selections'in row: fail()
|
|
278
|
+
else:
|
|
279
|
+
if not isinstance(row.get('selections'),list) or len(row['selections'])>1000: fail()
|
|
280
|
+
for selection in row['selections']:
|
|
281
|
+
if not isinstance(selection,dict) or not _text(selection.get('id')) or selection.get('status') not in ('active','suspended') or not all(key in selection and (selection[key] is None or number(selection[key])) for key in ('american','decimal','line','fair')): fail()
|
|
282
|
+
return body
|
|
283
|
+
|
|
284
|
+
class GetOdds:
|
|
285
|
+
def __init__(self, api_key=None, opener=None):
|
|
286
|
+
key = api_key if api_key is not None else os.environ.get('GETODDS_API_KEY')
|
|
287
|
+
if not _text(key) or any(c.isspace() or ord(c) < 32 or ord(c) == 127 for c in key):
|
|
288
|
+
raise GetOddsError('INVALID_API_KEY')
|
|
289
|
+
self._key = key
|
|
290
|
+
self._opener = opener if opener is not None else build_opener(_NoRedirect())
|
|
291
|
+
|
|
292
|
+
def markets(self, limit=50, source='prizepicks'):
|
|
293
|
+
if source not in SOURCES:
|
|
294
|
+
raise GetOddsError('SOURCE_UNAVAILABLE')
|
|
295
|
+
if type(limit) is not int or not 1 <= limit <= 200:
|
|
296
|
+
raise GetOddsError('INVALID_LIMIT')
|
|
297
|
+
return validate_markets(self._request(f'markets?source={source}&limit={limit}'), limit, source)
|
|
298
|
+
|
|
299
|
+
def sportsbook(self, resource, **options):
|
|
300
|
+
if resource in READ_RESOURCES:
|
|
301
|
+
info=read_query(resource,options)
|
|
302
|
+
return validate_read(self._request(info['path']+'?'+urlencode(info['query'])),resource,info)
|
|
303
|
+
query, limit, offset = sportsbook_query(resource, options)
|
|
304
|
+
path='events/'+options['id'] if resource=='events/{id}' else resource
|
|
305
|
+
body=validate_sportsbook(self._request(path + '?' + query), resource, limit, offset)
|
|
306
|
+
if resource=='events/{id}' and body['data'][0]['id']!=options['id']: raise GetOddsError('INVALID_RESPONSE')
|
|
307
|
+
return body
|
|
308
|
+
|
|
309
|
+
def events(self, **options): return self.sportsbook('events', **options)
|
|
310
|
+
def odds(self, **options): return self.sportsbook('odds', **options)
|
|
311
|
+
def history(self, **options): return self.sportsbook('history', **options)
|
|
312
|
+
def sports(self, **options): return self.sportsbook('sports', **options)
|
|
313
|
+
def bookmakers(self, **options): return self.sportsbook('bookmakers', **options)
|
|
314
|
+
def market_types(self, **options): return self.sportsbook('market-types', **options)
|
|
315
|
+
|
|
316
|
+
def search_events(self, **options): return self.sportsbook('events/search', **options)
|
|
317
|
+
def event(self, event_id, **options): return self.sportsbook('events/{id}', id=event_id, **options)
|
|
318
|
+
def odds_multi(self, **options): return self.sportsbook('odds/multi', **options)
|
|
319
|
+
def updated_odds(self, **options): return self.sportsbook('odds/updated', **options)
|
|
320
|
+
def dropping_odds(self, **options): return self.sportsbook('dropping-odds', **options)
|
|
321
|
+
def leagues(self, **options): return self.sportsbook('leagues', **options)
|
|
322
|
+
def historical_events(self, **options): return self.sportsbook('historical/events', **options)
|
|
323
|
+
def search_historical_events(self, **options): return self.sportsbook('historical/events/search', **options)
|
|
324
|
+
def odds_summary(self, **options): return self.sportsbook('odds/summary', **options)
|
|
325
|
+
def odds_summary_multi(self, **options): return self.sportsbook('odds/summary/multi', **options)
|
|
326
|
+
|
|
327
|
+
def event_state(self, event_id, **options): return self.sportsbook('events/{id}/state',id=event_id,**options)
|
|
328
|
+
def participants(self, **options): return self.sportsbook('participants',**options)
|
|
329
|
+
def participant(self, participant_id, **options): return self.sportsbook('participants/{id}',id=participant_id,**options)
|
|
330
|
+
def replay(self, **options): return self.sportsbook('odds/replay',**options)
|
|
331
|
+
def fanduel_markets(self, **options): return self.sportsbook('sportsbook/markets',**options)
|
|
332
|
+
def outrights(self, **options): return self.sportsbook('outrights',**options)
|
|
333
|
+
def outright_odds(self, **options): return self.sportsbook('outrights/odds',**options)
|
|
334
|
+
def live_events(self, **options): return self.sportsbook('events/live',**options)
|
|
335
|
+
|
|
336
|
+
def _request(self, path):
|
|
337
|
+
return _fetch('https://getoddsapi.com/api/v1/' + path,
|
|
338
|
+
{'Authorization': 'Bearer ' + self._key, 'Accept': 'application/json'}, self._opener)
|
|
339
|
+
|
|
340
|
+
def _fetch(url, headers, opener):
|
|
341
|
+
req = Request(url, headers=headers)
|
|
342
|
+
try:
|
|
343
|
+
with opener.open(req, timeout=20) as response:
|
|
344
|
+
raw = response.read(2 * 1024 * 1024 + 1)
|
|
345
|
+
if len(raw) > 2 * 1024 * 1024:
|
|
346
|
+
raise GetOddsError('RESPONSE_TOO_LARGE')
|
|
347
|
+
body = json.loads(raw)
|
|
348
|
+
except HTTPError as exc:
|
|
349
|
+
retry = exc.headers.get('Retry-After', '') if exc.headers else ''
|
|
350
|
+
delay = int(retry) if retry.isascii() and retry.isdigit() and len(retry) <= 6 else None
|
|
351
|
+
raise GetOddsError('HTTP_ERROR', exc.code, delay) from None
|
|
352
|
+
except (URLError, TimeoutError, OSError):
|
|
353
|
+
raise GetOddsError('NETWORK_ERROR') from None
|
|
354
|
+
except (ValueError, UnicodeError):
|
|
355
|
+
raise GetOddsError('INVALID_RESPONSE') from None
|
|
356
|
+
return body
|
|
357
|
+
|
|
358
|
+
# Keyless public boards: no API key, no credits. DraftKings prices (via ESPN), cached up to 5 minutes.
|
|
359
|
+
PUBLIC_SPORTS = ('nfl', 'nba', 'nhl', 'college-football', 'soccer-odds-api')
|
|
360
|
+
|
|
361
|
+
def _validate_board(body):
|
|
362
|
+
if (not isinstance(body, dict) or body.get('state') not in ('available', 'empty', 'unavailable')
|
|
363
|
+
or not _date(body.get('checked_at')) or not isinstance(body.get('rows'), list)
|
|
364
|
+
or not all(isinstance(r, dict) for r in body['rows'])):
|
|
365
|
+
raise GetOddsError('INVALID_RESPONSE')
|
|
366
|
+
return body
|
|
367
|
+
|
|
368
|
+
def public_board(sport, opener=None):
|
|
369
|
+
"""Keyless DraftKings board for one sport: moneyline, spread and total rows with capture times."""
|
|
370
|
+
if sport not in PUBLIC_SPORTS:
|
|
371
|
+
raise GetOddsError('INVALID_SPORT')
|
|
372
|
+
return _validate_board(_fetch('https://getoddsapi.com/api/public/boards/' + sport, {'Accept': 'application/json'},
|
|
373
|
+
opener if opener is not None else build_opener(_NoRedirect())))
|
|
374
|
+
|
|
375
|
+
def odds_board(opener=None):
|
|
376
|
+
"""Keyless homepage board: the next DraftKings Sportsbook (New Jersey) prices across leagues."""
|
|
377
|
+
return _validate_board(_fetch('https://getoddsapi.com/api/public/odds-board', {'Accept': 'application/json'},
|
|
378
|
+
opener if opener is not None else build_opener(_NoRedirect())))
|
getodds/__main__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from . import GetOdds, GetOddsError, RESOURCES, SOURCES, READ_RESOURCES, public_board, odds_board
|
|
5
|
+
|
|
6
|
+
def main():
|
|
7
|
+
parser=argparse.ArgumentParser(description='One bounded GetOdds read. No automatic pagination or retries.')
|
|
8
|
+
parser.add_argument('command', choices=['board', 'markets', *RESOURCES, *READ_RESOURCES])
|
|
9
|
+
for name in ['after','through','market-id','family','status']: parser.add_argument('--'+name)
|
|
10
|
+
parser.add_argument('--limit', type=int)
|
|
11
|
+
parser.add_argument('--source', choices=SOURCES)
|
|
12
|
+
parser.add_argument('--offset', type=int)
|
|
13
|
+
parser.add_argument('--sport')
|
|
14
|
+
parser.add_argument('--event-id')
|
|
15
|
+
parser.add_argument('--offer-id')
|
|
16
|
+
parser.add_argument('--market')
|
|
17
|
+
parser.add_argument('--fresh-only', action='store_true', default=None)
|
|
18
|
+
parser.add_argument('--id')
|
|
19
|
+
parser.add_argument('--q')
|
|
20
|
+
parser.add_argument('--event-ids')
|
|
21
|
+
parser.add_argument('--since')
|
|
22
|
+
parser.add_argument('--min-drop-pct', type=float)
|
|
23
|
+
parser.add_argument('--league')
|
|
24
|
+
parser.add_argument('--from', dest='from_')
|
|
25
|
+
parser.add_argument('--to')
|
|
26
|
+
parser.add_argument('--upcoming-only', action='store_true', default=None)
|
|
27
|
+
flags=[arg.split('=',1)[0] for arg in sys.argv[1:] if arg.startswith('--')]
|
|
28
|
+
if len(flags)!=len(set(flags)): parser.error('duplicate options are not supported')
|
|
29
|
+
args=vars(parser.parse_args())
|
|
30
|
+
command=args.pop('command')
|
|
31
|
+
options={k:v for k,v in args.items() if v is not None}
|
|
32
|
+
if command=='markets' and set(options)-{'limit','source'}: parser.error('markets only supports --limit and --source')
|
|
33
|
+
try:
|
|
34
|
+
if command=='board':
|
|
35
|
+
# Keyless: no GETODDS_API_KEY needed. --sport nfl|nba|nhl|college-football|soccer-odds-api, or omit for the mixed board.
|
|
36
|
+
if set(options)-{'sport'}: parser.error('board only supports --sport')
|
|
37
|
+
print(json.dumps(public_board(options['sport']) if 'sport' in options else odds_board(),indent=2))
|
|
38
|
+
return 0
|
|
39
|
+
client=GetOdds()
|
|
40
|
+
result=client.markets(**options) if command=='markets' else client.sportsbook(command,**options)
|
|
41
|
+
print(json.dumps(result,indent=2))
|
|
42
|
+
except GetOddsError as exc:
|
|
43
|
+
print(json.dumps({'error':exc.code,'status':exc.status,'retry_after':exc.retry_after}),file=sys.stderr)
|
|
44
|
+
return 1
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
if __name__ == '__main__': sys.exit(main())
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: getodds
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Python client for the GetOdds sports odds API: DraftKings, FanDuel and BetRivers prices plus PrizePicks, Underdog and Pick6 lines
|
|
5
|
+
Author-email: Stunning Couches LLC <ops@getoddsapi.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/getoddsapi/getodds-python
|
|
8
|
+
Project-URL: Issues, https://github.com/getoddsapi/getodds-python/issues
|
|
9
|
+
Project-URL: Homepage, https://getoddsapi.com
|
|
10
|
+
Project-URL: Documentation, https://getoddsapi.com/docs
|
|
11
|
+
Project-URL: Quickstart, https://getoddsapi.com/quickstart
|
|
12
|
+
Project-URL: OpenAPI, https://getoddsapi.com/openapi.json
|
|
13
|
+
Keywords: sports odds api,odds api,draftkings,fanduel,betrivers,prizepicks,underdog,sports betting,player props
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# getodds (Python)
|
|
25
|
+
|
|
26
|
+
Python client for the [GetOdds sports odds API](https://getoddsapi.com): DraftKings, FanDuel and BetRivers
|
|
27
|
+
sportsbook prices plus PrizePicks, Underdog and DraftKings Pick6 pick'em lines, with capture times on every row.
|
|
28
|
+
No dependencies (standard library only), Python 3.10+.
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install getodds
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Try it with no key
|
|
35
|
+
|
|
36
|
+
The public boards need no account and use no credits. They return DraftKings prices (read through ESPN's odds data),
|
|
37
|
+
cached for up to five minutes.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from getodds import public_board
|
|
41
|
+
|
|
42
|
+
board = public_board("nhl") # nfl, nba, nhl, college-football, soccer-odds-api
|
|
43
|
+
for row in board["rows"][:2]:
|
|
44
|
+
print(row["away"], "@", row["home"], row["market"], row["side"], row["american"], row["observed_at"])
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Output from a real run on 15 Nov 2026 (05:50 UTC):
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
Edmonton Oilers @ Philadelphia Flyers moneyline away -155 2026-11-15T05:44:25.914Z
|
|
51
|
+
Edmonton Oilers @ Philadelphia Flyers moneyline home 130 2026-11-15T05:44:25.914Z
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Prices move; your numbers will differ. From the shell: `getodds board --sport nba` (or `python -m getodds board`).
|
|
55
|
+
|
|
56
|
+
## With a free key
|
|
57
|
+
|
|
58
|
+
Create a free key at <https://getoddsapi.com/sign-up> (1,000 requests a UTC day, 60 a minute, no card) and set it in
|
|
59
|
+
the server environment:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
export GETODDS_API_KEY=... # never commit it
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from getodds import GetOdds, GetOddsError
|
|
67
|
+
|
|
68
|
+
client = GetOdds() # reads GETODDS_API_KEY
|
|
69
|
+
|
|
70
|
+
# DraftKings NBA moneylines, only rows captured recently
|
|
71
|
+
odds = client.odds(sport="NBA", market="moneyline", fresh_only=True, limit=10)
|
|
72
|
+
|
|
73
|
+
# Pick'em thresholds from PrizePicks or Underdog (the key needs that source granted)
|
|
74
|
+
lines = client.markets(source="prizepicks", limit=20)
|
|
75
|
+
|
|
76
|
+
# FanDuel native game markets
|
|
77
|
+
fd = client.fanduel_markets(league="NBA", limit=5)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
client.history(event_id="espn-draftkings:espn:NBA:401909985")
|
|
81
|
+
except GetOddsError as err:
|
|
82
|
+
print(err.code, err.status, err.retry_after) # e.g. HTTP_ERROR 429 60
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Each call makes one bounded request (20 s timeout, 2 MiB cap, no redirects) and validates the response contract.
|
|
86
|
+
There are no automatic retries or pagination: pass `offset` (or the returned cursor) yourself.
|
|
87
|
+
A successful data request uses one credit; failed reads use none.
|
|
88
|
+
|
|
89
|
+
| Method | Route |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `odds`, `events`, `history`, `sports`, `bookmakers`, `market_types` | DraftKings (via ESPN) stored odds |
|
|
92
|
+
| `search_events`, `event`, `odds_multi`, `updated_odds`, `dropping_odds`, `leagues` | event search and change feeds |
|
|
93
|
+
| `historical_events`, `odds_summary`, `replay`, `event_state`, `participants` | history, replay and metadata |
|
|
94
|
+
| `markets(source=...)` | PrizePicks, Underdog, DraftKings Pick6 thresholds |
|
|
95
|
+
| `fanduel_markets`, `outrights`, `outright_odds`, `live_events` | FanDuel native markets |
|
|
96
|
+
|
|
97
|
+
Full reference: <https://getoddsapi.com/docs> and the OpenAPI spec at <https://getoddsapi.com/openapi.json>.
|
|
98
|
+
|
|
99
|
+
## Tests
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
PYTHONPATH=. python3 -m unittest discover -s tests
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Tests run offline against fixtures and a fake opener.
|
|
106
|
+
|
|
107
|
+
GetOdds is an independent developer product, not affiliated with any sportsbook. Operated by Stunning Couches LLC.
|
|
108
|
+
MIT licensed.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
getodds/__init__.py,sha256=3O46Sd2A5ZLt08vTWl8aBap8LGWPsvqEikHNQpkj_Os,28170
|
|
2
|
+
getodds/__main__.py,sha256=6oyqaljXzeO6cwz9Xk8TWrcBp2LRErTn5Wrew_W5Rqw,2399
|
|
3
|
+
getodds-0.5.0.dist-info/licenses/LICENSE,sha256=x4zI-m2lWxFFkFn_BD86vA8lXA7YD4gq45y0VrP1r2o,1077
|
|
4
|
+
getodds-0.5.0.dist-info/METADATA,sha256=p4NWJ-Whn9sVgicXAtAlS2c5_-7NCX_LFu0DGszbVOI,4241
|
|
5
|
+
getodds-0.5.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
6
|
+
getodds-0.5.0.dist-info/entry_points.txt,sha256=a4urql3xBj1dvxU-N5k7CHmGLvSLX8os9LX26UMq5o0,50
|
|
7
|
+
getodds-0.5.0.dist-info/top_level.txt,sha256=woMdnHqfEpn44hzPWedpBdxlfmCZajtJM93ETCo2lE0,8
|
|
8
|
+
getodds-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
getodds
|