Changes: - Added `apply_params_to_db` function to streamline parameter application to the database. - Introduced `evaluate_breakout_param_combo`, `evaluate_momentum_param_combo`, and `evaluate_tail_param_combo` functions to enhance the evaluation of parameter combinations for respective strategies. - Updated `requirements.txt` to include `optuna==4.2.1` for improved optimization capabilities. Impact: - These additions improve the modularity and efficiency of parameter evaluations across different trading strategies, facilitating better optimization and backtesting processes.
1451 lines
69 KiB
Python
1451 lines
69 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 / 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|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,
|
||
portfolio_env_patch,
|
||
session_env_patch,
|
||
)
|
||
from kis_trader.backtest.param_search_cli_common import (
|
||
add_portfolio_cli_args,
|
||
add_search_filter_cli_args,
|
||
apply_session_to_fixed,
|
||
combo_passes_search_filters,
|
||
format_session_hm,
|
||
search_json_meta,
|
||
)
|
||
from kis_trader.backtest.param_search_pool import (
|
||
ParamSearchSharedPayload,
|
||
ParamSearchProgressETA,
|
||
assert_parent_alive,
|
||
cap_combos_uniform,
|
||
cap_combos_uniform_lazy,
|
||
iter_pool_chunk_results,
|
||
managed_process_pool,
|
||
param_search_chunk_plan,
|
||
param_search_worker_budget_line,
|
||
try_acquire_run_lock,
|
||
worker_shared_get,
|
||
)
|
||
from kis_trader.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":
|
||
# 16축 — tail_vol_mult×4, atr_tp_max×4 + 청산방식(ratchet)·RSI·반전패턴 (균등샘플 cap)
|
||
return {
|
||
"entry_mode": _parse_csv_strings(f"{p}ENTRY_MODE", ["align", "limit_atr"]),
|
||
"max_daily_change": _parse_csv_floats(f"{p}MAX_DAILY_CHG", [15.0, 20.0, 25.0, 30.0]),
|
||
"min_drop_rate": _parse_csv_floats(f"{p}MIN_DROP_RATE", [0.02, 0.03]),
|
||
"min_recovery_ratio": _parse_csv_floats(f"{p}MIN_RECOVERY_RATIO", [0.25, 0.4, 0.5]),
|
||
"tail_ratio_min": _parse_csv_floats(f"{p}TAIL_RATIO_MIN", [1.0, 1.5]),
|
||
"max_rec_3m": _parse_csv_floats(f"{p}MAX_REC_3M", [0.70, 0.80, 0.90]),
|
||
"shoulder_min_high": _parse_csv_floats(f"{p}SHOULDER_MIN_HIGH", [0.003, 0.005]),
|
||
"shoulder_cut_pct": _parse_csv_floats(f"{p}SHOULDER_CUT_PCT", [0.002, 0.003]),
|
||
"stop_atr_mult": _parse_csv_floats(f"{p}STOP_ATR_MULT", [1.5, 2.0]),
|
||
"target_atr_mult": _parse_csv_floats(f"{p}TARGET_ATR_MULT", [1.5, 2.0]),
|
||
"atr_tp_max_pct": _parse_csv_floats(f"{p}ATR_TP_MAX_PCT", [1.0, 1.5, 2.0, 3.0]),
|
||
"tail_vol_mult": _parse_csv_floats(f"{p}VOL_MULT", [0.0, 1.5, 2.0, 2.5]),
|
||
"tail_vol_win": _parse_csv_floats(f"{p}VOL_WIN", [5.0]),
|
||
"limit_atr_mult": _parse_csv_floats(f"{p}LIMIT_ATR_MULT", [1.0, 1.5, 2.0]),
|
||
# ── 청산방식 탐색: ratchet_tiers ──────────────────────────────
|
||
# off = 단일 어깨컷(shoulder_*) 사용 / 나머지 = 다단 래칫 트레일.
|
||
# "off" 포함으로 shoulder_min_high·shoulder_cut_pct 축이 비로소
|
||
# 의미를 갖는다(래칫 ON 조합에선 어깨컷은 엔진상 무시됨).
|
||
"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"],
|
||
),
|
||
# ── 과매매 제어: 종목당 일일 매수횟수 · 재진입 콜다운(분) ───────
|
||
# 실매 정합 핵심 — 현재 DB(20회·1분)는 같은 종목 연타로 PF 왜곡.
|
||
# 파람서치가 "적게 사는 게 PF↑"를 찾으면 DB에 반영된다.
|
||
"max_daily": _parse_csv_ints(f"{p}MAX_DAILY", [2, 3, 5]),
|
||
"cooldown_min": _parse_csv_floats(f"{p}COOLDOWN_MIN", [3.0, 5.0, 15.0]),
|
||
# ── 진입 캔들 등락 필터(%) — 신호봉이 이 구간 안일 때만 매수 ─────
|
||
# bar_chg_min: 하한(과대 급락 제외) / bar_chg_max: 상한(최소 하락폭)
|
||
"bar_chg_min_pct": _parse_csv_floats(f"{p}BAR_CHG_MIN", [-10.0, -7.0]),
|
||
"bar_chg_max_pct": _parse_csv_floats(f"{p}BAR_CHG_MAX", [-1.0, -1.5, -2.0]),
|
||
# ── RSI 과열 진입 거부 기준(%) ────────────────────────────────
|
||
"rsi_threshold": _parse_csv_floats(f"{p}RSI_THRESHOLD", [78.0, 82.0, 85.0]),
|
||
# ── 반전 캔들 패턴 ON/OFF (hammer 는 base 고정, 추가 패턴만 탐색) ─
|
||
# pin: 핀바(긴 아래꼬리 단일봉, 꼬리잡기와 직결)
|
||
# engulfing/piercing: 2봉 반전 — 신호 다양성·표본 확대용
|
||
"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]),
|
||
# ── 호가필터(2단계 조인트) 축 — 기본 단일값=동작 불변 ──────────
|
||
# max_spread_pct : 스프레드 상한(%) ↑완화
|
||
# min_bid_ask_ratio : 총매수/총매도 잔량비 하한(꼬리지지) ↓완화
|
||
"max_spread_pct": _parse_csv_floats(f"{p}MAX_SPREAD_PCT", [0.45]),
|
||
"min_bid_ask_ratio": _parse_csv_floats(f"{p}MIN_BID_ASK_RATIO", [0.85]),
|
||
# ── 당일 누적손익 트레일 익절(%) — 0=OFF(기본·동작 불변) ─────────
|
||
# 고점 대비 N% 되돌리면 그날 신규매수 중단(실매 daily_profit_halt 동일).
|
||
# 일(日) 단위 이벤트라 표본=거래일 수 → 긴 기간에서만 유의미(강건 구간 검증).
|
||
# 스윕하려면 env TAIL_GRID_FAST_TRAIL_DROP_PCT=0,30,40 + DB
|
||
# DAILY_PROFIT_TRAIL_ARM_KRW(발동 최소수익) 설정 필요.
|
||
# ── 당일 누적손익 다단계 트레일(ratchet 철학) — 벌수록 타이트 ──────
|
||
# 'off'=트레일 미사용(기준군) / '수익원:컷%,…'=다단 trail.
|
||
# peak 가 높은 구간에 들수록 되돌림 컷%를 좁혀 이익 보존(워런 버핏식).
|
||
# tier 가 켜지면 단일 trail_drop_pct·arm 은 무시됨. 일(日)단위 표본.
|
||
# env TAIL_GRID_FAST_TRAIL_TIERS 로 세미콜론(;) 구분 override.
|
||
"trail_tiers": _parse_semi_strings(f"{p}TRAIL_TIERS", [
|
||
"off",
|
||
"5000:40,10000:30,20000:20", # 보수: 일찍·타이트하게 사수
|
||
"30000:50,100000:35,300000:25", # 중도
|
||
"50000:60,200000:45,500000:30", # 공격: 많이 벌 때만 강하게 사수
|
||
]),
|
||
# 단일 트레일(tier='off' 일 때만 의미) — 하위호환·기준 비교용 단일값.
|
||
"trail_drop_pct": _parse_csv_floats(f"{p}TRAIL_DROP_PCT", [0.0]),
|
||
"trail_arm_krw": _parse_csv_floats(f"{p}TRAIL_ARM_KRW", [10000.0]),
|
||
}
|
||
|
||
if m == "coarse":
|
||
return {
|
||
"max_daily_change": _parse_csv_floats(f"{p}MAX_DAILY_CHG", [15.0, 20.0, 25.0, 30.0]),
|
||
"min_drop_rate": _parse_csv_floats(f"{p}MIN_DROP_RATE", [0.02, 0.03]),
|
||
"min_recovery_ratio": _parse_csv_floats(f"{p}MIN_RECOVERY_RATIO", [0.4, 0.45, 0.5]),
|
||
"tail_ratio_min": _parse_csv_floats(f"{p}TAIL_RATIO_MIN", [1.0, 1.5]),
|
||
"max_rec_3m": _parse_csv_floats(f"{p}MAX_REC_3M", [0.85, 0.9]),
|
||
"shoulder_min_high": _parse_csv_floats(f"{p}SHOULDER_MIN_HIGH", [0.002, 0.003, 0.005]),
|
||
"shoulder_cut_pct": _parse_csv_floats(f"{p}SHOULDER_CUT_PCT", [0.002, 0.003]),
|
||
"stop_atr_mult": _parse_csv_floats(f"{p}STOP_ATR_MULT", [1.5, 2.0]),
|
||
"target_atr_mult": _parse_csv_floats(f"{p}TARGET_ATR_MULT", [1.5, 2.0]),
|
||
"atr_tp_max_pct": _parse_csv_floats(f"{p}ATR_TP_MAX_PCT", [0.8, 1.0, 1.2]),
|
||
"max_loss_krw": _parse_csv_floats(f"{p}MAX_LOSS_KRW", [100000.0, 200000.0]),
|
||
"limit_atr_mult": _parse_csv_floats(f"{p}LIMIT_ATR_MULT", [1.0, 1.5, 2.0]),
|
||
}
|
||
|
||
# fine / full / massive — 기존 PARAM_GRIDS 폴백 + env 축만 덮어쓰기
|
||
base = dict(PARAM_GRIDS.get(m) or {})
|
||
if not base:
|
||
return _tail_grids("fast")
|
||
overrides = {
|
||
"max_daily_change": _parse_csv_floats(f"{p}MAX_DAILY_CHG", base.get("max_daily_change", [20.0, 25.0])),
|
||
"limit_atr_mult": _parse_csv_floats(f"{p}LIMIT_ATR_MULT", [1.0, 1.5, 2.0]),
|
||
}
|
||
for axis, env_suffix, fb_key in (
|
||
("min_drop_rate", "MIN_DROP_RATE", "min_drop_rate"),
|
||
("min_recovery_ratio", "MIN_RECOVERY_RATIO", "min_recovery_ratio"),
|
||
("tail_ratio_min", "TAIL_RATIO_MIN", "tail_ratio_min"),
|
||
("max_rec_3m", "MAX_REC_3M", "max_rec_3m"),
|
||
("shoulder_min_high", "SHOULDER_MIN_HIGH", "shoulder_min_high"),
|
||
("shoulder_cut_pct", "SHOULDER_CUT_PCT", "shoulder_cut_pct"),
|
||
("stop_atr_mult", "STOP_ATR_MULT", "stop_atr_mult"),
|
||
("target_atr_mult", "TARGET_ATR_MULT", "target_atr_mult"),
|
||
):
|
||
if fb_key in base or env_suffix:
|
||
overrides[axis] = _parse_csv_floats(
|
||
f"{p}{env_suffix}",
|
||
base.get(fb_key, overrides.get(axis, [0.03])),
|
||
)
|
||
merged = dict(base)
|
||
merged.update(overrides)
|
||
return merged
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 파라미터 그리드 정의 (fine/full/massive 폴백 — fast/coarse 는 _tail_grids)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
PARAM_GRIDS = {
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
# [FAST] ~10–15분 — 포트폴리오 ON · limit_atr_mult×3 포함 시 ~768조합 (2^8×3)
|
||
# 진입 4축 + 청산 4축, atr_tp_max·회복률 3단계는 DB base 고정 (coarse에서 탐색)
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
"fine": {
|
||
"min_drop_rate": [0.015, 0.02, 0.025, 0.03, 0.04],
|
||
"min_recovery_ratio": [0.35, 0.4, 0.45, 0.5, 0.6],
|
||
"tail_ratio_min": [1.0, 1.2, 1.5, 1.8, 2.0],
|
||
"tail_pct_min": [0.001, 0.002, 0.003, 0.005],
|
||
"stop_atr_mult": [1.5, 2.0, 2.5],
|
||
"target_atr_mult": [1.5, 2.0, 2.5, 3.0],
|
||
"atr_sl_min_pct": [0.5, 0.8],
|
||
"atr_sl_max_pct": [0.8, 1.0, 1.2],
|
||
"atr_tp_min_pct": [0.3, 0.5, 0.8],
|
||
"atr_tp_max_pct": [0.8, 1.0, 1.2],
|
||
"shoulder_min_high": [0.002, 0.0025, 0.003, 0.004, 0.005],
|
||
"shoulder_cut_pct": [0.0015, 0.002, 0.0025, 0.003, 0.004, 0.005],
|
||
"max_rec_3m": [0.85, 0.9, 0.95],
|
||
"sl_pct": [0.015, 0.02, 0.025],
|
||
"tp_pct": [0.03, 0.04, 0.05],
|
||
"rsi_threshold": [72, 75, 78, 82],
|
||
"max_loss_krw": [150000, 200000],
|
||
"min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02],
|
||
},
|
||
"full": {
|
||
"min_drop_rate": [0.015, 0.02, 0.025, 0.03, 0.04, 0.05],
|
||
"min_recovery_ratio": [0.35, 0.4, 0.5, 0.6],
|
||
"tail_ratio_min": [1.0, 1.2, 1.5, 1.8, 2.0],
|
||
"tail_pct_min": [0.001, 0.002, 0.003],
|
||
"stop_atr_mult": [1.5, 2.0, 2.5, 3.0],
|
||
"target_atr_mult": [1.5, 2.0, 2.5, 3.0],
|
||
"atr_sl_min_pct": [0.5, 0.8],
|
||
"atr_sl_max_pct": [0.8, 1.0, 1.2, 1.5],
|
||
"atr_tp_min_pct": [0.3, 0.5, 0.8],
|
||
"atr_tp_max_pct": [0.8, 1.0, 1.2, 1.5],
|
||
"shoulder_min_high": [0.002, 0.003, 0.004, 0.005, 0.007],
|
||
"shoulder_cut_pct": [0.0015, 0.002, 0.003, 0.004, 0.005],
|
||
"max_rec_3m": [0.85, 0.9, 0.95],
|
||
"sl_pct": [0.015, 0.02, 0.025, 0.03],
|
||
"tp_pct": [0.03, 0.04, 0.05, 0.06],
|
||
"ma20_max_above": [3.0, 5.0],
|
||
"max_daily_change": [15.0, 20.0, 25.0],
|
||
"max_loss_krw": [100000, 200000],
|
||
"min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02, 0.025],
|
||
},
|
||
"massive": {
|
||
"min_drop_rate": [0.015, 0.02, 0.025, 0.03, 0.04, 0.05],
|
||
"min_recovery_ratio": [0.35, 0.4, 0.45, 0.5, 0.6],
|
||
"tail_ratio_min": [1.0, 1.2, 1.5, 1.8, 2.0],
|
||
"tail_pct_min": [0.001, 0.002, 0.003, 0.005],
|
||
"stop_atr_mult": [1.5, 2.0, 2.5, 3.0],
|
||
"target_atr_mult": [1.5, 2.0, 2.5, 3.0],
|
||
"atr_sl_min_pct": [0.5, 0.8, 1.0],
|
||
"atr_sl_max_pct": [0.8, 1.0, 1.2, 1.5],
|
||
"atr_tp_min_pct": [0.3, 0.5, 0.8],
|
||
"atr_tp_max_pct": [0.8, 1.0, 1.2, 1.5],
|
||
"shoulder_min_high": [0.002, 0.0025, 0.003, 0.004, 0.005, 0.007],
|
||
"shoulder_cut_pct": [0.0015, 0.002, 0.0025, 0.003, 0.004, 0.005],
|
||
"max_rec_3m": [0.85, 0.9, 0.95],
|
||
"ma20_max_above": [2.0, 3.0, 5.0],
|
||
"max_daily_change": [15.0, 20.0, 25.0],
|
||
"sl_pct": [0.015, 0.02, 0.025, 0.03],
|
||
"tp_pct": [0.03, 0.04, 0.05, 0.06],
|
||
"risk_pct": [0.005, 0.01, 0.02],
|
||
"kelly_mult": [0.15, 0.25, 0.5],
|
||
"max_loss_krw": [100000, 200000],
|
||
"min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02],
|
||
},
|
||
}
|
||
|
||
|
||
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=단일 어깨컷 / '수익%:컷%,…'=다단 래칫 트레일",
|
||
"trail_tiers": "당일손익 다단 트레일 'off'/'수익원:컷%,…' — 벌수록 컷% 타이트(이익 보존), 일단위 표본",
|
||
"trail_drop_pct": "당일손익 단일 트레일(고점대비 %, 0=OFF) — tier='off' 일 때만 의미",
|
||
"trail_arm_krw": "단일 트레일 발동 최소수익(원) — tier='off' & drop>0 일 때만 의미",
|
||
"max_daily": "종목당 일일 매수 횟수 상한 (낮을수록 과매매↓·실매정합↑)",
|
||
"cooldown_min": "청산 후 동일종목 재진입 콜다운(분) — 연타 방지",
|
||
"bar_chg_min_pct": "진입 신호봉 등락 하한(%) — 과대 급락 제외",
|
||
"bar_chg_max_pct": "진입 신호봉 등락 상한(%) — 최소 하락폭 요구",
|
||
"rsi_threshold": "RSI 과열 진입 거부 기준(%) — 낮을수록 매수 적음",
|
||
"pattern_pin": "핀바(긴 아래꼬리 단일봉) 진입 패턴 ON/OFF",
|
||
"pattern_engulfing": "장악형(2봉 반전) 진입 패턴 ON/OFF",
|
||
"pattern_piercing": "관통형(2봉 반전) 진입 패턴 ON/OFF",
|
||
"max_loss_krw": "1회 최대 금액 손실(원, 트레일 미발동 시)",
|
||
"limit_atr_mult": "ATR 지정가 깊이(anchor−ATR×배수) — limit_atr 전용, 클수록 체결↓",
|
||
"entry_mode": "진입: align=다음봉 시가 / limit_atr=지정가(틱 체결 재생)",
|
||
"max_daily_change": "급등주 필터(당일 등락률 상한, %)",
|
||
"trail_pct": "보조 트레일 폭(고점 대비, 0=OFF). 래칫 ON이면 1단계보다 arm이 커야 효과",
|
||
"trail_arm_pct": "보조 트레일 무장: 진입×(1+arm) 도달 후 (0=즉시)",
|
||
"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"])
|
||
# 당일손익 트레일 익절 — tier(우선) 또는 단일 drop 이 켜졌을 때만 시뮬 게이트 ON.
|
||
_tier_combo = str(combo.get("trail_tiers") or "").strip().lower()
|
||
_tier_on = _tier_combo not in ("", "off")
|
||
if _tier_on:
|
||
test_params["_backtest_daily_profit_trail"] = True
|
||
test_params.setdefault("daily_profit_mode", "trailing")
|
||
test_params["daily_trail_tiers"] = str(combo["trail_tiers"]).strip()
|
||
elif float(combo.get("trail_drop_pct") or 0) > 0:
|
||
test_params["_backtest_daily_profit_trail"] = True
|
||
test_params.setdefault("daily_profit_mode", "trailing")
|
||
test_params["daily_trail_drop_pct"] = float(combo["trail_drop_pct"])
|
||
if combo.get("trail_arm_krw") is not None:
|
||
test_params["daily_trail_arm_krw"] = float(combo["trail_arm_krw"])
|
||
else:
|
||
_arm_env = os.environ.get("TAIL_GRID_TRAIL_ARM_KRW", "")
|
||
if str(_arm_env).strip() != "":
|
||
test_params["daily_trail_arm_krw"] = float(_arm_env)
|
||
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 {
|
||
"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
|
||
),
|
||
}
|
||
|
||
|
||
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 "스킵 — 코어 파라미터 순수 탐색",
|
||
)
|
||
|
||
row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone()
|
||
r = dict(row) if row else {}
|
||
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
|
||
base_params.setdefault("force_eod_exit", False)
|
||
base_params.setdefault("backtest_tick_fallback_ohlc", True)
|
||
# ── 틱재생(ws_ticks) — 기본 ON (실매 체결 정합) ──────────────────────
|
||
# 3분봉 OHLC(open→high→low→close 가정)는 손절보다 익절을 먼저 체결하는
|
||
# 낙관 편향을 만든다. 모멘텀·돌파와 동일하게 tail_tick_replay 기본값(ON)을
|
||
# 그대로 따르되, params/env(TAIL_BACKTEST_USE_TICK_DB)로 오버라이드 가능.
|
||
from kis_trader.engine.tail_tick_replay import tail_backtest_use_tick_db as _tail_use_tick
|
||
base_params.setdefault("backtest_use_tick_db", _tail_use_tick(None))
|
||
if base_params.get("backtest_use_tick_db"):
|
||
logger.info("📌 틱재생(ws_ticks): ON — 실매 체결 정합 모드 (OHLC 낙관편향 제거)")
|
||
|
||
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_* 키만."""
|
||
from kis_trader.engine.tail_env_keys import params_to_tail_env_patch
|
||
env_map = params_to_tail_env_patch(p)
|
||
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"])
|
||
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)
|
||
|
||
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 {}))
|
||
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():
|
||
today = datetime.now().strftime("%Y-%m-%d")
|
||
week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
|
||
|
||
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", "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()
|