"""kr_backtest_engine 청산: 라이브 모드/DB 비의존 + 실전 분할익절 규약.""" from datetime import date import pytest from app.core.config import settings from app.services import kr_backtest_engine as eng from app.services.kr_backtest_engine import ( BarData, MeanReversionBacktest, Position, check_exit_signal_backtest_v2, ) from app.services.sizing_core import remaining_pct_after_partial_sell @pytest.fixture(autouse=True) def _no_live_state(monkeypatch): """라이브 적응형·DB 값 조회는 호출 즉시 예외 (거짓 통과 방지).""" def boom(*a, **k): raise AssertionError("백테스트 청산이 라이브 상태를 읽음") import app.services.adaptive_strategy as adp import app.services.mean_reversion_strategy as mr monkeypatch.setattr(adp, "get_current_adaptive_params", boom) monkeypatch.setattr(mr, "resolve_swing_stop_params_v2", boom) monkeypatch.setattr(eng, "check_exit_signal", boom) def _p(**kw): return {**MeanReversionBacktest().default_params(), **kw} def test_stop_uses_params(): p = _p(stop_loss_pct=3.0, atr_stop_multiplier=settings.SWING_ATR_STOP_MULT_MAX) sig = check_exit_signal_backtest_v2(p, 50, 9690, 20000, 20000, 10000, 100, 1.0, None) assert sig and sig.sell_pct == 1.0 and "손절" in sig.reason assert check_exit_signal_backtest_v2(p, 50, 9750, 20000, 20000, 10000, 100, 1.0, None) is None def test_positive_stop_sign_guard(): sig = check_exit_signal_backtest_v2(_p(stop_loss_pct=7.0), 50, 10100, 20000, 20000, 10000, 100, 1.0, None) assert sig is None # 부호가 뒤집혀 +7% 로 해석되면 손절이 발동한다 def test_rsi_partial_threshold_from_params(): # close 10100 < bb_middle, 2xATR 미달 -> RSI 만 판정 args = (41, 10100, 20000, 20000, 10000, 100, 1.0, None) sig = check_exit_signal_backtest_v2(_p(rsi_exit_threshold=40), *args) assert sig and sig.action == "SELL_PARTIAL" and sig.sell_pct == 0.5 assert check_exit_signal_backtest_v2(_p(rsi_exit_threshold=60), *args) is None def _bars(n, price): return [BarData(trade_date=date(2024, 1, 1), open_price=price, high_price=price + 5, low_price=price - 5, close_price=price, volume=1000, stock_code="A") for _ in range(n)] @pytest.fixture def _ind(monkeypatch): monkeypatch.setattr(eng, "calculate_rsi", lambda *a, **k: float(settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD) + 1) monkeypatch.setattr(eng, "calculate_bollinger", lambda *a, **k: (20000.0, 20000.0, 8000.0)) monkeypatch.setattr(eng, "calculate_atr", lambda *a, **k: 10.0) def _pos(qty, orig, rem): return Position("A", date(2024, 1, 1), 10000.0, qty, 10100.0, remaining_pct=rem, original_quantity=orig) def test_partial_sell_qty_from_original_and_small_full(_ind): st = MeanReversionBacktest() bars = {"A": _bars(80, 10100)} o = st.on_bar(70, bars, {"A": _pos(7, 7, 1.0)}, 0, 1e7, _p(max_positions=1)) assert o and o[0]["quantity"] == 3 # 단계 소진(0.5) 이후엔 1차 익절 재발동 없음 assert st.on_bar(70, bars, {"A": _pos(4, 7, 0.5)}, 0, 1e7, _p(max_positions=1)) == [] # 잔량 3주 이하 + 신호 -> 전량 o = st.on_bar(70, bars, {"A": _pos(3, 7, 1.0)}, 0, 1e7, _p(max_positions=1)) assert o[0]["quantity"] == 3 def test_stage_remaining_pct_blocks_second_partial(): r = remaining_pct_after_partial_sell(1.0, 0.5, 4, 7) assert r <= 0.5 args = (settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD + 1, 10100, 20000, 20000, 10000, 100, r, None) assert check_exit_signal_backtest_v2(_p(), *args) is None