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:
2026-03-26 16:05:41 +09:00
parent 739a8a2e5d
commit de7921d270
149 changed files with 133353 additions and 3338 deletions

View File

@@ -6,11 +6,14 @@ MDD(Maximum Drawdown) 관리 - CLAUDE.md 섹션 6.3
- -20% 돌파 시: 전체 포지션 50% 강제 축소 (레버리지/인버스 우선)
"""
import asyncio
import json
import logging
from typing import Dict, Optional
logger = logging.getLogger(__name__)
_MDD_REDIS_KEY = "mdd:state"
class MDDManager:
"""포트폴리오 MDD 관리자 (싱글톤으로 사용)"""
@@ -20,8 +23,52 @@ class MDDManager:
self.is_paused: bool = False
self.is_short_term_strategy_paused: bool = False # MDD -10% 시 단기 전략 중단
self.reduction_applied: Optional[str] = None # None, "10pct", "20pct"
self._reduction_executed_10pct: bool = False # 중복 축소 실행 방지
self._reduction_executed_20pct: bool = False
self._lock = asyncio.Lock() # 스케줄러-sync 동시 접근 방지
async def save_to_redis(self) -> None:
"""MDD 상태를 Redis에 영속화 — 앱 재시작 시 복원용."""
try:
from app.core.redis import get_redis
r = get_redis()
if r:
state = {
"peak_balance": self.peak_balance,
"is_paused": self.is_paused,
"is_short_term_strategy_paused": self.is_short_term_strategy_paused,
"reduction_applied": self.reduction_applied,
"_reduction_executed_10pct": self._reduction_executed_10pct,
"_reduction_executed_20pct": self._reduction_executed_20pct,
}
await r.set(_MDD_REDIS_KEY, json.dumps(state))
except Exception as e:
logger.warning(f"[MDD] Redis 저장 실패: {e}")
async def load_from_redis(self) -> bool:
"""Redis에서 MDD 상태 복원. 복원 성공 시 True 반환."""
try:
from app.core.redis import get_redis
r = get_redis()
if r:
raw = await r.get(_MDD_REDIS_KEY)
if raw:
state = json.loads(raw)
self.peak_balance = float(state.get("peak_balance", 0.0))
self.is_paused = bool(state.get("is_paused", False))
self.is_short_term_strategy_paused = bool(state.get("is_short_term_strategy_paused", False))
self.reduction_applied = state.get("reduction_applied")
self._reduction_executed_10pct = bool(state.get("_reduction_executed_10pct", False))
self._reduction_executed_20pct = bool(state.get("_reduction_executed_20pct", False))
logger.info(
f"[MDD] Redis에서 복원: peak={self.peak_balance:,.0f}, "
f"paused={self.is_paused}, reduction={self.reduction_applied}"
)
return True
except Exception as e:
logger.warning(f"[MDD] Redis 복원 실패: {e}")
return False
async def update(self, current_balance: float) -> Dict:
"""MDD 업데이트 및 필요한 조치 반환 (thread-safe)
@@ -35,6 +82,8 @@ class MDDManager:
self.is_paused = False
self.is_short_term_strategy_paused = False
self.reduction_applied = None
self._reduction_executed_10pct = False
self._reduction_executed_20pct = False
if self.peak_balance <= 0:
return {"action": "NONE", "drawdown": 0.0}
@@ -44,8 +93,18 @@ class MDDManager:
# -20% 돌파: 50% 강제 축소
if drawdown_pct <= -20 and self.reduction_applied != "20pct":
self.is_paused = True
self.is_short_term_strategy_paused = True # 단번에 -20% 도달 시에도 단기전략 차단
self.reduction_applied = "20pct"
logger.warning(f"MDD {drawdown_pct:.1f}% - 50% 강제 축소 + 신규진입 중단")
await self.save_to_redis()
# Telegram MDD 경고
try:
from app.services.telegram_notifier import telegram_notifier
asyncio.ensure_future(telegram_notifier.notify_mdd_warning(
action="FORCE_REDUCE", drawdown=drawdown_pct, reduce_pct=0.50,
))
except Exception:
pass
return {
"action": "FORCE_REDUCE",
"drawdown": drawdown_pct,
@@ -59,6 +118,15 @@ class MDDManager:
self.is_short_term_strategy_paused = True # CLAUDE.md 5.3: 단기 매매 전략 중단
self.reduction_applied = "10pct"
logger.warning(f"MDD {drawdown_pct:.1f}% - 30% 축소 + 신규진입 중단 + 단기전략 중단")
await self.save_to_redis()
# Telegram MDD 경고
try:
from app.services.telegram_notifier import telegram_notifier
asyncio.ensure_future(telegram_notifier.notify_mdd_warning(
action="REDUCE_AND_PAUSE", drawdown=drawdown_pct, reduce_pct=0.30,
))
except Exception:
pass
return {
"action": "REDUCE_AND_PAUSE",
"drawdown": drawdown_pct,
@@ -66,6 +134,8 @@ class MDDManager:
"short_term_strategy_paused": True,
}
# Redis 영속화 (상태 변경 시)
await self.save_to_redis()
return {"action": "NONE", "drawdown": drawdown_pct}
async def can_open_new_position(self) -> bool:
@@ -78,12 +148,150 @@ class MDDManager:
async with self._lock:
return not self.is_short_term_strategy_paused
def reset(self):
"""MDD 상태 리셋 (수동 복구 시) — 동기 메서드, 단독 호출 시에만 사용"""
self.is_paused = False
self.is_short_term_strategy_paused = False
self.reduction_applied = None
logger.info("MDD 관리자 리셋")
async def reset(self):
"""MDD 상태 리셋 (수동 복구 시) — async + Lock으로 동시성 안전 보장"""
async with self._lock:
self.is_paused = False
self.is_short_term_strategy_paused = False
self.reduction_applied = None
self._reduction_executed_10pct = False
self._reduction_executed_20pct = False
logger.info("MDD 관리자 리셋")
await self.save_to_redis()
async def execute_reduction(self, mdd_action: dict) -> list[dict]:
"""MDD 액션에 따라 포지션 축소 매도 실행.
CLAUDE.md 5.3:
- REDUCE_AND_PAUSE (-10%): 기존 포지션 30% 축소
- FORCE_REDUCE (-20%): 50% 강제 축소, 레버리지/인버스 ETF 우선
중복 실행 방지: 동일 reduction_applied 레벨에서 1회만 실행.
"""
action = mdd_action.get("action", "NONE")
if action == "NONE":
return []
reduce_pct = mdd_action.get("reduce_pct", 0.0)
prioritize_leverage = mdd_action.get("prioritize_leverage", False)
if reduce_pct <= 0:
return []
# [BUG FIX] executed_key를 Lock 밖(finally)에서도 참조하므로
# Lock 진입 전에 None으로 초기화
executed_key: Optional[str] = None
# 중복 실행 방지: 이미 해당 레벨의 축소가 실행된 경우 스킵
async with self._lock:
# [이슈2 수정] reduction_applied가 None이면 executed_key가
# "_reduction_executed_None" 속성을 생성해 중복 실행 방지가 무력화됨.
# None 상태는 MDD 미달(정상)이므로 early return.
if self.reduction_applied is None:
logger.debug(f"[MDD] reduction_applied=None — 축소 불필요, 스킵")
return []
executed_key = f"_reduction_executed_{self.reduction_applied}"
if getattr(self, executed_key, False):
logger.debug(f"[MDD] {action} 축소 이미 실행됨 — 스킵")
return []
setattr(self, executed_key, True)
try:
from app.services.korea_investment_api import korea_investment_api
from app.services.db_worker import enqueue_trade_history
# 현재 보유 종목 조회
balance = await korea_investment_api.get_balance()
holdings = balance.get("output1", [])
if not holdings:
logger.info(f"[MDD] {action} — 보유 종목 없음, 축소 불필요")
return []
# 레버리지/인버스 ETF 우선 정렬 (FORCE_REDUCE 시)
LEVERAGE_KEYWORDS = {"레버리지", "2X", "3X", "인버스", "TIGER 200선물", "KODEX 인버스", "곱버스"}
if prioritize_leverage:
def _is_leverage(h):
name = h.get("prdt_name", "")
return any(kw in name for kw in LEVERAGE_KEYWORDS)
holdings = sorted(holdings, key=lambda h: (not _is_leverage(h),))
results = []
for h in holdings:
stock_code = h.get("pdno", "")
stock_name = h.get("prdt_name", stock_code)
quantity = int(h.get("hldg_qty", 0) or 0)
current_price = float(h.get("prpr", 0) or 0)
if quantity <= 0 or current_price <= 0:
continue
sell_qty = max(int(quantity * reduce_pct), 1)
sell_qty = min(sell_qty, quantity)
try:
order_resp = await korea_investment_api.place_order(
stock_code=stock_code,
order_type="SELL",
quantity=sell_qty,
price=None, # 시장가
)
if order_resp and order_resp.get("rt_cd") == "0":
order_id = order_resp.get("output", {}).get("ODNO", "")
profit_loss = (current_price - float(h.get("pchs_avg_pric", current_price))) * sell_qty
await enqueue_trade_history({
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "SELL",
"order_price": current_price,
"order_quantity": sell_qty,
"executed_price": current_price,
"executed_quantity": sell_qty,
"total_amount": current_price * sell_qty,
"status": "COMPLETED",
"order_id": order_id,
"exit_reason": f"MDD {action} ({reduce_pct*100:.0f}% 축소)",
"net_profit_loss": profit_loss,
"strategy_type": "mdd_reduction",
})
results.append({
"stock_code": stock_code,
"stock_name": stock_name,
"sell_quantity": sell_qty,
"reason": action,
})
logger.warning(
f"[MDD] {action} 매도: {stock_code} {stock_name} "
f"{sell_qty}주 (총 {quantity}주 중 {reduce_pct*100:.0f}%)"
)
await asyncio.sleep(0.3) # KIS API 레이트 리밋
except Exception as e:
logger.error(f"[MDD] 축소 매도 실패 ({stock_code}): {e}")
failed_count = len(holdings) - len(results)
if failed_count > 0 and executed_key is not None:
# 매도 실패 종목이 있으면 플래그 리셋 → 다음 주기에 재시도
async with self._lock:
setattr(self, executed_key, False)
logger.warning(
f"[MDD] {action} 부분 완료: {len(results)}종목 성공, "
f"{failed_count}종목 실패 → 다음 주기 재시도 예정"
)
else:
logger.warning(f"[MDD] {action} 완료: {len(results)}종목 축소 매도")
return results
except Exception as e:
logger.error(f"[MDD] execute_reduction 실패: {e}", exc_info=True)
# 전체 실패 시 플래그 리셋 → 재시도 가능
if executed_key is not None:
async with self._lock:
setattr(self, executed_key, False)
return []
# 싱글톤 인스턴스