main
py 457 lines 25.5 KB
Raw
1 from decimal import Decimal
2
3 import httpx
4 import pandas as pd
5 import pytest
6
7 from app.settings import Settings
8 from app.research_fetching import FetchError, HttpResearchFetcher
9 from app.structured_market import StructuredProviderError, YahooFinanceProvider, strongest_company_identity, _resolution_queries, _candidate_score
10
11
12 def provider(handler):
13 client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
14 return YahooFinanceProvider(Settings(), client)
15
16
17 def response(data, status=200):
18 return httpx.Response(status, json=data)
19
20
21 class FakeTicker:
22 def __init__(self, _symbol, info, news=None, income_stmt=None, balance_sheet=None, **statements):
23 self.info = info
24 self.news = news or []
25 self.income_stmt = income_stmt
26 self.balance_sheet = balance_sheet
27 for name, value in statements.items(): setattr(self, name, value)
28
29
30 def yfinance_provider(info, news=None, income_stmt=None, balance_sheet=None, **statements):
31 return YahooFinanceProvider(Settings(), httpx.AsyncClient(),
32 ticker_factory=lambda symbol: FakeTicker(symbol, info, news, income_stmt, balance_sheet, **statements))
33
34
35 def instrument(**updates):
36 value = {
37 "canonicalName": "ZEN TECHNOLOGIES LTD", "isin": "INE251B01027",
38 "canonicalSymbol": "ZENTEC", "brokerSymbol": "ZENTEC", "country": "IN",
39 "tradingCurrency": "INR", "assetType": "EQUITY",
40 }
41 value.update(updates)
42 return value
43
44
45 @pytest.mark.asyncio
46 async def test_resolves_nse_equity_and_normalizes_partial_fundamentals_with_provenance():
47 def handler(request):
48 if "/finance/search" in request.url.path:
49 return response({"quotes": [{"symbol": "ZENTEC.NS", "quoteType": "EQUITY", "longname": "Zen Technologies Limited", "exchange": "NSI", "currency": "INR"}]})
50 if request.url.path.endswith("/finance/quote"):
51 return response({"quoteResponse": {"result": [{"regularMarketPrice": 1825.6, "currency": "INR", "regularMarketTime": 1788076800, "trailingPE": 42.6}]}})
52 return response({"quoteSummary": {"result": [{"financialData": {"returnOnEquity": {"raw": .21}, "totalDebt": {"raw": 1000}}, "assetProfile": {"sector": "Industrials"}}]}})
53
54 snapshot = await provider(handler).collect(instrument())
55 assert snapshot.resolution.provider_ticker == "ZENTEC.NS"
56 assert snapshot.resolution.currency == "INR"
57 assert snapshot.facts["latestPrice"].value == Decimal("1825.6")
58 assert snapshot.facts["trailingPE"].value == Decimal("42.6")
59 assert snapshot.facts["roe"].value == Decimal("21.00")
60 assert snapshot.facts["latestPrice"].source_type == "STRUCTURED_MARKET_PROVIDER"
61
62
63 @pytest.mark.asyncio
64 async def test_yahoo_statement_adapter_requires_explicit_period_and_marks_unknown_basis():
65 period = pd.Timestamp("2026-06-30")
66 quarterly = pd.DataFrame({period: {"Total Revenue": 1000, "Net Income": 120, "EBITDA": 200, "Diluted EPS": 4.5}})
67 snapshot = await yfinance_provider({"currency": "INR", "quoteType": "EQUITY", "regularMarketPrice": 10},
68 quarterly_income_stmt=quarterly).collect(instrument(structuredProviderTicker="ZENTEC.NS", structuredProviderStatus="VERIFIED"))
69 values = {(item["metric"], item["periodType"], item["periodEnd"], item["reportingBasis"]): item["value"] for item in snapshot.statement_facts}
70 assert values[("revenue", "QUARTERLY", "2026-06-30T00:00:00", "UNKNOWN")] == Decimal("1000")
71 assert values[("pat", "QUARTERLY", "2026-06-30T00:00:00", "UNKNOWN")] == Decimal("120")
72 assert next(item for item in snapshot.statement_facts if item["metric"] == "revenue")["rawFieldOrigin"] == "Total Revenue"
73 assert "totalDebt" not in {item["metric"] for item in snapshot.statement_facts}
74
75
76 @pytest.mark.asyncio
77 async def test_resolves_eu_listing_and_rejects_wrong_currency_candidate():
78 def handler(request):
79 if "/finance/search" in request.url.path:
80 return response({"quotes": [
81 {"symbol": "BESI.AS", "quoteType": "EQUITY", "longname": "BE Semiconductor Industries N.V.", "exchange": "AMS", "currency": "EUR"},
82 {"symbol": "BESI", "quoteType": "EQUITY", "longname": "BE Semiconductor Industries", "exchange": "NMS", "currency": "USD"},
83 ]})
84 if request.url.path.endswith("/finance/quote"):
85 return response({"quoteResponse": {"result": [{"regularMarketPrice": 125, "currency": "EUR"}]}})
86 return response({"quoteSummary": {"result": []}})
87 snapshot = await provider(handler).collect(instrument(canonicalName="BE SEMICONDUCTOR INDUSTRIES", canonicalSymbol="BESI", brokerSymbol="BESI", country="NL", tradingCurrency="EUR"))
88 assert snapshot.resolution.provider_ticker == "BESI.AS"
89
90
91 @pytest.mark.asyncio
92 async def test_rejects_ambiguous_or_wrong_instrument_instead_of_attaching_data():
93 def handler(request):
94 return response({"quotes": [
95 {"symbol": "RENK.DE", "quoteType": "EQUITY", "longname": "RENK Group AG", "exchange": "GER", "currency": "EUR"},
96 {"symbol": "R3NK.DE", "quoteType": "EQUITY", "longname": "RENK Group AG", "exchange": "GER", "currency": "EUR"},
97 ]})
98 with pytest.raises(StructuredProviderError, match="RESOLUTION_AMBIGUOUS"):
99 await provider(handler).resolve_instrument(instrument(canonicalName="RENK GROUP AG", canonicalSymbol="", brokerSymbol="",
100 brokerExchange="IBIS2", country="DE", tradingCurrency="EUR"))
101
102
103 @pytest.mark.asyncio
104 async def test_provider_failure_is_typed_and_does_not_create_fake_fields():
105 async_provider = provider(lambda request: response({}, 503))
106 with pytest.raises(StructuredProviderError, match="STRUCTURED_PROVIDER_UNAVAILABLE"):
107 await async_provider.collect(instrument(canonicalName="ARCADIS NV", country="NL", tradingCurrency="EUR"))
108
109
110 def test_identity_priority_and_overview_fallback_trim_nbsp_and_separator():
111 assert strongest_company_identity({"canonicalName": "RENK", "overview": "RENK GROUP AG · R3NK"}) == "RENK GROUP AG"
112 assert strongest_company_identity({"overview": " AALBERTS N.V.\u00a0·\u00a0AALB "}) == "AALBERTS N.V."
113 assert strongest_company_identity({"overview": "BE Semiconductor Industries\u00c2\u00b7 BESI"}) == "BE Semiconductor Industries"
114
115
116 def test_full_overview_company_precedes_symbol_and_preserves_symbol_as_separate_evidence():
117 value = instrument(canonicalName="", companyName="", overview="BE Semiconductor Industries N.V. · BESI",
118 brokerSymbol="BESI", ticker="BESI")
119 queries = _resolution_queries(value, strongest_company_identity(value))
120 assert queries[0] == "BE Semiconductor Industries N.V."
121 assert queries == ["BE Semiconductor Industries N.V."]
122 assert value["brokerSymbol"] == "BESI"
123 assert all(not query.endswith((".AS", ".DE")) for query in queries)
124
125
126 def test_symbol_is_discovery_fallback_only_without_usable_company_name():
127 value = instrument(canonicalName="", companyName="", brokerDescription="", overview="", isin="",
128 canonicalSymbol="", brokerSymbol="BESI", ticker="BESI")
129 queries = _resolution_queries(value, strongest_company_identity(value))
130 assert queries[0] == "BESI"
131
132
133 @pytest.mark.asyncio
134 async def test_yahoo_first_discovery_request_uses_full_overview_company_not_symbol():
135 searches = []
136 def handler(request):
137 searches.append(request.url.params.get("q"))
138 return response({"quotes": []})
139 value = instrument(canonicalName="BESI", companyName="", brokerDescription="",
140 overview="BE Semiconductor Industries N.V. · BESI", isin="", canonicalSymbol="",
141 brokerSymbol="BESI", ticker="BESI")
142 with pytest.raises(StructuredProviderError, match="COMPANY_NOT_RESOLVED"):
143 await provider(handler).resolve_instrument(value)
144 assert searches[0] == "BE Semiconductor Industries N.V."
145 assert searches == ["BE Semiconductor Industries N.V."]
146
147
148 def test_listing_evidence_beats_higher_company_name_similarity_for_multi_listed_company():
149 held = instrument(canonicalName="Acme Industries N.V.", brokerSymbol="ACME", ticker="ACME",
150 brokerExchange="IBIS2", canonicalExchange="SMART", tradingCurrency="EUR")
151 wrong_listing = {"symbol": "ACME.AS", "quoteType": "EQUITY", "longname": "Acme Industries N.V.",
152 "exchange": "AMS", "currency": "EUR"}
153 held_listing = {"symbol": "ACM.DE", "quoteType": "EQUITY", "longname": "Acme Industries",
154 "exchange": "GER", "currency": "EUR"}
155 assert _candidate_score(held, held["canonicalName"], wrong_listing) == 0
156 assert _candidate_score(held, held["canonicalName"], held_listing) > 0.70
157
158
159 def test_amsterdam_contract_selects_amsterdam_listing_for_same_company():
160 held = instrument(canonicalName="Acme Industries N.V.", brokerSymbol="ACME", ticker="ACME",
161 brokerExchange="AEB", canonicalExchange="SMART", tradingCurrency="EUR")
162 amsterdam = {"symbol": "ACME.AS", "quoteType": "EQUITY", "longname": "Acme Industries N.V.",
163 "exchange": "AMS", "currency": "EUR"}
164 german = {"symbol": "ACM.DE", "quoteType": "EQUITY", "longname": "Acme Industries N.V.",
165 "exchange": "GER", "currency": "EUR"}
166 assert _candidate_score(held, held["canonicalName"], amsterdam) > 0.70
167 assert _candidate_score(held, held["canonicalName"], german) == 0
168
169
170 def test_currency_security_type_and_isin_are_listing_validation_evidence():
171 held = instrument(canonicalName="Acme Industries", isin="NL0000000001", brokerExchange="AEB",
172 tradingCurrency="EUR", securityType="COMMON")
173 matching = {"symbol": "ACME.AS", "quoteType": "EQUITY", "longname": "Acme Industries",
174 "exchange": "AMS", "currency": "EUR", "isin": "NL0000000001"}
175 wrong_currency = dict(matching, currency="USD")
176 wrong_type = dict(matching, quoteType="ETF")
177 wrong_isin = dict(matching, isin="NL0000000002")
178 assert _candidate_score(held, held["canonicalName"], matching) == 1.0
179 assert _candidate_score(held, held["canonicalName"], wrong_currency) == 0
180 assert _candidate_score(held, held["canonicalName"], wrong_type) == 0
181 assert _candidate_score(held, held["canonicalName"], wrong_isin) == 0
182
183
184 @pytest.mark.asyncio
185 async def test_full_name_discovers_multiple_listings_then_held_exchange_selects_one():
186 searches = []
187 candidates = [
188 {"symbol": "ACME.AS", "quoteType": "EQUITY", "longname": "Acme Industries N.V.", "exchange": "AMS", "currency": "EUR"},
189 {"symbol": "ACM.DE", "quoteType": "EQUITY", "longname": "Acme Industries", "exchange": "GER", "currency": "EUR"},
190 ]
191 def handler(request):
192 searches.append(request.url.params.get("q"))
193 return response({"quotes": candidates})
194 held = instrument(canonicalName="Acme Industries N.V.", overview="Acme Industries N.V. · ACME",
195 canonicalSymbol="ACME", brokerSymbol="ACME", ticker="ACME",
196 brokerExchange="IBIS2", canonicalExchange="SMART", tradingCurrency="EUR")
197 result = await provider(handler).resolve_instrument(held)
198 assert searches == ["Acme Industries N.V."]
199 assert result.provider_ticker == "ACM.DE"
200
201
202 @pytest.mark.asyncio
203 async def test_resolution_and_snapshot_are_reused_within_configured_freshness():
204 calls = 0
205 def handler(request):
206 nonlocal calls
207 calls += 1
208 if "/finance/search" in request.url.path:
209 return response({"quotes": [{"symbol": "ZENTEC.NS", "quoteType": "EQUITY", "longname": "Zen Technologies Limited", "exchange": "NSE", "currency": "INR"}]})
210 if request.url.path.endswith("/finance/quote"):
211 return response({"quoteResponse": {"result": [{"regularMarketPrice": 1800, "currency": "INR"}]}})
212 return response({"quoteSummary": {"result": []}})
213 market = provider(handler)
214 first = await market.collect(instrument())
215 second = await market.collect(instrument())
216 assert first is second
217 assert calls > 0
218 first_call_count = calls
219 await market.collect(instrument())
220 assert calls == first_call_count
221
222
223 @pytest.mark.asyncio
224 async def test_durable_verified_mapping_survives_local_cache_loss_without_fuzzy_search():
225 fuzzy_searches = 0
226 def handler(request):
227 nonlocal fuzzy_searches
228 if "/finance/search" in request.url.path:
229 if request.url.params.get("quotesCount") != "0":
230 fuzzy_searches += 1
231 return response({}, 503)
232 if request.url.path.endswith("/finance/quote"):
233 return response({"quoteResponse": {"result": [{"regularMarketPrice": 1800, "currency": "INR", "quoteType": "EQUITY"}]}})
234 return response({"quoteSummary": {"result": []}})
235 durable = instrument(structuredProviderTicker="ZENTEC.NS", structuredProviderExchange="NSE",
236 structuredProviderCurrency="INR", structuredProviderStatus="VERIFIED")
237 snapshot = await provider(handler).collect(durable)
238 assert snapshot.resolution.provider_ticker == "ZENTEC.NS"
239 assert snapshot.resolution.status == "VERIFIED_REUSED"
240 assert fuzzy_searches == 0
241
242
243 @pytest.mark.asyncio
244 async def test_known_nse_listing_rejects_bse_candidate_without_guessing_suffix():
245 async_provider = provider(lambda request: response({"quotes": [
246 {"symbol": "ZENTEC.BO", "quoteType": "EQUITY", "longname": "Zen Technologies Limited", "exchange": "BSE", "currency": "INR"}
247 ]}))
248 with pytest.raises(StructuredProviderError, match="COMPANY_NOT_RESOLVED"):
249 await async_provider.resolve_instrument(instrument(canonicalExchange="NSE"))
250
251
252 @pytest.mark.asyncio
253 async def test_verified_nse_candidate_is_validated_before_yahoo_mapping_can_be_persisted():
254 searches = []
255 def handler(request):
256 if "/finance/search" in request.url.path:
257 if request.url.params.get("quotesCount") != "0":
258 searches.append(request.url.params.get("q"))
259 return response({"quotes": [{"symbol": "OFFICIAL.NS", "quoteType": "EQUITY",
260 "longname": "Generic Components Limited", "exchange": "NSI", "currency": "INR",
261 "isin": "INE000A01010"}]})
262 if request.url.path.endswith("/finance/quote"):
263 return response({"quoteResponse": {"result": [{"regularMarketPrice": 100, "currency": "INR"}]}})
264 return response({"quoteSummary": {"result": []}})
265 held = instrument(canonicalName="Generic Components Limited", isin="INE000A01010", canonicalSymbol="BROKER_ALIAS",
266 brokerSymbol="BROKER_ALIAS", ticker="BROKER_ALIAS", canonicalExchange="NSE",
267 structuredNseCandidateTicker="OFFICIAL.NS", structuredNseCandidateSource="VERIFIED_NSE")
268 snapshot = await provider(handler).collect(held)
269 assert searches == ["OFFICIAL.NS"]
270 assert snapshot.resolution.provider_ticker == "OFFICIAL.NS"
271 assert snapshot.resolution.status == "VERIFIED_NSE_CANDIDATE"
272
273
274 @pytest.mark.asyncio
275 @pytest.mark.parametrize("candidate", [
276 {"symbol": "OFFICIAL.BO", "quoteType": "EQUITY", "longname": "Generic Components Limited", "exchange": "BSE", "currency": "INR"},
277 {"symbol": "OFFICIAL.NS", "quoteType": "EQUITY", "longname": "Different Company Limited", "exchange": "NSE", "currency": "INR"},
278 {"symbol": "OFFICIAL.NS", "quoteType": "ETF", "longname": "Generic Components Limited", "exchange": "NSE", "currency": "INR"},
279 ])
280 async def test_verified_nse_candidate_rejects_identity_or_security_conflicts(candidate):
281 async_provider = provider(lambda request: response({"quotes": [candidate]}))
282 held = instrument(canonicalName="Generic Components Limited", isin="INE000A01010", canonicalExchange="NSE",
283 structuredNseCandidateTicker="OFFICIAL.NS", structuredNseCandidateSource="VERIFIED_NSE")
284 with pytest.raises(StructuredProviderError, match="COMPANY_NOT_RESOLVED"):
285 await async_provider.resolve_instrument(held)
286
287
288 @pytest.mark.asyncio
289 async def test_untrusted_or_invalid_nse_hint_does_not_become_candidate_proof():
290 searches = []
291 def handler(request):
292 searches.append(request.url.params.get("q"))
293 return response({"quotes": []})
294 held = instrument(structuredNseCandidateTicker="BROKER_ALIAS.NS", structuredNseCandidateSource="BROKER_IMPORT_IDENTITY")
295 with pytest.raises(StructuredProviderError, match="COMPANY_NOT_RESOLVED"):
296 await provider(handler).resolve_instrument(held)
297 assert searches == ["ZEN TECHNOLOGIES LTD"]
298
299
300 @pytest.mark.asyncio
301 async def test_yfinance_current_price_and_full_structured_fields_are_normalized_without_raw_leak():
302 info = {"currentPrice": 1826.8, "regularMarketPrice": 1800, "currency": "INR", "quoteType": "EQUITY",
303 "exchange": "NSI", "longName": "Zen Technologies Limited", "sector": "Industrials",
304 "industry": "Aerospace & Defense", "marketCap": 100, "enterpriseValue": 90, "trailingPE": 42,
305 "forwardPE": 30, "priceToSalesTrailing12Months": 8, "priceToBook": 7, "enterpriseToRevenue": 6,
306 "enterpriseToEbitda": 20, "trailingEps": 10, "forwardEps": 12, "bid": 0, "ask": 0,
307 "targetLowPrice": 1500, "targetMedianPrice": 1800, "targetMeanPrice": 1850, "targetHighPrice": 2100,
308 "numberOfAnalystOpinions": 6, "recommendationKey": "buy", "regularMarketVolume": 1234}
309 market = yfinance_provider(info, [{"title": "Older update", "publisher": "Publisher", "link": "https://example.com/older", "providerPublishTime": 1788076700}, {"title": "Public update", "publisher": "Publisher", "link": "https://example.com/news", "providerPublishTime": 1788076800}])
310 snapshot = await market.collect(instrument(structuredProviderTicker="ZENTEC.NS", structuredProviderStatus="VERIFIED",
311 structuredProviderCurrency="INR", assetType="EQUITY"))
312 assert snapshot.facts["latestPrice"].value == Decimal("1826.8")
313 assert "bid" not in snapshot.facts and "ask" not in snapshot.facts
314 assert snapshot.facts["priceToSales"].value == Decimal("8")
315 assert snapshot.facts["publicAnalystTargetMedianPrice"].value == Decimal("1800")
316 assert snapshot.facts["sector"].value == "Industrials"
317 assert snapshot.news[0]["headline"] == "Public update"
318 assert snapshot.news[0]["publishedAt"].year == 2026
319 assert snapshot.news[0]["url"] == "https://example.com/news"
320 assert "currentPrice" not in snapshot.model_dump_json()
321
322
323 @pytest.mark.asyncio
324 async def test_yfinance_regular_market_fallback_missing_price_and_etf_type():
325 durable = instrument(structuredProviderTicker="TEST.NS", structuredProviderStatus="VERIFIED",
326 structuredProviderCurrency="INR", assetType="ETF")
327 snapshot = await yfinance_provider({"regularMarketPrice": 55, "currency": "INR", "quoteType": "ETF",
328 "exchange": "NSI", "navPrice": 54.8}).collect(durable)
329 assert snapshot.facts["latestPrice"].value == Decimal("55")
330 assert snapshot.facts["navPrice"].value == Decimal("54.8")
331 assert snapshot.resolution.quote_type == "ETF"
332 mislabeled = await yfinance_provider({"regularMarketPrice": 130.96, "currency": "INR", "quoteType": "EQUITY",
333 "longName": "Nippon India ETF Gold BeES"}).collect(durable)
334 assert mislabeled.resolution.quote_type == "ETF"
335 with pytest.raises(StructuredProviderError, match="STRUCTURED_PRICE_UNAVAILABLE"):
336 await yfinance_provider({"currency": "INR", "quoteType": "ETF"}).collect(durable)
337 with pytest.raises(StructuredProviderError, match="STRUCTURED_PRICE_UNAVAILABLE"):
338 await yfinance_provider({"regularMarketPrice": 0, "currency": "INR", "quoteType": "ETF"}).collect(durable)
339
340
341 @pytest.mark.asyncio
342 @pytest.mark.parametrize(
343 ("field", "value", "error"),
344 [
345 ("symbol", "WRONG.NS", "SYMBOL"),
346 ("exchange", "NYQ", "EXCHANGE"),
347 ("currency", "USD", "CURRENCY"),
348 ],
349 )
350 async def test_verified_yahoo_mapping_rejects_returned_identity_conflicts(field, value, error):
351 info = {
352 "symbol": "ZENTEC.NS",
353 "currentPrice": 10,
354 "currency": "INR",
355 "quoteType": "EQUITY",
356 "exchange": "NSI",
357 field: value,
358 }
359 durable = instrument(
360 structuredProviderTicker="ZENTEC.NS",
361 structuredProviderStatus="VERIFIED",
362 structuredProviderCurrency="INR",
363 structuredProviderExchange="XNSE",
364 )
365 with pytest.raises(StructuredProviderError, match=f"PERSISTED_MAPPING_CONFLICT:{error}"):
366 await yfinance_provider(info).collect(durable)
367
368
369 @pytest.mark.asyncio
370 async def test_yfinance_roe_normalization_and_missing_value_remains_absent():
371 durable = instrument(structuredProviderTicker="ZENTEC.NS", structuredProviderStatus="VERIFIED",
372 structuredProviderCurrency="INR")
373 present = await yfinance_provider({"currentPrice": 10, "currency": "INR", "quoteType": "EQUITY",
374 "returnOnEquity": 0.2145}).collect(durable)
375 assert present.facts["roe"].value == Decimal("21.4500")
376 assert present.facts["roe"].source_name == "Yahoo Finance"
377 missing = await yfinance_provider({"currentPrice": 10, "currency": "INR", "quoteType": "EQUITY"}).collect(durable)
378 assert "roe" not in missing.facts
379
380
381 @pytest.mark.asyncio
382 async def test_roce_uses_latest_common_annual_statement_period_and_requires_all_inputs():
383 period = pd.Timestamp("2026-03-31")
384 income = pd.DataFrame({period: {"EBIT": 250}})
385 balance = pd.DataFrame({period: {"Total Assets": 2000, "Current Liabilities": 750}})
386 durable = instrument(structuredProviderTicker="ZENTEC.NS", structuredProviderStatus="VERIFIED",
387 structuredProviderCurrency="INR")
388 snapshot = await yfinance_provider({"currentPrice": 10, "currency": "INR", "quoteType": "EQUITY"},
389 income_stmt=income, balance_sheet=balance).collect(durable)
390 assert snapshot.facts["roce"].value == Decimal("20.0")
391 assert "total assets - current liabilities" in snapshot.facts["roce"].calculation_basis
392 incomplete = await yfinance_provider({"currentPrice": 10, "currency": "INR", "quoteType": "EQUITY"},
393 income_stmt=income,
394 balance_sheet=pd.DataFrame({period: {"Total Assets": 2000}})).collect(durable)
395 assert "roce" not in incomplete.facts
396
397
398 @pytest.mark.asyncio
399 async def test_bank_does_not_receive_industrial_roce_even_when_statement_rows_exist():
400 period = pd.Timestamp("2026-03-31")
401 income = pd.DataFrame({period: {"EBIT": 250}})
402 balance = pd.DataFrame({period: {"Total Assets": 2000, "Current Liabilities": 750}})
403 durable = instrument(structuredProviderTicker="BANK.NS", structuredProviderStatus="VERIFIED",
404 structuredProviderCurrency="INR")
405 snapshot = await yfinance_provider({"currentPrice": 10, "currency": "INR", "quoteType": "EQUITY",
406 "sector": "Financial Services", "industry": "Banks - Regional",
407 "returnOnEquity": .15, "returnOnAssets": .01},
408 income_stmt=income, balance_sheet=balance).collect(durable)
409 assert snapshot.facts["roe"].value == Decimal("15.00")
410 assert snapshot.facts["roa"].value == Decimal("1.00")
411 assert "roce" not in snapshot.facts
412
413
414 @pytest.mark.asyncio
415 async def test_public_pdf_uses_normal_page_text_extraction_with_page_markers(monkeypatch):
416 class Page:
417 def __init__(self, text): self.text = text
418 def extract_text(self): return self.text
419 class Reader:
420 def __init__(self, _stream): self.pages = [Page("ZEN TECHNOLOGIES quarterly results"), Page("PAT and EPS details")]
421 monkeypatch.setattr("pypdf.PdfReader", Reader)
422 client = httpx.AsyncClient(transport=httpx.MockTransport(
423 lambda request: httpx.Response(200, headers={"content-type": "application/pdf"}, content=b"%PDF-public-pdf", request=request)
424 ))
425 result = await HttpResearchFetcher(Settings(research_max_retries=0), client).fetch("https://example.com/results.pdf")
426 assert "[PDF_PAGE 1]" in result.text
427 assert "PAT and EPS details" in result.text
428
429
430 @pytest.mark.asyncio
431 async def test_scanned_public_pdf_reports_ocr_required(monkeypatch):
432 class Page:
433 def extract_text(self): return ""
434 class Reader:
435 def __init__(self, _stream): self.pages = [Page()]
436 monkeypatch.setattr("pypdf.PdfReader", Reader)
437 client = httpx.AsyncClient(transport=httpx.MockTransport(
438 lambda request: httpx.Response(200, headers={"content-type": "application/pdf"},
439 content=b"%PDF-scanned-pdf", request=request)
440 ))
441 result = await HttpResearchFetcher(Settings(research_max_retries=0), client).fetch("https://example.com/scanned.pdf")
442 assert result.extraction_status == "PDF_SCANNED_OCR_REQUIRED"
443 assert result.text == "[PDF_PAGE 1]\n"
444
445
446 @pytest.mark.asyncio
447 async def test_financial_issuer_reported_statements_are_preserved_without_industrial_roce():
448 income = pd.DataFrame({pd.Timestamp("2026-06-30"): {"Total Revenue": 100, "Net Income": 20},
449 pd.Timestamp("2026-03-31"): {"Total Revenue": 90, "Net Income": 18}})
450 snapshot = await yfinance_provider({"currentPrice": 10, "currency": "INR", "quoteType": "EQUITY",
451 "sector": "Financial Services", "industry": "Financial Data & Stock Exchanges"},
452 income_stmt=income, quarterly_income_stmt=income).collect(instrument(
453 structuredProviderTicker="EXCHANGE.NS", structuredProviderStatus="VERIFIED", structuredProviderCurrency="INR"))
454 assert {row["metric"] for row in snapshot.statement_facts} >= {"revenue", "pat"}
455 assert {row["periodType"] for row in snapshot.statement_facts} == {"ANNUAL", "QUARTERLY"}
456 assert all(row["sourceUrl"].endswith("EXCHANGE.NS") for row in snapshot.statement_facts)
457 assert "roce" not in snapshot.facts