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,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