Files
auto-stock/backend/app/services/short_term_strategy.py
shuim de7921d270 fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건)
- day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가
- trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산
- risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수
- risk_management/recommendations: date.today() → KST 변환
- mdd_manager: Lock 밖 executed_key 참조 수정
- correlation_filter: output → output2 키 수정
- portfolio: 해외 종목 USD/KRW 환율 적용 분리
- Sharpe Ratio 거래빈도 기반 연간화
- telegram_notifier: send_message() 공개 래퍼 추가
- position_manager: 앱 재시작 시 DB 포지션 복원
- swing exit_service: remaining_pct 조건 범위 수정
- run_backtest: original_qty 기반 remaining_pct 계산
- us_market_schedule: 2027년 미국 공휴일 추가
- Login.tsx: res.ok 검증 추가
- mean_reversion_strategy: RSI 로그 하드코딩 수정

## HIGH 수정 (10건)
- db_worker: DLQ 파일 백업 메커니즘 추가
- korea_investment_api: 만료 토큰 임의 연장 제거
- market_data: chart_cache 최대 100개 + TTL 만료
- main.py: rate_limit_store 1시간 미접근 IP 정리
- recommendations: fire-and-forget try/except 추가
- swing/scheduler: DB 세션 3개 분리
- Dashboard: wsMessages 전체 순회
- Layout: /health 엔드포인트 + 30초 폴링
- ml_ensemble: POST /train require_api_key 추가
- day_trading: record_trade_result_safe() 전환

## 코드 정리
- 프론트엔드: isUsStock, formatTime 공통 유틸 추출
- 백엔드: dead code 제거, 중복 import 통합
- MCP: JSON 파싱 헬퍼 추출 (~60줄 감소)
- 전체 ~100개 파일 한국어 주석 추가
- 테스트 13건 코드 기준 수정 (468 passed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:05:41 +09:00

292 lines
10 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.
"""
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))