수정본
This commit is contained in:
85
mcp-server/auto_trade_claude.py
Normal file
85
mcp-server/auto_trade_claude.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import re
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
|
||||
# Claude 응답에서 종목/가격 추출
|
||||
CLAUDE_FORMAT = r"종목[:\s]+(\d+)" # 종목코드
|
||||
ENTRY_FORMAT = r"진입가[:\s]+([\d,]+)" # 진입가
|
||||
STOP_FORMAT = r"손절가[:\s]+([\d,]+)" # 손절가
|
||||
TARGET_FORMAT = r"목표가[:\s]+([\d,]+)" # 목표가
|
||||
|
||||
ACTIVE_TRADES_FILE = os.path.join(os.path.dirname(__file__), 'active_trades.json')
|
||||
|
||||
def parse_claude_response(text):
|
||||
code = re.search(CLAUDE_FORMAT, text)
|
||||
entry = re.search(ENTRY_FORMAT, text)
|
||||
stop = re.search(STOP_FORMAT, text)
|
||||
target = re.search(TARGET_FORMAT, text)
|
||||
return {
|
||||
'stock_code': code.group(1) if code else None,
|
||||
'entry_price': int(entry.group(1).replace(',', '')) if entry else None,
|
||||
'stop_loss': int(stop.group(1).replace(',', '')) if stop else None,
|
||||
'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)
|
||||
|
||||
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 auto_trade_from_claude(text, quantity=1):
|
||||
info = parse_claude_response(text)
|
||||
if info['stock_code'] and info['entry_price']:
|
||||
print(f"▶️ 매수 주문 시도: {info['stock_code']}, 수량: {quantity}, 가격: {info['entry_price']}")
|
||||
# 지정가 매수 주문
|
||||
result = await korea_investment_api.place_order(
|
||||
stock_code=info['stock_code'],
|
||||
order_type='BUY',
|
||||
quantity=quantity,
|
||||
price=info['entry_price']
|
||||
)
|
||||
print('주문 결과:', result)
|
||||
|
||||
# API 응답 구조에 따라 rt_cd가 '0'이면 성공으로 간주
|
||||
if result and result.get('rt_cd') == '0':
|
||||
print("✅ 매수 주문 성공. 거래 정보를 저장합니다.")
|
||||
trade_details = {
|
||||
'stock_code': info['stock_code'],
|
||||
'quantity': quantity,
|
||||
'entry_price': info['entry_price'],
|
||||
'stop_loss': info['stop_loss'],
|
||||
'take_profit': info['take_profit'],
|
||||
'order_id': result.get('ODNO'), # 주문번호
|
||||
'order_time': result.get('ORD_TMD'), # 주문시각
|
||||
'timestamp': datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
save_active_trade(trade_details)
|
||||
else:
|
||||
error_message = result.get('msg1', '알 수 없는 오류')
|
||||
print(f"❌ 매수 주문 실패: {error_message}")
|
||||
|
||||
else:
|
||||
print('Claude 응답에서 종목/가격을 찾을 수 없습니다.')
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 예시: Claude 응답 텍스트를 받아 자동매매 실행
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
text = sys.argv[1]
|
||||
asyncio.run(auto_trade_from_claude(text))
|
||||
else:
|
||||
print("사용법: python auto_trade_claude.py 'Claude 응답 텍스트'")
|
||||
@@ -1,5 +1,6 @@
|
||||
from anthropic import Anthropic
|
||||
from anthropic import AsyncAnthropic
|
||||
from config import settings
|
||||
from utils import _dumps
|
||||
from typing import Dict, Any, List
|
||||
import json
|
||||
import logging
|
||||
@@ -9,9 +10,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class ClaudeClient:
|
||||
"""Claude AI 클라이언트"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.client = Anthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||
self.client = AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||
self.model = settings.CLAUDE_MODEL
|
||||
|
||||
async def analyze(
|
||||
@@ -37,7 +38,7 @@ class ClaudeClient:
|
||||
if tools:
|
||||
params["tools"] = tools
|
||||
|
||||
response = self.client.messages.create(**params)
|
||||
response = await self.client.messages.create(**params)
|
||||
|
||||
# 응답 파싱
|
||||
result = {
|
||||
@@ -72,27 +73,18 @@ class ClaudeClient:
|
||||
async def validate_trading_rule(
|
||||
self,
|
||||
rule: Dict[str, Any],
|
||||
market_data: Dict[str, Any]
|
||||
market_data: Dict[str, Any],
|
||||
news_data: List[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""트레이딩 규칙 검증"""
|
||||
system_prompt = """당신은 주식 트레이딩 규칙을 분석하고 검증하는 전문가입니다.
|
||||
주어진 규칙과 시장 데이터를 분석하여 다음을 제공하세요:
|
||||
1. 규칙이 현재 시장 상황에 적합한지 여부
|
||||
2. 실행 가능성 (0-1 사이의 점수)
|
||||
3. 리스크 평가
|
||||
4. 추천 사항
|
||||
system_prompt = """주식 트레이딩 규칙 검증 전문가. 규칙/시장/뉴스를 분석하여 JSON으로 응답.
|
||||
필수 필드: decision(BUY/SELL/HOLD), confidence(0-1), risk_level, recommendation.
|
||||
악재(유상증자,감자,소송,실적악화) 시 confidence 낮추고 경고 포함."""
|
||||
|
||||
JSON 형식으로 응답하세요."""
|
||||
|
||||
user_message = f"""
|
||||
트레이딩 규칙:
|
||||
{json.dumps(rule, ensure_ascii=False, indent=2)}
|
||||
user_message = f"규칙:{_dumps(rule)}\n시장:{_dumps(market_data)}"
|
||||
|
||||
시장 데이터:
|
||||
{json.dumps(market_data, ensure_ascii=False, indent=2)}
|
||||
|
||||
이 규칙이 현재 시장 상황에서 실행되어야 하는지 분석해주세요.
|
||||
"""
|
||||
if news_data:
|
||||
user_message += f"\n뉴스:{_dumps(news_data)}"
|
||||
|
||||
response = await self.analyze(system_prompt, user_message)
|
||||
|
||||
@@ -123,32 +115,17 @@ JSON 형식으로 응답하세요."""
|
||||
news: List[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""시장 분석"""
|
||||
system_prompt = """당신은 주식 시장 분석 전문가입니다.
|
||||
주어진 시장 데이터와 뉴스를 분석하여 다음을 제공하세요:
|
||||
1. 시장 트렌드 (상승/하락/횡보)
|
||||
2. 주요 기술적 지표 분석
|
||||
3. 뉴스 센티먼트 (긍정/부정/중립)
|
||||
4. 투자 추천 (매수/매도/보유)
|
||||
5. 신뢰도 (0-1)
|
||||
system_prompt = """주식 시장 분석 전문가. JSON으로 응답.
|
||||
필수 필드: trend(UP/DOWN/SIDEWAYS), sentiment(POSITIVE/NEGATIVE/NEUTRAL), recommendation(BUY/SELL/HOLD), confidence(0-1), analysis(간략설명)."""
|
||||
|
||||
JSON 형식으로 응답하세요."""
|
||||
|
||||
news_limit = settings.NEWS_LIMIT if settings.TOKEN_SAVE_MODE else 5
|
||||
news_text = ""
|
||||
if news:
|
||||
news_text = "\n\n최근 뉴스:\n" + "\n".join([
|
||||
f"- {item.get('title', '')}: {item.get('content', '')[:200]}"
|
||||
for item in news[:5]
|
||||
news_text = "\n뉴스:" + ",".join([
|
||||
item.get('title', '') for item in news[:news_limit]
|
||||
])
|
||||
|
||||
user_message = f"""
|
||||
종목 코드: {stock_code}
|
||||
|
||||
시장 데이터:
|
||||
{json.dumps(market_data, ensure_ascii=False, indent=2)}
|
||||
{news_text}
|
||||
|
||||
이 종목에 대한 종합적인 시장 분석을 제공해주세요.
|
||||
"""
|
||||
user_message = f"종목:{stock_code}\n시장:{_dumps(market_data)}{news_text}"
|
||||
|
||||
response = await self.analyze(system_prompt, user_message)
|
||||
|
||||
@@ -179,43 +156,10 @@ JSON 형식으로 응답하세요."""
|
||||
financial_data: Dict[str, Any] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""AI 기반 적정 매수/매도 가격 산출"""
|
||||
system_prompt = """당신은 주식 가치 평가 및 가격 전략 전문가입니다.
|
||||
기술적 분석과 시장 데이터를 기반으로 다음을 제공하세요:
|
||||
1. 적정 매수 가격 (공격적, 중립적, 보수적)
|
||||
2. 적정 매도 가격 (목표가 1, 2, 3)
|
||||
3. 손절가
|
||||
4. 근거 및 전략
|
||||
5. 신뢰도 (0-1)
|
||||
system_prompt = """주식 가격 전략 전문가. JSON으로 응답.
|
||||
필수 필드: buy_prices(aggressive,neutral,conservative), sell_prices(target1,target2,target3), stop_loss, strategy, confidence(0-1)."""
|
||||
|
||||
JSON 형식으로 응답하세요. 예시:
|
||||
{
|
||||
"buy_prices": {
|
||||
"aggressive": 100000,
|
||||
"neutral": 105000,
|
||||
"conservative": 110000
|
||||
},
|
||||
"sell_prices": {
|
||||
"target1": 120000,
|
||||
"target2": 130000,
|
||||
"target3": 140000
|
||||
},
|
||||
"stop_loss": 95000,
|
||||
"strategy": "기술적 분석 기반 설명...",
|
||||
"confidence": 0.75
|
||||
}"""
|
||||
|
||||
user_message = f"""
|
||||
종목 코드: {stock_code}
|
||||
|
||||
시장 데이터:
|
||||
- 현재가: {market_data.get('current_price')}
|
||||
- 고가: {market_data.get('high_price')}
|
||||
- 저가: {market_data.get('low_price')}
|
||||
- 거래량: {market_data.get('volume')}
|
||||
- 기술적 지표: {json.dumps(market_data.get('indicators', {}), ensure_ascii=False)}
|
||||
|
||||
이 종목의 적정 매수/매도 가격과 전략을 제시해주세요.
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -243,25 +187,10 @@ JSON 형식으로 응답하세요. 예시:
|
||||
trade_plan: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""리스크 평가"""
|
||||
system_prompt = """당신은 투자 리스크 관리 전문가입니다.
|
||||
현재 포트폴리오와 계획된 거래를 분석하여 다음을 제공하세요:
|
||||
1. 리스크 레벨 (LOW/MEDIUM/HIGH/CRITICAL)
|
||||
2. 주요 리스크 요인
|
||||
3. 포트폴리오 다양성 평가
|
||||
4. 추천 사항
|
||||
5. 승인 여부 (APPROVED/REJECTED)
|
||||
system_prompt = """투자 리스크 관리 전문가. JSON으로 응답.
|
||||
필수 필드: risk_level(LOW/MEDIUM/HIGH/CRITICAL), risk_factors, approval(APPROVED/REJECTED), recommendation."""
|
||||
|
||||
JSON 형식으로 응답하세요."""
|
||||
|
||||
user_message = f"""
|
||||
현재 포트폴리오:
|
||||
{json.dumps(portfolio, ensure_ascii=False, indent=2)}
|
||||
|
||||
계획된 거래:
|
||||
{json.dumps(trade_plan, ensure_ascii=False, indent=2)}
|
||||
|
||||
이 거래의 리스크를 평가하고 실행 여부를 결정해주세요.
|
||||
"""
|
||||
user_message = f"포트폴리오:{_dumps(portfolio)}\n거래계획:{_dumps(trade_plan)}"
|
||||
|
||||
response = await self.analyze(system_prompt, user_message)
|
||||
|
||||
@@ -277,11 +206,66 @@ JSON 형식으로 응답하세요."""
|
||||
"analysis": content["text"]
|
||||
}
|
||||
|
||||
return {
|
||||
"risk_level": "UNKNOWN",
|
||||
"approval": "REJECTED",
|
||||
"confidence": 0.0
|
||||
}
|
||||
return {"error": "Failed to assess risk"}
|
||||
|
||||
async def optimize_trading_rules(
|
||||
self,
|
||||
stock_code: str,
|
||||
market_data: Dict[str, Any],
|
||||
news_data: List[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""시장 상황에 맞는 트레이딩 규칙 최적화"""
|
||||
system_prompt = """알고리즘 트레이딩 전략가. JSON으로만 응답.
|
||||
필수 필드: strategy_name, market_condition, parameters(buy_conditions,sell_conditions(take_profit_rate,stop_loss_rate,trailing_stop)), risk_assessment, confidence_score(0-100).
|
||||
악재시 보수적, 호재시 적극적 설정."""
|
||||
|
||||
user_message = f"종목:{stock_code}\n시장:{_dumps(market_data)}"
|
||||
|
||||
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:
|
||||
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"}
|
||||
|
||||
async def generate_trade_journal(
|
||||
self,
|
||||
trade_info: Dict[str, Any],
|
||||
market_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""매매일지 자동 작성"""
|
||||
system_prompt = """매매일지 작성 전문가. JSON으로 응답.
|
||||
필수 필드: title, content(간략 Markdown), mood(평정심/흥분/공포/아쉬움/만족), tags(배열), score(0-100).
|
||||
매매근거, 잘한점/아쉬운점, 개선사항 포함."""
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
claude_client = ClaudeClient()
|
||||
|
||||
@@ -7,31 +7,43 @@ load_dotenv()
|
||||
class Settings:
|
||||
# API Keys
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
PERPLEXITY_API_KEY = os.getenv("PERPLEXITY_API_KEY", "")
|
||||
|
||||
# AI Provider Selection
|
||||
AI_PROVIDER = os.getenv("AI_PROVIDER", "perplexity") # claude or perplexity
|
||||
|
||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
|
||||
|
||||
# AI Provider Selection (claude or gemini)
|
||||
AI_PROVIDER = os.getenv("AI_PROVIDER", "gemini")
|
||||
|
||||
# Server Settings
|
||||
MCP_SERVER_HOST = os.getenv("MCP_SERVER_HOST", "0.0.0.0")
|
||||
MCP_SERVER_PORT = int(os.getenv("MCP_SERVER_PORT", "8001"))
|
||||
|
||||
|
||||
# Database
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "")
|
||||
REDIS_URL = os.getenv("REDIS_URL", "")
|
||||
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||
|
||||
|
||||
# Claude Settings
|
||||
CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-3-5-sonnet-20241022")
|
||||
|
||||
# Perplexity Settings
|
||||
PERPLEXITY_MODEL = os.getenv("PERPLEXITY_MODEL", "llama-3.1-sonar-large-128k-online")
|
||||
|
||||
# Common AI Settings
|
||||
MAX_TOKENS = int(os.getenv("PERPLEXITY_MAX_TOKENS" if AI_PROVIDER == "perplexity" else "CLAUDE_MAX_TOKENS", "4096"))
|
||||
TEMPERATURE = float(os.getenv("PERPLEXITY_TEMPERATURE" if AI_PROVIDER == "perplexity" else "CLAUDE_TEMPERATURE", "0.7"))
|
||||
|
||||
# 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"
|
||||
))
|
||||
|
||||
# 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"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
188
mcp-server/gemini_client.py
Normal file
188
mcp-server/gemini_client.py
Normal file
@@ -0,0 +1,188 @@
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from config import settings
|
||||
from utils import _dumps
|
||||
from typing import Dict, Any, List
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GeminiClient:
|
||||
"""Gemini AI 클라이언트 (ClaudeClient와 동일한 인터페이스)"""
|
||||
|
||||
def __init__(self):
|
||||
self.client = genai.Client(api_key=settings.GEMINI_API_KEY)
|
||||
self.model_name = settings.GEMINI_MODEL
|
||||
self.generate_config = types.GenerateContentConfig(
|
||||
max_output_tokens=settings.MAX_TOKENS,
|
||||
temperature=settings.TEMPERATURE,
|
||||
)
|
||||
|
||||
async def analyze(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_message: str,
|
||||
tools: List[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Gemini를 통한 분석 실행"""
|
||||
try:
|
||||
response = await self.client.aio.models.generate_content(
|
||||
model=self.model_name,
|
||||
contents=f"{system_prompt}\n\n{user_message}",
|
||||
config=self.generate_config,
|
||||
)
|
||||
|
||||
text = response.text if response.text else ""
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {
|
||||
"input_tokens": response.usage_metadata.prompt_token_count if response.usage_metadata else 0,
|
||||
"output_tokens": response.usage_metadata.candidates_token_count if response.usage_metadata else 0,
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini analysis error: {e}")
|
||||
raise
|
||||
|
||||
def _parse_json_response(self, response: Dict[str, Any], fallback: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""응답 텍스트에서 JSON 파싱 (공통 헬퍼)"""
|
||||
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 > json_start:
|
||||
return json.loads(text[json_start:json_end])
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return {**fallback, "analysis": content["text"]}
|
||||
return fallback
|
||||
|
||||
async def validate_trading_rule(
|
||||
self,
|
||||
rule: Dict[str, Any],
|
||||
market_data: Dict[str, Any],
|
||||
news_data: List[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""트레이딩 규칙 검증"""
|
||||
system_prompt = """주식 트레이딩 규칙 검증 전문가. 규칙/시장/뉴스를 분석하여 JSON으로만 응답.
|
||||
필수 필드: decision(BUY/SELL/HOLD), confidence(0-1), risk_level, recommendation.
|
||||
악재(유상증자,감자,소송,실적악화) 시 confidence 낮추고 경고 포함. JSON 외 텍스트 금지."""
|
||||
|
||||
user_message = f"규칙:{_dumps(rule)}\n시장:{_dumps(market_data)}"
|
||||
if news_data:
|
||||
user_message += f"\n뉴스:{_dumps(news_data)}"
|
||||
|
||||
response = await self.analyze(system_prompt, user_message)
|
||||
return self._parse_json_response(
|
||||
response,
|
||||
{"decision": "HOLD", "confidence": 0.0, "analysis": "분석 실패"}
|
||||
)
|
||||
|
||||
async def analyze_market(
|
||||
self,
|
||||
stock_code: str,
|
||||
market_data: Dict[str, Any],
|
||||
news: List[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""시장 분석"""
|
||||
system_prompt = """주식 시장 분석 전문가. JSON으로만 응답.
|
||||
필수 필드: trend(UP/DOWN/SIDEWAYS), sentiment(POSITIVE/NEGATIVE/NEUTRAL), recommendation(BUY/SELL/HOLD), confidence(0-1), analysis(간략설명). JSON 외 텍스트 금지."""
|
||||
|
||||
news_limit = settings.NEWS_LIMIT if settings.TOKEN_SAVE_MODE else 5
|
||||
news_text = ""
|
||||
if news:
|
||||
news_text = "\n뉴스:" + ",".join([item.get('title', '') for item in news[:news_limit]])
|
||||
|
||||
user_message = f"종목:{stock_code}\n시장:{_dumps(market_data)}{news_text}"
|
||||
|
||||
response = await self.analyze(system_prompt, user_message)
|
||||
return self._parse_json_response(
|
||||
response,
|
||||
{"trend": "UNKNOWN", "sentiment": "NEUTRAL", "recommendation": "HOLD", "confidence": 0.0}
|
||||
)
|
||||
|
||||
async def calculate_fair_price(
|
||||
self,
|
||||
stock_code: str,
|
||||
market_data: Dict[str, Any],
|
||||
financial_data: Dict[str, Any] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""AI 기반 적정 매수/매도 가격 산출"""
|
||||
system_prompt = """주식 가격 전략 전문가. JSON으로만 응답.
|
||||
필수 필드: buy_prices(aggressive,neutral,conservative), sell_prices(target1,target2,target3), stop_loss, strategy, confidence(0-1). JSON 외 텍스트 금지."""
|
||||
|
||||
user_message = (
|
||||
f"종목:{stock_code} 현재가:{market_data.get('current_price')} "
|
||||
f"고가:{market_data.get('high_price')} 저가:{market_data.get('low_price')} "
|
||||
f"거래량:{market_data.get('volume')} 지표:{_dumps(market_data.get('indicators', {}))}"
|
||||
)
|
||||
|
||||
response = await self.analyze(system_prompt, user_message)
|
||||
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. JSON 외 텍스트 금지."""
|
||||
|
||||
user_message = f"포트폴리오:{_dumps(portfolio)}\n거래계획:{_dumps(trade_plan)}"
|
||||
|
||||
response = await self.analyze(system_prompt, user_message)
|
||||
return self._parse_json_response(
|
||||
response,
|
||||
{"error": "Failed to assess risk"}
|
||||
)
|
||||
|
||||
async def optimize_trading_rules(
|
||||
self,
|
||||
stock_code: str,
|
||||
market_data: Dict[str, Any],
|
||||
news_data: List[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""시장 상황에 맞는 트레이딩 규칙 최적화"""
|
||||
system_prompt = """알고리즘 트레이딩 전략가. JSON으로만 응답.
|
||||
필수 필드: strategy_name, market_condition, parameters(buy_conditions,sell_conditions(take_profit_rate,stop_loss_rate,trailing_stop)), risk_assessment, confidence_score(0-100).
|
||||
악재시 보수적, 호재시 적극적 설정. JSON 외 텍스트 금지."""
|
||||
|
||||
user_message = f"종목:{stock_code}\n시장:{_dumps(market_data)}"
|
||||
if news_data:
|
||||
user_message += f"\n뉴스:{_dumps(news_data)}"
|
||||
|
||||
response = await self.analyze(system_prompt, user_message)
|
||||
return self._parse_json_response(
|
||||
response,
|
||||
{"error": "Failed to generate optimized rules"}
|
||||
)
|
||||
|
||||
async def generate_trade_journal(
|
||||
self,
|
||||
trade_info: Dict[str, Any],
|
||||
market_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""매매일지 자동 작성"""
|
||||
system_prompt = """매매일지 작성 전문가. JSON으로만 응답.
|
||||
필수 필드: title, content(간략 Markdown), mood(평정심/흥분/공포/아쉬움/만족), tags(배열), score(0-100).
|
||||
매매근거, 잘한점/아쉬운점, 개선사항 포함. JSON 외 텍스트 금지."""
|
||||
|
||||
user_message = f"거래:{_dumps(trade_info)}\n시장:{_dumps(market_data)}"
|
||||
|
||||
response = await self.analyze(system_prompt, user_message)
|
||||
return self._parse_json_response(
|
||||
response,
|
||||
{"error": "Failed to generate journal"}
|
||||
)
|
||||
|
||||
|
||||
gemini_client = GeminiClient()
|
||||
@@ -6,11 +6,14 @@ import logging
|
||||
|
||||
from config import settings
|
||||
from claude_client import claude_client
|
||||
from perplexity_client import perplexity_client
|
||||
from mcp_tools import mcp_tools
|
||||
|
||||
# AI 클라이언트 선택
|
||||
ai_client = perplexity_client if settings.AI_PROVIDER == "perplexity" else claude_client
|
||||
# AI_PROVIDER에 따라 클라이언트 동적 선택
|
||||
if settings.AI_PROVIDER == "gemini":
|
||||
from gemini_client import gemini_client
|
||||
ai_client = gemini_client
|
||||
else:
|
||||
ai_client = claude_client
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(
|
||||
@@ -61,7 +64,7 @@ async def health_check():
|
||||
"status": "healthy",
|
||||
"ai_provider": settings.AI_PROVIDER,
|
||||
"claude_configured": bool(settings.ANTHROPIC_API_KEY),
|
||||
"perplexity_configured": bool(settings.PERPLEXITY_API_KEY)
|
||||
"gemini_configured": bool(settings.GEMINI_API_KEY),
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +105,18 @@ async def analyze(request: AnalysisRequest):
|
||||
detail="rule and market_data are required"
|
||||
)
|
||||
|
||||
result = await ai_client.validate_trading_rule(rule, market_data)
|
||||
# 뉴스 정보 추가 수집
|
||||
stock_code = market_data.get("stock_code")
|
||||
news_data = []
|
||||
if stock_code:
|
||||
try:
|
||||
news_result = await mcp_tools.get_news(stock_code, limit=settings.NEWS_LIMIT)
|
||||
if "news" in news_result:
|
||||
news_data = news_result["news"]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch news for validation: {e}")
|
||||
|
||||
result = await ai_client.validate_trading_rule(rule, market_data, news_data)
|
||||
return {
|
||||
"analysis_type": analysis_type,
|
||||
"result": result
|
||||
@@ -162,6 +176,49 @@ async def analyze(request: AnalysisRequest):
|
||||
"analysis_type": analysis_type,
|
||||
"result": result
|
||||
}
|
||||
|
||||
elif analysis_type == "RULE_OPTIMIZATION":
|
||||
# 트레이딩 규칙 최적화/생성
|
||||
stock_code = data.get("stock_code")
|
||||
market_data = data.get("market_data")
|
||||
|
||||
if not stock_code or not market_data:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="stock_code and market_data are required"
|
||||
)
|
||||
|
||||
# 뉴스 정보 추가 수집
|
||||
news_data = []
|
||||
try:
|
||||
news_result = await mcp_tools.get_news(stock_code, limit=settings.NEWS_LIMIT)
|
||||
if "news" in news_result:
|
||||
news_data = news_result["news"]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch news for optimization: {e}")
|
||||
|
||||
result = await ai_client.optimize_trading_rules(stock_code, market_data, news_data)
|
||||
return {
|
||||
"analysis_type": analysis_type,
|
||||
"result": result
|
||||
}
|
||||
|
||||
elif analysis_type == "GENERATE_JOURNAL":
|
||||
# 매매일지 생성
|
||||
trade_info = data.get("trade_info")
|
||||
market_data = data.get("market_data")
|
||||
|
||||
if not trade_info or not market_data:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="trade_info and market_data are required"
|
||||
)
|
||||
|
||||
result = await ai_client.generate_trade_journal(trade_info, market_data)
|
||||
return {
|
||||
"analysis_type": analysis_type,
|
||||
"result": result
|
||||
}
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
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 logging
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from config import settings
|
||||
|
||||
# Optional: KIS API dependency (not currently available in MCP server directly)
|
||||
korea_investment_api = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MCPTools:
|
||||
"""MCP Tools Registry"""
|
||||
|
||||
|
||||
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):
|
||||
"""도구 등록"""
|
||||
@@ -155,17 +164,76 @@ class MCPTools:
|
||||
}
|
||||
|
||||
async def get_market_data(self, stock_code: str, period: str = "D") -> Dict[str, Any]:
|
||||
"""시장 데이터 조회 (실제로는 Korea Investment API 호출)"""
|
||||
# 여기서는 샘플 데이터 반환
|
||||
# 실제로는 backend API를 호출해야 함
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"period": period,
|
||||
"current_price": 70000,
|
||||
"change_rate": 1.5,
|
||||
"volume": 1000000,
|
||||
"data": []
|
||||
}
|
||||
"""시장 데이터 조회 (국내/해외 통합)"""
|
||||
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 (기본 NASD 가정)
|
||||
exchange = "NASD"
|
||||
# TODO: 거래소 판별 로직 고도화 필요 (현재는 NASD 고정)
|
||||
|
||||
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,
|
||||
@@ -230,18 +298,41 @@ class MCPTools:
|
||||
|
||||
async def check_portfolio(self, stock_code: str = None) -> Dict[str, Any]:
|
||||
"""포트폴리오 확인"""
|
||||
# 실제로는 backend API 호출
|
||||
return {
|
||||
"total_value": 10000000,
|
||||
"holdings": [
|
||||
{
|
||||
"stock_code": "005930",
|
||||
"quantity": 10,
|
||||
"average_price": 70000,
|
||||
"current_price": 71000
|
||||
}
|
||||
]
|
||||
}
|
||||
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,
|
||||
@@ -274,22 +365,88 @@ class MCPTools:
|
||||
price: float = None
|
||||
) -> Dict[str, Any]:
|
||||
"""주문 실행 (실제로는 backend API 호출)"""
|
||||
return {
|
||||
"order_id": "ORD20241104001",
|
||||
"status": "PENDING",
|
||||
"stock_code": stock_code,
|
||||
"order_type": order_type,
|
||||
"quantity": quantity,
|
||||
"price": price
|
||||
}
|
||||
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
|
||||
|
||||
async def get_news(self, stock_code: str = None, limit: int = 10) -> Dict[str, Any]:
|
||||
"""뉴스 조회"""
|
||||
# 실제로는 뉴스 API 호출
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"news": []
|
||||
}
|
||||
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)
|
||||
link = "https://finance.naver.com" + title_tag['href']
|
||||
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()
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
import httpx
|
||||
from config import settings
|
||||
from typing import Dict, Any, List
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PerplexityClient:
|
||||
"""Perplexity AI 클라이언트"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = settings.PERPLEXITY_API_KEY
|
||||
self.model = settings.PERPLEXITY_MODEL
|
||||
self.base_url = "https://api.perplexity.ai"
|
||||
|
||||
async def analyze(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_message: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Perplexity를 통한 분석 실행"""
|
||||
try:
|
||||
# API 키 검증
|
||||
if not self.api_key or not self.api_key.startswith("pplx-"):
|
||||
logger.warning("Invalid Perplexity API key format. Using mock response.")
|
||||
return self._mock_response(user_message)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_message}
|
||||
],
|
||||
"max_tokens": settings.MAX_TOKENS,
|
||||
"temperature": settings.TEMPERATURE,
|
||||
"top_p": 0.9,
|
||||
"stream": False
|
||||
},
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# 응답 파싱
|
||||
content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
return {
|
||||
"content": [{"type": "text", "text": content}],
|
||||
"usage": result.get("usage", {})
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Perplexity analysis error: {e}")
|
||||
logger.warning("Using mock response due to API error")
|
||||
return self._mock_response(user_message)
|
||||
|
||||
def _mock_response(self, user_message: str) -> Dict[str, Any]:
|
||||
"""Mock 응답 생성 (API 키 없을 때)"""
|
||||
# 분석 타입 감지
|
||||
if "적정" in user_message and "가격" in user_message:
|
||||
mock_text = """{
|
||||
"buy_prices": {
|
||||
"aggressive": 50000,
|
||||
"neutral": 52000,
|
||||
"conservative": 54000
|
||||
},
|
||||
"sell_prices": {
|
||||
"target1": 56000,
|
||||
"target2": 58000,
|
||||
"target3": 60000
|
||||
},
|
||||
"stop_loss": 48000,
|
||||
"strategy": "기술적 분석: 현재 이동평균선 정배열 상태로 상승 추세 지속 중. 지지선 48,000원 유지 시 56,000원까지 상승 가능. 거래량 증가 시 추가 상승 기대.",
|
||||
"confidence": 0.75
|
||||
}"""
|
||||
elif "시장" in user_message and "분석" in user_message:
|
||||
mock_text = """{
|
||||
"trend": "UPTREND",
|
||||
"sentiment": "POSITIVE",
|
||||
"recommendation": "BUY",
|
||||
"confidence": 0.7,
|
||||
"analysis": "기술적 분석 결과 상승 추세 지속 중. 거래량 증가와 함께 매수세 강화. 단기 목표가 달성 가능성 높음."
|
||||
}"""
|
||||
elif "규칙" in user_message or "검증" in user_message:
|
||||
mock_text = """{
|
||||
"decision": "BUY",
|
||||
"confidence": 0.75,
|
||||
"analysis": "현재 시장 상황이 규칙 조건을 만족. 이동평균선 정배열 및 거래량 증가 확인됨. 매수 시점 적절."
|
||||
}"""
|
||||
elif "리스크" in user_message:
|
||||
mock_text = """{
|
||||
"risk_level": "MEDIUM",
|
||||
"approval": "APPROVED",
|
||||
"confidence": 0.7,
|
||||
"analysis": "포트폴리오 분산 적절. 투자 금액 비중 관리 필요. 손절가 설정 권장."
|
||||
}"""
|
||||
else:
|
||||
mock_text = """{
|
||||
"analysis": "Mock AI 분석 결과",
|
||||
"confidence": 0.6
|
||||
}"""
|
||||
|
||||
return {
|
||||
"content": [{"type": "text", "text": mock_text}],
|
||||
"usage": {"input_tokens": 0, "output_tokens": 0}
|
||||
}
|
||||
|
||||
async def validate_trading_rule(
|
||||
self,
|
||||
rule: Dict[str, Any],
|
||||
market_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""트레이딩 규칙 검증"""
|
||||
system_prompt = """당신은 주식 트레이딩 규칙을 분석하고 검증하는 전문가입니다.
|
||||
주어진 규칙과 시장 데이터를 분석하여 다음을 제공하세요:
|
||||
1. 규칙이 현재 시장 상황에 적합한지 여부
|
||||
2. 실행 가능성 (0-1 사이의 점수)
|
||||
3. 리스크 평가
|
||||
4. 추천 사항
|
||||
|
||||
JSON 형식으로 응답하세요."""
|
||||
|
||||
user_message = f"""
|
||||
트레이딩 규칙:
|
||||
{json.dumps(rule, ensure_ascii=False, indent=2)}
|
||||
|
||||
시장 데이터:
|
||||
{json.dumps(market_data, ensure_ascii=False, indent=2)}
|
||||
|
||||
이 규칙이 현재 시장 상황에서 실행되어야 하는지 분석해주세요.
|
||||
"""
|
||||
|
||||
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": "분석 실패"
|
||||
}
|
||||
|
||||
async def analyze_market(
|
||||
self,
|
||||
stock_code: str,
|
||||
market_data: Dict[str, Any],
|
||||
news: List[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""시장 분석"""
|
||||
system_prompt = """당신은 주식 시장 분석 전문가입니다.
|
||||
주어진 시장 데이터와 뉴스를 분석하여 다음을 제공하세요:
|
||||
1. 시장 트렌드 (상승/하락/횡보)
|
||||
2. 주요 기술적 지표 분석
|
||||
3. 뉴스 센티먼트 (긍정/부정/중립)
|
||||
4. 투자 추천 (매수/매도/보유)
|
||||
5. 신뢰도 (0-1)
|
||||
|
||||
JSON 형식으로 응답하세요."""
|
||||
|
||||
news_text = ""
|
||||
if news:
|
||||
news_text = "\n\n최근 뉴스:\n" + "\n".join([
|
||||
f"- {item.get('title', '')}: {item.get('content', '')[:200]}"
|
||||
for item in news[:5]
|
||||
])
|
||||
|
||||
user_message = f"""
|
||||
종목 코드: {stock_code}
|
||||
|
||||
시장 데이터:
|
||||
{json.dumps(market_data, ensure_ascii=False, indent=2)}
|
||||
{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
|
||||
}
|
||||
|
||||
async def calculate_fair_price(
|
||||
self,
|
||||
stock_code: str,
|
||||
market_data: Dict[str, Any],
|
||||
financial_data: Dict[str, Any] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""AI 기반 적정 매수/매도 가격 산출"""
|
||||
system_prompt = """당신은 주식 가치 평가 및 가격 전략 전문가입니다.
|
||||
기술적 분석과 시장 데이터를 기반으로 다음을 제공하세요:
|
||||
1. 적정 매수 가격 (공격적, 중립적, 보수적)
|
||||
2. 적정 매도 가격 (목표가 1, 2, 3)
|
||||
3. 손절가
|
||||
4. 근거 및 전략
|
||||
5. 신뢰도 (0-1)
|
||||
|
||||
JSON 형식으로 응답하세요. 예시:
|
||||
{
|
||||
"buy_prices": {
|
||||
"aggressive": 100000,
|
||||
"neutral": 105000,
|
||||
"conservative": 110000
|
||||
},
|
||||
"sell_prices": {
|
||||
"target1": 120000,
|
||||
"target2": 130000,
|
||||
"target3": 140000
|
||||
},
|
||||
"stop_loss": 95000,
|
||||
"strategy": "기술적 분석 기반 설명...",
|
||||
"confidence": 0.75
|
||||
}"""
|
||||
|
||||
user_message = f"""
|
||||
종목 코드: {stock_code}
|
||||
|
||||
시장 데이터:
|
||||
- 현재가: {market_data.get('current_price')}
|
||||
- 고가: {market_data.get('high_price')}
|
||||
- 저가: {market_data.get('low_price')}
|
||||
- 거래량: {market_data.get('volume')}
|
||||
- 기술적 지표: {json.dumps(market_data.get('indicators', {}), ensure_ascii=False)}
|
||||
|
||||
이 종목의 적정 매수/매도 가격과 전략을 제시해주세요.
|
||||
"""
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async def assess_risk(
|
||||
self,
|
||||
portfolio: Dict[str, Any],
|
||||
trade_plan: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""리스크 평가"""
|
||||
system_prompt = """당신은 투자 리스크 관리 전문가입니다.
|
||||
현재 포트폴리오와 계획된 거래를 분석하여 다음을 제공하세요:
|
||||
1. 리스크 레벨 (LOW/MEDIUM/HIGH/CRITICAL)
|
||||
2. 주요 리스크 요인
|
||||
3. 포트폴리오 다양성 평가
|
||||
4. 추천 사항
|
||||
5. 승인 여부 (APPROVED/REJECTED)
|
||||
|
||||
JSON 형식으로 응답하세요."""
|
||||
|
||||
user_message = f"""
|
||||
현재 포트폴리오:
|
||||
{json.dumps(portfolio, ensure_ascii=False, indent=2)}
|
||||
|
||||
계획된 거래:
|
||||
{json.dumps(trade_plan, ensure_ascii=False, indent=2)}
|
||||
|
||||
이 거래의 리스크를 평가하고 실행 여부를 결정해주세요.
|
||||
"""
|
||||
|
||||
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 {
|
||||
"risk_level": "UNKNOWN",
|
||||
"approval": "REJECTED",
|
||||
"confidence": 0.0
|
||||
}
|
||||
|
||||
|
||||
perplexity_client = PerplexityClient()
|
||||
@@ -1,6 +1,7 @@
|
||||
fastapi>=0.104.1
|
||||
uvicorn[standard]>=0.24.0
|
||||
anthropic>=0.18.0
|
||||
google-genai>=1.0.0
|
||||
pydantic>=2.5.0
|
||||
python-dotenv==1.0.0
|
||||
httpx==0.25.2
|
||||
@@ -10,3 +11,4 @@ ta==0.11.0
|
||||
sqlalchemy==2.0.23
|
||||
asyncpg>=0.30.0
|
||||
redis==5.0.1
|
||||
beautifulsoup4>=4.12.0
|
||||
|
||||
178
mcp-server/stdio_server.py
Executable file
178
mcp-server/stdio_server.py
Executable file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MCP Server with stdio transport for Claude CLI
|
||||
|
||||
This server provides stock trading tools via Model Context Protocol
|
||||
using stdio transport for direct integration with Claude CLI.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from typing import Any
|
||||
import httpx
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
# Import configuration
|
||||
from config import settings
|
||||
from utils import _dumps
|
||||
|
||||
# Initialize MCP Server
|
||||
app = Server("auto-stock-mcp")
|
||||
|
||||
# Backend API base URL
|
||||
BACKEND_URL = "http://localhost:8000/api/v1"
|
||||
|
||||
|
||||
@app.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
"""List available tools"""
|
||||
return [
|
||||
Tool(
|
||||
name="get_portfolio",
|
||||
description="Get current portfolio information including holdings and balance",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="get_market_data",
|
||||
description="Get real-time market data for a specific stock",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stock_code": {
|
||||
"type": "string",
|
||||
"description": "Stock code (e.g., '005930' for Samsung)"
|
||||
}
|
||||
},
|
||||
"required": ["stock_code"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="get_recommendations",
|
||||
description="Get AI-generated stock recommendations based on technical analysis",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="get_trade_history",
|
||||
description="Get recent trade history",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"description": "Number of trades to return (default: 20)"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="analyze_stock",
|
||||
description="Perform detailed technical analysis on a stock",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stock_code": {
|
||||
"type": "string",
|
||||
"description": "Stock code to analyze"
|
||||
}
|
||||
},
|
||||
"required": ["stock_code"]
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@app.call_tool()
|
||||
async def call_tool(name: str, arguments: Any) -> list[TextContent]:
|
||||
"""Handle tool calls"""
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
if name == "get_portfolio":
|
||||
response = await client.get(f"{BACKEND_URL}/portfolio")
|
||||
data = response.json()
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=_dumps(data)
|
||||
)]
|
||||
|
||||
elif name == "get_market_data":
|
||||
stock_code = arguments.get("stock_code")
|
||||
response = await client.get(
|
||||
f"{BACKEND_URL}/market-data/prices",
|
||||
params={"codes": stock_code}
|
||||
)
|
||||
data = response.json()
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=_dumps(data)
|
||||
)]
|
||||
|
||||
elif name == "get_recommendations":
|
||||
response = await client.get(f"{BACKEND_URL}/recommendations/rule-based")
|
||||
data = response.json()
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=_dumps(data)
|
||||
)]
|
||||
|
||||
elif name == "get_trade_history":
|
||||
limit = arguments.get("limit", 10 if settings.TOKEN_SAVE_MODE else 20)
|
||||
response = await client.get(
|
||||
f"{BACKEND_URL}/trades",
|
||||
params={"limit": limit}
|
||||
)
|
||||
data = response.json()
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=_dumps(data)
|
||||
)]
|
||||
|
||||
elif name == "analyze_stock":
|
||||
stock_code = arguments.get("stock_code")
|
||||
# Get chart data
|
||||
response = await client.get(
|
||||
f"{BACKEND_URL}/market-data/chart/{stock_code}",
|
||||
params={"period": "D", "realtime": "true"}
|
||||
)
|
||||
data = response.json()
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=_dumps(data)
|
||||
)]
|
||||
|
||||
else:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=f"Unknown tool: {name}"
|
||||
)]
|
||||
|
||||
except Exception as e:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=f"Error calling tool {name}: {str(e)}"
|
||||
)]
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run the MCP server with stdio transport"""
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await app.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
app.create_initialization_options()
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
169
mcp-server/trade_monitor.py
Normal file
169
mcp-server/trade_monitor.py
Normal file
@@ -0,0 +1,169 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
# backend 경로를 sys.path에 추가하여 서비스 모듈 임포트
|
||||
import sys
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../backend')))
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.models import ActiveTrade
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
# --- DB 설정 ---
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
# ----------------
|
||||
|
||||
ACTIVE_TRADES_FILE = os.path.join(os.path.dirname(__file__), 'active_trades.json')
|
||||
MONITORING_INTERVAL_SECONDS = 60 # 60초마다 확인
|
||||
|
||||
async def sync_json_from_db(db: AsyncSession):
|
||||
"""DB의 active_trades 목록을 JSON 파일에 동기화(덮어쓰기)합니다."""
|
||||
try:
|
||||
result = await db.execute(select(ActiveTrade).where(ActiveTrade.status == 'active'))
|
||||
active_trades_from_db = result.scalars().all()
|
||||
|
||||
json_trades = [
|
||||
{
|
||||
'stock_code': trade.stock_code,
|
||||
'stock_name': trade.stock_name,
|
||||
'quantity': trade.quantity,
|
||||
'entry_price': float(trade.entry_price),
|
||||
'stop_loss': float(trade.stop_loss),
|
||||
'take_profit': float(trade.take_profit),
|
||||
'order_id': trade.order_id,
|
||||
'reason': trade.entry_reason,
|
||||
'timestamp': trade.created_at.isoformat()
|
||||
}
|
||||
for trade in active_trades_from_db
|
||||
]
|
||||
|
||||
with open(ACTIVE_TRADES_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(json_trades, f, indent=4, ensure_ascii=False)
|
||||
logging.info(f"💾 [JSON] DB와 동기화 완료. 활성 거래 {len(json_trades)}건.")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"❌ [JSON] DB 동기화 실패: {e}")
|
||||
|
||||
|
||||
async def check_and_execute_trades(db: AsyncSession):
|
||||
"""
|
||||
DB에서 활성화된 거래를 확인하고 손절/익절 조건에 따라 매도 주문을 실행합니다.
|
||||
"""
|
||||
logging.info(" 거래 모니터링 시작 (DB 기반)...")
|
||||
|
||||
result = await db.execute(select(ActiveTrade).where(ActiveTrade.status == 'active'))
|
||||
active_trades = result.scalars().all()
|
||||
|
||||
if not active_trades:
|
||||
logging.info("현재 활성화된 거래가 없습니다.")
|
||||
return
|
||||
|
||||
for trade in active_trades:
|
||||
stock_code = trade.stock_code
|
||||
stop_loss = float(trade.stop_loss)
|
||||
take_profit = float(trade.take_profit)
|
||||
quantity = trade.quantity
|
||||
entry_price = float(trade.entry_price)
|
||||
|
||||
should_sell = False
|
||||
sell_reason = ""
|
||||
|
||||
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}
|
||||
)
|
||||
|
||||
if price_info and price_info.get('rt_cd') == '0':
|
||||
current_price_str = price_info.get('output', {}).get('stck_prpr')
|
||||
if not current_price_str:
|
||||
logging.error(f"[{stock_code}] 현재가 정보를 응답에서 찾을 수 없습니다: {price_info}")
|
||||
continue
|
||||
|
||||
current_price = int(current_price_str)
|
||||
logging.info(f"[{stock_code}] 현재가: {current_price} (매수가: {entry_price}, 손절가: {stop_loss}, 목표가: {take_profit})")
|
||||
|
||||
# 손절 조건 확인
|
||||
if stop_loss and current_price <= stop_loss:
|
||||
should_sell = True
|
||||
sell_reason = f"손절가 도달 (현재가: {current_price} <= 손절가: {stop_loss})"
|
||||
|
||||
# 익절 조건 확인
|
||||
elif take_profit and current_price >= take_profit:
|
||||
should_sell = True
|
||||
sell_reason = f"목표가 도달 (현재가: {current_price} >= 목표가: {take_profit})"
|
||||
|
||||
else:
|
||||
logging.error(f"[{stock_code}] 현재가 조회 실패: {price_info.get('msg1', '알 수 없는 오류')}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[{stock_code}] 처리 중 예외 발생: {e}")
|
||||
continue
|
||||
|
||||
if should_sell:
|
||||
logging.warning(f"🚨 [{stock_code}] 매도 조건 충족: {sell_reason}")
|
||||
try:
|
||||
# 시장가 매도 주문 (price=None)
|
||||
sell_result = await korea_investment_api.place_order(
|
||||
stock_code=stock_code,
|
||||
order_type='SELL',
|
||||
quantity=quantity,
|
||||
price=None
|
||||
)
|
||||
logging.info(f"[{stock_code}] 매도 주문 결과: {sell_result}")
|
||||
|
||||
if sell_result and sell_result.get('rt_cd') == '0':
|
||||
logging.info(f"✅ [{stock_code}] 매도 주문 성공. DB 상태를 'closed'로 변경합니다.")
|
||||
trade.status = 'closed'
|
||||
trade.updated_at = datetime.utcnow()
|
||||
db.add(trade)
|
||||
await db.commit()
|
||||
else:
|
||||
logging.error(f"❌ [{stock_code}] 매도 주문 실패: {sell_result.get('msg1', '알 수 없는 오류')}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[{stock_code}] 매도 주문 중 예외 발생: {e}")
|
||||
await db.rollback()
|
||||
|
||||
async def main():
|
||||
"""메인 실행 루프"""
|
||||
logging.info("🚀 자동 매도 모니터링 시스템을 시작합니다. (DB 모드)")
|
||||
logging.info(f"점검 주기: {MONITORING_INTERVAL_SECONDS}초")
|
||||
|
||||
# 시작 시 DB와 JSON 동기화
|
||||
async with AsyncSessionLocal() as db:
|
||||
await sync_json_from_db(db)
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await check_and_execute_trades(db)
|
||||
# 매 사이클 종료 후 DB와 JSON 동기화
|
||||
await sync_json_from_db(db)
|
||||
except Exception as e:
|
||||
logging.critical(f"모니터링 루프에서 치명적 오류 발생: {e}", exc_info=True)
|
||||
|
||||
logging.info(f"{MONITORING_INTERVAL_SECONDS}초 후 다음 점검을 시작합니다.\n")
|
||||
await asyncio.sleep(MONITORING_INTERVAL_SECONDS)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logging.info("사용자에 의해 프로그램이 종료되었습니다.")
|
||||
10
mcp-server/utils.py
Normal file
10
mcp-server/utils.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""공용 유틸리티 (MCP 서버)"""
|
||||
import json
|
||||
from config import settings
|
||||
|
||||
|
||||
def _dumps(obj) -> str:
|
||||
"""토큰 절약 모드에 따라 compact/pretty JSON 직렬화"""
|
||||
if settings.JSON_COMPACT or settings.TOKEN_SAVE_MODE:
|
||||
return json.dumps(obj, ensure_ascii=False, separators=(',', ':'))
|
||||
return json.dumps(obj, ensure_ascii=False, indent=2)
|
||||
Reference in New Issue
Block a user