"""스윙 청산 RSI 워밍업 (하드닝 R4 2026-10-09) — swing/exit_service 회귀 테스트. [MEDIUM] 청산 RSI 가 KIS 30봉 일봉으로 계산돼 Wilder RSI(14) 시드 영향이 남았다(운영 실측 RSI≥55 판정 불일치 6.9%). 이제 장기 이력(네이버 → KIS 기간 조회)을 하루 1회 캐시하고 KIS 30봉과 날짜로 병합해 RSI 를 계산한다. 실패·불일치·대기 초과면 기존 30봉 RSI 로 폴백한다. 규약(CLAUDE.md §4.1): 외부 조회 목은 예외 발생형 또는 명시적 반환, 주문 목은 공통 종단. """ import asyncio from datetime import timedelta from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest import app.services.mean_reversion_strategy as mr from app.services.mean_reversion_strategy import ExitSignal, calculate_rsi @pytest.fixture(autouse=True) def _reset(monkeypatch): import app.services.swing.exit_service as es for name in ("_pending_unknown_sells", "_last_live_prices", "_last_atr", "_presell_check_failures", "_no_price_skips", "_recent_exits", "_swing_exit_error_counts", "_swing_sell_reject_counts", "_swing_sell_reject_last", "_exit_history_cache", "_exit_history_tasks"): monkeypatch.setattr(es, name, {}) monkeypatch.setattr(es, "_exit_rsi_source_counts", {"merged": 0, "kis30": 0}) from app.core import log_throttle log_throttle.reset_for_tests() # 실수로 외부 조회가 일어나면 즉시 실패(예외 발생형). 개별 테스트가 필요 시 덮는다. import app.services.naver_finance as nf monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(side_effect=AssertionError("네이버 실호출 금지"))) return es def _dates(n, end=None): from app.core.timeutil import today_kst end = end or today_kst() out, d = [], end while len(out) < n: if d.weekday() < 5: out.append(d.strftime("%Y%m%d")) d -= timedelta(days=1) return list(reversed(out)) def _series(n): # 결정적 등락 — 30봉 RSI 와 전체 이력 RSI 가 확실히 갈리도록 앞쪽에 강한 추세를 둔다. closes, p = [], 10000.0 for i in range(n): p += (120 if i < n - 40 else 0) + (((i * 37) % 11) - 5) * 30 closes.append(round(p, 0)) return closes def _bars(dates, closes): return [{"stck_bsop_date": d, "stck_clpr": str(c), "stck_hgpr": str(c + 100), "stck_lwpr": str(c - 100)} for d, c in zip(dates, closes)] def _fixture_bars(n_hist=130, n_recent=30): dates = _dates(n_hist) closes = _series(n_hist) history = _bars(dates[:-1], closes[:-1]) # 이력은 어제까지 recent = _bars(dates[-n_recent:], closes[-n_recent:]) # KIS 30봉 (오늘 포함) return history, recent, closes # ═══════════════════════════════════════════════════════════════════════════ # 병합 순수 로직 # ═══════════════════════════════════════════════════════════════════════════ def test_merge_gives_full_history_rsi(_reset): es = _reset history, recent, closes = _fixture_bars() merged = es._merge_exit_bars_v2(history, recent) assert merged is not None and len(merged) == len(closes) rsi_full = calculate_rsi([float(b["stck_clpr"]) for b in merged]) assert rsi_full == pytest.approx(calculate_rsi(closes)) # 결함 재현: 30봉 RSI 는 전체 이력 RSI 와 다르다 assert abs(calculate_rsi(closes[-30:]) - rsi_full) > 0.5 def test_merge_today_bar_comes_from_recent_even_if_history_differs(_reset): es = _reset history, recent, closes = _fixture_bars() # 이력에 오늘 봉이 섞여 있고 값이 다르다(장중 진행) → 최근 봉이 이기고 검증 대상에서 제외 today = recent[-1]["stck_bsop_date"] history = history + [{"stck_bsop_date": today, "stck_clpr": str(closes[-1] * 1.05)}] merged = es._merge_exit_bars_v2(history, recent) assert merged is not None and merged[-1] is recent[-1] def test_merge_rejects_close_mismatch(_reset): es = _reset history, recent, _ = _fixture_bars() # 겹치는 확정 봉 하나의 종가가 허용 오차를 넘게 다르다(수정주가 차이 등) bad = dict(recent[5]) tol = es._EXIT_HISTORY_CLOSE_TOL bad["stck_clpr"] = str(float(bad["stck_clpr"]) * (1 + tol * 3)) recent = recent[:5] + [bad] + recent[6:] assert es._merge_exit_bars_v2(history, recent) is None def test_merge_rejects_no_overlap_or_no_older(_reset): es = _reset history, recent, _ = _fixture_bars() # 겹침 없음(이력이 최근 봉 시작 이전에서 끊김) first = recent[0]["stck_bsop_date"] gap_hist = [b for b in history if b["stck_bsop_date"] < first] assert es._merge_exit_bars_v2(gap_hist, recent) is None # 더 이른 봉이 없음(이득 없음) only_overlap = [b for b in history if b["stck_bsop_date"] >= first] assert es._merge_exit_bars_v2(only_overlap, recent) is None assert es._merge_exit_bars_v2(None, recent) is None # ═══════════════════════════════════════════════════════════════════════════ # _exit_rsi_v2 — 조회·캐시·폴백 # ═══════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_exit_rsi_uses_history_and_caches_daily(monkeypatch, _reset): es = _reset history, recent, closes = _fixture_bars() fetch = AsyncMock(return_value=(history, "naver")) monkeypatch.setattr(es, "_fetch_exit_history_bars_v2", fetch) c30 = [float(b["stck_clpr"]) for b in recent] r1 = await es._exit_rsi_v2("005930", recent, c30) r2 = await es._exit_rsi_v2("005930", recent, c30) assert r1 == pytest.approx(calculate_rsi(closes)) and r2 == r1 assert fetch.await_count == 1 # 하루 1회 assert es._exit_rsi_source_counts["merged"] == 2 @pytest.mark.asyncio async def test_exit_rsi_falls_back_when_sources_raise(monkeypatch, _reset): """네이버·KIS 기간 조회가 모두 예외 → 30봉 RSI, 실패는 재시도 간격 동안 캐시.""" es = _reset import app.services.naver_finance as nf naver = AsyncMock(side_effect=RuntimeError("naver down")) monkeypatch.setattr(nf, "get_naver_daily_chart", naver) api = MagicMock() api._quote_delegate = lambda: None api.get_daily_ohlcv_range = AsyncMock(side_effect=RuntimeError("kis down")) monkeypatch.setattr(es, "korea_investment_api", api) _, recent, _ = _fixture_bars() c30 = [float(b["stck_clpr"]) for b in recent] r = await es._exit_rsi_v2("005930", recent, c30) assert r == pytest.approx(calculate_rsi(c30)) r = await es._exit_rsi_v2("005930", recent, c30) assert naver.await_count == 1 and api.get_daily_ohlcv_range.await_count == 1 assert es._exit_rsi_source_counts["kis30"] == 2 @pytest.mark.asyncio async def test_exit_rsi_kis_range_used_when_naver_fails(monkeypatch, _reset): es = _reset import app.services.naver_finance as nf monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(side_effect=RuntimeError("x"))) history, recent, closes = _fixture_bars() api = MagicMock() api._quote_delegate = lambda: None api.get_daily_ohlcv_range = AsyncMock(return_value=list(reversed(history))) # KIS 는 최신→과거 monkeypatch.setattr(es, "korea_investment_api", api) c30 = [float(b["stck_clpr"]) for b in recent] r = await es._exit_rsi_v2("005930", recent, c30) assert r == pytest.approx(calculate_rsi(closes)) @pytest.mark.asyncio async def test_exit_rsi_slow_fetch_does_not_block_cycle(monkeypatch, _reset): """조회가 대기 상한을 넘기면 이번 사이클은 30봉 RSI, 완료 후 다음 사이클에 병합 RSI.""" es = _reset history, recent, closes = _fixture_bars() gate = asyncio.Event() async def _slow(code): await gate.wait() return history, "naver" monkeypatch.setattr(es, "_fetch_exit_history_bars_v2", _slow) monkeypatch.setattr(es, "EXIT_HISTORY_WAIT_SEC", 0.01) c30 = [float(b["stck_clpr"]) for b in recent] r1 = await es._exit_rsi_v2("005930", recent, c30) assert r1 == pytest.approx(calculate_rsi(c30)) gate.set() await asyncio.sleep(0) for _ in range(5): if not es._exit_history_tasks: break await asyncio.sleep(0.01) r2 = await es._exit_rsi_v2("005930", recent, c30) assert r2 == pytest.approx(calculate_rsi(closes)) @pytest.mark.asyncio async def test_exit_rsi_undated_bars_skip_fetch(monkeypatch, _reset): es = _reset fetch = AsyncMock(side_effect=AssertionError("날짜 없는 봉에서 이력 조회 금지")) monkeypatch.setattr(es, "_fetch_exit_history_bars_v2", fetch) recent = [{"stck_clpr": str(10000 + i * 10)} for i in range(30)] c30 = [float(b["stck_clpr"]) for b in recent] r = await es._exit_rsi_v2("005930", recent, c30) assert r == pytest.approx(calculate_rsi(c30)) assert fetch.await_count == 0 # ═══════════════════════════════════════════════════════════════════════════ # 청산 루프 통합 — check_exit_signal 에 전달되는 RSI, 손절 우선 # ═══════════════════════════════════════════════════════════════════════════ def _loop_env(monkeypatch, es, recent, captured, signal=None): import app.services.trade_executor as te import app.services.db_worker as dbw import app.services.portfolio_cache_bridge as pcb import app.services.adaptive_strategy as ad from app.core.timeutil import now_kst from app.services.daytrading.position_manager import day_trading_state price = float(recent[-1]["stck_clpr"]) monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True)) monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None) monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) holding = SimpleNamespace(stock_code="005930", stock_name="T", quantity=10, average_price=price, current_price=price, market="KRX", updated_at=now_kst()) monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(return_value=[holding])) monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"})) at = SimpleNamespace(strategy_name="swing", remaining_pct=1.0, highest_since_entry=None, created_at=now_kst() - timedelta(days=2), entry_reason=None, entry_price=price) monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(return_value=at)) api = MagicMock() api._quote_delegate = lambda: None api.get_daily_chart = AsyncMock(return_value={"output2": list(reversed(recent))}) api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(int(price))}}) api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [ {"pdno": "005930", "hldg_qty": "10", "ord_psbl_qty": "10"}]}) api.get_daily_ohlcv_range = AsyncMock(side_effect=RuntimeError("kis range down")) monkeypatch.setattr(es, "korea_investment_api", api) def _ces(**kw): captured.append(kw["rsi"]) return signal monkeypatch.setattr(es, "check_exit_signal", _ces) monkeypatch.setattr(es, "_time_stop_signal_v2", lambda *a, **k: None) monkeypatch.setattr(es, "_resolve_time_stop_entry_v3", AsyncMock(return_value=None)) monkeypatch.setattr(day_trading_state, "positions", {}) monkeypatch.setattr(day_trading_state, "save_daily_state", AsyncMock()) sent = [] async def _spo(**kw): sent.append((kw["stock_code"], kw["quantity"])) return {"rt_cd": "0", "output": {"ODNO": "9"}} monkeypatch.setattr(te, "safe_place_order", _spo) monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock()) monkeypatch.setattr(es, "enqueue_close_active_trade", AsyncMock()) monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", AsyncMock()) monkeypatch.setattr(dbw, "enqueue_db_write", AsyncMock()) monkeypatch.setattr(pcb, "reflect_sell", AsyncMock()) monkeypatch.setattr(es, "_schedule_swing_sell_fill_check_v2", MagicMock()) monkeypatch.setattr(es, "_prior_position_realized_net_v2", AsyncMock(return_value=None)) monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", AsyncMock()) monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock()) return sent @pytest.mark.asyncio async def test_exit_loop_passes_full_history_rsi(monkeypatch, _reset): es = _reset history, recent, closes = _fixture_bars() import app.services.naver_finance as nf monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=history)) captured = [] _loop_env(monkeypatch, es, recent, captured) es._recent_exits.clear() await es.check_and_execute_exit_v2(MagicMock()) assert captured and captured[0] == pytest.approx(calculate_rsi(closes)) assert captured[0] != pytest.approx(calculate_rsi([float(b["stck_clpr"]) for b in recent])) @pytest.mark.asyncio async def test_exit_loop_history_failure_keeps_stop_loss(monkeypatch, _reset): """이력 조회가 전부 실패해도 30봉 RSI 로 판정되고 손절 신호는 그대로 주문된다(§8.1-11).""" es = _reset _, recent, _ = _fixture_bars() import app.services.naver_finance as nf monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(side_effect=RuntimeError("down"))) captured = [] sent = _loop_env(monkeypatch, es, recent, captured, signal=ExitSignal(action="SELL_ALL", reason="손절 테스트", sell_pct=1.0)) es._recent_exits.clear() await es.check_and_execute_exit_v2(MagicMock()) assert captured[0] == pytest.approx(calculate_rsi([float(b["stck_clpr"]) for b in recent])) assert sent == [("005930", 10)]