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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ .pytest_cache/
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release: `Stream`, `Client`, `AsyncClient`, `Book` and the odds helpers.
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.
@@ -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.
@@ -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
+ ]