Files
auto-stock/backend/app/services/mean_reversion_strategy.py
2026-02-22 17:23:20 +09:00

341 lines
12 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.
"""
RSI + 볼린저 밴드 평균회귀 전략 (CLAUDE.md 섹션 7.1-7.5)
일봉 기준, 전체 자본의 최대 30% 이내에서만 운용.
"""
import logging
from typing import Optional, Dict, List, Tuple
from dataclasses import dataclass
from enum import Enum
logger = logging.getLogger(__name__)
class MarketRegime(Enum):
BULL = "bull"
BEAR = "bear"
@dataclass
class ExitSignal:
action: str # "SELL_PARTIAL", "SELL_ALL"
reason: str
sell_pct: float # 0.5 = 50%, 1.0 = 전량
def calculate_atr(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> Optional[float]:
"""ATR(Average True Range) 계산
TR = max(H-L, |H-Cprev|, |L-Cprev|)
ATR = SMA(TR, period)
"""
if len(highs) < period + 1 or len(lows) < period + 1 or len(closes) < period + 1:
return None
true_ranges = []
for i in range(1, len(highs)):
h, l, c_prev = highs[i], lows[i], closes[i - 1]
tr = max(h - l, abs(h - c_prev), abs(l - c_prev))
true_ranges.append(tr)
if len(true_ranges) < period:
return None
return sum(true_ranges[-period:]) / period
def calculate_adx(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> Optional[float]:
"""Wilder's ADX(Average Directional Index) 계산 — 추세 강도 지표
알고리즘:
1. TR = max(H-L, |H-Cprev|, |L-Cprev|)
2. +DM = H-Hprev if (H-Hprev) > (Lprev-L) and > 0, else 0
3. -DM = Lprev-L if (Lprev-L) > (H-Hprev) and > 0, else 0
4. Wilder 평활: ATR14 = ATR14_prev - ATR14_prev/14 + TR (초기값은 14일 합산)
5. +DI14 = 100 × (+DM14 / ATR14), -DI14 동일
6. DX = 100 × |+DI14 - -DI14| / (+DI14 + -DI14)
7. ADX = DX의 Wilder 평활
해석 (평균회귀 전략):
- ADX < 20: 횡보장 → 평균회귀 유리
- ADX 20~30: 중간 추세 → 조건부 허용
- ADX > 30: 강한 추세 → 평균회귀 위험 (진입 차단)
"""
n = len(highs)
# 최소 2*period + 1개 필요 (초기 평활 + ADX 평활)
if n < period * 2 + 1:
return None
trs: List[float] = []
plus_dms: List[float] = []
minus_dms: List[float] = []
for i in range(1, n):
h, l, c_prev = highs[i], lows[i], closes[i - 1]
h_prev, l_prev = highs[i - 1], lows[i - 1]
tr = max(h - l, abs(h - c_prev), abs(l - c_prev))
up_move = h - h_prev
down_move = l_prev - l
plus_dm = up_move if up_move > down_move and up_move > 0 else 0.0
minus_dm = down_move if down_move > up_move and down_move > 0 else 0.0
trs.append(tr)
plus_dms.append(plus_dm)
minus_dms.append(minus_dm)
def _wilder_smooth(values: List[float]) -> List[float]:
"""Wilder 평활: 초기값 = 첫 period개 합산, 이후 재귀 평활."""
smoothed = [sum(values[:period])]
for v in values[period:]:
smoothed.append(smoothed[-1] - smoothed[-1] / period + v)
return smoothed
atr_s = _wilder_smooth(trs)
plus_dm_s = _wilder_smooth(plus_dms)
minus_dm_s = _wilder_smooth(minus_dms)
dxs: List[float] = []
for atr_v, p_dm, m_dm in zip(atr_s, plus_dm_s, minus_dm_s):
if atr_v == 0:
dxs.append(0.0)
continue
plus_di = 100.0 * p_dm / atr_v
minus_di = 100.0 * m_dm / atr_v
di_sum = plus_di + minus_di
dxs.append(100.0 * abs(plus_di - minus_di) / di_sum if di_sum else 0.0)
if len(dxs) < period:
return None
adx_s = _wilder_smooth(dxs)
return round(adx_s[-1], 2)
def detect_market_regime(index_closes: List[float]) -> MarketRegime:
"""섹션 7.1: 200일 MA + 기울기로 상승장/하락장 판별
상승장(Bull): 지수 > MA200 AND MA200 기울기 양(+)
하락장(Bear): 그 외
"""
if len(index_closes) < 205:
return MarketRegime.BEAR # 데이터 부족 시 보수적
ma200_current = sum(index_closes[-200:]) / 200
ma200_5d_ago = sum(index_closes[-205:-5]) / 200
slope_positive = ma200_current > ma200_5d_ago
is_bull = index_closes[-1] > ma200_current and slope_positive
return MarketRegime.BULL if is_bull else MarketRegime.BEAR
def calculate_rsi(closes: List[float], period: int = 14) -> Optional[float]:
"""RSI(Relative Strength Index) 계산"""
if len(closes) < period + 1:
return None
gains, losses = [], []
for i in range(-period, 0):
diff = closes[i] - closes[i - 1]
gains.append(max(diff, 0))
losses.append(max(-diff, 0))
avg_gain = sum(gains) / period
avg_loss = sum(losses) / period
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
return 100 - (100 / (1 + rs))
def calculate_bollinger(closes: List[float], period: int = 20) -> Optional[Tuple[float, float, float]]:
"""볼린저 밴드 (20일, 2σ) 계산
Returns: (upper, middle, lower)
"""
if len(closes) < period:
return None
window = closes[-period:]
middle = sum(window) / period
variance = sum((p - middle) ** 2 for p in window) / period
std = variance ** 0.5
return (middle + 2 * std, middle, middle - 2 * std)
def is_reversal_candle(opens: List[float], highs: List[float],
lows: List[float], closes: List[float]) -> bool:
"""하락장 매수 조건용 되돌림 캔들 감지 (섹션 7.2)
양봉 또는 긴 하단 꼬리(하단 그림자 > 2× 몸통)
"""
if not closes:
return False
o, h, l, c = opens[-1], highs[-1], lows[-1], closes[-1]
body = abs(c - o)
lower_shadow = min(o, c) - l
is_bullish = c > o
has_long_lower_shadow = lower_shadow > 2 * max(body, 0.01)
return is_bullish or has_long_lower_shadow
def check_buy_signal(
regime: MarketRegime,
rsi: float,
close: float,
bb_upper: float,
bb_middle: float,
bb_lower: float,
avg_volume_20d: float,
current_volume: int,
prev_volume: int,
is_reversal: bool = False,
) -> Optional[Dict]:
"""섹션 7.2: 매수 조건 판별
Bull: RSI(14) ≤ 25 (과매도) OR 거래량 ≥ 20일평균×200%, 종가가 BB 하단선 -3% ~ +1.5% 구간
Bear: RSI(14) ≤ 35 (과매도), 종가 ≤ BB 하단선, 되돌림 캔들 출현, 전일 대비 거래량 증가
공통: 거래대금 ≥ 20일 평균
"""
if current_volume < avg_volume_20d:
return None
if regime == MarketRegime.BULL:
rsi_ok = rsi <= 25
vol_ok = current_volume >= avg_volume_20d * 2 # 거래량 20일평균 200% 이상
if rsi_ok or vol_ok:
bb_low_minus = bb_lower * 0.97 # -3%
bb_low_plus = bb_lower * 1.015 # +1.5%
if bb_low_minus <= close <= bb_low_plus:
trigger = f"RSI({rsi:.1f})<=25" if rsi_ok else f"거래량({current_volume / avg_volume_20d * 100:.0f}%>=200%)"
return {
"signal": "BUY",
"strategy": "MEAN_REVERSION_BULL",
"split_plan": [0.50, 0.50], # 2회 분할 매수
"description": f"{trigger} + BB 하단 -3%~+1.5% (상승장 과매도)",
}
elif regime == MarketRegime.BEAR:
if rsi <= 35: # 40 → 35: 더 낮은 가격에서 진입
# 하락장 진입 필터: 거래량 전일 대비 증가 확인 (매수세 유입)
if close <= bb_lower and is_reversal and current_volume > prev_volume:
return {
"signal": "BUY",
"strategy": "MEAN_REVERSION_BEAR",
"split_plan": [0.30, 0.30, 0.40], # 3회 분할 매수
"description": f"RSI({rsi:.1f})<=35 + BB 하단 + 되돌림 + 거래량↑ (하락장 과매도)",
}
return None
def check_exit_signal(
regime: MarketRegime,
rsi: float,
close: float,
bb_upper: float,
bb_middle: float,
bb_lower: float,
entry_price: float,
atr: float,
lowest_since_entry: float,
remaining_pct: float,
) -> Optional[ExitSignal]:
"""섹션 7.3 + 7.4: 청산 조건
손절: min(최저가 - 2.5×ATR, 진입가 - 8%) 중 높은 값(타이트한 것)
1차 익절: Bull 30% (트렌드 팔로잉), Bear 50% (리스크 관리)
2차 익절(전량): RSI ≥ 60 진입 + 종가 ≥ BB 상단선 근처
"""
# 손절 체크 (섹션 7.4)
atr_stop = lowest_since_entry - 2.5 * atr
pct_stop = entry_price * 0.92 # -8%
stop_price = max(atr_stop, pct_stop) # 높은 값 = 타이트한 손절
if close <= stop_price:
return ExitSignal(
action="SELL_ALL",
reason=f"손절 (close={close:.0f} <= stop={stop_price:.0f}, ATR={atr:.0f})",
sell_pct=1.0,
)
# 1차 익절: 시장 상태별 비중 조정 (RSI 45~50 회복 + BB 중앙선 상회)
if regime == MarketRegime.BULL:
# 상승장: 30% 익절 (트렌드 팔로잉)
if remaining_pct > 0.7 and 45 <= rsi <= 50 and close > bb_middle:
return ExitSignal(
action="SELL_PARTIAL",
reason=f"1차 익절(Bull 30%): RSI({rsi:.1f}) 45-50 회복 + BB중앙({bb_middle:.0f}) 상회",
sell_pct=0.30,
)
else:
# 하락장: 50% 익절 (리스크 관리)
if remaining_pct > 0.5 and 45 <= rsi <= 50 and close > bb_middle:
return ExitSignal(
action="SELL_PARTIAL",
reason=f"1차 익절(Bear 50%): RSI({rsi:.1f}) 45-50 회복 + BB중앙({bb_middle:.0f}) 상회",
sell_pct=0.50,
)
# 2차 익절: 전량 매도 (RSI ≥ 60 진입 + BB 상단선 근처)
threshold = 0.7 if regime == MarketRegime.BULL else 0.5
if remaining_pct <= threshold and rsi >= 60:
if close >= bb_upper * 0.99:
return ExitSignal(
action="SELL_ALL",
reason=f"2차 익절: RSI({rsi:.1f})>=60 진입 + BB상단({bb_upper:.0f}) 도달",
sell_pct=1.0,
)
return None
def calculate_position_size(
total_assets: float,
available_cash: float,
current_strategy_allocation: float,
regime: MarketRegime,
max_per_position: float = 0.04,
max_strategy_capital_bull: float = 0.70,
max_strategy_capital_bear: float = 0.30,
) -> float:
"""섹션 6.1 + 7.5: 포지션 사이징 (시장 상태별 동적 배분)
- 1종목당 최대 3-4% (max_per_position)
- 상승장(Bull): 전략 전체 최대 70% 운용 (공격적)
- 하락장(Bear): 전략 전체 최대 30% 운용 (보수적)
"""
# 시장 상태에 따라 최대 자본 비중 결정
max_strategy_capital = max_strategy_capital_bull if regime == MarketRegime.BULL else max_strategy_capital_bear
max_by_position = total_assets * max_per_position
remaining_budget = (total_assets * max_strategy_capital) - current_strategy_allocation
return max(0, min(max_by_position, remaining_budget, available_cash))
def check_h1_breakout_with_ma5(
h1_closes: List[float],
h1_current_close: float,
) -> bool:
"""섹션 7.5: 60분봉 5일선 돌파 확인 (가짜 반등 필터)
60분봉 기준 5일(120시간 = 5일×24시간) 이동평균선 상향 돌파 여부 확인
실제로는 5일 분량의 60분봉 데이터가 필요: 5일 × 6.5시간(장 운영) = 약 30~40개 봉
Args:
h1_closes: 60분봉 종가 리스트 (최소 5개 이상)
h1_current_close: 현재 60분봉 종가
Returns:
True: 5일선 상향 돌파, False: 미돌파
Note:
- 60분봉 데이터가 충분하지 않으면 False 반환 (보수적)
- 복합 타임프레임 전략에서 선택적으로 사용
"""
if len(h1_closes) < 5:
return False # 데이터 부족 시 보수적 대응
ma5 = sum(h1_closes[-5:]) / 5
return h1_current_close > ma5