tickerinside 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.
- tickerinside/__init__.py +83 -0
- tickerinside/_http.py +177 -0
- tickerinside/_math.py +276 -0
- tickerinside/_version.py +1 -0
- tickerinside/client.py +670 -0
- tickerinside/errors.py +71 -0
- tickerinside/py.typed +0 -0
- tickerinside/result.py +116 -0
- tickerinside-0.1.0.dist-info/METADATA +121 -0
- tickerinside-0.1.0.dist-info/RECORD +12 -0
- tickerinside-0.1.0.dist-info/WHEEL +4 -0
- tickerinside-0.1.0.dist-info/licenses/LICENSE +21 -0
tickerinside/__init__.py
ADDED
|
@@ -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")
|
tickerinside/_http.py
ADDED
|
@@ -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})
|
tickerinside/_math.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""The arithmetic, ported from the MCP server's math.js and tools.js.
|
|
2
|
+
|
|
3
|
+
Every figure this package computes (overlap, correlation, look-through) must
|
|
4
|
+
equal the figure the TickerInside MCP server and website give for the same
|
|
5
|
+
files, to the last digit. Three details of JavaScript are reproduced for that:
|
|
6
|
+
|
|
7
|
+
- Rounding is Math.round, which sends a half up towards positive infinity,
|
|
8
|
+
where Python's round() sends it to the even neighbour.
|
|
9
|
+
- An object's keys are walked in JavaScript's order: keys that are array
|
|
10
|
+
indices ("1", "7203") first, in ascending numeric order, then the others in
|
|
11
|
+
the order the file lists them. Sums of floating point numbers depend on
|
|
12
|
+
their order, and so does the order of ties after a stable sort.
|
|
13
|
+
- A null inside a weekly series counts as 0 in arithmetic, as it does there.
|
|
14
|
+
|
|
15
|
+
The tests compare every function here with values node computed on math.js
|
|
16
|
+
and tools.js for the same files.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
import re
|
|
22
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple
|
|
23
|
+
|
|
24
|
+
_INDEX = re.compile(r"(?:0|[1-9][0-9]*)", re.ASCII)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def js_round(x: Optional[float], d: int) -> Optional[float]:
|
|
28
|
+
"""math.js r(x, d): Math.round(x * 10**d) / 10**d, null for null or NaN."""
|
|
29
|
+
if x is None:
|
|
30
|
+
return None
|
|
31
|
+
x = float(x)
|
|
32
|
+
if math.isnan(x):
|
|
33
|
+
return None
|
|
34
|
+
if math.isinf(x):
|
|
35
|
+
return x
|
|
36
|
+
m = math.pow(10, d)
|
|
37
|
+
y = x * m
|
|
38
|
+
if math.isinf(y):
|
|
39
|
+
return x
|
|
40
|
+
f = math.floor(y)
|
|
41
|
+
n = f + 1 if y - f >= 0.5 else f
|
|
42
|
+
return n / m
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def js_keys(obj: Mapping[str, Any]) -> List[str]:
|
|
46
|
+
"""The keys of a parsed JSON object in the order JavaScript walks them."""
|
|
47
|
+
idx, rest = [], []
|
|
48
|
+
for k in obj:
|
|
49
|
+
if _INDEX.fullmatch(k) and int(k) < 4294967295:
|
|
50
|
+
idx.append(k)
|
|
51
|
+
else:
|
|
52
|
+
rest.append(k)
|
|
53
|
+
idx.sort(key=int)
|
|
54
|
+
return idx + rest
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def overlap(ha: Mapping[str, float], hb: Mapping[str, float],
|
|
58
|
+
skip: Optional[Set[str]] = None) -> Dict[str, Any]:
|
|
59
|
+
"""math.js overlap(): by weight, and the share of each fund held in common.
|
|
60
|
+
|
|
61
|
+
by_weight is the sum of the smaller weight each shared company has in the
|
|
62
|
+
two funds, and it is symmetric; share_of_a and share_of_b are not.
|
|
63
|
+
skip lists tickers both funds hold for two different companies, which are
|
|
64
|
+
left out of every figure and listed apart.
|
|
65
|
+
"""
|
|
66
|
+
by_weight = sa = sb = wa = wb = 0.0
|
|
67
|
+
shared, apart = [], []
|
|
68
|
+
ka = js_keys(ha)
|
|
69
|
+
for k in ka:
|
|
70
|
+
wa += ha[k]
|
|
71
|
+
for k in js_keys(hb):
|
|
72
|
+
wb += hb[k]
|
|
73
|
+
for k in ka:
|
|
74
|
+
if k not in hb:
|
|
75
|
+
continue
|
|
76
|
+
if skip and k in skip:
|
|
77
|
+
apart.append(k)
|
|
78
|
+
continue
|
|
79
|
+
m = min(ha[k], hb[k])
|
|
80
|
+
by_weight += m
|
|
81
|
+
sa += ha[k]
|
|
82
|
+
sb += hb[k]
|
|
83
|
+
shared.append({"ticker": k, "in_a": js_round(ha[k], 2), "in_b": js_round(hb[k], 2),
|
|
84
|
+
"counted": js_round(m, 2)})
|
|
85
|
+
shared.sort(key=lambda x: -x["counted"])
|
|
86
|
+
out = {
|
|
87
|
+
"by_weight": js_round(by_weight, 1),
|
|
88
|
+
"share_of_a": js_round(sa / wa * 100, 1) if wa else None,
|
|
89
|
+
"share_of_b": js_round(sb / wb * 100, 1) if wb else None,
|
|
90
|
+
"companies_in_both": len(shared),
|
|
91
|
+
"positions_a": len(ha),
|
|
92
|
+
"positions_b": len(hb),
|
|
93
|
+
"largest_shared": shared[:15],
|
|
94
|
+
}
|
|
95
|
+
if apart:
|
|
96
|
+
out["same_ticker_two_companies"] = sorted(apart)
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _num(x: Any) -> float:
|
|
101
|
+
return 0.0 if x is None else float(x)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def covariance(series: Sequence[Sequence[Optional[float]]]) -> Optional[Dict[str, Any]]:
|
|
105
|
+
"""math.js covariance(): annualised covariance in percent squared, on the
|
|
106
|
+
window every series shares (their common tail). None under 52 weeks."""
|
|
107
|
+
n = len(series)
|
|
108
|
+
if not n:
|
|
109
|
+
return None
|
|
110
|
+
ln = min(len(s) for s in series)
|
|
111
|
+
if not ln or ln < 52:
|
|
112
|
+
return None
|
|
113
|
+
rs = [[_num(x) for x in s[len(s) - ln:]] for s in series]
|
|
114
|
+
mean = []
|
|
115
|
+
for s in rs:
|
|
116
|
+
acc = 0.0
|
|
117
|
+
for x in s:
|
|
118
|
+
acc += x
|
|
119
|
+
mean.append(acc / ln)
|
|
120
|
+
S = []
|
|
121
|
+
for i in range(n):
|
|
122
|
+
row = []
|
|
123
|
+
for j in range(n):
|
|
124
|
+
acc = 0.0
|
|
125
|
+
for k in range(ln):
|
|
126
|
+
acc += (rs[i][k] - mean[i]) * (rs[j][k] - mean[j])
|
|
127
|
+
row.append(acc / (ln - 1) * 52 * 10000)
|
|
128
|
+
S.append(row)
|
|
129
|
+
return {"S": S, "weeks": ln}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def correlation(a: Optional[Sequence[Optional[float]]],
|
|
133
|
+
b: Optional[Sequence[Optional[float]]]) -> Tuple[Optional[float], Optional[float], Optional[int]]:
|
|
134
|
+
"""(correlation, covariance, weeks) of two weekly series, as compare_funds
|
|
135
|
+
rounds them, or (None, None, None) when either is missing or short."""
|
|
136
|
+
cov = covariance([a or [], b or []])
|
|
137
|
+
if not cov:
|
|
138
|
+
return None, None, None
|
|
139
|
+
S = cov["S"]
|
|
140
|
+
den = math.sqrt(S[0][0] * S[1][1])
|
|
141
|
+
corr = S[0][1] / den if den else float("nan")
|
|
142
|
+
return js_round(corr, 2), js_round(S[0][1], 1), cov["weeks"]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def look_through(rows: Sequence[Dict[str, Any]], by_ticker: Mapping[str, Mapping[str, Any]]) -> Dict[str, Any]:
|
|
146
|
+
"""math.js lookThrough(): the companies under a list of funds, in weight
|
|
147
|
+
and, when amounts are given, in money, with the funds each comes through.
|
|
148
|
+
|
|
149
|
+
rows are {"ticker", "amount"} with amount None when not given; by_ticker
|
|
150
|
+
maps a fund ticker to its fund file (only "holdings" is read).
|
|
151
|
+
"""
|
|
152
|
+
priced = any((p.get("amount") or 0) > 0 for p in rows)
|
|
153
|
+
total = 0
|
|
154
|
+
for p in rows:
|
|
155
|
+
total = total + (p.get("amount") or 0)
|
|
156
|
+
if priced:
|
|
157
|
+
w = [(p.get("amount") or 0) / (total or 1) for p in rows]
|
|
158
|
+
else:
|
|
159
|
+
w = [1 / len(rows) for _ in rows]
|
|
160
|
+
look: Dict[str, Dict[str, Any]] = {}
|
|
161
|
+
for i, p in enumerate(rows):
|
|
162
|
+
h = (by_ticker.get(p["ticker"]) or {}).get("holdings") or {}
|
|
163
|
+
for k in js_keys(h):
|
|
164
|
+
e = look.get(k)
|
|
165
|
+
if e is None:
|
|
166
|
+
e = look[k] = {"ticker": k, "weight_pct": 0.0, "through": []}
|
|
167
|
+
part = h[k] / 100 * w[i]
|
|
168
|
+
e["weight_pct"] += part * 100
|
|
169
|
+
e["through"].append({"fund": p["ticker"], "weight_pct": js_round(part * 100, 4)})
|
|
170
|
+
names = []
|
|
171
|
+
for k in js_keys(look):
|
|
172
|
+
x = look[k]
|
|
173
|
+
names.append({
|
|
174
|
+
"ticker": x["ticker"],
|
|
175
|
+
"weight_pct": js_round(x["weight_pct"], 3),
|
|
176
|
+
"through": sorted(x["through"], key=lambda t: -t["weight_pct"]),
|
|
177
|
+
"value": js_round(x["weight_pct"] / 100 * total, 2) if priced else None,
|
|
178
|
+
})
|
|
179
|
+
names.sort(key=lambda x: -x["weight_pct"])
|
|
180
|
+
return {"priced": priced, "total": total, "weights": w, "companies": names}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------ one ticker, two companies
|
|
184
|
+
#
|
|
185
|
+
# From tools.js. A fund from SEC filings lists under "abroad" the tickers it
|
|
186
|
+
# holds for a foreign company's local code where the US list gives the code
|
|
187
|
+
# to another company: Allianz is ALV in Frankfurt, ALV in New York is Autoliv.
|
|
188
|
+
# Two funds holding such a ticker can hold two companies, so the ticker is not
|
|
189
|
+
# counted as held in common, and a look-through lists it twice.
|
|
190
|
+
|
|
191
|
+
US_ONLY = frozenset(["Large Blend", "Large Growth", "Large Value", "Mid-Cap Blend", "Mid-Cap Growth",
|
|
192
|
+
"Mid-Cap Value", "Small Blend", "Small Growth", "Small Value"])
|
|
193
|
+
|
|
194
|
+
TWO_COMPANIES = ("The same ticker stands for a foreign company's local code in one fund and for the US "
|
|
195
|
+
"company of that ticker in another, so it is not counted as held in common.")
|
|
196
|
+
|
|
197
|
+
SPLIT_NOTE = ("Each of these tickers is listed twice: once for the US company of that ticker, and once, "
|
|
198
|
+
"marked listed_abroad, for the foreign company whose local code it is in the other funds. "
|
|
199
|
+
"A line marked listed_abroad is never the US company of its ticker.")
|
|
200
|
+
|
|
201
|
+
ABROAD_KEY = "\u0000abroad"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def is_sec(f: Optional[Mapping[str, Any]]) -> bool:
|
|
205
|
+
return bool(f) and f.get("source_layer") == "sec_nport"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def needs_categories(files: Mapping[str, Mapping[str, Any]]) -> bool:
|
|
209
|
+
"""Whether the categories from funds.json are needed: only when a fund
|
|
210
|
+
from SEC filings lists a foreign code."""
|
|
211
|
+
return any(is_sec(f) and (f.get("abroad") or []) for f in files.values())
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def reading_of(f: Mapping[str, Any], cat: Optional[str], k: str) -> Optional[str]:
|
|
215
|
+
if is_sec(f):
|
|
216
|
+
return "abroad" if k in (f.get("abroad") or []) else "us"
|
|
217
|
+
return "us" if cat in US_ONLY else None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def two_companies(a: str, b: str, files: Mapping[str, Mapping[str, Any]],
|
|
221
|
+
cats: Mapping[str, Optional[str]]) -> Set[str]:
|
|
222
|
+
fa, fb = files[a], files[b]
|
|
223
|
+
codes = (list(fa.get("abroad") or []) if is_sec(fa) else []) + (list(fb.get("abroad") or []) if is_sec(fb) else [])
|
|
224
|
+
out: Set[str] = set()
|
|
225
|
+
ha, hb = fa.get("holdings") or {}, fb.get("holdings") or {}
|
|
226
|
+
for k in codes:
|
|
227
|
+
if k not in ha or k not in hb:
|
|
228
|
+
continue
|
|
229
|
+
ra, rb = reading_of(fa, cats.get(a), k), reading_of(fb, cats.get(b), k)
|
|
230
|
+
if ra and rb and ra != rb:
|
|
231
|
+
out.add(k)
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def split_abroad(tickers: Sequence[str], files: Mapping[str, Mapping[str, Any]],
|
|
236
|
+
cats: Mapping[str, Optional[str]]) -> Tuple[Dict[str, Mapping[str, Any]], List[str]]:
|
|
237
|
+
codes: List[str] = []
|
|
238
|
+
for t in tickers:
|
|
239
|
+
if is_sec(files[t]):
|
|
240
|
+
for k in files[t].get("abroad") or []:
|
|
241
|
+
if k not in codes:
|
|
242
|
+
codes.append(k)
|
|
243
|
+
if not codes:
|
|
244
|
+
return dict(files), []
|
|
245
|
+
split = [k for k in codes
|
|
246
|
+
if any(k in (files[t].get("holdings") or {}) and reading_of(files[t], cats.get(t), k) == "us"
|
|
247
|
+
for t in tickers)]
|
|
248
|
+
out = dict(files)
|
|
249
|
+
for t in tickers:
|
|
250
|
+
h = files[t].get("holdings") or {}
|
|
251
|
+
moved = [k for k in codes if k in h and reading_of(files[t], cats.get(t), k) != "us"]
|
|
252
|
+
if not moved:
|
|
253
|
+
continue
|
|
254
|
+
copy = dict(h)
|
|
255
|
+
for k in moved:
|
|
256
|
+
copy[k + ABROAD_KEY] = copy[k]
|
|
257
|
+
del copy[k]
|
|
258
|
+
f = dict(files[t])
|
|
259
|
+
f["holdings"] = copy
|
|
260
|
+
out[t] = f
|
|
261
|
+
return out, split
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def unkey(c: Dict[str, Any]) -> Dict[str, Any]:
|
|
265
|
+
if c["ticker"].endswith(ABROAD_KEY):
|
|
266
|
+
c = dict(c)
|
|
267
|
+
c["ticker"] = c["ticker"][:-len(ABROAD_KEY)]
|
|
268
|
+
c["listed_abroad"] = True
|
|
269
|
+
return c
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def top_sum(values: Iterable[float]) -> float:
|
|
273
|
+
acc = 0.0
|
|
274
|
+
for v in values:
|
|
275
|
+
acc += v
|
|
276
|
+
return acc
|
tickerinside/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|