diff -ruN /data/files/bmw/stocklab-audit-remediation-SL006-SL007-SL008-SL009-20260910-224500/pristine/app/app/adapters/eodhd.py /data/files/bmw/stocklab-audit-remediation-SL006-SL007-SL008-SL009-20260910-224500/app/app/adapters/eodhd.py --- /data/files/bmw/stocklab-audit-remediation-SL006-SL007-SL008-SL009-20260910-224500/pristine/app/app/adapters/eodhd.py 2026-09-08 14:49:40.000000000 +0300 +++ /data/files/bmw/stocklab-audit-remediation-SL006-SL007-SL008-SL009-20260910-224500/app/app/adapters/eodhd.py 2026-09-10 21:41:38.676379478 +0300 @@ -49,6 +49,7 @@ self._api_key = api_key self._base_url = base_url.rstrip("/") self._client = httpx.Client(base_url=self._base_url, timeout=timeout, transport=transport) + self._profile_currency: Optional[str] = None def _get(self, path: str, **params): params["api_token"] = self._api_key @@ -67,6 +68,7 @@ code = f"{ticker}.{exchange_mic}" if exchange_mic else ticker data = self._get(f"/fundamentals/{code}") general = data.get("General", {}) + self._profile_currency = general.get("CurrencyCode") or "USD" return ProviderCompanyProfile( ticker=ticker, exchange_mic=general.get("Exchange"), legal_name=general.get("Name", ticker), display_name=general.get("Name", ticker), country_iso2=general.get("CountryISO"), @@ -84,6 +86,12 @@ Now: each row is validated individually; a bad row is dropped with a recorded reason and the good rows are returned. `self.last_price_validation` holds the report.""" + currency = self._profile_currency + if not currency: + raise ProviderNotFoundError( + f"No provider currency available for {ticker}; " + "get_company_profile() must succeed before get_prices()." + ) rows = self._get(f"/eod/{ticker}", period="d", **{"from": start.isoformat(), "to": end.isoformat()}) report = ValidationReport() bars = [] @@ -101,7 +109,7 @@ bars.append(ProviderPriceBar( date=_parse_date(clean["date"]), open=clean["open"], high=clean["high"], low=clean["low"], close=clean["close"], adjusted_close=clean["adjusted_close"], - volume=clean["volume"], currency="USD", + volume=clean["volume"], currency=currency, )) self.last_price_validation = report return bars diff -ruN /data/files/bmw/stocklab-audit-remediation-SL006-SL007-SL008-SL009-20260910-224500/pristine/app/app/adapters/fmp.py /data/files/bmw/stocklab-audit-remediation-SL006-SL007-SL008-SL009-20260910-224500/app/app/adapters/fmp.py --- /data/files/bmw/stocklab-audit-remediation-SL006-SL007-SL008-SL009-20260910-224500/pristine/app/app/adapters/fmp.py 2026-09-08 14:48:16.000000000 +0300 +++ /data/files/bmw/stocklab-audit-remediation-SL006-SL007-SL008-SL009-20260910-224500/app/app/adapters/fmp.py 2026-09-10 21:41:38.676379478 +0300 @@ -80,6 +80,7 @@ # `transport` is exposed purely for testing (httpx.MockTransport) — production callers # never pass it, so real requests always go over the network via the default transport. self._client = httpx.Client(base_url=self._base_url, timeout=timeout, transport=transport) + self._profile_currency: Optional[str] = None def _get(self, path: str, **params) -> list | dict: params["apikey"] = self._api_key @@ -99,6 +100,7 @@ raise ProviderNotFoundError(f"No FMP profile for {ticker}") row = data[0] return ProviderCompanyProfile( + self._profile_currency = row.get("currency") or row.get("reportedCurrency") or "USD" ticker=ticker, exchange_mic=row.get("exchangeShortName"), legal_name=row.get("companyName", ticker), display_name=row.get("companyName", ticker), country_iso2=row.get("country"), sector=row.get("sector"), industry=row.get("industry"), currency=row.get("currency", "USD"), @@ -143,6 +145,12 @@ """AUDIT FIX (Part D — docs/AUDIT_VALIDATION_D.md): see EODHDAdapter.get_prices() for the full reasoning. Same policy applied here so the two adapters cannot diverge: reject the row, not the batch, and record why. `self.last_price_validation` holds the report.""" + currency = self._profile_currency + if not currency: + raise ProviderNotFoundError( + f"No provider currency available for {ticker}; " + "get_company_profile() must succeed before get_prices()." + ) data = self._get(f"/historical-price-full/{ticker}", **{"from": start.isoformat(), "to": end.isoformat()}) rows = data.get("historical", []) if isinstance(data, dict) else [] report = ValidationReport() @@ -161,7 +169,7 @@ bars.append(ProviderPriceBar( date=_parse_date(clean["date"]), open=clean["open"], high=clean["high"], low=clean["low"], close=clean["close"], adjusted_close=clean["adjusted_close"], - volume=clean["volume"], currency="USD", + volume=clean["volume"], currency=currency, )) self.last_price_validation = report return bars diff -ruN /data/files/bmw/stocklab-audit-remediation-SL006-SL007-SL008-SL009-20260910-224500/pristine/app/app/workers/ingest.py /data/files/bmw/stocklab-audit-remediation-SL006-SL007-SL008-SL009-20260910-224500/app/app/workers/ingest.py --- /data/files/bmw/stocklab-audit-remediation-SL006-SL007-SL008-SL009-20260910-224500/pristine/app/app/workers/ingest.py 2026-09-10 18:10:37.000000000 +0300 +++ /data/files/bmw/stocklab-audit-remediation-SL006-SL007-SL008-SL009-20260910-224500/app/app/workers/ingest.py 2026-09-10 21:41:38.756380157 +0300 @@ -15,6 +15,7 @@ still passes — behavior is byte-identical to before this pass: nothing about the single-source path changed. """ +import httpx from __future__ import annotations from datetime import date, timedelta @@ -402,6 +403,12 @@ quarterly_income = [] if quarterly_income: income_periods = list(income_periods) + list(quarterly_income) + if not income_periods: + raise RuntimeError( + f"Provider returned no financial periods for {ticker}; " + "refusing to commit stale data or trigger recompute." + ) + balance_by_period.update(_fetch_statements_by_period( adapter, lambda t: adapter.get_balance_sheets(t, period="quarter"), ticker, "balance_sheets_quarterly")) @@ -611,7 +618,7 @@ @celery_app.task( name="app.workers.ingest.ingest_security_task", bind=True, - autoretry_for=(ProviderRateLimitError, ConnectionError, TimeoutError), + autoretry_for=(ProviderRateLimitError, httpx.TransportError), retry_backoff=True, # exponential backoff between attempts retry_backoff_max=300, # cap backoff at 5 minutes retry_jitter=True, # avoid a thundering herd of simultaneous retries