oddspush 0.1.0__tar.gz
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-0.1.0/.gitignore +7 -0
- oddspush-0.1.0/CHANGELOG.md +5 -0
- oddspush-0.1.0/LICENSE +21 -0
- oddspush-0.1.0/PKG-INFO +193 -0
- oddspush-0.1.0/README.md +159 -0
- oddspush-0.1.0/examples/clv_report.py +40 -0
- oddspush-0.1.0/examples/fair_prices.py +29 -0
- oddspush-0.1.0/examples/live_odds.py +34 -0
- oddspush-0.1.0/examples/prematch_tracker.py +69 -0
- oddspush-0.1.0/pyproject.toml +57 -0
- oddspush-0.1.0/src/oddspush/__init__.py +29 -0
- oddspush-0.1.0/src/oddspush/_constants.py +30 -0
- oddspush-0.1.0/src/oddspush/_version.py +1 -0
- oddspush-0.1.0/src/oddspush/book.py +263 -0
- oddspush-0.1.0/src/oddspush/errors.py +107 -0
- oddspush-0.1.0/src/oddspush/odds.py +60 -0
- oddspush-0.1.0/src/oddspush/py.typed +0 -0
- oddspush-0.1.0/src/oddspush/rest.py +284 -0
- oddspush-0.1.0/src/oddspush/stream.py +509 -0
- oddspush-0.1.0/tests/conftest.py +89 -0
- oddspush-0.1.0/tests/test_book.py +109 -0
- oddspush-0.1.0/tests/test_book_fixes.py +75 -0
- oddspush-0.1.0/tests/test_odds.py +39 -0
- oddspush-0.1.0/tests/test_rest.py +161 -0
- oddspush-0.1.0/tests/test_stream.py +303 -0
- oddspush-0.1.0/tests/test_stream_fixes.py +217 -0
oddspush-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OddsPush
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
oddspush-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: oddspush
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the OddsPush API: real-time Pinnacle odds over WebSocket and REST, with no-vig, fair and closing-line (CLV) prices.
|
|
5
|
+
Project-URL: Homepage, https://oddspush.com
|
|
6
|
+
Project-URL: Documentation, https://oddspush.com/docs
|
|
7
|
+
Project-URL: Pricing, https://oddspush.com/#pricing
|
|
8
|
+
Project-URL: Source, https://github.com/oddspush/oddspush-python
|
|
9
|
+
Project-URL: Issues, https://github.com/oddspush/oddspush-python/issues
|
|
10
|
+
Author-email: OddsPush <support@oddspush.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: betting,closing-line,clv,fair-odds,kalshi,no-vig,odds,odds-api,pinnacle,pinnacle-api,polymarket,sports-betting,websocket
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Requires-Dist: httpx>=0.24
|
|
29
|
+
Requires-Dist: websockets>=12
|
|
30
|
+
Provides-Extra: test
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'test'
|
|
32
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# oddspush
|
|
36
|
+
|
|
37
|
+
Python client for the [OddsPush](https://oddspush.com) API: **real-time Pinnacle odds** over
|
|
38
|
+
one WebSocket, plus a REST API, with **no-vig and fair prices** and the **closing line** for
|
|
39
|
+
your CLV.
|
|
40
|
+
|
|
41
|
+
- All 13 sports, live and pre-match.
|
|
42
|
+
- A local price book, kept up to date for you: merged by version, closed markets and
|
|
43
|
+
periods removed.
|
|
44
|
+
- Reconnects on its own with backoff, subscribes again and catches up with a snapshot.
|
|
45
|
+
- Sync and asyncio REST clients.
|
|
46
|
+
- Pure Python, two dependencies (`websockets`, `httpx`), Python 3.10+.
|
|
47
|
+
|
|
48
|
+
You need an API token (`opk_...`). [Start a free 7-day trial](https://oddspush.com) to get
|
|
49
|
+
one. Full API reference: [oddspush.com/docs](https://oddspush.com/docs).
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install oddspush
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Stream live odds
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import asyncio
|
|
59
|
+
from oddspush import Stream
|
|
60
|
+
|
|
61
|
+
async def main():
|
|
62
|
+
async with Stream("opk_...", sports=[1], channels=["ld", "both"]) as stream:
|
|
63
|
+
async for msg in stream:
|
|
64
|
+
if msg["type"] == "odds" and msg["data"].get("type") == "live":
|
|
65
|
+
rec = msg["data"]["rec"]
|
|
66
|
+
for key, market in stream.book.markets(rec["id"]).items():
|
|
67
|
+
print(rec["id"], key, [(p["designation"], p["price"]) for p in market["prices"]])
|
|
68
|
+
|
|
69
|
+
asyncio.run(main())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`stream.book` always holds the current open markets of every event you follow:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
stream.book.event_ids() # events in the book
|
|
76
|
+
stream.book.event(event_id) # league, participants, state, startTime...
|
|
77
|
+
stream.book.markets(event_id) # {"s;0;m": {...}, "s;0;ou;2.5": {...}}
|
|
78
|
+
stream.book.market(event_id, "s;0;m")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Prices are in American odds; `oddspush.american_to_decimal(-155)` gives `1.645`.
|
|
82
|
+
|
|
83
|
+
Sports: 1 Soccer, 2 Tennis, 3 Basketball, 4 Hockey, 5 American football, 6 Baseball,
|
|
84
|
+
7 Rugby, 8 MMA, 9 Boxing, 10 Other (volleyball and handball), 11 Esports, 12 Golf,
|
|
85
|
+
13 Cricket (`oddspush.SPORTS`).
|
|
86
|
+
|
|
87
|
+
Channels: `ld` and `both` are the prices (ask for both). Add `pre` for pre-match prices.
|
|
88
|
+
`dz` carries volatile moments: the book leaves it out, and with `dz` in your channels the
|
|
89
|
+
stream asks for snapshots of the book's channels only. Without `channels`, live updates come
|
|
90
|
+
on every channel; the book still takes only `ld` and `both`.
|
|
91
|
+
|
|
92
|
+
## No-vig and fair prices
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
async with Stream("opk_...", sports=[1], channels=["ld", "both"], fair=True) as stream:
|
|
96
|
+
async for msg in stream:
|
|
97
|
+
if msg["type"] == "fair":
|
|
98
|
+
for m in msg["data"]["markets"]:
|
|
99
|
+
print(m["key"], m["margin"], [(p["designation"], p["fair_decimal"]) for p in m["prices"]])
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Each `fair` message follows its `odds` update with the same markets priced without the
|
|
103
|
+
bookmaker's margin, two ways: `no_vig` (multiplicative) and `fair` (power method, which
|
|
104
|
+
corrects the favourite-longshot bias). The book keeps them too:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
stream.book.fair(event_id, "s;0;m") # {"version", "margin", "prices"} or None
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
It only returns the fair price of the very market version the book holds.
|
|
111
|
+
|
|
112
|
+
## Closing line and CLV
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from oddspush import Client, clv
|
|
116
|
+
|
|
117
|
+
with Client("opk_...") as api:
|
|
118
|
+
line = api.closing(1639824805) # pre-match or live event id
|
|
119
|
+
ml = next(m for m in line["markets"] if m["key"] == "s;0;m")
|
|
120
|
+
home = next(p for p in ml["prices"] if p["designation"] == "home")
|
|
121
|
+
print(clv(2.10, home["fair_decimal"])) # your price vs the fair closing price
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Or get them as they happen with `Stream(..., closing=True)` (`closing` messages).
|
|
125
|
+
|
|
126
|
+
## REST
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from oddspush import Client
|
|
130
|
+
|
|
131
|
+
with Client("opk_...") as api:
|
|
132
|
+
api.events(sport_id=1) # every event, markets per channel
|
|
133
|
+
api.event(1638807209, fair=True) # one event, ld + both merged
|
|
134
|
+
api.event(1639824805, channel="pre", fair=True) # a pre-match event
|
|
135
|
+
api.membership(sport_id=1) # pre-match events about to go live
|
|
136
|
+
for row in api.iter_closings(sport_id=1): # matches with a closing line
|
|
137
|
+
print(row["event_id"], row["start_ms"])
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`AsyncClient` has the same methods, awaitable. A `429` is retried after `retry-after`
|
|
141
|
+
(`max_retries=2`), then raised as `RateLimited`. Limits: 120 requests at once, then 300 a
|
|
142
|
+
minute per account.
|
|
143
|
+
|
|
144
|
+
## What the stream does for you
|
|
145
|
+
|
|
146
|
+
- Logs in and subscribes. After a drop it reconnects with exponential backoff (1 s, 2 s,
|
|
147
|
+
4 s... up to 30 s), restores your filter and asks for a snapshot.
|
|
148
|
+
- Asks for a snapshot after `missed` (you fell behind) and after `feed: up` (the source
|
|
149
|
+
came back), and again later if the snapshot rate limit says so.
|
|
150
|
+
- Yields `{"type": "disconnected", "reason": ..., "retry_in_s": ...}` each time the
|
|
151
|
+
connection drops, so you can log it.
|
|
152
|
+
- Snapshots count against your plan's `snapshots_per_hour`. Pass `resync=False` to stop
|
|
153
|
+
the automatic ones.
|
|
154
|
+
- Stops and raises on what retrying cannot fix: `Unauthorized`, `SubscriptionExpired`,
|
|
155
|
+
`NotInPlan` (a REST-only plan) and `TokenReplaced`.
|
|
156
|
+
|
|
157
|
+
Change the subscription at any time:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
await stream.subscribe(sports=[2, 3])
|
|
161
|
+
await stream.unsubscribe(sports=[3])
|
|
162
|
+
await stream.subscribe(fair=True) # turn fair prices on, filter unchanged, no snapshot
|
|
163
|
+
await stream.request_snapshot()
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
In a bot that runs for days, `stream.book.prune(3600)` drops events that have had no update
|
|
167
|
+
for an hour and have no open market left.
|
|
168
|
+
|
|
169
|
+
### One machine per token
|
|
170
|
+
|
|
171
|
+
A token works from one IP at a time: using it from a new IP (a stream login or a REST
|
|
172
|
+
request) moves it there and closes the stream on the old one (`TokenReplaced`). Run your
|
|
173
|
+
stream and your REST calls from the same machine.
|
|
174
|
+
|
|
175
|
+
### Keep up
|
|
176
|
+
|
|
177
|
+
Read fast and do heavy work in another task: if you fall more than 15 seconds behind, the
|
|
178
|
+
server closes the connection and the stream reconnects. Host your bot close to Ireland (AWS
|
|
179
|
+
`eu-west-1`) for the lowest latency.
|
|
180
|
+
|
|
181
|
+
## Examples
|
|
182
|
+
|
|
183
|
+
See [`examples/`](https://github.com/oddspush/oddspush-python/tree/main/examples): a live
|
|
184
|
+
printer, a fair-price watcher, a pre-match tracker and a CLV report.
|
|
185
|
+
|
|
186
|
+
## Links
|
|
187
|
+
|
|
188
|
+
- Website and pricing: [oddspush.com](https://oddspush.com)
|
|
189
|
+
- API reference: [oddspush.com/docs](https://oddspush.com/docs)
|
|
190
|
+
- Source and issues: [github.com/oddspush/oddspush-python](https://github.com/oddspush/oddspush-python)
|
|
191
|
+
- Support: support@oddspush.com
|
|
192
|
+
|
|
193
|
+
OddsPush is not affiliated with, endorsed by or sponsored by Pinnacle. MIT licensed.
|
oddspush-0.1.0/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# oddspush
|
|
2
|
+
|
|
3
|
+
Python client for the [OddsPush](https://oddspush.com) API: **real-time Pinnacle odds** over
|
|
4
|
+
one WebSocket, plus a REST API, with **no-vig and fair prices** and the **closing line** for
|
|
5
|
+
your CLV.
|
|
6
|
+
|
|
7
|
+
- All 13 sports, live and pre-match.
|
|
8
|
+
- A local price book, kept up to date for you: merged by version, closed markets and
|
|
9
|
+
periods removed.
|
|
10
|
+
- Reconnects on its own with backoff, subscribes again and catches up with a snapshot.
|
|
11
|
+
- Sync and asyncio REST clients.
|
|
12
|
+
- Pure Python, two dependencies (`websockets`, `httpx`), Python 3.10+.
|
|
13
|
+
|
|
14
|
+
You need an API token (`opk_...`). [Start a free 7-day trial](https://oddspush.com) to get
|
|
15
|
+
one. Full API reference: [oddspush.com/docs](https://oddspush.com/docs).
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install oddspush
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Stream live odds
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import asyncio
|
|
25
|
+
from oddspush import Stream
|
|
26
|
+
|
|
27
|
+
async def main():
|
|
28
|
+
async with Stream("opk_...", sports=[1], channels=["ld", "both"]) as stream:
|
|
29
|
+
async for msg in stream:
|
|
30
|
+
if msg["type"] == "odds" and msg["data"].get("type") == "live":
|
|
31
|
+
rec = msg["data"]["rec"]
|
|
32
|
+
for key, market in stream.book.markets(rec["id"]).items():
|
|
33
|
+
print(rec["id"], key, [(p["designation"], p["price"]) for p in market["prices"]])
|
|
34
|
+
|
|
35
|
+
asyncio.run(main())
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`stream.book` always holds the current open markets of every event you follow:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
stream.book.event_ids() # events in the book
|
|
42
|
+
stream.book.event(event_id) # league, participants, state, startTime...
|
|
43
|
+
stream.book.markets(event_id) # {"s;0;m": {...}, "s;0;ou;2.5": {...}}
|
|
44
|
+
stream.book.market(event_id, "s;0;m")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Prices are in American odds; `oddspush.american_to_decimal(-155)` gives `1.645`.
|
|
48
|
+
|
|
49
|
+
Sports: 1 Soccer, 2 Tennis, 3 Basketball, 4 Hockey, 5 American football, 6 Baseball,
|
|
50
|
+
7 Rugby, 8 MMA, 9 Boxing, 10 Other (volleyball and handball), 11 Esports, 12 Golf,
|
|
51
|
+
13 Cricket (`oddspush.SPORTS`).
|
|
52
|
+
|
|
53
|
+
Channels: `ld` and `both` are the prices (ask for both). Add `pre` for pre-match prices.
|
|
54
|
+
`dz` carries volatile moments: the book leaves it out, and with `dz` in your channels the
|
|
55
|
+
stream asks for snapshots of the book's channels only. Without `channels`, live updates come
|
|
56
|
+
on every channel; the book still takes only `ld` and `both`.
|
|
57
|
+
|
|
58
|
+
## No-vig and fair prices
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
async with Stream("opk_...", sports=[1], channels=["ld", "both"], fair=True) as stream:
|
|
62
|
+
async for msg in stream:
|
|
63
|
+
if msg["type"] == "fair":
|
|
64
|
+
for m in msg["data"]["markets"]:
|
|
65
|
+
print(m["key"], m["margin"], [(p["designation"], p["fair_decimal"]) for p in m["prices"]])
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Each `fair` message follows its `odds` update with the same markets priced without the
|
|
69
|
+
bookmaker's margin, two ways: `no_vig` (multiplicative) and `fair` (power method, which
|
|
70
|
+
corrects the favourite-longshot bias). The book keeps them too:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
stream.book.fair(event_id, "s;0;m") # {"version", "margin", "prices"} or None
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
It only returns the fair price of the very market version the book holds.
|
|
77
|
+
|
|
78
|
+
## Closing line and CLV
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from oddspush import Client, clv
|
|
82
|
+
|
|
83
|
+
with Client("opk_...") as api:
|
|
84
|
+
line = api.closing(1639824805) # pre-match or live event id
|
|
85
|
+
ml = next(m for m in line["markets"] if m["key"] == "s;0;m")
|
|
86
|
+
home = next(p for p in ml["prices"] if p["designation"] == "home")
|
|
87
|
+
print(clv(2.10, home["fair_decimal"])) # your price vs the fair closing price
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Or get them as they happen with `Stream(..., closing=True)` (`closing` messages).
|
|
91
|
+
|
|
92
|
+
## REST
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from oddspush import Client
|
|
96
|
+
|
|
97
|
+
with Client("opk_...") as api:
|
|
98
|
+
api.events(sport_id=1) # every event, markets per channel
|
|
99
|
+
api.event(1638807209, fair=True) # one event, ld + both merged
|
|
100
|
+
api.event(1639824805, channel="pre", fair=True) # a pre-match event
|
|
101
|
+
api.membership(sport_id=1) # pre-match events about to go live
|
|
102
|
+
for row in api.iter_closings(sport_id=1): # matches with a closing line
|
|
103
|
+
print(row["event_id"], row["start_ms"])
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`AsyncClient` has the same methods, awaitable. A `429` is retried after `retry-after`
|
|
107
|
+
(`max_retries=2`), then raised as `RateLimited`. Limits: 120 requests at once, then 300 a
|
|
108
|
+
minute per account.
|
|
109
|
+
|
|
110
|
+
## What the stream does for you
|
|
111
|
+
|
|
112
|
+
- Logs in and subscribes. After a drop it reconnects with exponential backoff (1 s, 2 s,
|
|
113
|
+
4 s... up to 30 s), restores your filter and asks for a snapshot.
|
|
114
|
+
- Asks for a snapshot after `missed` (you fell behind) and after `feed: up` (the source
|
|
115
|
+
came back), and again later if the snapshot rate limit says so.
|
|
116
|
+
- Yields `{"type": "disconnected", "reason": ..., "retry_in_s": ...}` each time the
|
|
117
|
+
connection drops, so you can log it.
|
|
118
|
+
- Snapshots count against your plan's `snapshots_per_hour`. Pass `resync=False` to stop
|
|
119
|
+
the automatic ones.
|
|
120
|
+
- Stops and raises on what retrying cannot fix: `Unauthorized`, `SubscriptionExpired`,
|
|
121
|
+
`NotInPlan` (a REST-only plan) and `TokenReplaced`.
|
|
122
|
+
|
|
123
|
+
Change the subscription at any time:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
await stream.subscribe(sports=[2, 3])
|
|
127
|
+
await stream.unsubscribe(sports=[3])
|
|
128
|
+
await stream.subscribe(fair=True) # turn fair prices on, filter unchanged, no snapshot
|
|
129
|
+
await stream.request_snapshot()
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
In a bot that runs for days, `stream.book.prune(3600)` drops events that have had no update
|
|
133
|
+
for an hour and have no open market left.
|
|
134
|
+
|
|
135
|
+
### One machine per token
|
|
136
|
+
|
|
137
|
+
A token works from one IP at a time: using it from a new IP (a stream login or a REST
|
|
138
|
+
request) moves it there and closes the stream on the old one (`TokenReplaced`). Run your
|
|
139
|
+
stream and your REST calls from the same machine.
|
|
140
|
+
|
|
141
|
+
### Keep up
|
|
142
|
+
|
|
143
|
+
Read fast and do heavy work in another task: if you fall more than 15 seconds behind, the
|
|
144
|
+
server closes the connection and the stream reconnects. Host your bot close to Ireland (AWS
|
|
145
|
+
`eu-west-1`) for the lowest latency.
|
|
146
|
+
|
|
147
|
+
## Examples
|
|
148
|
+
|
|
149
|
+
See [`examples/`](https://github.com/oddspush/oddspush-python/tree/main/examples): a live
|
|
150
|
+
printer, a fair-price watcher, a pre-match tracker and a CLV report.
|
|
151
|
+
|
|
152
|
+
## Links
|
|
153
|
+
|
|
154
|
+
- Website and pricing: [oddspush.com](https://oddspush.com)
|
|
155
|
+
- API reference: [oddspush.com/docs](https://oddspush.com/docs)
|
|
156
|
+
- Source and issues: [github.com/oddspush/oddspush-python](https://github.com/oddspush/oddspush-python)
|
|
157
|
+
- Support: support@oddspush.com
|
|
158
|
+
|
|
159
|
+
OddsPush is not affiliated with, endorsed by or sponsored by Pinnacle. MIT licensed.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Closing line value of your bets, against Pinnacle's fair closing price.
|
|
2
|
+
|
|
3
|
+
bets.csv has one bet per line: event_id,market_key,designation,decimal_odds
|
|
4
|
+
(for example ``1639824805,s;0;m,home,2.10``). Closing lines are kept for 7 days.
|
|
5
|
+
|
|
6
|
+
ODDSPUSH_TOKEN=opk_... python clv_report.py bets.csv
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from oddspush import Client, NotFound, clv
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(path):
|
|
17
|
+
total, n = 0.0, 0
|
|
18
|
+
with Client(os.environ["ODDSPUSH_TOKEN"]) as api, open(path, newline="") as f:
|
|
19
|
+
for event_id, key, designation, odds in csv.reader(f):
|
|
20
|
+
try:
|
|
21
|
+
line = api.closing(int(event_id))
|
|
22
|
+
except NotFound:
|
|
23
|
+
print(f"{event_id}: no closing line (not started yet, or older than 7 days)")
|
|
24
|
+
continue
|
|
25
|
+
market = next((m for m in line["markets"] if m["key"] == key), None)
|
|
26
|
+
leg = next((p for p in (market or {}).get("prices", [])
|
|
27
|
+
if p["designation"] == designation), None)
|
|
28
|
+
if leg is None:
|
|
29
|
+
print(f"{event_id} {key} {designation}: not in the closing line")
|
|
30
|
+
continue
|
|
31
|
+
value = clv(float(odds), leg["fair_decimal"])
|
|
32
|
+
total, n = total + value, n + 1
|
|
33
|
+
print(f"{line['home']} v {line['away']} {key} {designation}: "
|
|
34
|
+
f"{float(odds):.2f} vs fair close {leg['fair_decimal']:.2f} -> CLV {value:+.2%}")
|
|
35
|
+
if n:
|
|
36
|
+
print(f"average CLV over {n} bets: {total / n:+.2%}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
main(sys.argv[1])
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Watch the no-vig and fair prices of tennis moneylines, with the bookmaker's margin.
|
|
2
|
+
|
|
3
|
+
ODDSPUSH_TOKEN=opk_... python fair_prices.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from oddspush import Stream
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def main():
|
|
13
|
+
async with Stream(os.environ["ODDSPUSH_TOKEN"], sports=[2], channels=["ld", "both"],
|
|
14
|
+
fair=True) as stream:
|
|
15
|
+
async for msg in stream:
|
|
16
|
+
if msg["type"] != "fair":
|
|
17
|
+
continue
|
|
18
|
+
data = msg["data"]
|
|
19
|
+
for market in data["markets"]:
|
|
20
|
+
if market["key"] != "s;0;m":
|
|
21
|
+
continue
|
|
22
|
+
legs = ", ".join(f"{p['designation']} {p['fair_decimal']:.3f} "
|
|
23
|
+
f"(no-vig {p['no_vig_decimal']:.3f})"
|
|
24
|
+
for p in market["prices"])
|
|
25
|
+
print(f"{data['event_id']}: margin {market['margin']:.2%} | {legs}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Print the full-game moneyline of every soccer match as it moves.
|
|
2
|
+
|
|
3
|
+
ODDSPUSH_TOKEN=opk_... python live_odds.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from oddspush import Stream, american_to_decimal
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def teams(event):
|
|
13
|
+
names = {p.get("alignment"): p.get("name") for p in event.get("participants") or []}
|
|
14
|
+
return f"{names.get('home', '?')} v {names.get('away', '?')}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def main():
|
|
18
|
+
async with Stream(os.environ["ODDSPUSH_TOKEN"], sports=[1], channels=["ld", "both"]) as stream:
|
|
19
|
+
async for msg in stream:
|
|
20
|
+
if msg["type"] == "disconnected":
|
|
21
|
+
print("disconnected:", msg["reason"], "- retrying in", msg["retry_in_s"], "s")
|
|
22
|
+
if msg["type"] != "odds" or msg["data"].get("type") != "live":
|
|
23
|
+
continue
|
|
24
|
+
event_id = msg["data"]["rec"]["id"]
|
|
25
|
+
moneyline = stream.book.market(event_id, "s;0;m")
|
|
26
|
+
if moneyline is None:
|
|
27
|
+
continue
|
|
28
|
+
prices = {p["designation"]: round(american_to_decimal(p["price"]), 3)
|
|
29
|
+
for p in moneyline["prices"]}
|
|
30
|
+
print(teams(stream.book.event(event_id)), prices)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Track the pre-match fair price of a few matches until each one really kicks off.
|
|
2
|
+
|
|
3
|
+
Pre-match prices come on the ``pre`` channel. When a match starts (which can be later than
|
|
4
|
+
its scheduled ``startTime``), its closing line is set and arrives as a ``closing`` message:
|
|
5
|
+
the pre-match prices are final from then on, so the tracker prints the close and stops
|
|
6
|
+
following that match.
|
|
7
|
+
|
|
8
|
+
ODDSPUSH_TOKEN=opk_... python prematch_tracker.py 1639824805 1639824811
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from oddspush import Stream
|
|
17
|
+
|
|
18
|
+
GIVE_UP_S = 600 # an id that never shows up (wrong id, or already over)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def moneyline(prices):
|
|
22
|
+
return ", ".join(f"{p['designation']} {p['fair_decimal']:.3f}" for p in prices)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def main(event_ids):
|
|
26
|
+
following = set(event_ids)
|
|
27
|
+
seen = set()
|
|
28
|
+
started = time.monotonic()
|
|
29
|
+
async with Stream(os.environ["ODDSPUSH_TOKEN"], events=event_ids, channels=["pre"],
|
|
30
|
+
fair=True, closing=True) as stream:
|
|
31
|
+
async for msg in stream:
|
|
32
|
+
kind = msg["type"]
|
|
33
|
+
if kind == "closing":
|
|
34
|
+
data = msg["data"]
|
|
35
|
+
if data["event_id"] in following:
|
|
36
|
+
ml = next((m for m in data["markets"] if m["key"] == "s;0;m"), None)
|
|
37
|
+
print(f"{data['event_id']} kicked off ({data['reason']}); closing fair: "
|
|
38
|
+
f"{moneyline(ml['prices']) if ml else 'no moneyline'}")
|
|
39
|
+
following.discard(data["event_id"])
|
|
40
|
+
await stream.unsubscribe(events=[data["event_id"]])
|
|
41
|
+
elif kind == "snapshot" and msg.get("final"):
|
|
42
|
+
# The current prices; pre-match prices can then stay still for hours.
|
|
43
|
+
for event_id in sorted(following):
|
|
44
|
+
fair = stream.book.fair(event_id, "s;0;m")
|
|
45
|
+
if fair:
|
|
46
|
+
print(f"{event_id}: {moneyline(fair['prices'])}")
|
|
47
|
+
elif kind == "fair":
|
|
48
|
+
event_id = msg["data"]["event_id"]
|
|
49
|
+
fair = stream.book.fair(event_id, "s;0;m")
|
|
50
|
+
if event_id in following and fair:
|
|
51
|
+
print(f"{event_id}: {moneyline(fair['prices'])}")
|
|
52
|
+
elif kind == "odds" and msg["data"].get("op") == "del":
|
|
53
|
+
event_id = msg["data"]["rec"].get("id")
|
|
54
|
+
if event_id in following:
|
|
55
|
+
print(f"{event_id} removed (cancelled or withdrawn)")
|
|
56
|
+
following.discard(event_id)
|
|
57
|
+
for event_id in following:
|
|
58
|
+
if stream.book.event(event_id) is not None:
|
|
59
|
+
seen.add(event_id)
|
|
60
|
+
if time.monotonic() - started > GIVE_UP_S:
|
|
61
|
+
for event_id in following - seen:
|
|
62
|
+
print(f"{event_id}: never seen, giving up")
|
|
63
|
+
following &= seen
|
|
64
|
+
if not following:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
asyncio.run(main([int(a) for a in sys.argv[1:]]))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "oddspush"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the OddsPush API: real-time Pinnacle odds over WebSocket and REST, with no-vig, fair and closing-line (CLV) prices."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "OddsPush", email = "support@oddspush.com" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"pinnacle", "pinnacle-api", "odds", "odds-api", "sports-betting", "betting",
|
|
16
|
+
"websocket", "no-vig", "fair-odds", "clv", "closing-line", "polymarket", "kalshi",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Framework :: AsyncIO",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
30
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
31
|
+
"Typing :: Typed",
|
|
32
|
+
]
|
|
33
|
+
dependencies = [
|
|
34
|
+
"httpx>=0.24",
|
|
35
|
+
"websockets>=12",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
test = ["pytest>=7", "pytest-asyncio>=0.21"]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://oddspush.com"
|
|
43
|
+
Documentation = "https://oddspush.com/docs"
|
|
44
|
+
Pricing = "https://oddspush.com/#pricing"
|
|
45
|
+
Source = "https://github.com/oddspush/oddspush-python"
|
|
46
|
+
Issues = "https://github.com/oddspush/oddspush-python/issues"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/oddspush"]
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.sdist]
|
|
52
|
+
include = ["src/oddspush", "tests", "examples", "README.md", "LICENSE", "CHANGELOG.md"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
asyncio_mode = "auto"
|
|
56
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
57
|
+
testpaths = ["tests"]
|
|
@@ -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
|
+
]
|