Files
auto-stock/backend/app/services/mean_reversion_strategy.py
shuim 5f0f839039 전략 취약점 15건 수정 (코드 리뷰 기반)
## CRITICAL/HIGH (S1-S8)
- S1: Aggressive 모드 %B 스케일링 캡 1.3 (BB 중앙선 위 매수 방지)
- S2: ML scaler train-only fit (데이터 누수 제거)
- S3: SIDEWAYS = ADX<20 AND slope≈0 (트렌드 초기 차단 해소)
- S4: EOD 청산 실패 시 재시도 + CRITICAL 로그
- S5: 일일 손실 한도에 미실현 P&L 포함
- S6: trailing stop 고점 매 사이클 갱신
- S7: 섹터 필터 미매핑 종목 "기타" 분류 (max 3 적용)
- S8: 스캐너 RSI 기준 25→46, BB 점수 -3%~+1.5% 구간

## MEDIUM (M1-M7)
- M1: 스캐너 최소 거래량 100만→10만주
- M2: ADX 히스테리시스 18/22 (국면 플리핑 방지)
- M3: 분할매도 최소 수익 1.0%→0.5%
- M4: ORB 갭 필터 4% + 레인지 폭 0.3% 최소
- M5: 쿨다운 스트릭 부분매도 시 미변경
- M6: MDD 강제매도 loss_tracker 기록 제거 (허위 ban 방지)
- M7: RSI Wilder 스무딩으로 통일

## 인프라/보안
- 인증 누락 엔드포인트 수정 (analysis, journals)
- nginx 보안 헤더 location 블록 누락 수정
- Alembic 마이그레이션 IF NOT EXISTS 가드 추가
- SellRequest/UsOrderRequest 입력 검증 강화
- 차트 네이버 폴백 추가 (DB 데이터 없을 때)
- 서버 이전 시 자동 DB 마이그레이션 + KRX 초기 수집
- CLAUDE.md 섹션 5.1 백테스트 값 동기화 (4%→6%, -6%→-7%)
- MAX_SWING_POSITIONS 25→20 (CLAUDE.md 준수)
- mdd_manager failed_count에서 skip 종목 제외

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:44:14 +09:00

