| 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®ion=INDIA") |
| 263 | assert no_auth.status_code == 401 |
| 264 | |
| 265 | bad_region = client.get( |
| 266 | "/api/v1/research/instruments/search?q=CHENNPETRO®ion=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®ion=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 |