Files
auto-stock/mcp-server/mcp_tools.py
shuim de7921d270 fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건)
- day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가
- trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산
- risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수
- risk_management/recommendations: date.today() → KST 변환
- mdd_manager: Lock 밖 executed_key 참조 수정
- correlation_filter: output → output2 키 수정
- portfolio: 해외 종목 USD/KRW 환율 적용 분리
- Sharpe Ratio 거래빈도 기반 연간화
- telegram_notifier: send_message() 공개 래퍼 추가
- position_manager: 앱 재시작 시 DB 포지션 복원
- swing exit_service: remaining_pct 조건 범위 수정
- run_backtest: original_qty 기반 remaining_pct 계산
- us_market_schedule: 2027년 미국 공휴일 추가
- Login.tsx: res.ok 검증 추가
- mean_reversion_strategy: RSI 로그 하드코딩 수정

## HIGH 수정 (10건)
- db_worker: DLQ 파일 백업 메커니즘 추가
- korea_investment_api: 만료 토큰 임의 연장 제거
- market_data: chart_cache 최대 100개 + TTL 만료
- main.py: rate_limit_store 1시간 미접근 IP 정리
- recommendations: fire-and-forget try/except 추가
- swing/scheduler: DB 세션 3개 분리
- Dashboard: wsMessages 전체 순회
- Layout: /health 엔드포인트 + 30초 폴링
- ml_ensemble: POST /train require_api_key 추가
- day_trading: record_trade_result_safe() 전환

