Separate chart S/R from gate target ladder

This commit is contained in:
2026-07-13 11:17:03 +02:00
parent 995a0380c3
commit 8161c352a0
12 changed files with 277 additions and 58 deletions
+7 -4
View File
@@ -82,7 +82,12 @@ from app.services.scoring_service import (
compute_momentum_from_closes,
compute_technical_from_arrays,
)
from app.services.sr_service import MAX_LEVELS, detect_sr_levels, detect_sr_levels_legacy
from app.services.sr_service import (
MAX_LEVELS,
detect_gate_target_ladder,
detect_sr_levels,
detect_sr_levels_legacy,
)
logger = logging.getLogger(__name__)
@@ -375,12 +380,10 @@ def _window_setups(
highs, lows, closes, volumes, include_pivots=False
)
elif sr_variant == EXPLICIT_TARGET_LADDER_VARIANT:
detected_levels = detect_sr_levels_legacy(
detected_levels = detect_gate_target_ladder(
highs,
lows,
closes,
volumes,
explicit_range_grid=True,
)
elif sr_variant in {"legacy_range_grid_touch", "legacy_range_grid_neutral"}:
detected_levels = detect_sr_levels_legacy(
+50 -32
View File
@@ -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:
+24
View File
@@ -522,6 +522,30 @@ def detect_sr_levels_legacy(
return merged
def detect_gate_target_ladder(
highs: list[float],
lows: list[float],
closes: list[float],
tolerance: float = DEFAULT_TOLERANCE,
) -> list[dict]:
"""Build the scanner's internal, volume-free target proposal ladder.
This is intentionally not human-facing support/resistance. It preserves
the production gate's broad 20-bin range grid, unfiltered pivots, touch
strength, and merge geometry without performing or claiming a volume
profile calculation. The returned levels are transient and must not be
persisted as chart S/R.
"""
return detect_sr_levels_legacy(
highs,
lows,
closes,
[0] * len(closes),
tolerance,
explicit_range_grid=True,
)
def _merge_levels(
levels: list[dict],
tolerance: float = DEFAULT_TOLERANCE,