242 lines
7.4 KiB
Python
242 lines
7.4 KiB
Python
"""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
|
||
]
|