Make live signal reads non-mutating
This commit is contained in:
@@ -28,6 +28,7 @@ from app.models.trade_setup import TradeSetup
|
||||
from app.services.indicator_service import _extract_ohlcv, compute_atr
|
||||
from app.services.price_service import query_ohlcv
|
||||
from app.services.recommendation_service import (
|
||||
_risk_level_from_conflicts,
|
||||
build_recommendation_snapshot,
|
||||
enhance_trade_setup,
|
||||
get_recommendation_config,
|
||||
@@ -84,29 +85,6 @@ async def _get_latest_sentiment(db: AsyncSession, ticker_id: int) -> str | None:
|
||||
return row.classification if row else None
|
||||
|
||||
|
||||
async def _refresh_score_context_for_symbols(
|
||||
db: AsyncSession,
|
||||
symbols: set[str],
|
||||
) -> None:
|
||||
"""Refresh provider-free scores so live recommendation summaries match the page."""
|
||||
if not symbols:
|
||||
return
|
||||
|
||||
from app.services import scoring_service
|
||||
|
||||
refreshed = False
|
||||
for symbol in sorted(symbols):
|
||||
try:
|
||||
await scoring_service.compute_all_dimensions(db, symbol)
|
||||
await scoring_service.compute_composite_score(db, symbol)
|
||||
refreshed = True
|
||||
except Exception:
|
||||
logger.exception("Error refreshing live score context for %s", symbol)
|
||||
|
||||
if refreshed:
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _apply_live_recommendation_context(
|
||||
db: AsyncSession,
|
||||
setup_rows: list[tuple[TradeSetup, str]],
|
||||
@@ -122,10 +100,7 @@ async def _apply_live_recommendation_context(
|
||||
|
||||
ticker_ids = {setup.ticker_id for setup, _ in setup_rows}
|
||||
setups_by_id = {setup.id: setup for setup, _ in setup_rows}
|
||||
|
||||
directions_by_ticker: dict[int, set[str]] = {}
|
||||
for setup, _ in setup_rows:
|
||||
directions_by_ticker.setdefault(setup.ticker_id, set()).add(setup.direction.lower())
|
||||
directions_by_ticker = await _latest_available_directions_by_ticker(db, ticker_ids)
|
||||
|
||||
dim_result = await db.execute(
|
||||
select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids))
|
||||
@@ -166,10 +141,13 @@ async def _apply_live_recommendation_context(
|
||||
comp = composites.get(ticker_id)
|
||||
if comp is not None:
|
||||
live_row["composite_score"] = float(comp.score)
|
||||
live_row["context_as_of"]["score_computed_at"] = comp.computed_at
|
||||
|
||||
dimension_scores = dims_by_ticker.get(ticker_id)
|
||||
sentiment = sentiments.get(ticker_id)
|
||||
if sentiment is not None:
|
||||
live_row["context_as_of"]["sentiment_at"] = sentiment.timestamp
|
||||
if dimension_scores:
|
||||
sentiment = sentiments.get(ticker_id)
|
||||
snapshot = build_recommendation_snapshot(
|
||||
dimension_scores=dimension_scores,
|
||||
sentiment_classification=sentiment.classification if sentiment else None,
|
||||
@@ -181,13 +159,59 @@ async def _apply_live_recommendation_context(
|
||||
live_row["confidence_score"] = round(float(snapshot[confidence_key]), 2)
|
||||
live_row["recommended_action"] = snapshot["action"]
|
||||
live_row["reasoning"] = snapshot["reasoning"]
|
||||
live_row["risk_level"] = snapshot["risk_level"]
|
||||
setup_conflicts = _setup_specific_conflicts(live_row.get("conflict_flags", []))
|
||||
live_conflicts = [str(item) for item in snapshot["conflicts"]]
|
||||
live_row["conflict_flags"] = live_conflicts + setup_conflicts
|
||||
live_row["risk_level"] = _risk_level_from_conflicts(live_row["conflict_flags"])
|
||||
|
||||
live_rows.append(live_row)
|
||||
|
||||
return live_rows
|
||||
|
||||
|
||||
def _setup_specific_conflicts(conflicts: list[str]) -> list[str]:
|
||||
signal_prefixes = (
|
||||
"sentiment-technical:",
|
||||
"sentiment-momentum:",
|
||||
"momentum-technical:",
|
||||
"fundamental-technical:",
|
||||
)
|
||||
return [
|
||||
str(conflict)
|
||||
for conflict in conflicts
|
||||
if not str(conflict).startswith(signal_prefixes)
|
||||
]
|
||||
|
||||
|
||||
async def _latest_available_directions_by_ticker(
|
||||
db: AsyncSession,
|
||||
ticker_ids: set[int],
|
||||
) -> dict[int, set[str]]:
|
||||
if not ticker_ids:
|
||||
return {}
|
||||
|
||||
result = await db.execute(
|
||||
select(TradeSetup)
|
||||
.where(TradeSetup.ticker_id.in_(ticker_ids))
|
||||
.order_by(
|
||||
TradeSetup.ticker_id,
|
||||
TradeSetup.direction,
|
||||
TradeSetup.detected_at.desc(),
|
||||
TradeSetup.id.desc(),
|
||||
)
|
||||
)
|
||||
latest_by_key: set[tuple[int, str]] = set()
|
||||
directions: dict[int, set[str]] = {}
|
||||
for setup in result.scalars().all():
|
||||
direction = setup.direction.lower()
|
||||
key = (setup.ticker_id, direction)
|
||||
if key in latest_by_key:
|
||||
continue
|
||||
latest_by_key.add(key)
|
||||
directions.setdefault(setup.ticker_id, set()).add(direction)
|
||||
return directions
|
||||
|
||||
|
||||
def _json_default(value):
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
@@ -550,7 +574,6 @@ async def get_trade_setups(
|
||||
recommended_action: str | None = None,
|
||||
symbol: str | None = None,
|
||||
live_recommendation: bool = False,
|
||||
recompute_scores: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Get latest stored trade setups, optionally filtered."""
|
||||
stmt = (
|
||||
@@ -589,12 +612,7 @@ async def get_trade_setups(
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
if recompute_scores:
|
||||
await _refresh_score_context_for_symbols(
|
||||
db, {ticker_symbol for _, ticker_symbol in latest_rows}
|
||||
)
|
||||
|
||||
prices = await _latest_closes(db, {s.ticker_id for s, _ in latest_rows})
|
||||
prices = await _latest_price_context(db, {s.ticker_id for s, _ in latest_rows})
|
||||
rows_out = [
|
||||
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
|
||||
for setup, ticker_symbol in latest_rows
|
||||
@@ -623,8 +641,8 @@ async def get_trade_setups(
|
||||
return rows_out
|
||||
|
||||
|
||||
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]:
|
||||
"""Most recent close per ticker — used to judge a setup's current relevance."""
|
||||
async def _latest_price_context(db: AsyncSession, ticker_ids: set[int]) -> dict[int, dict]:
|
||||
"""Most recent daily OHLCV row per ticker for live price context."""
|
||||
if not ticker_ids:
|
||||
return {}
|
||||
latest = (
|
||||
@@ -633,7 +651,12 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl
|
||||
.group_by(OHLCVRecord.ticker_id)
|
||||
.subquery()
|
||||
)
|
||||
stmt = select(OHLCVRecord.ticker_id, OHLCVRecord.close).join(
|
||||
stmt = select(
|
||||
OHLCVRecord.ticker_id,
|
||||
OHLCVRecord.close,
|
||||
OHLCVRecord.date,
|
||||
OHLCVRecord.created_at,
|
||||
).join(
|
||||
latest,
|
||||
and_(
|
||||
OHLCVRecord.ticker_id == latest.c.ticker_id,
|
||||
@@ -641,7 +664,23 @@ async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, fl
|
||||
),
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return {tid: float(close) for tid, close in result.all()}
|
||||
return {
|
||||
tid: {
|
||||
"current_price": float(close),
|
||||
"price_date": price_date,
|
||||
"price_updated_at": created_at,
|
||||
}
|
||||
for tid, close, price_date, created_at in result.all()
|
||||
}
|
||||
|
||||
|
||||
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]:
|
||||
"""Most recent close per ticker, kept for callers that only need price."""
|
||||
price_context = await _latest_price_context(db, ticker_ids)
|
||||
return {
|
||||
ticker_id: context["current_price"]
|
||||
for ticker_id, context in price_context.items()
|
||||
}
|
||||
|
||||
|
||||
async def get_trade_setup_history(
|
||||
@@ -658,16 +697,28 @@ async def get_trade_setup_history(
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
prices = await _latest_closes(db, {s.ticker_id for s, _ in rows})
|
||||
prices = await _latest_price_context(db, {s.ticker_id for s, _ in rows})
|
||||
return [
|
||||
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
|
||||
for setup, ticker_symbol in rows
|
||||
]
|
||||
|
||||
|
||||
def _trade_setup_to_dict(setup: TradeSetup, symbol: str, current_price: float | None = None) -> dict:
|
||||
def _trade_setup_to_dict(setup: TradeSetup, symbol: str, price_context: dict | None = None) -> dict:
|
||||
targets: list[dict] = []
|
||||
conflicts: list[str] = []
|
||||
current_price = (
|
||||
float(price_context["current_price"])
|
||||
if price_context and price_context.get("current_price") is not None
|
||||
else None
|
||||
)
|
||||
context_as_of = {
|
||||
"setup_detected_at": setup.detected_at,
|
||||
"score_computed_at": None,
|
||||
"sentiment_at": None,
|
||||
"price_date": price_context.get("price_date") if price_context else None,
|
||||
"price_updated_at": price_context.get("price_updated_at") if price_context else None,
|
||||
}
|
||||
|
||||
if setup.targets_json:
|
||||
try:
|
||||
@@ -706,4 +757,5 @@ def _trade_setup_to_dict(setup: TradeSetup, symbol: str, current_price: float |
|
||||
"evaluated_at": setup.evaluated_at,
|
||||
"current_price": current_price,
|
||||
"momentum_percentile": setup.momentum_percentile,
|
||||
"context_as_of": context_as_of,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user