Files
auto-stock/backend/app/services/mean_reversion_strategy.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

1157 lines
55 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/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 Optional, Dict, List, 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).
⚠️ 백테스트 미검증 — `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
# ── 필수 ①: RSI 상한 ────────────────────────────────────────────────────
rsi_relax = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_RSI_RELAX")
rsi_hard_cap = _bear_v2_cfg("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 = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_PCT_B_HARD_CAP")
if pct_b > pct_b_hard_cap:
return None
w_pct_b = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_W_PCT_B")
w_reversal = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_W_REVERSAL")
w_volume = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_W_VOLUME")
w_depth = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_W_RSI_DEPTH")
pct_b_relax_mult = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_PCT_B_RELAX_MULT")
vol_mid_mult = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_VOLUME_MID_MULT")
min_score = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_MIN_SCORE")
_bvm = strategy_cfg.get("MEAN_REVERSION_BEAR_VOLUME_MULT")
bear_vol_mult = float(_bvm) if _bvm is not None else settings.MEAN_REVERSION_BEAR_VOLUME_MULT
# ── 점수 합산 ───────────────────────────────────────────────────────────
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 < 40, BB하단 -3%~+1.5%, 거래량 >= 20일평균
- BEAR: RSI < 30, 종가 <= BB하단, 되돌림 캔들, 거래량 > 1.5×20일평균
- RSI 반등 확인: rsi_prev 제공 시, RSI가 반등 중일 때만 진입 허용
[안전형 개선①] own_trend 게이트 (검증 완료: Sharpe 0.27→0.57)
- ma20/ma60 제공 시: 종목 자체 MA20 < MA60*0.97 (하락추세)이면 진입 차단
(물타기 방지 — 시장 국면과 무관하게 개별 종목이 자체 하락추세면 평균회귀 매수 금지)
- ma20/ma60 미제공(기본값 None): 게이트 미적용 — 기존 호출부/테스트 하위호환 유지
"""
# 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 > strategy_cfg(DB) > settings(config.py)
# [2026-09-27] 하드코딩 40.0 폴백 제거 → settings 참조 (BEAR 경로와 동일 패턴)
from app.core.config import settings
_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 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_rsi_threshold = _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)
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 as _adp_err:
_adaptive_bb_scale = 1.0
warn_throttled(logger, "mean_reversion:bb_scale_overlay",
"[MeanReversion] 적응형 %%B 스케일 조회 실패 — 1.0 사용: %s", _adp_err)
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] 임계값 40 하드코딩 제거 — 실효 임계값은 _base_rsi_threshold
# (adaptive > strategy_cfg > settings 우선순위, CLAUDE.md §0)이며 46 이 기본이다.
# 40 을 박아두면 로그·TradeJournal·ML 학습 데이터에 거짓 임계값이 남는다.
# `%%B` → `%B` 도 정정(f-string 은 % 를 이스케이프하지 않아 그대로 출력됐다).
"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)
# 파라미터 우선순위: adaptive_strategy > strategy_cfg(DB) > settings(config.py)
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 as _adp_err:
warn_throttled(logger, "mean_reversion:bear_rsi_overlay",
"[MeanReversion] 적응형 BEAR RSI 임계 조회 실패 — strategy_cfg/config 값 사용: %s", _adp_err)
# [§8.5-9] BEAR 점수제 v2 — 기본 OFF. OFF면 아래 기존 4중 AND 경로가 그대로 동작한다.
if _bear_v2_enabled():
return check_bear_buy_signal_v2(
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,
)
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회 분할 매수
# [§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_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
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 _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 정정):
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 트레일링(고점 − 1.5×ATR 하회, **1차 익절 후 remaining_pct ≤ 0.50 에서만**)
→ 보유 전량
"""
# ── 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 = 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 as _adp_err:
_adaptive_rsi_partial = settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD
_adaptive_rsi_full = settings.MEAN_REVERSION_RSI_FULL_EXIT
warn_throttled(logger, "mean_reversion:exit_rsi_overlay",
"[MeanReversion] 적응형 RSI 청산 임계 조회 실패 — config 값 사용: %s", _adp_err)
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 "잔량 트레일링"] 트레일링(고점 − 1.5×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 = []
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})도달")
_partial_done = remaining_pct <= threshold + 1e-9
if _partial_done and 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% 유지
# [§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