"""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 3:1). """ from __future__ import annotations import logging from datetime import datetime, timezone from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from app.exceptions import NotFoundError from app.models.score import CompositeScore 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 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 async def scan_ticker( db: AsyncSession, symbol: str, rr_threshold: float = 3.0, atr_multiplier: float = 1.5, ) -> list[TradeSetup]: """Scan a single ticker for trade setups meeting the R:R threshold. 1. Fetch OHLCV data and compute ATR. 2. Fetch SR levels. 3. Compute long and short setups. 4. Filter by R:R threshold. 5. Delete old setups for this ticker and persist new ones. Returns list of persisted TradeSetup models. """ ticker = await _get_ticker(db, symbol) # Fetch OHLCV 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), ) # Clear any stale setups await db.execute( delete(TradeSetup).where(TradeSetup.ticker_id == ticker.id) ) return [] _, highs, lows, closes, _ = _extract_ohlcv(records) entry_price = closes[-1] # Compute ATR try: atr_result = compute_atr(highs, lows, closes) atr_value = atr_result["atr"] except Exception: logger.info("Skipping %s: cannot compute ATR", symbol) await db.execute( delete(TradeSetup).where(TradeSetup.ticker_id == ticker.id) ) return [] if atr_value <= 0: logger.info("Skipping %s: ATR is zero or negative", symbol) await db.execute( delete(TradeSetup).where(TradeSetup.ticker_id == ticker.id) ) return [] # Fetch SR levels from DB (already computed by sr_service) 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) await db.execute( delete(TradeSetup).where(TradeSetup.ticker_id == ticker.id) ) 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, ) # Get composite score for this ticker 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 now = datetime.now(timezone.utc) setups: list[TradeSetup] = [] # Long setup: target = nearest SR above, stop = entry - ATR × multiplier if levels_above: target = levels_above[0].price_level stop = entry_price - (atr_value * atr_multiplier) reward = target - entry_price risk = entry_price - stop if risk > 0 and reward > 0: rr = reward / risk if rr >= rr_threshold: setups.append(TradeSetup( ticker_id=ticker.id, direction="long", entry_price=round(entry_price, 4), stop_loss=round(stop, 4), target=round(target, 4), rr_ratio=round(rr, 4), composite_score=round(composite_score, 4), detected_at=now, )) # Short setup: target = nearest SR below, stop = entry + ATR × multiplier if levels_below: target = levels_below[0].price_level stop = entry_price + (atr_value * atr_multiplier) reward = entry_price - target risk = stop - entry_price if risk > 0 and reward > 0: rr = reward / risk if rr >= rr_threshold: setups.append(TradeSetup( ticker_id=ticker.id, direction="short", entry_price=round(entry_price, 4), stop_loss=round(stop, 4), target=round(target, 4), rr_ratio=round(rr, 4), composite_score=round(composite_score, 4), detected_at=now, )) # Delete old setups for this ticker, persist new ones await db.execute( delete(TradeSetup).where(TradeSetup.ticker_id == ticker.id) ) for setup in setups: db.add(setup) await db.commit() # Refresh to get IDs for s in setups: await db.refresh(s) return setups async def scan_all_tickers( db: AsyncSession, rr_threshold: float = 3.0, atr_multiplier: float = 1.5, ) -> list[TradeSetup]: """Scan all tracked tickers for trade setups. Processes each ticker independently — one failure doesn't stop others. Returns all setups found across all tickers. """ result = await db.execute(select(Ticker).order_by(Ticker.symbol)) tickers = list(result.scalars().all()) all_setups: list[TradeSetup] = [] for ticker in tickers: try: 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, ) -> list[dict]: """Get all stored trade setups, optionally filtered by direction. Returns dicts sorted by R:R desc, secondary composite desc. Each dict includes the ticker symbol. """ stmt = ( select(TradeSetup, Ticker.symbol) .join(Ticker, TradeSetup.ticker_id == Ticker.id) ) if direction is not None: stmt = stmt.where(TradeSetup.direction == direction.lower()) stmt = stmt.order_by( TradeSetup.rr_ratio.desc(), TradeSetup.composite_score.desc(), ) result = await db.execute(stmt) rows = result.all() 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, } for setup, symbol in rows ]