diff --git a/app/routers/scores.py b/app/routers/scores.py index bb0b33f..3269921 100644 --- a/app/routers/scores.py +++ b/app/routers/scores.py @@ -54,7 +54,7 @@ async def read_score( _user=Depends(require_access), db: AsyncSession = Depends(get_db), ) -> APIEnvelope: - """Get composite + dimension scores for a symbol. Recomputes stale scores.""" + """Get the latest persisted composite + dimension scores for a symbol.""" result = await get_score(db, symbol) data = ScoreResponse( @@ -94,6 +94,7 @@ async def read_rankings( RankingEntry( symbol=r["symbol"], composite_score=r["composite_score"], + composite_stale=r.get("composite_stale", False), dimensions=[ DimensionScoreResponse(**d) for d in r["dimensions"] ], diff --git a/app/routers/trades.py b/app/routers/trades.py index 78c21d7..01cb3ba 100644 --- a/app/routers/trades.py +++ b/app/routers/trades.py @@ -97,7 +97,6 @@ async def get_ticker_trade_setups( db, symbol=symbol, live_recommendation=True, - recompute_scores=True, ) data = [] for row in rows: diff --git a/app/schemas/score.py b/app/schemas/score.py index 0f3bbda..d8faf3b 100644 --- a/app/schemas/score.py +++ b/app/schemas/score.py @@ -78,6 +78,7 @@ class RankingEntry(BaseModel): symbol: str composite_score: float + composite_stale: bool = False dimensions: list[DimensionScoreResponse] = [] diff --git a/app/schemas/trade_setup.py b/app/schemas/trade_setup.py index 109a763..604efd1 100644 --- a/app/schemas/trade_setup.py +++ b/app/schemas/trade_setup.py @@ -26,6 +26,14 @@ class RecommendationSummaryResponse(BaseModel): composite_score: float +class TradeSetupContextAsOfResponse(BaseModel): + setup_detected_at: datetime + score_computed_at: datetime | None = None + sentiment_at: datetime | None = None + price_date: date | None = None + price_updated_at: datetime | None = None + + class TradeSetupResponse(BaseModel): """A single trade setup detected by the R:R scanner.""" @@ -49,4 +57,5 @@ class TradeSetupResponse(BaseModel): evaluated_at: datetime | None = None current_price: float | None = None momentum_percentile: float | None = None + context_as_of: TradeSetupContextAsOfResponse | None = None recommendation_summary: RecommendationSummaryResponse | None = None diff --git a/app/services/rr_scanner_service.py b/app/services/rr_scanner_service.py index b7fe752..e4705a8 100644 --- a/app/services/rr_scanner_service.py +++ b/app/services/rr_scanner_service.py @@ -28,6 +28,7 @@ from app.models.trade_setup import TradeSetup from app.services.indicator_service import _extract_ohlcv, compute_atr from app.services.price_service import query_ohlcv from app.services.recommendation_service import ( + _risk_level_from_conflicts, build_recommendation_snapshot, enhance_trade_setup, get_recommendation_config, @@ -84,29 +85,6 @@ async def _get_latest_sentiment(db: AsyncSession, ticker_id: int) -> str | None: return row.classification if row else None -async def _refresh_score_context_for_symbols( - db: AsyncSession, - symbols: set[str], -) -> None: - """Refresh provider-free scores so live recommendation summaries match the page.""" - if not symbols: - return - - from app.services import scoring_service - - refreshed = False - for symbol in sorted(symbols): - try: - await scoring_service.compute_all_dimensions(db, symbol) - await scoring_service.compute_composite_score(db, symbol) - refreshed = True - except Exception: - logger.exception("Error refreshing live score context for %s", symbol) - - if refreshed: - await db.commit() - - async def _apply_live_recommendation_context( db: AsyncSession, setup_rows: list[tuple[TradeSetup, str]], @@ -122,10 +100,7 @@ async def _apply_live_recommendation_context( ticker_ids = {setup.ticker_id for setup, _ in setup_rows} setups_by_id = {setup.id: setup for setup, _ in setup_rows} - - directions_by_ticker: dict[int, set[str]] = {} - for setup, _ in setup_rows: - directions_by_ticker.setdefault(setup.ticker_id, set()).add(setup.direction.lower()) + directions_by_ticker = await _latest_available_directions_by_ticker(db, ticker_ids) dim_result = await db.execute( select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids)) @@ -166,10 +141,13 @@ async def _apply_live_recommendation_context( comp = composites.get(ticker_id) if comp is not None: live_row["composite_score"] = float(comp.score) + live_row["context_as_of"]["score_computed_at"] = comp.computed_at dimension_scores = dims_by_ticker.get(ticker_id) + sentiment = sentiments.get(ticker_id) + if sentiment is not None: + live_row["context_as_of"]["sentiment_at"] = sentiment.timestamp if dimension_scores: - sentiment = sentiments.get(ticker_id) snapshot = build_recommendation_snapshot( dimension_scores=dimension_scores, sentiment_classification=sentiment.classification if sentiment else None, @@ -181,13 +159,59 @@ async def _apply_live_recommendation_context( live_row["confidence_score"] = round(float(snapshot[confidence_key]), 2) live_row["recommended_action"] = snapshot["action"] live_row["reasoning"] = snapshot["reasoning"] - live_row["risk_level"] = snapshot["risk_level"] + setup_conflicts = _setup_specific_conflicts(live_row.get("conflict_flags", [])) + live_conflicts = [str(item) for item in snapshot["conflicts"]] + live_row["conflict_flags"] = live_conflicts + setup_conflicts + live_row["risk_level"] = _risk_level_from_conflicts(live_row["conflict_flags"]) live_rows.append(live_row) return live_rows +def _setup_specific_conflicts(conflicts: list[str]) -> list[str]: + signal_prefixes = ( + "sentiment-technical:", + "sentiment-momentum:", + "momentum-technical:", + "fundamental-technical:", + ) + return [ + str(conflict) + for conflict in conflicts + if not str(conflict).startswith(signal_prefixes) + ] + + +async def _latest_available_directions_by_ticker( + db: AsyncSession, + ticker_ids: set[int], +) -> dict[int, set[str]]: + if not ticker_ids: + return {} + + result = await db.execute( + select(TradeSetup) + .where(TradeSetup.ticker_id.in_(ticker_ids)) + .order_by( + TradeSetup.ticker_id, + TradeSetup.direction, + TradeSetup.detected_at.desc(), + TradeSetup.id.desc(), + ) + ) + latest_by_key: set[tuple[int, str]] = set() + directions: dict[int, set[str]] = {} + for setup in result.scalars().all(): + direction = setup.direction.lower() + key = (setup.ticker_id, direction) + if key in latest_by_key: + continue + latest_by_key.add(key) + directions.setdefault(setup.ticker_id, set()).add(direction) + return directions + + def _json_default(value): if isinstance(value, (datetime, date)): return value.isoformat() @@ -550,7 +574,6 @@ async def get_trade_setups( recommended_action: str | None = None, symbol: str | None = None, live_recommendation: bool = False, - recompute_scores: bool = False, ) -> list[dict]: """Get latest stored trade setups, optionally filtered.""" stmt = ( @@ -589,12 +612,7 @@ async def get_trade_setups( reverse=True, ) - if recompute_scores: - await _refresh_score_context_for_symbols( - db, {ticker_symbol for _, ticker_symbol in latest_rows} - ) - - prices = await _latest_closes(db, {s.ticker_id for s, _ in latest_rows}) + prices = await _latest_price_context(db, {s.ticker_id for s, _ in latest_rows}) rows_out = [ _trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id)) for setup, ticker_symbol in latest_rows @@ -623,8 +641,8 @@ async def get_trade_setups( return rows_out -async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]: - """Most recent close per ticker — used to judge a setup's current relevance.""" +async def _latest_price_context(db: AsyncSession, ticker_ids: set[int]) -> dict[int, dict]: + """Most recent daily OHLCV row per ticker for live price context.""" if not ticker_ids: return {} latest = ( @@ -633,7 +651,12 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl .group_by(OHLCVRecord.ticker_id) .subquery() ) - stmt = select(OHLCVRecord.ticker_id, OHLCVRecord.close).join( + stmt = select( + OHLCVRecord.ticker_id, + OHLCVRecord.close, + OHLCVRecord.date, + OHLCVRecord.created_at, + ).join( latest, and_( OHLCVRecord.ticker_id == latest.c.ticker_id, @@ -641,7 +664,23 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl ), ) result = await db.execute(stmt) - return {tid: float(close) for tid, close in result.all()} + return { + tid: { + "current_price": float(close), + "price_date": price_date, + "price_updated_at": created_at, + } + for tid, close, price_date, created_at in result.all() + } + + +async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]: + """Most recent close per ticker, kept for callers that only need price.""" + price_context = await _latest_price_context(db, ticker_ids) + return { + ticker_id: context["current_price"] + for ticker_id, context in price_context.items() + } async def get_trade_setup_history( @@ -658,16 +697,28 @@ async def get_trade_setup_history( result = await db.execute(stmt) rows = result.all() - prices = await _latest_closes(db, {s.ticker_id for s, _ in rows}) + prices = await _latest_price_context(db, {s.ticker_id for s, _ in rows}) return [ _trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id)) for setup, ticker_symbol in rows ] -def _trade_setup_to_dict(setup: TradeSetup, symbol: str, current_price: float | None = None) -> dict: +def _trade_setup_to_dict(setup: TradeSetup, symbol: str, price_context: dict | None = None) -> dict: targets: list[dict] = [] conflicts: list[str] = [] + current_price = ( + float(price_context["current_price"]) + if price_context and price_context.get("current_price") is not None + else None + ) + context_as_of = { + "setup_detected_at": setup.detected_at, + "score_computed_at": None, + "sentiment_at": None, + "price_date": price_context.get("price_date") if price_context else None, + "price_updated_at": price_context.get("price_updated_at") if price_context else None, + } if setup.targets_json: try: @@ -706,4 +757,5 @@ def _trade_setup_to_dict(setup: TradeSetup, symbol: str, current_price: float | "evaluated_at": setup.evaluated_at, "current_price": current_price, "momentum_percentile": setup.momentum_percentile, + "context_as_of": context_as_of, } diff --git a/app/services/scoring_service.py b/app/services/scoring_service.py index 5732a1e..f40d24d 100644 --- a/app/services/scoring_service.py +++ b/app/services/scoring_service.py @@ -2,8 +2,8 @@ Computes dimension scores (technical, sr_quality, sentiment, fundamental, momentum) each 0-100, composite score as weighted average of available -dimensions with re-normalized weights, staleness marking/recomputation -on demand, and weight update triggers full recomputation. +dimensions with re-normalized weights, staleness marking, explicit refresh +paths, and weight update triggers full recomputation. """ from __future__ import annotations @@ -765,73 +765,37 @@ async def compute_composite_score( async def get_score( db: AsyncSession, symbol: str ) -> dict: - """Get composite + all dimension scores for a ticker. + """Read composite + dimension scores for a ticker without recomputing. - Recomputes stale dimensions on demand, then recomputes composite. - Returns a dict suitable for ScoreResponse, including dimension breakdowns - and composite breakdown with re-normalization info. + GET endpoints use this path, so it must not mutate persisted score context. + Scheduled/manual write paths are responsible for refreshing scores. """ ticker = await _get_ticker(db, symbol) weights = await _get_weights(db) - # Check for stale dimension scores and recompute them - result = await db.execute( - select(DimensionScore).where(DimensionScore.ticker_id == ticker.id) - ) - dim_scores = {ds.dimension: ds for ds in result.scalars().all()} - - for dim in DIMENSIONS: - ds = dim_scores.get(dim) - if ds is None or ds.is_stale: - await compute_dimension_score(db, symbol, dim) - - # Check composite staleness - comp_result = await db.execute( - select(CompositeScore).where(CompositeScore.ticker_id == ticker.id) - ) - comp = comp_result.scalar_one_or_none() - - if comp is None or comp.is_stale: - await compute_composite_score(db, symbol, weights) - - await db.commit() - - # Re-fetch everything fresh result = await db.execute( select(DimensionScore).where(DimensionScore.ticker_id == ticker.id) ) dim_scores_list = list(result.scalars().all()) + dim_scores = {ds.dimension: ds for ds in dim_scores_list} comp_result = await db.execute( select(CompositeScore).where(CompositeScore.ticker_id == ticker.id) ) comp = comp_result.scalar_one_or_none() - # Compute breakdowns for each dimension by calling the dimension computers - breakdowns: dict[str, dict | None] = {} - for dim in DIMENSIONS: - try: - raw_result = await _DIMENSION_COMPUTERS[dim](db, symbol) - if isinstance(raw_result, tuple) and len(raw_result) == 2: - breakdowns[dim] = raw_result[1] - else: - breakdowns[dim] = None - except Exception: - breakdowns[dim] = None - - # Build dimension entries with breakdowns dimensions = [] missing = [] available_dims: list[str] = [] for dim in DIMENSIONS: - found = next((ds for ds in dim_scores_list if ds.dimension == dim), None) + found = dim_scores.get(dim) if found is not None and not found.is_stale and found.score is not None: dimensions.append({ "dimension": found.dimension, "score": found.score, "is_stale": found.is_stale, "computed_at": found.computed_at, - "breakdown": breakdowns.get(dim), + "breakdown": None, }) w = weights.get(dim, 0.0) if w > 0: @@ -845,7 +809,7 @@ async def get_score( "score": found.score, "is_stale": found.is_stale, "computed_at": found.computed_at, - "breakdown": breakdowns.get(dim), + "breakdown": None, }) # Build composite breakdown: the non-sentiment base (re-normalized weighted @@ -925,31 +889,13 @@ async def get_rankings(db: AsyncSession) -> dict: dims[ds.ticker_id][ds.dimension] = ds return comps, dims - # Two bulk reads instead of ~4 queries per ticker. comps, dims_by_ticker = await _load_scores() - # Lazily recompute any stale/missing scores (kept fresh by the daily scan; - # this self-heals tickers that aged out between scans), committing once. - recomputed = False - for ticker in tickers: - comp = comps.get(ticker.id) - if comp is None or comp.is_stale: - dim_scores = dims_by_ticker.get(ticker.id, {}) - for dim in DIMENSIONS: - ds = dim_scores.get(dim) - if ds is None or ds.is_stale: - await compute_dimension_score(db, ticker.symbol, dim) - await compute_composite_score(db, ticker.symbol, weights) - recomputed = True - - if recomputed: - await db.commit() - comps, dims_by_ticker = await _load_scores() - rankings = [ { "symbol": ticker.symbol, "composite_score": comp.score, + "composite_stale": comp.is_stale, "dimensions": [ { "dimension": ds.dimension, diff --git a/frontend/src/components/ui/ScoreCard.tsx b/frontend/src/components/ui/ScoreCard.tsx index f63a31c..bb64b3f 100644 --- a/frontend/src/components/ui/ScoreCard.tsx +++ b/frontend/src/components/ui/ScoreCard.tsx @@ -5,7 +5,7 @@ import type { DimensionScoreDetail, CompositeBreakdown } from '../../lib/types'; interface ScoreCardProps { compositeScore: number | null; dimensions: DimensionScoreDetail[]; - compositeBreakdown?: CompositeBreakdown; + compositeBreakdown?: CompositeBreakdown | null; /** Hide the composite ring/header when the composite is shown elsewhere * (e.g. the Standing matrix) and this card only carries the dimension detail. */ showComposite?: boolean; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 542260b..6146f89 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -96,7 +96,7 @@ export interface ScoreResponse { dimensions: DimensionScoreDetail[]; missing_dimensions: string[]; computed_at: string | null; - composite_breakdown?: CompositeBreakdown; + composite_breakdown?: CompositeBreakdown | null; } export interface DimensionScoreDetail { @@ -104,12 +104,13 @@ export interface DimensionScoreDetail { score: number; is_stale: boolean; computed_at: string | null; - breakdown?: ScoreBreakdown; + breakdown?: ScoreBreakdown | null; } export interface RankingEntry { symbol: string; composite_score: number; + composite_stale: boolean; dimensions: DimensionScoreDetail[]; } @@ -140,9 +141,18 @@ export interface TradeSetup { evaluated_at: string | null; current_price: number | null; momentum_percentile?: number | null; + context_as_of?: TradeSetupContextAsOf | null; recommendation_summary?: RecommendationSummary; } +export interface TradeSetupContextAsOf { + setup_detected_at: string; + score_computed_at: string | null; + sentiment_at: string | null; + price_date: string | null; + price_updated_at: string | null; +} + // Performance / outcome statistics export interface OutcomeBucketStats { total: number; diff --git a/tests/unit/test_rr_scanner_preservation.py b/tests/unit/test_rr_scanner_preservation.py index 520d798..158223b 100644 --- a/tests/unit/test_rr_scanner_preservation.py +++ b/tests/unit/test_rr_scanner_preservation.py @@ -11,13 +11,17 @@ Zero-candidate and single-candidate scenarios must produce identical results. from __future__ import annotations +import json from datetime import date, datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch import pytest from hypothesis import given, settings, HealthCheck, strategies as st +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.ohlcv import OHLCVRecord +from app.models.signal_context_snapshot import SignalContextSnapshot from app.models.sr_level import SRLevel from app.models.ticker import Ticker from app.models.trade_setup import TradeSetup @@ -568,3 +572,253 @@ async def test_live_recommendation_filters_apply_to_live_values( live_recommendation=True, ) assert rows == [] + + +async def _seed_two_direction_setup(db_session: AsyncSession) -> None: + current = datetime(2026, 7, 3, tzinfo=timezone.utc) + ticker = Ticker(symbol="BOTH") + db_session.add(ticker) + await db_session.flush() + + db_session.add_all([ + TradeSetup( + ticker_id=ticker.id, + direction="long", + entry_price=100.0, + stop_loss=95.0, + target=112.0, + rr_ratio=2.4, + composite_score=30.0, + detected_at=current, + confidence_score=25.0, + recommended_action="NEUTRAL", + risk_level="Low", + ), + TradeSetup( + ticker_id=ticker.id, + direction="short", + entry_price=100.0, + stop_loss=105.0, + target=88.0, + rr_ratio=2.4, + composite_score=30.0, + detected_at=current, + confidence_score=90.0, + recommended_action="SHORT_HIGH", + risk_level="Low", + ), + DimensionScore( + ticker_id=ticker.id, + dimension="technical", + score=10.0, + is_stale=False, + computed_at=current, + ), + DimensionScore( + ticker_id=ticker.id, + dimension="momentum", + score=10.0, + is_stale=False, + computed_at=current, + ), + DimensionScore( + ticker_id=ticker.id, + dimension="fundamental", + score=10.0, + is_stale=False, + computed_at=current, + ), + CompositeScore( + ticker_id=ticker.id, + score=30.0, + is_stale=False, + weights_json="{}", + computed_at=current, + ), + SentimentScore( + ticker_id=ticker.id, + classification="bearish", + confidence=90, + source="test", + timestamp=current, + reasoning="", + citations_json="[]", + ), + ]) + await db_session.flush() + + +@pytest.mark.asyncio +async def test_live_recommendation_action_independent_of_direction_filter( + db_session: AsyncSession, +): + await _seed_two_direction_setup(db_session) + + all_rows = await get_trade_setups( + db_session, + symbol="BOTH", + live_recommendation=True, + ) + filtered_rows = await get_trade_setups( + db_session, + symbol="BOTH", + direction="long", + live_recommendation=True, + ) + + long_from_all = next(row for row in all_rows if row["direction"] == "long") + assert len(filtered_rows) == 1 + assert long_from_all["recommended_action"] == "SHORT_HIGH" + assert filtered_rows[0]["recommended_action"] == "SHORT_HIGH" + + +@pytest.mark.asyncio +async def test_live_overlay_preserves_setup_specific_risk_and_context( + db_session: AsyncSession, +): + current = datetime(2026, 7, 3, tzinfo=timezone.utc) + ticker = Ticker(symbol="RISK") + db_session.add(ticker) + await db_session.flush() + db_session.add_all([ + TradeSetup( + ticker_id=ticker.id, + direction="long", + entry_price=100.0, + stop_loss=95.0, + target=112.0, + rr_ratio=2.4, + composite_score=50.0, + detected_at=current, + confidence_score=50.0, + recommended_action="NEUTRAL", + risk_level="Medium", + conflict_flags_json=json.dumps([ + "target-availability: Fewer than 3 valid S/R targets available" + ]), + ), + DimensionScore( + ticker_id=ticker.id, + dimension="technical", + score=50.0, + is_stale=False, + computed_at=current, + ), + DimensionScore( + ticker_id=ticker.id, + dimension="momentum", + score=50.0, + is_stale=False, + computed_at=current, + ), + CompositeScore( + ticker_id=ticker.id, + score=50.0, + is_stale=False, + weights_json="{}", + computed_at=current, + ), + SentimentScore( + ticker_id=ticker.id, + classification="neutral", + confidence=50, + source="test", + timestamp=current, + reasoning="", + citations_json="[]", + ), + OHLCVRecord( + ticker_id=ticker.id, + date=date(2026, 7, 3), + open=101.0, + high=102.0, + low=100.0, + close=101.0, + volume=1000, + created_at=current, + ), + ]) + await db_session.flush() + + rows = await get_trade_setups( + db_session, + symbol="RISK", + live_recommendation=True, + ) + + assert len(rows) == 1 + row = rows[0] + assert row["risk_level"] == "Medium" + assert row["conflict_flags"] == [ + "target-availability: Fewer than 3 valid S/R targets available" + ] + assert row["current_price"] == pytest.approx(101.0) + assert row["context_as_of"]["score_computed_at"] == current + assert row["context_as_of"]["sentiment_at"] == current + assert row["context_as_of"]["price_date"] == date(2026, 7, 3) + assert row["context_as_of"]["price_updated_at"] == current + + +@pytest.mark.asyncio +async def test_live_trade_setup_read_does_not_recompute_scores(db_session: AsyncSession): + await _seed_stale_setup_with_current_scores(db_session) + + with patch( + "app.services.scoring_service.compute_all_dimensions", + new=AsyncMock(side_effect=AssertionError("GET must not recompute dimensions")), + ), patch( + "app.services.scoring_service.compute_composite_score", + new=AsyncMock(side_effect=AssertionError("GET must not recompute composite")), + ): + rows = await get_trade_setups( + db_session, + symbol="TTWO", + live_recommendation=True, + ) + + assert len(rows) == 1 + + +@pytest.mark.asyncio +async def test_intraday_price_update_changes_live_price_without_new_signal_rows( + db_session: AsyncSession, +): + current = datetime(2026, 7, 3, tzinfo=timezone.utc) + ticker = Ticker(symbol="LIVEP") + db_session.add(ticker) + await db_session.flush() + setup = TradeSetup( + ticker_id=ticker.id, + direction="long", + entry_price=100.0, + stop_loss=95.0, + target=112.0, + rr_ratio=2.4, + composite_score=50.0, + detected_at=current, + ) + price = OHLCVRecord( + ticker_id=ticker.id, + date=date(2026, 7, 3), + open=100.0, + high=101.0, + low=99.0, + close=100.0, + volume=1000, + created_at=current, + ) + db_session.add_all([setup, price]) + await db_session.flush() + + rows = await get_trade_setups(db_session, symbol="LIVEP", live_recommendation=True) + assert rows[0]["current_price"] == pytest.approx(100.0) + + price.close = 102.0 + await db_session.flush() + + rows = await get_trade_setups(db_session, symbol="LIVEP", live_recommendation=True) + assert rows[0]["current_price"] == pytest.approx(102.0) + setup_count = await db_session.scalar(select(func.count()).select_from(TradeSetup)) + snapshot_count = await db_session.scalar(select(func.count()).select_from(SignalContextSnapshot)) + assert setup_count == 1 + assert snapshot_count == 0 diff --git a/tests/unit/test_scoring_service_get_score.py b/tests/unit/test_scoring_service_get_score.py index 4484d02..506695c 100644 --- a/tests/unit/test_scoring_service_get_score.py +++ b/tests/unit/test_scoring_service_get_score.py @@ -1,24 +1,24 @@ -"""Unit tests for get_score composite breakdown and dimension breakdown wiring.""" +"""Unit tests for read-only get_score composite breakdown wiring.""" from __future__ import annotations -from datetime import date -from types import SimpleNamespace +from datetime import datetime, timezone from unittest.mock import AsyncMock, patch import pytest 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_score, _DIMENSION_COMPUTERS +from app.services.scoring_service import get_score TEST_DATABASE_URL = "sqlite+aiosqlite://" @pytest.fixture async def fresh_db(): - """Provide a non-transactional session so get_score can commit.""" + """Provide a non-transactional session for persisted score reads.""" engine = create_async_engine(TEST_DATABASE_URL, echo=False) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) @@ -30,176 +30,101 @@ async def fresh_db(): await engine.dispose() -def _make_ohlcv_records(n: int, base_close: float = 100.0) -> list: - """Create n mock OHLCV records with realistic price data.""" - records = [] - for i in range(n): - price = base_close + (i * 0.5) - records.append( - SimpleNamespace( - date=date(2024, 1, 1), - open=price - 0.5, - high=price + 1.0, - low=price - 1.0, - close=price, - volume=1000000, - ) - ) - return records - - -def _mock_none_computer(): - """Return an AsyncMock that returns (None, None) — simulates missing dimension data.""" - return AsyncMock(return_value=(None, None)) - - -def _mock_score_computer(score: float, breakdown: dict | None = None): - """Return an AsyncMock that returns a fixed (score, breakdown) tuple.""" - bd = breakdown or { - "sub_scores": [{"name": "mock", "score": score, "weight": 1.0, "raw_value": score, "description": "mock"}], - "formula": "mock formula", - "unavailable": [], - } - return AsyncMock(return_value=(score, bd)) - - async def _seed_ticker(session: AsyncSession, symbol: str = "AAPL") -> Ticker: - """Insert a ticker row and return it.""" ticker = Ticker(symbol=symbol) session.add(ticker) await session.commit() return ticker +async def _seed_scores(session: AsyncSession, ticker: Ticker, *, stale: bool = False) -> None: + now = datetime(2026, 7, 3, tzinfo=timezone.utc) + session.add_all([ + DimensionScore( + ticker_id=ticker.id, + dimension="technical", + score=70.0, + is_stale=stale, + computed_at=now, + ), + DimensionScore( + ticker_id=ticker.id, + dimension="momentum", + score=60.0, + is_stale=False, + computed_at=now, + ), + CompositeScore( + ticker_id=ticker.id, + score=66.0, + is_stale=stale, + weights_json="{}", + computed_at=now, + ), + ]) + await session.commit() + + @pytest.mark.asyncio -async def test_get_score_returns_composite_breakdown(fresh_db): - """get_score should include a composite_breakdown dict with weights and re-normalization info.""" - await _seed_ticker(fresh_db, "AAPL") - - original = dict(_DIMENSION_COMPUTERS) - try: - _DIMENSION_COMPUTERS["technical"] = _mock_score_computer(70.0) - _DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(60.0) - _DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer() - _DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer() - _DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer() +async def test_get_score_returns_composite_breakdown_without_recomputing(fresh_db): + ticker = await _seed_ticker(fresh_db, "AAPL") + await _seed_scores(fresh_db, ticker) + with patch( + "app.services.scoring_service.compute_dimension_score", + new=AsyncMock(side_effect=AssertionError("GET must not recompute dimensions")), + ), patch( + "app.services.scoring_service.compute_composite_score", + new=AsyncMock(side_effect=AssertionError("GET must not recompute composite")), + ): result = await get_score(fresh_db, "AAPL") - finally: - _DIMENSION_COMPUTERS.update(original) - assert "composite_breakdown" in result + assert result["composite_score"] == 66.0 cb = result["composite_breakdown"] assert cb is not None - assert "weights" in cb - assert "available_dimensions" in cb - assert "missing_dimensions" in cb - assert "renormalized_weights" in cb - assert "formula" in cb - - -@pytest.mark.asyncio -async def test_get_score_composite_breakdown_has_correct_available_missing(fresh_db): - """Composite breakdown should correctly list available and missing dimensions.""" - await _seed_ticker(fresh_db, "AAPL") - - original = dict(_DIMENSION_COMPUTERS) - try: - _DIMENSION_COMPUTERS["technical"] = _mock_score_computer(70.0) - _DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(60.0) - _DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer() - _DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer() - _DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer() - - result = await get_score(fresh_db, "AAPL") - finally: - _DIMENSION_COMPUTERS.update(original) - - cb = result["composite_breakdown"] assert "technical" in cb["available_dimensions"] assert "momentum" in cb["available_dimensions"] assert "sentiment" in cb["missing_dimensions"] assert "fundamental" in cb["missing_dimensions"] assert "sr_quality" in cb["missing_dimensions"] - - -@pytest.mark.asyncio -async def test_get_score_renormalized_weights_sum_to_one(fresh_db): - """Re-normalized weights should sum to 1.0 when at least one dimension is available.""" - await _seed_ticker(fresh_db, "AAPL") - - original = dict(_DIMENSION_COMPUTERS) - try: - _DIMENSION_COMPUTERS["technical"] = _mock_score_computer(70.0) - _DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(60.0) - _DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer() - _DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer() - _DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer() - - result = await get_score(fresh_db, "AAPL") - finally: - _DIMENSION_COMPUTERS.update(original) - - cb = result["composite_breakdown"] assert cb["renormalized_weights"] - total = sum(cb["renormalized_weights"].values()) - assert abs(total - 1.0) < 1e-9 + assert abs(sum(cb["renormalized_weights"].values()) - 1.0) < 1e-9 @pytest.mark.asyncio -async def test_get_score_dimensions_include_breakdowns(fresh_db): - """Each available dimension entry should include a breakdown dict.""" - await _seed_ticker(fresh_db, "AAPL") +async def test_get_score_dimensions_do_not_recompute_breakdowns(fresh_db): + ticker = await _seed_ticker(fresh_db, "AAPL") + await _seed_scores(fresh_db, ticker) - tech_breakdown = { - "sub_scores": [ - {"name": "ADX", "score": 72.0, "weight": 0.4, "raw_value": 72.0, "description": "ADX value"}, - {"name": "EMA", "score": 65.0, "weight": 0.3, "raw_value": 1.5, "description": "EMA diff"}, - {"name": "RSI", "score": 62.0, "weight": 0.3, "raw_value": 62.0, "description": "RSI value"}, - ], - "formula": "Weighted average: 0.4*ADX + 0.3*EMA + 0.3*RSI", - "unavailable": [], - } - - original = dict(_DIMENSION_COMPUTERS) - try: - _DIMENSION_COMPUTERS["technical"] = _mock_score_computer(68.2, tech_breakdown) - _DIMENSION_COMPUTERS["momentum"] = _mock_score_computer(55.0) - _DIMENSION_COMPUTERS["sentiment"] = _mock_none_computer() - _DIMENSION_COMPUTERS["fundamental"] = _mock_none_computer() - _DIMENSION_COMPUTERS["sr_quality"] = _mock_none_computer() - - result = await get_score(fresh_db, "AAPL") - finally: - _DIMENSION_COMPUTERS.update(original) + result = await get_score(fresh_db, "AAPL") tech_dim = next((d for d in result["dimensions"] if d["dimension"] == "technical"), None) assert tech_dim is not None - assert "breakdown" in tech_dim - assert tech_dim["breakdown"] is not None - assert len(tech_dim["breakdown"]["sub_scores"]) == 3 - names = [s["name"] for s in tech_dim["breakdown"]["sub_scores"]] - assert "ADX" in names - assert "EMA" in names - assert "RSI" in names + assert tech_dim["breakdown"] is None @pytest.mark.asyncio async def test_get_score_all_dimensions_missing(fresh_db): - """When all dimensions return None, composite_breakdown should list all as missing.""" await _seed_ticker(fresh_db, "AAPL") - original = dict(_DIMENSION_COMPUTERS) - try: - for dim in _DIMENSION_COMPUTERS: - _DIMENSION_COMPUTERS[dim] = _mock_none_computer() - - result = await get_score(fresh_db, "AAPL") - finally: - _DIMENSION_COMPUTERS.update(original) + result = await get_score(fresh_db, "AAPL") cb = result["composite_breakdown"] assert cb["available_dimensions"] == [] assert len(cb["missing_dimensions"]) == 5 assert cb["renormalized_weights"] == {} assert result["composite_score"] is None + + +@pytest.mark.asyncio +async def test_get_score_reports_stale_without_refreshing(fresh_db): + ticker = await _seed_ticker(fresh_db, "AAPL") + await _seed_scores(fresh_db, ticker, stale=True) + + result = await get_score(fresh_db, "AAPL") + + assert result["composite_stale"] is True + assert "technical" in result["missing_dimensions"] + tech_dim = next((d for d in result["dimensions"] if d["dimension"] == "technical"), None) + assert tech_dim is not None + assert tech_dim["is_stale"] is True diff --git a/tests/unit/test_scoring_service_rankings.py b/tests/unit/test_scoring_service_rankings.py index 1d78137..34cc685 100644 --- a/tests/unit/test_scoring_service_rankings.py +++ b/tests/unit/test_scoring_service_rankings.py @@ -1,5 +1,4 @@ -"""Unit tests for get_rankings: bulk-load fast path, sorting, exclusion, and -lazy recompute of stale scores.""" +"""Unit tests for read-only get_rankings: bulk-load, sorting, and staleness.""" from __future__ import annotations @@ -7,7 +6,6 @@ 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 @@ -20,7 +18,7 @@ TEST_DATABASE_URL = "sqlite+aiosqlite://" @pytest.fixture async def fresh_db(): - """Non-transactional session so get_rankings can commit recomputes.""" + """Non-transactional session for persisted ranking reads.""" engine = create_async_engine(TEST_DATABASE_URL, echo=False) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) @@ -84,46 +82,34 @@ async def test_fast_path_sorts_and_does_not_recompute(fresh_db: AsyncSession): @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.""" + """A ticker without a persisted composite is omitted from rankings.""" fresh = await _seed_ticker(fresh_db, "OK") - await _seed_ticker(fresh_db, "NONE") # no composite; recompute can't make one + await _seed_ticker(fresh_db, "NONE") 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)), \ + new=AsyncMock(side_effect=AssertionError("should not recompute"))), \ patch("app.services.scoring_service.compute_composite_score", - new=AsyncMock(return_value=(None, ["technical"]))): + new=AsyncMock(side_effect=AssertionError("should not recompute"))): 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.""" +async def test_stale_composite_is_reported_without_recompute(fresh_db: AsyncSession): + """A stale composite appears with its stale flag and is not recomputed.""" 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)), \ + new=AsyncMock(side_effect=AssertionError("should not recompute"))), \ patch("app.services.scoring_service.compute_composite_score", - new=AsyncMock(side_effect=_fake_recompute)) as comp_mock: + new=AsyncMock(side_effect=AssertionError("should not recompute"))): 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 + assert result["rankings"][0]["composite_score"] == 10.0 + assert result["rankings"][0]["composite_stale"] is True