"""kr_backtest_engine R4: 장중 저가 손절 + 스윙 시간손절 + 모멘텀 전용 청산. 라이브 적응형/DB 조회는 호출 즉시 예외(거짓 통과 방지). 기대값은 settings 참조. """ 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, KrBacktestEngine, MeanReversionBacktest, Position, check_momentum_exit_backtest_v2, check_time_stop_backtest_v2, intraday_stop_fill_v2, ) @pytest.fixture(autouse=True) def _no_live_state(monkeypatch): 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) 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 _p(**kw): return {**MeanReversionBacktest().default_params(), **kw} def _bar(i, o, h, l, c): return BarData(trade_date=date.fromordinal(738000 + i), open_price=o, high_price=h, low_price=l, close_price=c, volume=1000, stock_code="A") def _flat(n, price=10000): return [_bar(i, price, price + 5, price - 5, price) for i in range(n)] # ---- 순수 함수 ---- def test_intraday_stop_fill_low_touch_and_gap(): p = _p(atr_stop_multiplier=settings.ATR_STOP_MULTIPLIER) # ATR=10 -> stop = max(10000-2.5*10, 10000*(1-pct)) = 9975 assert intraday_stop_fill_v2(p, 10000, 10, 10000, 10000, 9980) is None base, stop = intraday_stop_fill_v2(p, 10000, 10, 10000, 10000, 9970) assert stop == pytest.approx(9975) and base == pytest.approx(9975) base, stop = intraday_stop_fill_v2(p, 10000, 10, 10000, 9800, 9700) # 갭다운 -> 시가 assert base == 9800 def test_intraday_stop_sign_guard(): # 양수 손절률이 들어와도 +7% 로 해석되면 안 된다 p = _p(stop_loss_pct=abs(settings.STOP_LOSS_PERCENT), atr_stop_multiplier=settings.SWING_ATR_STOP_MULT_MAX) assert intraday_stop_fill_v2(p, 10000, 10, 10000, 10000, 9990) is None def test_time_stop_boundaries_use_settings(): d = settings.SWING_TIME_STOP_DAYS m = settings.SWING_TIME_STOP_MIN_PROFIT_PCT assert check_time_stop_backtest_v2(_p(), d - 1, m - 1) is None assert check_time_stop_backtest_v2(_p(), d, m + 0.1) is None sig = check_time_stop_backtest_v2(_p(), d, m - 0.1) assert sig and sig.sell_pct == 1.0 and "시간손절" in sig.reason assert check_time_stop_backtest_v2(_p(swing_time_stop_enabled=False), d, 0.0) is None def test_momentum_exit_stages(): p = _p() s1 = check_momentum_exit_backtest_v2(p, 11100, 10000, 10, 1.0, 11100) assert s1 and s1.action == "SELL_PARTIAL" and s1.sell_pct == 0.30 assert check_momentum_exit_backtest_v2(p, 11100, 10000, 10, 0.70, 11100) is None s2 = check_momentum_exit_backtest_v2(p, 12100, 10000, 10, 0.70, 12100) assert s2 and s2.sell_pct == 0.30 stop = check_momentum_exit_backtest_v2(p, 9000, 10000, 10, 1.0, 10000) assert stop and stop.sell_pct == 1.0 trail = check_momentum_exit_backtest_v2(p, 11000, 10000, 100, 0.40, 11500) # 고점-1.5*ATR=11350 assert trail and trail.sell_pct == 1.0 and "트레일링" in trail.reason # ---- on_bar 통합: 시간손절 우선순위 / 모멘텀 분기 ---- def _pos(**kw): base = dict(stock_code="A", entry_date=date(2024, 1, 1), entry_price=10000.0, quantity=7, highest_since_entry=10100.0, remaining_pct=1.0, original_quantity=7) base.update(kw) return Position(**base) def test_time_stop_overrides_partial_signal(): st = MeanReversionBacktest() bars = {"A": _flat(80, 10100)} d = settings.SWING_TIME_STOP_DAYS o = st.on_bar(70, bars, {"A": _pos(entry_bar_idx=70 - d)}, 0, 1e7, _p(max_positions=1)) assert o and o[0]["sell_pct"] == 1.0 and "시간손절" in o[0]["reason"] and o[0]["quantity"] == 7 o = st.on_bar(70, bars, {"A": _pos(entry_bar_idx=70 - d + 1)}, 0, 1e7, _p(max_positions=1)) assert o and o[0]["sell_pct"] == 0.5 def test_momentum_position_skips_time_stop(): st = MeanReversionBacktest() bars = {"A": _flat(80, 10100)} d = settings.SWING_TIME_STOP_DAYS o = st.on_bar(70, bars, {"A": _pos(entry_bar_idx=70 - d, strategy="MOMENTUM_BREAKOUT")}, 0, 1e7, _p(max_positions=1)) assert o == [] def test_momentum_position_uses_momentum_exit(): st = MeanReversionBacktest() bars = {"A": _flat(80, 11100)} o = st.on_bar(70, bars, {"A": _pos(highest_since_entry=11100.0, strategy="MOMENTUM_BREAKOUT")}, 0, 1e7, _p(max_positions=1)) assert o and o[0]["sell_pct"] == 0.30 and "모멘텀" in o[0]["reason"] # ---- 엔진 A-B: 장중 터치 후 종가 회복 ---- class _BuyOnce(MeanReversionBacktest): def on_bar(self, bar_idx, bars, positions, cash, total_equity, params): if bar_idx == 64 and "A" not in positions: return [{"action": "BUY", "stock_code": "A", "price": 10000.0, "quantity": 10, "reason": "t", "sell_pct": 0.0}] return [] def _run(intraday, bars_list): dates = [b.trade_date for b in bars_list] params = _p(intraday_stop_enabled=intraday, stop_slippage_pct=0.0) res = KrBacktestEngine()._simulate({"A": bars_list}, dates, _BuyOnce(), params, 10_000_000, "KOSPI", 1) return [t for t in res.trades if t["side"] == "SELL"] def _series(low_bar_open=10000, low=9900): bars = _flat(70, 10000) bars[67] = _bar(67, low_bar_open, 10005, low, 10000) # 장중 급락 후 종가 회복 return bars def test_engine_ab_intraday_stop_counts(): on = _run(True, _series()) off = _run(False, _series()) assert len(on) == 1 and "장중손절" in on[0]["reason"] assert len(off) == 0 assert on[0]["exit_price"] == pytest.approx(on[0]["entry_price"] - 25.0) def test_engine_gap_down_fills_at_open(): on = _run(True, _series(low_bar_open=9700, low=9600)) assert len(on) == 1 and on[0]["exit_price"] == pytest.approx(9700.0) def test_no_lookahead_atr_uses_prev_bars(monkeypatch): seen = [] def fake_atr(h, l, c, period=14): seen.append(len(c)) return 10.0 monkeypatch.setattr(eng, "calculate_atr", fake_atr) st = MeanReversionBacktest() st.intraday_exits_v2(70, {"A": _flat(80)}, {"A": _pos()}, _p()) assert seen == [70]