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:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user