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:
170
backend/tests/test_main_shutdown_ws_r3_20261009.py
Normal file
170
backend/tests/test_main_shutdown_ws_r3_20261009.py
Normal 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()
|
||||
Reference in New Issue
Block a user