하네스 규칙 검사 전역 위반 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>
1000 lines
42 KiB
Python
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
|