diff --git a/Dockerfile b/Dockerfile index 6b7eb51..2398a69 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,6 @@ COPY pyproject.toml ./ RUN pip install --no-cache-dir . && \ pip install --no-cache-dir openbb-quantitative openbb-econometrics openbb-technical && \ - pip install --no-cache-dir --upgrade curl_cffi && \ apt-get purge -y gcc g++ libssl-dev && \ apt-get autoremove -y diff --git a/macro_service.py b/macro_service.py index 06c5e89..6a4f4b4 100644 --- a/macro_service.py +++ b/macro_service.py @@ -36,19 +36,31 @@ def _to_dicts(result: Any) -> list[dict[str, Any]]: return [vars(result.results)] -async def get_series(series_id: str, limit: int = 10) -> list[dict[str, Any]]: +async def get_series( + series_id: str, limit: int = 10, latest: bool = False, +) -> list[dict[str, Any]]: """Get a FRED time series by ID.""" try: + fetch_limit = limit if not latest else None + kwargs: dict[str, Any] = { + "symbol": series_id, + "provider": PROVIDER, + } + if fetch_limit is not None: + kwargs["limit"] = fetch_limit result = await asyncio.to_thread( obb.economy.fred_series, - symbol=series_id, - limit=limit, - provider=PROVIDER, + **kwargs, ) items = _to_dicts(result) - for item in items: - if "date" in item and not isinstance(item["date"], str): - item = {**item, "date": str(item["date"])} + items = [ + {**item, "date": str(item["date"])} + if "date" in item and not isinstance(item["date"], str) + else item + for item in items + ] + if latest: + items = items[-limit:] return items except Exception: logger.warning("Failed to fetch FRED series %s", series_id, exc_info=True) @@ -58,20 +70,22 @@ async def get_series(series_id: str, limit: int = 10) -> list[dict[str, Any]]: async def get_macro_overview() -> dict[str, Any]: """Get a summary of key macro indicators.""" tasks = { - name: get_series(series_id, limit=1) + name: get_series(series_id, limit=1, latest=True) for name, series_id in SERIES.items() } results = await asyncio.gather(*tasks.values(), return_exceptions=True) overview: dict[str, Any] = {} - for name, result in zip(tasks.keys(), results): + for (name, series_id), result in zip(SERIES.items(), results): if isinstance(result, BaseException): logger.warning("Failed to fetch %s: %s", name, result) overview[name] = None elif result and len(result) > 0: - entry = result[0] + entry = result[-1] + # FRED returns values keyed by series ID, not "value" + value = entry.get(series_id) or entry.get("value") overview[name] = { - "value": entry.get("value"), + "value": value, "date": str(entry.get("date", "")), } else: diff --git a/main.py b/main.py index b2549bb..319c9bc 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,27 @@ import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +# Patch curl_cffi to use safari TLS fingerprint instead of chrome. +# curl_cffi's chrome impersonation triggers BoringSSL SSL_ERROR_SYSCALL on +# some networks; safari works reliably. This must happen before any import +# that creates a curl_cffi Session (yfinance, openbb). +import curl_cffi.requests as _cffi_requests + +_orig_session_init = _cffi_requests.Session.__init__ + +def _patched_session_init(self, *args, **kwargs): + if kwargs.get("impersonate") == "chrome": + kwargs["impersonate"] = "safari" + _orig_session_init(self, *args, **kwargs) + +_cffi_requests.Session.__init__ = _patched_session_init + +from openbb import obb from config import settings + +# Register optional provider credentials with OpenBB +if settings.fred_api_key: + obb.user.credentials.fred_api_key = settings.fred_api_key from routes import router from routes_sentiment import router as sentiment_router from routes_macro import router as macro_router diff --git a/openbb_service.py b/openbb_service.py index f6f5d92..c5d4d87 100644 --- a/openbb_service.py +++ b/openbb_service.py @@ -171,3 +171,30 @@ async def get_growth() -> list[dict]: obb.equity.discovery.growth_tech, provider=PROVIDER ) return _to_dicts(result) + + +async def get_upgrades_downgrades(symbol: str, limit: int = 20) -> list[dict]: + """Get analyst upgrades/downgrades via yfinance.""" + import yfinance as yf + + def _fetch() -> list[dict[str, Any]]: + t = yf.Ticker(symbol) + df = t.upgrades_downgrades + if df is None or df.empty: + return [] + df = df.head(limit).reset_index() + return [ + { + "date": str(row.get("GradeDate", "")), + "company": row.get("Firm"), + "action": row.get("Action"), + "from_grade": row.get("FromGrade"), + "to_grade": row.get("ToGrade"), + "price_target_action": row.get("priceTargetAction"), + "current_price_target": row.get("currentPriceTarget"), + "prior_price_target": row.get("priorPriceTarget"), + } + for _, row in df.iterrows() + ] + + return await asyncio.to_thread(_fetch) diff --git a/pyproject.toml b/pyproject.toml index 127a519..b90e012 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "openbb[yfinance]", "pydantic-settings", "httpx", + "curl_cffi==0.7.4", ] [project.optional-dependencies] diff --git a/routes_sentiment.py b/routes_sentiment.py index 8caa71b..e20ff13 100644 --- a/routes_sentiment.py +++ b/routes_sentiment.py @@ -8,6 +8,7 @@ from models import ApiResponse from route_utils import safe, validate_symbol import alphavantage_service import finnhub_service +import openbb_service import logging @@ -96,17 +97,7 @@ async def stock_recommendations(symbol: str = Path(..., min_length=1, max_length @router.get("/stock/{symbol}/upgrades", response_model=ApiResponse) @safe async def stock_upgrades(symbol: str = Path(..., min_length=1, max_length=20)): - """Get recent analyst upgrades and downgrades.""" + """Get recent analyst upgrades and downgrades (via yfinance).""" symbol = validate_symbol(symbol) - raw = await finnhub_service.get_upgrade_downgrade(symbol) - upgrades = [ - { - "company": u.get("company"), - "action": u.get("action"), - "from_grade": u.get("fromGrade"), - "to_grade": u.get("toGrade"), - "date": u.get("gradeTime"), - } - for u in raw[:20] - ] - return ApiResponse(data=upgrades) + data = await openbb_service.get_upgrades_downgrades(symbol) + return ApiResponse(data=data)