Files
auto-stock/backend/tests/test_main_health_exposure_r4.py
shuim a264561576 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
2026-10-10 12:17:49 +09:00

137 lines
5.6 KiB
Python

"""하드닝 R4: /health 가 스윙·MDD·적응형·체결보정 상태를 degraded 로 반영한다."""
import pytest
from app import main as m
class _NoDbEngine:
"""실 DB 접속 금지 — 예외 발생형(§4.1). /health 는 DB 실패를 degraded 로 처리한다."""
def connect(self):
raise RuntimeError("test: real DB access blocked")
@pytest.fixture(autouse=True)
def _block_db_and_redis(monkeypatch):
monkeypatch.setattr(m, "engine", _NoDbEngine())
from app.core import redis as _redis
monkeypatch.setattr(_redis, "get_redis", lambda: None)
def _patch_getters(monkeypatch, swing=None, mdd=None, age=0.0, fs=None):
from app.services.swing import scheduler as sch, fill_sweep as fsw
from app.services import adaptive_strategy as adp
monkeypatch.setattr(sch, "get_swing_scheduler_health_v2", lambda: swing or {"degraded_reasons": []})
monkeypatch.setattr(sch, "get_mdd_update_health", lambda: mdd or {"blind_block_active": False})
monkeypatch.setattr(adp, "get_last_success_age_sec_v2", lambda: age)
monkeypatch.setattr(fsw, "get_fill_sweep_health_v2", lambda: fs or {"exhausted": 0})
def test_clean(monkeypatch):
_patch_getters(monkeypatch)
_, reasons = m._runtime_health_v2(market_open=True)
assert reasons == []
def test_swing_reasons_and_blind_block(monkeypatch):
_patch_getters(monkeypatch, swing={"degraded_reasons": ["stuck_steps", "exit_stale"]},
mdd={"blind_block_active": True})
_, reasons = m._runtime_health_v2(market_open=True)
assert {"stuck_steps", "exit_stale", "mdd_blind_block"} <= set(reasons)
def test_adaptive_stale_only_in_market(monkeypatch):
from app.services import adaptive_strategy as adp
_patch_getters(monkeypatch, age=adp._STALE_LIMIT_SEC + 1)
assert "adaptive_stale" in m._runtime_health_v2(market_open=True)[1]
assert "adaptive_stale" not in m._runtime_health_v2(market_open=False)[1]
def test_getter_exception_is_degraded_not_crash(monkeypatch):
from app.services.swing import scheduler as sch
def boom():
raise RuntimeError("x")
_patch_getters(monkeypatch)
monkeypatch.setattr(sch, "get_swing_scheduler_health_v2", boom)
_, reasons = m._runtime_health_v2(market_open=True)
assert "swing_health_unavailable" in reasons
@pytest.mark.asyncio
async def test_health_impl_degraded_on_dlq_manual_and_reasons(monkeypatch):
from app.services import db_worker as w
_patch_getters(monkeypatch, mdd={"blind_block_active": True})
async def dlq():
return {"pending": 0, "manual": 2, "expired": 0}
monkeypatch.setattr(w, "dlq_status_counts", dlq)
monkeypatch.setattr(m, "is_market_open", lambda: True)
out = await m._health_check_impl()
assert out["status"] == "degraded"
assert "mdd_blind_block" in out["degraded_reasons"] and "dlq_manual" in out["degraded_reasons"]
def test_detail_endpoint_requires_api_key():
route = [r for r in m.app.routes if getattr(r, "path", "") == "/health/detail"][0]
assert route.dependant.dependencies
@pytest.mark.asyncio
async def test_health_impl_never_touches_real_engine(monkeypatch):
"""회귀: 실 engine 대신 차단 목이 쓰이고 db 실패는 degraded 로 귀결된다."""
from app.services import db_worker as w
_patch_getters(monkeypatch)
async def dlq():
return {"pending": 0, "manual": 0, "expired": 0}
monkeypatch.setattr(w, "dlq_status_counts", dlq)
monkeypatch.setattr(m, "is_market_open", lambda: True)
out = await m._health_check_impl()
assert out["status"] == "degraded"
def _patch_extra(monkeypatch, wh=None, bh=None, kh=None, rh=None):
from app.services import db_worker as w, schedulers as sc, market_analyzer as ma
monkeypatch.setattr(w, "get_worker_health_v2", lambda: wh or {"qsize": 0, "circuit_open": False, "last_success_ts": None})
monkeypatch.setattr(sc, "get_batch_analysis_health_v2", lambda: bh or {"consecutive_failed_cycles": 0})
monkeypatch.setattr(sc, "get_krx_data_health_v2", lambda: kh or {"ok": None})
monkeypatch.setattr(ma, "get_regime_source_health", lambda: rh or {"blocked": False})
def test_db_circuit_and_write_stall(monkeypatch):
import time
_patch_getters(monkeypatch)
_patch_extra(monkeypatch, wh={"qsize": 3, "circuit_open": True,
"last_success_ts": time.time() - m._DB_WRITE_STALL_SEC - 10})
_, reasons = m._runtime_health_v2(market_open=True)
assert {"db_circuit_open", "db_write_stalled"} <= set(reasons)
def test_idle_queue_not_stalled(monkeypatch):
import time
_patch_getters(monkeypatch)
_patch_extra(monkeypatch, wh={"qsize": 0, "circuit_open": False,
"last_success_ts": time.time() - 99999})
assert m._runtime_health_v2(market_open=True)[1] == []
def test_batch_krx_regime_reasons(monkeypatch):
_patch_getters(monkeypatch)
_patch_extra(monkeypatch,
bh={"consecutive_failed_cycles": m._BATCH_FAIL_CYCLES_DEGRADED},
kh={"ok": False}, rh={"blocked": True})
_, reasons = m._runtime_health_v2(market_open=True)
assert {"batch_analysis_failing", "krx_data_incomplete", "regime_blocked"} <= set(reasons)
assert "regime_blocked" not in m._runtime_health_v2(market_open=False)[1]
def test_new_getter_exception_degraded(monkeypatch):
from app.services import db_worker as w
_patch_getters(monkeypatch)
_patch_extra(monkeypatch)
def boom():
raise RuntimeError("x")
monkeypatch.setattr(w, "get_worker_health_v2", boom)
assert "db_worker_health_unavailable" in m._runtime_health_v2(market_open=True)[1]