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>
This commit is contained in:
2026-03-26 16:05:41 +09:00
parent 739a8a2e5d
commit de7921d270
149 changed files with 133353 additions and 3338 deletions

View File

@@ -1,3 +1,19 @@
"""Claude AI 응답 기반 자동매매 실행 모듈 (auto_trade_claude.py)
Claude 응답 텍스트에서 종목코드/진입가/손절가/목표가를 정규식으로 추출하고,
리스크 검증(risk_validator) 통과 후 KIS API를 통해 매수 주문을 실행한다.
주요 흐름:
1. parse_claude_response() — 정규식 파싱
2. get_account_balance() — 계좌 잔고 조회
3. get_stock_info() — 현재가 및 종목명 조회
4. validate_and_log_order() — 종합 리스크 검증 (CLAUDE.md 섹션 5)
5. place_order() — KIS API 지정가 매수 주문
6. save_active_trade() — 체결 정보 JSON 파일 저장
활성 거래 저장: active_trades.json (asyncio.Lock + to_thread로 동시 접근 방지)
"""
import re
import asyncio
import json
@@ -7,27 +23,40 @@ import logging
from datetime import datetime, timezone
from decimal import Decimal
# Backend imports for risk validation
# 백엔드 모듈 임포트를 위한 경로 추가
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../backend')))
from app.services.korea_investment_api import korea_investment_api
from app.core.database import AsyncSessionLocal
# Risk validator
# 리스크 검증 모듈
from risk_validator import validate_and_log_order
# Logging setup
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Claude 응답에서 종목/가격 추출
CLAUDE_FORMAT = r"종목[:\s]+(\d+)" # 종목코드
ENTRY_FORMAT = r"진입가[:\s]+([\d,]+)" # 진입가
# ── Claude 응답 파싱용 정규식 패턴 ──────────────────────────────────────────────
CLAUDE_FORMAT = r"종목[:\s]+(\d+)" # 종목코드 (6자리 숫자)
ENTRY_FORMAT = r"진입가[:\s]+([\d,]+)" # 진입가 (콤마 포함 숫자)
STOP_FORMAT = r"손절가[:\s]+([\d,]+)" # 손절가
TARGET_FORMAT = r"목표가[:\s]+([\d,]+)" # 목표가
# 활성 거래 정보를 저장하는 JSON 파일 경로
ACTIVE_TRADES_FILE = os.path.join(os.path.dirname(__file__), 'active_trades.json')
# active_trades.json 동시 접근 방지용 asyncio Lock
_trades_file_lock = asyncio.Lock()
def parse_claude_response(text):
"""Claude 응답 텍스트에서 종목코드, 진입가, 손절가, 목표가를 정규식으로 추출한다.
Args:
text: Claude AI 응답 텍스트
Returns:
dict: {"stock_code", "entry_price", "stop_loss", "take_profit"}
각 값이 없으면 None
"""
code = re.search(CLAUDE_FORMAT, text)
entry = re.search(ENTRY_FORMAT, text)
stop = re.search(STOP_FORMAT, text)
@@ -39,41 +68,53 @@ def parse_claude_response(text):
'take_profit': int(target.group(1).replace(',', '')) if target else None,
}
def save_active_trade(trade_info):
"""활성화된 거래를 JSON 파일에 저장합니다."""
trades = []
if os.path.exists(ACTIVE_TRADES_FILE):
with open(ACTIVE_TRADES_FILE, 'r', encoding='utf-8') as f:
try:
trades = json.load(f)
except json.JSONDecodeError:
print(f"경고: {ACTIVE_TRADES_FILE} 파일이 비어있거나 잘못된 형식입니다. 새 파일을 생성합니다.")
trades.append(trade_info)
def _read_trades_sync() -> list:
"""동기 방식으로 active_trades.json 읽기 (asyncio.to_thread 내부 전용)."""
if not os.path.exists(ACTIVE_TRADES_FILE):
return []
with open(ACTIVE_TRADES_FILE, 'r', encoding='utf-8') as f:
try:
return json.load(f)
except json.JSONDecodeError:
logger.warning(f"경고: {ACTIVE_TRADES_FILE} 파일이 비어있거나 잘못된 형식입니다. 새 파일을 생성합니다.")
return []
def _write_trades_sync(trades: list) -> None:
"""동기 방식으로 active_trades.json 쓰기 (asyncio.to_thread 내부 전용)."""
with open(ACTIVE_TRADES_FILE, 'w', encoding='utf-8') as f:
json.dump(trades, f, indent=4, ensure_ascii=False)
print(f"✅ 거래 정보 저장 완료: {trade_info['stock_code']}")
async def save_active_trade(trade_info: dict) -> None:
"""활성화된 거래를 JSON 파일에 저장합니다. (Lock + to_thread로 race condition 방지)"""
async with _trades_file_lock:
trades = await asyncio.to_thread(_read_trades_sync)
trades.append(trade_info)
await asyncio.to_thread(_write_trades_sync, trades)
logger.info(f"거래 정보 저장 완료: {trade_info['stock_code']}")
async def get_account_balance() -> float:
"""Fetch current account balance from KIS API"""
"""Fetch current account balance from KIS API via public get_balance() method.
[수정 1] _make_request() 직접 호출 제거 → get_balance() 공개 메서드 사용.
get_balance() 응답: output2[0].tot_evalu_mony (총평가금액)
output2[0].dnca_tot_amt (예수금총금액) 폴백
"""
try:
balance_info, _ = await korea_investment_api._make_request(
"GET",
"/uapi/domestic-stock/v1/trading/inquire-account",
"TTTC8434R",
params={}
)
balance_info = await korea_investment_api.get_balance()
if balance_info and balance_info.get('rt_cd') == '0':
output = balance_info.get('output', {})
# Try multiple possible field names for balance
balance = float(
output.get('tot_evalu_amt') or
output.get('tot_assets') or
output.get('dnca_tot_amt') or
0
)
return balance
output2 = balance_info.get('output2', [])
if output2:
summary = output2[0]
balance = float(
summary.get('tot_evalu_mony') or
summary.get('dnca_tot_amt') or
0
)
return balance
return 0
except Exception as e:
logger.error(f"Failed to fetch account balance: {e}")
@@ -81,14 +122,12 @@ async def get_account_balance() -> float:
async def get_stock_info(stock_code: str) -> tuple:
"""Fetch stock name and current price"""
"""Fetch stock name and current price via public get_current_price() method.
[수정 1] _make_request() 직접 호출 제거 → get_current_price() 공개 메서드 사용.
"""
try:
price_info, _ = await korea_investment_api._make_request(
"GET",
"/uapi/domestic-stock/v1/quotations/inquire-price",
"FHKST01010100",
params={"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": stock_code}
)
price_info = await korea_investment_api.get_current_price(stock_code)
if price_info and price_info.get('rt_cd') == '0':
output = price_info.get('output', {})
stock_name = output.get('hts_kor_isnm', 'Unknown')
@@ -184,7 +223,7 @@ async def auto_trade_from_claude(text, quantity=1):
'timestamp': datetime.now(timezone.utc).isoformat(),
'validation_details': validation_details # Store validation context
}
save_active_trade(trade_details)
await save_active_trade(trade_details)
else:
error_message = result.get('msg1', '알 수 없는 오류')
logger.error(f"❌ Order placement failed: {error_message}")
@@ -196,7 +235,6 @@ async def auto_trade_from_claude(text, quantity=1):
if __name__ == "__main__":
# 예시: Claude 응답 텍스트를 받아 자동매매 실행
import sys
if len(sys.argv) > 1:
text = sys.argv[1]
asyncio.run(auto_trade_from_claude(text))

