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

3292 lines
157 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
"""
backtest_web.py — 매매 성과 분석 & 백테스트 웹 대시보드
==========================================================
실행: python3 backtest_web.py
접속: http://localhost:5050
탭1. 실거래 분석 → trade_history 기반 실제 매매 결과
탭2. 스캘핑 백테스트 → ws_candles 1분봉 가격 재현(Price-Replay) 백테스트
"""
import sys, os, math, json, logging, threading, uuid
from datetime import datetime, timedelta
from typing import List, Dict, Optional, Tuple
sys.path.insert(0, os.path.dirname(__file__))
from database import TradeDB
import holding_bot as hb
import kis_holding_ver1 as hv1 # V1: RSI 3단계 분할매수 (횡보장 전략)
from flask import Flask, jsonify, request, render_template_string
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("backtest_web")
# TradeDB 초기화/종료 반복 로그 억제 (백테스트 루프에서 수백 번 찍히는 것 방지)
logging.getLogger("TradeDB").setLevel(logging.WARNING)
app = Flask(__name__)
# 60분봉 수집 백그라운드 job 상태 저장소
_min_fetch_jobs: Dict[str, Dict] = {}
# ────────────────────────────────────────────────────────────────────────────
# 헬퍼 함수
# ────────────────────────────────────────────────────────────────────────────
def _db() -> TradeDB:
return TradeDB()
def _get_fee_defaults() -> dict:
"""
DB env_config 에서 수수료/세금 기본값 로드.
백테스트 URL 파라미터 기본값으로 사용 → DB에 값이 있으면 그걸 따름.
"""
try:
db = _db()
row = db.conn.execute(
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
).fetchone()
db.close()
if row:
r = dict(row)
return {
"fee_rate": float(r.get("FEE_RATE_PCT") or 0.015),
"sell_tax": float(r.get("SELL_TAX_RATE_PCT") or 0.18),
}
except Exception:
pass
return {"fee_rate": 0.015, "sell_tax": 0.18}
def _compute_rsi_series(closes: list, period: int = 3) -> list:
"""RSI 시리즈 계산 (Wilder 스무딩)"""
rsi_list = [None] * len(closes)
if len(closes) < period + 1:
return rsi_list
deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
gains = [max(d, 0) for d in deltas]
losses = [max(-d, 0) for d in deltas]
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
for i in range(period, len(closes)):
idx = i - 1 # delta 배열 기준
if i > period:
avg_gain = (avg_gain * (period - 1) + gains[idx]) / period
avg_loss = (avg_loss * (period - 1) + losses[idx]) / period
rs = avg_gain / avg_loss if avg_loss > 0 else float('inf')
rsi_val = 100 - (100 / (1 + rs)) if avg_loss > 0 else 100.0
rsi_list[i] = rsi_val
return rsi_list
# ────────────────────────────────────────────────────────────────────────────
# API: 실거래 분석
# ────────────────────────────────────────────────────────────────────────────
@app.route("/api/actual", methods=["GET"])
def api_actual():
strategy = request.args.get("strategy", "SCALP_RSI_REVERSAL")
start = request.args.get("start", "")
end = request.args.get("end", "")
db = _db()
try:
params = [strategy]
sql = "SELECT * FROM trade_history WHERE strategy = %s"
if start:
sql += " AND sell_date >= %s"
params.append(start + " 00:00:00")
if end:
sql += " AND sell_date <= %s"
params.append(end + " 23:59:59")
sql += " ORDER BY sell_date ASC"
rows = db.conn.execute(sql, params).fetchall()
trades = [dict(r) for r in rows]
# 날짜 직렬화
for t in trades:
for k in ("buy_date", "sell_date"):
if t.get(k):
t[k] = str(t[k])
# 누적 손익 계산
equity = []
cum_pnl = 0.0
for t in trades:
cum_pnl += float(t.get("realized_pnl") or 0)
equity.append({
"date": t["sell_date"][:10] if t.get("sell_date") else "",
"cum_pnl": round(cum_pnl),
"pnl": round(float(t.get("realized_pnl") or 0)),
})
# 요약 통계
total = len(trades)
wins = [t for t in trades if float(t.get("realized_pnl") or 0) > 0]
losses = [t for t in trades if float(t.get("realized_pnl") or 0) < 0]
total_pnl = sum(float(t.get("realized_pnl") or 0) for t in trades)
avg_hold = (sum(float(t.get("hold_minutes") or 0) for t in trades) / total) if total else 0
win_pnl = sum(float(t.get("realized_pnl") or 0) for t in wins)
loss_pnl = sum(float(t.get("realized_pnl") or 0) for t in losses)
profit_factor = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0
# 최대 낙폭(MDD)
peak, mdd = 0.0, 0.0
cum = 0.0
for t in trades:
cum += float(t.get("realized_pnl") or 0)
if cum > peak:
peak = cum
dd = peak - cum
if dd > mdd:
mdd = dd
# 매도 이유별 집계
reasons: Dict[str, int] = {}
for t in trades:
r = t.get("sell_reason") or "기타"
reasons[r] = reasons.get(r, 0) + 1
# 일별 P&L
daily: Dict[str, float] = {}
for t in trades:
day = (t.get("sell_date") or "")[:10]
if day:
daily[day] = daily.get(day, 0) + float(t.get("realized_pnl") or 0)
daily_list = [{"date": d, "pnl": round(v)} for d, v in sorted(daily.items())]
# 종목별 상위 손익
code_pnl: Dict[str, float] = {}
code_name: Dict[str, str] = {}
for t in trades:
c = t["code"]
code_pnl[c] = code_pnl.get(c, 0) + float(t.get("realized_pnl") or 0)
code_name[c] = t.get("name") or c
top_codes = sorted(code_pnl.items(), key=lambda x: x[1], reverse=True)[:10]
top_list = [{"code": c, "name": code_name[c], "pnl": round(v)} for c, v in top_codes]
return jsonify({
"summary": {
"total_trades": total,
"win_trades": len(wins),
"loss_trades": len(losses),
"win_rate": round(len(wins) / total * 100, 1) if total else 0,
"total_pnl": round(total_pnl),
"avg_hold_min": round(avg_hold, 1),
"profit_factor": round(profit_factor, 2),
"max_drawdown": round(mdd),
},
"equity": equity,
"daily": daily_list,
"reasons": reasons,
"top_codes": top_list,
"trades": trades[-200:], # 최근 200건
})
finally:
db.close()
# ────────────────────────────────────────────────────────────────────────────
# API: 스캘핑 가격 재현 백테스트 (ws_candles 기반)
# ────────────────────────────────────────────────────────────────────────────
def _t2dt(t: str) -> datetime:
return datetime.strptime(t, "%Y%m%d%H%M")
import scalping_engine as se
@app.route("/api/backtest/scalping", methods=["GET"])
def api_backtest_scalping():
# 기본값 = DB(엔진 단일 소스) → 백테스트/param_search/실매매 동일 값
_def = se.get_scalping_defaults_from_db()
start = request.args.get("start", "")
end = request.args.get("end", "")
rsi_period = int(request.args.get("rsi_period", _def["rsi_period"]))
rsi_oversold = float(request.args.get("rsi_oversold", 25))
sl_pct = float(request.args.get("sl_pct", 1.5)) / 100 # 손절 %
tp_pct = float(request.args.get("tp_pct", 1.5)) / 100 # 익절 %
drop_rate = float(request.args.get("drop_rate", 1.5)) / 100 # 최소 낙폭(당일 시가→저가)
slot_money = float(request.args.get("slot_money", _def["slot_money"])) # 1회 투자금
_fee_rate = request.args.get("fee_rate")
fee_rate = float(_fee_rate) / 100 if _fee_rate not in (None, "") else _def["fee_rate"]
_sell_tax = request.args.get("sell_tax")
sell_tax = float(_sell_tax) / 100 if _sell_tax not in (None, "") else _def["sell_tax"]
_cooldown = request.args.get("cooldown_min")
cooldown_min = float(_cooldown) if _cooldown not in (None, "") else _def["cooldown_min"]
vol_mult = float(request.args.get("vol_mult", _def["vol_mult"])) # 거래량 필터(0=비활성)
_tr_trigger = request.args.get("trail_trigger")
trail_trigger = float(_tr_trigger) / 100 if _tr_trigger not in (None, "") else _def["trail_trigger"]
_tr_stop = request.args.get("trail_stop")
trail_stop = float(_tr_stop) / 100 if _tr_stop not in (None, "") else _def["trail_stop"]
_time_start = request.args.get("time_start")
time_start_hm = int(_time_start) if _time_start not in (None, "") else _def["time_start_hm"]
_time_end = request.args.get("time_end")
time_end_hm = int(_time_end) if _time_end not in (None, "") else _def["time_end_hm"]
max_daily = int(request.args.get("max_daily", _def["max_daily"])) # 종목당 일일 최대 거래횟수
db = _db()
try:
# 날짜 → candle_time 형식 (YYYYMMDDHHMI) 변환
start_key = (start.replace("-", "") + "0000") if start else "20260101"
end_key = (end.replace("-", "") + "2359") if end else "99991231"
codes_raw = db.conn.execute(
"SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 "
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
[start_key, end_key]
).fetchall()
codes = [r["code"] for r in codes_raw]
codes_candles: Dict[str, List[Dict]] = {}
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles "
"WHERE timeframe=1 AND code=%s "
"AND candle_time >= %s AND candle_time <= %s "
"AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[code, start_key, end_key]
).fetchall()
if len(rows) < rsi_period + 5:
continue
codes_candles[code] = [dict(r) for r in rows]
params = {
"rsi_period": rsi_period,
"rsi_oversold": rsi_oversold,
"sl_pct": sl_pct,
"tp_pct": tp_pct,
"drop_rate": drop_rate,
"slot_money": slot_money,
"fee_rate": fee_rate,
"sell_tax": sell_tax,
"cooldown_min": cooldown_min,
"trail_trigger": trail_trigger,
"trail_stop": trail_stop,
"time_start_hm": time_start_hm,
"time_end_hm": time_end_hm,
"max_daily": max_daily,
"vol_mult": vol_mult,
}
all_virtual_trades = se.run_scalping_backtest(codes_candles, params)
# ─────────────────────────────────────────────────────────────────
# 결과 집계
# ─────────────────────────────────────────────────────────────────
equity = []
cum = 0.0
for t in all_virtual_trades:
cum += t["pnl"]
equity.append({"date": t["sell_time"][:8], "cum_pnl": round(cum), "pnl": t["pnl"]})
total = len(all_virtual_trades)
wins = [t for t in all_virtual_trades if t["pnl"] > 0]
losses = [t for t in all_virtual_trades if t["pnl"] < 0]
total_pnl = sum(t["pnl"] for t in all_virtual_trades)
avg_hold = (sum(t["hold_min"] for t in all_virtual_trades) / total) if total else 0
win_pnl = sum(t["pnl"] for t in wins)
loss_pnl = sum(t["pnl"] for t in losses)
pf = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0
peak, mdd, cum = 0.0, 0.0, 0.0
for t in all_virtual_trades:
cum += t["pnl"]
if cum > peak: peak = cum
dd = peak - cum
if dd > mdd: mdd = dd
reasons: Dict[str, int] = {}
for t in all_virtual_trades:
reasons[t["sell_reason"]] = reasons.get(t["sell_reason"], 0) + 1
daily: Dict[str, float] = {}
for t in all_virtual_trades:
d8 = t["sell_time"][:8]
daily[d8] = daily.get(d8, 0) + t["pnl"]
daily_list = [{"date": d[:4]+"-"+d[4:6]+"-"+d[6:], "pnl": round(v)}
for d, v in sorted(daily.items())]
return jsonify({
"params": {
"rsi_period": rsi_period,
"rsi_oversold": rsi_oversold,
"sl_pct": sl_pct * 100,
"tp_pct": tp_pct * 100,
"drop_rate": drop_rate * 100,
"slot_money": slot_money,
"cooldown_min": cooldown_min,
"vol_mult": vol_mult,
"trail_trigger": trail_trigger * 100,
"trail_stop": trail_stop * 100,
"time_window": f"{time_start_hm:04d}-{time_end_hm:04d}",
"max_daily": max_daily,
"codes_analyzed": len(codes),
},
"summary": {
"total_trades": total,
"win_trades": len(wins),
"loss_trades": len(losses),
"win_rate": round(len(wins) / total * 100, 1) if total else 0,
"total_pnl": round(total_pnl),
"avg_hold_min": round(avg_hold, 1),
"profit_factor": round(pf, 2),
"max_drawdown": round(mdd),
},
"equity": equity,
"daily": daily_list,
"reasons": reasons,
"trades": all_virtual_trades[-200:],
})
finally:
db.close()
# ────────────────────────────────────────────────────────────────────────────
# API: 꼬리잡기 가격 재현 백테스트 (ws_candles 3분봉 기반, tail_engine 공통 로직 사용)
# ────────────────────────────────────────────────────────────────────────────
try:
import tail_engine as te
_TAIL_ENGINE_AVAILABLE = True
except ImportError:
_TAIL_ENGINE_AVAILABLE = False
def _get_tail_defaults_for_backtest():
"""꼬리잡기 백테스트 기본값: DB 단일 소스. 엔진 없으면 빈 dict."""
if not _TAIL_ENGINE_AVAILABLE:
return {}
return te.get_tail_defaults_from_db()
@app.route("/api/backtest/tail", methods=["GET"])
def api_backtest_tail():
"""
꼬리잡기 전략 가격 재현 백테스트.
entry 조건: 당일 낙폭(drop_rate) + 회복률(recovery_ratio) + 망치봉 꼬리 + RSI
exit 조건: 손절 / 익절 / 어깨 컷(trailing) / 장 마감 강제 청산
기본값 = DB(env_config) → tail_engine.get_tail_defaults_from_db(), 요청으로 덮어쓰기.
"""
_def = _get_tail_defaults_for_backtest()
start = request.args.get("start", "")
end = request.args.get("end", "")
rsi_period = int( request.args.get("rsi_period", _def.get("rsi_period", 14)))
rsi_threshold = float(request.args.get("rsi_threshold", _def.get("rsi_threshold", 78)))
min_drop_rate = float(request.args.get("min_drop_rate", _def.get("min_drop_rate", 0.03) * 100)) / 100
min_recovery_ratio = float(request.args.get("min_recovery_ratio", _def.get("min_recovery_ratio", 0.5) * 100)) / 100
# max_rec_3m / high_chase_thr: 폼에서 80·96(퍼센트) 또는 0.8·0.96(비율) 전달 가능 → 엔진은 항상 비율(0~1)
_max_rec_raw = float(request.args.get("max_rec_3m", _def.get("max_rec_3m", 0.8)))
max_rec_3m = _max_rec_raw if 0 < _max_rec_raw <= 1 else _max_rec_raw / 100
tail_ratio_min = float(request.args.get("tail_ratio_min", _def.get("tail_ratio_min", 1.5)))
tail_pct_min = float(request.args.get("tail_pct_min", _def.get("tail_pct_min", 0.003) * 100)) / 100
sl_pct = float(request.args.get("sl_pct", _def.get("sl_pct", 0.03) * 100)) / 100
tp_pct = float(request.args.get("tp_pct", _def.get("tp_pct", 0.05) * 100)) / 100
shoulder_min_high = float(request.args.get("shoulder_min_high", _def.get("shoulder_min_high", 0.015) * 100)) / 100
shoulder_cut_pct = float(request.args.get("shoulder_cut_pct", _def.get("shoulder_cut_pct", 0.03) * 100)) / 100
_high_chase_raw = float(request.args.get("high_chase_thr", _def.get("high_chase_thr", 0.96)))
high_chase_thr = _high_chase_raw if 0 < _high_chase_raw <= 1 else _high_chase_raw / 100
slot_money = float(request.args.get("slot_money", 1_000_000))
_fee_d = _get_fee_defaults()
fee_rate = float(request.args.get("fee_rate", _fee_d["fee_rate"])) / 100
sell_tax = float(request.args.get("sell_tax", _fee_d["sell_tax"])) / 100
cooldown_min = int( request.args.get("cooldown_min", _def.get("cooldown_min", 15)))
time_start_hm = int( request.args.get("time_start", _def.get("time_start_hm", 930)))
time_end_hm = int( request.args.get("time_end", _def.get("time_end_hm", 1500)))
max_daily = int( request.args.get("max_daily", _def.get("max_daily", 3)))
db = _db()
try:
start_key = (start.replace("-", "") + "0000") if start else "20260101"
end_key = (end.replace("-", "") + "2359") if end else "99991231"
codes_raw = db.conn.execute(
"SELECT DISTINCT code FROM ws_candles WHERE timeframe=3 "
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
[start_key, end_key]
).fetchall()
codes = [r["code"] for r in codes_raw]
use_engine = _TAIL_ENGINE_AVAILABLE and request.args.get("use_engine", "1") == "1"
all_trades: List[Dict] = []
if use_engine:
# tail_engine 공통 로직 사용 (실매매 ver3과 동일 계산식)
candles_by_code = {}
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles WHERE timeframe=3 AND code=%s "
"AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[code, start_key, end_key]
).fetchall()
if len(rows) < rsi_period + 5:
continue
candles_by_code[code] = [dict(r) for r in rows]
params = {
"min_drop_rate": min_drop_rate, "min_recovery_ratio": min_recovery_ratio,
"max_rec_3m": max_rec_3m, "tail_ratio_min": tail_ratio_min, "tail_pct_min": tail_pct_min,
"sl_pct": sl_pct, "tp_pct": tp_pct,
"shoulder_min_high": shoulder_min_high, "shoulder_cut_pct": shoulder_cut_pct,
"rsi_period": rsi_period, "rsi_threshold": rsi_threshold, "high_chase_thr": high_chase_thr,
"time_start_hm": time_start_hm, "time_end_hm": time_end_hm,
"cooldown_min": cooldown_min, "max_daily": max_daily,
}
all_trades = te.run_tail_backtest(candles_by_code, params)
for t in all_trades:
qty = max(1, int(slot_money / t["entry"]))
fee = (t["entry"] + t["exit"]) * qty * fee_rate
tax = t["exit"] * qty * sell_tax
t["pnl"] = round((t["exit"] - t["entry"]) * qty - fee - tax)
t["hold_min"] = round((_t2dt(t["exit_time"]) - _t2dt(t["entry_time"])).total_seconds() / 60, 1)
else:
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles "
"WHERE timeframe=3 AND code=%s "
"AND candle_time >= %s AND candle_time <= %s "
"AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[code, start_key, end_key]
).fetchall()
if len(rows) < rsi_period + 5:
continue
candles = [dict(r) for r in rows]
closes = [float(c["close"]) for c in candles]
rsis = _compute_rsi_series(closes, rsi_period)
position = None
last_exit_dt: Dict[str, datetime] = {}
daily_cnt: Dict[str, int] = {}
# look-ahead 없는 당일 누적 OHLC
cur_day = None
running_open = 0.0
running_high = 0.0
running_low = 0.0
for i in range(rsi_period + 1, len(candles)):
c = candles[i]
day = c["candle_time"][:8]
hm = int(c["candle_time"][8:12])
op = float(c["open"])
hi = float(c["high"])
lo = float(c["low"])
cl = float(c["close"])
# ── 당일 누적 OHLC 갱신 (선행 편향 없음) ─────────────────
if day != cur_day:
cur_day = day
running_open = op
running_high = hi
running_low = lo if lo > 0 else hi
else:
running_high = max(running_high, hi)
if lo > 0:
running_low = min(running_low, lo)
# ── 마지막 봉 여부 ────────────────────────────────────────
is_eod = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day)
# ─────────────────────────────────────────────────────────
# 포지션 보유 중: 청산 체크
# ─────────────────────────────────────────────────────────
if position is not None:
max_p = max(position["max_price"], hi)
position["max_price"] = max_p
reason = None
exit_price = cl
# 손절: 저가가 손절선 이하
if lo > 0 and lo <= position["stop"]:
reason = "손절"
exit_price = position["stop"]
# 익절: 고가가 익절선 이상
elif hi >= position["target"]:
reason = "익절"
exit_price = position["target"]
# 어깨 컷(trailing): 충분히 오른 뒤 고점에서 일정 이상 하락
elif (max_p >= position["entry_price"] * (1 + shoulder_min_high)
and cl <= max_p * (1 - shoulder_cut_pct)):
reason = "어깨컷"
exit_price = cl
# 장 마감 강제 청산
elif is_eod:
reason = "장마감"
exit_price = cl
if reason:
ep = position["entry_price"]
qty = max(1, int(slot_money / ep))
fee = (ep + exit_price) * qty * fee_rate
tax = exit_price * qty * sell_tax
pnl = (exit_price - ep) * qty - fee - tax
hold = round((
_t2dt(c["candle_time"]) -
_t2dt(position["entry_time"])
).total_seconds() / 60, 1)
all_trades.append({
"code": code,
"entry_time": position["entry_time"],
"exit_time": c["candle_time"],
"entry": round(ep),
"exit": round(exit_price),
"pnl": round(pnl),
"reason": reason,
"hold_min": hold,
})
last_exit_dt[day] = _t2dt(c["candle_time"])
daily_cnt[day] = daily_cnt.get(day, 0) + 1
position = None
continue # 다음 봉으로
# ─────────────────────────────────────────────────────────
# 포지션 없음: 매수 조건 체크
# ─────────────────────────────────────────────────────────
if cl <= 0 or running_open <= 0:
continue
if hm < time_start_hm or hm > time_end_hm:
continue
if daily_cnt.get(day, 0) >= max_daily:
continue
# 쿨다운: 마지막 청산 후 N분 이내 재진입 금지
if day in last_exit_dt:
elapsed = (_t2dt(c["candle_time"]) - last_exit_dt[day]).total_seconds() / 60
if elapsed < cooldown_min:
continue
# ── 1. 당일 낙폭 ─────────────────────────────────────────
drop = (running_open - running_low) / running_open
if drop < min_drop_rate:
continue
# ── 2. 당일 회복률 ─────────────────────────────────────
day_range = running_high - running_low
rec_day = (cl - running_low) / day_range if day_range > 0 else 0
if rec_day < min_recovery_ratio:
continue
# ── 3. 망치봉 꼬리 비율 계산 ────────────────────────────
body_top = max(op, cl)
body_bot = min(op, cl)
body_len = body_top - body_bot if body_top > body_bot else 1.0
tail_len = body_bot - lo if lo > 0 else 0.0
# 꼬리 없는 봉이면 이전 봉에서 재탐색 (최대 3봉 전)
if tail_len <= 0:
for j in range(i - 1, max(i - 4, rsi_period), -1):
prev = candles[j]
o2, h2, l2, c2 = float(prev["open"]), float(prev["high"]), float(prev["low"]), float(prev["close"])
if l2 <= 0:
continue
bt2, bb2 = max(o2, c2), min(o2, c2)
bl2 = bt2 - bb2 if bt2 > bb2 else 1.0
tl2 = bb2 - l2
if tl2 > 0:
tail_len = tl2
body_len = bl2
lo = l2
break
tail_ratio = tail_len / body_len
tail_pct = tail_len / lo if lo > 0 and tail_len > 0 else 0.0
if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min:
continue
# ── 4. 3분봉 내 회복 위치 (무릎~어깨) ──────────────────
c_range = float(c["high"]) - float(c["low"])
rec_3m = (cl - float(c["low"])) / c_range if c_range > 0 else 0
if not (min_recovery_ratio <= rec_3m <= max_rec_3m):
continue
# ── 5. RSI 과열 방지 ────────────────────────────────────
rsi_val = rsis[i]
if rsi_val is None or rsi_val >= rsi_threshold:
continue
# ── 6. 피뢰침 방지: 고점 근접 추격 금지 ────────────────
if cl >= running_high * high_chase_thr:
continue
# ── 매수 실행: 다음 봉 시가 진입 ───────────────────────
if i + 1 >= len(candles):
continue
next_c = candles[i + 1]
if next_c["candle_time"][:8] != day:
continue # 장 마감 직전 봉이면 다음날 시가 = 갭위험 → skip
entry_price = float(next_c["open"])
if entry_price <= 0:
entry_price = cl
position = {
"entry_price": entry_price,
"entry_time": next_c["candle_time"],
"stop": entry_price * (1 - sl_pct),
"target": entry_price * (1 + tp_pct),
"max_price": entry_price,
}
# 진입 봉을 이미 처리했으므로 다음 인덱스로 이동
i += 1
# ── 통계 집계 ──────────────────────────────────────────────────
total = len(all_trades)
wins = [t for t in all_trades if t["pnl"] > 0]
losses = [t for t in all_trades if t["pnl"] < 0]
total_pnl = sum(t["pnl"] for t in all_trades)
avg_hold = (sum(t["hold_min"] for t in all_trades) / total) if total else 0
win_pnl = sum(t["pnl"] for t in wins)
loss_pnl = sum(t["pnl"] for t in losses)
pf = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0
# MDD
peak, mdd, cum = 0.0, 0.0, 0.0
equity, daily_map = [], {}
for t in sorted(all_trades, key=lambda x: x["exit_time"]):
cum += t["pnl"]
if cum > peak:
peak = cum
dd = peak - cum
if dd > mdd:
mdd = dd
day = t["exit_time"][:8]
equity.append({"date": f"{day[:4]}-{day[4:6]}-{day[6:]}", "cum_pnl": round(cum)})
daily_map[day] = daily_map.get(day, 0) + t["pnl"]
daily_list = [{"date": f"{d[:4]}-{d[4:6]}-{d[6:]}", "pnl": round(v)}
for d, v in sorted(daily_map.items())]
reasons: Dict[str, int] = {}
for t in all_trades:
reasons[t["reason"]] = reasons.get(t["reason"], 0) + 1
return jsonify({
"params": {
"start": start, "end": end,
"rsi_period": rsi_period, "rsi_threshold": rsi_threshold,
"min_drop_rate": min_drop_rate * 100,
"min_recovery_ratio": min_recovery_ratio * 100,
"max_rec_3m": max_rec_3m * 100,
"tail_ratio_min": tail_ratio_min,
"tail_pct_min": tail_pct_min * 100,
"sl_pct": sl_pct * 100,
"tp_pct": tp_pct * 100,
"shoulder_min_high": shoulder_min_high * 100,
"shoulder_cut_pct": shoulder_cut_pct * 100,
"slot_money": slot_money,
"cooldown_min": cooldown_min,
"time_start_hm": time_start_hm,
"time_end_hm": time_end_hm,
"time_window": f"{time_start_hm:04d}-{time_end_hm:04d}",
"max_daily": max_daily,
"codes_analyzed": len(codes),
},
"summary": {
"total_trades": total,
"win_trades": len(wins),
"loss_trades": len(losses),
"win_rate": round(len(wins) / total * 100, 1) if total else 0,
"total_pnl": round(total_pnl),
"avg_hold_min": round(avg_hold, 1),
"profit_factor": round(pf, 2),
"max_drawdown": round(mdd),
},
"equity": equity,
"daily": daily_list,
"reasons": reasons,
"trades": all_trades[-200:],
})
finally:
db.close()
# ────────────────────────────────────────────────────────────────────────────
# API: 홀딩 전략 — 관심종목 + 종목별 파라미터 + 캔들 수집 + 백테스트 + 파라미터 탐색
# ────────────────────────────────────────────────────────────────────────────
def _holding_db() -> TradeDB:
db = _db()
hb.ensure_holding_tables(db)
return db
@app.route("/api/holding/stocks", methods=["GET"])
def api_holding_stocks():
"""관심종목 목록 + 종목별 현재 파라미터 + 보유 봉수 반환"""
db = _holding_db()
try:
items = hb.load_watchlist()
result = []
for item in items:
code = item["code"]
cfg = hb.get_stock_config(db, code)
cfg["name"] = item.get("name", cfg.get("name", ""))
# 보유 봉수
row = db.conn.execute(
"SELECT COUNT(*) as cnt, MIN(candle_date) as mn, MAX(candle_date) as mx "
"FROM holding_candles WHERE code=%s", [code]
).fetchone()
cfg["candle_count"] = int(row["cnt"]) if row else 0
cfg["candle_min"] = str(row["mn"]) if row and row["mn"] else ""
cfg["candle_max"] = str(row["mx"]) if row and row["mx"] else ""
# 60분봉 현황 추가
ms = hb.get_min_candle_stats(db, code, tf_min=60)
cfg["min60_count"] = ms["count"]
cfg["min60_min"] = ms["min"]
cfg["min60_max"] = ms["max"]
result.append(cfg)
return jsonify(result)
finally:
db.close()
@app.route("/api/holding/config/<code>", methods=["GET", "POST"])
def api_holding_config(code):
"""GET: 종목 파라미터 조회 | POST: 파라미터 저장"""
db = _holding_db()
try:
if request.method == "GET":
cfg = hb.get_stock_config(db, code)
return jsonify(cfg)
else:
body = request.get_json(force=True) or {}
name = body.pop("name", "")
hb.set_stock_config(db, code, name, body)
return jsonify({"ok": True})
finally:
db.close()
@app.route("/api/holding/candles/fetch", methods=["POST"])
def api_holding_fetch_candles():
"""종목 일봉 캔들 KIS API로 수집 후 DB 저장"""
body = request.get_json(force=True) or {}
code = body.get("code", "")
start_date = body.get("start", "2023-01-01")
end_date = body.get("end", datetime.now().strftime("%Y-%m-%d"))
if not code:
return jsonify({"error": "code 필수"}), 400
db = _holding_db()
try:
app_key, app_secret, base_url, mock = hb._get_kis_token(db)
rows = hb.fetch_daily_ohlcv(code, start_date, end_date, app_key, app_secret, base_url, mock=mock)
saved = hb.store_candles(db, code, rows)
return jsonify({"ok": True, "fetched": len(rows), "saved": saved})
except Exception as e:
logger.error(f"캔들 수집 오류 ({code}): {e}")
return jsonify({"error": str(e)}), 500
finally:
db.close()
@app.route("/api/holding/backtest", methods=["GET"])
def api_holding_backtest():
"""홀딩 전략 백테스트 (종목별 파라미터 사용 or 요청 파라미터 오버라이드)"""
code = request.args.get("code", "")
start_date = request.args.get("start", "2023-01-01")
end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d"))
if not code:
return jsonify({"error": "code 필수"}), 400
db = _holding_db()
try:
candles = hb.get_stored_candles(db, code, start_date, end_date)
if len(candles) < 20:
# 친절한 에러: DB 전체 보유 봉 수와 기간도 함께 안내
all_candles = hb.get_stored_candles(db, code)
if not all_candles:
hint = "📥 먼저 [캔들 수집] 버튼으로 데이터를 수집하세요."
else:
first = str(all_candles[0]["candle_date"])[:10]
last = str(all_candles[-1]["candle_date"])[:10]
hint = (
f"DB에 {len(all_candles)}봉 있음 ({first} ~ {last})\n"
f"👉 백테스트 시작일을 '{first}' 이후로 설정하세요."
)
return jsonify({
"error": f"봉 부족: {len(candles)}개 (최소 20개 필요)\n{hint}"
}), 400
# 요청 파라미터로 DB 설정 오버라이드 가능
cfg = hb.get_stock_config(db, code)
for ck in hb.DEFAULT_STOCK_CONFIG:
v = request.args.get(ck)
if v is not None:
cfg[ck] = float(v)
result = hb.run_backtest(candles, cfg)
result["code"] = code
result["candle_count"] = len(candles)
result["params"] = {k: cfg[k] for k in hb.DEFAULT_STOCK_CONFIG}
return jsonify(result)
finally:
db.close()
@app.route("/api/holding/v1/backtest", methods=["GET"])
def api_holding_v1_backtest():
"""홀딩 V1 (RSI 분할매수) 백테스트"""
code = request.args.get("code", "")
start_date = request.args.get("start", "")
end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d"))
if not code:
return jsonify({"error": "code 필수"}), 400
# 카드 UI 파라미터 수집 (DEFAULT_V1_CONFIG 키 기준)
cfg = {}
for key in hv1.DEFAULT_V1_CONFIG.keys():
val = request.args.get(key)
if val is not None:
try:
cfg[key] = float(val)
except (ValueError, TypeError):
pass
db = _holding_db()
try:
candles = hb.get_stored_candles(db, code, start_date, end_date)
if len(candles) < 10:
return jsonify({"error": f"봉 부족: {len(candles)}"}), 400
res = hv1.run_backtest_v1(candles, cfg)
return jsonify(res)
finally:
db.close()
@app.route("/api/holding/v1/param_search", methods=["GET"])
def api_holding_v1_param_search():
"""홀딩 V1 (RSI 분할매수) 파라미터 Grid Search"""
code = request.args.get("code", "")
start_date = request.args.get("start", "")
end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d"))
min_trades = int(request.args.get("min_trades", 2))
if not code:
return jsonify({"error": "code 필수"}), 400
# 카드 UI 파라미터를 base_cfg로 (그리드 외 파라미터 고정)
base_cfg = {}
for key in hv1.DEFAULT_V1_CONFIG.keys():
val = request.args.get(key)
if val is not None:
try:
base_cfg[key] = float(val)
except (ValueError, TypeError):
pass
db = _holding_db()
try:
candles = hb.get_stored_candles(db, code, start_date, end_date)
if len(candles) < 20:
return jsonify({"error": f"봉 부족: {len(candles)}"}), 400
results = hv1.run_param_search_v1(candles, min_trades=min_trades,
base_cfg=base_cfg if base_cfg else None)
return jsonify({"code": code, "top": results[:30]})
finally:
db.close()
@app.route("/api/holding/param_search", methods=["GET"])
def api_holding_param_search():
"""홀딩 전략 파라미터 Grid Search (단일 종목)"""
code = request.args.get("code", "")
start_date = request.args.get("start", "2023-01-01")
end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d"))
min_trades = int(request.args.get("min_trades", 2))
if not code:
return jsonify({"error": "code 필수"}), 400
# 카드에서 전달된 현재 파라미터를 base_cfg로 사용
# (탐색 그리드에 없는 rsi_sell, rsi_period, rsi_buy3 등이 사용자 값으로 고정됨)
base_cfg = {}
for key in hb.DEFAULT_STOCK_CONFIG.keys():
val = request.args.get(key)
if val is not None:
try:
base_cfg[key] = float(val)
except (ValueError, TypeError):
pass
db = _holding_db()
try:
candles = hb.get_stored_candles(db, code, start_date, end_date)
if len(candles) < 20:
return jsonify({"error": f"봉 부족: {len(candles)}"}), 400
results = hb.run_param_search(candles, min_trades=min_trades,
base_cfg=base_cfg if base_cfg else None)
return jsonify({"code": code, "top": results[:30]})
finally:
db.close()
# ────────────────────────────────────────────────────────────────────────────
# 메인 페이지
# ────────────────────────────────────────────────────────────────────────────
@app.route("/api/holding/min_candles/fetch", methods=["POST"])
def api_holding_min_candles_fetch():
"""60분봉 수집 API: 백그라운드 스레드로 실행, 즉시 job_id 반환"""
body = request.get_json(force=True, silent=True) or {}
code = body.get("code", "")
start = body.get("start", "")
end = body.get("end", datetime.now().strftime("%Y-%m-%d"))
tf = int(body.get("tf", 60))
if not code or not start:
return jsonify({"error": "code, start 필수"}), 400
job_id = uuid.uuid4().hex[:8]
_min_fetch_jobs[job_id] = {
"status": "running",
"code": code,
"fetched": 0, # 수집한 1분봉 수
"saved": 0, # DB에 저장된 60분봉 수
"current_date": "", # 현재 처리 중인 날짜
"error": None,
}
def _run():
db = _holding_db()
try:
app_key, app_secret, base_url, mock = hb._get_kis_token(db)
hb.fetch_and_store_min_candles(
db, code, start, end,
app_key, app_secret, base_url,
tf_min=tf, mock=mock,
progress=_min_fetch_jobs[job_id],
)
_min_fetch_jobs[job_id]["status"] = "done"
except Exception as e:
logger.error(f"60분봉 수집 오류 ({code}): {e}")
_min_fetch_jobs[job_id]["status"] = "error"
_min_fetch_jobs[job_id]["error"] = str(e)
finally:
db.close()
threading.Thread(target=_run, daemon=True).start()
return jsonify({"job_id": job_id, "status": "started"})
@app.route("/api/holding/min_candles/status/<job_id>")
def api_holding_min_candles_status(job_id: str):
"""60분봉 수집 진행상황 폴링 엔드포인트"""
job = _min_fetch_jobs.get(job_id)
if not job:
return jsonify({"error": "없는 job_id"}), 404
return jsonify(job)
@app.route("/api/holding/min_candles/fetch_kiwoom", methods=["POST"])
def api_holding_min_candles_fetch_kiwoom():
"""
키움 REST API (ka10080) 로 60분봉 수집 → holding_min_candles 저장.
KIS와 달리 1회 호출에 900봉(약 128영업일), 연속조회로 2년치 수집 가능.
필요 DB env_config 키: KIWOOM_APP_KEY, KIWOOM_APP_SECRET
"""
body = request.get_json(force=True, silent=True) or {}
code = body.get("code", "")
start = body.get("start", "")
end = body.get("end", datetime.now().strftime("%Y-%m-%d"))
if not code or not start:
return jsonify({"error": "code, start 필수"}), 400
job_id = uuid.uuid4().hex[:8]
_min_fetch_jobs[job_id] = {
"status": "running",
"code": code,
"source": "kiwoom",
"fetched": 0,
"saved": 0,
"current_date": "",
"error": None,
}
def _run():
db = _holding_db()
try:
row = db.conn.execute(
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
).fetchone()
if not row:
raise RuntimeError("env_config 없음")
r = dict(row)
is_mock = str(r.get("KIS_MOCK", "true")).lower() in ("true", "1", "yes")
# KIS_MOCK 설정에 따라 키움 실전/모의 키 자동 선택
# 우선순위: REAL/MOCK 전용 키 → 레거시 KIWOOM_APP_KEY
if is_mock:
kiwoom_key = str(r.get("KIWOOM_APP_KEY_MOCK", "") or "").strip()
kiwoom_secret = str(r.get("KIWOOM_APP_SECRET_MOCK", "") or "").strip()
mode_label = "모의"
else:
kiwoom_key = str(r.get("KIWOOM_APP_KEY_REAL", "") or "").strip()
kiwoom_secret = str(r.get("KIWOOM_APP_SECRET_REAL", "") or "").strip()
mode_label = "실전"
# 전용 키 없으면 레거시 키로 폴백
if not kiwoom_key or not kiwoom_secret:
kiwoom_key = str(r.get("KIWOOM_APP_KEY", "") or "").strip()
kiwoom_secret = str(r.get("KIWOOM_APP_SECRET", "") or "").strip()
mode_label += "(레거시키 사용)"
if not kiwoom_key or not kiwoom_secret:
raise RuntimeError(
f"키움 {mode_label} API 키 미설정.\n"
"DB env_config에 KIWOOM_APP_KEY_REAL(또는 KIWOOM_APP_KEY_MOCK) / "
"KIWOOM_APP_SECRET_REAL(또는 KIWOOM_APP_SECRET_MOCK) 추가 필요"
)
logger.info(f"키움 60분봉 수집: {code} [{mode_label}] {start}~{end}")
rows = hb.fetch_60min_via_kiwoom(
code, start, end, kiwoom_key, kiwoom_secret, is_mock=is_mock
)
_min_fetch_jobs[job_id]["fetched"] = len(rows)
if not rows:
_min_fetch_jobs[job_id]["status"] = "done"
_min_fetch_jobs[job_id]["saved"] = 0
return
saved = 0
logger.info(f"키움 60분봉 DB 저장 시작: {code} {len(rows)}")
err_sample = None
for row_data in rows:
try:
# MariaDB 문법: ON DUPLICATE KEY UPDATE (SQLite의 ON CONFLICT 아님)
db.conn.execute(
"""
INSERT INTO holding_min_candles
(code, candle_dt, tf, open, high, low, close, volume)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
open=VALUES(open), high=VALUES(high),
low=VALUES(low), close=VALUES(close), volume=VALUES(volume)
""",
(
code,
row_data["candle_date"],
60,
row_data["open"],
row_data["high"],
row_data["low"],
row_data["close"],
row_data["volume"],
),
)
saved += 1
except Exception as row_err:
if err_sample is None:
err_sample = str(row_err) # 첫 번째 오류만 샘플 보존
db.conn.commit()
if err_sample:
logger.warning(f"⚠️ 키움 60분봉 일부 저장 실패 ({code}): {err_sample}")
_min_fetch_jobs[job_id]["saved"] = saved
_min_fetch_jobs[job_id]["current_date"] = rows[-1]["candle_date"] if rows else ""
_min_fetch_jobs[job_id]["status"] = "done"
logger.info(f"✅ 키움 60분봉 저장 완료: {code} {saved}/{len(rows)}")
except Exception as e:
logger.error(f"❌ 키움 60분봉 수집 오류 ({code}): {e}", exc_info=True)
_min_fetch_jobs[job_id]["status"] = "error"
_min_fetch_jobs[job_id]["error"] = str(e)
finally:
db.close()
threading.Thread(target=_run, daemon=True).start()
return jsonify({"job_id": job_id, "status": "started"})
@app.route("/api/holding/min_backtest", methods=["GET"])
def api_holding_min_backtest():
"""60분봉 기반 백테스트 (run_backtest 재사용, candle_date=candle_dt 로 호환)"""
code = request.args.get("code", "")
start_date = request.args.get("start", "")
end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d"))
tf = int(request.args.get("tf", 60))
if not code:
return jsonify({"error": "code 필수"}), 400
db = _holding_db()
try:
candles = hb.get_stored_min_candles(db, code, start_date, end_date, tf_min=tf)
if not candles:
stats = hb.get_min_candle_stats(db, code, tf_min=tf)
if stats["count"] == 0:
hint = f"📥 먼저 [60분봉 수집] 버튼으로 데이터를 수집하세요."
else:
hint = (f"DB에 {stats['count']}봉 있음 ({stats['min']} ~ {stats['max']})\n"
f"👉 백테스트 시작일을 '{stats['min'][:10]}' 이후로 설정하세요.")
return jsonify({"error": f"60분봉 없음\n{hint}"}), 400
if len(candles) < 20:
stats = hb.get_min_candle_stats(db, code, tf_min=tf)
hint = (f"DB에 {stats['count']}봉 있음 ({stats['min']} ~ {stats['max']})\n"
f"👉 백테스트 날짜 범위를 넓혀 최소 20봉 이상 포함하세요.")
return jsonify({
"error": f"봉 부족: {len(candles)}개 (최소 20개 필요)\n{hint}"
}), 400
# 파라미터 오버라이드 (일봉 백테스트와 동일 방식)
cfg = hb.get_stock_config(db, code)
overrides = ["rsi_period","rsi_buy1","rsi_buy2","rsi_buy3","rsi_sell",
"take_profit_pct","stop_loss_pct","buy1_ratio","buy2_ratio",
"buy3_ratio","slot_money"]
for k in overrides:
v = request.args.get(k)
if v is not None:
cfg[k] = float(v)
# tf_min=60 전달 → 52주 윈도우를 날짜 기반으로 정확히 계산
result = hb.run_backtest(candles, cfg, tf_min=tf)
result["code"] = code
result["candle_count"] = len(candles)
result["tf"] = tf
result["params"] = {k: cfg[k] for k in hb.DEFAULT_STOCK_CONFIG}
return jsonify(result)
finally:
db.close()
@app.route("/api/holding/min_stats", methods=["GET"])
def api_holding_min_stats():
"""60분봉 보유 현황 (종목 카드에서 표시용)"""
code = request.args.get("code", "")
tf = int(request.args.get("tf", 60))
if not code:
return jsonify({"error": "code 필수"}), 400
db = _holding_db()
try:
return jsonify(hb.get_min_candle_stats(db, code, tf_min=tf))
finally:
db.close()
# ─────────────────────────────────────────────────────────────────────────────
# 스캘핑 / 꼬리잡기 파라미터 탐색 (Web 전용 coarse 그리드)
# ─────────────────────────────────────────────────────────────────────────────
# Web용 coarse 그리드 (256 조합 내외 → 약 5~10초)
_SCALP_WEB_GRID = {
"rsi_oversold": [15, 18, 20, 25],
"sl_pct": [0.8, 1.0, 1.5, 2.0],
"tp_pct": [1.5, 2.0, 2.5, 3.5],
"drop_rate": [1.0, 1.5, 2.0, 3.0],
}
_TAIL_WEB_GRID = {
"min_drop_rate": [2.0, 3.0, 4.0, 5.0],
"min_recovery_ratio": [30.0, 40.0, 50.0, 60.0],
"sl_pct": [2.0, 3.0, 5.0],
"tp_pct": [3.0, 5.0, 7.0, 10.0],
}
@app.route("/api/backtest/scalping/param_search", methods=["GET"])
def api_scalping_param_search():
"""스캘핑 파라미터 Grid Search (Web coarse, ~256 조합)"""
start = request.args.get("start", "")
end = request.args.get("end", datetime.now().strftime("%Y-%m-%d"))
top_n = int(request.args.get("top", 10))
min_tr = int(request.args.get("min_trades", 3))
# 그리드 외 파라미터: JS가 현재 UI 값을 전달하면 사용, 없으면 Python 기본값
_SCALP_NON_GRID_KEYS = [
"rsi_period", "slot_money", "cooldown_min", "vol_mult",
"trail_trigger", "trail_stop", "time_start", "time_end", "max_daily",
]
base_params = {"start": start, "end": end}
for k in _SCALP_NON_GRID_KEYS:
v = request.args.get(k)
if v is not None:
base_params[k] = v
from itertools import product as iproduct
keys = list(_SCALP_WEB_GRID.keys())
combos = list(iproduct(*[_SCALP_WEB_GRID[k] for k in keys]))
results = []
with app.test_client() as c:
for vals in combos:
p = dict(base_params) # base(UI 값) 먼저
p.update(dict(zip(keys, vals))) # 그리드 값으로 덮어쓰기
r = c.get("/api/backtest/scalping?" + "&".join(f"{k}={v}" for k, v in p.items()))
if r.status_code != 200:
continue
s = json.loads(r.data).get("summary", {})
if s.get("total_trades", 0) < min_tr:
continue
results.append({
"params": p,
"total_pnl": s.get("total_pnl", 0),
"win_rate": s.get("win_rate", 0),
"total_trades": s.get("total_trades", 0),
"pf": s.get("profit_factor", 0),
"mdd": s.get("max_drawdown", 0),
})
results.sort(key=lambda x: x["total_pnl"], reverse=True)
return jsonify({"results": results[:top_n], "total_combos": len(combos)})
@app.route("/api/backtest/scalping/save_config", methods=["POST"])
def api_scalping_save_config():
"""스캘핑 파라미터를 env_config에 저장 (봇이 즉시 반영)"""
body = request.get_json(force=True, silent=True) or {}
db = _db()
try:
latest = db.get_latest_env()
snap = dict(latest["snapshot"]) if latest else {}
# UI % 단위 → 봇이 사용하는 소수 단위로 변환
if "rsi_oversold" in body:
snap["SCALP_RSI_OVERSOLD"] = str(float(body["rsi_oversold"]))
if "sl_pct" in body:
snap["SCALP_STOP_LOSS_PCT"] = str(float(body["sl_pct"]) / 100)
if "tp_pct" in body:
snap["SCALP_TAKE_PROFIT_PCT"]= str(float(body["tp_pct"]) / 100)
if "drop_rate" in body:
snap["SCALP_MIN_DROP_RATE"] = str(float(body["drop_rate"]) / 100)
if "trail_trigger" in body:
snap["SCALP_ATR_UP_MULT"] = str(float(body["trail_trigger"]))
if "cooldown_min" in body:
snap["SCALP_COOLDOWN_SEC"] = str(int(float(body["cooldown_min"])) * 60)
env_id = db.insert_env_snapshot(snap)
return jsonify({"ok": True, "env_id": env_id, "saved_keys": [
"SCALP_RSI_OVERSOLD","SCALP_STOP_LOSS_PCT","SCALP_TAKE_PROFIT_PCT","SCALP_MIN_DROP_RATE"
]})
except Exception as e:
logger.error(f"스캘핑 설정저장 오류: {e}")
return jsonify({"error": str(e)}), 500
finally:
db.close()
@app.route("/api/backtest/tail/param_search", methods=["GET"])
def api_tail_param_search():
"""꼬리잡기 파라미터 Grid Search (Web coarse, ~192 조합)"""
start = request.args.get("start", "")
end = request.args.get("end", datetime.now().strftime("%Y-%m-%d"))
top_n = int(request.args.get("top", 10))
min_tr = int(request.args.get("min_trades", 3))
# 그리드 외 파라미터: JS가 현재 UI 값을 전달하면 사용, 없으면 Python 기본값
_TAIL_NON_GRID_KEYS = [
"slot_money", "tail_ratio_min", "shoulder_min_high", "shoulder_cut_pct",
"rsi_threshold", "cooldown_min", "time_start", "time_end", "max_daily",
]
base_params = {"start": start, "end": end}
for k in _TAIL_NON_GRID_KEYS:
v = request.args.get(k)
if v is not None:
base_params[k] = v
from itertools import product as iproduct
keys = list(_TAIL_WEB_GRID.keys())
combos = list(iproduct(*[_TAIL_WEB_GRID[k] for k in keys]))
results = []
with app.test_client() as c:
for vals in combos:
p = dict(base_params) # base(UI 값) 먼저
p.update(dict(zip(keys, vals))) # 그리드 값으로 덮어쓰기
r = c.get("/api/backtest/tail?" + "&".join(f"{k}={v}" for k, v in p.items()))
if r.status_code != 200:
continue
s = json.loads(r.data).get("summary", {})
if s.get("total_trades", 0) < min_tr:
continue
results.append({
"params": p,
"total_pnl": s.get("total_pnl", 0),
"win_rate": s.get("win_rate", 0),
"total_trades": s.get("total_trades", 0),
"pf": s.get("profit_factor", 0),
"mdd": s.get("max_drawdown", 0),
})
results.sort(key=lambda x: x["total_pnl"], reverse=True)
return jsonify({"results": results[:top_n], "total_combos": len(combos)})
@app.route("/api/backtest/tail/save_config", methods=["POST"])
def api_tail_save_config():
"""꼬리잡기 파라미터를 env_config에 저장 (봇이 즉시 반영)"""
body = request.get_json(force=True, silent=True) or {}
db = _db()
try:
latest = db.get_latest_env()
snap = dict(latest["snapshot"]) if latest else {}
if "min_drop_rate" in body:
snap["MIN_DROP_RATE"] = str(float(body["min_drop_rate"]) / 100)
if "min_recovery_ratio" in body:
snap["MIN_RECOVERY_RATIO_SHORT"] = str(float(body["min_recovery_ratio"]) / 100)
if "tail_ratio_min" in body:
snap["TAIL_RATIO_MIN"] = str(float(body["tail_ratio_min"]))
if "sl_pct" in body:
# 꼬리잡기 STOP_LOSS_PCT는 봇에서 음수 저장
snap["STOP_LOSS_PCT"] = str(-abs(float(body["sl_pct"])) / 100)
if "tp_pct" in body:
snap["TAKE_PROFIT_PCT"] = str(float(body["tp_pct"]) / 100)
if "shoulder_cut_pct" in body:
snap["SHOULDER_CUT_PCT"] = str(float(body["shoulder_cut_pct"]) / 100)
if "shoulder_min_high" in body:
snap["SHOULDER_MIN_HIGH_PCT"] = str(float(body["shoulder_min_high"]) / 100)
if "cooldown_min" in body:
snap["REENTRY_COOLDOWN_SEC"] = str(int(float(body["cooldown_min"])) * 60)
if "rsi_threshold" in body:
snap["RSI_OVERHEAT_THRESHOLD"] = str(float(body["rsi_threshold"]))
if "rsi_period" in body:
snap["RSI_PERIOD"] = str(int(float(body["rsi_period"])))
if "time_start" in body:
snap["TIME_START"] = str(int(float(body["time_start"])))
if "time_end" in body:
snap["TIME_END"] = str(int(float(body["time_end"])))
if "max_daily" in body:
snap["MAX_STOCKS"] = str(int(float(body["max_daily"])))
if "tail_pct_min" in body:
snap["TAIL_PCT_MIN"] = str(float(body["tail_pct_min"]) / 100)
if "max_rec_3m" in body:
snap["MAX_RECOVERY_RATIO_3M"] = str(float(body["max_rec_3m"]) / 100)
if "high_chase_thr" in body:
snap["HIGH_PRICE_CHASE_THRESHOLD"] = str(float(body["high_chase_thr"]) / 100)
env_id = db.insert_env_snapshot(snap)
return jsonify({"ok": True, "env_id": env_id, "saved_keys": [
"MIN_DROP_RATE","MIN_RECOVERY_RATIO_SHORT","STOP_LOSS_PCT",
"TAKE_PROFIT_PCT","TAIL_RATIO_MIN","TAIL_PCT_MIN","SHOULDER_CUT_PCT",
"REENTRY_COOLDOWN_SEC","RSI_OVERHEAT_THRESHOLD","RSI_PERIOD",
"TIME_START","TIME_END","MAX_STOCKS","MAX_RECOVERY_RATIO_3M","HIGH_PRICE_CHASE_THRESHOLD"
]})
except Exception as e:
logger.error(f"꼬리잡기 설정저장 오류: {e}")
return jsonify({"error": str(e)}), 500
finally:
db.close()
@app.route("/api/env/params", methods=["GET"])
def api_env_params():
"""
env_config 최신 스냅샷에서 스캘핑·꼬리잡기 UI 초기값 반환.
% 단위 변환 및 음수 → 양수 변환까지 수행해 JS가 바로 input.value에 넣을 수 있도록 함.
값이 DB에 없으면 null 반환 → JS에서 기존 HTML 기본값 유지.
"""
db = _db()
try:
latest = db.get_latest_env()
snap = (latest or {}).get("snapshot", {})
def fv(key):
"""DB 값을 float으로 파싱, 없으면 None"""
v = snap.get(key)
if v is None or v == "":
return None
try:
return float(v)
except (ValueError, TypeError):
return None
def smart_pct(key):
"""
DB 저장 형식이 소수(0.03) 또는 퍼센트(3.0) 중 어느 쪽이든
UI에 항상 퍼센트 단위(3.0)로 반환.
- 절댓값 < 0.5 → 소수 형식 → ×100
- 절댓값 >= 0.5 → 이미 퍼센트 형식 → 그대로
- STOP_LOSS_PCT처럼 음수 저장된 경우 → 양수 변환
"""
v = fv(key)
if v is None:
return None
av = abs(v)
if av == 0:
return 0.0
result = av if av >= 0.5 else round(av * 100, 3)
return round(result, 3)
def sec_to_min(key):
"""초 → 분, None 유지"""
v = fv(key)
return round(v / 60) if v is not None else None
def _ratio_to_pct(val, default):
"""비율(0~1)을 폼 퍼센트 표시용(80, 96 등)으로. DB 0.8 → 80 반환."""
if val is None:
return default
try:
v = float(val)
if 0 < v <= 1:
return round(v * 100, 2)
if v > 1:
return round(v, 2)
except (ValueError, TypeError):
pass
return default
return jsonify({
"scalp": {
"rsi_oversold": fv("SCALP_RSI_OVERSOLD"), # 과매도 임계값 (숫자 그대로)
"sl_pct": smart_pct("SCALP_STOP_LOSS_PCT"), # 소수/퍼센트 자동감지
"tp_pct": smart_pct("SCALP_TAKE_PROFIT_PCT"),
"drop_rate": smart_pct("SCALP_MIN_DROP_RATE"),
"trail_trigger": fv("SCALP_ATR_UP_MULT"), # 배수 그대로
"cooldown_min": sec_to_min("SCALP_COOLDOWN_SEC"), # 스캘핑 전용 (꼬리잡기 REENTRY_COOLDOWN_SEC 와 분리)
"slot_money": fv("SLOT_MONEY_DEFAULT"),
},
"tail": {
"drop": smart_pct("MIN_DROP_RATE"),
"rec": smart_pct("MIN_RECOVERY_RATIO_SHORT"),
"tail_ratio": fv("TAIL_RATIO_MIN"),
"tail_pct_min": smart_pct("TAIL_PCT_MIN"),
"sl_pct": smart_pct("STOP_LOSS_PCT"), # 음수 → 양수도 자동처리
"tp_pct": smart_pct("TAKE_PROFIT_PCT"),
"smin": smart_pct("SHOULDER_MIN_HIGH_PCT"),
"scut": smart_pct("SHOULDER_CUT_PCT"),
"cool": sec_to_min("REENTRY_COOLDOWN_SEC"),
"rsi": fv("RSI_OVERHEAT_THRESHOLD"),
"rsi_period": int(fv("RSI_PERIOD") or 14), # RSI 기간 (실매·파라서치와 동일)
"time_start": int(fv("TIME_START") or 930), # 매수시작 HHMM
"time_end": int(fv("TIME_END") or 1500), # 매수종료 HHMM
"max_daily": int(fv("MAX_STOCKS") or 3), # 일일최대매수 (실매 MAX_STOCKS)
# 비율(0~1) 저장값을 폼 퍼센트 표시용으로 80·96 형태로 반환 (백테 API는 80→0.8로 변환)
"max_rec_3m": _ratio_to_pct(fv("MAX_RECOVERY_RATIO_3M"), 80),
"high_chase": _ratio_to_pct(fv("HIGH_PRICE_CHASE_THRESHOLD"), 96),
},
})
finally:
db.close()
HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="ko" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>📈 KIS Quant 백테스트 대시보드</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style>
/* ── Bootstrap dark-theme 변수 오버라이드 ── */
[data-bs-theme="dark"] {
--bs-body-bg: #0d1117;
--bs-body-color: #e6edf3;
--bs-border-color: #21262d;
--bs-secondary-bg: #161b22;
--bs-tertiary-bg: #161b22;
--bs-table-color: #e6edf3;
--bs-table-bg: transparent;
--bs-table-border-color: #21262d;
--bs-table-hover-bg: rgba(88,166,255,.07);
--bs-table-hover-color: #e6edf3;
--bs-form-control-bg: #21262d;
--bs-form-control-color: #e6edf3;
--bs-input-bg: #21262d;
--bs-input-color: #e6edf3;
--bs-link-color: #58a6ff;
}
:root {
--bg: #0d1117; --surface: #161b22; --border: #21262d;
--text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
--green: #3fb950; --red: #f85149; --yellow: #d29922; --purple: #bc8cff;
}
* { box-sizing: border-box; }
body { background: var(--bg) !important; color: var(--text) !important;
font-family: 'Segoe UI', sans-serif; font-size: 14px; }
/* 네비 / 탭 */
.navbar-brand { font-weight: 700; letter-spacing: 1px; color: var(--accent) !important; }
.nav-tabs .nav-link { color: var(--muted); border: none; padding: 10px 20px; }
.nav-tabs .nav-link.active { color: var(--accent); border-bottom: 2px solid var(--accent); background: transparent; }
.nav-tabs { border-bottom: 1px solid var(--border); }
/* 카드 */
.card { background: var(--surface) !important; border: 1px solid var(--border) !important; border-radius: 10px; }
.card-title { font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); margin-bottom: 4px; }
.stat-val { font-size: 26px; font-weight: 700; color: var(--text); }
.stat-val.green { color: var(--green) !important; }
.stat-val.red { color: var(--red) !important; }
/* 폼 컨트롤 */
.form-control, .form-select, input[type=number], input[type=date] {
background: #21262d !important; border: 1px solid #30363d !important;
color: var(--text) !important; border-radius: 6px;
}
.form-control:focus, .form-select:focus, input:focus {
background: #21262d !important; color: var(--text) !important;
border-color: var(--accent) !important; box-shadow: none !important;
}
.btn-primary { background: var(--accent) !important; border: none !important; font-weight: 600; color: #0d1117 !important; }
.btn-primary:hover { background: #79b8ff !important; }
label, .form-label { color: var(--muted) !important; font-size: 12px; }
/* 테이블 — Bootstrap dark theme가 대부분 처리, 남은 것만 보정 */
.table { font-size: 12px; }
.table td, .table th { color: var(--text) !important; border-color: var(--border) !important; padding: 6px 10px; }
.table thead th { color: var(--muted) !important; }
.table-responsive { max-height: 420px; overflow-y: auto; }
.table-responsive::-webkit-scrollbar { width: 5px; }
.table-responsive::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* 배지 */
.badge-win { background: rgba(63,185,80,.2); color: var(--green) !important; padding: 2px 8px; border-radius: 20px; }
.badge-loss { background: rgba(248,81,73,.2); color: var(--red) !important; padding: 2px 8px; border-radius: 20px; }
.badge-flat { background: rgba(210,153,34,.15); color: var(--yellow) !important; padding: 2px 8px; border-radius: 20px; }
/* 스피너 */
.spin-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55);
z-index: 9999; align-items: center; justify-content: center; }
.spin-overlay.show { display: flex; }
.spinner { width: 48px; height: 48px; border: 4px solid var(--border);
border-top-color: var(--accent); border-radius: 50%; animation: spin .9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* 기타 */
canvas { background: transparent !important; }
.section-title { font-size: 13px; font-weight: 600; color: var(--muted);
text-transform: uppercase; letter-spacing: 1px; margin: 24px 0 12px; }
input[type=radio] { accent-color: var(--accent); }
.ratio-bar { height: 8px; border-radius: 4px; background: var(--border); margin-top: 4px; overflow: hidden; }
.ratio-fill-g { height: 100%; border-radius: 4px 0 0 4px; background: var(--green); display: inline-block; }
.ratio-fill-r { height: 100%; border-radius: 0 4px 4px 0; background: var(--red); display: inline-block; float: right; }
.text-pnl-pos { color: var(--green) !important; }
.text-pnl-neg { color: var(--red) !important; }
/* 파라미터 설명 툴팁 */
.param-help { font-size: 10px; color: var(--muted); margin-top: 2px; }
</style>
</head>
<body>
<!-- 로딩 오버레이 -->
<div class="spin-overlay" id="spinner">
<div class="spinner"></div>
</div>
<nav class="navbar px-4 py-3" style="background:var(--surface); border-bottom:1px solid var(--border)">
<span class="navbar-brand">📈 KIS Quant Dashboard</span>
<span class="text-muted" id="lastRefresh" style="font-size:12px"></span>
</nav>
<div class="container-fluid px-4 py-3">
<!-- 탭 -->
<ul class="nav nav-tabs mb-4" id="mainTab">
<li class="nav-item"><a class="nav-link active" data-tab="actual" href="#">📊 실거래 분석</a></li>
<li class="nav-item"><a class="nav-link" data-tab="backtest" href="#">🧪 스캘핑 백테스트</a></li>
<li class="nav-item"><a class="nav-link" data-tab="tail" href="#">🎣 꼬리잡기 백테스트</a></li>
<li class="nav-item"><a class="nav-link" data-tab="holding" href="#">📈 홀딩 전략</a></li>
</ul>
<!-- ═══ 실거래 분석 탭 ═══════════════════════════════════════════════════ -->
<div id="tab-actual">
<div class="card p-3 mb-3">
<div class="row g-3 align-items-end">
<div class="col-auto">
<label class="form-label param-row" style="color:var(--muted);font-size:12px">전략 선택</label><br>
<div class="d-flex gap-3 mt-1">
<label class="d-flex align-items-center gap-1">
<input type="radio" name="act_strategy" value="SCALP_RSI_REVERSAL" checked> 스캘핑
</label>
<label class="d-flex align-items-center gap-1">
<input type="radio" name="act_strategy" value="SHORT_ANT_SHAKING"> 꼬리잡기
</label>
</div>
</div>
<div class="col-auto">
<label class="form-label param-row">시작일</label>
<input type="date" class="form-control" id="act_start" style="width:150px">
</div>
<div class="col-auto">
<label class="form-label param-row">종료일</label>
<input type="date" class="form-control" id="act_end" style="width:150px">
</div>
<div class="col-auto">
<button class="btn btn-primary" onclick="loadActual()">🔍 조회</button>
</div>
</div>
</div>
<!-- 요약 카드 -->
<div class="row g-3 mb-3" id="act_summary_row">
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">총 거래</div><div class="stat-val" id="a_total">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">승률</div><div class="stat-val" id="a_winrate">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">순손익</div><div class="stat-val" id="a_pnl">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">Profit Factor</div><div class="stat-val" id="a_pf">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">최대 낙폭(MDD)</div><div class="stat-val red" id="a_mdd">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">평균 보유(분)</div><div class="stat-val" id="a_hold">-</div>
</div></div>
</div>
<!-- 승/패 바 -->
<div class="card p-3 mb-3" id="act_winbar_card" style="display:none">
<div class="d-flex justify-content-between mb-1">
<span style="color:var(--green)" id="a_win_label">-</span>
<span style="color:var(--red)" id="a_loss_label">-</span>
</div>
<div class="ratio-bar">
<span class="ratio-fill-g" id="a_ratio_g" style="width:50%"></span>
<span class="ratio-fill-r" id="a_ratio_r" style="width:50%"></span>
</div>
</div>
<div class="row g-3">
<!-- 누적 손익 곡선 -->
<div class="col-12 col-lg-7">
<div class="card p-3">
<div class="card-title mb-2">누적 손익 곡선</div>
<canvas id="a_equity_chart" height="220"></canvas>
</div>
</div>
<!-- 매도 이유 / 상위종목 -->
<div class="col-12 col-lg-5">
<div class="card p-3 mb-3">
<div class="card-title mb-2">매도 이유 분포</div>
<canvas id="a_reason_chart" height="180"></canvas>
</div>
<div class="card p-3">
<div class="card-title mb-2">종목별 손익 TOP10</div>
<canvas id="a_code_chart" height="180"></canvas>
</div>
</div>
</div>
<!-- 일별 P&L 바 -->
<div class="card p-3 mt-3">
<div class="card-title mb-2">일별 손익</div>
<canvas id="a_daily_chart" height="150"></canvas>
</div>
<!-- 거래 목록 -->
<div class="section-title">거래 내역</div>
<div class="card p-3">
<div class="table-responsive">
<table class="table table-hover" id="act_table">
<thead><tr>
<th>매도일시</th><th>종목</th><th>매수가</th><th>매도가</th>
<th>수량</th><th>손익(원)</th><th>수익률</th><th>보유(분)</th><th>매도사유</th>
</tr></thead>
<tbody id="act_tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- ═══ 스캘핑 백테스트 탭 ═══════════════════════════════════════════════ -->
<div id="tab-backtest" style="display:none">
<div class="card p-3 mb-3">
<div class="row g-2 align-items-end">
<!-- 기간 -->
<div class="col-6 col-md-2">
<label class="form-label param-row">시작일</label>
<input type="date" class="form-control" id="bt_start">
</div>
<div class="col-6 col-md-2">
<label class="form-label param-row">종료일</label>
<input type="date" class="form-control" id="bt_end">
</div>
<!-- 기본 파라미터 -->
<div class="col-4 col-md-1">
<label class="form-label param-row">RSI 기간</label>
<input type="number" class="form-control" id="bt_rsi_period" value="3" min="2" max="14">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row">RSI 과매도</label>
<input type="number" class="form-control" id="bt_rsi_oversold" value="18" min="10" max="45" step="1">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row">손절(%)</label>
<input type="number" class="form-control" id="bt_sl" value="1.0" min="0.3" max="5" step="0.1">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row">익절(%)</label>
<input type="number" class="form-control" id="bt_tp" value="2.5" min="0.3" max="5" step="0.1">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row">낙폭필터(%)</label>
<input type="number" class="form-control" id="bt_drop" value="2.0" min="0.5" max="5" step="0.1">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row">투자금(원)</label>
<input type="number" class="form-control" id="bt_slot" value="1000000" min="100000" step="100000">
</div>
</div>
<!-- 고급 필터 (2행) -->
<div class="row g-2 align-items-end mt-1">
<div class="col-4 col-md-1">
<label class="form-label param-row" title="손익 후 같은 종목 재진입 금지 시간(분)">쿨다운(분)</label>
<input type="number" class="form-control" id="bt_cooldown" value="10" min="0" max="60" step="5">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="현재봉 거래량 > 20봉평균 × N배. 0=비활성">거래량배수(0=끔)</label>
<input type="number" class="form-control" id="bt_vol_mult" value="0" min="0" max="5" step="0.1">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="트레일링스탑 발동 수익률(%). 0=비활성">트레일발동%</label>
<input type="number" class="form-control" id="bt_trail_trig" value="0.7" min="0" max="3" step="0.1">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="고점 대비 이 % 하락 시 청산">트레일추적%</label>
<input type="number" class="form-control" id="bt_trail_stop" value="0.4" min="0.1" max="3" step="0.1">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="매수 가능 시작 시각(HHMM)">매수시작</label>
<input type="number" class="form-control" id="bt_time_start" value="900" min="900" max="1500" step="100">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="매수 가능 종료 시각(HHMM)">매수종료</label>
<input type="number" class="form-control" id="bt_time_end" value="1400" min="900" max="1530" step="100">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="종목당 하루 최대 거래횟수">일일최대매수</label>
<input type="number" class="form-control" id="bt_max_daily" value="3" min="1" max="10">
</div>
<div class="col-12 col-md-auto mt-2 d-flex gap-2 flex-wrap">
<button class="btn btn-primary px-4" onclick="runBacktest()">🚀 백테스트 실행</button>
<button class="btn btn-warning px-3" onclick="runScalpParamSearch()">🔍 파라미터탐색</button>
<button class="btn btn-success px-3" onclick="saveScalpConfig()">💾 봇에 설정저장</button>
</div>
</div>
<!-- 파라미터 탐색 결과 (접기/펼치기) -->
<div id="scalp_search_result" style="display:none" class="mt-3">
<div class="card-title" style="font-size:13px;color:var(--accent)">🔍 파라미터탐색 결과 (수익 기준 정렬)</div>
<div class="table-responsive">
<table class="table table-sm table-hover" style="font-size:12px">
<thead><tr><th>#</th><th>RSI과매도</th><th>손절%</th><th>익절%</th><th>낙폭%</th>
<th>손익(원)</th><th>승률%</th><th>거래수</th><th>PF</th><th>MDD</th><th></th></tr></thead>
<tbody id="scalp_search_tbody"></tbody>
</table>
</div>
</div>
<!-- 파라미터 설명 -->
<div class="mt-3 p-2 rounded" style="background:#0d1117;border:1px solid #21262d;font-size:11px;color:var(--muted)">
<b style="color:var(--accent)">📖 파라미터 설명 (실제 봇 <code>kis_scalping_ver1.py</code> 기준)</b>
<div class="row g-1 mt-1">
<div class="col-12 col-md-6">
<table style="width:100%;border-collapse:collapse">
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">RSI 기간</td><td>RSI 계산 캔들 수 → 봇: <b>3</b> (SCALP_RSI_PERIOD 고정)</td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">RSI 과매도</td><td>이 값 <b>이하</b>일 때 매수 신호 → 봇 DB: <b>SCALP_RSI_OVERSOLD=25</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">손절(%)</td><td>매수가 대비 하락 시 손절 → 봇 DB: <b>SCALP_STOP_LOSS_PCT=1.5%</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">익절(%)</td><td>매수가 대비 상승 시 익절 → 봇 DB: <b>SCALP_TAKE_PROFIT_PCT=1.5%</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">낙폭필터(%)</td><td>당일 시가→저가 낙폭이 이 값 이상이어야 진입 → 봇 DB: <b>SCALP_MIN_DROP_RATE=1.5%</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">투자금(원)</td><td>1회 최대 투자금 → 봇: <b>MAX_LOSS_PER_TRADE_KRW ÷ SCALP_STOP_LOSS_PCT</b> 공식 적용<br><span style="opacity:.7">예) 200,000÷0.015 = 13,333,333원</span></td></tr>
</table>
</div>
<div class="col-12 col-md-6">
<table style="width:100%;border-collapse:collapse">
<tr><td style="color:#d29922;padding:1px 6px;white-space:nowrap">쿨다운(분)</td><td>청산 후 같은 종목 재진입 금지 시간 → <span style="opacity:.7">봇에는 없음(백테스트 전용)</span></td></tr>
<tr><td style="color:#d29922;padding:1px 6px;white-space:nowrap">거래량배수</td><td>신호봉 거래량 > 20봉 평균 × N배. 0=비활성 → <span style="opacity:.7">봇에는 없음</span></td></tr>
<tr><td style="color:#d29922;padding:1px 6px;white-space:nowrap">트레일발동%</td><td>수익이 이 값 이상 되면 트레일링스탑 발동 → <span style="opacity:.7">봇에는 없음</span></td></tr>
<tr><td style="color:#d29922;padding:1px 6px;white-space:nowrap">트레일추적%</td><td>고점 대비 이 값 하락 시 자동 청산 → <span style="opacity:.7">봇에는 없음</span></td></tr>
<tr><td style="color:#d29922;padding:1px 6px;white-space:nowrap">매수시작/종료</td><td>매수 가능 시간대 (HHMM) → 봇: 장중 전체 허용</td></tr>
<tr><td style="color:#d29922;padding:1px 6px;white-space:nowrap">일일최대매수</td><td>종목당 하루 최대 거래횟수 → <span style="opacity:.7">봇에는 없음</span></td></tr>
</table>
</div>
</div>
<div class="mt-1">⚡ 진입가 = <b>신호봉 다음 봉 시가</b> (실제 주문과 동일하게 1봉 지연) | 수수료 0.015%×2 + 거래세 0.18% 자동 차감</div>
</div>
</div>
<!-- 파라미터 요약 -->
<div id="bt_params_bar" class="text-muted mb-3" style="font-size:12px;display:none"></div>
<!-- 요약 카드 -->
<div class="row g-3 mb-3">
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">총 거래</div><div class="stat-val" id="b_total">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">승률</div><div class="stat-val" id="b_winrate">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">순손익(수수료 제외)</div><div class="stat-val" id="b_pnl">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">Profit Factor</div><div class="stat-val" id="b_pf">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">최대 낙폭(MDD)</div><div class="stat-val red" id="b_mdd">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">평균 보유(분)</div><div class="stat-val" id="b_hold">-</div>
</div></div>
</div>
<!-- Buy & Hold 비교 -->
<!-- 승/패 바 -->
<div class="card p-3 mb-3" id="bt_winbar_card" style="display:none">
<div class="d-flex justify-content-between mb-1">
<span style="color:var(--green)" id="b_win_label">-</span>
<span style="color:var(--red)" id="b_loss_label">-</span>
</div>
<div class="ratio-bar">
<span class="ratio-fill-g" id="b_ratio_g" style="width:50%"></span>
<span class="ratio-fill-r" id="b_ratio_r" style="width:50%"></span>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-lg-8">
<div class="card p-3">
<div class="card-title mb-2">누적 손익 곡선 (가상)</div>
<canvas id="b_equity_chart" height="220"></canvas>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card p-3">
<div class="card-title mb-2">매도 이유 분포</div>
<canvas id="b_reason_chart" height="200"></canvas>
</div>
</div>
</div>
<div class="card p-3 mt-3">
<div class="card-title mb-2">일별 손익 (가상)</div>
<canvas id="b_daily_chart" height="150"></canvas>
</div>
<!-- 백테스트 거래 목록 -->
<div class="section-title">가상 거래 내역 (최근 200건)</div>
<div class="card p-3">
<div class="table-responsive">
<table class="table table-hover" id="bt_table">
<thead><tr>
<th>종목</th><th>매수시각</th><th>매도시각</th>
<th>매수가</th><th>매도가</th><th>수량</th>
<th>손익(원)</th><th>수익률%</th><th>보유(분)</th><th>사유</th><th>진입RSI</th>
</tr></thead>
<tbody id="bt_tbody"></tbody>
</table>
</div>
</div>
</div><!-- tab-backtest end -->
<!-- ═══ 꼬리잡기 백테스트 탭 ══════════════════════════════════════════════ -->
<div id="tab-tail" style="display:none">
<div class="card p-3 mb-3">
<!-- 기간 -->
<div class="row g-2 align-items-end mb-2">
<div class="col-auto">
<label class="form-label param-row">시작일</label>
<input type="date" class="form-control" id="tl_start" style="width:150px">
</div>
<div class="col-auto">
<label class="form-label param-row">종료일</label>
<input type="date" class="form-control" id="tl_end" style="width:150px">
</div>
<div class="col-auto">
<label class="form-label param-row">투자금(원)</label>
<input type="number" class="form-control" id="tl_slot" value="1000000" min="100000" step="100000" style="width:130px">
</div>
</div>
<!-- 핵심 파라미터 (1행) -->
<div class="row g-2 align-items-end">
<div class="col-4 col-md-1">
<label class="form-label param-row" title="당일 시가→저가 낙폭이 이 값 이상이어야 진입">낙폭필터(%)</label>
<input type="number" class="form-control" id="tl_drop" value="2.0" min="0.5" max="10" step="0.5">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="(현재가-저가)/(고가-저가) 최소 회복 비율(%)">회복률(%)</label>
<input type="number" class="form-control" id="tl_rec" value="40" min="10" max="90" step="5">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="꼬리길이/몸통길이 최소 비율 (망치봉 강도)">꼬리/몸통</label>
<input type="number" class="form-control" id="tl_tail" value="1.5" min="0.3" max="5" step="0.1">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row">손절(%)</label>
<input type="number" class="form-control" id="tl_sl" value="3.0" min="0.5" max="10" step="0.5">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row">익절(%)</label>
<input type="number" class="form-control" id="tl_tp" value="5.0" min="0.5" max="15" step="0.5">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="매수가 대비 이 값 이상 오른 뒤 어깨 컷 발동">어깨발동(%)</label>
<input type="number" class="form-control" id="tl_smin" value="1.5" min="0.5" max="5" step="0.5">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="고점 대비 이 % 하락 시 어깨 컷 매도">어깨컷(%)</label>
<input type="number" class="form-control" id="tl_scut" value="3.0" min="0.5" max="8" step="0.5">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="꼬리 길이 최소 비율(%) → TAIL_PCT_MIN">꼬리최소(%)</label>
<input type="number" class="form-control" id="tl_tail_pct" value="0.3" min="0.01" max="2" step="0.05">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="3분봉 회복위치 상한(%) → MAX_RECOVERY_RATIO_3M">3분최대회복(%)</label>
<input type="number" class="form-control" id="tl_max_rec_3m" value="80" min="50" max="100" step="5">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="고점 대비 이 비율 이상이면 진입 거부(%) → HIGH_PRICE_CHASE_THRESHOLD">고점추격방지(%)</label>
<input type="number" class="form-control" id="tl_high_chase" value="96" min="90" max="100" step="1">
</div>
</div>
<!-- 고급 파라미터 (2행) -->
<div class="row g-2 align-items-end mt-1">
<div class="col-4 col-md-1">
<label class="form-label param-row" title="RSI 계산 캔들 수 (실매·파라서치와 동일)">RSI기간</label>
<input type="number" class="form-control" id="tl_rsi_period" value="14" min="3" max="21" step="1">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="RSI 이 값 이상이면 과열로 진입 거부">RSI과열기준</label>
<input type="number" class="form-control" id="tl_rsi" value="78" min="50" max="95" step="1">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="청산 후 같은 종목 재진입 금지 시간(분)">쿨다운(분)</label>
<input type="number" class="form-control" id="tl_cool" value="30" min="0" max="120" step="5">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row">매수시작(HHMM)</label>
<input type="number" class="form-control" id="tl_ts" value="930" min="900" max="1500" step="100">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row">매수종료(HHMM)</label>
<input type="number" class="form-control" id="tl_te" value="1500" min="900" max="1530" step="100">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="종목당 하루 최대 거래횟수 (실매 MAX_STOCKS)">일일최대매수</label>
<input type="number" class="form-control" id="tl_maxd" value="3" min="1" max="10">
</div>
<div class="col-12 col-md-auto mt-2 d-flex gap-2 flex-wrap">
<button class="btn btn-primary px-4" onclick="runTailBacktest()">🚀 백테스트 실행</button>
<button class="btn btn-warning px-3" onclick="runTailParamSearch()">🔍 파라미터탐색</button>
<button class="btn btn-success px-3" onclick="saveTailConfig()">💾 봇에 설정저장</button>
</div>
</div>
<!-- 파라미터 탐색 결과 -->
<div id="tail_search_result" style="display:none" class="mt-3">
<div class="card-title" style="font-size:13px;color:var(--accent)">🔍 파라미터탐색 결과 (수익 기준 정렬)</div>
<div class="table-responsive">
<table class="table table-sm table-hover" style="font-size:12px">
<thead><tr><th>#</th><th>낙폭%</th><th>회복%</th><th>손절%</th><th>익절%</th>
<th>손익(원)</th><th>승률%</th><th>거래수</th><th>PF</th><th>MDD</th><th></th></tr></thead>
<tbody id="tail_search_tbody"></tbody>
</table>
</div>
</div>
<!-- 파라미터 설명 -->
<div class="mt-3 p-2 rounded" style="background:#0d1117;border:1px solid #21262d;font-size:11px;color:var(--muted)">
<b style="color:var(--accent)">📖 파라미터 설명 (실매·파라서치와 동일 DB env_config — 화면 값 = 백테에 사용되는 값)</b>
<div class="row g-1 mt-1">
<div class="col-12 col-md-6">
<table style="width:100%;border-collapse:collapse">
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">낙폭필터(%)</td><td>당일 시가→저가 하락폭 기준 → 봇 DB: <b>MIN_DROP_RATE=3%</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">회복률(%)</td><td>(현재가-저가)/(고가-저가) 최소 → 봇 DB: <b>MIN_RECOVERY_RATIO_SHORT=50%</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">꼬리/몸통</td><td>망치봉 강도 (꼬리÷몸통) → 봇 DB: <b>TAIL_RATIO_MIN=1.5</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">손절(%)</td><td>매수가 대비 하락 → 봇 DB: <b>STOP_LOSS_PCT=-3%</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">익절(%)</td><td>매수가 대비 상승 → 봇 DB: <b>TAKE_PROFIT_PCT=5%</b></td></tr>
</table>
</div>
<div class="col-12 col-md-6">
<table style="width:100%;border-collapse:collapse">
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">어깨발동(%)</td><td>수익이 이 값 이상일 때 어깨 컷 활성 → 봇 DB: <b>SHOULDER_MIN_HIGH_PCT=1.5%</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">어깨컷(%)</td><td>고점 대비 이 값 하락 시 매도 → 봇 DB: <b>SHOULDER_CUT_PCT=3%</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">꼬리최소(%)</td><td>꼬리 길이 최소 비율 → 봇 DB: <b>TAIL_PCT_MIN</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">3분최대회복(%)</td><td>3분봉 회복위치 상한 → 봇 DB: <b>MAX_RECOVERY_RATIO_3M</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">고점추격방지(%)</td><td>고점 대비 이 비율 이상이면 진입 거부 → 봇 DB: <b>HIGH_PRICE_CHASE_THRESHOLD</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">RSI과열기준</td><td>RSI 이상이면 진입 거부 → 봇 DB: <b>RSI_OVERHEAT_THRESHOLD=78</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">쿨다운(분)</td><td>청산 후 재진입 금지 → 봇 DB: <b>REENTRY_COOLDOWN_SEC</b> (초→분)</td></tr>
</table>
</div>
</div>
<div class="mt-1">⚡ 진입가 = <b>신호봉(3분봉) 다음 봉 시가</b> | 수수료 0.015%×2 + 거래세 0.18% 자동 차감 | 데이터: ws_candles 3분봉</div>
<div class="mt-1" style="color:var(--muted)">📌 웹 백테와 파라미터서치 결과를 비교하려면 <b>시작일/종료일</b>과 <b>매수시작/매수종료</b>를 동일하게 두세요. 파라서치는 DB 기준(예: 9301500)을 쓰므로, 비교 시 웹에서도 0930·1500으로 맞추면 거래 수·손익이 비슷해집니다.</div>
</div>
</div>
<!-- 파라미터 요약 바 -->
<div id="tl_params_bar" class="text-muted mb-3" style="font-size:12px;display:none"></div>
<!-- 요약 카드 -->
<div class="row g-3 mb-3">
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">총 거래</div><div class="stat-val" id="tl_total">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">승률</div><div class="stat-val" id="tl_winrate">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">순손익(수수료 제외)</div><div class="stat-val" id="tl_pnl">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">Profit Factor</div><div class="stat-val" id="tl_pf">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">최대 낙폭(MDD)</div><div class="stat-val red" id="tl_mdd">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">평균 보유(분)</div><div class="stat-val" id="tl_hold">-</div>
</div></div>
</div>
<!-- 승/패 바 -->
<div class="card p-3 mb-3" id="tl_winbar_card" style="display:none">
<div class="d-flex justify-content-between mb-1">
<span style="color:var(--green)" id="tl_win_label">-</span>
<span style="color:var(--red)" id="tl_loss_label">-</span>
</div>
<div class="ratio-bar">
<span class="ratio-fill-g" id="tl_ratio_g" style="width:50%"></span>
<span class="ratio-fill-r" id="tl_ratio_r" style="width:50%"></span>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-lg-8">
<div class="card p-3">
<div class="card-title mb-2">누적 손익 곡선 (가상)</div>
<canvas id="tl_equity_chart" height="220"></canvas>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card p-3">
<div class="card-title mb-2">매도 이유 분포</div>
<canvas id="tl_reason_chart" height="200"></canvas>
</div>
</div>
</div>
<div class="card p-3 mt-3">
<div class="card-title mb-2">일별 손익 (가상)</div>
<canvas id="tl_daily_chart" height="150"></canvas>
</div>
<!-- 거래 내역 테이블 -->
<div class="section-title">가상 거래 내역 (최근 200건)</div>
<div class="card p-3">
<div class="table-responsive">
<table class="table table-hover" id="tl_table">
<thead><tr>
<th>종목</th><th>매수시각</th><th>매도시각</th>
<th>매수가</th><th>매도가</th><th>수량</th>
<th>손익(원)</th><th>보유(분)</th><th>매도사유</th>
</tr></thead>
<tbody id="tl_tbody"></tbody>
</table>
</div>
</div>
</div><!-- tab-tail end -->
<!-- ═══ 홀딩 전략 탭 ═══════════════════════════════════════════════════════ -->
<div id="tab-holding" style="display:none">
<!-- 공통 기간 -->
<div class="card p-3 mb-3">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label param-row">백테스트 시작일</label>
<input type="date" class="form-control" id="hd_start" style="width:150px">
</div>
<div class="col-auto">
<label class="form-label param-row">종료일</label>
<input type="date" class="form-control" id="hd_end" style="width:150px">
</div>
<div class="col-auto">
<label class="form-label param-row">캔들 수집 시작일</label>
<input type="date" class="form-control" id="hd_fetch_start" style="width:150px">
</div>
<div class="col-auto mt-auto">
<button class="btn btn-secondary" onclick="hdLoadStocks()">🔄 종목 목록 갱신</button>
</div>
</div>
<div class="mt-2" style="font-size:11px;color:var(--muted)">
⚡ 일봉 기반 전략 | RSI 3단계 분할매수 (35/30/25) | 수익률+익절 / RSI과열 / 손절 | <code>holding_candles</code> DB 사용
</div>
</div>
<!-- 종목 카드 목록 (JS로 동적 생성) -->
<div id="hd_stock_list" class="row g-3"></div>
<!-- 백테스트 결과 영역 (선택한 종목) -->
<div id="hd_result_area" style="display:none" class="mt-4">
<div class="section-title" id="hd_result_title">백테스트 결과</div>
<div class="row g-3 mb-3">
<div class="col-6 col-md-2"><div class="card p-3">
<div class="card-title">총 거래</div><div class="stat-val" id="hd_total">-</div>
</div></div>
<div class="col-6 col-md-2"><div class="card p-3">
<div class="card-title">승률</div><div class="stat-val" id="hd_wr">-</div>
</div></div>
<div class="col-6 col-md-2"><div class="card p-3">
<div class="card-title">순손익</div><div class="stat-val" id="hd_pnl">-</div>
</div></div>
<div class="col-6 col-md-2"><div class="card p-3">
<div class="card-title">Profit Factor</div><div class="stat-val" id="hd_pf">-</div>
</div></div>
<div class="col-6 col-md-2"><div class="card p-3">
<div class="card-title">최대 낙폭(MDD)</div><div class="stat-val red" id="hd_mdd">-</div>
</div></div>
<div class="col-6 col-md-2"><div class="card p-3">
<div class="card-title">평균 보유(일)</div><div class="stat-val" id="hd_hold">-</div>
</div></div>
</div>
<!-- Buy & Hold 비교 -->
<div class="row g-3 mb-3" id="hd_bnh_row" style="display:none">
<div class="col-12"><div class="card p-3" style="border-color:var(--accent);background:rgba(88,166,255,0.05)">
<div class="card-title" style="color:var(--accent)">📊 Buy &amp; Hold 벤치마크 비교 <small class="text-muted">("가만히 들고 있었으면?")</small></div>
<div class="row g-3 mt-1">
<div class="col-6 col-md-3"><div class="card-title">봇 수익률(투자금 대비)</div><div class="stat-val" id="hd_bot_pct">-</div></div>
<div class="col-6 col-md-3"><div class="card-title">Buy &amp; Hold 수익률</div><div class="stat-val" id="hd_bnh_pct">-</div></div>
<div class="col-6 col-md-3"><div class="card-title">Buy &amp; Hold 손익(원)</div><div class="stat-val" id="hd_bnh_pnl">-</div></div>
<div class="col-6 col-md-3"><div class="card-title">알파(봇 - B&amp;H)</div><div class="stat-val" id="hd_alpha">-</div></div>
</div>
</div></div>
</div>
<div class="row g-3 mb-3">
<div class="col-12 col-lg-8"><div class="card p-3">
<div class="card-title mb-2">누적 손익 곡선</div>
<canvas id="hd_equity_chart" height="220"></canvas>
</div></div>
<div class="col-12 col-lg-4"><div class="card p-3">
<div class="card-title mb-2">매도 이유 분포</div>
<canvas id="hd_reason_chart" height="200"></canvas>
</div></div>
</div>
<!-- 파라미터 탐색 결과 -->
<div id="hd_search_result" style="display:none">
<div class="section-title">🔍 파라미터 탐색 결과 (TOP 20)</div>
<div class="card p-3">
<div class="table-responsive">
<table class="table table-hover" style="font-size:12px">
<thead id="hd_search_thead"><tr>
<th>-</th><th>손익(원)</th><th>승률</th><th>거래</th><th>PF</th><th>보유(일)</th><th>적용</th>
</tr></thead>
<tbody id="hd_search_tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- 거래 내역 -->
<div class="section-title mt-3">가상 거래 내역 (최근 200건)</div>
<div class="card p-3">
<div class="table-responsive">
<table class="table table-hover" style="font-size:12px">
<thead><tr>
<th>매수일</th><th>매도일</th><th>평단가</th><th>매도가</th>
<th>수량</th><th>손익(원)</th><th>보유(일)</th><th>매도사유</th>
</tr></thead>
<tbody id="hd_trade_tbody"></tbody>
</table>
</div>
</div>
</div>
</div><!-- tab-holding end -->
</div><!-- container end -->
<script>
// ────────────────────────────────────────────
// 유틸
// ────────────────────────────────────────────
const $ = id => document.getElementById(id);
const fmt = n => n == null ? '-' : Number(n).toLocaleString('ko-KR');
const fmtPct = n => n == null ? '-' : (n >= 0 ? '+' : '') + Number(n).toFixed(2) + '%';
const colorPnl = (el, val) => {
el.classList.remove('green','red');
if (val > 0) el.classList.add('green');
else if (val < 0) el.classList.add('red');
};
// 차트 인스턴스 저장 (재생성용)
const charts = {};
function destroyChart(id) {
if (charts[id]) { charts[id].destroy(); delete charts[id]; }
}
const CHART_COLORS = {
blue: '#58a6ff', green: '#3fb950', red: '#f85149',
yellow: '#d29922', purple:'#bc8cff', orange:'#f0883e',
};
function lineChart(id, labels, data, label, color) {
destroyChart(id);
const ctx = $(id).getContext('2d');
charts[id] = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
label, data,
borderColor: color || CHART_COLORS.blue,
borderWidth: 2,
pointRadius: data.length > 100 ? 0 : 3,
fill: { target: 'origin', above: 'rgba(63,185,80,0.08)', below: 'rgba(248,81,73,0.08)' },
tension: 0.3,
}]
},
options: {
responsive: true, animation: false,
plugins: { legend: { display: false },
tooltip: { callbacks: { label: ctx => fmt(ctx.parsed.y) + '' } } },
scales: {
x: { ticks: { color: '#8b949e', maxTicksLimit: 12 }, grid: { color:'#21262d' } },
y: { ticks: { color: '#8b949e', callback: v => fmt(v) }, grid: { color:'#21262d' } },
}
}
});
}
function barChart(id, labels, data, colors) {
destroyChart(id);
const ctx = $(id).getContext('2d');
charts[id] = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [{
data,
backgroundColor: colors || data.map(v => v >= 0 ? 'rgba(63,185,80,0.7)' : 'rgba(248,81,73,0.7)'),
}]
},
options: {
responsive: true, animation: false,
plugins: { legend: { display: false },
tooltip: { callbacks: { label: ctx => fmt(ctx.parsed.y) + '' } } },
scales: {
x: { ticks: { color: '#8b949e', maxTicksLimit: 14 }, grid: { color:'#21262d' } },
y: { ticks: { color: '#8b949e', callback: v => fmt(v) }, grid: { color:'#21262d' } },
}
}
});
}
function doughnutChart(id, labels, data) {
destroyChart(id);
const ctx = $(id).getContext('2d');
const palette = [CHART_COLORS.green, CHART_COLORS.red, CHART_COLORS.yellow,
CHART_COLORS.purple, CHART_COLORS.orange, CHART_COLORS.blue];
charts[id] = new Chart(ctx, {
type: 'doughnut',
data: {
labels,
datasets: [{ data, backgroundColor: palette, borderWidth: 0 }]
},
options: {
responsive: true, animation: false,
plugins: { legend: { position: 'right', labels: { color:'#8b949e', font:{ size:11 } } } }
}
});
}
// ────────────────────────────────────────────
// 탭 전환
// ────────────────────────────────────────────
document.querySelectorAll('[data-tab]').forEach(el => {
el.addEventListener('click', e => {
e.preventDefault();
document.querySelectorAll('[data-tab]').forEach(x => x.classList.remove('active'));
el.classList.add('active');
const tab = el.dataset.tab;
$('tab-actual').style.display = tab === 'actual' ? '' : 'none';
$('tab-backtest').style.display = tab === 'backtest' ? '' : 'none';
$('tab-tail').style.display = tab === 'tail' ? '' : 'none';
$('tab-holding').style.display = tab === 'holding' ? '' : 'none';
if (tab === 'holding') hdLoadStocks();
});
});
// ────────────────────────────────────────────
// 날짜 기본값 설정
// ────────────────────────────────────────────
(function() {
const today = new Date();
const fmt = d => d.toISOString().slice(0, 10);
const monthAgo = new Date(today); monthAgo.setMonth(monthAgo.getMonth() - 1);
$('act_start').value = fmt(monthAgo);
$('act_end').value = fmt(today);
$('bt_start').value = fmt(monthAgo);
$('bt_end').value = fmt(today);
$('tl_start').value = fmt(monthAgo);
$('tl_end').value = fmt(today);
const twoYearsAgo = new Date(today); twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2);
$('hd_start').value = fmt(twoYearsAgo);
$('hd_end').value = fmt(today);
$('hd_fetch_start').value = fmt(twoYearsAgo);
// 캔들 수집 시작일 변경 시 → 백테스트 시작일도 자동 연동
$('hd_fetch_start').addEventListener('change', function() {
if (this.value) $('hd_start').value = this.value;
});
$('lastRefresh').textContent = '업데이트: ' + today.toLocaleString('ko-KR');
// ── DB 최신 env_config → 스캘핑·꼬리잡기 초기값 자동 로드 ──────────────
// DB 값이 있으면 반영, null이면 HTML에 하드코딩된 기본값 그대로 유지
fetch('/api/env/params')
.then(r => r.json())
.then(d => {
const set = (id, val) => { if (val !== null && val !== undefined) $(id).value = val; };
// 스캘핑 탭
const s = d.scalp || {};
set('bt_rsi_oversold', s.rsi_oversold);
set('bt_sl', s.sl_pct);
set('bt_tp', s.tp_pct);
set('bt_drop', s.drop_rate);
set('bt_trail_trig', s.trail_trigger);
set('bt_cooldown', s.cooldown_min);
if (s.slot_money) set('bt_slot', s.slot_money);
// 꼬리잡기 탭 — DB(env_config)와 동일 값으로 채움 (실매·파라서치와 동일)
const t = d.tail || {};
set('tl_drop', t.drop);
set('tl_rec', t.rec);
set('tl_tail', t.tail_ratio);
set('tl_sl', t.sl_pct);
set('tl_tp', t.tp_pct);
set('tl_smin', t.smin);
set('tl_scut', t.scut);
set('tl_cool', t.cool);
set('tl_rsi', t.rsi);
set('tl_ts', t.time_start);
set('tl_te', t.time_end);
set('tl_maxd', t.max_daily);
set('tl_rsi_period', t.rsi_period);
set('tl_tail_pct', t.tail_pct_min);
set('tl_max_rec_3m', t.max_rec_3m);
set('tl_high_chase', t.high_chase);
})
.catch(() => { /* DB 연결 실패 시 HTML 기본값 유지 */ });
})();
// ────────────────────────────────────────────
// 실거래 분석 로드
// ────────────────────────────────────────────
function loadActual() {
const strategy = document.querySelector('input[name=act_strategy]:checked').value;
const start = $('act_start').value;
const end = $('act_end').value;
showSpinner(true);
fetch(`/api/actual?strategy=${strategy}&start=${start}&end=${end}`)
.then(r => r.json())
.then(d => {
showSpinner(false);
renderActual(d);
})
.catch(err => { showSpinner(false); alert('오류: ' + err); });
}
function renderActual(d) {
const s = d.summary;
$('a_total').textContent = fmt(s.total_trades) + '';
$('a_winrate').textContent = s.win_rate + '%';
colorPnl($('a_winrate'), s.win_rate - 50);
$('a_pnl').textContent = fmt(s.total_pnl) + '';
colorPnl($('a_pnl'), s.total_pnl);
$('a_pf').textContent = s.profit_factor >= 999 ? '' : s.profit_factor;
colorPnl($('a_pf'), s.profit_factor - 1);
$('a_mdd').textContent = '-' + fmt(s.max_drawdown) + '';
$('a_hold').textContent = s.avg_hold_min + '';
// 승/패 바
if (s.total_trades > 0) {
$('act_winbar_card').style.display = '';
const wr = s.win_rate;
$('a_win_label').textContent = `🟢 승 ${s.win_trades}건 (${wr}%)`;
$('a_loss_label').textContent = `🔴 패 ${s.loss_trades}건 (${(100-wr).toFixed(1)}%)`;
$('a_ratio_g').style.width = wr + '%';
$('a_ratio_r').style.width = (100 - wr) + '%';
}
// 누적 손익 곡선
const eqLabels = d.equity.map((e,i) => i % Math.max(1, Math.floor(d.equity.length/20)) === 0 ? e.date : '');
lineChart('a_equity_chart', d.equity.map(e => e.date), d.equity.map(e => e.cum_pnl), '누적손익');
// 매도이유 도넛
const rKeys = Object.keys(d.reasons);
doughnutChart('a_reason_chart', rKeys, rKeys.map(k => d.reasons[k]));
// 종목별 손익 바
barChart('a_code_chart',
d.top_codes.map(c => c.name || c.code),
d.top_codes.map(c => c.pnl));
// 일별 바
barChart('a_daily_chart',
d.daily.map(e => e.date.slice(5)),
d.daily.map(e => e.pnl));
// 테이블
const tbody = $('act_tbody');
tbody.innerHTML = '';
const rows = [...d.trades].reverse();
rows.forEach(t => {
const pnl = Number(t.realized_pnl || 0);
const rate = Number(t.profit_rate || 0);
const badge = pnl > 0 ? 'badge-win' : (pnl < 0 ? 'badge-loss' : 'badge-flat');
const pnlCls = pnl > 0 ? 'text-pnl-pos' : (pnl < 0 ? 'text-pnl-neg' : '');
tbody.insertAdjacentHTML('beforeend', `
<tr>
<td>${(t.sell_date||'').slice(0,16)}</td>
<td>${t.name||''}<br><span style="color:var(--muted);font-size:11px">${t.code}</span></td>
<td>${fmt(t.buy_price)}</td>
<td>${fmt(t.sell_price)}</td>
<td>${fmt(t.qty)}</td>
<td class="${pnlCls}">${fmt(pnl)}</td>
<td><span class="${badge}">${fmtPct(rate)}</span></td>
<td>${t.hold_minutes||0}</td>
<td style="font-size:11px">${t.sell_reason||''}</td>
</tr>`);
});
}
// ────────────────────────────────────────────
// 백테스트 실행
// ────────────────────────────────────────────
// ── 스캘핑 파라미터탐색 ──────────────────────────────────────────────────────
function runScalpParamSearch() {
const start = $('bt_start').value;
const end = $('bt_end').value;
if (!start) { alert('시작일을 입력하세요'); return; }
// 그리드에 없는 파라미터를 현재 UI 값으로 고정 (rsi_period, cooldown 등)
const base = new URLSearchParams({
start, end, top: 10, min_trades: 3,
rsi_period: $('bt_rsi_period').value,
slot_money: $('bt_slot').value,
cooldown_min: $('bt_cooldown').value,
vol_mult: $('bt_vol_mult').value,
trail_trigger: $('bt_trail_trig').value,
trail_stop: $('bt_trail_stop').value,
time_start: $('bt_time_start').value,
time_end: $('bt_time_end').value,
max_daily: $('bt_max_daily').value,
}).toString();
showSpinner(true);
fetch('/api/backtest/scalping/param_search?' + base)
.then(r => r.json())
.then(d => {
showSpinner(false);
if (d.error) { alert('' + d.error); return; }
const res = d.results || [];
const tbody = $('scalp_search_tbody');
tbody.innerHTML = '';
if (!res.length) { tbody.innerHTML = '<tr><td colspan="11" class="text-center text-muted">결과 없음 (기간/봉 부족)</td></tr>'; }
res.forEach((r, i) => {
const p = r.params;
tbody.insertAdjacentHTML('beforeend', `<tr>
<td>${i+1}</td>
<td>${p.rsi_oversold}</td><td>${p.sl_pct}</td><td>${p.tp_pct}</td><td>${p.drop_rate}</td>
<td style="color:${r.total_pnl>=0?'var(--green)':'var(--red)'}">${r.total_pnl?.toLocaleString()}</td>
<td>${r.win_rate?.toFixed(1)}%</td><td>${r.total_trades}</td>
<td>${r.pf?.toFixed(2)}</td><td>${Math.round(r.mdd)?.toLocaleString()}</td>
<td><button class="btn btn-xs btn-outline-info" style="font-size:10px;padding:1px 6px"
onclick="applyScalpParams(${p.rsi_oversold},${p.sl_pct},${p.tp_pct},${p.drop_rate})">적용</button></td>
</tr>`);
});
$('scalp_search_result').style.display = '';
console.log(`[스캘핑 탐색] 조합 ${d.total_combos}개 / 유효 결과 ${res.length}개`);
})
.catch(e => { showSpinner(false); alert('오류: ' + e); });
}
function applyScalpParams(rsi_oversold, sl_pct, tp_pct, drop_rate) {
$('bt_rsi_oversold').value = rsi_oversold;
$('bt_sl').value = sl_pct;
$('bt_tp').value = tp_pct;
$('bt_drop').value = drop_rate;
alert(`✅ 파라미터 적용 완료\nRSI과매도:${rsi_oversold} / 손절:${sl_pct}% / 익절:${tp_pct}% / 낙폭:${drop_rate}%\n\n백테스트를 다시 실행하세요.`);
}
function saveScalpConfig() {
const body = {
rsi_oversold: parseFloat($('bt_rsi_oversold').value),
sl_pct: parseFloat($('bt_sl').value),
tp_pct: parseFloat($('bt_tp').value),
drop_rate: parseFloat($('bt_drop').value),
trail_trigger: parseFloat($('bt_trail_trig').value),
cooldown_min: parseFloat($('bt_cooldown').value),
};
if (!confirm(`💾 스캘핑 봇(kis_scalping_ver1.py)에 아래 파라미터를 저장합니까?\n\n` +
`RSI과매도: ${body.rsi_oversold}\n손절: ${body.sl_pct}%\n익절: ${body.tp_pct}%\n낙폭필터: ${body.drop_rate}%\n` +
`\n⚠ 봇이 실행 중이면 다음 루프부터 즉시 반영됩니다.`)) return;
fetch('/api/backtest/scalping/save_config', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
}).then(r => r.json()).then(d => {
if (d.error) { alert('' + d.error); return; }
alert(`✅ 저장 완료 (env_id: ${d.env_id})\n저장 키: ${d.saved_keys?.join(', ')}`);
}).catch(e => alert('오류: ' + e));
}
// ── 꼬리잡기 파라미터탐색 ────────────────────────────────────────────────────
function runTailParamSearch() {
const start = $('tl_start').value;
const end = $('tl_end').value;
if (!start) { alert('시작일을 입력하세요'); return; }
// 그리드에 없는 파라미터를 현재 UI 값으로 고정 (tail_ratio_min, shoulder 등)
const base = new URLSearchParams({
start, end, top: 10, min_trades: 3,
slot_money: $('tl_slot').value,
tail_ratio_min: $('tl_tail').value,
shoulder_min_high: $('tl_smin').value,
shoulder_cut_pct: $('tl_scut').value,
rsi_threshold: $('tl_rsi').value,
cooldown_min: $('tl_cool').value,
time_start: $('tl_ts').value,
time_end: $('tl_te').value,
max_daily: $('tl_maxd').value,
}).toString();
showSpinner(true);
fetch('/api/backtest/tail/param_search?' + base)
.then(r => r.json())
.then(d => {
showSpinner(false);
if (d.error) { alert('' + d.error); return; }
const res = d.results || [];
const tbody = $('tail_search_tbody');
tbody.innerHTML = '';
if (!res.length) { tbody.innerHTML = '<tr><td colspan="11" class="text-center text-muted">결과 없음 (기간/봉 부족)</td></tr>'; }
res.forEach((r, i) => {
const p = r.params;
tbody.insertAdjacentHTML('beforeend', `<tr>
<td>${i+1}</td>
<td>${p.min_drop_rate}</td><td>${p.min_recovery_ratio}</td>
<td>${p.sl_pct}</td><td>${p.tp_pct}</td>
<td style="color:${r.total_pnl>=0?'var(--green)':'var(--red)'}">${r.total_pnl?.toLocaleString()}</td>
<td>${r.win_rate?.toFixed(1)}%</td><td>${r.total_trades}</td>
<td>${r.pf?.toFixed(2)}</td><td>${Math.round(r.mdd)?.toLocaleString()}</td>
<td><button class="btn btn-xs btn-outline-info" style="font-size:10px;padding:1px 6px"
onclick="applyTailParams(${p.min_drop_rate},${p.min_recovery_ratio},${p.sl_pct},${p.tp_pct})">적용</button></td>
</tr>`);
});
$('tail_search_result').style.display = '';
console.log(`[꼬리잡기 탐색] 조합 ${d.total_combos}개 / 유효 결과 ${res.length}개`);
})
.catch(e => { showSpinner(false); alert('오류: ' + e); });
}
function applyTailParams(drop, rec, sl, tp) {
$('tl_drop').value = drop;
$('tl_rec').value = rec;
$('tl_sl').value = sl;
$('tl_tp').value = tp;
alert(`✅ 파라미터 적용 완료\n낙폭:${drop}% / 회복:${rec}% / 손절:${sl}% / 익절:${tp}%\n\n백테스트를 다시 실행하세요.`);
}
function saveTailConfig() {
const body = {
min_drop_rate: parseFloat($('tl_drop').value),
min_recovery_ratio: parseFloat($('tl_rec').value),
tail_ratio_min: parseFloat($('tl_tail').value),
tail_pct_min: parseFloat($('tl_tail_pct').value),
max_rec_3m: parseFloat($('tl_max_rec_3m').value),
sl_pct: parseFloat($('tl_sl').value),
tp_pct: parseFloat($('tl_tp').value),
shoulder_cut_pct: parseFloat($('tl_scut').value),
shoulder_min_high: parseFloat($('tl_smin').value),
high_chase_thr: parseFloat($('tl_high_chase').value),
cooldown_min: parseFloat($('tl_cool').value),
rsi_threshold: parseFloat($('tl_rsi').value),
rsi_period: parseInt($('tl_rsi_period').value, 10),
time_start: parseInt($('tl_ts').value, 10),
time_end: parseInt($('tl_te').value, 10),
max_daily: parseInt($('tl_maxd').value, 10),
};
if (!confirm(`💾 꼬리잡기 봇(실매·백테와 동일 DB)에 아래 파라미터를 저장합니까?\n\n` +
`낙폭: ${body.min_drop_rate}% | 회복: ${body.min_recovery_ratio}% | 꼬리/몸통: ${body.tail_ratio_min} | 꼬리최소: ${body.tail_pct_min}%\n` +
`손절: ${body.sl_pct}% | 익절: ${body.tp_pct}% | 어깨컷: ${body.shoulder_cut_pct}% | 3분최대회복: ${body.max_rec_3m}% | 고점추격방지: ${body.high_chase_thr}%\n` +
`RSI기간: ${body.rsi_period} | RSI과열: ${body.rsi_threshold} | 쿨다운: ${body.cooldown_min}분 | 매수시간: ${body.time_start}-${body.time_end} | 일일최대: ${body.max_daily}\n\n` +
`⚠️ STOP_LOSS_PCT는 음수로 저장됩니다. 봇 실행 중이면 다음 루프부터 반영됩니다.`)) return;
fetch('/api/backtest/tail/save_config', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
}).then(r => r.json()).then(d => {
if (d.error) { alert('' + d.error); return; }
alert(`✅ 저장 완료 (env_id: ${d.env_id})\n저장 키: ${d.saved_keys?.join(', ')}`);
}).catch(e => alert('오류: ' + e));
}
// ─────────────────────────────────────────────────────────────────────────────
function runBacktest() {
const params = {
start: $('bt_start').value,
end: $('bt_end').value,
rsi_period: $('bt_rsi_period').value,
rsi_oversold: $('bt_rsi_oversold').value,
sl_pct: $('bt_sl').value,
tp_pct: $('bt_tp').value,
drop_rate: $('bt_drop').value,
slot_money: $('bt_slot').value,
cooldown_min: $('bt_cooldown').value,
vol_mult: $('bt_vol_mult').value,
trail_trigger:$('bt_trail_trig').value,
trail_stop: $('bt_trail_stop').value,
time_start: $('bt_time_start').value,
time_end: $('bt_time_end').value,
max_daily: $('bt_max_daily').value,
};
const qs = new URLSearchParams(params).toString();
showSpinner(true);
fetch('/api/backtest/scalping?' + qs)
.then(r => r.json())
.then(d => { showSpinner(false); renderBacktest(d); })
.catch(err => { showSpinner(false); alert('오류: ' + err); });
}
function renderBacktest(d) {
const s = d.summary;
const p = d.params;
$('bt_params_bar').style.display = '';
$('bt_params_bar').innerHTML =
`RSI(${p.rsi_period}) &lt;${p.rsi_oversold} | ` +
`손절-${p.sl_pct}% 익절+${p.tp_pct}% | ` +
`낙폭≥${p.drop_rate}% | 쿨다운${p.cooldown_min}분 | ` +
`거래량${p.vol_mult}배 | 트레일(${p.trail_trigger}%발동/${p.trail_stop}%추적) | ` +
`${p.time_window} | 일${p.max_daily}회 | ` +
`종목수 ${p.codes_analyzed}개`;
$('b_total').textContent = fmt(s.total_trades) + '';
$('b_winrate').textContent = s.win_rate + '%';
colorPnl($('b_winrate'), s.win_rate - 50);
$('b_pnl').textContent = fmt(s.total_pnl) + '';
colorPnl($('b_pnl'), s.total_pnl);
$('b_pf').textContent = s.profit_factor >= 999 ? '' : s.profit_factor;
colorPnl($('b_pf'), s.profit_factor - 1);
$('b_mdd').textContent = '-' + fmt(s.max_drawdown) + '';
$('b_hold').textContent = s.avg_hold_min + '';
if (s.total_trades > 0) {
$('bt_winbar_card').style.display = '';
const wr = s.win_rate;
$('b_win_label').textContent = `🟢 익절+손절 승 ${s.win_trades}건 (${wr}%)`;
$('b_loss_label').textContent = `🔴 패 ${s.loss_trades}건 (${(100-wr).toFixed(1)}%)`;
$('b_ratio_g').style.width = wr + '%';
$('b_ratio_r').style.width = (100 - wr) + '%';
}
lineChart('b_equity_chart',
d.equity.map(e => e.date.slice(0,4)+'-'+e.date.slice(4,6)+'-'+e.date.slice(6)),
d.equity.map(e => e.cum_pnl), '가상누적손익');
const rKeys = Object.keys(d.reasons);
doughnutChart('b_reason_chart', rKeys, rKeys.map(k => d.reasons[k]));
barChart('b_daily_chart',
d.daily.map(e => e.date.slice(5)),
d.daily.map(e => e.pnl));
const tbody = $('bt_tbody');
tbody.innerHTML = '';
const rows = [...d.trades].reverse();
rows.forEach(t => {
const pnl = t.pnl;
const badge = pnl > 0 ? 'badge-win' : (pnl < 0 ? 'badge-loss' : 'badge-flat');
const pnlCls = pnl > 0 ? 'text-pnl-pos' : (pnl < 0 ? 'text-pnl-neg' : '');
const bt = t.buy_time || '';
const st = t.sell_time || '';
tbody.insertAdjacentHTML('beforeend', `
<tr>
<td>${t.code}</td>
<td style="font-size:11px">${bt.slice(0,4)+'-'+bt.slice(4,6)+'-'+bt.slice(6,8)+' '+bt.slice(8,10)+':'+bt.slice(10,12)}</td>
<td style="font-size:11px">${st.slice(0,4)+'-'+st.slice(4,6)+'-'+st.slice(6,8)+' '+st.slice(8,10)+':'+st.slice(10,12)}</td>
<td>${fmt(t.buy_price)}</td>
<td>${fmt(t.sell_price)}</td>
<td>${t.qty}</td>
<td class="${pnlCls}">${fmt(pnl)}</td>
<td><span class="${badge}">${fmtPct(t.profit_rate)}</span></td>
<td>${t.hold_min}</td>
<td style="font-size:11px">${t.sell_reason}</td>
<td>${t.rsi_entry}</td>
</tr>`);
});
}
// ────────────────────────────────────────────
// 꼬리잡기 백테스트
// ────────────────────────────────────────────
function runTailBacktest() {
const params = {
start: $('tl_start').value,
end: $('tl_end').value,
min_drop_rate: $('tl_drop').value,
min_recovery_ratio: $('tl_rec').value,
tail_ratio_min: $('tl_tail').value,
tail_pct_min: $('tl_tail_pct').value,
max_rec_3m: $('tl_max_rec_3m').value,
sl_pct: $('tl_sl').value,
tp_pct: $('tl_tp').value,
shoulder_min_high: $('tl_smin').value,
shoulder_cut_pct: $('tl_scut').value,
high_chase_thr: $('tl_high_chase').value,
rsi_period: $('tl_rsi_period').value,
rsi_threshold: $('tl_rsi').value,
cooldown_min: $('tl_cool').value,
time_start: $('tl_ts').value,
time_end: $('tl_te').value,
max_daily: $('tl_maxd').value,
slot_money: $('tl_slot').value,
};
const qs = new URLSearchParams(params).toString();
showSpinner(true);
fetch('/api/backtest/tail?' + qs)
.then(r => r.json())
.then(d => { showSpinner(false); renderTailBacktest(d); })
.catch(err => { showSpinner(false); alert('오류: ' + err); });
}
function renderTailBacktest(d) {
const s = d.summary;
const p = d.params;
$('tl_params_bar').style.display = '';
$('tl_params_bar').innerHTML =
`낙폭≥${p.min_drop_rate}% | 회복률≥${p.min_recovery_ratio}% | 꼬리/몸통≥${p.tail_ratio_min} | ` +
`손절-${p.sl_pct}% 익절+${p.tp_pct}% | 어깨컷(${p.shoulder_min_high}%발동/${p.shoulder_cut_pct}%하락) | ` +
`RSI(${p.rsi_period})과열<${p.rsi_threshold} | 쿨다운${p.cooldown_min}분 | ${p.time_window || '930-1500'} | 일${p.max_daily || 3}회 | 종목수 ${p.codes_analyzed}개`;
$('tl_total').textContent = (s.total_trades||0) + '';
$('tl_winrate').textContent = (s.win_rate||0) + '%';
colorPnl($('tl_winrate'), (s.win_rate||0) - 50);
$('tl_pnl').textContent = fmt(s.total_pnl) + '';
colorPnl($('tl_pnl'), s.total_pnl);
$('tl_pf').textContent = (s.profit_factor||0) >= 999 ? '' : (s.profit_factor||0);
colorPnl($('tl_pf'), (s.profit_factor||0) - 1);
$('tl_mdd').textContent = '-' + fmt(s.max_drawdown) + '';
$('tl_hold').textContent = (s.avg_hold_min||0) + '';
if ((s.total_trades||0) > 0) {
$('tl_winbar_card').style.display = '';
const wr = s.win_rate||0;
$('tl_win_label').textContent = `🟢 승 ${s.win_trades}건 (${wr}%)`;
$('tl_loss_label').textContent = `🔴 패 ${s.loss_trades}건 (${(100-wr).toFixed(1)}%)`;
$('tl_ratio_g').style.width = wr + '%';
$('tl_ratio_r').style.width = (100 - wr) + '%';
}
// 누적 손익 곡선
lineChart('tl_equity_chart',
d.equity.map(e => e.date),
d.equity.map(e => e.cum_pnl),
'가상누적손익', '#3fb950');
// 매도 이유 도넛
const rKeys = Object.keys(d.reasons || {});
doughnutChart('tl_reason_chart', rKeys, rKeys.map(k => d.reasons[k]));
// 일별 바
barChart('tl_daily_chart',
d.daily.map(e => e.date.slice(5)),
d.daily.map(e => e.pnl));
// 거래 목록
const tbody = $('tl_tbody');
tbody.innerHTML = '';
const rows = [...(d.trades||[])].reverse();
rows.forEach(t => {
const pnl = t.pnl;
const badge = pnl > 0 ? 'badge-win' : (pnl < 0 ? 'badge-loss' : 'badge-flat');
const pnlCls = pnl > 0 ? 'text-pnl-pos' : (pnl < 0 ? 'text-pnl-neg' : '');
const entry = t.entry_time || '';
const exit = t.exit_time || '';
const ep = t.entry || 0;
const xp = t.exit || 0;
const qty = ep > 0 ? Math.max(1, Math.floor(1000000 / ep)) : 0;
const ratePct= ep > 0 ? ((xp - ep) / ep * 100).toFixed(2) : '0.00';
function fmtCT(ct) {
if (!ct || ct.length < 12) return ct || '';
return ct.slice(0,4)+'-'+ct.slice(4,6)+'-'+ct.slice(6,8)+' '+ct.slice(8,10)+':'+ct.slice(10,12);
}
tbody.insertAdjacentHTML('beforeend', `
<tr>
<td>${t.code}</td>
<td style="font-size:11px">${fmtCT(entry)}</td>
<td style="font-size:11px">${fmtCT(exit)}</td>
<td>${fmt(ep)}</td>
<td>${fmt(xp)}</td>
<td>${qty}</td>
<td class="${pnlCls}">${fmt(pnl)}</td>
<td>${t.hold_min}</td>
<td><span class="${badge}">${t.reason}</span></td>
</tr>`);
});
}
// ────────────────────────────────────────────
// 홀딩 전략
// ────────────────────────────────────────────
let hdCurrentCode = null;
let hdCurrentName = null;
let hdCurrentCfg = {};
function hdLoadStocks() {
fetch('/api/holding/stocks')
.then(r => r.json())
.then(stocks => hdRenderStockList(stocks))
.catch(err => alert('종목 로드 실패: ' + err));
}
function hdRenderStockList(stocks) {
const list = $('hd_stock_list');
list.innerHTML = '';
stocks.forEach(s => {
const candleInfo = s.candle_count > 0
? `<span style="color:var(--green);font-size:11px">✅ 일봉 ${s.candle_count}봉 (${s.candle_min||'?'} ~ ${s.candle_max||'?'})</span>`
: `<span style="color:var(--red);font-size:11px">⚠️ 일봉 없음</span>`;
const min60Info = s.min60_count > 0
? `<span style="color:#58a6ff;font-size:11px">⏱ 60분봉 ${s.min60_count}봉 (${s.min60_min||'?'} ~ ${s.min60_max||'?'})</span>`
: `<span style="color:var(--muted);font-size:11px">⏱ 60분봉 없음</span>`;
list.insertAdjacentHTML('beforeend', `
<div class="col-12 col-md-6 col-lg-4">
<div class="card p-3">
<div class="d-flex justify-content-between align-items-start mb-1">
<div>
<b style="font-size:15px">${s.name||s.code}</b>
<span style="color:var(--muted);font-size:12px;margin-left:6px">${s.code}</span>
</div>
<div class="text-end">${candleInfo}<br>${min60Info}</div>
</div>
<!-- 파라미터 인풋 -->
<div class="row g-1" style="font-size:12px">
<div class="col-12" style="color:var(--accent);font-size:11px;font-weight:600;padding:2px 4px">▶ 추세 설정</div>
<div class="col-4"><label>MA단기 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_ma_fast" value="${s.ma_fast||20}" min="5" max="60" step="1"></label></div>
<div class="col-4"><label>MA장기 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_ma_slow" value="${s.ma_slow||60}" min="20" max="200" step="5"></label></div>
<div class="col-4"><label>트레일(%) <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_trail_stop_pct" value="${s.trail_stop_pct??8}" min="0" max="30" step="0.5" title="고점 대비 X% 하락 시 추세이탈 청산. 0=비활성"></label></div>
<div class="col-6"><label>추세필터
<select class="form-control form-control-sm" id="cfg_${s.code}_trend_filter">
<option value="1" ${(s.trend_filter??1)>=0.5?'selected':''}>ON (추세추종)</option>
<option value="0" ${(s.trend_filter??1)<0.5?'selected':''}>OFF (역추세)</option>
</select></label></div>
<div class="col-12" style="color:var(--muted);font-size:11px;font-weight:600;padding:2px 4px">▶ RSI 매수/매도</div>
<div class="col-6"><label>RSI기간 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_period" value="${s.rsi_period||14}" min="5" max="30"></label></div>
<div class="col-6"><label>매수1 RSI≤ <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_buy1" value="${s.rsi_buy1||55}" min="10" max="70" step="1" title="추세 ON: 1차 눌림목 / 추세 OFF: 1단계"></label></div>
<div class="col-6"><label>매수2 RSI≤ <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_buy2" value="${s.rsi_buy2||45}" min="10" max="65" step="1" title="추세 ON: 2차 눌림목 / 추세 OFF: 2단계"></label></div>
<div class="col-6"><label>매수3 RSI≤ <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_buy3" value="${s.rsi_buy3||35}" min="5" max="55" step="1" title="추세 OFF 전용 3단계 (추세 ON에선 미사용)"></label></div>
<div class="col-6"><label>매도 RSI≥ <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_sell" value="${s.rsi_sell||75}" min="50" max="95" step="1"></label></div>
<div class="col-6"><label>익절(%) <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_take_profit_pct" value="${s.take_profit_pct||15}" min="1" max="50" step="1" title="추세장엔 크게 잡아야 탈 안 남"></label></div>
<div class="col-6"><label>손절(%) <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_stop_loss_pct" value="${s.stop_loss_pct||10}" min="2" max="30" step="0.5"></label></div>
<div class="col-6"><label>투자금(원) <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_slot_money" value="${s.slot_money||3000000}" min="500000" step="500000"></label></div>
<div class="col-12" style="color:var(--muted);font-size:11px;font-weight:600;padding:2px 4px">▶ 샹들리에 엑시트 (추세장 청산 — 고점 ATR × 배수)</div>
<div class="col-6"><label title="ATR 계산 기간 (기본 14일)">ATR기간
<input type="number" class="form-control form-control-sm" id="cfg_${s.code}_atr_period"
value="${s.atr_period||14}" min="5" max="30" step="1"></label></div>
<div class="col-6"><label title="샹들리에 배수. 2=타이트 / 3=기본 / 4=여유 (클수록 오래 홀딩)">샹들리에배수
<input type="number" class="form-control form-control-sm" id="cfg_${s.code}_atr_mult"
value="${s.atr_mult||3}" min="1" max="6" step="0.5"></label></div>
<div class="col-12" style="color:var(--muted);font-size:11px;font-weight:600;padding:2px 4px">▶ 낙폭 필터 (0=비활성 / 예: 20 → 고점 대비 20% 이상 빠진 날만 매수)</div>
<div class="col-4"><label title="역대 최고가(ATH) 대비 현재가 낙폭이 X% 이상일 때만 매수. 0=끔">ATH낙폭(%)
<input type="number" class="form-control form-control-sm" id="cfg_${s.code}_ath_drop_min_pct"
value="${s.ath_drop_min_pct||0}" min="0" max="70" step="5"></label></div>
<div class="col-4"><label title="당해연도 고점 대비 낙폭 필터. 0=끔">연도낙폭(%)
<input type="number" class="form-control form-control-sm" id="cfg_${s.code}_year_drop_min_pct"
value="${s.year_drop_min_pct||0}" min="0" max="70" step="5"></label></div>
<div class="col-4"><label title="52주 고점 대비 낙폭 필터. 0=끔">52주낙폭(%)
<input type="number" class="form-control form-control-sm" id="cfg_${s.code}_w52_drop_min_pct"
value="${s.w52_drop_min_pct||0}" min="0" max="70" step="5"></label></div>
</div>
<!-- 버튼 (일봉 + 60분봉 수집) -->
<div class="d-flex gap-1 mt-2 flex-wrap">
<button class="btn btn-sm btn-outline-secondary" onclick="hdFetchCandles('${s.code}','${s.name||s.code}')" title="KIS 일봉 수집">📥 일봉수집</button>
<button class="btn btn-sm btn-outline-secondary" style="color:#58a6ff;border-color:#58a6ff" onclick="hdFetchMinCandlesKiwoom('${s.code}','${s.name||s.code}')" title="키움 60분봉 — 1회 900봉(약 128영업일), 연속조회로 2년치 수집. KIWOOM_APP_KEY 설정 필요">🗂 키움60분봉</button>
<button class="btn btn-sm btn-primary" onclick="hdRunBacktest('${s.code}','${s.name||s.code}')" title="추세추종 전략 (MA + RSI)">🚀 추세BT</button>
<button class="btn btn-sm btn-info" style="color:#fff" onclick="hdRunV1Backtest('${s.code}','${s.name||s.code}')" title="RSI 3단계 분할매수 전략 (횡보장)">📊 분할매수BT</button>
<button class="btn btn-sm btn-warning" onclick="hdParamSearch('${s.code}','${s.name||s.code}')" title="추세추종 파라미터 탐색">🔍 추세탐색</button>
<button class="btn btn-sm btn-outline-info" onclick="hdV1ParamSearch('${s.code}','${s.name||s.code}')" title="분할매수 파라미터 탐색">🔍 분할탐색</button>
<button class="btn btn-sm btn-success" onclick="hdSaveConfig('${s.code}','${s.name||s.code}')">💾 저장</button>
</div>
</div>
</div>`);
});
}
function hdGetCfg(code) {
const fields = ['rsi_period','rsi_buy1','rsi_buy2','rsi_buy3','rsi_sell',
'take_profit_pct','stop_loss_pct','slot_money',
'atr_period','atr_mult',
'ma_fast','ma_slow','trail_stop_pct','trend_filter',
'ath_drop_min_pct','year_drop_min_pct','w52_drop_min_pct'];
const cfg = {code};
fields.forEach(f => {
const el = $(`cfg_${code}_${f}`);
if (el) cfg[f] = parseFloat(el.value);
});
return cfg;
}
function hdFetchCandles(code, name) {
const start = $('hd_fetch_start').value;
const end = $('hd_end').value;
if (!start || !end) { alert('캔들 수집 날짜를 설정하세요'); return; }
showSpinner(true);
fetch('/api/holding/candles/fetch', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code, start, end}),
})
.then(r => r.json())
.then(d => {
showSpinner(false);
if (d.error) { alert('' + d.error); return; }
alert(`✅ ${name}(${code}) 일봉 수집 완료\n수집: ${d.fetched}봉 / 저장: ${d.saved}봉`);
hdLoadStocks();
})
.catch(err => { showSpinner(false); alert('오류: ' + err); });
}
function hdFetchMinCandlesKiwoom(code, name) {
// 키움 REST API (ka10080) 로 60분봉 수집 — 1회 900봉, 2년치 수집 가능
const start = $('hd_fetch_start').value;
const end = $('hd_end').value;
if (!start || !end) { alert('캔들 수집 날짜를 설정하세요'); return; }
if (!confirm(`🗂 ${name}(${code}) 키움 60분봉 수집 시작\n기간: ${start} ~ ${end}\n\n키움 API는 1회 900봉(약 128영업일), 연속조회로 2년치 수집 가능.\nDB에 KIWOOM_APP_KEY / KIWOOM_APP_SECRET 설정 필요.\n계속하시겠습니까?`)) return;
fetch('/api/holding/min_candles/fetch_kiwoom', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code, start, end}),
})
.then(r => r.json())
.then(d => {
if (d.error) { alert('' + d.error); return; }
const jobId = d.job_id;
const statusEl = document.getElementById(`min_status_${code}`) || (() => {
const el = document.createElement('span');
el.id = `min_status_${code}`;
el.style.cssText = 'font-size:11px;color:var(--accent);margin-left:6px';
return el;
})();
function poll() {
fetch(`/api/holding/min_candles/status/${jobId}`)
.then(r => r.json())
.then(j => {
if (j.status === 'running') {
statusEl.textContent = `⏳ 키움수집중… ${j.fetched}봉 → 저장 ${j.saved}봉`;
setTimeout(poll, 2000);
} else if (j.status === 'done') {
statusEl.textContent = `✅ 완료! ${j.saved}봉 저장됨 (키움)`;
hdLoadStocks();
} else {
statusEl.textContent = `❌ ${j.error}`;
}
})
.catch(() => setTimeout(poll, 3000));
}
poll();
})
.catch(err => alert('오류: ' + err));
}
function hdFetchMinCandles(code, name) {
// 60분봉 수집(KIS) — 백그라운드 스레드로 실행, 폴링으로 진행상황 표시
const start = $('hd_fetch_start').value;
const end = $('hd_end').value;
if (!start || !end) { alert('캔들 수집 날짜를 설정하세요'); return; }
if (!confirm(`⏱ ${name}(${code}) KIS 60분봉 수집 시작\n기간: ${start} ~ ${end}\n\n⚠ KIS는 최근 2일치 한계. 긴 기간은 [키움 60분봉]을 사용하세요.\n계속하시겠습니까?`)) return;
fetch('/api/holding/min_candles/fetch', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code, start, end, tf: 60}),
})
.then(r => r.json())
.then(d => {
if (d.error) { alert('' + d.error); return; }
const jobId = d.job_id;
// 수집 상태 표시 영역 업데이트 (스피너 대신 인라인 표시)
const btn = document.querySelector(`[onclick*="hdFetchMinCandles('${code}'"]`);
const statusEl = document.getElementById(`min_status_${code}`) || (() => {
const el = document.createElement('span');
el.id = `min_status_${code}`;
el.style.cssText = 'font-size:11px;color:var(--accent);margin-left:6px';
if (btn) btn.parentNode.insertBefore(el, btn.nextSibling);
return el;
})();
function poll() {
fetch(`/api/holding/min_candles/status/${jobId}`)
.then(r => r.json())
.then(j => {
if (j.error) { statusEl.textContent = '' + j.error; return; }
const d_str = j.current_date ? ` (${j.current_date.slice(0,4)}-${j.current_date.slice(4,6)}-${j.current_date.slice(6,8)})` : '';
if (j.status === 'running') {
statusEl.textContent = `⏳ 수집중… 1분봉 ${j.fetched}개 → 저장 ${j.saved}봉${d_str}`;
setTimeout(poll, 2000);
} else if (j.status === 'done') {
statusEl.textContent = `✅ 완료! 저장 ${j.saved}봉`;
hdLoadStocks();
} else {
statusEl.textContent = `❌ 오류: ${j.error}`;
}
})
.catch(() => setTimeout(poll, 3000));
}
poll();
})
.catch(err => alert('오류: ' + err));
}
function hdRunMinBacktest(code, name) {
// 60분봉 기반 백테스트 — 현재 카드 파라미터 적용
const cfg = hdGetCfg(code);
const start = $('hd_start').value;
const end = $('hd_end').value;
const qs = new URLSearchParams({code, start, end, tf: 60, ...cfg}).toString();
showSpinner(true);
fetch('/api/holding/min_backtest?' + qs)
.then(r => r.json())
.then(d => {
showSpinner(false);
if (d.error) { alert('' + d.error); return; }
hdCurrentCode = code;
hdCurrentName = name;
hdCurrentCfg = cfg;
// 결과 렌더링 — 기존 일봉 백테스트와 동일 함수 재사용, 제목만 구분
hdRenderResult(d, `[${name}] 60분봉 백테스트 결과 (${d.candle_count}봉)`);
})
.catch(err => { showSpinner(false); alert('오류: ' + err); });
}
function hdRunBacktest(code, name) {
const cfg = hdGetCfg(code);
const start = $('hd_start').value;
const end = $('hd_end').value;
const qs = new URLSearchParams({code, start, end, ...cfg}).toString();
showSpinner(true);
fetch('/api/holding/backtest?' + qs)
.then(r => r.json())
.then(d => {
showSpinner(false);
if (d.error) { alert('' + d.error); return; }
hdCurrentCode = code;
hdCurrentName = name;
hdRenderResult(d, name);
})
.catch(err => { showSpinner(false); alert('오류: ' + err); });
}
function hdGetV1Cfg(code) {
// V1 전략 파라미터: 카드에서 RSI/낙폭 관련 값만 수집 (MA/추세 파라미터 제외)
const fields = ['rsi_period','rsi_buy1','rsi_buy2','rsi_buy3','rsi_sell',
'take_profit_pct','stop_loss_pct','slot_money',
'ath_drop_min_pct','year_drop_min_pct','w52_drop_min_pct'];
const cfg = {code};
fields.forEach(f => {
const el = $(`cfg_${code}_${f}`);
if (el) cfg[f] = parseFloat(el.value);
});
return cfg;
}
function hdRunV1Backtest(code, name) {
// V1 RSI 분할매수 백테스트
const cfg = hdGetV1Cfg(code);
const start = $('hd_start').value;
const end = $('hd_end').value;
const qs = new URLSearchParams({code, start, end, ...cfg}).toString();
showSpinner(true);
fetch('/api/holding/v1/backtest?' + qs)
.then(r => r.json())
.then(d => {
showSpinner(false);
if (d.error) { alert('' + d.error); return; }
hdRenderResult(d, name + ' [분할매수V1]');
})
.catch(err => { showSpinner(false); alert('오류: ' + err); });
}
function hdV1ParamSearch(code, name) {
// V1 RSI 분할매수 파라미터탐색
const cfg = hdGetV1Cfg(code);
const start = $('hd_start').value;
const end = $('hd_end').value;
const qs = new URLSearchParams({code, start, end, ...cfg}).toString();
showSpinner(true);
fetch('/api/holding/v1/param_search?' + qs)
.then(r => r.json())
.then(d => {
showSpinner(false);
if (d.error) { alert('' + d.error); return; }
hdRenderParamSearch(d.top, code, name + ' [분할매수V1]');
})
.catch(err => { showSpinner(false); alert('오류: ' + err); });
}
function hdParamSearch(code, name) {
const start = $('hd_start').value;
const end = $('hd_end').value;
// 카드의 현재 파라미터를 기준값으로 전달 (rsi_sell, rsi_period 등 그리드에 없는 값도 정확히 반영)
const cfg = hdGetCfg(code);
const qs = new URLSearchParams({code, start, end, ...cfg}).toString();
showSpinner(true);
fetch('/api/holding/param_search?' + qs)
.then(r => r.json())
.then(d => {
showSpinner(false);
if (d.error) { alert('' + d.error); return; }
hdRenderParamSearch(d.top, code, name);
})
.catch(err => { showSpinner(false); alert('오류: ' + err); });
}
function hdSaveConfig(code, name) {
const cfg = hdGetCfg(code);
cfg.name = name;
fetch(`/api/holding/config/${code}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(cfg),
})
.then(r => r.json())
.then(d => { if (d.ok) alert(`✅ ${name}(${code}) 파라미터 저장 완료`); })
.catch(err => alert('오류: ' + err));
}
function hdApplySearchResult(code, name, p) {
// 탐색 결과를 카드 인풋에 반영
Object.entries(p).forEach(([k, v]) => {
const el = $(`cfg_${code}_${k}`);
if (el) el.value = v;
});
alert(`✅ ${name} 탐색 결과를 카드에 반영했습니다.\n💾 설정저장 버튼을 눌러 DB에 저장하세요.`);
}
function hdRenderResult(d, name) {
const s = d.summary;
$('hd_result_area').style.display = '';
$('hd_search_result').style.display = 'none';
$('hd_result_title').textContent = `📈 [${name}] 백테스트 결과 (${d.candle_count}봉)`;
$('hd_total').textContent = (s.total_trades||0) + '';
$('hd_wr').textContent = (s.win_rate||0) + '%';
colorPnl($('hd_wr'), (s.win_rate||0) - 50);
$('hd_pnl').textContent = fmt(s.total_pnl) + '';
colorPnl($('hd_pnl'), s.total_pnl);
$('hd_pf').textContent = (s.profit_factor||0) >= 999 ? '' : (s.profit_factor||0);
colorPnl($('hd_pf'), (s.profit_factor||0) - 1);
$('hd_mdd').textContent = '-' + fmt(s.max_drawdown) + '';
$('hd_hold').textContent = (s.avg_hold_days||0) + '';
// Buy & Hold 비교
if (s.bnh_pct !== undefined) {
$('hd_bnh_row').style.display = '';
const sign = v => v >= 0 ? '+' : '';
$('hd_bot_pct').textContent = sign(s.bot_pct) + s.bot_pct + '%';
colorPnl($('hd_bot_pct'), s.bot_pct);
$('hd_bnh_pct').textContent = sign(s.bnh_pct) + s.bnh_pct + '%';
colorPnl($('hd_bnh_pct'), s.bnh_pct);
$('hd_bnh_pnl').textContent = sign(s.bnh_pnl) + fmt(s.bnh_pnl) + '';
colorPnl($('hd_bnh_pnl'), s.bnh_pnl);
$('hd_alpha').textContent = sign(s.alpha_pct) + s.alpha_pct + '%p';
colorPnl($('hd_alpha'), s.alpha_pct);
}
lineChart('hd_equity_chart', d.equity.map(e=>e.date), d.equity.map(e=>e.cum_pnl),
'누적손익', '#bc8cff');
const rKeys = Object.keys(d.reasons||{});
doughnutChart('hd_reason_chart', rKeys, rKeys.map(k=>d.reasons[k]));
const tbody = $('hd_trade_tbody');
tbody.innerHTML = '';
[...(d.trades||[])].reverse().forEach(t => {
const pnlCls = t.pnl > 0 ? 'text-pnl-pos' : (t.pnl < 0 ? 'text-pnl-neg' : '');
tbody.insertAdjacentHTML('beforeend', `
<tr>
<td>${t.buy_date}</td><td>${t.sell_date}</td>
<td>${fmt(t.avg_price)}</td><td>${fmt(t.exit_price)}</td>
<td>${t.qty}</td>
<td class="${pnlCls}">${fmt(t.pnl)}</td>
<td>${t.hold_days}</td>
<td style="font-size:11px">${t.reason}</td>
</tr>`);
});
$('hd_result_area').scrollIntoView({behavior:'smooth'});
}
function hdRenderParamSearch(top, code, name) {
$('hd_result_area').style.display = '';
$('hd_search_result').style.display = '';
$('hd_result_title').textContent = `🔍 [${name}] 파라미터 탐색 결과`;
// ── 파라미터 키 한글 레이블 매핑 ──────────────────────────────
const labelMap = {
ma_fast: 'MA단기', ma_slow: 'MA장기',
atr_mult: '샹들리에배수', atr_period: 'ATR기간',
trail_stop_pct: '트레일%', trend_filter: '추세필터',
rsi_buy1: '매수1', rsi_buy2: '매수2', rsi_buy3: '매수3',
rsi_sell: '매도RSI', take_profit_pct: '익절%', stop_loss_pct: '손절%',
ath_drop_min_pct: 'ATH낙폭%', year_drop_min_pct: '연도낙폭%', w52_drop_min_pct: '52주낙폭%',
};
// ── 실제 params 키로 헤더 동적 생성 ───────────────────────────
const keys = top && top.length ? Object.keys(top[0].params) : [];
const thead = $('hd_search_thead');
thead.innerHTML = '<tr>' +
keys.map(k => `<th>${labelMap[k] || k}</th>`).join('') +
'<th>손익(원)</th><th>승률</th><th>거래</th><th>PF</th><th>보유(일)</th><th>적용</th></tr>';
const tbody = $('hd_search_tbody');
tbody.innerHTML = '';
(top||[]).forEach((r, idx) => {
const p = r.params;
const pnlCls = r.total_pnl > 0 ? 'text-pnl-pos' : 'text-pnl-neg';
const paramCells = keys.map(k => {
const v = p[k];
if (v === undefined || v === null) return '<td>-</td>';
// % 단위 컬럼은 % 표시
const isPct = k.includes('pct') || k.includes('rate');
return `<td>${isPct ? v+'%' : v}</td>`;
}).join('');
tbody.insertAdjacentHTML('beforeend', `
<tr>
${paramCells}
<td class="${pnlCls}">${fmt(r.total_pnl)}</td>
<td>${r.win_rate}%</td><td>${r.total_trades}</td>
<td>${r.pf}</td><td>${r.avg_hold}일</td>
<td><button class="btn btn-xs btn-outline-success" style="font-size:11px;padding:1px 6px"
onclick='hdApplySearchResult("${code}","${name}",${JSON.stringify(p)})'>적용</button></td>
</tr>`);
});
$('hd_result_area').scrollIntoView({behavior:'smooth'});
}
// ────────────────────────────────────────────
// 스피너
// ────────────────────────────────────────────
function showSpinner(v) {
$('spinner').classList.toggle('show', v);
}
// ────────────────────────────────────────────
// 최초 로드
// ────────────────────────────────────────────
loadActual();
</script>
</body>
</html>"""
@app.route("/")
def index():
return render_template_string(HTML_TEMPLATE)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5050, debug=False)