210 lines
7.7 KiB
Python
210 lines
7.7 KiB
Python
"""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)
|