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
This commit is contained in:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -0,0 +1,419 @@
"""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