779 lines
32 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 기준으로 변경
[2026-03-15 CLAUDE.md 정합성 수정]
① detect_market_regime: ADX < 20 → SIDEWAYS 판별 추가 (CLAUDE.md 6.1)
② check_buy_signal: SIDEWAYS 국면 진입 차단 (CLAUDE.md 6.1)
③ check_buy_signal: BULL RSI ≤30→<40, BEAR RSI ≤35→<30 (CLAUDE.md 6.2)
④ check_buy_signal: BEAR 거래량 prev_volume→1.5×avg_volume_20d (CLAUDE.md 6.2)
⑤ check_exit_signal: 1차 익절 AND→OR, RSI 60→50, ATR 1.5→2.0 (CLAUDE.md 6.3)
⑥ check_exit_signal: 분할매도 Bull 25%/Bear 40%→50% 통일 (CLAUDE.md 5.2)
⑦ check_exit_signal: 트레일링 high×0.96→high-1.5×ATR (CLAUDE.md 5.2)
[.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__)
# M2: ADX hysteresis state — 직전 regime을 기억하여 ADX 경계선에서 잦은 전환 방지
_previous_regime: Optional["MarketRegime"] = None
class MarketRegime(Enum):
BULL = "bull"
BEAR = "bear"
SIDEWAYS = "sideways" # CLAUDE.md 6.1: ADX < 20 OR 기울기 ≈ 0 → 진입 불가
@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 평활: 초기값은 mean(DX[:period]) — ATR/DM과 달리 합산이 아닌 평균
# _wilder_smooth()는 초기값이 sum이므로 ADX에는 인라인 계산 사용
adx_val = sum(dxs[:period]) / period # 초기값 = 평균
for dx_v in dxs[period:]:
adx_val = adx_val - adx_val / period + dx_v
return round(adx_val, 2)
def detect_market_regime(index_closes: List[float],
highs: Optional[List[float]] = None,
lows: Optional[List[float]] = None) -> MarketRegime:
"""섹션 7.1: 200일 MA + 기울기 + ADX로 시장 국면 판별
CLAUDE.md 6.1:
상승장(Bull): 지수 > MA200 AND MA200 기울기 양(+)
하락장(Bear): 지수 < MA200 AND MA200 기울기 음(-)
횡보장(Sideways): ADX < 20 OR 기울기 ≈ 0 → 모든 진입 신호 무시
highs/lows 미제공 시 기존 동작(BULL/BEAR만)과 동일 — 기존 호출부 호환 유지.
"""
from app.core.config import settings
if len(index_closes) < settings.MEAN_REVERSION_MA200_LOOKBACK:
return MarketRegime.BEAR # 데이터 부족 시 보수적
# CLAUDE.md 6.1: ADX < 20 → SIDEWAYS (진입 차단)
# S3 fix: ADX 값은 먼저 계산하되, slope도 함께 확인 후 판정
_adx_value = None
if highs is not None and lows is not None:
_adx_value = calculate_adx(highs, lows, index_closes)
ma200_current = sum(index_closes[-settings.MEAN_REVERSION_MA200_PERIOD:]) / settings.MEAN_REVERSION_MA200_PERIOD
# [#10] 기울기 비교 기간을 5일→20일로 확대하여 SIDEWAYS 과다판정 방지
_slope_lookback = 20
_slope_offset = settings.MEAN_REVERSION_MA200_PERIOD + _slope_lookback
if len(index_closes) >= _slope_offset:
ma200_prev = sum(index_closes[-_slope_offset:-_slope_lookback]) / settings.MEAN_REVERSION_MA200_PERIOD
else:
# 데이터 부족 시 기존 5일 방식 폴백
ma200_prev = sum(index_closes[-(settings.MEAN_REVERSION_MA200_LOOKBACK):-5]) / settings.MEAN_REVERSION_MA200_PERIOD
slope_positive = ma200_current > ma200_prev
# CLAUDE.md 6.1: 기울기 ≈ 0 → SIDEWAYS
slope_ratio = abs(ma200_current - ma200_prev) / ma200_prev if ma200_prev > 0 else 0
# S3 fix: SIDEWAYS 판정은 ADX < threshold AND slope ≈ 0 모두 충족 시에만.
# ADX < threshold이더라도 slope가 명확하면 BULL/BEAR로 분류한다.
# M2: ADX hysteresis — enter SIDEWAYS at ADX < 18, exit SIDEWAYS at ADX > 22
global _previous_regime
_ADX_ENTER_SIDEWAYS = settings.MEAN_REVERSION_ADX_SIDEWAYS - 2.0 # 18
_ADX_EXIT_SIDEWAYS = settings.MEAN_REVERSION_ADX_SIDEWAYS + 2.0 # 22
if _adx_value is not None:
_was_sideways = (_previous_regime == MarketRegime.SIDEWAYS)
# hysteresis: 이미 SIDEWAYS면 ADX > 22 돌파해야 탈출, 아니면 ADX < 18 이하여야 진입
_adx_triggers_sideways = (
(_was_sideways and _adx_value < _ADX_EXIT_SIDEWAYS) or
(not _was_sideways and _adx_value < _ADX_ENTER_SIDEWAYS)
)
if _adx_triggers_sideways:
if slope_ratio < settings.MEAN_REVERSION_SLOPE_THRESHOLD:
logger.info("시장 국면: SIDEWAYS (ADX=%.1f, hysteresis=[enter<%.0f,exit>%.0f] AND slope≈0, ratio=%.5f) — CLAUDE.md 6.1",
_adx_value, _ADX_ENTER_SIDEWAYS, _ADX_EXIT_SIDEWAYS, slope_ratio)
_previous_regime = MarketRegime.SIDEWAYS
return MarketRegime.SIDEWAYS
else:
logger.info("시장 국면: ADX=%.1f이나 slope 명확(ratio=%.5f) — BULL/BEAR로 분류", _adx_value, slope_ratio)
if slope_ratio < settings.MEAN_REVERSION_SLOPE_THRESHOLD:
logger.info("시장 국면: SIDEWAYS (MA200 기울기 ≈ 0, ratio=%.5f) — CLAUDE.md 6.1", slope_ratio)
_previous_regime = MarketRegime.SIDEWAYS
return MarketRegime.SIDEWAYS
is_bull = index_closes[-1] > ma200_current and slope_positive
result = MarketRegime.BULL if is_bull else MarketRegime.BEAR
logger.info("시장 국면: %s (MA200 slope_ratio=%.5f, 20일 기준)", result.value.upper(), slope_ratio)
_previous_regime = result
return result
def calculate_rsi(closes: List[float], period: int = 14) -> Optional[float]:
"""RSI(Relative Strength Index) 계산 — Wilder 평활법
feature_engineering.compute_rsi()와 동일한 Wilder smoothing 사용:
- 첫 period개: 단순 평균(seed)
- 이후: avg = (prev_avg * (period-1) + current) / period
"""
if len(closes) < period + 1:
return None
# 모든 price diff 계산
diffs = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
# Seed: 첫 period개의 단순 평균
avg_gain = sum(max(d, 0) for d in diffs[:period]) / period
avg_loss = sum(max(-d, 0) for d in diffs[:period]) / period
# Wilder smoothing: 나머지 구간
for i in range(period, len(diffs)):
avg_gain = (avg_gain * (period - 1) + max(diffs[i], 0)) / period
avg_loss = (avg_loss * (period - 1) + max(-diffs[i], 0)) / 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]:
"""CLAUDE.md 6.2: 매수 조건 판별
[2026-03-15 CLAUDE.md 정합성 수정]
- SIDEWAYS 국면: 진입 차단 (CLAUDE.md 6.1)
- BULL: RSI < 40, BB하단 -3%~+1.5%, 거래량 >= 20일평균
- BEAR: RSI < 30, 종가 <= BB하단, 되돌림 캔들, 거래량 > 1.5×20일평균
- RSI 반등 확인: rsi_prev 제공 시, RSI가 반등 중일 때만 진입 허용
"""
# CLAUDE.md 6.1: 횡보장(SIDEWAYS)에서는 모든 진입 신호를 무시
if regime == MarketRegime.SIDEWAYS:
logger.debug("⏭️ SIDEWAYS 국면 — 진입 차단 (CLAUDE.md 6.1)")
return None
if current_volume < avg_volume_20d:
return None
if regime == MarketRegime.BULL:
# CLAUDE.md 6.2: BULL 진입 RSI < 40
# 적응형 전략 오버라이드: adaptive.rsi_entry (실패 시 strategy_cfg / 40.0 fallback)
_rsi_threshold_val = strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD")
_base_rsi_threshold = float(_rsi_threshold_val) if _rsi_threshold_val is not None else 40.0
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp = get_current_adaptive_params()
if not _adp.fallback_used:
_base_rsi_threshold = _adp.rsi_entry
except Exception:
pass
rsi_ok = rsi < _base_rsi_threshold
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
# %B 기반 동적 BB 조건 (RSI 깊이에 따라 임계값 조정)
# 적응형 전략: bb_pct_b 오버라이드로 기본 pct_b_max 스케일 조정
_adaptive_bb_scale = 1.0 # 기본 스케일
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_bb = get_current_adaptive_params()
if not _adp_bb.fallback_used:
# Normal 기준 0.30 대비 비율로 스케일링
_adaptive_bb_scale = _adp_bb.bb_pct_b / 0.30 if _adp_bb.bb_pct_b > 0 else 1.0
# S1 fix: BB 중심선(pct_b=0.5) 위 진입 방지 — 스케일 상한 1.3
_adaptive_bb_scale = min(_adaptive_bb_scale, 1.3)
except Exception:
pass
bb_width = bb_upper - bb_lower
pct_b = (close - bb_lower) / bb_width if bb_width > 0 else 0.5
if rsi < 25:
pct_b_max = 0.35 * _adaptive_bb_scale # 깊은 과매도 → BB 조건 완화
elif rsi < 30:
pct_b_max = 0.30 * _adaptive_bb_scale
else:
pct_b_max = 0.20 * _adaptive_bb_scale # RSI 30~40 → BB 하단 근처 요구
if pct_b <= pct_b_max:
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})<40 + %%B({pct_b:.2f})<{pct_b_max} (상승장 과매도){reversal_info}",
}
elif regime == MarketRegime.BEAR:
# CLAUDE.md 6.2: BEAR 진입 RSI < 30
# 적응형 전략 오버라이드: adaptive.bear_rsi_entry (실패 시 기존 fallback)
from app.core.config import settings
_bear_rsi_threshold = strategy_cfg.get("MEAN_REVERSION_BEAR_RSI_THRESHOLD")
bear_rsi_threshold = float(_bear_rsi_threshold) if _bear_rsi_threshold is not None else settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_bear = get_current_adaptive_params()
if not _adp_bear.fallback_used:
bear_rsi_threshold = _adp_bear.bear_rsi_entry
except Exception:
pass
if rsi < bear_rsi_threshold:
# [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
# CLAUDE.md 6.2: BEAR 거래량 > 1.5 * 20일 평균 + %B 동적 임계값
bb_width = bb_upper - bb_lower
pct_b = (close - bb_lower) / bb_width if bb_width > 0 else 0.5
if rsi < settings.MEAN_REVERSION_RSI_STRONG_THRESHOLD:
pct_b_max = 0.30
elif rsi < settings.MEAN_REVERSION_RSI_NORMAL_THRESHOLD:
pct_b_max = 0.25
else:
pct_b_max = 0.20
_bear_vol_mult = strategy_cfg.get("MEAN_REVERSION_BEAR_VOLUME_MULT")
bear_vol_mult = float(_bear_vol_mult) if _bear_vol_mult is not None else settings.MEAN_REVERSION_BEAR_VOLUME_MULT
if pct_b <= pct_b_max and is_reversal and current_volume > bear_vol_mult * avg_volume_20d:
return {
"signal": "BUY",
"strategy": "MEAN_REVERSION_BEAR",
"split_plan": [0.30, 0.30, 0.40], # 3회 분할 매수
"description": f"RSI({rsi:.1f})<30 + %%B({pct_b:.2f})<{pct_b_max} + 되돌림 + 거래량>1.5×20MA (하락장 과매도)",
}
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]:
"""CLAUDE.md 6.3 + 5.2: 청산 조건
[2026-03-15 CLAUDE.md 정합성 수정]
- 1차 익절(부분매도): RSI >= 50 OR 가격 > BB 중앙선 OR 수익 > 2*ATR (OR 로직)
- 분할매도 비율: 50% (Bull/Bear 동일, CLAUDE.md 5.2)
- 트레일링 스탑: 고점 - 1.5 * ATR (CLAUDE.md 5.2)
시나리오:
1. 손절 체크 (최우선)
2. 1차 익절 (remaining_pct > 0.50)
└─ RSI >= 50 OR BB중앙선 상회 OR 2×ATR 수익: 50% 매도
3. 2차 익절 (1차 완료 후)
├─ 트레일링 스탑: 고점 - 1.5×ATR 하회 시 잔여 전량 청산
└─ BB 상단 도달 시 50% 추가 익절
"""
# ── 1. 손절 (최우선) ──────────────────────────────────────────
from app.core.config import settings
# [2026-03] ATR 손절 기준: lowest_since_entry → entry_price
# 이유: 매수 직후 저점이 낮으면 ATR 손절가가 현재가보다 훨씬 아래 → 손절 사실상 무력화
# [이슈10 수정] or 패턴 → None 명시 체크
_atr_stop_val = strategy_cfg.get("ATR_STOP_MULTIPLIER")
atr_stop_mult = float(_atr_stop_val) if _atr_stop_val is not None else 2.5
# CLAUDE.md 5.2: 안정적 종목 -6%, 변동성 종목 -10%
# 변동성 판별: ATR / 현재가 > 3% → 변동성 종목
_atr_pct = (atr / close * 100) if close > 0 else 0
# [이슈10 수정] or 패턴 → None 명시 체크
if _atr_pct > 3.0:
_sl_high_val = strategy_cfg.get("STOP_LOSS_HIGH_VOL")
_raw_sl = float(_sl_high_val) if _sl_high_val is not None else -10.0
else:
_sl_val = strategy_cfg.get("STOP_LOSS_PERCENT")
_raw_sl = float(_sl_val) if _sl_val is not None else -settings.STOP_LOSS_PERCENT # config.py 기본값(7.0) 참조
# 적응형 전략 오버라이드: stop_loss / atr_stop_multiplier
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_exit = get_current_adaptive_params()
if not _adp_exit.fallback_used:
atr_stop_mult = _adp_exit.atr_stop_multiplier
if _atr_pct > 3.0:
_raw_sl = _adp_exit.stop_loss_high_vol
else:
_raw_sl = _adp_exit.stop_loss_pct
except Exception:
pass
stop_loss_pct = -abs(_raw_sl) # 항상 음수 보장 (양수 입력 시 전종목 매도 방지)
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,
)
# CLAUDE.md 5.2: +2R 도달 시 50% 분할 매도 — Bull/Bear 동일
threshold = 0.50 # 1차 익절 후 remaining=0.50
# ── 2. 1차 익절: 아직 1차 안 했을 때 ──────────────────────────
# CLAUDE.md 6.3: RSI >= 50 OR 가격 > BB 중앙선 OR 수익 > 2*ATR (OR 로직)
# 적응형 전략: 1차 익절 RSI / 전량 청산 RSI 오버라이드
_adaptive_rsi_partial = settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD
_adaptive_rsi_full = settings.MEAN_REVERSION_RSI_FULL_EXIT
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_exit2 = get_current_adaptive_params()
if not _adp_exit2.fallback_used:
_adaptive_rsi_partial = _adp_exit2.rsi_partial_exit
_adaptive_rsi_full = _adp_exit2.rsi_full_exit
except Exception:
pass
min_profit_price = entry_price + 2.0 * atr # CLAUDE.md 6.3: 2×ATR
_profit_pct = ((close - entry_price) / entry_price * 100) if entry_price > 0 else 0
# 최소 수익률 가드: 수수료(~0.4%)를 커버하지 못하면 익절하지 않음
_MIN_PROFIT_FOR_EXIT = 0.5 # 최소 0.5% 수익이어야 익절 (수수료+세금 ~0.3-0.4% 커버)
if remaining_pct > threshold and _profit_pct >= _MIN_PROFIT_FOR_EXIT:
rsi_exit = rsi >= _adaptive_rsi_partial
bb_exit = close > bb_middle
atr_exit = close >= min_profit_price
if rsi_exit or bb_exit or atr_exit:
reasons = []
if rsi_exit:
reasons.append(f"RSI({rsi:.1f})>={_adaptive_rsi_partial:.0f}")
if bb_exit:
reasons.append(f"BB중앙({bb_middle:.0f})상회")
if atr_exit:
reasons.append(f"2×ATR({atr:.0f})수익")
# CLAUDE.md 5.2: 50% 분할 매도 — 단, 소량(3주 이하)이면 전량매도
# 1주를 50% 매도하면 0주 → 의미 없는 분할 방지
_is_small_position = (remaining_pct * 10) <= 3 # 대략 3주 이하 추정
if _is_small_position:
return ExitSignal(
action="SELL_ALL",
reason=f"소량 전량매도: {' OR '.join(reasons)} (분할 비효율)",
sell_pct=1.0,
)
return ExitSignal(
action="SELL_PARTIAL",
reason=f"1차 익절(50%): {' OR '.join(reasons)}",
sell_pct=0.50,
)
# ── 3. 전량매도 조건 (1차 익절 여부 무관) ──────────────────────────
# CLAUDE.md 6.3: RSI >= 65 OR 가격 > BB 상단(99%) OR 트레일링스탑 도달
from app.core.config import settings
full_exit_reasons = []
if rsi >= _adaptive_rsi_full:
full_exit_reasons.append(f"RSI({rsi:.1f})>={_adaptive_rsi_full}")
if close >= bb_upper * settings.MEAN_REVERSION_BB_UPPER_THRESHOLD:
full_exit_reasons.append(f"BB상단({bb_upper:.0f})도달")
if trailing_high is not None and trailing_high > entry_price:
trail_stop = trailing_high - 1.5 * atr # CLAUDE.md 5.2: ATR 기반 트레일링
if close < trail_stop:
full_exit_reasons.append(
f"트레일링(close={close:.0f}<{trail_stop:.0f})"
)
if full_exit_reasons:
return ExitSignal(
action="SELL_ALL",
reason=f"전량청산: {' OR '.join(full_exit_reasons)}",
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,
rsi: Optional[float] = None,
adx: Optional[float] = None,
) -> float:
"""섹션 6.1 + 5.1: 동적 포지션 사이징 (신호 강도 기반)
[2026-03-15 CLAUDE.md 정합성 수정]
CLAUDE.md 5.1: 종목당 최대 총자본의 3~4%. multiplier는 전략 자본 예산
배분 내에서의 우선순위 조정에만 사용하며, 종목당 절대 상한(4%)은 초과 불가.
시장별 전략 자본 한도:
- 상승장(Bull): 전략 전체 최대 70% 운용
- 하락장(Bear): 전략 전체 최대 30% 운용
"""
max_strategy_capital = max_strategy_capital_bull if regime == MarketRegime.BULL else max_strategy_capital_bear
# [이슈10 수정] or 패턴 → None 명시 체크 (0.0 입력 시 기본값 오염 방지)
def _cfg_float(key: str, default: float) -> float:
val = strategy_cfg.get(key)
return float(val) if val is not None else default
from app.core.config import settings
# [2026-03-15 CLAUDE.md 정합성 수정] CLAUDE.md 5.1: 종목당 최대 3~4%
# multiplier를 전략 자본 내 비중 조정에만 사용, 절대 상한은 4% 유지
max_by_position = total_assets * max_per_position # 절대 상한 4%
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, rsi=%s, adx=%s → %.0f원",
total_assets,
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
from app.core.config import settings
mult = 1.0
if rsi is not None:
if rsi <= settings.MEAN_REVERSION_RSI_STRONG_THRESHOLD:
mult *= 1.2
elif rsi <= settings.MEAN_REVERSION_RSI_NORMAL_THRESHOLD:
mult *= 1.1
if adx is not None and adx > settings.MEAN_REVERSION_ADX_STRONG_REDUCE:
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
# ═══════════════════════════════════════════════════════════════════════
# 듀얼 전략 추가: 모멘텀 돌파 + 눌림목 매수 (백테스트 검증 완료)
# ═══════════════════════════════════════════════════════════════════════
def check_momentum_breakout_signal(
closes: list[float],
highs: list[float],
volumes: list[int],
current_close: float,
current_volume: int,
rsi: float,
) -> Optional[dict]:
"""모멘텀 돌파 시그널: 20일 신고가 돌파 + 거래량 급증 + RSI 50~70.
BULL 시장에서만 사용. 추세를 타는 종목을 포착.
백테스트 결과: 펄어비스 +198만원(17승0패), 포스코인터 +170만원(12승1패)
"""
if len(highs) < 20 or len(volumes) < 20:
return None
high_20d = max(highs[-20:])
avg_vol_20d = sum(volumes[-20:]) / 20 if volumes[-20:] else 0
if avg_vol_20d <= 0:
return None
vol_ratio = current_volume / avg_vol_20d
# 20일 고점 돌파 + 거래량 2배 + RSI 50~70 (강한 구간, 과매수 아님)
if current_close > high_20d and vol_ratio >= 2.0 and 50 < rsi < 70:
confidence = min(1.0, 0.6 + (vol_ratio - 2) * 0.05)
return {
"signal": "BUY",
"strategy": "MOMENTUM_BREAKOUT",
"confidence": confidence,
"description": (
f"모멘텀 돌파: 20일고점({high_20d:.0f}) 돌파 "
f"vol({vol_ratio:.1f}x) RSI({rsi:.1f})"
),
}
return None
def check_pullback_buy_signal(
closes: list[float],
current_close: float,
current_volume: int,
rsi: float,
rsi_prev: Optional[float] = None,
volumes: list[int] | None = None,
) -> Optional[dict]:
"""눌림목 매수 시그널: 상승추세(MA20>MA60) 중 MA20 근처 조정 후 반등.
BULL 시장에서만 사용. 추세 지속 중 조정을 매수 기회로 활용.
"""
if len(closes) < 60:
return None
ma20 = sum(closes[-20:]) / 20
ma60 = sum(closes[-60:]) / 60
# 거래량 검증: 20일 평균 대비 1.2배 이상 (유동성 확인)
if volumes and len(volumes) >= 20 and current_volume > 0:
avg_vol = sum(volumes[-20:]) / 20
if avg_vol > 0 and current_volume < avg_vol * 1.2:
return None # 거래량 부족 → 유동성 트랩 방지
elif current_volume <= 0:
return None # 거래량 데이터 없으면 진입 불가
# 상승추세(MA20 > MA60) 확인
if ma20 <= ma60:
return None
# MA20 근처(±3%)까지 눌림
if not (ma20 * 0.97 < current_close < ma20 * 1.02):
return None
# RSI 40~55 (과매도에서 회복 중) + RSI 반등 확인
if not (40 < rsi < 55):
return None
if rsi_prev is not None and rsi <= rsi_prev:
return None
return {
"signal": "BUY",
"strategy": "PULLBACK_BUY",
"confidence": 0.55,
"description": f"눌림목: MA20({ma20:.0f}) 근처 반등 RSI({rsi:.1f})",
}
def check_time_stop_exit(
entry_date,
current_date,
profit_pct: float,
max_days: int = 20,
min_profit_pct: float = 3.0,
) -> Optional[dict]:
"""시간 손절: 일정 기간 보유해도 목표 수익률 미달 시 청산.
기회비용 관리 — 자금을 더 좋은 기회에 배분.
"""
if entry_date is None or current_date is None:
return None
holding_days = (current_date - entry_date).days
if holding_days >= max_days and profit_pct < min_profit_pct:
return {
"action": "SELL_ALL",
"sell_pct": 1.0,
"reason": f"시간손절 ({holding_days}일, {profit_pct:+.1f}% < +{min_profit_pct}%)",
}
return None