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