From c7a198ba8e9ec40043cf807f29a0cb0bdf49236d Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Fri, 10 Jul 2026 13:31:35 +0200 Subject: [PATCH] Primary target: probability floor stops lottery headlines A setup's primary target could carry a ~3% probability: the picker chose the most likely target among those with R:R >= 1.5, and after a run-up that pool can contain only far "lottery" levels (the near, likely levels fail the R:R floor). The lottery target's inflated R:R then became the setup's headline and passed the activation gate's min_rr floor - the gate's probability check only requires a value to exist. Fix, no new tuning knobs: the primary must clear BOTH floors (R:R >= 1.5 AND probability >= 20%). When nothing does, fall back to the most likely target overall, so the headline carries an honest low R:R and the gate rejects the setup on real numbers instead of being gamed by an unreachable target. Deliberately NOT pure EV-maximization (p*RR): the probability model adds strength/alignment bonuses as flat percentage points, so EV arithmetic would scale those bonuses by (RR+1) and systematically favor far targets on aligned setups - the same lottery bias through the back door. Shared by production (enhance_trade_setup) and the backtest simulator, so backtest comparisons stay apples-to-apples. Unit tests pin the degenerate case; full unit suite green (497 passed). Co-Authored-By: Claude Fable 5 --- app/services/recommendation_service.py | 33 ++++++++++++++++++----- tests/unit/test_recommendation_service.py | 26 ++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/app/services/recommendation_service.py b/app/services/recommendation_service.py index 8f1f658..45817a2 100644 --- a/app/services/recommendation_service.py +++ b/app/services/recommendation_service.py @@ -575,21 +575,40 @@ def build_recommendation_snapshot( PRIMARY_TARGET_MIN_RR = 1.5 +# Below this the target is a lottery ticket: under the two-barrier model a +# fair-race 1.5:1 target sits near ~34% before drift adjustments, so 20% only +# excludes targets the model itself considers long shots. +PRIMARY_TARGET_MIN_PROBABILITY = 20.0 -def _select_primary_target(targets: list[dict], min_rr: float = PRIMARY_TARGET_MIN_RR) -> dict | None: +def _select_primary_target( + targets: list[dict], + min_rr: float = PRIMARY_TARGET_MIN_RR, + min_probability: float = PRIMARY_TARGET_MIN_PROBABILITY, +) -> dict | None: """Primary = the most LIKELY target that still offers real asymmetry. - Among targets clearing a minimal R:R floor, pick the highest probability - (tie-break by R:R). This fixes the old pick, which ignored probability and - could land on the furthest, least-likely 'lottery' level. Stronger-reward - levels remain in the table as stretch targets. Falls back to the highest-R:R - target if nothing clears the floor. + Among targets clearing BOTH floors (R:R >= min_rr and probability >= + min_probability), pick the highest probability (tie-break by R:R). + Stronger-reward levels remain in the table as stretch targets. + + Degenerate case: after a run-up, every level with acceptable R:R can be a + far 'lottery' target (probability at/near the model's 3% clamp floor). + Previously the pick was restricted to the R:R pool, so such a lottery level + became the headline — its inflated R:R then sailed through the activation + gate's min_rr floor. Now we fall back to the most likely target overall: + the headline carries an honest (low) R:R and the gate rejects the setup on + real numbers instead of being gamed by an unreachable target. """ if not targets: return None - worthwhile = [t for t in targets if float(t.get("rr_ratio", 0.0)) >= min_rr] + worthwhile = [ + t + for t in targets + if float(t.get("rr_ratio", 0.0)) >= min_rr + and float(t.get("probability", 0.0)) >= min_probability + ] pool = worthwhile or targets return max( pool, diff --git a/tests/unit/test_recommendation_service.py b/tests/unit/test_recommendation_service.py index 6c5c59f..87fde30 100644 --- a/tests/unit/test_recommendation_service.py +++ b/tests/unit/test_recommendation_service.py @@ -128,6 +128,32 @@ def test_primary_target_none_when_empty(): assert _select_primary_target([]) is None +def test_primary_target_never_headlines_a_lottery(): + # After a run-up: the only target clearing the R:R floor is a near-impossible + # far level. The primary must NOT be that lottery — fall back to the most + # likely target overall, so the headline R:R is honest (and the activation + # gate rejects the setup on min_rr instead of being gamed). + targets = [ + {"price": 101.0, "rr_ratio": 0.9, "probability": 55.0}, # likely, no asymmetry + {"price": 140.0, "rr_ratio": 5.0, "probability": 3.0}, # asymmetric lottery + ] + primary = _select_primary_target(targets) + assert primary is not None + assert primary["price"] == 101.0 + + +def test_primary_target_requires_probability_floor(): + # A worthwhile-R:R target below the probability floor loses to one that + # clears both floors, even at lower R:R. + targets = [ + {"price": 130.0, "rr_ratio": 4.0, "probability": 12.0}, # asymmetric but unlikely + {"price": 112.0, "rr_ratio": 1.8, "probability": 38.0}, # clears both floors ← primary + ] + primary = _select_primary_target(targets) + assert primary is not None + assert primary["price"] == 112.0 + + def test_detects_sentiment_technical_conflict(): conflicts = signal_conflict_detector.detect_conflicts( dimension_scores={"technical": 72.0, "momentum": 55.0, "fundamental": 50.0},