""" 데이트레이딩 전략 (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 from app.core.log_throttle import warn_throttled # noqa: E402 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 # [2026-10-01] 시간손절 사유 접두어 — 사유 문자열 생성과 판별이 이 상수 하나를 공유한다. # 시간손절은 "진입 후 N분간 목표 방향 진행 미미"를 정리하는 **기회비용 청산**이지 # 손절선 이탈이 아니다. 이것까지 loss_tracker(3연패 → CONSECUTIVE_LOSS_BAN_DAYS 진입금지)에 # 집계하면 소폭 손실 시간손절 3회만으로 그 종목의 스윙 진입까지 42일간 막힌다. # 판별은 exit_service 가 청산 사유로 한다(_execute_exit 시그니처 불변 유지). TIME_STOP_REASON_PREFIX = "시간 손절" def is_time_stop_exit_reason(reason: Optional[str]) -> bool: """청산 사유가 DT 시간손절인지 판별 (check_day_trade_exit 가 만든 사유 기준).""" return bool(reason) and str(reason).startswith(TIME_STOP_REASON_PREFIX) # ── 분봉 파싱 헬퍼 ──────────────────────────────────────────────────────────── 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 _last_session_candles(candles: List[MinuteCandle]) -> List[MinuteCandle]: """마지막 봉과 **같은 영업일자**의 봉만 남긴다 (세션 경계 차단, 순수 함수). 진입 판정 경로의 시계열에는 지표 워밍업용 전일 봉이 섞여 있다 (`korea_investment_api.get_minute_chart_full_day_v2(include_prev_day=True)`). RSI·BB 처럼 연속 시계열이 필요한 지표는 그대로 써도 되지만, **거래량 비교는 같은 세션 안에서만 의미가 있다.** 예컨대 09:30 에 5분봉 `[-3]` 이 전일 마지막 5분봉(15:15~15:19, 종가 동시호가 물량)이면 당일 개장봉과의 비교가 등가가 아니고, 거래량 게이트가 개장 직후 자동 통과하거나 반대로 영구 탈락한다. 영업일자 필드가 비어 있는 응답(목/레거시)은 기존 동작을 유지하기 위해 그대로 통과시킨다. """ if not candles: return [] last_date = getattr(candles[-1], "date", "") or "" if not last_date: return list(candles) return [c for c in candles if (getattr(c, "date", "") or "") == last_date] # ── 지표 계산 ───────────────────────────────────────────────────────────────── 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 아님. ⚠️ **세션 경계를 먼저 끊는다** (`_last_session_candles`) — 2026-09-30 수정. 진입 판정 경로의 시계열에는 지표 워밍업용 **전일 봉**이 섞여 있다 (`get_minute_chart_full_day_v2(include_prev_day=True)`). TR 은 `max(H-L, |H-C_prev|, |L-C_prev|)` 이라 **당일 첫 봉의 TR = 오버나이트 갭**이고, `atr_sma` 가 마지막 `period` 개 TR 의 단순평균이므로 당일 5분봉이 14개 쌓이기 전(대략 10:10 이전)에는 갭 TR + 전일 오후 TR 이 윈도에 남는다. 그 구간이 바로 DT 진입이 가장 많이 나는 구간이다. 영향은 임계값이 아니라 **산출값**이었다: - `check_vwap_bounce_signal` / `check_rsi_bb_signal` 의 `target = 현재가 + DAY_TRADE_TARGET_MULTIPLIER × ATR` 은 **클램프가 없어** 부푼 ATR 이 1.5R 목표를 그대로 멀리 밀었다. - `raw_stop = 현재가 − ATR_MULT × ATR` 은 보통 최소손절폭(-2%)이 이기지만 고변동 종목에서는 -2% 를 뚫고 손절이 깊어졌다. - 청산 경로(`daytrading/exit_service`)는 당일 전용 분봉을 쓰므로 트레일링 ATR 은 깨끗했다 → **진입은 갭 ATR, 청산은 당일 ATR** 로 기준이 엇갈렸다. 여기서 한 번 끊으면 네 호출부(ORB 손절·VWAP·RSI_BB·트레일링) 전부가 같은 기준을 쓴다. 당일 봉이 `period+1` 개 미만이면 `None` 이고, 호출부는 기존 고정 폴백 (`DAY_TRADE_REVERSION_STOP_LOSS_PCT` 등)을 쓴다 — 워밍업 도입 이전과 같은 동작이다. **임계값은 하나도 바뀌지 않는다.** RSI(14)/BB(20) 는 연속 시계열이 필요한 판정 게이트이므로 워밍업을 그대로 받는다(`_rsi`/`_bollinger` 는 손대지 않았다). """ session = _last_session_candles(candles) if len(session) < period + 1: return None highs = [c.high for c in session] lows = [c.low for c in session] closes = [c.close for c in session] return calculate_atr(highs, lows, closes, period) # ── 진입 신호 ───────────────────────────────────────────────────────────────── def _overnight_gap_pct(candles: List[MinuteCandle]) -> Optional[float]: """오버나이트 갭률(%) = (당일 첫 봉 시가 − 전일 마지막 봉 종가) / 전일 종가 × 100. **진단용 순수 함수다 — 어떤 게이트도 이 값을 쓰지 않는다.** 진입 판정 경로의 시계열에는 지표 워밍업용 전일 봉이 섞여 있으므로 (`get_minute_chart_full_day_v2(include_prev_day=True)`) 벽시계나 별도 조회 없이 데이터만으로 계산된다. 전일 봉이 없거나 날짜 필드가 비면 `None`. 용도(2026-09-30): 장중 5분봉 RSI 는 워밍업 시계열을 쓰므로 **오버나이트 갭이 종가 차분 한 개로 RSI 에 들어간다**(실측: 3% 갭하락 → 워밍업 RSI 52.7 → 30.3). 이는 차트 플랫폼 표준 동작이고 평균회귀가 과매도를 사는 의도와 방향이 맞아 **그대로 유지하기로 결정**됐으나, 의도한 설계가 아니었으므로 관찰 대상이다. 진입 `reason` 에 이 값을 남겨 두면 나중에 원장(`trade_history.entry_reason`)만으로 "갭 하락 종목에 쏠렸는가"를 집계할 수 있다. """ if not candles: return None today = _last_session_candles(candles) if not today: return None today_date = getattr(today[0], "date", "") or "" if not today_date or len(today) == len(candles): return None # 전일 봉이 없다 / 날짜 필드 없음 prev = [c for c in candles if (getattr(c, "date", "") or "") != today_date] if not prev: return None prev_close = prev[-1].close first_open = today[0].open if prev_close <= 0 or first_open <= 0: return None return (first_open - prev_close) / prev_close * 100.0 def _diag_suffix(candles: List[MinuteCandle], atr: Optional[float]) -> str: """진입 `reason` 뒤에 붙는 **진단 정보** 문자열 (게이트 아님). - `갭=` : `_overnight_gap_pct` (RSI 갭 섭취 관찰용, 없으면 생략) - `ATR=` : 손절·목표 산출에 실제로 쓰인 ATR. `None` 이면 폴백 손절이 쓰였음을 명시한다 — 09:30~10:10 구간은 당일 5분봉 15개 미만이라 ATR 이 없고 `DAY_TRADE_REVERSION_STOP_LOSS_PCT` 폴백이 적용된다(CLAUDE.md §7 예외). 값만 넣는다. 임계값을 문자열에 하드코딩하지 않는다(§4). """ parts = ["ATR=없음→폴백손절" if atr is None else f"ATR={atr:.0f}"] gap = _overnight_gap_pct(candles) if gap is not None: parts.append(f"갭={gap:+.2f}%") return " [진단 " + ", ".join(parts) + "]" 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 + DAY_TRADE_ORB_BREAKOUT_MARGIN) 2. **마지막 완성 1분봉** 거래량 ≥ ORB 분당평균 × DAY_TRADE_ORB_BREAKOUT_VOL_MULT 3. 직전 5분봉이 양봉 (음봉이면 Fake-out 위험) [2026-09-29] 2번은 `candles_1min[-1]`(형성 중 봉)을 쓰고 있었다. 30초 주기 스캔의 사이클 대부분에서 마지막 1분봉은 절반만 채워져 있어 **실효 요구가 임계값의 약 2배**가 됐다(volume_surge_ratio 의 F7 과 같은 클래스의 버그). 비교 대상만 마지막 **완성봉** 으로 고치고 임계값은 건드리지 않는다. `orb.avg_vol_per_min` 이 당일 기준이므로 분자도 당일이어야 한다 — `_last_session_candles()` 로 세션을 고정한다. """ if not orb or current_price <= 0: return None # [M3 2026-10-02] 오버나이트 갭(전일 종가 대비)은 **세션을 자르기 전** 시계열에서만 # 계산된다. 플래그 OFF 면 계산하지 않는다(현행 판정 유지). _max_gap = float(settings.DAY_TRADE_ORB_MAX_GAP_PCT) _overnight_gap = ( _overnight_gap_pct(candles_1min) if settings.DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED else None ) # 전일 워밍업 봉이 섞여 들어와도 ORB 판정(갭·거래량·양봉·ATR)은 당일 봉만 쓴다 candles_1min = _last_session_candles(candles_1min) # M4: Gap filter. # ⚠️ 현행(기본) 판정은 **오버나이트 갭이 아니다** — |현재가 − 당일 09:00 시가| / 시가 로 # "시가 이후 장중 이동폭"을 본다(§7 문서의 '갭 4%↑ 거부'와 정의가 다름). # DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED=True 면 §7 대로 전일 종가 대비 갭으로 판정하고, # 전일 종가를 확보하지 못하면 이 현행 판정으로 폴백한다(warning). if settings.DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED and _overnight_gap is not None: if abs(_overnight_gap) / 100.0 > _max_gap: logger.info( "ORB skip %s: overnight gap %+.2f%% > %.2f%%", stock_code, _overnight_gap, _max_gap * 100, ) return None else: if settings.DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED: warn_throttled( logger, "dt_strategy:orb_overnight_gap_unavailable", "[DayTrade] ORB 오버나이트 갭 산출 불가(전일 봉 없음) — 당일시가 대비 판정으로 폴백", ) if orb.open_price > 0: _gap_ratio = abs(current_price - orb.open_price) / orb.open_price if _gap_ratio > _max_gap: logger.debug( "ORB skip %s: gap ratio %.2f%% > %.2f%%", stock_code, _gap_ratio * 100, _max_gap * 100, ) return None # M4: Range width filter — ORB 범위가 너무 좁으면 노이즈 돌파 위험 _min_range = float(settings.DAY_TRADE_ORB_MIN_RANGE_PCT) if orb.low > 0: _range_width = (orb.high - orb.low) / orb.low if _range_width < _min_range: logger.debug( "ORB skip %s: range width %.3f%% < %.3f%%", stock_code, _range_width * 100, _min_range * 100, ) return None if current_price < orb.high * (1 + settings.DAY_TRADE_ORB_BREAKOUT_MARGIN): return None # 거래량 체크 — 마지막 **완성** 1분봉([-2]) 기준. 형성 중 봉([-1])은 부분 거래량이라 # 임계값을 실질적으로 부풀린다. 완성봉이 없으면(당일 봉 2개 미만) 게이트 미통과. if len(candles_1min) < 2: return None recent_vol = candles_1min[-2].volume if orb.avg_vol_per_min <= 0: return None 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}배" f"(≥{settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT}배, 완성봉 기준)" # ORB 경로의 시계열은 당일 전용이라 오버나이트 갭을 계산할 수 없다 # (_diag_suffix 가 갭을 생략한다). ATR 만 남는다. + _diag_suffix(candles_1min, atr_orb) ), confidence=round(confidence, 2), ) def _last_completed_candle(candles_5min: List[MinuteCandle]) -> Optional[MinuteCandle]: """**당일** 마지막 완성 5분봉. 없으면 None (순수 함수). [2026-09-30] `aggregate_to_5min` 은 **형성 중인 5분 버킷도 하나의 봉으로 만든다.** 따라서 `candles_5min[-1]` 로 양봉/음봉을 판정하면 30초 주기 스캔에서 같은 버킷을 약 10회 보며 **같은 시장 상태에 사이클마다 다른 답**이 나온다(버킷 초반에 음봉이면 차단, 후반에 양봉이 되면 통과). 재현성이 없는 게이트다. F7(2026-09-29)이 거래량 게이트를 완성봉으로 고칠 때 **양봉 판정은 빠졌다.** `volume_surge_ratio()` 가 비교하는 봉과 **같은 봉**(`session[-2]`)을 돌려주므로 "거래량이 튄 그 봉이 양봉인가"라는 원래 의도가 성립한다 — `check_orb_signal` 은 이미 `candles_5m[-2]` 로 그렇게 하고 있었다(`:407`). 세션 경계도 함께 끊는다(`_last_session_candles`): 진입 경로의 시계열에는 지표 워밍업용 전일 봉이 섞여 있어(`include_prev_day=True`) 개장 직후에는 마지막 완성봉이 **전일 종가 동시호가 봉**이 된다. 그걸로 양봉 판정을 하면 게이트가 전일 결과로 열린다(C1 과 같은 함정). 완성봉이 없으면(당일 봉 2개 미만) `None` — 호출부는 **미통과**로 닫는다. 데이터 부족을 통과 쪽으로 열지 않는다. """ session = _last_session_candles(candles_5min) if len(session) < 2: return None return session[-2] def volume_surge_ratio(candles_5min: List[MinuteCandle]) -> Optional[float]: """직전 거래량 증가 배율 — **완성된 5분봉끼리** 비교한 값. [F7 2026-09-28] 기존 게이트는 ``candles_5min[-1].volume >= candles_5min[-2].volume * DAY_TRADE_VOLUME_MULTIPLIER`` 였다. 그런데 ``aggregate_to_5min`` 은 **형성 중인 5분 버킷도 하나의 봉으로 만든다.** 즉 좌변은 "아직 채워지는 중인 봉의 부분 거래량"이고 우변은 "완성된 봉의 전체 거래량"이라, 실제로 거래량이 1.3배로 늘어난 구간에서도 **버킷의 마지막 1분 남짓에만** 조건이 성립했다. 30초 주기 스캔에서 5분 버킷은 약 10사이클에 걸치므로 ~80% 사이클이 구조적으로 전멸했다. 비교 대상만 고친다(임계값 1.3 은 그대로): 마지막 **완성봉**([-2])을 그 직전 완성봉([-3])과 비교한다. 두 항이 모두 전체 5분치라 비교가 등가이고, 벽시계에 의존하지 않아 순수 함수로 남는다. [2026-09-29] 진입 경로의 시계열에 전일 워밍업 봉이 포함되므로, 비교는 `_last_session_candles()` 로 **마지막 봉과 같은 세션**에 한정한다. 세션을 넘겨 비교하면 전일 종가 동시호가 물량과 당일 개장봉을 견주게 되어 게이트가 무의미해진다. Returns: float | None: 배율. **당일** 완성봉이 2개 미만이거나 분모가 0이면 None (= 게이트 미통과. 데이터 부족은 통과 쪽으로 열지 않는다) """ session = _last_session_candles(candles_5min) if len(session) < 3: return None prev_completed = session[-3].volume last_completed = session[-2].volume if prev_completed <= 0: return None return last_completed / prev_completed 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. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외) 4. **그 완성봉이 양봉** (close > open) — 형성 중 버킷 제외, 당일 봉만 (2026-09-30) """ 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 # [F7] 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 (임계값 무변경) vol_ratio = volume_surge_ratio(candles_5min) if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER: return None # 양봉 확인 — **당일 마지막 완성봉**(거래량 게이트가 본 그 봉). 형성 중 버킷을 # 보면 사이클마다 답이 바뀐다(_last_completed_candle docstring). 임계값·조건 정의는 # 그대로이고 '어느 봉을 보는가'만 고친다. last = _last_completed_candle(candles_5min) if last is None or 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원 보장 # [M-1 2026-09-30] **1.5R 하한 보장** — RSI_BB 경로와 같은 방식(`max(기존목표, 진입가 # + risk × TARGET_MULTIPLIER)`)으로 맞춘다. 새 임계값은 없다: 두 값 중 큰 쪽을 고를 뿐이다. # # 고치지 않으면 `target_price` 가 위험보다 작아진다 — 이 값은 # `daytrading/exit_service` 를 통해 **1.5R 분할매도 트리거로 실제 쓰인다**(§7). # · ATR 폴백 구간(09:30~약 10:10, 당일 5분봉 15개 미만): 손절 -3.5%(폴백) vs # 목표 `vwap×1.03`(≈+3%) → **위험 > 보상** # · ATR 이 있는 구간도 마찬가지다: `risk = max(1.5×ATR, 2%)` 인데(최소 손절폭 보장의 # `min()` 때문에 손절이 -2% 쪽으로 느슨해진다) 목표는 `1.5×ATR` 이라 # **1.5×ATR < 2% 인 저변동 종목에서는 R:R 이 0.2~0.5** 로 떨어진다. # 실측: 진입 10,120 / ATR 29 → 손절 9,918(-2.0%) / 목표 10,164(+0.43%) = **0.21R**. # §7 은 "1.5R 도달 시 50% 분할"을 규약으로 못박고 있으므로 이는 규약 복귀다. _target_base = ( (current_price + settings.DAY_TRADE_TARGET_MULTIPLIER * atr) if atr else vwap * settings.DAY_TRADE_VWAP_TARGET_FALLBACK_MULT ) risk = current_price - stop_loss target = max(_target_base, current_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER) 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}(≤{settings.DAY_TRADE_VWAP_RSI_THRESHOLD:.0f}), " f"현재={current_price:.0f}, 거래량={vol_ratio:.2f}배" f"(≥{settings.DAY_TRADE_VOLUME_MULTIPLIER}배, 완성봉 기준)" + _diag_suffix(candles_5min, atr) ), 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. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외) 4. **그 완성봉이 양봉** (음봉이면 스킵) — 형성 중 버킷 제외, 당일 봉만 (2026-09-30) """ 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 # [F7] 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 (임계값 무변경) vol_ratio = volume_surge_ratio(candles_5min) if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER: return None # 양봉 확인 — VWAP 경로와 동일하게 당일 마지막 완성봉 기준 last = _last_completed_candle(candles_5min) if last is None or 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 # [L1 2026-10-02] 리터럴 1.5 → DAY_TRADE_TARGET_MULTIPLIER(1.5, 같은 값·같은 의미 "목표=진입+risk×R") target = max(bb_middle, current_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER) 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}(≤{settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD:.0f}), " f"BB하단={bb_lower:.0f}, 현재={current_price:.0f}, " f"거래량={vol_ratio:.2f}배(≥{settings.DAY_TRADE_VOLUME_MULTIPLIER}배, 완성봉 기준)" + _diag_suffix(candles_5min, atr) ), 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: warn_throttled(logger, "day_trading_strategy:732", 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. 시간 손절 — 진입 후 DAY_TRADE_TIME_STOP_MINUTES 경과 이후 수익률 < DAY_TRADE_TIME_STOP_MIN_GAIN 이면 청산 4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2) 5. 트레일링 스탑 (수익률 ≥ DAY_TRADE_TRAILING_TRIGGER 이후 고점 − ATR×DAY_TRADE_TRAILING_ATR_MULT) 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"{TIME_STOP_REASON_PREFIX}: {elapsed_min:.0f}분 경과, 수익={gain_pct*100:.2f}% < {settings.DAY_TRADE_TIME_STOP_MIN_GAIN*100:.2f}%", ) # 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 ≥ DAY_TRADE_RSI_EXIT_OVERBOUGHT, 기본 70) rsi = _rsi(candles_5min, 14) _rsi_ob = float(settings.DAY_TRADE_RSI_EXIT_OVERBOUGHT) if rsi is not None and rsi >= _rsi_ob: 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}≥{_rsi_ob:g}, 수익={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()