Files
auto-stock/backend/tests/test_trade_executor_hardening_r2_20261009.py
shuim a264561576 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
2026-10-10 12:17:49 +09:00

220 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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() == []