Files
signal-platform/tests/unit/test_fundamental_data_refresh.py
dennisthiessenandClaude Opus 5 3e83d63b05 chore: decommission FMP, Finnhub and Alpha Vantage (A6)
The A5 cutover has been on and observed in production, so SEC Company Facts +
DoltHub earnings are already the live source for `fundamental_data`. This
removes everything the legacy path still occupied.

Gone: the three providers and their config/env keys; the weekly
`fundamental_collector` job; the cutover toggle (SEC + Dolt is now the
unconditional path, so `off` can no longer silently freeze scoring inputs); the
A5 parity report, whose deltas became structurally zero once the candidate
builder started writing the table it compared against; and the FMP tier of
universe bootstrap.

Two behavioral notes:

- Disabling **SEC Fundamentals Import** now stops the SEC network fetch only.
  The local cache refresh moved outside the job-enable check, because candidates
  also derive from daily closes and earnings events — freezing those on an
  ingestion pause would stale scoring with no fallback left to recover from.
- `/ingestion/fetch?sources=fundamentals` still accepts the key and reports
  `skipped`; there is no per-ticker fetch any more.

Migration 029 does not blanket-delete the leftover settings rows. Migrations run
before the service restart, and pre-A6 code reads an absent `job_*_enabled` row
as *enabled* — so the two behavior-bearing keys become tombstones pinned to safe
values (hidden in Admin) and only the inert three are deleted. Removing the
provider keys from the production `.env` is the matching rollout step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:19:28 +02:00

253 lines
8.1 KiB
Python

