Primary target: probability floor stops lottery headlines
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 1m4s
Deploy / deploy (push) Successful in 34s

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:
2026-07-10 13:31:35 +02:00
co-authored by Claude Fable 5
parent 6e33897c1a
commit c7a198ba8e
2 changed files with 52 additions and 7 deletions
+26 -7
View File
@@ -575,21 +575,40 @@ def build_recommendation_snapshot(
PRIMARY_TARGET_MIN_RR = 1.5 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. """Primary = the most LIKELY target that still offers real asymmetry.
Among targets clearing a minimal R:R floor, pick the highest probability Among targets clearing BOTH floors (R:R >= min_rr and probability >=
(tie-break by R:R). This fixes the old pick, which ignored probability and min_probability), pick the highest probability (tie-break by R:R).
could land on the furthest, least-likely 'lottery' level. Stronger-reward Stronger-reward levels remain in the table as stretch targets.
levels remain in the table as stretch targets. Falls back to the highest-R:R
target if nothing clears the floor. 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: if not targets:
return None 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 pool = worthwhile or targets
return max( return max(
pool, pool,
+26
View File
@@ -128,6 +128,32 @@ def test_primary_target_none_when_empty():
assert _select_primary_target([]) is None 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(): def test_detects_sentiment_technical_conflict():
conflicts = signal_conflict_detector.detect_conflicts( conflicts = signal_conflict_detector.detect_conflicts(
dimension_scores={"technical": 72.0, "momentum": 55.0, "fundamental": 50.0}, dimension_scores={"technical": 72.0, "momentum": 55.0, "fundamental": 50.0},