Files
kis_bot/kis_trader/strategies/breakout.py
Your Name bc2b1b642c feat(execution): AccountOrderWorker로 매수·매도 주문 직렬화
전략별 tick/scan 매도 락 대신 계좌 단일 PriorityQueue로 place를 B-full 직렬화한다.
틱매도 only_code 필터와 inflight 중복 enqueue 방지로 REST 폭주를 줄인다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 16:45:26 +09:00

2142 lines
101 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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<start or 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