Make target probability and classification distance-aware
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 24s

Fixes nonsensical "Conservative @ 90%" on far targets (AJG: +39% target
labelled Conservative/90%).

- Probability = chance of touching the level within the outcome horizon
  under a driftless random walk: 2*(1 - Phi(d / (ATR*sqrt(T)))), T=30d to
  match the outcome evaluator. Distance (in ATR) dominates; strength and
  alignment modulate modestly. Far targets are now correctly low-prob.
- Classification derived from that probability (>=60 Conservative,
  40-60 Moderate, <40 Aggressive) instead of distance-rank.
- Combined with the most-likely-worthwhile primary pick, the nearest
  strong resistance becomes the Conservative primary; far levels demote
  to low-probability stretch targets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 12:26:36 +02:00
parent 5a0e8c8258
commit 8c89396987
2 changed files with 88 additions and 100 deletions
+35 -39
View File
@@ -159,48 +159,44 @@ def test_generate_targets_respects_direction_and_order():
assert distances == sorted(distances)
def test_probability_ranges_by_classification():
def test_probability_decreases_with_distance():
"""A far target must be far less likely than a near one — no 90% at +39%."""
config = {
"recommendation_signal_alignment_weight": 0.15,
"recommendation_sr_strength_weight": 0.20,
"recommendation_distance_penalty_factor": 0.10,
}
dimension_scores = {"technical": 70.0, "momentum": 70.0}
dims = {"technical": 50.0, "momentum": 50.0} # neutral, isolate the distance term
conservative = probability_estimator.estimate_probability(
{
"classification": "Conservative",
"sr_strength": 80,
"distance_atr_multiple": 1.5,
},
dimension_scores,
"bullish",
"long",
config,
)
moderate = probability_estimator.estimate_probability(
{
"classification": "Moderate",
"sr_strength": 60,
"distance_atr_multiple": 3.0,
},
dimension_scores,
"bullish",
"long",
config,
)
aggressive = probability_estimator.estimate_probability(
{
"classification": "Aggressive",
"sr_strength": 40,
"distance_atr_multiple": 6.0,
},
dimension_scores,
"bullish",
"long",
config,
)
def prob(atr_multiple: float, strength: float = 50.0) -> float:
return probability_estimator.estimate_probability(
{"sr_strength": strength, "distance_atr_multiple": atr_multiple},
dims, None, "long", config,
)
assert conservative > 60
assert 40 <= moderate <= 70
assert aggressive < 50
near = prob(1.5)
mid = prob(4.0)
far = prob(10.0)
# Monotonic decay with distance
assert near > mid > far
# Near target is genuinely likely; a 10-ATR target is a long shot
assert near > 60
assert far < 25
def test_far_target_not_high_probability_even_with_strong_level():
"""The AJG case: a far target stays low-probability even at max strength."""
config = {"recommendation_sr_strength_weight": 0.20, "recommendation_signal_alignment_weight": 0.15}
# ~10 ATR away, strongest possible level, fully aligned bullish
p = probability_estimator.estimate_probability(
{"sr_strength": 100, "distance_atr_multiple": 10.0},
{"technical": 80.0, "momentum": 80.0}, "bullish", "long", config,
)
assert p < 40 # nowhere near 90
def test_classify_by_probability_thresholds():
from app.services.recommendation_service import _classify_by_probability
assert _classify_by_probability(75) == "Conservative"
assert _classify_by_probability(50) == "Moderate"
assert _classify_by_probability(20) == "Aggressive"