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,295 @@
"""DT 청산 하드닝 라운드 2 회귀 테스트 (2026-10-09, daytrading/exit_service.py).
R2-1 15:20~15:30 종가 단일가 매도는 장 마감 후 재확인한 실제 체결가로 loss_tracker 승패 기록
R2-3 국내 ETF/ETN 매도는 증권거래세 0
R2-4 분할 익절 후 전량 청산의 승패는 왕복 순손익으로(당일 연패·loss_tracker)
R2-5 강제청산 None 반환도 실패로 재시도·텔레그램, 장 마감 후 잔존 DT 포지션 경보
목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`safe_place_order`·`_execute_exit_impl`)에 걸고,
호출되면 안 되는 경로는 BaseException 발생형. 기대값은 settings.* 에서 계산한다.
"""
import asyncio
from datetime import datetime
from unittest.mock import AsyncMock, patch
import pytest
from app.core.config import settings
from app.services.daytrading import exit_service as es
from app.services.daytrading import fill_reconciler as fr
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
@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),
set(day_trading_state.dt_exited_today), day_trading_state.daily_pnl,
day_trading_state.loss_streak_today)
day_trading_state.reset_if_new_day()
day_trading_state.positions.clear()
day_trading_state.closed_today.clear()
day_trading_state.dt_exited_today.clear()
day_trading_state.daily_pnl = 0.0
day_trading_state.loss_streak_today = 0
colls = (es._stale_sell_attempted, es._stale_sell_failures, es._last_exit_order_outcome,
es._unresolved_exit_unknown, es._stale_cleaned, es._exit_tokens,
es._recent_dt_sell_finalized, es._manual_alerted, es._once_alerted,
es._post_close_check_scheduled)
for coll in colls:
coll.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.dt_exited_today.clear()
day_trading_state.dt_exited_today.update(saved[2])
day_trading_state.daily_pnl = saved[3]
day_trading_state.loss_streak_today = saved[4]
for coll in colls:
coll.clear()
day_trading_state.exits_in_progress.clear()
@pytest.fixture
def exit_env():
"""공통 종단 safe_place_order 접수 + 큐·캐시·loss_tracker 목."""
import app.services.trade_executor as te
import app.services.portfolio_cache_bridge as pcb
rec = AsyncMock()
with patch.object(te, "safe_place_order",
AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "0000123"}})), \
patch.object(es, "enqueue_trade_history", AsyncMock()), \
patch.object(es, "enqueue_close_active_trade", AsyncMock()), \
patch("app.services.db_worker.enqueue_active_trade_upsert", AsyncMock()), \
patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()), \
patch.object(pcb, "reflect_sell", AsyncMock()), \
patch.object(es.loss_tracker, "record_trade_result_safe", rec), \
patch.object(es.loss_tracker, "save_to_db", AsyncMock()):
yield rec
def _pos(qty=10, entry=10_000.0, name="테스트"):
return {"stock_name": name, "entry_price": entry, "quantity": qty,
"stop_loss": entry * 0.97, "target_price": entry * 1.045, "highest_price": entry,
"order_id": "0000999", "entry_time": datetime.now(), "reason": "ORB"}
def _row(qty, filled, avg, rmn):
return [{"odno": "123", "pdno": "777777", "ord_qty": str(qty), "tot_ccld_qty": str(filled),
"avg_prvs": str(avg), "rmn_qty": str(rmn), "cncl_yn": "N"}]
# ═══ R2-3 ETF/ETN 거래세 0 ═══════════════════════════════════════════════════
@pytest.mark.parametrize("name", ["PLUS 200", "KIWOOM 200TR", "PLUS K방산", "KODEX 200",
"TIGER 미국S&P500", "삼성 인버스 2X WTI원유 선물 ETN"])
def test_etf_etn_sell_tax_zero(name):
assert es._dt_sell_tax_rate({"stock_name": name}) == 0.0
@pytest.mark.parametrize("name", ["삼성전자", "KB금융", "SK하이닉스", "", None])
def test_stock_sell_tax_unchanged(name):
assert es._dt_sell_tax_rate({"stock_name": name}) == float(settings.KR_SELL_TAX_RATE_KOSPI)
assert es._dt_sell_tax_rate({"stock_name": name, "market": "KOSDAQ"}) == \
float(settings.KR_SELL_TAX_RATE_KOSDAQ)
def test_etf_net_pnl_has_commission_only():
"""10/06 PLUS 200 재현: 비용은 수수료뿐(거래세 812원 없음)."""
r = es.dt_net_pnl(113_285.0, 112_840.0, 4, {"stock_name": "PLUS 200"})
fee = float(settings.KR_COMMISSION_RATE)
expected_cost = (113_285.0 * 4 + 112_840.0 * 4) * fee
assert abs(r["total_cost"] - expected_cost) <= 2.0
stock = es.dt_net_pnl(113_285.0, 112_840.0, 4, {"stock_name": "삼성전자"})
assert stock["total_cost"] - r["total_cost"] >= 112_840.0 * 4 * float(settings.KR_SELL_TAX_RATE_KOSPI) - 2
# ═══ R2-4 왕복 순손익 승패 ═══════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_partial_profit_then_small_loss_counts_as_round_trip_win(exit_env):
"""+1.5R 분할 익절 뒤 잔량 소폭 손실 청산 → 왕복 이익 → 연패 리셋·loss_tracker 이익."""
day_trading_state.positions["777777"] = _pos(10)
day_trading_state.loss_streak_today = 2
with patch.object(es, "_in_closing_auction_impl", return_value=False):
r1 = await es._execute_exit("777777", day_trading_state.positions["777777"], 10_450.0,
"목표가 도달", sell_pct=0.5)
assert r1["profit_loss"] > 0
assert day_trading_state.positions["777777"]["realized_net"] == pytest.approx(r1["profit_loss"])
assert exit_env.await_count == 0 # 분할 레그는 기록 안 함
r2 = await es._execute_exit("777777", day_trading_state.positions["777777"], 9_990.0,
"트레일링 스탑")
assert r2["profit_loss"] < 0 # 마지막 레그만 보면 손실
assert r1["profit_loss"] + r2["profit_loss"] > 0 # 왕복은 이익
exit_env.assert_awaited_once_with("777777", False)
assert day_trading_state.loss_streak_today == 0
assert day_trading_state.daily_pnl == pytest.approx(r1["profit_loss"] + r2["profit_loss"])
@pytest.mark.asyncio
async def test_round_trip_loss_still_counts_as_loss(exit_env):
day_trading_state.positions["777777"] = _pos(10)
with patch.object(es, "_in_closing_auction_impl", return_value=False):
await es._execute_exit("777777", day_trading_state.positions["777777"], 10_050.0,
"목표가 도달", sell_pct=0.5)
await es._execute_exit("777777", day_trading_state.positions["777777"], 9_700.0, "손절")
exit_env.assert_awaited_once_with("777777", True)
assert day_trading_state.loss_streak_today == 1
@pytest.mark.asyncio
async def test_no_partial_keeps_last_leg_rule(exit_env):
day_trading_state.positions["777777"] = _pos(10)
with patch.object(es, "_in_closing_auction_impl", return_value=False):
r = await es._execute_exit("777777", day_trading_state.positions["777777"], 10_010.0, "손절")
assert r["profit_loss"] < 0 # 비용 차감 후 손실
exit_env.assert_awaited_once_with("777777", True)
assert day_trading_state.loss_streak_today == 1
# ═══ R2-1 종가 단일가 매도 — 체결 확인 후 loss_tracker ═════════════════════════
@pytest.mark.asyncio
async def test_closing_auction_sell_records_loss_on_confirmed_fill(exit_env):
"""15:20 신호가로는 이익이지만 15:30 체결가로 손실 → 체결 확인 후 '손실'로 1회 기록."""
day_trading_state.positions["777777"] = _pos(10)
calls = {"n": 0}
async def _orders(code):
calls["n"] += 1
# 장 마감 전(즉시 확인) 구간에서는 loss_tracker 가 아직 기록되면 안 된다
if calls["n"] <= int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS):
assert exit_env.await_count == 0
return _row(10, 0, 0, 10) # 미체결·미종결
return _row(10, 10, 9_950, 0) # 15:30 종가 체결
with patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True), \
patch.object(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0), \
patch.object(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0), \
patch.object(es, "_in_closing_auction_impl", return_value=True), \
patch.object(es, "_auction_recheck_params_impl", return_value=(0.0, 3, 0.0)), \
patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \
patch.object(fr.korea_investment_api, "get_todays_orders", _orders):
r = await es._execute_exit("777777", day_trading_state.positions["777777"], 10_050.0,
"EOD 강제 청산")
assert r["profit_loss"] > 0 # 신호가 기준은 이익
await asyncio.gather(*list(es._auction_fill_tasks))
exit_env.assert_awaited_once_with("777777", True)
expected = es.dt_net_pnl(10_000.0, 9_950.0, 10, _pos())["net_profit_loss"]
assert day_trading_state.daily_pnl == pytest.approx(expected)
@pytest.mark.asyncio
async def test_closing_auction_unconfirmed_falls_back_to_signal_price(exit_env):
"""재확인까지 주문 행을 못 찾으면 신호가 기준으로 기록(구 동작) — 기록 누락 없음."""
day_trading_state.positions["777777"] = _pos(10)
with patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True), \
patch.object(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0), \
patch.object(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0), \
patch.object(es, "_in_closing_auction_impl", return_value=True), \
patch.object(es, "_auction_recheck_params_impl", return_value=(0.0, 2, 0.0)), \
patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \
patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[])):
await es._execute_exit("777777", day_trading_state.positions["777777"], 10_050.0,
"EOD 강제 청산")
await asyncio.gather(*list(es._auction_fill_tasks))
exit_env.assert_awaited_once_with("777777", False)
@pytest.mark.asyncio
async def test_closing_auction_zero_fill_does_not_record(exit_env):
"""종가 단일가 매도가 0주 체결로 종결 → 포지션 복원, loss_tracker 기록하지 않음."""
day_trading_state.positions["777777"] = _pos(10)
rows = [{"odno": "123", "pdno": "777777", "ord_qty": "10", "tot_ccld_qty": "0",
"avg_prvs": "0", "rmn_qty": "0", "cncl_yn": "Y"}]
with patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True), \
patch.object(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0), \
patch.object(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0), \
patch.object(es, "_in_closing_auction_impl", return_value=True), \
patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \
patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
await es._execute_exit("777777", day_trading_state.positions["777777"], 10_050.0,
"EOD 강제 청산")
await asyncio.gather(*list(es._auction_fill_tasks))
assert exit_env.await_count == 0
assert day_trading_state.positions["777777"]["quantity"] == 10
@pytest.mark.asyncio
async def test_time_stop_in_auction_still_not_recorded(exit_env):
day_trading_state.positions["777777"] = _pos(10)
with patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", False), \
patch.object(es, "_in_closing_auction_impl", return_value=True):
await es._execute_exit("777777", day_trading_state.positions["777777"], 9_960.0,
"시간 손절 (테스트)")
assert exit_env.await_count == 0
def test_closing_auction_window_bounds():
d = datetime(2026, 10, 6)
assert not es._in_closing_auction_impl(d.replace(hour=15, minute=19, second=59))
assert es._in_closing_auction_impl(d.replace(hour=15, minute=20))
assert es._in_closing_auction_impl(d.replace(hour=15, minute=29, second=59))
assert not es._in_closing_auction_impl(d.replace(hour=15, minute=30))
# ═══ R2-5 강제청산 미청산 경보 ═══════════════════════════════════════════════
@pytest.mark.asyncio
async def test_force_close_none_result_retried_and_alerted(_clean):
day_trading_state.positions["888888"] = _pos(5)
exec_mock = AsyncMock(return_value=None) # KIS 거부 등 — 예외 없이 None
with patch.object(es, "_execute_exit_impl", exec_mock), \
patch.object(es, "is_eod_flat_time", return_value=False), \
patch.object(es, "close_stale_positions", _boom("close_stale_positions")), \
patch.object(es.korea_investment_api, "get_current_price",
AsyncMock(return_value={"output": {"stck_prpr": "10000"}})):
out = await es.force_close_all(reason="EOD 강제 청산")
await asyncio.gather(*list(es._alert_tasks))
assert out == []
assert exec_mock.await_count == 2 # 1회 + 재시도 1회
tags = [c.args[0] for c in _clean.await_args_list]
assert tags == ["DT_EOD"]
assert "888888" in _clean.await_args_list[0].args[1]
@pytest.mark.asyncio
async def test_post_close_residual_check_alerts(_clean):
assert es.check_post_close_residual_v2() == []
day_trading_state.positions["888888"] = _pos(5)
assert es.check_post_close_residual_v2() == ["888888"]
es.check_post_close_residual_v2() # 같은 날 같은 잔존 → 1회만
await asyncio.gather(*list(es._alert_tasks))
assert [c.args[0] for c in _clean.await_args_list] == ["DT_EOD"]
@pytest.mark.asyncio
async def test_eod_force_close_schedules_post_close_check_once(_clean):
day_trading_state.positions["888888"] = _pos(5)
day_trading_state.exits_in_progress.add("888888") # 불명 보류 중 → 이번 회차 skip
with patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")), \
patch.object(es, "is_eod_flat_time", return_value=True), \
patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \
patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")):
await es.force_close_all(reason="EOD 강제 청산")
await es.force_close_all(reason="EOD 강제 청산")
assert len(es._post_close_tasks) <= 1
await asyncio.gather(*list(es._post_close_tasks))
await asyncio.gather(*list(es._alert_tasks))
assert [c.args[0] for c in _clean.await_args_list] == ["DT_EOD"]