294 lines
9.3 KiB
Python
294 lines
9.3 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.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"]
|