Compare commits

...

11 Commits

Author SHA1 Message Date
Yaojia Wang
ac101c663a fix: switch price target from FMP to yfinance
All checks were successful
continuous-integration/drone/push Build is passing
FMP provider requires a paid API key. yfinance provides
targetMeanPrice in ticker.info for free.
2026-03-19 15:58:17 +01:00
Yaojia Wang
f5b22deec3 fix: resolve curl_cffi TLS errors and fix FRED/upgrades endpoints
All checks were successful
continuous-integration/drone/push Build is passing
- Pin curl_cffi==0.7.4 to avoid BoringSSL bug in 0.12-0.14
- Patch curl_cffi Session to use safari TLS fingerprint instead of
  chrome, which triggers SSL_ERROR_SYSCALL on some networks
- Register FRED API key with OpenBB credentials at startup
- Fix macro overview to return latest data instead of oldest, and
  extract values by FRED series ID key
- Replace Finnhub upgrades endpoint (premium-only) with yfinance
  upgrades_downgrades which includes price target changes
- Remove redundant curl_cffi upgrade from Dockerfile
2026-03-19 15:40:41 +01:00
Yaojia Wang
b631c888a5 fix: keep libssl3 runtime dependency to prevent TLS errors in container
All checks were successful
continuous-integration/drone/push Build is passing
autoremove was removing SSL runtime libraries after purging gcc/g++,
causing curl_cffi TLS handshake failures when connecting to Yahoo Finance.
Explicitly install libssl3 as runtime dep and only purge libssl-dev.
2026-03-19 13:25:58 +01:00
Yaojia Wang
760b0a09ea Trigger build
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-19 11:29:20 +01:00
Yaojia Wang
16ad276146 fix: add list and update verbs to drone RBAC for rollout restart
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-19 11:10:11 +01:00
Yaojia Wang
e5820ebe4a fix: add list and update verbs to drone RBAC for rollout restart
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-19 11:00:32 +01:00
Yaojia Wang
cd6158b05c fix: add list and update verbs to drone RBAC for rollout restart
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-19 10:56:52 +01:00
Yaojia Wang
2446a2fde8 fix: add list and update verbs to drone RBAC for rollout restart
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-19 10:44:35 +01:00
Yaojia Wang
d3c919385f fix: add libnss3 and upgrade curl_cffi to fix TLS/SSL errors
Some checks failed
continuous-integration/drone/push Build is failing
curl_cffi uses BoringSSL internally and needs libnss3 on Debian.
Also upgrade curl_cffi to latest version for compatibility.
2026-03-19 00:38:45 +01:00
Yaojia Wang
e797f8929d fix: use fmp provider for price_target endpoint
Some checks reported errors
continuous-integration/drone/push Build was killed
yfinance is not supported by obb.equity.estimates.price_target,
which only accepts benzinga or fmp.
2026-03-19 00:13:07 +01:00
Yaojia Wang
d46e8685d7 ci: add RBAC for drone to restart invest-api deployment 2026-03-18 23:50:11 +01:00
7 changed files with 119 additions and 33 deletions

View File

@@ -3,14 +3,16 @@ FROM python:3.12-slim AS base
WORKDIR /app WORKDIR /app
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends gcc g++ ca-certificates libssl-dev curl && \ apt-get install -y --no-install-recommends \
gcc g++ libssl-dev \
ca-certificates curl libnss3 libssl3 && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
COPY pyproject.toml ./ COPY pyproject.toml ./
RUN pip install --no-cache-dir . && \ RUN pip install --no-cache-dir . && \
pip install --no-cache-dir openbb-quantitative openbb-econometrics openbb-technical && \ pip install --no-cache-dir openbb-quantitative openbb-econometrics openbb-technical && \
apt-get purge -y gcc g++ && \ apt-get purge -y gcc g++ libssl-dev && \
apt-get autoremove -y apt-get autoremove -y
COPY *.py ./ COPY *.py ./
@@ -24,4 +26,4 @@ EXPOSE 8000
USER appuser USER appuser
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

29
k8s/base/drone-rbac.yaml Normal file
View File

