"""R:R Scanner service. Scans tracked tickers for asymmetric risk-reward trade setups. Long: target = nearest SR above, stop = entry - ATR × multiplier. Short: target = nearest SR below, stop = entry + ATR × multiplier. Filters by configurable R:R threshold (default 1.5). """ from __future__ import annotations import json import logging from datetime import datetime, timezone from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.exceptions import NotFoundError from app.models.ohlcv import OHLCVRecord from app.models.score import CompositeScore, DimensionScore from app.models.sentiment import SentimentScore from app.models.sr_level import SRLevel 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 logger = logging.getLogger(__name__) async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker: normalised = symbol.strip().upper() result = await db.execute(select(Ticker).where(Ticker.symbol == normalised)) ticker = result.scalar_one_or_none() if ticker is None: raise NotFoundError(f"Ticker not found: {normalised}") return ticker def _compute_quality_score( rr: float, strength: int, distance: float, entry_price: float, *, w_rr: float = 0.35, w_strength: float = 0.35, w_proximity: float = 0.30, rr_cap: float = 10.0, ) -> float: """Compute a quality score for a candidate S/R level.""" norm_rr = min(rr / rr_cap, 1.0) norm_strength = strength / 100.0 norm_proximity = 1.0 - min(distance / entry_price, 1.0) return w_rr * norm_rr + w_strength * norm_strength + w_proximity * norm_proximity async def _get_dimension_scores(db: AsyncSession, ticker_id: int) -> dict[str, float]: result = await db.execute( select(DimensionScore).where(DimensionScore.ticker_id == ticker_id) ) rows = result.scalars().all() return {row.dimension: float(row.score) for row in rows} async def _get_latest_sentiment(db: AsyncSession, ticker_id: int) -> str | None: result = await db.execute( select(SentimentScore) .where(SentimentScore.ticker_id == ticker_id) .order_by(SentimentScore.timestamp.desc()) .limit(1) ) row = result.scalar_one_or_none() return row.classification if row else None async def scan_ticker( db: AsyncSession, symbol: str, rr_threshold: float = 1.5, atr_multiplier: float = 1.5, ) -> list[TradeSetup]: """Scan a single ticker for trade setups meeting the R:R threshold.""" ticker = await _get_ticker(db, symbol) records = await query_ohlcv(db, symbol) if not records or len(records) < 15: logger.info( "Skipping %s: insufficient OHLCV data (%d bars, need 15+)", symbol, len(records), ) return [] _, highs, lows, closes, _ = _extract_ohlcv(records) entry_price = closes[-1] try: atr_result = compute_atr(highs, lows, closes) atr_value = atr_result["atr"] except Exception: logger.info("Skipping %s: cannot compute ATR", symbol) return [] if atr_value <= 0: logger.info("Skipping %s: ATR is zero or negative", symbol) return [] sr_result = await db.execute( select(SRLevel).where(SRLevel.ticker_id == ticker.id) ) sr_levels = list(sr_result.scalars().all()) if not sr_levels: logger.info("Skipping %s: no SR levels available", symbol) return [] levels_above = sorted( [lv for lv in sr_levels if lv.price_level > entry_price], key=lambda lv: lv.price_level, ) levels_below = sorted( [lv for lv in sr_levels if lv.price_level < entry_price], key=lambda lv: lv.price_level, reverse=True, ) comp_result = await db.execute( select(CompositeScore).where(CompositeScore.ticker_id == ticker.id) ) comp = comp_result.scalar_one_or_none() composite_score = comp.score if comp else 0.0 dimension_scores = await _get_dimension_scores(db, ticker.id) sentiment_classification = await _get_latest_sentiment(db, ticker.id) now = datetime.now(timezone.utc) setups: list[TradeSetup] = [] if levels_above: stop = entry_price - (atr_value * atr_multiplier) risk = entry_price - stop if risk > 0: best_quality = 0.0 best_candidate_rr = 0.0 best_candidate_target = 0.0 for lv in levels_above: reward = lv.price_level - entry_price if reward <= 0: continue rr = reward / risk if rr < rr_threshold: continue distance = lv.price_level - entry_price quality = _compute_quality_score(rr, lv.strength, distance, entry_price) if quality > best_quality: best_quality = quality best_candidate_rr = rr best_candidate_target = lv.price_level if best_candidate_rr > 0: setups.append(TradeSetup( ticker_id=ticker.id, direction="long", entry_price=round(entry_price, 4), stop_loss=round(stop, 4), target=round(best_candidate_target, 4), rr_ratio=round(best_candidate_rr, 4), composite_score=round(composite_score, 4), detected_at=now, )) if levels_below: stop = entry_price + (atr_value * atr_multiplier) risk = stop - entry_price if risk > 0: best_quality = 0.0 best_candidate_rr = 0.0 best_candidate_target = 0.0 for lv in levels_below: reward = entry_price - lv.price_level if reward <= 0: continue rr = reward / risk if rr < rr_threshold: continue distance = entry_price - lv.price_level quality = _compute_quality_score(rr, lv.strength, distance, entry_price) if quality > best_quality: best_quality = quality best_candidate_rr = rr best_candidate_target = lv.price_level if best_candidate_rr > 0: setups.append(TradeSetup( ticker_id=ticker.id, direction="short", entry_price=round(entry_price, 4), stop_loss=round(stop, 4), target=round(best_candidate_target, 4), rr_ratio=round(best_candidate_rr, 4), composite_score=round(composite_score, 4), detected_at=now, )) available_directions = {s.direction for s in setups} enhanced_setups: list[TradeSetup] = [] for setup in setups: try: enhanced = await enhance_trade_setup( db=db, ticker=ticker, setup=setup, dimension_scores=dimension_scores, sr_levels=sr_levels, sentiment_classification=sentiment_classification, atr_value=atr_value, available_directions=available_directions, ) enhanced_setups.append(enhanced) except Exception: logger.exception("Error enhancing setup for %s (%s)", ticker.symbol, setup.direction) enhanced_setups.append(setup) for setup in enhanced_setups: db.add(setup) await db.commit() for s in enhanced_setups: await db.refresh(s) return enhanced_setups async def scan_all_tickers( db: AsyncSession, rr_threshold: float = 1.5, atr_multiplier: float = 1.5, ) -> list[TradeSetup]: """Scan all tracked tickers for trade setups.""" result = await db.execute(select(Ticker).order_by(Ticker.symbol)) tickers = list(result.scalars().all()) all_setups: list[TradeSetup] = [] for ticker in tickers: try: # Refresh scores first so the scheduled scan works off current data. # Nothing else marks scores stale, so without this they'd never # update for tickers the user doesn't manually fetch. try: from app.services import scoring_service await scoring_service.compute_all_dimensions(db, ticker.symbol) await scoring_service.compute_composite_score(db, ticker.symbol) await db.commit() except Exception: logger.exception("Error refreshing scores for %s", ticker.symbol) setups = await scan_ticker( db, ticker.symbol, rr_threshold, atr_multiplier ) all_setups.extend(setups) except Exception: logger.exception("Error scanning ticker %s", ticker.symbol) return all_setups async def get_trade_setups( db: AsyncSession, direction: str | None = None, min_confidence: float | None = None, recommended_action: str | None = None, symbol: str | None = None, ) -> list[dict]: """Get latest stored trade setups, optionally filtered.""" stmt = ( select(TradeSetup, Ticker.symbol) .join(Ticker, TradeSetup.ticker_id == Ticker.id) ) if direction is not None: 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: stmt = stmt.where(TradeSetup.confidence_score >= min_confidence) if recommended_action is not None: stmt = stmt.where(TradeSetup.recommended_action == recommended_action) stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc()) result = await db.execute(stmt) rows = result.all() latest_by_key: dict[tuple[str, str], tuple[TradeSetup, str]] = {} for setup, ticker_symbol in rows: dedupe_key = (ticker_symbol, setup.direction) if dedupe_key not in latest_by_key: latest_by_key[dedupe_key] = (setup, ticker_symbol) latest_rows = list(latest_by_key.values()) latest_rows.sort( key=lambda row: ( row[0].confidence_score if row[0].confidence_score is not None else -1.0, row[0].rr_ratio, row[0].composite_score, ), reverse=True, ) prices = await _latest_closes(db, {s.ticker_id for s, _ in latest_rows}) return [ _trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id)) for setup, ticker_symbol in latest_rows ] 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.""" if not ticker_ids: return {} latest = ( select(OHLCVRecord.ticker_id, func.max(OHLCVRecord.date).label("md")) .where(OHLCVRecord.ticker_id.in_(ticker_ids)) .group_by(OHLCVRecord.ticker_id) .subquery() ) stmt = select(OHLCVRecord.ticker_id, OHLCVRecord.close).join( latest, and_( OHLCVRecord.ticker_id == latest.c.ticker_id, OHLCVRecord.date == latest.c.md, ), ) result = await db.execute(stmt) return {tid: float(close) for tid, close in result.all()} async def get_trade_setup_history( db: AsyncSession, symbol: str, ) -> list[dict]: """Get full recommendation history for a symbol (newest first).""" stmt = ( select(TradeSetup, Ticker.symbol) .join(Ticker, TradeSetup.ticker_id == Ticker.id) .where(Ticker.symbol == symbol.strip().upper()) .order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc()) ) result = await db.execute(stmt) rows = result.all() prices = await _latest_closes(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: targets: list[dict] = [] conflicts: list[str] = [] if setup.targets_json: try: parsed_targets = json.loads(setup.targets_json) if isinstance(parsed_targets, list): targets = parsed_targets except (TypeError, ValueError): targets = [] if setup.conflict_flags_json: try: parsed_conflicts = json.loads(setup.conflict_flags_json) if isinstance(parsed_conflicts, list): conflicts = [str(item) for item in parsed_conflicts] except (TypeError, ValueError): conflicts = [] return { "id": setup.id, "symbol": symbol, "direction": setup.direction, "entry_price": setup.entry_price, "stop_loss": setup.stop_loss, "target": setup.target, "rr_ratio": setup.rr_ratio, "composite_score": setup.composite_score, "detected_at": setup.detected_at, "confidence_score": setup.confidence_score, "targets": targets, "conflict_flags": conflicts, "recommended_action": setup.recommended_action, "reasoning": setup.reasoning, "risk_level": setup.risk_level, "actual_outcome": setup.actual_outcome, "outcome_date": setup.outcome_date, "evaluated_at": setup.evaluated_at, "current_price": current_price, }