## 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>
448 lines
18 KiB
Python
448 lines
18 KiB
Python
"""리스크 검증 모듈 (risk_validator.py) — MCP 서버용
|
|
|
|
모든 매매 주문을 실행 전에 CLAUDE.md 섹션 5 리스크 규칙으로 검증한다.
|
|
|
|
검증 항목:
|
|
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
|
|
import logging
|
|
import sys
|
|
import os
|
|
from typing import Optional, Dict, Tuple
|
|
from datetime import datetime, timezone
|
|
from decimal import Decimal
|
|
|
|
# Backend imports
|
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../backend')))
|
|
|
|
from sqlalchemy import select
|
|
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.services.korea_investment_api import korea_investment_api
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Backend DB connection
|
|
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 = 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
|
|
|
|
# Leverage/Inverse ETF identifiers
|
|
LEVERAGE_ETF_KEYWORDS = ['레버리지', 'LEVERAGE', '2X', '3X', 'INVERSE', '인버스', '-1X', '-2X', '-3X']
|
|
|
|
|
|
class RiskValidationResult:
|
|
"""Result of a risk validation check"""
|
|
def __init__(self, allowed: bool, reason: str = "", details: Optional[Dict] = None):
|
|
self.allowed = allowed
|
|
self.reason = reason
|
|
self.details = details or {}
|
|
self.timestamp = datetime.now(timezone.utc).isoformat()
|
|
|
|
def __repr__(self):
|
|
return f"RiskValidationResult(allowed={self.allowed}, reason='{self.reason}')"
|
|
|
|
|
|
class RiskValidator:
|
|
"""Risk validation engine for Claude-based auto trading"""
|
|
|
|
def __init__(self):
|
|
self.db_session = None
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def _get_session(self) -> AsyncSession:
|
|
"""Get or create DB session.
|
|
|
|
DEPRECATED: 새 코드에서는 _session_ctx()를 사용하세요.
|
|
기존 시그니처 유지를 위해 남겨두되, 내부적으로 새 세션을 생성합니다.
|
|
"""
|
|
if self.db_session is None:
|
|
self.db_session = AsyncSessionLocal()
|
|
return self.db_session
|
|
|
|
async def _close_session(self):
|
|
"""Close DB session"""
|
|
if self.db_session is not None:
|
|
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
|
|
|
|
Args:
|
|
stock_code: Stock code to validate
|
|
market: Market code (default: KRX for domestic)
|
|
|
|
Returns:
|
|
RiskValidationResult
|
|
"""
|
|
try:
|
|
# Skip price check for overseas/ETF markets
|
|
if market and market not in ["KRX", "KOSPI", "KOSDAQ"]:
|
|
logger.info(f"[{stock_code}] Price filter skipped (overseas market: {market})")
|
|
return RiskValidationResult(allowed=True)
|
|
|
|
# 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 current_price or current_price <= 0:
|
|
return RiskValidationResult(
|
|
allowed=False,
|
|
reason=f"Failed to fetch current price for {stock_code}"
|
|
)
|
|
|
|
if current_price < MIN_DOMESTIC_PRICE:
|
|
return RiskValidationResult(
|
|
allowed=False,
|
|
reason=f"Price filter violation: {current_price} KRW < {MIN_DOMESTIC_PRICE} KRW minimum",
|
|
details={"current_price": current_price, "min_price": MIN_DOMESTIC_PRICE}
|
|
)
|
|
|
|
logger.info(f"[{stock_code}] Price filter passed: {current_price} KRW")
|
|
return RiskValidationResult(
|
|
allowed=True,
|
|
details={"current_price": current_price}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Price filter check error for {stock_code}: {e}")
|
|
return RiskValidationResult(allowed=False, reason=f"Price check error: {str(e)}")
|
|
|
|
async def validate_position_size(self, stock_code: str, order_quantity: int, entry_price: float,
|
|
account_balance: float) -> RiskValidationResult:
|
|
"""
|
|
Check position sizing: max 3-4% per stock
|
|
|
|
Args:
|
|
stock_code: Stock code to validate
|
|
order_quantity: Number of shares to buy
|
|
entry_price: Entry price per share
|
|
account_balance: Current account balance
|
|
|
|
Returns:
|
|
RiskValidationResult
|
|
"""
|
|
try:
|
|
order_value = order_quantity * entry_price
|
|
position_pct = (order_value / account_balance) * 100 if account_balance > 0 else 0
|
|
|
|
if position_pct > MAX_POSITION_SIZE_PCT * 100:
|
|
return RiskValidationResult(
|
|
allowed=False,
|
|
reason=f"Position size violation: {position_pct:.2f}% > {MAX_POSITION_SIZE_PCT*100}% maximum",
|
|
details={
|
|
"order_value": order_value,
|
|
"position_pct": position_pct,
|
|
"max_allowed_pct": MAX_POSITION_SIZE_PCT * 100
|
|
}
|
|
)
|
|
|
|
logger.info(f"[{stock_code}] Position size check passed: {position_pct:.2f}% of account")
|
|
return RiskValidationResult(
|
|
allowed=True,
|
|
details={"position_pct": position_pct, "order_value": order_value}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Position size check error for {stock_code}: {e}")
|
|
return RiskValidationResult(allowed=False, reason=f"Position size check error: {str(e)}")
|
|
|
|
async def validate_max_holdings(self, account_balance: float) -> RiskValidationResult:
|
|
"""
|
|
Check max holdings limit: 20-25 concurrent positions
|
|
|
|
Args:
|
|
account_balance: Current account balance (for context)
|
|
|
|
Returns:
|
|
RiskValidationResult
|
|
"""
|
|
try:
|
|
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)
|
|
|
|
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}")
|
|
return RiskValidationResult(allowed=False, reason=f"Max holdings check error: {str(e)}")
|
|
|
|
async def validate_mdd_state(self) -> RiskValidationResult:
|
|
"""
|
|
Check MDD (Maximum Drawdown) state:
|
|
- MDD -10%: pause new entries
|
|
- MDD -20%: force reduce positions
|
|
|
|
Returns:
|
|
RiskValidationResult
|
|
"""
|
|
try:
|
|
# Import MDD manager
|
|
from app.services.mdd_manager import mdd_manager
|
|
|
|
# Check if new positions are paused due to MDD
|
|
can_open = await mdd_manager.can_open_new_position()
|
|
|
|
if not can_open:
|
|
state = {
|
|
"is_paused": mdd_manager.is_paused,
|
|
"reduction_applied": mdd_manager.reduction_applied,
|
|
"peak_balance": mdd_manager.peak_balance
|
|
}
|
|
return RiskValidationResult(
|
|
allowed=False,
|
|
reason=f"MDD limit triggered (reduction: {mdd_manager.reduction_applied}). New entries paused.",
|
|
details=state
|
|
)
|
|
|
|
logger.info(f"MDD check passed: trading active (peak: {mdd_manager.peak_balance})")
|
|
return RiskValidationResult(
|
|
allowed=True,
|
|
details={
|
|
"is_paused": mdd_manager.is_paused,
|
|
"reduction_applied": mdd_manager.reduction_applied,
|
|
"peak_balance": mdd_manager.peak_balance
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"MDD check error: {e}")
|
|
# Don't block trading if MDD check fails - log and allow
|
|
logger.warning(f"MDD validation skipped due to error: {str(e)}")
|
|
return RiskValidationResult(allowed=True, reason="MDD check skipped (error)")
|
|
|
|
async def validate_leverage_etf_limit(self, stock_code: str, stock_name: str,
|
|
order_quantity: int, entry_price: float,
|
|
account_balance: float) -> RiskValidationResult:
|
|
"""
|
|
Check leverage/inverse ETF limit: max 20% of capital
|
|
|
|
Args:
|
|
stock_code: Stock code
|
|
stock_name: Stock name (for keyword matching)
|
|
order_quantity: Number of shares to buy
|
|
entry_price: Entry price per share
|
|
account_balance: Current account balance
|
|
|
|
Returns:
|
|
RiskValidationResult
|
|
"""
|
|
try:
|
|
# Check if this is a leverage/inverse ETF
|
|
is_leverage_etf = any(keyword in stock_name.upper() for keyword in LEVERAGE_ETF_KEYWORDS)
|
|
|
|
if not is_leverage_etf:
|
|
logger.info(f"[{stock_code}] Not a leverage ETF, skipping leverage limit check")
|
|
return RiskValidationResult(allowed=True)
|
|
|
|
# Calculate total leverage ETF position
|
|
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:
|
|
if any(keyword in (holding.stock_name or "").upper() for keyword in LEVERAGE_ETF_KEYWORDS):
|
|
holding_value = float(holding.current_price or 0) * holding.quantity
|
|
leverage_etf_value += holding_value
|
|
|
|
# Add proposed order
|
|
proposed_value = order_quantity * entry_price
|
|
total_leverage_value = leverage_etf_value + proposed_value
|
|
leverage_pct = (total_leverage_value / account_balance) * 100 if account_balance > 0 else 0
|
|
|
|
if leverage_pct > MAX_LEVERAGE_ETF_PCT * 100:
|
|
return RiskValidationResult(
|
|
allowed=False,
|
|
reason=f"Leverage ETF limit: {leverage_pct:.2f}% > {MAX_LEVERAGE_ETF_PCT*100}% maximum",
|
|
details={
|
|
"current_leverage_value": leverage_etf_value,
|
|
"proposed_value": proposed_value,
|
|
"total_leverage_pct": leverage_pct,
|
|
"max_allowed_pct": MAX_LEVERAGE_ETF_PCT * 100
|
|
}
|
|
)
|
|
|
|
logger.info(f"[{stock_code}] Leverage ETF limit check passed: {leverage_pct:.2f}% of account")
|
|
return RiskValidationResult(
|
|
allowed=True,
|
|
details={"leverage_etf_pct": leverage_pct, "total_leverage_value": total_leverage_value}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Leverage ETF limit check error for {stock_code}: {e}")
|
|
return RiskValidationResult(allowed=False, reason=f"Leverage ETF check error: {str(e)}")
|
|
|
|
async def validate_order_comprehensive(self,
|
|
stock_code: str,
|
|
stock_name: str,
|
|
order_quantity: int,
|
|
entry_price: float,
|
|
account_balance: float,
|
|
market: str = "KRX") -> Tuple[bool, Dict]:
|
|
"""
|
|
Comprehensive order validation combining all risk checks
|
|
|
|
Args:
|
|
stock_code: Stock code
|
|
stock_name: Stock name
|
|
order_quantity: Number of shares
|
|
entry_price: Entry price per share
|
|
account_balance: Current account balance
|
|
market: Market code (default: KRX)
|
|
|
|
Returns:
|
|
Tuple of (allowed: bool, details: Dict with all checks)
|
|
"""
|
|
try:
|
|
async with self._lock:
|
|
validation_results = {
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"stock_code": stock_code,
|
|
"stock_name": stock_name,
|
|
"order_quantity": order_quantity,
|
|
"entry_price": entry_price,
|
|
"account_balance": account_balance,
|
|
"checks": {}
|
|
}
|
|
|
|
# Run all checks
|
|
checks = [
|
|
("price_filter", await self.validate_price_filter(stock_code, market)),
|
|
("position_size", await self.validate_position_size(stock_code, order_quantity, entry_price, account_balance)),
|
|
("max_holdings", await self.validate_max_holdings(account_balance)),
|
|
("mdd_state", await self.validate_mdd_state()),
|
|
("leverage_etf_limit", await self.validate_leverage_etf_limit(stock_code, stock_name, order_quantity, entry_price, account_balance))
|
|
]
|
|
|
|
# Record all check results
|
|
all_passed = True
|
|
for check_name, result in checks:
|
|
validation_results["checks"][check_name] = {
|
|
"allowed": result.allowed,
|
|
"reason": result.reason,
|
|
"details": result.details
|
|
}
|
|
if not result.allowed:
|
|
all_passed = False
|
|
logger.warning(f"❌ [{stock_code}] {check_name}: {result.reason}")
|
|
else:
|
|
logger.info(f"✅ [{stock_code}] {check_name}: passed")
|
|
|
|
validation_results["overall_allowed"] = all_passed
|
|
|
|
if all_passed:
|
|
logger.info(f"✅✅✅ [{stock_code}] ALL RISK CHECKS PASSED - Order approved")
|
|
else:
|
|
logger.warning(f"❌ [{stock_code}] RISK VALIDATION FAILED - Order rejected")
|
|
|
|
return all_passed, validation_results
|
|
|
|
except Exception as e:
|
|
logger.error(f"Comprehensive validation error for {stock_code}: {e}", exc_info=True)
|
|
return False, {
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"stock_code": stock_code,
|
|
"overall_allowed": False,
|
|
"error": str(e)
|
|
}
|
|
|
|
|
|
# Singleton instance
|
|
risk_validator = RiskValidator()
|
|
|
|
|
|
async def validate_and_log_order(stock_code: str,
|
|
stock_name: str,
|
|
order_quantity: int,
|
|
entry_price: float,
|
|
account_balance: float,
|
|
market: str = "KRX") -> Tuple[bool, Dict]:
|
|
"""
|
|
Public API for order validation with comprehensive logging
|
|
|
|
Args:
|
|
stock_code: Stock code
|
|
stock_name: Stock name
|
|
order_quantity: Number of shares
|
|
entry_price: Entry price per share
|
|
account_balance: Current account balance
|
|
market: Market code
|
|
|
|
Returns:
|
|
Tuple of (allowed: bool, validation_details: Dict)
|
|
"""
|
|
allowed, details = await risk_validator.validate_order_comprehensive(
|
|
stock_code=stock_code,
|
|
stock_name=stock_name,
|
|
order_quantity=order_quantity,
|
|
entry_price=entry_price,
|
|
account_balance=account_balance,
|
|
market=market
|
|
)
|
|
|
|
# Log rejection with full details
|
|
if not allowed:
|
|
failed_checks = [
|
|
f"{check}: {data['reason']}"
|
|
for check, data in details.get('checks', {}).items()
|
|
if not data.get('allowed', True)
|
|
]
|
|
logger.error(f"🚫 ORDER REJECTED [{stock_code}]: {'; '.join(failed_checks)}")
|
|
|
|
return allowed, details
|