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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user