- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
1123 lines
47 KiB
Python
1123 lines
47 KiB
Python
"""
|
||
적응형 전략 엔진 (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
|
||
|
||
from app.core.log_throttle import warn_throttled # noqa: E402
|
||
|
||
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 _balance_total_or_none_v2(bal) -> Optional[float]:
|
||
"""get_balance() 응답 → 총평가금액(>0) 또는 None(산출 불가).
|
||
|
||
None 조건: dict 아님 / rt_cd 가 있고 '0' 아님(KIS 오류 응답) / output2 누락·형식 이상 /
|
||
tot_evlu_amt 해석 불가 / 값 ≤ 0(빈 계좌 — mdd_manager 는 이 경우 MDD 갱신을 스킵한다).
|
||
"""
|
||
if not isinstance(bal, dict):
|
||
return None
|
||
_rt = bal.get("rt_cd")
|
||
if _rt is not None and str(_rt) != "0":
|
||
return None
|
||
_out2 = bal.get("output2")
|
||
if not isinstance(_out2, list) or not _out2 or not isinstance(_out2[0], dict):
|
||
return None
|
||
_raw = _out2[0].get("tot_evlu_amt")
|
||
try:
|
||
total = float(_raw)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if total != total or total <= 0:
|
||
return None
|
||
return total
|
||
|
||
|
||
def _resolve_mdd_on_failure_v2(prev_mdd: float, mgr=None) -> float:
|
||
"""MDD 산출 실패 시 쓸 보수값 = min(직전 MDD, mdd_manager 상태 추정).
|
||
|
||
상태 추정: reduction_applied=='20pct' → -20, '10pct' 또는 is_paused → -10, 그 외 0.
|
||
상태 조회 자체가 실패하면 직전 값만 쓴다. 값이 작을수록(더 음수) Crisis 쪽이다.
|
||
"""
|
||
state_est = 0.0
|
||
try:
|
||
if mgr is not None:
|
||
ra = getattr(mgr, "reduction_applied", None)
|
||
if ra == "20pct":
|
||
state_est = -20.0
|
||
elif ra == "10pct" or bool(getattr(mgr, "is_paused", False)):
|
||
state_est = -10.0
|
||
except Exception as e:
|
||
warn_throttled(logger, "adaptive_strategy:mdd_state_est", "[Adaptive] MDD 상태 추정 실패: %s", e)
|
||
try:
|
||
prev = float(prev_mdd)
|
||
except (TypeError, ValueError):
|
||
prev = 0.0
|
||
return min(prev, state_est)
|
||
|
||
|
||
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()
|
||
|
||
# [R2 2026-10-09] 갱신 실패가 이어지면 직전 모드(Normal/Aggressive)가 무기한 유지돼
|
||
# Crisis 진입 차단이 fail-open 이 되는 결함 대응: 마지막 "성공" 갱신 시각을 추적하고
|
||
# 장중 _STALE_LIMIT_SEC 초과 시 Defensive 로 강등 + 텔레그램 경보.
|
||
_STALE_LIMIT_SEC = 600.0 # 10분 (갱신 주기 2분 × 5회)
|
||
_STALE_ALERT_INTERVAL_SEC = 1800.0
|
||
_last_success_ts: float = time.monotonic() # 기동 시각을 기준선으로(기동 후 계속 실패해도 감지)
|
||
_last_stale_alert_ts: float = 0.0
|
||
_bg_tasks: set = set() # fire-and-forget 태스크 참조 보관(GC 방지)
|
||
|
||
|
||
def _market_open_or_true_v2() -> bool:
|
||
"""장중 여부. 판정 실패 시 True(강등 쪽이 안전)."""
|
||
try:
|
||
from app.core.market_schedule import is_market_open
|
||
return bool(is_market_open())
|
||
except Exception as e:
|
||
warn_throttled(logger, "adaptive_strategy:stale_mkt", "[Adaptive] 장 상태 판정 실패 — 장중으로 간주: %s", e)
|
||
return True
|
||
|
||
|
||
def _spawn_bg_v2(coro) -> None:
|
||
try:
|
||
task = asyncio.ensure_future(coro)
|
||
except Exception:
|
||
coro.close()
|
||
raise
|
||
_bg_tasks.add(task)
|
||
task.add_done_callback(_bg_tasks.discard)
|
||
|
||
|
||
def get_last_success_age_sec_v2() -> float:
|
||
"""마지막 성공 갱신 이후 경과(초). /health 노출용."""
|
||
return time.monotonic() - _last_success_ts
|
||
|
||
|
||
def enforce_staleness_v2() -> Optional["AdaptiveParams"]:
|
||
"""갱신이 _STALE_LIMIT_SEC 넘게 실패 중이면(장중) Defensive 로 강등하고 경보한다.
|
||
|
||
이미 Defensive/Crisis 면 모드는 건드리지 않는다(더 보수적인 쪽 유지).
|
||
현재 파라미터를 반환한다.
|
||
"""
|
||
global _current_params, _last_stale_alert_ts, _last_update_ts
|
||
|
||
age = get_last_success_age_sec_v2()
|
||
if age <= _STALE_LIMIT_SEC or not _market_open_or_true_v2():
|
||
return _current_params
|
||
|
||
cur = _current_params
|
||
if cur is not None and cur.trading_mode in (TradingMode.CRISIS.value, TradingMode.DEFENSIVE.value):
|
||
degraded = False
|
||
else:
|
||
import pytz as _pytz
|
||
_current_params = AdaptiveParams(
|
||
trading_mode=TradingMode.DEFENSIVE.value,
|
||
condition=MarketCondition(trading_mode=TradingMode.DEFENSIVE.value),
|
||
mdd_current=cur.mdd_current if cur is not None else 0.0,
|
||
updated_at=datetime.now(_pytz.timezone("Asia/Seoul")).strftime("%Y-%m-%d %H:%M:%S"),
|
||
fallback_used=False,
|
||
**_MODE_PARAMS[TradingMode.DEFENSIVE.value],
|
||
)
|
||
_last_update_ts = time.monotonic()
|
||
degraded = True
|
||
|
||
now = time.monotonic()
|
||
if degraded or now - _last_stale_alert_ts >= _STALE_ALERT_INTERVAL_SEC:
|
||
_last_stale_alert_ts = now
|
||
logger.error(
|
||
"[Adaptive] 🚨 갱신 %.0f초째 실패(상한 %.0fs) — %s",
|
||
age, _STALE_LIMIT_SEC,
|
||
"Defensive 로 강등" if degraded else "모드 유지(이미 보수적)",
|
||
)
|
||
try:
|
||
from app.services.telegram_notifier import telegram_notifier
|
||
_spawn_bg_v2(telegram_notifier.notify_system_warning(
|
||
category="adaptive_stale",
|
||
message=(f"[적응형 전략] 모드 갱신 {age / 60:.0f}분째 실패 "
|
||
f"(상한 {_STALE_LIMIT_SEC / 60:.0f}분). "
|
||
f"{'Defensive 로 강등' if degraded else '현재 모드 유지'}. 시세/KIS 점검 필요"),
|
||
))
|
||
except RuntimeError:
|
||
pass # 실행 중인 루프 없음(동기 컨텍스트) — 로그 error 로 충분
|
||
except Exception as e:
|
||
logger.warning("[Adaptive] 경보 전송 실패: %s", e)
|
||
return _current_params
|
||
|
||
|
||
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.warning("[Adaptive] Redis 저장 실패: %s", e)
|
||
|
||
|
||
async def _save_data_source_v2(src: str, bars: int) -> 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("adaptive:data_source", _dumps({"src": src, "bars": bars}), ex=_REDIS_TTL)
|
||
except Exception as e:
|
||
logger.warning("[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.warning("[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,
|
||
)
|
||
|
||
|
||
_MIN_BARS_FOR_ATR_PCT = 120 # ATR 백분위 산출 최소 봉수(미달 시 갱신 실패)
|
||
|
||
|
||
async def _fetch_kodex_chart_v2():
|
||
"""KODEX200 일봉(newest-first)과 소스명을 반환. 네이버 → KIS 날짜범위 순.
|
||
|
||
각 소스가 _MIN_BARS_FOR_ATR_PCT 미만이면 다음 소스로 넘어간다. 예외는 warning.
|
||
"""
|
||
best, best_src = None, "none"
|
||
try:
|
||
from app.services.naver_finance import get_naver_daily_chart
|
||
naver_data = await get_naver_daily_chart("069500", days=400)
|
||
if naver_data and len(naver_data) >= _MIN_BARS_FOR_ATR_PCT:
|
||
return list(reversed(naver_data)), "naver"
|
||
if naver_data:
|
||
best, best_src = list(reversed(naver_data)), "naver"
|
||
except Exception as _swallowed:
|
||
warn_throttled(logger, "adaptive_strategy:643", "[Adaptive] 네이버 KODEX200 차트 조회 실패(KIS 폴백 진행): %s", _swallowed)
|
||
try:
|
||
from datetime import timedelta
|
||
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
|
||
_end = datetime.now()
|
||
rows = await _api.get_daily_ohlcv_range(
|
||
"069500", (_end - timedelta(days=400)).strftime("%Y%m%d"), _end.strftime("%Y%m%d"))
|
||
if rows and (best is None or len(rows) > len(best)):
|
||
best, best_src = list(rows), "kis_range"
|
||
except Exception as _kis_err:
|
||
warn_throttled(logger, "adaptive_strategy:kis_range", "[Adaptive] KIS KODEX200 범위 조회 실패: %s", _kis_err)
|
||
return best, best_src
|
||
|
||
|
||
|
||
async def refresh_adaptive_params() -> AdaptiveParams:
|
||
"""시장 데이터를 조회하여 적응형 파라미터를 갱신한다.
|
||
|
||
KODEX200(069500) 일봉 데이터를 가져와 MarketCondition을 분석하고
|
||
AdaptiveParams를 생성한다. 결과는 인메모리 + Redis에 캐시.
|
||
|
||
[호출 시점]
|
||
- main.py 스케줄러에서 2분 주기 호출
|
||
- API 엔드포인트에서 수동 호출 가능
|
||
"""
|
||
global _current_params, _current_condition, _last_update_ts, _last_success_ts
|
||
|
||
async with _update_lock:
|
||
try:
|
||
# KODEX200 일봉 데이터 가져오기 (네이버 우선, KIS 날짜범위 폴백)
|
||
# [R3 2026-10-09] ATR 백분위 최소 봉수(_MIN_BARS_FOR_ATR_PCT) 미달이면 갱신 실패로
|
||
# 처리한다(공식이 5일/전체 변동비로 조용히 바뀌어 Crisis 오발동하던 결함).
|
||
chart_data, _src = await _fetch_kodex_chart_v2()
|
||
if not chart_data or len(chart_data) < _MIN_BARS_FOR_ATR_PCT:
|
||
logger.warning(
|
||
"[Adaptive] KODEX200 데이터 부족(src=%s, bars=%d < %d) — 갱신 실패 처리, staleness 경로",
|
||
_src, len(chart_data or []), _MIN_BARS_FOR_ATR_PCT,
|
||
)
|
||
if _current_params is None:
|
||
_current_params = _get_fallback_params()
|
||
return enforce_staleness_v2() or _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 잔고 기반 실시간 계산
|
||
# [H3/§4 2026-10-02] 기존엔 산출 실패 시 mdd_val=0.0 으로 조용히 진행해
|
||
# Crisis 트리거(MDD < -10%)가 누락됐다. 실패하면 직전 MDD·mdd_manager 상태
|
||
# 추정 중 더 나쁜 값을 쓴다(_resolve_mdd_on_failure_v2) + warning.
|
||
_prev_mdd = float(_current_params.mdd_current) if _current_params is not None else 0.0
|
||
mdd_val = 0.0
|
||
_mdd_mgr = None
|
||
_mdd_failed = False
|
||
try:
|
||
from app.services.mdd_manager import mdd_manager as _mdd_mgr
|
||
if _mdd_mgr.peak_balance > 0:
|
||
# KIS 잔고 조회로 현재 잔고 추정
|
||
try:
|
||
from app.services.korea_investment_api import korea_investment_api
|
||
bal = await korea_investment_api.get_balance()
|
||
# [M-3 2026-10-02 3회차] get_balance() 는 rt_cd≠'0'(KIS 오류)이어도
|
||
# 예외 없이 dict 를 돌려준다 → 기존엔 tot_evlu_amt 0 → mdd 0 으로
|
||
# 오인해 Crisis(MDD<-10%) 트리거를 놓쳤다. 오류 응답·총자산≤0 은
|
||
# 산출 실패로 처리한다(_resolve_mdd_on_failure_v2). 총자산≤0 은
|
||
# mdd_manager 기준으로 "빈 계좌 = MDD 갱신 스킵"이므로 0 을 쓰지 않고
|
||
# 직전/상태 기반 값을 유지하는 것이 그 기준과 일관된다.
|
||
current_balance = _balance_total_or_none_v2(bal)
|
||
if current_balance is None:
|
||
raise ValueError(
|
||
f"잔고 응답 비정상(rt_cd={(bal or {}).get('rt_cd') if isinstance(bal, dict) else None!r}, "
|
||
f"msg={(bal or {}).get('msg1') if isinstance(bal, dict) else None!r}) 또는 총자산≤0"
|
||
)
|
||
mdd_val = ((current_balance - _mdd_mgr.peak_balance)
|
||
/ _mdd_mgr.peak_balance * 100)
|
||
except Exception as _bal_err:
|
||
_mdd_failed = True
|
||
warn_throttled(
|
||
logger, "adaptive_strategy:mdd_balance",
|
||
"[Adaptive] MDD 산출용 잔고 조회 실패 — 직전/상태 기반 보수값 사용: %s",
|
||
_bal_err,
|
||
)
|
||
except Exception as _mdd_err:
|
||
_mdd_failed = True
|
||
warn_throttled(
|
||
logger, "adaptive_strategy:mdd_state",
|
||
"[Adaptive] MDD 상태 조회 실패 — 직전 MDD 보수 유지: %s", _mdd_err,
|
||
)
|
||
if _mdd_failed:
|
||
mdd_val = _resolve_mdd_on_failure_v2(_prev_mdd, _mdd_mgr)
|
||
|
||
# 시장 컨디션 분석
|
||
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()
|
||
_last_success_ts = _last_update_ts
|
||
|
||
# Redis 저장
|
||
await _save_to_redis(condition, new_params)
|
||
await _save_data_source_v2(_src, len(chart_data))
|
||
|
||
logger.info(
|
||
"[Adaptive] 갱신 완료: src=%s bars=%d mode=%s, ATR=%.0f%%, ADX=%.1f, 5d=%+.2f%%",
|
||
_src, len(chart_data),
|
||
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 enforce_staleness_v2() or _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()
|
||
_spawn_bg_v2(refresh_adaptive_params())
|
||
except RuntimeError:
|
||
pass # 실행 중인 이벤트 루프 없음 — 동기 컨텍스트에서 호출된 경우
|
||
except Exception as _swallowed:
|
||
warn_throttled(logger, "adaptive_strategy:767", "[Adaptive] 백그라운드 갱신 예약 실패: %s", _swallowed)
|
||
# 낡은 값을 무기한 반환하지 않도록 staleness 강등 적용(try 밖)
|
||
return enforce_staleness_v2() or _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 as _swallowed:
|
||
logger.warning("[Adaptive] 모드 오버라이드 이력 기록 예약 실패: %s", _swallowed)
|
||
|
||
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
# 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.warning("[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
|