Files
kis_bot/kis_trader/engine/scalping_engine.py
Your Name e1ac8d119b chore: 작업 중 발생한 부수적 변경 사항 및 누락된 파일 전체 커밋
- 프론트엔드 UI 업데이트 (backtest.html, backtest.js) 엔진 라디오 버튼 통합 관련 반영
- Rust 플러그인(kis_rust_core) 및 컴파일 소스코드 추가
- CLI 백테스트 스크립트 수정 및 최신화
- 기타 스크래치 테스트 스크립트, 로그 요약 마크다운(.md) 등 누락 파일 일괄 반영
- 추가적으로 아직 발견되지 않은 엣지 케이스나 렌더링 오류가 포함되어 있을 가능성이 있음
2026-09-06 17:04:50 +09:00

1448 lines
60 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
kis_trader/engine/scalping_engine.py — 스캘핑(SCALP reversal) 백테·파서치·실매 공통 엔진
====================================================================
SCALP reversal 전용. 모멘텀(MOMENTUM)은 ``momentum_engine.py`` 로 완전 분리됨.
■ 라이브·백테 검증 원칙 (뇌동 분기 금지)
- SCALP 진입/청산: 웹·CLI·실매가 ``check_buy_signal_*`` / ``check_sell_signal_live`` 동일 경로.
- MOMENTUM: ``momentum_engine`` 전용 (SCALP 와 분리).
■ SCALP reversal 공통 로직 (A안 — RSI V자 + scalp_re HTS SCAN)
- SCAN: 키움 ``CONDITION_SCALP_KIWOOM_NAME`` (기본 scalp_re).
- ``SCALP_SKIP_HTS_SCAN_DUPES=true`` (kiwoom_condition 기본): TRIGGER 에 낙폭·RSI·되돌림 중복 검사 생략.
- 매수(reversal): 시간대, 쿨다운, 일일 진입 횟수, RSI 과매도/과매수, 되돌림(음봉->양봉), 낙폭, 거래량.
- 매수(macd): HTS C — MACD+Stochastic [12,26,5,3,3] 골든크로스 (``SCALP_USE_MACD_CROSS=true``).
- 매수 방어: 고점추격 방지(high_chase_thr), 급등주(max_daily_chg), 최소 가격(min_price).
- 매도(V4, tail_engine 동일): 1순위 어깨컷, 2순위 익절, 3순위 손절, 4순위 금액손실컷, 5순위 장마감청산.
- 백테 SCALP 청산: 1분 OHLC → N회 intrabar (``check_sell_signal_backtest_bar``).
캔들 형식: list of dict with keys candle_time(YYYYMMDDHHMI), open, high, low, close, volume
"""
from datetime import datetime
from typing import List, Dict, Any, Optional, Tuple, Callable
from kis_trader.utils.env import get_env_int
def _frac_from_env_keys(r: Optional[dict], keys: Tuple[str, ...], default: float) -> float:
"""env_config 행에서 비율(0.003=0.3%) 값 로드. 키 순서대로 fallback."""
if r:
for k in keys:
v = r.get(k)
if v not in (None, ""):
try:
return float(v)
except (ValueError, TypeError):
continue
return float(default)
def resolve_effective_tp_pct(tp_pct: float, tp_max_pct: float) -> float:
"""익절 % — tp_max_pct 상한 적용 (꼬리 TAIL_ATR_TP_MAX_PCT 와 동일 개념).
tp_pct=2.5%, tp_max=2% → 실제 2% 목표가.
tp_max=0 이면 상한 미적용(OFF).
"""
tp = abs(float(tp_pct))
cap = abs(float(tp_max_pct))
if cap > 0:
return min(tp, cap)
return tp
def effective_tp_pct_from_params(params: Dict[str, Any]) -> float:
"""params['tp_pct'] + params['tp_max_pct'] → 진입·청산용 실효 익절 비율."""
return resolve_effective_tp_pct(
params.get("tp_pct", 0.015),
params.get("tp_max_pct", 0.02),
)
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_scalp_skip_hts_scan_dupes(r: Optional[Dict[str, Any]] = None) -> bool:
"""
HTS scalp_re SCAN(kiwoom_condition) 사용 시 TRIGGER 중복 필터 생략 여부.
- ``SCALP_SKIP_HTS_SCAN_DUPES`` 명시(true/false) → 그대로
- 미설정 → ``SCALP_UNIVERSE_SOURCE`` 가 condition/kiwoom_condition 이면 True
- ranking 등 전종목 소스 → False (TRIGGER 에서 HTS A/B/C 재검사)
"""
if r is None:
try:
from kis_trader.utils.env import get_strategy_env_dict
r = get_strategy_env_dict("SCALP") or {}
except Exception:
r = {}
raw = r.get("SCALP_SKIP_HTS_SCAN_DUPES")
if raw is not None and str(raw).strip() != "":
return _to_bool(raw, True)
# 엔진 defaults dict(이미 skip_hts_scan_dupes 해석됨)를 넘긴 경우 —
# env 키 없음 → universe fallback(True) 로 덮어쓰지 않음 (실매 false 고정 재발 방지)
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("SCALP_UNIVERSE_SOURCE") or "condition").strip().lower()
return src in ("kiwoom_condition", "condition")
# DB 기본값 로드 (백테스트/param_search가 동일한 값 사용하도록 단일 소스)
def get_scalping_defaults_from_db(*, env_row: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
config_scalp + env_config 병합에서 스캘핑 관련 기본값 로드.
백테스트 API·param_search·실매(get_env_from_db)가 동일 merged 소스를 사용.
env_row 가 있으면 DB 조회 생략 (웹 백테 env 타임라인용).
"""
try:
if env_row is not None:
r = dict(env_row)
else:
from kis_trader.utils.env import get_strategy_env_dict
r = get_strategy_env_dict("SCALP")
if not r:
from database import TradeDB
db = TradeDB()
if hasattr(db, "get_merged_env_snapshot"):
r = db.get_merged_env_snapshot()
else:
latest = db.get_latest_env() if hasattr(db, "get_latest_env") else None
r = dict((latest or {}).get("snapshot") or {})
db.close()
if r:
# SCALP_COOLDOWN_SEC(초) → cooldown_min(분). 실매매와 동일 키 사용
sec = r.get("SCALP_COOLDOWN_SEC") or r.get("REENTRY_COOLDOWN_SEC") or "600"
cooldown_min = max(0, int(float(sec)) // 60)
fee_pct = float(r.get("FEE_RATE_PCT") or 0.015)
tax_pct = float(r.get("SELL_TAX_RATE_PCT") or 0.18)
slot = float(r.get("SLOT_MONEY_DEFAULT") or 3_000_000)
# ──────── 방어 로직 (SCALP_* 전용 키 우선 → 공용/타전략 fallback) ────────
# HIGH_CHASE_THR·MOMENTUM_* 를 먼저 읽으면 Optuna/웹 apply 값이 무시됨.
high_chase_thr = float(
r.get("SCALP_HIGH_PRICE_CHASE_THRESHOLD")
or r.get("HIGH_CHASE_THR")
or r.get("HIGH_PRICE_CHASE_THRESHOLD")
or 0.96,
)
max_daily_chg = float(
r.get("SCALP_MAX_DAILY_CHANGE_PCT")
or r.get("MAX_DAILY_CHG")
or r.get("MAX_DAILY_CHANGE_PCT")
or 20.0,
)
min_price = float(
r.get("SCALP_MIN_PRICE")
or r.get("MOMENTUM_MIN_PRICE")
or r.get("MIN_PRICE_TAIL")
or 1000.0,
)
max_loss_krw = int(
float(
r.get("SCALP_MAX_LOSS_PER_TRADE_KRW")
or r.get("MOMENTUM_MAX_LOSS_PER_TRADE_KRW")
or r.get("MAX_LOSS_PER_TRADE_KRW")
or 200000,
),
)
_min_drop_loss = r.get("SCALP_MIN_DROP_PCT_FOR_LOSS_CUT")
min_drop_pct_for_loss_cut = 0.015
if _min_drop_loss not in (None, ""):
v = float(_min_drop_loss)
min_drop_pct_for_loss_cut = v / 100.0 if v >= 1 else v
min_margin = float(
r.get("SCALP_MIN_PROFIT_PCT")
or r.get("MOMENTUM_MIN_PROFIT_PCT")
or 0.2,
)
rsi_overbought = float(r.get("SCALP_RSI_OVERBOUGHT") or 75.0)
use_defense_filters = _to_bool(r.get("SCALP_USE_DEFENSE_FILTERS"), True)
use_macd_cross = _to_bool(r.get("SCALP_USE_MACD_CROSS"), False)
macd_fast = int(float(r.get("SCALP_MACD_FAST") or 12))
macd_slow = int(float(r.get("SCALP_MACD_SLOW") or 26))
macd_signal = int(float(r.get("SCALP_MACD_SIGNAL") or 5))
stoch_k_period = int(float(r.get("SCALP_STOCH_K_PERIOD") or 5))
stoch_d_period = int(float(r.get("SCALP_STOCH_D_PERIOD") or 3))
stoch_slow = int(float(r.get("SCALP_STOCH_SLOW") or 3))
skip_hts_scan_dupes = resolve_scalp_skip_hts_scan_dupes(r)
shoulder_min_high = _frac_from_env_keys(
r,
("SCALP_SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_HIGH_PCT"),
0.005,
)
shoulder_cut_pct = _frac_from_env_keys(
r,
("SCALP_SHOULDER_CUT_PCT", "SHOULDER_CUT_PCT"),
0.003,
)
_mhs = r.get("SCALP_MIN_HOLD_SEC") or r.get("MIN_HOLD_SEC")
min_hold_sec = float(_mhs) if _mhs not in (None, "") else 30.0
tp_max_pct = _frac_from_env_keys(r, ("SCALP_TP_MAX_PCT",), 0.02)
max_stocks = int(float(r.get("SCALP_MAX_STOCKS") or r.get("MAX_STOCKS") or 3))
total_budget_krw = float(r.get("SCALP_TOTAL_BUDGET_KRW") or 0)
portfolio_mode = _to_bool(r.get("SCALP_PORTFOLIO_MODE"), True)
rsi_period = int(float(
r.get("SCALP_RSI_PERIOD") or r.get("RSI_PERIOD") or 3,
))
sl_pct = abs(float(r.get("SCALP_STOP_LOSS_PCT") or 0.015))
tp_pct = abs(float(r.get("SCALP_TAKE_PROFIT_PCT") or 0.015))
rsi_oversold = float(r.get("SCALP_RSI_OVERSOLD") or 25.0)
drop_rate = float(r.get("SCALP_MIN_DROP_RATE") or 0.015)
require_reversal_candle = _to_bool(r.get("SCALP_REQUIRE_REVERSAL_CANDLE"), True)
_vm = r.get("VOL_MULTIPLIER")
vol_mult = float(_vm) if _vm not in (None, "") else 0.0
time_start_hm = int(float(r.get("SCALP_TIME_START") or r.get("TIME_START") or 900))
time_end_hm = int(float(r.get("SCALP_TIME_END") or r.get("TIME_END") or 1530))
max_daily = int(float(r.get("SCALP_MAX_DAILY") or r.get("MAX_DAILY") or 3))
# EOD — 실매 기존 15:25 장마감청산과 동일 (strategy_eod SCALP)
eod_enabled = _to_bool(r.get("SCALP_EOD_ENABLED"), True)
if r.get("SCALP_EOD_ENABLED") in (None, "", "None") and r.get("force_eod_exit") not in (None, "", "None"):
eod_enabled = _to_bool(r.get("force_eod_exit"), True)
eod_hm = str(r.get("SCALP_EOD_HM") or "15:25").strip() or "15:25"
# 신호=T1 확정봉, 진입=형성 중 봉 T 시가(첫 틱) — 모멘텀/꼬리 BT 정합
# DB 신규 컬럼 NULL→'' 이면 _to_bool('')=False 가 되므로 빈값은 미설정 취급
_la = r.get("SCALP_LIVE_BACKTEST_ALIGN")
live_backtest_align = (
True if _la in (None, "") else _to_bool(_la, True)
)
_lb = r.get("SCALP_LIVE_SIGNAL_LOOKBACK_BARS")
live_signal_lookback_bars = int(float(_lb or 1))
_lf = r.get("SCALP_LIVE_ALIGN_USE_FORMING_BAR")
live_align_use_forming_bar = (
True if _lf in (None, "") else _to_bool(_lf, True)
)
backtest_use_tick_entry = _to_bool(
r.get("SCALP_BACKTEST_USE_TICK_ENTRY"), True,
)
backtest_tick_fallback_ohlc = _to_bool(
r.get("SCALP_BACKTEST_TICK_FALLBACK_OHLC"), False,
)
else:
cooldown_min, fee_pct, tax_pct, slot = 10, 0.015, 0.18, 300_000.0
high_chase_thr, max_daily_chg, min_price = 0.96, 20.0, 1000.0
max_loss_krw, min_margin, rsi_overbought = 200000, 0.2, 75.0
min_drop_pct_for_loss_cut = 0.015
use_defense_filters = True
use_macd_cross = False
macd_fast, macd_slow, macd_signal = 12, 26, 5
stoch_k_period, stoch_d_period, stoch_slow = 5, 3, 3
skip_hts_scan_dupes = True
shoulder_min_high, shoulder_cut_pct, min_hold_sec = 0.005, 0.003, 30.0
tp_max_pct = 0.02
max_stocks, total_budget_krw, portfolio_mode = 3, 0.0, True
rsi_period = 3
sl_pct, tp_pct = 0.015, 0.015
rsi_oversold, drop_rate = 25.0, 0.015
require_reversal_candle = True
vol_mult = 0.0
time_start_hm, time_end_hm, max_daily = 900, 1530, 3
eod_enabled, eod_hm = True, "15:25"
live_backtest_align, live_signal_lookback_bars = True, 1
live_align_use_forming_bar = True
backtest_use_tick_entry, backtest_tick_fallback_ohlc = True, False
except Exception:
cooldown_min, fee_pct, tax_pct, slot = 10, 0.015, 0.18, 300_000.0
high_chase_thr, max_daily_chg, min_price = 0.96, 20.0, 1000.0
max_loss_krw, min_margin, rsi_overbought = 200000, 0.2, 75.0
min_drop_pct_for_loss_cut = 0.015
use_defense_filters = True
use_macd_cross = False
macd_fast, macd_slow, macd_signal = 12, 26, 5
stoch_k_period, stoch_d_period, stoch_slow = 5, 3, 3
skip_hts_scan_dupes = True
shoulder_min_high, shoulder_cut_pct, min_hold_sec = 0.005, 0.003, 30.0
tp_max_pct = 0.02
max_stocks, total_budget_krw, portfolio_mode = 3, 0.0, True
rsi_period = 3
sl_pct, tp_pct = 0.015, 0.015
rsi_oversold, drop_rate = 25.0, 0.015
require_reversal_candle = True
vol_mult = 0.0
time_start_hm, time_end_hm, max_daily = 900, 1530, 3
eod_enabled, eod_hm = True, "15:25"
live_backtest_align, live_signal_lookback_bars = True, 1
live_align_use_forming_bar = True
backtest_use_tick_entry, backtest_tick_fallback_ohlc = True, False
return {
"cooldown_min": cooldown_min,
"time_start_hm": time_start_hm,
"time_end_hm": time_end_hm,
"time_start": time_start_hm,
"time_end": time_end_hm,
"fee_rate": fee_pct / 100,
"sell_tax": tax_pct / 100,
"slot_money": slot,
"rsi_period": rsi_period,
"rsi_oversold": rsi_oversold,
"sl_pct": sl_pct,
"tp_pct": tp_pct,
"drop_rate": drop_rate,
"require_reversal_candle": require_reversal_candle,
"vol_mult": vol_mult,
"trail_trigger": 0.007,
"trail_stop": 0.004,
"max_daily": max_daily,
"high_chase_thr": high_chase_thr,
"max_daily_chg": max_daily_chg,
"min_price": min_price,
"max_loss_krw": max_loss_krw,
"min_drop_pct_for_loss_cut": min_drop_pct_for_loss_cut,
"min_margin": min_margin / 100,
"rsi_overbought": rsi_overbought,
"use_defense_filters": use_defense_filters,
"use_macd_cross": use_macd_cross,
"macd_fast": macd_fast,
"macd_slow": macd_slow,
"macd_signal": macd_signal,
"stoch_k_period": stoch_k_period,
"stoch_d_period": stoch_d_period,
"stoch_slow": stoch_slow,
"skip_hts_scan_dupes": skip_hts_scan_dupes,
"shoulder_min_high": shoulder_min_high,
"shoulder_cut_pct": shoulder_cut_pct,
"min_hold_sec": min_hold_sec,
"tp_max_pct": tp_max_pct,
"max_stocks": max_stocks,
"total_budget_krw": total_budget_krw,
"portfolio_mode": portfolio_mode,
"eod_enabled": eod_enabled,
"eod_hm": eod_hm,
# 레거시 별칭 — 포트폴리오/단건 엔진 force_eod 경로 호환
"force_eod_exit": eod_enabled,
"live_backtest_align": live_backtest_align,
"live_signal_lookback_bars": live_signal_lookback_bars,
"live_align_use_forming_bar": live_align_use_forming_bar,
"backtest_use_tick_entry": backtest_use_tick_entry,
"backtest_tick_fallback_ohlc": backtest_tick_fallback_ohlc,
}
def compute_rsi_series(closes: list, period: int = 3) -> list:
"""RSI 시리즈 계산 (Wilder 스무딩). backtest_web과 동일."""
rsi_list = [None] * len(closes)
if len(closes) < period + 1:
return rsi_list
deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
gains = [max(d, 0) for d in deltas]
losses = [max(-d, 0) for d in deltas]
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
for i in range(period, len(closes)):
idx = i - 1
if i > period:
avg_gain = (avg_gain * (period - 1) + gains[idx]) / period
avg_loss = (avg_loss * (period - 1) + losses[idx]) / period
rs = avg_gain / avg_loss if avg_loss > 0 else float("inf")
rsi_val = 100 - (100 / (1 + rs)) if avg_loss > 0 else 100.0
rsi_list[i] = rsi_val
return rsi_list
def _ema_series(values: List[float], period: int) -> List[Optional[float]]:
"""지수이동평균(EMA) 시리즈. 초기값은 SMA."""
n = len(values)
out: List[Optional[float]] = [None] * n
if period <= 0 or n < period:
return out
mult = 2.0 / (period + 1)
ema = sum(values[:period]) / period
out[period - 1] = ema
for i in range(period, n):
ema = (values[i] - ema) * mult + ema
out[i] = ema
return out
def _sma_optional_series(values: List[Optional[float]], period: int) -> List[Optional[float]]:
"""None 을 건너뛰지 않고, 윈도우 내 유효값만으로 SMA."""
n = len(values)
out: List[Optional[float]] = [None] * n
if period <= 0:
return out
for i in range(period - 1, n):
window = [v for v in values[i - period + 1 : i + 1] if v is not None]
if len(window) == period:
out[i] = sum(window) / period
return out
def compute_macd_stoch_hts_lines(
candles: List[Dict],
macd_fast: int = 12,
macd_slow: int = 26,
macd_signal: int = 5,
stoch_k_period: int = 5,
stoch_d_period: int = 3,
stoch_slow: int = 3,
) -> Tuple[
List[Optional[float]],
List[Optional[float]],
List[Optional[float]],
List[Optional[float]],
List[Optional[float]],
List[Optional[float]],
]:
"""
HTS 조건식 C — MACD+Stochastic [12,26,5,3,3] 복합선.
- fast_line = MACD + Slow%K
- slow_line = Signal + Slow%D
- 골든크로스: fast 가 slow 를 0봉전(현재봉) 상향돌파
Returns:
macd_line, signal_line, slow_k, slow_d, fast_combined, slow_combined
"""
n = len(candles)
closes = [float(c["close"]) for c in candles]
highs = [float(c["high"]) for c in candles]
lows = [float(c["low"]) for c in candles]
ema_fast = _ema_series(closes, macd_fast)
ema_slow = _ema_series(closes, macd_slow)
macd_line: List[Optional[float]] = [None] * n
for i in range(n):
if ema_fast[i] is not None and ema_slow[i] is not None:
macd_line[i] = ema_fast[i] - ema_slow[i]
macd_vals = [v for v in macd_line if v is not None]
signal_line: List[Optional[float]] = [None] * n
if len(macd_vals) >= macd_signal:
sig_sub = _ema_series(macd_vals, macd_signal)
idx = 0
for i in range(n):
if macd_line[i] is not None:
if idx < len(sig_sub):
signal_line[i] = sig_sub[idx]
idx += 1
raw_k: List[Optional[float]] = [None] * n
for i in range(stoch_k_period - 1, n):
hh = max(highs[i - stoch_k_period + 1 : i + 1])
ll = min(lows[i - stoch_k_period + 1 : i + 1])
if hh == ll:
raw_k[i] = 50.0
else:
raw_k[i] = (closes[i] - ll) / (hh - ll) * 100.0
slow_k = _sma_optional_series(raw_k, stoch_slow)
slow_d = _sma_optional_series(slow_k, stoch_d_period)
fast_combined: List[Optional[float]] = [None] * n
slow_combined: List[Optional[float]] = [None] * n
for i in range(n):
if macd_line[i] is not None and slow_k[i] is not None:
fast_combined[i] = macd_line[i] + slow_k[i]
if signal_line[i] is not None and slow_d[i] is not None:
slow_combined[i] = signal_line[i] + slow_d[i]
return macd_line, signal_line, slow_k, slow_d, fast_combined, slow_combined
def _is_macd_stoch_golden_cross(
fast_combined: List[Optional[float]],
slow_combined: List[Optional[float]],
i: int,
) -> bool:
"""0봉전 골든크로스: fast 가 slow 를 상향돌파."""
if i < 1:
return False
f0, f1 = fast_combined[i], fast_combined[i - 1]
s0, s1 = slow_combined[i], slow_combined[i - 1]
if None in (f0, f1, s0, s1):
return False
return f0 > s0 and f1 <= s1
def _macd_min_bars(params: Dict[str, Any]) -> int:
"""MACD+Stoch 계산에 필요한 최소 봉 수."""
macd_slow = int(params.get("macd_slow", 26))
macd_signal = int(params.get("macd_signal", 5))
stoch_k = int(params.get("stoch_k_period", 5))
stoch_slow = int(params.get("stoch_slow", 3))
stoch_d = int(params.get("stoch_d_period", 3))
return macd_slow + macd_signal + stoch_k + stoch_slow + stoch_d + 5
def _apply_scalp_defense_and_vol(
candles: List[Dict],
i: int,
params: Dict[str, Any],
cl: float,
lo: float,
day: str,
) -> Tuple[str, Optional[str], Optional[str]]:
"""
방어 필터 + 거래량 배수 (reversal·macd 공통).
Returns:
("ok", None, None) — 통과
("reject", code, msg) — 탈락
("skip", None, None) — 당일 시가 없음 (신호 없음)
"""
drop_rate = float(params.get("drop_rate", 0.015))
vol_mult = float(params.get("vol_mult", 0))
high_chase_thr = float(params.get("high_chase_thr", 0.96))
max_daily_chg = float(params.get("max_daily_chg", 20.0))
min_price = float(params.get("min_price", 1000.0))
use_defense_filters = _to_bool(params.get("use_defense_filters", True), True)
skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), False)
if use_defense_filters and cl < min_price:
return ("reject", "탈락-최소가격", "%.0f < %.0f" % (cl, min_price))
running_open = float(candles[i]["open"])
running_low = lo
running_high = float(candles[i]["high"])
for j in range(i - 1, -1, -1):
if candles[j]["candle_time"][:8] != day:
break
running_open = float(candles[j]["open"])
running_low = min(running_low, float(candles[j]["low"]))
running_high = max(running_high, float(candles[j]["high"]))
if running_open <= 0:
return ("skip", None, None)
# HTS scalp_re SCAN(kiwoom) — B/C/F/D 이미 통과. 낙폭·고점·급등·분봉거래량 중복 생략
if use_defense_filters and not skip_hts:
dr = (running_open - running_low) / running_open
if dr < drop_rate:
return ("reject", "탈락-낙폭", "%.2f%% < %.1f%%(SCALP_MIN_DROP_RATE)" % (dr * 100, drop_rate * 100))
if cl >= running_high * high_chase_thr:
return ("reject", "탈락-고점추격", "현재가 %.0f >= 고가 %.0f * %.2f" % (cl, running_high, high_chase_thr))
if running_low > 0:
daily_chg_pct = (running_high - running_low) / running_low * 100
if daily_chg_pct > max_daily_chg:
return ("reject", "탈락-급등주", "일일변동 %.1f%% > %.0f%%" % (daily_chg_pct, max_daily_chg))
if not skip_hts and vol_mult > 0:
volumes = [float(x.get("volume", 0)) for x in candles]
vol = volumes[i] if i < len(volumes) else 0
win = max(1, min(20, i))
vol_avg = sum(volumes[i - win : i]) / win
if vol_avg > 0 and vol < vol_avg * vol_mult:
return ("reject", "탈락-거래량", "%.0f < 평균%.0f × %.1f" % (vol, vol_avg, vol_mult))
return ("ok", None, None)
def _t2dt(t: str) -> datetime:
"""candle_time / 실매 buy_time → datetime (공통 파서)."""
from kis_trader.utils.trade_time import parse_trade_datetime
return parse_trade_datetime(t)
def _slot_key(candle_time: str, scan_interval_min: int = 1) -> str:
"""
봉 시각을 N분 단위 슬롯 키로 변환.
신봇 기준:
* 실매매는 10초 주기 REST 폴링 + 변동 tick 마다 초단위 event_time 저장.
* 백테스트 caller(``param_search``/``tail_param_search``)가
``TradeDBExt.get_universe_by_candle_time()`` 으로 **1분 캔들 시각 키**
(YYYYMMDDHHMM) 를 가진 dict 를 만들어 엔진에 주입한다.
* 엔진은 그 dict 를 분단위(scan_interval_min=1) 키로 바로 조회.
과거 호환:
* ``--fallback-universe`` 시뮬레이션 경로는 여전히 5분 버킷팅을 쓸 수
있도록 ``scan_interval_min`` 파라미터는 남겨 둔다.
"""
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 build_universe_simulation(
codes_candles: Dict[str, List[Dict]],
top_n: int = 20,
min_score: float = 4.0,
scan_interval_min: int = 5,
) -> Dict[str, List[str]]:
"""
과거 1분봉만으로 '5분마다 강도 순 상위 N종목' 유니버스를 흉내 냄.
kiwoom_universe_scanner의 개미털기 점수(drop_rate·회복률)를 1분봉 기준으로 근사.
실매매의 target_candidates 5분 갱신과 동일한 타이밍으로 백테스트 제한용.
Returns:
slot_key(YYYYMMDDHHMM) → 해당 슬롯에 매수 검사 허용할 종목 코드 리스트
"""
slot_codes_scores: Dict[str, List[Tuple[str, float, float]]] = {} # slot -> [(code, score, vol)]
for code, rows in codes_candles.items():
if len(rows) < 2:
continue
candles = [dict(r) for r in rows]
# 날짜별로 묶어서 당일 시가/고/저/종가 누적
by_day: Dict[str, List[Dict]] = {}
for c in candles:
day = c["candle_time"][:8]
if day not in by_day:
by_day[day] = []
by_day[day].append(c)
for day, day_candles in by_day.items():
day_candles.sort(key=lambda x: x["candle_time"])
day_open = float(day_candles[0]["open"])
running_high = max(float(c["high"]) for c in day_candles)
running_low = min(float(c["low"]) for c in day_candles)
vol_sum = sum(float(c.get("volume", 0)) for c in day_candles)
# 5분 단위 슬롯: 해당 슬롯 시작 시점까지의 데이터로 점수 계산 (슬롯 시작 직전 봉까지)
market_open_min = 9 * 60 # 09:00 = 540분
seen_slots = set()
for j, c in enumerate(day_candles):
ct = c["candle_time"]
hm = int(ct[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)
slot_key = day + str(slot_hm).zfill(4)
if slot_key in seen_slots:
continue
seen_slots.add(slot_key)
# as_of: 이 슬롯에 쓰일 마지막 봉 (슬롯 시작 직전 봉, 장시작 09:00 이상)
slot_min_val = (slot_hm // 100) * 60 + (slot_hm % 100)
as_of_min = max(market_open_min, slot_min_val - 1)
as_of_hm = (as_of_min // 60) * 100 + (as_of_min % 60)
as_of_str = day + str(as_of_hm).zfill(4)
up_to = [x for x in day_candles if x["candle_time"] <= as_of_str]
if not up_to:
continue
o = float(up_to[0]["open"])
hi = max(float(x["high"]) for x in up_to)
lo = min(float(x["low"]) for x in up_to)
cl = float(up_to[-1]["close"])
drop_rate = (o - lo) / o if o > 0 else 0
rng = hi - lo
recovery_pos = (cl - lo) / rng if rng > 0 else 0
score = (drop_rate * 100) if (drop_rate >= 0.03 and recovery_pos >= 0.5) else 0.0
vol_part = sum(float(x.get("volume", 0)) for x in up_to)
if slot_key not in slot_codes_scores:
slot_codes_scores[slot_key] = []
slot_codes_scores[slot_key].append((code, score, vol_part))
# 슬롯별 상위 top_n, min_score 이상만
universe_by_slot: Dict[str, List[str]] = {}
for slot_key, lst in slot_codes_scores.items():
lst = [(c, s, v) for c, s, v in lst if s >= min_score]
lst.sort(key=lambda x: (-x[1], -x[2]))
universe_by_slot[slot_key] = [x[0] for x in lst[:top_n]]
return universe_by_slot
# ──────────────────────────────────────────────────────────────────────────────
# [레거시] 모멘텀 유니버스 시뮬 — momentum_engine.build_universe_simulation_momentum 위임
# ──────────────────────────────────────────────────────────────────────────────
def build_universe_simulation_momentum(
codes_candles: Dict[str, List[Dict]],
top_n: int = 20,
min_score: float = 4.0,
scan_interval_min: int = 5,
) -> Dict[str, List[str]]:
"""[폐기] ``momentum_engine.build_universe_simulation_momentum`` 사용."""
from kis_trader.engine.momentum_engine import build_universe_simulation_momentum as _impl
return _impl(codes_candles, top_n=top_n, min_score=min_score, scan_interval_min=scan_interval_min)
def run_scalping_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]]]] = None,
) -> List[Dict]:
"""
종목별 캔들에 대해 스캘핑 백테스트 실행. 실매매와 동일한 규칙(방어로직 포함) 적용.
universe_by_slot이 주어지면, 5분마다 해당 슬롯의 후보 종목에서만 매수 신호를 검사
(실매매의 target_candidates 5분 갱신과 동일한 유니버스 시뮬레이션).
"""
if _to_bool(params.get("use_rust"), False):
from kis_trader.utils.logger import get_logger
get_logger("kis_trader.scalping_engine").info("🚀 Rust 엔진(Experimental)으로 스캘핑 시뮬레이션을 실행합니다.")
return run_scalping_backtest_rust_experimental(codes_candles, params)
if _to_bool(params.get("portfolio_mode"), True):
from kis_trader.backtest.scalping_portfolio_backtest import run_scalping_backtest_portfolio
return run_scalping_backtest_portfolio(
codes_candles, params, universe_by_slot=universe_by_slot, mode="reversal",
ticks_by_code=ticks_by_code,
)
rsi_period = int(params.get("rsi_period", 3))
rsi_oversold = float(params.get("rsi_oversold", 25))
rsi_overbought = float(params.get("rsi_overbought", 75.0))
# 롱 스캘핑: 손절·익절 폭은 양의 비율이어야 함. 폼/DB에서 음수(-1.2%)가 넘어오면
# stop 이 진입가 위로 뒤집혀 같은 봉에서 '손절'로 오표시되며 청산가가 역전되는 버그 발생.
sl_pct = abs(float(params.get("sl_pct", 0.015)))
tp_pct = effective_tp_pct_from_params(params)
drop_rate = float(params.get("drop_rate", 0.015))
slot_money = float(params.get("slot_money", 300_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", 10))
trail_trigger = float(params.get("trail_trigger", 0))
trail_stop = float(params.get("trail_stop", 0.004))
time_start_hm = int(params.get("time_start_hm", 900))
time_end_hm = int(params.get("time_end_hm", 1530))
max_daily = int(params.get("max_daily", 3))
vol_mult = float(params.get("vol_mult", 0))
# 방어 로직 파라미터
high_chase_thr = float(params.get("high_chase_thr", 0.96))
max_daily_chg = float(params.get("max_daily_chg", 20.0))
min_price = float(params.get("min_price", 1000.0))
max_loss_krw = float(params.get("max_loss_krw", 200000.0))
min_margin = float(params.get("min_margin", 0.002))
use_defense_filters = _to_bool(params.get("use_defense_filters", True), True)
use_macd_cross = _to_bool(params.get("use_macd_cross", False), False)
from kis_trader.engine.strategy_eod import is_strategy_eod_bar
from kis_trader.engine.tick_exit_common import (
backtest_sell_slip_pct,
backtest_tick_poll_ms,
collect_minute_ticks,
resolve_backtest_sell,
strategy_tick_fallback_ohlc,
strategy_use_tick_exit,
)
use_tick_exit = bool(ticks_by_code) and strategy_use_tick_exit(
params, "SCALP_BACKTEST_USE_TICK_EXIT", default=True,
)
tick_fallback_ohlc = strategy_tick_fallback_ohlc(
params, "SCALP_BACKTEST_TICK_FALLBACK_OHLC", default=False,
)
tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="SCALP_BACKTEST_POLL_MS")
tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="SCALP_BACKTEST_SELL_SLIP_PCT")
all_trades: List[Dict] = []
for code, rows in codes_candles.items():
if len(rows) < rsi_period + 5:
continue
candles = [dict(r) for r in rows]
closes = [float(c["close"]) for c in candles]
volumes = [float(c.get("volume", 0)) for c in candles]
rsis = compute_rsi_series(closes, rsi_period)
macd_combined = _macd_lines_from_params(candles, params) if use_macd_cross else None
position: Optional[Dict] = None
last_exit_dt: Dict[str, datetime] = {}
daily_cnt: Dict[str, int] = {}
cur_day = None
running_open = 0.0
running_high = 0.0
running_low = 0.0
for i in range(rsi_period + 1, len(candles)):
c = candles[i]
day = c["candle_time"][:8]
hm = int(c["candle_time"][8:12])
cl = float(c["close"])
lo = float(c["low"])
hi = float(c["high"])
vol = volumes[i] if i < len(volumes) else 0
# 당일 고가/저가 누적 (피뢰침 방지용)
if day != cur_day:
cur_day = day
running_open = float(c["open"])
running_high = hi
running_low = lo
else:
running_high = max(running_high, hi)
running_low = min(running_low, lo)
# 실매 SCALP_EOD_HM(기본 15:25) 과 동일 — force_eod 마지막봉만 의존 금지
is_eod = is_strategy_eod_bar(c["candle_time"], params, "SCALP")
# ── 포지션 보유 중: 청산 체크 ──
if position is not None:
# 진입봉(같은 candle_time)에서는 청산 금지:
# 백테스트가 이미 알고 있는 봉의 고/저를 즉시 사용하는 look-ahead를 방지.
if c["candle_time"] == position["entry_time"]:
continue
# 틱 있으면 resolve_backtest_sell — 없으면 기존 OHLC N회 청산
reason = None
exit_price = cl
sell_time = c["candle_time"]
if use_tick_exit:
minute_ticks = collect_minute_ticks(
ticks_by_code, code, c["candle_time"],
)
res5 = resolve_backtest_sell(
position,
c,
params,
is_eod=is_eod,
sell_fn=check_sell_signal_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 res5:
reason, exit_price, sell_time, _hold, _src = res5
else:
# check_sell_signal_backtest_bar — 1분봉 N회 청산(실매 current_price 정렬)
reason, exit_price = check_sell_signal_backtest_bar(
position, c, params, is_eod,
) or (None, cl)
if reason:
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(
(_t2dt(sell_time or c["candle_time"]) - _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,
"rsi_entry": round(position["rsi"], 1),
})
last_exit_dt[day] = _t2dt(sell_time or c["candle_time"])
position = None
continue
# ── 포지션 없음: 매수 신호 (유니버스 시뮬레이션 시 해당 슬롯 후보만 검사) ──
if universe_by_slot is not None:
# 신봇 기본: 1분봉 == 슬롯 키. simulation fallback 은 caller 가
# ``scan_interval_min`` 을 params 에 명시해 5분 슬롯으로 바꿀 수 있음.
slot_key = _slot_key(c["candle_time"], params.get("scan_interval_min", 1))
if code not in universe_by_slot.get(slot_key, []):
continue
bt_state = {
"last_exit_dt": last_exit_dt.get(day),
"daily_cnt": daily_cnt.get(day, 0),
}
st = _apply_buy_state_filters(
candles, i, params,
bt_state,
)
if st[2] is None:
continue
reject, _, sig = _eval_scalp_buy_at_index(candles, i, params, macd_combined=macd_combined)
if reject or not sig:
continue
rsi = sig.get("rsi")
if rsi is None:
continue
# 다음 봉 시가에 진입
if i + 1 >= len(candles):
continue
next_c = candles[i + 1]
if next_c["candle_time"][:8] != day:
continue
entry_price = float(next_c["open"])
if entry_price <= 0:
continue
# 포지션 크기 계산 (최대 금액 손실 컷 기반)
invest_amount = slot_money
if max_loss_krw > 0 and sl_pct > 0:
invest_limit = max_loss_krw / sl_pct
invest_amount = min(invest_limit, slot_money)
qty = max(1, int(invest_amount / entry_price))
stop = entry_price * (1 - sl_pct)
target = entry_price * (1 + tp_pct)
position = {
"entry_price": entry_price,
"entry_time": next_c["candle_time"],
"qty": qty,
"stop": stop,
"target": target,
"max_price": entry_price,
"rsi": rsi,
}
daily_cnt[day] = daily_cnt.get(day, 0) + 1
all_trades.sort(key=lambda x: x["sell_time"])
return all_trades
# ── 실시간 봇용: 단일 시점 매수/매도 판단 (백테스트와 100% 동일 규칙) ──────────────
def _confirmed_candles_only(candles: List[Dict]) -> List[Dict]:
"""확정봉만 사용 (미확정 봉 제외). 없으면 원본 유지."""
confirmed = [
c for c in candles
if _to_bool(c.get("is_confirmed", 1), True)
]
return confirmed if confirmed else list(candles)
def _apply_buy_state_filters(
candles: List[Dict],
i: int,
params: Dict[str, Any],
state: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
"""시간대·쿨다운·일일횟수 등 상태 필터 (신호봉 인덱스 i 기준 — 백테스트와 동일)."""
c = candles[i]
time_start_hm = int(params.get("time_start_hm", 900))
time_end_hm = int(params.get("time_end_hm", 1530))
cooldown_min = float(params.get("cooldown_min", 10))
max_daily = int(params.get("max_daily", 3))
hm = int(c["candle_time"][8:12])
if hm < time_start_hm or hm >= time_end_hm:
return (None, None, None)
last_exit_dt = state.get("last_exit_dt")
if last_exit_dt is not None:
elapsed = (_t2dt(c["candle_time"]) - last_exit_dt).total_seconds() / 60
if elapsed < cooldown_min:
return (None, None, None)
if state.get("daily_cnt", 0) >= max_daily:
return (None, None, None)
return (None, None, {"ok": True})
def _macd_lines_from_params(
candles: List[Dict],
params: Dict[str, Any],
) -> Tuple[List[Optional[float]], List[Optional[float]]]:
"""종목당 1회 MACD+Stoch 복합선 계산 (백테스트 루프용)."""
_, _, _, _, fast_combined, slow_combined = compute_macd_stoch_hts_lines(
candles,
macd_fast=int(params.get("macd_fast", 12)),
macd_slow=int(params.get("macd_slow", 26)),
macd_signal=int(params.get("macd_signal", 5)),
stoch_k_period=int(params.get("stoch_k_period", 5)),
stoch_d_period=int(params.get("stoch_d_period", 3)),
stoch_slow=int(params.get("stoch_slow", 3)),
)
return fast_combined, slow_combined
def _eval_scalp_buy_at_index(
candles: List[Dict],
i: int,
params: Dict[str, Any],
macd_combined: Optional[Tuple[List[Optional[float]], List[Optional[float]]]] = None,
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
"""SCALP 진입 모드 분기: MACD 골든크로스 vs RSI reversal → 호가필터."""
if _to_bool(params.get("use_macd_cross", False), False):
reject, msg, sig = _eval_macd_golden_buy_at_index(
candles, i, params, macd_combined=macd_combined,
)
else:
reject, msg, sig = _eval_reversal_buy_at_index(candles, i, params)
if reject or not sig:
return (reject, msg, sig)
cl = float(candles[i].get("close") or 0)
# 휩쏘 — 모멘텀과 동일 순서(휩쏘 → 호가). SCALP_WHIPSAW_* / 주입틱·실매 WS.
from kis_trader.engine.whipsaw_filter import whipsaw_reject_for_signal
ws_rej, ws_msg = whipsaw_reject_for_signal(
params, "SCALP",
signal_bar=candles[i],
current_price=cl,
)
if ws_rej:
return (ws_rej, ws_msg, None)
# 호가필터 — ORDERBOOK_FILTER_ENABLED / params['_orderbook_filter_enabled']
# 실매 기본 OFF 면 차단 없음(수집만). 파람서치 --orderbook-filter on 시 차단.
from kis_trader.engine.orderbook_filter import orderbook_reject_for_entry
ob_rej, ob_msg = orderbook_reject_for_entry(params, "SCALP", current_price=cl)
if ob_rej:
return (ob_rej, ob_msg, None)
return (None, None, sig)
def _eval_macd_golden_buy_at_index(
candles: List[Dict],
i: int,
params: Dict[str, Any],
macd_combined: Optional[Tuple[List[Optional[float]], List[Optional[float]]]] = None,
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
"""
HTS C — MACD+Stochastic [12,26,5,3,3] 골든크로스 (0봉전 상향돌파).
SCAN 은 K/L/제외만 두고, 진입 타이밍은 TRIGGER 에서 검사 (SCAN vs TRIGGER 분리).
"""
min_bars = _macd_min_bars(params)
if i < 1 or i >= len(candles) or len(candles) < min_bars:
return ("탈락-봉부족", "MACD+Stoch 계산 봉 %d/%d" % (len(candles), min_bars), None)
c = candles[i]
day = c["candle_time"][:8]
cl = float(c["close"])
lo = float(c["low"])
status, rej_code, rej_msg = _apply_scalp_defense_and_vol(candles, i, params, cl, lo, day)
if status == "reject":
return (rej_code, rej_msg, None)
if status == "skip":
return (None, None, None)
if macd_combined is not None:
fast_combined, slow_combined = macd_combined
else:
fast_combined, slow_combined = _macd_lines_from_params(candles, params)
if not _is_macd_stoch_golden_cross(fast_combined, slow_combined, i):
f0 = fast_combined[i]
s0 = slow_combined[i]
return (
"탈락-MACD골든X",
"fast=%s slow=%s (0봉전 상향돌파 아님)" % (
"%.2f" % f0 if f0 is not None else "N/A",
"%.2f" % s0 if s0 is not None else "N/A",
),
None,
)
rsi_period = int(params.get("rsi_period", 3))
closes = [float(x["close"]) for x in candles]
rsis = compute_rsi_series(closes, rsi_period)
rsi = rsis[i] if i < len(rsis) else None
return (None, None, {"signal": True, "rsi": rsi, "entry_mode": "macd_golden"})
def _eval_reversal_buy_at_index(
candles: List[Dict],
i: int,
params: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
"""
봉 i 에서 reversal 매수 신호 판정 (시간·쿨다운 제외).
``run_scalping_backtest`` 의 신호봉 검사와 동일 규칙.
"""
if i < 1 or i >= len(candles):
return ("탈락-봉부족", "인덱스 범위 밖", None)
rsi_period = int(params.get("rsi_period", 3))
rsi_oversold = float(params.get("rsi_oversold", 25))
rsi_overbought = float(params.get("rsi_overbought", 75.0))
drop_rate = float(params.get("drop_rate", 0.015))
vol_mult = float(params.get("vol_mult", 0))
high_chase_thr = float(params.get("high_chase_thr", 0.96))
max_daily_chg = float(params.get("max_daily_chg", 20.0))
min_price = float(params.get("min_price", 1000.0))
use_defense_filters = _to_bool(params.get("use_defense_filters", True), True)
require_reversal_candle = bool(params.get("require_reversal_candle", True))
skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), False)
c = candles[i]
day = c["candle_time"][:8]
cl = float(c["close"])
lo = float(c["low"])
prev_c = candles[i - 1]
status, rej_code, rej_msg = _apply_scalp_defense_and_vol(candles, i, params, cl, lo, day)
if status == "reject":
return (rej_code, rej_msg, None)
if status == "skip":
return (None, None, None)
closes = [float(x["close"]) for x in candles]
rsis = compute_rsi_series(closes, rsi_period)
rsi = rsis[i] if i < len(rsis) else None
# kiwoom scalp_re SCAN — HTS B/C/F/D 통과 후 진입 타이밍만 (RSI V자·되돌림 중복 금지)
if skip_hts:
return (None, None, {"signal": True, "rsi": rsi, "entry_mode": "reversal_hts_scan"})
if rsi is None:
return ("탈락-RSI없음", "RSI 미계산 (봉 축적 중)", None)
if rsi <= 0.0:
return ("탈락-RSI무효", "RSI3=0.0 (봉 부족, 계산 불가)", None)
if rsi > rsi_overbought:
return ("탈락-RSI과열", "RSI3=%.1f > %.0f" % (rsi, rsi_overbought), None)
if rsi > rsi_oversold:
return ("탈락-RSI조건", "RSI3=%.1f (과매도<%.0f 아님)" % (rsi, rsi_oversold), None)
if require_reversal_candle:
prev_bear = float(prev_c["close"]) < float(prev_c["open"])
curr_bull = cl > float(c["open"])
if not (prev_bear and curr_bull):
return ("탈락-되돌림없음", "prev_bear=%s curr_bull=%s" % (prev_bear, curr_bull), None)
return (None, None, {"signal": True, "rsi": rsi, "entry_mode": "reversal"})
def check_buy_signal_live(
candles: List[Dict],
params: Dict[str, Any],
state: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
"""
실시간 reversal 매수 신호.
- ``live_backtest_align=True`` (기본):
- 신호봉 = 직전 확정봉 (T1)
- 진입봉 = 형성 중 봉 T (``live_align_use_forming_bar`` 기본 True)
→ ``entry_price`` = 진입봉 시가(실매·BT 첫 틱/시가 정합)
- forming 없으면 구 폴백: 신호=confirmed[-2], 진입=confirmed[-1] open
- ``live_backtest_align=False``: 구버전 — 마지막 1봉만 검사.
"""
if len(candles) < 4:
return ("탈락-봉부족", "확정봉 4개 미만", None)
live_align = _to_bool(params.get("live_backtest_align", True), True)
lookback = max(1, int(params.get("live_signal_lookback_bars", 1)))
use_forming = _to_bool(params.get("live_align_use_forming_bar", True), True)
forming: Optional[Dict[str, Any]] = None
if live_align and use_forming and candles:
last = candles[-1]
if last.get("is_confirmed") in (0, False, "0", "false"):
forming = last
confirmed = _confirmed_candles_only(candles)
if len(confirmed) < 4:
return ("탈락-봉부족", "확정봉 4개 미만", None)
last_reject: Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]] = (
None, None, None,
)
if live_align:
# forming 있으면: 신호=confirmed[-1], 진입=forming open (BT next open 과 동일)
# forming 없으면: 신호=confirmed[-2], 진입=confirmed[-1] open (구 폴백)
if forming is not None:
entry_bar = forming
signal_base_i = len(confirmed) - 1
else:
entry_bar = confirmed[-1]
signal_base_i = len(confirmed) - 2
for k in range(lookback):
signal_i = signal_base_i - k
if signal_i < 1:
break
st = _apply_buy_state_filters(confirmed, signal_i, params, state)
if st[2] is None:
continue
reject, msg, sig = _eval_scalp_buy_at_index(confirmed, signal_i, params)
if reject:
if k == 0:
last_reject = (reject, msg, None)
continue
if sig:
entry_open = float(entry_bar.get("open", 0) or 0)
if entry_open <= 0:
entry_open = float(entry_bar.get("close", 0) or 0)
sig["entry_price"] = entry_open
sig["entry_bar_key"] = entry_bar.get("candle_time")
return (None, None, sig)
return last_reject
# 구버전: 마지막 확정봉 1개만 신호봉으로 검사
i = len(confirmed) - 1
st = _apply_buy_state_filters(confirmed, i, params, state)
if st[2] is None:
return (None, None, None)
return _eval_scalp_buy_at_index(confirmed, i, params)
def _intrabar_exit_prices(
open_: float, high: float, low: float, close: float, n_checks: int,
) -> List[float]:
"""1분 OHLC → 실매 10초 폴링 흉내 가격 경로 (open→high→low→close).
**high·low 앵커는 반드시 경유** — 선형 보간만 쓰면 고가/저가를 빗나가 어깨컷이 어긋난다.
"""
n = max(2, int(n_checks))
anchors: List[float] = []
for px in (float(open_), float(high), float(low), float(close)):
if not anchors or px != anchors[-1]:
anchors.append(px)
if len(anchors) == 1:
return [anchors[0]] * n
if n <= len(anchors):
return anchors[:n]
# 앵커(o,h,l,c) 사이에 보조 점 삽입해 n개 맞춤
seg_count = len(anchors) - 1
extra_total = n - len(anchors)
extra_per_seg = [extra_total // seg_count] * seg_count
for i in range(extra_total % seg_count):
extra_per_seg[i] += 1
out: List[float] = [anchors[0]]
for si in range(seg_count):
a, b = anchors[si], anchors[si + 1]
extras = extra_per_seg[si]
for j in range(1, extras + 1):
t = j / (extras + 1)
out.append(a + (b - a) * t)
if out[-1] != b:
out.append(b)
return out[:n] if len(out) >= n else out + [out[-1]] * (n - len(out))
def check_sell_signal_backtest_bar(
position: Dict[str, Any],
candle: Dict[str, Any],
params: Dict[str, Any],
is_eod: bool = False,
sell_fn: Optional[Callable[..., Optional[tuple]]] = None,
low_mode: str = "current",
) -> Optional[tuple]:
"""백테 전용 청산 — 1분·N분 OHLC를 N회 가격 체크로 쪼개 실매와 정렬.
실매 폴링 흉내:
- ``low_mode="current"`` (스캘핑·모멘텀): ``low=close=current_price``
- ``low_mode="session_low"`` (꼬리잡기): ``low=누적 session_low``, ``close=current_price``
``sell_fn`` 미지정 시 ``check_sell_signal_live`` (scalping_engine).
``BACKTEST_EXIT_CHECKS_PER_BAR`` (기본 6) = 1 이면 구 OHLC 1회 체크.
"""
if sell_fn is None:
sell_fn = check_sell_signal_live
n_checks = get_env_int("BACKTEST_EXIT_CHECKS_PER_BAR", 6)
if n_checks <= 1:
return sell_fn(position, candle, params, is_eod)
o = float(candle.get("open", candle["close"]))
h = float(candle.get("high", candle["close"]))
l = float(candle.get("low", candle["close"]))
c = float(candle["close"])
ct = candle.get("candle_time", "")
prices = _intrabar_exit_prices(o, h, l, c, n_checks)
result: Optional[tuple] = None
session_low: Optional[float] = None
if str(low_mode).strip().lower() == "session_low":
ep = float(position.get("entry_price", 0) or 0)
session_low = float(position.get("session_low", ep) or ep)
for idx, px in enumerate(prices):
mp = float(position.get("max_price", 0) or 0)
if px > mp:
position["max_price"] = px
mp = px
if session_low is not None:
session_low = min(session_low, px)
position["session_low"] = session_low
lo_sim = session_low
else:
lo_sim = px
sim = {
"open": o,
"high": mp,
"low": lo_sim,
"close": px,
"candle_time": ct,
}
eod_here = bool(is_eod and idx == len(prices) - 1)
result = sell_fn(position, sim, params, is_eod=eod_here)
if result:
return result
return result
def check_sell_signal_live(
position: Dict[str, Any],
current_candle: Dict[str, Any],
params: Dict[str, Any],
is_eod: bool = False,
) -> Optional[tuple]:
"""
실시간 봇 및 백테스트 공용: 보유 포지션 청산 (tail_engine V4 어깨컷과 동일 우선순위).
position: { "entry_price", "entry_time", "qty", "stop", "target", "max_price" }
current_candle: { "high", "low", "close", "candle_time"(optional) }
반환: (reason_str, exit_price) 또는 None
[청산 우선순위 — 꼬리잡기 tail_engine.check_sell_signal_live 와 동일]
1순위 어깨컷: max_price 갱신 후 shoulder_cut_pct 되돌림 (저가 lo 기준, 매도선 체결)
2순위 익절 (target)
3순위 손절 (stop)
4순위 금액손실컷 — 어깨 미발동(수익 문턱 미도달)일 때만
5순위 장마감청산
"""
shoulder_min_high = float(params.get("shoulder_min_high", 0.005))
shoulder_cut_pct = float(params.get("shoulder_cut_pct", 0.003))
max_loss_krw = float(params.get("max_loss_krw", 200000.0))
min_hold_sec = float(params.get("min_hold_sec", 30.0))
hi = float(current_candle.get("high", current_candle["close"]))
lo = float(current_candle.get("low", current_candle["close"]))
cl = float(current_candle["close"])
candle_time = current_candle.get("candle_time", "")
max_p = max(float(position.get("max_price", 0) or 0), hi)
position["max_price"] = max_p
ep = float(position["entry_price"])
stop = float(position["stop"])
target = float(position["target"])
qty = int(position.get("qty", 1) or 1)
if (not is_eod) and candle_time and position.get("entry_time"):
try:
entry_dt = _t2dt(position["entry_time"])
curr_dt = _t2dt(candle_time)
if (curr_dt - entry_dt).total_seconds() < min_hold_sec:
return None
except Exception:
pass
reason = None
exit_price = cl
profit_val = (lo - ep) * qty
drop_pct = (ep - lo) / ep if ep > 0 else 0.0
min_drop_pct = float(params.get("min_drop_pct_for_loss_cut", 0.015))
trail_armed = ep > 0 and max_p >= ep * (1.0 + shoulder_min_high)
trail_stop_px = max_p * (1.0 - shoulder_cut_pct) if trail_armed else 0.0
trail_hit = trail_armed and lo > 0 and lo <= trail_stop_px
if trail_hit:
reason = "어깨컷"
exit_price = trail_stop_px
elif hi >= target:
reason = "익절"
exit_price = target
elif lo > 0 and lo <= stop:
reason = "손절"
exit_price = stop
elif (
not trail_armed
and profit_val <= -max_loss_krw
and drop_pct >= min_drop_pct
):
reason = "금액손실컷"
exit_price = ep - (max_loss_krw / qty) if qty > 0 else lo
elif is_eod:
reason = "장마감청산"
exit_price = cl
if reason:
return (reason, exit_price)
return None
def run_scalping_backtest_rust_experimental(
codes_candles: Dict[str, List[Dict]],
params: Dict[str, Any],
) -> List[Dict]:
"""
Rust 엔진 (kis_rust_core) 을 통한 초고속 스캘핑 백테스트 (실험).
"""
try:
import kis_rust_core
from kis_rust_core import ScalpParams, CandleData
except ImportError as e:
from kis_trader.utils.logger import get_logger
get_logger("kis_trader.scalping_engine").error(f"Rust core import failed: {e}")
return []
use_defense_filters = str(params.get("use_defense_filters", True)).strip().lower() in ("1", "true", "t", "y", "yes", "on")
# skip_hts_scan_dupes 는 HTS 조건검색 엔진 쓸 때 낙폭/RSI 중복을 끌지 여부.
skip_hts = False
if "skip_hts_scan_dupes" in params:
skip_hts = str(params.get("skip_hts_scan_dupes")).strip().lower() in ("1", "true", "t", "y", "yes", "on")
else:
src = str(params.get("SCALP_UNIVERSE_SOURCE", "condition")).strip().lower()
skip_hts = src in ("kiwoom_condition", "condition")
rp = ScalpParams(
rsi_period=int(params.get("rsi_period", 3)),
rsi_oversold=float(params.get("rsi_oversold", 25.0)),
rsi_overbought=float(params.get("rsi_overbought", 75.0)),
sl_pct=abs(float(params.get("sl_pct", 0.015))),
tp_pct=effective_tp_pct_from_params(params),
drop_rate=float(params.get("drop_rate", 0.015)),
cooldown_min=float(params.get("cooldown_min", 10.0)),
max_daily=int(params.get("max_daily", 3)),
high_chase_thr=float(params.get("high_chase_thr", 0.96)),
max_daily_chg=float(params.get("max_daily_chg", 20.0)),
min_price=float(params.get("min_price", 1000.0)),
vol_mult=float(params.get("vol_mult", 0.0)),
use_defense_filters=use_defense_filters,
skip_hts=skip_hts,
time_start_hm=int(params.get("time_start_hm", 900)),
time_end_hm=int(params.get("time_end_hm", 1530))
)
all_trades = []
for code, rows in codes_candles.items():
if not rows:
continue
# 캔들 변환
rust_candles = []
for r in rows:
rust_candles.append(CandleData(
str(r["candle_time"]),
float(r["open"]),
float(r["high"]),
float(r["low"]),
float(r["close"]),
float(r.get("volume", 0)),
float(r.get("rsi", 50.0)) # TODO: rust 내부에서 rsi 계산하도록 변경 필요
))
res = kis_rust_core.run_scalp_backtest_fast(code, rust_candles, rp)
for t in res:
all_trades.append({
"code": t.code,
"buy_time": t.buy_time,
"sell_time": t.sell_time,
"buy_price": t.buy_price,
"sell_price": t.sell_price,
"entry_time": t.buy_time,
"exit_time": t.sell_time,
"entry": t.buy_price,
"exit": t.sell_price,
"exit_reason": t.reason,
"qty": 1, # 임시
"pnl": 0, # PnL 부착부에서 재계산
"profit_rate": round(t.pnl_pct, 2),
"hold_min": 0,
"sell_reason": t.reason,
"rsi_entry": round(t.rsi_entry, 1),
"is_rust_core": True,
})
return all_trades