1644 lines
76 KiB
Python
1644 lines
76 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
upbit_backtest_web.py — 업비트 단타 백테스트 & 대시보드
|
|
=========================================================
|
|
실행: python3 upbit_backtest_web.py
|
|
접속: http://localhost:6060
|
|
|
|
탭1. 실거래 분석 → trade_history 기반 실제 매매 결과
|
|
탭2. 꼬리잡기 백테스트 → upbit_candles 분봉 가격 재현(Price-Replay) 백테스트
|
|
탭3. ENV 설정 → env_config 조회/수정 (업비트 전용 파라미터)
|
|
탭4. 캔들 수집 → 업비트 REST API로 분봉 수집 후 DB 저장
|
|
|
|
DB: MariaDB 192.168.0.141:3306/upbit_quant_db
|
|
"""
|
|
|
|
import sys, os, json, time, logging, threading, hashlib, uuid
|
|
from datetime import datetime, timedelta
|
|
from typing import List, Dict, Optional
|
|
from urllib.parse import urlencode
|
|
|
|
import requests
|
|
import pymysql
|
|
import pymysql.cursors
|
|
from flask import Flask, jsonify, request, render_template_string
|
|
|
|
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(message)s", datefmt="%H:%M:%S")
|
|
logger = logging.getLogger("upbit_backtest_web")
|
|
|
|
app = Flask(__name__)
|
|
|
|
# ── MariaDB 접속 정보 ──────────────────────────────────────────────────────
|
|
_DB_CFG = dict(
|
|
host="192.168.0.141", port=3306,
|
|
user="jae", password="1234",
|
|
database="upbit_quant_db",
|
|
charset="utf8mb4",
|
|
autocommit=True,
|
|
cursorclass=pymysql.cursors.DictCursor,
|
|
connect_timeout=10,
|
|
)
|
|
|
|
|
|
def _db():
|
|
"""요청마다 새 MariaDB 연결 반환"""
|
|
conn = pymysql.connect(**_DB_CFG)
|
|
return conn
|
|
|
|
|
|
# ── 업비트 REST 클라이언트 (캔들 수집용) ──────────────────────────────────
|
|
_UPBIT_BASE = "https://api.upbit.com/v1"
|
|
_last_req_t = 0.0
|
|
|
|
|
|
def _upbit_get(path: str, params: dict = None, auth_key: str = "", auth_secret: str = "") -> Optional[dict]:
|
|
"""업비트 REST GET (레이트리밋 0.12초 보호, HTTP 429 재시도)"""
|
|
global _last_req_t
|
|
elapsed = time.time() - _last_req_t
|
|
if elapsed < 0.12:
|
|
time.sleep(0.12 - elapsed)
|
|
_last_req_t = time.time()
|
|
|
|
url = f"{_UPBIT_BASE}{path}"
|
|
hdrs = {}
|
|
if auth_key and auth_secret:
|
|
try:
|
|
import jwt as pyjwt
|
|
payload = {"access_key": auth_key, "nonce": str(uuid.uuid4())}
|
|
qs = urlencode(params or {}, doseq=True)
|
|
if qs:
|
|
payload["query_hash"] = hashlib.sha512(qs.encode()).hexdigest()
|
|
payload["query_hash_alg"] = "SHA512"
|
|
hdrs["Authorization"] = f"Bearer {pyjwt.encode(payload, auth_secret, algorithm='HS256')}"
|
|
except ImportError:
|
|
pass
|
|
|
|
for attempt in range(4):
|
|
try:
|
|
r = requests.get(url, params=params, headers=hdrs, timeout=10)
|
|
if r.status_code == 429:
|
|
time.sleep(1.5 + attempt)
|
|
continue
|
|
if r.status_code == 200:
|
|
return r.json()
|
|
return None
|
|
except Exception as e:
|
|
time.sleep((2 ** attempt) * 0.5)
|
|
return None
|
|
|
|
|
|
# ── 캔들 수집 백그라운드 Job 상태 ─────────────────────────────────────────
|
|
_fetch_jobs: Dict[str, dict] = {}
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# RSI 시리즈 계산 헬퍼
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
def _compute_rsi_series(closes: list, period: int = 14) -> 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_g = sum(gains[:period]) / period
|
|
avg_l = sum(losses[:period]) / period
|
|
for i in range(period, len(closes)):
|
|
idx = i - 1
|
|
if i > period:
|
|
avg_g = (avg_g * (period-1) + gains[idx]) / period
|
|
avg_l = (avg_l * (period-1) + losses[idx]) / period
|
|
rs = avg_g / avg_l if avg_l > 0 else float("inf")
|
|
rsi_list[i] = 100 - (100 / (1 + rs)) if avg_l > 0 else 100.0
|
|
return rsi_list
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# API: 실거래 분석
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
@app.route("/api/actual", methods=["GET"])
|
|
def api_actual():
|
|
"""trade_history 기반 실제 매매 성과 분석"""
|
|
strategy = request.args.get("strategy", "UPBIT_TAIL_CATCH")
|
|
start = request.args.get("start", "")
|
|
end = request.args.get("end", "")
|
|
|
|
conn = _db()
|
|
try:
|
|
cur = conn.cursor()
|
|
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"
|
|
cur.execute(sql, params)
|
|
trades = cur.fetchall() or []
|
|
|
|
# 날짜 직렬화
|
|
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": str(t.get("sell_date") or "")[:10],
|
|
"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)
|
|
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
|
|
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 = str(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": pf,
|
|
"max_drawdown": round(mdd),
|
|
},
|
|
"equity": equity,
|
|
"daily": daily_list,
|
|
"reasons": reasons,
|
|
"top_codes": top_list,
|
|
"trades": trades[-200:],
|
|
})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# API: 꼬리잡기 백테스트 (upbit_candles 기반)
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
@app.route("/api/backtest/tail", methods=["GET"])
|
|
def api_backtest_tail():
|
|
"""
|
|
업비트 꼬리잡기 전략 가격 재현 백테스트.
|
|
entry 조건: 당일 낙폭 + 회복률 + 망치봉 꼬리 + RSI
|
|
exit 조건: 손절 / 익절 / 어깨 컷(trailing) / 24h 강제청산(코인은 24h 장)
|
|
"""
|
|
start = request.args.get("start", "")
|
|
end = request.args.get("end", "")
|
|
# 코인 필터: 콤마 구분으로 특정 마켓만 선택 (빈 값이면 전체)
|
|
market_filter = request.args.get("markets", "").strip().upper()
|
|
rsi_period = int( request.args.get("rsi_period", 14))
|
|
rsi_threshold = float(request.args.get("rsi_threshold", 78)) # RSI 과열 기준
|
|
min_drop_rate = float(request.args.get("min_drop_rate", 3.0)) / 100
|
|
min_recovery_ratio = float(request.args.get("min_recovery_ratio", 50)) / 100
|
|
max_rec = float(request.args.get("max_rec", 80)) / 100
|
|
tail_ratio_min = float(request.args.get("tail_ratio_min", 1.5))
|
|
tail_pct_min = float(request.args.get("tail_pct_min", 0.3)) / 100
|
|
sl_pct = float(request.args.get("sl_pct", 3.0)) / 100
|
|
tp_pct = float(request.args.get("tp_pct", 5.0)) / 100
|
|
shoulder_min_high = float(request.args.get("shoulder_min_high", 1.5)) / 100
|
|
shoulder_cut_pct = float(request.args.get("shoulder_cut_pct", 3.0)) / 100
|
|
high_chase_thr = float(request.args.get("high_chase_thr", 96.0)) / 100
|
|
slot_money = float(request.args.get("slot_money", 100_000))
|
|
fee_rate = float(request.args.get("fee_rate", 0.05)) / 100 # 업비트 0.05% 편도
|
|
cooldown_min = int( request.args.get("cooldown_min", 30))
|
|
max_hold_hours = float(request.args.get("max_hold_hours", 24)) # 코인: 24시간 강제청산
|
|
max_daily = int( request.args.get("max_daily", 5))
|
|
timeframe = int( request.args.get("timeframe", 3)) # 분봉 단위 (기본 3분봉)
|
|
|
|
conn = _db()
|
|
try:
|
|
start_key = (start.replace("-", "") + "0000") if start else "20260101"
|
|
end_key = (end.replace("-", "") + "2359") if end else "99991231"
|
|
|
|
cur = conn.cursor()
|
|
|
|
# 코인 필터 적용: 특정 마켓만 선택하거나 전체 조회
|
|
filter_list = [m.strip() for m in market_filter.split(",") if m.strip()] if market_filter else []
|
|
if filter_list:
|
|
fmt = ",".join(["%s"] * len(filter_list))
|
|
cur.execute(
|
|
f"SELECT DISTINCT code FROM upbit_candles "
|
|
f"WHERE timeframe=%s AND candle_time >= %s AND candle_time <= %s "
|
|
f"AND code IN ({fmt}) ORDER BY code",
|
|
[timeframe, start_key, end_key] + filter_list
|
|
)
|
|
else:
|
|
cur.execute(
|
|
"SELECT DISTINCT code FROM upbit_candles "
|
|
"WHERE timeframe=%s AND candle_time >= %s AND candle_time <= %s ORDER BY code",
|
|
[timeframe, start_key, end_key]
|
|
)
|
|
codes = [r["code"] for r in (cur.fetchall() or [])]
|
|
|
|
all_trades: List[Dict] = []
|
|
|
|
def _t2dt(t: str) -> datetime:
|
|
"""YYYYMMDDHHMI → datetime"""
|
|
return datetime.strptime(t, "%Y%m%d%H%M")
|
|
|
|
for code in codes:
|
|
cur.execute(
|
|
"SELECT candle_time, open_price, high_price, low_price, close_price, volume "
|
|
"FROM upbit_candles "
|
|
"WHERE timeframe=%s AND code=%s "
|
|
"AND candle_time >= %s AND candle_time <= %s "
|
|
"AND is_confirmed=1 "
|
|
"ORDER BY candle_time ASC",
|
|
[timeframe, code, start_key, end_key]
|
|
)
|
|
rows = cur.fetchall() or []
|
|
if len(rows) < rsi_period + 5:
|
|
continue
|
|
|
|
candles = [dict(r) for r in rows]
|
|
closes = [float(c["close_price"]) 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]
|
|
op = float(c["open_price"])
|
|
hi = float(c["high_price"])
|
|
lo = float(c["low_price"])
|
|
cl = float(c["close_price"])
|
|
|
|
# ── 당일 누적 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)
|
|
|
|
# ── 포지션 보유 중: 청산 체크 ─────────────────────────────
|
|
if position is not None:
|
|
max_p = max(position["max_price"], hi)
|
|
position["max_price"] = max_p
|
|
|
|
reason = None
|
|
exit_price = cl
|
|
|
|
# 손절: 캔들 저가가 손절선 이하
|
|
if lo <= position["stop"]:
|
|
reason = "손절"
|
|
exit_price = position["stop"]
|
|
# 익절: 캔들 고가가 익절선 이상
|
|
elif hi >= position["target"]:
|
|
reason = "익절"
|
|
exit_price = position["target"]
|
|
# 어깨매도: 고점에서 shoulder_cut_pct 하락 (수익 보존)
|
|
elif max_p >= position["entry_price"] * (1 + shoulder_min_high):
|
|
trail_stop = max_p * (1 - shoulder_cut_pct)
|
|
if cl <= trail_stop:
|
|
reason = "어깨매도"
|
|
exit_price = cl
|
|
# 24시간 강제청산 (코인은 장 마감 없음 → max_hold_hours로 대체)
|
|
if reason is None:
|
|
held_hours = (_t2dt(c["candle_time"]) -
|
|
_t2dt(position["entry_time"])).total_seconds() / 3600
|
|
if held_hours >= max_hold_hours:
|
|
reason = "보유시간초과"
|
|
exit_price = cl
|
|
|
|
if reason:
|
|
qty = position["qty"]
|
|
buy_amt = position["entry_price"] * qty
|
|
sell_amt = exit_price * qty
|
|
# 업비트 수수료: 매수/매도 각각 fee_rate (증권거래세 없음)
|
|
pnl = (sell_amt - buy_amt
|
|
- buy_amt * fee_rate
|
|
- sell_amt * fee_rate)
|
|
hold_min = int((_t2dt(c["candle_time"]) -
|
|
_t2dt(position["entry_time"])).total_seconds() / 60)
|
|
all_trades.append({
|
|
"code": code,
|
|
"buy_time": position["entry_time"],
|
|
"sell_time": c["candle_time"],
|
|
"buy_price": position["entry_price"],
|
|
"sell_price": round(exit_price, 4),
|
|
"qty": qty,
|
|
"pnl": round(pnl),
|
|
"profit_rate": round((exit_price - position["entry_price"])
|
|
/ position["entry_price"] * 100, 2),
|
|
"hold_min": hold_min,
|
|
"sell_reason": reason,
|
|
"rsi_entry": round(position["rsi"], 1),
|
|
})
|
|
last_exit_dt[day] = _t2dt(c["candle_time"])
|
|
position = None
|
|
continue
|
|
|
|
# ── 포지션 없음: 매수 신호 체크 ──────────────────────────
|
|
|
|
# 쿨다운 체크
|
|
if day in last_exit_dt:
|
|
elapsed = (_t2dt(c["candle_time"]) - last_exit_dt[day]).total_seconds() / 60
|
|
if elapsed < cooldown_min:
|
|
continue
|
|
|
|
# 일일 최대 거래횟수
|
|
if daily_cnt.get(day, 0) >= max_daily:
|
|
continue
|
|
|
|
# RSI 과열 방지
|
|
rsi = rsis[i]
|
|
if rsi is None or rsi >= rsi_threshold:
|
|
continue
|
|
|
|
# 낙폭 필터 (look-ahead 없는 running_low 사용)
|
|
if running_open <= 0:
|
|
continue
|
|
drop_rate = (running_open - running_low) / running_open
|
|
if drop_rate < min_drop_rate:
|
|
continue
|
|
|
|
# 회복률 필터
|
|
day_range = running_high - running_low
|
|
if day_range <= 0:
|
|
continue
|
|
recovery = (cl - running_low) / day_range
|
|
if not (min_recovery_ratio <= recovery <= max_rec):
|
|
continue
|
|
|
|
# 망치봉 꼬리 계산
|
|
body_top = max(op, cl)
|
|
body_bottom = min(op, cl)
|
|
body_len = max(body_top - body_bottom, 1.0)
|
|
tail_len = max(body_bottom - lo, 0.0)
|
|
tail_ratio = tail_len / body_len
|
|
tail_pct = tail_len / lo if lo > 0 else 0
|
|
|
|
if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min:
|
|
continue
|
|
|
|
# 고점 추격 방지
|
|
if cl >= running_high * high_chase_thr:
|
|
continue
|
|
|
|
# 진입 가격: 신호봉 다음봉 시가 (현실적 체결 가정)
|
|
if i + 1 >= len(candles):
|
|
continue
|
|
next_c = candles[i + 1]
|
|
entry_price = float(next_c["open_price"])
|
|
if entry_price <= 0:
|
|
continue
|
|
|
|
qty = slot_money / entry_price
|
|
stop = entry_price * (1 - sl_pct)
|
|
target = entry_price * (1 + tp_pct)
|
|
position = {
|
|
"entry_price": entry_price,
|
|
"entry_time": next_c["candle_time"],
|
|
"qty": qty,
|
|
"stop": stop,
|
|
"target": target,
|
|
"max_price": entry_price,
|
|
"rsi": rsi,
|
|
}
|
|
daily_cnt[day] = daily_cnt.get(day, 0) + 1
|
|
|
|
# ── 결과 집계 ──────────────────────────────────────────────────
|
|
all_trades.sort(key=lambda x: x["sell_time"])
|
|
|
|
equity = []
|
|
cum = 0.0
|
|
for t in all_trades:
|
|
cum += t["pnl"]
|
|
equity.append({"date": t["sell_time"][:8], "cum_pnl": round(cum), "pnl": t["pnl"]})
|
|
|
|
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
|
|
|
|
peak, mdd, cum = 0.0, 0.0, 0.0
|
|
for t in all_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_trades:
|
|
reasons[t["sell_reason"]] = reasons.get(t["sell_reason"], 0) + 1
|
|
|
|
daily: Dict[str, float] = {}
|
|
for t in all_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_threshold": rsi_threshold,
|
|
"min_drop_rate": min_drop_rate * 100,
|
|
"min_recovery": min_recovery_ratio * 100,
|
|
"sl_pct": sl_pct * 100,
|
|
"tp_pct": tp_pct * 100,
|
|
"shoulder_cut": shoulder_cut_pct * 100,
|
|
"slot_money": slot_money,
|
|
"fee_rate": fee_rate * 100,
|
|
"timeframe": timeframe,
|
|
"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": pf,
|
|
"max_drawdown": round(mdd),
|
|
},
|
|
"equity": equity,
|
|
"daily": daily_list,
|
|
"reasons": reasons,
|
|
"trades": all_trades[-200:],
|
|
})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# API: ENV 설정 조회/수정
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
@app.route("/api/env", methods=["GET"])
|
|
def api_env_get():
|
|
"""env_config 최신 row 조회"""
|
|
conn = _db()
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1")
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({})
|
|
result = {k: v for k, v in row.items() if k not in ("id", "created_at")}
|
|
return jsonify(result)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@app.route("/api/env", methods=["POST"])
|
|
def api_env_set():
|
|
"""env_config 업데이트 (제출된 필드만 수정)"""
|
|
body = request.get_json(force=True) or {}
|
|
if not body:
|
|
return jsonify({"error": "빈 요청"}), 400
|
|
|
|
# 허용된 컬럼만 업데이트 (보안)
|
|
conn = _db()
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute("DESCRIBE env_config")
|
|
allowed_cols = {r["Field"] for r in (cur.fetchall() or [])} - {"id", "created_at"}
|
|
|
|
sets, vals = [], []
|
|
for k, v in body.items():
|
|
if k in allowed_cols:
|
|
sets.append(f"`{k}` = %s")
|
|
vals.append(str(v))
|
|
|
|
if not sets:
|
|
return jsonify({"error": "유효한 필드 없음"}), 400
|
|
|
|
# 최신 row 업데이트
|
|
cur.execute("SELECT id FROM env_config ORDER BY id DESC LIMIT 1")
|
|
row = cur.fetchone()
|
|
if row:
|
|
vals.append(row["id"])
|
|
cur.execute(f"UPDATE env_config SET {', '.join(sets)} WHERE id = %s", vals)
|
|
else:
|
|
cols = ", ".join(f"`{k}`" for k in body.keys() if k in allowed_cols)
|
|
marks = ", ".join(["%s"] * len(sets))
|
|
cur.execute(f"INSERT INTO env_config ({cols}) VALUES ({marks})", vals)
|
|
|
|
return jsonify({"ok": True, "updated": len(sets)})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# API: 캔들 수집 (업비트 REST → upbit_candles DB 저장)
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
@app.route("/api/candles/fetch", methods=["POST"])
|
|
def api_candles_fetch():
|
|
"""
|
|
업비트 REST API로 지정 마켓/기간/분봉 수집 후 DB 저장
|
|
body: {market: "KRW-BTC", start: "2026-01-01", end: "2026-03-09", timeframe: 3}
|
|
"""
|
|
body = request.get_json(force=True) or {}
|
|
market = body.get("market", "").upper()
|
|
start_str = body.get("start", "")
|
|
end_str = body.get("end", datetime.now().strftime("%Y-%m-%d"))
|
|
tf = int(body.get("timeframe", 3))
|
|
markets = body.get("markets", []) # 여러 마켓 동시 수집
|
|
|
|
if not market and not markets:
|
|
return jsonify({"error": "market 또는 markets 필드 필수"}), 400
|
|
|
|
target_markets = markets if markets else [market]
|
|
job_id = str(uuid.uuid4())[:8]
|
|
_fetch_jobs[job_id] = {"status": "running", "progress": 0, "total": 0, "saved": 0, "errors": []}
|
|
|
|
def _worker():
|
|
total_saved = 0
|
|
conn_w = _db()
|
|
try:
|
|
cur_w = conn_w.cursor()
|
|
for mkt in target_markets:
|
|
# 수집 기간 설정 (업비트는 최신순 200봉씩 제공)
|
|
if start_str:
|
|
start_dt = datetime.strptime(start_str, "%Y-%m-%d")
|
|
else:
|
|
start_dt = datetime.now() - timedelta(days=7)
|
|
end_dt = datetime.strptime(end_str, "%Y-%m-%d") + timedelta(days=1)
|
|
|
|
# to 파라미터를 줄여가며 페이지네이션 수집
|
|
to_dt = end_dt
|
|
count = 0
|
|
|
|
while to_dt > start_dt:
|
|
to_str = to_dt.strftime("%Y-%m-%dT%H:%M:%S")
|
|
data = _upbit_get(
|
|
f"/candles/minutes/{tf}",
|
|
{"market": mkt, "to": to_str, "count": 200}
|
|
)
|
|
if not data:
|
|
break
|
|
|
|
batch = []
|
|
oldest_dt = to_dt
|
|
for c in data:
|
|
# candle_date_time_kst: "2026-03-09T14:30:00"
|
|
cdt_str = c.get("candle_date_time_kst", "")[:16]
|
|
if not cdt_str:
|
|
continue
|
|
try:
|
|
cdt = datetime.strptime(cdt_str, "%Y-%m-%dT%H:%M")
|
|
except Exception:
|
|
continue
|
|
if cdt < start_dt:
|
|
continue
|
|
ct = cdt.strftime("%Y%m%d%H%M")
|
|
batch.append((
|
|
mkt, ct, tf,
|
|
float(c.get("opening_price", 0)),
|
|
float(c.get("high_price", 0)),
|
|
float(c.get("low_price", 0)),
|
|
float(c.get("trade_price", 0)),
|
|
float(c.get("candle_acc_trade_volume", 0)),
|
|
1,
|
|
))
|
|
if cdt < oldest_dt:
|
|
oldest_dt = cdt
|
|
|
|
if batch:
|
|
cur_w.executemany("""
|
|
INSERT INTO upbit_candles
|
|
(code, candle_time, timeframe, open_price, high_price,
|
|
low_price, close_price, volume, is_confirmed)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
|
ON DUPLICATE KEY UPDATE
|
|
open_price=VALUES(open_price), high_price=VALUES(high_price),
|
|
low_price=VALUES(low_price), close_price=VALUES(close_price),
|
|
volume=VALUES(volume)
|
|
""", batch)
|
|
total_saved += len(batch)
|
|
count += len(batch)
|
|
|
|
# 다음 페이지: oldest_dt - 1분
|
|
to_dt = oldest_dt - timedelta(minutes=tf)
|
|
if to_dt <= start_dt:
|
|
break
|
|
time.sleep(0.15)
|
|
|
|
_fetch_jobs[job_id]["progress"] += 1
|
|
_fetch_jobs[job_id]["saved"] = total_saved
|
|
|
|
except Exception as e:
|
|
_fetch_jobs[job_id]["errors"].append(str(e))
|
|
logger.error(f"캔들 수집 오류: {e}")
|
|
finally:
|
|
conn_w.close()
|
|
_fetch_jobs[job_id]["status"] = "done"
|
|
|
|
t = threading.Thread(target=_worker, daemon=True)
|
|
t.start()
|
|
return jsonify({"ok": True, "job_id": job_id, "markets": target_markets})
|
|
|
|
|
|
@app.route("/api/candles/fetch/status/<job_id>", methods=["GET"])
|
|
def api_fetch_status(job_id: str):
|
|
"""캔들 수집 진행 상태 조회"""
|
|
job = _fetch_jobs.get(job_id)
|
|
if not job:
|
|
return jsonify({"error": "job_id 없음"}), 404
|
|
return jsonify(job)
|
|
|
|
|
|
@app.route("/api/candles/markets", methods=["GET"])
|
|
def api_candles_markets():
|
|
"""upbit_candles에 저장된 마켓 목록 조회"""
|
|
tf = request.args.get("timeframe", "3")
|
|
conn = _db()
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"SELECT code, COUNT(*) as cnt, MIN(candle_time) as first_dt, MAX(candle_time) as last_dt "
|
|
"FROM upbit_candles WHERE timeframe=%s GROUP BY code ORDER BY code",
|
|
[tf]
|
|
)
|
|
return jsonify(cur.fetchall() or [])
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@app.route("/api/candles/upbit_markets", methods=["GET"])
|
|
def api_upbit_markets():
|
|
"""업비트 KRW 마켓 목록 조회 (실시간)"""
|
|
data = _upbit_get("/market/all")
|
|
if not data:
|
|
return jsonify([])
|
|
return jsonify([m["market"] for m in data if m["market"].startswith("KRW-")])
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# API: 보유 현황 / 후보 목록
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
@app.route("/api/holdings", methods=["GET"])
|
|
def api_holdings():
|
|
"""현재 보유 포지션 조회"""
|
|
conn = _db()
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT * FROM active_trades WHERE status='HOLDING' ORDER BY buy_date DESC")
|
|
rows = cur.fetchall() or []
|
|
for r in rows:
|
|
for k in ("buy_date", "updated_at"):
|
|
if r.get(k):
|
|
r[k] = str(r[k])
|
|
return jsonify(rows)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@app.route("/api/candidates", methods=["GET"])
|
|
def api_candidates():
|
|
"""최근 스캔 후보 목록"""
|
|
conn = _db()
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT * FROM target_candidates ORDER BY score DESC LIMIT 50")
|
|
rows = cur.fetchall() or []
|
|
for r in rows:
|
|
for k in ("scan_time", "updated_at"):
|
|
if r.get(k):
|
|
r[k] = str(r[k])
|
|
return jsonify(rows)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# HTML 대시보드 템플릿
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
HTML_TEMPLATE = """<!doctype html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Upbit 단타 봇 V2 — 대시보드</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
<style>
|
|
/* ════════════════════════════════════════════
|
|
Upbit 단타봇 V2 — 다크 테마 전체 스타일
|
|
════════════════════════════════════════════ */
|
|
:root {
|
|
--orange: #f0931c;
|
|
--bg-base: #0f1117;
|
|
--bg-card: #1a1d27;
|
|
--bg-input: #141720;
|
|
--bg-hover: rgba(255,255,255,.04);
|
|
--border: #2a2d3a;
|
|
--text-primary: #e2e8f0;
|
|
--text-secondary: #94a3b8;
|
|
--text-dim: #64748b;
|
|
--green: #4ade80;
|
|
--red: #f87171;
|
|
}
|
|
|
|
/* ── 기본 ── */
|
|
*, *::before, *::after { box-sizing: border-box; }
|
|
body { background:var(--bg-base); color:var(--text-primary); font-size:14px; line-height:1.55; }
|
|
|
|
/* ── 네비게이션 ── */
|
|
nav.navbar { background:#0a0d16 !important; border-bottom:1px solid var(--border); }
|
|
.navbar-brand { color:var(--orange) !important; font-weight:700; font-size:1.15rem; letter-spacing:-.3px; }
|
|
.nav-link { color:var(--text-secondary) !important; font-size:13px; padding:.5rem .9rem !important; border-bottom:2px solid transparent; transition:color .2s; }
|
|
.nav-link:hover { color:var(--text-primary) !important; }
|
|
.nav-link.active { color:var(--orange) !important; border-bottom-color:var(--orange) !important; }
|
|
|
|
/* ── 카드 ── */
|
|
.card { background:var(--bg-card); border:1px solid var(--border); border-radius:10px; }
|
|
.card-header { background:rgba(255,255,255,.03); border-bottom:1px solid var(--border);
|
|
padding:.75rem 1rem; font-size:13px; font-weight:600; color:#c8d4e4; letter-spacing:.2px; }
|
|
.card-body { padding:1rem; }
|
|
|
|
/* ── 테이블 ── */
|
|
.table { color:var(--text-primary); font-size:13px; margin-bottom:0; }
|
|
.table thead th {
|
|
position:sticky; top:0; z-index:1; /* 헤더 고정 */
|
|
background:#141820;
|
|
color:var(--text-secondary); font-size:11px; font-weight:600;
|
|
text-transform:uppercase; letter-spacing:.5px;
|
|
border-bottom:2px solid var(--border); padding:.6rem .75rem;
|
|
white-space:nowrap;
|
|
}
|
|
.table tbody td { border-color:rgba(255,255,255,.05); padding:.55rem .75rem; vertical-align:middle; }
|
|
.table tbody tr:nth-child(odd) { background:rgba(255,255,255,.015); }
|
|
.table tbody tr:hover { background:rgba(240,147,28,.07) !important; }
|
|
|
|
/* ── 손익 색상 ── */
|
|
.text-pnl-pos { color:var(--green); font-weight:600; }
|
|
.text-pnl-neg { color:var(--red); font-weight:600; }
|
|
.badge-pos { background:#15803d; color:#fff; border-radius:4px; padding:2px 7px; font-size:11px; }
|
|
.badge-neg { background:#b91c1c; color:#fff; border-radius:4px; padding:2px 7px; font-size:11px; }
|
|
|
|
/* ── 통계 카드 ── */
|
|
.stat-card { text-align:center; padding:16px 10px; }
|
|
.stat-card .val { font-size:1.5rem; font-weight:700; line-height:1.2; }
|
|
.stat-card .lbl { font-size:11px; color:var(--text-secondary); margin-top:5px; text-transform:uppercase; letter-spacing:.5px; }
|
|
|
|
/* ── 라벨 / 텍스트 ── */
|
|
.text-muted { color:var(--text-secondary) !important; }
|
|
.form-label { color:#b8c5d6 !important; font-size:12px; font-weight:500; margin-bottom:4px; }
|
|
h6 { color:#c8d4e4; font-size:13px; font-weight:600; }
|
|
small, .small { color:var(--text-dim); }
|
|
.border-bottom.border-secondary { border-color:#3a4458 !important; }
|
|
|
|
/* ── 폼 입력 ── */
|
|
.form-control, .form-select {
|
|
background:var(--bg-input); border:1px solid var(--border);
|
|
color:var(--text-primary); font-size:13px; border-radius:6px;
|
|
padding:.38rem .6rem;
|
|
}
|
|
.form-control:focus, .form-select:focus {
|
|
background:var(--bg-input); border-color:var(--orange); color:#fff; box-shadow:none;
|
|
}
|
|
.form-control::placeholder { color:var(--text-dim); }
|
|
.form-select option { background:#1c2030; color:var(--text-primary); }
|
|
|
|
/* ── 버튼 ── */
|
|
.btn { font-size:13px; border-radius:6px; }
|
|
.btn-primary { background:var(--orange); border-color:var(--orange); color:#fff; font-weight:600; }
|
|
.btn-primary:hover { background:#d07a10; border-color:#d07a10; }
|
|
.btn-outline-secondary { border-color:#3a4458; color:var(--text-secondary); }
|
|
.btn-outline-secondary:hover { background:#242836; color:var(--text-primary); border-color:#556; }
|
|
.btn-link { color:#7aadff !important; text-decoration:none; }
|
|
.btn-link:hover { color:#a0c8ff !important; }
|
|
.btn-sm { font-size:12px; padding:.25rem .6rem; }
|
|
|
|
/* ── 스피너 / 토스트 ── */
|
|
.spinner-wrap { display:none; text-align:center; padding:40px; }
|
|
.spinner-wrap.show { display:block; }
|
|
#toast-container { position:fixed; bottom:20px; right:20px; z-index:9999; max-width:320px; }
|
|
.toast-msg { background:#1e2535; border:1px solid #3a4060; border-radius:8px;
|
|
padding:11px 16px; margin-top:8px; font-size:13px; color:#d0daea;
|
|
animation:fadeIn .25s; }
|
|
@keyframes fadeIn { from {opacity:0;transform:translateY(8px);} to {opacity:1;transform:translateY(0);} }
|
|
|
|
/* ── 기타 ── */
|
|
.input-group .form-control, .input-group .form-select { border-radius:6px !important; }
|
|
::-webkit-scrollbar { width:6px; height:6px; }
|
|
::-webkit-scrollbar-track { background:#0f1117; }
|
|
::-webkit-scrollbar-thumb { background:#2a3044; border-radius:3px; }
|
|
::-webkit-scrollbar-thumb:hover { background:#3a4060; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<nav class="navbar navbar-dark px-3 py-2" style="background:#0a0d16;border-bottom:1px solid #1e2030">
|
|
<a class="navbar-brand">⚡ Upbit 단타봇 V2</a>
|
|
<ul class="nav nav-tabs border-0" id="mainTab">
|
|
<li class="nav-item"><a class="nav-link active" href="#" onclick="showTab('actual',this)">실거래 분석</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="#" onclick="showTab('backtest',this)">꼬리잡기 백테스트</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="#" onclick="showTab('candles',this)">캔들 수집</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="#" onclick="showTab('env',this)">ENV 설정</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="#" onclick="showTab('holdings',this)">보유현황</a></li>
|
|
</ul>
|
|
</nav>
|
|
|
|
<div class="container-fluid p-3">
|
|
|
|
<!-- ══ 탭1: 실거래 분석 ══════════════════════════════════════════════════ -->
|
|
<div id="tab-actual">
|
|
<div class="row g-2 mb-2 align-items-end">
|
|
<div class="col-auto"><label class="form-label mb-0 text-muted">전략</label>
|
|
<input id="ac_strategy" class="form-control" style="width:200px" value="UPBIT_TAIL_CATCH"></div>
|
|
<div class="col-auto"><label class="form-label mb-0 text-muted">시작일</label>
|
|
<input type="date" id="ac_start" class="form-control" style="width:140px"></div>
|
|
<div class="col-auto"><label class="form-label mb-0 text-muted">종료일</label>
|
|
<input type="date" id="ac_end" class="form-control" style="width:140px"></div>
|
|
<div class="col-auto pt-4"><button class="btn btn-primary btn-sm" onclick="loadActual()">조회</button></div>
|
|
</div>
|
|
|
|
<div class="row g-2 mb-3" id="ac_stats_row"></div>
|
|
|
|
<div class="row g-2 mb-3">
|
|
<div class="col-lg-8">
|
|
<div class="card"><div class="card-header">누적 손익 (원)</div>
|
|
<div class="card-body p-2"><canvas id="ac_equity_chart" height="90"></canvas></div></div>
|
|
</div>
|
|
<div class="col-lg-4">
|
|
<div class="card h-100"><div class="card-header">일별 P&L</div>
|
|
<div class="card-body p-2"><canvas id="ac_daily_chart" height="180"></canvas></div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card mb-3">
|
|
<div class="card-header">최근 매매 내역 (최대 200건)</div>
|
|
<div class="card-body p-0" style="overflow-x:auto;max-height:360px">
|
|
<table class="table mb-0" id="ac_trades_tbl">
|
|
<thead><tr>
|
|
<th>매수시각</th><th>매도시각</th><th>종목</th>
|
|
<th>매수가</th><th>매도가</th><th>수량</th>
|
|
<th>손익(원)</th><th>수익률</th><th>보유(분)</th><th>사유</th>
|
|
</tr></thead>
|
|
<tbody id="ac_trades_body"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ══ 탭2: 꼬리잡기 백테스트 ═══════════════════════════════════════════ -->
|
|
<div id="tab-backtest" style="display:none">
|
|
<div class="card mb-3">
|
|
<div class="card-header">꼬리잡기 백테스트 파라미터</div>
|
|
<div class="card-body">
|
|
<div class="row g-2">
|
|
<div class="col-md-4 col-12">
|
|
<label class="form-label mb-0 text-muted">
|
|
코인 (빈칸=전체, 예: KRW-BTC,KRW-ETH)
|
|
<button type="button" class="btn btn-link btn-sm p-0 ms-1" style="font-size:11px"
|
|
onclick="loadBtMarkets()">DB목록 불러오기</button>
|
|
</label>
|
|
<div class="input-group">
|
|
<input id="bt_markets" class="form-control" placeholder="비워두면 DB 전체 마켓 사용">
|
|
<select id="bt_market_sel" class="form-select" style="max-width:160px"
|
|
onchange="addBtMarket(this.value)">
|
|
<option value="">-- 마켓 선택 --</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">시작일</label>
|
|
<input type="date" id="bt_start" class="form-control"></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">종료일</label>
|
|
<input type="date" id="bt_end" class="form-control"></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">분봉(3/5/15/60)</label>
|
|
<select id="bt_tf" class="form-select">
|
|
<option value="3" selected>3분봉</option>
|
|
<option value="5">5분봉</option>
|
|
<option value="15">15분봉</option>
|
|
<option value="60">60분봉</option>
|
|
</select></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">최소낙폭(%)</label>
|
|
<input id="bt_drop" class="form-control" value="3.0"></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">최소회복률(%)</label>
|
|
<input id="bt_rec" class="form-control" value="50"></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">최대회복률(%)</label>
|
|
<input id="bt_maxrec" class="form-control" value="80"></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">RSI 기간</label>
|
|
<input id="bt_rsi_p" class="form-control" value="14"></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">RSI 과열</label>
|
|
<input id="bt_rsi" class="form-control" value="78"></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">꼬리비율</label>
|
|
<input id="bt_tail_r" class="form-control" value="1.5"></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">꼬리%</label>
|
|
<input id="bt_tail_p" class="form-control" value="0.3"></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">손절(%)</label>
|
|
<input id="bt_sl" class="form-control" value="3.0"></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">익절(%)</label>
|
|
<input id="bt_tp" class="form-control" value="5.0"></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">어깨컷(%)</label>
|
|
<input id="bt_shoulder" class="form-control" value="3.0"></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">투자금(원)</label>
|
|
<input id="bt_slot" class="form-control" value="100000"></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">수수료(%)</label>
|
|
<input id="bt_fee" class="form-control" value="0.05"></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">쿨다운(분)</label>
|
|
<input id="bt_cool" class="form-control" value="30"></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">최대보유(시간)</label>
|
|
<input id="bt_hold" class="form-control" value="24"></div>
|
|
<div class="col-md-2 col-6"><label class="form-label mb-0 text-muted">일최대거래수</label>
|
|
<input id="bt_maxd" class="form-control" value="5"></div>
|
|
<div class="col-12 mt-2">
|
|
<button class="btn btn-primary btn-sm" onclick="runBacktest()">백테스트 실행</button>
|
|
<button class="btn btn-outline-secondary btn-sm ms-2" onclick="applyBestToEnv()">결과→ENV 적용</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="bt_spinner" class="spinner-wrap">
|
|
<div class="spinner-border text-warning"></div><p class="mt-2 text-muted">백테스트 실행 중...</p>
|
|
</div>
|
|
|
|
<div id="bt_results" style="display:none">
|
|
<div class="row g-2 mb-3" id="bt_stats_row"></div>
|
|
<div class="row g-2 mb-3">
|
|
<div class="col-lg-8">
|
|
<div class="card"><div class="card-header">누적 손익 (원)</div>
|
|
<div class="card-body p-2"><canvas id="bt_equity_chart" height="90"></canvas></div></div>
|
|
</div>
|
|
<div class="col-lg-4">
|
|
<div class="card h-100"><div class="card-header">매도 사유 분포</div>
|
|
<div class="card-body p-2"><canvas id="bt_reason_chart" height="180"></canvas></div></div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-header">백테스트 매매 내역 (최대 200건)</div>
|
|
<div class="card-body p-0" style="overflow-x:auto;max-height:360px">
|
|
<table class="table mb-0">
|
|
<thead><tr>
|
|
<th>매수시각</th><th>매도시각</th><th>종목</th>
|
|
<th>매수가</th><th>매도가</th><th>손익(원)</th>
|
|
<th>수익률(%)</th><th>보유(분)</th><th>RSI</th><th>사유</th>
|
|
</tr></thead>
|
|
<tbody id="bt_trades_body"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ══ 탭3: 캔들 수집 ═══════════════════════════════════════════════════ -->
|
|
<div id="tab-candles" style="display:none">
|
|
<div class="row g-3">
|
|
<div class="col-lg-5">
|
|
<div class="card">
|
|
<div class="card-header">캔들 수집 설정</div>
|
|
<div class="card-body">
|
|
<div class="mb-2"><label class="form-label text-muted">마켓 (콤마 구분, 예: KRW-BTC,KRW-ETH)</label>
|
|
<input id="cv_markets" class="form-control" placeholder="KRW-BTC,KRW-ETH,KRW-SOL"></div>
|
|
<div class="mb-2"><label class="form-label text-muted">분봉 단위</label>
|
|
<select id="cv_tf" class="form-select">
|
|
<option value="3" selected>3분봉</option>
|
|
<option value="5">5분봉</option>
|
|
<option value="15">15분봉</option>
|
|
<option value="60">60분봉</option>
|
|
</select></div>
|
|
<div class="row g-2 mb-2">
|
|
<div class="col"><label class="form-label text-muted">시작일</label>
|
|
<input type="date" id="cv_start" class="form-control"></div>
|
|
<div class="col"><label class="form-label text-muted">종료일</label>
|
|
<input type="date" id="cv_end" class="form-control"></div>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm w-100" onclick="fetchCandles()">수집 시작</button>
|
|
<div id="cv_status" class="mt-2 text-muted small"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-7">
|
|
<div class="card">
|
|
<div class="card-header d-flex align-items-center justify-content-between">
|
|
<span>DB 저장 캔들 현황</span>
|
|
<div>
|
|
<select id="cv_tf_filter" class="form-select form-select-sm" style="width:100px;display:inline-block" onchange="loadCandleMarkets()">
|
|
<option value="3">3분봉</option><option value="5">5분봉</option>
|
|
<option value="15">15분봉</option><option value="60">60분봉</option>
|
|
</select>
|
|
<button class="btn btn-outline-secondary btn-sm ms-1" onclick="loadCandleMarkets()">새로고침</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body p-0" style="overflow-x:auto;max-height:400px">
|
|
<table class="table mb-0">
|
|
<thead><tr><th>마켓</th><th>봉수</th><th>시작</th><th>종료</th></tr></thead>
|
|
<tbody id="cv_markets_body"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ══ 탭4: ENV 설정 ════════════════════════════════════════════════════ -->
|
|
<div id="tab-env" style="display:none">
|
|
<div class="card">
|
|
<div class="card-header d-flex align-items-center justify-content-between">
|
|
<span>업비트 봇 ENV 설정값</span>
|
|
<button class="btn btn-primary btn-sm" onclick="saveEnv()">저장</button>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="env_form" class="row g-2"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ══ 탭5: 보유현황 ════════════════════════════════════════════════════ -->
|
|
<div id="tab-holdings" style="display:none">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<h6 class="mb-0 text-muted">현재 보유 코인</h6>
|
|
<button class="btn btn-outline-secondary btn-sm" onclick="loadHoldings()">새로고침</button>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-body p-0" style="overflow-x:auto">
|
|
<table class="table mb-0">
|
|
<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><th>매수시각</th>
|
|
</tr></thead>
|
|
<tbody id="holdings_body"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3">
|
|
<h6 class="text-muted">최근 스캔 후보</h6>
|
|
<div class="card">
|
|
<div class="card-body p-0" style="overflow-x:auto;max-height:300px">
|
|
<table class="table mb-0">
|
|
<thead><tr><th>마켓</th><th>점수</th><th>가격</th><th>스캔시각</th></tr></thead>
|
|
<tbody id="candidates_body"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div><!-- /container -->
|
|
|
|
<div id="toast-container"></div>
|
|
|
|
<script>
|
|
// ── 유틸 ─────────────────────────────────────────────────────────────────
|
|
function $(id) { return document.getElementById(id); }
|
|
|
|
/** 손익 포맷: +1,234원 / -567원 */
|
|
function fmt(v) {
|
|
if (v == null || isNaN(v)) return '<span style="color:#64748b">-</span>';
|
|
const cls = v >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
|
|
return `<span class="${cls}">${v >= 0 ? '+' : ''}${Math.round(v).toLocaleString()}원</span>`;
|
|
}
|
|
|
|
/** 날짜 포맷: "2026-03-09T14:30:00" → "03/09 14:30" 또는 "20260309" → "03/09" */
|
|
function fmtDt(s) {
|
|
if (!s) return '-';
|
|
s = String(s).trim();
|
|
// ISO 형식: 2026-03-09T14:30
|
|
let m = s.match(/^([0-9]{4})-([0-9]{2})-([0-9]{2})[T ]([0-9]{2}):([0-9]{2})/);
|
|
if (m) return `${m[2]}/${m[3]} <span style="color:#64748b">${m[4]}:${m[5]}</span>`;
|
|
// YYYYMMDD 형식: 20260309
|
|
m = s.match(/^([0-9]{4})([0-9]{2})([0-9]{2})$/);
|
|
if (m) return `${m[2]}/${m[3]}`;
|
|
// YYYYMMDDHHMI 형식: 202603091430
|
|
m = s.match(/^([0-9]{4})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})$/);
|
|
if (m) return `${m[2]}/${m[3]} <span style="color:#64748b">${m[4]}:${m[5]}</span>`;
|
|
return s.slice(0, 16);
|
|
}
|
|
|
|
/** 숫자 포맷: 1234567 → "1,234,567" */
|
|
function fmtNum(v, dec=0) {
|
|
if (v == null || isNaN(v)) return '-';
|
|
return Number(v).toLocaleString('ko-KR', {minimumFractionDigits:dec, maximumFractionDigits:dec});
|
|
}
|
|
|
|
/** 수익률 포맷: +1.23% 색상 포함 */
|
|
function fmtRate(v) {
|
|
if (v == null || isNaN(v)) return '-';
|
|
const cls = v >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
|
|
return `<span class="${cls}">${v >= 0 ? '+' : ''}${Number(v).toFixed(2)}%</span>`;
|
|
}
|
|
|
|
/** 차트 라벨용 날짜: "2026-03-09" → "03/09" */
|
|
function fmtChartLabel(s) {
|
|
if (!s) return '';
|
|
const m = String(s).match(/([0-9]{2})[-/]([0-9]{2})/);
|
|
return m ? `${m[1]}/${m[2]}` : s.slice(5, 10);
|
|
}
|
|
|
|
function showTab(name, el) {
|
|
['actual','backtest','candles','env','holdings'].forEach(t => {
|
|
$('tab-'+t).style.display = t === name ? 'block' : 'none';
|
|
});
|
|
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
|
|
if (el) el.classList.add('active');
|
|
if (name === 'env') loadEnv();
|
|
if (name === 'holdings') loadHoldings();
|
|
if (name === 'candles') loadCandleMarkets();
|
|
}
|
|
|
|
function toast(msg, ok=true) {
|
|
const d = document.createElement('div');
|
|
d.className = 'toast-msg';
|
|
d.style.borderLeft = `3px solid ${ok ? '#16a34a' : '#dc2626'}`;
|
|
d.textContent = msg;
|
|
$('toast-container').appendChild(d);
|
|
setTimeout(() => d.remove(), 4000);
|
|
}
|
|
|
|
// ── Chart 공통 (다크 테마) ─────────────────────────────────────────────
|
|
let _charts = {};
|
|
function mkChart(id, cfg) {
|
|
if (_charts[id]) _charts[id].destroy();
|
|
_charts[id] = new Chart($(id), cfg);
|
|
}
|
|
const _GRID = '#252836';
|
|
const _TICK = '#8899aa';
|
|
const _chartDefaults = {
|
|
plugins: { legend:{ display:false }, tooltip:{ backgroundColor:'#1e2535', titleColor:'#e2e8f0', bodyColor:'#94a3b8', borderColor:'#3a4060', borderWidth:1 } },
|
|
scales: {
|
|
x: { ticks:{ color:_TICK, font:{size:11}, maxTicksLimit:10 }, grid:{ color:_GRID } },
|
|
y: { ticks:{ color:_TICK, font:{size:11} }, grid:{ color:_GRID } }
|
|
}
|
|
};
|
|
function makeLineChart(id, labels, data, label) {
|
|
const fmtLabels = labels.map(fmtChartLabel);
|
|
mkChart(id, { type:'line', data:{ labels:fmtLabels, datasets:[{
|
|
label, data,
|
|
borderColor:'#f0931c', backgroundColor:'rgba(240,147,28,.1)',
|
|
fill:true, tension:.35, pointRadius:2, pointHoverRadius:5, borderWidth:2
|
|
}]}, options:{ ..._chartDefaults,
|
|
scales:{
|
|
x:{ ..._chartDefaults.scales.x, ticks:{..._chartDefaults.scales.x.ticks, maxTicksLimit:12} },
|
|
y:{ ..._chartDefaults.scales.y, ticks:{..._chartDefaults.scales.y.ticks,
|
|
callback: v => (v >= 0 ? '+' : '') + v.toLocaleString() + '원' }}
|
|
}
|
|
}});
|
|
}
|
|
function makeBarChart(id, labels, data, label) {
|
|
const fmtLabels = labels.map(fmtChartLabel);
|
|
mkChart(id, { type:'bar', data:{ labels:fmtLabels, datasets:[{
|
|
label, data,
|
|
backgroundColor: data.map(v => v >= 0 ? 'rgba(74,222,128,.65)' : 'rgba(248,113,113,.65)'),
|
|
borderRadius: 3,
|
|
}]}, options:{ ..._chartDefaults,
|
|
scales:{
|
|
x:{ ticks:{ color:_TICK, font:{size:10}, maxRotation:0 }, grid:{ display:false } },
|
|
y:{ ..._chartDefaults.scales.y, ticks:{..._chartDefaults.scales.y.ticks,
|
|
callback: v => (v >= 0 ? '+' : '') + v.toLocaleString() }}
|
|
}
|
|
}});
|
|
}
|
|
function makePieChart(id, labels, data) {
|
|
mkChart(id, { type:'doughnut', data:{ labels, datasets:[{
|
|
data, backgroundColor:['#22c55e','#ef4444','#f0931c','#3b82f6','#8b5cf6','#ec4899','#06b6d4'],
|
|
borderWidth: 2, borderColor: '#1a1d27',
|
|
}]}, options:{ plugins:{ legend:{
|
|
position:'right', labels:{ color:'#b0bdd0', font:{size:12}, padding:12, boxWidth:12 }
|
|
}}, cutout:'55%' }
|
|
});
|
|
}
|
|
|
|
// ── 통계 카드 ─────────────────────────────────────────────────────────────
|
|
function renderStats(s, targetId) {
|
|
const wr = Number(s.win_rate || 0);
|
|
const pnl = Number(s.total_pnl || 0);
|
|
const mdd = Number(s.max_drawdown || 0);
|
|
const pf = Number(s.profit_factor || 0);
|
|
const stats = [
|
|
{ lbl:'총 거래', val: `<span>${(s.total_trades||0).toLocaleString()}건</span>` },
|
|
{ lbl:'승률', val: `<span class="${wr>=50?'text-pnl-pos':'text-pnl-neg'}">${wr}%</span>` },
|
|
{ lbl:'총 손익', val: `<span class="${pnl>=0?'text-pnl-pos':'text-pnl-neg'}">${pnl>=0?'+':''}${Math.round(pnl).toLocaleString()}원</span>` },
|
|
{ lbl:'Profit Factor', val: `<span class="${pf>=1?'text-pnl-pos':'text-pnl-neg'}">${pf}</span>` },
|
|
{ lbl:'평균보유', val: `<span>${s.avg_hold_min||0}분</span>` },
|
|
{ lbl:'최대손실(MDD)', val: `<span class="text-pnl-neg">-${Math.round(mdd).toLocaleString()}원</span>` },
|
|
];
|
|
$(targetId).innerHTML = stats.map(s => `
|
|
<div class="col-6 col-sm-4 col-lg-2">
|
|
<div class="card stat-card">
|
|
<div class="val">${s.val}</div>
|
|
<div class="lbl">${s.lbl}</div>
|
|
</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
// ── 탭1: 실거래 분석 ──────────────────────────────────────────────────────
|
|
async function loadActual() {
|
|
const strategy = $('ac_strategy').value;
|
|
const start = $('ac_start').value;
|
|
const end = $('ac_end').value;
|
|
const res = await fetch(`/api/actual?strategy=${strategy}&start=${start}&end=${end}`);
|
|
const data = await res.json();
|
|
if (!data.summary) { toast('데이터 없음', false); return; }
|
|
|
|
renderStats(data.summary, 'ac_stats_row');
|
|
|
|
const eq = data.equity || [];
|
|
makeLineChart('ac_equity_chart', eq.map(e=>e.date), eq.map(e=>e.cum_pnl), '누적손익');
|
|
|
|
const dl = data.daily || [];
|
|
makeBarChart('ac_daily_chart', dl.map(d=>d.date.slice(5)), dl.map(d=>d.pnl), '일별손익');
|
|
|
|
const tbody = $('ac_trades_body');
|
|
const rows = (data.trades||[]).slice(-200).reverse();
|
|
tbody.innerHTML = rows.length ? rows.map(t => `<tr>
|
|
<td>${fmtDt(t.buy_date)}</td>
|
|
<td>${fmtDt(t.sell_date)}</td>
|
|
<td><strong style="color:#e2e8f0">${t.code}</strong></td>
|
|
<td style="text-align:right">${fmtNum(t.buy_price, t.buy_price < 1 ? 4 : 0)}</td>
|
|
<td style="text-align:right">${fmtNum(t.sell_price, t.sell_price < 1 ? 4 : 0)}</td>
|
|
<td style="text-align:right;color:#94a3b8">${Number(t.qty||0).toFixed(4)}</td>
|
|
<td style="text-align:right">${fmt(t.realized_pnl)}</td>
|
|
<td style="text-align:right">${fmtRate(t.profit_rate)}</td>
|
|
<td style="text-align:right;color:#94a3b8">${t.hold_minutes||0}분</td>
|
|
<td><span style="background:#1e2535;padding:2px 7px;border-radius:4px;font-size:11px;color:#94a3b8">${t.sell_reason||''}</span></td>
|
|
</tr>`).join('') : '<tr><td colspan="10" style="text-align:center;padding:24px;color:#64748b">거래 내역이 없습니다</td></tr>';
|
|
}
|
|
|
|
// ── 탭2: 꼬리잡기 백테스트 ───────────────────────────────────────────────
|
|
let _lastBtResult = null;
|
|
|
|
// 백테스트 코인 선택 헬퍼
|
|
async function loadBtMarkets() {
|
|
const tf = $('bt_tf').value;
|
|
const res = await fetch(`/api/candles/markets?timeframe=${tf}`);
|
|
const rows = await res.json();
|
|
const sel = $('bt_market_sel');
|
|
sel.innerHTML = '<option value="">-- 마켓 선택 --</option>' +
|
|
(rows||[]).map(r => `<option value="${r.code}">${r.code} (${r.cnt.toLocaleString()}봉)</option>`).join('');
|
|
toast(`DB에 ${(rows||[]).length}개 마켓 로드됨`, true);
|
|
}
|
|
function addBtMarket(val) {
|
|
if (!val) return;
|
|
const inp = $('bt_markets');
|
|
const cur = inp.value.split(',').map(s=>s.trim()).filter(Boolean);
|
|
if (!cur.includes(val)) cur.push(val);
|
|
inp.value = cur.join(',');
|
|
$('bt_market_sel').value = '';
|
|
}
|
|
|
|
async function runBacktest() {
|
|
$('bt_spinner').classList.add('show');
|
|
$('bt_results').style.display = 'none';
|
|
try {
|
|
const p = {
|
|
markets: $('bt_markets').value, // 코인 필터 (빈칸=전체)
|
|
start: $('bt_start').value,
|
|
end: $('bt_end').value,
|
|
timeframe: $('bt_tf').value,
|
|
min_drop_rate: $('bt_drop').value,
|
|
min_recovery_ratio: $('bt_rec').value,
|
|
max_rec: $('bt_maxrec').value,
|
|
rsi_period: $('bt_rsi_p').value,
|
|
rsi_threshold: $('bt_rsi').value,
|
|
tail_ratio_min: $('bt_tail_r').value,
|
|
tail_pct_min: $('bt_tail_p').value,
|
|
sl_pct: $('bt_sl').value,
|
|
tp_pct: $('bt_tp').value,
|
|
shoulder_cut_pct: $('bt_shoulder').value,
|
|
slot_money: $('bt_slot').value,
|
|
fee_rate: $('bt_fee').value,
|
|
cooldown_min: $('bt_cool').value,
|
|
max_hold_hours: $('bt_hold').value,
|
|
max_daily: $('bt_maxd').value,
|
|
};
|
|
const qs = Object.entries(p).filter(([,v])=>v).map(([k,v])=>`${k}=${encodeURIComponent(v)}`).join('&');
|
|
const res = await fetch(`/api/backtest/tail?${qs}`);
|
|
const data = await res.json();
|
|
_lastBtResult = data;
|
|
|
|
if (!data.summary) { toast('백테스트 실패: ' + (data.error||''), false); return; }
|
|
|
|
renderStats(data.summary, 'bt_stats_row');
|
|
|
|
const eq = data.equity || [];
|
|
makeLineChart('bt_equity_chart', eq.map(e=>e.date), eq.map(e=>e.cum_pnl), '누적손익');
|
|
|
|
const reasons = data.reasons || {};
|
|
makePieChart('bt_reason_chart', Object.keys(reasons), Object.values(reasons));
|
|
|
|
const tbody = $('bt_trades_body');
|
|
const btRows = (data.trades||[]).slice(-200).reverse();
|
|
tbody.innerHTML = btRows.length ? btRows.map(t => `<tr>
|
|
<td>${fmtDt(t.buy_time)}</td>
|
|
<td>${fmtDt(t.sell_time)}</td>
|
|
<td><strong style="color:#e2e8f0">${t.code}</strong></td>
|
|
<td style="text-align:right">${fmtNum(t.buy_price, t.buy_price < 1 ? 4 : 0)}</td>
|
|
<td style="text-align:right">${fmtNum(t.sell_price, t.sell_price < 1 ? 4 : 0)}</td>
|
|
<td style="text-align:right">${fmt(t.pnl)}</td>
|
|
<td style="text-align:right">${fmtRate(t.profit_rate)}</td>
|
|
<td style="text-align:right;color:#94a3b8">${t.hold_min}분</td>
|
|
<td style="text-align:right;color:#94a3b8">${t.rsi_entry}</td>
|
|
<td><span style="background:#1e2535;padding:2px 7px;border-radius:4px;font-size:11px;color:#94a3b8">${t.sell_reason}</span></td>
|
|
</tr>`).join('') : '<tr><td colspan="10" style="text-align:center;padding:24px;color:#64748b">거래 내역이 없습니다</td></tr>';
|
|
|
|
$('bt_results').style.display = 'block';
|
|
const pnlVal = data.summary.total_pnl;
|
|
toast(`백테스트 완료 — ${data.summary.total_trades}건 / ${pnlVal>=0?'+':''}${Math.round(pnlVal).toLocaleString()}원`);
|
|
} catch(e) {
|
|
toast('오류: ' + e, false);
|
|
} finally {
|
|
$('bt_spinner').classList.remove('show');
|
|
}
|
|
}
|
|
|
|
async function applyBestToEnv() {
|
|
if (!_lastBtResult) { toast('먼저 백테스트를 실행하세요', false); return; }
|
|
const p = _lastBtResult.params;
|
|
const body = {
|
|
MIN_DROP_RATE: (p.min_drop_rate / 100).toFixed(4),
|
|
MIN_RECOVERY_RATIO: (p.min_recovery / 100).toFixed(4),
|
|
RSI_OVERHEAT_THRESHOLD: String(p.rsi_threshold),
|
|
TAIL_RATIO_MIN: String($('bt_tail_r').value),
|
|
TAIL_PCT_MIN: ($('bt_tail_p').value / 100).toFixed(4),
|
|
STOP_LOSS_PCT: (-p.sl_pct / 100).toFixed(4),
|
|
TAKE_PROFIT_PCT: (p.tp_pct / 100).toFixed(4),
|
|
SHOULDER_CUT_PCT: (p.shoulder_cut / 100).toFixed(4),
|
|
};
|
|
const res = await fetch('/api/env', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) });
|
|
const data = await res.json();
|
|
if (data.ok) toast('ENV 적용 완료!');
|
|
else toast('ENV 적용 실패', false);
|
|
}
|
|
|
|
// ── 탭3: 캔들 수집 ───────────────────────────────────────────────────────
|
|
async function fetchCandles() {
|
|
const markets = $('cv_markets').value.split(',').map(s=>s.trim()).filter(Boolean);
|
|
const tf = $('cv_tf').value;
|
|
const start = $('cv_start').value;
|
|
const end = $('cv_end').value;
|
|
if (!markets.length) { toast('마켓을 입력하세요', false); return; }
|
|
|
|
$('cv_status').textContent = '수집 시작 중...';
|
|
const res = await fetch('/api/candles/fetch', {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ markets, timeframe: parseInt(tf), start, end })
|
|
});
|
|
const data = await res.json();
|
|
if (!data.ok) { toast('수집 시작 실패', false); return; }
|
|
|
|
const jobId = data.job_id;
|
|
const poll = setInterval(async () => {
|
|
const sr = await fetch(`/api/candles/fetch/status/${jobId}`);
|
|
const stat = await sr.json();
|
|
$('cv_status').textContent = `진행 중... 저장: ${stat.saved}봉 (${stat.status})`;
|
|
if (stat.status === 'done') {
|
|
clearInterval(poll);
|
|
toast(`수집 완료! 저장된 봉: ${stat.saved}개`);
|
|
$('cv_status').textContent = `완료 — 저장: ${stat.saved}봉`;
|
|
loadCandleMarkets();
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
async function loadCandleMarkets() {
|
|
const tf = $('cv_tf_filter').value;
|
|
const res = await fetch(`/api/candles/markets?timeframe=${tf}`);
|
|
const rows = await res.json();
|
|
$('cv_markets_body').innerHTML = (rows||[]).map(r => {
|
|
const fmt_dt = s => s ? s.slice(0,4)+'-'+s.slice(4,6)+'-'+s.slice(6,8)+' '+s.slice(8,10)+':'+s.slice(10) : '';
|
|
return `<tr><td><strong>${r.code}</strong></td><td>${r.cnt}</td>
|
|
<td>${fmt_dt(r.first_dt)}</td><td>${fmt_dt(r.last_dt)}</td></tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
// ── 탭4: ENV 설정 ─────────────────────────────────────────────────────────
|
|
const ENV_LABELS = {
|
|
UPBIT_ACCESS_KEY: { label: 'Access Key', group: 'API 키' },
|
|
UPBIT_SECRET_KEY: { label: 'Secret Key', group: 'API 키' },
|
|
MM_SERVER_URL: { label: 'MM 서버 URL', group: '알림' },
|
|
MM_BOT_TOKEN_: { label: 'MM 봇 토큰', group: '알림' },
|
|
MATTERMOST_CHANNEL: { label: 'MM 채널명', group: '알림' },
|
|
MAX_STOCKS: { label: '최대 보유 코인 수', group: '포지션' },
|
|
SLOT_MONEY_DEFAULT: { label: '코인당 투자금(원)', group: '포지션' },
|
|
STOP_LOSS_PCT: { label: '손절 비율 (예:-0.02)', group: '손익' },
|
|
TAKE_PROFIT_PCT: { label: '익절 비율 (예:0.05)', group: '손익' },
|
|
MAX_LOSS_PER_TRADE_KRW: { label: '원화 최대 손실 한도', group: '손익' },
|
|
MIN_DROP_RATE: { label: '최소 낙폭 (예:0.03)', group: '스캔 조건' },
|
|
MIN_RECOVERY_RATIO: { label: '최소 회복률 (예:0.30)', group: '스캔 조건' },
|
|
MAX_RECOVERY_RATIO: { label: '최대 회복률 (예:0.80)', group: '스캔 조건' },
|
|
HIGH_PRICE_CHASE_THRESHOLD:{ label: '고점 추격 방지 (예:0.96)', group: '스캔 조건' },
|
|
RSI_OVERHEAT_THRESHOLD: { label: 'RSI 과열 기준', group: '스캔 조건' },
|
|
TAIL_RATIO_MIN: { label: '꼬리/몸통 최소 비율', group: '꼬리 조건' },
|
|
TAIL_PCT_MIN: { label: '꼬리 최소% (예:0.003)', group: '꼬리 조건' },
|
|
SHOULDER_CUT_PCT: { label: '어깨매도 하락률', group: '매도 로직' },
|
|
SHOULDER_MIN_HIGH_PCT: { label: '어깨매도 최소 이익률', group: '매도 로직' },
|
|
SCALP_ATR_UP_MULT: { label: 'ATR 스캘핑 상승배수', group: '매도 로직' },
|
|
SCALP_ATR_DOWN_MULT: { label: 'ATR 스캘핑 하락배수', group: '매도 로직' },
|
|
STOP_ATR_MULTIPLIER_TAIL: { label: '손절 ATR 배수', group: '매도 로직' },
|
|
TARGET_ATR_MULTIPLIER_TAIL:{ label: '목표가 ATR 배수', group: '매도 로직' },
|
|
REENTRY_COOLDOWN_SEC: { label: '재진입 쿨다운(초)', group: '기타' },
|
|
UPBIT_SCAN_INTERVAL_SEC: { label: '스캔 주기(초)', group: '기타' },
|
|
UPBIT_BUY_TOP_N: { label: '상위 N개 매수', group: '기타' },
|
|
UPBIT_CANDLE_UNIT: { label: '스캔 분봉 단위', group: '기타' },
|
|
};
|
|
|
|
let _envData = {};
|
|
|
|
async function loadEnv() {
|
|
const res = await fetch('/api/env');
|
|
_envData = await res.json();
|
|
const form = $('env_form');
|
|
|
|
// 그룹별 분류
|
|
const groups = {};
|
|
for (const [k, meta] of Object.entries(ENV_LABELS)) {
|
|
const g = meta.group;
|
|
if (!groups[g]) groups[g] = [];
|
|
groups[g].push({ k, label: meta.label });
|
|
}
|
|
|
|
form.innerHTML = Object.entries(groups).map(([grp, items]) => `
|
|
<div class="col-12">
|
|
<h6 style="color:#f0931c;border-bottom:1px solid #3a4458;padding-bottom:6px;margin:14px 0 10px">${grp}</h6>
|
|
</div>
|
|
${items.map(({k, label}) => `
|
|
<div class="col-md-4 col-sm-6 col-12">
|
|
<label class="form-label mb-1" style="font-size:12px;color:#94a3b8">${label}</label>
|
|
<input id="env_${k}" class="form-control"
|
|
value="${_envData[k] != null ? _envData[k] : ''}"
|
|
placeholder="${_envData[k] != null ? '' : '미설정'}">
|
|
</div>`).join('')}
|
|
`).join('');
|
|
}
|
|
|
|
async function saveEnv() {
|
|
const body = {};
|
|
for (const k of Object.keys(ENV_LABELS)) {
|
|
const el = $('env_' + k);
|
|
if (el) body[k] = el.value;
|
|
}
|
|
const res = await fetch('/api/env', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) });
|
|
const data = await res.json();
|
|
if (data.ok) toast('ENV 설정 저장 완료!');
|
|
else toast('저장 실패: ' + (data.error||''), false);
|
|
}
|
|
|
|
// ── 탭5: 보유현황 ─────────────────────────────────────────────────────────
|
|
async function loadHoldings() {
|
|
const [hRes, cRes] = await Promise.all([fetch('/api/holdings'), fetch('/api/candidates')]);
|
|
const holdings = await hRes.json();
|
|
const candidates = await cRes.json();
|
|
|
|
const noData = (cols) => `<tr><td colspan="${cols}" style="text-align:center;padding:20px;color:#64748b">데이터 없음</td></tr>`;
|
|
|
|
$('holdings_body').innerHTML = (holdings||[]).length ? (holdings||[]).map(h => {
|
|
const bp = Number(h.avg_buy_price||0);
|
|
const cp = Number(h.current_price||0);
|
|
const pct = bp > 0 ? (cp - bp) / bp * 100 : 0;
|
|
const dec = bp < 10 ? 4 : bp < 1000 ? 2 : 0;
|
|
return `<tr>
|
|
<td><strong style="color:#e2e8f0">${h.code}</strong></td>
|
|
<td><span style="background:#1e2535;padding:2px 7px;border-radius:4px;font-size:11px;color:#94a3b8">${h.strategy||''}</span></td>
|
|
<td style="text-align:right">${fmtNum(bp,dec)}</td>
|
|
<td style="text-align:right">${fmtRate(pct)}</td>
|
|
<td style="text-align:right">${fmtNum(cp,dec)}</td>
|
|
<td style="text-align:right">${fmtNum(h.max_price,dec)}</td>
|
|
<td style="text-align:right" class="text-pnl-neg">${fmtNum(h.stop_price,dec)}</td>
|
|
<td style="text-align:right" class="text-pnl-pos">${fmtNum(h.target_price,dec)}</td>
|
|
<td style="text-align:right;color:#94a3b8">${Number(h.current_qty||0).toFixed(4)}</td>
|
|
<td style="text-align:right">${fmtNum(h.total_invested,0)}</td>
|
|
<td style="text-align:right;color:#94a3b8">${Number(h.rsi||0).toFixed(1)}</td>
|
|
<td>${fmtDt(h.buy_date)}</td>
|
|
</tr>`;}).join('') : noData(12);
|
|
|
|
$('candidates_body').innerHTML = (candidates||[]).length ? (candidates||[]).map(c => `
|
|
<tr>
|
|
<td><strong style="color:#e2e8f0">${c.code}</strong></td>
|
|
<td style="text-align:right;color:#f0931c">${Number(c.score||0).toFixed(1)}</td>
|
|
<td style="text-align:right">${fmtNum(c.price, Number(c.price) < 10 ? 4 : 0)}</td>
|
|
<td>${fmtDt(c.scan_time)}</td>
|
|
</tr>`).join('') : noData(4);
|
|
}
|
|
|
|
// ── 초기화 ────────────────────────────────────────────────────────────────
|
|
// DB(env_config) 키 → 백테스트 입력 필드 매핑
|
|
// mult: DB 값에 곱할 배수 (DB가 소수점이면 *100 해서 % 단위로 표시)
|
|
// abs : true면 절댓값으로 변환 (STOP_LOSS_PCT는 DB에 -0.02 → 화면에 2.0)
|
|
const BT_ENV_MAP = [
|
|
{ key:'MIN_DROP_RATE', id:'bt_drop', mult:100, abs:false },
|
|
{ key:'MIN_RECOVERY_RATIO', id:'bt_rec', mult:100, abs:false },
|
|
{ key:'MAX_RECOVERY_RATIO', id:'bt_maxrec', mult:100, abs:false },
|
|
{ key:'RSI_OVERHEAT_THRESHOLD', id:'bt_rsi', mult:1, abs:false },
|
|
{ key:'TAIL_RATIO_MIN', id:'bt_tail_r', mult:1, abs:false },
|
|
{ key:'TAIL_PCT_MIN', id:'bt_tail_p', mult:100, abs:false },
|
|
{ key:'STOP_LOSS_PCT', id:'bt_sl', mult:100, abs:true },
|
|
{ key:'TAKE_PROFIT_PCT', id:'bt_tp', mult:100, abs:false },
|
|
{ key:'SHOULDER_CUT_PCT', id:'bt_shoulder', mult:100, abs:false },
|
|
{ key:'SLOT_MONEY_DEFAULT', id:'bt_slot', mult:1, abs:false },
|
|
{ key:'UPBIT_CANDLE_UNIT', id:'bt_tf', mult:1, abs:false },
|
|
];
|
|
|
|
async function loadBtDefaults() {
|
|
try {
|
|
const res = await fetch('/api/env');
|
|
const env = await res.json();
|
|
let applied = 0;
|
|
for (const {key, id, mult, abs} of BT_ENV_MAP) {
|
|
const raw = env[key];
|
|
if (raw == null || raw === '') continue;
|
|
const el = $(id);
|
|
if (!el) continue;
|
|
let val = parseFloat(raw);
|
|
if (isNaN(val)) continue;
|
|
if (abs) val = Math.abs(val);
|
|
if (mult !== 1) val = val * mult;
|
|
// 소수점 정리: 정수면 정수로, 아니면 소수 2자리까지
|
|
el.value = Number.isInteger(val) ? val : parseFloat(val.toFixed(2));
|
|
applied++;
|
|
}
|
|
if (applied > 0) {
|
|
console.log(`[init] 백테스트 기본값 DB에서 ${applied}개 로드`);
|
|
}
|
|
} catch(e) {
|
|
console.warn('[init] ENV 로드 실패, 하드코딩 기본값 사용:', e);
|
|
}
|
|
}
|
|
|
|
(async function init() {
|
|
const today = new Date().toISOString().slice(0,10);
|
|
const month = new Date(Date.now() - 30*86400000).toISOString().slice(0,10);
|
|
['ac_start','bt_start','cv_start'].forEach(id => { if ($(id)) $(id).value = month; });
|
|
['ac_end', 'bt_end', 'cv_end' ].forEach(id => { if ($(id)) $(id).value = today; });
|
|
|
|
// DB에서 백테스트 파라미터 기본값 로드 (ENV → % 단위 변환 포함)
|
|
await loadBtDefaults();
|
|
|
|
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=6060, debug=False)
|