feat: 자동매매 신뢰성 개선 — 시세 실서버 라우팅·일일 한도 전역화·수동 매수 API

- korea_investment_api: KIS_QUOTES_VIA_REAL — 모의투자 모드에서 시세/차트/호가 조회를
  실계좌 시세 서버(20/s)로 위임, 주문·잔고는 모의서버 유지 (EGW00201 대응)
- recommendations: execute_auto_trade_background asyncio.Lock 중복 실행 방지 +
  MAX_DAILY_TRADES를 당일 BUY 건수 차감 전역 기준으로 적용 (11종목 동시 매수 재발 방지)
- manual_buy.py 신규: POST /api/v1/portfolio/buy (리스크 가드 + safe_place_order + db_worker)
- mcp-server: claude_code_client(claude -p 헤드리스 모드) 추가, trade_monitor 부분매도 시
  active 유지·remaining_pct 갱신, API 키 헤더·/api/v1 경로 수정, ODNO 주문ID 수정
- config: MCP_REQUEST_TIMEOUT, MCP_RISK_CACHE_TTL, BATCH_ANALYSIS_INTERVAL,
  SWING_INDEX_BREAKER_ENABLED(OFF), API_TRIGGERED_AUTO_TRADE(OFF) 추가
- docker-compose: 포트 충돌 해소 (postgres 15433, frontend 3001)
- frontend: 추천 패널 매수 버튼, 차트/호가/저널 패널 개선
- CLAUDE.md 압축 최적화 (규칙 변경 없음), _sweep_single_axis.py 백테스트 단일축 스윕

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-19 23:37:08 +09:00
parent b1da208fd5
commit 028a1c352b
39 changed files with 1073 additions and 694 deletions

View File

@@ -2,6 +2,13 @@ FROM python:3.11-slim
WORKDIR /app
# Node.js + Claude Code CLI (AI_PROVIDER=claude_code 테스트 모드용 — `claude -p` 헤드리스 호출)
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates git \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& npm install -g @anthropic-ai/claude-code \
&& apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
# Python 의존성 설치
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
@@ -9,6 +16,11 @@ RUN pip install --no-cache-dir -r requirements.txt
# 애플리케이션 코드 복사
COPY . .
# claude CLI는 root 실행 시 일부 기능 제한 → 전용 사용자로 실행
RUN useradd -m -u 1000 mcp && chown -R mcp:mcp /app
USER mcp
ENV HOME=/home/mcp
# 포트 노출
EXPOSE 8001

View File

