d3eb8a2b97
Triggered by CNC showing "LONG (High Confidence)" with SHORT reasoning and no long setup. - A: recommendation action + reasoning are ticker-level and identical on both setups; reasoning always matches the shown action - B: recommended_action only picks a direction with a tradeable setup; strong bias with no setup (e.g. price at ATH) → NEUTRAL with an explanatory reason instead of a fake LONG_HIGH - C: confidence is a directional-agreement model — opposing signals push it below 50 (SHORT on a 92-technical/99-momentum stock ~0%, not 55%) - D: fundamental score requires >=2 real metrics (market-cap-only no longer yields a high score) - E: RSI score peaks at healthy momentum (~60) and penalizes overbought/oversold extremes instead of treating RSI 90 as maximal - F: fundamentals chain merges fields across providers (FMP market cap + Finnhub P/E) instead of stopping at the first with any field - NEUTRAL label: "No Clear Setup" (covers untradeable-bias case) Scores recompute on next scan/scoring run; C and E shift score distributions intentionally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
181 lines
5.3 KiB
Python
181 lines
5.3 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from app.services.recommendation_service import (
|
|
_build_reasoning,
|
|
_choose_recommended_action,
|
|
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_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_ranges_by_classification():
|
|
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}
|
|
|
|
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,
|
|
)
|
|
|
|
assert conservative > 60
|
|
assert 40 <= moderate <= 70
|
|
assert aggressive < 50
|