Serve live recommendation context on trade setup APIs and alerts
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 1m0s
Deploy / deploy (push) Successful in 32s

Stored TradeSetup rows are point-in-time snapshots from the RR scan, so
the ticker page could show stale confidence/reasoning/composite (e.g.
sentiment=neutral in the setup card while the sentiment panel showed
bullish). Overlay current score/sentiment context onto the API payload
for GET /trades and GET /trades/{symbol}, gate and format Telegram
qualified-setup alerts on the same live values, and apply the
min_confidence/recommended_action filters after the overlay so they
judge what the caller actually sees. Stored setups stay frozen for
outcome analysis and backtests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 09:17:27 +02:00
parent 2b0068ae08
commit ac51e23949
5 changed files with 348 additions and 34 deletions
+3 -1
View File
@@ -254,7 +254,9 @@ async def _watchlist_tickers(db: AsyncSession) -> list[tuple[int, str]]:
async def _qualified_setups(db: AsyncSession) -> list[dict]:
setups = await get_trade_setups(db)
# live_recommendation: gate and format on current score/sentiment context,
# not the values frozen into the setup at scan time.
setups = await get_trade_setups(db, live_recommendation=True)
config = await get_activation_config(db)
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
+57 -27
View File
@@ -524,6 +524,56 @@ def _build_reasoning(
)
def build_recommendation_snapshot(
dimension_scores: dict[str, float],
sentiment_classification: str | None,
config: dict[str, float],
available_directions: set[str] | None = None,
) -> dict[str, Any]:
"""Build the ticker-level recommendation from the supplied live context."""
conflicts = signal_conflict_detector.detect_conflicts(
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
config=config,
)
long_confidence = direction_analyzer.calculate_confidence(
direction="long",
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
conflicts=conflicts,
)
short_confidence = direction_analyzer.calculate_confidence(
direction="short",
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
conflicts=conflicts,
)
action = _choose_recommended_action(
long_confidence, short_confidence, config, available_directions
)
reasoning = _build_reasoning(
action=action,
long_confidence=long_confidence,
short_confidence=short_confidence,
conflicts=conflicts,
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
config=config,
available_directions=available_directions,
)
return {
"action": action,
"reasoning": reasoning,
"risk_level": _risk_level_from_conflicts(conflicts),
"long_confidence": long_confidence,
"short_confidence": short_confidence,
"conflicts": conflicts,
}
PRIMARY_TARGET_MIN_RR = 1.5
@@ -559,24 +609,15 @@ async def enhance_trade_setup(
) -> TradeSetup:
config = await get_recommendation_config(db)
conflicts = signal_conflict_detector.detect_conflicts(
snapshot = build_recommendation_snapshot(
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
config=config,
available_directions=available_directions,
)
long_confidence = direction_analyzer.calculate_confidence(
direction="long",
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
conflicts=conflicts,
)
short_confidence = direction_analyzer.calculate_confidence(
direction="short",
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
conflicts=conflicts,
)
conflicts = list(snapshot["conflicts"])
long_confidence = float(snapshot["long_confidence"])
short_confidence = float(snapshot["short_confidence"])
direction = setup.direction.lower()
confidence = long_confidence if direction == "long" else short_confidence
@@ -622,19 +663,8 @@ async def enhance_trade_setup(
# Action and reasoning are ticker-level: they consider both directions and
# which directions are actually tradeable, and are identical on every setup.
action = _choose_recommended_action(
long_confidence, short_confidence, config, available_directions
)
reasoning = _build_reasoning(
action=action,
long_confidence=long_confidence,
short_confidence=short_confidence,
conflicts=conflicts,
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
config=config,
available_directions=available_directions,
)
action = str(snapshot["action"])
reasoning = str(snapshot["reasoning"])
setup.confidence_score = round(confidence, 2)
setup.targets_json = json.dumps(targets)
+143 -4
View File
@@ -27,7 +27,11 @@ from app.models.ticker import Ticker
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 enhance_trade_setup
from app.services.recommendation_service import (
build_recommendation_snapshot,
enhance_trade_setup,
get_recommendation_config,
)
logger = logging.getLogger(__name__)
@@ -80,6 +84,110 @@ 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]],
rows: list[dict],
) -> list[dict]:
"""Decorate latest setup rows with current score/sentiment recommendation data.
This intentionally updates only the API payload. Stored trade setups and
history remain point-in-time records for outcome analysis.
"""
if not rows or not setup_rows:
return rows
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())
dim_result = await db.execute(
select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids))
)
dims_by_ticker: dict[int, dict[str, float]] = {}
for ds in dim_result.scalars().all():
dims_by_ticker.setdefault(ds.ticker_id, {})[ds.dimension] = float(ds.score)
comp_result = await db.execute(
select(CompositeScore)
.where(CompositeScore.ticker_id.in_(ticker_ids))
.order_by(CompositeScore.ticker_id, CompositeScore.computed_at.desc())
)
composites: dict[int, CompositeScore] = {}
for comp in comp_result.scalars().all():
composites.setdefault(comp.ticker_id, comp)
sent_result = await db.execute(
select(SentimentScore)
.where(SentimentScore.ticker_id.in_(ticker_ids))
.order_by(SentimentScore.ticker_id, SentimentScore.timestamp.desc())
)
sentiments: dict[int, SentimentScore] = {}
for sent in sent_result.scalars().all():
sentiments.setdefault(sent.ticker_id, sent)
config = await get_recommendation_config(db)
live_rows: list[dict] = []
for row in rows:
setup = setups_by_id.get(row["id"])
if setup is None:
live_rows.append(row)
continue
ticker_id = setup.ticker_id
live_row = dict(row)
comp = composites.get(ticker_id)
if comp is not None:
live_row["composite_score"] = float(comp.score)
dimension_scores = dims_by_ticker.get(ticker_id)
if dimension_scores:
sentiment = sentiments.get(ticker_id)
snapshot = build_recommendation_snapshot(
dimension_scores=dimension_scores,
sentiment_classification=sentiment.classification if sentiment else None,
config=config,
available_directions=directions_by_ticker.get(ticker_id),
)
direction = setup.direction.lower()
confidence_key = "long_confidence" if direction == "long" else "short_confidence"
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"]
live_rows.append(live_row)
return live_rows
def _json_default(value):
if isinstance(value, (datetime, date)):
return value.isoformat()
@@ -441,6 +549,8 @@ async def get_trade_setups(
min_confidence: float | None = None,
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 = (
@@ -451,9 +561,11 @@ async def get_trade_setups(
stmt = stmt.where(TradeSetup.direction == direction.lower())
if symbol is not None:
stmt = stmt.where(Ticker.symbol == symbol.strip().upper())
if min_confidence is not None:
# With live_recommendation these fields are overlaid with current values
# below, so filtering happens there instead of against the stored columns.
if min_confidence is not None and not live_recommendation:
stmt = stmt.where(TradeSetup.confidence_score >= min_confidence)
if recommended_action is not None:
if recommended_action is not None and not live_recommendation:
stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
@@ -477,11 +589,38 @@ 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})
return [
rows_out = [
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
for setup, ticker_symbol in latest_rows
]
if live_recommendation:
rows_out = await _apply_live_recommendation_context(db, latest_rows, rows_out)
if min_confidence is not None:
rows_out = [
row for row in rows_out
if row["confidence_score"] is not None
and row["confidence_score"] >= min_confidence
]
if recommended_action is not None:
rows_out = [
row for row in rows_out
if row["recommended_action"] == recommended_action
]
rows_out.sort(
key=lambda row: (
row["confidence_score"] if row["confidence_score"] is not None else -1.0,
row["rr_ratio"],
row["composite_score"],
),
reverse=True,
)
return rows_out
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]: