refactor: dedupe scheduler logging/runtime, centralize SystemSetting access, fix rankings N+1
Behavior-preserving cleanup (345 tests pass, ruff clean):
- scheduler: replace 62 inline logger.x(json.dumps({...})) calls with a
_log_event helper, and collapse 11 identical _job_runtime dicts into an
_idle_runtime() factory over _JOB_NAMES.
- settings: add app/services/settings_store.py (get_setting/get_value/get_map/
upsert_setting) and route ~13 hand-rolled SystemSetting queries + two
identical _settings_map helpers through it.
- scoring.get_rankings: collapse the per-ticker N+1 (3-4 queries + a commit each)
into 2 bulk reads + a single conditional commit; drop the redundant re-fetch.
Lazy recompute-on-read is preserved. Adds first tests for get_rankings.
Net ~ -245 lines across the touched modules.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
"""Unit tests for get_rankings: bulk-load fast path, sorting, exclusion, and
|
||||
lazy recompute of stale scores."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
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.score import CompositeScore, DimensionScore
|
||||
from app.models.ticker import Ticker
|
||||
from app.services.scoring_service import get_rankings
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite://"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def fresh_db():
|
||||
"""Non-transactional session so get_rankings can commit recomputes."""
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _seed_ticker(session: AsyncSession, symbol: str) -> Ticker:
|
||||
ticker = Ticker(symbol=symbol)
|
||||
session.add(ticker)
|
||||
await session.commit()
|
||||
await session.refresh(ticker)
|
||||
return ticker
|
||||
|
||||
|
||||
def _composite(ticker_id: int, score: float, *, stale: bool = False) -> CompositeScore:
|
||||
return CompositeScore(
|
||||
ticker_id=ticker_id, score=score, is_stale=stale,
|
||||
weights_json="{}", computed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _dimension(ticker_id: int, dimension: str, score: float) -> DimensionScore:
|
||||
return DimensionScore(
|
||||
ticker_id=ticker_id, dimension=dimension, score=score,
|
||||
is_stale=False, computed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fast_path_sorts_and_does_not_recompute(fresh_db: AsyncSession):
|
||||
"""All composites fresh: result is sorted desc and no recompute is triggered
|
||||
(the common steady-state path after the daily scan)."""
|
||||
low = await _seed_ticker(fresh_db, "LOW")
|
||||
high = await _seed_ticker(fresh_db, "HIGH")
|
||||
|
||||
fresh_db.add_all([
|
||||
_composite(low.id, 40.0),
|
||||
_composite(high.id, 90.0),
|
||||
_dimension(high.id, "technical", 88.0),
|
||||
_dimension(low.id, "technical", 42.0),
|
||||
])
|
||||
await fresh_db.commit()
|
||||
|
||||
# If the fast path tries to recompute, these blow up.
|
||||
with patch("app.services.scoring_service.compute_dimension_score",
|
||||
new=AsyncMock(side_effect=AssertionError("should not recompute"))), \
|
||||
patch("app.services.scoring_service.compute_composite_score",
|
||||
new=AsyncMock(side_effect=AssertionError("should not recompute"))):
|
||||
result = await get_rankings(fresh_db)
|
||||
|
||||
symbols = [r["symbol"] for r in result["rankings"]]
|
||||
assert symbols == ["HIGH", "LOW"] # sorted desc
|
||||
assert result["rankings"][0]["composite_score"] == 90.0
|
||||
assert result["rankings"][0]["dimensions"][0]["dimension"] == "technical"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ticker_without_computable_composite_is_excluded(fresh_db: AsyncSession):
|
||||
"""A ticker whose composite can't be computed (recompute yields no row) is
|
||||
omitted from the rankings rather than appearing with a null score."""
|
||||
fresh = await _seed_ticker(fresh_db, "OK")
|
||||
await _seed_ticker(fresh_db, "NONE") # no composite; recompute can't make one
|
||||
fresh_db.add_all([_composite(fresh.id, 50.0), _dimension(fresh.id, "technical", 50.0)])
|
||||
await fresh_db.commit()
|
||||
|
||||
# Recompute is a no-op that produces no composite row for NONE.
|
||||
with patch("app.services.scoring_service.compute_dimension_score",
|
||||
new=AsyncMock(return_value=None)), \
|
||||
patch("app.services.scoring_service.compute_composite_score",
|
||||
new=AsyncMock(return_value=(None, ["technical"]))):
|
||||
result = await get_rankings(fresh_db)
|
||||
|
||||
assert [r["symbol"] for r in result["rankings"]] == ["OK"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_composite_is_recomputed(fresh_db: AsyncSession):
|
||||
"""A stale composite triggers a recompute and then appears in the rankings."""
|
||||
ticker = await _seed_ticker(fresh_db, "STALE")
|
||||
fresh_db.add(_composite(ticker.id, 10.0, stale=True))
|
||||
await fresh_db.commit()
|
||||
|
||||
async def _fake_recompute(db, symbol, weights=None):
|
||||
# Mirror the real upsert: refresh the existing row in place.
|
||||
existing = (await db.execute(
|
||||
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
|
||||
)).scalar_one()
|
||||
existing.score = 77.0
|
||||
existing.is_stale = False
|
||||
return 77.0, []
|
||||
|
||||
# Dimension recompute is a no-op; composite recompute refreshes the score.
|
||||
with patch("app.services.scoring_service.compute_dimension_score",
|
||||
new=AsyncMock(return_value=55.0)), \
|
||||
patch("app.services.scoring_service.compute_composite_score",
|
||||
new=AsyncMock(side_effect=_fake_recompute)) as comp_mock:
|
||||
result = await get_rankings(fresh_db)
|
||||
|
||||
comp_mock.assert_awaited() # recompute path was taken
|
||||
assert [r["symbol"] for r in result["rankings"]] == ["STALE"]
|
||||
assert result["rankings"][0]["composite_score"] == 77.0 # reflects the recompute
|
||||
Reference in New Issue
Block a user