fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정

- 스윙 체결가 확인 전 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
This commit is contained in:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -0,0 +1,168 @@
"""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]