diff --git a/CLAUDE.md b/CLAUDE.md index 24ea382..ff6e220 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ | RSI_BB | RSI≤30 + 종가≤BB하단 + 거래량≥1.3× + 양봉 | `min(ATR×1.5, -2.5% 하드)` + 최소 1원 | - 사이징: 종목당 6%(`DAY_TRADE_MAX_CAPITAL_RATIO`), 동시 8종목(`DAY_TRADE_MAX_POSITIONS`), confidence 조정 `예산×(confidence/0.7)` -- 청산: +2R 시 50% 분할→잔량 트레일링(ATR×1.0) / 진입 30분(6봉)간 +0.3% 미달 시 시간손절 / 5분 RSI≥70 & 수수료 이상 수익 시 / **15:20 EOD 전량 청산**(`DAY_TRADE_EOD_FLAT_*`) +- 청산: +2R 시 50% 분할→잔량 트레일링(ATR×1.0) / 진입 30분(6봉)간 +0.3% 미달 시 시간손절 / 5분 RSI≥70 & 수수료 이상 수익 시 / **15:20 EOD 전량 청산**(`DAY_TRADE_EOD_FLAT_*`) / **전일 미청산 포지션은 장중 발견 즉시 전량 청산**(`close_stale_positions`, EOD 누락 안전장치) - 제한: CRASH/HALTED 국면 차단 / **15:00 이후 신규 마감**(`DAY_TRADE_ENTRY_CUTOFF_*`, 점심 제한은 2026-09-22 제거) / 일일 -3% 손실 시 당일 중단(`DAY_TRADE_DAILY_LOSS_LIMIT`) / 당일 3연패 쿨다운(position_manager) / 전량 매도 시 loss_tracker 기록 + `save_to_db()` - 감시종목: 09:00에 당일 분석이 없으면 **최근 분석일 결과로 선채움**(ORB 수집 보장), 자동 목록은 30분마다 갱신(수동 설정 목록은 유지) diff --git a/backend/app/services/daytrading/exit_service.py b/backend/app/services/daytrading/exit_service.py index 507f14e..c56f3c0 100644 --- a/backend/app/services/daytrading/exit_service.py +++ b/backend/app/services/daytrading/exit_service.py @@ -25,7 +25,7 @@ from datetime import datetime import pytz -from app.core.market_schedule import is_eod_flat_time +from app.core.market_schedule import is_eod_flat_time, is_market_open from app.services.korea_investment_api import korea_investment_api from app.services.day_trading_strategy import ( parse_minute_candles, aggregate_to_5min, @@ -94,6 +94,15 @@ async def check_and_exit_positions() -> list[dict]: logger.info("[DayExit] EOD (15:20) → 전체 강제 청산") return await force_close_all(reason="EOD 강제 청산") + # [안전장치 2026-09-23] 전일 미청산 포지션 즉시 청산. + # 데이트레이딩은 당일 청산이 원칙인데 15:20 EOD가 실패/누락되면(2026-09-22 LG씨엔에스) + # 포지션이 하룻밤 갭 리스크에 노출된 채 다음 날 일반 청산 로직을 타게 된다. + stale_exits = await close_stale_positions() + if stale_exits: + exits.extend(stale_exits) + if not day_trading_state.positions: + return exits + # 개별 포지션 청산 체크 codes_snapshot = list(day_trading_state.positions.keys()) for stock_code in codes_snapshot: @@ -297,6 +306,59 @@ async def _execute_exit( } +async def close_stale_positions() -> list[dict]: + """진입일이 오늘이 아닌(전일 미청산) 데이트레이딩 포지션을 시장가 전량 청산. + + EOD(15:20) 강제청산이 실패하거나 스케줄러가 그 구간을 놓친 경우의 안전장치. + 장중에만 동작하며(시장가 주문 필요), 청산 실패 시 다음 사이클에서 재시도된다. + + Returns: + list[dict]: 청산 실행 결과 목록 + """ + if not is_market_open(): + return [] + + today = _kst_now().date() + results: list[dict] = [] + + for stock_code in list(day_trading_state.positions.keys()): + pos = day_trading_state.get_position(stock_code) + if not pos: + continue + entry_time = pos.get("entry_time") + if entry_time is None: + continue + entry_date = entry_time.date() if hasattr(entry_time, "date") else None + if entry_date is None or entry_date >= today: + continue + + price = 0.0 + try: + price_resp = await korea_investment_api.get_current_price(stock_code) + price = float(price_resp.get("output", {}).get("stck_prpr", 0) or 0) if price_resp else 0.0 + except Exception as e: + logger.warning(f"[DayExit] 전일 포지션 현재가 조회 실패 ({stock_code}): {e}") + if price <= 0: + price = float(pos.get("entry_price", 0) or 0) + if price <= 0: + continue + + logger.warning( + f"[DayExit] ⚠️ 전일 미청산 포지션 즉시 청산: {stock_code} " + f"(진입일={entry_date}, 수량={pos.get('quantity')})" + ) + result = await _execute_exit( + stock_code, pos, price, + reason=f"전일 미청산 포지션 강제 청산 (진입일 {entry_date}, EOD 누락)", + sell_pct=1.0, + ) + if result: + results.append(result) + await asyncio.sleep(0.3) # KIS 레이트 리밋 + + return results + + async def force_close_all(reason: str = "강제 청산") -> list[dict]: """모든 데이트레이딩 포지션 강제 청산. diff --git a/backend/app/services/daytrading/scheduler.py b/backend/app/services/daytrading/scheduler.py index 0dd10cf..d1c5240 100644 --- a/backend/app/services/daytrading/scheduler.py +++ b/backend/app/services/daytrading/scheduler.py @@ -67,6 +67,14 @@ async def daytrading_scheduler() -> None: restored = await day_trading_state.restore_from_db(db) if restored: logger.info(f"[DayTrading] 시작 시 {restored}개 포지션 복원됨") + # 전일 미청산 포지션은 기동 직후(장중이면) 즉시 청산 — EOD 누락 안전장치 + try: + from app.services.daytrading.exit_service import close_stale_positions + stale = await close_stale_positions() + if stale: + logger.warning(f"[DayTrading] 기동 시 전일 포지션 {len(stale)}건 청산") + except Exception as e: + logger.error(f"[DayTrading] 기동 시 전일 포지션 청산 실패: {e}", exc_info=True) except Exception as e: logger.error(f"[DayTrading] 시작 시 포지션 복원 실패: {e}", exc_info=True) diff --git a/backend/tests/test_daytrading_stale_position_guard.py b/backend/tests/test_daytrading_stale_position_guard.py new file mode 100644 index 0000000..e1b26c9 --- /dev/null +++ b/backend/tests/test_daytrading_stale_position_guard.py @@ -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"