feat: add external API providers and central settings database
- Add central settings database client (system_settings on postgres-shared) - User-scoped settings with global fallback - API config storage with enabled/disabled toggle - Per-source category filtering for news - Add modular external API providers in src/apis/: - OpenMeteoProvider: weather with geocoding (free, no key) - NOSProvider: Dutch news RSS feeds - BBCProvider: English news RSS feeds - AggregatedNewsProvider: merges sources with category filtering - AlphaVantageProvider: financial quotes (API key from settings DB) - Add provider dependencies and lifecycle management - Add requirements-dev.txt with pip-audit for security auditing - Add MEMORY_REMEMBER_PLAN.md documenting volatile/document memory architecture 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Alpha Vantage financial API client.
|
||||
|
||||
Stock and cryptocurrency quotes.
|
||||
https://www.alphavantage.co/documentation/
|
||||
|
||||
Requires API key (free tier available).
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .base import FinancialProvider, StockQuote
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlphaVantageProvider(FinancialProvider):
|
||||
"""Alpha Vantage financial API implementation."""
|
||||
|
||||
BASE_URL = "https://www.alphavantage.co/query"
|
||||
|
||||
def __init__(self, api_key: str, timeout: int = 10):
|
||||
"""
|
||||
Initialize Alpha Vantage client.
|
||||
|
||||
Args:
|
||||
api_key: Alpha Vantage API key
|
||||
timeout: HTTP request timeout in seconds
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
@property
|
||||
def client(self) -> httpx.AsyncClient:
|
||||
"""Lazy-initialize HTTP client."""
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(timeout=self.timeout)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client."""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def get_quote(self, symbol: str) -> Optional[StockQuote]:
|
||||
"""
|
||||
Get current quote for a stock symbol.
|
||||
|
||||
Args:
|
||||
symbol: Stock ticker symbol (e.g., "AAPL", "MSFT")
|
||||
|
||||
Returns:
|
||||
StockQuote with current price info or None if not found
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
self.BASE_URL,
|
||||
params={
|
||||
"function": "GLOBAL_QUOTE",
|
||||
"symbol": symbol.upper(),
|
||||
"apikey": self.api_key
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Check for API errors
|
||||
if "Error Message" in data:
|
||||
logger.warning(f"Alpha Vantage error for {symbol}: {data['Error Message']}")
|
||||
return None
|
||||
|
||||
if "Note" in data:
|
||||
# Rate limit warning
|
||||
logger.warning(f"Alpha Vantage rate limit: {data['Note']}")
|
||||
return None
|
||||
|
||||
quote = data.get("Global Quote", {})
|
||||
if not quote:
|
||||
logger.warning(f"No quote data for symbol: {symbol}")
|
||||
return None
|
||||
|
||||
# Parse quote data
|
||||
price = float(quote.get("05. price", 0))
|
||||
change = float(quote.get("09. change", 0))
|
||||
change_percent_str = quote.get("10. change percent", "0%")
|
||||
change_percent = float(change_percent_str.rstrip('%'))
|
||||
|
||||
return StockQuote(
|
||||
symbol=symbol.upper(),
|
||||
name=None, # Global Quote doesn't include company name
|
||||
price=price,
|
||||
currency="USD", # Alpha Vantage returns USD for US stocks
|
||||
change=change,
|
||||
change_percent=change_percent,
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Alpha Vantage request failed for {symbol}: {e}")
|
||||
return None
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.error(f"Failed to parse Alpha Vantage response for {symbol}: {e}")
|
||||
return None
|
||||
|
||||
async def get_quotes(self, symbols: list[str]) -> list[StockQuote]:
|
||||
"""
|
||||
Get quotes for multiple stock symbols.
|
||||
|
||||
Note: Alpha Vantage free tier has rate limits (5 calls/min, 500 calls/day).
|
||||
Consider using batch endpoints or caching for production use.
|
||||
|
||||
Args:
|
||||
symbols: List of stock ticker symbols
|
||||
|
||||
Returns:
|
||||
List of StockQuote objects (may be less than input if some fail)
|
||||
"""
|
||||
quotes = []
|
||||
for symbol in symbols:
|
||||
quote = await self.get_quote(symbol)
|
||||
if quote:
|
||||
quotes.append(quote)
|
||||
return quotes
|
||||
|
||||
async def get_crypto_quote(
|
||||
self,
|
||||
symbol: str,
|
||||
market: str = "USD"
|
||||
) -> Optional[StockQuote]:
|
||||
"""
|
||||
Get current quote for a cryptocurrency.
|
||||
|
||||
Args:
|
||||
symbol: Crypto symbol (e.g., "BTC", "ETH")
|
||||
market: Market currency (default: USD)
|
||||
|
||||
Returns:
|
||||
StockQuote with current price info or None if not found
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
self.BASE_URL,
|
||||
params={
|
||||
"function": "CURRENCY_EXCHANGE_RATE",
|
||||
"from_currency": symbol.upper(),
|
||||
"to_currency": market.upper(),
|
||||
"apikey": self.api_key
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Check for API errors
|
||||
if "Error Message" in data:
|
||||
logger.warning(f"Alpha Vantage error for {symbol}: {data['Error Message']}")
|
||||
return None
|
||||
|
||||
if "Note" in data:
|
||||
logger.warning(f"Alpha Vantage rate limit: {data['Note']}")
|
||||
return None
|
||||
|
||||
rate_data = data.get("Realtime Currency Exchange Rate", {})
|
||||
if not rate_data:
|
||||
logger.warning(f"No exchange rate data for: {symbol}/{market}")
|
||||
return None
|
||||
|
||||
price = float(rate_data.get("5. Exchange Rate", 0))
|
||||
|
||||
return StockQuote(
|
||||
symbol=f"{symbol.upper()}/{market.upper()}",
|
||||
name=rate_data.get("2. From_Currency Name"),
|
||||
price=price,
|
||||
currency=market.upper(),
|
||||
change=None, # Exchange rate endpoint doesn't provide change
|
||||
change_percent=None,
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Alpha Vantage crypto request failed for {symbol}: {e}")
|
||||
return None
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.error(f"Failed to parse Alpha Vantage crypto response for {symbol}: {e}")
|
||||
return None
|
||||
|
||||
async def search_symbol(self, keywords: str) -> list[dict]:
|
||||
"""
|
||||
Search for stock symbols by keywords.
|
||||
|
||||
Args:
|
||||
keywords: Search keywords (company name or partial symbol)
|
||||
|
||||
Returns:
|
||||
List of matching symbols with metadata
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
self.BASE_URL,
|
||||
params={
|
||||
"function": "SYMBOL_SEARCH",
|
||||
"keywords": keywords,
|
||||
"apikey": self.api_key
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
matches = data.get("bestMatches", [])
|
||||
return [
|
||||
{
|
||||
"symbol": m.get("1. symbol"),
|
||||
"name": m.get("2. name"),
|
||||
"type": m.get("3. type"),
|
||||
"region": m.get("4. region"),
|
||||
"currency": m.get("8. currency"),
|
||||
}
|
||||
for m in matches
|
||||
]
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Alpha Vantage search failed for '{keywords}': {e}")
|
||||
return []
|
||||
Reference in New Issue
Block a user