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/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 = []