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.
- pinnradar-0.1.0/.github/workflows/ci.yml +17 -0
- pinnradar-0.1.0/.github/workflows/publish.yml +22 -0
- pinnradar-0.1.0/.gitignore +7 -0
- pinnradar-0.1.0/LICENSE +21 -0
- pinnradar-0.1.0/PKG-INFO +120 -0
- pinnradar-0.1.0/README.md +99 -0
- pinnradar-0.1.0/pyproject.toml +38 -0
- pinnradar-0.1.0/src/pinnradar/__init__.py +18 -0
- pinnradar-0.1.0/src/pinnradar/client.py +265 -0
- pinnradar-0.1.0/src/pinnradar/odds.py +49 -0
- pinnradar-0.1.0/src/pinnradar/py.typed +0 -0
- pinnradar-0.1.0/tests/test_client.py +106 -0
|
@@ -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
|
pinnradar-0.1.0/LICENSE
ADDED
|
@@ -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.
|
pinnradar-0.1.0/PKG-INFO
ADDED
|
@@ -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__
|