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
This commit is contained in:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -0,0 +1,200 @@
"""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]