main
py 404 lines 14.7 KB
Raw
1 from __future__ import annotations
2
3 from datetime import datetime, timezone
4 from decimal import Decimal
5 from types import SimpleNamespace
6 from uuid import UUID, uuid4
7
8 import pytest
9 from pydantic import ValidationError
10
11 from app.fact_precedence import FactSourceTier, FinancialFact, FinancialFactKey, merge_fact
12 from app.models import CompanyResearchProfile, ProvenancedValue, SourceMode
13 from app.research_readiness import (
14 ProviderAuthorityRegistry,
15 ResearchRefreshTarget,
16 ResearchRefreshPlan,
17 ResearchRequirementStatus,
18 RuleEngineArea,
19 )
20 from app.research_readiness_runtime import CapabilityExecutionResult, ResearchReadinessRuntime
21 from app.repository import ResearchRepository
22 from app.settings import Settings
23 from app.persistence import SqliteResearchPersistence
24 from app.yahoo_mcp_acquisition import (
25 ExternalMcpAcquisitionError,
26 HttpExternalResearchToolGateway,
27 McpFirstProviderPriority,
28 McpFirstResearchCapabilityExecutor,
29 YahooMcpNormalizedResult,
30 YahooMcpResultPersister,
31 )
32
33
34 INSTRUMENT_ID = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
35 NOW = datetime(2026, 9, 11, 10, 0, tzinfo=timezone.utc)
36
37
38 def profile(*, mapping=True):
39 return CompanyResearchProfile(
40 instrument_id=INSTRUMENT_ID,
41 company_id=uuid4(),
42 company_name="Ready Limited",
43 ticker="READY",
44 exchange="NSE",
45 mic="XNSE",
46 country="IN",
47 currency="INR",
48 provider_instrument_ids={"YAHOO_FINANCE": "READY.NS"} if mapping else {},
49 )
50
51
52 def target(requirement="LATEST_PRICE", region="INDIA"):
53 return ResearchRefreshTarget(
54 requirement_id=requirement,
55 rule_engine_area=RuleEngineArea.VALUATION,
56 reason=ResearchRequirementStatus.MISSING,
57 authority_policy=ProviderAuthorityRegistry.default().policy_for(requirement, region),
58 existing_evidence_ids=(),
59 )
60
61
62 def result(requirement="LATEST_PRICE", **overrides):
63 value = {
64 "adapterVersion": "YAHOO_FINANCE_MCP_ADAPTER_V1",
65 "providerId": "YAHOO_FINANCE_MCP",
66 "sourceTier": "APPROVED_EXTERNAL_TOOL",
67 "sourceTool": "get_quote",
68 "region": "INDIA",
69 "requirementId": requirement,
70 "globalInstrumentId": str(INSTRUMENT_ID),
71 "symbol": "READY.NS",
72 "exchange": "NSE",
73 "currency": "INR",
74 "retrievedAt": NOW.isoformat(),
75 "observedAt": NOW.isoformat(),
76 "sourceUrl": "https://finance.yahoo.com/quote/READY.NS",
77 "confidence": 0.8,
78 "freshness": "FRESH",
79 "structuredFacts": [
80 {
81 "metric": "latestPrice",
82 "value": "250",
83 "unit": "INR",
84 "asOf": NOW.isoformat(),
85 "publishedAt": None,
86 "sourceUrl": "https://finance.yahoo.com/quote/READY.NS",
87 "confidence": 0.8,
88 "rawFieldOrigin": "regularMarketPrice",
89 }
90 ],
91 "financialFacts": [],
92 "marketObservations": [
93 {"observedAt": NOW.isoformat(), "price": "250", "currency": "INR"}
94 ],
95 "companyProfile": None,
96 "news": [],
97 "events": [],
98 "shareholding": None,
99 }
100 value.update(overrides)
101 return YahooMcpNormalizedResult.model_validate(value)
102
103
104 class FakeRepository:
105 def __init__(self, item=None):
106 self.item = item or profile()
107 self.structured = []
108 self.prices = []
109 self.financial = []
110 self.evidence = []
111 self.shareholding = []
112
113 def profile(self, _instrument_id):
114 return self.item
115
116 async def persist_structured_market_snapshot_async(self, record):
117 self.structured.append(record)
118
119 async def upsert_market_price_observation_async(self, observation):
120 self.prices.append(observation)
121
122 async def persist_international_financial_facts_async(self, facts):
123 self.financial.extend(facts)
124 return len(facts)
125
126 async def persist_external_mcp_evidence_async(self, document, event):
127 self.evidence.append((document, event))
128
129 async def persist_external_mcp_shareholding_async(self, snapshot):
130 self.shareholding.append(snapshot)
131 return True
132
133
134 class FakeGateway:
135 def __init__(self, outcome=None):
136 self.outcome = outcome or result()
137 self.calls = []
138
139 async def acquire_requirement(self, profile, **kwargs):
140 self.calls.append((profile, kwargs))
141 if isinstance(self.outcome, Exception):
142 raise self.outcome
143 return self.outcome
144
145
146 class FakeLegacy:
147 def __init__(self, failure=None):
148 self.calls = []
149 self.fallback_calls = []
150 self.failure = failure
151
152 async def execute_primary(self, instrument_id, targets, **kwargs):
153 self.calls.append((instrument_id, tuple(targets), kwargs))
154 failures = ({targets[0].requirement_id: self.failure} if targets and self.failure else {})
155 return CapabilityExecutionResult(("LEGACY_PROVIDER",) if targets else (), failures)
156
157 async def execute_approved_fallbacks(self, instrument_id, targets):
158 self.fallback_calls.append((instrument_id, tuple(targets)))
159 return CapabilityExecutionResult(("LEGACY_SECONDARY",), {})
160
161
162 @pytest.mark.parametrize("region,fallback", [("INDIA", "NSE"), ("USA", "SEC_EDGAR"), ("EUROPE", "EODHD")])
163 def test_region_policy_is_yahoo_first_and_keeps_approved_fallback(region, fallback) -> None:
164 route = McpFirstProviderPriority().route(region, "QUARTERLY_FINANCIALS")
165 assert route.providers[0] == "YAHOO_FINANCE_MCP"
166 assert any(fallback in provider for provider in route.providers[1:])
167
168
169 def test_unknown_region_and_governance_do_not_route_to_yahoo() -> None:
170 priority = McpFirstProviderPriority()
171 assert "YAHOO_FINANCE_MCP" not in priority.route("GLOBAL", "LATEST_PRICE").providers
172 assert "YAHOO_FINANCE_MCP" not in priority.route("INDIA", "GOVERNANCE_HISTORY").providers
173
174
175 @pytest.mark.asyncio
176 async def test_yahoo_success_persists_and_causes_zero_fallback_calls() -> None:
177 repository, gateway, legacy = FakeRepository(), FakeGateway(), FakeLegacy()
178 executor = McpFirstResearchCapabilityExecutor(legacy, repository, gateway, enabled=True)
179 outcome = await executor.execute_primary(
180 INSTRUMENT_ID,
181 (target(),),
182 jurisdiction="INDIA",
183 correlation_id="correlation-5b",
184 identity_headers={},
185 )
186 assert gateway.calls[0][1]["request_id"] == "correlation-5b"
187 assert legacy.calls == []
188 assert repository.structured and repository.prices
189 assert outcome.satisfied_requirement_ids == ("LATEST_PRICE",)
190 assert outcome.failures == {}
191
192
193 @pytest.mark.asyncio
194 async def test_completed_yahoo_requirement_is_not_sent_to_secondary_fallback() -> None:
195 repository, gateway, legacy = FakeRepository(), FakeGateway(), FakeLegacy()
196 executor = McpFirstResearchCapabilityExecutor(legacy, repository, gateway, enabled=True)
197
198 class DataSource:
199 def mark_refreshing(self, *_args):
200 pass
201
202 def finish_refresh(self, *_args):
203 pass
204
205 runtime = object.__new__(ResearchReadinessRuntime)
206 runtime.data_source = DataSource()
207 runtime.executor = executor
208
209 async def still_missing(*_args, **_kwargs):
210 return SimpleNamespace(
211 for_requirement=lambda _requirement: SimpleNamespace(
212 status=ResearchRequirementStatus.MISSING
213 )
214 )
215
216 runtime.read = still_missing
217 plan = ResearchRefreshPlan(INSTRUMENT_ID, (target(),), NOW)
218 await runtime._execute_plan(
219 plan,
220 jurisdiction="INDIA",
221 correlation_id="no-double-call",
222 identity_headers={},
223 )
224 assert legacy.calls == []
225 assert legacy.fallback_calls == []
226
227
228 @pytest.mark.asyncio
229 @pytest.mark.parametrize(
230 "code",
231 [
232 "EXTERNAL_CAPABILITY_UNSUPPORTED",
233 "DOWNSTREAM_TIMEOUT",
234 "EXTERNAL_SCHEMA_INVALID",
235 "EXTERNAL_RESULT_INCOMPLETE",
236 "EXTERNAL_IDENTITY_CONFLICT",
237 "EXTERNAL_RESULT_STALE",
238 "EXTERNAL_PROVIDER_UNAVAILABLE",
239 ],
240 )
241 async def test_yahoo_failure_conditions_keep_reason_when_existing_fallback_is_empty(code) -> None:
242 repository = FakeRepository()
243 gateway = FakeGateway(ExternalMcpAcquisitionError(code))
244 legacy = FakeLegacy()
245 executor = McpFirstResearchCapabilityExecutor(legacy, repository, gateway, enabled=True)
246 outcome = await executor.execute_primary(
247 INSTRUMENT_ID,
248 (target(),),
249 jurisdiction="INDIA",
250 correlation_id="fallback-5b",
251 identity_headers={},
252 )
253 assert len(legacy.calls) == 1
254 assert outcome.executed_capabilities == (
255 "YAHOO_FINANCE_MCP:LATEST_PRICE",
256 "LEGACY_PROVIDER",
257 )
258 assert outcome.failures == {"LATEST_PRICE": code}
259
260
261 @pytest.mark.asyncio
262 async def test_mcp_incomplete_and_partial_legacy_result_keep_both_diagnostics() -> None:
263 repository = FakeRepository()
264 gateway = FakeGateway(ExternalMcpAcquisitionError("EXTERNAL_RESULT_INCOMPLETE"))
265 legacy = FakeLegacy("LEGACY_RETURNED_PARTIAL_EVIDENCE")
266 executor = McpFirstResearchCapabilityExecutor(legacy, repository, gateway, enabled=True)
267
268 outcome = await executor.execute_primary(
269 INSTRUMENT_ID,
270 (target(),),
271 jurisdiction="INDIA",
272 correlation_id="partial-fallback-5b",
273 identity_headers={},
274 )
275
276 assert outcome.failures == {
277 "LATEST_PRICE": "EXTERNAL_RESULT_INCOMPLETE|LEGACY_RETURNED_PARTIAL_EVIDENCE"
278 }
279
280
281 @pytest.mark.asyncio
282 async def test_verified_yahoo_mapping_is_required_before_network_access() -> None:
283 gateway = HttpExternalResearchToolGateway("http://mcp-gateway", 1, "research-engine")
284 grant = target().authority_policy.fallback_policy.authorize_external_tool(
285 global_instrument_id=INSTRUMENT_ID,
286 requirement_id="LATEST_PRICE",
287 status=ResearchRequirementStatus.MISSING,
288 confidence=None,
289 permitted_provider_ids=("YAHOO_FINANCE_MCP",),
290 now=NOW,
291 )
292 with pytest.raises(ExternalMcpAcquisitionError, match="VERIFIED_YAHOO_MAPPING_REQUIRED"):
293 await gateway.acquire_requirement(
294 profile(mapping=False),
295 region="INDIA",
296 requirement_id="LATEST_PRICE",
297 authorization=grant,
298 request_id="mapping-required",
299 )
300
301
302 @pytest.mark.asyncio
303 async def test_persister_does_not_mutate_verified_mapping() -> None:
304 repository = FakeRepository()
305 before = dict(repository.item.provider_instrument_ids)
306 await YahooMcpResultPersister(repository).persist(result(), repository.item)
307 assert repository.item.provider_instrument_ids == before
308
309
310 def _financial(metric, value, tier, provider):
311 key = FinancialFactKey(INSTRUMENT_ID, metric, "2025-03-31", "ANNUAL", "CONSOLIDATED")
312 return FinancialFact(
313 key,
314 ProvenancedValue(
315 value=Decimal(value),
316 source_url="https://source.test/fact",
317 source_name=provider,
318 retrieved_at=NOW,
319 ),
320 tier,
321 provider,
322 f"{provider}:{metric}",
323 SourceMode.REAL,
324 )
325
326
327 def test_mcp_first_acquisition_does_not_downgrade_official_fact_precedence() -> None:
328 official = _financial("revenue", "100", FactSourceTier.OFFICIAL_NSE, "NSE")
329 yahoo = _financial("revenue", "90", FactSourceTier.YAHOO, "YAHOO_FINANCE_MCP")
330 assert merge_fact(official, yahoo) is official
331 assert merge_fact(yahoo, official) is official
332
333
334 @pytest.mark.asyncio
335 async def test_mcp_persister_keeps_existing_official_fact_for_same_period() -> None:
336 canonical = profile()
337 repository = ResearchRepository(
338 settings=Settings(research_live_enabled=False, research_demo_enabled=False),
339 persistence=SqliteResearchPersistence(),
340 )
341 repository.profiles.append(canonical)
342 official = _financial("revenue", "100", FactSourceTier.OFFICIAL_NSE, "NSE")
343 assert repository.persistence.upsert_financial_fact(official) is True
344 normalized = result(
345 "BUSINESS_QUALITY_FACTS",
346 structuredFacts=[],
347 marketObservations=[],
348 financialFacts=[
349 {
350 "metric": "revenue",
351 "value": "90",
352 "unit": "INR",
353 "asOf": NOW.isoformat(),
354 "publishedAt": NOW.isoformat(),
355 "sourceUrl": "https://finance.yahoo.com/quote/READY.NS",
356 "confidence": 0.8,
357 "rawFieldOrigin": "totalRevenue",
358 "periodEnd": "2025-03-31",
359 "periodType": "ANNUAL",
360 "reportingBasis": "CONSOLIDATED",
361 }
362 ],
363 )
364 await YahooMcpResultPersister(repository).persist(normalized, canonical)
365 saved = repository.financial_facts_for(INSTRUMENT_ID)
366 assert len(saved) == 1
367 assert saved[0].source_provider == "NSE"
368 assert saved[0].value.value == Decimal("100")
369
370
371 def test_wire_contract_rejects_private_or_unknown_provider_fields() -> None:
372 raw = result().model_dump(mode="json", by_alias=True)
373 raw["quantity"] = 10
374 with pytest.raises(ValidationError):
375 YahooMcpNormalizedResult.model_validate(raw)
376
377
378 @pytest.mark.asyncio
379 async def test_successful_empty_news_is_durable_metadata_without_invented_events():
380 persistence = SqliteResearchPersistence()
381 repository = ResearchRepository(settings=Settings(research_live_enabled=False, research_demo_enabled=False), persistence=persistence)
382 canonical = profile()
383 repository.profiles.append(canonical)
384 empty = result("CURRENT_NEWS", structuredFacts=[], marketObservations=[], acquisitionOutcome="SUCCESS_EMPTY")
385 assert await YahooMcpResultPersister(repository).persist(empty, canonical) == 0
386 rows = persistence.load_acquisition_observations(INSTRUMENT_ID)
387 assert rows[0]["outcome"] == "SUCCESS_EMPTY"
388 assert rows[0]["evidence_count"] == 0
389 assert persistence.load_events() == []
390 assert persistence.load_documents() == []
391
392 @pytest.mark.asyncio
393 async def test_targeted_refresh_keeps_other_durable_failure_states():
394 from app.research_readiness_runtime import RepositoryResearchReadinessAdapter
395 from app.research_readiness import ResearchRequirementRegistry
396 persistence = SqliteResearchPersistence()
397 repository = ResearchRepository(settings=Settings(research_live_enabled=False, research_demo_enabled=False), persistence=persistence)
398 repository.profiles.append(profile())
399 await repository.record_acquisition_observation(INSTRUMENT_ID, "CURRENT_NEWS", "READINESS_EXECUTOR", "FAILED", NOW, failure_reason="ACQUISITION_TIMEOUT")
400 adapter = RepositoryResearchReadinessAdapter(repository)
401 adapter.finish_refresh(INSTRUMENT_ID, {"VALUATION_INPUTS": "PROVIDER_UNAVAILABLE"})
402 snapshot = adapter.load_by_global_instrument_id(INSTRUMENT_ID, ResearchRequirementRegistry.default().requirements)
403 assert snapshot.failure_reasons["CURRENT_NEWS"] == "ACQUISITION_TIMEOUT"
404 assert snapshot.failure_reasons["VALUATION_INPUTS"] == "PROVIDER_UNAVAILABLE"