""" MDD(Maximum Drawdown) 관리 - CLAUDE.md 섹션 6.3 - 포트폴리오 최고점 대비 하락률 추적 - -10% 돌파 시: 신규 진입 중단 + 기존 포지션 30% 축소 - -20% 돌파 시: 전체 포지션 50% 강제 축소 (레버리지/인버스 우선) """ import asyncio import json import logging from typing import Dict, Optional from app.core.json_helpers import _dumps logger = logging.getLogger(__name__) _MDD_REDIS_KEY = "mdd:state" class MDDManager: """포트폴리오 MDD 관리자 (싱글톤으로 사용)""" def __init__(self): self.peak_balance: float = 0.0 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, _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) Returns: {"action": "NONE"/"REDUCE_AND_PAUSE"/"FORCE_REDUCE", "drawdown": float, ...} """ async with self._lock: if current_balance > self.peak_balance: self.peak_balance = current_balance 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} drawdown_pct = ((current_balance - self.peak_balance) / self.peak_balance) * 100 # -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, "reduce_pct": 0.50, "prioritize_leverage": True, } # -10% 돌파: 30% 축소 + 신규진입 중단 + 단기전략 중단 if drawdown_pct <= -10 and self.reduction_applied is None: self.is_paused = True 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, "reduce_pct": 0.30, "short_term_strategy_paused": True, } # Redis 영속화 (상태 변경 시) await self.save_to_redis() return {"action": "NONE", "drawdown": drawdown_pct} async def save_to_redis_v2(self, user_id: int) -> None: """[v2] 사용자 스코프 MDD 상태를 `mdd:state:{user_id}` 키로 영속화. save_to_redis()와 동일 직렬화 포맷 재사용, 키 네임스페이스만 사용자별로 분리. 레거시 save_to_redis()는 불변(전역 "mdd:state" 키 그대로). """ 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(f"{_MDD_REDIS_KEY}:{user_id}", _dumps(state)) except Exception as e: logger.warning(f"[MDD v2] Redis 저장 실패 (user={user_id}): {e}") async def load_from_redis_v2(self, user_id: int) -> bool: """[v2] `mdd:state:{user_id}` 키에서 사용자 스코프 MDD 상태 복원.""" try: from app.core.redis import get_redis r = get_redis() if r: raw = await r.get(f"{_MDD_REDIS_KEY}:{user_id}") 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 v2] Redis에서 복원: user={user_id} peak={self.peak_balance:,.0f}, " f"paused={self.is_paused}, reduction={self.reduction_applied}" ) return True except Exception as e: logger.warning(f"[MDD v2] Redis 복원 실패 (user={user_id}): {e}") return False async def can_open_new_position(self) -> bool: """신규 포지션 진입 가능 여부 (thread-safe)""" async with self._lock: return not self.is_paused async def is_short_term_strategy_enabled(self) -> bool: """단기 전략 활성화 여부 (thread-safe) — MDD -10% 이상일 때만 활성화""" async with self._lock: return not self.is_short_term_strategy_paused 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 = [] skipped = 0 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: skipped += 1 continue sell_qty = max(int(quantity * reduce_pct), 1) sell_qty = min(sell_qty, quantity) try: from app.services.trade_executor import safe_place_order order_resp = await safe_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", "") avg_price = float(h.get("pchs_avg_pric", current_price)) profit_loss = (current_price - avg_price) * sell_qty profit_loss_rate = ((current_price - avg_price) / avg_price * 100) if avg_price > 0 else 0.0 # MDD 강제매도는 시스템적(systemic) 리스크 축소이며, # 개별 종목의 전략 실패가 아니므로 loss_tracker에 기록하지 않음. # 기록 시 양호한 종목이 MDD 축소로 인해 false ban될 수 있음. 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", }) # [FIX] 전량 매도 시 ActiveTrade 닫기 remaining_qty = quantity - sell_qty if remaining_qty <= 0: from app.services.db_worker import enqueue_close_active_trade await enqueue_close_active_trade(stock_code) else: # 부분 매도: remaining_pct 갱신 # 기존 ActiveTrade의 remaining_pct에 매도 비율을 곱해야 함 # (예: 기존 0.5 * 남은비율 0.7 = 0.35) # db_worker가 직접 setattr하므로, reduce_ratio를 전달하여 # 워커가 기존 값에 곱하도록 처리 from app.services.db_worker import enqueue_active_trade_upsert sell_ratio = remaining_qty / quantity # 남은 비율 (0.7 or 0.5) await enqueue_active_trade_upsert({ "stock_code": stock_code, "remaining_pct_multiply": sell_ratio, "quantity": remaining_qty, # 잔량 갱신(미갱신 시 다음 청산이 과다매도) }) # [2026-09-23] 축소 체결분을 Portfolio·DT 인메모리에도 반영. # 누락 시 다음 청산이 축소 전 수량으로 매도한다(과다매도). await _reflect_reduction(stock_code, sell_qty, current_price, remaining_qty) 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) - skipped 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 [] async def execute_reduction_v2(self, mdd_action: dict, kis, user_id: int) -> list[dict]: """[v2] 사용자 스코프 포지션 축소 매도 실행. execute_reduction()과 동일 로직이나 전역 korea_investment_api 대신 주입된 kis(사용자 인스턴스)를 사용하고, trade_history/active_trade 큐 payload에 user_id를 포함해 db_worker가 해당 사용자 레코드만 갱신하도록 한다. 기존 execute_reduction()은 완전히 불변. """ 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 [] executed_key: Optional[str] = None async with self._lock: if self.reduction_applied is None: logger.debug(f"[MDD v2] user={user_id} reduction_applied=None — 축소 불필요, 스킵") return [] executed_key = f"_reduction_executed_{self.reduction_applied}" if getattr(self, executed_key, False): logger.debug(f"[MDD v2] user={user_id} {action} 축소 이미 실행됨 — 스킵") return [] setattr(self, executed_key, True) try: from app.services.db_worker import ( enqueue_trade_history, enqueue_close_active_trade_v2, enqueue_active_trade_upsert, ) balance = await kis.get_balance() holdings = balance.get("output1", []) if not holdings: logger.info(f"[MDD v2] user={user_id} {action} — 보유 종목 없음, 축소 불필요") return [] 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 = [] skipped = 0 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: skipped += 1 continue sell_qty = max(int(quantity * reduce_pct), 1) sell_qty = min(sell_qty, quantity) try: # UserContext 조립 완료(트레이딩팀) — 종목별 동시 주문 락 # (_order_semaphores/Redis 분산락)을 거치도록 safe_place_order_v2 사용. # SL/사이징 로직은 그대로, 주문 호출 경로만 교체. from app.services.tenant.context import UserContext from app.services.trade_executor import safe_place_order_v2 from app.core.config import settings as _settings reduction_ctx = UserContext( user_id=user_id, username="", mode=getattr(kis, "mode", _settings.KIS_MODE), kis=kis, mdd=self, loss_tracker=None, ) order_resp = await safe_place_order_v2( reduction_ctx, 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", "") avg_price = float(h.get("pchs_avg_pric", current_price)) profit_loss = (current_price - avg_price) * sell_qty profit_loss_rate = ((current_price - avg_price) / avg_price * 100) if avg_price > 0 else 0.0 # MDD 강제매도는 시스템적 리스크 축소 — loss_tracker 미기록 (execute_reduction과 동일 정책) await enqueue_trade_history({ "user_id": user_id, "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", }) remaining_qty = quantity - sell_qty if remaining_qty <= 0: await enqueue_close_active_trade_v2(user_id, stock_code, strategy_name=None) else: sell_ratio = remaining_qty / quantity await enqueue_active_trade_upsert({ "user_id": user_id, "stock_code": stock_code, "remaining_pct_multiply": sell_ratio, "quantity": remaining_qty, }) # [2026-09-23] 축소 체결분을 Portfolio·DT 인메모리에도 반영. # 누락 시 다음 청산이 축소 전 수량으로 매도한다(과다매도). await _reflect_reduction(stock_code, sell_qty, current_price, remaining_qty) results.append({ "stock_code": stock_code, "stock_name": stock_name, "sell_quantity": sell_qty, "reason": action, }) logger.warning( f"[MDD v2] user={user_id} {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 v2] user={user_id} 축소 매도 실패 ({stock_code}): {e}") failed_count = len(holdings) - len(results) - skipped if failed_count > 0 and executed_key is not None: async with self._lock: setattr(self, executed_key, False) logger.warning( f"[MDD v2] user={user_id} {action} 부분 완료: {len(results)}종목 성공, " f"{failed_count}종목 실패 → 다음 주기 재시도 예정" ) else: logger.warning(f"[MDD v2] user={user_id} {action} 완료: {len(results)}종목 축소 매도") return results except Exception as e: logger.error(f"[MDD v2] user={user_id} execute_reduction_v2 실패: {e}", exc_info=True) if executed_key is not None: async with self._lock: setattr(self, executed_key, False) return [] # 싱글톤 인스턴스 mdd_manager = MDDManager() async def _reflect_reduction( stock_code: str, sell_qty: int, price: float, remaining_qty: int ) -> None: """MDD 축소 체결을 Portfolio 캐시/DB 와 데이트레이딩 인메모리 포지션에 반영. ActiveTrade 잔량은 호출측에서 upsert 하므로 여기서는 Portfolio 와 인메모리만 다룬다. """ try: from app.services.portfolio_cache_bridge import reflect_sell await reflect_sell(stock_code, sell_qty, price, remaining_qty=remaining_qty) except Exception as e: logger.warning(f"[MDD] Portfolio 반영 실패 ({stock_code}): {e}") try: from app.services.daytrading.position_manager import day_trading_state async with day_trading_state.trade_lock: pos = day_trading_state.positions.get(stock_code) if pos: _left = max(0, int(pos.get("quantity", 0) or 0) - sell_qty) if _left <= 0: day_trading_state.remove_position(stock_code) logger.warning(f"[MDD] DT 포지션 전량 축소: {stock_code}") else: pos["quantity"] = _left logger.warning(f"[MDD] DT 포지션 잔량 갱신: {stock_code} → {_left}주") except Exception as e: logger.warning(f"[MDD] DT 인메모리 반영 실패 ({stock_code}): {e}")