============================================================ STOCKLAB PROD — PRE-RESTART STATE ============================================================ Thu 10 Sep 22:36:28 EEST 2026 --- CONTAINERS BEFORE --- stocklab-redis docker.io/library/redis:7-alpine badcc1a4cfc5 Up 37 hours (healthy) stocklab-db docker.io/library/postgres:16-alpine c5eacf00c3fc Up 37 hours (healthy) stocklab-frontend localhost/stocklab-frontend:release-qa-20260909-182234 86c8aa085a7f Up 28 hours (healthy) stocklab-backend localhost/stocklab-backend:release-20260911-031500-FINAL-RELEASE c21450bec8f6 Up 12 minutes (healthy) stocklab-beat localhost/stocklab-backend:release-20260911-031500-FINAL-RELEASE a47ae0a4f49d Up 12 minutes stocklab-worker localhost/stocklab-backend:release-20260911-031500-FINAL-RELEASE 5eb73444a99f Up 12 minutes (healthy) --- RESTART COUNTS BEFORE --- stocklab-backend restart=0 status=running image=localhost/stocklab-backend:release-20260911-031500-FINAL-RELEASE stocklab-worker restart=0 status=running image=localhost/stocklab-backend:release-20260911-031500-FINAL-RELEASE stocklab-beat restart=0 status=running image=localhost/stocklab-backend:release-20260911-031500-FINAL-RELEASE stocklab-frontend restart=0 status=running image=localhost/stocklab-frontend:release-qa-20260909-182234 stocklab-db restart=0 status=running image=docker.io/library/postgres:16-alpine stocklab-redis restart=0 status=running image=docker.io/library/redis:7-alpine --- LISTENERS BEFORE --- LISTEN 0 2048 127.0.0.1:18000 0.0.0.0:* users:(("uvicorn",pid=2384926,fd=11)) LISTEN 0 511 127.0.0.1:6379 0.0.0.0:* users:(("redis-server",pid=1133711,fd=6)) LISTEN 0 200 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=1141225,fd=6)) LISTEN 0 511 127.0.0.1:13000 0.0.0.0:* users:(("next-server (v1",pid=1497838,fd=18)) ============================================================ FINAL RELEASE IMAGE ============================================================ RELEASE_ID=a892fa2d163267cfce2df46577e843d8d08e01b346ffbfe3e6803f6a97faaee8 RELEASE_DIGEST=localhost/stocklab-backend@sha256:7c2526e2e2f4d10dd9b6925fe102fe686df0ec29f8cfdabdf179b63e5fb6126f RELEASE_CREATED=2026-09-10 19:12:04.873637086 +0000 UTC --- OLD IMAGE --- OLD_ID=413a2618ce5e1832dc08fe1ee0c14a449374d8b3c3c94723bd87a6a639248cbe OLD_DIGEST=localhost/stocklab-backend@sha256:924c7d7641dacf65536f517cf9aa42f2f661431afa4d92e6b03a6059d34612db OLD_CREATED=2026-09-10 18:25:31.595146614 +0000 UTC ============================================================ CHANGES — FINAL AUDIT PATCH ============================================================ --- PATCH STAT --- @@ -97,12 +97,39 @@ @@ -230,11 +230,51 @@ @@ -9,11 +9,11 @@ @@ -44,12 +44,42 @@ @@ -12,10 +12,42 @@ @@ -39,11 +71,13 @@ @@ -94,11 +94,11 @@ @@ -113,11 +113,11 @@ @@ -24,12 +24,18 @@ @@ -23,10 +23,11 @@ @@ -478,35 +479,67 @@ @@ -6,11 +6,11 @@ @@ -94,10 +94,14 @@ @@ -118,11 +122,16 @@ @@ -337,30 +346,35 @@ @@ -106,12 +106,17 @@ --- CHANGED FILES --- --- FINAL PATCH CONTENT --- --- a/app/adapters/resolver.py +++ b/app/adapters/resolver.py @@ -97,12 +97,39 @@ if secondary is not None and secondary_tier is not None: candidates.append((secondary_tier, val_b)) result = resolve_conflict(candidates) if result.selected_value is not None: resolved[field] = result.selected_value - if result.is_conflicting: - conflicts.append(FieldConflict(field, val_a, val_b, result.selected_value, result.selection_reason)) + + # Secondary-only provenance must be observable. A NULL in the + # primary provider followed by a value from the secondary provider + # is not a numeric conflict, but it is still a cross-provider merge. + if ( + secondary is not None + and secondary_tier is not None + and val_a is None + and val_b is not None + ): + conflicts.append( + FieldConflict( + field, + val_a, + val_b, + result.selected_value, + "Primary field missing; value supplied by secondary source", + ) + ) + elif result.is_conflicting: + conflicts.append( + FieldConflict( + field, + val_a, + val_b, + result.selected_value, + result.selection_reason, + ) + ) return resolved, conflicts def merge_statement_line_items( income: dict, --- a/app/adapters/validation.py +++ b/app/adapters/validation.py @@ -230,11 +230,51 @@ SEVERITY_WARNING, row_key)) continue out[name] = number # Cross-field checks: each is an identity that must hold, not a heuristic. - revenue, gross_profit = out.get("revenue"), out.get("gross_profit") + revenue = out.get("revenue") + cogs = out.get("cogs") + gross_profit = out.get("gross_profit") + + # Accounting identity: + # gross_profit = revenue - cogs + # + # The provider resolver already treats <=0.5% financial differences + # as non-conflicting. Use the same tolerance here so ordinary provider + # rounding does not create a false integrity failure. + if revenue is not None and cogs is not None and gross_profit is not None: + expected_gross_profit = revenue - cogs + + if expected_gross_profit == 0: + inconsistent = abs(gross_profit) > 0.01 + else: + inconsistent = ( + abs(gross_profit - expected_gross_profit) + / abs(expected_gross_profit) + > 0.005 + ) + + if inconsistent: + report.add( + ValidationIssue( + "gross_profit", + gross_profit, + ( + f"does not reconcile with revenue ({revenue}) " + f"- cogs ({cogs}) = {expected_gross_profit}" + ), + SEVERITY_WARNING, + row_key, + ) + ) + + # Do not allow an internally inconsistent dependent value + # into the persisted merged statement. + del out["gross_profit"] + gross_profit = None + if revenue is not None and gross_profit is not None and gross_profit > revenue: report.add(ValidationIssue("gross_profit", gross_profit, f"exceeds revenue ({revenue})", SEVERITY_WARNING, row_key)) total_assets, current_assets = out.get("total_assets"), out.get("current_assets") if total_assets is not None and current_assets is not None and current_assets > total_assets: --- a/app/api/v1/rankings.py +++ b/app/api/v1/rankings.py @@ -9,11 +9,11 @@ """ from typing import Optional from fastapi import APIRouter, Depends, Query, Request -from sqlalchemy import select +from sqlalchemy import func, select, and_ from sqlalchemy.orm import Session from app.api.v1.deps import limiter from app.api.v1.serializers import company_summary, is_demo_by_security, security_eager_load_options from app.core.config import get_settings @@ -44,12 +44,42 @@ # AUDIT FIX (StockLab overhaul, Part A2, docs/AUDIT_PERFORMANCE.md's remaining # company_summary() N+1 finding): same fix as screeners.py -- batches the # Security->Company->{country,sector,industry} chain instead of 3 lazy-load queries per row. .options(*security_eager_load_options()) .join(Company, Security.company_id == Company.id) - .join(Score, Score.security_id == Security.id) - .outerjoin(Valuation, Valuation.security_id == Security.id) + .join( + Score, + and_( + Score.security_id == Security.id, + Score.id + == select(Score.id) + .distinct(Score.security_id) + .order_by( + Score.security_id, + Score.calculation_date.desc(), + ) + .limit(1) + .correlate(Security) + .scalar_subquery(), + ), + ) + .outerjoin( + Valuation, + and_( + Valuation.security_id == Security.id, + Valuation.id + == select(Valuation.id) + .distinct(Valuation.security_id) + .order_by( + Valuation.security_id, + Valuation.calculation_date.desc(), + ) + .limit(1) + .correlate(Security) + .scalar_subquery(), + ), + ) ) if country: query = query.join(Country, Company.country_id == Country.id).where(Country.iso2 == country.upper()) if sector: query = query.where(Company.sector.has(code=sector.upper())) --- a/app/engines/screening/executor.py +++ b/app/engines/screening/executor.py @@ -12,10 +12,42 @@ _SCORE_FIELDS = { "overall_score", "quality_score", "financial_health_score", "growth_score", "competitive_advantage_score", "valuation_score", "risk_score", } + +_PERCENTAGE_METRICS = { + "roic", + "roic_minus_wacc", + "revenue_growth_yoy", + "revenue_cagr_5y", + "eps_growth_yoy", + "fcf_growth_yoy", + "gross_margin", + "operating_margin", + "net_margin", + "fcf_margin", + "fcf_yield", + "dividend_yield", + "buyback_yield", + "shareholder_yield", + "roe", + "fcf_payout_ratio", +} + + +def _normalize_metric_threshold( + metric: str, + value: float | None, +) -> float | None: + """Convert public percentage-point thresholds to stored ratio values.""" + if value is None: + return None + if metric in _PERCENTAGE_METRICS: + return value / 100.0 + return value + _OP_MAP = { "gt": lambda col, v, v2: col > v, "gte": lambda col, v, v2: col >= v, "lt": lambda col, v, v2: col < v, @@ -39,11 +71,13 @@ raise InvalidScreenFilter( f"relative='{f.relative}' filters require percentile columns not yet materialized in " f"this build — see docs/SPEC_COVERAGE.md. Use relative='absolute' for now." ) m = Metric.__table__.alias(f"metric_{f.metric}") - condition = _OP_MAP[f.op](m.c.value, f.value, f.value2) + value = _normalize_metric_threshold(f.metric, f.value) + value2 = _normalize_metric_threshold(f.metric, f.value2) + condition = _OP_MAP[f.op](m.c.value, value, value2) return exists( select(1).select_from(m).where(m.c.security_id == Security.id, m.c.metric_key == f.metric, condition) ) --- a/app/schemas/common.py +++ b/app/schemas/common.py @@ -94,11 +94,11 @@ metrics: list[MetricOut] = [] as_of: Optional[date] = None class ScreenFilter(BaseModel): - metric: str + metric: str = Field(max_length=48) op: str # gt|gte|lt|lte|eq|between value: float value2: Optional[float] = None # for "between" relative: str = "absolute" # absolute|industry_percentile|historical_percentile|peer_percentile @@ -113,11 +113,11 @@ class ScreenRequest(BaseModel): universe: ScreenUniverse = ScreenUniverse() logic: str = "AND" - filters: list[ScreenFilter] = [] + filters: list[ScreenFilter] = Field(default=[], max_length=20) sort_by: str = "overall_score" sort_direction: str = "desc" # AUDIT FIX (StockLab overhaul, performance audit, docs/AUDIT_PERFORMANCE.md finding #1): no # upper bound existed here before this pass -- a client (or an unrate-limited abusive caller, # see docs/AUDIT_SECURITY.md finding #3) could request limit=100000 and force --- a/app/workers/celery_app.py +++ b/app/workers/celery_app.py @@ -24,12 +24,18 @@ worker_concurrency=settings.CELERY_WORKER_CONCURRENCY, worker_prefetch_multiplier=settings.CELERY_WORKER_PREFETCH_MULTIPLIER, task_acks_late=settings.CELERY_TASK_ACKS_LATE, task_time_limit=settings.CELERY_TASK_TIME_LIMIT_SECONDS, task_soft_time_limit=settings.CELERY_TASK_SOFT_TIME_LIMIT_SECONDS, + imports=( + "app.workers.discovery", + "app.workers.ingest", + "app.workers.peer_groups", + "app.workers.recompute", + "app.workers.token_cleanup", + ), ) -celery_app.autodiscover_tasks(["app.workers"]) celery_app.conf.beat_schedule = { "daily-universe-ingestion": { "task": "app.workers.ingest.ingest_universe_task", "schedule": crontab(hour=2, minute=0), # spec §52: universe refresh minimum daily --- a/app/workers/ingest.py +++ b/app/workers/ingest.py @@ -23,10 +23,11 @@ from app.adapters.base import ( ProviderAdapter, ProviderAuthError, ProviderNotFoundError, ProviderRateLimitError, ) from app.adapters.resolver import merge_statement_line_items, resolve_line_items +from app.adapters.validation import ValidationReport, validate_line_items from app.core.config import get_settings from app.core.db import SessionLocal from app.core.logging import get_logger from app.engines.identity import resolve_company_identity from app.engines.reference_data import canonical_mic, exchange_for_mic @@ -478,35 +479,67 @@ else None ) secondary_tier = secondary_adapter.tier if (secondary_adapter is not None and li_b is not None) else None income = fp.income_statement or IncomeStatement(financial_period_id=fp.id) - resolved, conflicts = resolve_line_items(li, li_b, _INCOME_FIELDS, adapter.tier, secondary_tier) + resolved, conflicts = resolve_line_items( + li, li_b, _INCOME_FIELDS, adapter.tier, secondary_tier + ) + merged_report = ValidationReport() + resolved = validate_line_items( + resolved, + merged_report, + row_key=f"{ticker}:{p.period_end}:{p.period_type}:income", + ) for field, value in resolved.items(): setattr(income, field, value) db.add(income) if conflicts: _record_conflicts(db, "income_statements", fp.id, conflicts, source, secondary_source) balance = fp.balance_sheet or BalanceSheet(financial_period_id=fp.id) - resolved, conflicts = resolve_line_items(li, li_b, _BALANCE_FIELDS, adapter.tier, secondary_tier) + resolved, conflicts = resolve_line_items( + li, li_b, _BALANCE_FIELDS, adapter.tier, secondary_tier + ) + merged_report = ValidationReport() + resolved = validate_line_items( + resolved, + merged_report, + row_key=f"{ticker}:{p.period_end}:{p.period_type}:balance", + ) for field, value in resolved.items(): setattr(balance, field, value) db.add(balance) if conflicts: _record_conflicts(db, "balance_sheets", fp.id, conflicts, source, secondary_source) cash_flow = fp.cash_flow or CashFlow(financial_period_id=fp.id) - resolved, conflicts = resolve_line_items(li, li_b, _CASH_FLOW_FIELDS, adapter.tier, secondary_tier) + resolved, conflicts = resolve_line_items( + li, li_b, _CASH_FLOW_FIELDS, adapter.tier, secondary_tier + ) + merged_report = ValidationReport() + resolved = validate_line_items( + resolved, + merged_report, + row_key=f"{ticker}:{p.period_end}:{p.period_type}:cash_flow", + ) for field, value in resolved.items(): setattr(cash_flow, field, value) db.add(cash_flow) if conflicts: _record_conflicts(db, "cash_flows", fp.id, conflicts, source, secondary_source) shares = fp.shares or Shares(financial_period_id=fp.id) - resolved, conflicts = resolve_line_items(li, li_b, _SHARES_FIELDS, adapter.tier, secondary_tier) + resolved, conflicts = resolve_line_items( + li, li_b, _SHARES_FIELDS, adapter.tier, secondary_tier + ) + merged_report = ValidationReport() + resolved = validate_line_items( + resolved, + merged_report, + row_key=f"{ticker}:{p.period_end}:{p.period_type}:shares", + ) for field, value in resolved.items(): setattr(shares, field, value) db.add(shares) if conflicts: _record_conflicts(db, "shares", fp.id, conflicts, source, secondary_source) --- a/app/workers/recompute.py +++ b/app/workers/recompute.py @@ -6,11 +6,11 @@ """ from __future__ import annotations from datetime import date -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.orm import Session from app.core.config import get_settings from app.core.db import SessionLocal from app.core.logging import get_logger @@ -94,10 +94,14 @@ select(FinancialPeriod) .where( FinancialPeriod.security_id == security.id, FinancialPeriod.period_type.in_(("Q1", "Q2", "Q3", "Q4")), FinancialPeriod.period_end <= as_of, + or_( + FinancialPeriod.filing_date.is_(None), + FinancialPeriod.filing_date <= as_of, + ), ) .order_by(FinancialPeriod.period_end.desc()) .limit(8) ).scalars().all() ) @@ -118,11 +122,16 @@ def build_snapshot_from_db(db: Session, security: Security, as_of: date) -> FinancialSnapshot | None: periods = ( db.execute( select(FinancialPeriod) - .where(FinancialPeriod.security_id == security.id, FinancialPeriod.period_type == "FY") + .where( + FinancialPeriod.security_id == security.id, + FinancialPeriod.period_type == "FY", + FinancialPeriod.period_end <= as_of, + FinancialPeriod.filing_date <= as_of, + ) .order_by(FinancialPeriod.period_end.desc()) .limit(11) ).scalars().all() ) if not periods: @@ -337,30 +346,35 @@ empty/partial `source_tiers` list without fabricating a score for missing entries.""" rows = db.execute( select(Source.provider_tier) .select_from(FinancialPeriod) .join(Source, FinancialPeriod.source_id == Source.id) - .where(FinancialPeriod.security_id == security_id, FinancialPeriod.period_end <= as_of) + .where(FinancialPeriod.security_id == security_id, + FinancialPeriod.period_end <= as_of, + or_( + FinancialPeriod.filing_date.is_(None), + FinancialPeriod.filing_date <= as_of, + )) .order_by(FinancialPeriod.period_end.desc()) .limit(limit) ).all() return [tier for (tier,) in rows] def recompute_security(db: Session, security_id: str, peer_metric_values: dict | None = None, - industry_medians: dict | None = None) -> None: + industry_medians: dict | None = None, as_of: date | None = None) -> None: """`peer_metric_values` (industry-percentile universe) is supplied by the caller — computing it requires a cross-security aggregate query, kept out of this function to keep it unit-testable with a hand-built peer set; see app/workers/peer_groups.py (IMPLEMENTED in Part B2 — see SPEC_COVERAGE.md) for the production aggregate-query version.""" settings = get_settings() security = db.get(Security, security_id) if security is None: logger.warning("recompute.security_not_found", security_id=security_id) return - as_of = date.today() + as_of = as_of or date.today() snapshot = build_snapshot_from_db(db, security, as_of) if snapshot is None: logger.warning("recompute.no_financial_data", security_id=security_id) return --- a/tests/test_provider_resilience.py +++ b/tests/test_provider_resilience.py @@ -106,12 +106,17 @@ def test_resolve_line_items_one_source_missing_a_field_other_has_it(): primary = {"revenue": 100.0} # net_income missing from primary entirely secondary = {"revenue": 100.0, "net_income": 10.0} resolved, conflicts = resolve_line_items(primary, secondary, _FIELDS, "PRIMARY", "SECONDARY") - assert resolved["net_income"] == 10.0 # only source with a value wins, not treated as a conflict - assert conflicts == [] + assert resolved["net_income"] == 10.0 + assert len(conflicts) == 1 + assert conflicts[0].field == "net_income" + assert conflicts[0].value_a is None + assert conflicts[0].value_b == 10.0 + assert conflicts[0].selected_value == 10.0 + assert conflicts[0].selection_reason == "Primary field missing; value supplied by secondary source" # --- Part A9: merge_statement_line_items (the critical ingestion bug fix) --- ============================================================ EXACT RELEASE SOURCE DIFFERENCES ============================================================ --- SOURCE DIFF OLD -> FINAL RELEASE --- diff -ruN '--exclude=__pycache__' '--exclude=*.pyc' /tmp/stocklab-release-diff-2358473/old/app/alembic/versions/0005_harden_financial_period_uniqueness.py /tmp/stocklab-release-diff-2358473/new/app/alembic/versions/0005_harden_financial_period_uniqueness.py --- /tmp/stocklab-release-diff-2358473/old/app/alembic/versions/0005_harden_financial_period_uniqueness.py 1970-01-01 02:00:00.000000000 +0200 +++ /tmp/stocklab-release-diff-2358473/new/app/alembic/versions/0005_harden_financial_period_uniqueness.py 2026-09-10 21:42:59.000000000 +0300 @@ -0,0 +1,39 @@ +"""Harden nullable financial-period uniqueness. + +Revision ID: 0005 +Revises: 0004 +""" + +from alembic import op + + +revision = "0005" +down_revision = "0004" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE financial_periods " + "DROP CONSTRAINT IF EXISTS uq_financial_period" + ) + op.execute( + "ALTER TABLE financial_periods " + "ADD CONSTRAINT uq_financial_period " + "UNIQUE NULLS NOT DISTINCT " + "(security_id, period_end, period_type, filing_date)" + ) + + +def downgrade() -> None: + op.execute( + "ALTER TABLE financial_periods " + "DROP CONSTRAINT IF EXISTS uq_financial_period" + ) + op.execute( + "ALTER TABLE financial_periods " + "ADD CONSTRAINT uq_financial_period " + "UNIQUE " + "(security_id, period_end, period_type, filing_date)" + ) diff -ruN '--exclude=__pycache__' '--exclude=*.pyc' /tmp/stocklab-release-diff-2358473/old/app/app/adapters/eodhd.py /tmp/stocklab-release-diff-2358473/new/app/app/adapters/eodhd.py --- /tmp/stocklab-release-diff-2358473/old/app/app/adapters/eodhd.py 2026-09-08 14:49:40.000000000 +0300 +++ /tmp/stocklab-release-diff-2358473/new/app/app/adapters/eodhd.py 2026-09-10 21:42:59.000000000 +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 '--exclude=__pycache__' '--exclude=*.pyc' /tmp/stocklab-release-diff-2358473/old/app/app/adapters/fmp.py /tmp/stocklab-release-diff-2358473/new/app/app/adapters/fmp.py --- /tmp/stocklab-release-diff-2358473/old/app/app/adapters/fmp.py 2026-09-08 14:48:16.000000000 +0300 +++ /tmp/stocklab-release-diff-2358473/new/app/app/adapters/fmp.py 2026-09-10 21:42:59.000000000 +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 @@ -98,6 +99,7 @@ if not data: raise ProviderNotFoundError(f"No FMP profile for {ticker}") row = data[0] + self._profile_currency = row.get("currency") or row.get("reportedCurrency") or "USD" return ProviderCompanyProfile( ticker=ticker, exchange_mic=row.get("exchangeShortName"), legal_name=row.get("companyName", ticker), display_name=row.get("companyName", ticker), country_iso2=row.get("country"), @@ -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 '--exclude=__pycache__' '--exclude=*.pyc' /tmp/stocklab-release-diff-2358473/old/app/app/workers/ingest.py /tmp/stocklab-release-diff-2358473/new/app/app/workers/ingest.py --- /tmp/stocklab-release-diff-2358473/old/app/app/workers/ingest.py 2026-09-10 18:10:37.000000000 +0300 +++ /tmp/stocklab-release-diff-2358473/new/app/app/workers/ingest.py 2026-09-10 21:42:59.000000000 +0300 @@ -16,6 +16,7 @@ changed. """ from __future__ import annotations +import httpx from datetime import date, timedelta @@ -409,6 +410,12 @@ adapter, lambda t: adapter.get_cash_flows(t, period="quarter"), ticker, "cash_flows_quarterly")) + if not income_periods: + raise RuntimeError( + f"Provider returned no financial periods for {ticker}; " + "refusing to commit stale data or trigger recompute." + ) + secondary_source = None secondary_by_period: dict = {} secondary_balance: dict = {} @@ -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 diff -ruN '--exclude=__pycache__' '--exclude=*.pyc' /tmp/stocklab-release-diff-2358473/old/app/tests/test_eodhd_adapter.py /tmp/stocklab-release-diff-2358473/new/app/tests/test_eodhd_adapter.py --- /tmp/stocklab-release-diff-2358473/old/app/tests/test_eodhd_adapter.py 2026-09-08 14:49:40.000000000 +0300 +++ /tmp/stocklab-release-diff-2358473/new/app/tests/test_eodhd_adapter.py 2026-09-10 22:03:34.000000000 +0300 @@ -145,8 +145,94 @@ assert raised + +def _prime_eodhd_price_currency(adapter, currency="EUR"): + """Test helper: emulate production profile resolution before price ingestion.""" + adapter._profile_currency = currency + assert adapter._profile_currency == currency + + # --- get_prices -------------------------------------------------------------------------------- +def test_get_prices_requires_profile_currency(): + from datetime import date as date_cls + from app.adapters.base import ProviderNotFoundError + + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("HTTP must not be called without provider currency") + + adapter = EODHDAdapter( + api_key="fake", + transport=_mock_transport(handler), + ) + + try: + adapter.get_prices( + "AAPL", + date_cls(2025, 9, 1), + date_cls(2025, 9, 30), + ) + except ProviderNotFoundError: + return + + raise AssertionError("get_prices() accepted missing provider currency") + + +def test_get_prices_uses_currency_from_company_profile(): + from datetime import date as date_cls + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + + if path.endswith("/fundamentals/AAPL"): + return httpx.Response( + 200, + json={ + "General": { + "Name": "Apple Inc.", + "Exchange": "XNAS", + "CountryISO": "US", + "Sector": "Technology", + "Industry": "Consumer Electronics", + "CurrencyCode": "EUR", + "ISIN": "US0378331005", + } + }, + ) + + if path.endswith("/eod/AAPL"): + return httpx.Response( + 200, + json=[ + { + "date": "2025-09-30", + "close": 228.2, + } + ], + ) + + raise AssertionError(f"unexpected path {path}") + + adapter = EODHDAdapter( + api_key="fake", + transport=_mock_transport(handler), + ) + + profile = adapter.get_company_profile("AAPL") + + assert profile.currency == "EUR" + assert adapter._profile_currency == "EUR" + + bars = adapter.get_prices( + "AAPL", + date_cls(2025, 9, 1), + date_cls(2025, 9, 30), + ) + + assert len(bars) == 1 + assert bars[0].currency == "EUR" + + def test_get_prices_parses_valid_bars(): from datetime import date as date_cls @@ -155,6 +241,7 @@ return httpx.Response(200, json=FAKE_EOD_RESPONSE) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) + _prime_eodhd_price_currency(adapter) 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 @@ -172,6 +259,7 @@ return httpx.Response(200, json=[{"date": "2025-09-30", "close": 228.2}]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) + _prime_eodhd_price_currency(adapter) 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 @@ -195,6 +283,7 @@ return httpx.Response(200, json=[{"date": "2025-09-30", "close": "not-a-number"}]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) + _prime_eodhd_price_currency(adapter) bars = adapter.get_prices("AAPL", date_cls(2025, 9, 1), date_cls(2025, 9, 30)) assert bars == [] report = adapter.last_price_validation @@ -211,6 +300,7 @@ return httpx.Response(200, json=[{"date": "2025-09-30", "close": "227.5"}]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) + _prime_eodhd_price_currency(adapter) 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 @@ -222,6 +312,7 @@ return httpx.Response(200, json=[]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) + _prime_eodhd_price_currency(adapter) bars = adapter.get_prices("AAPL", date_cls(2025, 9, 1), date_cls(2025, 9, 30)) assert bars == [] @@ -241,6 +332,7 @@ ]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) + _prime_eodhd_price_currency(adapter) 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 @@ -256,6 +348,7 @@ return httpx.Response(200, json=[{"close": 227.0}]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) + _prime_eodhd_price_currency(adapter) 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) @@ -267,6 +360,7 @@ return httpx.Response(200, json=[{"date": "2025-09-30", "close": -5.0}]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) + _prime_eodhd_price_currency(adapter) 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) @@ -280,6 +374,7 @@ return httpx.Response(200, json=[{"date": "2025-09-30", "close": 227.0, "volume": -100}]) adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) + _prime_eodhd_price_currency(adapter) 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 diff -ruN '--exclude=__pycache__' '--exclude=*.pyc' /tmp/stocklab-release-diff-2358473/old/app/tests/test_eodhd_adapter.py.before-sl006-fix3 /tmp/stocklab-release-diff-2358473/new/app/tests/test_eodhd_adapter.py.before-sl006-fix3 --- /tmp/stocklab-release-diff-2358473/old/app/tests/test_eodhd_adapter.py.before-sl006-fix3 1970-01-01 02:00:00.000000000 +0200 +++ /tmp/stocklab-release-diff-2358473/new/app/tests/test_eodhd_adapter.py.before-sl006-fix3 2026-09-10 22:03:34.000000000 +0300 @@ -0,0 +1,397 @@ +""" +Tests EODHDAdapter's request/response parsing against a mocked transport (httpx.MockTransport, +same pattern as test_fmp_adapter.py) -- no real network call is made. StockLab overhaul, final +engineering pass, Part A7: this file previously did not exist at all -- EODHD was the one real +provider adapter with zero test coverage. + +AUDIT (Part A7): `httpx` is not installed in this sandbox (confirmed repeatedly throughout this +overhaul -- docs/TEST_REPORT.md), so this file -- like test_fmp_adapter.py before it -- could not +actually be EXECUTED here. It is verified only by `python3 -m py_compile` and careful manual +review against app/adapters/eodhd.py's real source. Do not read a passing claim into this file's +existence; see docs/AUDIT_EODHD_TESTS_A7.md for the honest TESTED/NOT TESTED status. + +Coverage, per this pass's explicit shape list -- valid / missing-field / unexpected-field / +wrong-type / empty / API-error / partial-response -- applied to every EODHD method that has real +logic (get_company_profile, get_prices, list_universe, and the shared _get() error mapping). +get_income_statements/get_balance_sheets/get_cash_flows/get_estimates/get_dividends are all +unconditional NotImplementedError today (see eodhd.py's own module docstring) -- there is no field +mapping in them to exercise, so their tests only confirm the documented gap raises exactly what it +claims to, which matters in its own right (Part A6 found that an uncaught NotImplementedError from +exactly these methods would otherwise crash ingestion the moment PROVIDER_SECONDARY=EODHD is +configured -- see docs/AUDIT_PROVIDER_CONFLICT_A6.md §3). +""" +from __future__ import annotations + +import httpx + +from app.adapters.base import ProviderAuthError, ProviderNotFoundError, ProviderRateLimitError +from app.adapters.eodhd import EODHDAdapter + +FAKE_PROFILE_RESPONSE = { + "General": { + "Name": "Apple Inc.", "Exchange": "NASDAQ", "CountryISO": "US", "Sector": "Technology", + "Industry": "Consumer Electronics", "CurrencyCode": "USD", "ISIN": "US0378331005", + "WebURL": "https://apple.com", "Description": "Makes phones.", + }, +} + +FAKE_EOD_RESPONSE = [ + {"date": "2025-09-30", "open": 227.5, "high": 229.0, "low": 226.8, "close": 228.2, + "adjusted_close": 228.2, "volume": 45000000}, +] + + +def _mock_transport(handler): + return httpx.MockTransport(handler) + + +# --- get_company_profile --------------------------------------------------------------------- + +def test_get_company_profile_parses_known_fields_valid(): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path.endswith("/fundamentals/AAPL") + assert "api_token" in request.url.params + return httpx.Response(200, json=FAKE_PROFILE_RESPONSE) + + adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) + profile = adapter.get_company_profile("AAPL") + assert profile.legal_name == "Apple Inc." + assert profile.country_iso2 == "US" + assert profile.currency == "USD" + assert profile.isin == "US0378331005" + + +def test_get_company_profile_missing_fields_defaults_gracefully(): + # "General" present but nearly empty -- every .get(..., default) path should be exercised, + # not crash on a KeyError. + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"General": {}}) + + adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) + profile = adapter.get_company_profile("AAPL") + assert profile.legal_name == "AAPL" # falls back to the ticker, general.get("Name", ticker) + assert profile.currency == "USD" # falls back to "USD", general.get("CurrencyCode", "USD") + assert profile.country_iso2 is None + assert profile.sector is None + assert profile.beta is None # EODHDAdapter never populates beta -- always None + + +def test_get_company_profile_unexpected_extra_fields_ignored(): + # fields EODHD's real response is known to include that this adapter doesn't map at all + # (e.g. "Officers", "Listings", a nested "Highlights" block) must not break parsing. + response = { + "General": {**FAKE_PROFILE_RESPONSE["General"], "UnmappedField": "some value", "Officers": {"0": {"Name": "Tim Cook"}}}, + "Highlights": {"MarketCapitalization": 3000000000000}, + "Technicals": {"Beta": 1.2}, # note: NOT read by get_company_profile -- beta stays None + } + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=response) + + adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) + profile = adapter.get_company_profile("AAPL") + assert profile.legal_name == "Apple Inc." + assert profile.beta is None # documents that EODHDAdapter does NOT read Technicals.Beta today + + +def test_get_company_profile_empty_response(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={}) + + adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) + profile = adapter.get_company_profile("AAPL") + assert profile.legal_name == "AAPL" + assert profile.currency == "USD" + assert profile.country_iso2 is None + assert profile.description is None + + +def test_get_company_profile_auth_error_401(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, text="unauthorized") + + adapter = EODHDAdapter(api_key="bad-key", transport=_mock_transport(handler)) + try: + adapter.get_company_profile("AAPL") + raised = False + except ProviderAuthError: + raised = True + assert raised + + +def test_get_company_profile_rate_limit_429(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(429, text="rate limited") + + adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) + try: + adapter.get_company_profile("AAPL") + raised = False + 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"}, + ]) + + adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) + assert adapter.list_universe(exchange_mic="US") == ["AAPL", "MSFT"] + report = adapter.last_universe_validation + assert report.rows_seen == 3 and report.rows_accepted == 2 + assert any("no ticker code" in i.reason for i in report.errors) + + +def test_list_universe_no_exchange_mic_returns_empty(): + adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(lambda r: httpx.Response(200, json=[]))) + assert adapter.list_universe() == [] # no exchange_mic given -- never makes a request at all + + +def test_list_universe_empty_response(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=[]) + + adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(handler)) + assert adapter.list_universe(exchange_mic="US") == [] + + +# --- Statement methods: documented NotImplementedError (ties to Part A6's caught bug) ----------- + +def test_get_income_statements_raises_not_implemented(): + adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(lambda r: httpx.Response(200, json={}))) + try: + adapter.get_income_statements("AAPL") + raised = False + except NotImplementedError: + raised = True + assert raised + + +def test_get_balance_sheets_raises_not_implemented(): + adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(lambda r: httpx.Response(200, json={}))) + try: + adapter.get_balance_sheets("AAPL") + raised = False + except NotImplementedError: + raised = True + assert raised + + +def test_get_cash_flows_raises_not_implemented(): + adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(lambda r: httpx.Response(200, json={}))) + try: + adapter.get_cash_flows("AAPL") + raised = False + except NotImplementedError: + raised = True + assert raised + + +def test_get_estimates_raises_not_implemented(): + adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(lambda r: httpx.Response(200, json={}))) + try: + adapter.get_estimates("AAPL") + raised = False + except NotImplementedError: + raised = True + assert raised + + +def test_get_dividends_raises_not_implemented(): + adapter = EODHDAdapter(api_key="fake", transport=_mock_transport(lambda r: httpx.Response(200, json={}))) + try: + adapter.get_dividends("AAPL") + raised = False + except NotImplementedError: + raised = True + assert raised + + +ALL_TESTS = [obj for name, obj in list(globals().items()) if name.startswith("test_") and callable(obj)] + +if __name__ == "__main__": + passed, failed = 0, [] + for fn in ALL_TESTS: + try: + fn() + passed += 1 + print(f"PASS {fn.__name__}") + except AssertionError as e: + failed.append(fn.__name__) + print(f"FAIL {fn.__name__}: {e}") + print(f"\n{passed}/{len(ALL_TESTS)} passed") + if failed: + raise SystemExit(1) ============================================================ PRODUCTION RESTART ============================================================ --- RESTART BACKEND --- BACKEND_RESTART_RC=0 --- RESTART WORKER --- WORKER_RESTART_RC=0 --- RESTART BEAT --- BEAT_RESTART_RC=0 ============================================================ POST-RESTART SERVICE STATE ============================================================ active active active active active active --- CONTAINERS AFTER --- stocklab-redis docker.io/library/redis:7-alpine badcc1a4cfc5 Up 37 hours (healthy) stocklab-db docker.io/library/postgres:16-alpine c5eacf00c3fc Up 37 hours (healthy) stocklab-frontend localhost/stocklab-frontend:release-qa-20260909-182234 86c8aa085a7f Up 28 hours (healthy) stocklab-backend localhost/stocklab-backend:release-20260911-031500-FINAL-RELEASE da9002b240a4 Up 15 seconds (starting) stocklab-worker localhost/stocklab-backend:release-20260911-031500-FINAL-RELEASE f9311b8324c1 Up 10 seconds (starting) stocklab-beat localhost/stocklab-backend:release-20260911-031500-FINAL-RELEASE 9b9907c39783 Up 5 seconds --- RESTART COUNTS AFTER --- stocklab-backend restart=0 status=running image=localhost/stocklab-backend:release-20260911-031500-FINAL-RELEASE stocklab-worker restart=0 status=running image=localhost/stocklab-backend:release-20260911-031500-FINAL-RELEASE stocklab-beat restart=0 status=running image=localhost/stocklab-backend:release-20260911-031500-FINAL-RELEASE stocklab-frontend restart=0 status=running image=localhost/stocklab-frontend:release-qa-20260909-182234 stocklab-db restart=0 status=running image=docker.io/library/postgres:16-alpine stocklab-redis restart=0 status=running image=docker.io/library/redis:7-alpine ============================================================ IMAGE CONSISTENCY ============================================================ stocklab-backend=a892fa2d163267cfce2df46577e843d8d08e01b346ffbfe3e6803f6a97faaee8 stocklab-worker=a892fa2d163267cfce2df46577e843d8d08e01b346ffbfe3e6803f6a97faaee8 stocklab-beat=a892fa2d163267cfce2df46577e843d8d08e01b346ffbfe3e6803f6a97faaee8 --- EXPECTED FINAL IMAGE ID --- a892fa2d163267cfce2df46577e843d8d08e01b346ffbfe3e6803f6a97faaee8 ============================================================ HEALTH / READY ============================================================ {"status":"ok"} HEALTH_RC=0 {"status":"ok","database":true} READY_RC=0 ============================================================ CELERY REGISTRATION ============================================================ ============================================================ LOCAL SCREENER SMOKE ============================================================ HTTP_STATUS=200 {"total":1,"results":[{"company":{"security_id":"a3087dc2-25fd-41bf-a557-6bf5428ce281","ticker":"AAPL","company_name":"Apple Inc. (DEMO)","country":"United States","sector":"Technology","industry":"Consumer Electronics","is_demo":true},"overall_score":null,"recommendation":"WAIT","weighted_fair_value":292.4017061244043,"margin_of_safety":null,"confidence_score":60.308641975308646,"data_quality_score":35.66666666666667}]}SCREENER_RESPONSE_CONTRACT_PASS= True SCREENER_TOTAL= 1 SCREENER_RESULTS= 1 ============================================================ VALIDATION GUARDS ============================================================ 21_FILTERS_HTTP=422 {"detail":[{"type":"too_long","loc":["body","filters"],"msg":"List should have at most 20 items after validation, not 21","input":[{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"},{"metric":"roe","op":"gt","value":10,"relative":"absolute"}],"ctx":{"field_type":"List","max_length":20,"actual_length":21}}]}49_CHAR_METRIC_HTTP=422 {"detail":[{"type":"string_too_long","loc":["body","filters",0,"metric"],"msg":"String should have at most 48 characters","input":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","ctx":{"max_length":48}}]} ============================================================ PORTS ============================================================ LISTEN 0 2048 127.0.0.1:18000 0.0.0.0:* users:(("uvicorn",pid=2391308,fd=11)) LISTEN 0 511 127.0.0.1:6379 0.0.0.0:* users:(("redis-server",pid=1133711,fd=6)) LISTEN 0 200 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=1141225,fd=6)) LISTEN 0 511 127.0.0.1:13000 0.0.0.0:* users:(("next-server (v1",pid=1497838,fd=18)) ============================================================ PUBLIC HTTPS ============================================================ PUBLIC_ROOT_HTTP=200 PUBLIC_SCREEN_HTTP=200 PUBLIC_SCREENER_CONTRACT_PASS= True PUBLIC_TOTAL= 1 PUBLIC_RESULTS= 1 ============================================================ FINAL RESULT ============================================================ PROD_FINAL_RESTART=PASS LOG=/data/files/bmw/stocklab-prod-final-restart-20260911.log