Make live signal reads non-mutating
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user