let priceChart = null; let pnlChart = null; let cciChart = null; let priceZoomRange = null; let priceChartInteractionsAttached = false; let pricePanState = null; let priceClickTimer = null; let isAuthenticated = false; let currentLanguage = localStorage.getItem("dashboard-language") || "ko"; let hasInitializedFullPriceRange = false; let filterOptions = null; let selectedMarket = localStorage.getItem("dashboard-market") || ""; let selectedStrategyCode = localStorage.getItem("dashboard-strategy-code") || ""; const CCI_WINDOW = 20; const INLINE_FAVICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%23111827'/%3E%3Cpath d='M3 11h10M4 9l2-2 2 1 4-4' stroke='%23f8fafc' stroke-width='1.6' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"; const selectionPlugin = { id: "selectionOverlay", afterDatasetsDraw(chart) { return; }, }; function installSimpleChartFallback() { if (typeof Chart !== "undefined") { return; } class SimpleChart { static instances = {}; static nextId = 1; static register() { return undefined; } constructor(canvas, config) { this.canvas = canvas.canvas || canvas; this.ctx = this.canvas.getContext("2d"); this.data = config.data || { labels: [], datasets: [] }; this.options = config.options || {}; this.id = SimpleChart.nextId; SimpleChart.nextId += 1; SimpleChart.instances[this.id] = this; this.update(); } destroy() { delete SimpleChart.instances[this.id]; this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); } update() { this.draw(); } draw() { const rect = this.canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const width = Math.max(320, rect.width); const height = Math.max(180, rect.height); if (this.canvas.width !== Math.round(width * dpr) || this.canvas.height !== Math.round(height * dpr)) { this.canvas.width = Math.round(width * dpr); this.canvas.height = Math.round(height * dpr); } this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.ctx.clearRect(0, 0, width, height); const chartArea = { left: 54, right: width - 34, top: 18, bottom: height - 34, }; this.chartArea = chartArea; const labels = this.data.labels || []; const xMin = Number.isFinite(Number(this.options.scales?.x?.min)) ? Number(this.options.scales.x.min) : 0; const xMax = Number.isFinite(Number(this.options.scales?.x?.max)) ? Number(this.options.scales.x.max) : Math.max(labels.length - 1, 1); const axes = this.getAxes(chartArea); this.drawFrame(chartArea, width, height, labels, xMin, xMax, axes); for (const dataset of [...(this.data.datasets || [])].sort((a, b) => (b.order || 0) - (a.order || 0))) { if (dataset.hidden) { continue; } const axisId = dataset.yAxisID || "y"; const axis = axes[axisId] || axes.y || axes.price; if (!axis) { continue; } if (dataset.type === "bar") { this.drawBars(dataset, chartArea, xMin, xMax, axis); } else if (dataset.type === "scatter" || dataset.showLine === false) { this.drawPoints(dataset, chartArea, xMin, xMax, axis); } else { this.drawLine(dataset, chartArea, xMin, xMax, axis); } } } getAxes(chartArea) { const axes = {}; const scaleDefs = this.options.scales || {}; const axisIds = new Set( (this.data.datasets || []).map((dataset) => dataset.yAxisID || "y") ); if (axisIds.size === 0) { axisIds.add("y"); } for (const axisId of axisIds) { const scaleDef = scaleDefs[axisId] || {}; const values = []; for (const dataset of this.data.datasets || []) { if ((dataset.yAxisID || "y") !== axisId || dataset.hidden) { continue; } for (const point of dataset.data || []) { const y = Number(point.y); if (Number.isFinite(y)) { values.push(y); } } } let min = Number.isFinite(Number(scaleDef.min)) ? Number(scaleDef.min) : Math.min(...values, 0); let max = Number.isFinite(Number(scaleDef.max)) ? Number(scaleDef.max) : Math.max(...values, 1); if (!Number.isFinite(min) || !Number.isFinite(max)) { min = 0; max = 1; } if (scaleDef.beginAtZero) { min = Math.min(min, 0); max = Math.max(max, 0); } if (min === max) { min -= 1; max += 1; } axes[axisId] = { min, max, mapY: (value) => chartArea.bottom - ((Number(value) - min) / (max - min)) * (chartArea.bottom - chartArea.top), }; } return axes; } mapX(value, chartArea, xMin, xMax) { if (xMax === xMin) { return chartArea.left; } return chartArea.left + ((Number(value) - xMin) / (xMax - xMin)) * (chartArea.right - chartArea.left); } resolveColor(value, context, fallback) { if (typeof value === "function") { return value(context); } return value || fallback; } drawFrame(chartArea, width, height, labels, xMin, xMax, axes) { const ctx = this.ctx; ctx.save(); ctx.strokeStyle = isTokenGraphPage() ? "rgba(135, 160, 215, 0.18)" : "rgba(148, 163, 184, 0.25)"; ctx.fillStyle = isTokenGraphPage() ? "#93a4c8" : "#64748b"; ctx.lineWidth = 1; ctx.font = "11px Arial"; for (let i = 0; i <= 4; i += 1) { const y = chartArea.top + ((chartArea.bottom - chartArea.top) * i) / 4; ctx.beginPath(); ctx.moveTo(chartArea.left, y); ctx.lineTo(chartArea.right, y); ctx.stroke(); } const tickCount = Math.min(5, Math.max(1, labels.length)); for (let i = 0; i < tickCount; i += 1) { const xValue = xMin + ((xMax - xMin) * i) / Math.max(tickCount - 1, 1); const x = this.mapX(xValue, chartArea, xMin, xMax); const label = labels[Math.round(xValue)] || ""; ctx.fillText(formatAxisLabel(label), Math.min(x, width - 86), height - 12); } const firstAxis = axes.price || axes.y || Object.values(axes)[0]; if (firstAxis) { ctx.fillText(fmtNumber(firstAxis.max), 6, chartArea.top + 10); ctx.fillText(fmtNumber(firstAxis.min), 6, chartArea.bottom); } ctx.restore(); } drawLine(dataset, chartArea, xMin, xMax, axis) { const ctx = this.ctx; const points = (dataset.data || []) .map((point) => ({ x: this.mapX(point.x, chartArea, xMin, xMax), y: axis.mapY(point.y), rawX: Number(point.x), })) .filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y) && point.rawX >= xMin && point.rawX <= xMax); if (points.length === 0) { return; } ctx.save(); ctx.strokeStyle = dataset.borderColor || dataset.backgroundColor || "#70f5d7"; ctx.lineWidth = dataset.borderWidth || 2; if (dataset.borderDash) { ctx.setLineDash(dataset.borderDash); } ctx.beginPath(); points.forEach((point, index) => { if (index === 0) { ctx.moveTo(point.x, point.y); } else { ctx.lineTo(point.x, point.y); } }); ctx.stroke(); ctx.restore(); } drawBars(dataset, chartArea, xMin, xMax, axis) { const ctx = this.ctx; const visibleSpan = Math.max(xMax - xMin + 1, 1); const barWidth = Math.max(2, Math.min(12, (chartArea.right - chartArea.left) / visibleSpan * 0.65)); const zeroY = axis.mapY(0); ctx.save(); for (const point of dataset.data || []) { const rawX = Number(point.x); const yValue = Number(point.y); if (!Number.isFinite(rawX) || !Number.isFinite(yValue) || rawX < xMin || rawX > xMax) { continue; } const x = this.mapX(rawX, chartArea, xMin, xMax); const y = axis.mapY(yValue); ctx.fillStyle = this.resolveColor(dataset.backgroundColor, { raw: point, parsed: { y: yValue } }, "rgba(112, 245, 215, 0.35)"); ctx.fillRect(x - barWidth / 2, Math.min(y, zeroY), barWidth, Math.max(2, Math.abs(zeroY - y))); } ctx.restore(); } drawPoints(dataset, chartArea, xMin, xMax, axis) { const ctx = this.ctx; ctx.save(); ctx.fillStyle = dataset.backgroundColor || "#ef4444"; ctx.strokeStyle = dataset.borderColor || dataset.backgroundColor || "#ef4444"; ctx.lineWidth = dataset.borderWidth || 2; for (const point of dataset.data || []) { const rawX = Number(point.x); const yValue = Number(point.y); if (!Number.isFinite(rawX) || !Number.isFinite(yValue) || rawX < xMin || rawX > xMax) { continue; } const x = this.mapX(rawX, chartArea, xMin, xMax); const y = axis.mapY(yValue); const radius = dataset.pointRadius || 5; const rotation = dataset.rotation || 0; ctx.beginPath(); if (dataset.pointStyle === "triangle") { const angle = (rotation - 90) * Math.PI / 180; for (let i = 0; i < 3; i += 1) { const a = angle + i * (Math.PI * 2 / 3); const px = x + Math.cos(a) * radius; const py = y + Math.sin(a) * radius; if (i === 0) { ctx.moveTo(px, py); } else { ctx.lineTo(px, py); } } ctx.closePath(); } else { ctx.arc(x, y, radius, 0, Math.PI * 2); } ctx.fill(); ctx.stroke(); } ctx.restore(); } } window.Chart = SimpleChart; } installSimpleChartFallback(); if (typeof Chart !== "undefined") { Chart.register(selectionPlugin); } const translations = { ko: { appTitle: "업비트 전략 대시보드", strategy: "전략", market: "마켓", googleLogin: "Google 로그인", appleLogin: "Apple 로그인", logout: "로그아웃", refresh: "새로고침", latestPrice: "최근 가격", latestSignal: "최근 신호", currentTrend: "현재 추세", cumulativePnl: "누적 실현손익", returnOnBuyAmount: "누적 매수금액 대비 수익률", strategyPnlTitle: "전략별 누적 수익률", strategyPnlDescription: "전략별 누적 실현손익, 누적 매수금액, 수익률을 비교합니다.", cumulativeBuyAmount: "누적 매수금액", cumulativeReturn: "누적 수익률", tradeCount: "거래 수", selectMarket: "마켓 선택", selectStrategy: "전략 선택", 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: "손익", cci: "CCI", cciUpper: "CCI +100", cciLower: "CCI -100", 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", strategyPnlTitle: "Cumulative Return by Strategy", strategyPnlDescription: "Compare cumulative realized PnL, cumulative buy amount, and return by strategy.", cumulativeBuyAmount: "Cumulative Buy Amount", cumulativeReturn: "Cumulative Return", tradeCount: "Trades", selectMarket: "Select Market", selectStrategy: "Select Strategy", 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", cci: "CCI", cciUpper: "CCI +100", cciLower: "CCI -100", 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 isPublicView() { return document.body.dataset.view === "public"; } function isTokenGraphPage() { return document.body.classList.contains("token-graph-page"); } function applyViewMode() { if (!isPublicView()) { return; } document.body.dataset.view = "public"; document.body.classList.add("public-page"); const heading = document.querySelector(".page-header h1"); if (heading) { heading.textContent = "MediaMak 전략"; } const subtitle = document.querySelector(".page-header p"); if (subtitle) { subtitle.textContent = "BTC 가격 흐름과 전략 신호를 간결하게 확인합니다."; } document.querySelector(".table-card")?.remove(); document.querySelector(".chart-toolbar")?.remove(); document.querySelector("#cciChart")?.remove(); const adminLink = document.querySelector(".admin-link"); if (adminLink) { adminLink.textContent = "관리자"; adminLink.href = "/admin"; adminLink.classList.remove("admin-link"); } } function ensureFavicon() { if (document.querySelector("link[rel='icon']")) { return; } const icon = document.createElement("link"); icon.rel = "icon"; icon.href = INLINE_FAVICON; document.head.appendChild(icon); } 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 isFiniteNumberValue(value) { if (value === null || value === undefined || value === "") { return false; } return Number.isFinite(Number(value)); } 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"); } }); if (priceChart.options.scales?.price?.title) { priceChart.options.scales.price.title.text = t("price"); } if (priceChart.options.scales?.pnl?.title) { priceChart.options.scales.pnl.title.text = t("pnlBars"); } priceChart.update("none"); } if (cciChart) { cciChart.data.datasets.forEach((dataset) => { if (dataset.id === "cci") { dataset.label = t("cci"); } else if (dataset.id === "cciUpper") { dataset.label = t("cciUpper"); } else if (dataset.id === "cciLower") { dataset.label = t("cciLower"); } }); if (cciChart.options.scales?.y?.title) { cciChart.options.scales.y.title.text = t("cci"); } cciChart.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}`); } const text = await response.text(); const safeText = text .replace(/:\s*NaN(?=[,}])/g, ": null") .replace(/:\s*-?Infinity(?=[,}])/g, ": null"); return JSON.parse(safeText); } finally { clearTimeout(timeoutId); } } function buildApiUrl(path, extraParams = {}) { const params = new URLSearchParams(); if (selectedMarket) { params.set("market", selectedMarket); } if (selectedStrategyCode) { params.set("strategy_code", selectedStrategyCode); } for (const [key, value] of Object.entries(extraParams)) { if (value !== null && value !== undefined && value !== "") { params.set(key, value); } } const query = params.toString(); return query ? `${path}?${query}` : path; } function uniqueStrategiesForMarket(options, market) { if (!options) { return []; } const allowed = new Set( options.pairs .filter((row) => !market || row.market === market) .map((row) => row.strategy_code) ); return options.strategies.filter((strategy) => allowed.has(strategy.strategy_code)); } function setSelectOptions(select, rows, valueKey, labelBuilder, selectedValue) { if (!select) { return; } select.innerHTML = ""; for (const row of rows) { const option = document.createElement("option"); option.value = row[valueKey]; option.textContent = labelBuilder(row); select.appendChild(option); } if (selectedValue && rows.some((row) => row[valueKey] === selectedValue)) { select.value = selectedValue; } else if (rows.length > 0) { select.value = rows[0][valueKey]; } } function updateActiveFilterLabels() { const badge = document.getElementById("activeMarketBadge"); if (badge) { badge.textContent = selectedMarket || "-"; } } function syncSelectedFiltersFromDom() { const marketSelect = document.getElementById("marketFilter"); const strategySelect = document.getElementById("strategyFilter"); if (marketSelect?.value) { selectedMarket = marketSelect.value; } if (strategySelect?.value) { selectedStrategyCode = strategySelect.value; } } function renderFilterControls() { const marketSelect = document.getElementById("marketFilter"); const strategySelect = document.getElementById("strategyFilter"); if (!filterOptions || (!marketSelect && !strategySelect)) { updateActiveFilterLabels(); return; } const markets = filterOptions.markets.map((market) => ({ market })); setSelectOptions( marketSelect, markets, "market", (row) => row.market, selectedMarket ); selectedMarket = marketSelect?.value || selectedMarket || filterOptions.default_market; const strategies = uniqueStrategiesForMarket(filterOptions, selectedMarket); setSelectOptions( strategySelect, strategies, "strategy_code", (row) => row.strategy_name && row.strategy_name !== row.strategy_code ? `${row.strategy_code} / ${row.strategy_name}` : row.strategy_code, selectedStrategyCode ); selectedStrategyCode = strategySelect?.value || selectedStrategyCode || filterOptions.default_strategy_code; localStorage.setItem("dashboard-market", selectedMarket); localStorage.setItem("dashboard-strategy-code", selectedStrategyCode); updateActiveFilterLabels(); } async function loadFilterOptions() { filterOptions = await fetchJson("/api/filter-options"); if (!selectedMarket) { selectedMarket = filterOptions.default_market || ""; } if (!selectedStrategyCode) { selectedStrategyCode = filterOptions.default_strategy_code || ""; } renderFilterControls(); } function toIndexedData(rows, labelIndex) { return rows .map((row) => ({ ...row, x: labelIndex.get(row.x), })) .filter((row) => row.x !== undefined && isFiniteNumberValue(row.y)); } function buildPriceDatasets(events, labelIndex) { const buyPoints = []; const sellPoints = []; for (const event of events) { const point = { x: labelIndex.get(event.event_time_kst), y: Number(event.price), event, }; if (point.x === undefined) { continue; } 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 && isFiniteNumberValue(row.upbit_price)) .map((row) => ({ x: row.price_time_kst, y: Number(row.upbit_price), })); if (btcRows.length > 0) { return btcRows; } const signalRows = signals .filter((row) => row.signal_time_kst && isFiniteNumberValue(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 && isFiniteNumberValue(row.price)) .map((row) => ({ x: row.event_time_kst, y: Number(row.price), })); } function buildConvertedPriceSeries(btcPrices, signals) { const btcRows = btcPrices .filter((row) => row.price_time_kst && isFiniteNumberValue(row.converted_price)) .map((row) => ({ x: row.price_time_kst, y: Number(row.converted_price), })); if (btcRows.length > 0) { return btcRows; } return signals .filter((row) => row.signal_time_kst && isFiniteNumberValue(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 buildCciSeries(btcPrices, signals) { const btcRows = btcPrices .filter((row) => row.price_time_kst && isFiniteNumberValue(row.cci)) .map((row) => ({ x: row.price_time_kst, y: Number(row.cci), })); if (btcRows.length > 0) { return btcRows; } const calculatedRows = calculateCciFromPrices(btcPrices); if (calculatedRows.length > 0) { return calculatedRows; } return signals .filter((row) => row.signal_time_kst && isFiniteNumberValue(row.cci)) .map((row) => ({ x: row.signal_time_kst, y: Number(row.cci), })); } function calculateCciFromPrices(btcPrices) { const priceRows = btcPrices .filter((row) => row.price_time_kst && isFiniteNumberValue(row.upbit_price)) .map((row) => ({ x: row.price_time_kst, price: Number(row.upbit_price), })); const cciRows = []; for (let index = CCI_WINDOW - 1; index < priceRows.length; index += 1) { const windowRows = priceRows.slice(index - CCI_WINDOW + 1, index + 1); const prices = windowRows.map((row) => row.price); const mean = prices.reduce((sum, value) => sum + value, 0) / CCI_WINDOW; const meanAbsDeviation = prices.reduce( (sum, value) => sum + Math.abs(value - mean), 0 ) / CCI_WINDOW; if (meanAbsDeviation <= 0) { continue; } cciRows.push({ x: priceRows[index].x, y: (priceRows[index].price - mean) / (0.015 * meanAbsDeviation), }); } return cciRows; } function buildReferenceLine(labels, value) { if (labels.length === 0) { return []; } return [ { x: 0, y: value }, { x: labels.length - 1, y: value }, ]; } function ensureCciCanvas() { let cciCanvas = document.getElementById("cciChart"); if (cciCanvas) { return cciCanvas; } const priceCanvas = document.getElementById("priceChart"); if (!priceCanvas || !priceCanvas.parentNode) { return null; } cciCanvas = document.createElement("canvas"); cciCanvas.id = "cciChart"; cciCanvas.className = "cci-chart"; priceCanvas.parentNode.insertBefore(cciCanvas, priceCanvas.nextSibling); return cciCanvas; } function renderCciChart(labels, indexedCciData) { const ctx = ensureCciCanvas(); if (!ctx || typeof Chart === "undefined") { return; } const datasets = [ { id: "cci", label: t("cci"), data: indexedCciData, tension: 0.18, pointRadius: 0, pointHoverRadius: 3, borderWidth: 1.4, borderColor: isTokenGraphPage() ? "#c7a6ff" : "#7c3aed", backgroundColor: isTokenGraphPage() ? "#c7a6ff" : "#7c3aed", }, { id: "cciUpper", label: t("cciUpper"), data: buildReferenceLine(labels, 100), pointRadius: 0, borderWidth: 1, borderDash: [5, 5], borderColor: "rgba(185, 28, 28, 0.75)", backgroundColor: "rgba(185, 28, 28, 0.75)", }, { id: "cciLower", label: t("cciLower"), data: buildReferenceLine(labels, -100), pointRadius: 0, borderWidth: 1, borderDash: [5, 5], borderColor: "rgba(29, 78, 216, 0.75)", backgroundColor: "rgba(29, 78, 216, 0.75)", }, ]; if (cciChart) { cciChart.data.labels = labels; cciChart.data.datasets = datasets; applyCciZoomRange(); cciChart.update("none"); return; } cciChart = new Chart(ctx, { type: "line", data: { labels, datasets, }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: "index", intersect: false, }, plugins: { legend: { display: true, labels: { boxWidth: 10, boxHeight: 10, }, }, tooltip: { callbacks: { label: function(context) { return `${context.dataset.label || ""}: ${fmtNumber(context.parsed.y, 2)}`; }, }, }, }, scales: { x: { type: "linear", grid: { color: "rgba(148, 163, 184, 0.10)", }, ticks: { color: isTokenGraphPage() ? "#7f8dad" : "#64748b", autoSkip: true, maxTicksLimit: 7, maxRotation: 0, callback: function(value) { const index = Math.round(Number(value)); return formatAxisLabel(this.chart.data.labels[index]); }, }, }, y: { type: "linear", min: -250, max: 250, title: { display: true, text: t("cci"), }, grid: { color: "rgba(148, 163, 184, 0.10)", }, ticks: { color: isTokenGraphPage() ? "#c7a6ff" : "#7c3aed", callback: function(value) { return fmtNumber(value); }, }, }, }, }, }); applyCciZoomRange(); } function buildPriceLabels(priceRows, events, pnlRows = [], convertedRows = [], cciRows = []) { 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); } for (const row of cciRows) { 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"); if (!ctx || typeof Chart === "undefined") { return; } const priceData = buildPriceSeries(btcPrices, signals, events); const convertedPriceData = buildConvertedPriceSeries(btcPrices, signals); const pnlData = buildPnlSeries(pnlRows); const cciData = buildCciSeries(btcPrices, signals); const labels = buildPriceLabels( priceData, events, isPublicView() ? [] : pnlRows, isPublicView() ? [] : convertedPriceData, isPublicView() ? [] : cciData ); if (labels.length === 0) { labels.push("0"); } const labelIndex = new Map(labels.map((label, index) => [label, index])); const indexedPriceData = toIndexedData(priceData, labelIndex); const indexedConvertedPriceData = toIndexedData(convertedPriceData, labelIndex); const indexedPnlData = toIndexedData(pnlData, labelIndex); const indexedCciData = toIndexedData(cciData, labelIndex); if (isPublicView()) { if (cciChart) { cciChart.destroy(); cciChart = null; } const cciCanvas = document.getElementById("cciChart"); if (cciCanvas) { cciCanvas.remove(); } } else { renderCciChart(labels, indexedCciData); } const datasets = [ { id: "tradePrice", label: t("tradePrice"), data: indexedPriceData, yAxisID: "price", tension: 0.18, pointRadius: 0, pointHoverRadius: 4, borderWidth: 2, borderColor: isTokenGraphPage() ? "#70f5d7" : "#4b5563", backgroundColor: isTokenGraphPage() ? "#70f5d7" : "#4b5563", order: 1, }, ...(!isPublicView() ? [{ id: "convertedPrice", label: t("convertedCoinbasePrice"), data: indexedConvertedPriceData, yAxisID: "price", tension: 0.18, pointRadius: 0, pointHoverRadius: 4, borderWidth: 1.5, borderDash: [6, 4], borderColor: isTokenGraphPage() ? "#8ab4ff" : "#0f766e", backgroundColor: isTokenGraphPage() ? "#8ab4ff" : "#0f766e", order: 1, }] : []), ...(isAuthenticated ? [{ id: "cumulativePnl", label: t("cumulativeRealizedPnl"), type: "bar", data: indexedPnlData, yAxisID: "pnl", borderWidth: 0, borderColor: "#059669", backgroundColor: function(context) { const parsedValue = context.parsed && Number.isFinite(Number(context.parsed.y)) ? Number(context.parsed.y) : 0; const rawValue = context.raw && Number.isFinite(Number(context.raw.y)) ? Number(context.raw.y) : parsedValue; const value = rawValue; if (isTokenGraphPage()) { return value >= 0 ? "rgba(112, 245, 215, 0.36)" : "rgba(255, 92, 122, 0.34)"; } return value >= 0 ? "rgba(239, 68, 68, 0.45)" : "rgba(37, 99, 235, 0.45)"; }, order: 2, }] : []), ...buildPriceDatasets(events, labelIndex), ]; if (priceChart) { const previousLabelCount = priceChart.data.labels.length; priceChart.data.labels = labels; priceChart.data.datasets = datasets; if (!hasInitializedFullPriceRange || labels.length > previousLabelCount) { priceZoomRange = null; hasInitializedFullPriceRange = true; } 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: isTokenGraphPage() ? "rgba(6, 11, 31, 0.96)" : "rgba(15, 23, 42, 0.92)", borderColor: isTokenGraphPage() ? "rgba(112, 245, 215, 0.34)" : "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, labels: { color: isTokenGraphPage() ? "#b8c4df" : "#334155", boxWidth: 10, boxHeight: 10, }, }, }, scales: { x: { type: "linear", stacked: true, grid: { color: isTokenGraphPage() ? "rgba(120, 149, 202, 0.14)" : "rgba(148, 163, 184, 0.12)", }, ticks: { color: isTokenGraphPage() ? "#7f8dad" : "#64748b", autoSkip: true, maxTicksLimit: 7, maxRotation: 0, callback: function(value) { const index = Math.round(Number(value)); return formatAxisLabel(this.chart.data.labels[index]); }, }, }, y: { display: false, }, price: { type: "linear", position: "left", stack: "main", stackWeight: 4, title: { display: true, text: t("price"), }, ticks: { color: isTokenGraphPage() ? "#93a4c8" : "#64748b", callback: function(value) { return fmtNumber(value); }, }, }, pnl: { type: "linear", position: "right", stack: "main", stackWeight: 1, beginAtZero: true, grid: { drawOnChartArea: true, color: isTokenGraphPage() ? "rgba(120, 149, 202, 0.12)" : "rgba(107, 114, 128, 0.12)", }, title: { display: true, text: t("pnlBars"), }, ticks: { color: isTokenGraphPage() ? "#93a4c8" : "#64748b", callback: function(value) { return fmtNumber(value); }, }, }, }, }, }); attachPriceChartInteractions(); hasInitializedFullPriceRange = true; } 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 range = normalizePriceZoomRange(); if (!range) { delete xScale.min; delete xScale.max; applyVisibleYAxisRanges(null); applyCciZoomRange(); return; } xScale.min = range.minIndex; xScale.max = range.maxIndex; applyVisibleYAxisRanges(range); applyCciZoomRange(); } function applyCciZoomRange() { if (!cciChart) { return; } const xScale = cciChart.options.scales.x; const range = normalizePriceZoomRange(); if (!range) { delete xScale.min; delete xScale.max; applyVisibleCciYAxisRange(null); return; } xScale.min = range.minIndex; xScale.max = range.maxIndex; applyVisibleCciYAxisRange(range); } function getDatasetYRange(chart, axisId, range) { if (!chart) { return null; } let min = Infinity; let max = -Infinity; for (const dataset of chart.data.datasets) { const datasetAxisId = dataset.yAxisID || "y"; if (dataset.hidden || datasetAxisId !== axisId) { continue; } for (const point of dataset.data || []) { const x = Number(point.x); const y = Number(point.y); if (!Number.isFinite(y)) { continue; } if (range && Number.isFinite(x) && (x < range.minIndex || x > range.maxIndex)) { continue; } min = Math.min(min, y); max = Math.max(max, y); } } if (!Number.isFinite(min) || !Number.isFinite(max)) { return null; } return { min, max }; } function applyAxisRange(chart, axisId, range, minimumSpan = 1) { if (!chart?.options?.scales?.[axisId]) { return; } const scale = chart.options.scales[axisId]; const yRange = getDatasetYRange(chart, axisId, range); if (!yRange) { delete scale.min; delete scale.max; return; } let span = Math.max(yRange.max - yRange.min, minimumSpan); const padding = span * 0.08; scale.min = yRange.min - padding; scale.max = yRange.max + padding; } function applyVisibleYAxisRanges(range) { applyAxisRange(priceChart, "price", range, 1); if (isAuthenticated) { applyAxisRange(priceChart, "pnl", range, 1); } } function applyVisibleCciYAxisRange(range) { if (!cciChart?.options?.scales?.y) { return; } const scale = cciChart.options.scales.y; const yRange = getDatasetYRange(cciChart, "y", range); if (!yRange) { scale.min = -250; scale.max = 250; return; } const min = Math.min(yRange.min, -100); const max = Math.max(yRange.max, 100); const span = Math.max(max - min, 50); const padding = span * 0.08; scale.min = min - padding; scale.max = max + padding; } function resetPriceZoom() { priceZoomRange = null; applyPriceZoomRange(); updateLinkedCharts(); } function updateLinkedCharts() { if (priceChart) { priceChart.update("none"); } if (cciChart) { cciChart.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(); updateLinkedCharts(); 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(); updateLinkedCharts(); } 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(); updateLinkedCharts(); } 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(); updateLinkedCharts(); } 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(); updateLinkedCharts(); } function onFilterChanged() { const marketSelect = document.getElementById("marketFilter"); const strategySelect = document.getElementById("strategyFilter"); selectedMarket = marketSelect?.value || selectedMarket; if (marketSelect && strategySelect && filterOptions) { const strategies = uniqueStrategiesForMarket(filterOptions, selectedMarket); setSelectOptions( strategySelect, strategies, "strategy_code", (row) => row.strategy_name && row.strategy_name !== row.strategy_code ? `${row.strategy_code} / ${row.strategy_name}` : row.strategy_code, strategySelect.value ); } selectedStrategyCode = strategySelect?.value || selectedStrategyCode; localStorage.setItem("dashboard-market", selectedMarket); localStorage.setItem("dashboard-strategy-code", selectedStrategyCode); priceZoomRange = null; updateActiveFilterLabels(); refreshDashboard(); } function pixelToLabelIndex(pixelX) { if (!priceChart || priceChart.data.labels.length <= 0) { return 0; } const area = priceChart.chartArea; const labelCount = priceChart.data.labels.length; const current = normalizePriceZoomRange() || { minIndex: 0, maxIndex: labelCount - 1 }; if (area.right <= area.left) { return current.minIndex; } const ratio = clamp((pixelX - area.left) / (area.right - area.left), 0, 1); return Math.round(current.minIndex + (current.maxIndex - current.minIndex) * ratio); } function zoomPriceChartToPixels(startX, endX) { if (!priceChart || priceChart.data.labels.length <= 1) { return false; } const pixelDistance = Math.abs(endX - startX); if (pixelDistance < 8) { return false; } const labelCount = priceChart.data.labels.length; const startIndex = pixelToLabelIndex(startX); const endIndex = pixelToLabelIndex(endX); const minIndex = clamp(Math.min(startIndex, endIndex), 0, labelCount - 1); const maxIndex = clamp(Math.max(startIndex, endIndex), minIndex, labelCount - 1); if (maxIndex - minIndex < 2) { return false; } priceZoomRange = { minIndex, maxIndex }; applyPriceZoomRange(); updateLinkedCharts(); return true; } 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("contextmenu", function(event) { event.preventDefault(); }); canvas.addEventListener("dblclick", function(event) { event.preventDefault(); if (priceClickTimer) { clearTimeout(priceClickTimer); priceClickTimer = null; } resetPriceZoom(); }); canvas.addEventListener("pointerdown", function(event) { if (event.button !== 0 && event.button !== 2) { return; } const area = priceChart.chartArea; if ( event.offsetX < area.left || event.offsetX > area.right || event.offsetY < area.top || event.offsetY > area.bottom ) { return; } pricePanState = { pointerId: event.pointerId, button: event.button, startClientX: event.clientX, startClientY: event.clientY, startOffsetX: event.offsetX, lastX: event.clientX, moved: false, }; canvas.setPointerCapture(event.pointerId); }); canvas.addEventListener("pointermove", function(event) { if (!pricePanState || pricePanState.pointerId !== event.pointerId) { return; } const totalMoveX = Math.abs(event.clientX - pricePanState.startClientX); const totalMoveY = Math.abs(event.clientY - pricePanState.startClientY); if (totalMoveX > 3 || totalMoveY > 3) { pricePanState.moved = true; } if (pricePanState.moved) { panPriceChart(event.clientX - pricePanState.lastX); pricePanState.lastX = event.clientX; } }); canvas.addEventListener("pointerup", function(event) { if (pricePanState && pricePanState.pointerId === event.pointerId) { const state = pricePanState; pricePanState = null; if (!state.moved) { if (priceClickTimer) { clearTimeout(priceClickTimer); priceClickTimer = null; } priceClickTimer = setTimeout(function() { zoomPriceChart(state.button === 2 ? 1 : -1, state.startOffsetX); priceClickTimer = null; }, 180); } } }); canvas.addEventListener("pointercancel", function() { pricePanState = null; }); priceChartInteractionsAttached = true; } function ensurePriceToolbar() { const priceCanvas = document.getElementById("priceChart"); if (isPublicView()) { return; } 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 calculatePnlReturnPct(latestPnl, events) { if (!latestPnl) { return 0; } if (isFiniteNumberValue(latestPnl.cumulative_return_pct)) { return Number(latestPnl.cumulative_return_pct); } const latestPnlValue = Number(latestPnl.cumulative_realized_pnl_krw); const totalBuyFunds = calculateTotalBuyFunds(events); return totalBuyFunds > 0 ? ((Number.isNaN(latestPnlValue) ? 0 : latestPnlValue) / totalBuyFunds) * 100 : 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"); if (!tbody) { return; } tbody.innerHTML = ""; const recent = [...signals].reverse().slice(0, 30); for (const row of recent) { const tr = document.createElement("tr"); tr.innerHTML = ` ${row.signal_time_kst || "-"} ${fmtNumber(row.current_price)} ${fmtNumber(row.converted_price)} ${translateCode(row.entry_signal)} ${translateCode(row.signal_grade)} ${fmtPct((row.model_score || 0) * 100, 2)} ${fmtNumber(row.cci, 2)} ${fmtPct(row.kimchi_gap_pct, 3)} `; tbody.appendChild(tr); } } function renderStrategyPnlTable(strategyRows) { const tbody = document.getElementById("strategyPnlTableBody"); if (!tbody) { return; } tbody.innerHTML = ""; if (!strategyRows.length) { const tr = document.createElement("tr"); tr.innerHTML = `-`; tbody.appendChild(tr); return; } for (const row of strategyRows) { const tr = document.createElement("tr"); const strategyLabel = row.strategy_name ? `${row.strategy_code} / ${row.strategy_name}` : row.strategy_code; tr.innerHTML = ` ${strategyLabel || "-"} ${row.market || "-"} ${fmtNumber(row.cumulative_realized_pnl_krw)} KRW ${fmtNumber(row.cumulative_buy_funds_krw)} KRW ${fmtPct(row.cumulative_return_pct, 3)} ${fmtNumber(row.realized_trade_count)} ${row.last_event_time_kst || "-"} `; tr.dataset.strategyCode = row.strategy_code || ""; tr.dataset.market = row.market || ""; tbody.appendChild(tr); } } function updateSummary(signals, events, pnlRows) { const latestSignal = signals.length ? signals[signals.length - 1] : null; const latestPnl = pnlRows.length ? pnlRows[pnlRows.length - 1] : null; const latestPnlValue = latestPnl ? Number(latestPnl.cumulative_realized_pnl_krw) : 0; const pnlReturnPct = calculatePnlReturnPct(latestPnl, events); setStatus("running"); const latestPriceEl = document.getElementById("latestPrice"); const latestSignalEl = document.getElementById("latestSignal"); const latestPnlEl = document.getElementById("latestPnl"); const latestPnlReturnEl = document.getElementById("latestPnlReturn"); if (latestPriceEl) { latestPriceEl.textContent = latestSignal ? `${fmtNumber(latestSignal.current_price)} KRW` : "-"; } if (latestSignalEl) { latestSignalEl.textContent = latestSignal ? `${translateCode(latestSignal.signal_grade)} / ${translateCode(latestSignal.entry_signal)}` : "-"; } const trend = getTrendInfo(latestSignal); const trendCard = document.getElementById("trendCard"); const latestTrend = document.getElementById("latestTrend"); if (latestTrend) { latestTrend.textContent = trend.label; } if (trendCard) { trendCard.classList.remove("trend-up", "trend-down", "trend-flat"); trendCard.classList.add(trend.className); } if (latestPnlEl) { latestPnlEl.textContent = latestPnl ? `${fmtNumber(Number.isNaN(latestPnlValue) ? 0 : latestPnlValue)} KRW` : "0 KRW"; } if (latestPnlReturnEl) { latestPnlReturnEl.textContent = fmtPct(pnlReturnPct, 3); } } async function refreshDashboard() { try { setStatus("loading"); syncSelectedFiltersFromDom(); renderFilterControls(); const [btcPricesResult, signalsResult, eventsResult, pnlResult, strategyPnlResult] = await Promise.allSettled([ fetchJson(buildApiUrl("/api/btc-prices")), fetchJson(buildApiUrl("/api/signals")), fetchJson(buildApiUrl("/api/events")), fetchJson(buildApiUrl("/api/pnl")), fetchJson(buildApiUrl("/api/strategy-pnl")), ]); const btcPrices = btcPricesResult.status === "fulfilled" ? btcPricesResult.value.prices || [] : []; const signals = signalsResult.status === "fulfilled" ? signalsResult.value.signals || [] : []; const events = eventsResult.status === "fulfilled" ? eventsResult.value.events || [] : []; const pnlRows = pnlResult.status === "fulfilled" ? pnlResult.value.pnl || [] : []; const strategyPnlRows = strategyPnlResult.status === "fulfilled" ? strategyPnlResult.value.strategies || [] : []; const fetchFailed = [btcPricesResult, signalsResult, eventsResult, pnlResult, strategyPnlResult] .some((result) => result.status !== "fulfilled"); let renderFailed = false; for (const renderStep of [ () => updateSummary(signals, events, pnlRows), () => renderStrategyPnlTable(strategyPnlRows), () => renderSignalTable(signals), () => renderPriceChart(btcPrices, signals, events, pnlRows), () => renderPnlChart(pnlRows), ]) { try { renderStep(); } catch (error) { renderFailed = true; console.error(error); } } setStatus(fetchFailed || renderFailed ? "error" : "running"); } catch (error) { setStatus("error"); console.error(error); } } async function refreshAuthState() { try { const result = await fetchJson("/api/auth/me"); const user = result.user; const userBadge = document.getElementById("userBadge"); const logoutButton = document.getElementById("logoutButton"); const loginButtons = document.querySelectorAll(".google-login, .apple-login"); isAuthenticated = Boolean(user); updatePrivateVisibility(); if (!user) { return; } if (userBadge) { userBadge.textContent = user.email || user.name || t("loggedIn"); userBadge.hidden = false; } if (logoutButton) { logoutButton.hidden = false; } loginButtons.forEach((button) => { button.hidden = true; }); } catch (error) { isAuthenticated = false; updatePrivateVisibility(); console.error(error); } } ensureFavicon(); applyViewMode(); ensurePriceToolbar(); applyLanguage(); applyViewMode(); document.getElementById("refreshButton")?.addEventListener("click", refreshDashboard); document.getElementById("marketFilter")?.addEventListener("change", onFilterChanged); document.getElementById("strategyFilter")?.addEventListener("change", onFilterChanged); document.getElementById("marketFilter")?.addEventListener("input", onFilterChanged); document.getElementById("strategyFilter")?.addEventListener("input", onFilterChanged); document.getElementById("strategyPnlTableBody")?.addEventListener("click", function(event) { const row = event.target.closest("tr[data-strategy-code][data-market]"); if (!row) { return; } const marketSelect = document.getElementById("marketFilter"); const strategySelect = document.getElementById("strategyFilter"); if (marketSelect && row.dataset.market) { marketSelect.value = row.dataset.market; } selectedMarket = row.dataset.market || selectedMarket; renderFilterControls(); if (strategySelect && row.dataset.strategyCode) { strategySelect.value = row.dataset.strategyCode; } selectedStrategyCode = row.dataset.strategyCode || selectedStrategyCode; localStorage.setItem("dashboard-market", selectedMarket); localStorage.setItem("dashboard-strategy-code", selectedStrategyCode); priceZoomRange = null; updateActiveFilterLabels(); refreshDashboard(); }); document.getElementById("languageToggle")?.addEventListener("click", function() { currentLanguage = currentLanguage === "ko" ? "en" : "ko"; localStorage.setItem("dashboard-language", currentLanguage); applyLanguage(); refreshDashboard(); }); document.getElementById("resetPriceZoomButton")?.addEventListener("click", function() { resetPriceZoom(); }); document.getElementById("pricePanLeftButton")?.addEventListener("click", function() { movePriceWindow(-1); }); document.getElementById("pricePanRightButton")?.addEventListener("click", function() { movePriceWindow(1); }); document.querySelectorAll(".price-range-button").forEach((button) => { button.addEventListener("click", function() { const range = button.dataset.priceRange; showRecentPricePoints(range === "all" ? "all" : Number(range)); }); }); updatePrivateVisibility(); loadFilterOptions() .catch((error) => { console.error(error); filterOptions = { default_market: selectedMarket, default_strategy_code: selectedStrategyCode, markets: selectedMarket ? [selectedMarket] : [], strategies: selectedStrategyCode ? [{ strategy_code: selectedStrategyCode, strategy_name: selectedStrategyCode }] : [], pairs: selectedMarket && selectedStrategyCode ? [{ market: selectedMarket, strategy_code: selectedStrategyCode }] : [], }; }) .finally(() => refreshAuthState().finally(refreshDashboard)); setInterval(refreshDashboard, 30000);