novigapi 0.1.0__py3-none-any.whl
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/__init__.py +61 -0
- novigapi/client.py +287 -0
- novigapi/devig.py +202 -0
- novigapi-0.1.0.dist-info/METADATA +161 -0
- novigapi-0.1.0.dist-info/RECORD +8 -0
- novigapi-0.1.0.dist-info/WHEEL +5 -0
- novigapi-0.1.0.dist-info/licenses/LICENSE +21 -0
- novigapi-0.1.0.dist-info/top_level.txt +1 -0
novigapi/__init__.py
ADDED
|
@@ -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
|
+
]
|
novigapi/client.py
ADDED
|
@@ -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)
|
novigapi/devig.py
ADDED
|
@@ -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,8 @@
|
|
|
1
|
+
novigapi/__init__.py,sha256=vAU_k2LNzTrcFVg27QTKJm7o7j17hEw2D8F3ero1LfE,1735
|
|
2
|
+
novigapi/client.py,sha256=OUG-cvC18N33apCZXzHY7uy9Rs-ZJzI-sJfT4JHERF0,15378
|
|
3
|
+
novigapi/devig.py,sha256=LOaxHLKYVbFcXgfBbjo03wNlXKhLvgUdEY37pdlnoDI,6263
|
|
4
|
+
novigapi-0.1.0.dist-info/licenses/LICENSE,sha256=x4zI-m2lWxFFkFn_BD86vA8lXA7YD4gq45y0VrP1r2o,1077
|
|
5
|
+
novigapi-0.1.0.dist-info/METADATA,sha256=1jhRDIG3kf9BUKV6UBoQM9OYW-k2kBQ27rAwOXXze0k,7769
|
|
6
|
+
novigapi-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
7
|
+
novigapi-0.1.0.dist-info/top_level.txt,sha256=txUtRU8yEVHe2jVz2p-MM6UHRe0kHXET0G3f6z6lWgU,9
|
|
8
|
+
novigapi-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
novigapi
|