equity-aggregator 0.1.1__py3-none-any.whl → 0.1.5__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.
- equity_aggregator/README.md +49 -39
- equity_aggregator/adapters/__init__.py +13 -7
- equity_aggregator/adapters/data_sources/__init__.py +4 -6
- equity_aggregator/adapters/data_sources/_utils/_client.py +1 -1
- equity_aggregator/adapters/data_sources/{authoritative_feeds → _utils}/_record_types.py +1 -1
- equity_aggregator/adapters/data_sources/discovery_feeds/__init__.py +17 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/__init__.py +7 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/_utils/__init__.py +10 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/_utils/backoff.py +33 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/_utils/parser.py +107 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/intrinio.py +305 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/session.py +197 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/lseg/__init__.py +7 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/lseg/_utils/__init__.py +9 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/lseg/_utils/backoff.py +33 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/lseg/_utils/parser.py +120 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/lseg/lseg.py +239 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/lseg/session.py +162 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/sec/__init__.py +7 -0
- equity_aggregator/adapters/data_sources/{authoritative_feeds → discovery_feeds/sec}/sec.py +4 -5
- equity_aggregator/adapters/data_sources/discovery_feeds/stock_analysis/__init__.py +7 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/stock_analysis/stock_analysis.py +150 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/tradingview/__init__.py +5 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/tradingview/tradingview.py +275 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/xetra/__init__.py +7 -0
- equity_aggregator/adapters/data_sources/{authoritative_feeds → discovery_feeds/xetra}/xetra.py +9 -12
- equity_aggregator/adapters/data_sources/enrichment_feeds/__init__.py +6 -1
- equity_aggregator/adapters/data_sources/enrichment_feeds/gleif/__init__.py +5 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/gleif/api.py +71 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/gleif/download.py +109 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/gleif/gleif.py +195 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/gleif/parser.py +75 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/__init__.py +1 -1
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/_utils/__init__.py +11 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/{utils → _utils}/backoff.py +1 -1
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/{utils → _utils}/fuzzy.py +28 -26
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/_utils/json.py +36 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/api/__init__.py +1 -1
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/api/{summary.py → quote_summary.py} +44 -30
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/api/search.py +10 -5
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/auth.py +130 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/config.py +3 -3
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/ranking.py +97 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/session.py +85 -218
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/transport.py +191 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/yfinance.py +413 -0
- equity_aggregator/adapters/data_sources/reference_lookup/exchange_rate_api.py +6 -13
- equity_aggregator/adapters/data_sources/reference_lookup/openfigi.py +23 -7
- equity_aggregator/cli/dispatcher.py +11 -8
- equity_aggregator/cli/main.py +14 -5
- equity_aggregator/cli/parser.py +1 -1
- equity_aggregator/cli/signals.py +32 -0
- equity_aggregator/domain/_utils/__init__.py +2 -2
- equity_aggregator/domain/_utils/_load_converter.py +30 -21
- equity_aggregator/domain/_utils/_merge.py +221 -368
- equity_aggregator/domain/_utils/_merge_config.py +205 -0
- equity_aggregator/domain/_utils/_strategies.py +180 -0
- equity_aggregator/domain/pipeline/resolve.py +17 -11
- equity_aggregator/domain/pipeline/runner.py +4 -4
- equity_aggregator/domain/pipeline/seed.py +5 -1
- equity_aggregator/domain/pipeline/transforms/__init__.py +2 -2
- equity_aggregator/domain/pipeline/transforms/canonicalise.py +1 -1
- equity_aggregator/domain/pipeline/transforms/enrich.py +328 -285
- equity_aggregator/domain/pipeline/transforms/group.py +48 -0
- equity_aggregator/logging_config.py +4 -1
- equity_aggregator/schemas/__init__.py +11 -5
- equity_aggregator/schemas/canonical.py +11 -6
- equity_aggregator/schemas/feeds/__init__.py +11 -5
- equity_aggregator/schemas/feeds/gleif_feed_data.py +35 -0
- equity_aggregator/schemas/feeds/intrinio_feed_data.py +142 -0
- equity_aggregator/schemas/feeds/{lse_feed_data.py → lseg_feed_data.py} +85 -52
- equity_aggregator/schemas/feeds/sec_feed_data.py +36 -6
- equity_aggregator/schemas/feeds/stock_analysis_feed_data.py +107 -0
- equity_aggregator/schemas/feeds/tradingview_feed_data.py +144 -0
- equity_aggregator/schemas/feeds/xetra_feed_data.py +1 -1
- equity_aggregator/schemas/feeds/yfinance_feed_data.py +47 -35
- equity_aggregator/schemas/raw.py +5 -3
- equity_aggregator/schemas/types.py +7 -0
- equity_aggregator/schemas/validators.py +81 -27
- equity_aggregator/storage/data_store.py +5 -3
- {equity_aggregator-0.1.1.dist-info → equity_aggregator-0.1.5.dist-info}/METADATA +205 -115
- equity_aggregator-0.1.5.dist-info/RECORD +103 -0
- {equity_aggregator-0.1.1.dist-info → equity_aggregator-0.1.5.dist-info}/WHEEL +1 -1
- equity_aggregator/adapters/data_sources/authoritative_feeds/__init__.py +0 -13
- equity_aggregator/adapters/data_sources/authoritative_feeds/euronext.py +0 -420
- equity_aggregator/adapters/data_sources/authoritative_feeds/lse.py +0 -352
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/feed.py +0 -350
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/utils/__init__.py +0 -9
- equity_aggregator/domain/pipeline/transforms/deduplicate.py +0 -54
- equity_aggregator/schemas/feeds/euronext_feed_data.py +0 -59
- equity_aggregator-0.1.1.dist-info/RECORD +0 -72
- {equity_aggregator-0.1.1.dist-info → equity_aggregator-0.1.5.dist-info}/entry_points.txt +0 -0
- {equity_aggregator-0.1.1.dist-info → equity_aggregator-0.1.5.dist-info}/licenses/LICENCE.txt +0 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# gleif/gleif.py
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import AsyncIterator, Callable
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from equity_aggregator.storage import load_cache, save_cache
|
|
11
|
+
|
|
12
|
+
from .download import download_and_build_index
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@asynccontextmanager
|
|
18
|
+
async def open_gleif_feed(
|
|
19
|
+
*,
|
|
20
|
+
cache_key: str | None = "gleif",
|
|
21
|
+
client_factory: Callable[[], httpx.AsyncClient] | None = None,
|
|
22
|
+
) -> AsyncIterator["GleifFeed"]:
|
|
23
|
+
"""
|
|
24
|
+
Context manager to create a GleifFeed.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
cache_key: Cache key for the index; defaults to "gleif".
|
|
28
|
+
client_factory: Factory for HTTP client; defaults to make_client.
|
|
29
|
+
|
|
30
|
+
Yields:
|
|
31
|
+
GleifFeed with lazy-loaded index.
|
|
32
|
+
"""
|
|
33
|
+
yield GleifFeed(cache_key=cache_key, client_factory=client_factory)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GleifFeed:
|
|
37
|
+
"""
|
|
38
|
+
Async GLEIF feed for LEI enrichment.
|
|
39
|
+
|
|
40
|
+
Provides fetch_equity() to retrieve LEI data by ISIN.
|
|
41
|
+
The ISIN->LEI index is loaded lazily on first call.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
__slots__ = ("_cache_key", "_client_factory", "_index", "_loaded", "_lock")
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
cache_key: str | None,
|
|
50
|
+
client_factory: Callable[[], httpx.AsyncClient] | None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Initialise with lazy loading configuration.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
cache_key: Cache key for the index, or None to disable caching.
|
|
57
|
+
client_factory: Factory for HTTP client, or None for default.
|
|
58
|
+
"""
|
|
59
|
+
self._cache_key = cache_key
|
|
60
|
+
self._client_factory = client_factory
|
|
61
|
+
self._index: dict[str, str] | None = None
|
|
62
|
+
self._loaded = False
|
|
63
|
+
self._lock = asyncio.Lock()
|
|
64
|
+
|
|
65
|
+
async def fetch_equity(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
symbol: str,
|
|
69
|
+
name: str,
|
|
70
|
+
isin: str | None = None,
|
|
71
|
+
**kwargs: object,
|
|
72
|
+
) -> dict[str, object]:
|
|
73
|
+
"""
|
|
74
|
+
Fetch LEI data for an equity using its ISIN.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
symbol: Ticker symbol of the equity.
|
|
78
|
+
name: Full name of the equity.
|
|
79
|
+
isin: ISIN identifier for LEI lookup.
|
|
80
|
+
**kwargs: Additional identifiers (ignored by GLEIF).
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Dict containing name, symbol, and lei.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
LookupError: If no LEI can be found.
|
|
87
|
+
"""
|
|
88
|
+
if isin is None:
|
|
89
|
+
raise LookupError("No ISIN provided for LEI lookup")
|
|
90
|
+
|
|
91
|
+
await self._ensure_index_loaded()
|
|
92
|
+
|
|
93
|
+
if self._index is None:
|
|
94
|
+
raise LookupError("GLEIF index unavailable")
|
|
95
|
+
|
|
96
|
+
lei = self._index.get(isin.upper())
|
|
97
|
+
|
|
98
|
+
if lei is None:
|
|
99
|
+
raise LookupError(f"No LEI found for ISIN {isin}")
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"name": name,
|
|
103
|
+
"symbol": symbol,
|
|
104
|
+
"isin": isin,
|
|
105
|
+
"lei": lei,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async def _ensure_index_loaded(self) -> None:
|
|
109
|
+
"""
|
|
110
|
+
Ensure the ISIN->LEI index is loaded exactly once.
|
|
111
|
+
|
|
112
|
+
Uses a lock to prevent concurrent download attempts when multiple
|
|
113
|
+
tasks call fetch_equity simultaneously before the index is loaded.
|
|
114
|
+
"""
|
|
115
|
+
if self._loaded:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
async with self._lock:
|
|
119
|
+
if self._loaded:
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
self._index = await _get_index(
|
|
123
|
+
self._cache_key,
|
|
124
|
+
client_factory=self._client_factory,
|
|
125
|
+
)
|
|
126
|
+
self._loaded = True
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _get_index(
|
|
130
|
+
cache_key: str | None,
|
|
131
|
+
*,
|
|
132
|
+
client_factory: Callable[[], httpx.AsyncClient] | None = None,
|
|
133
|
+
) -> dict[str, str] | None:
|
|
134
|
+
"""
|
|
135
|
+
Retrieve or build the ISIN->LEI index.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
cache_key: Cache key for the index, or None to disable caching.
|
|
139
|
+
client_factory: Factory for HTTP client, or None for default.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
ISIN->LEI mapping dict, or None if unavailable.
|
|
143
|
+
"""
|
|
144
|
+
cached = _load_from_cache(cache_key)
|
|
145
|
+
if cached is not None:
|
|
146
|
+
return cached
|
|
147
|
+
|
|
148
|
+
return await _download_and_cache(cache_key, client_factory)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _load_from_cache(cache_key: str | None) -> dict[str, str] | None:
|
|
152
|
+
"""
|
|
153
|
+
Load index from cache if available.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
cache_key: Cache key for the index, or None to disable caching.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
ISIN->LEI mapping dict, or None if not cached.
|
|
160
|
+
"""
|
|
161
|
+
if not cache_key:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
cached = load_cache(cache_key)
|
|
165
|
+
if cached is not None:
|
|
166
|
+
logger.info("Loaded %d GLEIF ISIN->LEI mappings from cache.", len(cached))
|
|
167
|
+
|
|
168
|
+
return cached
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def _download_and_cache(
|
|
172
|
+
cache_key: str | None,
|
|
173
|
+
client_factory: Callable[[], httpx.AsyncClient] | None,
|
|
174
|
+
) -> dict[str, str] | None:
|
|
175
|
+
"""
|
|
176
|
+
Download index and save to cache.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
cache_key: Cache key for the index, or None to disable caching.
|
|
180
|
+
client_factory: Factory for HTTP client, or None for default.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
ISIN->LEI mapping dict, or None if download failed.
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
index = await download_and_build_index(client_factory=client_factory)
|
|
187
|
+
except Exception as error:
|
|
188
|
+
logger.error("Failed to build GLEIF ISIN->LEI index: %s", error, exc_info=True)
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
if index and cache_key:
|
|
192
|
+
save_cache(cache_key, index)
|
|
193
|
+
logger.info("Saved %d GLEIF ISIN->LEI mappings to cache.", len(index))
|
|
194
|
+
|
|
195
|
+
return index
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# gleif/parser.py
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_zip(zip_path: Path) -> dict[str, str]:
|
|
10
|
+
"""
|
|
11
|
+
Extract and parse the CSV from a ZIP file into an ISIN->LEI index.
|
|
12
|
+
|
|
13
|
+
Finds the first CSV file in the archive and parses it row by row,
|
|
14
|
+
building a dictionary that maps ISIN codes to LEI codes.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
zip_path: Path to the ZIP file.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Dictionary mapping ISIN codes to LEI codes.
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
ValueError: If no CSV file is found in the archive.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
27
|
+
csv_name = _find_csv(zf)
|
|
28
|
+
if csv_name is None:
|
|
29
|
+
raise ValueError("No CSV file found in GLEIF ZIP archive.")
|
|
30
|
+
|
|
31
|
+
with zf.open(csv_name) as csv_file:
|
|
32
|
+
return _parse_csv(csv_file)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _find_csv(zf: zipfile.ZipFile) -> str | None:
|
|
36
|
+
"""
|
|
37
|
+
Find the first CSV file in a ZIP archive.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
zf: Open ZIP file handle.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Name of the first CSV file found, or None if not found.
|
|
44
|
+
"""
|
|
45
|
+
return next(
|
|
46
|
+
(name for name in zf.namelist() if name.lower().endswith(".csv")),
|
|
47
|
+
None,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_csv(csv_file: io.BufferedReader) -> dict[str, str]:
|
|
52
|
+
"""
|
|
53
|
+
Parse the GLEIF ISIN->LEI CSV file into a look-up dictionary.
|
|
54
|
+
|
|
55
|
+
The CSV has columns: LEI, ISIN.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
csv_file: File-like object for the CSV data.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Dictionary mapping ISIN codes to LEI codes.
|
|
62
|
+
"""
|
|
63
|
+
text_wrapper = io.TextIOWrapper(csv_file, encoding="utf-8")
|
|
64
|
+
reader = csv.DictReader(text_wrapper)
|
|
65
|
+
|
|
66
|
+
index: dict[str, str] = {}
|
|
67
|
+
|
|
68
|
+
for row in reader:
|
|
69
|
+
isin = row.get("ISIN", "").strip().upper() or None
|
|
70
|
+
lei = row.get("LEI", "").strip().upper() or None
|
|
71
|
+
|
|
72
|
+
if isin and lei:
|
|
73
|
+
index[isin] = lei
|
|
74
|
+
|
|
75
|
+
return index
|
|
@@ -1,44 +1,40 @@
|
|
|
1
|
-
#
|
|
1
|
+
# _utils/fuzzy.py
|
|
2
2
|
|
|
3
3
|
from rapidfuzz import fuzz, utils
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
def
|
|
6
|
+
def rank_all_symbols(
|
|
7
7
|
quotes: list[dict],
|
|
8
8
|
*,
|
|
9
9
|
name_key: str,
|
|
10
10
|
expected_name: str,
|
|
11
11
|
expected_symbol: str,
|
|
12
12
|
min_score: int = 0,
|
|
13
|
-
) -> str
|
|
13
|
+
) -> list[str]:
|
|
14
14
|
"""
|
|
15
|
-
|
|
16
|
-
fuzzy matching.
|
|
15
|
+
Rank all matching symbols from a list of Yahoo Finance quotes using fuzzy matching.
|
|
17
16
|
|
|
18
|
-
For each quote,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
quote meets the threshold, None is returned.
|
|
17
|
+
For each quote, computes a combined fuzzy score based on similarity between the
|
|
18
|
+
quote's symbol and expected symbol, and between the quote's name and expected name.
|
|
19
|
+
Returns all symbols that meet or exceed the minimum score threshold, sorted by
|
|
20
|
+
score in descending order (best match first).
|
|
23
21
|
|
|
24
22
|
Args:
|
|
25
23
|
quotes (list[dict]): List of quote dictionaries, each with at least a
|
|
26
24
|
"symbol" key and a name field specified by `name_key`.
|
|
27
|
-
name_key (str): The key in each quote dict for equity name
|
|
28
|
-
(e.g., "longname").
|
|
25
|
+
name_key (str): The key in each quote dict for equity name (e.g., "longname").
|
|
29
26
|
expected_name (str): The expected equity name to match against.
|
|
30
27
|
expected_symbol (str): The expected ticker symbol to match against.
|
|
31
28
|
min_score (int, optional): Minimum combined fuzzy score required to accept a
|
|
32
29
|
match. Defaults to 0.
|
|
33
30
|
|
|
34
31
|
Returns:
|
|
35
|
-
str
|
|
32
|
+
list[str]: Ranked symbols (best first), empty if none meet threshold.
|
|
36
33
|
"""
|
|
37
|
-
|
|
38
34
|
if not quotes:
|
|
39
|
-
return
|
|
35
|
+
return []
|
|
40
36
|
|
|
41
|
-
#
|
|
37
|
+
# Compute fuzzy scores for each quote
|
|
42
38
|
scored = [
|
|
43
39
|
_score_quote(
|
|
44
40
|
quote,
|
|
@@ -49,15 +45,14 @@ def pick_best_symbol(
|
|
|
49
45
|
for quote in quotes
|
|
50
46
|
]
|
|
51
47
|
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return None
|
|
48
|
+
# Filter by minimum score and sort by score descending
|
|
49
|
+
filtered = [
|
|
50
|
+
(score, symbol, name) for score, symbol, name in scored if score >= min_score
|
|
51
|
+
]
|
|
52
|
+
ranked = sorted(filtered, key=lambda t: t[0], reverse=True)
|
|
58
53
|
|
|
59
|
-
#
|
|
60
|
-
return
|
|
54
|
+
# Return symbols in ranked order
|
|
55
|
+
return [symbol for _, symbol, _ in ranked]
|
|
61
56
|
|
|
62
57
|
|
|
63
58
|
def _score_quote(
|
|
@@ -72,8 +67,8 @@ def _score_quote(
|
|
|
72
67
|
|
|
73
68
|
This function calculates the sum of the fuzzy string similarity between the
|
|
74
69
|
quote's symbol and the expected symbol, and between the quote's name (using
|
|
75
|
-
`name_key`) and the expected name.
|
|
76
|
-
|
|
70
|
+
`name_key`) and the expected name. Applies minimum score thresholds to prevent
|
|
71
|
+
matching completely unrelated equities.
|
|
77
72
|
|
|
78
73
|
Args:
|
|
79
74
|
quote (dict): The quote dictionary containing at least a "symbol" key and
|
|
@@ -85,6 +80,7 @@ def _score_quote(
|
|
|
85
80
|
Returns:
|
|
86
81
|
tuple[int, str, str]: A tuple of (total_score, actual_symbol, actual_name),
|
|
87
82
|
where total_score is the sum of the symbol and name fuzzy scores.
|
|
83
|
+
Returns (0, symbol, name) if either score is below the minimum threshold.
|
|
88
84
|
"""
|
|
89
85
|
actual_symbol = quote["symbol"]
|
|
90
86
|
actual_name = quote.get(name_key, "<no-name>")
|
|
@@ -93,12 +89,18 @@ def _score_quote(
|
|
|
93
89
|
actual_symbol,
|
|
94
90
|
expected_symbol,
|
|
95
91
|
processor=utils.default_process,
|
|
92
|
+
score_cutoff=70,
|
|
96
93
|
)
|
|
97
94
|
name_score = fuzz.WRatio(
|
|
98
95
|
actual_name,
|
|
99
96
|
expected_name,
|
|
100
97
|
processor=utils.default_process,
|
|
98
|
+
score_cutoff=70,
|
|
101
99
|
)
|
|
102
100
|
|
|
101
|
+
# Reject if either score is below threshold
|
|
102
|
+
if name_score == 0 or symbol_score == 0:
|
|
103
|
+
return 0, actual_symbol, actual_name
|
|
104
|
+
|
|
103
105
|
total_score = symbol_score + name_score
|
|
104
106
|
return total_score, actual_symbol, actual_name
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# _utils/json.py
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def safe_json_parse(
|
|
7
|
+
response: httpx.Response,
|
|
8
|
+
context: str,
|
|
9
|
+
) -> dict[str, object]:
|
|
10
|
+
"""
|
|
11
|
+
Parse JSON response, raising LookupError on any failure.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
response (httpx.Response): The HTTP response to parse.
|
|
15
|
+
context (str): Context information for error messages (e.g., ticker symbol).
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
dict[str, object]: Parsed JSON data.
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
LookupError: If JSON parsing fails or content-type is invalid.
|
|
22
|
+
"""
|
|
23
|
+
# Validate content-type
|
|
24
|
+
content_type = response.headers.get("content-type", "")
|
|
25
|
+
if "application/json" not in content_type:
|
|
26
|
+
raise LookupError(
|
|
27
|
+
f"Non-JSON response (content-type: {content_type}) for {context}",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Parse JSON
|
|
31
|
+
try:
|
|
32
|
+
return response.json()
|
|
33
|
+
except Exception as exc:
|
|
34
|
+
raise LookupError(
|
|
35
|
+
f"Invalid JSON response from endpoint for {context}",
|
|
36
|
+
) from exc
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
# api/
|
|
1
|
+
# api/quote_summary.py
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
from collections.abc import Iterable, Mapping
|
|
5
5
|
|
|
6
6
|
import httpx
|
|
7
7
|
|
|
8
|
+
from .._utils import safe_json_parse
|
|
8
9
|
from ..session import YFSession
|
|
9
10
|
|
|
10
11
|
logger = logging.getLogger(__name__)
|
|
@@ -23,6 +24,10 @@ async def get_quote_summary(
|
|
|
23
24
|
in a single call, then merges the resulting module dictionaries into a single
|
|
24
25
|
flat mapping for convenience.
|
|
25
26
|
|
|
27
|
+
If the primary endpoint returns 500 (Internal Server Error), automatically
|
|
28
|
+
try the fallback quote endpoint which may have better availability.
|
|
29
|
+
This handles cases where quoteSummary has issues but the fallback endpoint works.
|
|
30
|
+
|
|
26
31
|
Args:
|
|
27
32
|
session (YFSession): The Yahoo Finance session for making HTTP requests.
|
|
28
33
|
ticker (str): The stock symbol to fetch (e.g., "AAPL").
|
|
@@ -36,7 +41,7 @@ async def get_quote_summary(
|
|
|
36
41
|
|
|
37
42
|
modules = tuple(modules or session.config.modules)
|
|
38
43
|
|
|
39
|
-
url = session.config.
|
|
44
|
+
url = session.config.quote_summary_primary_url + ticker
|
|
40
45
|
|
|
41
46
|
response = await session.get(
|
|
42
47
|
url,
|
|
@@ -52,25 +57,22 @@ async def get_quote_summary(
|
|
|
52
57
|
|
|
53
58
|
status = response.status_code
|
|
54
59
|
|
|
55
|
-
#
|
|
56
|
-
if status
|
|
57
|
-
httpx.codes.UNAUTHORIZED,
|
|
58
|
-
httpx.codes.INTERNAL_SERVER_ERROR,
|
|
59
|
-
httpx.codes.BAD_GATEWAY,
|
|
60
|
-
}:
|
|
60
|
+
# 500 → try fallback endpoint
|
|
61
|
+
if status == httpx.codes.INTERNAL_SERVER_ERROR:
|
|
61
62
|
return await _get_quote_summary_fallback(session, ticker)
|
|
62
63
|
|
|
63
|
-
#
|
|
64
|
-
if status
|
|
65
|
-
raise LookupError(f"HTTP
|
|
64
|
+
# Other non-200 status codes are errors
|
|
65
|
+
if status != httpx.codes.OK:
|
|
66
|
+
raise LookupError(f"HTTP {status} from quote summary endpoint for {ticker}")
|
|
67
|
+
|
|
68
|
+
# Parse and flatten the response
|
|
69
|
+
json = safe_json_parse(response, context=f"quote summary for {ticker}")
|
|
70
|
+
raw_data = json.get("quoteSummary", {}).get("result", [])
|
|
66
71
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if raw:
|
|
70
|
-
return _flatten_module_dicts(modules, raw[0])
|
|
72
|
+
if not raw_data:
|
|
73
|
+
return None
|
|
71
74
|
|
|
72
|
-
|
|
73
|
-
raise LookupError("Quote Summary endpoint returned nothing.")
|
|
75
|
+
return _flatten_module_dicts(modules, raw_data[0])
|
|
74
76
|
|
|
75
77
|
|
|
76
78
|
async def _get_quote_summary_fallback(
|
|
@@ -78,33 +80,45 @@ async def _get_quote_summary_fallback(
|
|
|
78
80
|
ticker: str,
|
|
79
81
|
) -> dict[str, object] | None:
|
|
80
82
|
"""
|
|
81
|
-
|
|
83
|
+
Fetch quote data from Yahoo Finance fallback endpoint.
|
|
82
84
|
|
|
83
|
-
This
|
|
84
|
-
|
|
85
|
-
fallback endpoint.
|
|
85
|
+
This endpoint returns a simpler data structure compared to quoteSummary,
|
|
86
|
+
with different field names.
|
|
86
87
|
|
|
87
88
|
Args:
|
|
88
89
|
session (YFSession): The Yahoo Finance session for making HTTP requests.
|
|
89
90
|
ticker (str): The stock symbol to fetch (e.g., "AAPL").
|
|
90
91
|
|
|
91
92
|
Returns:
|
|
92
|
-
dict[str, object] | None:
|
|
93
|
-
|
|
93
|
+
dict[str, object] | None: Quote data from the fallback endpoint,
|
|
94
|
+
or None if no data is found.
|
|
94
95
|
"""
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
url = session.config.quote_summary_fallback_url
|
|
97
|
+
|
|
98
|
+
response = await session.get(
|
|
99
|
+
url,
|
|
97
100
|
params={
|
|
98
|
-
"corsDomain": "finance.yahoo.com",
|
|
99
|
-
"formatted": "false",
|
|
100
101
|
"symbols": ticker,
|
|
102
|
+
"formatted": "false",
|
|
101
103
|
"lang": "en-US",
|
|
102
104
|
"region": "US",
|
|
103
105
|
},
|
|
104
106
|
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
|
|
108
|
+
status = response.status_code
|
|
109
|
+
|
|
110
|
+
if status != httpx.codes.OK:
|
|
111
|
+
raise LookupError(
|
|
112
|
+
f"HTTP {status} from quote fallback endpoint for {ticker}",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
json = safe_json_parse(response, context=f"quote fallback for {ticker}")
|
|
116
|
+
raw_data = json.get("quoteResponse", {}).get("result", [])
|
|
117
|
+
|
|
118
|
+
if raw_data and len(raw_data) > 0:
|
|
119
|
+
return raw_data[0]
|
|
120
|
+
|
|
121
|
+
return None
|
|
108
122
|
|
|
109
123
|
|
|
110
124
|
def _flatten_module_dicts(
|
|
@@ -4,6 +4,7 @@ import logging
|
|
|
4
4
|
|
|
5
5
|
import httpx
|
|
6
6
|
|
|
7
|
+
from .._utils import safe_json_parse
|
|
7
8
|
from ..session import YFSession
|
|
8
9
|
|
|
9
10
|
logger: logging.Logger = logging.getLogger(__name__)
|
|
@@ -27,13 +28,17 @@ async def search_quotes(
|
|
|
27
28
|
|
|
28
29
|
Returns:
|
|
29
30
|
list[dict]: List of quote dictionaries for equities matching the query.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
LookupError: If the search endpoint returns an HTTP error or network
|
|
34
|
+
error occurs.
|
|
30
35
|
"""
|
|
31
36
|
response = await session.get(session.config.search_url, params={"q": query})
|
|
32
37
|
|
|
33
|
-
if response.status_code
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
if response.status_code != httpx.codes.OK:
|
|
39
|
+
raise LookupError(f"Search endpoint returned HTTP {response.status_code}")
|
|
40
|
+
|
|
41
|
+
json = safe_json_parse(response, context=f"search query '{query}'")
|
|
42
|
+
raw_data = json.get("quotes", [])
|
|
36
43
|
|
|
37
|
-
response.raise_for_status() # other statuses are unexpected
|
|
38
|
-
raw_data = response.json().get("quotes", [])
|
|
39
44
|
return [quote for quote in raw_data if quote.get("quoteType") == "EQUITY"]
|