Skip to content

Commit 563ddf4

Browse files
Mlaz-codeclaude
andauthored
feat: drop game_state from opportunity models + add gamestate resource (v0.2.6) (#5)
* feat!: drop game_state from opportunity models (v0.3.0) The API server no longer emits a `game_state` object on the EV, arbitrage, or low-hold response paths. Live game state (scores, period, clock) is served exclusively by `/api/v1/gamestate` and the `gamestate` stream channel — correlate by `event_id`. - Remove `game_state` from ArbitrageOpportunity, MiddleOpportunity, and LowHoldOpportunity. - Remove vestigial `home_score`, `away_score`, `game_period`, `game_clock` fields from OddsLine (the /api/v1/odds endpoint never populated them). - Keep the `GameState` model exported for users consuming the gamestate endpoint. - Bump 0.2.5 -> 0.3.0. BREAKING CHANGE: code that read `arb.game_state`, `lh.game_state`, or `mid.game_state` will need to fetch state via the gamestate endpoint and join by `event_id`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(gamestate): add gamestate resource + stream method The previous commit dropped `game_state` from EV / arb / low-hold rows. This adds a first-class replacement: `client.gamestate.get(sport=...)` fetches `/api/v1/gamestate{/<sport>}` and parses the response into a `{sport: {event_id: GameState}}` map. Mirrored on the async client and as `client.stream.gamestate()` for SSE. The `GameState` model is rewritten to match what the gamestate endpoint actually emits — `home_score`, `away_score`, `game_period`, `game_clock`, `primary_book`, `book_count`, `stale`, `aggregator_stale` (plus team / sport pass-through). `extra="allow"` keeps adapter-specific fields from being silently dropped. Bundled with the v0.3.0 breaking change so users have a clean migration: where they used to read `arb.game_state.period`, they now do ``state[opp.sport][opp.event_id].game_period`` after a single `client.gamestate.get()` per refresh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(version): bump 0.2.5 -> 0.2.6 (patch, not minor) Earlier commit jumped to 0.3.0 to flag the breaking change. Project preference is to use 0.0.1 patch bumps; switch the version on this branch back to 0.2.6 so the release line stays continuous. The drop of `game_state` from EV / arb / low-hold rows is still highlighted as BREAKING in the commit message and PR — semver-strict downstreams should pin accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent db1c609 commit 563ddf4

10 files changed

Lines changed: 270 additions & 46 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "sharpapi"
7-
version = "0.2.5"
7+
version = "0.2.6"
88
description = "Official Python SDK for the SharpAPI real-time sports betting odds API"
99
readme = "README.md"
1010
license = "MIT"

src/sharpapi/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
)
5858
from .streaming import EventStream
5959

60-
__version__ = "0.2.5"
60+
__version__ = "0.2.6"
6161

