"""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() == []