""" 15분봉 단기 전략 (CLAUDE.md 섹션 8.2 — 승률 최적화) 전체 자본의 최대 25% 이내에서 운용. 일봉 RSI ≤ 40 관심 종목에 한해 15분봉 신호를 감지한다. [.env 설정] SHORT_TERM_MIN_ATR_RATIO (거래비용 커버용 ATR 비율 ≥ 0.8%) SHORT_TERM_RSI_LOOKBACK (RSI 반등 확인 과거 봉 수) SHORT_TERM_VOLUME_MULTIPLIER (거래량 배수) DAY_TRADE_ENABLED (단기 전략 활성화 여부) """ import logging from dataclasses import dataclass, field from datetime import time as dtime from enum import Enum from typing import List, Optional from app.core.config import settings from .mean_reversion_strategy import calculate_rsi, calculate_bollinger logger = logging.getLogger(__name__) # 비용 가정 — 백테스트 시 왕복으로 적용 COMMISSION_RATE = 0.003 SLIPPAGE_RATE = 0.002 TOTAL_COST_RATE = COMMISSION_RATE + SLIPPAGE_RATE # 장 운영 시간 필터 (동적 로드) def _get_skip_open_until(): return dtime(settings.SHORT_TERM_SKIP_OPEN_HOUR, settings.SHORT_TERM_SKIP_OPEN_MIN) def _get_skip_close_from(): return dtime(settings.SHORT_TERM_SKIP_CLOSE_HOUR, settings.SHORT_TERM_SKIP_CLOSE_MIN) _SKIP_OPEN_UNTIL = _get_skip_open_until() # 장 초반 30분 제외 (개장 직후 변동성 과다) _SKIP_CLOSE_FROM = _get_skip_close_from() # 마감 15분 전 제외 (종가 청산 압력) class TimeFrame(str, Enum): M15 = "15m" H1 = "60m" @dataclass class EntrySignal: ticker: str timeframe: TimeFrame reason: str rsi: float close: float atr_ratio: float = 0.0 # ATR/close — 수익 가능성 지표 @dataclass class ExitSignal: action: str # "SELL_ALL" reason: str sell_pct: float # 1.0 = 전량 class ShortTermStrategy: """ 15분봉 단기 평균회귀 전략 (승률 우선 최적화) 진입 조건 (모두 충족 필요): 1. 일봉 RSI(14) ≤ 40 — 관심 종목 사전 필터 2. 15분봉 RSI(14) ≤ 30 — 과매도 3. 15분봉 RSI 반등 중 — RSI_now > RSI_3봉전 (바닥 확인) 4. 15분봉 종가 ≤ BB 하단 × 1.01 5. 15분봉 종가 > MA5 — Fake-out 방지 6. 15분봉 거래량 > MA20 × 1.3 — CLAUDE.md 7.2 7. ATR/close ≥ 0.8% — 거래비용 커버 가능한 변동성 8. 반전 캔들 (optional) — 양봉 or 핵머 캔들 9. 시간 필터 (optional) — 09:30~15:15만 허용 청산 조건 (우선순위 순): 1. 손절: close ≤ entry - 1.0×ATR [승률 핵심 — 기존 코드에 누락] 2. RSI(14) ≥ 55 회복 3. 수익률 ≥ 1.5 × ATR_15m_pct """ TIMEFRAME = TimeFrame.M15 DAILY_RSI_THRESHOLD = 40.0 M15_RSI_ENTRY = 30.0 M15_RSI_EXIT = 55.0 def __init__(self, max_capital_ratio: float = None): # .env에서 DAY_TRADE_CAPITAL_RATIO 읽기 (없으면 기본값 0.25) self.max_capital_ratio = max_capital_ratio or settings.DAY_TRADE_CAPITAL_RATIO # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def entry_signal( self, ticker: str, daily_rsi: float, m15_closes: List[float], m15_volumes: List[int], current_volume: int, atr_15m: Optional[float] = None, m15_opens: Optional[List[float]] = None, m15_lows: Optional[List[float]] = None, trade_time: Optional[dtime] = None, ) -> Optional[EntrySignal]: """ 15분봉 진입 신호 감지 Args: ticker: 종목코드 daily_rsi: 일봉 RSI(14) m15_closes: 15분봉 종가 리스트 (최소 28개) m15_volumes: 15분봉 거래량 리스트 (최소 20개) current_volume: 현재 봉 거래량 atr_15m: 15분봉 ATR — None이면 ATR 비율 필터 스킵 m15_opens: 15분봉 시가 — None이면 반전 캔들 스킵 m15_lows: 15분봉 저가 — None이면 반전 캔들 스킵 trade_time: 현재 시각 — None이면 시간 필터 스킵 Returns: EntrySignal if 조건 충족, else None """ # 조건 1: 일봉 사전 필터 if daily_rsi > self.DAILY_RSI_THRESHOLD: return None # 조건 9: 시간 필터 (제공된 경우) if trade_time is not None: if not (_SKIP_OPEN_UNTIL <= trade_time < _SKIP_CLOSE_FROM): return None min_len = 25 + settings.SHORT_TERM_RSI_LOOKBACK if len(m15_closes) < min_len or len(m15_volumes) < 20: return None close = m15_closes[-1] # 조건 2: 15분봉 RSI 과매도 rsi_15m = calculate_rsi(m15_closes, 14) if rsi_15m is None or rsi_15m > self.M15_RSI_ENTRY: return None # 조건 3: RSI 반등 방향 확인 — 바닥에서 회복 중인지 확인 (승률 핵심) rsi_prev = calculate_rsi(m15_closes[:-settings.SHORT_TERM_RSI_LOOKBACK], 14) if rsi_prev is not None and rsi_15m <= rsi_prev: # RSI가 여전히 하락 중 → 변곡점 전이므로 진입 금지 return None # 조건 4: BB 하단 근처 bb = calculate_bollinger(m15_closes, 20) if bb is None: return None _, _, bb_lower = bb if close > bb_lower * settings.SHORT_TERM_BB_LOWER_BUFFER: return None # 조건 5: MA5 상향돌파 (CLAUDE.md 7.2: 5MA 상향돌파 필수) # [2026-03-15 CLAUDE.md 정합성 수정] BB 하단 근처 완화 조건 제거 ma5 = sum(m15_closes[-5:]) / 5 if close < ma5: return None # 조건 6: 거래량 급증 (CLAUDE.md 7.2: 1.3배) vol_ma20 = sum(m15_volumes[-20:]) / 20 if vol_ma20 == 0 or current_volume <= vol_ma20 * settings.SHORT_TERM_VOLUME_MULTIPLIER: return None # 조건 7: ATR 비율 필터 — 5,000원 필터 대체 atr_ratio = 0.0 if atr_15m is not None: atr_ratio = atr_15m / close if close > 0 else 0.0 if atr_ratio < settings.SHORT_TERM_MIN_ATR_RATIO: # 변동성 부족 → 1.5×ATR 익절 목표가 왕복 비용에 도달안함 return None # 조건 8: 반전 캔들 (데이터 제공 시) if m15_opens is not None and m15_lows is not None: if not _is_reversal_candle(m15_opens, m15_lows, m15_closes): return None return EntrySignal( ticker=ticker, timeframe=self.TIMEFRAME, reason=( f"RSI({rsi_15m:.1f})≤30↑ + BB하단 + MA5돌파" f" + 거래량({current_volume / vol_ma20:.1f}x)" + (f" + ATR비율({atr_ratio * 100:.1f}%)" if atr_15m else "") ), rsi=round(rsi_15m, 2), close=close, atr_ratio=round(atr_ratio, 4), ) def exit_signal( self, ticker: str, m15_closes: List[float], entry_price: float, atr_15m: Optional[float] = None, ) -> Optional[ExitSignal]: """ 15분봉 청산 신호 청산 우선순위: 1. 손절 : close ≤ max(entry - 1.0×ATR, entry × 0.98) [핵심 추가] 2. RSI ≥ 55 회복 3. 수익률 ≥ 1.5 × ATR_15m_pct """ if len(m15_closes) < 15: return None close = m15_closes[-1] rsi_15m = calculate_rsi(m15_closes, 14) # 청산 우선순위 1: 손절 [기존 코드에서 누락된 핸심 로직] if entry_price > 0: if atr_15m: atr_stop = entry_price - 1.0 * atr_15m hard_stop = entry_price * (1 - settings.SHORT_TERM_HARD_STOP_PCT) # -2% 하드 스탑 stop_price = max(atr_stop, hard_stop) # [FIX] 타이트한 쪽 = 진입가에 가까운 = 높은 가격 (CLAUDE.md 7.2) else: stop_price = entry_price * (1 - settings.SHORT_TERM_HARD_STOP_PCT) # ATR 없으면 -2% 고정 if close <= stop_price: loss_pct = (close - entry_price) / entry_price * 100 return ExitSignal( action="SELL_ALL", reason=f"손절 close({close:.0f}) ≤ stop({stop_price:.0f}) | {loss_pct:.1f}%", sell_pct=1.0, ) # 청산 우선순위 2: RSI ≥ 55 회복 if rsi_15m is not None and rsi_15m >= self.M15_RSI_EXIT: return ExitSignal( action="SELL_ALL", reason=f"15분봉 RSI({rsi_15m:.1f})≥55 회복", sell_pct=1.0, ) # 청산 우선순위 3: 수익률 ≥ 1.5 × ATR_15m_pct if atr_15m and entry_price > 0: profit_pct = (close - entry_price) / entry_price atr_pct = atr_15m / entry_price if profit_pct >= settings.SHORT_TERM_ATR_PROFIT_MULTIPLIER * atr_pct: return ExitSignal( action="SELL_ALL", reason=( f"익절 수익률({profit_pct * 100:.1f}%)" f" ≥ {settings.SHORT_TERM_ATR_PROFIT_MULTIPLIER}×ATR({atr_pct * 100:.1f}%)" ), sell_pct=1.0, ) return None def is_capital_available( self, total_assets: float, current_short_term_allocation: float ) -> bool: """CLAUDE.md 8.3: 단기 전략 자본 한도 충족 여부""" return current_short_term_allocation < total_assets * self.max_capital_ratio @staticmethod def net_profit_pct(gross_profit_pct: float) -> float: """수수료 + 슬리피지 차감 후 순수익률 (왕복 기준)""" return gross_profit_pct - TOTAL_COST_RATE * 2 # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _is_reversal_candle( opens: List[float], lows: List[float], closes: List[float], ) -> bool: """반전 캔들 확인 — 양봉 또는 핸머(카드) 여부 조건 (둘 중 하나): - 양봉: close > open - 해머: 하단 꼬리 > 몸통의 2배 (핀바 / 행장형) """ if not opens or not lows or not closes: return False o, l, c = opens[-1], lows[-1], closes[-1] body = abs(c - o) lower_shadow = min(o, c) - l return (c > o) or (lower_shadow > max(body * 2.0, 1e-6))