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 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