## 배경 "전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라 전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다. ## 파라미터 소스 단일화 - 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인 config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%). - DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신 (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등) ## 백테스트를 실전 로직에 정합 - calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트 `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향 - 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치 - us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명) - backend/backtest_results/ 25건은 폐기 대상 (§8.4) ## 리스크 가드 복원 - 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로 - 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다) - 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동 - MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용) - 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정 ## 죽은 코드 제거 (2,695줄) - short_term_strategy, kosdaq_scanner, app/strategy/ 패키지 - app/trading/ + korea_investment_api_adapter (real_market_api 정본은 korea_investment_api.py:1482, adapter 는 import 0건) ## 지표·사이징 단일화 (수치 무변경 증명) - indicators_core.py: 3,274건 비교 최대 오차 0.000e+00 - sizing_core.py: 42,105건 비교 불일치 0 - 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교) ## 테스트 안전 (실 KIS 호출 차단) - 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이 close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로 바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤 참조 누수로 실제 잔고 조회를 타 141초를 소비했다. - backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단. 예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게), 클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제 - 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed) ## 신규 (둘 다 플래그 OFF, 백테스트 미검증) - range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선 - check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%) ## 문서 - CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약, §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거, §8.5 미해결 19항목 - 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
899 lines
45 KiB
Python
899 lines
45 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) 음수 금지.
|
||
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)
|
||
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}")
|