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

@@ -0,0 +1,170 @@
"""하드닝 R3: 종료 drain 의 주문 경로 대기 + WebSocket 세션 재검증/소유권."""
import asyncio
import time
import pytest
from app import main as m
from app.core.config import settings
@pytest.fixture(autouse=True)
def _iso(monkeypatch):
calls = []
monkeypatch.setattr(m, "_notify_system_warning_bg", lambda c, msg: calls.append((c, msg)))
monkeypatch.setattr(m, "_ws_owners", {})
return calls
@pytest.mark.asyncio
async def test_drain_waits_for_swing_inflight_and_fill_reconciler(monkeypatch):
from app.services.swing import scheduler as sw
from app.services.daytrading import fill_reconciler as fr
done = []
async def _work(tag):
await asyncio.sleep(0.05)
done.append(tag)
t1 = asyncio.ensure_future(_work("swing"))
t2 = asyncio.ensure_future(_work("fill"))
monkeypatch.setattr(sw, "_inflight_steps", {"entry": t1})
monkeypatch.setattr(fr, "_pending_tasks", {t2})
n = await m._drain_order_path_tasks_v2(float(settings.SHUTDOWN_BACKGROUND_DRAIN_SECONDS))
assert n == 0 and sorted(done) == ["fill", "swing"]
@pytest.mark.asyncio
async def test_drain_waits_for_shielded_inner_coroutine(monkeypatch):
from app.services.swing import scheduler as sw
from app.services.daytrading import fill_reconciler as fr
monkeypatch.setattr(sw, "_inflight_steps", {})
monkeypatch.setattr(fr, "_pending_tasks", set())
finished = []
async def _place_and_finalize_exit_guarded_impl():
await asyncio.sleep(0.05)
finished.append(1)
outer = asyncio.ensure_future(asyncio.shield(_place_and_finalize_exit_guarded_impl()))
await asyncio.sleep(0)
outer.cancel() # 판정 코루틴 취소 — 내부 shield 작업은 계속 돈다
await m._drain_order_path_tasks_v2(5.0)
assert finished == [1]
@pytest.mark.asyncio
async def test_drain_timeout_cancels_and_alerts(monkeypatch, _iso):
from app.services.swing import scheduler as sw
from app.services.daytrading import fill_reconciler as fr
monkeypatch.setattr(fr, "_pending_tasks", set())
async def _stuck():
await asyncio.sleep(60)
t = asyncio.ensure_future(_stuck())
monkeypatch.setattr(sw, "_inflight_steps", {"exit": t})
n = await m._drain_order_path_tasks_v2(0.05)
assert n == 1 and t.cancelled()
assert _iso and _iso[0][0] == "SHUTDOWN_ORDER_TASKS" and "_stuck" in _iso[0][1]
@pytest.mark.asyncio
async def test_all_drain_includes_order_path(monkeypatch):
from app.services import trade_executor as te
called = []
async def _od(timeout):
called.append(timeout)
return 0
async def _te(timeout):
return 0
monkeypatch.setattr(m, "_drain_order_path_tasks_v2", _od)
monkeypatch.setattr(te, "drain_tracked_tasks_v2", _te)
await m._drain_all_background_tasks_v2(1.0)
assert called == [1.0]
def test_ws_exp_and_owner():
from app.core import security as sec
tok, _ = sec.create_session_token("bob")
exp = m._ws_token_exp_v2(tok)
assert exp is not None and exp > time.time()
assert m._ws_token_exp_v2("api-key-plain") is None
m._ws_owners["c1"] = "bob"
assert m._ws_owner_may_connect_v2("c1", "bob", {"c1": 1}) is True
assert m._ws_owner_may_connect_v2("c1", "eve", {"c1": 1}) is False
assert m._ws_owner_may_connect_v2("c2", "eve", {"c1": 1}) is True
@pytest.mark.asyncio
async def test_ws_session_revalidation(monkeypatch):
from app.core import security as sec
# exp 초과
assert await m._ws_session_still_valid_v2("t", time.time() - 1, recheck=False) is False
assert await m._ws_session_still_valid_v2("t", time.time() + 100, recheck=False) is True
async def _revoked(tok):
return None
monkeypatch.setattr(sec, "resolve_credential_v2", _revoked)
assert await m._ws_session_still_valid_v2("t", None, recheck=True) is False
async def _boom(tok):
raise RuntimeError("db down")
monkeypatch.setattr(sec, "resolve_credential_v2", _boom)
assert await m._ws_session_still_valid_v2("t", None, recheck=True) is True
# ── 하드닝 R5: WS 재확인 fail-closed / 요청 제한 ─────────────────────────────
@pytest.mark.asyncio
async def test_ws_recheck_closes_after_consecutive_failures(monkeypatch):
from app.core import security
async def _boom(token):
raise RuntimeError("db down")
monkeypatch.setattr(security, "resolve_credential_v2", _boom)
st = {"fails": 0}
limit = int(m._WS_RECHECK_MAX_FAILURES)
for _ in range(limit - 1):
assert await m._ws_session_still_valid_v3("t", None, True, st) is True
assert await m._ws_session_still_valid_v3("t", None, True, st) is False
@pytest.mark.asyncio
async def test_ws_recheck_success_resets_counter(monkeypatch):
from app.core import security
mode = {"fail": True}
async def _res(token):
if mode["fail"]:
raise RuntimeError("x")
return object()
monkeypatch.setattr(security, "resolve_credential_v2", _res)
st = {"fails": 0}
await m._ws_session_still_valid_v3("t", None, True, st)
mode["fail"] = False
assert await m._ws_session_still_valid_v3("t", None, True, st) is True
assert st["fails"] == 0
def test_ws_cap_codes_limits_and_dedupes():
codes = [f"C{i}" for i in range(m._WS_MAX_SUBSCRIBE_CODES + 50)] + ["C0", " "]
out = m._ws_cap_codes_v2(codes)
assert len(out) == m._WS_MAX_SUBSCRIBE_CODES and len(set(out)) == len(out)
def test_ws_chart_limiter_per_second_and_minute():
t = {"now": 0.0}
lim = m._WsChartLimiterV2(2, 3, clock=lambda: t["now"])
assert lim.allow() and lim.allow()
assert not lim.allow() # 초당 초과
t["now"] = 1.5
assert lim.allow() # 분당 3번째
t["now"] = 3.0
assert not lim.allow() # 분당 초과
t["now"] = 61.0
assert lim.allow()