Implement A5 fundamentals cutover activation
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
"""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.settings import SystemSetting
|
||||
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_default_off_performs_no_candidate_read_or_write(
|
||||
session: AsyncSession, monkeypatch
|
||||
):
|
||||
ticker = Ticker(symbol="AAA")
|
||||
session.add(ticker)
|
||||
await session.flush()
|
||||
session.add(
|
||||
FundamentalData(
|
||||
ticker_id=ticker.id,
|
||||
pe_ratio=12,
|
||||
revenue_growth=3,
|
||||
earnings_surprise=1,
|
||||
market_cap=100,
|
||||
fetched_at=NOW,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def should_not_read(*args, **kwargs):
|
||||
raise AssertionError("default-off refresh derived candidates")
|
||||
|
||||
monkeypatch.setattr(candidates, "build_candidates", should_not_read)
|
||||
summary = await refresh_service.refresh_if_enabled(session, today=TODAY)
|
||||
|
||||
stored = await session.scalar(
|
||||
select(FundamentalData).where(FundamentalData.ticker_id == ticker.id)
|
||||
)
|
||||
assert summary == {
|
||||
"enabled": False,
|
||||
"refreshed": 0,
|
||||
"score_inputs_changed": 0,
|
||||
"dimension_scores_staled": 0,
|
||||
"composite_scores_staled": 0,
|
||||
}
|
||||
assert stored.pe_ratio == 12
|
||||
|
||||
|
||||
async def test_activated_refresh_updates_all_fields_and_invalidates_scores(
|
||||
session: AsyncSession,
|
||||
):
|
||||
session.add(
|
||||
SystemSetting(key=refresh_service.ACTIVATION_KEY, value="true")
|
||||
)
|
||||
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_if_enabled(
|
||||
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_if_enabled(
|
||||
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"]
|
||||
@@ -12,6 +12,7 @@ from app.scheduler import (
|
||||
_last_successful,
|
||||
_run_shadow_import,
|
||||
run_fundamentals_parity_report,
|
||||
run_sec_fundamentals_import,
|
||||
configure_scheduler,
|
||||
get_job_runtime_snapshot,
|
||||
queue_backtest_options,
|
||||
@@ -247,6 +248,95 @@ class TestShadowImportJobs:
|
||||
assert runtime["status"] == "skipped"
|
||||
assert runtime["message"] == "Disabled"
|
||||
|
||||
async def test_sec_failure_still_runs_activated_local_refresh(self, monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def enabled(db, job_name):
|
||||
return True
|
||||
|
||||
async def unavailable(importer):
|
||||
raise RuntimeError("SEC unavailable")
|
||||
|
||||
async def refreshed(db):
|
||||
calls.append(db)
|
||||
return {
|
||||
"enabled": True,
|
||||
"refreshed": 511,
|
||||
"score_inputs_changed": 2,
|
||||
"dimension_scores_staled": 2,
|
||||
"composite_scores_staled": 2,
|
||||
}
|
||||
|
||||
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
|
||||
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
||||
monkeypatch.setattr("app.scheduler.run_import", unavailable)
|
||||
monkeypatch.setattr(
|
||||
"app.scheduler.fundamental_data_refresh_service.refresh_if_enabled",
|
||||
refreshed,
|
||||
)
|
||||
|
||||
await run_sec_fundamentals_import()
|
||||
|
||||
assert len(calls) == 1
|
||||
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
|
||||
assert runtime["status"] == "error"
|
||||
assert runtime["message"] == "SEC unavailable"
|
||||
|
||||
async def test_sec_success_surfaces_activated_refresh_summary(self, monkeypatch):
|
||||
async def enabled(db, job_name):
|
||||
return True
|
||||
|
||||
async def imported(importer):
|
||||
return SimpleNamespace(
|
||||
status="no_op", revision="abcdef1234567890", error_details=None
|
||||
)
|
||||
|
||||
async def refreshed(db):
|
||||
return {
|
||||
"enabled": True,
|
||||
"refreshed": 511,
|
||||
"score_inputs_changed": 2,
|
||||
"dimension_scores_staled": 2,
|
||||
"composite_scores_staled": 2,
|
||||
}
|
||||
|
||||
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
|
||||
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
||||
monkeypatch.setattr("app.scheduler.run_import", imported)
|
||||
monkeypatch.setattr(
|
||||
"app.scheduler.fundamental_data_refresh_service.refresh_if_enabled",
|
||||
refreshed,
|
||||
)
|
||||
|
||||
await run_sec_fundamentals_import()
|
||||
|
||||
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
|
||||
assert runtime["status"] == "completed"
|
||||
assert runtime["message"] == (
|
||||
"no_op · abcdef123456 · cache 511 · 2 score inputs changed"
|
||||
)
|
||||
|
||||
async def test_disabled_sec_job_does_not_run_local_refresh(self, monkeypatch):
|
||||
async def disabled(db, job_name):
|
||||
return False
|
||||
|
||||
async def should_not_run(*args, **kwargs):
|
||||
raise AssertionError("disabled SEC job ran work")
|
||||
|
||||
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
|
||||
monkeypatch.setattr("app.scheduler._is_job_enabled", disabled)
|
||||
monkeypatch.setattr("app.scheduler.run_import", should_not_run)
|
||||
monkeypatch.setattr(
|
||||
"app.scheduler.fundamental_data_refresh_service.refresh_if_enabled",
|
||||
should_not_run,
|
||||
)
|
||||
|
||||
await run_sec_fundamentals_import()
|
||||
|
||||
runtime = get_job_runtime_snapshot("sec_fundamentals_import")
|
||||
assert runtime["status"] == "skipped"
|
||||
assert runtime["message"] == "Disabled"
|
||||
|
||||
|
||||
async def test_fundamentals_parity_job_surfaces_report_summary(monkeypatch):
|
||||
async def enabled(db, job_name):
|
||||
|
||||
Reference in New Issue
Block a user