3292 lines
157 KiB
Python
3292 lines
157 KiB
Python
#!/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 기준(예: 930–1500)을 쓰므로, 비교 시 웹에서도 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 & 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 & Hold 수익률</div><div class="stat-val" id="hd_bnh_pct">-</div></div>
|
||
<div class="col-6 col-md-3"><div class="card-title">Buy & Hold 손익(원)</div><div class="stat-val" id="hd_bnh_pnl">-</div></div>
|
||
<div class="col-6 col-md-3"><div class="card-title">알파(봇 - B&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}) <${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)
|