Files
auto-stock/backend/app/services/mean_reversion_strategy.py
shuim 1b9257c4d2 fix: 매매 파라미터 3중 소스 정합 + 리스크 가드 복원 + 죽은 코드 2,695줄 제거
## 배경
"전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라
전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다.

## 파라미터 소스 단일화
- 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인
  config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%).
- DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신
  (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등)

## 백테스트를 실전 로직에 정합
- calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트
  `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향
- 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치
- us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명)
- backend/backtest_results/ 25건은 폐기 대상 (§8.4)

## 리스크 가드 복원
- 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로
- 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다)
- 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동
- MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용)
- 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정

## 죽은 코드 제거 (2,695줄)
- short_term_strategy, kosdaq_scanner, app/strategy/ 패키지
- app/trading/ + korea_investment_api_adapter (real_market_api 정본은
  korea_investment_api.py:1482, adapter 는 import 0건)

## 지표·사이징 단일화 (수치 무변경 증명)
- indicators_core.py: 3,274건 비교 최대 오차 0.000e+00
- sizing_core.py: 42,105건 비교 불일치 0
- 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교)

## 테스트 안전 (실 KIS 호출 차단)
- 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이
  close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로
  바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤
  참조 누수로 실제 잔고 조회를 타 141초를 소비했다.
- backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단.
  예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게),
  클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제
- 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed)

## 신규 (둘 다 플래그 OFF, 백테스트 미검증)
- range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선
- check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%)

## 문서
- CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약,
  §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거,
  §8.5 미해결 19항목
- 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 08:58:18 +09:00

945 lines
43 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
# §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: ADX < 20 OR 기울기 ≈ 0 → 진입 불가
@dataclass
class ExitSignal:
action: str # "SELL_PARTIAL", "SELL_ALL"
reason: str
sell_pct: float # 0.5 = 50%, 1.0 = 전량
def calculate_atr(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> Optional[float]:
"""ATR(Average True Range) 계산
TR = max(H-L, |H-Cprev|, |L-Cprev|)
ATR = SMA(TR, period)
[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): ADX < 20 OR 기울기 ≈ 0 → 모든 진입 신호 무시
highs/lows 미제공 시 기존 동작(BULL/BEAR만)과 동일 — 기존 호출부 호환 유지.
"""
from app.core.config import settings
if len(index_closes) < settings.MEAN_REVERSION_MA200_LOOKBACK:
return MarketRegime.BEAR # 데이터 부족 시 보수적
# CLAUDE.md 6.1: ADX < 20 → SIDEWAYS (진입 차단)
# S3 fix: ADX 값은 먼저 계산하되, slope도 함께 확인 후 판정
_adx_value = None
if highs is not None and lows is not None:
_adx_value = calculate_adx(highs, lows, index_closes)
ma200_current = sum(index_closes[-settings.MEAN_REVERSION_MA200_PERIOD:]) / settings.MEAN_REVERSION_MA200_PERIOD
# [#10] 기울기 비교 기간을 5일→20일로 확대하여 SIDEWAYS 과다판정 방지
_slope_lookback = 20
_slope_offset = settings.MEAN_REVERSION_MA200_PERIOD + _slope_lookback
if len(index_closes) >= _slope_offset:
ma200_prev = sum(index_closes[-_slope_offset:-_slope_lookback]) / settings.MEAN_REVERSION_MA200_PERIOD
else:
# 데이터 부족 시 기존 5일 방식 폴백
ma200_prev = sum(index_closes[-(settings.MEAN_REVERSION_MA200_LOOKBACK):-5]) / settings.MEAN_REVERSION_MA200_PERIOD
slope_positive = ma200_current > ma200_prev
# CLAUDE.md 6.1: 기울기 ≈ 0 → SIDEWAYS
slope_ratio = abs(ma200_current - ma200_prev) / ma200_prev if ma200_prev > 0 else 0
# S3 fix: SIDEWAYS 판정은 ADX < threshold AND slope ≈ 0 모두 충족 시에만.
# ADX < threshold이더라도 slope가 명확하면 BULL/BEAR로 분류한다.
# M2: ADX hysteresis — enter SIDEWAYS at ADX < 18, exit SIDEWAYS at ADX > 22
global _previous_regime
_ADX_ENTER_SIDEWAYS = settings.MEAN_REVERSION_ADX_SIDEWAYS - 2.0 # 18
_ADX_EXIT_SIDEWAYS = settings.MEAN_REVERSION_ADX_SIDEWAYS + 2.0 # 22
if _adx_value is not None:
_was_sideways = (_previous_regime == MarketRegime.SIDEWAYS)
# hysteresis: 이미 SIDEWAYS면 ADX > 22 돌파해야 탈출, 아니면 ADX < 18 이하여야 진입
_adx_triggers_sideways = (
(_was_sideways and _adx_value < _ADX_EXIT_SIDEWAYS) or
(not _was_sideways and _adx_value < _ADX_ENTER_SIDEWAYS)
)
if _adx_triggers_sideways:
if slope_ratio < settings.MEAN_REVERSION_SLOPE_THRESHOLD:
logger.info("시장 국면: SIDEWAYS (ADX=%.1f, hysteresis=[enter<%.0f,exit>%.0f] AND slope≈0, ratio=%.5f) — CLAUDE.md 6.1",
_adx_value, _ADX_ENTER_SIDEWAYS, _ADX_EXIT_SIDEWAYS, slope_ratio)
_previous_regime = MarketRegime.SIDEWAYS
return MarketRegime.SIDEWAYS
else:
logger.info("시장 국면: ADX=%.1f이나 slope 명확(ratio=%.5f) — BULL/BEAR로 분류", _adx_value, slope_ratio)
if slope_ratio < settings.MEAN_REVERSION_SLOPE_THRESHOLD:
logger.info("시장 국면: SIDEWAYS (MA200 기울기 ≈ 0, ratio=%.5f) — CLAUDE.md 6.1", slope_ratio)
_previous_regime = MarketRegime.SIDEWAYS
return MarketRegime.SIDEWAYS
is_bull = index_closes[-1] > ma200_current and slope_positive
result = MarketRegime.BULL if is_bull else MarketRegime.BEAR
logger.info("시장 국면: %s (MA200 slope_ratio=%.5f, 20일 기준)", result.value.upper(), slope_ratio)
_previous_regime = result
return result
def calculate_rsi(closes: List[float], period: int = 14) -> Optional[float]:
"""RSI(Relative Strength Index) 계산 — Wilder 평활법
feature_engineering.compute_rsi()와 동일한 Wilder smoothing 사용:
- 첫 period개: 단순 평균(seed)
- 이후: avg = (prev_avg * (period-1) + current) / period
[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:
pass
rsi_ok = rsi < _base_rsi_threshold
if rsi_ok:
# [2026-03] RSI 반등 확인: rsi_prev 제공 시 RSI 하락 중이면 진입 금지
if rsi_prev is not None and rsi <= rsi_prev:
logger.debug(
"⏭️ RSI 반등 미확인 — 진입 차단: rsi=%.1f, rsi_prev=%.1f (하락 중)",
rsi, rsi_prev,
)
return None
# %B 기반 동적 BB 조건 (RSI 깊이에 따라 임계값 조정)
# 적응형 전략: bb_pct_b 오버라이드로 기본 pct_b_max 스케일 조정
_adaptive_bb_scale = 1.0 # 기본 스케일
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_bb = get_current_adaptive_params()
if not _adp_bb.fallback_used:
# Normal 기준 0.30 대비 비율로 스케일링
_adaptive_bb_scale = _adp_bb.bb_pct_b / 0.30 if _adp_bb.bb_pct_b > 0 else 1.0
# S1 fix: BB 중심선(pct_b=0.5) 위 진입 방지 — 스케일 상한 1.3
_adaptive_bb_scale = min(_adaptive_bb_scale, 1.3)
except Exception:
pass
bb_width = bb_upper - bb_lower
pct_b = (close - bb_lower) / bb_width if bb_width > 0 else 0.5
if rsi < 25:
pct_b_max = 0.35 * _adaptive_bb_scale # 깊은 과매도 → BB 조건 완화
elif rsi < 30:
pct_b_max = 0.30 * _adaptive_bb_scale
else:
pct_b_max = 0.20 * _adaptive_bb_scale # RSI 30~40 → BB 하단 근처 요구
if pct_b <= pct_b_max:
reversal_info = ""
if rsi_prev is not None:
reversal_info = f" RSI반등({rsi_prev:.1f}→{rsi:.1f})"
return {
"signal": "BUY",
"strategy": "MEAN_REVERSION_BULL",
"split_plan": [0.50, 0.50], # 2회 분할 매수
# [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:
pass
# [§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회 분할 매수
"description": f"RSI({rsi:.1f})<30 + %%B({pct_b:.2f})<{pct_b_max} + 되돌림 + 거래량>1.5×20MA (하락장 과매도)",
}
return None
def check_exit_signal(
regime: MarketRegime,
rsi: float,
close: float,
bb_upper: float,
bb_middle: float,
bb_lower: float,
entry_price: float,
atr: float,
lowest_since_entry: float,
remaining_pct: float,
trailing_high: Optional[float] = None, # 진입 후 최고가 (트레일링 스탑용) — 기존 호출부 호환 유지
) -> Optional[ExitSignal]:
"""CLAUDE.md 6.3 + 5.2: 청산 조건
[2026-03-15 CLAUDE.md 정합성 수정]
- 1차 익절(부분매도): RSI >= 50 OR 가격 > BB 중앙선 OR 수익 > 2*ATR (OR 로직)
- 분할매도 비율: 50% (Bull/Bear 동일, CLAUDE.md 5.2)
- 트레일링 스탑: 고점 - 1.5 * ATR (CLAUDE.md 5.2)
시나리오:
1. 손절 체크 (최우선)
2. 1차 익절 (remaining_pct > 0.50)
└─ RSI >= 50 OR BB중앙선 상회 OR 2×ATR 수익: 50% 매도
3. 2차 익절 (1차 완료 후)
├─ 트레일링 스탑: 고점 - 1.5×ATR 하회 시 잔여 전량 청산
└─ BB 상단 도달 시 50% 추가 익절
"""
# ── 1. 손절 (최우선) ──────────────────────────────────────────
from app.core.config import settings
# [2026-03] ATR 손절 기준: lowest_since_entry → entry_price
# 이유: 매수 직후 저점이 낮으면 ATR 손절가가 현재가보다 훨씬 아래 → 손절 사실상 무력화
# [이슈10 수정] or 패턴 → None 명시 체크
_atr_stop_val = strategy_cfg.get("ATR_STOP_MULTIPLIER")
atr_stop_mult = float(_atr_stop_val) if _atr_stop_val is not None else 2.5
# CLAUDE.md §5.2: 기본 -7%(STOP_LOSS_PERCENT), 고변동 -10%(STOP_LOSS_HIGH_VOL)
# 파라미터 우선순위: adaptive_strategy > strategy_cfg(DB) > settings(config.py)
# 변동성 판별: ATR / 현재가 > 3% → 변동성 종목
_atr_pct = (atr / close * 100) if close > 0 else 0
# [이슈10 수정] or 패턴 → None 명시 체크
if _atr_pct > 3.0:
_sl_high_val = strategy_cfg.get("STOP_LOSS_HIGH_VOL")
_raw_sl = float(_sl_high_val) if _sl_high_val is not None else -abs(settings.STOP_LOSS_HIGH_VOL) # [2026-09-27] config.py 참조
else:
_sl_val = strategy_cfg.get("STOP_LOSS_PERCENT")
_raw_sl = float(_sl_val) if _sl_val is not None else -abs(settings.STOP_LOSS_PERCENT) # config.py 기본값(7.0) 참조
# 적응형 전략 오버라이드: stop_loss / atr_stop_multiplier
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_exit = get_current_adaptive_params()
if not _adp_exit.fallback_used:
atr_stop_mult = _adp_exit.atr_stop_multiplier
if _atr_pct > 3.0:
_raw_sl = _adp_exit.stop_loss_high_vol
else:
_raw_sl = _adp_exit.stop_loss_pct
except Exception:
pass
stop_loss_pct = -abs(_raw_sl) # 항상 음수 보장 (양수 입력 시 전종목 매도 방지)
atr_stop = entry_price - atr_stop_mult * atr
pct_stop = entry_price * (1 + stop_loss_pct / 100)
stop_price = max(atr_stop, pct_stop) # 높은 값 = 타이트한 손절
if close <= stop_price:
return ExitSignal(
action="SELL_ALL",
reason=f"손절 (close={close:.0f} <= stop={stop_price:.0f}, ATR기준={atr_stop:.0f}, {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:
pass
min_profit_price = entry_price + 2.0 * atr # CLAUDE.md 6.3: 2×ATR
_profit_pct = ((close - entry_price) / entry_price * 100) if entry_price > 0 else 0
# 최소 수익률 가드: 수수료(~0.4%)를 커버하지 못하면 익절하지 않음
_MIN_PROFIT_FOR_EXIT = 0.5 # 최소 0.5% 수익이어야 익절 (수수료+세금 ~0.3-0.4% 커버)
if remaining_pct > threshold and _profit_pct >= _MIN_PROFIT_FOR_EXIT:
rsi_exit = rsi >= _adaptive_rsi_partial
bb_exit = close > bb_middle
atr_exit = close >= min_profit_price
if rsi_exit or bb_exit or atr_exit:
reasons = []
if rsi_exit:
reasons.append(f"RSI({rsi:.1f})>={_adaptive_rsi_partial:.0f}")
if bb_exit:
reasons.append(f"BB중앙({bb_middle:.0f})상회")
if atr_exit:
reasons.append(f"2×ATR({atr:.0f})수익")
# CLAUDE.md 5.2: 50% 분할 매도 — 단, 소량(3주 이하)이면 전량매도
# 1주를 50% 매도하면 0주 → 의미 없는 분할 방지
_is_small_position = (remaining_pct * 10) <= 3 # 대략 3주 이하 추정
if _is_small_position:
return ExitSignal(
action="SELL_ALL",
reason=f"소량 전량매도: {' OR '.join(reasons)} (분할 비효율)",
sell_pct=1.0,
)
return ExitSignal(
action="SELL_PARTIAL",
reason=f"1차 익절(50%): {' OR '.join(reasons)}",
sell_pct=0.50,
)
# ── 3. 전량매도 조건 (1차 익절 여부 무관) ──────────────────────────
# CLAUDE.md 6.3: RSI >= 65 OR 가격 > BB 상단(99%) OR 트레일링스탑 도달
from app.core.config import settings
full_exit_reasons = []
if rsi >= _adaptive_rsi_full:
full_exit_reasons.append(f"RSI({rsi:.1f})>={_adaptive_rsi_full}")
if close >= bb_upper * settings.MEAN_REVERSION_BB_UPPER_THRESHOLD:
full_exit_reasons.append(f"BB상단({bb_upper:.0f})도달")
if trailing_high is not None and trailing_high > entry_price:
trail_stop = trailing_high - 1.5 * atr # CLAUDE.md 5.2: ATR 기반 트레일링
if close < trail_stop:
full_exit_reasons.append(
f"트레일링(close={close:.0f}<{trail_stop:.0f})"
)
if full_exit_reasons:
return ExitSignal(
action="SELL_ALL",
reason=f"전량청산: {' OR '.join(full_exit_reasons)}",
sell_pct=1.0,
)
return None
def calculate_position_size(
total_assets: float,
available_cash: float,
current_strategy_allocation: float,
regime: MarketRegime,
max_per_position: float = 0.04,
max_strategy_capital_bull: float = 0.70,
max_strategy_capital_bear: float = 0.30,
rsi: Optional[float] = None,
adx: Optional[float] = None,
) -> float:
"""섹션 6.1 + 5.1: 동적 포지션 사이징 (신호 강도 기반)
[2026-03-15 CLAUDE.md 정합성 수정]
CLAUDE.md 5.1: 종목당 최대 총자본의 3~4%. multiplier는 전략 자본 예산
배분 내에서의 우선순위 조정에만 사용하며, 종목당 절대 상한(4%)은 초과 불가.
시장별 전략 자본 한도:
- 상승장(Bull): 전략 전체 최대 70% 운용
- 하락장(Bear): 전략 전체 최대 30% 운용
"""
max_strategy_capital = max_strategy_capital_bull if regime == MarketRegime.BULL else max_strategy_capital_bear
# [이슈10 수정] or 패턴 → None 명시 체크 (0.0 입력 시 기본값 오염 방지)
def _cfg_float(key: str, default: float) -> float:
val = strategy_cfg.get(key)
return float(val) if val is not None else default
from app.core.config import settings
# [2026-03-15 CLAUDE.md 정합성 수정] CLAUDE.md 5.1: 종목당 최대 3~4%
# multiplier를 전략 자본 내 비중 조정에만 사용, 절대 상한은 4% 유지
# [§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_pullback_buy_signal(
closes: list[float],
current_close: float,
current_volume: int,
rsi: float,
rsi_prev: Optional[float] = None,
volumes: list[int] | None = None,
) -> Optional[dict]:
"""눌림목 매수 시그널: 상승추세(MA20>MA60) 중 MA20 근처 조정 후 반등.
BULL 시장에서만 사용. 추세 지속 중 조정을 매수 기회로 활용.
"""
if len(closes) < 60:
return None
ma20 = sum(closes[-20:]) / 20
ma60 = sum(closes[-60:]) / 60
# 거래량 검증: 20일 평균 대비 1.2배 이상 (유동성 확인)
if volumes and len(volumes) >= 20 and current_volume > 0:
avg_vol = sum(volumes[-20:]) / 20
if avg_vol > 0 and current_volume < avg_vol * 1.2:
return None # 거래량 부족 → 유동성 트랩 방지
elif current_volume <= 0:
return None # 거래량 데이터 없으면 진입 불가
# 상승추세(MA20 > MA60) 확인
if ma20 <= ma60:
return None
# MA20 근처(±3%)까지 눌림
if not (ma20 * 0.97 < current_close < ma20 * 1.02):
return None
# RSI 40~55 (과매도에서 회복 중) + RSI 반등 확인
if not (40 < rsi < 55):
return None
if rsi_prev is not None and rsi <= rsi_prev:
return None
return {
"signal": "BUY",
"strategy": "PULLBACK_BUY",
"confidence": 0.55,
"description": f"눌림목: MA20({ma20:.0f}) 근처 반등 RSI({rsi:.1f})",
}
def check_time_stop_exit(
entry_date,
current_date,
profit_pct: float,
max_days: int = 20,
min_profit_pct: float = 3.0,
) -> Optional[dict]:
"""시간 손절: 일정 기간 보유해도 목표 수익률 미달 시 청산.
기회비용 관리 — 자금을 더 좋은 기회에 배분.
"""
if entry_date is None or current_date is None:
return None
holding_days = (current_date - entry_date).days
if holding_days >= max_days and profit_pct < min_profit_pct:
return {
"action": "SELL_ALL",
"sell_pct": 1.0,
"reason": f"시간손절 ({holding_days}일, {profit_pct:+.1f}% < +{min_profit_pct}%)",
}
return None