Files
auto-stock/backend/tests/test_dt_trailing_high_post_entry_20261001.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 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
2026-10-10 12:17:49 +09:00

188 lines
7.7 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
es._rsi_warmup_prev_cache.clear()
with patch.object(es.korea_investment_api, "get_minute_chart_full_day",
AsyncMock(return_value=rows)), \
patch.object(es.korea_investment_api, "get_minute_chart_full_day_v2",
AsyncMock(return_value=[])), \
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