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

323 lines
15 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 하드닝 라운드 6 — 당일 매수분 고아 청산 손익의 당일 집계 (2026-10-10, daytrading/exit_service.py).
R6-1 당일 DT 매수 후 인메모리·active 행이 모두 유실돼 고아로 감지된 포지션은
전일·고아(stale) 경로(실보유·타전략 차감·재시도)를 그대로 타되,
실현손익은 당일 daily_pnl·loss_streak_today 에 넣는다(§7 의 제외 예외는 '전일' 포지션뿐).
청산 사유 접두사도 분리해 재기동 원장 재구성에서 당일 손익으로 포함된다.
목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`_execute_exit_impl`)에 걸고, 호출되면 안 되는
경로는 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.daytrading import exit_service as es
from app.services.daytrading import position_manager as pm
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": [{}],
})
CODE = "005930"
def _today_at(h, m):
return datetime.combine(es._kst_now().date(), datetime.min.time()).replace(hour=h, minute=m)
def _pf(qty=3):
return SimpleNamespace(stock_code=CODE, stock_name="삼성전자", quantity=qty,
average_price=70_000.0, strategy_type="daytrading")
def _ev(qty=3, created_at=None):
return SimpleNamespace(
stock_code=CODE, stock_name="삼성전자", quantity=qty, entry_price=70_000.0,
highest_since_entry=70_500.0, status="closed", strategy_name="day_trade",
created_at=created_at or (datetime.now().replace(microsecond=0) - timedelta(days=3)),
)
def _th(side, qty, created_at, 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, created_at=created_at)
@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.loss_streak_today, es._stale_realized_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
day_trading_state.loss_streak_today = 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.loss_streak_today = saved[4]
es._stale_realized_pnl = saved[5]
for coll in (es._unresolved_exit_unknown, es._manual_alerted, es._rest_price_seen,
es._recent_dt_sell_finalized, es._stale_sell_attempted, es._stale_cleaned):
coll.clear()
async def _run(session_factory, balance, exec_mock):
with patch.object(es, "is_market_open", return_value=True), \
patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", 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": "69000"}})), \
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
# ═══ 감지: 당일 순매수 → orphan_today ═══════════════════════════════════════════
def test_sanity_reason_prefix_not_treated_as_stale_in_rebuild():
"""새 사유 접두사는 전일·고아 제외 접두사와 겹치지 않는다(겹치면 원장 재구성이 다시 뺀다)."""
assert not es._ORPHAN_TODAY_EXIT_REASON_PREFIX.startswith(pm._STALE_EXIT_REASON_PREFIXES)
@pytest.mark.asyncio
async def test_today_buy_orphan_routes_to_stale_terminus_with_today_flag_and_reason():
"""근거 ActiveTrade 는 3일 전 행이지만 오늘 DT 매수 3주(순체결 +3) → 당일 고아.
stale 공통 종단을 타되(실보유·타전략 차감 유지) 사유는 당일 접두사, 진입일은 실제 매수일."""
buy_at = _today_at(9, 5)
th = [_th("BUY", 3, buy_at)]
exec_mock = AsyncMock(return_value={"stock_code": CODE})
await _run(_sess([], [_pf()], [_ev()], th, []), _balance([(CODE, 3, 3)]), exec_mock)
assert exec_mock.await_count == 1
args, kwargs = exec_mock.await_args.args, exec_mock.await_args.kwargs
pos, reason = args[1], args[3]
assert kwargs.get("is_stale") is True
assert pos[es._ORPHAN_TODAY_KEY] is True
assert pos["entry_time"] == buy_at
assert reason.startswith(es._ORPHAN_TODAY_EXIT_REASON_PREFIX)
assert f"진입일 {buy_at.date()}" in reason
assert not reason.startswith(pm._STALE_EXIT_REASON_PREFIXES)
@pytest.mark.asyncio
async def test_past_buy_orphan_keeps_stale_prefix_and_no_today_flag():
"""매수가 전일 이전뿐이면 기존 '전일·고아' 경로 그대로(당일 손익 제외)."""
buy_at = _today_at(9, 5) - timedelta(days=2)
th = [_th("BUY", 3, buy_at), _th("SELL", 3, buy_at + timedelta(hours=6), "CANCELLED", executed=0)]
exec_mock = AsyncMock(return_value={"stock_code": CODE})
await _run(_sess([], [_pf()], [_ev()], th, []), _balance([(CODE, 3, 3)]), exec_mock)
pos, reason = exec_mock.await_args.args[1], exec_mock.await_args.args[3]
assert pos[es._ORPHAN_TODAY_KEY] is False
assert pos["entry_time"] == buy_at # 근거 행(3일 전)이 아니라 실제 마지막 매수
assert reason.startswith("DT 고아 잔고 청산")
@pytest.mark.asyncio
async def test_today_orphan_still_capped_by_other_strategy_share():
"""당일 고아여도 §3.5 차감은 그대로 — 실보유 6 − 스윙 3 = DT 3 만 판다."""
synced = SimpleNamespace(stock_code=CODE, quantity=3)
th = [_th("BUY", 3, _today_at(9, 5))]
exec_mock = AsyncMock(return_value={"stock_code": CODE})
await _run(_sess([], [_pf(qty=6)], [_ev()], th, [synced]), _balance([(CODE, 6, 6)]), exec_mock)
assert exec_mock.await_args.args[1]["quantity"] == 3
assert exec_mock.await_args.args[1][es._ORPHAN_TODAY_KEY] is True
def test_last_buy_helper_splits_today_and_past():
t0 = _today_at(9, 1)
rows = [_th("BUY", 1, t0 - timedelta(days=1)), _th("BUY", 1, t0),
_th("BUY", 1, t0 + timedelta(minutes=5), status="PENDING"),
_th("SELL", 1, t0 + timedelta(minutes=9))]
out = es._last_completed_dt_buy_at_v2(rows, t0.date())
assert out[(CODE, True)] == t0
assert out[(CODE, False)] == t0 - timedelta(days=1)
# ═══ 반영: _finalize_exit_impl (stale 플래그로 들어와도 당일 집계) ══════════════
def _orphan_pos(today_flag: bool, qty=10, entry=10_000.0):
return {"stock_name": "삼성전자", "strategy": "day_trade", "entry_price": entry,
"quantity": qty, "highest_price": entry, "entry_time": _today_at(9, 5),
"reason": "", "order_id": "", "orphan": True, es._ORPHAN_TODAY_KEY: today_flag}
async def _finalize(pos, price):
import app.services.portfolio_cache_bridge as pcb
sched = AsyncMock()
with patch.object(es, "enqueue_trade_history", AsyncMock()), \
patch.object(es, "enqueue_close_active_trade", AsyncMock()), \
patch.object(es, "_schedule_sell_fill_reconcile_v2", lambda **k: None), \
patch.object(es, "_in_closing_auction_impl", return_value=False), \
patch.object(pcb, "reflect_sell", sched), \
patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()) as rec, \
patch.object(es.loss_tracker, "save_to_db", AsyncMock()) as save:
out = await es._finalize_exit_impl(
CODE, pos, price, f"{es._ORPHAN_TODAY_EXIT_REASON_PREFIX} (테스트)", 1.0,
{"output": {"ODNO": "0000777"}}, int(pos["quantity"]), int(pos["quantity"]), True,
count_daily_pnl=False,
)
return out, rec, save
@pytest.mark.asyncio
async def test_today_orphan_loss_counts_in_daily_pnl_and_streak():
stale_before = es._stale_realized_pnl
out, rec, save = await _finalize(_orphan_pos(True), 9_700.0)
assert out["profit_loss"] < 0
assert day_trading_state.daily_pnl == pytest.approx(out["profit_loss"])
assert day_trading_state.loss_streak_today == 1
assert es._stale_realized_pnl == pytest.approx(stale_before)
# loss_tracker 는 기존대로 기록 + save_to_db (§5.1)
assert rec.await_count == 1 and save.await_count == 1
assert CODE in day_trading_state.closed_today # 재진입 차단 유지
@pytest.mark.asyncio
async def test_past_orphan_loss_still_excluded_from_daily_pnl():
stale_before = es._stale_realized_pnl
out, _, _ = await _finalize(_orphan_pos(False), 9_700.0)
assert day_trading_state.daily_pnl == 0.0
assert day_trading_state.loss_streak_today == 0
assert es._stale_realized_pnl == pytest.approx(stale_before + out["profit_loss"])
@pytest.mark.asyncio
async def test_today_orphan_loss_triggers_daily_limit():
"""당일 고아 손실로 일일 손실한도(settings)를 넘으면 is_daily_loss_exceeded 가 발동한다.
(예전에는 stale 경로라 daily_pnl 이 0 으로 남아 한도가 발동하지 않았다.)"""
from app.services.day_trading_strategy import is_daily_loss_exceeded
total = 1_000_000.0
loss_ratio = abs(float(settings.DAY_TRADE_DAILY_LOSS_LIMIT))
qty, entry = 100, 10_000.0
price = entry - (total * loss_ratio * 2) / qty # 한도의 약 2배 손실
await _finalize(_orphan_pos(True, qty=qty, entry=entry), price)
assert is_daily_loss_exceeded(total, day_trading_state.daily_pnl)
day_trading_state.daily_pnl = 0.0
await _finalize(_orphan_pos(False, qty=qty, entry=entry), price)
assert not is_daily_loss_exceeded(total, day_trading_state.daily_pnl)
# ═══ 체결 확인: 0체결 종결 시 당일 손익 되돌림, 인메모리 복원 없음 ═══════════════
@pytest.mark.asyncio
async def test_today_orphan_unfilled_reverts_daily_pnl_without_memory_restore():
captured = {}
def _sched(code, order_id, side, qty, cb):
captured["cb"] = cb
return object()
day_trading_state.daily_pnl = -3_000.0
pos = _orphan_pos(True, qty=3)
upsert = AsyncMock()
with patch.object(es.fill_reconciler, "schedule_v2", _sched), \
patch("app.services.db_worker.enqueue_active_trade_upsert", upsert), \
patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()):
es._schedule_sell_fill_reconcile_v2(
stock_code=CODE, pos=pos, order_id="0000778", signal_price=9_000.0, quantity=3,
entry_price=10_000.0, recorded_net=-3_000.0, count_daily_pnl=False,
)
await captured["cb"]({"terminal": True, "filled_qty": 0, "avg_price": 0})
assert day_trading_state.daily_pnl == pytest.approx(0.0)
# 인메모리로 직접 되살리지는 않는다(손절가 없는 사본)
assert CODE not in day_trading_state.positions
# [R8-1] 대신 ActiveTrade(day_trade, active)를 당일 진입 시각으로 되살려 당일 행 복원 경로에 맡긴다
assert upsert.await_count == 1
payload = upsert.await_args.args[0]
assert payload["strategy_name"] == "day_trade" and payload["status"] == "active"
assert payload["quantity"] == 3
assert payload["created_at"] == pos["entry_time"]
assert CODE in day_trading_state.dt_reheld_today # 당일 행 복원 보류(restore_block) 해제
day_trading_state.dt_reheld_today.discard(CODE)
# stale 재청산 표식이 지워져 close_stale_positions 가 다시 잡는다
assert (es._kst_now().date().isoformat(), CODE) not in es._stale_sell_attempted
# ═══ 재기동 원장 재구성: 당일 고아 사유는 당일 손익에 포함 ═══════════════════════
def _row(i, order_type, net=None, reason=""):
return SimpleNamespace(
id=i, stock_code=CODE, order_type=order_type, status="COMPLETED", exit_reason=reason,
net_profit_loss=net, strategy_type="daytrading", created_at=_today_at(9, 10 + i),
executed_quantity=3, order_quantity=3,
)
def test_rebuild_counts_today_orphan_reason_but_excludes_stale_orphan():
today_rows = [_row(0, "BUY"), _row(1, "SELL", -2_000.0,
f"{es._ORPHAN_TODAY_EXIT_REASON_PREFIX} (진입일 x)")]
r = pm._rebuild_daily_state_from_rows(today_rows)
assert r["daily_pnl"] == pytest.approx(-2_000.0)
assert r["loss_streak_today"] == 1
assert r["stale_excluded"] == 0
stale_rows = [_row(0, "BUY"), _row(1, "SELL", -2_000.0, "DT 고아 잔고 청산 (진입일 x)")]
r2 = pm._rebuild_daily_state_from_rows(stale_rows)
assert r2["daily_pnl"] == 0.0
assert r2["stale_excluded"] == 1