- 스윙 체결가 확인 전 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
323 lines
15 KiB
Python
323 lines
15 KiB
Python
"""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
|