- Create PriceFeed service using yfinance - Cache prices in Redis with 60s TTL - Add PriceData dataclass for type safety - Support concurrent price fetching for multiple symbols
87 lines
2.6 KiB
Python
87 lines
2.6 KiB
Python
"""Redis-backed caching utilities with graceful fallback support."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
from redis.asyncio import Redis as RedisClient
|
|
except ImportError: # pragma: no cover - optional dependency
|
|
RedisClient = None # type: ignore[misc,assignment]
|
|
|
|
|
|
class CacheService:
|
|
"""Small async cache wrapper around Redis."""
|
|
|
|
def __init__(self, url: str | None, default_ttl: int = 300) -> None:
|
|
self.url = url
|
|
self.default_ttl = default_ttl
|
|
self._client: RedisClient | None = None
|
|
self._enabled = bool(url and RedisClient)
|
|
|
|
@property
|
|
def enabled(self) -> bool:
|
|
return self._enabled and self._client is not None
|
|
|
|
async def connect(self) -> None:
|
|
if not self._enabled:
|
|
if self.url and RedisClient is None:
|
|
logger.warning("Redis URL configured but redis package is not installed; cache disabled")
|
|
return
|
|
|
|
try:
|
|
self._client = RedisClient.from_url(self.url, decode_responses=True) # type: ignore[misc]
|
|
await self._client.ping()
|
|
logger.info("Connected to Redis cache")
|
|
except Exception as exc: # pragma: no cover - network dependent
|
|
logger.warning("Redis unavailable, cache disabled: %s", exc)
|
|
self._client = None
|
|
|
|
async def close(self) -> None:
|
|
if self._client is None:
|
|
return
|
|
await self._client.aclose()
|
|
self._client = None
|
|
|
|
async def get_json(self, key: str) -> dict[str, Any] | list[Any] | None:
|
|
if self._client is None:
|
|
return None
|
|
|
|
value = await self._client.get(key)
|
|
if value is None:
|
|
return None
|
|
|
|
return json.loads(value)
|
|
|
|
async def set_json(self, key: str, value: Any, ttl: int | None = None) -> None:
|
|
if self._client is None:
|
|
return
|
|
|
|
payload = json.dumps(value, default=self._json_default)
|
|
await self._client.set(key, payload, ex=ttl or self.default_ttl)
|
|
|
|
@staticmethod
|
|
def _json_default(value: Any) -> str:
|
|
if isinstance(value, datetime):
|
|
return value.isoformat()
|
|
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
|
|
|
|
# Global cache instance
|
|
_cache_instance: CacheService | None = None
|
|
|
|
|
|
def get_cache() -> CacheService:
|
|
"""Get or create global cache instance."""
|
|
global _cache_instance
|
|
if _cache_instance is None:
|
|
import os
|
|
redis_url = os.environ.get("REDIS_URL")
|
|
_cache_instance = CacheService(redis_url)
|
|
return _cache_instance
|