한투 호가 = 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 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
287 lines
11 KiB
Python
287 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
kis_trader/backtest/param_search_updow_us.py — UPDOW 해외(US) 종목 파라미터 Grid Search
|
||
======================================================================================
|
||
- 국내용 ``param_search_updow.py`` 와 동일 엔진(``run_param_search_updow``)
|
||
- **차이**: 영문 티커(QQQM 등), ``holding_min_candles`` 해외 분봉(KIS), **코스피 레짐(069500) 미사용**, 어깨 그리드는 국내와 동일
|
||
|
||
실행:
|
||
cd /home/hoon/kis_bot
|
||
python3 kis_trader/backtest/param_search_updow_us.py --symbol QQQM --start 2026-05-01 --end 2026-05-29 --tf 15
|
||
python3 -m kis_trader.backtest.param_search_updow_us --symbol QQQM --exchange NASD --mode fast --save-top
|
||
|
||
옵션:
|
||
--symbol 해외 티커 (필수, 예: QQQM). --code 와 동일
|
||
--exchange 거래소 (기본 NASD → KIS 시세 API는 NAS 로 변환)
|
||
--start / --end / --tf / --mode / --max-combos / --min_trades / --top / --out / --save-top
|
||
→ 의미는 국내 CLI 와 동일
|
||
|
||
탐색 규모(기본):
|
||
fast : US 전용 경량 그리드 = 2,880조합 (limit_atr_mult 0.0~1.5 포함, 레짐 OFF).
|
||
1회 백테 ≈ 0.25초(60분 400봉) → **약 10~15분**. 캡은 env UPDOW_US_FAST_MAX_COMBOS.
|
||
※ 저변동 지수 ETF는 깊은 지정가(ATR×1.5)가 거의 체결 안 되므로 낮은 limit_atr_mult 가 핵심.
|
||
full : 54,000조합 (≈ 수 시간) — 정밀 탐색용.
|
||
|
||
사전 준비:
|
||
웹 UPDOW 탭 또는 API 로 해당 심볼 분봉을 ``holding_min_candles`` 에 수집해 두세요.
|
||
(market_type=US, exchange=NASD, symbol=QQQM)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import logging
|
||
import os
|
||
import sys
|
||
from datetime import datetime, timedelta
|
||
from typing import Any, Dict, List, Tuple
|
||
|
||
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 # noqa: E402
|
||
ensure_legacy_root()
|
||
import holding_bot as hb # noqa: E402
|
||
from database import TradeDB # noqa: E402
|
||
from kis_trader.strategies import updow_buy as udb # noqa: E402
|
||
from kis_trader.strategies import updow_holding_cfg as uhc # noqa: E402
|
||
from kis_trader.strategies.updow_holding_cfg import ( # noqa: E402
|
||
ensure_updow_backtest_tables,
|
||
load_updow_engine_cfg,
|
||
set_updow_stock_config,
|
||
)
|
||
|
||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||
logger = logging.getLogger("param_search_updow_us")
|
||
|
||
|
||
def _db() -> TradeDB:
|
||
db = TradeDB()
|
||
ensure_updow_backtest_tables(db)
|
||
return db
|
||
|
||
|
||
def _results_dir() -> str:
|
||
d = os.path.join(HERE, "results")
|
||
os.makedirs(d, exist_ok=True)
|
||
return d
|
||
|
||
|
||
def _parse_symbol_exchange(args: argparse.Namespace) -> Tuple[str, str, str, str]:
|
||
"""(code, symbol, market_type, exchange) — DB·holding_min_candles 키는 code=symbol."""
|
||
raw = str(args.symbol or args.code or "").strip().upper()
|
||
if not raw:
|
||
raise ValueError("--symbol (또는 --code) 필수 (예: QQQM)")
|
||
ex_in = str(args.exchange or "NASD").strip().upper()
|
||
mt_in = str(args.market_type or "US").strip().upper()
|
||
mt, ex, sym = uhc.resolve_market_meta(raw, mt_in, ex_in, raw)
|
||
if mt != "US":
|
||
raise ValueError(
|
||
f"해외 전용 CLI 입니다. symbol={raw} → market_type={mt} (US 필요). "
|
||
"국내 6자리는 param_search_updow.py 를 사용하세요."
|
||
)
|
||
code = sym
|
||
return code, sym, mt, ex
|
||
|
||
|
||
def main(argv: List[str] | None = None) -> int:
|
||
from datetime import date, timedelta
|
||
from kis_trader.utils.kr_trading_day import clamp_to_prev_weekday
|
||
|
||
today = clamp_to_prev_weekday(date.today())
|
||
default_start = clamp_to_prev_weekday(
|
||
date.fromisoformat(today) - timedelta(days=365)
|
||
)
|
||
|
||
ap = argparse.ArgumentParser(
|
||
description="UPDOW 해외(US) 파라미터 그리드 탐색 (레짐 OFF, holding_min_candles)"
|
||
)
|
||
ap.add_argument("--symbol", default="", help="해외 티커 (예: QQQM)")
|
||
ap.add_argument("--code", default="", help="--symbol 과 동일 (호환)")
|
||
ap.add_argument("--exchange", default="NASD", help="거래소 코드 (NASD/NYSE/AMEX)")
|
||
ap.add_argument("--market-type", default="US", dest="market_type", help="시장 (기본 US)")
|
||
ap.add_argument("--start", default=default_start, help="시작일 YYYY-MM-DD (주말→이전 평일)")
|
||
ap.add_argument("--end", default=today, help="종료일 YYYY-MM-DD (주말→이전 평일)")
|
||
ap.add_argument("--tf", type=int, default=-1, help="분봉 (기본 -1 = env UPDOW_TF_MIN)")
|
||
ap.add_argument("--mode", choices=["fast", "full"], default="fast", help="fast|full")
|
||
ap.add_argument("--max-combos", type=int, default=-1, help="조합 상한 (0=무제한)")
|
||
ap.add_argument("--min_trades", type=int, default=1, help="최소 거래 건수")
|
||
ap.add_argument("--top", type=int, default=30, help="상위 N개 출력")
|
||
ap.add_argument("--out", default="", help="JSON 경로")
|
||
ap.add_argument("--save-top", action="store_true", help="1위 → updow_stock_config INSERT")
|
||
args = ap.parse_args(argv)
|
||
|
||
try:
|
||
code, symbol, market_type, exchange = _parse_symbol_exchange(args)
|
||
except ValueError as e:
|
||
logger.error("%s", e)
|
||
return 1
|
||
|
||
db = _db()
|
||
try:
|
||
latest = db.get_latest_env()
|
||
snap = dict(latest["snapshot"]) if latest else {}
|
||
tf_default = udb.read_updow_tf_min(snap)
|
||
tf = int(args.tf) if int(args.tf) >= 0 else tf_default
|
||
if tf not in hb.KIWOOM_MINUTE_TICS:
|
||
logger.error("tf=%s 는 허용 목록에 없습니다: %s", tf, hb.KIWOOM_MINUTE_TICS)
|
||
return 1
|
||
|
||
candles = hb.get_stored_min_candles(db, code, args.start, args.end, tf_min=tf)
|
||
if not candles:
|
||
logger.error(
|
||
"%s(%s) %s분봉 없음 (%s ~ %s).\n"
|
||
" → 웹 UPDOW 탭에서 [US:%s] %s 분봉 수집 후 재시도.",
|
||
symbol,
|
||
exchange,
|
||
tf,
|
||
args.start,
|
||
args.end,
|
||
exchange,
|
||
symbol,
|
||
)
|
||
return 2
|
||
if len(candles) < 5:
|
||
logger.error("봉 부족: %s개 (최소 5)", len(candles))
|
||
return 3
|
||
|
||
base_cfg = udb.cfg_from_env_snapshot(snap)
|
||
base_cfg = load_updow_engine_cfg(db, code, base_cfg)
|
||
# 해외: 코스피 레짐(069500) 미적용 — 종목 설정에 60이 있어도 탐색 베이스는 OFF
|
||
base_cfg["regime_ma_bars"] = 0.0
|
||
base_cfg["regime_ma_ease_pct"] = 0.0
|
||
|
||
mode = str(args.mode or "fast").strip().lower()
|
||
if mode == "full":
|
||
grid = udb.default_param_grid_us(snap)
|
||
max_combos = 0 if args.max_combos < 0 else max(0, int(args.max_combos))
|
||
else:
|
||
grid = udb.default_param_grid_us_fast(snap)
|
||
# fast 기본 캡 = env UPDOW_US_FAST_MAX_COMBOS (기본 2880 ≈ 10~15분)
|
||
max_combos = (
|
||
udb.read_us_fast_max_combos(snap)
|
||
if args.max_combos < 0
|
||
else max(0, int(args.max_combos))
|
||
)
|
||
|
||
results, meta = udb.run_param_search_updow(
|
||
candles,
|
||
grid=grid,
|
||
min_trades=max(0, int(args.min_trades)),
|
||
base_cfg=base_cfg,
|
||
env_snapshot=snap,
|
||
regime_candles=None,
|
||
stock_tf_min=tf,
|
||
max_combos=max_combos,
|
||
)
|
||
|
||
meta["market_type"] = market_type
|
||
meta["exchange"] = exchange
|
||
meta["symbol"] = symbol
|
||
meta["regime_proxy_code"] = None
|
||
meta["regime_1min_bars_loaded"] = 0
|
||
meta["regime_note"] = "US: KOSPI proxy regime disabled"
|
||
meta["search_mode"] = mode
|
||
meta["combo_cap"] = max_combos
|
||
meta["tf"] = tf
|
||
meta["code"] = code
|
||
meta["start"] = args.start
|
||
meta["end"] = args.end
|
||
meta["candle_count"] = len(candles)
|
||
|
||
top_n = max(1, int(args.top))
|
||
top = results[:top_n]
|
||
|
||
logger.info(
|
||
"=== UPDOW US param search %s [%s:%s] tf=%s 봉=%s ===\n"
|
||
"그리드 조합=%s 백테=%s 통과=%s (min_trades=%s, 레짐=OFF)",
|
||
symbol,
|
||
market_type,
|
||
exchange,
|
||
tf,
|
||
len(candles),
|
||
meta.get("cartesian_product"),
|
||
meta.get("backtests_run"),
|
||
meta.get("passed"),
|
||
args.min_trades,
|
||
)
|
||
hints = meta.get("grid_axis_hints") or {}
|
||
if hints:
|
||
logger.info("--- 그리드 축 ---")
|
||
for k, txt in hints.items():
|
||
logger.info(" [%s] %s", k, txt)
|
||
|
||
for i, row in enumerate(top, 1):
|
||
p = row.get("params") or {}
|
||
logger.info(
|
||
"#%s pnl=%s win=%s%% trades=%s pf=%s hold=%s params=%s",
|
||
i,
|
||
row.get("total_pnl"),
|
||
row.get("win_rate"),
|
||
row.get("total_trades"),
|
||
row.get("pf"),
|
||
row.get("avg_hold"),
|
||
p,
|
||
)
|
||
|
||
if args.save_top:
|
||
if not results:
|
||
logger.error("--save-top 지정했으나 통과한 결과가 없습니다.")
|
||
return 4
|
||
ac = results[0].get("apply_cfg") or {}
|
||
ac["regime_ma_bars"] = 0.0
|
||
ac["regime_ma_ease_pct"] = 0.0
|
||
nm = symbol
|
||
m = uhc.get_updow_stock_meta(db, code)
|
||
if m and m.get("name"):
|
||
nm = str(m["name"]).strip() or symbol
|
||
set_updow_stock_config(
|
||
db,
|
||
code,
|
||
nm,
|
||
ac,
|
||
tf_min=tf,
|
||
market_type=market_type,
|
||
exchange=exchange,
|
||
symbol=symbol,
|
||
)
|
||
logger.info(
|
||
"updow_stock_config 저장 (%s US:%s tf=%s max_hold=%s)",
|
||
code,
|
||
exchange,
|
||
tf,
|
||
int(float(ac.get("max_hold_bars", 16))),
|
||
)
|
||
|
||
payload: Dict[str, Any] = {
|
||
"code": code,
|
||
"symbol": symbol,
|
||
"market_type": market_type,
|
||
"exchange": exchange,
|
||
"tf": tf,
|
||
"start": args.start,
|
||
"end": args.end,
|
||
"meta": meta,
|
||
"top": top,
|
||
}
|
||
out_path = args.out.strip()
|
||
if not out_path:
|
||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
out_path = os.path.join(
|
||
_results_dir(),
|
||
f"updow_param_us_{symbol}_{tf}m_{ts}.json",
|
||
)
|
||
with open(out_path, "w", encoding="utf-8") as f:
|
||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||
logger.info("저장: %s", out_path)
|
||
return 0
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|