Files
auto-stock/backend/tests/test_swing_r3_fixes_20261002.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

915 lines
43 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))
bars = [{"stck_clpr": str(100 + i), "stck_hgpr": str(101 + i), "acml_vol": "1000"}
for i in range(30)]
api = MagicMock()
api.get_daily_chart = AsyncMock(return_value={"output2": bars})
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))
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)
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_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 # 전역 불변