except ProviderRateLimitError: raised = True assert raised def test_get_company_profile_not_found_404(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(404, text="not found") adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) try: adapter.get_company_profile("NOPE") raised = False except ProviderNotFoundError: raised = True assert raised # --- get_prices -------------------------------------------------------------------------------- def test_get_prices_parses_valid_bars(): from datetime import date as date_cls def handler(request: httpx.Request) -> httpx.Response: assert request.url.path.endswith("/eod/AAPL") return httpx.Response(200, json=FAKE_EOD_RESPONSE) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) bars = adapter.get_prices("AAPL", date_cls(2025, 9, 1), date_cls(2025, 9, 30)) assert len(bars) == 1 assert bars[0].close == 228.2 assert bars[0].volume == 45000000 assert bars[0].date.isoformat() == "2025-09-30" def test_get_prices_missing_optional_fields_default_to_none(): from datetime import date as date_cls # only "date" and "close" are ever required by ProviderPriceBar construction here (close has # no .get fallback); everything else the adapter reads via .get(...) and must default to None # rather than KeyError. def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[{"date": "2025-09-30", "close": 228.2}]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) bars = adapter.get_prices("AAPL", date_cls(2025, 9, 1), date_cls(2025, 9, 30)) assert len(bars) == 1 assert bars[0].open is None assert bars[0].high is None assert bars[0].low is None assert bars[0].adjusted_close is None assert bars[0].volume is None def test_get_prices_wrong_type_in_close_field_is_rejected(): from datetime import date as date_cls # HISTORICAL NOTE, preserved. Part A7 pinned the OPPOSITE behaviour: `ProviderPriceBar` is a # plain @dataclass, Python does not enforce `close: float` at construction, and get_prices() # did no coercion -- so `"not-a-number"` was passed straight through as a str. A7 documented # that as a real gap and deliberately did not fix it, because fixing one adapter method while # FMP's equivalent stayed unvalidated would have made the layer MORE inconsistent, not less. # UPDATED (Part D -- docs/AUDIT_VALIDATION_D.md): both adapters now share # app/adapters/validation.py. The row is rejected with a recorded reason instead. def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[{"date": "2025-09-30", "close": "not-a-number"}]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) bars = adapter.get_prices("AAPL", date_cls(2025, 9, 1), date_cls(2025, 9, 30)) assert bars == [] report = adapter.last_price_validation assert report.rows_seen == 1 and report.rows_accepted == 0 assert any("not parseable as a number" in i.reason for i in report.errors) def test_get_prices_numeric_string_is_coerced_not_rejected(): """Providers really do return `"227.0"`. Narrow coercion is the point -- rejecting a valid price because it arrived as a string would be its own kind of data loss.""" from datetime import date as date_cls def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[{"date": "2025-09-30", "close": "227.5"}]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) bars = adapter.get_prices("AAPL", date_cls(2025, 9, 1), date_cls(2025, 9, 30)) assert len(bars) == 1 and bars[0].close == 227.5 def test_get_prices_empty_response(): from datetime import date as date_cls def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) bars = adapter.get_prices("AAPL", date_cls(2025, 9, 1), date_cls(2025, 9, 30)) assert bars == [] def test_get_prices_partial_response_keeps_the_good_bars_and_reports_the_bad_one(): from datetime import date as date_cls # HISTORICAL NOTE, preserved. Part A7 pinned the OPPOSITE behaviour: one bar missing "close" # raised KeyError and the ENTIRE batch was lost, including every valid bar around it. A7 # recorded it as a real limitation and left it for a resilience pass rather than fixing one # adapter in isolation. UPDATED (Part D): reject the row, not the batch, and say why. def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[ {"date": "2025-09-29", "close": 227.0}, {"date": "2025-09-30"}, # missing "close" entirely {"date": "2025-10-01", "close": 229.0}, ]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) bars = adapter.get_prices("AAPL", date_cls(2025, 9, 1), date_cls(2025, 10, 1)) assert [b.close for b in bars] == [227.0, 229.0] report = adapter.last_price_validation assert report.rows_seen == 3 and report.rows_accepted == 2 assert report.rows_rejected == 1 assert abs(report.rejection_rate - 1 / 3) < 1e-12 def test_get_prices_row_without_a_date_is_rejected(): from datetime import date as date_cls def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[{"close": 227.0}]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) assert adapter.get_prices("AAPL", date_cls(2025, 9, 1), date_cls(2025, 9, 30)) == [] assert any("no date" in i.reason for i in adapter.last_price_validation.errors) def test_get_prices_negative_price_is_rejected(): from datetime import date as date_cls def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[{"date": "2025-09-30", "close": -5.0}]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) assert adapter.get_prices("AAPL", date_cls(2025, 9, 1), date_cls(2025, 9, 30)) == [] assert any("strictly positive" in i.reason for i in adapter.last_price_validation.errors) def test_get_prices_bad_volume_does_not_cost_the_price(): """A price is the field downstream code needs; an unusable volume is not a reason to throw it away. The volume becomes None and a WARNING is recorded.""" from datetime import date as date_cls def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[{"date": "2025-09-30", "close": 227.0, "volume": -100}]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) bars = adapter.get_prices("AAPL", date_cls(2025, 9, 1), date_cls(2025, 9, 30)) assert len(bars) == 1 and bars[0].close == 227.0 and bars[0].volume is None assert any("volume cannot be negative" in i.reason for i in adapter.last_price_validation.warnings) # --- list_universe ------------------------------------------------------------------------------- def test_list_universe_parses_codes_valid(): def handler(request: httpx.Request) -> httpx.Response: assert request.url.path.endswith("/exchange-symbol-list/US") return httpx.Response(200, json=[{"Code": "AAPL"}, {"Code": "MSFT"}]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) tickers = adapter.list_universe(exchange_mic="US") assert tickers == ["AAPL", "MSFT"] def test_list_universe_records_rows_it_drops_instead_of_dropping_them_silently(): # HISTORICAL NOTE, preserved. Part A7 pinned this as silently dropping rows without a "Code" # key -- the exact OPPOSITE policy to get_prices(), which failed the whole batch over one bad # row. A7 recorded the inconsistency between two methods of the same adapter as an input to # Part D. UPDATED (Part D): same policy in both, and the drop is now recorded. def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[ {"Code": "AAPL"}, {"Name": "no code here"}, {"Code": "MSFT"}, ])