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:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -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(