@@ -0,0 +1,29 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: drone-deploy
namespace: invest-api
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "patch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: drone-deploy
namespace: invest-api
subjects:
- kind: ServiceAccount
name: default
namespace: drone
- kind: ServiceAccount
name: drone
namespace: drone
- kind: ServiceAccount
name: drone-runner-drone-runner-kube
namespace: drone
roleRef:
kind: Role
name: drone-deploy
apiGroup: rbac.authorization.k8s.io

View File

@@ -36,19 +36,31 @@ def _to_dicts(result: Any) -> list[dict[str, Any]]:
return [vars(result.results)] 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.""" """Get a FRED time series by ID."""
try: 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( result = await asyncio.to_thread(
obb.economy.fred_series, obb.economy.fred_series,
symbol=series_id, **kwargs,
limit=limit,
provider=PROVIDER,
) )
items = _to_dicts(result) items = _to_dicts(result)
for item in items: items = [
if "date" in item and not isinstance(item["date"], str): {**item, "date": str(item["date"])}
item = {**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 return items
except Exception: except Exception:
logger.warning("Failed to fetch FRED series %s", series_id, exc_info=True) 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]: async def get_macro_overview() -> dict[str, Any]:
"""Get a summary of key macro indicators.""" """Get a summary of key macro indicators."""
tasks = { tasks = {
name: get_series(series_id, limit=1) name: get_series(series_id, limit=1, latest=True)
for name, series_id in SERIES.items() for name, series_id in SERIES.items()
} }
results = await asyncio.gather(*tasks.values(), return_exceptions=True) results = await asyncio.gather(*tasks.values(), return_exceptions=True)
overview: dict[str, Any] = {} 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): if isinstance(result, BaseException):
logger.warning("Failed to fetch %s: %s", name, result) logger.warning("Failed to fetch %s: %s", name, result)
overview[name] = None overview[name] = None
elif result and len(result) > 0: 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] = { overview[name] = {
"value": entry.get("value"), "value": value,
"date": str(entry.get("date", "")), "date": str(entry.get("date", "")),
} }
else: else:

20
main.py
View File

@@ -4,7 +4,27 @@ import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware 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 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 import router
from routes_sentiment import router as sentiment_router from routes_sentiment import router as sentiment_router
from routes_macro import router as macro_router from routes_macro import router as macro_router

View File

@@ -104,13 +104,15 @@ async def get_financials(symbol: str) -> dict:
async def get_price_target(symbol: str) -> float | None: async def get_price_target(symbol: str) -> float | None:
"""Get consensus analyst price target via yfinance."""
import yfinance as yf
def _fetch() -> float | None:
t = yf.Ticker(symbol)
return t.info.get("targetMeanPrice")
try: try:
result = await asyncio.to_thread( return await asyncio.to_thread(_fetch)
obb.equity.estimates.price_target, symbol, provider=PROVIDER
)
items = _to_dicts(result)
if items:
return items[0].get("adj_price_target") or items[0].get("price_target")
except Exception: except Exception:
logger.warning("Failed to get price target for %s", symbol, exc_info=True) logger.warning("Failed to get price target for %s", symbol, exc_info=True)
return None return None
@@ -171,3 +173,30 @@ async def get_growth() -> list[dict]:
obb.equity.discovery.growth_tech, provider=PROVIDER obb.equity.discovery.growth_tech, provider=PROVIDER
) )
return _to_dicts(result) 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)

View File

@@ -9,6 +9,7 @@ dependencies = [
"openbb[yfinance]", "openbb[yfinance]",
"pydantic-settings", "pydantic-settings",
"httpx", "httpx",
"curl_cffi==0.7.4",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -8,6 +8,7 @@ from models import ApiResponse
from route_utils import safe, validate_symbol from route_utils import safe, validate_symbol
import alphavantage_service import alphavantage_service
import finnhub_service import finnhub_service
import openbb_service
import logging 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) @router.get("/stock/{symbol}/upgrades", response_model=ApiResponse)
@safe @safe
async def stock_upgrades(symbol: str = Path(..., min_length=1, max_length=20)): 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) symbol = validate_symbol(symbol)
raw = await finnhub_service.get_upgrade_downgrade(symbol) data = await openbb_service.get_upgrades_downgrades(symbol)
upgrades = [ return ApiResponse(data=data)
{
"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)