""" 데이트레이딩 전략 (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 # §8.5-2 포지션 사이징 단일 출처 (순수 계산 모듈) from app.services.sizing_core import budget_by_ratios 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: MinuteCandle → closes 추출 후 canonical calculate_rsi 위임. 알고리즘: Wilder 평활 RSI (indicators_core.rsi_wilder) — 5분봉도 일봉과 동일 방식. """ 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: MinuteCandle → closes 추출 후 canonical calculate_bollinger 위임. 알고리즘: SMA 중앙선 + 모표준편차 2σ (indicators_core.bollinger_sma, num_std=2.0). """ closes = [c.close for c in candles] return calculate_bollinger(closes, period) def _atr(candles: List[MinuteCandle], period: int = 14) -> Optional[float]: """Wrapper: MinuteCandle → highs/lows/closes 추출 후 canonical calculate_atr 위임. 알고리즘: TR 의 단순이동평균 (indicators_core.atr_sma) — Wilder 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) 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) # 최소 손절폭 보장: 노이즈 손절 방지 min_stop = current_price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT) stop_loss = min(orb_stop, min_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_REVERSION_STOP_LOSS_PCT) # 최소 손절폭 보장: 노이즈 손절 방지 min_stop = current_price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT) stop_loss = min(raw_stop, min_stop) stop_loss = max(stop_loss, 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_REVERSION_STOP_LOSS_PCT) # 최소 손절폭 보장: 노이즈 손절 방지 min_stop = current_price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT) stop_loss = min(raw_stop, min_stop) stop_loss = max(stop_loss, 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 _elapsed_minutes(entry_time) -> Optional[float]: """진입 후 경과 시간(분). 값이 신뢰할 수 없으면 None. [2026-09-23] DB에서 복원한 포지션의 entry_time 이 UTC(예전 기본값)면 KST now 와 비교 시 경과가 +540분으로 부풀어, 재배포 직후 '시간 손절'이 즉시 발동해 포지션이 몇 분 만에 청산됐다(산일전기 565분·미스토홀딩스 568분 = 실제 25·28분 + 9시간). 음수이거나 정규장 길이(09:00~15:30 = 390분)를 넘는 값은 시각 기준 불일치로 보고 무시한다. """ if entry_time is None: return None try: from app.core.timeutil import now_kst as _now_kst now_kst = _now_kst() elapsed = (now_kst - entry_time).total_seconds() / 60 except Exception as e: logger.debug(f"[DayTradeExit] 경과 시간 계산 실패: {e}") return None if elapsed < 0 or elapsed > 390: logger.warning( f"[DayTradeExit] entry_time 기준 불일치 추정 — 경과 {elapsed:.0f}분 무시 " f"(entry_time={entry_time})" ) return None return elapsed 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] 시간 손절 — 진입 후 설정 시간 경과 시 목표 방향 진행 미미하면 청산 elapsed_min = _elapsed_minutes(entry_time) if elapsed_min is not None and 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}%", ) # 4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2: +2R 시 50% 분할 매도) # [FIX] target_price<=0 방어 — 0이면 즉시 조건 충족되어 오매도되는 것을 방지 if target_price > 0 and 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 elapsed_min is not None and elapsed_min * 60 < settings.DAY_TRADE_TRAILING_MIN_HOLD_SEC: _trailing_allowed = False # 최소 보유 시간(5분) 미경과 → 하드스탑만 적용 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 수정] 모듈 레벨 상수 대신 동적 접근자 사용 (한도 값의 출처는 settings) # [§8.5-2 통합 2026-09-27] 3중 min 식을 sizing_core.budget_by_ratios 로 위임. # 비율 자체는 여전히 settings 소유(_get_* 접근자) — 수식만 공유한다. return budget_by_ratios( total_assets=total_assets, available_cash=available_cash, current_allocation=current_day_trade_amount, max_per_position=_get_max_day_trade_ratio(), # 종목당 한도 (DAY_TRADE_MAX_CAPITAL_RATIO) max_strategy_ratio=_get_max_capital_ratio(), # 전체 DT 한도 (DAY_TRADE_MAX_RATIO_DT) ) 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()