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
+45 -24
View File
@@ -202,8 +202,18 @@ class AlphaVantageFundamentalProvider:
)
_FUNDAMENTAL_FIELDS = ("pe_ratio", "revenue_growth", "earnings_surprise", "market_cap")
class ChainedFundamentalProvider:
"""Try multiple fundamental providers in order until one succeeds."""
"""Merge fundamentals across providers, filling gaps from later sources.
A single provider rarely covers everything on free tiers — FMP's free plan,
for example, returns only market cap (the ratios/growth/earnings endpoints
402). Rather than stop at the first provider with *any* field, we take each
field from the first provider that supplies it, so FMP's market cap is
combined with Finnhub's P/E and earnings surprise.
"""
def __init__(self, providers: list[tuple[str, FundamentalProvider]]) -> None:
if not providers:
@@ -211,37 +221,48 @@ class ChainedFundamentalProvider:
self._providers = providers
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
merged: dict[str, float | None] = {f: None for f in _FUNDAMENTAL_FIELDS}
field_source: dict[str, str] = {}
errors: list[str] = []
for provider_name, provider in self._providers:
if all(merged[f] is not None for f in _FUNDAMENTAL_FIELDS):
break
try:
data = await provider.fetch_fundamentals(ticker)
has_any_metric = any(
value is not None
for value in (data.pe_ratio, data.revenue_growth, data.earnings_surprise, data.market_cap)
)
if not has_any_metric:
errors.append(f"{provider_name}: no usable metrics returned")
continue
unavailable = dict(data.unavailable_fields)
unavailable["provider"] = provider_name
return FundamentalData(
ticker=data.ticker,
pe_ratio=data.pe_ratio,
revenue_growth=data.revenue_growth,
earnings_surprise=data.earnings_surprise,
market_cap=data.market_cap,
fetched_at=data.fetched_at,
unavailable_fields=unavailable,
)
except Exception as exc:
errors.append(f"{provider_name}: {type(exc).__name__}: {exc}")
continue
attempts = "; ".join(errors[:6]) if errors else "no provider attempts"
raise ProviderError(f"All fundamentals providers failed for {ticker}. Attempts: {attempts}")
for field in _FUNDAMENTAL_FIELDS:
if merged[field] is None:
value = getattr(data, field)
if value is not None:
merged[field] = value
field_source[field] = provider_name
if all(merged[f] is None for f in _FUNDAMENTAL_FIELDS):
attempts = "; ".join(errors[:6]) if errors else "no usable metrics from any provider"
raise ProviderError(f"All fundamentals providers failed for {ticker}. Attempts: {attempts}")
unavailable: dict[str, str] = {
field: "not available from any configured provider"
for field in _FUNDAMENTAL_FIELDS
if merged[field] is None
}
# Record which provider supplied each field for transparency.
for field, src in field_source.items():
unavailable[f"source_{field}"] = src
return FundamentalData(
ticker=ticker,
pe_ratio=merged["pe_ratio"],
revenue_growth=merged["revenue_growth"],
earnings_surprise=merged["earnings_surprise"],
market_cap=merged["market_cap"],
fetched_at=datetime.now(timezone.utc),
unavailable_fields=unavailable,
)
def build_fundamental_provider_chain() -> FundamentalProvider: