Files
auto-stock/backend/tests/test_dt_exit_hardening_r1_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

420 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""DT 청산 하드닝 라운드 1 회귀 테스트 (2026-10-09, daytrading/exit_service.py).
R1-1 고아 근거를 DT 원장 순체결(BUY−SELL)>0 으로 한정 — 수동·HTS 합산분 매도 금지
R1-2 고아 매도 결과 불명이어도 후보에서 빠지지 않는다(불명 재판정 경로 재도달)
R1-3/7 KB금융(105560) 재현 — 'synced' active 행 공존·DT 원장 순보유 0 → 팔지 않고 수동 경보
R1-4 전일 포지션은 일반 청산 경로(손절 선판정·분봉·EOD·일일한도·공통 종단)에서 제외
R1-5 매도 미체결 복원 시 ActiveTrade(day_trade) 도 되살린다
R1-6 근거 없음 → error + 텔레그램(하루 1회)
R1-9 2단계 판정가 — 마지막 1분봉 이후 관측된 REST 현재가 재사용
목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`_execute_exit_impl`·`safe_place_order`)에 걸고,
호출되면 안 되는 경로는 BaseException 발생형. 기대값은 settings.* 에서 계산한다.
"""
import asyncio
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.day_trading_strategy import MinuteCandle
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
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))
async def commit(self):
raise MustNotBeCalled("commit")
def _sess(*results):
return lambda: _SeqSession(results)
def _balance(rows):
return AsyncMock(return_value={
"rt_cd": "0",
"output1": [{"pdno": c, "hldg_qty": str(h), "ord_psbl_qty": str(s)} for c, h, s in rows],
"output2": [{}],
})
KB = "105560"
def _pf(code=KB, qty=3):
return SimpleNamespace(stock_code=code, stock_name="KB금융", quantity=qty,
average_price=167_881.25, strategy_type="daytrading")
def _ev(code=KB, qty=3, days_ago=3):
return SimpleNamespace(
stock_code=code, stock_name="KB금융", quantity=qty, entry_price=166_866.0,
highest_since_entry=167_000.0, status="closed", strategy_name="day_trade",
created_at=datetime.now().replace(microsecond=0) - timedelta(days=days_ago),
)
def _th_row(code, side, qty, status="COMPLETED", executed=None):
return SimpleNamespace(stock_code=code, order_type=side, status=status,
executed_quantity=qty if executed is None else executed,
order_quantity=qty)
@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.last_total_assets)
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 coll in (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._rest_price_seen):
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.last_total_assets = saved[4]
for coll in (es._unresolved_exit_unknown, es._manual_alerted, es._rest_price_seen,
es._recent_dt_sell_finalized):
coll.clear()
async def _run(session_factory, balance, exec_mock):
with patch.object(es, "is_market_open", return_value=True), \
patch("app.core.database.AsyncSessionLocal", session_factory), \
patch.object(es.korea_investment_api, "get_balance", balance), \
patch.object(es.korea_investment_api, "get_current_price",
AsyncMock(return_value={"output": {"stck_prpr": "163400"}})), \
patch.object(es, "_execute_exit_impl", exec_mock):
out = await es.close_stale_positions()
await asyncio.sleep(0) # 경보 태스크 실행
await asyncio.sleep(0)
return out
# 쿼리 순서: ①DT active ③Portfolio DT ④DT 근거 행 ④b DT 원장 ⑤추가 비-DT
# ═══ R1-1 ════════════════════════════════════════════════════════════════════
def test_net_qty_from_history_is_conservative():
rows = [
_th_row("A", "BUY", 3), _th_row("A", "SELL", 3), # 정상 왕복 → 0
_th_row("B", "BUY", 3), _th_row("B", "SELL", 3, "CANCELLED", executed=0), # 0체결 → 3
_th_row("C", "BUY", 3), _th_row("C", "SELL", 3, "PENDING", executed=None), # 불명 = 팔림
_th_row("D", "BUY", 3, "PENDING"), # 미확인 매수 미산정
_th_row("E", "BUY", 3), _th_row("E", "SELL", 3, "FAILED"), # 실패 매도 미산정
]
net = es._dt_net_qty_from_history(rows)
assert net.get("A", 0) == 0
assert net["B"] == 3
assert net.get("C", 0) == 0
assert net.get("D", 0) == 0
assert net["E"] == 3
@pytest.mark.asyncio
async def test_manual_buy_after_normal_dt_exit_is_not_sold(_clean):
"""DT 3주 정상 청산 뒤 수동 10주 — 'daytrading' 태그·closed DT 행이 남아도 팔지 않는다."""
th = [_th_row(KB, "BUY", 3), _th_row(KB, "SELL", 3)]
out = await _run(_sess([], [_pf(qty=10)], [_ev()], th),
_balance([(KB, 10, 10)]), _boom("_execute_exit_impl"))
assert out == []
assert _clean.await_count == 1 # 수동 확인 경보
@pytest.mark.asyncio
async def test_zero_fill_cancelled_sell_is_orphan_capped_by_net(_clean):
"""근거 행 5주·Portfolio 10주라도 DT 원장 순보유(2주)까지만 판다."""
th = [_th_row(KB, "BUY", 5), _th_row(KB, "SELL", 3),
_th_row(KB, "SELL", 2, "CANCELLED", executed=0)]
exec_mock = AsyncMock(return_value={"stock_code": KB})
await _run(_sess([], [_pf(qty=10)], [_ev(qty=5)], th, []),
_balance([(KB, 10, 10)]), exec_mock)
assert exec_mock.await_count == 1
assert exec_mock.await_args.args[1]["quantity"] == 2
assert exec_mock.await_args.kwargs.get("is_stale") is True
# ═══ R1-2 ════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_orphan_with_unresolved_unknown_stays_candidate():
"""결과 불명이 남아 있어도 후보에 남아 stale 경로(불명 재판정)에 도달한다."""
es._unresolved_exit_unknown[(es._today_key(), KB)] = {
"qty": 3, "since": es._kst_now(), "total_qty": 3,
}
th = [_th_row(KB, "BUY", 3), _th_row(KB, "SELL", 3, "CANCELLED", executed=0)]
exec_mock = AsyncMock(return_value={"stock_code": KB})
await _run(_sess([], [_pf()], [_ev()], th, []), _balance([(KB, 3, 3)]), exec_mock)
assert exec_mock.await_count == 1
def test_restore_path_still_blocks_unresolved_unknown():
es._unresolved_exit_unknown[(es._today_key(), KB)] = {"qty": 1, "since": es._kst_now()}
assert es._orphan_blocked_reason(KB, es._today_key()) == "결과 불명 매도 미해결"
assert es._orphan_blocked_reason_v2(KB, es._today_key(), block_unresolved_unknown=False) is None
# ═══ R1-3 / R1-7 (KB금융 운영 상태 재현) ═════════════════════════════════════
@pytest.mark.asyncio
async def test_kb_synced_row_and_net_zero_history_alerts_instead_of_selling(_clean):
"""운영 DB(10/09): Portfolio 'daytrading' 3, DT 행 전부 closed, 'synced' active 3,
DT 원장 9/30·10/01·10/06 모두 3/3 왕복 → DT 몫 없음. 팔지 않고 수동 경보."""
th = [_th_row(KB, s, 3) for s in ("BUY", "SELL", "BUY", "SELL", "BUY", "SELL")]
out = await _run(_sess([], [_pf()], [_ev()], th), _balance([(KB, 3, 3)]),
_boom("_execute_exit_impl"))
assert out == []
assert _clean.await_count == 1
assert "105560" in _clean.await_args.args[1]
@pytest.mark.asyncio
async def test_orphan_with_synced_row_and_no_dt_available_alerts_error(_clean):
"""DT 원장상 3주 남았지만 실보유 3주 전부 'synced' 행 몫 → 팔지 않고 error+텔레그램."""
synced = SimpleNamespace(stock_code=KB, quantity=3)
th = [_th_row(KB, "BUY", 3), _th_row(KB, "SELL", 3, "CANCELLED", executed=0)]
await _run(_sess([], [_pf()], [_ev()], th, [synced]), _balance([(KB, 3, 3)]),
_boom("_execute_exit_impl"))
assert _clean.await_count == 1
@pytest.mark.asyncio
async def test_orphan_with_synced_row_sells_only_dt_share():
synced = SimpleNamespace(stock_code=KB, quantity=3)
th = [_th_row(KB, "BUY", 3), _th_row(KB, "SELL", 3, "CANCELLED", executed=0)]
exec_mock = AsyncMock(return_value={"stock_code": KB})
await _run(_sess([], [_pf(qty=6)], [_ev()], th, [synced]), _balance([(KB, 6, 6)]), exec_mock)
assert exec_mock.await_args.args[1]["quantity"] == 3
# ═══ R1-6 ════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_no_evidence_alerts_once_per_day(_clean):
for _ in range(2):
await _run(_sess([], [_pf()], [], []), _balance([(KB, 3, 3)]), _boom("_execute_exit_impl"))
assert _clean.await_count == 1
# ═══ R1-4 전일 포지션은 일반 경로 제외 ═══════════════════════════════════════
def _stale_pos(qty=10, entry=10_000.0):
return {"stock_name": "전일", "entry_price": entry, "quantity": qty,
"stop_loss": entry * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT),
"target_price": entry * 1.05, "highest_price": entry,
"entry_time": datetime.now() - timedelta(days=1), "order_id": "1"}
@pytest.mark.asyncio
async def test_fast_stop_and_minute_check_skip_stale_position():
day_trading_state.positions["111111"] = _stale_pos()
with patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \
patch.object(es.korea_investment_api, "get_minute_chart_full_day", _boom("chart")), \
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")):
assert await es._fast_stop_check_impl("111111") == (True, None)
assert await es._check_single_position("111111") is None
@pytest.mark.asyncio
async def test_common_terminus_refuses_stale_position_in_general_path():
pos = _stale_pos()
day_trading_state.positions["111111"] = pos
import app.services.trade_executor as te
with patch.object(te, "safe_place_order", _boom("safe_place_order")):
assert await es._execute_exit("111111", pos, 9_000.0, "손절") is None
assert "111111" not in day_trading_state.exits_in_progress
assert day_trading_state.daily_pnl == 0.0
@pytest.mark.asyncio
async def test_force_close_all_routes_stale_to_close_stale_positions():
day_trading_state.positions["111111"] = _stale_pos()
stale_mock = AsyncMock(return_value=[{"stock_code": "111111"}])
async def _exec(code, pos, price, reason, sell_pct, is_stale):
if not is_stale:
raise MustNotBeCalled("전일 포지션이 일반 경로로 매도됨")
return None
with patch.object(es, "_execute_exit_impl", _exec), \
patch.object(es, "close_stale_positions", stale_mock), \
patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")):
out = await es.force_close_all(reason="EOD 강제 청산")
assert stale_mock.await_count == 1
assert out == [{"stock_code": "111111"}]
@pytest.mark.asyncio
async def test_stale_overnight_gap_not_counted_in_daily_loss_limit():
"""전일 포지션 -20% 갭 미실현이 있어도 당일 -한도 강제청산이 발동하지 않는다."""
pos = _stale_pos(qty=100, entry=10_000.0)
pos["current_price"] = 8_000.0
day_trading_state.positions["111111"] = pos
day_trading_state.last_total_assets = 1_000_000.0
with patch.object(es, "_ensure_total_assets_for_exit_impl", AsyncMock(return_value=1_000_000.0)), \
patch.object(es, "force_close_all", _boom("force_close_all")), \
patch.object(es, "is_eod_flat_time", return_value=False), \
patch.object(es, "close_stale_positions", AsyncMock(return_value=[])), \
patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \
patch.object(es.korea_investment_api, "get_minute_chart_full_day", _boom("chart")), \
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")):
assert await es.check_and_exit_positions() == []
# ═══ R1-5 미체결 복원 → ActiveTrade 되살림 ═══════════════════════════════════
@pytest.fixture
def exit_env():
import app.services.trade_executor as te
import app.services.portfolio_cache_bridge as pcb
cap = {"upserts": []}
async def _ups(payload):
cap["upserts"].append(dict(payload))
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", _ups), \
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", AsyncMock()), \
patch.object(es.loss_tracker, "save_to_db", AsyncMock()), \
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):
yield cap
def _today_pos(qty=10):
return {"stock_name": "테스트", "entry_price": 10_000.0, "quantity": qty,
"stop_loss": 9_700.0, "target_price": 10_450.0, "highest_price": 10_050.0,
"order_id": "0000999", "entry_time": datetime.now(), "reason": "ORB"}
@pytest.mark.asyncio
async def test_full_exit_zero_fill_reopens_active_trade(exit_env):
day_trading_state.positions["666666"] = _today_pos(10)
rows = [{"odno": "123", "pdno": "666666", "ord_qty": "10", "tot_ccld_qty": "0",
"avg_prvs": "0", "rmn_qty": "0", "cncl_yn": "Y"}]
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
await es._execute_exit("666666", day_trading_state.positions["666666"], 9_900.0, "EOD 강제 청산")
await asyncio.gather(*list(fr._pending_tasks))
assert day_trading_state.positions["666666"]["quantity"] == 10
last = exit_env["upserts"][-1]
assert last["strategy_name"] == "day_trade" and last["status"] == "active"
assert last["quantity"] == 10 and last["entry_price"] == 10_000.0
assert last["stop_loss"] == 9_700.0 and last["take_profit"] == 10_450.0
@pytest.mark.asyncio
async def test_partial_exit_zero_fill_restores_active_trade_quantity(exit_env):
day_trading_state.positions["666666"] = _today_pos(10)
rows = [{"odno": "123", "pdno": "666666", "ord_qty": "5", "tot_ccld_qty": "0",
"avg_prvs": "0", "rmn_qty": "0", "cncl_yn": "Y"}]
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
await es._execute_exit("666666", day_trading_state.positions["666666"], 10_500.0,
"목표가 도달", sell_pct=0.5)
await asyncio.gather(*list(fr._pending_tasks))
assert day_trading_state.positions["666666"]["quantity"] == 10
# finalize 가 잔량 5로 줄인 뒤, 미체결 복원이 10 으로 되돌린다
assert [u["quantity"] for u in exit_env["upserts"]] == [5, 10]
last = exit_env["upserts"][-1]
# [R4-1 2026-10-09] 체결 0 = 분할이 없었던 것 → 목표가·partial_sold 를 분할 전으로 되돌리고
# remaining_pct=1.0 으로 영속화한다(예전 기대값 inf/<1.0 은 분할 익절 영구 소실 결함이었다).
restored = day_trading_state.positions["666666"]
assert restored["target_price"] == pytest.approx(10_450.0)
assert restored["partial_sold"] is False
assert last["take_profit"] == pytest.approx(10_450.0)
assert last["remaining_pct"] == 1.0
# ═══ R1-9 REST 현재가 재사용 ═════════════════════════════════════════════════
def _candle(dt: datetime, close: float) -> MinuteCandle:
return MinuteCandle(date=dt.strftime("%Y%m%d"), time=dt.strftime("%H%M%S"),
open=close, high=close, low=close, close=close, volume=100,
cum_volume=100, cum_amount=close * 100)
def test_fresher_rest_price_rules():
now = es._kst_now()
candles = [_candle(now - timedelta(seconds=150), 100.0)]
es._rest_price_seen["A"] = (101.0, now) # 마지막 봉 +60초 이후
assert es._fresher_rest_price_impl("A", candles) == 101.0
fresh_candles = [_candle(now - timedelta(seconds=10), 100.0)] # 봉이 더 신선
assert es._fresher_rest_price_impl("A", fresh_candles) is None
_old = float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + float(settings.DAY_TRADE_EXIT_CHECK_TIMEOUT_SEC) + 1
es._rest_price_seen["A"] = (101.0, now - timedelta(seconds=_old)) # 같은 사이클 아님
assert es._fresher_rest_price_impl("A", candles) is None
assert es._fresher_rest_price_impl("B", candles) is None
@pytest.mark.asyncio
async def test_minute_check_uses_fresher_rest_price_not_stale_candle_close():
now = es._kst_now()
pos = _today_pos(10)
pos["entry_time"] = now - timedelta(minutes=5)
day_trading_state.positions["555555"] = pos
es._rest_price_seen["555555"] = (10_020.0, now)
candles = [_candle(now - timedelta(minutes=10) + timedelta(minutes=i), 10_000.0) for i in range(8)]
c5 = [SimpleNamespace(close=9_990.0, high=9_990.0, low=9_990.0)] * 6
seen = {}
def _exit(**kw):
seen.update(kw)
return None
with patch.object(es.korea_investment_api, "get_minute_chart_full_day", AsyncMock(return_value={})), \
patch.object(es.korea_investment_api, "get_minute_chart_full_day_v2",
AsyncMock(return_value=[])), \
patch.object(es, "parse_minute_candles", return_value=candles), \
patch.object(es, "aggregate_to_5min", return_value=c5), \
patch.object(es, "check_day_trade_exit", _exit):
assert await es._check_single_position("555555") is None
assert seen["current_price"] == 10_020.0
assert day_trading_state.positions["555555"]["current_price"] == 10_020.0