novigapi 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.
novigapi-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stunning Couches LLC
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,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: novigapi
3
+ Version: 0.1.0
4
+ Summary: Python client for novigapi.com, a read-only Novig market data API, plus devig helpers. Independent; not affiliated with Novig.
5
+ License: MIT
6
+ Project-URL: Repository, https://github.com/novigapi/novigapi-python
7
+ Project-URL: Issues, https://github.com/novigapi/novigapi-python/issues
8
+ Project-URL: Homepage, https://novigapi.com
9
+ Project-URL: Documentation, https://novigapi.com/docs
10
+ Project-URL: Python guide, https://novigapi.com/novig-api-python
11
+ Keywords: novig,odds,devig,no-vig,prediction-market,sports-data,closing-line-value
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Topic :: Office/Business :: Financial
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest; extra == "test"
22
+ Dynamic: license-file
23
+
24
+ # novigapi (Python)
25
+
26
+ A small Python client for [novigapi.com](https://novigapi.com), a read-only Novig market data API, plus pure-Python devig helpers and three runnable examples.
27
+
28
+ Novig has an official trading API and free daily CSVs. novigapi.com is the read-only data layer on top: no account, names resolved, intraday line history, fair prices and alerts. This package talks to novigapi.com. To place orders or read a Novig account, use the official Novig API documented at [docs.novig.com](https://docs.novig.com).
29
+
30
+ Independent. Not affiliated with, endorsed by or sponsored by Novig.
31
+
32
+ - Standard library only (`urllib`, `json`). Python 3.8 or later. No dependencies.
33
+ - MIT licensed.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install novigapi
39
+ ```
40
+
41
+ Not on PyPI yet: the name `novigapi` was free on 15 Nov 2026 and the package is ready to publish. Until then, from a clone: `pip install -e .`
42
+
43
+ Tests need no key, no network and no pytest: `python3 tests/run.py` (pytest also works).
44
+
45
+ ## Quickstart
46
+
47
+ Get a free key at [novigapi.com/sign-up](https://novigapi.com/sign-up) (Free plan: 100 credits a month, 20 requests a minute), then:
48
+
49
+ ```bash
50
+ export NOVIGAPI_KEY=nv_...
51
+ ```
52
+
53
+ ```python
54
+ from novigapi import NovigAPI, devig, prob_to_american
55
+
56
+ api = NovigAPI() # reads NOVIGAPI_KEY
57
+
58
+ page = api.markets(league="NFL", status="open", limit=20)
59
+ for market in page["data"]:
60
+ print(market["market_id"], market.get("description"))
61
+
62
+ fair = api.fair_price(page["data"][0]["market_id"])
63
+ print(fair["data"], fair["meta"].get("formula"))
64
+
65
+ # Devig any two prices (probabilities from 0 to 1)
66
+ print(devig([0.5238, 0.5238], "shin")) # [0.5, 0.5]
67
+ print(prob_to_american(0.40)) # 150.0
68
+ ```
69
+
70
+ Every method returns the JSON envelope `{"data": ..., "meta": {...}}`. List endpoints page with `cursor`; `api.iter_rows("markets", league="NBA")` follows `meta.next_cursor` for you.
71
+
72
+ ### Methods
73
+
74
+ | Method | Endpoint |
75
+ |---|---|
76
+ | `markets(league, event_id, market_type, player, team, status, limit, cursor)` | `GET /markets` |
77
+ | `events(league, status, limit, cursor)` | `GET /events` |
78
+ | `market(market_id)` | `GET /markets/{id}` |
79
+ | `orderbook(market_id)` | `GET /orderbook/{id}` |
80
+ | `trades(market_id, since, limit, cursor)` | `GET /trades` |
81
+ | `ohlc_daily(market_id, date)` | `GET /ohlc/daily` |
82
+ | `fair_price(market_id)` | `GET /fair-price` |
83
+ | `line_history(market_id or outcome_id, since, until)` | `GET /line-history` |
84
+ | `player_props(league, player)` | `GET /player-props` |
85
+ | `players(league, q)`, `teams(league)`, `leagues()` | `GET /players`, `/teams`, `/leagues` |
86
+ | `results(league, market_id, graded, since)` | `GET /results` |
87
+ | `market_summary(date, since, league, market_type, kind)` | `GET /market-summary/daily` |
88
+ | `ev(league, min_edge, min_prob, books, sort, ...)` | `GET /ev` (FanDuel and DraftKings vs the Novig fair price) |
89
+ | `arbitrage(type, league, min_profit)` | `GET /arbitrage` (Production and up) |
90
+ | `compare(market_id)` | `GET /compare/{market_id}` |
91
+ | `bulk_csv(dataset, ...)` | `GET /bulk/csv` (Production and up), returns CSV text |
92
+ | `clv(market_id, player, league, date, settled, since, until)` | `GET /clv` |
93
+ | `line_moves(league, market_id, kind, since, min_delta_pp)` | `GET /line-moves` |
94
+
95
+ Base URL `https://novigapi.com/api/v1`. Auth header `Authorization: Bearer nv_...`. Full reference: [novigapi.com/docs](https://novigapi.com/docs).
96
+
97
+ ### Errors and limits
98
+
99
+ Any non-2xx response raises `NovigAPIError` with `status`, `code` and `message`. A 429 raises `RateLimitError`, which also carries the fields from the 429 body:
100
+
101
+ ```python
102
+ from novigapi import NovigAPI, RateLimitError
103
+ import time
104
+
105
+ api = NovigAPI()
106
+ try:
107
+ api.markets(league="NBA")
108
+ except RateLimitError as err:
109
+ print(err.code, err.plan, err.limit, err.reset_at, err.upgrade_url)
110
+ if err.code == "RATE_LIMITED" and err.retry_after:
111
+ time.sleep(err.retry_after)
112
+ ```
113
+
114
+ `RATE_LIMITED` is the per-minute limit and sends `Retry-After`. `QUOTA_EXCEEDED` means the month's credits are used; there are no automatic overage charges.
115
+
116
+ ## Devig helpers
117
+
118
+ `novigapi.devig` has four methods as pure functions with docstrings: `multiplicative`, `additive`, `power` and `shin`, plus `american_to_prob`, `prob_to_american`, `decimal_to_prob`, `prob_to_decimal`, `overround` and `hold`. `novigapi.taker_fee(price, contracts, coefficient=0.06)` returns Novig's taker fee, `coefficient x p x (1 - p) x contracts`, per [docs.novig.com](https://docs.novig.com/fees.md) (checked 12 Nov 2026).
119
+
120
+ Worked example, -300 / +240 (implied 75.00% and 29.41%, 4.41% overround):
121
+
122
+ | Method | Favourite | Longshot |
123
+ |---|---|---|
124
+ | Multiplicative | 71.83% (-255) | 28.17% (+255) |
125
+ | Additive | 72.79% (-268) | 27.21% (+268) |
126
+ | Power | 73.31% (-275) | 26.69% (+275) |
127
+ | Shin | 72.79% (-268) | 27.21% (+268) |
128
+
129
+ With two outcomes, Shin and additive agree. The [no-vig calculator](https://novigapi.com/no-vig-calculator) runs the same four methods in the browser.
130
+
131
+ ## Examples
132
+
133
+ ```bash
134
+ python examples/devig_board.py --league NFL --method shin # devig every two-sided market in a league
135
+ python examples/arb_scanner.py --league NBA # combined price below $1 after the taker fee
136
+ python examples/clv_backtest.py --start 2026-08-04 --end 2026-11-11 # no key needed
137
+ ```
138
+
139
+ - `devig_board.py` lists open two-way markets for a league and prints the fair probability and fair American odds of each side.
140
+ - `arb_scanner.py` adds the taker fee to each leg, `0.06 x p x (1 - p)` per $1 of payout (pregame game-market fills are fee-free; the default charges every leg, the conservative case), and reports any pair that costs less than $1. Pass `--other prices.csv` to pair a Novig side with a price you collected from another venue.
141
+ - `clv_backtest.py` streams Novig's own daily `markets.csv` files from `https://data.novig.com/reporting/trade-data/<date>/markets.csv` and compares an entry price (the opening print, or yours via `--bets`) with the `close` column. It needs no API key and saves nothing to disk. Pass `--results` with `market_id,won` to score open against close with the Brier score, or `--martingale` to test, without any results, whether one day's move predicts the next day's.
142
+
143
+ Tests:
144
+
145
+ ```bash
146
+ pip install -e ".[test]"
147
+ pytest tests -q
148
+ ```
149
+
150
+ ## Data notes
151
+
152
+ - Prices are probabilities. Novig shows them as percentages: 54% is 0.54.
153
+ - Novig's book holds bids only. The ask on an outcome is 1 minus the best bid on the other outcome.
154
+ - REST data on paid plans is at most about 10 seconds old; the stream pushes changes within 5 to 30 seconds.
155
+ - Novig's daily CSVs start in early August 2026 and carry no player or team names; novigapi.com joins the names.
156
+
157
+ ## License
158
+
159
+ MIT. Copyright (c) 2026 Stunning Couches LLC.
160
+
161
+ Independent. Not affiliated with, endorsed by or sponsored by Novig.
@@ -0,0 +1,138 @@
1
+ # novigapi (Python)
2
+
3
+ A small Python client for [novigapi.com](https://novigapi.com), a read-only Novig market data API, plus pure-Python devig helpers and three runnable examples.
4
+
5
+ Novig has an official trading API and free daily CSVs. novigapi.com is the read-only data layer on top: no account, names resolved, intraday line history, fair prices and alerts. This package talks to novigapi.com. To place orders or read a Novig account, use the official Novig API documented at [docs.novig.com](https://docs.novig.com).
6
+
7
+ Independent. Not affiliated with, endorsed by or sponsored by Novig.
8
+
9
+ - Standard library only (`urllib`, `json`). Python 3.8 or later. No dependencies.
10
+ - MIT licensed.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install novigapi
16
+ ```
17
+
18
+ Not on PyPI yet: the name `novigapi` was free on 15 Nov 2026 and the package is ready to publish. Until then, from a clone: `pip install -e .`
19
+
20
+ Tests need no key, no network and no pytest: `python3 tests/run.py` (pytest also works).
21
+
22
+ ## Quickstart
23
+
24
+ Get a free key at [novigapi.com/sign-up](https://novigapi.com/sign-up) (Free plan: 100 credits a month, 20 requests a minute), then:
25
+
26
+ ```bash
27
+ export NOVIGAPI_KEY=nv_...
28
+ ```
29
+
30
+ ```python
31
+ from novigapi import NovigAPI, devig, prob_to_american
32
+
33
+ api = NovigAPI() # reads NOVIGAPI_KEY
34
+
35
+ page = api.markets(league="NFL", status="open", limit=20)
36
+ for market in page["data"]:
37
+ print(market["market_id"], market.get("description"))
38
+
39
+ fair = api.fair_price(page["data"][0]["market_id"])
40
+ print(fair["data"], fair["meta"].get("formula"))
41
+
42
+ # Devig any two prices (probabilities from 0 to 1)
43
+ print(devig([0.5238, 0.5238], "shin")) # [0.5, 0.5]
44
+ print(prob_to_american(0.40)) # 150.0
45
+ ```
46
+
47
+ Every method returns the JSON envelope `{"data": ..., "meta": {...}}`. List endpoints page with `cursor`; `api.iter_rows("markets", league="NBA")` follows `meta.next_cursor` for you.
48
+
49
+ ### Methods
50
+
51
+ | Method | Endpoint |
52
+ |---|---|
53
+ | `markets(league, event_id, market_type, player, team, status, limit, cursor)` | `GET /markets` |
54
+ | `events(league, status, limit, cursor)` | `GET /events` |
55
+ | `market(market_id)` | `GET /markets/{id}` |
56
+ | `orderbook(market_id)` | `GET /orderbook/{id}` |
57
+ | `trades(market_id, since, limit, cursor)` | `GET /trades` |
58
+ | `ohlc_daily(market_id, date)` | `GET /ohlc/daily` |
59
+ | `fair_price(market_id)` | `GET /fair-price` |
60
+ | `line_history(market_id or outcome_id, since, until)` | `GET /line-history` |
61
+ | `player_props(league, player)` | `GET /player-props` |
62
+ | `players(league, q)`, `teams(league)`, `leagues()` | `GET /players`, `/teams`, `/leagues` |
63
+ | `results(league, market_id, graded, since)` | `GET /results` |
64
+ | `market_summary(date, since, league, market_type, kind)` | `GET /market-summary/daily` |
65
+ | `ev(league, min_edge, min_prob, books, sort, ...)` | `GET /ev` (FanDuel and DraftKings vs the Novig fair price) |
66
+ | `arbitrage(type, league, min_profit)` | `GET /arbitrage` (Production and up) |
67
+ | `compare(market_id)` | `GET /compare/{market_id}` |
68
+ | `bulk_csv(dataset, ...)` | `GET /bulk/csv` (Production and up), returns CSV text |
69
+ | `clv(market_id, player, league, date, settled, since, until)` | `GET /clv` |
70
+ | `line_moves(league, market_id, kind, since, min_delta_pp)` | `GET /line-moves` |
71
+
72
+ Base URL `https://novigapi.com/api/v1`. Auth header `Authorization: Bearer nv_...`. Full reference: [novigapi.com/docs](https://novigapi.com/docs).
73
+
74
+ ### Errors and limits
75
+
76
+ Any non-2xx response raises `NovigAPIError` with `status`, `code` and `message`. A 429 raises `RateLimitError`, which also carries the fields from the 429 body:
77
+
78
+ ```python
79
+ from novigapi import NovigAPI, RateLimitError
80
+ import time
81
+
82
+ api = NovigAPI()
83
+ try:
84
+ api.markets(league="NBA")
85
+ except RateLimitError as err:
86
+ print(err.code, err.plan, err.limit, err.reset_at, err.upgrade_url)
87
+ if err.code == "RATE_LIMITED" and err.retry_after:
88
+ time.sleep(err.retry_after)
89
+ ```
90
+
91
+ `RATE_LIMITED` is the per-minute limit and sends `Retry-After`. `QUOTA_EXCEEDED` means the month's credits are used; there are no automatic overage charges.
92
+
93
+ ## Devig helpers
94
+
95
+ `novigapi.devig` has four methods as pure functions with docstrings: `multiplicative`, `additive`, `power` and `shin`, plus `american_to_prob`, `prob_to_american`, `decimal_to_prob`, `prob_to_decimal`, `overround` and `hold`. `novigapi.taker_fee(price, contracts, coefficient=0.06)` returns Novig's taker fee, `coefficient x p x (1 - p) x contracts`, per [docs.novig.com](https://docs.novig.com/fees.md) (checked 12 Nov 2026).
96
+
97
+ Worked example, -300 / +240 (implied 75.00% and 29.41%, 4.41% overround):
98
+
99
+ | Method | Favourite | Longshot |
100
+ |---|---|---|
101
+ | Multiplicative | 71.83% (-255) | 28.17% (+255) |
102
+ | Additive | 72.79% (-268) | 27.21% (+268) |
103
+ | Power | 73.31% (-275) | 26.69% (+275) |
104
+ | Shin | 72.79% (-268) | 27.21% (+268) |
105
+
106
+ With two outcomes, Shin and additive agree. The [no-vig calculator](https://novigapi.com/no-vig-calculator) runs the same four methods in the browser.
107
+
108
+ ## Examples
109
+
110
+ ```bash
111
+ python examples/devig_board.py --league NFL --method shin # devig every two-sided market in a league
112
+ python examples/arb_scanner.py --league NBA # combined price below $1 after the taker fee
113
+ python examples/clv_backtest.py --start 2026-08-04 --end 2026-11-11 # no key needed
114
+ ```
115
+
116
+ - `devig_board.py` lists open two-way markets for a league and prints the fair probability and fair American odds of each side.
117
+ - `arb_scanner.py` adds the taker fee to each leg, `0.06 x p x (1 - p)` per $1 of payout (pregame game-market fills are fee-free; the default charges every leg, the conservative case), and reports any pair that costs less than $1. Pass `--other prices.csv` to pair a Novig side with a price you collected from another venue.
118
+ - `clv_backtest.py` streams Novig's own daily `markets.csv` files from `https://data.novig.com/reporting/trade-data/<date>/markets.csv` and compares an entry price (the opening print, or yours via `--bets`) with the `close` column. It needs no API key and saves nothing to disk. Pass `--results` with `market_id,won` to score open against close with the Brier score, or `--martingale` to test, without any results, whether one day's move predicts the next day's.
119
+
120
+ Tests:
121
+
122
+ ```bash
123
+ pip install -e ".[test]"
124
+ pytest tests -q
125
+ ```
126
+
127
+ ## Data notes
128
+
129
+ - Prices are probabilities. Novig shows them as percentages: 54% is 0.54.
130
+ - Novig's book holds bids only. The ask on an outcome is 1 minus the best bid on the other outcome.
131
+ - REST data on paid plans is at most about 10 seconds old; the stream pushes changes within 5 to 30 seconds.
132
+ - Novig's daily CSVs start in early August 2026 and carry no player or team names; novigapi.com joins the names.
133
+
134
+ ## License
135
+
136
+ MIT. Copyright (c) 2026 Stunning Couches LLC.
137
+
138
+ Independent. Not affiliated with, endorsed by or sponsored by Novig.
@@ -0,0 +1,35 @@
1
+ # Build metadata only. Nothing has been published: no upload to PyPI has been run,
2
+ # and none should be until the founder creates the PyPI account and approves it.
3
+ [build-system]
4
+ requires = ["setuptools>=61"]
5
+ build-backend = "setuptools.build_meta"
6
+
7
+ [project]
8
+ name = "novigapi"
9
+ version = "0.1.0"
10
+ description = "Python client for novigapi.com, a read-only Novig market data API, plus devig helpers. Independent; not affiliated with Novig."
11
+ readme = "README.md"
12
+ license = { text = "MIT" }
13
+ requires-python = ">=3.8"
14
+ dependencies = []
15
+ keywords = ["novig", "odds", "devig", "no-vig", "prediction-market", "sports-data", "closing-line-value"]
16
+ classifiers = [
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Operating System :: OS Independent",
20
+ "Intended Audience :: Developers",
21
+ "Topic :: Office/Business :: Financial",
22
+ ]
23
+
24
+ [project.urls]
25
+ Repository = "https://github.com/novigapi/novigapi-python"
26
+ Issues = "https://github.com/novigapi/novigapi-python/issues"
27
+ Homepage = "https://novigapi.com"
28
+ Documentation = "https://novigapi.com/docs"
29
+ "Python guide" = "https://novigapi.com/novig-api-python"
30
+
31
+ [project.optional-dependencies]
32
+ test = ["pytest"]
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,61 @@
1
+ """novigapi: Python client for novigapi.com, a read-only Novig market data API.
2
+
3
+ Independent. Not affiliated with, endorsed by or sponsored by Novig.
4
+ """
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ from .client import DEFAULT_BASE_URL, NovigAPI, NovigAPIError, RateLimitError # noqa: E402
9
+ from .devig import ( # noqa: E402
10
+ METHODS,
11
+ additive,
12
+ american_to_prob,
13
+ decimal_to_prob,
14
+ devig,
15
+ hold,
16
+ multiplicative,
17
+ overround,
18
+ power,
19
+ prob_to_american,
20
+ prob_to_decimal,
21
+ shin,
22
+ )
23
+
24
+
25
+ def taker_fee(price: float, contracts: float = 1.0, coefficient: float = 0.06) -> float:
26
+ """Novig taker fee in dollars: coefficient x p x (1 - p) per $1 of payout.
27
+
28
+ ``contracts`` counts dollars of payout. Novig's v3 API quotes contracts that
29
+ pay 1 cent each, so divide a raw v3 quantity by 100 before passing it here.
30
+ Per docs.novig.com (checked 12 Nov 2026) the coefficient is 0.06 on straight
31
+ contracts. Game markets charge it only while the event is live (pregame
32
+ fills are fee-free); futures markets charge it in every phase. The maker
33
+ pays nothing and earns a credit of 0.25 of the taker fee on game markets
34
+ (0.5 on futures) since 22 Oct 2026. Read the market's fee object when you
35
+ have it.
36
+ """
37
+ if not 0.0 < price < 1.0:
38
+ raise ValueError("price must be between 0 and 1")
39
+ return coefficient * price * (1.0 - price) * contracts
40
+
41
+
42
+ __all__ = [
43
+ "__version__",
44
+ "NovigAPI",
45
+ "NovigAPIError",
46
+ "RateLimitError",
47
+ "DEFAULT_BASE_URL",
48
+ "taker_fee",
49
+ "METHODS",
50
+ "devig",
51
+ "multiplicative",
52
+ "additive",
53
+ "power",
54
+ "shin",
55
+ "american_to_prob",
56
+ "decimal_to_prob",
57
+ "prob_to_american",
58
+ "prob_to_decimal",
59
+ "overround",
60
+ "hold",
61
+ ]
@@ -0,0 +1,287 @@
1
+ """HTTP client for the novigapi.com REST API (read-only Novig market data).
2
+
3
+ Standard library only: urllib for HTTP, json for parsing.
4
+
5
+ Every method returns the parsed JSON envelope, a dict shaped like
6
+
7
+ {"data": [...] or {...}, "meta": {"next_cursor": ..., "limit": ..., "count": ...}}
8
+
9
+ List endpoints page with ``cursor``: pass ``meta["next_cursor"]`` from the
10
+ previous page, or use ``iter_rows`` to walk every page.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+ from typing import Any, Dict, Iterator, Optional
21
+
22
+ from . import __version__
23
+
24
+ DEFAULT_BASE_URL = "https://novigapi.com/api/v1"
25
+ Json = Dict[str, Any]
26
+
27
+ __all__ = ["NovigAPI", "NovigAPIError", "RateLimitError", "DEFAULT_BASE_URL"]
28
+
29
+
30
+ class NovigAPIError(Exception):
31
+ """Any non-2xx response. Carries the API's error code, message and HTTP status."""
32
+
33
+ def __init__(self, status: int, code: str, message: str, body: Optional[Json] = None, headers: Optional[Dict[str, str]] = None) -> None:
34
+ super().__init__("{} {}: {}".format(status, code, message))
35
+ self.status = status
36
+ self.code = code
37
+ self.message = message
38
+ self.body = body or {}
39
+ self.headers = headers or {}
40
+
41
+
42
+ class RateLimitError(NovigAPIError):
43
+ """HTTP 429: the per-minute rate limit (RATE_LIMITED) or the monthly credits (QUOTA_EXCEEDED).
44
+
45
+ Fields from the 429 body are copied onto the exception:
46
+ plan, limit, reset_at (ISO 8601), upgrade_url, and retry_after (seconds, from the
47
+ Retry-After header, set on RATE_LIMITED only).
48
+ """
49
+
50
+ def __init__(self, status: int, code: str, message: str, body: Optional[Json] = None, headers: Optional[Dict[str, str]] = None) -> None:
51
+ super().__init__(status, code, message, body, headers)
52
+ err = (body or {}).get("error", {}) if isinstance(body, dict) else {}
53
+ self.plan: Optional[str] = err.get("plan")
54
+ self.limit: Optional[int] = err.get("limit")
55
+ self.reset_at: Optional[str] = err.get("reset_at")
56
+ self.upgrade_url: Optional[str] = err.get("upgrade_url")
57
+ retry = (headers or {}).get("retry-after")
58
+ self.retry_after: Optional[int] = int(retry) if retry and retry.isdigit() else None
59
+
60
+ def __str__(self) -> str:
61
+ parts = [super().__str__()]
62
+ if self.plan:
63
+ parts.append("plan={}".format(self.plan))
64
+ if self.limit is not None:
65
+ parts.append("limit={}".format(self.limit))
66
+ if self.reset_at:
67
+ parts.append("reset_at={}".format(self.reset_at))
68
+ if self.retry_after is not None:
69
+ parts.append("retry_after={}s".format(self.retry_after))
70
+ if self.upgrade_url:
71
+ parts.append("upgrade_url={}".format(self.upgrade_url))
72
+ return " ".join(parts)
73
+
74
+
75
+ class NovigAPI:
76
+ """Client for https://novigapi.com/api/v1.
77
+
78
+ >>> api = NovigAPI() # reads NOVIGAPI_KEY from the environment
79
+ >>> page = api.markets(league="NFL", status="open", limit=50)
80
+ >>> for market in page["data"]:
81
+ ... print(market["market_id"], market.get("description"))
82
+ """
83
+
84
+ def __init__(self, api_key: Optional[str] = None, base_url: str = DEFAULT_BASE_URL, timeout: float = 30.0) -> None:
85
+ key = api_key or os.environ.get("NOVIGAPI_KEY")
86
+ if not key:
87
+ raise ValueError("No API key. Set NOVIGAPI_KEY or pass api_key='nv_...'. Free keys: https://novigapi.com/sign-up")
88
+ if not key.startswith("nv_"):
89
+ raise ValueError("novigapi.com keys start with nv_")
90
+ self.api_key = key
91
+ self.base_url = base_url.rstrip("/")
92
+ self.timeout = timeout
93
+
94
+ # ----- transport -------------------------------------------------------
95
+
96
+ def get(self, path: str, **params: Any) -> Json:
97
+ """GET a path under the base URL. None-valued params are dropped."""
98
+ query = {k: _param(v) for k, v in params.items() if v is not None}
99
+ url = self.base_url + "/" + path.lstrip("/")
100
+ if query:
101
+ url += "?" + urllib.parse.urlencode(query)
102
+ req = urllib.request.Request(url, headers={
103
+ "Authorization": "Bearer " + self.api_key,
104
+ "Accept": "application/json",
105
+ "User-Agent": "novigapi-python/" + __version__,
106
+ })
107
+ try:
108
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
109
+ raw = resp.read()
110
+ return json.loads(raw.decode("utf-8")) if raw else {}
111
+ except urllib.error.HTTPError as exc:
112
+ raise _error_from(exc) from None
113
+
114
+ def _raw(self, path: str) -> str:
115
+ req = urllib.request.Request(self.base_url + "/" + path, headers={
116
+ "Authorization": "Bearer " + self.api_key, "Accept": "text/csv", "User-Agent": "novigapi-python/" + __version__,
117
+ })
118
+ try:
119
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
120
+ return resp.read().decode("utf-8")
121
+ except urllib.error.HTTPError as exc:
122
+ raise _error_from(exc) from None
123
+
124
+ def iter_rows(self, path: str, max_pages: Optional[int] = None, **params: Any) -> Iterator[Json]:
125
+ """Yield every row of a list endpoint, following meta.next_cursor."""
126
+ cursor = params.pop("cursor", None)
127
+ pages = 0
128
+ while True:
129
+ page = self.get(path, cursor=cursor, **params)
130
+ data = page.get("data") or []
131
+ for row in data if isinstance(data, list) else [data]:
132
+ yield row
133
+ pages += 1
134
+ cursor = (page.get("meta") or {}).get("next_cursor")
135
+ if not cursor or (max_pages is not None and pages >= max_pages):
136
+ return
137
+
138
+ # ----- markets and events ---------------------------------------------
139
+
140
+ def markets(self, league: Optional[str] = None, event_id: Optional[str] = None, market_type: Optional[str] = None,
141
+ player: Optional[str] = None, team: Optional[str] = None, status: Optional[str] = None,
142
+ limit: Optional[int] = None, cursor: Optional[str] = None) -> Json:
143
+ """GET /markets. status: open, closed, settled, suspended or all."""
144
+ return self.get("markets", league=league, event_id=event_id, market_type=market_type, player=player,
145
+ team=team, status=status, limit=limit, cursor=cursor)
146
+
147
+ def events(self, league: Optional[str] = None, status: Optional[str] = None,
148
+ limit: Optional[int] = None, cursor: Optional[str] = None) -> Json:
149
+ """GET /events."""
150
+ return self.get("events", league=league, status=status, limit=limit, cursor=cursor)
151
+
152
+ def market(self, market_id: str) -> Json:
153
+ """GET /markets/{id}: one market with its outcomes."""
154
+ return self.get("markets/" + _path_id(market_id))
155
+
156
+ def orderbook(self, market_id: str) -> Json:
157
+ """GET /orderbook/{id}: the current book. Novig holds bids only; the ask on an
158
+ outcome is 1 minus the best bid on the other outcome."""
159
+ return self.get("orderbook/" + _path_id(market_id))
160
+
161
+ # ----- prices and history ---------------------------------------------
162
+
163
+ def trades(self, market_id: Optional[str] = None, since: Optional[str] = None,
164
+ limit: Optional[int] = None, cursor: Optional[str] = None) -> Json:
165
+ """GET /trades. since: ISO 8601 date or time."""
166
+ return self.get("trades", market_id=market_id, since=since, limit=limit, cursor=cursor)
167
+
168
+ def ohlc_daily(self, market_id: Optional[str] = None, date: Optional[str] = None,
169
+ limit: Optional[int] = None, cursor: Optional[str] = None) -> Json:
170
+ """GET /ohlc/daily. Send market_id, date (YYYY-MM-DD) or both."""
171
+ if not market_id and not date:
172
+ raise ValueError("send market_id or date")
173
+ return self.get("ohlc/daily", market_id=market_id, date=date, limit=limit, cursor=cursor)
174
+
175
+ def fair_price(self, market_id: str) -> Json:
176
+ """GET /fair-price: the two-sided mid, fair_yes = (bid_yes + (1 - bid_no)) / 2."""
177
+ return self.get("fair-price", market_id=market_id)
178
+
179
+ def line_history(self, market_id: Optional[str] = None, outcome_id: Optional[str] = None,
180
+ since: Optional[str] = None, until: Optional[str] = None,
181
+ limit: Optional[int] = None, cursor: Optional[str] = None) -> Json:
182
+ """GET /line-history: intraday price ticks for a market or one outcome."""
183
+ if not market_id and not outcome_id:
184
+ raise ValueError("send market_id or outcome_id")
185
+ return self.get("line-history", market_id=market_id, outcome_id=outcome_id, since=since, until=until,
186
+ limit=limit, cursor=cursor)
187
+
188
+ def line_moves(self, league: Optional[str] = None, market_id: Optional[str] = None, kind: Optional[str] = None,
189
+ since: Optional[str] = None, min_delta_pp: Optional[float] = None, limit: Optional[int] = None,
190
+ cursor: Optional[str] = None) -> Json:
191
+ """GET /line-moves. kind: move or steam. min_delta_pp: smallest move to return, in percentage points."""
192
+ return self.get("line-moves", league=league, market_id=market_id, kind=kind, since=since,
193
+ min_delta_pp=min_delta_pp, limit=limit, cursor=cursor)
194
+
195
+ def clv(self, market_id: Optional[str] = None, player: Optional[str] = None, league: Optional[str] = None,
196
+ date: Optional[str] = None, settled: Optional[bool] = None, since: Optional[str] = None,
197
+ until: Optional[str] = None, limit: Optional[int] = None, cursor: Optional[str] = None) -> Json:
198
+ """GET /clv: each outcome's open_fair and close_fair before the start, clv_pp, and won once graded."""
199
+ if not any([market_id, player, league, date]):
200
+ raise ValueError("send market_id, player, league or date")
201
+ return self.get("clv", market_id=market_id, player=player, league=league, date=date, settled=settled,
202
+ since=since, until=until, limit=limit, cursor=cursor)
203
+
204
+ def results(self, league: Optional[str] = None, market_id: Optional[str] = None, graded: Optional[bool] = None,
205
+ since: Optional[str] = None, limit: Optional[int] = None, cursor: Optional[str] = None) -> Json:
206
+ """GET /results: settled markets with the winning outcome where known."""
207
+ return self.get("results", league=league, market_id=market_id, graded=graded, since=since, limit=limit, cursor=cursor)
208
+
209
+ def market_summary(self, date: Optional[str] = None, since: Optional[str] = None, league: Optional[str] = None,
210
+ market_type: Optional[str] = None, kind: Optional[str] = None,
211
+ limit: Optional[int] = None, cursor: Optional[str] = None) -> Json:
212
+ """GET /market-summary/daily: straight markets and parlays (COMBO) per day from Novig's daily files."""
213
+ return self.get("market-summary/daily", date=date, since=since, league=league, market_type=market_type,
214
+ kind=kind, limit=limit, cursor=cursor)
215
+
216
+ # ----- cross-venue: FanDuel and DraftKings against the Novig fair price ------
217
+
218
+ def ev(self, league: Optional[str] = None, market_type: Optional[str] = None, min_edge: Optional[float] = None,
219
+ books: Optional[str] = None, min_prob: Optional[float] = None, alt_lines: Optional[bool] = None,
220
+ confidence: Optional[str] = None, sort: Optional[str] = None, player: Optional[str] = None,
221
+ market_id: Optional[str] = None, limit: Optional[int] = None, cursor: Optional[str] = None) -> Json:
222
+ """GET /ev: book prices that pay more than the Novig fair probability. ev_pct = (fair x decimal - 1) x 100."""
223
+ return self.get("ev", league=league, market_type=market_type, min_edge=min_edge, books=books, min_prob=min_prob,
224
+ alt_lines=alt_lines, confidence=confidence, sort=sort, player=player, market_id=market_id,
225
+ limit=limit, cursor=cursor)
226
+
227
+ def arbitrage(self, type: Optional[str] = None, league: Optional[str] = None, market_type: Optional[str] = None,
228
+ min_profit: Optional[float] = None, limit: Optional[int] = None, cursor: Optional[str] = None) -> Json:
229
+ """GET /arbitrage: two-way arbitrage across Novig, FanDuel and DraftKings, or type="middles". Production and up."""
230
+ return self.get("arbitrage", type=type, league=league, market_type=market_type, min_profit=min_profit,
231
+ limit=limit, cursor=cursor)
232
+
233
+ def compare(self, market_id: str) -> Json:
234
+ """GET /compare/{market_id}: one Novig market beside each book on exactly the same line."""
235
+ return self.get("compare/" + _path_id(market_id))
236
+
237
+ def bulk_csv(self, dataset: str, market_id: Optional[str] = None, league: Optional[str] = None,
238
+ since: Optional[str] = None, date: Optional[str] = None, limit: Optional[int] = None,
239
+ cursor: Optional[str] = None) -> str:
240
+ """GET /bulk/csv: CSV text for a dataset such as line_moves. Production and up."""
241
+ query = {k: _param(v) for k, v in dict(dataset=dataset, market_id=market_id, league=league, since=since,
242
+ date=date, limit=limit, cursor=cursor).items() if v is not None}
243
+ return self._raw("bulk/csv?" + urllib.parse.urlencode(query))
244
+
245
+ # ----- names ----------------------------------------------------------
246
+
247
+ def player_props(self, league: str, player: str, limit: Optional[int] = None, cursor: Optional[str] = None) -> Json:
248
+ """GET /player-props. player is a slug, such as lebron-james."""
249
+ return self.get("player-props", league=league, player=player, limit=limit, cursor=cursor)
250
+
251
+ def players(self, league: Optional[str] = None, q: Optional[str] = None,
252
+ limit: Optional[int] = None, cursor: Optional[str] = None) -> Json:
253
+ """GET /players. q: name search, up to 80 characters."""
254
+ return self.get("players", league=league, q=q, limit=limit, cursor=cursor)
255
+
256
+ def teams(self, league: Optional[str] = None, limit: Optional[int] = None, cursor: Optional[str] = None) -> Json:
257
+ """GET /teams."""
258
+ return self.get("teams", league=league, limit=limit, cursor=cursor)
259
+
260
+ def leagues(self, limit: Optional[int] = None, cursor: Optional[str] = None) -> Json:
261
+ """GET /leagues."""
262
+ return self.get("leagues", limit=limit, cursor=cursor)
263
+
264
+
265
+ def _param(value: Any) -> str:
266
+ if isinstance(value, bool):
267
+ return "true" if value else "false"
268
+ return str(value)
269
+
270
+
271
+ def _path_id(value: str) -> str:
272
+ if not value:
273
+ raise ValueError("id is required")
274
+ return urllib.parse.quote(value, safe="")
275
+
276
+
277
+ def _error_from(exc: urllib.error.HTTPError) -> NovigAPIError:
278
+ headers = {k.lower(): v for k, v in (exc.headers.items() if exc.headers else [])}
279
+ try:
280
+ body = json.loads(exc.read().decode("utf-8"))
281
+ except (ValueError, UnicodeDecodeError):
282
+ body = {}
283
+ err = body.get("error", {}) if isinstance(body, dict) else {}
284
+ code = str(err.get("code") or "HTTP_{}".format(exc.code))
285
+ message = str(err.get("message") or exc.reason or "request failed")
286
+ cls = RateLimitError if exc.code == 429 else NovigAPIError
287
+ return cls(exc.code, code, message, body if isinstance(body, dict) else {}, headers)
@@ -0,0 +1,202 @@
1
+ """Devig (remove the vig from) a set of mutually exclusive prices.
2
+
3
+ Every function takes implied probabilities that sum to more than 1 (a book with
4
+ a margin) and returns fair probabilities that sum to 1. All functions are pure:
5
+ no I/O, no global state, standard library only.
6
+
7
+ Novig quotes prices as probabilities shown as percentages (54% = 0.54). A
8
+ sportsbook quotes American or decimal odds; convert those first with
9
+ ``american_to_prob`` or ``decimal_to_prob``.
10
+
11
+ Methods:
12
+
13
+ - ``multiplicative``: divide each probability by the total. The default most
14
+ calculators use.
15
+ - ``additive``: subtract an equal share of the margin from each outcome.
16
+ - ``power``: find k so that the sum of q_i ** k is 1. Takes more margin from
17
+ longshots than from favourites.
18
+ - ``shin``: Shin's (1993) model, which assumes a share z of the money is
19
+ informed. Also shifts margin toward longshots.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ from typing import Callable, Dict, List, Sequence
26
+
27
+ __all__ = [
28
+ "american_to_prob",
29
+ "decimal_to_prob",
30
+ "prob_to_american",
31
+ "prob_to_decimal",
32
+ "overround",
33
+ "hold",
34
+ "multiplicative",
35
+ "additive",
36
+ "power",
37
+ "shin",
38
+ "devig",
39
+ "METHODS",
40
+ ]
41
+
42
+
43
+ def american_to_prob(odds: float) -> float:
44
+ """Implied probability of American odds, margin included.
45
+
46
+ -110 -> 0.5238..., +150 -> 0.4.
47
+ """
48
+ if odds == 0 or -100 < odds < 100:
49
+ raise ValueError("American odds must be <= -100 or >= +100")
50
+ if odds < 0:
51
+ return -odds / (-odds + 100.0)
52
+ return 100.0 / (odds + 100.0)
53
+
54
+
55
+ def decimal_to_prob(odds: float) -> float:
56
+ """Implied probability of decimal odds. 2.50 -> 0.4."""
57
+ if odds <= 1.0:
58
+ raise ValueError("decimal odds must be greater than 1")
59
+ return 1.0 / odds
60
+
61
+
62
+ def prob_to_american(p: float) -> float:
63
+ """American odds for a probability. 0.4 -> +150, 0.6 -> -150, 0.5 -> +100."""
64
+ _check_prob(p)
65
+ if p > 0.5:
66
+ return -100.0 * p / (1.0 - p)
67
+ return 100.0 * (1.0 - p) / p
68
+
69
+
70
+ def prob_to_decimal(p: float) -> float:
71
+ """Decimal odds for a probability. 0.4 -> 2.5."""
72
+ _check_prob(p)
73
+ return 1.0 / p
74
+
75
+
76
+ def overround(probs: Sequence[float]) -> float:
77
+ """Sum of implied probabilities minus 1. -110/-110 -> 0.047619..."""
78
+ return math.fsum(probs) - 1.0
79
+
80
+
81
+ def hold(probs: Sequence[float]) -> float:
82
+ """Theoretical hold: the share of total stake the book keeps on a balanced book.
83
+
84
+ hold = 1 - 1 / sum(probs). -110/-110 -> 0.04545...
85
+ """
86
+ return 1.0 - 1.0 / math.fsum(probs)
87
+
88
+
89
+ def multiplicative(probs: Sequence[float]) -> List[float]:
90
+ """Normalise: p_i = q_i / sum(q).
91
+
92
+ Keeps the ratio between outcomes. Also called the proportional or basic method.
93
+ """
94
+ qs = _validate(probs)
95
+ total = math.fsum(qs)
96
+ return [q / total for q in qs]
97
+
98
+
99
+ def additive(probs: Sequence[float]) -> List[float]:
100
+ """Subtract an equal share of the margin: p_i = q_i - (sum(q) - 1) / n.
101
+
102
+ On a lopsided market this can push a longshot to zero or below; when that
103
+ happens the outcome is floored at 0 and the rest are renormalised, and the
104
+ result should be treated with suspicion.
105
+ """
106
+ qs = _validate(probs)
107
+ margin = (math.fsum(qs) - 1.0) / len(qs)
108
+ out = [q - margin for q in qs]
109
+ if min(out) <= 0.0:
110
+ out = [max(p, 0.0) for p in out]
111
+ total = math.fsum(out)
112
+ out = [p / total for p in out]
113
+ return out
114
+
115
+
116
+ def power(probs: Sequence[float], tol: float = 1e-12, max_iter: int = 200) -> List[float]:
117
+ """Solve for k with sum(q_i ** k) = 1, then p_i = q_i ** k.
118
+
119
+ With a positive margin k > 1, which shrinks small probabilities faster than
120
+ large ones, so longshots carry more of the margin.
121
+ """
122
+ qs = _validate(probs)
123
+ f: Callable[[float], float] = lambda k: math.fsum(q ** k for q in qs) - 1.0
124
+ lo, hi = 1e-6, 1.0
125
+ # sum(q^k) falls as k grows; widen the bracket until it straddles zero.
126
+ while f(hi) > 0.0:
127
+ hi *= 2.0
128
+ if hi > 1e6:
129
+ raise ValueError("power method did not converge")
130
+ k = _bisect(f, lo, hi, tol, max_iter, decreasing=True)
131
+ out = [q ** k for q in qs]
132
+ total = math.fsum(out)
133
+ return [p / total for p in out]
134
+
135
+
136
+ def shin(probs: Sequence[float], tol: float = 1e-12, max_iter: int = 200) -> List[float]:
137
+ """Shin's method.
138
+
139
+ For a share z of insider money, the fair probability of outcome i is
140
+
141
+ p_i(z) = (sqrt(z^2 + 4 (1 - z) q_i^2 / S) - z) / (2 (1 - z))
142
+
143
+ where q_i are the implied probabilities and S is their sum. z is chosen so
144
+ the p_i sum to 1. On a book with no margin z = 0 and p_i = q_i.
145
+ """
146
+ qs = _validate(probs)
147
+ total = math.fsum(qs)
148
+ if total <= 1.0:
149
+ return [q / total for q in qs]
150
+
151
+ def fair(z: float) -> List[float]:
152
+ return [(math.sqrt(z * z + 4.0 * (1.0 - z) * q * q / total) - z) / (2.0 * (1.0 - z)) for q in qs]
153
+
154
+ f = lambda z: math.fsum(fair(z)) - 1.0
155
+ z = _bisect(f, 0.0, 0.999, tol, max_iter, decreasing=True)
156
+ out = fair(z)
157
+ s = math.fsum(out)
158
+ return [p / s for p in out]
159
+
160
+
161
+ METHODS: Dict[str, Callable[[Sequence[float]], List[float]]] = {
162
+ "multiplicative": multiplicative,
163
+ "additive": additive,
164
+ "power": power,
165
+ "shin": shin,
166
+ }
167
+
168
+
169
+ def devig(probs: Sequence[float], method: str = "multiplicative") -> List[float]:
170
+ """Devig with the named method: multiplicative, additive, power or shin."""
171
+ try:
172
+ fn = METHODS[method]
173
+ except KeyError:
174
+ raise ValueError("method must be one of: " + ", ".join(METHODS)) from None
175
+ return fn(probs)
176
+
177
+
178
+ def _check_prob(p: float) -> None:
179
+ if not 0.0 < p < 1.0:
180
+ raise ValueError("probability must be between 0 and 1 (exclusive)")
181
+
182
+
183
+ def _validate(probs: Sequence[float]) -> List[float]:
184
+ qs = [float(q) for q in probs]
185
+ if len(qs) < 2:
186
+ raise ValueError("need at least two outcomes")
187
+ for q in qs:
188
+ _check_prob(q)
189
+ return qs
190
+
191
+
192
+ def _bisect(f: Callable[[float], float], lo: float, hi: float, tol: float, max_iter: int, decreasing: bool) -> float:
193
+ for _ in range(max_iter):
194
+ mid = (lo + hi) / 2.0
195
+ v = f(mid)
196
+ if abs(v) < tol:
197
+ return mid
198
+ if (v > 0.0) == decreasing:
199
+ lo = mid
200
+ else:
201
+ hi = mid
202
+ return (lo + hi) / 2.0
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: novigapi
3
+ Version: 0.1.0
4
+ Summary: Python client for novigapi.com, a read-only Novig market data API, plus devig helpers. Independent; not affiliated with Novig.
5
+ License: MIT
6
+ Project-URL: Repository, https://github.com/novigapi/novigapi-python
7
+ Project-URL: Issues, https://github.com/novigapi/novigapi-python/issues
8
+ Project-URL: Homepage, https://novigapi.com
9
+ Project-URL: Documentation, https://novigapi.com/docs
10
+ Project-URL: Python guide, https://novigapi.com/novig-api-python
11
+ Keywords: novig,odds,devig,no-vig,prediction-market,sports-data,closing-line-value
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Topic :: Office/Business :: Financial
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest; extra == "test"
22
+ Dynamic: license-file
23
+
24
+ # novigapi (Python)
25
+
26
+ A small Python client for [novigapi.com](https://novigapi.com), a read-only Novig market data API, plus pure-Python devig helpers and three runnable examples.
27
+
28
+ Novig has an official trading API and free daily CSVs. novigapi.com is the read-only data layer on top: no account, names resolved, intraday line history, fair prices and alerts. This package talks to novigapi.com. To place orders or read a Novig account, use the official Novig API documented at [docs.novig.com](https://docs.novig.com).
29
+
30
+ Independent. Not affiliated with, endorsed by or sponsored by Novig.
31
+
32
+ - Standard library only (`urllib`, `json`). Python 3.8 or later. No dependencies.
33
+ - MIT licensed.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install novigapi
39
+ ```
40
+
41
+ Not on PyPI yet: the name `novigapi` was free on 15 Nov 2026 and the package is ready to publish. Until then, from a clone: `pip install -e .`
42
+
43
+ Tests need no key, no network and no pytest: `python3 tests/run.py` (pytest also works).
44
+
45
+ ## Quickstart
46
+
47
+ Get a free key at [novigapi.com/sign-up](https://novigapi.com/sign-up) (Free plan: 100 credits a month, 20 requests a minute), then:
48
+
49
+ ```bash
50
+ export NOVIGAPI_KEY=nv_...
51
+ ```
52
+
53
+ ```python
54
+ from novigapi import NovigAPI, devig, prob_to_american
55
+
56
+ api = NovigAPI() # reads NOVIGAPI_KEY
57
+
58
+ page = api.markets(league="NFL", status="open", limit=20)
59
+ for market in page["data"]:
60
+ print(market["market_id"], market.get("description"))
61
+
62
+ fair = api.fair_price(page["data"][0]["market_id"])
63
+ print(fair["data"], fair["meta"].get("formula"))
64
+
65
+ # Devig any two prices (probabilities from 0 to 1)
66
+ print(devig([0.5238, 0.5238], "shin")) # [0.5, 0.5]
67
+ print(prob_to_american(0.40)) # 150.0
68
+ ```
69
+
70
+ Every method returns the JSON envelope `{"data": ..., "meta": {...}}`. List endpoints page with `cursor`; `api.iter_rows("markets", league="NBA")` follows `meta.next_cursor` for you.
71
+
72
+ ### Methods
73
+
74
+ | Method | Endpoint |
75
+ |---|---|
76
+ | `markets(league, event_id, market_type, player, team, status, limit, cursor)` | `GET /markets` |
77
+ | `events(league, status, limit, cursor)` | `GET /events` |
78
+ | `market(market_id)` | `GET /markets/{id}` |
79
+ | `orderbook(market_id)` | `GET /orderbook/{id}` |
80
+ | `trades(market_id, since, limit, cursor)` | `GET /trades` |
81
+ | `ohlc_daily(market_id, date)` | `GET /ohlc/daily` |
82
+ | `fair_price(market_id)` | `GET /fair-price` |
83
+ | `line_history(market_id or outcome_id, since, until)` | `GET /line-history` |
84
+ | `player_props(league, player)` | `GET /player-props` |
85
+ | `players(league, q)`, `teams(league)`, `leagues()` | `GET /players`, `/teams`, `/leagues` |
86
+ | `results(league, market_id, graded, since)` | `GET /results` |
87
+ | `market_summary(date, since, league, market_type, kind)` | `GET /market-summary/daily` |
88
+ | `ev(league, min_edge, min_prob, books, sort, ...)` | `GET /ev` (FanDuel and DraftKings vs the Novig fair price) |
89
+ | `arbitrage(type, league, min_profit)` | `GET /arbitrage` (Production and up) |
90
+ | `compare(market_id)` | `GET /compare/{market_id}` |
91
+ | `bulk_csv(dataset, ...)` | `GET /bulk/csv` (Production and up), returns CSV text |
92
+ | `clv(market_id, player, league, date, settled, since, until)` | `GET /clv` |
93
+ | `line_moves(league, market_id, kind, since, min_delta_pp)` | `GET /line-moves` |
94
+
95
+ Base URL `https://novigapi.com/api/v1`. Auth header `Authorization: Bearer nv_...`. Full reference: [novigapi.com/docs](https://novigapi.com/docs).
96
+
97
+ ### Errors and limits
98
+
99
+ Any non-2xx response raises `NovigAPIError` with `status`, `code` and `message`. A 429 raises `RateLimitError`, which also carries the fields from the 429 body:
100
+
101
+ ```python
102
+ from novigapi import NovigAPI, RateLimitError
103
+ import time
104
+
105
+ api = NovigAPI()
106
+ try:
107
+ api.markets(league="NBA")
108
+ except RateLimitError as err:
109
+ print(err.code, err.plan, err.limit, err.reset_at, err.upgrade_url)
110
+ if err.code == "RATE_LIMITED" and err.retry_after:
111
+ time.sleep(err.retry_after)
112
+ ```
113
+
114
+ `RATE_LIMITED` is the per-minute limit and sends `Retry-After`. `QUOTA_EXCEEDED` means the month's credits are used; there are no automatic overage charges.
115
+
116
+ ## Devig helpers
117
+
118
+ `novigapi.devig` has four methods as pure functions with docstrings: `multiplicative`, `additive`, `power` and `shin`, plus `american_to_prob`, `prob_to_american`, `decimal_to_prob`, `prob_to_decimal`, `overround` and `hold`. `novigapi.taker_fee(price, contracts, coefficient=0.06)` returns Novig's taker fee, `coefficient x p x (1 - p) x contracts`, per [docs.novig.com](https://docs.novig.com/fees.md) (checked 12 Nov 2026).
119
+
120
+ Worked example, -300 / +240 (implied 75.00% and 29.41%, 4.41% overround):
121
+
122
+ | Method | Favourite | Longshot |
123
+ |---|---|---|
124
+ | Multiplicative | 71.83% (-255) | 28.17% (+255) |
125
+ | Additive | 72.79% (-268) | 27.21% (+268) |
126
+ | Power | 73.31% (-275) | 26.69% (+275) |
127
+ | Shin | 72.79% (-268) | 27.21% (+268) |
128
+
129
+ With two outcomes, Shin and additive agree. The [no-vig calculator](https://novigapi.com/no-vig-calculator) runs the same four methods in the browser.
130
+
131
+ ## Examples
132
+
133
+ ```bash
134
+ python examples/devig_board.py --league NFL --method shin # devig every two-sided market in a league
135
+ python examples/arb_scanner.py --league NBA # combined price below $1 after the taker fee
136
+ python examples/clv_backtest.py --start 2026-08-04 --end 2026-11-11 # no key needed
137
+ ```
138
+
139
+ - `devig_board.py` lists open two-way markets for a league and prints the fair probability and fair American odds of each side.
140
+ - `arb_scanner.py` adds the taker fee to each leg, `0.06 x p x (1 - p)` per $1 of payout (pregame game-market fills are fee-free; the default charges every leg, the conservative case), and reports any pair that costs less than $1. Pass `--other prices.csv` to pair a Novig side with a price you collected from another venue.
141
+ - `clv_backtest.py` streams Novig's own daily `markets.csv` files from `https://data.novig.com/reporting/trade-data/<date>/markets.csv` and compares an entry price (the opening print, or yours via `--bets`) with the `close` column. It needs no API key and saves nothing to disk. Pass `--results` with `market_id,won` to score open against close with the Brier score, or `--martingale` to test, without any results, whether one day's move predicts the next day's.
142
+
143
+ Tests:
144
+
145
+ ```bash
146
+ pip install -e ".[test]"
147
+ pytest tests -q
148
+ ```
149
+
150
+ ## Data notes
151
+
152
+ - Prices are probabilities. Novig shows them as percentages: 54% is 0.54.
153
+ - Novig's book holds bids only. The ask on an outcome is 1 minus the best bid on the other outcome.
154
+ - REST data on paid plans is at most about 10 seconds old; the stream pushes changes within 5 to 30 seconds.
155
+ - Novig's daily CSVs start in early August 2026 and carry no player or team names; novigapi.com joins the names.
156
+
157
+ ## License
158
+
159
+ MIT. Copyright (c) 2026 Stunning Couches LLC.
160
+
161
+ Independent. Not affiliated with, endorsed by or sponsored by Novig.
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/novigapi/__init__.py
5
+ src/novigapi/client.py
6
+ src/novigapi/devig.py
7
+ src/novigapi.egg-info/PKG-INFO
8
+ src/novigapi.egg-info/SOURCES.txt
9
+ src/novigapi.egg-info/dependency_links.txt
10
+ src/novigapi.egg-info/requires.txt
11
+ src/novigapi.egg-info/top_level.txt
12
+ tests/test_client.py
13
+ tests/test_devig.py
@@ -0,0 +1,3 @@
1
+
2
+ [test]
3
+ pytest
@@ -0,0 +1 @@
1
+ novigapi
@@ -0,0 +1,100 @@
1
+ """Client tests with a stubbed transport: no network, no key needed."""
2
+ import io
3
+ import json
4
+ import os
5
+ import sys
6
+ import urllib.error
7
+ import urllib.request
8
+
9
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
10
+
11
+ from novigapi import NovigAPI, NovigAPIError, RateLimitError # noqa: E402
12
+
13
+ CALLS = []
14
+
15
+
16
+ class _Resp(io.BytesIO):
17
+ def __enter__(self):
18
+ return self
19
+
20
+ def __exit__(self, *a):
21
+ return False
22
+
23
+
24
+ def _stub(pages):
25
+ """Replace urlopen with one that returns each page in turn and records the request."""
26
+ it = iter(pages)
27
+
28
+ def fake(req, timeout=None):
29
+ CALLS.append(req)
30
+ nxt = next(it)
31
+ if isinstance(nxt, Exception):
32
+ raise nxt
33
+ return _Resp(json.dumps(nxt).encode())
34
+
35
+ urllib.request.urlopen = fake
36
+
37
+
38
+ def _http_error(status, body, headers=None):
39
+ from email.message import Message
40
+ msg = Message()
41
+ for k, v in (headers or {}).items():
42
+ msg[k] = v
43
+ return urllib.error.HTTPError("https://novigapi.com/api/v1/x", status, "err", msg, io.BytesIO(json.dumps(body).encode()))
44
+
45
+
46
+ def test_key_rules():
47
+ os.environ.pop("NOVIGAPI_KEY", None)
48
+ try:
49
+ NovigAPI()
50
+ assert False, "expected ValueError"
51
+ except ValueError as e:
52
+ assert "NOVIGAPI_KEY" in str(e)
53
+ try:
54
+ NovigAPI(api_key="sk_wrong")
55
+ assert False
56
+ except ValueError:
57
+ pass
58
+
59
+
60
+ def test_request_shape_and_paging():
61
+ CALLS.clear()
62
+ _stub([{"data": [{"market_id": "a"}], "meta": {"next_cursor": "c2"}}, {"data": [{"market_id": "b"}], "meta": {"next_cursor": None}}])
63
+ api = NovigAPI(api_key="nv_test")
64
+ rows = list(api.iter_rows("markets", league="NFL", status="open"))
65
+ assert [r["market_id"] for r in rows] == ["a", "b"]
66
+ first, second = CALLS
67
+ assert first.full_url == "https://novigapi.com/api/v1/markets?league=NFL&status=open"
68
+ assert second.full_url.endswith("cursor=c2&league=NFL&status=open") or "cursor=c2" in second.full_url
69
+ assert first.headers["Authorization"] == "Bearer nv_test"
70
+
71
+
72
+ def test_cross_venue_and_clv_params():
73
+ CALLS.clear()
74
+ _stub([{"data": [], "meta": {}}] * 4)
75
+ api = NovigAPI(api_key="nv_test")
76
+ api.ev(league="NBA", min_edge=2, sort="ev", alt_lines=False)
77
+ api.arbitrage(type="middles", league="NFL")
78
+ api.compare("01a1/x")
79
+ api.clv(league="NFL", date="2026-11-15", settled=True)
80
+ urls = [c.full_url for c in CALLS]
81
+ assert urls[0] == "https://novigapi.com/api/v1/ev?league=NBA&min_edge=2&alt_lines=false&sort=ev"
82
+ assert urls[1] == "https://novigapi.com/api/v1/arbitrage?type=middles&league=NFL"
83
+ assert urls[2] == "https://novigapi.com/api/v1/compare/01a1%2Fx"
84
+ assert urls[3] == "https://novigapi.com/api/v1/clv?league=NFL&date=2026-11-15&settled=true"
85
+
86
+
87
+ def test_errors_are_typed():
88
+ _stub([_http_error(429, {"error": {"code": "QUOTA_EXCEEDED", "message": "monthly credits used", "plan": "free", "limit": 100, "upgrade_url": "https://novigapi.com/upgrade"}}, {"Retry-After": "30"})])
89
+ api = NovigAPI(api_key="nv_test")
90
+ try:
91
+ api.markets()
92
+ assert False
93
+ except RateLimitError as e:
94
+ assert e.code == "QUOTA_EXCEEDED" and e.plan == "free" and e.limit == 100 and e.retry_after == 30
95
+ _stub([_http_error(401, {"error": {"code": "INVALID_API_KEY", "message": "bad key"}})])
96
+ try:
97
+ api.markets()
98
+ assert False
99
+ except NovigAPIError as e:
100
+ assert e.status == 401 and e.code == "INVALID_API_KEY"
@@ -0,0 +1,60 @@
1
+ import math
2
+ import os
3
+ import sys
4
+
5
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
6
+
7
+ from novigapi import taker_fee # noqa: E402
8
+ from novigapi.devig import ( # noqa: E402
9
+ additive,
10
+ american_to_prob,
11
+ devig,
12
+ hold,
13
+ multiplicative,
14
+ power,
15
+ prob_to_american,
16
+ shin,
17
+ )
18
+
19
+
20
+ def close(a, b, tol=1e-4):
21
+ return abs(a - b) <= tol
22
+
23
+
24
+ def test_conversions():
25
+ assert close(american_to_prob(-110), 110 / 210)
26
+ assert close(american_to_prob(150), 0.4)
27
+ assert close(prob_to_american(0.4), 150.0)
28
+ assert close(prob_to_american(0.6), -150.0)
29
+
30
+
31
+ def test_even_market_all_methods_agree():
32
+ q = [american_to_prob(-110), american_to_prob(-110)]
33
+ assert close(hold(q), 1 - 1 / (220 / 210))
34
+ for method in ("multiplicative", "additive", "power", "shin"):
35
+ p = devig(q, method)
36
+ assert close(p[0], 0.5) and close(p[1], 0.5)
37
+
38
+
39
+ def test_lopsided_market():
40
+ q = [american_to_prob(-300), american_to_prob(240)]
41
+ m = multiplicative(q)
42
+ a = additive(q)
43
+ pw = power(q)
44
+ s = shin(q)
45
+ assert close(m[0], 0.71831)
46
+ assert close(a[0], 0.72794)
47
+ assert close(pw[0], 0.73308)
48
+ # With two outcomes, Shin and additive give the same answer.
49
+ assert close(s[0], a[0])
50
+ # Power takes the most margin from the longshot, multiplicative the least.
51
+ assert pw[1] < a[1] < m[1]
52
+ for p in (m, a, pw, s):
53
+ assert close(math.fsum(p), 1.0, 1e-9)
54
+
55
+
56
+ def test_taker_fee_matches_novig_docs():
57
+ # docs.novig.com worked examples: 100 contracts at 0.50 -> $1.50, at 0.30 -> $1.26.
58
+ assert close(taker_fee(0.50, 100), 1.50)
59
+ assert close(taker_fee(0.30, 100), 1.26)
60
+ assert close(taker_fee(0.45, 500), 7.425)