Files
auto-stock/backend/app/services/mdd_manager.py
shuim 7abbb3411c fix: 재기동 내구성 + DT 진입 복구 + 유입 잔고 격리 + 대시보드 표시 오류
## 재기동 내구성
- scheduler: 복원 직후 reset_if_new_day()가 복원분을 전부 폐기하던 버그
  (begin_restore()로 날짜 경계를 선확정. 복원 성공 로그는 뜨는데 실제로는 지워졌다)
- 일일 안전장치(daily_pnl/loss_streak_today/closed_today) Redis 영속화
  날짜를 키와 페이로드 양쪽에 실어 이중 게이트, 복원은 보수적 병합
  (손익 min/연패 max/청산목록 합집합 — 복원이 안전장치를 약화시킬 수 없다)
- db_worker 종료 drain이 insert_trade_history만 커밋하고 포지션 추적 행은
  DLQ로 버리던 비대칭 제거 + DLQ 재처리 경로 신설(쓰기 전용이었다)
- 장중 기동 시 ORB 소급 수집(backfill), 전일 캔들 혼입 차단
- swing/exit_service "보유 종목 없음" debug→info, 10회 연속 warning

## DT 진입 복구
- 워밍업 회귀 복구: 1b9257c(2026-09-28)가 조회 계층에 당일 필터를 넣어
  5분봉 RSI(14)/BB(20) 워밍업이 죽고 VWAP/RSI_BB가 10:15/10:40 이전
  발화 불가가 됐다. 필터를 삭제하지 않고 용도별로 분리
  (get_minute_chart_full_day_v2 신설, 기존 메서드·3개 호출부 동작 무변경)
- 거래량 게이트 비교 대상 교정: 형성 중 봉 → 완성봉 (5분봉·ORB 1분봉 양쪽)
  임계값(1.3/1.5)은 불변, 고친 것은 비교 대상뿐
- 세션 경계 차단: _last_session_candles()로 거래량 비교가 전일 봉을 잡아
  개장 직후 게이트가 자동 통과하는 것을 방지
- 스캔 선조회(동시성 4)·종목 타임아웃·시간예산·라운드로빈 + 잘린 종목 수 로그
- watchlist 30분 갱신 시 ORB 보유 종목 우선 유지

## 유입 잔고 격리
- sync_portfolio()가 시스템 매수 근거 없는 KIS 잔고를 자기 스윙 포지션으로
  편입해 자동 손절로 처분하던 문제 → strategy_type='external' 격리
  (승격만 허용, 강등 없음. 기존 행은 수동 SQL 필요)
- 격리분 손절선 이탈은 집행 없이 warning + summary 노출
- 수동 청산 경로에 record_trade_result 누락 보강, 전략 간 당일 쿨다운

## 4% 상한
- market_data 표시 경로 클램프 누락 + confidence 배율 후 재클램프
- 회귀 방지 테스트(AST로 가드가 try 밖에 있는지 검증)

## 프론트
- 총자산 보정 가드(3%·종목수·가격결손·국내분 한정), 원인 미규명 명시
- cumulative_realized_pl이 /portfolio/combined에 없어 항상 0이던 문제

테스트 579 → 677 passed / 0 failed (소켓 차단 하 통과, 실호출 없음)
전략 임계값 변경 0건

⚠️ 워밍업 복구의 효과 크기는 미확정 — KIS 분봉 API에 날짜 파라미터가 없어
   미래 ref_time에 전일 데이터를 주는지가 미검증 전제다. 배포 후
   "[분봉] 워밍업 5분봉 N봉 (당일 a + 전일 b)" 로그의 b로 확정할 것.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-29 15:49:25 +09:00

