Files
auto-stock/backend/tests/test_swing_entry_hardening_r2_20261009.py
shuim d140055e64 fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강
- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강
- trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성)
- korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계)
- swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소)
- swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램
- daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거
- main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC)
- 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경)
- 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기
- CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기)
- 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:37:16 +09:00

247 lines
13 KiB
Python

"""swing/entry_service 하드닝 루프 라운드 2 회귀 테스트 (2026-10-09).
H KIS get_daily_chart(FHKST01010400)는 30봉 상한 → 눌림목(60봉)·박스권(40봉)이 구조적 미발화.
듀얼·박스권 스캔은 네이버 → KIS 기간 조회(FHKST03010100) → 기존 경로 순으로 일봉을 확보한다.
L 국내 가격 필터 경계 통일: MIN_PRICE_KRW '이하' 거부(§5.1 '>5,000원').
규약(CLAUDE.md §4.1): 도달하면 안 되는 경로의 목은 예외 발생형(_Reached, BaseException),
기대값은 settings.* 참조, 네이버(외부 HTTP)는 conftest 차단 대상이 아니므로 전부 목킹.
"""
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
import app.services.mean_reversion_strategy as mr
import app.services.naver_finance as nf
from app.core.config import settings
from app.core.timeutil import today_kst
class _Reached(BaseException):
"""도달하면 안 되는(또는 도달 확인용) 관문 — 프로덕션 except Exception 에 삼켜지지 않는다."""
def _bars(n: int, end_today: bool = True, start_close: float = 10000.0) -> list[dict]:
"""오래된 → 최신 순 KIS 필드명 일봉 n개. 마지막 봉이 오늘(KST) 또는 어제."""
last = today_kst() if end_today else today_kst() - timedelta(days=1)
out = []
for i in range(n):
d = last - timedelta(days=n - 1 - i)
c = start_close + i * 10
out.append({
"stck_bsop_date": d.strftime("%Y%m%d"),
"stck_oprc": str(c - 5), "stck_hgpr": str(c + 10),
"stck_lwpr": str(c - 10), "stck_clpr": str(c), "acml_vol": "100000",
})
return out
def _api(legacy_rows=None, range_rows=None, legacy_raises=False):
api = MagicMock()
api._quote_delegate = lambda: None
if legacy_raises:
api.get_daily_chart = AsyncMock(side_effect=_Reached("legacy get_daily_chart"))
else:
api.get_daily_chart = AsyncMock(return_value={"output2": legacy_rows or []})
api.get_daily_ohlcv_range = AsyncMock(return_value=range_rows or [])
return api
# ═══════════════════════════════════════════════════════════════════════════
# H — _fetch_scan_daily_bars_v2
# ═══════════════════════════════════════════════════════════════════════════
def test_kis_daily_price_30_rows_cannot_fire_pullback():
"""[결함 재현] 30봉으로는 눌림목 v2 가 길이 검사에서 무조건 None."""
closes = [float(b["stck_clpr"]) for b in _bars(30)]
assert mr.check_pullback_buy_signal_v2(
closes=closes, current_close=closes[-1], current_volume=10**9,
rsi=50.0, rsi_prev=40.0, volumes=[10**5] * 30, exclude_today_volume=True,
) is None
@pytest.mark.asyncio
async def test_fetch_prefers_naver_when_enough_bars(monkeypatch):
from app.services.swing import entry_service as es
nv = _bars(100)
monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=nv))
api = _api(legacy_raises=True)
api.get_daily_ohlcv_range = AsyncMock(side_effect=_Reached("kis range"))
bars, src = await es._fetch_scan_daily_bars_v2(api, "005930", es.DUAL_SCAN_MIN_BARS)
assert src == "naver"
assert len(bars) >= es.DUAL_SCAN_MIN_BARS
assert bars[-1]["stck_bsop_date"] == today_kst().strftime("%Y%m%d")
@pytest.mark.asyncio
async def test_fetch_falls_back_to_kis_range_sorted_oldest_first(monkeypatch):
from app.services.swing import entry_service as es
monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=None))
newest_first = list(reversed(_bars(90)))
api = _api(range_rows=newest_first, legacy_raises=True)
bars, src = await es._fetch_scan_daily_bars_v2(api, "005930", es.DUAL_SCAN_MIN_BARS)
assert src == "kis_range"
assert len(bars) == 90
dates = [b["stck_bsop_date"] for b in bars]
assert dates == sorted(dates)
assert dates[-1] == today_kst().strftime("%Y%m%d")
@pytest.mark.asyncio
async def test_fetch_rejects_source_without_today_bar(monkeypatch):
"""오늘 봉이 빠진 소스는 쓰지 않는다 — 어제 종가로 신호·주문가가 정해지는 것을 막는다."""
from app.services.swing import entry_service as es
monkeypatch.setattr(nf, "get_naver_daily_chart",
AsyncMock(return_value=_bars(100, end_today=False)))
api = _api(range_rows=list(reversed(_bars(90))), legacy_raises=True)
bars, src = await es._fetch_scan_daily_bars_v2(api, "005930", es.DUAL_SCAN_MIN_BARS)
assert src == "kis_range"
assert bars[-1]["stck_bsop_date"] == today_kst().strftime("%Y%m%d")
@pytest.mark.asyncio
async def test_fetch_uses_legacy_when_both_sources_fail(monkeypatch):
from app.services.swing import entry_service as es
monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(side_effect=RuntimeError("naver down")))
legacy_newest_first = list(reversed(_bars(30)))
api = _api(legacy_rows=legacy_newest_first)
api.get_daily_ohlcv_range = AsyncMock(side_effect=RuntimeError("kis down"))
bars, src = await es._fetch_scan_daily_bars_v2(api, "005930", es.DUAL_SCAN_MIN_BARS)
assert src == "kis_daily_price"
assert len(bars) == 30
assert [b["stck_bsop_date"] for b in bars] == sorted(b["stck_bsop_date"] for b in bars)
@pytest.mark.asyncio
async def test_fetch_short_but_today_prefers_longer_source_over_legacy(monkeypatch):
"""신규 상장 등으로 두 소스 모두 min_bars 미달이면 긴 쪽 — 기존 30봉 경로로 내려가지 않는다."""
from app.services.swing import entry_service as es
monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=_bars(45)))
api = _api(range_rows=list(reversed(_bars(50))), legacy_raises=True)
bars, src = await es._fetch_scan_daily_bars_v2(api, "005930", es.DUAL_SCAN_MIN_BARS)
assert (src, len(bars)) == ("kis_range", 50)
# ═══════════════════════════════════════════════════════════════════════════
# 듀얼 스캔 통합 — 실제 scan_dual_strategy_signals 호출
# ═══════════════════════════════════════════════════════════════════════════
class _DualDB:
def __init__(self, candidates):
self._candidates = candidates
async def execute(self, stmt):
r = MagicMock()
r.fetchall.return_value = []
r.scalars.return_value.all.return_value = list(self._candidates)
return r
@pytest.fixture
def dual_env(monkeypatch):
import app.core.market_schedule as ms
import app.services.korea_investment_api as kia
import app.services.swing.entry_service as es
import app.services.trade_executor as te
import app.services.adaptive_strategy as ad
monkeypatch.setattr(settings, "SWING_INDEX_BREAKER_ENABLED", False)
monkeypatch.setattr(settings, "MOMENTUM_BREAKOUT_ENABLED", True)
monkeypatch.setattr(ms, "is_market_open", lambda: True)
monkeypatch.setattr(es, "is_entry_allowed_by_playbook", lambda: True)
monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={
"regime_200d": "bull", "drop_from_ma20": 1.0, "max_trades": 5, "position_multiplier": 1.0}))
monkeypatch.setattr(es, "count_swing_positions", AsyncMock(return_value=0))
monkeypatch.setattr(es.mdd_manager, "can_open_new_position", AsyncMock(return_value=True))
monkeypatch.setattr(ad, "get_current_adaptive_params",
lambda: SimpleNamespace(fallback_used=False, trading_mode="normal"))
monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=0))
monkeypatch.setattr(es.asyncio, "sleep", AsyncMock(return_value=None))
api = _api(legacy_raises=True)
monkeypatch.setattr(kia, "korea_investment_api", api)
monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=_bars(100)))
monkeypatch.setattr(mr, "check_momentum_breakout_signal_v2", lambda **kw: None)
seen = {}
def _pullback_spy(**kw):
seen["n"] = len(kw["closes"])
return {"signal": "BUY", "strategy": "PULLBACK_BUY", "confidence": 0.6, "description": "t"}
monkeypatch.setattr(mr, "check_pullback_buy_signal_v2", _pullback_spy)
delegate = AsyncMock(side_effect=_Reached("delegated"))
monkeypatch.setattr(es, "execute_auto_trade_background", delegate)
stock = SimpleNamespace(stock_code="005930", stock_name="T", current_price=70000, score=5)
return SimpleNamespace(es=es, api=api, delegate=delegate, seen=seen, stock=stock)
@pytest.mark.asyncio
async def test_dual_scan_feeds_pullback_at_least_min_bars(dual_env):
"""[결함 반전] 이전엔 get_daily_chart 30봉만 전달돼 눌림목이 매번 길이 검사에서 탈락했다."""
with pytest.raises(_Reached):
await dual_env.es.scan_dual_strategy_signals(_DualDB([dual_env.stock]))
assert dual_env.seen["n"] >= dual_env.es.DUAL_SCAN_MIN_BARS
dual_env.api.get_daily_chart.assert_not_called()
rec = dual_env.delegate.call_args.args[0]
assert rec["recommendations_by_rule"][0]["stocks"][0]["strategy_name"] == "PULLBACK_BUY"
@pytest.mark.asyncio
async def test_dual_scan_warns_when_bars_short(monkeypatch, dual_env):
"""60봉 미달은 warning 으로 가시화(조용한 미발화 금지)."""
monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=None))
dual_env.api.get_daily_chart = AsyncMock(return_value={"output2": list(reversed(_bars(30)))})
monkeypatch.setattr(mr, "check_pullback_buy_signal_v2", lambda **kw: None)
calls = []
monkeypatch.setattr(dual_env.es, "warn_throttled",
lambda lg, key, msg, *a, **k: calls.append((key, msg)))
result = await dual_env.es.scan_dual_strategy_signals(_DualDB([dual_env.stock]))
assert result == []
short = [m for k, m in calls if k.startswith("entry_service:short_bars:DualStrategy")]
assert short and f"{dual_env.es.DUAL_SCAN_MIN_BARS}봉 미달" in short[0]
assert "005930:30봉" in short[0]
# ═══════════════════════════════════════════════════════════════════════════
# L — 가격 필터 경계 통일
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.parametrize("price,blocked", [
(lambda: settings.MIN_PRICE_KRW, True), # 정확히 경계 → 거부('초과'만 허용)
(lambda: settings.MIN_PRICE_KRW - 1, True),
(lambda: settings.MIN_PRICE_KRW + 1, False),
(lambda: None, True),
(lambda: "abc", True),
(lambda: float("nan"), True),
(lambda: float("inf"), True),
])
def test_below_min_price_krw_v2_boundary(price, blocked):
from app.services.swing.entry_service import _below_min_price_krw_v2
assert _below_min_price_krw_v2(price()) is blocked
@pytest.mark.asyncio
async def test_dual_scan_rejects_exact_min_price_before_chart(monkeypatch, dual_env):
"""[결함 반전] 듀얼 후보 단계가 `<` 라 정확히 MIN_PRICE_KRW 종목이 통과해 차트 조회·평가 슬롯을 썼다."""
stock = SimpleNamespace(stock_code="000001", stock_name="P",
current_price=settings.MIN_PRICE_KRW, score=5)
monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(side_effect=_Reached("chart fetched")))
result = await dual_env.es.scan_dual_strategy_signals(_DualDB([stock]))
assert result == []
dual_env.delegate.assert_not_called()
@pytest.mark.asyncio
async def test_dual_scan_rejects_when_chart_close_at_min_price(monkeypatch, dual_env):
"""후보 가격은 통과해도 차트 현재 종가가 MIN_PRICE_KRW 이하이면 진입 위임하지 않는다."""
bars = _bars(100, start_close=float(settings.MIN_PRICE_KRW) - 99 * 10)
assert float(bars[-1]["stck_clpr"]) == float(settings.MIN_PRICE_KRW)
monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=bars))
result = await dual_env.es.scan_dual_strategy_signals(_DualDB([dual_env.stock]))
assert result == []
dual_env.delegate.assert_not_called()
from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지)