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
+18 -4
View File
@@ -156,11 +156,27 @@ def compute_ema(
}
def _rsi_to_score(rsi: float) -> float:
"""Map RSI to a 'healthy momentum' score, penalizing extremes.
Raw RSI as a score treats 90 as maximally bullish, but RSI 90 is extreme
overbought (exhaustion/reversal risk), not a green light. This peaks around
RSI 60 (healthy uptrend) and falls off toward both ends, with the overbought
side penalized harder than oversold (oversold can mean-revert upward).
"""
peak = 60.0
if rsi <= peak:
score = 90.0 - (peak - rsi) * 0.9 # 60→90, 30→63, 0→36
else:
score = 90.0 - (rsi - peak) * 1.6 # 60→90, 80→58, 90→42, 100→26
return max(0.0, min(100.0, score))
def compute_rsi(
closes: list[float],
period: int = 14,
) -> dict[str, Any]:
"""Compute RSI. Score = RSI value (already 0-100)."""
"""Compute RSI. Score is a peaked mapping (see _rsi_to_score), not raw RSI."""
n = len(closes)
if n < period + 1:
raise ValidationError(
@@ -184,12 +200,10 @@ def compute_rsi(
rs = avg_gain / avg_loss
rsi = 100.0 - 100.0 / (1.0 + rs)
score = max(0.0, min(100.0, rsi))
return {
"rsi": round(rsi, 4),
"period": period,
"score": round(score, 4),
"score": round(_rsi_to_score(rsi), 4),
}