- 스윙 체결가 확인 전 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
220 lines
8.4 KiB
Python
220 lines
8.4 KiB
Python
"""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() == []
|