917 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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.config import settings
from app.core.json_helpers import _dumps
# [2026-09-27 §8.5 항목6] DT 전략명 정본은 db_worker (순환 import 없음 — db_worker 는
# 모듈 레벨에서 서비스 모듈을 import 하지 않는다).
from app.services.db_worker import DT_STRATEGY_NAMES as _DT_STRATEGY_NAMES
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 동시 접근 방지
# ── [항목7 2026-09-27] MDD 갱신 헬스 (fail-safe 판정용) ──────────────
# 갱신 경로(swing/scheduler)가 record_update_failure/success 로 여기에 보고한다.
# 상태를 mdd_manager 가 소유해야 can_open_new_position() 이 순환 import 없이
# 판정할 수 있다(scheduler → mdd_manager 는 모듈 레벨 import 이므로 역방향 금지).
self._update_failures: int = 0
self._update_last_error: Optional[str] = None
self._update_empty_account: bool = False
self._blind_block_active: bool = False
self._blind_block_hits: int = 0 # 차단 발동 누적 횟수(로그 레이트 리밋용)
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
# ── [항목7] MDD 갱신 헬스 보고/조회 ────────────────────────────────────────
# 기존 시그니처 변경 없이 새 메서드로만 추가한다.
async def record_update_failure(self, error: str) -> int:
"""MDD 갱신 실패를 기록하고 누적 연속 실패 횟수를 반환.
갱신 경로(swing/scheduler)의 except 절에서 호출한다.
여기서 누적된 횟수가 MDD_BLIND_FAILURE_THRESHOLD 이상이면
can_open_new_position() 이 신규 진입을 차단한다(청산은 영향 없음).
"""
async with self._lock:
self._update_failures += 1
self._update_last_error = str(error)
self._update_empty_account = False
return self._update_failures
async def record_update_success(self, empty_account: bool = False) -> None:
"""MDD 갱신 성공(또는 '정상 응답 + 빈 계좌')을 기록해 실패 카운터를 리셋.
empty_account=True 는 tot_evlu_amt<=0(미입금 계좌)로 갱신을 스킵한 경우다.
장애가 아니므로 실패로 계수하지 않고, 진입 차단 사유도 되지 않는다.
"""
async with self._lock:
self._update_failures = 0
self._update_last_error = None
self._update_empty_account = bool(empty_account)
self._blind_block_active = False
self._blind_block_hits = 0
def _is_update_blind(self) -> bool:
"""MDD 갱신이 '눈이 먼' 상태인지 판정 (Lock 보유 중 호출, DB/IO 없음).
- 빈 계좌(empty_account)는 차단 사유 아님 — 자산 0이면 애초에 매수 불가이고
입금 직후 첫 사이클을 막을 이유가 없다.
- 설정으로 끌 수 있으나 기본값은 True(config.MDD_BLIND_BLOCK_ENTRY).
"""
if not settings.MDD_BLIND_BLOCK_ENTRY:
return False
if self._update_empty_account:
return False
threshold = max(1, int(settings.MDD_BLIND_FAILURE_THRESHOLD))
return self._update_failures >= threshold
def get_update_health(self) -> dict:
"""MDD 갱신 헬스 스냅샷 (동기·읽기 전용). 차단 여부 관측용."""
return {
"consecutive_failures": self._update_failures,
"last_error": self._update_last_error,
"empty_account": self._update_empty_account,
"blind_block_enabled": bool(settings.MDD_BLIND_BLOCK_ENTRY),
"blind_block_threshold": int(settings.MDD_BLIND_FAILURE_THRESHOLD),
"blind_block_active": self._blind_block_active,
"blind_block_hits": self._blind_block_hits,
}
async def can_open_new_position(self) -> bool:
"""신규 포지션 진입 가능 여부 (thread-safe)
[항목7 2026-09-27] MDD 갱신이 연속 실패 중이면(= -10%/-20% 게이트가 작동
불가) 신규 진입을 차단한다. 모든 진입 경로가 이 메서드를 await 하므로
여기서 한 번에 적용된다. 시그니처 불변, 청산 경로에는 영향 없음.
"""
async with self._lock:
blind = self._is_update_blind()
self._blind_block_active = blind
if blind:
self._blind_block_hits += 1
if self._blind_block_hits == 1 or self._blind_block_hits % 20 == 0:
logger.error(
f"[MDD] 🚨 신규 진입 차단(fail-safe): MDD 갱신 연속 실패 "
f"{self._update_failures}회 (임계 {settings.MDD_BLIND_FAILURE_THRESHOLD}) — "
f"peak/drawdown 미갱신으로 -10%/-20% 게이트가 작동하지 않음. "
f"마지막 오류: {self._update_last_error} "
f"(누적 차단 {self._blind_block_hits}회, 청산은 정상 동작)"
)
return False
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으로 동시성 안전 보장
[2026-09-27 fail-safe 해제 누락 수정] MDD 갱신 연속 실패로 인한
fail-safe 차단(can_open_new_position() 이 False 를 반환하는 상태)은
기존에는 여기서 초기화되지 않아, 운영자가 reset()을 호출해도 KIS 장애가
스스로 해소될 때까지 신규 진입이 계속 막혀 있었다. 호출부(관리자용
수동 초기화 엔드포인트, "신규 진입 차단 해제"가 목적)의 의도에 맞춰
fail-safe 카운터/플래그도 함께 초기화한다. peak_balance/drawdown 관련
상태는 기존 그대로 건드리지 않는다.
"""
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
was_blind = self._blind_block_active or self._update_failures > 0
self._update_failures = 0
self._update_last_error = None
self._update_empty_account = False
self._blind_block_active = False
self._blind_block_hits = 0
logger.info("MDD 관리자 리셋")
if was_blind:
logger.info("[MDD] fail-safe 차단 해제 — 갱신 실패 카운터/차단 플래그 초기화됨")
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 닫기 / 부분 매도 시 잔량 갱신
# [M8 2026-09-27] 전략명을 비우면 db_worker 가 '가장 최근 active 행'을
# 매칭하므로 같은 종목에 DT 행이 있으면 DT 행이 닫히고 스윙 행이 영구 잔존한다.
# → DT 인메모리 포지션이 있는 종목은 전략명을 명시해 처리한다.
remaining_qty = quantity - sell_qty
await _sync_active_trade_after_reduction(
stock_code=stock_code,
quantity=quantity,
remaining_qty=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",
})
# [M8 2026-09-27] v1 과 동일 — DT 행 오폐기 방지 (전략명 명시)
remaining_qty = quantity - sell_qty
await _sync_active_trade_after_reduction(
stock_code=stock_code,
quantity=quantity,
remaining_qty=remaining_qty,
user_id=user_id,
)
# [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()
# [M8] 비-데이트레이딩(스윙 계열) ActiveTrade.strategy_name 값 전수 목록
# db_worker 는 strategy_name 동등 비교만 지원하므로 '!= day_trade' 를 표현할 수 없다.
# → 비-DT 후보 전략명을 개별로 지정해 DT 행을 절대 건드리지 않는다.
#
# ⚠️ 경고: 이 목록은 `ActiveTrade.strategy_name` 에 기록되는 **모든 비-DT 값**을 덮어야
# 한다. 하나라도 빠지면 MDD 축소가 그 행에 반영되지 않아(db_worker 가 '필수 키 누락'
# 으로 스킵) quantity/remaining_pct 가 축소 전 값으로 남고, 다음 청산이 과다매도 →
# 주문거부를 일으킨다. 전량 축소 경로에서는 해당 행이 영구히 status='active' 로 남는다.
#
# [조사 방법 — 전략명 추가/변경 시 반드시 재실행]
# 1) grep -rn 'strategy_name' backend/app --include='*.py' \
# | grep -E "strategy_name(\"|')?\]?\s*[:=]"
# → enqueue_active_trade_upsert / ActiveTrade(...) 로 들어가는 리터럴 수집
# 2) grep -rn 'strategy_type' backend/app --include='*.py' | grep -E "strategy_type.*[:=]"
# → trade_executor 는 `stock.get("strategy_name") or stock.get("strategy_type","swing")`,
# portfolio 동기화는 `strategy_name=_h.strategy_type or "swing"` 이므로
# Portfolio.strategy_type 값도 그대로 ActiveTrade.strategy_name 이 된다.
# 3) 수집값에서 DT 계열(DT_STRATEGY_NAMES)만 제외하고 나머지 전부를 여기에 넣는다.
#
# 2026-09-27 조사 결과:
# "swing" — trade_executor.py:1105 기본값 / swing/entry_service.py:288
# / portfolio.py:873, :1317
# "MOMENTUM_BREAKOUT" — swing/entry_service.py:291
# "PULLBACK_BUY" — swing/entry_service.py:313
# "manual" — manual_buy.py:156(+:168), portfolio_v2.py:382(+:402),
# portfolio.py:1536 → Portfolio.strategy_type='manual' 경유
# "us_swing" — us_market_scheduler.py:374, :577 (Portfolio.strategy_type)
# "us_mean_reversion" — us_market_scheduler.py:306, :406 (ActiveTrade 직접)
# "synced" — portfolio.py sync_portfolio 외부 유입 포지션 시딩(§8.5 항목4③)
#
# [2026-09-27 §8.5 항목6] 아래 목록은 **참조/문서용**으로만 남는다.
# 축소 반영 경로는 열거 대신 payload 의 `strategy_name_not=DT_STRATEGY_NAMES`
# (= strategy_name IS NULL OR NOT IN (DT)) 한 건으로 처리하므로, 목록에서 빠진
# 전략명이 있어도 누락되지 않는다(NULL·미등재 전략명까지 자동 포함).
SWING_STRATEGY_NAMES = (
"swing",
"MOMENTUM_BREAKOUT",
"PULLBACK_BUY",
"manual",
"us_swing",
"us_mean_reversion",
"synced",
)
# 데이트레이딩 계열 ActiveTrade.strategy_name 값 — 정본은 db_worker.
# "day_trade" — daytrading/entry_service.py, exit_service.py
# "daytrading" — Portfolio.strategy_type='daytrading' 이 동기화로 그대로 들어가는 경우
# [2026-09-27 §8.5 항목6] 이중 정의 제거 — 정본은 db_worker.DT_STRATEGY_NAMES
# (모듈 상단에서 import, not_dt_strategy_clause 와 동일 목록 공유).
# db_worker 는 모듈 레벨에서 어떤 서비스 모듈도 import 하지 않으므로
# (asyncio/sqlalchemy/core.database/models.models 뿐) 순환 참조가 없다.
# 재노출: 기존 `from app.services.mdd_manager import DT_STRATEGY_NAMES` 호출부 호환 유지.
DT_STRATEGY_NAMES = _DT_STRATEGY_NAMES
def _dt_position_qty(stock_code: str) -> int:
"""데이트레이딩 인메모리 포지션 수량 (조회 실패 시 0). DB 접근 없음."""
try:
from app.services.daytrading.position_manager import day_trading_state
pos = day_trading_state.positions.get(stock_code)
return int((pos or {}).get("quantity", 0) or 0)
except Exception:
return 0
async def _sync_active_trade_after_reduction(
stock_code: str,
quantity: int,
remaining_qty: int,
user_id: Optional[int] = None,
) -> None:
"""MDD 축소 체결을 ActiveTrade 에 반영 (전략 귀속을 명시).
db_worker 의 close_active_trade / upsert_active_trade 는 strategy_name 이
비어 있으면 '가장 최근 active 행'을 매칭한다. 같은 종목에 데이트레이딩 행이
있으면 DT 행이 닫히거나 잔량이 덮어써지고 스윙 행은 영구 잔존한다.
처리 규칙:
- DT 인메모리 포지션 없음 → 기존 동작 유지(전략명 없이 최신 행 1건)
- DT 인메모리 포지션 있음 →
* 전량 축소: 비-DT 행 + DT 행을 각각 닫음(모두 청산됐으므로)
* 부분 축소: 비-DT 행만 갱신 (DT 잔량은 DT 자체 청산 경로가 갱신)
[2026-09-27 §8.5 항목6] 전략명 열거(SWING_STRATEGY_NAMES 루프) → payload 필터
1건으로 대체. 열거 방식은 (a) 목록에 없는/NULL 전략명 행을 놓치고,
(b) 미존재 전략명마다 db_worker 의 '필수 키 누락' 경고 로그를 남겼다.
- 비-DT 대상: strategy_name_not=DT_STRATEGY_NAMES (NULL 포함, DT 만 제외)
- DT 대상 : strategy_names=DT_STRATEGY_NAMES (IN)
시그니처 변경 없이 기존 enqueue_* / enqueue_db_write 인자 범위 내에서만 처리한다.
"""
from app.services.db_worker import (
enqueue_active_trade_upsert,
enqueue_close_active_trade,
enqueue_close_active_trade_v2,
enqueue_db_write,
)
dt_qty = _dt_position_qty(stock_code)
async def _close(strategy_name: Optional[str]) -> None:
if user_id is not None:
await enqueue_close_active_trade_v2(user_id, stock_code, strategy_name=strategy_name)
else:
await enqueue_close_active_trade(stock_code, strategy_name=strategy_name)
async def _close_filtered(**filters) -> None:
"""전략명 확장 필터(strategy_names / strategy_name_not)로 close 태스크 큐잉.
enqueue_close_active_trade 시그니처는 strategy_name 만 받으므로
(시그니처 불변 원칙) 범용 enqueue_db_write 로 payload 를 직접 넘긴다.
"""
data: dict = {"stock_code": stock_code, **filters}
if user_id is not None:
data["user_id"] = user_id
await enqueue_db_write({"type": "close_active_trade", "data": data})
async def _upsert(strategy_name: Optional[str], ratio: float, qty: int) -> None:
payload: dict = {
"stock_code": stock_code,
"remaining_pct_multiply": ratio,
# 잔량 갱신(미갱신 시 다음 청산이 축소 전 수량으로 과다매도)
"quantity": qty,
}
if strategy_name:
payload["strategy_name"] = strategy_name
if user_id is not None:
payload["user_id"] = user_id
await enqueue_active_trade_upsert(payload)
async def _upsert_non_dt(ratio: float, qty: int) -> None:
"""비-DT(스윙 계열) 최신 active 행 1건만 잔량 갱신. DT 행은 절대 건드리지 않음."""
payload: dict = {
"stock_code": stock_code,
"remaining_pct_multiply": ratio,
"quantity": qty,
"strategy_name_not": DT_STRATEGY_NAMES,
}
if user_id is not None:
payload["user_id"] = user_id
await enqueue_active_trade_upsert(payload)
if remaining_qty <= 0:
if dt_qty > 0:
# 전량 청산 — 비-DT 행과 DT 행을 모두 닫는다(대상 범위 불변).
await _close_filtered(strategy_name_not=DT_STRATEGY_NAMES)
await _close_filtered(strategy_names=list(DT_STRATEGY_NAMES))
logger.warning(
f"[MDD] {stock_code}: DT {dt_qty}주 병존 — 비-DT/DT ActiveTrade 각각 종료"
)
else:
await _close(None)
return
# 부분 축소: 남은 비율을 기존 remaining_pct 에 곱한다 (예: 기존 0.5 × 0.7 = 0.35)
sell_ratio = (remaining_qty / quantity) if quantity > 0 else 1.0
if dt_qty > 0:
# 스윙 행의 잔량 = 전체 잔량 − DT 잔량 (DT 는 체결분만큼 인메모리에서 차감됨)
_sold = max(0, quantity - remaining_qty)
_dt_left = max(0, dt_qty - _sold)
_swing_left = max(0, remaining_qty - _dt_left)
# [M1 2026-09-27] 배율은 반드시 '스윙 수량 기준' 으로 계산한다.
# 총수량(스윙+DT) 기준 sell_ratio 를 곱하면 스윙 행의 remaining_pct 가
# 과소평가되어(예: 정답 0.875 대신 0.70) _check_momentum_exit 의
# 1차 익절 게이트(remaining_pct > 0.75)가 영구 미발동한다.
_swing_entry_qty = max(0, quantity - dt_qty)
if _swing_entry_qty > 0:
_swing_ratio = min(1.0, max(0.0, _swing_left / _swing_entry_qty))
else:
_swing_ratio = 1.0
if _swing_left <= 0:
# [M1-guard 2026-09-27] _close() 는 '관리 대상에서 제거'라는 과도한 부작용이
# 있으므로 인메모리 DT 수량을 신뢰할 수 있을 때만 허용한다.
# quantity 는 KIS 잔고(hldg_qty), dt_qty 는 인메모리 → dt_qty > quantity 면
# 인메모리가 잔고보다 큼 = stale 확정(외부 경로로 DT 분이 이미 매도된 경우).
# 이때 _swing_left 는 허위 0 이므로 close 하면 실제 보유 중인 스윙 행이
# status='closed' 가 되어 손절·트레일링 관리가 사라진다.
if dt_qty > quantity:
# 보수적 처리: 잔고 전량을 스윙분으로 간주하고 잔량만 갱신(close 금지).
# 아무것도 안 하면 quantity 가 축소 전 값으로 남아 다음 청산이 과다매도한다.
await _upsert_non_dt(sell_ratio, remaining_qty)
logger.warning(
f"[MDD] {stock_code}: DT 인메모리 수량이 잔고를 초과 — stale 판단 "
f"(잔고 quantity={quantity}, 인메모리 dt_qty={dt_qty}, 매도 {_sold}주) → "
f"스윙 ActiveTrade close 생략, 잔량 {remaining_qty}주·배율 {sell_ratio:.4f} 로 갱신"
)
return
# 스윙분은 전량 소진, 잔량은 전부 DT — quantity=0 유령 active 행을 남기지 않는다.
await _close_filtered(strategy_name_not=DT_STRATEGY_NAMES)
logger.warning(
f"[MDD] {stock_code}: 스윙분 전량 축소(잔량 {remaining_qty}주는 DT) — "
f"스윙 ActiveTrade 종료, DT 행은 DT 청산 경로에 위임"
)
return
await _upsert_non_dt(_swing_ratio, _swing_left)
logger.warning(
f"[MDD] {stock_code}: DT {dt_qty}주 병존 — 스윙 ActiveTrade 만 잔량 갱신 "
f"(스윙 잔량={_swing_left}/{_swing_entry_qty}, 배율={_swing_ratio:.4f}, "
f"DT 잔량={_dt_left}, DT 행은 DT 청산 경로에 위임)"
)
else:
await _upsert(None, sell_ratio, remaining_qty)
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}")
# [항목11 2026-09-27] 인메모리 차감 규칙을 _sync_active_trade_after_reduction 과 통일.
# ActiveTrade 측 규칙: _dt_left = max(0, dt_qty - _sold) (= DT 우선 차감)
# 단, dt_qty > quantity(KIS 잔고) 면 stale 로 보고 close 를 생략.
# 기존 코드는 DT 우선 차감(max(0, dt-sell))까지는 같았지만 stale 케이스를 가드하지
# 않아, 인메모리 잔량이 실제 잔고(remaining_qty)를 초과한 채 남았다.
# 예) 잔고 quantity=10, 인메모리 dt_qty=15, 매도 3주 → 잔고 7주인데 인메모리 12주
# → 다음 DT 청산이 12주 매도를 시도해 과다매도/주문거부.
# 따라서 (1) DT 우선 차감, (2) 실제 잔고 잔량으로 상한 클램프, (3) 음수 금지.
_dt_daily_state_dirty = False
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:
_dt_now = max(0, int(pos.get("quantity", 0) or 0))
_balance_before = max(0, sell_qty + remaining_qty) # = KIS 잔고(quantity)
_stale = _dt_now > _balance_before
if _stale:
# 인메모리가 잔고보다 큼 = 이미 외부 경로로 매도된 분이 반영 안 됨.
# 여기서 또 sell_qty 를 빼면 실제 잔고와 더 벌어지므로, 중복 차감 없이
# 실제 잔량으로 클램프만 한다(_sync 의 보수적 stale 처리와 동일 취지).
_left = min(_dt_now, remaining_qty)
logger.warning(
f"[MDD] {stock_code}: DT 인메모리 수량이 잔고를 초과 — stale 판단 "
f"(잔고 {_balance_before}주, 인메모리 {_dt_now}주, 매도 {sell_qty}주) → "
f"중복 차감 생략, 실제 잔량 {remaining_qty}주로 클램프"
)
else:
# DT 우선 차감 (ActiveTrade 의 _dt_left 계산과 동일 규칙)
_left = max(0, _dt_now - sell_qty)
# 잔고 잔량 초과 방지 (이론상 불가하나 이중 방어)
_left = min(_left, remaining_qty)
if _left <= 0:
day_trading_state.remove_position(stock_code)
# remove_position() 이 closed_today 에 추가한다 → 영속화 필요
_dt_daily_state_dirty = True
logger.warning(
f"[MDD] DT 포지션 전량 축소: {stock_code} "
f"(인메모리 {_dt_now}주 → 0, 매도 {sell_qty}주, 잔고 잔량 {remaining_qty}주)"
)
else:
pos["quantity"] = _left
logger.warning(
f"[MDD] DT 포지션 잔량 갱신: {stock_code} {_dt_now} → {_left}주 "
f"(매도 {sell_qty}주, 잔고 잔량 {remaining_qty}주)"
)
except Exception as e:
logger.warning(f"[MDD] DT 인메모리 반영 실패 ({stock_code}): {e}")
# [F9 2026-09-28] MDD 축소가 DT 포지션을 닫으면 closed_today 가 바뀌므로 영속화한다.
# 이 경로에 저장이 없으면 축소 직후 재기동 시 당일 재진입 차단이 풀려, MDD -10%/-20%
# 구간에서 방금 강제 축소한 종목을 그날 다시 매수할 수 있다.
# - trade_lock **해제 후** 호출 — Redis I/O 로 락을 잡지 않는다(§3.1 실시간 계층 보호)
# - 저장 실패가 축소 자체를 실패시키지 않도록 예외를 삼키되 warning 으로 남긴다
if _dt_daily_state_dirty:
try:
from app.services.daytrading.position_manager import day_trading_state as _dts_save
await _dts_save.save_daily_state()
except Exception as e:
logger.warning(
f"[MDD] DT 일일 안전장치 저장 실패 ({stock_code}): {e} — 재기동 시 "
f"당일 재진입 차단이 풀릴 수 있다"
)