feat: 전일 미청산 데이트레이딩 포지션 즉시 청산 안전장치

15:20 EOD 강제청산이 누락/실패하면 포지션이 하룻밤 갭 리스크에 노출된 채 다음 날
일반 청산 로직을 탄다(2026-09-22 LG씨엔에스 7주 → 익일 09:30 분할매도).
- exit_service.close_stale_positions(): 진입일이 오늘이 아닌 포지션을 장중 시장가 전량 청산,
  현재가 조회 실패 시 진입가 폴백, 실패 시 다음 30초 사이클에서 재시도
- 청산 사이클(check_and_exit_positions) 및 스케줄러 기동 직후 양쪽에서 호출
- 테스트 5건, CLAUDE.md §7.3 반영

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 10:01:00 +09:00
parent fd8a0f1188
commit a659de09e3
4 changed files with 177 additions and 2 deletions

View File

@@ -0,0 +1,105 @@
"""전일 미청산 데이트레이딩 포지션 안전장치.
2026-09-22: 14:38 진입한 LG씨엔에스가 15:20 EOD 청산을 타지 않고 익일까지 보유됨.
장중이면 전일 포지션을 즉시 전량 시장가 청산해야 한다.
"""
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, patch
import pytest
from app.services.daytrading import exit_service as es
from app.services.daytrading.position_manager import day_trading_state
def _pos(entry_dt):
return {
"stock_name": "LG씨엔에스", "strategy": "day_trade", "entry_price": 70800.0,
"stop_loss": 69000.0, "target_price": 73000.0, "quantity": 4,
"highest_price": 72200.0, "entry_time": entry_dt, "reason": "", "order_id": "",
}
@pytest.fixture(autouse=True)
def _clean():
day_trading_state.positions = {}
# reset_if_new_day()가 포지션을 비우지 않도록 오늘로 맞춤
day_trading_state.last_reset_date = es._kst_now().date()
yield
day_trading_state.positions = {}
@pytest.mark.asyncio
async def test_stale_position_closed_in_full():
yesterday = es._kst_now() - timedelta(days=1)
day_trading_state.positions["064400"] = _pos(yesterday)
exec_mock = AsyncMock(return_value={"stock_code": "064400", "quantity": 4})
with patch.object(es, "is_market_open", return_value=True), \
patch.object(es.korea_investment_api, "get_current_price",
AsyncMock(return_value={"output": {"stck_prpr": "71200"}})), \
patch.object(es, "_execute_exit", exec_mock):
out = await es.close_stale_positions()
assert len(out) == 1
kwargs = exec_mock.await_args.kwargs
assert kwargs["sell_pct"] == 1.0 and "전일 미청산" in kwargs["reason"]
assert exec_mock.await_args.args[2] == 71200.0 # 현재가로 매도
@pytest.mark.asyncio
async def test_today_position_untouched():
day_trading_state.positions["064400"] = _pos(es._kst_now())
exec_mock = AsyncMock()
with patch.object(es, "is_market_open", return_value=True), \
patch.object(es, "_execute_exit", exec_mock):
assert await es.close_stale_positions() == []
exec_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_no_action_when_market_closed():
day_trading_state.positions["064400"] = _pos(es._kst_now() - timedelta(days=1))
exec_mock = AsyncMock()
with patch.object(es, "is_market_open", return_value=False), \
patch.object(es, "_execute_exit", exec_mock):
assert await es.close_stale_positions() == []
exec_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_falls_back_to_entry_price_when_quote_fails():
day_trading_state.positions["064400"] = _pos(es._kst_now() - timedelta(days=1))
exec_mock = AsyncMock(return_value={"stock_code": "064400"})
with patch.object(es, "is_market_open", return_value=True), \
patch.object(es.korea_investment_api, "get_current_price",
AsyncMock(side_effect=RuntimeError("quote down"))), \
patch.object(es, "_execute_exit", exec_mock):
out = await es.close_stale_positions()
assert len(out) == 1
assert exec_mock.await_args.args[2] == 70800.0 # entry_price 폴백
@pytest.mark.asyncio
async def test_exit_cycle_invokes_stale_guard():
"""청산 사이클이 개별 포지션 체크 전에 전일 포지션 안전장치를 호출한다."""
day_trading_state.positions["064400"] = _pos(es._kst_now() - timedelta(days=1))
order: list[str] = []
async def _guard():
order.append("guard")
day_trading_state.positions.pop("064400", None) # 청산 성공 가정
return [{"stock_code": "064400", "reason": "전일 미청산"}]
async def _single(code):
order.append(f"single:{code}")
return None
with patch.object(es, "is_eod_flat_time", return_value=False), \
patch.object(es, "is_daily_loss_exceeded", return_value=False), \
patch.object(es, "close_stale_positions", _guard), \
patch.object(es, "_check_single_position", _single):
out = await es.check_and_exit_positions()
assert order == ["guard"], f"안전장치가 먼저 실행되어야 함: {order}"
assert len(out) == 1 and out[0]["stock_code"] == "064400"