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
@@ -0,0 +1,305 @@
1
+ # intrinio/intrinio.py
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+
7
+ from equity_aggregator.adapters.data_sources._utils import make_client
8
+ from equity_aggregator.adapters.data_sources._utils._record_types import (
9
+ EquityRecord,
10
+ RecordStream,
11
+ )
12
+ from equity_aggregator.storage import load_cache, save_cache
13
+
14
+ from ._utils import parse_companies_response, parse_securities_response
15
+ from .session import IntrinioSession
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _INTRINIO_COMPANIES_URL = "https://api-v2.intrinio.com/companies"
20
+ _INTRINIO_SECURITIES_URL = "https://api-v2.intrinio.com/securities"
21
+ _PAGE_SIZE = 5000
22
+
23
+
24
+ async def fetch_equity_records(
25
+ session: IntrinioSession | None = None,
26
+ *,
27
+ cache_key: str = "intrinio_records",
28
+ ) -> RecordStream:
29
+ """
30
+ Yield each Intrinio security record with quote data, using cache if available.
31
+
32
+ Fetches all companies, then for each company fetches its securities. Each
33
+ security (keyed by share_class_figi) becomes a separate equity record with
34
+ quote data attached.
35
+
36
+ Args:
37
+ session (IntrinioSession | None): Optional Intrinio session for requests.
38
+ cache_key (str): The key under which to cache the records.
39
+
40
+ Yields:
41
+ EquityRecord: Parsed Intrinio security record with company and quote data.
42
+
43
+ Raises:
44
+ ValueError: If INTRINIO_API_KEY environment variable is not set.
45
+ """
46
+ cached = load_cache(cache_key)
47
+
48
+ if cached:
49
+ logger.info("Loaded %d Intrinio records from cache.", len(cached))
50
+ for record in cached:
51
+ yield record
52
+ return
53
+
54
+ _get_api_key() # Validate API key is set before proceeding
55
+
56
+ session = session or IntrinioSession(make_client())
57
+
58
+ try:
59
+ async for record in _stream_and_cache(session, cache_key=cache_key):
60
+ yield record
61
+ finally:
62
+ await session.aclose()
63
+
64
+
65
+ async def _stream_and_cache(
66
+ session: IntrinioSession,
67
+ *,
68
+ cache_key: str,
69
+ ) -> RecordStream:
70
+ """
71
+ Stream unique Intrinio security records with quotes, cache them, and yield each.
72
+
73
+ Fetches all companies, then fetches securities for each company. Each security
74
+ gets quote data attached. Records are deduplicated by share_class_figi.
75
+
76
+ Args:
77
+ session (IntrinioSession): The Intrinio session used for requests.
78
+ cache_key (str): The key under which to cache the records.
79
+
80
+ Yields:
81
+ EquityRecord: Each unique security record with company and quote data.
82
+
83
+ Side Effects:
84
+ Saves all streamed records to cache after streaming completes.
85
+ """
86
+ all_companies = await _fetch_all_companies(session)
87
+ all_securities = await _fetch_all_securities(session, all_companies)
88
+ all_records = await _attach_quotes_to_all(session, all_securities)
89
+ unique_records = _deduplicate_by_share_class_figi(all_records)
90
+
91
+ for record in unique_records:
92
+ yield record
93
+
94
+ save_cache(cache_key, unique_records)
95
+ logger.info("Saved %d Intrinio records to cache.", len(unique_records))
96
+
97
+
98
+ async def _fetch_all_companies(session: IntrinioSession) -> list[EquityRecord]:
99
+ """
100
+ Fetch all company records from Intrinio, handling pagination.
101
+
102
+ Args:
103
+ session (IntrinioSession): The Intrinio session used for requests.
104
+
105
+ Returns:
106
+ list[EquityRecord]: Complete list of company records from all pages.
107
+ """
108
+ all_companies: list[EquityRecord] = []
109
+ next_page: str | None = None
110
+
111
+ while True:
112
+ page_records, next_page = await _fetch_companies_page(session, next_page)
113
+ all_companies.extend(page_records)
114
+
115
+ if not next_page:
116
+ return all_companies
117
+
118
+
119
+ async def _fetch_all_securities(
120
+ session: IntrinioSession,
121
+ companies: list[EquityRecord],
122
+ ) -> list[EquityRecord]:
123
+ """
124
+ Fetch securities for all companies concurrently.
125
+
126
+ For each company, fetches its securities from the securities endpoint.
127
+ A company may have multiple securities (e.g., common stock, preferred shares).
128
+
129
+ Args:
130
+ session (IntrinioSession): The Intrinio session used for requests.
131
+ companies (list[EquityRecord]): List of company records.
132
+
133
+ Returns:
134
+ list[EquityRecord]: Flattened list of security records with company data.
135
+ """
136
+ tasks = [_fetch_company_securities(session, company) for company in companies]
137
+ results = await asyncio.gather(*tasks)
138
+
139
+ all_securities = []
140
+ for securities in results:
141
+ all_securities.extend(securities)
142
+
143
+ return all_securities
144
+
145
+
146
+ async def _attach_quotes_to_all(
147
+ session: IntrinioSession,
148
+ securities: list[EquityRecord],
149
+ ) -> list[EquityRecord]:
150
+ """
151
+ Attach quote data to all securities concurrently.
152
+
153
+ Args:
154
+ session (IntrinioSession): The Intrinio session used for requests.
155
+ securities (list[EquityRecord]): List of security records.
156
+
157
+ Returns:
158
+ list[EquityRecord]: Security records with quote data attached.
159
+ """
160
+ tasks = [_attach_quote(session, security) for security in securities]
161
+ return await asyncio.gather(*tasks)
162
+
163
+
164
+ def _deduplicate_by_share_class_figi(
165
+ records: list[EquityRecord],
166
+ ) -> list[EquityRecord]:
167
+ """
168
+ Deduplicate records by share_class_figi, maintaining insertion order.
169
+
170
+ Args:
171
+ records (list[EquityRecord]): The list of equity records to deduplicate.
172
+
173
+ Returns:
174
+ list[EquityRecord]: Deduplicated list of equity records.
175
+ """
176
+ seen: set[str] = set()
177
+ unique: list[EquityRecord] = []
178
+
179
+ for record in records:
180
+ share_class_figi = record.get("share_class_figi")
181
+
182
+ if not share_class_figi or share_class_figi in seen:
183
+ continue
184
+
185
+ seen.add(share_class_figi)
186
+ unique.append(record)
187
+
188
+ return unique
189
+
190
+
191
+ async def _fetch_companies_page(
192
+ session: IntrinioSession,
193
+ next_page: str | None,
194
+ ) -> tuple[list[EquityRecord], str | None]:
195
+ """
196
+ Fetch a single page of results from the Intrinio companies endpoint.
197
+
198
+ Args:
199
+ session (IntrinioSession): The Intrinio session used for requests.
200
+ next_page (str | None): The pagination token for the next page.
201
+
202
+ Returns:
203
+ tuple[list[EquityRecord], str | None]: Tuple of (parsed records,
204
+ next_page token).
205
+ """
206
+ params = {"api_key": _get_api_key(), "page_size": str(_PAGE_SIZE)}
207
+
208
+ if next_page:
209
+ params["next_page"] = next_page
210
+
211
+ response = await session.get(_INTRINIO_COMPANIES_URL, params=params)
212
+ response.raise_for_status()
213
+ return parse_companies_response(response.json())
214
+
215
+
216
+ async def _fetch_company_securities(
217
+ session: IntrinioSession,
218
+ company: EquityRecord,
219
+ ) -> list[EquityRecord]:
220
+ """
221
+ Fetch securities for a single company.
222
+
223
+ A company may have multiple securities. Company data is extracted from the
224
+ securities response itself (not the passed company record) to avoid ticker
225
+ reassignment issues where stale company records have incorrect identifiers.
226
+
227
+ Args:
228
+ session (IntrinioSession): The Intrinio session used for requests.
229
+ company (EquityRecord): Company record containing company_ticker.
230
+
231
+ Returns:
232
+ list[EquityRecord]: List of security records for this company.
233
+ """
234
+ company_ticker = company.get("company_ticker")
235
+ url = f"{_INTRINIO_COMPANIES_URL}/{company_ticker}/securities"
236
+ params = {"api_key": _get_api_key()}
237
+
238
+ try:
239
+ response = await session.get(url, params=params)
240
+ response.raise_for_status()
241
+ return parse_securities_response(response.json())
242
+ except Exception:
243
+ return []
244
+
245
+
246
+ async def _attach_quote(
247
+ session: IntrinioSession,
248
+ security: EquityRecord,
249
+ ) -> EquityRecord:
250
+ """
251
+ Fetch quote data for a security and attach it to the record.
252
+
253
+ Uses share_class_figi as the identifier for the quote lookup.
254
+
255
+ Args:
256
+ session (IntrinioSession): The Intrinio session used for requests.
257
+ security (EquityRecord): Security record to attach quote to.
258
+
259
+ Returns:
260
+ EquityRecord: Security record with quote data attached.
261
+ """
262
+ share_class_figi = security.get("share_class_figi")
263
+ quote = await _fetch_quote(session, share_class_figi)
264
+ return {**security, "quote": quote}
265
+
266
+
267
+ async def _fetch_quote(
268
+ session: IntrinioSession,
269
+ share_class_figi: str,
270
+ ) -> dict | None:
271
+ """
272
+ Fetch quote data from Intrinio API using share_class_figi.
273
+
274
+ Args:
275
+ session (IntrinioSession): The Intrinio session used for requests.
276
+ share_class_figi (str): The share class FIGI identifier.
277
+
278
+ Returns:
279
+ dict | None: Quote data dictionary, or None if fetch fails.
280
+ """
281
+ url = f"{_INTRINIO_SECURITIES_URL}/{share_class_figi}/quote"
282
+ params = {"api_key": _get_api_key()}
283
+
284
+ try:
285
+ response = await session.get(url, params=params)
286
+ response.raise_for_status()
287
+ return response.json()
288
+ except Exception:
289
+ return None
290
+
291
+
292
+ def _get_api_key() -> str:
293
+ """
294
+ Retrieve the Intrinio API key from environment variables.
295
+
296
+ Returns:
297
+ str: The Intrinio API key.
298
+
299
+ Raises:
300
+ ValueError: If INTRINIO_API_KEY environment variable is not set.
301
+ """
302
+ api_key = os.getenv("INTRINIO_API_KEY")
303
+ if not api_key:
304
+ raise ValueError("INTRINIO_API_KEY environment variable not set")
305
+ return api_key
@@ -0,0 +1,197 @@
1
+ # intrinio/session.py
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import Mapping
6
+
7
+ import httpx
8
+
9
+ from equity_aggregator.adapters.data_sources._utils import make_client
10
+
11
+ from ._utils import backoff_delays
12
+
13
+ logger: logging.Logger = logging.getLogger(__name__)
14
+
15
+
16
+ class IntrinioSession:
17
+ """
18
+ Asynchronous session for Intrinio API endpoints.
19
+
20
+ Manages HTTP client lifecycle with automatic recovery on HTTP/2 connection
21
+ errors. Uses a class-level semaphore to limit concurrent streams and prevent
22
+ connection exhaustion.
23
+
24
+ Args:
25
+ client (httpx.AsyncClient | None): Optional pre-configured HTTP client.
26
+
27
+ Returns:
28
+ None
29
+ """
30
+
31
+ __slots__ = ("_client", "_lock")
32
+
33
+ # Limit HTTP/2 concurrent streams to prevent connection exhaustion
34
+ _concurrent_streams: asyncio.Semaphore = asyncio.Semaphore(10)
35
+
36
+ def __init__(self, client: httpx.AsyncClient | None = None) -> None:
37
+ """
38
+ Initialise IntrinioSession with optional HTTP client.
39
+
40
+ Args:
41
+ client (httpx.AsyncClient | None): Optional pre-configured HTTP client.
42
+
43
+ Returns:
44
+ None
45
+ """
46
+ self._client: httpx.AsyncClient = client or make_client()
47
+ self._lock: asyncio.Lock = asyncio.Lock()
48
+
49
+ async def aclose(self) -> None:
50
+ """
51
+ Close the underlying HTTP client.
52
+
53
+ Args:
54
+ None
55
+
56
+ Returns:
57
+ None
58
+ """
59
+ await self._client.aclose()
60
+
61
+ async def get(
62
+ self,
63
+ url: str,
64
+ *,
65
+ params: Mapping[str, str] | None = None,
66
+ ) -> httpx.Response:
67
+ """
68
+ Perform a GET request with concurrency limiting and connection recovery.
69
+
70
+ Automatically resets the HTTP client on protocol errors and retries
71
+ the request with exponential backoff. Handles 429 rate limit responses
72
+ with exponential backoff before returning.
73
+
74
+ Args:
75
+ url (str): Absolute URL to request.
76
+ params (Mapping[str, str] | None): Optional query parameters.
77
+
78
+ Returns:
79
+ httpx.Response: The HTTP response.
80
+
81
+ Raises:
82
+ httpx.HTTPError: If the request fails after all retries.
83
+ """
84
+ async with self.__class__._concurrent_streams:
85
+ return await self._get_with_rate_limit_retry(url, params=dict(params or {}))
86
+
87
+ async def _get_with_rate_limit_retry(
88
+ self,
89
+ url: str,
90
+ params: dict[str, str],
91
+ ) -> httpx.Response:
92
+ """
93
+ Perform GET request with 429 rate limit retry logic.
94
+
95
+ Applies exponential backoff when receiving 429 Too Many Requests responses
96
+ from the Intrinio API.
97
+
98
+ Args:
99
+ url (str): The absolute URL to request.
100
+ params (dict[str, str]): Query parameters for the request.
101
+
102
+ Returns:
103
+ httpx.Response: The HTTP response.
104
+
105
+ Raises:
106
+ httpx.HTTPStatusError: If still rate limited after all retries.
107
+ """
108
+ response = await self._get_with_retry(url, params=params)
109
+
110
+ if response.status_code != httpx.codes.TOO_MANY_REQUESTS:
111
+ return response
112
+
113
+ max_attempts = 5
114
+
115
+ for attempt, delay in enumerate(backoff_delays(attempts=max_attempts), 1):
116
+ logger.debug(
117
+ "429 Too Many Requests %s - sleeping %.1fs (attempt %d/%d)",
118
+ url,
119
+ delay,
120
+ attempt,
121
+ max_attempts,
122
+ )
123
+ await asyncio.sleep(delay)
124
+
125
+ response = await self._get_with_retry(url, params=params)
126
+
127
+ if response.status_code != httpx.codes.TOO_MANY_REQUESTS:
128
+ return response
129
+
130
+ return response
131
+
132
+ async def _get_with_retry(
133
+ self,
134
+ url: str,
135
+ params: dict[str, str],
136
+ *,
137
+ retries_remaining: int = 3,
138
+ ) -> httpx.Response:
139
+ """
140
+ Perform GET request with automatic client recovery on connection errors.
141
+
142
+ Uses optimistic retry: if the client was already reset by another task,
143
+ retries without consuming the retry budget.
144
+
145
+ Args:
146
+ url (str): The absolute URL to request.
147
+ params (dict[str, str]): Query parameters for the request.
148
+ retries_remaining (int): Number of retries left for connection errors.
149
+
150
+ Returns:
151
+ httpx.Response: The HTTP response.
152
+
153
+ Raises:
154
+ httpx.HTTPError: If all retry attempts fail.
155
+ """
156
+ if retries_remaining <= 0:
157
+ raise httpx.ConnectError("Connection failed after retries")
158
+
159
+ async with self._lock:
160
+ client, client_id = self._client, id(self._client)
161
+
162
+ try:
163
+ return await client.get(url, params=params)
164
+
165
+ except (httpx.TransportError, RuntimeError):
166
+ was_stale = await self._reset_if_needed(client_id)
167
+ next_retries = retries_remaining if was_stale else retries_remaining - 1
168
+
169
+ return await self._get_with_retry(
170
+ url,
171
+ params,
172
+ retries_remaining=next_retries,
173
+ )
174
+
175
+ async def _reset_if_needed(self, failed_client_id: int) -> bool:
176
+ """
177
+ Reset the HTTP client if it hasn't already been reset by another task.
178
+
179
+ Args:
180
+ failed_client_id (int): The id() of the client that failed.
181
+
182
+ Returns:
183
+ bool: True if client was already reset (free retry),
184
+ False if this task reset it (counts against budget).
185
+ """
186
+ async with self._lock:
187
+ if failed_client_id != id(self._client):
188
+ return True # Already reset by another task
189
+
190
+ old_client = self._client
191
+ self._client = make_client()
192
+
193
+ await old_client.aclose()
194
+ logger.debug(
195
+ "CLIENT_RESET: Transport error encountered, resetting Intrinio client",
196
+ )
197
+ return False
@@ -0,0 +1,7 @@
1
+ # discovery_feeds/lseg/__init__.py
2
+
3
+ from .lseg import fetch_equity_records
4
+
5
+ __all__ = [
6
+ "fetch_equity_records",
7
+ ]
@@ -0,0 +1,9 @@
1
+ # _utils/__init__.py
2
+
3
+ from .backoff import backoff_delays
4
+ from .parser import parse_response
5
+
6
+ __all__ = [
7
+ "backoff_delays",
8
+ "parse_response",
9
+ ]
@@ -0,0 +1,33 @@
1
+ # lseg/_utils/backoff.py
2
+
3
+
4
+ import random
5
+ from collections.abc import Iterator
6
+
7
+
8
+ def backoff_delays(
9
+ *,
10
+ base: float = 5.0,
11
+ cap: float = 128.0,
12
+ jitter: float = 0.10,
13
+ attempts: int = 5,
14
+ ) -> Iterator[float]:
15
+ """
16
+ Yield an exponential backoff sequence with bounded jitter for retry delays.
17
+
18
+ Each delay is calculated as: delay * (1 ± jitter), doubling each time up to cap.
19
+
20
+ Args:
21
+ base (float): Initial delay in seconds.
22
+ cap (float): Maximum delay in seconds.
23
+ jitter (float): Fractional jitter (+/-) applied to each delay.
24
+ attempts (int): Number of delay values to yield.
25
+
26
+ Returns:
27
+ Iterator[float]: Sequence of delay values in seconds.
28
+ """
29
+ delay: float = base
30
+ for _ in range(attempts):
31
+ delta: float = delay * jitter * (2 * random.random() - 1)
32
+ yield max(0.0, min(delay + delta, cap))
33
+ delay = min(delay * 2, cap)
@@ -0,0 +1,120 @@
1
+ # _utils/parser.py
2
+
3
+ from equity_aggregator.adapters.data_sources._utils._record_types import (
4
+ EquityRecord,
5
+ )
6
+
7
+
8
+ def parse_response(data: dict) -> tuple[list[EquityRecord], dict | None]:
9
+ """
10
+ Parse LSEG response to extract equity records and pagination metadata.
11
+
12
+ Extracts the price-explorer component, then the priceexplorersearch item,
13
+ then the value data containing the content array. Processes the content into
14
+ equity records with pagination info. Returns empty result if extraction fails.
15
+
16
+ Args:
17
+ data (dict): Raw JSON response from LSEG.
18
+
19
+ Returns:
20
+ tuple[list[EquityRecord], dict | None]: Tuple containing equity records
21
+ and pagination metadata, or empty list and None if no data found.
22
+ """
23
+ component = _find_price_explorer_component(data)
24
+
25
+ search_item = (
26
+ _find_content_item(component, "priceexplorersearch") if component else None
27
+ )
28
+
29
+ if not search_item:
30
+ return ([], None)
31
+
32
+ value_data = search_item.get("value")
33
+
34
+ if value_data and "content" in value_data:
35
+ return _process_value_data(value_data)
36
+
37
+ return ([], None)
38
+
39
+
40
+ def _find_price_explorer_component(data: dict) -> dict | None:
41
+ """
42
+ Find the price-explorer component.
43
+
44
+ Args:
45
+ data (dict): Raw JSON response from LSEG API.
46
+
47
+ Returns:
48
+ dict | None: The price-explorer component if found, None otherwise.
49
+ """
50
+ return next(
51
+ (
52
+ component
53
+ for component in data.get("components", [])
54
+ if component.get("type") == "price-explorer"
55
+ ),
56
+ None,
57
+ )
58
+
59
+
60
+ def _find_content_item(component: dict | None, item_name: str) -> dict | None:
61
+ """
62
+ Find a specific content item by name within a component.
63
+
64
+ Args:
65
+ component (dict | None): The component to search within.
66
+ item_name (str): Name of the content item to find.
67
+
68
+ Returns:
69
+ dict | None: The content item if found, None otherwise.
70
+ """
71
+ return component and next(
72
+ (
73
+ item
74
+ for item in component.get("content", [])
75
+ if item.get("name") == item_name
76
+ ),
77
+ None,
78
+ )
79
+
80
+
81
+ def _process_value_data(value_data: dict) -> tuple[list[EquityRecord], dict]:
82
+ """
83
+ Process value data into records and pagination info.
84
+
85
+ Args:
86
+ value_data (dict): Value data containing content and pagination metadata.
87
+
88
+ Returns:
89
+ tuple[list[EquityRecord], dict]: Tuple containing processed equity
90
+ records and pagination information.
91
+ """
92
+ records = [_extract_equity_record(equity) for equity in value_data["content"]]
93
+ pagination_info = {"totalPages": value_data.get("totalPages")}
94
+ return records, pagination_info
95
+
96
+
97
+ def _extract_equity_record(equity: dict) -> EquityRecord:
98
+ """
99
+ Normalise raw LSEG JSON equity data into EquityRecord dictionary.
100
+
101
+ Maps the raw API fields to the expected LsegFeedData schema fields.
102
+
103
+ Args:
104
+ equity (dict): Raw equity data from LSEG price-explorer API response
105
+ with equity information and market data fields.
106
+
107
+ Returns:
108
+ EquityRecord: Normalised equity record dictionary with field names
109
+ matching LsegFeedData schema expectations.
110
+ """
111
+ return {
112
+ "issuername": equity.get("issuername"),
113
+ "tidm": equity.get("tidm"),
114
+ "isin": equity.get("isin"),
115
+ "currency": equity.get("currency"),
116
+ "lastprice": equity.get("lastprice"),
117
+ "marketcapitalization": equity.get("marketcapitalization"),
118
+ "fiftyTwoWeeksMin": equity.get("fiftyTwoWeeksMin"),
119
+ "fiftyTwoWeeksMax": equity.get("fiftyTwoWeeksMax"),
120
+ }