Files
auto-stock/backend/tests/test_mr_backtest_entry_deterministic_20261009.py
shuim a264561576 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
2026-10-10 12:17:49 +09:00

174 lines
9.2 KiB
Python

"""하드닝 R2 (2026-10-09) — 백테스트 진입 판정 결정론화 회귀 테스트.
결함: kr_backtest_engine 의 진입 판정이 라이브 check_buy_signal 을 호출해, 실행 시각의
라이브 적응형 모드(get_current_adaptive_params)와 운영 DB(strategy_cfg)로 RSI 임계·%B 스케일이
정해졌다. 수정: check_buy_signal_backtest_v2(params, ...) 는 둘 다 읽지 않고 params > settings.
"""
import ast
import os
from types import SimpleNamespace
import pytest
from app.core.config import settings
from app.services import mean_reversion_strategy as mrs
from app.services.mean_reversion_strategy import MarketRegime
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
class LiveStateRead(BaseException):
"""백테스트 경로가 라이브 상태를 읽으면 발생 — except Exception 폴백에 삼켜지지 않게 BaseException."""
def _boom(*a, **k):
raise LiveStateRead("백테스트 진입 판정이 라이브 상태(adaptive/strategy_cfg)를 읽었다")
@pytest.fixture
def forbid_live_state(monkeypatch):
import app.services.adaptive_strategy as adp
monkeypatch.setattr(adp, "get_current_adaptive_params", _boom)
monkeypatch.setattr(mrs.strategy_cfg, "get", _boom)
def _bull_kwargs(rsi, close, rsi_prev=None):
return dict(
regime=MarketRegime.BULL, rsi=rsi, close=close,
bb_upper=11000.0, bb_middle=10500.0, bb_lower=10000.0,
avg_volume_20d=1_000_000, current_volume=1_200_000, prev_volume=900_000,
is_reversal=False, rsi_prev=rsi_prev,
)
def _bear_kwargs(rsi, volume_mult, reversal=True, rsi_prev=None):
return dict(
regime=MarketRegime.BEAR, rsi=rsi, close=10100.0, # %B = 0.10
bb_upper=11000.0, bb_middle=10500.0, bb_lower=10000.0,
avg_volume_20d=1_000_000, current_volume=int(1_000_000 * volume_mult), prev_volume=900_000,
is_reversal=reversal, rsi_prev=rsi_prev,
)
# ── 1. 라이브 상태를 전혀 읽지 않는다 ────────────────────────────────────────
def test_backtest_entry_never_reads_adaptive_or_strategy_cfg(forbid_live_state):
thr = float(settings.MEAN_REVERSION_RSI_THRESHOLD)
bear_thr = float(settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD)
# BULL 발화 / BULL 미발화 / BEAR 발화 / BEAR v2 경로 — 어느 것도 라이브 상태를 읽지 않아야 한다
assert mrs.check_buy_signal_backtest_v2({}, **_bull_kwargs(thr - 20, 10100.0)) is not None
assert mrs.check_buy_signal_backtest_v2({}, **_bull_kwargs(thr + 1, 10100.0)) is None
vm = float(settings.MEAN_REVERSION_BEAR_VOLUME_MULT) + 0.5
assert mrs.check_buy_signal_backtest_v2(None, **_bear_kwargs(bear_thr - 1, vm)) is not None
mrs.check_buy_signal_backtest_v2({"bear_v2_enabled": True}, **_bear_kwargs(bear_thr - 1, vm))
def test_live_check_buy_signal_still_reads_live_state(forbid_live_state):
"""대조군: 라이브 함수는 여전히 adaptive/strategy_cfg 를 읽는다(§0 우선순위 유지)."""
with pytest.raises(LiveStateRead):
mrs.check_buy_signal(**_bull_kwargs(float(settings.MEAN_REVERSION_RSI_THRESHOLD) - 20, 10100.0))
# ── 2. 같은 입력 → 라이브 모드와 무관하게 같은 결과 ──────────────────────────
@pytest.mark.parametrize("live_mode", [
SimpleNamespace(fallback_used=False, rsi_entry=50.0, bb_pct_b=0.39, bear_rsi_entry=40.0), # Aggressive 류
SimpleNamespace(fallback_used=False, rsi_entry=38.0, bb_pct_b=0.25, bear_rsi_entry=25.0), # Defensive 류
SimpleNamespace(fallback_used=True, rsi_entry=99.0, bb_pct_b=0.99, bear_rsi_entry=99.0),
])
def test_backtest_entry_independent_of_live_mode(monkeypatch, live_mode):
import app.services.adaptive_strategy as adp
monkeypatch.setattr(adp, "get_current_adaptive_params", lambda: live_mode)
monkeypatch.setattr(mrs.strategy_cfg, "get", lambda k, *a, **kw: 1.0) # 운영 DB 의 이상값 흉내
thr = float(settings.MEAN_REVERSION_RSI_THRESHOLD)
# thr+2 는 settings 기준 미발화 — 라이브 Aggressive(50) 가 새면 발화한다
assert mrs.check_buy_signal_backtest_v2({}, **_bull_kwargs(thr + 2, 10100.0)) is None
# 기본 스케일 1.0: RSI 30~임계 구간 %B 상한 0.20 → %B 0.25 는 미발화 (스케일 1.3 이 새면 발화)
assert mrs.check_buy_signal_backtest_v2({}, **_bull_kwargs(thr - 5, 10250.0)) is None
r = mrs.check_buy_signal_backtest_v2({}, **_bull_kwargs(thr - 5, 10150.0))
assert r is not None and f"<{thr:.0f}" in r["description"]
# ── 3. params 주입이 반영된다 (모드별 백테스트 가능) ────────────────────────
def test_params_rsi_entry_and_bb_pct_b_injected(forbid_live_state):
thr = float(settings.MEAN_REVERSION_RSI_THRESHOLD)
rsi = thr + 2
assert mrs.check_buy_signal_backtest_v2({}, **_bull_kwargs(rsi, 10100.0)) is None
r = mrs.check_buy_signal_backtest_v2({"rsi_entry": thr + 4}, **_bull_kwargs(rsi, 10100.0))
assert r is not None and f"<{thr + 4:.0f}" in r["description"]
# bb_pct_b=0.39 → 스케일 1.3(상한) → %B 상한 0.26 → %B 0.25 발화
assert mrs.check_buy_signal_backtest_v2({}, **_bull_kwargs(thr - 5, 10250.0)) is None
assert mrs.check_buy_signal_backtest_v2({"bb_pct_b": 0.39}, **_bull_kwargs(thr - 5, 10250.0)) is not None
# 상한 1.3 유지: bb_pct_b=0.9 여도 0.26 을 넘는 %B 0.30 은 미발화
assert mrs.check_buy_signal_backtest_v2({"bb_pct_b": 0.9}, **_bull_kwargs(thr - 5, 10300.0)) is None
def test_params_bear_threshold_and_volume_injected(forbid_live_state):
bear_thr = float(settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD)
base_vm = float(settings.MEAN_REVERSION_BEAR_VOLUME_MULT)
ok_vm = base_vm + 0.5
assert mrs.check_buy_signal_backtest_v2({}, **_bear_kwargs(bear_thr + 1, ok_vm)) is None
assert mrs.check_buy_signal_backtest_v2(
{"bear_rsi_entry": bear_thr + 3}, **_bear_kwargs(bear_thr + 1, ok_vm)) is not None
# 거래량 배수 상향 주입 시 미발화
assert mrs.check_buy_signal_backtest_v2(
{"bear_volume_mult": ok_vm + 1.0}, **_bear_kwargs(bear_thr - 1, ok_vm)) is None
@pytest.mark.parametrize("bad", ["abc", float("nan"), float("inf"), -5.0, 0.0, 150.0])
def test_invalid_rsi_entry_falls_back_to_settings(forbid_live_state, bad):
thr = float(settings.MEAN_REVERSION_RSI_THRESHOLD)
# 비정상 값은 settings 로 폴백 — 150 이 그대로 쓰이면 모든 봉이 진입한다
assert mrs.check_buy_signal_backtest_v2({"rsi_entry": bad}, **_bull_kwargs(thr + 2, 10100.0)) is None
# ── 4. 라이브 함수와 동일 로직 (출처만 다르다) ───────────────────────────────
def test_live_and_backtest_parity_for_same_effective_params(monkeypatch):
import app.services.adaptive_strategy as adp
mode = SimpleNamespace(fallback_used=False, rsi_entry=50.0, bb_pct_b=0.36, bear_rsi_entry=30.0)
monkeypatch.setattr(adp, "get_current_adaptive_params", lambda: mode)
monkeypatch.setattr(mrs.strategy_cfg, "get", lambda k, *a, **kw: None)
params = {"rsi_entry": 50.0, "bb_pct_b": 0.36, "bear_rsi_entry": 30.0}
cases = [_bull_kwargs(r, c, rp) for r in (22.0, 28.0, 45.0, 49.0, 51.0)
for c in (10050.0, 10250.0, 10400.0) for rp in (None, 10.0, 60.0)]
cases += [_bear_kwargs(r, vm, rev) for r in (20.0, 29.0, 31.0)
for vm in (1.0, 2.5) for rev in (True, False)]
for kw in cases:
assert mrs.check_buy_signal(**kw) == mrs.check_buy_signal_backtest_v2(params, **kw), kw
# ── 5. 엔진 배선: kr_backtest_engine 은 라이브 check_buy_signal 을 호출하지 않는다 ──
def test_kr_backtest_engine_uses_backtest_entry():
p = os.path.join(BACKEND_DIR, "app", "services", "kr_backtest_engine.py")
with open(p, encoding="utf-8") as f:
tree = ast.parse(f.read())
called = set()
for node in ast.walk(tree):
if isinstance(node, ast.Call):
fn = node.func
called.add(fn.id if isinstance(fn, ast.Name) else getattr(fn, "attr", None))
assert "check_buy_signal" not in called
assert "check_buy_signal_backtest_v2" in called
assert "get_current_adaptive_params" not in called
def test_kr_backtest_on_bar_passes_params(monkeypatch):
import app.services.kr_backtest_engine as eng
captured = []
def spy(params, **kw):
captured.append(params)
return None
monkeypatch.setattr(eng, "check_buy_signal_backtest_v2", spy)
monkeypatch.setattr(eng, "calculate_rsi", lambda c, p=14: 30.0)
monkeypatch.setattr(eng, "calculate_bollinger", lambda c, p=20: (110.0, 100.0, 90.0))
from datetime import date, timedelta
s = eng.MeanReversionBacktest()
p = s.default_params()
p["rsi_entry"] = 41.0
n = 80
d0 = date(2025, 1, 2)
bars = [eng.BarData(trade_date=d0 + timedelta(days=i), open_price=10000 + i, high_price=10100 + i,
low_price=9900 + i, close_price=10000 + i, volume=1000, stock_code="A")
for i in range(n)]
s.on_bar(n - 1, {"A": bars}, {}, 1e6, 1e6, p)
assert captured and captured[0] is p and captured[0]["rsi_entry"] == 41.0