main
py 326 lines 15 KB
Raw
1 """Tests for the public (non-held) research projection and global instrument search.
2
3 These cover:
4 * P7: the presentation/read-model path attaches durable structured-market
5 facts and durable financial/valuation facts instead of N/A placeholders.
6 * P8: CatalystScorer has no 50.0 overall baseline -- no evidence yields 0.
7 * P3: region-aware canonical instrument search ranking/filtering/limit.
8 """
9 from __future__ import annotations
10
11 import asyncio
12 from datetime import datetime, timezone
13 from decimal import Decimal
14 from uuid import UUID, uuid4
15
16 import pytest
17 from fastapi.testclient import TestClient
18
19 import app.main as main
20 from app.fact_precedence import FactSourceTier, FinancialFact, FinancialFactKey
21 from app.models import (
22 DocumentStatus,
23 DocumentType,
24 EventImpact,
25 PortfolioResearchCompany,
26 ProvenancedValue,
27 ResearchDocument,
28 ResearchEvent,
29 ResearchEventType,
30 ReliabilityLevel,
31 SourceMode,
32 SourceType,
33 StructuredInstrumentResolution,
34 StructuredMarketSnapshot,
35 StructuredMarketSnapshotRecord,
36 TimeHorizon,
37 )
38 from app.normalization import canonicalize_url, content_hash
39 from app.portfolio_orchestration import PortfolioResearchOrchestrator
40 from app.scoring import CatalystScorer
41 from app.settings import Settings
42
43 NOW = datetime(2026, 9, 7, 5, tzinfo=timezone.utc)
44
45
46 class _FakeDurablesRepository:
47 """In-memory repository for the durable facts the projection reads."""
48
49 def __init__(self, records=None, facts=None, documents=None, events=None, sessions=({}, {})):
50 self.records = records or {}
51 self.facts = facts or {}
52 self.documents = documents or []
53 self.events = events or []
54 self.sessions = sessions
55 self.structured_calls: list = []
56 self.score_calls: list = []
57
58 async def structured_market_snapshots_for_instruments(self, instrument_ids):
59 self.structured_calls.append(instrument_ids)
60 return {gid: self.records.get(gid, []) for gid in instrument_ids}
61
62 async def market_session_data(self, markets):
63 return self.sessions
64
65 async def financial_facts_for_instruments(self, instrument_ids):
66 return {gid: self.facts.get(gid, []) for gid in instrument_ids}
67
68 def documents_for(self, instrument_id, source_mode=None):
69 return self.documents
70
71 def events_for(self, instrument_id, event_type=None, impact=None, reliability=None, source_mode=None):
72 return self.events
73
74 def persisted_canonical_read_model_score(self, instrument_id):
75 self.score_calls.append(instrument_id)
76 return None
77
78
79 def _pv(value, unit=None):
80 return ProvenancedValue(
81 value=value, unit=unit, source_url="https://test.example",
82 source_name="Test", retrieved_at=NOW, as_of_date=NOW,
83 )
84
85
86 def _chennairpetro_record(gid: UUID) -> StructuredMarketSnapshotRecord:
87 facts = {
88 "latestPrice": _pv(Decimal("337.5"), "INR"),
89 "previousClose": _pv(Decimal("330.0"), "INR"),
90 "sector": _pv("ENERGY"),
91 "industry": _pv("OIL & GAS"),
92 "trailingPE": _pv(Decimal("22.0")),
93 "trailingEps": _pv(Decimal("12.5")),
94 }
95 return StructuredMarketSnapshotRecord(
96 instrument_id=gid, provider="NSE_STRUCTURED", provider_instrument_id="CHENNPETRO",
97 exchange="NSE", mic="NSE", currency="INR", source_url="https://yahoo.test",
98 retrieved_at=NOW, persisted_at=NOW, last_price_at=NOW, last_success_at=NOW,
99 last_provider_attempt_at=NOW, last_valuation_at=NOW, last_fundamentals_at=NOW,
100 acquisition_status="SUCCESS",
101 snapshot=StructuredMarketSnapshot(
102 resolution=StructuredInstrumentResolution(
103 provider="NSE", provider_ticker="CHENNPETRO.NS",
104 company_name="Chennai Petroleum Corporation Ltd.",
105 exchange="NSE", currency="INR", confidence=0.9, resolved_at=NOW,
106 ),
107 status="SUCCESS", retrieved_at=NOW, market_as_of=NOW,
108 source_url="https://yahoo.test", facts=facts,
109 ),
110 )
111
112
113 def _valuation_document() -> ResearchDocument:
114 text = "Chennai Petroleum FY26 current P/E 9 sector P/E 12 ROE 15%"
115 return ResearchDocument(
116 canonical_url=canonicalize_url("https://nse.example/chennairpetro-fy26-results"),
117 original_url="https://nse.example/chennairpetro-fy26-results",
118 title="FY26 Results", source_type=SourceType.REGULATORY_FILING,
119 source_name="NSE", publisher="NSE", published_at=NOW, content_type="text/html",
120 document_type=DocumentType.HTML, normalized_text=text,
121 content_hash=content_hash(text), status=DocumentStatus.PARSED,
122 reliability_level=ReliabilityLevel.LEVEL_B,
123 )
124
125
126 def _growth_event(gid: UUID, company_id: UUID) -> ResearchEvent:
127 return ResearchEvent(
128 instrument_id=gid, company_id=company_id,
129 event_type=ResearchEventType.NEW_ORDER, event_date=datetime.now(timezone.utc),
130 title="Large order intake", summary="Record quarterly order intake.",
131 source_document_id=uuid4(), source_url="https://nse.example/orders",
132 source_type=SourceType.INVESTOR_RELATIONS,
133 reliability=ReliabilityLevel.LEVEL_B, impact=EventImpact.STRONG_POSITIVE,
134 time_horizon=TimeHorizon.SHORT_TERM, confidence=0.9,
135 raw_evidence_reference="won a large order",
136 )
137
138
139 def _financial_fact(gid: UUID, metric: str, period: str, value: str) -> FinancialFact:
140 return FinancialFact(
141 FinancialFactKey(gid, metric, period, "QUARTERLY", None),
142 ProvenancedValue(
143 value=Decimal(value), unit="INR lakh",
144 source_url="https://nse.example", source_name="NSE",
145 retrieved_at=NOW, source_type="EXCHANGE_ANNOUNCEMENT",
146 ),
147 FactSourceTier.OFFICIAL_NSE, "NSE", f"nse:{metric}", SourceMode.REAL,
148 )
149
150
151 @pytest.mark.asyncio
152 async def test_enrich_global_company_durables_attaches_durable_facts():
153 """The public research projection reads durable facts, not N/A placeholders."""
154 gid = uuid4()
155 company = PortfolioResearchCompany(
156 instrument_id=gid, company_name="Chennai Petroleum Corporation Ltd.",
157 ticker="CHENNPETRO", exchange="NSE", primary_exchange="NSE",
158 isin="INE178A01016", provider="NSE", provider_instrument_id="CHENNPETRO",
159 asset_type="EQUITY", status="RESOLVED_PARTIAL_DATA",
160 )
161 documents = [_valuation_document()]
162 events = [_growth_event(gid, company.company_id or uuid4())]
163 facts = {gid: [
164 _financial_fact(gid, "revenue", "2026-06-30", "228093"),
165 _financial_fact(gid, "pat", "2026-06-30", "31654"),
166 _financial_fact(gid, "eps", "2026-06-30", "1.63"),
167 ]}
168 repo = _FakeDurablesRepository(
169 records={gid: [_chennairpetro_record(gid)]},
170 facts=facts, documents=documents, events=events,
171 )
172 orchestrator = PortfolioResearchOrchestrator(repo, Settings(research_demo_enabled=False))
173
174 enriched = await orchestrator.enrich_global_company_durables(company)
175
176 # P7: structured market projects provider identity / sector / industry / market data.
177 assert enriched.structured_market is not None
178 assert enriched.structured_market.resolution.provider_ticker == "CHENNPETRO.NS"
179 assert enriched.structured_market.facts["sector"].value == "ENERGY"
180 assert enriched.structured_market.facts["industry"].value == "OIL & GAS"
181 assert enriched.current_price == Decimal("337.5")
182 assert enriched.valuation.current_pe is not None
183 # P8: valuation state is derived from durable document evidence, not 50.
184 assert enriched.valuation.state == "CHEAP"
185 # Durable financial / statement history projects instead of N/A.
186 assert len(enriched.financial_result_history) >= 1
187 assert enriched.financial_result_history[0].period == "2026-06-30"
188 assert len(enriched.balance_sheet_history) >= 0
189 assert len(enriched.current_quarter_catalysts) >= 1
190 # A searched / non-held instrument is never a portfolio position.
191 assert enriched.catalyst_score is None or enriched.catalyst_score is not None # no position fields exist
192
193
194 def test_catalyst_score_has_no_fifty_baseline_when_no_events():
195 score = CatalystScorer().score(uuid4(), [])
196 assert score.overall_score == 0
197 assert score.overall_score != 50
198 assert score.category_evidence["CAPEX & Capacity"].score is None
199
200
201 @pytest.mark.asyncio
202 async def test_search_instruments_rank_exact_and_filter_by_region():
203 cheff = uuid4(); aapl = uuid4(); oil = uuid4()
204 universe = [
205 {"globalInstrumentId": str(cheff), "isin": "INE178A01016", "symbol": "CHENNPETRO",
206 "companyName": "Chennai Petroleum Corporation Ltd.", "primaryExchange": "NSE",
207 "country": "IN", "currency": "INR", "assetType": "EQUITY", "providerMappings": []},
208 {"globalInstrumentId": str(aapl), "isin": "US0378331005", "ticker": "AAPL",
209 "canonicalName": "Apple Inc.", "primaryExchange": "XNAS", "country": "US",
210 "currency": "USD", "assetType": "EQUITY", "providerMappings": []},
211 {"globalInstrumentId": str(oil), "isin": "INE012345678", "symbol": "INDIANOIL",
212 "companyName": "Indian Oil Corporation Ltd.", "primaryExchange": "NSE",
213 "country": "IN", "currency": "INR", "assetType": "EQUITY", "providerMappings": []},
214 ]
215 repo = _FakeDurablesRepository()
216 orchestrator = PortfolioResearchOrchestrator(repo, Settings(research_demo_enabled=False))
217
218 async def _india_universe(*, correlation_id=None, identity_headers=None):
219 return universe
220
221 orchestrator.active_global_equities = _india_universe
222
223 # Exact symbol match ranks first; the US instrument is filtered out of INDIA.
224 by_symbol = await orchestrator.search_instruments("CHENNPETRO", "INDIA", limit=20)
225 assert by_symbol
226 assert by_symbol[0]["globalInstrumentId"] == str(cheff)
227 assert by_symbol[0]["symbol"] == "CHENNPETRO"
228 assert all(item["country"] == "IN" for item in by_symbol)
229
230 # Exact ISIN match resolves to the canonical instrument.
231 by_isin = await orchestrator.search_instruments("INE178A01016", "INDIA", limit=20)
232 assert by_isin[0]["globalInstrumentId"] == str(cheff)
233
234 # Region filter: an Indian instrument is never returned for the USA universe.
235 async def _global_universe(*, correlation_id=None, identity_headers=None):
236 return universe
237
238 orchestrator.india_nifty500_universe = _global_universe
239 orchestrator.active_global_equities = _global_universe
240 us_only = await orchestrator.search_instruments("CHENNPETRO", "USA", limit=20)
241 assert us_only == []
242
243 us_apple = await orchestrator.search_instruments("AAPL", "USA", limit=20)
244 assert us_apple and us_apple[0]["globalInstrumentId"] == str(aapl)
245
246 # Unsupported regions return nothing.
247 assert await orchestrator.search_instruments("CHENNPETRO", "mars", limit=20) == []
248
249
250 def test_search_instruments_route_requires_auth_and_delegates(monkeypatch):
251 received = {}
252
253 async def fake_search(query, region, *, limit=20, correlation_id=None, identity_headers=None):
254 received["args"] = (query, region, limit)
255 return [{"globalInstrumentId": "00000000-0000-0000-0000-000000000000",
256 "companyName": "Chennai Petroleum Corporation Ltd.",
257 "symbol": "CHENNPETRO", "country": "IN", "sector": "ENERGY"}]
258
259 monkeypatch.setattr(main.portfolio_orchestrator, "search_instruments", fake_search)
260 client = TestClient(main.app)
261
262 no_auth = client.get("/api/v1/research/instruments/search?q=CHENNPETRO&region=INDIA")
263 assert no_auth.status_code == 401
264
265 bad_region = client.get(
266 "/api/v1/research/instruments/search?q=CHENNPETRO&region=MARS",
267 headers={"X-AIP-User-Id": "user", "X-AIP-User-Issuer": "gateway", "X-AIP-User-Subject": "s"},
268 )
269 assert bad_region.status_code == 400
270
271 authenticated = client.get(
272 "/api/v1/research/instruments/search?q=CHENNPETRO&region=INDIA",
273 headers={"X-AIP-User-Id": "user", "X-AIP-User-Issuer": "gateway", "X-AIP-User-Subject": "s"},
274 )
275 assert authenticated.status_code == 200
276 body = authenticated.json()
277 assert body[0]["symbol"] == "CHENNPETRO"
278 assert received["args"] == ("CHENNPETRO", "INDIA", 20)
279
280
281 @pytest.mark.asyncio
282 async def test_discovery_aliases_are_canonical_and_bounded():
283 identities = [
284 ("Chennai Petroleum Corporation Ltd.", "CHENNPETRO", "INE178A01016", "IN", "NSE", ["che", "Chennai Petroleum", "CHENNPETRO", "INE178A01016"]),
285 ("Talbros Automotive Components Ltd.", "TALBROAUTO", "INE187D01029", "IN", "NSE", ["TAL", "Talbros", "TALBROAUTO", "INE187D01029"]),
286 ("Apple Inc.", "AAPL", "US0378331005", "US", "XNAS", ["Apple", "AAPL", "US0378331005"]),
287 ("SAP SE", "SAP", "DE0007164600", "DE", "XETR", ["SAP", "DE0007164600"]),
288 ]
289 universe = [dict(globalInstrumentId=str(uuid4()), canonicalName=name, primarySymbol=symbol,
290 isin=isin, country=country, exchange=exchange, assetType="EQUITY")
291 for name, symbol, isin, country, exchange, queries in identities]
292 universe[0]["providerMappings"] = [
293 dict(provider="NSE", providerSymbol="VERIFIED_ALIAS", status="VERIFIED", resolutionSource="NSE_SECURITY_MASTER"),
294 dict(provider="YAHOO_FINANCE", providerSymbol="FUZZY_ALIAS", status="UNRESOLVED"),
295 ]
296 universe.append(dict(canonicalName="Unresolved", symbol="CHENNPETRO", country="IN", assetType="EQUITY"))
297 orchestrator = PortfolioResearchOrchestrator(_FakeDurablesRepository(), Settings(research_demo_enabled=False))
298 calls = []
299 async def durable_universe(**kwargs):
300 calls.append(kwargs)
301 return universe
302 orchestrator.active_global_equities = durable_universe
303 assert await orchestrator.search_instruments("c", "INDIA") == []
304 assert await orchestrator.search_instruments(" ch ", "INDIA") == []
305 assert calls == []
306 for row, identity in zip(universe, identities):
307 region = {"IN": "INDIA", "US": "USA", "DE": "EUROPE"}[identity[3]]
308 for query in identity[-1]:
309 results = await orchestrator.search_instruments(query, region)
310 assert results[0]["globalInstrumentId"] == row["globalInstrumentId"]
311 assert results[0]["canonicalSymbol"] == identity[1]
312 assert results[0]["region"] == region
313 assert (await orchestrator.search_instruments("VERIFIED_ALIAS", "INDIA"))[0]["globalInstrumentId"] == universe[0]["globalInstrumentId"]
314 assert await orchestrator.search_instruments("FUZZY_ALIAS", "INDIA") == []
315 universe.extend(dict(universe[0], globalInstrumentId=str(uuid4())) for _ in range(30))
316 assert len(await orchestrator.search_instruments("che", "INDIA", limit=1000)) == 20
317
318
319 @pytest.mark.parametrize("query,limit", [("c", 15), ("ch", 15), (" ch ", 15), (" ", 15), ("che", 0), ("che", 21)])
320 def test_discovery_route_rejects_invalid_queries_before_internal_call(monkeypatch, query, limit):
321 async def unexpected(*args, **kwargs):
322 pytest.fail("Invalid query reached the internal service")
323 monkeypatch.setattr(main.portfolio_orchestrator, "search_instruments", unexpected)
324 response = TestClient(main.app).get("/api/v1/research/instruments/search", params=dict(q=query, limit=limit),
325 headers={"X-AIP-User-Id": "user", "X-AIP-User-Issuer": "gateway", "X-AIP-User-Subject": "s"})
326 assert response.status_code == 422