전략별 tick/scan 매도 락 대신 계좌 단일 PriorityQueue로 place를 B-full 직렬화한다. 틱매도 only_code 필터와 inflight 중복 enqueue 방지로 REST 폭주를 줄인다. Co-authored-by: Cursor <cursoragent@cursor.com>
2142 lines
101 KiB
Python
2142 lines
101 KiB
Python
"""
|
||
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
|