sharpapi 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.
- sharpapi/__init__.py +93 -0
- sharpapi/_base.py +121 -0
- sharpapi/_utils.py +39 -0
- sharpapi/async_client.py +464 -0
- sharpapi/client.py +682 -0
- sharpapi/exceptions.py +52 -0
- sharpapi/models.py +433 -0
- sharpapi/py.typed +0 -0
- sharpapi/streaming.py +200 -0
- sharpapi-0.1.0.dist-info/METADATA +203 -0
- sharpapi-0.1.0.dist-info/RECORD +12 -0
- sharpapi-0.1.0.dist-info/WHEEL +4 -0
sharpapi/exceptions.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""SharpAPI exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SharpAPIError(Exception):
|
|
7
|
+
"""Base exception for all SharpAPI errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, code: str | None = None, status: int | None = None):
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.code = code
|
|
12
|
+
self.status = status
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthenticationError(SharpAPIError):
|
|
16
|
+
"""API key is missing or invalid (401)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TierRestrictedError(SharpAPIError):
|
|
20
|
+
"""Feature not available on current tier (403)."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
message: str,
|
|
25
|
+
code: str | None = None,
|
|
26
|
+
status: int | None = None,
|
|
27
|
+
required_tier: str | None = None,
|
|
28
|
+
):
|
|
29
|
+
super().__init__(message, code, status)
|
|
30
|
+
self.required_tier = required_tier
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RateLimitedError(SharpAPIError):
|
|
34
|
+
"""Too many requests (429)."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
message: str,
|
|
39
|
+
code: str | None = None,
|
|
40
|
+
status: int | None = None,
|
|
41
|
+
retry_after: float | None = None,
|
|
42
|
+
):
|
|
43
|
+
super().__init__(message, code, status)
|
|
44
|
+
self.retry_after = retry_after
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ValidationError(SharpAPIError):
|
|
48
|
+
"""Invalid request parameters (400)."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class StreamError(SharpAPIError):
|
|
52
|
+
"""Error during SSE streaming."""
|
sharpapi/models.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Pydantic models for SharpAPI responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Generic, List, Optional, TypeVar
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# =============================================================================
|
|
13
|
+
# Common
|
|
14
|
+
# =============================================================================
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OddsValue(BaseModel):
|
|
18
|
+
"""Odds in multiple formats."""
|
|
19
|
+
|
|
20
|
+
american: int | float
|
|
21
|
+
decimal: float
|
|
22
|
+
probability: float
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Pagination(BaseModel):
|
|
26
|
+
limit: int
|
|
27
|
+
offset: int
|
|
28
|
+
has_more: bool
|
|
29
|
+
next_offset: Optional[int] = None
|
|
30
|
+
total: Optional[int] = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ResponseMeta(BaseModel):
|
|
34
|
+
"""Metadata returned with API responses."""
|
|
35
|
+
|
|
36
|
+
count: Optional[int] = None
|
|
37
|
+
total: Optional[int] = None
|
|
38
|
+
pagination: Optional[Pagination] = None
|
|
39
|
+
updated: Optional[str] = None
|
|
40
|
+
source: Optional[str] = None
|
|
41
|
+
last_update: Optional[str] = None
|
|
42
|
+
data_age_seconds: Optional[float] = None
|
|
43
|
+
filters: Optional[dict[str, Any]] = None
|
|
44
|
+
summary: Optional[dict[str, Any]] = None
|
|
45
|
+
books_analyzed: Optional[int] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class APIResponse(BaseModel, Generic[T]):
|
|
49
|
+
"""Standard API response wrapper."""
|
|
50
|
+
|
|
51
|
+
success: Optional[bool] = None
|
|
52
|
+
data: T
|
|
53
|
+
meta: Optional[ResponseMeta] = None
|
|
54
|
+
timestamp: Optional[str] = None
|
|
55
|
+
tier: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
def to_dataframe(self, flatten: bool = True):
|
|
58
|
+
"""Convert response data to a pandas DataFrame.
|
|
59
|
+
|
|
60
|
+
Requires ``pip install sharpapi[pandas]``.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
flatten: If True (default), flatten nested objects like
|
|
64
|
+
``game_state.period`` into ``game_state_period`` columns.
|
|
65
|
+
Nested lists (like ``legs``) remain as-is.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
pandas.DataFrame with one row per item in ``data``.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
import pandas as pd
|
|
72
|
+
except ImportError:
|
|
73
|
+
raise ImportError(
|
|
74
|
+
"pandas is required for to_dataframe(). "
|
|
75
|
+
"Install it with: pip install sharpapi[pandas]"
|
|
76
|
+
) from None
|
|
77
|
+
|
|
78
|
+
data = self.data
|
|
79
|
+
if not data:
|
|
80
|
+
return pd.DataFrame()
|
|
81
|
+
|
|
82
|
+
if not isinstance(data, list):
|
|
83
|
+
data = [data]
|
|
84
|
+
|
|
85
|
+
rows = []
|
|
86
|
+
for item in data:
|
|
87
|
+
if hasattr(item, "model_dump"):
|
|
88
|
+
row = item.model_dump()
|
|
89
|
+
else:
|
|
90
|
+
row = dict(item) if isinstance(item, dict) else {"value": item}
|
|
91
|
+
|
|
92
|
+
if flatten:
|
|
93
|
+
row = _flatten_dict(row)
|
|
94
|
+
rows.append(row)
|
|
95
|
+
|
|
96
|
+
return pd.DataFrame(rows)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _flatten_dict(d: dict, parent_key: str = "", sep: str = "_") -> dict:
|
|
100
|
+
"""Flatten nested dicts, skip lists."""
|
|
101
|
+
items: list[tuple[str, Any]] = []
|
|
102
|
+
for k, v in d.items():
|
|
103
|
+
new_key = f"{parent_key}{sep}{k}" if parent_key else k
|
|
104
|
+
if isinstance(v, dict):
|
|
105
|
+
items.extend(_flatten_dict(v, new_key, sep).items())
|
|
106
|
+
else:
|
|
107
|
+
items.append((new_key, v))
|
|
108
|
+
return dict(items)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class GameState(BaseModel):
|
|
112
|
+
"""Live game state."""
|
|
113
|
+
|
|
114
|
+
period: Optional[str] = None
|
|
115
|
+
clock: Optional[str] = None
|
|
116
|
+
score_home: Optional[int] = None
|
|
117
|
+
score_away: Optional[int] = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# =============================================================================
|
|
121
|
+
# Odds
|
|
122
|
+
# =============================================================================
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class OddsLine(BaseModel):
|
|
126
|
+
"""A single odds line from a sportsbook."""
|
|
127
|
+
|
|
128
|
+
id: str
|
|
129
|
+
sportsbook: str
|
|
130
|
+
sportsbook_name: Optional[str] = None
|
|
131
|
+
event_id: str
|
|
132
|
+
sport: str
|
|
133
|
+
league: str
|
|
134
|
+
home_team: str
|
|
135
|
+
away_team: str
|
|
136
|
+
market_type: str
|
|
137
|
+
selection: str
|
|
138
|
+
selection_type: Optional[str] = None
|
|
139
|
+
odds_american: int | float
|
|
140
|
+
odds_decimal: float
|
|
141
|
+
probability: float
|
|
142
|
+
line: Optional[float] = None
|
|
143
|
+
event_start_time: Optional[str] = None
|
|
144
|
+
timestamp: Optional[str] = None
|
|
145
|
+
is_live: bool = False
|
|
146
|
+
deep_link: Optional[str] = None
|
|
147
|
+
player_name: Optional[str] = None
|
|
148
|
+
stat_category: Optional[str] = None
|
|
149
|
+
home_score: Optional[int] = None
|
|
150
|
+
away_score: Optional[int] = None
|
|
151
|
+
game_period: Optional[str] = None
|
|
152
|
+
game_clock: Optional[str] = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# =============================================================================
|
|
156
|
+
# EV Opportunities
|
|
157
|
+
# =============================================================================
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class EVOpportunity(BaseModel):
|
|
161
|
+
"""A positive expected value (+EV) opportunity."""
|
|
162
|
+
|
|
163
|
+
id: str
|
|
164
|
+
event_id: Optional[str] = Field(None, alias="game_id")
|
|
165
|
+
event_name: Optional[str] = Field(None, alias="game")
|
|
166
|
+
sport: str
|
|
167
|
+
league: str
|
|
168
|
+
market_type: Optional[str] = Field(None, alias="market")
|
|
169
|
+
selection: str
|
|
170
|
+
sportsbook: str
|
|
171
|
+
odds_american: int | float
|
|
172
|
+
odds_decimal: float
|
|
173
|
+
no_vig_odds: Optional[float] = None
|
|
174
|
+
true_probability: Optional[float] = None
|
|
175
|
+
ev_percent: float = Field(alias="ev_percent")
|
|
176
|
+
kelly_fraction: Optional[float] = None
|
|
177
|
+
confidence_score: Optional[float] = None
|
|
178
|
+
book_count: Optional[int] = None
|
|
179
|
+
market_width: Optional[float] = None
|
|
180
|
+
devig_method: Optional[str] = None
|
|
181
|
+
devig_book: Optional[str] = None
|
|
182
|
+
sharp_odds_american: Optional[int | float] = None
|
|
183
|
+
sharp_odds_decimal: Optional[float] = None
|
|
184
|
+
line: Optional[float] = None
|
|
185
|
+
home_team: Optional[str] = None
|
|
186
|
+
away_team: Optional[str] = None
|
|
187
|
+
start_time: Optional[str] = None
|
|
188
|
+
is_live: bool = False
|
|
189
|
+
arb_available: Optional[bool] = None
|
|
190
|
+
arb_profit: Optional[float] = None
|
|
191
|
+
is_player_prop: bool = False
|
|
192
|
+
player_name: Optional[str] = None
|
|
193
|
+
stat_category: Optional[str] = None
|
|
194
|
+
possibly_stale: bool = False
|
|
195
|
+
oldest_odds_age_seconds: Optional[float] = None
|
|
196
|
+
warnings: List[str] = Field(default_factory=list)
|
|
197
|
+
detected_at: Optional[str] = None
|
|
198
|
+
external_event_id: Optional[str] = None
|
|
199
|
+
selection_id: Optional[str] = None
|
|
200
|
+
|
|
201
|
+
model_config = {"populate_by_name": True}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# =============================================================================
|
|
205
|
+
# Arbitrage Opportunities
|
|
206
|
+
# =============================================================================
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class ArbitrageLeg(BaseModel):
|
|
210
|
+
"""One leg of an arbitrage opportunity."""
|
|
211
|
+
|
|
212
|
+
sportsbook: str
|
|
213
|
+
selection: str
|
|
214
|
+
odds_american: int | float
|
|
215
|
+
odds_decimal: float
|
|
216
|
+
implied_probability: Optional[float] = None
|
|
217
|
+
stake_percent: float
|
|
218
|
+
timestamp: Optional[str] = None
|
|
219
|
+
external_event_id: Optional[str] = None
|
|
220
|
+
selection_id: Optional[str] = None
|
|
221
|
+
market_id: Optional[str] = None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class ArbitrageOpportunity(BaseModel):
|
|
225
|
+
"""A guaranteed-profit arbitrage opportunity."""
|
|
226
|
+
|
|
227
|
+
id: str
|
|
228
|
+
event_id: Optional[str] = None
|
|
229
|
+
event_name: str
|
|
230
|
+
sport: str
|
|
231
|
+
league: Optional[str] = None
|
|
232
|
+
market_type: str
|
|
233
|
+
line: Optional[float] = None
|
|
234
|
+
profit_percent: float
|
|
235
|
+
implied_total: Optional[float] = None
|
|
236
|
+
estimated_net_profit_percent: Optional[float] = None
|
|
237
|
+
start_time: Optional[str] = None
|
|
238
|
+
is_live: bool = False
|
|
239
|
+
is_alternate_line: bool = False
|
|
240
|
+
possibly_stale: bool = False
|
|
241
|
+
oldest_odds_age_seconds: Optional[float] = None
|
|
242
|
+
warnings: List[str] = Field(default_factory=list)
|
|
243
|
+
game_state: Optional[GameState] = None
|
|
244
|
+
ev_available: Optional[bool] = None
|
|
245
|
+
ev_percentage: Optional[float] = None
|
|
246
|
+
is_player_prop: bool = False
|
|
247
|
+
player_name: Optional[str] = None
|
|
248
|
+
stat_category: Optional[str] = None
|
|
249
|
+
legs: List[ArbitrageLeg]
|
|
250
|
+
detected_at: Optional[str] = None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# =============================================================================
|
|
254
|
+
# Middle Opportunities
|
|
255
|
+
# =============================================================================
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class MiddleSide(BaseModel):
|
|
259
|
+
"""One side of a middle opportunity."""
|
|
260
|
+
|
|
261
|
+
book: str
|
|
262
|
+
selection: str
|
|
263
|
+
line: float
|
|
264
|
+
odds: OddsValue
|
|
265
|
+
stake_percent: Optional[float] = None
|
|
266
|
+
odds_age_seconds: Optional[float] = None
|
|
267
|
+
deep_link: Optional[str] = None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class MiddleOpportunity(BaseModel):
|
|
271
|
+
"""A middle opportunity where both sides can win."""
|
|
272
|
+
|
|
273
|
+
id: str
|
|
274
|
+
event_id: Optional[str] = None
|
|
275
|
+
event_name: str
|
|
276
|
+
sport: str
|
|
277
|
+
league: Optional[str] = None
|
|
278
|
+
market_type: str
|
|
279
|
+
home_team: Optional[str] = None
|
|
280
|
+
away_team: Optional[str] = None
|
|
281
|
+
start_time: Optional[str] = None
|
|
282
|
+
side1: Optional[MiddleSide] = None
|
|
283
|
+
side2: Optional[MiddleSide] = None
|
|
284
|
+
middle_size: Optional[float] = None
|
|
285
|
+
middle_numbers: Optional[List[int]] = None
|
|
286
|
+
middle_probability: Optional[float] = None
|
|
287
|
+
expected_value: Optional[float] = None
|
|
288
|
+
roi_percentage: Optional[float] = None
|
|
289
|
+
worst_case_loss: Optional[float] = None
|
|
290
|
+
best_case_profit: Optional[float] = None
|
|
291
|
+
break_even_percent: Optional[float] = None
|
|
292
|
+
is_guaranteed_profit: bool = False
|
|
293
|
+
guaranteed_roi: Optional[float] = None
|
|
294
|
+
key_numbers: Optional[List[int]] = None
|
|
295
|
+
key_number_probability: Optional[float] = None
|
|
296
|
+
quality_score: Optional[float] = None
|
|
297
|
+
market_overround: Optional[float] = None
|
|
298
|
+
is_live: bool = False
|
|
299
|
+
game_state: Optional[GameState] = None
|
|
300
|
+
is_player_prop: bool = False
|
|
301
|
+
player_name: Optional[str] = None
|
|
302
|
+
stat_category: Optional[str] = None
|
|
303
|
+
odds_age_seconds: Optional[float] = None
|
|
304
|
+
warnings: List[str] = Field(default_factory=list)
|
|
305
|
+
detected_at: Optional[str] = None
|
|
306
|
+
# Flat fields (alternative to side1/side2 nesting)
|
|
307
|
+
gap_size: Optional[float] = Field(None, alias="gapSize")
|
|
308
|
+
potential_profit: Optional[float] = Field(None, alias="potentialProfit")
|
|
309
|
+
legs: Optional[List[ArbitrageLeg]] = None
|
|
310
|
+
|
|
311
|
+
model_config = {"populate_by_name": True}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# =============================================================================
|
|
315
|
+
# Low Hold
|
|
316
|
+
# =============================================================================
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class LowHoldSide(BaseModel):
|
|
320
|
+
"""One side of a low-hold opportunity."""
|
|
321
|
+
|
|
322
|
+
selection: str
|
|
323
|
+
books: Optional[List[str]] = None
|
|
324
|
+
line: Optional[float] = None
|
|
325
|
+
odds: Optional[OddsValue] = None
|
|
326
|
+
deep_links: Optional[dict[str, str]] = None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class LowHoldOpportunity(BaseModel):
|
|
330
|
+
"""A low-hold (low vig) market."""
|
|
331
|
+
|
|
332
|
+
id: str
|
|
333
|
+
event_id: Optional[str] = None
|
|
334
|
+
event_name: str
|
|
335
|
+
sport: str
|
|
336
|
+
league: Optional[str] = None
|
|
337
|
+
market_type: str
|
|
338
|
+
line: Optional[float] = None
|
|
339
|
+
home_team: Optional[str] = None
|
|
340
|
+
away_team: Optional[str] = None
|
|
341
|
+
start_time: Optional[str] = None
|
|
342
|
+
hold_percentage: float
|
|
343
|
+
side1: Optional[LowHoldSide] = None
|
|
344
|
+
side2: Optional[LowHoldSide] = None
|
|
345
|
+
side3: Optional[LowHoldSide] = None
|
|
346
|
+
is_live: bool = False
|
|
347
|
+
game_state: Optional[GameState] = None
|
|
348
|
+
is_alternate_line: bool = False
|
|
349
|
+
all_books: Optional[List[str]] = None
|
|
350
|
+
confidence: Optional[float] = None
|
|
351
|
+
odds_age_seconds: Optional[float] = None
|
|
352
|
+
possibly_stale: bool = False
|
|
353
|
+
is_player_prop: bool = False
|
|
354
|
+
player_name: Optional[str] = None
|
|
355
|
+
stat_category: Optional[str] = None
|
|
356
|
+
detected_at: Optional[str] = None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# =============================================================================
|
|
360
|
+
# Reference Data
|
|
361
|
+
# =============================================================================
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class Sport(BaseModel):
|
|
365
|
+
id: str
|
|
366
|
+
name: str
|
|
367
|
+
slug: str
|
|
368
|
+
active: bool
|
|
369
|
+
event_count: Optional[int] = None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class League(BaseModel):
|
|
373
|
+
id: str
|
|
374
|
+
name: str
|
|
375
|
+
slug: str
|
|
376
|
+
sport_id: Optional[str] = None
|
|
377
|
+
country: Optional[str] = None
|
|
378
|
+
active: bool
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class Sportsbook(BaseModel):
|
|
382
|
+
id: str
|
|
383
|
+
name: str
|
|
384
|
+
slug: str
|
|
385
|
+
active: bool
|
|
386
|
+
regions: Optional[List[str]] = None
|
|
387
|
+
features: Optional[List[str]] = None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class Event(BaseModel):
|
|
391
|
+
id: str
|
|
392
|
+
sport: str
|
|
393
|
+
league: str
|
|
394
|
+
home_team: str
|
|
395
|
+
away_team: str
|
|
396
|
+
start_time: Optional[str] = None
|
|
397
|
+
is_live: bool = False
|
|
398
|
+
status: Optional[str] = None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# =============================================================================
|
|
402
|
+
# Account
|
|
403
|
+
# =============================================================================
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class AccountLimits(BaseModel):
|
|
407
|
+
requests_per_minute: Optional[int] = None
|
|
408
|
+
max_streams: Optional[int] = None
|
|
409
|
+
odds_delay_seconds: Optional[int] = None
|
|
410
|
+
max_books: Optional[int] = None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
class AccountFeatures(BaseModel):
|
|
414
|
+
ev: bool = False
|
|
415
|
+
arbitrage: bool = False
|
|
416
|
+
middles: bool = False
|
|
417
|
+
streaming: bool = False
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class AccountInfo(BaseModel):
|
|
421
|
+
key: Optional[dict[str, Any]] = None
|
|
422
|
+
limits: Optional[AccountLimits] = None
|
|
423
|
+
features: Optional[AccountFeatures] = None
|
|
424
|
+
add_ons: Optional[List[str]] = None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class RateLimitInfo(BaseModel):
|
|
428
|
+
"""Rate limit state from response headers."""
|
|
429
|
+
|
|
430
|
+
limit: Optional[int] = None
|
|
431
|
+
remaining: Optional[int] = None
|
|
432
|
+
reset: Optional[float] = None
|
|
433
|
+
tier: Optional[str] = None
|
sharpapi/py.typed
ADDED
|
File without changes
|
sharpapi/streaming.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""SSE streaming client for SharpAPI real-time data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Callable, Iterator
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .exceptions import AuthenticationError, StreamError
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("sharpapi.stream")
|
|
15
|
+
|
|
16
|
+
EventHandler = Callable[[Any], None]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EventStream:
|
|
20
|
+
"""Server-Sent Events (SSE) stream client.
|
|
21
|
+
|
|
22
|
+
Connects to the SharpAPI streaming endpoint and dispatches typed events
|
|
23
|
+
to registered handlers.
|
|
24
|
+
|
|
25
|
+
Example::
|
|
26
|
+
|
|
27
|
+
stream = client.stream.opportunities(league="nba")
|
|
28
|
+
|
|
29
|
+
@stream.on("ev:detected")
|
|
30
|
+
def handle_ev(data):
|
|
31
|
+
print(f"+EV: {data}")
|
|
32
|
+
|
|
33
|
+
@stream.on("arb:detected")
|
|
34
|
+
def handle_arb(data):
|
|
35
|
+
print(f"Arb: {data}")
|
|
36
|
+
|
|
37
|
+
stream.connect() # Blocks, processing events
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
url: str,
|
|
43
|
+
headers: dict[str, str],
|
|
44
|
+
timeout: float = 90.0,
|
|
45
|
+
max_reconnects: int = 5,
|
|
46
|
+
):
|
|
47
|
+
self._url = url
|
|
48
|
+
self._headers = headers
|
|
49
|
+
self._timeout = timeout
|
|
50
|
+
self._max_reconnects = max_reconnects
|
|
51
|
+
self._handlers: dict[str, list[EventHandler]] = {}
|
|
52
|
+
self._running = False
|
|
53
|
+
self._last_event_id: str | None = None
|
|
54
|
+
|
|
55
|
+
def on(self, event_type: str, handler: EventHandler | None = None):
|
|
56
|
+
"""Register a handler for an event type. Can be used as a decorator.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
event_type: SSE event name (e.g. "ev:detected", "arb:detected",
|
|
60
|
+
"odds:update", "snapshot", "heartbeat", "error")
|
|
61
|
+
handler: Callback receiving parsed event data. If None, returns
|
|
62
|
+
a decorator.
|
|
63
|
+
|
|
64
|
+
Example::
|
|
65
|
+
|
|
66
|
+
@stream.on("ev:detected")
|
|
67
|
+
def handle_ev(data):
|
|
68
|
+
print(data)
|
|
69
|
+
|
|
70
|
+
# Or without decorator:
|
|
71
|
+
stream.on("arb:detected", my_handler)
|
|
72
|
+
"""
|
|
73
|
+
if handler is not None:
|
|
74
|
+
self._handlers.setdefault(event_type, []).append(handler)
|
|
75
|
+
return handler
|
|
76
|
+
|
|
77
|
+
def decorator(fn: EventHandler) -> EventHandler:
|
|
78
|
+
self._handlers.setdefault(event_type, []).append(fn)
|
|
79
|
+
return fn
|
|
80
|
+
|
|
81
|
+
return decorator
|
|
82
|
+
|
|
83
|
+
def off(self, event_type: str, handler: EventHandler) -> None:
|
|
84
|
+
"""Remove a handler for an event type."""
|
|
85
|
+
handlers = self._handlers.get(event_type, [])
|
|
86
|
+
if handler in handlers:
|
|
87
|
+
handlers.remove(handler)
|
|
88
|
+
|
|
89
|
+
def _emit(self, event_type: str, data: Any) -> None:
|
|
90
|
+
for handler in self._handlers.get(event_type, []):
|
|
91
|
+
try:
|
|
92
|
+
handler(data)
|
|
93
|
+
except Exception:
|
|
94
|
+
logger.exception("Handler error for event %s", event_type)
|
|
95
|
+
# Also emit to wildcard handlers
|
|
96
|
+
for handler in self._handlers.get("*", []):
|
|
97
|
+
try:
|
|
98
|
+
handler({"type": event_type, "data": data})
|
|
99
|
+
except Exception:
|
|
100
|
+
logger.exception("Wildcard handler error for event %s", event_type)
|
|
101
|
+
|
|
102
|
+
def connect(self) -> None:
|
|
103
|
+
"""Connect and block, processing events until disconnect() or error.
|
|
104
|
+
|
|
105
|
+
Automatically reconnects with exponential backoff on connection loss.
|
|
106
|
+
"""
|
|
107
|
+
self._running = True
|
|
108
|
+
reconnect_attempts = 0
|
|
109
|
+
|
|
110
|
+
while self._running and reconnect_attempts <= self._max_reconnects:
|
|
111
|
+
try:
|
|
112
|
+
self._stream_loop()
|
|
113
|
+
if not self._running:
|
|
114
|
+
break
|
|
115
|
+
except (httpx.ConnectError, httpx.ReadTimeout, httpx.RemoteProtocolError) as e:
|
|
116
|
+
reconnect_attempts += 1
|
|
117
|
+
if reconnect_attempts > self._max_reconnects:
|
|
118
|
+
raise StreamError(
|
|
119
|
+
f"Max reconnection attempts ({self._max_reconnects}) reached",
|
|
120
|
+
code="max_reconnects",
|
|
121
|
+
) from e # noqa: B904
|
|
122
|
+
delay = min(2**reconnect_attempts, 30)
|
|
123
|
+
logger.warning(
|
|
124
|
+
"Connection lost, reconnecting in %ds (attempt %d/%d)",
|
|
125
|
+
delay,
|
|
126
|
+
reconnect_attempts,
|
|
127
|
+
self._max_reconnects,
|
|
128
|
+
)
|
|
129
|
+
time.sleep(delay)
|
|
130
|
+
except httpx.HTTPStatusError as e:
|
|
131
|
+
if e.response.status_code == 401:
|
|
132
|
+
raise AuthenticationError(
|
|
133
|
+
"Invalid API key", code="invalid_api_key", status=401
|
|
134
|
+
) from e
|
|
135
|
+
raise StreamError(
|
|
136
|
+
f"HTTP {e.response.status_code}", code="http_error", status=e.response.status_code
|
|
137
|
+
) from e
|
|
138
|
+
|
|
139
|
+
def _stream_loop(self) -> None:
|
|
140
|
+
headers = {**self._headers, "Accept": "text/event-stream"}
|
|
141
|
+
if self._last_event_id:
|
|
142
|
+
headers["Last-Event-ID"] = self._last_event_id
|
|
143
|
+
|
|
144
|
+
with httpx.Client(timeout=httpx.Timeout(self._timeout, connect=10.0)) as http:
|
|
145
|
+
with http.stream("GET", self._url, headers=headers) as response:
|
|
146
|
+
response.raise_for_status()
|
|
147
|
+
for event_type, data in _parse_sse(response.iter_lines()):
|
|
148
|
+
if not self._running:
|
|
149
|
+
break
|
|
150
|
+
self._emit(event_type, data)
|
|
151
|
+
|
|
152
|
+
def disconnect(self) -> None:
|
|
153
|
+
"""Stop the stream."""
|
|
154
|
+
self._running = False
|
|
155
|
+
|
|
156
|
+
def iter_events(self) -> Iterator[tuple[str, Any]]:
|
|
157
|
+
"""Iterate over events as (event_type, data) tuples.
|
|
158
|
+
|
|
159
|
+
Example::
|
|
160
|
+
|
|
161
|
+
for event_type, data in stream.iter_events():
|
|
162
|
+
if event_type == "ev:detected":
|
|
163
|
+
print(data)
|
|
164
|
+
"""
|
|
165
|
+
self._running = True
|
|
166
|
+
headers = {**self._headers, "Accept": "text/event-stream"}
|
|
167
|
+
if self._last_event_id:
|
|
168
|
+
headers["Last-Event-ID"] = self._last_event_id
|
|
169
|
+
|
|
170
|
+
with httpx.Client(timeout=httpx.Timeout(self._timeout, connect=10.0)) as http:
|
|
171
|
+
with http.stream("GET", self._url, headers=headers) as response:
|
|
172
|
+
response.raise_for_status()
|
|
173
|
+
for event_type, data in _parse_sse(response.iter_lines()):
|
|
174
|
+
if not self._running:
|
|
175
|
+
break
|
|
176
|
+
yield event_type, data
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _parse_sse(lines: Iterator[str]) -> Iterator[tuple[str, Any]]:
|
|
180
|
+
"""Parse SSE text stream into (event_type, parsed_data) tuples."""
|
|
181
|
+
event_type = "message"
|
|
182
|
+
data_lines: list[str] = []
|
|
183
|
+
|
|
184
|
+
for line in lines:
|
|
185
|
+
if line.startswith("event:"):
|
|
186
|
+
event_type = line[6:].strip()
|
|
187
|
+
elif line.startswith("data:"):
|
|
188
|
+
data_lines.append(line[5:].strip())
|
|
189
|
+
elif line.startswith("id:"):
|
|
190
|
+
pass # Tracked by httpx/EventSource
|
|
191
|
+
elif line == "" and data_lines:
|
|
192
|
+
# End of event
|
|
193
|
+
raw = "\n".join(data_lines)
|
|
194
|
+
try:
|
|
195
|
+
parsed = json.loads(raw)
|
|
196
|
+
except json.JSONDecodeError:
|
|
197
|
+
parsed = raw
|
|
198
|
+
yield event_type, parsed
|
|
199
|
+
event_type = "message"
|
|
200
|
+
data_lines = []
|