Changes: - Introduced new files for strategy definitions and study names. - Enhanced `backtest_web.py` with functions to handle integer display prices and trade data formatting. - Updated backtesting logic to incorporate end-of-day (EOD) parameters for breakout and momentum strategies. - Added EOD configuration options in the database and parameter search files. Impact: - These changes improve the modularity and usability of the backtesting framework, allowing for better integration of EOD strategies and clearer trade data presentation.
1866 lines
86 KiB
Python
1866 lines
86 KiB
Python
"""
|
||
kis_trader/strategies/breakout.py — 돌파 매매 (Breakout)
|
||
=============================================================
|
||
전제 (SCAN vs TRIGGER):
|
||
* [SCAN] HTS 조건식은 **널넬하게** — 후보 풀만 확보 (등락·거래량·돌파를 HTS에서
|
||
약하게 걸거나 일부 조건 제거). ETF/관리종목 제외 등 구조 필터만 HTS에 둠.
|
||
* [TRIGGER] 본 파일 매수체크는 **엄격하게** — 등락·고가돌파·회전율(유통주식 대비)
|
||
·선택 vol_mult(직전봉 평균 대비 배수)·골든타임·이격·양봉 등은 **항상 코드**에서 검사.
|
||
HTS 와 수치를 맞출 필요 없음 — HTS 는 후보만 넓게.
|
||
* 진입은 '장 초반 골든타임' 한정 (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 이후 전량)
|
||
* 백테 청산: 1분 OHLC → N회 intrabar (``check_sell_signal_backtest_bar`` + ``check_sell_signal_breakout_live``)
|
||
|
||
주문 집행은 모두 ``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.scalping_engine import check_sell_signal_backtest_bar
|
||
from ..engine.tail_engine 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 _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) 변환. 백테(YYYYMMDDHHMM)·실매(YYYY-MM-DD HH:MM:SS) 모두 허용."""
|
||
if s is None:
|
||
return None
|
||
txt = str(s).strip()
|
||
if not txt:
|
||
return None
|
||
try:
|
||
if "-" in txt or ":" in txt:
|
||
# 실매 포맷 "YYYY-MM-DD HH:MM:SS"
|
||
d = dt.strptime(txt[:19], "%Y-%m-%d %H:%M:%S")
|
||
else:
|
||
# 백테 candle_time "YYYYMMDDHHMM[SS]"
|
||
digits = "".join(ch for ch in txt if ch.isdigit())
|
||
if len(digits) < 12:
|
||
return None
|
||
d = dt.strptime(digits[:12], "%Y%m%d%H%M")
|
||
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))
|
||
|
||
need_len = max(lookback_min, vol_window) + 2
|
||
if i < 2 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]
|
||
prev_prev_c = candles[i - 2]
|
||
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)
|
||
|
||
# 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))
|
||
need_n = 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_wants_tick_replay(params: Optional[Dict[str, Any]] = None) -> bool:
|
||
"""B안 + 틱 DB 백테 재생이 필요한지."""
|
||
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 근사 폴백."""
|
||
if params is not None and params.get("backtest_tick_fallback_ohlc") is not None:
|
||
return _to_bool(params.get("backtest_tick_fallback_ohlc"), True)
|
||
return get_env_bool("BREAKOUT_BACKTEST_TICK_FALLBACK_OHLC", True)
|
||
|
||
|
||
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))
|
||
need_n = 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) 직전봉 등락 (_eval step2: prev/prev_prev = 확정봉 끝 2개)
|
||
if 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) 저항선(불변): price ≤ 저항 인 틱은 _eval 도 '탈락-저항미돌파'(sig 없음)
|
||
_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 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 생략 (결과 동일: sig 없음)
|
||
if 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 {}
|
||
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")
|
||
|
||
return {
|
||
"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",
|
||
}
|
||
|
||
|
||
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]
|
||
|
||
use_ticks = breakout_backtest_use_tick_db(params) and minute_ticks is not None
|
||
if use_ticks and 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
|
||
if not breakout_backtest_tick_fallback_ohlc(params):
|
||
return r, m, 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_high" if use_ticks else "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))
|
||
need_n = 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)
|
||
|
||
# 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(YYYYMMDDHHMI) → datetime."""
|
||
return dt.strptime(t, "%Y%m%d%H%M")
|
||
|
||
|
||
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_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
|
||
|
||
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)
|
||
rp = check_sell_signal_backtest_bar(
|
||
position,
|
||
bar,
|
||
params,
|
||
is_eod=is_eod,
|
||
sell_fn=check_sell_signal_breakout_live,
|
||
low_mode="current",
|
||
)
|
||
if rp:
|
||
reason, exit_price = 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
|
||
)
|
||
hold_min = int(
|
||
(_bt_t2dt(c["candle_time"]) - _bt_t2dt(position["entry_time"])).total_seconds() / 60
|
||
)
|
||
all_trades.append({
|
||
"code": code,
|
||
"buy_time": position["entry_time"],
|
||
"sell_time": 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(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)
|
||
|
||
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 현재가 → 틱 링버퍼 폴백."""
|
||
wsd = self.ws.get_price(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
|
||
return 0.0
|
||
|
||
def check_buy(self, code: str, name: str) -> Optional[Dict]:
|
||
# 골든타임 가드 (_candidate_filter 가 미리 거른 뒤에도 방어용 재검사)
|
||
if not self._is_golden_time():
|
||
return None
|
||
|
||
need_n = max(self.lookback_min, self.vol_window) + 2
|
||
if self.use_ema_filter:
|
||
need_n = max(need_n, self.ema_slow_period + 5)
|
||
confirmed = self.ws.get_candles(code, self.candle_tf, n=need_n + 5)
|
||
if len(confirmed) < need_n - 1:
|
||
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,
|
||
}
|
||
|
||
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안: 체결 틱 없음 — 정상 스킵. 기본 debug (SCAN_REJECT_LOG_VERBOSE 시 60s 1회 info)
|
||
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.logger.info("🔍 [가격없음] %s(%s) B안", name, code)
|
||
else:
|
||
self.logger.debug("🔍 [가격없음] %s(%s) B안", 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,
|
||
),
|
||
)
|
||
if reason:
|
||
self.logger.info("🔍 [%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)
|
||
if reason:
|
||
self.logger.info("🔍 [%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)
|
||
if align_on and entry_open > 0:
|
||
curr_price = entry_open
|
||
else:
|
||
curr_price = float(signal.get("close") or 0.0)
|
||
p = self._live_current_price(code)
|
||
if p > 0:
|
||
curr_price = p
|
||
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.logger.info("🔍 [가격없음] %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
|
||
|
||
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"
|
||
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"],
|
||
)
|
||
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) -> 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()):
|
||
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 = 0.0
|
||
price_src = ""
|
||
wsd = self.ws.get_price(code)
|
||
if wsd:
|
||
try:
|
||
current_price = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", "")))
|
||
price_src = "WS"
|
||
except Exception:
|
||
current_price = 0.0
|
||
if current_price <= 0:
|
||
pd_ = self.client.inquire_price(code)
|
||
if pd_:
|
||
try:
|
||
current_price = abs(float(str(pd_.get("stck_prpr", 0)).replace(",", "")))
|
||
price_src = "REST"
|
||
except Exception:
|
||
current_price = 0.0
|
||
if current_price <= 0:
|
||
# 가격 소스 둘 다 실패 — 매도 판단 불가. 분당 1회만 경고.
|
||
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()
|
||
self.logger.warning(
|
||
"⚠️ [매도-가격없음] %s(%s) WS+REST 둘 다 실패 → 매도 판단 보류",
|
||
name, code,
|
||
)
|
||
continue
|
||
|
||
# 최고가 갱신
|
||
if current_price > max_price:
|
||
max_price = current_price
|
||
holding["max_price"] = max_price
|
||
|
||
profit_pct = (current_price - buy_price) / buy_price
|
||
|
||
# ── 청산 판정: 백테와 100% 동일한 공용 함수에 위임 (중복 제거) ──
|
||
# 실매는 현재가만 알 수 있으므로 high=low=close=현재가 단일점 봉으로 전달.
|
||
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,
|
||
}
|
||
# 진입 변동성(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": current_price,
|
||
"low": current_price,
|
||
"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
|
||
|
||
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 = buy_price * (1 + self.stop_loss_pct)
|
||
tp_line = buy_price * (1 + self.take_profit_pct)
|
||
trail_line = max_price * (1 - self.trail_pct) if max_price > buy_price 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
|
||
self.logger.info(
|
||
"🔍 [보유중] %s(%s) 현재=%.0f 매수=%.0f (%.2f%%) "
|
||
"손절=%.0f 익절=%.0f 어깨=%.0f 트레일=%.0f [%s]",
|
||
name, code, current_price, buy_price, profit_pct * 100.0,
|
||
sl_line, tp_line, sh_line, trail_line, price_src,
|
||
)
|
||
continue
|
||
|
||
signals.append({
|
||
"code": code,
|
||
"name": name,
|
||
"current_price": current_price,
|
||
"price": current_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
|