Compare commits
13 Commits
d4e06c71b7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac101c663a | ||
|
|
f5b22deec3 | ||
|
|
b631c888a5 | ||
|
|
760b0a09ea | ||
|
|
16ad276146 | ||
|
|
e5820ebe4a | ||
|
|
cd6158b05c | ||
|
|
2446a2fde8 | ||
|
|
d3c919385f | ||
|
|
e797f8929d | ||
|
|
d46e8685d7 | ||
|
|
26cd716590 | ||
|
|
42f25426ac |
17
.drone.yml
17
.drone.yml
@@ -23,18 +23,7 @@ steps:
|
|||||||
--insecure
|
--insecure
|
||||||
--skip-tls-verify
|
--skip-tls-verify
|
||||||
|
|
||||||
- name: update-manifests
|
- name: restart-deployment
|
||||||
image: alpine/git:latest
|
image: bitnami/kubectl:latest
|
||||||
environment:
|
|
||||||
GITEA_TOKEN:
|
|
||||||
from_secret: gitea_token
|
|
||||||
commands:
|
commands:
|
||||||
- cd /drone/src
|
- kubectl rollout restart deploy/invest-api -n invest-api
|
||||||
- TAG=$(echo $DRONE_COMMIT_SHA | cut -c1-8)
|
|
||||||
- sed -i "s/newTag:.*/newTag: $TAG/" k8s/base/kustomization.yaml
|
|
||||||
- git config user.email "drone@colacoder.com"
|
|
||||||
- git config user.name "Drone CI"
|
|
||||||
- git add k8s/base/kustomization.yaml
|
|
||||||
- "git commit -m \"chore: update image tag to $TAG [skip ci]\""
|
|
||||||
- git remote set-url origin https://kai:$GITEA_TOKEN@git.colacoder.com/kai/openbb-invest-api.git
|
|
||||||
- git push origin main
|
|
||||||
|
|||||||
@@ -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
29
k8s/base/drone-rbac.yaml
Normal 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
|
||||||
@@ -10,6 +10,3 @@ resources:
|
|||||||
- service.yaml
|
- service.yaml
|
||||||
- ingress.yaml
|
- ingress.yaml
|
||||||
|
|
||||||
images:
|
|
||||||
- name: 192.168.68.11:30500/invest-api
|
|
||||||
newTag: latest
|
|
||||||
|
|||||||
@@ -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=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)
|
||||||
|
|||||||
@@ -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