first commit
Some checks failed
Deploy / lint (push) Failing after 7s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped

This commit is contained in:
Dennis Thiessen
2026-02-20 17:31:01 +01:00
commit 61ab24490d
160 changed files with 17034 additions and 0 deletions

View File

@@ -0,0 +1 @@

63
app/providers/alpaca.py Normal file
View File

@@ -0,0 +1,63 @@
"""Alpaca Markets OHLCV provider using the alpaca-py SDK."""
from __future__ import annotations
import asyncio
import logging
from datetime import date
from alpaca.data.historical import StockHistoricalDataClient
from alpaca.data.requests import StockBarsRequest
from alpaca.data.timeframe import TimeFrame
from app.exceptions import ProviderError, RateLimitError
from app.providers.protocol import OHLCVData
logger = logging.getLogger(__name__)
class AlpacaOHLCVProvider:
"""Fetches daily OHLCV bars from Alpaca Markets Data API."""
def __init__(self, api_key: str, api_secret: str) -> None:
if not api_key or not api_secret:
raise ProviderError("Alpaca API key and secret are required")
self._client = StockHistoricalDataClient(api_key, api_secret)
async def fetch_ohlcv(
self, ticker: str, start_date: date, end_date: date
) -> list[OHLCVData]:
"""Fetch daily OHLCV bars for *ticker* between *start_date* and *end_date*."""
try:
request = StockBarsRequest(
symbol_or_symbols=ticker,
timeframe=TimeFrame.Day,
start=start_date,
end=end_date,
)
# alpaca-py's client is synchronous — run in a thread
bars = await asyncio.to_thread(self._client.get_stock_bars, request)
results: list[OHLCVData] = []
bar_set = bars.get(ticker, []) if hasattr(bars, "get") else getattr(bars, "data", {}).get(ticker, [])
for bar in bar_set:
results.append(
OHLCVData(
ticker=ticker,
date=bar.timestamp.date(),
open=float(bar.open),
high=float(bar.high),
low=float(bar.low),
close=float(bar.close),
volume=int(bar.volume),
)
)
return results
except Exception as exc:
msg = str(exc).lower()
if "rate" in msg and "limit" in msg:
raise RateLimitError(f"Alpaca rate limit hit for {ticker}") from exc
logger.error("Alpaca provider error for %s: %s", ticker, exc)
raise ProviderError(f"Alpaca provider error for {ticker}: {exc}") from exc

94
app/providers/fmp.py Normal file
View File

