357 lines
17 KiB
Python
357 lines
17 KiB
Python
"""
|
|
적응형 전략 엔진 유닛 테스트
|
|
|
|
[테스트 범위]
|
|
- 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):
|
|
params = _get_fallback_params()
|
|
# Should match config.py defaults (MAX_POSITION_SIZE=0.06)
|
|
assert params.position_size == 0.06
|
|
assert params.atr_stop_multiplier == 2.5
|
|
|
|
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
|