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,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,5 +1,5 @@
1
1
  # yfinance/__init__.py
2
2
 
3
- from .feed import open_yfinance_feed
3
+ from .yfinance import open_yfinance_feed
4
4
 
5
5
  __all__ = ["open_yfinance_feed"]
@@ -0,0 +1,11 @@
1
+ # _utils/__init__.py
2
+
3
+ from .backoff import backoff_delays
4
+ from .fuzzy import rank_all_symbols
5
+ from .json import safe_json_parse
6
+
7
+ __all__ = [
8
+ "rank_all_symbols",
9
+ "backoff_delays",
10
+ "safe_json_parse",
11
+ ]
@@ -1,4 +1,4 @@
1
- # utils/backoff.py
1
+ # _utils/backoff.py
2
2
 
3
3
 
4
4
  import random
@@ -1,44 +1,40 @@
1
- # utils/fuzzy.py
1
+ # _utils/fuzzy.py
2
2
 
3
3
  from rapidfuzz import fuzz, utils
4
4
 
5
5
 
6
- def pick_best_symbol(
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 | None:
13
+ ) -> list[str]:
14
14
  """
15
- Select the best-matching symbol from a list of Yahoo Finance quotes using
16
- fuzzy matching.
15
+ Rank all matching symbols from a list of Yahoo Finance quotes using fuzzy matching.
17
16
 
18
- For each quote, this function computes a combined fuzzy score based on the
19
- similarity between the quote's symbol and the expected symbol, and between the
20
- quote's name (using `name_key`) and the expected name. Quote with the highest
21
- combined score is selected if its score meets or exceeds `min_score`. If no
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 | None: Best-matching symbol if a suitable match is found, else None.
32
+ list[str]: Ranked symbols (best first), empty if none meet threshold.
36
33
  """
37
-
38
34
  if not quotes:
39
- return None
35
+ return []
40
36
 
41
- # compute fuzzy scores for each quote
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
- # compute the best score and symbol from the scored list
53
- best_score, best_symbol, best_name = max(scored, key=lambda t: t[0])
54
-
55
- # if the best score is below the minimum threshold, return None
56
- if best_score < min_score:
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
- # otherwise, return the best symbol found
60
- return best_symbol
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. The result is a tuple containing the total
76
- score, the actual symbol, and the actual name.
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,6 +1,6 @@
1
1
  # api/__init__.py
2
2
 
3
+ from .quote_summary import get_quote_summary
3
4
  from .search import search_quotes
4
- from .summary import get_quote_summary
5
5
 
6
6
  __all__ = ["search_quotes", "get_quote_summary"]
@@ -1,10 +1,11 @@
1
- # api/summary.py
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.quote_summary_url + ticker
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
- # 401/500/502 → fallback
56
- if status in {
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
- # 429 after back-off treat as “no data” so the caller logs it cleanly
64
- if status == httpx.codes.TOO_MANY_REQUESTS:
65
- raise LookupError(f"HTTP 429 Too Many Requests for {ticker}")
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
- # everything else: try to parse
68
- raw = response.json().get("quoteSummary", {}).get("result", [])
69
- if raw:
70
- return _flatten_module_dicts(modules, raw[0])
72
+ if not raw_data:
73
+ return None
71
74
 
72
- # empty result
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
- Fallback: fetch basic quote data from Yahoo Finance's v7 /finance/quote endpoint.
83
+ Fetch quote data from Yahoo Finance fallback endpoint.
82
84
 
83
- This coroutine is used if the main quoteSummary endpoint returns no data. It
84
- retrieves a basic set of quote fields for the given ticker symbol from the
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: The first quote dictionary from the response if
93
- available, otherwise None.
93
+ dict[str, object] | None: Quote data from the fallback endpoint,
94
+ or None if no data is found.
94
95
  """
95
- resp = await session.get(
96
- session.config.quote_summary_fallback_url,
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
- resp.raise_for_status()
106
- results = resp.json().get("quoteResponse", {}).get("result", [])
107
- return results[0] if results else None
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 == httpx.codes.TOO_MANY_REQUESTS:
34
- logger.warning("429 from search endpoint for %s", query)
35
- return []
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"]