@@ -0,0 +1,94 @@
"""Financial Modeling Prep (FMP) fundamentals provider using httpx."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
import httpx
from app.exceptions import ProviderError, RateLimitError
from app.providers.protocol import FundamentalData
logger = logging.getLogger(__name__)
_FMP_BASE_URL = "https://financialmodelingprep.com/api/v3"
class FMPFundamentalProvider:
"""Fetches fundamental data from Financial Modeling Prep REST API."""
def __init__(self, api_key: str) -> None:
if not api_key:
raise ProviderError("FMP API key is required")
self._api_key = api_key
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
"""Fetch P/E, revenue growth, earnings surprise, and market cap."""
try:
async with httpx.AsyncClient(timeout=30.0) as client:
profile = await self._fetch_profile(client, ticker)
earnings = await self._fetch_earnings_surprise(client, ticker)
pe_ratio = self._safe_float(profile.get("pe"))
revenue_growth = self._safe_float(profile.get("revenueGrowth"))
market_cap = self._safe_float(profile.get("mktCap"))
earnings_surprise = self._safe_float(earnings)
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),
)
except (ProviderError, RateLimitError):
raise
except Exception as exc:
logger.error("FMP provider error for %s: %s", ticker, exc)
raise ProviderError(f"FMP provider error for {ticker}: {exc}") from exc
async def _fetch_profile(self, client: httpx.AsyncClient, ticker: str) -> dict:
"""Fetch company profile (P/E, revenue growth, market cap)."""
url = f"{_FMP_BASE_URL}/profile/{ticker}"
resp = await client.get(url, params={"apikey": self._api_key})
self._check_response(resp, ticker, "profile")
data = resp.json()
if isinstance(data, list) and data:
return data[0]
return data if isinstance(data, dict) else {}
async def _fetch_earnings_surprise(
self, client: httpx.AsyncClient, ticker: str
) -> float | None:
"""Fetch the most recent earnings surprise percentage."""
url = f"{_FMP_BASE_URL}/earnings-surprises/{ticker}"
resp = await client.get(url, params={"apikey": self._api_key})
self._check_response(resp, ticker, "earnings-surprises")
data = resp.json()
if isinstance(data, list) and data:
return self._safe_float(data[0].get("actualEarningResult"))
return None
def _check_response(
self, resp: httpx.Response, ticker: str, endpoint: str
) -> None:
"""Raise appropriate errors for non-200 responses."""
if resp.status_code == 429:
raise RateLimitError(f"FMP rate limit hit for {ticker} ({endpoint})")
if resp.status_code != 200:
raise ProviderError(
f"FMP {endpoint} error for {ticker}: HTTP {resp.status_code}"
)
@staticmethod
def _safe_float(value: object) -> float | None:
"""Convert a value to float, returning None on failure."""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None

View File

@@ -0,0 +1,90 @@
"""Gemini sentiment provider using google-genai with search grounding."""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from google import genai
from google.genai import types
from app.exceptions import ProviderError, RateLimitError
from app.providers.protocol import SentimentData
logger = logging.getLogger(__name__)
_SENTIMENT_PROMPT = """\
Analyze the current market sentiment for the stock ticker {ticker}.
Search the web for recent news articles, social media mentions, and analyst opinions.
Respond ONLY with a JSON object in this exact format (no markdown, no extra text):
{{"classification": "<bullish|bearish|neutral>", "confidence": <0-100>, "reasoning": "<brief explanation>"}}
Rules:
- classification must be exactly one of: bullish, bearish, neutral
- confidence must be an integer from 0 to 100
- reasoning should be a brief one-sentence explanation
"""
VALID_CLASSIFICATIONS = {"bullish", "bearish", "neutral"}
class GeminiSentimentProvider:
"""Fetches sentiment analysis from Gemini with search grounding."""
def __init__(self, api_key: str, model: str = "gemini-2.0-flash") -> None:
if not api_key:
raise ProviderError("Gemini API key is required")
self._client = genai.Client(api_key=api_key)
self._model = model
async def fetch_sentiment(self, ticker: str) -> SentimentData:
"""Send a structured prompt to Gemini and parse the JSON response."""
try:
response = await self._client.aio.models.generate_content(
model=self._model,
contents=_SENTIMENT_PROMPT.format(ticker=ticker),
config=types.GenerateContentConfig(
tools=[types.Tool(google_search=types.GoogleSearch())],
response_mime_type="application/json",
),
)
raw_text = response.text.strip()
logger.debug("Gemini raw response for %s: %s", ticker, raw_text)
parsed = json.loads(raw_text)
classification = parsed.get("classification", "").lower()
if classification not in VALID_CLASSIFICATIONS:
raise ProviderError(
f"Invalid classification '{classification}' from Gemini for {ticker}"
)
confidence = int(parsed.get("confidence", 50))
confidence = max(0, min(100, confidence))
reasoning = parsed.get("reasoning", "")
if reasoning:
logger.info("Gemini sentiment for %s: %s (confidence=%d) — %s",
ticker, classification, confidence, reasoning)
return SentimentData(
ticker=ticker,
classification=classification,
confidence=confidence,
source="gemini",
timestamp=datetime.now(timezone.utc),
)
except json.JSONDecodeError as exc:
logger.error("Failed to parse Gemini JSON for %s: %s", ticker, exc)
raise ProviderError(f"Invalid JSON from Gemini for {ticker}") from exc
except ProviderError:
raise
except Exception as exc:
msg = str(exc).lower()
if "rate" in msg or "quota" in msg or "429" in msg:
raise RateLimitError(f"Gemini rate limit hit for {ticker}") from exc
logger.error("Gemini provider error for %s: %s", ticker, exc)
raise ProviderError(f"Gemini provider error for {ticker}: {exc}") from exc

84
app/providers/protocol.py Normal file
View File

@@ -0,0 +1,84 @@
"""Provider protocols and lightweight data transfer objects.
Protocols define the interface for external data providers.
DTOs are simple dataclasses — NOT SQLAlchemy models — used to
transfer data between providers and the service layer.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from typing import Protocol
# ---------------------------------------------------------------------------
# Data Transfer Objects
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class OHLCVData:
"""Lightweight OHLCV record returned by market data providers."""
ticker: str
date: date
open: float
high: float
low: float
close: float
volume: int
@dataclass(frozen=True, slots=True)
class SentimentData:
"""Sentiment analysis result returned by sentiment providers."""
ticker: str
classification: str # "bullish" | "bearish" | "neutral"
confidence: int # 0-100
source: str
timestamp: datetime
@dataclass(frozen=True, slots=True)
class FundamentalData:
"""Fundamental metrics returned by fundamental providers."""
ticker: str
pe_ratio: float | None
revenue_growth: float | None
earnings_surprise: float | None
market_cap: float | None
fetched_at: datetime
# ---------------------------------------------------------------------------
# Provider Protocols
# ---------------------------------------------------------------------------
class MarketDataProvider(Protocol):
"""Protocol for OHLCV market data providers."""
async def fetch_ohlcv(
self, ticker: str, start_date: date, end_date: date
) -> list[OHLCVData]:
"""Fetch OHLCV data for a ticker in a date range."""
...
class SentimentProvider(Protocol):
"""Protocol for sentiment analysis providers."""
async def fetch_sentiment(self, ticker: str) -> SentimentData:
"""Fetch current sentiment analysis for a ticker."""
...
class FundamentalProvider(Protocol):
"""Protocol for fundamental data providers."""
async def fetch_fundamentals(self, ticker: str) -> FundamentalData:
"""Fetch fundamental data for a ticker."""
...