Initial commit
This commit is contained in:
456
backend/app/services/korea_investment_api.py
Normal file
456
backend/app/services/korea_investment_api.py
Normal file
@@ -0,0 +1,456 @@
|
||||
import httpx
|
||||
from typing import Dict, Any, Optional, Deque
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import asyncio
|
||||
import time
|
||||
from collections import deque
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class APICallMonitor:
|
||||
"""API 호출 모니터링 및 통계"""
|
||||
|
||||
def __init__(self, window_size: int = 100):
|
||||
self.window_size = window_size
|
||||
self.call_history: Deque[Dict[str, Any]] = deque(maxlen=window_size)
|
||||
self.total_calls = 0
|
||||
self.total_errors = 0
|
||||
self.total_retries = 0
|
||||
|
||||
def record_call(self, success: bool, duration: float, retries: int = 0):
|
||||
"""API 호출 기록"""
|
||||
self.total_calls += 1
|
||||
if not success:
|
||||
self.total_errors += 1
|
||||
self.total_retries += retries
|
||||
|
||||
self.call_history.append({
|
||||
"timestamp": time.time(),
|
||||
"success": success,
|
||||
"duration": duration,
|
||||
"retries": retries
|
||||
})
|
||||
|
||||
def get_error_rate(self) -> float:
|
||||
"""최근 윈도우 내 오류율 계산"""
|
||||
if not self.call_history:
|
||||
return 0.0
|
||||
errors = sum(1 for call in self.call_history if not call["success"])
|
||||
return errors / len(self.call_history)
|
||||
|
||||
def get_avg_duration(self) -> float:
|
||||
"""평균 응답 시간"""
|
||||
if not self.call_history:
|
||||
return 0.0
|
||||
return sum(call["duration"] for call in self.call_history) / len(self.call_history)
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""통계 정보 반환"""
|
||||
return {
|
||||
"total_calls": self.total_calls,
|
||||
"total_errors": self.total_errors,
|
||||
"total_retries": self.total_retries,
|
||||
"error_rate": self.get_error_rate(),
|
||||
"avg_duration": self.get_avg_duration(),
|
||||
"recent_calls": len(self.call_history)
|
||||
}
|
||||
|
||||
def should_alert(self, threshold: float = 0.3) -> bool:
|
||||
"""오류율 임계값 초과 여부"""
|
||||
return self.get_error_rate() > threshold
|
||||
|
||||
|
||||
class KoreaInvestmentAPI:
|
||||
"""한국투자증권 API 클라이언트 (안전한 재시도 및 쓰로틀링 적용)"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.KOREA_INVESTMENT_API_BASE_URL
|
||||
self.app_key = settings.KOREA_INVESTMENT_APP_KEY
|
||||
self.app_secret = settings.KOREA_INVESTMENT_APP_SECRET
|
||||
self.account_number = settings.KOREA_INVESTMENT_ACCOUNT_NUMBER
|
||||
self.access_token: Optional[str] = None
|
||||
self.token_expires_at: Optional[datetime] = None
|
||||
self.last_request_time: Optional[float] = None
|
||||
|
||||
# 설정 기반 쓰로틀링 파라미터
|
||||
self.min_request_interval = settings.API_MIN_REQUEST_INTERVAL # 기본 0.5초 (초당 2회)
|
||||
self.max_retries = settings.API_MAX_RETRIES # 기본 3회
|
||||
self.retry_delay = settings.API_RETRY_DELAY # 기본 1.5초
|
||||
self.retry_backoff = settings.API_RETRY_BACKOFF # 기본 2.0배
|
||||
self.timeout = settings.API_TIMEOUT # 기본 30초
|
||||
|
||||
# 동시 요청 제한 (세마포어)
|
||||
self.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS)
|
||||
|
||||
# 모니터링
|
||||
self.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW)
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
"""접근 토큰 발급"""
|
||||
if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at:
|
||||
return self.access_token
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/oauth2/tokenP",
|
||||
json={
|
||||
"grant_type": "client_credentials",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
self.access_token = data["access_token"]
|
||||
# 토큰 만료 시간 설정 (일반적으로 24시간)
|
||||
from datetime import timedelta
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=23)
|
||||
return self.access_token
|
||||
else:
|
||||
raise Exception(f"Failed to get access token: {response.text}")
|
||||
|
||||
async def _make_request(self, method: str, endpoint: str, tr_id: str, **kwargs) -> Dict[str, Any]:
|
||||
"""API 요청 실행 (안전한 재시도 + 쓰로틀링 + 모니터링)"""
|
||||
|
||||
# 동시 요청 수 제한 (세마포어)
|
||||
async with self.semaphore:
|
||||
return await self._make_request_with_retry(method, endpoint, tr_id, **kwargs)
|
||||
|
||||
async def _make_request_with_retry(self, method: str, endpoint: str, tr_id: str, **kwargs) -> Dict[str, Any]:
|
||||
"""재시도 로직을 포함한 실제 요청 실행"""
|
||||
start_time = time.time()
|
||||
retry_count = 0
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
# 쓰로틀링: 최소 요청 간격 보장
|
||||
if self.last_request_time:
|
||||
elapsed = time.time() - self.last_request_time
|
||||
if elapsed < self.min_request_interval:
|
||||
await asyncio.sleep(self.min_request_interval - elapsed)
|
||||
|
||||
self.last_request_time = time.time()
|
||||
|
||||
# 토큰 갱신
|
||||
token = await self._get_access_token()
|
||||
|
||||
headers = {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"authorization": f"Bearer {token}",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret,
|
||||
"tr_id": tr_id,
|
||||
}
|
||||
|
||||
# 타임아웃 설정
|
||||
timeout = httpx.Timeout(self.timeout, connect=10.0)
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
f"{self.base_url}{endpoint}",
|
||||
headers=headers,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# 성공
|
||||
if response.status_code == 200:
|
||||
duration = time.time() - start_time
|
||||
self.monitor.record_call(success=True, duration=duration, retries=retry_count)
|
||||
|
||||
# 오류율 경고
|
||||
if self.monitor.should_alert(threshold=settings.API_ERROR_THRESHOLD):
|
||||
logger.warning(
|
||||
f"⚠️ API 오류율 높음: {self.monitor.get_error_rate():.1%} "
|
||||
f"(임계값: {settings.API_ERROR_THRESHOLD:.1%})"
|
||||
)
|
||||
|
||||
return response.json()
|
||||
|
||||
# API 호출 제한 초과 (429) 또는 서버 오류 (5xx)
|
||||
elif response.status_code in [429, 500, 502, 503, 504]:
|
||||
retry_count += 1
|
||||
last_exception = Exception(
|
||||
f"API rate limit or server error: {response.status_code} - {response.text}"
|
||||
)
|
||||
|
||||
if attempt < self.max_retries:
|
||||
# 지수 백오프 적용
|
||||
delay = self.retry_delay * (self.retry_backoff ** attempt)
|
||||
logger.warning(
|
||||
f"⏳ API 재시도 {attempt + 1}/{self.max_retries}: "
|
||||
f"상태코드 {response.status_code}, {delay:.1f}초 후 재시도"
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
else:
|
||||
logger.error(f"❌ API 최대 재시도 횟수 초과: {response.status_code}")
|
||||
raise last_exception
|
||||
|
||||
# 인증 오류 또는 잘못된 요청 (4xx) - 재시도 불필요
|
||||
elif 400 <= response.status_code < 500:
|
||||
duration = time.time() - start_time
|
||||
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
|
||||
|
||||
error_msg = f"API 클라이언트 오류 (재시도 안함): {response.status_code} - {response.text}"
|
||||
logger.error(f"❌ {error_msg}")
|
||||
raise Exception(error_msg)
|
||||
|
||||
# 기타 오류
|
||||
else:
|
||||
duration = time.time() - start_time
|
||||
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
|
||||
|
||||
error_msg = f"API 요청 실패: {response.status_code} - {response.text}"
|
||||
logger.error(f"❌ {error_msg}")
|
||||
raise Exception(error_msg)
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
retry_count += 1
|
||||
last_exception = e
|
||||
|
||||
if attempt < self.max_retries:
|
||||
delay = self.retry_delay * (self.retry_backoff ** attempt)
|
||||
logger.warning(f"⏳ API 타임아웃, {delay:.1f}초 후 재시도 ({attempt + 1}/{self.max_retries})")
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
else:
|
||||
duration = time.time() - start_time
|
||||
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
|
||||
logger.error(f"❌ API 타임아웃 최대 재시도 초과")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
# 예상치 못한 오류
|
||||
duration = time.time() - start_time
|
||||
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
|
||||
logger.error(f"❌ API 요청 중 예외 발생: {str(e)}")
|
||||
raise
|
||||
|
||||
# 여기까지 왔다면 모든 재시도 실패
|
||||
duration = time.time() - start_time
|
||||
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
raise Exception("API 요청 실패 (알 수 없는 오류)")
|
||||
|
||||
async def get_current_price(self, stock_code: str) -> Dict[str, Any]:
|
||||
"""현재가 조회"""
|
||||
try:
|
||||
result = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-price",
|
||||
"FHKST01010100",
|
||||
params={
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": stock_code
|
||||
}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching price for {stock_code}: {e}")
|
||||
raise
|
||||
|
||||
async def get_daily_chart(self, stock_code: str, period: str = "D") -> Dict[str, Any]:
|
||||
"""일봉/주봉/월봉 조회
|
||||
|
||||
KIS API는 기본적으로 최근 30~100개 데이터를 반환
|
||||
더 많은 데이터가 필요하면 반복 호출 필요 (페이지네이션 없음)
|
||||
"""
|
||||
try:
|
||||
result = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-daily-price",
|
||||
"FHKST01010400",
|
||||
params={
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": stock_code,
|
||||
"FID_PERIOD_DIV_CODE": period,
|
||||
"FID_ORG_ADJ_PRC": "0" # 0: 수정주가, 1: 원주가
|
||||
}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching chart for {stock_code}: {e}")
|
||||
raise
|
||||
|
||||
async def get_balance(self) -> Dict[str, Any]:
|
||||
"""계좌 잔고 조회"""
|
||||
try:
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
|
||||
result = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/trading/inquire-balance",
|
||||
"TTTC8434R",
|
||||
params={
|
||||
"CANO": account_number_prefix,
|
||||
"ACNT_PRDT_CD": account_number_suffix,
|
||||
"AFHR_FLPR_YN": "N",
|
||||
"OFL_YN": "",
|
||||
"INQR_DVSN": "02",
|
||||
"UNPR_DVSN": "01",
|
||||
"FUND_STTL_ICLD_YN": "N",
|
||||
"FNCG_AMT_AUTO_RDPT_YN": "N",
|
||||
"PRCS_DVSN": "01",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": ""
|
||||
}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching balance: {e}")
|
||||
raise
|
||||
|
||||
async def place_order(
|
||||
self,
|
||||
stock_code: str,
|
||||
order_type: str, # "BUY" or "SELL"
|
||||
quantity: int,
|
||||
price: Optional[float] = None # None for market order
|
||||
) -> Dict[str, Any]:
|
||||
"""주문 실행"""
|
||||
try:
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
|
||||
# 매수/매도 구분
|
||||
if order_type == "BUY":
|
||||
tr_id = "TTTC0802U" # 현금 매수
|
||||
else:
|
||||
tr_id = "TTTC0801U" # 현금 매도
|
||||
|
||||
# 주문 유형 (00: 지정가, 01: 시장가)
|
||||
order_dvsn = "01" if price is None else "00"
|
||||
order_price = "0" if price is None else str(int(price))
|
||||
|
||||
result = await self._make_request(
|
||||
"POST",
|
||||
"/uapi/domestic-stock/v1/trading/order-cash",
|
||||
tr_id,
|
||||
json={
|
||||
"CANO": account_number_prefix,
|
||||
"ACNT_PRDT_CD": account_number_suffix,
|
||||
"PDNO": stock_code,
|
||||
"ORD_DVSN": order_dvsn,
|
||||
"ORD_QTY": str(quantity),
|
||||
"ORD_UNPR": order_price
|
||||
}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error placing order: {e}")
|
||||
raise
|
||||
|
||||
async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]:
|
||||
"""주문 취소"""
|
||||
try:
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
|
||||
result = await self._make_request(
|
||||
"POST",
|
||||
"/uapi/domestic-stock/v1/trading/order-rvsecncl",
|
||||
"TTTC0803U",
|
||||
json={
|
||||
"CANO": account_number_prefix,
|
||||
"ACNT_PRDT_CD": account_number_suffix,
|
||||
"KRX_FWDG_ORD_ORGNO": "",
|
||||
"ORGN_ODNO": order_id,
|
||||
"ORD_DVSN": "00",
|
||||
"RVSE_CNCL_DVSN_CD": "02", # 취소
|
||||
"ORD_QTY": "0",
|
||||
"ORD_UNPR": "0",
|
||||
"QTY_ALL_ORD_YN": "Y"
|
||||
}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error canceling order: {e}")
|
||||
raise
|
||||
|
||||
async def search_stocks_by_condition(
|
||||
self,
|
||||
market: str = "J", # J: 코스피, Q: 코스닥
|
||||
condition_name: str = "",
|
||||
max_results: int = 100
|
||||
) -> Dict[str, Any]:
|
||||
"""조건 검색으로 종목 조회"""
|
||||
try:
|
||||
result = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-search",
|
||||
"HHKST03900300",
|
||||
params={
|
||||
"FID_COND_MRKT_DIV_CODE": market,
|
||||
"FID_COND_SCR_DIV_CODE": "20171", # 조건검색
|
||||
"FID_INPUT_ISCD": "0000",
|
||||
"FID_DIV_CLS_CODE": "0",
|
||||
"FID_BLNG_CLS_CODE": "0",
|
||||
"FID_TRGT_CLS_CODE": "111111111",
|
||||
"FID_TRGT_EXLS_CLS_CODE": "000000",
|
||||
"FID_INPUT_PRICE_1": "",
|
||||
"FID_INPUT_PRICE_2": "",
|
||||
"FID_VOL_CNT": "",
|
||||
"FID_INPUT_DATE_1": ""
|
||||
}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching stocks: {e}")
|
||||
raise
|
||||
|
||||
async def get_stock_list(self, market: str = "J") -> Dict[str, Any]:
|
||||
"""전체 종목 리스트 조회"""
|
||||
try:
|
||||
# 거래대금 상위 종목 조회
|
||||
result = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/volume-rank",
|
||||
"FHPST01710000",
|
||||
params={
|
||||
"FID_COND_MRKT_DIV_CODE": market,
|
||||
"FID_COND_SCR_DIV_CODE": "20171",
|
||||
"FID_INPUT_ISCD": "0000",
|
||||
"FID_DIV_CLS_CODE": "0",
|
||||
"FID_BLNG_CLS_CODE": "0",
|
||||
"FID_TRGT_CLS_CODE": "111111111",
|
||||
"FID_TRGT_EXLS_CLS_CODE": "000000",
|
||||
"FID_INPUT_PRICE_1": "",
|
||||
"FID_INPUT_PRICE_2": "",
|
||||
"FID_VOL_CNT": "",
|
||||
"FID_INPUT_DATE_1": ""
|
||||
}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stock list: {e}")
|
||||
raise
|
||||
|
||||
async def get_volume_rank(self, market: str = "J") -> Dict[str, Any]:
|
||||
"""거래량 순위 조회 (get_stock_list와 동일하지만 명시적 이름)"""
|
||||
return await self.get_stock_list(market)
|
||||
|
||||
def get_monitor_stats(self) -> Dict[str, Any]:
|
||||
"""API 호출 모니터링 통계 반환"""
|
||||
return self.monitor.get_stats()
|
||||
|
||||
def log_monitor_stats(self):
|
||||
"""모니터링 통계 로깅"""
|
||||
stats = self.get_monitor_stats()
|
||||
logger.info(
|
||||
f"📊 API 통계: 총 {stats['total_calls']}건, "
|
||||
f"오류 {stats['total_errors']}건 ({stats['error_rate']:.1%}), "
|
||||
f"재시도 {stats['total_retries']}건, "
|
||||
f"평균 응답 {stats['avg_duration']:.2f}초"
|
||||
)
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
korea_investment_api = KoreaInvestmentAPI()
|
||||
79
backend/app/services/websocket_manager.py
Normal file
79
backend/app/services/websocket_manager.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from typing import Dict, List
|
||||
from fastapi import WebSocket
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebSocketManager:
|
||||
def __init__(self):
|
||||
self.active_connections: Dict[str, WebSocket] = {}
|
||||
|
||||
async def connect(self, websocket: WebSocket, client_id: str):
|
||||
"""클라이언트 연결"""
|
||||
await websocket.accept()
|
||||
self.active_connections[client_id] = websocket
|
||||
logger.info(f"Client {client_id} connected. Total connections: {len(self.active_connections)}")
|
||||
|
||||
def disconnect(self, client_id: str):
|
||||
"""클라이언트 연결 해제"""
|
||||
if client_id in self.active_connections:
|
||||
del self.active_connections[client_id]
|
||||
logger.info(f"Client {client_id} disconnected. Total connections: {len(self.active_connections)}")
|
||||
|
||||
async def send_personal_message(self, message: str, client_id: str):
|
||||
"""특정 클라이언트에게 메시지 전송"""
|
||||
if client_id in self.active_connections:
|
||||
try:
|
||||
await self.active_connections[client_id].send_text(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending message to {client_id}: {e}")
|
||||
self.disconnect(client_id)
|
||||
|
||||
async def broadcast(self, message: str):
|
||||
"""모든 연결된 클라이언트에게 메시지 브로드캐스트"""
|
||||
disconnected_clients = []
|
||||
for client_id, connection in self.active_connections.items():
|
||||
try:
|
||||
await connection.send_text(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting to {client_id}: {e}")
|
||||
disconnected_clients.append(client_id)
|
||||
|
||||
# 연결 실패한 클라이언트 제거
|
||||
for client_id in disconnected_clients:
|
||||
self.disconnect(client_id)
|
||||
|
||||
async def broadcast_json(self, data: dict):
|
||||
"""JSON 데이터를 모든 클라이언트에게 브로드캐스트"""
|
||||
message = json.dumps(data)
|
||||
await self.broadcast(message)
|
||||
|
||||
async def send_market_update(self, stock_code: str, data: dict):
|
||||
"""시장 데이터 업데이트 전송"""
|
||||
message = {
|
||||
"type": "market_update",
|
||||
"stock_code": stock_code,
|
||||
"data": data
|
||||
}
|
||||
await self.broadcast_json(message)
|
||||
|
||||
async def send_trade_notification(self, trade_data: dict):
|
||||
"""거래 알림 전송"""
|
||||
message = {
|
||||
"type": "trade_notification",
|
||||
"data": trade_data
|
||||
}
|
||||
await self.broadcast_json(message)
|
||||
|
||||
async def send_mcp_status(self, status: dict):
|
||||
"""MCP 상태 업데이트 전송"""
|
||||
message = {
|
||||
"type": "mcp_status",
|
||||
"data": status
|
||||
}
|
||||
await self.broadcast_json(message)
|
||||
|
||||
|
||||
websocket_manager = WebSocketManager()
|
||||
Reference in New Issue
Block a user