373- is_demo = adapter.tier == "DEMO" 374- security = get_or_create_company(db, adapter, ticker) 375- _assert_no_demo_live_mix(db, security.id, is_demo) 376- 377- source = _get_or_create_source(db, adapter.name, adapter.tier, is_demo, f"ingest:{ticker}") 378- 379- # AUDIT FIX (StockLab final engineering pass, Part A9 -- CRITICAL, see 380- # docs/AUDIT_INGEST_STATEMENTS_A9.md). Before this fix, this function fetched ONLY the income 381- # statement and then wrote the BalanceSheet, CashFlow and Shares rows out of that same 382- # response's line items. adapter.get_balance_sheets() and adapter.get_cash_flows() were never 383- # called anywhere in the application. That is invisible in DEMO mode (DemoDataAdapter returns 384- # one fully-merged dict from all three methods) and catastrophic against FMP, whose income 385- # response carries only _INCOME_MAP's fields -- every balance-sheet, cash-flow and share-count 386- # column would have been written NULL for real provider data, taking most metrics, all five 387- # pillar scores and every valuation down with it. 388: income_periods = adapter.get_income_statements(ticker) 389- balance_by_period = _fetch_statements_by_period(adapter, adapter.get_balance_sheets, ticker, "balance_sheets") 390- cash_by_period = _fetch_statements_by_period(adapter, adapter.get_cash_flows, ticker, "cash_flows") 391- 392- # Part A9: optional quarterly ingestion. Off by default (Settings.INGEST_QUARTERLY_PERIODS). 393- # Quarterly rows are stored as ordinary FinancialPeriod rows with period_type Q1..Q4 -- the 394- # (security_id, period_end, period_type, filing_date) unique constraint keeps them distinct 395- # from the annual rows, and every existing query that filters period_type == "FY" is 396- # unaffected. app/engines/ttm.py turns four of them into a TTM basis at recompute time. 397- if include_quarterly: 398- try: 399- quarterly_income = adapter.get_income_statements(ticker, period="quarter") 400- except NotImplementedError: 401- logger.warning("ingest.security.quarterly_not_implemented", ticker=ticker, 402- provider=adapter.name) 403- quarterly_income = [] 404- if quarterly_income: 405- income_periods = list(income_periods) + list(quarterly_income) 406- balance_by_period.update(_fetch_statements_by_period( 407- adapter, lambda t: adapter.get_balance_sheets(t, period="quarter"), ticker, 408- "balance_sheets_quarterly")) 409- cash_by_period.update(_fetch_statements_by_period( 410- adapter, lambda t: adapter.get_cash_flows(t, period="quarter"), ticker, 411- "cash_flows_quarterly")) 412- 413- secondary_source = None 414- secondary_by_period: dict = {} 415- secondary_balance: dict = {} 416- secondary_cash: dict = {} 417- if secondary_adapter is not None: 418- if secondary_adapter.tier == "DEMO" or is_demo: 419- # Mirrors _assert_no_demo_live_mix's own rule at the single-call level: a demo source 420- # can never be one half of a dual-source resolution, since demo data is synthetic and 421- # was never meant to be cross-checked against (or corrupt) real provider data. 422- raise RuntimeError("Refusing dual-source ingestion: DEMO cannot be paired with a second provider.") 423- secondary_source = _get_or_create_source(db, secondary_adapter.name, secondary_adapter.tier, is_demo, f"ingest:{ticker}") 424- # AUDIT (Part A6): period alignment across two providers is keyed on (period_end, 425- # period_type) only -- NOT filing_date, which two providers can legitimately report a day 426- # or more apart for the "same" filing. This is a real, documented limitation: a provider 427- # that reports a fiscal period boundary a few days off from the other (a genuine, if rare, 428- # real-world occurrence) will not be matched and that period is silently treated as 429- # primary-only for this run, not flagged as a mismatch. See docs/AUDIT_PROVIDER_CONFLICT_A6.md. 430- try: 431- secondary_by_period = { 432- (p.period_end, p.period_type): p for p in secondary_adapter.get_income_statements(ticker) 433- } 434- # Part A9: the secondary provider's balance-sheet and cash-flow responses are fetched 435- # too, so cross-source conflict resolution covers all four statement groups rather 436- # than only the income statement. _fetch_statements_by_period returns {} for an 437- # adapter that has not implemented a statement, so this adds no new failure mode. 438- secondary_balance = _fetch_statements_by_period( 439- secondary_adapter, secondary_adapter.get_balance_sheets, ticker, "balance_sheets") 440- secondary_cash = _fetch_statements_by_period( 441- secondary_adapter, secondary_adapter.get_cash_flows, ticker, "cash_flows") 442- except NotImplementedError: 443- # AUDIT FIX (Part A6, real bug caught before this shipped): EODHDAdapter.get_income_ 444- # statements/get_balance_sheets/get_cash_flows are NotImplementedError today 445- # (app/adapters/eodhd.py's own module docstring -- statement-level field mapping was 446- # deliberately deferred until verified against a live response). EODHD is the only 447- # other real (non-DEMO) adapter besides FMP, so PROVIDER_SECONDARY="EODHD" is the one 448- # realistic dual-source configuration this codebase can actually be set to today -- and 449- # without this except clause, setting it would crash every single ingestion task the 450- # instant this function tried to call the secondary adapter's statement method. Treated 451- # as "no secondary statement data available this run", not a crash: falls back to 452- # exactly the primary-only behavior secondary_adapter=None already has for every period. 453- logger.warning("ingest.security.secondary_statements_not_implemented", ticker=ticker, 454- secondary_provider=secondary_adapter.name) 455- 456- for p in income_periods: 457- fp = db.query(FinancialPeriod).filter_by( 458- security_id=security.id, period_end=p.period_end, period_type=p.period_type, filing_date=p.filing_date, 459- ).one_or_none() 460- if fp is None: 461- fp = FinancialPeriod( 462- security_id=security.id, period_end=p.period_end, period_type=p.period_type, 463- filing_date=p.filing_date, currency=p.currency, source_id=source.id, 464- ) 465- db.add(fp) 466- db.flush() 467- 468- key = (p.period_end, p.period_type) 469- li = merge_statement_line_items( 470- p.line_items, 471- balance_by_period.get(key), 472- cash_by_period.get(key), 473- ) 474- secondary_p = secondary_by_period.get(key) 475- li_b = ( 476- merge_statement_line_items( 477- secondary_p.line_items, secondary_balance.get(key), secondary_cash.get(key) 478- )