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_generate_targets_spreads_across_distance_bands(): """Near levels must not be crowded out by far high-R:R ones — expect a mix of distance bands, including the nearest, not just the 5 farthest.""" # entry 100, atr 2 → ATR multiples: 5→2.5, 8→4.0, 14→7.0, 24→12, 34→17, 44→22 sr_levels = [ _SRLevelStub(id=1, price_level=105.0, type="resistance", strength=60), # 2.5 ATR (conservative) _SRLevelStub(id=2, price_level=108.0, type="resistance", strength=55), # 4.0 ATR (moderate) _SRLevelStub(id=3, price_level=114.0, type="resistance", strength=90), # 7.0 ATR (aggressive, strong) _SRLevelStub(id=4, price_level=124.0, type="resistance", strength=95), # 12 ATR (aggressive, strong) _SRLevelStub(id=5, price_level=134.0, type="resistance", strength=92), # 17 ATR (aggressive, strong) _SRLevelStub(id=6, price_level=144.0, type="resistance", strength=88), # 22 ATR (aggressive, strong) ] targets = target_generator.generate_targets( direction="long", entry_price=100.0, stop_loss=96.0, sr_levels=sr_levels, atr_value=2.0, # type: ignore[arg-type] ) multiples = [t["distance_atr_multiple"] for t in targets] # The nearest (conservative) and a moderate target survive despite the # strong far levels that would dominate a pure top-5-by-quality pick. assert any(m <= 2.9 for m in multiples), "expected a conservative (near) target" assert any(2.9 < m <= 4.6 for m in multiples), "expected a moderate target" assert any(m > 4.6 for m in multiples), "expected an aggressive (far) target" 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" def test_zone_representative_levels_merges_wall(): """Near-duplicate resistances collapse to one zone with combined strength.""" from types import SimpleNamespace from app.services.recommendation_service import _zone_representative_levels levels = [ SimpleNamespace(id=1, price_level=183.0, type="resistance", strength=60), SimpleNamespace(id=2, price_level=185.0, type="resistance", strength=60), SimpleNamespace(id=3, price_level=200.0, type="resistance", strength=50), ] reps = _zone_representative_levels(levels, entry_price=180.0) # 183/185 merge into one zone; 200 stays separate → 2 representatives assert len(reps) == 2 wall = min(reps, key=lambda r: r.price_level) assert wall.price_level == 183.0 # near edge of the wall assert wall.strength == 100 # 60 + 60, capped at 100 far = max(reps, key=lambda r: r.price_level) assert far.price_level == 200.0 assert far.strength == 50 def test_zone_representative_levels_singletons_unchanged(): from types import SimpleNamespace from app.services.recommendation_service import _zone_representative_levels levels = [ SimpleNamespace(id=1, price_level=120.0, type="resistance", strength=70), SimpleNamespace(id=2, price_level=150.0, type="resistance", strength=40), ] reps = _zone_representative_levels(levels, entry_price=100.0) assert len(reps) == 2 assert {round(r.price_level) for r in reps} == {120, 150}