Files
auto-stock/backend/app/services/adaptive_strategy.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 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
2026-10-10 12:17:49 +09:00

1123 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
적응형 전략 엔진 (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