6262
__all__ = [
6363
# Clients

src/sharpapi/async_client.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
ClosingSnapshot,
3030
Event,
3131
EVOpportunity,
32+
GameState,
3233
League,
3334
LowHoldOpportunity,
3435
Market,
@@ -103,6 +104,7 @@ def __init__(
103104
self.arbitrage = _AsyncArbitrageResource(self)
104105
self.middles = _AsyncMiddlesResource(self)
105106
self.low_hold = _AsyncLowHoldResource(self)
107+
self.gamestate = _AsyncGameStateResource(self)
106108
self.sports = _AsyncSportsResource(self)
107109
self.leagues = _AsyncLeaguesResource(self)
108110
self.sportsbooks = _AsyncSportsbooksResource(self)
@@ -426,6 +428,41 @@ async def get(
426428
return parse_response(data, LowHoldOpportunity)
427429

428430

431+
class _AsyncGameStateResource:
432+
"""Async access to live game state — scores, period, clock —
433+
merged across sportsbooks.
434+
435+
Requires the Game State add-on ($79/mo) or Enterprise tier.
436+
"""
437+
438+
def __init__(self, client: AsyncSharpAPI):
439+
self._client = client
440+
441+
async def get(self, sport: str | None = None) -> dict[str, dict[str, GameState]]:
442+
"""Fetch the current game state.
443+
444+
Args:
445+
sport: Limit to a single sport (e.g. ``"basketball"``).
446+
Omit to fetch every sport at once.
447+
448+
Returns:
449+
Nested mapping ``{sport: {event_id: GameState}}``.
450+
"""
451+
path = f"/gamestate/{sport}" if sport else "/gamestate"
452+
data = await self._client._get(path)
453+
raw = data.get("data", {}) or {}
454+
result: dict[str, dict[str, GameState]] = {}
455+
for sport_key, events in raw.items():
456+
if not isinstance(events, dict):
457+
continue
458+
result[sport_key] = {
459+
eid: GameState.model_validate(state)
460+
for eid, state in events.items()
461+
if isinstance(state, dict)
462+
}
463+
return result
464+
465+
429466
class _AsyncSportsResource:
430467
def __init__(self, client: AsyncSharpAPI):
431468
self._client = client

src/sharpapi/client.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
ClosingSnapshot,
3030
Event,
3131
EVOpportunity,
32+
GameState,
3233
League,
3334
LowHoldOpportunity,
3435
Market,
@@ -111,6 +112,7 @@ def __init__(
111112
self.arbitrage = _ArbitrageResource(self)
112113
self.middles = _MiddlesResource(self)
113114
self.low_hold = _LowHoldResource(self)
115+
self.gamestate = _GameStateResource(self)
114116
self.sports = _SportsResource(self)
115117
self.leagues = _LeaguesResource(self)
116118
self.sportsbooks = _SportsbooksResource(self)
@@ -542,6 +544,44 @@ def get(
542544
return _parse_response(data, LowHoldOpportunity)
543545

544546

547+
class _GameStateResource:
548+
"""Live game state — scores, period, clock — merged across sportsbooks.
549+
550+
Requires the Game State add-on ($79/mo) or Enterprise tier.
551+
Pair with EV / arb / low-hold rows: those endpoints no longer carry
552+
``game_state`` themselves — look up the row's ``event_id`` here.
553+
"""
554+
555+
def __init__(self, client: SharpAPI):
556+
self._client = client
557+
558+
def get(self, sport: str | None = None) -> dict[str, dict[str, GameState]]:
559+
"""Fetch the current game state.
560+
561+
Args:
562+
sport: Limit to a single sport (e.g. ``"basketball"``,
563+
``"football"``). Omit to fetch every sport at once.
564+
565+
Returns:
566+
Nested mapping ``{sport: {event_id: GameState}}``. Look up an
567+
opportunity's state with
568+
``result.get(opp.sport, {}).get(opp.event_id)``.
569+
"""
570+
path = f"/gamestate/{sport}" if sport else "/gamestate"
571+
data = self._client._get(path)
572+
raw = data.get("data", {}) or {}
573+
result: dict[str, dict[str, GameState]] = {}
574+
for sport_key, events in raw.items():
575+
if not isinstance(events, dict):
576+
continue
577+
result[sport_key] = {
578+
eid: GameState.model_validate(state)
579+
for eid, state in events.items()
580+
if isinstance(state, dict)
581+
}
582+
return result
583+
584+
545585
class _SportsResource:
546586
def __init__(self, client: SharpAPI):
547587
self._client = client
@@ -781,6 +821,15 @@ def event(
781821
"market": market,
782822
})
783823

824+
def gamestate(self) -> EventStream:
825+
"""Stream live game state updates (scores, period, clock).
826+
827+
Emits ``gamestate:snapshot`` (initial dump on connect) and
828+
``gamestate:update`` / ``gamestate:removed`` events. Requires the
829+
Game State add-on or Enterprise tier.
830+
"""
831+
return self._build_stream("/stream/gamestate")
832+
784833

785834
# =============================================================================
786835
# Helpers

src/sharpapi/models.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,9 @@ def to_dataframe(self, flatten: bool = True):
6060
Requires ``pip install sharpapi[pandas]``.
6161
6262
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.
63+
flatten: If True (default), flatten nested objects into
64+
underscore-joined columns. Nested lists (like ``legs``)
65+
remain as-is.
6666
6767
Returns:
6868
pandas.DataFrame with one row per item in ``data``.
@@ -109,12 +109,29 @@ def _flatten_dict(d: dict, parent_key: str = "", sep: str = "_") -> dict:
109109

110110

111111
class GameState(BaseModel):
112-
"""Live game state."""
112+
"""Live game state for a single event, merged across sportsbooks.
113113
114-
period: str | None = None
115-
clock: str | None = None
116-
score_home: int | None = None
117-
score_away: int | None = None
114+
Returned by ``/api/v1/gamestate`` and the ``gamestate`` stream channel.
115+
Scores are consensus-merged with period-guarded outlier rejection;
116+
period/clock are picked from the most-advanced book. Not present on
117+
EV / arb / low-hold opportunity rows — correlate by ``event_id``.
118+
119+
``extra="allow"`` lets adapter-specific fields pass through unchanged.
120+
"""
121+
122+
home_score: int | None = None
123+
away_score: int | None = None
124+
game_period: str | None = None
125+
game_clock: str | None = None
126+
home_team: str | None = None
127+
away_team: str | None = None
128+
sport: str | None = None
129+
primary_book: str | None = None
130+
book_count: int | None = None
131+
stale: bool = False
132+
aggregator_stale: bool = False
133+
134+
model_config = {"extra": "allow"}
118135

119136

120137
# =============================================================================
@@ -146,10 +163,6 @@ class OddsLine(BaseModel):
146163
deep_link: str | None = None
147164
player_name: str | None = None
148165
stat_category: str | None = None
149-
home_score: int | None = None
150-
away_score: int | None = None
151-
game_period: str | None = None
152-
game_clock: str | None = None
153166

154167

155168
# =============================================================================
@@ -248,7 +261,6 @@ class ArbitrageOpportunity(BaseModel):
248261
possibly_stale: bool = False
249262
oldest_odds_age_seconds: float | None = None
250263
warnings: list[str] = Field(default_factory=list)
251-
game_state: GameState | None = None
252264
ev_available: bool | None = None
253265
ev_percentage: float | None = None
254266
is_player_prop: bool = False
@@ -304,7 +316,6 @@ class MiddleOpportunity(BaseModel):
304316
quality_score: float | None = None
305317
market_overround: float | None = None
306318
is_live: bool = False
307-
game_state: GameState | None = None
308319
is_player_prop: bool = False
309320
player_name: str | None = None
310321
stat_category: str | None = None
@@ -352,7 +363,6 @@ class LowHoldOpportunity(BaseModel):
352363
side2: LowHoldSide | None = None
353364
side3: LowHoldSide | None = None
354365
is_live: bool = False
355-
game_state: GameState | None = None
356366
is_alternate_line: bool = False
357367
all_books: list[str] | None = None
358368
confidence: float | None = None

tests/conftest.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,6 @@
2828
"possibly_stale": False,
2929
"oldest_odds_age_seconds": 12.5,
3030
"warnings": ["LIVE_GAME"],
31-
"game_state": {
32-
"period": "Q2",
33-
"clock": "5:23",
34-
"score_home": 48,
35-
"score_away": 52,
36-
},
3731
"ev_available": True,
3832
"ev_percentage": 3.2,
3933
"is_player_prop": False,
@@ -249,6 +243,54 @@
249243
},
250244
}
251245

246+
GAMESTATE_RESPONSE = {
247+
"data": {
248+
"basketball": {
249+
"evt_lal_bos": {
250+
"home_score": 48,
251+
"away_score": 52,
252+
"game_period": "Q2",
253+
"game_clock": "5:23",
254+
"home_team": "Boston Celtics",
255+
"away_team": "Los Angeles Lakers",
256+
"sport": "basketball",
257+
"primary_book": "draftkings",
258+
"book_count": 4,
259+
"stale": False,
260+
},
261+
"evt_gsw_phx": {
262+
"home_score": 30,
263+
"away_score": 28,
264+
"game_period": "Q1",
265+
"game_clock": "1:42",
266+
"sport": "basketball",
267+
"primary_book": "fanduel",
268+
"book_count": 3,
269+
"aggregator_stale": True,
270+
},
271+
},
272+
"football": {
273+
"evt_kc_buf": {
274+
"home_score": 14,
275+
"away_score": 7,
276+
"game_period": "Q2",
277+
"game_clock": "8:15",
278+
"sport": "football",
279+
"primary_book": "draftkings",
280+
"book_count": 5,
281+
},
282+
},
283+
},
284+
"updated_at": "2026-04-25T20:30:00Z",
285+
}
286+
287+
GAMESTATE_BASKETBALL_RESPONSE = {
288+
"data": {
289+
"basketball": GAMESTATE_RESPONSE["data"]["basketball"],
290+
},
291+
"updated_at": "2026-04-25T20:30:00Z",
292+
}
293+
252294
ERROR_401 = {
253295
"error": {"code": "invalid_api_key", "message": "Invalid API key"},
254296
}

tests/test_async_client.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
AsyncSharpAPI,
1212
AuthenticationError,
1313
EVOpportunity,
14+
GameState,
1415
OddsLine,
1516
RateLimitedError,
1617
Sport,
@@ -24,6 +25,7 @@
2425
ERROR_401,
2526
ERROR_429,
2627
EV_RESPONSE,
28+
GAMESTATE_RESPONSE,
2729
ODDS_RESPONSE,
2830
RATE_LIMIT_HEADERS,
2931
SPORTS_RESPONSE,
@@ -51,6 +53,7 @@ def test_resource_namespaces_exist(self):
5153
assert hasattr(client, "arbitrage")
5254
assert hasattr(client, "middles")
5355
assert hasattr(client, "low_hold")
56+
assert hasattr(client, "gamestate")
5457
assert hasattr(client, "sports")
5558
assert hasattr(client, "leagues")
5659
assert hasattr(client, "sportsbooks")
@@ -177,6 +180,35 @@ async def test_get_ev(self):
177180
assert result.data[0].ev_percentage == 4.2
178181

179182

183+
class TestAsyncGameState:
184+
@pytest.mark.asyncio
185+
@respx.mock
186+
async def test_get_all_sports(self):
187+
respx.get(f"{BASE_URL}/api/v1/gamestate").mock(
188+
return_value=Response(200, json=GAMESTATE_RESPONSE)
189+
)
190+
async with AsyncSharpAPI(API_KEY) as client:
191+
result = await client.gamestate.get()
192+
state = result["basketball"]["evt_lal_bos"]
193+
assert isinstance(state, GameState)
194+
assert state.home_score == 48
195+
assert state.game_period == "Q2"
196+
197+
@pytest.mark.asyncio
198+
@respx.mock
199+
async def test_get_single_sport(self):
200+
route = respx.get(f"{BASE_URL}/api/v1/gamestate/basketball").mock(
201+
return_value=Response(200, json={
202+
"data": {"basketball": GAMESTATE_RESPONSE["data"]["basketball"]},
203+
"updated_at": "2026-04-25T20:30:00Z",
204+
})
205+
)
206+
async with AsyncSharpAPI(API_KEY) as client:
207+
result = await client.gamestate.get("basketball")
208+
assert route.called
209+
assert "football" not in result
210+
211+
180212
# =============================================================================
181213
# Async Reference Data
182214
# =============================================================================

0 commit comments

Comments
 (0)