let priceChart = null; let pnlChart = null; let priceZoomRange = null; let priceChartInteractionsAttached = false; let pricePanState = null; let isAuthenticated = false; let currentLanguage = localStorage.getItem("dashboard-language") || "ko"; const translations = { ko: { appTitle: "업비트 전략 대시보드", strategy: "전략", market: "마켓", googleLogin: "Google 로그인", appleLogin: "Apple 로그인", logout: "로그아웃", refresh: "새로고침", latestPrice: "최근 가격", latestSignal: "최근 신호", currentTrend: "현재 추세", cumulativePnl: "누적 실현손익", returnOnBuyAmount: "누적 매수금액 대비 수익률", loginToView: "로그인 후 확인", chartTitle: "가격 / 누적 실현손익", chartDescription: "가격선과 매매 마커, 로그인 후 하단 누적 수익률 막대를 함께 확인합니다.", lockedProfitTitle: "누적 수익률 메뉴 잠금", lockedProfitDescription: "로그인하면 하단 누적 실현손익 막대와 수익률 카드가 열립니다.", viewWithGoogle: "Google로 보기", all: "전체", previous: "이전", next: "다음", resetZoom: "확대 초기화", recentSignals: "최근 신호", signalsDescription: "XGBoost / CCI / 김치프리미엄 계산 결과", time: "시간", upbitPrice: "업비트 가격", convertedCoinbaseTablePrice: "변환 코인베이스 가격", price: "가격", signal: "신호", grade: "등급", model: "모델", kimchiPremium: "김프", currentStatus: "현재 상태", running: "실행 중", loading: "로딩 중", error: "오류", loggedIn: "로그인됨", trendUp: "상승세", trendDown: "하락세", trendFlat: "보합", tradePrice: "가격", convertedCoinbasePrice: "코인베이스 환산가", cumulativeRealizedPnl: "누적 실현손익", pnlBars: "손익", buy: "매수", sell: "매도", noEntry: "진입 없음", watch: "관망", entry: "진입", hold: "보유", exit: "청산", }, en: { appTitle: "Upbit Strategy Dashboard", strategy: "Strategy", market: "Market", googleLogin: "Sign in with Google", appleLogin: "Sign in with Apple", logout: "Log out", refresh: "Refresh", latestPrice: "Latest Price", latestSignal: "Latest Signal", currentTrend: "Current Trend", cumulativePnl: "Cumulative Realized PnL", returnOnBuyAmount: "Return on Cumulative Buy Amount", loginToView: "Sign in to view", chartTitle: "Price / Cumulative Realized PnL", chartDescription: "View the price line and trade markers. Sign in to unlock cumulative profit bars.", lockedProfitTitle: "Cumulative Return Locked", lockedProfitDescription: "Sign in to unlock lower realized PnL bars and return cards.", viewWithGoogle: "View with Google", all: "All", previous: "Previous", next: "Next", resetZoom: "Reset Zoom", recentSignals: "Recent Signals", signalsDescription: "XGBoost / CCI / Kimchi premium calculations", time: "Time", upbitPrice: "Upbit Price", convertedCoinbaseTablePrice: "Converted Coinbase Price", price: "Price", signal: "Signal", grade: "Grade", model: "Model", kimchiPremium: "Kimchi", currentStatus: "Current Status", running: "Running", loading: "Loading", error: "Error", loggedIn: "Signed in", trendUp: "Uptrend", trendDown: "Downtrend", trendFlat: "Sideways", tradePrice: "Price", convertedCoinbasePrice: "Converted Coinbase Price", cumulativeRealizedPnl: "Cumulative Realized PnL", pnlBars: "PnL", buy: "Buy", sell: "Sell", noEntry: "No Entry", watch: "Watch", entry: "Entry", hold: "Hold", exit: "Exit", }, }; function t(key) { return translations[currentLanguage][key] || translations.ko[key] || key; } function translateCode(value) { if (!value) { return "-"; } const normalized = String(value).trim().toLowerCase(); const codeMap = { buy: "buy", sell: "sell", no_entry: "noEntry", noentry: "noEntry", watch: "watch", entry: "entry", hold: "hold", exit: "exit", }; return codeMap[normalized] ? t(codeMap[normalized]) : String(value); } function fmtNumber(value, digits = 0) { if (value === null || value === undefined || Number.isNaN(Number(value))) { return "-"; } return Number(value).toLocaleString("ko-KR", { minimumFractionDigits: digits, maximumFractionDigits: digits, }); } function fmtPct(value, digits = 3) { if (value === null || value === undefined || Number.isNaN(Number(value))) { return "-"; } return `${Number(value).toFixed(digits)}%`; } function setStatus(key) { const statusText = document.getElementById("statusText"); if (statusText) { statusText.textContent = t(key); } } function applyLanguage() { document.documentElement.lang = currentLanguage; document.title = t("appTitle"); document.querySelectorAll("[data-i18n]").forEach((element) => { element.textContent = t(element.dataset.i18n); }); const languageToggle = document.getElementById("languageToggle"); if (languageToggle) { languageToggle.textContent = currentLanguage === "ko" ? "English" : "한국어"; } if (priceChart) { priceChart.data.datasets.forEach((dataset) => { if (dataset.id === "tradePrice") { dataset.label = t("tradePrice"); } else if (dataset.id === "convertedPrice") { dataset.label = t("convertedCoinbasePrice"); } else if (dataset.id === "cumulativePnl") { dataset.label = t("cumulativeRealizedPnl"); } else if (dataset.id === "buyMarkers") { dataset.label = t("buy"); } else if (dataset.id === "sellMarkers") { dataset.label = t("sell"); } }); priceChart.options.scales.price.title.text = t("price"); priceChart.options.scales.pnl.title.text = t("pnlBars"); priceChart.update("none"); } } async function fetchJson(url, timeoutMs = 10000) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal }); if (!response.ok) { throw new Error(`${url} ${response.status}`); } return response.json(); } finally { clearTimeout(timeoutId); } } function buildPriceDatasets(events) { const buyPoints = []; const sellPoints = []; for (const event of events) { const point = { x: event.event_time_kst, y: Number(event.price), event, }; if (event.event_type === "BUY") { buyPoints.push(point); } else if (event.event_type === "SELL") { sellPoints.push(point); } } return [ { id: "buyMarkers", label: t("buy"), type: "scatter", data: buyPoints, yAxisID: "price", pointStyle: "triangle", pointRadius: 7, pointHoverRadius: 10, rotation: 0, borderWidth: 2, borderColor: "#b91c1c", backgroundColor: "#ef4444", order: 0, showLine: false, }, { id: "sellMarkers", label: t("sell"), type: "scatter", data: sellPoints, yAxisID: "price", pointStyle: "triangle", pointRadius: 7, pointHoverRadius: 10, rotation: 180, borderWidth: 2, borderColor: "#1d4ed8", backgroundColor: "#3b82f6", order: 0, showLine: false, }, ]; } function buildPriceSeries(btcPrices, signals, events) { const btcRows = btcPrices .filter((row) => row.price_time_kst && !Number.isNaN(Number(row.price))) .map((row) => ({ x: row.price_time_kst, y: Number(row.price), })); if (btcRows.length > 0) { return btcRows; } const signalRows = signals .filter((row) => row.signal_time_kst && !Number.isNaN(Number(row.current_price))) .map((row) => ({ x: row.signal_time_kst, y: Number(row.current_price), })); if (signalRows.length > 0) { return signalRows; } return events .filter((row) => row.event_time_kst && !Number.isNaN(Number(row.price))) .map((row) => ({ x: row.event_time_kst, y: Number(row.price), })); } function buildConvertedPriceSeries(signals) { return signals .filter((row) => row.signal_time_kst && !Number.isNaN(Number(row.converted_price))) .map((row) => ({ x: row.signal_time_kst, y: Number(row.converted_price), })); } function buildPnlSeries(pnlRows) { if (!isAuthenticated) { return []; } const rows = pnlRows.length > 0 ? pnlRows : [{ event_time_kst: "0", cumulative_realized_pnl_krw: 0 }]; return rows.map((row) => { const value = Number(row.cumulative_realized_pnl_krw); return { x: row.event_time_kst || "-", y: Number.isNaN(value) ? 0 : value, }; }); } function buildPriceLabels(priceRows, events, pnlRows = [], convertedRows = []) { const labels = new Set(); for (const row of priceRows) { labels.add(row.x); } for (const event of events) { if (event.event_time_kst) { labels.add(event.event_time_kst); } } for (const row of pnlRows) { if (row.event_time_kst) { labels.add(row.event_time_kst); } } for (const row of convertedRows) { labels.add(row.x); } return [...labels].sort(); } function formatAxisLabel(value) { if (!value || value === "0" || value === "-") { return value || "-"; } const parts = String(value).split(" "); if (parts.length < 2) { return String(value); } const datePart = parts[0].slice(5); const timePart = parts[1].slice(0, 5); return `${datePart} ${timePart}`; } function renderPriceChart(btcPrices, signals, events, pnlRows) { const ctx = document.getElementById("priceChart"); const priceData = buildPriceSeries(btcPrices, signals, events); const convertedPriceData = buildConvertedPriceSeries(signals); const pnlData = buildPnlSeries(pnlRows); const labels = buildPriceLabels(priceData, events, pnlRows, convertedPriceData); if (labels.length === 0) { labels.push("0"); } const datasets = [ { id: "tradePrice", label: t("tradePrice"), data: priceData, yAxisID: "price", tension: 0.18, pointRadius: 0, pointHoverRadius: 4, borderWidth: 2, borderColor: "#4b5563", backgroundColor: "#4b5563", order: 1, }, { id: "convertedPrice", label: t("convertedCoinbasePrice"), data: convertedPriceData, yAxisID: "price", tension: 0.18, pointRadius: 0, pointHoverRadius: 4, borderWidth: 1.5, borderDash: [6, 4], borderColor: "#0f766e", backgroundColor: "#0f766e", order: 1, }, ...(isAuthenticated ? [{ id: "cumulativePnl", label: t("cumulativeRealizedPnl"), type: "bar", data: pnlData, yAxisID: "pnl", borderWidth: 0, borderColor: "#059669", backgroundColor: function(context) { const value = context.parsed.y; return value >= 0 ? "rgba(239, 68, 68, 0.45)" : "rgba(37, 99, 235, 0.45)"; }, order: 2, }] : []), ...buildPriceDatasets(events), ]; if (priceChart) { priceChart.data.labels = labels; priceChart.data.datasets = datasets; applyPriceZoomRange(); priceChart.update("none"); return; } priceChart = new Chart(ctx, { type: "line", data: { labels, datasets, }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: "index", intersect: false, }, plugins: { title: { display: false, }, tooltip: { backgroundColor: "rgba(15, 23, 42, 0.92)", borderColor: "rgba(148, 163, 184, 0.35)", borderWidth: 1, padding: 10, displayColors: true, callbacks: { label: function(context) { const label = context.dataset.label || ""; const value = context.parsed.y; if (context.dataset.id === "buyMarkers" || context.dataset.id === "sellMarkers") { const event = context.raw.event; return `${label}: ${fmtNumber(value)} KRW / pnl=${fmtPct(event.pnl_pct)}`; } if (context.dataset.id === "cumulativePnl") { return `${label}: ${fmtNumber(value)} KRW`; } return `${label}: ${fmtNumber(value)} KRW`; }, }, }, legend: { display: true, }, }, scales: { x: { stacked: true, grid: { color: "rgba(148, 163, 184, 0.12)", }, ticks: { color: "#64748b", autoSkip: true, maxTicksLimit: 7, maxRotation: 0, callback: function(value) { return formatAxisLabel(this.getLabelForValue(value)); }, }, }, y: { display: false, }, price: { type: "linear", position: "left", stack: "main", stackWeight: 4, title: { display: true, text: t("price"), }, ticks: { color: "#64748b", callback: function(value) { return fmtNumber(value); }, }, }, pnl: { type: "linear", position: "right", stack: "main", stackWeight: 1, beginAtZero: true, grid: { drawOnChartArea: true, color: "rgba(107, 114, 128, 0.12)", }, title: { display: true, text: t("pnlBars"), }, ticks: { color: "#64748b", callback: function(value) { return fmtNumber(value); }, }, }, }, }, }); attachPriceChartInteractions(); } function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function normalizePriceZoomRange() { if (!priceChart || !priceZoomRange) { return null; } const labelCount = priceChart.data.labels.length; if (labelCount <= 0) { priceZoomRange = null; return null; } const minIndex = clamp(priceZoomRange.minIndex, 0, labelCount - 1); const maxIndex = clamp(priceZoomRange.maxIndex, minIndex, labelCount - 1); priceZoomRange = { minIndex, maxIndex }; if (minIndex === 0 && maxIndex === labelCount - 1) { priceZoomRange = null; return null; } return priceZoomRange; } function applyPriceZoomRange() { if (!priceChart) { return; } const xScale = priceChart.options.scales.x; const labels = priceChart.data.labels; const range = normalizePriceZoomRange(); if (!range) { delete xScale.min; delete xScale.max; return; } xScale.min = labels[range.minIndex]; xScale.max = labels[range.maxIndex]; } function resetPriceZoom() { priceZoomRange = null; applyPriceZoomRange(); if (priceChart) { priceChart.update("none"); } } function zoomPriceChart(deltaY, offsetX) { if (!priceChart || priceChart.data.labels.length <= 1) { return; } const labels = priceChart.data.labels; const labelCount = labels.length; const area = priceChart.chartArea; const current = normalizePriceZoomRange() || { minIndex: 0, maxIndex: labelCount - 1 }; const visibleCount = current.maxIndex - current.minIndex + 1; const zoomFactor = deltaY < 0 ? 0.78 : 1.28; const nextVisibleCount = clamp(Math.round(visibleCount * zoomFactor), 5, labelCount); if (nextVisibleCount === labelCount) { priceZoomRange = null; applyPriceZoomRange(); priceChart.update("none"); return; } const xRatio = area.right > area.left ? clamp((offsetX - area.left) / (area.right - area.left), 0, 1) : 0.5; const focusIndex = current.minIndex + Math.round((visibleCount - 1) * xRatio); let minIndex = Math.round(focusIndex - (nextVisibleCount - 1) * xRatio); minIndex = clamp(minIndex, 0, labelCount - nextVisibleCount); priceZoomRange = { minIndex, maxIndex: minIndex + nextVisibleCount - 1, }; applyPriceZoomRange(); priceChart.update("none"); } function panPriceChart(deltaX) { if (!priceChart || !priceZoomRange) { return; } const labels = priceChart.data.labels; const labelCount = labels.length; const area = priceChart.chartArea; const range = normalizePriceZoomRange(); if (!range || area.right <= area.left) { return; } const visibleCount = range.maxIndex - range.minIndex + 1; const labelsPerPixel = visibleCount / (area.right - area.left); const offset = Math.round(-deltaX * labelsPerPixel); if (offset === 0) { return; } const minIndex = clamp(range.minIndex + offset, 0, labelCount - visibleCount); priceZoomRange = { minIndex, maxIndex: minIndex + visibleCount - 1, }; applyPriceZoomRange(); priceChart.update("none"); } function showRecentPricePoints(count) { if (!priceChart) { return; } const labelCount = priceChart.data.labels.length; if (count === "all" || labelCount <= count) { resetPriceZoom(); return; } priceZoomRange = { minIndex: labelCount - count, maxIndex: labelCount - 1, }; applyPriceZoomRange(); priceChart.update("none"); } function movePriceWindow(direction) { if (!priceChart) { return; } const labelCount = priceChart.data.labels.length; const range = normalizePriceZoomRange(); if (!range) { showRecentPricePoints(Math.min(100, labelCount)); return; } const visibleCount = range.maxIndex - range.minIndex + 1; const step = Math.max(1, Math.round(visibleCount * 0.7)); const minIndex = clamp( range.minIndex + direction * step, 0, Math.max(0, labelCount - visibleCount) ); priceZoomRange = { minIndex, maxIndex: minIndex + visibleCount - 1, }; applyPriceZoomRange(); priceChart.update("none"); } function attachPriceChartInteractions() { if (!priceChart || priceChartInteractionsAttached) { return; } const canvas = priceChart.canvas; canvas.addEventListener("wheel", function(event) { event.preventDefault(); zoomPriceChart(event.deltaY, event.offsetX); }, { passive: false }); canvas.addEventListener("pointerdown", function(event) { if (!event.shiftKey) { return; } pricePanState = { pointerId: event.pointerId, lastX: event.clientX, }; canvas.setPointerCapture(event.pointerId); }); canvas.addEventListener("pointermove", function(event) { if (!pricePanState || pricePanState.pointerId !== event.pointerId) { return; } panPriceChart(event.clientX - pricePanState.lastX); pricePanState.lastX = event.clientX; }); canvas.addEventListener("pointerup", function(event) { if (pricePanState && pricePanState.pointerId === event.pointerId) { pricePanState = null; } }); canvas.addEventListener("pointercancel", function() { pricePanState = null; }); priceChartInteractionsAttached = true; } function ensurePriceToolbar() { const priceCanvas = document.getElementById("priceChart"); if (!priceCanvas || document.getElementById("resetPriceZoomButton")) { return; } const toolbar = document.createElement("div"); toolbar.className = "chart-toolbar"; toolbar.innerHTML = ` `; priceCanvas.parentNode.insertBefore(toolbar, priceCanvas); } function updatePrivateVisibility() { document.body.classList.toggle("is-authenticated", isAuthenticated); document.body.classList.toggle("is-anonymous", !isAuthenticated); if (priceChart) { priceChart.update("none"); } } function renderPnlChart(pnlRows) { const ctx = document.getElementById("pnlChart"); if (pnlChart) { pnlChart.destroy(); pnlChart = null; } if (ctx && ctx.closest(".chart-card")) { ctx.closest(".chart-card").style.display = "none"; } } function calculateTotalBuyFunds(events) { return events.reduce((sum, event) => { if (event.event_type !== "BUY") { return sum; } const funds = Number(event.funds); return sum + (Number.isNaN(funds) ? 0 : funds); }, 0); } function getTrendInfo(signal) { if (!signal) { return { label: "-", className: "trend-flat" }; } if (signal.trend_up) { return { label: t("trendUp"), className: "trend-up" }; } if (signal.trend_down) { return { label: t("trendDown"), className: "trend-down" }; } return { label: t("trendFlat"), className: "trend-flat" }; } function renderSignalTable(signals) { const tbody = document.getElementById("signalTableBody"); tbody.innerHTML = ""; const recent = [...signals].reverse().slice(0, 30); for (const row of recent) { const tr = document.createElement("tr"); tr.innerHTML = `