| 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 |