- 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>
228 lines
7.2 KiB
Python
228 lines
7.2 KiB
Python
"""
|
|
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 []
|