Files
signal-platform/tests/unit/test_recommendation_service.py
T
dennisthiessen 8c89396987
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 24s
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>
2026-06-14 12:26:36 +02:00

203 lines
6.7 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from app.services.recommendation_service import (
_build_reasoning,
_choose_recommended_action,
_select_primary_target,
direction_analyzer,
probability_estimator,
signal_conflict_detector,
target_generator,
)
_DEFAULT_CFG = {
"recommendation_high_confidence_threshold": 70.0,
"recommendation_moderate_confidence_threshold": 50.0,
"recommendation_confidence_diff_threshold": 20.0,
}
@dataclass
class _SRLevelStub:
id: int
price_level: float
type: str
strength: int
def test_high_confidence_long_example():
dimension_scores = {
"technical": 75.0,
"momentum": 68.0,
"fundamental": 55.0,
}
confidence = direction_analyzer.calculate_confidence(
direction="long",
dimension_scores=dimension_scores,
sentiment_classification="bullish",
conflicts=[],
)
assert confidence > 70.0
def test_high_confidence_short_example():
dimension_scores = {
"technical": 30.0,
"momentum": 35.0,
"fundamental": 45.0,
}
confidence = direction_analyzer.calculate_confidence(
direction="short",
dimension_scores=dimension_scores,
sentiment_classification="bearish",
conflicts=[],
)
assert confidence > 70.0
def test_short_confidence_low_on_strongly_bullish_stock():
"""The CNC case: technical 92 / momentum 99 must make SHORT confidence low."""
dims = {"technical": 92.0, "momentum": 99.0, "fundamental": 96.0}
short_conf = direction_analyzer.calculate_confidence(
direction="short", dimension_scores=dims, sentiment_classification="neutral", conflicts=[],
)
long_conf = direction_analyzer.calculate_confidence(
direction="long", dimension_scores=dims, sentiment_classification="neutral", conflicts=[],
)
assert short_conf < 20.0 # not 55
assert long_conf > 90.0
def test_action_neutral_when_bias_direction_has_no_setup():
"""Strong LONG bias but only a SHORT setup is tradeable → NEUTRAL, not LONG_HIGH."""
action = _choose_recommended_action(
long_confidence=100.0, short_confidence=5.0, config=_DEFAULT_CFG,
available_directions={"short"},
)
assert action == "NEUTRAL"
# With the long setup available, the same numbers give LONG_HIGH
action_ok = _choose_recommended_action(
long_confidence=100.0, short_confidence=5.0, config=_DEFAULT_CFG,
available_directions={"long", "short"},
)
assert action_ok == "LONG_HIGH"
def test_reasoning_explains_missing_setup():
reasoning = _build_reasoning(
action="NEUTRAL", long_confidence=100.0, short_confidence=5.0, conflicts=[],
dimension_scores={"technical": 92.0, "momentum": 99.0},
sentiment_classification="neutral", config=_DEFAULT_CFG,
available_directions={"short"},
)
assert "bias is LONG" in reasoning
assert "no high-conviction long setup" in reasoning.lower()
def test_primary_target_is_most_likely_worthwhile_not_lottery():
targets = [
{"price": 110.0, "rr_ratio": 2.0, "probability": 65.0}, # worthwhile, most likely ← primary
{"price": 120.0, "rr_ratio": 3.5, "probability": 50.0},
{"price": 140.0, "rr_ratio": 6.0, "probability": 15.0}, # far lottery — not chosen
]
primary = _select_primary_target(targets)
assert primary is not None
assert primary["price"] == 110.0
def test_primary_target_skips_sub_threshold_rr():
targets = [
{"price": 102.0, "rr_ratio": 1.0, "probability": 95.0}, # high prob but trivial R:R — skipped
{"price": 115.0, "rr_ratio": 2.5, "probability": 60.0}, # most likely above the R:R floor ← primary
]
primary = _select_primary_target(targets)
assert primary is not None
assert primary["price"] == 115.0
def test_primary_target_none_when_empty():
assert _select_primary_target([]) is None
def test_detects_sentiment_technical_conflict():
conflicts = signal_conflict_detector.detect_conflicts(
dimension_scores={"technical": 72.0, "momentum": 55.0, "fundamental": 50.0},
sentiment_classification="bearish",
)
assert any("sentiment-technical" in conflict for conflict in conflicts)
def test_generate_targets_respects_direction_and_order():
sr_levels = [
_SRLevelStub(id=1, price_level=110.0, type="resistance", strength=80),
_SRLevelStub(id=2, price_level=115.0, type="resistance", strength=70),
_SRLevelStub(id=3, price_level=120.0, type="resistance", strength=60),
_SRLevelStub(id=4, price_level=95.0, type="support", strength=75),
]
targets = target_generator.generate_targets(
direction="long",
entry_price=100.0,
stop_loss=96.0,
sr_levels=sr_levels, # type: ignore[arg-type]
atr_value=2.0,
)
assert len(targets) >= 1
assert all(target["price"] > 100.0 for target in targets)
distances = [target["distance_from_entry"] for target in targets]
assert distances == sorted(distances)
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,
}
dims = {"technical": 50.0, "momentum": 50.0} # neutral, isolate the distance term
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,
)
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"