Files
auto-stock/backend/tests/test_dt_exit_hardening_loop_r2_20261009.py
shuim a264561576 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
2026-10-10 12:17:49 +09:00

246 lines
11 KiB
Python

"""DT 청산 하드닝 루프 라운드 2 회귀 테스트 (2026-10-09, daytrading/exit_service.py).
HL2-1 매도 0체결 복원 ActiveTrade payload 에 원 진입 시각(created_at)·매수 ODNO(order_id)를 싣는다.
HL2-2 0체결 복원 포지션 재청산 시 같은 왕복을 loss_tracker·당일 연패에 두 번 세지 않고,
부분체결 복원이면 체결 레그 순손익을 realized_net(왕복 누적)에 반영한다.
목킹 원칙(CLAUDE.md §4.1): 주문 공통 종단(`safe_place_order`)은 BaseException 발생형으로 막고,
호출되면 안 되는 loss_tracker 기록도 예외 발생형으로 둔다. 기대값은 settings.* / 모듈 헬퍼에서 계산.
"""
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytz
from app.core.config import settings
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
CODE = "333333"
BUY_ODNO = "0000999"
_COLLS = (
"_stale_sell_attempted", "_stale_sell_failures", "_last_exit_order_outcome",
"_unresolved_exit_unknown", "_stale_cleaned", "_exit_tokens", "_recent_dt_sell_finalized",
"_unknown_lookup_failures",
)
@pytest.fixture(autouse=True)
def _clean():
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
for name in _COLLS:
getattr(es, name).clear()
day_trading_state.exits_in_progress.clear()
with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \
patch("app.services.trade_executor.safe_place_order", _boom("safe_place_order")), \
patch.object(es, "_alert_once_v2", MagicMock()), \
patch.object(es, "_in_closing_auction_impl", return_value=False), \
patch.object(es, "enqueue_trade_history", AsyncMock()), \
patch.object(es, "enqueue_close_active_trade", AsyncMock()), \
patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()), \
patch("app.services.portfolio_cache_bridge.reflect_sell", AsyncMock()):
yield
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 name in _COLLS:
getattr(es, name).clear()
day_trading_state.exits_in_progress.clear()
ENTRY = 10_000.0
STOP = ENTRY * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT)
def _pos(qty=4, entry_time=None):
risk = ENTRY - STOP
return {"stock_name": "테스트", "strategy": "VWAP", "entry_price": ENTRY, "quantity": qty,
"stop_loss": STOP, "target_price": ENTRY + risk * settings.DAY_TRADE_TARGET_MULTIPLIER,
"highest_price": ENTRY,
"entry_time": entry_time or (es._kst_now() - timedelta(minutes=12)).replace(microsecond=0),
"reason": "VWAP 반등", "order_id": BUY_ODNO}
async def _full_exit(pos, price, reason, *, rec, upserts, sell_odno="0000777"):
"""전량 청산 접수 반영 → fill_reconciler 콜백을 캡처해 돌려준다."""
captured = {}
def _sched(code, order_id, side, qty, cb):
captured["cb"] = cb
return object()
with patch.object(es.fill_reconciler, "schedule_v2", _sched), \
patch("app.services.db_worker.enqueue_active_trade_upsert", upserts), \
patch.object(es.loss_tracker, "record_trade_result_safe", rec), \
patch.object(es.loss_tracker, "save_to_db", AsyncMock()):
qty = int(pos["quantity"])
res = await es._finalize_exit_impl(
CODE, pos, price, reason, 1.0, {"output": {"ODNO": sell_odno}}, qty, qty, True,
)
assert res is not None
return captured["cb"]
async def _deliver(cb, info, upserts):
with patch("app.services.db_worker.enqueue_active_trade_upsert", upserts):
await cb(info)
# ═══ HL2-1 복원 ActiveTrade 에 원 진입 시각·ODNO ═══════════════════════════════
@pytest.mark.asyncio
async def test_zero_fill_restore_payload_carries_entry_time_and_order_id():
pos = _pos(qty=4)
orig_entry_time = pos["entry_time"]
day_trading_state.positions[CODE] = pos
upserts = AsyncMock()
rec = AsyncMock()
cb = await _full_exit(dict(pos), STOP - 10, "손절 이탈", rec=rec, upserts=upserts)
await _deliver(cb, {"terminal": True, "filled_qty": 0, "avg_price": 0}, upserts)
payload = upserts.await_args.args[0]
assert payload["status"] == "active" and payload["quantity"] == 4
assert payload["created_at"] == orig_entry_time
assert payload["order_id"] == BUY_ODNO
assert day_trading_state.positions[CODE]["entry_time"] == orig_entry_time
@pytest.mark.asyncio
async def test_restore_payload_without_entry_time_omits_created_at():
pos = _pos(qty=2)
pos["entry_time"] = None
pos["order_id"] = ""
upserts = AsyncMock()
with patch("app.services.db_worker.enqueue_active_trade_upsert", upserts):
await es._enqueue_restored_active_trade_impl(CODE, pos)
payload = upserts.await_args.args[0]
assert "created_at" not in payload # 모델 기본값(now_kst) — 지어내지 않는다
assert "order_id" not in payload
def test_restored_created_at_normalizes_aware_and_rejects_future():
kst = pytz.timezone("Asia/Seoul")
naive = (es._kst_now() - timedelta(minutes=5)).replace(microsecond=0)
aware_utc = kst.localize(naive).astimezone(pytz.utc)
assert es._restored_created_at_impl(aware_utc) == naive
assert es._restored_created_at_impl(naive) == naive
assert es._restored_created_at_impl(es._kst_now() + timedelta(hours=1)) is None
assert es._restored_created_at_impl("2026-10-09T09:10:00") is None
# ═══ HL2-2 재청산 이중 집계 방지 ═══════════════════════════════════════════════
@pytest.mark.asyncio
async def test_restored_position_reexit_does_not_double_count_loss():
pos = _pos(qty=4)
day_trading_state.positions[CODE] = pos
upserts = AsyncMock()
rec1 = AsyncMock()
cb = await _full_exit(dict(pos), STOP - 10, "손절 이탈", rec=rec1, upserts=upserts)
assert rec1.await_count == 1 and rec1.await_args.args == (CODE, True)
assert day_trading_state.loss_streak_today == 1
await _deliver(cb, {"terminal": True, "filled_qty": 0, "avg_price": 0}, upserts)
cur = day_trading_state.positions[CODE]
assert cur[es._RESTORED_STREAK_MARK_KEY] is True
assert cur[es._RESTORED_LOSS_TRACKER_MARK_KEY] is True
assert es._FULL_EXIT_RECORD_KEY not in cur
pnl_after_restore = day_trading_state.daily_pnl
assert pnl_after_restore == pytest.approx(0.0) # 0체결 → 접수 시점 손익 되돌림
# 재청산(손실) — loss_tracker 기록은 호출되면 안 된다, 연패도 그대로
await _full_exit(dict(cur), STOP - 20, "손절 이탈", rec=_boom("loss_tracker 재기록"),
upserts=upserts, sell_odno="0000778")
assert day_trading_state.loss_streak_today == 1
leg = es.dt_net_pnl(ENTRY, STOP - 20, 4, cur)["net_profit_loss"]
assert day_trading_state.daily_pnl == pytest.approx(pnl_after_restore + leg)
assert CODE not in day_trading_state.positions
@pytest.mark.asyncio
async def test_restored_after_win_record_then_loss_is_recorded():
"""접수 시 이익(비손실)으로 기록 → 0체결 복원 → 재청산 손실이면 손실은 기록한다(보수적)."""
pos = _pos(qty=4)
day_trading_state.positions[CODE] = pos
upserts = AsyncMock()
rec1 = AsyncMock()
cb = await _full_exit(dict(pos), ENTRY * 1.03, "트레일링 스탑", rec=rec1, upserts=upserts)
assert rec1.await_args.args == (CODE, False)
await _deliver(cb, {"terminal": True, "filled_qty": 0, "avg_price": 0}, upserts)
cur = day_trading_state.positions[CODE]
assert cur[es._RESTORED_LOSS_TRACKER_MARK_KEY] is False
rec2 = AsyncMock()
await _full_exit(dict(cur), STOP - 10, "손절 이탈", rec=rec2, upserts=upserts, sell_odno="0000778")
assert rec2.await_count == 1 and rec2.await_args.args == (CODE, True)
assert day_trading_state.loss_streak_today == 1
@pytest.mark.asyncio
async def test_partial_fill_restore_adds_filled_leg_to_realized_net():
pos = _pos(qty=4)
day_trading_state.positions[CODE] = pos
upserts = AsyncMock()
rec1 = AsyncMock()
fill_px = ENTRY * 1.02
cb = await _full_exit(dict(pos), fill_px, "트레일링 스탑", rec=rec1, upserts=upserts)
await _deliver(cb, {"terminal": True, "filled_qty": 1, "avg_price": fill_px}, upserts)
cur = day_trading_state.positions[CODE]
filled_leg = es.dt_net_pnl(ENTRY, fill_px, 1, pos)["net_profit_loss"]
assert cur["quantity"] == 3
assert cur["realized_net"] == pytest.approx(filled_leg)
assert upserts.await_args.args[0]["quantity"] == 3
# 잔량 3주를 소폭 손실로 청산 — 왕복(체결 레그 +잔량)이 이익이면 이전 기록(이익)과 같아 재기록 없음
small_loss_px = ENTRY * 0.999
last_leg = es.dt_net_pnl(ENTRY, small_loss_px, 3, cur)["net_profit_loss"]
assert filled_leg + last_leg > 0, "테스트 전제: 왕복은 이익"
await _full_exit(dict(cur), small_loss_px, "EOD 청산", rec=_boom("loss_tracker 재기록"),
upserts=upserts, sell_odno="0000778")
assert day_trading_state.loss_streak_today == 0
@pytest.mark.asyncio
async def test_plain_full_exit_without_marks_records_once():
"""표식 없는 일반 전량 청산은 기존대로 1회 기록(무변경 확인)."""
pos = _pos(qty=2)
day_trading_state.positions[CODE] = pos
rec = AsyncMock()
await _full_exit(dict(pos), STOP - 10, "손절 이탈", rec=rec, upserts=AsyncMock())
assert rec.await_count == 1 and rec.await_args.args == (CODE, True)
assert day_trading_state.loss_streak_today == 1
def test_should_record_outcome_rules():
f = es._should_record_outcome_impl
assert f(None, True) is True and f(None, False) is True
assert f(True, True) is False and f(False, False) is False
assert f(False, True) is True # 이익 기록 후 손실 → 손실 기록
assert f(True, False) is False # 손실 기록 유지(보수적)