Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions core/order_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,32 @@ def _drawdown_pre_order_check(self, action: str = "BUY") -> dict:

mdd_pct = abs(float(summary.get("mdd") or 0))
mdd_limit_pct = max_mdd * 100

# 히스테리시스 게이트(상시 프로세스용 2차 방어): 상태 갱신(peak·halt 진입/해제)을
# 정적 한도 체크보다 먼저 수행해야 한도 돌파 시 halt가 실제로 진입한다.
# halt된 뒤에는 MDD가 한도의 절반 아래로 회복할 때까지 신규 BUY를 계속 차단 —
# 한도 경계 바로 아래로 깜빡 회복했을 때 재진입→재돌파 반복을 막는다.
# RiskManager 인스턴스 내 상태라 1회성 CLI에서는 peak이 현재값에서 시작해
# 정적 체크와 동일하게 동작하고(추가 차단 없음), 상시 스케줄러에서만 누적된다.
# 정적 체크보다 느슨해질 수는 없다(엄격히 추가 차단만).
if max_mdd > 0:
halt_state = self.risk_manager.check_mdd(total_value)
if halt_state.get("is_halted"):
reason = (
f"MDD halt 유지 중(히스테리시스): 현재 MDD {halt_state.get('mdd', 0):.2f}% — "
f"한도의 절반({mdd_limit_pct / 2:.2f}%) 아래로 회복해야 신규 매수 재개"
)
logger.warning("신규 매수 차단: {}", reason)
return {
"allowed": False,
"reason": reason,
"drawdown_guard_blocked": True,
"drawdown_guard_type": "mdd_hysteresis",
"mdd": halt_state.get("mdd", 0),
"mdd_limit": round(mdd_limit_pct, 2),
"mode": self.mode,
}

if max_mdd > 0 and mdd_pct >= mdd_limit_pct:
reason = f"MDD 한도 도달: {mdd_pct:.2f}% >= {mdd_limit_pct:.2f}%"
logger.warning("신규 매수 차단: {}", reason)
Expand Down
43 changes: 43 additions & 0 deletions tests/test_order_executor_paper.py
Original file line number Diff line number Diff line change
Expand Up @@ -1613,3 +1613,46 @@ def test_daily_loss_baseline_skips_when_only_today_snapshot(fresh_db):
session.commit()
session.close()
assert executor._daily_loss_baseline_value() == 10_000_000


def test_mdd_hysteresis_keeps_blocking_until_half_recovery(fresh_db, monkeypatch):
"""MDD 한도 돌파로 halt된 뒤에는 한도 바로 아래로 깜빡 회복해도 신규 BUY를
계속 차단하고(히스테리시스), 한도의 절반 아래로 회복해야 재개한다.

상시 스케줄러(장기 프로세스)용 2차 방어 — 1회성 CLI에서는 peak이 현재값에서
시작하므로 정적 한도 체크와 동일하게 동작한다(추가 차단 없음).
"""
from core.order_executor import OrderExecutor

values = {"total_value": 10_000_000, "mdd": 0.0}

class FakePortfolioManager:
def __init__(self, config=None, account_key=""):
pass

def get_portfolio_summary(self):
return dict(values)

executor = OrderExecutor(account_key="mdd_hyst_test")
executor.config.risk_params["drawdown"]["max_portfolio_mdd"] = 0.15
monkeypatch.setattr("core.portfolio_manager.PortfolioManager", FakePortfolioManager)

def check(total, mdd_pct):
values["total_value"] = total
values["mdd"] = mdd_pct
return executor._drawdown_pre_order_check("BUY")

# 1) peak 형성(10M, MDD 0) → 허용
assert check(10_000_000, 0.0)["allowed"] is True
# 2) -16% 하락 → 한도 돌파, halt 진입(in-memory peak 10M 기준 16% ≥ 15%)
r = check(8_400_000, 16.0)
assert r["allowed"] is False
assert r["drawdown_guard_type"] in ("mdd", "mdd_hysteresis")
# 3) 한도 바로 아래(-14%)로 깜빡 회복 → 정적 체크는 통과지만 히스테리시스가 차단 유지
r = check(8_600_000, 14.0)
assert r["allowed"] is False
assert r["drawdown_guard_type"] == "mdd_hysteresis"
assert "halt 유지" in r["reason"]
# 4) 한도의 절반(7.5%) 아래(-7%)로 회복 → 재개
r = check(9_300_000, 7.0)
assert r["allowed"] is True
Loading