Files
auto-stock/backend/app/services/adaptive_strategy.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

1000 lines
42 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()
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 _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,
)
async def refresh_adaptive_params() -> AdaptiveParams:
"""시장 데이터를 조회하여 적응형 파라미터를 갱신한다.
KODEX200(069500) 일봉 데이터를 가져와 MarketCondition을 분석하고
AdaptiveParams를 생성한다. 결과는 인메모리 + Redis에 캐시.
[호출 시점]
- main.py 스케줄러에서 2분 주기 호출
- API 엔드포인트에서 수동 호출 가능
"""
global _current_params, _current_condition, _last_update_ts
async with _update_lock:
try:
# KODEX200 일봉 데이터 가져오기 (네이버 우선, KIS 폴백)
chart_data = None
try:
from app.services.naver_finance import get_naver_daily_chart
# ATR 백분위 산출 히스토리 확보 — 캘린더일 기준 400일 요청(≈270거래일, 백테스트와 정합)
naver_data = await get_naver_daily_chart("069500", days=400)
if naver_data and len(naver_data) >= 60:
chart_data = list(reversed(naver_data)) # newest first -> list
except Exception as _swallowed:
warn_throttled(logger, "adaptive_strategy:643", "[Adaptive] 네이버 KODEX200 차트 조회 실패(KIS 폴백 진행): %s", _swallowed)
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 잔고 기반 실시간 계산
# [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()
# Redis 저장
await _save_to_redis(condition, new_params)
logger.info(
"[Adaptive] 갱신 완료: mode=%s, ATR=%.0f%%, ADX=%.1f, 5d=%+.2f%%",
new_params.trading_mode,
condition.atr_percentile,
condition.adx_value,
condition.recent_5d_return,
)
return new_params
except Exception as e:
logger.error("[Adaptive] 갱신 실패: %s", e, exc_info=True)
if _current_params is None:
_current_params = _get_fallback_params()
return _current_params
def get_current_adaptive_params() -> AdaptiveParams:
"""현재 적응형 파라미터를 반환 (동기 함수).
캐시된 값을 즉시 반환. 캐시 만료 시 비동기 갱신을 트리거.
캐시가 없으면 config.py 기본값 기반 fallback을 반환.
[사용법]
from app.services.adaptive_strategy import get_current_adaptive_params
adaptive = get_current_adaptive_params()
# adaptive.position_size, adaptive.rsi_entry 등
"""
global _current_params, _last_update_ts
# 캐시된 값이 있고 TTL 이내면 즉시 반환
if _current_params is not None:
age = time.monotonic() - _last_update_ts
if age <= _CACHE_TTL:
return _current_params
# TTL 초과 — 비동기 갱신 트리거 (호출자를 블록하지 않음)
try:
# get_event_loop()는 실행 중인 루프가 없는 동기 컨텍스트에서 예외/경고를
# 유발할 수 있으므로 get_running_loop()를 우선 사용하고, 실행 중인 루프가
# 없으면(RuntimeError) 백그라운드 갱신을 조용히 스킵한다.
asyncio.get_running_loop()
asyncio.ensure_future(refresh_adaptive_params())
except RuntimeError:
pass # 실행 중인 이벤트 루프 없음 — 동기 컨텍스트에서 호출된 경우
except Exception as _swallowed:
warn_throttled(logger, "adaptive_strategy:767", "[Adaptive] 백그라운드 갱신 예약 실패: %s", _swallowed)
return _current_params
# 캐시 없음 — fallback
# [FIX] _last_update_ts를 갱신하지 않으면 다음 호출에서 age가 (monotonic()-0.0)으로
# 거대해져 매번 TTL 초과로 판정되고 refresh_adaptive_params()가 남발된다.
_current_params = _get_fallback_params()
_last_update_ts = time.monotonic()
return _current_params
async def aget_current_adaptive_params() -> AdaptiveParams:
"""현재 적응형 파라미터를 반환 (비동기 함수).
캐시 만료 시 즉시 갱신 후 반환.
"""
global _current_params, _last_update_ts
if _current_params is not None:
age = time.monotonic() - _last_update_ts
if age <= _CACHE_TTL:
return _current_params
return await refresh_adaptive_params()
def get_current_condition() -> Optional[MarketCondition]:
"""현재 시장 컨디션 반환 (디버깅/API용)."""
return _current_condition
def force_mode_override(mode: str, reason: str = "") -> None:
"""미장 심리 등 외부 요인으로 적응형 전략 모드를 강제 오버라이드.
Args:
mode: "defensive" 또는 "crisis" (TradingMode 값)
reason: 오버라이드 사유 (로깅용)
"""
global _current_params, _last_update_ts
if mode not in _MODE_PARAMS:
logger.warning("[ADAPTIVE] 알 수 없는 모드 오버라이드 요청: %s", mode)
return
params_dict = _MODE_PARAMS[mode]
# [FIX] 이전에는 dataclass 필드 일부만 채워 나머지(특히 max_daily_trades)가 기본값(5)으로
# 남았다. 예: crisis 오버라이드가 max_daily_trades=0을 의도했지만 실제로는 5가 적용되어
# 진입 차단이 되지 않았다. get_adaptive_params()와 동일하게 _MODE_PARAMS 전체를
# **params_dict로 펼쳐 계산된 모드와 완전히 동일한 필드 구성을 보장한다.
import pytz as _pytz
_current_params = AdaptiveParams(
trading_mode=mode,
condition=MarketCondition(trading_mode=mode),
mdd_current=_current_params.mdd_current if _current_params else 0.0,
updated_at=datetime.now(_pytz.timezone("Asia/Seoul")).strftime("%Y-%m-%d %H:%M:%S"),
fallback_used=False,
**params_dict,
)
_last_update_ts = time.monotonic()
logger.warning(
"[ADAPTIVE] 모드 강제 오버라이드: %s (사유: %s)", mode, reason or "없음"
)
# Redis에 이력 기록
try:
# get_event_loop()는 실행 중인 루프가 없는 동기 컨텍스트에서 예외/경고를
# 유발할 수 있으므로 get_running_loop()를 우선 사용한다.
asyncio.get_running_loop()
asyncio.ensure_future(_record_mode_change("override", mode, MarketCondition(trading_mode=mode)))
except RuntimeError:
pass # 실행 중인 이벤트 루프 없음 — 동기 컨텍스트에서 호출된 경우
except Exception 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