Fix scoring/recommendation correctness and calibration
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 32s
Deploy / deploy (push) Successful in 22s

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>
This commit is contained in:
2026-06-13 15:34:36 +02:00
parent ffb609d38f
commit d3eb8a2b97
9 changed files with 269 additions and 108 deletions
+29 -1
View File
@@ -56,7 +56,35 @@ async def test_chained_provider_uses_fallback_provider_on_primary_failure():
assert result.pe_ratio == 25.0
assert result.market_cap == 1_000_000.0
assert result.unavailable_fields.get("provider") == "fallback"
assert result.unavailable_fields.get("source_pe_ratio") == "fallback"
@pytest.mark.asyncio
async def test_chained_provider_merges_fields_across_providers():
"""Primary supplies only market cap; fallback fills P/E and earnings."""
primary_data = FundamentalData(
ticker="AAPL", pe_ratio=None, revenue_growth=None, earnings_surprise=None,
market_cap=2_000_000.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={},
)
fallback_data = FundamentalData(
ticker="AAPL", pe_ratio=18.0, revenue_growth=12.0, earnings_surprise=4.0,
market_cap=999.0, fetched_at=datetime.now(timezone.utc), unavailable_fields={},
)
provider = ChainedFundamentalProvider([
("fmp", _DataProvider(primary_data)),
("finnhub", _DataProvider(fallback_data)),
])
result = await provider.fetch_fundamentals("AAPL")
# market cap from primary (first to supply it), the rest from fallback
assert result.market_cap == 2_000_000.0
assert result.pe_ratio == 18.0
assert result.revenue_growth == 12.0
assert result.earnings_surprise == 4.0
assert result.unavailable_fields.get("source_market_cap") == "fmp"
assert result.unavailable_fields.get("source_pe_ratio") == "finnhub"
@pytest.mark.asyncio
+11
View File
@@ -89,6 +89,17 @@ class TestComputeRSI:
with pytest.raises(ValidationError, match="RSI requires"):
compute_rsi([100.0] * 5)
def test_overbought_rsi_is_penalized_not_maximal(self):
"""RSI 100 (extreme overbought) must NOT score near 100."""
from app.services.indicator_service import _rsi_to_score
assert _rsi_to_score(100.0) < 40.0 # overbought penalized
assert _rsi_to_score(90.0) < _rsi_to_score(60.0) # extreme < healthy
assert _rsi_to_score(60.0) > 80.0 # healthy momentum rewarded
# All gains → RSI 100 → low score, not 100
result = compute_rsi(_rising_closes(20, step=1))
assert result["score"] < 40.0
# ---------------------------------------------------------------------------
# ATR
+50
View File
@@ -3,12 +3,20 @@ 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:
@@ -52,6 +60,48 @@ def test_high_confidence_short_example():
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},