Files
auto-stock/backend/tests/test_swing_trailing_mult_20261009.py
shuim d140055e64 fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강
- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강
- trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성)
- korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계)
- swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소)
- swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램
- daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거
- main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC)
- 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경)
- 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기
- CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기)
- 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:37:16 +09:00

161 lines
6.9 KiB
Python
Raw Permalink 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.
"""[R3 2026-10-09] 스윙 ATR 트레일링 배수 단일 출처 회귀 테스트.
결함: 평균회귀 check_exit_signal·모멘텀 _check_momentum_exit 가 리터럴 1.5 를 써
config / strategy_cfg(DB) / 적응형 어느 값도 소비되지 않았다.
수정: resolve_swing_trailing_atr_mult_v2 (strategy_cfg > config, 적응형은
SWING_ADAPTIVE_TRAILING_ENABLED 일 때만 — 백테스트 전 기본 OFF).
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import app.services.mean_reversion_strategy as mr
from app.core.config import settings
from app.services.strategy_config_service import DEFAULT_PARAMS
class _AdaptiveCalled(Exception):
"""플래그 OFF 인데 적응형을 조회하면 실패시키는 예외 발생형 목."""
def _cfg(values):
m = MagicMock()
m.get.side_effect = lambda key, default=None: values.get(key, default)
return m
_FALLBACK_ADP = SimpleNamespace(fallback_used=True)
@pytest.fixture
def no_adaptive():
"""적응형 오버레이 없음(fallback_used=True) — 손절/RSI 경로 결정론."""
with patch("app.services.adaptive_strategy.get_current_adaptive_params",
return_value=_FALLBACK_ADP):
yield
def _exit(close, cfg_values, trailing_high=11000.0, atr=200.0):
with patch.object(mr, "strategy_cfg", _cfg(cfg_values)):
return mr.check_exit_signal(
regime=mr.MarketRegime.BULL, rsi=50.0, close=close,
bb_upper=20000.0, bb_middle=9000.0, bb_lower=8000.0,
entry_price=10000.0, atr=atr, lowest_since_entry=9800.0,
remaining_pct=0.50, trailing_high=trailing_high,
)
def test_default_uses_config_value(no_adaptive):
with patch.object(mr, "strategy_cfg", _cfg({})):
mult, source = mr._resolve_swing_trailing_atr_mult_impl()
assert mult == float(settings.ATR_TRAILING_MULTIPLIER)
assert source == "config"
def test_strategy_cfg_overrides_config_and_reaches_exit(no_adaptive):
cfg_mult = 2.0
assert cfg_mult != float(settings.ATR_TRAILING_MULTIPLIER)
atr, high = 200.0, 11000.0
# config 배수 트레일과 DB 배수 트레일 사이의 종가 → DB 값이면 미청산이어야 한다
close = high - ((float(settings.ATR_TRAILING_MULTIPLIER) + cfg_mult) / 2) * atr
if cfg_mult > float(settings.ATR_TRAILING_MULTIPLIER):
assert _exit(close, {"ATR_TRAILING_MULTIPLIER": cfg_mult}) is None
sig = _exit(close, {})
assert sig is not None and sig.action == "SELL_ALL" and "트레일링" in sig.reason
assert f"{float(settings.ATR_TRAILING_MULTIPLIER):g}×ATR" in sig.reason
def test_out_of_range_db_value_falls_back_to_config(no_adaptive):
_hi = float(DEFAULT_PARAMS["ATR_TRAILING_MULTIPLIER"][6])
for bad in (_hi + 1, 0, -1.5, "abc", float("nan")):
with patch.object(mr, "strategy_cfg", _cfg({"ATR_TRAILING_MULTIPLIER": bad})):
mult, source = mr._resolve_swing_trailing_atr_mult_impl()
assert source == "config", bad
assert mult == float(settings.ATR_TRAILING_MULTIPLIER)
def test_adaptive_not_consulted_when_flag_off():
with patch("app.services.adaptive_strategy.get_current_adaptive_params",
side_effect=_AdaptiveCalled("적응형 조회 금지")), \
patch.object(mr, "_adaptive_trailing_enabled_v2", return_value=False), \
patch.object(mr, "strategy_cfg", _cfg({})):
mult, source = mr._resolve_swing_trailing_atr_mult_impl()
status = mr.get_swing_trailing_mult_status_v2()
assert source == "config"
assert mult == float(settings.ATR_TRAILING_MULTIPLIER)
assert status["adaptive_trailing_applied"] is False
assert status["adaptive_trailing_enabled"] is False
def test_flag_default_is_off():
# 백테스트 전에는 적응형 배수(2.0/1.6/1.3/1.0)가 실전에 적용되면 안 된다
assert getattr(settings, "SWING_ADAPTIVE_TRAILING_ENABLED", False) is not True
assert mr._adaptive_trailing_enabled_v2() is False
def test_adaptive_applied_when_flag_on():
adp = SimpleNamespace(fallback_used=False, atr_trailing_multiplier=1.0)
with patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=adp), \
patch.object(mr, "_adaptive_trailing_enabled_v2", return_value=True), \
patch.object(mr, "strategy_cfg", _cfg({"ATR_TRAILING_MULTIPLIER": 2.0})):
mult, source = mr._resolve_swing_trailing_atr_mult_impl()
assert (mult, source) == (1.0, "adaptive")
def test_adaptive_failure_falls_back_when_flag_on():
with patch("app.services.adaptive_strategy.get_current_adaptive_params",
side_effect=RuntimeError("adaptive down")), \
patch.object(mr, "_adaptive_trailing_enabled_v2", return_value=True), \
patch.object(mr, "strategy_cfg", _cfg({"ATR_TRAILING_MULTIPLIER": 2.0})):
mult, source = mr._resolve_swing_trailing_atr_mult_impl()
assert (mult, source) == (2.0, "strategy_cfg")
def test_invalid_config_uses_meta_default(monkeypatch):
monkeypatch.setattr(settings, "ATR_TRAILING_MULTIPLIER", 0.0)
with patch.object(mr, "strategy_cfg", _cfg({})):
mult, source = mr._resolve_swing_trailing_atr_mult_impl()
assert source == "meta_default"
assert mult == float(DEFAULT_PARAMS["ATR_TRAILING_MULTIPLIER"][4]) > 0
def test_momentum_trailing_uses_resolver(no_adaptive):
"""공통 종단(resolve_swing_trailing_atr_mult_v2)을 예외 발생형으로 치환 → 모멘텀 경로가 반드시 호출."""
from app.services.swing import exit_service
class _ResolverHit(Exception):
pass
with patch.object(mr, "strategy_cfg", _cfg({})), \
patch.object(mr, "resolve_swing_trailing_atr_mult_v2", side_effect=_ResolverHit()):
with pytest.raises(_ResolverHit):
exit_service._check_momentum_exit(
current_price=10900.0, entry_price=10000.0, atr=200.0,
remaining_pct=0.40, trailing_high=12000.0,
)
def test_momentum_trailing_honors_db_value(no_adaptive):
from app.services.swing import exit_service
atr, high = 200.0, 12000.0
base = float(settings.ATR_TRAILING_MULTIPLIER)
cfg_mult = 2.0
close = high - ((base + cfg_mult) / 2) * atr # 두 트레일 사이
kwargs = dict(current_price=close, entry_price=10000.0, atr=atr,
remaining_pct=0.40, trailing_high=high)
with patch.object(mr, "strategy_cfg", _cfg({})):
sig = exit_service._check_momentum_exit(**kwargs)
assert sig is not None and "트레일링" in sig.reason
with patch.object(mr, "strategy_cfg", _cfg({"ATR_TRAILING_MULTIPLIER": cfg_mult})):
sig2 = exit_service._check_momentum_exit(**kwargs)
assert sig2 is None or "트레일링" not in sig2.reason
from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지)