diff --git a/app/routers/trades.py b/app/routers/trades.py index cd78d7e..78c21d7 100644 --- a/app/routers/trades.py +++ b/app/routers/trades.py @@ -34,6 +34,7 @@ async def list_trade_setups( direction=direction, min_confidence=min_confidence, recommended_action=recommended_action, + live_recommendation=True, ) data = [] @@ -92,7 +93,12 @@ async def get_ticker_trade_setups( _user=Depends(require_access), db: AsyncSession = Depends(get_db), ) -> APIEnvelope: - rows = await get_trade_setups(db, symbol=symbol) + rows = await get_trade_setups( + db, + symbol=symbol, + live_recommendation=True, + recompute_scores=True, + ) data = [] for row in rows: summary = RecommendationSummaryResponse( diff --git a/app/services/alert_service.py b/app/services/alert_service.py index cca3a34..0141a6c 100644 --- a/app/services/alert_service.py +++ b/app/services/alert_service.py @@ -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)] diff --git a/app/services/recommendation_service.py b/app/services/recommendation_service.py index 98248fc..8f1f658 100644 --- a/app/services/recommendation_service.py +++ b/app/services/recommendation_service.py @@ -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) diff --git a/app/services/rr_scanner_service.py b/app/services/rr_scanner_service.py index b58f682..b7fe752 100644 --- a/app/services/rr_scanner_service.py +++ b/app/services/rr_scanner_service.py @@ -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]: diff --git a/tests/unit/test_rr_scanner_preservation.py b/tests/unit/test_rr_scanner_preservation.py index 9b4e8d3..520d798 100644 --- a/tests/unit/test_rr_scanner_preservation.py +++ b/tests/unit/test_rr_scanner_preservation.py @@ -21,7 +21,8 @@ from app.models.ohlcv import OHLCVRecord from app.models.sr_level import SRLevel from app.models.ticker import Ticker from app.models.trade_setup import TradeSetup -from app.models.score import CompositeScore +from app.models.score import CompositeScore, DimensionScore +from app.models.sentiment import SentimentScore from app.services.rr_scanner_service import scan_ticker, get_trade_setups @@ -431,3 +432,139 @@ async def test_get_trade_setups_sorting_rr_desc_composite_desc(db_session: Async f"Expected symbol order ['SORTD', 'SORTC', 'SORTB', 'SORTA'], " f"got {symbols}" ) + + +async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup: + """Stored setup frozen at scan time (conf 82, neutral) vs. current context + (bullish sentiment, composite 96) that yields live confidence 97.""" + old_scan = datetime(2026, 7, 1, tzinfo=timezone.utc) + current = datetime(2026, 7, 3, tzinfo=timezone.utc) + old_reasoning = ( + "LONG (high confidence): 82% with aligned signals " + "(technical=88, momentum=60, sentiment=neutral)." + ) + + ticker = Ticker(symbol="TTWO") + db_session.add(ticker) + await db_session.flush() + + stale_setup = TradeSetup( + ticker_id=ticker.id, + direction="long", + entry_price=235.0, + stop_loss=220.0, + target=265.0, + rr_ratio=2.0, + composite_score=71.8, + detected_at=old_scan, + confidence_score=82.0, + recommended_action="LONG_HIGH", + reasoning=old_reasoning, + risk_level="High", + ) + db_session.add(stale_setup) + + db_session.add_all([ + DimensionScore( + ticker_id=ticker.id, + dimension="technical", + score=88.0, + is_stale=False, + computed_at=current, + ), + DimensionScore( + ticker_id=ticker.id, + dimension="momentum", + score=60.0, + is_stale=False, + computed_at=current, + ), + DimensionScore( + ticker_id=ticker.id, + dimension="fundamental", + score=95.0, + is_stale=False, + computed_at=current, + ), + DimensionScore( + ticker_id=ticker.id, + dimension="sentiment", + score=85.0, + is_stale=False, + computed_at=current, + ), + CompositeScore( + ticker_id=ticker.id, + score=96.0, + is_stale=False, + weights_json="{}", + computed_at=current, + ), + SentimentScore( + ticker_id=ticker.id, + classification="bullish", + confidence=85, + source="test", + timestamp=current, + reasoning="", + citations_json="[]", + ), + ]) + await db_session.flush() + return stale_setup + + +@pytest.mark.asyncio +async def test_live_recommendation_payload_uses_current_score_and_sentiment( + db_session: AsyncSession, +): + """Latest setup payload should not show stale scan text when score context moved.""" + stale_setup = await _seed_stale_setup_with_current_scores(db_session) + old_reasoning = stale_setup.reasoning + + rows = await get_trade_setups( + db_session, + symbol="TTWO", + live_recommendation=True, + ) + + assert len(rows) == 1 + row = rows[0] + assert row["composite_score"] == pytest.approx(96.0) + assert row["confidence_score"] == pytest.approx(97.0) + assert row["recommended_action"] == "LONG_HIGH" + assert "sentiment=bullish" in row["reasoning"] + assert "sentiment=neutral" not in row["reasoning"] + + persisted = await db_session.get(TradeSetup, stale_setup.id) + assert persisted is not None + assert persisted.composite_score == pytest.approx(71.8) + assert persisted.reasoning == old_reasoning + + +@pytest.mark.asyncio +async def test_live_recommendation_filters_apply_to_live_values( + db_session: AsyncSession, +): + """min_confidence must judge the overlaid live confidence, not the stored one.""" + await _seed_stale_setup_with_current_scores(db_session) + + # Stored confidence is 82 — a stored-column filter would drop this row. + # Live confidence is 97, so it must pass. + rows = await get_trade_setups( + db_session, + symbol="TTWO", + min_confidence=90.0, + live_recommendation=True, + ) + assert len(rows) == 1 + assert rows[0]["confidence_score"] == pytest.approx(97.0) + + # And a floor above the live value must drop it. + rows = await get_trade_setups( + db_session, + symbol="TTWO", + min_confidence=98.0, + live_recommendation=True, + ) + assert rows == []