first commit
This commit is contained in:
1
app/providers/__init__.py
Normal file
1
app/providers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
63
app/providers/alpaca.py
Normal file
63
app/providers/alpaca.py
Normal 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
94
app/providers/fmp.py
Normal 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
|
||||
90
app/providers/gemini_sentiment.py
Normal file
90
app/providers/gemini_sentiment.py
Normal 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
84
app/providers/protocol.py
Normal 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."""
|
||||
...
|
||||
Reference in New Issue
Block a user