- 프론트엔드 UI 업데이트 (backtest.html, backtest.js) 엔진 라디오 버튼 통합 관련 반영 - Rust 플러그인(kis_rust_core) 및 컴파일 소스코드 추가 - CLI 백테스트 스크립트 수정 및 최신화 - 기타 스크래치 테스트 스크립트, 로그 요약 마크다운(.md) 등 누락 파일 일괄 반영 - 추가적으로 아직 발견되지 않은 엣지 케이스나 렌더링 오류가 포함되어 있을 가능성이 있음
1951 lines
89 KiB
Python
1951 lines
89 KiB
Python
#!/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, attach_optional_backtest_trades
|
||
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_filter_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,
|
||
include_trades: bool = False,
|
||
) -> 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_optional_backtest_trades(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), trades, include_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
|
||
base_params["_tick_meta_cached"] = tick_backtest_meta
|
||
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()
|