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
+102 -76
View File
@@ -127,58 +127,45 @@ class DirectionAnalyzer:
sentiment_classification: str | None,
conflicts: list[str] | None = None,
) -> float:
confidence = 50.0
"""Directional-agreement confidence around a 50 baseline.
Each dimension contributes in proportion to how strongly it agrees
with the proposed direction: a bullish dimension RAISES long confidence
and LOWERS short confidence (and vice-versa). Signals that oppose the
direction push confidence below 50 — so a short on a strongly bullish
stock scores near zero, not 55.
"""
technical = float(dimension_scores.get("technical", 50.0))
momentum = float(dimension_scores.get("momentum", 50.0))
fundamental = float(dimension_scores.get("fundamental", 50.0))
sentiment = _sentiment_value(sentiment_classification)
dir_sign = 1.0 if direction == "long" else -1.0
if direction == "long":
if technical > 70:
confidence += 25.0
elif technical > 60:
confidence += 15.0
def agree(score: float) -> float:
# -1 (fully against) .. +1 (fully for) the proposed direction
return ((score - 50.0) / 50.0) * dir_sign
if momentum > 70:
confidence += 20.0
elif momentum > 60:
confidence += 15.0
sentiment_val = {"bullish": 1.0, "bearish": -1.0}.get(sentiment or "", 0.0)
sentiment_agree = sentiment_val * dir_sign
if sentiment == "bullish":
confidence += 15.0
elif sentiment == "neutral":
confidence += 5.0
if fundamental > 60:
confidence += 10.0
else:
if technical < 30:
confidence += 25.0
elif technical < 40:
confidence += 15.0
if momentum < 30:
confidence += 20.0
elif momentum < 40:
confidence += 15.0
if sentiment == "bearish":
confidence += 15.0
elif sentiment == "neutral":
confidence += 5.0
if fundamental < 40:
confidence += 10.0
confidence = 50.0 + (
agree(technical) * 25.0
+ agree(momentum) * 20.0
+ sentiment_agree * 15.0
+ agree(fundamental) * 10.0
)
# Explicit conflict patterns trim a little more (the agreement terms
# already capture most disagreement, so penalties are modest).
for conflict in conflicts or []:
if "sentiment-technical" in conflict:
confidence -= 20.0
confidence -= 12.0
elif "momentum-technical" in conflict:
confidence -= 15.0
elif "sentiment-momentum" in conflict:
confidence -= 20.0
elif "fundamental-technical" in conflict:
confidence -= 10.0
elif "sentiment-momentum" in conflict:
confidence -= 12.0
elif "fundamental-technical" in conflict:
confidence -= 6.0
return _clamp(confidence, 0.0, 100.0)
@@ -377,53 +364,83 @@ def _choose_recommended_action(
long_confidence: float,
short_confidence: float,
config: dict[str, float],
available_directions: set[str] | None = None,
) -> str:
"""Pick the ticker action — but only recommend a direction you can trade.
A direction is recommendable only if a tradeable setup exists for it
(``available_directions``). So a strong LONG bias on a stock at all-time
highs — where the scanner can build no long target — does NOT yield
LONG_HIGH; it falls through to NEUTRAL, and the reasoning explains why.
"""
high = float(config.get("recommendation_high_confidence_threshold", 70.0))
moderate = float(config.get("recommendation_moderate_confidence_threshold", 50.0))
diff = float(config.get("recommendation_confidence_diff_threshold", 20.0))
if long_confidence >= high and (long_confidence - short_confidence) >= diff:
long_ok = available_directions is None or "long" in available_directions
short_ok = available_directions is None or "short" in available_directions
if long_ok and long_confidence >= high and (long_confidence - short_confidence) >= diff:
return "LONG_HIGH"
if short_confidence >= high and (short_confidence - long_confidence) >= diff:
if short_ok and short_confidence >= high and (short_confidence - long_confidence) >= diff:
return "SHORT_HIGH"
if long_confidence >= moderate and (long_confidence - short_confidence) >= diff:
if long_ok and long_confidence >= moderate and (long_confidence - short_confidence) >= diff:
return "LONG_MODERATE"
if short_confidence >= moderate and (short_confidence - long_confidence) >= diff:
if short_ok and short_confidence >= moderate and (short_confidence - long_confidence) >= diff:
return "SHORT_MODERATE"
return "NEUTRAL"
def _build_reasoning(
direction: str,
confidence: float,
action: str,
long_confidence: float,
short_confidence: float,
conflicts: list[str],
dimension_scores: dict[str, float],
sentiment_classification: str | None,
action: str,
config: dict[str, float],
available_directions: set[str] | None = None,
) -> str:
aligned, alignment_text = check_signal_alignment(
direction,
dimension_scores,
sentiment_classification,
)
"""Ticker-level reasoning that always matches the recommended action.
Stored identically on both setups so the displayed summary can never mix a
SHORT setup's reasoning under a LONG action.
"""
sentiment = _sentiment_value(sentiment_classification) or "unknown"
technical = float(dimension_scores.get("technical", 50.0))
momentum = float(dimension_scores.get("momentum", 50.0))
signals = f"technical={technical:.0f}, momentum={momentum:.0f}, sentiment={sentiment}"
conflict_note = f" {len(conflicts)} conflict(s) detected, risk-adjusted." if conflicts else ""
direction_text = direction.upper()
alignment_summary = "aligned" if aligned else "mixed"
base = (
f"{direction_text} confidence {confidence:.1f}% with {alignment_summary} signals "
f"(technical={technical:.0f}, momentum={momentum:.0f}, sentiment={sentiment})."
)
if conflicts:
if action != "NEUTRAL":
direction = "long" if action.startswith("LONG") else "short"
tier = "high" if action.endswith("HIGH") else "moderate"
confidence = long_confidence if direction == "long" else short_confidence
aligned, _ = check_signal_alignment(direction, dimension_scores, sentiment_classification)
return (
f"{base} {alignment_text} Detected {len(conflicts)} conflict(s), "
f"so recommendation is risk-adjusted. Action={action}."
f"{direction.upper()} ({tier} confidence): {confidence:.0f}% with "
f"{'aligned' if aligned else 'mixed'} signals ({signals}).{conflict_note}"
)
return f"{base} {alignment_text} No major conflicts detected. Action={action}."
# NEUTRAL — explain whether it's a missing setup or genuinely mixed signals.
moderate = float(config.get("recommendation_moderate_confidence_threshold", 50.0))
avail = available_directions if available_directions is not None else {"long", "short"}
bias_dir = "long" if long_confidence >= short_confidence else "short"
bias_conf = max(long_confidence, short_confidence)
if bias_conf >= moderate and bias_dir not in avail:
other = "short" if bias_dir == "long" else "long"
extreme = "highs (no resistance target above)" if bias_dir == "long" else "lows (no support target below)"
return (
f"Ticker bias is {bias_dir.upper()} (confidence {bias_conf:.0f}%, {signals}) but price is "
f"extended near {extreme}, so no high-conviction {bias_dir} setup is available. "
f"The available {other.upper()} setup is counter-trend.{conflict_note}"
)
return (
f"No high-conviction setup: LONG {long_confidence:.0f}%, SHORT {short_confidence:.0f}% "
f"({signals}).{conflict_note}"
)
async def enhance_trade_setup(
@@ -434,6 +451,7 @@ async def enhance_trade_setup(
sr_levels: list[SRLevel],
sentiment_classification: str | None,
atr_value: float,
available_directions: set[str] | None = None,
) -> TradeSetup:
config = await get_recommendation_config(db)
@@ -476,24 +494,32 @@ async def enhance_trade_setup(
config=config,
)
# Per-setup conflicts (target availability is specific to this setup)
setup_conflicts = list(conflicts)
if len(targets) < 3:
conflicts = [*conflicts, "target-availability: Fewer than 3 valid S/R targets available"]
setup_conflicts.append("target-availability: Fewer than 3 valid S/R targets available")
action = _choose_recommended_action(long_confidence, short_confidence, config)
risk_level = _risk_level_from_conflicts(conflicts)
setup.confidence_score = round(confidence, 2)
setup.targets_json = json.dumps(targets)
setup.conflict_flags_json = json.dumps(conflicts)
setup.recommended_action = action
setup.reasoning = _build_reasoning(
direction=direction,
confidence=confidence,
# Action and reasoning are ticker-level: they consider both directions and
# which directions are actually tradeable, and are identical on every setup.
action = _choose_recommended_action(
long_confidence, short_confidence, config, available_directions
)
reasoning = _build_reasoning(
action=action,
long_confidence=long_confidence,
short_confidence=short_confidence,
conflicts=conflicts,
dimension_scores=dimension_scores,
sentiment_classification=sentiment_classification,
action=action,
config=config,
available_directions=available_directions,
)
setup.risk_level = risk_level
setup.confidence_score = round(confidence, 2)
setup.targets_json = json.dumps(targets)
setup.conflict_flags_json = json.dumps(setup_conflicts)
setup.recommended_action = action
setup.reasoning = reasoning
setup.risk_level = _risk_level_from_conflicts(setup_conflicts)
return setup