- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강 - trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성) - korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계) - swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소) - swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램 - daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거 - main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC) - 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경) - 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기 - CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기) - 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
244 lines
11 KiB
Python
244 lines
11 KiB
Python
"""하드닝 R2 — MDD 갱신 입력 가드 회귀 테스트 (swing/scheduler._mdd_update_guarded_v2).
|
|
|
|
결함: tot_evlu_amt 원값을 그대로 mdd_manager.update() 에 넣어
|
|
- 출금 10%+ 한 번으로 drawdown<=-10% → is_paused + 전 보유 30% 시장가 축소,
|
|
- KIS 가 일부 종목 평가를 0 으로 준 응답 한 번으로 동일 축소,
|
|
- 입금이 peak 를 부풀리거나 진행 중인 MDD 정지를 해제했다.
|
|
|
|
실제 MDDManager 상태 전이(_update_locked_v2)를 쓰고, 외부 I/O(Redis 저장·텔레그램)만
|
|
예외 발생형/무동작 목으로 막는다. 임계값은 settings 를 참조한다.
|
|
"""
|
|
import asyncio
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
class _NoIO(BaseException):
|
|
"""Redis/텔레그램 실호출이 새면 즉시 터지게 하는 예외."""
|
|
|
|
|
|
@pytest.fixture
|
|
def env(monkeypatch):
|
|
import app.services.swing.scheduler as sch
|
|
import app.services.telegram_notifier as tn
|
|
from app.services.mdd_manager import MDDManager
|
|
|
|
mgr = MDDManager()
|
|
|
|
async def _no_write(key, state): # Redis 쓰기 차단(무동작) — 상태 전이는 실제 로직
|
|
return None
|
|
|
|
monkeypatch.setattr(mgr, "_write_state_v2", _no_write)
|
|
monkeypatch.setattr(mgr, "save_to_redis", AsyncMock())
|
|
monkeypatch.setattr(sch, "mdd_manager", mgr)
|
|
monkeypatch.setattr(sch, "_mdd_gate_state", {"prev": None, "pending": 0, "suspect": 0, "last_flow": None})
|
|
monkeypatch.setattr(sch, "_alert_sent_at", {})
|
|
monkeypatch.setattr(tn.telegram_notifier, "notify_mdd_warning", AsyncMock())
|
|
monkeypatch.setattr(tn.telegram_notifier, "notify_system_warning", AsyncMock())
|
|
monkeypatch.setattr(sch, "_send_system_warning_checked_v2", AsyncMock(return_value=True))
|
|
return sch, mgr
|
|
|
|
|
|
def _resp(total, scts, holdings=None):
|
|
return {
|
|
"output1": holdings if holdings is not None else [],
|
|
"output2": [{"tot_evlu_amt": str(total), "scts_evlu_amt": str(scts)}],
|
|
}
|
|
|
|
|
|
async def _feed(sch, total, scts, holdings=None):
|
|
return await sch._mdd_update_guarded_v2(float(total), _resp(total, scts, holdings))
|
|
|
|
|
|
def _pause_ratio():
|
|
# [R3] 확인 대상 판정은 mdd_manager 실효 임계(_mdd_stage_thresholds_v2)와 같다
|
|
import app.services.swing.scheduler as sch
|
|
return 1 + sch._mdd_stage_thresholds_v2()[0] / 100.0
|
|
|
|
|
|
# ── 1. 출금은 낙폭이 아니다 ──────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_withdrawal_does_not_trigger_mdd_reduction(env):
|
|
sch, mgr = env
|
|
await _feed(sch, 10_000_000, 6_000_000) # peak 10M
|
|
assert mgr.peak_balance == 10_000_000
|
|
# 현금 4M 중 1.5M 출금 (총자산 -15%), 유가평가 불변
|
|
r = await _feed(sch, 8_500_000, 6_000_000)
|
|
assert r["status"] == "applied"
|
|
assert r["result"]["action"] == "NONE"
|
|
assert mgr.reduction_applied is None and not mgr.is_paused
|
|
assert mgr.peak_balance == pytest.approx(8_500_000)
|
|
assert sch._mdd_gate_state["last_flow"] == pytest.approx(-1_500_000)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_withdrawal_preserves_existing_drawdown_pct(env):
|
|
sch, mgr = env
|
|
await _feed(sch, 10_000_000, 6_000_000)
|
|
await _feed(sch, 9_500_000, 5_500_000) # 실제 -5% (유가 손실)
|
|
await _feed(sch, 4_500_000, 5_500_000) # 현금 5M 출금
|
|
dd = (4_500_000 - mgr.peak_balance) / mgr.peak_balance * 100
|
|
assert dd == pytest.approx(-5.0, abs=1e-6)
|
|
assert mgr.reduction_applied is None
|
|
|
|
|
|
# ── 2. 입금이 진행 중인 MDD 정지를 해제하지 않는다 ─────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deposit_does_not_clear_active_pause(env):
|
|
sch, mgr = env
|
|
mgr.peak_balance = 10_000_000.0
|
|
mgr.reduction_applied = "10pct"
|
|
mgr.is_paused = True
|
|
sch._mdd_gate_state["prev"] = {"total": 8_900_000.0, "scts": 6_000_000.0}
|
|
r = await _feed(sch, 11_900_000, 6_000_000) # 3M 입금 → 원값이면 peak 초과
|
|
assert r["status"] == "applied"
|
|
assert mgr.reduction_applied == "10pct" and mgr.is_paused
|
|
assert mgr.peak_balance == pytest.approx(10_000_000 * 11_900_000 / 8_900_000)
|
|
|
|
|
|
# ── 3. 매매(현금↔유가 동반 이동)는 입출금으로 보지 않는다 ─────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_buy_trade_not_treated_as_cash_flow(env):
|
|
sch, mgr = env
|
|
await _feed(sch, 10_000_000, 6_000_000)
|
|
await _feed(sch, 9_998_000, 6_598_000) # 60만원 매수(수수료 2천)
|
|
assert sch._mdd_gate_state["last_flow"] is None
|
|
assert mgr.peak_balance == 10_000_000
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_real_securities_loss_not_hidden_as_cash_flow(env):
|
|
"""유가평가 급락은 입출금으로 숨기지 않고 확인 후 축소한다(안전 방향)."""
|
|
sch, mgr = env
|
|
await _feed(sch, 10_000_000, 6_000_000)
|
|
r1 = await _feed(sch, 8_800_000, 4_800_000) # 유가 -1.2M → -12%
|
|
assert sch._mdd_gate_state["last_flow"] is None
|
|
assert r1["status"] == "pending"
|
|
assert mgr.reduction_applied is None # 1회 관측으로는 전이하지 않음
|
|
r2 = await _feed(sch, 8_800_000, 4_800_000)
|
|
assert r2["status"] == "applied"
|
|
assert r2["result"]["action"] == "REDUCE_AND_PAUSE"
|
|
assert mgr.reduction_applied == "10pct" and mgr.is_paused
|
|
|
|
|
|
# ── 4. 단일 사이클 급락은 확인 전까지 반영하지 않는다 ─────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_cycle_blip_is_not_applied(env):
|
|
sch, mgr = env
|
|
await _feed(sch, 10_000_000, 6_000_000)
|
|
low = 10_000_000 * _pause_ratio() - 100_000
|
|
r = await _feed(sch, low, low - 4_000_000)
|
|
assert r["status"] == "pending"
|
|
r = await _feed(sch, 9_900_000, 5_900_000) # 다음 사이클 정상 복귀
|
|
assert r["status"] == "applied"
|
|
assert sch._mdd_gate_state["pending"] == 0
|
|
assert mgr.reduction_applied is None and not mgr.is_paused
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confirm_cycles_setting_respected(env, monkeypatch):
|
|
sch, mgr = env
|
|
monkeypatch.setattr(sch, "_DEFAULT_MDD_STAGE_CONFIRM_CYCLES", 3) # config 키 미편입 — 기본값 경로
|
|
await _feed(sch, 10_000_000, 6_000_000)
|
|
for _ in range(2):
|
|
assert (await _feed(sch, 8_500_000, 4_500_000))["status"] == "pending"
|
|
assert (await _feed(sch, 8_500_000, 4_500_000))["status"] == "applied"
|
|
assert mgr.reduction_applied == "10pct"
|
|
|
|
|
|
# ── 5. 부분응답(보유 평가 0) 교차검증 ─────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_response_raises_and_keeps_state(env):
|
|
sch, mgr = env
|
|
held = [{"pdno": "005930", "hldg_qty": "10", "evlu_amt": "6000000", "prpr": "600000"}]
|
|
await _feed(sch, 10_000_000, 6_000_000, held)
|
|
broken = [{"pdno": "005930", "hldg_qty": "10", "evlu_amt": "0", "prpr": "0"}]
|
|
with pytest.raises(ValueError, match="부분응답"):
|
|
await _feed(sch, 4_000_000, 0, broken)
|
|
assert mgr.peak_balance == 10_000_000
|
|
assert mgr.reduction_applied is None
|
|
assert sch._mdd_gate_state["prev"]["total"] == 10_000_000 # 부분응답은 기준값으로 쓰지 않음
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persistent_partial_response_eventually_applied(env, monkeypatch):
|
|
"""의심이 상한만큼 연속이면 실제 낙폭을 무기한 억제하지 않는다(§5.3)."""
|
|
sch, mgr = env
|
|
monkeypatch.setattr(sch, "_DEFAULT_MDD_STAGE_SUSPECT_MAX_HOLDS", 2)
|
|
monkeypatch.setattr(sch, "_DEFAULT_MDD_STAGE_CONFIRM_CYCLES", 1) # config 키 미편입 — 기본값 경로
|
|
mgr.peak_balance = 10_000_000.0
|
|
broken = [{"pdno": "005930", "hldg_qty": "10", "evlu_amt": "0", "prpr": "0"}]
|
|
with pytest.raises(ValueError):
|
|
await _feed(sch, 4_000_000, 0, broken)
|
|
r = await _feed(sch, 4_000_000, 0, broken)
|
|
assert r["status"] == "applied"
|
|
assert mgr.reduction_applied == "20pct"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_eval_fields_do_not_block_real_mdd(env, monkeypatch):
|
|
"""필드가 없으면 의심으로 보지 않는다 — 필드명 불일치로 실제 MDD 를 막지 않게."""
|
|
sch, mgr = env
|
|
monkeypatch.setattr(sch, "_DEFAULT_MDD_STAGE_CONFIRM_CYCLES", 1) # config 키 미편입 — 기본값 경로
|
|
mgr.peak_balance = 10_000_000.0
|
|
held = [{"pdno": "005930", "hldg_qty": "10"}]
|
|
r = await _feed(sch, 8_500_000, 4_500_000, held)
|
|
assert r["status"] == "applied"
|
|
assert mgr.reduction_applied == "10pct"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_eval_sum_mismatch_is_suspect(env):
|
|
sch, mgr = env
|
|
mgr.peak_balance = 10_000_000.0
|
|
held = [{"pdno": "005930", "hldg_qty": "10", "evlu_amt": "6000000", "prpr": "600000"}]
|
|
with pytest.raises(ValueError, match="평가합"):
|
|
await _feed(sch, 8_000_000, 4_000_000, held) # scts 4M < 보유 평가합 6M
|
|
assert mgr.reduction_applied is None
|
|
|
|
|
|
# ── 6. 스케줄러 루프 배선 ────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loop_pending_blocks_entry_without_reduction(monkeypatch, env):
|
|
"""확인 대기 사이클: update 미호출 + 축소 미실행 + 신규 진입 차단."""
|
|
sch, mgr = env
|
|
from test_swing_scheduler_hardening_r1 import _loop_env, _one_cycle
|
|
resp = _resp(8_500_000, 4_500_000)
|
|
sch2, _api, calls = _loop_env(monkeypatch, balance=AsyncMock(return_value=resp))
|
|
# _loop_env 가 싱글톤 상태를 0 으로 고정하므로 그 뒤에 시나리오 상태를 세팅
|
|
monkeypatch.setattr(sch2.mdd_manager, "peak_balance", 10_000_000.0)
|
|
monkeypatch.setattr(sch2, "_mdd_gate_state", {
|
|
"prev": {"total": 10_000_000.0, "scts": 6_000_000.0}, "pending": 0, "suspect": 0, "last_flow": None,
|
|
})
|
|
reduce = AsyncMock(side_effect=AssertionError("확인 전 축소 금지"))
|
|
monkeypatch.setattr(sch2.mdd_manager, "execute_reduction", reduce)
|
|
await _one_cycle(sch2)
|
|
sch2.mdd_manager.update.assert_not_awaited()
|
|
reduce.assert_not_awaited()
|
|
assert calls["entry"] == 0
|
|
assert sch2._mdd_gate_state["pending"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loop_partial_response_counts_as_update_failure(monkeypatch, env):
|
|
sch, mgr = env
|
|
from test_swing_scheduler_hardening_r1 import _loop_env, _one_cycle
|
|
broken = [{"pdno": "005930", "hldg_qty": "10", "evlu_amt": "0", "prpr": "0"}]
|
|
sch2, _api, _calls = _loop_env(monkeypatch, balance=AsyncMock(return_value=_resp(4_000_000, 0, broken)))
|
|
monkeypatch.setattr(sch2.mdd_manager, "peak_balance", 10_000_000.0)
|
|
await _one_cycle(sch2)
|
|
sch2.mdd_manager.record_update_failure.assert_awaited_once()
|
|
sch2.mdd_manager.update.assert_not_awaited()
|
|
|
|
|
|
from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지)
|