tickerinside 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.
- tickerinside-0.1.0/LICENSE +21 -0
- tickerinside-0.1.0/PKG-INFO +121 -0
- tickerinside-0.1.0/README.md +91 -0
- tickerinside-0.1.0/pyproject.toml +53 -0
- tickerinside-0.1.0/tickerinside/__init__.py +83 -0
- tickerinside-0.1.0/tickerinside/_http.py +177 -0
- tickerinside-0.1.0/tickerinside/_math.py +276 -0
- tickerinside-0.1.0/tickerinside/_version.py +1 -0
- tickerinside-0.1.0/tickerinside/client.py +670 -0
- tickerinside-0.1.0/tickerinside/errors.py +71 -0
- tickerinside-0.1.0/tickerinside/py.typed +0 -0
- tickerinside-0.1.0/tickerinside/result.py +116 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TickerInside
|
|
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,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tickerinside
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the free TickerInside API: the full holdings of US ETFs, which funds hold a stock, fund overlap and portfolio look-through.
|
|
5
|
+
Project-URL: Homepage, https://tickerinside.com
|
|
6
|
+
Project-URL: Documentation, https://tickerinside.com/api/
|
|
7
|
+
Author-email: TickerInside <contact@tickerinside.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: api,etf,etf holdings,etf overlap,finance,look-through,n-port,portfolio,sec
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Provides-Extra: pandas
|
|
28
|
+
Requires-Dist: pandas>=1.3; extra == 'pandas'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# tickerinside
|
|
32
|
+
|
|
33
|
+
A Python client for the free TickerInside API, which serves the full holdings of US ETFs, the funds that hold each stock, and the overlap and look-through computed from them.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
pip install tickerinside
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The package uses only the Python standard library and runs on Python 3.9 or later. There is no API key and no account to create. pandas is optional: install it (or `pip install "tickerinside[pandas]"`) and every result also offers a `.df` DataFrame.
|
|
42
|
+
|
|
43
|
+
## Examples
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import tickerinside as ti
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The holdings of VOO as a DataFrame, heaviest first:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
voo = ti.holdings("VOO", names=True)
|
|
53
|
+
voo.holdings_as_of # '2026-08-31', the date of the issuer's file
|
|
54
|
+
voo.df.head(3)
|
|
55
|
+
# ticker name weight_pct
|
|
56
|
+
# 0 NVDA Nvidia 8.0927
|
|
57
|
+
# 1 AAPL Apple Inc. 7.0432
|
|
58
|
+
# 2 MSFT Microsoft 5.7033
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Which ETFs hold NVDA, and at what weight:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
nvda = ti.holders("NVDA")
|
|
65
|
+
nvda.data["counts"] # {'issuer': 177, 'sec_nport': None}
|
|
66
|
+
nvda.df[["fund", "weight_pct", "holdings_as_of"]].head(3)
|
|
67
|
+
# fund weight_pct holdings_as_of
|
|
68
|
+
# 0 GXPT 19.9100 2026-09-24
|
|
69
|
+
# 1 VGT 17.7869 2026-08-31
|
|
70
|
+
# 2 VUSG 16.9500 2026-09-25
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Funds covered from their issuers' own files and funds covered from SEC Form N-PORT filings, which are months older, are kept apart in `nvda.data["issuer"]` and `nvda.data["sec_nport"]` (None while the API lists only the first kind). `.df` lists the issuer rows first and the SEC rows after them, without merging the two by weight.
|
|
74
|
+
|
|
75
|
+
How much VOO and QQQ overlap:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
c = ti.compare("VOO", "QQQ")
|
|
79
|
+
c.data["overlap"]["by_weight"] # 53.9, the smaller weight of each shared company, summed
|
|
80
|
+
c.data["overlap"]["share_of_b"] # 93.7, the part of QQQ held in companies VOO also holds
|
|
81
|
+
c.data["correlation_3y"] # 0.95, from three years of weekly returns
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
What a small portfolio holds underneath, in percent and in money:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
lt = ti.look_through({"VOO": 6000, "QQQ": 3000, "SCHD": 1000})
|
|
88
|
+
lt.df.head(2)
|
|
89
|
+
# ticker weight_pct value via_VOO via_QQQ via_SCHD
|
|
90
|
+
# 0 NVDA 7.312 731.24 4.8556 2.4568 0.0
|
|
91
|
+
# 1 AAPL 6.449 644.88 4.2259 2.2229 0.0
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Citing a figure, with the line every result carries:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
print(c.cite)
|
|
98
|
+
# TickerInside, VOO vs QQQ, holdings files of 2026-08-31 and 2026-09-24, https://tickerinside.com/compare/voo-vs-qqq/, CC BY 4.0
|
|
99
|
+
c.page_url # the page where a reader can check it
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The figures above are from the files of 25 September 2026 and change as issuers publish new ones.
|
|
103
|
+
|
|
104
|
+
## What each call returns
|
|
105
|
+
|
|
106
|
+
The functions are `funds()`, `fund(ticker)`, `holdings(ticker)`, `holders(stock_ticker)`, `security(ticker)`, `compare(a, b)`, `overlap_matrix(tickers)`, `look_through(positions)` and `census()`. Each returns a result with these attributes:
|
|
107
|
+
|
|
108
|
+
- `.data`: the answer as plain dicts and lists.
|
|
109
|
+
- `.df`: the same answer as a pandas DataFrame, when pandas is installed.
|
|
110
|
+
- `.as_of`: the date of the price data, and `.holdings_as_of`: the date of the holdings file, or a dict of dates by fund when the answer uses several funds.
|
|
111
|
+
- `.source`: where the holdings come from, `issuer` for the issuer's own file or `sec_nport` for a fund covered from its latest SEC Form N-PORT filing, which is months older. Like `.holdings_as_of`, it is a dict by fund when the answer uses several funds.
|
|
112
|
+
- `.cite`: a citation line naming the source, its date, a link and the licence.
|
|
113
|
+
- `.url`: the API file the answer was read from, and `.page_url`: the page on tickerinside.com where it can be checked.
|
|
114
|
+
|
|
115
|
+
Overlap, correlation and look-through are computed on your machine from the fund files, with the same arithmetic as the TickerInside website and MCP server, so the figures agree with theirs. A ticker that is not covered raises `tickerinside.NotCovered` with the API's own message, and a fund given to `holders()` or a stock given to `fund()` gets a message naming the call that fits it. A network failure raises `tickerinside.APIError` with the URL that failed, and so does a 404 that is not the API's own answer, which usually means a wrong base URL. Each file is kept in memory for an hour, so asking twice costs one request. `ti.Client(base_url=..., timeout=..., cache_ttl=...)` gives a session with other settings, and the `TICKERINSIDE_API` environment variable, when it is set, replaces the default base URL `https://tickerinside.com/api/v1` (the TickerInside MCP server reads the same variable). Requests identify themselves with the User-Agent `tickerinside-python/<version>`, and nothing else is sent anywhere.
|
|
116
|
+
|
|
117
|
+
## Licence and attribution
|
|
118
|
+
|
|
119
|
+
The code of this package is released under the MIT licence. The data it reads is published by TickerInside under the Creative Commons Attribution 4.0 licence (CC BY 4.0): you may use it, commercially as well, provided you credit TickerInside and link to https://tickerinside.com. The `.cite` line on every result does both. The figures are measurements of what funds hold and how they have behaved, not investment advice.
|
|
120
|
+
|
|
121
|
+
The API reference, with every field and endpoint, is at https://tickerinside.com/api/.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# tickerinside
|
|
2
|
+
|
|
3
|
+
A Python client for the free TickerInside API, which serves the full holdings of US ETFs, the funds that hold each stock, and the overlap and look-through computed from them.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install tickerinside
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The package uses only the Python standard library and runs on Python 3.9 or later. There is no API key and no account to create. pandas is optional: install it (or `pip install "tickerinside[pandas]"`) and every result also offers a `.df` DataFrame.
|
|
12
|
+
|
|
13
|
+
## Examples
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import tickerinside as ti
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The holdings of VOO as a DataFrame, heaviest first:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
voo = ti.holdings("VOO", names=True)
|
|
23
|
+
voo.holdings_as_of # '2026-08-31', the date of the issuer's file
|
|
24
|
+
voo.df.head(3)
|
|
25
|
+
# ticker name weight_pct
|
|
26
|
+
# 0 NVDA Nvidia 8.0927
|
|
27
|
+
# 1 AAPL Apple Inc. 7.0432
|
|
28
|
+
# 2 MSFT Microsoft 5.7033
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Which ETFs hold NVDA, and at what weight:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
nvda = ti.holders("NVDA")
|
|
35
|
+
nvda.data["counts"] # {'issuer': 177, 'sec_nport': None}
|
|
36
|
+
nvda.df[["fund", "weight_pct", "holdings_as_of"]].head(3)
|
|
37
|
+
# fund weight_pct holdings_as_of
|
|
38
|
+
# 0 GXPT 19.9100 2026-09-24
|
|
39
|
+
# 1 VGT 17.7869 2026-08-31
|
|
40
|
+
# 2 VUSG 16.9500 2026-09-25
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Funds covered from their issuers' own files and funds covered from SEC Form N-PORT filings, which are months older, are kept apart in `nvda.data["issuer"]` and `nvda.data["sec_nport"]` (None while the API lists only the first kind). `.df` lists the issuer rows first and the SEC rows after them, without merging the two by weight.
|
|
44
|
+
|
|
45
|
+
How much VOO and QQQ overlap:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
c = ti.compare("VOO", "QQQ")
|
|
49
|
+
c.data["overlap"]["by_weight"] # 53.9, the smaller weight of each shared company, summed
|
|
50
|
+
c.data["overlap"]["share_of_b"] # 93.7, the part of QQQ held in companies VOO also holds
|
|
51
|
+
c.data["correlation_3y"] # 0.95, from three years of weekly returns
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
What a small portfolio holds underneath, in percent and in money:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
lt = ti.look_through({"VOO": 6000, "QQQ": 3000, "SCHD": 1000})
|
|
58
|
+
lt.df.head(2)
|
|
59
|
+
# ticker weight_pct value via_VOO via_QQQ via_SCHD
|
|
60
|
+
# 0 NVDA 7.312 731.24 4.8556 2.4568 0.0
|
|
61
|
+
# 1 AAPL 6.449 644.88 4.2259 2.2229 0.0
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Citing a figure, with the line every result carries:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
print(c.cite)
|
|
68
|
+
# TickerInside, VOO vs QQQ, holdings files of 2026-08-31 and 2026-09-24, https://tickerinside.com/compare/voo-vs-qqq/, CC BY 4.0
|
|
69
|
+
c.page_url # the page where a reader can check it
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The figures above are from the files of 25 September 2026 and change as issuers publish new ones.
|
|
73
|
+
|
|
74
|
+
## What each call returns
|
|
75
|
+
|
|
76
|
+
The functions are `funds()`, `fund(ticker)`, `holdings(ticker)`, `holders(stock_ticker)`, `security(ticker)`, `compare(a, b)`, `overlap_matrix(tickers)`, `look_through(positions)` and `census()`. Each returns a result with these attributes:
|
|
77
|
+
|
|
78
|
+
- `.data`: the answer as plain dicts and lists.
|
|
79
|
+
- `.df`: the same answer as a pandas DataFrame, when pandas is installed.
|
|
80
|
+
- `.as_of`: the date of the price data, and `.holdings_as_of`: the date of the holdings file, or a dict of dates by fund when the answer uses several funds.
|
|
81
|
+
- `.source`: where the holdings come from, `issuer` for the issuer's own file or `sec_nport` for a fund covered from its latest SEC Form N-PORT filing, which is months older. Like `.holdings_as_of`, it is a dict by fund when the answer uses several funds.
|
|
82
|
+
- `.cite`: a citation line naming the source, its date, a link and the licence.
|
|
83
|
+
- `.url`: the API file the answer was read from, and `.page_url`: the page on tickerinside.com where it can be checked.
|
|
84
|
+
|
|
85
|
+
Overlap, correlation and look-through are computed on your machine from the fund files, with the same arithmetic as the TickerInside website and MCP server, so the figures agree with theirs. A ticker that is not covered raises `tickerinside.NotCovered` with the API's own message, and a fund given to `holders()` or a stock given to `fund()` gets a message naming the call that fits it. A network failure raises `tickerinside.APIError` with the URL that failed, and so does a 404 that is not the API's own answer, which usually means a wrong base URL. Each file is kept in memory for an hour, so asking twice costs one request. `ti.Client(base_url=..., timeout=..., cache_ttl=...)` gives a session with other settings, and the `TICKERINSIDE_API` environment variable, when it is set, replaces the default base URL `https://tickerinside.com/api/v1` (the TickerInside MCP server reads the same variable). Requests identify themselves with the User-Agent `tickerinside-python/<version>`, and nothing else is sent anywhere.
|
|
86
|
+
|
|
87
|
+
## Licence and attribution
|
|
88
|
+
|
|
89
|
+
The code of this package is released under the MIT licence. The data it reads is published by TickerInside under the Creative Commons Attribution 4.0 licence (CC BY 4.0): you may use it, commercially as well, provided you credit TickerInside and link to https://tickerinside.com. The `.cite` line on every result does both. The figures are measurements of what funds hold and how they have behaved, not investment advice.
|
|
90
|
+
|
|
91
|
+
The API reference, with every field and endpoint, is at https://tickerinside.com/api/.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tickerinside"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python client for the free TickerInside API: the full holdings of US ETFs, which funds hold a stock, fund overlap and portfolio look-through."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "TickerInside", email = "contact@tickerinside.com" }]
|
|
14
|
+
keywords = ["etf", "etf holdings", "etf overlap", "look-through", "portfolio", "finance", "sec", "n-port", "api"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
19
|
+
"Intended Audience :: Science/Research",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Programming Language :: Python :: 3.14",
|
|
29
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
30
|
+
"Typing :: Typed",
|
|
31
|
+
]
|
|
32
|
+
dependencies = []
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
pandas = ["pandas>=1.3"]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://tickerinside.com"
|
|
39
|
+
Documentation = "https://tickerinside.com/api/"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.version]
|
|
42
|
+
path = "tickerinside/_version.py"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["tickerinside"]
|
|
46
|
+
core-metadata-version = "2.4"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.sdist]
|
|
49
|
+
core-metadata-version = "2.4"
|
|
50
|
+
# The tests stay in the repository: their fixtures are recorded API files,
|
|
51
|
+
# including issuer-layer holdings, which are not redistributed on PyPI.
|
|
52
|
+
include = ["/tickerinside", "/README.md", "/LICENSE", "/pyproject.toml"]
|
|
53
|
+
exclude = ["**/__pycache__", "**/*.pyc"]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""tickerinside: Python client for the free TickerInside API.
|
|
2
|
+
|
|
3
|
+
The full holdings of US ETFs, which funds hold a stock, the overlap between
|
|
4
|
+
funds and the look-through of a portfolio, read from the static JSON files
|
|
5
|
+
tickerinside.com serves. No key, no account, standard library only; pandas is
|
|
6
|
+
used when it is installed.
|
|
7
|
+
|
|
8
|
+
import tickerinside as ti
|
|
9
|
+
|
|
10
|
+
ti.holdings("VOO").df # every position, heaviest first
|
|
11
|
+
ti.holders("NVDA").df # the funds that hold NVDA
|
|
12
|
+
ti.compare("VOO", "QQQ").data # overlap and correlation
|
|
13
|
+
ti.look_through({"VOO": 6000, "QQQ": 3000, "SCHD": 1000}).df
|
|
14
|
+
|
|
15
|
+
Every call returns a Result with .data, .df, .as_of, .holdings_as_of,
|
|
16
|
+
.source, .cite, .url and .page_url. The module functions share one Client;
|
|
17
|
+
create your own Client for another base URL, timeout or cache.
|
|
18
|
+
|
|
19
|
+
The data is published by TickerInside under CC BY 4.0: credit TickerInside
|
|
20
|
+
and link to https://tickerinside.com when you use it.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import functools
|
|
25
|
+
import inspect
|
|
26
|
+
from typing import Optional, Sequence
|
|
27
|
+
|
|
28
|
+
from ._http import USER_AGENT
|
|
29
|
+
from ._version import __version__
|
|
30
|
+
from .client import Client, Positions
|
|
31
|
+
from .errors import APIError, NotCovered, TickerInsideError
|
|
32
|
+
from .result import OverlapMatrix, Result
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"__version__", "USER_AGENT", "Client", "Result", "OverlapMatrix",
|
|
36
|
+
"TickerInsideError", "APIError", "NotCovered",
|
|
37
|
+
"funds", "fund", "holdings", "holders", "security", "compare", "overlap_matrix",
|
|
38
|
+
"look_through", "census", "configure", "clear_cache",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
_default: Optional[Client] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _client() -> Client:
|
|
45
|
+
global _default
|
|
46
|
+
if _default is None:
|
|
47
|
+
_default = Client()
|
|
48
|
+
return _default
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def configure(base_url: Optional[str] = None, timeout: float = 20.0, cache_ttl: Optional[float] = 3600.0) -> Client:
|
|
52
|
+
"""Replace the client the module functions use, and return it."""
|
|
53
|
+
global _default
|
|
54
|
+
_default = Client(base_url=base_url, timeout=timeout, cache_ttl=cache_ttl)
|
|
55
|
+
return _default
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def clear_cache() -> None:
|
|
59
|
+
"""Forget every file the module functions have read."""
|
|
60
|
+
_client().clear_cache()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _bind(name):
|
|
64
|
+
method = getattr(Client, name)
|
|
65
|
+
|
|
66
|
+
@functools.wraps(method)
|
|
67
|
+
def call(*args, **kwargs):
|
|
68
|
+
return getattr(_client(), name)(*args, **kwargs)
|
|
69
|
+
sig = inspect.signature(method)
|
|
70
|
+
call.__signature__ = sig.replace(parameters=list(sig.parameters.values())[1:]) # type: ignore[attr-defined]
|
|
71
|
+
del call.__wrapped__
|
|
72
|
+
return call
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
funds = _bind("funds")
|
|
76
|
+
fund = _bind("fund")
|
|
77
|
+
holdings = _bind("holdings")
|
|
78
|
+
holders = _bind("holders")
|
|
79
|
+
security = _bind("security")
|
|
80
|
+
compare = _bind("compare")
|
|
81
|
+
overlap_matrix = _bind("overlap_matrix")
|
|
82
|
+
look_through = _bind("look_through")
|
|
83
|
+
census = _bind("census")
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Reading the API: one GET per file, standard library only.
|
|
2
|
+
|
|
3
|
+
The API is a set of static files on a CDN, with no key and no quota, so the
|
|
4
|
+
only things worth doing here are naming the program honestly in the
|
|
5
|
+
User-Agent, not fetching the same file twice in a session, retrying once when
|
|
6
|
+
the CDN has a bad moment, and telling a ticker we do not cover apart from a
|
|
7
|
+
network that is down. Nothing else is sent anywhere.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import gzip
|
|
12
|
+
import http.client
|
|
13
|
+
import json
|
|
14
|
+
import socket
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
20
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
21
|
+
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
from .errors import APIError, NotCovered
|
|
24
|
+
|
|
25
|
+
USER_AGENT = f"tickerinside-python/{__version__} (+https://tickerinside.com/api/)"
|
|
26
|
+
DEFAULT_TIMEOUT = 20.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Transport:
|
|
30
|
+
"""GETs files under a base URL and keeps each one in memory, by URL,
|
|
31
|
+
for ``cache_ttl`` seconds (0 turns the cache off, None keeps files for
|
|
32
|
+
the life of the object)."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, base_url: str, timeout: float = DEFAULT_TIMEOUT,
|
|
35
|
+
cache_ttl: Optional[float] = 3600.0, retry_wait: float = 1.0, workers: int = 8):
|
|
36
|
+
self.base_url = base_url.rstrip("/")
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
self.cache_ttl = cache_ttl
|
|
39
|
+
self.retry_wait = retry_wait
|
|
40
|
+
self.workers = max(1, int(workers))
|
|
41
|
+
self._cache: Dict[str, Tuple[float, str]] = {}
|
|
42
|
+
self._lock = threading.Lock()
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------ cache
|
|
45
|
+
|
|
46
|
+
def clear_cache(self) -> None:
|
|
47
|
+
with self._lock:
|
|
48
|
+
self._cache.clear()
|
|
49
|
+
|
|
50
|
+
def _cached(self, url: str) -> Optional[str]:
|
|
51
|
+
if self.cache_ttl == 0:
|
|
52
|
+
return None
|
|
53
|
+
with self._lock:
|
|
54
|
+
hit = self._cache.get(url)
|
|
55
|
+
if hit is None:
|
|
56
|
+
return None
|
|
57
|
+
if self.cache_ttl is not None and time.monotonic() - hit[0] > self.cache_ttl:
|
|
58
|
+
with self._lock:
|
|
59
|
+
self._cache.pop(url, None)
|
|
60
|
+
return None
|
|
61
|
+
return hit[1]
|
|
62
|
+
|
|
63
|
+
def _store(self, url: str, text: str) -> None:
|
|
64
|
+
if self.cache_ttl == 0:
|
|
65
|
+
return
|
|
66
|
+
with self._lock:
|
|
67
|
+
self._cache[url] = (time.monotonic(), text)
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------ reading
|
|
70
|
+
|
|
71
|
+
def url(self, path: str) -> str:
|
|
72
|
+
return self.base_url + path
|
|
73
|
+
|
|
74
|
+
def text(self, url: str, ticker: Optional[str] = None, accept: str = "application/json") -> str:
|
|
75
|
+
"""The body of ``url`` as text. A 404 raises NotCovered when the
|
|
76
|
+
file belongs to a ticker (``ticker`` is given) and the body is the
|
|
77
|
+
API's own not-covered answer, APIError otherwise."""
|
|
78
|
+
hit = self._cached(url)
|
|
79
|
+
if hit is not None:
|
|
80
|
+
return hit
|
|
81
|
+
body = self._get(url, ticker, accept)
|
|
82
|
+
self._store(url, body)
|
|
83
|
+
return body
|
|
84
|
+
|
|
85
|
+
def json(self, path: str, ticker: Optional[str] = None) -> Tuple[Any, str]:
|
|
86
|
+
"""(parsed file, url) for an API path such as "/fund/voo.json"."""
|
|
87
|
+
url = self.url(path)
|
|
88
|
+
body = self.text(url, ticker)
|
|
89
|
+
try:
|
|
90
|
+
return json.loads(body), url
|
|
91
|
+
except ValueError:
|
|
92
|
+
raise APIError(f"{url} did not answer with JSON.", url) from None
|
|
93
|
+
|
|
94
|
+
def many(self, items: Sequence[Tuple[str, Optional[str]]]) -> List[Any]:
|
|
95
|
+
"""Several (path, ticker) reads at once. Each result is (file, url) or
|
|
96
|
+
the NotCovered raised for it; any other error is raised."""
|
|
97
|
+
def one(item):
|
|
98
|
+
try:
|
|
99
|
+
return self.json(item[0], item[1])
|
|
100
|
+
except NotCovered as e:
|
|
101
|
+
return e
|
|
102
|
+
if len(items) <= 1:
|
|
103
|
+
return [one(i) for i in items]
|
|
104
|
+
with ThreadPoolExecutor(max_workers=min(self.workers, len(items))) as pool:
|
|
105
|
+
return list(pool.map(one, items))
|
|
106
|
+
|
|
107
|
+
def _get(self, url: str, ticker: Optional[str], accept: str) -> str:
|
|
108
|
+
req = urllib.request.Request(url, headers={
|
|
109
|
+
"User-Agent": USER_AGENT,
|
|
110
|
+
"Accept": accept,
|
|
111
|
+
"Accept-Encoding": "gzip",
|
|
112
|
+
})
|
|
113
|
+
for attempt in (0, 1):
|
|
114
|
+
try:
|
|
115
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
116
|
+
return _decode(resp.read(), resp.headers.get("Content-Encoding"), url)
|
|
117
|
+
except urllib.error.HTTPError as e:
|
|
118
|
+
status = e.code
|
|
119
|
+
raw = _read_error_body(e)
|
|
120
|
+
if 500 <= status < 600 and attempt == 0:
|
|
121
|
+
if self.retry_wait:
|
|
122
|
+
time.sleep(self.retry_wait)
|
|
123
|
+
continue
|
|
124
|
+
if status == 404:
|
|
125
|
+
if ticker is not None:
|
|
126
|
+
err = _not_covered(ticker, url, raw, e.headers.get("Content-Encoding") if e.headers else None)
|
|
127
|
+
if err is not None:
|
|
128
|
+
raise err
|
|
129
|
+
raise APIError(f"{url} answered HTTP 404, so there is no file at that address; "
|
|
130
|
+
f"check the base URL.", url, status) from None
|
|
131
|
+
raise APIError(f"{url} answered HTTP {status}.", url, status) from None
|
|
132
|
+
except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError,
|
|
133
|
+
http.client.HTTPException, OSError) as e:
|
|
134
|
+
reason = getattr(e, "reason", None) or e
|
|
135
|
+
raise APIError(f"Could not read {url}: {reason}", url) from None
|
|
136
|
+
raise APIError(f"{url} could not be read.", url) # pragma: no cover
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _read_error_body(e: urllib.error.HTTPError) -> bytes:
|
|
140
|
+
try:
|
|
141
|
+
return e.read() or b""
|
|
142
|
+
except Exception:
|
|
143
|
+
return b""
|
|
144
|
+
finally:
|
|
145
|
+
try:
|
|
146
|
+
e.close()
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _decode(raw: bytes, encoding: Optional[str], url: str) -> str:
|
|
152
|
+
if (encoding or "").lower() == "gzip" or raw[:2] == b"\x1f\x8b":
|
|
153
|
+
try:
|
|
154
|
+
raw = gzip.decompress(raw)
|
|
155
|
+
except (OSError, EOFError):
|
|
156
|
+
raise APIError(f"{url} sent a body that could not be decompressed.", url) from None
|
|
157
|
+
try:
|
|
158
|
+
return raw.decode("utf-8")
|
|
159
|
+
except UnicodeDecodeError:
|
|
160
|
+
raise APIError(f"{url} sent a body that is not UTF-8.", url) from None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _not_covered(ticker: str, url: str, raw: bytes, encoding: Optional[str]) -> Optional[NotCovered]:
|
|
164
|
+
"""The NotCovered for a 404, or None when the 404 is not the API's own
|
|
165
|
+
answer about a ticker (a wrong base URL, a proxy's error page), which is
|
|
166
|
+
then reported as an APIError rather than as a fact about the ticker."""
|
|
167
|
+
try:
|
|
168
|
+
body = json.loads(_decode(raw, encoding, url))
|
|
169
|
+
except (APIError, ValueError):
|
|
170
|
+
return None
|
|
171
|
+
if not isinstance(body, dict) or body.get("error") != "not_covered":
|
|
172
|
+
return None
|
|
173
|
+
message = body.get("message") if isinstance(body.get("message"), str) else None
|
|
174
|
+
if not message:
|
|
175
|
+
message = (f"{ticker} is not covered yet. The funds covered today are listed at "
|
|
176
|
+
f"https://tickerinside.com/api/v1/index.json.")
|
|
177
|
+
return NotCovered({ticker: message}, {ticker: url}, {ticker: body})
|