Files
auto-stock/backend/app/services/day_trading_strategy.py
shuim 5f0f839039 전략 취약점 15건 수정 (코드 리뷰 기반)
## CRITICAL/HIGH (S1-S8)
- S1: Aggressive 모드 %B 스케일링 캡 1.3 (BB 중앙선 위 매수 방지)
- S2: ML scaler train-only fit (데이터 누수 제거)
- S3: SIDEWAYS = ADX<20 AND slope≈0 (트렌드 초기 차단 해소)
- S4: EOD 청산 실패 시 재시도 + CRITICAL 로그
- S5: 일일 손실 한도에 미실현 P&L 포함
- S6: trailing stop 고점 매 사이클 갱신
- S7: 섹터 필터 미매핑 종목 "기타" 분류 (max 3 적용)
- S8: 스캐너 RSI 기준 25→46, BB 점수 -3%~+1.5% 구간

## MEDIUM (M1-M7)
- M1: 스캐너 최소 거래량 100만→10만주
- M2: ADX 히스테리시스 18/22 (국면 플리핑 방지)
- M3: 분할매도 최소 수익 1.0%→0.5%
- M4: ORB 갭 필터 4% + 레인지 폭 0.3% 최소
- M5: 쿨다운 스트릭 부분매도 시 미변경
- M6: MDD 강제매도 loss_tracker 기록 제거 (허위 ban 방지)
- M7: RSI Wilder 스무딩으로 통일

## 인프라/보안
- 인증 누락 엔드포인트 수정 (analysis, journals)
- nginx 보안 헤더 location 블록 누락 수정
- Alembic 마이그레이션 IF NOT EXISTS 가드 추가
- SellRequest/UsOrderRequest 입력 검증 강화
- 차트 네이버 폴백 추가 (DB 데이터 없을 때)
- 서버 이전 시 자동 DB 마이그레이션 + KRX 초기 수집
- CLAUDE.md 섹션 5.1 백테스트 값 동기화 (4%→6%, -6%→-7%)
- MAX_SWING_POSITIONS 25→20 (CLAUDE.md 준수)
- mdd_manager failed_count에서 skip 종목 제외

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:44:14 +09:00

