fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
@@ -22,6 +22,94 @@ logger = logging.getLogger(__name__)
|
||||
_MDD_REDIS_KEY = "mdd:state"
|
||||
|
||||
|
||||
_PAGE_FULL_SENTINEL = "<balance-page-full>"
|
||||
# MDD 갱신 정지 판정 기본값(초). swing 사이클 상한(진입 300 + sleep 60 + 청산 180 + 잔고 60 = 600)보다
|
||||
# 커야 정상 사이클이 stale 로 오판되지 않는다. config.MDD_UPDATE_STALE_SEC 가 있으면 그것이 우선.
|
||||
_MDD_UPDATE_STALE_SEC_DEFAULT = 900
|
||||
|
||||
|
||||
def _stale_sec_v2() -> int:
|
||||
try:
|
||||
return int(getattr(settings, "MDD_UPDATE_STALE_SEC", _MDD_UPDATE_STALE_SEC_DEFAULT) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return _MDD_UPDATE_STALE_SEC_DEFAULT
|
||||
|
||||
|
||||
def _balance_page_full_v2(holdings) -> bool:
|
||||
"""get_balance 첫 페이지가 가득 찼는지(연속조회 미구현 — 응답 밖 종목 누락 가능)."""
|
||||
rows = int(getattr(settings, "SWING_BALANCE_FULL_PAGE_ROWS", 20) or 0)
|
||||
return rows > 0 and len(holdings) >= rows
|
||||
|
||||
|
||||
def _reduction_net_pnl_v2(avg_price: float, current_price: float, sell_qty: int) -> dict:
|
||||
"""[R4 2026-10-09] MDD 축소 매도의 비용 차감 순손익 (CLAUDE.md §5.1 순손익 기준).
|
||||
|
||||
이전엔 net_profit_loss 에 비용 전 gross 추정치를 넣고 commission/profit_loss_rate 를 비웠다.
|
||||
시장 구분 정보가 없어 거래세는 DAY_TRADE_DEFAULT_TAX_MARKET 세율(DT 와 동일 규약)을 쓴다.
|
||||
체결가(executed_price)는 NULL 이라 current_price 기준 추정치다 — 확정되면 fill_sweep 이 보정.
|
||||
반환: net_profit_loss / commission(왕복 비용) / profit_loss_rate(비용 차감 %).
|
||||
"""
|
||||
from app.services.trade_cost_core import net_realized_pnl
|
||||
if str(getattr(settings, "DAY_TRADE_DEFAULT_TAX_MARKET", "KOSPI")).upper() in ("KOSDAQ", "KQ"):
|
||||
tax_rate = float(settings.KR_SELL_TAX_RATE_KOSDAQ)
|
||||
else:
|
||||
tax_rate = float(settings.KR_SELL_TAX_RATE_KOSPI)
|
||||
pnl = net_realized_pnl(
|
||||
float(avg_price), float(current_price), int(sell_qty),
|
||||
float(settings.KR_COMMISSION_RATE), tax_rate,
|
||||
)
|
||||
return {
|
||||
"net_profit_loss": pnl["net_profit_loss"],
|
||||
"commission": pnl["total_cost"],
|
||||
"profit_loss_rate": pnl["net_profit_loss_rate"],
|
||||
}
|
||||
|
||||
|
||||
_alert_tasks: set = set() # fire-and-forget 태스크 참조 보관(GC 방지)
|
||||
|
||||
|
||||
def _alert_retries_v2() -> int:
|
||||
try:
|
||||
return max(0, int(getattr(settings, "MDD_ALERT_SEND_RETRIES", 3)))
|
||||
except (TypeError, ValueError):
|
||||
return 3
|
||||
|
||||
|
||||
def _alert_backoff_v2() -> float:
|
||||
try:
|
||||
return max(0.0, float(getattr(settings, "MDD_ALERT_RETRY_BACKOFF_SEC", 5.0)))
|
||||
except (TypeError, ValueError):
|
||||
return 5.0
|
||||
|
||||
|
||||
async def _send_mdd_alert_v2(action: str, drawdown: float, reduce_pct: float) -> bool:
|
||||
"""MDD 단계 전이 텔레그램 — 예외/False(전송 실패) 시 재시도, 끝내 실패하면 error 로그.
|
||||
|
||||
notify_mdd_warning 이 bool 을 주면 그 값을, None(반환값 미사용 구현)이면 예외 없음=성공으로 본다.
|
||||
"""
|
||||
retries = _alert_retries_v2()
|
||||
backoff = _alert_backoff_v2()
|
||||
last_err = "send returned False"
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
res = await telegram_notifier.notify_mdd_warning(
|
||||
action=action, drawdown=drawdown, reduce_pct=reduce_pct,
|
||||
)
|
||||
if res is not False:
|
||||
return True
|
||||
last_err = "send returned False"
|
||||
except Exception as e:
|
||||
last_err = f"{type(e).__name__}: {e}"
|
||||
if attempt < retries:
|
||||
await asyncio.sleep(backoff * (attempt + 1))
|
||||
logger.error(
|
||||
"[MDD] %s 텔레그램 알림 최종 실패(%d회 시도): %s — 운영자 수동 확인 필요",
|
||||
action, retries + 1, last_err,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class MDDManager:
|
||||
"""포트폴리오 MDD 관리자 (싱글톤으로 사용)"""
|
||||
|
||||
@@ -60,6 +148,30 @@ class MDDManager:
|
||||
# 레벨 축소가 **완료**됐을 때만 True 로 둔다 — 루프 시작 시 True 로 두면 루프 중
|
||||
# save_to_redis 가 그 값을 영속해, 중간 재기동 시 남은 종목 축소가 영구 생략됐다.
|
||||
self._reduction_running: set = set()
|
||||
# [R1 2026-10-09] 마지막 관측 잔고 / reset 후 peak 재설정 대기 / 갱신 신선도(staleness)
|
||||
self._last_balance: float = 0.0
|
||||
self._rebase_peak_pending: bool = False
|
||||
self._update_last_ok_at = None # datetime(KST). None 이면 생성 시각을 기준으로 삼는다
|
||||
self._created_at = self._now_kst_v2()
|
||||
|
||||
@staticmethod
|
||||
def _spawn_mdd_alert_v2(action: str, drawdown: float, reduce_pct: float) -> None:
|
||||
"""알림 태스크를 만들고 참조를 보관한다(GC 소실 방지). 실행 중 루프가 없으면 error."""
|
||||
try:
|
||||
task = asyncio.get_running_loop().create_task(
|
||||
_send_mdd_alert_v2(action, drawdown, reduce_pct)
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.error("[MDD] %s 텔레그램 알림 예약 실패(이벤트 루프 없음): %s", action, e)
|
||||
return
|
||||
_alert_tasks.add(task)
|
||||
task.add_done_callback(_alert_tasks.discard)
|
||||
|
||||
@staticmethod
|
||||
def _now_kst_v2():
|
||||
from datetime import datetime
|
||||
from app.core.market_schedule import KST
|
||||
return datetime.now(KST)
|
||||
|
||||
def _state_snapshot_v2(self) -> dict:
|
||||
"""[L-6 5회차] 영속 상태 스냅샷 (동기, I/O 없음 — Lock 안에서 호출 가능)."""
|
||||
@@ -123,12 +235,18 @@ class MDDManager:
|
||||
# (Redis 타임아웃 동안 can_open_new_position()·축소 루프가 락을 기다리던 문제)
|
||||
async with self._lock:
|
||||
result = self._update_locked_v2(current_balance)
|
||||
if current_balance > 0:
|
||||
self._last_balance = float(current_balance)
|
||||
snap = self._state_snapshot_v2()
|
||||
await self._write_state_v2(_MDD_REDIS_KEY, snap)
|
||||
return result
|
||||
|
||||
def _update_locked_v2(self, current_balance: float) -> Dict:
|
||||
"""update() 의 상태 전이 본체 (Lock 보유 중 호출, I/O 없음)."""
|
||||
if self._rebase_peak_pending and current_balance > 0:
|
||||
# [R1] reset() 시점에 잔고를 몰랐던 경우 — 첫 갱신 잔고를 새 기준 peak 로 삼는다
|
||||
self.peak_balance = float(current_balance)
|
||||
self._rebase_peak_pending = False
|
||||
if current_balance > self.peak_balance:
|
||||
self.peak_balance = current_balance
|
||||
self.is_paused = False
|
||||
@@ -150,13 +268,7 @@ class MDDManager:
|
||||
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)
|
||||
self._spawn_mdd_alert_v2("FORCE_REDUCE", drawdown_pct, 0.50)
|
||||
return {
|
||||
"action": "FORCE_REDUCE",
|
||||
"drawdown": drawdown_pct,
|
||||
@@ -171,13 +283,7 @@ class MDDManager:
|
||||
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)
|
||||
self._spawn_mdd_alert_v2("REDUCE_AND_PAUSE", drawdown_pct, 0.30)
|
||||
return {
|
||||
"action": "REDUCE_AND_PAUSE",
|
||||
"drawdown": drawdown_pct,
|
||||
@@ -264,6 +370,27 @@ class MDDManager:
|
||||
self._update_empty_account = bool(empty_account)
|
||||
self._blind_block_active = False
|
||||
self._blind_block_hits = 0
|
||||
self._update_last_ok_at = self._now_kst_v2()
|
||||
|
||||
def _is_update_stale_v2(self) -> bool:
|
||||
"""[R1] 장중 마지막 갱신 성공 후 MDD_UPDATE_STALE_SEC 초과 여부 (Lock 보유 중 호출, I/O 없음).
|
||||
|
||||
예외 없이 swing 사이클이 지연·정지하면 실패 카운터가 늘지 않아 낡은 drawdown 으로
|
||||
진입이 허용되던 구멍을 막는다. 기준 시각은 max(마지막 성공, 오늘 장 시작 09:00) —
|
||||
야간/휴장 후 첫 사이클 전에 DT 진입이 막히지 않게 한다. 장외에는 판정하지 않는다.
|
||||
"""
|
||||
stale_sec = _stale_sec_v2()
|
||||
if stale_sec <= 0:
|
||||
return False
|
||||
from app.core.market_schedule import is_market_open
|
||||
if not is_market_open():
|
||||
return False
|
||||
now = self._now_kst_v2()
|
||||
ref = self._update_last_ok_at or self._created_at
|
||||
open_dt = now.replace(hour=9, minute=0, second=0, microsecond=0)
|
||||
if open_dt > ref:
|
||||
ref = open_dt
|
||||
return (now - ref).total_seconds() > stale_sec
|
||||
|
||||
def _is_update_blind(self) -> bool:
|
||||
"""MDD 갱신이 '눈이 먼' 상태인지 판정 (Lock 보유 중 호출, DB/IO 없음).
|
||||
@@ -274,10 +401,13 @@ class MDDManager:
|
||||
"""
|
||||
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
|
||||
# 빈 계좌는 '실패 카운터' 조건만 면제한다. 정체(stale) 판정은 그대로 수행해
|
||||
# 빈 계좌 응답 이후 swing 사이클이 멈춘 경우에도 fail-safe 가 작동한다.
|
||||
if not self._update_empty_account:
|
||||
threshold = max(1, int(settings.MDD_BLIND_FAILURE_THRESHOLD))
|
||||
if self._update_failures >= threshold:
|
||||
return True
|
||||
return self._is_update_stale_v2()
|
||||
|
||||
def get_update_health(self) -> dict:
|
||||
"""MDD 갱신 헬스 스냅샷 (동기·읽기 전용). 차단 여부 관측용."""
|
||||
@@ -307,7 +437,8 @@ class MDDManager:
|
||||
logger.error(
|
||||
f"[MDD] 🚨 신규 진입 차단(fail-safe): MDD 갱신 연속 실패 "
|
||||
f"{self._update_failures}회 (임계 {settings.MDD_BLIND_FAILURE_THRESHOLD}) — "
|
||||
f"peak/drawdown 미갱신으로 -10%/-20% 게이트가 작동하지 않음. "
|
||||
f"peak/drawdown 미갱신으로 -10%/-20% 게이트가 작동하지 않음(또는 장중 갱신 정지 "
|
||||
f"{_stale_sec_v2()}초 초과). "
|
||||
f"마지막 오류: {self._update_last_error} "
|
||||
f"(누적 차단 {self._blind_block_hits}회, 청산은 정상 동작)"
|
||||
)
|
||||
@@ -322,14 +453,17 @@ class MDDManager:
|
||||
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 관련
|
||||
상태는 기존 그대로 건드리지 않는다.
|
||||
[R1 2026-10-09] 플래그만 지우고 peak 를 유지하면 낙폭이 남아 있는 한 다음
|
||||
update() 가 REDUCE_AND_PAUSE 를 즉시 재발동해 전 종목 30% 를 또 매도했다.
|
||||
따라서 수동 리셋은 **peak 를 마지막 관측 잔고로 재설정**(re-base)한다 — 이후 다시
|
||||
-10% 가 되어야 재발동한다. 마지막 잔고를 모르면(재기동 직후) 다음 갱신 잔고로
|
||||
재설정한다. fail-safe 차단 카운터/플래그도 함께 초기화한다.
|
||||
fail-safe 만 풀려면 reset_fail_safe_v2() 를 쓴다(MDD 상태 불변).
|
||||
"""
|
||||
await self.reset_v2()
|
||||
|
||||
async def reset_v2(self, rebase_peak: bool = True) -> dict:
|
||||
"""reset() 본체. rebase_peak=False 면 구 동작(peak 유지 — 축소 재발동 가능)."""
|
||||
async with self._lock:
|
||||
self.is_paused = False
|
||||
self.is_short_term_strategy_paused = False
|
||||
@@ -337,17 +471,43 @@ class MDDManager:
|
||||
self._reduction_executed_10pct = False
|
||||
self._reduction_executed_20pct = False
|
||||
self._clear_reduction_progress()
|
||||
old_peak = self.peak_balance
|
||||
if rebase_peak:
|
||||
if self._last_balance > 0:
|
||||
self.peak_balance = self._last_balance
|
||||
self._rebase_peak_pending = False
|
||||
else:
|
||||
self._rebase_peak_pending = True
|
||||
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 관리자 리셋")
|
||||
self._update_last_ok_at = self._now_kst_v2()
|
||||
logger.warning(
|
||||
f"MDD 관리자 리셋 — peak {old_peak:,.0f} -> {self.peak_balance:,.0f} "
|
||||
f"(rebase={rebase_peak}, pending={self._rebase_peak_pending})"
|
||||
)
|
||||
if was_blind:
|
||||
logger.info("[MDD] fail-safe 차단 해제 — 갱신 실패 카운터/차단 플래그 초기화됨")
|
||||
result = {
|
||||
"old_peak": old_peak, "peak_balance": self.peak_balance,
|
||||
"rebase_peak": bool(rebase_peak), "rebase_pending": self._rebase_peak_pending,
|
||||
}
|
||||
snap = self._state_snapshot_v2()
|
||||
await self._write_state_v2(_MDD_REDIS_KEY, snap) # [L-6] 락 밖에서
|
||||
return result
|
||||
|
||||
async def reset_fail_safe_v2(self) -> None:
|
||||
"""fail-safe(갱신 실패/정지) 차단만 해제. peak/pause/축소 상태는 건드리지 않는다."""
|
||||
async with self._lock:
|
||||
self._update_failures = 0
|
||||
self._update_last_error = None
|
||||
self._update_empty_account = False
|
||||
self._blind_block_active = False
|
||||
self._blind_block_hits = 0
|
||||
self._update_last_ok_at = self._now_kst_v2()
|
||||
|
||||
# ── [C-1 2026-10-02] 강제축소 진행 상태 헬퍼 (기존 시그니처 불변, 신규 메서드만) ──
|
||||
|
||||
@@ -712,6 +872,14 @@ class MDDManager:
|
||||
results = []
|
||||
skipped = 0
|
||||
pending: list[str] = [] # 다음 주기 재시도 대상 (실패·보류)
|
||||
if _balance_page_full_v2(holdings):
|
||||
# [R2 LOW] 첫 페이지만 조회 — 응답 밖 종목은 축소 대상에서 빠진다. 보이는 종목은
|
||||
# 축소하되 executed 를 세우지 않아 완료로 오인되지 않게 한다.
|
||||
logger.error(
|
||||
f"[MDD] {action} 잔고 응답이 페이지 한도({len(holdings)}행)에 도달 — 응답 밖 보유 종목은 "
|
||||
f"축소되지 않았을 수 있음(수동 확인 필요, get_balance 연속조회 미구현)"
|
||||
)
|
||||
pending.append(_PAGE_FULL_SENTINEL)
|
||||
for h in holdings:
|
||||
# [M-1 4회차] 루프 도중 reset()/peak 갱신이면 남은 종목 매도를 중단한다.
|
||||
if self._reduction_epoch != epoch:
|
||||
@@ -768,8 +936,7 @@ class MDDManager:
|
||||
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
|
||||
profit_loss_rate = ((current_price - avg_price) / avg_price * 100) if avg_price > 0 else 0.0
|
||||
_pnl = _reduction_net_pnl_v2(avg_price, current_price, sell_qty)
|
||||
|
||||
# MDD 강제매도는 시스템적(systemic) 리스크 축소이며,
|
||||
# 개별 종목의 전략 실패가 아니므로 loss_tracker에 기록하지 않음.
|
||||
@@ -781,13 +948,17 @@ class MDDManager:
|
||||
"order_type": "SELL",
|
||||
"order_price": current_price,
|
||||
"order_quantity": sell_qty,
|
||||
"executed_price": current_price,
|
||||
# [2026-10-08] 시장가 — 체결가는 swing/fill_sweep 체결조회가 채운다(그 전엔 NULL).
|
||||
# total_amount·net_profit_loss 는 현재가 기준 추정치.
|
||||
"executed_price": None,
|
||||
"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,
|
||||
"net_profit_loss": _pnl["net_profit_loss"],
|
||||
"commission": _pnl["commission"],
|
||||
"profit_loss_rate": _pnl["profit_loss_rate"],
|
||||
"strategy_type": "mdd_reduction",
|
||||
})
|
||||
|
||||
@@ -942,6 +1113,12 @@ class MDDManager:
|
||||
results = []
|
||||
skipped = 0
|
||||
pending: list[str] = [] # 다음 주기 재시도 대상 (실패·보류)
|
||||
if _balance_page_full_v2(holdings):
|
||||
logger.error(
|
||||
f"[MDD v2] user={user_id} {action} 잔고 응답이 페이지 한도({len(holdings)}행)에 도달 — "
|
||||
f"응답 밖 보유 종목은 축소되지 않았을 수 있음(수동 확인 필요)"
|
||||
)
|
||||
pending.append(_PAGE_FULL_SENTINEL)
|
||||
for h in holdings:
|
||||
if self._reduction_epoch != epoch:
|
||||
logger.warning(f"[MDD v2] user={user_id} {action} 진행 중 MDD 상태 리셋 감지 — 남은 종목 축소 중단")
|
||||
@@ -1013,8 +1190,7 @@ class MDDManager:
|
||||
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
|
||||
profit_loss_rate = ((current_price - avg_price) / avg_price * 100) if avg_price > 0 else 0.0
|
||||
_pnl = _reduction_net_pnl_v2(avg_price, current_price, sell_qty)
|
||||
|
||||
# MDD 강제매도는 시스템적 리스크 축소 — loss_tracker 미기록 (execute_reduction과 동일 정책)
|
||||
await enqueue_trade_history({
|
||||
@@ -1024,13 +1200,17 @@ class MDDManager:
|
||||
"order_type": "SELL",
|
||||
"order_price": current_price,
|
||||
"order_quantity": sell_qty,
|
||||
"executed_price": current_price,
|
||||
# [2026-10-08] 시장가 — 체결가는 swing/fill_sweep 체결조회가 채운다(그 전엔 NULL).
|
||||
# total_amount·net_profit_loss 는 현재가 기준 추정치.
|
||||
"executed_price": None,
|
||||
"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,
|
||||
"net_profit_loss": _pnl["net_profit_loss"],
|
||||
"commission": _pnl["commission"],
|
||||
"profit_loss_rate": _pnl["profit_loss_rate"],
|
||||
"strategy_type": "mdd_reduction",
|
||||
})
|
||||
|
||||
@@ -1230,7 +1410,10 @@ def _dt_position_qty(stock_code: str) -> int:
|
||||
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:
|
||||
except Exception as e:
|
||||
# 조회 실패를 0 으로 삼키되 반드시 기록한다. 호출부는 0 이어도 전략명 없는
|
||||
# close/upsert 를 하지 않고 비-DT 필터를 쓰므로 DT 행 오폐기는 일어나지 않는다.
|
||||
logger.warning(f"[MDD] DT 인메모리 수량 조회 실패 ({stock_code}) — 0 으로 간주: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -1262,19 +1445,11 @@ async def _sync_active_trade_after_reduction(
|
||||
"""
|
||||
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 태스크 큐잉.
|
||||
|
||||
@@ -1286,24 +1461,11 @@ async def _sync_active_trade_after_reduction(
|
||||
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,
|
||||
# [하드닝 R3] remaining_pct 불변 (익절 단계 보존) — quantity 만 갱신
|
||||
"quantity": qty,
|
||||
"strategy_name_not": DT_STRATEGY_NAMES,
|
||||
}
|
||||
@@ -1320,10 +1482,12 @@ async def _sync_active_trade_after_reduction(
|
||||
f"[MDD] {stock_code}: DT {dt_qty}주 병존 — 비-DT/DT ActiveTrade 각각 종료"
|
||||
)
|
||||
else:
|
||||
await _close(None)
|
||||
# [R5] 전략명 없는 close 는 '최근 active 행'을 닫아 고아 DT 행을 오폐기한다.
|
||||
# MDD 축소 대상은 스윙 계열이므로 비-DT 필터(NULL 포함)로 한정한다.
|
||||
await _close_filtered(strategy_name_not=DT_STRATEGY_NAMES)
|
||||
return
|
||||
|
||||
# 부분 축소: 남은 비율을 기존 remaining_pct 에 곱한다 (예: 기존 0.5 × 0.7 = 0.35)
|
||||
# 부분 축소: quantity 만 갱신하고 remaining_pct(익절 단계)는 유지한다 [하드닝 R3]
|
||||
sell_ratio = (remaining_qty / quantity) if quantity > 0 else 1.0
|
||||
if dt_qty > 0:
|
||||
# 스윙 행의 잔량 = 전체 잔량 − DT 잔량 (DT 는 체결분만큼 인메모리에서 차감됨)
|
||||
@@ -1340,7 +1504,7 @@ async def _sync_active_trade_after_reduction(
|
||||
else:
|
||||
_swing_ratio = 1.0
|
||||
if _swing_left <= 0:
|
||||
# [M1-guard 2026-09-27] _close() 는 '관리 대상에서 제거'라는 과도한 부작용이
|
||||
# [M1-guard 2026-09-27] _close_filtered() 는 '관리 대상에서 제거'라는 과도한 부작용이
|
||||
# 있으므로 인메모리 DT 수량을 신뢰할 수 있을 때만 허용한다.
|
||||
# quantity 는 KIS 잔고(hldg_qty), dt_qty 는 인메모리 → dt_qty > quantity 면
|
||||
# 인메모리가 잔고보다 큼 = stale 확정(외부 경로로 DT 분이 이미 매도된 경우).
|
||||
@@ -1370,7 +1534,8 @@ async def _sync_active_trade_after_reduction(
|
||||
f"DT 잔량={_dt_left}, DT 행은 DT 청산 경로에 위임)"
|
||||
)
|
||||
else:
|
||||
await _upsert(None, sell_ratio, remaining_qty)
|
||||
# [R5] 전략명 없는 upsert 금지 — DT 행이 덮어써지고 스윙 행이 유령화된다.
|
||||
await _upsert_non_dt(sell_ratio, remaining_qty)
|
||||
|
||||
|
||||
async def _reflect_reduction(
|
||||
|
||||
Reference in New Issue
Block a user