Files
kis_bot/kis_trader/backtest/tail_param_search.py
Your Name 36a3e2b4a1 feat: Enhance trading system with new permanent subscription features and order book management
Changes:
- Added a new API endpoint for managing permanent subscriptions, allowing users to enable or disable subscriptions dynamically.
- Implemented a function to fill candle data from Kiwoom, ensuring that only relevant data is inserted into the database.
- Introduced a mechanism to handle master subscription states, improving the management of subscription statuses.
- Updated the database schema to include new fields for managing subscription states and order book filtering.

Impact:
- These enhancements improve the flexibility and reliability of the trading system, allowing for better management of subscriptions and order book data, while reducing the risk of data inconsistencies.

히스토리 align 제거 븅신같은 초기설계 아예 제거
진입모드에 구멍메움
호가진입을 켜도 호가가 안들어올때 호가 안보고 그냥 사버림
2026-08-15 23:01:14 +09:00

1950 lines
89 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
kis_trader/backtest/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조합·~1020분·포트폴리오)
--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 스캔 · ~1530초)
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 지정가 깊이(anchorATR×배수) — limit_atr 전용, 클수록 체결↓",
"entry_mode": "진입: align=다음봉 시가 / limit_atr=지정가(틱 체결 재생)",
"skip_hts_scan_dupes": "TRIGGER 3분 직전대비 등락(BAR_CHG) 재검사 생략 — HTS 일봉A/1분G와 축 다름",
"cand_limit": "실매 SHORT_CAND_LIMIT 동일 적용 — 그 시각 유니버스 상위 N개만 매수체크 (0=무제한)",
"max_daily_change": "급등주 필터(당일 등락률 상한, %)",
"trail_pct": "보조 트레일 폭(고점 대비, 0=OFF). 래칫 ON이면 1단계보다 arm이 커야 효과",
"trail_arm_pct": "보조 트레일 무장: 진입×(1+arm) 도달 후 (0=즉시)",
"whipsaw_enabled": "휩쏘 필터 ON/OFF — 실매 TAIL_WHIPSAW_FILTER_ENABLED (현재 false 포함)",
"whipsaw_subbar_sec": "휩쏘 미니봉(초) — 기본 30 · TPE [15,30,45,60]",
"whipsaw_lookback_sec": "휩쏘 룩백(초) — 기본 90 · TPE [60,90,120,180]",
"whipsaw_dip_pct": "휩쏘 지지이탈 하락폭(분율) — 기본 0.003",
"whipsaw_recovery_tol_pct": "휩쏘 회복 허용(분율) — 기본 0.001",
"max_spread_pct": "호가 스프레드 상한(%) — kiwoom_0d 본체 재계산(6/25~)",
"min_bid_ask_ratio": "총매수/총매도 잔량비 하한(꼬리지지) — kiwoom_0d 본체 재계산(6/25~)",
}
def evaluate_tail_param_combo(
combo: Dict[str, Any],
*,
base_params: Dict[str, Any],
candles_by_code: Dict[str, List[Dict]],
fee_rate: float,
sell_tax: float,
min_trades: int,
min_win_rate: float,
min_pf: float,
universe_by_slot: Optional[Dict[str, List[str]]] = None,
slot_money: float = 3_000_000.0,
max_stocks: int = 3,
total_budget_krw: float = 9_000_000.0,
period_days: int = 1,
cache_holder: Optional[Dict[str, Any]] = None,
ticks_by_code: Any = None,
orderbook_by_code: Any = None,
program_by_code: Any = None,
log_verdict_by_code: Any = None,
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
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조합·포트폴리오·~1020분, 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()