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.
Files changed (93) hide show
  1. equity_aggregator/README.md +49 -39
  2. equity_aggregator/adapters/__init__.py +13 -7
  3. equity_aggregator/adapters/data_sources/__init__.py +4 -6
  4. equity_aggregator/adapters/data_sources/_utils/_client.py +1 -1
  5. equity_aggregator/adapters/data_sources/{authoritative_feeds → _utils}/_record_types.py +1 -1
  6. equity_aggregator/adapters/data_sources/discovery_feeds/__init__.py +17 -0
  7. equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/__init__.py +7 -0
  8. equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/_utils/__init__.py +10 -0
  9. equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/_utils/backoff.py +33 -0
  10. equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/_utils/parser.py +107 -0
  11. equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/intrinio.py +305 -0
  12. equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/session.py +197 -0
  13. equity_aggregator/adapters/data_sources/discovery_feeds/lseg/__init__.py +7 -0
  14. equity_aggregator/adapters/data_sources/discovery_feeds/lseg/_utils/__init__.py +9 -0
  15. equity_aggregator/adapters/data_sources/discovery_feeds/lseg/_utils/backoff.py +33 -0
  16. equity_aggregator/adapters/data_sources/discovery_feeds/lseg/_utils/parser.py +120 -0
  17. equity_aggregator/adapters/data_sources/discovery_feeds/lseg/lseg.py +239 -0
  18. equity_aggregator/adapters/data_sources/discovery_feeds/lseg/session.py +162 -0
  19. equity_aggregator/adapters/data_sources/discovery_feeds/sec/__init__.py +7 -0
  20. equity_aggregator/adapters/data_sources/{authoritative_feeds → discovery_feeds/sec}/sec.py +4 -5
  21. equity_aggregator/adapters/data_sources/discovery_feeds/stock_analysis/__init__.py +7 -0
  22. equity_aggregator/adapters/data_sources/discovery_feeds/stock_analysis/stock_analysis.py +150 -0
  23. equity_aggregator/adapters/data_sources/discovery_feeds/tradingview/__init__.py +5 -0
  24. equity_aggregator/adapters/data_sources/discovery_feeds/tradingview/tradingview.py +275 -0
  25. equity_aggregator/adapters/data_sources/discovery_feeds/xetra/__init__.py +7 -0
  26. equity_aggregator/adapters/data_sources/{authoritative_feeds → discovery_feeds/xetra}/xetra.py +9 -12
  27. equity_aggregator/adapters/data_sources/enrichment_feeds/__init__.py +6 -1
  28. equity_aggregator/adapters/data_sources/enrichment_feeds/gleif/__init__.py +5 -0
  29. equity_aggregator/adapters/data_sources/enrichment_feeds/gleif/api.py +71 -0
  30. equity_aggregator/adapters/data_sources/enrichment_feeds/gleif/download.py +109 -0
  31. equity_aggregator/adapters/data_sources/enrichment_feeds/gleif/gleif.py +195 -0
  32. equity_aggregator/adapters/data_sources/enrichment_feeds/gleif/parser.py +75 -0
  33. equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/__init__.py +1 -1
  34. equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/_utils/__init__.py +11 -0
  35. equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/{utils → _utils}/backoff.py +1 -1
  36. equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/{utils → _utils}/fuzzy.py +28 -26
  37. equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/_utils/json.py +36 -0
  38. equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/api/__init__.py +1 -1
  39. equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/api/{summary.py → quote_summary.py} +44 -30
  40. equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/api/search.py +10 -5
  41. equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/auth.py +130 -0
  42. equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/config.py +3 -3
  43. equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/ranking.py +97 -0
  44. equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/session.py +85 -218
  45. equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/transport.py +191 -0
  46. equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/yfinance.py +413 -0
  47. equity_aggregator/adapters/data_sources/reference_lookup/exchange_rate_api.py +6 -13
  48. equity_aggregator/adapters/data_sources/reference_lookup/openfigi.py +23 -7
  49. equity_aggregator/cli/dispatcher.py +11 -8
  50. equity_aggregator/cli/main.py +14 -5
  51. equity_aggregator/cli/parser.py +1 -1
  52. equity_aggregator/cli/signals.py +32 -0
  53. equity_aggregator/domain/_utils/__init__.py +2 -2
  54. equity_aggregator/domain/_utils/_load_converter.py +30 -21
  55. equity_aggregator/domain/_utils/_merge.py +221 -368
  56. equity_aggregator/domain/_utils/_merge_config.py +205 -0
  57. equity_aggregator/domain/_utils/_strategies.py +180 -0
  58. equity_aggregator/domain/pipeline/resolve.py +17 -11
  59. equity_aggregator/domain/pipeline/runner.py +4 -4
  60. equity_aggregator/domain/pipeline/seed.py +5 -1
  61. equity_aggregator/domain/pipeline/transforms/__init__.py +2 -2
  62. equity_aggregator/domain/pipeline/transforms/canonicalise.py +1 -1
  63. equity_aggregator/domain/pipeline/transforms/enrich.py +328 -285
  64. equity_aggregator/domain/pipeline/transforms/group.py +48 -0
  65. equity_aggregator/logging_config.py +4 -1
  66. equity_aggregator/schemas/__init__.py +11 -5
  67. equity_aggregator/schemas/canonical.py +11 -6
  68. equity_aggregator/schemas/feeds/__init__.py +11 -5
  69. equity_aggregator/schemas/feeds/gleif_feed_data.py +35 -0
  70. equity_aggregator/schemas/feeds/intrinio_feed_data.py +142 -0
  71. equity_aggregator/schemas/feeds/{lse_feed_data.py → lseg_feed_data.py} +85 -52
  72. equity_aggregator/schemas/feeds/sec_feed_data.py +36 -6
  73. equity_aggregator/schemas/feeds/stock_analysis_feed_data.py +107 -0
  74. equity_aggregator/schemas/feeds/tradingview_feed_data.py +144 -0
  75. equity_aggregator/schemas/feeds/xetra_feed_data.py +1 -1
  76. equity_aggregator/schemas/feeds/yfinance_feed_data.py +47 -35
  77. equity_aggregator/schemas/raw.py +5 -3
  78. equity_aggregator/schemas/types.py +7 -0
  79. equity_aggregator/schemas/validators.py +81 -27
  80. equity_aggregator/storage/data_store.py +5 -3
  81. {equity_aggregator-0.1.1.dist-info → equity_aggregator-0.1.5.dist-info}/METADATA +205 -115
  82. equity_aggregator-0.1.5.dist-info/RECORD +103 -0
  83. {equity_aggregator-0.1.1.dist-info → equity_aggregator-0.1.5.dist-info}/WHEEL +1 -1
  84. equity_aggregator/adapters/data_sources/authoritative_feeds/__init__.py +0 -13
  85. equity_aggregator/adapters/data_sources/authoritative_feeds/euronext.py +0 -420
  86. equity_aggregator/adapters/data_sources/authoritative_feeds/lse.py +0 -352
  87. equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/feed.py +0 -350
  88. equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/utils/__init__.py +0 -9
  89. equity_aggregator/domain/pipeline/transforms/deduplicate.py +0 -54
  90. equity_aggregator/schemas/feeds/euronext_feed_data.py +0 -59
  91. equity_aggregator-0.1.1.dist-info/RECORD +0 -72
  92. {equity_aggregator-0.1.1.dist-info → equity_aggregator-0.1.5.dist-info}/entry_points.txt +0 -0
  93. {equity_aggregator-0.1.1.dist-info → equity_aggregator-0.1.5.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
- )
@@ -1,9 +0,0 @@
1
- # utils/__init__.py
2
-
3
- from .backoff import backoff_delays
4
- from .fuzzy import pick_best_symbol
5
-
6
- __all__ = [
7
- "pick_best_symbol",
8
- "backoff_delays",
9
- ]