Files
auto-stock/backend/app/services/mean_reversion_strategy.py
shuim a477072bdc feat: 리스크 관리 강화 — 포트폴리오 락, MDD 단기전략 비활성화, 상관관계 필터
- portfolio.py: summary_lock으로 동시성 보호, apply_buy/apply_sell async화
- mdd_manager.py: MDD -10% 시 단기전략 비활성화 (is_short_term_strategy_paused)
- loss_tracker.py: DB 직접 접근 → db_worker 큐 기반 전환
- db_worker.py: upsert_stock_ban 태스크 추가
- correlation_filter.py: 섹터 집중도 + 피어슨 상관관계 검증 (CLAUDE.md 5.4)
- trade_executor.py: 상관관계 필터 통합, await 적용
- mean_reversion_strategy.py: config 값 타입 안전성 (float 변환)
- main.py/day_trading.py: MDD 체크 후 자동매매/데이트레이딩 차단

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:26:13 +09:00

482 lines
18 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% 이내에서만 운용.
[2026-02 수익 극대화 개선]
① R:R 개선: 손절 -8%→-6%, 1차 익절 RSI 45~50→55 이상, 트레일링 스탑 도입
② 진입 기회 확대: BULL RSI ≤25 → ≤30 (2배 증가)
③ 동적 포지션 사이징: RSI/ADX 기반 4~8% 다이나믹 비중
[2026-03 손실 원인 수정]
① check_buy_signal: 거래량 OR 조건 제거 (모멘텀 진입이 평균회귀 청산과 충돌)
② check_buy_signal: RSI 반등 확인 파라미터 추가 (낙하 중 진입 차단)
③ check_exit_signal: 1차 익절 RSI 상한 제거 (55 이상이면 모두 1차 익절 허용)
④ check_exit_signal: ATR 손절을 lowest_since_entry → entry_price 기준으로 변경
[.env 설정]
MEAN_REVERSION_RSI_THRESHOLD
MEAN_REVERSION_MULTIPLIER_STRONG
MEAN_REVERSION_MULTIPLIER_NORMAL
MEAN_REVERSION_MULTIPLIER_WEAK
"""
import logging
from typing import Optional, Dict, List, Tuple
from dataclasses import dataclass
from enum import Enum
from app.services.strategy_config_service import strategy_cfg
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,
rsi_prev: Optional[float] = None, # [2026-03] RSI 반등 확인용 — 3봉 전 RSI
) -> Optional[Dict]:
"""섹션 7.2: 매수 조건 판별
[2026-03 수정]
- BULL: 거래량 OR 조건 제거 → RSI ≤ 30 AND BB하단 AND 거래량 ≥ 평균 100%로 단순화
(기존 `vol_ok AND rsi<=50` 조건은 모멘텀 진입이므로 평균회귀 청산 로직과 충돌)
- RSI 반등 확인: rsi_prev 제공 시, RSI가 반등 중일 때만 진입 허용
(낙하하는 칼 잡기 방지 — short_term_strategy와 동일 로직)
Bull: RSI(14) ≤ 30 (과매도), 종가가 BB 하단선 -3% ~ +1.5% 구간, 거래량 ≥ 20일평균
Bear: RSI(14) ≤ 35 (과매도), 종가 ≤ BB 하단선, 되돌림 캔들 출현, 전일 대비 거래량 증가
"""
if current_volume < avg_volume_20d:
return None
if regime == MarketRegime.BULL:
rsi_ok = rsi <= float(strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD") or 30.0)
if rsi_ok:
# [2026-03] RSI 반등 확인: rsi_prev 제공 시 RSI 하락 중이면 진입 금지
if rsi_prev is not None and rsi <= rsi_prev:
logger.debug(
"⏭️ RSI 반등 미확인 — 진입 차단: rsi=%.1f, rsi_prev=%.1f (하락 중)",
rsi, rsi_prev,
)
return None
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:
reversal_info = ""
if rsi_prev is not None:
reversal_info = f" RSI반등({rsi_prev:.1f}→{rsi:.1f})"
return {
"signal": "BUY",
"strategy": "MEAN_REVERSION_BULL",
"split_plan": [0.50, 0.50], # 2회 분할 매수
"description": f"RSI({rsi:.1f})<=30 + BB 하단 -3%~+1.5% (상승장 과매도){reversal_info}",
}
elif regime == MarketRegime.BEAR:
if rsi <= 35:
# [2026-03] 하락장도 RSI 반등 확인 적용
if rsi_prev is not None and rsi <= rsi_prev:
logger.debug(
"⏭️ RSI 반등 미확인(Bear) — 진입 차단: rsi=%.1f, rsi_prev=%.1f",
rsi, rsi_prev,
)
return None
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,
trailing_high: Optional[float] = None, # 진입 후 최고가 (트레일링 스탑용) — 기존 호출부 호환 유지
) -> Optional[ExitSignal]:
"""섹션 7.3 + 7.4: 청산 조건 (R:R 약 2:1 목표)
[2026-03 수정]
① ATR 손절 기준: lowest_since_entry → entry_price 기준으로 변경
- 기존: 저점 기준이라 매수 직후 손절이 너무 느슨함
- 개선: 진입가 기준으로 손절 즉시 발동 가능
② 1차 익절 RSI 상한 제거: `55 <= rsi <= 65` → `rsi >= 55`
- 빠른 반등 시 65를 바로 뚫어도 1차 익절 캐치 가능
시나리오:
1. 손절 체크 (최우선)
2. 1차 익절 (remaining_pct > threshold)
└─ RSI ≥ 55 회복 + BB중앙선 상회: Bull 30% / Bear 50% 매도
3. 2차 익절 (1차 완료 후)
├─ 트레일링 스탑: 최고가 대비 -3% 하락 시 잔여 전량 청산
└─ BB 상단 도달 시 50% 추가 익절
"""
# ── 1. 손절 (최우선) ──────────────────────────────────────────
# [2026-03] ATR 손절 기준: lowest_since_entry → entry_price
# 이유: 매수 직후 저점이 낮으면 ATR 손절가가 현재가보다 훨씬 아래 → 손절 사실상 무력화
atr_stop_mult = float(strategy_cfg.get("ATR_STOP_MULTIPLIER") or 2.5)
stop_loss_pct = float(strategy_cfg.get("STOP_LOSS_PERCENT") or -6.0) # e.g. -6.0
atr_stop = entry_price - atr_stop_mult * atr
pct_stop = entry_price * (1 + stop_loss_pct / 100)
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_stop:.0f}, -6%기준={pct_stop:.0f})",
sell_pct=1.0,
)
# 시장 상태별 1차 익절 임계값
# Bull: 1차 30% 후 remaining=0.7, Bear: 1차 50% 후 remaining=0.5
threshold = 0.7 if regime == MarketRegime.BULL else 0.5
# ── 2. 1차 익절: 아직 1차 안 했을 때 ──────────────────────────
# 최소 1×ATR 수익 조건 — 수수료+슬리피지(~0.35%) 이하 수익에서 조기 청산 방지
min_profit_price = entry_price + 1.0 * atr
if remaining_pct > threshold:
# [2026-03] RSI 상한 제거: `55 <= rsi <= 65` → `rsi >= 55`
# 이유: 빠른 반등 시 RSI가 65를 바로 뚫으면 1차 익절 기회를 놓쳤음
if rsi >= 55 and close > bb_middle and close >= min_profit_price:
if regime == MarketRegime.BULL:
return ExitSignal(
action="SELL_PARTIAL",
reason=f"1차 익절(Bull 30%): RSI({rsi:.1f})>=55 + BB중앙({bb_middle:.0f}) 상회 + 1×ATR({atr:.0f}) 수익",
sell_pct=0.30,
)
else:
return ExitSignal(
action="SELL_PARTIAL",
reason=f"1차 익절(Bear 50%): RSI({rsi:.1f})>=55 + BB중앙({bb_middle:.0f}) 상회 + 1×ATR({atr:.0f}) 수익",
sell_pct=0.50,
)
# ── 3. 2차 익절: 1차 완료 후 ─────────────────────────────────────
if remaining_pct <= threshold:
# 3a. 트레일링 스탑: 최고가 대비 -3% 하락 시 잔여 전량 청산
if trailing_high is not None and trailing_high > entry_price:
trail_stop = trailing_high * 0.97
if close < trail_stop:
return ExitSignal(
action="SELL_ALL",
reason=(
f"트레일링 청산: close={close:.0f} < 최고가×0.97={trail_stop:.0f} "
f"(최고가={trailing_high:.0f})"
),
sell_pct=1.0,
)
# 3b. BB 상단 도달 시 50% 추가 익절 (나머지는 트레일링으로)
if rsi >= 65 and close >= bb_upper * 0.99 and remaining_pct > 0.25:
return ExitSignal(
action="SELL_PARTIAL",
reason=f"2차 익절(50%): RSI({rsi:.1f})>=65 + BB상단({bb_upper:.0f}) 도달",
sell_pct=0.50,
)
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,
rsi: Optional[float] = None,
adx: Optional[float] = None,
) -> float:
"""섹션 6.1 + 7.5: 동적 포지션 사이징 (신호 강도 기반)
신호 강도별 비중:
- RSI ≤ 20 (극도 과매도): 1.5× = 6%
- RSI ≤ 25 (강한 신호): 1.25× = 5%
- RSI ≤ 30 (표준 신호): 1.0× = 4% (default)
- ADX < 15 (강한 횡보장): +0.25× 보너스 (최대 2.0× = 8%)
시장별 전략 자본 한도:
- 상승장(Bull): 전략 전체 최대 70% 운용
- 하락장(Bear): 전략 전체 최대 30% 운용
"""
max_strategy_capital = max_strategy_capital_bull if regime == MarketRegime.BULL else max_strategy_capital_bear
if rsi is not None:
if rsi <= 20:
multiplier = float(strategy_cfg.get("MEAN_REVERSION_MULTIPLIER_STRONG") or 1.50)
elif rsi <= 25:
multiplier = float(strategy_cfg.get("MEAN_REVERSION_MULTIPLIER_NORMAL") or 1.25)
else:
multiplier = float(strategy_cfg.get("MEAN_REVERSION_MULTIPLIER_WEAK") or 1.00)
else:
multiplier = float(strategy_cfg.get("MEAN_REVERSION_MULTIPLIER_WEAK") or 1.00)
if adx is not None and adx < 15:
multiplier = min(multiplier + 0.25, 2.0)
max_by_position = total_assets * max_per_position * multiplier
remaining_budget = (total_assets * max_strategy_capital) - current_strategy_allocation
result = max(0.0, min(max_by_position, remaining_budget, available_cash))
if rsi is not None or adx is not None:
logger.debug(
"position_size: total=%.0f, multiplier=%.2f×, rsi=%s, adx=%s → %.0f원",
total_assets, multiplier,
f"{rsi:.1f}" if rsi is not None else "N/A",
f"{adx:.1f}" if adx is not None else "N/A",
result,
)
return result
def calculate_position_size_v2(
total_assets: float,
available_cash: float,
current_strategy_allocation: float,
regime: MarketRegime,
rsi: Optional[float] = None,
adx: Optional[float] = None,
max_per_position: float = 0.04,
max_strategy_capital_bull: float = 0.70,
max_strategy_capital_bear: float = 0.30,
) -> float:
"""CLAUDE.md 섹션 6.1 + RSI/ADX 동적 포지션 사이징
calculate_position_size 기반으로 RSI·ADX 보정 추가:
- RSI ≤ 20: ×1.2 (깊은 과매도 = 더 유리한 진입)
- RSI ≤ 25: ×1.1
- ADX > 30: ×0.8 (강추세 중 평균회귀 위험 = 포지션 축소)
보정 후에도 종목당 4% 상한 재적용 (CLAUDE.md 절대 룰)
"""
base = calculate_position_size(
total_assets, available_cash, current_strategy_allocation,
regime, max_per_position, max_strategy_capital_bull, max_strategy_capital_bear,
rsi=None, adx=None,
)
if base <= 0:
return 0.0
mult = 1.0
if rsi is not None:
if rsi <= 20:
mult *= 1.2
elif rsi <= 25:
mult *= 1.1
if adx is not None and adx > 30:
mult *= 0.8
max_allowed = total_assets * max_per_position
return min(base * mult, max_allowed, available_cash)
def check_h1_breakout_with_ma5(
h1_closes: List[float],
h1_current_close: float,
) -> bool:
"""섹션 7.5: 60분봉 5일선 돌파 확인 (가짜 반등 필터)
Args:
h1_closes: 60분봉 종가 리스트 (최소 5개 이상)
h1_current_close: 현재 60분봉 종가
Returns:
True: 5일선 상향 돌파, False: 미돌파
"""
if len(h1_closes) < 5:
return False
ma5 = sum(h1_closes[-5:]) / 5
return h1_current_close > ma5