## 재기동 내구성 - 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>
917 lines
46 KiB
Python
917 lines
46 KiB
Python
"""
|
||
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"당일 재진입 차단이 풀릴 수 있다"
|
||
)
|