Compare commits
9 Commits
e797f8929d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac101c663a | ||
|
|
f5b22deec3 | ||
|
|
b631c888a5 | ||
|
|
760b0a09ea | ||
|
|
16ad276146 | ||
|
|
e5820ebe4a | ||
|
|
cd6158b05c | ||
|
|
2446a2fde8 | ||
|
|
d3c919385f |
@@ -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"]
|
||||||
@@ -6,7 +6,7 @@ metadata:
|
|||||||
rules:
|
rules:
|
||||||
- apiGroups: ["apps"]
|
- apiGroups: ["apps"]
|
||||||
resources: ["deployments"]
|
resources: ["deployments"]
|
||||||
verbs: ["get", "patch"]
|
verbs: ["get", "list", "patch", "update"]
|
||||||
---
|
---
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
kind: RoleBinding
|
kind: RoleBinding
|
||||||
@@ -17,6 +17,12 @@ subjects:
|
|||||||
- kind: ServiceAccount
|
- kind: ServiceAccount
|
||||||
name: default
|
name: default
|
||||||
namespace: drone
|
namespace: drone
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: drone
|
||||||
|
namespace: drone
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: drone-runner-drone-runner-kube
|
||||||
|
namespace: drone
|
||||||
roleRef:
|
roleRef:
|
||||||
kind: Role
|
kind: Role
|
||||||
name: drone-deploy
|
name: drone-deploy
|
||||||
|
|||||||
@@ -9,5 +9,4 @@ resources:
|
|||||||
- deployment.yaml
|
- deployment.yaml
|
||||||
- service.yaml
|
- service.yaml
|
||||||
- ingress.yaml
|
- ingress.yaml
|
||||||
- drone-rbac.yaml
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
20
main.py
@@ -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
|
||||||
|
|||||||
@@ -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="fmp"
|
|
||||||
)
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user