"""DT 청산 RSI 워밍업 회귀 테스트 (하드닝 라운드 3, 2026-10-09, daytrading/exit_service.py). 결함: 진입은 전일 봉을 붙인 워밍업 시계열(get_minute_chart_full_day_v2(include_prev_day=True)) 로 RSI(14)를 계산하는데, 청산(_check_single_position)은 당일 봉만 써서 당일 5분봉 15개 미만(약 10:10 이전)에는 RSI=None → '과매수 청산'이 구조적으로 발동하지 않았다. 수정: RSI 판정 시계열만 진입과 같은 구성(전일 워밍업 + 당일 봉)으로 맞춘다. 현재가·고점은 당일 봉, ATR 은 `_atr` 이 당일 세션만 쓴다. 임계값(DAY_TRADE_RSI_EXIT_OVERBOUGHT)은 불변. 목킹(CLAUDE.md §4.1): 주문은 공통 종단(`_execute_exit_impl`·`safe_place_order`)에 예외 발생형. """ from datetime import datetime, timedelta from unittest.mock import AsyncMock, patch import pytest from app.core.config import settings from app.services import day_trading_strategy as dts from app.services.daytrading import entry_service as ens from app.services.daytrading import exit_service as es from app.services.daytrading.position_manager import day_trading_state TODAY = "20261008" PREV = "20261007" NOW = datetime(2026, 10, 8, 9, 40, 30) # KST naive — ORB 진입 직후 구간 ENTRY = datetime(2026, 10, 8, 9, 31, 0) CODE = "123450" class ExitReached(BaseException): """공통 종단 도달 — 청산 신호가 났다는 증거. except Exception 에 삼켜지지 않는다.""" class LiveCallBlocked(BaseException): """호출되면 안 되는 경로.""" def _boom(name): async def _f(*a, **k): raise LiveCallBlocked(f"호출 금지 경로 도달: {name}") return _f def _row(date, hhmm, c, v=1000): return { "stck_bsop_date": date, "stck_cntg_hour": f"{hhmm}00", "stck_oprc": str(c - 5), "stck_hgpr": str(c + 5), "stck_lwpr": str(c - 10), "stck_prpr": str(c), "cntg_vol": str(v), "acml_vol": "0", "acml_tr_pbmn": "0", } def _today_rows(): """09:00~09:39 1분봉(5분봉 8개). 마지막은 상승 추세로 RSI 과열.""" rows, t, px = [], datetime(2026, 10, 8, 9, 0), 10_000 while t < datetime(2026, 10, 8, 9, 40): # 약간의 눌림을 섞어 RSI 가 100 고정이 되지 않게 px += -10 if t.minute % 7 == 3 else 15 rows.append(_row(TODAY, t.strftime("%H%M"), px)) t += timedelta(minutes=1) return rows def _prev_rows(): """전일 14:00~15:19 1분봉(5분봉 16개) — 워밍업.""" rows, t, px = [], datetime(2026, 10, 7, 14, 0), 9_800 while t < datetime(2026, 10, 7, 15, 20): px += -8 if t.minute % 4 == 0 else 6 rows.append(_row(PREV, t.strftime("%H%M"), px)) t += timedelta(minutes=1) return rows def _pos(): rows = _today_rows() last = float(rows[-1]["stck_prpr"]) return { "stock_name": "테스트", "strategy": "ORB", "entry_price": 10_000.0, # 손절·목표는 신호에 개입하지 않게 멀리, 고점=현재가(트레일링 미발동) "stop_loss": 9_000.0, "target_price": 20_000.0, "quantity": 10, "highest_price": last, "entry_time": ENTRY, } @pytest.fixture(autouse=True) def _isolated(): import app.services.trade_executor as te saved = dict(day_trading_state.positions) day_trading_state.positions.clear() es._rsi_warmup_prev_cache.clear() es._rest_price_seen.pop(CODE, None) ens._candle_cache.pop(CODE, None) async def _reach(stock_code, pos, price, reason, sell_pct, is_stale): raise ExitReached(reason) with patch.object(es, "_kst_now", lambda: NOW), \ patch.object(te, "safe_place_order", _boom("safe_place_order")), \ patch.object(es, "_execute_exit_impl", _reach): yield day_trading_state.positions.clear() day_trading_state.positions.update(saved) es._rsi_warmup_prev_cache.clear() ens._candle_cache.pop(CODE, None) def _today_only_rsi_is_none(): c5 = dts.aggregate_to_5min(dts.parse_minute_candles(_today_rows())) return len(c5) < 15 and dts._rsi(c5, 14) is None @pytest.mark.asyncio async def test_0940_orb_position_overbought_exit_with_prev_day_warmup(): """09:40 ORB 포지션, 당일 5분봉 8개 + 전일 워밍업 → RSI≥임계면 과매수 청산 신호.""" assert _today_only_rsi_is_none() # 전제: 당일 봉만으로는 RSI 판정 불가 warm = dts.aggregate_to_5min(dts.parse_minute_candles(_prev_rows() + _today_rows())) rsi = dts._rsi(warm, 14) assert rsi is not None and rsi >= float(settings.DAY_TRADE_RSI_EXIT_OVERBOUGHT) day_trading_state.positions[CODE] = _pos() with patch.object(es.korea_investment_api, "get_minute_chart_full_day", AsyncMock(return_value=_today_rows())), \ patch.object(es.korea_investment_api, "get_minute_chart_full_day_v2", AsyncMock(return_value=_prev_rows() + _today_rows())): with pytest.raises(ExitReached) as ei: await es._check_single_position(CODE) assert "과매수 청산" in str(ei.value) assert f"{float(settings.DAY_TRADE_RSI_EXIT_OVERBOUGHT):g}" in str(ei.value) @pytest.mark.asyncio async def test_without_warmup_no_overbought_signal_and_no_crash(): """워밍업 확보 실패(빈 결과) → 종전 동작(당일 봉만, RSI None) — 주문 경로 미도달.""" day_trading_state.positions[CODE] = _pos() with patch.object(es.korea_investment_api, "get_minute_chart_full_day", AsyncMock(return_value=_today_rows())), \ patch.object(es.korea_investment_api, "get_minute_chart_full_day_v2", AsyncMock(return_value=[])): assert await es._check_single_position(CODE) is None @pytest.mark.asyncio async def test_warmup_fetch_error_is_contained_and_retry_throttled(): """워밍업 조회 예외는 판정을 막지 않고, 재시도 간격 안에서는 다시 조회하지 않는다.""" day_trading_state.positions[CODE] = _pos() v2 = AsyncMock(side_effect=RuntimeError("kis down")) with patch.object(es.korea_investment_api, "get_minute_chart_full_day", AsyncMock(return_value=_today_rows())), \ patch.object(es.korea_investment_api, "get_minute_chart_full_day_v2", v2): assert await es._check_single_position(CODE) is None assert await es._check_single_position(CODE) is None assert v2.await_count == 1 @pytest.mark.asyncio async def test_prev_day_warmup_cached_once_per_day(): day_trading_state.positions[CODE] = _pos() v2 = AsyncMock(return_value=_prev_rows() + _today_rows()) with patch.object(es.korea_investment_api, "get_minute_chart_full_day", AsyncMock(return_value=_today_rows())), \ patch.object(es.korea_investment_api, "get_minute_chart_full_day_v2", v2): for _ in range(2): with pytest.raises(ExitReached): await es._check_single_position(CODE) assert v2.await_count == 1 @pytest.mark.asyncio async def test_entry_scan_cache_reused_without_network(): """진입 스캔 캐시(같은 워밍업 시계열)에 전일 봉이 있으면 네트워크 조회를 하지 않는다.""" import time as _t ens._candle_cache[CODE] = ( _t.monotonic(), dts.parse_minute_candles(_prev_rows() + _today_rows()) ) day_trading_state.positions[CODE] = _pos() with patch.object(es.korea_investment_api, "get_minute_chart_full_day", AsyncMock(return_value=_today_rows())), \ patch.object(es.korea_investment_api, "get_minute_chart_full_day_v2", _boom("get_minute_chart_full_day_v2")): with pytest.raises(ExitReached) as ei: await es._check_single_position(CODE) assert "과매수 청산" in str(ei.value) def test_signal_series_keeps_today_atr_and_current_bar(): """워밍업을 붙여도 ATR(당일 세션)·마지막 봉(현재가 기준)은 당일 봉만 쓴 것과 같다.""" today = dts.parse_minute_candles(_today_rows()) prev = dts.parse_minute_candles(_prev_rows()) only = dts.aggregate_to_5min(today) merged = es._exit_signal_candles_5m_impl(today, prev) assert len(merged) > len(only) assert dts._atr(merged, 14) == dts._atr(only, 14) assert merged[-1] == only[-1] def test_prev_session_filter_excludes_today_and_blank_date(): rows = dts.parse_minute_candles(_prev_rows()[:2] + _today_rows()[:2]) rows[0].date = "" out = es._prev_session_candles_impl(rows, TODAY) assert [c.date for c in out] == [PREV]