equity-aggregator 0.1.1__py3-none-any.whl → 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- equity_aggregator/README.md +40 -36
- equity_aggregator/adapters/__init__.py +13 -7
- equity_aggregator/adapters/data_sources/__init__.py +4 -6
- equity_aggregator/adapters/data_sources/_utils/_client.py +1 -1
- equity_aggregator/adapters/data_sources/{authoritative_feeds → _utils}/_record_types.py +1 -1
- equity_aggregator/adapters/data_sources/discovery_feeds/__init__.py +17 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/__init__.py +7 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/_utils/__init__.py +10 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/_utils/backoff.py +33 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/_utils/parser.py +107 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/intrinio.py +305 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/intrinio/session.py +197 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/lseg/__init__.py +7 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/lseg/_utils/__init__.py +9 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/lseg/_utils/backoff.py +33 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/lseg/_utils/parser.py +120 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/lseg/lseg.py +239 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/lseg/session.py +162 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/sec/__init__.py +7 -0
- equity_aggregator/adapters/data_sources/{authoritative_feeds → discovery_feeds/sec}/sec.py +4 -5
- equity_aggregator/adapters/data_sources/discovery_feeds/stock_analysis/__init__.py +7 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/stock_analysis/stock_analysis.py +150 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/tradingview/__init__.py +5 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/tradingview/tradingview.py +275 -0
- equity_aggregator/adapters/data_sources/discovery_feeds/xetra/__init__.py +7 -0
- equity_aggregator/adapters/data_sources/{authoritative_feeds → discovery_feeds/xetra}/xetra.py +9 -12
- equity_aggregator/adapters/data_sources/enrichment_feeds/__init__.py +6 -1
- equity_aggregator/adapters/data_sources/enrichment_feeds/gleif/__init__.py +5 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/gleif/api.py +71 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/gleif/download.py +109 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/gleif/gleif.py +195 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/gleif/parser.py +75 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/__init__.py +1 -1
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/_utils/__init__.py +11 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/{utils → _utils}/backoff.py +1 -1
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/{utils → _utils}/fuzzy.py +28 -26
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/_utils/json.py +36 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/api/__init__.py +1 -1
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/api/{summary.py → quote_summary.py} +44 -30
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/api/search.py +10 -5
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/auth.py +130 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/config.py +3 -3
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/ranking.py +97 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/session.py +85 -218
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/transport.py +191 -0
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/yfinance.py +413 -0
- equity_aggregator/adapters/data_sources/reference_lookup/exchange_rate_api.py +6 -13
- equity_aggregator/adapters/data_sources/reference_lookup/openfigi.py +23 -7
- equity_aggregator/cli/dispatcher.py +11 -8
- equity_aggregator/cli/main.py +14 -5
- equity_aggregator/cli/parser.py +1 -1
- equity_aggregator/cli/signals.py +32 -0
- equity_aggregator/domain/_utils/__init__.py +2 -2
- equity_aggregator/domain/_utils/_load_converter.py +30 -21
- equity_aggregator/domain/_utils/_merge.py +221 -368
- equity_aggregator/domain/_utils/_merge_config.py +205 -0
- equity_aggregator/domain/_utils/_strategies.py +180 -0
- equity_aggregator/domain/pipeline/resolve.py +17 -11
- equity_aggregator/domain/pipeline/runner.py +4 -4
- equity_aggregator/domain/pipeline/seed.py +5 -1
- equity_aggregator/domain/pipeline/transforms/__init__.py +2 -2
- equity_aggregator/domain/pipeline/transforms/canonicalise.py +1 -1
- equity_aggregator/domain/pipeline/transforms/enrich.py +328 -285
- equity_aggregator/domain/pipeline/transforms/group.py +48 -0
- equity_aggregator/logging_config.py +4 -1
- equity_aggregator/schemas/__init__.py +11 -5
- equity_aggregator/schemas/canonical.py +11 -6
- equity_aggregator/schemas/feeds/__init__.py +11 -5
- equity_aggregator/schemas/feeds/gleif_feed_data.py +35 -0
- equity_aggregator/schemas/feeds/intrinio_feed_data.py +142 -0
- equity_aggregator/schemas/feeds/{lse_feed_data.py → lseg_feed_data.py} +85 -52
- equity_aggregator/schemas/feeds/sec_feed_data.py +36 -6
- equity_aggregator/schemas/feeds/stock_analysis_feed_data.py +107 -0
- equity_aggregator/schemas/feeds/tradingview_feed_data.py +144 -0
- equity_aggregator/schemas/feeds/xetra_feed_data.py +1 -1
- equity_aggregator/schemas/feeds/yfinance_feed_data.py +47 -35
- equity_aggregator/schemas/raw.py +5 -3
- equity_aggregator/schemas/types.py +7 -0
- equity_aggregator/schemas/validators.py +81 -27
- equity_aggregator/storage/data_store.py +5 -3
- {equity_aggregator-0.1.1.dist-info → equity_aggregator-0.1.4.dist-info}/METADATA +205 -115
- equity_aggregator-0.1.4.dist-info/RECORD +103 -0
- {equity_aggregator-0.1.1.dist-info → equity_aggregator-0.1.4.dist-info}/WHEEL +1 -1
- equity_aggregator/adapters/data_sources/authoritative_feeds/__init__.py +0 -13
- equity_aggregator/adapters/data_sources/authoritative_feeds/euronext.py +0 -420
- equity_aggregator/adapters/data_sources/authoritative_feeds/lse.py +0 -352
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/feed.py +0 -350
- equity_aggregator/adapters/data_sources/enrichment_feeds/yfinance/utils/__init__.py +0 -9
- equity_aggregator/domain/pipeline/transforms/deduplicate.py +0 -54
- equity_aggregator/schemas/feeds/euronext_feed_data.py +0 -59
- equity_aggregator-0.1.1.dist-info/RECORD +0 -72
- {equity_aggregator-0.1.1.dist-info → equity_aggregator-0.1.4.dist-info}/entry_points.txt +0 -0
- {equity_aggregator-0.1.1.dist-info → equity_aggregator-0.1.4.dist-info}/licenses/LICENCE.txt +0 -0
|
@@ -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
|
-
|
|
33
|
+
OSError: If the API key is missing.
|
|
34
34
|
httpx.HTTPError: For network or HTTP status errors.
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
188
|
+
raise ValueError(f"Exchange Rate API error: {error}")
|