- Optuna web jobs/TPE/apply snapshot·틱로더 정합, jobs limit·감사로그 - 백테 UI 호가모드·후보 적용 흐름, feed_collect_stats API/탭 - 가설검증·교차검증 룰, 4전략 스모크·OB slot41 진단 스크립트 Co-authored-by: Cursor <cursoragent@cursor.com>
1692 lines
79 KiB
Python
1692 lines
79 KiB
Python
#!/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()
|