Make live signal reads non-mutating
Deploy / lint (push) Successful in 6s
Deploy / test (push) Failing after 48s
Deploy / deploy (push) Has been skipped

This commit is contained in:
2026-07-03 10:09:46 +02:00
parent ac51e23949
commit 8c36cfcef1
11 changed files with 460 additions and 277 deletions
+2 -1
View File
@@ -54,7 +54,7 @@ async def read_score(
_user=Depends(require_access), _user=Depends(require_access),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> APIEnvelope: ) -> 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) result = await get_score(db, symbol)
data = ScoreResponse( data = ScoreResponse(
@@ -94,6 +94,7 @@ async def read_rankings(
RankingEntry( RankingEntry(
symbol=r["symbol"], symbol=r["symbol"],
composite_score=r["composite_score"], composite_score=r["composite_score"],
composite_stale=r.get("composite_stale", False),
dimensions=[ dimensions=[
DimensionScoreResponse(**d) for d in r["dimensions"] DimensionScoreResponse(**d) for d in r["dimensions"]
], ],
-1
View File
@@ -97,7 +97,6 @@ async def get_ticker_trade_setups(
db, db,
symbol=symbol, symbol=symbol,
live_recommendation=True, live_recommendation=True,
recompute_scores=True,
) )
data = [] data = []
for row in rows: for row in rows:
+1
View File
@@ -78,6 +78,7 @@ class RankingEntry(BaseModel):
symbol: str symbol: str
composite_score: float composite_score: float
composite_stale: bool = False
dimensions: list[DimensionScoreResponse] = [] dimensions: list[DimensionScoreResponse] = []
+9
View File
@@ -26,6 +26,14 @@ class RecommendationSummaryResponse(BaseModel):
composite_score: float 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): class TradeSetupResponse(BaseModel):
"""A single trade setup detected by the R:R scanner.""" """A single trade setup detected by the R:R scanner."""
@@ -49,4 +57,5 @@ class TradeSetupResponse(BaseModel):
evaluated_at: datetime | None = None evaluated_at: datetime | None = None
current_price: float | None = None current_price: float | None = None
momentum_percentile: float | None = None momentum_percentile: float | None = None
context_as_of: TradeSetupContextAsOfResponse | None = None
recommendation_summary: RecommendationSummaryResponse | None = None recommendation_summary: RecommendationSummaryResponse | None = None
+94 -42
View File
@@ -28,6 +28,7 @@ from app.models.trade_setup import TradeSetup
from app.services.indicator_service import _extract_ohlcv, compute_atr from app.services.indicator_service import _extract_ohlcv, compute_atr
from app.services.price_service import query_ohlcv from app.services.price_service import query_ohlcv
from app.services.recommendation_service import ( from app.services.recommendation_service import (
_risk_level_from_conflicts,
build_recommendation_snapshot, build_recommendation_snapshot,
enhance_trade_setup, enhance_trade_setup,
get_recommendation_config, 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 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( async def _apply_live_recommendation_context(
db: AsyncSession, db: AsyncSession,
setup_rows: list[tuple[TradeSetup, str]], 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} ticker_ids = {setup.ticker_id for setup, _ in setup_rows}
setups_by_id = {setup.id: setup for setup, _ in setup_rows} setups_by_id = {setup.id: setup for setup, _ in setup_rows}
directions_by_ticker = await _latest_available_directions_by_ticker(db, ticker_ids)
directions_by_ticker: dict[int, set[str]] = {}
for setup, _ in setup_rows:
directions_by_ticker.setdefault(setup.ticker_id, set()).add(setup.direction.lower())
dim_result = await db.execute( dim_result = await db.execute(
select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids)) select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids))
@@ -166,10 +141,13 @@ async def _apply_live_recommendation_context(
comp = composites.get(ticker_id) comp = composites.get(ticker_id)
if comp is not None: if comp is not None:
live_row["composite_score"] = float(comp.score) 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) 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: if dimension_scores:
sentiment = sentiments.get(ticker_id)
snapshot = build_recommendation_snapshot( snapshot = build_recommendation_snapshot(
dimension_scores=dimension_scores, dimension_scores=dimension_scores,
sentiment_classification=sentiment.classification if sentiment else None, 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["confidence_score"] = round(float(snapshot[confidence_key]), 2)
live_row["recommended_action"] = snapshot["action"] live_row["recommended_action"] = snapshot["action"]
live_row["reasoning"] = snapshot["reasoning"] 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) live_rows.append(live_row)
return live_rows 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): def _json_default(value):
if isinstance(value, (datetime, date)): if isinstance(value, (datetime, date)):
return value.isoformat() return value.isoformat()
@@ -550,7 +574,6 @@ async def get_trade_setups(
recommended_action: str | None = None, recommended_action: str | None = None,
symbol: str | None = None, symbol: str | None = None,
live_recommendation: bool = False, live_recommendation: bool = False,
recompute_scores: bool = False,
) -> list[dict]: ) -> list[dict]:
"""Get latest stored trade setups, optionally filtered.""" """Get latest stored trade setups, optionally filtered."""
stmt = ( stmt = (
@@ -589,12 +612,7 @@ async def get_trade_setups(
reverse=True, reverse=True,
) )
if recompute_scores: prices = await _latest_price_context(db, {s.ticker_id for s, _ in latest_rows})
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})
rows_out = [ rows_out = [
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id)) _trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
for setup, ticker_symbol in latest_rows for setup, ticker_symbol in latest_rows
@@ -623,8 +641,8 @@ async def get_trade_setups(
return rows_out return rows_out
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]: async def _latest_price_context(db: AsyncSession, ticker_ids: set[int]) -> dict[int, dict]:
"""Most recent close per ticker — used to judge a setup's current relevance.""" """Most recent daily OHLCV row per ticker for live price context."""
if not ticker_ids: if not ticker_ids:
return {} return {}
latest = ( latest = (
@@ -633,7 +651,12 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl
.group_by(OHLCVRecord.ticker_id) .group_by(OHLCVRecord.ticker_id)
.subquery() .subquery()
) )
stmt = select(OHLCVRecord.ticker_id, OHLCVRecord.close).join( stmt = select(
OHLCVRecord.ticker_id,
OHLCVRecord.close,
OHLCVRecord.date,
OHLCVRecord.created_at,
).join(
latest, latest,
and_( and_(
OHLCVRecord.ticker_id == latest.c.ticker_id, 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) 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( async def get_trade_setup_history(
@@ -658,16 +697,28 @@ async def get_trade_setup_history(
result = await db.execute(stmt) result = await db.execute(stmt)
rows = result.all() 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 [ return [
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id)) _trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
for setup, ticker_symbol in rows 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] = [] targets: list[dict] = []
conflicts: list[str] = [] 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: if setup.targets_json:
try: try:
@@ -706,4 +757,5 @@ def _trade_setup_to_dict(setup: TradeSetup, symbol: str, current_price: float |
"evaluated_at": setup.evaluated_at, "evaluated_at": setup.evaluated_at,
"current_price": current_price, "current_price": current_price,
"momentum_percentile": setup.momentum_percentile, "momentum_percentile": setup.momentum_percentile,
"context_as_of": context_as_of,
} }
+10 -64
View File
@@ -2,8 +2,8 @@
Computes dimension scores (technical, sr_quality, sentiment, fundamental, Computes dimension scores (technical, sr_quality, sentiment, fundamental,
momentum) each 0-100, composite score as weighted average of available momentum) each 0-100, composite score as weighted average of available
dimensions with re-normalized weights, staleness marking/recomputation dimensions with re-normalized weights, staleness marking, explicit refresh
on demand, and weight update triggers full recomputation. paths, and weight update triggers full recomputation.
""" """
from __future__ import annotations from __future__ import annotations
@@ -765,73 +765,37 @@ async def compute_composite_score(
async def get_score( async def get_score(
db: AsyncSession, symbol: str db: AsyncSession, symbol: str
) -> dict: ) -> 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. GET endpoints use this path, so it must not mutate persisted score context.
Returns a dict suitable for ScoreResponse, including dimension breakdowns Scheduled/manual write paths are responsible for refreshing scores.
and composite breakdown with re-normalization info.
""" """
ticker = await _get_ticker(db, symbol) ticker = await _get_ticker(db, symbol)
weights = await _get_weights(db) 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( result = await db.execute(
select(DimensionScore).where(DimensionScore.ticker_id == ticker.id) select(DimensionScore).where(DimensionScore.ticker_id == ticker.id)
) )
dim_scores_list = list(result.scalars().all()) dim_scores_list = list(result.scalars().all())
dim_scores = {ds.dimension: ds for ds in dim_scores_list}
comp_result = await db.execute( comp_result = await db.execute(
select(CompositeScore).where(CompositeScore.ticker_id == ticker.id) select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
) )
comp = comp_result.scalar_one_or_none() 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 = [] dimensions = []
missing = [] missing = []
available_dims: list[str] = [] available_dims: list[str] = []
for dim in DIMENSIONS: 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: if found is not None and not found.is_stale and found.score is not None:
dimensions.append({ dimensions.append({
"dimension": found.dimension, "dimension": found.dimension,
"score": found.score, "score": found.score,
"is_stale": found.is_stale, "is_stale": found.is_stale,
"computed_at": found.computed_at, "computed_at": found.computed_at,
"breakdown": breakdowns.get(dim), "breakdown": None,
}) })
w = weights.get(dim, 0.0) w = weights.get(dim, 0.0)
if w > 0: if w > 0:
@@ -845,7 +809,7 @@ async def get_score(
"score": found.score, "score": found.score,
"is_stale": found.is_stale, "is_stale": found.is_stale,
"computed_at": found.computed_at, "computed_at": found.computed_at,
"breakdown": breakdowns.get(dim), "breakdown": None,
}) })
# Build composite breakdown: the non-sentiment base (re-normalized weighted # 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 dims[ds.ticker_id][ds.dimension] = ds
return comps, dims return comps, dims
# Two bulk reads instead of ~4 queries per ticker.
comps, dims_by_ticker = await _load_scores() 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 = [ rankings = [
{ {
"symbol": ticker.symbol, "symbol": ticker.symbol,
"composite_score": comp.score, "composite_score": comp.score,
"composite_stale": comp.is_stale,
"dimensions": [ "dimensions": [
{ {
"dimension": ds.dimension, "dimension": ds.dimension,
+1 -1
View File
@@ -5,7 +5,7 @@ import type { DimensionScoreDetail, CompositeBreakdown } from '../../lib/types';
interface ScoreCardProps { interface ScoreCardProps {
compositeScore: number | null; compositeScore: number | null;
dimensions: DimensionScoreDetail[]; dimensions: DimensionScoreDetail[];
compositeBreakdown?: CompositeBreakdown; compositeBreakdown?: CompositeBreakdown | null;
/** Hide the composite ring/header when the composite is shown elsewhere /** Hide the composite ring/header when the composite is shown elsewhere
* (e.g. the Standing matrix) and this card only carries the dimension detail. */ * (e.g. the Standing matrix) and this card only carries the dimension detail. */
showComposite?: boolean; showComposite?: boolean;
+12 -2
View File
@@ -96,7 +96,7 @@ export interface ScoreResponse {
dimensions: DimensionScoreDetail[]; dimensions: DimensionScoreDetail[];
missing_dimensions: string[]; missing_dimensions: string[];
computed_at: string | null; computed_at: string | null;
composite_breakdown?: CompositeBreakdown; composite_breakdown?: CompositeBreakdown | null;
} }
export interface DimensionScoreDetail { export interface DimensionScoreDetail {
@@ -104,12 +104,13 @@ export interface DimensionScoreDetail {
score: number; score: number;
is_stale: boolean; is_stale: boolean;
computed_at: string | null; computed_at: string | null;
breakdown?: ScoreBreakdown; breakdown?: ScoreBreakdown | null;
} }
export interface RankingEntry { export interface RankingEntry {
symbol: string; symbol: string;
composite_score: number; composite_score: number;
composite_stale: boolean;
dimensions: DimensionScoreDetail[]; dimensions: DimensionScoreDetail[];
} }
@@ -140,9 +141,18 @@ export interface TradeSetup {
evaluated_at: string | null; evaluated_at: string | null;
current_price: number | null; current_price: number | null;
momentum_percentile?: number | null; momentum_percentile?: number | null;
context_as_of?: TradeSetupContextAsOf | null;
recommendation_summary?: RecommendationSummary; 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 // Performance / outcome statistics
export interface OutcomeBucketStats { export interface OutcomeBucketStats {
total: number; total: number;
+254
View File
@@ -11,13 +11,17 @@ Zero-candidate and single-candidate scenarios must produce identical results.
from __future__ import annotations from __future__ import annotations
import json
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import pytest import pytest
from hypothesis import given, settings, HealthCheck, strategies as st from hypothesis import given, settings, HealthCheck, strategies as st
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ohlcv import OHLCVRecord from app.models.ohlcv import OHLCVRecord
from app.models.signal_context_snapshot import SignalContextSnapshot
from app.models.sr_level import SRLevel from app.models.sr_level import SRLevel
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup from app.models.trade_setup import TradeSetup
@@ -568,3 +572,253 @@ async def test_live_recommendation_filters_apply_to_live_values(
live_recommendation=True, live_recommendation=True,
) )
assert rows == [] 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
+65 -140
View File
@@ -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 __future__ import annotations
from datetime import date from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base from app.database import Base
from app.models.score import CompositeScore, DimensionScore
from app.models.ticker import Ticker 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://" TEST_DATABASE_URL = "sqlite+aiosqlite://"
@pytest.fixture @pytest.fixture
async def fresh_db(): 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) engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
@@ -30,176 +30,101 @@ async def fresh_db():
await engine.dispose() 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: async def _seed_ticker(session: AsyncSession, symbol: str = "AAPL") -> Ticker:
"""Insert a ticker row and return it."""
ticker = Ticker(symbol=symbol) ticker = Ticker(symbol=symbol)
session.add(ticker) session.add(ticker)
await session.commit() await session.commit()
return ticker 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 @pytest.mark.asyncio
async def test_get_score_returns_composite_breakdown(fresh_db): async def test_get_score_returns_composite_breakdown_without_recomputing(fresh_db):
"""get_score should include a composite_breakdown dict with weights and re-normalization info.""" ticker = await _seed_ticker(fresh_db, "AAPL")
await _seed_ticker(fresh_db, "AAPL") await _seed_scores(fresh_db, ticker)
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()
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") 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"] cb = result["composite_breakdown"]
assert cb is not None 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 "technical" in cb["available_dimensions"]
assert "momentum" in cb["available_dimensions"] assert "momentum" in cb["available_dimensions"]
assert "sentiment" in cb["missing_dimensions"] assert "sentiment" in cb["missing_dimensions"]
assert "fundamental" in cb["missing_dimensions"] assert "fundamental" in cb["missing_dimensions"]
assert "sr_quality" 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"] assert cb["renormalized_weights"]
total = sum(cb["renormalized_weights"].values()) assert abs(sum(cb["renormalized_weights"].values()) - 1.0) < 1e-9
assert abs(total - 1.0) < 1e-9
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_score_dimensions_include_breakdowns(fresh_db): async def test_get_score_dimensions_do_not_recompute_breakdowns(fresh_db):
"""Each available dimension entry should include a breakdown dict.""" ticker = await _seed_ticker(fresh_db, "AAPL")
await _seed_ticker(fresh_db, "AAPL") await _seed_scores(fresh_db, ticker)
tech_breakdown = { result = await get_score(fresh_db, "AAPL")
"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)
tech_dim = next((d for d in result["dimensions"] if d["dimension"] == "technical"), None) tech_dim = next((d for d in result["dimensions"] if d["dimension"] == "technical"), None)
assert tech_dim is not None assert tech_dim is not None
assert "breakdown" in tech_dim assert tech_dim["breakdown"] is None
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
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_score_all_dimensions_missing(fresh_db): 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") await _seed_ticker(fresh_db, "AAPL")
original = dict(_DIMENSION_COMPUTERS) result = await get_score(fresh_db, "AAPL")
try:
for dim in _DIMENSION_COMPUTERS:
_DIMENSION_COMPUTERS[dim] = _mock_none_computer()
result = await get_score(fresh_db, "AAPL")
finally:
_DIMENSION_COMPUTERS.update(original)
cb = result["composite_breakdown"] cb = result["composite_breakdown"]
assert cb["available_dimensions"] == [] assert cb["available_dimensions"] == []
assert len(cb["missing_dimensions"]) == 5 assert len(cb["missing_dimensions"]) == 5
assert cb["renormalized_weights"] == {} assert cb["renormalized_weights"] == {}
assert result["composite_score"] is None 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
+12 -26
View File
@@ -1,5 +1,4 @@
"""Unit tests for get_rankings: bulk-load fast path, sorting, exclusion, and """Unit tests for read-only get_rankings: bulk-load, sorting, and staleness."""
lazy recompute of stale scores."""
from __future__ import annotations from __future__ import annotations
@@ -7,7 +6,6 @@ from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base from app.database import Base
@@ -20,7 +18,7 @@ TEST_DATABASE_URL = "sqlite+aiosqlite://"
@pytest.fixture @pytest.fixture
async def fresh_db(): 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) engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) 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 @pytest.mark.asyncio
async def test_ticker_without_computable_composite_is_excluded(fresh_db: AsyncSession): 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 """A ticker without a persisted composite is omitted from rankings."""
omitted from the rankings rather than appearing with a null score."""
fresh = await _seed_ticker(fresh_db, "OK") 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)]) fresh_db.add_all([_composite(fresh.id, 50.0), _dimension(fresh.id, "technical", 50.0)])
await fresh_db.commit() 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", 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", 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) result = await get_rankings(fresh_db)
assert [r["symbol"] for r in result["rankings"]] == ["OK"] assert [r["symbol"] for r in result["rankings"]] == ["OK"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stale_composite_is_recomputed(fresh_db: AsyncSession): async def test_stale_composite_is_reported_without_recompute(fresh_db: AsyncSession):
"""A stale composite triggers a recompute and then appears in the rankings.""" """A stale composite appears with its stale flag and is not recomputed."""
ticker = await _seed_ticker(fresh_db, "STALE") ticker = await _seed_ticker(fresh_db, "STALE")
fresh_db.add(_composite(ticker.id, 10.0, stale=True)) fresh_db.add(_composite(ticker.id, 10.0, stale=True))
await fresh_db.commit() 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", 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", 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) result = await get_rankings(fresh_db)
comp_mock.assert_awaited() # recompute path was taken
assert [r["symbol"] for r in result["rankings"]] == ["STALE"] 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