- 스윙 체결가 확인 전 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
382 lines
18 KiB
Python
382 lines
18 KiB
Python
"""DT 청산 하드닝 라운드 3 회귀 테스트 (2026-10-09, daytrading/exit_service.py).
|
|
|
|
R3-1 KIS 잔고 첫 페이지가 가득 찼는데 종목이 없으면 '0주'가 아니라 '판정 불가'(보류)
|
|
R3-2 장 마감 후 잔존 점검은 종가 매도 재확인이 끝난 뒤에, 장 마감 후 0체결 복원은 텔레그램
|
|
R3-3 청산 판정 실패(상한 초과·예외·판정 불가)가 N 사이클 연속이면 텔레그램 + 헬스 노출
|
|
R3-4 폴백 손절 대체가는 나이 상한 안의 KIS 관측가만 — 낡은 값/시각 없는 값은 '판정 불가'
|
|
|
|
목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`_execute_exit_impl`)에, 호출되면 안 되는 경로는
|
|
BaseException 발생형. 기대값은 settings.* / 모듈 헬퍼에서 계산한다.
|
|
"""
|
|
import asyncio
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
from types import SimpleNamespace
|
|
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.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
|
|
|
|
|
|
def _fail(name):
|
|
"""프로덕션이 처리해야 하는 일반 실패(KIS 장애 재현)."""
|
|
async def _f(*a, **k):
|
|
raise RuntimeError(f"KIS 장애 재현: {name}")
|
|
return _f
|
|
|
|
|
|
_COLLS = (
|
|
"_stale_sell_attempted", "_stale_sell_failures", "_last_exit_order_outcome",
|
|
"_unresolved_exit_unknown", "_stale_cleaned", "_exit_tokens", "_recent_dt_sell_finalized",
|
|
"_manual_alerted", "_once_alerted", "_post_close_check_scheduled", "_price_seen_mono",
|
|
"_exit_check_failures", "_exit_stall_alerted", "_rest_price_seen",
|
|
)
|
|
|
|
|
|
@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.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()
|
|
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]
|
|
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, entry_time=None):
|
|
return {"stock_name": "테스트", "strategy": "VWAP", "entry_price": entry, "quantity": qty,
|
|
"stop_loss": stop, "target_price": entry * 1.045, "highest_price": entry,
|
|
"entry_time": entry_time or es._kst_now(), "reason": "", "order_id": "0000999"}
|
|
|
|
|
|
def _full_page(extra=None, n_other=None):
|
|
"""첫 페이지가 가득 찬 잔고 응답 — 다른 종목 n 행(+ extra 행)."""
|
|
full = int(settings.SWING_BALANCE_FULL_PAGE_ROWS)
|
|
rows = [{"pdno": f"9{i:05d}", "hldg_qty": "1", "ord_psbl_qty": "1"}
|
|
for i in range(full if n_other is None else n_other)]
|
|
if extra:
|
|
rows = rows[: max(0, len(rows) - len(extra))] + extra
|
|
return AsyncMock(return_value={"rt_cd": "0", "output1": rows, "output2": [{}]})
|
|
|
|
|
|
class _SeqSession:
|
|
def __init__(self, results):
|
|
self._results = list(results)
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
async def execute(self, *a, **k):
|
|
rows = self._results.pop(0) if self._results else []
|
|
return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows))
|
|
|
|
|
|
def _sess(*results):
|
|
return lambda: _SeqSession(results)
|
|
|
|
|
|
# ═══ R3-1 잔고 첫 페이지 가득 참 ═══════════════════════════════════════════════
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_holdings_v3_flags_incomplete_and_v2_returns_none_on_full_page():
|
|
with patch.object(es.korea_investment_api, "get_balance", _full_page()):
|
|
h, complete = await es._fetch_kis_holdings_v3_impl()
|
|
assert h is not None and complete is False
|
|
# v2(entry_service 결과 불명 매수 판정 경로)는 '미확인'으로 — hldg==0 미접수 오판 방지
|
|
assert await es._fetch_kis_holdings_v2_impl() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_holdings_v2_unchanged_below_full_page():
|
|
n = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) - 1
|
|
with patch.object(es.korea_investment_api, "get_balance", _full_page(n_other=n)):
|
|
h, complete = await es._fetch_kis_holdings_v3_impl()
|
|
assert complete is True
|
|
h2 = await es._fetch_kis_holdings_v2_impl()
|
|
assert h2 is not None and len(h2) == n
|
|
assert (h2.get("064400") or {}).get("hldg", 0) == 0 # 페이지가 덜 찼으면 '없음 = 0주' 유지
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_missing_on_full_page_is_not_closed(_clean):
|
|
"""2페이지에 있을 수 있는 전일 DT 종목 → 매도 없이 ActiveTrade 정리 금지, 수동 경보."""
|
|
day_trading_state.positions["064400"] = _p = _pos(entry_time=es._kst_now() - timedelta(days=1))
|
|
with patch.object(es, "is_market_open", return_value=True), \
|
|
patch("app.core.database.AsyncSessionLocal", _sess([], [])), \
|
|
patch.object(es.korea_investment_api, "get_balance", _full_page()), \
|
|
patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \
|
|
patch.object(es, "enqueue_close_active_trade", _boom("enqueue_close_active_trade")), \
|
|
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")):
|
|
assert await es.close_stale_positions() == []
|
|
await _drain_alerts()
|
|
assert day_trading_state.positions.get("064400") is _p
|
|
assert "064400" not in day_trading_state.closed_today
|
|
assert not es._stale_cleaned
|
|
assert _tags(_clean) == ["DT_MANUAL_CHECK"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_found_on_full_page_still_sold():
|
|
"""페이지가 가득 차도 응답에 있는 종목은 수량을 믿고 정상 청산(§3.5 차감 유지)."""
|
|
day_trading_state.positions["064400"] = _pos(qty=4, entry_time=es._kst_now() - timedelta(days=1))
|
|
exec_mock = AsyncMock(return_value={"stock_code": "064400"})
|
|
bal = _full_page(extra=[{"pdno": "064400", "hldg_qty": "4", "ord_psbl_qty": "4"}])
|
|
with patch.object(es, "is_market_open", return_value=True), \
|
|
patch("app.core.database.AsyncSessionLocal", _sess([], [])), \
|
|
patch.object(es.korea_investment_api, "get_balance", bal), \
|
|
patch.object(es.korea_investment_api, "get_current_price",
|
|
AsyncMock(return_value={"output": {"stck_prpr": "10000"}})), \
|
|
patch.object(es, "_execute_exit_impl", exec_mock):
|
|
await es.close_stale_positions()
|
|
assert exec_mock.await_count == 1
|
|
assert exec_mock.await_args.args[1]["quantity"] == 4
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_exit_missing_on_full_page_is_lookup_failed():
|
|
"""결과 불명 매도: 미발견+페이지 가득 → 'accepted'(인메모리 제거) 금지, 판정 보류."""
|
|
with patch.object(es.korea_investment_api, "get_balance", _full_page()), \
|
|
patch.object(es, "_fetch_non_dt_active_qty_impl", _boom("_fetch_non_dt_active_qty_impl")), \
|
|
patch.object(es, "_lookup_todays_orders_impl", _boom("_lookup_todays_orders_impl")):
|
|
status, resp, dt_sellable = await es._resolve_unknown_exit_v2_impl(
|
|
"064400", 4, es._kst_now(), 4,
|
|
)
|
|
assert (status, resp, dt_sellable) == ("lookup_failed", None, 0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_exit_missing_on_partial_page_still_accepted():
|
|
"""페이지가 덜 찼으면 기존 판정 유지(미발견 = 0주 → 접수)."""
|
|
n = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) - 1
|
|
with patch.object(es.korea_investment_api, "get_balance", _full_page(n_other=n)), \
|
|
patch.object(es, "_fetch_non_dt_active_qty_impl", AsyncMock(return_value=0)), \
|
|
patch.object(es, "_lookup_todays_orders_impl", AsyncMock(return_value=[])):
|
|
status, _resp, _ = await es._resolve_unknown_exit_v2_impl("064400", 4, es._kst_now(), 4)
|
|
assert status == "accepted"
|
|
|
|
|
|
# ═══ R3-2 장 마감 후 잔존 점검 순서 + 0체결 복원 경보 ═══════════════════════════
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_close_residual_waits_for_auction_recheck(_clean):
|
|
"""재확인이 0체결로 포지션을 되살리기 전에 '잔존 없음'을 확정하지 않는다."""
|
|
async def _recheck():
|
|
await asyncio.sleep(0.05) # get_todays_orders 왕복 재현
|
|
day_trading_state.positions["105560"] = _pos(qty=3)
|
|
|
|
t = asyncio.get_running_loop().create_task(_recheck())
|
|
es._auction_fill_tasks.add(t)
|
|
t.add_done_callback(es._auction_fill_tasks.discard)
|
|
with patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0):
|
|
es._schedule_post_close_residual_check_impl()
|
|
await asyncio.gather(*list(es._post_close_tasks))
|
|
await _drain_alerts()
|
|
assert _tags(_clean) == ["DT_EOD"]
|
|
assert "105560" in _clean.await_args_list[0].args[1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_close_wait_is_bounded():
|
|
assert es._auction_recheck_wait_cap_impl() > 0
|
|
never = asyncio.get_running_loop().create_task(asyncio.sleep(3600))
|
|
es._auction_fill_tasks.add(never)
|
|
try:
|
|
with patch.object(es, "_auction_recheck_wait_cap_impl", return_value=0.05):
|
|
await asyncio.wait_for(es._await_auction_rechecks_impl(), timeout=2.0)
|
|
finally:
|
|
never.cancel()
|
|
es._auction_fill_tasks.discard(never)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unfilled_restore_after_close_sends_telegram(_clean):
|
|
"""15:30 이후 0체결 종결 → 인메모리 복원 + DT_EOD 텔레그램(오버나이트 확정)."""
|
|
after_close = datetime.combine(es._kst_now().date(), es._KRX_MARKET_CLOSE) + timedelta(minutes=1)
|
|
info = {"terminal": True, "filled_qty": 0, "avg_price": 0}
|
|
pos = _pos(qty=3)
|
|
with patch.object(es, "_kst_now", return_value=after_close), \
|
|
patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \
|
|
patch.object(es.fill_reconciler, "fetch_fill_v2", AsyncMock(return_value=info)), \
|
|
patch.object(es, "_enqueue_restored_active_trade_impl", AsyncMock()), \
|
|
patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()), \
|
|
patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True):
|
|
task = es._schedule_sell_fill_reconcile_v2(
|
|
stock_code="105560", pos=pos, order_id="0000411", signal_price=80_000.0, quantity=3,
|
|
entry_price=10_000.0, recorded_net=0.0, count_daily_pnl=True, closing_auction=True,
|
|
)
|
|
assert task is not None
|
|
await task
|
|
await _drain_alerts()
|
|
assert day_trading_state.positions["105560"]["quantity"] == 3
|
|
assert _tags(_clean) == ["DT_EOD"]
|
|
assert "105560" in _clean.await_args_list[0].args[1]
|
|
|
|
|
|
# ═══ R3-3 청산 판정 실패 연속 → 경보 ═══════════════════════════════════════════
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bounded_v2_reports_timeout_and_exception():
|
|
async def _slow(code):
|
|
await asyncio.sleep(1.0)
|
|
|
|
async def _err(code):
|
|
raise RuntimeError("x")
|
|
|
|
out, failed = await es._run_exit_checks_bounded_v2_impl(["A"], _slow, 0.05, "t")
|
|
assert out == {} and failed == {"A"}
|
|
out, failed = await es._run_exit_checks_bounded_v2_impl(["B"], _err, 1.0, "t")
|
|
assert failed == {"B"}
|
|
# 구 시그니처는 dict 만 반환(호환)
|
|
assert await es._run_exit_checks_bounded_impl(["C"], _err, 1.0, "t") == {}
|
|
|
|
|
|
def _cycle_patches(price_mock, chart_mock):
|
|
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", price_mock),
|
|
patch.object(es.korea_investment_api, "get_minute_chart_full_day", chart_mock),
|
|
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exit_check_stall_alerts_after_n_cycles_then_resets(_clean):
|
|
day_trading_state.positions["222222"] = _pos(qty=4)
|
|
n = es._exit_check_fail_alert_cycles_impl()
|
|
p = _cycle_patches(_fail("get_current_price"), _fail("get_minute_chart_full_day"))
|
|
with p[0], p[1], p[2], p[3], p[4], p[5]:
|
|
for i in range(n - 1):
|
|
assert await es.check_and_exit_positions() == []
|
|
await _drain_alerts()
|
|
assert _tags(_clean) == [] # N-1 사이클까지는 경보 없음
|
|
assert es.get_exit_check_health_v2()["failing"] == {"222222": n - 1}
|
|
await es.check_and_exit_positions()
|
|
await es.check_and_exit_positions() # 같은 연속 구간 → 1회만
|
|
await _drain_alerts()
|
|
assert _tags(_clean) == ["DT_EXIT_STALL"]
|
|
assert es.get_exit_check_health_v2()["stalled"] == ["222222"]
|
|
|
|
# 시세 회복(손절 미도달 판정 성공) → 카운터·경보 상태 해제
|
|
ok_price = AsyncMock(return_value={"output": {"stck_prpr": "10000"}})
|
|
p = _cycle_patches(ok_price, _fail("get_minute_chart_full_day"))
|
|
with p[0], p[1], p[2], p[3], p[4], p[5]:
|
|
await es.check_and_exit_positions()
|
|
assert es.get_exit_check_health_v2()["failing"] == {}
|
|
assert not es._exit_stall_alerted
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_orb_window_price_failure_counts_as_check_failure(_clean):
|
|
day_trading_state.positions["333333"] = _pos(qty=4)
|
|
n = es._exit_check_fail_alert_cycles_impl()
|
|
with patch.object(es, "close_stale_positions", AsyncMock(return_value=[])), \
|
|
patch.object(es.korea_investment_api, "get_current_price", _fail("get_current_price")), \
|
|
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")):
|
|
for _ in range(n):
|
|
await es.check_stops_only_v2()
|
|
await _drain_alerts()
|
|
assert _tags(_clean) == ["DT_EXIT_STALL"]
|
|
|
|
|
|
# ═══ R3-4 폴백 손절 대체가 나이 ═══════════════════════════════════════════════
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_ignores_untimed_position_price():
|
|
"""pos['current_price'] 만 있고 관측 시각이 없으면 '미도달'이 아니라 '판정 불가'."""
|
|
pos = _pos(qty=4)
|
|
pos["current_price"] = 10_000.0 # 손절가(9,700)보다 높은 낡은 값
|
|
day_trading_state.positions["444444"] = pos
|
|
with patch.object(es.korea_investment_api, "get_current_price", _fail("get_current_price")), \
|
|
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")):
|
|
out, failed = await es._run_exit_checks_bounded_v2_impl(
|
|
["444444"], lambda c: es._check_fallback_stop_loss(c, pos, 9_700.0), 5.0, "t",
|
|
)
|
|
assert out == {"444444": None}
|
|
assert failed == {"444444"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_rejects_cached_price_older_than_max_age():
|
|
pos = _pos(qty=4)
|
|
day_trading_state.positions["444444"] = pos
|
|
max_age = es._dt_fallback_price_max_age_impl()
|
|
es._price_seen_mono["444444"] = (10_000.0, time.monotonic() - max_age - 5.0)
|
|
with patch.object(es.korea_investment_api, "get_current_price", _fail("get_current_price")), \
|
|
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")):
|
|
_out, failed = await es._run_exit_checks_bounded_v2_impl(
|
|
["444444"], lambda c: es._check_fallback_stop_loss(c, pos, 9_700.0), 5.0, "t",
|
|
)
|
|
assert failed == {"444444"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_uses_fresh_cached_price_and_stops():
|
|
"""나이 상한 안의 관측가는 쓴다 — 손절선 이탈이면 전량 청산(공통 종단 도달)."""
|
|
pos = _pos(qty=4)
|
|
day_trading_state.positions["444444"] = pos
|
|
es._remember_price_impl("444444", 9_600.0)
|
|
exec_mock = AsyncMock(return_value={"stock_code": "444444", "quantity": 4})
|
|
with patch.object(es.korea_investment_api, "get_current_price", _fail("get_current_price")), \
|
|
patch.object(es, "_execute_exit_impl", exec_mock):
|
|
out, failed = await es._run_exit_checks_bounded_v2_impl(
|
|
["444444"], lambda c: es._check_fallback_stop_loss(c, pos, 9_700.0), 5.0, "t",
|
|
)
|
|
assert failed == set()
|
|
assert exec_mock.await_count == 1
|
|
assert out["444444"]["quantity"] == 4
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fast_stop_records_observed_price():
|
|
day_trading_state.positions["555555"] = _pos(qty=4)
|
|
with patch.object(es.korea_investment_api, "get_current_price",
|
|
AsyncMock(return_value={"output": {"stck_prpr": "10100"}})), \
|
|
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")):
|
|
assert await es._fast_stop_check_impl("555555") == (False, None)
|
|
assert es._price_seen_mono["555555"][0] == 10_100.0
|