feat: add fundamentals parity reporting
This commit is contained in:
@@ -104,6 +104,22 @@ def test_net_debt_leverage_and_share_dilution():
|
||||
assert d.metrics["share_count_change_yoy"].value == pytest.approx(-10.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_split_suspect_share_move_suppresses_share_and_eps_comparisons():
|
||||
rows = _two_years()
|
||||
for row in rows:
|
||||
if row.fiscal_year == 2026:
|
||||
row.shares_outstanding = 2000 # +100% resembles an unadjusted 2-for-1 split
|
||||
|
||||
d = fd.derive(rows)
|
||||
|
||||
for key in ("share_count_change_yoy", "eps_growth_yoy"):
|
||||
series = d.metrics[key]
|
||||
assert series.value is None
|
||||
assert series.history[-1].value is None
|
||||
assert "possible split" in series.caveat
|
||||
assert d.metrics["revenue_growth_yoy"].value == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_valuation_inputs():
|
||||
d = fd.derive(_two_years())
|
||||
# TTM diluted EPS FY2026 = 1.1+1.21+1.32+1.43 = 5.06
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""A5 fundamentals parity report: read-only comparison + artifact archive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.data_import_run import DataImportRun
|
||||
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.ticker import Ticker
|
||||
from app.services.fundamentals_parity_service import (
|
||||
build_report,
|
||||
fundamental_score,
|
||||
load_latest,
|
||||
load_latest_csv,
|
||||
load_latest_json,
|
||||
store_report,
|
||||
)
|
||||
|
||||
UTC = timezone.utc
|
||||
GENERATED = datetime(2026, 7, 23, 10, 30, tzinfo=UTC)
|
||||
|
||||
|
||||
def _snapshot_rows(cik: str) -> list[FundamentalSnapshot]:
|
||||
rows = []
|
||||
periods = ("Q1", "Q2", "Q3", "FY")
|
||||
months = (3, 6, 9, 12)
|
||||
for fy, multiplier in ((2025, 1.0), (2026, 1.1)):
|
||||
revenues = [100 * multiplier, 110 * multiplier, 120 * multiplier, 130 * multiplier]
|
||||
eps = [1.0 * multiplier, 1.1 * multiplier, 1.2 * multiplier, 1.3 * multiplier]
|
||||
for index, period in enumerate(periods):
|
||||
period_end = date(fy, months[index], 28)
|
||||
rows.append(
|
||||
FundamentalSnapshot(
|
||||
cik=cik,
|
||||
accession=f"{cik}-{fy}-{period}",
|
||||
form="10-K" if period == "FY" else "10-Q",
|
||||
filed_date=period_end,
|
||||
accepted_at=datetime(fy, months[index], 28, tzinfo=UTC),
|
||||
period_end=period_end,
|
||||
fiscal_year=fy,
|
||||
fiscal_period=period,
|
||||
revenue=sum(revenues[: index + 1]),
|
||||
operating_income=sum(revenues[: index + 1]) * 0.2,
|
||||
diluted_eps=sum(eps[: index + 1]),
|
||||
cfo=sum(revenues[: index + 1]) * 0.25,
|
||||
capex=sum(revenues[: index + 1]) * 0.05,
|
||||
depreciation_amortization=sum(revenues[: index + 1]) * 0.05,
|
||||
cash_and_st_investments=40,
|
||||
total_debt=100,
|
||||
shares_outstanding=1000,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
async def _seed(db_session):
|
||||
first = Ticker(symbol="AAA", cik="0000000001", sic="3571")
|
||||
second = Ticker(symbol="BBB", cik=None, sic=None)
|
||||
db_session.add_all([first, second])
|
||||
await db_session.flush()
|
||||
db_session.add_all(_snapshot_rows(first.cik))
|
||||
db_session.add_all(
|
||||
[
|
||||
FundamentalData(
|
||||
ticker_id=first.id,
|
||||
pe_ratio=25,
|
||||
revenue_growth=5,
|
||||
earnings_surprise=0,
|
||||
fetched_at=GENERATED,
|
||||
),
|
||||
FundamentalData(
|
||||
ticker_id=second.id,
|
||||
pe_ratio=12,
|
||||
revenue_growth=3,
|
||||
earnings_surprise=None,
|
||||
fetched_at=GENERATED,
|
||||
),
|
||||
OHLCVRecord(
|
||||
ticker_id=first.id,
|
||||
date=date(2026, 7, 22),
|
||||
open=100,
|
||||
high=100,
|
||||
low=100,
|
||||
close=100,
|
||||
volume=100,
|
||||
),
|
||||
EarningsEvent(
|
||||
ticker_id=first.id,
|
||||
announce_date=date(2026, 7, 1),
|
||||
session="amc",
|
||||
eps_estimate=2,
|
||||
eps_actual=2.2,
|
||||
source="dolt_earnings",
|
||||
),
|
||||
DataImportRun(
|
||||
source="sec_facts",
|
||||
revision="sec-rev",
|
||||
status="promoted",
|
||||
source_max_date=date(2026, 7, 22),
|
||||
started_at=GENERATED,
|
||||
completed_at=GENERATED,
|
||||
),
|
||||
DataImportRun(
|
||||
source="dolt_earnings",
|
||||
revision="dolt-rev",
|
||||
status="no_op",
|
||||
source_max_date=date(2026, 7, 22),
|
||||
started_at=GENERATED,
|
||||
completed_at=GENERATED,
|
||||
),
|
||||
]
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
|
||||
def test_score_formula_matches_production_rules():
|
||||
score = fundamental_score(pe_ratio=15, revenue_growth=0, earnings_surprise=0)
|
||||
assert score == pytest.approx((100 + 50 + 50) / 3)
|
||||
assert fundamental_score(pe_ratio=15, revenue_growth=None, earnings_surprise=None) is None
|
||||
|
||||
async def test_report_compares_sources_and_leaves_database_untouched(db_session):
|
||||
await _seed(db_session)
|
||||
before = await db_session.scalar(select(func.count()).select_from(FundamentalData))
|
||||
|
||||
report = await build_report(
|
||||
db_session,
|
||||
generated_at=GENERATED,
|
||||
today=date(2026, 7, 23),
|
||||
)
|
||||
|
||||
after = await db_session.scalar(select(func.count()).select_from(FundamentalData))
|
||||
assert before == after == 2
|
||||
assert not db_session.new and not db_session.dirty and not db_session.deleted
|
||||
assert report["read_only"] is True
|
||||
assert report["approval_status"] == "pending_explicit_approval"
|
||||
assert report["source_runs"]["sec_facts"]["revision"] == "sec-rev"
|
||||
assert report["source_runs"]["dolt_earnings"]["revision"] == "dolt-rev"
|
||||
|
||||
first = next(row for row in report["rows"] if row["symbol"] == "AAA")
|
||||
assert first["fields"]["pe_ratio"]["candidate"] == pytest.approx(
|
||||
100 / 5.06, abs=1e-4
|
||||
)
|
||||
assert first["fields"]["revenue_growth"]["candidate"] == pytest.approx(10)
|
||||
assert first["fields"]["earnings_surprise"]["candidate"] == pytest.approx(10)
|
||||
assert first["scores"]["candidate_fundamental"] is not None
|
||||
assert report["summary"]["universe_count"] == 2
|
||||
assert report["summary"]["field_stats"]["pe_ratio"]["both_available"] == 1
|
||||
|
||||
|
||||
async def test_artifacts_archive_and_latest_manifest(db_session, tmp_path):
|
||||
await _seed(db_session)
|
||||
report = await build_report(
|
||||
db_session,
|
||||
generated_at=GENERATED,
|
||||
today=date(2026, 7, 23),
|
||||
)
|
||||
|
||||
paths = store_report(report, tmp_path)
|
||||
|
||||
assert tmp_path.joinpath("latest.json").exists()
|
||||
assert paths["json"].endswith(".json") and paths["csv"].endswith(".csv")
|
||||
assert load_latest(tmp_path)["generated_at"] == GENERATED.isoformat()
|
||||
csv_artifact = load_latest_csv(tmp_path)
|
||||
assert csv_artifact is not None
|
||||
assert csv_artifact[0].endswith(".csv")
|
||||
assert "legacy_fundamental,candidate_fundamental" in csv_artifact[1]
|
||||
assert "AAA" in csv_artifact[1]
|
||||
json_artifact = load_latest_json(tmp_path)
|
||||
assert json_artifact is not None and '"rows"' in json_artifact[1]
|
||||
|
||||
|
||||
async def test_admin_endpoints_return_compact_summary_and_downloads(
|
||||
client, db_session, tmp_path, monkeypatch
|
||||
):
|
||||
from app.config import settings
|
||||
from app.dependencies import require_admin
|
||||
from app.main import app
|
||||
|
||||
await _seed(db_session)
|
||||
report = await build_report(
|
||||
db_session,
|
||||
generated_at=GENERATED,
|
||||
today=date(2026, 7, 23),
|
||||
)
|
||||
store_report(report, tmp_path)
|
||||
monkeypatch.setattr(settings, "fundamentals_parity_report_dir", str(tmp_path))
|
||||
app.dependency_overrides[require_admin] = lambda: None
|
||||
try:
|
||||
summary_response = await client.get("/api/v1/admin/fundamentals-parity")
|
||||
assert summary_response.status_code == 200
|
||||
summary = summary_response.json()["data"]
|
||||
assert summary["summary"]["universe_count"] == 2
|
||||
assert "rows" not in summary
|
||||
|
||||
csv_response = await client.get("/api/v1/admin/fundamentals-parity/csv")
|
||||
assert csv_response.status_code == 200
|
||||
assert "AAA" in csv_response.json()["data"]["content"]
|
||||
|
||||
json_response = await client.get("/api/v1/admin/fundamentals-parity/json")
|
||||
assert json_response.status_code == 200
|
||||
assert '"rows"' in json_response.json()["data"]["content"]
|
||||
finally:
|
||||
app.dependency_overrides.pop(require_admin, None)
|
||||
@@ -85,6 +85,7 @@ class TestTradingDayCrons:
|
||||
(
|
||||
("schedule_dolt_earnings_cron", 2, 30),
|
||||
("schedule_sec_fundamentals_cron", 4, 0),
|
||||
("schedule_fundamentals_parity_cron", 5, 30),
|
||||
),
|
||||
)
|
||||
def test_shadow_imports_run_daily_at_expected_et_time(
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.scheduler import (
|
||||
_resume_tickers,
|
||||
_last_successful,
|
||||
_run_shadow_import,
|
||||
run_fundamentals_parity_report,
|
||||
configure_scheduler,
|
||||
get_job_runtime_snapshot,
|
||||
queue_backtest_options,
|
||||
@@ -112,6 +113,7 @@ class TestConfigureScheduler:
|
||||
"fundamental_collector",
|
||||
"dolt_earnings_import",
|
||||
"sec_fundamentals_import",
|
||||
"fundamentals_parity_report",
|
||||
"rr_scanner",
|
||||
"shadow_book",
|
||||
"ticker_universe_sync",
|
||||
@@ -145,6 +147,7 @@ class TestConfigureScheduler:
|
||||
"fundamental_collector",
|
||||
"dolt_earnings_import",
|
||||
"sec_fundamentals_import",
|
||||
"fundamentals_parity_report",
|
||||
"market_regime",
|
||||
"near_close_pipeline",
|
||||
"regime_monitor",
|
||||
@@ -243,3 +246,32 @@ class TestShadowImportJobs:
|
||||
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):
|
||||
return True
|
||||
|
||||
async def generated(db, report_dir):
|
||||
return (
|
||||
{
|
||||
"generated_at": "2026-07-23T10:30:00+00:00",
|
||||
"summary": {
|
||||
"universe_count": 511,
|
||||
"fundamental_score_material_changes": 12,
|
||||
},
|
||||
},
|
||||
{"json": "report.json", "csv": "report.csv"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.scheduler.async_session_factory", TestShadowImportJobs._session_factory)
|
||||
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
|
||||
monkeypatch.setattr(
|
||||
"app.scheduler.fundamentals_parity_service.generate_and_store", generated
|
||||
)
|
||||
|
||||
await run_fundamentals_parity_report()
|
||||
|
||||
runtime = get_job_runtime_snapshot("fundamentals_parity_report")
|
||||
assert runtime["status"] == "completed"
|
||||
assert runtime["message"] == "511 tickers · 12 material score changes"
|
||||
|
||||
Reference in New Issue
Block a user