@@ -77,7 +77,7 @@ class ClaudeClient:
params = {
"model": use_model,
"max_tokens": max_tokens or settings.MAX_TOKENS,
"temperature": settings.TEMPERATURE,
# anthropic SDK 1.x: temperature 등 샘플링 파라미터 제거됨 (전달 시 TypeError)
"system": [
{
"type": "text",

View File

@@ -0,0 +1,124 @@
"""Claude Code CLI 기반 AI 클라이언트 (테스트/개발용).
Anthropic API 크레딧 대신 Claude Code 구독(`claude -p` 헤드리스 모드)으로 분석을 실행한다.
ClaudeClient와 동일한 인터페이스 — `analyze()`만 서브프로세스 호출로 교체하고
나머지 도메인 메서드(assess_risk, analyze_market 등)는 그대로 상속.
사용:
AI_PROVIDER=claude_code
CLAUDE_CODE_OAUTH_TOKEN=<`claude setup-token` 출력> # 컨테이너에서 실행 시 필수
제약:
- API tool_use(함수 호출) 미지원 → tools 인자는 무시하고 텍스트(JSON) 응답만 사용.
- 프롬프트 캐시 usage 는 CLI가 보고하는 값을 그대로 전달.
"""
import asyncio
import json
import logging
import os
import shutil
import tempfile
from typing import Any, Dict, List
from config import settings
from claude_client import ClaudeClient, _CACHED_CONTEXT
logger = logging.getLogger(__name__)
_CLI_TIMEOUT = float(os.getenv("CLAUDE_CODE_TIMEOUT", "120"))
_CLI_BIN = os.getenv("CLAUDE_CODE_BIN", "claude")
_MAX_CONCURRENT = int(os.getenv("CLAUDE_CODE_MAX_CONCURRENT", "2"))
# 프로젝트 .claude/ 설정(훅·CLAUDE.md)이 끼어들지 않도록 중립 디렉터리에서 실행
_CLI_CWD = os.getenv("CLAUDE_CODE_CWD") or os.path.join(tempfile.gettempdir(), "autostock-claude-code")
class ClaudeCodeClient(ClaudeClient):
"""`claude -p` 서브프로세스로 ClaudeClient.analyze() 를 대체."""
def __init__(self):
# 부모 __init__ 은 AsyncAnthropic(api_key) 를 만들지만 여기서는 SDK 미사용
self.client = None
self.model = settings.CLAUDE_MODEL
self.haiku_model = settings.CLAUDE_HAIKU_MODEL
self._sem = asyncio.Semaphore(_MAX_CONCURRENT)
os.makedirs(_CLI_CWD, exist_ok=True)
if shutil.which(_CLI_BIN) is None:
logger.error("[ClaudeCode] '%s' 실행 파일을 찾을 수 없음 — 분석 호출은 모두 실패합니다", _CLI_BIN)
if not os.getenv("CLAUDE_CODE_OAUTH_TOKEN") and not os.getenv("ANTHROPIC_API_KEY"):
logger.warning("[ClaudeCode] CLAUDE_CODE_OAUTH_TOKEN 미설정 — 호스트 로그인 세션이 없으면 인증 실패")
async def analyze(
self,
system_prompt: str,
user_message: str,
tools: List[Dict[str, Any]] = None,
max_tokens: int = None,
model_override: str = None,
) -> Dict[str, Any]:
use_model = model_override or self.model
if tools:
logger.debug("[ClaudeCode] tools %d개 무시 (CLI 모드는 tool_use 미지원)", len(tools))
full_system = f"{_CACHED_CONTEXT}\n\n{system_prompt}"
cmd = [
_CLI_BIN, "-p", user_message,
"--output-format", "json",
"--model", use_model,
"--system-prompt", full_system,
"--tools", "", # 파일/셸 도구 비활성 — 순수 텍스트 분석
"--max-turns", "1",
]
env = os.environ.copy()
env.pop("ANTHROPIC_API_KEY", None) # 크레딧 없는 API 키가 구독 인증을 가리지 않도록
try:
async with self._sem:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
cwd=_CLI_CWD,
)
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout=_CLI_TIMEOUT)
except asyncio.TimeoutError:
proc.kill()
raise RuntimeError(f"claude CLI timeout ({_CLI_TIMEOUT}s)")
if proc.returncode != 0:
raise RuntimeError(
f"claude CLI exit={proc.returncode}: {err.decode(errors='ignore')[-500:]}"
)
data = json.loads(out.decode())
if data.get("is_error"):
raise RuntimeError(f"claude CLI error: {data.get('result')}")
usage = data.get("usage") or {}
result = {
"content": [{"type": "text", "text": data.get("result", "")}],
"stop_reason": data.get("stop_reason", "end_turn"),
"model_used": use_model,
"usage": {
"input_tokens": usage.get("input_tokens", 0),
"output_tokens": usage.get("output_tokens", 0),
"cache_read_tokens": usage.get("cache_read_input_tokens", 0),
"cache_write_tokens": usage.get("cache_creation_input_tokens", 0),
},
}
logger.debug(
"[ClaudeCode] model=%s in=%s out=%s cost=$%s",
use_model, usage.get("input_tokens"), usage.get("output_tokens"),
data.get("total_cost_usd"),
)
return result
except Exception as e:
logger.error(f"Claude Code analysis error: {e}")
raise
claude_code_client = ClaudeCodeClient()

View File

@@ -42,7 +42,7 @@ class Settings:
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
# Claude Settings
CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-6-20250514")
CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-6")
CLAUDE_HAIKU_MODEL = os.getenv("CLAUDE_HAIKU_MODEL", "claude-haiku-4-5-20251001")
# Gemini Settings
@@ -69,7 +69,7 @@ class Settings:
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:
else: # claude | claude_code
self.MAX_TOKENS = int(os.getenv("CLAUDE_MAX_TOKENS", "1024"))
self.TEMPERATURE = float(os.getenv("CLAUDE_TEMPERATURE", "0.3"))

View File

@@ -59,8 +59,8 @@ class GeminiClient:
temperature=self.config.temperature,
)
# 비동기 호출
response = await self.client.models.generate_content(
# 비동기 호출 (google-genai SDK: models.generate_content는 동기 함수 — aio 네임스페이스 사용)
response = await self.client.aio.models.generate_content(
model=self.model_id,
contents=user_message,
config=config_with_system

View File

@@ -35,6 +35,10 @@ from mcp_tools import mcp_tools
if settings.AI_PROVIDER == "gemini":
from gemini_client import gemini_client
ai_client = gemini_client
elif settings.AI_PROVIDER == "claude_code":
# Claude Code 구독(`claude -p`)으로 실행 — API 크레딧 불필요 (테스트용)
from claude_code_client import claude_code_client
ai_client = claude_code_client
else:
ai_client = claude_client
@@ -118,7 +122,7 @@ async def health_check():
return {
"status": "healthy",
"ai_provider": settings.AI_PROVIDER,
"hybrid_ai_enabled": settings.HYBRID_AI_ENABLED if settings.AI_PROVIDER == "claude" else False,
"hybrid_ai_enabled": settings.HYBRID_AI_ENABLED if settings.AI_PROVIDER in ("claude", "claude_code") else False,
"claude_model": settings.CLAUDE_MODEL,
"claude_haiku_model": settings.CLAUDE_HAIKU_MODEL,
"claude_configured": bool(settings.ANTHROPIC_API_KEY),

View File

@@ -35,7 +35,7 @@ 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 Portfolio, ActiveTrade, TradeHistory
from app.models.models import Portfolio, ActiveTrade, TradeHistory, StockBan
from app.services.korea_investment_api import korea_investment_api
logger = logging.getLogger(__name__)
@@ -234,6 +234,16 @@ class RiskValidator:
# Import MDD manager
from app.services.mdd_manager import mdd_manager
# MCP는 백엔드와 별도 프로세스 — 인메모리 mdd_manager는 항상 초기 상태이므로
# 판정 전에 백엔드가 Redis에 저장한 최신 상태를 복원한다.
# 복원 실패 시 fail-open 유지 (기존 인메모리 상태로 계속 진행 + 경고 로그).
try:
restored = await mdd_manager.load_from_redis()
if not restored:
logger.warning("MDD state restore from Redis returned no data (fail-open, using in-memory state)")
except Exception as redis_err:
logger.warning(f"MDD state restore from Redis failed (fail-open): {redis_err}")
# Check if new positions are paused due to MDD
can_open = await mdd_manager.can_open_new_position()

View File

@@ -30,7 +30,14 @@ ACTIVE_TRADES_FILE = os.path.join(os.path.dirname(__file__), 'active_trades.json
MONITORING_INTERVAL_SECONDS = 60 # 60초마다 확인
BACKEND_API_BASE = os.getenv('BACKEND_API_BASE', 'http://localhost:8000')
async def update_active_trade_state_via_api(stock_code: str, new_status: str, reason: str = "") -> bool:
# 백엔드 require_api_key와 동일한 소스 (app/core/security.py: API_KEY 우선, 없으면 SECRET_KEY 폴백)
API_KEY = settings.API_KEY if settings.API_KEY else settings.SECRET_KEY
_API_HEADERS = {"X-API-Key": API_KEY}
async def update_active_trade_state_via_api(
stock_code: str, new_status: str, reason: str = "", remaining_pct: float = None
) -> bool:
"""
CLAUDE.md Section 3.2: 직접 DB 접근 금지. 백엔드 API를 통해 상태 업데이트.
@@ -41,20 +48,25 @@ async def update_active_trade_state_via_api(stock_code: str, new_status: str, re
stock_code: 종목 코드
new_status: 목표 상태 ('active', 'pending_sell', 'closed')
reason: 상태 변경 이유
remaining_pct: 부분 매도 후 잔여 비율 (제공 시 ActiveTrade.remaining_pct 갱신)
Returns:
성공 여부
"""
try:
async with aiohttp.ClientSession() as session:
url = f"{BACKEND_API_BASE}/api/portfolio/active-trades/{stock_code}/status"
url = f"{BACKEND_API_BASE}/api/v1/portfolio/active-trades/{stock_code}/status"
payload = {
"status": new_status,
"reason": reason,
"updated_at": datetime.utcnow().isoformat()
}
if remaining_pct is not None:
payload["remaining_pct"] = remaining_pct
async with session.patch(url, json=payload, timeout=aiohttp.ClientTimeout(total=5)) as resp:
async with session.patch(
url, json=payload, headers=_API_HEADERS, timeout=aiohttp.ClientTimeout(total=5)
) as resp:
if resp.status == 200:
logging.info(f"✅ [{stock_code}] API를 통해 상태 변경: {new_status}")
return True
@@ -240,6 +252,7 @@ async def check_and_execute_trades(db: AsyncSession):
should_sell = False
sell_reason = ""
sell_quantity = 0
new_remaining_pct = 0.0 # 매도 후 잔여 비율 (0.0 = 전량 매도 → closed)
try:
# 현재가 조회
@@ -269,6 +282,7 @@ async def check_and_execute_trades(db: AsyncSession):
should_sell = True
sell_quantity = int(quantity * remaining_pct)
sell_reason = f"손절가 도달 (현재가: {current_price} <= 손절가: {stop_loss})"
new_remaining_pct = 0.0 # 전량 매도 → closed
# ─────────────────────────────────────────────────────────────
# 2. 기본 익절 조건 확인
@@ -277,6 +291,7 @@ async def check_and_execute_trades(db: AsyncSession):
should_sell = True
sell_quantity = int(quantity * remaining_pct)
sell_reason = f"목표가 도달 (현재가: {current_price} >= 목표가: {take_profit})"
new_remaining_pct = 0.0 # 전량 매도 → closed
# ─────────────────────────────────────────────────────────────
# 3. 부분 익절 + 트레일링 스탑 조건 (CLAUDE.md 5.2)
@@ -296,11 +311,13 @@ async def check_and_execute_trades(db: AsyncSession):
if should_tp:
should_sell = True
if remaining_pct == 1.0:
# 첫 익절: 50% 분할 매도
# 첫 익절: 50% 분할 매도 — 잔량 50%는 'active'로 유지 (CLAUDE.md 5.2)
sell_quantity = int(quantity * 0.5)
new_remaining_pct = remaining_pct * 0.5
else:
# 트레일링 스탑: 남은 물량 전부
# 트레일링 스탑: 남은 물량 전부 → closed
sell_quantity = int(quantity * remaining_pct)
new_remaining_pct = 0.0
sell_reason = tp_reason
else:
@@ -313,7 +330,7 @@ async def check_and_execute_trades(db: AsyncSession):
if should_sell and sell_quantity > 0:
await _execute_sell_with_pending_state(
stock_code, quantity, sell_quantity, entry_price, sell_reason
stock_code, quantity, sell_quantity, entry_price, sell_reason, new_remaining_pct
)
@@ -322,7 +339,8 @@ async def _execute_sell_with_pending_state(
total_quantity: int,
sell_quantity: int,
entry_price: float,
sell_reason: str
sell_reason: str,
new_remaining_pct: float = 0.0
) -> None:
"""
CLAUDE.md Section 5.2: 매도 주문 실행 및 상태 전이
@@ -330,7 +348,9 @@ async def _execute_sell_with_pending_state(
단계:
1. 상태를 'pending_sell'로 변경 (즉시)
2. 매도 주문 실행
3. 주문 성공 시 'closed'로 변경 (1-5초 대기)
3. 주문 성공 시 상태 전이 (1-5초 대기)
- new_remaining_pct > 0 (부분 매도): 'active' 유지 + remaining_pct 갱신
- new_remaining_pct == 0 (전량 매도): 'closed'로 전이
4. 실패 시 'active'로 롤백
Args:
@@ -339,6 +359,7 @@ async def _execute_sell_with_pending_state(
sell_quantity: 매도할 수량
entry_price: 진입가
sell_reason: 매도 이유
new_remaining_pct: 매도 후 잔여 비율 (0.0이면 전량 매도 → closed)
"""
try:
logging.warning(
@@ -374,24 +395,41 @@ async def _execute_sell_with_pending_state(
logging.info(f"[{stock_code}] 매도 주문 결과: {sell_result}")
if sell_result and sell_result.get('rt_cd') == '0':
order_id = sell_result.get('output', {}).get('order_id', '')
order_id = sell_result.get('output', {}).get('ODNO', '')
logging.info(f"✅ [{stock_code}] 매도 주문 성공 (주문ID: {order_id})")
# ─────────────────────────────────────────────────────────
# Step 3: 1-5초 대기 후 체결 확인 및 상태 'closed'로 변경
# Step 3: 1-5초 대기 후 체결 확인 및 상태 전이
# CLAUDE.md 5.2: 부분 매도(+2R 50%)는 'active' 유지 + remaining_pct 갱신,
# 전량 매도만 'closed'로 전이 (잔량이 감시에서 이탈하지 않도록)
# ─────────────────────────────────────────────────────────
await asyncio.sleep(2) # 체결 대기
success = await update_active_trade_state_via_api(
stock_code,
"closed",
f"매도 완료: {sell_reason} (주문ID: {order_id})"
)
if success:
logging.info(f"✅ [{stock_code}] 상태: pending_sell → closed")
if new_remaining_pct > 0:
success = await update_active_trade_state_via_api(
stock_code,
"active",
f"부분 매도 완료: {sell_reason} (주문ID: {order_id})",
remaining_pct=new_remaining_pct
)
if success:
logging.info(
f"✅ [{stock_code}] 상태: pending_sell → active "
f"(잔여비율: {new_remaining_pct*100:.1f}%)"
)
else:
logging.warning(f"⚠️ [{stock_code}] 부분매도 후 active 전이 실패 (order_id: {order_id})")
else:
logging.warning(f"⚠️ [{stock_code}] closed 상태 전이 실패 (order_id: {order_id})")
success = await update_active_trade_state_via_api(
stock_code,
"closed",
f"매도 완료: {sell_reason} (주문ID: {order_id})"
)
if success:
logging.info(f"✅ [{stock_code}] 상태: pending_sell → closed")
else:
logging.warning(f"⚠️ [{stock_code}] closed 상태 전이 실패 (order_id: {order_id})")
else:
error_msg = sell_result.get('msg1', '알 수 없는 오류') if sell_result else "응답 없음"
logging.error(f"❌ [{stock_code}] 매도 주문 실패: {error_msg}")