fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영

하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 12:01:31 +09:00
parent 0f89a9d678
commit 9fab4018d4
99 changed files with 15712 additions and 1718 deletions

View File

@@ -12,6 +12,7 @@ from typing import Dict, Optional
from app.core.config import settings
from app.core.json_helpers import _dumps
from app.core.log_throttle import warn_throttled
# [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
@@ -41,24 +42,50 @@ class MDDManager:
self._update_empty_account: bool = False
self._blind_block_active: bool = False
self._blind_block_hits: int = 0 # 차단 발동 누적 횟수(로그 레이트 리밋용)
# ── [C-1 2026-10-02] 강제축소 진행 상태 (레벨별) ─────────────────────
# 한 종목이라도 실패하면 executed 플래그를 리셋해 재시도하는데, 기존에는
# 재시도가 '모든 보유종목'을 다시 30%/50% 매도해 성공 종목이 사이클마다 누적
# 축소됐다(5사이클 82% 청산 재현). 레벨별 완료 집합으로 재시도를 실패 종목에
# 한정한다. 완료 집합은 peak 갱신·reset() 시에만 초기화(§5.3 플래그 리셋과 동일)
# 하고, 시도 횟수/포기 집합은 KST 날짜가 바뀌면 초기화한다.
self._reduced_codes: Dict[str, set] = {} # level -> {stock_code}
self._reduction_attempts: Dict[str, Dict[str, int]] = {} # level -> {code: 실패횟수}
self._reduction_gave_up: Dict[str, set] = {} # level -> {code} (당일 포기)
self._reduction_progress_date: Optional[str] = None
self._reduction_epoch: int = 0 # peak 갱신/reset 세대 (늦은 완료 기록 무효화)
# [M-1 5회차] 전송 후 결과 불명 주문 — level -> {code: {"qty_before","sell_qty","cycles"}}
# 다음 주기 재매도 전에 KIS 잔고(보유·주문가능 수량)로 체결 여부를 확인한다.
self._reduction_unconfirmed: Dict[str, Dict[str, dict]] = {}
# [3회차 2026-10-02] 진행 중 레벨(인메모리 전용 동시 실행 가드). executed 플래그는
# 레벨 축소가 **완료**됐을 때만 True 로 둔다 — 루프 시작 시 True 로 두면 루프 중
# save_to_redis 가 그 값을 영속해, 중간 재기동 시 남은 종목 축소가 영구 생략됐다.
self._reduction_running: set = set()
async def save_to_redis(self) -> None:
"""MDD 상태를 Redis에 영속화 — 앱 재시작 시 복원용."""
def _state_snapshot_v2(self) -> dict:
"""[L-6 5회차] 영속 상태 스냅샷 (동기, I/O 없음 — Lock 안에서 호출 가능)."""
return {
"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,
**self._reduction_progress_state(),
}
async def _write_state_v2(self, key: str, state: dict) -> None:
"""[L-6 5회차] Redis 쓰기 — **Lock 밖에서만** 호출."""
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))
await r.set(key, _dumps(state))
except Exception as e:
logger.warning(f"[MDD] Redis 저장 실패: {e}")
logger.warning(f"[MDD] Redis 저장 실패 ({key}): {e}")
async def save_to_redis(self) -> None:
"""MDD 상태를 Redis에 영속화 — 앱 재시작 시 복원용. (Lock 밖에서 호출)"""
await self._write_state_v2(_MDD_REDIS_KEY, self._state_snapshot_v2())
async def load_from_redis(self) -> bool:
"""Redis에서 MDD 상태 복원. 복원 성공 시 True 반환."""
@@ -75,6 +102,7 @@ class MDDManager:
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))
self._restore_reduction_progress(state)
logger.info(
f"[MDD] Redis에서 복원: peak={self.peak_balance:,.0f}, "
f"paused={self.is_paused}, reduction={self.reduction_applied}"
@@ -91,67 +119,74 @@ class MDDManager:
{"action": "NONE"/"REDUCE_AND_PAUSE"/"FORCE_REDUCE",
"drawdown": float, ...}
"""
# [L-6 5회차] 락 안에서는 상태 변경 + 스냅샷만, Redis 쓰기는 락 밖에서.
# (Redis 타임아웃 동안 can_open_new_position()·축소 루프가 락을 기다리던 문제)
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
result = self._update_locked_v2(current_balance)
snap = self._state_snapshot_v2()
await self._write_state_v2(_MDD_REDIS_KEY, snap)
return result
if self.peak_balance <= 0:
return {"action": "NONE", "drawdown": 0.0}
def _update_locked_v2(self, current_balance: float) -> Dict:
"""update() 의 상태 전이 본체 (Lock 보유 중 호출, I/O 없음)."""
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
self._clear_reduction_progress()
drawdown_pct = ((current_balance - self.peak_balance) / self.peak_balance) * 100
if self.peak_balance <= 0:
return {"action": "NONE", "drawdown": 0.0}
# -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,
}
drawdown_pct = ((current_balance - self.peak_balance) / self.peak_balance) * 100
# -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,
}
# -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% 강제 축소 + 신규진입 중단")
# 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 as _swallowed:
logger.warning("[MDD] FORCE_REDUCE 텔레그램 알림 실패: %s", _swallowed)
return {
"action": "FORCE_REDUCE",
"drawdown": drawdown_pct,
"reduce_pct": 0.50,
"prioritize_leverage": True,
}
# Redis 영속화 (상태 변경 시)
await self.save_to_redis()
return {"action": "NONE", "drawdown": drawdown_pct}
# -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% 축소 + 신규진입 중단 + 단기전략 중단")
# 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 as _swallowed:
logger.warning("[MDD] REDUCE_AND_PAUSE 텔레그램 알림 실패: %s", _swallowed)
return {
"action": "REDUCE_AND_PAUSE",
"drawdown": drawdown_pct,
"reduce_pct": 0.30,
"short_term_strategy_paused": True,
}
# Redis 영속화는 호출부(update)가 락 밖에서 수행
return {"action": "NONE", "drawdown": drawdown_pct}
async def save_to_redis_v2(self, user_id: int) -> None:
"""[v2] 사용자 스코프 MDD 상태를 `mdd:state:{user_id}` 키로 영속화.
@@ -170,6 +205,7 @@ class MDDManager:
"reduction_applied": self.reduction_applied,
"_reduction_executed_10pct": self._reduction_executed_10pct,
"_reduction_executed_20pct": self._reduction_executed_20pct,
**self._reduction_progress_state(),
}
await r.set(f"{_MDD_REDIS_KEY}:{user_id}", _dumps(state))
except Exception as e:
@@ -190,6 +226,7 @@ class MDDManager:
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))
self._restore_reduction_progress(state)
logger.info(
f"[MDD v2] Redis에서 복원: user={user_id} peak={self.peak_balance:,.0f}, "
f"paused={self.is_paused}, reduction={self.reduction_applied}"
@@ -299,6 +336,7 @@ class MDDManager:
self.reduction_applied = None
self._reduction_executed_10pct = False
self._reduction_executed_20pct = False
self._clear_reduction_progress()
was_blind = self._blind_block_active or self._update_failures > 0
self._update_failures = 0
self._update_last_error = None
@@ -308,7 +346,276 @@ class MDDManager:
logger.info("MDD 관리자 리셋")
if was_blind:
logger.info("[MDD] fail-safe 차단 해제 — 갱신 실패 카운터/차단 플래그 초기화됨")
await self.save_to_redis()
snap = self._state_snapshot_v2()
await self._write_state_v2(_MDD_REDIS_KEY, snap) # [L-6] 락 밖에서
# ── [C-1 2026-10-02] 강제축소 진행 상태 헬퍼 (기존 시그니처 불변, 신규 메서드만) ──
@staticmethod
def _today_kst() -> str:
from datetime import datetime
from app.core.market_schedule import KST
return datetime.now(KST).date().isoformat()
def _clear_reduction_progress(self) -> None:
"""완료/시도/포기 집합 전부 초기화 (Lock 보유 중 호출). peak 갱신·reset() 전용.
세대(epoch)를 올려, 초기화 이전에 시작된 축소 루프가 뒤늦게 완료를 기록해
다음 낙폭 이벤트에서 그 종목을 '이미 축소됨'으로 건너뛰는 일을 막는다.
"""
self._reduced_codes = {}
self._reduction_attempts = {}
self._reduction_gave_up = {}
self._reduction_progress_date = None
self._reduction_unconfirmed = {}
self._reduction_epoch = getattr(self, "_reduction_epoch", 0) + 1
def _reduction_progress_state(self) -> dict:
"""Redis 직렬화용 진행 상태 (save_to_redis / save_to_redis_v2 공용)."""
return {
"reduced_codes": {lv: sorted(c) for lv, c in self._reduced_codes.items()},
"reduction_attempts": {lv: dict(a) for lv, a in self._reduction_attempts.items()},
"reduction_gave_up": {lv: sorted(c) for lv, c in self._reduction_gave_up.items()},
"reduction_progress_date": self._reduction_progress_date,
"reduction_unconfirmed": {lv: {c: dict(v) for c, v in m.items()}
for lv, m in self._reduction_unconfirmed.items()},
}
def _restore_reduction_progress(self, state: dict) -> None:
"""Redis 상태에서 진행 상태 복원. 키가 없으면(구버전 상태) 빈 집합.
재기동 후 완료 집합이 없으면 실패 종목 재시도 시 이미 축소한 종목을 다시
매도하게 되므로, 복원 실패는 warning 으로 남긴다.
"""
try:
self._reduced_codes = {
str(lv): set(map(str, codes))
for lv, codes in (state.get("reduced_codes") or {}).items()
}
self._reduction_attempts = {
str(lv): {str(c): int(n) for c, n in (att or {}).items()}
for lv, att in (state.get("reduction_attempts") or {}).items()
}
self._reduction_gave_up = {
str(lv): set(map(str, codes))
for lv, codes in (state.get("reduction_gave_up") or {}).items()
}
self._reduction_progress_date = state.get("reduction_progress_date")
self._reduction_unconfirmed = {
str(lv): {str(c): dict(v) for c, v in (m or {}).items()}
for lv, m in (state.get("reduction_unconfirmed") or {}).items()
}
except Exception as e:
logger.warning(f"[MDD] 축소 진행 상태 복원 실패 — 빈 상태로 시작: {e}")
self._reduced_codes = {}
self._reduction_attempts = {}
self._reduction_gave_up = {}
self._reduction_progress_date = None
def _roll_reduction_day_locked(self) -> None:
"""KST 날짜가 바뀌면 시도 횟수·포기 집합만 초기화 (Lock 보유 중 호출).
완료 집합은 날짜로 초기화하지 않는다 — 낙폭이 이어지는 동안 날짜 리셋으로
완료 종목을 다시 축소하면 C-1 누적 매도가 하루 주기로 재발한다.
"""
today = self._today_kst()
if self._reduction_progress_date != today:
if self._reduction_attempts or self._reduction_gave_up:
logger.warning(
f"[MDD] 날짜 변경({self._reduction_progress_date} → {today}) — "
f"축소 재시도 횟수·당일 포기 종목 초기화 "
f"(포기 종목: { {lv: sorted(c) for lv, c in self._reduction_gave_up.items()} })"
)
# 포기 종목이 있던 레벨은 executed 를 내려 새 날짜에 재시도되게 한다
# (구버전 상태 복원 등으로 executed=True 와 포기 종목이 함께 남은 경우 방어).
for _lv, _codes in self._reduction_gave_up.items():
if _codes:
setattr(self, f"_reduction_executed_{_lv}", False)
self._reduction_attempts = {}
self._reduction_gave_up = {}
self._reduction_progress_date = today
def _try_begin_reduction_locked(self, action: str, log_prefix: str) -> Optional[str]:
"""축소 시작 판정 (Lock 보유 중 호출). 시작하면 레벨, 아니면 None.
날짜 롤오버를 executed 체크보다 **먼저** 수행한다 — 포기 종목만 남은 레벨도
새 날짜에는 재시도된다. 동시 실행은 인메모리 `_reduction_running` 으로만 막는다.
"""
level = self.reduction_applied
if level is None:
logger.debug(f"{log_prefix} reduction_applied=None — 축소 불필요, 스킵")
return None
self._roll_reduction_day_locked()
if level in self._reduction_running:
logger.debug(f"{log_prefix} {action} 축소 진행 중 — 스킵")
return None
if getattr(self, f"_reduction_executed_{level}", False):
logger.debug(f"{log_prefix} {action} 축소 이미 완료 — 스킵")
return None
self._reduction_running.add(level)
return level
async def _end_reduction_running(self, level: Optional[str]) -> None:
if level is None:
return
async with self._lock:
self._reduction_running.discard(level)
async def _mark_reduction_unconfirmed_v2(
self, level: str, epoch: int, stock_code: str, qty_before: int, sell_qty: int
) -> None:
async with self._lock:
if self._reduction_epoch != epoch:
return
self._reduction_unconfirmed.setdefault(level, {})[stock_code] = {
"qty_before": int(qty_before), "sell_qty": int(sell_qty), "cycles": 0,
}
async def _resolve_unconfirmed_reduction_v2(
self, level: str, epoch: int, h: dict, log_prefix: str
) -> Optional[str]:
"""[M-1 5회차] 결과 불명 주문의 판정 — None(대상 아님)/'sold'/'wait'/'not_sent'/'gave_up'.
- 보유 < 주문 전 보유 → 'sold' (완료 처리; 판 수량은 _verdict_qty 로 조회)
- 주문가능 < 보유 → 미체결 매도 주문이 살아 있음 → 'wait'
- 주문가능 == 보유 == 주문 전 보유 → 전송 안 됨 → 'not_sent'(정상 재주문)
- 주문가능 수량 필드가 없어 판정 불가 → 'wait', 연속 MDD_REDUCTION_MAX_RETRIES 주기면
당일 포기 + error(수동 확인). 재매도는 하지 않는다.
"""
code = h.get("pdno", "")
rec = (self._reduction_unconfirmed.get(level) or {}).get(code)
if rec is None:
return None
qty_now = int(h.get("hldg_qty", 0) or 0)
_psbl_raw = h.get("ord_psbl_qty")
ord_psbl = int(float(_psbl_raw)) if _psbl_raw not in (None, "") else None
before = int(rec.get("qty_before", 0))
async with self._lock:
if self._reduction_epoch != epoch:
return "wait"
if qty_now < before:
self._reduction_unconfirmed[level].pop(code, None)
rec["sold_qty"] = before - qty_now
self._reduction_unconfirmed.setdefault("_sold_" + level, {})[code] = rec
self._reduced_codes.setdefault(level, set()).add(code)
verdict = "sold"
elif ord_psbl is not None and ord_psbl < qty_now:
rec["cycles"] = int(rec.get("cycles", 0)) + 1
verdict = "wait"
elif ord_psbl is not None:
self._reduction_unconfirmed[level].pop(code, None)
verdict = "not_sent"
else:
rec["cycles"] = int(rec.get("cycles", 0)) + 1
cap = max(1, int(settings.MDD_REDUCTION_MAX_RETRIES))
if rec["cycles"] >= cap:
self._reduction_unconfirmed[level].pop(code, None)
self._reduction_gave_up.setdefault(level, set()).add(code)
verdict = "gave_up"
else:
verdict = "wait"
if verdict == "sold":
logger.warning(f"{log_prefix} {code} 결과 불명 축소 주문 체결 확인(잔고 {before}→{qty_now}주) — 완료 처리")
elif verdict == "not_sent":
logger.warning(f"{log_prefix} {code} 결과 불명 축소 주문 미접수 확인(잔고·주문가능 불변) — 재주문")
elif verdict == "gave_up":
logger.error(
f"{log_prefix} 🚨 {code} 결과 불명 축소 주문을 {rec['cycles']}주기 동안 확인 불가"
f"(주문가능 수량 미제공) — 당일 재매도 안 함, 수동 확인 필요"
)
else:
logger.warning(f"{log_prefix} {code} 결과 불명 축소 주문 확인 대기({rec['cycles']}주기) — 재매도 안 함")
return verdict
async def _begin_reduction_progress(self, level: str) -> tuple:
"""축소 루프 시작 시 (건너뛸 종목 집합, 세대) 스냅샷. Lock 보유 중 호출 금지."""
async with self._lock:
self._roll_reduction_day_locked()
settled = set(self._reduced_codes.get(level, ())) | set(
self._reduction_gave_up.get(level, ())
)
return settled, getattr(self, "_reduction_epoch", 0)
async def _mark_reduction_done(self, level: str, stock_code: str, epoch: int) -> None:
async with self._lock:
if getattr(self, "_reduction_epoch", 0) != epoch:
return # 루프 도중 peak 갱신/reset — 새 세대에 옛 완료를 남기지 않는다
self._reduced_codes.setdefault(level, set()).add(stock_code)
self._reduction_attempts.get(level, {}).pop(stock_code, None)
async def _mark_reduction_failed(
self, level: str, stock_code: str, epoch: int, reason: str, log_prefix: str
) -> bool:
"""실패 1회 기록. 상한 도달 시 당일 포기 집합에 넣고 True 반환."""
max_retries = max(1, int(settings.MDD_REDUCTION_MAX_RETRIES))
async with self._lock:
if getattr(self, "_reduction_epoch", 0) != epoch:
return False
att = self._reduction_attempts.setdefault(level, {})
att[stock_code] = att.get(stock_code, 0) + 1
n = att[stock_code]
gave_up = n >= max_retries
if gave_up:
self._reduction_gave_up.setdefault(level, set()).add(stock_code)
if gave_up:
logger.error(
f"{log_prefix} 🚨 강제축소 {stock_code} {n}회 연속 실패(상한 {max_retries}) — "
f"당일 재시도 중단, 수동 확인 필요. 마지막 사유: {reason}"
)
else:
logger.warning(
f"{log_prefix} 강제축소 {stock_code} 실패 {n}/{max_retries}회 — "
f"다음 주기에 이 종목만 재시도. 사유: {reason}"
)
return gave_up
async def _finish_reduction_progress(
self,
executed_key: str,
action: str,
results: list,
pending: list,
log_prefix: str,
level: Optional[str] = None,
epoch: Optional[int] = None,
) -> None:
"""루프 종료 처리.
executed 플래그는 **재시도 대상(실패·보류)도, 당일 포기 종목도 없을 때만** True.
- pending 있음 → 다음 주기에 그 종목만 재시도(완료 종목은 settled 로 건너뜀)
- 포기 종목만 남음 → executed=False 유지. 당일에는 settled 로 skip(주문 없음),
KST 날짜가 바뀌면 시도 횟수가 초기화돼 재시도된다.
"""
lv = level or self.reduction_applied or ""
async with self._lock:
if epoch is not None and epoch != self._reduction_epoch:
# [M-1 4회차] 루프 도중 reset()/peak 갱신 — 옛 세대의 결과로 executed 를
# 세우면 다음 -10% 이벤트의 축소가 생략된다. 상태를 건드리지 않는다.
logger.warning(
f"{log_prefix} {action} 진행 중 MDD 상태가 리셋됨(세대 {epoch}→{self._reduction_epoch}) "
f"— 이번 결과로 executed/진행 상태를 갱신하지 않음 (이번 주기 {len(results)}종목 매도)"
)
return
gave_up = sorted(self._reduction_gave_up.get(lv, ()))
done = not pending and not gave_up
setattr(self, executed_key, bool(done))
if pending:
logger.warning(
f"{log_prefix} {action} 부분 완료: 이번 주기 {len(results)}종목 성공, "
f"재시도 대상 {len(pending)}종목 {pending} — 완료 종목은 재매도하지 않음"
)
elif gave_up:
warn_throttled(
logger, f"mdd:gave_up:{lv}",
"%s 🚨 %s 미완료 — 이번 주기 %d종목 성공, 당일 포기 %s "
"(KST 날짜 변경 시 자동 재시도, 즉시 필요하면 수동 매도)",
log_prefix, action, len(results), gave_up,
)
else:
logger.warning(
f"{log_prefix} {action} 레벨 축소 완료 (이번 주기 {len(results)}종목 매도, "
f"누적 완료 {len(self._reduced_codes.get(lv, ()))}종목)"
)
async def execute_reduction(self, mdd_action: dict) -> list[dict]:
@@ -319,6 +626,20 @@ class MDDManager:
- FORCE_REDUCE (-20%): 50% 강제 축소, 레버리지/인버스 ETF 우선
중복 실행 방지: 동일 reduction_applied 레벨에서 1회만 실행.
[C-1 2026-10-02] 재시도 정책:
- 종목 단위 완료 집합(`_reduced_codes[level]`)으로 이미 축소한 종목은 같은
레벨에서 다시 매도하지 않는다. 일부 종목 실패 시 다음 주기에는 **실패 종목만**
재시도한다(기존: 전 보유종목 재매도 → 성공 종목 누적 축소).
- 완료 기록은 주문 접수(rt_cd=="0") 직후, 부기(TradeHistory·ActiveTrade)보다 먼저
남긴다 — 부기 예외가 재매도를 유발하지 않게.
- `order_lock_busy`(다른 경로가 같은 종목 주문 중)는 실패가 아닌 보류로 재시도.
- 실패는 종목별로 계수해 `MDD_REDUCTION_MAX_RETRIES` 도달 시 당일 포기 + error.
- 완료 집합은 Redis(`mdd:state`)에 영속 → 재기동 후에도 재축소하지 않는다.
peak 갱신·reset() 시 초기화(§5.3 플래그 리셋과 동일), KST 날짜 변경 시에는
시도 횟수·포기 집합만 초기화한다.
- 수량 정책(불변): `max(int(보유 × 비율), 1)` — 1주 보유 종목은 30%/50% 단계에서도
1주 전량 매도된다(0주 축소로 리스크가 남는 것보다 우선).
"""
action = mdd_action.get("action", "NONE")
if action == "NONE":
@@ -335,18 +656,26 @@ class MDDManager:
executed_key: Optional[str] = None
# 중복 실행 방지: 이미 해당 레벨의 축소가 실행된 경우 스킵
# [이슈2] reduction_applied=None 이면 축소 불필요(early return).
# [3회차] 날짜 롤오버 → executed 체크 → 인메모리 진행 가드 순(_try_begin_reduction_locked)
if not _reduction_market_open_v2("[MDD]", action):
return []
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)
level = self._try_begin_reduction_locked(action, "[MDD]")
if level is None:
return []
executed_key = f"_reduction_executed_{level}"
try:
return await self._execute_reduction_impl(
action, reduce_pct, prioritize_leverage, level, executed_key
)
finally:
await self._end_reduction_running(level)
async def _execute_reduction_impl(
self, action: str, reduce_pct: float, prioritize_leverage: bool,
level: str, executed_key: str,
) -> list[dict]:
try:
from app.services.korea_investment_api import korea_investment_api
@@ -354,9 +683,22 @@ class MDDManager:
# 현재 보유 종목 조회
balance = await korea_investment_api.get_balance()
holdings = balance.get("output1", [])
# [C-2 3회차] 오류 응답(rt_cd≠0: EGW00201·40910000 등)은 output1 이 없다 —
# '보유 없음'으로 처리하면 executed 가 서고 이 레벨 축소가 영구 생략됐다.
if not _balance_response_ok(balance):
logger.warning(
f"[MDD] {action} 잔고 조회 실패 응답 — 축소 보류, 다음 주기 재시도 "
f"(rt_cd={_rt_cd(balance)}, msg={_msg(balance)})"
)
return []
holdings = balance.get("output1") or []
settled, epoch = await self._begin_reduction_progress(level)
if not holdings:
logger.info(f"[MDD] {action} — 보유 종목 없음, 축소 불필요")
logger.info(f"[MDD] {action} — 보유 종목 없음(정상 응답), 축소 불필요")
await self._finish_reduction_progress(
executed_key, action, [], [], "[MDD]", level=level, epoch=epoch
)
await self.save_to_redis()
return []
# 레버리지/인버스 ETF 우선 정렬 (FORCE_REDUCE 시)
@@ -369,7 +711,12 @@ class MDDManager:
results = []
skipped = 0
pending: list[str] = [] # 다음 주기 재시도 대상 (실패·보류)
for h in holdings:
# [M-1 4회차] 루프 도중 reset()/peak 갱신이면 남은 종목 매도를 중단한다.
if self._reduction_epoch != epoch:
logger.warning(f"[MDD] {action} 진행 중 MDD 상태 리셋 감지 — 남은 종목 축소 중단")
break
stock_code = h.get("pdno", "")
stock_name = h.get("prdt_name", stock_code)
quantity = int(h.get("hldg_qty", 0) or 0)
@@ -378,6 +725,30 @@ class MDDManager:
if quantity <= 0 or current_price <= 0:
skipped += 1
continue
if stock_code in settled:
# [C-1] 이 레벨에서 이미 축소 완료(또는 당일 포기) — 재매도 금지
skipped += 1
continue
# [M-1 5회차] 직전 주기 '전송 후 결과 불명' 주문 — 재매도 전에 잔고로 확인
_verdict = await self._resolve_unconfirmed_reduction_v2(
level, epoch, h, "[MDD]"
)
if _verdict == "sold":
_uc_sold = int(_verdict_qty(h, self, level, stock_code))
await _sync_active_trade_after_reduction(
stock_code=stock_code, quantity=quantity + _uc_sold,
remaining_qty=quantity,
)
await _reflect_reduction(stock_code, _uc_sold, current_price, quantity)
skipped += 1
continue
if _verdict == "wait":
pending.append(stock_code)
continue
if _verdict == "gave_up":
skipped += 1
continue
_ordered = False
sell_qty = max(int(quantity * reduce_pct), 1)
sell_qty = min(sell_qty, quantity)
@@ -391,6 +762,10 @@ class MDDManager:
price=None, # 시장가
)
if order_resp and order_resp.get("rt_cd") == "0":
# [C-1] 완료 기록을 부기보다 먼저 — 이후 예외가 재매도를 유발하지 않게
_ordered = True
await self._mark_reduction_done(level, stock_code, epoch)
await self.save_to_redis()
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
@@ -442,30 +817,56 @@ class MDDManager:
f"{sell_qty}주 (총 {quantity}주 중 {reduce_pct*100:.0f}%)"
)
elif _is_order_lock_busy(order_resp):
# [C-1] 다른 경로가 같은 종목을 주문 중 — 실패로 세지 않고 보류
pending.append(stock_code)
logger.warning(
f"[MDD] {action} {stock_code} 주문 락 점유 중(order_lock_busy) — "
f"보류, 다음 주기 재시도"
)
elif _is_transient_order_failure_v2(order_resp):
# [M-1 5회차] 전송 전 실패·레이트리밋·토큰 — 일시 장애. 재시도 횟수 미산정
# (토큰 장애 5분으로 축소를 당일 포기하지 않도록).
pending.append(stock_code)
logger.warning(
f"[MDD] {action} {stock_code} 일시 실패 "
f"(msg_cd={(order_resp or {}).get('msg_cd')}) — 미산정, 다음 주기 재시도"
)
elif _is_order_outcome_unknown_mdd_v2(order_resp):
# [M-1 5회차] 전송 후 결과 불명 — 팔렸을 수 있다. 다음 주기에 잔고로 확인할
# 때까지 재매도하지 않는다(누적 축소 방지).
await self._mark_reduction_unconfirmed_v2(level, epoch, stock_code, quantity, sell_qty)
pending.append(stock_code)
logger.error(
f"[MDD] {action} {stock_code} {sell_qty}주 주문 결과 불명 "
f"(msg_cd={(order_resp or {}).get('msg_cd')}) — 다음 주기 잔고 확인 전 재매도 안 함"
)
else:
_reason = (order_resp or {}).get("msg1") or "no_response"
if not await self._mark_reduction_failed(
level, stock_code, epoch, str(_reason), "[MDD]"
):
pending.append(stock_code)
await asyncio.sleep(0.3) # KIS API 레이트 리밋
except Exception as e:
logger.error(f"[MDD] 축소 매도 실패 ({stock_code}): {e}")
if not _ordered:
if not await self._mark_reduction_failed(
level, stock_code, epoch, str(e), "[MDD]"
):
pending.append(stock_code)
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)}종목 축소 매도")
# [C-1] 실패·보류 종목이 남으면 플래그를 내려 그 종목만 재시도
await self._finish_reduction_progress(
executed_key, action, results, pending, "[MDD]", level=level, epoch=epoch
)
await self.save_to_redis()
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)
# executed 는 완료 시에만 True — 여기서는 그대로 False(다음 주기 재시도)
return []
async def execute_reduction_v2(self, mdd_action: dict, kis, user_id: int) -> list[dict]:
@@ -488,15 +889,24 @@ class MDDManager:
executed_key: Optional[str] = None
if not _reduction_market_open_v2(f"[MDD v2] user={user_id}", action):
return []
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)
level = self._try_begin_reduction_locked(action, f"[MDD v2] user={user_id}")
if level is None:
return []
executed_key = f"_reduction_executed_{level}"
try:
return await self._execute_reduction_v2_impl(
action, reduce_pct, prioritize_leverage, level, executed_key, kis, user_id
)
finally:
await self._end_reduction_running(level)
async def _execute_reduction_v2_impl(
self, action: str, reduce_pct: float, prioritize_leverage: bool,
level: str, executed_key: str, kis, user_id: int,
) -> list[dict]:
try:
from app.services.db_worker import (
@@ -506,9 +916,20 @@ class MDDManager:
)
balance = await kis.get_balance()
holdings = balance.get("output1", [])
if not _balance_response_ok(balance):
logger.warning(
f"[MDD v2] user={user_id} {action} 잔고 조회 실패 응답 — 축소 보류, 다음 주기 재시도 "
f"(rt_cd={_rt_cd(balance)}, msg={_msg(balance)})"
)
return []
holdings = balance.get("output1") or []
settled, epoch = await self._begin_reduction_progress(level)
if not holdings:
logger.info(f"[MDD v2] user={user_id} {action} — 보유 종목 없음, 축소 불필요")
logger.info(f"[MDD v2] user={user_id} {action} — 보유 종목 없음(정상 응답), 축소 불필요")
await self._finish_reduction_progress(
executed_key, action, [], [], f"[MDD v2] user={user_id}", level=level, epoch=epoch
)
await self.save_to_redis_v2(user_id)
return []
LEVERAGE_KEYWORDS = {"레버리지", "2X", "3X", "인버스", "TIGER 200선물", "KODEX 인버스", "곱버스"}
@@ -520,7 +941,11 @@ class MDDManager:
results = []
skipped = 0
pending: list[str] = [] # 다음 주기 재시도 대상 (실패·보류)
for h in holdings:
if self._reduction_epoch != epoch:
logger.warning(f"[MDD v2] user={user_id} {action} 진행 중 MDD 상태 리셋 감지 — 남은 종목 축소 중단")
break
stock_code = h.get("pdno", "")
stock_name = h.get("prdt_name", stock_code)
quantity = int(h.get("hldg_qty", 0) or 0)
@@ -529,6 +954,30 @@ class MDDManager:
if quantity <= 0 or current_price <= 0:
skipped += 1
continue
if stock_code in settled:
# [C-1] 이 레벨에서 이미 축소 완료(또는 당일 포기) — 재매도 금지
skipped += 1
continue
# [M-1 5회차] 직전 주기 '전송 후 결과 불명' 주문 — 재매도 전에 잔고로 확인
_verdict = await self._resolve_unconfirmed_reduction_v2(
level, epoch, h, f"[MDD v2] user={user_id}"
)
if _verdict == "sold":
_uc_sold = int(_verdict_qty(h, self, level, stock_code))
await _sync_active_trade_after_reduction(
stock_code=stock_code, quantity=quantity + _uc_sold,
remaining_qty=quantity, user_id=user_id,
)
await _reflect_reduction(stock_code, _uc_sold, current_price, quantity)
skipped += 1
continue
if _verdict == "wait":
pending.append(stock_code)
continue
if _verdict == "gave_up":
skipped += 1
continue
_ordered = False
sell_qty = max(int(quantity * reduce_pct), 1)
sell_qty = min(sell_qty, quantity)
@@ -558,6 +1007,10 @@ class MDDManager:
price=None, # 시장가
)
if order_resp and order_resp.get("rt_cd") == "0":
# [C-1] 완료 기록을 부기보다 먼저 — 이후 예외가 재매도를 유발하지 않게
_ordered = True
await self._mark_reduction_done(level, stock_code, epoch)
await self.save_to_redis_v2(user_id)
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
@@ -605,31 +1058,113 @@ class MDDManager:
f"{sell_qty}주 (총 {quantity}주 중 {reduce_pct*100:.0f}%)"
)
elif _is_order_lock_busy(order_resp):
# [C-1] 다른 경로가 같은 종목을 주문 중 — 실패로 세지 않고 보류
pending.append(stock_code)
logger.warning(
f"[MDD v2] user={user_id} {action} {stock_code} 주문 락 점유 중(order_lock_busy) — "
f"보류, 다음 주기 재시도"
)
elif _is_transient_order_failure_v2(order_resp):
# [M-1 5회차] 전송 전 실패·레이트리밋·토큰 — 일시 장애. 재시도 횟수 미산정
# (토큰 장애 5분으로 축소를 당일 포기하지 않도록).
pending.append(stock_code)
logger.warning(
f"[MDD v2] user={user_id} {action} {stock_code} 일시 실패 "
f"(msg_cd={(order_resp or {}).get('msg_cd')}) — 미산정, 다음 주기 재시도"
)
elif _is_order_outcome_unknown_mdd_v2(order_resp):
# [M-1 5회차] 전송 후 결과 불명 — 팔렸을 수 있다. 다음 주기에 잔고로 확인할
# 때까지 재매도하지 않는다(누적 축소 방지).
await self._mark_reduction_unconfirmed_v2(level, epoch, stock_code, quantity, sell_qty)
pending.append(stock_code)
logger.error(
f"[MDD v2] user={user_id} {action} {stock_code} {sell_qty}주 주문 결과 불명 "
f"(msg_cd={(order_resp or {}).get('msg_cd')}) — 다음 주기 잔고 확인 전 재매도 안 함"
)
else:
_reason = (order_resp or {}).get("msg1") or "no_response"
if not await self._mark_reduction_failed(
level, stock_code, epoch, str(_reason), f"[MDD v2] user={user_id}"
):
pending.append(stock_code)
await asyncio.sleep(0.3) # KIS API 레이트 리밋
except Exception as e:
logger.error(f"[MDD v2] user={user_id} 축소 매도 실패 ({stock_code}): {e}")
if not _ordered:
if not await self._mark_reduction_failed(
level, stock_code, epoch, str(e), f"[MDD v2] user={user_id}"
):
pending.append(stock_code)
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)}종목 축소 매도")
# [C-1] 실패·보류 종목이 남으면 플래그를 내려 그 종목만 재시도
await self._finish_reduction_progress(
executed_key, action, results, pending, f"[MDD v2] user={user_id}", level=level, epoch=epoch
)
await self.save_to_redis_v2(user_id)
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 []
def _reduction_market_open_v2(log_prefix: str, action: str) -> bool:
"""[L-1 4회차] 강제축소 주문은 정규장(휴장일·주말·장외 제외)에만. 판정·상태는 유지된다.
swing/scheduler 도 장 미오픈이면 사이클을 건너뛰지만, 사이클이 15:30 을 넘기거나
다른 호출부(v2)가 부를 수 있으므로 주문 직전에 한 번 더 확인한다.
"""
from app.core.market_schedule import is_market_open
if is_market_open():
return True
warn_throttled(
logger, "mdd:reduction_market_closed",
"%s %s 강제축소 보류 — 장 운영시간 아님(휴장일/주말/장외). 개장 후 재시도", log_prefix, action,
)
return False
def _verdict_qty(h: dict, mgr, level: str, code: str) -> int:
"""'sold' 판정 직후 실제 감소 수량 (판정 시 기록, 1회 소비)."""
rec = (mgr._reduction_unconfirmed.get("_sold_" + level) or {}).pop(code, None) or {}
return int(rec.get("sold_qty", 0))
def _is_transient_order_failure_v2(resp) -> bool:
"""전송 전 실패·레이트리밋·토큰 무효 — trade_executor 의 재전송 안전 판정과 같은 정의."""
from app.services.trade_executor import _is_retryable_order_failure
return isinstance(resp, dict) and resp.get("rt_cd") != "0" and _is_retryable_order_failure(resp)
def _is_order_outcome_unknown_mdd_v2(resp) -> bool:
from app.services.trade_executor import _is_order_outcome_unknown_v2
return _is_order_outcome_unknown_v2(resp)
def _rt_cd(resp):
return resp.get("rt_cd") if isinstance(resp, dict) else None
def _msg(resp):
return (resp.get("msg_cd"), resp.get("msg1")) if isinstance(resp, dict) else repr(resp)
def _balance_response_ok(balance) -> bool:
"""잔고 응답이 '정상'인지. rt_cd 가 있고 '0' 이 아니거나 output1 이 list 가 아니면 False."""
if not isinstance(balance, dict):
return False
if _rt_cd(balance) not in (None, "0"):
return False
return isinstance(balance.get("output1"), list)
def _is_order_lock_busy(order_resp) -> bool:
"""safe_place_order(_v2) 가 분산락 획득 실패 시 돌려주는 응답인지 판정."""
return bool(order_resp) and order_resp.get("msg1") == "order_lock_busy"
# 싱글톤 인스턴스
mdd_manager = MDDManager()