ps3838api 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.
Potentially problematic release.
This version of ps3838api might be problematic. Click here for more details.
- ps3838api/__init__.py +5 -0
- ps3838api/api.py +528 -0
- ps3838api/logic.py +146 -0
- ps3838api/matching.py +106 -0
- ps3838api/models/__init__.py +0 -0
- ps3838api/models/bets.py +249 -0
- ps3838api/models/errors.py +42 -0
- ps3838api/models/event.py +57 -0
- ps3838api/models/fixtures.py +53 -0
- ps3838api/models/lines.py +27 -0
- ps3838api/models/odds.py +107 -0
- ps3838api/models/tank.py +5 -0
- ps3838api/tank.py +237 -0
- ps3838api/totals.py +52 -0
- ps3838api/utils/match_leagues.py +71 -0
- ps3838api-0.1.0.dist-info/METADATA +192 -0
- ps3838api-0.1.0.dist-info/RECORD +20 -0
- ps3838api-0.1.0.dist-info/WHEEL +5 -0
- ps3838api-0.1.0.dist-info/licenses/LICENSE +8 -0
- ps3838api-0.1.0.dist-info/top_level.txt +1 -0
ps3838api/__init__.py
ADDED
ps3838api/api.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"""
|
|
2
|
+
bets_ps3838.py
|
|
3
|
+
|
|
4
|
+
A simplified module providing functions to interact with the PS3838 API.
|
|
5
|
+
All endpoints required for basic usage (sports, leagues, fixtures, odds,
|
|
6
|
+
totals, placing bets, client balance, etc.) are included.
|
|
7
|
+
|
|
8
|
+
Environment Variables:
|
|
9
|
+
PS3838_LOGIN: str # The account username
|
|
10
|
+
PS3838_PASSWORD: str # The account password
|
|
11
|
+
|
|
12
|
+
Python Version: 3.12.8 (Strict type hints)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import base64
|
|
17
|
+
import uuid
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
from typing import Literal, TypedDict, Any, NotRequired
|
|
22
|
+
from typing import cast
|
|
23
|
+
from typing import Callable, ParamSpec, TypeVar
|
|
24
|
+
from functools import wraps
|
|
25
|
+
|
|
26
|
+
from ps3838api.models.errors import (
|
|
27
|
+
AccessBlockedError,
|
|
28
|
+
BaseballOnlyArgumentError,
|
|
29
|
+
PS3838APIError,
|
|
30
|
+
WrongEndpoint,
|
|
31
|
+
)
|
|
32
|
+
from ps3838api.models.fixtures import FixturesResponse
|
|
33
|
+
from ps3838api.models.lines import LineResponse
|
|
34
|
+
from ps3838api.models.odds import OddsResponse
|
|
35
|
+
from ps3838api.models.bets import (
|
|
36
|
+
BetType,
|
|
37
|
+
BetsResponse,
|
|
38
|
+
FillType,
|
|
39
|
+
OddsFormat,
|
|
40
|
+
PlaceStraightBetResponse,
|
|
41
|
+
Side,
|
|
42
|
+
Team,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
###############################################################################
|
|
46
|
+
# Environment Variables & Authorization
|
|
47
|
+
###############################################################################
|
|
48
|
+
|
|
49
|
+
_USERNAME: str = os.environ.get("PS3838_LOGIN", "")
|
|
50
|
+
_PASSWORD: str = os.environ.get("PS3838_PASSWORD", "")
|
|
51
|
+
|
|
52
|
+
if not _USERNAME or not _PASSWORD:
|
|
53
|
+
raise ValueError("PS3838_LOGIN and PS3838_PASSWORD must be set in environment.")
|
|
54
|
+
|
|
55
|
+
# Basic HTTP Auth header
|
|
56
|
+
_token: str = base64.b64encode(f"{_USERNAME}:{_PASSWORD}".encode("utf-8")).decode(
|
|
57
|
+
"utf-8"
|
|
58
|
+
)
|
|
59
|
+
_HEADERS = {
|
|
60
|
+
"Authorization": f"Basic {_token}",
|
|
61
|
+
"User-Agent": "Mozilla/5.0 (PS3838 client)",
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
###############################################################################
|
|
67
|
+
# API Base
|
|
68
|
+
###############################################################################
|
|
69
|
+
|
|
70
|
+
_BASE_URL: str = "https://api.ps3838.com"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
###############################################################################
|
|
74
|
+
# TypedDict Classes
|
|
75
|
+
###############################################################################
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class BalanceData(TypedDict):
|
|
79
|
+
availableBalance: float
|
|
80
|
+
outstandingTransactions: float
|
|
81
|
+
givenCredit: float
|
|
82
|
+
currency: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PeriodData(TypedDict, total=False):
|
|
86
|
+
number: NotRequired[int]
|
|
87
|
+
description: NotRequired[str]
|
|
88
|
+
shortDescription: NotRequired[str]
|
|
89
|
+
spreadDescription: NotRequired[str]
|
|
90
|
+
moneylineDescription: NotRequired[str]
|
|
91
|
+
totalDescription: NotRequired[str]
|
|
92
|
+
team1TotalDescription: NotRequired[str]
|
|
93
|
+
team2TotalDescription: NotRequired[str]
|
|
94
|
+
spreadShortDescription: NotRequired[str]
|
|
95
|
+
moneylineShortDescription: NotRequired[str]
|
|
96
|
+
totalShortDescription: NotRequired[str]
|
|
97
|
+
team1TotalShortDescription: NotRequired[str]
|
|
98
|
+
team2TotalShortDescription: NotRequired[str]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class LeagueV3(TypedDict):
|
|
102
|
+
id: int
|
|
103
|
+
name: str
|
|
104
|
+
homeTeamType: NotRequired[str] # Usually "Team1" or "Team2"
|
|
105
|
+
hasOfferings: NotRequired[bool]
|
|
106
|
+
container: NotRequired[str] # Region/country (e.g., "England")
|
|
107
|
+
allowRoundRobins: NotRequired[bool]
|
|
108
|
+
leagueSpecialsCount: NotRequired[int]
|
|
109
|
+
eventSpecialsCount: NotRequired[int]
|
|
110
|
+
eventCount: NotRequired[int]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
###############################################################################
|
|
114
|
+
# A normal SPORTS dict (subset or full). You can move this to a JSON if desired.
|
|
115
|
+
###############################################################################
|
|
116
|
+
_SPORTS: dict[int, str] = {
|
|
117
|
+
1: "Badminton",
|
|
118
|
+
2: "Bandy",
|
|
119
|
+
3: "Baseball",
|
|
120
|
+
4: "Basketball",
|
|
121
|
+
5: "Beach Volleyball",
|
|
122
|
+
6: "Boxing",
|
|
123
|
+
7: "Chess",
|
|
124
|
+
8: "Cricket",
|
|
125
|
+
9: "Curling",
|
|
126
|
+
10: "Darts",
|
|
127
|
+
13: "Field Hockey",
|
|
128
|
+
14: "Floorball",
|
|
129
|
+
15: "Football",
|
|
130
|
+
16: "Futsal",
|
|
131
|
+
17: "Golf",
|
|
132
|
+
18: "Handball",
|
|
133
|
+
19: "Hockey",
|
|
134
|
+
20: "Horse Racing Specials",
|
|
135
|
+
21: "Lacrosse",
|
|
136
|
+
22: "Mixed Martial Arts",
|
|
137
|
+
23: "Other Sports",
|
|
138
|
+
24: "Politics",
|
|
139
|
+
26: "Rugby League",
|
|
140
|
+
27: "Rugby Union",
|
|
141
|
+
28: "Snooker",
|
|
142
|
+
29: "Soccer",
|
|
143
|
+
30: "Softball",
|
|
144
|
+
31: "Squash",
|
|
145
|
+
32: "Table Tennis",
|
|
146
|
+
33: "Tennis",
|
|
147
|
+
34: "Volleyball",
|
|
148
|
+
36: "Water Polo",
|
|
149
|
+
37: "Padel Tennis",
|
|
150
|
+
39: "Aussie Rules",
|
|
151
|
+
40: "Alpine Skiing",
|
|
152
|
+
41: "Biathlon",
|
|
153
|
+
42: "Ski Jumping",
|
|
154
|
+
43: "Cross Country",
|
|
155
|
+
44: "Formula 1",
|
|
156
|
+
45: "Cycling",
|
|
157
|
+
46: "Bobsleigh",
|
|
158
|
+
47: "Figure Skating",
|
|
159
|
+
48: "Freestyle Skiing",
|
|
160
|
+
49: "Luge",
|
|
161
|
+
50: "Nordic Combined",
|
|
162
|
+
51: "Short Track",
|
|
163
|
+
52: "Skeleton",
|
|
164
|
+
53: "Snow Boarding",
|
|
165
|
+
54: "Speed Skating",
|
|
166
|
+
55: "Olympics",
|
|
167
|
+
56: "Athletics",
|
|
168
|
+
57: "Crossfit",
|
|
169
|
+
58: "Entertainment",
|
|
170
|
+
59: "Archery",
|
|
171
|
+
60: "Drone Racing",
|
|
172
|
+
62: "Poker",
|
|
173
|
+
63: "Motorsport",
|
|
174
|
+
64: "Simulated Games",
|
|
175
|
+
65: "Sumo",
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
SOCCER_SPORT_ID = 29
|
|
179
|
+
BASEBALL_SPORT_ID = 3
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
P = ParamSpec("P")
|
|
183
|
+
R = TypeVar("R")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def raise_ps3838_api_errors(
|
|
187
|
+
func: Callable[P, requests.Response],
|
|
188
|
+
) -> Callable[P, dict[str, Any]]:
|
|
189
|
+
@wraps(func)
|
|
190
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> dict[str, Any]:
|
|
191
|
+
response = func(*args, **kwargs)
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
response.raise_for_status()
|
|
195
|
+
result = response.json()
|
|
196
|
+
except requests.exceptions.HTTPError as e:
|
|
197
|
+
if e.response and e.response.status_code == 405:
|
|
198
|
+
raise WrongEndpoint()
|
|
199
|
+
raise AccessBlockedError(
|
|
200
|
+
e.response.status_code if e.response else "Unknown"
|
|
201
|
+
)
|
|
202
|
+
except requests.exceptions.JSONDecodeError:
|
|
203
|
+
raise AccessBlockedError("Empty response")
|
|
204
|
+
|
|
205
|
+
match result:
|
|
206
|
+
case {"code": str(code), "message": str(message)}:
|
|
207
|
+
raise PS3838APIError(code=code, message=message)
|
|
208
|
+
case _:
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
return wrapper
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
###############################################################################
|
|
215
|
+
# Helper to make GET requests
|
|
216
|
+
###############################################################################
|
|
217
|
+
@raise_ps3838_api_errors
|
|
218
|
+
def _get(endpoint: str, params: dict[str, Any] | None = None):
|
|
219
|
+
"""
|
|
220
|
+
Internal helper to perform GET requests to the PS3838 API.
|
|
221
|
+
Returns JSON-decoded response as Any. Raise errors on non-200.
|
|
222
|
+
"""
|
|
223
|
+
url: str = f"{_BASE_URL}{endpoint}"
|
|
224
|
+
response = requests.get(url, headers=_HEADERS, params=params or {})
|
|
225
|
+
return response
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
###############################################################################
|
|
229
|
+
# Helper to make POST requests (for placing bets, etc.)
|
|
230
|
+
###############################################################################
|
|
231
|
+
@raise_ps3838_api_errors
|
|
232
|
+
def _post(endpoint: str, body: dict[str, Any]):
|
|
233
|
+
"""
|
|
234
|
+
Internal helper to perform POST requests to the PS3838 API.
|
|
235
|
+
Returns JSON-decoded response as Any. Raise errors on non-200.
|
|
236
|
+
"""
|
|
237
|
+
url: str = f"{_BASE_URL}{endpoint}"
|
|
238
|
+
response = requests.post(url, headers=_HEADERS, json=body)
|
|
239
|
+
return response
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
###############################################################################
|
|
243
|
+
# Endpoints
|
|
244
|
+
###############################################################################
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def get_client_balance() -> BalanceData:
|
|
248
|
+
"""
|
|
249
|
+
Returns current client balance, outstanding transactions, credit, and currency.
|
|
250
|
+
GET https://api.ps3838.com/v1/client/balance
|
|
251
|
+
"""
|
|
252
|
+
endpoint = "/v1/client/balance"
|
|
253
|
+
data: Any = _get(endpoint)
|
|
254
|
+
# We expect data like:
|
|
255
|
+
# {
|
|
256
|
+
# "availableBalance": float,
|
|
257
|
+
# "outstandingTransactions": float,
|
|
258
|
+
# "givenCredit": float,
|
|
259
|
+
# "currency": "USD"
|
|
260
|
+
# }
|
|
261
|
+
return cast(BalanceData, data)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def get_periods(sport_id: int = SOCCER_SPORT_ID) -> list[PeriodData]:
|
|
265
|
+
"""
|
|
266
|
+
Returns all periods for a given sport.
|
|
267
|
+
GET https://api.ps3838.com/v1/periods?sportId={sport_id}
|
|
268
|
+
"""
|
|
269
|
+
endpoint = "/v1/periods"
|
|
270
|
+
data = _get(endpoint, params={"sportId": str(sport_id)})
|
|
271
|
+
# Typically the response is { "periods": [ { ...PeriodData... }, ... ] }
|
|
272
|
+
# We'll return just the list of PeriodData.
|
|
273
|
+
periods_data = data.get("periods", [])
|
|
274
|
+
return cast(list[PeriodData], periods_data)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def get_sports() -> Any:
|
|
278
|
+
"""
|
|
279
|
+
GET https://api.ps3838.com/v3/sports
|
|
280
|
+
Returns available sports. Fields uncertain, so use Any.
|
|
281
|
+
"""
|
|
282
|
+
endpoint = "/v3/sports"
|
|
283
|
+
return _get(endpoint)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def get_leagues(sport_id: int = SOCCER_SPORT_ID) -> list[LeagueV3]:
|
|
287
|
+
"""
|
|
288
|
+
GET https://api.ps3838.com/v3/leagues?sportId={sport_id}
|
|
289
|
+
Returns leagues for a particular sport. Fields uncertain, so use Any.
|
|
290
|
+
"""
|
|
291
|
+
endpoint = "/v3/leagues"
|
|
292
|
+
data = _get(endpoint, params={"sportId": sport_id})
|
|
293
|
+
leagues_data = data.get("leagues", [])
|
|
294
|
+
return cast(list[LeagueV3], leagues_data)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def get_fixtures(
|
|
298
|
+
sport_id: int = SOCCER_SPORT_ID,
|
|
299
|
+
league_ids: list[int] | None = None,
|
|
300
|
+
is_live: bool | None = None,
|
|
301
|
+
since: int | None = None,
|
|
302
|
+
event_ids: list[int] | None = None,
|
|
303
|
+
settled: bool = False,
|
|
304
|
+
) -> FixturesResponse:
|
|
305
|
+
"""
|
|
306
|
+
GET https://api.ps3838.com/v3/fixtures or /v3/fixtures/settled
|
|
307
|
+
Query parameters:
|
|
308
|
+
sportId, leagueIds, isLive, since, eventIds, ...
|
|
309
|
+
Returns fixtures data. Use Any for uncertain fields.
|
|
310
|
+
"""
|
|
311
|
+
subpath = "/v3/fixtures/settled" if settled else "/v3/fixtures"
|
|
312
|
+
endpoint = f"{subpath}"
|
|
313
|
+
|
|
314
|
+
params: dict[str, Any] = {"sportId": sport_id}
|
|
315
|
+
if league_ids:
|
|
316
|
+
params["leagueIds"] = ",".join(map(str, league_ids))
|
|
317
|
+
if is_live is not None:
|
|
318
|
+
params["isLive"] = int(is_live)
|
|
319
|
+
if since is not None:
|
|
320
|
+
params["since"] = since
|
|
321
|
+
if event_ids:
|
|
322
|
+
params["eventIds"] = ",".join(map(str, event_ids))
|
|
323
|
+
|
|
324
|
+
return cast(FixturesResponse, _get(endpoint, params))
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def get_odds(
|
|
328
|
+
sport_id: int = SOCCER_SPORT_ID,
|
|
329
|
+
is_special: bool = False,
|
|
330
|
+
league_ids: list[int] | None = None,
|
|
331
|
+
odds_format: OddsFormat = "DECIMAL",
|
|
332
|
+
since: int | None = None,
|
|
333
|
+
is_live: bool | None = None,
|
|
334
|
+
event_ids: list[int] | None = None,
|
|
335
|
+
) -> OddsResponse:
|
|
336
|
+
"""
|
|
337
|
+
GET Straight Odds (v3) or GET Special Odds (v2) for non-settled events.
|
|
338
|
+
- If is_special=True -> https://api.ps3838.com/v2/odds/special
|
|
339
|
+
- Else -> https://api.ps3838.com/v3/odds
|
|
340
|
+
Allows filtering by leagueIds, eventIds, etc.
|
|
341
|
+
"""
|
|
342
|
+
if is_special:
|
|
343
|
+
endpoint = "/v2/odds/special"
|
|
344
|
+
else:
|
|
345
|
+
endpoint = "/v3/odds"
|
|
346
|
+
|
|
347
|
+
params: dict[str, Any] = {
|
|
348
|
+
"sportId": sport_id,
|
|
349
|
+
"oddsFormat": odds_format,
|
|
350
|
+
}
|
|
351
|
+
if league_ids:
|
|
352
|
+
params["leagueIds"] = ",".join(map(str, league_ids))
|
|
353
|
+
if since is not None:
|
|
354
|
+
params["since"] = since
|
|
355
|
+
if is_live is not None:
|
|
356
|
+
params["isLive"] = int(is_live)
|
|
357
|
+
if event_ids:
|
|
358
|
+
params["eventIds"] = ",".join(map(str, event_ids))
|
|
359
|
+
|
|
360
|
+
return cast(OddsResponse, _get(endpoint, params))
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def get_special_fixtures(
|
|
364
|
+
sport_id: int = SOCCER_SPORT_ID,
|
|
365
|
+
league_ids: list[int] | None = None,
|
|
366
|
+
event_id: int | None = None,
|
|
367
|
+
) -> Any:
|
|
368
|
+
"""
|
|
369
|
+
GET https://api.ps3838.com/v2/fixtures/special
|
|
370
|
+
Returns special fixtures for non-settled events in a given sport.
|
|
371
|
+
Possibly filter by leagueIds or eventId.
|
|
372
|
+
"""
|
|
373
|
+
endpoint = "/v2/fixtures/special"
|
|
374
|
+
params: dict[str, Any] = {"sportId": sport_id, "oddsFormat": "Decimal"}
|
|
375
|
+
|
|
376
|
+
if league_ids:
|
|
377
|
+
params["leagueIds"] = ",".join(map(str, league_ids))
|
|
378
|
+
if event_id is not None:
|
|
379
|
+
params["eventId"] = event_id
|
|
380
|
+
|
|
381
|
+
return _get(endpoint, params)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def get_line(
|
|
385
|
+
league_id: int,
|
|
386
|
+
event_id: int,
|
|
387
|
+
period_number: int,
|
|
388
|
+
bet_type: Literal["SPREAD", "MONEYLINE", "TOTAL_POINTS", "TEAM_TOTAL_POINTS"],
|
|
389
|
+
handicap: float,
|
|
390
|
+
team: Literal["Team1", "Team2", "Draw"] | None = None,
|
|
391
|
+
side: Literal["OVER", "UNDER"] | None = None,
|
|
392
|
+
sport_id: int = SOCCER_SPORT_ID,
|
|
393
|
+
odds_format: str = "Decimal",
|
|
394
|
+
) -> LineResponse:
|
|
395
|
+
"""
|
|
396
|
+
GET https://api.ps3838.com/v2/line
|
|
397
|
+
or known in docs as "Get Straight Line (v2)".
|
|
398
|
+
Use this to get the exact line, odds, and limit for a single bet (Spread, Total, etc.)
|
|
399
|
+
|
|
400
|
+
side is required for TOTAL_POINTS and TEAM_TOTAL_POINTS
|
|
401
|
+
"""
|
|
402
|
+
endpoint = "/v2/line"
|
|
403
|
+
params: dict[str, Any] = {
|
|
404
|
+
"sportId": sport_id,
|
|
405
|
+
"leagueId": league_id,
|
|
406
|
+
"eventId": event_id,
|
|
407
|
+
"periodNumber": period_number,
|
|
408
|
+
"betType": bet_type,
|
|
409
|
+
"handicap": handicap,
|
|
410
|
+
"oddsFormat": odds_format,
|
|
411
|
+
}
|
|
412
|
+
if team:
|
|
413
|
+
params["team"] = team
|
|
414
|
+
if side:
|
|
415
|
+
params["side"] = side
|
|
416
|
+
|
|
417
|
+
return cast(LineResponse, _get(endpoint, params))
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# parameters are key only, because all are very important
|
|
421
|
+
def place_straigh_bet(
|
|
422
|
+
*,
|
|
423
|
+
stake: float,
|
|
424
|
+
event_id: int,
|
|
425
|
+
bet_type: BetType,
|
|
426
|
+
line_id: int | None,
|
|
427
|
+
# EVENT INFO (JUST GUESSING)
|
|
428
|
+
period_number: int = 0,
|
|
429
|
+
sport_id: int = SOCCER_SPORT_ID,
|
|
430
|
+
# ALT LINE ID
|
|
431
|
+
alt_line_id: int | None = None,
|
|
432
|
+
# BET UUID
|
|
433
|
+
unique_request_id: str | None = None,
|
|
434
|
+
# BETS PARAMETERS
|
|
435
|
+
odds_format: OddsFormat = "DECIMAL",
|
|
436
|
+
fill_type: FillType = "NORMAL",
|
|
437
|
+
accept_better_line: bool = True,
|
|
438
|
+
win_risk_stake: Literal["WIN", "RISK"] = "RISK",
|
|
439
|
+
# BASEBALL ONLY
|
|
440
|
+
pitcher1_must_start: bool = True,
|
|
441
|
+
pitcher2_must_start: bool = True,
|
|
442
|
+
# TEAM
|
|
443
|
+
team: Team | None = None,
|
|
444
|
+
# SIDE (FOR TOTALS)
|
|
445
|
+
side: Side | None = None,
|
|
446
|
+
# HANDICAP (OR POINTS)
|
|
447
|
+
handicap: float | None = None,
|
|
448
|
+
) -> PlaceStraightBetResponse:
|
|
449
|
+
if unique_request_id is None:
|
|
450
|
+
unique_request_id = str(uuid.uuid1())
|
|
451
|
+
if sport_id != BASEBALL_SPORT_ID:
|
|
452
|
+
if not pitcher1_must_start or not pitcher2_must_start:
|
|
453
|
+
raise BaseballOnlyArgumentError()
|
|
454
|
+
params: dict[str, Any] = {
|
|
455
|
+
"oddsFormat": odds_format,
|
|
456
|
+
"uniqueRequestId": unique_request_id,
|
|
457
|
+
"acceptBetterLine": accept_better_line,
|
|
458
|
+
"stake": stake,
|
|
459
|
+
"winRiskStake": win_risk_stake,
|
|
460
|
+
"pitcher1MustStart": pitcher1_must_start,
|
|
461
|
+
"pitcher2MustStart": pitcher2_must_start,
|
|
462
|
+
"fillType": fill_type,
|
|
463
|
+
"sportId": sport_id,
|
|
464
|
+
"eventId": event_id,
|
|
465
|
+
"periodNumber": period_number,
|
|
466
|
+
"betType": bet_type,
|
|
467
|
+
}
|
|
468
|
+
if team is not None:
|
|
469
|
+
params["team"] = team
|
|
470
|
+
if line_id is not None:
|
|
471
|
+
params["lineId"] = line_id
|
|
472
|
+
if alt_line_id is not None:
|
|
473
|
+
params["altLineId"] = alt_line_id
|
|
474
|
+
if side is not None:
|
|
475
|
+
params["side"] = side
|
|
476
|
+
if handicap is not None:
|
|
477
|
+
params["handicap"] = handicap
|
|
478
|
+
|
|
479
|
+
endpoint = "/v2/bets/place"
|
|
480
|
+
data = _post(endpoint, params)
|
|
481
|
+
return cast(PlaceStraightBetResponse, data)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def get_bets(
|
|
485
|
+
bet_ids: list[int] | None = None,
|
|
486
|
+
unique_request_ids: list[str] | None = None,
|
|
487
|
+
since: int | None = None,
|
|
488
|
+
) -> BetsResponse:
|
|
489
|
+
"""
|
|
490
|
+
GET https://api.ps3838.com/v3/bets
|
|
491
|
+
Retrieve status for placed bets. Filter by betId or since.
|
|
492
|
+
If bet is in PENDING_ACCEPTANCE state, you can poll this every 5s to see if accepted or rejected.
|
|
493
|
+
"""
|
|
494
|
+
endpoint = "/v3/bets"
|
|
495
|
+
params: dict[str, Any] = {}
|
|
496
|
+
if bet_ids:
|
|
497
|
+
# Usually you can pass betIds= comma separated.
|
|
498
|
+
params["betIds"] = ",".join(map(str, bet_ids))
|
|
499
|
+
if unique_request_ids:
|
|
500
|
+
params["uniqueRequestIds"] = ",".join(unique_request_ids)
|
|
501
|
+
if since is not None:
|
|
502
|
+
params["since"] = since
|
|
503
|
+
|
|
504
|
+
return cast(BetsResponse, _get(endpoint, params))
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
class BettingStatusResponse(TypedDict):
|
|
508
|
+
status: Literal[
|
|
509
|
+
"ALL_BETTING_ENABLED", "ALL_LIVE_BETTING_CLOSED", "ALL_BETTING_CLOSED"
|
|
510
|
+
]
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def get_betting_status() -> BettingStatusResponse:
|
|
514
|
+
"""
|
|
515
|
+
GET https://api.ps3838.com/v1/bets/betting-status
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
BettingStatusResponse: A dict containing the current betting status.
|
|
519
|
+
The 'status' field can be one of:
|
|
520
|
+
- "ALL_BETTING_ENABLED"
|
|
521
|
+
- "ALL_LIVE_BETTING_CLOSED"
|
|
522
|
+
- "ALL_BETTING_CLOSED"
|
|
523
|
+
|
|
524
|
+
Note:
|
|
525
|
+
During maintenance windows, betting might be disabled.
|
|
526
|
+
"""
|
|
527
|
+
endpoint: str = "/v1/bets/betting-status"
|
|
528
|
+
return cast(BettingStatusResponse, _get(endpoint, {}))
|
ps3838api/logic.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from ps3838api.models.event import NoSuchLeagueFixtures, NoSuchOddsAvailable
|
|
2
|
+
from ps3838api.models.fixtures import FixtureV3, FixturesLeagueV3, FixturesResponse
|
|
3
|
+
from ps3838api.models.odds import OddsEventV3, OddsLeagueV3, OddsResponse
|
|
4
|
+
from ps3838api.models.tank import EventInfo
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def merge_odds_response(old: OddsResponse, new: OddsResponse) -> OddsResponse:
|
|
8
|
+
"""
|
|
9
|
+
Merge a snapshot OddsResponse (old) with a delta OddsResponse (new).
|
|
10
|
+
- Leagues are matched by league["id"].
|
|
11
|
+
- Events are matched by event["id"].
|
|
12
|
+
- Periods are matched by period["number"].
|
|
13
|
+
- Any period present in 'new' entirely replaces the same period number in 'old'.
|
|
14
|
+
- Periods not present in 'new' remain as they were in 'old'.
|
|
15
|
+
|
|
16
|
+
Returns a merged OddsResponse that includes updated odds and periods, retaining
|
|
17
|
+
old entries when no changes were reported in the delta.
|
|
18
|
+
|
|
19
|
+
Based on "How to get odds changes?" from https://ps3838api.github.io/FAQs.html
|
|
20
|
+
"""
|
|
21
|
+
# Index the old leagues by their IDs
|
|
22
|
+
league_index: dict[int, OddsLeagueV3] = {
|
|
23
|
+
league["id"]: league for league in old.get("leagues", [])
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Loop through the new leagues
|
|
27
|
+
for new_league in new.get("leagues", []):
|
|
28
|
+
lid = new_league["id"]
|
|
29
|
+
|
|
30
|
+
# If it's an entirely new league, just store it
|
|
31
|
+
if lid not in league_index:
|
|
32
|
+
league_index[lid] = new_league
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
# Otherwise merge it with the existing league
|
|
36
|
+
old_league = league_index[lid]
|
|
37
|
+
old_event_index = {event["id"]: event for event in old_league.get("events", [])}
|
|
38
|
+
|
|
39
|
+
# Loop through the new events
|
|
40
|
+
for new_event in new_league.get("events", []):
|
|
41
|
+
eid = new_event["id"]
|
|
42
|
+
|
|
43
|
+
# If it's an entirely new event, just store it
|
|
44
|
+
if eid not in old_event_index:
|
|
45
|
+
old_event_index[eid] = new_event
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
# Otherwise, merge with the existing event
|
|
49
|
+
old_event = old_event_index[eid]
|
|
50
|
+
|
|
51
|
+
# Periods: build an index by 'number' from the old event
|
|
52
|
+
old_period_index = {
|
|
53
|
+
p["number"]: p for p in old_event.get("periods", []) if "number" in p
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Take all the new event's periods and override or insert them by 'number'
|
|
57
|
+
for new_period in new_event.get("periods", []):
|
|
58
|
+
if "number" not in new_period:
|
|
59
|
+
continue
|
|
60
|
+
old_period_index[new_period["number"]] = new_period
|
|
61
|
+
|
|
62
|
+
# Merge top-level fields: new event fields override old ones
|
|
63
|
+
merged_event = old_event.copy()
|
|
64
|
+
merged_event.update(new_event)
|
|
65
|
+
|
|
66
|
+
# Rebuild the merged_event's periods from the updated dictionary
|
|
67
|
+
merged_event["periods"] = list(old_period_index.values())
|
|
68
|
+
|
|
69
|
+
# Store back in the event index
|
|
70
|
+
old_event_index[eid] = merged_event
|
|
71
|
+
|
|
72
|
+
# Rebuild league's events list from the merged event index
|
|
73
|
+
old_league["events"] = list(old_event_index.values())
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
"sportId": new.get("sportId", old["sportId"]),
|
|
77
|
+
# Always take the latest `last` timestamp from the new (delta) response
|
|
78
|
+
"last": new["last"],
|
|
79
|
+
# Rebuild leagues list
|
|
80
|
+
"leagues": list(league_index.values()),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def merge_fixtures(old: FixturesResponse, new: FixturesResponse) -> FixturesResponse:
|
|
85
|
+
league_index: dict[int, FixturesLeagueV3] = {
|
|
86
|
+
league["id"]: league for league in old.get("league", [])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for new_league in new.get("league", []):
|
|
90
|
+
lid = new_league["id"]
|
|
91
|
+
if lid in league_index:
|
|
92
|
+
old_events = {e["id"]: e for e in league_index[lid]["events"]}
|
|
93
|
+
for event in new_league["events"]:
|
|
94
|
+
old_events[event["id"]] = event # override or insert
|
|
95
|
+
league_index[lid]["events"] = list(old_events.values())
|
|
96
|
+
else:
|
|
97
|
+
league_index[lid] = new_league # new league entirely
|
|
98
|
+
return {
|
|
99
|
+
"sportId": new.get("sportId", old["sportId"]),
|
|
100
|
+
"last": new["last"],
|
|
101
|
+
"league": list(league_index.values()),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def find_league_in_fixtures(
|
|
106
|
+
fixtures: FixturesResponse, league: str, league_id: int
|
|
107
|
+
) -> FixturesLeagueV3 | NoSuchLeagueFixtures:
|
|
108
|
+
for leagueV3 in fixtures["league"]:
|
|
109
|
+
if leagueV3["id"] == league_id:
|
|
110
|
+
return leagueV3
|
|
111
|
+
else:
|
|
112
|
+
return NoSuchLeagueFixtures(league)
|
|
113
|
+
|
|
114
|
+
def find_fixtureV3_in_league(leagueV3: FixturesLeagueV3, event_id: int) -> FixtureV3:
|
|
115
|
+
for eventV3 in leagueV3['events']:
|
|
116
|
+
if eventV3["id"] == event_id:
|
|
117
|
+
return eventV3
|
|
118
|
+
raise ValueError('No such event')
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def filter_odds(
|
|
122
|
+
odds: OddsResponse, event_id: int, league_id: int | None = None
|
|
123
|
+
) -> OddsEventV3 | NoSuchOddsAvailable:
|
|
124
|
+
"""passing `league_id` makes search in json faster"""
|
|
125
|
+
for league in odds["leagues"]:
|
|
126
|
+
if league_id and league_id != league["id"]:
|
|
127
|
+
continue
|
|
128
|
+
for fixture in league["events"]:
|
|
129
|
+
if fixture["id"] == event_id:
|
|
130
|
+
return fixture
|
|
131
|
+
return NoSuchOddsAvailable(event_id)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def normalize_to_set(name: str) -> set[str]:
|
|
135
|
+
return set(
|
|
136
|
+
name.replace(" II", " 2").replace(" I", "").lower().replace("-", " ").split()
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def find_event_by_id(fixtures: FixturesResponse, event: EventInfo) -> FixtureV3 | None:
|
|
141
|
+
for leagueV3 in fixtures["league"]:
|
|
142
|
+
if leagueV3["id"] == event["leagueId"]:
|
|
143
|
+
for fixtureV3 in leagueV3["events"]:
|
|
144
|
+
if fixtureV3["id"] == event["eventId"]:
|
|
145
|
+
return fixtureV3
|
|
146
|
+
return None
|