Files
kis_bot/kis_holding_ver1.py
2026-03-17 12:33:30 +09:00

484 lines
23 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_holding_ver1.py — 홀딩 전략 V1 (RSI 3단계 분할매수 · 횡보장 특화)
=======================================================================
holding_bot.py(추세추종)의 대응 버전. 추세 판단 없이 RSI만으로 진입,
가격이 더 빠질수록 비중을 늘려가는 전통적 '물타기(분할매수)' 전략.
전략 개요
---------
진입 (분할매수):
· RSI ≤ rsi_buy1 → 1단계 매수 (slot_money × buy1_ratio)
· 보유 중 RSI ≤ rsi_buy2 → 2단계 추가매수 (× buy2_ratio)
· 보유 중 RSI ≤ rsi_buy3 → 3단계 추가매수 (× buy3_ratio)
· 낙폭 필터(ath/year/w52)로 "충분히 싼 구간"에만 진입 가능
청산:
· 평단가 대비 +take_profit_pct% → 익절
· RSI ≥ rsi_sell → 과열 청산
· 평단가 대비 stop_loss_pct% → 손절
holding_bot.py와 동일한 DB 테이블(holding_candles, holding_stock_config) 사용.
실행 예시:
python3 kis_holding_ver1.py --code 005930 \\
--start 2024-01-01 --end 2026-03-06
"""
import sys, os, json, argparse
from datetime import date as _date, timedelta as _td, datetime
from typing import List, Dict, Optional
ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT)
import logging
logger = logging.getLogger(__name__)
# holding_bot.py와 DB/캔들 공통 함수 공유
from holding_bot import (
ensure_holding_tables, get_stored_candles,
_rsi_series,
)
from database import TradeDB
# ─────────────────────────────────────────────────────────────────────────────
# 기본 파라미터 (V1 전용 — MA/추세 관련 파라미터 없음)
# ─────────────────────────────────────────────────────────────────────────────
DEFAULT_V1_CONFIG: Dict = {
"rsi_period": 14.0,
# ── 진입 RSI 임계값 (rsi_buy1 > rsi_buy2 > rsi_buy3 이어야 함) ──────────
"rsi_buy1": 50.0, # 1단계: RSI ≤ 이 값 → 처음 매수
"rsi_buy2": 40.0, # 2단계: RSI ≤ 이 값 → 추가매수
"rsi_buy3": 30.0, # 3단계: RSI ≤ 이 값 → 최종 추가매수
# ── 청산 기준 ────────────────────────────────────────────────────────────
"rsi_sell": 75.0, # RSI 과열 청산
"take_profit_pct": 15.0, # 평단가 대비 익절 %
"stop_loss_pct": 10.0, # 평단가 대비 손절 %
# ── 투자금 / 분할 비율 ────────────────────────────────────────────────────
"slot_money": 3_000_000.0,
"buy1_ratio": 0.4, # 1단계 투자금 비율 (40%)
"buy2_ratio": 0.35, # 2단계 투자금 비율 (35%)
"buy3_ratio": 0.25, # 3단계 투자금 비율 (25%)
# ── 비용 ────────────────────────────────────────────────────────────────
"fee_rate": 0.0015, # 수수료율 (편도)
"sell_tax": 0.0018, # 증권거래세
# ── 낙폭 필터 (0=비활성) ─────────────────────────────────────────────────
"ath_drop_min_pct": 0.0, # 역대 최고가 대비 최소 낙폭 %
"year_drop_min_pct": 0.0, # 당해연도 고점 대비 최소 낙폭 %
"w52_drop_min_pct": 0.0, # 52주 고점 대비 최소 낙폭 %
}
# ─────────────────────────────────────────────────────────────────────────────
# 백테스트 엔진
# ─────────────────────────────────────────────────────────────────────────────
def run_backtest_v1(candles: List[Dict], cfg: Dict) -> Dict:
"""
V1 RSI 분할매수 전략 백테스트.
실행가: 신호봉 다음 봉 시가 (1봉 지연, 실매매와 동일)
수수료: 편도 fee_rate × 2 + 매도 시 sell_tax
"""
rsi_period = int(cfg.get("rsi_period", DEFAULT_V1_CONFIG["rsi_period"]))
rsi_buy1 = float(cfg.get("rsi_buy1", DEFAULT_V1_CONFIG["rsi_buy1"]))
rsi_buy2 = float(cfg.get("rsi_buy2", DEFAULT_V1_CONFIG["rsi_buy2"]))
rsi_buy3 = float(cfg.get("rsi_buy3", DEFAULT_V1_CONFIG["rsi_buy3"]))
rsi_sell = float(cfg.get("rsi_sell", DEFAULT_V1_CONFIG["rsi_sell"]))
tp_pct = float(cfg.get("take_profit_pct",DEFAULT_V1_CONFIG["take_profit_pct"]))
sl_pct = float(cfg.get("stop_loss_pct", DEFAULT_V1_CONFIG["stop_loss_pct"]))
slot_money = float(cfg.get("slot_money", DEFAULT_V1_CONFIG["slot_money"]))
buy1_r = float(cfg.get("buy1_ratio", DEFAULT_V1_CONFIG["buy1_ratio"]))
buy2_r = float(cfg.get("buy2_ratio", DEFAULT_V1_CONFIG["buy2_ratio"]))
buy3_r = float(cfg.get("buy3_ratio", DEFAULT_V1_CONFIG["buy3_ratio"]))
fee_rate = float(cfg.get("fee_rate", DEFAULT_V1_CONFIG["fee_rate"]))
sell_tax = float(cfg.get("sell_tax", DEFAULT_V1_CONFIG["sell_tax"]))
ath_drop_min = float(cfg.get("ath_drop_min_pct", DEFAULT_V1_CONFIG["ath_drop_min_pct"]))
year_drop_min = float(cfg.get("year_drop_min_pct", DEFAULT_V1_CONFIG["year_drop_min_pct"]))
w52_drop_min = float(cfg.get("w52_drop_min_pct", DEFAULT_V1_CONFIG["w52_drop_min_pct"]))
if len(candles) < rsi_period + 5:
return {"error": f"봉 부족: {len(candles)}개 (최소 {rsi_period + 5}개 필요)"}
closes = [float(c["close"]) for c in candles]
highs = [float(c["high"]) for c in candles]
lows = [float(c["low"]) for c in candles]
opens = [float(c["open"]) for c in candles]
dates = [str(c["candle_date"])[:10] for c in candles]
rsis = _rsi_series(closes, rsi_period)
# ── 컨텍스트 배열 사전 계산 (ATH / 연도 고점 / 52주 고점) ─────────────────
ath_arr: List[float] = []
year_arr: List[float] = []
w52_arr: List[float] = []
_year_max: Dict[str, float] = {}
_ath_run = 0.0
for i in range(len(candles)):
h = highs[i]; y = dates[i][:4]
_ath_run = max(_ath_run, h)
_year_max[y] = max(_year_max.get(y, 0.0), h)
# 52주(약 252거래일) 슬라이딩 윈도우
w52_s = max(0, i - 251)
try:
cutoff = str(_date.fromisoformat(dates[i]) - _td(days=365))
tmp = i
while tmp > 0 and dates[tmp - 1] >= cutoff:
tmp -= 1
w52_s = tmp
except Exception:
pass
ath_arr.append(_ath_run)
year_arr.append(_year_max[y])
w52_arr.append(max(highs[w52_s:i + 1]))
# ── 시뮬레이션 ────────────────────────────────────────────────────────────
position = None # None 또는 Dict
trades: List[Dict] = []
equity: List[Dict] = []
cum_pnl = 0.0
start_i = rsi_period + 1
for i in range(start_i, len(candles) - 1):
rsi = rsis[i]
if rsi is None:
continue
close = closes[i]
next_open = float(opens[i + 1]) if opens[i + 1] > 0 else close
if next_open <= 0:
continue
date_str = dates[i]
def _drop(ref: float) -> float:
"""현재가 기준 고점 대비 낙폭 %"""
return (ref - close) / ref * 100 if ref > 0 else 0.0
# ─── 보유 중: 청산 먼저 체크, 그 다음 추가매수 ───────────────────────
if position is not None:
avg = position["avg"]
qty = position["qty"]
stage = position["stage"]
profit_pct_now = (close - avg) / avg * 100 if avg > 0 else 0.0
# 청산 조건
sell_reason = None
if profit_pct_now >= tp_pct:
sell_reason = f"익절(+{profit_pct_now:.1f}%)"
elif rsi >= rsi_sell:
sell_reason = f"RSI과열({rsi:.1f})"
elif profit_pct_now <= -sl_pct:
sell_reason = f"손절({profit_pct_now:.1f}%)"
if sell_reason:
exit_price = next_open
fee = exit_price * qty * (fee_rate + sell_tax)
pnl = (exit_price - avg) * qty - fee
cum_pnl += pnl
trades.append({
"buy_date": dates[position["entry_i"]],
"sell_date": dates[i + 1],
"avg_price": round(avg),
"exit_price": round(exit_price),
"qty": qty,
"pnl": round(pnl),
"hold_days": i + 1 - position["entry_i"],
"reason": sell_reason,
"stage": stage,
"ath_drop": position.get("ath_drop", 0),
})
equity.append({"date": dates[i + 1], "cum_pnl": round(cum_pnl)})
position = None
else:
# ─ 추가매수 (분할매수 핵심) ─────────────────────────────────
# 2단계: 더 빠져서 rsi_buy2 이하가 됐을 때 추가
if stage == 1 and rsi <= rsi_buy2:
add_inv = slot_money * buy2_r
add_qty = max(1, int(add_inv / next_open))
add_cost = next_open * add_qty * (1 + fee_rate)
new_qty = qty + add_qty
position["avg"] = (avg * qty + next_open * add_qty) / new_qty
position["qty"] = new_qty
position["cost"] += add_cost
position["stage"] = 2
# 3단계: 더 빠져서 rsi_buy3 이하가 됐을 때 추가
elif stage == 2 and rsi <= rsi_buy3:
add_inv = slot_money * buy3_r
add_qty = max(1, int(add_inv / next_open))
add_cost = next_open * add_qty * (1 + fee_rate)
new_qty = qty + add_qty
position["avg"] = (avg * qty + next_open * add_qty) / new_qty
position["qty"] = new_qty
position["cost"] += add_cost
position["stage"] = 3
continue # 보유 중엔 신규 진입 스킵
# ─── 미보유: 낙폭 필터 → 1단계 진입 ──────────────────────────────────
if ath_drop_min > 0 and _drop(ath_arr[i]) < ath_drop_min: continue
if year_drop_min > 0 and _drop(year_arr[i]) < year_drop_min: continue
if w52_drop_min > 0 and _drop(w52_arr[i]) < w52_drop_min: continue
if rsi > rsi_buy1:
continue
invest = slot_money * buy1_r
qty = max(1, int(invest / next_open))
cost = next_open * qty * (1 + fee_rate)
position = {
"avg": next_open,
"qty": qty,
"cost": cost,
"stage": 1,
"entry_i": i + 1,
"ath_drop": round(_drop(ath_arr[i]), 1),
}
# ── 기간 종료 강제 청산 ───────────────────────────────────────────────────
if position is not None and candles:
exit_price = closes[-1]
avg = position["avg"]; qty = position["qty"]
fee = exit_price * qty * (fee_rate + sell_tax)
pnl = (exit_price - avg) * qty - fee
cum_pnl += pnl
trades.append({
"buy_date": dates[position["entry_i"]],
"sell_date": dates[-1],
"avg_price": round(avg),
"exit_price": round(exit_price),
"qty": qty,
"pnl": round(pnl),
"hold_days": len(candles) - 1 - position["entry_i"],
"reason": "기간종료",
"stage": position["stage"],
"ath_drop": position.get("ath_drop", 0),
})
equity.append({"date": dates[-1], "cum_pnl": round(cum_pnl)})
# ── 요약 통계 ──────────────────────────────────────────────────────────────
total = len(trades)
wins = [t for t in trades if t["pnl"] > 0]
losses = [t for t in trades if t["pnl"] < 0]
gross_profit = sum(t["pnl"] for t in wins)
gross_loss = abs(sum(t["pnl"] for t in losses))
pf = round(gross_profit / gross_loss, 2) if gross_loss > 0 else 9999.0
peak_eq = 0.0; mdd = 0.0; run_pnl = 0.0
for t in trades:
run_pnl += t["pnl"]
peak_eq = max(peak_eq, run_pnl)
mdd = max(mdd, peak_eq - run_pnl)
# Buy & Hold 비교 (첫 진입 시점 기준)
bnh_pct = 0.0; bnh_pnl = 0.0; bot_pct = 0.0
if candles and slot_money > 0:
first_p = float(candles[start_i]["open"])
last_p = float(candles[-1]["close"])
if first_p > 0:
bnh_qty = max(1, int(slot_money / first_p))
bnh_pnl = round((last_p - first_p) * bnh_qty)
bnh_pct = round((last_p - first_p) / first_p * 100, 2)
total_pnl = sum(t["pnl"] for t in trades)
bot_pct = round(total_pnl / slot_money * 100, 2) if slot_money > 0 else 0.0
reason_dist: Dict[str, int] = {}
for t in trades:
r = t["reason"].split("(")[0]
reason_dist[r] = reason_dist.get(r, 0) + 1
avg_hold = round(sum(t["hold_days"] for t in trades) / total, 1) if total else 0
return {
"candle_count": len(candles),
"summary": {
"total_trades": total,
"win_rate": round(len(wins) / total * 100, 1) if total else 0,
"total_pnl": round(total_pnl),
"profit_factor": pf,
"max_drawdown": round(mdd),
"avg_hold_days": avg_hold,
"reason_dist": reason_dist,
"bnh_pct": bnh_pct,
"bnh_pnl": bnh_pnl,
"bot_pct": bot_pct,
},
"equity": equity[-200:],
"trades": trades[-200:],
}
# ─────────────────────────────────────────────────────────────────────────────
# 파라미터 Grid Search
# ─────────────────────────────────────────────────────────────────────────────
def run_param_search_v1(
candles: List[Dict],
grid: Optional[Dict] = None,
min_trades: int = 2,
base_cfg: Optional[Dict] = None,
) -> List[Dict]:
"""
V1 전략 파라미터 Grid Search.
base_cfg: 탐색 그리드에 없는 파라미터 기준값.
웹 카드에서 전달받은 사용자 설정값 사용.
"""
from itertools import product as iproduct
if grid is None:
grid = {
# ── 진입 RSI 임계값 ──────────────────────────────────────────────
"rsi_buy1": [60, 55, 50, 45],
"rsi_buy2": [50, 45, 40, 35],
"rsi_buy3": [40, 35, 30, 25],
# ── 매도 RSI ────────────────────────────────────────────────────
"rsi_sell": [70, 75, 80],
# ── 익절/손절 ────────────────────────────────────────────────────
"take_profit_pct": [10.0, 15.0, 20.0, 30.0],
"stop_loss_pct": [7.0, 10.0, 15.0],
# ── ATH 낙폭 필터 ─────────────────────────────────────────────
"ath_drop_min_pct": [0.0, 20.0],
}
keys = list(grid.keys())
combos = list(iproduct(*[grid[k] for k in keys]))
_base = dict(DEFAULT_V1_CONFIG)
if base_cfg:
_base.update(base_cfg)
results = []
for vals in combos:
cfg = dict(_base)
cfg.update(dict(zip(keys, vals)))
# rsi_buy1 > rsi_buy2 > rsi_buy3 조건 보정
if not (cfg["rsi_buy1"] > cfg["rsi_buy2"] > cfg["rsi_buy3"]):
continue
res = run_backtest_v1(candles, cfg)
if "error" in res:
continue
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"],
})
results.sort(key=lambda x: x["total_pnl"], reverse=True)
return results
# ─────────────────────────────────────────────────────────────────────────────
# CLI 진입점
# ─────────────────────────────────────────────────────────────────────────────
def main():
today = datetime.now().strftime("%Y-%m-%d")
year_ago = (datetime.now() - _td(days=365)).strftime("%Y-%m-%d")
parser = argparse.ArgumentParser(
description="홀딩 V1 (RSI 분할매수) 백테스트 · 파라미터탐색"
)
parser.add_argument("--code", required=True, help="종목 코드 (예: 005930)")
parser.add_argument("--start", default=year_ago, help="시작일 YYYY-MM-DD")
parser.add_argument("--end", default=today, help="종료일 YYYY-MM-DD")
parser.add_argument("--search", action="store_true", help="파라미터 탐색 모드")
parser.add_argument("--min_trades", default=2, type=int, help="탐색 최소 거래 수")
parser.add_argument("--top", default=20, type=int, help="탐색 결과 상위 N개")
parser.add_argument("--rsi_buy1", default=None, type=float)
parser.add_argument("--rsi_buy2", default=None, type=float)
parser.add_argument("--rsi_buy3", default=None, type=float)
parser.add_argument("--rsi_sell", default=None, type=float)
parser.add_argument("--tp", default=None, type=float, dest="take_profit_pct")
parser.add_argument("--sl", default=None, type=float, dest="stop_loss_pct")
parser.add_argument("--ath_drop", default=None, type=float, dest="ath_drop_min_pct")
args = parser.parse_args()
import logging as _lg
_lg.getLogger("TradeDB").setLevel(_lg.WARNING)
db = TradeDB()
ensure_holding_tables(db)
candles = get_stored_candles(db, args.code, args.start, args.end)
db.close()
if not candles:
print(f"{args.code} 캔들 없음 (holding_candles 테이블 확인)")
return
print(f"{args.code} | {len(candles)}봉 ({candles[0]['candle_date']} ~ {candles[-1]['candle_date']})")
# CLI 파라미터 오버라이드
override = {}
for k in ["rsi_buy1", "rsi_buy2", "rsi_buy3", "rsi_sell",
"take_profit_pct", "stop_loss_pct", "ath_drop_min_pct"]:
v = getattr(args, k, None)
if v is not None:
override[k] = v
if args.search:
print(f"\n🔍 V1 파라미터 탐색 중…")
results = run_param_search_v1(candles, min_trades=args.min_trades,
base_cfg=override or None)
if not results:
print("⚠️ 유효 결과 없음")
return
print(f"\n{'='*70}")
print(f" 🏆 RSI 분할매수 V1 — TOP {min(args.top, len(results))}")
print(f"{'='*70}")
keys = list(results[0]["params"].keys())
hdr = " ".join(f"{k:>14}" for k in keys)
print(f"{hdr} | {'손익':>10} {'승률':>6} {'거래':>5} {'PF':>5} {'보유':>6}")
print("-" * (len(hdr) + 50))
for r in results[:args.top]:
p = r["params"]
row = " ".join(f"{p[k]:>14.4g}" for k in keys)
print(f"{row} | {r['total_pnl']:>+10,.0f} {r['win_rate']:>5.1f}% "
f"{r['total_trades']:>5} {r['pf']:>5.2f} {r['avg_hold']:>5.1f}")
else:
cfg = dict(DEFAULT_V1_CONFIG)
cfg.update(override)
res = run_backtest_v1(candles, cfg)
if "error" in res:
print(f"{res['error']}")
return
s = res["summary"]
print(f"""
╔══════════════════════════════════════════════╗
║ 📊 V1 RSI 분할매수 백테스트 결과 ║
╠══════════════════════════════════════════════╣
║ 총 거래 : {s['total_trades']:>5}건 ║
║ 승률 : {s['win_rate']:>5.1f}% ║
║ 순손익 : {s['total_pnl']:>+12,.0f} 원 ║
║ PF : {s['profit_factor']:>5.2f}
║ MDD : {s['max_drawdown']:>12,.0f} 원 ║
║ 평균보유 : {s['avg_hold_days']:>5.1f}일 ║
╠══════════════════════════════════════════════╣
║ 봇 수익률 : {s['bot_pct']:>+8.2f}% ║
║ B&H 수익률 : {s['bnh_pct']:>+8.2f}% ║
║ 알파 : {round(s['bot_pct']-s['bnh_pct'],2):>+8.2f}%p ║
╚══════════════════════════════════════════════╝""")
for t in res["trades"][-10:]:
print(f" {t['buy_date']}{t['sell_date']} "
f"평단:{t['avg_price']:,} 매도:{t['exit_price']:,} "
f"수량:{t['qty']} 손익:{t['pnl']:+,} 단계:{t['stage']} {t['reason']}")
if __name__ == "__main__":
main()