Files
kis_bot/kis_trader/backtest/param_search_updown_box.py
Your Name fc27e726f9 feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes):

구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함.

스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함.

코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함.

시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함.

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
2026-07-17 01:09:09 +09:00

277 lines
12 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/param_search_updown_box.py — UPDOWN 박스권 글로벌 파라미터 Grid Search
========================================================================================================
[전략 = UPDOWN 박스권(횡보 하단 되돌림 매수)]
- 엔진: ``kis_trader.engine.updown_box`` (판별 evaluate_box · 진입 box_entry_signal ·
청산 eval_box_exit_at_price · 백테 run_backtest_box) — **실매와 100% 동일 단일 소스**.
- 파라미터는 **글로벌**(env_config ``UPDOWN_BOX_*``) — 종목별 행 없음. 여러 종목을 같은
파라미터로 돌려 **합산 성과**가 가장 좋은 조합을 찾는다(실매 운영과 정합).
- 분봉 데이터: ``holding_min_candles`` (``holding_bot.get_stored_min_candles``) — 기본 15분.
[관련 CLI]
UPDOW (구 하락매수): kis_trader/backtest/param_search_updow.py
UPDOWN 박스권 : kis_trader/backtest/param_search_updown_box.py ← 이 파일
실행:
cd /home/hoon/kis_bot
# 종목 직접 지정
python3 kis_trader/backtest/param_search_updown_box.py --codes 005930,000660,035720 --tf 15 \
--start 2026-01-01 --end 2026-06-30 --mode fast
# watchlist active 종목 자동 사용
python3 -m kis_trader.backtest.param_search_updown_box --from-watchlist --tf 15 --mode full --top 30
# 1위 → env_config UPDOWN_BOX_* 적용
python3 kis_trader/backtest/param_search_updown_box.py --codes 005930 --tf 15 --apply 1
옵션:
--codes 쉼표 구분 6자리 종목코드 (--from-watchlist 와 택일)
--from-watchlist updown_watchlist active 종목 자동 사용
--start/--end 기간 (기본 최근 365일)
--tf 분봉 (기본 env UPDOWN_SCAN_TF_MIN=15)
--mode fast(핵심4축) | full (기본 fast)
--max-combos 조합 상한 (기본 fast=800, full=0=무제한)
--min-trades 합산 최소 거래 수 필터 (기본 3)
--top 상위 N개 출력·저장 (기본 30)
--out 결과 JSON 경로 (선택)
--apply N N번째 결과를 env_config UPDOWN_BOX_* 에 적용 (총손익>0 일 때만)
--allow-non-positive-pnl total_pnl≤0 이어도 적용
"""
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, 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)
import holding_bot as hb # noqa: E402
from database import TradeDB # noqa: E402
from kis_trader.engine import updown_box as bx # noqa: E402
logging.basicConfig(level=logging.INFO, format="%(message)s")
logging.getLogger("TradeDB").setLevel(logging.WARNING)
logger = logging.getLogger("param_search_updown_box")
def _db() -> TradeDB:
return TradeDB()
def _results_dir() -> str:
d = os.path.join(HERE, "results")
os.makedirs(d, exist_ok=True)
return d
def _resolve_codes(db: TradeDB, args) -> List[str]:
"""--codes 또는 --from-watchlist 로 종목 리스트 해석."""
if args.from_watchlist:
try:
from kis_trader.strategies.updown_watchlist import (
ensure_updown_watchlist_table,
list_active_watchlist,
)
ensure_updown_watchlist_table(db)
rows = list_active_watchlist(db)
codes = [str(r.get("code") or "").strip() for r in rows]
return [c for c in codes if len(c) == 6 and c.isdigit()]
except Exception as e:
logger.error("watchlist 조회 실패: %s", e)
return []
raw = str(args.codes or "").strip()
out: List[str] = []
for part in raw.replace(" ", "").split(","):
if len(part) == 6 and part.isdigit():
out.append(part)
return out
def _apply_env_patch(db: TradeDB, patch: Dict[str, Any]) -> int:
"""UPDOWN_BOX_* env_config 패치 INSERT (최신 스냅샷에 머지)."""
try:
snap = db.get_merged_env_snapshot() or {}
for k, v in patch.items():
snap[k] = str(v)
eid = db.insert_env_snapshot(snap)
if eid is None:
logger.error("env_config INSERT 실패")
return 8
logger.info("✅ env_config INSERT id=%s keys=%s", eid, list(patch.keys()))
return 0
except Exception as e:
logger.error("env 적용 실패: %s", e)
return 8
def main(argv: Optional[List[str]] = None) -> int:
from kis_trader.backtest.param_search_dates import resolve_param_search_range
default_start, today = resolve_param_search_range("UPDOWN", lookback_days=365)
ap = argparse.ArgumentParser(description="UPDOWN 박스권 글로벌 파라미터 그리드 탐색")
ap.add_argument("--codes", default="", help="쉼표 구분 6자리 종목코드")
ap.add_argument("--from-watchlist", action="store_true", help="watchlist active 종목 사용")
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="분봉 (기본 env UPDOWN_SCAN_TF_MIN=15)")
ap.add_argument("--mode", choices=["fast", "full"], default="fast")
ap.add_argument("--max-combos", type=int, default=-1, help="조합 상한 (0=무제한)")
ap.add_argument("--min-trades", type=int, default=3, help="합산 최소 거래 수 필터")
ap.add_argument("--rank-by", choices=["alpha", "pnl"], default="alpha",
help="정렬 기준 alpha=B&H초과수익(기본) | pnl=절대손익")
ap.add_argument("--keep-negative-alpha", action="store_true",
help="기본값(미지정)은 base_cfg 기준 α≤0 종목(추세주)을 universe에서 제외")
ap.add_argument("--top", type=int, default=30, help="상위 N개 출력·저장")
ap.add_argument("--out", default="", help="결과 JSON 경로")
ap.add_argument("--apply", nargs="?", const=1, type=int, default=None, metavar="N",
help="N번째 결과를 env_config UPDOWN_BOX_* 에 적용")
ap.add_argument("--allow-non-positive-pnl", action="store_true",
help="total_pnl≤0 이어도 적용")
args = ap.parse_args(argv)
db = _db()
try:
codes = _resolve_codes(db, args)
if not codes:
logger.error("종목 없음 — --codes 005930,000660 또는 --from-watchlist 지정")
return 1
if int(args.tf) >= 0:
tf = int(args.tf)
else:
from kis_trader.utils.env import get_env_int
tf = get_env_int("UPDOWN_SCAN_TF_MIN", 15)
if tf not in hb.KIWOOM_MINUTE_TICS:
logger.error("tf=%s 허용 목록 아님: %s", tf, hb.KIWOOM_MINUTE_TICS)
return 1
base_cfg = bx.get_box_cfg_from_env()
candles_by_code: Dict[str, List[Dict[str, Any]]] = {}
for code in codes:
cs = hb.get_stored_min_candles(db, code, args.start, args.end, tf_min=tf)
if cs and len(cs) >= int(base_cfg.get("min_bars", 20)) + 2:
candles_by_code[code] = cs
else:
logger.warning("⚠️ %s 분봉 부족(%s개) → 제외", code, len(cs) if cs else 0)
if not candles_by_code:
logger.error("백테 가능한 종목 0 (%s분봉 수집 필요: %s ~ %s)", tf, args.start, args.end)
return 2
# === 추세종목(α≤0) 자동 제외 ===
# 박스전략은 "그냥 들고 있는 것(B&H)"보다 나아야 의미가 있다. base_cfg 기준으로
# 각 종목 백테 → 봇 총손익 ≤ 단순보유 인 종목은 박스권 대상이 아니므로 universe 에서 뺀다.
# (--keep-negative-alpha 로 끌 수 있음)
if not args.keep_negative_alpha:
kept: Dict[str, List[Dict[str, Any]]] = {}
for code, cs in candles_by_code.items():
bt = bx.run_backtest_box(cs, base_cfg)
a = float(bt.get("alpha_pct", bt.get("total_pnl", 0)) or 0)
bh = float(bt.get("buy_hold_pct", 0) or 0)
if a > 0:
kept[code] = cs
logger.info("%s 채택 (봇 %.2f%% vs B&H %.2f%%α=%.2f%%)",
code, float(bt.get("total_pnl", 0)), bh, a)
else:
logger.warning("%s 제외 — 추세주 (봇 %.2f%% ≤ B&H %.2f%%α=%.2f%%)",
code, float(bt.get("total_pnl", 0)), bh, a)
if not kept:
logger.error("α>0 종목 0 — 박스전략 적합 종목이 없습니다 (--keep-negative-alpha 로 강제 가능)")
return 2
candles_by_code = kept
mode = str(args.mode or "fast").strip().lower()
grid = bx.default_box_param_grid(mode)
if mode == "full":
max_combos = 0 if args.max_combos < 0 else max(0, int(args.max_combos))
else:
max_combos = 800 if args.max_combos < 0 else max(0, int(args.max_combos))
results, meta = bx.run_param_search_box_multi(
candles_by_code,
grid=grid,
base_cfg=base_cfg,
min_trades_total=max(0, int(args.min_trades)),
max_combos=max_combos,
rank_by=str(args.rank_by),
)
meta["search_mode"] = mode
meta["tf"] = tf
meta["start"] = args.start
meta["end"] = args.end
logger.info(
"=== UPDOWN 박스권 param search tf=%s 종목=%d ===\n"
"그리드 조합=%s 백테=%s 통과=%s (min_trades=%s)",
tf, len(candles_by_code),
meta.get("cartesian_product"), meta.get("backtests_run"),
meta.get("passed"), args.min_trades,
)
logger.info("--- 그리드 축 ---")
for k, vals in (meta.get("grid_axes") or {}).items():
logger.info(" [%s] %s", k, vals)
logger.info("정렬기준=%s (alpha=B&H초과수익)", meta.get("rank_by"))
top_n = max(1, int(args.top))
top = results[:top_n]
for i, row in enumerate(top, 1):
logger.info(
"#%s α=%s%% pnl=%s%% (B&H %s%%) pf=%s win=%s%% trades=%s cfg=%s",
i, row.get("alpha_pct"), row.get("total_pnl"), row.get("buy_hold_pct"),
row.get("pf"), row.get("win_rate"), row.get("n_trades"), row.get("apply_cfg"),
)
payload: Dict[str, Any] = {
"strategy": "UPDOWN_BOX",
"codes": list(candles_by_code.keys()),
"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"updown_box_param_{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)
if args.apply is not None:
rank = max(1, int(args.apply))
if rank > len(top):
logger.error("순번 %s 유효하지 않음 (1~%s)", rank, len(top))
return 5
row = top[rank - 1]
# alpha 정렬이면 α(B&H 초과수익)>0, pnl 정렬이면 절대손익>0 을 적용 가드로 사용
if str(args.rank_by) == "alpha":
guard = float(row.get("alpha_pct") or 0)
gname = "alpha"
else:
guard = float(row.get("total_pnl") or 0)
gname = "total_pnl"
if guard <= 0 and not args.allow_non_positive_pnl:
logger.warning("%s=%s ≤ 0 → 미적용 (--allow-non-positive-pnl 로 강제)", gname, guard)
return 6
patch = bx.box_cfg_to_env_patch(row.get("apply_cfg") or {})
if not patch:
logger.error("apply_cfg 비어 있음")
return 7
return _apply_env_patch(db, patch)
return 0
finally:
db.close()
if __name__ == "__main__":
raise SystemExit(main())