Files
auto-stock/backend/tests/test_daytrading_stale_position_guard.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

233 lines
9.4 KiB
Python

"""전일 미청산 데이트레이딩 포지션 안전장치.
2026-09-22: 14:38 진입한 LG씨엔에스가 15:20 EOD 청산을 타지 않고 익일까지 보유됨.
장중이면 전일 포지션을 즉시 전량 시장가 청산해야 한다.
"""
from datetime import datetime, timedelta
from types import SimpleNamespace
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
class LiveCallBlocked(AssertionError):
"""테스트가 실제 외부 I/O(KIS/DB)를 호출하려 한 경우 — 목킹 누락 신호."""
def _blocked(name):
async def _boom(*a, **k):
raise LiveCallBlocked(f"실호출 차단: {name} — 목킹이 누락되었습니다")
return _boom
class _EmptySession:
"""AsyncSessionLocal 대체 — SELECT 결과가 빈 목록인 세션(네트워크 없음)."""
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def execute(self, *a, **k):
return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: []))
@pytest.fixture(autouse=True)
def _block_live_io():
"""KIS 실호출·DB 실접속 전면 차단.
close_stale_positions() 가 타는 외부 I/O 경로를 모두 막는다:
- trade_executor.safe_place_order (exit_service._execute_exit_impl 내부 지연 임포트)
- korea_investment_api.place_order / place_overseas_order / _get_access_token
- korea_investment_api.get_current_price / get_balance / 분봉 조회
- app.core.database.AsyncSessionLocal (ActiveTrade 조회)
개별 테스트는 필요한 항목만 중첩 patch 로 정상 응답으로 덮어쓴다.
"""
import app.services.trade_executor as te
api = es.korea_investment_api
patches = [
patch.object(te, "safe_place_order", _blocked("safe_place_order")),
patch.object(te, "safe_place_order_v2", _blocked("safe_place_order_v2")),
patch.object(api, "place_order", _blocked("place_order")),
patch.object(api, "place_overseas_order", _blocked("place_overseas_order")),
patch.object(api, "_get_access_token", _blocked("_get_access_token")),
patch.object(api, "_request_new_token", _blocked("_request_new_token")),
patch.object(api, "get_current_price", _blocked("get_current_price")),
patch.object(api, "get_balance", _blocked("get_balance")),
patch.object(api, "get_minute_chart", _blocked("get_minute_chart")),
patch.object(api, "get_minute_chart_full_day", _blocked("get_minute_chart_full_day")),
patch("app.core.database.AsyncSessionLocal", _EmptySession),
]
for p in patches:
p.start()
try:
yield
finally:
for p in reversed(patches):
p.stop()
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": "",
}
def _impl_call(mock) -> dict:
"""_execute_exit_impl(stock_code, pos, price, reason, sell_pct, is_stale) 호출 인자 → dict."""
names = ["stock_code", "pos", "price", "reason", "sell_pct", "is_stale"]
d = dict(zip(names, mock.await_args.args))
d.update(mock.await_args.kwargs)
return d
def _holdings(code="064400", qty=4):
"""[H3] KIS 실보유 응답 — close_stale_positions 는 실보유 확인 없이는 매도하지 않는다."""
return AsyncMock(return_value={"output1": [{"pdno": code, "hldg_qty": str(qty)}], "output2": [{}]})
@pytest.fixture(autouse=True)
def _clean():
es._stale_sell_attempted.clear()
es._stale_sell_failures.clear()
es._last_exit_order_outcome.clear()
es._unresolved_exit_unknown.clear()
es._stale_cleaned.clear()
es._exit_tokens.clear()
day_trading_state.exits_in_progress.clear()
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_balance", _holdings()), \
patch.object(es.korea_investment_api, "get_current_price",
AsyncMock(return_value={"output": {"stck_prpr": "71200"}})), \
patch.object(es, "_execute_exit_impl", exec_mock):
out = await es.close_stale_positions()
assert len(out) == 1
call = _impl_call(exec_mock)
assert call["sell_pct"] == 1.0 and "전일 미청산" in call["reason"]
assert call["is_stale"] is True # 전일 손익은 당일 집계 제외
assert call["price"] == 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_impl", 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 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 _Empty:
def scalars(self):
return SimpleNamespace(all=lambda: [])
class _Sess:
"""1번째 SELECT = day_trade active 행, 2번째 SELECT(H3 비-DT 보유분) = 없음."""
calls = 0
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def execute(self, *a, **k):
type(self).calls += 1
return _Result() if type(self).calls == 1 else _Empty()
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_balance", _holdings()), \
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_impl", exec_mock):
out = await es.close_stale_positions()
assert len(out) == 1
call = _impl_call(exec_mock)
assert call["pos"]["quantity"] == 4
assert call["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_impl", 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_balance", _holdings()), \
patch.object(es.korea_investment_api, "get_current_price",
AsyncMock(side_effect=RuntimeError("quote down"))), \
patch.object(es, "_execute_exit_impl", exec_mock):
out = await es.close_stale_positions()
assert len(out) == 1
assert _impl_call(exec_mock)["price"] == 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"