"""DT 트레일링 고점 오염 회귀 테스트 (2026-10-01). 버그: exit_service._check_single_position 이 `max(c.high for c in candles_5m[-3:])` 로 진입 **전** 5분봉 고가까지 highest_price 에 반영 → 진입 직후 +1% 트레일링 트리거 오판, `고점 − 1.5×ATR` 손절선 부풀림. 운영 실측: 트레일링 6건 "고점 +1.1~2.2%" 후 손실 종료. """ from datetime import datetime, timedelta from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest import pytz from app.services.daytrading import exit_service as es from app.services.daytrading.position_manager import day_trading_state TODAY = "20261001" NOW = datetime(2026, 10, 1, 10, 15, 0) # KST naive (진입 후 10분 — 최근 5분봉 3개에 진입 전 봉 포함) ENTRY = datetime(2026, 10, 1, 10, 5, 30) # KST naive ENTRY_PRICE = 10_000.0 class LiveCallBlocked(BaseException): """주문 경로 도달 = 목킹 누락 신호 (except Exception 폴백에 삼켜지지 않도록 BaseException).""" def _c(date, hhmm, high, close=None): return SimpleNamespace(date=date, time=f"{hhmm}00", high=float(high), close=float(close if close is not None else high)) def _row(date, hhmm, o, h, l, c, v=1000): return { "stck_bsop_date": date, "stck_cntg_hour": f"{hhmm}00", "stck_oprc": str(o), "stck_hgpr": str(h), "stck_lwpr": str(l), "stck_prpr": str(c), "cntg_vol": str(v), "acml_vol": "0", "acml_tr_pbmn": "0", } def _minute_rows(pre_entry_spike: float, post_entry_high: float, date=TODAY): """09:30~10:14 1분봉. 10:02 에 진입 전 스파이크, 10:12 에 진입 후 고가.""" rows = [] t = datetime(2026, 10, 1, 9, 30) while t < datetime(2026, 10, 1, 10, 15): hhmm = t.strftime("%H%M") h = 10_010 if hhmm == "1002": h = pre_entry_spike elif hhmm == "1012": h = post_entry_high rows.append(_row(date, hhmm, 10_000, h, 9_990, 10_000)) t += timedelta(minutes=1) return rows @pytest.fixture(autouse=True) def _frozen_now_and_blocked_orders(): import app.services.trade_executor as te def _boom(name): async def _f(*a, **k): raise LiveCallBlocked(f"실주문 경로 도달: {name}") return _f with patch.object(es, "_kst_now", lambda: NOW), \ patch.object(te, "safe_place_order", _boom("safe_place_order")), \ patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): saved = dict(day_trading_state.positions) day_trading_state.positions.clear() yield day_trading_state.positions.clear() day_trading_state.positions.update(saved) # ── 순수 함수 ───────────────────────────────────────────────────────────── def test_pre_entry_candle_high_excluded(): candles = [_c(TODAY, "1002", 10_500), _c(TODAY, "1005", 10_400), # 진입 전 / 진입봉 _c(TODAY, "1006", 10_300), # 라벨 < 진입+60s(10:06:30) _c(TODAY, "1007", 10_050)] assert es._post_entry_candle_high(candles, ENTRY) == 10_050 def test_post_entry_new_high_reflected(): candles = [_c(TODAY, "1002", 10_500), _c(TODAY, "1010", 10_600), _c(TODAY, "1015", 10_200)] assert es._post_entry_candle_high(candles, ENTRY) == 10_600 def test_no_post_entry_candle_returns_none(): assert es._post_entry_candle_high([_c(TODAY, "1002", 10_500)], ENTRY) is None def test_entry_time_missing_returns_none(): assert es._post_entry_candle_high([_c(TODAY, "1010", 10_600)], None) is None assert es._post_entry_candle_high([_c(TODAY, "1010", 10_600)], "2026-10-01 10:05") is None def test_utc_stored_entry_time_treated_unreliable(): # KST 10:05 진입이 UTC 01:05 로 저장된 레거시 행 → 정규장 밖 → 불신 utc_like = datetime(2026, 10, 1, 1, 5, 30) assert es._post_entry_candle_high([_c(TODAY, "1002", 10_500)], utc_like) is None def test_tz_aware_entry_time_converted_to_kst(): aware = pytz.utc.localize(datetime(2026, 10, 1, 1, 5, 30)) # = KST 10:05:30 candles = [_c(TODAY, "1002", 10_500), _c(TODAY, "1010", 10_100)] assert es._post_entry_candle_high(candles, aware) == 10_100 def test_date_boundary_previous_day_candles_excluded(): # 전일 14:50 진입 포지션이 아니라 '당일 09:05 진입' — 전일 같은 시각대 이후 봉(라벨상 늦은 시각)이 섞여도 제외 entry = datetime(2026, 10, 1, 9, 5, 0) candles = [_c("20260930", "1500", 11_000), _c("20260930", "1519", 11_200), _c(TODAY, "0901", 10_900), _c(TODAY, "0910", 10_100)] with patch.object(es, "_kst_now", lambda: datetime(2026, 10, 1, 9, 20)): assert es._post_entry_candle_high(candles, entry) == 10_100 def test_candle_without_date_excluded(): candles = [SimpleNamespace(date="", time="101000", high=12_000.0), _c(TODAY, "1010", 10_100)] assert es._post_entry_candle_high(candles, ENTRY) == 10_100 def test_future_entry_time_unreliable(): assert es._post_entry_candle_high([_c(TODAY, "1010", 10_600)], NOW + timedelta(minutes=5)) is None # ── _check_single_position 통합 (KIS 분봉만 목, 주문 경로는 예외 발생) ────────────── async def _run_single(pos: dict, rows): captured = {} def _capture(**kw): captured.update(kw) return None # 청산 신호 없음 day_trading_state.positions["005930"] = pos with patch.object(es.korea_investment_api, "get_minute_chart_full_day", AsyncMock(return_value=rows)), \ patch.object(es, "check_day_trade_exit", _capture): result = await es._check_single_position("005930") return result, captured def _pos(entry_time=ENTRY, highest=ENTRY_PRICE): return { "stock_name": "삼성전자", "strategy": "day_trade", "entry_price": ENTRY_PRICE, "stop_loss": 9_700.0, "target_price": 10_450.0, "quantity": 10, "highest_price": highest, "entry_time": entry_time, } @pytest.mark.asyncio async def test_single_position_high_not_contaminated_by_pre_entry_spike(): # 진입 전 10:02 고가 10,250(+2.5%) — 예전 코드면 트레일링 +1% 트리거가 켜졌다 rows = _minute_rows(pre_entry_spike=10_250, post_entry_high=10_040) result, cap = await _run_single(_pos(), rows) assert result is None assert cap["highest_since_entry"] == 10_040 assert day_trading_state.positions["005930"]["highest_price"] == 10_040 assert (cap["highest_since_entry"] - ENTRY_PRICE) / ENTRY_PRICE < es.settings.DAY_TRADE_TRAILING_TRIGGER @pytest.mark.asyncio async def test_single_position_post_entry_new_high_reflected(): rows = _minute_rows(pre_entry_spike=10_050, post_entry_high=10_300) _, cap = await _run_single(_pos(), rows) assert cap["highest_since_entry"] == 10_300 @pytest.mark.asyncio async def test_single_position_highest_monotonic(): rows = _minute_rows(pre_entry_spike=10_050, post_entry_high=10_040) _, cap = await _run_single(_pos(highest=10_400), rows) assert cap["highest_since_entry"] == 10_400 @pytest.mark.asyncio async def test_single_position_missing_entry_time_uses_current_price_only(): rows = _minute_rows(pre_entry_spike=10_250, post_entry_high=10_300) _, cap = await _run_single(_pos(entry_time=None), rows) # 마지막 1분봉 종가 10,000 (= 현재가) 와 기존 고점 10,000 만 사용 → 분봉 고가 무시 assert cap["highest_since_entry"] == ENTRY_PRICE