pinnradar 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,17 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ pull_request:
5
+ jobs:
6
+ test:
7
+ runs-on: ubuntu-latest
8
+ strategy:
9
+ matrix:
10
+ python: ["3.9", "3.11", "3.13"]
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: ${{ matrix.python }}
16
+ - run: pip install pytest
17
+ - run: pytest -q
@@ -0,0 +1,22 @@
1
+ # Publishes to PyPI when a version tag (v*) is pushed. Uses PyPI trusted
2
+ # publishing (OIDC): no token is stored anywhere. Configure once on PyPI:
3
+ # project "pinnradar", owner "PinnRadar", repo "pinnradar-python",
4
+ # workflow "publish.yml", environment "pypi".
5
+ name: Publish
6
+ on:
7
+ push:
8
+ tags: ["v*"]
9
+ workflow_dispatch:
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+ environment: pypi
14
+ permissions:
15
+ id-token: write
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.12"
21
+ - run: pip install pytest build && pytest -q && python -m build
22
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ .venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PinnRadar
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,120 @@
1
+ Metadata-Version: 2.5
2
+ Name: pinnradar
3
+ Version: 0.1.0
4
+ Summary: Python client for the PinnRadar API: real-time Pinnacle odds, live and prematch markets, dropping-odds alerts over SSE.
5
+ Project-URL: Homepage, https://pinnradar.com
6
+ Project-URL: Documentation, https://pinnradar.com/docs
7
+ Project-URL: Source, https://github.com/PinnRadar/pinnradar-python
8
+ Project-URL: Pricing, https://pinnradar.com/pinnacle-api-pricing
9
+ Author-email: PinnRadar <info@pinnradar.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: dropping odds,live odds,no vig,odds,odds api,pinnacle,pinnacle api,sports betting,sports data
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+
22
+ # pinnradar
23
+
24
+ Python client for the [PinnRadar API](https://pinnradar.com): real-time **Pinnacle odds** as JSON. Live and prematch markets (moneyline, spreads, totals, team totals, every period), dropping-odds alerts over Server-Sent Events, and no-vig helpers.
25
+
26
+ - No dependencies (standard library only), Python 3.8+
27
+ - Typed, with clear errors (`AuthError`, `RateLimitError`)
28
+ - Streams reconnect with backoff
29
+
30
+ ```bash
31
+ pip install pinnradar
32
+ ```
33
+
34
+ Get a free key at [pinnradar.com](https://pinnradar.com/panel#signup) (email only, no card, no Pinnacle account).
35
+
36
+ ## Quick start
37
+
38
+ ```python
39
+ from pinnradar import Client
40
+
41
+ pr = Client("YOUR_KEY") # or set PINNRADAR_API_KEY
42
+
43
+ # Live soccer board (sport_id 1)
44
+ board = pr.markets(sport_id=1)
45
+ for ev in board["events"]:
46
+ ml = ev["periods"]["num_0"].get("money_line") or {}
47
+ print(ev["home"], "v", ev["away"], ml.get("home"), ml.get("draw"), ml.get("away"))
48
+
49
+ # Only what changed since the last call
50
+ board = pr.markets(sport_id=1, since=board["last"])
51
+ ```
52
+
53
+ ## Prematch
54
+
55
+ ```python
56
+ fixtures = pr.prematch_fixtures(sport_id=3) # basketball
57
+ event_id = fixtures["events"][0]["event_id"]
58
+ markets = pr.prematch_markets(event_id) # every period and line
59
+ totals = pr.prematch_lines(event_id, market_type="totals")
60
+ ```
61
+
62
+ ## Dropping odds
63
+
64
+ Recent drops from the server-side buffer:
65
+
66
+ ```python
67
+ for d in pr.drops(mode="live", sport_id=1, min_drop_pct=7, max_age_sec=600):
68
+ print(d["home"], d["market"], d["side"], d["from"], "->", d["to"], f'{d["drop_pct"]:.1f}%')
69
+ ```
70
+
71
+ Or follow them live over SSE (plans with SSE: Drops, Edge, Scale):
72
+
73
+ ```python
74
+ for drop in pr.stream_drops(min_drop=5): # prematch=True for /odds-drop-prematch
75
+ print(drop["home"], drop["sect"], drop["outcome"], drop["from_price"], "->", drop["to_price"], "fair", drop["nvp"])
76
+ ```
77
+
78
+ ## No-vig (fair) odds
79
+
80
+ ```python
81
+ from pinnradar import no_vig, margin
82
+
83
+ margin([1.952, 1.952]) # 0.0246 -> 2.5 % margin
84
+ no_vig([2.10, 3.45, 3.70]) # fair 1X2 prices
85
+ no_vig([1.25, 4.20], method="power") # takes more margin from the longshot
86
+ ```
87
+
88
+ There is also an online [no-vig calculator](https://pinnradar.com/tools/no-vig-calculator).
89
+
90
+ ## Sport ids
91
+
92
+ | id | sport | id | sport |
93
+ |---|---|---|---|
94
+ | 1 | Soccer | 5 | American football |
95
+ | 2 | Tennis | 6 | Baseball |
96
+ | 3 | Basketball | 11 | Esports |
97
+ | 4 | Hockey | | |
98
+
99
+ ## Errors and limits
100
+
101
+ ```python
102
+ from pinnradar import AuthError, RateLimitError
103
+
104
+ try:
105
+ pr.markets(1)
106
+ except RateLimitError as e:
107
+ print("slow down for", e.retry_after, "seconds")
108
+ except AuthError:
109
+ print("check your key or plan")
110
+ ```
111
+
112
+ Rate limits depend on the plan; see [pricing](https://pinnradar.com/pinnacle-api-pricing). Any other endpoint can be called with `pr.get("/path", **params)`.
113
+
114
+ ## Links
115
+
116
+ - API docs: https://pinnradar.com/docs
117
+ - WebSocket feed and MCP server: https://pinnradar.com/docs
118
+ - Node.js client: https://www.npmjs.com/package/pinnradar
119
+
120
+ PinnRadar is an independent data service, not affiliated with Pinnacle. MIT licensed.
@@ -0,0 +1,99 @@
1
+ # pinnradar
2
+
3
+ Python client for the [PinnRadar API](https://pinnradar.com): real-time **Pinnacle odds** as JSON. Live and prematch markets (moneyline, spreads, totals, team totals, every period), dropping-odds alerts over Server-Sent Events, and no-vig helpers.
4
+
5
+ - No dependencies (standard library only), Python 3.8+
6
+ - Typed, with clear errors (`AuthError`, `RateLimitError`)
7
+ - Streams reconnect with backoff
8
+
9
+ ```bash
10
+ pip install pinnradar
11
+ ```
12
+
13
+ Get a free key at [pinnradar.com](https://pinnradar.com/panel#signup) (email only, no card, no Pinnacle account).
14
+
15
+ ## Quick start
16
+
17
+ ```python
18
+ from pinnradar import Client
19
+
20
+ pr = Client("YOUR_KEY") # or set PINNRADAR_API_KEY
21
+
22
+ # Live soccer board (sport_id 1)
23
+ board = pr.markets(sport_id=1)
24
+ for ev in board["events"]:
25
+ ml = ev["periods"]["num_0"].get("money_line") or {}
26
+ print(ev["home"], "v", ev["away"], ml.get("home"), ml.get("draw"), ml.get("away"))
27
+
28
+ # Only what changed since the last call
29
+ board = pr.markets(sport_id=1, since=board["last"])
30
+ ```
31
+
32
+ ## Prematch
33
+
34
+ ```python
35
+ fixtures = pr.prematch_fixtures(sport_id=3) # basketball
36
+ event_id = fixtures["events"][0]["event_id"]
37
+ markets = pr.prematch_markets(event_id) # every period and line
38
+ totals = pr.prematch_lines(event_id, market_type="totals")
39
+ ```
40
+
41
+ ## Dropping odds
42
+
43
+ Recent drops from the server-side buffer:
44
+
45
+ ```python
46
+ for d in pr.drops(mode="live", sport_id=1, min_drop_pct=7, max_age_sec=600):
47
+ print(d["home"], d["market"], d["side"], d["from"], "->", d["to"], f'{d["drop_pct"]:.1f}%')
48
+ ```
49
+
50
+ Or follow them live over SSE (plans with SSE: Drops, Edge, Scale):
51
+
52
+ ```python
53
+ for drop in pr.stream_drops(min_drop=5): # prematch=True for /odds-drop-prematch
54
+ print(drop["home"], drop["sect"], drop["outcome"], drop["from_price"], "->", drop["to_price"], "fair", drop["nvp"])
55
+ ```
56
+
57
+ ## No-vig (fair) odds
58
+
59
+ ```python
60
+ from pinnradar import no_vig, margin
61
+
62
+ margin([1.952, 1.952]) # 0.0246 -> 2.5 % margin
63
+ no_vig([2.10, 3.45, 3.70]) # fair 1X2 prices
64
+ no_vig([1.25, 4.20], method="power") # takes more margin from the longshot
65
+ ```
66
+
67
+ There is also an online [no-vig calculator](https://pinnradar.com/tools/no-vig-calculator).
68
+
69
+ ## Sport ids
70
+
71
+ | id | sport | id | sport |
72
+ |---|---|---|---|
73
+ | 1 | Soccer | 5 | American football |
74
+ | 2 | Tennis | 6 | Baseball |
75
+ | 3 | Basketball | 11 | Esports |
76
+ | 4 | Hockey | | |
77
+
78
+ ## Errors and limits
79
+
80
+ ```python
81
+ from pinnradar import AuthError, RateLimitError
82
+
83
+ try:
84
+ pr.markets(1)
85
+ except RateLimitError as e:
86
+ print("slow down for", e.retry_after, "seconds")
87
+ except AuthError:
88
+ print("check your key or plan")
89
+ ```
90
+
91
+ Rate limits depend on the plan; see [pricing](https://pinnradar.com/pinnacle-api-pricing). Any other endpoint can be called with `pr.get("/path", **params)`.
92
+
93
+ ## Links
94
+
95
+ - API docs: https://pinnradar.com/docs
96
+ - WebSocket feed and MCP server: https://pinnradar.com/docs
97
+ - Node.js client: https://www.npmjs.com/package/pinnradar
98
+
99
+ PinnRadar is an independent data service, not affiliated with Pinnacle. MIT licensed.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pinnradar"
7
+ dynamic = ["version"]
8
+ description = "Python client for the PinnRadar API: real-time Pinnacle odds, live and prematch markets, dropping-odds alerts over SSE."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.8"
13
+ authors = [{ name = "PinnRadar", email = "info@pinnradar.com" }]
14
+ keywords = ["pinnacle", "odds", "odds api", "pinnacle api", "sports betting", "live odds", "dropping odds", "sports data", "no vig"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Libraries",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.urls]
26
+ Homepage = "https://pinnradar.com"
27
+ Documentation = "https://pinnradar.com/docs"
28
+ Source = "https://github.com/PinnRadar/pinnradar-python"
29
+ Pricing = "https://pinnradar.com/pinnacle-api-pricing"
30
+
31
+ [tool.hatch.version]
32
+ path = "src/pinnradar/__init__.py"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/pinnradar"]
36
+
37
+ [tool.pytest.ini_options]
38
+ pythonpath = ["src"]
@@ -0,0 +1,18 @@
1
+ """PinnRadar: real-time Pinnacle odds API client. https://pinnradar.com/docs"""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .client import AuthError, Client, PinnRadarError, RateLimitError, parse_sse # noqa: E402
6
+ from .odds import implied, margin, no_vig # noqa: E402
7
+
8
+ __all__ = [
9
+ "Client",
10
+ "PinnRadarError",
11
+ "AuthError",
12
+ "RateLimitError",
13
+ "parse_sse",
14
+ "implied",
15
+ "margin",
16
+ "no_vig",
17
+ "__version__",
18
+ ]
@@ -0,0 +1,265 @@
1
+ """HTTP client for the PinnRadar odds API. Standard library only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from typing import Any, Dict, Iterable, Iterator, List, Optional, Union
12
+
13
+ from . import __version__
14
+
15
+ DEFAULT_BASE_URL = "https://pinnradar.com"
16
+ Params = Dict[str, Any]
17
+
18
+
19
+ class PinnRadarError(Exception):
20
+ """Any non-success answer from the API."""
21
+
22
+ def __init__(self, message: str, status: Optional[int] = None, body: Any = None):
23
+ super().__init__(message)
24
+ self.status = status
25
+ self.body = body
26
+
27
+
28
+ class AuthError(PinnRadarError):
29
+ """401/403: the key is missing, wrong, expired, or the plan lacks this feature."""
30
+
31
+
32
+ class RateLimitError(PinnRadarError):
33
+ """429: slow down. `retry_after` is the number of seconds the server asked for."""
34
+
35
+ def __init__(self, message: str, retry_after: Optional[float], status: int = 429, body: Any = None):
36
+ super().__init__(message, status, body)
37
+ self.retry_after = retry_after
38
+
39
+
40
+ def _clean(params: Params) -> Params:
41
+ out: Params = {}
42
+ for k, v in params.items():
43
+ if v is None:
44
+ continue
45
+ if isinstance(v, bool):
46
+ v = int(v)
47
+ elif isinstance(v, (list, tuple, set)):
48
+ v = ",".join(str(x) for x in v)
49
+ out[k] = v
50
+ return out
51
+
52
+
53
+ def _csv(v: Union[None, str, Iterable[Any]]) -> Optional[str]:
54
+ if v is None or isinstance(v, str):
55
+ return v
56
+ return ",".join(str(x) for x in v)
57
+
58
+
59
+ def parse_sse(lines: Iterable[str]) -> Iterator[str]:
60
+ """Yield the `data` payload of each Server-Sent Event in an iterable of text lines."""
61
+ data: List[str] = []
62
+ for raw in lines:
63
+ line = raw.rstrip("\r\n")
64
+ if line == "":
65
+ if data:
66
+ yield "\n".join(data)
67
+ data = []
68
+ continue
69
+ if line.startswith(":"):
70
+ continue # comment / keep-alive
71
+ field, _, value = line.partition(":")
72
+ if value.startswith(" "):
73
+ value = value[1:]
74
+ if field == "data":
75
+ data.append(value)
76
+ if data:
77
+ yield "\n".join(data)
78
+
79
+
80
+ class Client:
81
+ """PinnRadar API client.
82
+
83
+ >>> from pinnradar import Client
84
+ >>> pr = Client("YOUR_KEY") # or set PINNRADAR_API_KEY
85
+ >>> board = pr.markets(sport_id=1) # live soccer
86
+ >>> for drop in pr.stream_drops(min_drop=5):
87
+ ... print(drop["home"], drop["sect"], drop["from_price"], "->", drop["to_price"])
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ api_key: Optional[str] = None,
93
+ base_url: str = DEFAULT_BASE_URL,
94
+ timeout: float = 20.0,
95
+ user_agent: Optional[str] = None,
96
+ ):
97
+ self.api_key = api_key or os.environ.get("PINNRADAR_API_KEY") or os.environ.get("PINNRADAR_KEY")
98
+ if not self.api_key:
99
+ raise AuthError("No API key. Pass Client('...') or set PINNRADAR_API_KEY. Free keys: https://pinnradar.com/panel")
100
+ self.base_url = base_url.rstrip("/")
101
+ self.timeout = timeout
102
+ self.user_agent = user_agent or f"pinnradar-python/{__version__}"
103
+
104
+ # ------------------------------------------------------------------ core
105
+
106
+ def _url(self, path: str, params: Optional[Params] = None) -> str:
107
+ q = urllib.parse.urlencode(_clean(params or {}))
108
+ return f"{self.base_url}{path}" + (f"?{q}" if q else "")
109
+
110
+ def _request(self, path: str, params: Optional[Params] = None, auth: bool = True, stream: bool = False):
111
+ headers = {"user-agent": self.user_agent, "accept": "text/event-stream" if stream else "application/json"}
112
+ if auth:
113
+ headers["x-api-key"] = self.api_key # type: ignore[assignment]
114
+ req = urllib.request.Request(self._url(path, params), headers=headers)
115
+ try:
116
+ return urllib.request.urlopen(req, timeout=None if stream else self.timeout)
117
+ except urllib.error.HTTPError as e:
118
+ raise self._error(e) from None
119
+ except urllib.error.URLError as e:
120
+ raise PinnRadarError(f"Connection failed: {e.reason}") from None
121
+
122
+ @staticmethod
123
+ def _error(e: urllib.error.HTTPError) -> PinnRadarError:
124
+ raw = e.read().decode("utf-8", "replace")
125
+ try:
126
+ body: Any = json.loads(raw)
127
+ except ValueError:
128
+ body = raw
129
+ detail = body.get("error") if isinstance(body, dict) else None
130
+ msg = f"HTTP {e.code}" + (f": {detail}" if detail else "")
131
+ if e.code == 429:
132
+ ra = e.headers.get("retry-after")
133
+ try:
134
+ retry = float(ra) if ra is not None else None
135
+ except ValueError:
136
+ retry = None
137
+ return RateLimitError(msg, retry, body=body)
138
+ if e.code in (401, 403):
139
+ return AuthError(msg, e.code, body)
140
+ return PinnRadarError(msg, e.code, body)
141
+
142
+ def get(self, path: str, **params: Any) -> Any:
143
+ """Call any GET endpoint and return the decoded JSON."""
144
+ with self._request(path, params) as resp:
145
+ return json.loads(resp.read().decode("utf-8"))
146
+
147
+ # ------------------------------------------------------------- endpoints
148
+
149
+ def markets(
150
+ self,
151
+ sport_id: int,
152
+ event_type: str = "live",
153
+ since: Optional[int] = None,
154
+ include_specials: Union[None, bool, str] = None,
155
+ ) -> Dict[str, Any]:
156
+ """Full board for one sport: `event_type` "live" (default) or "prematch".
157
+
158
+ Pass the `last` value of the previous answer as `since` to receive only what changed.
159
+ `include_specials=True` adds props, correct score and outrights ("nested" attaches them
160
+ under their parent event).
161
+ """
162
+ inc = 1 if include_specials is True else (include_specials or None)
163
+ return self.get("/kit/v1/markets", sport_id=sport_id, event_type=event_type, since=since, include_specials=inc)
164
+
165
+ def details(self, event_id: int) -> Dict[str, Any]:
166
+ """Every market of one live event."""
167
+ return self.get("/kit/v1/details", event_id=event_id)
168
+
169
+ def prematch_fixtures(self, sport_id: int, since: Optional[int] = None) -> Dict[str, Any]:
170
+ """Upcoming fixtures of one sport with their main prices."""
171
+ return self.get("/kit/v1/prematch/fixtures", sport_id=sport_id, since=since)
172
+
173
+ def prematch_markets(self, event_id: int) -> Dict[str, Any]:
174
+ """Complete market set of one prematch event."""
175
+ return self.get("/kit/v1/prematch/markets", event_id=event_id)
176
+
177
+ def prematch_lines(self, event_id: int, market_type: Optional[str] = None) -> Dict[str, Any]:
178
+ """Lines of one prematch event; `market_type` is money_line, spreads, totals or team_total."""
179
+ return self.get("/kit/v1/prematch/lines", event_id=event_id, market_type=market_type)
180
+
181
+ def drops(
182
+ self,
183
+ max_age_sec: int = 600,
184
+ mode: str = "live",
185
+ min_drop_pct: Optional[float] = None,
186
+ max_drop_pct: Optional[float] = None,
187
+ sport_id: Optional[int] = None,
188
+ markets: Union[None, str, Iterable[str]] = None,
189
+ periods: Union[None, str, Iterable[int]] = None,
190
+ limit: Optional[int] = None,
191
+ ) -> List[Dict[str, Any]]:
192
+ """Recent price drops from the server-side buffer (about 3 hours deep).
193
+
194
+ Returns the list of drop rows (`event_id`, `home`, `away`, `market`, `side`, `from`, `to`,
195
+ `nvp`, `drop_pct`, ...).
196
+ """
197
+ page = self.get(
198
+ "/api/drops",
199
+ mode=mode,
200
+ max_age_sec=max_age_sec,
201
+ min_drop_pct=min_drop_pct,
202
+ max_drop_pct=max_drop_pct,
203
+ sport_id=sport_id,
204
+ markets=_csv(markets),
205
+ periods=_csv(periods),
206
+ limit=limit,
207
+ )
208
+ return page.get("drops", []) if isinstance(page, dict) else page
209
+
210
+ def ping(self) -> bool:
211
+ """True when the API answers. Needs no key."""
212
+ try:
213
+ with self._request("/ping", auth=False) as resp:
214
+ return resp.status == 200
215
+ except PinnRadarError:
216
+ return False
217
+
218
+ # -------------------------------------------------------------- streams
219
+
220
+ def stream_drops(
221
+ self,
222
+ prematch: bool = False,
223
+ min_drop: Optional[float] = None,
224
+ recheck: Optional[int] = None,
225
+ reconnect: bool = True,
226
+ max_backoff: float = 30.0,
227
+ ) -> Iterator[Dict[str, Any]]:
228
+ """Follow the live (or prematch) drop alerts over Server-Sent Events.
229
+
230
+ Yields one dict per drop as soon as the detector flags it: `home`, `away`, `league`,
231
+ `sport`, `sect` (Moneyline, Spread, Total, TeamTotal), `outcome`, `point`, `period`,
232
+ `from_price`, `to_price`, `nvp` (no-vig price), `limit` and, in play, `live` (score,
233
+ clock, cards, corners). Reconnects with backoff
234
+ when the connection drops (set `reconnect=False` to stop instead). Needs a plan with
235
+ SSE (Drops, Edge or Scale). Only one stream of each kind per key at a time.
236
+ """
237
+ path = "/odds-drop-prematch" if prematch else "/odds-drop"
238
+ backoff = 1.0
239
+ while True:
240
+ try:
241
+ with self._request(path, {"min_drop": min_drop, "recheck": recheck}, stream=True) as resp:
242
+ backoff = 1.0
243
+ lines = (b.decode("utf-8", "replace") for b in resp)
244
+ for payload in parse_sse(lines):
245
+ try:
246
+ frame = json.loads(payload)
247
+ except ValueError:
248
+ continue
249
+ if isinstance(frame, list):
250
+ for drop in frame:
251
+ yield drop
252
+ elif isinstance(frame, dict):
253
+ if frame.get("type") == "error":
254
+ raise PinnRadarError(str(frame.get("error") or "stream error"), body=frame)
255
+ if "to_price" in frame or "to" in frame:
256
+ yield frame
257
+ except (AuthError, RateLimitError):
258
+ raise
259
+ except (PinnRadarError, OSError):
260
+ if not reconnect:
261
+ raise
262
+ if not reconnect:
263
+ return
264
+ time.sleep(backoff)
265
+ backoff = min(max_backoff, backoff * 2)
@@ -0,0 +1,49 @@
1
+ """Odds helpers: implied probability, margin and no-vig (fair) prices for decimal odds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Sequence
6
+
7
+
8
+ def implied(price: float) -> float:
9
+ """Implied probability of a decimal price (1 / price)."""
10
+ if price <= 1:
11
+ raise ValueError("decimal odds must be greater than 1")
12
+ return 1.0 / price
13
+
14
+
15
+ def margin(prices: Sequence[float]) -> float:
16
+ """Bookmaker margin of a complete market: sum of implied probabilities minus 1."""
17
+ return sum(implied(p) for p in prices) - 1.0
18
+
19
+
20
+ def no_vig(prices: Sequence[float], method: str = "multiplicative") -> List[float]:
21
+ """Fair (no-vig) decimal prices for a complete market (every outcome).
22
+
23
+ `method` is "multiplicative" (proportional, the usual default) or "power" (takes more
24
+ margin from longshots).
25
+
26
+ >>> [round(x, 3) for x in no_vig([1.952, 1.952])]
27
+ [2.0, 2.0]
28
+ """
29
+ if len(prices) < 2:
30
+ raise ValueError("need every outcome of the market (at least two prices)")
31
+ q = [implied(p) for p in prices]
32
+ if method == "multiplicative":
33
+ s = sum(q)
34
+ probs = [x / s for x in q]
35
+ elif method == "power":
36
+ lo, hi = 0.05, 50.0
37
+ for _ in range(200):
38
+ k = (lo + hi) / 2
39
+ if sum(x ** k for x in q) > 1:
40
+ lo = k
41
+ else:
42
+ hi = k
43
+ k = (lo + hi) / 2
44
+ probs = [x ** k for x in q]
45
+ t = sum(probs)
46
+ probs = [p / t for p in probs]
47
+ else:
48
+ raise ValueError("method must be 'multiplicative' or 'power'")
49
+ return [1.0 / p for p in probs]
File without changes
@@ -0,0 +1,106 @@
1
+ import io
2
+ import json
3
+ import urllib.error
4
+ import urllib.request
5
+
6
+ import pytest
7
+
8
+ import pinnradar
9
+ from pinnradar import AuthError, Client, RateLimitError, no_vig, parse_sse
10
+
11
+
12
+ class FakeResp(io.BytesIO):
13
+ status = 200
14
+
15
+ def __enter__(self):
16
+ return self
17
+
18
+ def __exit__(self, *a):
19
+ self.close()
20
+
21
+
22
+ def capture(monkeypatch, body=b"{}", error=None):
23
+ seen = {}
24
+
25
+ def fake(req, timeout=None):
26
+ seen["url"] = req.full_url
27
+ seen["headers"] = {k.lower(): v for k, v in req.header_items()}
28
+ if error:
29
+ raise error
30
+ return FakeResp(body)
31
+
32
+ monkeypatch.setattr(urllib.request, "urlopen", fake)
33
+ return seen
34
+
35
+
36
+ def test_requires_key(monkeypatch):
37
+ monkeypatch.delenv("PINNRADAR_API_KEY", raising=False)
38
+ monkeypatch.delenv("PINNRADAR_KEY", raising=False)
39
+ with pytest.raises(AuthError):
40
+ Client()
41
+
42
+
43
+ def test_env_key(monkeypatch):
44
+ monkeypatch.setenv("PINNRADAR_API_KEY", "abc")
45
+ assert Client().api_key == "abc"
46
+
47
+
48
+ def test_markets_url_and_header(monkeypatch):
49
+ seen = capture(monkeypatch, b'{"sport_id":1,"events":[]}')
50
+ out = Client("k").markets(1, include_specials=True)
51
+ assert out["sport_id"] == 1
52
+ assert seen["url"] == "https://pinnradar.com/kit/v1/markets?sport_id=1&event_type=live&include_specials=1"
53
+ assert seen["headers"]["x-api-key"] == "k"
54
+ assert seen["headers"]["user-agent"].startswith("pinnradar-python/")
55
+
56
+
57
+ def test_drops_csv_and_unwrap(monkeypatch):
58
+ seen = capture(monkeypatch, json.dumps({"drops": [{"from": 2.0, "to": 1.8}]}).encode())
59
+ rows = Client("k").drops(mode="prematch", markets=["total", "spread"], periods=[0, 1], min_drop_pct=3)
60
+ assert rows == [{"from": 2.0, "to": 1.8}]
61
+ assert "markets=total%2Cspread" in seen["url"] and "periods=0%2C1" in seen["url"] and "min_drop_pct=3" in seen["url"]
62
+
63
+
64
+ def http_error(code, body=b"{}", headers=None):
65
+ import email.message
66
+
67
+ msg = email.message.Message()
68
+ for k, v in (headers or {}).items():
69
+ msg[k] = v
70
+ return urllib.error.HTTPError("https://pinnradar.com/x", code, "err", msg, io.BytesIO(body))
71
+
72
+
73
+ def test_rate_limit(monkeypatch):
74
+ capture(monkeypatch, error=http_error(429, b'{"error":"rate_limited"}', {"Retry-After": "2"}))
75
+ with pytest.raises(RateLimitError) as e:
76
+ Client("k").markets(1)
77
+ assert e.value.retry_after == 2.0
78
+
79
+
80
+ def test_auth_error(monkeypatch):
81
+ capture(monkeypatch, error=http_error(401, b'{"error":"invalid_key"}'))
82
+ with pytest.raises(AuthError) as e:
83
+ Client("k").details(5)
84
+ assert "invalid_key" in str(e.value)
85
+
86
+
87
+ def test_parse_sse():
88
+ lines = [": keepalive\n", "data: [1,2]\n", "\n", "event: x\n", "data: {\"a\":\n", "data: 1}\n", "\n"]
89
+ assert list(parse_sse(lines)) == ["[1,2]", '{"a":\n1}']
90
+
91
+
92
+ def test_stream_drops(monkeypatch):
93
+ body = b'data: [{"from":2.0,"to":1.8},{"from":3.0,"to":2.5}]\n\n: ping\n\ndata: {"from":1.9,"to":1.7}\n\n'
94
+ capture(monkeypatch, body)
95
+ got = list(Client("k").stream_drops(min_drop=3, reconnect=False))
96
+ assert [d["to"] for d in got] == [1.8, 2.5, 1.7]
97
+
98
+
99
+ def test_no_vig():
100
+ assert [round(x, 3) for x in no_vig([1.952, 1.952])] == [2.0, 2.0]
101
+ fair = no_vig([2.1, 3.45, 3.7], method="power")
102
+ assert abs(sum(1 / x for x in fair) - 1) < 1e-9
103
+
104
+
105
+ def test_version():
106
+ assert pinnradar.__version__