- 스윙 체결가 확인 전 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
195 lines
9.0 KiB
Python
195 lines
9.0 KiB
Python
"""DT 청산 하드닝 라운드 4 회귀 테스트 (2026-10-09, daytrading/exit_service.py).
|
|
|
|
R4-1 1.5R 분할 매도가 체결 0 으로 종결되면 수량뿐 아니라 목표가·partial_sold 도 분할 전으로
|
|
되돌리고 ActiveTrade 에 remaining_pct=1.0 을 싣는다(부분체결이면 현행 유지).
|
|
R4-2 결과 불명 매도가 다음 판정에서 '접수'로 확정되면 **원 주문**의 사유·가격으로 반영한다
|
|
(이번 사이클의 판정 사유로 확정해 loss_tracker 반영 여부가 뒤집히지 않게).
|
|
|
|
목킹 원칙(CLAUDE.md §4.1): 주문 공통 종단(`safe_place_order`)은 BaseException 발생형으로 막는다.
|
|
기대값은 settings.* / 모듈 헬퍼에서 계산한다.
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from app.core.config import settings
|
|
from app.services.day_trading_strategy import TIME_STOP_REASON_PREFIX
|
|
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 = "222222"
|
|
_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.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
|
|
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("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]
|
|
for name in _COLLS:
|
|
getattr(es, name).clear()
|
|
day_trading_state.exits_in_progress.clear()
|
|
|
|
|
|
def _pos(qty=4, entry=10_000.0, stop=9_700.0):
|
|
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": es._kst_now(), "reason": "VWAP 반등",
|
|
"order_id": "0000999"}
|
|
|
|
|
|
async def _partial_leg_then_result(info):
|
|
"""분할 50% 레그를 접수 반영(_finalize_exit_impl)한 뒤 체결 확인 결과(info)를 넘긴다."""
|
|
pos = _pos(qty=4)
|
|
orig_target = pos["target_price"]
|
|
day_trading_state.positions[CODE] = pos
|
|
captured = {}
|
|
|
|
def _sched(code, order_id, side, qty, cb):
|
|
captured["cb"] = cb
|
|
return object()
|
|
|
|
upserts = AsyncMock()
|
|
with patch.object(es.fill_reconciler, "schedule_v2", _sched), \
|
|
patch.object(es, "enqueue_trade_history", AsyncMock()), \
|
|
patch("app.services.db_worker.enqueue_active_trade_upsert", upserts), \
|
|
patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()), \
|
|
patch.object(es.loss_tracker, "record_trade_result_safe", _boom("loss_tracker")):
|
|
res = await es._finalize_exit_impl(
|
|
CODE, pos, orig_target, "목표가 도달", 0.5, {"output": {"ODNO": "0000777"}},
|
|
2, 4, False,
|
|
)
|
|
assert res is not None
|
|
cur = day_trading_state.positions[CODE]
|
|
assert cur["target_price"] == float("inf") and cur["partial_sold"] is True # 접수 시점 반영
|
|
await captured["cb"](info)
|
|
return orig_target, upserts
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_leg_zero_fill_restores_target_and_partial_flag():
|
|
orig_target, upserts = await _partial_leg_then_result(
|
|
{"terminal": True, "filled_qty": 0, "avg_price": 0})
|
|
cur = day_trading_state.positions[CODE]
|
|
assert cur["quantity"] == 4
|
|
assert cur["target_price"] == pytest.approx(orig_target)
|
|
assert cur["partial_sold"] is False
|
|
assert float(cur.get("realized_net", 0.0) or 0.0) == pytest.approx(0.0)
|
|
assert es._PRE_PARTIAL_STATE_KEY not in cur
|
|
payload = upserts.await_args.args[0] # 마지막 upsert = 복원
|
|
assert payload["quantity"] == 4
|
|
assert payload["remaining_pct"] == 1.0
|
|
assert payload["take_profit"] == pytest.approx(orig_target)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_leg_partial_fill_keeps_trailing_state():
|
|
orig_target, upserts = await _partial_leg_then_result(
|
|
{"terminal": True, "filled_qty": 1, "avg_price": 10_450.0})
|
|
cur = day_trading_state.positions[CODE]
|
|
assert cur["quantity"] == 3
|
|
assert cur["target_price"] == float("inf")
|
|
assert cur["partial_sold"] is True
|
|
payload = upserts.await_args.args[0]
|
|
assert payload["remaining_pct"] < 1.0
|
|
assert "take_profit" not in payload
|
|
|
|
|
|
# ═══ R4-2 결과 불명 매도 확정은 원 주문 사유·가격으로 ═══════════════════════════════
|
|
|
|
async def _confirm_unknown_with(orig_reason, orig_price, now_reason, now_price):
|
|
pos = _pos(qty=4)
|
|
day_trading_state.positions[CODE] = pos
|
|
es._unresolved_exit_unknown[(es._today_key(), CODE)] = {
|
|
"qty": 4, "since": es._kst_now(), "total_qty": 4,
|
|
"reason": orig_reason, "price": orig_price, "sell_pct": 1.0,
|
|
}
|
|
th = AsyncMock()
|
|
rec = AsyncMock()
|
|
resolved = {"rt_cd": "0", "output": {"ODNO": "0000555"}}
|
|
with patch.object(es, "_resolve_unknown_exit_v2_impl",
|
|
AsyncMock(return_value=("accepted", resolved, 0))), \
|
|
patch.object(es, "_in_closing_auction_impl", return_value=False), \
|
|
patch.object(es.fill_reconciler, "schedule_v2", MagicMock(return_value=object())), \
|
|
patch.object(es, "enqueue_trade_history", th), \
|
|
patch.object(es, "enqueue_close_active_trade", AsyncMock()), \
|
|
patch.object(es.loss_tracker, "record_trade_result_safe", rec), \
|
|
patch.object(es.loss_tracker, "save_to_db", AsyncMock()):
|
|
res = await es._execute_exit(CODE, pos, now_price, now_reason)
|
|
return res, th.await_args.args[0], rec
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_confirmed_uses_original_stop_reason_and_price():
|
|
"""원 주문=하드 손절, 이번 판정=시간손절 → 손절로 기록되고 loss_tracker 에 들어간다."""
|
|
res, th_payload, rec = await _confirm_unknown_with(
|
|
"손절 9,700 이탈", 9_690.0, f"{TIME_STOP_REASON_PREFIX} (30분 경과)", 9_960.0)
|
|
assert res is not None and res["reason"] == "손절 9,700 이탈"
|
|
assert th_payload["exit_reason"] == "손절 9,700 이탈"
|
|
assert th_payload["order_price"] == pytest.approx(9_690.0)
|
|
assert rec.await_count == 1 and rec.await_args.args == (CODE, True)
|
|
assert CODE not in day_trading_state.positions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_confirmed_original_time_stop_stays_neutral():
|
|
"""원 주문=시간손절, 이번 판정=하드 손절 → 시간손절로 기록(loss_tracker 중립, §5.1)."""
|
|
orig = f"{TIME_STOP_REASON_PREFIX} (30분 경과)"
|
|
res, th_payload, rec = await _confirm_unknown_with(orig, 9_960.0, "손절 9,700 이탈", 9_690.0)
|
|
assert th_payload["exit_reason"] == orig
|
|
assert th_payload["order_price"] == pytest.approx(9_960.0)
|
|
assert rec.await_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_registration_stores_original_order_fields():
|
|
"""불명 등록 시 원 사유·가격·매도비율을 함께 저장한다."""
|
|
pos = _pos(qty=4)
|
|
day_trading_state.positions[CODE] = pos
|
|
unknown_resp = {"rt_cd": "", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "lost"}
|
|
with patch("app.services.trade_executor.safe_place_order", AsyncMock(return_value=unknown_resp)), \
|
|
patch.object(es, "_resolve_unknown_exit_v2_impl",
|
|
AsyncMock(return_value=("not_accepted", None, 4))):
|
|
res = await es._execute_exit(CODE, pos, 9_690.0, "손절 9,700 이탈")
|
|
assert res is None
|
|
entry = es._unresolved_exit_unknown[(es._today_key(), CODE)]
|
|
assert entry["reason"] == "손절 9,700 이탈"
|
|
assert entry["price"] == pytest.approx(9_690.0)
|
|
assert entry["sell_pct"] == 1.0
|
|
assert entry["qty"] == 4
|