diff --git a/app/services/recommendation_service.py b/app/services/recommendation_service.py index 0c8ff2b..20e023f 100644 --- a/app/services/recommendation_service.py +++ b/app/services/recommendation_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import logging import math +from types import SimpleNamespace from typing import Any from sqlalchemy import select @@ -12,6 +13,7 @@ from app.models.settings import SystemSetting from app.models.sr_level import SRLevel from app.models.ticker import Ticker from app.models.trade_setup import TradeSetup +from app.services.sr_service import cluster_sr_zones logger = logging.getLogger(__name__) @@ -38,11 +40,58 @@ _TARGET_HORIZON_DAYS = 30.0 _CONSERVATIVE_MAX_ATR = 2.9 _MODERATE_MAX_ATR = 4.6 +# Merge S/R levels within this fraction into one zone before generating targets — +# the same tolerance the chart and alerts use, so S/R is one model app-wide. +_SR_ZONE_TOLERANCE = 0.02 + def _clamp(value: float, low: float, high: float) -> float: return max(low, min(high, value)) +def _zone_representative_levels(sr_levels: list[SRLevel], entry_price: float) -> list[Any]: + """Collapse near-duplicate S/R levels into one representative per zone. + + Targets are generated from these representatives, so a clustered wall (e.g. + 183 + 185) becomes a single target carrying the zone's COMBINED strength + (capped at 100) instead of two near-identical targets, each undervaluing the + wall. Same clusterer as the chart and alerts → one S/R model everywhere. + + The representative price is the zone's near edge (the reachable side of the + wall) and it keeps the strongest constituent's id for reference. Singleton + levels pass through unchanged. + """ + if not sr_levels or entry_price <= 0: + return list(sr_levels) + + level_dicts = [ + {"price_level": float(lv.price_level), "strength": int(lv.strength), "type": lv.type} + for lv in sr_levels + ] + zones = cluster_sr_zones(level_dicts, entry_price, tolerance=_SR_ZONE_TOLERANCE) + + reps: list[Any] = [] + for zone in zones: + constituents = [ + lv for lv in sr_levels if zone["low"] <= float(lv.price_level) <= zone["high"] + ] + if not constituents: + continue + strongest = max(constituents, key=lambda lv: lv.strength) + # Near edge: bottom of a resistance wall (above entry), top of a support + # wall (below entry) — the first price the move reaches. + near_edge = zone["low"] if zone["type"] == "resistance" else zone["high"] + reps.append( + SimpleNamespace( + id=int(strongest.id), + price_level=float(near_edge), + type=zone["type"], + strength=int(zone["strength"]), + ) + ) + return reps + + def _norm_cdf(x: float) -> float: """Standard normal CDF via erf (no SciPy dependency).""" return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0))) @@ -522,11 +571,15 @@ async def enhance_trade_setup( direction = setup.direction.lower() confidence = long_confidence if direction == "long" else short_confidence + # Merge near-duplicate levels into zone representatives first, so a clustered + # wall yields one strength-combined target instead of several near-identical + # ones — consistent with the chart and alerts. + zone_levels = _zone_representative_levels(sr_levels, setup.entry_price) targets = target_generator.generate_targets( direction=direction, entry_price=setup.entry_price, stop_loss=setup.stop_loss, - sr_levels=sr_levels, + sr_levels=zone_levels, atr_value=atr_value, ) diff --git a/tests/unit/test_recommendation_service.py b/tests/unit/test_recommendation_service.py index b7a27c9..a503194 100644 --- a/tests/unit/test_recommendation_service.py +++ b/tests/unit/test_recommendation_service.py @@ -226,3 +226,38 @@ def test_classify_by_probability_thresholds(): assert _classify_by_probability(75) == "Conservative" assert _classify_by_probability(50) == "Moderate" assert _classify_by_probability(20) == "Aggressive" + + +def test_zone_representative_levels_merges_wall(): + """Near-duplicate resistances collapse to one zone with combined strength.""" + from types import SimpleNamespace + from app.services.recommendation_service import _zone_representative_levels + + levels = [ + SimpleNamespace(id=1, price_level=183.0, type="resistance", strength=60), + SimpleNamespace(id=2, price_level=185.0, type="resistance", strength=60), + SimpleNamespace(id=3, price_level=200.0, type="resistance", strength=50), + ] + reps = _zone_representative_levels(levels, entry_price=180.0) + + # 183/185 merge into one zone; 200 stays separate → 2 representatives + assert len(reps) == 2 + wall = min(reps, key=lambda r: r.price_level) + assert wall.price_level == 183.0 # near edge of the wall + assert wall.strength == 100 # 60 + 60, capped at 100 + far = max(reps, key=lambda r: r.price_level) + assert far.price_level == 200.0 + assert far.strength == 50 + + +def test_zone_representative_levels_singletons_unchanged(): + from types import SimpleNamespace + from app.services.recommendation_service import _zone_representative_levels + + levels = [ + SimpleNamespace(id=1, price_level=120.0, type="resistance", strength=70), + SimpleNamespace(id=2, price_level=150.0, type="resistance", strength=40), + ] + reps = _zone_representative_levels(levels, entry_price=100.0) + assert len(reps) == 2 + assert {round(r.price_level) for r in reps} == {120, 150}