Files
auto-stock/backend/app/services/mean_reversion_strategy.py
shuim a45007cbb2 feat: TradingView 스타일 대시보드 + 차트 오버레이 API, Crisis 손절 소급 차단 플래그(기본 OFF)
- frontend: 대시보드를 TradingView 레이아웃으로 재구성(components/tv/*) — 캔들+MA/BB/거래량,
  평단·손절·목표·트레일링 가격선, 체결 마커, RSI 기준선은 실효 설정값, 하단 탭(보유·체결·DT 카드·
  비용 후 손익곡선/낙폭·저널), 디자인 토큰·한국식 색·최소 11px, 모바일 가로 스크롤 제거
- backend(GET 전용, require_api_key): /positions/overlay, /risk/equity-history,
  /strategy-config/display, /trades 응답 필드(side·strategy_type·commission·net_profit_loss),
  /risk/mdd-status thresholds, /market-data/chart 1m|5m(캐시·in-flight 공유)
- mean_reversion_strategy: RSI 청산 임계를 resolve_swing_exit_rsi_v2 로 추출(동작 동일) —
  청산과 화면 표시가 같은 값을 쓴다
- §8.5-39: SWING_STOP_IGNORE_CRISIS_OVERLAY(기본 False) — 반사실 재생에서 Crisis 조기 손절이
  오히려 손실을 줄여 기본 OFF 유지, 정식 백테스트 전 켜지 말 것
- 유닛 스위트 2816 passed / 0 failed, tsc·build 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:47:00 +09:00

1540 lines
73 KiB
Python
Raw Permalink 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/BEAR RSI 임계는 config·adaptive 로 결정 (CLAUDE.md §0/§6)
④ 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 Any, Callable, Dict, List, Optional, Tuple
from dataclasses import dataclass
from enum import Enum
from app.services.strategy_config_service import strategy_cfg
from app.core.log_throttle import warn_throttled
# §8.5-1 지표 계산 단일 출처 (순수 계산 모듈 — I/O 없음)
from app.services.indicators_core import (
adx_wilder,
atr_sma,
bollinger_sma,
rsi_wilder,
)
# §8.5-2 포지션 사이징 단일 출처 (순수 계산 모듈 — settings/I/O 없음)
from app.services.sizing_core import budget_by_ratios, clamp_by_ratios
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: 판정은 detect_market_regime() 참조 → 진입 불가
@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)
[2026-09-27 §8.5-1 중복 통합] 계산 본체는 indicators_core.atr_sma() 로 이동.
이 함수는 시그니처·반환형 호환을 위한 얇은 위임(wrapper)이며 수치는 동일하다.
"""
return atr_sma(highs, lows, closes, 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: 강한 추세 → 평균회귀 위험 (진입 차단)
[2026-09-27 §8.5-1 중복 통합] 계산 본체는 indicators_core.adx_wilder() 로 이동.
ADX 평활 초기값이 mean(DX[:period])이고 갱신식이 `a - a/p + dx/p` 인 점
(아래 [FIX] 주석의 발산 버그 수정분)까지 그대로 옮겼다. 수치 동일.
SIDEWAYS 판정 로직(detect_market_regime)은 변경하지 않았다.
# [FIX] Wilder ADX 평활: ADX = ADX_prev*(p-1)/p + DX/p
# = ADX_prev - ADX_prev/p + DX/p. 기존엔 마지막 항의 /period가 누락돼
# (+ dx_v) DX(0~100)를 그대로 더해 ADX가 100을 넘어 발산 → SIDEWAYS
# 게이트(ADX<20)가 실서비스에서 영구 무력화되던 버그. (CLAUDE.md 6.1)
"""
return adx_wilder(highs, lows, closes, period)
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): 모든 진입 신호 무시
[실제 동작 — 2026-10-02 H2 수정, 사용자 결정(거래 확대), 백테스트 미검증]
ADX 가 있으면 SIDEWAYS = ADX 히스테리시스(진입 ADX<MEAN_REVERSION_ADX_SIDEWAYS−2 /
해제 >+2) **AND** MA200 기울기 ≈ 0(slope_ratio < MEAN_REVERSION_SLOPE_THRESHOLD,
20일 비교). ADX 가 높으면 기울기가 평평해도 BULL/BEAR 로 분류한다.
이전에는 slope≈0 단독 분기가 무조건 SIDEWAYS 를 내 ADX 가 무시됐다.
highs/lows 미제공(ADX 계산 불가) 시에는 slope≈0 단독으로 SIDEWAYS 를 판정한다.
"""
global _previous_regime
result, _previous_regime = _detect_market_regime_impl(
index_closes, highs, lows, _previous_regime
)
return result
@dataclass
class RegimeHysteresisState:
"""detect_market_regime_v2 용 ADX 히스테리시스 상태 컨테이너.
[M-4 2026-10-02 3회차] 실전 경로(market_analyzer)는 모듈 전역 `_previous_regime` 을
쓴다. 배치·백테스트가 같은 전역을 덮어쓰면 실전 히스테리시스가 오염되므로
그쪽은 v2 로 자체 상태를 주입(또는 무상태)한다.
"""
previous: Optional["MarketRegime"] = None
def detect_market_regime_v2(index_closes: List[float],
highs: Optional[List[float]] = None,
lows: Optional[List[float]] = None,
state: Optional[RegimeHysteresisState] = None) -> MarketRegime:
"""detect_market_regime 과 같은 판정이되 모듈 전역 상태를 건드리지 않는다.
state 를 주면 그 객체의 previous 를 읽고 갱신한다(호출자 전용 히스테리시스).
None 이면 무상태(직전 국면 없음으로 간주, 아무것도 저장하지 않음).
"""
_prev = state.previous if state is not None else None
result, _new_prev = _detect_market_regime_impl(index_closes, highs, lows, _prev)
if state is not None:
state.previous = _new_prev
return result
def _detect_market_regime_impl(index_closes: List[float],
highs: Optional[List[float]],
lows: Optional[List[float]],
previous: Optional["MarketRegime"]):
"""국면 판정 본체 → (국면, 갱신된 히스테리시스 상태). 전역 상태 접근 없음."""
from app.core.config import settings
if len(index_closes) < settings.MEAN_REVERSION_MA200_LOOKBACK:
return MarketRegime.BEAR, previous # 데이터 부족 시 보수적 (히스테리시스 상태 불변)
# ADX 는 아래 히스테리시스 분기에서 slope≈0 과 AND 로 SIDEWAYS 를 판정한다(H2).
# 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
_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 == 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)
return MarketRegime.SIDEWAYS, MarketRegime.SIDEWAYS
else:
logger.info("시장 국면: ADX=%.1f이나 slope 명확(ratio=%.5f) — BULL/BEAR로 분류", _adx_value, slope_ratio)
# [H2 2026-10-02, 사용자 결정 — 거래 확대] slope≈0 단독 SIDEWAYS 는 ADX 를 계산할 수
# 없을 때(highs/lows 미제공)만 적용한다. ADX 가 있으면 위 히스테리시스 분기
# (ADX<18 AND slope≈0)만 SIDEWAYS 를 낸다 — CLAUDE.md §6.1 설계대로.
if _adx_value is None and slope_ratio < settings.MEAN_REVERSION_SLOPE_THRESHOLD:
logger.info("시장 국면: SIDEWAYS (ADX 없음, MA200 기울기 ≈ 0, ratio=%.5f) — CLAUDE.md 6.1", slope_ratio)
return MarketRegime.SIDEWAYS, 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)
return result, 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
[2026-09-27 §8.5-1 중복 통합] 계산 본체는 indicators_core.rsi_wilder() 로 이동.
이 함수는 시그니처·반환형 호환을 위한 얇은 위임(wrapper)이며 수치는 동일하다.
"""
return rsi_wilder(closes, period)
def calculate_bollinger(closes: List[float], period: int = 20) -> Optional[Tuple[float, float, float]]:
"""볼린저 밴드 (20일, 2σ) 계산
Returns: (upper, middle, lower)
[2026-09-27 §8.5-1 중복 통합] 계산 본체는 indicators_core.bollinger_sma() 로 이동.
σ배수 2.0 고정(기존 동작 그대로) — 설정값을 쓰는 곳은 analyze_bollinger 쪽이다.
"""
return bollinger_sma(closes, period, 2.0)
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
# ═════════════════════════════════════════════════════════════════════════════
# [§8.5-9] BEAR 진입 점수제 v2
# ═════════════════════════════════════════════════════════════════════════════
def _bear_v2_cfg(key: str) -> float:
"""BEAR v2 파라미터 조회 — strategy_cfg(DB) > settings(config.py) 우선순위."""
from app.core.config import settings
_v = strategy_cfg.get(key)
if _v is not None:
return float(_v)
return float(getattr(settings, key))
def _bear_v2_enabled() -> bool:
"""MEAN_REVERSION_BEAR_V2_ENABLED 조회 (DB 오버라이드 허용). 기본 False."""
from app.core.config import settings
_v = strategy_cfg.get("MEAN_REVERSION_BEAR_V2_ENABLED")
if _v is None:
return bool(settings.MEAN_REVERSION_BEAR_V2_ENABLED)
if isinstance(_v, str):
return _v.strip().lower() in ("1", "true", "yes", "on")
return bool(_v)
def check_bear_buy_signal_v2(
rsi: float,
close: float,
bb_upper: float,
bb_lower: float,
avg_volume_20d: float,
current_volume: int,
is_reversal: bool = False,
rsi_prev: Optional[float] = None,
bear_rsi_threshold: Optional[float] = None,
) -> Optional[Dict]:
"""BEAR 진입 점수제 v2 — 기존 4중 AND(RSI<35 AND %B≤0.20 AND 거래량>1.5×20MA AND 반전캔들)를
가중 점수 합산으로 완화한다. `check_buy_signal`의 BEAR 분기에서
`MEAN_REVERSION_BEAR_V2_ENABLED=True` 일 때만 호출된다(기본 False).
⚠️ **백테스트 전용 (2026-10-09 하드닝 R2 확인)**: `check_buy_signal` 의 호출부는
`kr_backtest_engine`·`run_backtest` 뿐이다. 실전 1차 진입 게이트는
`api/endpoints/recommendations.is_mean_reversion_candidate` 이고 그 BEAR 분기는
(RSI ≤ 임계 + %B 동적 상한)만 보며 이 함수·거래량·반전캔들을 참조하지 않는다.
따라서 이 플래그를 True 로 바꿔도 **실전 BEAR 진입은 달라지지 않는다.**
실전 반영은 StockAnalysis 에 거래량비·반전캔들 입력을 갖춘 뒤 별도 `_v2` 배선이 필요하다.
⚠️ 백테스트 미검증 — `True` 전환 금지.
DB(NAS) 중단으로 백테스트를 돌릴 수 없고, 기존 백테스트 산출물은 실전과 다른 지표
(SMA RSI vs Wilder, 발산한 ADX)로 생성된 것이 확인되어 근거로 쓸 수 없다.
BEAR 완화는 "하락장에서 진입을 늘리는" 변경이므로 아래를 모두 통과한 뒤에만 켠다:
1) indicators_core(Wilder RSI/ADX)로 재구축한 백테스트 엔진에서 2023-03~2026-03 재검증
2) **BEAR 국면 구간만 분리**한 성과: 승률 ≥ 기존 경로, Profit Factor ≥ 1.2,
BEAR 구간 MDD가 기존 경로 대비 악화되지 않음
3) **최대 연속 손실 횟수**가 기존 경로 대비 증가하지 않음
(연속 손실은 loss_tracker 3연패 밴과 직결 — 악화 시 전 종목 진입 봉쇄로 번진다)
4) Walk-forward(최소 3분할) 통과 + 거래 표본 ≥ 30건 (CLAUDE.md §8.1-4)
필수(AND)로 남긴 조건 — 점수로 우회 불가:
· RSI 반등 확인: `rsi_prev` 제공 시 `rsi > rsi_prev`. 하락 중 진입은 BEAR에서 치명적.
· RSI 상한: `min(bear_rsi_threshold + RSI_RELAX, RSI_HARD_CAP)` 미만.
(기본 35+5=40, BULL 진입 임계 46보다 낮게 유지 → "하락장은 더 보수적" 원칙 유지)
· %B 하드 상한(기존 조건의 완화된 잔존 하한): `PCT_B_HARD_CAP`(0.50) 이하.
BB 중심선 위 매수는 평균회귀가 아니다.
· 데이터 유효성: 거래량 0 / 20MA 0 / BB 폭 0 → 진입 금지(거래정지·데이터 결손).
점수화 대상(가중치 근거는 config.py 주석 참조):
· %B 근접 0.40 — 평균회귀 엣지의 직접 근거. 만점=기존 동적 임계값 통과,
절반=임계값×1.6 이내.
· 반전캔들 0.35 — 낙하 중 진입 방지 대리변수. 필수화하면 도지/데이터부족 시
영구 미발화 → 점수 항목으로만 둔다.
· 거래량 0.25 — 확인 지표. 하락장 대량거래는 투매일 수도 있어 단독 예측력 약함.
만점=1.5×20MA 초과, 0.6배=1.2×20MA 이상, 0.2배=20MA 이상.
· RSI 깊이 0.15 (보너스) — RSI<20 만점, RSI<25 절반. 깊은 과매도 반등 프리미엄.
임계: 합계 ≥ `MEAN_REVERSION_BEAR_V2_MIN_SCORE`(0.60).
주의: 공통 선행 필터(거래량 ≥ 20MA, own_trend MA20≥MA60×0.97, SIDEWAYS 차단)는
호출부(`check_buy_signal`)에 그대로 남아 있으며 이 함수는 그것을 우회하지 않는다.
"""
from app.core.config import settings
if bear_rsi_threshold is None:
_t = strategy_cfg.get("MEAN_REVERSION_BEAR_RSI_THRESHOLD")
bear_rsi_threshold = float(_t) if _t is not None else settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD
return _check_bear_buy_signal_v2_impl(
rsi=rsi,
close=close,
bb_upper=bb_upper,
bb_lower=bb_lower,
avg_volume_20d=avg_volume_20d,
current_volume=current_volume,
is_reversal=is_reversal,
rsi_prev=rsi_prev,
bear_rsi_threshold=float(bear_rsi_threshold),
cfg_get=_bear_v2_cfg,
bear_vol_mult_fn=_live_bear_volume_mult_v2,
)
def _live_bear_volume_mult_v2() -> float:
"""BEAR 거래량 배수 — strategy_cfg(DB) > settings(config.py)."""
from app.core.config import settings
_bvm = strategy_cfg.get("MEAN_REVERSION_BEAR_VOLUME_MULT")
return float(_bvm) if _bvm is not None else float(settings.MEAN_REVERSION_BEAR_VOLUME_MULT)
def _check_bear_buy_signal_v2_impl(
rsi: float,
close: float,
bb_upper: float,
bb_lower: float,
avg_volume_20d: float,
current_volume: int,
is_reversal: bool,
rsi_prev: Optional[float],
bear_rsi_threshold: float,
cfg_get: Callable[[str], float],
bear_vol_mult_fn: Callable[[], float],
) -> Optional[Dict]:
"""BEAR v2 점수제 본체 — 파라미터 출처를 호출부가 주입한다(라이브: strategy_cfg/settings,
백테스트: params/settings). 이 함수 자체는 strategy_cfg·adaptive 를 읽지 않는다."""
from app.core.config import settings
# ── 필수 ①: RSI 상한 ────────────────────────────────────────────────────
rsi_relax = cfg_get("MEAN_REVERSION_BEAR_V2_RSI_RELAX")
rsi_hard_cap = cfg_get("MEAN_REVERSION_BEAR_V2_RSI_HARD_CAP")
rsi_upper = min(float(bear_rsi_threshold) + rsi_relax, rsi_hard_cap)
if not (rsi < rsi_upper):
return None
# ── 필수 ②: RSI 반등 확인 (하락 중 진입 차단) ───────────────────────────
if rsi_prev is not None and rsi <= rsi_prev:
logger.debug(
"⏭️ [BEARv2] RSI 반등 미확인 — 진입 차단: rsi=%.1f, rsi_prev=%.1f", rsi, rsi_prev
)
return None
# ── 데이터 유효성 가드 (점수로 우회 불가) ───────────────────────────────
# 거래량 0/평균 0 = 거래정지·데이터 결손, BB 폭 0 = 지표 산출 불가 → 진입 금지.
# (기존 경로는 호출부 공통 필터 `current_volume < avg_volume_20d` 로 걸렀으나,
# v2 를 직접 호출하는 경우에도 안전하도록 함수 내부에서 재확인한다.)
if avg_volume_20d <= 0 or current_volume <= 0:
return None
bb_width = bb_upper - bb_lower
if bb_width <= 0:
return None
# ── %B 계산 (기존 BEAR 분기와 동일한 동적 임계값) ───────────────────────
pct_b = (close - bb_lower) / bb_width
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
# ── 필수 ③: %B 하드 상한 (BB 중심선 위 매수 금지) ───────────────────────
pct_b_hard_cap = cfg_get("MEAN_REVERSION_BEAR_V2_PCT_B_HARD_CAP")
if pct_b > pct_b_hard_cap:
return None
w_pct_b = cfg_get("MEAN_REVERSION_BEAR_V2_W_PCT_B")
w_reversal = cfg_get("MEAN_REVERSION_BEAR_V2_W_REVERSAL")
w_volume = cfg_get("MEAN_REVERSION_BEAR_V2_W_VOLUME")
w_depth = cfg_get("MEAN_REVERSION_BEAR_V2_W_RSI_DEPTH")
pct_b_relax_mult = cfg_get("MEAN_REVERSION_BEAR_V2_PCT_B_RELAX_MULT")
vol_mid_mult = cfg_get("MEAN_REVERSION_BEAR_V2_VOLUME_MID_MULT")
min_score = cfg_get("MEAN_REVERSION_BEAR_V2_MIN_SCORE")
bear_vol_mult = float(bear_vol_mult_fn())
# ── 점수 합산 ───────────────────────────────────────────────────────────
score_pct_b = (
w_pct_b if pct_b <= pct_b_max
else (w_pct_b * 0.5 if pct_b <= pct_b_max * pct_b_relax_mult else 0.0)
)
score_reversal = w_reversal if is_reversal else 0.0
# 거래량: 공통 선행 필터가 volume ≥ 20MA 를 이미 보장하므로 하위 등급은 방어적 처리.
vol_ratio = current_volume / avg_volume_20d
if vol_ratio > bear_vol_mult:
score_volume = w_volume
elif vol_ratio >= vol_mid_mult:
score_volume = w_volume * 0.6
elif vol_ratio >= 1.0:
score_volume = w_volume * 0.2
else:
score_volume = 0.0
if rsi < settings.MEAN_REVERSION_RSI_STRONG_THRESHOLD:
score_depth = w_depth
elif rsi < settings.MEAN_REVERSION_RSI_NORMAL_THRESHOLD:
score_depth = w_depth * 0.5
else:
score_depth = 0.0
total = score_pct_b + score_reversal + score_volume + score_depth
if total < min_score:
logger.debug(
"⏭️ [BEARv2] 점수 미달 %.2f < %.2f (pctB=%.2f/%.2f vol=%.2fx rev=%s rsi=%.1f)",
total, min_score, pct_b, pct_b_max, vol_ratio, is_reversal, rsi,
)
return None
return {
"signal": "BUY",
"strategy": "MEAN_REVERSION_BEAR_V2",
"split_plan": [0.30, 0.30, 0.40], # 3회 분할 매수 (기존 BEAR 와 동일)
"description": (
f"[BEARv2 score {total:.2f}≥{min_score:.2f}] RSI({rsi:.1f})<{rsi_upper:.1f} "
f"+ %B({pct_b:.2f}/{pct_b_max:.2f}) + 거래량({vol_ratio:.2f}×) "
f"+ 반전({'Y' if is_reversal else 'N'}) (하락장 과매도, 점수제)"
),
"bear_v2_score": round(total, 4),
"bear_v2_components": {
"pct_b": round(score_pct_b, 4),
"reversal": round(score_reversal, 4),
"volume": round(score_volume, 4),
"rsi_depth": round(score_depth, 4),
},
}
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
ma20: Optional[float] = None, # [안전형 개선①] own_trend 게이트용 — 종목 자체 MA20
ma60: Optional[float] = None, # [안전형 개선①] own_trend 게이트용 — 종목 자체 MA60
) -> Optional[Dict]:
"""CLAUDE.md 6.2: 매수 조건 판별
[2026-03-15 CLAUDE.md 정합성 수정]
- SIDEWAYS 국면: 진입 차단 (CLAUDE.md 6.1)
- BULL: RSI < MEAN_REVERSION_RSI_THRESHOLD(실효값) + %B 동적 임계, 거래량 >= 20일평균
- BEAR: RSI < MEAN_REVERSION_BEAR_RSI_THRESHOLD(실효값) + %B 동적 임계 + 되돌림 캔들
+ 거래량 > MEAN_REVERSION_BEAR_VOLUME_MULT×20일평균
- RSI 반등 확인: rsi_prev 제공 시, RSI가 반등 중일 때만 진입 허용
⚠️ 임계는 실행 시각의 라이브 적응형 모드·strategy_cfg(DB)를 읽는다(§0 우선순위).
**백테스트는 이 함수를 쓰지 말고 `check_buy_signal_backtest_v2(params, ...)` 를 쓸 것**
(2026-10-09 하드닝 R2 — 같은 입력이 실행 시각마다 다른 결과를 내던 비결정성 제거).
[안전형 개선①] own_trend 게이트 (검증 완료: Sharpe 0.27→0.57)
- ma20/ma60 제공 시: 종목 자체 MA20 < MA60*0.97 (하락추세)이면 진입 차단
(물타기 방지 — 시장 국면과 무관하게 개별 종목이 자체 하락추세면 평균회귀 매수 금지)
- ma20/ma60 미제공(기본값 None): 게이트 미적용 — 기존 호출부/테스트 하위호환 유지
"""
return _check_buy_signal_impl(
regime=regime,
rsi=rsi,
close=close,
bb_upper=bb_upper,
bb_middle=bb_middle,
bb_lower=bb_lower,
avg_volume_20d=avg_volume_20d,
current_volume=current_volume,
prev_volume=prev_volume,
is_reversal=is_reversal,
rsi_prev=rsi_prev,
ma20=ma20,
ma60=ma60,
bull_rsi_fn=_live_bull_rsi_threshold_v2,
bb_scale_fn=_live_bb_pct_b_scale_v2,
bear_rsi_fn=_live_bear_rsi_threshold_v2,
bear_vol_mult_fn=_live_bear_volume_mult_v2,
bear_v2_enabled_fn=_bear_v2_enabled,
bear_v2_cfg_get=_bear_v2_cfg,
)
# ─────────────────────────────────────────────────────────────────────────────
# 진입 파라미터 리졸버 — 라이브(adaptive > strategy_cfg > settings)
# ─────────────────────────────────────────────────────────────────────────────
_BB_PCT_B_NORMAL_REF = 0.30 # 적응형 bb_pct_b 의 Normal 기준값 (스케일 = bb_pct_b / 0.30)
_BB_PCT_B_SCALE_CAP = 1.3 # S1 fix: BB 중심선(pct_b=0.5) 위 진입 방지 — 스케일 상한
def _bb_pct_b_to_scale_v2(bb_pct_b: float) -> float:
"""적응형 bb_pct_b → %B 임계 스케일 (Normal 0.30 = 1.0, 상한 1.3). 라이브·백테스트 공용."""
_scale = bb_pct_b / _BB_PCT_B_NORMAL_REF if bb_pct_b > 0 else 1.0
return min(_scale, _BB_PCT_B_SCALE_CAP)
def _live_bull_rsi_threshold_v2() -> float:
from app.core.config import settings
_rsi_threshold_val = strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD")
_base = float(_rsi_threshold_val) if _rsi_threshold_val is not None else float(settings.MEAN_REVERSION_RSI_THRESHOLD)
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp = get_current_adaptive_params()
if not _adp.fallback_used:
_base = float(_adp.rsi_entry)
except Exception as _adp_err:
# [§4 2026-10-02] config/DB 폴백 유지 + warning(레이트리밋)
warn_throttled(logger, "mean_reversion:bull_rsi_overlay",
"[MeanReversion] 적응형 BULL RSI 임계 조회 실패 — strategy_cfg/config 값 사용: %s", _adp_err)
return _base
def _live_bb_pct_b_scale_v2() -> float:
_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:
_scale = _bb_pct_b_to_scale_v2(_adp_bb.bb_pct_b)
except Exception as _adp_err:
_scale = 1.0
warn_throttled(logger, "mean_reversion:bb_scale_overlay",
"[MeanReversion] 적응형 %%B 스케일 조회 실패 — 1.0 사용: %s", _adp_err)
return _scale
def _live_bear_rsi_threshold_v2() -> float:
from app.core.config import settings
_bear_rsi_threshold = strategy_cfg.get("MEAN_REVERSION_BEAR_RSI_THRESHOLD")
_th = float(_bear_rsi_threshold) if _bear_rsi_threshold is not None else float(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:
_th = float(_adp_bear.bear_rsi_entry)
except Exception as _adp_err:
warn_throttled(logger, "mean_reversion:bear_rsi_overlay",
"[MeanReversion] 적응형 BEAR RSI 임계 조회 실패 — strategy_cfg/config 값 사용: %s", _adp_err)
return _th
# ─────────────────────────────────────────────────────────────────────────────
# 백테스트 전용 진입 판정 — 라이브 적응형 모드·DB(strategy_cfg)를 읽지 않는다
# ─────────────────────────────────────────────────────────────────────────────
def _bt_entry_param_v2(params: Dict[str, Any], key: str, default: float,
lo: float, hi: float) -> float:
"""백테스트 진입 파라미터: params[key] 가 유한하고 (lo, hi] 안이면 그 값, 아니면 default(+warning).
default 는 항상 settings(config.py) 값이다 — strategy_cfg(DB)·adaptive 는 보지 않는다.
"""
val = params.get(key) if params else None
if val is None:
return float(default)
try:
f = float(val)
except (TypeError, ValueError):
f = float("nan")
if f != f or f in (float("inf"), float("-inf")) or not (lo < f <= hi):
logger.warning("[MeanReversionBT] 진입 파라미터 %s=%r 비정상 — 기본값 %s 사용", key, val, default)
return float(default)
return f
def _bt_bool_param_v2(params: Dict[str, Any], key: str, default: bool) -> bool:
val = params.get(key) if params else None
if val is None:
return bool(default)
if isinstance(val, str):
return val.strip().lower() in ("1", "true", "yes", "on")
return bool(val)
def check_buy_signal_backtest_v2(
params: Optional[Dict[str, Any]],
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,
ma20: Optional[float] = None,
ma60: Optional[float] = None,
) -> Optional[Dict]:
"""백테스트 전용 진입 판정 (결정론) — `check_buy_signal` 과 로직 동일, 파라미터 출처만 다르다.
라이브 `check_buy_signal` 은 `get_current_adaptive_params()`(실행 시각의 라이브 모드, TTL 초과 시
백그라운드 refresh 로 네이버/KIS 조회까지 기동)와 `strategy_cfg`(운영 DB)를 읽어, 같은 입력이라도
실행 시각마다 결과가 달랐다. 이 함수는 둘 다 읽지 않는다. 모든 진입 임계는 `params` 로 명시
주입되고, 없으면 settings(config.py) 기본값이다 — 적응형 모드 미적용 = Normal/폴백 경로와 동일.
params 키 (모두 선택):
· rsi_entry BULL RSI 진입 임계 (기본 MEAN_REVERSION_RSI_THRESHOLD)
· bear_rsi_entry BEAR RSI 진입 임계 (기본 MEAN_REVERSION_BEAR_RSI_THRESHOLD)
· bb_pct_b 적응형 모드의 bb_pct_b — %B 임계 스케일 = bb_pct_b/0.30 (상한 1.3).
미지정이면 스케일 1.0
· bear_volume_mult BEAR 거래량 배수 (기본 MEAN_REVERSION_BEAR_VOLUME_MULT)
· bear_v2_enabled BEAR 점수제 v2 (기본 settings.MEAN_REVERSION_BEAR_V2_ENABLED)
· MEAN_REVERSION_BEAR_V2_* (config 키 이름 그대로) — v2 가중치·임계 오버라이드, 기본 settings
모드별 백테스트는 봉마다 해당 모드의 rsi_entry/bear_rsi_entry/bb_pct_b 를 params 로 넘긴다
(run_backtest.determine_adaptive_mode 방식).
"""
from app.core.config import settings
_p: Dict[str, Any] = dict(params or {})
def _bear_v2_cfg_bt(key: str) -> float:
_v = _p.get(key)
if _v is not None:
return float(_v)
return float(getattr(settings, key))
def _bull_rsi() -> float:
return _bt_entry_param_v2(_p, "rsi_entry", settings.MEAN_REVERSION_RSI_THRESHOLD, 0.0, 100.0)
def _bear_rsi() -> float:
return _bt_entry_param_v2(_p, "bear_rsi_entry", settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD, 0.0, 100.0)
def _bb_scale() -> float:
if _p.get("bb_pct_b") is None:
return 1.0
return _bb_pct_b_to_scale_v2(
_bt_entry_param_v2(_p, "bb_pct_b", _BB_PCT_B_NORMAL_REF, 0.0, 1.0))
def _bear_vol() -> float:
return _bt_entry_param_v2(_p, "bear_volume_mult", settings.MEAN_REVERSION_BEAR_VOLUME_MULT, 0.0, 10.0)
def _v2_enabled() -> bool:
return _bt_bool_param_v2(_p, "bear_v2_enabled", settings.MEAN_REVERSION_BEAR_V2_ENABLED)
return _check_buy_signal_impl(
regime=regime,
rsi=rsi,
close=close,
bb_upper=bb_upper,
bb_middle=bb_middle,
bb_lower=bb_lower,
avg_volume_20d=avg_volume_20d,
current_volume=current_volume,
prev_volume=prev_volume,
is_reversal=is_reversal,
rsi_prev=rsi_prev,
ma20=ma20,
ma60=ma60,
bull_rsi_fn=_bull_rsi,
bb_scale_fn=_bb_scale,
bear_rsi_fn=_bear_rsi,
bear_vol_mult_fn=_bear_vol,
bear_v2_enabled_fn=_v2_enabled,
bear_v2_cfg_get=_bear_v2_cfg_bt,
)
def _check_buy_signal_impl(
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,
rsi_prev: Optional[float],
ma20: Optional[float],
ma60: Optional[float],
bull_rsi_fn: Callable[[], float],
bb_scale_fn: Callable[[], float],
bear_rsi_fn: Callable[[], float],
bear_vol_mult_fn: Callable[[], float],
bear_v2_enabled_fn: Callable[[], bool],
bear_v2_cfg_get: Callable[[str], float],
) -> Optional[Dict]:
"""평균회귀 진입 판정 본체. 파라미터는 전부 주입된 리졸버(지연 호출)로만 얻는다 —
이 함수 자체는 strategy_cfg·adaptive 를 읽지 않는다(라이브/백테스트가 리졸버로 출처를 고른다)."""
from app.core.config import settings
# CLAUDE.md 6.1: 횡보장(SIDEWAYS)에서는 모든 진입 신호를 무시
if regime == MarketRegime.SIDEWAYS:
logger.debug("⏭️ SIDEWAYS 국면 — 진입 차단 (CLAUDE.md 6.1)")
return None
# [안전형 개선①] own_trend 게이트 — 종목 자체 하락추세 물타기 방지
if ma20 is not None and ma60 is not None and ma60 > 0 and ma20 < ma60 * 0.97:
logger.debug(
"⏭️ own_trend 게이트 — 종목 자체 하락추세 진입 차단: MA20(%.1f) < MA60(%.1f)*0.97",
ma20, ma60,
)
return None
if current_volume < avg_volume_20d:
return None
if regime == MarketRegime.BULL:
# CLAUDE.md §6 / §8.2: BULL 진입 RSI < 46 (MEAN_REVERSION_RSI_THRESHOLD=46.0)
# 임계 출처는 리졸버(라이브: adaptive > strategy_cfg > settings / 백테스트: params > settings)
_base_rsi_threshold = float(bull_rsi_fn())
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 깊이에 따라 임계값 조정) × 적응형 스케일(상한 1.3)
_adaptive_bb_scale = float(bb_scale_fn())
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회 분할 매수
# [2026-09-27] 임계값 하드코딩 제거 — 실효 임계값(_base_rsi_threshold)을 기록한다.
"description": (
f"RSI({rsi:.1f})<{_base_rsi_threshold:.0f} + "
f"%B({pct_b:.2f})<{pct_b_max:.2f} (상승장 과매도){reversal_info}"
),
}
elif regime == MarketRegime.BEAR:
# CLAUDE.md §6 / §8.2: BEAR 진입 RSI < 35 (MEAN_REVERSION_BEAR_RSI_THRESHOLD=35.0)
bear_rsi_threshold = float(bear_rsi_fn())
# [§8.5-9] BEAR 점수제 v2 — 기본 OFF. OFF면 아래 기존 4중 AND 경로가 그대로 동작한다.
# ⚠️ 이 분기(및 아래 4중 AND)는 백테스트 전용이다(check_buy_signal 호출부 = 백테스트 엔진 2곳).
# 실전 BEAR 진입은 recommendations.is_mean_reversion_candidate 가 판정하며 v2 플래그를 보지 않는다.
if bear_v2_enabled_fn():
return _check_bear_buy_signal_v2_impl(
rsi=rsi,
close=close,
bb_upper=bb_upper,
bb_lower=bb_lower,
avg_volume_20d=avg_volume_20d,
current_volume=current_volume,
is_reversal=is_reversal,
rsi_prev=rsi_prev,
bear_rsi_threshold=bear_rsi_threshold,
cfg_get=bear_v2_cfg_get,
bear_vol_mult_fn=bear_vol_mult_fn,
)
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 = float(bear_vol_mult_fn())
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회 분할 매수
# [§4 2026-10-02] 하드코딩 `<30`·`1.5×`·`%%B` 리터럴 → 실효값.
"description": (
f"RSI({rsi:.1f})<{bear_rsi_threshold:.0f} + %B({pct_b:.2f})<={pct_b_max:.2f} "
f"+ 되돌림 + 거래량>{bear_vol_mult:g}×20MA (하락장 과매도)"
),
}
return None
def resolve_swing_exit_rsi_v2() -> Tuple[float, float, bool]:
"""스윙 RSI 청산 임계 단일 출처 → (1차 익절 RSI, 전량 RSI, 적응형 적용 여부).
settings 기본 + 적응형(fallback_used=False)이면 적응형 값. check_exit_signal 과 프론트 표시
(/strategy-config/display)가 같은 함수를 쓴다. 조회 실패는 warning 후 settings 값.
"""
from app.core.config import settings
partial = settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD
full = settings.MEAN_REVERSION_RSI_FULL_EXIT
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp = get_current_adaptive_params()
if not _adp.fallback_used:
return _adp.rsi_partial_exit, _adp.rsi_full_exit, True
except Exception as _adp_err:
warn_throttled(logger, "mean_reversion:exit_rsi_overlay",
"[MeanReversion] 적응형 RSI 청산 임계 조회 실패 — config 값 사용: %s", _adp_err)
return partial, full, False
def resolve_swing_stop_params_v2(atr: Optional[float], close: float) -> Tuple[float, float]:
"""스윙 손절 파라미터 단일 출처 → (stop_loss_pct[항상 음수, %], atr_stop_mult).
우선순위(CLAUDE.md §0): adaptive_strategy > strategy_cfg(DB) > settings(config.py).
- 변동성 판별: ATR / 종가 > 3% → STOP_LOSS_HIGH_VOL(기본 10%), 아니면 STOP_LOSS_PERCENT(7%)
(ATR 을 모르면 일반 종목으로 본다)
- ATR 배수: ATR_STOP_MULTIPLIER
check_exit_signal 의 기존 인라인 계산을 그대로 옮긴 것이다(수치 동일).
적응형 조회 실패는 폴백 유지 + warning. 부호 가드(-abs)는 try 밖에서 항상 적용.
"""
from app.core.config import settings
# [C-1 2026-10-02 3회차] 각 소스 값은 _stop_param_or_none_v2 로 검증한다 — 숫자로
# 해석할 수 없거나(None·문자열·NaN/inf) 0 이하인 값은 warning 후 다음 소스로 넘어간다.
# 이전엔 strategy_cfg 의 비정상 값이나 적응형 None 필드가 float() 예외를 던져
# 이 함수를 쓰는 지표 경로·폴백 손절이 **함께** 실패했다(손절 공백).
# 최종 기본값은 config 이므로 이 함수는 예외 없이 항상 유효한 값을 반환한다.
atr_stop_mult = float(settings.ATR_STOP_MULTIPLIER)
_cfg_mult = _stop_param_or_none_v2(strategy_cfg.get("ATR_STOP_MULTIPLIER"), "strategy_cfg.ATR_STOP_MULTIPLIER")
if _cfg_mult is not None:
atr_stop_mult = _cfg_mult
try:
_atr_pct = (float(atr) / close * 100) if (atr and close > 0) else 0.0
except (TypeError, ValueError):
_atr_pct = 0.0
_high_vol = _atr_pct > 3.0
_sl_key = "STOP_LOSS_HIGH_VOL" if _high_vol else "STOP_LOSS_PERCENT"
_raw_sl = -abs(float(getattr(settings, _sl_key)))
_cfg_sl = _stop_param_or_none_v2(strategy_cfg.get(_sl_key), f"strategy_cfg.{_sl_key}")
if _cfg_sl is not None:
_raw_sl = _cfg_sl
_adp_exit = None
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_exit = get_current_adaptive_params()
except Exception as _adp_err:
warn_throttled(logger, "mean_reversion:exit_sl_overlay",
"[MeanReversion] 적응형 손절/ATR 배수 조회 실패 — strategy_cfg/config 값 사용: %s", _adp_err)
_adp_exit = None
# [§8.5-39] Crisis 는 신규 진입 차단 용도 — 보유분 손절선을 소급 조정하지 않는다(플래그, 기본 OFF).
# 플래그 조회 자체는 예외 가능성이 없는 getattr 이며, 모드 판정은 단순 속성 비교다.
_ignore_crisis = getattr(settings, "SWING_STOP_IGNORE_CRISIS_OVERLAY", False) is True
if (_ignore_crisis and _adp_exit is not None
and str(getattr(_adp_exit, "trading_mode", "")) == "crisis"):
warn_throttled(logger, "mean_reversion:exit_sl_crisis_ignored",
"[MeanReversion] 적응형 모드=crisis — 손절 오버레이 미적용(strategy_cfg/config 값 사용)")
_adp_exit = None
if _adp_exit is not None and not getattr(_adp_exit, "fallback_used", True):
_adp_mult = _stop_param_or_none_v2(getattr(_adp_exit, "atr_stop_multiplier", None),
"adaptive.atr_stop_multiplier")
if _adp_mult is not None:
atr_stop_mult = _adp_mult
_adp_field = "stop_loss_high_vol" if _high_vol else "stop_loss_pct"
_adp_sl = _stop_param_or_none_v2(getattr(_adp_exit, _adp_field, None), f"adaptive.{_adp_field}")
if _adp_sl is not None:
_raw_sl = _adp_sl
stop_loss_pct = -abs(float(_raw_sl)) # 항상 음수 보장 (양수 입력 시 전종목 매도 방지)
return stop_loss_pct, float(atr_stop_mult)
def _trailing_mult_range_v2() -> Tuple[float, float]:
"""ATR 트레일링 배수 허용 범위 — strategy_configs 메타(DEFAULT_PARAMS min/max)가 단일 출처.
메타를 읽을 수 없으면 (0, inf) 대신 config 값 하나만 허용하는 효과가 나도록
호출부가 config 로 폴백한다(여기서는 넓은 범위를 만들지 않는다).
"""
from app.services.strategy_config_service import DEFAULT_PARAMS
_meta = DEFAULT_PARAMS["ATR_TRAILING_MULTIPLIER"]
return float(_meta[5]), float(_meta[6])
def _trailing_mult_or_none_v2(val, label: str) -> Optional[float]:
"""트레일링 배수 검증 — 범위 안의 유한한 양수면 float, 아니면 None(+warning)."""
if val is None:
return None
try:
f = float(val)
except (TypeError, ValueError):
warn_throttled(logger, f"mean_reversion:bad_trail_mult:{label}",
"[MeanReversion] 트레일링 배수 %s=%r 해석 불가 — 하위 소스 값 사용", label, val)
return None
if f != f or f in (float("inf"), float("-inf")):
warn_throttled(logger, f"mean_reversion:bad_trail_mult:{label}",
"[MeanReversion] 트레일링 배수 %s=%r 비정상 — 하위 소스 값 사용", label, val)
return None
try:
lo, hi = _trailing_mult_range_v2()
except Exception as _meta_err:
warn_throttled(logger, "mean_reversion:trail_mult_meta",
"[MeanReversion] 트레일링 배수 범위 메타 조회 실패 — 하위 소스 값 사용: %s", _meta_err)
return None
if not (lo <= f <= hi):
warn_throttled(logger, f"mean_reversion:bad_trail_mult:{label}",
"[MeanReversion] 트레일링 배수 %s=%r 범위[%s~%s] 밖 — 하위 소스 값 사용",
label, val, lo, hi)
return None
return f
def _adaptive_trailing_enabled_v2() -> bool:
"""적응형 트레일링 배수 적용 스위치. config 키 SWING_ADAPTIVE_TRAILING_ENABLED(미정의 시 False).
백테스트로 적응형 2.0/1.6/1.3/1.0 을 검증하기 전에는 False 유지(동작 = 1.5 고정과 동일).
"""
from app.core.config import settings
return getattr(settings, "SWING_ADAPTIVE_TRAILING_ENABLED", False) is True
def get_swing_trailing_mult_status_v2() -> Dict[str, object]:
"""상태 API/UI 용 — 실효 트레일링 배수와 적응형 값 적용 여부.
adaptive_strategy 상태 응답의 atr_trailing_multiplier(1.6 등)는 SWING_ADAPTIVE_TRAILING_ENABLED
가 False 면 **미적용**이다. 화면에 이 함수의 결과를 함께 노출해 혼동을 막는다.
"""
mult, source = _resolve_swing_trailing_atr_mult_impl()
return {
"effective_atr_trailing_multiplier": mult,
"source": source,
"adaptive_trailing_applied": source == "adaptive",
"adaptive_trailing_enabled": _adaptive_trailing_enabled_v2(),
}
def _resolve_swing_trailing_atr_mult_impl() -> Tuple[float, str]:
"""스윙 ATR 트레일링 배수 → (배수, 출처["config"|"strategy_cfg"|"adaptive"]).
우선순위(CLAUDE.md §0): adaptive > strategy_cfg(DB) > settings(config.py).
단 **적응형 값은 SWING_ADAPTIVE_TRAILING_ENABLED(기본 False) 일 때만** 적용한다.
적응형 _MODE_PARAMS 의 2.0/1.6/1.3/1.0 은 실전 동작을 바꾸므로 백테스트 전에는
켜지 않는다(이전 동작 = 항상 1.5). config/DB 기본값은 1.5 로 이전 리터럴과 동일하다.
예외 없이 항상 유효한 양수를 반환한다(최종 폴백은 config).
"""
from app.core.config import settings
mult = float(settings.ATR_TRAILING_MULTIPLIER)
source = "config"
_cfg = _trailing_mult_or_none_v2(strategy_cfg.get("ATR_TRAILING_MULTIPLIER"),
"strategy_cfg.ATR_TRAILING_MULTIPLIER")
if _cfg is not None:
mult, source = _cfg, "strategy_cfg"
if _adaptive_trailing_enabled_v2():
_adp = None
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp = get_current_adaptive_params()
except Exception as _adp_err:
warn_throttled(logger, "mean_reversion:trail_mult_overlay",
"[MeanReversion] 적응형 트레일링 배수 조회 실패 — strategy_cfg/config 값 사용: %s",
_adp_err)
_adp = None
if _adp is not None and not getattr(_adp, "fallback_used", True):
_adp_mult = _trailing_mult_or_none_v2(getattr(_adp, "atr_trailing_multiplier", None),
"adaptive.atr_trailing_multiplier")
if _adp_mult is not None:
mult, source = _adp_mult, "adaptive"
# 최종 가드: config 자체가 비정상(0·음수·NaN)이면 트레일링 스탑이 고점 이상(즉시 청산)이
# 되거나 의미를 잃는다 → strategy_configs 메타의 문서화 기본값으로 대체(+error).
if not (mult > 0) or mult != mult:
from app.services.strategy_config_service import DEFAULT_PARAMS
_default = float(DEFAULT_PARAMS["ATR_TRAILING_MULTIPLIER"][4])
logger.error("[MeanReversion] ATR 트레일링 배수 비정상(%r, 출처=%s) — 메타 기본값 %s 사용",
mult, source, _default)
mult, source = _default, "meta_default"
return float(mult), source
def resolve_swing_trailing_atr_mult_v2() -> float:
"""스윙 트레일링 스탑 배수(고점 − 배수×ATR) 단일 출처. 상세는 _impl 참조."""
return _resolve_swing_trailing_atr_mult_impl()[0]
def _stop_param_or_none_v2(val, label: str) -> Optional[float]:
"""손절 파라미터 값 검증 — 합리 범위 안의 유한한 숫자면 float, 아니면 None(+warning).
손절률은 부호 규약이 소스마다 달라(config 양수 / DB·적응형 음수) **크기(abs, %)** 로
[SWING_STOP_LOSS_PCT_MIN, SWING_STOP_LOSS_PCT_MAX] 범위를 검사한다(부호는 그대로 반환,
최종 -abs 가드는 호출부). ATR 배수는 값 그대로 [SWING_ATR_STOP_MULT_MIN,
SWING_ATR_STOP_MULT_MAX] — 0·음수는 손절가가 진입가 이상이 되고, 과대값은 손절 무력화다.
[L-6 2026-10-02 4회차] 이전엔 0 만 거부해 0.01%(즉시 손절)·500%(손절 없음)가 통과했다.
"""
from app.core.config import settings
if val is None:
return None
try:
f = float(val)
except (TypeError, ValueError):
warn_throttled(logger, f"mean_reversion:bad_stop_param:{label}",
"[MeanReversion] 손절 파라미터 %s=%r 해석 불가 — 하위 소스 값 사용", label, val)
return None
if f != f or f in (float("inf"), float("-inf")):
warn_throttled(logger, f"mean_reversion:bad_stop_param:{label}",
"[MeanReversion] 손절 파라미터 %s=%r 비정상 — 하위 소스 값 사용", label, val)
return None
if label.endswith("atr_stop_multiplier") or label.endswith("ATR_STOP_MULTIPLIER"):
lo, hi = float(settings.SWING_ATR_STOP_MULT_MIN), float(settings.SWING_ATR_STOP_MULT_MAX)
if not (lo <= f <= hi):
warn_throttled(logger, f"mean_reversion:bad_stop_param:{label}",
"[MeanReversion] ATR 배수 %s=%r 범위[%s~%s] 밖 — 하위 소스 값 사용",
label, val, lo, hi)
return None
return f
lo, hi = float(settings.SWING_STOP_LOSS_PCT_MIN), float(settings.SWING_STOP_LOSS_PCT_MAX)
if not (lo <= abs(f) <= hi):
warn_throttled(logger, f"mean_reversion:bad_stop_param:{label}",
"[MeanReversion] 손절률 %s=%r 범위[%s~%s%%] 밖 — 하위 소스 값 사용",
label, val, lo, hi)
return None
return f
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)
시나리오 (실제 동작 — 2026-10-02 정정, 2026-10-09 R4 재정정):
1. 손절 체크 (최우선)
2. 1차 익절 (remaining_pct > 0.50 AND 수익 >= 0.5%)
└─ RSI >= rsi_partial_exit(기본 MEAN_REVERSION_RSI_EXIT_THRESHOLD)
OR BB중앙선 상회 OR 2×ATR 수익: 50% 매도 (sell_pct = 원본 수량 대비)
3. 전량 청산
└─ RSI >= rsi_full_exit OR BB 상단×MEAN_REVERSION_BB_UPPER_THRESHOLD 도달
OR 트레일링(고점 − 배수×ATR 하회 — 배수는 resolve_swing_trailing_atr_mult_v2, 기본 1.5; **1차 익절 후 remaining_pct ≤ 0.50 에서만**)
→ 보유 전량
⚠️ 단계 우선순위 (R4 2026-10-09 — "단계 무관"은 사실이 아니었다):
1차 익절 전(remaining_pct > 0.50) + 수익 ≥ 0.5% 이면 **2단계가 먼저 return** 한다.
RSI ≥ rsi_full_exit 이면 RSI ≥ rsi_partial_exit 도, BB상단×임계 도달이면 BB중앙 상회도
항상 함께 참이므로, 이 구간의 전량 조건은 **50% 분할로 처리**된다. 잔량은 다음 사이클
(swing/exit_service 쿨다운 후)에 전량 조건이 여전히 성립할 때 청산되고, 그 사이 되돌리면
트레일링 관리로 넘어간다. 전량 조건이 단계와 무관하게 즉시 전량으로 작동하는 것은
1차 익절 이후(remaining_pct ≤ 0.50) 인 경우뿐이다.
[R6 2026-10-10] RSI/BB상단 전량 조건도 수익 ≥ 0.5%(_MIN_PROFIT_FOR_EXIT) 일 때만 성립한다.
수익 < 0.5%(손실 포함)에서는 손절·시간손절·(1차 익절 후) 트레일링만 청산한다.
순서를 바꾸면 청산 타이밍이 달라지므로 백테스트(§8.1-5) 없이 변경하지 말 것.
(kr_backtest_engine 의 v2 청산도 같은 순서다.)
"""
# ── 1. 손절 (최우선) ──────────────────────────────────────────
from app.core.config import settings
# [2026-03] ATR 손절 기준: lowest_since_entry → entry_price
# 이유: 매수 직후 저점이 낮으면 ATR 손절가가 현재가보다 훨씬 아래 → 손절 사실상 무력화
# [H4 2026-10-02] 손절률·ATR 배수 산출을 resolve_swing_stop_params_v2 로 추출(수치 동일).
# swing/exit_service 의 폴백 손절·모멘텀 손절도 같은 소스를 쓴다.
stop_loss_pct, atr_stop_mult = resolve_swing_stop_params_v2(atr, close)
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}, {stop_loss_pct:.1f}%기준={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, _adaptive_rsi_full, _ = resolve_swing_exit_rsi_v2()
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주 → 의미 없는 분할 방지
# [주석 2026-10-02, 동작 무변경] 이 식은 수량이 아니라 비율(remaining_pct ≤ 0.30)을
# 본다. 바깥 조건 remaining_pct > 0.50 안에서는 **항상 False(도달 불가)** 다.
# 실제 소량 처리는 호출부가 한다: swing/exit_service 는 잔량 ≤ 3주면 부분 신호를
# 전량으로, trade_executor._execute_sell 은 계산 수량 == 보유 전량이면 전량 청산.
_is_small_position = (remaining_pct * 10) <= 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. 전량매도 조건 ───────────────────────────────────────────────
# CLAUDE.md 6.3: RSI >= 전량청산 임계(rsi_full_exit / MEAN_REVERSION_RSI_FULL_EXIT)
# OR 가격 >= BB 상단 × MEAN_REVERSION_BB_UPPER_THRESHOLD OR 트레일링스탑 도달
# [2026-10-02 §5.2 "잔량 트레일링"] 트레일링(고점 − 배수×ATR)은 1차 분할 익절 **이후**
# (remaining_pct ≤ 0.50 — 분할 단계 기록값, sizing_core.remaining_pct_after_partial_sell)
# 에만 적용한다. 이전엔 remaining_pct 와 무관하게 1차 익절 전에도 보유 전량을 청산했다.
# 1차 익절 전 하락은 손절(1단계)이 담당한다.
from app.core.config import settings
full_exit_reasons = []
# [R6 2026-10-10] RSI/BB상단 전량 '익절' 조건에도 1차 익절과 같은 최소수익 가드
# (_MIN_PROFIT_FOR_EXIT) 를 둔다. 이전엔 손실 구간(예: trade_history id 29, -0.42%)에서도
# '전량청산: BB상단 도달'로 손실을 확정하고 loss_tracker 연패에 쌓였다.
# 손실 구간 청산은 손절(1단계)·시간손절(exit_service)·트레일링(아래, 가드 없음)이 맡는다.
_full_take_profit_ok = _profit_pct >= _MIN_PROFIT_FOR_EXIT
if _full_take_profit_ok and rsi >= _adaptive_rsi_full:
full_exit_reasons.append(f"RSI({rsi:.1f})>={_adaptive_rsi_full}")
if _full_take_profit_ok and close >= bb_upper * settings.MEAN_REVERSION_BB_UPPER_THRESHOLD:
full_exit_reasons.append(f"BB상단({bb_upper:.0f})도달")
_partial_done = remaining_pct <= threshold + 1e-9
if _partial_done and trailing_high is not None and trailing_high > entry_price:
# [R3 2026-10-09] 리터럴 1.5 → resolve_swing_trailing_atr_mult_v2
# (strategy_cfg > config, 적응형은 SWING_ADAPTIVE_TRAILING_ENABLED 일 때만). 기본값 동일.
_trail_mult = resolve_swing_trailing_atr_mult_v2()
trail_stop = trailing_high - _trail_mult * atr # CLAUDE.md 5.2: ATR 기반 트레일링
if close < trail_stop:
full_exit_reasons.append(
f"트레일링(close={close:.0f}<{trail_stop:.0f}=고점-{_trail_mult:g}×ATR)"
)
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% 유지
# [§8.5-2 통합 2026-09-27] 3중 min 식을 sizing_core.budget_by_ratios 로 위임.
# = max(0, min(총자산×종목당한도, 총자산×전략한도 − 현재배분, 가용현금)) — 수식 동일.
result = budget_by_ratios(
total_assets=total_assets,
available_cash=available_cash,
current_allocation=current_strategy_allocation,
max_per_position=max_per_position, # 절대 상한 4%
max_strategy_ratio=max_strategy_capital, # BULL 70% / BEAR 30%
)
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
# [§8.5-2 통합 2026-09-27] min(base*mult, 총자산×종목당한도, 가용현금) 재클램프.
# floor(max(0,...)) 없음 — 통합 전과 동일(base>0 이 이미 보장됨).
return clamp_by_ratios(base * mult, total_assets, [max_per_position], 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_momentum_breakout_signal_v2(
closes: list[float],
highs: list[float],
volumes: list[int],
current_close: float,
current_volume: int,
rsi: float,
) -> Optional[dict]:
"""모멘텀 돌파 — 오늘(장중) 봉을 기준 구간에서 제외한 버전.
[C2 2026-10-02] v1 은 highs[-20:] 에 오늘 봉이 포함돼 오늘 고가 ≥ 현재가 이므로
`current_close > max(highs[-20:])` 가 구조적으로 거짓이었다(실전 발화 0).
20일 고점·평균거래량을 **직전 20봉**(`[-21:-1]`)으로 계산한다. 임계값(거래량 2×,
RSI 50~70)은 v1 과 동일. 호출부는 settings.MOMENTUM_BREAKOUT_ENABLED 로 게이트한다.
"""
if len(highs) < 21 or len(volumes) < 21:
return None
prior_highs = highs[-21:-1]
prior_vols = volumes[-21:-1]
high_20d = max(prior_highs)
avg_vol_20d = sum(prior_vols) / 20
if avg_vol_20d <= 0:
return None
vol_ratio = current_volume / avg_vol_20d
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_v2(
closes: list[float],
current_close: float,
current_volume: int,
rsi: float,
rsi_prev: Optional[float] = None,
volumes: list[int] | None = None,
exclude_today_volume: bool = False,
) -> Optional[dict]:
"""눌림목 — 20일 평균거래량에서 오늘 봉을 뺄 수 있는 버전.
exclude_today_volume=False 면 v1 과 완전히 동일하다(그대로 위임).
True 면 거래량 게이트의 평균을 직전 20봉(`volumes[-21:-1]`)으로 계산하고, 나머지
조건(MA20>MA60, MA20 −3%~+2%, RSI 40~55 + 반등)은 v1 과 같다.
오늘 거래량이 평균보다 크면 평균에서 빠지므로 게이트가 완화된다 → 발화 증가 가능.
"""
if not exclude_today_volume:
return check_pullback_buy_signal(
closes=closes, current_close=current_close, current_volume=current_volume,
rsi=rsi, rsi_prev=rsi_prev, volumes=volumes,
)
if volumes is not None and len(volumes) < 21:
return None # 직전 20봉 평균을 만들 수 없음
prior = list(volumes[:-1]) if volumes else volumes
return check_pullback_buy_signal(
closes=closes, current_close=current_close, current_volume=current_volume,
rsi=rsi, rsi_prev=rsi_prev, volumes=prior,
)
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]:
"""시간 손절: 일정 기간 보유해도 목표 수익률 미달 시 청산.
기회비용 관리 — 자금을 더 좋은 기회에 배분.
[H-3 2026-10-02 3회차] `max_days` 는 **KRX 거래일** 수다(주말·휴장일 제외,
market_schedule.count_krx_trading_days_between). 이전엔 캘린더일이라 연휴가 끼면
20"일"이 실제 13~14거래일에 발동했다. 호출부는 swing/exit_service 하나다.
"""
if entry_date is None or current_date is None:
return None
from app.core.market_schedule import count_krx_trading_days_between
holding_days = count_krx_trading_days_between(entry_date, current_date)
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