631 lines
24 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.
"""
데이트레이딩 전략 (CLAUDE.md 섹션 8.2 확장)
전략 3종:
1. ORB — Opening Range Breakout (09:00~09:30 고점 돌파)
2. VWAP — VWAP 지지 + 과매도 반등
3. RSI_BB — 5분봉 RSI+볼린저 과매도 반등
공통 규칙:
- EOD 강제 청산: 15:20 이후 전량 시장가 매도
- 일일 손실 한도: 전체 자본 -3% 초과 시 신규 진입 중단
- 동시 최대 3종목, 종목당 최대 4% 자본 (CLAUDE.md 5.1)
- 전체 데이트레이딩 자본: 총자산의 최대 25% (CLAUDE.md 8.2)
[.env 설정]
DAY_TRADE_MAX_CAPITAL_RATIO
DAY_TRADE_MAX_RATIO_DT
DAY_TRADE_MAX_POSITIONS (== MAX_DAY_TRADE_POSITIONS)
DAY_TRADE_DAILY_LOSS_LIMIT
DAY_TRADE_ORB_STOP_LOSS_PCT
DAY_TRADE_REVERSION_STOP_LOSS_PCT
DAY_TRADE_TRAILING_TRIGGER
DAY_TRADE_TRAILING_ATR_MULT
DAY_TRADE_ORB_BREAKOUT_MARGIN
DAY_TRADE_COST_PCT
DAY_TRADE_ENTRY_CUTOFF_HOUR, DAY_TRADE_ENTRY_CUTOFF_MIN
DAY_TRADE_EOD_HOUR, DAY_TRADE_EOD_MIN
"""
import logging
from typing import Optional, Dict, List, Tuple
from dataclasses import dataclass
from datetime import time
from app.core.config import settings
from app.services.mean_reversion_strategy import calculate_rsi, calculate_atr, calculate_bollinger
logger = logging.getLogger(__name__)
# ── 장 시간 상수 (KST) ──────────────────────────────────────────────────────
MARKET_OPEN = time(9, 0)
ORB_END = time(9, 30) # Opening Range 집계 종료
# 동적 시간 설정 (settings에서 읽음)
def _get_entry_cutoff() -> time:
"""신규 진입 마감 시간 (.env: DAY_TRADE_ENTRY_CUTOFF_HOUR, DAY_TRADE_ENTRY_CUTOFF_MIN)"""
return time(settings.DAY_TRADE_ENTRY_CUTOFF_HOUR, settings.DAY_TRADE_ENTRY_CUTOFF_MIN)
def _get_eod_flat() -> time:
"""EOD 강제 청산 시작 시간 (.env: DAY_TRADE_EOD_HOUR, DAY_TRADE_EOD_MIN)"""
return time(settings.DAY_TRADE_EOD_HOUR, settings.DAY_TRADE_EOD_MIN)
MARKET_CLOSE = time(15, 30)
# ── 동적 설정 접근자 (이슈6 수정) ──────────────────────────────────────────────
# [이슈6 수정] 모듈 로드 시점에 settings 값을 상수로 고정하면, 런타임에 .env가
# 변경되거나 테스트에서 settings를 오버라이드해도 반영되지 않음.
# 접근자 함수로 래핑하여 호출 시점에 settings를 읽도록 변경.
# 기존 모듈 레벨 이름(ENTRY_CUTOFF, EOD_FLAT 등)은 하위 호환을 위해 유지하되
# 실제 사용부(calculate_day_trade_position_size, is_daily_loss_exceeded)는
# 아래 _get_* 함수를 통해 동적으로 읽음.
def _get_max_positions() -> int:
return settings.DAY_TRADE_MAX_POSITIONS
def _get_max_day_trade_ratio() -> float:
return settings.DAY_TRADE_MAX_CAPITAL_RATIO
def _get_max_capital_ratio() -> float:
return settings.DAY_TRADE_MAX_RATIO_DT
def _get_daily_loss_limit() -> float:
return settings.DAY_TRADE_DAILY_LOSS_LIMIT
# 모듈 레벨 이름 — 하위 호환 유지 (import하는 코드가 있을 경우 그대로 동작)
ENTRY_CUTOFF = _get_entry_cutoff()
EOD_FLAT = _get_eod_flat()
MAX_POSITIONS = settings.DAY_TRADE_MAX_POSITIONS
MAX_DAY_TRADE_RATIO = settings.DAY_TRADE_MAX_CAPITAL_RATIO
MAX_CAPITAL_RATIO = settings.DAY_TRADE_MAX_RATIO_DT
DAILY_LOSS_LIMIT = settings.DAY_TRADE_DAILY_LOSS_LIMIT # 이미 비율 (-0.03 = -3%)
@dataclass
class MinuteCandle:
"""KIS API output2 데이터 정규화 결과"""
date: str # YYYYMMDD
time: str # HHMMSS
open: float
high: float
low: float
close: float
volume: int
cum_volume: int # 누적 거래량 (acml_vol)
cum_amount: float # 누적 거래대금 원 (acml_tr_pbmn)
@dataclass
class OpeningRange:
high: float
low: float
total_volume: int
avg_vol_per_min: float # 분당 평균 거래량 (ORB 기간)
open_price: float = 0.0 # M4: 시가 (첫 ORB 봉의 open, gap 필터용)
@dataclass
class DayTradeSignal:
stock_code: str
stock_name: str
strategy: str # "ORB" | "VWAP_BOUNCE" | "RSI_BB"
entry_price: float
stop_loss: float
target_price: float
reason: str
confidence: float # 0.0 ~ 1.0
def __post_init__(self) -> None:
"""Validate DayTradeSignal field constraints.
Issues warnings for validation failures but does not raise exceptions
to avoid breaking existing code.
"""
# Validate confidence: 0.0 to 1.0
if not (0.0 <= self.confidence <= 1.0):
logger.warning(
f"[DayTradeSignal] {self.stock_code}: confidence={self.confidence} not in [0.0, 1.0]"
)
# Validate entry_price > 0
if self.entry_price <= 0:
logger.warning(
f"[DayTradeSignal] {self.stock_code}: entry_price={self.entry_price} must be > 0"
)
# Validate stop_loss > 0 and < entry_price
if self.stop_loss <= 0:
logger.warning(
f"[DayTradeSignal] {self.stock_code}: stop_loss={self.stop_loss} must be > 0"
)
elif self.stop_loss >= self.entry_price:
logger.warning(
f"[DayTradeSignal] {self.stock_code}: stop_loss={self.stop_loss} must be < entry_price={self.entry_price}"
)
# Validate target_price > entry_price
if self.target_price <= self.entry_price:
logger.warning(
f"[DayTradeSignal] {self.stock_code}: target_price={self.target_price} must be > entry_price={self.entry_price}"
)
# Validate strategy is one of allowed values
allowed_strategies = {"ORB", "VWAP_BOUNCE", "RSI_BB"}
if self.strategy not in allowed_strategies:
logger.warning(
f"[DayTradeSignal] {self.stock_code}: strategy='{self.strategy}' not in {allowed_strategies}"
)
@dataclass
class DayTradeExit:
action: str # "SELL_ALL"
reason: str
sell_pct: float = 1.0
# ── 분봉 파싱 헬퍼 ────────────────────────────────────────────────────────────
def parse_minute_candles(raw_output2: list) -> List[MinuteCandle]:
"""KIS API output2 리스트를 MinuteCandle 리스트로 변환
KIS 응답 필드:
stck_bsop_date / stck_cntg_hour / stck_oprc / stck_hgpr /
stck_lwpr / stck_prpr (종가) / cntg_vol / acml_vol / acml_tr_pbmn
"""
candles: List[MinuteCandle] = []
for row in raw_output2:
try:
candles.append(MinuteCandle(
date=row.get("stck_bsop_date", ""),
time=row.get("stck_cntg_hour", ""),
open=float(row.get("stck_oprc", 0) or 0),
high=float(row.get("stck_hgpr", 0) or 0),
low=float(row.get("stck_lwpr", 0) or 0),
close=float(row.get("stck_prpr", 0) or 0),
volume=int(row.get("cntg_vol", 0) or 0),
cum_volume=int(row.get("acml_vol", 0) or 0),
cum_amount=float(row.get("acml_tr_pbmn", 0) or 0),
))
except (ValueError, TypeError):
continue
return candles
def aggregate_to_5min(candles: List[MinuteCandle]) -> List[MinuteCandle]:
"""1분봉 → 5분봉 집계 (09:00 기준 5분 버킷)"""
if not candles:
return []
buckets: Dict[tuple, List[MinuteCandle]] = {}
for c in candles:
if not c.time:
continue
h = int(c.time[:2])
m = int(c.time[2:4])
key = (c.date, (h * 60 + m) // 5) # 날짜 포함 → 다른 날 캔들 혼합 방지
buckets.setdefault(key, []).append(c)
result: List[MinuteCandle] = []
for key in sorted(buckets):
bkt = buckets[key]
result.append(MinuteCandle(
date=bkt[0].date,
time=bkt[0].time,
open=bkt[0].open,
high=max(b.high for b in bkt),
low=min(b.low for b in bkt),
close=bkt[-1].close,
volume=sum(b.volume for b in bkt),
cum_volume=bkt[-1].cum_volume,
cum_amount=bkt[-1].cum_amount,
))
return result
# ── 지표 계산 ─────────────────────────────────────────────────────────────────
def calculate_vwap(candles: List[MinuteCandle]) -> Optional[float]:
"""VWAP = 누적거래대금 / 누적거래량 (당일 09:00 기준 누적)
cum_volume이 0인 경우 (KIS API 분봉에 acml_vol 미제공 시)
개별 캔들의 typical_price * volume 합산으로 계산.
"""
if not candles:
return None
# 방법 1: 누적 필드 사용
last = candles[-1]
if last.cum_volume > 0:
return last.cum_amount / last.cum_volume
# 방법 2: 개별 캔들에서 합산 (acml_vol 미제공 시 폴백)
total_pv = 0.0
total_vol = 0
for c in candles:
if c.volume > 0:
typical = (c.high + c.low + c.close) / 3.0
total_pv += typical * c.volume
total_vol += c.volume
if total_vol <= 0:
return None
return total_pv / total_vol
def calculate_opening_range(candles_1min: List[MinuteCandle]) -> Optional[OpeningRange]:
"""09:00~09:29 분봉으로 Opening Range (H/L) 계산"""
orb = [c for c in candles_1min if "090000" <= c.time <= "092959"]
if len(orb) < 5:
return None
total_vol = sum(c.volume for c in orb)
# M4: 시가 = ORB 기간 첫 봉의 open (gap 필터용)
sorted_orb = sorted(orb, key=lambda c: c.time)
return OpeningRange(
high=max(c.high for c in orb),
low=min(c.low for c in orb),
total_volume=total_vol,
avg_vol_per_min=total_vol / len(orb),
open_price=sorted_orb[0].open,
)
def _rsi(candles: List[MinuteCandle], period: int = 14) -> Optional[float]:
"""Wrapper: extract closes from MinuteCandle list and delegate to canonical calculate_rsi"""
closes = [c.close for c in candles]
return calculate_rsi(closes, period)
def _bollinger(candles: List[MinuteCandle], period: int = 20) -> Optional[Tuple[float, float, float]]:
"""Wrapper: extract closes from MinuteCandle list and delegate to canonical calculate_bollinger"""
closes = [c.close for c in candles]
return calculate_bollinger(closes, period)
def _atr(candles: List[MinuteCandle], period: int = 14) -> Optional[float]:
"""Wrapper: extract highs/lows/closes from MinuteCandle list and delegate to canonical calculate_atr"""
if len(candles) < period + 1:
return None
highs = [c.high for c in candles]
lows = [c.low for c in candles]
closes = [c.close for c in candles]
return calculate_atr(highs, lows, closes, period)
# ── 진입 신호 ─────────────────────────────────────────────────────────────────
def check_orb_signal(
stock_code: str,
stock_name: str,
candles_1min: List[MinuteCandle],
orb: OpeningRange,
current_price: float,
) -> Optional[DayTradeSignal]:
"""ORB 상향 돌파 매수 신호
조건:
1. 현재가 > ORB 고점 × 1.002 (0.2% 이상 돌파)
2. 최근 1분봉 거래량 ≥ ORB 분당평균 × 1.5 (거래량 폭발)
3. 직전 5분봉이 양봉 (음봉이면 Fake-out 위험)
"""
if not orb or current_price <= 0:
return None
# M4: Gap filter — 시가가 전일종가 대비 4%+ 갭이면 ORB 돌파 위험 과대, 진입 거부
# prev_close 근사: candles_1min 첫 봉의 open 이전 데이터 없으므로 orb.open_price 사용
# orb.open_price = 09:00 시가. 현재가와 시가의 괴리가 4% 초과면 갭 상승 종목으로 간주
if orb.open_price > 0:
_gap_ratio = abs(current_price - orb.open_price) / orb.open_price
if _gap_ratio > 0.04:
logger.debug("ORB skip %s: gap ratio %.2f%% > 4%%", stock_code, _gap_ratio * 100)
return None
# M4: Range width filter — ORB 범위가 너무 좁으면 노이즈 돌파 위험
if orb.low > 0:
_range_width = (orb.high - orb.low) / orb.low
if _range_width < 0.003: # 최소 0.3% 범위
logger.debug("ORB skip %s: range width %.3f%% < 0.3%%", stock_code, _range_width * 100)
return None
if current_price < orb.high * (1 + settings.DAY_TRADE_ORB_BREAKOUT_MARGIN):
return None
# 최근 1분봉 거래량 체크
if not candles_1min:
return None
recent_vol = candles_1min[-1].volume
if recent_vol < orb.avg_vol_per_min * settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT:
return None
# 직전 5분봉 양봉 확인 (5분봉 2개 이상 없으면 보수적으로 차단)
candles_5m = aggregate_to_5min(candles_1min)
if len(candles_5m) < 2:
return None # 데이터 부족 시 진입 차단
if candles_5m[-2].close < candles_5m[-2].open:
return None # 직전 5분봉 음봉 → 스킵
# [P1] ORB 손절: ATR 하한 + 하드 스탑 상한 클램프
atr_orb = _atr(aggregate_to_5min(candles_1min), 14)
hard_stop = current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT) # 상한 클램프
orb_stop = max(orb.low, current_price * (1 - settings.DAY_TRADE_ORB_STOP_LOSS_PCT))
# ATR 기반 하한: 최소 ATR 손절폭 확보 (수수료 커버)
if atr_orb:
atr_floor = current_price - settings.DAY_TRADE_ORB_ATR_FLOOR_MULT * atr_orb
orb_stop = max(orb_stop, atr_floor)
# 상한 클램프: 일일 손실 한도 대비 종목당 최대 한도
stop_loss = max(orb_stop, hard_stop)
risk = current_price - stop_loss
if risk <= 0:
return None # 손절폭이 0 이하면 진입 무의미
# 수수료 고려: 목표 = 진입가 + 2R + 수수료 보전
target = current_price + max(risk * settings.DAY_TRADE_TARGET_MULTIPLIER, current_price * settings.DAY_TRADE_ORB_MARGIN_CHECK)
vol_ratio = recent_vol / orb.avg_vol_per_min
confidence = min(0.85, 0.65 + vol_ratio * 0.05)
return DayTradeSignal(
stock_code=stock_code,
stock_name=stock_name,
strategy="ORB",
entry_price=current_price,
stop_loss=round(stop_loss, 0),
target_price=round(target, 0),
reason=(
f"ORB 상향 돌파: ORB고={orb.high:.0f}, "
f"현재={current_price:.0f}, 거래량={vol_ratio:.1f}배"
),
confidence=round(confidence, 2),
)
def check_vwap_bounce_signal(
stock_code: str,
stock_name: str,
candles_5min: List[MinuteCandle],
vwap: float,
current_price: float,
) -> Optional[DayTradeSignal]:
"""VWAP 지지 반등 매수 신호
조건:
1. 현재가가 VWAP의 -1% ~ 0.5% 구간 (VWAP 근처)
2. 5분봉 RSI(14) ≤ threshold
3. 최근 봉 거래량 ≥ 직전 봉 × vol_mult
4. 최근 5분봉 양봉 전환 (close > open)
"""
if not candles_5min or vwap <= 0 or current_price <= 0:
return None
if not (vwap * settings.DAY_TRADE_VWAP_PRICE_LOWER <= current_price <= vwap * settings.DAY_TRADE_VWAP_PRICE_UPPER):
return None
rsi = _rsi(candles_5min, 14)
if rsi is None or rsi > settings.DAY_TRADE_VWAP_RSI_THRESHOLD:
return None
if len(candles_5min) < 2:
return None
if candles_5min[-1].volume < candles_5min[-2].volume * settings.DAY_TRADE_VOLUME_MULTIPLIER:
return None
# 최근 봉 양봉 확인
last = candles_5min[-1]
if last.close <= last.open:
return None
atr = _atr(candles_5min, 14)
raw_stop = (current_price - settings.DAY_TRADE_VWAP_ATR_MULT * atr) if atr else current_price * (1 - settings.DAY_TRADE_ORB_STOP_LOSS_PCT)
hard_stop_vwap = current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT)
stop_loss = max(raw_stop, hard_stop_vwap, 1.0) # 타이트한 쪽 = 진입가에 가까운 = 높은 가격, 최소 1원 보장
target = (current_price + settings.DAY_TRADE_TARGET_MULTIPLIER * atr) if atr else vwap * 1.03
return DayTradeSignal(
stock_code=stock_code,
stock_name=stock_name,
strategy="VWAP_BOUNCE",
entry_price=current_price,
stop_loss=round(stop_loss, 0),
target_price=round(target, 0),
reason=(
f"VWAP 반등: VWAP={vwap:.0f}, RSI={rsi:.1f}, "
f"현재={current_price:.0f}"
),
confidence=0.65,
)
def check_rsi_bb_signal(
stock_code: str,
stock_name: str,
candles_5min: List[MinuteCandle],
current_price: float,
) -> Optional[DayTradeSignal]:
"""5분봉 RSI + 볼린저 과매도 반등 신호
조건:
1. RSI(14) ≤ threshold (강한 과매도)
2. 종가 ≤ BB 하단 + buffer
3. 직전 봉 대비 거래량 ≥ vol_mult배
4. 최근 봉 양봉 (음봉이면 스킵)
"""
if not candles_5min or current_price <= 0:
return None
rsi = _rsi(candles_5min, 14)
if rsi is None or rsi > settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD:
return None
bb = _bollinger(candles_5min, 20)
if bb is None:
return None
bb_upper, bb_middle, bb_lower = bb
if current_price > bb_lower * settings.DAY_TRADE_RSI_BB_BB_LOWER_BUFFER:
return None
if len(candles_5min) < 2:
return None
if candles_5min[-1].volume < candles_5min[-2].volume * settings.DAY_TRADE_VOLUME_MULTIPLIER:
return None
last = candles_5min[-1]
if last.close <= last.open:
return None
atr = _atr(candles_5min, 14)
raw_stop = (current_price - settings.DAY_TRADE_RSI_BB_ATR_MULT * atr) if atr else current_price * (1 - settings.DAY_TRADE_ORB_STOP_LOSS_PCT)
hard_stop_rsi = current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT)
stop_loss = max(raw_stop, hard_stop_rsi, 1.0) # 타이트한 쪽 = 진입가에 가까운 = 높은 가격, 최소 1원 보장
# 최소 1.5R 보장: bb_middle이 너무 가까우면 R:R 확보를 위해 목표 상향
risk = current_price - stop_loss
target = max(bb_middle, current_price + risk * 1.5) # 1.5R 보장
if target <= current_price: # 방어 로직 (stop_loss ≥ current_price 극단 케이스)
return None
return DayTradeSignal(
stock_code=stock_code,
stock_name=stock_name,
strategy="RSI_BB",
entry_price=current_price,
stop_loss=round(stop_loss, 0),
target_price=round(target, 0),
reason=(
f"5분봉 과매도 반등: RSI={rsi:.1f}, "
f"BB하단={bb_lower:.0f}, 현재={current_price:.0f}"
),
confidence=0.60,
)
# ── 청산 신호 ─────────────────────────────────────────────────────────────────
def check_day_trade_exit(
entry_price: float,
current_price: float,
stop_loss: float,
target_price: float,
highest_since_entry: float,
candles_5min: List[MinuteCandle],
current_time: time,
entry_time: Optional["datetime"] = None,
) -> Optional[DayTradeExit]:
"""데이트레이딩 청산 조건 체크 (우선순위 순)
1. EOD 강제 청산 (15:20 이후)
2. 손절 (현재가 ≤ stop_loss)
3. 시간 손절 — 진입 후 30분간 +0.3% 미달 시 청산
4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2)
5. 트레일링 스탑 (1.5% 수익 후 ATR 트레일)
6. 5분봉 RSI ≥ 70 (과매수 청산)
"""
from datetime import datetime as _dt
import pytz
# 1. EOD 강제 청산
if current_time >= _get_eod_flat():
return DayTradeExit(
action="SELL_ALL",
reason=f"EOD 강제 청산 ({current_time.strftime('%H:%M')} ≥ {_get_eod_flat().strftime('%H:%M')})",
)
# 2. 손절
if current_price <= stop_loss:
loss_pct = (current_price - entry_price) / entry_price * 100
return DayTradeExit(
action="SELL_ALL",
reason=f"손절 {loss_pct:.1f}% (현재={current_price:.0f} ≤ 손절가={stop_loss:.0f})",
)
# 3. [P2] 시간 손절 — 진입 후 설정 시간 경과 시 목표 방향 진행 미미하면 청산
if entry_time is not None:
try:
now_kst = _dt.now(pytz.timezone("Asia/Seoul")).replace(tzinfo=None)
elapsed_min = (now_kst - entry_time).total_seconds() / 60
if elapsed_min >= settings.DAY_TRADE_TIME_STOP_MINUTES:
gain_pct = (current_price - entry_price) / entry_price
if gain_pct < settings.DAY_TRADE_TIME_STOP_MIN_GAIN: # 최소 수익 미달 (수수료 커버 못함)
return DayTradeExit(
action="SELL_ALL",
reason=f"시간 손절: {elapsed_min:.0f}분 경과, 수익={gain_pct*100:.1f}% < {settings.DAY_TRADE_TIME_STOP_MIN_GAIN*100:.1f}%",
)
except Exception:
pass
# 4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2: +2R 시 50% 분할 매도)
if current_price >= target_price:
gain_pct = (current_price - entry_price) / entry_price * 100
return DayTradeExit(
action="SELL_PARTIAL",
reason=f"목표가 달성 +{gain_pct:.1f}% → 50% 분할 매도",
sell_pct=0.50,
)
# 5. 트레일링 스탑 (최소 보유 시간 경과 + 최소 수익률 이상 시 발동)
# [whipsaw 방지] 진입 직후 순간 상승→되돌림에 트레일링이 걸리는 것을 방지
_trailing_allowed = True
if entry_time is not None:
try:
now_kst = _dt.now(pytz.timezone("Asia/Seoul")).replace(tzinfo=None)
elapsed_sec = (now_kst - entry_time).total_seconds()
if elapsed_sec < settings.DAY_TRADE_TRAILING_MIN_HOLD_SEC:
_trailing_allowed = False # 최소 보유 시간(5분) 미경과 → 하드스탑만 적용
except Exception:
pass
if _trailing_allowed:
profit_pct = (highest_since_entry - entry_price) / entry_price
if profit_pct >= settings.DAY_TRADE_TRAILING_TRIGGER:
atr_val = _atr(candles_5min, 14)
if atr_val:
trailing_stop = highest_since_entry - settings.DAY_TRADE_TRAILING_ATR_MULT * atr_val
else:
trailing_stop = highest_since_entry * (1 - settings.DAY_TRADE_TRAILING_DEFAULT)
if current_price <= trailing_stop:
return DayTradeExit(
action="SELL_ALL",
reason=(
f"트레일링 스탑: 최고={highest_since_entry:.0f}, "
f"트레일={trailing_stop:.0f}, 현재={current_price:.0f}"
),
)
# 6. 과매수 청산 (RSI ≥ 70)
rsi = _rsi(candles_5min, 14)
if rsi is not None and rsi >= 70:
gain_pct = (current_price - entry_price) / entry_price * 100
if gain_pct > settings.DAY_TRADE_COST_PCT * 100:
return DayTradeExit(
action="SELL_ALL",
reason=f"과매수 청산: RSI={rsi:.1f}≥70, 수익={gain_pct:.1f}%",
)
return None
# ── 포지션 사이징 ─────────────────────────────────────────────────────────────
def calculate_day_trade_position_size(
total_assets: float,
available_cash: float,
current_day_trade_amount: float,
) -> float:
"""데이트레이딩 포지션 크기 계산
[2026-03-15 CLAUDE.md 정합성 수정]
- 종목당 최대: 총자산 × 4% (CLAUDE.md 5.1)
- 전체 데이트레이딩 합산: 총자산 × 25% (CLAUDE.md 8.2)
- 동시 최대 3종목 (MAX_POSITIONS)
"""
# [이슈6 수정] 모듈 레벨 상수 대신 동적 접근자 사용
total_dt_budget = total_assets * _get_max_capital_ratio() # 전체 DT 한도 (25%)
remaining_dt = total_dt_budget - current_day_trade_amount # 남은 DT 한도
per_position_budget = total_assets * _get_max_day_trade_ratio() # 종목당 한도 (4%, CLAUDE.md 5.1)
return max(0.0, min(per_position_budget, remaining_dt, available_cash))
def is_daily_loss_exceeded(
total_assets: float,
realized_pnl_today: float,
) -> bool:
if total_assets <= 0:
return False
# realized_pnl_today가 0이면 손실 없음 → 한도 미초과
if realized_pnl_today >= 0:
return False
# [이슈6 수정] 동적 접근자로 런타임 설정값 반영
return (realized_pnl_today / total_assets) <= _get_daily_loss_limit()