Files
auto-stock/backend/tests/test_dt_trailing_high_post_entry_20261001.py
shuim 599de3b52e fix(daytrading): 트레일링 고점이 진입 전 고가로 오염되던 버그
- highest_price 를 max(candles_5m[-3:].high) 대신 진입+60초 이후 시작한 1분봉 고가 + 현재가로만 갱신
  (진입 전 스파이크가 +1% 트리거를 오발동시키고 고점-1.5×ATR 손절선을 부풀리던 문제)
- 날짜+시각 KST 비교로 전일 캔들 제외, entry_time 부재·UTC 저장 추정 시 현재가만 사용
- 파라미터 변경 없음. 하드손절·시간손절·EOD·폴백 손절 경로 불변
- CLAUDE.md §7 시간손절 서술을 실제 동작(30분 경과 후 상한 없이 -0.3% 미만)으로 정정
- 회귀 테스트 13건 (전체 909 passed)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:04:07 +09:00

185 lines
7.5 KiB
Python
Raw 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.
"""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