Make target probability and classification distance-aware
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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user