하네스 규칙 검사 전역 위반 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>
915 lines
43 KiB
Python
915 lines
43 KiB
Python
"""스윙 도메인 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 # 전역 불변
|