- 스윙 체결가 확인 전 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
296 lines
16 KiB
Python
296 lines
16 KiB
Python
"""DT 청산 하드닝 라운드 2 회귀 테스트 (2026-10-09, daytrading/exit_service.py).
|
|
|
|
R2-1 15:20~15:30 종가 단일가 매도는 장 마감 후 재확인한 실제 체결가로 loss_tracker 승패 기록
|
|
R2-3 국내 ETF/ETN 매도는 증권거래세 0
|
|
R2-4 분할 익절 후 전량 청산의 승패는 왕복 순손익으로(당일 연패·loss_tracker)
|
|
R2-5 강제청산 None 반환도 실패로 재시도·텔레그램, 장 마감 후 잔존 DT 포지션 경보
|
|
|
|
목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`safe_place_order`·`_execute_exit_impl`)에 걸고,
|
|
호출되면 안 되는 경로는 BaseException 발생형. 기대값은 settings.* 에서 계산한다.
|
|
"""
|
|
import asyncio
|
|
from datetime import datetime
|
|
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 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
|
|
|
|
|
|
@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)
|
|
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
|
|
colls = (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._once_alerted,
|
|
es._post_close_check_scheduled)
|
|
for coll in colls:
|
|
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]
|
|
for coll in colls:
|
|
coll.clear()
|
|
day_trading_state.exits_in_progress.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def exit_env():
|
|
"""공통 종단 safe_place_order 접수 + 큐·캐시·loss_tracker 목."""
|
|
import app.services.trade_executor as te
|
|
import app.services.portfolio_cache_bridge as pcb
|
|
rec = AsyncMock()
|
|
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", AsyncMock()), \
|
|
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", rec), \
|
|
patch.object(es.loss_tracker, "save_to_db", AsyncMock()):
|
|
yield rec
|
|
|
|
|
|
def _pos(qty=10, entry=10_000.0, name="테스트"):
|
|
return {"stock_name": name, "entry_price": entry, "quantity": qty,
|
|
"stop_loss": entry * 0.97, "target_price": entry * 1.045, "highest_price": entry,
|
|
"order_id": "0000999", "entry_time": datetime.now(), "reason": "ORB"}
|
|
|
|
|
|
def _row(qty, filled, avg, rmn):
|
|
return [{"odno": "123", "pdno": "777777", "ord_qty": str(qty), "tot_ccld_qty": str(filled),
|
|
"avg_prvs": str(avg), "rmn_qty": str(rmn), "cncl_yn": "N"}]
|
|
|
|
|
|
# ═══ R2-3 ETF/ETN 거래세 0 ═══════════════════════════════════════════════════
|
|
|
|
@pytest.mark.parametrize("name", ["PLUS 200", "KIWOOM 200TR", "PLUS K방산", "KODEX 200",
|
|
"TIGER 미국S&P500", "삼성 인버스 2X WTI원유 선물 ETN"])
|
|
def test_etf_etn_sell_tax_zero(name):
|
|
assert es._dt_sell_tax_rate({"stock_name": name}) == 0.0
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["삼성전자", "KB금융", "SK하이닉스", "", None])
|
|
def test_stock_sell_tax_unchanged(name):
|
|
assert es._dt_sell_tax_rate({"stock_name": name}) == float(settings.KR_SELL_TAX_RATE_KOSPI)
|
|
assert es._dt_sell_tax_rate({"stock_name": name, "market": "KOSDAQ"}) == \
|
|
float(settings.KR_SELL_TAX_RATE_KOSDAQ)
|
|
|
|
|
|
def test_etf_net_pnl_has_commission_only():
|
|
"""10/06 PLUS 200 재현: 비용은 수수료뿐(거래세 812원 없음)."""
|
|
r = es.dt_net_pnl(113_285.0, 112_840.0, 4, {"stock_name": "PLUS 200"})
|
|
fee = float(settings.KR_COMMISSION_RATE)
|
|
expected_cost = (113_285.0 * 4 + 112_840.0 * 4) * fee
|
|
assert abs(r["total_cost"] - expected_cost) <= 2.0
|
|
stock = es.dt_net_pnl(113_285.0, 112_840.0, 4, {"stock_name": "삼성전자"})
|
|
assert stock["total_cost"] - r["total_cost"] >= 112_840.0 * 4 * float(settings.KR_SELL_TAX_RATE_KOSPI) - 2
|
|
|
|
|
|
# ═══ R2-4 왕복 순손익 승패 ═══════════════════════════════════════════════════
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_profit_then_small_loss_counts_as_round_trip_win(exit_env):
|
|
"""+1.5R 분할 익절 뒤 잔량 소폭 손실 청산 → 왕복 이익 → 연패 리셋·loss_tracker 이익."""
|
|
day_trading_state.positions["777777"] = _pos(10)
|
|
day_trading_state.loss_streak_today = 2
|
|
with patch.object(es, "_in_closing_auction_impl", return_value=False):
|
|
r1 = await es._execute_exit("777777", day_trading_state.positions["777777"], 10_450.0,
|
|
"목표가 도달", sell_pct=0.5)
|
|
assert r1["profit_loss"] > 0
|
|
assert day_trading_state.positions["777777"]["realized_net"] == pytest.approx(r1["profit_loss"])
|
|
assert exit_env.await_count == 0 # 분할 레그는 기록 안 함
|
|
r2 = await es._execute_exit("777777", day_trading_state.positions["777777"], 9_990.0,
|
|
"트레일링 스탑")
|
|
assert r2["profit_loss"] < 0 # 마지막 레그만 보면 손실
|
|
assert r1["profit_loss"] + r2["profit_loss"] > 0 # 왕복은 이익
|
|
exit_env.assert_awaited_once_with("777777", False)
|
|
assert day_trading_state.loss_streak_today == 0
|
|
assert day_trading_state.daily_pnl == pytest.approx(r1["profit_loss"] + r2["profit_loss"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_round_trip_loss_still_counts_as_loss(exit_env):
|
|
day_trading_state.positions["777777"] = _pos(10)
|
|
with patch.object(es, "_in_closing_auction_impl", return_value=False):
|
|
await es._execute_exit("777777", day_trading_state.positions["777777"], 10_050.0,
|
|
"목표가 도달", sell_pct=0.5)
|
|
await es._execute_exit("777777", day_trading_state.positions["777777"], 9_700.0, "손절")
|
|
exit_env.assert_awaited_once_with("777777", True)
|
|
assert day_trading_state.loss_streak_today == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_partial_keeps_last_leg_rule(exit_env):
|
|
day_trading_state.positions["777777"] = _pos(10)
|
|
with patch.object(es, "_in_closing_auction_impl", return_value=False):
|
|
r = await es._execute_exit("777777", day_trading_state.positions["777777"], 10_010.0, "손절")
|
|
assert r["profit_loss"] < 0 # 비용 차감 후 손실
|
|
exit_env.assert_awaited_once_with("777777", True)
|
|
assert day_trading_state.loss_streak_today == 1
|
|
|
|
|
|
# ═══ R2-1 종가 단일가 매도 — 체결 확인 후 loss_tracker ═════════════════════════
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_closing_auction_sell_records_loss_on_confirmed_fill(exit_env):
|
|
"""15:20 신호가로는 이익이지만 15:30 체결가로 손실 → 체결 확인 후 '손실'로 1회 기록."""
|
|
day_trading_state.positions["777777"] = _pos(10)
|
|
calls = {"n": 0}
|
|
|
|
async def _orders(code):
|
|
calls["n"] += 1
|
|
# 장 마감 전(즉시 확인) 구간에서는 loss_tracker 가 아직 기록되면 안 된다
|
|
if calls["n"] <= int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS):
|
|
assert exit_env.await_count == 0
|
|
return _row(10, 0, 0, 10) # 미체결·미종결
|
|
return _row(10, 10, 9_950, 0) # 15:30 종가 체결
|
|
|
|
with 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), \
|
|
patch.object(es, "_in_closing_auction_impl", return_value=True), \
|
|
patch.object(es, "_auction_recheck_params_impl", return_value=(0.0, 3, 0.0)), \
|
|
patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \
|
|
patch.object(fr.korea_investment_api, "get_todays_orders", _orders):
|
|
r = await es._execute_exit("777777", day_trading_state.positions["777777"], 10_050.0,
|
|
"EOD 강제 청산")
|
|
assert r["profit_loss"] > 0 # 신호가 기준은 이익
|
|
await asyncio.gather(*list(es._auction_fill_tasks))
|
|
exit_env.assert_awaited_once_with("777777", True)
|
|
expected = es.dt_net_pnl(10_000.0, 9_950.0, 10, _pos())["net_profit_loss"]
|
|
assert day_trading_state.daily_pnl == pytest.approx(expected)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_closing_auction_unconfirmed_falls_back_to_signal_price(exit_env):
|
|
"""재확인까지 주문 행을 못 찾으면 신호가 기준으로 기록(구 동작) — 기록 누락 없음."""
|
|
day_trading_state.positions["777777"] = _pos(10)
|
|
with 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), \
|
|
patch.object(es, "_in_closing_auction_impl", return_value=True), \
|
|
patch.object(es, "_auction_recheck_params_impl", return_value=(0.0, 2, 0.0)), \
|
|
patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \
|
|
patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[])):
|
|
await es._execute_exit("777777", day_trading_state.positions["777777"], 10_050.0,
|
|
"EOD 강제 청산")
|
|
await asyncio.gather(*list(es._auction_fill_tasks))
|
|
exit_env.assert_awaited_once_with("777777", False)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_closing_auction_zero_fill_does_not_record(exit_env):
|
|
"""종가 단일가 매도가 0주 체결로 종결 → 포지션 복원, loss_tracker 기록하지 않음."""
|
|
day_trading_state.positions["777777"] = _pos(10)
|
|
rows = [{"odno": "123", "pdno": "777777", "ord_qty": "10", "tot_ccld_qty": "0",
|
|
"avg_prvs": "0", "rmn_qty": "0", "cncl_yn": "Y"}]
|
|
with 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), \
|
|
patch.object(es, "_in_closing_auction_impl", return_value=True), \
|
|
patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \
|
|
patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
|
|
await es._execute_exit("777777", day_trading_state.positions["777777"], 10_050.0,
|
|
"EOD 강제 청산")
|
|
await asyncio.gather(*list(es._auction_fill_tasks))
|
|
assert exit_env.await_count == 0
|
|
assert day_trading_state.positions["777777"]["quantity"] == 10
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_time_stop_in_auction_still_not_recorded(exit_env):
|
|
day_trading_state.positions["777777"] = _pos(10)
|
|
with patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", False), \
|
|
patch.object(es, "_in_closing_auction_impl", return_value=True):
|
|
await es._execute_exit("777777", day_trading_state.positions["777777"], 9_960.0,
|
|
"시간 손절 (테스트)")
|
|
assert exit_env.await_count == 0
|
|
|
|
|
|
def test_closing_auction_window_bounds():
|
|
d = datetime(2026, 10, 6)
|
|
assert not es._in_closing_auction_impl(d.replace(hour=15, minute=19, second=59))
|
|
assert es._in_closing_auction_impl(d.replace(hour=15, minute=20))
|
|
assert es._in_closing_auction_impl(d.replace(hour=15, minute=29, second=59))
|
|
assert not es._in_closing_auction_impl(d.replace(hour=15, minute=30))
|
|
|
|
|
|
# ═══ R2-5 강제청산 미청산 경보 ═══════════════════════════════════════════════
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_force_close_none_result_retried_and_alerted(_clean):
|
|
day_trading_state.positions["888888"] = _pos(5)
|
|
exec_mock = AsyncMock(return_value=None) # KIS 거부 등 — 예외 없이 None
|
|
with patch.object(es, "_execute_exit_impl", exec_mock), \
|
|
patch.object(es, "is_eod_flat_time", return_value=False), \
|
|
patch.object(es, "close_stale_positions", _boom("close_stale_positions")), \
|
|
patch.object(es.korea_investment_api, "get_current_price",
|
|
AsyncMock(return_value={"output": {"stck_prpr": "10000"}})):
|
|
out = await es.force_close_all(reason="EOD 강제 청산")
|
|
await asyncio.gather(*list(es._alert_tasks))
|
|
assert out == []
|
|
assert exec_mock.await_count == 2 # 1회 + 재시도 1회
|
|
tags = [c.args[0] for c in _clean.await_args_list]
|
|
assert tags == ["DT_EOD"]
|
|
assert "888888" in _clean.await_args_list[0].args[1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_close_residual_check_alerts(_clean):
|
|
assert es.check_post_close_residual_v2() == []
|
|
day_trading_state.positions["888888"] = _pos(5)
|
|
assert es.check_post_close_residual_v2() == ["888888"]
|
|
es.check_post_close_residual_v2() # 같은 날 같은 잔존 → 1회만
|
|
await asyncio.gather(*list(es._alert_tasks))
|
|
assert [c.args[0] for c in _clean.await_args_list] == ["DT_EOD"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_eod_force_close_schedules_post_close_check_once(_clean):
|
|
day_trading_state.positions["888888"] = _pos(5)
|
|
day_trading_state.exits_in_progress.add("888888") # 불명 보류 중 → 이번 회차 skip
|
|
with patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")), \
|
|
patch.object(es, "is_eod_flat_time", return_value=True), \
|
|
patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \
|
|
patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")):
|
|
await es.force_close_all(reason="EOD 강제 청산")
|
|
await es.force_close_all(reason="EOD 강제 청산")
|
|
assert len(es._post_close_tasks) <= 1
|
|
await asyncio.gather(*list(es._post_close_tasks))
|
|
await asyncio.gather(*list(es._alert_tasks))
|
|
assert [c.args[0] for c in _clean.await_args_list] == ["DT_EOD"]
|