Make live signal reads non-mutating
This commit is contained in:
@@ -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"]
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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] = []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
if dimension_scores:
|
|
||||||
sentiment = sentiments.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:
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|
||||||
# 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()
|
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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
|
||||||
"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")
|
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)
|
|
||||||
try:
|
|
||||||
for dim in _DIMENSION_COMPUTERS:
|
|
||||||
_DIMENSION_COMPUTERS[dim] = _mock_none_computer()
|
|
||||||
|
|
||||||
result = await get_score(fresh_db, "AAPL")
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user