From 3aebfd72d300fc07e6bbb97c3df36eab9f493514 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Sun, 14 Jun 2026 12:44:59 +0200 Subject: [PATCH] Spread trade targets across distance bands MKC showed 5 targets all far/Aggressive: target selection was top-5 by quality (0.35*R:R + ...), and R:R grows with distance, so far levels crowded out every nearby one. generate_targets now selects for spread: always include the nearest level, plus the best-quality representative from each distance band (Conservative <=2.9 ATR, Moderate <=4.6 ATR, Aggressive beyond), then fill remaining slots by quality. Restores a Conservative/Moderate/ Aggressive mix with the nearest target always present. Co-Authored-By: Claude Fable 5 --- app/services/recommendation_service.py | 43 ++++++++++++++++++++--- tests/unit/test_recommendation_service.py | 26 ++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/app/services/recommendation_service.py b/app/services/recommendation_service.py index b171e48..c7e70f5 100644 --- a/app/services/recommendation_service.py +++ b/app/services/recommendation_service.py @@ -33,6 +33,12 @@ DEFAULT_RECOMMENDATION_CONFIG: dict[str, float] = { # metric we actually measure. _TARGET_HORIZON_DAYS = 30.0 +# Distance bands (in ATR) used to spread targets across Conservative / Moderate +# / Aggressive. Aligned with where the touch-probability crosses 60% / 40% over +# the horizon, so a target from each band tends to land in the matching label. +_CONSERVATIVE_MAX_ATR = 2.9 +_MODERATE_MAX_ATR = 4.6 + def _clamp(value: float, low: float, high: float) -> float: return max(low, min(high, value)) @@ -255,12 +261,39 @@ class TargetGenerator: } ) - candidates.sort(key=lambda row: row["quality"], reverse=True) - selected = candidates[:5] - selected.sort(key=lambda row: row["distance_from_entry"]) + if not candidates: + return [] - # Classification is assigned later from the computed reach-probability - # (see _classify_by_probability), not from distance rank. + # Select up to 5 targets that SPAN the distance range, instead of the + # top-5 by quality (which biases toward far, high-R:R levels and buries + # every nearby target). Guarantees the nearest level plus a + # representative from each distance band when they exist, so the table + # offers a real mix of Conservative / Moderate / Aggressive. + conservative = [c for c in candidates if c["distance_atr_multiple"] <= _CONSERVATIVE_MAX_ATR] + moderate = [c for c in candidates if _CONSERVATIVE_MAX_ATR < c["distance_atr_multiple"] <= _MODERATE_MAX_ATR] + aggressive = [c for c in candidates if c["distance_atr_multiple"] > _MODERATE_MAX_ATR] + + selected: list[dict[str, Any]] = [] + selected_ids: set[int] = set() + + def _add(candidate: dict[str, Any] | None) -> None: + if candidate is not None and candidate["sr_level_id"] not in selected_ids: + selected.append(candidate) + selected_ids.add(candidate["sr_level_id"]) + + # Nearest overall (the most likely / Conservative anchor) + _add(min(candidates, key=lambda c: c["distance_atr_multiple"])) + # Best-quality representative from each band → spread across labels + for bucket in (conservative, moderate, aggressive): + if bucket: + _add(max(bucket, key=lambda c: c["quality"])) + # Fill remaining slots with the next-best by quality + for candidate in sorted(candidates, key=lambda c: c["quality"], reverse=True): + if len(selected) >= 5: + break + _add(candidate) + + selected.sort(key=lambda row: row["distance_from_entry"]) for target in selected: target.pop("quality", None) diff --git a/tests/unit/test_recommendation_service.py b/tests/unit/test_recommendation_service.py index 6b5ebf9..b7a27c9 100644 --- a/tests/unit/test_recommendation_service.py +++ b/tests/unit/test_recommendation_service.py @@ -159,6 +159,32 @@ def test_generate_targets_respects_direction_and_order(): assert distances == sorted(distances) +def test_generate_targets_spreads_across_distance_bands(): + """Near levels must not be crowded out by far high-R:R ones — expect a mix + of distance bands, including the nearest, not just the 5 farthest.""" + # entry 100, atr 2 → ATR multiples: 5→2.5, 8→4.0, 14→7.0, 24→12, 34→17, 44→22 + sr_levels = [ + _SRLevelStub(id=1, price_level=105.0, type="resistance", strength=60), # 2.5 ATR (conservative) + _SRLevelStub(id=2, price_level=108.0, type="resistance", strength=55), # 4.0 ATR (moderate) + _SRLevelStub(id=3, price_level=114.0, type="resistance", strength=90), # 7.0 ATR (aggressive, strong) + _SRLevelStub(id=4, price_level=124.0, type="resistance", strength=95), # 12 ATR (aggressive, strong) + _SRLevelStub(id=5, price_level=134.0, type="resistance", strength=92), # 17 ATR (aggressive, strong) + _SRLevelStub(id=6, price_level=144.0, type="resistance", strength=88), # 22 ATR (aggressive, strong) + ] + + targets = target_generator.generate_targets( + direction="long", entry_price=100.0, stop_loss=96.0, + sr_levels=sr_levels, atr_value=2.0, # type: ignore[arg-type] + ) + + multiples = [t["distance_atr_multiple"] for t in targets] + # The nearest (conservative) and a moderate target survive despite the + # strong far levels that would dominate a pure top-5-by-quality pick. + assert any(m <= 2.9 for m in multiples), "expected a conservative (near) target" + assert any(2.9 < m <= 4.6 for m in multiples), "expected a moderate target" + assert any(m > 4.6 for m in multiples), "expected an aggressive (far) target" + + def test_probability_decreases_with_distance(): """A far target must be far less likely than a near one — no 90% at +39%.""" config = {