""" 적응형 전략 엔진 (Adaptive Strategy Engine) 시장 상황(변동성, 추세 강도, 심리, 거래량)에 따라 매매 파라미터를 자동으로 조정하는 모듈. [핵심 아이디어] - 변동성 높을 때: 손절 넓게, 포지션 축소, 진입 조건 엄격 - 변동성 낮을 때: 손절 좁게, 포지션 확대, 진입 조건 완화 - 추세장: 평균회귀 축소, 추세추종 확대 - 횡보장: 평균회귀 확대, 데이트레이딩 축소 - 급락 후 반등: 공격적 매수, 포지션 확대 [모드 전환 규칙] CLAUDE.md 섹션 8.4 Aggressive: ATR백분위 < 30 AND ADX > 25 AND 5일수익률 > +2% Normal: 기본 Defensive: ATR백분위 > 70 OR 5일수익률 < -3% Crisis: ATR백분위 > 90 OR 5일수익률 < -5% OR MDD < -10% [통합 방식] 기존 config.py 값은 Normal 모드 기본값으로 유지. adaptive_strategy 모듈이 없거나 실패하면 config.py 값으로 graceful fallback. strategy_config_service에 기록하지 않고, 인메모리 + Redis 캐시 운영. [사용법] from app.services.adaptive_strategy import get_current_adaptive_params adaptive = get_current_adaptive_params() # adaptive.position_size, adaptive.rsi_entry, adaptive.max_holdings 등 """ import asyncio import logging import time from dataclasses import dataclass, field, asdict from datetime import datetime from enum import Enum from typing import Optional, List, Dict, Any logger = logging.getLogger(__name__) # ═════════════════════════════════════════════════════════════════════════════ # 1. 데이터 모델 # ═════════════════════════════════════════════════════════════════════════════ class TradingMode(str, Enum): AGGRESSIVE = "aggressive" NORMAL = "normal" DEFENSIVE = "defensive" CRISIS = "crisis" class VixLevel(str, Enum): LOW = "low" NORMAL = "normal" HIGH = "high" EXTREME = "extreme" class TrendStrength(str, Enum): STRONG_UP = "strong_up" WEAK_UP = "weak_up" SIDEWAYS = "sideways" WEAK_DOWN = "weak_down" STRONG_DOWN = "strong_down" class VolumeRegime(str, Enum): DRY = "dry" NORMAL = "normal" SURGE = "surge" @dataclass class MarketCondition: """시장 상태 분석 결과.""" # 변동성 레짐 vix_level: str = VixLevel.NORMAL.value atr_percentile: float = 50.0 # ATR 백분위 (0~100) # 추세 강도 trend_strength: str = TrendStrength.SIDEWAYS.value adx_value: float = 20.0 # 시장 심리 market_breadth: float = 0.5 # 상승종목비율 (0~1) recent_5d_return: float = 0.0 # 최근 5일 수익률 (%) # 거래량 volume_regime: str = VolumeRegime.NORMAL.value volume_ratio: float = 1.0 # 현재 거래량 / 20일 평균 # 종합 모드 trading_mode: str = TradingMode.NORMAL.value # 메타 analyzed_at: str = "" data_source: str = "KODEX200" @dataclass class AdaptiveParams: """적응형 매매 파라미터. 기존 config.py 값을 오버라이드하는 데 사용. trading_mode가 'normal'이면 config.py 기본값과 동일. """ # 현재 모드 trading_mode: str = TradingMode.NORMAL.value # 진입 조건 rsi_entry: float = 42.0 # RSI 진입 임계값 (BULL 기준) bb_pct_b: float = 0.30 # %B 진입 임계값 bear_rsi_entry: float = 30.0 # BEAR RSI 진입 임계값 # 포지션 관리 position_size: float = 0.04 # 종목당 최대 비중 (4%) max_holdings: int = 12 # 최대 동시 보유 종목 max_daily_trades: int = 5 # 일일 최대 매매 수 # 손절/익절 stop_loss_pct: float = -6.0 # 기본 손절 (%) stop_loss_high_vol: float = -10.0 # 고변동 손절 (%) atr_stop_multiplier: float = 2.5 # ATR 손절 배수 atr_trailing_multiplier: float = 1.5 # ATR 트레일링 배수 # 익절 rsi_partial_exit: float = 50.0 # 1차 익절 RSI rsi_full_exit: float = 65.0 # 전량 청산 RSI # 전략별 활성화 daytrading_enabled: bool = True short_term_enabled: bool = True # MDD 기반 조정 (외부 참조용) mdd_current: float = 0.0 # 현재 MDD (%) # 메타 condition: Optional[MarketCondition] = None updated_at: str = "" fallback_used: bool = False # ═════════════════════════════════════════════════════════════════════════════ # 2. 모드별 파라미터 테이블 # ═════════════════════════════════════════════════════════════════════════════ # 백테스트 검증 완료 파라미터 (2023-03~2026-03, 34종목, 듀얼전략) # 수익률 10.29%, CAGR 3.44%, MDD -3.78%, 승률 71.8%, 매매 284회 # # [HIGH-2 주의] 아래 position_size(Normal 0.06 / Aggressive 0.07)는 문서·백테스트 # 기준값이므로 변경하지 않는다. 다만 CLAUDE.md §5.1/§8.2의 종목당 절대 상한은 # settings.MAX_POSITION_SIZE(4%)이며, 소비 지점(trade_executor._adaptive_position_cap)에서 # min(_adp.position_size, settings.MAX_POSITION_SIZE)로 클램프된다. # → 실효 종목당 비중은 어떤 모드에서도 4%를 넘지 않는다. _MODE_PARAMS: Dict[str, Dict[str, Any]] = { TradingMode.AGGRESSIVE.value: { "rsi_entry": 50.0, # 진입 기회 최대화 "bb_pct_b": 0.45, "bear_rsi_entry": 40.0, "position_size": 0.07, # 종목당 7% (신뢰도 곱으로 최대 ~10%) "max_holdings": 15, "max_daily_trades": 7, "stop_loss_pct": -9.0, # 넓은 손절 (모멘텀 종목 보호) "stop_loss_high_vol": -13.0, "atr_stop_multiplier": 3.0, "atr_trailing_multiplier": 2.0, # 넉넉한 트레일링 (이익 키움) "rsi_partial_exit": 60.0, # 늦은 1차 익절 "rsi_full_exit": 75.0, # 충분히 오를 때까지 "daytrading_enabled": True, "short_term_enabled": True, }, TradingMode.NORMAL.value: { "rsi_entry": 46.0, # 백테스트 최적 (기존 42→46) "bb_pct_b": 0.40, # 완화 (기존 0.30→0.40) "bear_rsi_entry": 35.0, # 백테스트 최적 (기존 30→35) "position_size": 0.06, # 확대 (기존 0.04→0.06) "max_holdings": 14, "max_daily_trades": 5, "stop_loss_pct": -7.0, # 약간 완화 (기존 -6→-7) "stop_loss_high_vol": -11.0, "atr_stop_multiplier": 2.5, "atr_trailing_multiplier": 1.6, "rsi_partial_exit": 55.0, # 늦은 익절 (기존 50→55) "rsi_full_exit": 70.0, # 늦은 청산 (기존 65→70) "daytrading_enabled": True, "short_term_enabled": True, }, TradingMode.DEFENSIVE.value: { "rsi_entry": 38.0, "bb_pct_b": 0.25, "bear_rsi_entry": 28.0, "position_size": 0.04, "max_holdings": 10, "max_daily_trades": 3, "stop_loss_pct": -6.0, "stop_loss_high_vol": -9.0, "atr_stop_multiplier": 2.2, "atr_trailing_multiplier": 1.3, "rsi_partial_exit": 50.0, "rsi_full_exit": 63.0, "daytrading_enabled": True, # Defensive에서도 데이트레이딩 허용 (당일 청산이라 리스크 제한적) "short_term_enabled": False, }, TradingMode.CRISIS.value: { "rsi_entry": 25.0, "bb_pct_b": 0.10, "bear_rsi_entry": 18.0, "position_size": 0.015, # 소량 매매 허용 (급락 후 반등 포착) "max_holdings": 3, "max_daily_trades": 0, "stop_loss_pct": -3.0, # 타이트한 손절 "stop_loss_high_vol": -5.0, "atr_stop_multiplier": 1.5, "atr_trailing_multiplier": 1.0, "rsi_partial_exit": 50.0, # 기존 42→50 (너무 이른 익절 방지) "rsi_full_exit": 60.0, # 기존 52→60 "daytrading_enabled": False, "short_term_enabled": False, }, } # ═════════════════════════════════════════════════════════════════════════════ # 3. 시장 컨디션 분석 # ═════════════════════════════════════════════════════════════════════════════ def _calculate_atr_percentile(atr_values: List[float], current_atr: float) -> float: """ATR 값 리스트에서 현재 ATR의 백분위 계산. Args: atr_values: 과거 ATR 값 리스트 (최소 20개 권장) current_atr: 현재 ATR 값 Returns: 0~100 사이 백분위 """ if not atr_values or current_atr <= 0: return 50.0 count_below = sum(1 for v in atr_values if v <= current_atr) return (count_below / len(atr_values)) * 100.0 def _determine_vix_level(atr_percentile: float) -> str: """ATR 백분위 기반 변동성 레짐 판단.""" if atr_percentile >= 90: return VixLevel.EXTREME.value elif atr_percentile >= 70: return VixLevel.HIGH.value elif atr_percentile >= 30: return VixLevel.NORMAL.value else: return VixLevel.LOW.value def _determine_trend_strength(adx: float, ma20: float, ma60: float, current_price: float) -> str: """ADX + MA 관계로 추세 강도 판단. Args: adx: ADX 값 ma20: 20일 이동평균 ma60: 60일 이동평균 current_price: 현재가 """ if adx < 20: return TrendStrength.SIDEWAYS.value elif adx >= 25: if current_price > ma20 > ma60: return TrendStrength.STRONG_UP.value elif current_price < ma20 < ma60: return TrendStrength.STRONG_DOWN.value elif current_price > ma20: return TrendStrength.WEAK_UP.value else: return TrendStrength.WEAK_DOWN.value else: # 20 <= adx < 25 if current_price > ma20: return TrendStrength.WEAK_UP.value elif current_price < ma20: return TrendStrength.WEAK_DOWN.value else: return TrendStrength.SIDEWAYS.value def _determine_volume_regime(volume_ratio: float) -> str: """거래량 레짐 판단. Args: volume_ratio: 현재 거래량 / 20일 평균 거래량 """ if volume_ratio >= 2.0: return VolumeRegime.SURGE.value elif volume_ratio >= 0.8: return VolumeRegime.NORMAL.value else: return VolumeRegime.DRY.value def _determine_trading_mode( atr_percentile: float, adx_value: float, recent_5d_return: float, mdd_current: float, ) -> str: """모드 전환 규칙 적용 (히스테리시스 포함). [우선순위: Crisis > Defensive > Aggressive > Normal] CLAUDE.md 섹션 8.4: Crisis: ATR백분위 > 90 OR 5일수익률 < -5% OR MDD < -10% Defensive: ATR백분위 > 70 OR 5일수익률 < -3% Aggressive: ATR백분위 < 30 AND ADX > 25 AND 5일수익률 > +2% Normal: 기본 [히스테리시스] 현재 모드에서 해제되려면 진입 기준보다 완화된 조건 필요. 예: Defensive 진입 ATR>70, 해제 ATR<60 (10%p 밴드) → 경계선에서 빈번한 전환(whipsaw) 방지. """ prev_mode = _current_params.trading_mode if _current_params else TradingMode.NORMAL.value # Crisis 체크 (최우선) — CLAUDE.md 섹션 8.4 정합 if atr_percentile > 90 or recent_5d_return < -5.0 or mdd_current < -10.0: return TradingMode.CRISIS.value # Crisis 해제: 더 완화된 조건 (히스테리시스) if prev_mode == TradingMode.CRISIS.value: if atr_percentile > 80 or recent_5d_return < -3.0 or mdd_current < -8.0: return TradingMode.CRISIS.value # 아직 위기 유지 # Defensive 체크 — CLAUDE.md 섹션 8.4 정합 if atr_percentile > 70 or recent_5d_return < -3.0: return TradingMode.DEFENSIVE.value # Defensive 해제: ATR<60, 5일수익>-1% (히스테리시스) if prev_mode == TradingMode.DEFENSIVE.value: if atr_percentile > 60 or recent_5d_return < -1.0: return TradingMode.DEFENSIVE.value # 아직 방어 유지 # Aggressive 체크 (모든 조건 AND) if atr_percentile < 30 and adx_value > 25 and recent_5d_return > 2.0: return TradingMode.AGGRESSIVE.value # Aggressive 해제: 조건이 완전히 벗어나야 (히스테리시스) if prev_mode == TradingMode.AGGRESSIVE.value: if atr_percentile < 40 and adx_value > 20 and recent_5d_return > 0.0: return TradingMode.AGGRESSIVE.value # 아직 공격 유지 return TradingMode.NORMAL.value def analyze_market_condition( closes: List[float], highs: Optional[List[float]] = None, lows: Optional[List[float]] = None, volumes: Optional[List[float]] = None, mdd_current: float = 0.0, ) -> MarketCondition: """시장 컨디션 종합 분석. KODEX200 일봉 데이터 기반으로 변동성/추세/심리/거래량 분석 후 종합 trading_mode를 결정한다. Args: closes: 종가 리스트 (oldest first, 최소 60개 권장) highs: 고가 리스트 (oldest first) lows: 저가 리스트 (oldest first) volumes: 거래량 리스트 (oldest first) mdd_current: 현재 MDD 값 (%) Returns: MarketCondition 데이터클래스 """ from app.services.mean_reversion_strategy import calculate_atr, calculate_adx condition = MarketCondition() if len(closes) < 20: logger.warning("[Adaptive] 데이터 부족 (closes=%d) - Normal 모드 유지", len(closes)) condition.trading_mode = TradingMode.NORMAL.value return condition # ── ATR 백분위 ────────────────────────────────────────────── if highs is not None and lows is not None and len(highs) >= 35: # 20일 롤링 ATR 계산 atr_history = [] for i in range(20, len(closes)): h_slice = highs[max(0, i - 14):i + 1] l_slice = lows[max(0, i - 14):i + 1] c_slice = closes[max(0, i - 14):i + 1] atr_val = calculate_atr(h_slice, l_slice, c_slice, period=14) if atr_val is not None: atr_history.append(atr_val) current_atr = calculate_atr(highs, lows, closes, period=14) if current_atr is not None and atr_history: condition.atr_percentile = _calculate_atr_percentile(atr_history, current_atr) condition.vix_level = _determine_vix_level(condition.atr_percentile) else: # highs/lows 없으면 종가 기반 단순 변동성 if len(closes) >= 20: daily_returns = [ abs(closes[i] - closes[i - 1]) / closes[i - 1] * 100 for i in range(1, len(closes)) if closes[i - 1] > 0 ] if daily_returns: recent_vol = sum(daily_returns[-5:]) / max(len(daily_returns[-5:]), 1) all_vol = sum(daily_returns) / len(daily_returns) vol_ratio = recent_vol / all_vol if all_vol > 0 else 1.0 condition.atr_percentile = min(100, max(0, vol_ratio * 50)) condition.vix_level = _determine_vix_level(condition.atr_percentile) # ── ADX (추세 강도) ────────────────────────────────────────── adx_value = 20.0 if highs is not None and lows is not None: adx_calc = calculate_adx(highs, lows, closes, period=14) if adx_calc is not None: adx_value = adx_calc condition.adx_value = adx_value # ── 이동평균 ────────────────────────────────────────────────── ma20 = sum(closes[-20:]) / 20 if len(closes) >= 20 else closes[-1] ma60 = sum(closes[-60:]) / 60 if len(closes) >= 60 else ma20 condition.trend_strength = _determine_trend_strength( adx_value, ma20, ma60, closes[-1] ) # ── 5일 수익률 ────────────────────────────────────────────── if len(closes) >= 6: condition.recent_5d_return = ( (closes[-1] - closes[-6]) / closes[-6] * 100 ) else: condition.recent_5d_return = 0.0 # ── 거래량 레짐 ────────────────────────────────────────────── if volumes is not None and len(volumes) >= 20: avg_vol_20 = sum(volumes[-20:]) / 20 current_vol = volumes[-1] condition.volume_ratio = current_vol / avg_vol_20 if avg_vol_20 > 0 else 1.0 condition.volume_regime = _determine_volume_regime(condition.volume_ratio) # ── 시장 심리 (상승종목 비율 proxy) ────────────────────────── # KODEX200 데이터만으로는 breadth를 계산할 수 없으므로 # 5일 수익률 기반 proxy 사용 if condition.recent_5d_return > 3.0: condition.market_breadth = 0.75 elif condition.recent_5d_return > 0: condition.market_breadth = 0.55 elif condition.recent_5d_return > -3.0: condition.market_breadth = 0.40 else: condition.market_breadth = 0.25 # ── 종합 모드 결정 ────────────────────────────────────────── condition.trading_mode = _determine_trading_mode( atr_percentile=condition.atr_percentile, adx_value=condition.adx_value, recent_5d_return=condition.recent_5d_return, mdd_current=mdd_current, ) import pytz condition.analyzed_at = datetime.now( pytz.timezone("Asia/Seoul") ).strftime("%Y-%m-%d %H:%M:%S") return condition # ═════════════════════════════════════════════════════════════════════════════ # 4. 적응형 파라미터 생성 # ═════════════════════════════════════════════════════════════════════════════ def get_adaptive_params(condition: MarketCondition) -> AdaptiveParams: """MarketCondition 기반으로 적응형 파라미터 생성. _MODE_PARAMS 테이블에서 모드별 파라미터를 가져온 뒤 AdaptiveParams 데이터클래스로 래핑한다. """ mode = condition.trading_mode params_dict = _MODE_PARAMS.get(mode, _MODE_PARAMS[TradingMode.NORMAL.value]) import pytz params = AdaptiveParams( trading_mode=mode, condition=condition, mdd_current=0.0, updated_at=datetime.now(pytz.timezone("Asia/Seoul")).strftime("%Y-%m-%d %H:%M:%S"), **params_dict, ) return params def _get_fallback_params() -> AdaptiveParams: """config.py 기본값 기반 fallback 파라미터 (Normal 모드). adaptive_strategy 모듈 실패 시 사용. """ from app.core.config import settings params = AdaptiveParams( trading_mode=TradingMode.NORMAL.value, rsi_entry=settings.MEAN_REVERSION_RSI_THRESHOLD, bb_pct_b=0.30, bear_rsi_entry=settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD, position_size=settings.MAX_POSITION_SIZE, max_holdings=settings.MAX_HOLDINGS_COUNT, max_daily_trades=settings.MAX_DAILY_TRADES, stop_loss_pct=-abs(settings.STOP_LOSS_PERCENT), stop_loss_high_vol=-abs(settings.STOP_LOSS_HIGH_VOL), atr_stop_multiplier=settings.ATR_STOP_MULTIPLIER, atr_trailing_multiplier=settings.ATR_TRAILING_MULTIPLIER, rsi_partial_exit=settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD, rsi_full_exit=settings.MEAN_REVERSION_RSI_FULL_EXIT, daytrading_enabled=settings.DAY_TRADE_ENABLED, short_term_enabled=True, fallback_used=True, ) return params # ═════════════════════════════════════════════════════════════════════════════ # 5. 글로벌 상태 관리 (싱글톤) # ═════════════════════════════════════════════════════════════════════════════ _REDIS_KEY_CONDITION = "adaptive:market_condition" _REDIS_KEY_PARAMS = "adaptive:params" _REDIS_KEY_HISTORY = "adaptive:mode_history" _REDIS_TTL = 600 # 10분 # 인메모리 캐시 _current_params: Optional[AdaptiveParams] = None _current_condition: Optional[MarketCondition] = None _last_update_ts: float = 0.0 _CACHE_TTL = 120 # 2분 _update_lock = asyncio.Lock() async def _save_to_redis(condition: MarketCondition, params: AdaptiveParams) -> None: """Redis에 현재 상태 저장.""" try: from app.core.redis import get_redis from app.core.json_helpers import _dumps client = get_redis() if client: await client.set( _REDIS_KEY_CONDITION, _dumps(asdict(condition)), ex=_REDIS_TTL, ) # params에서 condition 필드 제거 (중복 방지) params_dict = asdict(params) params_dict.pop("condition", None) await client.set( _REDIS_KEY_PARAMS, _dumps(params_dict), ex=_REDIS_TTL, ) except Exception as e: logger.debug("[Adaptive] Redis 저장 실패: %s", e) async def _record_mode_change(prev_mode: str, new_mode: str, condition: MarketCondition) -> None: """모드 변경 이력을 Redis 리스트에 기록 + Telegram 알림. 최근 100건만 유지. """ import pytz now_str = datetime.now(pytz.timezone("Asia/Seoul")).strftime("%Y-%m-%d %H:%M:%S") entry = { "timestamp": now_str, "prev_mode": prev_mode, "new_mode": new_mode, "atr_percentile": round(condition.atr_percentile, 1), "adx_value": round(condition.adx_value, 1), "recent_5d_return": round(condition.recent_5d_return, 2), "vix_level": condition.vix_level, "trend_strength": condition.trend_strength, } try: from app.core.redis import get_redis from app.core.json_helpers import _dumps client = get_redis() if client: await client.lpush(_REDIS_KEY_HISTORY, _dumps(entry)) await client.ltrim(_REDIS_KEY_HISTORY, 0, 99) # 최근 100건 except Exception as e: logger.debug("[Adaptive] 이력 기록 실패: %s", e) # Telegram 알림 try: from app.services.telegram_notifier import telegram_notifier _mode_label = { "aggressive": "AGGRESSIVE (공격적)", "normal": "NORMAL (보통)", "defensive": "DEFENSIVE (방어적)", "crisis": "CRISIS (위기)", } text = ( f"[적응형 전략] 모드 전환\n" f" {_mode_label.get(prev_mode, prev_mode)} -> {_mode_label.get(new_mode, new_mode)}\n" f" ATR백분위: {condition.atr_percentile:.0f}%\n" f" ADX: {condition.adx_value:.1f}\n" f" 5일수익률: {condition.recent_5d_return:+.2f}%\n" f" 변동성: {condition.vix_level}\n" f" 추세: {condition.trend_strength}" ) asyncio.ensure_future(telegram_notifier.notify_system_warning( category="adaptive_mode_change", message=text, )) except Exception: pass # Telegram 실패해도 무방 logger.info( "[Adaptive] 모드 전환: %s -> %s (ATR=%.0f%%, ADX=%.1f, 5d=%.2f%%)", prev_mode, new_mode, condition.atr_percentile, condition.adx_value, condition.recent_5d_return, ) async def refresh_adaptive_params() -> AdaptiveParams: """시장 데이터를 조회하여 적응형 파라미터를 갱신한다. KODEX200(069500) 일봉 데이터를 가져와 MarketCondition을 분석하고 AdaptiveParams를 생성한다. 결과는 인메모리 + Redis에 캐시. [호출 시점] - main.py 스케줄러에서 2분 주기 호출 - API 엔드포인트에서 수동 호출 가능 """ global _current_params, _current_condition, _last_update_ts async with _update_lock: try: # KODEX200 일봉 데이터 가져오기 (네이버 우선, KIS 폴백) chart_data = None try: from app.services.naver_finance import get_naver_daily_chart # ATR 백분위 산출 히스토리 확보 — 캘린더일 기준 400일 요청(≈270거래일, 백테스트와 정합) naver_data = await get_naver_daily_chart("069500", days=400) if naver_data and len(naver_data) >= 60: chart_data = list(reversed(naver_data)) # newest first -> list except Exception: pass if chart_data is None: from app.services.korea_investment_api import ( korea_investment_api, real_market_api as _real_market_api, ) _api = _real_market_api or korea_investment_api kospi_chart = await _api.get_daily_chart("069500", "D", limit=120) if "output2" in kospi_chart and kospi_chart["output2"]: chart_data = kospi_chart["output2"] if not chart_data or len(chart_data) < 20: logger.warning("[Adaptive] KODEX200 데이터 부족 — fallback 유지") if _current_params is None: _current_params = _get_fallback_params() return _current_params # 데이터 파싱 (chart_data는 newest-first 형식) closes = [float(item.get("stck_clpr", 0)) for item in chart_data] highs = [float(item.get("stck_hgpr", 0)) for item in chart_data] lows = [float(item.get("stck_lwpr", 0)) for item in chart_data] volumes = [float(item.get("acml_vol", 0)) for item in chart_data] # oldest-first로 변환 (분석 함수 요구사항) closes.reverse() highs.reverse() lows.reverse() volumes.reverse() # MDD 현재 값 가져오기 — KIS 잔고 기반 실시간 계산 mdd_val = 0.0 try: from app.services.mdd_manager import mdd_manager if mdd_manager.peak_balance > 0: # KIS 잔고 조회로 현재 잔고 추정 try: from app.services.korea_investment_api import korea_investment_api bal = await korea_investment_api.get_balance() current_balance = float((bal.get("output2") or [{}])[0].get("tot_evlu_amt", 0) or 0) if current_balance > 0: mdd_val = ((current_balance - mdd_manager.peak_balance) / mdd_manager.peak_balance * 100) except Exception: # 잔고 조회 실패 시 상태 기반 추정 if mdd_manager.reduction_applied == "20pct": mdd_val = -20.0 elif mdd_manager.reduction_applied == "10pct": mdd_val = -10.0 elif mdd_manager.is_paused: mdd_val = -10.0 except Exception: pass # 시장 컨디션 분석 condition = analyze_market_condition( closes=closes, highs=highs, lows=lows, volumes=volumes, mdd_current=mdd_val, ) # 적응형 파라미터 생성 new_params = get_adaptive_params(condition) new_params.mdd_current = mdd_val # 모드 변경 감지 & 기록 prev_mode = _current_params.trading_mode if _current_params else TradingMode.NORMAL.value if new_params.trading_mode != prev_mode: await _record_mode_change(prev_mode, new_params.trading_mode, condition) # 캐시 갱신 _current_params = new_params _current_condition = condition _last_update_ts = time.monotonic() # Redis 저장 await _save_to_redis(condition, new_params) logger.info( "[Adaptive] 갱신 완료: mode=%s, ATR=%.0f%%, ADX=%.1f, 5d=%+.2f%%", new_params.trading_mode, condition.atr_percentile, condition.adx_value, condition.recent_5d_return, ) return new_params except Exception as e: logger.error("[Adaptive] 갱신 실패: %s", e, exc_info=True) if _current_params is None: _current_params = _get_fallback_params() return _current_params def get_current_adaptive_params() -> AdaptiveParams: """현재 적응형 파라미터를 반환 (동기 함수). 캐시된 값을 즉시 반환. 캐시 만료 시 비동기 갱신을 트리거. 캐시가 없으면 config.py 기본값 기반 fallback을 반환. [사용법] from app.services.adaptive_strategy import get_current_adaptive_params adaptive = get_current_adaptive_params() # adaptive.position_size, adaptive.rsi_entry 등 """ global _current_params, _last_update_ts # 캐시된 값이 있고 TTL 이내면 즉시 반환 if _current_params is not None: age = time.monotonic() - _last_update_ts if age <= _CACHE_TTL: return _current_params # TTL 초과 — 비동기 갱신 트리거 (호출자를 블록하지 않음) try: # get_event_loop()는 실행 중인 루프가 없는 동기 컨텍스트에서 예외/경고를 # 유발할 수 있으므로 get_running_loop()를 우선 사용하고, 실행 중인 루프가 # 없으면(RuntimeError) 백그라운드 갱신을 조용히 스킵한다. asyncio.get_running_loop() asyncio.ensure_future(refresh_adaptive_params()) except RuntimeError: pass # 실행 중인 이벤트 루프 없음 — 동기 컨텍스트에서 호출된 경우 except Exception: pass return _current_params # 캐시 없음 — fallback # [FIX] _last_update_ts를 갱신하지 않으면 다음 호출에서 age가 (monotonic()-0.0)으로 # 거대해져 매번 TTL 초과로 판정되고 refresh_adaptive_params()가 남발된다. _current_params = _get_fallback_params() _last_update_ts = time.monotonic() return _current_params async def aget_current_adaptive_params() -> AdaptiveParams: """현재 적응형 파라미터를 반환 (비동기 함수). 캐시 만료 시 즉시 갱신 후 반환. """ global _current_params, _last_update_ts if _current_params is not None: age = time.monotonic() - _last_update_ts if age <= _CACHE_TTL: return _current_params return await refresh_adaptive_params() def get_current_condition() -> Optional[MarketCondition]: """현재 시장 컨디션 반환 (디버깅/API용).""" return _current_condition def force_mode_override(mode: str, reason: str = "") -> None: """미장 심리 등 외부 요인으로 적응형 전략 모드를 강제 오버라이드. Args: mode: "defensive" 또는 "crisis" (TradingMode 값) reason: 오버라이드 사유 (로깅용) """ global _current_params, _last_update_ts if mode not in _MODE_PARAMS: logger.warning("[ADAPTIVE] 알 수 없는 모드 오버라이드 요청: %s", mode) return params_dict = _MODE_PARAMS[mode] # [FIX] 이전에는 dataclass 필드 일부만 채워 나머지(특히 max_daily_trades)가 기본값(5)으로 # 남았다. 예: crisis 오버라이드가 max_daily_trades=0을 의도했지만 실제로는 5가 적용되어 # 진입 차단이 되지 않았다. get_adaptive_params()와 동일하게 _MODE_PARAMS 전체를 # **params_dict로 펼쳐 계산된 모드와 완전히 동일한 필드 구성을 보장한다. import pytz as _pytz _current_params = AdaptiveParams( trading_mode=mode, condition=MarketCondition(trading_mode=mode), mdd_current=_current_params.mdd_current if _current_params else 0.0, updated_at=datetime.now(_pytz.timezone("Asia/Seoul")).strftime("%Y-%m-%d %H:%M:%S"), fallback_used=False, **params_dict, ) _last_update_ts = time.monotonic() logger.warning( "[ADAPTIVE] 모드 강제 오버라이드: %s (사유: %s)", mode, reason or "없음" ) # Redis에 이력 기록 try: # get_event_loop()는 실행 중인 루프가 없는 동기 컨텍스트에서 예외/경고를 # 유발할 수 있으므로 get_running_loop()를 우선 사용한다. asyncio.get_running_loop() asyncio.ensure_future(_record_mode_change("override", mode, MarketCondition(trading_mode=mode))) except RuntimeError: pass # 실행 중인 이벤트 루프 없음 — 동기 컨텍스트에서 호출된 경우 except Exception: pass # ═════════════════════════════════════════════════════════════════════════════ # 6. 전략 이력 조회 # ═════════════════════════════════════════════════════════════════════════════ async def get_strategy_history(days: int = 30) -> List[dict]: """최근 N일간 모드 변경 이력 조회. Redis 리스트에서 최근 100건 중 days일 이내 항목만 필터. """ import json from datetime import timedelta import pytz try: from app.core.redis import get_redis client = get_redis() if not client: return [] raw_list = await client.lrange(_REDIS_KEY_HISTORY, 0, 99) if not raw_list: return [] kst = pytz.timezone("Asia/Seoul") cutoff = datetime.now(kst) - timedelta(days=days) result = [] for raw in raw_list: try: entry = json.loads(raw) ts_str = entry.get("timestamp", "") ts = kst.localize(datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")) if ts >= cutoff: result.append(entry) except Exception: continue return result except Exception as e: logger.debug("[Adaptive] 이력 조회 실패: %s", e) return [] # ═════════════════════════════════════════════════════════════════════════════ # 7. API 엔드포인트용 헬퍼 # ═════════════════════════════════════════════════════════════════════════════ def get_adaptive_status() -> dict: """현재 적응형 전략 상태를 딕셔너리로 반환 (API 응답용).""" params = get_current_adaptive_params() condition = get_current_condition() status = { "trading_mode": params.trading_mode, "fallback_used": params.fallback_used, "updated_at": params.updated_at, "params": { "rsi_entry": params.rsi_entry, "bb_pct_b": params.bb_pct_b, "bear_rsi_entry": params.bear_rsi_entry, "position_size": params.position_size, "max_holdings": params.max_holdings, "max_daily_trades": params.max_daily_trades, "stop_loss_pct": params.stop_loss_pct, "stop_loss_high_vol": params.stop_loss_high_vol, "atr_stop_multiplier": params.atr_stop_multiplier, "atr_trailing_multiplier": params.atr_trailing_multiplier, "rsi_partial_exit": params.rsi_partial_exit, "rsi_full_exit": params.rsi_full_exit, "daytrading_enabled": params.daytrading_enabled, "short_term_enabled": params.short_term_enabled, }, } if condition: status["condition"] = { "vix_level": condition.vix_level, "atr_percentile": round(condition.atr_percentile, 1), "trend_strength": condition.trend_strength, "adx_value": round(condition.adx_value, 1), "market_breadth": round(condition.market_breadth, 2), "recent_5d_return": round(condition.recent_5d_return, 2), "volume_regime": condition.volume_regime, "volume_ratio": round(condition.volume_ratio, 2), "analyzed_at": condition.analyzed_at, } return status