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,219 @@
"""trade_executor 하드닝 루프 라운드 2 회귀 테스트 (2026-10-09).
1. 추천 경로 check_exit_signal 블록: 트레일링 고점은 당일 고가(high_price)가 아니라
진입 이후 고점(highest_since_entry)이고, atr 이 없는 운영 추천 dict 에서는 휴면이다.
2. trade_executor 추적 백그라운드 태스크가 종료 시 lifespan 에서 drain 된다
(main `_background_tasks` 와 같은 예산 안에서 동시에 — 종료 예산 불변).
규약(CLAUDE.md §4.1): 목은 예외 발생형(BaseException 파생), 기대값은 settings.* 참조.
"""
import ast
import asyncio
import inspect
import textwrap
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from app.core.config import settings
from app.services import trade_executor
class _Reached(BaseException):
"""경로 도달 신호 — `except Exception:` 에 삼켜지지 않게 BaseException 파생."""
class _Result:
def __init__(self, rows=None):
self._rows = list(rows or [])
def scalars(self):
return self
def first(self):
return self._rows[0] if self._rows else None
def scalar_one_or_none(self):
return self._rows[0] if self._rows else None
def all(self):
return list(self._rows)
class _SellFlowDB:
"""보유 조회 → holding, 보유 수 → [holding], 그 외 → 빈 결과. commit 은 금지."""
def __init__(self, holding):
self._seq = [[holding], [holding]]
self.n = 0
async def execute(self, stmt):
rows = self._seq[self.n] if self.n < len(self._seq) else []
self.n += 1
return _Result(rows)
async def commit(self):
raise AssertionError("commit 금지 (§3.2)")
def _holding():
return SimpleNamespace(quantity=10, strategy_type="swing", average_price=10_000.0,
profit_loss_rate=10.0, stock_name="테스트")
def _patch_flow(monkeypatch):
monkeypatch.setattr(trade_executor.mdd_manager, "can_open_new_position", AsyncMock(return_value=True))
monkeypatch.setattr(trade_executor.loss_tracker, "is_banned_safe", AsyncMock(return_value=False))
monkeypatch.setattr(settings, "ML_ENSEMBLE_ENABLED", False)
monkeypatch.setattr(settings, "NEWS_ENABLED", False)
captured = {}
def _capture(**kw):
captured.update(kw)
raise _Reached("check_exit_signal reached")
monkeypatch.setattr(trade_executor, "check_exit_signal", _capture)
monkeypatch.setattr(trade_executor, "_execute_sell", AsyncMock(side_effect=_Reached("sell reached")))
monkeypatch.setattr(trade_executor, "_execute_buy", AsyncMock(side_effect=_Reached("buy reached")))
return captured
# ── 1. 트레일링 고점 / 휴면 ──────────────────────────────────────────────────
def test_trailing_high_ignores_intraday_high_price():
stock = {"high_price": 20_000.0}
assert trade_executor._recommendation_trailing_high_v2(stock, 11_000.0) == 11_000.0
def test_trailing_high_uses_highest_since_entry():
stock = {"high_price": 20_000.0, "highest_since_entry": 12_500.0}
assert trade_executor._recommendation_trailing_high_v2(stock, 11_000.0) == 12_500.0
# 현재가가 기존 고점보다 높으면 현재가로 상향(swing/exit_service 와 동일 정의)
assert trade_executor._recommendation_trailing_high_v2(stock, 13_000.0) == 13_000.0
@pytest.mark.parametrize("bad", ["abc", float("nan"), float("inf")])
def test_trailing_high_bad_value_falls_back_to_current(bad):
stock = {"highest_since_entry": bad, "high_price": 20_000.0}
assert trade_executor._recommendation_trailing_high_v2(stock, 11_000.0) == 11_000.0
@pytest.mark.asyncio
async def test_exit_block_passes_post_entry_high_not_day_high(monkeypatch):
"""atr 이 실린 dict 로 블록이 살아나도 trailing_high 는 당일 고가가 아니다."""
captured = _patch_flow(monkeypatch)
stock = {
"stock_code": "005930", "stock_name": "테스트", "current_price": 11_000.0,
"signals": [], "strategy_type": "swing", "remaining_pct": 1.0,
"rsi": 56.0, "bb": (12_000.0, 10_500.0, 9_000.0), "atr": 200.0,
"high_price": 20_000.0, "highest_since_entry": 11_800.0,
}
with pytest.raises(_Reached, match="check_exit_signal"):
await trade_executor.execute_auto_trade(stock, _SellFlowDB(_holding()))
assert captured["trailing_high"] == 11_800.0
assert captured["trailing_high"] != stock["high_price"]
@pytest.mark.asyncio
async def test_exit_block_dormant_for_recommendation_dict_without_atr(monkeypatch):
"""운영 추천 dict(atr/bb/closes 없음)에서는 check_exit_signal 이 호출되지 않는다.
호출되면 _capture 가 _Reached 를 던져 테스트가 실패한다(휴면 계약 고정).
"""
_patch_flow(monkeypatch)
stock = {
"stock_code": "005930", "stock_name": "테스트", "current_price": 8_000.0,
"signals": [], "strategy_type": "swing", "remaining_pct": 1.0, "rsi": 25.0,
"high_price": 20_000.0,
}
res = await trade_executor.execute_auto_trade(stock, _SellFlowDB(_holding()))
assert isinstance(res, dict)
assert res.get("action") != "SELL"
def test_exit_block_no_longer_reads_high_price():
src = inspect.getsource(trade_executor.execute_auto_trade)
assert 'stock.get("high_price") or current_price' not in src
assert "_recommendation_trailing_high_v2" in src
# ── 2. 종료 시 추적 태스크 drain 배선 ────────────────────────────────────────
def _lifespan_tree():
from app import main
return ast.parse(textwrap.dedent(inspect.getsource(main.lifespan)))
def test_lifespan_background_step_drains_tracked_tasks():
from app import main
tree = _lifespan_tree()
calls = [
n for n in ast.walk(tree)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
and n.func.id == "_drain_all_background_tasks_v2"
]
assert calls, "lifespan 이 _drain_all_background_tasks_v2 를 호출하지 않는다"
drain_src = inspect.getsource(main._drain_all_background_tasks_v2)
assert "drain_tracked_tasks_v2" in drain_src
assert "_await_background_tasks" in drain_src
def test_drain_runs_before_db_worker_stops():
"""종료 튜플에서 '__background__' 가 'db_worker_task' 보다 앞에 있다."""
tree = _lifespan_tree()
for n in ast.walk(tree):
if isinstance(n, ast.Tuple):
names = [e.value for e in n.elts if isinstance(e, ast.Constant)]
if "__background__" in names and "db_worker_task" in names:
assert names.index("__background__") < names.index("db_worker_task")
assert names[-1] == "db_worker_task"
return
pytest.fail("종료 태스크 튜플을 찾지 못했다")
@pytest.mark.asyncio
async def test_drain_all_shares_one_budget_and_cancels_both(monkeypatch):
from app import main
async def _slow():
await asyncio.sleep(3600)
t_main = asyncio.get_running_loop().create_task(_slow())
main._background_tasks.add(t_main)
t_main.add_done_callback(main._background_tasks.discard)
t_tracked = trade_executor.spawn_tracked_task_v2(_slow(), name="verify-fill")
budget = 0.2
t0 = time.monotonic()
await main._drain_all_background_tasks_v2(budget)
elapsed = time.monotonic() - t0
# 직렬이면 ≥ 2×budget. 동시 drain 이므로 한 예산 + 여유 안에 끝난다.
assert elapsed < budget * 1.8
assert t_main.cancelled()
assert t_tracked.cancelled()
await asyncio.sleep(0)
assert t_tracked not in trade_executor._tracked_bg_tasks
@pytest.mark.asyncio
async def test_drain_all_failure_is_logged_not_raised(monkeypatch, caplog):
from app import main
class _Boom(Exception):
pass
async def _boom(timeout):
raise _Boom("drain failed")
monkeypatch.setattr(trade_executor, "drain_tracked_tasks_v2", _boom)
with caplog.at_level("WARNING", logger=main.logger.name):
await main._drain_all_background_tasks_v2(0.01)
assert any("drain 실패" in r.getMessage() for r in caplog.records)
def test_shutdown_budget_unchanged_by_tracked_drain():
"""동시 drain 이므로 종료 예산 공식(uvicorn + 백그라운드 + db drain)은 그대로 유예 미만."""
from app import main
assert main.check_shutdown_budget() == []