"""스윙 도메인 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 # 전역 불변