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
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() |