## 코드 정리
- 프론트엔드: isUsStock, formatTime 공통 유틸 추출
- 백엔드: dead code 제거, 중복 import 통합
- MCP: JSON 파싱 헬퍼 추출 (~60줄 감소)
- 전체 ~100개 파일 한국어 주석 추가
- 테스트 13건 코드 기준 수정 (468 passed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:05:41 +09:00

475 lines
19 KiB
Python

"""MCP 도구 레지스트리 모듈 (mcp_tools.py)
MCP 서버가 제공하는 도구(Tool)들을 등록하고 실행하는 모듈.
Claude/Gemini AI가 function calling으로 이 도구들을 호출할 수 있다.
등록된 도구:
- get_market_data: 국내/해외 주식 시장 데이터 조회 (KIS API)
- calculate_indicators: 기술적 지표 계산 (RSI, MACD, BB, SMA, EMA)
- check_portfolio: 포트폴리오 보유 현황 조회
- validate_rules: 트레이딩 규칙 검증 (RSI 조건 등)
- execute_order: 주문 실행 (차단됨 - 백엔드 API 경유 필수)
- get_news: 네이버 금융 뉴스 크롤링
아키텍처 주의사항:
- korea_investment_api는 None (MCP 서버에서 KIS API 직접 호출 불가)
- execute_order()는 리스크 검증 우회 방지를 위해 항상 차단 응답 반환
- 주문은 반드시 백엔드 /api/trades 엔드포인트를 통해 실행해야 함
"""
from typing import Dict, Any, Callable
import logging
import pandas as pd
import numpy as np
from ta.trend import SMAIndicator, EMAIndicator, MACD
from ta.momentum import RSIIndicator, StochasticOscillator
from ta.volatility import BollingerBands
import httpx
from bs4 import BeautifulSoup
from config import settings
# KIS API 인스턴스 — MCP 서버에서는 직접 사용 불가 (None)
# 주문은 백엔드 API를 통해서만 실행해야 한다 (리스크 검증 파이프라인 우회 방지)
korea_investment_api = None
logger = logging.getLogger(__name__)
# Major US stock exchange mapping
_EXCHANGE_MAP = {
# NYSE stocks
"JPM": "NYSE", "V": "NYSE", "JNJ": "NYSE", "WMT": "NYSE", "PG": "NYSE",
"UNH": "NYSE", "HD": "NYSE", "BAC": "NYSE", "XOM": "NYSE", "CVX": "NYSE",
"KO": "NYSE", "DIS": "NYSE", "MCD": "NYSE", "NKE": "NYSE", "BA": "NYSE",
# AMEX (mostly ETFs)
"SPY": "AMEX", "QQQ": "AMEX", "IWM": "AMEX", "XLF": "AMEX",
}
def _detect_exchange(ticker: str) -> str:
return _EXCHANGE_MAP.get(ticker.upper(), "NAS") # Default to NAS
class MCPTools:
"""MCP Tools Registry"""
def __init__(self):
self.tools: Dict[str, Callable] = {}
self._register_tools()
def _register_tools(self):
"""도구 등록"""
self.tools = {
"get_market_data": self.get_market_data,
"calculate_indicators": self.calculate_indicators,
"check_portfolio": self.check_portfolio,
"validate_rules": self.validate_rules,
"execute_order": self.execute_order,
"get_news": self.get_news
}
def get_tool_definitions(self):
"""도구 정의 반환 (Claude API 용)"""
return [
{
"name": "get_market_data",
"description": "주식의 시장 데이터를 조회합니다.",
"input_schema": {
"type": "object",
"properties": {
"stock_code": {
"type": "string",
"description": "종목 코드 (예: 005930)"
},
"period": {
"type": "string",
"description": "조회 기간 (D: 일봉, W: 주봉, M: 월봉)",
"enum": ["D", "W", "M"]
}
},
"required": ["stock_code"]
}
},
{
"name": "calculate_indicators",
"description": "기술적 지표를 계산합니다.",
"input_schema": {
"type": "object",
"properties": {
"price_data": {
"type": "array",
"description": "가격 데이터 배열"
},
"indicators": {
"type": "array",
"description": "계산할 지표 목록 (RSI, MACD, BB, SMA, EMA)",
"items": {"type": "string"}
}
},
"required": ["price_data", "indicators"]
}
},
{
"name": "check_portfolio",
"description": "현재 포트폴리오 상태를 확인합니다.",
"input_schema": {
"type": "object",
"properties": {
"stock_code": {
"type": "string",
"description": "특정 종목 필터 (선택사항)"
}
}
}
},
{
"name": "validate_rules",
"description": "트레이딩 규칙을 검증합니다.",
"input_schema": {
"type": "object",
"properties": {
"rule": {
"type": "object",
"description": "검증할 규칙"
},
"market_context": {
"type": "object",
"description": "시장 상황"
}
},
"required": ["rule", "market_context"]
}
},
{
"name": "execute_order",
"description": "주문을 실행합니다.",
"input_schema": {
"type": "object",
"properties": {
"stock_code": {"type": "string"},
"order_type": {
"type": "string",
"enum": ["BUY", "SELL"]
},
"quantity": {"type": "integer"},
"price": {"type": "number", "description": "지정가 (시장가는 null)"}
},
"required": ["stock_code", "order_type", "quantity"]
}
},
{
"name": "get_news",
"description": "주식 관련 뉴스를 조회합니다.",
"input_schema": {
"type": "object",
"properties": {
"stock_code": {
"type": "string",
"description": "종목 코드"
},
"limit": {
"type": "integer",
"description": "조회할 뉴스 개수",
"default": 10
}
}
}
}
]
async def call_tool(self, tool_name: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
"""도구 호출"""
if tool_name not in self.tools:
raise ValueError(f"Unknown tool: {tool_name}")
try:
result = await self.tools[tool_name](**parameters)
return {
"status": "success",
"result": result
}
except Exception as e:
logger.error(f"Tool {tool_name} execution error: {e}")
return {
"status": "error",
"error": str(e)
}
async def get_market_data(self, stock_code: str, period: str = "D") -> Dict[str, Any]:
"""시장 데이터 조회 (국내/해외 통합)"""
if not korea_investment_api:
return {"error": "Korea Investment API not available"}
try:
# 국내 주식 여부 판단 (6자리 숫자)
is_domestic = stock_code.isdigit() and len(stock_code) == 6
if is_domestic:
# 현재가 조회
current_price_data = await korea_investment_api.get_current_price(stock_code)
# 차트 데이터 조회
chart_data = await korea_investment_api.get_daily_chart(stock_code, period, limit=settings.CHART_DAYS)
output2 = chart_data.get('output2', [])
formatted_data = []
for item in output2:
formatted_data.append({
"date": item.get('stck_bsop_date'),
"open": int(item.get('stck_oprc')) if item.get('stck_oprc') else 0,
"high": int(item.get('stck_hgpr')) if item.get('stck_hgpr') else 0,
"low": int(item.get('stck_lwpr')) if item.get('stck_lwpr') else 0,
"close": int(item.get('stck_clpr')) if item.get('stck_clpr') else 0,
"volume": int(item.get('acml_vol')) if item.get('acml_vol') else 0
})
current_output = current_price_data.get('output', {})
return {
"stock_code": stock_code,
"period": period,
"current_price": int(current_output.get('stck_prpr', 0)) if current_output else 0,
"change_rate": float(current_output.get('prdy_ctrt', 0)) if current_output else 0,
"volume": int(current_output.get('acml_vol', 0)) if current_output else 0,
"data": formatted_data
}
else:
# 해외 주식 logic (거래소 자동 판별)
exchange = _detect_exchange(stock_code)
current_price_data = await korea_investment_api.get_overseas_price(stock_code, exchange)
chart_data = await korea_investment_api.get_overseas_daily_chart(stock_code, exchange, period)
output2 = chart_data.get('output2', [])
formatted_data = []
for item in output2:
# 해외주식 데이터 키 매핑 (Key names depend on actual API response, adjusted to common KIS overseas response)
formatted_data.append({
"date": item.get('xymd'), # 일자
"open": float(item.get('open')) if item.get('open') else 0.0,
"high": float(item.get('high')) if item.get('high') else 0.0,
"low": float(item.get('low')) if item.get('low') else 0.0,
"close": float(item.get('clos')) if item.get('clos') else 0.0,
"volume": float(item.get('tvol')) if item.get('tvol') else 0.0
})
curr = current_price_data.get('output', {})
return {
"stock_code": stock_code,
"period": period,
"current_price": float(curr.get('last', 0)) if curr else 0.0,
"change_rate": float(curr.get('rate', 0)) if curr else 0.0,
"volume": float(curr.get('tvol', 0)) if curr else 0.0,
"data": formatted_data
}
except Exception as e:
logger.error(f"Failed to get market data for {stock_code}: {e}")
raise
async def calculate_indicators(
self,
price_data: list,
indicators: list
) -> Dict[str, Any]:
"""기술적 지표 계산"""
try:
df = pd.DataFrame(price_data)
if 'close' not in df.columns:
raise ValueError("Price data must include 'close' column")
results = {}
# RSI 계산
if "RSI" in indicators:
rsi = RSIIndicator(close=df['close'], window=14)
results["RSI"] = float(rsi.rsi().iloc[-1])
# MACD 계산
if "MACD" in indicators:
macd = MACD(close=df['close'])
results["MACD"] = {
"macd": float(macd.macd().iloc[-1]),
"signal": float(macd.macd_signal().iloc[-1]),
"histogram": float(macd.macd_diff().iloc[-1])
}
# Bollinger Bands 계산
if "BB" in indicators:
bb = BollingerBands(close=df['close'])
results["BollingerBands"] = {
"upper": float(bb.bollinger_hband().iloc[-1]),
"middle": float(bb.bollinger_mavg().iloc[-1]),
"lower": float(bb.bollinger_lband().iloc[-1])
}
# SMA 계산
if "SMA" in indicators:
sma_20 = SMAIndicator(close=df['close'], window=20)
sma_60 = SMAIndicator(close=df['close'], window=60)
results["SMA"] = {
"sma_20": float(sma_20.sma_indicator().iloc[-1]),
"sma_60": float(sma_60.sma_indicator().iloc[-1])
}
# EMA 계산
if "EMA" in indicators:
ema_12 = EMAIndicator(close=df['close'], window=12)
ema_26 = EMAIndicator(close=df['close'], window=26)
results["EMA"] = {
"ema_12": float(ema_12.ema_indicator().iloc[-1]),
"ema_26": float(ema_26.ema_indicator().iloc[-1])
}
return results
except Exception as e:
logger.error(f"Error calculating indicators: {e}")
raise
async def check_portfolio(self, stock_code: str = None) -> Dict[str, Any]:
"""포트폴리오 확인"""
if not korea_investment_api:
return {"error": "Korea Investment API not available"}
try:
balance = await korea_investment_api.get_balance()
output1 = balance.get('output1', [])
output2 = balance.get('output2', []) # Total asset summary
holdings = []
for item in output1:
# Filter if stock_code provided
if stock_code and item.get('pdno') != stock_code:
continue
holdings.append({
"stock_code": item.get('pdno'),
"stock_name": item.get('prdt_name'),
"quantity": int(item.get('hldg_qty')),
"average_price": float(item.get('pchs_avg_pric')),
"current_price": float(item.get('prpr')),
"profit_loss": float(item.get('evlu_pfls_amt')),
"profit_rate": float(item.get('evlu_pfls_rt'))
})
total_eval = 0
if output2:
total_eval = float(output2[0].get('tot_evlu_mony', 0))
return {
"total_value": total_eval,
"holdings": holdings
}
except Exception as e:
logger.error(f"Failed to get portfolio: {e}")
raise
async def validate_rules(
self,
rule: Dict[str, Any],
market_context: Dict[str, Any]
) -> Dict[str, Any]:
"""규칙 검증"""
# 간단한 규칙 검증 로직
conditions = rule.get("conditions", {})
# RSI 조건 확인
if "rsi_below" in conditions:
market_rsi = market_context.get("RSI", 50)
if market_rsi < conditions["rsi_below"]:
return {
"valid": True,
"reason": f"RSI {market_rsi} is below {conditions['rsi_below']}"
}
return {
"valid": False,
"reason": "Conditions not met"
}
async def execute_order(
self,
stock_code: str,
order_type: str,
quantity: int,
price: float = None
) -> Dict[str, Any]:
"""주문 실행.
[수정 3] MCP 서버에서 KIS API를 직접 호출하는 것은 리스크 검증 파이프라인을 우회하므로
허용하지 않는다. 주문은 반드시 백엔드 API(/api/trades)를 통해 실행해야 한다.
MCP 서버 내부에서 korea_investment_api 인스턴스가 None인지 여부와 무관하게
이 함수는 항상 에러를 반환한다. 실제 주문은 auto_trade_claude.py의
auto_trade_from_claude() 함수(리스크 검증 포함)를 사용하거나
백엔드 /api/trades 엔드포인트를 호출해야 한다.
"""
logger.warning(
"[execute_order] MCP 서버에서 직접 주문 호출 차단 — "
"백엔드 /api/trades 엔드포인트를 통해 주문하십시오. "
f"요청 정보: stock_code={stock_code}, order_type={order_type}, qty={quantity}, price={price}"
)
return {
"error": (
"MCP 서버에서 KIS API 직접 주문은 리스크 검증 우회 위험으로 차단되었습니다. "
"백엔드 API(/api/trades)를 통해 주문하십시오."
),
"blocked": True,
}
async def get_news(self, stock_code: str = None, limit: int = 5) -> Dict[str, Any]:
"""네이버 금융 뉴스 크롤링"""
if not stock_code:
return {"error": "stock_code is required"}
try:
url = f"https://finance.naver.com/item/news_news.naver?code={stock_code}&page=1"
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers)
if response.status_code != 200:
return {"error": f"Failed to fetch news: {response.status_code}"}
soup = BeautifulSoup(response.text, 'html.parser')
news_items = []
# 네이버 금융 뉴스 구조에 맞춰 파싱
for row in soup.select('table.type5 tbody tr'):
title_tag = row.select_one('.title a')
if not title_tag:
continue
title = title_tag.get_text(strip=True)
# [수정 6] href 속성이 없는 경우 KeyError 방지
href = title_tag.get('href', '')
link = ("https://finance.naver.com" + href) if href else ''
date_tag = row.select_one('.date')
date = date_tag.get_text(strip=True) if date_tag else ""
source_tag = row.select_one('.info')
source = source_tag.get_text(strip=True) if source_tag else ""
# 관련뉴스/공시 제외하고 싶으면 필터링 가능
item = {"title": title, "date": date, "source": source}
if not settings.TOKEN_SAVE_MODE:
item["link"] = link
news_items.append(item)
if len(news_items) >= limit:
break
return {
"stock_code": stock_code,
"news": news_items
}
except Exception as e:
logger.error(f"Error fetching news for {stock_code}: {e}")
return {"error": str(e), "news": []}
mcp_tools = MCPTools()