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,181 @@
"""DT 청산 하드닝 라운드 4 — R4-3 청산 주문 연속 거부 경보 회귀 테스트 (2026-10-09).
손절선에 닿은 DT 포지션의 시장가 매도가 KIS 업무 거부(rt_cd≠0)로 계속 실패해도
logger.error 만 남고 청산 판정 헬스는 '성공'으로 리셋됐다(1단계 handled 종목은 2단계·헬스 실패
집합에서 빠짐). 이제:
- 거부 1건마다 판정 실패 수집기에 기록 → 헬스 연속 실패로 센다(1단계 handled 포함)
- 종목별 거부 연속 횟수가 N(settings.DAY_TRADE_EXIT_CHECK_FAIL_ALERT_CYCLES) 이면 DT_EXIT_REJECT 텔레그램 1회
- 접수(accepted) 1회면 리셋
- 전일(stale) 경로는 _stale_sell_failures 가 따로 세므로 제외
목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`safe_place_order`)에 건다. 거부 응답은 시나리오
자체(KIS 거부 재현)이고, 그 외 호출되면 안 되는 경로는 BaseException 발생형이다.
"""
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from app.services.daytrading import exit_service as es
from app.services.daytrading.position_manager import day_trading_state
class MustNotBeCalled(BaseException):
"""호출되면 안 되는 경로 (except Exception 에 삼켜지지 않게 BaseException)."""
def _boom(name):
async def _f(*a, **k):
raise MustNotBeCalled(f"호출 금지 경로 도달: {name}")
return _f
_REJECT = {"rt_cd": "1", "msg_cd": "40910000", "msg1": "모의투자 주문처리가 안되었습니다"}
_COLLS = (
"_stale_sell_attempted", "_stale_sell_failures", "_last_exit_order_outcome",
"_last_exit_order_detail", "_unresolved_exit_unknown", "_exit_tokens",
"_once_alerted", "_price_seen_mono", "_exit_check_failures", "_exit_stall_alerted",
"_rest_price_seen", "_exit_reject_streak", "_exit_reject_alerted", "_alert_tasks",
)
@pytest.fixture(autouse=True)
def _clean():
from app.services.telegram_notifier import telegram_notifier
saved = (dict(day_trading_state.positions), set(day_trading_state.closed_today),
day_trading_state.daily_pnl)
day_trading_state.reset_if_new_day()
day_trading_state.positions.clear()
day_trading_state.daily_pnl = 0.0
for name in _COLLS:
getattr(es, name).clear()
day_trading_state.exits_in_progress.clear()
tg = AsyncMock()
with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \
patch.object(telegram_notifier, "notify_system_warning", tg):
yield tg
day_trading_state.positions.clear()
day_trading_state.positions.update(saved[0])
day_trading_state.closed_today.clear()
day_trading_state.closed_today.update(saved[1])
day_trading_state.daily_pnl = saved[2]
for name in _COLLS:
getattr(es, name).clear()
day_trading_state.exits_in_progress.clear()
async def _drain_alerts():
await asyncio.gather(*list(es._alert_tasks), return_exceptions=True)
def _tags(tg):
return [c.args[0] for c in tg.await_args_list]
def _pos(qty=4, entry=10_000.0, stop=9_700.0):
return {"stock_name": "테스트", "strategy": "VWAP", "entry_price": entry, "quantity": qty,
"stop_loss": stop, "target_price": entry * 1.045, "highest_price": entry,
"entry_time": es._kst_now(), "reason": "", "order_id": "0000999"}
def _cycle_patches(order_mock):
"""손절선 아래 현재가 → 1단계 손절 선판정이 매도 주문. 분봉 조회는 도달하면 안 된다."""
return (
patch.object(es, "is_eod_flat_time", return_value=False),
patch.object(es, "close_stale_positions", AsyncMock(return_value=[])),
patch.object(es, "_ensure_total_assets_for_exit_impl", AsyncMock(return_value=0.0)),
patch.object(es.korea_investment_api, "get_current_price",
AsyncMock(return_value={"output": {"stck_prpr": "9500"}})),
patch.object(es.korea_investment_api, "get_minute_chart_full_day",
_boom("get_minute_chart_full_day")),
patch("app.services.trade_executor.safe_place_order", order_mock),
)
@pytest.mark.asyncio
async def test_fast_stop_reject_counts_as_failure_and_alerts_after_n(_clean):
code = "444444"
day_trading_state.positions[code] = _pos()
n = es._exit_check_fail_alert_cycles_impl()
order = AsyncMock(return_value=dict(_REJECT))
p = _cycle_patches(order)
with p[0], p[1], p[2], p[3], p[4], p[5]:
for _ in range(n - 1):
assert await es.check_and_exit_positions() == []
await _drain_alerts()
assert "DT_EXIT_REJECT" not in _tags(_clean) # N-1 회까지는 경보 없음
# 1단계 handled 종목이라도 거부면 헬스가 '성공'으로 리셋되지 않는다
h = es.get_exit_check_health_v2()
assert h["failing"] == {code: n - 1}
assert h["order_rejects"] == {code: n - 1}
await es.check_and_exit_positions()
await es.check_and_exit_positions() # 같은 연속 구간 → 1회만
await _drain_alerts()
tags = _tags(_clean)
assert tags.count("DT_EXIT_REJECT") == 1
msg = next(c.args[1] for c in _clean.await_args_list if c.args[0] == "DT_EXIT_REJECT")
assert "40910000" in msg and f"수량={_pos()['quantity']}주" in msg
assert order.await_count == n + 1 # 매매 로직(매 사이클 재주문)은 그대로
assert code in day_trading_state.positions # 거부라 포지션 유지
assert es.get_exit_check_health_v2()["stalled"] == [code]
@pytest.mark.asyncio
async def test_accept_resets_reject_streak(_clean):
code = "555555"
day_trading_state.positions[code] = _pos()
p = _cycle_patches(AsyncMock(return_value=dict(_REJECT)))
with p[0], p[1], p[2], p[3], p[4], p[5]:
await es.check_and_exit_positions()
await es.check_and_exit_positions()
assert es._exit_reject_streak[code] == 2
accepted = {"rt_cd": "0", "msg_cd": "", "msg1": "ok", "output": {"ODNO": "1"}}
fin = AsyncMock(return_value=None) # 접수 이후 반영은 이 테스트 범위 밖
p = _cycle_patches(AsyncMock(return_value=accepted))
with p[0], p[1], p[2], p[3], p[4], p[5], patch.object(es, "_finalize_after_accept_impl", fin):
await es.check_and_exit_positions()
assert fin.await_count == 1
assert code not in es._exit_reject_streak
assert code not in es._exit_reject_alerted
assert es.get_exit_check_health_v2()["failing"] == {}
@pytest.mark.asyncio
async def test_stage2_reject_marks_bounded_failure_through_shield(_clean):
"""2단계(시간손절·트레일링 등)의 거부도 판정 실패로 수집된다(shield 태스크 경유 컨텍스트 전파)."""
code = "666666"
pos = _pos()
day_trading_state.positions[code] = pos
async def _fn(c):
return await es._execute_exit(c, pos, 9_950.0, "시간손절 테스트", sell_pct=1.0)
with patch("app.services.trade_executor.safe_place_order", AsyncMock(return_value=dict(_REJECT))):
out, failed = await es._run_exit_checks_bounded_v2_impl([code], _fn, 5.0, "청산 판정")
assert out == {code: None}
assert failed == {code}
assert es._exit_reject_streak[code] == 1
@pytest.mark.asyncio
async def test_stale_path_reject_not_counted(_clean):
"""전일 경로 거부는 _stale_sell_failures 담당 — R4-3 카운터·경보에 넣지 않는다."""
code = "777777"
pos = _pos()
with patch("app.services.trade_executor.safe_place_order", AsyncMock(return_value=dict(_REJECT))):
assert await es._execute_exit_stale(code, pos, 9_500.0, "전일 미청산") is None
assert es._last_exit_order_outcome[code] == "rejected"
assert code not in es._exit_reject_streak
@pytest.mark.asyncio
async def test_reject_counter_cleared_when_position_gone(_clean):
code = "888888"
day_trading_state.positions[code] = _pos()
es._note_exit_order_rejected_impl(code, 4, "40910000", "x", "손절")
assert es._exit_reject_streak[code] == 1
day_trading_state.positions.pop(code)
es._update_exit_check_health_impl([code], set(), "청산 판정")
assert code not in es._exit_reject_streak