- 스윙 체결가 확인 전 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
201 lines
8.4 KiB
Python
201 lines
8.4 KiB
Python
"""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]
|