한투 호가 = 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 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
160 lines
5.9 KiB
Python
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()
|