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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user