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:
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 불필요.
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user