Files
kis_bot/kis_trader/backtest/dbband_param_search.py
Your Name 0ecac7cb95 이번에 들어간 내용
한투 호가 = 2번째 앱키 전용
키 없거나 start 실패 시 메인에 H0STASP0 안 붙임. 운영설정 WS_ORDERBOOK_SAVE_KIS 빨간 danger.

LS RAM 합집합
후보∪보유∪영구∪grace. sync_targets와 split reconcile 둘 다. 틱 DB 영구 게이트는 그대로.

분봉 쓰레기 → 다음 소스 봉 통째
그 분 틱 0건이거나 전부 봉끝 대비 LIVE_FEED_FALLBACK_MAX_AGE_SEC 초과면 구멍. 메인 WS → 2차 → LS → REST → rollup. CANDLE_GARBAGE_FALLBACK 기본 true.

파일: feed_fallback.py(신규), ws_manager.py, kis_ws.py, candle_series.py, bt_candle_source.py, live_config_schema.py, database.py, 스모크, MD 2개.

같은 ws_manager/database/kis_ws/live_config에는 직전 커밋 이후 쌓여 있던 시세 폴백·ENV 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
2026-08-19 22:11:31 +09:00

160 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""
kis_trader/backtest/dbband_param_search.py — DBBAND 종목별 Grid Search
======================================================================
실행:
python3 -m kis_trader.backtest.dbband_param_search --symbol QQQM --start 2026-05-01 --end 2026-05-29 --tf 15
python3 -m kis_trader.backtest.dbband_param_search --symbol 069500 --tf 15
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime, timedelta
from itertools import product
from typing import Any, Dict, List, Optional
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)
from kis_trader.utils.legacy_root import ensure_legacy_root
ensure_legacy_root()
import holding_bot as hb
from database import TradeDB
from kis_trader.backtest import dbband_backtest_common as bbc
from kis_trader.engine import dbband_engine as bbe
from kis_trader.engine.dbband_env_keys import params_to_dbband_env_patch
from kis_trader.strategies import dbband_stock_cfg as dsc
def _results_dir() -> str:
d = os.path.join(HERE, "results")
os.makedirs(d, exist_ok=True)
return d
def _grid_axes(snap: Dict[str, Any]) -> Dict[str, List[Any]]:
def _f(k: str, default: str) -> List[float]:
raw = str(snap.get(k) or default)
return [float(x.strip()) for x in raw.split(",") if x.strip()]
return {
"bb_inner_std": _f("DBBAND_GRID_INNER_STD0", "2,2.5") or [2.0, 2.5],
"bb_outer_std": _f("DBBAND_GRID_OUTER_STD0", "2.5,3,3.5") or [2.5, 3.0, 3.5],
"trend_ma_period": [int(x) for x in _f("DBBAND_GRID_TREND_MA0", "100,120,150,200") or [100, 120, 150, 200]],
"stop_loss_pct": _f("DBBAND_GRID_SL0", "1.5,2,2.5") or [1.5, 2.0, 2.5],
"tp_mode": ["opposite_band", "rr"],
"rr_ratio": _f("DBBAND_GRID_RR0", "1.5,2") or [1.5, 2.0],
}
def run_search_for_code(
db: Any,
code: str,
start_date: str,
end_date: str,
tf: int,
*,
base_cfg: Optional[Dict[str, Any]] = None,
top_n: int = 30,
) -> Dict[str, Any]:
dsc.ensure_dbband_backtest_tables(db)
base = dict(base_cfg or bbe.get_dbband_defaults_from_db(db))
snap = db.get_merged_env_snapshot() if hasattr(db, "get_merged_env_snapshot") else {}
raw_candles = hb.get_stored_min_candles(db, code, start_date, end_date, tf_min=int(tf))
candles = bbc.normalize_stored_min_candles(raw_candles)
if not candles:
return {"error": f"{code} {tf}분봉 없음 — holding_min_candles 수집 필요", "results": []}
fee_rate = float(snap.get("FEE_RATE_PCT") or 0.015) / 100
sell_tax = float(snap.get("SELL_TAX_RATE_PCT") or 0.18) / 100
slot = float(base.get("slot_money") or snap.get("DBBAND_SLOT_MONEY") or 3_000_000)
axes = _grid_axes(snap)
combos: List[Dict[str, Any]] = []
for inn, out, ma, sl, tm, rr in product(
axes["bb_inner_std"],
axes["bb_outer_std"],
axes["trend_ma_period"],
axes["stop_loss_pct"],
axes["tp_mode"],
axes["rr_ratio"],
):
p = dict(base)
p["bb_inner_std"] = inn
p["bb_outer_std"] = out
p["trend_ma_period"] = ma
p["sl_pct"] = float(sl) / 100.0
p["stop_loss_pct"] = float(sl)
p["tp_mode"] = tm
p["rr_ratio"] = rr
combos.append(p)
from kis_trader.backtest.backtest_portfolio_common import backtest_slip_pct
results: List[Dict[str, Any]] = []
for params in combos:
trades = bbe.run_dbband_backtest_single(candles, params)
bbc.attach_dbband_trade_pnl(
trades, slot_money=slot, fee_rate=fee_rate, sell_tax=sell_tax,
slip_pct=backtest_slip_pct(params),
)
total_pnl = sum(int(t.get("pnl") or 0) for t in trades)
wins = sum(1 for t in trades if int(t.get("pnl") or 0) > 0)
n = len(trades)
results.append({
"params": params,
"apply_cfg": params_to_dbband_env_patch(params),
"total_trades": n,
"win_rate": round(wins / n * 100, 1) if n else 0.0,
"total_pnl": total_pnl,
})
results.sort(key=lambda x: (x["total_pnl"], x["win_rate"]), reverse=True)
return {
"code": code,
"start": start_date,
"end": end_date,
"timeframe": int(tf),
"tested_combos": len(combos),
"candle_count": len(candles),
"results": results[:top_n],
}
def main() -> None:
today = datetime.now().date()
parser = argparse.ArgumentParser(description="DBBAND 종목별 파라미터 탐색")
parser.add_argument("--symbol", "--code", dest="code", required=True)
parser.add_argument("--start", default=(today - timedelta(days=30)).isoformat())
parser.add_argument("--end", default=today.isoformat())
parser.add_argument("--tf", type=int, default=15)
parser.add_argument("--top", type=int, default=50)
parser.add_argument("--save-top", action="store_true", help="1위를 dbband_stock_config에 저장")
args = parser.parse_args()
db = TradeDB()
try:
dsc.ensure_dbband_backtest_tables(db)
base = dsc.load_dbband_engine_cfg(db, args.code, bbe.get_dbband_defaults_from_db(db))
out = run_search_for_code(
db, args.code, args.start, args.end, args.tf, base_cfg=base, top_n=args.top,
)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
sym = str(args.code).upper()
path = os.path.join(_results_dir(), f"search_dbband_{sym}_{ts}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(out, f, ensure_ascii=False, indent=2)
print(f"saved {path}")
if args.save_top and out.get("results"):
top = out["results"][0]["params"]
dsc.set_dbband_stock_config(db, sym, sym, top, tf_min=args.tf)
print(f"applied top-1 → dbband_stock_config ({sym})")
finally:
db.close()
if __name__ == "__main__":
main()