main
py 120 lines 4.97 KB
Raw
1 from __future__ import annotations
2
3 import asyncio
4 from datetime import datetime, timedelta, timezone
5 from typing import Any, Callable
6
7 import yfinance as yf
8
9 from app.historical_market_data import YahooHistoricalPriceProvider
10 from app.models import StructuredInstrumentResolution, StructuredMarketSnapshot
11 from app.settings import Settings as ResearchSettings
12 from app.structured_market import StructuredProviderError, YahooFinanceProvider
13
14 from yahoo_mcp_server.contracts import IdentityInput
15 from yahoo_mcp_server.settings import YahooMcpSettings
16
17
18 class YahooMcpServiceError(RuntimeError):
19 def __init__(self, code: str) -> None:
20 super().__init__(code)
21 self.code = code
22
23
24 class YahooAcquisitionService:
25 """Reuses the proven research-engine Yahoo client and normalizers."""
26
27 def __init__(
28 self,
29 settings: YahooMcpSettings,
30 *,
31 ticker_factory: Callable[[str], Any] | None = None,
32 clock: Callable[[], datetime] | None = None,
33 ) -> None:
34 self.settings = settings
35 self.clock = clock or (lambda: datetime.now(timezone.utc))
36 self.ticker_factory = ticker_factory or yf.Ticker
37 shared_settings = ResearchSettings(
38 structured_provider_timeout_seconds=settings.upstream_timeout_seconds,
39 research_user_agent=settings.user_agent,
40 )
41 self.structured = YahooFinanceProvider(
42 shared_settings, ticker_factory=self.ticker_factory
43 )
44 self.history = YahooHistoricalPriceProvider(self.ticker_factory)
45 self._concurrency = asyncio.Semaphore(settings.max_concurrency)
46
47 async def close(self) -> None:
48 await self.structured.client.aclose()
49
50 async def snapshot(self, identity: IdentityInput) -> StructuredMarketSnapshot:
51 resolution = StructuredInstrumentResolution(
52 instrument_id=identity.global_instrument_id,
53 provider="YAHOO_FINANCE",
54 provider_ticker=identity.verified_yahoo_symbol,
55 company_name=identity.verified_yahoo_symbol,
56 exchange=identity.exchange,
57 currency=identity.currency,
58 quote_type="EQUITY",
59 confidence=1.0,
60 resolved_at=self.clock(),
61 status="VERIFIED_APPLICATION_MAPPING",
62 )
63 try:
64 async with self._concurrency:
65 async with asyncio.timeout(self.settings.upstream_timeout_seconds):
66 result = await self.structured.collect_verified(resolution)
67 except TimeoutError as exc:
68 raise YahooMcpServiceError("YAHOO_MCP_UPSTREAM_TIMEOUT") from exc
69 except StructuredProviderError as exc:
70 raise _map_shared_error(exc) from exc
71 except asyncio.CancelledError:
72 raise
73 except Exception as exc:
74 raise YahooMcpServiceError("YAHOO_MCP_UPSTREAM_UNAVAILABLE") from exc
75 self._validate_resolution(identity, result)
76 return result
77
78 async def closes(self, identity: IdentityInput, *, lookback_days: int):
79 now = self.clock().astimezone(timezone.utc)
80 instrument = {
81 "globalInstrumentId": str(identity.global_instrument_id),
82 "structuredProviderTicker": identity.verified_yahoo_symbol,
83 "currency": identity.currency,
84 }
85 try:
86 async with self._concurrency:
87 async with asyncio.timeout(self.settings.upstream_timeout_seconds):
88 values = await self.history.closes(
89 instrument,
90 start=now - timedelta(days=lookback_days),
91 end=now + timedelta(days=1),
92 )
93 except TimeoutError as exc:
94 raise YahooMcpServiceError("YAHOO_MCP_UPSTREAM_TIMEOUT") from exc
95 except asyncio.CancelledError:
96 raise
97 except Exception as exc:
98 raise YahooMcpServiceError("YAHOO_MCP_UPSTREAM_UNAVAILABLE") from exc
99 if not values:
100 raise YahooMcpServiceError("YAHOO_MCP_INCOMPLETE")
101 return values[: self.settings.max_response_items]
102
103 @staticmethod
104 def _validate_resolution(identity: IdentityInput, snapshot: StructuredMarketSnapshot) -> None:
105 resolved = snapshot.resolution
106 if resolved.provider_ticker.strip().upper() != identity.verified_yahoo_symbol:
107 raise YahooMcpServiceError("YAHOO_MCP_IDENTITY_MISMATCH")
108 if identity.currency and (
109 not resolved.currency or resolved.currency.upper() != identity.currency
110 ):
111 raise YahooMcpServiceError("YAHOO_MCP_IDENTITY_MISMATCH")
112
113
114 def _map_shared_error(error: StructuredProviderError) -> YahooMcpServiceError:
115 code = str(error)
116 if code.startswith("PERSISTED_MAPPING_CONFLICT") or code == "COMPANY_NOT_RESOLVED":
117 return YahooMcpServiceError("YAHOO_MCP_IDENTITY_MISMATCH")
118 if "PRICE_UNAVAILABLE" in code or "NO_ACCEPTED_FIELDS" in code:
119 return YahooMcpServiceError("YAHOO_MCP_INCOMPLETE")
120 return YahooMcpServiceError("YAHOO_MCP_UPSTREAM_UNAVAILABLE")