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>
285 lines
12 KiB
Python
285 lines
12 KiB
Python
"""Chained fundamentals provider with fallback adapters.
|
|
|
|
Order:
|
|
1) FMP (if configured)
|
|
2) Finnhub (if configured)
|
|
3) Alpha Vantage (if configured)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
|
|
from app.config import settings
|
|
from app.exceptions import ProviderError, RateLimitError
|
|
from app.providers.fmp import FMPFundamentalProvider
|
|
from app.providers.protocol import FundamentalData, FundamentalProvider
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
|
|
if not _CA_BUNDLE or not Path(_CA_BUNDLE).exists():
|
|
_CA_BUNDLE_PATH: str | bool = True
|
|
else:
|
|
_CA_BUNDLE_PATH = _CA_BUNDLE
|
|
|
|
|
|
def _safe_float(value: object) -> float | None:
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _to_api_symbol(symbol: str) -> str:
|
|
"""Convert internal symbol format (BRK-B) to API format (BRK.B).
|
|
|
|
Finnhub and Alpha Vantage use dot-separated share class notation.
|
|
"""
|
|
return symbol.replace("-", ".")
|
|
|
|
|
|
class FinnhubFundamentalProvider:
|
|
"""Fundamentals provider backed by Finnhub free endpoints."""
|
|
|
|
def __init__(self, api_key: str) -> None:
|
|
if not api_key:
|
|
raise ProviderError("Finnhub API key is required")
|
|
self._api_key = api_key
|
|
self._base_url = "https://finnhub.io/api/v1"
|
|
|
|
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
|
|
unavailable: dict[str, str] = {}
|
|
api_symbol = _to_api_symbol(ticker)
|
|
|
|
async with httpx.AsyncClient(timeout=30.0, verify=_CA_BUNDLE_PATH) as client:
|
|
profile_resp = await client.get(
|
|
f"{self._base_url}/stock/profile2",
|
|
params={"symbol": api_symbol, "token": self._api_key},
|
|
)
|
|
metric_resp = await client.get(
|
|
f"{self._base_url}/stock/metric",
|
|
params={"symbol": api_symbol, "metric": "all", "token": self._api_key},
|
|
)
|
|
earnings_resp = await client.get(
|
|
f"{self._base_url}/stock/earnings",
|
|
params={"symbol": api_symbol, "limit": 1, "token": self._api_key},
|
|
)
|
|
|
|
for resp, endpoint in (
|
|
(profile_resp, "profile2"),
|
|
(metric_resp, "stock/metric"),
|
|
(earnings_resp, "stock/earnings"),
|
|
):
|
|
if resp.status_code == 429:
|
|
raise RateLimitError(f"Finnhub rate limit hit for {ticker} ({endpoint})")
|
|
if resp.status_code in (401, 403):
|
|
raise ProviderError(f"Finnhub access denied for {ticker} ({endpoint}): HTTP {resp.status_code}")
|
|
if resp.status_code != 200:
|
|
raise ProviderError(f"Finnhub error for {ticker} ({endpoint}): HTTP {resp.status_code}")
|
|
|
|
profile_payload = profile_resp.json() if profile_resp.text else {}
|
|
metric_payload = metric_resp.json() if metric_resp.text else {}
|
|
earnings_payload = earnings_resp.json() if earnings_resp.text else []
|
|
|
|
metrics = metric_payload.get("metric", {}) if isinstance(metric_payload, dict) else {}
|
|
market_cap = _safe_float((profile_payload or {}).get("marketCapitalization"))
|
|
pe_ratio = _safe_float(metrics.get("peTTM") or metrics.get("peNormalizedAnnual"))
|
|
revenue_growth = _safe_float(metrics.get("revenueGrowthTTMYoy") or metrics.get("revenueGrowth5Y"))
|
|
|
|
earnings_surprise = None
|
|
if isinstance(earnings_payload, list) and earnings_payload:
|
|
first = earnings_payload[0] if isinstance(earnings_payload[0], dict) else {}
|
|
earnings_surprise = _safe_float(first.get("surprisePercent"))
|
|
|
|
if pe_ratio is None:
|
|
unavailable["pe_ratio"] = "not available from provider payload"
|
|
if revenue_growth is None:
|
|
unavailable["revenue_growth"] = "not available from provider payload"
|
|
if earnings_surprise is None:
|
|
unavailable["earnings_surprise"] = "not available from provider payload"
|
|
if market_cap is None:
|
|
unavailable["market_cap"] = "not available from provider payload"
|
|
|
|
return FundamentalData(
|
|
ticker=ticker,
|
|
pe_ratio=pe_ratio,
|
|
revenue_growth=revenue_growth,
|
|
earnings_surprise=earnings_surprise,
|
|
market_cap=market_cap,
|
|
fetched_at=datetime.now(timezone.utc),
|
|
unavailable_fields=unavailable,
|
|
)
|
|
|
|
|
|
class AlphaVantageFundamentalProvider:
|
|
"""Fundamentals provider backed by Alpha Vantage free endpoints."""
|
|
|
|
def __init__(self, api_key: str) -> None:
|
|
if not api_key:
|
|
raise ProviderError("Alpha Vantage API key is required")
|
|
self._api_key = api_key
|
|
self._base_url = "https://www.alphavantage.co/query"
|
|
|
|
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
|
|
unavailable: dict[str, str] = {}
|
|
api_symbol = _to_api_symbol(ticker)
|
|
|
|
async with httpx.AsyncClient(timeout=30.0, verify=_CA_BUNDLE_PATH) as client:
|
|
overview_resp = await client.get(
|
|
self._base_url,
|
|
params={"function": "OVERVIEW", "symbol": api_symbol, "apikey": self._api_key},
|
|
)
|
|
earnings_resp = await client.get(
|
|
self._base_url,
|
|
params={"function": "EARNINGS", "symbol": api_symbol, "apikey": self._api_key},
|
|
)
|
|
income_resp = await client.get(
|
|
self._base_url,
|
|
params={"function": "INCOME_STATEMENT", "symbol": api_symbol, "apikey": self._api_key},
|
|
)
|
|
|
|
for resp, endpoint in (
|
|
(overview_resp, "OVERVIEW"),
|
|
(earnings_resp, "EARNINGS"),
|
|
(income_resp, "INCOME_STATEMENT"),
|
|
):
|
|
if resp.status_code == 429:
|
|
raise RateLimitError(f"Alpha Vantage rate limit hit for {ticker} ({endpoint})")
|
|
if resp.status_code != 200:
|
|
raise ProviderError(f"Alpha Vantage error for {ticker} ({endpoint}): HTTP {resp.status_code}")
|
|
|
|
overview = overview_resp.json() if overview_resp.text else {}
|
|
earnings = earnings_resp.json() if earnings_resp.text else {}
|
|
income = income_resp.json() if income_resp.text else {}
|
|
|
|
if isinstance(overview, dict) and overview.get("Information"):
|
|
raise ProviderError(f"Alpha Vantage unavailable for {ticker}: {overview.get('Information')}")
|
|
if isinstance(overview, dict) and overview.get("Note"):
|
|
raise RateLimitError(f"Alpha Vantage rate limit for {ticker}: {overview.get('Note')}")
|
|
|
|
pe_ratio = _safe_float((overview or {}).get("PERatio"))
|
|
market_cap = _safe_float((overview or {}).get("MarketCapitalization"))
|
|
|
|
earnings_surprise = None
|
|
quarterly = earnings.get("quarterlyEarnings", []) if isinstance(earnings, dict) else []
|
|
if isinstance(quarterly, list) and quarterly:
|
|
first = quarterly[0] if isinstance(quarterly[0], dict) else {}
|
|
earnings_surprise = _safe_float(first.get("surprisePercentage"))
|
|
|
|
revenue_growth = None
|
|
annual = income.get("annualReports", []) if isinstance(income, dict) else []
|
|
if isinstance(annual, list) and len(annual) >= 2:
|
|
curr = _safe_float((annual[0] or {}).get("totalRevenue"))
|
|
prev = _safe_float((annual[1] or {}).get("totalRevenue"))
|
|
if curr is not None and prev not in (None, 0):
|
|
revenue_growth = ((curr - prev) / abs(prev)) * 100.0
|
|
|
|
if pe_ratio is None:
|
|
unavailable["pe_ratio"] = "not available from provider payload"
|
|
if revenue_growth is None:
|
|
unavailable["revenue_growth"] = "not available from provider payload"
|
|
if earnings_surprise is None:
|
|
unavailable["earnings_surprise"] = "not available from provider payload"
|
|
if market_cap is None:
|
|
unavailable["market_cap"] = "not available from provider payload"
|
|
|
|
return FundamentalData(
|
|
ticker=ticker,
|
|
pe_ratio=pe_ratio,
|
|
revenue_growth=revenue_growth,
|
|
earnings_surprise=earnings_surprise,
|
|
market_cap=market_cap,
|
|
fetched_at=datetime.now(timezone.utc),
|
|
unavailable_fields=unavailable,
|
|
)
|
|
|
|
|
|
_FUNDAMENTAL_FIELDS = ("pe_ratio", "revenue_growth", "earnings_surprise", "market_cap")
|
|
|
|
|
|
class ChainedFundamentalProvider:
|
|
"""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:
|
|
raise ProviderError("No fundamental providers configured")
|
|
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)
|
|
except Exception as exc:
|
|
errors.append(f"{provider_name}: {type(exc).__name__}: {exc}")
|
|
continue
|
|
|
|
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:
|
|
providers: list[tuple[str, FundamentalProvider]] = []
|
|
|
|
if settings.fmp_api_key:
|
|
providers.append(("fmp", FMPFundamentalProvider(settings.fmp_api_key)))
|
|
if settings.finnhub_api_key:
|
|
providers.append(("finnhub", FinnhubFundamentalProvider(settings.finnhub_api_key)))
|
|
if settings.alpha_vantage_api_key:
|
|
providers.append(("alpha_vantage", AlphaVantageFundamentalProvider(settings.alpha_vantage_api_key)))
|
|
|
|
if not providers:
|
|
raise ProviderError(
|
|
"No fundamentals provider configured. Set one of FMP_API_KEY, FINNHUB_API_KEY, ALPHA_VANTAGE_API_KEY"
|
|
)
|
|
|
|
logger.info("Fundamentals provider chain configured: %s", [name for name, _ in providers])
|
|
return ChainedFundamentalProvider(providers)
|