Compare commits

..

2 Commits

Author SHA1 Message Date
92cceb2cae Merge branch 'dev' into main — 전일 미청산 포지션 안전장치 2026-09-23 10:01:02 +09:00
a659de09e3 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>
2026-09-23 10:01:00 +09:00
4 changed files with 177 additions and 2 deletions

View File

@@ -72,7 +72,7 @@
| RSI_BB | RSI≤30 + 종가≤BB하단 + 거래량≥1.3× + 양봉 | `min(ATR×1.5, -2.5% 하드)` + 최소 1원 | | 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)` - 사이징: 종목당 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()` - 제한: 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분마다 갱신(수동 설정 목록은 유지) - 감시종목: 09:00에 당일 분석이 없으면 **최근 분석일 결과로 선채움**(ORB 수집 보장), 자동 목록은 30분마다 갱신(수동 설정 목록은 유지)

View File

@@ -25,7 +25,7 @@ from datetime import datetime
import pytz 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.korea_investment_api import korea_investment_api
from app.services.day_trading_strategy import ( from app.services.day_trading_strategy import (
parse_minute_candles, aggregate_to_5min, parse_minute_candles, aggregate_to_5min,
@@ -94,6 +94,15 @@ async def check_and_exit_positions() -> list[dict]:
logger.info("[DayExit] EOD (15:20) → 전체 강제 청산") logger.info("[DayExit] EOD (15:20) → 전체 강제 청산")
return await force_close_all(reason="EOD 강제 청산") 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()) codes_snapshot = list(day_trading_state.positions.keys())
for stock_code in codes_snapshot: 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]: async def force_close_all(reason: str = "강제 청산") -> list[dict]:
"""모든 데이트레이딩 포지션 강제 청산. """모든 데이트레이딩 포지션 강제 청산.

View File

@@ -67,6 +67,14 @@ async def daytrading_scheduler() -> None:
restored = await day_trading_state.restore_from_db(db) restored = await day_trading_state.restore_from_db(db)
if restored: if restored:
logger.info(f"[DayTrading] 시작 시 {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: except Exception as e:
logger.error(f"[DayTrading] 시작 시 포지션 복원 실패: {e}", exc_info=True) logger.error(f"[DayTrading] 시작 시 포지션 복원 실패: {e}", exc_info=True)

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"