View File

@@ -1,3 +1,21 @@
"""Claude AI 클라이언트 모듈 (claude_client.py)
Anthropic Claude API를 사용하여 주식 분석, 리스크 평가, 매매일지 생성 등을 수행.
주요 기능:
- analyze(): 범용 Claude 분석 (캐싱 컨텍스트 지원)
- validate_trading_rule(): 트레이딩 규칙 검증
- analyze_market(): 시장 분석 (추세/심리/추천)
- calculate_fair_price(): AI 기반 적정가 산출
- assess_risk(): 리스크 평가 (토큰 최소화)
- optimize_trading_rules(): 전략 파라미터 최적화
- generate_trade_journal(): 매매일지 자동 생성
캐싱 전략:
- _CACHED_CONTEXT를 system 프롬프트의 첫 블록으로 전송 (cache_control: ephemeral)
- 반복 호출 시 Anthropic 서버 측에서 캐시 히트로 입력 토큰 절약
"""
from anthropic import AsyncAnthropic
from config import settings
from utils import _dumps
@@ -36,7 +54,8 @@ class ClaudeClient:
self,
system_prompt: str,
user_message: str,
tools: List[Dict[str, Any]] = None
tools: List[Dict[str, Any]] = None,
max_tokens: int = None,
) -> Dict[str, Any]:
"""Claude를 통한 분석 실행.
@@ -51,7 +70,7 @@ class ClaudeClient:
params = {
"model": self.model,
"max_tokens": settings.MAX_TOKENS,
"max_tokens": max_tokens or settings.MAX_TOKENS,
"temperature": settings.TEMPERATURE,
"system": [
{
@@ -109,6 +128,32 @@ class ClaudeClient:
logger.error(f"Claude analysis error: {e}")
raise
def _parse_json_response(self, response: Dict[str, Any], fallback: Dict[str, Any]) -> Dict[str, Any]:
"""응답 텍스트에서 JSON 파싱 (공통 헬퍼).
GeminiClient._parse_json_response()와 동일한 로직.
1) 응답 content에서 text 블록을 찾아 JSON 파싱 시도
2) 순수 json.loads 실패 시 텍스트 내 첫 '{' ~ 마지막 '}' 범위를 추출하여 재시도
3) 모두 실패하면 fallback에 analysis 필드를 추가하여 반환
"""
for content in response["content"]:
if content["type"] == "text":
text = content["text"]
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# JSON이 마크다운 코드블록 등으로 감싸진 경우 추출 시도
try:
json_start = text.find('{')
json_end = text.rfind('}') + 1
if json_start != -1 and json_end > json_start:
return json.loads(text[json_start:json_end])
except (json.JSONDecodeError, Exception):
pass
return {**fallback, "analysis": content["text"]}
return fallback
async def validate_trading_rule(
self,
rule: Dict[str, Any],
@@ -124,28 +169,12 @@ class ClaudeClient:
if news_data:
user_message += f"\n뉴스:{_dumps(news_data)}"
response = await self.analyze(system_prompt, user_message)
# 텍스트 응답 파싱
for content in response["content"]:
if content["type"] == "text":
try:
# JSON 응답 파싱 시도
return json.loads(content["text"])
except json.JSONDecodeError:
# JSON이 아닌 경우 기본 구조로 반환
return {
"decision": "HOLD",
"confidence": 0.5,
"analysis": content["text"]
}
return {
"decision": "HOLD",
"confidence": 0.0,
"analysis": "분석 실패"
}
return self._parse_json_response(
response,
{"decision": "HOLD", "confidence": 0.0, "analysis": "분석 실패"}
)
async def analyze_market(
self,
@@ -167,26 +196,10 @@ class ClaudeClient:
user_message = f"종목:{stock_code}\n시장:{_dumps(market_data)}{news_text}"
response = await self.analyze(system_prompt, user_message)
for content in response["content"]:
if content["type"] == "text":
try:
return json.loads(content["text"])
except json.JSONDecodeError:
return {
"trend": "SIDEWAYS",
"sentiment": "NEUTRAL",
"recommendation": "HOLD",
"confidence": 0.5,
"analysis": content["text"]
}
return {
"trend": "UNKNOWN",
"sentiment": "NEUTRAL",
"recommendation": "HOLD",
"confidence": 0.0
}
return self._parse_json_response(
response,
{"trend": "UNKNOWN", "sentiment": "NEUTRAL", "recommendation": "HOLD", "confidence": 0.0}
)
async def calculate_fair_price(
self,
@@ -201,51 +214,28 @@ class ClaudeClient:
user_message = f"종목:{stock_code} 현재가:{market_data.get('current_price')} 고가:{market_data.get('high_price')} 저가:{market_data.get('low_price')} 거래량:{market_data.get('volume')} 지표:{_dumps(market_data.get('indicators', {}))}"
response = await self.analyze(system_prompt, user_message)
for content in response["content"]:
if content["type"] == "text":
try:
return json.loads(content["text"])
except json.JSONDecodeError:
return {
"buy_prices": {"neutral": market_data.get('current_price', 0)},
"sell_prices": {"target1": market_data.get('current_price', 0) * 1.05},
"stop_loss": market_data.get('current_price', 0) * 0.97,
"strategy": content["text"],
"confidence": 0.5
}
return {
"error": "분석 실패",
"confidence": 0.0
}
return self._parse_json_response(
response,
{"error": "분석 실패", "confidence": 0.0}
)
async def assess_risk(
self,
portfolio: Dict[str, Any],
trade_plan: Dict[str, Any]
) -> Dict[str, Any]:
"""리스크 평가"""
system_prompt = """투자 리스크 관리 전문가. JSON으로 응답.
필수 필드: risk_level(LOW/MEDIUM/HIGH/CRITICAL), risk_factors, approval(APPROVED/REJECTED), recommendation."""
"""리스크 평가 (토큰 최소화 — max_tokens=RISK_MAX_TOKENS)"""
system_prompt = """리스크 평가. JSON만 출력.
필수: risk_level(LOW/MEDIUM/HIGH), approval(APPROVED/REJECTED), reason(한줄)."""
user_message = f"포트폴리오:{_dumps(portfolio)}\n거래계획:{_dumps(trade_plan)}"
response = await self.analyze(system_prompt, user_message)
for content in response["content"]:
if content["type"] == "text":
try:
return json.loads(content["text"])
except json.JSONDecodeError:
return {
"risk_level": "MEDIUM",
"approval": "HOLD",
"confidence": 0.5,
"analysis": content["text"]
}
return {"error": "Failed to assess risk"}
slim_portfolio = {k: portfolio[k] for k in ("strategy", "position_count", "mdd") if k in portfolio}
user_message = f"포트:{_dumps(slim_portfolio)} 계획:{_dumps(trade_plan)}"
response = await self.analyze(system_prompt, user_message, max_tokens=settings.RISK_MAX_TOKENS)
return self._parse_json_response(
response,
{"error": "Failed to assess risk"}
)
async def optimize_trading_rules(
self,
@@ -264,20 +254,10 @@ class ClaudeClient:
user_message += f"\n뉴스:{_dumps(news_data)}"
response = await self.analyze(system_prompt, user_message)
# 텍스트 응답 파싱
for content in response["content"]:
if content["type"] == "text":
try:
text = content["text"]
json_start = text.find('{')
json_end = text.rfind('}') + 1
if json_start != -1 and json_end != -1:
return json.loads(text[json_start:json_end])
except Exception as e:
logger.error(f"Failed to parse optimization result: {e}")
return {"error": "Failed to generate optimized rules"}
return self._parse_json_response(
response,
{"error": "Failed to generate optimized rules"}
)
async def generate_trade_journal(
self,
@@ -292,19 +272,10 @@ class ClaudeClient:
user_message = f"거래:{_dumps(trade_info)}\n시장:{_dumps(market_data)}"
response = await self.analyze(system_prompt, user_message)
for content in response["content"]:
if content["type"] == "text":
try:
text = content["text"]
json_start = text.find('{')
json_end = text.rfind('}') + 1
if json_start != -1 and json_end != -1:
return json.loads(text[json_start:json_end])
except Exception as e:
logger.error(f"Failed to parse journal result: {e}")
return {"error": "Failed to generate journal"}
return self._parse_json_response(
response,
{"error": "Failed to generate journal"}
)
claude_client = ClaudeClient()

View File

@@ -1,10 +1,25 @@
"""MCP 서버 설정 모듈 (config.py)
환경변수 기반 설정을 관리하는 Settings 클래스.
모듈 하단에서 settings = Settings() 싱글톤 인스턴스를 생성한다.
주의: 이 모듈은 백엔드의 app.core.config.settings와 별도 인스턴스이다.
MCP 서버 전용 설정(MCP_SERVER_HOST, MCP_API_KEY 등)을 관리한다.
"""
import os
from dotenv import load_dotenv
load_dotenv(override=True) # 컨테이너 빈 env var보다 .env 파일 우선
# Docker 환경변수 우선, .env 파일은 폴백으로 사용
load_dotenv(override=False)
class Settings:
"""MCP 서버 전역 설정.
AI_PROVIDER에 따라 MAX_TOKENS, TEMPERATURE를 동적으로 설정한다.
클래스 변수는 import 시점에 평가되고, __init__에서 인스턴스 속성으로 재설정된다.
"""
# API Keys
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
@@ -16,6 +31,9 @@ class Settings:
MCP_SERVER_HOST = os.getenv("MCP_SERVER_HOST", "0.0.0.0")
MCP_SERVER_PORT = int(os.getenv("MCP_SERVER_PORT", "8001"))
# MCP API Key Authentication (backend -> MCP 서버 인증)
MCP_API_KEY = os.getenv("MCP_API_KEY", "")
# Database
DATABASE_URL = os.getenv("DATABASE_URL", "")
REDIS_URL = os.getenv("REDIS_URL", "")
@@ -29,21 +47,30 @@ class Settings:
# Gemini Settings
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.0-flash")
# Common AI Settings (AI_PROVIDER에 따라 자동 선택)
MAX_TOKENS = int(os.getenv(
"GEMINI_MAX_TOKENS" if AI_PROVIDER == "gemini" else "CLAUDE_MAX_TOKENS",
"1024"
))
TEMPERATURE = float(os.getenv(
"GEMINI_TEMPERATURE" if AI_PROVIDER == "gemini" else "CLAUDE_TEMPERATURE",
"0.3"
))
# Common AI Settings — __init__에서 AI_PROVIDER 값을 읽어 동적으로 설정한다.
# [수정 2] 클래스 변수 평가 시점 문제: AI_PROVIDER가 클래스 변수로 먼저 정의되어 있어도
# 아래 두 줄은 클래스 정의 시점(import 시)에 한 번만 평가되므로, 환경변수가 변경돼도
# 올바른 분기가 보장되지 않는다. __init__에서 재설정한다.
MAX_TOKENS: int = 1024 # 임시 기본값 — __init__에서 덮어씀
TEMPERATURE: float = 0.3 # 임시 기본값 — __init__에서 덮어씀
# Token Optimization Settings
TOKEN_SAVE_MODE = os.getenv("TOKEN_SAVE_MODE", "false").lower() == "true"
NEWS_LIMIT = int(os.getenv("NEWS_LIMIT", "5"))
CHART_DAYS = int(os.getenv("CHART_DAYS", "100"))
JSON_COMPACT = os.getenv("JSON_COMPACT", "false").lower() == "true"
def __init__(self):
# AI_PROVIDER를 인스턴스 속성으로 다시 읽어 올바른 env 키를 선택한다.
self.AI_PROVIDER = os.getenv("AI_PROVIDER", "gemini")
if self.AI_PROVIDER == "gemini":
self.MAX_TOKENS = int(os.getenv("GEMINI_MAX_TOKENS", "1024"))
self.TEMPERATURE = float(os.getenv("GEMINI_TEMPERATURE", "0.3"))
else:
self.MAX_TOKENS = int(os.getenv("CLAUDE_MAX_TOKENS", "1024"))
self.TEMPERATURE = float(os.getenv("CLAUDE_TEMPERATURE", "0.3"))
# Token Optimization Settings (기본값을 절약 모드로 설정)
TOKEN_SAVE_MODE = os.getenv("TOKEN_SAVE_MODE", "true").lower() == "true"
NEWS_LIMIT = int(os.getenv("NEWS_LIMIT", "3"))
CHART_DAYS = int(os.getenv("CHART_DAYS", "30"))
JSON_COMPACT = os.getenv("JSON_COMPACT", "true").lower() == "true"
# RISK_ASSESSMENT 전용 max_tokens (단순 approve/reject — 256 충분)
RISK_MAX_TOKENS = int(os.getenv("RISK_MAX_TOKENS", "256"))
# CORS (이슈 5)
# 서버-서버 통신용: 브라우저 credentials 불필요.

View File

@@ -1,3 +1,21 @@
"""Gemini AI 클라이언트 모듈 (gemini_client.py)
Google Gemini API를 사용하여 주식 분석, 리스크 평가, 매매일지 생성 등을 수행.
Claude 클라이언트와 동일한 인터페이스를 제공하여 AI_PROVIDER 설정으로 전환 가능.
주요 기능:
- analyze(): 범용 Gemini 분석
- validate_trading_rule(): 트레이딩 규칙 검증
- analyze_market(): 시장 분석
- calculate_fair_price(): 적정가 산출
- assess_risk(): 리스크 평가
- optimize_trading_rules(): 전략 최적화
- generate_trade_journal(): 매매일지 생성
공통 헬퍼:
- _parse_json_response(): AI 응답에서 JSON 추출 (Claude 클라이언트에도 동일 로직 존재)
"""
from google import genai
from google.genai import types
from config import settings
@@ -30,15 +48,14 @@ class GeminiClient:
self,
system_prompt: str,
user_message: str,
tools: List[Dict[str, Any]] = None
tools: List[Dict[str, Any]] = None,
max_tokens: int = None,
) -> Dict[str, Any]:
"""Gemini를 통한 분석 실행"""
try:
# 최신 SDK에서는 별도의 모델 객체 생성 없이 client.models.generate_content를 사용합니다.
# system_instruction을 config에 포함시켜 보냅니다.
config_with_system = types.GenerateContentConfig(
system_instruction=system_prompt,
max_output_tokens=self.config.max_output_tokens,
max_output_tokens=max_tokens or self.config.max_output_tokens,
temperature=self.config.temperature,
)
@@ -149,13 +166,14 @@ class GeminiClient:
portfolio: Dict[str, Any],
trade_plan: Dict[str, Any]
) -> Dict[str, Any]:
"""리스크 평가"""
system_prompt = """투자 리스크 관리 전문가. JSON으로만 응답.
필수 필드: risk_level(LOW/MEDIUM/HIGH/CRITICAL), risk_factors, approval(APPROVED/REJECTED), recommendation. JSON 외 텍스트 금지."""
"""리스크 평가 (토큰 최소화)"""
system_prompt = """리스크 평가. JSON만 출력.
필수: risk_level(LOW/MEDIUM/HIGH), approval(APPROVED/REJECTED), reason(한줄). JSON 외 금지."""
user_message = f"포트폴리오:{_dumps(portfolio)}\n거래계획:{_dumps(trade_plan)}"
slim_portfolio = {k: portfolio[k] for k in ("strategy", "position_count", "mdd") if k in portfolio}
user_message = f"포트:{_dumps(slim_portfolio)} 계획:{_dumps(trade_plan)}"
response = await self.analyze(system_prompt, user_message)
response = await self.analyze(system_prompt, user_message, max_tokens=settings.RISK_MAX_TOKENS)
return self._parse_json_response(
response,
{"error": "Failed to assess risk"}

View File

@@ -1,8 +1,31 @@
from fastapi import FastAPI, HTTPException
"""MCP 서버 메인 모듈 (main.py)
Model Context Protocol(MCP) 서버 — AI 기반 주식 매매를 위한 REST API 제공.
백엔드(FastAPI)에서 이 서버를 호출하여 AI 분석 및 도구 실행을 요청한다.
엔드포인트:
GET / — 서버 상태 확인
GET /health — 헬스 체크 (AI 프로바이더 설정 확인)
GET /tools — 사용 가능한 MCP 도구 목록
POST /tools/call — MCP 도구 호출 (시장 데이터, 지표 계산, 뉴스 조회 등)
POST /analyze — AI 분석 실행 (규칙 검증, 시장 분석, 리스크 평가 등)
보안:
- MCP_API_KEY가 설정된 경우 X-API-Key 헤더 검증 (constant-time 비교)
- CORS: MCP_CORS_ORIGINS로 origin 제한
AI 프로바이더:
- AI_PROVIDER=gemini 이면 GeminiClient, 아니면 ClaudeClient 사용
"""
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from typing import Dict, Any, List
import hmac
import logging
import os
from config import settings
from claude_client import claude_client
@@ -44,10 +67,33 @@ app.add_middleware(
allow_origins=_allowed_origins,
allow_credentials=False, # 서버-서버 통신; 브라우저 세션 쿠키 불필요
allow_methods=["GET", "POST"],
allow_headers=["Content-Type", "Authorization"],
allow_headers=["Content-Type", "Authorization", "X-API-Key"],
)
# ─── API Key 인증 미들웨어 ──────────────────────────────────────────────
# MCP_API_KEY가 설정되어 있으면 모든 요청에 X-API-Key 헤더 검증 (health/root 제외)
_MCP_API_KEY = settings.MCP_API_KEY
_PUBLIC_PATHS = {"/", "/health"}
@app.middleware("http")
async def verify_api_key(request: Request, call_next):
"""X-API-Key 헤더를 constant-time 비교로 검증.
MCP_API_KEY가 빈 문자열이면 인증을 비활성화 (개발 환경 호환).
"""
if _MCP_API_KEY and request.url.path not in _PUBLIC_PATHS:
api_key = request.headers.get("X-API-Key", "")
if not api_key or not hmac.compare_digest(api_key.encode(), _MCP_API_KEY.encode()):
logger.warning(f"[MCP Security] API 키 검증 실패 — path={request.url.path}")
return JSONResponse(
status_code=401,
content={"detail": "유효한 X-API-Key 헤더가 필요합니다."},
)
return await call_next(request)
class ToolCallRequest(BaseModel):
tool_name: str
parameters: Dict[str, Any]
@@ -112,6 +158,12 @@ async def analyze(request: AnalysisRequest):
detail="rule and market_data are required"
)
stock_code = market_data.get("stock_code")
# TOKEN_SAVE_MODE: OHLCV 배열 제거, 핵심 지표만 유지
if settings.TOKEN_SAVE_MODE:
_keep = {"stock_code", "current_price", "change_rate", "volume",
"rsi", "atr", "adx", "bollinger_upper", "bollinger_lower",
"bollinger_middle", "ma5", "ma20", "ma60", "signals", "score"}
market_data = {k: v for k, v in market_data.items() if k in _keep}
news_data = []
if stock_code:
try:
@@ -132,6 +184,12 @@ async def analyze(request: AnalysisRequest):
status_code=400,
detail="stock_code and market_data are required"
)
# TOKEN_SAVE_MODE: OHLCV 배열 제거
if settings.TOKEN_SAVE_MODE and isinstance(market_data, dict):
market_data = {k: v for k, v in market_data.items() if k != "data"}
# 뉴스 제목만 유지
if settings.TOKEN_SAVE_MODE and news:
news = [{"title": n.get("title", "")} for n in news[:settings.NEWS_LIMIT]]
result = await ai_client.analyze_market(stock_code, market_data, news)
return {"analysis_type": analysis_type, "result": result}
@@ -145,6 +203,9 @@ async def analyze(request: AnalysisRequest):
status_code=400,
detail="trade_plan is required"
)
# signals를 trade_plan에 병합하여 AI에 전달
if signals:
trade_plan["signals"] = signals
result = await ai_client.assess_risk(portfolio, trade_plan)
return {"analysis_type": analysis_type, "result": result}
@@ -206,9 +267,12 @@ async def analyze(request: AnalysisRequest):
if __name__ == "__main__":
import uvicorn
# [수정 5] reload=True는 운영 환경에서 성능 저하 및 다중 프로세스 부작용을 유발한다.
# 개발 환경에서는 MCP_DEV_RELOAD=true 환경변수로 활성화할 수 있다.
_dev_reload = os.getenv("MCP_DEV_RELOAD", "false").lower() == "true"
uvicorn.run(
"main:app",
host=settings.MCP_SERVER_HOST,
port=settings.MCP_SERVER_PORT,
reload=True
reload=_dev_reload,
)

View File

@@ -1,3 +1,22 @@
"""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
@@ -9,11 +28,25 @@ import httpx
from bs4 import BeautifulSoup
from config import settings
# Optional: KIS API dependency (not currently available in MCP server directly)
# 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"""
@@ -22,8 +55,6 @@ class MCPTools:
def __init__(self):
self.tools: Dict[str, Callable] = {}
self._register_tools()
# Initialize KIS API if available
self.korea_investment_api = None
def _register_tools(self):
"""도구 등록"""
@@ -201,9 +232,8 @@ class MCPTools:
"data": formatted_data
}
else:
# 해외 주식 logic (기본 NASD 가정)
exchange = "NASD"
# TODO: 거래소 판별 로직 고도화 필요 (현재는 NASD 고정)
# 해외 주식 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)
@@ -364,38 +394,28 @@ class MCPTools:
quantity: int,
price: float = None
) -> Dict[str, Any]:
"""주문 실행 (실제로는 backend API 호출)"""
if not korea_investment_api:
return {"error": "Korea Investment API not available"}
"""주문 실행.
try:
# 국내/해외 구분
is_domestic = stock_code.isdigit() and len(stock_code) == 6
if is_domestic:
result = await korea_investment_api.place_order(
stock_code=stock_code,
order_type=order_type,
quantity=quantity,
price=int(price) if price else None
)
else:
# 해외 주문 (지정가 필수)
if price is None:
return {"error": "Overseas orders require a limit price."}
result = await korea_investment_api.place_overseas_order(
symbol=stock_code,
order_type=order_type,
quantity=quantity,
price=price,
exchange_code="NASD" # 기본값
)
return result
except Exception as e:
logger.error(f"Failed to place order: {e}")
raise
[수정 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]:
"""네이버 금융 뉴스 크롤링"""
@@ -424,7 +444,9 @@ class MCPTools:
continue
title = title_tag.get_text(strip=True)
link = "https://finance.naver.com" + title_tag['href']
# [수정 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')

View File

@@ -1,16 +1,22 @@
"""
🔒 Risk Validation Module for Auto Trading (MCP Server)
"""리스크 검증 모듈 (risk_validator.py) — MCP 서버용
This module validates all trades BEFORE execution against CLAUDE.md Section 5 risk rules:
- Position sizing: max 3-4% per stock, max 20-25 holdings
- Price filter: > 5,000 KRW (domestic)
- MDD state check: -10% pauses new entries, -20% forces reduction
- Leverage ETF limit: max 20% of capital
모든 매매 주문을 실행 전에 CLAUDE.md 섹션 5 리스크 규칙으로 검증한다.
CLAUDE.md Compliance:
- All DB reads use AsyncSessionLocal from backend
- No breaking changes to existing function signatures
- All rejections are logged with clear reasons
검증 항목:
1. 가격 필터: 국내 5,000원 초과 (ETF/리츠 예외)
2. 포지션 사이징: 종목당 총자본 4% 이내
3. 최대 보유 종목: 20개 이내
4. MDD 상태: -10% 신규 진입 중단, -20% 강제 축소
5. 레버리지 ETF 한도: 총자본 20% 이내
아키텍처:
- DB 읽기: AsyncSessionLocal 직접 사용 (SELECT only)
- DB 쓰기: 없음 (검증만 수행)
- 싱글톤: risk_validator 인스턴스, asyncio.Lock으로 동시성 보호
- 공개 API: validate_and_log_order() — 종합 검증 + 로깅
[주의] 이 모듈은 자체 DB 엔진을 생성한다 (백엔드 database.py와 별도).
MCP 서버가 백엔드와 다른 프로세스에서 실행되기 때문.
"""
import asyncio
@@ -39,8 +45,8 @@ engine = create_async_engine(settings.DATABASE_URL, echo=False)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
# Risk limits from CLAUDE.md Section 5
MAX_POSITION_SIZE_PCT = 0.04 # 3-4% per stock (using 4% as upper bound)
MAX_HOLDINGS_COUNT = 25 # max 20-25 holdings
MAX_POSITION_SIZE_PCT = settings.MAX_POSITION_SIZE # config.py와 동기화 (백테스트 검증 완료: 0.06)
MAX_HOLDINGS_COUNT = 20 # CLAUDE.md 5.1: max 20 holdings
MIN_DOMESTIC_PRICE = 5000 # KRW
MAX_LEVERAGE_ETF_PCT = 0.20 # max 20% of capital for leverage/inverse ETF
@@ -68,7 +74,11 @@ class RiskValidator:
self._lock = asyncio.Lock()
async def _get_session(self) -> AsyncSession:
"""Get or create DB session"""
"""Get or create DB session.
DEPRECATED: 새 코드에서는 _session_ctx()를 사용하세요.
기존 시그니처 유지를 위해 남겨두되, 내부적으로 새 세션을 생성합니다.
"""
if self.db_session is None:
self.db_session = AsyncSessionLocal()
return self.db_session
@@ -76,9 +86,21 @@ class RiskValidator:
async def _close_session(self):
"""Close DB session"""
if self.db_session is not None:
await self.db_session.close()
try:
await self.db_session.close()
except Exception:
pass
self.db_session = None
def _session_ctx(self):
"""컨텍스트 매니저로 세션을 안전하게 관리.
사용법:
async with self._session_ctx() as session:
result = await session.execute(...)
"""
return AsyncSessionLocal()
async def validate_price_filter(self, stock_code: str, market: str = "KRX") -> RiskValidationResult:
"""
Check price filter: min 5,000 KRW for domestic stocks
@@ -96,22 +118,17 @@ class RiskValidator:
logger.info(f"[{stock_code}] Price filter skipped (overseas market: {market})")
return RiskValidationResult(allowed=True)
# Get current price from KIS API
price_info, _ = await korea_investment_api._make_request(
"GET",
"/uapi/domestic-stock/v1/quotations/inquire-price",
"FHKST01010100",
params={"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": stock_code}
)
# Get current price from KIS API (공개 메서드 사용)
# get_current_price()는 dict를 반환: {"output": {"stck_prpr": "50000", ...}}
price_info = await korea_investment_api.get_current_price(stock_code)
current_price = int(price_info.get('output', {}).get('stck_prpr', 0))
if not price_info or price_info.get('rt_cd') != '0':
if not current_price or current_price <= 0:
return RiskValidationResult(
allowed=False,
reason=f"Failed to fetch current price for {stock_code}"
)
current_price = int(price_info.get('output', {}).get('stck_prpr', 0))
if current_price < MIN_DOMESTIC_PRICE:
return RiskValidationResult(
allowed=False,
@@ -179,27 +196,26 @@ class RiskValidator:
RiskValidationResult
"""
try:
db = await self._get_session()
# Count active holdings (quantity > 0)
result = await db.execute(
select(Portfolio).where(Portfolio.quantity > 0)
)
active_holdings = result.scalars().all()
holdings_count = len(active_holdings)
if holdings_count >= MAX_HOLDINGS_COUNT:
return RiskValidationResult(
allowed=False,
reason=f"Max holdings limit reached: {holdings_count} >= {MAX_HOLDINGS_COUNT}",
details={"current_holdings": holdings_count, "max_allowed": MAX_HOLDINGS_COUNT}
async with self._session_ctx() as db:
# Count active holdings (quantity > 0)
result = await db.execute(
select(Portfolio).where(Portfolio.quantity > 0)
)
active_holdings = result.scalars().all()
holdings_count = len(active_holdings)
logger.info(f"Max holdings check passed: {holdings_count}/{MAX_HOLDINGS_COUNT}")
return RiskValidationResult(
allowed=True,
details={"current_holdings": holdings_count, "available_slots": MAX_HOLDINGS_COUNT - holdings_count}
)
if holdings_count >= MAX_HOLDINGS_COUNT:
return RiskValidationResult(
allowed=False,
reason=f"Max holdings limit reached: {holdings_count} >= {MAX_HOLDINGS_COUNT}",
details={"current_holdings": holdings_count, "max_allowed": MAX_HOLDINGS_COUNT}
)
logger.info(f"Max holdings check passed: {holdings_count}/{MAX_HOLDINGS_COUNT}")
return RiskValidationResult(
allowed=True,
details={"current_holdings": holdings_count, "available_slots": MAX_HOLDINGS_COUNT - holdings_count}
)
except Exception as e:
logger.error(f"Max holdings check error: {e}")
@@ -274,9 +290,9 @@ class RiskValidator:
return RiskValidationResult(allowed=True)
# Calculate total leverage ETF position
db = await self._get_session()
result = await db.execute(select(Portfolio))
all_holdings = result.scalars().all()
async with self._session_ctx() as db:
result = await db.execute(select(Portfolio))
all_holdings = result.scalars().all()
leverage_etf_value = 0
for holding in all_holdings: