equity-aggregator 0.1.1__py3-none-any.whl → 0.1.4__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 +40 -36
- 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.4.dist-info}/METADATA +205 -115
- equity_aggregator-0.1.4.dist-info/RECORD +103 -0
- {equity_aggregator-0.1.1.dist-info → equity_aggregator-0.1.4.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.4.dist-info}/entry_points.txt +0 -0
- {equity_aggregator-0.1.1.dist-info → equity_aggregator-0.1.4.dist-info}/licenses/LICENCE.txt +0 -0
|
@@ -1,352 +0,0 @@
|
|
|
1
|
-
# authoritative_feeds/lse.py
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
import logging
|
|
5
|
-
|
|
6
|
-
from httpx import AsyncClient
|
|
7
|
-
|
|
8
|
-
from equity_aggregator.adapters.data_sources._utils import make_client
|
|
9
|
-
from equity_aggregator.storage import load_cache, save_cache
|
|
10
|
-
|
|
11
|
-
from ._record_types import (
|
|
12
|
-
EquityRecord,
|
|
13
|
-
RecordStream,
|
|
14
|
-
RecordUniqueKeyExtractor,
|
|
15
|
-
UniqueRecordStream,
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
logger = logging.getLogger(__name__)
|
|
19
|
-
|
|
20
|
-
_LSE_SEARCH_URL = "https://api.londonstockexchange.com/api/v1/components/refresh"
|
|
21
|
-
|
|
22
|
-
_HEADERS = {
|
|
23
|
-
"Accept": "application/json, text/plain, */*",
|
|
24
|
-
"User-Agent": "Mozilla/5.0",
|
|
25
|
-
"Content-Type": "application/json; charset=UTF-8",
|
|
26
|
-
"Referer": "https://www.londonstockexchange.com/",
|
|
27
|
-
"Origin": "https://www.londonstockexchange.com",
|
|
28
|
-
"Cache-Control": "no-cache",
|
|
29
|
-
"Pragma": "no-cache",
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
async def fetch_equity_records(
|
|
34
|
-
client: AsyncClient | None = None,
|
|
35
|
-
*,
|
|
36
|
-
cache_key: str = "lse_records",
|
|
37
|
-
) -> RecordStream:
|
|
38
|
-
"""
|
|
39
|
-
Yield each LSE equity record exactly once, using cache if available.
|
|
40
|
-
|
|
41
|
-
If a cache is present, loads and yields records from cache. Otherwise, streams
|
|
42
|
-
all MICs concurrently, yields records as they arrive, and caches the results.
|
|
43
|
-
|
|
44
|
-
Args:
|
|
45
|
-
client (AsyncClient | None): Optional HTTP client to use for requests.
|
|
46
|
-
cache_key (str): The key under which to cache the records.
|
|
47
|
-
|
|
48
|
-
Yields:
|
|
49
|
-
EquityRecord: Parsed LSE equity record.
|
|
50
|
-
"""
|
|
51
|
-
cached = load_cache(cache_key)
|
|
52
|
-
|
|
53
|
-
if cached:
|
|
54
|
-
logger.info("Loaded %d LSE records from cache.", len(cached))
|
|
55
|
-
for record in cached:
|
|
56
|
-
yield record
|
|
57
|
-
return
|
|
58
|
-
|
|
59
|
-
# use provided client or create a bespoke lse client
|
|
60
|
-
client = client or make_client(headers=_HEADERS)
|
|
61
|
-
|
|
62
|
-
async with client:
|
|
63
|
-
async for record in _stream_and_cache(client, cache_key=cache_key):
|
|
64
|
-
yield record
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
async def _stream_and_cache(
|
|
68
|
-
client: AsyncClient,
|
|
69
|
-
*,
|
|
70
|
-
cache_key: str,
|
|
71
|
-
) -> RecordStream:
|
|
72
|
-
"""
|
|
73
|
-
Asynchronously stream unique LSE equity records, cache them, and yield each.
|
|
74
|
-
|
|
75
|
-
Args:
|
|
76
|
-
client (AsyncClient): The asynchronous HTTP client used for requests.
|
|
77
|
-
cache_key (str): The key under which to cache the records.
|
|
78
|
-
|
|
79
|
-
Yields:
|
|
80
|
-
EquityRecord: Each unique LSE equity record as it is retrieved.
|
|
81
|
-
|
|
82
|
-
Side Effects:
|
|
83
|
-
Saves all streamed records to cache after streaming completes.
|
|
84
|
-
"""
|
|
85
|
-
# collect all records in a buffer to cache them later
|
|
86
|
-
buffer: list[EquityRecord] = []
|
|
87
|
-
|
|
88
|
-
# stream all records concurrently and deduplicate by ISIN
|
|
89
|
-
async for record in _deduplicate_records(lambda record: record["isin"])(
|
|
90
|
-
_stream_all_pages(client),
|
|
91
|
-
):
|
|
92
|
-
buffer.append(record)
|
|
93
|
-
yield record
|
|
94
|
-
|
|
95
|
-
save_cache(cache_key, buffer)
|
|
96
|
-
logger.info("Saved %d LSE records to cache.", len(buffer))
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _deduplicate_records(extract_key: RecordUniqueKeyExtractor) -> UniqueRecordStream:
|
|
100
|
-
"""
|
|
101
|
-
Creates a deduplication coroutine for async iterators of dictionaries, yielding only
|
|
102
|
-
unique records based on a key extracted from each record.
|
|
103
|
-
Args:
|
|
104
|
-
extract_key (RecordUniqueKeyExtractor): A function that takes a
|
|
105
|
-
dictionary record and returns a value used to determine uniqueness.
|
|
106
|
-
Returns:
|
|
107
|
-
UniqueRecordStream: A coroutine that accepts an async iterator of dictionaries,
|
|
108
|
-
yields only unique records, as determined by the extracted key.
|
|
109
|
-
"""
|
|
110
|
-
|
|
111
|
-
async def deduplicator(records: RecordStream) -> RecordStream:
|
|
112
|
-
seen: set[object] = set()
|
|
113
|
-
async for record in records:
|
|
114
|
-
key = extract_key(record)
|
|
115
|
-
if key in seen:
|
|
116
|
-
continue
|
|
117
|
-
seen.add(key)
|
|
118
|
-
yield record
|
|
119
|
-
|
|
120
|
-
return deduplicator
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
async def _stream_all_pages(client: AsyncClient) -> RecordStream:
|
|
124
|
-
"""
|
|
125
|
-
Stream all LSE equity records across all pages.
|
|
126
|
-
|
|
127
|
-
Args:
|
|
128
|
-
client (AsyncClient): The asynchronous HTTP client used for requests.
|
|
129
|
-
|
|
130
|
-
Yields:
|
|
131
|
-
EquityRecord: Each equity record from all pages, as soon as it is available.
|
|
132
|
-
"""
|
|
133
|
-
# shared queue for all producers to enqueue records
|
|
134
|
-
queue: asyncio.Queue[EquityRecord | None] = asyncio.Queue()
|
|
135
|
-
|
|
136
|
-
first_page = await _fetch_page(client, page=1)
|
|
137
|
-
first_page_records = _extract_records(first_page)
|
|
138
|
-
|
|
139
|
-
total_pages = _get_total_pages(first_page)
|
|
140
|
-
|
|
141
|
-
# yield first-page records immediately
|
|
142
|
-
for record in first_page_records:
|
|
143
|
-
yield record
|
|
144
|
-
|
|
145
|
-
logger.debug("LSE page 1 completed")
|
|
146
|
-
|
|
147
|
-
# if there is only a single page, just return early
|
|
148
|
-
if total_pages <= 1:
|
|
149
|
-
return
|
|
150
|
-
|
|
151
|
-
# spawn one producer task per remaining page
|
|
152
|
-
producers = [
|
|
153
|
-
asyncio.create_task(_produce_page(client, page, queue))
|
|
154
|
-
for page in range(2, total_pages + 1)
|
|
155
|
-
]
|
|
156
|
-
|
|
157
|
-
# consume queue until every producer sends its sentinel
|
|
158
|
-
async for record in _consume_queue(queue, expected_sentinels=len(producers)):
|
|
159
|
-
yield record
|
|
160
|
-
|
|
161
|
-
# ensure exceptions (if any) propagate after consumption finishes
|
|
162
|
-
await asyncio.gather(*producers)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
async def _produce_page(
|
|
166
|
-
client: AsyncClient,
|
|
167
|
-
page: int,
|
|
168
|
-
queue: asyncio.Queue[EquityRecord | None],
|
|
169
|
-
) -> None:
|
|
170
|
-
"""
|
|
171
|
-
Fetch a single LSE page, enqueue its records, and signal completion.
|
|
172
|
-
|
|
173
|
-
Args:
|
|
174
|
-
client (AsyncClient): The HTTP client for making requests.
|
|
175
|
-
page (int): The 1-based page number to fetch.
|
|
176
|
-
queue (asyncio.Queue[EquityRecord | None]): Queue to put records and sentinel.
|
|
177
|
-
|
|
178
|
-
Side Effects:
|
|
179
|
-
- Puts each EquityRecord from the page into the queue.
|
|
180
|
-
- Puts None into the queue after all records (even on error) to signal done.
|
|
181
|
-
|
|
182
|
-
Returns:
|
|
183
|
-
None
|
|
184
|
-
"""
|
|
185
|
-
try:
|
|
186
|
-
# stream records from the page and enqueue them
|
|
187
|
-
page_json = await _fetch_page(client, page)
|
|
188
|
-
for record in _extract_records(page_json):
|
|
189
|
-
await queue.put(record)
|
|
190
|
-
|
|
191
|
-
logger.debug("LSE page %s completed", page)
|
|
192
|
-
|
|
193
|
-
except Exception as error:
|
|
194
|
-
logger.fatal("LSE page %s failed: %s", page, error, exc_info=True)
|
|
195
|
-
raise
|
|
196
|
-
|
|
197
|
-
finally:
|
|
198
|
-
await queue.put(None)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
async def _consume_queue(
|
|
202
|
-
queue: asyncio.Queue[EquityRecord | None],
|
|
203
|
-
expected_sentinels: int,
|
|
204
|
-
) -> RecordStream:
|
|
205
|
-
"""
|
|
206
|
-
Yield records from the queue until the expected number of sentinel values (None)
|
|
207
|
-
have been received, indicating all producers are completed.
|
|
208
|
-
|
|
209
|
-
Args:
|
|
210
|
-
queue (asyncio.Queue[EquityRecord | None]): The queue from which to consume
|
|
211
|
-
equity records or sentinel values.
|
|
212
|
-
expected_sentinels (int): The number of sentinel (None) values to wait for
|
|
213
|
-
before stopping iteration.
|
|
214
|
-
|
|
215
|
-
Yields:
|
|
216
|
-
EquityRecord: Each equity record retrieved from the queue, as they arrive.
|
|
217
|
-
"""
|
|
218
|
-
completed = 0
|
|
219
|
-
while completed < expected_sentinels:
|
|
220
|
-
item = await queue.get()
|
|
221
|
-
if item is None:
|
|
222
|
-
completed += 1
|
|
223
|
-
else:
|
|
224
|
-
yield item
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
async def _fetch_page(client: AsyncClient, page: int) -> dict[str, object]:
|
|
228
|
-
"""
|
|
229
|
-
Fetch a single page of results from the LSE feed.
|
|
230
|
-
|
|
231
|
-
Sends a POST request to the LSE search endpoint with the specified page and
|
|
232
|
-
returns the parsed JSON response. HTTP and JSON errors are propagated to the caller.
|
|
233
|
-
|
|
234
|
-
Args:
|
|
235
|
-
client (AsyncClient): The HTTP client used to send the request.
|
|
236
|
-
page (int): The 1-based page number to fetch.
|
|
237
|
-
|
|
238
|
-
Returns:
|
|
239
|
-
dict[str, object]: The parsed JSON response from the LSE feed.
|
|
240
|
-
|
|
241
|
-
httpx.HTTPStatusError: If the response status is not successful.
|
|
242
|
-
httpx.ReadError: If there is a network or connection error.
|
|
243
|
-
ValueError: If the response body cannot be parsed as JSON.
|
|
244
|
-
"""
|
|
245
|
-
response = await client.post(_LSE_SEARCH_URL, json=_build_payload(page))
|
|
246
|
-
response.raise_for_status()
|
|
247
|
-
|
|
248
|
-
try:
|
|
249
|
-
return response.json()[0]
|
|
250
|
-
|
|
251
|
-
except (ValueError, IndexError) as error:
|
|
252
|
-
logger.fatal(
|
|
253
|
-
"LSE JSON decode error at page %s: %s",
|
|
254
|
-
page,
|
|
255
|
-
error,
|
|
256
|
-
exc_info=True,
|
|
257
|
-
)
|
|
258
|
-
raise
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
def _extract_records(page_response_json: dict[str, object]) -> list[EquityRecord]:
|
|
262
|
-
"""
|
|
263
|
-
Normalise raw LSE JSON page data into a list of EquityRecord dictionaries.
|
|
264
|
-
|
|
265
|
-
Args:
|
|
266
|
-
page_response_json (dict[str, object]): Parsed JSON response from a LSE page.
|
|
267
|
-
|
|
268
|
-
Returns:
|
|
269
|
-
list[EquityRecord]: A list of normalised equity records, each as a dictionary
|
|
270
|
-
with standardised keys matching the eurONext schema.
|
|
271
|
-
"""
|
|
272
|
-
rows, _ = _parse_equities(page_response_json)
|
|
273
|
-
records: list[EquityRecord] = []
|
|
274
|
-
|
|
275
|
-
for row in rows:
|
|
276
|
-
record = dict(row)
|
|
277
|
-
record.setdefault("mics", ["XLON"])
|
|
278
|
-
records.append(record)
|
|
279
|
-
|
|
280
|
-
return records
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
def _get_total_pages(page_json: dict[str, object]) -> int:
|
|
284
|
-
"""
|
|
285
|
-
Extract the total number of pages from the first page of LSE results.
|
|
286
|
-
|
|
287
|
-
Args:
|
|
288
|
-
page_json (dict[str, object]): Parsed JSON response from a LSE page.
|
|
289
|
-
|
|
290
|
-
Returns:
|
|
291
|
-
int: The total number of result pages. Returns 1 if not found.
|
|
292
|
-
"""
|
|
293
|
-
_, total_pages = _parse_equities(page_json)
|
|
294
|
-
return int(total_pages or 1)
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
def _build_payload(page: int, page_size: int = 100) -> dict[str, object]:
|
|
298
|
-
"""
|
|
299
|
-
Construct the JSON payload for a LSE search POST request.
|
|
300
|
-
|
|
301
|
-
Args:
|
|
302
|
-
page (int): The 1-based page number to request.
|
|
303
|
-
page_size (int, optional): Number of records per page. Defaults to 100.
|
|
304
|
-
|
|
305
|
-
Returns:
|
|
306
|
-
dict[str, object]: The payload dictionary to send in the POST request.
|
|
307
|
-
"""
|
|
308
|
-
return {
|
|
309
|
-
"path": "live-markets/market-data-dashboard/price-explorer",
|
|
310
|
-
"parameters": (
|
|
311
|
-
"markets%3DMAINMARKET%26categories%3DEQUITY%26indices%3DASX"
|
|
312
|
-
f"%26showonlylse%3Dtrue&page%3D{page}"
|
|
313
|
-
),
|
|
314
|
-
"components": [
|
|
315
|
-
{
|
|
316
|
-
"componentId": "block_content%3A9524a5dd-7053-4f7a-ac75-71d12db796b4",
|
|
317
|
-
"parameters": (
|
|
318
|
-
"markets=MAINMARKET&categories=EQUITY&indices=ASX"
|
|
319
|
-
f"&showonlylse=true&page={page}&size={page_size}"
|
|
320
|
-
),
|
|
321
|
-
},
|
|
322
|
-
],
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def _parse_equities(page_json: dict[str, object]) -> tuple[list[dict], int | None]:
|
|
327
|
-
"""
|
|
328
|
-
Extracts equity data rows and total page count from a LSE price explorer JSON block.
|
|
329
|
-
|
|
330
|
-
Args:
|
|
331
|
-
page_json (dict[str, object]): The JSON dictionary representing a page of
|
|
332
|
-
LSE data, expected to contain a "content" key with blocks.
|
|
333
|
-
|
|
334
|
-
Returns:
|
|
335
|
-
tuple[list[dict], int | None]: A tuple containing:
|
|
336
|
-
- A list of dictionaries, each representing an equity row from the
|
|
337
|
-
price explorer block (empty if not found).
|
|
338
|
-
- The total number of pages as an integer, or None if unavailable.
|
|
339
|
-
"""
|
|
340
|
-
price_explorer_block = next(
|
|
341
|
-
(
|
|
342
|
-
item
|
|
343
|
-
for item in page_json.get("content", [])
|
|
344
|
-
if item.get("name") == "priceexplorersearch"
|
|
345
|
-
),
|
|
346
|
-
None,
|
|
347
|
-
)
|
|
348
|
-
if not price_explorer_block:
|
|
349
|
-
return [], None
|
|
350
|
-
|
|
351
|
-
value_section = price_explorer_block.get("value", {})
|
|
352
|
-
return value_section.get("content", []), value_section.get("totalPages")
|
|
@@ -1,350 +0,0 @@
|
|
|
1
|
-
# yfinance/feed.py
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
5
|
-
from contextlib import asynccontextmanager
|
|
6
|
-
from itertools import filterfalse
|
|
7
|
-
|
|
8
|
-
from equity_aggregator.schemas import YFinanceFeedData
|
|
9
|
-
from equity_aggregator.storage import (
|
|
10
|
-
load_cache_entry,
|
|
11
|
-
save_cache_entry,
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
from .api import (
|
|
15
|
-
get_quote_summary,
|
|
16
|
-
search_quotes,
|
|
17
|
-
)
|
|
18
|
-
from .config import FeedConfig
|
|
19
|
-
from .session import YFSession
|
|
20
|
-
from .utils import pick_best_symbol
|
|
21
|
-
|
|
22
|
-
logger = logging.getLogger(__name__)
|
|
23
|
-
|
|
24
|
-
LookupFn = Callable[..., Awaitable[dict | None]]
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@asynccontextmanager
|
|
28
|
-
async def open_yfinance_feed(
|
|
29
|
-
*,
|
|
30
|
-
config: FeedConfig | None = None,
|
|
31
|
-
) -> AsyncIterator["YFinanceFeed"]:
|
|
32
|
-
"""
|
|
33
|
-
Context manager to create and close a YFinanceFeed instance.
|
|
34
|
-
|
|
35
|
-
Args:
|
|
36
|
-
config (FeedConfig | None, optional): Custom feed configuration; defaults to
|
|
37
|
-
default FeedConfig.
|
|
38
|
-
|
|
39
|
-
Yields:
|
|
40
|
-
YFinanceFeed: An initialised feed with an active session.
|
|
41
|
-
"""
|
|
42
|
-
config = config or FeedConfig()
|
|
43
|
-
session = YFSession(config)
|
|
44
|
-
try:
|
|
45
|
-
yield YFinanceFeed(session, config)
|
|
46
|
-
finally:
|
|
47
|
-
await session.aclose()
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
class YFinanceFeed:
|
|
51
|
-
"""
|
|
52
|
-
Asynchronous Yahoo Finance feed with caching and fuzzy lookup.
|
|
53
|
-
|
|
54
|
-
Provides fetch_equity() to retrieve equity data by symbol, name, ISIN or CUSIP.
|
|
55
|
-
|
|
56
|
-
Attributes:
|
|
57
|
-
_session (YFSession): HTTP session for Yahoo Finance.
|
|
58
|
-
_config (FeedConfig): Endpoints and modules configuration.
|
|
59
|
-
_min_score (int): Minimum fuzzy score threshold.
|
|
60
|
-
"""
|
|
61
|
-
|
|
62
|
-
__slots__ = ("_session", "_config")
|
|
63
|
-
|
|
64
|
-
# Data model associated with the Yahoo Finance feed
|
|
65
|
-
model = YFinanceFeedData
|
|
66
|
-
|
|
67
|
-
# Minimum fuzzy matching score
|
|
68
|
-
_min_score = 150
|
|
69
|
-
|
|
70
|
-
def __init__(self, session: YFSession, config: FeedConfig | None = None) -> None:
|
|
71
|
-
"""
|
|
72
|
-
Initialise with an active YFSession and optional custom FeedConfig.
|
|
73
|
-
|
|
74
|
-
Args:
|
|
75
|
-
session (YFSession): The Yahoo Finance HTTP session.
|
|
76
|
-
config (FeedConfig | None, optional): Feed configuration; defaults to
|
|
77
|
-
session.config.
|
|
78
|
-
"""
|
|
79
|
-
self._session = session
|
|
80
|
-
self._config = config or session.config
|
|
81
|
-
|
|
82
|
-
async def fetch_equity(
|
|
83
|
-
self,
|
|
84
|
-
*,
|
|
85
|
-
symbol: str,
|
|
86
|
-
name: str,
|
|
87
|
-
isin: str | None = None,
|
|
88
|
-
cusip: str | None = None,
|
|
89
|
-
) -> dict | None:
|
|
90
|
-
"""
|
|
91
|
-
Fetch enriched equity data using symbol, name, ISIN, or CUSIP.
|
|
92
|
-
|
|
93
|
-
The method performs the following steps:
|
|
94
|
-
1. Checks for a cached entry for the given symbol and returns it if found.
|
|
95
|
-
2. Attempts an exact lookup using ISIN and CUSIP, if provided.
|
|
96
|
-
3. Falls back to a fuzzy search using the name or symbol.
|
|
97
|
-
4. Raises LookupError if no data is found from any source.
|
|
98
|
-
|
|
99
|
-
Args:
|
|
100
|
-
symbol (str): Ticker symbol of the equity.
|
|
101
|
-
name (str): Full name of the equity.
|
|
102
|
-
isin (str | None): ISIN identifier, if available.
|
|
103
|
-
cusip (str | None): CUSIP identifier, if available.
|
|
104
|
-
|
|
105
|
-
Returns:
|
|
106
|
-
dict | None: Enriched equity data if found, otherwise None.
|
|
107
|
-
|
|
108
|
-
Raises:
|
|
109
|
-
LookupError: If no matching equity data is found.
|
|
110
|
-
"""
|
|
111
|
-
if record := load_cache_entry("yfinance_equities", symbol):
|
|
112
|
-
return record
|
|
113
|
-
|
|
114
|
-
# try identifiers first
|
|
115
|
-
lookups: list[tuple[LookupFn, str]] = [
|
|
116
|
-
(self._try_identifier, identifier)
|
|
117
|
-
for identifier in (isin, cusip)
|
|
118
|
-
if identifier
|
|
119
|
-
]
|
|
120
|
-
|
|
121
|
-
# fallback to fuzzy search
|
|
122
|
-
lookups.append((self._try_name_or_symbol, name or symbol))
|
|
123
|
-
|
|
124
|
-
for fn, arg in lookups:
|
|
125
|
-
try:
|
|
126
|
-
data = await fn(arg, name, symbol)
|
|
127
|
-
except LookupError:
|
|
128
|
-
continue
|
|
129
|
-
if data:
|
|
130
|
-
save_cache_entry("yfinance_equities", symbol, data)
|
|
131
|
-
return data
|
|
132
|
-
|
|
133
|
-
raise LookupError("Quote Summary endpoint returned nothing.")
|
|
134
|
-
|
|
135
|
-
async def _try_identifier(
|
|
136
|
-
self,
|
|
137
|
-
identifier: str,
|
|
138
|
-
expected_name: str,
|
|
139
|
-
expected_symbol: str,
|
|
140
|
-
) -> dict | None:
|
|
141
|
-
"""
|
|
142
|
-
Attempt to fetch equity data from Yahoo Finance using an ISIN or CUSIP.
|
|
143
|
-
|
|
144
|
-
This method:
|
|
145
|
-
1. Searches Yahoo Finance for quotes matching the identifier.
|
|
146
|
-
2. Filters results to those with both a symbol and a name.
|
|
147
|
-
3. Selects the best candidate using fuzzy matching.
|
|
148
|
-
4. Retrieves detailed quote summary data for the chosen symbol.
|
|
149
|
-
|
|
150
|
-
Args:
|
|
151
|
-
identifier (str): The ISIN or CUSIP to search for.
|
|
152
|
-
expected_name (str): The expected company or equity name.
|
|
153
|
-
expected_symbol (str): The expected ticker symbol.
|
|
154
|
-
|
|
155
|
-
Returns:
|
|
156
|
-
dict | None: Detailed equity data if a suitable match is found, else None.
|
|
157
|
-
|
|
158
|
-
Raises:
|
|
159
|
-
LookupError: If no valid candidate is found or quote summary is unavailable.
|
|
160
|
-
"""
|
|
161
|
-
quotes = await search_quotes(self._session, identifier)
|
|
162
|
-
|
|
163
|
-
if not quotes:
|
|
164
|
-
raise LookupError("Quote Search endpoint returned nothing.")
|
|
165
|
-
|
|
166
|
-
viable = _filter_equities(quotes)
|
|
167
|
-
|
|
168
|
-
if not viable:
|
|
169
|
-
raise LookupError("No viable candidates found.")
|
|
170
|
-
|
|
171
|
-
chosen = _choose_symbol(
|
|
172
|
-
viable,
|
|
173
|
-
expected_name=expected_name,
|
|
174
|
-
expected_symbol=expected_symbol,
|
|
175
|
-
min_score=self._min_score,
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
if not chosen:
|
|
179
|
-
raise LookupError("Low Fuzzy Score.")
|
|
180
|
-
|
|
181
|
-
info = await get_quote_summary(
|
|
182
|
-
self._session,
|
|
183
|
-
chosen,
|
|
184
|
-
modules=self._config.modules,
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
if info is None:
|
|
188
|
-
raise LookupError("Quote Summary endpoint returned nothing.")
|
|
189
|
-
|
|
190
|
-
return info
|
|
191
|
-
|
|
192
|
-
async def _try_name_or_symbol(
|
|
193
|
-
self,
|
|
194
|
-
query: str,
|
|
195
|
-
expected_name: str,
|
|
196
|
-
expected_symbol: str,
|
|
197
|
-
) -> dict | None:
|
|
198
|
-
"""
|
|
199
|
-
Attempt to retrieve a quote summary for an equity using a name or symbol query.
|
|
200
|
-
|
|
201
|
-
This method searches Yahoo Finance using the provided query string and the
|
|
202
|
-
expected symbol. For each search term, it:
|
|
203
|
-
1. Retrieves quote candidates.
|
|
204
|
-
2. Filters out entries lacking a name or symbol.
|
|
205
|
-
3. Selects the best match using fuzzy logic.
|
|
206
|
-
4. Fetches and returns the detailed quote summary for the chosen symbol.
|
|
207
|
-
|
|
208
|
-
Args:
|
|
209
|
-
query (str): Primary search string, typically a company name or symbol.
|
|
210
|
-
expected_name (str): Expected equity name for fuzzy matching.
|
|
211
|
-
expected_symbol (str): Expected ticker symbol for fuzzy matching.
|
|
212
|
-
|
|
213
|
-
Returns:
|
|
214
|
-
dict | None: Quote summary dictionary if a suitable match is found,
|
|
215
|
-
otherwise None.
|
|
216
|
-
|
|
217
|
-
Raises:
|
|
218
|
-
LookupError: If no suitable candidate is found after all queries.
|
|
219
|
-
"""
|
|
220
|
-
|
|
221
|
-
searches = tuple(dict.fromkeys((query, expected_symbol)))
|
|
222
|
-
|
|
223
|
-
for term in searches:
|
|
224
|
-
# search for quotes
|
|
225
|
-
quotes = await search_quotes(self._session, term)
|
|
226
|
-
if not quotes:
|
|
227
|
-
continue
|
|
228
|
-
|
|
229
|
-
# filter out any without name or symbol
|
|
230
|
-
viable = _filter_equities(quotes)
|
|
231
|
-
if not viable:
|
|
232
|
-
continue
|
|
233
|
-
|
|
234
|
-
# pick best symbol via fuzzy matching
|
|
235
|
-
symbol = _choose_symbol(
|
|
236
|
-
viable,
|
|
237
|
-
expected_name=expected_name,
|
|
238
|
-
expected_symbol=expected_symbol,
|
|
239
|
-
min_score=self._min_score,
|
|
240
|
-
)
|
|
241
|
-
if not symbol:
|
|
242
|
-
continue
|
|
243
|
-
|
|
244
|
-
# fetch and return the quote summary
|
|
245
|
-
return await get_quote_summary(
|
|
246
|
-
self._session,
|
|
247
|
-
symbol,
|
|
248
|
-
modules=self._config.modules,
|
|
249
|
-
)
|
|
250
|
-
|
|
251
|
-
# Nothing matched
|
|
252
|
-
raise LookupError("No candidate matched.")
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def _filter_equities(quotes: list[dict]) -> list[dict]:
|
|
256
|
-
"""
|
|
257
|
-
Filter out any quotes lacking a longname or symbol.
|
|
258
|
-
|
|
259
|
-
Note:
|
|
260
|
-
The Yahoo Finance search quote query endpoint returns 'longname' and 'shortname'
|
|
261
|
-
fields in lowercase.
|
|
262
|
-
|
|
263
|
-
Args:
|
|
264
|
-
quotes (list[dict]): Raw list of quote dicts from Yahoo Finance.
|
|
265
|
-
|
|
266
|
-
Returns:
|
|
267
|
-
list[dict]: Only those quotes that have both 'longname' and 'symbol'.
|
|
268
|
-
"""
|
|
269
|
-
return [
|
|
270
|
-
quote
|
|
271
|
-
for quote in quotes
|
|
272
|
-
if (quote.get("longname") or quote.get("shortname")) and quote.get("symbol")
|
|
273
|
-
]
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def _choose_symbol(
|
|
277
|
-
viable: list[dict],
|
|
278
|
-
*,
|
|
279
|
-
expected_name: str,
|
|
280
|
-
expected_symbol: str,
|
|
281
|
-
min_score: int,
|
|
282
|
-
) -> str | None:
|
|
283
|
-
"""
|
|
284
|
-
Select the most appropriate symbol from a list of viable Yahoo Finance quote dicts.
|
|
285
|
-
|
|
286
|
-
If only one candidate is present, its symbol is returned. If multiple candidates
|
|
287
|
-
exist, the function attempts to select the best match by comparing the expected
|
|
288
|
-
name and symbol to the 'longname' and 'shortname' fields of each candidate. If
|
|
289
|
-
all candidates share the same name, the first such symbol is returned. Otherwise,
|
|
290
|
-
fuzzy matching is performed using pick_best_symbol, which considers the expected
|
|
291
|
-
name, expected symbol, and a minimum score threshold.
|
|
292
|
-
|
|
293
|
-
Args:
|
|
294
|
-
viable (list[dict]): List of filtered Yahoo Finance quote dictionaries.
|
|
295
|
-
expected_name (str): Expected company or equity name for fuzzy matching.
|
|
296
|
-
expected_symbol (str): Expected ticker symbol for fuzzy matching.
|
|
297
|
-
min_score (int): Minimum fuzzy score required to accept a match.
|
|
298
|
-
|
|
299
|
-
Returns:
|
|
300
|
-
str | None: The selected symbol if a suitable candidate is found, else None.
|
|
301
|
-
"""
|
|
302
|
-
|
|
303
|
-
# if there’s only one candidate, return its symbol immediately
|
|
304
|
-
if len(viable) == 1:
|
|
305
|
-
return viable[0]["symbol"]
|
|
306
|
-
|
|
307
|
-
def select_best_symbol(name_key: str) -> str | None:
|
|
308
|
-
"""
|
|
309
|
-
Selects the best symbol from a list of candidates based on provided name key.
|
|
310
|
-
|
|
311
|
-
Examines the specified name field (e.g., 'longname' or 'shortname')
|
|
312
|
-
across all viable candidates. If all candidate names are identical, it returns
|
|
313
|
-
the corresponding symbol. Otherwise, it applies fuzzy matching against the
|
|
314
|
-
expected name or symbol to determine the best match.
|
|
315
|
-
|
|
316
|
-
Args:
|
|
317
|
-
name_key (str): The key in each candidate dict to use for name comparison
|
|
318
|
-
(e.g., 'longname' or 'shortname').
|
|
319
|
-
|
|
320
|
-
Returns:
|
|
321
|
-
str | None: Selected symbol if suitable candidate is found, otherwise None.
|
|
322
|
-
"""
|
|
323
|
-
|
|
324
|
-
# gather all names under the given key
|
|
325
|
-
candidate_names = [quote[name_key] for quote in viable if quote.get(name_key)]
|
|
326
|
-
|
|
327
|
-
if not candidate_names:
|
|
328
|
-
return None
|
|
329
|
-
|
|
330
|
-
# all names identical → pick first matching symbol
|
|
331
|
-
if len({*candidate_names}) == 1:
|
|
332
|
-
return next(quote["symbol"] for quote in viable if quote.get(name_key))
|
|
333
|
-
|
|
334
|
-
# otherwise perform fuzzy matching
|
|
335
|
-
return pick_best_symbol(
|
|
336
|
-
viable,
|
|
337
|
-
name_key=name_key,
|
|
338
|
-
expected_name=expected_name,
|
|
339
|
-
expected_symbol=expected_symbol,
|
|
340
|
-
min_score=min_score,
|
|
341
|
-
)
|
|
342
|
-
|
|
343
|
-
# try 'longname' then 'shortname', return first non-None result
|
|
344
|
-
return next(
|
|
345
|
-
filterfalse(
|
|
346
|
-
lambda x: x is None,
|
|
347
|
-
map(select_best_symbol, ("longname", "shortname")),
|
|
348
|
-
),
|
|
349
|
-
None,
|
|
350
|
-
)
|