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:
136
backend/tests/test_main_health_exposure_r4.py
Normal file
136
backend/tests/test_main_health_exposure_r4.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""하드닝 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]
|
||||
Reference in New Issue
Block a user