Files
kis_bot/kis_trader/backtest/param_search_scalping.py
Your Name 8fbba264ba feat(옵투나·웹): 후처리 재탐색·ob_modes·적용감사·수집통계
- Optuna web jobs/TPE/apply snapshot·틱로더 정합, jobs limit·감사로그
- 백테 UI 호가모드·후보 적용 흐름, feed_collect_stats API/탭
- 가설검증·교차검증 룰, 4전략 스모크·OB slot41 진단 스크립트

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 15:23:44 +09:00

1692 lines
79 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
kis_trader/backtest/param_search_scalping.py — 스캘핑(Reversal) 백테스트 파라미터 자동 탐색 (Grid Search)
======================================================================================================
[전략 = SCALP / Reversal — RSI V자 + scalp_re HTS SCAN]
- SCAN: 키움 ``CONDITION_SCALP_KIWOOM_NAME`` (기본 scalp_re) → ``target_candidates_history``
- TRIGGER: RSI(3) 과매도 + 음봉→양봉 + 낙폭·고점추격 (``SCALP_USE_MACD_CROSS=false``)
- 청산: 어깨 → TP/SL → 금액손실 → EOD (``check_sell_signal_live``)
- 실매매 ScalpingStrategy.check_buy → ``scalping_engine.check_buy_signal_live`` 호출.
- 본 CLI 는 ``scalping_backtest_common.run_scalping_backtest_web_aligned`` 위에서 그리드 서치.
[관련 CLI (전략별 파일 분리, 2026-05 정리)]
SCALP(Reversal): kis_trader/backtest/param_search_scalping.py ← 이 파일
SCALP Optuna : kis_trader/backtest/param_search_optuna.py --strategy scalp
MOMENTUM : kis_trader/backtest/param_search_momentum.py
BREAKOUT : kis_trader/backtest/param_search_breakout.py
SHORT(꼬리잡기) : kis_trader/backtest/tail_param_search.py
실행:
cd /home/hoon/kis_bot
python3 kis_trader/backtest/param_search_scalping.py
# 또는 패키지 모듈로
python3 -m kis_trader.backtest.param_search_scalping --mode fast --apply 1
python3 -m kis_trader.backtest.param_search_optuna --strategy scalp --mode trigger --trials 100
옵션:
--start 시작일 (기본: 오늘-7일)
--end 종료일 (기본: 오늘)
--mode 탐색 모드: fast / trigger / exit / coarse / fine / full / wide
fast = reversal·어깨·tp_max·손익 (~192조합)
trigger = RSI·낙폭·고점추격·V자 (진입, 청산 DB 고정)
exit = 청산 전용 (진입 DB 고정)
--top 상위 N개 출력 (기본: 5000)
--min_trades 최소 거래 건수 필터 (기본: 1)
--apply 결과 N위 조합을 DB에 자동 적용 (기본: False, 지정하면 N 생략 시 1)
--apply-ai Gemini 로 수익·승률 기준 조합 하나 선택해서 DB 자동 적용
설명:
웹 API를 거치지 않고 scalping_engine 을 직접 호출하여 속도를 극대화했습니다.
피뢰침 방지(high_chase_thr)·급등주 필터(max_daily_chg)·방어필터 ON/OFF 도 그리드에 포함됩니다.
위치 이관 이력:
1) backtest_scalping/param_search.py
2) → kis_trader/backtest/param_search.py (2026-04)
3) → kis_trader/backtest/param_search_scalping.py (2026-05, 전략별 파일 분리)
- ROOT = kis_bot 프로젝트 루트 (__file__ 기준 3단계 위)
- 결과 저장: kis_trader/backtest/results/ (구 backtest_scalping/results 는 그대로 유지)
- scalping_engine / database.TradeDB 는 ROOT 에서 그대로 임포트
"""
import sys, os, json, time, argparse, signal
import heapq
from datetime import datetime, timedelta
from itertools import product
from concurrent.futures import as_completed
from typing import Optional, List, Dict, Any, Tuple
# kis_bot 루트를 경로에 추가 (스크립트·패키지 실행 모두 대응)
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
if HERE not in sys.path:
sys.path.insert(0, HERE)
import logging
logging.getLogger("TradeDB").setLevel(logging.WARNING) # 반복 초기화 로그 억제
from database import TradeDB
from kis_trader.engine import scalping_engine as se
from kis_trader.backtest import scalping_backtest_common as sbc
from kis_trader.backtest.backtest_portfolio_common import (
merge_param_search_apply_source,
strip_portfolio_keys_from_apply_patch,
session_env_patch,
)
from kis_trader.backtest.param_search_cli_common import (
add_portfolio_cli_args,
add_search_filter_cli_args,
apply_session_to_fixed,
combo_passes_search_filters,
format_session_hm,
search_json_meta,
)
from kis_trader.backtest.param_search_pool import (
ParamSearchSharedPayload,
ParamSearchProgressETA,
assert_parent_alive,
cap_combos_uniform,
cap_combos_uniform_lazy,
iter_pool_chunk_results,
managed_process_pool,
param_search_chunk_plan,
param_search_worker_budget_line,
try_acquire_run_lock,
worker_shared_get,
)
from kis_trader.backtest.breakout_tick_loader import (
load_breakout_ticks_by_code,
tick_coverage_stats,
)
from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
# Grid 축 — Optuna·웹 힌트 공용
SCALP_GRID_AXIS_HINTS_KO: Dict[str, str] = {
"rsi_period": "RSI 기간 (봉)",
"rsi_oversold": "RSI 과매도 기준",
"rsi_overbought": "RSI 과열 차단",
"sl_pct": "손절 %",
"tp_pct": "익절 %",
"tp_max_pct": "익절 상한 %",
"drop_rate": "TRIGGER 최소 낙폭 %(시가→저점) — HTS scalp_re B(2~6) 안",
"shoulder_min_high": "어깨 발동 최소 수익 %",
"shoulder_cut_pct": "어깨 되돌림 %",
"high_chase_thr": "고점추격 방지 (현재가/당일고가)",
"max_daily_chg": "당일 급등 상한 %",
"min_price": "최소 종가(원)",
"max_loss_krw": "1회 최대손실(원)",
"min_drop_pct_for_loss_cut": "손실컷 최소낙폭(비율)",
"min_margin": "본절 마진 %",
"vol_mult": "거래량 배수 (0=OFF)",
"require_reversal_candle": "음봉→양봉 V자 필수",
"use_defense_filters": "방어필터 ON",
"use_macd_cross": "MACD 골든크로스 진입",
"skip_hts_scan_dupes": "HTS SCAN 중복 스킵",
"min_hold_sec": "최소 보유 초 (0=OFF)",
"cooldown_min": "재진입 쿨다운 (분)",
"max_daily": "종목당 일일 최대매수",
"time_start_hm": "매수 시작 HHMM",
"time_end_hm": "매수 종료 HHMM",
# 호가필터 (--orderbook-filter on 일 때 의미, 실매 ORDERBOOK_FILTER_ENABLED 와 별개)
"max_spread_pct": "호가 스프레드 상한(%)",
"min_bid_ask_ratio": "매수/매도 잔량비 하한",
"ask_max_mult": "매도벽 허용배수(필요수량×N)",
}
def _scalp_ob_grid_axes(*, sweep: bool) -> Dict[str, List[Any]]:
"""호가필터 축 — 실매 SCALP_ORDERBOOK_* 앵커 포함. sweep=False 면 운영값 1점."""
from kis_trader.engine.orderbook_env import (
OB_DEFAULT_ENTRY_ASK_MAX_MULT,
OB_DEFAULT_MAX_SPREAD_PCT,
OB_DEFAULT_MIN_BID_ASK_RATIO,
get_ob_float,
)
live_spread = float(get_ob_float("SCALP", "MAX_SPREAD_PCT", OB_DEFAULT_MAX_SPREAD_PCT))
live_ratio = float(get_ob_float("SCALP", "MIN_BID_ASK_RATIO", OB_DEFAULT_MIN_BID_ASK_RATIO))
live_ask = float(get_ob_float("SCALP", "ENTRY_ASK_MAX_MULT", OB_DEFAULT_ENTRY_ASK_MAX_MULT))
if not sweep:
return {
"max_spread_pct": _fmerge([live_spread], live_spread),
"min_bid_ask_ratio": _fmerge([live_ratio], live_ratio),
"ask_max_mult": _fmerge([live_ask], live_ask),
}
return {
"max_spread_pct": _fmerge([0.30, 0.45, 0.80, 1.20], live_spread),
"min_bid_ask_ratio": _fmerge([0.50, 0.70, 0.85, 1.00], live_ratio),
"ask_max_mult": _fmerge([2.0, 3.0, 5.0, 8.0], live_ask),
}
def _parse_csv_floats(env_key: str, fallback: List[float]) -> List[float]:
raw = get_env_from_db(env_key, "")
if not raw or str(raw).strip() in ("", "None"):
return list(fallback)
out: List[float] = []
for chunk in str(raw).replace("|", ",").split(","):
chunk = chunk.strip()
if not chunk:
continue
try:
out.append(float(chunk))
except (TypeError, ValueError):
continue
return out if out else list(fallback)
def _parse_csv_ints(env_key: str, fallback: List[int]) -> List[int]:
raw = get_env_from_db(env_key, "")
if not raw or str(raw).strip() in ("", "None"):
return list(fallback)
out: List[int] = []
for chunk in str(raw).replace("|", ",").split(","):
chunk = chunk.strip()
if not chunk:
continue
try:
out.append(int(float(chunk)))
except (TypeError, ValueError):
continue
return out if out else list(fallback)
def _parse_csv_bools(env_key: str, fallback: List[bool]) -> List[bool]:
raw = get_env_from_db(env_key, "")
if not raw or str(raw).strip() in ("", "None"):
return list(fallback)
out: List[bool] = []
for chunk in str(raw).replace("|", ",").split(","):
chunk = chunk.strip().lower()
if not chunk:
continue
out.append(chunk in ("1", "true", "t", "y", "yes", "on"))
return out if out else list(fallback)
# ──────────────────────────────────────────────────────────────────────────────
# 스캘핑 기본값 = 엔진에서 DB 로드 (백테스트 API와 동일 단일 소스, 실매매와 동기화)
# ──────────────────────────────────────────────────────────────────────────────
def _fixed_defaults():
"""엔진 get_scalping_defaults_from_db() 사용. UI/파람서치용으로 %/비율 변환."""
_d = se.get_scalping_defaults_from_db()
return {
"rsi_period": _d["rsi_period"],
"rsi_oversold": _d.get("rsi_oversold", 25.0),
"sl_pct": abs(float(_d.get("sl_pct", 0.015))) * 100,
"tp_pct": abs(float(_d.get("tp_pct", 0.015))) * 100,
"drop_rate": float(_d.get("drop_rate", 0.015)) * 100,
"require_reversal_candle": bool(_d.get("require_reversal_candle", True)),
"min_hold_sec": float(_d.get("min_hold_sec", 30.0)),
"rsi_overbought": _d.get("rsi_overbought", 75.0), # 과열 차단 (그리드 탐색 제외, 고정값)
"slot_money": _d["slot_money"],
"vol_mult": _d.get("vol_mult", 0),
"trail_trigger": _d["trail_trigger"] * 100, # % 단위 (레거시, 청산 미사용)
"trail_stop": _d["trail_stop"] * 100, # % 단위 (레거시, 청산 미사용)
"shoulder_min_high": _d.get("shoulder_min_high", 0.005) * 100,
"shoulder_cut_pct": _d.get("shoulder_cut_pct", 0.003) * 100,
"tp_max_pct": _d.get("tp_max_pct", 0.02) * 100,
"cooldown_min": _d["cooldown_min"],
"time_start_hm": _d["time_start_hm"],
"time_end_hm": _d["time_end_hm"],
"max_daily": _d["max_daily"],
"fee_rate": _d["fee_rate"] * 100, # % 단위
"sell_tax": _d["sell_tax"] * 100, # % 단위
# 방어로직
"high_chase_thr": _d["high_chase_thr"], # 비율 (0.96)
"max_daily_chg": _d["max_daily_chg"], # % 단위
"min_price": _d["min_price"],
"max_loss_krw": _d["max_loss_krw"],
"min_margin": _d["min_margin"] * 100, # % 단위
"use_defense_filters": _d.get("use_defense_filters", True),
"use_macd_cross": _d.get("use_macd_cross", False),
"skip_hts_scan_dupes": bool(_d.get("skip_hts_scan_dupes", se.resolve_scalp_skip_hts_scan_dupes())),
}
# RSI_OVERSOLD별 JSON 저장 개수 (한 RSI에 치중되지 않도록 균등 분배)
PER_RSI_JSON = 20
# ──────────────────────────────────────────────────────────────────────────────
# 결과 디렉터리 (신규 위치 우선, 구 경로도 계속 조회 가능)
# ──────────────────────────────────────────────────────────────────────────────
def _results_dir_for_write() -> str:
"""새로 저장할 결과는 kis_trader/backtest/results/ 에 둔다."""
d = os.path.join(HERE, "results")
os.makedirs(d, exist_ok=True)
return d
def _results_dirs_for_read() -> List[str]:
"""읽기용 디렉터리: 신규 → 구 순서."""
return [
os.path.join(HERE, "results"),
os.path.join(ROOT, "backtest_scalping", "results"),
]
def _latest_json(prefix: str) -> Optional[str]:
"""읽기용 디렉터리에서 prefix 로 시작하는 가장 최근 JSON 경로 반환."""
best_path, best_mtime = None, -1.0
for d in _results_dirs_for_read():
if not os.path.isdir(d):
continue
for f in os.listdir(d):
if not (f.startswith(prefix) and f.endswith(".json")):
continue
p = os.path.join(d, f)
try:
m = os.path.getmtime(p)
except OSError:
continue
if m > best_mtime:
best_mtime = m
best_path = p
return best_path
# ──────────────────────────────────────────────────────────────────────────────
# 파라미터 그리드 (min_price 등은 DB 앵커 기반 — 고정 1값만 쓰지 않음)
# ──────────────────────────────────────────────────────────────────────────────
def _min_price_grid():
"""최소가격 스윕 — HTS 1000원+ 와 정합.
- 500/10만 등 조건식 밖·하루 과적합 값은 제외.
- 기본·실매 앵커=1000, 보조=6000(wide Top에서 납득 가능 구간).
"""
mp = int(_fixed_defaults()["min_price"] or 1000)
return sorted(set([1000, 6000, max(1000, mp)]))
# HTS scalp_re 조건 B: 시가→저가 등락률 -6%~-2% → 절대 낙폭 2%~6%.
# TRIGGER drop_rate(최소 낙폭 %)는 방어 ON·skip_hts=false 일 때 재검 → HTS 밖 값(예 7.3)은 공집합.
_HTS_SCALP_DROP_MIN_PCT = 2.0
_HTS_SCALP_DROP_MAX_PCT = 6.0
_HTS_SCALP_DROP_STEPS = (2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0)
def _hts_scalp_drop_rate_grid(live_drop_pct: float, steps: Optional[Tuple[float, ...]] = None) -> List[float]:
"""TPE/그리드용 drop_rate(%) — HTS scalp_re B 밴드(2~6) 안만. live가 밖이면 격자에 넣지 않음."""
base = list(steps) if steps is not None else list(_HTS_SCALP_DROP_STEPS)
try:
v = float(live_drop_pct)
except (TypeError, ValueError):
return base
if _HTS_SCALP_DROP_MIN_PCT - 1e-9 <= v <= _HTS_SCALP_DROP_MAX_PCT + 1e-9:
return _fmerge(base, v)
return base
def _scalp_live_anchors() -> Dict[str, Any]:
"""실매 DB 앵커 — 그리드에 반드시 포함 (단위=UI/% 표시)."""
d = _fixed_defaults()
eng = se.get_scalping_defaults_from_db()
return {
"rsi_period": int(eng.get("rsi_period") or d.get("rsi_period") or 3),
"rsi_oversold": float(d.get("rsi_oversold") or 25),
"rsi_overbought": float(d.get("rsi_overbought") or 75),
"sl_pct": float(d.get("sl_pct") or 1.5),
"tp_pct": float(d.get("tp_pct") or 1.5),
"tp_max_pct": float(d.get("tp_max_pct") or 2.0),
"drop_rate": float(d.get("drop_rate") or 2.0), # HTS 밴드 하한(2%) 기본
"shoulder_min_high": float(d.get("shoulder_min_high") or 0.5),
"shoulder_cut_pct": float(d.get("shoulder_cut_pct") or 0.3),
"high_chase_thr": float(d.get("high_chase_thr") or 0.96),
"max_daily_chg": float(d.get("max_daily_chg") or 20.0),
"min_price": int(d.get("min_price") or 1000),
"max_loss_krw": int(d.get("max_loss_krw") or 200000),
"min_drop_pct_for_loss_cut": float(eng.get("min_drop_pct_for_loss_cut") or 0.015),
"min_margin": float(d.get("min_margin") or 0.2),
"vol_mult": float(d.get("vol_mult") or 0.0),
"require_reversal_candle": bool(d.get("require_reversal_candle", True)),
"use_defense_filters": bool(d.get("use_defense_filters", True)),
"use_macd_cross": bool(d.get("use_macd_cross", False)),
"skip_hts_scan_dupes": bool(d.get("skip_hts_scan_dupes", False)),
"min_hold_sec": int(float(d.get("min_hold_sec") or 30)),
"cooldown_min": int(d.get("cooldown_min") or 10),
"max_daily": int(d.get("max_daily") or 3),
"time_start_hm": int(d.get("time_start_hm") or 900),
"time_end_hm": int(d.get("time_end_hm") or 1530),
}
def _fmerge(candidates: List[float], live: float) -> List[float]:
return sorted(set([float(x) for x in candidates] + [float(live)]))
def _imerge(candidates: List[int], live: int) -> List[int]:
return sorted(set([int(x) for x in candidates] + [int(live)]))
def _bmerge(candidates: List[bool], live: bool) -> List[bool]:
out: List[bool] = []
for v in list(candidates) + [bool(live)]:
if v not in out:
out.append(v)
# 실매 기본(False) 우선 — SKIP_HTS 철학
if False in out and out[0] is not False:
out = [False] + [x for x in out if x is not False]
return out
def _scalp_grids():
"""탐색 모드별 그리드.
- HTS SCAN(시가→저가 -8~-1.5% 등)은 유니버스 참고 — 그리드 목적이 아님.
- 축 = TRIGGER/청산/방어 (실매 엔진 키). 실매 DB 앵커는 모든 mode에 포함.
- 범위는 꼬리 coarse/full 수준으로 넓힘 (Optuna TPE 샘플링 전제).
"""
live = _scalp_live_anchors()
mp_list = _min_price_grid()
# fine/coarse/fast — HTS 1000원+ · wide(7/15) Top: 1000 기본 + 6000 보조 (500/10만 제외)
min_price_ops = _imerge([1000, 6000], int(live["min_price"]))
p = "SCALP_GRID_" # env 오버라이드 prefix (mode별 키는 기존 TRIGGER/EXIT 유지)
# 매수 종료 HHMM — 오전컷(0930~1230) + 장마감(1530). 시작은 0900 고정(×5만, 시작×끝=25 방지)
session_end_hm = _imerge([930, 1030, 1130, 1230, 1530], live["time_end_hm"])
session_start_hm = _imerge([900], live["time_start_hm"])
return {
# ─────────────────────────────────────────────────────────────────────
# [FAST] wide(7/15) Top 근방 스모크 — sl3.5·tp2.5·sh3·defense OFF 앵커
# ─────────────────────────────────────────────────────────────────────
"fast": {
"rsi_period": _imerge([3, 7], live["rsi_period"]),
"rsi_oversold": _fmerge([19, 21, 23, 25], live["rsi_oversold"]),
"sl_pct": _fmerge([2.5, 3.0, 3.5], live["sl_pct"]),
"tp_pct": _fmerge([2.0, 2.5, 3.0], live["tp_pct"]),
"tp_max_pct": _fmerge([4.0, 5.0], live["tp_max_pct"]),
"shoulder_min_high": _fmerge([0.8, 3.0], live["shoulder_min_high"]),
"shoulder_cut_pct": _fmerge([0.1, 0.2, 0.4], live["shoulder_cut_pct"]),
"drop_rate": _hts_scalp_drop_rate_grid(live["drop_rate"], (2.0, 3.0, 4.0, 5.0, 6.0)),
"high_chase_thr": _fmerge([0.98, 1.0], live["high_chase_thr"]),
"vol_mult": _fmerge([0.0, 1.2, 1.5], live["vol_mult"]),
"require_reversal_candle": _bmerge([True, False], live["require_reversal_candle"]),
"cooldown_min": _imerge([1, 5], live["cooldown_min"]),
"min_hold_sec": _imerge([0, 30, 60], live["min_hold_sec"]),
"use_defense_filters": _bmerge([False, True], live["use_defense_filters"]),
"use_macd_cross": _bmerge([False], live["use_macd_cross"]),
"min_price": min_price_ops,
"max_daily_chg": _fmerge([16.0, 20.0, 40.0], live["max_daily_chg"]),
"time_start_hm": session_start_hm,
"time_end_hm": session_end_hm,
**_scalp_ob_grid_axes(sweep=False),
},
# trigger: RSI V자 진입 전용 — 청산은 DB 고정
"trigger": {
"rsi_period": _parse_csv_ints(
f"{p}TRIGGER_RSI_PERIOD", _imerge([3, 7, 14], live["rsi_period"]),
),
"rsi_oversold": _parse_csv_floats(
f"{p}TRIGGER_RSI_OVERSOLD",
_fmerge([12, 15, 17, 19, 21, 23, 25, 28, 32], live["rsi_oversold"]),
),
"drop_rate": _parse_csv_floats(
f"{p}TRIGGER_DROP_RATE",
# HTS scalp_re B: 시가→저가 -6%~-2% (절대 2~6). HTS 밖(7.3·8·10) 탐색 금지
_hts_scalp_drop_rate_grid(live["drop_rate"]),
),
"high_chase_thr": _parse_csv_floats(
f"{p}TRIGGER_HIGH_CHASE_THR",
_fmerge([0.92, 0.94, 0.96, 0.98, 0.99, 1.0], live["high_chase_thr"]),
),
"vol_mult": _parse_csv_floats(
f"{p}TRIGGER_VOL_MULT",
_fmerge([0.0, 1.0, 1.2, 1.5, 2.0, 2.5, 3.0], live["vol_mult"]),
),
"require_reversal_candle": _parse_csv_bools(
f"{p}TRIGGER_REQUIRE_REVERSAL",
_bmerge([True, False], live["require_reversal_candle"]),
),
"use_macd_cross": _parse_csv_bools(
f"{p}TRIGGER_USE_MACD",
_bmerge([False, True], live["use_macd_cross"]),
),
"rsi_overbought": _parse_csv_floats(
f"{p}TRIGGER_RSI_OVERBOUGHT",
_fmerge([70, 75, 80, 85], live["rsi_overbought"]),
),
**_scalp_ob_grid_axes(sweep=False),
},
# exit: 청산 전용 — TRIGGER(DB) 고정
"exit": {
"sl_pct": _parse_csv_floats(
f"{p}EXIT_SL_PCT",
_fmerge([0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 2.5, 3.0], live["sl_pct"]),
),
"tp_pct": _parse_csv_floats(
f"{p}EXIT_TP_PCT",
_fmerge([1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0], live["tp_pct"]),
),
"tp_max_pct": _parse_csv_floats(
f"{p}EXIT_TP_MAX_PCT",
_fmerge([1.5, 1.8, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, 6.0], live["tp_max_pct"]),
),
"shoulder_min_high": _parse_csv_floats(
f"{p}EXIT_SHOULDER_MIN_HIGH_PCT",
_fmerge(
[0.1, 0.2, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0],
live["shoulder_min_high"],
),
),
"shoulder_cut_pct": _parse_csv_floats(
f"{p}EXIT_SHOULDER_CUT_PCT",
_fmerge(
[0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5, 0.7],
live["shoulder_cut_pct"],
),
),
"min_hold_sec": _parse_csv_ints(
f"{p}EXIT_MIN_HOLD_SEC",
_imerge([0, 10, 15, 30, 60, 90, 120], live["min_hold_sec"]),
),
"max_loss_krw": _parse_csv_ints(
f"{p}EXIT_MAX_LOSS_KRW",
_imerge(
[50000, 100000, 150000, 200000, 300000, 500000],
live["max_loss_krw"],
),
),
"min_margin": _parse_csv_floats(
f"{p}EXIT_MIN_MARGIN",
_fmerge([0.0, 0.1, 0.2, 0.3, 0.5], live["min_margin"]),
),
},
# coarse — wide Top 밴드 조금 넓게 (1차 스크리닝)
"coarse": {
"rsi_period": _imerge([3, 7, 14], live["rsi_period"]),
"rsi_oversold": _fmerge(
[17, 19, 21, 23, 25, 28], live["rsi_oversold"],
),
"sl_pct": _fmerge([2.0, 2.5, 3.0, 3.5], live["sl_pct"]),
"tp_pct": _fmerge([1.5, 2.0, 2.5, 3.0, 3.5], live["tp_pct"]),
"tp_max_pct": _fmerge(
[3.0, 4.0, 5.0], live["tp_max_pct"],
),
"drop_rate": _hts_scalp_drop_rate_grid(
live["drop_rate"], (2.0, 2.5, 3.0, 4.0, 5.0, 6.0),
),
"shoulder_min_high": _fmerge(
[0.8, 1.5, 3.0], live["shoulder_min_high"],
),
"shoulder_cut_pct": _fmerge(
[0.1, 0.15, 0.2, 0.3, 0.4], live["shoulder_cut_pct"],
),
"high_chase_thr": _fmerge(
[0.96, 0.98, 0.99, 1.0], live["high_chase_thr"],
),
"max_daily_chg": _fmerge(
[16.0, 20.0, 25.0, 30.0, 40.0], live["max_daily_chg"],
),
"min_price": min_price_ops,
"vol_mult": _fmerge([0.0, 1.2, 1.5, 2.0], live["vol_mult"]),
"use_defense_filters": _bmerge([False, True], live["use_defense_filters"]),
"use_macd_cross": _bmerge([False, True], live["use_macd_cross"]),
"require_reversal_candle": _bmerge(
[True, False], live["require_reversal_candle"],
),
"max_loss_krw": _imerge(
[100000, 150000, 200000], live["max_loss_krw"],
),
"min_drop_pct_for_loss_cut": _fmerge(
[0.01, 0.015, 0.02], live["min_drop_pct_for_loss_cut"],
),
"min_margin": _fmerge([0.1, 0.2, 0.3], live["min_margin"]),
"cooldown_min": _imerge([1, 3, 5, 10], live["cooldown_min"]),
"min_hold_sec": _imerge([0, 15, 30, 60], live["min_hold_sec"]),
"max_daily": _imerge([10, 20, 50, 100], live["max_daily"]),
"time_start_hm": session_start_hm,
"time_end_hm": session_end_hm,
**_scalp_ob_grid_axes(sweep=False),
},
# fine — 2026-07-15 wide Top 재설계 (min_price=1000·6000, 500/10만 제외)
"fine": {
"rsi_period": _imerge([3, 7], live["rsi_period"]),
"rsi_oversold": _fmerge(
[19, 21, 23, 25], live["rsi_oversold"],
),
"sl_pct": _fmerge([2.5, 3.0, 3.5], live["sl_pct"]),
"tp_pct": _fmerge([2.0, 2.5, 3.0], live["tp_pct"]),
"tp_max_pct": _fmerge(
[4.0, 5.0], live["tp_max_pct"],
),
"drop_rate": _hts_scalp_drop_rate_grid(
live["drop_rate"], (2.0, 2.5, 3.0, 4.0, 5.0, 6.0),
),
"shoulder_min_high": _fmerge(
[0.8, 3.0], live["shoulder_min_high"],
),
"shoulder_cut_pct": _fmerge(
[0.1, 0.2, 0.3, 0.4], live["shoulder_cut_pct"],
),
"cooldown_min": _imerge([1, 5], live["cooldown_min"]),
"high_chase_thr": _fmerge(
[0.98, 0.99, 1.0], live["high_chase_thr"],
),
"max_daily_chg": _fmerge(
[16.0, 20.0, 30.0, 40.0], live["max_daily_chg"],
),
"min_price": min_price_ops,
"vol_mult": _fmerge([0.0, 1.2, 1.5], live["vol_mult"]),
"use_defense_filters": _bmerge([False, True], live["use_defense_filters"]),
"use_macd_cross": _bmerge([False], live["use_macd_cross"]),
"require_reversal_candle": _bmerge(
[True, False], live["require_reversal_candle"],
),
"max_loss_krw": _imerge(
[100000, 150000, 200000], live["max_loss_krw"],
),
"min_drop_pct_for_loss_cut": _fmerge(
[0.01, 0.015], live["min_drop_pct_for_loss_cut"],
),
"min_margin": _fmerge([0.1, 0.2, 0.3], live["min_margin"]),
"min_hold_sec": _imerge([0, 30, 60], live["min_hold_sec"]),
"max_daily": _imerge([20, 50, 100], live["max_daily"]),
"rsi_overbought": _fmerge([70, 75], live["rsi_overbought"]),
"time_start_hm": session_start_hm,
"time_end_hm": session_end_hm,
**_scalp_ob_grid_axes(sweep=False),
},
# full — 전축 최대 폭 (꼬리 full 급)
"full": {
"rsi_period": _imerge([3, 5, 7, 14, 21], live["rsi_period"]),
"rsi_oversold": _fmerge(
[10, 12, 15, 17, 19, 21, 23, 25, 28, 32, 35], live["rsi_oversold"],
),
"rsi_overbought": _fmerge(
[65, 70, 75, 80, 85, 90], live["rsi_overbought"],
),
"sl_pct": _fmerge(
[0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 2.5, 3.0, 4.0], live["sl_pct"],
),
"tp_pct": _fmerge(
[1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, 6.0], live["tp_pct"],
),
"tp_max_pct": _fmerge(
[1.5, 1.8, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0], live["tp_max_pct"],
),
"drop_rate": _hts_scalp_drop_rate_grid(live["drop_rate"]),
"shoulder_min_high": _fmerge(
[0.1, 0.2, 0.3, 0.5, 0.7, 1.0, 1.5, 2.0], live["shoulder_min_high"],
),
"shoulder_cut_pct": _fmerge(
[0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.7, 1.0],
live["shoulder_cut_pct"],
),
"cooldown_min": _imerge([0, 1, 3, 5, 10, 15, 20], live["cooldown_min"]),
"high_chase_thr": _fmerge(
[0.90, 0.92, 0.94, 0.96, 0.98, 0.99, 1.0], live["high_chase_thr"],
),
"max_daily_chg": _fmerge(
[8.0, 12.0, 15.0, 20.0, 25.0, 30.0, 40.0, 50.0],
live["max_daily_chg"],
),
"min_price": mp_list,
"vol_mult": _fmerge(
[0.0, 1.0, 1.2, 1.5, 2.0, 2.5, 3.0], live["vol_mult"],
),
"use_defense_filters": _bmerge([False, True], live["use_defense_filters"]),
"use_macd_cross": _bmerge([False, True], live["use_macd_cross"]),
"require_reversal_candle": _bmerge(
[True, False], live["require_reversal_candle"],
),
"skip_hts_scan_dupes": _bmerge(
[False, True], live["skip_hts_scan_dupes"],
),
"max_loss_krw": _imerge(
[50000, 100000, 150000, 200000, 300000, 500000],
live["max_loss_krw"],
),
"min_drop_pct_for_loss_cut": _fmerge(
[0.005, 0.01, 0.015, 0.02, 0.025, 0.03],
live["min_drop_pct_for_loss_cut"],
),
"min_margin": _fmerge(
[0.0, 0.1, 0.2, 0.3, 0.5, 1.0], live["min_margin"],
),
"min_hold_sec": _imerge(
[0, 10, 15, 30, 60, 90, 120], live["min_hold_sec"],
),
"max_daily": _imerge([3, 5, 10, 20, 50, 100], live["max_daily"]),
"time_start_hm": session_start_hm,
"time_end_hm": session_end_hm,
**_scalp_ob_grid_axes(sweep=False),
},
# wide — 축 스크리닝 (min_price=1000/6000, sl에 3.5 포함)
"wide": {
"rsi_oversold": _fmerge(
[15, 17, 19, 21, 23, 25, 28], live["rsi_oversold"],
),
"sl_pct": _fmerge([1.5, 2.0, 2.5, 3.0, 3.5], live["sl_pct"]),
"tp_pct": _fmerge([1.5, 2.0, 2.5, 3.0, 4.0], live["tp_pct"]),
"tp_max_pct": _fmerge([2.0, 3.0, 4.0, 5.0], live["tp_max_pct"]),
"drop_rate": _hts_scalp_drop_rate_grid(
live["drop_rate"], (2.0, 2.5, 3.0, 4.0, 5.0, 6.0),
),
"shoulder_min_high": _fmerge(
[0.5, 0.8, 3.0], live["shoulder_min_high"],
),
"shoulder_cut_pct": _fmerge(
[0.1, 0.2, 0.3, 0.4], live["shoulder_cut_pct"],
),
"high_chase_thr": _fmerge(
[0.96, 0.98, 0.99, 1.0], live["high_chase_thr"],
),
"max_daily_chg": _fmerge(
[12.0, 16.0, 20.0, 25.0, 30.0, 40.0],
live["max_daily_chg"],
),
"min_price": mp_list,
"use_defense_filters": _bmerge([False, True], live["use_defense_filters"]),
"max_loss_krw": _imerge(
[100000, 150000, 200000, 300000], live["max_loss_krw"],
),
"min_drop_pct_for_loss_cut": _fmerge(
[0.01, 0.015, 0.02], live["min_drop_pct_for_loss_cut"],
),
**_scalp_ob_grid_axes(sweep=False),
},
}
def _get_scalp_field_map():
"""스캘핑 파라미터 → env_config 컬럼 매핑 (apply / db_snapshot 공용). 웹·봇과 동일 키 저장."""
return {
"rsi_oversold": ("SCALP_RSI_OVERSOLD", lambda v: str(int(v))),
"rsi_overbought": ("SCALP_RSI_OVERBOUGHT", lambda v: str(int(v))),
"sl_pct": ("SCALP_STOP_LOSS_PCT", lambda v: str(float(v) / 100)),
"tp_pct": ("SCALP_TAKE_PROFIT_PCT", lambda v: str(float(v) / 100)),
"drop_rate": ("SCALP_MIN_DROP_RATE", lambda v: str(float(v) / 100)),
"trail_trigger": ("SCALP_ATR_UP_MULT", lambda v: str(abs(float(v)) / 100.0)),
"trail_stop": ("SCALP_ATR_DOWN_MULT", lambda v: str(abs(float(v)) / 100.0)),
"shoulder_min_high": ("SCALP_SHOULDER_MIN_HIGH_PCT", lambda v: str(float(v) / 100)),
"shoulder_cut_pct": ("SCALP_SHOULDER_CUT_PCT", lambda v: str(float(v) / 100)),
"tp_max_pct": ("SCALP_TP_MAX_PCT", lambda v: str(float(v) / 100)),
"cooldown_min": ("SCALP_COOLDOWN_SEC", lambda v: str(int(float(v)) * 60)),
# 스캘핑 전용 방어로직 (꼬리잡기와 값 분리)
"high_chase_thr": ("SCALP_HIGH_PRICE_CHASE_THRESHOLD", lambda v: str(float(v))),
"max_daily_chg": ("SCALP_MAX_DAILY_CHANGE_PCT", lambda v: str(float(v))),
"min_price": ("SCALP_MIN_PRICE", lambda v: str(int(float(v)))),
"max_loss_krw": ("SCALP_MAX_LOSS_PER_TRADE_KRW", lambda v: str(int(float(v)))),
"min_drop_pct_for_loss_cut": ("SCALP_MIN_DROP_PCT_FOR_LOSS_CUT", lambda v: str(round(float(v)*100, 2)) if float(v) < 1 else str(round(float(v), 2))), # % (1.5)
"min_margin": ("SCALP_MIN_PROFIT_PCT", lambda v: str(float(v))), # % 단위 (0.2 등)
"use_defense_filters": ("SCALP_USE_DEFENSE_FILTERS", lambda v: "true" if v else "false"),
"use_macd_cross": ("SCALP_USE_MACD_CROSS", lambda v: "true" if v else "false"),
"require_reversal_candle": ("SCALP_REQUIRE_REVERSAL_CANDLE", lambda v: "true" if v else "false"),
# skip_hts_scan_dupes: full+ 그리드 탐색만, apply 제외(운영 false 고정)
"vol_mult": ("VOL_MULTIPLIER", lambda v: str(float(v))),
"min_hold_sec": ("SCALP_MIN_HOLD_SEC", lambda v: str(int(float(v)))),
"rsi_period": ("SCALP_RSI_PERIOD", lambda v: str(int(float(v)))),
"max_daily": ("SCALP_MAX_DAILY", lambda v: str(int(float(v)))),
# time_start/end: field_map 제외 — session_env_patch 기본 OFF(운영 시간창 보존)
# 호가필터 축 → SCALP_ORDERBOOK_* (글로벌 ORDERBOOK_* 폐기)
"max_spread_pct": ("SCALP_ORDERBOOK_MAX_SPREAD_PCT", lambda v: str(float(v))),
"min_bid_ask_ratio": ("SCALP_ORDERBOOK_MIN_BID_ASK_RATIO", lambda v: str(float(v))),
"ask_max_mult": ("SCALP_ORDERBOOK_ENTRY_ASK_MAX_MULT", lambda v: str(float(v))),
"_orderbook_filter_enabled": (
"SCALP_ORDERBOOK_FILTER_ENABLED",
lambda v: "true" if v in (True, 1, "1", "true", "True", "on", "ON") else "false",
),
"ob_filter_enabled": (
"SCALP_ORDERBOOK_FILTER_ENABLED",
lambda v: "true" if v in (True, 1, "1", "true", "True", "on", "ON") else "false",
),
"whipsaw_enabled": (
"SCALP_WHIPSAW_FILTER_ENABLED",
lambda v: "true" if v in (True, 1, "1", "true", "True", "on", "ON") else "false",
),
"whipsaw_subbar_sec": ("SCALP_WHIPSAW_SUBBAR_SEC", lambda v: str(int(float(v)))),
"whipsaw_lookback_sec": ("SCALP_WHIPSAW_LOOKBACK_SEC", lambda v: str(int(float(v)))),
"whipsaw_dip_pct": ("SCALP_WHIPSAW_DIP_PCT", lambda v: str(float(v))),
"whipsaw_recovery_tol_pct": ("SCALP_WHIPSAW_RECOVERY_TOL_PCT", lambda v: str(float(v))),
}
def apply_params_to_db(best_params: dict) -> None:
"""Grid/Optuna 1위 → insert_env_snapshot (config_scalp + env_config)."""
_apply_to_db(best_params)
def evaluate_scalp_param_combo(
combo: Dict[str, Any],
*,
base_fixed: Dict[str, Any],
grid_keys: List[str],
codes_candles: Dict[str, List[Dict]],
min_trades: int,
min_win_rate: float,
min_pf: float,
universe_by_slot: Optional[Dict[str, List[str]]] = None,
slot_money: float = 3_000_000.0,
max_stocks: int = 3,
total_budget_krw: float = 9_000_000.0,
fee_rate: float = 0.00015,
sell_tax: float = 0.0018,
period_days: int = 1,
cache_holder: Optional[Dict[str, Any]] = None,
ticks_by_code: Any = None,
orderbook_by_code: Any = None,
program_by_code: Any = None,
start_key: str = "",
end_key: str = "",
include_trades: bool = False,
) -> Optional[Dict[str, Any]]:
"""단일 스캘핑(reversal) 조합 백테 — Grid 워커·Optuna objective 공통.
ticks_by_code: 웹/실매 정합용 ws_ticks (없으면 OHLC만 — 탐색 과대평가 위험).
"""
ui_params = dict(base_fixed)
ui_params.update(combo)
engine_params = _ui_to_engine_params(ui_params)
if cache_holder:
engine_params.update(cache_holder)
engine_params["slot_money"] = float(slot_money)
engine_params["max_stocks"] = int(max_stocks)
engine_params["total_budget_krw"] = float(total_budget_krw)
engine_params["portfolio_mode"] = True
if orderbook_by_code is not None:
engine_params["_bt_orderbook_by_code"] = orderbook_by_code
if program_by_code is not None:
engine_params["_bt_program_by_code"] = program_by_code
meta: Dict[str, Any] = {}
sk = str(start_key or "").strip()
ek = str(end_key or "").strip()
if len(sk) >= 12:
meta["start_key"] = sk[:12]
engine_params["_backtest_period_start_key"] = sk[:12]
elif len(sk) >= 8:
meta["start_key"] = sk[:8] + "0000"
engine_params["_backtest_period_start_key"] = meta["start_key"]
if len(ek) >= 12:
meta["end_key"] = ek[:12]
elif len(ek) >= 8:
meta["end_key"] = ek[:8] + "2359"
trades = sbc.run_scalping_backtest_web_aligned(
codes_candles, engine_params, universe_by_slot,
slot_money=slot_money, fee_rate=fee_rate, sell_tax=sell_tax,
max_stocks=max_stocks, total_budget_krw=total_budget_krw,
meta_out=meta, mode="reversal",
ticks_by_code=ticks_by_code,
)
stats = sbc.summarize_scalp_trades(
trades, total_budget_krw=total_budget_krw, period_days=period_days,
)
total_trades = stats["total_trades"]
if total_trades < min_trades:
return None
total_pnl = stats["total_pnl"]
win_rate = stats["win_rate"]
pf = float(stats.get("pf") or 0)
if not combo_passes_search_filters(
win_rate=win_rate, pf=pf,
min_win_rate=min_win_rate, min_pf=min_pf,
):
return None
peak, mdd, cum = 0.0, 0.0, 0.0
for t in trades:
cum += t["pnl"]
if cum > peak:
peak = cum
dd = peak - cum
if dd > mdd:
mdd = dd
merged = dict(ui_params)
merged["slot_money"] = float(slot_money)
merged["max_stocks"] = int(max_stocks)
merged["total_budget_krw"] = float(total_budget_krw)
from kis_trader.backtest.optuna_common import attach_daily_stability, attach_optional_backtest_trades
return attach_optional_backtest_trades(attach_daily_stability({
"params": {k: ui_params[k] for k in grid_keys if k in ui_params},
"total_pnl": int(total_pnl),
"win_rate": round(win_rate, 2),
"total_trades": total_trades,
"pf": round(pf, 2),
"avg_hold": round(stats["avg_hold_min"], 1),
"mdd": round(mdd),
"bot_pct": stats["bot_pct"],
"daily_avg_pct": stats["daily_avg_pct"],
"merged_params": merged,
}, trades), trades, include_trades)
def _params_to_db_snapshot(params: dict) -> dict:
"""그리드 params(표시 단위) → env_config 컬럼명:값 문자열 dict.
슬롯·동시보유·총한도는 apply 제외(운영 한도 보존). 웹「봇에 설정저장」만 포트폴리오 기록.
"""
field_map = _get_scalp_field_map()
snap = {
db_col: fmt(params[param_k])
for param_k, (db_col, fmt) in field_map.items()
if param_k in params
}
snap.update(session_env_patch("SCALP", params))
return strip_portfolio_keys_from_apply_patch(snap, "SCALP")
def _apply_from_latest_json(rank: int):
"""최근 search_*.json에서 rank번째(1-based) 항목의 merged_params를 DB에 적용."""
latest_path = _latest_json("search_")
if not latest_path:
print("⚠️ search_*.json 파일이 없습니다 (신규/구 경로 모두).")
return
with open(latest_path, "r", encoding="utf-8") as f:
data = json.load(f)
top = data.get("top") or []
if rank < 1 or rank > len(top):
print(f"⚠️ 순번 {rank}이(가) 유효하지 않습니다. (1~{len(top)})")
return
item = top[rank - 1]
merged = merge_param_search_apply_source(item, data)
if not merged:
print("⚠️ 해당 항목에 merged_params/params가 없습니다.")
return
if item.get("total_pnl", 0) <= 0:
print(f"⚠️ {rank}번째 결과는 총손익 ≤ 0 (조건 미충족). DB 미적용. 기존 설정 유지.")
return
print(f"📂 {latest_path} 에서 {rank}번째 적용합니다.")
_apply_to_db(merged)
def _ui_to_engine_params(ui_params: dict) -> dict:
"""UI 표시용(% 등) → 엔진용 비율 단위. 워커에서 공통 사용."""
engine_params = dict(ui_params)
engine_params["sl_pct"] = ui_params["sl_pct"] / 100
engine_params["tp_pct"] = ui_params["tp_pct"] / 100
if "tp_max_pct" in ui_params:
engine_params["tp_max_pct"] = ui_params["tp_max_pct"] / 100
elif "tp_max_pct" not in engine_params:
engine_params["tp_max_pct"] = 0.02
engine_params["drop_rate"] = ui_params["drop_rate"] / 100
if "shoulder_min_high" in ui_params:
engine_params["shoulder_min_high"] = ui_params["shoulder_min_high"] / 100
if "shoulder_cut_pct" in ui_params:
engine_params["shoulder_cut_pct"] = ui_params["shoulder_cut_pct"] / 100
if "trail_trigger" in ui_params:
engine_params["trail_trigger"] = ui_params["trail_trigger"] / 100
if "trail_stop" in ui_params:
engine_params["trail_stop"] = ui_params["trail_stop"] / 100
engine_params["fee_rate"] = ui_params["fee_rate"] / 100
engine_params["sell_tax"] = ui_params["sell_tax"] / 100
engine_params["min_margin"] = ui_params.get("min_margin", 0.2) / 100
if "use_defense_filters" in ui_params:
engine_params["use_defense_filters"] = bool(ui_params["use_defense_filters"])
if "use_macd_cross" in ui_params:
engine_params["use_macd_cross"] = bool(ui_params["use_macd_cross"])
if "require_reversal_candle" in ui_params:
engine_params["require_reversal_candle"] = bool(ui_params["require_reversal_candle"])
if "min_hold_sec" in ui_params:
engine_params["min_hold_sec"] = float(ui_params["min_hold_sec"])
if "vol_mult" in ui_params:
engine_params["vol_mult"] = float(ui_params["vol_mult"])
if "skip_hts_scan_dupes" in ui_params:
engine_params["skip_hts_scan_dupes"] = bool(ui_params["skip_hts_scan_dupes"])
elif "skip_hts_scan_dupes" not in engine_params:
engine_params["skip_hts_scan_dupes"] = se.resolve_scalp_skip_hts_scan_dupes()
# 호가필터 오버라이드 (파람서치 — 본체/스냅샷 재평가)
if ui_params.get("_orderbook_filter_enabled") is not None:
engine_params["_orderbook_filter_enabled"] = bool(ui_params["_orderbook_filter_enabled"])
if "max_spread_pct" in ui_params and ui_params.get("max_spread_pct") is not None:
engine_params["_ob_max_spread_pct"] = float(ui_params["max_spread_pct"])
if "min_bid_ask_ratio" in ui_params and ui_params.get("min_bid_ask_ratio") is not None:
engine_params["_ob_min_bid_ask_ratio"] = float(ui_params["min_bid_ask_ratio"])
if "ask_max_mult" in ui_params and ui_params.get("ask_max_mult") is not None:
engine_params["_ob_ask_max_mult"] = float(ui_params["ask_max_mult"])
# 👇 [핵심] 손절 퍼센트에 맞춰 1회 투자금(slot_money) 자동 계산 (봇과 동일 공식)
# 5억 고정이면 수수료만으로 손절컷 걸려 좋은 조합(RSI 17 등)이 버려짐 → max_loss_krw/sl_pct 로 보정
max_loss = engine_params.get("max_loss_krw", 200000)
if engine_params["sl_pct"] > 0 and max_loss > 0:
engine_params["slot_money"] = max_loss / engine_params["sl_pct"]
return engine_params
def _evaluate_scalp_chunk(
param_chunk: List[Dict[str, Any]],
base_fixed: Dict[str, Any],
keys: List[str],
codes_candles: Optional[Dict[str, List[Dict]]],
min_trades: int,
min_win_rate: float,
min_pf: float,
top_n: int,
universe_by_slot: Optional[Dict[str, List[str]]] = None,
sort_by: str = "pnl",
slot_money: float = 3_000_000.0,
max_stocks: int = 3,
total_budget_krw: float = 9_000_000.0,
fee_rate: float = 0.00015,
sell_tax: float = 0.0018,
period_days: int = 1,
) -> List[Tuple[float, float, int, Dict]]:
"""워커: 청크 내 조합 평가 — 시각순 포트폴리오·총한도 (scalping_backtest_common)."""
shared = worker_shared_get()
ticks_preloaded = None
period_start_key = ""
if shared:
if codes_candles is None:
codes_candles = shared.get("codes_candles") or {}
if universe_by_slot is None:
universe_by_slot = shared.get("universe_by_slot")
ticks_preloaded = shared.get("ticks_by_code")
period_start_key = str(shared.get("start_key") or "")[:12]
# ws_ticks 공유메모리(opt-in): descriptor 로 read-only attach (워커당 1회 재사용).
if not ticks_preloaded:
_desc = shared.get("ticks_shared_descriptor")
if _desc:
_tm = shared.get("_ticks_mapping_cache")
if _tm is None:
from kis_trader.backtest.shared_ticks import SharedTicksMapping
_tm = SharedTicksMapping(_desc)
shared["_ticks_mapping_cache"] = _tm
ticks_preloaded = _tm
if codes_candles is None:
codes_candles = {}
local_heap: List[Tuple[float, float, int, Dict]] = []
for combo in param_chunk:
assert_parent_alive()
ui_params = dict(base_fixed)
ui_params.update(combo)
engine_params = _ui_to_engine_params(ui_params)
engine_params["slot_money"] = float(slot_money)
engine_params["max_stocks"] = int(max_stocks)
engine_params["total_budget_krw"] = float(total_budget_krw)
engine_params["portfolio_mode"] = True
if period_start_key:
engine_params["_backtest_period_start_key"] = period_start_key
meta: Dict[str, Any] = {}
if period_start_key:
meta["start_key"] = period_start_key
trades = sbc.run_scalping_backtest_web_aligned(
codes_candles, engine_params, universe_by_slot,
slot_money=slot_money, fee_rate=fee_rate, sell_tax=sell_tax,
max_stocks=max_stocks, total_budget_krw=total_budget_krw,
meta_out=meta, mode="reversal",
ticks_by_code=ticks_preloaded,
)
stats = sbc.summarize_scalp_trades(
trades, total_budget_krw=total_budget_krw, period_days=period_days,
)
total_trades = stats["total_trades"]
if total_trades < min_trades:
continue
total_pnl = stats["total_pnl"]
win_rate = stats["win_rate"]
pf = float(stats.get("pf") or 0)
if not combo_passes_search_filters(
win_rate=win_rate, pf=pf,
min_win_rate=min_win_rate, min_pf=min_pf,
):
continue
avg_hold = stats["avg_hold_min"]
peak, mdd, cum = 0.0, 0.0, 0.0
for t in trades:
cum += t["pnl"]
if cum > peak:
peak = cum
dd = peak - cum
if dd > mdd:
mdd = dd
merged = dict(ui_params)
merged["slot_money"] = float(slot_money)
merged["max_stocks"] = int(max_stocks)
merged["total_budget_krw"] = float(total_budget_krw)
result_pkg = {
"params": {k: ui_params[k] for k in keys},
"total_pnl": int(total_pnl),
"win_rate": round(win_rate, 2),
"total_trades": total_trades,
"pf": round(pf, 2),
"avg_hold": round(avg_hold, 1),
"mdd": round(mdd),
"bot_pct": stats["bot_pct"],
"daily_avg_pct": stats["daily_avg_pct"],
"skipped_micro_buys": int(
(meta.get("skip_stats") or {}).get("skipped_micro_buys") or 0
),
"merged_params": merged,
}
# 청크 내 상위 top_n: sort_by 에 따라 heapreplace (과거 min-heap+pushpop 로 최악 조합만 남는 버그 수정)
if str(sort_by).strip().lower() == "pnl":
item_t = (total_pnl, win_rate, id(result_pkg), result_pkg)
if len(local_heap) < top_n:
heapq.heappush(local_heap, item_t)
elif total_pnl > local_heap[0][0]:
heapq.heapreplace(local_heap, item_t)
else:
item_t = (win_rate, total_pnl, id(result_pkg), result_pkg)
if len(local_heap) < top_n:
heapq.heappush(local_heap, item_t)
elif win_rate > local_heap[0][0]:
heapq.heapreplace(local_heap, item_t)
return local_heap
def _load_candles_for_search(
start: str,
end: str,
rsi_period: int,
*,
history_source: str = "kiwoom",
) -> dict:
"""엔진 직접 타격을 위해 DB에서 캔들을 한 번만 메모리에 로드합니다."""
db = TradeDB()
codes_candles = {}
try:
start_key = (start.replace("-", "") + "0000") if start else "20260101"
end_key = (end.replace("-", "") + "2359") if end else "99991231"
from kis_trader.backtest.scalping_backtest_common import load_scalp_candles_by_code
codes_candles, _ = load_scalp_candles_by_code(
db, start_key, end_key, rsi_period=rsi_period,
history_source=history_source,
)
finally:
db.close()
return codes_candles
def run_search(start: str, end: str, mode: str, top_n: int,
min_trades: int, min_win_rate: float, min_pf: float,
apply_rank: Optional[int], from_file_only: bool,
sort_by: str = "pnl", use_fallback_universe: bool = False,
slot_money: Optional[float] = None,
max_stocks: Optional[int] = None,
total_budget_krw: Optional[float] = None,
time_start_hm: Optional[int] = None,
time_end_hm: Optional[int] = None,
max_combos: Optional[int] = None) -> bool:
"""탐색 실행. 결과가 있어서 JSON 저장까지 했으면 True, 조건 만족 조합 없이 조기 return 시 False."""
if from_file_only and apply_rank is not None and apply_rank >= 1:
_apply_from_latest_json(apply_rank)
return True
grid = _scalp_grids()[mode]
keys = list(grid.keys())
axes = [grid[k] for k in keys]
# 데카르트곱 전체를 RAM 에 펼치지 않는다 (대형 그리드 OOM 방지).
dict_combos, total_grid, max_combos_cap, dropped_by_cap = cap_combos_uniform_lazy(
keys,
axes,
mode,
strategy_env_prefix="SCALP",
default_fast=192,
max_combos_override=max_combos,
)
total = len(dict_combos)
print(f"\n[{mode.upper()} 모드] 그리드: {total_grid:,} → 백테: {total:,} | 기간: {start} ~ {end}")
if mode == "fast":
print(f"📌 [fast] {total_grid:,}{max_combos_cap}균등샘플 · reversal·어깨·손익 축")
elif mode == "trigger":
print("📌 [trigger] RSI·낙폭·V자·거래량 — 청산은 DB 고정")
elif mode == "exit":
print("📌 [exit] 청산 전용 — TRIGGER(DB) 고정")
if dropped_by_cap:
print(f" (max-combos={max_combos_cap} 균등 샘플, 제외 {dropped_by_cap:,}개)")
print(f"📌 1위 정렬 기준: {'총손익 최대 (수익 나는 조합 우선)' if sort_by == 'pnl' else '승률 최대'}")
print(f"📌 필터: 승률≥{min_win_rate}% · PF≥{min_pf} · 거래≥{min_trades}")
print("=" * 70)
results = []
t0 = time.time()
FIXED_DEFAULTS = _fixed_defaults()
apply_session_to_fixed(
FIXED_DEFAULTS,
time_start_hm=time_start_hm,
time_end_hm=time_end_hm,
)
db = TradeDB()
try:
from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row
env_row = load_portfolio_env_row(db)
finally:
db.close()
fee_rate, sell_tax, slot_from_env = sbc.fee_and_slot_from_env(env_row, strategy="SCALP")
portfolio = sbc.resolve_scalp_portfolio_params(
env_row,
None,
strategy="SCALP",
slot_money=slot_money if slot_money is not None else slot_from_env,
max_stocks=max_stocks,
total_budget_krw=total_budget_krw,
)
slot_money_v = float(portfolio["slot_money"])
max_stocks_v = int(portfolio["max_stocks"])
total_budget_v = float(portfolio["total_budget_krw"])
period_days = max(
1,
(datetime.strptime(end, "%Y-%m-%d") - datetime.strptime(start, "%Y-%m-%d")).days + 1,
)
print(
f"💼 포트폴리오: 1회 {slot_money_v:,.0f}원 | 동시 {max_stocks_v}종 | "
f"총한도 {total_budget_v:,.0f}원 | 매매 {format_session_hm(FIXED_DEFAULTS)}"
)
if portfolio.get("budget_warning"):
print(f"💰 {portfolio['budget_warning']}")
# 캔들 데이터를 메모리에 1회 로드 (워커에 전달)
# rsi_period 그리드 최댓값으로 로드 → 조합별 기간 변경 시 봉 부족 방지
print("⏳ DB에서 캔들 데이터를 메모리로 불러오는 중...")
_rsi_cands = grid.get("rsi_period") or [FIXED_DEFAULTS.get("rsi_period", 3)]
try:
_rsi_load = max(int(float(x)) for x in _rsi_cands)
except (TypeError, ValueError):
_rsi_load = int(FIXED_DEFAULTS.get("rsi_period") or 3)
codes_candles = _load_candles_for_search(start, end, _rsi_load)
print(f"✅ 데이터 로드 완료: {len(codes_candles)}종목")
# ── 틱재생(ws_ticks) — 기본 ON (웹·실매 체결 정합, OHLC 폴백 기본 OFF) ──
start_key = (start.replace("-", "") + "0000") if start else "202601010000"
end_key = (end.replace("-", "") + "2359") if end else "999912312359"
ticks_by_code: Dict[str, Any] = {}
tick_backtest_meta: Dict[str, Any] = {}
tick_rows = 0
if sbc._scalp_backtest_wants_ticks(_ui_to_engine_params(FIXED_DEFAULTS)):
_tick_db = TradeDB()
try:
ticks_by_code, tick_rows = load_breakout_ticks_by_code(
_tick_db, start_key, end_key, set(codes_candles.keys()),
)
tick_backtest_meta = tick_coverage_stats(codes_candles, ticks_by_code)
tick_backtest_meta["ws_tick_rows_loaded"] = tick_rows
cov = tick_backtest_meta.get("tick_bar_coverage_pct", 0)
print(
f"✅ ws_ticks {tick_rows:,}건 | 분봉 커버리지 {cov}% "
f"({tick_backtest_meta.get('tick_codes_with_data', 0)}/"
f"{tick_backtest_meta.get('tick_codes_total', 0)}종목)"
)
if tick_rows <= 0:
print(
"⚠️ ws_ticks 없음 — SCALP 틱 청산/진입 불가 "
"(FALLBACK_OHLC 기본 OFF, 틱 수집 후 재탐색)"
)
else:
print(
"📌 틱재생(ws_ticks): ON — OHLC 폴백 "
f"{'ON' if get_env_bool('SCALP_BACKTEST_TICK_FALLBACK_OHLC', False) else 'OFF'}"
)
finally:
_tick_db.close()
# 유니버스: --fallback-universe 이면 이력 무시하고 시뮬레이션만 사용 (조합별 거래 수 확대)
# 신봇 기준:
# * 실매매는 10초 REST 폴링 + 변동 tick 마다 초단위 event_time 으로 저장.
# * 백테스트는 TradeDBExt.get_universe_by_candle_time("SCALP", ...) 로
# 1분 캔들 시각 키를 가진 dict 로 받아 엔진에 그대로 주입.
# * fallback 시뮬레이션은 1분봉 근사 점수 기반 5분 버킷팅 (과거 호환).
universe_by_slot = None
fallback_sim_interval = 5 # --fallback-universe 전용 시뮬레이션 버킷 (분)
start_ymd = start.replace("-", "") if start else ""
end_ymd = end.replace("-", "") if end else ""
if use_fallback_universe:
print("📌 [유니버스] --fallback-universe: 저장 이력 무시 → 시뮬레이션 유니버스 (조합별 거래 수 확대)")
elif start_ymd and end_ymd:
try:
# Optuna/웹과 동일 — resolve_scalp_universe (EXIT debounce 포함)
from kis_trader.backtest.scalping_backtest_common import resolve_scalp_universe
history, _src, n_bins, _scan_iv = resolve_scalp_universe(
start_ymd, end_ymd, use_saved_history=True, strategy_id="SCALP",
)
if history:
universe_by_slot = history
avg = sum(len(v) for v in history.values()) / max(1, n_bins)
print(
f"✅ 유니버스: 신봇 이력 사용 (event_time → 1분 캔들 리샘플링) | "
f"{n_bins:,}분봉 · 평균 {avg:.1f}종목 (SCALP)"
)
print(
"📌 [유니버스] 이력만 쓰면 매수 기회 적어 거래 0~1건 나올 수 있음. "
"조합 많을 때는 --fallback-universe 권장."
)
else:
universe_by_slot = None
except Exception as _e:
logging.getLogger("param_search").debug(
"신봇 유니버스 이력 조회 스킵: %s", _e,
)
universe_by_slot = None
# 엔진이 쓸 슬롯 단위 결정:
# * 신봇 이력 경로 → 1분봉 키(=passthrough)
# * 시뮬 fallback → fallback_sim_interval 분 버킷
engine_scan_interval_min = 1
if universe_by_slot is None:
universe_top_n = int(os.environ.get("UPDATE_UNIVERSE_TOP_N", "20"))
universe_min_score = float(os.environ.get("UPDATE_UNIVERSE_MIN_SCORE", "4.0"))
universe_by_slot = se.build_universe_simulation(
codes_candles,
top_n=universe_top_n,
min_score=universe_min_score,
scan_interval_min=fallback_sim_interval,
)
engine_scan_interval_min = fallback_sim_interval
n_slots = len(universe_by_slot)
avg_per_slot = sum(len(c) for c in universe_by_slot.values()) / max(1, n_slots)
print(
f"✅ 유니버스: 시뮬레이션 사용 (이력 없음) | "
f"{fallback_sim_interval}분 슬롯 {n_slots}개 · 슬롯당 평균 {avg_per_slot:.1f}종목"
)
print("📌 [유니버스] 서치는 '시뮬레이션 유니버스' 기준입니다. (신봇 이력 없음)")
# 엔진에 슬롯 단위 주입 (engine._slot_key 가 이 값으로 캔들 시각 정규화)
FIXED_DEFAULTS["scan_interval_min"] = engine_scan_interval_min
# ── ws_ticks 공유메모리 — 워커별 사본 대신 1벌 공유 (momentum·breakout 과 동일) ──
shared_tick_store = None
if get_env_bool("SCALP_PARAM_SEARCH_SHARED_TICKS", True) and ticks_by_code:
from kis_trader.backtest.shared_ticks import (
build_shared_ticks,
shared_ticks_available,
)
if shared_ticks_available():
shared_tick_store = build_shared_ticks(ticks_by_code)
if shared_tick_store is not None:
import atexit as _atexit
_atexit.register(shared_tick_store.unlink)
print("📦 ws_ticks 공유메모리 ON — 워커 attach(read-only), 사본 제거")
ticks_by_code = {}
import gc as _gc
_gc.collect()
try:
import ctypes as _ctypes
_ctypes.CDLL("libc.so.6").malloc_trim(0)
except Exception:
pass
else:
print("⚠️ ws_ticks 공유메모리 build 실패 — 기존 경로 폴백")
else:
print("⚠️ numpy/shared_memory 미지원 — 기존 경로 폴백")
# 조합을 딕셔너리 리스트로 변환 후 청크 분할 (dict_combos 는 fast 균등샘플 적용 완료)
shared = ParamSearchSharedPayload({
"codes_candles": codes_candles,
"universe_by_slot": universe_by_slot,
"ticks_by_code": ticks_by_code,
"ticks_shared_descriptor": (shared_tick_store.descriptor() if shared_tick_store else None),
"tick_backtest_meta": tick_backtest_meta,
"start_key": start_key[:12] if start_key else "",
"end_key": end_key[:12] if end_key else "",
})
payload_bytes = shared.estimate_bytes()
n_cpu = os.cpu_count() or 4
_cpu_frac = get_env_float("PARAM_SEARCH_CPU_FRAC", 0.8)
max_workers, chunk_size, _ = param_search_chunk_plan(total, payload_bytes)
# 틱 payload 시 워커 상한 (OOM 방지) — 하드코딩 금지, DB/Env
if ticks_by_code or shared_tick_store is not None:
if ticks_by_code:
tick_cap = get_env_int("SCALP_PARAM_SEARCH_MAX_WORKERS_WITH_TICKS", 2)
if tick_cap > 0 and max_workers > tick_cap:
max_workers = tick_cap
print(
f"📌 ws_ticks payload — 워커 상한 {max_workers} "
"(SCALP_PARAM_SEARCH_MAX_WORKERS_WITH_TICKS)"
)
if shared_tick_store is not None:
shared_cap = get_env_int("SCALP_PARAM_SEARCH_MAX_WORKERS_WITH_SHARED_TICKS", 0)
if shared_cap > 0 and max_workers > shared_cap:
max_workers = shared_cap
print(
f"📌 ws_ticks 공유메모리 — 워커 상한 {max_workers} "
"(SCALP_PARAM_SEARCH_MAX_WORKERS_WITH_SHARED_TICKS)"
)
chunks = [dict_combos[i:i + chunk_size] for i in range(0, len(dict_combos), chunk_size)]
print(param_search_worker_budget_line(payload_bytes))
print(f"⚙️ 멀티프로세싱 시작 (코어: {n_cpu}, 워커: {max_workers}, CPU {_cpu_frac*100:.0f}%) | 청크: {len(chunks):,}개 (청크당 ~{chunk_size}조합)")
start_time = time.time()
global_heap: List[Tuple[float, float, Tuple[int, int], Dict]] = []
progress_eta = ParamSearchProgressETA(len(chunks), max_workers)
with managed_process_pool(max_workers, shared_payload=shared) as executor:
def _submit(chunk: List[Dict[str, Any]]):
return executor.submit(
_evaluate_scalp_chunk, chunk, FIXED_DEFAULTS, keys, None,
min_trades, min_win_rate, min_pf, top_n, None, sort_by,
slot_money_v, max_stocks_v, total_budget_v, fee_rate, sell_tax, period_days,
)
combos_per_chunk = chunk_size
print(f"⏳ 청크 처리 중… (청크당 최대 {combos_per_chunk:,}개 조합, 완료되는 대로 진행률·ETA 출력)")
processed = 0
use_carriage_return = sys.stdout.isatty()
for local_results in iter_pool_chunk_results(
executor, chunks, _submit, max_workers=max_workers,
):
processed += 1
for idx, item in enumerate(local_results):
if sort_by == "pnl":
pnl, wr, _, result_pkg = item
entry = (pnl, wr, (processed, idx), result_pkg)
if len(global_heap) < top_n:
heapq.heappush(global_heap, entry)
elif pnl > global_heap[0][0]:
heapq.heapreplace(global_heap, entry)
else:
wr, pnl, _, result_pkg = item
entry = (wr, pnl, (processed, idx), result_pkg)
if len(global_heap) < top_n:
heapq.heappush(global_heap, entry)
elif wr > global_heap[0][0]:
heapq.heapreplace(global_heap, entry)
# ETA — 워밍업 1파도 제외 누적 평균 (param_search_pool.ParamSearchProgressETA)
progress = (processed / len(chunks)) * 100
elapsed_so_far = time.time() - start_time
eta_str = ParamSearchProgressETA.format_sec(
progress_eta.remaining_sec(processed, elapsed_so_far),
)
elapsed_str = ParamSearchProgressETA.format_elapsed(elapsed_so_far)
eta_msg = f" | 경과: {elapsed_str} | 남은시간: {eta_str}"
line = f"⏳ 진행률: {progress:.1f}% ({processed:,}/{len(chunks):,} 청크 완료){eta_msg}"
if use_carriage_return:
print(f"\r{line}", end="", flush=True)
else:
print(line, flush=True)
if use_carriage_return:
print(flush=True) # 줄바꿈으로 진행률 줄 마무리
elapsed = time.time() - start_time
if not global_heap:
print("⚠️ 조건을 만족하는 조합이 없습니다. (min_trades를 낮추거나 기간을 늘려보세요.)")
print("📌 DB 미적용. 기존 설정 유지.")
return False
# 힙: heapreplace 로 상위 유지 → heappop 순은 정렬 보조용일 뿐, 최종은 아래 sort 로 확정
results = [heapq.heappop(global_heap)[3] for _ in range(len(global_heap))]
if sort_by == "win_rate":
results.sort(key=lambda r: (-r["win_rate"], -r["total_pnl"]))
else:
results.sort(key=lambda r: (-r["total_pnl"], -r["win_rate"]))
# RSI_OVERSOLD 기준으로 균등 분배 → JSON에 RSI별 PER_RSI_JSON개씩 (한 RSI에 치중 방지)
if "rsi_oversold" in keys and results:
rsi_vals = sorted(set(r["params"]["rsi_oversold"] for r in results))
by_rsi: Dict[Any, List[Dict]] = {}
for r in results:
v = r["params"]["rsi_oversold"]
if v not in by_rsi:
by_rsi[v] = []
if len(by_rsi[v]) < PER_RSI_JSON:
by_rsi[v].append(r)
results = []
for v in rsi_vals:
results.extend(by_rsi.get(v, []))
# 손익 순 유지, 동점이면 RSI 낮은 쪽(과매도 강함) 우선
results.sort(key=lambda r: (-r["total_pnl"], r["params"]["rsi_oversold"], -r["win_rate"]))
print(f"✅ RSI별 상위 {PER_RSI_JSON}개씩 보장 → {len(results)}건 (총 {len(rsi_vals)}가지 RSI)")
# 승률 min_win_rate 이상만 우선; 없으면 차악
filtered = [
r for r in results
if combo_passes_search_filters(
win_rate=float(r.get("win_rate") or 0),
pf=float(r.get("pf") or 0),
min_win_rate=min_win_rate,
min_pf=min_pf,
)
]
if filtered:
results = filtered
order_msg = "수익→승률 순" if sort_by == "pnl" else "승률→수익 순"
print(f"✅ 승률≥{min_win_rate}% · PF≥{min_pf} {len(results)}건 중 {order_msg} 상위")
else:
print(f"⚠️ 승률≥{min_win_rate}% · PF≥{min_pf} 없음 → 차악(상위) 적용")
# 손익 마이너스인 조합 제외 (수익 나는 것만 표시·저장)
profitable = [r for r in results if r["total_pnl"] > 0]
if profitable:
results = profitable
print(f"✅ 총손익 플러스만 사용: {len(results)}건 (손실 조합 제외)")
else:
print(f"⚠️ 수익 나는 조합 없음 → 손실 최소 순으로 표시")
print(f"\n완료: {elapsed:.1f}초 | 유효 결과: {len(results)}")
# ── 결과 출력 ──
hdr_keys = [k for k in keys]
col_w = max(len(k) for k in hdr_keys) + 2
order_label = "수익" if sort_by == "pnl" else "승률"
print(f"\n{'='*90}")
print(f" 🏆 {order_label} TOP {min(top_n, len(results))} (투자금 1,000,000원 기준)")
print(f"{'='*90}")
# 헤더
hdr = " ".join(f"{k:>{col_w}}" for k in hdr_keys)
print(f"{hdr} | {'손익(원)':>12} {'승률':>6} {'거래':>5} {'PF':>5} {'보유':>6}")
print("-" * (len(hdr) + 55))
for r in results[:top_n]:
p = r["params"]
row = " ".join(
(f"{str(p[k]):>{col_w}}" if isinstance(p[k], bool) else f"{p[k]:>{col_w}.4g}")
for k in hdr_keys
)
print(f"{row} | {r['total_pnl']:>+12,.0f} {r['win_rate']:>5.1f}% "
f"{r['total_trades']:>5} {r['pf']:>5.2f} {r['avg_hold']:>5.1f}")
best = results[0]
bp = best["params"]
print(f"""
╔══════════════════════════════════════════╗
║ 🏆 1위 최적 파라미터 ║
╠══════════════════════════════════════════╣""")
_disp_map = {
"rsi_oversold": "SCALP_RSI_OVERSOLD",
"rsi_overbought": "SCALP_RSI_OVERBOUGHT",
"sl_pct": "SCALP_STOP_LOSS_PCT (÷100)",
"tp_pct": "SCALP_TAKE_PROFIT_PCT(÷100)",
"drop_rate": "SCALP_MIN_DROP_RATE (÷100)",
"trail_trigger": "SCALP_ATR_UP_MULT (레거시)",
"trail_stop": "SCALP_ATR_DOWN_MULT(레거시)",
"shoulder_min_high": "SCALP_SHOULDER_MIN_HIGH_PCT (÷100)",
"shoulder_cut_pct": "SCALP_SHOULDER_CUT_PCT (÷100)",
"tp_max_pct": "SCALP_TP_MAX_PCT (÷100)",
"cooldown_min": "SCALP_COOLDOWN_SEC(÷60=분)",
"high_chase_thr": "HIGH_PRICE_CHASE_THRESHOLD",
"max_daily_chg": "MAX_DAILY_CHANGE_PCT",
"max_loss_krw": "MAX_LOSS_PER_TRADE_KRW(원)",
"min_price": "SCALP_MIN_PRICE(원)",
"use_defense_filters": "SCALP_USE_DEFENSE_FILTERS",
}
for k, v in bp.items():
label = _disp_map.get(k, k)
print(f"{label:<30s} : {v!s:>6}")
print(f"""╠══════════════════════════════════════════╣
║ 총 손익 : {best['total_pnl']:>+12,.0f} 원 ║
║ 승률 : {best['win_rate']:>6.1f}% ║
║ 총 거래 : {best['total_trades']:>5} 건 ║
║ Profit Factor : {best['pf']:>5.2f}
║ 평균 보유 : {best['avg_hold']:>5.1f} 분 ║
║ 최대 낙폭(MDD): {best['mdd']:>12,.0f} 원 ║
╚══════════════════════════════════════════╝""")
# JSON 저장용: RSI별 200개씩 모은 전체 리스트 (콘솔 TOP은 여전히 top_n만 출력)
top_list = []
for idx, r in enumerate(results):
p = r["params"]
merged = r.get("merged_params", p)
top_list.append({
"rank": idx + 1,
"params": p,
"merged_params": merged,
"db_snapshot": _params_to_db_snapshot(merged),
"total_pnl": r["total_pnl"],
"win_rate": r["win_rate"],
"total_trades": r["total_trades"],
"pf": r["pf"],
"avg_hold": r["avg_hold"],
"mdd": r["mdd"],
"bot_pct": r.get("bot_pct"),
"daily_avg_pct": r.get("daily_avg_pct"),
"skipped_micro_buys": r.get("skipped_micro_buys", 0),
})
# ── JSON 결과 저장 (kis_trader/backtest/results/) ──
# 권한 문제 대비: 쓰기 실패 시 사용자 홈(~/.kis_bot_search_results/)으로 폴백.
out_dir = _results_dir_for_write()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_path = os.path.join(out_dir, f"search_{mode}_{ts}.json")
payload = {
"mode": mode,
"start": start,
"end": end,
"min_win_rate": min_win_rate,
"min_pf": min_pf,
"top": top_list,
}
payload.update(search_json_meta(portfolio, FIXED_DEFAULTS))
try:
with open(out_path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print(f"\n💾 결과 저장: {out_path}")
except (PermissionError, OSError) as _e:
fallback_dir = os.path.join(os.path.expanduser("~"), ".kis_bot_search_results")
os.makedirs(fallback_dir, exist_ok=True)
out_path = os.path.join(fallback_dir, f"search_{mode}_{ts}.json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print(f"\n⚠️ 기본 경로({out_dir}) 쓰기 실패({type(_e).__name__}). 폴백 저장: {out_path}")
print(f" 권한 복구: sudo chown -R $USER:$USER {out_dir}")
# ── DB 자동 적용 (만족 조건: 총손익 > 0. 미충족 시 기존 설정 유지) ──
if apply_rank is not None and apply_rank >= 1 and apply_rank <= len(top_list):
cand = top_list[apply_rank - 1]
if cand.get("total_pnl", 0) <= 0:
print(f"⚠️ {apply_rank}번째 결과는 총손익 ≤ 0 (조건 미충족). DB 미적용. 기존 설정 유지.")
else:
merged_apply = merge_param_search_apply_source(cand, payload)
_apply_to_db(merged_apply)
print(f"{apply_rank}번째 결과 적용 완료")
return True
def _apply_to_db(best_params: dict):
"""1위 파라미터 → insert_env_snapshot (config_scalp + env_config)."""
from kis_trader.backtest.param_search_apply_snapshot import apply_env_patch
patch = _params_to_db_snapshot(best_params)
if not patch:
print("DB 적용할 파라미터가 없습니다.")
return
eid = apply_env_patch(patch)
if eid is None:
print("❌ insert_env_snapshot 실패")
return
print(f"\n✅ config_scalp + env_config INSERT id={eid}:")
for k, v in sorted(patch.items()):
print(f" {k:<30s} = {v}")
# ──────────────────────────────────────────────────────────────────────────────
# CLI 진입점
# ──────────────────────────────────────────────────────────────────────────────
def main():
from kis_trader.backtest.param_search_dates import resolve_param_search_range
week_ago, today = resolve_param_search_range("SCALP", lookback_days=7)
parser = argparse.ArgumentParser(description="스캘핑 백테스트 파라미터 Grid Search")
parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD, 거래일 보정)")
parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD, 주말·휴장이면 이전 장운영일)")
parser.add_argument("--mode", default="fast",
choices=["fast", "trigger", "exit", "coarse", "fine", "full", "wide"],
help="탐색 모드: fast / trigger(진입) / exit(청산) / coarse / fine / full / wide")
parser.add_argument(
"--max-combos", type=int, default=None, dest="max_combos",
help="백테 조합 상한 (fast 기본 env SCALP_FAST_MAX_COMBOS 또는 PARAM_SEARCH_FAST_MAX_COMBOS=192, 0=무제한)",
)
parser.add_argument("--top", default=5000, type=int, help="상위 N개 출력·JSON 저장 (기본 5000)")
parser.add_argument("--min_trades", default=1, type=int, help="최소 거래 건수")
add_search_filter_cli_args(parser)
parser.add_argument("--apply", nargs="?", const=1, type=int, default=None, metavar="N",
help="N번째 결과를 DB에 적용 (기본 1). --from-file 시 최근 JSON에서 적용")
parser.add_argument("--from-file", action="store_true", help="--apply N 과 함께 사용 시, 최근 결과 JSON에서만 적용 (탐색 생략)")
parser.add_argument("--sort-by", default="pnl", choices=["pnl", "win_rate"],
help="1위 기준: pnl=총손익 최대(기본), win_rate=승률 최대")
parser.add_argument("--fallback-universe", action="store_true", dest="fallback_universe",
help="저장 이력 무시, 시뮬레이션 유니버스만 사용. 조합 많을 때 거래 수 확대용")
add_portfolio_cli_args(parser)
parser.add_argument("--apply-ai", action="store_true", dest="apply_ai",
help="Gemini가 수익·승률 기준으로 하나 골라 DB 적용. --from-file 과 함께 쓰면 탐색 없이 최근 JSON만 사용; 그 외에는 탐색 완료 후 방금 생성된 JSON으로 적용")
args = parser.parse_args()
# 탐색 없이 최근 JSON으로만 AI 적용 (--from-file --apply-ai)
if args.apply_ai and args.from_file:
import param_apply_ai
param_apply_ai.apply_ai_scalp()
return
# SIGTERM 을 KeyboardInterrupt 와 동일하게 처리 (systemd·운영자 kill 대응)
def _sigterm_to_kbd(_sig, _frm):
raise KeyboardInterrupt("SIGTERM 수신 → 워커 정리 후 종료")
try:
signal.signal(signal.SIGTERM, _sigterm_to_kbd)
except Exception:
pass
run_lock = None
if not (args.from_file and args.apply is not None):
run_lock = try_acquire_run_lock("param_search_scalping")
if run_lock is None:
print(
"⛔ 이미 실행 중인 param_search_scalping 이 있습니다.\n"
" ps -ef | grep param_search_scalping\n"
" pkill -f 'param_search_scalping.py' 후 재실행하세요.",
flush=True,
)
sys.exit(2)
try:
had_results = run_search(
start = args.start,
end = args.end,
mode = args.mode,
top_n = args.top,
min_trades = args.min_trades,
min_win_rate = args.min_win_rate,
min_pf = args.min_pf,
apply_rank = args.apply,
from_file_only = args.from_file,
sort_by = args.sort_by,
use_fallback_universe = args.fallback_universe,
slot_money = args.slot_money,
max_stocks = args.max_stocks,
total_budget_krw = args.total_budget,
time_start_hm = args.time_start,
time_end_hm = args.time_end,
max_combos = args.max_combos,
)
except KeyboardInterrupt as e:
print(f"\n{e} — 미완료 결과 없이 종료합니다.", flush=True)
sys.exit(130)
finally:
if run_lock is not None:
run_lock.release()
# 탐색에서 조건 만족 조합이 있었을 때만 방금 저장된 JSON으로 AI 적용 (없으면 기존 설정 유지)
if args.apply_ai and had_results:
import param_apply_ai
param_apply_ai.apply_ai_scalp()
elif args.apply_ai and not had_results:
print("📌 이번 탐색에서 조건 만족 조합 없음 → apply_ai 스킵. DB 미적용. 기존 설정 유지.")
if __name__ == "__main__":
main()