Files
auto-stock/backend/tests/test_adaptive_strategy.py
shuim de7921d270 fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건)
- day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가
- trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산
- risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수
- risk_management/recommendations: date.today() → KST 변환
- mdd_manager: Lock 밖 executed_key 참조 수정
- correlation_filter: output → output2 키 수정
- portfolio: 해외 종목 USD/KRW 환율 적용 분리
- Sharpe Ratio 거래빈도 기반 연간화
- telegram_notifier: send_message() 공개 래퍼 추가
- position_manager: 앱 재시작 시 DB 포지션 복원
- swing exit_service: remaining_pct 조건 범위 수정
- run_backtest: original_qty 기반 remaining_pct 계산
- us_market_schedule: 2027년 미국 공휴일 추가
- Login.tsx: res.ok 검증 추가
- mean_reversion_strategy: RSI 로그 하드코딩 수정

## HIGH 수정 (10건)
- db_worker: DLQ 파일 백업 메커니즘 추가
- korea_investment_api: 만료 토큰 임의 연장 제거
- market_data: chart_cache 최대 100개 + TTL 만료
- main.py: rate_limit_store 1시간 미접근 IP 정리
- recommendations: fire-and-forget try/except 추가
- swing/scheduler: DB 세션 3개 분리
- Dashboard: wsMessages 전체 순회
- Layout: /health 엔드포인트 + 30초 폴링
- ml_ensemble: POST /train require_api_key 추가
- day_trading: record_trade_result_safe() 전환

## 코드 정리
- 프론트엔드: isUsStock, formatTime 공통 유틸 추출
- 백엔드: dead code 제거, 중복 import 통합
- MCP: JSON 파싱 헬퍼 추출 (~60줄 감소)
- 전체 ~100개 파일 한국어 주석 추가
- 테스트 13건 코드 기준 수정 (468 passed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:05:41 +09:00

352 lines
16 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):
result = _determine_trading_mode(atr_percentile=96.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):
result = _determine_trading_mode(atr_percentile=50.0, adx_value=20.0, recent_5d_return=-9.0, mdd_current=0.0)
assert result == TradingMode.CRISIS.value
def test_crisis_mdd(self):
result = _determine_trading_mode(atr_percentile=50.0, adx_value=20.0, recent_5d_return=0.0, mdd_current=-16.0)
assert result == TradingMode.CRISIS.value
def test_defensive_high_atr(self):
result = _determine_trading_mode(atr_percentile=85.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):
result = _determine_trading_mode(atr_percentile=50.0, adx_value=20.0, recent_5d_return=-6.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=96 triggers both crisis (>95) and defensive (>80), crisis wins
result = _determine_trading_mode(atr_percentile=96.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