"""전일 미청산 데이트레이딩 포지션 안전장치. 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_stale_position_found_from_db_after_midnight_reset(): """자정 reset 으로 인메모리가 비어도 DB(ActiveTrade)에서 전일 포지션을 찾아 청산한다.""" from types import SimpleNamespace from datetime import datetime as _dt day_trading_state.positions = {} row = SimpleNamespace( stock_code="064400", stock_name="LG씨엔에스", entry_price=70800.0, quantity=4, highest_since_entry=71200.0, created_at=_dt.now().replace(microsecond=0) - timedelta(days=1), order_id="1", strategy_name="day_trade", status="active", ) class _Result: def scalars(self): return SimpleNamespace(all=lambda: [row]) class _Sess: async def __aenter__(self): return self async def __aexit__(self, *a): return False async def execute(self, *a, **k): return _Result() exec_mock = AsyncMock(return_value={"stock_code": "064400", "quantity": 4}) with patch.object(es, "is_market_open", return_value=True), \ patch("app.core.database.AsyncSessionLocal", _Sess), \ patch.object(es.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "70300"}})), \ patch.object(es, "_execute_exit", exec_mock): out = await es.close_stale_positions() assert len(out) == 1 assert exec_mock.await_args.args[1]["quantity"] == 4 assert exec_mock.await_args.kwargs["sell_pct"] == 1.0 @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"