- 스윙 체결가 확인 전 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
246 lines
11 KiB
Python
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 # 손실 기록 유지(보수적)
|