Files
upbit_trader/upbit_backtest_web.py
2026-03-13 04:37:58 +09:00

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)