#!/usr/bin/env python3 """ kis_trader/backtest/tail_param_search.py — 꼬리잡기 백테스트 파라미터 자동 탐색 (Grid Search) ============================================================================================== tail_engine 을 직접 임포트해 run_tail_backtest 호출. 기본값은 DB(env_config) 단일 소스. 실매매·백테스트·파라서치가 동일한 env 값을 사용해 결과 예측 가능. [V3 통합]: MA20, 피뢰침, ATR 동적 배수, 리스크 비율 등 고급 방어 로직 변수 탐색 추가. [성능 최적화]: 멀티프로세싱(ProcessPool) 및 Heapq 기반 메모리 최적화 적용. 실행: cd /home/hoon/kis_bot python3 kis_trader/backtest/tail_param_search.py # 또는 python3 -m kis_trader.backtest.tail_param_search --mode full --apply 옵션: --start 시작일 (기본: 오늘-7일) --end 종료일 (기본: 오늘) --mode 탐색 모드: fast / coarse / fine / wide / full / massive (기본: fast ≈768조합·~10–20분·포트폴리오) --timeframe ws_candles 분봉 (3·5·15·60, 기본 3 — backtest_web 꼬리 탭과 동일) --slot-money 1회투자금(원). 미지정 시 DB (웹 tl_slot) --max-stocks 동시보유 종목. 미지정 시 DB SHORT_MAX_STOCKS --total-budget 총 운용한도(원). 0/미지정 시 동시×1회투자 (웹 tl_total_budget) --top 상위 N개 출력·JSON 저장 (기본: 5000) --min_trades 최소 거래 건수 필터 (기본: 1) --min_win_rate 승률 하한 (기본: 40.0). --min_pf Profit Factor 하한 (기본: 1.0). --max-combos 백테 조합 상한 (기본 5000, 0=무제한·fine/full 비추) --apply [N] 1위(또는 N위) 결과를 DB에 적용. N 생략 시 1. --from-file --apply N 과 함께 사용 시, 최근 결과 JSON에서 N번째 적용 (탐색 생략). ⚠️ 그리드 축 env 오버라이드 (콤마 구분, DB/env_config 또는 셸 export): TAIL_GRID_{FAST|COARSE|FINE|WIDE|FULL|MASSIVE}_{축이름} 예) TAIL_GRID_COARSE_MAX_DAILY_CHG=15,20,25,30 예) TAIL_GRID_FAST_LIMIT_ATR_MULT=1.5,2.0 ⚠️ 파라서치 진입 모드 (체결 수 확보): TAIL_PARAM_SEARCH_ENTRY_MODE=align|limit_atr 미설정 시 DB TAIL_ENTRY_MODE 사용. limit_atr 은 거래 수가 적을 수 있음. UPDOW(직전봉 하락·다음봉 시가 매수, holding_min_candles): kis_trader/backtest/param_search_updow.py 위치 이관 (2026-04 기준): backtest_scalping/tail_param_search.py → kis_trader/backtest/tail_param_search.py - ROOT = kis_bot 프로젝트 루트 (__file__ 기준 3단계 위) - 결과 저장: kis_trader/backtest/results/search_tail_{mode}_YYYYMMDD_HHMMSS.json (``search_momentum_*`` / ``search_breakout_*`` 와 동일 패턴 · 매 실행 신규 파일) - 쓰기 실패 시: ~/.kis_bot_search_results/ 에 동일 파일명 폴백 - --from-file /--apply-ai: 최신 search_tail_*.json 우선 → 구 tail_search_*.json → 레거시 tail_param_result.json (kis_trader/backtest·backtest_scalping) """ import sys import os import time import json import signal import logging import argparse import itertools import heapq from datetime import datetime, timedelta from concurrent.futures import as_completed from typing import List, Dict, Any, Tuple, Optional, Set # 프로젝트 루트 경로 추가 (database, tail_engine 등 임포트용) 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) from database import TradeDB from kis_trader.engine import tail_engine as te from kis_trader.backtest import tail_backtest_common as tbc 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.optuna_common import attach_daily_stability 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.engine.indicator_cache import attach_indicator_caches_to_params from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger("tail_param_search") DEFAULT_MAX_COMBOS = 5000 # coarse 그리드 전체(~3500) + fine/full 상한 FAST_MAX_COMBOS = 256 # fast 16축 → 균등샘플 (각 축 값별 marginal 스캔 · ~15–30초) TAIL_BACKTEST_STRATEGY_ID = tbc.TAIL_STRATEGY_ID # backtest_web 과 동일 # ───────────────────────────────────────────────────────────────────────────── # 결과 디렉터리 (신규 위치 우선, 구 경로 fallback) # ───────────────────────────────────────────────────────────────────────────── def _results_dir_for_write() -> str: d = os.path.join(HERE, "results") os.makedirs(d, exist_ok=True) return d def _results_dirs_for_read() -> List[str]: """타임스탬프 JSON 탐색 — 신규 results · 홈 폴백 · 구 backtest_scalping/results.""" seen: Set[str] = set() out: List[str] = [] for d in ( os.path.join(HERE, "results"), os.path.join(os.path.expanduser("~"), ".kis_bot_search_results"), os.path.join(ROOT, "backtest_scalping", "results"), ): if d and d not in seen: seen.add(d) out.append(d) return out def _legacy_tail_param_fixed_paths() -> List[str]: """구버전 고정 파일명 (--from-file 레거시 호환).""" return [ os.path.join(HERE, "tail_param_result.json"), os.path.join(ROOT, "backtest_scalping", "tail_param_result.json"), ] def _latest_tail_json_path() -> Optional[str]: """가장 최근 수정 시각인 search_tail_*.json 또는 tail_search_*.json 경로.""" prefixes = ("search_tail_", "tail_search_") best_path: Optional[str] = None best_mtime = -1.0 for d in _results_dirs_for_read(): if not os.path.isdir(d): continue try: names = os.listdir(d) except OSError: continue for f in names: if not f.endswith(".json"): continue if not any(f.startswith(p) for p in prefixes): 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 def _find_tail_result_json() -> Optional[str]: """--from-file / --apply / apply_ai 가 읽을 JSON (최신 타임스탬프 파일 우선).""" ts_path = _latest_tail_json_path() if ts_path: return ts_path for p in _legacy_tail_param_fixed_paths(): if os.path.isfile(p): return p return None def _parse_csv_floats(env_key: str, fallback: List[float]) -> List[float]: """env/DB 콤마 구분 float 리스트 → 그리드 축.""" raw = get_env_from_db(env_key, "") if not raw or str(raw).strip() in ("", "None"): return list(fallback) out: List[float] = [] for part in str(raw).split(","): part = part.strip().split("#", 1)[0].strip() if not part: continue try: out.append(float(part)) except ValueError: continue return out if out else list(fallback) def _parse_csv_strings(env_key: str, fallback: List[str]) -> List[str]: """env 콤마 문자열 → 그리드 축 (entry_mode 등).""" raw = get_env_from_db(env_key, "") if raw in (None, "", "None"): return list(fallback) out: List[str] = [] for part in str(raw).split(","): part = part.strip().split("#", 1)[0].strip().lower() if part: out.append(part) return out if out else list(fallback) def _parse_semi_strings(env_key: str, fallback: List[str]) -> List[str]: """env 세미콜론(;) 구분 문자열 → 그리드 축. tier 프리셋처럼 값 자체에 콤마가 들어가는 경우용(예: '30000:50,100000:35'). 예) TAIL_GRID_FAST_TRAIL_TIERS='off;20000:40,50000:30;30000:50,100000:35' """ raw = get_env_from_db(env_key, "") if raw in (None, "", "None"): return list(fallback) out: List[str] = [] for part in str(raw).split(";"): part = part.strip() if part: out.append(part) return out if out else list(fallback) def _parse_csv_bools(env_key: str, fallback: List[bool]) -> List[bool]: """env 콤마 bool 리스트 → 그리드 축 (패턴 ON/OFF 등). 'true/1/on' = True.""" raw = get_env_from_db(env_key, "") if raw in (None, "", "None"): return list(fallback) out: List[bool] = [] for part in str(raw).split(","): part = part.strip().split("#", 1)[0].strip().lower() if not part: continue out.append(part in ("1", "true", "t", "y", "yes", "on")) return out if out else list(fallback) def _format_param_cell(value: Any, col_w: int) -> str: """그리드 표시: 문자열·bool은 그대로, 숫자는 .4g.""" if isinstance(value, bool): return f"{str(value):>{col_w}}" if isinstance(value, str): return f"{value:>{col_w}}" return f"{value:>{col_w}.4g}" 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 part in str(raw).split(","): part = part.strip().split("#", 1)[0].strip() if not part: continue try: out.append(int(float(part))) except ValueError: continue return out if out else list(fallback) def _snap_float(snap: Optional[Dict[str, Any]], key: str, default: float) -> float: """env_config 스냅샷 → float (그리드 끝값).""" if snap and key in snap and snap[key] is not None: raw = str(snap[key]).split("#", 1)[0].strip() if raw: try: return float(raw) except (TypeError, ValueError): pass try: return float(get_env_from_db(key, default) or default) except (TypeError, ValueError): return float(default) def _tail_grids(mode: str) -> Dict[str, List[Any]]: """ 꼬리 파라서치 그리드 — TAIL_GRID_{MODE}_* env 로 축 오버라이드 (돌파 BREAKOUT_GRID_* 와 동일 패턴). fast: entry_mode(align/limit_atr) + limit_atr_mult — 틱 OFF·3분봉 OHLC (모멘텀과 동일). """ m = (mode or "fast").strip().lower() p = f"TAIL_GRID_{m.upper()}_" if m == "fast": # [FAST] wide1(7/15) Top 근방 스모크 — plateau +16k 분지 # 앵커: rec0.1·래칫1.0:0.4… · atr_tp 2.5/5 · drop0.03 · align · trail off # 실매 앵커(rec0.25·atr_tp2·sl_max6·래칫실매·rsi85·cd5) 포함 return { "entry_mode": _parse_csv_strings(f"{p}ENTRY_MODE", ["align"]), "cand_limit": _parse_csv_ints( f"{p}CAND_LIMIT", sorted(set([0, 20, get_env_int("SHORT_CAND_LIMIT", 0)])), ), "max_daily_change": _parse_csv_floats( f"{p}MAX_DAILY_CHG", [30.0, 35.0], ), "min_drop_rate": _parse_csv_floats( f"{p}MIN_DROP_RATE", [0.015, 0.03], ), "min_recovery_ratio": _parse_csv_floats( f"{p}MIN_RECOVERY_RATIO", [0.10, 0.25], ), "tail_ratio_min": _parse_csv_floats(f"{p}TAIL_RATIO_MIN", [0.6, 1.0]), "tail_pct_min": _parse_csv_floats(f"{p}TAIL_PCT_MIN", [0.001, 0.005]), "max_rec_3m": _parse_csv_floats(f"{p}MAX_REC_3M", [0.85, 0.90]), "shoulder_min_high": _parse_csv_floats( f"{p}SHOULDER_MIN_HIGH", [0.003, 0.005, 0.007], ), "shoulder_cut_pct": _parse_csv_floats( f"{p}SHOULDER_CUT_PCT", [0.001, 0.002], ), "stop_atr_mult": _parse_csv_floats(f"{p}STOP_ATR_MULT", [1.0, 1.5, 2.0]), "target_atr_mult": _parse_csv_floats( f"{p}TARGET_ATR_MULT", [1.0, 1.5, 2.5], ), "atr_sl_min_pct": _parse_csv_floats(f"{p}ATR_SL_MIN_PCT", [0.5, 0.8]), "atr_sl_max_pct": _parse_csv_floats( f"{p}ATR_SL_MAX_PCT", [2.0, 6.0], ), "atr_tp_min_pct": _parse_csv_floats(f"{p}ATR_TP_MIN_PCT", [0.3, 0.5]), "atr_tp_max_pct": _parse_csv_floats( f"{p}ATR_TP_MAX_PCT", [2.0, 2.5, 5.0], ), "tail_vol_mult": _parse_csv_floats(f"{p}VOL_MULT", [0.0, 1.5, 3.0]), "tail_vol_win": _parse_csv_floats(f"{p}VOL_WIN", [3.0, 5.0]), "limit_atr_mult": _parse_csv_floats(f"{p}LIMIT_ATR_MULT", [1.0, 2.0]), "ratchet_tiers": _parse_csv_strings( f"{p}RATCHET_TIERS", [ "off", "0.5:0.3,1.0:0.25,2.0:0.2", # 실매 "1.0:0.4,2.0:0.3", # wide1 Top ], ), "symbol_daily_loss_limit_pct": _parse_csv_floats( f"{p}SYMBOL_LOSS_PCT", [1.5, 2.0], ), "symbol_daily_loss_limit_krw": _parse_csv_floats( f"{p}SYMBOL_LOSS_KRW", [30000.0, 50000.0], ), "reentry_min_edge_krw": _parse_csv_floats( f"{p}REENTRY_MIN_EDGE", [0.0, 500.0], ), "reentry_require_nonneg": _parse_csv_bools( f"{p}REENTRY_REQUIRE_NONNEG", [False], ), "max_daily": _parse_csv_ints(f"{p}MAX_DAILY", [20, 50]), "cooldown_min": _parse_csv_floats(f"{p}COOLDOWN_MIN", [1.0, 5.0]), "bar_chg_min_pct": _parse_csv_floats(f"{p}BAR_CHG_MIN", [-10.0, -5.0]), "bar_chg_max_pct": _parse_csv_floats(f"{p}BAR_CHG_MAX", [-0.5]), "rsi_threshold": _parse_csv_floats( f"{p}RSI_THRESHOLD", [75.0, 85.0], ), "pattern_pin": _parse_csv_bools(f"{p}PATTERN_PIN", [False]), "pattern_engulfing": _parse_csv_bools(f"{p}PATTERN_ENGULFING", [True]), "pattern_piercing": _parse_csv_bools(f"{p}PATTERN_PIERCING", [True]), "max_spread_pct": _parse_csv_floats(f"{p}MAX_SPREAD_PCT", [0.30, 0.45]), "min_bid_ask_ratio": _parse_csv_floats( f"{p}MIN_BID_ASK_RATIO", [0.85, 1.0], ), "max_loss_krw": _parse_csv_floats( f"{p}MAX_LOSS_KRW", [150000.0, 200000.0], ), } if m == "coarse": # [COARSE] wide1 Top 밴드 살짝 넓힘 (1차 스크리닝) return { "entry_mode": _parse_csv_strings(f"{p}ENTRY_MODE", ["align", "limit_atr"]), "cand_limit": _parse_csv_ints( f"{p}CAND_LIMIT", sorted(set([0, 10, 20, get_env_int("SHORT_CAND_LIMIT", 0)])), ), "max_daily_change": _parse_csv_floats( f"{p}MAX_DAILY_CHG", [25.0, 30.0, 35.0, 40.0], ), "min_drop_rate": _parse_csv_floats( f"{p}MIN_DROP_RATE", [0.015, 0.025, 0.03, 0.04, 0.06], ), "min_recovery_ratio": _parse_csv_floats( f"{p}MIN_RECOVERY_RATIO", [0.10, 0.15, 0.20, 0.25, 0.30], ), "tail_ratio_min": _parse_csv_floats( f"{p}TAIL_RATIO_MIN", [0.6, 0.8, 1.0, 1.5], ), "tail_pct_min": _parse_csv_floats( f"{p}TAIL_PCT_MIN", [0.001, 0.002, 0.005], ), "max_rec_3m": _parse_csv_floats( f"{p}MAX_REC_3M", [0.80, 0.85, 0.90, 0.95], ), "shoulder_min_high": _parse_csv_floats( f"{p}SHOULDER_MIN_HIGH", [0.002, 0.003, 0.005, 0.007], ), "shoulder_cut_pct": _parse_csv_floats( f"{p}SHOULDER_CUT_PCT", [0.001, 0.0015, 0.002, 0.003], ), "stop_atr_mult": _parse_csv_floats( f"{p}STOP_ATR_MULT", [1.0, 1.5, 2.0, 2.5], ), "target_atr_mult": _parse_csv_floats( f"{p}TARGET_ATR_MULT", [1.0, 1.5, 2.0, 2.5], ), "atr_sl_min_pct": _parse_csv_floats(f"{p}ATR_SL_MIN_PCT", [0.5, 0.8]), "atr_sl_max_pct": _parse_csv_floats( f"{p}ATR_SL_MAX_PCT", [1.5, 2.0, 3.0, 6.0], ), "atr_tp_min_pct": _parse_csv_floats(f"{p}ATR_TP_MIN_PCT", [0.3, 0.5]), "atr_tp_max_pct": _parse_csv_floats( f"{p}ATR_TP_MAX_PCT", [2.0, 2.5, 3.0, 5.0], ), "tail_vol_mult": _parse_csv_floats( f"{p}VOL_MULT", [0.0, 1.5, 2.0, 3.0], ), "tail_vol_win": _parse_csv_floats(f"{p}VOL_WIN", [3.0, 5.0]), "limit_atr_mult": _parse_csv_floats( f"{p}LIMIT_ATR_MULT", [1.0, 1.5, 2.0], ), "ratchet_tiers": _parse_csv_strings( f"{p}RATCHET_TIERS", [ "off", "0.5:0.3,1.0:0.25,2.0:0.2", "1.0:0.4,2.0:0.3", "0.3:0.4,0.8:0.3,1.5:0.2", ], ), "symbol_daily_loss_limit_pct": _parse_csv_floats( f"{p}SYMBOL_LOSS_PCT", [0.0, 1.5, 2.0], ), "symbol_daily_loss_limit_krw": _parse_csv_floats( f"{p}SYMBOL_LOSS_KRW", [0.0, 30000.0, 50000.0], ), "reentry_min_edge_krw": _parse_csv_floats( f"{p}REENTRY_MIN_EDGE", [0.0, 500.0], ), "reentry_require_nonneg": _parse_csv_bools( f"{p}REENTRY_REQUIRE_NONNEG", [False, True], ), "max_daily": _parse_csv_ints(f"{p}MAX_DAILY", [10, 20, 50]), "cooldown_min": _parse_csv_floats( f"{p}COOLDOWN_MIN", [1.0, 3.0, 5.0, 10.0], ), "bar_chg_min_pct": _parse_csv_floats( f"{p}BAR_CHG_MIN", [-10.0, -7.0, -5.0], ), "bar_chg_max_pct": _parse_csv_floats( f"{p}BAR_CHG_MAX", [-0.5, -1.0, -1.5], ), "rsi_threshold": _parse_csv_floats( f"{p}RSI_THRESHOLD", [75.0, 78.0, 85.0, 90.0], ), "pattern_pin": _parse_csv_bools(f"{p}PATTERN_PIN", [False, True]), "pattern_engulfing": _parse_csv_bools(f"{p}PATTERN_ENGULFING", [True]), "pattern_piercing": _parse_csv_bools(f"{p}PATTERN_PIERCING", [True]), "max_spread_pct": _parse_csv_floats(f"{p}MAX_SPREAD_PCT", [0.30, 0.45]), "min_bid_ask_ratio": _parse_csv_floats( f"{p}MIN_BID_ASK_RATIO", [0.85, 1.0], ), "max_loss_krw": _parse_csv_floats( f"{p}MAX_LOSS_KRW", [100000.0, 150000.0, 200000.0], ), } if m == "fine": # [FINE] 2026-07-15 wide1 Top 분지 재설계 (+16.6k plateau) # Top: rec0.1 · ratchet 1.0:0.4,2.0:0.3 · atr_tp 2.5~5 · drop0.03/0.015 # · stop1.0~1.5 · target1.0~2.5 · atr_sl_max2 · trail off · align · cand0 # 실매 포함: rec0.25 · atr_tp2 · atr_sl_max6 · 실매래칫 · sh0.005·0.002 # · rsi85 · bar=-10~-0.5 · cd5 · daily20 · max_loss20만 # wide2 느슨한 래칫·강한 trail 은 제외 (악화 원인) return { "entry_mode": _parse_csv_strings(f"{p}ENTRY_MODE", ["align"]), "cand_limit": _parse_csv_ints( f"{p}CAND_LIMIT", sorted(set([0, 20, get_env_int("SHORT_CAND_LIMIT", 0)])), ), "max_daily_change": _parse_csv_floats( f"{p}MAX_DAILY_CHG", [25.0, 30.0, 35.0, 40.0], ), "min_drop_rate": _parse_csv_floats( f"{p}MIN_DROP_RATE", [0.015, 0.025, 0.03, 0.04, 0.06], ), "min_recovery_ratio": _parse_csv_floats( f"{p}MIN_RECOVERY_RATIO", [0.10, 0.15, 0.20, 0.25, 0.30], ), "tail_ratio_min": _parse_csv_floats( f"{p}TAIL_RATIO_MIN", [0.6, 0.8, 1.0, 1.2], ), "tail_pct_min": _parse_csv_floats( f"{p}TAIL_PCT_MIN", [0.001, 0.002, 0.005], ), "max_rec_3m": _parse_csv_floats( f"{p}MAX_REC_3M", [0.80, 0.85, 0.90, 0.95], ), "shoulder_min_high": _parse_csv_floats( f"{p}SHOULDER_MIN_HIGH", [0.003, 0.005, 0.007, 0.01], ), "shoulder_cut_pct": _parse_csv_floats( f"{p}SHOULDER_CUT_PCT", [0.001, 0.0015, 0.002, 0.003], ), "stop_atr_mult": _parse_csv_floats( f"{p}STOP_ATR_MULT", [1.0, 1.5, 2.0, 2.5], ), "target_atr_mult": _parse_csv_floats( f"{p}TARGET_ATR_MULT", [1.0, 1.5, 2.0, 2.5, 3.0], ), "atr_sl_min_pct": _parse_csv_floats( f"{p}ATR_SL_MIN_PCT", [0.5, 0.8], ), "atr_sl_max_pct": _parse_csv_floats( f"{p}ATR_SL_MAX_PCT", [1.5, 2.0, 3.0, 6.0], ), "atr_tp_min_pct": _parse_csv_floats( f"{p}ATR_TP_MIN_PCT", [0.3, 0.5], ), "atr_tp_max_pct": _parse_csv_floats( f"{p}ATR_TP_MAX_PCT", [2.0, 2.5, 3.0, 4.0, 5.0], ), "tail_vol_mult": _parse_csv_floats( f"{p}VOL_MULT", [0.0, 1.5, 2.0, 3.0], ), "tail_vol_win": _parse_csv_floats(f"{p}VOL_WIN", [3.0, 5.0]), "limit_atr_mult": _parse_csv_floats( f"{p}LIMIT_ATR_MULT", [1.0, 1.5, 2.0], ), "ratchet_tiers": _parse_csv_strings( f"{p}RATCHET_TIERS", [ "off", "0.5:0.3,1.0:0.25,2.0:0.2", "1.0:0.4,2.0:0.3", "0.3:0.4,0.8:0.3,1.5:0.2", ], ), "symbol_daily_loss_limit_pct": _parse_csv_floats( f"{p}SYMBOL_LOSS_PCT", [0.0, 1.5, 2.0], ), "symbol_daily_loss_limit_krw": _parse_csv_floats( f"{p}SYMBOL_LOSS_KRW", [0.0, 30000.0, 50000.0], ), "reentry_min_edge_krw": _parse_csv_floats( f"{p}REENTRY_MIN_EDGE", [0.0, 500.0], ), "reentry_require_nonneg": _parse_csv_bools( f"{p}REENTRY_REQUIRE_NONNEG", [False], ), "max_daily": _parse_csv_ints(f"{p}MAX_DAILY", [10, 20, 50]), "cooldown_min": _parse_csv_floats( f"{p}COOLDOWN_MIN", [1.0, 3.0, 5.0, 10.0], ), "bar_chg_min_pct": _parse_csv_floats( f"{p}BAR_CHG_MIN", [-10.0, -5.0], ), "bar_chg_max_pct": _parse_csv_floats( f"{p}BAR_CHG_MAX", [-0.5, -1.0], ), "rsi_threshold": _parse_csv_floats( f"{p}RSI_THRESHOLD", [75.0, 78.0, 85.0, 90.0], ), # --- 반전 패턴 ON/OFF (실매 DB와 정합: 망치는 BASE 고정 True, 아래만 스윕) --- # 빼고 싶으면 해당 줄 주석 처리. 값만 고정: [False] 또는 [True] # 스윕: [False, True] / env: TAIL_GRID_FINE_PATTERN_* "pattern_pin": _parse_csv_bools(f"{p}PATTERN_PIN", [False, True]), "pattern_engulfing": _parse_csv_bools(f"{p}PATTERN_ENGULFING", [True]), "pattern_piercing": _parse_csv_bools(f"{p}PATTERN_PIERCING", [True]), # 희귀 패턴 — 실매 기본 False. 과적합·노이즈 많으면 아래 3줄 통째 주석 or [False]만 "pattern_harami": _parse_csv_bools(f"{p}PATTERN_HARAMI", [False, True]), "pattern_doji": _parse_csv_bools(f"{p}PATTERN_DOJI", [False, True]), "pattern_morning_star": _parse_csv_bools( f"{p}PATTERN_MORNING_STAR", [False, True], ), "max_spread_pct": _parse_csv_floats(f"{p}MAX_SPREAD_PCT", [0.30, 0.45]), "min_bid_ask_ratio": _parse_csv_floats( f"{p}MIN_BID_ASK_RATIO", [0.85, 1.0], ), "max_loss_krw": _parse_csv_floats( f"{p}MAX_LOSS_KRW", [100000.0, 150000.0, 200000.0], ), } if m == "full": # fine 전축 + 더 촘촘·넓·다양 return { "entry_mode": _parse_csv_strings(f"{p}ENTRY_MODE", ["align", "limit_atr"]), # full+ 전용 — apply 시 DB 미반영(운영 false 고정) "skip_hts_scan_dupes": _parse_csv_bools( f"{p}SKIP_HTS", [False, True], ), "cand_limit": _parse_csv_ints( f"{p}CAND_LIMIT", [get_env_int("SHORT_CAND_LIMIT", 0)], ), "max_daily_change": _parse_csv_floats( f"{p}MAX_DAILY_CHG", [5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0], ), "min_drop_rate": _parse_csv_floats( f"{p}MIN_DROP_RATE", [0.01, 0.015, 0.02, 0.025, 0.03, 0.035, 0.04, 0.05, 0.06], ), "min_recovery_ratio": _parse_csv_floats( f"{p}MIN_RECOVERY_RATIO", [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50, 0.60, 0.70], ), "tail_ratio_min": _parse_csv_floats( f"{p}TAIL_RATIO_MIN", [0.5, 0.6, 0.8, 1.0, 1.2, 1.5, 1.8, 2.0, 2.5, 3.0], ), "tail_pct_min": _parse_csv_floats( f"{p}TAIL_PCT_MIN", [0.0005, 0.001, 0.002, 0.003, 0.005, 0.008], ), "max_rec_3m": _parse_csv_floats( f"{p}MAX_REC_3M", [0.55, 0.60, 0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95, 0.98], ), "shoulder_min_high": _parse_csv_floats( f"{p}SHOULDER_MIN_HIGH", [0.001, 0.0015, 0.002, 0.0025, 0.003, 0.004, 0.005, 0.007, 0.01], ), "shoulder_cut_pct": _parse_csv_floats( f"{p}SHOULDER_CUT_PCT", [0.001, 0.0015, 0.002, 0.0025, 0.003, 0.004, 0.005, 0.007], ), "stop_atr_mult": _parse_csv_floats( f"{p}STOP_ATR_MULT", [0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], ), "target_atr_mult": _parse_csv_floats( f"{p}TARGET_ATR_MULT", [0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0], ), "atr_sl_min_pct": _parse_csv_floats( f"{p}ATR_SL_MIN_PCT", [0.3, 0.5, 0.8, 1.0], ), "atr_sl_max_pct": _parse_csv_floats( f"{p}ATR_SL_MAX_PCT", [0.8, 1.0, 1.2, 1.5, 2.0, 3.0], ), "atr_tp_min_pct": _parse_csv_floats( f"{p}ATR_TP_MIN_PCT", [0.2, 0.3, 0.5, 0.8, 1.0], ), "atr_tp_max_pct": _parse_csv_floats( f"{p}ATR_TP_MAX_PCT", [0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0], ), "tail_vol_mult": _parse_csv_floats( f"{p}VOL_MULT", [0.0, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0], ), "tail_vol_win": _parse_csv_floats(f"{p}VOL_WIN", [3.0, 5.0, 8.0, 10.0]), "limit_atr_mult": _parse_csv_floats( f"{p}LIMIT_ATR_MULT", [0.5, 1.0, 1.5, 2.0, 2.5, 3.0], ), "ratchet_tiers": _parse_csv_strings( f"{p}RATCHET_TIERS", [ "off", "0.5:0.3,1.0:0.25,2.0:0.2", "1.0:0.4,2.0:0.3", "0.3:0.4,0.8:0.3,1.5:0.2", "0.8:0.35,1.5:0.25,3.0:0.15", ], ), "symbol_daily_loss_limit_pct": _parse_csv_floats( f"{p}SYMBOL_LOSS_PCT", [0.0, 0.5, 1.0, 1.5, 2.0, 3.0], ), "symbol_daily_loss_limit_krw": _parse_csv_floats( f"{p}SYMBOL_LOSS_KRW", [0.0, 10000.0, 15000.0, 30000.0, 50000.0, 80000.0], ), "reentry_min_edge_krw": _parse_csv_floats( f"{p}REENTRY_MIN_EDGE", [0.0, 300.0, 500.0, 1000.0, 2000.0, 3000.0], ), "reentry_require_nonneg": _parse_csv_bools( f"{p}REENTRY_REQUIRE_NONNEG", [False, True], ), "max_daily": _parse_csv_ints(f"{p}MAX_DAILY", [5, 10, 20, 30, 50]), "cooldown_min": _parse_csv_floats( f"{p}COOLDOWN_MIN", [0.0, 1.0, 3.0, 5.0, 10.0, 15.0, 30.0], ), "bar_chg_min_pct": _parse_csv_floats( f"{p}BAR_CHG_MIN", [-15.0, -12.0, -10.0, -7.0, -5.0, -3.0], ), "bar_chg_max_pct": _parse_csv_floats( f"{p}BAR_CHG_MAX", [-0.3, -0.5, -1.0, -1.5, -2.0, -3.0, -4.0], ), "rsi_threshold": _parse_csv_floats( f"{p}RSI_THRESHOLD", [70.0, 72.0, 75.0, 78.0, 82.0, 85.0, 90.0, 95.0], ), "pattern_pin": _parse_csv_bools(f"{p}PATTERN_PIN", [False, True]), "pattern_engulfing": _parse_csv_bools(f"{p}PATTERN_ENGULFING", [False, True]), "pattern_piercing": _parse_csv_bools(f"{p}PATTERN_PIERCING", [False, True]), "max_spread_pct": _parse_csv_floats( f"{p}MAX_SPREAD_PCT", [0.20, 0.30, 0.45, 0.60, 0.80], ), "min_bid_ask_ratio": _parse_csv_floats( f"{p}MIN_BID_ASK_RATIO", [0.50, 0.70, 0.85, 1.0, 1.2], ), "max_loss_krw": _parse_csv_floats( f"{p}MAX_LOSS_KRW", [30000.0, 50000.0, 100000.0, 150000.0, 200000.0, 300000.0, 500000.0], ), } if m == "wide": # 축 스크리닝 v2 — 1차 wide(+16k/5건 plateau, rec=0.1 고착)가 좁아서 확장 # Optuna ~100 trial · 7/15 · 실매 앵커 유지 · skip_hts 스윕 안 함 # 실매: drop0.03 / rec0.25 / atr_tp_max=2 / stop2 / target1.5 / # atr_sl 0.8~6.0 / ratchet=0.5:0.3,… / sh0.005·0.002 / rsi85 / # bar=-10~-0.5 / cd5 / daily20 / cand20 / entry=align / max_loss=20만 return { "entry_mode": _parse_csv_strings(f"{p}ENTRY_MODE", ["align", "limit_atr"]), "cand_limit": _parse_csv_ints( f"{p}CAND_LIMIT", sorted(set([0, 5, 10, 20, 30, get_env_int("SHORT_CAND_LIMIT", 0)])), ), "max_daily_change": _parse_csv_floats( f"{p}MAX_DAILY_CHG", [5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 50.0, 60.0], ), "min_drop_rate": _parse_csv_floats( f"{p}MIN_DROP_RATE", [0.005, 0.01, 0.015, 0.02, 0.025, 0.03, 0.04, 0.05, 0.06, 0.08], ), # Top20 전부 rec=0.1 이었음 → 0.05 추가 + 실매 0.25 유지 + 상단 확대 "min_recovery_ratio": _parse_csv_floats( f"{p}MIN_RECOVERY_RATIO", [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.40, 0.50, 0.60, 0.70], ), "tail_ratio_min": _parse_csv_floats( f"{p}TAIL_RATIO_MIN", [0.4, 0.5, 0.6, 0.8, 1.0, 1.2, 1.5, 2.0, 2.5, 3.0], ), "tail_pct_min": _parse_csv_floats( f"{p}TAIL_PCT_MIN", [0.0005, 0.001, 0.002, 0.003, 0.005, 0.008], ), "max_rec_3m": _parse_csv_floats( f"{p}MAX_REC_3M", [0.50, 0.60, 0.70, 0.80, 0.85, 0.90, 0.95, 0.98], ), "shoulder_min_high": _parse_csv_floats( f"{p}SHOULDER_MIN_HIGH", [0.001, 0.0015, 0.002, 0.003, 0.005, 0.007, 0.01, 0.015], ), "shoulder_cut_pct": _parse_csv_floats( f"{p}SHOULDER_CUT_PCT", [0.001, 0.0015, 0.002, 0.003, 0.004, 0.005, 0.007, 0.01], ), "stop_atr_mult": _parse_csv_floats( f"{p}STOP_ATR_MULT", [0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0], ), "target_atr_mult": _parse_csv_floats( f"{p}TARGET_ATR_MULT", [0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0], ), "atr_sl_min_pct": _parse_csv_floats( f"{p}ATR_SL_MIN_PCT", [0.3, 0.5, 0.8, 1.0, 1.2], ), # 실매 atr_sl_max=6.0 필수 "atr_sl_max_pct": _parse_csv_floats( f"{p}ATR_SL_MAX_PCT", [0.8, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0], ), "atr_tp_min_pct": _parse_csv_floats( f"{p}ATR_TP_MIN_PCT", [0.2, 0.3, 0.5, 0.8, 1.0], ), # 1차 Top이 atr_tp=2.5~5 고착 → 천장 더 열어 익절 여유 탐색 "atr_tp_max_pct": _parse_csv_floats( f"{p}ATR_TP_MAX_PCT", [0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0], ), "tail_vol_mult": _parse_csv_floats( f"{p}VOL_MULT", [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0], ), "tail_vol_win": _parse_csv_floats( f"{p}VOL_WIN", [3.0, 5.0, 8.0, 10.0, 15.0], ), "limit_atr_mult": _parse_csv_floats( f"{p}LIMIT_ATR_MULT", [0.3, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], ), # 느슨한 래칫(이익 더 끌고) + OFF + 실매 tier "ratchet_tiers": _parse_csv_strings( f"{p}RATCHET_TIERS", [ "off", "0.5:0.3,1.0:0.25,2.0:0.2", "1.0:0.4,2.0:0.3", "0.3:0.4,0.8:0.3,1.5:0.2", "0.8:0.35,1.5:0.25,3.0:0.15", "1.5:0.5,3.0:0.35,5.0:0.25", "2.0:0.5,4.0:0.35,6.0:0.2", ], ), "symbol_daily_loss_limit_pct": _parse_csv_floats( f"{p}SYMBOL_LOSS_PCT", [0.0, 0.5, 1.0, 1.5, 2.0, 3.0], ), "symbol_daily_loss_limit_krw": _parse_csv_floats( f"{p}SYMBOL_LOSS_KRW", [0.0, 10000.0, 15000.0, 30000.0, 50000.0, 80000.0], ), "reentry_min_edge_krw": _parse_csv_floats( f"{p}REENTRY_MIN_EDGE", [0.0, 300.0, 500.0, 1000.0, 2000.0, 3000.0], ), "reentry_require_nonneg": _parse_csv_bools( f"{p}REENTRY_REQUIRE_NONNEG", [False, True], ), "max_daily": _parse_csv_ints( f"{p}MAX_DAILY", [5, 10, 20, 30, 50, 80, 100], ), "cooldown_min": _parse_csv_floats( f"{p}COOLDOWN_MIN", [0.0, 1.0, 3.0, 5.0, 10.0, 15.0, 30.0], ), "bar_chg_min_pct": _parse_csv_floats( f"{p}BAR_CHG_MIN", [-20.0, -15.0, -12.0, -10.0, -7.0, -5.0, -3.0], ), "bar_chg_max_pct": _parse_csv_floats( f"{p}BAR_CHG_MAX", [-0.3, -0.5, -1.0, -1.5, -2.0, -3.0, -4.0], ), "rsi_threshold": _parse_csv_floats( f"{p}RSI_THRESHOLD", [70.0, 72.0, 75.0, 78.0, 82.0, 85.0, 90.0, 95.0], ), "pattern_pin": _parse_csv_bools(f"{p}PATTERN_PIN", [False, True]), "pattern_engulfing": _parse_csv_bools(f"{p}PATTERN_ENGULFING", [False, True]), "pattern_piercing": _parse_csv_bools(f"{p}PATTERN_PIERCING", [False, True]), "max_spread_pct": _parse_csv_floats( f"{p}MAX_SPREAD_PCT", [0.20, 0.30, 0.45, 0.60, 0.80], ), "min_bid_ask_ratio": _parse_csv_floats( f"{p}MIN_BID_ASK_RATIO", [0.50, 0.70, 0.85, 1.0, 1.2], ), "max_loss_krw": _parse_csv_floats( f"{p}MAX_LOSS_KRW", [30000.0, 50000.0, 100000.0, 150000.0, 200000.0, 300000.0, 500000.0], ), } if m == "massive": # full 전축 + 최대 폭 (탐색 공간 최대) return { "entry_mode": _parse_csv_strings(f"{p}ENTRY_MODE", ["align", "limit_atr"]), "skip_hts_scan_dupes": _parse_csv_bools( f"{p}SKIP_HTS", [False, True], ), "cand_limit": _parse_csv_ints( f"{p}CAND_LIMIT", sorted(set([0, 10, 20, get_env_int("SHORT_CAND_LIMIT", 0)])), ), "max_daily_change": _parse_csv_floats( f"{p}MAX_DAILY_CHG", [5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 50.0], ), "min_drop_rate": _parse_csv_floats( f"{p}MIN_DROP_RATE", [0.005, 0.01, 0.015, 0.02, 0.025, 0.03, 0.035, 0.04, 0.05, 0.06, 0.08], ), "min_recovery_ratio": _parse_csv_floats( f"{p}MIN_RECOVERY_RATIO", [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50, 0.60, 0.70, 0.80], ), "tail_ratio_min": _parse_csv_floats( f"{p}TAIL_RATIO_MIN", [0.4, 0.5, 0.6, 0.8, 1.0, 1.2, 1.5, 1.8, 2.0, 2.5, 3.0], ), "tail_pct_min": _parse_csv_floats( f"{p}TAIL_PCT_MIN", [0.0005, 0.001, 0.002, 0.003, 0.005, 0.008, 0.01], ), "max_rec_3m": _parse_csv_floats( f"{p}MAX_REC_3M", [0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95, 0.98], ), "shoulder_min_high": _parse_csv_floats( f"{p}SHOULDER_MIN_HIGH", [0.001, 0.0015, 0.002, 0.0025, 0.003, 0.004, 0.005, 0.007, 0.01, 0.015], ), "shoulder_cut_pct": _parse_csv_floats( f"{p}SHOULDER_CUT_PCT", [0.001, 0.0015, 0.002, 0.0025, 0.003, 0.004, 0.005, 0.007, 0.01], ), "stop_atr_mult": _parse_csv_floats( f"{p}STOP_ATR_MULT", [0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0], ), "target_atr_mult": _parse_csv_floats( f"{p}TARGET_ATR_MULT", [0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0], ), "atr_sl_min_pct": _parse_csv_floats( f"{p}ATR_SL_MIN_PCT", [0.3, 0.5, 0.8, 1.0, 1.2], ), "atr_sl_max_pct": _parse_csv_floats( f"{p}ATR_SL_MAX_PCT", [0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 4.0], ), "atr_tp_min_pct": _parse_csv_floats( f"{p}ATR_TP_MIN_PCT", [0.2, 0.3, 0.5, 0.8, 1.0, 1.5], ), "atr_tp_max_pct": _parse_csv_floats( f"{p}ATR_TP_MAX_PCT", [0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0], ), "tail_vol_mult": _parse_csv_floats( f"{p}VOL_MULT", [0.0, 0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0], ), "tail_vol_win": _parse_csv_floats(f"{p}VOL_WIN", [3.0, 5.0, 8.0, 10.0, 15.0]), "limit_atr_mult": _parse_csv_floats( f"{p}LIMIT_ATR_MULT", [0.3, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], ), "ratchet_tiers": _parse_csv_strings( f"{p}RATCHET_TIERS", [ "off", "0.5:0.3,1.0:0.25,2.0:0.2", "1.0:0.4,2.0:0.3", "0.3:0.4,0.8:0.3,1.5:0.2", "0.8:0.35,1.5:0.25,3.0:0.15", ], ), "symbol_daily_loss_limit_pct": _parse_csv_floats( f"{p}SYMBOL_LOSS_PCT", [0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 5.0], ), "symbol_daily_loss_limit_krw": _parse_csv_floats( f"{p}SYMBOL_LOSS_KRW", [0.0, 10000.0, 15000.0, 30000.0, 50000.0, 80000.0, 100000.0], ), "reentry_min_edge_krw": _parse_csv_floats( f"{p}REENTRY_MIN_EDGE", [0.0, 300.0, 500.0, 1000.0, 2000.0, 3000.0, 5000.0], ), "reentry_require_nonneg": _parse_csv_bools( f"{p}REENTRY_REQUIRE_NONNEG", [False, True], ), "max_daily": _parse_csv_ints(f"{p}MAX_DAILY", [5, 10, 20, 30, 50, 100]), "cooldown_min": _parse_csv_floats( f"{p}COOLDOWN_MIN", [0.0, 1.0, 3.0, 5.0, 10.0, 15.0, 30.0, 60.0], ), "bar_chg_min_pct": _parse_csv_floats( f"{p}BAR_CHG_MIN", [-20.0, -15.0, -12.0, -10.0, -7.0, -5.0, -3.0], ), "bar_chg_max_pct": _parse_csv_floats( f"{p}BAR_CHG_MAX", [-0.3, -0.5, -1.0, -1.5, -2.0, -3.0, -4.0, -5.0], ), "rsi_threshold": _parse_csv_floats( f"{p}RSI_THRESHOLD", [65.0, 70.0, 72.0, 75.0, 78.0, 82.0, 85.0, 90.0, 95.0], ), "pattern_pin": _parse_csv_bools(f"{p}PATTERN_PIN", [False, True]), "pattern_engulfing": _parse_csv_bools(f"{p}PATTERN_ENGULFING", [False, True]), "pattern_piercing": _parse_csv_bools(f"{p}PATTERN_PIERCING", [False, True]), "max_spread_pct": _parse_csv_floats( f"{p}MAX_SPREAD_PCT", [0.15, 0.20, 0.30, 0.45, 0.60, 0.80, 1.0], ), "min_bid_ask_ratio": _parse_csv_floats( f"{p}MIN_BID_ASK_RATIO", [0.40, 0.50, 0.70, 0.85, 1.0, 1.2, 1.5], ), "max_loss_krw": _parse_csv_floats( f"{p}MAX_LOSS_KRW", [30000.0, 50000.0, 100000.0, 150000.0, 200000.0, 300000.0, 500000.0], ), "ma20_max_above": _parse_csv_floats(f"{p}MA20_MAX_ABOVE", [2.0, 3.0, 5.0, 8.0]), } # 알 수 없는 mode → fast return _tail_grids("fast") # ───────────────────────────────────────────────────────────────────────────── # 레거시 PARAM_GRIDS (미사용 — coarse/fine/wide/full/massive 는 _tail_grids 전용) # ───────────────────────────────────────────────────────────────────────────── PARAM_GRIDS: Dict[str, Dict[str, List[Any]]] = { } TAIL_GRID_AXIS_HINTS_KO: Dict[str, str] = { "min_drop_rate": "당일 낙폭(시가→저점) 하한", "min_recovery_ratio": "저점 대비 회복률 하한 (낮을수록 매수 많음)", "tail_ratio_min": "망치 꼬리/몸통 비율 하한", "tail_pct_min": "꼬리 길이 최소 비율", "max_rec_3m": "3분봉 회복 위치 상한 (높을수록 매수 많음)", "stop_atr_mult": "ATR 손절 배수 (실제 손절=배수×ATR 후 %캡)", "target_atr_mult": "ATR 익절 배수 (실제 익절=배수×ATR 후 %캡)", "atr_sl_min_pct": "ATR 손절 % 하한", "atr_sl_max_pct": "ATR 손절 % 상한", "atr_tp_min_pct": "ATR 익절 % 하한", "atr_tp_max_pct": "ATR 익절 % 상한 (잡주 과대 목표 방지) — fast 그리드", "tail_vol_mult": "TRIGGER 거래량 — 신호봉 vol ≥ N봉평균×배수 (0=OFF)", "tail_vol_win": "거래량 평균 창(봉 수) — tail_vol_mult>0 일 때", "shoulder_min_high": "어깨 발동: 진입가 대비 최소 수익(0.003=0.3%) 후 max_price 추적", "shoulder_cut_pct": "어깨 폭: 갱신된 max_price 대비 되돌림(0.002=0.2%, 매도선=max×(1-cut))", "ratchet_tiers": "청산방식: off=단일 어깨컷 / '수익%:컷%,…'=다단 래칫 트레일", "max_daily": "종목당 일일 매수 안전판(무한루프 방지, 기본 20)", "symbol_daily_loss_limit_pct": "종목당 당일 실현손익 손실한도(%·양수, slot 대비) — 재진입 차단", "symbol_daily_loss_limit_krw": "종목당 당일 실현손익 손실한도(원·양수) — KRW·% 중 먼저 닿는 쪽", "reentry_min_edge_krw": "재진입 최소 당일 누적순익(원) — 2회째부터, 수수료 감안 엣지", "reentry_require_nonneg": "재진입 시 당일 누적순익≥0 필수 — OFF=엣지(원)만 검사", "cooldown_min": "청산 후 동일종목 재진입 콜다운(분) — 연타 방지", "bar_chg_min_pct": "3분 신호봉 직전대비 등락 하한(%) — 과대 급락 제외", "bar_chg_max_pct": "3분 신호봉 직전대비 등락 상한(%) — 이보다 덜 빠지면 탈락(예:-0.5=최소0.5%하락)", "rsi_threshold": "RSI 과열 진입 거부 기준(%) — 낮을수록 매수 적음", "pattern_pin": "핀바(긴 아래꼬리 단일봉) 진입 패턴 ON/OFF", "pattern_engulfing": "장악형(2봉 반전) 진입 패턴 ON/OFF", "pattern_piercing": "관통형(2봉 반전) 진입 패턴 ON/OFF", "pattern_harami": "하라미(안쪽형 2봉 반전) 진입 패턴 ON/OFF — fine 스윕", "pattern_doji": "도지(십자형) 진입 패턴 ON/OFF — fine 스윕", "pattern_morning_star": "샛별(모닝스타 3봉 반전) 진입 패턴 ON/OFF — fine 스윕", "max_loss_krw": "1회 최대 금액 손실(원, 트레일 미발동 시)", "limit_atr_mult": "ATR 지정가 깊이(anchor−ATR×배수) — limit_atr 전용, 클수록 체결↓", "entry_mode": "진입: align=다음봉 시가 / limit_atr=지정가(틱 체결 재생)", "skip_hts_scan_dupes": "TRIGGER 3분 직전대비 등락(BAR_CHG) 재검사 생략 — HTS 일봉A/1분G와 축 다름", "cand_limit": "실매 SHORT_CAND_LIMIT 동일 적용 — 그 시각 유니버스 상위 N개만 매수체크 (0=무제한)", "max_daily_change": "급등주 필터(당일 등락률 상한, %)", "trail_pct": "보조 트레일 폭(고점 대비, 0=OFF). 래칫 ON이면 1단계보다 arm이 커야 효과", "trail_arm_pct": "보조 트레일 무장: 진입×(1+arm) 도달 후 (0=즉시)", "whipsaw_enabled": "휩쏘 필터 ON/OFF — 실매 TAIL_WHIPSAW_FILTER_ENABLED (현재 false 포함)", "whipsaw_subbar_sec": "휩쏘 미니봉(초) — 기본 30 · TPE [15,30,45,60]", "whipsaw_lookback_sec": "휩쏘 룩백(초) — 기본 90 · TPE [60,90,120,180]", "whipsaw_dip_pct": "휩쏘 지지이탈 하락폭(분율) — 기본 0.003", "whipsaw_recovery_tol_pct": "휩쏘 회복 허용(분율) — 기본 0.001", "max_spread_pct": "호가 스프레드 상한(%) — kiwoom_0d 본체 재계산(6/25~)", "min_bid_ask_ratio": "총매수/총매도 잔량비 하한(꼬리지지) — kiwoom_0d 본체 재계산(6/25~)", } def evaluate_tail_param_combo( combo: Dict[str, Any], *, base_params: Dict[str, Any], candles_by_code: Dict[str, List[Dict]], fee_rate: float, sell_tax: float, 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, 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, log_verdict_by_code: Any = None, ) -> Optional[Dict[str, Any]]: """ 단일 파라미터 조합 백테 — Grid 워커·Optuna objective 공통. min_trades·승률·PF 게이트 미통과 시 None. """ test_params = dict(base_params) test_params.update(combo) if "max_spread_pct" in combo and combo.get("max_spread_pct") is not None: test_params["_ob_max_spread_pct"] = float(combo["max_spread_pct"]) if "min_bid_ask_ratio" in combo and combo.get("min_bid_ask_ratio") is not None: test_params["_ob_min_bid_ask_ratio"] = float(combo["min_bid_ask_ratio"]) # 당일손익 다단트레일 — 그리드/Optuna 탐색 제외. 시뮬은 DB/env(SHORT_DAILY_PROFIT_*) 고정 추종. # (과거 combo 키 trail_tiers/drop/arm 이 남아 있어도 무시) for _k in ("trail_tiers", "trail_drop_pct", "trail_arm_krw", "daily_trail_tiers", "daily_trail_drop_pct", "daily_trail_arm_krw"): test_params.pop(_k, None) if cache_holder: test_params.update(cache_holder) if log_verdict_by_code: test_params["_backtest_log_verdict_by_code"] = log_verdict_by_code meta: Dict[str, Any] = {} trades = tbc.run_tail_backtest_web_aligned( candles_by_code, test_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, ticks_by_code=ticks_by_code, orderbook_by_code=orderbook_by_code, program_by_code=program_by_code, meta_out=meta, ) stats = tbc.summarize_tail_trades( trades, total_budget_krw=total_budget_krw, period_days=period_days, ) total_trades = stats["total_trades"] if total_trades < min_trades: return None win_rate = stats["win_rate"] total_pnl = stats["total_pnl"] 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 return attach_daily_stability({ "params": combo, "apply_cfg": {**base_params, **combo}, "total_trades": total_trades, "win_rate": win_rate, "total_pnl": total_pnl, "wins": stats["wins"], "losses": stats["losses"], "pf": stats["pf"], "bot_pct": stats["bot_pct"], "daily_avg_pct": stats["daily_avg_pct"], "avg_hold_min": stats["avg_hold_min"], "sell_reasons": _count_tail_sell_reasons(trades), "skipped_micro_buys": int( (meta.get("skip_stats") or {}).get("skipped_micro_buys") or 0 ), }, trades) def evaluate_param_chunk( param_chunk: List[Dict[str, Any]], base_params: Dict[str, Any], candles_by_code: Optional[Dict[str, List[Dict]]], fee_rate: float, sell_tax: float, min_trades: int, min_win_rate: float, min_pf: float, top_n: int, 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, period_days: int = 1, ) -> List[Tuple[float, Dict]]: """ 워커 프로세스에서 실행될 백테스트 평가 함수. universe_by_slot이 있으면 유니버스 히스토리(5분마다 후보)만 매수 검사. 시각순 포트폴리오·총한도·소액매수 스킵은 tail_backtest_common 과 웹과 동일. """ shared = worker_shared_get() ticks_preloaded = None orderbook_preloaded = None program_preloaded = None log_verdict_preloaded = None if shared: if candles_by_code is None: candles_by_code = shared.get("candles_by_code") or {} if universe_by_slot is None: universe_by_slot = shared.get("universe_by_slot") ticks_preloaded = shared.get("ticks_by_code") # 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 orderbook_preloaded = shared.get("orderbook_by_code") program_preloaded = shared.get("program_by_code") log_verdict_preloaded = shared.get("log_verdict_by_code") if candles_by_code is None: candles_by_code = {} cache_holder: Dict[str, Any] = {} attach_indicator_caches_to_params(cache_holder, candles_by_code) local_heap = [] for combo in param_chunk: assert_parent_alive() result_pkg = evaluate_tail_param_combo( combo, base_params=base_params, candles_by_code=candles_by_code, fee_rate=fee_rate, sell_tax=sell_tax, min_trades=min_trades, min_win_rate=min_win_rate, min_pf=min_pf, universe_by_slot=universe_by_slot, slot_money=slot_money, max_stocks=max_stocks, total_budget_krw=total_budget_krw, period_days=period_days, cache_holder=cache_holder, ticks_by_code=ticks_preloaded, orderbook_by_code=orderbook_preloaded, program_by_code=program_preloaded, log_verdict_by_code=log_verdict_preloaded, ) if result_pkg is None: continue win_rate = result_pkg["win_rate"] total_pnl = result_pkg["total_pnl"] if len(local_heap) < top_n: heapq.heappush(local_heap, (win_rate, total_pnl, id(result_pkg), result_pkg)) else: heapq.heappushpop(local_heap, (win_rate, total_pnl, id(result_pkg), result_pkg)) return local_heap def run_search( start: str, end: str, mode: str, top_n: int, min_trades: int, min_win_rate: float, min_pf: float, sort_by: str = "pnl", use_fallback_universe: bool = False, max_combos: int = 0, timeframe: int = 3, time_start_hm: Optional[int] = None, time_end_hm: Optional[int] = None, slot_money: Optional[float] = None, max_stocks: Optional[int] = None, total_budget_krw: Optional[float] = None, orderbook_filter: str = "off", ) -> bool: """탐색 실행. 결과가 있어서 JSON 저장까지 했으면 True, 조건 만족 조합 없이 조기 return 시 False.""" db = TradeDB() try: # 1. Base Environment Parameters 로드 (backtest_web _get_tail_defaults_for_backtest 와 동일) base_params = te.get_tail_defaults_from_db(db) if time_start_hm is not None: base_params["time_start_hm"] = int(time_start_hm) if time_end_hm is not None: base_params["time_end_hm"] = int(time_end_hm) # ── 호가필터 ON/OFF (2단계 워크플로) ─────────────────────────── # off(기본): 1단계 — 호가 게이트 없이 코어 파라미터만 순수 탐색. # on : 2단계 — kiwoom_0d 본체로 스프레드·꼬리지지(총잔량비) 실제 적용. # auto : env/DB 의 *_ORDERBOOK_FILTER_ENABLED 값을 그대로 따름. _ob_mode = (orderbook_filter or "off").strip().lower() if _ob_mode == "off": base_params["_orderbook_filter_enabled"] = False elif _ob_mode == "on": base_params["_orderbook_filter_enabled"] = True _ob_filter_on = bool(base_params.get("_orderbook_filter_enabled")) or _ob_mode == "auto" logger.info( "📌 호가필터: %s (%s)", _ob_mode.upper(), "적용" if _ob_filter_on else "스킵 — 코어 파라미터 순수 탐색", ) from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row r = load_portfolio_env_row(db) fee_rate, sell_tax, _slot_from_fee = tbc.fee_and_slot_from_env_row(r) portfolio = tbc.resolve_tail_portfolio_params( r, base_params, slot_money=slot_money if slot_money is not None else _slot_from_fee, max_stocks=max_stocks, total_budget_krw=total_budget_krw, ) slot_money = float(portfolio["slot_money"]) max_stocks = int(portfolio["max_stocks"]) total_budget_krw = float(portfolio["total_budget_krw"]) tbc.merge_tail_portfolio_into_params(base_params, portfolio) base_params["capital"] = float( r.get("BACKTEST_CAPITAL") or base_params.get("capital") or 100_000_000.0 ) period_days = max( 1, (datetime.strptime(end, "%Y-%m-%d") - datetime.strptime(start, "%Y-%m-%d")).days + 1, ) tail_tf = int(timeframe) if tail_tf not in tbc.VALID_TIMEFRAMES: logger.error("❌ timeframe 은 3·5·15·60 중 하나여야 합니다 (backtest_web 과 동일)") return False start_key, end_key, start_ymd, end_ymd = tbc.date_keys(start, end) use_saved_history = not use_fallback_universe universe_by_slot, universe_source, universe_history_slots, scan_interval_min = ( tbc.resolve_tail_universe(start_ymd, end_ymd, use_saved_history=use_saved_history) ) if use_fallback_universe: print("📌 [유니버스] --fallback-universe: 저장 이력 무시 → ws_candles 전 종목") elif universe_source == "history": avg = ( sum(len(v) for v in universe_by_slot.values()) / max(1, universe_history_slots) if universe_by_slot else 0 ) print( f"✅ 유니버스: SHORT 저장 이력 (웹백테 동일) | " f"{universe_history_slots:,}슬롯 · 평균 {avg:.1f}종목" ) else: print("📌 [유니버스] 저장 이력 없음 → ws_candles 전 종목 (웹 폴백과 동일)") base_params = dict(base_params) base_params["scan_interval_min"] = scan_interval_min base_params["timeframe"] = tail_tf from kis_trader.engine.tail_tick_replay import ( tail_backtest_use_tick_db as _tail_use_tick, tail_backtest_use_tick_exit as _tail_use_tick_exit, ) # ── 틱재생(ws_ticks) — 기본 ON (실매 체결 정합) ────────────────────── # 절대규칙: 파람서치는 OHLC 폴백으로 숫자 변조 금지 base_params.setdefault("backtest_use_tick_db", _tail_use_tick(None)) base_params.setdefault("backtest_use_tick_exit", _tail_use_tick_exit(None)) base_params["backtest_tick_fallback_ohlc"] = False if base_params.get("backtest_use_tick_db") or base_params.get("backtest_use_tick_exit"): logger.info("📌 틱재생(ws_ticks): ON — OHLC 폴백 강제 OFF (정합 절대규칙)") logger.info( f"📅 데이터 로드: {start} ~ {end} | TF={tail_tf} | " f"유니버스={universe_source} | 매수시간 " f"{base_params.get('time_start_hm', 930):04d}-{base_params.get('time_end_hm', 1500):04d}" ) rsi_period = int(base_params.get("rsi_period", 14)) candles_by_code, total_candles, has_holding_peak = tbc.load_tail_candles_by_code( db, start_key, end_key, tail_tf, rsi_period=rsi_period, ) if not candles_by_code: logger.info("❌ 백테스트할 데이터가 없습니다.") return False ticks_by_code: Dict[str, Dict[str, List[Dict]]] = {} tick_backtest_meta: Dict[str, Any] = {} from kis_trader.engine.tail_tick_replay import tail_backtest_wants_tick_replay from kis_trader.backtest.tail_tick_loader import load_tail_ticks_by_code, tick_coverage_stats _tick_probe = dict(base_params) _tick_probe["entry_mode"] = "align" if tail_backtest_wants_tick_replay(_tick_probe): _tick_db = TradeDB() try: ticks_by_code, tick_rows = load_tail_ticks_by_code( _tick_db, start_key, end_key, set(candles_by_code.keys()), ) finally: _tick_db.close() else: tick_rows = 0 if tick_rows > 0: tick_backtest_meta = tick_coverage_stats(candles_by_code, ticks_by_code) tick_backtest_meta["ws_tick_rows_loaded"] = tick_rows cov = tick_backtest_meta.get("tick_bar_coverage_pct", 0) logger.info( "✅ ws_ticks %s건 | 3분봉 커버리지 %s%% (%s/%s종목)", f"{tick_rows:,}", cov, tick_backtest_meta.get("tick_codes_with_data", 0), tick_backtest_meta.get("tick_codes_total", 0), ) elif tail_backtest_wants_tick_replay(_tick_probe): logger.warning("⚠️ ws_ticks 없음 — OHLC 폴백 (WS_TICK_SAVE_ENABLED 후 재탐색)") _pre_grid = _tail_grids(mode) # kiwoom_0d 본체 재계산 — 필터 ON + 호가필터 축 **실제 스윕**(값 2개↑) 일 때만. _ob_axes = ("max_spread_pct", "min_bid_ask_ratio") _ob_sweeping = any(len(set(_pre_grid.get(k) or [])) > 1 for k in _ob_axes) if _ob_filter_on and _ob_sweeping: base_params["backtest_use_kiwoom_body_snapshot"] = True base_params["_backtest_use_kiwoom_body"] = True _ob_axis_vals = {k: _pre_grid.get(k) for k in _ob_axes if len(set(_pre_grid.get(k) or [])) > 1} logger.info( "📌 호가필터 스윕 활성 → kiwoom_0d 본체 재계산 " f"(축 {_ob_axis_vals}, 본체 없는 날은 log_backfill 폴백)" ) elif _ob_filter_on: logger.info("📌 호가필터 ON · 스윕 없음 → 본체 재계산 생략(판정 재생 경로)") orderbook_by_code: Dict[str, Dict[str, List[Dict]]] = {} program_by_code: Dict[str, Dict[str, List[Dict]]] = {} log_verdict_by_code: Dict[str, Dict[str, List[Dict]]] = {} trigger_snap_meta: Dict[str, Any] = {} try: from kis_trader.backtest.trigger_snapshot_loader import load_trigger_snapshots_by_code orderbook_by_code, program_by_code, trigger_snap_meta = load_trigger_snapshots_by_code( db, start_key, end_key, set(candles_by_code.keys()), engine_params=base_params, strategy="TAIL", ) log_verdict_by_code = trigger_snap_meta.get("log_verdict_by_code") or {} ob_rows = int(trigger_snap_meta.get("ws_orderbook_rows_loaded") or 0) pg_rows = int(trigger_snap_meta.get("ws_program_rows_loaded") or 0) lv_rows = int(trigger_snap_meta.get("log_verdict_rows") or 0) logger.info( "✅ TRIGGER 스냅샷 ws_orderbook %s건 | ws_program %s건 | log_backfill 판정 %s건", f"{ob_rows:,}", f"{pg_rows:,}", f"{lv_rows:,}", ) if ob_rows <= 0 and pg_rows <= 0: logger.warning( "⚠️ TRIGGER 스냅샷 없음 — 호가·프로그램 필터 스킵 (실매 수집 후 재탐색)" ) except Exception as _snap_ex: logger.debug("trigger snapshot 로드 스킵: %s", _snap_ex) logger.info( f"📦 종목: {len(candles_by_code)}개 (ws_candles 전체) | " f"캔들: {total_candles:,}개 | portfolio_mode=ON" ) logger.info( f"💼 포트폴리오: 1회 {slot_money:,.0f}원 | 동시 {max_stocks}종 | " f"총한도 {total_budget_krw:,.0f}원 (웹백테 동일)" ) if portfolio.get("budget_warning"): logger.warning(f"💰 {portfolio['budget_warning']}") # 4. Grid 조합 생성 grid = _tail_grids(mode) if not grid: logger.error(f"❌ 알 수 없는 모드: {mode}") return False if "entry_mode" not in grid: _search_entry = get_env_from_db("TAIL_PARAM_SEARCH_ENTRY_MODE", "") if _search_entry not in (None, "", "None"): base_params["entry_mode"] = str(_search_entry).strip().lower() if mode == "fast": print( f"📌 [fast] TAIL_GRID_FAST_* · 22축(거래량·atr_tp_max·청산방식·RSI·반전패턴·" f"과매매제어·봉등락) · →{FAST_MAX_COMBOS}샘플 · " f"ws_ticks={'ON' if tick_rows > 0 else 'OFF'}" ) if mode == "coarse": print( "📌 [coarse] TAIL_GRID_COARSE_* env · max_daily_chg 포함 (기본 ~4천조합, cap 5000)" ) if "entry_mode" in grid: print(f"📌 진입모드 그리드: {grid['entry_mode']} (조합별 align/limit_atr + 틱 재생)") else: print( f"📌 진입모드: {base_params.get('entry_mode', 'align')} " f"(TAIL_PARAM_SEARCH_ENTRY_MODE)" ) keys = list(grid.keys()) axes = list(grid.values()) # 데카르트곱 전체를 RAM 에 펼치지 않는다 (호가 스윕 시 수천만 → OOM 방지). dict_combos, total_grid, max_combos_cap, dropped_by_cap = cap_combos_uniform_lazy( keys, axes, mode, strategy_env_prefix="TAIL", default_fast=FAST_MAX_COMBOS, default_other=DEFAULT_MAX_COMBOS, max_combos_override=max_combos, ) sampled = len(dict_combos) logger.info(f"🔍 탐색 모드: {mode.upper()} | 그리드 조합: {total_grid:,} → 백테: {sampled:,}") if mode == "fast": logger.info(f"📌 [fast] {total_grid:,}→{max_combos_cap}균등샘플") if dropped_by_cap: logger.info(f" (max-combos={max_combos_cap} 균등 샘플, 제외 {dropped_by_cap:,}개)") for k in keys: hint = TAIL_GRID_AXIS_HINTS_KO.get(k) if hint: logger.info(f" [{k}] {hint}") logger.info(f"📌 1위 정렬 기준: {'총손익 최대 (수익 나는 조합 우선)' if sort_by == 'pnl' else '승률 최대'}") # ── ws_ticks 공유메모리 — 워커별 사본 대신 1벌 공유 (momentum 과 동일) ──────── # 기본 ON(검증 완료). ON 이면 컬럼(numpy)로 shared_memory 에 1벌 올리고 워커는 # read-only attach → 메모리 N배 제거 + 워커 상한 해제. 엔진 핫루프는 TickColumnView # 로 배열 직접 읽어 dict 경로와 bit-identical. 끄려면 TAIL_PARAM_SEARCH_SHARED_TICKS=0. # numpy/shm 미지원·빌드 실패 시 자동 폴백. shared_tick_store = None if get_env_bool("TAIL_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) logger.info("📦 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: logger.info("⚠️ ws_ticks 공유메모리 build 실패 — 기존 경로 폴백") else: logger.info("⚠️ numpy/shared_memory 미지원 — 기존 경로 폴백") # 5. 멀티프로세싱을 위한 청크 분할 (메모리 및 부하 분산) shared = ParamSearchSharedPayload({ "candles_by_code": candles_by_code, "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), "orderbook_by_code": orderbook_by_code, "program_by_code": program_by_code, "log_verdict_by_code": log_verdict_by_code, "trigger_snapshot_meta": trigger_snap_meta, }) 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(sampled, payload_bytes) if ticks_by_code: tick_cap = get_env_int("TAIL_PARAM_SEARCH_MAX_WORKERS_WITH_TICKS", 3) if tick_cap > 0: max_workers = min(max_workers, tick_cap) logger.info( "📌 ws_ticks payload — 워커 상한 %d (TAIL_PARAM_SEARCH_MAX_WORKERS_WITH_TICKS)", max_workers, ) # 공유메모리 틱은 사본이 없어 OOM 위험이 없다 → 기본 상한 없음(CPU/메모리 계획대로). if shared_tick_store is not None: shared_cap = get_env_int("TAIL_PARAM_SEARCH_MAX_WORKERS_WITH_SHARED_TICKS", 0) if shared_cap > 0 and max_workers > shared_cap: max_workers = shared_cap logger.info( "📌 ws_ticks 공유메모리 — 워커 상한 %d (TAIL_PARAM_SEARCH_MAX_WORKERS_WITH_SHARED_TICKS)", max_workers, ) chunks = [dict_combos[i:i + chunk_size] for i in range(0, len(dict_combos), chunk_size)] logger.info(param_search_worker_budget_line(payload_bytes)) logger.info( f"⚙️ 멀티프로세싱 시작 (코어: {n_cpu}, 워커: {max_workers}, CPU {_cpu_frac*100:.0f}%) | " f"청크: {len(chunks):,}개 (청크당 ~{chunk_size}조합)" ) start_time = time.time() global_heap = [] 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_param_chunk, chunk, base_params, None, fee_rate, sell_tax, min_trades, min_win_rate, min_pf, top_n, None, slot_money, max_stocks, total_budget_krw, period_days, ) logger.info(f"⏳ 청크 처리 중… (청크당 최대 {chunk_size:,}개 조합, 완료되는 대로 진행률·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 i, item in enumerate(local_results): wr, pnl, _, result_pkg = item if sort_by == "pnl": unique_item = (-pnl, wr, (processed, i), result_pkg) else: unique_item = (wr, pnl, (processed, i), result_pkg) if len(global_heap) < top_n: heapq.heappush(global_heap, unique_item) else: heapq.heappushpop(global_heap, unique_item) # 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: logger.info(line) if use_carriage_return: print(flush=True) # 줄바꿈으로 진행률 줄 마무리 # 워커 종료 후 공유메모리 즉시 해제 (atexit 는 크래시 대비 이중 안전장치). if shared_tick_store is not None: shared_tick_store.unlink() shared_tick_store = None elapsed = time.time() - start_time # 5. 결과 정렬 및 출력 if not global_heap: logger.info("⚠️ 조건을 만족하는 조합이 없습니다. (min_trades를 낮추거나 기간을 늘려보세요)") print("📌 DB 미적용. 기존 설정 유지.") return False # sort_by pnl → (-pnl, wr) 최소힙 → heappop 순이 이미 [best pnl, ..., worst] 이므로 reverse 금지 # sort_by win_rate → (wr, pnl) 최소힙 → heappop 순은 [low wr, ..., high wr] 이므로 reverse 필요 best_results = [heapq.heappop(global_heap)[3] for _ in range(len(global_heap))] if sort_by == "win_rate": best_results.reverse() # sl_pct(손절%)별 상위 보장 → 한 값만 상위 독점 방지, 동점이면 손절 낮은 쪽(보수적) 1위 if "sl_pct" in keys and best_results: sl_vals = sorted(set(r["params"]["sl_pct"] for r in best_results)) per_sl = max(1, top_n // len(sl_vals)) by_sl = {} for r in best_results: v = r["params"]["sl_pct"] if v not in by_sl: by_sl[v] = [] if len(by_sl[v]) < per_sl: by_sl[v].append(r) best_results = [] for v in sl_vals: best_results.extend(by_sl.get(v, [])) best_results.sort(key=lambda r: (-r["total_pnl"], r["params"]["sl_pct"], -r["win_rate"])) logger.info(f"✅ 손절(sl_pct)별 상위 {per_sl}개씩 보장 → {len(best_results)}건 (동점 시 손절 낮은 쪽 1위)") filtered = [ r for r in best_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: best_results = filtered order_msg = "수익→승률 순" if sort_by == "pnl" else "승률→수익 순" logger.info( f"✅ 승률≥{min_win_rate}% · PF≥{min_pf} {len(best_results)}건 중 {order_msg} 상위" ) else: logger.info( f"⚠️ 승률≥{min_win_rate}% · PF≥{min_pf} 없음 → 차악(상위) 적용" ) # 손익 마이너스인 조합 제외 (수익 나는 것만 표시·저장) profitable = [r for r in best_results if r["total_pnl"] > 0] if profitable: best_results = profitable logger.info(f"✅ 총손익 플러스만 사용: {len(best_results)}건 (손실 조합 제외)") else: logger.info(f"⚠️ 수익 나는 조합 없음 → 손실 최소 순으로 표시") order_label = "수익" if sort_by == "pnl" else "승률" hdr_keys = [k for k in keys if k in (best_results[0]["params"] if best_results else {})] col_w = max((len(k) for k in hdr_keys), default=6) + 2 sep_w = len(hdr_keys) * (col_w + 2) + 50 print(f"\n✅ 탐색 완료! 총 소요 시간: {elapsed:.1f}초") print(f"\n{'='*min(sep_w, 100)}") print(f" 🏆 꼬리잡기 {order_label} TOP {min(top_n, len(best_results))}") print(f"{'='*min(sep_w, 100)}") hdr = " ".join(f"{k:>{col_w}}" for k in hdr_keys) print(f"{hdr} | {'손익(원)':>12} {'수익%':>7} {'승률':>6} {'거래':>5} {'PF':>5}") print("-" * min(sep_w + 10, 110)) for i, res in enumerate(best_results[:top_n]): p = res["params"] row = " ".join(_format_param_cell(p.get(k, ""), col_w) for k in hdr_keys) pf = res.get("pf", 0) or 0 bot_pct = res.get("bot_pct", 0) or 0 print( f"{row} | {res['total_pnl']:>+12,.0f} {bot_pct:>+6.2f}% " f"{res['win_rate']:>5.1f}% {res['total_trades']:>5} {pf:>5.2f}" ) best = best_results[0] bp = best["params"] print(f""" ╔══════════════════════════════════════════╗ ║ 🏆 1위 최적 파라미터 ║ ╠══════════════════════════════════════════╣""") for k in hdr_keys: v = bp.get(k, "") print(f"║ {k:<28s} : {str(v):>6} ║") print(f"""╠══════════════════════════════════════════╣ ║ 총 손익 : {best['total_pnl']:>+12,.0f} 원 ║ ║ 수익률(한도) : {best.get('bot_pct', 0):>+6.2f}% ║ ║ 승률 : {best['win_rate']:>6.1f}% ║ ║ 총 거래 : {best['total_trades']:>5} 건 ║ ║ Profit Factor : {best.get('pf', 0):>5.2f} ║ ╚══════════════════════════════════════════╝""") reasons = best.get("sell_reasons") or {} if reasons: parts = ", ".join(f"{k}={v}" for k, v in sorted(reasons.items(), key=lambda x: -x[1])) print(f"🏅 1위 청산분포: {parts}") # 6. JSON 파일로 결과 저장 (매 실행 새 파일 · momentum/breakout 과 동일 정책) out_data = { "mode": mode, "start": start, "end": end, "timeframe": tail_tf, "universe_source": universe_source, "universe_history_slots": universe_history_slots, "slot_money": int(slot_money), "max_stocks": max_stocks, "total_budget_krw": int(total_budget_krw), "portfolio_mode": True, "budget_warning": portfolio.get("budget_warning"), "backtest_days": period_days, "web_backtest_aligned": True, "min_trades": min_trades, "min_win_rate": min_win_rate, "min_pf": min_pf, "grid_keys": keys, "cartesian_product": total_grid, "sampled_backtests": sampled, "dropped_by_combo_cap": dropped_by_cap, "max_combos": max_combos_cap, "grid_axis_hints": {k: TAIL_GRID_AXIS_HINTS_KO[k] for k in keys if k in TAIL_GRID_AXIS_HINTS_KO}, "holding_peak_in_candles": has_holding_peak, "tested_combos": sampled, "elapsed_sec": round(elapsed, 1), "results": best_results[:top_n], } ts = datetime.now().strftime("%Y%m%d_%H%M%S") out_name = f"search_tail_{mode}_{ts}.json" out_dir = _results_dir_for_write() out_path = os.path.join(out_dir, out_name) try: with open(out_path, "w", encoding="utf-8") as f: json.dump(out_data, f, indent=2, ensure_ascii=False) except OSError: fb = os.path.join(os.path.expanduser("~"), ".kis_bot_search_results") os.makedirs(fb, exist_ok=True) out_path = os.path.join(fb, out_name) with open(out_path, "w", encoding="utf-8") as f: json.dump(out_data, f, indent=2, ensure_ascii=False) logger.warning("⚠️ results/ 쓰기 권한 없음 → 폴백 저장: %s", out_path) logger.info("💾 결과 저장: %s", out_path) return True except Exception as e: logger.error(f"❌ 탐색 중 오류 발생: {e}", exc_info=True) return False finally: db.close() def _merge_tail_apply_source( target: Dict[str, Any], search_meta: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """tail JSON → apply 소스 (공통 merge 사용).""" return merge_param_search_apply_source(target, search_meta) def _count_tail_sell_reasons(trades: List[Dict[str, Any]]) -> Dict[str, int]: """청산 사유별 건수 — tail trades 는 reason 또는 sell_reason.""" out: Dict[str, int] = {} for t in trades or []: k = str(t.get("sell_reason") or t.get("reason") or "unknown") out[k] = out.get(k, 0) + 1 return out def _tail_params_to_env_map(p: Dict[str, Any]) -> Dict[str, str]: """꼬리 파라서치·웹 save_config 와 동일 — TAIL_* 키 + 당일손익 트레일(글로벌/SHORT).""" from kis_trader.engine.tail_env_keys import params_to_tail_env_patch src = dict(p or {}) # skip_hts_scan_dupes: full+ 그리드 탐색만, param_search apply 제외 src.pop("skip_hts_scan_dupes", None) env_map = params_to_tail_env_patch(src) if "risk_pct" in p: env_map["RISK_PCT_PER_TRADE"] = str(p["risk_pct"]) if "kelly_mult" in p: env_map["KELLY_MULTIPLIER"] = str(p["kelly_mult"]) # 당일손익 다단트레일 — apply-best 로 DB 덮어쓰기 금지 (운영 UI/DB만 변경). from kis_trader.engine.orderbook_env import orderbook_params_to_env_patch env_map.update(orderbook_params_to_env_patch("TAIL", p)) return env_map def apply_from_json(apply_idx: int): """ 저장된 JSON 결과에서 N위 조합을 추출하여 env_config에 즉시 반영 """ out_file = _find_tail_result_json() if not out_file: logger.error( "❌ 결과를 찾을 수 없습니다. 후보:\n" " - kis_trader/backtest/results/search_tail_*.json (최신)\n" " - 구 형식 tail_search_*.json 또는 tail_param_result.json" ) return with open(out_file, "r", encoding="utf-8") as f: data = json.load(f) results = data.get("results", []) if not results: logger.error("❌ JSON에 저장된 결과가 없습니다.") return if apply_idx < 1 or apply_idx > len(results): logger.error(f"❌ 유효하지 않은 순위입니다. (1~{len(results)} 사이 입력)") return target = results[apply_idx - 1] if target.get("total_pnl", 0) <= 0: logger.warning(f"⚠️ {apply_idx}번째 결과는 총손익 ≤ 0 (조건 미충족). DB 미적용. 기존 설정 유지.") print("⚠️ 해당 순위는 총손익 ≤ 0 (조건 미충족). DB 미적용. 기존 설정 유지.") return merged = _merge_tail_apply_source(target, data) env_map = _tail_params_to_env_map(merged) # 슬롯·동시보유·총한도 제외 — 파람서치가 운영 한도를 덮지 않음 env_map = strip_portfolio_keys_from_apply_patch(env_map, "TAIL", "SHORT") db = TradeDB() try: latest = db.get_latest_env() snap = dict(latest["snapshot"]) if latest else {} snap.update(env_map) env_id = db.insert_env_snapshot(snap) logger.info(f"\n🚀 [자동 반영 완료] {apply_idx}위 조합을 DB에 적용했습니다. (env_id: {env_id})") logger.info(f"📂 파일: {out_file}") logger.info(f"적용된 값: {json.dumps(env_map, indent=2, ensure_ascii=False)}") logger.info("실매매 봇(kis_trader/main.py TailCatchStrategy)이 다음 루프부터 이 설정을 사용하여 매매를 시작합니다.") finally: db.close() def apply_params_to_db(p: dict): """ 병합된 꼬리 설정 dict → env_config 반영. (_merge_tail_apply_source 결과 또는 apply_from_json 과 동일 형태) """ env_map = _tail_params_to_env_map(dict(p or {})) env_map = strip_portfolio_keys_from_apply_patch(env_map, "TAIL", "SHORT") if not env_map: logger.warning("DB 적용할 env 키가 없습니다.") return db = TradeDB() try: latest = db.get_latest_env() snap = dict(latest["snapshot"]) if latest else {} snap.update(env_map) db.insert_env_snapshot(snap) finally: db.close() def main(): from kis_trader.backtest.param_search_dates import resolve_param_search_range week_ago, today = resolve_param_search_range("TAIL", lookback_days=7) parser = argparse.ArgumentParser(description="꼬리잡기 V3 백테스트 파라미터 최적화 (Grid Search)") parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD, 기본 7일·거래일 보정)") parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD, 주말·휴장이면 이전 장운영일)") parser.add_argument("--timeframe", "--tf", default=3, type=int, dest="timeframe", help="ws_candles 분봉 3·5·15·60 (backtest_web 꼬리 탭과 동일, 기본 3)") add_portfolio_cli_args(parser) parser.add_argument("--mode", default="fast", choices=["fast", "coarse", "fine", "wide", "full", "massive"], help="탐색 모드 (fast≈768조합·포트폴리오·~10–20분, coarse≈10k)") parser.add_argument("--max-combos", type=int, default=-1, help=f"백테 조합 상한 (기본 {DEFAULT_MAX_COMBOS}, 0=무제한·수억 조합 모드 비추)") parser.add_argument("--top", default=5000, type=int, help="메모리에 유지·JSON 저장할 상위 N개 (기본 5000)") parser.add_argument("--min_trades", default=3, type=int, help="최소 거래 건수 (1건 플러스 과최적화 방지, 기본 3)") 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 과 함께 쓰면 재탐색 없이 즉시 적용") parser.add_argument("--from-file", action="store_true", help="탐색 생략하고 기존 저장된 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="저장 이력 무시, 전체 종목으로 매수 검사 (조합별 거래 수 확대·느림)") parser.add_argument("--use-universe-history", action="store_true", dest="use_universe_history", help="SHORT 유니버스 저장 이력만 매수 (기본 동작과 동일, 명시용)") parser.add_argument( "--orderbook-filter", default="off", choices=["off", "on", "auto"], dest="orderbook_filter", help="호가필터: off=1단계(코어만·기본) / on=2단계(kiwoom 본체 스프레드·꼬리지지) / auto=DB값", ) parser.add_argument("--apply-ai", action="store_true", dest="apply_ai", help="Gemini가 수익·승률 기준으로 하나 골라 DB 적용. --from-file 과 함께 쓰면 탐색 없이 최근 JSON만 사용; 그 외에는 탐색 완료 후 방금 생성된 JSON으로 적용") args = parser.parse_args() start = args.start end = args.end # 탐색 없이 최근 JSON으로만 AI 적용 (--from-file --apply-ai) if args.apply_ai and args.from_file: import param_apply_ai param_apply_ai.apply_ai_tail() return if args.from_file and args.apply is not None: apply_from_json(args.apply) 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 = try_acquire_run_lock("tail_param_search") if run_lock is None: logger.error( "⛔ 이미 실행 중인 tail_param_search 가 있습니다.\n" " ps -ef | grep tail_param_search\n" " pkill -f 'tail_param_search.py' 후 재실행하세요." ) sys.exit(2) try: if args.max_combos >= 0: max_combos = max(0, int(args.max_combos)) elif args.mode == "fast": max_combos = FAST_MAX_COMBOS else: max_combos = DEFAULT_MAX_COMBOS # 기본: SHORT 유니버스 저장 이력 (backtest_web universe=history). 전종목은 --fallback-universe. use_fallback = bool(args.fallback_universe) if args.use_universe_history: use_fallback = False had_results = run_search( start, end, args.mode, args.top, args.min_trades, args.min_win_rate, args.min_pf, sort_by=args.sort_by, use_fallback_universe=use_fallback, max_combos=max_combos, timeframe=args.timeframe, time_start_hm=args.time_start, time_end_hm=args.time_end, slot_money=args.slot_money, max_stocks=args.max_stocks, total_budget_krw=args.total_budget, orderbook_filter=args.orderbook_filter, ) except KeyboardInterrupt as e: print(f"\n⛔ {e} — 미완료 결과 없이 종료합니다.", flush=True) sys.exit(130) finally: run_lock.release() if args.apply is not None: if _find_tail_result_json(): apply_from_json(args.apply) else: logger.warning("결과가 없어 DB 적용을 건너뜁니다.") # 탐색에서 조건 만족 조합이 있었을 때만 방금 저장된 JSON으로 AI 적용 (없으면 기존 설정 유지) if args.apply_ai and had_results: import param_apply_ai param_apply_ai.apply_ai_tail() elif args.apply_ai and not had_results: print("📌 이번 탐색에서 조건 만족 조합 없음 → apply_ai 스킵. DB 미적용. 기존 설정 유지.") if __name__ == "__main__": main()