커서가 망쳐놓은 듯
This commit is contained in:
210
backtest_scalping/holding_param_search.py
Normal file
210
backtest_scalping/holding_param_search.py
Normal file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
holding_param_search.py — 홀딩 전략 파라미터 자동 탐색 (종목별 Grid Search)
|
||||
=============================================================================
|
||||
실행:
|
||||
cd /home/hoon/kis_bot
|
||||
python3 backtest_scalping/holding_param_search.py
|
||||
|
||||
옵션:
|
||||
--code 종목코드 (미지정 시 관심종목 전체)
|
||||
--start 시작일 (기본: 2년 전)
|
||||
--end 종료일 (기본: 오늘)
|
||||
--mode coarse(기본) / fine / full
|
||||
--top 상위 N개 출력 (기본: 10)
|
||||
--apply 1위 파라미터를 DB에 자동 적용
|
||||
|
||||
예시:
|
||||
python3 backtest_scalping/holding_param_search.py --code 005930 --apply
|
||||
python3 backtest_scalping/holding_param_search.py --mode fine --apply
|
||||
"""
|
||||
|
||||
import sys, os, json, time, argparse
|
||||
from datetime import datetime, timedelta
|
||||
from itertools import product as iproduct
|
||||
|
||||
import logging
|
||||
logging.getLogger("TradeDB").setLevel(logging.WARNING)
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from database import TradeDB
|
||||
import holding_bot as hb
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 그리드 정의
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
GRIDS = {
|
||||
# 빠른 1차 탐색 (~270 조합)
|
||||
"coarse": {
|
||||
"rsi_buy1": [40, 35, 30],
|
||||
"rsi_buy2": [35, 30, 25],
|
||||
"rsi_buy3": [30, 25, 20],
|
||||
"take_profit_pct": [3.0, 4.0, 5.0, 6.0, 8.0],
|
||||
"stop_loss_pct": [7.0, 10.0, 15.0],
|
||||
},
|
||||
# 세밀 2차 탐색 (~2,400 조합)
|
||||
"fine": {
|
||||
"rsi_buy1": [45, 40, 35, 30],
|
||||
"rsi_buy2": [40, 35, 30, 25],
|
||||
"rsi_buy3": [35, 30, 25, 20],
|
||||
"take_profit_pct": [2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0],
|
||||
"stop_loss_pct": [5.0, 7.0, 10.0, 15.0, 20.0],
|
||||
"rsi_sell": [65, 70, 75],
|
||||
},
|
||||
# 전체 탐색 (~10,000+ 조합)
|
||||
"full": {
|
||||
"rsi_buy1": [50, 45, 40, 35, 30],
|
||||
"rsi_buy2": [45, 40, 35, 30, 25],
|
||||
"rsi_buy3": [40, 35, 30, 25, 20, 15],
|
||||
"take_profit_pct": [2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0, 12.0],
|
||||
"stop_loss_pct": [5.0, 7.0, 10.0, 15.0, 20.0],
|
||||
"rsi_sell": [60, 65, 70, 75, 80],
|
||||
"rsi_period": [7, 14, 21],
|
||||
},
|
||||
}
|
||||
|
||||
FIXED_DEFAULTS = {
|
||||
"rsi_sell": 70,
|
||||
"rsi_period": 14,
|
||||
"buy1_ratio": 30,
|
||||
"buy2_ratio": 30,
|
||||
"buy3_ratio": 40,
|
||||
"slot_money": 3_000_000,
|
||||
}
|
||||
|
||||
|
||||
def run_search(code: str, name: str, candles, mode: str,
|
||||
top_n: int, min_trades: int, apply_best: bool, db):
|
||||
grid = GRIDS[mode]
|
||||
keys = list(grid.keys())
|
||||
combos = list(iproduct(*[grid[k] for k in keys]))
|
||||
valid = [(zip(keys, v)) for v in combos]
|
||||
|
||||
results = []
|
||||
base_cfg = dict(hb.DEFAULT_STOCK_CONFIG)
|
||||
base_cfg.update(FIXED_DEFAULTS)
|
||||
|
||||
t0 = time.time()
|
||||
total = len(combos)
|
||||
print(f"\n[{code}] {name} | {mode.upper()} | 조합: {total:,}개")
|
||||
print("=" * 60)
|
||||
|
||||
for i, vals in enumerate(combos):
|
||||
cfg = dict(base_cfg)
|
||||
cfg.update(dict(zip(keys, vals)))
|
||||
# rsi_buy 순서 보정
|
||||
if cfg["rsi_buy1"] <= cfg["rsi_buy2"] or cfg["rsi_buy2"] <= cfg["rsi_buy3"]:
|
||||
continue
|
||||
|
||||
res = hb.run_backtest(candles, cfg)
|
||||
s = res.get("summary", {})
|
||||
if s.get("total_trades", 0) < min_trades:
|
||||
continue
|
||||
|
||||
results.append({
|
||||
"params": {k: cfg[k] for k in keys},
|
||||
"total_pnl": s["total_pnl"],
|
||||
"win_rate": s["win_rate"],
|
||||
"total_trades": s["total_trades"],
|
||||
"pf": s["profit_factor"],
|
||||
"avg_hold": s["avg_hold_days"],
|
||||
"mdd": s["max_drawdown"],
|
||||
})
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f"완료: {elapsed:.1f}초 | 유효: {len(results)}건")
|
||||
|
||||
if not results:
|
||||
print("⚠️ 유효 결과 없음. 기간을 늘리거나 min_trades를 낮추세요.")
|
||||
return
|
||||
|
||||
results.sort(key=lambda x: x["total_pnl"], reverse=True)
|
||||
|
||||
col_w = max(len(k) for k in keys) + 2
|
||||
hdr = " ".join(f"{k:>{col_w}}" for k in keys)
|
||||
print(f"\n{'='*60}")
|
||||
print(f" TOP {min(top_n, len(results))}")
|
||||
print(f"{'='*60}")
|
||||
print(f"{hdr} | {'손익':>12} {'승률':>6} {'거래':>5} {'PF':>5} {'보유(일)':>8}")
|
||||
print("-" * (len(hdr) + 50))
|
||||
for r in results[:top_n]:
|
||||
p = r["params"]
|
||||
row = " ".join(f"{p[k]:>{col_w}.4g}" for k in keys)
|
||||
print(f"{row} | {r['total_pnl']:>+12,.0f} {r['win_rate']:>5.1f}% "
|
||||
f"{r['total_trades']:>5} {r['pf']:>5.2f} {r['avg_hold']:>7.1f}일")
|
||||
|
||||
best = results[0]
|
||||
bp = best["params"]
|
||||
print(f"""
|
||||
╔═══════════════════════════════════════════╗
|
||||
║ 🏆 [{code}] {name[:10]} 최적 파라미터
|
||||
╠═══════════════════════════════════════════╣""")
|
||||
for k, v in bp.items():
|
||||
print(f"║ {k:<22s} : {v!s:>8} ║")
|
||||
print(f"""╠═══════════════════════════════════════════╣
|
||||
║ 총 손익 : {best['total_pnl']:>+12,.0f} 원 ║
|
||||
║ 승률 : {best['win_rate']:>6.1f}% ║
|
||||
║ 거래 : {best['total_trades']:>5} 건 ║
|
||||
║ PF : {best['pf']:>5.2f} ║
|
||||
║ 보유(일) : {best['avg_hold']:>5.1f} 일 ║
|
||||
║ MDD : {best['mdd']:>12,.0f} 원 ║
|
||||
╚═══════════════════════════════════════════╝""")
|
||||
|
||||
# 결과 저장
|
||||
out_dir = os.path.join(ROOT, "backtest_scalping", "results")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_path = os.path.join(out_dir, f"holding_{code}_{mode}_{ts}.json")
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"code": code, "name": name, "mode": mode,
|
||||
"top": results[:top_n]}, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n💾 결과 저장: {out_path}")
|
||||
|
||||
if apply_best:
|
||||
hb.set_stock_config(db, code, name, bp)
|
||||
print(f"✅ DB 파라미터 적용 완료 ({code})")
|
||||
|
||||
|
||||
def main():
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
two_ago = (datetime.now() - timedelta(days=730)).strftime("%Y-%m-%d")
|
||||
|
||||
parser = argparse.ArgumentParser(description="홀딩 전략 파라미터 Grid Search")
|
||||
parser.add_argument("--code", default="", help="종목코드 (공백=관심종목 전체)")
|
||||
parser.add_argument("--start", default=two_ago, help="시작일")
|
||||
parser.add_argument("--end", default=today, help="종료일")
|
||||
parser.add_argument("--mode", default="coarse", choices=["coarse", "fine", "full"])
|
||||
parser.add_argument("--top", default=10, type=int)
|
||||
parser.add_argument("--min_trades", default=2, type=int)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
db = TradeDB()
|
||||
hb.ensure_holding_tables(db)
|
||||
|
||||
items = hb.load_watchlist()
|
||||
if args.code:
|
||||
items = [i for i in items if i["code"] == args.code]
|
||||
if not items:
|
||||
print(f"종목을 찾을 수 없습니다: {args.code}")
|
||||
db.close()
|
||||
return
|
||||
|
||||
for item in items:
|
||||
code = item["code"]
|
||||
name = item["name"]
|
||||
candles = hb.get_stored_candles(db, code, args.start, args.end)
|
||||
if len(candles) < 20:
|
||||
print(f"⚠️ [{code}] {name}: 봉 부족 ({len(candles)}개) → 스킵. 먼저 캔들을 수집하세요.")
|
||||
print(f" 실행: python3 holding_bot.py fetch --start {args.start} --end {args.end}")
|
||||
continue
|
||||
run_search(code, name, candles, args.mode,
|
||||
args.top, args.min_trades, args.apply, db)
|
||||
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
378
backtest_scalping/param_search.py
Normal file
378
backtest_scalping/param_search.py
Normal file
@@ -0,0 +1,378 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
param_search.py — 스캘핑 백테스트 파라미터 자동 탐색 (Grid Search)
|
||||
=====================================================================
|
||||
실행:
|
||||
cd /home/hoon/kis_bot
|
||||
python3 backtest_scalping/param_search.py
|
||||
|
||||
옵션:
|
||||
--start 시작일 (기본: 오늘-7일)
|
||||
--end 종료일 (기본: 오늘)
|
||||
--mode 탐색 모드: coarse(기본) / fine / full
|
||||
--top 상위 N개 출력 (기본: 20)
|
||||
--min_trades 최소 거래 건수 필터 (기본: 5)
|
||||
--apply 결과 1위를 DB에 자동 적용 (기본: False)
|
||||
|
||||
예시:
|
||||
python3 backtest_scalping/param_search.py --start 2026-03-01 --end 2026-03-06 --mode fine --apply
|
||||
"""
|
||||
"""
|
||||
# 오늘 하루 기준, 빠른 탐색 (약 720 조합, 2분 소요)
|
||||
python3 backtest_scalping/param_search.py --start 2026-03-06 --end 2026-03-06
|
||||
|
||||
# 지난 1주일, 세밀 탐색 (약 5,400 조합, 15분 소요)
|
||||
python3 backtest_scalping/param_search.py --start 2026-03-01 --end 2026-03-06 --mode fine
|
||||
|
||||
# 탐색 후 1위 파라미터를 DB에 자동 적용
|
||||
python3 backtest_scalping/param_search.py --start 2026-11-01 --end 2026-03-06 --mode fine --apply
|
||||
|
||||
# 전체 탐색 (51,840 조합, 2시간 이상, 주말에 돌려두기)
|
||||
python3 backtest_scalping/param_search.py --start 2026-02-01 --end 2026-03-06 --mode full --apply
|
||||
|
||||
결과는 backtest_scalping/results/search_coarse_YYYYMMDD_HHMMSS.json에 자동 저장됩니다.
|
||||
|
||||
핵심 팁: 데이터가 많을수록 신뢰도가 높으니, ws_candles 데이터가 2주 이상 쌓인 후에 --mode fine으로 돌리는 게 좋습니다.
|
||||
"""
|
||||
|
||||
import sys, os, json, time, argparse
|
||||
from datetime import datetime, timedelta
|
||||
from itertools import product
|
||||
from typing import Optional
|
||||
|
||||
MIN_WIN_RATE_DEFAULT = 52.0
|
||||
|
||||
# kis_bot 루트를 경로에 추가
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import logging
|
||||
logging.getLogger("TradeDB").setLevel(logging.WARNING) # 반복 초기화 로그 억제
|
||||
|
||||
from backtest_web import app
|
||||
from database import TradeDB
|
||||
import scalping_engine as se
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 스캘핑 기본값 = 엔진에서 DB 로드 (백테스트 API와 동일 단일 소스, 실매매와 동기화)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _fixed_defaults():
|
||||
"""엔진 get_scalping_defaults_from_db() 사용. API 쿼리용으로 percent 변환."""
|
||||
_d = se.get_scalping_defaults_from_db()
|
||||
return {
|
||||
"rsi_period": _d["rsi_period"],
|
||||
"slot_money": _d["slot_money"],
|
||||
"vol_mult": _d["vol_mult"],
|
||||
"trail_trigger": _d["trail_trigger"] * 100,
|
||||
"trail_stop": _d["trail_stop"] * 100,
|
||||
"cooldown_min": _d["cooldown_min"],
|
||||
"time_start": _d["time_start"],
|
||||
"time_end": _d["time_end"],
|
||||
"max_daily": _d["max_daily"],
|
||||
"fee_rate": _d["fee_rate"] * 100,
|
||||
"sell_tax": _d["sell_tax"] * 100,
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 파라미터 그리드 정의
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
GRIDS = {
|
||||
# 빠른 1차 탐색 (약 720 조합)
|
||||
"coarse": {
|
||||
"rsi_oversold": [10, 12, 15, 18, 20, 25],
|
||||
"sl_pct": [0.8, 1.0, 1.5, 2.0],
|
||||
"tp_pct": [1.5, 2.0, 2.5, 3.0, 4.0],
|
||||
"drop_rate": [0.5, 1.0, 1.5, 2.0, 2.5, 3.0],
|
||||
# 아래는 고정 (coarse 모드)
|
||||
},
|
||||
# 세밀 2차 탐색 (약 5,400 조합, 시간 오래 걸림)
|
||||
"fine": {
|
||||
"rsi_oversold": [15, 16, 17, 18, 19, 20],
|
||||
"sl_pct": [0.7, 0.8, 1.0, 1.2, 1.5],
|
||||
"tp_pct": [2.0, 2.5, 3.0, 3.5, 4.0],
|
||||
"drop_rate": [1.5, 2.0, 2.5, 3.0],
|
||||
"trail_trigger": [0, 0.5, 0.8],
|
||||
"trail_stop": [0.3, 0.5],
|
||||
"cooldown_min": [5, 10, 15],
|
||||
},
|
||||
# 전체 탐색 (약 51,840 조합, 매우 오래 걸림)
|
||||
"full": {
|
||||
"rsi_oversold": [10, 12, 15, 18, 20, 25],
|
||||
"sl_pct": [0.8, 1.0, 1.5, 2.0],
|
||||
"tp_pct": [1.5, 2.0, 2.5, 3.0, 4.0],
|
||||
"drop_rate": [0.5, 1.0, 1.5, 2.0, 2.5, 3.0],
|
||||
"trail_trigger": [0, 0.5, 0.8, 1.0],
|
||||
"trail_stop": [0.3, 0.5, 0.8],
|
||||
"cooldown_min": [5, 10, 20],
|
||||
"max_daily": [2, 3],
|
||||
},
|
||||
}
|
||||
|
||||
def _get_scalp_field_map():
|
||||
"""스캘핑 파라미터 → env_config 컬럼 매핑 (apply / db_snapshot 공용)."""
|
||||
return {
|
||||
"rsi_oversold": ("SCALP_RSI_OVERSOLD", lambda v: str(int(v))),
|
||||
"sl_pct": ("SCALP_STOP_LOSS_PCT", lambda v: str(float(v) / 100)),
|
||||
"tp_pct": ("SCALP_TAKE_PROFIT_PCT", lambda v: str(float(v) / 100)),
|
||||
"drop_rate": ("SCALP_MIN_DROP_RATE", lambda v: str(float(v) / 100)),
|
||||
}
|
||||
|
||||
|
||||
def _params_to_db_snapshot(params: dict) -> dict:
|
||||
"""그리드 params(표시 단위) → env_config 컬럼명:값 문자열 dict."""
|
||||
field_map = _get_scalp_field_map()
|
||||
return {
|
||||
db_col: fmt(params[param_k])
|
||||
for param_k, (db_col, fmt) in field_map.items()
|
||||
if param_k in params
|
||||
}
|
||||
|
||||
|
||||
def _apply_from_latest_json(rank: int):
|
||||
"""최근 search_*.json에서 rank번째(1-based) 항목의 merged_params를 DB에 적용."""
|
||||
out_dir = os.path.join(ROOT, "backtest_scalping", "results")
|
||||
if not os.path.isdir(out_dir):
|
||||
print("⚠️ results 디렉터리가 없습니다.")
|
||||
return
|
||||
jsons = [f for f in os.listdir(out_dir) if f.startswith("search_") and f.endswith(".json")]
|
||||
if not jsons:
|
||||
print("⚠️ search_*.json 파일이 없습니다.")
|
||||
return
|
||||
jsons.sort(key=lambda f: os.path.getmtime(os.path.join(out_dir, f)), reverse=True)
|
||||
latest_path = os.path.join(out_dir, jsons[0])
|
||||
with open(latest_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
top = data.get("top") or []
|
||||
if rank < 1 or rank > len(top):
|
||||
print(f"⚠️ 순번 {rank}이(가) 유효하지 않습니다. (1~{len(top)})")
|
||||
return
|
||||
item = top[rank - 1]
|
||||
merged = item.get("merged_params")
|
||||
if not merged:
|
||||
merged = item.get("params")
|
||||
if not merged:
|
||||
print("⚠️ 해당 항목에 merged_params/params가 없습니다.")
|
||||
return
|
||||
print(f"📂 {latest_path} 에서 {rank}번째 적용합니다.")
|
||||
_apply_to_db(merged)
|
||||
|
||||
|
||||
def run_search(start: str, end: str, mode: str, top_n: int,
|
||||
min_trades: int, min_win_rate: float, apply_rank: Optional[int], from_file_only: bool):
|
||||
if from_file_only and apply_rank is not None and apply_rank >= 1:
|
||||
_apply_from_latest_json(apply_rank)
|
||||
return
|
||||
|
||||
grid = GRIDS[mode]
|
||||
keys = list(grid.keys())
|
||||
combos = list(product(*[grid[k] for k in keys]))
|
||||
total = len(combos)
|
||||
print(f"\n[{mode.upper()} 모드] 탐색 조합: {total:,}개 | 기간: {start} ~ {end}")
|
||||
print("=" * 70)
|
||||
|
||||
results = []
|
||||
t0 = time.time()
|
||||
|
||||
FIXED_DEFAULTS = _fixed_defaults()
|
||||
with app.test_client() as c:
|
||||
for i, vals in enumerate(combos):
|
||||
params = dict(FIXED_DEFAULTS) # 고정값 먼저 (DB 쿨다운/장시간 반영)
|
||||
params.update(dict(zip(keys, vals))) # 그리드값으로 덮어쓰기
|
||||
params["start"] = start
|
||||
params["end"] = end
|
||||
|
||||
qs = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
r = c.get(f"/api/backtest/scalping?{qs}")
|
||||
if r.status_code != 200:
|
||||
continue
|
||||
d = json.loads(r.data)
|
||||
s = d.get("summary", {})
|
||||
|
||||
if s.get("total_trades", 0) < min_trades:
|
||||
continue
|
||||
|
||||
results.append({
|
||||
"params": {k: params[k] for k in keys}, # 탐색 파라미터만 저장
|
||||
"total_pnl": s["total_pnl"],
|
||||
"win_rate": s["win_rate"],
|
||||
"total_trades": s["total_trades"],
|
||||
"pf": s["profit_factor"],
|
||||
"avg_hold": s["avg_hold_min"],
|
||||
"mdd": s["max_drawdown"],
|
||||
})
|
||||
|
||||
# 진행률 출력 (200건마다)
|
||||
if (i + 1) % 200 == 0:
|
||||
elapsed = time.time() - t0
|
||||
eta = elapsed / (i + 1) * (total - i - 1)
|
||||
print(f" 진행: {i+1:>5}/{total} 경과: {elapsed:>5.0f}s 남은: {eta:>5.0f}s", flush=True)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f"\n완료: {elapsed:.1f}초 | 유효 결과: {len(results)}건")
|
||||
|
||||
if not results:
|
||||
print("⚠️ 수익 조합을 찾지 못했습니다. 기간을 늘리거나 min_trades를 낮춰보세요.")
|
||||
return
|
||||
|
||||
# 총 손익 기준 정렬 후, 승률 min_win_rate 이상만 우선; 없으면 차악(손익 1위) 유지
|
||||
results.sort(key=lambda x: x["total_pnl"], reverse=True)
|
||||
results_52 = [r for r in results if r["win_rate"] >= min_win_rate]
|
||||
if results_52:
|
||||
results = results_52
|
||||
results.sort(key=lambda x: x["total_pnl"], reverse=True)
|
||||
print(f"✅ 승률 {min_win_rate}% 이상 {len(results)}건 중 손익순으로 1위 선정")
|
||||
else:
|
||||
print(f"⚠️ 승률 {min_win_rate}% 이상 없음 → 차악(손익 1위) 적용")
|
||||
|
||||
# ── 결과 출력 ──
|
||||
hdr_keys = [k for k in keys]
|
||||
col_w = max(len(k) for k in hdr_keys) + 2
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f" 🏆 수익 TOP {min(top_n, len(results))} (투자금 1,000,000원 기준)")
|
||||
print(f"{'='*70}")
|
||||
|
||||
# 헤더
|
||||
hdr = " ".join(f"{k:>{col_w}}" for k in hdr_keys)
|
||||
print(f"{hdr} | {'손익(원)':>12} {'승률':>6} {'거래':>5} {'PF':>5} {'보유':>6}")
|
||||
print("-" * (len(hdr) + 55))
|
||||
|
||||
for r in results[:top_n]:
|
||||
p = r["params"]
|
||||
row = " ".join(f"{p[k]:>{col_w}.4g}" for k in hdr_keys)
|
||||
print(f"{row} | {r['total_pnl']:>+12,.0f} {r['win_rate']:>5.1f}% "
|
||||
f"{r['total_trades']:>5} {r['pf']:>5.2f} {r['avg_hold']:>5.1f}분")
|
||||
|
||||
best = results[0]
|
||||
bp = best["params"]
|
||||
print(f"""
|
||||
╔══════════════════════════════════════════╗
|
||||
║ 🏆 1위 최적 파라미터 ║
|
||||
╠══════════════════════════════════════════╣""")
|
||||
# 결과 파라미터명 → DB 컬럼명 표시
|
||||
_disp_map = {
|
||||
"rsi_oversold": "SCALP_RSI_OVERSOLD",
|
||||
"sl_pct": "SCALP_STOP_LOSS_PCT (÷100)",
|
||||
"tp_pct": "SCALP_TAKE_PROFIT_PCT(÷100)",
|
||||
"drop_rate": "SCALP_MIN_DROP_RATE (÷100)",
|
||||
"trail_trigger": "(백테스트전용-미저장)",
|
||||
"trail_stop": "(백테스트전용-미저장)",
|
||||
"cooldown_min": "SCALP_COOLDOWN_SEC(÷60=분)",
|
||||
}
|
||||
for k, v in bp.items():
|
||||
label = _disp_map.get(k, k)
|
||||
print(f"║ {label:<30s} : {v!s:>6} ║")
|
||||
print(f"""╠══════════════════════════════════════════╣
|
||||
║ 총 손익 : {best['total_pnl']:>+12,.0f} 원 ║
|
||||
║ 승률 : {best['win_rate']:>6.1f}% ║
|
||||
║ 총 거래 : {best['total_trades']:>5} 건 ║
|
||||
║ Profit Factor : {best['pf']:>5.2f} ║
|
||||
║ 평균 보유 : {best['avg_hold']:>5.1f} 분 ║
|
||||
║ 최대 낙폭(MDD): {best['mdd']:>12,.0f} 원 ║
|
||||
╚══════════════════════════════════════════╝""")
|
||||
|
||||
# ── 각 결과에 순번·merged_params·db_snapshot 부여 (JSON 및 apply N 지원) ──
|
||||
top_list = []
|
||||
for idx, r in enumerate(results[:top_n]):
|
||||
p = r["params"]
|
||||
top_list.append({
|
||||
"rank": idx + 1,
|
||||
"params": p,
|
||||
"merged_params": p,
|
||||
"db_snapshot": _params_to_db_snapshot(p),
|
||||
"total_pnl": r["total_pnl"],
|
||||
"win_rate": r["win_rate"],
|
||||
"total_trades": r["total_trades"],
|
||||
"pf": r["pf"],
|
||||
"avg_hold": r["avg_hold"],
|
||||
"mdd": r["mdd"],
|
||||
})
|
||||
|
||||
# ── JSON 결과 저장 ──
|
||||
out_dir = os.path.join(ROOT, "backtest_scalping", "results")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_path = os.path.join(out_dir, f"search_{mode}_{ts}.json")
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"mode": mode,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"min_win_rate": min_win_rate,
|
||||
"top": top_list,
|
||||
}, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n💾 결과 저장: {out_path}")
|
||||
|
||||
# ── DB 자동 적용 ──
|
||||
if apply_rank is not None and apply_rank >= 1 and apply_rank <= len(top_list):
|
||||
_apply_to_db(top_list[apply_rank - 1]["merged_params"])
|
||||
print(f"✅ {apply_rank}번째 결과 적용 완료")
|
||||
|
||||
|
||||
def _apply_to_db(best_params: dict):
|
||||
"""1위 파라미터를 env_config DB에 자동 반영.
|
||||
trail_trigger / trail_stop / cooldown_min 은 env_config 컬럼이 없으므로 저장 제외.
|
||||
"""
|
||||
field_map = _get_scalp_field_map()
|
||||
|
||||
sets = []
|
||||
vals = []
|
||||
for param_k, val in best_params.items():
|
||||
if param_k not in field_map:
|
||||
continue
|
||||
db_col, fmt = field_map[param_k]
|
||||
sets.append(f"{db_col} = %s")
|
||||
vals.append(fmt(val))
|
||||
|
||||
if not sets:
|
||||
print("DB 적용할 파라미터가 없습니다.")
|
||||
return
|
||||
|
||||
db = TradeDB()
|
||||
db.conn.execute(f"UPDATE env_config SET {', '.join(sets)}", vals)
|
||||
db.conn.commit()
|
||||
db.close()
|
||||
print("\n✅ DB env_config 자동 적용 완료:")
|
||||
for param_k, val in best_params.items():
|
||||
if param_k in field_map:
|
||||
db_col, fmt = field_map[param_k]
|
||||
print(f" {db_col:<30s} = {fmt(val)}")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# CLI 진입점
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
|
||||
parser = argparse.ArgumentParser(description="스캘핑 백테스트 파라미터 Grid Search")
|
||||
parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD)")
|
||||
parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD)")
|
||||
parser.add_argument("--mode", default="coarse", choices=["coarse", "fine", "full"],
|
||||
help="탐색 모드: coarse(빠름) / fine(보통) / full(느림)")
|
||||
parser.add_argument("--top", default=20, type=int, help="상위 N개 출력")
|
||||
parser.add_argument("--min_trades", default=5, type=int, help="최소 거래 건수")
|
||||
parser.add_argument("--min_win_rate", default=MIN_WIN_RATE_DEFAULT, type=float, help="승률 하한 (%%). 이 이상만 손익순 1위")
|
||||
parser.add_argument("--apply", nargs="?", const=1, type=int, default=None, metavar="N",
|
||||
help="N번째 결과를 DB에 적용 (기본 1). --from-file 시 최근 JSON에서 적용")
|
||||
parser.add_argument("--from-file", action="store_true", help="--apply N 과 함께 사용 시, 최근 결과 JSON에서만 적용 (탐색 생략)")
|
||||
args = parser.parse_args()
|
||||
|
||||
run_search(
|
||||
start = args.start,
|
||||
end = args.end,
|
||||
mode = args.mode,
|
||||
top_n = args.top,
|
||||
min_trades = args.min_trades,
|
||||
min_win_rate = args.min_win_rate,
|
||||
apply_rank = args.apply,
|
||||
from_file_only = args.from_file,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
531
backtest_scalping/tail_param_search.py
Normal file
531
backtest_scalping/tail_param_search.py
Normal file
@@ -0,0 +1,531 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tail_param_search.py — 꼬리잡기 백테스트 파라미터 자동 탐색 (Grid Search)
|
||||
==========================================================================
|
||||
tail_engine을 직접 임포트해 run_tail_backtest 호출. 기본값은 DB(env_config) 단일 소스.
|
||||
실매매·백테스트·파라서치가 동일한 env 값을 사용해 결과 예측 가능.
|
||||
|
||||
실행:
|
||||
cd /home/hoon/kis_bot
|
||||
python3 backtest_scalping/tail_param_search.py
|
||||
|
||||
옵션:
|
||||
--start 시작일 (기본: 오늘-7일)
|
||||
--end 종료일 (기본: 오늘)
|
||||
--mode 탐색 모드: coarse(기본) / fine / full
|
||||
--top 상위 N개 출력 (기본: 20)
|
||||
--min_trades 최소 거래 건수 필터 (기본: 3)
|
||||
--min_win_rate 승률 하한 (기본: 52). 이 이상인 것만 손익순 정렬 후 1위. 없으면 차악(손익 1위) 적용.
|
||||
--apply [N] 1위(또는 N위) 결과를 DB에 적용. N 생략 시 1.
|
||||
--from-file --apply N 과 함께 사용 시, 최근 결과 JSON에서 N번째 적용 (탐색 생략).
|
||||
|
||||
예시:
|
||||
python3 backtest_scalping/tail_param_search.py --start 2026-03-01 --end 2026-03-06
|
||||
python3 backtest_scalping/tail_param_search.py --mode fine --apply
|
||||
python3 backtest_scalping/tail_param_search.py --apply 2 --from-file
|
||||
|
||||
참고:
|
||||
- 백테스트 엔진(tail_engine)에는 실매 전용 로직이 없음: 금액손실컷(MAX_LOSS_PER_TRADE_KRW),
|
||||
MIN_HOLD_AFTER_BUY_SEC, ATR 기반 손절/목표가(STOP_ATR_MULTIPLIER_TAIL 등) → 웹 백테·파라서치에도 없음.
|
||||
- full 모드 조합 수가 너무 크면 OOM으로 Killed 되므로, 그리드 값 개수를 제한함.
|
||||
"""
|
||||
MIN_WIN_RATE_DEFAULT = 52.0
|
||||
|
||||
import sys, os, json, time, argparse
|
||||
from datetime import datetime, timedelta
|
||||
from itertools import product
|
||||
from typing import Optional
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import logging
|
||||
logging.getLogger("TradeDB").setLevel(logging.WARNING) # 반복 초기화 로그 억제
|
||||
|
||||
from database import TradeDB
|
||||
import tail_engine as te
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 파라미터 그리드 (그리드 값은 % 단위 → 엔진 호출 시 비율로 변환)
|
||||
# 실매(kis_short_ver3)와 동일한 env를 경우의수에 포함해 진짜 백테스트에 가깝게 함.
|
||||
# time_start_hm / time_end_hm: DB 고정. 탐색 시 아래 주석 해제.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
GRIDS = {
|
||||
"coarse": {
|
||||
"min_drop_rate": [1.5, 2.0, 2.5, 3.0, 4.0],
|
||||
"min_recovery_ratio": [30, 40, 50, 60],
|
||||
"sl_pct": [1.5, 2.0, 3.0],
|
||||
"tp_pct": [3.0, 4.0, 5.0, 6.0],
|
||||
"tail_ratio_min": [1.0, 1.5, 2.0],
|
||||
"max_loss_per_trade_krw": [150000, 200000],
|
||||
"risk_pct_per_trade": [1.0, 2.0],
|
||||
# "time_start_hm": [900, 930],
|
||||
# "time_end_hm": [1430, 1500],
|
||||
},
|
||||
"fine": {
|
||||
"min_drop_rate": [1.5, 2.0, 2.5, 3.0, 4.0],
|
||||
"min_recovery_ratio": [30, 40, 50, 60],
|
||||
"sl_pct": [1.0, 1.5, 2.0, 2.5, 3.0],
|
||||
"tp_pct": [3.0, 4.0, 5.0, 6.0, 7.0],
|
||||
"tail_ratio_min": [0.8, 1.0, 1.5, 2.0],
|
||||
"shoulder_cut_pct": [2.0, 3.0, 4.0],
|
||||
"shoulder_min_high": [1.0, 1.2, 1.5],
|
||||
"tail_pct_min": [0.1, 0.15, 0.2],
|
||||
"max_rec_3m": [70, 80, 90],
|
||||
"high_chase_thr": [94, 96, 98],
|
||||
"max_loss_per_trade_krw": [150000, 200000],
|
||||
"risk_pct_per_trade": [1.0, 2.0],
|
||||
# "time_start_hm": [900, 930],
|
||||
# "time_end_hm": [1430, 1500],
|
||||
},
|
||||
# full: 조합 수 ~52만 (26만×2×2). MAX_LOSS 하드캡, RISK_PCT 엑셀레이터 테스트. rsi_period는 꼬리잡기용 DB 고정(그리드 제외). 1억 개에서 터진 거라 20~30만이면 10GB·10~20분 내 가능.
|
||||
"full": {
|
||||
"min_drop_rate": [1.5, 2.0, 2.5, 3.0, 4.0],
|
||||
"min_recovery_ratio": [30, 40, 50, 60, 70],
|
||||
"sl_pct": [1.0, 1.5, 2.0, 3.0],
|
||||
"tp_pct": [4.0, 5.0, 6.0, 8.0],
|
||||
"tail_ratio_min": [1.0, 1.5, 2.0],
|
||||
"shoulder_cut_pct": [2.0, 3.0, 4.0],
|
||||
"shoulder_min_high": [1.0, 1.2, 1.5],
|
||||
"tail_pct_min": [0.1, 0.15, 0.2],
|
||||
"max_rec_3m": [70, 80],
|
||||
"high_chase_thr": [94, 96],
|
||||
"rsi_threshold": [72, 78],
|
||||
"max_loss_per_trade_krw": [150000, 200000],
|
||||
"risk_pct_per_trade": [1.0, 2.0],
|
||||
},
|
||||
}
|
||||
|
||||
# % 단위 그리드 키 → 엔진은 비율(0~1) 사용
|
||||
_PCT_KEYS = frozenset({
|
||||
"min_drop_rate", "min_recovery_ratio", "max_rec_3m", "tail_pct_min",
|
||||
"sl_pct", "tp_pct", "shoulder_min_high", "shoulder_cut_pct", "high_chase_thr",
|
||||
"risk_pct_per_trade",
|
||||
})
|
||||
|
||||
# 포지션 사이징: 실매와 동일한 effective_slot 계산용 (백테 가정 자본·켈리)
|
||||
BACKTEST_CAPITAL = 100_000_000 # 1억 원 가정
|
||||
KELLY_MULTIPLIER_TAIL = 0.25
|
||||
|
||||
|
||||
def _get_fixed_defaults():
|
||||
"""고정값 = DB(env_config) 단일 소스. 엔진과 동일."""
|
||||
d = te.get_tail_defaults_from_db()
|
||||
d["slot_money"] = 1_000_000
|
||||
# 수수료/세금은 백테스트와 동일하게 (backtest_web _get_fee_defaults 기준)
|
||||
d["fee_rate"] = 0.00015 # 0.015%
|
||||
d["sell_tax"] = 0.0018 # 0.18%
|
||||
return d
|
||||
|
||||
|
||||
def _load_candles(start: str, end: str, rsi_period: int):
|
||||
"""ws_candles 3분봉 확정봉만 로드 (backtest_web과 동일)."""
|
||||
start_key = (start.replace("-", "") + "0000") if start else "20260101"
|
||||
end_key = (end.replace("-", "") + "2359") if end else "99991231"
|
||||
db = TradeDB()
|
||||
try:
|
||||
codes_raw = db.conn.execute(
|
||||
"SELECT DISTINCT code FROM ws_candles WHERE timeframe=3 "
|
||||
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
|
||||
[start_key, end_key]
|
||||
).fetchall()
|
||||
codes = [r["code"] for r in codes_raw]
|
||||
candles_by_code = {}
|
||||
for code in codes:
|
||||
rows = db.conn.execute(
|
||||
"SELECT candle_time, open, high, low, close, volume "
|
||||
"FROM ws_candles WHERE timeframe=3 AND code=%s "
|
||||
"AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1 "
|
||||
"ORDER BY candle_time ASC",
|
||||
[code, start_key, end_key]
|
||||
).fetchall()
|
||||
if len(rows) < rsi_period + 5:
|
||||
continue
|
||||
candles_by_code[code] = [dict(r) for r in rows]
|
||||
return candles_by_code
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _params_for_engine(fixed: dict, grid_keys: list, grid_vals: list) -> dict:
|
||||
"""고정값 + 그리드 조합 → 엔진용 params (비율 단위)."""
|
||||
params = dict(fixed)
|
||||
for k, v in zip(grid_keys, grid_vals):
|
||||
if k in _PCT_KEYS:
|
||||
params[k] = float(v) / 100.0
|
||||
else:
|
||||
params[k] = v
|
||||
# 엔진에 불필요한 키 제거 (slot_money, fee_rate, sell_tax는 백테 후처리용)
|
||||
for skip in ("slot_money", "fee_rate", "sell_tax"):
|
||||
params.pop(skip, None)
|
||||
return params
|
||||
|
||||
|
||||
def _summary_from_trades(all_trades: list, params: dict, fixed: dict, fee_rate: float, sell_tax: float) -> dict:
|
||||
"""all_trades에 pnl/hold_min 보강 후 요약 통계 반환 (backtest_web과 동일).
|
||||
params에 max_loss_per_trade_krw, risk_pct_per_trade, sl_pct가 있으면 실매와 동일한 effective_slot로 손익 계산."""
|
||||
def _t2dt(s):
|
||||
from datetime import datetime
|
||||
if isinstance(s, str) and len(s) >= 12:
|
||||
return datetime(int(s[:4]), int(s[4:6]), int(s[6:8]), int(s[8:10]), int(s[10:12]))
|
||||
return None
|
||||
# 포지션 크기: 실매와 동일 공식 적용 시 effective_slot, 아니면 고정 slot_money
|
||||
if ("max_loss_per_trade_krw" in params and "risk_pct_per_trade" in params and "sl_pct" in params
|
||||
and params.get("sl_pct", 0) > 0):
|
||||
sl_pct = float(params["sl_pct"])
|
||||
max_loss_krw = int(params["max_loss_per_trade_krw"])
|
||||
risk_pct = float(params["risk_pct_per_trade"])
|
||||
from_risk = BACKTEST_CAPITAL * risk_pct * KELLY_MULTIPLIER_TAIL / sl_pct
|
||||
from_cap = max_loss_krw / sl_pct
|
||||
effective_slot = min(from_risk, from_cap)
|
||||
else:
|
||||
effective_slot = fixed.get("slot_money", 1_000_000)
|
||||
for t in all_trades:
|
||||
qty = max(1, int(effective_slot / t["entry"]))
|
||||
fee = (t["entry"] + t["exit"]) * qty * fee_rate
|
||||
tax = t["exit"] * qty * sell_tax
|
||||
t["pnl"] = round((t["exit"] - t["entry"]) * qty - fee - tax)
|
||||
t["hold_min"] = round((_t2dt(t["exit_time"]) - _t2dt(t["entry_time"])).total_seconds() / 60, 1) if _t2dt(t["exit_time"]) and _t2dt(t["entry_time"]) else 0
|
||||
total = len(all_trades)
|
||||
if total == 0:
|
||||
return {"total_pnl": 0, "win_rate": 0, "total_trades": 0, "profit_factor": 0, "avg_hold_min": 0, "max_drawdown": 0}
|
||||
total_pnl = sum(t["pnl"] for t in all_trades)
|
||||
wins = [t for t in all_trades if t["pnl"] > 0]
|
||||
win_pnl = sum(t["pnl"] for t in wins)
|
||||
loss_pnl = sum(t["pnl"] for t in all_trades if t["pnl"] < 0)
|
||||
pf = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0
|
||||
cum = 0
|
||||
mdd = 0
|
||||
for t in all_trades:
|
||||
cum += t["pnl"]
|
||||
if cum > mdd:
|
||||
mdd = cum
|
||||
mdd = min(mdd, cum)
|
||||
mdd = abs(min(0, mdd))
|
||||
avg_hold = sum(t["hold_min"] for t in all_trades) / total
|
||||
return {
|
||||
"total_pnl": round(total_pnl),
|
||||
"win_rate": round(len(wins) / total * 100, 1),
|
||||
"total_trades": total,
|
||||
"profit_factor": pf,
|
||||
"avg_hold_min": round(avg_hold, 1),
|
||||
"max_drawdown": round(mdd),
|
||||
}
|
||||
|
||||
|
||||
def _apply_from_latest_json(rank: int):
|
||||
"""최근 tail_search_*.json에서 rank번째(1-based) 항목의 merged_params를 DB에 적용."""
|
||||
out_dir = os.path.join(ROOT, "backtest_scalping", "results")
|
||||
if not os.path.isdir(out_dir):
|
||||
print("⚠️ results 디렉터리가 없습니다.")
|
||||
return
|
||||
jsons = [f for f in os.listdir(out_dir) if f.startswith("tail_search_") and f.endswith(".json")]
|
||||
if not jsons:
|
||||
print("⚠️ tail_search_*.json 파일이 없습니다.")
|
||||
return
|
||||
jsons.sort(key=lambda f: os.path.getmtime(os.path.join(out_dir, f)), reverse=True)
|
||||
latest_path = os.path.join(out_dir, jsons[0])
|
||||
with open(latest_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
top = data.get("top") or []
|
||||
if rank < 1 or rank > len(top):
|
||||
print(f"⚠️ 순번 {rank}이(가) 유효하지 않습니다. (1~{len(top)})")
|
||||
return
|
||||
item = top[rank - 1]
|
||||
merged = item.get("merged_params")
|
||||
if not merged:
|
||||
print("⚠️ 해당 항목에 merged_params가 없습니다. (구 버전 JSON)")
|
||||
return
|
||||
print(f"📂 {latest_path} 에서 {rank}번째 적용합니다.")
|
||||
_apply_to_db(merged)
|
||||
|
||||
|
||||
def run_search(start: str, end: str, mode: str, top_n: int,
|
||||
min_trades: int, min_win_rate: float, apply_rank: Optional[int], from_file_only: bool):
|
||||
if from_file_only and apply_rank is not None and apply_rank >= 1:
|
||||
_apply_from_latest_json(apply_rank)
|
||||
return
|
||||
|
||||
grid = GRIDS[mode]
|
||||
keys = list(grid.keys())
|
||||
combos = list(product(*[grid[k] for k in keys]))
|
||||
total = len(combos)
|
||||
fixed = _get_fixed_defaults()
|
||||
fee_rate = fixed["fee_rate"]
|
||||
sell_tax = fixed["sell_tax"]
|
||||
rsi_period = fixed.get("rsi_period", 14)
|
||||
|
||||
print(f"\n[{mode.upper()} 모드 — 꼬리잡기] 탐색 조합: {total:,}개 | 기간: {start} ~ {end}")
|
||||
print(" 기본값: DB(env_config) tail_engine.get_tail_defaults_from_db()")
|
||||
print("=" * 70)
|
||||
|
||||
candles_by_code = _load_candles(start, end, rsi_period)
|
||||
if not candles_by_code:
|
||||
print("⚠️ 해당 기간 ws_candles 3분봉 데이터가 없습니다.")
|
||||
return
|
||||
|
||||
results = []
|
||||
t0 = time.time()
|
||||
for i, vals in enumerate(combos):
|
||||
params = _params_for_engine(fixed, keys, vals)
|
||||
all_trades = te.run_tail_backtest(candles_by_code, params)
|
||||
if len(all_trades) < min_trades:
|
||||
continue
|
||||
s = _summary_from_trades(all_trades, params, fixed, fee_rate, sell_tax)
|
||||
results.append({
|
||||
"params": dict(zip(keys, vals)),
|
||||
"total_pnl": s["total_pnl"],
|
||||
"win_rate": s["win_rate"],
|
||||
"total_trades": s["total_trades"],
|
||||
"pf": s["profit_factor"],
|
||||
"avg_hold": s["avg_hold_min"],
|
||||
"mdd": s["max_drawdown"],
|
||||
})
|
||||
if (i + 1) % 100 == 0:
|
||||
elapsed = time.time() - t0
|
||||
eta = elapsed / (i + 1) * (total - i - 1)
|
||||
print(f" 진행: {i+1:>5}/{total} 경과: {elapsed:>5.0f}s 남은: {eta:>5.0f}s", flush=True)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f"\n완료: {elapsed:.1f}초 | 유효 결과: {len(results)}건")
|
||||
|
||||
if not results:
|
||||
print("⚠️ 유효 조합을 찾지 못했습니다. 기간을 늘리거나 min_trades를 낮춰보세요.")
|
||||
return
|
||||
|
||||
results.sort(key=lambda x: x["total_pnl"], reverse=True)
|
||||
# 승률 min_win_rate 이상만 우선; 없으면 차악(손익 1위) 유지
|
||||
results_52 = [r for r in results if r["win_rate"] >= min_win_rate]
|
||||
if results_52:
|
||||
results = results_52
|
||||
results.sort(key=lambda x: x["total_pnl"], reverse=True)
|
||||
print(f"✅ 승률 {min_win_rate}% 이상 {len(results)}건 중 손익순으로 1위 선정")
|
||||
else:
|
||||
print(f"⚠️ 승률 {min_win_rate}% 이상 없음 → 차악(손익 1위) 적용")
|
||||
|
||||
# ── 결과 출력 ──
|
||||
hdr_keys = list(keys)
|
||||
col_w = max(len(k) for k in hdr_keys) + 2
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f" 🏆 수익 TOP {min(top_n, len(results))} (손익: 조합별 effective_slot·MAX_LOSS·RISK_PCT 적용)")
|
||||
print(f"{'='*70}")
|
||||
|
||||
hdr = " ".join(f"{k:>{col_w}}" for k in hdr_keys)
|
||||
print(f"{hdr} | {'손익(원)':>12} {'승률':>6} {'거래':>5} {'PF':>5} {'보유':>6}")
|
||||
print("-" * (len(hdr) + 55))
|
||||
|
||||
for r in results[:top_n]:
|
||||
p = r["params"]
|
||||
row = " ".join(f"{p[k]:>{col_w}.4g}" for k in hdr_keys)
|
||||
print(f"{row} | {r['total_pnl']:>+12,.0f} {r['win_rate']:>5.1f}% "
|
||||
f"{r['total_trades']:>5} {r['pf']:>5.2f} {r['avg_hold']:>5.1f}분")
|
||||
|
||||
best = results[0]
|
||||
bp = best["params"]
|
||||
# 적용 시 사용할 전체 파라미터 = 고정값(DB) + 그리드 1위
|
||||
merged = dict(fixed)
|
||||
for k, v in bp.items():
|
||||
merged[k] = v
|
||||
# 표시용: 그리드 키 + 결과에 영향 주는 고정 파라미터
|
||||
_tail_disp_map = {
|
||||
"min_drop_rate": "MIN_DROP_RATE (÷100)",
|
||||
"min_recovery_ratio": "MIN_RECOVERY_RATIO_SHORT(÷100)",
|
||||
"sl_pct": "STOP_LOSS_PCT (음수÷100)",
|
||||
"tp_pct": "TAKE_PROFIT_PCT (÷100)",
|
||||
"tail_ratio_min": "TAIL_RATIO_MIN",
|
||||
"tail_pct_min": "TAIL_PCT_MIN (÷100)",
|
||||
"shoulder_min_high": "SHOULDER_MIN_HIGH_PCT (÷100)",
|
||||
"shoulder_cut_pct": "SHOULDER_CUT_PCT (÷100)",
|
||||
"rsi_period": "RSI_PERIOD",
|
||||
"rsi_threshold": "RSI_OVERHEAT_THRESHOLD",
|
||||
"max_rec_3m": "MAX_RECOVERY_RATIO_3M (÷100)",
|
||||
"high_chase_thr": "HIGH_PRICE_CHASE_THRESHOLD(÷100)",
|
||||
"cooldown_min": "REENTRY_COOLDOWN_SEC (×60초)",
|
||||
"time_start_hm": "TIME_START / TAIL_TIME_START",
|
||||
"time_end_hm": "TIME_END / TAIL_TIME_END",
|
||||
"max_daily": "MAX_DAILY_TAIL / MAX_STOCKS",
|
||||
"max_loss_per_trade_krw": "MAX_LOSS_PER_TRADE_KRW (원)",
|
||||
"risk_pct_per_trade": "RISK_PCT_PER_TRADE (÷100)",
|
||||
}
|
||||
print(f"""
|
||||
╔══════════════════════════════════════════╗
|
||||
║ 🏆 꼬리잡기 최적 파라미터 ║
|
||||
╠══════════════════════════════════════════╣""")
|
||||
for k in keys:
|
||||
v = bp.get(k, merged.get(k))
|
||||
label = _tail_disp_map.get(k, k)
|
||||
print(f"║ {label:<34s} : {v!s:>6} ║")
|
||||
for k in ("rsi_period", "rsi_threshold", "max_rec_3m", "tail_pct_min", "shoulder_min_high",
|
||||
"high_chase_thr", "cooldown_min", "time_start_hm", "time_end_hm", "max_daily",
|
||||
"max_loss_per_trade_krw", "risk_pct_per_trade"):
|
||||
if k in merged and k not in keys:
|
||||
label = _tail_disp_map.get(k, k)
|
||||
print(f"║ {label:<34s} : {merged[k]!s:>6} ║")
|
||||
print(f"""╠══════════════════════════════════════════╣
|
||||
║ 총 손익 : {best['total_pnl']:>+12,.0f} 원 ║
|
||||
║ 승률 : {best['win_rate']:>6.1f}% ║
|
||||
║ 총 거래 : {best['total_trades']:>5} 건 ║
|
||||
║ Profit Factor : {best['pf']:>5.2f} ║
|
||||
║ 평균 보유 : {best['avg_hold']:>5.1f} 분 ║
|
||||
║ 최대 낙폭(MDD): {best['mdd']:>12,.0f} 원 ║
|
||||
╚══════════════════════════════════════════╝""")
|
||||
|
||||
# ── 각 결과에 순번·merged_params·db_snapshot 부여 (JSON 및 apply N 지원) ──
|
||||
top_list = []
|
||||
for idx, r in enumerate(results[:top_n]):
|
||||
merged_for_db = dict(fixed)
|
||||
for k, v in r["params"].items():
|
||||
merged_for_db[k] = (float(v) / 100.0) if k in _PCT_KEYS else v
|
||||
for skip in ("slot_money", "fee_rate", "sell_tax"):
|
||||
merged_for_db.pop(skip, None)
|
||||
top_list.append({
|
||||
"rank": idx + 1,
|
||||
"params": r["params"],
|
||||
"total_pnl": r["total_pnl"],
|
||||
"win_rate": r["win_rate"],
|
||||
"total_trades": r["total_trades"],
|
||||
"pf": r["pf"],
|
||||
"avg_hold": r["avg_hold"],
|
||||
"mdd": r["mdd"],
|
||||
"merged_params": merged_for_db,
|
||||
"db_snapshot": _merged_to_db_snapshot(merged_for_db),
|
||||
})
|
||||
|
||||
# ── JSON 저장 ──
|
||||
out_dir = os.path.join(ROOT, "backtest_scalping", "results")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_path = os.path.join(out_dir, f"tail_search_{mode}_{ts}.json")
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"mode": mode,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"min_win_rate": min_win_rate,
|
||||
"top": top_list,
|
||||
}, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n💾 결과 저장: {out_path}")
|
||||
|
||||
if apply_rank is not None and apply_rank >= 1 and apply_rank <= len(top_list):
|
||||
_apply_to_db(top_list[apply_rank - 1]["merged_params"])
|
||||
print(f"✅ {apply_rank}번째 결과 적용 완료")
|
||||
|
||||
|
||||
def _get_tail_field_map():
|
||||
"""꼬리잡기 파라미터 → env_config 컬럼 매핑 (apply / db_snapshot 공용)."""
|
||||
return {
|
||||
"min_drop_rate": ("MIN_DROP_RATE", lambda v: str(float(v))),
|
||||
"min_recovery_ratio": ("MIN_RECOVERY_RATIO_SHORT", lambda v: str(float(v))),
|
||||
"sl_pct": ("STOP_LOSS_PCT", lambda v: str(-abs(float(v)))),
|
||||
"tp_pct": ("TAKE_PROFIT_PCT", lambda v: str(float(v))),
|
||||
"tail_ratio_min": ("TAIL_RATIO_MIN", lambda v: str(float(v))),
|
||||
"tail_pct_min": ("TAIL_PCT_MIN", lambda v: str(float(v))),
|
||||
"shoulder_min_high": ("SHOULDER_MIN_HIGH_PCT", lambda v: str(float(v))),
|
||||
"shoulder_cut_pct": ("SHOULDER_CUT_PCT", lambda v: str(float(v))),
|
||||
"rsi_period": ("RSI_PERIOD", lambda v: str(int(v))),
|
||||
"rsi_threshold": ("RSI_OVERHEAT_THRESHOLD", lambda v: str(float(v))),
|
||||
"max_rec_3m": ("MAX_RECOVERY_RATIO_3M", lambda v: str(float(v))),
|
||||
"high_chase_thr": ("HIGH_PRICE_CHASE_THRESHOLD",lambda v: str(float(v))),
|
||||
"cooldown_min": ("REENTRY_COOLDOWN_SEC", lambda v: str(int(v) * 60)),
|
||||
"max_daily": ("MAX_STOCKS", lambda v: str(int(v))),
|
||||
"max_loss_per_trade_krw": ("MAX_LOSS_PER_TRADE_KRW", lambda v: str(int(v))),
|
||||
"risk_pct_per_trade": ("RISK_PCT_PER_TRADE", lambda v: str(float(v))),
|
||||
}
|
||||
|
||||
|
||||
def _merged_to_db_snapshot(merged_params: dict) -> dict:
|
||||
"""merged_params(비율 단위) → env_config 컬럼명:값 문자열 dict (JSON 저장용)."""
|
||||
field_map = _get_tail_field_map()
|
||||
if "time_start_hm" in merged_params:
|
||||
field_map = {**field_map, "time_start_hm": ("TIME_START", lambda v: str(int(v)))}
|
||||
if "time_end_hm" in merged_params:
|
||||
field_map = {**field_map, "time_end_hm": ("TIME_END", lambda v: str(int(v)))}
|
||||
return {
|
||||
db_col: fmt(merged_params[param_k])
|
||||
for param_k, (db_col, fmt) in field_map.items()
|
||||
if param_k in merged_params
|
||||
}
|
||||
|
||||
|
||||
def _apply_to_db(merged_params: dict):
|
||||
"""1위 파라미터(비율 단위)를 env_config DB에 자동 반영. 결과에 영향 주는 env 키 전부 반영."""
|
||||
field_map = _get_tail_field_map()
|
||||
if "time_start_hm" in merged_params:
|
||||
field_map["time_start_hm"] = ("TIME_START", lambda v: str(int(v)))
|
||||
if "time_end_hm" in merged_params:
|
||||
field_map["time_end_hm"] = ("TIME_END", lambda v: str(int(v)))
|
||||
|
||||
sets, vals = [], []
|
||||
for param_k, val in merged_params.items():
|
||||
if param_k not in field_map:
|
||||
continue
|
||||
db_col, fmt = field_map[param_k]
|
||||
sets.append(f"{db_col} = %s")
|
||||
vals.append(fmt(val))
|
||||
|
||||
if not sets:
|
||||
print("DB 적용할 파라미터가 없습니다.")
|
||||
return
|
||||
|
||||
db = TradeDB()
|
||||
try:
|
||||
db.conn.execute(f"UPDATE env_config SET {', '.join(sets)} WHERE id = (SELECT MAX(id) FROM env_config)", vals)
|
||||
db.conn.commit()
|
||||
except Exception as e:
|
||||
# 컬럼 없음 등으로 실패 시 일부만 적용
|
||||
print(f"⚠️ 전체 적용 실패: {e}. 적용 가능한 컬럼만 시도합니다.")
|
||||
for param_k, val in merged_params.items():
|
||||
if param_k not in field_map:
|
||||
continue
|
||||
db_col, fmt = field_map[param_k]
|
||||
try:
|
||||
db.conn.execute(f"UPDATE env_config SET {db_col} = %s WHERE id = (SELECT MAX(id) FROM env_config)", [fmt(val)])
|
||||
db.conn.commit()
|
||||
print(f" {db_col:<30s} = {fmt(val)}")
|
||||
except Exception:
|
||||
pass
|
||||
db.close()
|
||||
print("\n✅ DB env_config 자동 적용 완료:")
|
||||
for param_k, val in merged_params.items():
|
||||
if param_k in field_map:
|
||||
db_col, fmt = field_map[param_k]
|
||||
print(f" {db_col:<30s} = {fmt(val)}")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# CLI 진입점
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
|
||||
parser = argparse.ArgumentParser(description="꼬리잡기 백테스트 파라미터 Grid Search")
|
||||
parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD)")
|
||||
parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD)")
|
||||
parser.add_argument("--mode", default="coarse", choices=["coarse", "fine", "full"],
|
||||
help="탐색 모드")
|
||||
parser.add_argument("--top", default=20, type=int)
|
||||
parser.add_argument("--min_trades", default=3, type=int, help="최소 거래 건수")
|
||||
parser.add_argument("--min_win_rate", default=MIN_WIN_RATE_DEFAULT, type=float, help="승률 하한 (%%). 이 이상만 손익순 1위")
|
||||
parser.add_argument("--apply", nargs="?", const=1, type=int, default=None, metavar="N",
|
||||
help="N번째 결과를 DB에 적용 (기본 1). --from-file 과 함께 시 최근 JSON에서 적용")
|
||||
parser.add_argument("--from-file", action="store_true", help="--apply N 과 함께 사용 시, 최근 결과 JSON에서만 적용 (탐색 생략)")
|
||||
args = parser.parse_args()
|
||||
|
||||
run_search(
|
||||
start = args.start,
|
||||
end = args.end,
|
||||
mode = args.mode,
|
||||
top_n = args.top,
|
||||
min_trades = args.min_trades,
|
||||
min_win_rate = args.min_win_rate,
|
||||
apply_rank = args.apply,
|
||||
from_file_only = args.from_file,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user