You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

799 lines
34 KiB
Python

"""
FRED API Pass-through Proxy Service
모든 FRED API 엔드포인트를 proxy로 전달하는 서비스
"""
import logging
import httpx
import json
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, desc, text
from app.models.fred_data import FredApiUsage, FredSeries, FredObservation
from app.core.config import settings
logger = logging.getLogger(__name__)
class FredProxyService:
"""FRED API Proxy Service with intelligent caching and daily limit management"""
def __init__(self):
self.api_key = "2b12c4c62a7e9d9002d746dad7bfd147"
self.base_url = "https://api.stlouisfed.org/fred"
self.daily_limit = 1000
self.cache_duration_hours = 24 # 24시간 캐시
self._client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(timeout=30.0)
return self._client
async def close(self) -> None:
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def _check_daily_limit(self, db: AsyncSession) -> Tuple[bool, int, int]:
"""
일일 API 사용량 확인
Returns:
(can_make_request, used_today, remaining)
"""
today = datetime.now().strftime('%Y-%m-%d')
# 오늘의 API 사용량 조회
result = await db.execute(
select(func.count(FredApiUsage.id))
.where(and_(
FredApiUsage.date == today,
FredApiUsage.success == True
))
)
used_today = result.scalar() or 0
remaining = self.daily_limit - used_today
can_make_request = remaining > 0
logger.debug(f"📊 FRED API usage today: {used_today}/{self.daily_limit} (remaining: {remaining})")
return can_make_request, used_today, remaining
async def _log_api_usage(
self,
db: AsyncSession,
endpoint: str,
request_params: Optional[Dict] = None,
success: bool = True,
response_size: int = 0,
series_id: Optional[str] = None
):
"""API 사용량 로깅"""
today = datetime.now().strftime('%Y-%m-%d')
# series_id 추출 시도
if not series_id and request_params:
series_id = request_params.get('series_id')
usage_log = FredApiUsage(
date=today,
endpoint=endpoint,
series_id=series_id,
request_params=request_params or {},
success=success,
response_size=response_size
)
db.add(usage_log)
await db.commit()
logger.info(f"📝 FRED API call logged: {endpoint} {'' if success else ''}")
async def _is_cache_valid(self, cached_at: datetime, cache_hours: int = 24) -> bool:
"""캐시 유효성 확인"""
if not cached_at:
return False
expiry_time = cached_at + timedelta(hours=cache_hours)
return datetime.now() < expiry_time
async def _cache_series_data(self, db: AsyncSession, series_data: Dict) -> None:
"""시리즈 데이터를 DB에 캐시"""
try:
series_id = series_data['id']
# 기존 데이터 조회
cached_series = await db.execute(
select(FredSeries).where(FredSeries.id == series_id)
)
existing = cached_series.scalar_one_or_none()
if existing:
# 업데이트
existing.title = series_data.get('title')
existing.units = series_data.get('units')
existing.units_short = series_data.get('units_short')
existing.frequency = series_data.get('frequency')
existing.frequency_short = series_data.get('frequency_short')
existing.seasonal_adjustment = series_data.get('seasonal_adjustment')
existing.seasonal_adjustment_short = series_data.get('seasonal_adjustment_short')
existing.last_updated = datetime.fromisoformat(series_data['last_updated'].replace('-05', '')) if series_data.get('last_updated') else None
existing.popularity = series_data.get('popularity', 0)
existing.notes = series_data.get('notes')
existing.cached_at = datetime.now()
existing.cache_expires_at = datetime.now() + timedelta(hours=self.cache_duration_hours)
existing.fred_metadata = series_data
else:
# 새로 생성
new_series = FredSeries(
id=series_data['id'],
title=series_data.get('title'),
units=series_data.get('units'),
units_short=series_data.get('units_short'),
frequency=series_data.get('frequency'),
frequency_short=series_data.get('frequency_short'),
seasonal_adjustment=series_data.get('seasonal_adjustment'),
seasonal_adjustment_short=series_data.get('seasonal_adjustment_short'),
last_updated=datetime.fromisoformat(series_data['last_updated'].replace('-05', '')) if series_data.get('last_updated') else None,
popularity=series_data.get('popularity', 0),
notes=series_data.get('notes'),
cached_at=datetime.now(),
cache_expires_at=datetime.now() + timedelta(hours=self.cache_duration_hours),
fred_metadata=series_data
)
db.add(new_series)
await db.commit()
logger.info(f"✅ FRED series cached: {series_id} - {series_data.get('title', 'Unknown')[:50]}")
except Exception as e:
logger.error(f"❌ Error caching series data: {e}")
async def _cache_observations_data(self, db: AsyncSession, series_id: str, observations: list) -> int:
"""관측값 데이터를 DB에 영구 저장 (중복 방지)"""
try:
# 기존 데이터 조회
existing_dates = set()
existing_query = await db.execute(
select(FredObservation.date).where(FredObservation.series_id == series_id)
)
existing_dates = {row[0] for row in existing_query.fetchall()}
# 새로운 관측값만 저장
new_observations_count = 0
for obs_data in observations:
obs_date = obs_data['date']
if obs_date not in existing_dates:
new_obs = FredObservation(
series_id=series_id,
date=obs_date,
value=obs_data['value'],
realtime_start=obs_data.get('realtime_start'),
realtime_end=obs_data.get('realtime_end'),
cached_at=datetime.now()
)
db.add(new_obs)
new_observations_count += 1
else:
# 기존 데이터의 cached_at 업데이트 (최신성 표시)
update_query = await db.execute(
select(FredObservation).where(
and_(
FredObservation.series_id == series_id,
FredObservation.date == obs_date
)
)
)
existing_obs = update_query.scalar_one_or_none()
if existing_obs:
existing_obs.cached_at = datetime.now()
await db.commit()
total_in_db = len(existing_dates) + new_observations_count
logger.info(f"✅ FRED observations cached: {series_id} ({new_observations_count} new, {total_in_db} total in DB)")
return new_observations_count
except Exception as e:
logger.error(f"❌ Error caching observations data: {e}")
return 0
async def _get_cached_series(self, db: AsyncSession, series_id: str) -> Optional[Dict]:
"""캐시된 시리즈 데이터 조회"""
try:
cached_series = await db.execute(
select(FredSeries).where(FredSeries.id == series_id)
)
cached = cached_series.scalar_one_or_none()
if cached and await self._is_cache_valid(cached.cached_at):
logger.info(f"📦 FRED series cache hit: {series_id}")
return {
'id': cached.id,
'title': cached.title,
'units': cached.units,
'frequency': cached.frequency,
'last_updated': cached.last_updated.isoformat() if cached.last_updated else None,
'cached': True,
'cached_at': cached.cached_at.isoformat(),
'fred_metadata': cached.fred_metadata
}
return None
except Exception as e:
logger.error(f"❌ Error getting cached series: {e}")
return None
async def _get_cached_observations(
self,
db: AsyncSession,
series_id: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: Optional[int] = None
) -> Optional[Dict]:
"""캐시된 관측값 데이터 조회"""
try:
query = select(FredObservation).where(FredObservation.series_id == series_id)
if start_date:
query = query.where(FredObservation.date >= start_date)
if end_date:
query = query.where(FredObservation.date <= end_date)
query = query.order_by(desc(FredObservation.date))
if limit:
query = query.limit(limit)
cached_obs = await db.execute(query)
cached_data = cached_obs.scalars().all()
# 캐시가 있고 최근 데이터인지 확인
if cached_data and await self._is_cache_valid(cached_data[0].cached_at):
logger.info(f"📦 FRED observations cache hit: {series_id} ({len(cached_data)} records)")
return {
'series_id': series_id,
'observations': [
{
'date': obs.date,
'value': obs.value,
'realtime_start': obs.realtime_start,
'realtime_end': obs.realtime_end
}
for obs in cached_data
],
'count': len(cached_data),
'cached': True,
'cached_at': cached_data[0].cached_at.isoformat() if cached_data else None
}
return None
except Exception as e:
logger.error(f"❌ Error getting cached observations: {e}")
return None
async def _make_fred_request(self, endpoint: str, params: Dict) -> Optional[Dict]:
"""FRED API 요청 실행"""
url = f"{self.base_url}/{endpoint}"
params['api_key'] = self.api_key
params['file_type'] = 'json'
try:
client = await self._get_client()
response = await client.get(url, params=params)
response.raise_for_status()
data = response.json()
# 응답 크기 추정
response_size = 0
if 'seriess' in data:
response_size = len(data.get('seriess', []))
elif 'observations' in data:
response_size = len(data.get('observations', []))
elif 'categories' in data:
response_size = len(data.get('categories', []))
elif 'sources' in data:
response_size = len(data.get('sources', []))
elif 'releases' in data:
response_size = len(data.get('releases', []))
elif 'tags' in data:
response_size = len(data.get('tags', []))
else:
response_size = 1
logger.info(f"✅ FRED API success: {endpoint} -> {response_size} records")
return {
'data': data,
'response_size': response_size
}
except httpx.HTTPError as e:
logger.error(f"❌ FRED API error: {endpoint} -> {e}")
return None
except Exception as e:
logger.error(f"❌ FRED API unexpected error: {endpoint} -> {e}")
return None
async def _handle_multiple_series_observations(
self,
db: AsyncSession,
series_ids: list,
params: Dict[str, Any],
bypass_limit_check: bool = False,
force_refresh: bool = False
) -> Dict[str, Any]:
"""다중 시리즈의 관측값을 개별 요청으로 처리"""
try:
all_observations = []
all_metadata = []
successful_series = []
failed_series = []
# 각 시리즈를 개별적으로 요청
for series_id in series_ids:
individual_params = params.copy()
individual_params['series_id'] = series_id.strip()
try:
result = await self.proxy_fred_request(
db,
'series/observations',
individual_params,
bypass_limit_check,
force_refresh
)
if result['success'] and 'data' in result and 'observations' in result['data']:
# 각 관측값에 series_id 추가
for obs in result['data']['observations']:
obs['series_id'] = series_id.strip()
all_observations.extend(result['data']['observations'])
all_metadata.append({
'series_id': series_id.strip(),
'count': len(result['data']['observations']),
'cached': result['metadata'].get('cached', False)
})
successful_series.append(series_id.strip())
else:
failed_series.append({
'series_id': series_id.strip(),
'error': result.get('error', 'Unknown error')
})
except Exception as e:
logger.error(f"❌ Error processing series {series_id}: {e}")
failed_series.append({
'series_id': series_id.strip(),
'error': str(e)
})
# 결과 정렬 (날짜순)
all_observations.sort(key=lambda x: x['date'])
# API 사용량 로깅 (다중 시리즈)
await self._log_api_usage(
db,
'series/observations',
params,
len(successful_series) > 0,
len(all_observations),
','.join(successful_series)
)
return {
'success': len(successful_series) > 0,
'data': {
'realtime_start': datetime.now().strftime('%Y-%m-%d'),
'realtime_end': datetime.now().strftime('%Y-%m-%d'),
'observation_start': params.get('observation_start', '1600-01-01'),
'observation_end': params.get('observation_end', '9999-12-31'),
'units': 'lin',
'output_type': 1,
'file_type': 'json',
'order_by': 'observation_date',
'sort_order': 'asc',
'count': len(all_observations),
'offset': 0,
'limit': params.get('limit', 100000),
'observations': all_observations
},
'metadata': {
'source': 'fred.stlouisfed.org',
'endpoint': 'series/observations',
'proxy_mode': True,
'multiple_series': True,
'successful_series': successful_series,
'failed_series': failed_series,
'series_metadata': all_metadata,
'total_series_requested': len(series_ids),
'successful_series_count': len(successful_series),
'failed_series_count': len(failed_series)
}
}
except Exception as e:
logger.error(f"❌ Error in multiple series observations handler: {e}")
return {
'success': False,
'error': f'Internal server error: {str(e)}',
'details': {
'endpoint': 'series/observations',
'series_ids': series_ids,
'params': params
}
}
async def proxy_fred_request(
self,
db: AsyncSession,
endpoint: str,
params: Dict[str, Any],
bypass_limit_check: bool = False,
force_refresh: bool = False
) -> Dict[str, Any]:
"""
FRED API 요청을 proxy로 전달 (캐싱 및 영구 저장 지원)
Args:
db: Database session
endpoint: FRED API endpoint (e.g., "series", "series/observations")
params: Query parameters
bypass_limit_check: 제한 확인 우회 (관리자용)
force_refresh: 캐시 무시하고 API 호출
Returns:
API response with metadata
"""
try:
series_id = params.get('series_id')
# 다중 시리즈 요청 처리 (series/observations 엔드포인트만)
if endpoint == 'series/observations' and series_id and ',' in series_id:
series_ids = [s.strip() for s in series_id.split(',')]
logger.info(f"🔄 Processing multiple series observations: {len(series_ids)} series")
return await self._handle_multiple_series_observations(
db, series_ids, params, bypass_limit_check, force_refresh
)
# 1. 캐시 확인 (force_refresh가 아닌 경우)
if not force_refresh and series_id:
if endpoint == 'series':
# 시리즈 정보 캐시 확인
cached_data = await self._get_cached_series(db, series_id)
if cached_data:
return {
'success': True,
'data': {'seriess': [cached_data['fred_metadata']]},
'metadata': {
'source': 'fred.stlouisfed.org',
'endpoint': endpoint,
'proxy_mode': True,
'cached': True,
'cached_at': cached_data['cached_at']
}
}
elif endpoint == 'series/observations':
# 관측값 캐시 확인
start_date = params.get('observation_start')
end_date = params.get('observation_end')
limit = params.get('limit')
if isinstance(limit, str):
limit = int(limit)
cached_data = await self._get_cached_observations(db, series_id, start_date, end_date, limit)
if cached_data:
return {
'success': True,
'data': {
'realtime_start': datetime.now().strftime('%Y-%m-%d'),
'realtime_end': datetime.now().strftime('%Y-%m-%d'),
'observation_start': start_date or '1600-01-01',
'observation_end': end_date or '9999-12-31',
'units': 'lin',
'output_type': 1,
'file_type': 'json',
'order_by': 'observation_date',
'sort_order': 'desc',
'count': cached_data['count'],
'offset': 0,
'limit': limit or 1000000,
'observations': cached_data['observations']
},
'metadata': {
'source': 'fred.stlouisfed.org',
'endpoint': endpoint,
'proxy_mode': True,
'cached': True,
'cached_at': cached_data['cached_at']
}
}
# 2. API 호출 가능 여부 확인 (bypass_limit_check가 False인 경우)
if not bypass_limit_check:
can_call, used, remaining = await self._check_daily_limit(db)
if not can_call:
logger.warning(f"🚫 FRED API daily limit reached: {used}/{self.daily_limit}")
# 캐시된 데이터라도 반환 (만료되었더라도)
if series_id:
if endpoint == 'series':
cached_data = await self._get_cached_series(db, series_id)
if cached_data:
cached_data['cache_expired'] = True
cached_data['api_limit_reached'] = True
return {
'success': True,
'data': {'seriess': [cached_data['fred_metadata']]},
'metadata': {
'source': 'fred.stlouisfed.org',
'endpoint': endpoint,
'proxy_mode': True,
'cached': True,
'cache_expired': True,
'api_limit_reached': True
}
}
elif endpoint == 'series/observations':
start_date = params.get('observation_start')
end_date = params.get('observation_end')
limit = params.get('limit')
if isinstance(limit, str):
limit = int(limit)
cached_data = await self._get_cached_observations(db, series_id, start_date, end_date, limit)
if cached_data:
return {
'success': True,
'data': {
'realtime_start': datetime.now().strftime('%Y-%m-%d'),
'realtime_end': datetime.now().strftime('%Y-%m-%d'),
'observation_start': start_date or '1600-01-01',
'observation_end': end_date or '9999-12-31',
'units': 'lin',
'output_type': 1,
'file_type': 'json',
'order_by': 'observation_date',
'sort_order': 'desc',
'count': cached_data['count'],
'offset': 0,
'limit': limit or 1000000,
'observations': cached_data['observations']
},
'metadata': {
'source': 'fred.stlouisfed.org',
'endpoint': endpoint,
'proxy_mode': True,
'cached': True,
'cache_expired': True,
'api_limit_reached': True
}
}
return {
'success': False,
'error': 'Daily API limit reached',
'details': {
'used_today': used,
'daily_limit': self.daily_limit,
'remaining': remaining
}
}
else:
can_call, used, remaining = True, 0, self.daily_limit
# 3. FRED API 호출
response_data = await self._make_fred_request(endpoint, params)
if not response_data:
await self._log_api_usage(db, endpoint, params, False, 0)
return {
'success': False,
'error': 'Failed to fetch data from FRED API',
'details': {
'endpoint': endpoint,
'params': params
}
}
# 4. 응답 처리 및 캐싱
data = response_data['data']
response_size = response_data['response_size']
# 캐싱 로직
if series_id:
if endpoint == 'series' and 'seriess' in data and data['seriess']:
# 시리즈 데이터 캐싱
series_data = data['seriess'][0]
await self._cache_series_data(db, series_data)
elif endpoint == 'series/observations' and 'observations' in data:
# 관측값 데이터 캐싱 (영구 저장)
observations = data['observations']
new_count = await self._cache_observations_data(db, series_id, observations)
logger.info(f"📦 Cached {new_count} new observations for {series_id}")
await self._log_api_usage(db, endpoint, params, True, response_size, series_id)
logger.info(f"✅ FRED proxy success: {endpoint} -> {response_size} records")
return {
'success': True,
'data': data,
'metadata': {
'source': 'fred.stlouisfed.org',
'endpoint': endpoint,
'proxy_mode': True,
'api_calls_remaining': remaining - 1 if can_call else remaining,
'response_size': response_size,
'cached': False
}
}
except Exception as e:
logger.error(f"❌ Error in FRED proxy request: {e}")
return {
'success': False,
'error': f'Internal server error: {str(e)}',
'details': {
'endpoint': endpoint,
'params': params
}
}
async def get_api_usage_stats(self, db: AsyncSession, days: int = 7) -> Dict:
"""API 사용량 통계 조회"""
try:
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
# 기간별 사용량 조회 - 간단한 방식으로 변경
usage_query = await db.execute(
select(
FredApiUsage.date,
func.count(FredApiUsage.id).label('total_calls'),
func.count(FredApiUsage.id).filter(FredApiUsage.success == True).label('successful_calls'),
func.sum(FredApiUsage.response_size).label('total_records')
)
.where(FredApiUsage.date >= start_date.strftime('%Y-%m-%d'))
.group_by(FredApiUsage.date)
.order_by(desc(FredApiUsage.date))
)
daily_stats = []
for row in usage_query.fetchall():
successful_calls = row.successful_calls or 0
total_calls = row.total_calls or 0
daily_stats.append({
'date': row.date,
'total_calls': total_calls,
'successful_calls': successful_calls,
'total_records': row.total_records or 0,
'success_rate': (successful_calls / total_calls * 100) if total_calls > 0 else 0
})
# 오늘의 사용량
today = datetime.now().strftime('%Y-%m-%d')
can_call, used_today, remaining = await self._check_daily_limit(db)
# 캐시 통계
cache_stats_query = await db.execute(
select(
func.count(FredSeries.id).label('cached_series'),
func.count(FredObservation.id).label('cached_observations')
)
)
cache_row = cache_stats_query.fetchone()
# 엔드포인트별 사용량 (상위 10개)
endpoint_query = await db.execute(
select(
FredApiUsage.endpoint,
func.count(FredApiUsage.id).label('call_count')
)
.where(FredApiUsage.date >= start_date.strftime('%Y-%m-%d'))
.group_by(FredApiUsage.endpoint)
.order_by(desc(func.count(FredApiUsage.id)))
.limit(10)
)
endpoint_stats = [
{
'endpoint': row.endpoint,
'call_count': row.call_count
}
for row in endpoint_query.fetchall()
]
return {
'success': True,
'data': {
'daily_limit': self.daily_limit,
'used_today': used_today,
'remaining_today': remaining,
'usage_percentage': (used_today / self.daily_limit * 100),
'can_make_requests': can_call,
'daily_stats': daily_stats,
'endpoint_stats': endpoint_stats,
'cache_stats': {
'cached_series': cache_row.cached_series or 0,
'cached_observations': cache_row.cached_observations or 0,
'cache_duration_hours': self.cache_duration_hours
},
'proxy_info': {
'mode': 'pass_through_proxy',
'cache_duration_hours': self.cache_duration_hours,
'supported_endpoints': 'all_fred_endpoints',
'permanent_storage': True,
'smart_caching': True
}
}
}
except Exception as e:
logger.error(f"❌ Error getting API usage stats: {e}")
return {'success': False, 'error': str(e)}
def get_supported_endpoints(self) -> Dict[str, Any]:
"""지원되는 FRED API 엔드포인트 목록 반환"""
return {
'series_endpoints': [
'series',
'series/categories',
'series/observations',
'series/release',
'series/search',
'series/search/tags',
'series/search/related_tags',
'series/tags',
'series/updates',
'series/vintagedates'
],
'category_endpoints': [
'category',
'category/children',
'category/related',
'category/series',
'category/tags',
'category/related_tags'
],
'release_endpoints': [
'releases',
'releases/dates',
'release',
'release/dates',
'release/series',
'release/sources',
'release/tags',
'release/related_tags',
'release/tables'
],
'source_endpoints': [
'sources',
'source',
'source/releases'
],
'tag_endpoints': [
'tags',
'related_tags',
'tags/series'
],
'other_endpoints': [
'search',
'search/tags'
],
'note': 'All FRED API endpoints are supported through pass-through proxy'
}
# 싱글톤 인스턴스
fred_proxy_service = FredProxyService()