- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
169 lines
6.7 KiB
Python
169 lines
6.7 KiB
Python
"""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]
|