Files
auto-stock/backend/tests/test_swing_r3_fixes_20261002.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

960 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""스윙 도메인 3회차 검증 결함 회귀 테스트 (2026-10-02).
재현 테스트(scratchpad r3/test_r3_swing.py, test_r3_dual.py)를 "결함 재현 → 수정 후
기대 동작"으로 뒤집어 옮겼다. 모두 소스 문자열 검사가 아니라 실제 호출이다.
C-1 청산 판정 예외 시에도 폴백 손절이 돈다 / 손절 파라미터 해석 실패 시 config 바닥값
H-1 듀얼 스캔에 플레이북·지수 서킷브레이커 게이트
M-1 스케줄러: 평균회귀 진입 → 듀얼 순서, 평균회귀 조기 종료·예외가 듀얼을 막지 않음,
일일 한도 도달 시 듀얼 일봉 조회 생략
H-3 시간손절 = KRX 거래일 기준, 동기화 유입 행은 원 BUY 일자 기준
M-3 잔고 rt_cd≠'0'/총자산≤0 → MDD 산출 실패 경로
M-4 detect_market_regime_v2 는 실전 히스테리시스 전역을 건드리지 않는다
H-4 모멘텀 v2·눌림목 v2·resolve_swing_stop_params_v2·_time_stop_signal_v2·
_regime_200d_or_none_v2·_adaptive_entry_block_reason_v2·_run_dual_strategy_scan_v2·
remaining_pct_after_partial_sell·_resolve_mdd_on_failure_v2·H2 히스테리시스
규약(CLAUDE.md §4.1): 주문 경로 목은 예외 발생형(_Reached, BaseException).
기대값은 settings.* 참조.
"""
import asyncio
from datetime import date, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
import app.services.mean_reversion_strategy as mr
from app.core.config import settings
class _Reached(BaseException):
"""주문/위임 관문 도달 신호 — 프로덕션 except Exception 에 삼켜지지 않는다."""
@pytest.fixture(autouse=True)
def _reset_regime_state(monkeypatch):
monkeypatch.setattr(mr, "_previous_regime", None)
from app.core import log_throttle
log_throttle.reset_for_tests()
yield
def _no_adaptive(monkeypatch):
import app.services.adaptive_strategy as ad
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True))
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None)
# ═══════════════════════════════════════════════════════════════════════════
# C-1 / H-3 — swing/exit_service.check_and_execute_exit_v2
# ═══════════════════════════════════════════════════════════════════════════
def _exit_harness(monkeypatch, *, price, avg, strategy="swing", created_days_ago=1,
remaining=1.0, entry_reason=None, indicators=True):
import app.services.swing.exit_service as es
import app.services.trade_executor as te
from app.core.timeutil import now_kst
holding = SimpleNamespace(stock_code="005930", stock_name="T", quantity=10,
average_price=avg, current_price=price, market="KRX")
monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(return_value=[holding]))
monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"}))
at = SimpleNamespace(strategy_name=strategy, remaining_pct=remaining, highest_since_entry=None,
created_at=now_kst() - timedelta(days=created_days_ago),
entry_reason=entry_reason)
monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(return_value=at))
api = MagicMock()
api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(int(price))}})
bars = [{"stck_clpr": str(avg), "stck_hgpr": str(avg + 100), "stck_lwpr": str(avg - 100)}
for _ in range(30)]
api.get_daily_chart = AsyncMock(return_value={"output2": bars if indicators else []})
# [C-1 6회차] 국내 매도 직전 KIS 실보유 확인 — 보유 10주 전부 스윙(DT 없음)
api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [
{"pdno": "005930", "hldg_qty": "10", "ord_psbl_qty": "10"}]})
monkeypatch.setattr(es, "korea_investment_api", api)
# 지표 고정: ATR 1%(일반 변동성), RSI 중립, BB 중앙은 진입가 위 → 익절 없음
monkeypatch.setattr(es, "calculate_atr", lambda h, l, c: avg * 0.01)
monkeypatch.setattr(es, "calculate_rsi", lambda c: 45.0)
monkeypatch.setattr(es, "calculate_bollinger", lambda c: (avg * 1.2, avg * 1.1, avg * 0.9))
placed = []
async def _spo(**kw):
placed.append(kw)
raise _Reached("sell order")
monkeypatch.setattr(te, "safe_place_order", _spo)
from app.services.daytrading.position_manager import day_trading_state
monkeypatch.setattr(day_trading_state, "positions", {})
es._recent_exits.clear()
return es, placed
async def _run_exit(es, db=None):
try:
await es.check_and_execute_exit_v2(db if db is not None else MagicMock())
except _Reached:
pass
@pytest.mark.asyncio
async def test_c1_check_exit_signal_exception_still_runs_fallback_stop(monkeypatch):
"""[결함 재현 반전] 판정 함수 예외 → 이전엔 주문 0건, 이제 폴백 손절로 전량 매도."""
_no_adaptive(monkeypatch)
es, placed = _exit_harness(monkeypatch, price=8000, avg=10000) # -20%
def _boom(**kw):
raise RuntimeError("boom")
monkeypatch.setattr(es, "check_exit_signal", _boom)
await _run_exit(es)
assert len(placed) == 1 and placed[0]["quantity"] == 10 and placed[0]["order_type"] == "SELL"
@pytest.mark.asyncio
async def test_c1_momentum_exit_exception_still_runs_fallback_stop(monkeypatch):
_no_adaptive(monkeypatch)
es, placed = _exit_harness(monkeypatch, price=8000, avg=10000, strategy="MOMENTUM_BREAKOUT")
def _boom(*a, **kw):
raise RuntimeError("boom")
monkeypatch.setattr(es, "_check_momentum_exit", _boom)
await _run_exit(es)
assert len(placed) == 1 and placed[0]["quantity"] == 10
@pytest.mark.asyncio
async def test_c1_resolver_failure_uses_config_floor(monkeypatch):
"""지표 경로·폴백이 공유하는 손절 해석기가 죽어도 config 바닥값으로 손절한다."""
_no_adaptive(monkeypatch)
floor = -abs(settings.STOP_LOSS_PERCENT)
below = int(10000 * (1 + (floor - 1.0) / 100)) # 바닥값보다 1%p 더 손실
es, placed = _exit_harness(monkeypatch, price=below, avg=10000)
def _boom(*a, **kw):
raise RuntimeError("resolver down")
monkeypatch.setattr(mr, "resolve_swing_stop_params_v2", _boom)
await _run_exit(es)
assert len(placed) == 1 and placed[0]["quantity"] == 10
@pytest.mark.asyncio
async def test_c1_resolver_failure_floor_does_not_oversell(monkeypatch):
_no_adaptive(monkeypatch)
floor = -abs(settings.STOP_LOSS_PERCENT)
above = int(10000 * (1 + (floor + 2.0) / 100)) # 바닥값보다 2%p 덜 손실
es, placed = _exit_harness(monkeypatch, price=above, avg=10000)
def _boom(*a, **kw):
raise RuntimeError("resolver down")
monkeypatch.setattr(mr, "resolve_swing_stop_params_v2", _boom)
await _run_exit(es)
assert placed == []
@pytest.mark.asyncio
async def test_c1_evaluated_hold_does_not_trigger_fallback(monkeypatch):
"""정상 평가된 '보유' 판정은 폴백이 뒤집지 않는다(H4 의도 유지)."""
_no_adaptive(monkeypatch)
es, placed = _exit_harness(monkeypatch, price=8000, avg=10000)
monkeypatch.setattr(es, "check_exit_signal", lambda **kw: None)
await _run_exit(es)
assert placed == []
@pytest.mark.asyncio
async def test_c1_no_chart_uses_fallback(monkeypatch):
_no_adaptive(monkeypatch)
es, placed = _exit_harness(monkeypatch, price=8000, avg=10000, indicators=False)
await _run_exit(es)
assert len(placed) == 1
def test_fallback_stop_loss_pct_v2_floor_and_sign(monkeypatch):
import app.services.swing.exit_service as es
def _boom(*a, **kw):
raise ValueError("x")
monkeypatch.setattr(mr, "resolve_swing_stop_params_v2", _boom)
assert es._fallback_stop_loss_pct_v2(None, 10000.0) == -abs(settings.STOP_LOSS_PERCENT)
monkeypatch.setattr(mr, "resolve_swing_stop_params_v2", lambda a, c: (5.0, 2.5))
assert es._fallback_stop_loss_pct_v2(None, 10000.0) == -5.0 # 양수 누출 → 음수 가드
# ── 시간손절(H-3) — 루프 경유 ────────────────────────────────────────────
@pytest.mark.asyncio
async def test_time_stop_fires_for_old_mean_reversion_position(monkeypatch):
_no_adaptive(monkeypatch)
es, placed = _exit_harness(monkeypatch, price=9900, avg=10000, created_days_ago=60)
await _run_exit(es)
assert len(placed) == 1 and placed[0]["quantity"] == 10
@pytest.mark.asyncio
async def test_time_stop_overrides_partial_signal(monkeypatch):
_no_adaptive(monkeypatch)
es, placed = _exit_harness(monkeypatch, price=10100, avg=10000, created_days_ago=60)
monkeypatch.setattr(es, "check_exit_signal", lambda **kw: mr.ExitSignal("SELL_PARTIAL", "p", 0.5))
await _run_exit(es)
assert len(placed) == 1 and placed[0]["quantity"] == 10 # 전량
@pytest.mark.asyncio
async def test_time_stop_not_applied_to_momentum(monkeypatch):
_no_adaptive(monkeypatch)
es, placed = _exit_harness(monkeypatch, price=9900, avg=10000, created_days_ago=60,
strategy="MOMENTUM_BREAKOUT")
monkeypatch.setattr(es, "_check_momentum_exit", lambda *a, **k: None)
await _run_exit(es)
assert placed == []
@pytest.mark.asyncio
async def test_time_stop_exception_does_not_block_fallback_stop(monkeypatch):
_no_adaptive(monkeypatch)
es, placed = _exit_harness(monkeypatch, price=8000, avg=10000, indicators=False)
def _boom(*a, **k):
raise RuntimeError("ts boom")
monkeypatch.setattr(es, "_time_stop_signal_v2", _boom)
await _run_exit(es)
assert len(placed) == 1
# ── 시간손절(H-3) — 순수 판정 ────────────────────────────────────────────
def test_count_krx_trading_days_between_weekends():
from app.core.market_schedule import count_krx_trading_days_between
# 2026-01-02(금) → 2026-01-30(금): 캘린더 28일, 평일 20일(1월 공휴일 없음)
assert count_krx_trading_days_between(date(2026, 1, 2), date(2026, 1, 30)) == 20
assert count_krx_trading_days_between(date(2026, 1, 2), date(2026, 1, 2)) == 0
assert count_krx_trading_days_between(date(2026, 1, 30), date(2026, 1, 2)) == 0
# 주말만 끼면 0
assert count_krx_trading_days_between(date(2026, 1, 9), date(2026, 1, 11)) == 0
def test_count_krx_trading_days_between_holidays():
from app.core import market_schedule as ms
if ms._kr_holidays is None:
pytest.skip("holidays 패키지 없음")
assert ms.is_krx_holiday(date(2026, 2, 17)) # 설날
# 2026-02-13(금) → 02-20(금): 평일 5일 중 설 연휴 16~18 제외 → 19, 20
assert ms.count_krx_trading_days_between(date(2026, 2, 13), date(2026, 2, 20)) == 2
def _patch_today(monkeypatch, d: date):
import app.core.timeutil as tu
monkeypatch.setattr(tu, "now_kst", lambda: datetime(d.year, d.month, d.day, 10, 0))
def test_time_stop_signal_v2_counts_trading_days(monkeypatch):
import app.services.swing.exit_service as es
at = SimpleNamespace(created_at=datetime(2026, 1, 2, 10, 0))
days = int(settings.SWING_TIME_STOP_DAYS)
from app.core.market_schedule import count_krx_trading_days_between
# 캘린더로는 기간을 넘었지만 거래일로는 미달인 날 → 미발동
d = date(2026, 1, 2) + timedelta(days=days)
assert (d - date(2026, 1, 2)).days >= days
assert count_krx_trading_days_between(date(2026, 1, 2), d) < days
_patch_today(monkeypatch, d)
assert es._time_stop_signal_v2(at, 9900, 10000) is None
# 거래일 기간 도달일 → 발동
d2 = date(2026, 1, 2)
while count_krx_trading_days_between(date(2026, 1, 2), d2) < days:
d2 += timedelta(days=1)
_patch_today(monkeypatch, d2)
sig = es._time_stop_signal_v2(at, 9900, 10000)
assert sig is not None and sig.action == "SELL_ALL" and sig.sell_pct == 1.0
assert "거래일" in sig.reason
# 수익이 최소수익 이상이면 미발동
win = 10000 * (1 + (settings.SWING_TIME_STOP_MIN_PROFIT_PCT + 1) / 100)
assert es._time_stop_signal_v2(at, win, 10000) is None
def test_time_stop_signal_v2_unknown_entry_is_none():
import app.services.swing.exit_service as es
assert es._time_stop_signal_v2(None, 9900, 10000) is None
assert es._time_stop_signal_v2(SimpleNamespace(created_at=None), 9900, 10000) is None
class _ScalarDB:
def __init__(self, value):
self.value = value
self.calls = 0
async def execute(self, stmt):
self.calls += 1
return SimpleNamespace(scalar=lambda: self.value)
@pytest.mark.asyncio
async def test_time_stop_entry_uses_original_buy_for_sync_inflow_row():
import app.services.swing.exit_service as es
created = datetime(2026, 3, 2, 10, 0)
orig = datetime(2026, 1, 5, 9, 30)
at = SimpleNamespace(created_at=created, entry_reason="동기화 유입 포지션 (부분매도 시 자동 생성)")
db = _ScalarDB(orig)
basis = await es._resolve_time_stop_entry_v2(db, "005930", at)
assert basis.created_at == orig and db.calls == 2 # 전량 청산 하한 + BUY 조회(L-3)
@pytest.mark.asyncio
async def test_time_stop_entry_keeps_created_at_for_normal_rows_and_misses():
import app.services.swing.exit_service as es
created = datetime(2026, 3, 2, 10, 0)
normal = SimpleNamespace(created_at=created, entry_reason="[PULLBACK_BUY] x")
db = _ScalarDB(datetime(2026, 1, 5))
assert await es._resolve_time_stop_entry_v2(db, "005930", normal) is normal
assert db.calls == 0
sync = SimpleNamespace(created_at=created, entry_reason="동기화 유입 포지션 (부분매도 시 자동 생성)")
assert await es._resolve_time_stop_entry_v2(_ScalarDB(None), "005930", sync) is sync
# ═══════════════════════════════════════════════════════════════════════════
# resolve_swing_stop_params_v2
# ═══════════════════════════════════════════════════════════════════════════
def test_resolve_stop_params_config_normal_and_high_vol(monkeypatch):
_no_adaptive(monkeypatch)
sl, mult = mr.resolve_swing_stop_params_v2(100.0, 10000.0) # ATR 1%
assert sl == -abs(settings.STOP_LOSS_PERCENT) and mult == float(settings.ATR_STOP_MULTIPLIER)
sl_hv, _ = mr.resolve_swing_stop_params_v2(400.0, 10000.0) # ATR 4% > 3%
assert sl_hv == -abs(settings.STOP_LOSS_HIGH_VOL)
sl_none, _ = mr.resolve_swing_stop_params_v2(None, 10000.0)
assert sl_none == -abs(settings.STOP_LOSS_PERCENT)
def test_resolve_stop_params_adaptive_wins(monkeypatch):
import app.services.adaptive_strategy as ad
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: {"STOP_LOSS_PERCENT": -6.0,
"ATR_STOP_MULTIPLIER": 3.0}.get(k))
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(
fallback_used=False, stop_loss_pct=-5.0, stop_loss_high_vol=-8.0, atr_stop_multiplier=2.0))
assert mr.resolve_swing_stop_params_v2(100.0, 10000.0) == (-5.0, 2.0)
assert mr.resolve_swing_stop_params_v2(400.0, 10000.0) == (-8.0, 2.0)
def test_resolve_stop_params_strategy_cfg_used_when_adaptive_fallback(monkeypatch):
import app.services.adaptive_strategy as ad
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: {"STOP_LOSS_PERCENT": -6.0,
"ATR_STOP_MULTIPLIER": 3.0}.get(k))
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True))
assert mr.resolve_swing_stop_params_v2(100.0, 10000.0) == (-6.0, 3.0)
@pytest.mark.parametrize("bad", ["abc", float("nan"), float("inf"), 0, 0.0, [1]])
def test_resolve_stop_params_bad_strategy_cfg_falls_back_to_config(monkeypatch, bad):
import app.services.adaptive_strategy as ad
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: bad)
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True))
sl, mult = mr.resolve_swing_stop_params_v2(100.0, 10000.0)
assert sl == -abs(settings.STOP_LOSS_PERCENT)
assert mult == float(settings.ATR_STOP_MULTIPLIER)
def test_resolve_stop_params_none_adaptive_fields_do_not_raise(monkeypatch):
import app.services.adaptive_strategy as ad
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None)
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(
fallback_used=False, stop_loss_pct=None, stop_loss_high_vol=None, atr_stop_multiplier=None))
assert mr.resolve_swing_stop_params_v2(100.0, 10000.0) == (
-abs(settings.STOP_LOSS_PERCENT), float(settings.ATR_STOP_MULTIPLIER))
def test_resolve_stop_params_adaptive_raises_uses_lower_sources(monkeypatch):
import app.services.adaptive_strategy as ad
def _boom():
raise RuntimeError("x")
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None)
monkeypatch.setattr(ad, "get_current_adaptive_params", _boom)
assert mr.resolve_swing_stop_params_v2(100.0, 10000.0)[0] == -abs(settings.STOP_LOSS_PERCENT)
def test_resolve_stop_params_positive_value_is_sign_guarded(monkeypatch):
import app.services.adaptive_strategy as ad
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: 6.0 if k == "STOP_LOSS_PERCENT" else None)
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True))
assert mr.resolve_swing_stop_params_v2(100.0, 10000.0)[0] == -6.0
# ═══════════════════════════════════════════════════════════════════════════
# 모멘텀 v2 / 눌림목 v2
# ═══════════════════════════════════════════════════════════════════════════
def test_momentum_v2_excludes_today_bar():
highs = [100.0] * 20 + [112.0] # 오늘 고가 112(장중) — 직전 20봉 고점 100
vols = [1000] * 20 + [2500]
closes = [99.0] * 20 + [110.0]
sig = mr.check_momentum_breakout_signal_v2(closes, highs, vols, 110.0, 2500, 60.0)
assert sig is not None and sig["strategy"] == "MOMENTUM_BREAKOUT"
# v1 은 오늘 고가가 구간에 포함돼 구조적으로 불가
assert mr.check_momentum_breakout_signal(closes, highs, vols, 110.0, 2500, 60.0) is None
def test_momentum_v2_window_is_exactly_prior_20():
highs = [200.0] + [100.0] * 20 + [111.0] # 21봉 전 고점은 구간 밖 → 무시
vols = [1000] * 21 + [2500]
closes = [99.0] * 21 + [110.0]
assert mr.check_momentum_breakout_signal_v2(closes, highs, vols, 110.0, 2500, 60.0) is not None
highs2 = [100.0, 200.0] + [100.0] * 19 + [111.0] # 20봉 전 고점은 구간 안 → 반영
assert mr.check_momentum_breakout_signal_v2(closes, highs2, vols, 110.0, 2500, 60.0) is None
@pytest.mark.parametrize("vol,rsi,ok", [
(1999, 60.0, False), # 거래량 2× 미달
(2000, 60.0, True),
(2500, 50.0, False), # RSI 50 초과 필요
(2500, 70.0, False), # RSI 70 미만 필요
])
def test_momentum_v2_volume_and_rsi_gates(vol, rsi, ok):
highs = [100.0] * 20 + [111.0]
vols = [1000] * 20 + [vol]
closes = [99.0] * 20 + [110.0]
assert (mr.check_momentum_breakout_signal_v2(closes, highs, vols, 110.0, vol, rsi) is not None) is ok
def test_momentum_v2_needs_21_bars():
assert mr.check_momentum_breakout_signal_v2([1.0] * 20, [1.0] * 20, [1] * 20, 2.0, 10, 60.0) is None
_PB_CLOSES = [100 + i * 0.5 for i in range(60)] # MA20 124.75 > MA60 114.75
_PB_MA20 = sum(_PB_CLOSES[-20:]) / 20
def test_pullback_v2_excludes_today_volume_from_average():
vols = [1000] * 20 + [1210]
# 오늘 제외: 직전 20봉 평균 1000 → 1210 ≥ 1.2× 통과
sig = mr.check_pullback_buy_signal_v2(_PB_CLOSES, _PB_MA20, 1210, 45.0, 40.0, vols,
exclude_today_volume=True)
assert sig is not None and sig["strategy"] == "PULLBACK_BUY"
# 오늘 포함(v1 동일): 평균 1010.5 → 1.2× = 1212.6 > 1210 → 탈락
assert mr.check_pullback_buy_signal_v2(_PB_CLOSES, _PB_MA20, 1210, 45.0, 40.0, vols) is None
@pytest.mark.parametrize("kwargs", [
dict(rsi=45.0, rsi_prev=46.0), # RSI 하락 중
dict(rsi=56.0, rsi_prev=50.0), # RSI 범위 밖
dict(current_close=_PB_MA20 * 1.05), # MA20 에서 너무 멂
dict(closes=list(reversed(_PB_CLOSES))), # MA20 < MA60
])
def test_pullback_v2_rejections(kwargs):
base = dict(closes=_PB_CLOSES, current_close=_PB_MA20, current_volume=1500, rsi=45.0,
rsi_prev=40.0, volumes=[1000] * 21, exclude_today_volume=True)
base.update(kwargs)
assert mr.check_pullback_buy_signal_v2(**base) is None
def test_pullback_v2_short_volume_history_rejected():
assert mr.check_pullback_buy_signal_v2(_PB_CLOSES, _PB_MA20, 1500, 45.0, 40.0, [1000] * 20,
exclude_today_volume=True) is None
# ═══════════════════════════════════════════════════════════════════════════
# remaining_pct_after_partial_sell — 동일 단계 재발동 없음
# ═══════════════════════════════════════════════════════════════════════════
def _simulate_partial_sells(qty, signal_fn, cycles=6):
"""exit_service 의 매도 수량 공식 + remaining_pct 기록을 반복 적용. 매도 기록 반환."""
from app.services.sizing_core import remaining_pct_after_partial_sell
held, remaining, sells = qty, 1.0, []
for _ in range(cycles):
sig = signal_fn(remaining)
if sig is None or held <= 0:
break
sell_pct = sig.sell_pct
original = held
if 0 < remaining < 1.0:
original = max(held, int(round(held / remaining)))
if sell_pct >= 1.0 or held <= 3:
sell_qty = held
else:
sell_qty = min(max(int(original * sell_pct), 1), held)
sells.append((sig.reason[:6], sell_qty))
if sell_qty >= held:
held = 0
break
remaining = remaining_pct_after_partial_sell(remaining, sell_pct, held - sell_qty, original)
held -= sell_qty
return sells, held
@pytest.mark.parametrize("qty", [5, 7, 11, 21])
def test_mean_reversion_partial_fires_once(monkeypatch, qty):
_no_adaptive(monkeypatch)
def _sig(remaining): # 1차 익절 조건 상시 충족(BB 중앙 상회, +3%), 전량 조건 없음
return mr.check_exit_signal(mr.MarketRegime.BULL, rsi=50, close=10300, bb_upper=20000,
bb_middle=10200, bb_lower=9000, entry_price=10000, atr=50,
lowest_since_entry=9900, remaining_pct=remaining,
trailing_high=None)
sells, held = _simulate_partial_sells(qty, _sig)
assert len(sells) == 1, sells
assert sells[0][1] == int(qty * 0.5)
assert held == qty - int(qty * 0.5) > 0
@pytest.mark.parametrize("qty", [5, 6, 9, 13])
def test_momentum_stages_fire_once_each(monkeypatch, qty):
_no_adaptive(monkeypatch)
import app.services.swing.exit_service as es
def _sig(remaining): # +25%: 1·2차 단계 모두 조건 충족, 트레일링 없음
return es._check_momentum_exit(12500, 10000, 50, remaining, None)
sells, held = _simulate_partial_sells(qty, _sig)
# 1차·2차 각 1회씩, 같은 단계 재발동 없음, 잔량(트레일링 몫)이 남는다
assert [s[0] for s in sells] == ["모멘텀 1차", "모멘텀 2차"], sells
assert held > 0
# ═══════════════════════════════════════════════════════════════════════════
# entry_service 게이트 헬퍼
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.parametrize("regime,expected", [
({"regime_200d": "bull"}, "bull"),
({"regime_200d": "sideways"}, "sideways"),
({}, None),
({"regime_200d": ""}, None),
({"regime_200d": 1}, None),
(None, None),
("bull", None),
])
def test_regime_200d_or_none_v2(regime, expected):
from app.services.swing.entry_service import _regime_200d_or_none_v2
assert _regime_200d_or_none_v2(regime) == expected
def test_adaptive_entry_block_reason_v2(monkeypatch):
import app.services.adaptive_strategy as ad
from app.services.swing.entry_service import _adaptive_entry_block_reason_v2 as f
monkeypatch.setattr(ad, "get_current_adaptive_params",
lambda: SimpleNamespace(fallback_used=False, trading_mode="crisis"))
assert "CRISIS" in f()
monkeypatch.setattr(ad, "get_current_adaptive_params",
lambda: SimpleNamespace(fallback_used=True, trading_mode="crisis"))
assert f() == ""
monkeypatch.setattr(ad, "get_current_adaptive_params",
lambda: SimpleNamespace(fallback_used=False, trading_mode="normal"))
assert f() == ""
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: None)
assert f() != ""
def _boom():
raise RuntimeError("x")
monkeypatch.setattr(ad, "get_current_adaptive_params", _boom)
assert "fail-closed" in f()
def test_index_breaker_block_reason_v2(monkeypatch):
from app.services.swing.entry_service import _index_breaker_block_reason_v2 as f
monkeypatch.setattr(settings, "SWING_INDEX_BREAKER_ENABLED", False)
assert f({"drop_from_ma20": -5.0}) == ""
monkeypatch.setattr(settings, "SWING_INDEX_BREAKER_ENABLED", True)
assert f({"drop_from_ma20": -0.1}) != ""
assert f({"drop_from_ma20": 0.5}) == ""
assert f({}) == ""
assert "fail-closed" in f({"drop_from_ma20": "abc"})
# ═══════════════════════════════════════════════════════════════════════════
# H-1 / M-1 — scan_dual_strategy_signals · _run_dual_strategy_scan_v2
# ═══════════════════════════════════════════════════════════════════════════
class _DualDB:
def __init__(self, candidates):
self._candidates = candidates
async def execute(self, stmt):
r = MagicMock()
r.fetchall.return_value = []
r.scalars.return_value.all.return_value = list(self._candidates)
return r
class _SessionCM:
def __init__(self, db):
self.db = db
async def __aenter__(self):
return self.db
async def __aexit__(self, *a):
return False
@pytest.fixture
def dual_env(monkeypatch):
"""모든 게이트 통과 상태 + 모멘텀 신호 고정. 위임(execute_auto_trade_background)은 _Reached."""
import app.core.market_schedule as ms
import app.services.korea_investment_api as kia
import app.services.swing.entry_service as es
import app.services.swing.scheduler as sch
import app.services.trade_executor as te
import app.services.adaptive_strategy as ad
monkeypatch.setattr(settings, "AUTO_TRADE_ENABLED", True)
monkeypatch.setattr(settings, "SWING_INDEX_BREAKER_ENABLED", False)
monkeypatch.setattr(settings, "MOMENTUM_BREAKOUT_ENABLED", True)
monkeypatch.setattr(ms, "is_market_open", lambda: True)
monkeypatch.setattr(es, "is_entry_allowed_by_playbook", lambda: True)
monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={
"regime_200d": "bull", "drop_from_ma20": -1.0, "max_trades": 5, "position_multiplier": 1.0}))
monkeypatch.setattr(es, "count_swing_positions", AsyncMock(return_value=0))
monkeypatch.setattr(es.mdd_manager, "can_open_new_position", AsyncMock(return_value=True))
monkeypatch.setattr(ad, "get_current_adaptive_params",
lambda: SimpleNamespace(fallback_used=False, trading_mode="normal"))
monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=0))
# [하드닝 R2 2026-10-09] 차트 현재 종가에도 가격 필터(MIN_PRICE_KRW 이하 거부)가 걸리므로
# 가격대를 settings 기준으로 경계 위에 둔다.
_base = int(settings.MIN_PRICE_KRW) * 10
bars = [{"stck_clpr": str(_base + i), "stck_hgpr": str(_base + 1 + i), "acml_vol": "1000"}
for i in range(30)]
api = MagicMock()
api.get_daily_chart = AsyncMock(return_value={"output2": bars})
# [하드닝 R2 2026-10-09] 듀얼 스캔은 네이버 → KIS 기간 조회 → get_daily_chart 순으로
# 일봉을 확보한다. 네이버는 conftest 전역 차단 대상이 아니므로 반드시 목킹한다.
import app.services.naver_finance as nf
monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=None))
api._quote_delegate = lambda: None
api.get_daily_ohlcv_range = AsyncMock(return_value=[])
monkeypatch.setattr(kia, "korea_investment_api", api)
monkeypatch.setattr(mr, "check_momentum_breakout_signal_v2", lambda **kw: {
"signal": "BUY", "strategy": "MOMENTUM_BREAKOUT", "confidence": 0.7, "description": "t"})
delegate = AsyncMock(side_effect=_Reached("delegated"))
monkeypatch.setattr(es, "execute_auto_trade_background", delegate)
stock = SimpleNamespace(stock_code="005930", stock_name="T", current_price=70000, score=5)
db = _DualDB([stock])
monkeypatch.setattr(sch, "AsyncSessionLocal", lambda: _SessionCM(db))
return SimpleNamespace(es=es, sch=sch, te=te, api=api, delegate=delegate, db=db)
async def _run_dual(env):
try:
await env.sch._run_dual_strategy_scan_v2()
except _Reached:
return True
return False
@pytest.mark.asyncio
async def test_dual_positive_control_delegates(dual_env):
assert await _run_dual(dual_env) is True
@pytest.mark.asyncio
async def test_dual_blocked_by_playbook(monkeypatch, dual_env):
"""[결함 재현 반전] 이전엔 플레이북 차단 중에도 듀얼 진입이 위임됐다."""
monkeypatch.setattr(dual_env.es, "is_entry_allowed_by_playbook", lambda: False)
assert await _run_dual(dual_env) is False
dual_env.api.get_daily_chart.assert_not_called()
@pytest.mark.asyncio
async def test_dual_blocked_by_index_breaker_when_enabled(monkeypatch, dual_env):
monkeypatch.setattr(settings, "SWING_INDEX_BREAKER_ENABLED", True)
assert await _run_dual(dual_env) is False
@pytest.mark.asyncio
async def test_dual_blocked_by_mdd(monkeypatch, dual_env):
monkeypatch.setattr(dual_env.es.mdd_manager, "can_open_new_position", AsyncMock(return_value=False))
assert await _run_dual(dual_env) is False
@pytest.mark.asyncio
async def test_dual_blocked_when_auto_trade_disabled(monkeypatch, dual_env):
monkeypatch.setattr(settings, "AUTO_TRADE_ENABLED", False)
assert await _run_dual(dual_env) is False
@pytest.mark.asyncio
@pytest.mark.parametrize("r", ["sideways", "bear"])
async def test_dual_blocked_outside_bull(monkeypatch, dual_env, r):
monkeypatch.setattr(dual_env.es, "check_market_regime", AsyncMock(return_value={"regime_200d": r}))
assert await _run_dual(dual_env) is False
@pytest.mark.asyncio
async def test_dual_blocked_by_crisis(monkeypatch, dual_env):
import app.services.adaptive_strategy as ad
monkeypatch.setattr(ad, "get_current_adaptive_params",
lambda: SimpleNamespace(fallback_used=False, trading_mode="crisis"))
assert await _run_dual(dual_env) is False
@pytest.mark.asyncio
async def test_dual_skips_chart_scan_when_daily_limit_reached(monkeypatch, dual_env):
monkeypatch.setattr(dual_env.te, "_count_swing_buys_today_v2",
AsyncMock(return_value=int(settings.MAX_DAILY_TRADES)))
assert await _run_dual(dual_env) is False
dual_env.api.get_daily_chart.assert_not_called()
@pytest.mark.asyncio
async def test_dual_daily_limit_uses_regime_max_trades(monkeypatch, dual_env):
monkeypatch.setattr(settings, "BEAR_MARKET_FILTER_ENABLED", True)
monkeypatch.setattr(dual_env.es, "check_market_regime", AsyncMock(return_value={
"regime_200d": "bull", "max_trades": 1, "position_multiplier": 1.0}))
monkeypatch.setattr(dual_env.te, "_count_swing_buys_today_v2", AsyncMock(return_value=1))
assert await _run_dual(dual_env) is False
@pytest.mark.asyncio
async def test_dual_daily_count_failure_is_fail_closed(monkeypatch, dual_env):
monkeypatch.setattr(dual_env.te, "_count_swing_buys_today_v2", AsyncMock(return_value=None))
assert await _run_dual(dual_env) is False
dual_env.api.get_daily_chart.assert_not_called()
# ── M-1 스케줄러 순서 ─────────────────────────────────────────────────────
class _StopLoop(BaseException):
pass
def _scheduler_env(monkeypatch, mr_side_effect=None):
import app.services.swing.scheduler as sch
import app.services.korea_investment_api as kia
import app.api.endpoints.recommendations as rec_mod
import app.api.endpoints.mcp as mcp_mod
import app.core.redis as redis_mod
order = []
async def _sleep(sec):
if sec >= 60:
raise _StopLoop()
monkeypatch.setattr(sch.asyncio, "sleep", _sleep)
monkeypatch.setattr(sch, "is_market_open", lambda: True)
monkeypatch.setattr(sch, "check_and_execute_exit_v2", AsyncMock(return_value=[]))
monkeypatch.setattr(sch, "AsyncSessionLocal", lambda: _SessionCM(MagicMock()))
api = MagicMock()
api.get_balance = AsyncMock(return_value={"output2": [{"tot_evlu_amt": "10000000"}]})
monkeypatch.setattr(kia, "korea_investment_api", api)
monkeypatch.setattr(sch.mdd_manager, "update", AsyncMock(return_value={"action": "NONE"}))
monkeypatch.setattr(sch.mdd_manager, "record_update_success", AsyncMock())
monkeypatch.setattr(sch.mdd_manager, "record_update_failure", AsyncMock())
monkeypatch.setattr(sch.mdd_manager, "can_open_new_position", AsyncMock(return_value=True))
monkeypatch.setattr(settings, "AUTO_TRADE_ENABLED", True)
monkeypatch.setattr(rec_mod, "get_rule_based_recommendations_from_db", AsyncMock(return_value={}))
monkeypatch.setattr(mcp_mod, "request_mcp_risk_assessment", AsyncMock(return_value=(True, "")))
monkeypatch.setattr(redis_mod, "get_json", AsyncMock(return_value=None))
# [하드닝 R2] 기동 시 _check_mdd_threshold_knobs_v2 → strategy_cfg.aget → _refresh_impl →
# AsyncSessionLocal() 이 실제 DB(127.0.0.1:5432)에 접속하고 있었다(소켓 차단 하 실패,
# 로컬 DB 가 떠 있으면 실제 strategy_configs 행에 의존). 예외 발생형으로 막는다:
# - strategy_cfg.aget: RuntimeError → 대조 함수의 "DB 조회 실패(대조 생략)" 분기를 실제로 탄다
# - strategy_config_service.AsyncSessionLocal(공통 종단): 호출되면 기록 + 예외 → 테스트가 0회를 단언
import app.services.strategy_config_service as scs
sessions_opened = []
def _no_db_session(*a, **k):
sessions_opened.append(1)
raise RuntimeError("unit test: strategy_config DB session blocked")
monkeypatch.setattr(scs, "AsyncSessionLocal", _no_db_session)
aget = AsyncMock(side_effect=RuntimeError("unit test: strategy_cfg.aget blocked"))
monkeypatch.setattr(scs.strategy_cfg, "aget", aget)
order_meta = {"sessions_opened": sessions_opened, "aget": aget}
async def _mr(*a, **k):
order.append("mean_reversion")
if mr_side_effect:
raise mr_side_effect
async def _dual():
order.append("dual")
monkeypatch.setattr(sch, "_run_recommendation_entry_v2", _mr)
monkeypatch.setattr(sch, "_run_dual_strategy_scan_v2", _dual)
monkeypatch.setattr(sch, "_r2_test_meta", order_meta, raising=False) # 회귀 테스트 단언용
return sch, order
async def _one_cycle(sch):
with pytest.raises(_StopLoop):
await sch.swing_scheduler()
@pytest.mark.asyncio
async def test_scheduler_runs_mean_reversion_then_dual(monkeypatch):
sch, order = _scheduler_env(monkeypatch)
await _one_cycle(sch)
assert order == ["mean_reversion", "dual"]
@pytest.mark.asyncio
async def test_scheduler_startup_mdd_knob_check_opens_no_db_session(monkeypatch):
"""[하드닝 R2] 기동 대조(_check_mdd_threshold_knobs_v2)는 실제로 호출되지만(aget 첫 키에서 예외),
strategy_config DB 세션은 한 번도 열지 않고, 조회 실패가 사이클 진행을 막지 않는다."""
sch, order = _scheduler_env(monkeypatch)
meta = sch._r2_test_meta
monkeypatch.setattr(sch, "_mdd_knob_state", {"mismatches": ["stale"]})
await _one_cycle(sch)
assert order == ["mean_reversion", "dual"]
keys = [c.args[0] for c in meta["aget"].await_args_list]
assert keys == ["MDD_PAUSE_THRESHOLD"] # 첫 조회 예외 → 대조 생략(warning), DB 미접속
assert meta["sessions_opened"] == []
# settings 값만으로 대조가 끝났다 — settings 가 실효값과 같으면 불일치 없음
expected = sch._mdd_threshold_mismatches_v2({"settings": {
"MDD_PAUSE_THRESHOLD": settings.MDD_PAUSE_THRESHOLD,
"MDD_FORCE_THRESHOLD": settings.MDD_FORCE_THRESHOLD}})
assert sch._mdd_knob_state["mismatches"] == expected
@pytest.mark.asyncio
async def test_scheduler_mean_reversion_error_does_not_skip_dual(monkeypatch):
sch, order = _scheduler_env(monkeypatch, mr_side_effect=RuntimeError("boom"))
await _one_cycle(sch)
assert order == ["mean_reversion", "dual"]
@pytest.mark.asyncio
@pytest.mark.parametrize("case", ["no_rec", "mcp_reject"])
async def test_recommendation_entry_early_exits_return_normally(monkeypatch, case):
"""조기 종료 분기는 return(=듀얼로 진행) — 진입 실행은 일어나지 않는다."""
import app.services.swing.scheduler as sch
monkeypatch.setattr(sch, "execute_swing_entries", AsyncMock(side_effect=_Reached("entry")))
import app.services.db_worker as dbw
monkeypatch.setattr(dbw, "enqueue_db_write", AsyncMock())
rec = {"recommendations_by_rule": [{"stocks": [{"stock_code": "005930"}]}]}
async def _get_json(key):
if key == "recommendations:latest":
return None if case == "no_rec" else rec
return None
get_db_rec = AsyncMock(return_value={})
monkeypatch.setattr(sch, "AsyncSessionLocal", lambda: _SessionCM(MagicMock()))
mcp = AsyncMock(return_value=(False, "deny"))
assert await sch._run_recommendation_entry_v2(_get_json, get_db_rec, mcp) is None
if case == "mcp_reject":
mcp.assert_awaited_once()
dbw.enqueue_db_write.assert_awaited_once()
# ═══════════════════════════════════════════════════════════════════════════
# M-3 — 적응형 MDD 산출
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.parametrize("bal,expected", [
({"rt_cd": "0", "output2": [{"tot_evlu_amt": "1000"}]}, 1000.0),
({"output2": [{"tot_evlu_amt": "1000"}]}, 1000.0),
({"rt_cd": "1", "msg1": "err", "output2": [{"tot_evlu_amt": "1000"}]}, None),
({"rt_cd": "1", "msg1": "err"}, None),
({"rt_cd": "0", "output2": [{"tot_evlu_amt": "0"}]}, None),
({"rt_cd": "0", "output2": []}, None),
({"rt_cd": "0", "output2": [{"tot_evlu_amt": "x"}]}, None),
(None, None),
])
def test_balance_total_or_none_v2(bal, expected):
from app.services.adaptive_strategy import _balance_total_or_none_v2
assert _balance_total_or_none_v2(bal) == expected
@pytest.mark.parametrize("prev,state,expected", [
(-3.0, {"reduction_applied": "20pct"}, -20.0),
(-3.0, {"reduction_applied": "10pct"}, -10.0),
(-3.0, {"reduction_applied": None, "is_paused": True}, -10.0),
(-3.0, {"reduction_applied": None, "is_paused": False}, -3.0),
(-25.0, {"reduction_applied": "10pct"}, -25.0),
("x", {"reduction_applied": None}, 0.0),
])
def test_resolve_mdd_on_failure_v2(prev, state, expected):
from app.services.adaptive_strategy import _resolve_mdd_on_failure_v2
assert _resolve_mdd_on_failure_v2(prev, SimpleNamespace(**state)) == expected
def test_resolve_mdd_on_failure_v2_no_manager_keeps_prev():
from app.services.adaptive_strategy import _resolve_mdd_on_failure_v2
assert _resolve_mdd_on_failure_v2(-4.0, None) == -4.0
@pytest.mark.asyncio
async def test_refresh_adaptive_error_balance_uses_failure_path(monkeypatch):
"""rt_cd≠'0' 잔고 → mdd 0 이 아니라 _resolve_mdd_on_failure_v2(-20) 로 Crisis 판정 입력."""
import app.services.adaptive_strategy as ad
import app.services.korea_investment_api as kia
import app.services.naver_finance as nf
import app.services.mdd_manager as mm
closes = [10000 + (i % 7) * 10 for i in range(260)]
chart = [{"stck_clpr": str(c), "stck_hgpr": str(c + 50), "stck_lwpr": str(c - 50),
"acml_vol": "1000"} for c in reversed(closes)]
monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=list(reversed(chart))))
api = MagicMock()
api.get_balance = AsyncMock(return_value={"rt_cd": "1", "msg1": "EGW00123"})
monkeypatch.setattr(kia, "korea_investment_api", api)
mgr = SimpleNamespace(peak_balance=10_000_000.0, reduction_applied="20pct", is_paused=True)
monkeypatch.setattr(mm, "mdd_manager", mgr)
seen = {}
_orig = ad.analyze_market_condition
def _spy(**kw):
seen["mdd"] = kw["mdd_current"]
return _orig(**kw)
monkeypatch.setattr(ad, "analyze_market_condition", _spy)
monkeypatch.setattr(ad, "_save_to_redis", AsyncMock())
monkeypatch.setattr(ad, "_record_mode_change", AsyncMock())
monkeypatch.setattr(ad, "_current_params", None)
await ad.refresh_adaptive_params()
api.get_balance.assert_awaited()
assert seen["mdd"] == -20.0
# ═══════════════════════════════════════════════════════════════════════════
# H2 히스테리시스 / M-4 상태 격리
# ═══════════════════════════════════════════════════════════════════════════
def _flat_closes():
return [2500.0] * 230
def test_h2_hysteresis_enter_and_exit(monkeypatch):
enter = settings.MEAN_REVERSION_ADX_SIDEWAYS - 2.0
exit_ = settings.MEAN_REVERSION_ADX_SIDEWAYS + 2.0
seq = [enter - 1, enter + 1, exit_ + 1, enter + 1, exit_ - 1]
out = []
for adx in seq:
monkeypatch.setattr(mr, "calculate_adx", lambda h, l, c, period=14, _a=adx: float(_a))
c = _flat_closes()
out.append(mr.detect_market_regime(c, highs=c, lows=c) == mr.MarketRegime.SIDEWAYS)
# 진입(<18) → 유지(<22) → 해제(>22) → 재진입은 <18 필요 → 여전히 아님
assert out == [True, True, False, False, False]
def test_h2_high_adx_flat_slope_is_not_sideways(monkeypatch):
monkeypatch.setattr(mr, "calculate_adx", lambda *a, **k: settings.MEAN_REVERSION_ADX_SIDEWAYS + 10)
c = _flat_closes()
assert mr.detect_market_regime(c, highs=c, lows=c) != mr.MarketRegime.SIDEWAYS
def test_h2_adx_none_slope_flat_is_sideways(monkeypatch):
monkeypatch.setattr(mr, "calculate_adx", lambda *a, **k: None)
c = _flat_closes()
assert mr.detect_market_regime(c, highs=c, lows=c) == mr.MarketRegime.SIDEWAYS
def test_m4_v2_does_not_touch_live_state(monkeypatch):
monkeypatch.setattr(mr, "_previous_regime", mr.MarketRegime.SIDEWAYS)
rising = [2000 + i * 5 for i in range(230)]
assert mr.detect_market_regime_v2(rising) == mr.MarketRegime.BULL
assert mr._previous_regime == mr.MarketRegime.SIDEWAYS # 전역 불변
# v1 은 전역을 갱신한다(실전 경로 동작 유지)
assert mr.detect_market_regime(rising) == mr.MarketRegime.BULL
assert mr._previous_regime == mr.MarketRegime.BULL
def test_m4_v2_injected_state_carries_hysteresis(monkeypatch):
state = mr.RegimeHysteresisState()
mid = settings.MEAN_REVERSION_ADX_SIDEWAYS # 18 < mid < 22
c = _flat_closes()
monkeypatch.setattr(mr, "calculate_adx", lambda *a, **k: float(mid))
# 상태 없음: mid 는 진입 임계(<18) 미달 → 비횡보
assert mr.detect_market_regime_v2(c, c, c, state) != mr.MarketRegime.SIDEWAYS
state.previous = mr.MarketRegime.SIDEWAYS
# 주입 상태가 SIDEWAYS 면 mid 는 해제 임계(>22) 미달 → 횡보 유지
assert mr.detect_market_regime_v2(c, c, c, state) == mr.MarketRegime.SIDEWAYS
assert mr._previous_regime is None # 전역 불변