설계: docs/multi_tenant_design.md / 검수: docs/multi_tenant_phase1_review.md (High 0, MT=False 배포 가능)
데이터·인프라
- user_kis_credentials(Fernet 암호화, PBKDF2 키 파생, key_version), stock_bans_v2 신설,
portfolio/trade_history/active_trades/trade_journals/trading_sessions 등 user_id nullable 추가
- alembic 0009~0011(idempotent, legacy owner 백필). main.py 기동 시 portfolio.user_id 스키마 검사(H1)
- Jenkinsfile: DB Migration 을 Deploy 앞으로, alembic 이력 없으면 stamp 0008 후 upgrade(N1)
- bootstrap: users 비어 있으면 BOOTSTRAP_ADMIN_* 로 관리자 시드 + .env KIS 자격증명 이관 + 백필(SAVEPOINT)
백엔드
- core/crypto.py, core/tenant_auth.py(get_current_user — 비활성 사용자 401, API 키→legacy owner,
deny_non_owner_session_when_mt), core/dt_utils.py
- services/tenant: context, kis_pool, registry(UserContextRegistry, 락 최소화·복호화 to_thread), risk_factory
- KoreaInvestmentAPI.for_user(사용자별 인스턴스, 토큰캐시 users/{id}/, 시세는 공용 위임)
- trade_executor.safe_place_order_v2(사용자별 락/세마포어, legacy ctx 는 기존 키)
- mdd_manager/loss_tracker _v2 + ban_until tz-aware/naive 경계 정규화(레거시 경로 버그 포함 수정)
- db_worker: payload user_id 옵션 필터, upsert_stock_ban_v2, legacy ActiveTrade 조회 LIMIT 1
- API: /account/credentials(등록·마스킹조회·삭제·연결테스트·자동매매), /auth/admin/tenant/*,
/portfolio/v2(조회·요약·sync·buy·sell), /trades/v2(+pending). PortfolioResponse Optional 수정
프론트
- /account 내 계좌 설정, useTenantMode(세션=v2 / API키=legacy, 미확정 시 쿼리·버튼 비활성),
계좌 미등록 인라인 안내, v2 매수/매도/거래이력 분기
테스트: tests/integration(격리 Postgres, KIS 전면 mock) 20 passed / 1 xfailed(0012 이전 제약)
2단계(스케줄러 사용자화·legacy 라우터 격리) 전까지 MULTI_TENANT_ENABLED=True 전환 금지
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
533 lines
25 KiB
Python
533 lines
25 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.json_helpers import _dumps
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_MDD_REDIS_KEY = "mdd:state"
|
|
|
|
|
|
class MDDManager:
|
|
"""포트폴리오 MDD 관리자 (싱글톤으로 사용)"""
|
|
|
|
def __init__(self):
|
|
self.peak_balance: float = 0.0
|
|
self.is_paused: bool = False
|
|
self.is_short_term_strategy_paused: bool = False # MDD -10% 시 단기 전략 중단
|
|
self.reduction_applied: Optional[str] = None # None, "10pct", "20pct"
|
|
self._reduction_executed_10pct: bool = False # 중복 축소 실행 방지
|
|
self._reduction_executed_20pct: bool = False
|
|
self._lock = asyncio.Lock() # 스케줄러-sync 동시 접근 방지
|
|
|
|
async def save_to_redis(self) -> None:
|
|
"""MDD 상태를 Redis에 영속화 — 앱 재시작 시 복원용."""
|
|
try:
|
|
from app.core.redis import get_redis
|
|
r = get_redis()
|
|
if r:
|
|
state = {
|
|
"peak_balance": self.peak_balance,
|
|
"is_paused": self.is_paused,
|
|
"is_short_term_strategy_paused": self.is_short_term_strategy_paused,
|
|
"reduction_applied": self.reduction_applied,
|
|
"_reduction_executed_10pct": self._reduction_executed_10pct,
|
|
"_reduction_executed_20pct": self._reduction_executed_20pct,
|
|
}
|
|
await r.set(_MDD_REDIS_KEY, _dumps(state))
|
|
except Exception as e:
|
|
logger.warning(f"[MDD] Redis 저장 실패: {e}")
|
|
|
|
async def load_from_redis(self) -> bool:
|
|
"""Redis에서 MDD 상태 복원. 복원 성공 시 True 반환."""
|
|
try:
|
|
from app.core.redis import get_redis
|
|
r = get_redis()
|
|
if r:
|
|
raw = await r.get(_MDD_REDIS_KEY)
|
|
if raw:
|
|
state = json.loads(raw)
|
|
self.peak_balance = float(state.get("peak_balance", 0.0))
|
|
self.is_paused = bool(state.get("is_paused", False))
|
|
self.is_short_term_strategy_paused = bool(state.get("is_short_term_strategy_paused", False))
|
|
self.reduction_applied = state.get("reduction_applied")
|
|
self._reduction_executed_10pct = bool(state.get("_reduction_executed_10pct", False))
|
|
self._reduction_executed_20pct = bool(state.get("_reduction_executed_20pct", False))
|
|
logger.info(
|
|
f"[MDD] Redis에서 복원: peak={self.peak_balance:,.0f}, "
|
|
f"paused={self.is_paused}, reduction={self.reduction_applied}"
|
|
)
|
|
return True
|
|
except Exception as e:
|
|
logger.warning(f"[MDD] Redis 복원 실패: {e}")
|
|
return False
|
|
|
|
async def update(self, current_balance: float) -> Dict:
|
|
"""MDD 업데이트 및 필요한 조치 반환 (thread-safe)
|
|
|
|
Returns:
|
|
{"action": "NONE"/"REDUCE_AND_PAUSE"/"FORCE_REDUCE",
|
|
"drawdown": float, ...}
|
|
"""
|
|
async with self._lock:
|
|
if current_balance > self.peak_balance:
|
|
self.peak_balance = current_balance
|
|
self.is_paused = False
|
|
self.is_short_term_strategy_paused = False
|
|
self.reduction_applied = None
|
|
self._reduction_executed_10pct = False
|
|
self._reduction_executed_20pct = False
|
|
|
|
if self.peak_balance <= 0:
|
|
return {"action": "NONE", "drawdown": 0.0}
|
|
|
|
drawdown_pct = ((current_balance - self.peak_balance) / self.peak_balance) * 100
|
|
|
|
# -20% 돌파: 50% 강제 축소
|
|
if drawdown_pct <= -20 and self.reduction_applied != "20pct":
|
|
self.is_paused = True
|
|
self.is_short_term_strategy_paused = True # 단번에 -20% 도달 시에도 단기전략 차단
|
|
self.reduction_applied = "20pct"
|
|
logger.warning(f"MDD {drawdown_pct:.1f}% - 50% 강제 축소 + 신규진입 중단")
|
|
await self.save_to_redis()
|
|
# Telegram MDD 경고
|
|
try:
|
|
from app.services.telegram_notifier import telegram_notifier
|
|
asyncio.ensure_future(telegram_notifier.notify_mdd_warning(
|
|
action="FORCE_REDUCE", drawdown=drawdown_pct, reduce_pct=0.50,
|
|
))
|
|
except Exception:
|
|
pass
|
|
return {
|
|
"action": "FORCE_REDUCE",
|
|
"drawdown": drawdown_pct,
|
|
"reduce_pct": 0.50,
|
|
"prioritize_leverage": True,
|
|
}
|
|
|
|
# -10% 돌파: 30% 축소 + 신규진입 중단 + 단기전략 중단
|
|
if drawdown_pct <= -10 and self.reduction_applied is None:
|
|
self.is_paused = True
|
|
self.is_short_term_strategy_paused = True # CLAUDE.md 5.3: 단기 매매 전략 중단
|
|
self.reduction_applied = "10pct"
|
|
logger.warning(f"MDD {drawdown_pct:.1f}% - 30% 축소 + 신규진입 중단 + 단기전략 중단")
|
|
await self.save_to_redis()
|
|
# Telegram MDD 경고
|
|
try:
|
|
from app.services.telegram_notifier import telegram_notifier
|
|
asyncio.ensure_future(telegram_notifier.notify_mdd_warning(
|
|
action="REDUCE_AND_PAUSE", drawdown=drawdown_pct, reduce_pct=0.30,
|
|
))
|
|
except Exception:
|
|
pass
|
|
return {
|
|
"action": "REDUCE_AND_PAUSE",
|
|
"drawdown": drawdown_pct,
|
|
"reduce_pct": 0.30,
|
|
"short_term_strategy_paused": True,
|
|
}
|
|
|
|
# Redis 영속화 (상태 변경 시)
|
|
await self.save_to_redis()
|
|
return {"action": "NONE", "drawdown": drawdown_pct}
|
|
|
|
async def save_to_redis_v2(self, user_id: int) -> None:
|
|
"""[v2] 사용자 스코프 MDD 상태를 `mdd:state:{user_id}` 키로 영속화.
|
|
|
|
save_to_redis()와 동일 직렬화 포맷 재사용, 키 네임스페이스만 사용자별로 분리.
|
|
레거시 save_to_redis()는 불변(전역 "mdd:state" 키 그대로).
|
|
"""
|
|
try:
|
|
from app.core.redis import get_redis
|
|
r = get_redis()
|
|
if r:
|
|
state = {
|
|
"peak_balance": self.peak_balance,
|
|
"is_paused": self.is_paused,
|
|
"is_short_term_strategy_paused": self.is_short_term_strategy_paused,
|
|
"reduction_applied": self.reduction_applied,
|
|
"_reduction_executed_10pct": self._reduction_executed_10pct,
|
|
"_reduction_executed_20pct": self._reduction_executed_20pct,
|
|
}
|
|
await r.set(f"{_MDD_REDIS_KEY}:{user_id}", _dumps(state))
|
|
except Exception as e:
|
|
logger.warning(f"[MDD v2] Redis 저장 실패 (user={user_id}): {e}")
|
|
|
|
async def load_from_redis_v2(self, user_id: int) -> bool:
|
|
"""[v2] `mdd:state:{user_id}` 키에서 사용자 스코프 MDD 상태 복원."""
|
|
try:
|
|
from app.core.redis import get_redis
|
|
r = get_redis()
|
|
if r:
|
|
raw = await r.get(f"{_MDD_REDIS_KEY}:{user_id}")
|
|
if raw:
|
|
state = json.loads(raw)
|
|
self.peak_balance = float(state.get("peak_balance", 0.0))
|
|
self.is_paused = bool(state.get("is_paused", False))
|
|
self.is_short_term_strategy_paused = bool(state.get("is_short_term_strategy_paused", False))
|
|
self.reduction_applied = state.get("reduction_applied")
|
|
self._reduction_executed_10pct = bool(state.get("_reduction_executed_10pct", False))
|
|
self._reduction_executed_20pct = bool(state.get("_reduction_executed_20pct", False))
|
|
logger.info(
|
|
f"[MDD v2] Redis에서 복원: user={user_id} peak={self.peak_balance:,.0f}, "
|
|
f"paused={self.is_paused}, reduction={self.reduction_applied}"
|
|
)
|
|
return True
|
|
except Exception as e:
|
|
logger.warning(f"[MDD v2] Redis 복원 실패 (user={user_id}): {e}")
|
|
return False
|
|
|
|
async def can_open_new_position(self) -> bool:
|
|
"""신규 포지션 진입 가능 여부 (thread-safe)"""
|
|
async with self._lock:
|
|
return not self.is_paused
|
|
|
|
async def is_short_term_strategy_enabled(self) -> bool:
|
|
"""단기 전략 활성화 여부 (thread-safe) — MDD -10% 이상일 때만 활성화"""
|
|
async with self._lock:
|
|
return not self.is_short_term_strategy_paused
|
|
|
|
async def reset(self):
|
|
"""MDD 상태 리셋 (수동 복구 시) — async + Lock으로 동시성 안전 보장"""
|
|
async with self._lock:
|
|
self.is_paused = False
|
|
self.is_short_term_strategy_paused = False
|
|
self.reduction_applied = None
|
|
self._reduction_executed_10pct = False
|
|
self._reduction_executed_20pct = False
|
|
logger.info("MDD 관리자 리셋")
|
|
await self.save_to_redis()
|
|
|
|
|
|
async def execute_reduction(self, mdd_action: dict) -> list[dict]:
|
|
"""MDD 액션에 따라 포지션 축소 매도 실행.
|
|
|
|
CLAUDE.md 5.3:
|
|
- REDUCE_AND_PAUSE (-10%): 기존 포지션 30% 축소
|
|
- FORCE_REDUCE (-20%): 50% 강제 축소, 레버리지/인버스 ETF 우선
|
|
|
|
중복 실행 방지: 동일 reduction_applied 레벨에서 1회만 실행.
|
|
"""
|
|
action = mdd_action.get("action", "NONE")
|
|
if action == "NONE":
|
|
return []
|
|
|
|
reduce_pct = mdd_action.get("reduce_pct", 0.0)
|
|
prioritize_leverage = mdd_action.get("prioritize_leverage", False)
|
|
|
|
if reduce_pct <= 0:
|
|
return []
|
|
|
|
# [BUG FIX] executed_key를 Lock 밖(finally)에서도 참조하므로
|
|
# Lock 진입 전에 None으로 초기화
|
|
executed_key: Optional[str] = None
|
|
|
|
# 중복 실행 방지: 이미 해당 레벨의 축소가 실행된 경우 스킵
|
|
async with self._lock:
|
|
# [이슈2 수정] reduction_applied가 None이면 executed_key가
|
|
# "_reduction_executed_None" 속성을 생성해 중복 실행 방지가 무력화됨.
|
|
# None 상태는 MDD 미달(정상)이므로 early return.
|
|
if self.reduction_applied is None:
|
|
logger.debug(f"[MDD] reduction_applied=None — 축소 불필요, 스킵")
|
|
return []
|
|
executed_key = f"_reduction_executed_{self.reduction_applied}"
|
|
if getattr(self, executed_key, False):
|
|
logger.debug(f"[MDD] {action} 축소 이미 실행됨 — 스킵")
|
|
return []
|
|
setattr(self, executed_key, True)
|
|
|
|
try:
|
|
from app.services.korea_investment_api import korea_investment_api
|
|
from app.services.db_worker import enqueue_trade_history
|
|
|
|
# 현재 보유 종목 조회
|
|
balance = await korea_investment_api.get_balance()
|
|
holdings = balance.get("output1", [])
|
|
if not holdings:
|
|
logger.info(f"[MDD] {action} — 보유 종목 없음, 축소 불필요")
|
|
return []
|
|
|
|
# 레버리지/인버스 ETF 우선 정렬 (FORCE_REDUCE 시)
|
|
LEVERAGE_KEYWORDS = {"레버리지", "2X", "3X", "인버스", "TIGER 200선물", "KODEX 인버스", "곱버스"}
|
|
if prioritize_leverage:
|
|
def _is_leverage(h):
|
|
name = h.get("prdt_name", "")
|
|
return any(kw in name for kw in LEVERAGE_KEYWORDS)
|
|
holdings = sorted(holdings, key=lambda h: (not _is_leverage(h),))
|
|
|
|
results = []
|
|
skipped = 0
|
|
for h in holdings:
|
|
stock_code = h.get("pdno", "")
|
|
stock_name = h.get("prdt_name", stock_code)
|
|
quantity = int(h.get("hldg_qty", 0) or 0)
|
|
current_price = float(h.get("prpr", 0) or 0)
|
|
|
|
if quantity <= 0 or current_price <= 0:
|
|
skipped += 1
|
|
continue
|
|
|
|
sell_qty = max(int(quantity * reduce_pct), 1)
|
|
sell_qty = min(sell_qty, quantity)
|
|
|
|
try:
|
|
from app.services.trade_executor import safe_place_order
|
|
order_resp = await safe_place_order(
|
|
stock_code=stock_code,
|
|
order_type="SELL",
|
|
quantity=sell_qty,
|
|
price=None, # 시장가
|
|
)
|
|
if order_resp and order_resp.get("rt_cd") == "0":
|
|
order_id = order_resp.get("output", {}).get("ODNO", "")
|
|
avg_price = float(h.get("pchs_avg_pric", current_price))
|
|
profit_loss = (current_price - avg_price) * sell_qty
|
|
profit_loss_rate = ((current_price - avg_price) / avg_price * 100) if avg_price > 0 else 0.0
|
|
|
|
# MDD 강제매도는 시스템적(systemic) 리스크 축소이며,
|
|
# 개별 종목의 전략 실패가 아니므로 loss_tracker에 기록하지 않음.
|
|
# 기록 시 양호한 종목이 MDD 축소로 인해 false ban될 수 있음.
|
|
|
|
await enqueue_trade_history({
|
|
"stock_code": stock_code,
|
|
"stock_name": stock_name,
|
|
"order_type": "SELL",
|
|
"order_price": current_price,
|
|
"order_quantity": sell_qty,
|
|
"executed_price": current_price,
|
|
"executed_quantity": sell_qty,
|
|
"total_amount": current_price * sell_qty,
|
|
"status": "COMPLETED",
|
|
"order_id": order_id,
|
|
"exit_reason": f"MDD {action} ({reduce_pct*100:.0f}% 축소)",
|
|
"net_profit_loss": profit_loss,
|
|
"strategy_type": "mdd_reduction",
|
|
})
|
|
|
|
# [FIX] 전량 매도 시 ActiveTrade 닫기
|
|
remaining_qty = quantity - sell_qty
|
|
if remaining_qty <= 0:
|
|
from app.services.db_worker import enqueue_close_active_trade
|
|
await enqueue_close_active_trade(stock_code)
|
|
else:
|
|
# 부분 매도: remaining_pct 갱신
|
|
# 기존 ActiveTrade의 remaining_pct에 매도 비율을 곱해야 함
|
|
# (예: 기존 0.5 * 남은비율 0.7 = 0.35)
|
|
# db_worker가 직접 setattr하므로, reduce_ratio를 전달하여
|
|
# 워커가 기존 값에 곱하도록 처리
|
|
from app.services.db_worker import enqueue_active_trade_upsert
|
|
sell_ratio = remaining_qty / quantity # 남은 비율 (0.7 or 0.5)
|
|
await enqueue_active_trade_upsert({
|
|
"stock_code": stock_code,
|
|
"remaining_pct_multiply": sell_ratio,
|
|
})
|
|
|
|
results.append({
|
|
"stock_code": stock_code,
|
|
"stock_name": stock_name,
|
|
"sell_quantity": sell_qty,
|
|
"reason": action,
|
|
})
|
|
logger.warning(
|
|
f"[MDD] {action} 매도: {stock_code} {stock_name} "
|
|
f"{sell_qty}주 (총 {quantity}주 중 {reduce_pct*100:.0f}%)"
|
|
)
|
|
|
|
await asyncio.sleep(0.3) # KIS API 레이트 리밋
|
|
|
|
except Exception as e:
|
|
logger.error(f"[MDD] 축소 매도 실패 ({stock_code}): {e}")
|
|
|
|
failed_count = len(holdings) - len(results) - skipped
|
|
if failed_count > 0 and executed_key is not None:
|
|
# 매도 실패 종목이 있으면 플래그 리셋 → 다음 주기에 재시도
|
|
async with self._lock:
|
|
setattr(self, executed_key, False)
|
|
logger.warning(
|
|
f"[MDD] {action} 부분 완료: {len(results)}종목 성공, "
|
|
f"{failed_count}종목 실패 → 다음 주기 재시도 예정"
|
|
)
|
|
else:
|
|
logger.warning(f"[MDD] {action} 완료: {len(results)}종목 축소 매도")
|
|
return results
|
|
|
|
except Exception as e:
|
|
logger.error(f"[MDD] execute_reduction 실패: {e}", exc_info=True)
|
|
# 전체 실패 시 플래그 리셋 → 재시도 가능
|
|
if executed_key is not None:
|
|
async with self._lock:
|
|
setattr(self, executed_key, False)
|
|
return []
|
|
|
|
async def execute_reduction_v2(self, mdd_action: dict, kis, user_id: int) -> list[dict]:
|
|
"""[v2] 사용자 스코프 포지션 축소 매도 실행.
|
|
|
|
execute_reduction()과 동일 로직이나 전역 korea_investment_api 대신 주입된
|
|
kis(사용자 인스턴스)를 사용하고, trade_history/active_trade 큐 payload에
|
|
user_id를 포함해 db_worker가 해당 사용자 레코드만 갱신하도록 한다.
|
|
기존 execute_reduction()은 완전히 불변.
|
|
"""
|
|
action = mdd_action.get("action", "NONE")
|
|
if action == "NONE":
|
|
return []
|
|
|
|
reduce_pct = mdd_action.get("reduce_pct", 0.0)
|
|
prioritize_leverage = mdd_action.get("prioritize_leverage", False)
|
|
|
|
if reduce_pct <= 0:
|
|
return []
|
|
|
|
executed_key: Optional[str] = None
|
|
|
|
async with self._lock:
|
|
if self.reduction_applied is None:
|
|
logger.debug(f"[MDD v2] user={user_id} reduction_applied=None — 축소 불필요, 스킵")
|
|
return []
|
|
executed_key = f"_reduction_executed_{self.reduction_applied}"
|
|
if getattr(self, executed_key, False):
|
|
logger.debug(f"[MDD v2] user={user_id} {action} 축소 이미 실행됨 — 스킵")
|
|
return []
|
|
setattr(self, executed_key, True)
|
|
|
|
try:
|
|
from app.services.db_worker import (
|
|
enqueue_trade_history,
|
|
enqueue_close_active_trade_v2,
|
|
enqueue_active_trade_upsert,
|
|
)
|
|
|
|
balance = await kis.get_balance()
|
|
holdings = balance.get("output1", [])
|
|
if not holdings:
|
|
logger.info(f"[MDD v2] user={user_id} {action} — 보유 종목 없음, 축소 불필요")
|
|
return []
|
|
|
|
LEVERAGE_KEYWORDS = {"레버리지", "2X", "3X", "인버스", "TIGER 200선물", "KODEX 인버스", "곱버스"}
|
|
if prioritize_leverage:
|
|
def _is_leverage(h):
|
|
name = h.get("prdt_name", "")
|
|
return any(kw in name for kw in LEVERAGE_KEYWORDS)
|
|
holdings = sorted(holdings, key=lambda h: (not _is_leverage(h),))
|
|
|
|
results = []
|
|
skipped = 0
|
|
for h in holdings:
|
|
stock_code = h.get("pdno", "")
|
|
stock_name = h.get("prdt_name", stock_code)
|
|
quantity = int(h.get("hldg_qty", 0) or 0)
|
|
current_price = float(h.get("prpr", 0) or 0)
|
|
|
|
if quantity <= 0 or current_price <= 0:
|
|
skipped += 1
|
|
continue
|
|
|
|
sell_qty = max(int(quantity * reduce_pct), 1)
|
|
sell_qty = min(sell_qty, quantity)
|
|
|
|
try:
|
|
# UserContext 조립 완료(트레이딩팀) — 종목별 동시 주문 락
|
|
# (_order_semaphores/Redis 분산락)을 거치도록 safe_place_order_v2 사용.
|
|
# SL/사이징 로직은 그대로, 주문 호출 경로만 교체.
|
|
from app.services.tenant.context import UserContext
|
|
from app.services.trade_executor import safe_place_order_v2
|
|
|
|
from app.core.config import settings as _settings
|
|
|
|
reduction_ctx = UserContext(
|
|
user_id=user_id,
|
|
username="",
|
|
mode=getattr(kis, "mode", _settings.KIS_MODE),
|
|
kis=kis,
|
|
mdd=self,
|
|
loss_tracker=None,
|
|
)
|
|
order_resp = await safe_place_order_v2(
|
|
reduction_ctx,
|
|
stock_code=stock_code,
|
|
order_type="SELL",
|
|
quantity=sell_qty,
|
|
price=None, # 시장가
|
|
)
|
|
if order_resp and order_resp.get("rt_cd") == "0":
|
|
order_id = order_resp.get("output", {}).get("ODNO", "")
|
|
avg_price = float(h.get("pchs_avg_pric", current_price))
|
|
profit_loss = (current_price - avg_price) * sell_qty
|
|
profit_loss_rate = ((current_price - avg_price) / avg_price * 100) if avg_price > 0 else 0.0
|
|
|
|
# MDD 강제매도는 시스템적 리스크 축소 — loss_tracker 미기록 (execute_reduction과 동일 정책)
|
|
await enqueue_trade_history({
|
|
"user_id": user_id,
|
|
"stock_code": stock_code,
|
|
"stock_name": stock_name,
|
|
"order_type": "SELL",
|
|
"order_price": current_price,
|
|
"order_quantity": sell_qty,
|
|
"executed_price": current_price,
|
|
"executed_quantity": sell_qty,
|
|
"total_amount": current_price * sell_qty,
|
|
"status": "COMPLETED",
|
|
"order_id": order_id,
|
|
"exit_reason": f"MDD {action} ({reduce_pct*100:.0f}% 축소)",
|
|
"net_profit_loss": profit_loss,
|
|
"strategy_type": "mdd_reduction",
|
|
})
|
|
|
|
remaining_qty = quantity - sell_qty
|
|
if remaining_qty <= 0:
|
|
await enqueue_close_active_trade_v2(user_id, stock_code, strategy_name=None)
|
|
else:
|
|
sell_ratio = remaining_qty / quantity
|
|
await enqueue_active_trade_upsert({
|
|
"user_id": user_id,
|
|
"stock_code": stock_code,
|
|
"remaining_pct_multiply": sell_ratio,
|
|
})
|
|
|
|
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()
|