"""A5 activation: local candidate derivation and compat-cache refresh."""
from __future__ import annotations
import json
from datetime import date, datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base
from app.models.earnings_event import EarningsEvent
from app.models.fundamental import FundamentalData
from app.models.fundamental_snapshot import FundamentalSnapshot
from app.models.ohlcv import OHLCVRecord
from app.models.score import CompositeScore, DimensionScore
from app.models.ticker import Ticker
from app.services import fundamentals_candidate_service as candidates
from app.services import fundamentals_derivation as deriv
from app.services import fundamental_data_refresh_service as refresh_service
UTC = timezone.utc
NOW = datetime(2026, 7, 24, 10, 0, tzinfo=UTC)
TODAY = date(2026, 7, 24)
_engine = create_async_engine("sqlite+aiosqlite://", echo=False)
_session_factory = async_sessionmaker(
_engine, class_=AsyncSession, expire_on_commit=False
)
@pytest.fixture(autouse=True)
async def _setup_tables():
async with _engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
yield
async with _engine.begin() as connection:
await connection.run_sync(Base.metadata.drop_all)
@pytest.fixture
async def session() -> AsyncSession:
async with _session_factory() as db:
yield db
def _snapshot_rows(cik: str) -> list[FundamentalSnapshot]:
rows: list[FundamentalSnapshot] = []
periods = ("Q1", "Q2", "Q3", "FY")
months = (3, 6, 9, 12)
for fiscal_year, multiplier in ((2025, 1.0), (2026, 1.1)):
revenue = [100 * multiplier, 110 * multiplier, 120 * multiplier, 130 * multiplier]
eps = [1.0 * multiplier, 1.1 * multiplier, 1.2 * multiplier, 1.3 * multiplier]
for index, fiscal_period in enumerate(periods):
period_end = date(fiscal_year, months[index], 28)
rows.append(
FundamentalSnapshot(
cik=cik,
accession=f"{cik}-{fiscal_year}-{fiscal_period}",
form="10-K" if fiscal_period == "FY" else "10-Q",
filed_date=period_end,
accepted_at=datetime(
fiscal_year, months[index], 28, tzinfo=UTC
),
period_end=period_end,
fiscal_year=fiscal_year,
fiscal_period=fiscal_period,
revenue=sum(revenue[: index + 1]),
diluted_eps=sum(eps[: index + 1]),
shares_outstanding=1_000,
)
)
return rows
async def test_refresh_updates_all_fields_and_invalidates_scores(
session: AsyncSession,
):
first = Ticker(symbol="AAA", cik="0000000001")
second = Ticker(symbol="AAB", cik="0000000001")
session.add_all([first, second])
await session.flush()
session.add_all(_snapshot_rows(first.cik))
session.add_all(
[
OHLCVRecord(
ticker_id=first.id,
date=TODAY - timedelta(days=1),
open=100,
high=100,
low=100,
close=100,
volume=100,
),
OHLCVRecord(
ticker_id=second.id,
date=TODAY - timedelta(days=1),
open=200,
high=200,
low=200,
close=200,
volume=100,
),
EarningsEvent(
ticker_id=first.id,
announce_date=TODAY - timedelta(days=10),
session="amc",
eps_estimate=2,
eps_actual=2.2,
source="dolt_earnings",
),
EarningsEvent(
ticker_id=first.id,
announce_date=TODAY,
session="amc",
source="dolt_earnings",
),
]
)
for ticker in (first, second):
session.add(
FundamentalData(
ticker_id=ticker.id,
pe_ratio=1,
revenue_growth=1,
earnings_surprise=1,
market_cap=1,
fetched_at=NOW - timedelta(days=1),
)
)
session.add(
DimensionScore(
ticker_id=ticker.id,
dimension="fundamental",
score=50,
is_stale=False,
computed_at=NOW,
)
)
session.add(
CompositeScore(
ticker_id=ticker.id,
score=50,
is_stale=False,
weights_json="{}",
computed_at=NOW,
)
)
await session.commit()
summary = await refresh_service.refresh(
session, now=NOW, today=TODAY
)
stored = {
row.ticker_id: row
for row in (
await session.execute(select(FundamentalData))
).scalars()
}
assert summary["refreshed"] == 2
assert summary["score_inputs_changed"] == 2
assert stored[first.id].pe_ratio == pytest.approx(100 / 5.06)
assert stored[second.id].pe_ratio == pytest.approx(200 / 5.06)
assert stored[first.id].revenue_growth == pytest.approx(10)
assert stored[first.id].earnings_surprise == pytest.approx(10)
assert stored[first.id].market_cap == pytest.approx(100_000)
assert stored[first.id].next_earnings_date == TODAY
metadata = json.loads(stored[first.id].unavailable_fields_json)
assert metadata["source_pe_ratio"] == "sec_facts+ohlcv_records"
assert metadata["source_next_earnings_date"] == "dolt_earnings"
dimensions = (
await session.execute(select(DimensionScore))
).scalars().all()
composites = (
await session.execute(select(CompositeScore))
).scalars().all()
assert all(row.is_stale for row in dimensions)
assert all(row.is_stale for row in composites)
for row in (*dimensions, *composites):
row.is_stale = False
await session.commit()
unchanged = await refresh_service.refresh(
session, now=NOW + timedelta(hours=1), today=TODAY
)
assert unchanged["score_inputs_changed"] == 0
assert not any(
(await session.execute(select(DimensionScore.is_stale))).scalars()
)
assert not any(
(await session.execute(select(CompositeScore.is_stale))).scalars()
)
async def test_candidate_uses_guarded_derive_outputs_and_share_fallback(
session: AsyncSession, monkeypatch
):
ticker = Ticker(symbol="GUARD", cik="0000000002")
session.add(ticker)
await session.flush()
session.add(
FundamentalSnapshot(
cik=ticker.cik,
accession="raw-accession",
form="10-Q",
filed_date=TODAY,
accepted_at=NOW,
period_end=TODAY,
fiscal_year=2026,
fiscal_period="Q2",
diluted_eps=99,
shares_outstanding=999,
)
)
session.add(
OHLCVRecord(
ticker_id=ticker.id,
date=TODAY,
open=50,
high=50,
low=50,
close=50,
volume=100,
)
)
await session.commit()
def guarded(_rows):
return deriv.DerivedFundamentals(
metrics={
"revenue_growth_yoy": deriv.MetricSeries(value=7)
},
ttm_diluted_eps=None,
ttm_diluted_eps_caveat="split guard applied",
shares_outstanding=123,
shares_outstanding_estimated=True,
latest_period_end=TODAY,
latest_filed_date=TODAY,
)
monkeypatch.setattr(candidates.deriv, "derive", guarded)
candidate = (await candidates.build_candidates(session, today=TODAY))[0]
assert candidate.pe_ratio is None
assert candidate.market_cap == 50 * 123
assert candidate.revenue_growth == 7
assert candidate.unavailable_fields["pe_ratio"] == "split guard applied"
assert "weighted-average" in candidate.unavailable_fields["market_cap_estimated"]