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:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -1691,3 +1691,100 @@ async def test_query_5xx_with_json_behavior_unchanged(tmp_path):
with pytest.raises(Exception, match="최대 재시도 초과"):
await api._make_request_with_retry("GET", "/uapi/domestic-stock/v1/quotations/inquire-price", "TR")
assert len(client.auth_headers) == api.max_retries + 1
def test_compose_backend_has_healthcheck_hang_only():
"""[R2 M] backend 는 무응답(hang) 감지 healthcheck 를 가져야 하고, degraded 로 오탐하지 않는다."""
from pathlib import Path
import yaml
compose = yaml.safe_load((Path(__file__).resolve().parents[2] / "docker-compose.yml").read_text())
hc = compose["services"]["backend"].get("healthcheck")
assert hc, "backend healthcheck 누락"
cmd = " ".join(hc["test"])
assert "/health" in cmd and "timeout=" in cmd
assert "degraded" not in cmd and "healthy" not in cmd # 상태 문자열 판정 금지(오탐)
assert int(str(hc["retries"])) >= 2
def test_compose_has_health_watch_sidecar():
"""[하드닝 R1] /health degraded 능동 감시는 NAS cron 에 의존하지 않고 compose 사이드카가 수행한다."""
from pathlib import Path
import yaml
compose = yaml.safe_load((Path(__file__).resolve().parents[2] / "docker-compose.yml").read_text())
svc = compose["services"]["health-watch"]
assert "health_watch_state" in compose["volumes"]
env = " ".join(svc["environment"])
assert "HEALTH_URL=http://backend:8000/health" in env
assert "TELEGRAM_BOT_TOKEN" in env and "TELEGRAM_CHAT_ID" in env
assert any("health_watch.py" in v for v in svc["volumes"])
assert "health_watch.py" in " ".join(svc["command"])
assert svc["restart"] == "unless-stopped"
# ─────────────────────────────────────────────────────────────────────────────
# 라운드4 — Redis 초기 연결 실패 후 지연 재연결
# ─────────────────────────────────────────────────────────────────────────────
def _load_redis_isolated():
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location(
"_redis_lazy_under_test", Path(__file__).resolve().parents[1] / "app" / "core" / "redis.py"
)
r = importlib.util.module_from_spec(spec)
spec.loader.exec_module(r)
return r
@pytest.mark.asyncio
async def test_redis_lazy_reconnect_after_init_failure(monkeypatch):
r = _load_redis_isolated()
built = []
class _Dead:
async def ping(self):
raise ConnectionError("down")
class _Alive:
pass
def _from_url(url, **kw):
c = _Dead() if not built else _Alive()
built.append(c)
return c
monkeypatch.setattr(r.aioredis, "from_url", _from_url)
monkeypatch.setattr(r, "_redis_client", None)
clock = [1000.0]
monkeypatch.setattr(r.time, "monotonic", lambda: clock[0])
await r.init_redis()
assert r._redis_client is None
# 쓰로틀 구간 내에는 재시도하지 않는다
assert r.get_redis() is None
assert len(built) == 1
clock[0] += r.REDIS_RECONNECT_INTERVAL_SEC + 1
c = r.get_redis()
assert isinstance(c, _Alive)
assert r.get_redis() is c # 이후 재생성 없음
assert len(built) == 2
@pytest.mark.asyncio
async def test_redis_no_lazy_reconnect_after_close(monkeypatch):
r = _load_redis_isolated()
def _boom(*a, **k):
raise AssertionError("재연결 금지")
monkeypatch.setattr(r.aioredis, "from_url", _boom)
monkeypatch.setattr(r, "_redis_client", None)
clock = [1000.0]
monkeypatch.setattr(r.time, "monotonic", lambda: clock[0])
await r.close_redis()
clock[0] += 1000
assert r.get_redis() is None