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.
Files changed (93) hide show
  1. equity_aggregator/README.md +40 -36
  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.4.dist-info}/METADATA +205 -115
  82. equity_aggregator-0.1.4.dist-info/RECORD +103 -0
  83. {equity_aggregator-0.1.1.dist-info → equity_aggregator-0.1.4.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.4.dist-info}/entry_points.txt +0 -0
  93. {equity_aggregator-0.1.1.dist-info → equity_aggregator-0.1.4.dist-info}/licenses/LICENCE.txt +0 -0
@@ -0,0 +1,191 @@
1
+ # yfinance/transport.py
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import Callable
6
+
7
+ import httpx
8
+
9
+ from equity_aggregator.adapters.data_sources._utils import make_client
10
+
11
+ logger: logging.Logger = logging.getLogger(__name__)
12
+
13
+ # Type aliases
14
+ OnResetFn = Callable[[], None]
15
+ ClientFactory = Callable[[], httpx.AsyncClient]
16
+
17
+
18
+ class HttpTransport:
19
+ """
20
+ Manages HTTP client lifecycle with automatic recovery on transport errors.
21
+
22
+ When a request fails due to transport errors (connection failures, protocol
23
+ errors, timeouts), the transport resets the client and retries. Uses
24
+ optimistic concurrency: if another task already reset the client, the retry
25
+ is "free" (doesn't consume budget).
26
+
27
+ Args:
28
+ client (httpx.AsyncClient | None): Optional pre-configured HTTP client.
29
+ on_reset (OnResetFn | None): Optional callback invoked after client reset.
30
+ client_factory (ClientFactory | None): Optional factory function to create
31
+ new clients during reset. Defaults to make_client.
32
+
33
+ Returns:
34
+ None
35
+ """
36
+
37
+ __slots__ = ("_client", "_client_factory", "_lock", "_on_reset", "_ready")
38
+
39
+ def __init__(
40
+ self,
41
+ client: httpx.AsyncClient | None = None,
42
+ on_reset: OnResetFn | None = None,
43
+ *,
44
+ client_factory: ClientFactory | None = None,
45
+ ) -> None:
46
+ """
47
+ Initialise HttpTransport with optional client and reset callback.
48
+
49
+ Args:
50
+ client (httpx.AsyncClient | None): Optional pre-configured HTTP client.
51
+ on_reset (OnResetFn | None): Optional callback invoked after client reset.
52
+ client_factory (ClientFactory | None): Optional factory function to create
53
+ new clients during reset. Defaults to make_client.
54
+
55
+ Returns:
56
+ None
57
+ """
58
+ self._client_factory: ClientFactory = client_factory or make_client
59
+ self._client: httpx.AsyncClient = client or self._client_factory()
60
+ self._lock: asyncio.Lock = asyncio.Lock()
61
+ self._ready: asyncio.Event = asyncio.Event()
62
+ self._ready.set()
63
+ self._on_reset: OnResetFn | None = on_reset
64
+
65
+ async def aclose(self) -> None:
66
+ """
67
+ Close the underlying HTTP client.
68
+
69
+ Args:
70
+ None
71
+
72
+ Returns:
73
+ None
74
+ """
75
+ async with self._lock:
76
+ client = self._client
77
+ await client.aclose()
78
+
79
+ async def get(
80
+ self,
81
+ url: str,
82
+ params: dict[str, str],
83
+ *,
84
+ retries_remaining: int = 3,
85
+ ) -> httpx.Response:
86
+ """
87
+ Perform GET request with automatic client recovery on connection errors.
88
+
89
+ Uses optimistic retry: if the client was already reset by another task,
90
+ retries without consuming the retry budget. Only failures on a fresh
91
+ client decrement the budget.
92
+
93
+ Args:
94
+ url (str): The absolute URL to request.
95
+ params (dict[str, str]): Query parameters for the request.
96
+ retries_remaining (int): Number of retries left for fresh client failures.
97
+
98
+ Returns:
99
+ httpx.Response: The HTTP response.
100
+
101
+ Raises:
102
+ LookupError: If all retry attempts fail due to connection errors.
103
+ """
104
+ if retries_remaining <= 0:
105
+ raise LookupError("Connection failed after retries") from None
106
+
107
+ await self._ready.wait()
108
+
109
+ async with self._lock:
110
+ client, client_id = self._client, id(self._client)
111
+
112
+ try:
113
+ return await client.get(url, params=params)
114
+
115
+ except (httpx.TransportError, RuntimeError):
116
+ # Check if client was already reset by another task (returns True if stale)
117
+ was_stale = await self._handle_connection_error(client_id)
118
+
119
+ # Free retry if stale, otherwise decrement retry budget
120
+ next_retries = retries_remaining if was_stale else retries_remaining - 1
121
+
122
+ # Recursively retry request with updated retry budget
123
+ return await self.get(url, params, retries_remaining=next_retries)
124
+
125
+ async def _handle_connection_error(self, failed_client_id: int) -> bool:
126
+ """
127
+ Handle connection error, resetting client if necessary.
128
+
129
+ Checks if another task already reset the client (stale client).
130
+ If not, this task triggers the reset.
131
+
132
+ Args:
133
+ failed_client_id (int): The id() of the client that failed.
134
+
135
+ Returns:
136
+ bool: True if client was already reset (free retry),
137
+ False if this task reset it (counts against budget).
138
+ """
139
+ async with self._lock:
140
+ already_reset = failed_client_id != id(self._client)
141
+
142
+ if already_reset:
143
+ return True
144
+
145
+ logger.debug(
146
+ "CLIENT_RESET: Transport error encountered, resetting YFinance client",
147
+ )
148
+ await self._reset()
149
+ return False
150
+
151
+ async def _reset(self) -> None:
152
+ """
153
+ Reset the HTTP client instance.
154
+
155
+ Creates a new client, verifies it can connect, then replaces the
156
+ old client. Invokes the on_reset callback if configured.
157
+
158
+ Args:
159
+ None
160
+
161
+ Returns:
162
+ None
163
+
164
+ Raises:
165
+ Exception: If new client creation or health check fails.
166
+ """
167
+ async with self._lock:
168
+ # Save old client before replacement, blocking new requests until ready
169
+ old_client = self._client
170
+ self._ready.clear()
171
+
172
+ try:
173
+ # Create and verify new client with health check
174
+ new_client = self._client_factory()
175
+ await new_client.get("https://finance.yahoo.com", timeout=5.0)
176
+
177
+ except Exception:
178
+ # Restore ready state if health check fails
179
+ self._ready.set()
180
+ raise
181
+
182
+ # Atomically swap in new client and unblock requests
183
+ self._client = new_client
184
+ self._ready.set()
185
+
186
+ # Clean up old client outside lock to avoid blocking
187
+ await old_client.aclose()
188
+
189
+ # Notify listeners of client reset
190
+ if self._on_reset is not None:
191
+ self._on_reset()
@@ -0,0 +1,413 @@
1
+ # yfinance/yfinance.py
2
+
3
+ import logging
4
+ from collections.abc import AsyncIterator
5
+ from contextlib import asynccontextmanager
6
+
7
+ from equity_aggregator.schemas import YFinanceFeedData
8
+ from equity_aggregator.storage import (
9
+ load_cache_entry,
10
+ save_cache_entry,
11
+ )
12
+
13
+ from .api import (
14
+ get_quote_summary,
15
+ search_quotes,
16
+ )
17
+ from .config import FeedConfig
18
+ from .ranking import filter_equities, rank_symbols
19
+ from .session import YFSession
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @asynccontextmanager
25
+ async def open_yfinance_feed(
26
+ *,
27
+ config: FeedConfig | None = None,
28
+ ) -> AsyncIterator["YFinanceFeed"]:
29
+ """
30
+ Context manager to create and close a YFinanceFeed.
31
+
32
+ Args:
33
+ config: Custom feed configuration; defaults to FeedConfig().
34
+
35
+ Yields:
36
+ YFinanceFeed with active session.
37
+ """
38
+ config = config or FeedConfig()
39
+ session = YFSession(config)
40
+ try:
41
+ yield YFinanceFeed(session)
42
+ finally:
43
+ await session.aclose()
44
+
45
+
46
+ class YFinanceFeed:
47
+ """
48
+ Async Yahoo Finance feed with caching and fuzzy lookup.
49
+
50
+ Provides fetch_equity() to retrieve equity data by symbol, name, ISIN or CUSIP.
51
+
52
+ Attributes:
53
+ model: YFinanceFeedData schema class.
54
+ default_min_score: Default minimum fuzzy matching score threshold.
55
+ """
56
+
57
+ __slots__ = ("_session",)
58
+
59
+ model = YFinanceFeedData
60
+ default_min_score = 160
61
+
62
+ def __init__(self, session: YFSession) -> None:
63
+ """
64
+ Initialise with an active YFSession.
65
+
66
+ Args:
67
+ session: The Yahoo Finance HTTP session.
68
+ """
69
+ self._session = session
70
+
71
+ async def fetch_equity(
72
+ self,
73
+ *,
74
+ symbol: str,
75
+ name: str,
76
+ isin: str | None = None,
77
+ cusip: str | None = None,
78
+ **kwargs: object,
79
+ ) -> dict:
80
+ """
81
+ Fetch enriched equity data using symbol, name, ISIN, or CUSIP.
82
+
83
+ Steps:
84
+ 1. Check cache for existing entry
85
+ 2. Resolve candidate symbols via identifiers or search
86
+ 3. Fetch and validate quote summary for first viable candidate
87
+ 4. Cache and return result
88
+
89
+ Args:
90
+ symbol: Ticker symbol of the equity.
91
+ name: Full name of the equity.
92
+ isin: ISIN identifier, if available.
93
+ cusip: CUSIP identifier, if available.
94
+ **kwargs: Additional identifiers (ignored by Yahoo Finance).
95
+
96
+ Returns:
97
+ Enriched equity data.
98
+
99
+ Raises:
100
+ LookupError: If no matching equity data is found.
101
+ """
102
+ if record := load_cache_entry("yfinance_equities", symbol):
103
+ return record
104
+
105
+ try:
106
+ candidates = await self._resolve_candidates(
107
+ symbol=symbol,
108
+ name=name,
109
+ isin=isin,
110
+ cusip=cusip,
111
+ )
112
+
113
+ data = await self._fetch_first_valid_quote(candidates)
114
+
115
+ save_cache_entry("yfinance_equities", symbol, data)
116
+ return data
117
+
118
+ except LookupError:
119
+ raise LookupError(f"No enrichment data found for {symbol}.") from None
120
+
121
+ async def _resolve_candidates(
122
+ self,
123
+ *,
124
+ symbol: str,
125
+ name: str,
126
+ isin: str | None,
127
+ cusip: str | None,
128
+ ) -> list[str]:
129
+ """
130
+ Resolve candidate symbols using full fallback chain.
131
+
132
+ Resolution order:
133
+ 1. ISIN lookup (if provided)
134
+ 2. CUSIP lookup (if provided)
135
+ 3. Name/symbol search (fallback)
136
+
137
+ Args:
138
+ symbol: Expected ticker symbol.
139
+ name: Expected company name.
140
+ isin: ISIN identifier or None.
141
+ cusip: CUSIP identifier or None.
142
+
143
+ Returns:
144
+ Ranked candidate symbols (best first).
145
+
146
+ Raises:
147
+ LookupError: If all resolution strategies fail.
148
+ """
149
+ identifiers = _build_identifier_sequence(isin, cusip)
150
+
151
+ for identifier in identifiers:
152
+ candidates = await self._resolve_by_identifier_safe(
153
+ identifier=identifier,
154
+ expected_name=name,
155
+ expected_symbol=symbol,
156
+ )
157
+ if candidates:
158
+ return candidates
159
+
160
+ return await self._resolve_by_search_terms(
161
+ query=name or symbol,
162
+ expected_name=name,
163
+ expected_symbol=symbol,
164
+ )
165
+
166
+ async def _resolve_by_identifier_safe(
167
+ self,
168
+ *,
169
+ identifier: str,
170
+ expected_name: str,
171
+ expected_symbol: str,
172
+ ) -> list[str]:
173
+ """
174
+ Attempt identifier resolution, returning empty list on failure.
175
+
176
+ Args:
177
+ identifier: ISIN or CUSIP to search.
178
+ expected_name: Expected company name.
179
+ expected_symbol: Expected ticker symbol.
180
+
181
+ Returns:
182
+ Ranked symbols or empty list on any failure.
183
+ """
184
+ try:
185
+ return await self._resolve_by_identifier(
186
+ identifier=identifier,
187
+ expected_name=expected_name,
188
+ expected_symbol=expected_symbol,
189
+ )
190
+ except LookupError:
191
+ return []
192
+
193
+ async def _resolve_by_identifier(
194
+ self,
195
+ *,
196
+ identifier: str,
197
+ expected_name: str,
198
+ expected_symbol: str,
199
+ ) -> list[str]:
200
+ """
201
+ Search by ISIN/CUSIP and return ranked candidate symbols.
202
+
203
+ Args:
204
+ identifier: ISIN or CUSIP to search.
205
+ expected_name: Expected company name for ranking.
206
+ expected_symbol: Expected ticker symbol for ranking.
207
+
208
+ Returns:
209
+ Ranked symbols (best first).
210
+
211
+ Raises:
212
+ LookupError: If search returns no results or no viable candidates.
213
+ """
214
+ quotes = await search_quotes(self._session, identifier)
215
+
216
+ if not quotes:
217
+ raise LookupError("Quote Search endpoint returned no results")
218
+
219
+ viable = filter_equities(quotes)
220
+
221
+ if not viable:
222
+ raise LookupError("No viable candidates found")
223
+
224
+ min_score = _select_identifier_min_score(len(viable), self.default_min_score)
225
+
226
+ return rank_symbols(
227
+ viable,
228
+ expected_name=expected_name,
229
+ expected_symbol=expected_symbol,
230
+ min_score=min_score,
231
+ )
232
+
233
+ async def _resolve_by_search_terms(
234
+ self,
235
+ *,
236
+ query: str,
237
+ expected_name: str,
238
+ expected_symbol: str,
239
+ ) -> list[str]:
240
+ """
241
+ Search by query and return ranked candidate symbols.
242
+
243
+ Tries query first, then expected_symbol if they differ.
244
+
245
+ Args:
246
+ query: Primary search query (typically company name or symbol).
247
+ expected_name: Expected company name for ranking.
248
+ expected_symbol: Expected ticker symbol for ranking and fallback search.
249
+
250
+ Returns:
251
+ Ranked symbols (best first).
252
+
253
+ Raises:
254
+ LookupError: If no viable candidates found.
255
+ """
256
+ terms = _build_search_terms(query, expected_symbol)
257
+
258
+ for term in terms:
259
+ quotes = await search_quotes(self._session, term)
260
+
261
+ if not quotes:
262
+ continue
263
+
264
+ ranked = _rank_viable_candidates(
265
+ quotes,
266
+ expected_name=expected_name,
267
+ expected_symbol=expected_symbol,
268
+ min_score=self.default_min_score,
269
+ )
270
+
271
+ if ranked:
272
+ return ranked
273
+
274
+ raise LookupError("No symbol candidates found")
275
+
276
+ async def _fetch_first_valid_quote(self, symbols: list[str]) -> dict:
277
+ """
278
+ Fetch and validate quote summary for first viable symbol.
279
+
280
+ Args:
281
+ symbols: Ranked candidate symbols to try.
282
+
283
+ Returns:
284
+ Validated quote summary data.
285
+
286
+ Raises:
287
+ LookupError: If all candidates fail validation.
288
+ """
289
+ for symbol in symbols:
290
+ data = await get_quote_summary(
291
+ self._session,
292
+ symbol,
293
+ modules=self._session.config.modules,
294
+ )
295
+ try:
296
+ return _validate_quote_summary(data, symbol)
297
+ except LookupError:
298
+ continue
299
+
300
+ raise LookupError("All candidates failed validation")
301
+
302
+
303
+ def _build_identifier_sequence(
304
+ isin: str | None,
305
+ cusip: str | None,
306
+ ) -> tuple[str, ...]:
307
+ """
308
+ Return non-None identifiers in resolution priority order.
309
+
310
+ Args:
311
+ isin: ISIN identifier or None.
312
+ cusip: CUSIP identifier or None.
313
+
314
+ Returns:
315
+ Tuple of identifiers with None values filtered out.
316
+ """
317
+ return tuple(filter(None, (isin, cusip)))
318
+
319
+
320
+ def _build_search_terms(query: str, symbol: str) -> tuple[str, ...]:
321
+ """
322
+ Return deduplicated search terms in priority order.
323
+
324
+ Args:
325
+ query: Primary search query (typically company name or symbol).
326
+ symbol: Ticker symbol (fallback search term).
327
+
328
+ Returns:
329
+ Tuple of unique search terms, query first.
330
+ """
331
+ return tuple(dict.fromkeys((query, symbol)))
332
+
333
+
334
+ def _select_identifier_min_score(viable_count: int, default_min_score: int) -> int:
335
+ """
336
+ Select fuzzy match threshold based on result count.
337
+
338
+ Single-result identifier lookups use a reduced threshold (120) since
339
+ ISIN/CUSIP identifiers are globally unique. Multiple results use the
340
+ default threshold for stricter ranking.
341
+
342
+ Args:
343
+ viable_count: Number of viable candidates.
344
+ default_min_score: Default minimum score threshold.
345
+
346
+ Returns:
347
+ Minimum score threshold.
348
+ """
349
+ return 120 if viable_count == 1 else default_min_score
350
+
351
+
352
+ def _validate_quote_summary(data: dict | None, symbol: str) -> dict:
353
+ """
354
+ Validate quote summary meets EQUITY criteria.
355
+
356
+ Checks:
357
+ 1. Data is not empty
358
+ 2. quoteType is "EQUITY"
359
+ 3. longName or shortName is present
360
+
361
+ Args:
362
+ data: Quote summary data or None.
363
+ symbol: Symbol for error messages.
364
+
365
+ Returns:
366
+ Validated data dict.
367
+
368
+ Raises:
369
+ LookupError: If any validation check fails.
370
+ """
371
+ if not data:
372
+ raise LookupError(f"Quote summary returned no data for {symbol}")
373
+
374
+ quote_type = data.get("quoteType")
375
+ if quote_type != "EQUITY":
376
+ raise LookupError(
377
+ f"Symbol {symbol} has quoteType '{quote_type}', expected 'EQUITY'",
378
+ )
379
+
380
+ if not data.get("longName") and not data.get("shortName"):
381
+ raise LookupError(f"Symbol {symbol} has no company name")
382
+
383
+ return data
384
+
385
+
386
+ def _rank_viable_candidates(
387
+ quotes: list[dict],
388
+ expected_name: str,
389
+ expected_symbol: str,
390
+ min_score: int,
391
+ ) -> list[str]:
392
+ """
393
+ Filter and rank quote candidates by fuzzy match quality.
394
+
395
+ Args:
396
+ quotes: Raw quotes from search API.
397
+ expected_name: Expected company name.
398
+ expected_symbol: Expected ticker symbol.
399
+ min_score: Minimum fuzzy score threshold.
400
+
401
+ Returns:
402
+ Ranked symbols (best first), empty if none viable.
403
+ """
404
+ viable = filter_equities(quotes)
405
+ if not viable:
406
+ return []
407
+
408
+ return rank_symbols(
409
+ viable,
410
+ expected_name=expected_name,
411
+ expected_symbol=expected_symbol,
412
+ min_score=min_score,
413
+ )
@@ -30,9 +30,9 @@ async def retrieve_conversion_rates(
30
30
  dict[str, Decimal]: Mapping of currency codes to their Decimal conversion rates.
31
31
 
32
32
  Raises:
33
- EnvironmentError: If the API key is missing.
33
+ OSError: If the API key is missing.
34
34
  httpx.HTTPError: For network or HTTP status errors.
35
- Exception: For API-level failures or invalid responses.
35
+ ValueError: For API-level failures or invalid responses.
36
36
 
37
37
  Notes:
38
38
  Uses a cache to avoid unnecessary API calls. Cache is refreshed every 24 hours.
@@ -40,11 +40,8 @@ async def retrieve_conversion_rates(
40
40
  cached = load_cache(cache_key)
41
41
 
42
42
  if cached is not None:
43
- logger.debug("Loaded exchange rates from cache.")
44
43
  return cached
45
44
 
46
- logger.info("Fetching exchange rates from ExchangeRateApi API.")
47
-
48
45
  # fetch from API and validate
49
46
  api_key = _get_api_key()
50
47
  url = _build_url(api_key)
@@ -60,8 +57,7 @@ async def retrieve_conversion_rates(
60
57
  logger.info("Saved exchange rates to cache.")
61
58
  return rates
62
59
 
63
- # If any error occurs on the request, treat it as fatal and exit
64
- except Exception as error:
60
+ except (httpx.HTTPError, ValueError) as error:
65
61
  logger.fatal(
66
62
  "Fatal error while fetching exchange rates: %s",
67
63
  error,
@@ -84,7 +80,6 @@ def _get_api_key() -> str:
84
80
  if not key:
85
81
  logger.error("EXCHANGE_RATE_API_KEY environment variable is not set.")
86
82
  raise OSError("EXCHANGE_RATE_API_KEY environment variable is not set.")
87
- logger.debug("Exchange Rate API key loaded from environment.")
88
83
  return key
89
84
 
90
85
 
@@ -150,8 +145,6 @@ async def _fetch_and_validate(client: httpx.AsyncClient, url: str) -> dict:
150
145
  logger.error(f"Unexpected error while fetching exchange rates: {e}")
151
146
  raise
152
147
 
153
- logger.debug("Exchange rates response received and parsed.")
154
-
155
148
  _assert_success(payload)
156
149
  return payload
157
150
 
@@ -173,7 +166,7 @@ def _convert_rate(key: str, rate: float) -> tuple[str, Decimal]:
173
166
 
174
167
  def _assert_success(payload: dict) -> None:
175
168
  """
176
- Checks if the API response indicates success; raises an Exception otherwise.
169
+ Checks if the API response indicates success; raises a ValueError otherwise.
177
170
 
178
171
  Args:
179
172
  payload (dict): The response payload from the Exchange Rate API. Must contain
@@ -181,7 +174,7 @@ def _assert_success(payload: dict) -> None:
181
174
  describing the error.
182
175
 
183
176
  Raises:
184
- Exception: If the 'result' key is not 'success', raises an Exception with the
177
+ ValueError: If the 'result' key is not 'success', raises a ValueError with the
185
178
  error type from the payload or 'Unknown error' if not provided.
186
179
 
187
180
  Returns:
@@ -192,4 +185,4 @@ def _assert_success(payload: dict) -> None:
192
185
 
193
186
  logger.error(f"Exchange Rate API error: {error}")
194
187
 
195
- raise Exception(f"Exchange Rate API error: {error}")
188
+ raise ValueError(f"Exchange Rate API error: {error}")