""" kis_trader/strategies/breakout.py — 돌파 매매 (Breakout) ============================================================= 전제 (SCAN vs TRIGGER): * [SCAN] HTS 조건식은 **널넬하게** — 후보 풀만 확보 (등락·거래량·돌파를 HTS에서 약하게 걸거나 일부 조건 제거). ETF/관리종목 제외 등 구조 필터만 HTS에 둠. * [TRIGGER] ``BREAKOUT_SKIP_HTS_SCAN_DUPES=true`` (kiwoom 기본): 일봉 돌파·등락·거래량 중복 생략 — 진입 타이밍·호가·휩쏘만 검사. * [TRIGGER] ``BREAKOUT_SKIP_HTS_SCAN_DUPES=false``: 등락·고가돌파·회전율·vol_mult 등 1분봉 TRIGGER 전체 검사. * 진입은 '장 초반 골든타임' 한정 (09:00 ~ BREAKOUT_GOLDEN_END_HM). 오후장 돌파는 거의 속임수(휩쏘) → 봇이 쫓지 않는다. 매수 필터 (네 가지 모두 만족): 1) 저항선 돌파 : 최근 ``BREAKOUT_LOOKBACK_MIN`` 분봉의 고가 > 최대고가(전고점) 2) 거래량(1차) : 1분 거래량/유통주식 ≥ ``BREAKOUT_MIN_TURNOVER_1M_PCT``% (키움 ka10001 ``dstr_stk``) 2b) 거래량(보조): ``BREAKOUT_VOL_MULT``>0 일 때만 — 돌파봉 ≥ 직전 ``BREAKOUT_VOL_WIN`` 평균×배수 3) 상승 확증 : 직전 봉 종가 대비 현재가 상승 & 종가 > 시가 4) 이격 과열 X : 당일 상승률 ≤ BREAKOUT_MAX_DAILY_CHG (기본 15%) 5) 최소 거래대금: 1분봉 거래량×가격 ≥ BREAKOUT_MIN_BAR_TRADE_VALUE_KRW (0=OFF) 매도 (돌파 전용 — 익절 우선, 모멘텀·스캘핑 V4 어깨 선행과 순위 다름): * 1순위 익절 : 진입가 × (1 + BREAKOUT_TAKE_PROFIT_PCT) (기본 +5%) * 2순위 어깨컷 : 고가 ≥ 진입×(1+SHOULDER_MIN_HIGH) 무장 후, 고점 대비 SHOULDER_CUT 되돌림 * 3순위 손절 : 진입가 × (1 + BREAKOUT_STOP_LOSS_PCT) (기본 -2%) * 4순위 트레일링 : 최고가 대비 BREAKOUT_TRAIL_PCT 하락 (기본 1.5%, 어깨 미무장 시 보조) * EOD 강제청산 (15:15 이후 전량) * 백테 청산: ws_ticks 시간순 (``resolve_backtest_sell``) → OHLC 폴백 기본 OFF 주문 집행은 모두 ``OrderManager.place()`` 경유 → ODNO·실잔고검증·종목락 공유. """ from __future__ import annotations import time from datetime import datetime as dt from typing import Any, Dict, List, Optional, Tuple from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int from ..utils.logger import get_logger _bo_logger = get_logger("kis_trader.breakout") from ..utils.position_sizing import invest_qty_for_price from ..engine.ema_trend_filter import eval_ema_uptrend_reject from ..engine.tick_exit_common import ( backtest_sell_slip_pct, backtest_tick_poll_ms, collect_minute_ticks, resolve_backtest_sell, ) from ..engine.atr_series import compute_atr_series from ..engine.whipsaw_filter import whipsaw_reject_for_signal from ..engine.orderbook_filter import orderbook_reject_for_entry from ..engine.program_filter import program_reject_for_entry from ..share.stock_share import share_denom_for_code from .base import BaseStrategy, is_strategy_eod_bar def _to_bool(v: Any, default: bool = True) -> bool: if v is None: return default if isinstance(v, bool): return v s = str(v).strip().lower() if s in ("1", "true", "t", "y", "yes", "on"): return True if s in ("0", "false", "f", "n", "no", "off", ""): return False return default def resolve_breakout_skip_hts_scan_dupes(r: Optional[Dict[str, Any]] = None) -> bool: """ HTS breakout SCAN(kiwoom_condition) 사용 시 TRIGGER 중복 필터 생략 여부. - ``BREAKOUT_SKIP_HTS_SCAN_DUPES`` 명시(true/false) → 그대로 - 미설정 → ``BREAKOUT_UNIVERSE_SOURCE`` 가 condition/kiwoom_condition 이면 True """ if r is None: try: from kis_trader.utils.env import get_strategy_env_dict r = get_strategy_env_dict("BREAKOUT") or {} except Exception: r = {} raw = r.get("BREAKOUT_SKIP_HTS_SCAN_DUPES") if raw is not None and str(raw).strip() != "": return _to_bool(raw, True) # 엔진 defaults 에 이미 해석된 bool 이 있으면 universe fallback 금지 if "skip_hts_scan_dupes" in r and r.get("skip_hts_scan_dupes") is not None: return _to_bool(r.get("skip_hts_scan_dupes"), False) src = str(r.get("BREAKOUT_UNIVERSE_SOURCE") or "condition").strip().lower() return src in ("kiwoom_condition", "condition") def _confirmed_candles_only(candles: List[Dict[str, Any]]) -> List[Dict[str, Any]]: confirmed = [c for c in candles if _to_bool(c.get("is_confirmed", 1), True)] return confirmed if confirmed else list(candles) def _golden_end_hm_parts(raw: Any) -> Tuple[int, int]: """골든타임 종료 시각 — env ``10:30`` / ``1030`` / HHMM 정수 모두 허용.""" try: s = str(raw or "").strip() if ":" in s: hh, mm = s.split(":", 1) return int(hh), int(mm) if s.isdigit() and len(s) >= 3: hm = int(s[:4]) if len(s) >= 4 else int(s) return hm // 100, hm % 100 except Exception: pass return 10, 30 # ── 1회 최대 손실 / 슬롯 금액 (백테·탐색·웹 공통) ───────────────────────────── # env 에 99_999_999 등 “금액손실컷 OFF” 가 들어있으면 포지션이 수억~수십억으로 부풀어 # PF·총손익이 말이 안 되게 나옴 → 1천만 이상은 실전 기본 20만으로 정규화. MAX_LOSS_OFF_THRESHOLD_KRW = 10_000_000 DEFAULT_BREAKOUT_MAX_LOSS_KRW = 200_000 def normalize_breakout_max_loss_krw(val: Any, default: int = DEFAULT_BREAKOUT_MAX_LOSS_KRW) -> int: try: m = int(float(val)) except (TypeError, ValueError): return int(default) if m <= 0 or m >= MAX_LOSS_OFF_THRESHOLD_KRW: return int(default) return m def _breakout_shoulder_ratios(params: Dict[str, Any]) -> Tuple[float, float]: """params/env의 어깨 비율(0.02=2%) — 엔진·백테·실매 공용. 어깨컷은 '돌파 러너 보호용' 이다. 기본값을 스캘프급(0.3%/0.2%)에서 무장 2% / 되돌림 1% 로 올려, 진입 직후 정상 출렁임에 털리지 않고 +5% 익절(BREAKOUT_TAKE_PROFIT_PCT)까지 달릴 공간을 준다. (env BREAKOUT_SHOULDER_* 또는 파라서치 적용값이 있으면 그게 우선) """ smh = params.get("shoulder_min_high") if smh is None: smh = get_env_float("BREAKOUT_SHOULDER_MIN_HIGH_PCT", 0.02) sc = params.get("shoulder_cut_pct") if sc is None: sc = get_env_float("BREAKOUT_SHOULDER_CUT_PCT", 0.01) return max(0.0, float(smh)), max(0.0, float(sc)) def _breakout_entry_atr_from_candles( candles: List[Dict[str, Any]], period: int, ) -> float: """진입 시점 변동성(ATR, RMA) — 마지막 확정 ATR 값. 백테·실매 공용. ATR(Average True Range)은 '최근 변동성'을 가격 단위로 나타내는 지표다. 값이 클수록 그 종목이 위아래로 크게 출렁였다는 뜻 → 손절폭도 그만큼 넓혀야 노이즈(휩쏘)에 안 털린다. 계산 불가(데이터 부족 등) 시 0.0 → 고정손절 폴백. """ try: n = int(period) if not candles or len(candles) < n + 1: return 0.0 atrs = compute_atr_series(candles, n) for a in reversed(atrs): if a is not None and float(a) > 0.0: return float(a) except Exception: pass return 0.0 def _breakout_sl_line( entry: float, sl_pct: float, position: Dict[str, Any], params: Dict[str, Any], ) -> float: """손절선 계산 — sl_mode='atr' 이면 변동성(ATR) 비례 동적 손절, 아니면 고정%(기존). 동적 손절(%) = clamp( ATR×배수 / 진입가 , 하한% , 상한% ) · 하한(atr_sl_min_pct): 변동성이 너무 작아 손절이 과도하게 타이트해지는 것 방지(휩쏘 방어) · 상한(atr_sl_max_pct): 비정상 급변동에서 손절이 너무 넓어져 큰 손실 보는 것 방지 entry_atr 가 없거나(0) sl_mode!=atr 이면 기존 고정% 손절(sl_pct, 음수)로 폴백 → 회귀 불변. """ sl_mode = str(params.get("sl_mode", "fixed") or "fixed").strip().lower() entry_atr = float(position.get("entry_atr") or 0.0) if sl_mode == "atr" and entry_atr > 0.0 and entry > 0.0: mult = float(params.get("atr_sl_mult", 2.0) or 2.0) sl_frac = (entry_atr * mult) / entry sl_min = abs(float(params.get("atr_sl_min_pct", 0.8) or 0.8)) / 100.0 sl_max = abs(float(params.get("atr_sl_max_pct", 6.0) or 6.0)) / 100.0 if sl_max < sl_min: sl_max = sl_min sl_frac = max(sl_min, min(sl_max, sl_frac)) return entry * (1.0 - sl_frac) # 기존 고정% 손절 (sl_pct 음수) — sl_mode='fixed' 또는 ATR 데이터 없을 때 return entry * (1 + sl_pct) def _breakout_ratchet_tiers(params: Dict[str, Any]) -> List[Tuple[float, float]]: """래칫형 단계 트레일 정의 파싱 → [(수익비율, 컷비율), ...] (수익 오름차순). 수익 구간이 올라갈수록 되돌림 컷을 좁혀 이익을 단계적으로 잠그는 트레일. 형식(env/params 모두 %단위 문자열): ``"2:1.5,5:1.0,8:0.7"`` → +2% 수익부터 1.5% 되돌림컷, +5% 부터 1.0%, +8% 부터 0.7%. 비어 있으면 [] (= 래칫 OFF, 기존 단일 어깨컷 사용). 기본 OFF. """ raw = params.get("ratchet_tiers") if raw is None: raw = get_env_from_db("BREAKOUT_RATCHET_TIERS", "") # 리스트로 직접 전달된 경우도 허용 if isinstance(raw, (list, tuple)): pairs = list(raw) else: s = str(raw or "").strip() if not s: return [] pairs = [] for chunk in s.split(","): chunk = chunk.strip() if not chunk or ":" not in chunk: continue g, c = chunk.split(":", 1) pairs.append((g, c)) tiers: List[Tuple[float, float]] = [] for g, c in pairs: try: gain = abs(float(g)) / 100.0 # % → 비율 cut = abs(float(c)) / 100.0 except (TypeError, ValueError): continue if gain <= 0 or cut <= 0: continue tiers.append((gain, cut)) tiers.sort(key=lambda x: x[0]) return tiers def _breakout_max_hold_bars(params: Dict[str, Any]) -> int: """시간컷: 최대 보유 분(=1분봉 개수). 0=OFF(기본). env BREAKOUT_MAX_HOLD_BARS.""" v = params.get("max_hold_bars") if v is None: v = get_env_int("BREAKOUT_MAX_HOLD_BARS", 0) try: return max(0, int(float(v))) except (TypeError, ValueError): return 0 def _bo_dt_to_minutes(s: Any) -> Optional[int]: """캔들시각/매수시각 → epoch 분(minute). 실매·백테 공통 파서.""" if s is None: return None txt = str(s).strip() if not txt: return None try: from kis_trader.utils.trade_time import parse_trade_datetime d = parse_trade_datetime(txt) return int(d.timestamp() // 60) except Exception: return None def _breakout_minutes_held(position: Dict[str, Any], current_candle: Dict[str, Any]) -> Optional[int]: """진입 이후 경과 분 — 시간컷(max_hold) 판정용. 산출 불가 시 None.""" e = _bo_dt_to_minutes(position.get("entry_time") or position.get("buy_time")) n = _bo_dt_to_minutes(current_candle.get("candle_time")) if e is None or n is None: return None return max(0, n - e) def breakout_invest_amount_krw( max_loss_krw: Any, sl_pct_ui: float, slot_money_cap: Any = 2_000_000, ) -> float: """손절 % 와 1회 최대손실로 투자금 산출. ``min(슬롯캡, max_loss / |sl|)``.""" ml = normalize_breakout_max_loss_krw(max_loss_krw) sl_abs = abs(float(sl_pct_ui)) / 100.0 cap = float(slot_money_cap or 2_000_000) if sl_abs <= 0: return cap return min(cap, ml / sl_abs) # 진입 룰 모듈 함수 — 라이브(BreakoutStrategy.check_buy) ↔ 백테스트 100% 공유 # ════════════════════════════════════════════════════════════════════════════ # TRIGGER 진입 룰 (HTS SCAN 이 널넬어도 여기서 전부 재검사): # 1) 직전봉 등락률 ∈ [prev_chg_min, prev_chg_max] — 전전봉 종가 대비 직전봉 종가 % # 2) 직전 ``lookback_min`` 봉 최대고가 < 현재 종가 # 3) 1분 회전율 ≥ ``min_turnover_1m_pct`` (유통주식 대비, ka10001) — 1차 게이트 # 3b) ``vol_mult``>0 일 때만 — 현재 거래량 ≥ 직전 ``vol_window`` 평균 × 배수 (HTS 300%=3.0) # + 코드 전용 가드: # 4) 양봉(close > open) # 5) 전봉 종가 초과(close > prev_close) — 2)와 중복될 수 있으나 종가 갭 안전판 # 6) 일중 누적 상승률 ≤ ``max_daily_chg`` (이격 과열 회피) # 7) 최소가격 가드 # 시간대 가드(``time_start_hm`` ~ ``time_end_hm``)는 옵션 — 라이브는 외부의 # ``_is_golden_time`` 가 처리하므로 0~2400 으로 비활성화하고, 백테스트는 # 골든타임 (예: 0900~1030) 을 명시적으로 지정. def _eval_breakout_buy_at_index( candles: List[Dict[str, Any]], i: int, params: Dict[str, Any], day_open: Optional[float] = None, ) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: """신호봉 인덱스 ``i`` 에서 돌파 매수 조건 평가 (백테 ``sub_candles[:i+1]`` 와 동일). [성능] 과거 구현은 ``sub = candles[:i+1]`` 로 매 호출마다 전체 히스토리를 복사하고, '당일 시가' 를 리스트 처음부터 정주행 스캔(O(n)) 했다. B안 틱재생 백테는 이 함수를 틱마다(수백만 회) 호출하므로 O(n²)~O(n³) 로 폭증한다. → 동작은 **완전히 동일**하게 두고, ①전체 복사 제거(인덱스 직접 접근) ②당일 시가를 뒤에서 앞으로(현재 봉 i → 당일 경계) 역스캔(O(당일봉수)) 으로 바꿔 비용을 낮춘다. 호출측이 ``day_open`` 을 미리 계산해 넘기면 그 스캔조차 생략한다(분봉당 1회 재사용). """ lookback_min = int(params.get("lookback_min", 1)) vol_window = int(params.get("vol_window", 7)) vol_mult = float(params.get("vol_mult", 0) or 0) prev_chg_min = float(params.get("prev_chg_min", 1.0)) prev_chg_max = float(params.get("prev_chg_max", 10.0)) max_daily_chg = float(params.get("max_daily_chg", 15.0)) min_price = float(params.get("min_price", 1000.0)) time_start_hm = int(params.get("time_start_hm", 0)) time_end_hm = int(params.get("time_end_hm", 2400)) # ── 가짜돌파(휩쏘) 필터 — 둘 다 0=OFF (기존과 100% 동일) ──────────────── # confirm_margin_pct: 저항선을 이 %만큼 '확실히' 넘겨야 진입 (선만 찌르는 가짜돌파 차단) # body_min_pct : 돌파봉 양봉 몸통(종가-시가)/시가 가 이 % 이상이어야 진입 (도지·윗꼬리 차단) confirm_margin_pct = float(params.get("confirm_margin_pct", 0.0) or 0.0) body_min_pct = float(params.get("body_min_pct", 0.0) or 0.0) # 1분봉 최소 거래대금(원) — 0=OFF. 초저유동 돌파(배수 착시) 차단. min_bar_trade_value = float(params.get("min_bar_trade_value_krw", 0) or 0) # 1분 회전율(%) — 유통주식 대비. 0=OFF. share_denom 은 라이브 WS 캐시. min_turnover_1m_pct = float(params.get("min_turnover_1m_pct", 0) or 0) share_denom = float(params.get("share_denom", 0) or 0) use_ema_filter = _to_bool(params.get("use_ema_filter"), False) ema_fast_period = int(params.get("ema_fast_period", 9)) ema_slow_period = int(params.get("ema_slow_period", 21)) skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), False) need_len = 1 if skip_hts else max(lookback_min, vol_window) + 2 min_i = 0 if skip_hts else 2 if i < min_i or i + 1 > len(candles) or (i + 1) < need_len: return ("탈락-캔들부족", "have=%d need_len=%d i=%d" % (len(candles), need_len, i), None) # ※ sub=candles[:i+1] 복사 없이 인덱스로 직접 접근 (결과 동일) c = candles[i] prev_c = candles[i - 1] if i >= 1 else c prev_prev_c = candles[i - 2] if i >= 2 else prev_c try: cl = float(c.get("close", 0)) op = float(c.get("open", 0)) vol = float(c.get("volume", 0)) prev_close = float(prev_c.get("close", 0)) prev_prev_close = float(prev_prev_c.get("close", 0)) except Exception as e: return ("탈락-캔들파싱", str(e), None) # 시간대 가드 (라이브는 외부에서 골든타임 처리, 백테스트는 여기서) ct = c.get("candle_time", "") if ct and len(ct) >= 12: try: hm = int(ct[8:12]) if hm < time_start_hm or hm >= time_end_hm: return (None, None, None) except Exception: pass # 1) 최소 가격 if cl < min_price: return ("탈락-최소가격", "%.0f < %.0f" % (cl, min_price), None) # kiwoom breakout SCAN — 일봉 돌파·등락·5일거래량 이미 통과, TRIGGER 는 타이밍·호가·휩쏘만 if skip_hts: if body_min_pct > 0 and op > 0: body_pct = (cl - op) / op * 100.0 if body_pct < body_min_pct: return ("탈락-몸통부족", "몸통 %.2f%% < %.2f%%" % (body_pct, body_min_pct), None) sig_hts: Dict[str, Any] = { "resistance": 0.0, "vol_ratio": 0.0, "prev_chg": 0.0, "close": cl, "signal_candle_time": ct, "pattern": "breakout_hts_scan", "mode": "breakout_hts", } ws_rej, ws_msg = whipsaw_reject_for_signal( params, "BREAKOUT", signal_bar=c, current_price=cl, resistance=0.0, ) if ws_rej: return (ws_rej, ws_msg, None) ob_rej, ob_msg = orderbook_reject_for_entry( params, "BREAKOUT", current_price=cl, resistance=0.0, ) if ob_rej: return (ob_rej, ob_msg, None) prog_rej, prog_msg = program_reject_for_entry( params, "BREAKOUT", current_price=cl, ) if prog_rej: return (prog_rej, prog_msg, None) return (None, None, sig_hts) # 2) 직전봉 등락률 (TRIGGER — HTS 는 널넬게, 여기서 1~10% 등으로 조임) if prev_prev_close <= 0: return ("탈락-직전봉(전전봉)없음", "prev_prev_close=0", None) prev_chg = (prev_close - prev_prev_close) / prev_prev_close * 100.0 if prev_chg < prev_chg_min: return ("탈락-직전봉약세", "prev=%.2f%% < %.2f%%" % (prev_chg, prev_chg_min), None) if prev_chg > prev_chg_max: return ("탈락-직전봉과열", "prev=%.2f%% > %.2f%%" % (prev_chg, prev_chg_max), None) # 3) 저항선 돌파 (sub[-(lookback+1):-1] == candles[i-lookback:i]) window_highs = [float(x.get("high", 0)) for x in candles[i - lookback_min:i]] if not window_highs: return ("탈락-저항창없음", "lookback=%d" % lookback_min, None) resistance = max(window_highs) if resistance <= 0 or cl <= resistance: gap_pct = (cl - resistance) / resistance * 100.0 if resistance > 0 else 0.0 return ("탈락-저항미돌파", "close=%.0f ≤ 저항=%.0f (gap=%.2f%%)" % (cl, resistance, gap_pct), None) # 3-1) 돌파 확인 여유 — 저항을 confirm_margin_pct% 이상 확실히 넘겨야 진입 (가짜돌파 차단) if confirm_margin_pct > 0: need_price = resistance * (1.0 + confirm_margin_pct / 100.0) if cl < need_price: over_pct = (cl - resistance) / resistance * 100.0 return ( "탈락-돌파여유부족", "돌파 %.2f%% < 요구 %.2f%% (close=%.0f 저항=%.0f)" % (over_pct, confirm_margin_pct, cl, resistance), None, ) # 3-2) 1분 최소 거래대금 — vol_mult 배수 착시(직전 1분 28주 등) 차단 (0=OFF) if min_bar_trade_value > 0: bar_trade_value = vol * cl if bar_trade_value < min_bar_trade_value: return ( "탈락-거래대금부족", "%.0f원 < %.0f원 (vol=%.0f px=%.0f)" % (bar_trade_value, min_bar_trade_value, vol, cl), None, ) # 3-3) 거래량 — 1차: 유통주식 대비 1분 회전율 (키움 ka10001 ``dstr_stk``) # 보조: vol_mult>0 일 때만 직전 vol_window 평균 대비 배수 (0=OFF) use_turnover = min_turnover_1m_pct > 0 use_vol_mult = vol_mult > 0 if use_turnover: if share_denom <= 0: return ( "탈락-유통주식없음", "share_denom=0 (ka10001·stock_share_meta 필요)", None, ) turnover_pct = (vol / share_denom) * 100.0 if turnover_pct < min_turnover_1m_pct: return ( "탈락-회전율부족", "%.3f%% < %.3f%% (vol=%.0f / denom=%.0f)" % (turnover_pct, min_turnover_1m_pct, vol, share_denom), None, ) if use_vol_mult: vol_window_arr = [float(x.get("volume", 0)) for x in candles[i - vol_window:i]] if not vol_window_arr or sum(vol_window_arr) <= 0: return ("탈락-거래량창없음", "vol_window=%d" % vol_window, None) avg_vol = sum(vol_window_arr) / len(vol_window_arr) if avg_vol <= 0 or vol < avg_vol * vol_mult: ratio = (vol / avg_vol) if avg_vol > 0 else 0.0 return ( "탈락-거래량부족", "%.2fx < %.1fx (curr=%.0f avg=%.0f)" % (ratio, vol_mult, vol, avg_vol), None, ) # 4) 양봉 (close > open) — 안전 가드 if cl <= op: return ("탈락-음봉/도지", "close=%.0f ≤ open=%.0f" % (cl, op), None) # 5-1) 양봉 몸통 최소 — 몸통이 너무 얇으면(도지·윗꼬리) 가짜돌파로 보고 제외 if body_min_pct > 0 and op > 0: body_pct = (cl - op) / op * 100.0 if body_pct < body_min_pct: return ("탈락-몸통부족", "몸통 %.2f%% < %.2f%%" % (body_pct, body_min_pct), None) # 6) 전봉 종가 초과 — 갭 안전판 if cl <= prev_close: chg = (cl - prev_close) / prev_close * 100.0 if prev_close > 0 else 0.0 return ("탈락-전봉종가미달", "close=%.0f ≤ prev=%.0f (%.2f%%)" % (cl, prev_close, chg), None) closes_bo = params.get("_ema_full_closes") if closes_bo is not None: ema_i = int(params.get("_ema_full_index", len(closes_bo) - 1)) else: closes_bo = [float(x.get("close", 0)) for x in candles] ema_i = i ic = params.get("_indicator_cache") ema_fast_val = ic.ema_at(ema_i, ema_fast_period) if ic is not None and hasattr(ic, "ema_at") else None ema_slow_val = ic.ema_at(ema_i, ema_slow_period) if ic is not None and hasattr(ic, "ema_at") else None ema_rej, ema_msg = eval_ema_uptrend_reject( closes_bo, ema_i, cl, use_filter=use_ema_filter, fast_period=ema_fast_period, slow_period=ema_slow_period, ema_fast_val=ema_fast_val, ema_slow_val=ema_slow_val, ) if ema_rej: return (ema_rej, ema_msg, None) # 7) 일중 이격 과열 — 당일 시가 대비 누적 상승률 # day_open 미주입 시: 현재 봉 i 에서 뒤로 가며 '당일 첫 봉' 을 찾는다(역스캔, 결과 동일). if day_open is None: day = ct[:8] if (ct and len(ct) >= 8) else None if day: earliest = i j = i while j >= 0 and str(candles[j].get("candle_time", ""))[:8] == day: earliest = j j -= 1 day_open = float(candles[earliest].get("open", 0)) if day_open is None or day_open <= 0: day_open = float(candles[0].get("open", 0)) if day_open and day_open > 0: daily_chg = (cl - day_open) / day_open * 100.0 if daily_chg > max_daily_chg: return ("탈락-이격과열", "일중 %.2f%% > %.1f%%" % (daily_chg, max_daily_chg), None) vol_ratio = 0.0 if use_vol_mult: _vw = [float(x.get("volume", 0)) for x in candles[i - vol_window:i]] _avg = sum(_vw) / len(_vw) if _vw else 0.0 vol_ratio = (vol / _avg) if _avg > 0 else 0.0 elif use_turnover and share_denom > 0: vol_ratio = (vol / share_denom) * 100.0 sig = { "resistance": resistance, "vol_ratio": vol_ratio, "prev_chg": prev_chg, "close": cl, "signal_candle_time": ct, } ws_rej, ws_msg = whipsaw_reject_for_signal( params, "BREAKOUT", signal_bar=candles[i], current_price=cl, resistance=resistance, ) if ws_rej: return (ws_rej, ws_msg, None) ob_rej, ob_msg = orderbook_reject_for_entry( params, "BREAKOUT", current_price=cl, resistance=resistance, ) if ob_rej: return (ob_rej, ob_msg, None) prog_rej, prog_msg = program_reject_for_entry( params, "BREAKOUT", current_price=cl, ) if prog_rej: return (prog_rej, prog_msg, None) return (None, None, sig) # A안=확정봉 종가 돌파 → 다음 봉 시가 / B안=봉 내 전고점·저항 돌파 → 현재가(HTS E) _INTRABAR_ENTRY_MODES = frozenset({"intrabar", "b", "live_b", "hts"}) def breakout_entry_mode(params: Optional[Dict[str, Any]] = None) -> str: """``BREAKOUT_ENTRY_MODE``: ``align``(A) | ``intrabar``(B, 기본).""" if params and params.get("entry_mode") is not None: return str(params.get("entry_mode") or "intrabar").strip().lower() return str(get_env_from_db("BREAKOUT_ENTRY_MODE", "intrabar") or "intrabar").strip().lower() def check_buy_signal_breakout_intrabar_live( confirmed: List[Dict[str, Any]], current_price: float, forming_bar: Dict[str, Any], params: Dict[str, Any], day_open: Optional[float] = None, ) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: """ B안 실매·백테 공용 — 직전 확정봉들로 필터, **현재가**로 저항 돌파 판정. * 저항·거래량·직전봉 등락은 확정봉+진행봉(OHLCV)으로 ``_eval_breakout_buy_at_index`` 재사용. * 진입가 = ``current_price`` (다음 봉 시가 대기 없음). """ lookback_min = int(params.get("lookback_min", 1)) vol_window = int(params.get("vol_window", 7)) skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), False) need_n = 1 if skip_hts else max(lookback_min, vol_window) + 2 confirmed_only = _confirmed_candles_only(confirmed) if current_price <= 0: return ("탈락-가격없음", "WS 현재가 없음", None) if len(confirmed_only) < need_n - 1: return ( "탈락-캔들부족", "confirmed=%d need>=%d (+진행봉)" % (len(confirmed_only), need_n - 1), None, ) cur = dict(forming_bar or {}) cur["close"] = float(current_price) try: cur["high"] = max(float(cur.get("high") or 0), float(current_price)) cur["low"] = min(float(cur.get("low") or current_price), float(current_price)) except Exception: cur["high"] = float(current_price) cur["low"] = float(current_price) if not cur.get("open") or float(cur.get("open") or 0) <= 0: cur["open"] = float(current_price) virtual = list(confirmed_only) + [cur] i = len(virtual) - 1 reason, msg, sig = _eval_breakout_buy_at_index(virtual, i, params, day_open=day_open) if sig: sig["entry_price"] = float(current_price) sig["entry_bar_key"] = cur.get("candle_time") sig["entry_mode"] = "intrabar" return reason, msg, sig def breakout_intrabar_entry_price( forming_bar: Dict[str, Any], signal: Dict[str, Any], params: Dict[str, Any], ) -> float: """백테 B안 체결가 — 저항선·시가·고가·슬리피지(env) 중 현실적 터치가.""" _raw_slip = params.get("intrabar_slippage_pct") if _raw_slip in (None, ""): # params 에 없을 때만 env(→DB) — 0.0 설정값은 그대로 사용 slip_pct = get_env_float("BREAKOUT_INTRABAR_SLIPPAGE_PCT", 0.0) else: slip_pct = float(_raw_slip or 0.0) res = float(signal.get("resistance") or 0) op = float(forming_bar.get("open") or 0) hi = float(forming_bar.get("high") or 0) if res > 0: touch = res * (1.0 + slip_pct / 100.0) ep = max(touch, op) if op > 0 else touch if hi > 0: ep = min(ep, hi) return ep return hi if hi > 0 else op def breakout_backtest_use_tick_exit(params: Optional[Dict[str, Any]] = None) -> bool: """백테 청산에 ws_ticks 사용 (기본 ON — OHLC intrabar 낙관 편향 제거).""" if params is not None and params.get("backtest_use_tick_exit") is not None: return _to_bool(params.get("backtest_use_tick_exit"), True) return get_env_bool("BREAKOUT_BACKTEST_USE_TICK_EXIT", True) def breakout_backtest_wants_tick_replay(params: Optional[Dict[str, Any]] = None) -> bool: """B안 진입 틱 또는 청산 틱 재생이 필요한지.""" if breakout_backtest_use_tick_exit(params): return True return ( breakout_entry_mode(params) in _INTRABAR_ENTRY_MODES and breakout_backtest_use_tick_db(params) ) def breakout_backtest_use_tick_db(params: Optional[Dict[str, Any]] = None) -> bool: """B안 백테 시 ``ws_ticks`` 재생 사용 여부 (기본 ON — 실매 체결 정합). 1분봉 OHLC 경로(open→high→low→close 가정)는 손절보다 익절/어깨컷을 먼저 체결하는 **낙관적 편향**을 만들어 실매 손실을 백테 수익으로 둔갑시킨다. (7/3 검증: 모멘텀 OHLC +22k → 틱재생 -57k, 실매 -49k 와 정합) 따라서 꼬리·모멘텀과 동일하게 기본 ON 으로 저장 틱(실 체결 경로)을 재생한다. 틱이 없는 구간은 ``BREAKOUT_BACKTEST_TICK_FALLBACK_OHLC`` 로 OHLC 폴백. 끄려면 BREAKOUT_BACKTEST_USE_TICK_DB=0 또는 params 로 명시 OFF. """ if params is not None and params.get("backtest_use_tick_db") is not None: return _to_bool(params.get("backtest_use_tick_db"), True) return get_env_bool("BREAKOUT_BACKTEST_USE_TICK_DB", True) def breakout_backtest_tick_fallback_ohlc(params: Optional[Dict[str, Any]] = None) -> bool: """해당 분에 틱 없을 때 1분봉 high 근사 폴백 (기본 OFF — 유령거래 방지).""" if params is not None and params.get("backtest_tick_fallback_ohlc") is not None: return _to_bool(params.get("backtest_tick_fallback_ohlc"), False) return get_env_bool("BREAKOUT_BACKTEST_TICK_FALLBACK_OHLC", False) def breakout_scan_buy_intrabar_from_ticks( confirmed: List[Dict[str, Any]], minute_ticks: List[Dict[str, Any]], minute_bar: Dict[str, Any], params: Dict[str, Any], day_open: Optional[float] = None, ) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]], float, str]: """ ``ws_ticks`` 시간순 재생 — 실매 B안(현재가 돌파)과 동일하게 **첫 돌파 틱** 에 진입. 틱이 없으면 (None, ...) 호출측에서 OHLC 폴백. """ if not minute_ticks: return ("탈락-틱없음", "해당 분봉 ws_ticks 없음", None, 0.0, "") _raw_slip = params.get("intrabar_slippage_pct") if _raw_slip in (None, ""): # params 에 없을 때만 env(→DB) — 0.0 설정값은 그대로 사용 slip_pct = get_env_float("BREAKOUT_INTRABAR_SLIPPAGE_PCT", 0.0) else: slip_pct = float(_raw_slip or 0.0) minute_key = str(minute_bar.get("candle_time") or "")[:12] forming_base = dict(minute_bar) cum_vol = 0 last_reject: Tuple[Optional[str], Optional[str]] = (None, None) # ── [성능 핵심] 분봉 내 불변값을 틱 루프 밖에서 1회만 계산 ───────────────── # confirmed(직전 확정봉)는 한 분봉 동안 고정이다. 과거엔 틱마다 # _confirmed_candles_only(confirmed) + (confirmed_only+[cur]) 전체복사 + 당일시가 # 정주행 스캔을 반복 → ws_ticks 수백만 건에서 O(n²)~O(n³) 폭증(웹 백테 무응답). # 아래처럼 ①확정봉 필터 ②윈도우 꼬리 ③당일시가 를 분봉당 1회만 만들고, # 틱마다 진행봉(cur)만 buf[-1] 에 갈아끼우면 결과는 동일하면서 비용이 급감한다. confirmed_only = _confirmed_candles_only(confirmed) lookback_min = int(params.get("lookback_min", 1)) vol_window = int(params.get("vol_window", 7)) skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), False) need_n = 1 if skip_hts else max(lookback_min, vol_window) + 2 if len(confirmed_only) < need_n - 1: return ( "탈락-캔들부족", "confirmed=%d need>=%d" % (len(confirmed_only), need_n - 1), None, 0.0, "", ) minute_open = float(forming_base.get("open") or 0) day_open_hoist: Optional[float] = day_open # 호출측(포트폴리오)에서 사전계산값 주입 가능 if day_open_hoist is not None and day_open_hoist > 0: # 주입된 당일시가 사용 → 당일 전체봉 불필요, 윈도우 꼬리만. base_tail = confirmed_only[-need_n:] if len(confirmed_only) > need_n else confirmed_only elif minute_open > 0: # 당일 시가(이격과열 필터용): 진행봉 open 이 분봉 내 고정이라 1회 산출 가능. # check_buy_signal_breakout_intrabar_live → _eval 의 '당일 첫 봉 시가' 와 동일. day = minute_key[:8] fm_open: Optional[float] = None for x in confirmed_only: xt = str(x.get("candle_time") or "") if xt[:8] == day: fm_open = float(x.get("open", 0) or 0) break if fm_open is None: fm_open = minute_open # 당일 첫 봉이 진행봉(cur) → cur.open ≈ 분봉 open day_open_hoist = fm_open if day_open_hoist is None or day_open_hoist <= 0: day_open_hoist = float( (confirmed_only[0].get("open", 0) if confirmed_only else minute_open) or 0 ) # 꼬리: 윈도우(need_n)만큼만. 당일시가를 주입하므로 당일 전체봉은 불필요. base_tail = confirmed_only[-need_n:] if len(confirmed_only) > need_n else confirmed_only else: # 진행봉 open 부재(비정상 데이터) → 정확성 우선: 전체 confirmed + eval 내부 역스캔. base_tail = confirmed_only buf = list(base_tail) buf.append(None) # 진행봉(cur) 자리 — 틱마다 교체 # ── [성능] 분봉 단위 사전 게이트(불변 조건) ───────────────────────────── # 시간대·직전봉등락·저항선은 한 분봉 동안 고정이다(_eval 과 동일 기준). # 통과 불가한 분봉은 수십~수백 틱의 dict 생성+_eval 호출을 통째로 생략하고, # 통과 분봉에서도 'price ≤ 저항' 틱은 _eval(저항미돌파 확정) 없이 즉시 스킵한다. # → ws_ticks 수백만 건에서 비싼 평가를 '실제 돌파 후보 틱' 으로만 한정. 결과 동일. # 1) 시간대 가드 (_eval: hm=end → (None,None,None)) try: _hm = int(minute_key[8:12]) except Exception: _hm = None _ts = int(params.get("time_start_hm", 0)) _te = int(params.get("time_end_hm", 2400)) if _hm is not None and (_hm < _ts or _hm >= _te): return (None, None, None, 0.0, "") # 2) 직전봉 등락 — skip_hts 시 HTS 일봉 등락과 중복이므로 생략 if not skip_hts and len(confirmed_only) >= 2: _pc = float(confirmed_only[-1].get("close", 0) or 0) _ppc = float(confirmed_only[-2].get("close", 0) or 0) if _ppc > 0: _prev_chg = (_pc - _ppc) / _ppc * 100.0 _pmin = float(params.get("prev_chg_min", 1.0)) _pmax = float(params.get("prev_chg_max", 10.0)) if _prev_chg < _pmin: return ("탈락-직전봉약세", "prev=%.2f%% < %.2f%%" % (_prev_chg, _pmin), None, 0.0, "") if _prev_chg > _pmax: return ("탈락-직전봉과열", "prev=%.2f%% > %.2f%%" % (_prev_chg, _pmax), None, 0.0, "") # 3) 저항선(불변) — skip_hts 시 HTS 전일고가 돌파와 중복이므로 생략 resistance_gate = 0.0 if not skip_hts: _res_window = ( [float(x.get("high", 0) or 0) for x in confirmed_only[-lookback_min:]] if lookback_min > 0 else [] ) resistance_gate = max(_res_window) if _res_window else 0.0 if resistance_gate <= 0: return ("탈락-저항창없음", "lookback=%d" % lookback_min, None, 0.0, "") # ── [백테 틱재생·공유메모리] 컬럼 뷰 직행 경로 (dict 재구성/재정렬 제거) ───────── # minute_ticks 가 공유메모리 컬럼 뷰(또는 그 _TickList)면, 매 틱 sorted()+tick_upto # 리스트 재생성(O(n²))을 없애고 배열을 직접 순회한다. 저장 순서(로더 ORDER BY # tick_time)가 dict 경로의 stable sort 결과와 동일하고, _whipsaw_ticks 는 'tt<=현재' # prefix 뷰로 넘겨 whipsaw 컬럼 경로를 그대로 탄다 → dict 경로와 bit-identical. _cview = None try: from kis_trader.backtest.shared_ticks import TickColumnView, _TickList if isinstance(minute_ticks, TickColumnView): _cview = minute_ticks elif isinstance(minute_ticks, _TickList): _cview = minute_ticks.column_view() except Exception: _cview = None if _cview is not None: owner = _cview.owner _price = owner._price _volume = owner._volume _ttarr = owner._tick_time idxs = list(_cview.iter_idx()) # 저장(=tick_time 오름차순) 순서 절대 인덱스 n = len(idxs) # tt 문자열(≤14) 미리 디코드 + 동일 tick_time 묶음의 끝 위치(group_end) 사전계산. # dict 의 tick_upto = [tt <= 현재] 는 정렬상 연속 prefix 이고, 동률(같은 tt)은 # 모두 포함되므로 prefix 끝 = 현재와 같은 tt 의 마지막 위치다. tts = [_ttarr[idxs[k]].decode("utf-8") for k in range(n)] group_end = [0] * n j = 0 while j < n: k = j while k + 1 < n and tts[k + 1] == tts[j]: k += 1 for t in range(j, k + 1): group_end[t] = k j = k + 1 off0 = idxs[0] if n > 0 else 0 for pos in range(n): ai = idxs[pos] price = float(_price[ai]) if price <= 0: continue cum_vol += int(_volume[ai]) if not skip_hts and price <= resistance_gate: last_reject = ("탈락-저항미돌파", "price=%.0f ≤ 저항=%.0f" % (price, resistance_gate)) continue forming = dict(forming_base) forming["close"] = price forming["high"] = max(float(forming.get("high") or 0), price) forming["low"] = min( float(forming.get("low") or price) if forming.get("low") else price, price, ) if not forming.get("open") or float(forming.get("open") or 0) <= 0: forming["open"] = price forming["volume"] = cum_vol forming["candle_time"] = minute_key buf[-1] = forming # tt<=현재 prefix 뷰(동률 포함) — 단일 분봉이라 연속 세그먼트 1개. prefix_cnt = group_end[pos] + 1 eval_params = dict(params) if prefix_cnt > 0: eval_params["_whipsaw_ticks"] = TickColumnView(owner, [(off0, prefix_cnt)]) reason, msg, sig = _eval_breakout_buy_at_index( buf, len(buf) - 1, eval_params, day_open=day_open_hoist, ) if sig: res = float(sig.get("resistance") or 0) ep = price if res > 0 and slip_pct: ep = max(ep, res * (1.0 + slip_pct / 100.0)) et = minute_key sig["entry_price"] = ep sig["entry_bar_key"] = et sig["entry_mode"] = "intrabar" sig["entry_tick_time"] = tts[pos] sig["backtest_entry_source"] = "ws_ticks" return None, None, sig, ep, et if reason and reason not in ("탈락-틱없음",): last_reject = (reason, msg) else: last_reject = (None, None) if last_reject[0]: return last_reject[0], last_reject[1], None, 0.0, "" return ("탈락-돌파틱없음", "분봉 내 돌파 틱 없음", None, 0.0, "") for tick in sorted(minute_ticks, key=lambda x: str(x.get("tick_time") or "")): price = float(tick.get("price") or 0) if price <= 0: continue cum_vol += int(tick.get("volume") or 0) # 저항 미돌파 틱 → 비싼 _eval 생략 (skip_hts 시 저항 게이트 없음) if not skip_hts and price <= resistance_gate: last_reject = ("탈락-저항미돌파", "price=%.0f ≤ 저항=%.0f" % (price, resistance_gate)) continue forming = dict(forming_base) forming["close"] = price forming["high"] = max(float(forming.get("high") or 0), price) forming["low"] = min( float(forming.get("low") or price) if forming.get("low") else price, price, ) if not forming.get("open") or float(forming.get("open") or 0) <= 0: forming["open"] = price forming["volume"] = cum_vol forming["candle_time"] = minute_key # check_buy_signal_breakout_intrabar_live() 와 동일 평가 (cur=forming 그대로). # 당일시가는 호이스팅 값 주입(없으면 eval 내부에서 역스캔). buf[-1] = forming tick_buf = sorted(minute_ticks, key=lambda x: str(x.get("tick_time") or "")) tick_upto = [t for t in tick_buf if str(t.get("tick_time") or "") <= str(tick.get("tick_time") or "")] eval_params = dict(params) if tick_upto: eval_params["_whipsaw_ticks"] = tick_upto reason, msg, sig = _eval_breakout_buy_at_index( buf, len(buf) - 1, eval_params, day_open=day_open_hoist, ) if sig: res = float(sig.get("resistance") or 0) ep = price if res > 0 and slip_pct: ep = max(ep, res * (1.0 + slip_pct / 100.0)) et = minute_key sig["entry_price"] = ep sig["entry_bar_key"] = et sig["entry_mode"] = "intrabar" sig["entry_tick_time"] = str(tick.get("tick_time") or "") sig["backtest_entry_source"] = "ws_ticks" return None, None, sig, ep, et if reason and reason not in ("탈락-틱없음",): last_reject = (reason, msg) else: last_reject = (None, None) if last_reject[0]: return last_reject[0], last_reject[1], None, 0.0, "" return ("탈락-돌파틱없음", "분봉 내 돌파 틱 없음", None, 0.0, "") def breakout_min_bars_required(params: Optional[Dict[str, Any]] = None) -> int: """백테/파서치 캔들 최소 개수 — A안(+1 다음봉) vs B안(당일봉 진입).""" p = params or {} skip_hts = _to_bool(p.get("skip_hts_scan_dupes"), False) if skip_hts: need_n = 1 else: lookback_min = int(p.get("lookback_min", 1)) vol_window = int(p.get("vol_window", 7)) need_n = max(lookback_min, vol_window) + 2 extra = 0 if breakout_entry_mode(p) in _INTRABAR_ENTRY_MODES else 1 return need_n + extra def breakout_ui_to_engine_params(ui: Dict[str, Any]) -> Dict[str, Any]: """ 웹·파서치 UI(%) → ``run_breakout_backtest`` 엔진 dict. ``entry_mode``: intrabar(B, 실매 HTS E) | align(A, 다음 봉 시가). """ sl_pct_ui = float(ui.get("sl_pct", 2.0)) max_loss = normalize_breakout_max_loss_krw(ui.get("max_loss_krw", 200_000)) slot_cap = float(ui.get("slot_money", 2_000_000)) slot = breakout_invest_amount_krw(max_loss, sl_pct_ui, slot_cap) mode_raw = str(ui.get("entry_mode") or "").strip().lower() mode = mode_raw if mode_raw else breakout_entry_mode() def _fee_ratio(key_ui: str, key_alt: str, default_ui: float) -> float: raw = ui.get(key_ui) if raw in (None, ""): raw = ui.get(key_alt, default_ui) v = float(raw) return v / 100.0 smh_ui = ui.get("shoulder_min_high_pct") sc_ui = ui.get("shoulder_cut_pct") def _ui_bool(key: str, default: bool = False) -> bool: raw = ui.get(key) if raw is None or raw == "": return default if isinstance(raw, bool): return raw return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on") ep = { "lookback_min": int(ui.get("lookback_min", 1)), "vol_window": int(ui.get("vol_window", 1)), "vol_mult": float(ui.get("vol_mult", 0) or 0), "prev_chg_min": float(ui.get("prev_chg_min", 1.0)), "prev_chg_max": float(ui.get("prev_chg_max", 10.0)), "max_daily_chg": float(ui.get("max_daily_chg", 15.0)), "min_price": float(ui.get("min_price", 1000.0)), "min_bar_trade_value_krw": float(ui.get("min_bar_trade_value_krw", 0) or 0), "min_turnover_1m_pct": float(ui.get("min_turnover_1m_pct", 0.05) or 0), # 가짜돌파 필터 (둘 다 0=OFF) "confirm_margin_pct": float(ui.get("confirm_margin_pct", 0.0) or 0.0), "body_min_pct": float(ui.get("body_min_pct", 0.0) or 0.0), "time_start_hm": int(ui.get("time_start_hm", 900)), "time_end_hm": int(ui.get("time_end_hm", 1030)), "cooldown_min": float(ui.get("cooldown_min", 30)), "max_daily": int(ui.get("max_daily", 1)), "max_loss_krw": max_loss, "slot_money": slot, "stop_loss_pct": -abs(sl_pct_ui) / 100.0, # ── ATR 동적 손절 (sl_mode='atr' 일 때만 활성, 기본 fixed=기존 고정% 동작) ── # min/max_pct 는 % 단위 그대로 전달(check_sell 의 _breakout_sl_line 가 /100 처리). "sl_mode": str(ui.get("sl_mode", "fixed") or "fixed").strip().lower(), "atr_period": int(float(ui.get("atr_period", 14) or 14)), "atr_sl_mult": float(ui.get("atr_sl_mult", 2.0) or 2.0), "atr_sl_min_pct": float(ui.get("atr_sl_min_pct", 0.8) or 0.8), "atr_sl_max_pct": float(ui.get("atr_sl_max_pct", 6.0) or 6.0), "take_profit_pct": abs(float(ui.get("tp_pct", 5.0))) / 100.0, "trail_pct": abs(float(ui.get("trail_pct", 1.5))) / 100.0, # 트레일링 무장 임계값(%) — 고점이 진입가×(1+arm) 도달 후에만 보조 트레일 작동. # 0 = 즉시 무장(기존과 동일). 진입 직후 본전 청산 방지용. "trail_arm_pct": abs(float(ui.get("trail_arm_pct", 0.0) or 0.0)) / 100.0, "shoulder_min_high": ( abs(float(smh_ui)) / 100.0 if smh_ui is not None else None ), "shoulder_cut_pct": ( abs(float(sc_ui)) / 100.0 if sc_ui is not None else None ), # 래칫 단계 트레일(문자열 "g:c,..", 기본 OFF) · 시간컷(분, 0=OFF) "ratchet_tiers": ui.get("ratchet_tiers", "") or "", "max_hold_bars": int(float(ui.get("max_hold_bars", 0) or 0)), "fee_rate": _fee_ratio("fee_rate_pct", "fee_rate", 0.015), "sell_tax": _fee_ratio("sell_tax_pct", "sell_tax", 0.18), "entry_mode": mode, "intrabar_slippage_pct": float(ui.get("intrabar_slippage_pct", 0.0) or 0.0), "live_backtest_align": mode not in _INTRABAR_ENTRY_MODES, "live_signal_lookback_bars": int(ui.get("live_signal_lookback_bars", 1)), "use_ema_filter": _ui_bool("use_ema_filter", False), "ema_fast_period": int(float(ui.get("ema_fast_period", 9) or 9)), "ema_slow_period": int(float(ui.get("ema_slow_period", 21) or 21)), # EOD — 실매 BREAKOUT_EOD_* 와 동일 (백테·파서치 공용) "eod_enabled": _ui_bool("eod_enabled", True), "eod_hm": str(ui.get("eod_hm") or "15:15").strip() or "15:15", "skip_hts_scan_dupes": _ui_bool( "skip_hts_scan_dupes", resolve_breakout_skip_hts_scan_dupes(), ), # 수익구간 호가매도 / 손절호가 — UI% → 엔진 분율 (기본 OFF) "exit_ob_enabled": _ui_bool("exit_ob_enabled", False), "exit_ob_ratio_min": float(ui.get("exit_ob_ratio_min", 0.4) or 0.4), "exit_ob_ma_window": int(float(ui.get("exit_ob_ma_window", 5) or 5)), "exit_ob_min_profit_pct": abs(float(ui.get("exit_ob_min_profit_pct", 0.5) or 0.5)) / 100.0, "exit_ob_min_hold_bars": int(float(ui.get("exit_ob_min_hold_bars", 3) or 3)), "stop_ob_enabled": _ui_bool("stop_ob_enabled", False), "stop_ob_ratio_min": float(ui.get("stop_ob_ratio_min", 0.4) or 0.4), "stop_ob_ma_window": int(float(ui.get("stop_ob_ma_window", 5) or 5)), "stop_ob_min_loss_pct": abs(float(ui.get("stop_ob_min_loss_pct", 0.3) or 0.3)) / 100.0, "stop_ob_min_hold_bars": int(float(ui.get("stop_ob_min_hold_bars", 2) or 2)), } return ep def _breakout_params_with_ema_closes( params: Dict[str, Any], candles: List[Dict[str, Any]], bar_index: int, *, forming_close: Optional[float] = None, ) -> Dict[str, Any]: """intrabar 짧은 virtual 창에서도 EMA 를 전체 히스토리 기준으로 계산.""" if not _to_bool(params.get("use_ema_filter"), False): return params slow_p = max(1, int(params.get("ema_slow_period", 21))) start = max(0, bar_index - slow_p - 10) closes = [float(x.get("close", 0)) for x in candles[start:bar_index]] if forming_close is not None and forming_close > 0: closes.append(float(forming_close)) elif 0 <= bar_index < len(candles): closes.append(float(candles[bar_index].get("close", 0))) if not closes: return params out = dict(params) out["_ema_full_closes"] = closes out["_ema_full_index"] = len(closes) - 1 return out def breakout_scan_buy_at_bar( candles: List[Dict[str, Any]], bar_index: int, params: Dict[str, Any], *, minute_ticks: Optional[List[Dict[str, Any]]] = None, day_open: Optional[float] = None, ) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]], float, str]: """ 백테 단일 봉 매수 스캔 — A/B 통합. B안 + ``minute_ticks`` + ``BREAKOUT_BACKTEST_USE_TICK_DB`` → ws_ticks 재생. 틱 없으면 (옵션) 1분봉 high OHLC 폴백. [성능] ``day_open`` 주입 시(포트폴리오 백테가 종목별 1회 사전계산) intrabar 경로의 ``candles[:i+1]`` 전체복사를 피하고 윈도우 꼬리만 슬라이스 → 슬롯×종목 반복에서 O(n²) 제거. Returns: reason, msg, signal, entry_price, entry_time(candle_time) """ mode = breakout_entry_mode(params) i = bar_index if mode in _INTRABAR_ENTRY_MODES: if i < 1: return ("탈락-캔들부족", "intrabar i<1", None, 0.0, "") forming = candles[i] if day_open is not None and day_open > 0: # 당일시가 주입됨 → 윈도우(need_n) 꼬리만 (전체복사 방지). 백테 캔들은 모두 확정봉. need_n = max(int(params.get("lookback_min", 1)), int(params.get("vol_window", 7))) + 2 confirmed = candles[max(0, i - need_n):i] else: confirmed = candles[:i] want_ticks = breakout_backtest_use_tick_db(params) if want_ticks: # 절대규칙: 틱 진입 모드에서 봉 high OHLC 폴백으로 신호/체결 변조 금지 if minute_ticks: r, m, sig, ep, et = breakout_scan_buy_intrabar_from_ticks( confirmed, minute_ticks, forming, params, day_open=day_open, ) if sig: return r, m, sig, ep, et return r, m, None, 0.0, "" return ("탈락-틱없음", "intrabar ticks empty", None, 0.0, "") probe = float(forming.get("high") or forming.get("close") or 0) ema_params = _breakout_params_with_ema_closes( params, candles, i, forming_close=probe, ) reason, msg, sig = check_buy_signal_breakout_intrabar_live( confirmed, probe, forming, ema_params, day_open=day_open, ) if not sig: return reason, msg, None, 0.0, "" ep = breakout_intrabar_entry_price(forming, sig, params) et = str(forming.get("candle_time") or "") if sig: sig["backtest_entry_source"] = "ohlc_intrabar" return None, None, sig, ep, et sub = candles[: i + 1] align_params = _breakout_params_with_ema_closes(params, candles, i) reason, msg, sig = check_buy_signal_breakout_live(sub, align_params) if not sig: return reason, msg, None, 0.0, "" if i + 1 >= len(candles): return ("탈락-진입봉없음", "다음 봉 없음", None, 0.0, "") next_c = candles[i + 1] if next_c["candle_time"][:8] != sub[-1]["candle_time"][:8]: return ("탈락-일자넘김", "다음봉 일자 불일치", None, 0.0, "") ep = float(next_c.get("open") or 0) if ep <= 0: ep = float(sig.get("entry_price") or sig.get("close") or 0) return None, None, sig, ep, str(next_c.get("candle_time") or "") def check_buy_signal_breakout_live( candles: List[Dict[str, Any]], params: Dict[str, Any], ) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: """ 돌파 1분봉 매수 신호. - ``live_backtest_align=True`` (기본): 직전 확정봉=신호, 현재 확정봉=진입(시가). - ``live_backtest_align=False``: 구버전 — 마지막 확정봉만 신호봉. """ lookback_min = int(params.get("lookback_min", 1)) vol_window = int(params.get("vol_window", 7)) skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), False) need_n = 1 if skip_hts else max(lookback_min, vol_window) + 2 live_align = _to_bool(params.get("live_backtest_align", True), True) lookback = max(1, int(params.get("live_signal_lookback_bars", 1))) confirmed = _confirmed_candles_only(candles) if len(confirmed) < need_n: return ("탈락-캔들부족", "have=%d need=%d" % (len(confirmed), need_n), None) last_reject: Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]] = ( None, None, None, ) if live_align: if len(confirmed) < need_n + 1: return ("탈락-캔들부족", "진입봉 분리하려면 확정봉 %d개 필요" % (need_n + 1), None) entry_i = len(confirmed) - 1 for k in range(lookback): signal_i = entry_i - 1 - k if signal_i < need_n - 1: break reason, msg, sig = _eval_breakout_buy_at_index(confirmed, signal_i, params) if reason: if k == 0: last_reject = (reason, msg, None) continue if sig: ent = confirmed[entry_i] entry_open = float(ent.get("open", 0) or 0) if entry_open <= 0: entry_open = float(sig.get("close", 0) or 0) sig["entry_price"] = entry_open sig["entry_bar_key"] = ent.get("candle_time") return (None, None, sig) return last_reject i = len(confirmed) - 1 return _eval_breakout_buy_at_index(confirmed, i, params) # ════════════════════════════════════════════════════════════════════════════ # 매도 룰 모듈 함수 — 백테스트·실매 공용. 돌파 전용 우선순위: # EOD → 익절 → 어깨컷 → 수익구간호가매도 → 손절호가 → 손절 → 트레일링(보조) # ════════════════════════════════════════════════════════════════════════════ def check_sell_signal_breakout_live( position: Dict[str, Any], current_candle: Dict[str, Any], params: Dict[str, Any], is_eod: bool = False, ) -> Optional[Tuple[str, float]]: """돌파매매 매도 신호 판단. (reason, exit_price) 또는 None.""" sl_pct = float(params.get("stop_loss_pct", -0.02)) tp_pct = float(params.get("take_profit_pct", 0.05)) trail_pct = float(params.get("trail_pct", 0.015)) shoulder_min_high, shoulder_cut_pct = _breakout_shoulder_ratios(params) ratchet_tiers = _breakout_ratchet_tiers(params) max_hold_bars = _breakout_max_hold_bars(params) try: hi = float(current_candle.get("high", current_candle["close"])) lo = float(current_candle.get("low", current_candle["close"])) cl = float(current_candle["close"]) except Exception: return None max_price = max(float(position.get("max_price", position["entry_price"])), hi) position["max_price"] = max_price if is_eod: return ("eod", cl) entry = float(position["entry_price"]) # 손절선: sl_mode='atr' 이면 변동성 비례 동적, 아니면 고정%(기존 100% 동일) sl_line = _breakout_sl_line(entry, sl_pct, position, params) tp_line = entry * (1 + tp_pct) # 1순위: 익절 if hi >= tp_line: return ("take_profit", tp_line) # 2순위: 어깨 트레일 — 래칫(단계식) 우선, 없으면 단일 어깨컷 if ratchet_tiers and entry > 0: peak_gain = (max_price - entry) / entry # 고점 기준 최대 수익률(비율) cut_ratio = 0.0 for gain, cut in ratchet_tiers: # 수익 오름차순 → 도달한 마지막 단계의 컷 적용 if peak_gain >= gain: cut_ratio = cut if cut_ratio > 0.0: # 첫 단계 수익 도달 시에만 무장 ratchet_line = max_price * (1.0 - cut_ratio) if lo <= ratchet_line: return ("shoulder_cut", ratchet_line) else: trail_armed = entry > 0 and max_price >= entry * (1.0 + shoulder_min_high) shoulder_line = max_price * (1.0 - shoulder_cut_pct) if trail_armed else 0.0 if trail_armed and lo <= shoulder_line: return ("shoulder_cut", shoulder_line) # 2.5·2.6: 수익구간 호가매도 / 손절호가 (기본 OFF · OR 부족 시 미발동 · OHLC 폴백 금지) # 호가 소스: params["_ob_or_history"] (돌파=LS · 모멘텀=키움 — 주입 경로가 분리) from kis_trader.engine.momentum_hts_logic import ( _check_exit_ob_l3, _check_stop_ob, ) hold_bars_now = _breakout_minutes_held(position, current_candle) if hold_bars_now is None: hold_bars_now = 0 ob_or_history = list(params.get("_ob_or_history", []) or []) if _check_exit_ob_l3(params, ob_or_history, entry, cl, hold_bars_now): return ("호가컷", cl) if _check_stop_ob(params, ob_or_history, entry, cl, hold_bars_now): return ("손절호가", cl) # 3순위: 손절 if lo <= sl_line: return ("stop_loss", sl_line) # 4순위: 보조 트레일링 (어깨 미무장 구간 보호) # trail_arm_pct > 0 이면 '고점이 진입가×(1+arm)' 에 도달한 뒤에만 트레일 무장. # 진입 직후 정상 출렁임(팝 후 되돌림)에 본전 청산되는 것을 막는다. # 0 = 즉시 무장(기존과 100% 동일). 그 전 구간은 손절(sl_pct)이 방어한다. trail_arm_pct = float(params.get("trail_arm_pct", 0.0) or 0.0) trail_arm_line = entry * (1.0 + trail_arm_pct) if max_price > entry and max_price >= trail_arm_line: trail_line = max_price * (1 - trail_pct) if lo <= trail_line: return ("trailing", trail_line) # 5순위: 시간컷 — 옆으로 기는 실패 돌파를 N분 후 종가 정리 (기본 OFF) if max_hold_bars > 0: held = _breakout_minutes_held(position, current_candle) if held is not None and held >= max_hold_bars: return ("max_hold", cl) return None # ════════════════════════════════════════════════════════════════════════════ # 백테스트 — 매수/매도 모듈 함수 호출 (실매 BreakoutStrategy 와 동일 판정 함수) # ════════════════════════════════════════════════════════════════════════════ def _bt_t2dt(t: str): """candle_time / buy_time → datetime (공통 파서).""" from kis_trader.utils.trade_time import parse_trade_datetime return parse_trade_datetime(t) def _bt_slot_key(candle_time: str, scan_interval_min: int = 1) -> str: """봉 시각을 N분 슬롯 키로 변환. (universe_by_slot 조회용)""" date = candle_time[:8] hm = int(candle_time[8:12]) total_min = (hm // 100) * 60 + (hm % 100) slot_min = (total_min // scan_interval_min) * scan_interval_min slot_hm = (slot_min // 60) * 100 + (slot_min % 60) return date + str(slot_hm).zfill(4) def run_breakout_backtest( codes_candles: Dict[str, List[Dict]], params: Dict[str, Any], universe_by_slot: Optional[Dict[str, List[str]]] = None, ticks_by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]] = None, orderbook_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None, program_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None, ) -> List[Dict]: """돌파매매 백테스트. universe_by_slot 이 주어지면 해당 슬롯에 종목이 들어와 있을 때만 매수 검사. (실매매의 키움 'breakout' 조건검색 폴링 시뮬레이션과 동일.) 매수 신호는 ``check_buy_signal_breakout_live`` 를, 매도는 ``check_sell_signal_breakout_live`` 를 그대로 호출 → 실매매 룰 100% 동등. """ # ── [성능] env(→DB) 기반 백테 플래그를 시뮬 시작 전 '1회만' 해석 ────────── # get_latest_env() 는 캐시가 없어 호출마다 env_config+config_* 를 DB 조회한다. # 과거엔 스캔(슬롯×종목)·분봉마다 use_tick_db·tick_fallback_ohlc·slip 을 env 로 # 재조회 → 수만 회 DB 왕복으로 웹 백테가 사실상 무응답이었다. # 아래에서 값을 확정해 params 에 박아두면 downstream 함수는 params 만 읽어(재조회 X) # 동작은 동일하면서 DB 폭주가 사라진다. params = dict(params) if params.get("backtest_use_tick_db") is None: params["backtest_use_tick_db"] = breakout_backtest_use_tick_db(params) if params.get("backtest_use_tick_exit") is None: params["backtest_use_tick_exit"] = breakout_backtest_use_tick_exit(params) if params.get("backtest_tick_fallback_ohlc") is None: params["backtest_tick_fallback_ohlc"] = breakout_backtest_tick_fallback_ohlc(params) if params.get("intrabar_slippage_pct") in (None, ""): params["intrabar_slippage_pct"] = get_env_float("BREAKOUT_INTRABAR_SLIPPAGE_PCT", 0.0) # 가짜돌파 필터 기본값도 1회 확정 (params 에 없을 때만 env) — 0=OFF if params.get("confirm_margin_pct") is None: params["confirm_margin_pct"] = get_env_float("BREAKOUT_CONFIRM_MARGIN_PCT", 0.0) if params.get("body_min_pct") is None: params["body_min_pct"] = get_env_float("BREAKOUT_BODY_MIN_PCT", 0.0) # 매도 트레일(어깨) 비율도 1회 확정 (params 에 없을 때만 env) if params.get("shoulder_min_high") is None or params.get("shoulder_cut_pct") is None: _smh, _sc = _breakout_shoulder_ratios(params) params["shoulder_min_high"] = _smh params["shoulder_cut_pct"] = _sc if _to_bool(params.get("portfolio_mode"), True): from kis_trader.backtest.breakout_portfolio_backtest import run_breakout_backtest_portfolio return run_breakout_backtest_portfolio( codes_candles, params, universe_by_slot=universe_by_slot, ticks_by_code=ticks_by_code, orderbook_by_code=orderbook_by_code, program_by_code=program_by_code, ) # 매수 룰 파라미터 (check_buy 함수에 그대로 전달) lookback_min = int(params.get("lookback_min", 1)) vol_window = int(params.get("vol_window", 7)) # 매도/포지션 파라미터 sl_pct = float(params.get("stop_loss_pct", -0.02)) # 음수 slot_money = float(params.get("slot_money", 2_000_000)) fee_rate = float(params.get("fee_rate", 0.00015)) sell_tax = float(params.get("sell_tax", 0.0018)) cooldown_min = float(params.get("cooldown_min", 30)) # 종목당 재진입 금지 분 max_daily = int(params.get("max_daily", 1)) # 종목당 일일 최대 진입 max_loss_krw = float(params.get("max_loss_krw", 200000)) # 매수 시간대(골든타임) — 라이브 ``_is_golden_time`` 와 일치 (기본 09:00~10:30) time_start_hm = int(params.get("time_start_hm", 900)) time_end_hm = int(params.get("time_end_hm", 1030)) # 매수 룰 함수에 전달할 params (시간 가드 포함) buy_params = dict(params) buy_params["time_start_hm"] = time_start_hm buy_params["time_end_hm"] = time_end_hm use_tick_exit = bool(ticks_by_code) and breakout_backtest_use_tick_exit(params) tick_fallback_ohlc = breakout_backtest_tick_fallback_ohlc(params) tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="BREAKOUT_BACKTEST_POLL_MS") tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="BREAKOUT_BACKTEST_SELL_SLIP_PCT") all_trades: List[Dict] = [] _bt_mode = breakout_entry_mode(params) _align_extra = 0 if _bt_mode in _INTRABAR_ENTRY_MODES else 1 for code, rows in codes_candles.items(): need_n = max(lookback_min, vol_window) + 2 if len(rows) < need_n + _align_extra: continue candles = [dict(r) for r in rows] # ATR 시리즈(진입 봉 변동성) — sl_mode='atr' 일 때만. 아니면 None(기존과 동일). _atr_series = ( compute_atr_series(candles, int(params.get("atr_period", 14) or 14)) if str(params.get("sl_mode", "fixed") or "fixed").strip().lower() == "atr" else None ) position: Optional[Dict] = None last_exit_dt: Dict[str, Any] = {} # day → datetime daily_cnt: Dict[str, int] = {} for i in range(need_n - 1, len(candles)): c = candles[i] day = c["candle_time"][:8] cl = float(c["close"]) is_eod = is_strategy_eod_bar(c["candle_time"], params, "BREAKOUT") # ── 보유 중: 청산 체크 (intrabar — 실매 current_price 폴링 정렬) ── if position is not None: bar = dict(c) if "open" not in bar or bar.get("open") in (None, ""): bar["open"] = float(c.get("open") or cl) minute_ticks = ( collect_minute_ticks(ticks_by_code, code, c["candle_time"]) if use_tick_exit else None ) from kis_trader.engine.momentum_hts_logic import ( collect_exit_ob_or_history, need_ob_or_history, _ob_or_ma_window_for_history, ) sell_params = dict(params) if need_ob_or_history(params) and orderbook_by_code: ors = collect_exit_ob_or_history( orderbook_by_code, code, entry_time=str(position.get("entry_time") or ""), asof_time=c["candle_time"], ma_window=_ob_or_ma_window_for_history(params), ) position["_ob_or_history"] = ors sell_params["_ob_or_history"] = list(ors) else: sell_params["_ob_or_history"] = list(position.get("_ob_or_history") or []) rp = resolve_backtest_sell( position, bar, sell_params, is_eod=is_eod, sell_fn=check_sell_signal_breakout_live, low_mode="current", ticks=minute_ticks, use_tick_exit=use_tick_exit, tick_fallback_ohlc=tick_fallback_ohlc, poll_ms=tick_poll_ms, slip_pct=tick_sell_slip, ) if rp: reason, exit_price, sell_time, hold_min, _exit_src = rp qty = position["qty"] buy_amt = position["entry_price"] * qty sell_amt = exit_price * qty pnl = ( sell_amt - buy_amt - buy_amt * fee_rate - sell_amt * fee_rate - sell_amt * sell_tax ) if hold_min <= 0: hold_min = int( (_bt_t2dt(sell_time or c["candle_time"]) - _bt_t2dt(position["entry_time"])).total_seconds() / 60 ) all_trades.append({ "code": code, "buy_time": position["entry_time"], "sell_time": sell_time or c["candle_time"], "buy_price": position["entry_price"], "sell_price": round(exit_price, 2), "qty": qty, "pnl": round(pnl), "profit_rate": round( (exit_price - position["entry_price"]) / position["entry_price"] * 100, 2 ), "hold_min": hold_min, "sell_reason": reason, }) last_exit_dt[day] = _bt_t2dt(sell_time or c["candle_time"]) position = None continue # ── 포지션 없음: 매수 시그널 ─────────────────────────────────── # 유니버스 시뮬레이션 (있으면 해당 슬롯에 종목이 있어야 진입 후보) if universe_by_slot is not None: slot_key = _bt_slot_key(c["candle_time"], int(params.get("scan_interval_min", 1))) if code not in universe_by_slot.get(slot_key, []): continue # 쿨다운 if day in last_exit_dt: elapsed_min = (_bt_t2dt(c["candle_time"]) - last_exit_dt[day]).total_seconds() / 60 if elapsed_min < cooldown_min: continue # 일일 횟수 if daily_cnt.get(day, 0) >= max_daily: continue # 매수 룰 검사 (시간 가드는 함수가 처리) minute_ticks = None if ticks_by_code: minute_ticks = (ticks_by_code.get(code) or {}).get( str(c["candle_time"])[:12], ) reason, msg, signal, entry_price, entry_time = breakout_scan_buy_at_bar( candles, i, { **buy_params, "share_denom": share_denom_for_code(buy_params, code), }, minute_ticks=minute_ticks, ) if reason and i == len(candles) - need_n: pass # 첫 스캔 탈락은 로그 생략 if not signal or entry_price <= 0 or not entry_time: continue if entry_time[:8] != day: continue # 포지션 크기 = 손실허용액 / |손절비율| (캡 = slot_money) max_loss_krw = normalize_breakout_max_loss_krw(max_loss_krw) invest_amount = breakout_invest_amount_krw( max_loss_krw, abs(sl_pct) * 100.0, slot_money, ) qty = max(1, int(invest_amount / entry_price)) position = { "entry_price": entry_price, "entry_time": entry_time, "qty": qty, "max_price": entry_price, "entry_atr": ( float(_atr_series[i]) if _atr_series is not None and 0 <= i < len(_atr_series) and _atr_series[i] is not None else 0.0 ), } daily_cnt[day] = daily_cnt.get(day, 0) + 1 all_trades.sort(key=lambda x: x["sell_time"]) return all_trades class BreakoutStrategy(BaseStrategy): strategy_id = "BREAKOUT" loop_min_sleep = 0.8 loop_max_sleep = 1.8 def __init__(self, **kwargs): super().__init__(**kwargs) self.candle_tf = 1 # 1분봉 기준 self._intrabar_fired: Dict[str, str] = {} # code → 마지막 진입봉 키 (분당 1회) self.reload_config() # ------------------------------------------------------------------ def reload_config(self) -> None: self.golden_end_hm = get_env_from_db("BREAKOUT_GOLDEN_END_HM", "10:30") # TRIGGER: 회전율(유통주식 대비) 1차 · vol_mult=0 이면 직전봉평균 배수 OFF self.lookback_min = get_env_int("BREAKOUT_LOOKBACK_MIN", 1) self.vol_window = get_env_int("BREAKOUT_VOL_WIN", 1) self.vol_mult = get_env_float("BREAKOUT_VOL_MULT", 0.0) self.prev_chg_min = get_env_float("BREAKOUT_PREV_CHG_MIN", 1.0) self.prev_chg_max = get_env_float("BREAKOUT_PREV_CHG_MAX", 10.0) self.max_daily_chg = get_env_float("BREAKOUT_MAX_DAILY_CHG", 15.0) self.min_price = get_env_float("BREAKOUT_MIN_PRICE", 1000.0) # 1분봉 최소 거래대금(원). 0=OFF — 고정 원화는 소형주에 과함. self.min_bar_trade_value_krw = get_env_float( "BREAKOUT_MIN_BAR_TRADE_VALUE_KRW", 0.0, ) # 1분 회전율(%) — 유통주식 대비. 기본 0.05%. 0=OFF. self.min_turnover_1m_pct = get_env_float( "BREAKOUT_MIN_TURNOVER_1M_PCT", 0.05, ) # 가짜돌파(휩쏘) 필터 — 0=OFF. 저항을 이 %만큼 확실히 넘기고(여유), # 돌파봉 양봉 몸통이 이 % 이상이어야 진입 → 선만 찌르는 가짜돌파 차단. self.confirm_margin_pct = get_env_float("BREAKOUT_CONFIRM_MARGIN_PCT", 0.0) self.body_min_pct = get_env_float("BREAKOUT_BODY_MIN_PCT", 0.0) self.use_ema_filter = get_env_bool("BREAKOUT_USE_EMA_FILTER", False) self.ema_fast_period = get_env_int("BREAKOUT_EMA_FAST_PERIOD", 9) self.ema_slow_period = get_env_int("BREAKOUT_EMA_SLOW_PERIOD", 21) self.slot_money = get_env_int( "BREAKOUT_SLOT_MONEY", get_env_int("SLOT_MONEY_DEFAULT", 3_000_000), ) self.stop_loss_pct = get_env_float("BREAKOUT_STOP_LOSS_PCT", -0.02) self.take_profit_pct = get_env_float("BREAKOUT_TAKE_PROFIT_PCT", 0.05) self.trail_pct = get_env_float("BREAKOUT_TRAIL_PCT", 0.015) # 트레일링 무장 임계값(비율) — 고점이 진입가×(1+arm) 도달 후에만 보조 트레일 작동. # 0 = 즉시 무장(기존과 동일). 진입 직후 정상 출렁임에 본전 청산되는 것 방지. self.trail_arm_pct = get_env_float("BREAKOUT_TRAIL_ARM_PCT", 0.0) # 어깨컷 기본 상향: 스캘프급(0.3%/0.2%)은 돌파 러너를 질식 → 무장 2%/되돌림 1% self.shoulder_min_high = get_env_float("BREAKOUT_SHOULDER_MIN_HIGH_PCT", 0.02) self.shoulder_cut_pct = get_env_float("BREAKOUT_SHOULDER_CUT_PCT", 0.01) # 래칫 단계 트레일(기본 OFF) · 시간컷(기본 OFF) — 청산 일원화에서 사용 self.ratchet_tiers_raw = get_env_from_db("BREAKOUT_RATCHET_TIERS", "") self.max_hold_bars = get_env_int("BREAKOUT_MAX_HOLD_BARS", 0) # EOD 당일청산 — OFF면 15:15 강제청산 비활성(익절·손절·시간컷만) self.eod_enabled = get_env_bool("BREAKOUT_EOD_ENABLED", True) self.eod_hm = get_env_from_db("BREAKOUT_EOD_HM", "15:15") # ── ATR 동적 손절 (sl_mode='atr' 일 때만 활성, 기본 'fixed'=기존 고정% 손절) ── # 돌파봉 변동성에 손절폭을 비례시켜 휩쏘(속임수 하락)에 시드 헌납하는 것을 줄인다. self.sl_mode = str( get_env_from_db("BREAKOUT_SL_MODE", "fixed") or "fixed" ).strip().lower() self.atr_period = get_env_int("BREAKOUT_ATR_PERIOD", 14) self.atr_sl_mult = get_env_float("BREAKOUT_ATR_SL_MULT", 2.0) self.atr_sl_min_pct = get_env_float("BREAKOUT_ATR_SL_MIN_PCT", 0.8) self.atr_sl_max_pct = get_env_float("BREAKOUT_ATR_SL_MAX_PCT", 6.0) self.skip_hts_scan_dupes = resolve_breakout_skip_hts_scan_dupes() # 수익구간 호가매도 / 손절호가 (기본 OFF · LS WS 호가 OR) self.exit_ob_enabled = get_env_bool("BREAKOUT_EXIT_OB_ENABLED", False) self.exit_ob_ratio_min = get_env_float("BREAKOUT_EXIT_OB_RATIO_MIN", 0.4) self.exit_ob_ma_window = get_env_int("BREAKOUT_EXIT_OB_MA_WINDOW", 5) self.exit_ob_min_profit_pct = get_env_float("BREAKOUT_EXIT_OB_MIN_PROFIT_PCT", 0.005) self.exit_ob_min_hold_bars = get_env_int("BREAKOUT_EXIT_OB_MIN_HOLD_BARS", 3) self.stop_ob_enabled = get_env_bool("BREAKOUT_STOP_OB_ENABLED", False) self.stop_ob_ratio_min = get_env_float("BREAKOUT_STOP_OB_RATIO_MIN", 0.4) self.stop_ob_ma_window = get_env_int("BREAKOUT_STOP_OB_MA_WINDOW", 5) self.stop_ob_min_loss_pct = get_env_float("BREAKOUT_STOP_OB_MIN_LOSS_PCT", 0.003) self.stop_ob_min_hold_bars = get_env_int("BREAKOUT_STOP_OB_MIN_HOLD_BARS", 2) def _breakout_invest_cap_krw(self) -> float: max_loss = normalize_breakout_max_loss_krw( get_env_int("BREAKOUT_MAX_LOSS_PER_TRADE_KRW", 0) or get_env_int("MAX_LOSS_PER_TRADE_KRW", DEFAULT_BREAKOUT_MAX_LOSS_KRW) ) sl_pct_ui = abs(float(self.stop_loss_pct)) * 100.0 return breakout_invest_amount_krw(max_loss, sl_pct_ui, self.slot_money) # 유니버스 로드는 BaseStrategy._load_candidates 가 이미 조건검색 우선 처리. def _candidate_filter(self, candidate: Dict) -> bool: if not candidate.get("code"): return False # 골든타임 외에는 check_buy 호출 자체를 차단 → 탈락 로그 노이즈 방지 if not self._is_golden_time(): return False return True # ------------------------------------------------------------------ # 매수 # ------------------------------------------------------------------ def _is_golden_time(self) -> bool: hh, mm = _golden_end_hm_parts(self.golden_end_hm) now = dt.now() if now.hour < 9: return False if now.hour > hh: return False if now.hour == hh and now.minute > mm: return False return True def _live_current_price(self, code: str) -> float: """WS 현재가 → 틱 링버퍼 → 확정봉 종가 폴백 (저유동 0틱).""" wsd = self._ws_last_quote(code) if wsd: try: p = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", ""))) if p > 0: return p except Exception: pass try: lp = self.ws.get_recent_ticks(code, limit=1) if lp: p2 = float(lp[-1].get("price") or 0) if p2 > 0: return p2 except Exception: pass # 키움 갭보정으로 봉은 있는데 체결 틱이 없는 저유동 — 마지막 확정 종가 try: bars = self.ws.get_candles(code, self.candle_tf, n=1) or [] if bars: p3 = float(bars[-1].get("close") or 0) if p3 > 0: return p3 except Exception: pass return 0.0 def check_buy(self, code: str, name: str) -> Optional[Dict]: _cb = self._cb_prof_start(code) try: return self._check_buy_impl(code, name, _cb) finally: self._cb_prof_finish(_cb) def _check_buy_impl(self, code: str, name: str, _cb) -> Optional[Dict]: # 골든타임 가드 (_candidate_filter 가 미리 거른 뒤에도 방어용 재검사) if not self._is_golden_time(): return None need_n = 1 if self.skip_hts_scan_dupes else max(self.lookback_min, self.vol_window) + 2 if self.use_ema_filter and not self.skip_hts_scan_dupes: need_n = max(need_n, self.ema_slow_period + 5) confirmed = self.ws.get_candles(code, self.candle_tf, n=need_n + 5) self._cb_prof_mark(_cb, "candles") if len(confirmed) < need_n - 1: # force 연타 금지 — 시세불가·특수코드는 fail_max 후에도 REST 폭주 force_sec = max(30, get_env_int("BREAKOUT_CANDLE_GAP_FORCE_SEC", 120)) now_g = time.time() if not hasattr(self, "_candle_gap_force_ts"): self._candle_gap_force_ts = {} last_g = float(self._candle_gap_force_ts.get(code, 0) or 0) do_force = (now_g - last_g) >= float(force_sec) if do_force: self._candle_gap_force_ts[code] = now_g try: self.ws.fill_gap([code], force=do_force) except Exception: pass self._cb_prof_mark(_cb, "fill_gap") log_sec = max(15, get_env_int("BREAKOUT_CANDLE_SHORT_LOG_SEC", 60)) if not hasattr(self, "_candle_short_log_ts"): self._candle_short_log_ts = {} last_l = float(self._candle_short_log_ts.get(code, 0) or 0) if now_g - last_l >= float(log_sec): self._candle_short_log_ts[code] = now_g self.logger.info( "🔍 [캔들부족] %s(%s) need>=%d have=%d", name, code, need_n - 1, len(confirmed), ) return None params = { "lookback_min": self.lookback_min, "vol_window": self.vol_window, "vol_mult": self.vol_mult, "prev_chg_min": self.prev_chg_min, "prev_chg_max": self.prev_chg_max, "max_daily_chg": self.max_daily_chg, "min_price": self.min_price, "min_bar_trade_value_krw": self.min_bar_trade_value_krw, "min_turnover_1m_pct": self.min_turnover_1m_pct, "share_denom": ( float(self.ws.get_share_denom(code)) if getattr(self.ws, "get_share_denom", None) else 0.0 ), "confirm_margin_pct": self.confirm_margin_pct, # 가짜돌파 필터 (0=OFF) "body_min_pct": self.body_min_pct, # 가짜돌파 필터 (0=OFF) "use_ema_filter": self.use_ema_filter, "ema_fast_period": self.ema_fast_period, "ema_slow_period": self.ema_slow_period, "time_start_hm": 0, "time_end_hm": 2400, "entry_mode": breakout_entry_mode(), "live_backtest_align": get_env_bool( "BREAKOUT_LIVE_BACKTEST_ALIGN", True, ), "live_signal_lookback_bars": get_env_int( "BREAKOUT_LIVE_SIGNAL_LOOKBACK_BARS", 1, ), "intrabar_slippage_pct": get_env_float( "BREAKOUT_INTRABAR_SLIPPAGE_PCT", 0.0, ), "_whipsaw_ws": self.ws, "_whipsaw_code": code, "_orderbook_ws": self.ws, "_orderbook_code": code, "_program_ws": self.ws, "_program_code": code, "slot_money": self.slot_money, "skip_hts_scan_dupes": self.skip_hts_scan_dupes, } mode = breakout_entry_mode(params) forming = None if mode in _INTRABAR_ENTRY_MODES: curr_price = self._live_current_price(code) if curr_price <= 0: # 저유동 B안: 체결 틱 없음 — 60초 1회 INFO (debug 만이면 탈락 이유 안 보임) if not hasattr(self, "_no_price_log"): self._no_price_log = {} last = self._no_price_log.get(code, 0) gap = 15.0 if get_env_bool("SCAN_REJECT_LOG_VERBOSE", False) else 60.0 if time.time() - last >= gap: self._no_price_log[code] = time.time() self._scan_log("info", code, "🔍 [탈락-가격없음] %s(%s) B안 WS시세 없음", name, code) return None forming = None if getattr(self.ws, "get_current_candle", None): forming = self.ws.get_current_candle(code, self.candle_tf) if not forming: now_key = dt.now().strftime("%Y%m%d%H%M") forming = { "candle_time": now_key, "open": curr_price, "high": curr_price, "low": curr_price, "close": curr_price, "volume": 0, } bar_key = str(forming.get("candle_time") or "") if bar_key and self._intrabar_fired.get(code) == bar_key: return None reason, msg, signal = check_buy_signal_breakout_intrabar_live( confirmed, curr_price, forming, _breakout_params_with_ema_closes( params, confirmed + ([forming] if forming else []), len(confirmed), forming_close=curr_price, ), ) self._cb_prof_mark(_cb, "engine") if reason: self._scan_log("info", code, "🔍 [%s] %s(%s) %s", reason, name, code, msg or "") return None if not signal: return None if bar_key: self._intrabar_fired[code] = bar_key curr_price = float(signal.get("entry_price") or curr_price) else: reason, msg, signal = check_buy_signal_breakout_live(confirmed, params) self._cb_prof_mark(_cb, "engine") if reason: self._scan_log("info", code, "🔍 [%s] %s(%s) %s", reason, name, code, msg or "") return None if not signal: return None align_on = get_env_bool("BREAKOUT_LIVE_BACKTEST_ALIGN", True) entry_open = float(signal.get("entry_price", 0) or 0) entry_src = "ohlc_open" _ebk = str(signal.get("entry_bar_key") or "")[:12] _defer = self._defer_mid_enroll_entry( code, _ebk, int(self.candle_tf or 1), params, ) self._cb_prof_mark(_cb, "mid_enroll") if _defer: self._scan_log("info", code, "🔍 [%s] %s(%s)", _defer, name, code) return None if align_on and entry_open > 0: from kis_trader.engine.tail_tick_replay import live_align_entry_price curr_price, entry_src = live_align_entry_price( self.ws, code, entry_open, entry_bar_key=_ebk, tf_min=int(self.candle_tf or 1), ) else: curr_price = float(signal.get("close") or 0.0) p = self._live_current_price(code) if p > 0: curr_price = p self._cb_prof_mark(_cb, "align") if curr_price <= 0: if get_env_bool("SCAN_REJECT_LOG_VERBOSE", False): if not hasattr(self, "_no_price_log"): self._no_price_log = {} last = self._no_price_log.get(code, 0) if time.time() - last >= 60: self._no_price_log[code] = time.time() self._scan_log("info", code, "🔍 [가격없음] %s(%s)", name, code) else: self.logger.debug("🔍 [가격없음] %s(%s)", name, code) return None invest_cap = self._breakout_invest_cap_krw() if self._live_portfolio_budget_enabled(): qty, _, rej = self._resolve_live_buy_qty(curr_price, invest_cap=invest_cap) if rej: self.logger.info( "🔍 [탈락-%s] %s(%s) price=%.0f cap=%.0f", rej, name, code, curr_price, invest_cap, ) return None else: qty = invest_qty_for_price(curr_price, float(invest_cap)) if qty < 1: self.logger.info( "🔍 [탈락-수량0] %s(%s) price=%.0f cap=%.0f", name, code, curr_price, invest_cap, ) return None self._cb_prof_mark(_cb, "qty") stop_price = curr_price * (1 + self.stop_loss_pct) target_price = curr_price * (1 + self.take_profit_pct) mode_tag = "B" if mode in _INTRABAR_ENTRY_MODES else "A" # A=align: entry_src 는 계산가 출처. B=intrabar: 현재가 경로라 ohlc_open 표기 안 함 if mode in _INTRABAR_ENTRY_MODES: self.logger.info( "🚀 [BREAKOUT-%s] %s(%s) price=%.0f qty=%d 저항=%.0f volX=%.1f prevChg=%.2f%%", mode_tag, name, code, curr_price, qty, signal["resistance"], signal["vol_ratio"], signal["prev_chg"], ) else: self.logger.info( "🚀 [BREAKOUT-%s] %s(%s) price=%.0f qty=%d 저항=%.0f volX=%.1f prevChg=%.2f%% entry_src=%s", mode_tag, name, code, curr_price, qty, signal["resistance"], signal["vol_ratio"], signal["prev_chg"], entry_src, ) return { "code": code, "name": name, "price": curr_price, "qty": qty, "stop_price": stop_price, "target_price": target_price, # 진입 시점 ATR(변동성) — ATR 동적 손절(sl_mode='atr') 용. holding 에 보존되면 # check_sell_signals 가 매수 시점 변동성으로 손절선을 잡는다(백테와 정합). "atr_entry": _breakout_entry_atr_from_candles(confirmed, self.atr_period), "size_class": "", "entry_features": { "resistance": signal["resistance"], "vol_ratio": signal["vol_ratio"], "prev_chg": signal["prev_chg"], }, } # ------------------------------------------------------------------ # 매도 # ------------------------------------------------------------------ def check_sell_signals(self, only_code: Optional[str] = None) -> List[Dict]: if not self.holdings: return [] try: eod_hh, eod_mm = [int(x) for x in self.eod_hm.split(":")] except Exception: eod_hh, eod_mm = 15, 15 now = dt.now() is_eod = False if self.eod_enabled: is_eod = (now.hour > eod_hh) or (now.hour == eod_hh and now.minute >= eod_mm) signals: List[Dict] = [] for code, holding in list(self.holdings.items()): if only_code and code != only_code: continue try: name = holding.get("name", code) buy_price = float(holding.get("buy_price", 0)) qty = int(holding.get("qty", 0)) max_price = float(holding.get("max_price", buy_price)) if qty <= 0 or buy_price <= 0: self.logger.info( "🔍 [매도-잘못된보유] %s(%s) qty=%d buy=%.0f", name, code, qty, buy_price, ) continue current_price = self._resolve_sell_price( code, is_eod=is_eod, buy_price=buy_price, ) price_src = "WS" if current_price <= 0: # 가격 소스 실패 — 매도 판단 불가. 분당 1회만 경고. EOD는 폴백으로 >0. last_warn = getattr(self, "_sell_no_price_log", {}).get(code, 0) if time.time() - last_warn >= 60: if not hasattr(self, "_sell_no_price_log"): self._sell_no_price_log = {} self._sell_no_price_log[code] = time.time() feed_lab = "" try: if hasattr(self.ws, "get_tick_feed_label"): feed_lab = str(self.ws.get_tick_feed_label(code) or "").strip() except Exception: feed_lab = "" self.logger.warning( "⚠️ [매도-가격없음] %s(%s) WS·REST·폴백 없음 (R:%s) → 매도 판단 보류", name, code, feed_lab or "?", ) continue # 최고가·세션저점 갱신 (모멘텀·백테 intrabar 와 동일 — 폴링 사이 저점도 손절·트레일에 반영) if current_price > max_price: max_price = current_price holding["max_price"] = max_price session_low = float(holding.get("session_low", buy_price) or buy_price) if session_low <= 0: session_low = buy_price if current_price < session_low: session_low = current_price holding["session_low"] = session_low profit_pct = (current_price - buy_price) / buy_price # ── 청산 판정: 백테와 100% 동일한 공용 함수에 위임 (중복 제거) ── # 실매: high=누적고점, low=세션저점(현재가 포함) — 단일점(현재가=고=저)보다 # 손절·어깨·트레일·시간컷 판정이 백테·모멘텀과 정합. sell_params = { "stop_loss_pct": self.stop_loss_pct, "take_profit_pct": self.take_profit_pct, "trail_pct": self.trail_pct, "trail_arm_pct": self.trail_arm_pct, "shoulder_min_high": self.shoulder_min_high, "shoulder_cut_pct": self.shoulder_cut_pct, "ratchet_tiers": self.ratchet_tiers_raw, "max_hold_bars": self.max_hold_bars, # ATR 동적 손절 (sl_mode='atr' 일 때만 활성, 기본 fixed=기존 고정%) "sl_mode": self.sl_mode, "atr_sl_mult": self.atr_sl_mult, "atr_sl_min_pct": self.atr_sl_min_pct, "atr_sl_max_pct": self.atr_sl_max_pct, "exit_ob_enabled": self.exit_ob_enabled, "exit_ob_ratio_min": self.exit_ob_ratio_min, "exit_ob_ma_window": self.exit_ob_ma_window, "exit_ob_min_profit_pct": self.exit_ob_min_profit_pct, "exit_ob_min_hold_bars": self.exit_ob_min_hold_bars, "stop_ob_enabled": self.stop_ob_enabled, "stop_ob_ratio_min": self.stop_ob_ratio_min, "stop_ob_ma_window": self.stop_ob_ma_window, "stop_ob_min_loss_pct": self.stop_ob_min_loss_pct, "stop_ob_min_hold_bars": self.stop_ob_min_hold_bars, } # 수익구간·손절호가: 전략 WS(돌파=LS) 호가 OR — 둘 중 ON일 때만 from kis_trader.engine.momentum_hts_logic import ( append_live_exit_ob_or, need_ob_or_history, _ob_or_ma_window_for_history, ) if need_ob_or_history(sell_params): hist = list(holding.get("_ob_or_history") or []) hist = append_live_exit_ob_or( hist, self.ws, code, ma_window=_ob_or_ma_window_for_history(sell_params), ) holding["_ob_or_history"] = hist sell_params["_ob_or_history"] = list(hist) else: sell_params["_ob_or_history"] = [] # 진입 변동성(ATR): 매수 시 저장값 우선, 없으면 1회 lazy 계산 후 holding 캐시. # sl_mode='fixed' 면 계산 자체를 건너뛴다(불필요한 캔들 조회 방지). entry_atr = float(holding.get("entry_atr") or holding.get("atr_entry") or 0.0) if self.sl_mode == "atr" and entry_atr <= 0.0: _cand = self.ws.get_candles(code, self.candle_tf, n=self.atr_period + 10) entry_atr = _breakout_entry_atr_from_candles(_cand, self.atr_period) if entry_atr > 0.0: holding["entry_atr"] = entry_atr sell_pos = { "entry_price": buy_price, "max_price": max_price, "entry_time": holding.get("buy_time", ""), "entry_atr": entry_atr, } live_candle = { "high": max(max_price, current_price), "low": session_low, "close": current_price, "candle_time": now.strftime("%Y-%m-%d %H:%M:%S"), } rp = check_sell_signal_breakout_live( sell_pos, live_candle, sell_params, is_eod=is_eod, ) # 공용 함수가 max_price 를 갱신 → 보유 정보에 즉시 반영(다음 폴링 정합) new_mp = float(sell_pos.get("max_price", max_price)) if new_mp > max_price: max_price = new_mp holding["max_price"] = max_price reason = rp[0] if rp else None exit_price = float(rp[1]) if rp else current_price if not reason: # 보유 중이지만 매도 조건 미충족 — 종목별 60초 1회 상태 로그 if not hasattr(self, "_sell_state_log"): self._sell_state_log = {} last = self._sell_state_log.get(code, 0) if time.time() - last >= 60: self._sell_state_log[code] = time.time() sl_line = _breakout_sl_line( buy_price, self.stop_loss_pct, sell_pos, sell_params, ) tp_line = buy_price * (1 + self.take_profit_pct) trail_arm_line = buy_price * (1.0 + self.trail_arm_pct) trail_armed = ( max_price > buy_price and max_price >= trail_arm_line ) trail_line = ( max_price * (1 - self.trail_pct) if trail_armed else 0.0 ) sh_armed = max_price >= buy_price * (1.0 + self.shoulder_min_high) sh_line = max_price * (1.0 - self.shoulder_cut_pct) if sh_armed else 0.0 hold_min = _breakout_minutes_held(sell_pos, live_candle) mh = int(self.max_hold_bars or 0) mh_left = ( max(0, mh - hold_min) if (mh > 0 and hold_min is not None) else -1 ) self.logger.info( "🔍 [보유중] %s(%s) 현재=%.0f 매수=%.0f (%.2f%%) " "손절=%.0f 익절=%.0f 어깨=%.0f 트레일=%.0f " "보유=%s분 시간컷잔여=%s [%s]", name, code, current_price, buy_price, profit_pct * 100.0, sl_line, tp_line, sh_line, trail_line, hold_min if hold_min is not None else "?", f"{mh_left}분" if mh_left >= 0 else "OFF", price_src, ) continue signals.append({ "code": code, "name": name, "current_price": current_price, "price": exit_price, "qty": qty, "buy_price": buy_price, "profit_pct": profit_pct, "reason": reason, }) except Exception as e: self.logger.error("매도 시그널 체크 오류(%s): %s", code, e) return signals