""" 적응형 전략 엔진 유닛 테스트 [테스트 범위] - MarketCondition 분석 - 모드 전환 로직 - AdaptiveParams 생성 - fallback 동작 - 파라미터 테이블 정합성 """ import pytest from unittest.mock import patch, MagicMock from app.services.adaptive_strategy import ( MarketCondition, AdaptiveParams, TradingMode, VixLevel, TrendStrength, VolumeRegime, analyze_market_condition, get_adaptive_params, _get_fallback_params, _calculate_atr_percentile, _determine_vix_level, _determine_trend_strength, _determine_volume_regime, _determine_trading_mode, get_current_adaptive_params, get_adaptive_status, _MODE_PARAMS, ) # ═════════════════════════════════════════════════════════════════════════════ # ATR Percentile # ═════════════════════════════════════════════════════════════════════════════ class TestATRPercentile: def test_empty_history(self): assert _calculate_atr_percentile([], 10.0) == 50.0 def test_zero_atr(self): assert _calculate_atr_percentile([1.0, 2.0, 3.0], 0.0) == 50.0 def test_lowest(self): history = [10.0, 20.0, 30.0, 40.0, 50.0] result = _calculate_atr_percentile(history, 5.0) assert result == 0.0 def test_highest(self): history = [10.0, 20.0, 30.0, 40.0, 50.0] result = _calculate_atr_percentile(history, 60.0) assert result == 100.0 def test_median(self): history = [10.0, 20.0, 30.0, 40.0, 50.0] result = _calculate_atr_percentile(history, 30.0) assert result == 60.0 # 3 out of 5 <= 30 # ═════════════════════════════════════════════════════════════════════════════ # VIX Level # ═════════════════════════════════════════════════════════════════════════════ class TestVixLevel: def test_extreme(self): assert _determine_vix_level(95.0) == VixLevel.EXTREME.value def test_high(self): assert _determine_vix_level(75.0) == VixLevel.HIGH.value def test_normal(self): assert _determine_vix_level(50.0) == VixLevel.NORMAL.value def test_low(self): assert _determine_vix_level(20.0) == VixLevel.LOW.value # ═════════════════════════════════════════════════════════════════════════════ # Trend Strength # ═════════════════════════════════════════════════════════════════════════════ class TestTrendStrength: def test_strong_up(self): result = _determine_trend_strength(adx=30.0, ma20=100.0, ma60=95.0, current_price=105.0) assert result == TrendStrength.STRONG_UP.value def test_strong_down(self): result = _determine_trend_strength(adx=30.0, ma20=95.0, ma60=100.0, current_price=90.0) assert result == TrendStrength.STRONG_DOWN.value def test_sideways_low_adx(self): result = _determine_trend_strength(adx=15.0, ma20=100.0, ma60=99.0, current_price=100.0) assert result == TrendStrength.SIDEWAYS.value def test_weak_up(self): result = _determine_trend_strength(adx=22.0, ma20=100.0, ma60=102.0, current_price=101.0) assert result == TrendStrength.WEAK_UP.value def test_weak_down(self): result = _determine_trend_strength(adx=22.0, ma20=100.0, ma60=98.0, current_price=99.0) assert result == TrendStrength.WEAK_DOWN.value # ═════════════════════════════════════════════════════════════════════════════ # Volume Regime # ═════════════════════════════════════════════════════════════════════════════ class TestVolumeRegime: def test_surge(self): assert _determine_volume_regime(2.5) == VolumeRegime.SURGE.value def test_normal(self): assert _determine_volume_regime(1.0) == VolumeRegime.NORMAL.value def test_dry(self): assert _determine_volume_regime(0.5) == VolumeRegime.DRY.value # ═════════════════════════════════════════════════════════════════════════════ # Trading Mode # ═════════════════════════════════════════════════════════════════════════════ class TestTradingMode: def test_crisis_high_atr(self): # CLAUDE.md 8.5: ATR백분위 > 90 → Crisis result = _determine_trading_mode(atr_percentile=91.0, adx_value=20.0, recent_5d_return=0.0, mdd_current=0.0) assert result == TradingMode.CRISIS.value def test_crisis_deep_loss(self): # CLAUDE.md 8.5: 5일수익 < -5% → Crisis result = _determine_trading_mode(atr_percentile=50.0, adx_value=20.0, recent_5d_return=-6.0, mdd_current=0.0) assert result == TradingMode.CRISIS.value def test_crisis_mdd(self): # CLAUDE.md 8.5: MDD < -10% → Crisis result = _determine_trading_mode(atr_percentile=50.0, adx_value=20.0, recent_5d_return=0.0, mdd_current=-11.0) assert result == TradingMode.CRISIS.value def test_defensive_high_atr(self): # CLAUDE.md 8.5: ATR백분위 > 70 → Defensive (but < 90 so not Crisis) result = _determine_trading_mode(atr_percentile=75.0, adx_value=20.0, recent_5d_return=0.0, mdd_current=0.0) assert result == TradingMode.DEFENSIVE.value def test_defensive_moderate_loss(self): # CLAUDE.md 8.5: 5일수익 < -3% → Defensive (but > -5% so not Crisis) result = _determine_trading_mode(atr_percentile=50.0, adx_value=20.0, recent_5d_return=-4.0, mdd_current=0.0) assert result == TradingMode.DEFENSIVE.value def test_aggressive(self): result = _determine_trading_mode(atr_percentile=20.0, adx_value=30.0, recent_5d_return=3.0, mdd_current=0.0) assert result == TradingMode.AGGRESSIVE.value def test_aggressive_requires_all_conditions(self): # ATR low but ADX too low -> Normal result = _determine_trading_mode(atr_percentile=20.0, adx_value=15.0, recent_5d_return=3.0, mdd_current=0.0) assert result == TradingMode.NORMAL.value def test_normal_default(self): result = _determine_trading_mode(atr_percentile=50.0, adx_value=20.0, recent_5d_return=0.5, mdd_current=0.0) assert result == TradingMode.NORMAL.value def test_crisis_takes_priority_over_defensive(self): # ATR=92 triggers both crisis (>90) and defensive (>70), crisis wins result = _determine_trading_mode(atr_percentile=92.0, adx_value=30.0, recent_5d_return=3.0, mdd_current=0.0) assert result == TradingMode.CRISIS.value # ═════════════════════════════════════════════════════════════════════════════ # AdaptiveParams Generation # ═════════════════════════════════════════════════════════════════════════════ class TestGetAdaptiveParams: def test_normal_mode(self): condition = MarketCondition(trading_mode=TradingMode.NORMAL.value) params = get_adaptive_params(condition) assert params.trading_mode == TradingMode.NORMAL.value assert params.position_size == 0.06 assert params.max_holdings == 14 assert params.daytrading_enabled is True def test_crisis_mode(self): condition = MarketCondition(trading_mode=TradingMode.CRISIS.value) params = get_adaptive_params(condition) assert params.trading_mode == TradingMode.CRISIS.value assert params.position_size == 0.015 assert params.max_holdings == 3 assert params.daytrading_enabled is False assert params.short_term_enabled is False def test_defensive_mode(self): condition = MarketCondition(trading_mode=TradingMode.DEFENSIVE.value) params = get_adaptive_params(condition) assert params.trading_mode == TradingMode.DEFENSIVE.value assert params.position_size == 0.04 assert params.daytrading_enabled is True def test_aggressive_mode(self): condition = MarketCondition(trading_mode=TradingMode.AGGRESSIVE.value) params = get_adaptive_params(condition) assert params.trading_mode == TradingMode.AGGRESSIVE.value assert params.position_size == 0.07 assert params.max_holdings == 15 assert params.rsi_entry == 50.0 def test_condition_attached(self): condition = MarketCondition( trading_mode=TradingMode.NORMAL.value, atr_percentile=42.0, ) params = get_adaptive_params(condition) assert params.condition is not None assert params.condition.atr_percentile == 42.0 # ═════════════════════════════════════════════════════════════════════════════ # Fallback # ═════════════════════════════════════════════════════════════════════════════ class TestFallback: def test_fallback_returns_normal_mode(self): params = _get_fallback_params() assert params.trading_mode == TradingMode.NORMAL.value assert params.fallback_used is True def test_fallback_uses_config_values(self): from app.core.config import settings params = _get_fallback_params() # fallback은 하드코딩이 아니라 settings 값을 그대로 읽어야 한다. # config.py 기본값이 바뀌어도(예: MAX_POSITION_SIZE 0.06→0.04) 이 테스트가 # 함께 갱신되도록 settings를 직접 참조한다. assert params.position_size == settings.MAX_POSITION_SIZE assert params.atr_stop_multiplier == settings.ATR_STOP_MULTIPLIER def test_get_current_without_init(self): """초기화 없이 호출하면 fallback을 반환해야 한다.""" # Reset global state import app.services.adaptive_strategy as mod mod._current_params = None mod._last_update_ts = 0.0 params = get_current_adaptive_params() assert params.fallback_used is True assert params.trading_mode == TradingMode.NORMAL.value # ═════════════════════════════════════════════════════════════════════════════ # analyze_market_condition # ═════════════════════════════════════════════════════════════════════════════ class TestAnalyzeMarketCondition: def test_insufficient_data(self): condition = analyze_market_condition(closes=[100.0] * 10) assert condition.trading_mode == TradingMode.NORMAL.value def test_with_full_data(self): # Generate synthetic data: 100 days of gradually rising prices closes = [10000.0 + i * 10 for i in range(100)] highs = [c + 50 for c in closes] lows = [c - 50 for c in closes] volumes = [1000000.0] * 100 condition = analyze_market_condition( closes=closes, highs=highs, lows=lows, volumes=volumes ) assert condition.analyzed_at != "" assert condition.trading_mode in [m.value for m in TradingMode] def test_high_volatility_triggers_defensive(self): # Create highly volatile data import random random.seed(42) base = [10000.0] for _ in range(99): change = random.uniform(-500, 500) base.append(max(5000, base[-1] + change)) # Make last 5 days very volatile for i in range(95, 100): base[i] = base[i] + random.uniform(-1000, 1000) highs = [c + abs(c * 0.05) for c in base] lows = [c - abs(c * 0.05) for c in base] volumes = [1000000.0] * 100 condition = analyze_market_condition( closes=base, highs=highs, lows=lows, volumes=volumes ) # High volatility should push toward defensive or crisis assert condition.atr_percentile > 0 # some ATR analysis happened def test_mdd_triggers_crisis(self): closes = [10000.0] * 100 condition = analyze_market_condition(closes=closes, mdd_current=-16.0) assert condition.trading_mode == TradingMode.CRISIS.value # ═════════════════════════════════════════════════════════════════════════════ # Mode Params Table Consistency # ═════════════════════════════════════════════════════════════════════════════ class TestModeParamsTable: """각 모드의 파라미터가 유효한 범위에 있는지 확인.""" def test_all_modes_defined(self): for mode in TradingMode: assert mode.value in _MODE_PARAMS def test_position_size_range(self): for mode, params in _MODE_PARAMS.items(): assert 0.0 <= params["position_size"] <= 0.10, f"{mode}: position_size out of range" def test_stop_loss_negative(self): for mode, params in _MODE_PARAMS.items(): assert params["stop_loss_pct"] < 0, f"{mode}: stop_loss_pct should be negative" def test_atr_multiplier_positive(self): for mode, params in _MODE_PARAMS.items(): assert params["atr_stop_multiplier"] > 0, f"{mode}: atr_stop_multiplier should be positive" def test_rsi_entry_ascending_with_defense(self): """방어적일수록 RSI 진입 기준이 낮아야 한다.""" assert _MODE_PARAMS["aggressive"]["rsi_entry"] > _MODE_PARAMS["normal"]["rsi_entry"] assert _MODE_PARAMS["normal"]["rsi_entry"] > _MODE_PARAMS["defensive"]["rsi_entry"] assert _MODE_PARAMS["defensive"]["rsi_entry"] > _MODE_PARAMS["crisis"]["rsi_entry"] def test_max_holdings_descending_with_defense(self): """방어적일수록 최대 보유 종목이 적어야 한다.""" assert _MODE_PARAMS["aggressive"]["max_holdings"] >= _MODE_PARAMS["normal"]["max_holdings"] assert _MODE_PARAMS["normal"]["max_holdings"] >= _MODE_PARAMS["defensive"]["max_holdings"] assert _MODE_PARAMS["defensive"]["max_holdings"] >= _MODE_PARAMS["crisis"]["max_holdings"] def test_crisis_disables_daytrading(self): assert _MODE_PARAMS["crisis"]["daytrading_enabled"] is False assert _MODE_PARAMS["crisis"]["short_term_enabled"] is False def test_defensive_disables_short_term(self): assert _MODE_PARAMS["defensive"]["daytrading_enabled"] is True assert _MODE_PARAMS["defensive"]["short_term_enabled"] is False # ═════════════════════════════════════════════════════════════════════════════ # API Status # ═════════════════════════════════════════════════════════════════════════════ class TestAdaptiveStatus: def test_status_returns_dict(self): # Reset global import app.services.adaptive_strategy as mod mod._current_params = None mod._current_condition = None mod._last_update_ts = 0.0 status = get_adaptive_status() assert isinstance(status, dict) assert "trading_mode" in status assert "params" in status assert status["fallback_used"] is True