Separate chart S/R from gate target ladder
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
"""R:R Scanner service.
|
||||
"""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).
|
||||
Scans tracked tickers for asymmetric risk-reward trade setups. Candidate
|
||||
targets come from a transient, volume-free proposal ladder; persisted S/R is
|
||||
reserved for human-facing charts and alerts. Stops remain ATR-based.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,6 +11,8 @@ import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -23,11 +24,11 @@ from app.models.paper_trade import PaperTrade
|
||||
from app.models.score import CompositeScore, DimensionScore
|
||||
from app.models.sentiment import SentimentScore
|
||||
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||
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.sr_service import detect_gate_target_ladder
|
||||
from app.services.recommendation_service import (
|
||||
_risk_level_from_conflicts,
|
||||
build_recommendation_snapshot,
|
||||
@@ -38,6 +39,7 @@ from app.services.recommendation_service import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STRATEGY_VERSION = "residual_highvol_80_20_atr_trail3_v1"
|
||||
PRIMARY_TARGET_MIN_RR = 1.5
|
||||
|
||||
# A setup counts as live only while the daily scan keeps re-emitting it. The
|
||||
# scan runs every day (07:00 UTC cron), so anything older than this was NOT
|
||||
@@ -49,6 +51,28 @@ STRATEGY_VERSION = "residual_highvol_80_20_atr_trail3_v1"
|
||||
LIVE_SETUP_MAX_AGE_DAYS = 3
|
||||
|
||||
|
||||
def _materialize_gate_target_levels(
|
||||
highs: list[float],
|
||||
lows: list[float],
|
||||
closes: list[float],
|
||||
) -> list[Any]:
|
||||
"""Create transient level objects for target generation, never persistence."""
|
||||
detected = detect_gate_target_ladder(highs, lows, closes)
|
||||
return [
|
||||
SimpleNamespace(
|
||||
id=-(index + 1),
|
||||
price_level=float(level["price_level"]),
|
||||
type=str(level["type"]),
|
||||
strength=int(level["strength"]),
|
||||
detection_method=str(level.get("detection_method", "range_grid")),
|
||||
sources=list(level.get("sources") or ["range_grid"]),
|
||||
rejection_count=int(level.get("rejection_count", 0) or 0),
|
||||
last_rejection_age=level.get("last_rejection_age"),
|
||||
)
|
||||
for index, level in enumerate(detected)
|
||||
]
|
||||
|
||||
|
||||
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||
normalised = symbol.strip().upper()
|
||||
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
|
||||
@@ -413,22 +437,25 @@ async def scan_ticker(
|
||||
strategy_rank: float | None = None,
|
||||
volatility_percentile: float | None = None,
|
||||
primary_min_rr: float | None = None,
|
||||
gate_levels_override: list[Any] | None = None,
|
||||
) -> list[TradeSetup]:
|
||||
"""Scan a single ticker for trade setups meeting the R:R threshold.
|
||||
|
||||
``momentum_percentile`` is the ticker's residual 12-1 momentum activation
|
||||
rank across the universe (computed by the caller), stored on each setup so
|
||||
the activation gate can select the top slice. ``strategy_rank`` is the
|
||||
production ordering score used for top-pick ranking."""
|
||||
production ordering score used for top-pick ranking.
|
||||
|
||||
``primary_min_rr`` controls target selection only. Its 1.5 default is
|
||||
intentionally independent of the later activation floor (2.0 in the live
|
||||
Admin configuration). ``gate_levels_override`` is dependency injection for
|
||||
deterministic scanner tests; production builds the transient ladder from
|
||||
the ticker's OHLCV window.
|
||||
"""
|
||||
ticker = await _get_ticker(db, symbol)
|
||||
|
||||
if primary_min_rr is None:
|
||||
# Direct single-ticker scans still use the same activation threshold as
|
||||
# qualification. scan_all_tickers resolves this once for the universe.
|
||||
from app.services.admin_service import get_activation_config
|
||||
|
||||
activation = await get_activation_config(db)
|
||||
primary_min_rr = float(activation.get("min_rr", rr_threshold))
|
||||
primary_min_rr = PRIMARY_TARGET_MIN_RR
|
||||
|
||||
records = await query_ohlcv(db, symbol)
|
||||
if not records or len(records) < 15:
|
||||
@@ -452,21 +479,22 @@ async def scan_ticker(
|
||||
logger.info("Skipping %s: ATR is zero or negative", symbol)
|
||||
return []
|
||||
|
||||
sr_result = await db.execute(
|
||||
select(SRLevel).where(SRLevel.ticker_id == ticker.id)
|
||||
gate_levels = (
|
||||
list(gate_levels_override)
|
||||
if gate_levels_override is not None
|
||||
else _materialize_gate_target_levels(highs, lows, closes)
|
||||
)
|
||||
sr_levels = list(sr_result.scalars().all())
|
||||
|
||||
if not sr_levels:
|
||||
logger.info("Skipping %s: no SR levels available", symbol)
|
||||
if not gate_levels:
|
||||
logger.info("Skipping %s: no gate target levels available", symbol)
|
||||
return []
|
||||
|
||||
levels_above = sorted(
|
||||
[lv for lv in sr_levels if lv.price_level > entry_price],
|
||||
[lv for lv in gate_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],
|
||||
[lv for lv in gate_levels if lv.price_level < entry_price],
|
||||
key=lambda lv: lv.price_level,
|
||||
reverse=True,
|
||||
)
|
||||
@@ -564,7 +592,7 @@ async def scan_ticker(
|
||||
ticker=ticker,
|
||||
setup=setup,
|
||||
dimension_scores=dimension_scores,
|
||||
sr_levels=sr_levels,
|
||||
sr_levels=gate_levels,
|
||||
sentiment_classification=sentiment_classification,
|
||||
atr_value=atr_value,
|
||||
primary_min_rr=primary_min_rr,
|
||||
@@ -620,16 +648,6 @@ async def scan_all_tickers(
|
||||
logger.exception("Activation ranking refresh failed")
|
||||
ranks = {}
|
||||
|
||||
try:
|
||||
from app.services.admin_service import get_activation_config
|
||||
|
||||
activation = await get_activation_config(db)
|
||||
primary_min_rr = float(activation.get("min_rr", rr_threshold))
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.exception("Activation config load failed; using scanner R:R floor")
|
||||
primary_min_rr = rr_threshold
|
||||
|
||||
all_setups: list[TradeSetup] = []
|
||||
for index, symbol in enumerate(symbols):
|
||||
if progress_callback is not None:
|
||||
@@ -661,7 +679,7 @@ async def scan_all_tickers(
|
||||
momentum_percentile=(ranks.get(symbol) or {}).get("momentum_percentile"),
|
||||
strategy_rank=(ranks.get(symbol) or {}).get("strategy_rank"),
|
||||
volatility_percentile=(ranks.get(symbol) or {}).get("volatility_percentile"),
|
||||
primary_min_rr=primary_min_rr,
|
||||
primary_min_rr=PRIMARY_TARGET_MIN_RR,
|
||||
)
|
||||
all_setups.extend(setups)
|
||||
except Exception:
|
||||
|
||||
Reference in New Issue
Block a user