generate targets from S/R zones, not raw levels (consistency + strength)
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 23s

Trade-setup targets now pre-merge near-duplicate S/R levels into zone
representatives (same 2% clusterer as chart + alerts) before generate_targets
runs. A clustered wall (e.g. 183 + 185) becomes one target carrying the zone's
COMBINED strength (capped 100) instead of two near-identical targets that each
undervalue the wall — which also feeds a more honest reach-probability via the
S/R-strength magnet. Representative price is the zone's near edge; the strongest
constituent's id is retained. Singleton levels pass through unchanged, so the
downstream band-spreading / probability / primary-selection pipeline and its
tests are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 10:20:15 +02:00
parent 88239e6ef8
commit e355368748
2 changed files with 89 additions and 1 deletions
+54 -1
View File
@@ -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,
)
+35
View File
@@ -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}