## 배경 "전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라 전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다. ## 파라미터 소스 단일화 - 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인 config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%). - DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신 (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등) ## 백테스트를 실전 로직에 정합 - calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트 `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향 - 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치 - us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명) - backend/backtest_results/ 25건은 폐기 대상 (§8.4) ## 리스크 가드 복원 - 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로 - 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다) - 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동 - MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용) - 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정 ## 죽은 코드 제거 (2,695줄) - short_term_strategy, kosdaq_scanner, app/strategy/ 패키지 - app/trading/ + korea_investment_api_adapter (real_market_api 정본은 korea_investment_api.py:1482, adapter 는 import 0건) ## 지표·사이징 단일화 (수치 무변경 증명) - indicators_core.py: 3,274건 비교 최대 오차 0.000e+00 - sizing_core.py: 42,105건 비교 불일치 0 - 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교) ## 테스트 안전 (실 KIS 호출 차단) - 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이 close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로 바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤 참조 누수로 실제 잔고 조회를 타 141초를 소비했다. - backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단. 예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게), 클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제 - 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed) ## 신규 (둘 다 플래그 OFF, 백테스트 미검증) - range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선 - check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%) ## 문서 - CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약, §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거, §8.5 미해결 19항목 - 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
361 lines
17 KiB
Python
361 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):
|
|
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
|