refactor: dedupe scheduler logging/runtime, centralize SystemSetting access, fix rankings N+1
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 42s
Deploy / deploy (push) Successful in 27s

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:
2026-06-24 11:23:39 +02:00
parent f48d8705de
commit 437ceacfc1
11 changed files with 341 additions and 465 deletions
+129
View File
@@ -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