Files
auto-stock/backend/tests/test_daytrading_stale_position_guard.py
shuim 1b9257c4d2 fix: 매매 파라미터 3중 소스 정합 + 리스크 가드 복원 + 죽은 코드 2,695줄 제거
## 배경
"전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라
전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다.

## 파라미터 소스 단일화
- 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인
  config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%).
- DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신
  (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등)

## 백테스트를 실전 로직에 정합
- calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트
  `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향
- 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치
- us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명)
- backend/backtest_results/ 25건은 폐기 대상 (§8.4)

## 리스크 가드 복원
- 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로
- 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다)
- 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동
- MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용)
- 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정

## 죽은 코드 제거 (2,695줄)
- short_term_strategy, kosdaq_scanner, app/strategy/ 패키지
- app/trading/ + korea_investment_api_adapter (real_market_api 정본은
  korea_investment_api.py:1482, adapter 는 import 0건)

## 지표·사이징 단일화 (수치 무변경 증명)
- indicators_core.py: 3,274건 비교 최대 오차 0.000e+00
- sizing_core.py: 42,105건 비교 불일치 0
- 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교)

## 테스트 안전 (실 KIS 호출 차단)
- 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이
  close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로
  바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤
  참조 누수로 실제 잔고 조회를 타 141초를 소비했다.
- backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단.
  예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게),
  클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제
- 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed)

## 신규 (둘 다 플래그 OFF, 백테스트 미검증)
- range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선
- check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%)

## 문서
- CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약,
  §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거,
  §8.5 미해결 19항목
- 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 08:58:18 +09:00

210 lines
8.3 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
@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_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 _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_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_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"