Files
auto-stock/backend/app/services/adaptive_strategy.py
shuim c818810667
Some checks failed
CI/CD Pipeline / test-backend (push) Has been cancelled
CI/CD Pipeline / test-frontend (push) Has been cancelled
CI/CD Pipeline / build-and-push (push) Has been cancelled
수정본
2026-03-27 14:47:49 +09:00

912 lines
36 KiB
Python

"""
적응형 전략 엔진 (Adaptive Strategy Engine)
시장 상황(변동성, 추세 강도, 심리, 거래량)에 따라
매매 파라미터를 자동으로 조정하는 모듈.
[핵심 아이디어]
- 변동성 높을 때: 손절 넓게, 포지션 축소, 진입 조건 엄격
- 변동성 낮을 때: 손절 좁게, 포지션 확대, 진입 조건 완화
- 추세장: 평균회귀 축소, 추세추종 확대
- 횡보장: 평균회귀 확대, 데이트레이딩 축소
- 급락 후 반등: 공격적 매수, 포지션 확대
[모드 전환 규칙] CLAUDE.md 섹션 8.5
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회
_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.5:
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.5 정합
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.5 정합
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
naver_data = await get_naver_daily_chart("069500", days=120)
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", [{}])[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:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.ensure_future(refresh_adaptive_params())
except Exception:
pass
return _current_params
# 캐시 없음 — fallback
_current_params = _get_fallback_params()
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]
_current_params = AdaptiveParams(
trading_mode=mode,
rsi_entry=params_dict["rsi_entry"],
position_size=params_dict["position_size"],
stop_loss_pct=params_dict["stop_loss_pct"],
stop_loss_high_vol=params_dict.get("stop_loss_high_vol", params_dict["stop_loss_pct"] - 3.0),
atr_stop_multiplier=params_dict["atr_stop_multiplier"],
rsi_partial_exit=params_dict["rsi_partial_exit"],
rsi_full_exit=params_dict["rsi_full_exit"],
daytrading_enabled=params_dict["daytrading_enabled"],
fallback_used=False,
)
_last_update_ts = time.monotonic()
logger.warning(
"[ADAPTIVE] 모드 강제 오버라이드: %s (사유: %s)", mode, reason or "없음"
)
# Redis에 이력 기록
try:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.ensure_future(_record_mode_change("override", mode, MarketCondition(trading_mode=mode)))
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