oddspush 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.
- oddspush/__init__.py +29 -0
- oddspush/_constants.py +30 -0
- oddspush/_version.py +1 -0
- oddspush/book.py +263 -0
- oddspush/errors.py +107 -0
- oddspush/odds.py +60 -0
- oddspush/py.typed +0 -0
- oddspush/rest.py +284 -0
- oddspush/stream.py +509 -0
- oddspush-0.1.0.dist-info/METADATA +193 -0
- oddspush-0.1.0.dist-info/RECORD +13 -0
- oddspush-0.1.0.dist-info/WHEEL +4 -0
- oddspush-0.1.0.dist-info/licenses/LICENSE +21 -0
oddspush/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Python client for the OddsPush API: real-time Pinnacle odds over WebSocket and REST,
|
|
2
|
+
with no-vig, fair and closing-line prices.
|
|
3
|
+
|
|
4
|
+
- :class:`Stream`: the WebSocket stream, with reconnection and a local price book.
|
|
5
|
+
- :class:`Client` / :class:`AsyncClient`: the REST API.
|
|
6
|
+
- :class:`Book`: the price book, if you feed it yourself.
|
|
7
|
+
- :mod:`oddspush.odds`: American/decimal odds, margin, no-vig and CLV.
|
|
8
|
+
|
|
9
|
+
Docs: https://oddspush.com/docs
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from ._constants import CHANNELS, PRICE_CHANNELS, REST_URL, SPORTS, STREAM_URL
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
from .book import Book, channel_of
|
|
15
|
+
from .errors import (BadRequest, FatalError, IpNotAllowed, NotFound, NotInPlan,
|
|
16
|
+
OddsPushError, RateLimited, StreamClosed, SubscriptionExpired,
|
|
17
|
+
TokenReplaced, Unauthorized, Unavailable)
|
|
18
|
+
from .odds import american_to_decimal, clv, decimal_to_american, implied_probability
|
|
19
|
+
from .rest import AsyncClient, Client
|
|
20
|
+
from .stream import Stream
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"__version__", "Stream", "Client", "AsyncClient", "Book", "channel_of",
|
|
24
|
+
"SPORTS", "CHANNELS", "PRICE_CHANNELS", "STREAM_URL", "REST_URL",
|
|
25
|
+
"american_to_decimal", "decimal_to_american", "implied_probability", "clv",
|
|
26
|
+
"OddsPushError", "FatalError", "Unauthorized", "SubscriptionExpired", "NotInPlan",
|
|
27
|
+
"IpNotAllowed", "TokenReplaced", "RateLimited", "NotFound", "BadRequest",
|
|
28
|
+
"Unavailable", "StreamClosed",
|
|
29
|
+
]
|
oddspush/_constants.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Endpoints, sport IDs and channel names of the OddsPush API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
STREAM_URL = "wss://feed.oddspush.com/v1/stream"
|
|
6
|
+
REST_URL = "https://feed.oddspush.com"
|
|
7
|
+
|
|
8
|
+
#: Sport IDs accepted by ``subscribe`` and the REST API.
|
|
9
|
+
SPORTS = {
|
|
10
|
+
1: "Soccer",
|
|
11
|
+
2: "Tennis",
|
|
12
|
+
3: "Basketball",
|
|
13
|
+
4: "Hockey",
|
|
14
|
+
5: "American football",
|
|
15
|
+
6: "Baseball",
|
|
16
|
+
7: "Rugby",
|
|
17
|
+
8: "MMA",
|
|
18
|
+
9: "Boxing",
|
|
19
|
+
10: "Other (volleyball and handball)",
|
|
20
|
+
11: "Esports",
|
|
21
|
+
12: "Golf",
|
|
22
|
+
13: "Cricket",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#: Channels whose prices make up the main price book.
|
|
26
|
+
PRICE_CHANNELS = ("ld", "both")
|
|
27
|
+
|
|
28
|
+
#: Every channel name the API knows. ``dz`` carries volatile moments and ``pre`` pre-match
|
|
29
|
+
#: prices; anything else is reported as ``other``.
|
|
30
|
+
CHANNELS = ("ld", "both", "dz", "pre", "other")
|
oddspush/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
oddspush/book.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""A local price book: the current open markets of every event, kept up to date from the
|
|
2
|
+
stream.
|
|
3
|
+
|
|
4
|
+
The rules (the same as in the API documentation):
|
|
5
|
+
|
|
6
|
+
- markets are ordered by ``version``: an update older than the one held is ignored;
|
|
7
|
+
- a market whose ``status`` is not ``open`` is removed;
|
|
8
|
+
- when a period turns ``closed`` or ``settled``, every market of that period is removed;
|
|
9
|
+
- ``op: "del"`` removes the whole event, whatever channel it comes on;
|
|
10
|
+
- a snapshot is merged by version, never used to clear the book.
|
|
11
|
+
|
|
12
|
+
:class:`~oddspush.Stream` keeps one up to date for you (``stream.book``); you can also feed
|
|
13
|
+
one yourself with :meth:`Book.apply_message`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
from collections import OrderedDict
|
|
20
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
from ._constants import PRICE_CHANNELS
|
|
23
|
+
|
|
24
|
+
Market = Dict[str, Any]
|
|
25
|
+
_CLOSED_PERIOD = ("closed", "settled")
|
|
26
|
+
_REMOVED_MAX = 20000 # removed event ids remembered, so a late snapshot cannot revive them
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def channel_of(topic: Optional[str]) -> str:
|
|
30
|
+
"""The channel of an ``odds`` update, from the last segment of its ``topic``."""
|
|
31
|
+
last = (topic or "").rsplit("/", 1)[-1]
|
|
32
|
+
return last if last in ("ld", "both", "dz", "pre") else "other"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _Event:
|
|
36
|
+
__slots__ = ("meta", "markets", "versions", "fair", "updated", "live")
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self.updated = time.monotonic()
|
|
40
|
+
self.live = False # got a live update: snapshots no longer rewind it
|
|
41
|
+
self.meta: Dict[str, Any] = {}
|
|
42
|
+
self.markets: Dict[str, Market] = {}
|
|
43
|
+
# The highest version seen per key, closed markets included: a late update of a
|
|
44
|
+
# market that has since closed must not bring it back.
|
|
45
|
+
self.versions: Dict[str, int] = {}
|
|
46
|
+
# key -> {"version", "margin", "prices"} as sent in `fair` messages.
|
|
47
|
+
self.fair: Dict[str, Dict[str, Any]] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Book:
|
|
51
|
+
"""Open markets per event, merged by version.
|
|
52
|
+
|
|
53
|
+
``channels`` are the channels whose live updates go into the book: ``ld`` and ``both``
|
|
54
|
+
by default, which are the prices. Keep ``dz`` (volatile moments) out of a price book.
|
|
55
|
+
Add ``pre`` to follow pre-match prices.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, channels: Iterable[str] = PRICE_CHANNELS) -> None:
|
|
59
|
+
self.channels = frozenset(channels)
|
|
60
|
+
self._events: Dict[int, _Event] = {}
|
|
61
|
+
self._removed: "OrderedDict[Any, None]" = OrderedDict()
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------ feeding it
|
|
64
|
+
def apply_message(self, msg: Dict[str, Any]) -> bool:
|
|
65
|
+
"""Apply one message from the stream. Returns ``True`` if it touched the book.
|
|
66
|
+
|
|
67
|
+
Handles ``odds`` (live updates, removals and the state resent by the source),
|
|
68
|
+
``snapshot`` parts and ``fair``. Any other message is ignored.
|
|
69
|
+
"""
|
|
70
|
+
kind = msg.get("type")
|
|
71
|
+
if kind == "odds":
|
|
72
|
+
data = msg.get("data") or {}
|
|
73
|
+
dtype = data.get("type")
|
|
74
|
+
if dtype == "snapshot":
|
|
75
|
+
for rec in data.get("events") or ():
|
|
76
|
+
self.apply_rec(rec, snapshot=True)
|
|
77
|
+
return True
|
|
78
|
+
if dtype != "live":
|
|
79
|
+
return False
|
|
80
|
+
rec = data.get("rec") or {}
|
|
81
|
+
if data.get("op") == "del":
|
|
82
|
+
return self.remove(rec.get("id"))
|
|
83
|
+
if channel_of(data.get("topic")) not in self.channels:
|
|
84
|
+
return False
|
|
85
|
+
self.apply_rec(rec)
|
|
86
|
+
return True
|
|
87
|
+
if kind == "snapshot":
|
|
88
|
+
for rec in msg.get("events") or ():
|
|
89
|
+
self.apply_rec(rec, snapshot=True)
|
|
90
|
+
return True
|
|
91
|
+
if kind == "fair":
|
|
92
|
+
return self.apply_fair(msg.get("data") or {})
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
def apply_rec(self, rec: Dict[str, Any], snapshot: bool = False) -> None:
|
|
96
|
+
"""Merge one event (a ``rec``) into the book.
|
|
97
|
+
|
|
98
|
+
``snapshot=True`` for a rec from a snapshot. A snapshot is taken when it is asked
|
|
99
|
+
for and can arrive after newer live updates, so it never brings back a removed
|
|
100
|
+
event, and for an event already updated live it only adds what is newer: its
|
|
101
|
+
details and closed periods do not overwrite the live ones.
|
|
102
|
+
"""
|
|
103
|
+
event_id = rec.get("id")
|
|
104
|
+
if event_id is None:
|
|
105
|
+
return
|
|
106
|
+
if snapshot and event_id in self._removed:
|
|
107
|
+
return
|
|
108
|
+
ev = self._events.get(event_id)
|
|
109
|
+
if ev is None:
|
|
110
|
+
ev = self._events[event_id] = _Event()
|
|
111
|
+
if not snapshot:
|
|
112
|
+
self._removed.pop(event_id, None)
|
|
113
|
+
ev.updated = time.monotonic()
|
|
114
|
+
rewind = snapshot and ev.live
|
|
115
|
+
if rewind:
|
|
116
|
+
for k, v in rec.items():
|
|
117
|
+
if k != "markets":
|
|
118
|
+
ev.meta.setdefault(k, v)
|
|
119
|
+
else:
|
|
120
|
+
ev.meta.update((k, v) for k, v in rec.items() if k != "markets")
|
|
121
|
+
if not snapshot:
|
|
122
|
+
ev.live = True
|
|
123
|
+
newest = None
|
|
124
|
+
for m in rec.get("markets") or ():
|
|
125
|
+
key = m.get("key")
|
|
126
|
+
if key is None:
|
|
127
|
+
continue
|
|
128
|
+
version = m.get("version")
|
|
129
|
+
if version is not None and (newest is None or version > newest):
|
|
130
|
+
newest = version
|
|
131
|
+
seen = ev.versions.get(key)
|
|
132
|
+
if version is not None:
|
|
133
|
+
if seen is not None and version < seen:
|
|
134
|
+
continue # an older update arriving late
|
|
135
|
+
ev.versions[key] = version
|
|
136
|
+
if m.get("status") == "open":
|
|
137
|
+
fair = m.get("fair")
|
|
138
|
+
if fair is not None: # snapshot parts carry it inline
|
|
139
|
+
m = {k: v for k, v in m.items() if k != "fair"}
|
|
140
|
+
ev.fair[key] = {"version": version, **fair}
|
|
141
|
+
ev.markets[key] = m
|
|
142
|
+
else:
|
|
143
|
+
ev.markets.pop(key, None)
|
|
144
|
+
ev.fair.pop(key, None)
|
|
145
|
+
if rewind:
|
|
146
|
+
# An older picture of the periods: close only markets no newer than it.
|
|
147
|
+
if newest is not None:
|
|
148
|
+
self._close_periods(ev, rec.get("periods"), up_to=newest)
|
|
149
|
+
else:
|
|
150
|
+
self._close_periods(ev, rec.get("periods"))
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _close_periods(ev: _Event, periods: Any, up_to: Optional[int] = None) -> None:
|
|
154
|
+
closed = set()
|
|
155
|
+
if isinstance(periods, dict): # {"0": {"status": ...}} or {"0": "open"}
|
|
156
|
+
for num, p in periods.items():
|
|
157
|
+
status = p.get("status") if isinstance(p, dict) else p
|
|
158
|
+
if status in _CLOSED_PERIOD:
|
|
159
|
+
closed.add(str(num))
|
|
160
|
+
elif isinstance(periods, list):
|
|
161
|
+
for p in periods:
|
|
162
|
+
if isinstance(p, dict) and p.get("status") in _CLOSED_PERIOD:
|
|
163
|
+
closed.add(str(p.get("period")))
|
|
164
|
+
if not closed:
|
|
165
|
+
return
|
|
166
|
+
for key in [k for k, m in ev.markets.items() if str(m.get("period")) in closed
|
|
167
|
+
and (up_to is None or (m.get("version") or 0) <= up_to)]:
|
|
168
|
+
del ev.markets[key]
|
|
169
|
+
ev.fair.pop(key, None)
|
|
170
|
+
|
|
171
|
+
def apply_fair(self, data: Dict[str, Any]) -> bool:
|
|
172
|
+
"""Store the no-vig and fair prices of a ``fair`` message: only for the book's
|
|
173
|
+
channels, and only for the very market version the book holds."""
|
|
174
|
+
ev = self._events.get(data.get("event_id"))
|
|
175
|
+
if ev is None:
|
|
176
|
+
return False
|
|
177
|
+
channel = data.get("channel")
|
|
178
|
+
if channel not in ("ld", "both", "dz", "pre"):
|
|
179
|
+
channel = "other"
|
|
180
|
+
if channel not in self.channels:
|
|
181
|
+
return False
|
|
182
|
+
stored = False
|
|
183
|
+
for m in data.get("markets") or ():
|
|
184
|
+
key = m.get("key")
|
|
185
|
+
held = ev.markets.get(key) if key is not None else None
|
|
186
|
+
if held is None or held.get("version") != m.get("version"):
|
|
187
|
+
continue
|
|
188
|
+
ev.fair[key] = {"version": m.get("version"), "margin": m.get("margin"),
|
|
189
|
+
"prices": m.get("prices") or []}
|
|
190
|
+
stored = True
|
|
191
|
+
return stored
|
|
192
|
+
|
|
193
|
+
def remove(self, event_id: Any) -> bool:
|
|
194
|
+
"""Remove a whole event. Returns ``True`` if it was in the book. A snapshot that
|
|
195
|
+
arrives later will not bring it back; a live update will."""
|
|
196
|
+
self._removed[event_id] = None
|
|
197
|
+
self._removed.move_to_end(event_id)
|
|
198
|
+
while len(self._removed) > _REMOVED_MAX:
|
|
199
|
+
self._removed.popitem(last=False)
|
|
200
|
+
return self._events.pop(event_id, None) is not None
|
|
201
|
+
|
|
202
|
+
def prune(self, max_idle_s: float) -> List[int]:
|
|
203
|
+
"""Remove the events with no update for ``max_idle_s`` seconds and no open market
|
|
204
|
+
left, and return their ids. Useful in a long-running bot: an event whose removal you
|
|
205
|
+
missed (while disconnected, say) otherwise stays in the book."""
|
|
206
|
+
limit = time.monotonic() - max_idle_s
|
|
207
|
+
gone = [eid for eid, ev in self._events.items()
|
|
208
|
+
if ev.updated < limit and not ev.markets]
|
|
209
|
+
for eid in gone:
|
|
210
|
+
del self._events[eid]
|
|
211
|
+
return gone
|
|
212
|
+
|
|
213
|
+
def clear(self) -> None:
|
|
214
|
+
self._events.clear()
|
|
215
|
+
self._removed.clear()
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------ reading it
|
|
218
|
+
def __len__(self) -> int:
|
|
219
|
+
return len(self._events)
|
|
220
|
+
|
|
221
|
+
def __contains__(self, event_id: object) -> bool:
|
|
222
|
+
return event_id in self._events
|
|
223
|
+
|
|
224
|
+
def __iter__(self) -> Iterator[int]:
|
|
225
|
+
return iter(list(self._events))
|
|
226
|
+
|
|
227
|
+
def event_ids(self) -> List[int]:
|
|
228
|
+
return list(self._events)
|
|
229
|
+
|
|
230
|
+
def event(self, event_id: int) -> Optional[Dict[str, Any]]:
|
|
231
|
+
"""The event's details (``league``, ``participants``, ``state``, ``startTime``...),
|
|
232
|
+
without its markets, or ``None``."""
|
|
233
|
+
ev = self._events.get(event_id)
|
|
234
|
+
return ev.meta if ev is not None else None
|
|
235
|
+
|
|
236
|
+
def markets(self, event_id: int) -> Dict[str, Market]:
|
|
237
|
+
"""The open markets of an event, by ``key`` (empty if unknown)."""
|
|
238
|
+
ev = self._events.get(event_id)
|
|
239
|
+
return dict(ev.markets) if ev is not None else {}
|
|
240
|
+
|
|
241
|
+
def market(self, event_id: int, key: str) -> Optional[Market]:
|
|
242
|
+
ev = self._events.get(event_id)
|
|
243
|
+
return ev.markets.get(key) if ev is not None else None
|
|
244
|
+
|
|
245
|
+
def fair(self, event_id: int, key: str) -> Optional[Dict[str, Any]]:
|
|
246
|
+
"""``{"version", "margin", "prices"}`` for the market held, or ``None``.
|
|
247
|
+
|
|
248
|
+
Only returned when it belongs to the very ``version`` of the market in the book: an
|
|
249
|
+
older or newer one would be another price.
|
|
250
|
+
"""
|
|
251
|
+
ev = self._events.get(event_id)
|
|
252
|
+
if ev is None:
|
|
253
|
+
return None
|
|
254
|
+
m = ev.markets.get(key)
|
|
255
|
+
f = ev.fair.get(key)
|
|
256
|
+
if m is None or f is None or f.get("version") != m.get("version"):
|
|
257
|
+
return None
|
|
258
|
+
return f
|
|
259
|
+
|
|
260
|
+
def items(self) -> Iterator[Tuple[int, Dict[str, Market]]]:
|
|
261
|
+
"""``(event_id, markets)`` for every event."""
|
|
262
|
+
for event_id, ev in list(self._events.items()):
|
|
263
|
+
yield event_id, dict(ev.markets)
|
oddspush/errors.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Exceptions raised by the OddsPush client.
|
|
2
|
+
|
|
3
|
+
Every error carries the API's ``code`` (``unauthorized``, ``rate_limited``...). Errors that
|
|
4
|
+
retrying with the same token cannot fix (an unknown token, an expired plan, a plan without
|
|
5
|
+
the stream) are :class:`FatalError`; the stream stops reconnecting when it gets one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OddsPushError(Exception):
|
|
14
|
+
"""Base class of every error raised by this package."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, code: str, message: Optional[str] = None, *,
|
|
17
|
+
status: Optional[int] = None, close_code: Optional[int] = None,
|
|
18
|
+
retry_after: Optional[float] = None) -> None:
|
|
19
|
+
self.code = code
|
|
20
|
+
self.status = status
|
|
21
|
+
self.close_code = close_code
|
|
22
|
+
self.retry_after = retry_after
|
|
23
|
+
super().__init__(message or code)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FatalError(OddsPushError):
|
|
27
|
+
"""Retrying with the same token will not help."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Unauthorized(FatalError):
|
|
31
|
+
"""Unknown or revoked token (HTTP 401, close 4101)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SubscriptionExpired(FatalError):
|
|
35
|
+
"""The plan has expired (close 4103)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NotInPlan(FatalError):
|
|
39
|
+
"""The plan does not include this: ``stream_not_in_plan`` (4109), ``rest_not_in_plan``
|
|
40
|
+
or ``sport_not_in_plan``."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class IpNotAllowed(FatalError):
|
|
44
|
+
"""Only for accounts set up with a fixed IP (close 4105)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TokenReplaced(FatalError):
|
|
48
|
+
"""The token was used from another IP or connection, which now holds it (close 4107).
|
|
49
|
+
|
|
50
|
+
Reconnecting would close the other machine's stream, which would close this one again,
|
|
51
|
+
and so on. Run each token from one machine only.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RateLimited(OddsPushError):
|
|
56
|
+
"""Too many requests, reconnects or snapshots: wait ``retry_after`` seconds."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class NotFound(OddsPushError):
|
|
60
|
+
"""No such event, or no closing line yet (HTTP 404)."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BadRequest(OddsPushError):
|
|
64
|
+
"""A parameter is not valid (HTTP 400)."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Unavailable(OddsPushError):
|
|
68
|
+
"""The service is temporarily unavailable (HTTP 503)."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class StreamClosed(OddsPushError):
|
|
72
|
+
"""The stream closed and is not going to be reopened (``reconnect=False``)."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_BY_CODE = {
|
|
76
|
+
"unauthorized": Unauthorized,
|
|
77
|
+
"subscription_expired": SubscriptionExpired,
|
|
78
|
+
"stream_not_in_plan": NotInPlan,
|
|
79
|
+
"rest_not_in_plan": NotInPlan,
|
|
80
|
+
"sport_not_in_plan": NotInPlan,
|
|
81
|
+
"ip_not_allowed": IpNotAllowed,
|
|
82
|
+
"replaced": TokenReplaced,
|
|
83
|
+
"rate_limited": RateLimited,
|
|
84
|
+
"reconnect_rate_limited": RateLimited,
|
|
85
|
+
"snapshot_rate_limited": RateLimited,
|
|
86
|
+
"not_found": NotFound,
|
|
87
|
+
"bad_request": BadRequest,
|
|
88
|
+
"unavailable": Unavailable,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#: Close codes of the stream, by number.
|
|
92
|
+
CLOSE_CODES = {
|
|
93
|
+
4101: "unauthorized",
|
|
94
|
+
4102: "server_full",
|
|
95
|
+
4103: "subscription_expired",
|
|
96
|
+
4104: "connection_limit",
|
|
97
|
+
4105: "ip_not_allowed",
|
|
98
|
+
4106: "reconnect_rate_limited",
|
|
99
|
+
4107: "replaced",
|
|
100
|
+
4108: "too_slow",
|
|
101
|
+
4109: "stream_not_in_plan",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def error_for(code: str, message: Optional[str] = None, **kw) -> OddsPushError:
|
|
106
|
+
"""The exception that matches an API error ``code``."""
|
|
107
|
+
return _BY_CODE.get(code, OddsPushError)(code, message, **kw)
|
oddspush/odds.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Odds arithmetic: American and decimal odds, implied probability, margin and CLV.
|
|
2
|
+
|
|
3
|
+
The API sends prices in American odds and, with ``fair`` on, the no-vig and fair prices
|
|
4
|
+
already worked out. These helpers cover the rest: converting your own prices and
|
|
5
|
+
measuring your closing line value.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Iterable, List
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def american_to_decimal(american: float) -> float:
|
|
14
|
+
"""``-155`` -> ``1.6452``; ``+450`` -> ``5.5``. ``|american|`` must be at least 100."""
|
|
15
|
+
a = float(american)
|
|
16
|
+
if a >= 100:
|
|
17
|
+
return 1.0 + a / 100.0
|
|
18
|
+
if a <= -100:
|
|
19
|
+
return 1.0 + 100.0 / -a
|
|
20
|
+
raise ValueError(f"not valid American odds: {american!r}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def decimal_to_american(decimal: float) -> float:
|
|
24
|
+
"""``2.5`` -> ``150``; ``1.5`` -> ``-200``. ``decimal`` must be above 1."""
|
|
25
|
+
d = float(decimal)
|
|
26
|
+
if d <= 1.0:
|
|
27
|
+
raise ValueError(f"not valid decimal odds: {decimal!r}")
|
|
28
|
+
if d >= 2.0:
|
|
29
|
+
return (d - 1.0) * 100.0
|
|
30
|
+
return -100.0 / (d - 1.0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def implied_probability(american: float) -> float:
|
|
34
|
+
"""The probability the price implies, margin included: ``1 / decimal odds``."""
|
|
35
|
+
return 1.0 / american_to_decimal(american)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def margin(prices: Iterable[float]) -> float:
|
|
39
|
+
"""The bookmaker's margin of a market, from the American prices of all its legs:
|
|
40
|
+
the sum of their implied probabilities minus 1."""
|
|
41
|
+
return sum(implied_probability(p) for p in prices) - 1.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def no_vig(prices: Iterable[float]) -> List[float]:
|
|
45
|
+
"""No-vig probabilities, multiplicative method: each implied probability divided by
|
|
46
|
+
their sum. The API sends these as ``no_vig`` (with ``fair`` on)."""
|
|
47
|
+
implied = [implied_probability(p) for p in prices]
|
|
48
|
+
total = sum(implied)
|
|
49
|
+
return [q / total for q in implied]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def clv(decimal_odds: float, closing_decimal: float) -> float:
|
|
53
|
+
"""Closing line value of a bet: ``decimal_odds / closing_decimal - 1``.
|
|
54
|
+
|
|
55
|
+
``closing_decimal`` is usually the ``fair_decimal`` of the same market and leg in the
|
|
56
|
+
closing line. ``0.05`` means you beat the close by 5 %.
|
|
57
|
+
"""
|
|
58
|
+
if closing_decimal <= 1.0:
|
|
59
|
+
raise ValueError(f"not valid decimal odds: {closing_decimal!r}")
|
|
60
|
+
return float(decimal_odds) / float(closing_decimal) - 1.0
|
oddspush/py.typed
ADDED
|
File without changes
|